From eced5a13631e6a3e4df6d3ff719f95e6f8b5c86f Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Sat, 16 May 2026 16:11:34 +0200 Subject: [PATCH 01/43] Add OpenClaw bridge package skeleton --- packages/openclaw/LICENSE | 1 + packages/openclaw/README.md | 13 ++ packages/openclaw/package.json | 73 +++++++ packages/openclaw/src/index.ts | 3 + .../openclaw/src/openclaw-event-map.test.ts | 141 +++++++++++++ packages/openclaw/src/openclaw-event-map.ts | 197 ++++++++++++++++++ packages/openclaw/src/stream-map.ts | 174 ++++++++++++++++ packages/openclaw/src/types.ts | 47 +++++ packages/openclaw/tsconfig.json | 8 + packages/openclaw/tsdown.config.ts | 8 + packages/openclaw/vitest.config.ts | 12 ++ pnpm-lock.yaml | 28 +++ pnpm-workspace.yaml | 1 + 13 files changed, 706 insertions(+) create mode 100644 packages/openclaw/LICENSE create mode 100644 packages/openclaw/README.md create mode 100644 packages/openclaw/package.json create mode 100644 packages/openclaw/src/index.ts create mode 100644 packages/openclaw/src/openclaw-event-map.test.ts create mode 100644 packages/openclaw/src/openclaw-event-map.ts create mode 100644 packages/openclaw/src/stream-map.ts create mode 100644 packages/openclaw/src/types.ts create mode 100644 packages/openclaw/tsconfig.json create mode 100644 packages/openclaw/tsdown.config.ts create mode 100644 packages/openclaw/vitest.config.ts 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..52065eb --- /dev/null +++ b/packages/openclaw/README.md @@ -0,0 +1,13 @@ +# @beeper/pickle-openclaw + +`@beeper/pickle-openclaw` is the Pickle package for bridging OpenClaw sessions into Beeper/Matrix. + +The bridge is appservice-first: it creates non-federated Matrix rooms on the homeserver, represents every OpenClaw agent as a bridge-owned ghost contact, and streams OpenClaw runs into Beeper Desktop's native AI message UI. + +Current package surface: + +- OpenClaw session and agent binding types. +- Desktop-compatible stream chunk builders. +- OpenClaw SDK event to Beeper stream mapping for assistant text, thinking, tools, run finalization, and approvals. + +Planned appservice modules will add Beeper account setup/provisioning, bridge registration, room and Space management, terminal/mac app backfill, and live OpenClaw gateway session control. diff --git a/packages/openclaw/package.json b/packages/openclaw/package.json new file mode 100644 index 0000000..a222d27 --- /dev/null +++ b/packages/openclaw/package.json @@ -0,0 +1,73 @@ +{ + "name": "@beeper/pickle-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" + }, + "main": "./dist/index.mjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + }, + "./openclaw-event-map": { + "types": "./dist/openclaw-event-map.d.mts", + "import": "./dist/openclaw-event-map.mjs" + }, + "./stream-map": { + "types": "./dist/stream-map.d.mts", + "import": "./dist/stream-map.mjs" + }, + "./types": { + "types": "./dist/types.d.mts", + "import": "./dist/types.mjs" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsdown", + "clean": "rm -rf dist", + "test": "vitest run --coverage", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@beeper/pickle": "workspace:*", + "@beeper/pickle-bridge": "workspace:*", + "@beeper/pickle-state-file": "workspace:*" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@vitest/coverage-v8": "^4.0.18", + "tsdown": "^0.21.10", + "typescript": "^5.7.2", + "vitest": "^4.0.18" + }, + "keywords": [ + "beeper", + "matrix", + "openclaw", + "appservice", + "bridge" + ], + "engines": { + "node": ">=20" + }, + "license": "MPL-2.0" +} diff --git a/packages/openclaw/src/index.ts b/packages/openclaw/src/index.ts new file mode 100644 index 0000000..569b59b --- /dev/null +++ b/packages/openclaw/src/index.ts @@ -0,0 +1,3 @@ +export * from "./openclaw-event-map"; +export * from "./stream-map"; +export * from "./types"; diff --git a/packages/openclaw/src/openclaw-event-map.test.ts b/packages/openclaw/src/openclaw-event-map.test.ts new file mode 100644 index 0000000..8fe4075 --- /dev/null +++ b/packages/openclaw/src/openclaw-event-map.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import { createOpenClawStreamState, mapOpenClawEventToBeeperChunks } from "./openclaw-event-map"; + +describe("OpenClaw event to Beeper stream mapping", () => { + it("maps run lifecycle and assistant deltas into a single Beeper message", () => { + const state = createOpenClawStreamState("turn_oc"); + + expect(mapOpenClawEventToBeeperChunks(state, { + agentId: "codex", + runId: "run_1", + sessionKey: "agent:codex:main", + type: "run.started", + })).toEqual([ + { + messageId: "turn_oc", + messageMetadata: { + agent_id: "codex", + run_id: "run_1", + session_key: "agent:codex:main", + turn_id: "turn_oc", + }, + type: "start", + }, + ]); + + expect(mapOpenClawEventToBeeperChunks(state, { data: { delta: "Hello" }, type: "assistant.delta" })).toEqual([ + { id: "text_turn_oc", type: "text-start" }, + { delta: "Hello", id: "text_turn_oc", type: "text-delta" }, + ]); + expect(mapOpenClawEventToBeeperChunks(state, { data: { delta: " thinking" }, type: "thinking.delta" })).toEqual([ + { id: "reasoning_turn_oc", type: "reasoning-start" }, + { delta: " thinking", id: "reasoning_turn_oc", type: "reasoning-delta" }, + ]); + expect(mapOpenClawEventToBeeperChunks(state, { runId: "run_1", type: "run.completed" })).toEqual([ + { id: "reasoning_turn_oc", type: "reasoning-end" }, + { id: "text_turn_oc", type: "text-end" }, + { + finishReason: "stop", + messageMetadata: { finish_reason: "stop", run_id: "run_1", turn_id: "turn_oc" }, + type: "finish", + }, + ]); + }); + + it("maps tool lifecycle events to Desktop-compatible tool chunks", () => { + const state = createOpenClawStreamState("turn_tools"); + + expect(mapOpenClawEventToBeeperChunks(state, { + data: { arguments: "{\"cmd\":\"pnpm test\"}", id: "call_1", name: "shell" }, + type: "tool.call.started", + })).toEqual([ + { + dynamic: true, + input: { cmd: "pnpm test" }, + toolCallId: "call_1", + toolName: "shell", + type: "tool-input-available", + }, + ]); + + expect(mapOpenClawEventToBeeperChunks(state, { + data: { delta: "{\"cmd\"", toolCallId: "call_2", toolName: "edit" }, + type: "tool.call.delta", + })).toEqual([ + { + dynamic: true, + inputTextDelta: "{\"cmd\"", + toolCallId: "call_2", + toolName: "edit", + type: "tool-input-delta", + }, + ]); + + expect(mapOpenClawEventToBeeperChunks(state, { + data: { output: "ok", preliminary: true, toolCallId: "call_1", toolName: "shell" }, + type: "tool.call.completed", + })).toEqual([ + { + dynamic: true, + output: "ok", + preliminary: true, + toolCallId: "call_1", + toolName: "shell", + type: "tool-output-available", + }, + ]); + + expect(mapOpenClawEventToBeeperChunks(state, { + data: { error: { message: "denied" }, toolCallId: "call_3", toolName: "write" }, + type: "tool.call.failed", + })).toEqual([ + { + dynamic: true, + errorText: "{\"message\":\"denied\"}", + toolCallId: "call_3", + toolName: "write", + type: "tool-output-error", + }, + ]); + }); + + it("maps OpenClaw approval events to Beeper approval chunks", () => { + const state = createOpenClawStreamState("turn_approvals"); + + expect(mapOpenClawEventToBeeperChunks(state, { + data: { + approvalId: "approval_1", + message: "Allow shell?", + toolCallId: "call_1", + toolName: "shell", + }, + type: "approval.requested", + })).toEqual([ + { + approvalId: "approval_1", + message: "Allow shell?", + toolCallId: "call_1", + toolName: "shell", + type: "tool-approval-request", + }, + ]); + expect(state.toolCallIdToApprovalId.call_1).toBe("approval_1"); + + expect(mapOpenClawEventToBeeperChunks(state, { + data: { + approvalId: "approval_1", + decision: "approve", + toolCallId: "call_1", + }, + type: "approval.resolved", + })).toEqual([ + { + approvalId: "approval_1", + approved: true, + approvedAlways: false, + toolCallId: "call_1", + type: "tool-approval-response", + }, + ]); + }); +}); diff --git a/packages/openclaw/src/openclaw-event-map.ts b/packages/openclaw/src/openclaw-event-map.ts new file mode 100644 index 0000000..d33bc07 --- /dev/null +++ b/packages/openclaw/src/openclaw-event-map.ts @@ -0,0 +1,197 @@ +import { + closeOpenMessageParts, + createStreamRunState, + finishChunk, + mapOpenClawApprovalRequest, + mapOpenClawApprovalResponse, + mapOpenClawMessageDelta, + mapOpenClawToolInput, + mapOpenClawToolOutput, + startChunk, + type BeeperUIMessageChunk, + type StreamRunState, +} from "./stream-map"; + +type ToolInputChunkInput = Parameters[0]; +type ToolOutputChunkInput = Parameters[0]; +type ApprovalRequestChunkInput = Parameters[1]; +type ApprovalResponseChunkInput = Parameters[0]; + +export function createOpenClawStreamState(turnId: string): StreamRunState { + return createStreamRunState(turnId); +} + +export function mapOpenClawEventToBeeperChunks( + state: StreamRunState, + event: unknown +): BeeperUIMessageChunk[] { + const record = recordValue(event); + const type = stringValue(record?.type) ?? stringValue(record?.event); + if (!record || !type) return []; + const data = recordValue(record.data) ?? recordValue(record.payload) ?? record; + const metadata = streamMetadata(record); + + switch (type) { + case "run.created": + case "run.queued": + case "run.started": + return [startChunk(state, metadata)]; + case "assistant.delta": { + const delta = stringValue(data.delta) ?? stringValue(data.text) ?? stringValue(data.content); + return delta ? mapOpenClawMessageDelta(state, { kind: "text", value: delta }) : []; + } + case "assistant.message": { + const text = stringValue(data.text) ?? stringValue(data.content) ?? stringValue(data.message); + return text ? mapOpenClawMessageDelta(state, { kind: "text", value: text }) : []; + } + case "thinking.delta": { + const delta = stringValue(data.delta) ?? stringValue(data.text) ?? stringValue(data.content); + return delta ? mapOpenClawMessageDelta(state, { kind: "thinking", value: delta }) : []; + } + case "tool.call.started": + return [mapOpenClawToolInput(toolInput(data))]; + case "tool.call.delta": { + const inputTextDelta = stringValue(data.delta) ?? stringValue(data.inputTextDelta); + const input = inputTextDelta ? undefined : data.input ?? data.args ?? parseMaybeJSONValue(data.arguments); + return [stripUndefined({ + dynamic: true, + input, + inputTextDelta, + toolCallId: toolCallId(data), + toolName: toolName(data), + type: inputTextDelta ? "tool-input-delta" : "tool-input-available", + })]; + } + case "tool.call.completed": + return [mapOpenClawToolOutput(toolOutput(data))]; + case "tool.call.failed": + return [mapOpenClawToolOutput({ ...toolOutput(data), error: data.error ?? data.message ?? data.output })]; + case "approval.requested": + return [mapOpenClawApprovalRequest(state, approvalRequest(data))]; + case "approval.resolved": + return [mapOpenClawApprovalResponse(approvalResponse(data))]; + case "run.completed": + return [...closeOpenMessageParts(state), finishChunk(state, "stop", metadata)]; + case "run.failed": + return [...closeOpenMessageParts(state), { errorText: errorText(data.error ?? data.message ?? data), type: "error" }, finishChunk(state, "error", metadata)]; + case "run.cancelled": + return [...closeOpenMessageParts(state), { reason: stringValue(data.reason), type: "abort" }, finishChunk(state, "cancelled", metadata)]; + case "run.timed_out": + return [...closeOpenMessageParts(state), { errorText: "OpenClaw run timed out.", type: "error" }, finishChunk(state, "timeout", metadata)]; + default: + return []; + } +} + +function streamMetadata(event: Record): Record { + return stripUndefined({ + agent_id: stringValue(event.agentId), + run_id: stringValue(event.runId), + session_id: stringValue(event.sessionId), + session_key: stringValue(event.sessionKey), + task_id: stringValue(event.taskId), + }); +} + +function toolInput(data: Record): ToolInputChunkInput { + const input: ToolInputChunkInput = { toolCallId: toolCallId(data) }; + const toolInputValue = data.input ?? data.args ?? parseMaybeJSONValue(data.arguments); + const providerExecuted = booleanValue(data.providerExecuted); + const startedAtMs = numberValue(data.startedAtMs); + const title = stringValue(data.title); + const name = toolName(data); + if (toolInputValue !== undefined) input.input = toolInputValue; + if (providerExecuted !== undefined) input.providerExecuted = providerExecuted; + if (startedAtMs !== undefined) input.startedAtMs = startedAtMs; + if (title !== undefined) input.title = title; + if (name !== undefined) input.toolName = name; + return input; +} + +function toolOutput(data: Record): ToolOutputChunkInput { + const output: ToolOutputChunkInput = { toolCallId: toolCallId(data) }; + const completedAtMs = numberValue(data.completedAtMs); + const outputValue = data.output ?? data.result ?? data.content; + const preliminary = booleanValue(data.preliminary); + const providerExecuted = booleanValue(data.providerExecuted); + const name = toolName(data); + if (completedAtMs !== undefined) output.completedAtMs = completedAtMs; + if (outputValue !== undefined) output.output = outputValue; + if (preliminary !== undefined) output.preliminary = preliminary; + if (providerExecuted !== undefined) output.providerExecuted = providerExecuted; + if (name !== undefined) output.toolName = name; + return output; +} + +function approvalRequest(data: Record): ApprovalRequestChunkInput { + const request: ApprovalRequestChunkInput = {}; + const approvalId = stringValue(data.approvalId) ?? stringValue(data.id); + const message = stringValue(data.message) ?? stringValue(data.reason); + const callId = stringValue(data.toolCallId) ?? stringValue(data.callId); + const name = toolName(data); + if (approvalId !== undefined) request.approvalId = approvalId; + if (message !== undefined) request.message = message; + if (callId !== undefined) request.toolCallId = callId; + if (name !== undefined) request.toolName = name; + return request; +} + +function approvalResponse(data: Record): ApprovalResponseChunkInput { + const approvalId = stringValue(data.approvalId) ?? stringValue(data.id); + if (!approvalId) throw new Error("OpenClaw approval.resolved event is missing approvalId"); + const response: ApprovalResponseChunkInput = { + approvalId, + approved: data.approved === true || data.decision === "approve" || data.decision === "allow", + approvedAlways: data.approvedAlways === true || data.decision === "approve_always" || data.decision === "allow_always", + }; + const callId = stringValue(data.toolCallId) ?? stringValue(data.callId); + if (callId !== undefined) response.toolCallId = callId; + return response; +} + +function toolCallId(data: Record): string { + return stringValue(data.toolCallId) ?? stringValue(data.callId) ?? stringValue(data.id) ?? "tool_call"; +} + +function toolName(data: Record): string | undefined { + return stringValue(data.toolName) ?? stringValue(data.name); +} + +function parseMaybeJSONValue(value: unknown): unknown { + if (typeof value !== "string") return value; + try { + return JSON.parse(value); + } catch { + return value; + } +} + +function errorText(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + return JSON.stringify(error) ?? String(error); +} + +function stripUndefined>(input: T): T { + for (const key of Object.keys(input)) { + if (input[key] === undefined) delete input[key]; + } + return input; +} + +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 : undefined; +} + +function numberValue(value: unknown): number | undefined { + return typeof value === "number" ? value : undefined; +} + +function booleanValue(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} diff --git a/packages/openclaw/src/stream-map.ts b/packages/openclaw/src/stream-map.ts new file mode 100644 index 0000000..ea89b09 --- /dev/null +++ b/packages/openclaw/src/stream-map.ts @@ -0,0 +1,174 @@ +export type BeeperUIMessageChunk = Record & { type: string }; + +export interface StreamRunState { + reasoningPartId?: string; + textPartId?: string; + toolCallIdToApprovalId: Record; + turnId: string; +} + +export function createStreamRunState(turnId: string): StreamRunState { + return { toolCallIdToApprovalId: {}, turnId }; +} + +export function createTurnId(): string { + return `turn_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; +} + +export function startChunk(state: StreamRunState, metadata: Record = {}): BeeperUIMessageChunk { + return { + messageId: state.turnId, + messageMetadata: { turn_id: state.turnId, ...metadata }, + type: "start", + }; +} + +export function finishChunk( + state: StreamRunState, + finishReason = "stop", + metadata: Record = {} +): BeeperUIMessageChunk { + return { + finishReason, + messageMetadata: { finish_reason: finishReason, turn_id: state.turnId, ...metadata }, + type: "finish", + }; +} + +export function mapOpenClawMessageDelta( + state: StreamRunState, + delta: { kind: "text" | "thinking"; value: string } +): BeeperUIMessageChunk[] { + if (delta.kind === "text") { + return [...openTextPart(state), { delta: delta.value, id: state.textPartId!, type: "text-delta" }]; + } + return [...openReasoningPart(state), { delta: delta.value, id: state.reasoningPartId!, type: "reasoning-delta" }]; +} + +export function closeOpenMessageParts(state: StreamRunState): BeeperUIMessageChunk[] { + return [...closeReasoningPart(state), ...closeTextPart(state)]; +} + +export function openTextPart(state: StreamRunState): BeeperUIMessageChunk[] { + if (state.textPartId) return []; + state.textPartId = `text_${state.turnId}`; + return [{ id: state.textPartId, type: "text-start" }]; +} + +export function closeTextPart(state: StreamRunState): BeeperUIMessageChunk[] { + if (!state.textPartId) return []; + const id = state.textPartId; + delete state.textPartId; + return [{ id, type: "text-end" }]; +} + +export function openReasoningPart(state: StreamRunState): BeeperUIMessageChunk[] { + if (state.reasoningPartId) return []; + state.reasoningPartId = `reasoning_${state.turnId}`; + return [{ id: state.reasoningPartId, type: "reasoning-start" }]; +} + +export function closeReasoningPart(state: StreamRunState): BeeperUIMessageChunk[] { + if (!state.reasoningPartId) return []; + const id = state.reasoningPartId; + delete state.reasoningPartId; + return [{ id, type: "reasoning-end" }]; +} + +export function mapOpenClawToolInput(event: { + dynamic?: boolean; + input?: unknown; + providerExecuted?: boolean; + startedAtMs?: number; + title?: string; + toolCallId: string; + toolName?: string; +}): BeeperUIMessageChunk { + return stripUndefined({ + dynamic: event.dynamic ?? true, + input: event.input, + providerExecuted: event.providerExecuted, + startedAtMs: event.startedAtMs, + title: event.title, + toolCallId: event.toolCallId, + toolName: event.toolName, + type: "tool-input-available", + }); +} + +export function mapOpenClawToolOutput(event: { + completedAtMs?: number; + error?: unknown; + output?: unknown; + preliminary?: boolean; + providerExecuted?: boolean; + toolCallId: string; + toolName?: string; +}): BeeperUIMessageChunk { + if (event.error !== undefined) { + return stripUndefined({ + dynamic: true, + errorText: errorText(event.error), + preliminary: event.preliminary, + providerExecuted: event.providerExecuted, + completedAtMs: event.completedAtMs, + toolCallId: event.toolCallId, + toolName: event.toolName, + type: "tool-output-error", + }); + } + return stripUndefined({ + dynamic: true, + output: event.output, + preliminary: event.preliminary, + providerExecuted: event.providerExecuted, + completedAtMs: event.completedAtMs, + toolCallId: event.toolCallId, + toolName: event.toolName, + type: "tool-output-available", + }); +} + +export function mapOpenClawApprovalRequest( + state: StreamRunState, + event: { approvalId?: string; message?: string; toolCallId?: string; toolName?: string } +): BeeperUIMessageChunk { + const toolCallId = event.toolCallId ?? event.approvalId ?? "approval"; + const approvalId = event.approvalId ?? `approval_${toolCallId}`; + state.toolCallIdToApprovalId[toolCallId] = approvalId; + return stripUndefined({ + approvalId, + message: event.message, + toolCallId, + toolName: event.toolName, + type: "tool-approval-request", + }); +} + +export function mapOpenClawApprovalResponse(event: { + approvalId: string; + approved: boolean; + approvedAlways?: boolean; + toolCallId?: string; +}): BeeperUIMessageChunk { + return stripUndefined({ + approvalId: event.approvalId, + approved: event.approved, + approvedAlways: event.approvedAlways, + toolCallId: event.toolCallId, + type: "tool-approval-response", + }); +} + +function stripUndefined>(input: T): T { + for (const key of Object.keys(input)) { + if (input[key] === undefined) delete input[key]; + } + return input; +} + +function errorText(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + return JSON.stringify(error) ?? String(error); +} diff --git a/packages/openclaw/src/types.ts b/packages/openclaw/src/types.ts new file mode 100644 index 0000000..66dcba2 --- /dev/null +++ b/packages/openclaw/src/types.ts @@ -0,0 +1,47 @@ +export type OpenClawBindingOwner = "bridge" | "terminal" | "mac-app" | "imported"; +export type OpenClawBindingKind = "session" | "agent"; + +export interface OpenClawAgentContact { + agentId: string; + displayName: string; + ghostUserId: string; + avatarMxc?: string; + description?: string; +} + +export interface OpenClawSessionBinding { + id: string; + kind: OpenClawBindingKind; + owner: OpenClawBindingOwner; + roomId: string; + spaceId?: string; + sessionKey: string; + agentId: string; + ghostUserId: string; + cwd?: string; + label?: string; + createdAt: number; + updatedAt: number; + lastRunId?: string; + lastMatrixEventId?: string; + lastStreamTargetEventId?: string; +} + +export interface OpenClawBridgeConfig { + accessToken?: string; + allowedRoomIds?: string[]; + allowedUserIds?: string[]; + appserviceId: string; + dataDir: string; + gatewayUrl?: string; + homeserver?: string; + serviceBotLocalpart: string; + storePath: string; +} + +export interface OpenClawBridgeRegistryData { + agents: OpenClawAgentContact[]; + bindings: OpenClawSessionBinding[]; + dedupe: Record; + schemaVersion: 1; +} 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..d569054 --- /dev/null +++ b/packages/openclaw/tsdown.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + clean: true, + dts: true, + entry: ["src/index.ts", "src/openclaw-event-map.ts", "src/stream-map.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..bdbea6f --- /dev/null +++ b/packages/openclaw/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineProject } from "vitest/config"; + +export default defineProject({ + test: { + coverage: { + include: ["src/**/*.ts"], + provider: "v8", + reporter: ["text", "json-summary"], + }, + environment: "node", + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e946a91..4646c3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -210,6 +210,34 @@ importers: 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)) + packages/openclaw: + dependencies: + '@beeper/pickle': + specifier: workspace:* + version: link:../pickle + '@beeper/pickle-bridge': + specifier: workspace:* + version: link:../bridge + '@beeper/pickle-state-file': + specifier: workspace:* + version: link:../state-file + devDependencies: + '@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) + 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)) + packages/pi: dependencies: '@beeper/pickle': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a645762..6abf047 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" From a388a23857e3cae5760d6dd8f28443989ec233ab Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Sat, 16 May 2026 16:13:56 +0200 Subject: [PATCH 02/43] Add OpenClaw bridge registration config --- packages/openclaw/package.json | 8 +++ packages/openclaw/src/config.test.ts | 36 +++++++++++ packages/openclaw/src/config.ts | 75 ++++++++++++++++++++++ packages/openclaw/src/index.ts | 2 + packages/openclaw/src/registration.test.ts | 50 +++++++++++++++ packages/openclaw/src/registration.ts | 63 ++++++++++++++++++ packages/openclaw/src/types.ts | 21 ++++++ packages/openclaw/tsdown.config.ts | 2 +- 8 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 packages/openclaw/src/config.test.ts create mode 100644 packages/openclaw/src/config.ts create mode 100644 packages/openclaw/src/registration.test.ts create mode 100644 packages/openclaw/src/registration.ts diff --git a/packages/openclaw/package.json b/packages/openclaw/package.json index a222d27..09dc482 100644 --- a/packages/openclaw/package.json +++ b/packages/openclaw/package.json @@ -20,10 +20,18 @@ "types": "./dist/index.d.mts", "import": "./dist/index.mjs" }, + "./config": { + "types": "./dist/config.d.mts", + "import": "./dist/config.mjs" + }, "./openclaw-event-map": { "types": "./dist/openclaw-event-map.d.mts", "import": "./dist/openclaw-event-map.mjs" }, + "./registration": { + "types": "./dist/registration.d.mts", + "import": "./dist/registration.mjs" + }, "./stream-map": { "types": "./dist/stream-map.d.mts", "import": "./dist/stream-map.mjs" diff --git a/packages/openclaw/src/config.test.ts b/packages/openclaw/src/config.test.ts new file mode 100644 index 0000000..4b3c334 --- /dev/null +++ b/packages/openclaw/src/config.test.ts @@ -0,0 +1,36 @@ +import { readFile, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { mkdtemp } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; +import { createDefaultConfig, readConfig, writeConfig } from "./config"; + +describe("OpenClaw bridge config", () => { + it("defaults to appservice-owned non-federated bridge settings", () => { + const config = createDefaultConfig({ dataDir: "/tmp/openclaw-bridge" }); + expect(config).toMatchObject({ + appserviceId: "pickle-openclaw", + dataDir: "/tmp/openclaw-bridge", + ghostLocalpartPrefix: "openclaw_agent_", + nonFederatedRooms: true, + registrationUrl: "http://127.0.0.1:29391", + senderLocalpart: "openclawbot", + serviceBotLocalpart: "openclawbot", + storePath: "/tmp/openclaw-bridge/matrix-store", + userLocalpartPrefix: "openclaw_user_", + }); + }); + + 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({ accessToken: "secret", dataDir: dir, homeserver: "https://matrix.example" }); + await writeConfig(config, path); + expect(JSON.parse(await readFile(path, "utf8"))).toMatchObject({ + accessToken: "secret", + homeserver: "https://matrix.example", + }); + expect((await stat(path)).mode & 0o777).toBe(0o600); + await expect(readConfig(path)).resolves.toMatchObject(config); + }); +}); diff --git a/packages/openclaw/src/config.ts b/packages/openclaw/src/config.ts new file mode 100644 index 0000000..d1381e6 --- /dev/null +++ b/packages/openclaw/src/config.ts @@ -0,0 +1,75 @@ +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 type { OpenClawBridgeConfig } from "./types"; + +export const DEFAULT_APPSERVICE_ID = "pickle-openclaw"; +export const DEFAULT_GHOST_LOCALPART_PREFIX = "openclaw_agent_"; +export const DEFAULT_REGISTRATION_URL = "http://127.0.0.1:29391"; +export const DEFAULT_SENDER_LOCALPART = "openclawbot"; +export const DEFAULT_SERVICE_BOT_LOCALPART = "openclawbot"; +export const DEFAULT_USER_LOCALPART_PREFIX = "openclaw_user_"; + +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 config: OpenClawBridgeConfig = { + appserviceId: overrides.appserviceId ?? process.env.PICKLE_OPENCLAW_APPSERVICE_ID ?? DEFAULT_APPSERVICE_ID, + dataDir, + ghostLocalpartPrefix: + overrides.ghostLocalpartPrefix ?? + process.env.PICKLE_OPENCLAW_GHOST_LOCALPART_PREFIX ?? + DEFAULT_GHOST_LOCALPART_PREFIX, + nonFederatedRooms: overrides.nonFederatedRooms ?? envBoolean(process.env.PICKLE_OPENCLAW_NON_FEDERATED_ROOMS) ?? true, + registrationUrl: + overrides.registrationUrl ?? process.env.PICKLE_OPENCLAW_REGISTRATION_URL ?? DEFAULT_REGISTRATION_URL, + senderLocalpart: overrides.senderLocalpart ?? process.env.PICKLE_OPENCLAW_SENDER_LOCALPART ?? DEFAULT_SENDER_LOCALPART, + serviceBotLocalpart: + overrides.serviceBotLocalpart ?? + process.env.PICKLE_OPENCLAW_SERVICE_BOT_LOCALPART ?? + DEFAULT_SERVICE_BOT_LOCALPART, + storePath: overrides.storePath ?? process.env.PICKLE_OPENCLAW_STORE_PATH ?? resolve(dataDir, "matrix-store"), + userLocalpartPrefix: + overrides.userLocalpartPrefix ?? process.env.PICKLE_OPENCLAW_USER_LOCALPART_PREFIX ?? DEFAULT_USER_LOCALPART_PREFIX, + }; + const accessToken = overrides.accessToken ?? process.env.PICKLE_OPENCLAW_ACCESS_TOKEN; + const gatewayUrl = overrides.gatewayUrl ?? process.env.PICKLE_OPENCLAW_GATEWAY_URL; + const homeserver = overrides.homeserver ?? process.env.PICKLE_OPENCLAW_HOMESERVER; + const hsToken = overrides.hsToken ?? process.env.PICKLE_OPENCLAW_HS_TOKEN; + if (accessToken) config.accessToken = accessToken; + if (gatewayUrl) config.gatewayUrl = gatewayUrl; + if (homeserver) config.homeserver = homeserver; + if (hsToken) config.hsToken = hsToken; + if (overrides.allowedRoomIds) config.allowedRoomIds = overrides.allowedRoomIds; + if (overrides.allowedUserIds) config.allowedUserIds = overrides.allowedUserIds; + return config; +} + +export async function readConfig(path = defaultConfigPath()): Promise { + return createDefaultConfig(JSON.parse(await readFile(path, "utf8")) as Partial); +} + +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 envBoolean(value: string | undefined): boolean | undefined { + if (value === undefined) return undefined; + if (["1", "true", "yes", "on"].includes(value.toLowerCase())) return true; + if (["0", "false", "no", "off"].includes(value.toLowerCase())) return false; + return undefined; +} diff --git a/packages/openclaw/src/index.ts b/packages/openclaw/src/index.ts index 569b59b..3bbad8d 100644 --- a/packages/openclaw/src/index.ts +++ b/packages/openclaw/src/index.ts @@ -1,3 +1,5 @@ +export * from "./config"; export * from "./openclaw-event-map"; +export * from "./registration"; export * from "./stream-map"; export * from "./types"; diff --git a/packages/openclaw/src/registration.test.ts b/packages/openclaw/src/registration.test.ts new file mode 100644 index 0000000..1768657 --- /dev/null +++ b/packages/openclaw/src/registration.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { createDefaultConfig } from "./config"; +import { + createAppserviceRegistration, + openClawAgentGhostLocalpart, + openClawAliasLocalpart, + openClawRoomCreationPreset, + openClawUserGhostLocalpart, +} from "./registration"; + +describe("OpenClaw appservice registration", () => { + it("reserves bridge bot, OpenClaw agent, and human ghost namespaces", () => { + const config = createDefaultConfig({ + appserviceId: "pickle-openclaw", + dataDir: "/tmp/openclaw", + ghostLocalpartPrefix: "oc_agent_", + senderLocalpart: "ocbot", + userLocalpartPrefix: "oc_user_", + }); + const registration = createAppserviceRegistration(config, { asToken: "as", hsToken: "hs" }); + expect(registration).toMatchObject({ + as_token: "as", + hs_token: "hs", + id: "pickle-openclaw", + rate_limited: false, + receive_ephemeral: true, + sender_localpart: "ocbot", + url: "http://127.0.0.1:29391", + }); + expect(registration.namespaces.users).toEqual([ + { exclusive: true, regex: "^@ocbot:.*$" }, + { exclusive: true, regex: "^@oc_agent_.+:.*$" }, + { exclusive: true, regex: "^@oc_user_.+:.*$" }, + ]); + expect(registration.namespaces.aliases).toEqual([ + { exclusive: true, regex: "^#pickle-openclaw_.+:.*$" }, + ]); + }); + + it("derives Matrix-safe localparts and non-federated room presets", () => { + const config = createDefaultConfig({ dataDir: "/tmp/openclaw" }); + expect(openClawAgentGhostLocalpart(config, "Codex/Main Agent")).toBe("openclaw_agent_codex/main_agent"); + expect(openClawUserGhostLocalpart(config, "@alice:beeper.local")).toBe("openclaw_user_alice_beeper.local"); + expect(openClawAliasLocalpart(config, "session 1")).toBe("pickle-openclaw_session_1"); + expect(openClawRoomCreationPreset(config)).toEqual({ + creation_content: { "m.federate": false }, + preset: "private_chat", + }); + }); +}); diff --git a/packages/openclaw/src/registration.ts b/packages/openclaw/src/registration.ts new file mode 100644 index 0000000..70c04f1 --- /dev/null +++ b/packages/openclaw/src/registration.ts @@ -0,0 +1,63 @@ +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 ghostPrefix = escapeRegex(config.ghostLocalpartPrefix); + const userPrefix = escapeRegex(config.userLocalpartPrefix); + const sender = escapeRegex(config.senderLocalpart); + return { + as_token: options.asToken ?? config.accessToken ?? secretToken(), + hs_token: options.hsToken ?? config.hsToken ?? secretToken(), + id: config.appserviceId, + namespaces: { + aliases: [{ exclusive: true, regex: `^#${escapeRegex(config.appserviceId)}_.+:.*$` }], + rooms: [], + users: [ + { exclusive: true, regex: `^@${sender}:.*$` }, + { exclusive: true, regex: `^@${ghostPrefix}.+:.*$` }, + { exclusive: true, regex: `^@${userPrefix}.+:.*$` }, + ], + }, + receive_ephemeral: true, + rate_limited: false, + sender_localpart: config.senderLocalpart, + url: config.registrationUrl, + }; +} + +export function openClawAgentGhostLocalpart(config: OpenClawBridgeConfig, agentId: string): string { + return `${config.ghostLocalpartPrefix}${encodeLocalpartSegment(agentId)}`; +} + +export function openClawUserGhostLocalpart(config: OpenClawBridgeConfig, userId: string): string { + return `${config.userLocalpartPrefix}${encodeLocalpartSegment(userId)}`; +} + +export function openClawAliasLocalpart(config: OpenClawBridgeConfig, roomKey: string): string { + return `${config.appserviceId}_${encodeLocalpartSegment(roomKey)}`; +} + +export function openClawRoomCreationPreset(config: OpenClawBridgeConfig): Record { + return { + creation_content: { + "m.federate": !config.nonFederatedRooms, + }, + preset: "private_chat", + }; +} + +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/types.ts b/packages/openclaw/src/types.ts index 66dcba2..3465fff 100644 --- a/packages/openclaw/src/types.ts +++ b/packages/openclaw/src/types.ts @@ -33,10 +33,16 @@ export interface OpenClawBridgeConfig { allowedUserIds?: string[]; appserviceId: string; dataDir: string; + ghostLocalpartPrefix: string; gatewayUrl?: string; homeserver?: string; + hsToken?: string; + nonFederatedRooms: boolean; + registrationUrl: string; + senderLocalpart: string; serviceBotLocalpart: string; storePath: string; + userLocalpartPrefix: string; } export interface OpenClawBridgeRegistryData { @@ -45,3 +51,18 @@ export interface OpenClawBridgeRegistryData { 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/tsdown.config.ts b/packages/openclaw/tsdown.config.ts index d569054..864cf53 100644 --- a/packages/openclaw/tsdown.config.ts +++ b/packages/openclaw/tsdown.config.ts @@ -3,6 +3,6 @@ import { defineConfig } from "tsdown"; export default defineConfig({ clean: true, dts: true, - entry: ["src/index.ts", "src/openclaw-event-map.ts", "src/stream-map.ts", "src/types.ts"], + entry: ["src/config.ts", "src/index.ts", "src/openclaw-event-map.ts", "src/registration.ts", "src/stream-map.ts", "src/types.ts"], format: ["esm"], }); From 5905c34376df5d701f89a175062e832a4f5a789e Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Sat, 16 May 2026 16:15:50 +0200 Subject: [PATCH 03/43] Add OpenClaw agent room registry --- packages/openclaw/package.json | 8 ++ packages/openclaw/src/index.ts | 2 + packages/openclaw/src/registry.test.ts | 40 +++++++++ packages/openclaw/src/registry.ts | 108 +++++++++++++++++++++++++ packages/openclaw/src/rooms.test.ts | 85 +++++++++++++++++++ packages/openclaw/src/rooms.ts | 93 +++++++++++++++++++++ packages/openclaw/tsdown.config.ts | 2 +- 7 files changed, 337 insertions(+), 1 deletion(-) create mode 100644 packages/openclaw/src/registry.test.ts create mode 100644 packages/openclaw/src/registry.ts create mode 100644 packages/openclaw/src/rooms.test.ts create mode 100644 packages/openclaw/src/rooms.ts diff --git a/packages/openclaw/package.json b/packages/openclaw/package.json index 09dc482..c25fa70 100644 --- a/packages/openclaw/package.json +++ b/packages/openclaw/package.json @@ -28,10 +28,18 @@ "types": "./dist/openclaw-event-map.d.mts", "import": "./dist/openclaw-event-map.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" + }, "./stream-map": { "types": "./dist/stream-map.d.mts", "import": "./dist/stream-map.mjs" diff --git a/packages/openclaw/src/index.ts b/packages/openclaw/src/index.ts index 3bbad8d..f35fae7 100644 --- a/packages/openclaw/src/index.ts +++ b/packages/openclaw/src/index.ts @@ -1,5 +1,7 @@ export * from "./config"; export * from "./openclaw-event-map"; +export * from "./registry"; export * from "./registration"; +export * from "./rooms"; export * from "./stream-map"; export * from "./types"; diff --git a/packages/openclaw/src/registry.test.ts b/packages/openclaw/src/registry.test.ts new file mode 100644 index 0000000..429bced --- /dev/null +++ b/packages/openclaw/src/registry.test.ts @@ -0,0 +1,40 @@ +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { OpenClawBridgeRegistry } from "./registry"; + +describe("OpenClawBridgeRegistry", () => { + it("persists agent contacts, session bindings, and dedupe keys", async () => { + const dir = await mkdtemp(resolve(tmpdir(), "pickle-openclaw-")); + const path = resolve(dir, "registry.json"); + const registry = new OpenClawBridgeRegistry(path); + await registry.load(); + registry.upsertAgent({ + agentId: "codex", + displayName: "Codex", + ghostUserId: "@openclaw_agent_codex:example.com", + }); + registry.upsertBinding({ + agentId: "codex", + createdAt: 1, + ghostUserId: "@openclaw_agent_codex:example.com", + id: "binding", + kind: "session", + owner: "bridge", + 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..414412b --- /dev/null +++ b/packages/openclaw/src/registry.ts @@ -0,0 +1,108 @@ +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}.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(binding); + else this.#data.bindings[index] = 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; + } + + 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 : [], + dedupe: data.dedupe && typeof data.dedupe === "object" ? data.dedupe : {}, + schemaVersion: 1, + }; +} diff --git a/packages/openclaw/src/rooms.test.ts b/packages/openclaw/src/rooms.test.ts new file mode 100644 index 0000000..ce6436e --- /dev/null +++ b/packages/openclaw/src/rooms.test.ts @@ -0,0 +1,85 @@ +import type { MatrixClient } from "@beeper/pickle"; +import { describe, expect, it, vi } from "vitest"; +import { createDefaultConfig } from "./config"; +import { + agentContactFromOpenClawAgent, + agentGhostUserId, + bindingIdForRoom, + createSessionRoom, + 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("@openclaw_agent_codex_main:matrix.example.com"); + expect(serviceBotUserId(config)).toBe("@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", + description: "Local code agent", + displayName: "Codex", + ghostUserId: "@openclaw_agent_codex:matrix.example.com", + }); + }); + + it("creates non-federated appservice rooms for OpenClaw sessions", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-16T12:00:00.000Z")); + const createRoom = vi.fn(async () => ({ raw: {}, roomId: "!session:example.com" })); + const client = { appservice: { createRoom } } as unknown as MatrixClient; + const config = createDefaultConfig({ + allowedUserIds: ["@owner:example.com"], + dataDir: "/tmp/openclaw", + homeserver: "https://example.com", + }); + + try { + const binding = await createSessionRoom(client, config, { + agent: { + agentId: "codex", + displayName: "Codex", + ghostUserId: "@openclaw_agent_codex:example.com", + }, + cwd: "/repo", + label: "Fix tests", + sessionKey: "agent:codex:main", + spaceId: "!space:example.com", + }); + + expect(createRoom).toHaveBeenCalledWith({ + creation_content: { "m.federate": false }, + invite: ["@owner:example.com"], + isDirect: true, + name: "Fix tests", + preset: "private_chat", + topic: "OpenClaw agent: codex\nsession: agent:codex:main\ncwd: /repo", + userId: "@openclawbot:example.com", + visibility: "private", + }); + expect(binding).toEqual({ + agentId: "codex", + createdAt: Date.parse("2026-05-16T12:00:00.000Z"), + cwd: "/repo", + ghostUserId: "@openclaw_agent_codex:example.com", + id: bindingIdForRoom("!session:example.com"), + kind: "session", + label: "Fix tests", + owner: "bridge", + roomId: "!session:example.com", + sessionKey: "agent:codex:main", + spaceId: "!space:example.com", + updatedAt: Date.parse("2026-05-16T12:00:00.000Z"), + }); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/openclaw/src/rooms.ts b/packages/openclaw/src/rooms.ts new file mode 100644 index 0000000..ac7380e --- /dev/null +++ b/packages/openclaw/src/rooms.ts @@ -0,0 +1,93 @@ +import type { MatrixClient } from "@beeper/pickle"; +import type { OpenClawAgentContact, OpenClawBridgeConfig, OpenClawSessionBinding } from "./types"; +import { openClawAgentGhostLocalpart, openClawRoomCreationPreset } 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"; + } +} + +export function agentGhostUserId(config: OpenClawBridgeConfig, agentId: string, domain = matrixDomainFromHomeserver(config.homeserver)): string { + return `@${openClawAgentGhostLocalpart(config, agentId)}:${domain}`; +} + +export function serviceBotUserId(config: OpenClawBridgeConfig, domain = matrixDomainFromHomeserver(config.homeserver)): string { + return `@${config.serviceBotLocalpart}:${domain}`; +} + +export function agentContactFromOpenClawAgent( + config: OpenClawBridgeConfig, + agent: Record, + domain = matrixDomainFromHomeserver(config.homeserver) +): 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 avatarMxc = stringValue(agent.avatarMxc) ?? stringValue(agent.avatar_url) ?? stringValue(agent.avatarUrl); + const description = stringValue(agent.description); + if (avatarMxc) contact.avatarMxc = avatarMxc; + if (description) contact.description = description; + return contact; +} + +export async function createSessionRoom( + client: Pick, + config: OpenClawBridgeConfig, + options: { + agent: OpenClawAgentContact; + cwd?: string; + domain?: string; + label?: string; + sessionKey: string; + spaceId?: string; + } +): Promise { + const now = Date.now(); + const domain = options.domain ?? matrixDomainFromHomeserver(config.homeserver); + const roomName = options.label ?? `${options.agent.displayName}: ${options.sessionKey}`; + const topic = [ + `OpenClaw agent: ${options.agent.agentId}`, + `session: ${options.sessionKey}`, + options.cwd ? `cwd: ${options.cwd}` : undefined, + ].filter(Boolean).join("\n"); + const result = await client.appservice.createRoom({ + ...openClawRoomCreationPreset(config), + invite: config.allowedUserIds ?? [], + isDirect: true, + name: roomName, + topic, + userId: serviceBotUserId(config, domain), + visibility: "private", + }); + const binding: OpenClawSessionBinding = { + agentId: options.agent.agentId, + createdAt: now, + ghostUserId: options.agent.ghostUserId, + id: bindingIdForRoom(result.roomId), + kind: "session", + owner: "bridge", + roomId: result.roomId, + sessionKey: options.sessionKey, + updatedAt: now, + }; + if (options.cwd) binding.cwd = options.cwd; + if (options.label) binding.label = options.label; + if (options.spaceId) binding.spaceId = options.spaceId; + return binding; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} diff --git a/packages/openclaw/tsdown.config.ts b/packages/openclaw/tsdown.config.ts index 864cf53..3385a28 100644 --- a/packages/openclaw/tsdown.config.ts +++ b/packages/openclaw/tsdown.config.ts @@ -3,6 +3,6 @@ import { defineConfig } from "tsdown"; export default defineConfig({ clean: true, dts: true, - entry: ["src/config.ts", "src/index.ts", "src/openclaw-event-map.ts", "src/registration.ts", "src/stream-map.ts", "src/types.ts"], + entry: ["src/config.ts", "src/index.ts", "src/openclaw-event-map.ts", "src/registry.ts", "src/registration.ts", "src/rooms.ts", "src/stream-map.ts", "src/types.ts"], format: ["esm"], }); From 313886fdab849bf02676173cd64347038bc37cb9 Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Sat, 16 May 2026 16:16:50 +0200 Subject: [PATCH 04/43] Add OpenClaw approval response mapping --- packages/openclaw/package.json | 4 + packages/openclaw/src/approval.test.ts | 91 ++++++++++++++++++ packages/openclaw/src/approval.ts | 127 +++++++++++++++++++++++++ packages/openclaw/src/index.ts | 1 + packages/openclaw/tsdown.config.ts | 2 +- 5 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 packages/openclaw/src/approval.test.ts create mode 100644 packages/openclaw/src/approval.ts diff --git a/packages/openclaw/package.json b/packages/openclaw/package.json index c25fa70..b1d82f1 100644 --- a/packages/openclaw/package.json +++ b/packages/openclaw/package.json @@ -20,6 +20,10 @@ "types": "./dist/index.d.mts", "import": "./dist/index.mjs" }, + "./approval": { + "types": "./dist/approval.d.mts", + "import": "./dist/approval.mjs" + }, "./config": { "types": "./dist/config.d.mts", "import": "./dist/config.mjs" diff --git a/packages/openclaw/src/approval.test.ts b/packages/openclaw/src/approval.test.ts new file mode 100644 index 0000000..6f70fdd --- /dev/null +++ b/packages/openclaw/src/approval.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { + 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("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", + }); + }); +}); diff --git a/packages/openclaw/src/approval.ts b/packages/openclaw/src/approval.ts new file mode 100644 index 0000000..9b94053 --- /dev/null +++ b/packages/openclaw/src/approval.ts @@ -0,0 +1,127 @@ +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 type ApprovalDecision = "allow_once" | "allow_always" | "allow_session" | "allow_room" | "deny"; +export type OpenClawApprovalResolveDecision = "approve" | "approve_always" | "deny"; + +export interface ParsedApprovalResponse { + approvalId?: string; + approved: boolean; + approvedAlways: boolean; + decision: ApprovalDecision; + toolCallId?: string; +} + +export interface OpenClawApprovalResolvePayload { + approvalId: string; + decision: OpenClawApprovalResolveDecision; + toolCallId?: string; +} + +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 toolCallId = stringValue(recordValue(content)?.toolCallId); + if (approvalId) response.approvalId = approvalId; + if (toolCallId) response.toolCallId = toolCallId; + return response; +} + +export function parseToolApprovalResponseChunk(chunk: unknown): ParsedApprovalResponse | undefined { + const record = recordValue(chunk); + 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 toolCallId = stringValue(record.toolCallId); + if (approvalId) response.approvalId = approvalId; + if (toolCallId) response.toolCallId = toolCallId; + return response; +} + +export function parseApprovalResponseContent(content: unknown): ParsedApprovalResponse | undefined { + return parseToolApprovalResponseChunk(content) ?? parseApprovalResponseFromDeltas(content) ?? parseApprovalReactionContent(content); +} + +export function toOpenClawApprovalResolvePayload( + approvalId: string, + response: ParsedApprovalResponse +): OpenClawApprovalResolvePayload { + const payload: OpenClawApprovalResolvePayload = { + approvalId, + decision: response.approved ? (response.approvedAlways ? "approve_always" : "approve") : "deny", + }; + if (response.toolCallId) payload.toolCallId = response.toolCallId; + return payload; +} + +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 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"; + default: + 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/index.ts b/packages/openclaw/src/index.ts index f35fae7..6c980e9 100644 --- a/packages/openclaw/src/index.ts +++ b/packages/openclaw/src/index.ts @@ -1,3 +1,4 @@ +export * from "./approval"; export * from "./config"; export * from "./openclaw-event-map"; export * from "./registry"; diff --git a/packages/openclaw/tsdown.config.ts b/packages/openclaw/tsdown.config.ts index 3385a28..af0fe0d 100644 --- a/packages/openclaw/tsdown.config.ts +++ b/packages/openclaw/tsdown.config.ts @@ -3,6 +3,6 @@ import { defineConfig } from "tsdown"; export default defineConfig({ clean: true, dts: true, - entry: ["src/config.ts", "src/index.ts", "src/openclaw-event-map.ts", "src/registry.ts", "src/registration.ts", "src/rooms.ts", "src/stream-map.ts", "src/types.ts"], + entry: ["src/approval.ts", "src/config.ts", "src/index.ts", "src/openclaw-event-map.ts", "src/registry.ts", "src/registration.ts", "src/rooms.ts", "src/stream-map.ts", "src/types.ts"], format: ["esm"], }); From 41b9fc69ea03f66da027cbda896d3fb278204061 Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Sat, 16 May 2026 16:18:13 +0200 Subject: [PATCH 05/43] Add OpenClaw gateway runtime wrapper --- packages/openclaw/package.json | 4 + packages/openclaw/src/index.ts | 1 + .../openclaw/src/openclaw-runtime.test.ts | 90 +++++++++++ packages/openclaw/src/openclaw-runtime.ts | 150 ++++++++++++++++++ packages/openclaw/tsdown.config.ts | 2 +- 5 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 packages/openclaw/src/openclaw-runtime.test.ts create mode 100644 packages/openclaw/src/openclaw-runtime.ts diff --git a/packages/openclaw/package.json b/packages/openclaw/package.json index b1d82f1..ea258c5 100644 --- a/packages/openclaw/package.json +++ b/packages/openclaw/package.json @@ -32,6 +32,10 @@ "types": "./dist/openclaw-event-map.d.mts", "import": "./dist/openclaw-event-map.mjs" }, + "./openclaw-runtime": { + "types": "./dist/openclaw-runtime.d.mts", + "import": "./dist/openclaw-runtime.mjs" + }, "./registry": { "types": "./dist/registry.d.mts", "import": "./dist/registry.mjs" diff --git a/packages/openclaw/src/index.ts b/packages/openclaw/src/index.ts index 6c980e9..e29ff11 100644 --- a/packages/openclaw/src/index.ts +++ b/packages/openclaw/src/index.ts @@ -1,6 +1,7 @@ export * from "./approval"; export * from "./config"; export * from "./openclaw-event-map"; +export * from "./openclaw-runtime"; export * from "./registry"; export * from "./registration"; export * from "./rooms"; diff --git a/packages/openclaw/src/openclaw-runtime.test.ts b/packages/openclaw/src/openclaw-runtime.test.ts new file mode 100644 index 0000000..e5333c5 --- /dev/null +++ b/packages/openclaw/src/openclaw-runtime.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from "vitest"; +import { createDefaultConfig } from "./config"; +import { OpenClawGatewayRuntime, type OpenClawGatewayEvent, type OpenClawTransport } from "./openclaw-runtime"; + +describe("OpenClawGatewayRuntime", () => { + it("lists OpenClaw agents as Matrix ghost contacts", async () => { + const transport = fakeTransport({ + "agents.list": { agents: [{ description: "Code", id: "codex", name: "Codex" }] }, + }); + const runtime = new OpenClawGatewayRuntime({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw", homeserver: "https://matrix.example" }), + transport, + }); + + await expect(runtime.listAgentContacts()).resolves.toEqual([ + { + agentId: "codex", + description: "Code", + displayName: "Codex", + ghostUserId: "@openclaw_agent_codex:matrix.example", + }, + ]); + expect(transport.request).toHaveBeenCalledWith("agents.list", {}); + }); + + it("creates sessions and sends messages through OpenClaw RPC", async () => { + const transport = fakeTransport({ + "sessions.create": { key: "agent:codex:main", sessionId: "session_1" }, + "sessions.send": { runId: "run_1", sessionKey: "agent:codex:main" }, + }); + const runtime = new OpenClawGatewayRuntime({ + 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 })).resolves.toEqual({ + raw: { runId: "run_1", sessionKey: "agent:codex:main" }, + runId: "run_1", + sessionKey: "agent:codex:main", + }); + expect(transport.request).toHaveBeenCalledWith("sessions.send", { + key: "agent:codex:main", + message: "hello", + timeoutMs: 1000, + }, { expectFinal: true, timeoutMs: 1000 }); + }); + + 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 }, + }, events); + const runtime = new OpenClawGatewayRuntime({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + transport, + }); + + const received: OpenClawGatewayEvent[] = []; + for await (const event of runtime.eventsForRun("run_1")) received.push(event); + expect(received).toEqual([{ event: "assistant.delta", payload: { delta: "use", runId: "run_1" } }]); + 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", + }); + }); +}); + +function fakeTransport(responses: Record, events: OpenClawGatewayEvent[] = []): OpenClawTransport & { + 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]), + }; +} diff --git a/packages/openclaw/src/openclaw-runtime.ts b/packages/openclaw/src/openclaw-runtime.ts new file mode 100644 index 0000000..b738ae0 --- /dev/null +++ b/packages/openclaw/src/openclaw-runtime.ts @@ -0,0 +1,150 @@ +import type { OpenClawAgentContact, OpenClawBridgeConfig } from "./types"; +import { agentContactFromOpenClawAgent } from "./rooms"; +import type { OpenClawApprovalResolvePayload } from "./approval"; + +export type GatewayRequestOptions = { + expectFinal?: boolean; + timeoutMs?: number | null; +}; + +export type OpenClawGatewayEvent = { + event?: string; + payload?: unknown; + seq?: number; + stateVersion?: unknown; +}; + +export interface OpenClawTransport { + close?(): Promise | void; + events(filter?: (event: OpenClawGatewayEvent) => boolean): AsyncIterable; + request(method: string, params?: unknown, options?: GatewayRequestOptions): Promise; +} + +export interface OpenClawSessionCreateOptions { + agentId: string; + key?: string; + label?: string; + message?: string; + model?: string; + parentSessionKey?: string; + task?: string; +} + +export interface OpenClawSessionSendOptions { + attachments?: unknown[]; + idempotencyKey?: string; + message: string; + sessionKey: string; + thinking?: string; + timeoutMs?: number; +} + +export interface OpenClawSessionRef { + agentId?: string; + key: string; + label?: string; + raw?: unknown; + sessionId?: string; +} + +export interface OpenClawRunRef { + raw?: unknown; + runId: string; + sessionKey: string; +} + +export class OpenClawGatewayRuntime { + readonly config: OpenClawBridgeConfig; + readonly transport: OpenClawTransport; + + constructor(options: { config: OpenClawBridgeConfig; transport: OpenClawTransport }) { + 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"); + return stripUndefined({ + agentId: stringValue(record.agentId) ?? options.agentId, + key, + label: stringValue(record.label) ?? options.label, + raw, + sessionId: stringValue(record.sessionId), + }); + } + + async sendMessage(options: OpenClawSessionSendOptions): Promise { + const requestOptions: GatewayRequestOptions = { expectFinal: true }; + if (options.timeoutMs !== undefined) requestOptions.timeoutMs = options.timeoutMs; + const raw = await this.transport.request("sessions.send", { + key: options.sessionKey, + message: options.message, + ...(options.attachments ? { attachments: options.attachments } : {}), + ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}), + ...(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 sessions.send did not return a runId"); + return { raw, runId, sessionKey: stringValue(record.sessionKey) ?? options.sessionKey }; + } + + eventsForRun(runId: string): AsyncIterable { + return this.transport.events((event) => { + const payload = recordValue(event.payload); + return stringValue(payload?.runId) === runId || stringValue(payload?.id) === runId; + }); + } + + async resolveApproval(payload: OpenClawApprovalResolvePayload): Promise { + return await this.transport.request("exec.approval.resolve", payload); + } + + async close(): Promise { + await this.transport.close?.(); + } +} + +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; +} + +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/tsdown.config.ts b/packages/openclaw/tsdown.config.ts index af0fe0d..5beed90 100644 --- a/packages/openclaw/tsdown.config.ts +++ b/packages/openclaw/tsdown.config.ts @@ -3,6 +3,6 @@ import { defineConfig } from "tsdown"; export default defineConfig({ clean: true, dts: true, - entry: ["src/approval.ts", "src/config.ts", "src/index.ts", "src/openclaw-event-map.ts", "src/registry.ts", "src/registration.ts", "src/rooms.ts", "src/stream-map.ts", "src/types.ts"], + entry: ["src/approval.ts", "src/config.ts", "src/index.ts", "src/openclaw-event-map.ts", "src/openclaw-runtime.ts", "src/registry.ts", "src/registration.ts", "src/rooms.ts", "src/stream-map.ts", "src/types.ts"], format: ["esm"], }); From 043966c1f4a30d05bc00da48d334f9cb0163acf7 Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Sat, 16 May 2026 16:19:18 +0200 Subject: [PATCH 06/43] Add OpenClaw Matrix bridge coordinator --- packages/openclaw/package.json | 4 + packages/openclaw/src/bridge-agent.test.ts | 127 +++++++++++++++++++++ packages/openclaw/src/bridge-agent.ts | 91 +++++++++++++++ packages/openclaw/src/index.ts | 1 + packages/openclaw/tsdown.config.ts | 2 +- 5 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 packages/openclaw/src/bridge-agent.test.ts create mode 100644 packages/openclaw/src/bridge-agent.ts diff --git a/packages/openclaw/package.json b/packages/openclaw/package.json index ea258c5..0569650 100644 --- a/packages/openclaw/package.json +++ b/packages/openclaw/package.json @@ -24,6 +24,10 @@ "types": "./dist/approval.d.mts", "import": "./dist/approval.mjs" }, + "./bridge-agent": { + "types": "./dist/bridge-agent.d.mts", + "import": "./dist/bridge-agent.mjs" + }, "./config": { "types": "./dist/config.d.mts", "import": "./dist/config.mjs" diff --git a/packages/openclaw/src/bridge-agent.test.ts b/packages/openclaw/src/bridge-agent.test.ts new file mode 100644 index 0000000..6f9fdb4 --- /dev/null +++ b/packages/openclaw/src/bridge-agent.test.ts @@ -0,0 +1,127 @@ +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, type OpenClawBridgeStreamPublisher } from "./bridge-agent"; +import { OpenClawGatewayRuntime, type OpenClawGatewayEvent, type OpenClawTransport } 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(); + const agent = new OpenClawMatrixBridgeAgent({ + registry, + runtime: runtimeWith({ + responses: { "agents.list": { agents: [{ id: "codex", name: "Codex" }] } }, + }), + streams: { publish: vi.fn() }, + }); + + await agent.syncAgentContacts(); + expect(registry.getAgent("codex")?.ghostUserId).toBe("@openclaw_agent_codex:localhost"); + }); + + it("sends Matrix room text to the bound OpenClaw session and streams run events", async () => { + const registry = await tempRegistry(); + registry.upsertBinding(testBinding()); + const published: Array<{ binding: OpenClawSessionBinding; chunks: unknown[] }> = []; + const streams: OpenClawBridgeStreamPublisher = { + publish(binding, chunks) { + published.push({ binding, chunks }); + }, + }; + const runtime = runtimeWith({ + events: [ + { event: "assistant.delta", payload: { data: { delta: "hi" }, runId: "run_1", type: "assistant.delta" } }, + { event: "run.completed", payload: { runId: "run_1", type: "run.completed" } }, + ], + responses: { "sessions.send": { runId: "run_1", sessionKey: "agent:codex:main" } }, + }); + const agent = new OpenClawMatrixBridgeAgent({ registry, runtime, streams }); + + await agent.handleMatrixText({ + eventId: "$event", + roomId: "!room:example.com", + sender: "@alice:example.com", + text: "hello", + }); + + expect(runtime.transport.request).toHaveBeenCalledWith("sessions.send", { + idempotencyKey: "$event", + key: "agent:codex:main", + message: "hello", + }, { expectFinal: true }); + expect(registry.getBindingByRoom("!room:example.com")?.lastRunId).toBe("run_1"); + expect(published.flatMap((item) => item.chunks).map((chunk) => (chunk as { type: string }).type)).toEqual([ + "text-start", + "text-delta", + "text-end", + "finish", + ]); + }); + + it("forwards Beeper approval responses back to OpenClaw", async () => { + const registry = await tempRegistry(); + const runtime = runtimeWith({ responses: { "exec.approval.resolve": { ok: true } } }); + const agent = new OpenClawMatrixBridgeAgent({ registry, runtime, streams: { publish: vi.fn() } }); + + 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", + }); + }); +}); + +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: "@openclaw_agent_codex:example.com", + id: "binding", + kind: "session", + owner: "bridge", + roomId: "!room:example.com", + sessionKey: "agent:codex:main", + updatedAt: 1, + }; +} + +function runtimeWith(options: { + events?: OpenClawGatewayEvent[]; + responses: Record; +}): OpenClawGatewayRuntime & { transport: OpenClawTransport & { 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]), + }; + return new OpenClawGatewayRuntime({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + transport, + }) as OpenClawGatewayRuntime & { transport: OpenClawTransport & { request: ReturnType } }; +} diff --git a/packages/openclaw/src/bridge-agent.ts b/packages/openclaw/src/bridge-agent.ts new file mode 100644 index 0000000..02aa625 --- /dev/null +++ b/packages/openclaw/src/bridge-agent.ts @@ -0,0 +1,91 @@ +import { + parseApprovalResponseContent, + toOpenClawApprovalResolvePayload, + type ParsedApprovalResponse, +} from "./approval"; +import { createOpenClawStreamState, mapOpenClawEventToBeeperChunks } from "./openclaw-event-map"; +import type { OpenClawGatewayRuntime, OpenClawGatewayEvent } from "./openclaw-runtime"; +import type { OpenClawBridgeRegistry } from "./registry"; +import { createTurnId, type BeeperUIMessageChunk } from "./stream-map"; +import type { OpenClawSessionBinding } from "./types"; + +export interface OpenClawBridgeStreamPublisher { + publish(binding: OpenClawSessionBinding, chunks: BeeperUIMessageChunk[]): Promise | void; +} + +export interface MatrixTextTurn { + eventId: string; + roomId: string; + sender: string; + text: string; +} + +export class OpenClawMatrixBridgeAgent { + readonly registry: OpenClawBridgeRegistry; + readonly runtime: OpenClawGatewayRuntime; + readonly streams: OpenClawBridgeStreamPublisher; + + constructor(options: { + registry: OpenClawBridgeRegistry; + runtime: OpenClawGatewayRuntime; + streams: OpenClawBridgeStreamPublisher; + }) { + this.registry = options.registry; + this.runtime = options.runtime; + this.streams = options.streams; + } + + async syncAgentContacts(): Promise { + for (const contact of await this.runtime.listAgentContacts()) { + this.registry.upsertAgent(contact); + } + await this.registry.save(); + } + + async handleMatrixText(turn: MatrixTextTurn): Promise { + if (this.registry.hasDedupe(turn.eventId)) return; + this.registry.markDedupe(turn.eventId); + const binding = this.registry.getBindingByRoom(turn.roomId); + if (!binding) { + await this.registry.save(); + return; + } + const run = await this.runtime.sendMessage({ + idempotencyKey: turn.eventId, + message: turn.text, + sessionKey: binding.sessionKey, + }); + this.registry.updateBinding(binding.id, (current) => ({ + ...current, + lastMatrixEventId: turn.eventId, + lastRunId: run.runId, + updatedAt: Date.now(), + })); + await this.streamRun(binding, run.runId); + await this.registry.save(); + } + + async handleApprovalContent(content: unknown, approvalId?: string): Promise { + const response = parseApprovalResponseContent(content); + const resolvedApprovalId = response?.approvalId ?? approvalId; + if (!response || !resolvedApprovalId) return undefined; + await this.runtime.resolveApproval(toOpenClawApprovalResolvePayload(resolvedApprovalId, response)); + return response; + } + + async streamRun(binding: OpenClawSessionBinding, runId: string): Promise { + const state = createOpenClawStreamState(createTurnId()); + for await (const gatewayEvent of this.runtime.eventsForRun(runId)) { + const chunks = mapOpenClawEventToBeeperChunks(state, openClawEventFromGateway(gatewayEvent)); + if (chunks.length > 0) await this.streams.publish(binding, chunks); + } + } +} + +function openClawEventFromGateway(event: OpenClawGatewayEvent): unknown { + if (event.payload && typeof event.payload === "object") { + return event.payload; + } + if (event.event) return { type: event.event, data: event.payload }; + return event; +} diff --git a/packages/openclaw/src/index.ts b/packages/openclaw/src/index.ts index e29ff11..c1707a9 100644 --- a/packages/openclaw/src/index.ts +++ b/packages/openclaw/src/index.ts @@ -1,4 +1,5 @@ export * from "./approval"; +export * from "./bridge-agent"; export * from "./config"; export * from "./openclaw-event-map"; export * from "./openclaw-runtime"; diff --git a/packages/openclaw/tsdown.config.ts b/packages/openclaw/tsdown.config.ts index 5beed90..f51fd1f 100644 --- a/packages/openclaw/tsdown.config.ts +++ b/packages/openclaw/tsdown.config.ts @@ -3,6 +3,6 @@ import { defineConfig } from "tsdown"; export default defineConfig({ clean: true, dts: true, - entry: ["src/approval.ts", "src/config.ts", "src/index.ts", "src/openclaw-event-map.ts", "src/openclaw-runtime.ts", "src/registry.ts", "src/registration.ts", "src/rooms.ts", "src/stream-map.ts", "src/types.ts"], + entry: ["src/approval.ts", "src/bridge-agent.ts", "src/config.ts", "src/index.ts", "src/openclaw-event-map.ts", "src/openclaw-runtime.ts", "src/registry.ts", "src/registration.ts", "src/rooms.ts", "src/stream-map.ts", "src/types.ts"], format: ["esm"], }); From 72529730514ead9d929f7c64a08caae019fc577a Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Sat, 16 May 2026 16:20:29 +0200 Subject: [PATCH 07/43] Add OpenClaw bridge management CLI --- packages/openclaw/package.json | 7 ++ packages/openclaw/src/cli.test.ts | 82 +++++++++++++++++ packages/openclaw/src/cli.ts | 138 +++++++++++++++++++++++++++++ packages/openclaw/src/index.ts | 1 + packages/openclaw/tsdown.config.ts | 2 +- 5 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 packages/openclaw/src/cli.test.ts create mode 100644 packages/openclaw/src/cli.ts diff --git a/packages/openclaw/package.json b/packages/openclaw/package.json index 0569650..b0641b5 100644 --- a/packages/openclaw/package.json +++ b/packages/openclaw/package.json @@ -12,6 +12,9 @@ "bugs": { "url": "https://github.com/beeper/pickle/issues" }, + "bin": { + "pickle-openclaw": "./dist/cli.mjs" + }, "main": "./dist/index.mjs", "module": "./dist/index.mjs", "types": "./dist/index.d.mts", @@ -28,6 +31,10 @@ "types": "./dist/bridge-agent.d.mts", "import": "./dist/bridge-agent.mjs" }, + "./cli": { + "types": "./dist/cli.d.mts", + "import": "./dist/cli.mjs" + }, "./config": { "types": "./dist/config.d.mts", "import": "./dist/config.mjs" diff --git a/packages/openclaw/src/cli.test.ts b/packages/openclaw/src/cli.test.ts new file mode 100644 index 0000000..11b1006 --- /dev/null +++ b/packages/openclaw/src/cli.test.ts @@ -0,0 +1,82 @@ +import { mkdtemp, readFile, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { runCli } from "./cli"; + +describe("pickle-openclaw CLI", () => { + it("writes secure config and registration files", async () => { + const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-cli-")); + const configPath = join(dir, "config.json"); + const registrationPath = join(dir, "registration.json"); + const initIO = captureIO(); + await expect(runCli([ + "init", + "--config", + configPath, + "--data-dir", + dir, + "--homeserver", + "https://matrix.example", + "--access-token", + "secret", + ], initIO)).resolves.toBe(0); + expect(initIO.stdoutText).toContain('"accessToken": ""'); + expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({ + accessToken: "secret", + homeserver: "https://matrix.example", + }); + expect((await stat(configPath)).mode & 0o777).toBe(0o600); + + const registerIO = captureIO(); + await expect(runCli([ + "register", + "--config", + configPath, + "--output", + registrationPath, + "--as-token", + "as", + "--hs-token", + "hs", + ], registerIO)).resolves.toBe(0); + expect(registerIO.stdoutText.trim()).toBe(registrationPath); + expect(JSON.parse(await readFile(registrationPath, "utf8"))).toMatchObject({ + as_token: "as", + hs_token: "hs", + id: "pickle-openclaw", + sender_localpart: "openclawbot", + }); + expect((await stat(registrationPath)).mode & 0o777).toBe(0o600); + }); + + it("reports unknown commands", async () => { + const io = captureIO(); + await expect(runCli(["wat"], io)).resolves.toBe(2); + expect(io.stderrText).toContain("Unknown command: wat"); + }); +}); + +function captureIO() { + const io = { + stderrText: "", + stdoutText: "", + stderr: { + write(this: { owner: { stderrText: string } }, chunk: string) { + this.owner.stderrText += chunk; + return true; + }, + owner: undefined as unknown as { stderrText: string }, + }, + stdout: { + write(this: { owner: { stdoutText: string } }, chunk: string) { + this.owner.stdoutText += chunk; + return true; + }, + owner: undefined as unknown as { stdoutText: string }, + }, + }; + io.stderr.owner = io; + io.stdout.owner = io; + return io; +} diff --git a/packages/openclaw/src/cli.ts b/packages/openclaw/src/cli.ts new file mode 100644 index 0000000..fb50e09 --- /dev/null +++ b/packages/openclaw/src/cli.ts @@ -0,0 +1,138 @@ +#!/usr/bin/env node +import { chmod, mkdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { createDefaultConfig, defaultConfigPath, readConfig, secretToken, writeConfig } from "./config"; +import { createAppserviceRegistration } from "./registration"; +import type { AppserviceRegistration, OpenClawBridgeConfig } from "./types"; + +export interface CliIO { + stderr: Pick; + stdout: Pick; +} + +export async function runCli(argv = process.argv.slice(2), io: CliIO = process): Promise { + const [command, ...args] = argv; + try { + if (!command || command === "help" || command === "--help" || command === "-h") { + io.stdout.write(helpText()); + return 0; + } + if (command === "init") { + const options = parseOptions(args); + const config = createDefaultConfig(configOverridesFromOptions(options)); + await writeConfig(config, stringOption(options, "config") ?? defaultConfigPath(config.dataDir)); + io.stdout.write(`${JSON.stringify(redactConfig(config), null, 2)}\n`); + return 0; + } + if (command === "register") { + const options = parseOptions(args); + const config = await loadConfig(options); + const registration = createAppserviceRegistration(config, { + asToken: stringOption(options, "as-token") ?? secretToken(), + hsToken: stringOption(options, "hs-token") ?? secretToken(), + }); + const output = stringOption(options, "output") ?? resolve(config.dataDir, "registration.json"); + await writeRegistration(output, registration); + io.stdout.write(`${output}\n`); + return 0; + } + if (command === "status") { + const config = await loadConfig(parseOptions(args)); + io.stdout.write(`${JSON.stringify(redactConfig(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; + } +} + +export async function writeRegistration(path: string, registration: AppserviceRegistration): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(registration, null, 2)}\n`, { mode: 0o600 }); + await chmod(path, 0o600); +} + +function helpText(): string { + return [ + "pickle-openclaw ", + "", + "Commands:", + " init Write a secure OpenClaw bridge config", + " register Write a Matrix appservice registration file", + " status Print the redacted effective config", + "", + "Common options:", + " --config ", + " --data-dir ", + " --homeserver ", + " --gateway-url ", + " --registration-url ", + " --access-token ", + " --hs-token ", + " --as-token ", + " --output ", + "", + ].join("\n"); +} + +function configOverridesFromOptions(options: Map): Partial { + const overrides: Partial = {}; + const accessToken = stringOption(options, "access-token"); + const appserviceId = stringOption(options, "appservice-id"); + const dataDir = stringOption(options, "data-dir"); + const gatewayUrl = stringOption(options, "gateway-url"); + const homeserver = stringOption(options, "homeserver"); + const registrationUrl = stringOption(options, "registration-url"); + if (accessToken) overrides.accessToken = accessToken; + if (appserviceId) overrides.appserviceId = appserviceId; + if (dataDir) overrides.dataDir = dataDir; + if (gatewayUrl) overrides.gatewayUrl = gatewayUrl; + if (homeserver) overrides.homeserver = homeserver; + if (registrationUrl) overrides.registrationUrl = registrationUrl; + return overrides; +} + +async function loadConfig(options: Map): Promise { + const configPath = stringOption(options, "config"); + if (configPath) return readConfig(configPath); + return createDefaultConfig(configOverridesFromOptions(options)); +} + +function redactConfig(config: OpenClawBridgeConfig): OpenClawBridgeConfig { + return { + ...config, + ...(config.accessToken ? { accessToken: "" } : {}), + ...(config.hsToken ? { hsToken: "" } : {}), + }; +} + +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; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + runCli().then((code) => { + process.exitCode = code; + }); +} diff --git a/packages/openclaw/src/index.ts b/packages/openclaw/src/index.ts index c1707a9..adfd555 100644 --- a/packages/openclaw/src/index.ts +++ b/packages/openclaw/src/index.ts @@ -1,5 +1,6 @@ export * from "./approval"; export * from "./bridge-agent"; +export * from "./cli"; export * from "./config"; export * from "./openclaw-event-map"; export * from "./openclaw-runtime"; diff --git a/packages/openclaw/tsdown.config.ts b/packages/openclaw/tsdown.config.ts index f51fd1f..8b29f0b 100644 --- a/packages/openclaw/tsdown.config.ts +++ b/packages/openclaw/tsdown.config.ts @@ -3,6 +3,6 @@ import { defineConfig } from "tsdown"; export default defineConfig({ clean: true, dts: true, - entry: ["src/approval.ts", "src/bridge-agent.ts", "src/config.ts", "src/index.ts", "src/openclaw-event-map.ts", "src/openclaw-runtime.ts", "src/registry.ts", "src/registration.ts", "src/rooms.ts", "src/stream-map.ts", "src/types.ts"], + entry: ["src/approval.ts", "src/bridge-agent.ts", "src/cli.ts", "src/config.ts", "src/index.ts", "src/openclaw-event-map.ts", "src/openclaw-runtime.ts", "src/registry.ts", "src/registration.ts", "src/rooms.ts", "src/stream-map.ts", "src/types.ts"], format: ["esm"], }); From 8b59e297bca2ed76da34f9b6a37e56ad6d179d81 Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Sat, 16 May 2026 16:24:09 +0200 Subject: [PATCH 08/43] Add OpenClaw Pickle bridge connector --- packages/openclaw/package.json | 4 + packages/openclaw/src/connector.test.ts | 217 ++++++++++++++++ packages/openclaw/src/connector.ts | 332 ++++++++++++++++++++++++ packages/openclaw/src/index.ts | 1 + packages/openclaw/tsdown.config.ts | 2 +- 5 files changed, 555 insertions(+), 1 deletion(-) create mode 100644 packages/openclaw/src/connector.test.ts create mode 100644 packages/openclaw/src/connector.ts diff --git a/packages/openclaw/package.json b/packages/openclaw/package.json index b0641b5..b1cd8e5 100644 --- a/packages/openclaw/package.json +++ b/packages/openclaw/package.json @@ -39,6 +39,10 @@ "types": "./dist/config.d.mts", "import": "./dist/config.mjs" }, + "./connector": { + "types": "./dist/connector.d.mts", + "import": "./dist/connector.mjs" + }, "./openclaw-event-map": { "types": "./dist/openclaw-event-map.d.mts", "import": "./dist/openclaw-event-map.mjs" diff --git a/packages/openclaw/src/connector.test.ts b/packages/openclaw/src/connector.test.ts new file mode 100644 index 0000000..2f29b5a --- /dev/null +++ b/packages/openclaw/src/connector.test.ts @@ -0,0 +1,217 @@ +import type { BridgeRequestContext, MatrixMessage, MatrixReaction, UserLogin } from "@beeper/pickle-bridge"; +import { describe, expect, it, vi } from "vitest"; +import { createDefaultConfig } from "./config"; +import { createOpenClawConnector, OpenClawNetworkAPI } from "./connector"; +import { OpenClawGatewayRuntime, type OpenClawGatewayEvent, type OpenClawTransport } from "./openclaw-runtime"; +import { OpenClawBridgeRegistry } from "./registry"; + +describe("OpenClawBridgeConnector", () => { + it("exposes bridgev2-shaped metadata, capabilities, and login flow", async () => { + const connector = createOpenClawConnector({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw", gatewayUrl: "ws://gateway" }), + }); + expect(connector.getName()).toMatchObject({ + beeperBridgeType: "openclaw", + defaultCommandPrefix: "!openclaw", + displayName: "OpenClaw", + networkId: "openclaw", + }); + expect(connector.getCapabilities().provisioning?.resolveIdentifier).toEqual({ + contactList: true, + createDM: true, + lookupUsername: true, + }); + expect(connector.getLoginFlows()).toEqual([ + { + description: "Connect to an existing OpenClaw gateway by URL and optional bearer token.", + id: "openclaw.gateway", + name: "OpenClaw Gateway", + }, + ]); + + const process = connector.createLogin({} as BridgeRequestContext, { id: "@alice:example.com" }, "openclaw.gateway"); + await expect(process.start()).resolves.toMatchObject({ + stepId: "openclaw.gateway.credentials", + type: "user_input", + }); + await expect( + "submitUserInput" in process + ? process.submitUserInput({ access_token: "token", gateway_url: "ws://gateway" }) + : undefined + ).resolves.toMatchObject({ + complete: { + userLogin: { + metadata: { + accessToken: "token", + gatewayUrl: "ws://gateway", + }, + remoteName: "OpenClaw", + userId: "@alice:example.com", + }, + }, + type: "complete", + }); + }); + + 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: [{ id: "codex", name: "Codex" }] } }, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime, + streams: { publish: vi.fn() }, + }); + const registerGhost = vi.fn(); + await api.connect({ bridge: { registerGhost }, queue: vi.fn(), queueRemoteEvent: vi.fn() } as unknown as Parameters[0]); + expect(registerGhost).toHaveBeenCalledWith({ + displayName: "Codex", + id: "codex", + metadata: { + openclaw: { + agentId: "codex", + displayName: "Codex", + ghostUserId: "@openclaw_agent_codex:localhost", + }, + }, + mxid: "@openclaw_agent_codex:localhost", + }); + }); + + 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: {} }), + streams: { publish: vi.fn() }, + }); + await expect(api.resolveIdentifier({} as BridgeRequestContext, { + createDM: true, + identifier: "codex", + type: "username", + })).resolves.toEqual({ + ghost: { + displayName: "Codex", + id: "codex", + metadata: { + openclaw: { + agentId: "codex", + displayName: "Codex", + ghostUserId: "@codex:example.com", + }, + }, + mxid: "@codex:example.com", + }, + portal: { + id: "agent:codex", + metadata: { + openclaw: { + agentId: "codex", + ghostUserId: "@codex:example.com", + sessionKey: "agent:codex", + }, + }, + portalKey: { id: "agent:codex", receiver: "login" }, + receiver: "login", + roomType: "dm", + }, + userId: "@codex:example.com", + }); + }); + + it("dispatches Matrix text and approval reactions 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.send": { runId: "run_1", sessionKey: "agent:codex" }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime, + streams: { publish: vi.fn() }, + }); + const portal = { + id: "agent:codex", + metadata: { + openclaw: { + agentId: "codex", + ghostUserId: "@codex:example.com", + sessionKey: "agent:codex", + }, + }, + mxid: "!room:example.com", + portalKey: { id: "agent:codex", receiver: "login" }, + receiver: "login", + }; + + await expect(api.handleMatrixMessage({} as BridgeRequestContext, { + event: { eventId: "$message" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "hello", + } as MatrixMessage)).resolves.toEqual({ pending: false }); + expect(runtime.transport.request).toHaveBeenCalledWith("sessions.send", { + idempotencyKey: "$message", + key: "agent:codex", + message: "hello", + }, { expectFinal: true }); + + 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", + }, + }, + }, + }); + expect(runtime.transport.request).toHaveBeenCalledWith("exec.approval.resolve", { + approvalId: "approval_1", + decision: "deny", + }); + }); +}); + +function login(): UserLogin { + return { id: "login", metadata: { gatewayUrl: "ws://gateway" }, userId: "@alice:example.com" }; +} + +function runtimeWith(options: { + events?: OpenClawGatewayEvent[]; + responses: Record; +}): OpenClawGatewayRuntime & { transport: OpenClawTransport & { 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]), + }; + return new OpenClawGatewayRuntime({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + transport, + }) as OpenClawGatewayRuntime & { transport: OpenClawTransport & { request: ReturnType } }; +} diff --git a/packages/openclaw/src/connector.ts b/packages/openclaw/src/connector.ts new file mode 100644 index 0000000..5569a87 --- /dev/null +++ b/packages/openclaw/src/connector.ts @@ -0,0 +1,332 @@ +import type { + BridgeConnector, + BridgeContext, + BridgeRequestContext, + BridgeUser, + ConnectContext, + IdentifierResolvingNetworkAPI, + LoginCreateContext, + LoginFlow, + LoginProcess, + LoginStep, + LoadUserLoginContext, + MatrixMessage, + MatrixMessageResponse, + MatrixReaction, + MessageHandlingNetworkAPI, + NetworkAPI, + NetworkGeneralCapabilities, + Portal, + ReactionHandlingNetworkAPI, + Reaction, + ResolveIdentifierParams, + ResolveIdentifierResponse, + UserLogin, +} from "@beeper/pickle-bridge"; +import { parseApprovalResponseContent } from "./approval"; +import { OpenClawMatrixBridgeAgent, type OpenClawBridgeStreamPublisher } from "./bridge-agent"; +import { createDefaultConfig } from "./config"; +import { OpenClawGatewayRuntime, type OpenClawTransport } from "./openclaw-runtime"; +import { OpenClawBridgeRegistry } from "./registry"; +import { agentContactFromOpenClawAgent } from "./rooms"; +import type { OpenClawAgentContact, OpenClawBridgeConfig, OpenClawSessionBinding } from "./types"; + +export interface OpenClawConnectorOptions { + config?: OpenClawBridgeConfig; + registry?: OpenClawBridgeRegistry; + runtimeFactory?: (login: UserLogin, config: OpenClawBridgeConfig) => OpenClawGatewayRuntime; + streams?: OpenClawBridgeStreamPublisher; + transportFactory?: (login: UserLogin, config: OpenClawBridgeConfig) => OpenClawTransport; +} + +export function createOpenClawConnector(options: OpenClawConnectorOptions = {}): OpenClawBridgeConnector { + return new OpenClawBridgeConnector(options); +} + +export class OpenClawBridgeConnector implements BridgeConnector { + readonly config: OpenClawBridgeConfig; + readonly registry: OpenClawBridgeRegistry; + #runtimeFactory: (login: UserLogin, config: OpenClawBridgeConfig) => OpenClawGatewayRuntime; + #streams: OpenClawBridgeStreamPublisher; + + constructor(options: OpenClawConnectorOptions = {}) { + this.config = options.config ?? createDefaultConfig(); + this.registry = options.registry ?? new OpenClawBridgeRegistry(); + this.#streams = options.streams ?? { publish: () => undefined }; + this.#runtimeFactory = + options.runtimeFactory ?? + ((login, config) => new OpenClawGatewayRuntime({ + config, + transport: options.transportFactory?.(login, config) ?? missingTransport(), + })); + } + + 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, + }, + }, + }; + } + + getLoginFlows(): LoginFlow[] { + return [ + { + description: "Connect to an existing OpenClaw gateway by URL and optional bearer token.", + id: "openclaw.gateway", + name: "OpenClaw Gateway", + }, + ]; + } + + async init(_ctx: BridgeContext): Promise { + await this.registry.load(); + } + + async start(_ctx: BridgeContext): Promise { + await this.registry.save(); + } + + createLogin(_ctx: LoginCreateContext, user: BridgeUser, flowId: string): LoginProcess { + if (flowId !== "openclaw.gateway") throw new Error(`Unsupported OpenClaw login flow: ${flowId}`); + return new OpenClawGatewayLoginProcess(user.id, this.config); + } + + loadUserLogin(_ctx: LoadUserLoginContext, login: UserLogin): NetworkAPI { + return new OpenClawNetworkAPI({ + config: this.config, + login, + registry: this.registry, + runtime: this.#runtimeFactory(login, this.config), + streams: this.#streams, + }); + } +} + +export class OpenClawGatewayLoginProcess implements LoginProcess { + readonly #defaultConfig: OpenClawBridgeConfig; + readonly #userId: string; + + constructor(userId: string, defaultConfig: OpenClawBridgeConfig) { + this.#userId = userId; + this.#defaultConfig = defaultConfig; + } + + cancel(): void {} + + async start(): Promise { + return { + instructions: "Enter your OpenClaw gateway URL and optional bearer token.", + stepId: "openclaw.gateway.credentials", + type: "user_input", + userInput: { + fields: [ + { + defaultValue: this.#defaultConfig.gatewayUrl ?? "ws://127.0.0.1:29390", + description: "OpenClaw gateway URL.", + id: "gateway_url", + name: "Gateway URL", + type: "url", + }, + { + description: "Optional OpenClaw gateway bearer token.", + id: "access_token", + name: "Access token", + type: "token", + }, + ], + }, + }; + } + + async submitUserInput(_ctxOrInput?: BridgeRequestContext | Record, maybeInput?: Record): Promise { + const input = maybeInput ?? (_ctxOrInput as Record | undefined) ?? {}; + const gatewayUrl = input.gateway_url || this.#defaultConfig.gatewayUrl || "ws://127.0.0.1:29390"; + const accessToken = input.access_token || this.#defaultConfig.accessToken; + return { + complete: { + userLogin: { + id: `openclaw:${encodeLoginId(gatewayUrl)}`, + metadata: { + ...(accessToken ? { accessToken } : {}), + gatewayUrl, + }, + remoteName: "OpenClaw", + userId: this.#userId, + }, + userLoginId: `openclaw:${encodeLoginId(gatewayUrl)}`, + }, + instructions: "OpenClaw gateway configured.", + stepId: "openclaw.gateway.complete", + type: "complete", + }; + } +} + +export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetworkAPI, MessageHandlingNetworkAPI, ReactionHandlingNetworkAPI { + readonly #agent: OpenClawMatrixBridgeAgent; + readonly #login: UserLogin; + readonly #registry: OpenClawBridgeRegistry; + readonly #runtime: OpenClawGatewayRuntime; + + constructor(options: { + config: OpenClawBridgeConfig; + login: UserLogin; + registry: OpenClawBridgeRegistry; + runtime: OpenClawGatewayRuntime; + streams: OpenClawBridgeStreamPublisher; + }) { + this.#login = options.login; + this.#registry = options.registry; + this.#runtime = options.runtime; + this.#agent = new OpenClawMatrixBridgeAgent({ + registry: options.registry, + runtime: options.runtime, + streams: options.streams, + }); + } + + async connect(ctx: ConnectContext): Promise { + await this.#agent.syncAgentContacts(); + for (const contact of this.#registry.data.agents) { + ctx.bridge.registerGhost({ + displayName: contact.displayName, + id: contact.agentId, + metadata: { openclaw: contact }, + mxid: contact.ghostUserId, + }); + } + } + + async disconnect(): Promise { + await this.#runtime.close(); + } + + async resolveIdentifier(_ctx: BridgeRequestContext, params: ResolveIdentifierParams): Promise { + const contact = this.#registry.getAgent(params.identifier) ?? agentContactFromOpenClawAgent(this.#runtime.config, { id: params.identifier }); + const portal = params.createDM ? portalForAgent(contact, this.#login.id) : undefined; + return { + ghost: { + displayName: contact.displayName, + id: contact.agentId, + metadata: { openclaw: contact }, + mxid: contact.ghostUserId, + }, + ...(portal ? { portal } : {}), + userId: contact.ghostUserId, + }; + } + + async handleMatrixMessage(_ctx: BridgeRequestContext, msg: MatrixMessage): Promise { + const binding = bindingFromPortal(msg.portal); + if (binding && !this.#registry.getBindingByRoom(msg.portal.mxid ?? "")) this.#registry.upsertBinding(binding); + if (msg.portal.mxid) { + await this.#agent.handleMatrixText({ + eventId: msg.event.eventId, + roomId: msg.portal.mxid, + sender: msg.sender.userId, + text: msg.text, + }); + } + return { pending: false }; + } + + async handleMatrixReaction(_ctx: BridgeRequestContext, msg: MatrixReaction): Promise { + const approval = parseApprovalResponseContent(msg.content); + if (!approval) return null; + await this.#agent.handleApprovalContent(msg.content, approval.approvalId ?? msg.targetMessage.id); + return { id: msg.event.eventId, metadata: { openclaw: { approval } } }; + } +} + +function portalForAgent(contact: OpenClawAgentContact, receiver: string): Portal { + const id = `agent:${contact.agentId}`; + return { + id, + metadata: { + openclaw: { + agentId: contact.agentId, + ghostUserId: contact.ghostUserId, + sessionKey: id, + }, + }, + portalKey: { id, receiver }, + receiver, + roomType: "dm", + }; +} + +function bindingFromPortal(portal: Portal): OpenClawSessionBinding | undefined { + const metadata = recordValue(portal.metadata)?.openclaw; + const openclaw = recordValue(metadata); + const roomId = portal.mxid; + const agentId = stringValue(openclaw?.agentId) ?? portal.id.replace(/^agent:/, ""); + const sessionKey = stringValue(openclaw?.sessionKey) ?? portal.id; + const ghostUserId = stringValue(openclaw?.ghostUserId); + if (!roomId || !agentId || !sessionKey || !ghostUserId) return undefined; + const now = Date.now(); + return { + agentId, + createdAt: now, + ghostUserId, + id: Buffer.from(roomId).toString("base64url"), + kind: "session", + owner: "bridge", + roomId, + sessionKey, + updatedAt: now, + }; +} + +function missingTransport(): OpenClawTransport { + return { + async *events() {}, + async request() { + throw new Error("OpenClaw transport is not configured"); + }, + }; +} + +function encodeLoginId(value: string): string { + return Buffer.from(value).toString("base64url").slice(0, 32); +} + +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/index.ts b/packages/openclaw/src/index.ts index adfd555..eba41bc 100644 --- a/packages/openclaw/src/index.ts +++ b/packages/openclaw/src/index.ts @@ -2,6 +2,7 @@ export * from "./approval"; export * from "./bridge-agent"; export * from "./cli"; export * from "./config"; +export * from "./connector"; export * from "./openclaw-event-map"; export * from "./openclaw-runtime"; export * from "./registry"; diff --git a/packages/openclaw/tsdown.config.ts b/packages/openclaw/tsdown.config.ts index 8b29f0b..7e88984 100644 --- a/packages/openclaw/tsdown.config.ts +++ b/packages/openclaw/tsdown.config.ts @@ -3,6 +3,6 @@ import { defineConfig } from "tsdown"; export default defineConfig({ clean: true, dts: true, - entry: ["src/approval.ts", "src/bridge-agent.ts", "src/cli.ts", "src/config.ts", "src/index.ts", "src/openclaw-event-map.ts", "src/openclaw-runtime.ts", "src/registry.ts", "src/registration.ts", "src/rooms.ts", "src/stream-map.ts", "src/types.ts"], + entry: ["src/approval.ts", "src/bridge-agent.ts", "src/cli.ts", "src/config.ts", "src/connector.ts", "src/index.ts", "src/openclaw-event-map.ts", "src/openclaw-runtime.ts", "src/registry.ts", "src/registration.ts", "src/rooms.ts", "src/stream-map.ts", "src/types.ts"], format: ["esm"], }); From 2974170338db5fd54717b22ca794bb0ae22c457d Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Sat, 16 May 2026 16:26:31 +0200 Subject: [PATCH 09/43] Add OpenClaw session backfill planning --- packages/openclaw/package.json | 4 + packages/openclaw/src/backfill.test.ts | 127 +++++++++++++++++++++ packages/openclaw/src/backfill.ts | 131 ++++++++++++++++++++++ packages/openclaw/src/index.ts | 1 + packages/openclaw/src/openclaw-runtime.ts | 71 ++++++++++++ packages/openclaw/tsdown.config.ts | 2 +- 6 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 packages/openclaw/src/backfill.test.ts create mode 100644 packages/openclaw/src/backfill.ts diff --git a/packages/openclaw/package.json b/packages/openclaw/package.json index b1cd8e5..5e20f4b 100644 --- a/packages/openclaw/package.json +++ b/packages/openclaw/package.json @@ -27,6 +27,10 @@ "types": "./dist/approval.d.mts", "import": "./dist/approval.mjs" }, + "./backfill": { + "types": "./dist/backfill.d.mts", + "import": "./dist/backfill.mjs" + }, "./bridge-agent": { "types": "./dist/bridge-agent.d.mts", "import": "./dist/bridge-agent.mjs" diff --git a/packages/openclaw/src/backfill.test.ts b/packages/openclaw/src/backfill.test.ts new file mode 100644 index 0000000..8c41d3d --- /dev/null +++ b/packages/openclaw/src/backfill.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildBackfillImport, discoverOneToOneSessions, isOneToOneSession } from "./backfill"; +import { createDefaultConfig } from "./config"; +import { OpenClawGatewayRuntime, type OpenClawTransport } from "./openclaw-runtime"; + +describe("OpenClaw backfill", () => { + it("discovers terminal, mac app, and DM-like sessions while skipping group sessions", async () => { + const runtime = runtimeWith({ + "sessions.list": { + sessions: [ + { key: "agent:main:terminal:local", origin: { surface: "terminal" } }, + { key: "agent:main:desktop:abc", origin: { surface: "mac-app" } }, + { chatType: "dm", key: "agent:main:whatsapp:user-1", lastTo: "user-1" }, + { chatType: "group", key: "agent:main:whatsapp:group-1", lastTo: "a,b" }, + ], + }, + }); + + await expect(discoverOneToOneSessions(runtime)).resolves.toEqual([ + { + agentId: "main", + label: "agent:main:terminal:local", + session: { key: "agent:main:terminal:local", origin: { surface: "terminal" } }, + sessionKey: "agent:main:terminal:local", + source: "terminal", + }, + { + agentId: "main", + label: "agent:main:desktop:abc", + session: { key: "agent:main:desktop:abc", origin: { surface: "mac-app" } }, + sessionKey: "agent:main:desktop:abc", + source: "mac-app", + }, + { + agentId: "main", + label: "agent:main:whatsapp:user-1", + session: { chatType: "dm", key: "agent:main:whatsapp:user-1", lastTo: "user-1" }, + sessionKey: "agent:main:whatsapp:user-1", + source: "unknown", + }, + ]); + }); + + it("builds import bindings and normalized Matrix backfill messages", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-16T12:00:00.000Z")); + const runtime = runtimeWith({ + "chat.history": { + messages: [ + { content: "hello", id: "m1", messageSeq: 1, role: "user" }, + { content: [{ text: "hi" }], id: "m2", messageSeq: 2, role: "assistant" }, + ], + }, + }); + try { + await expect(buildBackfillImport(runtime, createDefaultConfig({ dataDir: "/tmp/openclaw" }), { + agentId: "main", + label: "Terminal", + session: { key: "agent:main:terminal:local" }, + sessionKey: "agent:main:terminal:local", + source: "terminal", + }, { + limit: 50, + roomId: "!room:example.com", + })).resolves.toMatchObject({ + binding: { + agentId: "main", + ghostUserId: "@openclaw_agent_main:localhost", + label: "Terminal", + owner: "imported", + roomId: "!room:example.com", + sessionKey: "agent:main:terminal:local", + }, + messages: [ + { + content: { + body: "hello", + msgtype: "m.notice", + "com.beeper.openclaw.backfill": { messageSeq: 1, role: "user" }, + }, + id: "m1", + role: "user", + sender: "human", + seq: 1, + }, + { + content: { + body: "hi", + msgtype: "m.text", + "com.beeper.openclaw.backfill": { messageSeq: 2, role: "assistant" }, + }, + id: "m2", + role: "assistant", + sender: "agent", + seq: 2, + }, + ], + source: "terminal", + }); + expect(runtime.transport.request).toHaveBeenCalledWith("chat.history", { + limit: 50, + sessionKey: "agent:main:terminal:local", + }); + } finally { + vi.useRealTimers(); + } + }); + + it("classifies one-to-one sessions conservatively", () => { + expect(isOneToOneSession({ chatType: "direct", key: "agent:main:direct:user" })).toBe(true); + expect(isOneToOneSession({ key: "agent:main:whatsapp:user", lastTo: "user" })).toBe(true); + expect(isOneToOneSession({ chatType: "group", key: "agent:main:group", lastTo: "a,b" })).toBe(false); + }); +}); + +function runtimeWith(responses: Record): OpenClawGatewayRuntime & { + transport: OpenClawTransport & { request: ReturnType }; +} { + const transport = { + async *events() {}, + request: vi.fn(async (method: string) => responses[method]), + }; + return new OpenClawGatewayRuntime({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + transport, + }) as OpenClawGatewayRuntime & { transport: OpenClawTransport & { request: ReturnType } }; +} diff --git a/packages/openclaw/src/backfill.ts b/packages/openclaw/src/backfill.ts new file mode 100644 index 0000000..4f8c565 --- /dev/null +++ b/packages/openclaw/src/backfill.ts @@ -0,0 +1,131 @@ +import type { + OpenClawChatHistoryMessage, + OpenClawGatewayRuntime, + OpenClawListedSession, +} from "./openclaw-runtime"; +import { agentGhostUserId, bindingIdForRoom } from "./rooms"; +import type { OpenClawBridgeConfig, OpenClawSessionBinding } from "./types"; + +export interface OpenClawBackfillSession { + agentId: string; + label: string; + session: OpenClawListedSession; + sessionKey: string; + source: "terminal" | "mac-app" | "channel" | "unknown"; +} + +export interface OpenClawBackfillMessage { + content: Record; + id: string; + role: "assistant" | "system" | "tool" | "user" | string; + sender: "agent" | "human" | "system"; + seq: number; +} + +export interface OpenClawBackfillImport { + binding: OpenClawSessionBinding; + messages: OpenClawBackfillMessage[]; + source: OpenClawBackfillSession["source"]; +} + +export async function discoverOneToOneSessions(runtime: OpenClawGatewayRuntime): Promise { + const sessions = await runtime.listSessions({ includeArchived: true }); + return sessions.flatMap((session) => { + if (!isOneToOneSession(session)) return []; + const agentId = resolveAgentId(session); + return [{ + agentId, + label: session.displayName ?? session.derivedTitle ?? session.label ?? session.key, + session, + sessionKey: session.key, + source: sessionSource(session), + }]; + }); +} + +export async function buildBackfillImport( + runtime: OpenClawGatewayRuntime, + config: OpenClawBridgeConfig, + session: OpenClawBackfillSession, + options: { limit?: number; roomId: string } +): Promise { + const messages = (await runtime.loadHistory(session.sessionKey, options.limit)).map((message, index) => + normalizeHistoryMessage(message, index) + ); + return { + binding: { + agentId: session.agentId, + createdAt: Date.now(), + ghostUserId: agentGhostUserId(config, session.agentId), + id: bindingIdForRoom(options.roomId), + kind: "session", + label: session.label, + owner: "imported", + roomId: options.roomId, + sessionKey: session.sessionKey, + updatedAt: Date.now(), + }, + messages, + source: session.source, + }; +} + +export function isOneToOneSession(session: OpenClawListedSession): boolean { + const chatType = session.chatType?.toLowerCase(); + if (chatType && ["dm", "direct", "private", "one_to_one", "1:1"].includes(chatType)) return true; + if (session.lastTo && !session.lastTo.includes(",") && !session.lastTo.includes(" ")) return true; + const originType = stringValue(session.origin?.type) ?? stringValue(session.origin?.surface); + return originType === "terminal" || originType === "mac-app"; +} + +function normalizeHistoryMessage(message: OpenClawChatHistoryMessage, index: number): OpenClawBackfillMessage { + const role = typeof message.role === "string" ? message.role : "assistant"; + const text = contentText(message.content); + return { + content: { + body: text || JSON.stringify(message.content ?? message), + msgtype: role === "assistant" ? "m.text" : "m.notice", + "com.beeper.openclaw.backfill": { + messageSeq: message.messageSeq ?? index, + role, + }, + }, + id: typeof message.id === "string" ? message.id : `history_${index}`, + role, + sender: role === "assistant" || role === "tool" ? "agent" : role === "system" ? "system" : "human", + seq: typeof message.messageSeq === "number" ? message.messageSeq : index, + }; +} + +function resolveAgentId(session: OpenClawListedSession): string { + if (session.agentId) return session.agentId; + const match = /^agent:([^:]+)/.exec(session.key); + return match?.[1] ?? "main"; +} + +function sessionSource(session: OpenClawListedSession): OpenClawBackfillSession["source"] { + const originSurface = stringValue(session.origin?.surface) ?? stringValue(session.origin?.type); + if (originSurface === "terminal" || session.provider === "terminal") return "terminal"; + if (originSurface === "mac-app" || originSurface === "desktop" || session.provider === "mac-app") return "mac-app"; + if (session.lastChannel || session.lastProvider) return "channel"; + return "unknown"; +} + +function contentText(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content.map((part) => { + if (typeof part === "string") return part; + const record = recordValue(part); + return stringValue(record?.text) ?? stringValue(record?.content) ?? ""; + }).join(""); +} + +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/index.ts b/packages/openclaw/src/index.ts index eba41bc..6955305 100644 --- a/packages/openclaw/src/index.ts +++ b/packages/openclaw/src/index.ts @@ -1,4 +1,5 @@ export * from "./approval"; +export * from "./backfill"; export * from "./bridge-agent"; export * from "./cli"; export * from "./config"; diff --git a/packages/openclaw/src/openclaw-runtime.ts b/packages/openclaw/src/openclaw-runtime.ts index b738ae0..da88a06 100644 --- a/packages/openclaw/src/openclaw-runtime.ts +++ b/packages/openclaw/src/openclaw-runtime.ts @@ -53,6 +53,32 @@ export interface OpenClawRunRef { 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 class OpenClawGatewayRuntime { readonly config: OpenClawBridgeConfig; readonly transport: OpenClawTransport; @@ -90,6 +116,51 @@ export class OpenClawGatewayRuntime { }); } + 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), + 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: true }; if (options.timeoutMs !== undefined) requestOptions.timeoutMs = options.timeoutMs; diff --git a/packages/openclaw/tsdown.config.ts b/packages/openclaw/tsdown.config.ts index 7e88984..f2e9f3e 100644 --- a/packages/openclaw/tsdown.config.ts +++ b/packages/openclaw/tsdown.config.ts @@ -3,6 +3,6 @@ import { defineConfig } from "tsdown"; export default defineConfig({ clean: true, dts: true, - entry: ["src/approval.ts", "src/bridge-agent.ts", "src/cli.ts", "src/config.ts", "src/connector.ts", "src/index.ts", "src/openclaw-event-map.ts", "src/openclaw-runtime.ts", "src/registry.ts", "src/registration.ts", "src/rooms.ts", "src/stream-map.ts", "src/types.ts"], + entry: ["src/approval.ts", "src/backfill.ts", "src/bridge-agent.ts", "src/cli.ts", "src/config.ts", "src/connector.ts", "src/index.ts", "src/openclaw-event-map.ts", "src/openclaw-runtime.ts", "src/registry.ts", "src/registration.ts", "src/rooms.ts", "src/stream-map.ts", "src/types.ts"], format: ["esm"], }); From cbf35b6b2550c73f1114303dd6df52de8b1b8d5a Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Sat, 16 May 2026 16:28:55 +0200 Subject: [PATCH 10/43] Wire OpenClaw history into bridge backfill --- packages/openclaw/src/connector.test.ts | 44 +++++++++++++++++++++++++ packages/openclaw/src/connector.ts | 42 +++++++++++++++++++++-- packages/openclaw/vitest.config.ts | 8 +++++ 3 files changed, 92 insertions(+), 2 deletions(-) diff --git a/packages/openclaw/src/connector.test.ts b/packages/openclaw/src/connector.test.ts index 2f29b5a..8c6354b 100644 --- a/packages/openclaw/src/connector.test.ts +++ b/packages/openclaw/src/connector.test.ts @@ -192,6 +192,50 @@ describe("OpenClawBridgeConnector", () => { decision: "deny", }); }); + + it("fetches OpenClaw chat history for Pickle backfill", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); + const runtime = runtimeWith({ + responses: { + "chat.history": { + messages: [ + { content: "hello", id: "m1", messageSeq: 1, role: "user" }, + { content: "hi", id: "m2", messageSeq: 2, role: "assistant" }, + ], + }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime, + streams: { publish: vi.fn() }, + }); + const portal = { + id: "agent:codex", + metadata: { + openclaw: { + agentId: "codex", + ghostUserId: "@codex:example.com", + sessionKey: "agent:codex", + }, + }, + mxid: "!room:example.com", + portalKey: { id: "agent:codex", receiver: "login" }, + receiver: "login", + }; + + const response = await api.fetchMessages({} as BridgeRequestContext, { limit: 2, portal }); + expect(response.hasMore).toBe(false); + expect(response.messages).toHaveLength(2); + expect(response.messages.map((message) => message.event.getID())).toEqual(["m1", "m2"]); + expect(response.messages.map((message) => message.event.getSender().sender)).toEqual(["login:human", "codex"]); + expect(runtime.transport.request).toHaveBeenCalledWith("chat.history", { + limit: 2, + sessionKey: "agent:codex", + }); + }); }); function login(): UserLogin { diff --git a/packages/openclaw/src/connector.ts b/packages/openclaw/src/connector.ts index 5569a87..5aae6f0 100644 --- a/packages/openclaw/src/connector.ts +++ b/packages/openclaw/src/connector.ts @@ -1,9 +1,13 @@ -import type { +import { + createRemoteMessage, + type BackfillingNetworkAPI, BridgeConnector, BridgeContext, BridgeRequestContext, BridgeUser, ConnectContext, + FetchMessagesParams, + FetchMessagesResponse, IdentifierResolvingNetworkAPI, LoginCreateContext, LoginFlow, @@ -23,6 +27,7 @@ import type { ResolveIdentifierResponse, UserLogin, } from "@beeper/pickle-bridge"; +import { buildBackfillImport } from "./backfill"; import { parseApprovalResponseContent } from "./approval"; import { OpenClawMatrixBridgeAgent, type OpenClawBridgeStreamPublisher } from "./bridge-agent"; import { createDefaultConfig } from "./config"; @@ -194,7 +199,7 @@ export class OpenClawGatewayLoginProcess implements LoginProcess { } } -export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetworkAPI, MessageHandlingNetworkAPI, ReactionHandlingNetworkAPI { +export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetworkAPI, MessageHandlingNetworkAPI, ReactionHandlingNetworkAPI, BackfillingNetworkAPI { readonly #agent: OpenClawMatrixBridgeAgent; readonly #login: UserLogin; readonly #registry: OpenClawBridgeRegistry; @@ -268,6 +273,39 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor await this.#agent.handleApprovalContent(msg.content, approval.approvalId ?? msg.targetMessage.id); return { id: msg.event.eventId, metadata: { openclaw: { approval } } }; } + + async fetchMessages(_ctx: BridgeRequestContext, params: FetchMessagesParams): Promise { + const binding = bindingFromPortal(params.portal); + if (!binding) return { hasMore: false, messages: [] }; + const importOptions: { limit?: number; roomId: string } = { roomId: binding.roomId }; + const limit = params.limit ?? params.count; + if (limit !== undefined) importOptions.limit = limit; + const backfill = await buildBackfillImport(this.#runtime, this.#runtime.config, { + agentId: binding.agentId, + label: binding.label ?? binding.sessionKey, + session: { key: binding.sessionKey }, + sessionKey: binding.sessionKey, + source: binding.owner === "imported" ? "unknown" : "channel", + }, importOptions); + return { + hasMore: false, + messages: backfill.messages.map((message) => ({ + event: createRemoteMessage({ + convert: () => ({ + parts: [{ content: message.content, id: message.id, type: "m.text" }], + }), + data: message, + id: message.id, + portalKey: params.portal.portalKey, + sender: { + isFromMe: message.sender !== "agent", + sender: message.sender === "agent" ? binding.agentId : `${this.#login.id}:human`, + }, + timestamp: new Date(0), + }), + })), + }; + } } function portalForAgent(contact: OpenClawAgentContact, receiver: string): Portal { diff --git a/packages/openclaw/vitest.config.ts b/packages/openclaw/vitest.config.ts index bdbea6f..45fa348 100644 --- a/packages/openclaw/vitest.config.ts +++ b/packages/openclaw/vitest.config.ts @@ -1,6 +1,14 @@ import { defineProject } from "vitest/config"; export default defineProject({ + resolve: { + alias: { + "@beeper/pickle-bridge": new URL("../bridge/src/index.ts", import.meta.url).pathname, + "@beeper/pickle-state-file": new URL("../state-file/src/index.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, + }, + }, test: { coverage: { include: ["src/**/*.ts"], From 2ba589dd3e75fcc8cef6771736e0663ba5fe656c Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Sat, 16 May 2026 16:32:16 +0200 Subject: [PATCH 11/43] Add OpenClaw Beeper setup helpers --- packages/openclaw/package.json | 4 + packages/openclaw/src/beeper-setup.test.ts | 115 ++++++++++++++ packages/openclaw/src/beeper-setup.ts | 169 +++++++++++++++++++++ packages/openclaw/src/cli.ts | 123 +++++++++++++++ packages/openclaw/src/index.ts | 1 + packages/openclaw/tsdown.config.ts | 2 +- packages/openclaw/vitest.config.ts | 1 + 7 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 packages/openclaw/src/beeper-setup.test.ts create mode 100644 packages/openclaw/src/beeper-setup.ts diff --git a/packages/openclaw/package.json b/packages/openclaw/package.json index 5e20f4b..7003b5d 100644 --- a/packages/openclaw/package.json +++ b/packages/openclaw/package.json @@ -35,6 +35,10 @@ "types": "./dist/bridge-agent.d.mts", "import": "./dist/bridge-agent.mjs" }, + "./beeper-setup": { + "types": "./dist/beeper-setup.d.mts", + "import": "./dist/beeper-setup.mjs" + }, "./cli": { "types": "./dist/cli.d.mts", "import": "./dist/cli.mjs" diff --git a/packages/openclaw/src/beeper-setup.test.ts b/packages/openclaw/src/beeper-setup.test.ts new file mode 100644 index 0000000..64f317d --- /dev/null +++ b/packages/openclaw/src/beeper-setup.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import { + createOpenClawBeeperAppService, + loginToBeeperForOpenClaw, + setupOpenClawBeeperBridge, +} from "./beeper-setup"; + +describe("OpenClaw Beeper setup", () => { + 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", + 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: "openclaw" }, + }), + ]); + expect(result.config).toEqual({ + accessToken: "mx-token", + homeserver: "https://matrix.beeper.com", + }); + }); + + it("registers the OpenClaw Beeper appservice with self-hosted defaults", async () => { + const seen: unknown[] = []; + const result = await createOpenClawBeeperAppService({ + accessToken: "mx-token", + createAppServiceInit: async (options) => { + seen.push(options); + return { + homeserver: "https://matrix.beeper.com/_hungryserv/batuhan", + homeserverDomain: "beeper.local", + registration: { + asToken: "as", + hsToken: "hs", + id: "openclaw", + namespaces: { aliases: [], rooms: [], users: [] }, + senderLocalpart: "openclawbot", + url: "http://127.0.0.1:29391", + }, + }; + }, + }); + + expect(seen).toEqual([ + expect.objectContaining({ + address: "http://127.0.0.1:29391", + bridge: "openclaw", + bridgeType: "openclaw", + selfHosted: true, + token: "mx-token", + }), + ]); + expect(result.config).toEqual({ + appserviceId: "openclaw", + homeserver: "https://matrix.beeper.com/_hungryserv/batuhan", + hsToken: "hs", + registrationUrl: "http://127.0.0.1:29391", + }); + }); + + it("combines Beeper login and appservice registration config", async () => { + const result = await setupOpenClawBeeperBridge({ + email: "batuhan@example.com", + env: "staging", + getLoginCode: () => "123456", + 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", + homeserver: "https://matrix.beeper-staging.com", + token: "mx-token", + }); + return { + homeserver: "https://matrix.beeper-staging.com/_hungryserv/batuhan", + registration: { + asToken: "as", + hsToken: "hs", + id: "openclaw", + namespaces: { aliases: [], rooms: [], users: [] }, + senderLocalpart: "openclawbot", + url: "http://127.0.0.1:29391", + }, + }; + }, + }); + + expect(result.config).toEqual({ + accessToken: "mx-token", + appserviceId: "openclaw", + homeserver: "https://matrix.beeper-staging.com/_hungryserv/batuhan", + hsToken: "hs", + registrationUrl: "http://127.0.0.1:29391", + }); + }); +}); diff --git a/packages/openclaw/src/beeper-setup.ts b/packages/openclaw/src/beeper-setup.ts new file mode 100644 index 0000000..9dde92d --- /dev/null +++ b/packages/openclaw/src/beeper-setup.ts @@ -0,0 +1,169 @@ +import type { MatrixAppserviceInitOptions } from "@beeper/pickle"; +import { createBeeperLogin, type BeeperAuthOptions, type BeeperEnvironment } from "@beeper/pickle/beeper/auth"; +import { createBeeperAppServiceInit, type CreateAppServiceOptions } from "@beeper/pickle-bridge"; +import { DEFAULT_REGISTRATION_URL } from "./config"; +import type { OpenClawBridgeConfig } from "./types"; + +export const DEFAULT_BEEPER_BRIDGE = "openclaw"; +export const DEFAULT_BEEPER_BRIDGE_TYPE = "openclaw"; + +export interface BeeperSetupAccount { + accessToken: string; + deviceId: string; + homeserver: string; + userId: string; +} + +export interface BeeperLoginForOpenClawOptions { + email: string; + env?: BeeperEnvironment; + fetch?: typeof fetch; + getLoginCode?: () => Promise | string; + initialDeviceDisplayName?: string; + login?: (options: BeeperAuthOptions) => Promise; + metadata?: Record; +} + +export interface BeeperLoginForOpenClawResult { + account: BeeperSetupAccount; + config: Pick; +} + +export interface CreateOpenClawBeeperAppServiceOptions { + accessToken: string; + address?: string; + baseDomain?: string; + bridge?: string; + bridgeType?: string; + createAppServiceInit?: (options: CreateOpenClawBeeperAppServiceRequest) => Promise; + fetch?: typeof fetch; + getOnly?: boolean; + homeserver?: string; + homeserverDomain?: string; + postState?: boolean; + push?: boolean; + selfHosted?: boolean; + 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 { + address?: string; + baseDomain?: string; + bridge?: string; + bridgeType?: string; + createAppServiceInit?: CreateOpenClawBeeperAppServiceOptions["createAppServiceInit"]; + getOnly?: boolean; + homeserverDomain?: string; + postState?: boolean; + push?: boolean; + selfHosted?: boolean; + username?: string; +} + +export interface SetupOpenClawBeeperBridgeResult { + account: BeeperSetupAccount; + config: Pick; + init: MatrixAppserviceInitOptions; +} + +export async function loginToBeeperForOpenClaw(options: BeeperLoginForOpenClawOptions): Promise { + const login = options.login ?? createBeeperLogin; + const request: BeeperAuthOptions = { + email: options.email, + initialDeviceDisplayName: options.initialDeviceDisplayName ?? "Pickle OpenClaw", + metadata: { ...options.metadata, bridge: DEFAULT_BEEPER_BRIDGE }, + }; + if (options.env !== undefined) request.env = options.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: { + accessToken: account.accessToken, + homeserver: account.homeserver, + }, + }; +} + +export async function createOpenClawBeeperAppService( + options: CreateOpenClawBeeperAppServiceOptions +): Promise { + const createInit = options.createAppServiceInit ?? createBeeperAppServiceInit; + const request: CreateOpenClawBeeperAppServiceRequest = { + address: options.address ?? DEFAULT_REGISTRATION_URL, + bridge: options.bridge ?? DEFAULT_BEEPER_BRIDGE, + bridgeType: options.bridgeType ?? DEFAULT_BEEPER_BRIDGE_TYPE, + selfHosted: options.selfHosted ?? true, + token: options.accessToken, + }; + if (options.baseDomain !== undefined) request.baseDomain = options.baseDomain; + if (options.fetch !== undefined) request.fetch = options.fetch; + if (options.getOnly !== undefined) request.getOnly = options.getOnly; + if (options.homeserver !== undefined) request.homeserver = options.homeserver; + if (options.homeserverDomain !== undefined) request.homeserverDomain = options.homeserverDomain; + if (options.postState !== undefined) request.postState = options.postState; + if (options.push !== undefined) request.push = options.push; + if (options.username !== undefined) request.username = options.username; + const init = await createInit(request); + return { + config: { + appserviceId: init.registration.id, + homeserver: init.homeserver, + hsToken: init.registration.hsToken, + registrationUrl: options.address ?? init.registration.url ?? DEFAULT_REGISTRATION_URL, + }, + init, + }; +} + +export async function setupOpenClawBeeperBridge( + options: SetupOpenClawBeeperBridgeOptions +): Promise { + const login = await loginToBeeperForOpenClaw(options); + const appserviceOptions: CreateOpenClawBeeperAppServiceOptions = { + accessToken: login.account.accessToken, + homeserver: login.account.homeserver, + }; + const baseDomain = options.baseDomain ?? beeperBaseDomain(options.env); + if (options.address !== undefined) appserviceOptions.address = options.address; + if (baseDomain !== undefined) appserviceOptions.baseDomain = baseDomain; + if (options.bridge !== undefined) appserviceOptions.bridge = options.bridge; + if (options.bridgeType !== undefined) appserviceOptions.bridgeType = options.bridgeType; + if (options.createAppServiceInit !== undefined) appserviceOptions.createAppServiceInit = options.createAppServiceInit; + if (options.fetch !== undefined) appserviceOptions.fetch = options.fetch; + if (options.getOnly !== undefined) appserviceOptions.getOnly = options.getOnly; + if (options.homeserverDomain !== undefined) appserviceOptions.homeserverDomain = options.homeserverDomain; + if (options.postState !== undefined) appserviceOptions.postState = options.postState; + if (options.push !== undefined) appserviceOptions.push = options.push; + if (options.selfHosted !== undefined) appserviceOptions.selfHosted = options.selfHosted; + if (options.username !== undefined) appserviceOptions.username = options.username; + 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"; +} diff --git a/packages/openclaw/src/cli.ts b/packages/openclaw/src/cli.ts index fb50e09..b3b773c 100644 --- a/packages/openclaw/src/cli.ts +++ b/packages/openclaw/src/cli.ts @@ -1,6 +1,8 @@ #!/usr/bin/env node import { chmod, mkdir, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; +import type { BeeperEnvironment } from "@beeper/pickle/beeper/auth"; +import { createOpenClawBeeperAppService, loginToBeeperForOpenClaw, setupOpenClawBeeperBridge } from "./beeper-setup"; import { createDefaultConfig, defaultConfigPath, readConfig, secretToken, writeConfig } from "./config"; import { createAppserviceRegistration } from "./registration"; import type { AppserviceRegistration, OpenClawBridgeConfig } from "./types"; @@ -41,6 +43,96 @@ export async function runCli(argv = process.argv.slice(2), io: CliIO = process): io.stdout.write(`${JSON.stringify(redactConfig(config), null, 2)}\n`); return 0; } + if (command === "beeper-login") { + const options = parseOptions(args); + const email = requiredStringOption(options, "email"); + const loginCode = stringOption(options, "login-code"); + const loginOptions: Parameters[0] = { + email, + }; + const env = beeperEnvOption(options); + if (env !== undefined) loginOptions.env = env; + if (loginCode !== undefined) loginOptions.getLoginCode = () => loginCode; + const result = await loginToBeeperForOpenClaw(loginOptions); + const config = createDefaultConfig({ + ...configOverridesFromOptions(options), + ...result.config, + }); + await writeConfig(config, stringOption(options, "config") ?? defaultConfigPath(config.dataDir)); + io.stdout.write(`${JSON.stringify(redactConfig(config), null, 2)}\n`); + return 0; + } + if (command === "beeper-register") { + const options = parseOptions(args); + const configPath = stringOption(options, "config"); + const existingConfig = configPath ? await readConfig(configPath) : createDefaultConfig(configOverridesFromOptions(options)); + const accessToken = stringOption(options, "access-token") ?? existingConfig.accessToken; + if (!accessToken) throw new Error("beeper-register requires --access-token or a config with accessToken"); + const registerOptions: Parameters[0] = { + accessToken, + address: stringOption(options, "registration-url") ?? existingConfig.registrationUrl, + getOnly: booleanOption(options, "get-only"), + postState: !booleanOption(options, "no-post-state"), + push: booleanOption(options, "push"), + selfHosted: !booleanOption(options, "not-self-hosted"), + }; + const baseDomain = stringOption(options, "base-domain") ?? beeperBaseDomainOption(options); + const bridge = stringOption(options, "bridge"); + const bridgeType = stringOption(options, "bridge-type"); + const homeserver = stringOption(options, "homeserver") ?? existingConfig.homeserver; + const homeserverDomain = stringOption(options, "homeserver-domain"); + const username = stringOption(options, "username"); + if (baseDomain !== undefined) registerOptions.baseDomain = baseDomain; + if (bridge !== undefined) registerOptions.bridge = bridge; + if (bridgeType !== undefined) registerOptions.bridgeType = bridgeType; + if (homeserver !== undefined) registerOptions.homeserver = homeserver; + if (homeserverDomain !== undefined) registerOptions.homeserverDomain = homeserverDomain; + if (username !== undefined) registerOptions.username = username; + const result = await createOpenClawBeeperAppService(registerOptions); + const config = createDefaultConfig({ + ...existingConfig, + ...configOverridesFromOptions(options), + ...result.config, + accessToken, + }); + await writeConfig(config, configPath ?? defaultConfigPath(config.dataDir)); + io.stdout.write(`${JSON.stringify({ config: redactConfig(config), init: result.init }, null, 2)}\n`); + return 0; + } + if (command === "beeper-setup") { + const options = parseOptions(args); + const email = requiredStringOption(options, "email"); + const loginCode = stringOption(options, "login-code"); + const setupOptions: Parameters[0] = { + email, + postState: !booleanOption(options, "no-post-state"), + push: booleanOption(options, "push"), + selfHosted: !booleanOption(options, "not-self-hosted"), + }; + const address = stringOption(options, "registration-url"); + const baseDomain = stringOption(options, "base-domain") ?? beeperBaseDomainOption(options); + const bridge = stringOption(options, "bridge"); + const bridgeType = stringOption(options, "bridge-type"); + const env = beeperEnvOption(options); + const homeserverDomain = stringOption(options, "homeserver-domain"); + const username = stringOption(options, "username"); + if (address !== undefined) setupOptions.address = address; + if (baseDomain !== undefined) setupOptions.baseDomain = baseDomain; + if (bridge !== undefined) setupOptions.bridge = bridge; + if (bridgeType !== undefined) setupOptions.bridgeType = bridgeType; + if (env !== undefined) setupOptions.env = env; + if (loginCode !== undefined) setupOptions.getLoginCode = () => loginCode; + if (homeserverDomain !== undefined) setupOptions.homeserverDomain = homeserverDomain; + if (username !== undefined) setupOptions.username = username; + const result = await setupOpenClawBeeperBridge(setupOptions); + const config = createDefaultConfig({ + ...configOverridesFromOptions(options), + ...result.config, + }); + await writeConfig(config, stringOption(options, "config") ?? defaultConfigPath(config.dataDir)); + io.stdout.write(`${JSON.stringify({ config: redactConfig(config), init: result.init }, null, 2)}\n`); + return 0; + } io.stderr.write(`Unknown command: ${command}\n\n${helpText()}`); return 2; } catch (error) { @@ -63,6 +155,9 @@ function helpText(): string { " init Write a secure OpenClaw bridge config", " register Write a Matrix appservice registration file", " status Print the redacted effective config", + " beeper-login Log in to Beeper and write Matrix credentials", + " beeper-register Register the OpenClaw appservice with Beeper", + " beeper-setup Log in and register the OpenClaw appservice", "", "Common options:", " --config ", @@ -74,6 +169,9 @@ function helpText(): string { " --hs-token ", " --as-token ", " --output ", + " --email
", + " --login-code ", + " --env ", "", ].join("\n"); } @@ -131,6 +229,31 @@ function stringOption(options: Map, key: string): stri return typeof value === "string" ? value : undefined; } +function requiredStringOption(options: Map, key: string): string { + const value = stringOption(options, key); + if (!value) throw new Error(`Missing required option --${key}`); + return value; +} + +function booleanOption(options: Map, key: string): boolean { + return options.get(key) === true; +} + +function beeperEnvOption(options: Map): BeeperEnvironment | undefined { + const env = stringOption(options, "env"); + if (env === undefined) return undefined; + if (env === "production" || env === "staging" || env === "dev" || env === "local") return env; + throw new Error(`Invalid --env: ${env}`); +} + +function beeperBaseDomainOption(options: Map): string | undefined { + const env = beeperEnvOption(options); + if (env === "dev") return "beeper-dev.com"; + if (env === "local") return "beeper.localtest.me"; + if (env === "staging") return "beeper-staging.com"; + return undefined; +} + if (import.meta.url === `file://${process.argv[1]}`) { runCli().then((code) => { process.exitCode = code; diff --git a/packages/openclaw/src/index.ts b/packages/openclaw/src/index.ts index 6955305..2080ac4 100644 --- a/packages/openclaw/src/index.ts +++ b/packages/openclaw/src/index.ts @@ -1,5 +1,6 @@ export * from "./approval"; export * from "./backfill"; +export * from "./beeper-setup"; export * from "./bridge-agent"; export * from "./cli"; export * from "./config"; diff --git a/packages/openclaw/tsdown.config.ts b/packages/openclaw/tsdown.config.ts index f2e9f3e..9f8f41b 100644 --- a/packages/openclaw/tsdown.config.ts +++ b/packages/openclaw/tsdown.config.ts @@ -3,6 +3,6 @@ import { defineConfig } from "tsdown"; export default defineConfig({ clean: true, dts: true, - entry: ["src/approval.ts", "src/backfill.ts", "src/bridge-agent.ts", "src/cli.ts", "src/config.ts", "src/connector.ts", "src/index.ts", "src/openclaw-event-map.ts", "src/openclaw-runtime.ts", "src/registry.ts", "src/registration.ts", "src/rooms.ts", "src/stream-map.ts", "src/types.ts"], + entry: ["src/approval.ts", "src/backfill.ts", "src/beeper-setup.ts", "src/bridge-agent.ts", "src/cli.ts", "src/config.ts", "src/connector.ts", "src/index.ts", "src/openclaw-event-map.ts", "src/openclaw-runtime.ts", "src/registry.ts", "src/registration.ts", "src/rooms.ts", "src/stream-map.ts", "src/types.ts"], format: ["esm"], }); diff --git a/packages/openclaw/vitest.config.ts b/packages/openclaw/vitest.config.ts index 45fa348..63ab85c 100644 --- a/packages/openclaw/vitest.config.ts +++ b/packages/openclaw/vitest.config.ts @@ -5,6 +5,7 @@ export default defineProject({ alias: { "@beeper/pickle-bridge": new URL("../bridge/src/index.ts", import.meta.url).pathname, "@beeper/pickle-state-file": new URL("../state-file/src/index.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, }, From 4918041399ce554f7fc4d63ede82e28f23cb7444 Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Sat, 16 May 2026 16:33:35 +0200 Subject: [PATCH 12/43] Add OpenClaw HTTP gateway transport --- packages/openclaw/src/connector.ts | 19 +- .../openclaw/src/openclaw-runtime.test.ts | 77 +++++++- packages/openclaw/src/openclaw-runtime.ts | 174 ++++++++++++++++++ 3 files changed, 260 insertions(+), 10 deletions(-) diff --git a/packages/openclaw/src/connector.ts b/packages/openclaw/src/connector.ts index 5aae6f0..1489e15 100644 --- a/packages/openclaw/src/connector.ts +++ b/packages/openclaw/src/connector.ts @@ -31,7 +31,7 @@ import { buildBackfillImport } from "./backfill"; import { parseApprovalResponseContent } from "./approval"; import { OpenClawMatrixBridgeAgent, type OpenClawBridgeStreamPublisher } from "./bridge-agent"; import { createDefaultConfig } from "./config"; -import { OpenClawGatewayRuntime, type OpenClawTransport } from "./openclaw-runtime"; +import { createOpenClawHttpTransport, OpenClawGatewayRuntime, type OpenClawTransport } from "./openclaw-runtime"; import { OpenClawBridgeRegistry } from "./registry"; import { agentContactFromOpenClawAgent } from "./rooms"; import type { OpenClawAgentContact, OpenClawBridgeConfig, OpenClawSessionBinding } from "./types"; @@ -62,7 +62,7 @@ export class OpenClawBridgeConnector implements BridgeConnector new OpenClawGatewayRuntime({ config, - transport: options.transportFactory?.(login, config) ?? missingTransport(), + transport: options.transportFactory?.(login, config) ?? transportFromLogin(login, config), })); } @@ -347,13 +347,14 @@ function bindingFromPortal(portal: Portal): OpenClawSessionBinding | undefined { }; } -function missingTransport(): OpenClawTransport { - return { - async *events() {}, - async request() { - throw new Error("OpenClaw transport is not configured"); - }, - }; +function transportFromLogin(login: UserLogin, config: OpenClawBridgeConfig): OpenClawTransport { + const metadata = recordValue(login.metadata); + const gatewayUrl = stringValue(metadata?.gatewayUrl) ?? config.gatewayUrl; + if (!gatewayUrl) throw new Error("OpenClaw gateway URL is not configured"); + const options: Parameters[0] = { url: gatewayUrl }; + const accessToken = stringValue(metadata?.accessToken) ?? config.accessToken; + if (accessToken !== undefined) options.accessToken = accessToken; + return createOpenClawHttpTransport(options); } function encodeLoginId(value: string): string { diff --git a/packages/openclaw/src/openclaw-runtime.test.ts b/packages/openclaw/src/openclaw-runtime.test.ts index e5333c5..4617872 100644 --- a/packages/openclaw/src/openclaw-runtime.test.ts +++ b/packages/openclaw/src/openclaw-runtime.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it, vi } from "vitest"; import { createDefaultConfig } from "./config"; -import { OpenClawGatewayRuntime, type OpenClawGatewayEvent, type OpenClawTransport } from "./openclaw-runtime"; +import { + OpenClawGatewayRuntime, + createOpenClawHttpTransport, + type OpenClawGatewayEvent, + type OpenClawTransport, +} from "./openclaw-runtime"; describe("OpenClawGatewayRuntime", () => { it("lists OpenClaw agents as Matrix ghost contacts", async () => { @@ -74,6 +79,76 @@ describe("OpenClawGatewayRuntime", () => { decision: "approve", }); }); + + it("sends OpenClaw requests over the HTTP gateway transport", async () => { + const requests: Array<{ body: unknown; headers: Headers; url: string }> = []; + const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ + body: JSON.parse(String(init?.body)), + headers: new Headers(init?.headers), + url: String(input), + }); + return new Response(JSON.stringify({ result: { runId: "run_1" } }), { status: 200 }); + }); + const transport = createOpenClawHttpTransport({ + accessToken: "secret", + fetch: fetchImpl, + url: "ws://127.0.0.1:29390/openclaw", + }); + + await expect(transport.request("sessions.send", { key: "session", message: "hi" }, { expectFinal: true })).resolves.toEqual({ + runId: "run_1", + }); + expect(requests).toEqual([ + { + body: { + expectFinal: true, + method: "sessions.send", + params: { key: "session", message: "hi" }, + }, + headers: expect.any(Headers), + url: "http://127.0.0.1:29390/openclaw/rpc", + }, + ]); + expect(requests[0]?.headers.get("authorization")).toBe("Bearer secret"); + }); + + it("streams OpenClaw gateway events from SSE frames", async () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode([ + "event: assistant.delta", + "data: {\"payload\":{\"runId\":\"skip\",\"delta\":\"no\"}}", + "", + "event: assistant.delta", + "data: {\"payload\":{\"runId\":\"run_1\",\"delta\":\"yes\"},\"seq\":2}", + "", + "", + ].join("\n"))); + controller.close(); + }, + }); + const transport = createOpenClawHttpTransport({ + fetch: vi.fn(async () => new Response(stream, { status: 200 })), + url: "http://gateway", + }); + + const events: OpenClawGatewayEvent[] = []; + for await (const event of transport.events((candidate) => { + const payload = candidate.payload as { runId?: string }; + return payload.runId === "run_1"; + })) { + events.push(event); + } + + expect(events).toEqual([ + { + event: "assistant.delta", + payload: { runId: "run_1", delta: "yes" }, + seq: 2, + }, + ]); + }); }); function fakeTransport(responses: Record, events: OpenClawGatewayEvent[] = []): OpenClawTransport & { diff --git a/packages/openclaw/src/openclaw-runtime.ts b/packages/openclaw/src/openclaw-runtime.ts index da88a06..706a52b 100644 --- a/packages/openclaw/src/openclaw-runtime.ts +++ b/packages/openclaw/src/openclaw-runtime.ts @@ -20,6 +20,14 @@ export interface OpenClawTransport { request(method: string, params?: unknown, options?: GatewayRequestOptions): Promise; } +export interface OpenClawHttpTransportOptions { + accessToken?: string; + eventsPath?: string; + fetch?: typeof fetch; + requestPath?: string; + url: string; +} + export interface OpenClawSessionCreateOptions { agentId: string; key?: string; @@ -194,6 +202,79 @@ export class OpenClawGatewayRuntime { } } +export class OpenClawHttpTransport implements OpenClawTransport { + readonly #accessToken: string | undefined; + readonly #baseUrl: URL; + readonly #eventsPath: string; + readonly #fetch: typeof fetch; + readonly #requestPath: string; + #abortController = new AbortController(); + + constructor(options: OpenClawHttpTransportOptions) { + this.#accessToken = options.accessToken; + this.#baseUrl = normalizeGatewayUrl(options.url); + this.#eventsPath = options.eventsPath ?? "/events"; + this.#fetch = options.fetch ?? fetch; + this.#requestPath = options.requestPath ?? "/rpc"; + } + + async request(method: string, params?: unknown, options: GatewayRequestOptions = {}): Promise { + const abort = new AbortController(); + const timeout = options.timeoutMs == null ? undefined : setTimeout(() => abort.abort(), options.timeoutMs); + try { + const response = await this.#fetch(endpointUrl(this.#baseUrl, this.#requestPath), { + body: JSON.stringify(stripUndefined({ + expectFinal: options.expectFinal, + method, + params: params ?? {}, + })), + headers: { + ...this.#headers("application/json"), + "content-type": "application/json", + }, + method: "POST", + signal: abort.signal, + }); + const raw = await readGatewayResponse(response); + const record = recordValue(raw); + if (record?.error !== undefined) throw new Error(`OpenClaw gateway ${method} failed: ${errorMessage(record.error)}`); + return (record && "result" in record ? record.result : raw) as T; + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } + } + + async *events(filter?: (event: OpenClawGatewayEvent) => boolean): AsyncIterable { + const response = await this.#fetch(endpointUrl(this.#baseUrl, this.#eventsPath), { + headers: this.#headers("text/event-stream"), + method: "GET", + signal: this.#abortController.signal, + }); + if (!response.ok) throw new Error(`OpenClaw gateway events failed (${response.status}): ${await response.text()}`); + const stream = response.body; + if (!stream) return; + for await (const event of parseEventStream(stream)) { + if (!filter || filter(event)) yield event; + } + } + + close(): void { + this.#abortController.abort(); + this.#abortController = new AbortController(); + } + + #headers(accept: string): Record { + return stripUndefined({ + accept, + authorization: this.#accessToken ? `Bearer ${this.#accessToken}` : undefined, + }); + } +} + +export function createOpenClawHttpTransport(options: OpenClawHttpTransportOptions): OpenClawHttpTransport { + return new OpenClawHttpTransport(options); +} + function arrayValue(value: unknown): unknown[] | undefined { return Array.isArray(value) ? value : undefined; } @@ -207,6 +288,99 @@ function stringValue(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } +async function readGatewayResponse(response: Response): Promise { + const text = await response.text(); + if (!response.ok) throw new Error(`OpenClaw gateway request failed (${response.status}): ${text || response.statusText}`); + return text ? JSON.parse(text) : undefined; +} + +function normalizeGatewayUrl(value: string): URL { + const url = new URL(value); + if (url.protocol === "ws:") url.protocol = "http:"; + if (url.protocol === "wss:") url.protocol = "https:"; + return url; +} + +function endpointUrl(baseUrl: URL, path: string): URL { + if (/^https?:\/\//.test(path)) return new URL(path); + const base = new URL(baseUrl); + base.pathname = joinPath(base.pathname, path); + base.search = ""; + base.hash = ""; + return base; +} + +function joinPath(basePath: string, path: string): string { + const base = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath; + const next = path.startsWith("/") ? path : `/${path}`; + return `${base}${next}` || "/"; +} + +async function* parseEventStream(stream: ReadableStream): AsyncIterable { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let split = eventBoundary(buffer); + while (split >= 0) { + const frame = buffer.slice(0, split); + buffer = buffer.slice(split + frameBoundaryLength(buffer, split)); + const event = parseEventFrame(frame); + if (event) yield event; + split = eventBoundary(buffer); + } + } + buffer += decoder.decode(); + const event = parseEventFrame(buffer); + if (event) yield event; + } finally { + reader.releaseLock(); + } +} + +function eventBoundary(value: string): number { + const lf = value.indexOf("\n\n"); + const crlf = value.indexOf("\r\n\r\n"); + if (lf < 0) return crlf; + if (crlf < 0) return lf; + return Math.min(lf, crlf); +} + +function frameBoundaryLength(value: string, index: number): number { + return value.slice(index, index + 4) === "\r\n\r\n" ? 4 : 2; +} + +function parseEventFrame(frame: string): OpenClawGatewayEvent | undefined { + const lines = frame.split(/\r?\n/); + let event: string | undefined; + const data: string[] = []; + for (const line of lines) { + if (line.startsWith("event:")) event = line.slice("event:".length).trim(); + if (line.startsWith("data:")) data.push(line.slice("data:".length).trimStart()); + } + if (data.length === 0) return undefined; + const payload = JSON.parse(data.join("\n")) as unknown; + const record = recordValue(payload); + if (record && ("event" in record || "payload" in record || "seq" in record)) { + return stripUndefined({ + event: stringValue(record.event) ?? event, + payload: record.payload ?? payload, + seq: typeof record.seq === "number" ? record.seq : undefined, + stateVersion: record.stateVersion, + }); + } + return stripUndefined({ event, payload }); +} + +function errorMessage(error: unknown): string { + const record = recordValue(error); + return stringValue(record?.message) ?? stringValue(error) ?? JSON.stringify(error); +} + type StripUndefined = { [K in keyof T as undefined extends T[K] ? never : K]: T[K]; } & { From 5922ca48fe2c6f145ecff6ce63c725472e70b80b Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Sat, 16 May 2026 16:34:50 +0200 Subject: [PATCH 13/43] Add OpenClaw Beeper bridge runtime --- packages/openclaw/package.json | 4 ++ packages/openclaw/src/appservice.test.ts | 61 ++++++++++++++++++++++++ packages/openclaw/src/appservice.ts | 51 ++++++++++++++++++++ packages/openclaw/src/index.ts | 1 + packages/openclaw/tsdown.config.ts | 2 +- 5 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 packages/openclaw/src/appservice.test.ts create mode 100644 packages/openclaw/src/appservice.ts diff --git a/packages/openclaw/package.json b/packages/openclaw/package.json index 7003b5d..bb46987 100644 --- a/packages/openclaw/package.json +++ b/packages/openclaw/package.json @@ -27,6 +27,10 @@ "types": "./dist/approval.d.mts", "import": "./dist/approval.mjs" }, + "./appservice": { + "types": "./dist/appservice.d.mts", + "import": "./dist/appservice.mjs" + }, "./backfill": { "types": "./dist/backfill.d.mts", "import": "./dist/backfill.mjs" diff --git a/packages/openclaw/src/appservice.test.ts b/packages/openclaw/src/appservice.test.ts new file mode 100644 index 0000000..30500bd --- /dev/null +++ b/packages/openclaw/src/appservice.test.ts @@ -0,0 +1,61 @@ +import type { CreateNodeBeeperBridgeOptions, PickleBridge } from "@beeper/pickle-bridge"; +import { describe, expect, it, vi } from "vitest"; +import { createDefaultConfig } from "./config"; +import { createOpenClawBeeperBridge, startOpenClawBeeperBridge } from "./appservice"; + +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", + registrationUrl: "http://127.0.0.1:29391", + }); + + await expect(createOpenClawBeeperBridge({ + account: account(), + bridgeFactory, + config, + dataDir: "/tmp/openclaw-data", + getOnly: true, + })).resolves.toBe(bridge); + + expect(bridgeFactory).toHaveBeenCalledWith(expect.objectContaining({ + account: account(), + address: "http://127.0.0.1:29391", + bridge: "openclaw", + bridgeType: "openclaw", + connector: expect.objectContaining({ + config, + }), + dataDir: "/tmp/openclaw-data", + getOnly: true, + })); + }); + + it("starts the created bridge", async () => { + const bridge = fakeBridge(); + await expect(startOpenClawBeeperBridge({ + account: account(), + bridgeFactory: async () => bridge, + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + })).resolves.toBe(bridge); + expect(bridge.start).toHaveBeenCalledOnce(); + }); +}); + +function account() { + return { + accessToken: "mx-token", + deviceId: "DEVICE", + homeserver: "https://matrix.beeper.com", + userId: "@batuhan:beeper.com", + }; +} + +function fakeBridge(): PickleBridge { + return { + 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..5b2a42d --- /dev/null +++ b/packages/openclaw/src/appservice.ts @@ -0,0 +1,51 @@ +import type { MatrixAccount } from "@beeper/pickle"; +import { createBeeperBridge, type CreateNodeBeeperBridgeOptions, type PickleBridge } from "@beeper/pickle-bridge"; +import { DEFAULT_BEEPER_BRIDGE, DEFAULT_BEEPER_BRIDGE_TYPE } from "./beeper-setup"; +import { createOpenClawConnector, type OpenClawConnectorOptions } from "./connector"; +import type { OpenClawBridgeConfig } from "./types"; + +export interface CreateOpenClawBeeperBridgeOptions extends OpenClawConnectorOptions { + account: MatrixAccount; + bridge?: string; + bridgeFactory?: (options: CreateNodeBeeperBridgeOptions) => Promise; + bridgeType?: string; + connector?: CreateNodeBeeperBridgeOptions["connector"]; + dataDir?: string; + getOnly?: boolean; + 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 = { + account: options.account, + bridge: options.bridge ?? DEFAULT_BEEPER_BRIDGE, + bridgeType: options.bridgeType ?? DEFAULT_BEEPER_BRIDGE_TYPE, + connector, + }; + if (config?.registrationUrl !== undefined) bridgeOptions.address = config.registrationUrl; + if (options.dataDir !== undefined) bridgeOptions.dataDir = options.dataDir; + if (options.getOnly !== undefined) bridgeOptions.getOnly = options.getOnly; + if (options.matrix !== undefined) bridgeOptions.matrix = options.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(); + return bridge; +} + +function connectorOptions(options: CreateOpenClawBeeperBridgeOptions): OpenClawConnectorOptions { + const output: OpenClawConnectorOptions = {}; + if (options.config !== undefined) output.config = options.config; + if (options.registry !== undefined) output.registry = options.registry; + if (options.runtimeFactory !== undefined) output.runtimeFactory = options.runtimeFactory; + if (options.streams !== undefined) output.streams = options.streams; + if (options.transportFactory !== undefined) output.transportFactory = options.transportFactory; + return output; +} diff --git a/packages/openclaw/src/index.ts b/packages/openclaw/src/index.ts index 2080ac4..259ec7e 100644 --- a/packages/openclaw/src/index.ts +++ b/packages/openclaw/src/index.ts @@ -1,4 +1,5 @@ export * from "./approval"; +export * from "./appservice"; export * from "./backfill"; export * from "./beeper-setup"; export * from "./bridge-agent"; diff --git a/packages/openclaw/tsdown.config.ts b/packages/openclaw/tsdown.config.ts index 9f8f41b..fc9436e 100644 --- a/packages/openclaw/tsdown.config.ts +++ b/packages/openclaw/tsdown.config.ts @@ -3,6 +3,6 @@ import { defineConfig } from "tsdown"; export default defineConfig({ clean: true, dts: true, - entry: ["src/approval.ts", "src/backfill.ts", "src/beeper-setup.ts", "src/bridge-agent.ts", "src/cli.ts", "src/config.ts", "src/connector.ts", "src/index.ts", "src/openclaw-event-map.ts", "src/openclaw-runtime.ts", "src/registry.ts", "src/registration.ts", "src/rooms.ts", "src/stream-map.ts", "src/types.ts"], + entry: ["src/approval.ts", "src/appservice.ts", "src/backfill.ts", "src/beeper-setup.ts", "src/bridge-agent.ts", "src/cli.ts", "src/config.ts", "src/connector.ts", "src/index.ts", "src/openclaw-event-map.ts", "src/openclaw-runtime.ts", "src/registry.ts", "src/registration.ts", "src/rooms.ts", "src/stream-map.ts", "src/types.ts"], format: ["esm"], }); From c449f8a4a24895cc060f506d457db9ccfbd2586a Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Sat, 16 May 2026 16:35:51 +0200 Subject: [PATCH 14/43] Persist OpenClaw Beeper account identity --- packages/openclaw/src/appservice.test.ts | 12 +++++++++++- packages/openclaw/src/appservice.ts | 13 +++++++++++++ packages/openclaw/src/beeper-setup.test.ts | 4 ++++ packages/openclaw/src/beeper-setup.ts | 6 ++++-- packages/openclaw/src/cli.ts | 6 ++++++ packages/openclaw/src/config.ts | 4 ++++ packages/openclaw/src/types.ts | 2 ++ 7 files changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/openclaw/src/appservice.test.ts b/packages/openclaw/src/appservice.test.ts index 30500bd..1be1dce 100644 --- a/packages/openclaw/src/appservice.test.ts +++ b/packages/openclaw/src/appservice.test.ts @@ -1,7 +1,7 @@ import type { CreateNodeBeeperBridgeOptions, PickleBridge } from "@beeper/pickle-bridge"; import { describe, expect, it, vi } from "vitest"; import { createDefaultConfig } from "./config"; -import { createOpenClawBeeperBridge, startOpenClawBeeperBridge } from "./appservice"; +import { accountFromOpenClawConfig, createOpenClawBeeperBridge, startOpenClawBeeperBridge } from "./appservice"; describe("OpenClaw Beeper appservice runtime", () => { it("creates a Pickle Beeper bridge with the OpenClaw connector defaults", async () => { @@ -42,6 +42,16 @@ describe("OpenClaw Beeper appservice runtime", () => { })).resolves.toBe(bridge); expect(bridge.start).toHaveBeenCalledOnce(); }); + + it("recreates the Beeper Matrix account from persisted setup config", () => { + expect(accountFromOpenClawConfig(createDefaultConfig({ + accessToken: "mx-token", + dataDir: "/tmp/openclaw", + homeserver: "https://matrix.beeper.com", + matrixDeviceId: "DEVICE", + matrixUserId: "@batuhan:beeper.com", + }))).toEqual(account()); + }); }); function account() { diff --git a/packages/openclaw/src/appservice.ts b/packages/openclaw/src/appservice.ts index 5b2a42d..e578388 100644 --- a/packages/openclaw/src/appservice.ts +++ b/packages/openclaw/src/appservice.ts @@ -40,6 +40,19 @@ export async function startOpenClawBeeperBridge(options: CreateOpenClawBeeperBri return bridge; } +export function accountFromOpenClawConfig(config: OpenClawBridgeConfig): MatrixAccount { + if (!config.accessToken) throw new Error("OpenClaw config is missing accessToken"); + if (!config.homeserver) throw new Error("OpenClaw config is missing homeserver"); + if (!config.matrixDeviceId) throw new Error("OpenClaw config is missing matrixDeviceId"); + if (!config.matrixUserId) throw new Error("OpenClaw config is missing matrixUserId"); + return { + accessToken: config.accessToken, + deviceId: config.matrixDeviceId, + homeserver: config.homeserver, + userId: config.matrixUserId, + }; +} + function connectorOptions(options: CreateOpenClawBeeperBridgeOptions): OpenClawConnectorOptions { const output: OpenClawConnectorOptions = {}; if (options.config !== undefined) output.config = options.config; diff --git a/packages/openclaw/src/beeper-setup.test.ts b/packages/openclaw/src/beeper-setup.test.ts index 64f317d..e451efe 100644 --- a/packages/openclaw/src/beeper-setup.test.ts +++ b/packages/openclaw/src/beeper-setup.test.ts @@ -32,6 +32,8 @@ describe("OpenClaw Beeper setup", () => { expect(result.config).toEqual({ accessToken: "mx-token", homeserver: "https://matrix.beeper.com", + matrixDeviceId: "DEV", + matrixUserId: "@batuhan:beeper.com", }); }); @@ -109,6 +111,8 @@ describe("OpenClaw Beeper setup", () => { appserviceId: "openclaw", homeserver: "https://matrix.beeper-staging.com/_hungryserv/batuhan", hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@batuhan:beeper-staging.com", registrationUrl: "http://127.0.0.1:29391", }); }); diff --git a/packages/openclaw/src/beeper-setup.ts b/packages/openclaw/src/beeper-setup.ts index 9dde92d..7c158aa 100644 --- a/packages/openclaw/src/beeper-setup.ts +++ b/packages/openclaw/src/beeper-setup.ts @@ -26,7 +26,7 @@ export interface BeeperLoginForOpenClawOptions { export interface BeeperLoginForOpenClawResult { account: BeeperSetupAccount; - config: Pick; + config: Pick; } export interface CreateOpenClawBeeperAppServiceOptions { @@ -74,7 +74,7 @@ export interface SetupOpenClawBeeperBridgeOptions extends BeeperLoginForOpenClaw export interface SetupOpenClawBeeperBridgeResult { account: BeeperSetupAccount; - config: Pick; + config: Pick; init: MatrixAppserviceInitOptions; } @@ -94,6 +94,8 @@ export async function loginToBeeperForOpenClaw(options: BeeperLoginForOpenClawOp config: { accessToken: account.accessToken, homeserver: account.homeserver, + matrixDeviceId: account.deviceId, + matrixUserId: account.userId, }, }; } diff --git a/packages/openclaw/src/cli.ts b/packages/openclaw/src/cli.ts index b3b773c..68d3470 100644 --- a/packages/openclaw/src/cli.ts +++ b/packages/openclaw/src/cli.ts @@ -165,6 +165,8 @@ function helpText(): string { " --homeserver ", " --gateway-url ", " --registration-url ", + " --matrix-device-id ", + " --matrix-user-id ", " --access-token ", " --hs-token ", " --as-token ", @@ -183,12 +185,16 @@ function configOverridesFromOptions(options: Map): Par const dataDir = stringOption(options, "data-dir"); const gatewayUrl = stringOption(options, "gateway-url"); const homeserver = stringOption(options, "homeserver"); + const matrixDeviceId = stringOption(options, "matrix-device-id"); + const matrixUserId = stringOption(options, "matrix-user-id"); const registrationUrl = stringOption(options, "registration-url"); if (accessToken) overrides.accessToken = accessToken; if (appserviceId) overrides.appserviceId = appserviceId; if (dataDir) overrides.dataDir = dataDir; if (gatewayUrl) overrides.gatewayUrl = gatewayUrl; if (homeserver) overrides.homeserver = homeserver; + if (matrixDeviceId) overrides.matrixDeviceId = matrixDeviceId; + if (matrixUserId) overrides.matrixUserId = matrixUserId; if (registrationUrl) overrides.registrationUrl = registrationUrl; return overrides; } diff --git a/packages/openclaw/src/config.ts b/packages/openclaw/src/config.ts index d1381e6..610e8ab 100644 --- a/packages/openclaw/src/config.ts +++ b/packages/openclaw/src/config.ts @@ -44,10 +44,14 @@ export function createDefaultConfig(overrides: Partial = { const gatewayUrl = overrides.gatewayUrl ?? process.env.PICKLE_OPENCLAW_GATEWAY_URL; const homeserver = overrides.homeserver ?? process.env.PICKLE_OPENCLAW_HOMESERVER; const hsToken = overrides.hsToken ?? process.env.PICKLE_OPENCLAW_HS_TOKEN; + const matrixDeviceId = overrides.matrixDeviceId ?? process.env.PICKLE_OPENCLAW_MATRIX_DEVICE_ID; + const matrixUserId = overrides.matrixUserId ?? process.env.PICKLE_OPENCLAW_MATRIX_USER_ID; if (accessToken) config.accessToken = accessToken; if (gatewayUrl) config.gatewayUrl = gatewayUrl; if (homeserver) config.homeserver = homeserver; if (hsToken) config.hsToken = hsToken; + if (matrixDeviceId) config.matrixDeviceId = matrixDeviceId; + if (matrixUserId) config.matrixUserId = matrixUserId; if (overrides.allowedRoomIds) config.allowedRoomIds = overrides.allowedRoomIds; if (overrides.allowedUserIds) config.allowedUserIds = overrides.allowedUserIds; return config; diff --git a/packages/openclaw/src/types.ts b/packages/openclaw/src/types.ts index 3465fff..b5761e6 100644 --- a/packages/openclaw/src/types.ts +++ b/packages/openclaw/src/types.ts @@ -37,6 +37,8 @@ export interface OpenClawBridgeConfig { gatewayUrl?: string; homeserver?: string; hsToken?: string; + matrixDeviceId?: string; + matrixUserId?: string; nonFederatedRooms: boolean; registrationUrl: string; senderLocalpart: string; From 75b2fcb1e9fa434e8df8562e9fd6896023e507d5 Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Sat, 16 May 2026 16:36:42 +0200 Subject: [PATCH 15/43] Add OpenClaw bridge start command --- packages/openclaw/src/cli.test.ts | 43 ++++++++++++++++++++++++++++++- packages/openclaw/src/cli.ts | 20 +++++++++++++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/packages/openclaw/src/cli.test.ts b/packages/openclaw/src/cli.test.ts index 11b1006..2485f60 100644 --- a/packages/openclaw/src/cli.test.ts +++ b/packages/openclaw/src/cli.test.ts @@ -1,7 +1,7 @@ import { mkdtemp, readFile, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { runCli } from "./cli"; describe("pickle-openclaw CLI", () => { @@ -55,6 +55,47 @@ describe("pickle-openclaw CLI", () => { await expect(runCli(["wat"], io)).resolves.toBe(2); expect(io.stderrText).toContain("Unknown command: wat"); }); + + it("starts the bridge from persisted Beeper account config", async () => { + const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-start-")); + const configPath = join(dir, "config.json"); + const io = captureIO(); + const startBridge = vi.fn(async () => undefined); + await expect(runCli([ + "init", + "--config", + configPath, + "--data-dir", + dir, + "--access-token", + "mx-token", + "--gateway-url", + "http://127.0.0.1:29390", + "--homeserver", + "https://matrix.beeper.com", + "--matrix-device-id", + "DEVICE", + "--matrix-user-id", + "@batuhan:beeper.com", + ], captureIO())).resolves.toBe(0); + + await expect(runCli(["start", "--config", configPath, "--get-only"], io, { startBridge })).resolves.toBe(0); + + expect(startBridge).toHaveBeenCalledWith(expect.objectContaining({ + account: { + accessToken: "mx-token", + deviceId: "DEVICE", + homeserver: "https://matrix.beeper.com", + userId: "@batuhan:beeper.com", + }, + config: expect.objectContaining({ + gatewayUrl: "http://127.0.0.1:29390", + matrixUserId: "@batuhan:beeper.com", + }), + getOnly: true, + })); + expect(io.stdoutText).toContain("OpenClaw bridge started"); + }); }); function captureIO() { diff --git a/packages/openclaw/src/cli.ts b/packages/openclaw/src/cli.ts index 68d3470..99e5eb0 100644 --- a/packages/openclaw/src/cli.ts +++ b/packages/openclaw/src/cli.ts @@ -2,6 +2,7 @@ import { chmod, mkdir, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import type { BeeperEnvironment } from "@beeper/pickle/beeper/auth"; +import { accountFromOpenClawConfig, startOpenClawBeeperBridge, type CreateOpenClawBeeperBridgeOptions } from "./appservice"; import { createOpenClawBeeperAppService, loginToBeeperForOpenClaw, setupOpenClawBeeperBridge } from "./beeper-setup"; import { createDefaultConfig, defaultConfigPath, readConfig, secretToken, writeConfig } from "./config"; import { createAppserviceRegistration } from "./registration"; @@ -12,7 +13,11 @@ export interface CliIO { stdout: Pick; } -export async function runCli(argv = process.argv.slice(2), io: CliIO = process): Promise { +export interface CliDeps { + startBridge?: (options: CreateOpenClawBeeperBridgeOptions) => Promise; +} + +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") { @@ -43,6 +48,18 @@ export async function runCli(argv = process.argv.slice(2), io: CliIO = process): io.stdout.write(`${JSON.stringify(redactConfig(config), null, 2)}\n`); return 0; } + if (command === "start") { + const options = parseOptions(args); + const config = await loadConfig(options); + const startOptions: CreateOpenClawBeeperBridgeOptions = { + account: accountFromOpenClawConfig(config), + config, + }; + if (booleanOption(options, "get-only")) startOptions.getOnly = true; + await (deps.startBridge ?? startOpenClawBeeperBridge)(startOptions); + io.stdout.write("OpenClaw bridge started\n"); + return 0; + } if (command === "beeper-login") { const options = parseOptions(args); const email = requiredStringOption(options, "email"); @@ -154,6 +171,7 @@ function helpText(): string { "Commands:", " init Write a secure OpenClaw bridge config", " register Write a Matrix appservice registration file", + " start Start the OpenClaw Beeper bridge from config", " status Print the redacted effective config", " beeper-login Log in to Beeper and write Matrix credentials", " beeper-register Register the OpenClaw appservice with Beeper", From d460ca3ea8deab26ece2abf3d42b3a16171fa851 Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Sat, 16 May 2026 16:38:02 +0200 Subject: [PATCH 16/43] Support Beeper account creation in OpenClaw setup --- packages/openclaw/src/beeper-setup.test.ts | 25 ++++++++++++++++ packages/openclaw/src/beeper-setup.ts | 2 ++ packages/openclaw/src/cli.ts | 3 ++ packages/pickle/src/beeper/auth.test.ts | 34 ++++++++++++++++++++++ packages/pickle/src/beeper/auth.ts | 16 ++++++---- 5 files changed, 74 insertions(+), 6 deletions(-) diff --git a/packages/openclaw/src/beeper-setup.test.ts b/packages/openclaw/src/beeper-setup.test.ts index e451efe..27bb69c 100644 --- a/packages/openclaw/src/beeper-setup.test.ts +++ b/packages/openclaw/src/beeper-setup.test.ts @@ -37,6 +37,31 @@ describe("OpenClaw Beeper setup", () => { }); }); + it("can request Beeper account creation instead of existing-account login", async () => { + const seen: unknown[] = []; + await loginToBeeperForOpenClaw({ + email: "new@example.com", + getLoginCode: () => "123456", + login: async (options) => { + seen.push(options); + return { + accessToken: "mx-token", + deviceId: "DEV", + homeserver: "https://matrix.beeper.com", + userId: "@new:beeper.com", + }; + }, + onlyExistingAccounts: false, + }); + + expect(seen).toEqual([ + expect.objectContaining({ + email: "new@example.com", + onlyExistingAccounts: false, + }), + ]); + }); + it("registers the OpenClaw Beeper appservice with self-hosted defaults", async () => { const seen: unknown[] = []; const result = await createOpenClawBeeperAppService({ diff --git a/packages/openclaw/src/beeper-setup.ts b/packages/openclaw/src/beeper-setup.ts index 7c158aa..8185a26 100644 --- a/packages/openclaw/src/beeper-setup.ts +++ b/packages/openclaw/src/beeper-setup.ts @@ -22,6 +22,7 @@ export interface BeeperLoginForOpenClawOptions { initialDeviceDisplayName?: string; login?: (options: BeeperAuthOptions) => Promise; metadata?: Record; + onlyExistingAccounts?: boolean; } export interface BeeperLoginForOpenClawResult { @@ -88,6 +89,7 @@ export async function loginToBeeperForOpenClaw(options: BeeperLoginForOpenClawOp if (options.env !== undefined) request.env = options.env; if (options.fetch !== undefined) request.fetch = options.fetch; if (options.getLoginCode !== undefined) request.getLoginCode = options.getLoginCode; + if (options.onlyExistingAccounts !== undefined) request.onlyExistingAccounts = options.onlyExistingAccounts; const account = await login(request); return { account, diff --git a/packages/openclaw/src/cli.ts b/packages/openclaw/src/cli.ts index 99e5eb0..d65158f 100644 --- a/packages/openclaw/src/cli.ts +++ b/packages/openclaw/src/cli.ts @@ -70,6 +70,7 @@ export async function runCli(argv = process.argv.slice(2), io: CliIO = process, const env = beeperEnvOption(options); if (env !== undefined) loginOptions.env = env; if (loginCode !== undefined) loginOptions.getLoginCode = () => loginCode; + if (booleanOption(options, "create-account")) loginOptions.onlyExistingAccounts = false; const result = await loginToBeeperForOpenClaw(loginOptions); const config = createDefaultConfig({ ...configOverridesFromOptions(options), @@ -140,6 +141,7 @@ export async function runCli(argv = process.argv.slice(2), io: CliIO = process, if (env !== undefined) setupOptions.env = env; if (loginCode !== undefined) setupOptions.getLoginCode = () => loginCode; if (homeserverDomain !== undefined) setupOptions.homeserverDomain = homeserverDomain; + if (booleanOption(options, "create-account")) setupOptions.onlyExistingAccounts = false; if (username !== undefined) setupOptions.username = username; const result = await setupOpenClawBeeperBridge(setupOptions); const config = createDefaultConfig({ @@ -191,6 +193,7 @@ function helpText(): string { " --output ", " --email
", " --login-code ", + " --create-account", " --env ", "", ].join("\n"); diff --git a/packages/pickle/src/beeper/auth.test.ts b/packages/pickle/src/beeper/auth.test.ts index f885c64..1beb4aa 100644 --- a/packages/pickle/src/beeper/auth.test.ts +++ b/packages/pickle/src/beeper/auth.test.ts @@ -66,6 +66,40 @@ 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({ 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, + })).resolves.toMatchObject({ + accessToken: "access", + userId: "@bot:beeper.com", + }); + + expect(await requestBody(fetchImpl, 1)).toMatchObject({ + onlyExistingAccounts: false, + }); + expect(await requestBody(fetchImpl, 2)).toMatchObject({ + onlyExistingAccounts: false, + }); + }); }); 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..14c46ee 100644 --- a/packages/pickle/src/beeper/auth.ts +++ b/packages/pickle/src/beeper/auth.ts @@ -9,6 +9,7 @@ export interface BeeperAuthOptions { getLoginCode?: () => Promise | string; initialDeviceDisplayName?: string; metadata?: Record; + onlyExistingAccounts?: boolean; } export interface BeeperAuthStartResult { @@ -36,9 +37,10 @@ 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,11 +99,12 @@ export async function sendBeeperLoginCode( fetchImpl: typeof fetch, domain: string, requestId: string, - code: string + code: string, + options: { onlyExistingAccounts?: boolean } = {} ): Promise { const raw = await beeperRequest(fetchImpl, domain, "/user/login/response", { appType: "pickle", - onlyExistingAccounts: true, + onlyExistingAccounts: options.onlyExistingAccounts ?? true, request: requestId, response: code, }); From c6e401d6f76c37b3a5a35bd3b17f1223f7445139 Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Sat, 16 May 2026 16:40:06 +0200 Subject: [PATCH 17/43] Model OpenClaw session users as ghosts --- packages/openclaw/src/backfill.test.ts | 16 ++++++++++++ packages/openclaw/src/backfill.ts | 22 +++++++++++------ packages/openclaw/src/connector.ts | 25 ++++++++++++++++--- packages/openclaw/src/registry.test.ts | 9 ++++++- packages/openclaw/src/registry.ts | 15 ++++++++++-- packages/openclaw/src/rooms.test.ts | 13 ++++++++++ packages/openclaw/src/rooms.ts | 34 +++++++++++++++++++++++++- packages/openclaw/src/types.ts | 9 +++++++ 8 files changed, 128 insertions(+), 15 deletions(-) diff --git a/packages/openclaw/src/backfill.test.ts b/packages/openclaw/src/backfill.test.ts index 8c41d3d..1252360 100644 --- a/packages/openclaw/src/backfill.test.ts +++ b/packages/openclaw/src/backfill.test.ts @@ -33,6 +33,11 @@ describe("OpenClaw backfill", () => { }, { agentId: "main", + human: { + displayName: "user-1", + ghostUserId: "@openclaw_user_user-1:localhost", + userId: "user-1", + }, label: "agent:main:whatsapp:user-1", session: { chatType: "dm", key: "agent:main:whatsapp:user-1", lastTo: "user-1" }, sessionKey: "agent:main:whatsapp:user-1", @@ -55,6 +60,11 @@ describe("OpenClaw backfill", () => { try { await expect(buildBackfillImport(runtime, createDefaultConfig({ dataDir: "/tmp/openclaw" }), { agentId: "main", + human: { + displayName: "Alice", + ghostUserId: "@openclaw_user_alice:localhost", + userId: "alice", + }, label: "Terminal", session: { key: "agent:main:terminal:local" }, sessionKey: "agent:main:terminal:local", @@ -66,11 +76,17 @@ describe("OpenClaw backfill", () => { binding: { agentId: "main", ghostUserId: "@openclaw_agent_main:localhost", + humanGhostUserId: "@openclaw_user_alice:localhost", label: "Terminal", owner: "imported", roomId: "!room:example.com", sessionKey: "agent:main:terminal:local", }, + human: { + displayName: "Alice", + ghostUserId: "@openclaw_user_alice:localhost", + userId: "alice", + }, messages: [ { content: { diff --git a/packages/openclaw/src/backfill.ts b/packages/openclaw/src/backfill.ts index 4f8c565..818a11b 100644 --- a/packages/openclaw/src/backfill.ts +++ b/packages/openclaw/src/backfill.ts @@ -3,11 +3,12 @@ import type { OpenClawGatewayRuntime, OpenClawListedSession, } from "./openclaw-runtime"; -import { agentGhostUserId, bindingIdForRoom } from "./rooms"; -import type { OpenClawBridgeConfig, OpenClawSessionBinding } from "./types"; +import { agentGhostUserId, bindingIdForRoom, userContactFromOpenClawSession } from "./rooms"; +import type { OpenClawBridgeConfig, OpenClawSessionBinding, OpenClawUserContact } from "./types"; export interface OpenClawBackfillSession { agentId: string; + human?: OpenClawUserContact; label: string; session: OpenClawListedSession; sessionKey: string; @@ -24,6 +25,7 @@ export interface OpenClawBackfillMessage { export interface OpenClawBackfillImport { binding: OpenClawSessionBinding; + human?: OpenClawUserContact; messages: OpenClawBackfillMessage[]; source: OpenClawBackfillSession["source"]; } @@ -33,13 +35,16 @@ export async function discoverOneToOneSessions(runtime: OpenClawGatewayRuntime): return sessions.flatMap((session) => { if (!isOneToOneSession(session)) return []; const agentId = resolveAgentId(session); - return [{ + const result: OpenClawBackfillSession = { agentId, label: session.displayName ?? session.derivedTitle ?? session.label ?? session.key, session, sessionKey: session.key, source: sessionSource(session), - }]; + }; + const human = userContactFromOpenClawSession(runtime.config, session); + if (human !== undefined) result.human = human; + return [result]; }); } @@ -52,8 +57,7 @@ export async function buildBackfillImport( const messages = (await runtime.loadHistory(session.sessionKey, options.limit)).map((message, index) => normalizeHistoryMessage(message, index) ); - return { - binding: { + const binding: OpenClawSessionBinding = { agentId: session.agentId, createdAt: Date.now(), ghostUserId: agentGhostUserId(config, session.agentId), @@ -64,7 +68,11 @@ export async function buildBackfillImport( roomId: options.roomId, sessionKey: session.sessionKey, updatedAt: Date.now(), - }, + }; + if (session.human !== undefined) binding.humanGhostUserId = session.human.ghostUserId; + return { + binding, + ...(session.human !== undefined ? { human: session.human } : {}), messages, source: session.source, }; diff --git a/packages/openclaw/src/connector.ts b/packages/openclaw/src/connector.ts index 1489e15..d051043 100644 --- a/packages/openclaw/src/connector.ts +++ b/packages/openclaw/src/connector.ts @@ -232,6 +232,14 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor mxid: contact.ghostUserId, }); } + for (const contact of this.#registry.data.users) { + ctx.bridge.registerGhost({ + displayName: contact.displayName, + id: contact.userId, + metadata: { openclaw: contact }, + mxid: contact.ghostUserId, + }); + } } async disconnect(): Promise { @@ -280,13 +288,22 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor const importOptions: { limit?: number; roomId: string } = { roomId: binding.roomId }; const limit = params.limit ?? params.count; if (limit !== undefined) importOptions.limit = limit; - const backfill = await buildBackfillImport(this.#runtime, this.#runtime.config, { + const sessionOptions: Parameters[2] = { agentId: binding.agentId, label: binding.label ?? binding.sessionKey, session: { key: binding.sessionKey }, sessionKey: binding.sessionKey, source: binding.owner === "imported" ? "unknown" : "channel", - }, importOptions); + }; + if (binding.humanGhostUserId) { + sessionOptions.human = { + displayName: binding.humanGhostUserId, + ghostUserId: binding.humanGhostUserId, + userId: binding.humanGhostUserId, + }; + } + const backfill = await buildBackfillImport(this.#runtime, this.#runtime.config, sessionOptions, importOptions); + if (backfill.human) this.#registry.upsertUser(backfill.human); return { hasMore: false, messages: backfill.messages.map((message) => ({ @@ -298,8 +315,8 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor id: message.id, portalKey: params.portal.portalKey, sender: { - isFromMe: message.sender !== "agent", - sender: message.sender === "agent" ? binding.agentId : `${this.#login.id}:human`, + isFromMe: false, + sender: message.sender === "agent" ? binding.agentId : binding.humanGhostUserId ?? `${this.#login.id}:human`, }, timestamp: new Date(0), }), diff --git a/packages/openclaw/src/registry.test.ts b/packages/openclaw/src/registry.test.ts index 429bced..a0d4760 100644 --- a/packages/openclaw/src/registry.test.ts +++ b/packages/openclaw/src/registry.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest"; import { OpenClawBridgeRegistry } from "./registry"; describe("OpenClawBridgeRegistry", () => { - it("persists agent contacts, session bindings, and dedupe keys", async () => { + it("persists agent contacts, user contacts, session bindings, and dedupe keys", async () => { const dir = await mkdtemp(resolve(tmpdir(), "pickle-openclaw-")); const path = resolve(dir, "registry.json"); const registry = new OpenClawBridgeRegistry(path); @@ -15,6 +15,12 @@ describe("OpenClawBridgeRegistry", () => { displayName: "Codex", ghostUserId: "@openclaw_agent_codex:example.com", }); + registry.upsertUser({ + displayName: "Alice", + ghostUserId: "@openclaw_user_alice:example.com", + source: "whatsapp", + userId: "alice", + }); registry.upsertBinding({ agentId: "codex", createdAt: 1, @@ -32,6 +38,7 @@ describe("OpenClawBridgeRegistry", () => { const loaded = new OpenClawBridgeRegistry(path); await loaded.load(); expect(loaded.getAgent("codex")?.displayName).toBe("Codex"); + expect(loaded.getUser("alice")?.ghostUserId).toBe("@openclaw_user_alice:example.com"); 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); diff --git a/packages/openclaw/src/registry.ts b/packages/openclaw/src/registry.ts index 414412b..1a6d475 100644 --- a/packages/openclaw/src/registry.ts +++ b/packages/openclaw/src/registry.ts @@ -1,14 +1,14 @@ 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"; +import type { OpenClawAgentContact, OpenClawBridgeRegistryData, OpenClawSessionBinding, OpenClawUserContact } from "./types"; export function defaultRegistryPath(dataDir = defaultDataDir()): string { return resolve(dataDir, "registry.json"); } export function emptyRegistry(): OpenClawBridgeRegistryData { - return { agents: [], bindings: [], dedupe: {}, schemaVersion: 1 }; + return { agents: [], bindings: [], dedupe: {}, schemaVersion: 1, users: [] }; } export class OpenClawBridgeRegistry { @@ -53,6 +53,16 @@ export class OpenClawBridgeRegistry { this.#data.agents = [...agents]; } + getUser(userId: string): OpenClawUserContact | undefined { + return this.#data.users.find((user) => user.userId === userId); + } + + upsertUser(user: OpenClawUserContact): void { + const index = this.#data.users.findIndex((item) => item.userId === user.userId); + if (index === -1) this.#data.users.push(user); + else this.#data.users[index] = user; + } + getBindingById(id: string): OpenClawSessionBinding | undefined { return this.#data.bindings.find((binding) => binding.id === id); } @@ -104,5 +114,6 @@ function normalizeRegistry(value: unknown): OpenClawBridgeRegistryData { bindings: Array.isArray(data.bindings) ? data.bindings : [], dedupe: data.dedupe && typeof data.dedupe === "object" ? data.dedupe : {}, schemaVersion: 1, + users: Array.isArray(data.users) ? data.users : [], }; } diff --git a/packages/openclaw/src/rooms.test.ts b/packages/openclaw/src/rooms.test.ts index ce6436e..3861184 100644 --- a/packages/openclaw/src/rooms.test.ts +++ b/packages/openclaw/src/rooms.test.ts @@ -8,6 +8,8 @@ import { createSessionRoom, matrixDomainFromHomeserver, serviceBotUserId, + userContactFromOpenClawSession, + userGhostUserId, } from "./rooms"; describe("OpenClaw room and contact helpers", () => { @@ -15,6 +17,7 @@ describe("OpenClaw room and contact helpers", () => { 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("@openclaw_agent_codex_main:matrix.example.com"); + expect(userGhostUserId(config, "whatsapp:+1 555")).toBe("@openclaw_user_whatsapp=3a=2b1=20555:matrix.example.com"); expect(serviceBotUserId(config)).toBe("@openclawbot:matrix.example.com"); expect(agentContactFromOpenClawAgent(config, { avatarMxc: "mxc://example/avatar", @@ -28,6 +31,16 @@ describe("OpenClaw room and contact helpers", () => { displayName: "Codex", ghostUserId: "@openclaw_agent_codex:matrix.example.com", }); + expect(userContactFromOpenClawSession(config, { + displayName: "Alice", + lastProvider: "whatsapp", + lastTo: "whatsapp:+1 555", + })).toEqual({ + displayName: "Alice", + ghostUserId: "@openclaw_user_whatsapp=3a=2b1=20555:matrix.example.com", + source: "whatsapp", + userId: "whatsapp:+1 555", + }); }); it("creates non-federated appservice rooms for OpenClaw sessions", async () => { diff --git a/packages/openclaw/src/rooms.ts b/packages/openclaw/src/rooms.ts index ac7380e..7e012da 100644 --- a/packages/openclaw/src/rooms.ts +++ b/packages/openclaw/src/rooms.ts @@ -1,5 +1,5 @@ import type { MatrixClient } from "@beeper/pickle"; -import type { OpenClawAgentContact, OpenClawBridgeConfig, OpenClawSessionBinding } from "./types"; +import type { OpenClawAgentContact, OpenClawBridgeConfig, OpenClawSessionBinding, OpenClawUserContact } from "./types"; import { openClawAgentGhostLocalpart, openClawRoomCreationPreset } from "./registration"; export function bindingIdForRoom(roomId: string): string { @@ -19,6 +19,10 @@ export function agentGhostUserId(config: OpenClawBridgeConfig, agentId: string, return `@${openClawAgentGhostLocalpart(config, agentId)}:${domain}`; } +export function userGhostUserId(config: OpenClawBridgeConfig, userId: string, domain = matrixDomainFromHomeserver(config.homeserver)): string { + return `@${config.userLocalpartPrefix}${encodeLocalpartSegment(userId)}:${domain}`; +} + export function serviceBotUserId(config: OpenClawBridgeConfig, domain = matrixDomainFromHomeserver(config.homeserver)): string { return `@${config.serviceBotLocalpart}:${domain}`; } @@ -42,6 +46,30 @@ export function agentContactFromOpenClawAgent( return contact; } +export function userContactFromOpenClawSession( + config: OpenClawBridgeConfig, + session: { + displayName?: string; + lastAccountId?: string; + lastProvider?: string; + lastTo?: string; + origin?: Record; + provider?: string; + }, + domain = matrixDomainFromHomeserver(config.homeserver) +): OpenClawUserContact | undefined { + const userId = session.lastTo ?? session.lastAccountId ?? stringValue(session.origin?.userId) ?? stringValue(session.origin?.accountId); + if (!userId) return undefined; + const contact: OpenClawUserContact = { + displayName: session.displayName ?? userId, + ghostUserId: userGhostUserId(config, userId, domain), + userId, + }; + const source = session.lastProvider ?? session.provider ?? stringValue(session.origin?.surface) ?? stringValue(session.origin?.type); + if (source) contact.source = source; + return contact; +} + export async function createSessionRoom( client: Pick, config: OpenClawBridgeConfig, @@ -91,3 +119,7 @@ export async function createSessionRoom( function stringValue(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } + +function encodeLocalpartSegment(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9._=-]/g, (char) => `=${char.codePointAt(0)?.toString(16) ?? "00"}`); +} diff --git a/packages/openclaw/src/types.ts b/packages/openclaw/src/types.ts index b5761e6..17c42a9 100644 --- a/packages/openclaw/src/types.ts +++ b/packages/openclaw/src/types.ts @@ -9,6 +9,13 @@ export interface OpenClawAgentContact { description?: string; } +export interface OpenClawUserContact { + displayName: string; + ghostUserId: string; + source?: string; + userId: string; +} + export interface OpenClawSessionBinding { id: string; kind: OpenClawBindingKind; @@ -18,6 +25,7 @@ export interface OpenClawSessionBinding { sessionKey: string; agentId: string; ghostUserId: string; + humanGhostUserId?: string; cwd?: string; label?: string; createdAt: number; @@ -52,6 +60,7 @@ export interface OpenClawBridgeRegistryData { bindings: OpenClawSessionBinding[]; dedupe: Record; schemaVersion: 1; + users: OpenClawUserContact[]; } export interface AppserviceRegistration { From afeeabea77c043f0337cc783fe0ae43f9101bd4c Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Sat, 16 May 2026 16:42:23 +0200 Subject: [PATCH 18/43] Add OpenClaw WebSocket gateway transport --- packages/openclaw/src/connector.ts | 5 +- .../openclaw/src/openclaw-runtime.test.ts | 96 ++++++++++ packages/openclaw/src/openclaw-runtime.ts | 175 ++++++++++++++++++ 3 files changed, 275 insertions(+), 1 deletion(-) diff --git a/packages/openclaw/src/connector.ts b/packages/openclaw/src/connector.ts index d051043..1dc4b54 100644 --- a/packages/openclaw/src/connector.ts +++ b/packages/openclaw/src/connector.ts @@ -31,7 +31,7 @@ import { buildBackfillImport } from "./backfill"; import { parseApprovalResponseContent } from "./approval"; import { OpenClawMatrixBridgeAgent, type OpenClawBridgeStreamPublisher } from "./bridge-agent"; import { createDefaultConfig } from "./config"; -import { createOpenClawHttpTransport, OpenClawGatewayRuntime, type OpenClawTransport } from "./openclaw-runtime"; +import { createOpenClawHttpTransport, createOpenClawWebSocketTransport, OpenClawGatewayRuntime, type OpenClawTransport } from "./openclaw-runtime"; import { OpenClawBridgeRegistry } from "./registry"; import { agentContactFromOpenClawAgent } from "./rooms"; import type { OpenClawAgentContact, OpenClawBridgeConfig, OpenClawSessionBinding } from "./types"; @@ -371,6 +371,9 @@ function transportFromLogin(login: UserLogin, config: OpenClawBridgeConfig): Ope const options: Parameters[0] = { url: gatewayUrl }; const accessToken = stringValue(metadata?.accessToken) ?? config.accessToken; if (accessToken !== undefined) options.accessToken = accessToken; + if (gatewayUrl.startsWith("ws://") || gatewayUrl.startsWith("wss://")) { + return createOpenClawWebSocketTransport(options); + } return createOpenClawHttpTransport(options); } diff --git a/packages/openclaw/src/openclaw-runtime.test.ts b/packages/openclaw/src/openclaw-runtime.test.ts index 4617872..fb8696e 100644 --- a/packages/openclaw/src/openclaw-runtime.test.ts +++ b/packages/openclaw/src/openclaw-runtime.test.ts @@ -3,6 +3,7 @@ import { createDefaultConfig } from "./config"; import { OpenClawGatewayRuntime, createOpenClawHttpTransport, + createOpenClawWebSocketTransport, type OpenClawGatewayEvent, type OpenClawTransport, } from "./openclaw-runtime"; @@ -149,8 +150,103 @@ describe("OpenClawGatewayRuntime", () => { }, ]); }); + + it("uses OpenClaw gateway WebSocket req/res framing and broadcast events", async () => { + FakeWebSocket.instances = []; + const transport = createOpenClawWebSocketTransport({ + accessToken: "secret", + WebSocket: FakeWebSocket as unknown as typeof WebSocket, + url: "ws://gateway", + }); + + const request = transport.request("sessions.send", { key: "session", message: "hi" }); + const socket = FakeWebSocket.instances[0]; + await waitFor(() => socket?.sent.length === 1); + expect(JSON.parse(socket?.sent[0] ?? "{}")).toMatchObject({ + method: "connect", + params: { + auth: { token: "secret" }, + role: "operator", + scopes: ["operator.read", "operator.write", "operator.approvals"], + }, + type: "req", + }); + socket?.receive({ id: JSON.parse(socket.sent[0] ?? "{}").id, ok: true, payload: { ok: true }, type: "res" }); + await waitFor(() => socket?.sent.length === 2); + const sent = JSON.parse(socket?.sent[1] ?? "{}"); + expect(sent).toMatchObject({ + method: "sessions.send", + params: { key: "session", message: "hi" }, + type: "req", + }); + socket?.receive({ id: sent.id, ok: true, payload: { runId: "run_1" }, type: "res" }); + await expect(request).resolves.toEqual({ runId: "run_1" }); + + const events: OpenClawGatewayEvent[] = []; + const iterator = transport.events((event) => { + const payload = event.payload as { runId?: string }; + return payload.runId === "run_1"; + }); + const next = iterator[Symbol.asyncIterator]().next(); + await new Promise((resolve) => setTimeout(resolve, 0)); + socket?.receive({ event: "session.message", payload: { runId: "skip" }, type: "event" }); + socket?.receive({ event: "session.message", payload: { runId: "run_1" }, seq: 3, type: "event" }); + events.push((await next).value); + expect(events).toEqual([{ event: "session.message", payload: { runId: "run_1" }, seq: 3 }]); + transport.close(); + }); }); +class FakeWebSocket { + static instances: FakeWebSocket[] = []; + readonly sent: string[] = []; + readyState = 0; + #listeners = new Map void>>(); + + constructor(readonly url: string) { + FakeWebSocket.instances.push(this); + queueMicrotask(() => { + this.readyState = 1; + this.#emit("open", {}); + }); + } + + addEventListener(type: string, listener: (event: { data?: string }) => void): void { + const listeners = this.#listeners.get(type) ?? new Set(); + listeners.add(listener); + this.#listeners.set(type, listeners); + } + + removeEventListener(type: string, listener: (event: { data?: string }) => void): void { + this.#listeners.get(type)?.delete(listener); + } + + send(data: string): void { + this.sent.push(data); + } + + close(): void { + this.readyState = 3; + this.#emit("close", {}); + } + + receive(frame: unknown): void { + this.#emit("message", { data: JSON.stringify(frame) }); + } + + #emit(type: string, event: { data?: string }): void { + for (const listener of this.#listeners.get(type) ?? []) listener(event); + } +} + +async function waitFor(predicate: () => boolean): Promise { + for (let index = 0; index < 20; index += 1) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + throw new Error("Timed out waiting for condition"); +} + function fakeTransport(responses: Record, events: OpenClawGatewayEvent[] = []): OpenClawTransport & { request: ReturnType; } { diff --git a/packages/openclaw/src/openclaw-runtime.ts b/packages/openclaw/src/openclaw-runtime.ts index 706a52b..2eb6d3a 100644 --- a/packages/openclaw/src/openclaw-runtime.ts +++ b/packages/openclaw/src/openclaw-runtime.ts @@ -28,6 +28,15 @@ export interface OpenClawHttpTransportOptions { url: string; } +export interface OpenClawWebSocketTransportOptions { + accessToken?: string; + clientId?: string; + clientVersion?: string; + requestTimeoutMs?: number; + url: string; + WebSocket?: typeof WebSocket; +} + export interface OpenClawSessionCreateOptions { agentId: string; key?: string; @@ -275,6 +284,172 @@ export function createOpenClawHttpTransport(options: OpenClawHttpTransportOption return new OpenClawHttpTransport(options); } +export class OpenClawWebSocketTransport implements OpenClawTransport { + readonly #options: OpenClawWebSocketTransportOptions; + readonly #pending = new Map; + }>(); + readonly #subscribers = new Set<{ + events: OpenClawGatewayEvent[]; + filter: ((event: OpenClawGatewayEvent) => boolean) | undefined; + notify: (() => void) | undefined; + closed: boolean; + }>(); + #connectPromise: Promise | undefined; + #socket: WebSocket | undefined; + + constructor(options: OpenClawWebSocketTransportOptions) { + this.#options = options; + } + + async request(method: string, params?: unknown, options: GatewayRequestOptions = {}): Promise { + await this.#connect(); + return await this.#sendRequest(method, params, options) as T; + } + + #sendRequest(method: string, params?: unknown, options: GatewayRequestOptions = {}): Promise { + const socket = this.#socket; + if (!socket) throw new Error("OpenClaw gateway socket is not connected"); + const id = `req_${Date.now()}_${Math.random().toString(36).slice(2)}`; + const timeoutMs = options.timeoutMs ?? this.#options.requestTimeoutMs ?? 30_000; + const response = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.#pending.delete(id); + reject(new Error(`OpenClaw gateway request timed out: ${method}`)); + }, timeoutMs); + this.#pending.set(id, { reject, resolve, timeout }); + }); + socket.send(JSON.stringify({ + id, + method, + params: params ?? {}, + type: "req", + })); + return response; + } + + async *events(filter?: (event: OpenClawGatewayEvent) => boolean): AsyncIterable { + await this.#connect(); + const subscriber = { closed: false, events: [] as OpenClawGatewayEvent[], filter, notify: undefined as (() => void) | undefined }; + this.#subscribers.add(subscriber); + try { + for (;;) { + const event = subscriber.events.shift(); + if (event) { + yield event; + continue; + } + if (subscriber.closed) return; + await new Promise((resolve) => { + subscriber.notify = resolve; + }); + } + } finally { + subscriber.closed = true; + this.#subscribers.delete(subscriber); + } + } + + close(): void { + const socket = this.#socket; + this.#socket = undefined; + this.#connectPromise = undefined; + socket?.close(); + for (const pending of this.#pending.values()) { + clearTimeout(pending.timeout); + pending.reject(new Error("OpenClaw gateway socket closed")); + } + this.#pending.clear(); + for (const subscriber of this.#subscribers) { + subscriber.closed = true; + subscriber.notify?.(); + } + } + + async #connect(): Promise { + if (this.#socket?.readyState === 1) return; + this.#connectPromise ??= this.#open(); + await this.#connectPromise; + } + + async #open(): Promise { + const WebSocketCtor = this.#options.WebSocket ?? globalThis.WebSocket; + if (!WebSocketCtor) throw new Error("OpenClaw WebSocket transport requires WebSocket"); + const socket = new WebSocketCtor(this.#options.url); + this.#socket = socket; + await new Promise((resolve, reject) => { + const cleanup = () => { + socket.removeEventListener("open", onOpen); + socket.removeEventListener("error", onError); + }; + const onOpen = () => { + cleanup(); + resolve(); + }; + const onError = () => { + cleanup(); + reject(new Error("OpenClaw gateway socket failed to open")); + }; + socket.addEventListener("open", onOpen); + socket.addEventListener("error", onError); + }); + socket.addEventListener("message", (event) => { + this.#handleFrame(String(event.data)); + }); + socket.addEventListener("close", () => { + this.close(); + }); + await this.#sendRequest("connect", { + auth: this.#options.accessToken ? { token: this.#options.accessToken } : {}, + client: { + id: this.#options.clientId ?? "pickle-openclaw", + mode: "backend", + platform: "matrix", + version: this.#options.clientVersion ?? "0.1.0", + }, + maxProtocol: 4, + minProtocol: 4, + role: "operator", + scopes: ["operator.read", "operator.write", "operator.approvals"], + }); + } + + #handleFrame(raw: string): void { + const frame = JSON.parse(raw) as Record; + if (frame.type === "res") { + const id = stringValue(frame.id); + const pending = id ? this.#pending.get(id) : undefined; + if (!id || !pending) return; + this.#pending.delete(id); + clearTimeout(pending.timeout); + if (frame.ok === false) pending.reject(new Error(`OpenClaw gateway request failed: ${errorMessage(frame.error)}`)); + else pending.resolve(frame.payload); + return; + } + if (frame.type === "event") { + const event = stripUndefined({ + event: stringValue(frame.event), + payload: frame.payload, + seq: typeof frame.seq === "number" ? frame.seq : undefined, + stateVersion: frame.stateVersion, + }); + for (const subscriber of this.#subscribers) { + if (!subscriber.filter || subscriber.filter(event)) { + subscriber.events.push(event); + subscriber.notify?.(); + subscriber.notify = undefined; + } + } + } + } +} + +export function createOpenClawWebSocketTransport(options: OpenClawWebSocketTransportOptions): OpenClawWebSocketTransport { + return new OpenClawWebSocketTransport(options); +} + function arrayValue(value: unknown): unknown[] | undefined { return Array.isArray(value) ? value : undefined; } From b28f9fdc67ec75e5b7401cdd0e568094e1bc93ab Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Sat, 16 May 2026 16:43:24 +0200 Subject: [PATCH 19/43] Expose broader OpenClaw gateway features --- .../openclaw/src/openclaw-runtime.test.ts | 34 +++++ packages/openclaw/src/openclaw-runtime.ts | 124 ++++++++++++++++++ 2 files changed, 158 insertions(+) diff --git a/packages/openclaw/src/openclaw-runtime.test.ts b/packages/openclaw/src/openclaw-runtime.test.ts index fb8696e..503e7e1 100644 --- a/packages/openclaw/src/openclaw-runtime.test.ts +++ b/packages/openclaw/src/openclaw-runtime.test.ts @@ -58,6 +58,40 @@ describe("OpenClawGatewayRuntime", () => { }, { expectFinal: true, timeoutMs: 1000 }); }); + it("exposes generic OpenClaw gateway feature RPC wrappers", async () => { + const transport = fakeTransport({ + "artifacts.list": { artifacts: [{ id: "artifact_1" }] }, + "models.list": { models: ["gpt-5.4"] }, + "sessions.abort": { aborted: true }, + "sessions.steer": { runId: "run_steer", sessionKey: "agent:codex:main" }, + "tasks.cancel": { cancelled: true }, + "tasks.list": { tasks: [] }, + "tools.catalog": { tools: [{ name: "exec" }] }, + "tools.effective": { tools: [{ name: "read" }] }, + "tools.invoke": { ok: true }, + }); + const runtime = new OpenClawGatewayRuntime({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + transport, + }); + + await expect(runtime.listModels()).resolves.toEqual({ models: ["gpt-5.4"] }); + await expect(runtime.listTools()).resolves.toEqual({ tools: [{ name: "exec" }] }); + await expect(runtime.effectiveTools("agent:codex:main")).resolves.toEqual({ tools: [{ name: "read" }] }); + await expect(runtime.invokeTool({ name: "read", sessionKey: "agent:codex:main" })).resolves.toEqual({ ok: true }); + await expect(runtime.listTasks()).resolves.toEqual({ tasks: [] }); + await expect(runtime.cancelTask("task_1", "stale")).resolves.toEqual({ cancelled: true }); + await expect(runtime.listArtifacts({ sessionKey: "agent:codex:main" })).resolves.toEqual({ artifacts: [{ id: "artifact_1" }] }); + await expect(runtime.steerSession({ message: "actually do this", sessionKey: "agent:codex:main" })).resolves.toEqual({ + raw: { runId: "run_steer", sessionKey: "agent:codex:main" }, + runId: "run_steer", + sessionKey: "agent:codex:main", + }); + await expect(runtime.abortSession({ runId: "run_steer" })).resolves.toEqual({ aborted: true }); + expect(transport.request).toHaveBeenCalledWith("tasks.cancel", { reason: "stale", taskId: "task_1" }, undefined); + expect(transport.request).toHaveBeenCalledWith("sessions.abort", { runId: "run_steer" }, undefined); + }); + it("filters gateway events by run id and resolves approvals", async () => { const events: OpenClawGatewayEvent[] = [ { event: "assistant.delta", payload: { delta: "skip", runId: "run_other" } }, diff --git a/packages/openclaw/src/openclaw-runtime.ts b/packages/openclaw/src/openclaw-runtime.ts index 2eb6d3a..1ae2c38 100644 --- a/packages/openclaw/src/openclaw-runtime.ts +++ b/packages/openclaw/src/openclaw-runtime.ts @@ -56,6 +56,23 @@ export interface OpenClawSessionSendOptions { timeoutMs?: number; } +export interface OpenClawGatewayFeatureSnapshot { + agents?: unknown; + artifacts?: unknown; + channels?: unknown; + commands?: unknown; + config?: unknown; + cron?: unknown; + health?: unknown; + models?: unknown; + sessions?: unknown; + skills?: unknown; + status?: unknown; + tasks?: unknown; + tools?: unknown; + usage?: unknown; +} + export interface OpenClawSessionRef { agentId?: string; key: string; @@ -111,6 +128,85 @@ export class OpenClawGatewayRuntime { return (agents ?? []).map((agent) => agentContactFromOpenClawAgent(this.config, recordValue(agent) ?? {})); } + call(method: string, params?: unknown, options?: GatewayRequestOptions): Promise { + return this.transport.request(method, params, options); + } + + async featureSnapshot(): Promise { + const entries = await Promise.allSettled([ + this.call("health", {}), + this.call("status", {}), + this.call("models.list", { view: "configured" }), + this.call("channels.status", {}), + this.call("sessions.list", { includeArchived: true }), + this.call("commands.list", {}), + this.call("tools.catalog", {}), + this.call("skills.status", {}), + this.call("tasks.list", { limit: 100 }), + this.call("usage.status", {}), + this.call("artifacts.list", {}), + this.call("cron.list", {}), + this.call("agents.list", {}), + this.call("config.get", {}), + ]); + return stripUndefined({ + health: settledValue(entries[0]), + status: settledValue(entries[1]), + models: settledValue(entries[2]), + channels: settledValue(entries[3]), + sessions: settledValue(entries[4]), + commands: settledValue(entries[5]), + tools: settledValue(entries[6]), + skills: settledValue(entries[7]), + tasks: settledValue(entries[8]), + usage: settledValue(entries[9]), + artifacts: settledValue(entries[10]), + cron: settledValue(entries[11]), + agents: settledValue(entries[12]), + config: settledValue(entries[13]), + }); + } + + listModels(params: Record = { view: "configured" }): Promise { + return this.call("models.list", params); + } + + listTools(params: Record = {}): Promise { + return this.call("tools.catalog", params); + } + + effectiveTools(sessionKey: string): Promise { + return this.call("tools.effective", { sessionKey }); + } + + invokeTool(params: Record, options?: GatewayRequestOptions): Promise { + return this.call("tools.invoke", params, options); + } + + listTasks(params: Record = { limit: 100 }): Promise { + return this.call("tasks.list", params); + } + + getTask(taskId: string): Promise { + return this.call("tasks.get", { taskId }); + } + + cancelTask(taskId: string, reason?: string): Promise { + return this.call("tasks.cancel", stripUndefined({ reason, taskId })); + } + + listArtifacts(params: Record): Promise { + return this.call("artifacts.list", params); + } + + getArtifact(params: Record): Promise { + return this.call("artifacts.get", params); + } + + downloadArtifact(params: Record): Promise { + return this.call("artifacts.download", params); + } + async createSession(options: OpenClawSessionCreateOptions): Promise { const raw = await this.transport.request("sessions.create", stripUndefined({ agentId: options.agentId, @@ -195,6 +291,30 @@ export class OpenClawGatewayRuntime { return { raw, runId, sessionKey: stringValue(record.sessionKey) ?? options.sessionKey }; } + async steerSession(options: OpenClawSessionSendOptions): Promise { + const requestOptions: GatewayRequestOptions = { expectFinal: true }; + if (options.timeoutMs !== undefined) requestOptions.timeoutMs = options.timeoutMs; + const raw = await this.transport.request("sessions.steer", { + key: options.sessionKey, + message: options.message, + ...(options.attachments ? { attachments: options.attachments } : {}), + ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}), + ...(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 sessions.steer did not return a runId"); + return { raw, runId, sessionKey: stringValue(record.sessionKey) ?? options.sessionKey }; + } + + abortSession(params: { runId?: string; sessionKey?: string }): Promise { + return this.call("sessions.abort", stripUndefined({ + key: params.sessionKey, + runId: params.runId, + })); + } + eventsForRun(runId: string): AsyncIterable { return this.transport.events((event) => { const payload = recordValue(event.payload); @@ -463,6 +583,10 @@ function stringValue(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } +function settledValue(result: PromiseSettledResult): unknown { + return result.status === "fulfilled" ? result.value : undefined; +} + async function readGatewayResponse(response: Response): Promise { const text = await response.text(); if (!response.ok) throw new Error(`OpenClaw gateway request failed (${response.status}): ${text || response.statusText}`); From 2d89f80adc5ab8526c426fcee730ace2da5efa33 Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Sat, 16 May 2026 16:44:29 +0200 Subject: [PATCH 20/43] Add OpenClaw session backfill executor --- packages/openclaw/src/backfill.test.ts | 56 ++++++++++++++++++++- packages/openclaw/src/backfill.ts | 68 +++++++++++++++++++++++++- 2 files changed, 122 insertions(+), 2 deletions(-) diff --git a/packages/openclaw/src/backfill.test.ts b/packages/openclaw/src/backfill.test.ts index 1252360..547d977 100644 --- a/packages/openclaw/src/backfill.test.ts +++ b/packages/openclaw/src/backfill.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from "vitest"; -import { buildBackfillImport, discoverOneToOneSessions, isOneToOneSession } from "./backfill"; +import { backfillAllOpenClawSessions, buildBackfillImport, discoverOneToOneSessions, isOneToOneSession } from "./backfill"; import { createDefaultConfig } from "./config"; import { OpenClawGatewayRuntime, type OpenClawTransport } from "./openclaw-runtime"; +import { OpenClawBridgeRegistry } from "./registry"; describe("OpenClaw backfill", () => { it("discovers terminal, mac app, and DM-like sessions while skipping group sessions", async () => { @@ -127,6 +128,59 @@ describe("OpenClaw backfill", () => { expect(isOneToOneSession({ key: "agent:main:whatsapp:user", lastTo: "user" })).toBe(true); expect(isOneToOneSession({ chatType: "group", key: "agent:main:group", lastTo: "a,b" })).toBe(false); }); + + it("creates portals and imports every discovered one-to-one session", async () => { + const runtime = runtimeWith({ + "chat.history": { messages: [{ content: "hello", id: "m1", role: "user" }] }, + "sessions.list": { + sessions: [ + { agentId: "codex", chatType: "dm", displayName: "Alice", key: "agent:codex:whatsapp:alice", lastProvider: "whatsapp", lastTo: "alice" }, + ], + }, + }); + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-backfill-test.json"); + const bridge = { + backfillPortal: vi.fn(async () => ({ eventIds: [] })), + createPortal: vi.fn(async () => ({ + id: "session:created", + mxid: "!room:example.com", + portalKey: { id: "session:created", receiver: "login" }, + receiver: "login", + })), + }; + const login = { id: "login", userId: "@owner:example.com" }; + + await expect(backfillAllOpenClawSessions({ + bridge: bridge as never, + limit: 25, + login, + registry, + runtime, + })).resolves.toMatchObject({ + portals: [{ mxid: "!room:example.com" }], + sessions: [{ agentId: "codex", sessionKey: "agent:codex:whatsapp:alice" }], + }); + + expect(bridge.createPortal).toHaveBeenCalledWith(login, expect.objectContaining({ + metadata: { + openclaw: { + agentId: "codex", + ghostUserId: "@openclaw_agent_codex:localhost", + humanGhostUserId: "@openclaw_user_alice:localhost", + sessionKey: "agent:codex:whatsapp:alice", + source: "channel", + }, + }, + name: "Alice", + roomType: "dm", + sender: "codex", + })); + expect(bridge.backfillPortal).toHaveBeenCalledWith(login, expect.objectContaining({ + mxid: "!room:example.com", + }), { limit: 25 }); + expect(registry.getUser("alice")?.ghostUserId).toBe("@openclaw_user_alice:localhost"); + expect(registry.getBindingByRoom("!room:example.com")?.humanGhostUserId).toBe("@openclaw_user_alice:localhost"); + }); }); function runtimeWith(responses: Record): OpenClawGatewayRuntime & { diff --git a/packages/openclaw/src/backfill.ts b/packages/openclaw/src/backfill.ts index 818a11b..53312f2 100644 --- a/packages/openclaw/src/backfill.ts +++ b/packages/openclaw/src/backfill.ts @@ -1,9 +1,11 @@ +import type { PickleBridge, Portal, UserLogin } from "@beeper/pickle-bridge"; import type { OpenClawChatHistoryMessage, OpenClawGatewayRuntime, OpenClawListedSession, } from "./openclaw-runtime"; -import { agentGhostUserId, bindingIdForRoom, userContactFromOpenClawSession } from "./rooms"; +import { agentContactFromOpenClawAgent, agentGhostUserId, bindingIdForRoom, userContactFromOpenClawSession } from "./rooms"; +import type { OpenClawBridgeRegistry } from "./registry"; import type { OpenClawBridgeConfig, OpenClawSessionBinding, OpenClawUserContact } from "./types"; export interface OpenClawBackfillSession { @@ -30,6 +32,19 @@ export interface OpenClawBackfillImport { source: OpenClawBackfillSession["source"]; } +export interface BackfillAllOpenClawSessionsOptions { + bridge: PickleBridge; + limit?: number; + login: UserLogin; + registry: OpenClawBridgeRegistry; + runtime: OpenClawGatewayRuntime; +} + +export interface BackfillAllOpenClawSessionsResult { + portals: Portal[]; + sessions: OpenClawBackfillSession[]; +} + export async function discoverOneToOneSessions(runtime: OpenClawGatewayRuntime): Promise { const sessions = await runtime.listSessions({ includeArchived: true }); return sessions.flatMap((session) => { @@ -78,6 +93,50 @@ export async function buildBackfillImport( }; } +export async function backfillAllOpenClawSessions(options: BackfillAllOpenClawSessionsOptions): Promise { + const sessions = await discoverOneToOneSessions(options.runtime); + const portals: Portal[] = []; + for (const session of sessions) { + const agent = options.registry.getAgent(session.agentId) ?? agentContactFromOpenClawAgent(options.runtime.config, { + id: session.agentId, + }); + options.registry.upsertAgent(agent); + if (session.human) options.registry.upsertUser(session.human); + const portal = await options.bridge.createPortal(options.login, { + id: portalIdForBackfillSession(session), + metadata: { + openclaw: stripUndefined({ + agentId: session.agentId, + ghostUserId: agent.ghostUserId, + humanGhostUserId: session.human?.ghostUserId, + sessionKey: session.sessionKey, + source: session.source, + }), + }, + name: session.label, + roomType: "dm", + sender: session.agentId, + }); + portals.push(portal); + if (portal.mxid) { + const importOptions: { limit?: number; roomId: string } = { roomId: portal.mxid }; + if (options.limit !== undefined) importOptions.limit = options.limit; + const imported = await buildBackfillImport(options.runtime, options.runtime.config, session, { + ...importOptions, + }); + options.registry.upsertBinding(imported.binding); + } + await options.bridge.backfillPortal(options.login, portal, { + ...(options.limit !== undefined ? { limit: options.limit } : {}), + }); + } + return { portals, sessions }; +} + +export function portalIdForBackfillSession(session: Pick): string { + return `session:${Buffer.from(session.sessionKey).toString("base64url")}`; +} + export function isOneToOneSession(session: OpenClawListedSession): boolean { const chatType = session.chatType?.toLowerCase(); if (chatType && ["dm", "direct", "private", "one_to_one", "1:1"].includes(chatType)) return true; @@ -137,3 +196,10 @@ function recordValue(value: unknown): Record | undefined { function stringValue(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } + +function stripUndefined>(value: T): T { + for (const key of Object.keys(value)) { + if (value[key] === undefined) delete value[key]; + } + return value; +} From adcb310ea650210f4d6a94d77366dd8c411800b8 Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Sat, 16 May 2026 16:46:13 +0200 Subject: [PATCH 21/43] Normalize OpenClaw gateway stream events --- .../openclaw/src/openclaw-event-map.test.ts | 50 +++++++++++++++++++ packages/openclaw/src/openclaw-event-map.ts | 50 +++++++++++++++---- 2 files changed, 91 insertions(+), 9 deletions(-) diff --git a/packages/openclaw/src/openclaw-event-map.test.ts b/packages/openclaw/src/openclaw-event-map.test.ts index 8fe4075..8a25a09 100644 --- a/packages/openclaw/src/openclaw-event-map.test.ts +++ b/packages/openclaw/src/openclaw-event-map.test.ts @@ -138,4 +138,54 @@ describe("OpenClaw event to Beeper stream mapping", () => { }, ]); }); + + it("normalizes upstream gateway session and approval event families", () => { + const state = createOpenClawStreamState("turn_gateway"); + + expect(mapOpenClawEventToBeeperChunks(state, { + event: "session.operation", + payload: { phase: "started", runId: "run_1", sessionKey: "session_1" }, + })).toEqual([ + { + messageId: "turn_gateway", + messageMetadata: { + run_id: "run_1", + session_key: "session_1", + turn_id: "turn_gateway", + }, + type: "start", + }, + ]); + expect(mapOpenClawEventToBeeperChunks(state, { + event: "session.message", + payload: { deltaText: "Hello", role: "assistant", runId: "run_1" }, + })).toEqual([ + { id: "text_turn_gateway", type: "text-start" }, + { delta: "Hello", id: "text_turn_gateway", type: "text-delta" }, + ]); + expect(mapOpenClawEventToBeeperChunks(state, { + event: "session.tool", + payload: { args: { cmd: "pwd" }, phase: "started", tool: "exec", toolCallId: "tool_1" }, + })).toEqual([ + { + dynamic: true, + input: { cmd: "pwd" }, + toolCallId: "tool_1", + toolName: "exec", + type: "tool-input-available", + }, + ]); + expect(mapOpenClawEventToBeeperChunks(state, { + event: "exec.approval.requested", + payload: { id: "approval_1", reason: "Run command?", tool: "exec", toolCallId: "tool_1" }, + })).toEqual([ + { + approvalId: "approval_1", + message: "Run command?", + toolCallId: "tool_1", + toolName: "exec", + type: "tool-approval-request", + }, + ]); + }); }); diff --git a/packages/openclaw/src/openclaw-event-map.ts b/packages/openclaw/src/openclaw-event-map.ts index d33bc07..9c8af76 100644 --- a/packages/openclaw/src/openclaw-event-map.ts +++ b/packages/openclaw/src/openclaw-event-map.ts @@ -26,7 +26,8 @@ export function mapOpenClawEventToBeeperChunks( event: unknown ): BeeperUIMessageChunk[] { const record = recordValue(event); - const type = stringValue(record?.type) ?? stringValue(record?.event); + const rawType = stringValue(record?.type) ?? stringValue(record?.event); + const type = normalizeOpenClawEventType(rawType, record); if (!record || !type) return []; const data = recordValue(record.data) ?? recordValue(record.payload) ?? record; const metadata = streamMetadata(record); @@ -37,11 +38,11 @@ export function mapOpenClawEventToBeeperChunks( case "run.started": return [startChunk(state, metadata)]; case "assistant.delta": { - const delta = stringValue(data.delta) ?? stringValue(data.text) ?? stringValue(data.content); + const delta = stringValue(data.delta) ?? stringValue(data.deltaText) ?? stringValue(data.text) ?? stringValue(data.content); return delta ? mapOpenClawMessageDelta(state, { kind: "text", value: delta }) : []; } case "assistant.message": { - const text = stringValue(data.text) ?? stringValue(data.content) ?? stringValue(data.message); + const text = stringValue(data.deltaText) ?? stringValue(data.text) ?? stringValue(data.content) ?? stringValue(data.message); return text ? mapOpenClawMessageDelta(state, { kind: "text", value: text }) : []; } case "thinking.delta": { @@ -83,13 +84,44 @@ export function mapOpenClawEventToBeeperChunks( } } +export function normalizeOpenClawEventType(type: string | undefined, event?: Record): string | undefined { + if (!type) return undefined; + const payload = recordValue(event?.payload) ?? recordValue(event?.data) ?? event; + const phase = stringValue(payload?.phase) ?? stringValue(payload?.status) ?? stringValue(payload?.kind); + if (type === "chat") return "assistant.delta"; + if (type === "session.message") { + const role = stringValue(payload?.role); + if (role === "assistant") return "assistant.delta"; + if (role === "reasoning" || role === "thinking") return "thinking.delta"; + return "assistant.message"; + } + if (type === "session.operation") { + if (phase === "started" || phase === "queued" || phase === "running") return "run.started"; + if (phase === "completed" || phase === "complete" || phase === "done") return "run.completed"; + if (phase === "failed" || phase === "error") return "run.failed"; + if (phase === "cancelled" || phase === "canceled") return "run.cancelled"; + if (phase === "timed_out" || phase === "timeout") return "run.timed_out"; + return type; + } + if (type === "session.tool") { + if (phase === "delta" || payload?.delta !== undefined || payload?.inputTextDelta !== undefined) return "tool.call.delta"; + if (phase === "completed" || phase === "complete" || phase === "result") return "tool.call.completed"; + if (phase === "failed" || phase === "error") return "tool.call.failed"; + return "tool.call.started"; + } + if (type === "exec.approval.requested" || type === "plugin.approval.requested") return "approval.requested"; + if (type === "exec.approval.resolved" || type === "plugin.approval.resolved") return "approval.resolved"; + return type; +} + function streamMetadata(event: Record): Record { + const payload = recordValue(event.payload) ?? recordValue(event.data); return stripUndefined({ - agent_id: stringValue(event.agentId), - run_id: stringValue(event.runId), - session_id: stringValue(event.sessionId), - session_key: stringValue(event.sessionKey), - task_id: stringValue(event.taskId), + agent_id: stringValue(event.agentId) ?? stringValue(payload?.agentId), + run_id: stringValue(event.runId) ?? stringValue(payload?.runId), + session_id: stringValue(event.sessionId) ?? stringValue(payload?.sessionId), + session_key: stringValue(event.sessionKey) ?? stringValue(payload?.sessionKey), + task_id: stringValue(event.taskId) ?? stringValue(payload?.taskId), }); } @@ -154,7 +186,7 @@ function toolCallId(data: Record): string { } function toolName(data: Record): string | undefined { - return stringValue(data.toolName) ?? stringValue(data.name); + return stringValue(data.toolName) ?? stringValue(data.name) ?? stringValue(data.tool); } function parseMaybeJSONValue(value: unknown): unknown { From 58cfd370c4e2cf32333117220bfd33f194fd2648 Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Sat, 16 May 2026 16:49:26 +0200 Subject: [PATCH 22/43] Add OpenClaw protocol coverage manifest --- packages/openclaw/package.json | 4 ++ packages/openclaw/src/index.ts | 1 + .../openclaw/src/protocol-coverage.test.ts | 57 +++++++++++++++++ packages/openclaw/src/protocol-coverage.ts | 64 +++++++++++++++++++ packages/openclaw/tsdown.config.ts | 2 +- 5 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 packages/openclaw/src/protocol-coverage.test.ts create mode 100644 packages/openclaw/src/protocol-coverage.ts diff --git a/packages/openclaw/package.json b/packages/openclaw/package.json index bb46987..4542740 100644 --- a/packages/openclaw/package.json +++ b/packages/openclaw/package.json @@ -63,6 +63,10 @@ "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" diff --git a/packages/openclaw/src/index.ts b/packages/openclaw/src/index.ts index 259ec7e..8505c46 100644 --- a/packages/openclaw/src/index.ts +++ b/packages/openclaw/src/index.ts @@ -8,6 +8,7 @@ export * from "./config"; export * from "./connector"; export * from "./openclaw-event-map"; export * from "./openclaw-runtime"; +export * from "./protocol-coverage"; export * from "./registry"; export * from "./registration"; export * from "./rooms"; diff --git a/packages/openclaw/src/protocol-coverage.test.ts b/packages/openclaw/src/protocol-coverage.test.ts new file mode 100644 index 0000000..d7c19d8 --- /dev/null +++ b/packages/openclaw/src/protocol-coverage.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { + OPENCLAW_BRIDGE_COVERAGE, + 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 generic gateway calls plus wrappers", () => { + expect(OPENCLAW_BRIDGE_COVERAGE.methodAccess.genericGatewayCall).toBe("OpenClawGatewayRuntime.call"); + expect(OPENCLAW_BRIDGE_COVERAGE.methodAccess.bridgeSpecificWrappers).toEqual(expect.arrayContaining([ + "agents.list", + "sessions.send", + "sessions.steer", + "sessions.abort", + "chat.history", + "exec.approval.resolve", + "tools.invoke", + "artifacts.download", + ])); + }); +}); diff --git a/packages/openclaw/src/protocol-coverage.ts b/packages/openclaw/src/protocol-coverage.ts new file mode 100644 index 0000000..7e27d5d --- /dev/null +++ b/packages/openclaw/src/protocol-coverage.ts @@ -0,0 +1,64 @@ +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_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: { + bridgeSpecificWrappers: ["agents.list", "sessions.list", "sessions.create", "sessions.send", "sessions.steer", "sessions.abort", "chat.history", "exec.approval.resolve", "models.list", "tools.catalog", "tools.effective", "tools.invoke", "tasks.list", "tasks.get", "tasks.cancel", "artifacts.list", "artifacts.get", "artifacts.download"], + genericGatewayCall: "OpenClawGatewayRuntime.call", + snapshotProbe: ["health", "status", "models.list", "channels.status", "sessions.list", "commands.list", "tools.catalog", "skills.status", "tasks.list", "usage.status", "artifacts.list", "cron.list", "agents.list", "config.get"], + }, + source: ".upstream/openclaw/docs/gateway/protocol.md", +} as const; + +export type OpenClawGatewayMethodFamily = typeof OPENCLAW_GATEWAY_METHOD_FAMILIES[number]; +export type OpenClawGatewayEventFamily = typeof OPENCLAW_GATEWAY_EVENT_FAMILIES[number]; diff --git a/packages/openclaw/tsdown.config.ts b/packages/openclaw/tsdown.config.ts index fc9436e..75cf7e8 100644 --- a/packages/openclaw/tsdown.config.ts +++ b/packages/openclaw/tsdown.config.ts @@ -3,6 +3,6 @@ import { defineConfig } from "tsdown"; export default defineConfig({ clean: true, dts: true, - entry: ["src/approval.ts", "src/appservice.ts", "src/backfill.ts", "src/beeper-setup.ts", "src/bridge-agent.ts", "src/cli.ts", "src/config.ts", "src/connector.ts", "src/index.ts", "src/openclaw-event-map.ts", "src/openclaw-runtime.ts", "src/registry.ts", "src/registration.ts", "src/rooms.ts", "src/stream-map.ts", "src/types.ts"], + entry: ["src/approval.ts", "src/appservice.ts", "src/backfill.ts", "src/beeper-setup.ts", "src/bridge-agent.ts", "src/cli.ts", "src/config.ts", "src/connector.ts", "src/index.ts", "src/openclaw-event-map.ts", "src/openclaw-runtime.ts", "src/protocol-coverage.ts", "src/registry.ts", "src/registration.ts", "src/rooms.ts", "src/stream-map.ts", "src/types.ts"], format: ["esm"], }); From f75e8b4af84aedb890d409ace35df5a7fd60f3a1 Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Sat, 16 May 2026 16:49:45 +0200 Subject: [PATCH 23/43] Document OpenClaw bridge package usage --- packages/openclaw/README.md | 99 ++++++++++++++++++++++++++++++++++--- 1 file changed, 92 insertions(+), 7 deletions(-) diff --git a/packages/openclaw/README.md b/packages/openclaw/README.md index 52065eb..11a201d 100644 --- a/packages/openclaw/README.md +++ b/packages/openclaw/README.md @@ -1,13 +1,98 @@ # @beeper/pickle-openclaw -`@beeper/pickle-openclaw` is the Pickle package for bridging OpenClaw sessions into Beeper/Matrix. +Pickle bridge package for exposing OpenClaw Gateway sessions in Beeper/Matrix. -The bridge is appservice-first: it creates non-federated Matrix rooms on the homeserver, represents every OpenClaw agent as a bridge-owned ghost contact, and streams OpenClaw runs into Beeper Desktop's native AI message UI. +## What It Provides -Current package surface: +- Beeper email-code login for existing accounts or account creation. +- Beeper appservice registration for the OpenClaw bridge. +- Pickle bridgev2-style connector for OpenClaw agents, sessions, approvals, and backfill. +- OpenClaw WebSocket Gateway transport using protocol v4 `req`/`res`/`event` frames. +- Compatibility HTTP/SSE transport for gateway-like test or proxy deployments. +- Agent ghosts for OpenClaw agents and user ghosts for imported one-to-one sessions. +- Non-federated Matrix room creation defaults through the generated appservice registration. +- Backfill helpers for terminal, mac app, and external one-to-one OpenClaw sessions. -- OpenClaw session and agent binding types. -- Desktop-compatible stream chunk builders. -- OpenClaw SDK event to Beeper stream mapping for assistant text, thinking, tools, run finalization, and approvals. +## CLI -Planned appservice modules will add Beeper account setup/provisioning, bridge registration, room and Space management, terminal/mac app backfill, and live OpenClaw gateway session control. +Write a local config: + +```sh +pickle-openclaw init \ + --config ~/.openclaw/pickle-bridge/config.json \ + --gateway-url ws://127.0.0.1:18789 +``` + +Log in to an existing Beeper account: + +```sh +pickle-openclaw beeper-login \ + --config ~/.openclaw/pickle-bridge/config.json \ + --email you@example.com \ + --login-code 123456 +``` + +Request Beeper account creation during the same email-code flow: + +```sh +pickle-openclaw beeper-login \ + --config ~/.openclaw/pickle-bridge/config.json \ + --email you@example.com \ + --login-code 123456 \ + --create-account +``` + +Register the OpenClaw appservice with Beeper: + +```sh +pickle-openclaw beeper-register \ + --config ~/.openclaw/pickle-bridge/config.json +``` + +Do login and appservice registration in one step: + +```sh +pickle-openclaw beeper-setup \ + --config ~/.openclaw/pickle-bridge/config.json \ + --email you@example.com \ + --login-code 123456 \ + --gateway-url ws://127.0.0.1:18789 +``` + +Start the bridge: + +```sh +pickle-openclaw start --config ~/.openclaw/pickle-bridge/config.json +``` + +## Programmatic Runtime + +```ts +import { + accountFromOpenClawConfig, + backfillAllOpenClawSessions, + createDefaultConfig, + createOpenClawBeeperBridge, +} from "@beeper/pickle-openclaw"; + +const config = createDefaultConfig({ + accessToken: process.env.BEEPER_ACCESS_TOKEN, + gatewayUrl: "ws://127.0.0.1:18789", + homeserver: "https://matrix.beeper.com", + matrixDeviceId: process.env.BEEPER_DEVICE_ID, + matrixUserId: process.env.BEEPER_USER_ID, +}); + +const bridge = await createOpenClawBeeperBridge({ + account: accountFromOpenClawConfig(config), + config, +}); + +await bridge.start(); +``` + +The runtime exposes `OpenClawGatewayRuntime.call(method, params)` for the full Gateway RPC surface. Common bridge paths also have wrappers for agents, sessions, models, tools, tasks, artifacts, approvals, and feature snapshots. + +## Protocol Coverage + +`src/protocol-coverage.ts` tracks the upstream Gateway method and event families from `.upstream/openclaw/docs/gateway/protocol.md`. The manifest is tested so future changes can audit which families are streamed to Matrix, mapped to approvals, intentionally ignored as operational noise, or available through generic Gateway calls. From a7c03efb6d9b65f548febb3e8014083982d6de82 Mon Sep 17 00:00:00 2001 From: batuhan icoz Date: Sat, 16 May 2026 16:53:47 +0200 Subject: [PATCH 24/43] Wire OpenClaw startup backfill --- packages/openclaw/README.md | 9 +++++ packages/openclaw/src/appservice.ts | 27 ++++++++++++++- packages/openclaw/src/bridge-agent.test.ts | 38 ++++++++++++++++++++++ packages/openclaw/src/bridge-agent.ts | 3 ++ packages/openclaw/src/cli.test.ts | 4 ++- packages/openclaw/src/cli.ts | 13 ++++++++ packages/openclaw/src/connector.ts | 21 ++++++++++++ 7 files changed, 113 insertions(+), 2 deletions(-) diff --git a/packages/openclaw/README.md b/packages/openclaw/README.md index 11a201d..427b2a5 100644 --- a/packages/openclaw/README.md +++ b/packages/openclaw/README.md @@ -65,6 +65,15 @@ Start the bridge: pickle-openclaw start --config ~/.openclaw/pickle-bridge/config.json ``` +Start the bridge and import discovered one-to-one OpenClaw sessions from terminal, mac app, and channel surfaces: + +```sh +pickle-openclaw start \ + --config ~/.openclaw/pickle-bridge/config.json \ + --backfill \ + --backfill-limit 500 +``` + ## Programmatic Runtime ```ts diff --git a/packages/openclaw/src/appservice.ts b/packages/openclaw/src/appservice.ts index e578388..c1da5a4 100644 --- a/packages/openclaw/src/appservice.ts +++ b/packages/openclaw/src/appservice.ts @@ -1,11 +1,15 @@ import type { MatrixAccount } from "@beeper/pickle"; import { createBeeperBridge, type CreateNodeBeeperBridgeOptions, type PickleBridge } from "@beeper/pickle-bridge"; +import { backfillAllOpenClawSessions } from "./backfill"; import { DEFAULT_BEEPER_BRIDGE, DEFAULT_BEEPER_BRIDGE_TYPE } from "./beeper-setup"; -import { createOpenClawConnector, type OpenClawConnectorOptions } from "./connector"; +import { createOpenClawConnector, createOpenClawRuntimeFromLogin, userLoginFromOpenClawConfig, type OpenClawConnectorOptions } from "./connector"; +import { OpenClawBridgeRegistry } from "./registry"; import type { OpenClawBridgeConfig } from "./types"; export interface CreateOpenClawBeeperBridgeOptions extends OpenClawConnectorOptions { account: MatrixAccount; + backfill?: boolean; + backfillLimit?: number; bridge?: string; bridgeFactory?: (options: CreateNodeBeeperBridgeOptions) => Promise; bridgeType?: string; @@ -37,6 +41,21 @@ export async function createOpenClawBeeperBridge(options: CreateOpenClawBeeperBr export async function startOpenClawBeeperBridge(options: CreateOpenClawBeeperBridgeOptions): Promise { const bridge = await createOpenClawBeeperBridge(options); await bridge.start(); + if (options.backfill) { + const config = options.config; + if (!config) throw new Error("OpenClaw backfill requires config"); + const registry = options.registry ?? registryFromConnector(bridge.connector); + if (!registry) throw new Error("OpenClaw backfill requires registry"); + const login = userLoginFromOpenClawConfig(config); + await backfillAllOpenClawSessions({ + bridge, + ...(options.backfillLimit !== undefined ? { limit: options.backfillLimit } : {}), + login, + registry, + runtime: options.runtimeFactory?.(login, config) ?? createOpenClawRuntimeFromLogin(login, config), + }); + await registry.save(); + } return bridge; } @@ -62,3 +81,9 @@ function connectorOptions(options: CreateOpenClawBeeperBridgeOptions): OpenClawC if (options.transportFactory !== undefined) output.transportFactory = options.transportFactory; return output; } + +function registryFromConnector(connector: unknown): OpenClawBridgeRegistry | undefined { + if (!connector || typeof connector !== "object" || !("registry" in connector)) return undefined; + const registry = (connector as { registry?: unknown }).registry; + return registry instanceof OpenClawBridgeRegistry ? registry : undefined; +} diff --git a/packages/openclaw/src/bridge-agent.test.ts b/packages/openclaw/src/bridge-agent.test.ts index 6f9fdb4..689b281 100644 --- a/packages/openclaw/src/bridge-agent.test.ts +++ b/packages/openclaw/src/bridge-agent.test.ts @@ -62,6 +62,44 @@ describe("OpenClawMatrixBridgeAgent", () => { ]); }); + it("preserves gateway event names when streaming protocol-v4 payload frames", async () => { + const registry = await tempRegistry(); + const binding = testBinding(); + registry.upsertBinding(binding); + const published: unknown[] = []; + const streams: OpenClawBridgeStreamPublisher = { + publish(_binding, chunks) { + published.push(...chunks); + }, + }; + const agent = new OpenClawMatrixBridgeAgent({ + registry, + runtime: runtimeWith({ + events: [ + { event: "session.operation", payload: { phase: "started", runId: "run_1" } }, + { event: "session.message", payload: { deltaText: "hello", role: "assistant", runId: "run_1" } }, + { event: "session.tool", payload: { input: { cmd: "pwd" }, name: "shell", phase: "started", runId: "run_1", toolCallId: "tool_1" } }, + { event: "exec.approval.requested", payload: { approvalId: "approval_1", message: "Run command?", runId: "run_1", toolCallId: "tool_1" } }, + { event: "session.operation", payload: { phase: "completed", runId: "run_1" } }, + ], + responses: {}, + }), + streams, + }); + + await agent.streamRun(binding, "run_1"); + + expect(published.map((chunk) => (chunk as { type: string }).type)).toEqual([ + "start", + "text-start", + "text-delta", + "tool-input-available", + "tool-approval-request", + "text-end", + "finish", + ]); + }); + it("forwards Beeper approval responses back to OpenClaw", async () => { const registry = await tempRegistry(); const runtime = runtimeWith({ responses: { "exec.approval.resolve": { ok: true } } }); diff --git a/packages/openclaw/src/bridge-agent.ts b/packages/openclaw/src/bridge-agent.ts index 02aa625..272ff60 100644 --- a/packages/openclaw/src/bridge-agent.ts +++ b/packages/openclaw/src/bridge-agent.ts @@ -83,6 +83,9 @@ export class OpenClawMatrixBridgeAgent { } function openClawEventFromGateway(event: OpenClawGatewayEvent): unknown { + if (event.event && event.payload && typeof event.payload === "object") { + return { ...(event.payload as Record), payload: event.payload, type: event.event }; + } if (event.payload && typeof event.payload === "object") { return event.payload; } diff --git a/packages/openclaw/src/cli.test.ts b/packages/openclaw/src/cli.test.ts index 2485f60..90a8bf7 100644 --- a/packages/openclaw/src/cli.test.ts +++ b/packages/openclaw/src/cli.test.ts @@ -79,7 +79,7 @@ describe("pickle-openclaw CLI", () => { "@batuhan:beeper.com", ], captureIO())).resolves.toBe(0); - await expect(runCli(["start", "--config", configPath, "--get-only"], io, { startBridge })).resolves.toBe(0); + await expect(runCli(["start", "--config", configPath, "--get-only", "--backfill", "--backfill-limit", "25"], io, { startBridge })).resolves.toBe(0); expect(startBridge).toHaveBeenCalledWith(expect.objectContaining({ account: { @@ -88,6 +88,8 @@ describe("pickle-openclaw CLI", () => { homeserver: "https://matrix.beeper.com", userId: "@batuhan:beeper.com", }, + backfill: true, + backfillLimit: 25, config: expect.objectContaining({ gatewayUrl: "http://127.0.0.1:29390", matrixUserId: "@batuhan:beeper.com", diff --git a/packages/openclaw/src/cli.ts b/packages/openclaw/src/cli.ts index d65158f..796ccc5 100644 --- a/packages/openclaw/src/cli.ts +++ b/packages/openclaw/src/cli.ts @@ -56,6 +56,9 @@ export async function runCli(argv = process.argv.slice(2), io: CliIO = process, config, }; if (booleanOption(options, "get-only")) startOptions.getOnly = true; + if (booleanOption(options, "backfill")) startOptions.backfill = true; + const backfillLimit = numberOption(options, "backfill-limit"); + if (backfillLimit !== undefined) startOptions.backfillLimit = backfillLimit; await (deps.startBridge ?? startOpenClawBeeperBridge)(startOptions); io.stdout.write("OpenClaw bridge started\n"); return 0; @@ -194,6 +197,8 @@ function helpText(): string { " --email
", " --login-code ", " --create-account", + " --backfill", + " --backfill-limit ", " --env ", "", ].join("\n"); @@ -266,6 +271,14 @@ function booleanOption(options: Map, key: string): boo return options.get(key) === true; } +function numberOption(options: Map, key: string): number | undefined { + const value = stringOption(options, key); + if (value === undefined) return undefined; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 0) throw new Error(`Invalid --${key}: ${value}`); + return parsed; +} + function beeperEnvOption(options: Map): BeeperEnvironment | undefined { const env = stringOption(options, "env"); if (env === undefined) return undefined; diff --git a/packages/openclaw/src/connector.ts b/packages/openclaw/src/connector.ts index 1dc4b54..12a2084 100644 --- a/packages/openclaw/src/connector.ts +++ b/packages/openclaw/src/connector.ts @@ -377,6 +377,27 @@ function transportFromLogin(login: UserLogin, config: OpenClawBridgeConfig): Ope return createOpenClawHttpTransport(options); } +export function userLoginFromOpenClawConfig(config: OpenClawBridgeConfig): UserLogin { + const gatewayUrl = config.gatewayUrl; + if (!gatewayUrl) throw new Error("OpenClaw gateway URL is not configured"); + return { + id: `openclaw:${encodeLoginId(gatewayUrl)}`, + metadata: { + ...(config.accessToken ? { accessToken: config.accessToken } : {}), + gatewayUrl, + }, + remoteName: "OpenClaw", + userId: config.matrixUserId ?? config.serviceBotLocalpart, + }; +} + +export function createOpenClawRuntimeFromLogin(login: UserLogin, config: OpenClawBridgeConfig): OpenClawGatewayRuntime { + return new OpenClawGatewayRuntime({ + config, + transport: transportFromLogin(login, config), + }); +} + function encodeLoginId(value: string): string { return Buffer.from(value).toString("base64url").slice(0, 32); } From 7b6fce65000cb8a15bca2e190daa7957b3e0a5c7 Mon Sep 17 00:00:00 2001 From: batuhan icoz Date: Sat, 16 May 2026 16:56:15 +0200 Subject: [PATCH 25/43] Force non-federated OpenClaw portals --- packages/bridge/src/bridge.test.ts | 2 ++ packages/bridge/src/bridge.ts | 1 + packages/bridge/src/types.ts | 1 + packages/openclaw/src/backfill.test.ts | 1 + packages/openclaw/src/backfill.ts | 8 +++++--- packages/pickle/native/internal/core/appservice.go | 11 ++++++++++- .../pickle/native/internal/core/appservice_test.go | 8 +++++++- packages/pickle/src/generated-runtime-types.ts | 6 +----- 8 files changed, 28 insertions(+), 10 deletions(-) diff --git a/packages/bridge/src/bridge.test.ts b/packages/bridge/src/bridge.test.ts index 21d692b..79885fc 100644 --- a/packages/bridge/src/bridge.test.ts +++ b/packages/bridge/src/bridge.test.ts @@ -246,6 +246,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 +263,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", })); diff --git a/packages/bridge/src/bridge.ts b/packages/bridge/src/bridge.ts index e7845aa..5dbe024 100644 --- a/packages/bridge/src/bridge.ts +++ b/packages/bridge/src/bridge.ts @@ -289,6 +289,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, diff --git a/packages/bridge/src/types.ts b/packages/bridge/src/types.ts index 9cbdfba..ac62d89 100644 --- a/packages/bridge/src/types.ts +++ b/packages/bridge/src/types.ts @@ -646,6 +646,7 @@ export interface BridgeRemoteBackfillMessageOptions extends Omit
; info?: ChatInfo; initialState?: { content: Record; stateKey: string; type: string }[]; invite?: UserID[]; diff --git a/packages/openclaw/src/backfill.test.ts b/packages/openclaw/src/backfill.test.ts index 547d977..6b92a8d 100644 --- a/packages/openclaw/src/backfill.test.ts +++ b/packages/openclaw/src/backfill.test.ts @@ -162,6 +162,7 @@ describe("OpenClaw backfill", () => { }); expect(bridge.createPortal).toHaveBeenCalledWith(login, expect.objectContaining({ + creationContent: { "m.federate": false }, metadata: { openclaw: { agentId: "codex", diff --git a/packages/openclaw/src/backfill.ts b/packages/openclaw/src/backfill.ts index 53312f2..e5e3f2c 100644 --- a/packages/openclaw/src/backfill.ts +++ b/packages/openclaw/src/backfill.ts @@ -1,4 +1,4 @@ -import type { PickleBridge, Portal, UserLogin } from "@beeper/pickle-bridge"; +import type { BridgeCreatePortalOptions, PickleBridge, Portal, UserLogin } from "@beeper/pickle-bridge"; import type { OpenClawChatHistoryMessage, OpenClawGatewayRuntime, @@ -102,7 +102,7 @@ export async function backfillAllOpenClawSessions(options: BackfillAllOpenClawSe }); options.registry.upsertAgent(agent); if (session.human) options.registry.upsertUser(session.human); - const portal = await options.bridge.createPortal(options.login, { + const portalOptions: BridgeCreatePortalOptions = { id: portalIdForBackfillSession(session), metadata: { openclaw: stripUndefined({ @@ -116,7 +116,9 @@ export async function backfillAllOpenClawSessions(options: BackfillAllOpenClawSe name: session.label, roomType: "dm", sender: session.agentId, - }); + }; + if (options.runtime.config.nonFederatedRooms) portalOptions.creationContent = { "m.federate": false }; + const portal = await options.bridge.createPortal(options.login, portalOptions); portals.push(portal); if (portal.mxid) { const importOptions: { limit?: number; roomId: string } = { roomId: portal.mxid }; diff --git a/packages/pickle/native/internal/core/appservice.go b/packages/pickle/native/internal/core/appservice.go index 2ad6bef..1d750a7 100644 --- a/packages/pickle/native/internal/core/appservice.go +++ b/packages/pickle/native/internal/core/appservice.go @@ -91,6 +91,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"` @@ -356,7 +357,7 @@ func (as *matrixAppservice) makePortalCreateRoomRequest(req MatrixAppserviceCrea BeeperBridgeAccountID: req.PortalKey.Receiver, BeeperBridgeName: bridgeName, BeeperLocalRoomID: localRoomID, - CreationContent: map[string]any{}, + CreationContent: cloneMap(req.CreationContent), InitialState: make([]*event.Event, 0, 5), Invite: toUserIDs(req.Invite), IsDirect: req.IsDirect, @@ -705,3 +706,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 e556af3..ec5d17f 100644 --- a/packages/pickle/native/internal/core/appservice_test.go +++ b/packages/pickle/native/internal/core/appservice_test.go @@ -28,7 +28,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", @@ -50,6 +53,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) } diff --git a/packages/pickle/src/generated-runtime-types.ts b/packages/pickle/src/generated-runtime-types.ts index d963d02..3a259dc 100644 --- a/packages/pickle/src/generated-runtime-types.ts +++ b/packages/pickle/src/generated-runtime-types.ts @@ -74,6 +74,7 @@ export interface MatrixAppserviceCreatePortalRoomOptions { autoJoinInvites?: boolean; bridge: MatrixAppserviceBridgeName; bridgeName?: string; + creationContent?: { [key: string]: unknown }; initialState?: MatrixRoomStateInput[]; initialMembers?: string[]; invite?: string[]; @@ -218,11 +219,6 @@ export interface MatrixStartBeeperStreamMessageResult { descriptor: { [key: string]: unknown }; eventId: string; roomId: string; - subscribers?: MatrixBeeperStreamSubscriber[]; -} -export interface MatrixBeeperStreamSubscriber { - deviceId: string; - userId: string; } export interface MatrixPublishBeeperStreamMessagePartOptions { agentId?: string; From 227f644b926408789841cec6c9599620a4a8989e Mon Sep 17 00:00:00 2001 From: batuhan icoz Date: Sat, 16 May 2026 16:58:29 +0200 Subject: [PATCH 26/43] Expose OpenClaw gateway RPC management --- packages/openclaw/README.md | 13 +- packages/openclaw/src/cli.test.ts | 58 +++++++ packages/openclaw/src/cli.ts | 65 +++++++ .../openclaw/src/protocol-coverage.test.ts | 14 ++ packages/openclaw/src/protocol-coverage.ts | 162 ++++++++++++++++++ 5 files changed, 311 insertions(+), 1 deletion(-) diff --git a/packages/openclaw/README.md b/packages/openclaw/README.md index 427b2a5..3fe7e6c 100644 --- a/packages/openclaw/README.md +++ b/packages/openclaw/README.md @@ -74,6 +74,17 @@ pickle-openclaw start \ --backfill-limit 500 ``` +Probe or call the Gateway surface directly: + +```sh +pickle-openclaw features --config ~/.openclaw/pickle-bridge/config.json + +pickle-openclaw rpc \ + --config ~/.openclaw/pickle-bridge/config.json \ + config.schema.lookup \ + --params-json '{"path":["agents"]}' +``` + ## Programmatic Runtime ```ts @@ -100,7 +111,7 @@ const bridge = await createOpenClawBeeperBridge({ await bridge.start(); ``` -The runtime exposes `OpenClawGatewayRuntime.call(method, params)` for the full Gateway RPC surface. Common bridge paths also have wrappers for agents, sessions, models, tools, tasks, artifacts, approvals, and feature snapshots. +The runtime exposes `OpenClawGatewayRuntime.call(method, params)` and the CLI exposes `pickle-openclaw rpc --params-json ` for the full Gateway RPC surface. Common bridge paths also have wrappers for agents, sessions, models, tools, tasks, artifacts, approvals, and feature snapshots. ## Protocol Coverage diff --git a/packages/openclaw/src/cli.test.ts b/packages/openclaw/src/cli.test.ts index 90a8bf7..ef6ae90 100644 --- a/packages/openclaw/src/cli.test.ts +++ b/packages/openclaw/src/cli.test.ts @@ -98,8 +98,66 @@ describe("pickle-openclaw CLI", () => { })); expect(io.stdoutText).toContain("OpenClaw bridge started"); }); + + it("calls arbitrary OpenClaw Gateway RPC methods from config", async () => { + const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-rpc-")); + const configPath = join(dir, "config.json"); + await expect(runCli([ + "init", + "--config", + configPath, + "--data-dir", + dir, + "--gateway-url", + "http://127.0.0.1:29390", + ], captureIO())).resolves.toBe(0); + const runtime = fakeRuntime({ + "config.schema.lookup": { path: ["agents"], type: "object" }, + }); + const io = captureIO(); + + await expect(runCli([ + "rpc", + "--config", + configPath, + "config.schema.lookup", + "--params-json", + "{\"path\":[\"agents\"]}", + ], io, { runtimeFactory: () => runtime })).resolves.toBe(0); + + expect(runtime.call).toHaveBeenCalledWith("config.schema.lookup", { path: ["agents"] }); + expect(runtime.close).toHaveBeenCalledOnce(); + expect(JSON.parse(io.stdoutText)).toEqual({ path: ["agents"], type: "object" }); + }); + + it("prints an OpenClaw Gateway feature snapshot", async () => { + const runtime = fakeRuntime({}, { + agents: { agents: [] }, + status: { ok: true }, + }); + const io = captureIO(); + + await expect(runCli(["features", "--gateway-url", "http://127.0.0.1:29390"], io, { + runtimeFactory: () => runtime, + })).resolves.toBe(0); + + expect(runtime.featureSnapshot).toHaveBeenCalledOnce(); + expect(runtime.close).toHaveBeenCalledOnce(); + expect(JSON.parse(io.stdoutText)).toEqual({ + agents: { agents: [] }, + status: { ok: true }, + }); + }); }); +function fakeRuntime(responses: Record, snapshot: unknown = {}) { + return { + call: vi.fn(async (method: string) => responses[method]), + close: vi.fn(async () => undefined), + featureSnapshot: vi.fn(async () => snapshot), + } as never; +} + function captureIO() { const io = { stderrText: "", diff --git a/packages/openclaw/src/cli.ts b/packages/openclaw/src/cli.ts index 796ccc5..24d6c10 100644 --- a/packages/openclaw/src/cli.ts +++ b/packages/openclaw/src/cli.ts @@ -5,6 +5,8 @@ import type { BeeperEnvironment } from "@beeper/pickle/beeper/auth"; import { accountFromOpenClawConfig, startOpenClawBeeperBridge, type CreateOpenClawBeeperBridgeOptions } from "./appservice"; import { createOpenClawBeeperAppService, loginToBeeperForOpenClaw, setupOpenClawBeeperBridge } from "./beeper-setup"; import { createDefaultConfig, defaultConfigPath, readConfig, secretToken, writeConfig } from "./config"; +import { createOpenClawRuntimeFromLogin, userLoginFromOpenClawConfig } from "./connector"; +import type { OpenClawGatewayRuntime } from "./openclaw-runtime"; import { createAppserviceRegistration } from "./registration"; import type { AppserviceRegistration, OpenClawBridgeConfig } from "./types"; @@ -14,6 +16,7 @@ export interface CliIO { } export interface CliDeps { + runtimeFactory?: (config: OpenClawBridgeConfig) => OpenClawGatewayRuntime; startBridge?: (options: CreateOpenClawBeeperBridgeOptions) => Promise; } @@ -48,6 +51,32 @@ export async function runCli(argv = process.argv.slice(2), io: CliIO = process, io.stdout.write(`${JSON.stringify(redactConfig(config), null, 2)}\n`); return 0; } + if (command === "features") { + const options = parseOptions(args); + const config = await loadConfig(options); + const runtime = deps.runtimeFactory?.(config) ?? runtimeFromConfig(config); + try { + io.stdout.write(`${JSON.stringify(await runtime.featureSnapshot(), null, 2)}\n`); + } finally { + await runtime.close(); + } + return 0; + } + if (command === "rpc") { + const { paramsText, positional } = splitOptionsAndPositionals(args); + const options = parseOptions(args); + const method = positional[0]; + if (!method) throw new Error("rpc requires a Gateway method name"); + const params = paramsText !== undefined ? parseJsonParam(paramsText) : parseJsonParam(positional[1] ?? "{}"); + const config = await loadConfig(options); + const runtime = deps.runtimeFactory?.(config) ?? runtimeFromConfig(config); + try { + io.stdout.write(`${JSON.stringify(await runtime.call(method, params), null, 2)}\n`); + } finally { + await runtime.close(); + } + return 0; + } if (command === "start") { const options = parseOptions(args); const config = await loadConfig(options); @@ -178,6 +207,8 @@ function helpText(): string { " register Write a Matrix appservice registration file", " start Start the OpenClaw Beeper bridge from config", " status Print the redacted effective config", + " features Probe the documented OpenClaw Gateway feature surface", + " rpc Call any OpenClaw Gateway RPC method", " beeper-login Log in to Beeper and write Matrix credentials", " beeper-register Register the OpenClaw appservice with Beeper", " beeper-setup Log in and register the OpenClaw appservice", @@ -199,6 +230,7 @@ function helpText(): string { " --create-account", " --backfill", " --backfill-limit ", + " --params-json ", " --env ", "", ].join("\n"); @@ -256,6 +288,35 @@ function parseOptions(args: string[]): Map { return options; } +function splitOptionsAndPositionals(args: string[]): { paramsText?: string; positional: string[] } { + const positional: string[] = []; + let paramsText: string | undefined; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (!arg) continue; + if (arg === "--params-json") { + paramsText = args[index + 1]; + index += 1; + continue; + } + if (arg.startsWith("--")) { + const next = args[index + 1]; + if (next && !next.startsWith("--")) index += 1; + continue; + } + positional.push(arg); + } + return { ...(paramsText !== undefined ? { paramsText } : {}), positional }; +} + +function parseJsonParam(value: string): unknown { + try { + return JSON.parse(value); + } catch (error) { + throw new Error(`Invalid JSON params: ${error instanceof Error ? error.message : String(error)}`); + } +} + function stringOption(options: Map, key: string): string | undefined { const value = options.get(key); return typeof value === "string" ? value : undefined; @@ -294,6 +355,10 @@ function beeperBaseDomainOption(options: Map): string return undefined; } +function runtimeFromConfig(config: OpenClawBridgeConfig): OpenClawGatewayRuntime { + return createOpenClawRuntimeFromLogin(userLoginFromOpenClawConfig(config), config); +} + if (import.meta.url === `file://${process.argv[1]}`) { runCli().then((code) => { process.exitCode = code; diff --git a/packages/openclaw/src/protocol-coverage.test.ts b/packages/openclaw/src/protocol-coverage.test.ts index d7c19d8..316abe6 100644 --- a/packages/openclaw/src/protocol-coverage.test.ts +++ b/packages/openclaw/src/protocol-coverage.test.ts @@ -1,6 +1,7 @@ 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"; @@ -43,6 +44,7 @@ describe("OpenClaw gateway protocol coverage manifest", () => { it("keeps broad feature access routed through generic gateway calls plus wrappers", () => { expect(OPENCLAW_BRIDGE_COVERAGE.methodAccess.genericGatewayCall).toBe("OpenClawGatewayRuntime.call"); + expect(OPENCLAW_BRIDGE_COVERAGE.methodAccess.managementCli).toBe("pickle-openclaw rpc [json-params]"); expect(OPENCLAW_BRIDGE_COVERAGE.methodAccess.bridgeSpecificWrappers).toEqual(expect.arrayContaining([ "agents.list", "sessions.send", @@ -53,5 +55,17 @@ describe("OpenClaw gateway protocol coverage manifest", () => { "tools.invoke", "artifacts.download", ])); + 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 index 7e27d5d..e319a22 100644 --- a/packages/openclaw/src/protocol-coverage.ts +++ b/packages/openclaw/src/protocol-coverage.ts @@ -22,6 +22,165 @@ export const OPENCLAW_GATEWAY_METHOD_FAMILIES = [ "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.send", + "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", @@ -54,11 +213,14 @@ export const OPENCLAW_BRIDGE_COVERAGE = { }, methodAccess: { bridgeSpecificWrappers: ["agents.list", "sessions.list", "sessions.create", "sessions.send", "sessions.steer", "sessions.abort", "chat.history", "exec.approval.resolve", "models.list", "tools.catalog", "tools.effective", "tools.invoke", "tasks.list", "tasks.get", "tasks.cancel", "artifacts.list", "artifacts.get", "artifacts.download"], + commonGatewayMethods: OPENCLAW_GATEWAY_COMMON_METHODS, genericGatewayCall: "OpenClawGatewayRuntime.call", + managementCli: "pickle-openclaw rpc [json-params]", snapshotProbe: ["health", "status", "models.list", "channels.status", "sessions.list", "commands.list", "tools.catalog", "skills.status", "tasks.list", "usage.status", "artifacts.list", "cron.list", "agents.list", "config.get"], }, 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]; From 00a8a1134a59402a169cdd8b07f18c2e3e8bea7b Mon Sep 17 00:00:00 2001 From: batuhan icoz Date: Sat, 16 May 2026 16:59:56 +0200 Subject: [PATCH 27/43] Create sessions for OpenClaw agent DMs --- packages/openclaw/src/bridge-agent.test.ts | 35 ++++++++++++++++++++++ packages/openclaw/src/bridge-agent.ts | 25 ++++++++++++++-- packages/openclaw/src/connector.test.ts | 5 ++-- packages/openclaw/src/connector.ts | 4 +-- 4 files changed, 63 insertions(+), 6 deletions(-) diff --git a/packages/openclaw/src/bridge-agent.test.ts b/packages/openclaw/src/bridge-agent.test.ts index 689b281..c515caa 100644 --- a/packages/openclaw/src/bridge-agent.test.ts +++ b/packages/openclaw/src/bridge-agent.test.ts @@ -62,6 +62,41 @@ describe("OpenClawMatrixBridgeAgent", () => { ]); }); + it("creates an OpenClaw session before sending the first message in an agent contact DM", async () => { + const registry = await tempRegistry(); + registry.upsertBinding({ + ...testBinding(), + sessionKey: "agent:codex", + }); + 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" }, + "sessions.send": { runId: "run_1", sessionKey: "agent:codex:session_1" }, + }, + }); + const agent = new OpenClawMatrixBridgeAgent({ registry, runtime, streams: { publish: vi.fn() } }); + + 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.send", { + idempotencyKey: "$event", + key: "agent:codex:session_1", + message: "hello", + }, { expectFinal: true }); + expect(registry.getBindingByRoom("!room:example.com")?.sessionKey).toBe("agent:codex:session_1"); + }); + it("preserves gateway event names when streaming protocol-v4 payload frames", async () => { const registry = await tempRegistry(); const binding = testBinding(); diff --git a/packages/openclaw/src/bridge-agent.ts b/packages/openclaw/src/bridge-agent.ts index 272ff60..ffbf631 100644 --- a/packages/openclaw/src/bridge-agent.ts +++ b/packages/openclaw/src/bridge-agent.ts @@ -50,18 +50,20 @@ export class OpenClawMatrixBridgeAgent { await this.registry.save(); return; } + const sessionKey = await this.ensureSession(binding); const run = await this.runtime.sendMessage({ idempotencyKey: turn.eventId, message: turn.text, - sessionKey: binding.sessionKey, + sessionKey, }); this.registry.updateBinding(binding.id, (current) => ({ ...current, lastMatrixEventId: turn.eventId, lastRunId: run.runId, + sessionKey: run.sessionKey, updatedAt: Date.now(), })); - await this.streamRun(binding, run.runId); + await this.streamRun({ ...binding, sessionKey: run.sessionKey }, run.runId); await this.registry.save(); } @@ -80,6 +82,25 @@ export class OpenClawMatrixBridgeAgent { if (chunks.length > 0) await this.streams.publish(binding, chunks); } } + + async ensureSession(binding: OpenClawSessionBinding): Promise { + if (binding.sessionKey !== agentPortalSessionKey(binding.agentId)) return binding.sessionKey; + const createOptions: { agentId: string; label?: string } = { + agentId: binding.agentId, + }; + 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(), + })); + return session.key; + } +} + +export function agentPortalSessionKey(agentId: string): string { + return `agent:${agentId}`; } function openClawEventFromGateway(event: OpenClawGatewayEvent): unknown { diff --git a/packages/openclaw/src/connector.test.ts b/packages/openclaw/src/connector.test.ts index 8c6354b..8a67abe 100644 --- a/packages/openclaw/src/connector.test.ts +++ b/packages/openclaw/src/connector.test.ts @@ -131,7 +131,8 @@ describe("OpenClawBridgeConnector", () => { events: [{ event: "run.completed", payload: { runId: "run_1", type: "run.completed" } }], responses: { "exec.approval.resolve": { ok: true }, - "sessions.send": { runId: "run_1", sessionKey: "agent:codex" }, + "sessions.create": { key: "agent:codex:session_1" }, + "sessions.send": { runId: "run_1", sessionKey: "agent:codex:session_1" }, }, }); const api = new OpenClawNetworkAPI({ @@ -163,7 +164,7 @@ describe("OpenClawBridgeConnector", () => { } as MatrixMessage)).resolves.toEqual({ pending: false }); expect(runtime.transport.request).toHaveBeenCalledWith("sessions.send", { idempotencyKey: "$message", - key: "agent:codex", + key: "agent:codex:session_1", message: "hello", }, { expectFinal: true }); diff --git a/packages/openclaw/src/connector.ts b/packages/openclaw/src/connector.ts index 12a2084..d90e3a1 100644 --- a/packages/openclaw/src/connector.ts +++ b/packages/openclaw/src/connector.ts @@ -29,7 +29,7 @@ import { } from "@beeper/pickle-bridge"; import { buildBackfillImport } from "./backfill"; import { parseApprovalResponseContent } from "./approval"; -import { OpenClawMatrixBridgeAgent, type OpenClawBridgeStreamPublisher } from "./bridge-agent"; +import { agentPortalSessionKey, OpenClawMatrixBridgeAgent, type OpenClawBridgeStreamPublisher } from "./bridge-agent"; import { createDefaultConfig } from "./config"; import { createOpenClawHttpTransport, createOpenClawWebSocketTransport, OpenClawGatewayRuntime, type OpenClawTransport } from "./openclaw-runtime"; import { OpenClawBridgeRegistry } from "./registry"; @@ -333,7 +333,7 @@ function portalForAgent(contact: OpenClawAgentContact, receiver: string): Portal openclaw: { agentId: contact.agentId, ghostUserId: contact.ghostUserId, - sessionKey: id, + sessionKey: agentPortalSessionKey(contact.agentId), }, }, portalKey: { id, receiver }, From 514a1a92b7d8a679ce6b6520ecc63a2b1bd0e030 Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Sun, 24 May 2026 23:31:40 +0200 Subject: [PATCH 28/43] Refactor core workflow and supporting modules --- PLAN.md | 66 ++ packages/bridge/src/bridge.ts | 10 + packages/bridge/src/provisioning.test.ts | 59 +- packages/bridge/src/provisioning.ts | 29 +- packages/bridge/src/types.ts | 14 + packages/openclaw/README.md | 26 +- packages/openclaw/openclaw.plugin.json | 176 ++++ packages/openclaw/package.json | 76 ++ packages/openclaw/src/appservice.test.ts | 9 + packages/openclaw/src/appservice.ts | 10 +- packages/openclaw/src/backfill.test.ts | 152 +++- packages/openclaw/src/backfill.ts | 89 +- packages/openclaw/src/beeper-setup.test.ts | 56 +- packages/openclaw/src/beeper-setup.ts | 12 +- packages/openclaw/src/beeper-stream.test.ts | 263 ++++++ packages/openclaw/src/beeper-stream.ts | 388 ++++++++ packages/openclaw/src/bridge-agent.test.ts | 103 ++- packages/openclaw/src/bridge-agent.ts | 17 +- packages/openclaw/src/cli.test.ts | 231 ++++- packages/openclaw/src/cli.ts | 66 +- packages/openclaw/src/config.test.ts | 35 +- packages/openclaw/src/config.ts | 71 ++ packages/openclaw/src/connector.test.ts | 838 +++++++++++++++++- packages/openclaw/src/connector.ts | 613 ++++++++++++- packages/openclaw/src/index.ts | 5 + packages/openclaw/src/integration.test.ts | 266 ++++++ .../openclaw/src/openclaw-event-map.test.ts | 195 +++- packages/openclaw/src/openclaw-event-map.ts | 49 +- .../openclaw/src/openclaw-extension.test.ts | 110 +++ packages/openclaw/src/openclaw-extension.ts | 21 + .../openclaw/src/openclaw-runtime.test.ts | 36 +- packages/openclaw/src/openclaw-runtime.ts | 32 +- packages/openclaw/src/plugin-entry.ts | 4 + packages/openclaw/src/registration.test.ts | 18 + packages/openclaw/src/registration.ts | 2 +- packages/openclaw/src/serial.ts | 9 + packages/openclaw/src/setup-entry.ts | 8 + packages/openclaw/src/setup.test.ts | 547 ++++++++++++ packages/openclaw/src/setup.ts | 706 +++++++++++++++ packages/openclaw/src/stream-map.ts | 309 ++++--- packages/openclaw/src/types.ts | 13 + packages/openclaw/tsdown.config.ts | 2 +- packages/openclaw/vitest.config.ts | 2 + pnpm-lock.yaml | 3 + 44 files changed, 5432 insertions(+), 314 deletions(-) create mode 100644 PLAN.md create mode 100644 packages/openclaw/openclaw.plugin.json create mode 100644 packages/openclaw/src/beeper-stream.test.ts create mode 100644 packages/openclaw/src/beeper-stream.ts create mode 100644 packages/openclaw/src/integration.test.ts create mode 100644 packages/openclaw/src/openclaw-extension.test.ts create mode 100644 packages/openclaw/src/openclaw-extension.ts create mode 100644 packages/openclaw/src/plugin-entry.ts create mode 100644 packages/openclaw/src/serial.ts create mode 100644 packages/openclaw/src/setup-entry.ts create mode 100644 packages/openclaw/src/setup.test.ts create mode 100644 packages/openclaw/src/setup.ts diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..1a41b92 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,66 @@ +# Production OpenClaw Beeper Bridge + +## Summary + +Build a production ClawHub-installable OpenClaw channel plugin in Pickle that bridges OpenClaw sessions into Beeper through a self-hosted Beeper appservice. The plugin owns Beeper login, appservice registration, settings/setup, contact discovery, DM creation, Matrix event parsing, slash commands, native Beeper live streaming, approvals, reactions, replies, and opt-in session backfill. + +The package remains in Pickle, but ships OpenClaw plugin metadata, setup entrypoints, and runtime entrypoints so users install it with `openclaw plugins install clawhub:` and configure it from the OpenClaw dashboard. + +## Key Changes + +- Package and ClawHub shape: + - Turn `packages/openclaw` into the public OpenClaw plugin package, with `openclaw.plugin.json`, `openclaw` package metadata, `setupEntry`, runtime entry, ClawHub install metadata, peer dependency on OpenClaw, and publish-ready docs. + - Use channel id `beeper`, label `Beeper`, and keep Pickle bridge code as the transport/runtime layer inside the package. + - Default import scope is opt-in per source: dashboard, TUI, channel-origin sessions, archived sessions. + +- Beeper login, registration, and settings: + - Add OpenClaw setup-entry support for dashboard-driven Beeper email/OTP login and self-hosted bridge/appservice registration. + - Store settings under `plugins.entries.beeper.config` / `channels.beeper` as appropriate for OpenClaw channel config conventions. + - Settings include Beeper env, registration URL, bridge manager token, gateway URL, import sources, backfill limit, non-federated rooms, contact visibility, stream/finalization behavior, and approval behavior. + - CLI remains available for scripting, but dashboard setup is the primary path. + +- Contacts, search, and DMs: + - Sync all OpenClaw agents into Beeper ghosts with deterministic fixed MXIDs. + - Expose agents through Pickle `resolveIdentifier` contact-list/search behavior and create one DM room per agent on demand. + - `/new` creates a fresh OpenClaw session and Beeper room; existing agent DMs start a session on first user message. + - Avoid bot-loop/cross-room forwarding: ignore Beeper self/bot-originated events and never forward messages between Beeper rooms. + +- Matrix message parsing and commands: + - Parse Matrix text, replies, threads, edits, reactions, redactions, attachments, emoji, formatted bodies, and relation chains into OpenClaw session input metadata. + - Implement bridge slash commands in Matrix rooms: `/new`, `/agent`, `/sessions`, `/import`, `/backfill`, `/abort`, `/approve`, `/deny`, `/status`, `/settings`. + - Reactions map to OpenClaw reactions where applicable, and approval reactions map to approval decisions. + - Replies preserve target event/message ids and quoted context so OpenClaw can understand conversation references. + +- Live streaming, approvals, and backfill: + - Add the real default Beeper stream publisher using `client.beeper.streams.startMessage`, `publishPart`, and `finalizeMessage`. + - Publish full AG-UI/Beeper native stream lifecycle: reasoning, text deltas, tool inputs, tool outputs, approval requests/responses, errors, aborts, and final replacement message. + - Finalize streams as editable/replaced Beeper messages where supported; keep fallback final text for clients without native rendering. + - Approval gates are end-to-end: Beeper approval UI/reactions/slash commands resolve OpenClaw exec/plugin approvals. + - Backfill imports selected OpenClaw session sources only when enabled in settings, creates room bindings, preserves agent/user ghosts, and avoids duplicate imports via registry state. + +## Test Plan + +- Unit tests: + - Beeper OTP/setup config, appservice registration, ClawHub/package metadata, settings schema, and dashboard setup adapters. + - Agent contact sync/search/DM creation, fixed ghost MXIDs, bot-loop suppression, slash command parsing, and Matrix relation parsing. + - Native stream publisher start/publish/finalize/error/abort behavior with AG-UI parts and final `com.beeper.ai` content. + - Backfill opt-in source filtering, dedupe, registry persistence, and room binding. + +- Integration-style tests: + - Pickle bridge dispatch for messages, replies, reactions, edits, approvals, and backfill. + - OpenClaw plugin setup-entry import safety using `.upstream/openclaw` channel plugin contracts. + - Dashboard channel card/settings behavior via OpenClaw UI patterns where package-level tests can cover it without patching OpenClaw core. + +- Verification gates: + - `pnpm --filter @beeper/pickle-openclaw typecheck` + - `pnpm --filter @beeper/pickle-openclaw test -- --run` + - `pnpm --filter @beeper/pickle-openclaw build` + - Focused Pickle bridge stream/appservice tests + - Package validation for OpenClaw plugin manifest and ClawHub publish dry-run shape + +## Assumptions + +- Implementation stays in Pickle; OpenClaw core/dashboard are not patched. +- Users install from ClawHub, so dashboard integration must come from OpenClaw plugin metadata, setup entrypoints, config schema, channel metadata, and runtime methods. +- Default backfill/import is opt-in by source, not automatic. +- v1 must support at least contact search, create DM, full live streaming, approvals, replies, reactions, slash commands, Beeper login, bridge registration, dashboard setup/settings, and opt-in backfill. diff --git a/packages/bridge/src/bridge.ts b/packages/bridge/src/bridge.ts index 5dbe024..094ee0b 100644 --- a/packages/bridge/src/bridge.ts +++ b/packages/bridge/src/bridge.ts @@ -848,6 +848,16 @@ export class RuntimeBridge implements PickleBridge { listLogins: () => Array.from(this.#userLogins.values()), loginFlows: () => this.connector.getLoginFlows(), loadLogin: (login) => this.loadUserLogin(login).then(() => undefined), + listContacts: async (login, query, limit) => { + 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(), { + ...(limit !== undefined ? { limit } : {}), + ...(query !== undefined ? { query } : {}), + }); + }, requestContext: () => this.#requestContext(), resolveIdentifier: (login, identifier, createDM) => this.resolveIdentifier(login, { createDM, identifier }), }, { logins: this.#provisioningLogins }, request); diff --git a/packages/bridge/src/provisioning.test.ts b/packages/bridge/src/provisioning.test.ts index fc308ae..72b5799 100644 --- a/packages/bridge/src/provisioning.test.ts +++ b/packages/bridge/src/provisioning.test.ts @@ -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,57 @@ 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: "q=codex&limit=10", + })).resolves.toMatchObject({ + body: { + contacts: [{ + id: "intern", + mxid: "@intern:example", + name: "Intern", + }], + }, + status: 200, + }); + + expect(runtime.listContacts).toHaveBeenCalledWith({ id: "intern" }, "codex", 10); + }); + + 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(); + }); }); function provisioningRuntime(): ProvisioningRuntime { @@ -58,6 +109,12 @@ function provisioningRuntime(): ProvisioningRuntime { }), createLogin: vi.fn(), listLogins: () => [login], + listContacts: vi.fn(async () => ({ + contacts: [{ + ghost: { displayName: "Intern", id: "intern", mxid: "@intern:example" }, + userId: "@intern:example", + }], + })), loginFlows: () => [], loadLogin: vi.fn(), requestContext: vi.fn(), diff --git a/packages/bridge/src/provisioning.ts b/packages/bridge/src/provisioning.ts index c8a232f..933b1a3 100644 --- a/packages/bridge/src/provisioning.ts +++ b/packages/bridge/src/provisioning.ts @@ -8,6 +8,7 @@ import type { LoginStep, LoginUserInput, LoginCookieInput, + ListContactsResponse, NetworkGeneralCapabilities, ResolveIdentifierResponse, UserLogin, @@ -19,6 +20,7 @@ export interface ProvisioningRuntime { listLogins(): UserLogin[]; loginFlows(): unknown[]; loadLogin(login: UserLogin): Promise; + listContacts?(login: UserLogin, query?: string, limit?: number): Promise; requestContext(): BridgeRequestContext; resolveIdentifier(login: UserLogin, identifier: string, createDM: boolean): Promise; } @@ -41,6 +43,17 @@ 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 jsonHTTPResponse(404, matrixError("M_UNSUPPORTED", "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, + queryParam(request.query, "q"), + intQueryParam(request.query, "limit"), + ))); + } + const createDM = match(path, /^\/_matrix\/provision\/v3\/create_dm\/([^/]+)$/); if (method === "POST" && createDM) { const [identifier] = createDM; @@ -85,7 +98,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; } @@ -143,6 +156,13 @@ function resolvedIdentifierResponse(resolved: ResolveIdentifierResponse): Record }); } +function contactsListResponse(response: ListContactsResponse): Record { + return stripUndefined({ + contacts: response.contacts.map((contact) => resolvedIdentifierResponse(contact)), + next_batch: response.nextBatch, + }); +} + function loginStepResponse(loginId: string, step: LoginStep): Record { return { login_id: loginId, @@ -211,6 +231,13 @@ 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 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/types.ts b/packages/bridge/src/types.ts index b6355e1..893a698 100644 --- a/packages/bridge/src/types.ts +++ b/packages/bridge/src/types.ts @@ -160,6 +160,10 @@ export interface IdentifierResolvingNetworkAPI extends NetworkAPI { resolveIdentifier(ctx: BridgeRequestContext, identifier: ResolveIdentifierParams): Promise; } +export interface ContactListingNetworkAPI extends NetworkAPI { + listContacts(ctx: BridgeRequestContext, params: ListContactsParams): Promise; +} + export interface MessageRequestHandlingNetworkAPI extends NetworkAPI { handleMessageRequest(ctx: BridgeRequestContext, request: MessageRequest): Promise; } @@ -887,6 +891,16 @@ export interface ResolveIdentifierResponse { userId?: UserID; } +export interface ListContactsParams { + limit?: number; + query?: string; +} + +export interface ListContactsResponse { + contacts: ResolveIdentifierResponse[]; + nextBatch?: string; +} + export interface UserProfile { avatarUrl?: string; displayName?: string; diff --git a/packages/openclaw/README.md b/packages/openclaw/README.md index 3fe7e6c..df0533e 100644 --- a/packages/openclaw/README.md +++ b/packages/openclaw/README.md @@ -2,10 +2,21 @@ Pickle bridge package for exposing OpenClaw Gateway sessions in Beeper/Matrix. +## OpenClaw Plugin Install + +Install the Beeper channel plugin from ClawHub: + +```sh +openclaw plugins install clawhub:@beeper/pickle-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 or account creation. +- Beeper email-code login for existing accounts. - Beeper appservice registration for the OpenClaw bridge. +- OpenClaw channel metadata, setup entrypoint, runtime entrypoint, and ClawHub install metadata. - Pickle bridgev2-style connector for OpenClaw agents, sessions, approvals, and backfill. - OpenClaw WebSocket Gateway transport using protocol v4 `req`/`res`/`event` frames. - Compatibility HTTP/SSE transport for gateway-like test or proxy deployments. @@ -32,21 +43,12 @@ pickle-openclaw beeper-login \ --login-code 123456 ``` -Request Beeper account creation during the same email-code flow: - -```sh -pickle-openclaw beeper-login \ - --config ~/.openclaw/pickle-bridge/config.json \ - --email you@example.com \ - --login-code 123456 \ - --create-account -``` - Register the OpenClaw appservice with Beeper: ```sh pickle-openclaw beeper-register \ - --config ~/.openclaw/pickle-bridge/config.json + --config ~/.openclaw/pickle-bridge/config.json \ + --bridge-manager-token "$BEEPER_BRIDGE_MANAGER_TOKEN" ``` Do login and appservice registration in one step: diff --git a/packages/openclaw/openclaw.plugin.json b/packages/openclaw/openclaw.plugin.json new file mode 100644 index 0000000..ad14c8a --- /dev/null +++ b/packages/openclaw/openclaw.plugin.json @@ -0,0 +1,176 @@ +{ + "id": "beeper", + "name": "Beeper", + "description": "Bridge OpenClaw sessions and agents into Beeper.", + "activation": { + "onStartup": true + }, + "channels": ["beeper"], + "channelEnvVars": { + "beeper": [ + "PICKLE_OPENCLAW_ACCESS_TOKEN", + "PICKLE_OPENCLAW_ALLOW_ROOMS", + "PICKLE_OPENCLAW_ALLOW_USERS", + "PICKLE_OPENCLAW_AS_TOKEN", + "PICKLE_OPENCLAW_APP_SERVICE_ID", + "PICKLE_OPENCLAW_APPROVAL_BEHAVIOR", + "PICKLE_OPENCLAW_BACKFILL_LIMIT", + "PICKLE_OPENCLAW_BASE_DOMAIN", + "PICKLE_OPENCLAW_BEEPER_ENV", + "PICKLE_OPENCLAW_BRIDGE_MANAGER_POST_STATE", + "PICKLE_OPENCLAW_BRIDGE_MANAGER_TOKEN", + "PICKLE_OPENCLAW_CONTACT_VISIBILITY", + "PICKLE_OPENCLAW_DATA_DIR", + "PICKLE_OPENCLAW_GATEWAY_ACCESS_TOKEN", + "PICKLE_OPENCLAW_GATEWAY_URL", + "PICKLE_OPENCLAW_HOMESERVER", + "PICKLE_OPENCLAW_HOMESERVER_DOMAIN", + "PICKLE_OPENCLAW_HS_TOKEN", + "PICKLE_OPENCLAW_IMPORT_SOURCES", + "PICKLE_OPENCLAW_MATRIX_DEVICE_ID", + "PICKLE_OPENCLAW_MATRIX_USER_ID", + "PICKLE_OPENCLAW_NON_FEDERATED_ROOMS", + "PICKLE_OPENCLAW_REGISTRATION_URL", + "PICKLE_OPENCLAW_STREAM_FINALIZATION" + ] + }, + "uiHints": { + "accessToken": { + "label": "Beeper Access Token", + "help": "Beeper Matrix access token returned by login.", + "sensitive": true + }, + "hsToken": { + "label": "Homeserver Token", + "help": "Homeserver token returned by Beeper bridge registration.", + "sensitive": true + }, + "asToken": { + "label": "Appservice Token", + "help": "Appservice token returned by Beeper bridge registration.", + "sensitive": true + }, + "bridgeManagerToken": { + "label": "Bridge Manager Token", + "help": "Optional Beeper bridge-manager token used to register the self-hosted bridge.", + "sensitive": true + }, + "gatewayAccessToken": { + "label": "OpenClaw Gateway Token", + "help": "Optional bearer token for the local OpenClaw gateway.", + "sensitive": true + } + }, + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable the Beeper bridge channel." + }, + "accessToken": { + "type": "string", + "description": "Beeper Matrix access token returned by login." + }, + "asToken": { + "type": "string", + "description": "Appservice token returned by Beeper bridge registration." + }, + "dataDir": { + "type": "string", + "description": "Directory for bridge config, registration, and runtime state." + }, + "registrationUrl": { + "type": "string", + "description": "Public or LAN callback URL for the Matrix appservice." + }, + "gatewayAccessToken": { + "type": "string", + "description": "Optional bearer token for the local OpenClaw gateway." + }, + "gatewayUrl": { + "type": "string", + "description": "OpenClaw gateway URL used by the bridge runtime." + }, + "homeserver": { + "type": "string", + "description": "Beeper Matrix homeserver URL returned by login." + }, + "hsToken": { + "type": "string", + "description": "Homeserver token returned by Beeper bridge registration." + }, + "matrixDeviceId": { + "type": "string", + "description": "Beeper Matrix device id for this bridge." + }, + "matrixUserId": { + "type": "string", + "description": "Beeper Matrix user id for this bridge." + }, + "allowedRoomIds": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional allow-list of Matrix rooms the bridge may import from." + }, + "allowedUserIds": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional allow-list of Matrix users the bridge may accept commands from." + }, + "importSources": { + "type": "array", + "items": { + "type": "string", + "enum": ["dashboard", "tui", "channels", "archived"] + }, + "description": "OpenClaw session sources to import and backfill." + }, + "backfillLimit": { + "type": "number", + "description": "Maximum OpenClaw messages to backfill per imported session." + }, + "nonFederatedRooms": { + "type": "boolean", + "description": "Create Matrix rooms with non-federated room creation content where supported." + }, + "beeperEnv": { + "type": "string", + "enum": ["production", "staging", "dev", "local"], + "description": "Beeper environment for login and appservice registration." + }, + "bridgeManagerToken": { + "type": "string", + "description": "Beeper bridge-manager token used to register the self-hosted bridge." + }, + "bridgeManagerPostState": { + "type": "boolean", + "description": "Post Beeper bridge state after registering the self-hosted bridge." + }, + "baseDomain": { + "type": "string", + "description": "Beeper API base domain for non-production environments." + }, + "homeserverDomain": { + "type": "string", + "description": "Homeserver domain advertised in the Beeper appservice registration." + }, + "contactVisibility": { + "type": "string", + "enum": ["agents", "agents-and-users", "none"], + "description": "Which OpenClaw identities should appear in Beeper contacts." + }, + "streamFinalization": { + "type": "string", + "enum": ["replace", "append", "native-only"], + "description": "How native Beeper stream output is finalized." + }, + "approvalBehavior": { + "type": "string", + "enum": ["native", "reactions", "slash", "disabled"], + "description": "How Beeper approval decisions resolve OpenClaw approval gates." + } + } + } +} diff --git a/packages/openclaw/package.json b/packages/openclaw/package.json index 4542740..f2316bd 100644 --- a/packages/openclaw/package.json +++ b/packages/openclaw/package.json @@ -43,6 +43,10 @@ "types": "./dist/beeper-setup.d.mts", "import": "./dist/beeper-setup.mjs" }, + "./beeper-stream": { + "types": "./dist/beeper-stream.d.mts", + "import": "./dist/beeper-stream.mjs" + }, "./cli": { "types": "./dist/cli.d.mts", "import": "./dist/cli.mjs" @@ -59,6 +63,14 @@ "types": "./dist/openclaw-event-map.d.mts", "import": "./dist/openclaw-event-map.mjs" }, + "./openclaw-extension": { + "types": "./dist/openclaw-extension.d.mts", + "import": "./dist/openclaw-extension.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" @@ -79,6 +91,14 @@ "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" + }, "./stream-map": { "types": "./dist/stream-map.d.mts", "import": "./dist/stream-map.mjs" @@ -90,9 +110,56 @@ }, "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 bridge", + "detailLabel": "Beeper Matrix bridge", + "docsPath": "/channels/beeper", + "docsLabel": "beeper", + "blurb": "bridges OpenClaw sessions and agents into Beeper with Matrix-native streaming, replies, reactions, and approvals.", + "systemImage": "message", + "cliAddOptions": [ + { + "flags": "--email ", + "description": "Beeper account email for login" + }, + { + "flags": "--bridge-manager-token ", + "description": "Beeper bridge-manager token for self-hosted appservice registration" + } + ] + }, + "install": { + "clawhubSpec": "clawhub:@beeper/pickle-openclaw@0.1.0", + "npmSpec": "@beeper/pickle-openclaw@0.1.0", + "defaultChoice": "clawhub", + "minHostVersion": ">=2026.5.24" + }, + "compat": { + "pluginApi": ">=2026.5.24" + }, + "build": { + "openclawVersion": "2026.5.24" + }, + "release": { + "publishToClawHub": true, + "publishToNpm": true + } + }, "publishConfig": { "access": "public" }, @@ -104,6 +171,7 @@ }, "dependencies": { "@beeper/pickle": "workspace:*", + "@beeper/pickle-ag-ui": "workspace:*", "@beeper/pickle-bridge": "workspace:*", "@beeper/pickle-state-file": "workspace:*" }, @@ -114,6 +182,14 @@ "typescript": "^5.7.2", "vitest": "^4.0.18" }, + "peerDependencies": { + "openclaw": ">=2026.5.24" + }, + "peerDependenciesMeta": { + "openclaw": { + "optional": true + } + }, "keywords": [ "beeper", "matrix", diff --git a/packages/openclaw/src/appservice.test.ts b/packages/openclaw/src/appservice.test.ts index 1be1dce..e23abd9 100644 --- a/packages/openclaw/src/appservice.test.ts +++ b/packages/openclaw/src/appservice.test.ts @@ -8,7 +8,11 @@ describe("OpenClaw Beeper appservice runtime", () => { const bridge = fakeBridge(); const bridgeFactory = vi.fn(async (_options: CreateNodeBeeperBridgeOptions) => bridge); const config = createDefaultConfig({ + beeperEnv: "staging", + bridgeManagerPostState: false, + bridgeManagerToken: "hungry-token", dataDir: "/tmp/openclaw", + homeserverDomain: "beeper.local", registrationUrl: "http://127.0.0.1:29391", }); @@ -23,13 +27,17 @@ describe("OpenClaw Beeper appservice runtime", () => { expect(bridgeFactory).toHaveBeenCalledWith(expect.objectContaining({ account: account(), address: "http://127.0.0.1:29391", + baseDomain: "beeper-staging.com", bridge: "openclaw", + bridgeManagerPostState: false, + bridgeManagerToken: "hungry-token", bridgeType: "openclaw", connector: expect.objectContaining({ config, }), dataDir: "/tmp/openclaw-data", getOnly: true, + homeserverDomain: "beeper.local", })); }); @@ -47,6 +55,7 @@ describe("OpenClaw Beeper appservice runtime", () => { expect(accountFromOpenClawConfig(createDefaultConfig({ accessToken: "mx-token", dataDir: "/tmp/openclaw", + gatewayAccessToken: "gateway-token", homeserver: "https://matrix.beeper.com", matrixDeviceId: "DEVICE", matrixUserId: "@batuhan:beeper.com", diff --git a/packages/openclaw/src/appservice.ts b/packages/openclaw/src/appservice.ts index c1da5a4..d2885ef 100644 --- a/packages/openclaw/src/appservice.ts +++ b/packages/openclaw/src/appservice.ts @@ -1,7 +1,7 @@ import type { MatrixAccount } from "@beeper/pickle"; import { createBeeperBridge, type CreateNodeBeeperBridgeOptions, type PickleBridge } from "@beeper/pickle-bridge"; import { backfillAllOpenClawSessions } from "./backfill"; -import { DEFAULT_BEEPER_BRIDGE, DEFAULT_BEEPER_BRIDGE_TYPE } from "./beeper-setup"; +import { beeperBaseDomain, DEFAULT_BEEPER_BRIDGE, DEFAULT_BEEPER_BRIDGE_TYPE } from "./beeper-setup"; import { createOpenClawConnector, createOpenClawRuntimeFromLogin, userLoginFromOpenClawConfig, type OpenClawConnectorOptions } from "./connector"; import { OpenClawBridgeRegistry } from "./registry"; import type { OpenClawBridgeConfig } from "./types"; @@ -30,6 +30,14 @@ export async function createOpenClawBeeperBridge(options: CreateOpenClawBeeperBr connector, }; if (config?.registrationUrl !== undefined) bridgeOptions.address = config.registrationUrl; + if (config?.baseDomain !== undefined) bridgeOptions.baseDomain = config.baseDomain; + else { + const baseDomain = beeperBaseDomain(config?.beeperEnv); + if (baseDomain !== undefined) bridgeOptions.baseDomain = baseDomain; + } + if (config?.bridgeManagerToken !== undefined) bridgeOptions.bridgeManagerToken = config.bridgeManagerToken; + if (config?.bridgeManagerPostState !== undefined) bridgeOptions.bridgeManagerPostState = config.bridgeManagerPostState; + 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.matrix !== undefined) bridgeOptions.matrix = options.matrix; diff --git a/packages/openclaw/src/backfill.test.ts b/packages/openclaw/src/backfill.test.ts index 6b92a8d..8de7dd2 100644 --- a/packages/openclaw/src/backfill.test.ts +++ b/packages/openclaw/src/backfill.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { backfillAllOpenClawSessions, buildBackfillImport, discoverOneToOneSessions, isOneToOneSession } from "./backfill"; +import { backfillAllOpenClawSessions, buildBackfillImport, discoverOneToOneSessions, isOneToOneSession, shouldImportSession } from "./backfill"; import { createDefaultConfig } from "./config"; import { OpenClawGatewayRuntime, type OpenClawTransport } from "./openclaw-runtime"; import { OpenClawBridgeRegistry } from "./registry"; @@ -17,7 +17,7 @@ describe("OpenClaw backfill", () => { }, }); - await expect(discoverOneToOneSessions(runtime)).resolves.toEqual([ + await expect(discoverOneToOneSessions(runtime, { importSources: ["dashboard", "tui", "channels"] })).resolves.toEqual([ { agentId: "main", label: "agent:main:terminal:local", @@ -53,8 +53,8 @@ describe("OpenClaw backfill", () => { const runtime = runtimeWith({ "chat.history": { messages: [ - { content: "hello", id: "m1", messageSeq: 1, role: "user" }, - { content: [{ text: "hi" }], id: "m2", messageSeq: 2, role: "assistant" }, + { content: "hello", createdAt: "2026-05-16T11:59:00.000Z", id: "m1", messageSeq: 1, role: "user" }, + { content: [{ text: "hi" }], id: "m2", messageSeq: 2, role: "assistant", timestamp: 1_779_000_000 }, ], }, }); @@ -99,6 +99,7 @@ describe("OpenClaw backfill", () => { role: "user", sender: "human", seq: 1, + timestamp: new Date("2026-05-16T11:59:00.000Z"), }, { content: { @@ -110,6 +111,7 @@ describe("OpenClaw backfill", () => { role: "assistant", sender: "agent", seq: 2, + timestamp: new Date(1_779_000_000_000), }, ], source: "terminal", @@ -129,6 +131,28 @@ describe("OpenClaw backfill", () => { expect(isOneToOneSession({ chatType: "group", key: "agent:main:group", lastTo: "a,b" })).toBe(false); }); + it("filters backfill sessions by opt-in import source and archived state", async () => { + expect(shouldImportSession({ key: "agent:main:terminal:local", origin: { surface: "terminal" } }, ["tui"])).toBe(true); + expect(shouldImportSession({ key: "agent:main:desktop:abc", origin: { surface: "mac-app" } }, ["dashboard"])).toBe(true); + expect(shouldImportSession({ key: "agent:main:whatsapp:alice", lastProvider: "whatsapp" }, ["channels"])).toBe(true); + expect(shouldImportSession({ key: "agent:main:terminal:old", origin: { surface: "terminal" }, updatedAt: null }, ["tui"])).toBe(false); + expect(shouldImportSession({ key: "agent:main:terminal:old", origin: { surface: "terminal" }, updatedAt: null }, ["tui", "archived"])).toBe(true); + expect(shouldImportSession({ key: "agent:main:desktop:abc", origin: { surface: "mac-app" } }, ["tui"])).toBe(false); + + const runtime = runtimeWith({ + "sessions.list": { + sessions: [ + { key: "agent:main:terminal:local", origin: { surface: "terminal" } }, + { key: "agent:main:desktop:abc", origin: { surface: "mac-app" } }, + { chatType: "dm", key: "agent:main:whatsapp:user-1", lastProvider: "whatsapp", lastTo: "user-1" }, + ], + }, + }); + await expect(discoverOneToOneSessions(runtime, { importSources: ["dashboard"] })).resolves.toMatchObject([ + { sessionKey: "agent:main:desktop:abc", source: "mac-app" }, + ]); + }); + it("creates portals and imports every discovered one-to-one session", async () => { const runtime = runtimeWith({ "chat.history": { messages: [{ content: "hello", id: "m1", role: "user" }] }, @@ -152,6 +176,7 @@ describe("OpenClaw backfill", () => { await expect(backfillAllOpenClawSessions({ bridge: bridge as never, + importSources: ["channels"], limit: 25, login, registry, @@ -159,6 +184,7 @@ describe("OpenClaw backfill", () => { })).resolves.toMatchObject({ portals: [{ mxid: "!room:example.com" }], sessions: [{ agentId: "codex", sessionKey: "agent:codex:whatsapp:alice" }], + skipped: [], }); expect(bridge.createPortal).toHaveBeenCalledWith(login, expect.objectContaining({ @@ -182,6 +208,124 @@ describe("OpenClaw backfill", () => { expect(registry.getUser("alice")?.ghostUserId).toBe("@openclaw_user_alice:localhost"); expect(registry.getBindingByRoom("!room:example.com")?.humanGhostUserId).toBe("@openclaw_user_alice:localhost"); }); + + it("skips already-imported sessions instead of creating duplicate portals", async () => { + const runtime = runtimeWith({ + "sessions.list": { + sessions: [ + { agentId: "codex", chatType: "dm", displayName: "Alice", key: "agent:codex:terminal:alice", origin: { surface: "terminal" } }, + ], + }, + }); + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-backfill-existing-test.json"); + registry.upsertBinding({ + agentId: "codex", + createdAt: 1, + ghostUserId: "@openclaw_agent_codex:localhost", + id: "room:existing", + kind: "session", + label: "Alice", + owner: "imported", + roomId: "!existing:example.com", + sessionKey: "agent:codex:terminal:alice", + updatedAt: 1, + }); + const bridge = { + backfillPortal: vi.fn(async () => ({ eventIds: [] })), + createPortal: vi.fn(async () => ({ + id: "session:created", + mxid: "!room:example.com", + portalKey: { id: "session:created", receiver: "login" }, + receiver: "login", + })), + }; + const login = { id: "login", userId: "@owner:example.com" }; + + await expect(backfillAllOpenClawSessions({ + bridge: bridge as never, + importSources: ["tui"], + login, + registry, + runtime, + })).resolves.toMatchObject({ + portals: [], + sessions: [], + skipped: [{ agentId: "codex", sessionKey: "agent:codex:terminal:alice" }], + }); + + expect(bridge.createPortal).not.toHaveBeenCalled(); + expect(bridge.backfillPortal).not.toHaveBeenCalled(); + }); + + it("skips sessions when portal creation does not return a Matrix room", async () => { + const runtime = runtimeWith({ + "chat.history": { messages: [{ content: "hello", id: "m1", role: "user" }] }, + "sessions.list": { + sessions: [ + { agentId: "codex", chatType: "dm", displayName: "Alice", key: "agent:codex:terminal:alice", origin: { surface: "terminal" } }, + ], + }, + }); + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-backfill-no-room-test.json"); + const bridge = { + backfillPortal: vi.fn(async () => ({ eventIds: [] })), + createPortal: vi.fn(async () => ({ + id: "session:created", + portalKey: { id: "session:created", receiver: "login" }, + receiver: "login", + })), + }; + const login = { id: "login", userId: "@owner:example.com" }; + + await expect(backfillAllOpenClawSessions({ + bridge: bridge as never, + importSources: ["tui"], + login, + registry, + runtime, + })).resolves.toMatchObject({ + portals: [{ id: "session:created" }], + sessions: [], + skipped: [{ agentId: "codex", sessionKey: "agent:codex:terminal:alice" }], + }); + + expect(bridge.backfillPortal).not.toHaveBeenCalled(); + expect(runtime.transport.request).not.toHaveBeenCalledWith("chat.history", expect.anything()); + expect(registry.getBindingBySessionKey("agent:codex:terminal:alice")).toBeUndefined(); + }); + + it("omits non-federation creation content when federated rooms are enabled", async () => { + const runtime = runtimeWith({ + "chat.history": { messages: [] }, + "sessions.list": { + sessions: [ + { agentId: "codex", chatType: "dm", displayName: "Alice", key: "agent:codex:terminal:alice", origin: { surface: "terminal" } }, + ], + }, + }); + runtime.config.nonFederatedRooms = false; + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-backfill-federated-test.json"); + const bridge = { + backfillPortal: vi.fn(async () => ({ eventIds: [] })), + createPortal: vi.fn(async () => ({ + id: "session:created", + mxid: "!room:example.com", + portalKey: { id: "session:created", receiver: "login" }, + receiver: "login", + })), + }; + const login = { id: "login", userId: "@owner:example.com" }; + + await backfillAllOpenClawSessions({ + bridge: bridge as never, + importSources: ["tui"], + login, + registry, + runtime, + }); + + expect(bridge.createPortal.mock.calls[0]?.[1]).not.toHaveProperty("creationContent"); + }); }); function runtimeWith(responses: Record): OpenClawGatewayRuntime & { diff --git a/packages/openclaw/src/backfill.ts b/packages/openclaw/src/backfill.ts index e5e3f2c..f62950b 100644 --- a/packages/openclaw/src/backfill.ts +++ b/packages/openclaw/src/backfill.ts @@ -6,7 +6,7 @@ import type { } from "./openclaw-runtime"; import { agentContactFromOpenClawAgent, agentGhostUserId, bindingIdForRoom, userContactFromOpenClawSession } from "./rooms"; import type { OpenClawBridgeRegistry } from "./registry"; -import type { OpenClawBridgeConfig, OpenClawSessionBinding, OpenClawUserContact } from "./types"; +import type { OpenClawBridgeConfig, OpenClawImportSource, OpenClawSessionBinding, OpenClawUserContact } from "./types"; export interface OpenClawBackfillSession { agentId: string; @@ -23,6 +23,7 @@ export interface OpenClawBackfillMessage { role: "assistant" | "system" | "tool" | "user" | string; sender: "agent" | "human" | "system"; seq: number; + timestamp?: Date; } export interface OpenClawBackfillImport { @@ -34,6 +35,7 @@ export interface OpenClawBackfillImport { export interface BackfillAllOpenClawSessionsOptions { bridge: PickleBridge; + importSources?: OpenClawImportSource[]; limit?: number; login: UserLogin; registry: OpenClawBridgeRegistry; @@ -43,12 +45,17 @@ export interface BackfillAllOpenClawSessionsOptions { export interface BackfillAllOpenClawSessionsResult { portals: Portal[]; sessions: OpenClawBackfillSession[]; + skipped: OpenClawBackfillSession[]; } -export async function discoverOneToOneSessions(runtime: OpenClawGatewayRuntime): Promise { +export async function discoverOneToOneSessions( + runtime: OpenClawGatewayRuntime, + options: { importSources?: OpenClawImportSource[] } = {}, +): Promise { const sessions = await runtime.listSessions({ includeArchived: true }); return sessions.flatMap((session) => { if (!isOneToOneSession(session)) return []; + if (!shouldImportSession(session, options.importSources)) return []; const agentId = resolveAgentId(session); const result: OpenClawBackfillSession = { agentId, @@ -94,9 +101,18 @@ export async function buildBackfillImport( } export async function backfillAllOpenClawSessions(options: BackfillAllOpenClawSessionsOptions): Promise { - const sessions = await discoverOneToOneSessions(options.runtime); + const discoverOptions: { importSources?: OpenClawImportSource[] } = {}; + const importSources = options.importSources ?? options.runtime.config.importSources; + if (importSources !== undefined) discoverOptions.importSources = importSources; + const sessions = await discoverOneToOneSessions(options.runtime, discoverOptions); const portals: Portal[] = []; + const importedSessions: OpenClawBackfillSession[] = []; + const skipped: OpenClawBackfillSession[] = []; for (const session of sessions) { + if (options.registry.getBindingBySessionKey(session.sessionKey)) { + skipped.push(session); + continue; + } const agent = options.registry.getAgent(session.agentId) ?? agentContactFromOpenClawAgent(options.runtime.config, { id: session.agentId, }); @@ -117,22 +133,26 @@ export async function backfillAllOpenClawSessions(options: BackfillAllOpenClawSe roomType: "dm", sender: session.agentId, }; - if (options.runtime.config.nonFederatedRooms) portalOptions.creationContent = { "m.federate": false }; + const creationContent = openClawBackfillRoomCreationContent(options.runtime.config); + if (creationContent) portalOptions.creationContent = creationContent; const portal = await options.bridge.createPortal(options.login, portalOptions); portals.push(portal); - if (portal.mxid) { - const importOptions: { limit?: number; roomId: string } = { roomId: portal.mxid }; - if (options.limit !== undefined) importOptions.limit = options.limit; - const imported = await buildBackfillImport(options.runtime, options.runtime.config, session, { - ...importOptions, - }); - options.registry.upsertBinding(imported.binding); + if (!portal.mxid) { + skipped.push(session); + continue; } + const importOptions: { limit?: number; roomId: string } = { roomId: portal.mxid }; + if (options.limit !== undefined) importOptions.limit = options.limit; + const imported = await buildBackfillImport(options.runtime, options.runtime.config, session, { + ...importOptions, + }); + options.registry.upsertBinding(imported.binding); await options.bridge.backfillPortal(options.login, portal, { ...(options.limit !== undefined ? { limit: options.limit } : {}), }); + importedSessions.push(session); } - return { portals, sessions }; + return { portals, sessions: importedSessions, skipped }; } export function portalIdForBackfillSession(session: Pick): string { @@ -147,10 +167,24 @@ export function isOneToOneSession(session: OpenClawListedSession): boolean { return originType === "terminal" || originType === "mac-app"; } +export function shouldImportSession( + session: OpenClawListedSession, + importSources: readonly OpenClawImportSource[] | undefined, +): boolean { + if (!importSources || importSources.length === 0) return false; + const normalized = new Set(importSources); + if (session.updatedAt === null && !normalized.has("archived")) return false; + const source = sessionSource(session); + if (source === "terminal") return normalized.has("tui"); + if (source === "mac-app") return normalized.has("dashboard"); + if (source === "channel") return normalized.has("channels"); + return normalized.has("channels"); +} + function normalizeHistoryMessage(message: OpenClawChatHistoryMessage, index: number): OpenClawBackfillMessage { const role = typeof message.role === "string" ? message.role : "assistant"; const text = contentText(message.content); - return { + const normalized: OpenClawBackfillMessage = { content: { body: text || JSON.stringify(message.content ?? message), msgtype: role === "assistant" ? "m.text" : "m.notice", @@ -164,6 +198,9 @@ function normalizeHistoryMessage(message: OpenClawChatHistoryMessage, index: num sender: role === "assistant" || role === "tool" ? "agent" : role === "system" ? "system" : "human", seq: typeof message.messageSeq === "number" ? message.messageSeq : index, }; + const timestamp = historyTimestamp(message); + if (timestamp !== undefined) normalized.timestamp = timestamp; + return normalized; } function resolveAgentId(session: OpenClawListedSession): string { @@ -190,6 +227,28 @@ function contentText(content: unknown): string { }).join(""); } +function historyTimestamp(message: OpenClawChatHistoryMessage): Date | undefined { + const raw = + message.timestamp ?? + message.createdAt ?? + message.created_at ?? + message.time ?? + message.date; + if (raw instanceof Date && !Number.isNaN(raw.getTime())) return raw; + if (typeof raw === "number" && Number.isFinite(raw)) { + const milliseconds = raw < 10_000_000_000 ? raw * 1000 : raw; + const date = new Date(milliseconds); + return Number.isNaN(date.getTime()) ? undefined : date; + } + if (typeof raw === "string" && raw.trim()) { + const numeric = Number(raw); + if (Number.isFinite(numeric)) return historyTimestamp({ timestamp: numeric }); + const date = new Date(raw); + return Number.isNaN(date.getTime()) ? undefined : date; + } + return undefined; +} + function recordValue(value: unknown): Record | undefined { if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; return value as Record; @@ -205,3 +264,7 @@ function stripUndefined>(value: T): T { } return value; } + +function openClawBackfillRoomCreationContent(config: OpenClawBridgeConfig): Record | undefined { + return config.nonFederatedRooms ? { "m.federate": false } : undefined; +} diff --git a/packages/openclaw/src/beeper-setup.test.ts b/packages/openclaw/src/beeper-setup.test.ts index 27bb69c..2b70778 100644 --- a/packages/openclaw/src/beeper-setup.test.ts +++ b/packages/openclaw/src/beeper-setup.test.ts @@ -37,31 +37,6 @@ describe("OpenClaw Beeper setup", () => { }); }); - it("can request Beeper account creation instead of existing-account login", async () => { - const seen: unknown[] = []; - await loginToBeeperForOpenClaw({ - email: "new@example.com", - getLoginCode: () => "123456", - login: async (options) => { - seen.push(options); - return { - accessToken: "mx-token", - deviceId: "DEV", - homeserver: "https://matrix.beeper.com", - userId: "@new:beeper.com", - }; - }, - onlyExistingAccounts: false, - }); - - expect(seen).toEqual([ - expect.objectContaining({ - email: "new@example.com", - onlyExistingAccounts: false, - }), - ]); - }); - it("registers the OpenClaw Beeper appservice with self-hosted defaults", async () => { const seen: unknown[] = []; const result = await createOpenClawBeeperAppService({ @@ -94,12 +69,42 @@ describe("OpenClaw Beeper setup", () => { ]); expect(result.config).toEqual({ appserviceId: "openclaw", + asToken: "as", homeserver: "https://matrix.beeper.com/_hungryserv/batuhan", hsToken: "hs", registrationUrl: "http://127.0.0.1:29391", }); }); + it("passes a bridge manager token as the Beeper hungry token", async () => { + const seen: unknown[] = []; + await createOpenClawBeeperAppService({ + accessToken: "mx-token", + bridgeManagerToken: "hungry-token", + createAppServiceInit: async (options) => { + seen.push(options); + return { + homeserver: "https://matrix.beeper.com/_hungryserv/batuhan", + registration: { + asToken: "as", + hsToken: "hs", + id: "openclaw", + namespaces: { aliases: [], rooms: [], users: [] }, + senderLocalpart: "openclawbot", + url: "http://127.0.0.1:29391", + }, + }; + }, + }); + + expect(seen).toEqual([ + expect.objectContaining({ + hungryToken: "hungry-token", + token: "mx-token", + }), + ]); + }); + it("combines Beeper login and appservice registration config", async () => { const result = await setupOpenClawBeeperBridge({ email: "batuhan@example.com", @@ -134,6 +139,7 @@ describe("OpenClaw Beeper setup", () => { expect(result.config).toEqual({ accessToken: "mx-token", appserviceId: "openclaw", + asToken: "as", homeserver: "https://matrix.beeper-staging.com/_hungryserv/batuhan", hsToken: "hs", matrixDeviceId: "DEV", diff --git a/packages/openclaw/src/beeper-setup.ts b/packages/openclaw/src/beeper-setup.ts index 8185a26..068a2fb 100644 --- a/packages/openclaw/src/beeper-setup.ts +++ b/packages/openclaw/src/beeper-setup.ts @@ -22,7 +22,6 @@ export interface BeeperLoginForOpenClawOptions { initialDeviceDisplayName?: string; login?: (options: BeeperAuthOptions) => Promise; metadata?: Record; - onlyExistingAccounts?: boolean; } export interface BeeperLoginForOpenClawResult { @@ -35,6 +34,7 @@ export interface CreateOpenClawBeeperAppServiceOptions { address?: string; baseDomain?: string; bridge?: string; + bridgeManagerToken?: string; bridgeType?: string; createAppServiceInit?: (options: CreateOpenClawBeeperAppServiceRequest) => Promise; fetch?: typeof fetch; @@ -50,12 +50,13 @@ export interface CreateOpenClawBeeperAppServiceOptions { export type CreateOpenClawBeeperAppServiceRequest = CreateAppServiceOptions & { baseDomain?: string; fetch?: typeof fetch; + hungryToken?: string; token: string; username?: string; }; export interface CreateOpenClawBeeperAppServiceResult { - config: Pick; + config: Pick; init: MatrixAppserviceInitOptions; } @@ -63,6 +64,7 @@ export interface SetupOpenClawBeeperBridgeOptions extends BeeperLoginForOpenClaw address?: string; baseDomain?: string; bridge?: string; + bridgeManagerToken?: string; bridgeType?: string; createAppServiceInit?: CreateOpenClawBeeperAppServiceOptions["createAppServiceInit"]; getOnly?: boolean; @@ -75,7 +77,7 @@ export interface SetupOpenClawBeeperBridgeOptions extends BeeperLoginForOpenClaw export interface SetupOpenClawBeeperBridgeResult { account: BeeperSetupAccount; - config: Pick; + config: Pick; init: MatrixAppserviceInitOptions; } @@ -89,7 +91,6 @@ export async function loginToBeeperForOpenClaw(options: BeeperLoginForOpenClawOp if (options.env !== undefined) request.env = options.env; if (options.fetch !== undefined) request.fetch = options.fetch; if (options.getLoginCode !== undefined) request.getLoginCode = options.getLoginCode; - if (options.onlyExistingAccounts !== undefined) request.onlyExistingAccounts = options.onlyExistingAccounts; const account = await login(request); return { account, @@ -114,6 +115,7 @@ export async function createOpenClawBeeperAppService( token: options.accessToken, }; if (options.baseDomain !== undefined) request.baseDomain = options.baseDomain; + if (options.bridgeManagerToken !== undefined) request.hungryToken = options.bridgeManagerToken; if (options.fetch !== undefined) request.fetch = options.fetch; if (options.getOnly !== undefined) request.getOnly = options.getOnly; if (options.homeserver !== undefined) request.homeserver = options.homeserver; @@ -125,6 +127,7 @@ export async function createOpenClawBeeperAppService( return { config: { appserviceId: init.registration.id, + asToken: init.registration.asToken, homeserver: init.homeserver, hsToken: init.registration.hsToken, registrationUrl: options.address ?? init.registration.url ?? DEFAULT_REGISTRATION_URL, @@ -145,6 +148,7 @@ export async function setupOpenClawBeeperBridge( if (options.address !== undefined) appserviceOptions.address = options.address; if (baseDomain !== undefined) appserviceOptions.baseDomain = baseDomain; if (options.bridge !== undefined) appserviceOptions.bridge = options.bridge; + if (options.bridgeManagerToken !== undefined) appserviceOptions.bridgeManagerToken = options.bridgeManagerToken; if (options.bridgeType !== undefined) appserviceOptions.bridgeType = options.bridgeType; if (options.createAppServiceInit !== undefined) appserviceOptions.createAppServiceInit = options.createAppServiceInit; if (options.fetch !== undefined) appserviceOptions.fetch = options.fetch; diff --git a/packages/openclaw/src/beeper-stream.test.ts b/packages/openclaw/src/beeper-stream.test.ts new file mode 100644 index 0000000..3a87a96 --- /dev/null +++ b/packages/openclaw/src/beeper-stream.test.ts @@ -0,0 +1,263 @@ +import type { MatrixClient } from "@beeper/pickle"; +import { describe, expect, it, vi } from "vitest"; +import { BeeperStreamPublisher, OpenClawBeeperStreamPublisher } from "./beeper-stream"; +import type { OpenClawSessionBinding } from "./types"; + +describe("OpenClaw Beeper native stream publisher", () => { + it("starts one native Beeper stream, publishes AG-UI events, and finalizes replacement content", async () => { + const { client, finalizeMessage, publishPart, startMessage } = createClient(); + const publisher = new BeeperStreamPublisher({ + client, + initialMessageMetadata: { agent_id: "codex" }, + roomId: "!room:example.com", + turnId: "turn_1", + userId: "@openclaw_agent_codex:example.com", + }); + + await publisher.publish({ messageId: "turn_1", role: "assistant", type: "TEXT_MESSAGE_START" }); + await publisher.publish({ delta: "hello", messageId: "turn_1", type: "TEXT_MESSAGE_CONTENT" }); + await publisher.finalize(); + + expect(startMessage).toHaveBeenCalledWith({ + content: { + body: "...", + "com.beeper.ai": { + id: "turn_1", + metadata: { agent_id: "codex", turn_id: "turn_1" }, + parts: [], + role: "assistant", + }, + msgtype: "m.text", + }, + roomId: "!room:example.com", + streamType: "com.beeper.llm", + userId: "@openclaw_agent_codex:example.com", + }); + expect(publishPart.mock.calls.map(([options]) => options.part.type)).toEqual([ + "TEXT_MESSAGE_START", + "TEXT_MESSAGE_CONTENT", + "RUN_FINISHED", + ]); + expect(finalizeMessage).toHaveBeenCalledWith(expect.objectContaining({ + body: "hello", + content: expect.objectContaining({ + "com.beeper.ai": expect.objectContaining({ + parts: [{ state: "done", text: "hello", type: "text" }], + }), + body: "hello", + msgtype: "m.text", + }), + eventId: "$target", + roomId: "!room:example.com", + })); + }); + + it("keeps one room/run publisher open until a terminal event arrives", async () => { + const { client, finalizeMessage, publishPart, startMessage } = createClient(); + const publisher = new OpenClawBeeperStreamPublisher({ client, userId: "@bot:example.com" }); + const binding = sessionBinding(); + + await publisher.publish(binding, [ + { runId: "turn_2", threadId: "turn_2", type: "RUN_STARTED" }, + { messageId: "turn_2", role: "assistant", type: "TEXT_MESSAGE_START" }, + ]); + await publisher.publish(binding, [ + { delta: "hi", messageId: "turn_2", type: "TEXT_MESSAGE_CONTENT" }, + { finishReason: "stop", runId: "turn_2", threadId: "turn_2", type: "RUN_FINISHED" }, + ]); + + expect(startMessage).toHaveBeenCalledTimes(1); + expect(publishPart.mock.calls.map(([options]) => options.part.type)).toEqual([ + "RUN_STARTED", + "TEXT_MESSAGE_START", + "TEXT_MESSAGE_CONTENT", + "RUN_FINISHED", + ]); + expect(finalizeMessage).toHaveBeenCalledTimes(1); + }); + + it("honors native-only stream finalization without sending a replacement edit", async () => { + const { client, finalizeMessage, publishPart, startMessage } = createClient(); + const publisher = new OpenClawBeeperStreamPublisher({ + client, + config: { streamFinalization: "native-only" }, + userId: "@bot:example.com", + }); + + await publisher.publish(sessionBinding(), [ + { runId: "turn_3", threadId: "turn_3", type: "RUN_STARTED" }, + { delta: "native", messageId: "turn_3", type: "TEXT_MESSAGE_CONTENT" }, + { finishReason: "stop", runId: "turn_3", threadId: "turn_3", type: "RUN_FINISHED" }, + ]); + + expect(startMessage).toHaveBeenCalledTimes(1); + expect(publishPart.mock.calls.map(([options]) => options.part.type)).toEqual([ + "RUN_STARTED", + "TEXT_MESSAGE_CONTENT", + "RUN_FINISHED", + ]); + expect(finalizeMessage).not.toHaveBeenCalled(); + }); + + it("drops a terminal run publisher even when Beeper finalization fails", async () => { + const { client, finalizeMessage, startMessage } = createClient(); + finalizeMessage.mockRejectedValueOnce(new Error("finalize failed")); + const publisher = new OpenClawBeeperStreamPublisher({ client, userId: "@bot:example.com" }); + const binding = sessionBinding(); + + await expect(publisher.publish(binding, [ + { delta: "first", messageId: "turn_4", type: "TEXT_MESSAGE_CONTENT" }, + { error: "boom", message: "boom", runId: "turn_4", type: "RUN_ERROR" }, + ])).rejects.toThrow("finalize failed"); + + await publisher.publish(binding, [ + { delta: "second", messageId: "turn_4", type: "TEXT_MESSAGE_CONTENT" }, + ]); + + expect(startMessage).toHaveBeenCalledTimes(2); + }); + + it("finalizes run errors with a readable fallback body", async () => { + const { client, finalizeMessage } = createClient(); + const publisher = new BeeperStreamPublisher({ + 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(finalizeMessage).toHaveBeenCalledWith(expect.objectContaining({ + body: "Tool exploded", + content: expect.objectContaining({ + body: "Tool exploded", + }), + })); + }); + + it("preserves cancelled runs as abort terminal metadata", async () => { + const { client, finalizeMessage } = createClient(); + const publisher = new BeeperStreamPublisher({ + client, + roomId: "!room:example.com", + turnId: "turn_abort", + }); + + await publisher.finalize({ + body: "cancelled", + terminalPart: { + message: "user stopped it", + reason: "user stopped it", + runId: "turn_abort", + terminalType: "abort", + type: "RUN_ERROR", + } as never, + }); + + const aiMessage = finalizeMessage.mock.calls[0]?.[0].content["com.beeper.ai"]; + expect(aiMessage.metadata.beeper_terminal_state).toEqual({ + reason: "user stopped it", + type: "abort", + }); + }); + + it("accumulates reasoning, tool calls, and approval parts into final Beeper AI content", async () => { + const { client, finalizeMessage } = createClient(); + const publisher = new BeeperStreamPublisher({ + client, + roomId: "!room:example.com", + turnId: "turn_rich", + }); + + await publisher.publishMany([ + { messageId: "reasoning", type: "REASONING_MESSAGE_START" }, + { delta: "thinking", messageId: "reasoning", type: "REASONING_MESSAGE_CONTENT" }, + { messageId: "reasoning", type: "REASONING_MESSAGE_END" }, + { toolCallId: "tool_1", toolName: "shell", type: "TOOL_CALL_START" }, + { delta: "{\"cmd\":\"date\"}", toolCallId: "tool_1", type: "TOOL_CALL_ARGS" }, + { args: "{\"cmd\":\"date\"}", toolCallId: "tool_1", toolName: "shell", type: "TOOL_CALL_END" }, + { content: "ok", state: "done", toolCallId: "tool_1", toolName: "shell", type: "TOOL_CALL_RESULT" }, + { + name: "approval-requested", + type: "CUSTOM", + value: { + approval: { id: "approval_1" }, + message: "Run shell?", + toolCallId: "tool_1", + toolName: "shell", + }, + }, + { + name: "approval-responded", + type: "CUSTOM", + value: { + approval: { approved: true, approvedAlways: true, id: "approval_1" }, + toolCallId: "tool_1", + }, + }, + { delta: "done", messageId: "turn_rich", type: "TEXT_MESSAGE_CONTENT" }, + ]); + await publisher.finalize({ terminalPart: { finishReason: "stop", runId: "turn_rich", type: "RUN_FINISHED" } }); + + const aiMessage = finalizeMessage.mock.calls[0]?.[0].content["com.beeper.ai"]; + expect(aiMessage.parts).toEqual(expect.arrayContaining([ + expect.objectContaining({ text: "thinking", type: "reasoning" }), + expect.objectContaining({ + approval: { approved: true, id: "approval_1" }, + input: { cmd: "date" }, + output: "ok", + state: "approval-responded", + toolCallId: "tool_1", + toolName: "shell", + type: "dynamic-tool", + }), + expect.objectContaining({ text: "done", type: "text" }), + ])); + }); +}); + +function sessionBinding(): OpenClawSessionBinding { + return { + agentId: "codex", + createdAt: 1, + ghostUserId: "@openclaw_agent_codex:example.com", + id: "binding", + kind: "session", + owner: "bridge", + roomId: "!room:example.com", + sessionKey: "agent:codex:session", + updatedAt: 1, + }; +} + +function createClient() { + const startMessage = vi.fn(async () => ({ + descriptor: { device_id: "DEVICE", type: "com.beeper.llm", user_id: "@bot:example.com" }, + eventId: "$target", + roomId: "!room:example.com", + })); + const publishPart = vi.fn(async () => undefined); + const finalizeMessage = vi.fn(async () => ({ + eventId: "$target", + raw: {}, + replacementEventId: "$edit", + roomId: "!room:example.com", + })); + const client = { + beeper: { + streams: { + finalizeMessage, + publishPart, + startMessage, + }, + }, + } as unknown as MatrixClient; + return { client, finalizeMessage, publishPart, startMessage }; +} diff --git a/packages/openclaw/src/beeper-stream.ts b/packages/openclaw/src/beeper-stream.ts new file mode 100644 index 0000000..f234f2f --- /dev/null +++ b/packages/openclaw/src/beeper-stream.ts @@ -0,0 +1,388 @@ +import type { MatrixBeeper, SentEvent } from "@beeper/pickle"; +import { + applyFinalMessagePart, + compactFinalContent, + createFinalMessageAccumulator, + finalizeAccumulatedAIMessage, + getFinalMessageText, + type BeeperFinalMessageAccumulator, +} from "@beeper/pickle/streams/beeper-message"; +import type { OpenClawBridgeStreamPublisher } from "./bridge-agent"; +import { SerialQueue } from "./serial"; +import { AGUIEventType, createTurnId, type AGUIEvent } from "./stream-map"; +import type { OpenClawBridgeConfig, OpenClawSessionBinding } from "./types"; + +type FinishReason = "stop" | "length" | "content_filter" | "tool_calls" | null; + +export interface BeeperStreamPublisherClient { + beeper: MatrixBeeper; +} + +export interface BeeperStreamSubscriber { + deviceId: string; + userId: string; +} + +export interface CreateBeeperStreamPublisherOptions { + agentId?: string; + client: BeeperStreamPublisherClient; + initialMessageMetadata?: Record; + roomId: string; + subscribers?: BeeperStreamSubscriber[]; + threadRoot?: string; + turnId?: string; + userId?: string; +} + +export interface BeeperStreamStartResult { + descriptor: Record; + eventId: string; + turnId: string; +} + +export interface BeeperStreamFinalizeOptions { + body?: string; + finalText?: string; + finalization?: OpenClawBridgeConfig["streamFinalization"]; + finishReason?: string; + message?: Record; + terminalPart?: AGUIEvent; +} + +export class BeeperStreamPublisher { + readonly roomId: string; + readonly turnId: string; + #accumulator: BeeperFinalMessageAccumulator; + #agentId: string | undefined; + #client: BeeperStreamPublisherClient; + #descriptor: Record | undefined; + #finalized = false; + #initialMessageMetadata: Record; + #queue = new SerialQueue(); + #subscribers: BeeperStreamSubscriber[]; + #targetEventId: string | undefined; + #threadRoot: string | undefined; + #userId: string | undefined; + + constructor(options: CreateBeeperStreamPublisherOptions) { + this.#agentId = options.agentId; + this.#client = options.client; + this.#initialMessageMetadata = options.initialMessageMetadata ?? {}; + this.roomId = options.roomId; + this.turnId = options.turnId ?? createTurnId(); + this.#subscribers = options.subscribers ?? []; + this.#threadRoot = options.threadRoot; + this.#userId = options.userId; + this.#accumulator = createFinalMessageAccumulator(this.turnId); + } + + get targetEventId(): string | undefined { + return this.#targetEventId; + } + + async start(): Promise { + return this.#queue.run(() => this.#start()); + } + + async publish(part: AGUIEvent): Promise { + return this.#queue.run(async () => { + if (this.#finalized) throw new Error("Cannot publish to finalized Beeper stream"); + const { eventId } = await this.#start(); + await this.#publishPart(eventId, part); + }); + } + + async publishMany(parts: Iterable): Promise { + return this.#queue.run(async () => { + for (const part of parts) { + if (this.#finalized) throw new Error("Cannot publish to finalized Beeper stream"); + const { eventId } = await this.#start(); + await this.#publishPart(eventId, part); + } + }); + } + + async finalize(options: BeeperStreamFinalizeOptions = {}): Promise { + return this.#queue.run(async () => { + if (this.#finalized) throw new Error("Beeper stream is already finalized"); + const finishReason = normalizeFinishReason(options.finishReason); + const { eventId } = await this.#start(); + await this.#publishPart(eventId, options.terminalPart ?? { + finishReason, + runId: this.turnId, + threadId: this.turnId, + type: AGUIEventType.RUN_FINISHED, + }); + const finalMessage = options.message ?? finalizeAccumulatedAIMessage(this.#accumulator); + const accumulatedText = getFinalMessageText(finalMessage); + const finalText = options.body ?? options.finalText ?? (accumulatedText || terminalFallbackText(options.terminalPart)); + const finalContent = compactFinalContent({ + aiMessage: finalMessage, + body: finalText, + }); + const finalization = options.finalization ?? "replace"; + if (finalization === "native-only") { + this.#finalized = true; + return { + eventId, + roomId: this.roomId, + raw: { + logicalEventId: eventId, + nativeOnly: true, + }, + }; + } + const topLevelContent = finalization === "append" + ? {} + : { + "com.beeper.dont_render_edited": true, + }; + const replacement = await this.#client.beeper.streams.finalizeMessage({ + body: finalContent.body || "...", + content: { + body: finalContent.body || "...", + "com.beeper.ai": finalContent.aiMessage, + msgtype: "m.text", + }, + eventId, + roomId: this.roomId, + topLevelContent, + ...(this.#userId ? { userId: this.#userId } : {}), + }); + this.#finalized = true; + return { + eventId, + roomId: replacement.roomId, + raw: { + logicalEventId: eventId, + raw: replacement.raw, + replacementEventId: replacement.replacementEventId, + }, + }; + }); + } + + async #start(): Promise { + if (this.#targetEventId && this.#descriptor) { + return { descriptor: this.#descriptor, eventId: this.#targetEventId, turnId: this.turnId }; + } + const target = await this.#client.beeper.streams.startMessage({ + content: { + body: "...", + "com.beeper.ai": { + id: this.turnId, + metadata: { turn_id: this.turnId, ...this.#initialMessageMetadata }, + parts: [], + role: "assistant", + }, + msgtype: "m.text", + }, + roomId: this.roomId, + streamType: "com.beeper.llm", + ...(this.#subscribers.length > 0 ? { subscribers: this.#subscribers } : {}), + ...(this.#threadRoot ? { threadRootEventId: this.#threadRoot } : {}), + ...(this.#userId ? { userId: this.#userId } : {}), + }); + this.#descriptor = target.descriptor; + this.#targetEventId = target.eventId; + return { descriptor: target.descriptor, eventId: target.eventId, turnId: this.turnId }; + } + + async #publishPart(eventId: string, part: AGUIEvent): Promise { + await this.#client.beeper.streams.publishPart({ + ...(this.#agentId ? { agentId: this.#agentId } : {}), + eventId, + part, + roomId: this.roomId, + turnId: this.turnId, + }); + for (const accumulatorPart of aguiEventToFinalMessageParts(this.turnId, part)) { + applyFinalMessagePart(this.#accumulator, accumulatorPart); + } + } +} + +export interface OpenClawBeeperStreamPublisherOptions { + client: BeeperStreamPublisherClient; + config?: Pick; + userId?: string; +} + +export class OpenClawBeeperStreamPublisher implements OpenClawBridgeStreamPublisher { + #client: BeeperStreamPublisherClient; + #config: Pick; + #publishers = new Map(); + #userId: string | undefined; + + constructor(options: OpenClawBeeperStreamPublisherOptions) { + this.#client = options.client; + this.#config = options.config ?? {}; + this.#userId = options.userId; + } + + async publish(binding: OpenClawSessionBinding, events: AGUIEvent[]): Promise { + if (!events.length) return; + const key = streamKey(binding, events); + let publisher = this.#publishers.get(key); + if (!publisher) { + publisher = new BeeperStreamPublisher({ + agentId: binding.agentId, + client: this.#client, + initialMessageMetadata: { + agent_id: binding.agentId, + session_key: binding.sessionKey, + }, + roomId: binding.roomId, + turnId: firstRunId(events) ?? createTurnId(), + ...(this.#userId ? { userId: this.#userId } : {}), + }); + this.#publishers.set(key, publisher); + } + + const terminal = events.find(isTerminalEvent); + const nonTerminal = terminal ? events.filter((event) => event !== terminal) : events; + await publisher.publishMany(nonTerminal); + if (terminal) { + try { + await publisher.finalize({ + finalization: this.#config.streamFinalization, + terminalPart: terminal, + }); + } finally { + this.#publishers.delete(key); + } + } + } +} + +function streamKey(binding: OpenClawSessionBinding, events: AGUIEvent[]): string { + return `${binding.roomId}:${firstRunId(events) ?? binding.sessionKey}`; +} + +function firstRunId(events: AGUIEvent[]): string | undefined { + for (const event of events) { + const runId = stringValue((event as Record).runId); + if (runId) return runId; + } + return undefined; +} + +function isTerminalEvent(event: AGUIEvent): boolean { + return event.type === AGUIEventType.RUN_FINISHED || event.type === AGUIEventType.RUN_ERROR; +} + +function terminalFallbackText(event: AGUIEvent | undefined): string { + if (!event) return ""; + if (event.type === AGUIEventType.RUN_ERROR) { + return stringValue(event.message) ?? stringValue(event.error) ?? "OpenClaw run failed"; + } + return ""; +} + +function aguiEventToFinalMessageParts(turnId: string, event: AGUIEvent): Record[] { + switch (event.type) { + case AGUIEventType.RUN_STARTED: + return [{ messageId: stringValue(event.runId) ?? turnId, messageMetadata: { turn_id: stringValue(event.runId) ?? turnId }, type: "start" }]; + case AGUIEventType.RUN_FINISHED: + return [{ finishReason: stringValue(event.finishReason) ?? "stop", messageMetadata: { finish_reason: stringValue(event.finishReason) ?? "stop", turn_id: stringValue(event.runId) ?? turnId }, type: "finish" }]; + case AGUIEventType.RUN_ERROR: + if (stringValue((event as Record).terminalType) === "abort") { + return [{ + reason: stringValue((event as Record).reason) ?? stringValue(event.message) ?? stringValue(event.error) ?? "Run aborted", + type: "abort", + }]; + } + return [{ errorText: stringValue(event.message) ?? stringValue(event.error) ?? "Run failed", type: "error" }]; + case AGUIEventType.TEXT_MESSAGE_START: + return [{ id: stringValue(event.messageId) ?? turnId, type: "text-start" }]; + case AGUIEventType.TEXT_MESSAGE_CONTENT: + return [{ delta: stringValue(event.delta) ?? "", id: stringValue(event.messageId) ?? turnId, type: "text-delta" }]; + case AGUIEventType.TEXT_MESSAGE_END: + return [{ id: stringValue(event.messageId) ?? turnId, type: "text-end" }]; + case AGUIEventType.REASONING_MESSAGE_START: + return [{ id: reasoningPartId(event, turnId), type: "reasoning-start" }]; + case AGUIEventType.REASONING_MESSAGE_CONTENT: + return [{ delta: stringValue(event.delta) ?? "", id: reasoningPartId(event, turnId), type: "reasoning-delta" }]; + case AGUIEventType.REASONING_MESSAGE_END: + return [{ id: reasoningPartId(event, turnId), type: "reasoning-end" }]; + case AGUIEventType.TOOL_CALL_START: + return [{ dynamic: true, toolCallId: stringValue(event.toolCallId), toolName: stringValue(event.toolName) ?? stringValue(event.toolCallName), type: "tool-input-start" }]; + case AGUIEventType.TOOL_CALL_ARGS: + return [{ inputTextDelta: stringValue(event.delta) ?? stringifyValue(event.args), toolCallId: stringValue(event.toolCallId), type: "tool-input-delta" }]; + case AGUIEventType.TOOL_CALL_END: + return [{ dynamic: true, input: event.input ?? parseMaybeJSON(stringValue(event.args)), toolCallId: stringValue(event.toolCallId), toolName: stringValue(event.toolName) ?? stringValue(event.toolCallName), type: "tool-input-available" }]; + case AGUIEventType.TOOL_CALL_RESULT: + return [{ + dynamic: true, + ...(event.state === "error" ? { errorText: stringValue(event.content) ?? stringifyValue(event.content) } : { output: parseMaybeJSON(stringValue(event.content)) ?? event.content }), + preliminary: event.state === "streaming" ? true : undefined, + toolCallId: stringValue(event.toolCallId), + toolName: stringValue(event.toolName), + type: event.state === "error" ? "tool-output-error" : "tool-output-available", + }]; + case AGUIEventType.CUSTOM: + return customEventToFinalMessageParts(event); + default: + return []; + } +} + +function customEventToFinalMessageParts(event: AGUIEvent): Record[] { + const value = recordValue(event.value); + if (event.name === "approval-requested" && value) { + const approval = recordValue(value.approval); + const approvalId = stringValue(value.approvalId) ?? stringValue(value.approvalMessageId) ?? stringValue(approval?.id); + if (!approvalId) return []; + return [{ approvalId, message: value.message, toolCallId: stringValue(value.toolCallId), toolName: stringValue(value.toolName), type: "tool-approval-request" }]; + } + if (event.name === "approval-responded" && value) { + const approval = recordValue(value.approval); + const approvalId = stringValue(value.approvalId) ?? stringValue(approval?.id); + if (!approvalId) return []; + return [{ + approvalId, + approved: approval?.approved, + approvedAlways: approval?.approvedAlways ?? approval?.always, + toolCallId: stringValue(value.toolCallId), + type: "tool-approval-response", + }]; + } + return []; +} + +function reasoningPartId(event: AGUIEvent, turnId: string): string { + return `reasoning_${stringValue(event.messageId) ?? turnId}`; +} + +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 stringifyValue(value: unknown): string { + if (typeof value === "string") return value; + if (value === undefined) return ""; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function parseMaybeJSON(value: string | undefined): unknown { + if (value === undefined || value === "") return undefined; + try { + return JSON.parse(value); + } catch { + return value; + } +} + +function normalizeFinishReason(reason: string | undefined): FinishReason { + if (reason === "length" || reason === "content_filter" || reason === "tool_calls") return reason; + return "stop"; +} diff --git a/packages/openclaw/src/bridge-agent.test.ts b/packages/openclaw/src/bridge-agent.test.ts index c515caa..1741094 100644 --- a/packages/openclaw/src/bridge-agent.test.ts +++ b/packages/openclaw/src/bridge-agent.test.ts @@ -52,16 +52,55 @@ describe("OpenClawMatrixBridgeAgent", () => { idempotencyKey: "$event", key: "agent:codex:main", message: "hello", - }, { expectFinal: true }); + }, { expectFinal: false }); expect(registry.getBindingByRoom("!room:example.com")?.lastRunId).toBe("run_1"); expect(published.flatMap((item) => item.chunks).map((chunk) => (chunk as { type: string }).type)).toEqual([ - "text-start", - "text-delta", - "text-end", - "finish", + "TEXT_MESSAGE_START", + "TEXT_MESSAGE_CONTENT", + "TEXT_MESSAGE_END", + "RUN_FINISHED", ]); }); + it("does not poison message dedupe when OpenClaw send fails before persistence", async () => { + const registry = await tempRegistry(); + registry.upsertBinding(testBinding()); + const runtime = runtimeWith({ + responses: { + "sessions.send": new Error("gateway down"), + }, + }); + const agent = new OpenClawMatrixBridgeAgent({ registry, runtime, streams: { publish: vi.fn() } }); + + await expect(agent.handleMatrixText({ + eventId: "$retryable", + roomId: "!room:example.com", + sender: "@alice:example.com", + text: "hello", + })).rejects.toThrow("gateway down"); + + expect(registry.hasDedupe("$retryable")).toBe(false); + + runtime.transport.request.mockImplementation(async (method: string) => { + if (method === "sessions.send") return { runId: "run_retry", sessionKey: "agent:codex:main" }; + return undefined; + }); + + await agent.handleMatrixText({ + eventId: "$retryable", + roomId: "!room:example.com", + sender: "@alice:example.com", + text: "hello", + }); + + expect(registry.hasDedupe("$retryable")).toBe(true); + expect(runtime.transport.request).toHaveBeenCalledWith("sessions.send", { + idempotencyKey: "$retryable", + key: "agent:codex:main", + message: "hello", + }, { expectFinal: false }); + }); + it("creates an OpenClaw session before sending the first message in an agent contact DM", async () => { const registry = await tempRegistry(); registry.upsertBinding({ @@ -93,7 +132,7 @@ describe("OpenClawMatrixBridgeAgent", () => { idempotencyKey: "$event", key: "agent:codex:session_1", message: "hello", - }, { expectFinal: true }); + }, { expectFinal: false }); expect(registry.getBindingByRoom("!room:example.com")?.sessionKey).toBe("agent:codex:session_1"); }); @@ -125,13 +164,45 @@ describe("OpenClawMatrixBridgeAgent", () => { await agent.streamRun(binding, "run_1"); expect(published.map((chunk) => (chunk as { type: string }).type)).toEqual([ - "start", - "text-start", - "text-delta", - "tool-input-available", - "tool-approval-request", - "text-end", - "finish", + "RUN_STARTED", + "TEXT_MESSAGE_START", + "TEXT_MESSAGE_CONTENT", + "TOOL_CALL_START", + "TOOL_CALL_ARGS", + "TOOL_CALL_END", + "CUSTOM", + "TEXT_MESSAGE_END", + "RUN_FINISHED", + ]); + }); + + it("seeds streaming state with the actual OpenClaw run id", async () => { + const registry = await tempRegistry(); + const binding = testBinding(); + const published: unknown[] = []; + const agent = new OpenClawMatrixBridgeAgent({ + registry, + runtime: runtimeWith({ + events: [ + { event: "session.message", payload: { deltaText: "hello", role: "assistant", runId: "run_actual" } }, + { event: "session.operation", payload: { phase: "completed", runId: "run_actual" } }, + ], + responses: {}, + }), + streams: { + publish(_binding, chunks) { + published.push(...chunks); + }, + }, + }); + + await agent.streamRun(binding, "run_actual"); + + expect(published).toEqual([ + expect.objectContaining({ messageId: "run_actual", type: "TEXT_MESSAGE_START" }), + expect.objectContaining({ messageId: "run_actual", type: "TEXT_MESSAGE_CONTENT" }), + expect.objectContaining({ messageId: "run_actual", type: "TEXT_MESSAGE_END" }), + expect.objectContaining({ runId: "run_actual", type: "RUN_FINISHED" }), ]); }); @@ -191,7 +262,11 @@ function runtimeWith(options: { if (!filter || filter(event)) yield event; } }, - request: vi.fn(async (method: string) => options.responses[method]), + request: vi.fn(async (method: string) => { + const response = options.responses[method]; + if (response instanceof Error) throw response; + return response; + }), }; return new OpenClawGatewayRuntime({ config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), diff --git a/packages/openclaw/src/bridge-agent.ts b/packages/openclaw/src/bridge-agent.ts index ffbf631..cd6d206 100644 --- a/packages/openclaw/src/bridge-agent.ts +++ b/packages/openclaw/src/bridge-agent.ts @@ -4,18 +4,21 @@ import { type ParsedApprovalResponse, } from "./approval"; import { createOpenClawStreamState, mapOpenClawEventToBeeperChunks } from "./openclaw-event-map"; -import type { OpenClawGatewayRuntime, OpenClawGatewayEvent } from "./openclaw-runtime"; +import type { OpenClawGatewayRuntime, OpenClawGatewayEvent, OpenClawMatrixMessageMetadata } from "./openclaw-runtime"; import type { OpenClawBridgeRegistry } from "./registry"; -import { createTurnId, type BeeperUIMessageChunk } from "./stream-map"; +import type { AGUIEvent } from "./stream-map"; import type { OpenClawSessionBinding } from "./types"; export interface OpenClawBridgeStreamPublisher { - publish(binding: OpenClawSessionBinding, chunks: BeeperUIMessageChunk[]): Promise | void; + publish(binding: OpenClawSessionBinding, events: AGUIEvent[]): Promise | void; } export interface MatrixTextTurn { + attachments?: unknown[]; eventId: string; + matrix?: OpenClawMatrixMessageMetadata; roomId: string; + replyToEventId?: string; sender: string; text: string; } @@ -44,16 +47,19 @@ export class OpenClawMatrixBridgeAgent { async handleMatrixText(turn: MatrixTextTurn): Promise { if (this.registry.hasDedupe(turn.eventId)) return; - this.registry.markDedupe(turn.eventId); const binding = this.registry.getBindingByRoom(turn.roomId); if (!binding) { + this.registry.markDedupe(turn.eventId); await this.registry.save(); return; } const sessionKey = await this.ensureSession(binding); const run = await this.runtime.sendMessage({ + ...(turn.attachments && turn.attachments.length > 0 ? { attachments: turn.attachments } : {}), idempotencyKey: turn.eventId, + ...(turn.matrix ? { matrix: turn.matrix } : {}), message: turn.text, + ...(turn.replyToEventId ? { replyTo: { eventId: turn.replyToEventId, roomId: turn.roomId } } : {}), sessionKey, }); this.registry.updateBinding(binding.id, (current) => ({ @@ -64,6 +70,7 @@ export class OpenClawMatrixBridgeAgent { updatedAt: Date.now(), })); await this.streamRun({ ...binding, sessionKey: run.sessionKey }, run.runId); + this.registry.markDedupe(turn.eventId); await this.registry.save(); } @@ -76,7 +83,7 @@ export class OpenClawMatrixBridgeAgent { } async streamRun(binding: OpenClawSessionBinding, runId: string): Promise { - const state = createOpenClawStreamState(createTurnId()); + const state = createOpenClawStreamState(runId); for await (const gatewayEvent of this.runtime.eventsForRun(runId)) { const chunks = mapOpenClawEventToBeeperChunks(state, openClawEventFromGateway(gatewayEvent)); if (chunks.length > 0) await this.streams.publish(binding, chunks); diff --git a/packages/openclaw/src/cli.test.ts b/packages/openclaw/src/cli.test.ts index ef6ae90..9a1e306 100644 --- a/packages/openclaw/src/cli.test.ts +++ b/packages/openclaw/src/cli.test.ts @@ -1,6 +1,7 @@ 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"; @@ -18,12 +19,16 @@ describe("pickle-openclaw CLI", () => { dir, "--homeserver", "https://matrix.example", + "--gateway-access-token", + "gateway-secret", "--access-token", "secret", ], initIO)).resolves.toBe(0); expect(initIO.stdoutText).toContain('"accessToken": ""'); + expect(initIO.stdoutText).toContain('"gatewayAccessToken": ""'); expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({ accessToken: "secret", + gatewayAccessToken: "gateway-secret", homeserver: "https://matrix.example", }); expect((await stat(configPath)).mode & 0o777).toBe(0o600); @@ -148,6 +153,229 @@ describe("pickle-openclaw CLI", () => { status: { ok: true }, }); }); + + it("runs Beeper setup from CLI and persists runtime bridge-manager settings", async () => { + const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-beeper-setup-")); + const configPath = join(dir, "config.json"); + const io = captureIO(); + const setupBridge = vi.fn(async (options) => { + expect(options).toMatchObject({ + baseDomain: "beeper-staging.com", + bridgeManagerToken: "hungry-token", + email: "batuhan@example.com", + env: "staging", + homeserverDomain: "beeper.local", + postState: false, + }); + expect(await options.getLoginCode?.()).toBe("123456"); + return { + account: { + accessToken: "mx-token", + deviceId: "DEV", + homeserver: "https://matrix.beeper-staging.com", + userId: "@batuhan:beeper-staging.com", + }, + config: { + accessToken: "mx-token", + appserviceId: "openclaw", + homeserver: "https://matrix.beeper-staging.com/_hungryserv/batuhan", + hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@batuhan:beeper-staging.com", + registrationUrl: "http://127.0.0.1:29391", + }, + init: { + homeserver: "https://matrix.beeper-staging.com/_hungryserv/batuhan", + registration: { id: "openclaw", hsToken: "hs", url: "http://127.0.0.1:29391" }, + }, + } as never; + }); + + await expect(runCli([ + "beeper-setup", + "--config", + configPath, + "--data-dir", + dir, + "--email", + "batuhan@example.com", + "--login-code", + "123456", + "--env", + "staging", + "--bridge-manager-token", + "hungry-token", + "--homeserver-domain", + "beeper.local", + "--no-post-state", + ], io, { setupBridge })).resolves.toBe(0); + + const written = JSON.parse(await readFile(configPath, "utf8")); + expect(written).toMatchObject({ + accessToken: "mx-token", + appserviceId: "openclaw", + baseDomain: "beeper-staging.com", + beeperEnv: "staging", + bridgeManagerPostState: false, + bridgeManagerToken: "hungry-token", + homeserver: "https://matrix.beeper-staging.com/_hungryserv/batuhan", + homeserverDomain: "beeper.local", + hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@batuhan:beeper-staging.com", + }); + expect(io.stdoutText).toContain('"bridgeManagerToken": ""'); + expect(io.stdoutText).not.toContain("hungry-token"); + }); + + it("prompts for Beeper login OTP in CLI setup when --login-code is omitted", async () => { + const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-beeper-setup-prompt-")); + const configPath = join(dir, "config.json"); + const io = captureIO("654321\n"); + const setupBridge = vi.fn(async (options) => { + expect(await options.getLoginCode?.()).toBe("654321"); + return { + account: { + accessToken: "mx-token", + deviceId: "DEV", + homeserver: "https://matrix.beeper.com", + userId: "@batuhan:beeper.com", + }, + config: { + accessToken: "mx-token", + appserviceId: "openclaw", + homeserver: "https://matrix.beeper.com/_hungryserv/batuhan", + hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@batuhan:beeper.com", + registrationUrl: "http://127.0.0.1:29391", + }, + init: { + homeserver: "https://matrix.beeper.com/_hungryserv/batuhan", + registration: { id: "openclaw", hsToken: "hs", url: "http://127.0.0.1:29391" }, + }, + } as never; + }); + + await expect(runCli([ + "beeper-setup", + "--config", + configPath, + "--data-dir", + dir, + "--email", + "batuhan@example.com", + ], io, { setupBridge })).resolves.toBe(0); + + expect(setupBridge).toHaveBeenCalledOnce(); + expect(io.stderrText).toContain("Enter Beeper login code:"); + expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({ + accessToken: "mx-token", + matrixDeviceId: "DEV", + }); + }); + + it("prompts for Beeper login OTP in CLI login when --login-code is omitted", async () => { + const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-beeper-login-prompt-")); + const configPath = join(dir, "config.json"); + const io = captureIO("111222\n"); + const loginToBeeper = vi.fn(async (options) => { + expect(await options.getLoginCode?.()).toBe("111222"); + return { + account: { + accessToken: "mx-token", + deviceId: "DEV", + homeserver: "https://matrix.beeper.com", + userId: "@batuhan:beeper.com", + }, + config: { + accessToken: "mx-token", + homeserver: "https://matrix.beeper.com", + matrixDeviceId: "DEV", + matrixUserId: "@batuhan:beeper.com", + }, + }; + }); + + await expect(runCli([ + "beeper-login", + "--config", + configPath, + "--data-dir", + dir, + "--email", + "batuhan@example.com", + ], io, { loginToBeeper })).resolves.toBe(0); + + expect(loginToBeeper).toHaveBeenCalledOnce(); + expect(io.stderrText).toContain("Enter Beeper login code:"); + expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({ + accessToken: "mx-token", + matrixUserId: "@batuhan:beeper.com", + }); + }); + + it("runs Beeper appservice registration from CLI and preserves existing login config", async () => { + const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-beeper-register-")); + const configPath = join(dir, "config.json"); + await expect(runCli([ + "init", + "--config", + configPath, + "--data-dir", + dir, + "--access-token", + "mx-token", + "--homeserver", + "https://matrix.beeper.com", + ], captureIO())).resolves.toBe(0); + const createAppService = vi.fn(async (options) => { + expect(options).toMatchObject({ + accessToken: "mx-token", + address: "http://127.0.0.1:29391", + bridgeManagerToken: "hungry-token", + getOnly: true, + homeserver: "https://matrix.beeper.com", + postState: false, + selfHosted: true, + }); + return { + config: { + appserviceId: "openclaw", + homeserver: "https://matrix.beeper.com/_hungryserv/batuhan", + hsToken: "hs", + registrationUrl: "http://127.0.0.1:29391", + }, + init: { + homeserver: "https://matrix.beeper.com/_hungryserv/batuhan", + registration: { id: "openclaw", hsToken: "hs", url: "http://127.0.0.1:29391" }, + }, + } as never; + }); + const io = captureIO(); + + await expect(runCli([ + "beeper-register", + "--config", + configPath, + "--bridge-manager-token", + "hungry-token", + "--get-only", + "--no-post-state", + ], io, { createAppService })).resolves.toBe(0); + + const written = JSON.parse(await readFile(configPath, "utf8")); + expect(written).toMatchObject({ + accessToken: "mx-token", + appserviceId: "openclaw", + bridgeManagerPostState: false, + bridgeManagerToken: "hungry-token", + homeserver: "https://matrix.beeper.com/_hungryserv/batuhan", + hsToken: "hs", + }); + expect(io.stdoutText).toContain('"bridgeManagerToken": ""'); + expect(io.stdoutText).not.toContain("hungry-token"); + }); }); function fakeRuntime(responses: Record, snapshot: unknown = {}) { @@ -158,10 +386,11 @@ function fakeRuntime(responses: Record, snapshot: unknown = {}) } as never; } -function captureIO() { +function captureIO(stdinText?: string) { const io = { stderrText: "", stdoutText: "", + stdin: stdinText === undefined ? undefined : Readable.from([stdinText]), stderr: { write(this: { owner: { stderrText: string } }, chunk: string) { this.owner.stderrText += chunk; diff --git a/packages/openclaw/src/cli.ts b/packages/openclaw/src/cli.ts index 24d6c10..7730c32 100644 --- a/packages/openclaw/src/cli.ts +++ b/packages/openclaw/src/cli.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { chmod, mkdir, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; +import { createInterface } from "node:readline/promises"; import type { BeeperEnvironment } from "@beeper/pickle/beeper/auth"; import { accountFromOpenClawConfig, startOpenClawBeeperBridge, type CreateOpenClawBeeperBridgeOptions } from "./appservice"; import { createOpenClawBeeperAppService, loginToBeeperForOpenClaw, setupOpenClawBeeperBridge } from "./beeper-setup"; @@ -12,11 +13,15 @@ import type { AppserviceRegistration, OpenClawBridgeConfig } from "./types"; export interface CliIO { stderr: Pick; + stdin?: NodeJS.ReadableStream; stdout: Pick; } export interface CliDeps { + createAppService?: typeof createOpenClawBeeperAppService; + loginToBeeper?: typeof loginToBeeperForOpenClaw; runtimeFactory?: (config: OpenClawBridgeConfig) => OpenClawGatewayRuntime; + setupBridge?: typeof setupOpenClawBeeperBridge; startBridge?: (options: CreateOpenClawBeeperBridgeOptions) => Promise; } @@ -38,8 +43,8 @@ export async function runCli(argv = process.argv.slice(2), io: CliIO = process, const options = parseOptions(args); const config = await loadConfig(options); const registration = createAppserviceRegistration(config, { - asToken: stringOption(options, "as-token") ?? secretToken(), - hsToken: stringOption(options, "hs-token") ?? secretToken(), + asToken: stringOption(options, "as-token") ?? config.asToken ?? secretToken(), + hsToken: stringOption(options, "hs-token") ?? config.hsToken ?? secretToken(), }); const output = stringOption(options, "output") ?? resolve(config.dataDir, "registration.json"); await writeRegistration(output, registration); @@ -102,10 +107,11 @@ export async function runCli(argv = process.argv.slice(2), io: CliIO = process, const env = beeperEnvOption(options); if (env !== undefined) loginOptions.env = env; if (loginCode !== undefined) loginOptions.getLoginCode = () => loginCode; - if (booleanOption(options, "create-account")) loginOptions.onlyExistingAccounts = false; - const result = await loginToBeeperForOpenClaw(loginOptions); + else loginOptions.getLoginCode = () => promptForLoginCode(io); + const result = await (deps.loginToBeeper ?? loginToBeeperForOpenClaw)(loginOptions); const config = createDefaultConfig({ ...configOverridesFromOptions(options), + ...beeperRuntimeOverridesFromOptions(options), ...result.config, }); await writeConfig(config, stringOption(options, "config") ?? defaultConfigPath(config.dataDir)); @@ -128,20 +134,23 @@ export async function runCli(argv = process.argv.slice(2), io: CliIO = process, }; const baseDomain = stringOption(options, "base-domain") ?? beeperBaseDomainOption(options); const bridge = stringOption(options, "bridge"); + const bridgeManagerToken = stringOption(options, "bridge-manager-token"); const bridgeType = stringOption(options, "bridge-type"); const homeserver = stringOption(options, "homeserver") ?? existingConfig.homeserver; const homeserverDomain = stringOption(options, "homeserver-domain"); const username = stringOption(options, "username"); if (baseDomain !== undefined) registerOptions.baseDomain = baseDomain; if (bridge !== undefined) registerOptions.bridge = bridge; + if (bridgeManagerToken !== undefined) registerOptions.bridgeManagerToken = bridgeManagerToken; if (bridgeType !== undefined) registerOptions.bridgeType = bridgeType; if (homeserver !== undefined) registerOptions.homeserver = homeserver; if (homeserverDomain !== undefined) registerOptions.homeserverDomain = homeserverDomain; if (username !== undefined) registerOptions.username = username; - const result = await createOpenClawBeeperAppService(registerOptions); + const result = await (deps.createAppService ?? createOpenClawBeeperAppService)(registerOptions); const config = createDefaultConfig({ ...existingConfig, ...configOverridesFromOptions(options), + ...beeperRuntimeOverridesFromOptions(options), ...result.config, accessToken, }); @@ -162,6 +171,7 @@ export async function runCli(argv = process.argv.slice(2), io: CliIO = process, const address = stringOption(options, "registration-url"); const baseDomain = stringOption(options, "base-domain") ?? beeperBaseDomainOption(options); const bridge = stringOption(options, "bridge"); + const bridgeManagerToken = stringOption(options, "bridge-manager-token"); const bridgeType = stringOption(options, "bridge-type"); const env = beeperEnvOption(options); const homeserverDomain = stringOption(options, "homeserver-domain"); @@ -169,15 +179,17 @@ export async function runCli(argv = process.argv.slice(2), io: CliIO = process, if (address !== undefined) setupOptions.address = address; if (baseDomain !== undefined) setupOptions.baseDomain = baseDomain; if (bridge !== undefined) setupOptions.bridge = bridge; + if (bridgeManagerToken !== undefined) setupOptions.bridgeManagerToken = bridgeManagerToken; if (bridgeType !== undefined) setupOptions.bridgeType = bridgeType; if (env !== undefined) setupOptions.env = env; if (loginCode !== undefined) setupOptions.getLoginCode = () => loginCode; + else setupOptions.getLoginCode = () => promptForLoginCode(io); if (homeserverDomain !== undefined) setupOptions.homeserverDomain = homeserverDomain; - if (booleanOption(options, "create-account")) setupOptions.onlyExistingAccounts = false; if (username !== undefined) setupOptions.username = username; - const result = await setupOpenClawBeeperBridge(setupOptions); + 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)); @@ -217,6 +229,7 @@ function helpText(): string { " --config ", " --data-dir ", " --homeserver ", + " --gateway-access-token ", " --gateway-url ", " --registration-url ", " --matrix-device-id ", @@ -227,7 +240,7 @@ function helpText(): string { " --output ", " --email
", " --login-code ", - " --create-account", + " --bridge-manager-token ", " --backfill", " --backfill-limit ", " --params-json ", @@ -239,16 +252,20 @@ function helpText(): string { function configOverridesFromOptions(options: Map): Partial { const overrides: Partial = {}; const accessToken = stringOption(options, "access-token"); + const asToken = stringOption(options, "as-token"); const appserviceId = stringOption(options, "appservice-id"); const dataDir = stringOption(options, "data-dir"); + const gatewayAccessToken = stringOption(options, "gateway-access-token"); const gatewayUrl = stringOption(options, "gateway-url"); const homeserver = stringOption(options, "homeserver"); const matrixDeviceId = stringOption(options, "matrix-device-id"); const matrixUserId = stringOption(options, "matrix-user-id"); const registrationUrl = stringOption(options, "registration-url"); if (accessToken) overrides.accessToken = accessToken; + if (asToken) overrides.asToken = asToken; if (appserviceId) overrides.appserviceId = appserviceId; if (dataDir) overrides.dataDir = dataDir; + if (gatewayAccessToken) overrides.gatewayAccessToken = gatewayAccessToken; if (gatewayUrl) overrides.gatewayUrl = gatewayUrl; if (homeserver) overrides.homeserver = homeserver; if (matrixDeviceId) overrides.matrixDeviceId = matrixDeviceId; @@ -257,6 +274,21 @@ function configOverridesFromOptions(options: Map): Par return overrides; } +function beeperRuntimeOverridesFromOptions(options: Map): Partial { + const overrides: Partial = {}; + const baseDomain = stringOption(options, "base-domain") ?? beeperBaseDomainOption(options); + const bridgeManagerToken = stringOption(options, "bridge-manager-token"); + const env = beeperEnvOption(options); + const homeserverDomain = stringOption(options, "homeserver-domain"); + if (baseDomain !== undefined) overrides.baseDomain = baseDomain; + if (bridgeManagerToken !== undefined) overrides.bridgeManagerToken = bridgeManagerToken; + if (env !== undefined) overrides.beeperEnv = env; + if (homeserverDomain !== undefined) overrides.homeserverDomain = homeserverDomain; + if (options.has("no-post-state")) overrides.bridgeManagerPostState = false; + else if (options.has("post-state")) overrides.bridgeManagerPostState = true; + return overrides; +} + async function loadConfig(options: Map): Promise { const configPath = stringOption(options, "config"); if (configPath) return readConfig(configPath); @@ -267,6 +299,9 @@ function redactConfig(config: OpenClawBridgeConfig): OpenClawBridgeConfig { return { ...config, ...(config.accessToken ? { accessToken: "" } : {}), + ...(config.asToken ? { asToken: "" } : {}), + ...(config.bridgeManagerToken ? { bridgeManagerToken: "" } : {}), + ...(config.gatewayAccessToken ? { gatewayAccessToken: "" } : {}), ...(config.hsToken ? { hsToken: "" } : {}), }; } @@ -359,6 +394,21 @@ function runtimeFromConfig(config: OpenClawBridgeConfig): OpenClawGatewayRuntime return createOpenClawRuntimeFromLogin(userLoginFromOpenClawConfig(config), config); } +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 index 4b3c334..3b7d14e 100644 --- a/packages/openclaw/src/config.test.ts +++ b/packages/openclaw/src/config.test.ts @@ -21,13 +21,46 @@ describe("OpenClaw bridge config", () => { }); }); + it("accepts dashboard-derived bridge behavior settings", () => { + expect(createDefaultConfig({ + backfillLimit: 25, + baseDomain: "beeper-staging.com", + beeperEnv: "staging", + bridgeManagerPostState: false, + bridgeManagerToken: "hungry-token", + asToken: "as-token", + contactVisibility: "agents-and-users", + dataDir: "/tmp/openclaw-bridge", + gatewayAccessToken: "gateway-token", + homeserverDomain: "beeper.local", + importSources: ["dashboard", "tui"], + approvalBehavior: "native", + streamFinalization: "replace", + })).toMatchObject({ + approvalBehavior: "native", + backfillLimit: 25, + baseDomain: "beeper-staging.com", + beeperEnv: "staging", + bridgeManagerPostState: false, + bridgeManagerToken: "hungry-token", + asToken: "as-token", + contactVisibility: "agents-and-users", + gatewayAccessToken: "gateway-token", + homeserverDomain: "beeper.local", + importSources: ["dashboard", "tui"], + streamFinalization: "replace", + }); + }); + 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({ accessToken: "secret", dataDir: dir, homeserver: "https://matrix.example" }); + const config = createDefaultConfig({ accessToken: "secret", asToken: "as-secret", dataDir: dir, gatewayAccessToken: "gateway-secret", homeserver: "https://matrix.example" }); await writeConfig(config, path); expect(JSON.parse(await readFile(path, "utf8"))).toMatchObject({ accessToken: "secret", + asToken: "as-secret", + gatewayAccessToken: "gateway-secret", homeserver: "https://matrix.example", }); expect((await stat(path)).mode & 0o777).toBe(0o600); diff --git a/packages/openclaw/src/config.ts b/packages/openclaw/src/config.ts index 610e8ab..3861c37 100644 --- a/packages/openclaw/src/config.ts +++ b/packages/openclaw/src/config.ts @@ -2,6 +2,7 @@ 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 { getBeeperChannelSettings, type OpenClawSetupConfig } from "./setup"; import type { OpenClawBridgeConfig } from "./types"; export const DEFAULT_APPSERVICE_ID = "pickle-openclaw"; @@ -41,17 +42,41 @@ export function createDefaultConfig(overrides: Partial = { overrides.userLocalpartPrefix ?? process.env.PICKLE_OPENCLAW_USER_LOCALPART_PREFIX ?? DEFAULT_USER_LOCALPART_PREFIX, }; const accessToken = overrides.accessToken ?? process.env.PICKLE_OPENCLAW_ACCESS_TOKEN; + const asToken = overrides.asToken ?? process.env.PICKLE_OPENCLAW_AS_TOKEN; + const baseDomain = overrides.baseDomain ?? process.env.PICKLE_OPENCLAW_BASE_DOMAIN; + const beeperEnv = overrides.beeperEnv ?? envBeeperEnv(process.env.PICKLE_OPENCLAW_BEEPER_ENV); + const bridgeManagerToken = overrides.bridgeManagerToken ?? process.env.PICKLE_OPENCLAW_BRIDGE_MANAGER_TOKEN; + const gatewayAccessToken = overrides.gatewayAccessToken ?? process.env.PICKLE_OPENCLAW_GATEWAY_ACCESS_TOKEN; const gatewayUrl = overrides.gatewayUrl ?? process.env.PICKLE_OPENCLAW_GATEWAY_URL; const homeserver = overrides.homeserver ?? process.env.PICKLE_OPENCLAW_HOMESERVER; + const homeserverDomain = overrides.homeserverDomain ?? process.env.PICKLE_OPENCLAW_HOMESERVER_DOMAIN; const hsToken = overrides.hsToken ?? process.env.PICKLE_OPENCLAW_HS_TOKEN; const matrixDeviceId = overrides.matrixDeviceId ?? process.env.PICKLE_OPENCLAW_MATRIX_DEVICE_ID; const matrixUserId = overrides.matrixUserId ?? process.env.PICKLE_OPENCLAW_MATRIX_USER_ID; + const backfillLimit = overrides.backfillLimit ?? envNumber(process.env.PICKLE_OPENCLAW_BACKFILL_LIMIT); + const contactVisibility = overrides.contactVisibility ?? envContactVisibility(process.env.PICKLE_OPENCLAW_CONTACT_VISIBILITY); + const importSources = overrides.importSources ?? envImportSources(process.env.PICKLE_OPENCLAW_IMPORT_SOURCES); + const streamFinalization = overrides.streamFinalization ?? envStreamFinalization(process.env.PICKLE_OPENCLAW_STREAM_FINALIZATION); + const approvalBehavior = overrides.approvalBehavior ?? envApprovalBehavior(process.env.PICKLE_OPENCLAW_APPROVAL_BEHAVIOR); + const bridgeManagerPostState = overrides.bridgeManagerPostState ?? envBoolean(process.env.PICKLE_OPENCLAW_BRIDGE_MANAGER_POST_STATE); if (accessToken) config.accessToken = accessToken; + if (asToken) config.asToken = asToken; + if (baseDomain) config.baseDomain = baseDomain; + if (beeperEnv) config.beeperEnv = beeperEnv; + if (bridgeManagerToken) config.bridgeManagerToken = bridgeManagerToken; + if (gatewayAccessToken) config.gatewayAccessToken = gatewayAccessToken; if (gatewayUrl) config.gatewayUrl = gatewayUrl; 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; + if (backfillLimit !== undefined) config.backfillLimit = backfillLimit; + if (contactVisibility !== undefined) config.contactVisibility = contactVisibility; + if (importSources !== undefined) config.importSources = importSources; + if (streamFinalization !== undefined) config.streamFinalization = streamFinalization; + if (approvalBehavior !== undefined) config.approvalBehavior = approvalBehavior; + if (bridgeManagerPostState !== undefined) config.bridgeManagerPostState = bridgeManagerPostState; if (overrides.allowedRoomIds) config.allowedRoomIds = overrides.allowedRoomIds; if (overrides.allowedUserIds) config.allowedUserIds = overrides.allowedUserIds; return config; @@ -61,6 +86,17 @@ export async function readConfig(path = defaultConfigPath()): Promise); } +export function createConfigFromOpenClawSetup( + cfg: OpenClawSetupConfig, + overrides: Partial = {}, +): OpenClawBridgeConfig { + const settings = getBeeperChannelSettings(cfg); + return createDefaultConfig({ + ...settings, + ...overrides, + }); +} + 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 }); @@ -77,3 +113,38 @@ function envBoolean(value: string | undefined): boolean | undefined { if (["0", "false", "no", "off"].includes(value.toLowerCase())) return false; return undefined; } + +function envNumber(value: string | undefined): number | undefined { + if (value === undefined || value === "") return undefined; + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined; +} + +function envContactVisibility(value: string | undefined): OpenClawBridgeConfig["contactVisibility"] | undefined { + if (value === "agents" || value === "agents-and-users" || value === "none") return value; + return undefined; +} + +function envImportSources(value: string | undefined): OpenClawBridgeConfig["importSources"] | undefined { + if (!value) return undefined; + const sources = value.split(",").map((entry) => entry.trim()).filter(Boolean); + if (sources.every((source) => source === "dashboard" || source === "tui" || source === "channels" || source === "archived")) { + return sources as OpenClawBridgeConfig["importSources"]; + } + return undefined; +} + +function envStreamFinalization(value: string | undefined): OpenClawBridgeConfig["streamFinalization"] | undefined { + if (value === "replace" || value === "append" || value === "native-only") return value; + return undefined; +} + +function envApprovalBehavior(value: string | undefined): OpenClawBridgeConfig["approvalBehavior"] | undefined { + if (value === "native" || value === "reactions" || value === "slash" || value === "disabled") return value; + return undefined; +} + +function envBeeperEnv(value: string | undefined): OpenClawBridgeConfig["beeperEnv"] | undefined { + if (value === "production" || value === "staging" || value === "dev" || value === "local") return value; + return undefined; +} diff --git a/packages/openclaw/src/connector.test.ts b/packages/openclaw/src/connector.test.ts index 8a67abe..f8e52d3 100644 --- a/packages/openclaw/src/connector.test.ts +++ b/packages/openclaw/src/connector.test.ts @@ -1,7 +1,7 @@ -import type { BridgeRequestContext, MatrixMessage, MatrixReaction, UserLogin } from "@beeper/pickle-bridge"; +import type { BridgeRequestContext, MatrixEdit, MatrixMessage, MatrixReaction, MatrixRedaction, UserLogin } from "@beeper/pickle-bridge"; import { describe, expect, it, vi } from "vitest"; import { createDefaultConfig } from "./config"; -import { createOpenClawConnector, OpenClawNetworkAPI } from "./connector"; +import { createOpenClawConnector, OpenClawNetworkAPI, parseMatrixTextMessage, userLoginFromOpenClawConfig } from "./connector"; import { OpenClawGatewayRuntime, type OpenClawGatewayEvent, type OpenClawTransport } from "./openclaw-runtime"; import { OpenClawBridgeRegistry } from "./registry"; @@ -42,7 +42,7 @@ describe("OpenClawBridgeConnector", () => { complete: { userLogin: { metadata: { - accessToken: "token", + gatewayAccessToken: "token", gatewayUrl: "ws://gateway", }, remoteName: "OpenClaw", @@ -53,6 +53,27 @@ describe("OpenClawBridgeConnector", () => { }); }); + it("keeps Beeper Matrix tokens separate from OpenClaw gateway bearer tokens", () => { + expect(userLoginFromOpenClawConfig(createDefaultConfig({ + accessToken: "matrix-token", + dataDir: "/tmp/openclaw", + gatewayAccessToken: "gateway-token", + gatewayUrl: "ws://gateway", + }))).toMatchObject({ + metadata: { + gatewayAccessToken: "gateway-token", + gatewayUrl: "ws://gateway", + }, + }); + expect(userLoginFromOpenClawConfig(createDefaultConfig({ + accessToken: "matrix-token", + dataDir: "/tmp/openclaw", + gatewayUrl: "ws://gateway", + })).metadata).toEqual({ + gatewayUrl: "ws://gateway", + }); + }); + it("loads a network API that registers OpenClaw agents as ghosts", async () => { const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); const runtime = runtimeWith({ @@ -81,6 +102,37 @@ describe("OpenClawBridgeConnector", () => { }); }); + it("honors contact visibility when registering ghosts", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); + registry.upsertAgent({ agentId: "codex", displayName: "Codex", ghostUserId: "@codex:example.com" }); + registry.upsertUser({ displayName: "Alice", ghostUserId: "@alice-ghost:example.com", userId: "alice" }); + const runtime = runtimeWith({ responses: { "agents.list": { agents: [] } } }); + runtime.config.contactVisibility = "agents-and-users"; + const api = new OpenClawNetworkAPI({ + config: runtime.config, + login: login(), + registry, + runtime, + streams: { publish: vi.fn() }, + }); + const registerGhost = vi.fn(); + await api.connect({ bridge: { registerGhost }, queue: vi.fn(), queueRemoteEvent: vi.fn() } as unknown as Parameters[0]); + expect(registerGhost).toHaveBeenCalledWith(expect.objectContaining({ id: "alice", mxid: "@alice-ghost:example.com" })); + + const hidden = runtimeWith({ responses: { "agents.list": { agents: [] } } }); + hidden.config.contactVisibility = "none"; + const hiddenApi = new OpenClawNetworkAPI({ + config: hidden.config, + login: login(), + registry, + runtime: hidden, + streams: { publish: vi.fn() }, + }); + const hiddenRegisterGhost = vi.fn(); + await hiddenApi.connect({ bridge: { registerGhost: hiddenRegisterGhost }, queue: vi.fn(), queueRemoteEvent: vi.fn() } as unknown as Parameters[0]); + expect(hiddenRegisterGhost).not.toHaveBeenCalled(); + }); + 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" }); @@ -91,7 +143,40 @@ describe("OpenClawBridgeConnector", () => { runtime: runtimeWith({ responses: {} }), streams: { publish: vi.fn() }, }); - await expect(api.resolveIdentifier({} as BridgeRequestContext, { + await expect(api.resolveIdentifier({ bridge: { createPortal: vi.fn() } } as unknown as BridgeRequestContext, { + createDM: false, + identifier: "codex", + type: "username", + })).resolves.toEqual({ + ghost: { + displayName: "Codex", + id: "codex", + metadata: { + openclaw: { + agentId: "codex", + displayName: "Codex", + ghostUserId: "@codex:example.com", + }, + }, + mxid: "@codex:example.com", + }, + userId: "@codex:example.com", + }); + + const createPortal = vi.fn(async () => ({ + id: "agent:codex", + metadata: { + openclaw: { + agentId: "codex", + ghostUserId: "@codex:example.com", + sessionKey: "agent:codex", + }, + }, + mxid: "!codex-dm:example.com", + portalKey: { id: "agent:codex", receiver: "login" }, + receiver: "login", + })); + await expect(api.resolveIdentifier({ bridge: { createPortal } } as unknown as BridgeRequestContext, { createDM: true, identifier: "codex", type: "username", @@ -120,9 +205,164 @@ describe("OpenClawBridgeConnector", () => { portalKey: { id: "agent:codex", receiver: "login" }, receiver: "login", roomType: "dm", + mxid: "!codex-dm:example.com", }, userId: "@codex:example.com", }); + expect(createPortal).toHaveBeenCalledWith(login(), { + creationContent: { "m.federate": false }, + id: "agent:codex", + metadata: { + openclaw: { + agentId: "codex", + ghostUserId: "@codex:example.com", + sessionKey: "agent:codex", + }, + }, + name: "Codex", + roomType: "dm", + sender: "codex", + }); + expect(registry.getBindingByRoom("!codex-dm:example.com")).toMatchObject({ + agentId: "codex", + roomId: "!codex-dm:example.com", + sessionKey: "agent:codex", + }); + }); + + 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, + streams: { publish: vi.fn() }, + }); + + await expect(api.listContacts({} as BridgeRequestContext, { query: "code" })).resolves.toEqual({ + contacts: [{ + ghost: { + displayName: "Codex", + id: "codex", + metadata: { + openclaw: { + agentId: "codex", + displayName: "Codex", + ghostUserId: "@openclaw_agent_codex:localhost", + }, + }, + mxid: "@openclaw_agent_codex:localhost", + }, + userId: "@openclaw_agent_codex:localhost", + }], + }); + }); + + it("applies contact visibility to Beeper contact listing", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-contacts-test.json"); + registry.upsertUser({ + displayName: "Alice from Telegram", + ghostUserId: "@openclaw_user_alice:example.com", + source: "telegram", + userId: "alice", + }); + const runtime = runtimeWith({ + responses: { + "agents.list": { + agents: [{ id: "codex", name: "Codex" }], + }, + }, + }); + runtime.config.contactVisibility = "agents-and-users"; + const api = new OpenClawNetworkAPI({ + config: runtime.config, + login: login(), + registry, + runtime, + streams: { publish: vi.fn() }, + }); + + await expect(api.listContacts({} as BridgeRequestContext, { query: "telegram" })).resolves.toEqual({ + contacts: [{ + ghost: { + displayName: "Alice from Telegram", + id: "alice", + metadata: { + openclaw: { + displayName: "Alice from Telegram", + ghostUserId: "@openclaw_user_alice:example.com", + source: "telegram", + userId: "alice", + }, + }, + mxid: "@openclaw_user_alice:example.com", + }, + userId: "@openclaw_user_alice:example.com", + }], + }); + + runtime.config.contactVisibility = "none"; + await expect(api.listContacts({} as BridgeRequestContext, {})).resolves.toEqual({ contacts: [] }); + }); + + it("drops disallowed rooms, users, and bridge-owned 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" }, + "sessions.send": { runId: "run_1", sessionKey: "agent:codex:session_1" }, + }, + }); + runtime.config.allowedRoomIds = ["!allowed:example.com"]; + runtime.config.allowedUserIds = ["@alice:example.com"]; + runtime.config.matrixUserId = "@openclawbot:example.com"; + const api = new OpenClawNetworkAPI({ + config: runtime.config, + login: login(), + registry, + runtime, + streams: { publish: vi.fn() }, + }); + const portal = { + id: "agent:codex", + metadata: { openclaw: { agentId: "codex", ghostUserId: "@codex:example.com", sessionKey: "agent:codex" } }, + mxid: "!blocked:example.com", + portalKey: { id: "agent:codex", receiver: "login" }, + receiver: "login", + }; + + await api.handleMatrixMessage({} as BridgeRequestContext, { + event: { eventId: "$blocked-room" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "hello", + } as MatrixMessage); + await api.handleMatrixMessage({} as BridgeRequestContext, { + event: { eventId: "$blocked-user" }, + portal: { ...portal, mxid: "!allowed:example.com" }, + sender: { userId: "@mallory:example.com" }, + text: "hello", + } as MatrixMessage); + await api.handleMatrixMessage({} as BridgeRequestContext, { + event: { eventId: "$ghost" }, + portal: { ...portal, mxid: "!allowed:example.com" }, + sender: { userId: "@codex:example.com" }, + text: "hello", + } as MatrixMessage); + + expect(runtime.transport.request).not.toHaveBeenCalled(); }); it("dispatches Matrix text and approval reactions to OpenClaw", async () => { @@ -165,8 +405,11 @@ describe("OpenClawBridgeConnector", () => { expect(runtime.transport.request).toHaveBeenCalledWith("sessions.send", { idempotencyKey: "$message", key: "agent:codex:session_1", + matrix: { + sender: "@alice:example.com", + }, message: "hello", - }, { expectFinal: true }); + }, { expectFinal: false }); await expect(api.handleMatrixReaction({} as BridgeRequestContext, { content: { @@ -194,14 +437,591 @@ describe("OpenClawBridgeConnector", () => { }); }); + 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: [], + replyToEventId: "$old", + text: "new text", + }); + expect(parseMatrixTextMessage("/stop", {})).toEqual({ + attachments: [], + command: { args: "", name: "stop" }, + text: "/stop", + }); + 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", + }); + + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); + const runtime = runtimeWith({ + events: [{ event: "run.completed", payload: { runId: "run_2", type: "run.completed" } }], + responses: { + "sessions.create": { key: "agent:codex:session_2" }, + "sessions.send": { runId: "run_2", sessionKey: "agent:codex:session_2" }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime, + streams: { publish: vi.fn() }, + }); + const portal = { + id: "agent:codex", + metadata: { + openclaw: { + agentId: "codex", + ghostUserId: "@codex:example.com", + sessionKey: "agent:codex", + }, + }, + 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.transport.request).toHaveBeenCalledWith("sessions.send", { + attachments: [{ contentType: "image/png", contentUri: "mxc://example/photo", filename: "photo.png", kind: "image" }], + idempotencyKey: "$reply", + key: "agent:codex:session_2", + matrix: { + relation: { + kind: "reply", + replyToEventId: "$old", + }, + sender: "@alice:example.com", + }, + message: "new text", + replyTo: { eventId: "$old", roomId: "!room:example.com" }, + }, { expectFinal: false }); + }); + + 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" }, + "sessions.send": { runId: "run_thread", sessionKey: "agent:codex:session_thread" }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime, + streams: { publish: vi.fn() }, + }); + + 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", + sessionKey: "agent:codex", + }, + }, + mxid: "!room:example.com", + portalKey: { id: "agent:codex", receiver: "login" }, + receiver: "login", + }, + sender: { userId: "@alice:example.com" }, + text: "hello", + } as MatrixMessage); + + expect(runtime.transport.request).toHaveBeenCalledWith("sessions.send", { + idempotencyKey: "$thread-message", + key: "agent:codex:session_thread", + matrix: { + formattedBody: "hello", + mentions: { room: true, userIds: ["@bob:example.com"] }, + relation: { + kind: "thread", + replyToEventId: "$thread-root", + threadRootEventId: "$thread-root", + }, + sender: "@alice:example.com", + threadRootEventId: "$thread-root", + }, + message: "hello", + replyTo: { eventId: "$thread-root", roomId: "!room:example.com" }, + }, { expectFinal: false }); + }); + + it("maps /stop and /abort slash commands to session abort", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); + registry.upsertBinding({ + agentId: "codex", + createdAt: 1, + ghostUserId: "@codex:example.com", + id: "binding", + kind: "session", + lastRunId: "run_1", + owner: "bridge", + roomId: "!room:example.com", + sessionKey: "agent:codex:session_1", + updatedAt: 1, + }); + const runtime = runtimeWith({ + responses: { + "sessions.abort": { ok: true }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime, + streams: { publish: vi.fn() }, + }); + + await expect(api.handleMatrixMessage({} as BridgeRequestContext, { + event: { eventId: "$stop" }, + 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", + }, + sender: { userId: "@alice:example.com" }, + text: "/stop", + } as MatrixMessage)).resolves.toEqual({ pending: false }); + expect(runtime.transport.request).toHaveBeenCalledWith("sessions.abort", { + key: "agent:codex:session_1", + runId: "run_1", + }, undefined); + }); + + it("forwards Matrix edits, redactions, and non-approval reactions as session context", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); + 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" }, + "sessions.send": { runId: "run_edit", sessionKey: "agent:codex:session_1" }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime, + streams: { publish: vi.fn() }, + }); + const portal = { + id: "agent:codex", + metadata: { openclaw: { agentId: "codex", ghostUserId: "@codex:example.com", sessionKey: "agent:codex" } }, + mxid: "!room:example.com", + portalKey: { id: "agent:codex", receiver: "login" }, + receiver: "login", + }; + + await api.handleMatrixEdit({} as BridgeRequestContext, { + content: {}, + event: { eventId: "$edit" }, + existing: [], + portal, + sender: { userId: "@alice:example.com" }, + targetMessage: { id: "$old" }, + text: "corrected", + } as MatrixEdit); + expect(runtime.transport.request).toHaveBeenCalledWith("sessions.send", expect.objectContaining({ + idempotencyKey: "$edit:edit", + matrix: { + relation: { + kind: "edit", + targetEventId: "$old", + }, + sender: "@alice:example.com", + }, + message: "corrected", + replyTo: { eventId: "$old", roomId: "!room:example.com" }, + }), { expectFinal: false }); + + 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.transport.request).toHaveBeenCalledWith("sessions.send", expect.objectContaining({ + idempotencyKey: "$react", + matrix: { + relation: { + key: "👍", + kind: "reaction", + targetEventId: "$old", + }, + sender: "@alice:example.com", + }, + message: "Reacted 👍 to $old", + replyTo: { eventId: "$old", roomId: "!room:example.com" }, + }), { expectFinal: false }); + + await api.handleMatrixRedaction({} as BridgeRequestContext, { + eventId: "$redact", + portal, + targetMessage: { id: "$old" }, + } as MatrixRedaction); + expect(runtime.transport.request).toHaveBeenCalledWith("sessions.send", expect.objectContaining({ + idempotencyKey: "$redact", + matrix: { + relation: { + kind: "redaction", + targetEventId: "$old", + }, + sender: "redaction", + }, + message: "Redacted message $old", + replyTo: { eventId: "$old", roomId: "!room:example.com" }, + }), { expectFinal: false }); + }); + + it("handles bridge slash commands without forwarding them as chat turns", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); + registry.upsertBinding({ + agentId: "codex", + createdAt: 1, + ghostUserId: "@codex:example.com", + id: "binding", + kind: "session", + owner: "bridge", + roomId: "!room:example.com", + sessionKey: "agent:codex:session_1", + updatedAt: 1, + }); + const runtime = runtimeWith({ + responses: { + "chat.history": { messages: [{ content: "hello", id: "m1", role: "user" }] }, + "sessions.create": { key: "agent:codex:new" }, + "sessions.list": { + sessions: [ + { displayName: "Desktop chat", key: "agent:codex:desktop", origin: { surface: "mac-app" } }, + { displayName: "Terminal chat", key: "agent:codex:tui", origin: { surface: "terminal" } }, + ], + }, + }, + }); + runtime.config.importSources = ["dashboard"]; + runtime.config.backfillLimit = 5; + runtime.config.gatewayUrl = "ws://gateway"; + const api = new OpenClawNetworkAPI({ + config: runtime.config, + login: login(), + registry, + runtime, + streams: { publish: vi.fn() }, + }); + const queueRemoteEvent = vi.fn(); + const createPortal = vi.fn(async () => ({ + id: "session:YWdlbnQ6Y29kZXg6bmV3", + mxid: "!new-room:example.com", + portalKey: { id: "session:YWdlbnQ6Y29kZXg6bmV3", receiver: "login" }, + receiver: "login", + })); + const ctx = { bridge: { createPortal }, queueRemoteEvent } as unknown as BridgeRequestContext; + 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.handleMatrixMessage(ctx, { + event: { eventId: "$status" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "/status", + } as MatrixMessage)).resolves.toEqual({ pending: false }); + expect(queueRemoteEvent.mock.calls.at(-1)?.[1].getID()).toBe("$status:openclaw-command"); + await expect(queueRemoteEvent.mock.calls.at(-1)?.[1].convertMessage()).resolves.toMatchObject({ + parts: [{ content: { body: expect.stringContaining("Import sources: dashboard") } }], + }); + + await api.handleMatrixMessage(ctx, { + event: { eventId: "$sessions" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "/sessions", + } as MatrixMessage); + await expect(queueRemoteEvent.mock.calls.at(-1)?.[1].convertMessage()).resolves.toMatchObject({ + parts: [{ content: { body: expect.stringContaining("Desktop chat") } }], + }); + const sessionsBody = (await queueRemoteEvent.mock.calls.at(-1)?.[1].convertMessage()).parts[0].content.body; + expect(sessionsBody).not.toContain("Terminal chat"); + + await api.handleMatrixMessage(ctx, { + event: { eventId: "$backfill" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "/backfill", + } as MatrixMessage); + await expect(queueRemoteEvent.mock.calls.at(-1)?.[1].convertMessage()).resolves.toMatchObject({ + parts: [{ content: { body: "Queued backfill for 1 message." } }], + }); + expect(runtime.transport.request).toHaveBeenCalledWith("chat.history", { + limit: 5, + sessionKey: "agent:codex:session_1", + }); + + await api.handleMatrixMessage(ctx, { + event: { eventId: "$new" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "/new fresh", + } as MatrixMessage); + expect(runtime.transport.request).toHaveBeenCalledWith("sessions.create", { + agentId: "codex", + label: "fresh", + }); + expect(createPortal).toHaveBeenCalledWith(login(), { + creationContent: { "m.federate": false }, + id: "session:YWdlbnQ6Y29kZXg6bmV3", + metadata: { + openclaw: { + agentId: "codex", + ghostUserId: "@codex:example.com", + sessionKey: "agent:codex:new", + }, + }, + name: "fresh", + roomType: "dm", + sender: "codex", + }); + expect(registry.getBindingByRoom("!new-room:example.com")).toMatchObject({ + agentId: "codex", + label: "fresh", + sessionKey: "agent:codex:new", + }); + await expect(queueRemoteEvent.mock.calls.at(-1)?.[1].convertMessage()).resolves.toMatchObject({ + parts: [{ content: { body: "Created a new OpenClaw session room: !new-room:example.com" } }], + }); + expect(runtime.transport.request).not.toHaveBeenCalledWith("sessions.send", expect.anything(), expect.anything()); + }); + + it("creates a new agent session room from slash commands in unbound rooms", 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:new-from-management" }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: runtime.config, + login: login(), + registry, + runtime, + streams: { publish: vi.fn() }, + }); + const queueRemoteEvent = vi.fn(); + const createPortal = vi.fn(async () => ({ + id: "session:YWdlbnQ6Y29kZXg6bmV3LWZyb20tbWFuYWdlbWVudA", + mxid: "!new-management-room:example.com", + portalKey: { id: "session:YWdlbnQ6Y29kZXg6bmV3LWZyb20tbWFuYWdlbWVudA", receiver: "login" }, + receiver: "login", + })); + const ctx = { bridge: { createPortal }, queueRemoteEvent } as unknown as BridgeRequestContext; + const portal = { + id: "management", + mxid: "!management:example.com", + portalKey: { id: "management", receiver: "login" }, + receiver: "login", + }; + + await api.handleMatrixMessage(ctx, { + event: { eventId: "$new-unbound" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "/new codex Deep work", + } as MatrixMessage); + + expect(runtime.transport.request).toHaveBeenCalledWith("sessions.create", { + agentId: "codex", + label: "Deep work", + }); + expect(createPortal).toHaveBeenCalledWith(login(), { + creationContent: { "m.federate": false }, + id: "session:YWdlbnQ6Y29kZXg6bmV3LWZyb20tbWFuYWdlbWVudA", + metadata: { + openclaw: { + agentId: "codex", + ghostUserId: "@codex:example.com", + sessionKey: "agent:codex:new-from-management", + }, + }, + name: "Deep work", + roomType: "dm", + sender: "codex", + }); + expect(registry.getBindingByRoom("!new-management-room:example.com")).toMatchObject({ + agentId: "codex", + label: "Deep work", + sessionKey: "agent:codex:new-from-management", + }); + + await api.handleMatrixMessage(ctx, { + event: { eventId: "$new-missing-agent" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "/new", + } as MatrixMessage); + await expect(queueRemoteEvent.mock.calls.at(-1)?.[1].convertMessage()).resolves.toMatchObject({ + parts: [{ content: { body: expect.stringContaining("Usage: /new [agent-id]") } }], + }); + }); + + it("honors configured approval behavior for reactions and slash commands", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); + const runtime = runtimeWith({ + responses: { + "exec.approval.resolve": { ok: true }, + }, + }); + runtime.config.approvalBehavior = "slash"; + const api = new OpenClawNetworkAPI({ + config: runtime.config, + login: login(), + registry, + runtime, + streams: { publish: vi.fn() }, + }); + 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()); + + const queueRemoteEvent = vi.fn(); + await api.handleMatrixMessage({ queueRemoteEvent } as unknown as BridgeRequestContext, { + event: { eventId: "$approve" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "/approve approval_1", + } as MatrixMessage); + expect(runtime.transport.request).toHaveBeenCalledWith("exec.approval.resolve", { + approvalId: "approval_1", + decision: "approve", + }); + + await api.handleMatrixMessage({ queueRemoteEvent } as unknown 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).toHaveBeenCalledWith("exec.approval.resolve", { + approvalId: "approval_1_reply", + decision: "deny", + }); + + runtime.config.approvalBehavior = "disabled"; + await api.handleMatrixMessage({ queueRemoteEvent } as unknown as BridgeRequestContext, { + event: { eventId: "$approve-disabled" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "/approve approval_2", + } as MatrixMessage); + await expect(queueRemoteEvent.mock.calls.at(-1)?.[1].convertMessage()).resolves.toMatchObject({ + parts: [{ content: { body: "Approval slash commands are disabled for this bridge." } }], + }); + + runtime.config.approvalBehavior = "slash"; + await api.handleMatrixMessage({ queueRemoteEvent } as unknown as BridgeRequestContext, { + event: { eventId: "$approve-missing" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "/approve", + } as MatrixMessage); + await expect(queueRemoteEvent.mock.calls.at(-1)?.[1].convertMessage()).resolves.toMatchObject({ + parts: [{ content: { body: "Usage: /approve or reply to an approval message with /approve" } }], + }); + }); + it("fetches OpenClaw chat history for Pickle backfill", async () => { const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); const runtime = runtimeWith({ responses: { "chat.history": { messages: [ - { content: "hello", id: "m1", messageSeq: 1, role: "user" }, - { content: "hi", id: "m2", messageSeq: 2, role: "assistant" }, + { content: "hello", id: "m1", messageSeq: 1, role: "user", timestamp: "2026-05-16T11:59:00.000Z" }, + { content: "hi", id: "m2", messageSeq: 2, role: "assistant", timestamp: 1_779_000_000 }, ], }, }, @@ -232,6 +1052,10 @@ describe("OpenClawBridgeConnector", () => { expect(response.messages).toHaveLength(2); expect(response.messages.map((message) => message.event.getID())).toEqual(["m1", "m2"]); expect(response.messages.map((message) => message.event.getSender().sender)).toEqual(["login:human", "codex"]); + expect(response.messages.map((message) => message.event.getTimestamp())).toEqual([ + new Date("2026-05-16T11:59:00.000Z"), + new Date(1_779_000_000_000), + ]); expect(runtime.transport.request).toHaveBeenCalledWith("chat.history", { limit: 2, sessionKey: "agent:codex", diff --git a/packages/openclaw/src/connector.ts b/packages/openclaw/src/connector.ts index d90e3a1..735adaf 100644 --- a/packages/openclaw/src/connector.ts +++ b/packages/openclaw/src/connector.ts @@ -6,35 +6,43 @@ import { BridgeRequestContext, BridgeUser, ConnectContext, + type ContactListingNetworkAPI, FetchMessagesParams, FetchMessagesResponse, + type EditHandlingNetworkAPI, IdentifierResolvingNetworkAPI, + type ListContactsParams, + type ListContactsResponse, LoginCreateContext, LoginFlow, LoginProcess, LoginStep, LoadUserLoginContext, + MatrixEdit, MatrixMessage, MatrixMessageResponse, MatrixReaction, + MatrixRedaction, MessageHandlingNetworkAPI, NetworkAPI, NetworkGeneralCapabilities, Portal, ReactionHandlingNetworkAPI, + type RedactionHandlingNetworkAPI, Reaction, ResolveIdentifierParams, ResolveIdentifierResponse, UserLogin, } from "@beeper/pickle-bridge"; -import { buildBackfillImport } from "./backfill"; +import { buildBackfillImport, discoverOneToOneSessions } from "./backfill"; import { parseApprovalResponseContent } from "./approval"; +import { OpenClawBeeperStreamPublisher } from "./beeper-stream"; import { agentPortalSessionKey, OpenClawMatrixBridgeAgent, type OpenClawBridgeStreamPublisher } from "./bridge-agent"; import { createDefaultConfig } from "./config"; -import { createOpenClawHttpTransport, createOpenClawWebSocketTransport, OpenClawGatewayRuntime, type OpenClawTransport } from "./openclaw-runtime"; +import { createOpenClawHttpTransport, createOpenClawWebSocketTransport, OpenClawGatewayRuntime, type OpenClawMatrixMessageMetadata, type OpenClawTransport } from "./openclaw-runtime"; import { OpenClawBridgeRegistry } from "./registry"; -import { agentContactFromOpenClawAgent } from "./rooms"; -import type { OpenClawAgentContact, OpenClawBridgeConfig, OpenClawSessionBinding } from "./types"; +import { agentContactFromOpenClawAgent, serviceBotUserId } from "./rooms"; +import type { OpenClawAgentContact, OpenClawBridgeConfig, OpenClawSessionBinding, OpenClawUserContact } from "./types"; export interface OpenClawConnectorOptions { config?: OpenClawBridgeConfig; @@ -52,12 +60,12 @@ export class OpenClawBridgeConnector implements BridgeConnector OpenClawGatewayRuntime; - #streams: OpenClawBridgeStreamPublisher; + #streams: OpenClawBridgeStreamPublisher | undefined; constructor(options: OpenClawConnectorOptions = {}) { this.config = options.config ?? createDefaultConfig(); this.registry = options.registry ?? new OpenClawBridgeRegistry(); - this.#streams = options.streams ?? { publish: () => undefined }; + this.#streams = options.streams; this.#runtimeFactory = options.runtimeFactory ?? ((login, config) => new OpenClawGatewayRuntime({ @@ -115,8 +123,15 @@ export class OpenClawBridgeConnector implements BridgeConnector { + async init(ctx: BridgeContext): Promise { await this.registry.load(); + const streamOptions: ConstructorParameters[0] = { + client: ctx.client, + config: this.config, + }; + const ownUserId = ctx.bridge.getOwnUserId(); + if (ownUserId) streamOptions.userId = ownUserId; + this.#streams ??= new OpenClawBeeperStreamPublisher(streamOptions); } async start(_ctx: BridgeContext): Promise { @@ -134,7 +149,7 @@ export class OpenClawBridgeConnector implements BridgeConnector undefined }, }); } } @@ -178,13 +193,13 @@ export class OpenClawGatewayLoginProcess implements LoginProcess { async submitUserInput(_ctxOrInput?: BridgeRequestContext | Record, maybeInput?: Record): Promise { const input = maybeInput ?? (_ctxOrInput as Record | undefined) ?? {}; const gatewayUrl = input.gateway_url || this.#defaultConfig.gatewayUrl || "ws://127.0.0.1:29390"; - const accessToken = input.access_token || this.#defaultConfig.accessToken; + const accessToken = input.access_token || this.#defaultConfig.gatewayAccessToken; return { complete: { userLogin: { id: `openclaw:${encodeLoginId(gatewayUrl)}`, metadata: { - ...(accessToken ? { accessToken } : {}), + ...(accessToken ? { gatewayAccessToken: accessToken } : {}), gatewayUrl, }, remoteName: "OpenClaw", @@ -199,8 +214,9 @@ export class OpenClawGatewayLoginProcess implements LoginProcess { } } -export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetworkAPI, MessageHandlingNetworkAPI, ReactionHandlingNetworkAPI, BackfillingNetworkAPI { +export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetworkAPI, ContactListingNetworkAPI, MessageHandlingNetworkAPI, EditHandlingNetworkAPI, ReactionHandlingNetworkAPI, RedactionHandlingNetworkAPI, BackfillingNetworkAPI { readonly #agent: OpenClawMatrixBridgeAgent; + readonly #config: OpenClawBridgeConfig; readonly #login: UserLogin; readonly #registry: OpenClawBridgeRegistry; readonly #runtime: OpenClawGatewayRuntime; @@ -212,6 +228,7 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor runtime: OpenClawGatewayRuntime; streams: OpenClawBridgeStreamPublisher; }) { + this.#config = options.config; this.#login = options.login; this.#registry = options.registry; this.#runtime = options.runtime; @@ -224,21 +241,26 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor async connect(ctx: ConnectContext): Promise { await this.#agent.syncAgentContacts(); - for (const contact of this.#registry.data.agents) { - ctx.bridge.registerGhost({ - displayName: contact.displayName, - id: contact.agentId, - metadata: { openclaw: contact }, - mxid: contact.ghostUserId, - }); + const contactVisibility = this.#runtime.config.contactVisibility ?? "agents"; + if (contactVisibility !== "none") { + for (const contact of this.#registry.data.agents) { + ctx.bridge.registerGhost({ + displayName: contact.displayName, + id: contact.agentId, + metadata: { openclaw: contact }, + mxid: contact.ghostUserId, + }); + } } - for (const contact of this.#registry.data.users) { - ctx.bridge.registerGhost({ - displayName: contact.displayName, - id: contact.userId, - metadata: { openclaw: contact }, - mxid: contact.ghostUserId, - }); + if (contactVisibility === "agents-and-users") { + for (const contact of this.#registry.data.users) { + ctx.bridge.registerGhost({ + displayName: contact.displayName, + id: contact.userId, + metadata: { openclaw: contact }, + mxid: contact.ghostUserId, + }); + } } } @@ -246,44 +268,164 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor await this.#runtime.close(); } - async resolveIdentifier(_ctx: BridgeRequestContext, params: ResolveIdentifierParams): Promise { + async resolveIdentifier(ctx: BridgeRequestContext, params: ResolveIdentifierParams): Promise { const contact = this.#registry.getAgent(params.identifier) ?? agentContactFromOpenClawAgent(this.#runtime.config, { id: params.identifier }); - const portal = params.createDM ? portalForAgent(contact, this.#login.id) : undefined; - return { - ghost: { - displayName: contact.displayName, - id: contact.agentId, - metadata: { openclaw: contact }, - mxid: contact.ghostUserId, - }, - ...(portal ? { portal } : {}), - userId: contact.ghostUserId, - }; + let portal = params.createDM ? portalForAgent(contact, this.#login.id) : undefined; + if (portal && params.createDM) { + const portalOptions: Parameters[1] = { + id: portal.id, + metadata: portal.metadata, + name: contact.displayName, + roomType: "dm", + sender: contact.agentId, + }; + 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 { + await this.#agent.syncAgentContacts(); + const contactVisibility = this.#runtime.config.contactVisibility ?? "agents"; + if (contactVisibility === "none") return { contacts: [] }; + const query = params.query?.trim().toLowerCase(); + const contacts = [ + ...this.#registry.data.agents.map((contact) => ({ + response: contactResponse(contact), + text: `${contact.agentId} ${contact.displayName}`.toLowerCase(), + })), + ...(contactVisibility === "agents-and-users" + ? this.#registry.data.users.map((contact) => ({ + response: userContactResponse(contact), + text: `${contact.userId} ${contact.displayName} ${contact.source ?? ""}`.toLowerCase(), + })) + : []), + ] + .filter((contact) => !query || contact.text.includes(query)) + .slice(0, params.limit ?? 100) + .map((contact) => contact.response); + return { contacts }; } - async handleMatrixMessage(_ctx: BridgeRequestContext, msg: MatrixMessage): Promise { + async handleMatrixMessage(ctx: BridgeRequestContext, msg: MatrixMessage): Promise { + if (!this.isAllowedMatrixIngress(msg.portal.mxid, msg.sender.userId)) return { pending: false }; const binding = bindingFromPortal(msg.portal); if (binding && !this.#registry.getBindingByRoom(msg.portal.mxid ?? "")) this.#registry.upsertBinding(binding); + const parsed = parseMatrixTextMessage(msg.text, msg.content, msg); if (msg.portal.mxid) { + if (parsed.command?.name === "stop" || parsed.command?.name === "abort") { + const currentBinding = this.#registry.getBindingByRoom(msg.portal.mxid) ?? binding; + const abortOptions: { runId?: string; sessionKey?: string } = {}; + if (currentBinding?.lastRunId) abortOptions.runId = currentBinding.lastRunId; + if (currentBinding?.sessionKey) abortOptions.sessionKey = currentBinding.sessionKey; + await this.#runtime.abortSession(abortOptions); + return { pending: false }; + } + if (parsed.command) { + return await this.handleSlashCommand(ctx, parsed.command, binding, msg); + } await this.#agent.handleMatrixText({ + ...(parsed.attachments.length > 0 ? { attachments: parsed.attachments } : {}), eventId: msg.event.eventId, + matrix: matrixMetadataFromParsed(parsed, msg.sender.userId), roomId: msg.portal.mxid, + ...(parsed.replyToEventId ? { replyToEventId: parsed.replyToEventId } : {}), sender: msg.sender.userId, - text: msg.text, + text: parsed.text, + }); + } + 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; + if (msg.portal.mxid) { + await this.#agent.handleMatrixText({ + ...(parsed.attachments.length > 0 ? { attachments: parsed.attachments } : {}), + eventId: `${msg.event.eventId}:edit`, + matrix: matrixMetadataFromParsed(parsed, msg.sender.userId, { + kind: "edit", + targetEventId: 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) return null; - await this.#agent.handleApprovalContent(msg.content, approval.approvalId ?? msg.targetMessage.id); - return { id: msg.event.eventId, metadata: { openclaw: { approval } } }; + 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 reactionKey = matrixReactionKey(msg.content); + if (!reactionKey || !msg.portal.mxid) return null; + this.upsertPortalBinding(msg.portal); + await this.#agent.handleMatrixText({ + eventId: msg.event.eventId, + matrix: { + relation: { + key: reactionKey, + kind: "reaction", + targetEventId: 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 handleMatrixRedaction(_ctx: BridgeRequestContext, msg: MatrixRedaction): Promise { + if (!msg.portal.mxid) return; + if (!this.isAllowedRoom(msg.portal.mxid)) return; + this.upsertPortalBinding(msg.portal); + await this.#agent.handleMatrixText({ + eventId: msg.eventId, + matrix: { + relation: { + kind: "redaction", + ...(msg.targetMessage?.id ? { targetEventId: 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 fetchMessages(_ctx: BridgeRequestContext, params: FetchMessagesParams): Promise { const binding = bindingFromPortal(params.portal); + if (!this.isAllowedRoom(binding?.roomId ?? params.portal.mxid)) return { hasMore: false, messages: [] }; if (!binding) return { hasMore: false, messages: [] }; const importOptions: { limit?: number; roomId: string } = { roomId: binding.roomId }; const limit = params.limit ?? params.count; @@ -318,11 +460,228 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor isFromMe: false, sender: message.sender === "agent" ? binding.agentId : binding.humanGhostUserId ?? `${this.#login.id}:human`, }, - timestamp: new Date(0), + timestamp: message.timestamp ?? new Date(0), }), })), }; } + + async handleSlashCommand( + ctx: BridgeRequestContext, + command: NonNullable, + binding: OpenClawSessionBinding | undefined, + msg: MatrixMessage, + ): Promise { + switch (command.name) { + case "status": + case "settings": + return commandNotice(ctx, this.#login, msg, bridgeStatusText(this.#runtime.config, this.#registry.data.bindings.length)); + case "sessions": { + const options: Parameters[1] = {}; + if (this.#runtime.config.importSources !== undefined) options.importSources = this.#runtime.config.importSources; + const sessions = await discoverOneToOneSessions(this.#runtime, options); + return commandNotice(ctx, this.#login, msg, sessionsSummaryText(sessions)); + } + case "backfill": + case "import": { + const count = await this.backfillCurrentRoom(binding, msg); + return commandNotice(ctx, this.#login, msg, `Queued backfill for ${count} message${count === 1 ? "" : "s"}.`); + } + case "new": { + const request = this.resolveNewSessionCommand(command.args, binding); + if (!request) { + return commandNotice(ctx, this.#login, msg, "Usage: /new [agent-id] [session label]. In an agent DM, /new [session label] is enough."); + } + const session = await this.#runtime.createSession({ agentId: request.agentId, label: request.label }); + const portalOptions: Parameters[1] = { + id: portalIdForSession(session.key), + metadata: { + openclaw: stripUndefined({ + agentId: request.agentId, + ghostUserId: request.ghostUserId, + sessionKey: session.key, + }), + }, + name: request.label, + roomType: "dm", + sender: request.agentId, + }; + const creationContent = openClawPortalCreationContent(this.#runtime.config); + if (creationContent) portalOptions.creationContent = creationContent; + const portal = await ctx.bridge.createPortal(this.#login, portalOptions); + if (portal.mxid) { + this.#registry.upsertBinding({ + agentId: request.agentId, + createdAt: Date.now(), + ghostUserId: request.ghostUserId, + id: Buffer.from(portal.mxid).toString("base64url"), + kind: "session", + label: request.label, + owner: "bridge", + roomId: portal.mxid, + sessionKey: session.key, + updatedAt: Date.now(), + }); + } + await this.#registry.save(); + return commandNotice(ctx, this.#login, msg, portal.mxid + ? `Created a new OpenClaw session room: ${portal.mxid}` + : `Created a new OpenClaw session: ${session.key}`); + } + case "approve": + case "deny": { + if (!approvalSlashEnabled(this.#runtime.config)) { + return commandNotice(ctx, this.#login, msg, "Approval slash commands are disabled for this bridge."); + } + const approvalId = command.args.trim() || approvalIdFromMatrixReply(msg); + if (!approvalId) return commandNotice(ctx, this.#login, msg, `Usage: /${command.name} or reply to an approval message with /${command.name}`); + await this.#agent.handleApprovalContent({ + approvalId, + approved: command.name === "approve", + approvedAlways: false, + type: "tool-approval-response", + }, approvalId); + return commandNotice(ctx, this.#login, msg, `${command.name === "approve" ? "Approved" : "Denied"} ${approvalId}.`); + } + case "agent": + return commandNotice(ctx, this.#login, msg, binding ? `Agent: ${binding.agentId}` : "This room is not bound to an OpenClaw agent yet."); + default: + return commandNotice(ctx, this.#login, msg, `Unknown OpenClaw command: /${command.name}`); + } + } + + async backfillCurrentRoom(binding: OpenClawSessionBinding | undefined, msg: MatrixMessage): Promise { + const roomId = msg.portal.mxid; + if (!binding || !roomId) return 0; + const importOptions: { limit?: number; roomId: string } = { roomId }; + if (this.#runtime.config.backfillLimit !== undefined) importOptions.limit = this.#runtime.config.backfillLimit; + const imported = await buildBackfillImport(this.#runtime, this.#runtime.config, { + agentId: binding.agentId, + label: binding.label ?? binding.sessionKey, + session: { key: binding.sessionKey }, + sessionKey: binding.sessionKey, + source: binding.owner === "imported" ? "unknown" : "channel", + }, importOptions); + if (imported.human) this.#registry.upsertUser(imported.human); + this.#registry.upsertBinding(imported.binding); + await this.#registry.save(); + return imported.messages.length; + } + + isAllowedMatrixIngress(roomId: string | undefined, sender: string | undefined): boolean { + if (!this.isAllowedRoom(roomId)) return false; + if (!this.isAllowedUser(sender)) return false; + if (sender && this.isBridgeOwnedSender(sender)) return false; + return true; + } + + isAllowedRoom(roomId: string | undefined): boolean { + return !this.#config.allowedRoomIds?.length || Boolean(roomId && this.#config.allowedRoomIds.includes(roomId)); + } + + isAllowedUser(sender: string | undefined): boolean { + return !this.#config.allowedUserIds?.length || Boolean(sender && this.#config.allowedUserIds.includes(sender)); + } + + isBridgeOwnedSender(sender: string): boolean { + return sender === this.#config.matrixUserId + || sender === serviceBotUserId(this.#config) + || this.#registry.data.agents.some((contact) => contact.ghostUserId === sender) + || this.#registry.data.users.some((contact) => contact.ghostUserId === sender); + } + + private upsertPortalBinding(portal: Portal): void { + const binding = bindingFromPortal(portal); + if (binding && !this.#registry.getBindingByRoom(portal.mxid ?? "")) this.#registry.upsertBinding(binding); + } + + 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 || binding.label || "Beeper", + }; + } + const [agentId, ...labelParts] = trimmed.split(/\s+/u).filter(Boolean); + if (!agentId) return undefined; + const contact = this.#registry.getAgent(agentId) ?? agentContactFromOpenClawAgent(this.#runtime.config, { id: agentId }); + return { + agentId: contact.agentId, + ghostUserId: contact.ghostUserId, + label: labelParts.join(" ") || "Beeper", + }; + } +} + +function commandNotice(ctx: BridgeRequestContext, login: UserLogin, msg: MatrixMessage, text: string): MatrixMessageResponse { + ctx.queueRemoteEvent(login, createRemoteMessage({ + convert: () => ({ + parts: [{ content: { body: text, msgtype: "m.notice" }, id: "body", type: "m.text" }], + }), + data: { text }, + id: `${msg.event.eventId}:openclaw-command`, + portalKey: msg.portal.portalKey, + sender: { + isFromMe: true, + sender: "openclawbot", + }, + timestamp: new Date(), + })); + return { pending: false }; +} + +function bridgeStatusText(config: OpenClawBridgeConfig, boundRooms: number): string { + return [ + "OpenClaw Beeper bridge", + `Gateway: ${config.gatewayUrl ?? "not configured"}`, + `Import sources: ${(config.importSources ?? []).join(", ") || "none"}`, + `Approvals: ${config.approvalBehavior ?? "native"}`, + `Stream finalization: ${config.streamFinalization ?? "replace"}`, + `Backfill limit: ${config.backfillLimit ?? "default"}`, + `Bound rooms: ${boundRooms}`, + ].join("\n"); +} + +function approvalReactionsEnabled(config: OpenClawBridgeConfig): boolean { + return config.approvalBehavior === undefined || config.approvalBehavior === "native" || config.approvalBehavior === "reactions"; +} + +function approvalSlashEnabled(config: OpenClawBridgeConfig): boolean { + return config.approvalBehavior === undefined || config.approvalBehavior === "native" || config.approvalBehavior === "slash"; +} + +function openClawPortalCreationContent(config: OpenClawBridgeConfig): Record | undefined { + return config.nonFederatedRooms ? { "m.federate": false } : undefined; +} + +function sessionsSummaryText(sessions: Awaited>): string { + if (sessions.length === 0) return "No importable OpenClaw sessions found for the enabled import sources."; + return sessions.slice(0, 20).map((session) => `${session.label} (${session.source})`).join("\n"); +} + +function matrixMetadataFromParsed( + parsed: ParsedMatrixTextMessage, + sender: string, + relationPatch: NonNullable = {}, +): OpenClawMatrixMessageMetadata { + const metadata: OpenClawMatrixMessageMetadata = { sender }; + 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 || Object.keys(relationPatch).length > 0) { + metadata.relation = { + kind: parsed.threadRootEventId ? "thread" : "reply", + ...(parsed.replyToEventId ? { replyToEventId: parsed.replyToEventId } : {}), + ...(parsed.threadRootEventId ? { threadRootEventId: parsed.threadRootEventId } : {}), + ...relationPatch, + }; + } + return metadata; } function portalForAgent(contact: OpenClawAgentContact, receiver: string): Portal { @@ -342,6 +701,35 @@ function portalForAgent(contact: OpenClawAgentContact, receiver: string): Portal }; } +function portalIdForSession(sessionKey: string): string { + return `session:${Buffer.from(sessionKey).toString("base64url")}`; +} + +function contactResponse(contact: OpenClawAgentContact, portal?: Portal): ResolveIdentifierResponse { + return { + ghost: { + displayName: contact.displayName, + id: contact.agentId, + metadata: { openclaw: contact }, + mxid: contact.ghostUserId, + }, + ...(portal ? { portal } : {}), + userId: contact.ghostUserId, + }; +} + +function userContactResponse(contact: OpenClawUserContact): ResolveIdentifierResponse { + return { + ghost: { + displayName: contact.displayName, + id: contact.userId, + metadata: { openclaw: contact }, + mxid: contact.ghostUserId, + }, + userId: contact.ghostUserId, + }; +} + function bindingFromPortal(portal: Portal): OpenClawSessionBinding | undefined { const metadata = recordValue(portal.metadata)?.openclaw; const openclaw = recordValue(metadata); @@ -369,7 +757,7 @@ function transportFromLogin(login: UserLogin, config: OpenClawBridgeConfig): Ope const gatewayUrl = stringValue(metadata?.gatewayUrl) ?? config.gatewayUrl; if (!gatewayUrl) throw new Error("OpenClaw gateway URL is not configured"); const options: Parameters[0] = { url: gatewayUrl }; - const accessToken = stringValue(metadata?.accessToken) ?? config.accessToken; + const accessToken = stringValue(metadata?.gatewayAccessToken) ?? stringValue(metadata?.accessToken) ?? config.gatewayAccessToken; if (accessToken !== undefined) options.accessToken = accessToken; if (gatewayUrl.startsWith("ws://") || gatewayUrl.startsWith("wss://")) { return createOpenClawWebSocketTransport(options); @@ -383,7 +771,7 @@ export function userLoginFromOpenClawConfig(config: OpenClawBridgeConfig): UserL return { id: `openclaw:${encodeLoginId(gatewayUrl)}`, metadata: { - ...(config.accessToken ? { accessToken: config.accessToken } : {}), + ...(config.gatewayAccessToken ? { gatewayAccessToken: config.gatewayAccessToken } : {}), gatewayUrl, }, remoteName: "OpenClaw", @@ -410,3 +798,138 @@ function recordValue(value: unknown): Record | undefined { 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 interface ParsedMatrixTextMessage { + attachments: unknown[]; + command?: { + args: string; + name: string; + }; + formattedBody?: string; + mentions?: { room?: boolean; userIds?: string[] }; + replyToEventId?: string; + text: string; + threadRootEventId?: string; +} + +export function parseMatrixTextMessage(text: string, content: unknown, msg?: Pick): ParsedMatrixTextMessage { + const relates = recordValue(recordValue(content)?.["m.relates_to"]); + 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 body = stripMatrixReplyFallback(text); + const command = parseSlashCommand(body); + const formattedBody = stringValue(recordValue(content)?.formatted_body) ?? stringValue(msg?.event.html); + const mentions = normalizeMentions(recordValue(content)?.["m.mentions"] ?? msg?.event.mentions); + const attachments = normalizeMatrixAttachments(msg?.attachments ?? msg?.event.attachments ?? [], content); + return { + attachments, + ...(command ? { command } : {}), + ...(formattedBody ? { formattedBody } : {}), + ...(mentions ? { mentions } : {}), + ...(replyToEventId ? { replyToEventId } : {}), + text: body, + ...(threadRootEventId ? { threadRootEventId } : {}), + }; +} + +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 stripMatrixReplyFallback(text: string): string { + const lines = text.replace(/\r\n?/gu, "\n").split("\n"); + let index = 0; + while (index < lines.length && lines[index]?.startsWith(">")) index += 1; + if (index > 0 && lines[index] === "") index += 1; + return lines.slice(index).join("\n").trim(); +} + +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 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/index.ts b/packages/openclaw/src/index.ts index 8505c46..6154d27 100644 --- a/packages/openclaw/src/index.ts +++ b/packages/openclaw/src/index.ts @@ -1,16 +1,21 @@ export * from "./approval"; export * from "./appservice"; export * from "./backfill"; +export * from "./beeper-stream"; export * from "./beeper-setup"; export * from "./bridge-agent"; export * from "./cli"; export * from "./config"; export * from "./connector"; export * from "./openclaw-event-map"; +export * from "./openclaw-extension"; export * from "./openclaw-runtime"; +export * from "./plugin-entry"; export * from "./protocol-coverage"; export * from "./registry"; export * from "./registration"; export * from "./rooms"; +export * from "./setup"; +export * from "./setup-entry"; export * from "./stream-map"; export * from "./types"; diff --git a/packages/openclaw/src/integration.test.ts b/packages/openclaw/src/integration.test.ts new file mode 100644 index 0000000..f9c192b --- /dev/null +++ b/packages/openclaw/src/integration.test.ts @@ -0,0 +1,266 @@ +import type { MatrixClient, MatrixClientEvent, MatrixMessageEvent, MatrixSubscription } from "@beeper/pickle"; +import { RuntimeBridge } from "@beeper/pickle-bridge"; +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 { OpenClawGatewayRuntime, type OpenClawGatewayEvent, type OpenClawTransport } from "./openclaw-runtime"; +import { OpenClawBridgeRegistry } from "./registry"; + +describe("OpenClaw bridge integration", () => { + it("dispatches a Matrix DM through Pickle into OpenClaw and publishes native stream chunks", async () => { + const dir = await mkdtemp(resolve(tmpdir(), "pickle-openclaw-integration-")); + const config = createDefaultConfig({ + dataDir: dir, + gatewayUrl: "ws://gateway", + homeserver: "https://matrix.example", + matrixUserId: "@openclawbot:example", + }); + const transport = fakeTransport({ + events: [ + { event: "assistant.delta", payload: { data: { delta: "hi" }, runId: "run_1", type: "assistant.delta" } }, + { event: "run.completed", payload: { runId: "run_1", type: "run.completed" } }, + ], + responses: { + "agents.list": { agents: [{ id: "codex", name: "Codex" }] }, + "sessions.create": { key: "session_1" }, + "sessions.send": { runId: "run_1", sessionKey: "session_1" }, + }, + }); + const streams = { publish: vi.fn(async () => {}) }; + const registry = new OpenClawBridgeRegistry(resolve(dir, "registry.json")); + const connector = createOpenClawConnector({ + config, + registry, + runtimeFactory: () => new OpenClawGatewayRuntime({ config, transport }), + streams, + }); + 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: "@openclaw_agent_codex:matrix.example", + sessionKey: "agent:codex", + }, + }, + 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(transport.request).toHaveBeenCalledWith("sessions.send", { + idempotencyKey: "$hello", + key: "session_1", + matrix: { sender: "@alice:example" }, + message: "hello", + }, { expectFinal: false }); + expect(streams.publish).toHaveBeenCalledWith( + expect.objectContaining({ + roomId: "!codex:example", + sessionKey: "session_1", + }), + expect.arrayContaining([expect.objectContaining({ type: "TEXT_MESSAGE_CONTENT" })]), + ); + expect(registry.getBindingByRoom("!codex:example")).toMatchObject({ + lastMatrixEventId: "$hello", + lastRunId: "run_1", + sessionKey: "session_1", + }); + }); + + it("dispatches approval reactions through Pickle into OpenClaw approval resolution", async () => { + const dir = await mkdtemp(resolve(tmpdir(), "pickle-openclaw-approval-integration-")); + const config = createDefaultConfig({ + dataDir: dir, + gatewayUrl: "ws://gateway", + homeserver: "https://matrix.example", + matrixUserId: "@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 connector = createOpenClawConnector({ + config, + registry, + runtimeFactory: () => new OpenClawGatewayRuntime({ config, transport }), + streams: { publish: vi.fn(async () => {}) }, + }); + 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: "@openclaw_agent_codex:matrix.example", + sessionKey: "agent:codex", + }, + }, + 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).toHaveBeenCalledWith("exec.approval.resolve", { + approvalId: "approval_1", + decision: "approve", + }); + }); +}); + +function fakeTransport(options: { + events?: OpenClawGatewayEvent[]; + responses: Record; +}): OpenClawTransport & { request: ReturnType } { + 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]), + }; +} + +function matrixConfig() { + return { + account: { + accessToken: "matrix-token", + deviceId: "DEVICE", + homeserver: "https://matrix.example", + userId: "@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 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 createFakeMatrixClient(): MatrixClient & { subscription: MatrixSubscription & { stop: ReturnType } } { + const subscription = { + catchUp: vi.fn(async () => {}), + done: Promise.resolve(), + stop: vi.fn(async () => {}), + }; + return { + accountData: {} as MatrixClient["accountData"], + appservice: { + batchSend: vi.fn(async () => ({ eventIds: ["$backfilled"], 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: "@openclawbot:example", id: "openclaw" })), + sendMessage: vi.fn(async () => ({ eventId: "$sent", raw: {}, roomId: "!room:example" })), + }, + beeper: {} as MatrixClient["beeper"], + boot: vi.fn(async () => ({ deviceId: "DEVICE", userId: "@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: "@openclawbot:example" })), + }; +} diff --git a/packages/openclaw/src/openclaw-event-map.test.ts b/packages/openclaw/src/openclaw-event-map.test.ts index 8a25a09..8c77363 100644 --- a/packages/openclaw/src/openclaw-event-map.test.ts +++ b/packages/openclaw/src/openclaw-event-map.test.ts @@ -12,32 +12,44 @@ describe("OpenClaw event to Beeper stream mapping", () => { type: "run.started", })).toEqual([ { - messageId: "turn_oc", - messageMetadata: { + metadata: { agent_id: "codex", run_id: "run_1", session_key: "agent:codex:main", turn_id: "turn_oc", }, - type: "start", + runId: "turn_oc", + threadId: "turn_oc", + type: "RUN_STARTED", + }, + { + messageId: "turn_oc", + role: "assistant", + type: "TEXT_MESSAGE_START", }, ]); expect(mapOpenClawEventToBeeperChunks(state, { data: { delta: "Hello" }, type: "assistant.delta" })).toEqual([ - { id: "text_turn_oc", type: "text-start" }, - { delta: "Hello", id: "text_turn_oc", type: "text-delta" }, + { delta: "Hello", messageId: "turn_oc", type: "TEXT_MESSAGE_CONTENT" }, ]); expect(mapOpenClawEventToBeeperChunks(state, { data: { delta: " thinking" }, type: "thinking.delta" })).toEqual([ - { id: "reasoning_turn_oc", type: "reasoning-start" }, - { delta: " thinking", id: "reasoning_turn_oc", type: "reasoning-delta" }, + { messageId: "turn_oc", type: "REASONING_START" }, + { messageId: "turn_oc", role: "reasoning", type: "REASONING_MESSAGE_START" }, + { delta: " thinking", messageId: "turn_oc", type: "REASONING_MESSAGE_CONTENT" }, ]); expect(mapOpenClawEventToBeeperChunks(state, { runId: "run_1", type: "run.completed" })).toEqual([ - { id: "reasoning_turn_oc", type: "reasoning-end" }, - { id: "text_turn_oc", type: "text-end" }, + { messageId: "turn_oc", type: "REASONING_MESSAGE_END" }, + { messageId: "turn_oc", type: "REASONING_END" }, + { + messageId: "turn_oc", + type: "TEXT_MESSAGE_END", + }, { finishReason: "stop", - messageMetadata: { finish_reason: "stop", run_id: "run_1", turn_id: "turn_oc" }, - type: "finish", + metadata: { finish_reason: "stop", run_id: "run_1", turn_id: "turn_oc" }, + runId: "turn_oc", + threadId: "turn_oc", + type: "RUN_FINISHED", }, ]); }); @@ -50,11 +62,27 @@ describe("OpenClaw event to Beeper stream mapping", () => { type: "tool.call.started", })).toEqual([ { - dynamic: true, + parentMessageId: "call_1", + state: "awaiting-input", + toolCallId: "call_1", + toolCallName: "shell", + toolName: "shell", + type: "TOOL_CALL_START", + }, + { + args: "{\"cmd\":\"pnpm test\"}", + delta: "{\"cmd\":\"pnpm test\"}", + state: "input-streaming", + toolCallId: "call_1", + type: "TOOL_CALL_ARGS", + }, + { input: { cmd: "pnpm test" }, + state: "input-complete", toolCallId: "call_1", + toolCallName: "shell", toolName: "shell", - type: "tool-input-available", + type: "TOOL_CALL_END", }, ]); @@ -63,11 +91,11 @@ describe("OpenClaw event to Beeper stream mapping", () => { type: "tool.call.delta", })).toEqual([ { - dynamic: true, - inputTextDelta: "{\"cmd\"", + args: "{\"cmd\"", + delta: "{\"cmd\"", + state: "input-streaming", toolCallId: "call_2", - toolName: "edit", - type: "tool-input-delta", + type: "TOOL_CALL_ARGS", }, ]); @@ -76,12 +104,14 @@ describe("OpenClaw event to Beeper stream mapping", () => { type: "tool.call.completed", })).toEqual([ { - dynamic: true, - output: "ok", + content: "ok", + messageId: "call_1", preliminary: true, + role: "tool", + state: "streaming", toolCallId: "call_1", toolName: "shell", - type: "tool-output-available", + type: "TOOL_CALL_RESULT", }, ]); @@ -90,11 +120,13 @@ describe("OpenClaw event to Beeper stream mapping", () => { type: "tool.call.failed", })).toEqual([ { - dynamic: true, - errorText: "{\"message\":\"denied\"}", + content: "{\"message\":\"denied\"}", + messageId: "call_3", + role: "tool", + state: "error", toolCallId: "call_3", toolName: "write", - type: "tool-output-error", + type: "TOOL_CALL_RESULT", }, ]); }); @@ -112,11 +144,18 @@ describe("OpenClaw event to Beeper stream mapping", () => { type: "approval.requested", })).toEqual([ { - approvalId: "approval_1", - message: "Allow shell?", - toolCallId: "call_1", - toolName: "shell", - type: "tool-approval-request", + name: "approval-requested", + type: "CUSTOM", + value: { + approval: { + id: "approval_1", + needsApproval: true, + }, + approvalMessageId: "approval_1", + message: "Allow shell?", + toolCallId: "call_1", + toolName: "shell", + }, }, ]); expect(state.toolCallIdToApprovalId.call_1).toBe("approval_1"); @@ -130,12 +169,33 @@ describe("OpenClaw event to Beeper stream mapping", () => { type: "approval.resolved", })).toEqual([ { - approvalId: "approval_1", - approved: true, - approvedAlways: false, - toolCallId: "call_1", - type: "tool-approval-response", + name: "approval-responded", + type: "CUSTOM", + value: { + approval: { + always: false, + approved: true, + id: "approval_1", + }, + toolCallId: "call_1", + }, + }, + ]); + }); + + it("starts text messages when upstream sends deltas before run.started", () => { + const state = createOpenClawStreamState("turn_delta_only"); + + expect(mapOpenClawEventToBeeperChunks(state, { data: { delta: "Hello" }, type: "assistant.delta" })).toEqual([ + { + messageId: "turn_delta_only", + role: "assistant", + type: "TEXT_MESSAGE_START", }, + { delta: "Hello", messageId: "turn_delta_only", type: "TEXT_MESSAGE_CONTENT" }, + ]); + expect(mapOpenClawEventToBeeperChunks(state, { data: { delta: " again" }, type: "assistant.delta" })).toEqual([ + { delta: " again", messageId: "turn_delta_only", type: "TEXT_MESSAGE_CONTENT" }, ]); }); @@ -147,32 +207,53 @@ describe("OpenClaw event to Beeper stream mapping", () => { payload: { phase: "started", runId: "run_1", sessionKey: "session_1" }, })).toEqual([ { - messageId: "turn_gateway", - messageMetadata: { + metadata: { run_id: "run_1", session_key: "session_1", turn_id: "turn_gateway", }, - type: "start", + runId: "turn_gateway", + threadId: "turn_gateway", + type: "RUN_STARTED", + }, + { + messageId: "turn_gateway", + role: "assistant", + type: "TEXT_MESSAGE_START", }, ]); expect(mapOpenClawEventToBeeperChunks(state, { event: "session.message", payload: { deltaText: "Hello", role: "assistant", runId: "run_1" }, })).toEqual([ - { id: "text_turn_gateway", type: "text-start" }, - { delta: "Hello", id: "text_turn_gateway", type: "text-delta" }, + { delta: "Hello", messageId: "turn_gateway", type: "TEXT_MESSAGE_CONTENT" }, ]); expect(mapOpenClawEventToBeeperChunks(state, { event: "session.tool", payload: { args: { cmd: "pwd" }, phase: "started", tool: "exec", toolCallId: "tool_1" }, })).toEqual([ { - dynamic: true, + parentMessageId: "tool_1", + state: "awaiting-input", + toolCallId: "tool_1", + toolCallName: "exec", + toolName: "exec", + type: "TOOL_CALL_START", + }, + { + args: "{\"cmd\":\"pwd\"}", + delta: "{\"cmd\":\"pwd\"}", + state: "input-streaming", + toolCallId: "tool_1", + type: "TOOL_CALL_ARGS", + }, + { input: { cmd: "pwd" }, + state: "input-complete", toolCallId: "tool_1", + toolCallName: "exec", toolName: "exec", - type: "tool-input-available", + type: "TOOL_CALL_END", }, ]); expect(mapOpenClawEventToBeeperChunks(state, { @@ -180,11 +261,35 @@ describe("OpenClaw event to Beeper stream mapping", () => { payload: { id: "approval_1", reason: "Run command?", tool: "exec", toolCallId: "tool_1" }, })).toEqual([ { - approvalId: "approval_1", - message: "Run command?", - toolCallId: "tool_1", - toolName: "exec", - type: "tool-approval-request", + name: "approval-requested", + type: "CUSTOM", + value: { + approval: { + id: "approval_1", + needsApproval: true, + }, + approvalMessageId: "approval_1", + message: "Run command?", + toolCallId: "tool_1", + toolName: "exec", + }, + }, + ]); + }); + + it("marks cancelled OpenClaw runs as abort terminal stream events", () => { + const state = createOpenClawStreamState("turn_cancel"); + + expect(mapOpenClawEventToBeeperChunks(state, { + event: "session.operation", + payload: { phase: "cancelled", reason: "user stopped it", runId: "run_cancel" }, + })).toEqual([ + { + message: "user stopped it", + reason: "user stopped it", + runId: "turn_cancel", + terminalType: "abort", + type: "RUN_ERROR", }, ]); }); diff --git a/packages/openclaw/src/openclaw-event-map.ts b/packages/openclaw/src/openclaw-event-map.ts index 9c8af76..baac172 100644 --- a/packages/openclaw/src/openclaw-event-map.ts +++ b/packages/openclaw/src/openclaw-event-map.ts @@ -1,14 +1,16 @@ import { closeOpenMessageParts, createStreamRunState, - finishChunk, + finishRunEvents, mapOpenClawApprovalRequest, mapOpenClawApprovalResponse, mapOpenClawMessageDelta, mapOpenClawToolInput, + mapOpenClawToolInputDelta, mapOpenClawToolOutput, - startChunk, - type BeeperUIMessageChunk, + startRunEvents, + AGUIEventType, + type AGUIEvent, type StreamRunState, } from "./stream-map"; @@ -24,7 +26,7 @@ export function createOpenClawStreamState(turnId: string): StreamRunState { export function mapOpenClawEventToBeeperChunks( state: StreamRunState, event: unknown -): BeeperUIMessageChunk[] { +): AGUIEvent[] { const record = recordValue(event); const rawType = stringValue(record?.type) ?? stringValue(record?.event); const type = normalizeOpenClawEventType(rawType, record); @@ -36,7 +38,7 @@ export function mapOpenClawEventToBeeperChunks( case "run.created": case "run.queued": case "run.started": - return [startChunk(state, metadata)]; + return startRunEvents(state, metadata); case "assistant.delta": { const delta = stringValue(data.delta) ?? stringValue(data.deltaText) ?? stringValue(data.text) ?? stringValue(data.content); return delta ? mapOpenClawMessageDelta(state, { kind: "text", value: delta }) : []; @@ -50,35 +52,44 @@ export function mapOpenClawEventToBeeperChunks( return delta ? mapOpenClawMessageDelta(state, { kind: "thinking", value: delta }) : []; } case "tool.call.started": - return [mapOpenClawToolInput(toolInput(data))]; + return mapOpenClawToolInput(toolInput(data)); case "tool.call.delta": { const inputTextDelta = stringValue(data.delta) ?? stringValue(data.inputTextDelta); const input = inputTextDelta ? undefined : data.input ?? data.args ?? parseMaybeJSONValue(data.arguments); - return [stripUndefined({ - dynamic: true, - input, - inputTextDelta, + const delta: Parameters[0] = { toolCallId: toolCallId(data), - toolName: toolName(data), - type: inputTextDelta ? "tool-input-delta" : "tool-input-available", - })]; + }; + const name = toolName(data); + if (input !== undefined) delta.input = input; + if (inputTextDelta !== undefined) delta.inputTextDelta = inputTextDelta; + if (name !== undefined) delta.toolName = name; + return mapOpenClawToolInputDelta(delta); } case "tool.call.completed": - return [mapOpenClawToolOutput(toolOutput(data))]; + return mapOpenClawToolOutput(toolOutput(data)); case "tool.call.failed": - return [mapOpenClawToolOutput({ ...toolOutput(data), error: data.error ?? data.message ?? data.output })]; + return mapOpenClawToolOutput({ ...toolOutput(data), error: data.error ?? data.message ?? data.output }); case "approval.requested": return [mapOpenClawApprovalRequest(state, approvalRequest(data))]; case "approval.resolved": return [mapOpenClawApprovalResponse(approvalResponse(data))]; case "run.completed": - return [...closeOpenMessageParts(state), finishChunk(state, "stop", metadata)]; + return finishRunEvents(state, "stop", metadata); case "run.failed": - return [...closeOpenMessageParts(state), { errorText: errorText(data.error ?? data.message ?? data), type: "error" }, finishChunk(state, "error", metadata)]; + return [...closeOpenMessageParts(state), { message: errorText(data.error ?? data.message ?? data), runId: state.turnId, type: AGUIEventType.RUN_ERROR }]; case "run.cancelled": - return [...closeOpenMessageParts(state), { reason: stringValue(data.reason), type: "abort" }, finishChunk(state, "cancelled", metadata)]; + return [ + ...closeOpenMessageParts(state), + { + message: stringValue(data.reason) ?? "OpenClaw run cancelled.", + reason: stringValue(data.reason), + runId: state.turnId, + terminalType: "abort", + type: AGUIEventType.RUN_ERROR, + } as AGUIEvent, + ]; case "run.timed_out": - return [...closeOpenMessageParts(state), { errorText: "OpenClaw run timed out.", type: "error" }, finishChunk(state, "timeout", metadata)]; + return [...closeOpenMessageParts(state), { message: "OpenClaw run timed out.", runId: state.turnId, type: AGUIEventType.RUN_ERROR }]; default: return []; } diff --git a/packages/openclaw/src/openclaw-extension.test.ts b/packages/openclaw/src/openclaw-extension.test.ts new file mode 100644 index 0000000..c27f17c --- /dev/null +++ b/packages/openclaw/src/openclaw-extension.test.ts @@ -0,0 +1,110 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import extension, { openClawBeeperPlugin } from "./openclaw-extension"; + +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(registered).toEqual([ + expect.objectContaining({ + capabilities: expect.objectContaining({ + reactions: true, + threads: true, + }), + id: "beeper", + setup: expect.any(Object), + setupWizard: expect.any(Object), + }), + 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?: { id?: string }; + install?: { clawhubSpec?: string; defaultChoice?: string; npmSpec?: string }; + compat?: { pluginApi?: string }; + }; + peerDependencies?: { openclaw?: string }; + version?: string; + }; + const manifest = JSON.parse(await readFile(resolve("openclaw.plugin.json"), "utf8")) as { + id?: string; + channels?: string[]; + configSchema?: { + properties?: Record; + }; + uiHints?: Record; + }; + + expect(packageJson.files).toContain("openclaw.plugin.json"); + 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?.install?.defaultChoice).toBe("clawhub"); + expect(packageJson.openclaw?.install?.clawhubSpec).toBe( + `clawhub:@beeper/pickle-openclaw@${packageJson.version}`, + ); + expect(packageJson.openclaw?.install?.npmSpec).toBe( + `@beeper/pickle-openclaw@${packageJson.version}`, + ); + expect(packageJson.openclaw?.compat?.pluginApi).toBe(">=2026.5.24"); + expect(packageJson.peerDependencies?.openclaw).toBe(">=2026.5.24"); + expect(manifest).toEqual(expect.objectContaining({ id: "beeper", channels: ["beeper"] })); + expect(manifest.uiHints).toMatchObject({ + accessToken: { sensitive: true }, + asToken: { sensitive: true }, + bridgeManagerToken: { sensitive: true }, + gatewayAccessToken: { sensitive: true }, + hsToken: { sensitive: true }, + }); + expect(Object.keys(manifest.configSchema?.properties ?? {}).sort()).toEqual([ + "accessToken", + "allowedRoomIds", + "allowedUserIds", + "approvalBehavior", + "asToken", + "backfillLimit", + "baseDomain", + "beeperEnv", + "bridgeManagerPostState", + "bridgeManagerToken", + "contactVisibility", + "dataDir", + "enabled", + "gatewayAccessToken", + "gatewayUrl", + "homeserver", + "homeserverDomain", + "hsToken", + "importSources", + "matrixDeviceId", + "matrixUserId", + "nonFederatedRooms", + "registrationUrl", + "streamFinalization", + ]); + }); +}); diff --git a/packages/openclaw/src/openclaw-extension.ts b/packages/openclaw/src/openclaw-extension.ts new file mode 100644 index 0000000..97ec6f0 --- /dev/null +++ b/packages/openclaw/src/openclaw-extension.ts @@ -0,0 +1,21 @@ +import { beeperChannelPlugin } from "./setup"; + +export interface OpenClawPluginApi { + registerChannel?: (registration: { plugin: unknown }) => void; + channels?: { + register?: (plugin: unknown) => void; + }; +} + +export const openClawBeeperPlugin = { + id: "beeper", + name: "Beeper", + description: "Bridge OpenClaw sessions and agents into Beeper.", + plugin: beeperChannelPlugin, + register(api: OpenClawPluginApi): void { + api.registerChannel?.({ plugin: beeperChannelPlugin }); + api.channels?.register?.(beeperChannelPlugin); + }, +}; + +export default openClawBeeperPlugin; diff --git a/packages/openclaw/src/openclaw-runtime.test.ts b/packages/openclaw/src/openclaw-runtime.test.ts index 503e7e1..a26d917 100644 --- a/packages/openclaw/src/openclaw-runtime.test.ts +++ b/packages/openclaw/src/openclaw-runtime.test.ts @@ -55,7 +55,7 @@ describe("OpenClawGatewayRuntime", () => { key: "agent:codex:main", message: "hello", timeoutMs: 1000, - }, { expectFinal: true, timeoutMs: 1000 }); + }, { expectFinal: false, timeoutMs: 1000 }); }); it("exposes generic OpenClaw gateway feature RPC wrappers", async () => { @@ -131,13 +131,13 @@ describe("OpenClawGatewayRuntime", () => { url: "ws://127.0.0.1:29390/openclaw", }); - await expect(transport.request("sessions.send", { key: "session", message: "hi" }, { expectFinal: true })).resolves.toEqual({ + await expect(transport.request("sessions.send", { key: "session", message: "hi" }, { expectFinal: false })).resolves.toEqual({ runId: "run_1", }); expect(requests).toEqual([ { body: { - expectFinal: true, + expectFinal: false, method: "sessions.send", params: { key: "session", message: "hi" }, }, @@ -229,6 +229,36 @@ describe("OpenClawGatewayRuntime", () => { expect(events).toEqual([{ event: "session.message", payload: { runId: "run_1" }, seq: 3 }]); transport.close(); }); + + it("accepts gateway WebSocket events with top-level run metadata", async () => { + FakeWebSocket.instances = []; + const transport = createOpenClawWebSocketTransport({ + WebSocket: FakeWebSocket as unknown as typeof WebSocket, + url: "ws://gateway", + }); + + const iterator = transport.events((event) => { + const payload = event.payload as { runId?: string }; + return payload.runId === "run_top"; + }); + const next = iterator[Symbol.asyncIterator]().next(); + await waitFor(() => (FakeWebSocket.instances[0]?.sent.length ?? 0) === 1); + const socket = FakeWebSocket.instances[0]!; + socket?.receive({ id: JSON.parse(socket.sent[0] ?? "{}").id, ok: true, payload: { ok: true }, type: "res" }); + await new Promise((resolve) => setTimeout(resolve, 0)); + socket?.receive({ event: "session.message", runId: "run_skip", type: "event" }); + socket?.receive({ deltaText: "hi", event: "session.message", runId: "run_top", seq: 4, type: "event" }); + + await expect(next).resolves.toEqual({ + done: false, + value: { + event: "session.message", + payload: { deltaText: "hi", event: "session.message", runId: "run_top", seq: 4, type: "event" }, + seq: 4, + }, + }); + transport.close(); + }); }); class FakeWebSocket { diff --git a/packages/openclaw/src/openclaw-runtime.ts b/packages/openclaw/src/openclaw-runtime.ts index 1ae2c38..9fd5c1c 100644 --- a/packages/openclaw/src/openclaw-runtime.ts +++ b/packages/openclaw/src/openclaw-runtime.ts @@ -50,12 +50,36 @@ export interface OpenClawSessionCreateOptions { export interface OpenClawSessionSendOptions { attachments?: unknown[]; idempotencyKey?: string; + matrix?: OpenClawMatrixMessageMetadata; message: string; + replyTo?: OpenClawReplyReference; sessionKey: string; thinking?: string; timeoutMs?: number; } +export interface OpenClawMatrixMessageMetadata { + formattedBody?: string; + mentions?: { + room?: boolean; + userIds?: string[]; + }; + relation?: { + key?: string; + kind?: "reply" | "thread" | "edit" | "reaction" | "redaction"; + replyToEventId?: string; + targetEventId?: string; + threadRootEventId?: string; + }; + sender?: string; + threadRootEventId?: string; +} + +export interface OpenClawReplyReference { + eventId: string; + roomId?: string; +} + export interface OpenClawGatewayFeatureSnapshot { agents?: unknown; artifacts?: unknown; @@ -275,13 +299,15 @@ export class OpenClawGatewayRuntime { } async sendMessage(options: OpenClawSessionSendOptions): Promise { - const requestOptions: GatewayRequestOptions = { expectFinal: true }; + const requestOptions: GatewayRequestOptions = { expectFinal: false }; if (options.timeoutMs !== undefined) requestOptions.timeoutMs = options.timeoutMs; const raw = await this.transport.request("sessions.send", { 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); @@ -292,7 +318,7 @@ export class OpenClawGatewayRuntime { } async steerSession(options: OpenClawSessionSendOptions): Promise { - const requestOptions: GatewayRequestOptions = { expectFinal: true }; + const requestOptions: GatewayRequestOptions = { expectFinal: false }; if (options.timeoutMs !== undefined) requestOptions.timeoutMs = options.timeoutMs; const raw = await this.transport.request("sessions.steer", { key: options.sessionKey, @@ -551,7 +577,7 @@ export class OpenClawWebSocketTransport implements OpenClawTransport { if (frame.type === "event") { const event = stripUndefined({ event: stringValue(frame.event), - payload: frame.payload, + payload: frame.payload ?? frame, seq: typeof frame.seq === "number" ? frame.seq : undefined, stateVersion: frame.stateVersion, }); diff --git a/packages/openclaw/src/plugin-entry.ts b/packages/openclaw/src/plugin-entry.ts new file mode 100644 index 0000000..e2c5484 --- /dev/null +++ b/packages/openclaw/src/plugin-entry.ts @@ -0,0 +1,4 @@ +import { openClawBeeperPlugin } from "./openclaw-extension"; + +export default openClawBeeperPlugin; +export { openClawBeeperPlugin }; diff --git a/packages/openclaw/src/registration.test.ts b/packages/openclaw/src/registration.test.ts index 1768657..0b42d92 100644 --- a/packages/openclaw/src/registration.test.ts +++ b/packages/openclaw/src/registration.test.ts @@ -47,4 +47,22 @@ describe("OpenClaw appservice registration", () => { preset: "private_chat", }); }); + + it("keeps appservice tokens independent from the Beeper Matrix access token", () => { + const config = createDefaultConfig({ + accessToken: "mx-token", + 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({ + accessToken: "mx-token", + dataDir: "/tmp/openclaw", + })); + expect(generated.as_token).not.toBe("mx-token"); + expect(generated.as_token).toMatch(/^[a-f0-9]{64}$/u); + }); }); diff --git a/packages/openclaw/src/registration.ts b/packages/openclaw/src/registration.ts index 70c04f1..4a8d886 100644 --- a/packages/openclaw/src/registration.ts +++ b/packages/openclaw/src/registration.ts @@ -14,7 +14,7 @@ export function createAppserviceRegistration( const userPrefix = escapeRegex(config.userLocalpartPrefix); const sender = escapeRegex(config.senderLocalpart); return { - as_token: options.asToken ?? config.accessToken ?? secretToken(), + as_token: options.asToken ?? config.asToken ?? secretToken(), hs_token: options.hsToken ?? config.hsToken ?? secretToken(), id: config.appserviceId, namespaces: { 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..abadae3 --- /dev/null +++ b/packages/openclaw/src/setup-entry.ts @@ -0,0 +1,8 @@ +import { beeperChannelPlugin } from "./setup"; + +export const openClawBeeperSetupEntry = { + kind: "bundled-channel-setup-entry", + loadSetupPlugin: () => beeperChannelPlugin, +} as const; + +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..682d57c --- /dev/null +++ b/packages/openclaw/src/setup.test.ts @@ -0,0 +1,547 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import extension from "./openclaw-extension"; +import setupEntry from "./setup-entry"; +import { + applyBeeperChannelSettings, + beeperChannelPlugin, + beeperSetupAdapter, + beeperSetupWizard, + defaultBeeperChannelSettings, + getBeeperChannelSettings, + isBeeperChannelConfigured, + startBeeperGatewayAccount, + validateBeeperSetupInput, +} from "./setup"; +import { createConfigFromOpenClawSetup } from "./config"; + +const appserviceMocks = vi.hoisted(() => ({ + accountFromOpenClawConfig: vi.fn((config: unknown) => ({ config, kind: "account" })), + startOpenClawBeeperBridge: vi.fn(), +})); + +vi.mock("./appservice", () => appserviceMocks); + +describe("OpenClaw Beeper setup surface", () => { + beforeEach(() => { + appserviceMocks.accountFromOpenClawConfig.mockClear(); + appserviceMocks.startOpenClawBeeperBridge.mockReset(); + }); + + it("exposes a channel plugin through the setup entry shape OpenClaw loads", () => { + expect(extension.plugin).toBe(beeperChannelPlugin); + expect(beeperChannelPlugin).toMatchObject({ + id: "beeper", + meta: { + id: "beeper", + label: "Beeper", + }, + capabilities: { + media: true, + reactions: true, + threads: true, + }, + reload: { + configPrefixes: ["channels.beeper", "plugins.entries.beeper"], + }, + gateway: { + startAccount: expect.any(Function), + stopAccount: expect.any(Function), + }, + uiHints: { + accessToken: { + sensitive: true, + }, + asToken: { + sensitive: true, + }, + bridgeManagerToken: { + sensitive: true, + }, + gatewayAccessToken: { + sensitive: true, + }, + hsToken: { + sensitive: true, + }, + }, + }); + expect(beeperChannelPlugin.setup).toBe(beeperSetupAdapter); + expect(beeperChannelPlugin.setupWizard).toBe(beeperSetupWizard); + }); + + it("matches the OpenClaw channel contract surface used by the dashboard and runtime", () => { + 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.capabilities.chatTypes).toEqual( + expect.arrayContaining(["direct", "thread"]), + ); + 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), + })); + + const cfg = beeperSetupAdapter.applyAccountConfig({ + accountId: "default", + cfg: {}, + input: { + gatewayUrl: "ws://127.0.0.1:29390", + registrationUrl: "http://127.0.0.1:29391", + }, + }); + expect(cfg).not.toHaveProperty("then"); + expect(getBeeperChannelSettings(cfg)).toMatchObject({ + gatewayUrl: "ws://127.0.0.1:29390", + registrationUrl: "http://127.0.0.1:29391", + }); + }); + + 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 cfg = applyBeeperChannelSettings({}, { + accessToken: "at", + asToken: "as", + backfillLimit: 25, + dataDir: "/tmp/openclaw-beeper", + enabled: true, + gatewayUrl: "ws://gateway", + homeserver: "https://matrix.example", + hsToken: "hs", + importSources: ["dashboard", "tui"], + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + registrationUrl: "http://bridge", + }); + + const task = startBeeperGatewayAccount({ + abortSignal: abort.signal, + accountId: "default", + cfg, + setStatus: (next) => statuses.push(next), + }); + await vi.waitFor(() => expect(appserviceMocks.startOpenClawBeeperBridge).toHaveBeenCalledOnce()); + expect(appserviceMocks.accountFromOpenClawConfig).toHaveBeenCalledWith(expect.objectContaining({ + accessToken: "at", + asToken: "as", + gatewayUrl: "ws://gateway", + hsToken: "hs", + })); + expect(appserviceMocks.startOpenClawBeeperBridge).toHaveBeenCalledWith(expect.objectContaining({ + account: expect.objectContaining({ kind: "account" }), + backfill: true, + backfillLimit: 25, + config: expect.objectContaining({ + dataDir: "/tmp/openclaw-beeper", + importSources: ["dashboard", "tui"], + }), + dataDir: "/tmp/openclaw-beeper", + })); + expect(statuses).toContainEqual(expect.objectContaining({ running: true })); + abort.abort(); + await task; + expect(stop).toHaveBeenCalledOnce(); + expect(statuses).toContainEqual(expect.objectContaining({ running: false })); + }); + + it("rejects gateway startup until Beeper setup has complete credentials", async () => { + await expect(startBeeperGatewayAccount({ + abortSignal: new AbortController().signal, + accountId: "default", + cfg: applyBeeperChannelSettings({}, { + enabled: true, + gatewayUrl: "ws://gateway", + registrationUrl: "http://bridge", + }), + })).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()).toBe(beeperChannelPlugin); + }); + + it("applies dashboard setup input into channels.beeper settings", async () => { + const cfg = await beeperSetupAdapter.applyAccountConfig({ + accountId: "default", + cfg: {}, + input: { + accessToken: "mx", + allowedRoomIds: "!one:example,!two:example,!one:example", + allowedUserIds: ["@alice:example", "@bob:example", "@alice:example"], + approvalBehavior: "native", + backfillLimit: "42", + baseDomain: "beeper-staging.com", + beeperEnv: "staging", + bridgeManagerToken: "hungry", + contactVisibility: "agents-and-users", + gatewayAccessToken: "gw-token", + gatewayUrl: "ws://127.0.0.1:29390", + importSources: "dashboard,tui", + nonFederatedRooms: "false", + registrationUrl: "http://127.0.0.1:29391", + streamFinalization: "replace", + }, + }); + expect(getBeeperChannelSettings(cfg)).toEqual({ + accessToken: "mx", + allowedRoomIds: ["!one:example", "!two:example"], + allowedUserIds: ["@alice:example", "@bob:example"], + approvalBehavior: "native", + backfillLimit: 42, + baseDomain: "beeper-staging.com", + beeperEnv: "staging", + bridgeManagerToken: "hungry", + contactVisibility: "agents-and-users", + enabled: true, + gatewayAccessToken: "gw-token", + gatewayUrl: "ws://127.0.0.1:29390", + importSources: ["dashboard", "tui"], + nonFederatedRooms: false, + registrationUrl: "http://127.0.0.1:29391", + streamFinalization: "replace", + }); + expect(isBeeperChannelConfigured(cfg)).toBe(false); + expect(cfg.plugins?.entries?.beeper).toEqual({ + config: getBeeperChannelSettings(cfg), + }); + }); + + it("keeps async Beeper login out of the synchronous OpenClaw setup adapter", () => { + expect(() => beeperSetupAdapter.applyAccountConfig({ + accountId: "default", + cfg: {}, + input: { + email: "alice@example.com", + }, + })).toThrow("Beeper email login is asynchronous"); + }); + + 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 login code": "123456", + "OpenClaw Gateway URL": "ws://127.0.0.1:29390", + "Appservice callback URL": "http://127.0.0.1:29391", + "Beeper API base domain": "beeper.localtest.me", + "Bridge manager token": "hungry", + "Homeserver domain": "beeper.local", + "Backfill limit per session": "500", + }; + 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 environment") return "dev"; + if (message === "Beeper contact visibility") return "agents"; + if (message === "Stream finalization") return "replace"; + 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("dev"); + expect(options.baseDomain).toBe("beeper.localtest.me"); + expect(options.bridgeManagerToken).toBe("hungry"); + expect(options.homeserverDomain).toBe("beeper.local"); + expect(options.postState).toBe(false); + expect(await options.getLoginCode?.()).toBe("123456"); + expect(options.address).toBe("http://127.0.0.1:29391"); + return { + account: { + accessToken: "at", + deviceId: "DEV", + homeserver: "https://matrix.example", + userId: "@alice:example", + }, + config: { + accessToken: "at", + appserviceId: "pickle-openclaw", + asToken: "as", + homeserver: "https://matrix.example", + hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + registrationUrl: "http://127.0.0.1:29391", + }, + init: { + homeserver: "https://matrix.example", + registration: { + asToken: "as", + id: "pickle-openclaw", + hsToken: "hs", + url: "http://127.0.0.1:29391", + }, + } as never, + }; + }, + }, + }); + const cfg = result.cfg; + expect(result.accountId).toBe("default"); + expect(getBeeperChannelSettings(cfg)).toMatchObject({ + enabled: true, + accessToken: "at", + asToken: "as", + baseDomain: "beeper.localtest.me", + bridgeManagerPostState: false, + bridgeManagerToken: "hungry", + gatewayUrl: "ws://127.0.0.1:29390", + homeserver: "https://matrix.example", + homeserverDomain: "beeper.local", + hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + registrationUrl: "http://127.0.0.1:29391", + }); + }); + + it("keeps manually entered tokens in setup input", async () => { + const cfg = await beeperSetupAdapter.applyAccountConfig({ + accountId: "default", + cfg: {}, + input: { + accessToken: "at", + asToken: "as", + gatewayUrl: "ws://127.0.0.1:29390", + registrationUrl: "http://127.0.0.1:29391", + }, + }); + expect(getBeeperChannelSettings(cfg)).toMatchObject({ + accessToken: "at", + asToken: "as", + gatewayUrl: "ws://127.0.0.1:29390", + registrationUrl: "http://127.0.0.1:29391", + }); + }); + + it("does not report configured until login, appservice, and gateway details are present", async () => { + expect(isBeeperChannelConfigured(applyBeeperChannelSettings({}, { + enabled: true, + gatewayUrl: "ws://gateway", + registrationUrl: "http://bridge", + }))).toBe(false); + const cfg = applyBeeperChannelSettings({}, { + accessToken: "at", + asToken: "as", + enabled: true, + gatewayUrl: "ws://gateway", + homeserver: "https://matrix.example", + hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + registrationUrl: "http://bridge", + }); + expect(isBeeperChannelConfigured(cfg)).toBe(true); + }); + + it("legacy direct applyBeeperSetupConfig path still supports test/runtime callers", async () => { + const { applyBeeperSetupConfig } = await import("./setup"); + const cfg = await applyBeeperSetupConfig({ + cfg: {}, + input: { + beeperEnv: "dev", + code: "123456", + email: "alice@example.com", + gatewayUrl: "ws://127.0.0.1:29390", + registrationUrl: "http://127.0.0.1:29391", + }, + runtime: { + setupBridge: async (options) => { + expect(options.email).toBe("alice@example.com"); + expect(options.env).toBe("dev"); + expect(options.baseDomain).toBeUndefined(); + expect(options.bridgeManagerToken).toBeUndefined(); + expect(options.homeserverDomain).toBeUndefined(); + expect(options.postState).toBeUndefined(); + expect(await options.getLoginCode?.()).toBe("123456"); + expect(options.address).toBe("http://127.0.0.1:29391"); + return { + account: { + accessToken: "at", + deviceId: "DEV", + homeserver: "https://matrix.example", + userId: "@alice:example", + }, + config: { + accessToken: "at", + appserviceId: "pickle-openclaw", + asToken: "as", + homeserver: "https://matrix.example", + hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + registrationUrl: "http://127.0.0.1:29391", + }, + init: { + homeserver: "https://matrix.example", + registration: { + asToken: "as", + id: "pickle-openclaw", + hsToken: "hs", + url: "http://127.0.0.1:29391", + }, + } as never, + }; + }, + }, + }); + expect(getBeeperChannelSettings(cfg)).toMatchObject({ + enabled: true, + accessToken: "at", + asToken: "as", + gatewayUrl: "ws://127.0.0.1:29390", + homeserver: "https://matrix.example", + hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + registrationUrl: "http://127.0.0.1:29391", + }); + }); + + it("keeps default import scope opt-in to dashboard and TUI sessions", async () => { + expect(defaultBeeperChannelSettings()).toMatchObject({ + enabled: true, + importSources: ["dashboard", "tui"], + streamFinalization: "replace", + }); + const configured = await beeperSetupWizard.configure({ cfg: {} }); + expect(getBeeperChannelSettings(configured.cfg)).toMatchObject({ + enabled: true, + importSources: ["dashboard", "tui"], + }); + }); + + it("reports setup status and validates dashboard input", async () => { + expect(validateBeeperSetupInput({ email: "not-email" })).toContain("valid email"); + expect(validateBeeperSetupInput({ backfillLimit: "-1" })).toContain("non-negative"); + const cfg = applyBeeperChannelSettings({}, { + enabled: true, + gatewayUrl: "ws://gateway", + importSources: ["dashboard"], + registrationUrl: "http://bridge", + }); + await expect(beeperSetupWizard.getStatus({ cfg })).resolves.toMatchObject({ + channel: "beeper", + configured: false, + quickstartScore: 20, + }); + }); + + it("creates bridge runtime config from persisted channels.beeper settings", () => { + const cfg = createConfigFromOpenClawSetup({ + channels: { + beeper: { + dataDir: "/tmp/beeper", + gatewayUrl: "ws://gateway", + homeserver: "https://matrix.example", + hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + nonFederatedRooms: false, + registrationUrl: "http://bridge", + }, + }, + }); + expect(cfg).toMatchObject({ + dataDir: "/tmp/beeper", + gatewayUrl: "ws://gateway", + homeserver: "https://matrix.example", + hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + nonFederatedRooms: false, + registrationUrl: "http://bridge", + }); + }); + + it("reads plugin-entry channel config with channels.beeper taking precedence", () => { + expect(getBeeperChannelSettings({ + channels: { + beeper: { + gatewayUrl: "ws://channel", + importSources: ["dashboard"], + }, + }, + plugins: { + entries: { + beeper: { + config: { + enabled: true, + gatewayUrl: "ws://plugin-entry", + registrationUrl: "http://bridge", + }, + }, + }, + }, + })).toEqual({ + enabled: true, + gatewayUrl: "ws://channel", + importSources: ["dashboard"], + registrationUrl: "http://bridge", + }); + + expect(createConfigFromOpenClawSetup({ + plugins: { + entries: { + beeper: { + config: { + gatewayUrl: "ws://plugin-entry", + registrationUrl: "http://bridge", + }, + }, + }, + }, + })).toMatchObject({ + gatewayUrl: "ws://plugin-entry", + registrationUrl: "http://bridge", + }); + }); +}); diff --git a/packages/openclaw/src/setup.ts b/packages/openclaw/src/setup.ts new file mode 100644 index 0000000..89e542f --- /dev/null +++ b/packages/openclaw/src/setup.ts @@ -0,0 +1,706 @@ +import { createConfigFromOpenClawSetup, DEFAULT_REGISTRATION_URL, defaultDataDir } from "./config"; +import type { setupOpenClawBeeperBridge, SetupOpenClawBeeperBridgeOptions } from "./beeper-setup"; + +export type OpenClawSetupConfig = { + channels?: Record; + plugins?: { + entries?: Record; + }; +}; + +export type BeeperImportSource = "dashboard" | "tui" | "channels" | "archived"; + +export interface BeeperChannelSettings { + accessToken?: string; + allowedRoomIds?: string[]; + allowedUserIds?: string[]; + asToken?: string; + approvalBehavior?: "native" | "reactions" | "slash" | "disabled"; + backfillLimit?: number; + baseDomain?: string; + beeperEnv?: "production" | "staging" | "dev" | "local"; + bridgeManagerToken?: string; + bridgeManagerPostState?: boolean; + contactVisibility?: "agents" | "agents-and-users" | "none"; + dataDir?: string; + enabled?: boolean; + gatewayAccessToken?: string; + gatewayUrl?: string; + homeserver?: string; + hsToken?: string; + importSources?: BeeperImportSource[]; + matrixDeviceId?: string; + matrixUserId?: string; + homeserverDomain?: string; + nonFederatedRooms?: boolean; + registrationUrl?: string; + streamFinalization?: "replace" | "append" | "native-only"; +} + +export interface BeeperSetupInput { + accessToken?: string; + allowedRoomIds?: string[] | string; + allowedUserIds?: string[] | string; + asToken?: string; + approvalBehavior?: string; + backfillLimit?: number | string; + baseDomain?: string; + beeperEnv?: string; + bridgeManagerToken?: string; + code?: string; + contactVisibility?: string; + dataDir?: string; + email?: string; + getOnly?: boolean | string; + gatewayAccessToken?: string; + gatewayUrl?: string; + homeserverDomain?: string; + importSources?: string[] | string; + nonFederatedRooms?: boolean | string; + postState?: boolean | string; + push?: boolean | string; + registrationUrl?: string; + selfHosted?: boolean | string; + streamFinalization?: string; + username?: string; +} + +export interface BeeperSetupRuntime { + setupBridge?: (options: SetupOpenClawBeeperBridgeOptions) => Promise>>; +} + +type StartedBeeperBridge = { + stop?: () => Promise | void; +}; + +type BeeperGatewayContext = { + abortSignal: AbortSignal; + accountId: string; + cfg: OpenClawSetupConfig; + log?: { + info?: (message: string) => void; + warn?: (message: string) => void; + error?: (message: string) => void; + }; + setStatus?: (next: Record) => 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"; + +export const BeeperChannelConfigSchema = { + type: "object", + additionalProperties: false, + properties: { + accessToken: { type: "string" }, + asToken: { type: "string" }, + allowedRoomIds: { type: "array", items: { type: "string" } }, + allowedUserIds: { type: "array", items: { type: "string" } }, + enabled: { type: "boolean" }, + baseDomain: { type: "string" }, + beeperEnv: { type: "string", enum: ["production", "staging", "dev", "local"] }, + dataDir: { type: "string" }, + gatewayAccessToken: { type: "string" }, + gatewayUrl: { type: "string" }, + registrationUrl: { type: "string" }, + bridgeManagerToken: { type: "string" }, + bridgeManagerPostState: { type: "boolean" }, + importSources: { + type: "array", + items: { type: "string", enum: ["dashboard", "tui", "channels", "archived"] }, + }, + backfillLimit: { type: "number" }, + nonFederatedRooms: { type: "boolean" }, + contactVisibility: { type: "string", enum: ["agents", "agents-and-users", "none"] }, + homeserverDomain: { type: "string" }, + streamFinalization: { type: "string", enum: ["replace", "append", "native-only"] }, + approvalBehavior: { type: "string", enum: ["native", "reactions", "slash", "disabled"] }, + }, +} as const; + +export const BeeperChannelUiHints = { + accessToken: { + help: "Beeper Matrix access token returned by login.", + label: "Beeper Access Token", + sensitive: true, + }, + bridgeManagerToken: { + help: "Optional Beeper bridge-manager token used to register the self-hosted bridge.", + label: "Bridge Manager Token", + sensitive: true, + }, + gatewayAccessToken: { + help: "Optional bearer token for the local OpenClaw gateway.", + label: "OpenClaw Gateway Token", + sensitive: true, + }, + asToken: { + help: "Appservice token returned by Beeper bridge registration.", + label: "Appservice Token", + sensitive: true, + }, + hsToken: { + help: "Homeserver token returned by Beeper bridge registration.", + label: "Homeserver Token", + sensitive: true, + }, +} as const; + +export const beeperSetupAdapter = { + resolveAccountId: () => "default", + resolveBindingAccountId: () => "default", + applyAccountName: ({ cfg }: { cfg: OpenClawSetupConfig }) => cfg, + validateInput: ({ input }: { input: BeeperSetupInput }) => validateBeeperSetupInput(input), + applyAccountConfig: ({ + cfg, + input, + runtime, + }: { + cfg: OpenClawSetupConfig; + accountId: string; + input: BeeperSetupInput; + runtime?: BeeperSetupRuntime; + }): OpenClawSetupConfig => { + if (input.email) { + throw new Error("Beeper email login is asynchronous; use the Beeper setup wizard or pickle-openclaw beeper-setup."); + } + return applyBeeperChannelSettings(cfg, normalizeBeeperSetupInput(input)); + }, +}; + +export const beeperSetupWizard = { + channel: BEEPER_CHANNEL_ID, + async getStatus(ctx: { cfg: OpenClawSetupConfig }) { + const settings = getBeeperChannelSettings(ctx.cfg); + const configured = isBeeperChannelConfigured(ctx.cfg); + return { + channel: BEEPER_CHANNEL_ID, + configured, + statusLines: [ + `Gateway: ${settings.gatewayUrl ?? "not configured"}`, + `Registration URL: ${settings.registrationUrl ?? "not configured"}`, + `Import sources: ${(settings.importSources ?? []).join(", ") || "none"}`, + ], + selectionHint: configured ? "Beeper bridge configured" : "Beeper login and bridge registration required", + quickstartScore: configured ? 100 : 20, + }; + }, + async configure(ctx: { cfg: OpenClawSetupConfig }) { + return { + accountId: "default", + cfg: applyBeeperChannelSettings(ctx.cfg, defaultBeeperChannelSettings()), + }; + }, + async configureInteractive(ctx: { + cfg: OpenClawSetupConfig; + runtime?: BeeperSetupRuntime; + prompter: BeeperWizardPrompter; + }) { + const current = { + ...defaultBeeperChannelSettings(), + ...getBeeperChannelSettings(ctx.cfg), + }; + const email = await ctx.prompter.text({ + message: "Beeper email", + placeholder: "name@example.com", + validate: (value) => validateBeeperSetupInput({ email: value }) ?? undefined, + }); + const code = await ctx.prompter.text({ + message: "Beeper login code", + sensitive: true, + validate: (value) => (value.trim() ? undefined : "Beeper login code is required."), + }); + const gatewayUrl = await ctx.prompter.text({ + message: "OpenClaw Gateway URL", + initialValue: current.gatewayUrl ?? "ws://127.0.0.1:29390", + validate: (value) => (value.trim() ? undefined : "OpenClaw Gateway URL is required."), + }); + const registrationUrl = await ctx.prompter.text({ + message: "Appservice callback URL", + initialValue: current.registrationUrl ?? DEFAULT_REGISTRATION_URL, + validate: (value) => (value.trim() ? undefined : "Appservice callback URL is required."), + }); + const beeperEnv = await ctx.prompter.select({ + message: "Beeper environment", + initialValue: current.beeperEnv ?? "production", + options: [ + { value: "production", label: "Production" }, + { value: "staging", label: "Staging" }, + { value: "dev", label: "Development" }, + { value: "local", label: "Local" }, + ], + }); + const defaultBaseDomain = current.baseDomain ?? setupBeeperBaseDomain(beeperEnv); + const baseDomain = await ctx.prompter.text({ + message: "Beeper API base domain", + ...(defaultBaseDomain ? { initialValue: defaultBaseDomain } : {}), + placeholder: "leave empty for production default", + }); + const bridgeManagerToken = await ctx.prompter.text({ + message: "Bridge manager token", + ...(current.bridgeManagerToken ? { initialValue: current.bridgeManagerToken } : {}), + placeholder: "optional", + sensitive: true, + }); + const homeserverDomain = await ctx.prompter.text({ + message: "Homeserver domain", + ...(current.homeserverDomain ? { initialValue: current.homeserverDomain } : {}), + placeholder: "optional", + }); + const importSources = await ctx.prompter.multiselect({ + message: "OpenClaw sessions to import", + initialValues: current.importSources ?? ["dashboard", "tui"], + options: [ + { value: "dashboard", label: "Dashboard" }, + { value: "tui", label: "TUI" }, + { value: "channels", label: "Channel-origin sessions" }, + { value: "archived", label: "Archived sessions" }, + ], + }); + const backfillLimit = await ctx.prompter.text({ + message: "Backfill limit per session", + initialValue: String(current.backfillLimit ?? 500), + validate: (value) => validateBeeperSetupInput({ backfillLimit: value }) ?? undefined, + }); + const contactVisibility = await ctx.prompter.select({ + message: "Beeper contact visibility", + initialValue: current.contactVisibility ?? "agents", + options: [ + { value: "agents", label: "Agents" }, + { value: "agents-and-users", label: "Agents and users" }, + { value: "none", label: "None" }, + ], + }); + const streamFinalization = await ctx.prompter.select({ + message: "Stream finalization", + initialValue: current.streamFinalization ?? "replace", + options: [ + { value: "replace", label: "Replace final message" }, + { value: "append", label: "Append final message" }, + { value: "native-only", label: "Native stream only" }, + ], + }); + const approvalBehavior = await ctx.prompter.select({ + message: "Approval behavior", + initialValue: current.approvalBehavior ?? "native", + options: [ + { value: "native", label: "Native" }, + { value: "reactions", label: "Reactions" }, + { value: "slash", label: "Slash commands" }, + { value: "disabled", label: "Disabled" }, + ], + }); + const nonFederatedRooms = await ctx.prompter.confirm({ + message: "Create non-federated Matrix rooms", + initialValue: current.nonFederatedRooms ?? true, + }); + const postState = await ctx.prompter.confirm({ + message: "Post bridge state to Beeper", + initialValue: current.bridgeManagerPostState ?? true, + }); + const progress = ctx.prompter.progress?.("Setting up Beeper bridge"); + progress?.update("Logging in and registering appservice"); + try { + const input: BeeperSetupInput = { + backfillLimit, + code, + email, + gatewayUrl, + importSources, + nonFederatedRooms, + postState, + registrationUrl, + }; + if (approvalBehavior !== undefined) input.approvalBehavior = approvalBehavior; + if (baseDomain.trim()) input.baseDomain = baseDomain.trim(); + if (beeperEnv !== undefined) input.beeperEnv = beeperEnv; + if (bridgeManagerToken.trim()) input.bridgeManagerToken = bridgeManagerToken.trim(); + if (contactVisibility !== undefined) input.contactVisibility = contactVisibility; + if (homeserverDomain.trim()) input.homeserverDomain = homeserverDomain.trim(); + if (streamFinalization !== undefined) input.streamFinalization = streamFinalization; + const setupParams: Parameters[0] = { + cfg: ctx.cfg, + input, + }; + if (ctx.runtime !== undefined) setupParams.runtime = ctx.runtime; + const cfg = await applyBeeperSetupConfig(setupParams); + progress?.stop("Beeper bridge configured"); + return { accountId: "default", cfg }; + } catch (error) { + progress?.stop("Beeper bridge setup failed"); + throw error; + } + }, + disable: (cfg: OpenClawSetupConfig) => applyBeeperChannelSettings(cfg, { enabled: false }), +}; + +export const beeperChannelConfig = { + listAccountIds: () => ["default"], + defaultAccountId: () => "default", + resolveAccount: (cfg: OpenClawSetupConfig) => ({ + accountId: "default", + configured: isBeeperChannelConfigured(cfg), + settings: getBeeperChannelSettings(cfg), + }), + isEnabled: (account: { settings?: BeeperChannelSettings }) => account.settings?.enabled !== false, + isConfigured: (account: { configured?: boolean }) => account.configured === true, + hasConfiguredState: ({ cfg }: { cfg: OpenClawSetupConfig }) => isBeeperChannelConfigured(cfg), + describeAccount: (account: { configured?: boolean; settings?: BeeperChannelSettings }) => ({ + id: "default", + label: "Beeper", + configured: account.configured === true, + extra: { + gatewayUrl: account.settings?.gatewayUrl, + registrationUrl: account.settings?.registrationUrl, + }, + }), +}; + +const startedBridges = new Map(); + +export async function applyBeeperSetupConfig(params: { + cfg: OpenClawSetupConfig; + input: BeeperSetupInput; + runtime?: BeeperSetupRuntime; +}): Promise { + const baseSettings = normalizeBeeperSetupInput(params.input); + if (!params.input.email) return applyBeeperChannelSettings(params.cfg, baseSettings); + const setupBridge = params.runtime?.setupBridge ?? (await loadBeeperSetupBridge()); + const bridgeOptions = setupOptionsFromInput(params.input); + const result = await setupBridge(bridgeOptions); + const setupSettings: Partial = { + ...baseSettings, + enabled: true, + registrationUrl: result.config.registrationUrl, + }; + if (result.config.homeserver) setupSettings.homeserver = result.config.homeserver; + if (result.config.accessToken) setupSettings.accessToken = result.config.accessToken; + if (result.config.asToken) setupSettings.asToken = result.config.asToken; + if (params.input.homeserverDomain) setupSettings.homeserverDomain = params.input.homeserverDomain; + if (result.config.hsToken) setupSettings.hsToken = result.config.hsToken; + if (result.config.matrixDeviceId) setupSettings.matrixDeviceId = result.config.matrixDeviceId; + if (result.config.matrixUserId) setupSettings.matrixUserId = result.config.matrixUserId; + return applyBeeperChannelSettings(params.cfg, setupSettings); +} + +async function loadBeeperSetupBridge(): Promise { + return (await import("./beeper-setup")).setupOpenClawBeeperBridge; +} + +export const beeperChannelPlugin = { + id: BEEPER_CHANNEL_ID, + meta: { + id: BEEPER_CHANNEL_ID, + label: "Beeper", + selectionLabel: "Beeper bridge", + docsPath: "/channels/beeper", + docsLabel: "beeper", + blurb: "bridges OpenClaw sessions and agents into Beeper.", + order: 90, + quickstartAllowFrom: true, + }, + capabilities: { + chatTypes: ["direct", "thread"], + media: true, + reactions: true, + threads: true, + }, + reload: { configPrefixes: ["channels.beeper", "plugins.entries.beeper"] }, + configSchema: BeeperChannelConfigSchema, + uiHints: BeeperChannelUiHints, + config: beeperChannelConfig, + gateway: { + startAccount: startBeeperGatewayAccount, + stopAccount: stopBeeperGatewayAccount, + }, + setup: beeperSetupAdapter, + setupWizard: beeperSetupWizard, +}; + +export async function startBeeperGatewayAccount(ctx: BeeperGatewayContext): Promise { + const settings = getBeeperChannelSettings(ctx.cfg); + if (settings.enabled === false) { + ctx.log?.info?.("Beeper bridge is disabled; skipping startup."); + return; + } + if (!isBeeperChannelConfigured(ctx.cfg)) { + throw new Error("Beeper bridge is not fully configured; run Beeper channel setup first."); + } + const { accountFromOpenClawConfig, startOpenClawBeeperBridge } = await import("./appservice"); + const config = createConfigFromOpenClawSetup(ctx.cfg); + const bridge = await startOpenClawBeeperBridge({ + account: accountFromOpenClawConfig(config), + backfill: Boolean(config.importSources?.length), + ...(config.backfillLimit !== undefined ? { backfillLimit: config.backfillLimit } : {}), + config, + dataDir: config.dataDir, + }); + const key = gatewayAccountKey(ctx.accountId); + 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); + await bridge.stop?.(); + ctx.setStatus?.({ + accountId: ctx.accountId, + running: false, + }); + ctx.log?.info?.("Beeper bridge stopped."); + } +} + +export async function stopBeeperGatewayAccount(ctx: BeeperGatewayContext): Promise { + const bridge = startedBridges.get(gatewayAccountKey(ctx.accountId)); + if (!bridge) return; + startedBridges.delete(gatewayAccountKey(ctx.accountId)); + await bridge.stop?.(); + ctx.setStatus?.({ + accountId: ctx.accountId, + running: false, + }); +} + +export function getBeeperChannelSettings(cfg: OpenClawSetupConfig): BeeperChannelSettings { + const pluginEntry = recordValue(cfg.plugins?.entries?.[BEEPER_CHANNEL_ID]); + const pluginSettings = recordValue(pluginEntry?.config); + const channelSettings = recordValue(cfg.channels?.[BEEPER_CHANNEL_ID]); + return { + ...(pluginSettings as BeeperChannelSettings | undefined), + ...(channelSettings as BeeperChannelSettings | undefined), + }; +} + +export function isBeeperChannelConfigured(cfg: OpenClawSetupConfig): boolean { + const settings = getBeeperChannelSettings(cfg); + return Boolean( + settings.enabled && + settings.accessToken && + settings.asToken && + settings.gatewayUrl && + settings.homeserver && + settings.hsToken && + settings.matrixDeviceId && + settings.matrixUserId && + settings.registrationUrl + ); +} + +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, + }, + plugins: { + ...cfg.plugins, + entries: { + ...cfg.plugins?.entries, + [BEEPER_CHANNEL_ID]: { + ...(recordValue(cfg.plugins?.entries?.[BEEPER_CHANNEL_ID]) ?? {}), + config: nextSettings, + }, + }, + }, + }; +} + +export function defaultBeeperChannelSettings(): BeeperChannelSettings { + return { + approvalBehavior: "native", + backfillLimit: 500, + beeperEnv: "production", + contactVisibility: "agents", + dataDir: defaultDataDir(), + enabled: true, + importSources: ["dashboard", "tui"], + nonFederatedRooms: true, + registrationUrl: DEFAULT_REGISTRATION_URL, + streamFinalization: "replace", + }; +} + +export function validateBeeperSetupInput(input: BeeperSetupInput): string | null { + if (input.email !== undefined && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/u.test(input.email)) return "Beeper email must be a valid email address."; + if (input.beeperEnv !== undefined && normalizeBeeperEnv(input.beeperEnv) === undefined) return "Beeper environment must be production, staging, dev, or local."; + if (input.contactVisibility !== undefined && normalizeContactVisibility(input.contactVisibility) === undefined) return "Contact visibility must be agents, agents-and-users, or none."; + if (input.streamFinalization !== undefined && normalizeStreamFinalization(input.streamFinalization) === undefined) return "Stream finalization must be replace, append, or native-only."; + if (input.approvalBehavior !== undefined && normalizeApprovalBehavior(input.approvalBehavior) === undefined) return "Approval behavior must be native, reactions, slash, or disabled."; + const backfillLimit = normalizeOptionalNumber(input.backfillLimit); + if (backfillLimit !== undefined && (!Number.isInteger(backfillLimit) || backfillLimit < 0)) return "Backfill limit must be a non-negative integer."; + return null; +} + +export function normalizeBeeperSetupInput(input: BeeperSetupInput): Partial { + const settings: Partial = { enabled: true }; + const allowedRoomIds = normalizeStringList(input.allowedRoomIds); + const allowedUserIds = normalizeStringList(input.allowedUserIds); + const approvalBehavior = normalizeApprovalBehavior(input.approvalBehavior); + const backfillLimit = normalizeOptionalNumber(input.backfillLimit); + const beeperEnv = normalizeBeeperEnv(input.beeperEnv); + const contactVisibility = normalizeContactVisibility(input.contactVisibility); + const importSources = normalizeImportSources(input.importSources); + const nonFederatedRooms = normalizeOptionalBoolean(input.nonFederatedRooms); + const bridgeManagerPostState = normalizeOptionalBoolean(input.postState); + const streamFinalization = normalizeStreamFinalization(input.streamFinalization); + if (input.accessToken) settings.accessToken = input.accessToken; + if (input.asToken) settings.asToken = input.asToken; + if (allowedRoomIds) settings.allowedRoomIds = allowedRoomIds; + if (allowedUserIds) settings.allowedUserIds = allowedUserIds; + if (approvalBehavior) settings.approvalBehavior = approvalBehavior; + if (backfillLimit !== undefined) settings.backfillLimit = backfillLimit; + if (input.baseDomain) settings.baseDomain = input.baseDomain; + if (beeperEnv) settings.beeperEnv = beeperEnv; + if (contactVisibility) settings.contactVisibility = contactVisibility; + if (input.bridgeManagerToken) settings.bridgeManagerToken = input.bridgeManagerToken; + if (bridgeManagerPostState !== undefined) settings.bridgeManagerPostState = bridgeManagerPostState; + if (input.dataDir) settings.dataDir = input.dataDir; + if (input.gatewayAccessToken) settings.gatewayAccessToken = input.gatewayAccessToken; + if (input.gatewayUrl) settings.gatewayUrl = input.gatewayUrl; + if (input.homeserverDomain) settings.homeserverDomain = input.homeserverDomain; + if (importSources) settings.importSources = importSources; + if (nonFederatedRooms !== undefined) settings.nonFederatedRooms = nonFederatedRooms; + if (input.registrationUrl) settings.registrationUrl = input.registrationUrl; + if (streamFinalization) settings.streamFinalization = streamFinalization; + return settings; +} + +export function setupOptionsFromInput(input: BeeperSetupInput): SetupOpenClawBeeperBridgeOptions { + if (!input.email) throw new Error("Beeper email is required for dashboard login setup"); + const options: SetupOpenClawBeeperBridgeOptions = { + email: input.email, + }; + const env = normalizeBeeperEnv(input.beeperEnv); + const getOnly = normalizeOptionalBoolean(input.getOnly); + const postState = normalizeOptionalBoolean(input.postState); + const push = normalizeOptionalBoolean(input.push); + const selfHosted = normalizeOptionalBoolean(input.selfHosted); + if (env) options.env = env; + if (input.baseDomain) options.baseDomain = input.baseDomain; + if (input.bridgeManagerToken) options.bridgeManagerToken = input.bridgeManagerToken; + if (input.code) options.getLoginCode = () => input.code!; + if (getOnly !== undefined) options.getOnly = getOnly; + if (input.homeserverDomain) options.homeserverDomain = input.homeserverDomain; + if (postState !== undefined) options.postState = postState; + if (push !== undefined) options.push = push; + if (input.registrationUrl) options.address = input.registrationUrl; + if (selfHosted !== undefined) options.selfHosted = selfHosted; + if (input.username) options.username = input.username; + return options; +} + +function normalizeImportSources(value: string[] | string | undefined): BeeperImportSource[] | undefined { + if (value === undefined) return undefined; + const raw = Array.isArray(value) ? value : value.split(","); + const sources = raw.map((entry) => entry.trim()).filter(Boolean); + if (sources.every(isImportSource)) return [...new Set(sources)]; + return undefined; +} + +function normalizeStringList(value: string[] | string | undefined): string[] | undefined { + if (value === undefined) return undefined; + const entries = (Array.isArray(value) ? value : value.split(",")) + .map((entry) => entry.trim()) + .filter(Boolean); + return entries.length > 0 ? [...new Set(entries)] : undefined; +} + +function isImportSource(value: string): value is BeeperImportSource { + return value === "dashboard" || value === "tui" || value === "channels" || value === "archived"; +} + +function normalizeBeeperEnv(value: string | undefined): BeeperChannelSettings["beeperEnv"] | undefined { + if (value === "production" || value === "staging" || value === "dev" || value === "local") return value; + return undefined; +} + +function setupBeeperBaseDomain(env: BeeperChannelSettings["beeperEnv"]): 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"; +} + +function gatewayAccountKey(accountId: string): string { + return accountId || "default"; +} + +function waitForAbort(signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve(); + return new Promise((resolve) => { + signal.addEventListener("abort", () => resolve(), { once: true }); + }); +} + +function normalizeContactVisibility(value: string | undefined): BeeperChannelSettings["contactVisibility"] | undefined { + if (value === "agents" || value === "agents-and-users" || value === "none") return value; + return undefined; +} + +function normalizeStreamFinalization(value: string | undefined): BeeperChannelSettings["streamFinalization"] | undefined { + if (value === "replace" || value === "append" || value === "native-only") return value; + return undefined; +} + +function normalizeApprovalBehavior(value: string | undefined): BeeperChannelSettings["approvalBehavior"] | undefined { + if (value === "native" || value === "reactions" || value === "slash" || value === "disabled") return value; + return undefined; +} + +function normalizeOptionalNumber(value: number | string | undefined): number | undefined { + if (value === undefined || value === "") return undefined; + const parsed = typeof value === "number" ? value : Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function normalizeOptionalBoolean(value: boolean | string | undefined): boolean | undefined { + if (typeof value === "boolean") return value; + if (value === undefined || value === "") return undefined; + if (["1", "true", "yes", "on"].includes(value.toLowerCase())) return true; + if (["0", "false", "no", "off"].includes(value.toLowerCase())) return false; + return undefined; +} + +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/stream-map.ts b/packages/openclaw/src/stream-map.ts index ea89b09..1b1858f 100644 --- a/packages/openclaw/src/stream-map.ts +++ b/packages/openclaw/src/stream-map.ts @@ -1,78 +1,148 @@ -export type BeeperUIMessageChunk = Record & { type: string }; +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 type { RunFinishedEvent } from "@beeper/pickle-ag-ui"; + +type FinishReason = NonNullable; export interface StreamRunState { - reasoningPartId?: string; - textPartId?: string; + messageStarted: boolean; + reasoningStarted: boolean; + textStarted: boolean; toolCallIdToApprovalId: Record; turnId: string; } export function createStreamRunState(turnId: string): StreamRunState { - return { toolCallIdToApprovalId: {}, turnId }; + return { + messageStarted: false, + reasoningStarted: false, + textStarted: false, + toolCallIdToApprovalId: {}, + turnId, + }; } export function createTurnId(): string { return `turn_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; } -export function startChunk(state: StreamRunState, metadata: Record = {}): BeeperUIMessageChunk { - return { - messageId: state.turnId, - messageMetadata: { turn_id: state.turnId, ...metadata }, - type: "start", - }; +export function startRunEvents(state: StreamRunState, metadata: Record = {}): AGUIEvent[] { + if (state.messageStarted) return []; + state.messageStarted = true; + state.textStarted = true; + return [ + { + runId: state.turnId, + threadId: state.turnId, + type: AGUIEventType.RUN_STARTED, + ...(Object.keys(metadata).length > 0 ? { metadata: { turn_id: state.turnId, ...metadata } } : {}), + }, + { + messageId: state.turnId, + role: "assistant", + type: AGUIEventType.TEXT_MESSAGE_START, + }, + ]; } -export function finishChunk( +export function finishRunEvents( state: StreamRunState, - finishReason = "stop", + finishReason: FinishReason = "stop", metadata: Record = {} -): BeeperUIMessageChunk { - return { - finishReason, - messageMetadata: { finish_reason: finishReason, turn_id: state.turnId, ...metadata }, - type: "finish", - }; +): AGUIEvent[] { + return [ + ...closeOpenMessageParts(state), + { + messageId: state.turnId, + type: AGUIEventType.TEXT_MESSAGE_END, + }, + { + finishReason, + runId: state.turnId, + threadId: state.turnId, + type: AGUIEventType.RUN_FINISHED, + ...(Object.keys(metadata).length > 0 ? { metadata: { finish_reason: finishReason, turn_id: state.turnId, ...metadata } } : {}), + }, + ]; } export function mapOpenClawMessageDelta( state: StreamRunState, delta: { kind: "text" | "thinking"; value: string } -): BeeperUIMessageChunk[] { +): AGUIEvent[] { if (delta.kind === "text") { - return [...openTextPart(state), { delta: delta.value, id: state.textPartId!, type: "text-delta" }]; + return [ + ...openTextPart(state), + { + delta: delta.value, + messageId: state.turnId, + type: AGUIEventType.TEXT_MESSAGE_CONTENT, + }, + ]; } - return [...openReasoningPart(state), { delta: delta.value, id: state.reasoningPartId!, type: "reasoning-delta" }]; + return [ + ...openReasoningPart(state), + { + delta: delta.value, + messageId: state.turnId, + type: AGUIEventType.REASONING_MESSAGE_CONTENT, + }, + ]; } -export function closeOpenMessageParts(state: StreamRunState): BeeperUIMessageChunk[] { +export function closeOpenMessageParts(state: StreamRunState): AGUIEvent[] { return [...closeReasoningPart(state), ...closeTextPart(state)]; } -export function openTextPart(state: StreamRunState): BeeperUIMessageChunk[] { - if (state.textPartId) return []; - state.textPartId = `text_${state.turnId}`; - return [{ id: state.textPartId, type: "text-start" }]; +export function openTextPart(state: StreamRunState): AGUIEvent[] { + if (state.textStarted) return []; + state.textStarted = true; + return [ + { + messageId: state.turnId, + role: "assistant", + type: AGUIEventType.TEXT_MESSAGE_START, + }, + ]; } -export function closeTextPart(state: StreamRunState): BeeperUIMessageChunk[] { - if (!state.textPartId) return []; - const id = state.textPartId; - delete state.textPartId; - return [{ id, type: "text-end" }]; +export function closeTextPart(state: StreamRunState): AGUIEvent[] { + if (!state.textStarted) return []; + state.textStarted = false; + return []; } -export function openReasoningPart(state: StreamRunState): BeeperUIMessageChunk[] { - if (state.reasoningPartId) return []; - state.reasoningPartId = `reasoning_${state.turnId}`; - return [{ id: state.reasoningPartId, type: "reasoning-start" }]; +export function openReasoningPart(state: StreamRunState): AGUIEvent[] { + if (state.reasoningStarted) return []; + state.reasoningStarted = true; + return [ + { + messageId: state.turnId, + type: AGUIEventType.REASONING_START, + }, + { + messageId: state.turnId, + role: "reasoning", + type: AGUIEventType.REASONING_MESSAGE_START, + }, + ]; } -export function closeReasoningPart(state: StreamRunState): BeeperUIMessageChunk[] { - if (!state.reasoningPartId) return []; - const id = state.reasoningPartId; - delete state.reasoningPartId; - return [{ id, type: "reasoning-end" }]; +export function closeReasoningPart(state: StreamRunState): AGUIEvent[] { + if (!state.reasoningStarted) return []; + state.reasoningStarted = false; + return [ + { + messageId: state.turnId, + type: AGUIEventType.REASONING_MESSAGE_END, + }, + { + messageId: state.turnId, + type: AGUIEventType.REASONING_END, + }, + ]; } export function mapOpenClawToolInput(event: { @@ -83,17 +153,54 @@ export function mapOpenClawToolInput(event: { title?: string; toolCallId: string; toolName?: string; -}): BeeperUIMessageChunk { - return stripUndefined({ - dynamic: event.dynamic ?? true, - input: event.input, - providerExecuted: event.providerExecuted, - startedAtMs: event.startedAtMs, - title: event.title, - toolCallId: event.toolCallId, - toolName: event.toolName, - type: "tool-input-available", - }); +}): AGUIEvent[] { + const toolName = event.toolName || "tool"; + return [ + { + parentMessageId: event.toolCallId, + state: "awaiting-input", + toolCallId: event.toolCallId, + toolCallName: toolName, + toolName, + type: AGUIEventType.TOOL_CALL_START, + ...(event.dynamic !== undefined ? { dynamic: event.dynamic } : {}), + ...(event.providerExecuted !== undefined ? { providerExecuted: event.providerExecuted } : {}), + ...(event.startedAtMs !== undefined ? { startedAtMs: event.startedAtMs } : {}), + ...(event.title !== undefined ? { title: event.title } : {}), + }, + { + args: stringifyToolValue(event.input), + delta: stringifyToolValue(event.input), + state: "input-streaming", + toolCallId: event.toolCallId, + type: AGUIEventType.TOOL_CALL_ARGS, + }, + { + input: event.input, + state: "input-complete", + toolCallId: event.toolCallId, + toolCallName: toolName, + toolName, + type: AGUIEventType.TOOL_CALL_END, + }, + ]; +} + +export function mapOpenClawToolInputDelta(event: { + input?: unknown; + inputTextDelta?: string; + toolCallId: string; + toolName?: string; +}): AGUIEvent[] { + return [ + { + args: event.inputTextDelta ?? stringifyToolValue(event.input), + delta: event.inputTextDelta ?? stringifyToolValue(event.input), + state: "input-streaming", + toolCallId: event.toolCallId, + type: AGUIEventType.TOOL_CALL_ARGS, + }, + ]; } export function mapOpenClawToolOutput(event: { @@ -104,45 +211,45 @@ export function mapOpenClawToolOutput(event: { providerExecuted?: boolean; toolCallId: string; toolName?: string; -}): BeeperUIMessageChunk { - if (event.error !== undefined) { - return stripUndefined({ - dynamic: true, - errorText: errorText(event.error), - preliminary: event.preliminary, - providerExecuted: event.providerExecuted, - completedAtMs: event.completedAtMs, +}): AGUIEvent[] { + const state = event.error !== undefined ? "error" : event.preliminary ? "streaming" : "complete"; + return [ + { + content: stringifyToolValue(event.error !== undefined ? event.error : event.output), + messageId: event.toolCallId, + role: "tool", + state, toolCallId: event.toolCallId, - toolName: event.toolName, - type: "tool-output-error", - }); - } - return stripUndefined({ - dynamic: true, - output: event.output, - preliminary: event.preliminary, - providerExecuted: event.providerExecuted, - completedAtMs: event.completedAtMs, - toolCallId: event.toolCallId, - toolName: event.toolName, - type: "tool-output-available", - }); + type: AGUIEventType.TOOL_CALL_RESULT, + ...(event.completedAtMs !== undefined ? { completedAtMs: event.completedAtMs } : {}), + ...(event.preliminary !== undefined ? { preliminary: event.preliminary } : {}), + ...(event.providerExecuted !== undefined ? { providerExecuted: event.providerExecuted } : {}), + ...(event.toolName ? { toolName: event.toolName } : {}), + }, + ]; } export function mapOpenClawApprovalRequest( state: StreamRunState, event: { approvalId?: string; message?: string; toolCallId?: string; toolName?: string } -): BeeperUIMessageChunk { +): AGUIEvent { const toolCallId = event.toolCallId ?? event.approvalId ?? "approval"; const approvalId = event.approvalId ?? `approval_${toolCallId}`; state.toolCallIdToApprovalId[toolCallId] = approvalId; - return stripUndefined({ - approvalId, - message: event.message, - toolCallId, - toolName: event.toolName, - type: "tool-approval-request", - }); + return { + name: "approval-requested", + type: AGUIEventType.CUSTOM, + value: { + approval: { + id: approvalId, + needsApproval: true, + }, + approvalMessageId: approvalId, + message: event.message, + toolCallId, + toolName: event.toolName, + }, + }; } export function mapOpenClawApprovalResponse(event: { @@ -150,25 +257,27 @@ export function mapOpenClawApprovalResponse(event: { approved: boolean; approvedAlways?: boolean; toolCallId?: string; -}): BeeperUIMessageChunk { - return stripUndefined({ - approvalId: event.approvalId, - approved: event.approved, - approvedAlways: event.approvedAlways, - toolCallId: event.toolCallId, - type: "tool-approval-response", - }); -} - -function stripUndefined>(input: T): T { - for (const key of Object.keys(input)) { - if (input[key] === undefined) delete input[key]; - } - return input; +}): AGUIEvent { + return { + name: "approval-responded", + type: AGUIEventType.CUSTOM, + value: { + approval: { + always: event.approvedAlways, + approved: event.approved, + id: event.approvalId, + }, + toolCallId: event.toolCallId, + }, + }; } -function errorText(error: unknown): string { - if (error instanceof Error) return error.message; - if (typeof error === "string") return error; - return JSON.stringify(error) ?? String(error); +function stringifyToolValue(value: unknown): string { + if (typeof value === "string") return value; + if (value === undefined) return ""; + try { + return JSON.stringify(value); + } catch { + return String(value); + } } diff --git a/packages/openclaw/src/types.ts b/packages/openclaw/src/types.ts index 17c42a9..4fe4389 100644 --- a/packages/openclaw/src/types.ts +++ b/packages/openclaw/src/types.ts @@ -1,5 +1,6 @@ export type OpenClawBindingOwner = "bridge" | "terminal" | "mac-app" | "imported"; export type OpenClawBindingKind = "session" | "agent"; +export type OpenClawImportSource = "dashboard" | "tui" | "channels" | "archived"; export interface OpenClawAgentContact { agentId: string; @@ -39,12 +40,23 @@ export interface OpenClawBridgeConfig { accessToken?: string; allowedRoomIds?: string[]; allowedUserIds?: string[]; + asToken?: string; appserviceId: string; + approvalBehavior?: "native" | "reactions" | "slash" | "disabled"; + backfillLimit?: number; + baseDomain?: string; + beeperEnv?: "production" | "staging" | "dev" | "local"; + bridgeManagerPostState?: boolean; + bridgeManagerToken?: string; + contactVisibility?: "agents" | "agents-and-users" | "none"; dataDir: string; ghostLocalpartPrefix: string; + gatewayAccessToken?: string; gatewayUrl?: string; homeserver?: string; hsToken?: string; + homeserverDomain?: string; + importSources?: OpenClawImportSource[]; matrixDeviceId?: string; matrixUserId?: string; nonFederatedRooms: boolean; @@ -52,6 +64,7 @@ export interface OpenClawBridgeConfig { senderLocalpart: string; serviceBotLocalpart: string; storePath: string; + streamFinalization?: "replace" | "append" | "native-only"; userLocalpartPrefix: string; } diff --git a/packages/openclaw/tsdown.config.ts b/packages/openclaw/tsdown.config.ts index 75cf7e8..24f87ed 100644 --- a/packages/openclaw/tsdown.config.ts +++ b/packages/openclaw/tsdown.config.ts @@ -3,6 +3,6 @@ import { defineConfig } from "tsdown"; export default defineConfig({ clean: true, dts: true, - entry: ["src/approval.ts", "src/appservice.ts", "src/backfill.ts", "src/beeper-setup.ts", "src/bridge-agent.ts", "src/cli.ts", "src/config.ts", "src/connector.ts", "src/index.ts", "src/openclaw-event-map.ts", "src/openclaw-runtime.ts", "src/protocol-coverage.ts", "src/registry.ts", "src/registration.ts", "src/rooms.ts", "src/stream-map.ts", "src/types.ts"], + entry: ["src/approval.ts", "src/appservice.ts", "src/backfill.ts", "src/beeper-stream.ts", "src/beeper-setup.ts", "src/bridge-agent.ts", "src/cli.ts", "src/config.ts", "src/connector.ts", "src/index.ts", "src/openclaw-event-map.ts", "src/openclaw-extension.ts", "src/openclaw-runtime.ts", "src/plugin-entry.ts", "src/protocol-coverage.ts", "src/registry.ts", "src/registration.ts", "src/rooms.ts", "src/serial.ts", "src/setup.ts", "src/setup-entry.ts", "src/stream-map.ts", "src/types.ts"], format: ["esm"], }); diff --git a/packages/openclaw/vitest.config.ts b/packages/openclaw/vitest.config.ts index 63ab85c..6a9a866 100644 --- a/packages/openclaw/vitest.config.ts +++ b/packages/openclaw/vitest.config.ts @@ -3,10 +3,12 @@ import { defineProject } from "vitest/config"; export default defineProject({ resolve: { alias: { + "@beeper/pickle-ag-ui": new URL("../ag-ui/src/index.ts", import.meta.url).pathname, "@beeper/pickle-bridge": new URL("../bridge/src/index.ts", import.meta.url).pathname, "@beeper/pickle-state-file": new URL("../state-file/src/index.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/streams/beeper-message": new URL("../pickle/src/streams/beeper-message.ts", import.meta.url).pathname, "@beeper/pickle": new URL("../pickle/src/index.ts", import.meta.url).pathname, }, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a9502b..31a832c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -222,6 +222,9 @@ importers: '@beeper/pickle': specifier: workspace:* version: link:../pickle + '@beeper/pickle-ag-ui': + specifier: workspace:* + version: link:../ag-ui '@beeper/pickle-bridge': specifier: workspace:* version: link:../bridge From 51d4bdf8c3c7b449fe93f59d26a2c3fab6894fa2 Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Mon, 25 May 2026 02:47:13 +0200 Subject: [PATCH 29/43] Remove gateway token fallback from OpenClaw bridge --- .../bridge/src/appservice-websocket.test.ts | 202 ++++++++++ packages/bridge/src/appservice-websocket.ts | 88 ++++- packages/bridge/src/bridge.test.ts | 91 +++++ packages/bridge/src/bridge.ts | 83 +++++ packages/openclaw/.npmignore | 9 + packages/openclaw/README.md | 17 +- packages/openclaw/openclaw.plugin.json | 276 +++++++++++++- packages/openclaw/package.json | 15 +- packages/openclaw/src/approval.test.ts | 146 ++++++++ packages/openclaw/src/approval.ts | 176 ++++++++- packages/openclaw/src/appservice.test.ts | 72 +++- packages/openclaw/src/appservice.ts | 8 +- packages/openclaw/src/backfill.test.ts | 55 ++- packages/openclaw/src/backfill.ts | 9 +- packages/openclaw/src/beeper-stream.test.ts | 70 +++- packages/openclaw/src/beeper-stream.ts | 84 ++++- packages/openclaw/src/bridge-agent.test.ts | 88 ++++- packages/openclaw/src/bridge-agent.ts | 33 +- packages/openclaw/src/cli.test.ts | 190 +++++++++- packages/openclaw/src/cli.ts | 96 ++++- packages/openclaw/src/config.test.ts | 58 ++- packages/openclaw/src/config.ts | 27 +- packages/openclaw/src/connector.test.ts | 346 ++++++++++++++++-- packages/openclaw/src/connector.ts | 268 ++++++++++++-- packages/openclaw/src/integration.test.ts | 278 +++++++++++++- .../openclaw/src/openclaw-event-map.test.ts | 32 ++ packages/openclaw/src/openclaw-event-map.ts | 39 +- .../openclaw/src/openclaw-extension.test.ts | 109 +++++- packages/openclaw/src/openclaw-extension.ts | 4 +- .../openclaw/src/openclaw-runtime.test.ts | 73 +++- packages/openclaw/src/openclaw-runtime.ts | 265 +++++++++++++- packages/openclaw/src/setup.test.ts | 77 +++- packages/openclaw/src/setup.ts | 100 ++++- packages/openclaw/src/stream-map.ts | 2 + packages/openclaw/src/types.ts | 2 +- .../native/internal/core/appservice_test.go | 61 +++ pnpm-lock.yaml | 8 +- 37 files changed, 3345 insertions(+), 212 deletions(-) create mode 100644 packages/openclaw/.npmignore diff --git a/packages/bridge/src/appservice-websocket.test.ts b/packages/bridge/src/appservice-websocket.test.ts index c4aa237..61109b7 100644 --- a/packages/bridge/src/appservice-websocket.test.ts +++ b/packages/bridge/src/appservice-websocket.test.ts @@ -71,6 +71,208 @@ describe("AppserviceWebsocket", () => { })); }); + it("preserves Matrix edit, reply, thread, mention, and formatted body metadata from appservice transactions", 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 connected = new Promise((resolve, reject) => { + wsServer.on("connection", (socket) => { + socket.once("message", () => resolve()); + socket.send(JSON.stringify({ + command: "transaction", + events: [ + { + content: { + body: "* old", + "m.new_content": { + body: "corrected", + formatted_body: "corrected", + "m.mentions": { room: true, user_ids: ["@bob:example"] }, + msgtype: "m.text", + }, + "m.relates_to": { event_id: "$old", rel_type: "m.replace" }, + msgtype: "m.text", + }, + event_id: "$edit", + room_id: "!room:example", + sender: "@alice:example", + type: "m.room.message", + }, + { + content: { + body: "thread reply", + "m.relates_to": { + event_id: "$thread", + is_falling_back: false, + "m.in_reply_to": { event_id: "$parent" }, + rel_type: "m.thread", + }, + msgtype: "m.text", + }, + event_id: "$thread-reply", + room_id: "!room:example", + sender: "@alice:example", + type: "m.room.message", + }, + ], + id: 11, + txn_id: "txn-relations", + })); + }); + }); + const websocket = createWebsocket(homeserver, { + dispatch, + log: (() => {}) as BridgeLogger, + }); + websockets.push(websocket); + + websocket.start(); + await connected; + + expect(dispatch).toHaveBeenNthCalledWith(1, expect.objectContaining({ + edited: true, + eventId: "$edit", + html: "corrected", + mentions: { room: true, userIds: ["@bob:example"] }, + relation: { eventId: "$old", type: "m.replace" }, + replaces: "$old", + text: "corrected", + })); + expect(dispatch).toHaveBeenNthCalledWith(2, expect.objectContaining({ + edited: false, + eventId: "$thread-reply", + relation: { eventId: "$thread", isFallback: false, replyTo: "$parent", type: "m.thread" }, + replyTo: "$parent", + text: "thread reply", + threadRoot: "$thread", + })); + }); + + it("converts appservice Matrix media messages into attachments", 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 connected = new Promise((resolve) => { + wsServer.on("connection", (socket) => { + socket.once("message", () => resolve()); + socket.send(JSON.stringify({ + command: "transaction", + events: [{ + content: { + body: "photo.png", + info: { + h: 480, + mimetype: "image/png", + size: 12345, + w: 640, + }, + msgtype: "m.image", + url: "mxc://example/photo", + }, + event_id: "$image", + room_id: "!room:example", + sender: "@alice:example", + type: "m.room.message", + }], + id: 12, + txn_id: "txn-media", + })); + }); + }); + const websocket = createWebsocket(homeserver, { + dispatch, + log: (() => {}) as BridgeLogger, + }); + websockets.push(websocket); + + websocket.start(); + await connected; + + expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ + attachments: [{ + contentType: "image/png", + contentUri: "mxc://example/photo", + filename: "photo.png", + height: 480, + kind: "image", + size: 12345, + width: 640, + }], + eventId: "$image", + messageType: "m.image", + text: "photo.png", + })); + }); + + it("converts encrypted appservice Matrix media into encrypted attachments", 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 encryptedFile = { + hashes: { sha256: "hash" }, + iv: "iv", + key: { alg: "A256CTR", ext: true, k: "key", key_ops: ["encrypt", "decrypt"], kty: "oct" }, + url: "mxc://example/encrypted", + v: "v2", + }; + const connected = new Promise((resolve) => { + wsServer.on("connection", (socket) => { + socket.once("message", () => resolve()); + socket.send(JSON.stringify({ + command: "transaction", + events: [{ + content: { + body: "secret.pdf", + file: encryptedFile, + filename: "secret.pdf", + info: { + mimetype: "application/pdf", + size: 777, + }, + msgtype: "m.file", + }, + event_id: "$encrypted-file", + room_id: "!room:example", + sender: "@alice:example", + type: "m.room.message", + }], + id: 13, + txn_id: "txn-encrypted-media", + })); + }); + }); + const websocket = createWebsocket(homeserver, { + dispatch, + log: (() => {}) as BridgeLogger, + }); + websockets.push(websocket); + + websocket.start(); + await connected; + + expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ + attachments: [{ + contentType: "application/pdf", + encryptedFile, + filename: "secret.pdf", + kind: "file", + size: 777, + }], + eventId: "$encrypted-file", + messageType: "m.file", + text: "secret.pdf", + })); + }); + it("forwards appservice transactions before acknowledging them", async () => { const httpServer = createServer(); const wsServer = new WebSocketServer({ server: httpServer }); diff --git a/packages/bridge/src/appservice-websocket.ts b/packages/bridge/src/appservice-websocket.ts index 0655d2a..4906480 100644 --- a/packages/bridge/src/appservice-websocket.ts +++ b/packages/bridge/src/appservice-websocket.ts @@ -408,19 +408,32 @@ function rawMatrixEvent(raw: RawMatrixEvent): MatrixClientEvent | null { const senderId = raw.sender; const sender = senderId ? { isMe: false, userId: senderId } : undefined; if (type === "m.room.message" && roomId && eventId && sender) { + const relates = objectValue(content["m.relates_to"]); + const newContent = objectValue(content["m.new_content"]); + const messageContent = newContent ?? content; + const relation = matrixRelation(relates); + const replyTo = matrixReplyTo(relates); + const threadRoot = relation?.type === "m.thread" ? relation.eventId : undefined; + const mentions = matrixMentions(messageContent); return stripUndefined({ - attachments: [], + attachments: matrixAttachments(messageContent), class: "message", content, - edited: false, + edited: Boolean(newContent && relation?.type === "m.replace"), encrypted: false, eventId, + html: stringValue(messageContent.formatted_body), kind: "message", - messageType: stringValue(content.msgtype) ?? "m.text", + mentions, + messageType: stringValue(messageContent.msgtype) ?? "m.text", raw, + relation, + replaces: relation?.type === "m.replace" ? relation.eventId : undefined, + replyTo, roomId, sender, - text: stringValue(content.body) ?? "", + text: stringValue(messageContent.body) ?? "", + threadRoot, timestamp: raw.origin_server_ts, type, unsigned: raw.unsigned, @@ -477,6 +490,73 @@ function stringValue(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } +function matrixRelation(relates: Record | undefined): Record | undefined { + const eventId = stringValue(relates?.event_id); + const type = stringValue(relates?.rel_type); + if (!eventId || !type) return undefined; + if (type === "m.annotation") { + const key = stringValue(relates?.key); + return key ? { eventId, key, type } : undefined; + } + if (type === "m.thread") { + return { + eventId, + ...(typeof relates?.is_falling_back === "boolean" ? { isFallback: relates.is_falling_back } : {}), + ...(stringValue(objectValue(relates?.["m.in_reply_to"])?.event_id) ? { replyTo: stringValue(objectValue(relates?.["m.in_reply_to"])?.event_id) } : {}), + type, + }; + } + if (type === "m.replace" || type === "m.reference") return { eventId, type }; + return { eventId, type }; +} + +function matrixReplyTo(relates: Record | undefined): string | undefined { + return stringValue(objectValue(relates?.["m.in_reply_to"])?.event_id) + ?? (relates?.rel_type === "m.thread" ? stringValue(relates.event_id) : undefined); +} + +function matrixMentions(content: Record): Record | undefined { + const raw = objectValue(content["m.mentions"]); + if (!raw) return undefined; + const userIds = Array.isArray(raw.user_ids) ? raw.user_ids.filter((userId): userId is string => typeof userId === "string") : undefined; + return stripUndefined({ + room: typeof raw.room === "boolean" ? raw.room : undefined, + userIds, + }); +} + +function matrixAttachments(content: Record): Record[] { + const msgtype = stringValue(content.msgtype); + const kind = matrixAttachmentKind(msgtype); + if (!kind) return []; + const info = objectValue(content.info); + const encryptedFile = objectValue(content.file); + const attachment = stripUndefined({ + contentType: stringValue(info?.mimetype) ?? stringValue(content.info_mimetype), + contentUri: stringValue(content.url), + duration: numberValue(info?.duration), + encryptedFile, + filename: stringValue(content.filename) ?? stringValue(content.body), + height: numberValue(info?.h), + kind, + size: numberValue(info?.size), + width: numberValue(info?.w), + }); + return attachment.contentUri || attachment.encryptedFile ? [attachment] : []; +} + +function matrixAttachmentKind(msgtype: string | undefined): "image" | "video" | "audio" | "file" | undefined { + if (msgtype === "m.image") return "image"; + if (msgtype === "m.video") return "video"; + if (msgtype === "m.audio") return "audio"; + if (msgtype === "m.file") return "file"; + return undefined; +} + +function numberValue(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + function stripUndefined>(value: T): T { for (const key of Object.keys(value)) { if (value[key] === undefined) delete value[key]; diff --git a/packages/bridge/src/bridge.test.ts b/packages/bridge/src/bridge.test.ts index 79885fc..c07c283 100644 --- a/packages/bridge/src/bridge.test.ts +++ b/packages/bridge/src/bridge.test.ts @@ -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(); @@ -925,14 +1010,20 @@ function createFakeConnector(network: FakeNetworkAPI): BridgeConnector & { type FakeNetworkAPI = NetworkAPI & { connect: ReturnType; disconnect: ReturnType; + handleMatrixEdit: ReturnType; handleMatrixMessage: ReturnType; + handleMatrixReaction: ReturnType; + handleMatrixReactionRemove: ReturnType; }; function createFakeNetworkAPI(): FakeNetworkAPI { return { connect: vi.fn(), disconnect: vi.fn(), + handleMatrixEdit: vi.fn(), handleMatrixMessage: vi.fn(), + handleMatrixReaction: vi.fn(), + handleMatrixReactionRemove: vi.fn(), }; } diff --git a/packages/bridge/src/bridge.ts b/packages/bridge/src/bridge.ts index 094ee0b..741e16d 100644 --- a/packages/bridge/src/bridge.ts +++ b/packages/bridge/src/bridge.ts @@ -35,8 +35,10 @@ import type { DownloadMediaResult, Ghost, MatrixDispatchResult, + MatrixEdit, MatrixMessage, MatrixReaction, + MatrixReactionRemove, MatrixRedaction, MatrixTyping, EventSender, @@ -671,9 +673,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")) { @@ -905,6 +909,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) { @@ -1102,6 +1142,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) { @@ -1112,6 +1173,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)) { @@ -1381,6 +1443,27 @@ function isGenericEvent(event: MatrixClientEvent, kind: string): event is Generi return event.kind === kind && "content" in event && typeof event.content === "object" && event.content !== null; } +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 hasMethod(value: object, method: T): value is object & Record unknown> { return method in value && typeof (value as Record)[method] === "function"; } 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/README.md b/packages/openclaw/README.md index df0533e..a8cf8a3 100644 --- a/packages/openclaw/README.md +++ b/packages/openclaw/README.md @@ -21,8 +21,13 @@ OpenClaw loads the runtime entry from `dist/plugin-entry.mjs` and the lightweigh - OpenClaw WebSocket Gateway transport using protocol v4 `req`/`res`/`event` frames. - Compatibility HTTP/SSE transport for gateway-like test or proxy deployments. - Agent ghosts for OpenClaw agents and user ghosts for imported one-to-one sessions. +- 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. +- Matrix slash commands: `/new`, `/agent`, `/sessions`, `/import`, `/backfill`, `/stop`, `/approve`, `/deny`, `/status`, and `/settings`. `/abort` is accepted as a compatibility alias for `/stop`. +- Native Beeper stream publishing for reasoning, text, tool input/output, approvals, errors, aborts, and final replacement messages. +- Native approval UI parsing first, with reactions and `/approve`/`/deny` as escape hatches. - Non-federated Matrix room creation defaults through the generated appservice registration. -- Backfill helpers for terminal, mac app, and external one-to-one OpenClaw sessions. +- Opt-in backfill/import helpers for dashboard, TUI, channel-origin, and archived one-to-one OpenClaw sessions. ## CLI @@ -76,6 +81,16 @@ pickle-openclaw start \ --backfill-limit 500 ``` +Run a non-daemon smoke check before handing the bridge to OpenClaw: + +```sh +pickle-openclaw smoke --config ~/.openclaw/pickle-bridge/config.json +``` + +The smoke command validates the saved Beeper account shape, probes the Gateway feature surface, lists agents and recent sessions, and creates the Beeper bridge in `getOnly` mode. Use `--gateway-only` to skip Beeper setup checks or `--start` when you explicitly want the command to start and then stop the bridge object. + +Installed OpenClaw plugins run inside OpenClaw directly. The CLI gateway URL option is only for smoke/debug commands that explicitly probe a local gateway surface. + Probe or call the Gateway surface directly: ```sh diff --git a/packages/openclaw/openclaw.plugin.json b/packages/openclaw/openclaw.plugin.json index ad14c8a..49457df 100644 --- a/packages/openclaw/openclaw.plugin.json +++ b/packages/openclaw/openclaw.plugin.json @@ -5,7 +5,9 @@ "activation": { "onStartup": true }, - "channels": ["beeper"], + "channels": [ + "beeper" + ], "channelEnvVars": { "beeper": [ "PICKLE_OPENCLAW_ACCESS_TOKEN", @@ -13,6 +15,7 @@ "PICKLE_OPENCLAW_ALLOW_USERS", "PICKLE_OPENCLAW_AS_TOKEN", "PICKLE_OPENCLAW_APP_SERVICE_ID", + "PICKLE_OPENCLAW_APPSERVICE_ID", "PICKLE_OPENCLAW_APPROVAL_BEHAVIOR", "PICKLE_OPENCLAW_BACKFILL_LIMIT", "PICKLE_OPENCLAW_BASE_DOMAIN", @@ -21,8 +24,8 @@ "PICKLE_OPENCLAW_BRIDGE_MANAGER_TOKEN", "PICKLE_OPENCLAW_CONTACT_VISIBILITY", "PICKLE_OPENCLAW_DATA_DIR", - "PICKLE_OPENCLAW_GATEWAY_ACCESS_TOKEN", "PICKLE_OPENCLAW_GATEWAY_URL", + "PICKLE_OPENCLAW_GHOST_LOCALPART_PREFIX", "PICKLE_OPENCLAW_HOMESERVER", "PICKLE_OPENCLAW_HOMESERVER_DOMAIN", "PICKLE_OPENCLAW_HS_TOKEN", @@ -31,6 +34,10 @@ "PICKLE_OPENCLAW_MATRIX_USER_ID", "PICKLE_OPENCLAW_NON_FEDERATED_ROOMS", "PICKLE_OPENCLAW_REGISTRATION_URL", + "PICKLE_OPENCLAW_SENDER_LOCALPART", + "PICKLE_OPENCLAW_SERVICE_BOT_LOCALPART", + "PICKLE_OPENCLAW_STORE_PATH", + "PICKLE_OPENCLAW_USER_LOCALPART_PREFIX", "PICKLE_OPENCLAW_STREAM_FINALIZATION" ] }, @@ -54,11 +61,6 @@ "label": "Bridge Manager Token", "help": "Optional Beeper bridge-manager token used to register the self-hosted bridge.", "sensitive": true - }, - "gatewayAccessToken": { - "label": "OpenClaw Gateway Token", - "help": "Optional bearer token for the local OpenClaw gateway.", - "sensitive": true } }, "configSchema": { @@ -77,6 +79,10 @@ "type": "string", "description": "Appservice token returned by Beeper bridge registration." }, + "appserviceId": { + "type": "string", + "description": "Matrix appservice id used in registration namespaces." + }, "dataDir": { "type": "string", "description": "Directory for bridge config, registration, and runtime state." @@ -85,10 +91,6 @@ "type": "string", "description": "Public or LAN callback URL for the Matrix appservice." }, - "gatewayAccessToken": { - "type": "string", - "description": "Optional bearer token for the local OpenClaw gateway." - }, "gatewayUrl": { "type": "string", "description": "OpenClaw gateway URL used by the bridge runtime." @@ -111,19 +113,28 @@ }, "allowedRoomIds": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "description": "Optional allow-list of Matrix rooms the bridge may import from." }, "allowedUserIds": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "description": "Optional allow-list of Matrix users the bridge may accept commands from." }, "importSources": { "type": "array", "items": { "type": "string", - "enum": ["dashboard", "tui", "channels", "archived"] + "enum": [ + "dashboard", + "tui", + "channels", + "archived" + ] }, "description": "OpenClaw session sources to import and backfill." }, @@ -137,7 +148,12 @@ }, "beeperEnv": { "type": "string", - "enum": ["production", "staging", "dev", "local"], + "enum": [ + "production", + "staging", + "dev", + "local" + ], "description": "Beeper environment for login and appservice registration." }, "bridgeManagerToken": { @@ -156,21 +172,245 @@ "type": "string", "description": "Homeserver domain advertised in the Beeper appservice registration." }, + "ghostLocalpartPrefix": { + "type": "string", + "description": "Localpart prefix for deterministic OpenClaw ghost users." + }, + "senderLocalpart": { + "type": "string", + "description": "Localpart for the Beeper bridge sender user." + }, + "serviceBotLocalpart": { + "type": "string", + "description": "Localpart for the OpenClaw service bot user." + }, + "storePath": { + "type": "string", + "description": "Path for Matrix client store state." + }, + "userLocalpartPrefix": { + "type": "string", + "description": "Localpart prefix for imported OpenClaw user ghosts." + }, "contactVisibility": { "type": "string", - "enum": ["agents", "agents-and-users", "none"], + "enum": [ + "agents", + "agents-and-users", + "none" + ], "description": "Which OpenClaw identities should appear in Beeper contacts." }, "streamFinalization": { "type": "string", - "enum": ["replace", "append", "native-only"], + "enum": [ + "replace", + "append", + "native-only" + ], "description": "How native Beeper stream output is finalized." }, "approvalBehavior": { "type": "string", - "enum": ["native", "reactions", "slash", "disabled"], + "enum": [ + "native", + "reactions", + "slash", + "disabled" + ], "description": "How Beeper approval decisions resolve OpenClaw approval gates." } } + }, + "channelConfigs": { + "beeper": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable the Beeper bridge channel." + }, + "accessToken": { + "type": "string", + "description": "Beeper Matrix access token returned by login." + }, + "asToken": { + "type": "string", + "description": "Appservice token returned by Beeper bridge registration." + }, + "appserviceId": { + "type": "string", + "description": "Matrix appservice id used in registration namespaces." + }, + "dataDir": { + "type": "string", + "description": "Directory for bridge config, registration, and runtime state." + }, + "registrationUrl": { + "type": "string", + "description": "Public or LAN callback URL for the Matrix appservice." + }, + "gatewayUrl": { + "type": "string", + "description": "OpenClaw gateway URL used by the bridge runtime." + }, + "homeserver": { + "type": "string", + "description": "Beeper Matrix homeserver URL returned by login." + }, + "hsToken": { + "type": "string", + "description": "Homeserver token returned by Beeper bridge registration." + }, + "matrixDeviceId": { + "type": "string", + "description": "Beeper Matrix device id for this bridge." + }, + "matrixUserId": { + "type": "string", + "description": "Beeper Matrix user id for this bridge." + }, + "allowedRoomIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Optional allow-list of Matrix rooms the bridge may import from." + }, + "allowedUserIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Optional allow-list of Matrix users the bridge may accept commands from." + }, + "importSources": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "dashboard", + "tui", + "channels", + "archived" + ] + }, + "description": "OpenClaw session sources to import and backfill." + }, + "backfillLimit": { + "type": "number", + "description": "Maximum OpenClaw messages to backfill per imported session." + }, + "nonFederatedRooms": { + "type": "boolean", + "description": "Create Matrix rooms with non-federated room creation content where supported." + }, + "beeperEnv": { + "type": "string", + "enum": [ + "production", + "staging", + "dev", + "local" + ], + "description": "Beeper environment for login and appservice registration." + }, + "bridgeManagerToken": { + "type": "string", + "description": "Beeper bridge-manager token used to register the self-hosted bridge." + }, + "bridgeManagerPostState": { + "type": "boolean", + "description": "Post Beeper bridge state after registering the self-hosted bridge." + }, + "baseDomain": { + "type": "string", + "description": "Beeper API base domain for non-production environments." + }, + "homeserverDomain": { + "type": "string", + "description": "Homeserver domain advertised in the Beeper appservice registration." + }, + "ghostLocalpartPrefix": { + "type": "string", + "description": "Localpart prefix for deterministic OpenClaw ghost users." + }, + "senderLocalpart": { + "type": "string", + "description": "Localpart for the Beeper bridge sender user." + }, + "serviceBotLocalpart": { + "type": "string", + "description": "Localpart for the OpenClaw service bot user." + }, + "storePath": { + "type": "string", + "description": "Path for Matrix client store state." + }, + "userLocalpartPrefix": { + "type": "string", + "description": "Localpart prefix for imported OpenClaw user ghosts." + }, + "contactVisibility": { + "type": "string", + "enum": [ + "agents", + "agents-and-users", + "none" + ], + "description": "Which OpenClaw identities should appear in Beeper contacts." + }, + "streamFinalization": { + "type": "string", + "enum": [ + "replace", + "append", + "native-only" + ], + "description": "How native Beeper stream output is finalized." + }, + "approvalBehavior": { + "type": "string", + "enum": [ + "native", + "reactions", + "slash", + "disabled" + ], + "description": "How Beeper approval decisions resolve OpenClaw approval gates." + } + } + }, + "uiHints": { + "accessToken": { + "label": "Beeper Access Token", + "help": "Beeper Matrix access token returned by login.", + "sensitive": true + }, + "hsToken": { + "label": "Homeserver Token", + "help": "Homeserver token returned by Beeper bridge registration.", + "sensitive": true + }, + "asToken": { + "label": "Appservice Token", + "help": "Appservice token returned by Beeper bridge registration.", + "sensitive": true + }, + "bridgeManagerToken": { + "label": "Bridge Manager Token", + "help": "Optional Beeper bridge-manager token used to register the self-hosted bridge.", + "sensitive": true + } + }, + "label": "Beeper", + "description": "Bridge OpenClaw sessions and agents into Beeper.", + "commands": { + "nativeCommandsAutoEnabled": true, + "nativeSkillsAutoEnabled": true + } + } } } diff --git a/packages/openclaw/package.json b/packages/openclaw/package.json index f2316bd..6011527 100644 --- a/packages/openclaw/package.json +++ b/packages/openclaw/package.json @@ -147,10 +147,10 @@ "clawhubSpec": "clawhub:@beeper/pickle-openclaw@0.1.0", "npmSpec": "@beeper/pickle-openclaw@0.1.0", "defaultChoice": "clawhub", - "minHostVersion": ">=2026.5.24" + "minHostVersion": ">=2026.5.22" }, "compat": { - "pluginApi": ">=2026.5.24" + "pluginApi": ">=2026.5.22" }, "build": { "openclawVersion": "2026.5.24" @@ -166,14 +166,15 @@ "scripts": { "build": "tsdown", "clean": "rm -rf dist", + "prepublishOnly": "node ../../scripts/guard-pnpm-publish.mjs", "test": "vitest run --coverage", "typecheck": "tsc --noEmit" }, "dependencies": { - "@beeper/pickle": "workspace:*", - "@beeper/pickle-ag-ui": "workspace:*", - "@beeper/pickle-bridge": "workspace:*", - "@beeper/pickle-state-file": "workspace:*" + "@beeper/pickle": "workspace:^", + "@beeper/pickle-ag-ui": "workspace:^", + "@beeper/pickle-bridge": "workspace:^", + "@beeper/pickle-state-file": "workspace:^" }, "devDependencies": { "@types/node": "^20.0.0", @@ -183,7 +184,7 @@ "vitest": "^4.0.18" }, "peerDependencies": { - "openclaw": ">=2026.5.24" + "openclaw": ">=2026.5.22" }, "peerDependenciesMeta": { "openclaw": { diff --git a/packages/openclaw/src/approval.test.ts b/packages/openclaw/src/approval.test.ts index 6f70fdd..309067b 100644 --- a/packages/openclaw/src/approval.test.ts +++ b/packages/openclaw/src/approval.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; import { + createBeeperApprovalNotice, + defaultBeeperApprovalChoices, parseApprovalReactionContent, parseApprovalResponseContent, parseToolApprovalResponseChunk, @@ -30,6 +32,106 @@ describe("OpenClaw approval response parsing", () => { }); }); + it("preserves plugin approval kind from native content and reactions", () => { + const reaction = parseApprovalReactionContent({ + approvalKind: "plugin", + "m.relates_to": { + event_id: "plugin:approval_1", + key: "✅", + 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("also accepts ai-bridge/OpenClaw Matrix approval choice keys and emoji as fallback reactions", () => { + expect(parseApprovalReactionContent({ + "m.relates_to": { + event_id: "approval_ai_1", + key: "✅", + }, + })).toMatchObject({ + approvalId: "approval_ai_1", + approved: true, + approvedAlways: false, + decision: "allow_once", + }); + + expect(parseApprovalReactionContent({ + "m.relates_to": { + event_id: "approval_ai_2", + key: "always_approve", + }, + })).toMatchObject({ + approvalId: "approval_ai_2", + approved: true, + approvedAlways: true, + decision: "allow_always", + }); + + expect(parseApprovalReactionContent({ + "m.relates_to": { + event_id: "approval_ai_3", + key: "❌", + }, + })).toMatchObject({ + approvalId: "approval_ai_3", + approved: false, + approvedAlways: false, + decision: "deny", + }); + }); + + 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", + })).toEqual({ + 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", @@ -88,4 +190,48 @@ describe("OpenClaw approval response parsing", () => { 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: "dynamic-tool", + }, + ], + }, + })).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 index 9b94053..d909529 100644 --- a/packages/openclaw/src/approval.ts +++ b/packages/openclaw/src/approval.ts @@ -4,11 +4,25 @@ 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; @@ -17,11 +31,37 @@ export interface ParsedApprovalResponse { 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 parseApprovalReactionKey(key: unknown): ParsedApprovalResponse | undefined { + const aiBridgeChoice = resolveBeeperApprovalChoiceKey(key); + if (aiBridgeChoice) { + return approvalResponseForChoice(aiBridgeChoice); + } switch (key) { case APPROVAL_ALLOW_ONCE_REACTION: return { approved: true, approvedAlways: false, decision: "allow_once" }; @@ -43,14 +83,17 @@ export function parseApprovalReactionContent(content: unknown): ParsedApprovalRe 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"; @@ -60,14 +103,19 @@ export function parseToolApprovalResponseChunk(chunk: unknown): ParsedApprovalRe 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) ?? parseApprovalReactionContent(content); + return parseToolApprovalResponseChunk(content) + ?? parseApprovalResponseFromDeltas(content) + ?? parseApprovalResponseFromAIMessage(content) + ?? parseApprovalReactionContent(content); } export function toOpenClawApprovalResolvePayload( @@ -76,12 +124,41 @@ export function toOpenClawApprovalResolvePayload( ): 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; + toolCallId?: string; + toolName?: string; + choices?: readonly BeeperApprovalChoice[]; +}): Record { + return stripUndefined({ + choices: approvalChoicesAsAny(params.choices), + id: params.approvalId, + messageId: params.messageId, + schema: "com.beeper.ai.approval.v1", + state: "requested", + toolCallId: params.toolCallId, + toolName: params.toolName, + }); +} + function parseApprovalResponseFromDeltas(content: unknown): ParsedApprovalResponse | undefined { const deltas = recordValue(content)?.["com.beeper.llm.deltas"]; if (!Array.isArray(deltas)) return undefined; @@ -96,6 +173,52 @@ function parseApprovalResponseFromDeltas(content: unknown): ParsedApprovalRespon 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": @@ -112,11 +235,58 @@ function approvalDecisionValue(value: unknown): ApprovalDecision | undefined { return "allow_session"; case "allow-room": return "allow_room"; + case "allow": + return "allow_once"; + case "always": + return "allow_always"; default: return undefined; } } +function approvalResponseForChoice(choiceKey: string): ParsedApprovalResponse | undefined { + switch (choiceKey) { + case AI_BRIDGE_APPROVAL_CHOICE_APPROVE: + return { approved: true, approvedAlways: false, decision: "allow_once" }; + case AI_BRIDGE_APPROVAL_CHOICE_ALWAYS_APPROVE: + return { approved: true, approvedAlways: true, decision: "allow_always" }; + case AI_BRIDGE_APPROVAL_CHOICE_DENY: + return { approved: false, approvedAlways: false, decision: "deny" }; + default: + return undefined; + } +} + +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 resolveBeeperApprovalChoiceKey(key: unknown): string | undefined { + if (typeof key !== "string") return undefined; + const normalized = normalizeReactionKey(key); + if (!normalized) return undefined; + for (const choice of defaultBeeperApprovalChoices()) { + if (normalizeReactionKey(choice.key) === normalized || normalizeReactionKey(choice.alias) === normalized) { + return choice.key; + } + } + if (normalized === "♾") return AI_BRIDGE_APPROVAL_CHOICE_ALWAYS_APPROVE; + return undefined; +} + +function normalizeReactionKey(key: string): string { + return key.trim().replace(/\ufe0f/gu, "").toLowerCase(); +} + function recordValue(value: unknown): Record | undefined { if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; return value as Record; @@ -125,3 +295,7 @@ function recordValue(value: unknown): Record | undefined { 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 index e23abd9..5f7c246 100644 --- a/packages/openclaw/src/appservice.test.ts +++ b/packages/openclaw/src/appservice.test.ts @@ -2,6 +2,8 @@ import type { CreateNodeBeeperBridgeOptions, PickleBridge } from "@beeper/pickle import { describe, expect, it, vi } from "vitest"; import { createDefaultConfig } from "./config"; import { accountFromOpenClawConfig, createOpenClawBeeperBridge, startOpenClawBeeperBridge } from "./appservice"; +import { OpenClawGatewayRuntime, type OpenClawTransport } from "./openclaw-runtime"; +import { OpenClawBridgeRegistry } from "./registry"; describe("OpenClaw Beeper appservice runtime", () => { it("creates a Pickle Beeper bridge with the OpenClaw connector defaults", async () => { @@ -51,11 +53,63 @@ describe("OpenClaw Beeper appservice runtime", () => { expect(bridge.start).toHaveBeenCalledOnce(); }); + it("runs startup backfill with the configured import source scope", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-appservice-backfill-test.json"); + const bridge = fakeBridge({ registry }); + bridge.createPortal = vi.fn(async (_login, options) => ({ + id: options.id, + mxid: "!desktop:example.com", + portalKey: { id: options.id, receiver: "login" }, + receiver: "login", + })); + bridge.backfillPortal = vi.fn(async () => ({ eventIds: [] })); + const config = createDefaultConfig({ + accessToken: "mx-token", + dataDir: "/tmp/openclaw", + gatewayUrl: "ws://gateway", + homeserver: "https://matrix.beeper.com", + importSources: ["dashboard"], + matrixDeviceId: "DEVICE", + matrixUserId: "@batuhan:beeper.com", + }); + const runtime = runtimeWith({ + responses: { + "chat.history": { messages: [] }, + "sessions.list": { + sessions: [ + { displayName: "Desktop", key: "agent:codex:desktop", origin: { surface: "mac-app" } }, + { displayName: "Terminal", key: "agent:codex:tui", origin: { surface: "terminal" } }, + ], + }, + }, + }); + + await expect(startOpenClawBeeperBridge({ + account: account(), + backfill: true, + backfillLimit: 3, + bridgeFactory: async () => bridge, + config, + registry, + runtimeFactory: () => runtime, + })).resolves.toBe(bridge); + + expect(bridge.createPortal).toHaveBeenCalledOnce(); + expect(bridge.createPortal).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + id: "session:YWdlbnQ6Y29kZXg6ZGVza3RvcA", + name: "Desktop", + })); + expect(bridge.backfillPortal).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + mxid: "!desktop:example.com", + }), { limit: 3 }); + expect(registry.getBindingBySessionKey("agent:codex:desktop")).toBeDefined(); + expect(registry.getBindingBySessionKey("agent:codex:tui")).toBeUndefined(); + }); + it("recreates the Beeper Matrix account from persisted setup config", () => { expect(accountFromOpenClawConfig(createDefaultConfig({ accessToken: "mx-token", dataDir: "/tmp/openclaw", - gatewayAccessToken: "gateway-token", homeserver: "https://matrix.beeper.com", matrixDeviceId: "DEVICE", matrixUserId: "@batuhan:beeper.com", @@ -72,9 +126,23 @@ function account() { }; } -function fakeBridge(): PickleBridge { +function fakeBridge(options: { registry?: OpenClawBridgeRegistry } = {}): PickleBridge { return { + connector: options.registry ? { registry: options.registry } : undefined, start: vi.fn(), stop: vi.fn(), } as unknown as PickleBridge; } + +function runtimeWith(options: { + responses: Record; +}): OpenClawGatewayRuntime & { transport: OpenClawTransport & { request: ReturnType } } { + const transport = { + async *events() {}, + request: vi.fn(async (method: string) => options.responses[method]), + }; + return new OpenClawGatewayRuntime({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + transport, + }) as OpenClawGatewayRuntime & { transport: OpenClawTransport & { request: ReturnType } }; +} diff --git a/packages/openclaw/src/appservice.ts b/packages/openclaw/src/appservice.ts index d2885ef..8f25670 100644 --- a/packages/openclaw/src/appservice.ts +++ b/packages/openclaw/src/appservice.ts @@ -55,13 +55,15 @@ export async function startOpenClawBeeperBridge(options: CreateOpenClawBeeperBri const registry = options.registry ?? registryFromConnector(bridge.connector); if (!registry) throw new Error("OpenClaw backfill requires registry"); const login = userLoginFromOpenClawConfig(config); - await backfillAllOpenClawSessions({ + const backfillOptions: Parameters[0] = { bridge, - ...(options.backfillLimit !== undefined ? { limit: options.backfillLimit } : {}), login, registry, runtime: options.runtimeFactory?.(login, config) ?? createOpenClawRuntimeFromLogin(login, config), - }); + }; + if (config.importSources !== undefined) backfillOptions.importSources = config.importSources; + if (options.backfillLimit !== undefined) backfillOptions.limit = options.backfillLimit; + await backfillAllOpenClawSessions(backfillOptions); await registry.save(); } return bridge; diff --git a/packages/openclaw/src/backfill.test.ts b/packages/openclaw/src/backfill.test.ts index 8de7dd2..c819c57 100644 --- a/packages/openclaw/src/backfill.test.ts +++ b/packages/openclaw/src/backfill.test.ts @@ -1,3 +1,6 @@ +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { backfillAllOpenClawSessions, buildBackfillImport, discoverOneToOneSessions, isOneToOneSession, shouldImportSession } from "./backfill"; import { createDefaultConfig } from "./config"; @@ -136,13 +139,16 @@ describe("OpenClaw backfill", () => { expect(shouldImportSession({ key: "agent:main:desktop:abc", origin: { surface: "mac-app" } }, ["dashboard"])).toBe(true); expect(shouldImportSession({ key: "agent:main:whatsapp:alice", lastProvider: "whatsapp" }, ["channels"])).toBe(true); expect(shouldImportSession({ key: "agent:main:terminal:old", origin: { surface: "terminal" }, updatedAt: null }, ["tui"])).toBe(false); + expect(shouldImportSession({ key: "agent:main:terminal:old", origin: { surface: "terminal" }, updatedAt: null }, ["archived"])).toBe(true); expect(shouldImportSession({ key: "agent:main:terminal:old", origin: { surface: "terminal" }, updatedAt: null }, ["tui", "archived"])).toBe(true); + expect(shouldImportSession({ key: "agent:main:desktop:old", origin: { surface: "mac-app" }, updatedAt: null }, ["dashboard"])).toBe(false); expect(shouldImportSession({ key: "agent:main:desktop:abc", origin: { surface: "mac-app" } }, ["tui"])).toBe(false); const runtime = runtimeWith({ "sessions.list": { sessions: [ { key: "agent:main:terminal:local", origin: { surface: "terminal" } }, + { key: "agent:main:terminal:archived", origin: { surface: "terminal" }, updatedAt: null }, { key: "agent:main:desktop:abc", origin: { surface: "mac-app" } }, { chatType: "dm", key: "agent:main:whatsapp:user-1", lastProvider: "whatsapp", lastTo: "user-1" }, ], @@ -151,6 +157,9 @@ describe("OpenClaw backfill", () => { await expect(discoverOneToOneSessions(runtime, { importSources: ["dashboard"] })).resolves.toMatchObject([ { sessionKey: "agent:main:desktop:abc", source: "mac-app" }, ]); + await expect(discoverOneToOneSessions(runtime, { importSources: ["archived"] })).resolves.toMatchObject([ + { sessionKey: "agent:main:terminal:archived", source: "terminal" }, + ]); }); it("creates portals and imports every discovered one-to-one session", async () => { @@ -162,7 +171,9 @@ describe("OpenClaw backfill", () => { ], }, }); - const registry = new OpenClawBridgeRegistry("/tmp/openclaw-backfill-test.json"); + const dir = await mkdtemp(join(tmpdir(), "openclaw-backfill-test-")); + const registryPath = join(dir, "registry.json"); + const registry = new OpenClawBridgeRegistry(registryPath); const bridge = { backfillPortal: vi.fn(async () => ({ eventIds: [] })), createPortal: vi.fn(async () => ({ @@ -207,6 +218,12 @@ describe("OpenClaw backfill", () => { }), { limit: 25 }); expect(registry.getUser("alice")?.ghostUserId).toBe("@openclaw_user_alice:localhost"); expect(registry.getBindingByRoom("!room:example.com")?.humanGhostUserId).toBe("@openclaw_user_alice:localhost"); + const persisted = new OpenClawBridgeRegistry(registryPath); + await persisted.load(); + expect(persisted.getBindingBySessionKey("agent:codex:whatsapp:alice")).toMatchObject({ + humanGhostUserId: "@openclaw_user_alice:localhost", + roomId: "!room:example.com", + }); }); it("skips already-imported sessions instead of creating duplicate portals", async () => { @@ -294,6 +311,42 @@ describe("OpenClaw backfill", () => { expect(registry.getBindingBySessionKey("agent:codex:terminal:alice")).toBeUndefined(); }); + it("does not mark a session imported when Matrix backfill fails", async () => { + const runtime = runtimeWith({ + "chat.history": { messages: [{ content: "hello", id: "m1", role: "user" }] }, + "sessions.list": { + sessions: [ + { agentId: "codex", chatType: "dm", displayName: "Alice", key: "agent:codex:terminal:alice", origin: { surface: "terminal" } }, + ], + }, + }); + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-backfill-failure-test.json"); + const bridge = { + backfillPortal: vi.fn(async () => { + throw new Error("batch send failed"); + }), + createPortal: vi.fn(async () => ({ + id: "session:created", + mxid: "!room:example.com", + portalKey: { id: "session:created", receiver: "login" }, + receiver: "login", + })), + }; + const login = { id: "login", userId: "@owner:example.com" }; + + await expect(backfillAllOpenClawSessions({ + bridge: bridge as never, + importSources: ["tui"], + login, + registry, + runtime, + })).rejects.toThrow("batch send failed"); + + expect(bridge.createPortal).toHaveBeenCalledOnce(); + expect(bridge.backfillPortal).toHaveBeenCalledOnce(); + expect(registry.getBindingBySessionKey("agent:codex:terminal:alice")).toBeUndefined(); + }); + it("omits non-federation creation content when federated rooms are enabled", async () => { const runtime = runtimeWith({ "chat.history": { messages: [] }, diff --git a/packages/openclaw/src/backfill.ts b/packages/openclaw/src/backfill.ts index f62950b..57f8f43 100644 --- a/packages/openclaw/src/backfill.ts +++ b/packages/openclaw/src/backfill.ts @@ -143,15 +143,14 @@ export async function backfillAllOpenClawSessions(options: BackfillAllOpenClawSe } const importOptions: { limit?: number; roomId: string } = { roomId: portal.mxid }; if (options.limit !== undefined) importOptions.limit = options.limit; - const imported = await buildBackfillImport(options.runtime, options.runtime.config, session, { - ...importOptions, - }); - options.registry.upsertBinding(imported.binding); + const imported = await buildBackfillImport(options.runtime, options.runtime.config, session, importOptions); await options.bridge.backfillPortal(options.login, portal, { ...(options.limit !== undefined ? { limit: options.limit } : {}), }); + options.registry.upsertBinding(imported.binding); importedSessions.push(session); } + await options.registry.save(); return { portals, sessions: importedSessions, skipped }; } @@ -173,7 +172,7 @@ export function shouldImportSession( ): boolean { if (!importSources || importSources.length === 0) return false; const normalized = new Set(importSources); - if (session.updatedAt === null && !normalized.has("archived")) return false; + if (session.updatedAt === null) return normalized.has("archived"); const source = sessionSource(session); if (source === "terminal") return normalized.has("tui"); if (source === "mac-app") return normalized.has("dashboard"); diff --git a/packages/openclaw/src/beeper-stream.test.ts b/packages/openclaw/src/beeper-stream.test.ts index 3a87a96..6ce6954 100644 --- a/packages/openclaw/src/beeper-stream.test.ts +++ b/packages/openclaw/src/beeper-stream.test.ts @@ -27,6 +27,19 @@ describe("OpenClaw Beeper native stream publisher", () => { parts: [], role: "assistant", }, + "com.beeper.ai.metadata": expect.objectContaining({ + data: { agent_id: "codex" }, + model: "openclaw/gateway", + protocol: "ag-ui", + runId: "turn_1", + schema: "com.beeper.ai.run.v1", + status: { state: "streaming" }, + threadId: "turn_1", + }), + "com.beeper.stream": { + type: "com.beeper.llm", + user_id: "@openclaw_agent_codex:example.com", + }, msgtype: "m.text", }, roomId: "!room:example.com", @@ -44,6 +57,19 @@ describe("OpenClaw Beeper native stream publisher", () => { "com.beeper.ai": expect.objectContaining({ parts: [{ state: "done", text: "hello", type: "text" }], }), + "com.beeper.ai.metadata": expect.objectContaining({ + protocol: "ag-ui", + runId: "turn_1", + schema: "com.beeper.ai.run.v1", + status: expect.objectContaining({ + finishReason: "stop", + state: "complete", + }), + }), + "com.beeper.stream": { + type: "com.beeper.llm", + user_id: "@openclaw_agent_codex:example.com", + }, body: "hello", msgtype: "m.text", }), @@ -57,15 +83,17 @@ describe("OpenClaw Beeper native stream publisher", () => { const publisher = new OpenClawBeeperStreamPublisher({ client, userId: "@bot:example.com" }); const binding = sessionBinding(); - await publisher.publish(binding, [ + const startResult = await publisher.publish(binding, [ { runId: "turn_2", threadId: "turn_2", type: "RUN_STARTED" }, { messageId: "turn_2", role: "assistant", type: "TEXT_MESSAGE_START" }, ]); - await publisher.publish(binding, [ + const finishResult = await publisher.publish(binding, [ { delta: "hi", messageId: "turn_2", type: "TEXT_MESSAGE_CONTENT" }, { finishReason: "stop", runId: "turn_2", threadId: "turn_2", type: "RUN_FINISHED" }, ]); + expect(startResult).toEqual({ targetEventId: "$target" }); + expect(finishResult).toEqual({ targetEventId: "$target" }); expect(startMessage).toHaveBeenCalledTimes(1); expect(publishPart.mock.calls.map(([options]) => options.part.type)).toEqual([ "RUN_STARTED", @@ -99,6 +127,44 @@ describe("OpenClaw Beeper native stream publisher", () => { expect(finalizeMessage).not.toHaveBeenCalled(); }); + it("honors append stream finalization without suppressing the streamed event", async () => { + const { client, finalizeMessage } = createClient(); + const publisher = new OpenClawBeeperStreamPublisher({ + client, + config: { streamFinalization: "append" }, + userId: "@bot:example.com", + }); + + const result = await publisher.publish(sessionBinding(), [ + { delta: "append me", messageId: "turn_append", type: "TEXT_MESSAGE_CONTENT" }, + { finishReason: "stop", runId: "turn_append", threadId: "turn_append", type: "RUN_FINISHED" }, + ]); + + expect(result).toEqual({ targetEventId: "$target" }); + expect(finalizeMessage).toHaveBeenCalledWith(expect.objectContaining({ + body: "append me", + eventId: "$target", + roomId: "!room:example.com", + topLevelContent: {}, + userId: "@bot:example.com", + })); + }); + + it("suppresses the streamed event when finalizing replacement content by default", async () => { + const { client, finalizeMessage } = createClient(); + const publisher = new OpenClawBeeperStreamPublisher({ client, userId: "@bot:example.com" }); + + await publisher.publish(sessionBinding(), [ + { delta: "replace me", messageId: "turn_replace", type: "TEXT_MESSAGE_CONTENT" }, + { finishReason: "stop", runId: "turn_replace", threadId: "turn_replace", type: "RUN_FINISHED" }, + ]); + + expect(finalizeMessage).toHaveBeenCalledWith(expect.objectContaining({ + body: "replace me", + topLevelContent: { "com.beeper.dont_render_edited": true }, + })); + }); + it("drops a terminal run publisher even when Beeper finalization fails", async () => { const { client, finalizeMessage, startMessage } = createClient(); finalizeMessage.mockRejectedValueOnce(new Error("finalize failed")); diff --git a/packages/openclaw/src/beeper-stream.ts b/packages/openclaw/src/beeper-stream.ts index f234f2f..8fcd8c2 100644 --- a/packages/openclaw/src/beeper-stream.ts +++ b/packages/openclaw/src/beeper-stream.ts @@ -7,13 +7,18 @@ import { getFinalMessageText, type BeeperFinalMessageAccumulator, } from "@beeper/pickle/streams/beeper-message"; -import type { OpenClawBridgeStreamPublisher } from "./bridge-agent"; +import type { OpenClawBridgeStreamPublisher, OpenClawStreamPublishResult } from "./bridge-agent"; import { SerialQueue } from "./serial"; import { AGUIEventType, createTurnId, type AGUIEvent } from "./stream-map"; import type { OpenClawBridgeConfig, OpenClawSessionBinding } from "./types"; type FinishReason = "stop" | "length" | "content_filter" | "tool_calls" | null; +const BEEPER_AI_KEY = "com.beeper.ai"; +const BEEPER_AI_METADATA_KEY = "com.beeper.ai.metadata"; +const BEEPER_STREAM_DESCRIPTOR_KEY = "com.beeper.stream"; +const BEEPER_AI_STREAM_TYPE = "com.beeper.llm"; + export interface BeeperStreamPublisherClient { beeper: MatrixBeeper; } @@ -120,6 +125,7 @@ export class BeeperStreamPublisher { aiMessage: finalMessage, body: finalText, }); + const finalMetadata = this.#runMetadata(options.terminalPart?.type === AGUIEventType.RUN_ERROR ? "error" : "complete", options.terminalPart); const finalization = options.finalization ?? "replace"; if (finalization === "native-only") { this.#finalized = true; @@ -141,7 +147,9 @@ export class BeeperStreamPublisher { body: finalContent.body || "...", content: { body: finalContent.body || "...", - "com.beeper.ai": finalContent.aiMessage, + [BEEPER_AI_KEY]: finalContent.aiMessage, + [BEEPER_AI_METADATA_KEY]: finalMetadata, + [BEEPER_STREAM_DESCRIPTOR_KEY]: this.#streamDescriptor(), msgtype: "m.text", }, eventId, @@ -166,19 +174,22 @@ export class BeeperStreamPublisher { if (this.#targetEventId && this.#descriptor) { return { descriptor: this.#descriptor, eventId: this.#targetEventId, turnId: this.turnId }; } + const metadata = this.#runMetadata("streaming"); const target = await this.#client.beeper.streams.startMessage({ content: { body: "...", - "com.beeper.ai": { + [BEEPER_AI_KEY]: { id: this.turnId, metadata: { turn_id: this.turnId, ...this.#initialMessageMetadata }, parts: [], role: "assistant", }, + [BEEPER_AI_METADATA_KEY]: metadata, + [BEEPER_STREAM_DESCRIPTOR_KEY]: this.#streamDescriptor(), msgtype: "m.text", }, roomId: this.roomId, - streamType: "com.beeper.llm", + streamType: BEEPER_AI_STREAM_TYPE, ...(this.#subscribers.length > 0 ? { subscribers: this.#subscribers } : {}), ...(this.#threadRoot ? { threadRootEventId: this.#threadRoot } : {}), ...(this.#userId ? { userId: this.#userId } : {}), @@ -200,6 +211,44 @@ export class BeeperStreamPublisher { applyFinalMessagePart(this.#accumulator, accumulatorPart); } } + + #runMetadata(state: "streaming" | "complete" | "error", terminalPart?: AGUIEvent): Record { + return stripUndefined({ + agent: stripUndefined({ + id: this.#agentId, + }), + data: this.#initialMessageMetadata, + messageId: this.turnId, + model: "openclaw/gateway", + preview: { + text: "", + truncated: false, + }, + protocol: "ag-ui", + runId: this.turnId, + schema: "com.beeper.ai.run.v1", + status: stripUndefined({ + error: state === "error" ? terminalError(terminalPart) : undefined, + finishReason: state === "complete" ? terminalFinishReason(terminalPart) : undefined, + state, + terminal: terminalPart, + }), + threadId: this.turnId, + usage: { + completionTokens: 0, + promptTokens: 0, + totalTokens: 0, + }, + usageDetails: {}, + }); + } + + #streamDescriptor(): Record { + return stripUndefined({ + type: BEEPER_AI_STREAM_TYPE, + user_id: this.#userId, + }); + } } export interface OpenClawBeeperStreamPublisherOptions { @@ -220,8 +269,8 @@ export class OpenClawBeeperStreamPublisher implements OpenClawBridgeStreamPublis this.#userId = options.userId; } - async publish(binding: OpenClawSessionBinding, events: AGUIEvent[]): Promise { - if (!events.length) return; + async publish(binding: OpenClawSessionBinding, events: AGUIEvent[]): Promise { + if (!events.length) return undefined; const key = streamKey(binding, events); let publisher = this.#publishers.get(key); if (!publisher) { @@ -244,24 +293,28 @@ export class OpenClawBeeperStreamPublisher implements OpenClawBridgeStreamPublis await publisher.publishMany(nonTerminal); if (terminal) { try { - await publisher.finalize({ + const finalized = await publisher.finalize({ finalization: this.#config.streamFinalization, terminalPart: terminal, }); + const raw = recordValue(finalized.raw); + return { targetEventId: stringValue(raw?.logicalEventId) ?? finalized.eventId }; } finally { this.#publishers.delete(key); } } + return publisher.targetEventId ? { targetEventId: publisher.targetEventId } : undefined; } } function streamKey(binding: OpenClawSessionBinding, events: AGUIEvent[]): string { - return `${binding.roomId}:${firstRunId(events) ?? binding.sessionKey}`; + return `${binding.roomId}:${firstRunId(events) ?? binding.lastStreamRunId ?? binding.lastRunId ?? binding.sessionKey}`; } function firstRunId(events: AGUIEvent[]): string | undefined { for (const event of events) { - const runId = stringValue((event as Record).runId); + const record = event as Record; + const runId = stringValue(record.runId) ?? stringValue(record.threadId) ?? stringValue(record.messageId); if (runId) return runId; } return undefined; @@ -386,3 +439,16 @@ function normalizeFinishReason(reason: string | undefined): FinishReason { if (reason === "length" || reason === "content_filter" || reason === "tool_calls") return reason; return "stop"; } + +function terminalFinishReason(event: AGUIEvent | undefined): string { + return stringValue(event?.finishReason) ?? "stop"; +} + +function terminalError(event: AGUIEvent | undefined): unknown { + if (!event) return undefined; + return stringValue(event.message) ?? stringValue(event.error) ?? event; +} + +function stripUndefined>(record: T): T { + return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T; +} diff --git a/packages/openclaw/src/bridge-agent.test.ts b/packages/openclaw/src/bridge-agent.test.ts index 1741094..8e3636e 100644 --- a/packages/openclaw/src/bridge-agent.test.ts +++ b/packages/openclaw/src/bridge-agent.test.ts @@ -62,6 +62,39 @@ describe("OpenClawMatrixBridgeAgent", () => { ]); }); + it("persists the Beeper stream target event id for later relation handling", async () => { + const registry = await tempRegistry(); + registry.upsertBinding(testBinding()); + const streams: OpenClawBridgeStreamPublisher = { + publish: vi.fn(async () => ({ targetEventId: "$stream-root" })), + }; + const agent = new OpenClawMatrixBridgeAgent({ + registry, + runtime: runtimeWith({ + events: [ + { event: "assistant.delta", payload: { data: { delta: "hi" }, runId: "run_1", type: "assistant.delta" } }, + { event: "run.completed", payload: { runId: "run_1", type: "run.completed" } }, + ], + responses: { "sessions.send": { runId: "run_1", sessionKey: "agent:codex:main" } }, + }), + streams, + }); + + await agent.handleMatrixText({ + eventId: "$event", + roomId: "!room:example.com", + sender: "@alice:example.com", + text: "hello", + }); + + expect(registry.getBindingByRoom("!room:example.com")).toMatchObject({ + lastMatrixEventId: "$event", + lastRunId: "run_1", + lastStreamRunId: "run_1", + lastStreamTargetEventId: "$stream-root", + }); + }); + it("does not poison message dedupe when OpenClaw send fails before persistence", async () => { const registry = await tempRegistry(); registry.upsertBinding(testBinding()); @@ -206,9 +239,46 @@ describe("OpenClawMatrixBridgeAgent", () => { ]); }); + it("stops consuming gateway events after a terminal run event", async () => { + const registry = await tempRegistry(); + const binding = testBinding(); + let consumedAfterTerminal = false; + const runtime = new OpenClawGatewayRuntime({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + transport: { + async *events() { + yield { event: "run.completed", payload: { runId: "run_1", type: "run.completed" } }; + consumedAfterTerminal = true; + yield { event: "assistant.delta", payload: { data: { delta: "late" }, runId: "run_1", type: "assistant.delta" } }; + }, + request: vi.fn(), + }, + }); + const streams: OpenClawBridgeStreamPublisher = { + publish: vi.fn(), + }; + const agent = new OpenClawMatrixBridgeAgent({ registry, runtime, streams }); + + await agent.streamRun(binding, "run_1"); + + expect(consumedAfterTerminal).toBe(false); + expect(streams.publish).toHaveBeenCalledWith(expect.objectContaining({ + ...binding, + lastRunId: "run_1", + lastStreamRunId: "run_1", + }), expect.arrayContaining([ + expect.objectContaining({ type: "RUN_FINISHED" }), + ])); + }); + it("forwards Beeper approval responses back to OpenClaw", async () => { const registry = await tempRegistry(); - const runtime = runtimeWith({ responses: { "exec.approval.resolve": { ok: true } } }); + const runtime = runtimeWith({ + responses: { + "exec.approval.resolve": { ok: true }, + "plugin.approval.resolve": { ok: true }, + }, + }); const agent = new OpenClawMatrixBridgeAgent({ registry, runtime, streams: { publish: vi.fn() } }); await expect(agent.handleApprovalContent({ @@ -228,6 +298,22 @@ describe("OpenClawMatrixBridgeAgent", () => { 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", + }); }); }); diff --git a/packages/openclaw/src/bridge-agent.ts b/packages/openclaw/src/bridge-agent.ts index cd6d206..78ebc6c 100644 --- a/packages/openclaw/src/bridge-agent.ts +++ b/packages/openclaw/src/bridge-agent.ts @@ -1,4 +1,5 @@ import { + approvalKindForId, parseApprovalResponseContent, toOpenClawApprovalResolvePayload, type ParsedApprovalResponse, @@ -6,11 +7,15 @@ import { import { createOpenClawStreamState, mapOpenClawEventToBeeperChunks } from "./openclaw-event-map"; import type { OpenClawGatewayRuntime, OpenClawGatewayEvent, OpenClawMatrixMessageMetadata } from "./openclaw-runtime"; import type { OpenClawBridgeRegistry } from "./registry"; -import type { AGUIEvent } from "./stream-map"; +import { AGUIEventType, type AGUIEvent } from "./stream-map"; import type { OpenClawSessionBinding } from "./types"; export interface OpenClawBridgeStreamPublisher { - publish(binding: OpenClawSessionBinding, events: AGUIEvent[]): Promise | void; + publish(binding: OpenClawSessionBinding, events: AGUIEvent[]): Promise | OpenClawStreamPublishResult | undefined; +} + +export interface OpenClawStreamPublishResult { + targetEventId?: string; } export interface MatrixTextTurn { @@ -78,6 +83,8 @@ export class OpenClawMatrixBridgeAgent { 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; } @@ -86,7 +93,23 @@ export class OpenClawMatrixBridgeAgent { const state = createOpenClawStreamState(runId); for await (const gatewayEvent of this.runtime.eventsForRun(runId)) { const chunks = mapOpenClawEventToBeeperChunks(state, openClawEventFromGateway(gatewayEvent)); - if (chunks.length > 0) await this.streams.publish(binding, chunks); + if (chunks.length > 0) { + const result = await this.streams.publish({ + ...binding, + lastRunId: runId, + lastStreamRunId: runId, + }, chunks); + const targetEventId = result?.targetEventId; + if (targetEventId) { + this.registry.updateBinding(binding.id, (current) => ({ + ...current, + lastStreamRunId: runId, + lastStreamTargetEventId: targetEventId, + updatedAt: Date.now(), + })); + } + if (chunks.some(isTerminalStreamEvent)) break; + } } } @@ -120,3 +143,7 @@ function openClawEventFromGateway(event: OpenClawGatewayEvent): unknown { if (event.event) return { type: event.event, data: event.payload }; return event; } + +function isTerminalStreamEvent(event: AGUIEvent): boolean { + return event.type === AGUIEventType.RUN_FINISHED || event.type === AGUIEventType.RUN_ERROR; +} diff --git a/packages/openclaw/src/cli.test.ts b/packages/openclaw/src/cli.test.ts index 9a1e306..adccb70 100644 --- a/packages/openclaw/src/cli.test.ts +++ b/packages/openclaw/src/cli.test.ts @@ -19,16 +19,12 @@ describe("pickle-openclaw CLI", () => { dir, "--homeserver", "https://matrix.example", - "--gateway-access-token", - "gateway-secret", "--access-token", "secret", ], initIO)).resolves.toBe(0); expect(initIO.stdoutText).toContain('"accessToken": ""'); - expect(initIO.stdoutText).toContain('"gatewayAccessToken": ""'); expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({ accessToken: "secret", - gatewayAccessToken: "gateway-secret", homeserver: "https://matrix.example", }); expect((await stat(configPath)).mode & 0o777).toBe(0o600); @@ -75,7 +71,7 @@ describe("pickle-openclaw CLI", () => { "--access-token", "mx-token", "--gateway-url", - "http://127.0.0.1:29390", + "http://127.0.0.1:18789", "--homeserver", "https://matrix.beeper.com", "--matrix-device-id", @@ -96,7 +92,7 @@ describe("pickle-openclaw CLI", () => { backfill: true, backfillLimit: 25, config: expect.objectContaining({ - gatewayUrl: "http://127.0.0.1:29390", + gatewayUrl: "http://127.0.0.1:18789", matrixUserId: "@batuhan:beeper.com", }), getOnly: true, @@ -114,7 +110,7 @@ describe("pickle-openclaw CLI", () => { "--data-dir", dir, "--gateway-url", - "http://127.0.0.1:29390", + "http://127.0.0.1:18789", ], captureIO())).resolves.toBe(0); const runtime = fakeRuntime({ "config.schema.lookup": { path: ["agents"], type: "object" }, @@ -142,7 +138,7 @@ describe("pickle-openclaw CLI", () => { }); const io = captureIO(); - await expect(runCli(["features", "--gateway-url", "http://127.0.0.1:29390"], io, { + await expect(runCli(["features", "--gateway-url", "http://127.0.0.1:18789"], io, { runtimeFactory: () => runtime, })).resolves.toBe(0); @@ -154,6 +150,177 @@ describe("pickle-openclaw CLI", () => { }); }); + it("reports gateway smoke failures without token setup guidance", async () => { + const io = captureIO(); + const runtime = { + close: vi.fn(async () => undefined), + featureSnapshot: vi.fn(async () => { + throw new Error("OpenClaw gateway request failed: unauthorized: gateway token missing (provide gateway auth token)"); + }), + listAgentContacts: vi.fn(), + listSessions: vi.fn(), + } as never; + + await expect(runCli(["smoke", "--gateway-only"], io, { + runtimeFactory: () => runtime, + })).resolves.toBe(1); + + expect(io.stderrText).toContain("gateway token missing"); + expect(io.stderrText).not.toContain("--gateway-access-token"); + expect(io.stderrText).not.toContain("OPENCLAW_GATEWAY_TOKEN"); + }); + + it("runs a conservative smoke check across Gateway and Beeper bridge setup", async () => { + const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-smoke-")); + const configPath = join(dir, "config.json"); + await expect(runCli([ + "init", + "--config", + configPath, + "--data-dir", + dir, + "--access-token", + "mx-token", + "--gateway-url", + "http://127.0.0.1:18789", + "--homeserver", + "https://matrix.beeper.com", + "--matrix-device-id", + "DEVICE", + "--matrix-user-id", + "@batuhan:beeper.com", + "--registration-url", + "http://127.0.0.1:29391", + ], captureIO())).resolves.toBe(0); + const runtime = fakeRuntime({}, { + agents: { agents: [{ id: "codex" }] }, + status: { ok: true }, + }, { + agents: [{ agentId: "codex", displayName: "Codex" }], + sessions: [{ key: "dashboard:1", label: "Dashboard session" }], + }); + const bridge = { start: vi.fn(async () => undefined), stop: vi.fn(async () => undefined) }; + const createBridge = vi.fn(async () => bridge as never); + const io = captureIO(); + + await expect(runCli(["smoke", "--config", configPath, "--session-limit", "10"], io, { + createBridge, + runtimeFactory: () => runtime, + })).resolves.toBe(0); + + expect(runtime.featureSnapshot).toHaveBeenCalledOnce(); + expect(runtime.listAgentContacts).toHaveBeenCalledOnce(); + expect(runtime.listSessions).toHaveBeenCalledWith({ includeArchived: true, limit: 10 }); + expect(runtime.close).toHaveBeenCalledOnce(); + expect(createBridge).toHaveBeenCalledWith(expect.objectContaining({ + account: { + accessToken: "mx-token", + deviceId: "DEVICE", + homeserver: "https://matrix.beeper.com", + userId: "@batuhan:beeper.com", + }, + config: expect.objectContaining({ + gatewayUrl: "http://127.0.0.1:18789", + matrixUserId: "@batuhan:beeper.com", + }), + getOnly: true, + })); + expect(bridge.start).not.toHaveBeenCalled(); + expect(bridge.stop).toHaveBeenCalledOnce(); + expect(JSON.parse(io.stdoutText)).toMatchObject({ + beeper: { + bridgeCreated: true, + getOnly: true, + homeserver: "https://matrix.beeper.com", + userId: "@batuhan:beeper.com", + }, + gateway: { + agents: 1, + sessions: 1, + }, + ok: true, + }); + expect(io.stdoutText).not.toContain("mx-token"); + }); + + it("starts and stops the Beeper bridge during smoke checks when requested", async () => { + const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-smoke-start-")); + const configPath = join(dir, "config.json"); + await expect(runCli([ + "init", + "--config", + configPath, + "--data-dir", + dir, + "--access-token", + "mx-token", + "--homeserver", + "https://matrix.beeper.com", + "--matrix-device-id", + "DEVICE", + "--matrix-user-id", + "@batuhan:beeper.com", + "--registration-url", + "http://127.0.0.1:29391", + ], captureIO())).resolves.toBe(0); + const runtime = fakeRuntime({}, { status: { ok: true } }, { + agents: [{ agentId: "codex", displayName: "Codex" }], + sessions: [], + }); + const bridge = { start: vi.fn(async () => undefined), stop: vi.fn(async () => undefined) }; + const createBridge = vi.fn(async () => bridge as never); + const io = captureIO(); + + await expect(runCli(["smoke", "--config", configPath, "--start"], io, { + createBridge, + runtimeFactory: () => runtime, + })).resolves.toBe(0); + + expect(createBridge).toHaveBeenCalledWith(expect.objectContaining({ getOnly: false })); + expect(bridge.start).toHaveBeenCalledOnce(); + expect(bridge.stop).toHaveBeenCalledOnce(); + expect(JSON.parse(io.stdoutText)).toMatchObject({ + beeper: { + bridgeCreated: true, + getOnly: false, + }, + ok: true, + }); + }); + + it("fails smoke checks when Beeper bridge lifecycle methods are missing", async () => { + const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-smoke-invalid-")); + const configPath = join(dir, "config.json"); + await expect(runCli([ + "init", + "--config", + configPath, + "--data-dir", + dir, + "--access-token", + "mx-token", + "--homeserver", + "https://matrix.beeper.com", + "--matrix-device-id", + "DEVICE", + "--matrix-user-id", + "@batuhan:beeper.com", + ], captureIO())).resolves.toBe(0); + const runtime = fakeRuntime({}, { status: { ok: true } }, { + agents: [], + sessions: [], + }); + const io = captureIO(); + + await expect(runCli(["smoke", "--config", configPath], io, { + createBridge: vi.fn(async () => ({}) as never), + runtimeFactory: () => runtime, + })).resolves.toBe(1); + + expect(runtime.close).toHaveBeenCalledOnce(); + expect(io.stderrText).toContain("bridge object is missing start/stop lifecycle methods"); + }); + it("runs Beeper setup from CLI and persists runtime bridge-manager settings", async () => { const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-beeper-setup-")); const configPath = join(dir, "config.json"); @@ -378,11 +545,16 @@ describe("pickle-openclaw CLI", () => { }); }); -function fakeRuntime(responses: Record, snapshot: unknown = {}) { +function fakeRuntime(responses: Record, snapshot: unknown = {}, lists: { + agents?: unknown[]; + sessions?: unknown[]; +} = {}) { return { call: vi.fn(async (method: string) => responses[method]), close: vi.fn(async () => undefined), featureSnapshot: vi.fn(async () => snapshot), + listAgentContacts: vi.fn(async () => lists.agents ?? []), + listSessions: vi.fn(async () => lists.sessions ?? []), } as never; } diff --git a/packages/openclaw/src/cli.ts b/packages/openclaw/src/cli.ts index 7730c32..2d96127 100644 --- a/packages/openclaw/src/cli.ts +++ b/packages/openclaw/src/cli.ts @@ -3,7 +3,12 @@ import { chmod, mkdir, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { createInterface } from "node:readline/promises"; import type { BeeperEnvironment } from "@beeper/pickle/beeper/auth"; -import { accountFromOpenClawConfig, startOpenClawBeeperBridge, type CreateOpenClawBeeperBridgeOptions } from "./appservice"; +import { + accountFromOpenClawConfig, + createOpenClawBeeperBridge, + startOpenClawBeeperBridge, + type CreateOpenClawBeeperBridgeOptions, +} from "./appservice"; import { createOpenClawBeeperAppService, loginToBeeperForOpenClaw, setupOpenClawBeeperBridge } from "./beeper-setup"; import { createDefaultConfig, defaultConfigPath, readConfig, secretToken, writeConfig } from "./config"; import { createOpenClawRuntimeFromLogin, userLoginFromOpenClawConfig } from "./connector"; @@ -19,6 +24,7 @@ export interface CliIO { export interface CliDeps { createAppService?: typeof createOpenClawBeeperAppService; + createBridge?: typeof createOpenClawBeeperBridge; loginToBeeper?: typeof loginToBeeperForOpenClaw; runtimeFactory?: (config: OpenClawBridgeConfig) => OpenClawGatewayRuntime; setupBridge?: typeof setupOpenClawBeeperBridge; @@ -67,6 +73,58 @@ export async function runCli(argv = process.argv.slice(2), io: CliIO = process, } return 0; } + if (command === "smoke") { + const options = parseOptions(args); + const config = await loadConfig(options); + const runtime = deps.runtimeFactory?.(config) ?? runtimeFromConfig(config); + let bridge: unknown; + try { + const [features, agents, sessions] = await Promise.all([ + runtime.featureSnapshot(), + runtime.listAgentContacts(), + runtime.listSessions({ includeArchived: true, limit: numberOption(options, "session-limit") ?? 25 }), + ]); + const includeBeeper = !booleanOption(options, "gateway-only"); + const account = includeBeeper ? accountFromOpenClawConfig(config) : undefined; + if (account) { + bridge = await (deps.createBridge ?? createOpenClawBeeperBridge)({ + account, + config, + getOnly: !booleanOption(options, "start"), + }); + validateSmokeBridgeObject(bridge); + if (booleanOption(options, "start")) { + await startBridgeObject(bridge); + } + } + io.stdout.write(`${JSON.stringify({ + beeper: includeBeeper ? { + bridgeCreated: Boolean(bridge), + getOnly: !booleanOption(options, "start"), + homeserver: account?.homeserver, + userId: account?.userId, + } : { skipped: true }, + config: { + appserviceId: config.appserviceId, + gatewayUrl: config.gatewayUrl, + hasAccessToken: Boolean(config.accessToken), + homeserver: config.homeserver, + matrixUserId: config.matrixUserId, + registrationUrl: config.registrationUrl, + }, + gateway: { + agents: agents.length, + featureSnapshot: features, + sessions: sessions.length, + }, + ok: true, + }, null, 2)}\n`); + } finally { + await stopBridgeObject(bridge); + await runtime.close(); + } + return 0; + } if (command === "rpc") { const { paramsText, positional } = splitOptionsAndPositionals(args); const options = parseOptions(args); @@ -199,7 +257,7 @@ export async function runCli(argv = process.argv.slice(2), io: CliIO = process, io.stderr.write(`Unknown command: ${command}\n\n${helpText()}`); return 2; } catch (error) { - io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + io.stderr.write(`${formatCliError(error)}\n`); return 1; } } @@ -220,6 +278,7 @@ function helpText(): string { " start Start the OpenClaw Beeper bridge from config", " status Print the redacted effective config", " features Probe the documented OpenClaw Gateway feature surface", + " smoke Validate config, Gateway reachability, and Beeper bridge setup", " rpc Call any OpenClaw Gateway RPC method", " beeper-login Log in to Beeper and write Matrix credentials", " beeper-register Register the OpenClaw appservice with Beeper", @@ -229,7 +288,6 @@ function helpText(): string { " --config ", " --data-dir ", " --homeserver ", - " --gateway-access-token ", " --gateway-url ", " --registration-url ", " --matrix-device-id ", @@ -243,19 +301,26 @@ function helpText(): string { " --bridge-manager-token ", " --backfill", " --backfill-limit ", + " --gateway-only", + " --session-limit ", + " --start", " --params-json ", " --env ", "", ].join("\n"); } +function formatCliError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message; +} + function configOverridesFromOptions(options: Map): Partial { const overrides: Partial = {}; const accessToken = stringOption(options, "access-token"); const asToken = stringOption(options, "as-token"); const appserviceId = stringOption(options, "appservice-id"); const dataDir = stringOption(options, "data-dir"); - const gatewayAccessToken = stringOption(options, "gateway-access-token"); const gatewayUrl = stringOption(options, "gateway-url"); const homeserver = stringOption(options, "homeserver"); const matrixDeviceId = stringOption(options, "matrix-device-id"); @@ -265,7 +330,6 @@ function configOverridesFromOptions(options: Map): Par if (asToken) overrides.asToken = asToken; if (appserviceId) overrides.appserviceId = appserviceId; if (dataDir) overrides.dataDir = dataDir; - if (gatewayAccessToken) overrides.gatewayAccessToken = gatewayAccessToken; if (gatewayUrl) overrides.gatewayUrl = gatewayUrl; if (homeserver) overrides.homeserver = homeserver; if (matrixDeviceId) overrides.matrixDeviceId = matrixDeviceId; @@ -301,7 +365,6 @@ function redactConfig(config: OpenClawBridgeConfig): OpenClawBridgeConfig { ...(config.accessToken ? { accessToken: "" } : {}), ...(config.asToken ? { asToken: "" } : {}), ...(config.bridgeManagerToken ? { bridgeManagerToken: "" } : {}), - ...(config.gatewayAccessToken ? { gatewayAccessToken: "" } : {}), ...(config.hsToken ? { hsToken: "" } : {}), }; } @@ -390,6 +453,27 @@ function beeperBaseDomainOption(options: Map): string return undefined; } +async function startBridgeObject(bridge: unknown): Promise { + const start = bridge && typeof bridge === "object" && "start" in bridge ? bridge.start : undefined; + if (typeof start === "function") await start.call(bridge); +} + +async function stopBridgeObject(bridge: unknown): Promise { + const stop = bridge && typeof bridge === "object" && "stop" in bridge ? bridge.stop : undefined; + if (typeof stop === "function") await stop.call(bridge); +} + +function validateSmokeBridgeObject(bridge: unknown): void { + if (!bridge || typeof bridge !== "object") { + throw new Error("Beeper smoke failed: bridge factory did not return a bridge object"); + } + const start = "start" in bridge ? bridge.start : undefined; + const stop = "stop" in bridge ? bridge.stop : undefined; + if (typeof start !== "function" || typeof stop !== "function") { + throw new Error("Beeper smoke failed: bridge object is missing start/stop lifecycle methods"); + } +} + function runtimeFromConfig(config: OpenClawBridgeConfig): OpenClawGatewayRuntime { return createOpenClawRuntimeFromLogin(userLoginFromOpenClawConfig(config), config); } diff --git a/packages/openclaw/src/config.test.ts b/packages/openclaw/src/config.test.ts index 3b7d14e..088b2c6 100644 --- a/packages/openclaw/src/config.test.ts +++ b/packages/openclaw/src/config.test.ts @@ -2,10 +2,17 @@ import { readFile, stat } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { mkdtemp } from "node:fs/promises"; -import { describe, expect, it } from "vitest"; -import { createDefaultConfig, readConfig, writeConfig } from "./config"; +import { afterEach, describe, expect, it } from "vitest"; +import { createDefaultConfig, createConfigFromOpenClawSetup, readConfig, writeConfig } from "./config"; describe("OpenClaw bridge config", () => { + afterEach(() => { + delete process.env.PICKLE_OPENCLAW_ALLOW_ROOMS; + delete process.env.PICKLE_OPENCLAW_ALLOW_USERS; + delete process.env.PICKLE_OPENCLAW_APPSERVICE_ID; + delete process.env.PICKLE_OPENCLAW_APP_SERVICE_ID; + }); + it("defaults to appservice-owned non-federated bridge settings", () => { const config = createDefaultConfig({ dataDir: "/tmp/openclaw-bridge" }); expect(config).toMatchObject({ @@ -31,7 +38,6 @@ describe("OpenClaw bridge config", () => { asToken: "as-token", contactVisibility: "agents-and-users", dataDir: "/tmp/openclaw-bridge", - gatewayAccessToken: "gateway-token", homeserverDomain: "beeper.local", importSources: ["dashboard", "tui"], approvalBehavior: "native", @@ -45,22 +51,62 @@ describe("OpenClaw bridge config", () => { bridgeManagerToken: "hungry-token", asToken: "as-token", contactVisibility: "agents-and-users", - gatewayAccessToken: "gateway-token", homeserverDomain: "beeper.local", importSources: ["dashboard", "tui"], streamFinalization: "replace", }); }); + it("preserves dashboard bridge identity settings through OpenClaw setup config", () => { + const config = createConfigFromOpenClawSetup({ + channels: { + beeper: { + appserviceId: "custom-openclaw", + dataDir: "/tmp/openclaw-bridge", + ghostLocalpartPrefix: "oc_agent_", + senderLocalpart: "ocbot", + serviceBotLocalpart: "ocservice", + storePath: "/tmp/openclaw-store", + userLocalpartPrefix: "oc_user_", + }, + }, + }); + + expect(config).toMatchObject({ + appserviceId: "custom-openclaw", + dataDir: "/tmp/openclaw-bridge", + ghostLocalpartPrefix: "oc_agent_", + senderLocalpart: "ocbot", + serviceBotLocalpart: "ocservice", + storePath: "/tmp/openclaw-store", + userLocalpartPrefix: "oc_user_", + }); + }); + + it("accepts manifest-advertised environment variables", () => { + process.env.PICKLE_OPENCLAW_APP_SERVICE_ID = "manifest-openclaw"; + process.env.PICKLE_OPENCLAW_ALLOW_ROOMS = "!a:example.com, !b:example.com"; + process.env.PICKLE_OPENCLAW_ALLOW_USERS = "@alice:example.com,@bob:example.com"; + + expect(createDefaultConfig({ dataDir: "/tmp/openclaw-bridge" })).toMatchObject({ + allowedRoomIds: ["!a:example.com", "!b:example.com"], + allowedUserIds: ["@alice:example.com", "@bob:example.com"], + appserviceId: "manifest-openclaw", + }); + + process.env.PICKLE_OPENCLAW_APPSERVICE_ID = "legacy-openclaw"; + expect(createDefaultConfig({ dataDir: "/tmp/openclaw-bridge" }).appserviceId).toBe("legacy-openclaw"); + }); + + 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({ accessToken: "secret", asToken: "as-secret", dataDir: dir, gatewayAccessToken: "gateway-secret", homeserver: "https://matrix.example" }); + const config = createDefaultConfig({ accessToken: "secret", asToken: "as-secret", dataDir: dir, homeserver: "https://matrix.example" }); await writeConfig(config, path); expect(JSON.parse(await readFile(path, "utf8"))).toMatchObject({ accessToken: "secret", asToken: "as-secret", - gatewayAccessToken: "gateway-secret", homeserver: "https://matrix.example", }); expect((await stat(path)).mode & 0o777).toBe(0o600); diff --git a/packages/openclaw/src/config.ts b/packages/openclaw/src/config.ts index 3861c37..7c14f22 100644 --- a/packages/openclaw/src/config.ts +++ b/packages/openclaw/src/config.ts @@ -6,6 +6,7 @@ import { getBeeperChannelSettings, type OpenClawSetupConfig } from "./setup"; import type { OpenClawBridgeConfig } from "./types"; export const DEFAULT_APPSERVICE_ID = "pickle-openclaw"; +export const DEFAULT_GATEWAY_URL = "ws://127.0.0.1:18789"; export const DEFAULT_GHOST_LOCALPART_PREFIX = "openclaw_agent_"; export const DEFAULT_REGISTRATION_URL = "http://127.0.0.1:29391"; export const DEFAULT_SENDER_LOCALPART = "openclawbot"; @@ -23,7 +24,11 @@ export function defaultConfigPath(dataDir = defaultDataDir()): string { export function createDefaultConfig(overrides: Partial = {}): OpenClawBridgeConfig { const dataDir = overrides.dataDir ?? process.env.PICKLE_OPENCLAW_DATA_DIR ?? defaultDataDir(); const config: OpenClawBridgeConfig = { - appserviceId: overrides.appserviceId ?? process.env.PICKLE_OPENCLAW_APPSERVICE_ID ?? DEFAULT_APPSERVICE_ID, + appserviceId: + overrides.appserviceId ?? + process.env.PICKLE_OPENCLAW_APPSERVICE_ID ?? + process.env.PICKLE_OPENCLAW_APP_SERVICE_ID ?? + DEFAULT_APPSERVICE_ID, dataDir, ghostLocalpartPrefix: overrides.ghostLocalpartPrefix ?? @@ -46,8 +51,7 @@ export function createDefaultConfig(overrides: Partial = { const baseDomain = overrides.baseDomain ?? process.env.PICKLE_OPENCLAW_BASE_DOMAIN; const beeperEnv = overrides.beeperEnv ?? envBeeperEnv(process.env.PICKLE_OPENCLAW_BEEPER_ENV); const bridgeManagerToken = overrides.bridgeManagerToken ?? process.env.PICKLE_OPENCLAW_BRIDGE_MANAGER_TOKEN; - const gatewayAccessToken = overrides.gatewayAccessToken ?? process.env.PICKLE_OPENCLAW_GATEWAY_ACCESS_TOKEN; - const gatewayUrl = overrides.gatewayUrl ?? process.env.PICKLE_OPENCLAW_GATEWAY_URL; + const gatewayUrl = overrides.gatewayUrl ?? process.env.PICKLE_OPENCLAW_GATEWAY_URL ?? DEFAULT_GATEWAY_URL; const homeserver = overrides.homeserver ?? process.env.PICKLE_OPENCLAW_HOMESERVER; const homeserverDomain = overrides.homeserverDomain ?? process.env.PICKLE_OPENCLAW_HOMESERVER_DOMAIN; const hsToken = overrides.hsToken ?? process.env.PICKLE_OPENCLAW_HS_TOKEN; @@ -59,12 +63,13 @@ export function createDefaultConfig(overrides: Partial = { const streamFinalization = overrides.streamFinalization ?? envStreamFinalization(process.env.PICKLE_OPENCLAW_STREAM_FINALIZATION); const approvalBehavior = overrides.approvalBehavior ?? envApprovalBehavior(process.env.PICKLE_OPENCLAW_APPROVAL_BEHAVIOR); const bridgeManagerPostState = overrides.bridgeManagerPostState ?? envBoolean(process.env.PICKLE_OPENCLAW_BRIDGE_MANAGER_POST_STATE); + const allowedRoomIds = overrides.allowedRoomIds ?? envStringList(process.env.PICKLE_OPENCLAW_ALLOW_ROOMS); + const allowedUserIds = overrides.allowedUserIds ?? envStringList(process.env.PICKLE_OPENCLAW_ALLOW_USERS); if (accessToken) config.accessToken = accessToken; if (asToken) config.asToken = asToken; if (baseDomain) config.baseDomain = baseDomain; if (beeperEnv) config.beeperEnv = beeperEnv; if (bridgeManagerToken) config.bridgeManagerToken = bridgeManagerToken; - if (gatewayAccessToken) config.gatewayAccessToken = gatewayAccessToken; if (gatewayUrl) config.gatewayUrl = gatewayUrl; if (homeserver) config.homeserver = homeserver; if (homeserverDomain) config.homeserverDomain = homeserverDomain; @@ -77,8 +82,8 @@ export function createDefaultConfig(overrides: Partial = { if (streamFinalization !== undefined) config.streamFinalization = streamFinalization; if (approvalBehavior !== undefined) config.approvalBehavior = approvalBehavior; if (bridgeManagerPostState !== undefined) config.bridgeManagerPostState = bridgeManagerPostState; - if (overrides.allowedRoomIds) config.allowedRoomIds = overrides.allowedRoomIds; - if (overrides.allowedUserIds) config.allowedUserIds = overrides.allowedUserIds; + if (allowedRoomIds) config.allowedRoomIds = allowedRoomIds; + if (allowedUserIds) config.allowedUserIds = allowedUserIds; return config; } @@ -126,14 +131,20 @@ function envContactVisibility(value: string | undefined): OpenClawBridgeConfig[" } function envImportSources(value: string | undefined): OpenClawBridgeConfig["importSources"] | undefined { - if (!value) return undefined; - const sources = value.split(",").map((entry) => entry.trim()).filter(Boolean); + const sources = envStringList(value); + if (!sources) return undefined; if (sources.every((source) => source === "dashboard" || source === "tui" || source === "channels" || source === "archived")) { return sources as OpenClawBridgeConfig["importSources"]; } return undefined; } +function envStringList(value: string | undefined): string[] | undefined { + if (!value) return undefined; + const values = value.split(",").map((entry) => entry.trim()).filter(Boolean); + return values.length > 0 ? values : undefined; +} + function envStreamFinalization(value: string | undefined): OpenClawBridgeConfig["streamFinalization"] | undefined { if (value === "replace" || value === "append" || value === "native-only") return value; return undefined; diff --git a/packages/openclaw/src/connector.test.ts b/packages/openclaw/src/connector.test.ts index f8e52d3..955747b 100644 --- a/packages/openclaw/src/connector.test.ts +++ b/packages/openclaw/src/connector.test.ts @@ -1,4 +1,4 @@ -import type { BridgeRequestContext, MatrixEdit, MatrixMessage, MatrixReaction, MatrixRedaction, UserLogin } from "@beeper/pickle-bridge"; +import type { BridgeRequestContext, MatrixEdit, MatrixMessage, MatrixReaction, MatrixReactionRemove, MatrixRedaction, UserLogin } from "@beeper/pickle-bridge"; import { describe, expect, it, vi } from "vitest"; import { createDefaultConfig } from "./config"; import { createOpenClawConnector, OpenClawNetworkAPI, parseMatrixTextMessage, userLoginFromOpenClawConfig } from "./connector"; @@ -23,7 +23,7 @@ describe("OpenClawBridgeConnector", () => { }); expect(connector.getLoginFlows()).toEqual([ { - description: "Connect to an existing OpenClaw gateway by URL and optional bearer token.", + description: "Connect to an existing OpenClaw gateway by URL.", id: "openclaw.gateway", name: "OpenClaw Gateway", }, @@ -36,13 +36,12 @@ describe("OpenClawBridgeConnector", () => { }); await expect( "submitUserInput" in process - ? process.submitUserInput({ access_token: "token", gateway_url: "ws://gateway" }) + ? process.submitUserInput({ gateway_url: "ws://gateway" }) : undefined ).resolves.toMatchObject({ complete: { userLogin: { metadata: { - gatewayAccessToken: "token", gatewayUrl: "ws://gateway", }, remoteName: "OpenClaw", @@ -53,25 +52,16 @@ describe("OpenClawBridgeConnector", () => { }); }); - it("keeps Beeper Matrix tokens separate from OpenClaw gateway bearer tokens", () => { + it("keeps Beeper Matrix tokens out of OpenClaw gateway metadata", () => { expect(userLoginFromOpenClawConfig(createDefaultConfig({ accessToken: "matrix-token", dataDir: "/tmp/openclaw", - gatewayAccessToken: "gateway-token", gatewayUrl: "ws://gateway", }))).toMatchObject({ metadata: { - gatewayAccessToken: "gateway-token", gatewayUrl: "ws://gateway", }, }); - expect(userLoginFromOpenClawConfig(createDefaultConfig({ - accessToken: "matrix-token", - dataDir: "/tmp/openclaw", - gatewayUrl: "ws://gateway", - })).metadata).toEqual({ - gatewayUrl: "ws://gateway", - }); }); it("loads a network API that registers OpenClaw agents as ghosts", async () => { @@ -230,6 +220,68 @@ describe("OpenClawBridgeConnector", () => { }); }); + 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, + streams: { publish: vi.fn() }, + }); + 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("reuses an existing agent DM portal instead of creating duplicate rooms", 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", + kind: "session", + owner: "bridge", + roomId: "!existing-codex-dm:example.com", + sessionKey: "agent:codex", + updatedAt: 1, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime: runtimeWith({ responses: {} }), + streams: { publish: vi.fn() }, + }); + const createPortal = vi.fn(); + + await expect(api.resolveIdentifier({ bridge: { createPortal } } as unknown as BridgeRequestContext, { + createDM: true, + identifier: "codex", + type: "username", + })).resolves.toMatchObject({ + portal: { + id: "agent:codex", + mxid: "!existing-codex-dm:example.com", + portalKey: { id: "agent:codex", receiver: "login" }, + }, + userId: "@codex:example.com", + }); + expect(createPortal).not.toHaveBeenCalled(); + }); + it("lists searchable OpenClaw agent contacts for Beeper contact lists", async () => { const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); const runtime = runtimeWith({ @@ -435,6 +487,28 @@ describe("OpenClawBridgeConnector", () => { approvalId: "approval_1", decision: "deny", }); + + await expect(api.handleMatrixMessage({} 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.transport.request).not.toHaveBeenCalledWith("sessions.send", expect.objectContaining({ + idempotencyKey: "$native-approval", + }), expect.anything()); }); it("parses Matrix replies and slash commands for OpenClaw turns", async () => { @@ -444,6 +518,10 @@ describe("OpenClawBridgeConnector", () => { }, })).toEqual({ attachments: [], + replyQuote: { + body: "old", + sender: "@alice", + }, replyToEventId: "$old", text: "new text", }); @@ -452,6 +530,11 @@ describe("OpenClawBridgeConnector", () => { 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", @@ -476,8 +559,53 @@ describe("OpenClawBridgeConnector", () => { 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", + kind: "session", + lastRunId: "run_previous", + lastStreamRunId: "run_previous", + lastStreamTargetEventId: "$old", + owner: "bridge", + 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: { @@ -523,9 +651,16 @@ describe("OpenClawBridgeConnector", () => { idempotencyKey: "$reply", key: "agent:codex:session_2", 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", }, sender: "@alice:example.com", }, @@ -644,6 +779,20 @@ describe("OpenClawBridgeConnector", () => { 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", + kind: "session", + lastRunId: "run_streamed", + lastStreamRunId: "run_streamed", + lastStreamTargetEventId: "$old", + owner: "bridge", + 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" } }, @@ -671,20 +820,33 @@ describe("OpenClawBridgeConnector", () => { }; await api.handleMatrixEdit({} as BridgeRequestContext, { - content: {}, + 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: "corrected", + text: "* typo", } as MatrixEdit); expect(runtime.transport.request).toHaveBeenCalledWith("sessions.send", expect.objectContaining({ idempotencyKey: "$edit:edit", matrix: { + formattedBody: "corrected", relation: { kind: "edit", targetEventId: "$old", + targetRunId: "run_streamed", + targetSessionKey: "agent:codex:session_1", }, sender: "@alice:example.com", }, @@ -708,6 +870,8 @@ describe("OpenClawBridgeConnector", () => { key: "👍", kind: "reaction", targetEventId: "$old", + targetRunId: "run_streamed", + targetSessionKey: "agent:codex:session_1", }, sender: "@alice:example.com", }, @@ -715,6 +879,30 @@ describe("OpenClawBridgeConnector", () => { replyTo: { eventId: "$old", roomId: "!room:example.com" }, }), { expectFinal: false }); + 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.transport.request).toHaveBeenCalledWith("sessions.send", expect.objectContaining({ + idempotencyKey: "$react-redact", + matrix: { + relation: { + key: "👍", + kind: "reaction_remove", + targetEventId: "$old", + targetReactionId: "$react", + targetRunId: "run_streamed", + targetSessionKey: "agent:codex:session_1", + }, + sender: "@alice:example.com", + }, + message: "Removed reaction 👍 from $old", + replyTo: { eventId: "$old", roomId: "!room:example.com" }, + }), { expectFinal: false }); + await api.handleMatrixRedaction({} as BridgeRequestContext, { eventId: "$redact", portal, @@ -726,6 +914,8 @@ describe("OpenClawBridgeConnector", () => { relation: { kind: "redaction", targetEventId: "$old", + targetRunId: "run_streamed", + targetSessionKey: "agent:codex:session_1", }, sender: "redaction", }, @@ -762,6 +952,12 @@ describe("OpenClawBridgeConnector", () => { runtime.config.importSources = ["dashboard"]; runtime.config.backfillLimit = 5; runtime.config.gatewayUrl = "ws://gateway"; + runtime.config.allowedRoomIds = ["!room:example.com"]; + runtime.config.allowedUserIds = ["@alice:example.com"]; + runtime.config.beeperEnv = "staging"; + runtime.config.bridgeManagerPostState = false; + runtime.config.bridgeManagerToken = "hungry-token"; + runtime.config.contactVisibility = "agents-and-users"; const api = new OpenClawNetworkAPI({ config: runtime.config, login: login(), @@ -770,13 +966,14 @@ describe("OpenClawBridgeConnector", () => { streams: { publish: vi.fn() }, }); const queueRemoteEvent = vi.fn(); - const createPortal = vi.fn(async () => ({ - id: "session:YWdlbnQ6Y29kZXg6bmV3", - mxid: "!new-room:example.com", - portalKey: { id: "session:YWdlbnQ6Y29kZXg6bmV3", receiver: "login" }, + const createPortal = vi.fn(async (_login: UserLogin, options: { id: string }) => ({ + id: options.id, + mxid: options.id.includes("ZGVza3RvcA") ? "!imported-desktop:example.com" : "!new-room:example.com", + portalKey: { id: options.id, receiver: "login" }, receiver: "login", })); - const ctx = { bridge: { createPortal }, queueRemoteEvent } as unknown as BridgeRequestContext; + const backfillPortal = vi.fn(); + const ctx = { bridge: { backfillPortal, createPortal }, queueRemoteEvent } as unknown as BridgeRequestContext; const portal = { id: "agent:codex", metadata: { openclaw: { agentId: "codex", ghostUserId: "@codex:example.com", sessionKey: "agent:codex:session_1" } }, @@ -795,6 +992,24 @@ describe("OpenClawBridgeConnector", () => { await expect(queueRemoteEvent.mock.calls.at(-1)?.[1].convertMessage()).resolves.toMatchObject({ parts: [{ content: { body: expect.stringContaining("Import sources: dashboard") } }], }); + await expect(queueRemoteEvent.mock.calls.at(-1)?.[1].convertMessage()).resolves.toMatchObject({ + parts: [{ content: { body: expect.stringContaining("Approvals: native Beeper UI with slash/reaction escape hatches") } }], + }); + + await api.handleMatrixMessage(ctx, { + event: { eventId: "$settings" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "/settings", + } as MatrixMessage); + const settingsBody = (await queueRemoteEvent.mock.calls.at(-1)?.[1].convertMessage()).parts[0].content.body; + expect(settingsBody).toContain("OpenClaw Beeper settings"); + expect(settingsBody).toContain("Beeper environment: staging"); + expect(settingsBody).toContain("Bridge manager token: configured"); + expect(settingsBody).toContain("Post bridge state: disabled"); + expect(settingsBody).toContain("Contact visibility: agents-and-users"); + expect(settingsBody).toContain("Allowed rooms: !room:example.com"); + expect(settingsBody).toContain("Allowed users: @alice:example.com"); await api.handleMatrixMessage(ctx, { event: { eventId: "$sessions" }, @@ -821,6 +1036,30 @@ describe("OpenClawBridgeConnector", () => { limit: 5, sessionKey: "agent:codex:session_1", }); + expect(backfillPortal).toHaveBeenCalledWith(login(), portal, { limit: 5 }); + + await api.handleMatrixMessage(ctx, { + event: { eventId: "$import" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "/import", + } as MatrixMessage); + await expect(queueRemoteEvent.mock.calls.at(-1)?.[1].convertMessage()).resolves.toMatchObject({ + parts: [{ content: { body: "Imported 1 OpenClaw session.\nSkipped 0 already imported or unavailable sessions." } }], + }); + expect(createPortal).toHaveBeenCalledWith(login(), expect.objectContaining({ + id: "session:YWdlbnQ6Y29kZXg6ZGVza3RvcA", + name: "Desktop chat", + roomType: "dm", + sender: "codex", + })); + expect(backfillPortal).toHaveBeenCalledWith(login(), expect.objectContaining({ + mxid: "!imported-desktop:example.com", + }), { limit: 5 }); + expect(registry.getBindingBySessionKey("agent:codex:desktop")).toMatchObject({ + owner: "imported", + roomId: "!imported-desktop:example.com", + }); await api.handleMatrixMessage(ctx, { event: { eventId: "$new" }, @@ -962,6 +1201,25 @@ describe("OpenClawBridgeConnector", () => { }); expect(runtime.transport.request).not.toHaveBeenCalledWith("exec.approval.resolve", expect.anything()); + await api.handleMatrixMessage({} as BridgeRequestContext, { + content: { + approvalId: "approval_native_disabled", + approved: true, + type: "tool-approval-response", + }, + event: { eventId: "$native-disabled" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "Approved", + } as MatrixMessage); + expect(runtime.transport.request).not.toHaveBeenCalledWith("exec.approval.resolve", { + approvalId: "approval_native_disabled", + decision: "approve", + }); + expect(runtime.transport.request).not.toHaveBeenCalledWith("sessions.send", expect.objectContaining({ + idempotencyKey: "$native-disabled", + }), expect.anything()); + const queueRemoteEvent = vi.fn(); await api.handleMatrixMessage({ queueRemoteEvent } as unknown as BridgeRequestContext, { event: { eventId: "$approve" }, @@ -1014,6 +1272,52 @@ describe("OpenClawBridgeConnector", () => { }); }); + it("keeps slash and reaction approval escape hatches enabled in native approval mode", async () => { + const runtime = runtimeWith({ + responses: { + "exec.approval.resolve": { ok: true }, + }, + }); + runtime.config.approvalBehavior = "native"; + const api = new OpenClawNetworkAPI({ + config: runtime.config, + login: login(), + registry: new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"), + runtime, + streams: { publish: vi.fn() }, + }); + 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", + }; + const queueRemoteEvent = vi.fn(); + + await api.handleMatrixMessage({ queueRemoteEvent } as unknown as BridgeRequestContext, { + event: { eventId: "$approve-native" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "/approve approval_slash", + } as MatrixMessage); + await api.handleMatrixReaction({} as BridgeRequestContext, { + content: { "m.relates_to": { event_id: "approval_reaction", key: "approval.deny" } }, + event: { eventId: "$reaction-native" }, + portal, + targetMessage: { id: "approval_reaction" }, + } as MatrixReaction); + + expect(runtime.transport.request).toHaveBeenCalledWith("exec.approval.resolve", { + approvalId: "approval_slash", + decision: "approve", + }); + expect(runtime.transport.request).toHaveBeenCalledWith("exec.approval.resolve", { + approvalId: "approval_reaction", + decision: "deny", + }); + }); + it("fetches OpenClaw chat history for Pickle backfill", async () => { const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); const runtime = runtimeWith({ diff --git a/packages/openclaw/src/connector.ts b/packages/openclaw/src/connector.ts index 735adaf..3011b87 100644 --- a/packages/openclaw/src/connector.ts +++ b/packages/openclaw/src/connector.ts @@ -1,3 +1,4 @@ +import { resolve } from "node:path"; import { createRemoteMessage, type BackfillingNetworkAPI, @@ -22,23 +23,25 @@ import { MatrixMessage, MatrixMessageResponse, MatrixReaction, + MatrixReactionRemove, MatrixRedaction, MessageHandlingNetworkAPI, NetworkAPI, NetworkGeneralCapabilities, Portal, ReactionHandlingNetworkAPI, + type ReactionRemoveHandlingNetworkAPI, type RedactionHandlingNetworkAPI, Reaction, ResolveIdentifierParams, ResolveIdentifierResponse, UserLogin, } from "@beeper/pickle-bridge"; -import { buildBackfillImport, discoverOneToOneSessions } from "./backfill"; +import { backfillAllOpenClawSessions, buildBackfillImport, discoverOneToOneSessions } from "./backfill"; import { parseApprovalResponseContent } from "./approval"; import { OpenClawBeeperStreamPublisher } from "./beeper-stream"; import { agentPortalSessionKey, OpenClawMatrixBridgeAgent, type OpenClawBridgeStreamPublisher } from "./bridge-agent"; -import { createDefaultConfig } from "./config"; +import { createDefaultConfig, DEFAULT_GATEWAY_URL } from "./config"; import { createOpenClawHttpTransport, createOpenClawWebSocketTransport, OpenClawGatewayRuntime, type OpenClawMatrixMessageMetadata, type OpenClawTransport } from "./openclaw-runtime"; import { OpenClawBridgeRegistry } from "./registry"; import { agentContactFromOpenClawAgent, serviceBotUserId } from "./rooms"; @@ -116,7 +119,7 @@ export class OpenClawBridgeConnector implements BridgeConnector { return { - instructions: "Enter your OpenClaw gateway URL and optional bearer token.", + instructions: "Enter your OpenClaw gateway URL.", stepId: "openclaw.gateway.credentials", type: "user_input", userInput: { fields: [ { - defaultValue: this.#defaultConfig.gatewayUrl ?? "ws://127.0.0.1:29390", + defaultValue: this.#defaultConfig.gatewayUrl ?? DEFAULT_GATEWAY_URL, description: "OpenClaw gateway URL.", id: "gateway_url", name: "Gateway URL", type: "url", }, - { - description: "Optional OpenClaw gateway bearer token.", - id: "access_token", - name: "Access token", - type: "token", - }, ], }, }; @@ -192,14 +189,12 @@ export class OpenClawGatewayLoginProcess implements LoginProcess { async submitUserInput(_ctxOrInput?: BridgeRequestContext | Record, maybeInput?: Record): Promise { const input = maybeInput ?? (_ctxOrInput as Record | undefined) ?? {}; - const gatewayUrl = input.gateway_url || this.#defaultConfig.gatewayUrl || "ws://127.0.0.1:29390"; - const accessToken = input.access_token || this.#defaultConfig.gatewayAccessToken; + const gatewayUrl = input.gateway_url || this.#defaultConfig.gatewayUrl || DEFAULT_GATEWAY_URL; return { complete: { userLogin: { id: `openclaw:${encodeLoginId(gatewayUrl)}`, metadata: { - ...(accessToken ? { gatewayAccessToken: accessToken } : {}), gatewayUrl, }, remoteName: "OpenClaw", @@ -214,7 +209,7 @@ export class OpenClawGatewayLoginProcess implements LoginProcess { } } -export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetworkAPI, ContactListingNetworkAPI, MessageHandlingNetworkAPI, EditHandlingNetworkAPI, ReactionHandlingNetworkAPI, RedactionHandlingNetworkAPI, BackfillingNetworkAPI { +export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetworkAPI, ContactListingNetworkAPI, MessageHandlingNetworkAPI, EditHandlingNetworkAPI, ReactionHandlingNetworkAPI, ReactionRemoveHandlingNetworkAPI, RedactionHandlingNetworkAPI, BackfillingNetworkAPI { readonly #agent: OpenClawMatrixBridgeAgent; readonly #config: OpenClawBridgeConfig; readonly #login: UserLogin; @@ -269,9 +264,13 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor } async resolveIdentifier(ctx: BridgeRequestContext, params: ResolveIdentifierParams): Promise { - const contact = this.#registry.getAgent(params.identifier) ?? agentContactFromOpenClawAgent(this.#runtime.config, { id: params.identifier }); - let portal = params.createDM ? portalForAgent(contact, this.#login.id) : undefined; - if (portal && params.createDM) { + await this.#agent.syncAgentContacts(); + const contact = findAgentContact(this.#registry.data.agents, params.identifier); + if (!contact) return {}; + let portal = params.createDM + ? existingAgentPortal(this.#registry.getBindingBySessionKey(agentPortalSessionKey(contact.agentId)), this.#login.id) ?? portalForAgent(contact, this.#login.id) + : undefined; + if (portal && params.createDM && !portal.mxid) { const portalOptions: Parameters[1] = { id: portal.id, metadata: portal.metadata, @@ -324,10 +323,17 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor if (!this.isAllowedMatrixIngress(msg.portal.mxid, msg.sender.userId)) return { pending: false }; const binding = bindingFromPortal(msg.portal); if (binding && !this.#registry.getBindingByRoom(msg.portal.mxid ?? "")) this.#registry.upsertBinding(binding); + const currentBinding = msg.portal.mxid ? this.#registry.getBindingByRoom(msg.portal.mxid) ?? binding : binding; + const approval = parseApprovalResponseContent(msg.content); + if (approval) { + if (approvalNativeEnabled(this.#runtime.config)) { + 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 (parsed.command?.name === "stop" || parsed.command?.name === "abort") { - const currentBinding = this.#registry.getBindingByRoom(msg.portal.mxid) ?? binding; const abortOptions: { runId?: string; sessionKey?: string } = {}; if (currentBinding?.lastRunId) abortOptions.runId = currentBinding.lastRunId; if (currentBinding?.sessionKey) abortOptions.sessionKey = currentBinding.sessionKey; @@ -340,7 +346,7 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor await this.#agent.handleMatrixText({ ...(parsed.attachments.length > 0 ? { attachments: parsed.attachments } : {}), eventId: msg.event.eventId, - matrix: matrixMetadataFromParsed(parsed, msg.sender.userId), + matrix: matrixMetadataFromParsed(parsed, msg.sender.userId, streamTargetRelationPatch(currentBinding, parsed.replyToEventId)), roomId: msg.portal.mxid, ...(parsed.replyToEventId ? { replyToEventId: parsed.replyToEventId } : {}), sender: msg.sender.userId, @@ -355,6 +361,7 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor 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 } : {}), @@ -362,6 +369,7 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor matrix: matrixMetadataFromParsed(parsed, msg.sender.userId, { kind: "edit", targetEventId: targetId, + ...streamTargetRelationPatch(binding, targetId), }), roomId: msg.portal.mxid, replyToEventId: targetId, @@ -385,6 +393,7 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor 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: { @@ -392,6 +401,7 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor key: reactionKey, kind: "reaction", targetEventId: msg.targetMessage.id, + ...streamTargetRelationPatch(binding, msg.targetMessage.id), }, sender: senderUserId(msg.event.sender) ?? "reaction", }, @@ -403,16 +413,45 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor 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", }, @@ -474,8 +513,9 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor ): Promise { switch (command.name) { case "status": - case "settings": return commandNotice(ctx, this.#login, msg, bridgeStatusText(this.#runtime.config, this.#registry.data.bindings.length)); + case "settings": + return commandNotice(ctx, this.#login, msg, bridgeSettingsText(this.#runtime.config, this.#registry.data.bindings.length)); case "sessions": { const options: Parameters[1] = {}; if (this.#runtime.config.importSources !== undefined) options.importSources = this.#runtime.config.importSources; @@ -483,9 +523,19 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor return commandNotice(ctx, this.#login, msg, sessionsSummaryText(sessions)); } case "backfill": - case "import": { - const count = await this.backfillCurrentRoom(binding, msg); + const count = await this.backfillCurrentRoom(ctx, binding, msg); return commandNotice(ctx, this.#login, msg, `Queued backfill for ${count} message${count === 1 ? "" : "s"}.`); + case "import": { + const importOptions: Parameters[0] = { + bridge: ctx.bridge, + login: this.#login, + registry: this.#registry, + runtime: this.#runtime, + }; + if (this.#runtime.config.importSources !== undefined) importOptions.importSources = this.#runtime.config.importSources; + if (this.#runtime.config.backfillLimit !== undefined) importOptions.limit = this.#runtime.config.backfillLimit; + const result = await backfillAllOpenClawSessions(importOptions); + return commandNotice(ctx, this.#login, msg, importSummaryText(result)); } case "new": { const request = this.resolveNewSessionCommand(command.args, binding); @@ -550,7 +600,7 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor } } - async backfillCurrentRoom(binding: OpenClawSessionBinding | undefined, msg: MatrixMessage): Promise { + async backfillCurrentRoom(ctx: BridgeRequestContext, binding: OpenClawSessionBinding | undefined, msg: MatrixMessage): Promise { const roomId = msg.portal.mxid; if (!binding || !roomId) return 0; const importOptions: { limit?: number; roomId: string } = { roomId }; @@ -564,6 +614,9 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor }, importOptions); if (imported.human) this.#registry.upsertUser(imported.human); this.#registry.upsertBinding(imported.binding); + const backfillOptions: { limit?: number } = {}; + if (this.#runtime.config.backfillLimit !== undefined) backfillOptions.limit = this.#runtime.config.backfillLimit; + await ctx.bridge.backfillPortal(this.#login, msg.portal, backfillOptions); await this.#registry.save(); return imported.messages.length; } @@ -640,13 +693,47 @@ function bridgeStatusText(config: OpenClawBridgeConfig, boundRooms: number): str "OpenClaw Beeper bridge", `Gateway: ${config.gatewayUrl ?? "not configured"}`, `Import sources: ${(config.importSources ?? []).join(", ") || "none"}`, - `Approvals: ${config.approvalBehavior ?? "native"}`, + `Approvals: ${describeApprovalBehavior(config.approvalBehavior)}`, `Stream finalization: ${config.streamFinalization ?? "replace"}`, `Backfill limit: ${config.backfillLimit ?? "default"}`, `Bound rooms: ${boundRooms}`, ].join("\n"); } +function bridgeSettingsText(config: OpenClawBridgeConfig, boundRooms: number): string { + return [ + "OpenClaw Beeper settings", + `Beeper environment: ${config.beeperEnv ?? "production"}`, + `Homeserver: ${config.homeserver ?? "not configured"}`, + `Registration URL: ${config.registrationUrl ?? "not configured"}`, + `Gateway: ${config.gatewayUrl ?? "not configured"}`, + `Bridge manager token: ${config.bridgeManagerToken ? "configured" : "not configured"}`, + `Post bridge state: ${config.bridgeManagerPostState === undefined ? "default" : config.bridgeManagerPostState ? "enabled" : "disabled"}`, + `Import sources: ${(config.importSources ?? []).join(", ") || "none"}`, + `Backfill limit: ${config.backfillLimit ?? "default"}`, + `Contact visibility: ${config.contactVisibility ?? "agents"}`, + `Stream finalization: ${config.streamFinalization ?? "replace"}`, + `Approvals: ${describeApprovalBehavior(config.approvalBehavior)}`, + `Non-federated rooms: ${config.nonFederatedRooms ? "yes" : "no"}`, + `Allowed rooms: ${config.allowedRoomIds?.length ? config.allowedRoomIds.join(", ") : "all"}`, + `Allowed users: ${config.allowedUserIds?.length ? config.allowedUserIds.join(", ") : "all"}`, + `Bound rooms: ${boundRooms}`, + ].join("\n"); +} + +function describeApprovalBehavior(behavior: OpenClawBridgeConfig["approvalBehavior"]): string { + switch (behavior ?? "native") { + case "native": + return "native Beeper UI with slash/reaction escape hatches"; + case "reactions": + return "reaction fallback only"; + case "slash": + return "slash command fallback only"; + case "disabled": + return "disabled"; + } +} + function approvalReactionsEnabled(config: OpenClawBridgeConfig): boolean { return config.approvalBehavior === undefined || config.approvalBehavior === "native" || config.approvalBehavior === "reactions"; } @@ -655,6 +742,10 @@ function approvalSlashEnabled(config: OpenClawBridgeConfig): boolean { return config.approvalBehavior === undefined || config.approvalBehavior === "native" || config.approvalBehavior === "slash"; } +function approvalNativeEnabled(config: OpenClawBridgeConfig): boolean { + return config.approvalBehavior === undefined || config.approvalBehavior === "native"; +} + function openClawPortalCreationContent(config: OpenClawBridgeConfig): Record | undefined { return config.nonFederatedRooms ? { "m.federate": false } : undefined; } @@ -664,20 +755,45 @@ function sessionsSummaryText(sessions: Awaited `${session.label} (${session.source})`).join("\n"); } +function importSummaryText(result: Awaited>): string { + const imported = result.sessions.length; + const skipped = result.skipped.length; + if (imported === 0 && skipped === 0) return "No importable OpenClaw sessions found for the enabled import sources."; + return [ + `Imported ${imported} OpenClaw session${imported === 1 ? "" : "s"}.`, + `Skipped ${skipped} already imported or unavailable session${skipped === 1 ? "" : "s"}.`, + ].join("\n"); +} + +function streamTargetRelationPatch( + binding: OpenClawSessionBinding | undefined, + targetEventId: string | undefined, +): Partial> { + if (!binding?.lastStreamTargetEventId || binding.lastStreamTargetEventId !== targetEventId) return {}; + const patch: Partial> = { + targetSessionKey: binding.sessionKey, + }; + const targetRunId = binding.lastStreamRunId ?? binding.lastRunId; + if (targetRunId) patch.targetRunId = targetRunId; + return patch; +} + function matrixMetadataFromParsed( parsed: ParsedMatrixTextMessage, sender: string, relationPatch: NonNullable = {}, ): OpenClawMatrixMessageMetadata { const metadata: OpenClawMatrixMessageMetadata = { sender }; + if (parsed.attachments.length > 0) metadata.attachments = parsed.attachments as NonNullable; 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 || Object.keys(relationPatch).length > 0) { + 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, }; } @@ -701,6 +817,35 @@ function portalForAgent(contact: OpenClawAgentContact, receiver: string): Portal }; } +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 existingAgentPortal(binding: OpenClawSessionBinding | undefined, receiver: string): Portal | undefined { + if (!binding) return undefined; + if (!binding.roomId) return undefined; + return { + id: `agent:${binding.agentId}`, + metadata: { + openclaw: { + agentId: binding.agentId, + ghostUserId: binding.ghostUserId, + sessionKey: binding.sessionKey, + }, + }, + mxid: binding.roomId, + portalKey: { id: `agent:${binding.agentId}`, receiver }, + receiver, + roomType: "dm", + }; +} + function portalIdForSession(sessionKey: string): string { return `session:${Buffer.from(sessionKey).toString("base64url")}`; } @@ -757,10 +902,11 @@ function transportFromLogin(login: UserLogin, config: OpenClawBridgeConfig): Ope const gatewayUrl = stringValue(metadata?.gatewayUrl) ?? config.gatewayUrl; if (!gatewayUrl) throw new Error("OpenClaw gateway URL is not configured"); const options: Parameters[0] = { url: gatewayUrl }; - const accessToken = stringValue(metadata?.gatewayAccessToken) ?? stringValue(metadata?.accessToken) ?? config.gatewayAccessToken; - if (accessToken !== undefined) options.accessToken = accessToken; if (gatewayUrl.startsWith("ws://") || gatewayUrl.startsWith("wss://")) { - return createOpenClawWebSocketTransport(options); + return createOpenClawWebSocketTransport({ + ...options, + deviceIdentityPath: resolve(config.dataDir, "gateway-device.json"), + }); } return createOpenClawHttpTransport(options); } @@ -771,7 +917,6 @@ export function userLoginFromOpenClawConfig(config: OpenClawBridgeConfig): UserL return { id: `openclaw:${encodeLoginId(gatewayUrl)}`, metadata: { - ...(config.gatewayAccessToken ? { gatewayAccessToken: config.gatewayAccessToken } : {}), gatewayUrl, }, remoteName: "OpenClaw", @@ -828,35 +973,51 @@ export interface ParsedMatrixTextMessage { }; 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 relates = recordValue(recordValue(content)?.["m.relates_to"]); + 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 body = stripMatrixReplyFallback(text); - const command = parseSlashCommand(body); - const formattedBody = stringValue(recordValue(content)?.formatted_body) ?? stringValue(msg?.event.html); - const mentions = normalizeMentions(recordValue(content)?.["m.mentions"] ?? msg?.event.mentions); - const attachments = normalizeMatrixAttachments(msg?.attachments ?? msg?.event.attachments ?? [], content); + 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); @@ -909,12 +1070,39 @@ function normalizeMentions(value: unknown): ParsedMatrixTextMessage["mentions"] return mentions.room || mentions.userIds?.length ? mentions : undefined; } -function stripMatrixReplyFallback(text: string): string { +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; - return lines.slice(index).join("\n").trim(); + 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 { @@ -927,6 +1115,10 @@ function parseSlashCommand(text: string): ParsedMatrixTextMessage["command"] | u }; } +function stripLeadingMatrixMention(text: string): string { + return text.trimStart().replace(/^@[^\s:]+(?::[^\s]+)?\s+/u, ""); +} + function stripUndefined>(input: T): T { for (const key of Object.keys(input)) { if (input[key] === undefined) delete input[key]; diff --git a/packages/openclaw/src/integration.test.ts b/packages/openclaw/src/integration.test.ts index f9c192b..2d37cce 100644 --- a/packages/openclaw/src/integration.test.ts +++ b/packages/openclaw/src/integration.test.ts @@ -149,6 +149,231 @@ describe("OpenClaw bridge integration", () => { decision: "approve", }); }); + + it("dispatches Matrix edits, emoji reactions, and redactions through Pickle into OpenClaw", async () => { + const dir = await mkdtemp(resolve(tmpdir(), "pickle-openclaw-relations-integration-")); + const config = createDefaultConfig({ + dataDir: dir, + gatewayUrl: "ws://gateway", + homeserver: "https://matrix.example", + matrixUserId: "@openclawbot:example", + }); + const transport = fakeTransport({ + responses: { + "agents.list": { agents: [{ id: "codex", name: "Codex" }] }, + "sessions.send": { runId: "run_relation", sessionKey: "agent:codex:session_1" }, + }, + }); + const registry = new OpenClawBridgeRegistry(resolve(dir, "registry.json")); + const connector = createOpenClawConnector({ + config, + registry, + runtimeFactory: () => new OpenClawGatewayRuntime({ config, transport }), + streams: { publish: vi.fn(async () => {}) }, + }); + 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: "@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" }); + + expect(transport.request).toHaveBeenCalledWith("sessions.send", expect.objectContaining({ + idempotencyKey: "$edit:edit", + matrix: expect.objectContaining({ + relation: expect.objectContaining({ kind: "edit", targetEventId: "$old" }), + }), + message: "corrected", + replyTo: { eventId: "$old", roomId: "!codex:example" }, + }), { expectFinal: false }); + expect(transport.request).toHaveBeenCalledWith("sessions.send", 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" }, + }), { expectFinal: false }); + expect(transport.request).toHaveBeenCalledWith("sessions.send", expect.objectContaining({ + idempotencyKey: "$redact", + matrix: expect.objectContaining({ + relation: expect.objectContaining({ kind: "redaction", targetEventId: "$old" }), + }), + message: "Redacted message $old", + replyTo: { eventId: "$old", roomId: "!codex:example" }, + }), { expectFinal: false }); + }); + + it("smokes contact DM creation, Matrix ingress, native streaming, approval, and backfill with local fakes", async () => { + const dir = await mkdtemp(resolve(tmpdir(), "pickle-openclaw-local-smoke-")); + const config = createDefaultConfig({ + accessToken: "mx-token", + dataDir: dir, + gatewayUrl: "ws://gateway", + homeserver: "https://matrix.example", + importSources: ["dashboard"], + matrixDeviceId: "DEVICE", + matrixUserId: "@openclawbot:example", + }); + const transport = fakeTransport({ + events: [ + { event: "session.operation", payload: { phase: "started", runId: "run_1", sessionKey: "session_1" } }, + { event: "session.message", payload: { deltaText: "hello from OpenClaw", role: "assistant", runId: "run_1" } }, + { event: "exec.approval.requested", payload: { approvalId: "approval_1", message: "Run tool?", runId: "run_1", toolCallId: "tool_1", toolName: "shell" } }, + { event: "session.operation", payload: { phase: "completed", runId: "run_1" } }, + ], + responses: { + "agents.list": { agents: [{ id: "codex", name: "Codex" }] }, + "chat.history": { messages: [{ content: "older desktop turn", id: "m1", role: "user" }] }, + "exec.approval.resolve": { ok: true }, + "sessions.create": { key: "session_1" }, + "sessions.list": { sessions: [{ displayName: "Desktop chat", key: "agent:codex:desktop", origin: { surface: "mac-app" } }] }, + "sessions.send": { runId: "run_1", sessionKey: "session_1" }, + }, + }); + const registry = new OpenClawBridgeRegistry(resolve(dir, "registry.json")); + const client = createFakeMatrixClient(); + const connector = createOpenClawConnector({ + config, + registry, + runtimeFactory: () => new OpenClawGatewayRuntime({ config, transport }), + }); + 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: "@openclaw_agent_codex:matrix.example", + }, + }); + + const resolved = await bridge.resolveIdentifier(login, { + createDM: true, + identifier: "codex", + type: "username", + }); + expect(resolved.portal).toMatchObject({ + id: "agent:codex", + mxid: "!created:example", + portalKey: { id: "agent:codex", receiver: login.id }, + }); + expect(client.appservice.createPortalRoom).toHaveBeenCalledWith(expect.objectContaining({ + creationContent: { "m.federate": false }, + isDirect: true, + name: "Codex", + portalKey: { id: "agent:codex", receiver: login.id }, + roomType: "dm", + userId: "@codex:example", + })); + + await expect(bridge.dispatchMatrixEvent(messageEvent({ + body: "hello", + eventId: "$hello", + roomId: "!created:example", + sender: "@alice:example", + }))).resolves.toMatchObject({ + dispatched: true, + handlers: 1, + roomId: "!created:example", + }); + + expect(client.beeper.streams.startMessage).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.objectContaining({ + "com.beeper.ai": expect.objectContaining({ id: "run_1" }), + "com.beeper.ai.metadata": expect.objectContaining({ protocol: "ag-ui", runId: "run_1" }), + "com.beeper.stream": { type: "com.beeper.llm", user_id: "@openclawbot:example" }, + }), + roomId: "!created:example", + streamType: "com.beeper.llm", + userId: "@openclawbot:example", + })); + expect(client.beeper.streams.publishPart).toHaveBeenCalledWith(expect.objectContaining({ + part: expect.objectContaining({ type: "CUSTOM" }), + roomId: "!created:example", + turnId: expect.any(String), + })); + expect(client.beeper.streams.finalizeMessage).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.objectContaining({ + "com.beeper.ai": expect.objectContaining({ + parts: expect.arrayContaining([ + expect.objectContaining({ text: "hello from OpenClaw", type: "text" }), + ]), + }), + "com.beeper.stream": { type: "com.beeper.llm", user_id: "@openclawbot:example" }, + }), + eventId: "$stream-root", + 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).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(client.appservice.batchSend).toHaveBeenCalledWith(expect.objectContaining({ + events: expect.any(Array), + roomId: "!created:example", + })); + expect(registry.getBindingBySessionKey("agent:codex:desktop")).toMatchObject({ + label: "Desktop chat", + owner: "imported", + roomId: "!created:example", + }); + }); }); function fakeTransport(options: { @@ -195,6 +420,30 @@ function messageEvent(options: { body: string; eventId: string; roomId: string; }; } +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, @@ -217,12 +466,39 @@ function reactionEvent(options: { eventId: string; key: string; relatesTo: strin }; } +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 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", + })), + }; return { accountData: {} as MatrixClient["accountData"], appservice: { @@ -235,7 +511,7 @@ function createFakeMatrixClient(): MatrixClient & { subscription: MatrixSubscrip init: vi.fn(async () => ({ botUserId: "@openclawbot:example", id: "openclaw" })), sendMessage: vi.fn(async () => ({ eventId: "$sent", raw: {}, roomId: "!room:example" })), }, - beeper: {} as MatrixClient["beeper"], + beeper: { streams: beeperStreams } as unknown as MatrixClient["beeper"], boot: vi.fn(async () => ({ deviceId: "DEVICE", userId: "@openclawbot:example" })), close: vi.fn(async () => {}), crypto: {} as MatrixClient["crypto"], diff --git a/packages/openclaw/src/openclaw-event-map.test.ts b/packages/openclaw/src/openclaw-event-map.test.ts index 8c77363..4b8893a 100644 --- a/packages/openclaw/src/openclaw-event-map.test.ts +++ b/packages/openclaw/src/openclaw-event-map.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { defaultBeeperApprovalChoices } from "./approval"; import { createOpenClawStreamState, mapOpenClawEventToBeeperChunks } from "./openclaw-event-map"; describe("OpenClaw event to Beeper stream mapping", () => { @@ -152,6 +153,7 @@ describe("OpenClaw event to Beeper stream mapping", () => { needsApproval: true, }, approvalMessageId: "approval_1", + choices: defaultBeeperApprovalChoices(), message: "Allow shell?", toolCallId: "call_1", toolName: "shell", @@ -228,6 +230,35 @@ describe("OpenClaw event to Beeper stream mapping", () => { })).toEqual([ { delta: "Hello", messageId: "turn_gateway", type: "TEXT_MESSAGE_CONTENT" }, ]); + expect(mapOpenClawEventToBeeperChunks(state, { + event: "session.message", + payload: { + message: { + content: [{ text: " from transcript", type: "text" }], + role: "assistant", + }, + messageId: "msg_1", + messageSeq: 1, + runId: "run_1", + sessionKey: "session_1", + }, + })).toEqual([ + { delta: " from transcript", messageId: "turn_gateway", type: "TEXT_MESSAGE_CONTENT" }, + ]); + expect(mapOpenClawEventToBeeperChunks(state, { + event: "session.message", + payload: { + message: { + content: [{ thinking: "checking current files", type: "thinking" }], + role: "assistant", + }, + runId: "run_1", + }, + })).toEqual([ + { messageId: "turn_gateway", type: "REASONING_START" }, + { messageId: "turn_gateway", role: "reasoning", type: "REASONING_MESSAGE_START" }, + { delta: "checking current files", messageId: "turn_gateway", type: "REASONING_MESSAGE_CONTENT" }, + ]); expect(mapOpenClawEventToBeeperChunks(state, { event: "session.tool", payload: { args: { cmd: "pwd" }, phase: "started", tool: "exec", toolCallId: "tool_1" }, @@ -269,6 +300,7 @@ describe("OpenClaw event to Beeper stream mapping", () => { needsApproval: true, }, approvalMessageId: "approval_1", + choices: defaultBeeperApprovalChoices(), message: "Run command?", toolCallId: "tool_1", toolName: "exec", diff --git a/packages/openclaw/src/openclaw-event-map.ts b/packages/openclaw/src/openclaw-event-map.ts index baac172..5c776d0 100644 --- a/packages/openclaw/src/openclaw-event-map.ts +++ b/packages/openclaw/src/openclaw-event-map.ts @@ -40,15 +40,15 @@ export function mapOpenClawEventToBeeperChunks( case "run.started": return startRunEvents(state, metadata); case "assistant.delta": { - const delta = stringValue(data.delta) ?? stringValue(data.deltaText) ?? stringValue(data.text) ?? stringValue(data.content); + const delta = stringValue(data.delta) ?? stringValue(data.deltaText) ?? stringValue(data.text) ?? sessionTextDelta(data) ?? stringValue(data.content); return delta ? mapOpenClawMessageDelta(state, { kind: "text", value: delta }) : []; } case "assistant.message": { - const text = stringValue(data.deltaText) ?? stringValue(data.text) ?? stringValue(data.content) ?? stringValue(data.message); + const text = stringValue(data.deltaText) ?? stringValue(data.text) ?? sessionTextDelta(data) ?? stringValue(data.content) ?? stringValue(data.message); return text ? mapOpenClawMessageDelta(state, { kind: "text", value: text }) : []; } case "thinking.delta": { - const delta = stringValue(data.delta) ?? stringValue(data.text) ?? stringValue(data.content); + const delta = stringValue(data.delta) ?? stringValue(data.text) ?? sessionThinkingDelta(data) ?? stringValue(data.content); return delta ? mapOpenClawMessageDelta(state, { kind: "thinking", value: delta }) : []; } case "tool.call.started": @@ -101,7 +101,10 @@ export function normalizeOpenClawEventType(type: string | undefined, event?: Rec const phase = stringValue(payload?.phase) ?? stringValue(payload?.status) ?? stringValue(payload?.kind); if (type === "chat") return "assistant.delta"; if (type === "session.message") { - const role = stringValue(payload?.role); + const message = recordValue(payload?.message); + const role = stringValue(payload?.role) ?? stringValue(message?.role); + if (sessionTextDelta(payload ?? {}) !== undefined) return "assistant.delta"; + if (sessionThinkingDelta(payload ?? {}) !== undefined) return "thinking.delta"; if (role === "assistant") return "assistant.delta"; if (role === "reasoning" || role === "thinking") return "thinking.delta"; return "assistant.message"; @@ -215,6 +218,30 @@ function errorText(error: unknown): string { return JSON.stringify(error) ?? String(error); } +function sessionTextDelta(data: Record): string | undefined { + return sessionContentText(data, "text"); +} + +function sessionThinkingDelta(data: Record): string | undefined { + return sessionContentText(data, "thinking"); +} + +function sessionContentText(data: Record, kind: "text" | "thinking"): string | undefined { + const message = recordValue(data.message) ?? data; + const content = arrayValue(message.content); + if (!content) return undefined; + const chunks: string[] = []; + for (const part of content) { + const record = recordValue(part); + if (!record || record.type !== kind) continue; + const value = kind === "thinking" + ? stringValue(record.thinking) ?? stringValue(record.text) + : stringValue(record.text); + if (value) chunks.push(value); + } + return chunks.length > 0 ? chunks.join("") : undefined; +} + function stripUndefined>(input: T): T { for (const key of Object.keys(input)) { if (input[key] === undefined) delete input[key]; @@ -227,6 +254,10 @@ function recordValue(value: unknown): Record | undefined { return value as Record; } +function arrayValue(value: unknown): unknown[] | undefined { + return Array.isArray(value) ? value : undefined; +} + function stringValue(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } diff --git a/packages/openclaw/src/openclaw-extension.test.ts b/packages/openclaw/src/openclaw-extension.test.ts index c27f17c..8174dc7 100644 --- a/packages/openclaw/src/openclaw-extension.test.ts +++ b/packages/openclaw/src/openclaw-extension.test.ts @@ -17,6 +17,15 @@ describe("OpenClaw plugin package metadata", () => { }, }); expect(extension.id).toBe("beeper"); + expect(extension.kind).toBe("bundled-channel-entry"); + expect(extension.loadChannelPlugin()).toBe(registered[0]); + expect(resolveBundledRuntimeChannelRegistration(extension)).toMatchObject({ + id: "beeper", + plugin: expect.objectContaining({ + id: "beeper", + setupWizard: expect.any(Object), + }), + }); expect(registered).toEqual([ expect.objectContaining({ capabilities: expect.objectContaining({ @@ -46,15 +55,22 @@ describe("OpenClaw plugin package metadata", () => { compat?: { pluginApi?: string }; }; peerDependencies?: { openclaw?: string }; + scripts?: Record; version?: string; }; const manifest = JSON.parse(await readFile(resolve("openclaw.plugin.json"), "utf8")) as { id?: string; channels?: string[]; + channelConfigs?: Record; + schema?: { properties?: Record }; + uiHints?: Record; + }>; configSchema?: { properties?: Record; }; uiHints?: Record; + channelEnvVars?: Record; }; expect(packageJson.files).toContain("openclaw.plugin.json"); @@ -70,14 +86,17 @@ describe("OpenClaw plugin package metadata", () => { expect(packageJson.openclaw?.install?.npmSpec).toBe( `@beeper/pickle-openclaw@${packageJson.version}`, ); - expect(packageJson.openclaw?.compat?.pluginApi).toBe(">=2026.5.24"); - expect(packageJson.peerDependencies?.openclaw).toBe(">=2026.5.24"); + expect(packageJson.openclaw?.compat?.pluginApi).toBe(">=2026.5.22"); + expect(packageJson.peerDependencies?.openclaw).toBe(">=2026.5.22"); + 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.channelEnvVars?.beeper).not.toContain("PICKLE_OPENCLAW_GATEWAY_ACCESS_TOKEN"); + expect(manifest.channelEnvVars?.beeper).not.toContain("OPENCLAW_GATEWAY_TOKEN"); expect(manifest.uiHints).toMatchObject({ accessToken: { sensitive: true }, asToken: { sensitive: true }, bridgeManagerToken: { sensitive: true }, - gatewayAccessToken: { sensitive: true }, hsToken: { sensitive: true }, }); expect(Object.keys(manifest.configSchema?.properties ?? {}).sort()).toEqual([ @@ -85,6 +104,7 @@ describe("OpenClaw plugin package metadata", () => { "allowedRoomIds", "allowedUserIds", "approvalBehavior", + "appserviceId", "asToken", "backfillLimit", "baseDomain", @@ -94,8 +114,8 @@ describe("OpenClaw plugin package metadata", () => { "contactVisibility", "dataDir", "enabled", - "gatewayAccessToken", "gatewayUrl", + "ghostLocalpartPrefix", "homeserver", "homeserverDomain", "hsToken", @@ -104,7 +124,88 @@ describe("OpenClaw plugin package metadata", () => { "matrixUserId", "nonFederatedRooms", "registrationUrl", + "senderLocalpart", + "serviceBotLocalpart", + "storePath", "streamFinalization", + "userLocalpartPrefix", ]); + expect(manifest.channelConfigs?.beeper).toMatchObject({ + commands: { + nativeCommandsAutoEnabled: true, + nativeSkillsAutoEnabled: true, + }, + schema: { + properties: expect.objectContaining({ + accessToken: expect.any(Object), + gatewayUrl: expect.any(Object), + importSources: expect.any(Object), + }), + }, + uiHints: { + accessToken: { sensitive: true }, + }, + }); + }); + + 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; + files?: string[]; + main?: string; + openclaw?: { + runtimeExtensions?: string[]; + runtimeSetupEntry?: string; + }; + }; + const npmIgnore = await readFile(resolve(".npmignore"), "utf8"); + const dependencies = Object.entries(packageJson.dependencies ?? {}); + + expect(packageJson.files).toContain("dist"); + expect(npmIgnore.split(/\r?\n/)).toEqual(expect.arrayContaining([ + "src", + "!dist", + "!dist/**", + ])); + expect(packageJson.main).toBe("./dist/index.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.arrayContaining([ + ["@beeper/pickle", "workspace:^"], + ["@beeper/pickle-ag-ui", "workspace:^"], + ["@beeper/pickle-bridge", "workspace:^"], + ["@beeper/pickle-state-file", "workspace:^"], + ])); + expect(dependencies.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; + kind?: unknown; + loadChannelPlugin?: unknown; + }; + if ( + entry.kind !== "bundled-channel-entry" || + 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-extension.ts b/packages/openclaw/src/openclaw-extension.ts index 97ec6f0..908c21e 100644 --- a/packages/openclaw/src/openclaw-extension.ts +++ b/packages/openclaw/src/openclaw-extension.ts @@ -9,13 +9,15 @@ export interface OpenClawPluginApi { export const openClawBeeperPlugin = { id: "beeper", + kind: "bundled-channel-entry", name: "Beeper", description: "Bridge OpenClaw sessions and agents into Beeper.", plugin: beeperChannelPlugin, + loadChannelPlugin: () => beeperChannelPlugin, register(api: OpenClawPluginApi): void { api.registerChannel?.({ plugin: beeperChannelPlugin }); api.channels?.register?.(beeperChannelPlugin); }, -}; +} as const; export default openClawBeeperPlugin; diff --git a/packages/openclaw/src/openclaw-runtime.test.ts b/packages/openclaw/src/openclaw-runtime.test.ts index a26d917..b316a9b 100644 --- a/packages/openclaw/src/openclaw-runtime.test.ts +++ b/packages/openclaw/src/openclaw-runtime.test.ts @@ -99,6 +99,7 @@ describe("OpenClawGatewayRuntime", () => { ]; const transport = fakeTransport({ "exec.approval.resolve": { ok: true }, + "plugin.approval.resolve": { plugin: true }, }, events); const runtime = new OpenClawGatewayRuntime({ config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), @@ -113,6 +114,11 @@ describe("OpenClawGatewayRuntime", () => { 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("sends OpenClaw requests over the HTTP gateway transport", async () => { @@ -126,9 +132,8 @@ describe("OpenClawGatewayRuntime", () => { return new Response(JSON.stringify({ result: { runId: "run_1" } }), { status: 200 }); }); const transport = createOpenClawHttpTransport({ - accessToken: "secret", fetch: fetchImpl, - url: "ws://127.0.0.1:29390/openclaw", + url: "ws://127.0.0.1:18789/openclaw", }); await expect(transport.request("sessions.send", { key: "session", message: "hi" }, { expectFinal: false })).resolves.toEqual({ @@ -142,10 +147,10 @@ describe("OpenClawGatewayRuntime", () => { params: { key: "session", message: "hi" }, }, headers: expect.any(Headers), - url: "http://127.0.0.1:29390/openclaw/rpc", + url: "http://127.0.0.1:18789/openclaw/rpc", }, ]); - expect(requests[0]?.headers.get("authorization")).toBe("Bearer secret"); + expect(requests[0]?.headers.get("authorization")).toBeNull(); }); it("streams OpenClaw gateway events from SSE frames", async () => { @@ -188,18 +193,28 @@ describe("OpenClawGatewayRuntime", () => { it("uses OpenClaw gateway WebSocket req/res framing and broadcast events", async () => { FakeWebSocket.instances = []; const transport = createOpenClawWebSocketTransport({ - accessToken: "secret", WebSocket: FakeWebSocket as unknown as typeof WebSocket, url: "ws://gateway", }); const request = transport.request("sessions.send", { key: "session", message: "hi" }); + await waitFor(() => FakeWebSocket.instances.length === 1); const socket = FakeWebSocket.instances[0]; + await sendConnectChallenge(socket); await waitFor(() => socket?.sent.length === 1); expect(JSON.parse(socket?.sent[0] ?? "{}")).toMatchObject({ method: "connect", params: { - auth: { token: "secret" }, + client: { + displayName: "pickle-openclaw", + id: "gateway-client", + mode: "backend", + platform: process.platform, + version: "0.1.0", + }, + device: { + nonce: "nonce-1", + }, role: "operator", scopes: ["operator.read", "operator.write", "operator.approvals"], }, @@ -242,8 +257,10 @@ describe("OpenClawGatewayRuntime", () => { return payload.runId === "run_top"; }); const next = iterator[Symbol.asyncIterator]().next(); - await waitFor(() => (FakeWebSocket.instances[0]?.sent.length ?? 0) === 1); + await waitFor(() => FakeWebSocket.instances.length === 1); const socket = FakeWebSocket.instances[0]!; + await sendConnectChallenge(socket); + await waitFor(() => socket.sent.length === 1); socket?.receive({ id: JSON.parse(socket.sent[0] ?? "{}").id, ok: true, payload: { ok: true }, type: "res" }); await new Promise((resolve) => setTimeout(resolve, 0)); socket?.receive({ event: "session.message", runId: "run_skip", type: "event" }); @@ -259,6 +276,42 @@ describe("OpenClawGatewayRuntime", () => { }); transport.close(); }); + + it("replays early WebSocket run events to late subscribers", async () => { + FakeWebSocket.instances = []; + const transport = createOpenClawWebSocketTransport({ + replayLimit: 10, + WebSocket: FakeWebSocket as unknown as typeof WebSocket, + url: "ws://gateway", + }); + + const request = transport.request("sessions.send", { key: "session", message: "hi" }); + await waitFor(() => FakeWebSocket.instances.length === 1); + const socket = FakeWebSocket.instances[0]!; + await sendConnectChallenge(socket); + await waitFor(() => socket.sent.length === 1); + socket.receive({ id: JSON.parse(socket.sent[0] ?? "{}").id, ok: true, payload: { ok: true }, type: "res" }); + await waitFor(() => socket.sent.length === 2); + const sent = JSON.parse(socket.sent[1] ?? "{}"); + socket.receive({ event: "session.message", payload: { deltaText: "early", runId: "run_early" }, seq: 5, type: "event" }); + socket.receive({ id: sent.id, ok: true, payload: { runId: "run_early" }, type: "res" }); + await expect(request).resolves.toEqual({ runId: "run_early" }); + + const iterator = transport.events((event) => { + const payload = event.payload as { runId?: string }; + return payload.runId === "run_early"; + })[Symbol.asyncIterator](); + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { + event: "session.message", + payload: { deltaText: "early", runId: "run_early" }, + seq: 5, + }, + }); + await iterator.return?.(); + transport.close(); + }); }); class FakeWebSocket { @@ -311,6 +364,12 @@ async function waitFor(predicate: () => boolean): Promise { throw new Error("Timed out waiting for condition"); } +async function sendConnectChallenge(socket: FakeWebSocket | undefined): Promise { + await waitFor(() => socket?.readyState === 1); + await new Promise((resolve) => setTimeout(resolve, 0)); + socket?.receive({ event: "connect.challenge", payload: { nonce: "nonce-1" }, type: "event" }); +} + function fakeTransport(responses: Record, events: OpenClawGatewayEvent[] = []): OpenClawTransport & { request: ReturnType; } { diff --git a/packages/openclaw/src/openclaw-runtime.ts b/packages/openclaw/src/openclaw-runtime.ts index 9fd5c1c..93d3fc2 100644 --- a/packages/openclaw/src/openclaw-runtime.ts +++ b/packages/openclaw/src/openclaw-runtime.ts @@ -1,3 +1,6 @@ +import { generateKeyPairSync, createHash, createPrivateKey, createPublicKey, sign } from "node:crypto"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; import type { OpenClawAgentContact, OpenClawBridgeConfig } from "./types"; import { agentContactFromOpenClawAgent } from "./rooms"; import type { OpenClawApprovalResolvePayload } from "./approval"; @@ -21,7 +24,6 @@ export interface OpenClawTransport { } export interface OpenClawHttpTransportOptions { - accessToken?: string; eventsPath?: string; fetch?: typeof fetch; requestPath?: string; @@ -29,14 +31,35 @@ export interface OpenClawHttpTransportOptions { } export interface OpenClawWebSocketTransportOptions { - accessToken?: string; clientId?: string; + deviceIdentityPath?: string; + deviceToken?: string; clientVersion?: string; + replayLimit?: number; requestTimeoutMs?: number; url: string; WebSocket?: typeof WebSocket; } +const DEFAULT_GATEWAY_CLIENT_ID = "gateway-client"; +const DEFAULT_GATEWAY_CLIENT_MODE = "backend"; +const DEFAULT_GATEWAY_ROLE = "operator"; +const DEFAULT_GATEWAY_SCOPES = ["operator.read", "operator.write", "operator.approvals"]; +const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); + +type GatewayDeviceIdentity = { + deviceId: string; + privateKeyPem: string; + publicKeyPem: string; +}; + +type StoredGatewayDeviceIdentity = GatewayDeviceIdentity & { + createdAtMs: number; + deviceToken?: string; + tokenScopes?: string[]; + version: 1; +}; + export interface OpenClawSessionCreateOptions { agentId: string; key?: string; @@ -58,7 +81,20 @@ export interface OpenClawSessionSendOptions { 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 { + attachments?: OpenClawMatrixAttachmentMetadata[]; formattedBody?: string; mentions?: { room?: boolean; @@ -66,9 +102,16 @@ export interface OpenClawMatrixMessageMetadata { }; relation?: { key?: string; - kind?: "reply" | "thread" | "edit" | "reaction" | "redaction"; + kind?: "reply" | "thread" | "edit" | "reaction" | "reaction_remove" | "redaction"; + quote?: { + body?: string; + sender?: string; + }; replyToEventId?: string; targetEventId?: string; + targetReactionId?: string; + targetRunId?: string; + targetSessionKey?: string; threadRootEventId?: string; }; sender?: string; @@ -349,7 +392,9 @@ export class OpenClawGatewayRuntime { } async resolveApproval(payload: OpenClawApprovalResolvePayload): Promise { - return await this.transport.request("exec.approval.resolve", payload); + const { approvalKind, ...requestPayload } = payload; + const method = approvalKind === "plugin" ? "plugin.approval.resolve" : "exec.approval.resolve"; + return await this.transport.request(method, requestPayload); } async close(): Promise { @@ -358,7 +403,6 @@ export class OpenClawGatewayRuntime { } export class OpenClawHttpTransport implements OpenClawTransport { - readonly #accessToken: string | undefined; readonly #baseUrl: URL; readonly #eventsPath: string; readonly #fetch: typeof fetch; @@ -366,7 +410,6 @@ export class OpenClawHttpTransport implements OpenClawTransport { #abortController = new AbortController(); constructor(options: OpenClawHttpTransportOptions) { - this.#accessToken = options.accessToken; this.#baseUrl = normalizeGatewayUrl(options.url); this.#eventsPath = options.eventsPath ?? "/events"; this.#fetch = options.fetch ?? fetch; @@ -421,7 +464,6 @@ export class OpenClawHttpTransport implements OpenClawTransport { #headers(accept: string): Record { return stripUndefined({ accept, - authorization: this.#accessToken ? `Bearer ${this.#accessToken}` : undefined, }); } } @@ -443,6 +485,7 @@ export class OpenClawWebSocketTransport implements OpenClawTransport { notify: (() => void) | undefined; closed: boolean; }>(); + readonly #replay: OpenClawGatewayEvent[] = []; #connectPromise: Promise | undefined; #socket: WebSocket | undefined; @@ -478,7 +521,12 @@ export class OpenClawWebSocketTransport implements OpenClawTransport { async *events(filter?: (event: OpenClawGatewayEvent) => boolean): AsyncIterable { await this.#connect(); - const subscriber = { closed: false, events: [] as OpenClawGatewayEvent[], filter, notify: undefined as (() => void) | undefined }; + const subscriber = { + closed: false, + events: this.#replay.filter((event) => !filter || filter(event)), + filter, + notify: undefined as (() => void) | undefined, + }; this.#subscribers.add(subscriber); try { for (;;) { @@ -547,21 +595,93 @@ export class OpenClawWebSocketTransport implements OpenClawTransport { socket.addEventListener("close", () => { this.close(); }); + const challenge = await this.#waitForConnectChallenge(socket); + const identityState = this.#loadDeviceIdentityState(); + const clientId = this.#options.clientId ?? DEFAULT_GATEWAY_CLIENT_ID; + const clientMode = DEFAULT_GATEWAY_CLIENT_MODE; + const role = DEFAULT_GATEWAY_ROLE; + const scopes = [...DEFAULT_GATEWAY_SCOPES]; + const platform = process.platform; + const deviceToken = this.#options.deviceToken ?? identityState.stored.deviceToken; await this.#sendRequest("connect", { - auth: this.#options.accessToken ? { token: this.#options.accessToken } : {}, + auth: stripUndefined({ + deviceToken, + }), client: { - id: this.#options.clientId ?? "pickle-openclaw", - mode: "backend", - platform: "matrix", + displayName: "pickle-openclaw", + id: clientId, + mode: clientMode, + platform, version: this.#options.clientVersion ?? "0.1.0", }, + device: buildGatewayDeviceConnectParams(stripUndefined({ + clientId, + clientMode, + identity: identityState.identity, + nonce: challenge.nonce, + platform, + role, + scopes, + token: deviceToken, + })), maxProtocol: 4, minProtocol: 4, - role: "operator", - scopes: ["operator.read", "operator.write", "operator.approvals"], + role, + scopes, + }).then((hello) => { + const auth = recordValue(recordValue(hello)?.auth); + const nextDeviceToken = stringValue(auth?.deviceToken); + if (nextDeviceToken && this.#options.deviceIdentityPath) { + writeDeviceIdentityState(this.#options.deviceIdentityPath, stripUndefined({ + ...identityState.stored, + deviceToken: nextDeviceToken, + tokenScopes: arrayValue(auth?.scopes)?.filter((scope): scope is string => typeof scope === "string"), + })); + } + }); + } + + #waitForConnectChallenge(socket: WebSocket): Promise<{ nonce: string }> { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); + reject(new Error("OpenClaw gateway connect challenge timed out")); + }, this.#options.requestTimeoutMs ?? 30_000); + const cleanup = () => { + clearTimeout(timeout); + socket.removeEventListener("message", onMessage); + socket.removeEventListener("close", onClose); + }; + const onClose = () => { + cleanup(); + reject(new Error("OpenClaw gateway socket closed before connect challenge")); + }; + const onMessage = (event: MessageEvent) => { + const frame = recordValue(safeJsonParse(String(event.data))); + if (frame?.type !== "event" || frame.event !== "connect.challenge") return; + const nonce = stringValue(recordValue(frame.payload)?.nonce); + if (!nonce) { + cleanup(); + reject(new Error("OpenClaw gateway connect challenge missing nonce")); + return; + } + cleanup(); + resolve({ nonce }); + }; + socket.addEventListener("message", onMessage); + socket.addEventListener("close", onClose); }); } + #loadDeviceIdentityState(): { identity: GatewayDeviceIdentity; stored: StoredGatewayDeviceIdentity } { + if (this.#options.deviceIdentityPath) return loadOrCreateDeviceIdentityState(this.#options.deviceIdentityPath); + const identity = generateDeviceIdentity(); + return { + identity, + stored: { ...identity, createdAtMs: Date.now(), version: 1 }, + }; + } + #handleFrame(raw: string): void { const frame = JSON.parse(raw) as Record; if (frame.type === "res") { @@ -581,6 +701,7 @@ export class OpenClawWebSocketTransport implements OpenClawTransport { seq: typeof frame.seq === "number" ? frame.seq : undefined, stateVersion: frame.stateVersion, }); + this.#recordReplay(event); for (const subscriber of this.#subscribers) { if (!subscriber.filter || subscriber.filter(event)) { subscriber.events.push(event); @@ -590,6 +711,16 @@ export class OpenClawWebSocketTransport implements OpenClawTransport { } } } + + #recordReplay(event: OpenClawGatewayEvent): void { + this.#replay.push(event); + const limit = this.#options.replayLimit ?? 500; + if (limit <= 0) { + this.#replay.length = 0; + return; + } + if (this.#replay.length > limit) this.#replay.splice(0, this.#replay.length - limit); + } } export function createOpenClawWebSocketTransport(options: OpenClawWebSocketTransportOptions): OpenClawWebSocketTransport { @@ -706,6 +837,112 @@ function errorMessage(error: unknown): string { return stringValue(record?.message) ?? stringValue(error) ?? JSON.stringify(error); } +function safeJsonParse(raw: string): unknown { + try { + return JSON.parse(raw) as unknown; + } catch { + return undefined; + } +} + +function loadOrCreateDeviceIdentityState(filePath: string): { + identity: GatewayDeviceIdentity; + stored: StoredGatewayDeviceIdentity; +} { + const parsed = readStoredDeviceIdentity(filePath); + if (parsed) return { identity: parsed, stored: parsed }; + const identity = generateDeviceIdentity(); + const stored = { ...identity, createdAtMs: Date.now(), version: 1 as const }; + writeDeviceIdentityState(filePath, stored); + return { identity, stored }; +} + +function readStoredDeviceIdentity(filePath: string): StoredGatewayDeviceIdentity | undefined { + try { + const parsed = recordValue(JSON.parse(readFileSync(filePath, "utf8")) as unknown); + if (!parsed || parsed.version !== 1) return undefined; + const deviceId = stringValue(parsed.deviceId); + const publicKeyPem = stringValue(parsed.publicKeyPem); + const privateKeyPem = stringValue(parsed.privateKeyPem); + if (!deviceId || !publicKeyPem || !privateKeyPem) return undefined; + return stripUndefined({ + createdAtMs: typeof parsed.createdAtMs === "number" ? parsed.createdAtMs : Date.now(), + deviceId, + deviceToken: stringValue(parsed.deviceToken), + privateKeyPem, + publicKeyPem, + tokenScopes: arrayValue(parsed.tokenScopes)?.filter((scope): scope is string => typeof scope === "string"), + version: 1 as const, + }); + } catch { + return undefined; + } +} + +function writeDeviceIdentityState(filePath: string, value: StoredGatewayDeviceIdentity): void { + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); +} + +function generateDeviceIdentity(): GatewayDeviceIdentity { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString(); + const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + return { + deviceId: createHash("sha256").update(publicKeyRawFromPem(publicKeyPem)).digest("hex"), + privateKeyPem, + publicKeyPem, + }; +} + +function buildGatewayDeviceConnectParams(options: { + clientId: string; + clientMode: string; + identity: GatewayDeviceIdentity; + nonce: string; + platform: string; + role: string; + scopes: string[]; + token?: string; +}): Record { + const signedAt = Date.now(); + const payload = [ + "v3", + options.identity.deviceId, + options.clientId, + options.clientMode, + options.role, + options.scopes.join(","), + String(signedAt), + options.token ?? "", + options.nonce, + options.platform.trim(), + "", + ].join("|"); + return { + id: options.identity.deviceId, + nonce: options.nonce, + publicKey: base64Url(publicKeyRawFromPem(options.identity.publicKeyPem)), + signature: base64Url(sign(null, Buffer.from(payload, "utf8"), createPrivateKey(options.identity.privateKeyPem))), + signedAt, + }; +} + +function publicKeyRawFromPem(publicKeyPem: string): Buffer { + const spki = createPublicKey(publicKeyPem).export({ type: "spki", format: "der" }) as Buffer; + if ( + spki.length === ED25519_SPKI_PREFIX.length + 32 && + spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX) + ) { + return spki.subarray(ED25519_SPKI_PREFIX.length); + } + return spki; +} + +function base64Url(value: Buffer): string { + return value.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, ""); +} + type StripUndefined = { [K in keyof T as undefined extends T[K] ? never : K]: T[K]; } & { diff --git a/packages/openclaw/src/setup.test.ts b/packages/openclaw/src/setup.test.ts index 682d57c..5fae1fb 100644 --- a/packages/openclaw/src/setup.test.ts +++ b/packages/openclaw/src/setup.test.ts @@ -3,7 +3,9 @@ import extension from "./openclaw-extension"; import setupEntry from "./setup-entry"; import { applyBeeperChannelSettings, + beeperChannelConfig, beeperChannelPlugin, + beeperStatusAdapter, beeperSetupAdapter, beeperSetupWizard, defaultBeeperChannelSettings, @@ -57,9 +59,6 @@ describe("OpenClaw Beeper setup surface", () => { bridgeManagerToken: { sensitive: true, }, - gatewayAccessToken: { - sensitive: true, - }, hsToken: { sensitive: true, }, @@ -106,18 +105,19 @@ describe("OpenClaw Beeper setup surface", () => { startAccount: expect.any(Function), stopAccount: expect.any(Function), })); + expect(beeperChannelPlugin.status).toBe(beeperStatusAdapter); const cfg = beeperSetupAdapter.applyAccountConfig({ accountId: "default", cfg: {}, input: { - gatewayUrl: "ws://127.0.0.1:29390", + gatewayUrl: "ws://127.0.0.1:18789", registrationUrl: "http://127.0.0.1:29391", }, }); expect(cfg).not.toHaveProperty("then"); expect(getBeeperChannelSettings(cfg)).toMatchObject({ - gatewayUrl: "ws://127.0.0.1:29390", + gatewayUrl: "ws://127.0.0.1:18789", registrationUrl: "http://127.0.0.1:29391", }); }); @@ -200,24 +200,30 @@ describe("OpenClaw Beeper setup surface", () => { accessToken: "mx", allowedRoomIds: "!one:example,!two:example,!one:example", allowedUserIds: ["@alice:example", "@bob:example", "@alice:example"], + appserviceId: "custom-openclaw", approvalBehavior: "native", backfillLimit: "42", baseDomain: "beeper-staging.com", beeperEnv: "staging", bridgeManagerToken: "hungry", contactVisibility: "agents-and-users", - gatewayAccessToken: "gw-token", - gatewayUrl: "ws://127.0.0.1:29390", + gatewayUrl: "ws://127.0.0.1:18789", + ghostLocalpartPrefix: "oc_agent_", importSources: "dashboard,tui", nonFederatedRooms: "false", registrationUrl: "http://127.0.0.1:29391", + senderLocalpart: "ocbot", + serviceBotLocalpart: "ocservice", + storePath: "/tmp/openclaw-store", streamFinalization: "replace", + userLocalpartPrefix: "oc_user_", }, }); expect(getBeeperChannelSettings(cfg)).toEqual({ accessToken: "mx", allowedRoomIds: ["!one:example", "!two:example"], allowedUserIds: ["@alice:example", "@bob:example"], + appserviceId: "custom-openclaw", approvalBehavior: "native", backfillLimit: 42, baseDomain: "beeper-staging.com", @@ -225,12 +231,16 @@ describe("OpenClaw Beeper setup surface", () => { bridgeManagerToken: "hungry", contactVisibility: "agents-and-users", enabled: true, - gatewayAccessToken: "gw-token", - gatewayUrl: "ws://127.0.0.1:29390", + gatewayUrl: "ws://127.0.0.1:18789", + ghostLocalpartPrefix: "oc_agent_", importSources: ["dashboard", "tui"], nonFederatedRooms: false, registrationUrl: "http://127.0.0.1:29391", + senderLocalpart: "ocbot", + serviceBotLocalpart: "ocservice", + storePath: "/tmp/openclaw-store", streamFinalization: "replace", + userLocalpartPrefix: "oc_user_", }); expect(isBeeperChannelConfigured(cfg)).toBe(false); expect(cfg.plugins?.entries?.beeper).toEqual({ @@ -256,7 +266,6 @@ describe("OpenClaw Beeper setup surface", () => { const promptValues: Record = { "Beeper email": "alice@example.com", "Beeper login code": "123456", - "OpenClaw Gateway URL": "ws://127.0.0.1:29390", "Appservice callback URL": "http://127.0.0.1:29391", "Beeper API base domain": "beeper.localtest.me", "Bridge manager token": "hungry", @@ -333,7 +342,7 @@ describe("OpenClaw Beeper setup surface", () => { baseDomain: "beeper.localtest.me", bridgeManagerPostState: false, bridgeManagerToken: "hungry", - gatewayUrl: "ws://127.0.0.1:29390", + gatewayUrl: "ws://127.0.0.1:18789", homeserver: "https://matrix.example", homeserverDomain: "beeper.local", hsToken: "hs", @@ -350,14 +359,14 @@ describe("OpenClaw Beeper setup surface", () => { input: { accessToken: "at", asToken: "as", - gatewayUrl: "ws://127.0.0.1:29390", + gatewayUrl: "ws://127.0.0.1:18789", registrationUrl: "http://127.0.0.1:29391", }, }); expect(getBeeperChannelSettings(cfg)).toMatchObject({ accessToken: "at", asToken: "as", - gatewayUrl: "ws://127.0.0.1:29390", + gatewayUrl: "ws://127.0.0.1:18789", registrationUrl: "http://127.0.0.1:29391", }); }); @@ -390,7 +399,7 @@ describe("OpenClaw Beeper setup surface", () => { beeperEnv: "dev", code: "123456", email: "alice@example.com", - gatewayUrl: "ws://127.0.0.1:29390", + gatewayUrl: "ws://127.0.0.1:18789", registrationUrl: "http://127.0.0.1:29391", }, runtime: { @@ -437,7 +446,7 @@ describe("OpenClaw Beeper setup surface", () => { enabled: true, accessToken: "at", asToken: "as", - gatewayUrl: "ws://127.0.0.1:29390", + gatewayUrl: "ws://127.0.0.1:18789", homeserver: "https://matrix.example", hsToken: "hs", matrixDeviceId: "DEV", @@ -475,6 +484,44 @@ describe("OpenClaw Beeper setup surface", () => { }); }); + it("reports lightweight channel status without starting bridge runtime", () => { + const account = beeperChannelConfig.resolveAccount(applyBeeperChannelSettings({}, { + enabled: true, + gatewayUrl: "ws://gateway", + importSources: ["dashboard", "tui"], + registrationUrl: "http://bridge", + streamFinalization: "replace", + })); + const snapshot = beeperStatusAdapter.buildAccountSnapshot({ account }); + + expect(snapshot).toMatchObject({ + accountId: "default", + configured: false, + enabled: true, + extra: { + gatewayUrl: "ws://gateway", + importSources: ["dashboard", "tui"], + mode: "self-hosted-appservice", + registrationUrl: "http://bridge", + }, + running: false, + }); + expect(beeperStatusAdapter.buildChannelSummary({ snapshot })).toMatchObject({ + configured: false, + enabled: true, + gatewayUrl: "ws://gateway", + mode: "self-hosted-appservice", + running: false, + }); + expect(beeperStatusAdapter.resolveAccountState({ configured: false, enabled: true })).toBe("missing_credentials"); + expect(beeperStatusAdapter.collectStatusIssues([snapshot])).toEqual([ + expect.objectContaining({ + message: expect.stringContaining("not fully configured"), + severity: "warning", + }), + ]); + }); + it("creates bridge runtime config from persisted channels.beeper settings", () => { const cfg = createConfigFromOpenClawSetup({ channels: { diff --git a/packages/openclaw/src/setup.ts b/packages/openclaw/src/setup.ts index 89e542f..f113e2c 100644 --- a/packages/openclaw/src/setup.ts +++ b/packages/openclaw/src/setup.ts @@ -1,4 +1,4 @@ -import { createConfigFromOpenClawSetup, DEFAULT_REGISTRATION_URL, defaultDataDir } from "./config"; +import { createConfigFromOpenClawSetup, DEFAULT_GATEWAY_URL, DEFAULT_REGISTRATION_URL, defaultDataDir } from "./config"; import type { setupOpenClawBeeperBridge, SetupOpenClawBeeperBridgeOptions } from "./beeper-setup"; export type OpenClawSetupConfig = { @@ -14,6 +14,7 @@ export interface BeeperChannelSettings { accessToken?: string; allowedRoomIds?: string[]; allowedUserIds?: string[]; + appserviceId?: string; asToken?: string; approvalBehavior?: "native" | "reactions" | "slash" | "disabled"; backfillLimit?: number; @@ -24,8 +25,8 @@ export interface BeeperChannelSettings { contactVisibility?: "agents" | "agents-and-users" | "none"; dataDir?: string; enabled?: boolean; - gatewayAccessToken?: string; gatewayUrl?: string; + ghostLocalpartPrefix?: string; homeserver?: string; hsToken?: string; importSources?: BeeperImportSource[]; @@ -34,13 +35,18 @@ export interface BeeperChannelSettings { homeserverDomain?: string; nonFederatedRooms?: boolean; registrationUrl?: string; + senderLocalpart?: string; + serviceBotLocalpart?: string; + storePath?: string; streamFinalization?: "replace" | "append" | "native-only"; + userLocalpartPrefix?: string; } export interface BeeperSetupInput { accessToken?: string; allowedRoomIds?: string[] | string; allowedUserIds?: string[] | string; + appserviceId?: string; asToken?: string; approvalBehavior?: string; backfillLimit?: number | string; @@ -52,17 +58,21 @@ export interface BeeperSetupInput { dataDir?: string; email?: string; getOnly?: boolean | string; - gatewayAccessToken?: string; gatewayUrl?: string; + ghostLocalpartPrefix?: string; homeserverDomain?: string; importSources?: string[] | string; nonFederatedRooms?: boolean | string; postState?: boolean | string; push?: boolean | string; registrationUrl?: string; + senderLocalpart?: string; + serviceBotLocalpart?: string; selfHosted?: boolean | string; + storePath?: string; streamFinalization?: string; username?: string; + userLocalpartPrefix?: string; } export interface BeeperSetupRuntime { @@ -116,6 +126,7 @@ export const BeeperChannelConfigSchema = { additionalProperties: false, properties: { accessToken: { type: "string" }, + appserviceId: { type: "string" }, asToken: { type: "string" }, allowedRoomIds: { type: "array", items: { type: "string" } }, allowedUserIds: { type: "array", items: { type: "string" } }, @@ -123,8 +134,12 @@ export const BeeperChannelConfigSchema = { baseDomain: { type: "string" }, beeperEnv: { type: "string", enum: ["production", "staging", "dev", "local"] }, dataDir: { type: "string" }, - gatewayAccessToken: { type: "string" }, gatewayUrl: { type: "string" }, + ghostLocalpartPrefix: { type: "string" }, + homeserver: { type: "string" }, + hsToken: { type: "string" }, + matrixDeviceId: { type: "string" }, + matrixUserId: { type: "string" }, registrationUrl: { type: "string" }, bridgeManagerToken: { type: "string" }, bridgeManagerPostState: { type: "boolean" }, @@ -134,10 +149,14 @@ export const BeeperChannelConfigSchema = { }, backfillLimit: { type: "number" }, nonFederatedRooms: { type: "boolean" }, + senderLocalpart: { type: "string" }, + serviceBotLocalpart: { type: "string" }, + storePath: { type: "string" }, contactVisibility: { type: "string", enum: ["agents", "agents-and-users", "none"] }, homeserverDomain: { type: "string" }, streamFinalization: { type: "string", enum: ["replace", "append", "native-only"] }, approvalBehavior: { type: "string", enum: ["native", "reactions", "slash", "disabled"] }, + userLocalpartPrefix: { type: "string" }, }, } as const; @@ -152,11 +171,6 @@ export const BeeperChannelUiHints = { label: "Bridge Manager Token", sensitive: true, }, - gatewayAccessToken: { - help: "Optional bearer token for the local OpenClaw gateway.", - label: "OpenClaw Gateway Token", - sensitive: true, - }, asToken: { help: "Appservice token returned by Beeper bridge registration.", label: "Appservice Token", @@ -233,11 +247,6 @@ export const beeperSetupWizard = { sensitive: true, validate: (value) => (value.trim() ? undefined : "Beeper login code is required."), }); - const gatewayUrl = await ctx.prompter.text({ - message: "OpenClaw Gateway URL", - initialValue: current.gatewayUrl ?? "ws://127.0.0.1:29390", - validate: (value) => (value.trim() ? undefined : "OpenClaw Gateway URL is required."), - }); const registrationUrl = await ctx.prompter.text({ message: "Appservice callback URL", initialValue: current.registrationUrl ?? DEFAULT_REGISTRATION_URL, @@ -328,7 +337,7 @@ export const beeperSetupWizard = { backfillLimit, code, email, - gatewayUrl, + gatewayUrl: current.gatewayUrl ?? DEFAULT_GATEWAY_URL, importSources, nonFederatedRooms, postState, @@ -379,6 +388,58 @@ export const beeperChannelConfig = { }), }; +export const beeperStatusAdapter = { + defaultRuntime: { + accountId: "default", + configured: false, + enabled: false, + extra: { + mode: "self-hosted-appservice", + }, + running: false, + }, + buildChannelSummary: ({ snapshot }: { snapshot: Record }) => ({ + configured: snapshot.configured === true, + enabled: snapshot.enabled !== false, + gatewayUrl: recordValue(snapshot.extra)?.gatewayUrl, + homeserver: recordValue(snapshot.extra)?.homeserver, + mode: "self-hosted-appservice", + running: snapshot.running === true, + }), + buildAccountSnapshot: ({ account }: { account: { accountId?: string; configured?: boolean; settings?: BeeperChannelSettings } }) => { + const settings = account.settings ?? {}; + return { + accountId: account.accountId ?? "default", + configured: account.configured === true, + enabled: settings.enabled !== false, + extra: { + approvalBehavior: settings.approvalBehavior ?? "native", + beeperEnv: settings.beeperEnv ?? "production", + contactVisibility: settings.contactVisibility ?? "agents", + gatewayUrl: settings.gatewayUrl, + homeserver: settings.homeserver, + importSources: settings.importSources ?? [], + mode: "self-hosted-appservice", + registrationUrl: settings.registrationUrl, + streamFinalization: settings.streamFinalization ?? "replace", + }, + name: "Beeper", + running: false, + }; + }, + resolveAccountState: ({ configured, enabled }: { configured: boolean; enabled: boolean }) => { + if (!enabled) return "disabled"; + return configured ? "configured" : "missing_credentials"; + }, + collectStatusIssues: (accounts: Array<{ configured?: boolean; enabled?: boolean }>) => + accounts + .filter((account) => account.enabled !== false && account.configured !== true) + .map(() => ({ + message: "Beeper bridge is not fully configured; run Beeper channel setup.", + severity: "warning", + })), +}; + const startedBridges = new Map(); export async function applyBeeperSetupConfig(params: { @@ -432,6 +493,7 @@ export const beeperChannelPlugin = { configSchema: BeeperChannelConfigSchema, uiHints: BeeperChannelUiHints, config: beeperChannelConfig, + status: beeperStatusAdapter, gateway: { startAccount: startBeeperGatewayAccount, stopAccount: stopBeeperGatewayAccount, @@ -552,6 +614,7 @@ export function defaultBeeperChannelSettings(): BeeperChannelSettings { contactVisibility: "agents", dataDir: defaultDataDir(), enabled: true, + gatewayUrl: DEFAULT_GATEWAY_URL, importSources: ["dashboard", "tui"], nonFederatedRooms: true, registrationUrl: DEFAULT_REGISTRATION_URL, @@ -583,6 +646,7 @@ export function normalizeBeeperSetupInput(input: BeeperSetupInput): Partial; @@ -245,6 +246,7 @@ export function mapOpenClawApprovalRequest( needsApproval: true, }, approvalMessageId: approvalId, + choices: defaultBeeperApprovalChoices(), message: event.message, toolCallId, toolName: event.toolName, diff --git a/packages/openclaw/src/types.ts b/packages/openclaw/src/types.ts index 4fe4389..056859a 100644 --- a/packages/openclaw/src/types.ts +++ b/packages/openclaw/src/types.ts @@ -33,6 +33,7 @@ export interface OpenClawSessionBinding { updatedAt: number; lastRunId?: string; lastMatrixEventId?: string; + lastStreamRunId?: string; lastStreamTargetEventId?: string; } @@ -51,7 +52,6 @@ export interface OpenClawBridgeConfig { contactVisibility?: "agents" | "agents-and-users" | "none"; dataDir: string; ghostLocalpartPrefix: string; - gatewayAccessToken?: string; gatewayUrl?: string; homeserver?: string; hsToken?: string; diff --git a/packages/pickle/native/internal/core/appservice_test.go b/packages/pickle/native/internal/core/appservice_test.go index f88d285..a5b669d 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" @@ -207,6 +208,66 @@ 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) + } + deltas, ok := content[aistream.BeeperAIStreamDeltas].([]aistream.Envelope) + if !ok || len(deltas) != 1 { + t.Fatalf("expected ai-bridge deltas envelope, got %#v", content) + } + envelope := deltas[0] + if envelope.Seq != 7 || envelope.TargetEvent != "$stream" || envelope.AgentID != "codex" { + t.Fatalf("unexpected ai-bridge envelope routing fields: %#v", envelope) + } + if envelope.ThreadID != "thread-1" || envelope.RunID != "run-1" || envelope.MessageID != "msg-1" { + t.Fatalf("unexpected ai-bridge run identity: %#v", envelope) + } + if envelope.RelatesTo.Type != "m.reference" || envelope.RelatesTo.EventID != "$stream" { + t.Fatalf("expected ai-bridge reference relation, got %#v", envelope.RelatesTo) + } + if envelope.Part["type"] != "TEXT_MESSAGE_CONTENT" || envelope.Part["delta"] != "hello" { + t.Fatalf("unexpected ai-bridge part payload: %#v", envelope.Part) + } + if _, ok := envelope.Part["timestamp"]; !ok { + t.Fatalf("expected native bridge to add timestamp before ai-bridge validation: %#v", envelope.Part) + } + + remapped, 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 := remapped[aistream.BeeperAIStreamDeltas]; ok { + t.Fatalf("expected custom stream type to remap ai-bridge deltas key, got %#v", remapped) + } + if _, ok := remapped["com.example.custom.deltas"].([]aistream.Envelope); !ok { + t.Fatalf("expected custom stream deltas to still use ai-bridge envelopes, got %#v", remapped) + } +} + func TestRegisterBeeperStreamInjectsDirectSubscribers(t *testing.T) { requests := make(chan recordedRequest, 4) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31a832c..534f24f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -220,16 +220,16 @@ importers: packages/openclaw: dependencies: '@beeper/pickle': - specifier: workspace:* + specifier: workspace:^ version: link:../pickle '@beeper/pickle-ag-ui': - specifier: workspace:* + specifier: workspace:^ version: link:../ag-ui '@beeper/pickle-bridge': - specifier: workspace:* + specifier: workspace:^ version: link:../bridge '@beeper/pickle-state-file': - specifier: workspace:* + specifier: workspace:^ version: link:../state-file devDependencies: '@types/node': From 485850a734a9a44ace675bd49967af74e447c078 Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Mon, 25 May 2026 14:33:46 +0200 Subject: [PATCH 30/43] Refine pickle openclaw plugin packaging and session handling --- packages/bridge/src/beeper.test.ts | 27 +- packages/bridge/src/beeper.ts | 9 +- packages/bridge/src/bridge.ts | 4 + packages/bridge/src/provisioning.test.ts | 48 + packages/bridge/src/provisioning.ts | 86 ++ packages/openclaw/README.md | 75 +- packages/openclaw/openclaw.plugin.json | 19 +- packages/openclaw/package.json | 8 +- .../openclaw/scripts/copy-runtime-assets.mjs | 19 + packages/openclaw/src/appservice.test.ts | 157 ++- packages/openclaw/src/appservice.ts | 127 +- packages/openclaw/src/backfill.test.ts | 139 ++- packages/openclaw/src/backfill.ts | 116 +- packages/openclaw/src/beeper-setup.test.ts | 45 +- packages/openclaw/src/beeper-setup.ts | 40 +- packages/openclaw/src/beeper-stream.test.ts | 2 +- packages/openclaw/src/beeper-stream.ts | 2 +- packages/openclaw/src/bridge-agent.ts | 12 +- packages/openclaw/src/cli.test.ts | 686 +++-------- packages/openclaw/src/cli.ts | 359 +----- packages/openclaw/src/config.test.ts | 13 +- packages/openclaw/src/config.ts | 11 +- packages/openclaw/src/connector.test.ts | 202 ++- packages/openclaw/src/connector.ts | 240 ++-- packages/openclaw/src/ids.ts | 9 + packages/openclaw/src/integration.test.ts | 5 - .../openclaw/src/openclaw-extension.test.ts | 11 +- packages/openclaw/src/openclaw-extension.ts | 21 +- packages/openclaw/src/openclaw-identity.ts | 33 + .../openclaw/src/openclaw-runtime.test.ts | 404 +++--- packages/openclaw/src/openclaw-runtime.ts | 1089 +++++++++-------- .../openclaw/src/protocol-coverage.test.ts | 2 +- packages/openclaw/src/protocol-coverage.ts | 2 +- packages/openclaw/src/registration.test.ts | 15 +- packages/openclaw/src/registration.ts | 16 +- packages/openclaw/src/rooms.ts | 14 +- packages/openclaw/src/setup.test.ts | 39 +- packages/openclaw/src/setup.ts | 52 +- packages/openclaw/src/types.ts | 2 +- packages/openclaw/tsdown.config.ts | 3 + .../pickle/native/internal/core/appservice.go | 43 +- packages/pickle/src/beeper/auth.test.ts | 26 + packages/pickle/src/beeper/auth.ts | 8 +- 43 files changed, 2286 insertions(+), 1954 deletions(-) create mode 100644 packages/openclaw/scripts/copy-runtime-assets.mjs create mode 100644 packages/openclaw/src/ids.ts create mode 100644 packages/openclaw/src/openclaw-identity.ts 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..ae80778 100644 --- a/packages/bridge/src/beeper.ts +++ b/packages/bridge/src/beeper.ts @@ -112,6 +112,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 +122,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 +136,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.ts b/packages/bridge/src/bridge.ts index 741e16d..a7afc80 100644 --- a/packages/bridge/src/bridge.ts +++ b/packages/bridge/src/bridge.ts @@ -852,6 +852,10 @@ 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, query, limit) => { const client = await this.loadUserLogin(login); if (!hasMethod(client, "listContacts")) { diff --git a/packages/bridge/src/provisioning.test.ts b/packages/bridge/src/provisioning.test.ts index 72b5799..9c8ad44 100644 --- a/packages/bridge/src/provisioning.test.ts +++ b/packages/bridge/src/provisioning.test.ts @@ -67,6 +67,41 @@ describe("handleProvisioningHTTPProxy", () => { expect(runtime.listContacts).toHaveBeenCalledWith({ id: "intern" }, "codex", 10); }); + 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(); @@ -95,6 +130,7 @@ describe("handleProvisioningHTTPProxy", () => { expect(runtime.resolveIdentifier).not.toHaveBeenCalled(); expect(runtime.listContacts).not.toHaveBeenCalled(); + expect(runtime.backfill).not.toHaveBeenCalled(); }); }); @@ -115,6 +151,18 @@ function provisioningRuntime(): ProvisioningRuntime { 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 933b1a3..7abf3dc 100644 --- a/packages/bridge/src/provisioning.ts +++ b/packages/bridge/src/provisioning.ts @@ -11,6 +11,8 @@ import type { ListContactsResponse, NetworkGeneralCapabilities, ResolveIdentifierResponse, + BackfillQueueResult, + BackfillQueueParams, UserLogin, } from "./types"; @@ -23,8 +25,11 @@ export interface ProvisioningRuntime { listContacts?(login: UserLogin, query?: string, limit?: number): Promise; requestContext(): BridgeRequestContext; resolveIdentifier(login: UserLogin, identifier: string, createDM: boolean): Promise; + backfill?(login: UserLogin, roomId: string, params: ProvisioningBackfillParams): Promise; } +export type ProvisioningBackfillParams = Pick; + export interface ProvisioningState { logins: Map; } @@ -54,6 +59,16 @@ export async function handleProvisioningHTTPProxy(runtime: ProvisioningRuntime, ))); } + const backfill = match(path, /^\/_matrix\/provision\/v3\/backfill\/([^/]+)$/); + if ((method === "GET" || method === "POST") && backfill) { + if (!runtime.backfill) return jsonHTTPResponse(404, matrixError("M_UNSUPPORTED", "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; @@ -163,6 +178,33 @@ function contactsListResponse(response: ListContactsResponse): 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, @@ -238,6 +280,50 @@ function intQueryParam(rawQuery: string | undefined, key: string): number | unde 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/openclaw/README.md b/packages/openclaw/README.md index a8cf8a3..aeee301 100644 --- a/packages/openclaw/README.md +++ b/packages/openclaw/README.md @@ -1,6 +1,6 @@ # @beeper/pickle-openclaw -Pickle bridge package for exposing OpenClaw Gateway sessions in Beeper/Matrix. +Pickle bridge package for exposing OpenClaw sessions in Beeper/Matrix as an OpenClaw-native channel plugin. ## OpenClaw Plugin Install @@ -18,8 +18,7 @@ OpenClaw loads the runtime entry from `dist/plugin-entry.mjs` and the lightweigh - Beeper appservice registration for the OpenClaw bridge. - OpenClaw channel metadata, setup entrypoint, runtime entrypoint, and ClawHub install metadata. - Pickle bridgev2-style connector for OpenClaw agents, sessions, approvals, and backfill. -- OpenClaw WebSocket Gateway transport using protocol v4 `req`/`res`/`event` frames. -- Compatibility HTTP/SSE transport for gateway-like test or proxy deployments. +- Direct in-process OpenClaw plugin runtime access. - Agent ghosts for OpenClaw agents and user ghosts for imported one-to-one sessions. - 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. @@ -31,76 +30,23 @@ OpenClaw loads the runtime entry from `dist/plugin-entry.mjs` and the lightweigh ## CLI -Write a local config: +Log in to an existing Beeper account and register the OpenClaw appservice: ```sh -pickle-openclaw init \ +pickle-openclaw login \ --config ~/.openclaw/pickle-bridge/config.json \ - --gateway-url ws://127.0.0.1:18789 + --email you@example.com ``` -Log in to an existing Beeper account: +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. -```sh -pickle-openclaw beeper-login \ - --config ~/.openclaw/pickle-bridge/config.json \ - --email you@example.com \ - --login-code 123456 -``` - -Register the OpenClaw appservice with Beeper: - -```sh -pickle-openclaw beeper-register \ - --config ~/.openclaw/pickle-bridge/config.json \ - --bridge-manager-token "$BEEPER_BRIDGE_MANAGER_TOKEN" -``` - -Do login and appservice registration in one step: - -```sh -pickle-openclaw beeper-setup \ - --config ~/.openclaw/pickle-bridge/config.json \ - --email you@example.com \ - --login-code 123456 \ - --gateway-url ws://127.0.0.1:18789 -``` - -Start the bridge: - -```sh -pickle-openclaw start --config ~/.openclaw/pickle-bridge/config.json -``` - -Start the bridge and import discovered one-to-one OpenClaw sessions from terminal, mac app, and channel surfaces: - -```sh -pickle-openclaw start \ - --config ~/.openclaw/pickle-bridge/config.json \ - --backfill \ - --backfill-limit 500 -``` - -Run a non-daemon smoke check before handing the bridge to OpenClaw: +Print the saved Beeper bridge identity: ```sh -pickle-openclaw smoke --config ~/.openclaw/pickle-bridge/config.json +pickle-openclaw whoami --config ~/.openclaw/pickle-bridge/config.json ``` -The smoke command validates the saved Beeper account shape, probes the Gateway feature surface, lists agents and recent sessions, and creates the Beeper bridge in `getOnly` mode. Use `--gateway-only` to skip Beeper setup checks or `--start` when you explicitly want the command to start and then stop the bridge object. - -Installed OpenClaw plugins run inside OpenClaw directly. The CLI gateway URL option is only for smoke/debug commands that explicitly probe a local gateway surface. - -Probe or call the Gateway surface directly: - -```sh -pickle-openclaw features --config ~/.openclaw/pickle-bridge/config.json - -pickle-openclaw rpc \ - --config ~/.openclaw/pickle-bridge/config.json \ - config.schema.lookup \ - --params-json '{"path":["agents"]}' -``` +The bridge runtime itself is started by OpenClaw when the installed channel plugin is enabled. ## Programmatic Runtime @@ -114,7 +60,6 @@ import { const config = createDefaultConfig({ accessToken: process.env.BEEPER_ACCESS_TOKEN, - gatewayUrl: "ws://127.0.0.1:18789", homeserver: "https://matrix.beeper.com", matrixDeviceId: process.env.BEEPER_DEVICE_ID, matrixUserId: process.env.BEEPER_USER_ID, @@ -128,7 +73,7 @@ const bridge = await createOpenClawBeeperBridge({ await bridge.start(); ``` -The runtime exposes `OpenClawGatewayRuntime.call(method, params)` and the CLI exposes `pickle-openclaw rpc --params-json ` for the full Gateway RPC surface. Common bridge paths also have wrappers for agents, sessions, models, tools, tasks, artifacts, approvals, and feature snapshots. +The runtime uses the in-process OpenClaw plugin context and exposes wrappers for agents, sessions, models, tools, tasks, artifacts, approvals, and feature snapshots. ## Protocol Coverage diff --git a/packages/openclaw/openclaw.plugin.json b/packages/openclaw/openclaw.plugin.json index 49457df..72f52c1 100644 --- a/packages/openclaw/openclaw.plugin.json +++ b/packages/openclaw/openclaw.plugin.json @@ -20,11 +20,12 @@ "PICKLE_OPENCLAW_BACKFILL_LIMIT", "PICKLE_OPENCLAW_BASE_DOMAIN", "PICKLE_OPENCLAW_BEEPER_ENV", + "PICKLE_OPENCLAW_BRIDGE_ID", "PICKLE_OPENCLAW_BRIDGE_MANAGER_POST_STATE", "PICKLE_OPENCLAW_BRIDGE_MANAGER_TOKEN", "PICKLE_OPENCLAW_CONTACT_VISIBILITY", "PICKLE_OPENCLAW_DATA_DIR", - "PICKLE_OPENCLAW_GATEWAY_URL", + "PICKLE_OPENCLAW_DEVICE_ID", "PICKLE_OPENCLAW_GHOST_LOCALPART_PREFIX", "PICKLE_OPENCLAW_HOMESERVER", "PICKLE_OPENCLAW_HOMESERVER_DOMAIN", @@ -83,6 +84,10 @@ "type": "string", "description": "Matrix appservice id used in registration namespaces." }, + "bridgeId": { + "type": "string", + "description": "Beeper self-hosted bridge id, derived as sh-openclaw-$deviceid by login setup." + }, "dataDir": { "type": "string", "description": "Directory for bridge config, registration, and runtime state." @@ -91,10 +96,6 @@ "type": "string", "description": "Public or LAN callback URL for the Matrix appservice." }, - "gatewayUrl": { - "type": "string", - "description": "OpenClaw gateway URL used by the bridge runtime." - }, "homeserver": { "type": "string", "description": "Beeper Matrix homeserver URL returned by login." @@ -244,6 +245,10 @@ "type": "string", "description": "Matrix appservice id used in registration namespaces." }, + "bridgeId": { + "type": "string", + "description": "Beeper self-hosted bridge id, derived as sh-openclaw-$deviceid by login setup." + }, "dataDir": { "type": "string", "description": "Directory for bridge config, registration, and runtime state." @@ -252,10 +257,6 @@ "type": "string", "description": "Public or LAN callback URL for the Matrix appservice." }, - "gatewayUrl": { - "type": "string", - "description": "OpenClaw gateway URL used by the bridge runtime." - }, "homeserver": { "type": "string", "description": "Beeper Matrix homeserver URL returned by login." diff --git a/packages/openclaw/package.json b/packages/openclaw/package.json index 6011527..df9ebf8 100644 --- a/packages/openclaw/package.json +++ b/packages/openclaw/package.json @@ -164,19 +164,17 @@ "access": "public" }, "scripts": { - "build": "tsdown", + "build": "tsdown && node scripts/copy-runtime-assets.mjs", "clean": "rm -rf dist", "prepublishOnly": "node ../../scripts/guard-pnpm-publish.mjs", "test": "vitest run --coverage", "typecheck": "tsc --noEmit" }, - "dependencies": { + "devDependencies": { "@beeper/pickle": "workspace:^", "@beeper/pickle-ag-ui": "workspace:^", "@beeper/pickle-bridge": "workspace:^", - "@beeper/pickle-state-file": "workspace:^" - }, - "devDependencies": { + "@beeper/pickle-state-file": "workspace:^", "@types/node": "^20.0.0", "@vitest/coverage-v8": "^4.0.18", "tsdown": "^0.21.10", diff --git a/packages/openclaw/scripts/copy-runtime-assets.mjs b/packages/openclaw/scripts/copy-runtime-assets.mjs new file mode 100644 index 0000000..04cc7be --- /dev/null +++ b/packages/openclaw/scripts/copy-runtime-assets.mjs @@ -0,0 +1,19 @@ +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/pickle-openclaw`); + } + await copyFile(source, resolve(outputDir, file)); +} diff --git a/packages/openclaw/src/appservice.test.ts b/packages/openclaw/src/appservice.test.ts index 5f7c246..d42b4bb 100644 --- a/packages/openclaw/src/appservice.test.ts +++ b/packages/openclaw/src/appservice.test.ts @@ -30,7 +30,7 @@ describe("OpenClaw Beeper appservice runtime", () => { account: account(), address: "http://127.0.0.1:29391", baseDomain: "beeper-staging.com", - bridge: "openclaw", + bridge: "sh-openclaw", bridgeManagerPostState: false, bridgeManagerToken: "hungry-token", bridgeType: "openclaw", @@ -53,6 +53,83 @@ describe("OpenClaw Beeper appservice runtime", () => { expect(bridge.start).toHaveBeenCalledOnce(); }); + it("marks the self-hosted bridge running after the appservice starts", async () => { + const bridge = fakeBridge(); + const postBridgeState = vi.fn(async () => undefined); + const bridgeStateClientFactory = vi.fn(() => ({ postBridgeState })); + const config = createDefaultConfig({ + accessToken: "mx-token", + appserviceId: "sh-openclaw-device", + asToken: "as-token", + beeperEnv: "staging", + bridgeId: "sh-openclaw-device", + dataDir: "/tmp/openclaw", + matrixUserId: "@batuhan:beeper-staging.com", + }); + + await expect(startOpenClawBeeperBridge({ + account: account(), + bridgeFactory: async () => bridge, + bridgeStateClientFactory, + config, + })).resolves.toBe(bridge); + + expect(bridgeStateClientFactory).toHaveBeenCalledWith({ + baseDomain: "beeper-staging.com", + token: "mx-token", + }); + expect(postBridgeState).toHaveBeenCalledWith(expect.objectContaining({ + bridge: "sh-openclaw-device", + bridgeType: "openclaw", + isSelfHosted: true, + reason: "BRIDGE_STARTED", + stateEvent: "RUNNING", + }), "as-token"); + }); + + it("starts from persisted appservice config without re-registering", async () => { + const bridge = fakeBridge(); + const bridgeFactory = vi.fn(async (_options: CreateNodeBeeperBridgeOptions) => bridge); + const config = createDefaultConfig({ + accessToken: "mx-token", + 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", + registrationUrl: "websocket", + }); + + await expect(startOpenClawBeeperBridge({ + account: account(), + 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: "openclawbot", + 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("deviceId"); + expect(bridgeFactory.mock.calls[0]?.[0].matrix).not.toHaveProperty("token"); + }); + it("runs startup backfill with the configured import source scope", async () => { const registry = new OpenClawBridgeRegistry("/tmp/openclaw-appservice-backfill-test.json"); const bridge = fakeBridge({ registry }); @@ -66,7 +143,6 @@ describe("OpenClaw Beeper appservice runtime", () => { const config = createDefaultConfig({ accessToken: "mx-token", dataDir: "/tmp/openclaw", - gatewayUrl: "ws://gateway", homeserver: "https://matrix.beeper.com", importSources: ["dashboard"], matrixDeviceId: "DEVICE", @@ -106,6 +182,80 @@ describe("OpenClaw Beeper appservice runtime", () => { expect(registry.getBindingBySessionKey("agent:codex:tui")).toBeUndefined(); }); + it("wraps the native OpenClaw host runtime for startup backfill", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-appservice-host-runtime-backfill-test.json"); + const bridge = fakeBridge({ registry }); + bridge.createPortal = vi.fn(async (_login, options) => ({ + id: options.id, + mxid: "!dashboard:example.com", + portalKey: { id: options.id, receiver: "login" }, + receiver: "login", + })); + bridge.backfillPortal = vi.fn(async () => ({ eventIds: [] })); + const config = createDefaultConfig({ + accessToken: "mx-token", + dataDir: "/tmp/openclaw", + homeserver: "https://matrix.beeper.com", + importSources: ["dashboard"], + matrixDeviceId: "DEVICE", + matrixUserId: "@batuhan:beeper.com", + }); + + await expect(startOpenClawBeeperBridge({ + account: account(), + backfill: true, + bridgeFactory: async () => bridge, + config, + registry, + runtime: { + agent: { + session: { + listSessionEntries: ({ agentId }: { agentId?: string } = {}) => agentId === "main" + ? [{ + entry: { + agentId: "main", + chatType: "direct", + displayName: "Dashboard", + origin: { provider: "webchat", surface: "webchat" }, + }, + sessionKey: "agent:main:dashboard:one", + }] + : [], + }, + }, + }, + })).resolves.toBe(bridge); + + expect(bridge.createPortal).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + id: "session:YWdlbnQ6bWFpbjpkYXNoYm9hcmQ6b25l", + name: "Dashboard", + })); + expect(registry.getBindingBySessionKey("agent:main:dashboard:one")).toBeDefined(); + }); + + it("keeps the bridge running when startup backfill has no direct OpenClaw runtime", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-appservice-no-runtime-test.json"); + const bridge = fakeBridge({ registry }); + + await expect(startOpenClawBeeperBridge({ + account: account(), + backfill: true, + bridgeFactory: async () => bridge, + config: createDefaultConfig({ + accessToken: "mx-token", + dataDir: "/tmp/openclaw", + homeserver: "https://matrix.beeper.com", + importSources: ["dashboard"], + matrixDeviceId: "DEVICE", + matrixUserId: "@batuhan:beeper.com", + }), + registry, + })).resolves.toBe(bridge); + + expect(bridge.start).toHaveBeenCalledOnce(); + expect(bridge.createPortal).not.toHaveBeenCalled(); + }); + it("recreates the Beeper Matrix account from persisted setup config", () => { expect(accountFromOpenClawConfig(createDefaultConfig({ accessToken: "mx-token", @@ -129,6 +279,9 @@ function account() { function fakeBridge(options: { registry?: OpenClawBridgeRegistry } = {}): PickleBridge { return { connector: options.registry ? { registry: options.registry } : undefined, + backfillPortal: vi.fn(), + 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 index 8f25670..370fde9 100644 --- a/packages/openclaw/src/appservice.ts +++ b/packages/openclaw/src/appservice.ts @@ -1,8 +1,18 @@ -import type { MatrixAccount } from "@beeper/pickle"; -import { createBeeperBridge, type CreateNodeBeeperBridgeOptions, type PickleBridge } from "@beeper/pickle-bridge"; +import type { MatrixAccount, MatrixAppserviceInitOptions, MatrixAppserviceRegistration } from "@beeper/pickle"; +import { + createBeeperBridge, + createBeeperBridgeManagerClient, + type BeeperBridgeManagerClient, + type CreateNodeBeeperBridgeOptions, + type PickleBridge, + type PostBridgeStateOptions, +} from "@beeper/pickle-bridge"; import { backfillAllOpenClawSessions } from "./backfill"; -import { beeperBaseDomain, DEFAULT_BEEPER_BRIDGE, DEFAULT_BEEPER_BRIDGE_TYPE } from "./beeper-setup"; -import { createOpenClawConnector, createOpenClawRuntimeFromLogin, userLoginFromOpenClawConfig, type OpenClawConnectorOptions } from "./connector"; +import { beeperBaseDomain } from "./beeper-setup"; +import { DEFAULT_BEEPER_BRIDGE_TYPE } from "./ids"; +import { createOpenClawConnector, userLoginFromOpenClawConfig, type OpenClawConnectorOptions } from "./connector"; +import { createOpenClawHostTransport, OpenClawGatewayRuntime } from "./openclaw-runtime"; +import { createAppserviceRegistration } from "./registration"; import { OpenClawBridgeRegistry } from "./registry"; import type { OpenClawBridgeConfig } from "./types"; @@ -11,6 +21,7 @@ export interface CreateOpenClawBeeperBridgeOptions extends OpenClawConnectorOpti backfill?: boolean; backfillLimit?: number; bridge?: string; + bridgeStateClientFactory?: (options: { baseDomain?: string; token: string }) => Pick; bridgeFactory?: (options: CreateNodeBeeperBridgeOptions) => Promise; bridgeType?: string; connector?: CreateNodeBeeperBridgeOptions["connector"]; @@ -25,7 +36,7 @@ export async function createOpenClawBeeperBridge(options: CreateOpenClawBeeperBr const connector = options.connector ?? createOpenClawConnector(connectorOptions(options)); const bridgeOptions: CreateNodeBeeperBridgeOptions = { account: options.account, - bridge: options.bridge ?? DEFAULT_BEEPER_BRIDGE, + bridge: options.bridge ?? config?.bridgeId ?? config?.appserviceId ?? "sh-openclaw", bridgeType: options.bridgeType ?? DEFAULT_BEEPER_BRIDGE_TYPE, connector, }; @@ -40,7 +51,8 @@ export async function createOpenClawBeeperBridge(options: CreateOpenClawBeeperBr 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.matrix !== undefined) bridgeOptions.matrix = options.matrix; + 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); @@ -49,17 +61,21 @@ export async function createOpenClawBeeperBridge(options: CreateOpenClawBeeperBr export async function startOpenClawBeeperBridge(options: CreateOpenClawBeeperBridgeOptions): Promise { const bridge = await createOpenClawBeeperBridge(options); await bridge.start(); + await postOpenClawBridgeRunningState(options); + await bridge.setBridgeState("running"); if (options.backfill) { const config = options.config; if (!config) throw new Error("OpenClaw backfill requires config"); const registry = options.registry ?? registryFromConnector(bridge.connector); if (!registry) throw new Error("OpenClaw backfill requires registry"); + const runtime = tryResolveOpenClawRuntime(options, config); + if (!runtime) return bridge; const login = userLoginFromOpenClawConfig(config); const backfillOptions: Parameters[0] = { bridge, login, registry, - runtime: options.runtimeFactory?.(login, config) ?? createOpenClawRuntimeFromLogin(login, config), + runtime, }; if (config.importSources !== undefined) backfillOptions.importSources = config.importSources; if (options.backfillLimit !== undefined) backfillOptions.limit = options.backfillLimit; @@ -69,6 +85,34 @@ export async function startOpenClawBeeperBridge(options: CreateOpenClawBeeperBri return bridge; } +async function postOpenClawBridgeRunningState(options: CreateOpenClawBeeperBridgeOptions): Promise { + const config = options.config; + const bridge = options.bridge ?? config?.bridgeId ?? config?.appserviceId; + if (!config?.accessToken || !config.asToken || !bridge) return; + const baseDomain = config.baseDomain ?? beeperBaseDomain(config.beeperEnv); + const factory = options.bridgeStateClientFactory ?? createBeeperBridgeManagerClient; + const clientOptions: { baseDomain?: string; token: string } = { token: config.accessToken }; + if (baseDomain !== undefined) clientOptions.baseDomain = baseDomain; + const state: PostBridgeStateOptions = { + bridge, + bridgeType: options.bridgeType ?? DEFAULT_BEEPER_BRIDGE_TYPE, + info: { + openclaw: { + appserviceId: config.appserviceId, + matrixUserId: config.matrixUserId, + }, + }, + isSelfHosted: true, + reason: "BRIDGE_STARTED", + stateEvent: "RUNNING", + }; + try { + await factory(clientOptions).postBridgeState(state, config.asToken); + } catch { + // The websocket bridge_status still reports liveness; keep the plugin running if the REST state echo fails. + } +} + export function accountFromOpenClawConfig(config: OpenClawBridgeConfig): MatrixAccount { if (!config.accessToken) throw new Error("OpenClaw config is missing accessToken"); if (!config.homeserver) throw new Error("OpenClaw config is missing homeserver"); @@ -88,12 +132,79 @@ function connectorOptions(options: CreateOpenClawBeeperBridgeOptions): OpenClawC if (options.registry !== undefined) output.registry = options.registry; if (options.runtimeFactory !== undefined) output.runtimeFactory = options.runtimeFactory; if (options.streams !== undefined) output.streams = options.streams; - if (options.transportFactory !== undefined) output.transportFactory = options.transportFactory; + if (options.runtime !== undefined) output.runtime = options.runtime; return output; } +function resolveOpenClawRuntime(options: CreateOpenClawBeeperBridgeOptions, config: OpenClawBridgeConfig): OpenClawGatewayRuntime { + if (options.runtime instanceof OpenClawGatewayRuntime) return options.runtime; + if (options.runtime !== undefined) { + return new OpenClawGatewayRuntime({ config, transport: createOpenClawHostTransport(options.runtime) }); + } + if (options.runtimeFactory) return options.runtimeFactory(config); + const connector = options.connector; + if (connector && typeof connector === "object" && "runtime" in connector) { + const runtime = (connector as { runtime?: unknown }).runtime; + if (runtime instanceof OpenClawGatewayRuntime) return runtime; + } + throw new Error("OpenClaw direct plugin runtime is required"); +} + +function tryResolveOpenClawRuntime( + options: CreateOpenClawBeeperBridgeOptions, + config: OpenClawBridgeConfig +): OpenClawGatewayRuntime | undefined { + try { + return resolveOpenClawRuntime(options, config); + } catch { + return undefined; + } +} + function registryFromConnector(connector: unknown): OpenClawBridgeRegistry | undefined { if (!connector || typeof connector !== "object" || !("registry" in connector)) return undefined; const registry = (connector as { registry?: unknown }).registry; return registry instanceof OpenClawBridgeRegistry ? registry : undefined; } + +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; + const useUserMatrixAccount = !appservice && config && hasPersistedMatrixAccount(config); + return { + ...input, + ...(useUserMatrixAccount && input?.account === undefined ? { account: accountFromOpenClawConfig(config) } : {}), + ...(appservice && input?.appservice === undefined ? { appservice } : {}), + ...(!appservice && config?.matrixDeviceId && input?.deviceId === undefined ? { deviceId: config.matrixDeviceId } : {}), + ...(!appservice && config?.accessToken && input?.token === undefined ? { token: config.accessToken } : {}), + ...(config?.homeserver && input?.homeserver === undefined ? { homeserver: config.homeserver } : {}), + }; +} + +function hasPersistedAppservice(config: OpenClawBridgeConfig): boolean { + return Boolean(config.asToken && config.hsToken && config.homeserver); +} + +function hasPersistedMatrixAccount(config: OpenClawBridgeConfig): boolean { + return Boolean(config.accessToken && config.homeserver && config.matrixDeviceId && config.matrixUserId); +} + +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/backfill.test.ts b/packages/openclaw/src/backfill.test.ts index c819c57..f24dcb0 100644 --- a/packages/openclaw/src/backfill.test.ts +++ b/packages/openclaw/src/backfill.test.ts @@ -14,6 +14,7 @@ describe("OpenClaw backfill", () => { sessions: [ { key: "agent:main:terminal:local", origin: { surface: "terminal" } }, { key: "agent:main:desktop:abc", origin: { surface: "mac-app" } }, + { chatType: "direct", key: "agent:main:dashboard:web", lastChannel: "webchat", origin: { provider: "webchat", surface: "webchat" } }, { chatType: "dm", key: "agent:main:whatsapp:user-1", lastTo: "user-1" }, { chatType: "group", key: "agent:main:whatsapp:group-1", lastTo: "a,b" }, ], @@ -35,6 +36,18 @@ describe("OpenClaw backfill", () => { sessionKey: "agent:main:desktop:abc", source: "mac-app", }, + { + agentId: "main", + label: "agent:main:dashboard:web", + session: { + chatType: "direct", + key: "agent:main:dashboard:web", + lastChannel: "webchat", + origin: { provider: "webchat", surface: "webchat" }, + }, + sessionKey: "agent:main:dashboard:web", + source: "mac-app", + }, { agentId: "main", human: { @@ -137,6 +150,7 @@ describe("OpenClaw backfill", () => { it("filters backfill sessions by opt-in import source and archived state", async () => { expect(shouldImportSession({ key: "agent:main:terminal:local", origin: { surface: "terminal" } }, ["tui"])).toBe(true); expect(shouldImportSession({ key: "agent:main:desktop:abc", origin: { surface: "mac-app" } }, ["dashboard"])).toBe(true); + expect(shouldImportSession({ chatType: "direct", key: "agent:main:dashboard:web", lastChannel: "webchat", origin: { surface: "webchat" } }, ["dashboard"])).toBe(true); expect(shouldImportSession({ key: "agent:main:whatsapp:alice", lastProvider: "whatsapp" }, ["channels"])).toBe(true); expect(shouldImportSession({ key: "agent:main:terminal:old", origin: { surface: "terminal" }, updatedAt: null }, ["tui"])).toBe(false); expect(shouldImportSession({ key: "agent:main:terminal:old", origin: { surface: "terminal" }, updatedAt: null }, ["archived"])).toBe(true); @@ -150,12 +164,14 @@ describe("OpenClaw backfill", () => { { key: "agent:main:terminal:local", origin: { surface: "terminal" } }, { key: "agent:main:terminal:archived", origin: { surface: "terminal" }, updatedAt: null }, { key: "agent:main:desktop:abc", origin: { surface: "mac-app" } }, + { chatType: "direct", key: "agent:main:dashboard:web", lastChannel: "webchat", origin: { surface: "webchat" } }, { chatType: "dm", key: "agent:main:whatsapp:user-1", lastProvider: "whatsapp", lastTo: "user-1" }, ], }, }); await expect(discoverOneToOneSessions(runtime, { importSources: ["dashboard"] })).resolves.toMatchObject([ { sessionKey: "agent:main:desktop:abc", source: "mac-app" }, + { sessionKey: "agent:main:dashboard:web", source: "mac-app" }, ]); await expect(discoverOneToOneSessions(runtime, { importSources: ["archived"] })).resolves.toMatchObject([ { sessionKey: "agent:main:terminal:archived", source: "terminal" }, @@ -211,7 +227,6 @@ describe("OpenClaw backfill", () => { }, name: "Alice", roomType: "dm", - sender: "codex", })); expect(bridge.backfillPortal).toHaveBeenCalledWith(login, expect.objectContaining({ mxid: "!room:example.com", @@ -379,6 +394,128 @@ describe("OpenClaw backfill", () => { expect(bridge.createPortal.mock.calls[0]?.[1]).not.toHaveProperty("creationContent"); }); + + it("creates an initial agent DM when no importable sessions exist", async () => { + const runtime = runtimeWith({ + "agents.list": { agents: [{ displayName: "Main Agent", id: "main" }] }, + "sessions.list": { sessions: [] }, + }); + const dir = await mkdtemp(join(tmpdir(), "openclaw-backfill-empty-test-")); + const registry = new OpenClawBridgeRegistry(join(dir, "registry.json")); + const bridge = { + backfillPortal: vi.fn(async () => ({ eventIds: [] })), + createPortal: vi.fn(async () => ({ + id: "agent:main", + mxid: "!main:example.com", + portalKey: { id: "agent:main", receiver: "login" }, + receiver: "login", + })), + }; + const login = { id: "login", userId: "@owner:example.com" }; + + await expect(backfillAllOpenClawSessions({ + bridge: bridge as never, + importSources: ["dashboard", "tui"], + login, + registry, + runtime, + })).resolves.toMatchObject({ + portals: [{ mxid: "!main:example.com" }], + sessions: [], + skipped: [], + }); + + expect(bridge.createPortal).toHaveBeenCalledWith(login, expect.objectContaining({ + id: "agent:main", + name: "Main Agent", + roomType: "dm", + })); + expect(bridge.backfillPortal).not.toHaveBeenCalled(); + expect(registry.getBindingBySessionKey("agent:main")).toMatchObject({ + agentId: "main", + owner: "bridge", + roomId: "!main:example.com", + }); + }); + + it("heals stale registry ghost domains when an initial DM already exists", async () => { + const runtime = runtimeWith({ + "agents.list": { agents: [{ displayName: "Main Agent", id: "main" }] }, + "sessions.list": { sessions: [] }, + }); + runtime.config.homeserver = "https://matrix.beeper-staging.com/_hungryserv/account"; + runtime.config.homeserverDomain = "beeper.local"; + const dir = await mkdtemp(join(tmpdir(), "openclaw-backfill-heal-test-")); + const registry = new OpenClawBridgeRegistry(join(dir, "registry.json")); + registry.upsertAgent({ + agentId: "main", + displayName: "Main Agent", + ghostUserId: "@openclaw_agent_main:matrix.beeper-staging.com", + }); + registry.upsertBinding({ + agentId: "main", + createdAt: 1, + ghostUserId: "@openclaw_agent_main:matrix.beeper-staging.com", + id: "existing", + kind: "session", + label: "Main Agent", + owner: "bridge", + roomId: "!existing:beeper.local", + sessionKey: "agent:main", + updatedAt: 1, + }); + const bridge = { + backfillPortal: vi.fn(async () => ({ eventIds: [] })), + createPortal: vi.fn(async () => ({ id: "agent:main", mxid: "!new:beeper.local", portalKey: { id: "agent:main", receiver: "login" } })), + }; + + await backfillAllOpenClawSessions({ + bridge: bridge as never, + importSources: ["dashboard", "tui"], + login: { id: "login", userId: "@owner:beeper.local" }, + registry, + runtime, + }); + + expect(bridge.createPortal).not.toHaveBeenCalled(); + expect(registry.getAgent("main")?.ghostUserId).toBe("@openclaw_agent_main:beeper.local"); + expect(registry.getBindingBySessionKey("agent:main")?.ghostUserId).toBe("@openclaw_agent_main:beeper.local"); + }); + + it("rebuilds the registry from an existing bridge portal before creating an initial DM", async () => { + const runtime = runtimeWith({ + "agents.list": { agents: [{ displayName: "Main Agent", id: "main" }] }, + "sessions.list": { sessions: [] }, + }); + const dir = await mkdtemp(join(tmpdir(), "openclaw-backfill-existing-portal-test-")); + const registry = new OpenClawBridgeRegistry(join(dir, "registry.json")); + const existingPortal = { + id: "agent:main", + mxid: "!existing:beeper.local", + portalKey: { id: "agent:main", receiver: "login" }, + receiver: "login", + }; + const bridge = { + backfillPortal: vi.fn(async () => ({ eventIds: [] })), + createPortal: vi.fn(), + getPortal: vi.fn(() => existingPortal), + }; + + const result = await backfillAllOpenClawSessions({ + bridge: bridge as never, + importSources: ["dashboard", "tui"], + login: { id: "login", userId: "@owner:beeper.local" }, + registry, + runtime, + }); + + expect(result.portals).toEqual([existingPortal]); + expect(bridge.createPortal).not.toHaveBeenCalled(); + expect(registry.getBindingBySessionKey("agent:main")).toMatchObject({ + agentId: "main", + roomId: "!existing:beeper.local", + }); + }); }); function runtimeWith(responses: Record): OpenClawGatewayRuntime & { diff --git a/packages/openclaw/src/backfill.ts b/packages/openclaw/src/backfill.ts index 57f8f43..67d440d 100644 --- a/packages/openclaw/src/backfill.ts +++ b/packages/openclaw/src/backfill.ts @@ -108,13 +108,22 @@ export async function backfillAllOpenClawSessions(options: BackfillAllOpenClawSe const portals: Portal[] = []; const importedSessions: OpenClawBackfillSession[] = []; const skipped: OpenClawBackfillSession[] = []; + if (sessions.length === 0) { + const portal = await createInitialOpenClawRoom(options); + if (portal) portals.push(portal); + await options.registry.save(); + return { portals, sessions: importedSessions, skipped }; + } for (const session of sessions) { - if (options.registry.getBindingBySessionKey(session.sessionKey)) { + const existingBinding = options.registry.getBindingBySessionKey(session.sessionKey); + if (existingBinding) { + healBindingGhosts(options.runtime.config, options.registry, existingBinding); skipped.push(session); continue; } - const agent = options.registry.getAgent(session.agentId) ?? agentContactFromOpenClawAgent(options.runtime.config, { - id: session.agentId, + const agent = normalizeAgentContact(options.runtime.config, options.registry.getAgent(session.agentId) ?? { + agentId: session.agentId, + displayName: session.agentId, }); options.registry.upsertAgent(agent); if (session.human) options.registry.upsertUser(session.human); @@ -131,11 +140,11 @@ export async function backfillAllOpenClawSessions(options: BackfillAllOpenClawSe }, name: session.label, roomType: "dm", - sender: session.agentId, }; const creationContent = openClawBackfillRoomCreationContent(options.runtime.config); if (creationContent) portalOptions.creationContent = creationContent; - const portal = await options.bridge.createPortal(options.login, portalOptions); + const portal = getExistingBridgePortal(options.bridge, { id: portalOptions.id, receiver: options.login.id }) + ?? await options.bridge.createPortal(options.login, portalOptions); portals.push(portal); if (!portal.mxid) { skipped.push(session); @@ -154,16 +163,102 @@ export async function backfillAllOpenClawSessions(options: BackfillAllOpenClawSe return { portals, sessions: importedSessions, skipped }; } +async function createInitialOpenClawRoom(options: BackfillAllOpenClawSessionsOptions): Promise { + const contacts = await options.runtime.listAgentContacts(); + const agent = normalizeAgentContact( + options.runtime.config, + contacts[0] ?? options.registry.data.agents[0] ?? agentContactFromOpenClawAgent(options.runtime.config, { id: "main", name: "OpenClaw" }), + ); + options.registry.upsertAgent(agent); + const sessionKey = agentPortalSessionKey(agent.agentId); + const existing = options.registry.getBindingBySessionKey(sessionKey); + if (existing) { + healBindingGhosts(options.runtime.config, options.registry, existing); + return undefined; + } + const portalOptions: BridgeCreatePortalOptions = { + id: `agent:${agent.agentId}`, + metadata: { + openclaw: { + agentId: agent.agentId, + ghostUserId: agent.ghostUserId, + sessionKey, + }, + }, + name: agent.displayName, + roomType: "dm", + }; + const creationContent = openClawBackfillRoomCreationContent(options.runtime.config); + if (creationContent) portalOptions.creationContent = creationContent; + const portal = getExistingBridgePortal(options.bridge, { id: portalOptions.id, receiver: options.login.id }) + ?? await options.bridge.createPortal(options.login, portalOptions); + if (portal.mxid) { + const now = Date.now(); + options.registry.upsertBinding({ + agentId: agent.agentId, + createdAt: now, + ghostUserId: agent.ghostUserId, + id: bindingIdForRoom(portal.mxid), + kind: "session", + label: agent.displayName, + owner: "bridge", + roomId: portal.mxid, + sessionKey, + updatedAt: now, + }); + } + return portal; +} + export function portalIdForBackfillSession(session: Pick): string { return `session:${Buffer.from(session.sessionKey).toString("base64url")}`; } +function agentPortalSessionKey(agentId: string): string { + return `agent:${agentId}`; +} + +function getExistingBridgePortal(bridge: PickleBridge, portalKey: { id: string; receiver: string }): Portal | null { + const getPortal = (bridge as { getPortal?: (key: { id: string; receiver?: string }) => Portal | null }).getPortal; + return getPortal?.call(bridge, portalKey) ?? null; +} + +function normalizeAgentContact( + config: OpenClawBridgeConfig, + agent: { agentId?: string; avatarMxc?: string; description?: string; displayName?: string; ghostUserId?: string } | undefined, +) { + const normalized = agentContactFromOpenClawAgent(config, { + avatarMxc: agent?.avatarMxc, + description: agent?.description, + displayName: agent?.displayName, + id: agent?.agentId, + }); + return normalized; +} + +function healBindingGhosts( + config: OpenClawBridgeConfig, + registry: OpenClawBridgeRegistry, + binding: OpenClawSessionBinding, +): void { + const agent = normalizeAgentContact(config, registry.getAgent(binding.agentId) ?? { + agentId: binding.agentId, + displayName: binding.label ?? binding.agentId, + }); + registry.upsertAgent(agent); + registry.updateBinding(binding.id, (existing) => ({ + ...existing, + ghostUserId: agent.ghostUserId, + updatedAt: Date.now(), + })); +} + export function isOneToOneSession(session: OpenClawListedSession): boolean { const chatType = session.chatType?.toLowerCase(); if (chatType && ["dm", "direct", "private", "one_to_one", "1:1"].includes(chatType)) return true; if (session.lastTo && !session.lastTo.includes(",") && !session.lastTo.includes(" ")) return true; const originType = stringValue(session.origin?.type) ?? stringValue(session.origin?.surface); - return originType === "terminal" || originType === "mac-app"; + return originType === "terminal" || isDashboardSurface(originType); } export function shouldImportSession( @@ -210,12 +305,17 @@ function resolveAgentId(session: OpenClawListedSession): string { function sessionSource(session: OpenClawListedSession): OpenClawBackfillSession["source"] { const originSurface = stringValue(session.origin?.surface) ?? stringValue(session.origin?.type); - if (originSurface === "terminal" || session.provider === "terminal") return "terminal"; - if (originSurface === "mac-app" || originSurface === "desktop" || session.provider === "mac-app") return "mac-app"; + const provider = session.provider ?? session.lastProvider ?? session.lastChannel; + if (originSurface === "terminal" || provider === "terminal") return "terminal"; + if (isDashboardSurface(originSurface) || isDashboardSurface(provider)) return "mac-app"; if (session.lastChannel || session.lastProvider) return "channel"; return "unknown"; } +function isDashboardSurface(value: string | undefined): boolean { + return value === "mac-app" || value === "desktop" || value === "webchat" || value === "dashboard"; +} + function contentText(content: unknown): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; diff --git a/packages/openclaw/src/beeper-setup.test.ts b/packages/openclaw/src/beeper-setup.test.ts index 2b70778..beb27e5 100644 --- a/packages/openclaw/src/beeper-setup.test.ts +++ b/packages/openclaw/src/beeper-setup.test.ts @@ -6,11 +6,21 @@ import { } 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 { @@ -26,7 +36,11 @@ describe("OpenClaw Beeper setup", () => { expect.objectContaining({ email: "batuhan@example.com", initialDeviceDisplayName: "Pickle OpenClaw", - metadata: { bridge: "openclaw" }, + metadata: { + bridge: "sh-openclaw-openclaw-device", + bridgeType: "openclaw", + openClawDeviceId: "OPENCLAW-DEVICE", + }, }), ]); expect(result.config).toEqual({ @@ -41,6 +55,7 @@ describe("OpenClaw Beeper setup", () => { const seen: unknown[] = []; const result = await createOpenClawBeeperAppService({ accessToken: "mx-token", + matrixDeviceId: "DEV", createAppServiceInit: async (options) => { seen.push(options); return { @@ -49,7 +64,7 @@ describe("OpenClaw Beeper setup", () => { registration: { asToken: "as", hsToken: "hs", - id: "openclaw", + id: "appservice-uuid", namespaces: { aliases: [], rooms: [], users: [] }, senderLocalpart: "openclawbot", url: "http://127.0.0.1:29391", @@ -61,18 +76,24 @@ describe("OpenClaw Beeper setup", () => { expect(seen).toEqual([ expect.objectContaining({ address: "http://127.0.0.1:29391", - bridge: "openclaw", + bridge: "sh-openclaw-dev", bridgeType: "openclaw", selfHosted: true, token: "mx-token", }), ]); expect(result.config).toEqual({ - appserviceId: "openclaw", + appserviceId: "appservice-uuid", asToken: "as", + bridgeId: "sh-openclaw-dev", + ghostLocalpartPrefix: "sh-openclaw-dev_agent_", homeserver: "https://matrix.beeper.com/_hungryserv/batuhan", + homeserverDomain: "beeper.local", hsToken: "hs", registrationUrl: "http://127.0.0.1:29391", + senderLocalpart: "openclawbot", + serviceBotLocalpart: "openclawbot", + userLocalpartPrefix: "sh-openclaw-dev_user_", }); }); @@ -81,6 +102,7 @@ describe("OpenClaw Beeper setup", () => { await createOpenClawBeeperAppService({ accessToken: "mx-token", bridgeManagerToken: "hungry-token", + matrixDeviceId: "DEV", createAppServiceInit: async (options) => { seen.push(options); return { @@ -88,7 +110,7 @@ describe("OpenClaw Beeper setup", () => { registration: { asToken: "as", hsToken: "hs", - id: "openclaw", + id: "appservice-uuid", namespaces: { aliases: [], rooms: [], users: [] }, senderLocalpart: "openclawbot", url: "http://127.0.0.1:29391", @@ -110,6 +132,7 @@ describe("OpenClaw Beeper setup", () => { email: "batuhan@example.com", env: "staging", getLoginCode: () => "123456", + openClawDeviceId: "OPENCLAW-DEVICE", login: async () => ({ accessToken: "mx-token", deviceId: "DEV", @@ -119,15 +142,16 @@ describe("OpenClaw Beeper setup", () => { createAppServiceInit: async (options) => { expect(options).toMatchObject({ baseDomain: "beeper-staging.com", - homeserver: "https://matrix.beeper-staging.com", + bridge: "sh-openclaw-openclaw-device", token: "mx-token", }); + expect(options.homeserver).toBeUndefined(); return { homeserver: "https://matrix.beeper-staging.com/_hungryserv/batuhan", registration: { asToken: "as", hsToken: "hs", - id: "openclaw", + id: "appservice-uuid", namespaces: { aliases: [], rooms: [], users: [] }, senderLocalpart: "openclawbot", url: "http://127.0.0.1:29391", @@ -138,13 +162,18 @@ describe("OpenClaw Beeper setup", () => { expect(result.config).toEqual({ accessToken: "mx-token", - appserviceId: "openclaw", + appserviceId: "appservice-uuid", asToken: "as", + bridgeId: "sh-openclaw-openclaw-device", + ghostLocalpartPrefix: "sh-openclaw-openclaw-device_agent_", homeserver: "https://matrix.beeper-staging.com/_hungryserv/batuhan", hsToken: "hs", matrixDeviceId: "DEV", matrixUserId: "@batuhan:beeper-staging.com", registrationUrl: "http://127.0.0.1:29391", + senderLocalpart: "openclawbot", + serviceBotLocalpart: "openclawbot", + userLocalpartPrefix: "sh-openclaw-openclaw-device_user_", }); }); }); diff --git a/packages/openclaw/src/beeper-setup.ts b/packages/openclaw/src/beeper-setup.ts index 068a2fb..7693ace 100644 --- a/packages/openclaw/src/beeper-setup.ts +++ b/packages/openclaw/src/beeper-setup.ts @@ -2,10 +2,11 @@ import type { MatrixAppserviceInitOptions } from "@beeper/pickle"; import { createBeeperLogin, type BeeperAuthOptions, type BeeperEnvironment } from "@beeper/pickle/beeper/auth"; import { createBeeperAppServiceInit, type CreateAppServiceOptions } from "@beeper/pickle-bridge"; import { DEFAULT_REGISTRATION_URL } from "./config"; +import { DEFAULT_BEEPER_BRIDGE_TYPE, openClawBeeperBridgeId } from "./ids"; +import { resolveOpenClawDeviceId } from "./openclaw-identity"; import type { OpenClawBridgeConfig } from "./types"; -export const DEFAULT_BEEPER_BRIDGE = "openclaw"; -export const DEFAULT_BEEPER_BRIDGE_TYPE = "openclaw"; +export { DEFAULT_BEEPER_BRIDGE_TYPE, openClawBeeperBridgeId }; export interface BeeperSetupAccount { accessToken: string; @@ -22,6 +23,7 @@ export interface BeeperLoginForOpenClawOptions { initialDeviceDisplayName?: string; login?: (options: BeeperAuthOptions) => Promise; metadata?: Record; + openClawDeviceId?: string; } export interface BeeperLoginForOpenClawResult { @@ -41,6 +43,7 @@ export interface CreateOpenClawBeeperAppServiceOptions { getOnly?: boolean; homeserver?: string; homeserverDomain?: string; + matrixDeviceId?: string; postState?: boolean; push?: boolean; selfHosted?: boolean; @@ -56,7 +59,7 @@ export type CreateOpenClawBeeperAppServiceRequest = CreateAppServiceOptions & { }; export interface CreateOpenClawBeeperAppServiceResult { - config: Pick; + config: Pick; init: MatrixAppserviceInitOptions; } @@ -69,6 +72,7 @@ export interface SetupOpenClawBeeperBridgeOptions extends BeeperLoginForOpenClaw createAppServiceInit?: CreateOpenClawBeeperAppServiceOptions["createAppServiceInit"]; getOnly?: boolean; homeserverDomain?: string; + openClawDeviceId?: string; postState?: boolean; push?: boolean; selfHosted?: boolean; @@ -77,16 +81,18 @@ export interface SetupOpenClawBeeperBridgeOptions extends BeeperLoginForOpenClaw export interface SetupOpenClawBeeperBridgeResult { account: BeeperSetupAccount; - config: Pick; + config: Pick; init: MatrixAppserviceInitOptions; } export async function loginToBeeperForOpenClaw(options: BeeperLoginForOpenClawOptions): Promise { const login = options.login ?? createBeeperLogin; + const openClawDeviceId = options.openClawDeviceId ?? await resolveOpenClawDeviceId(); + const bridgeId = openClawBeeperBridgeId(openClawDeviceId); const request: BeeperAuthOptions = { email: options.email, initialDeviceDisplayName: options.initialDeviceDisplayName ?? "Pickle OpenClaw", - metadata: { ...options.metadata, bridge: DEFAULT_BEEPER_BRIDGE }, + metadata: { ...options.metadata, bridge: bridgeId, bridgeType: DEFAULT_BEEPER_BRIDGE_TYPE, openClawDeviceId }, }; if (options.env !== undefined) request.env = options.env; if (options.fetch !== undefined) request.fetch = options.fetch; @@ -107,9 +113,11 @@ 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 = { address: options.address ?? DEFAULT_REGISTRATION_URL, - bridge: options.bridge ?? DEFAULT_BEEPER_BRIDGE, + bridge, bridgeType: options.bridgeType ?? DEFAULT_BEEPER_BRIDGE_TYPE, selfHosted: options.selfHosted ?? true, token: options.accessToken, @@ -124,14 +132,21 @@ export async function createOpenClawBeeperAppService( if (options.push !== undefined) request.push = options.push; if (options.username !== undefined) request.username = options.username; const init = await createInit(request); - return { - config: { + const config: CreateOpenClawBeeperAppServiceResult["config"] = { appserviceId: init.registration.id, asToken: init.registration.asToken, + bridgeId: bridge, + ghostLocalpartPrefix: `${bridge}_agent_`, homeserver: init.homeserver, hsToken: init.registration.hsToken, registrationUrl: options.address ?? init.registration.url ?? DEFAULT_REGISTRATION_URL, - }, + senderLocalpart: init.registration.senderLocalpart, + serviceBotLocalpart: init.registration.senderLocalpart, + userLocalpartPrefix: `${bridge}_user_`, + }; + if (init.homeserverDomain !== undefined) config.homeserverDomain = init.homeserverDomain; + return { + config, init, }; } @@ -139,15 +154,16 @@ export async function createOpenClawBeeperAppService( export async function setupOpenClawBeeperBridge( options: SetupOpenClawBeeperBridgeOptions ): Promise { - const login = await loginToBeeperForOpenClaw(options); + const openClawDeviceId = options.openClawDeviceId ?? await resolveOpenClawDeviceId(); + const login = await loginToBeeperForOpenClaw({ ...options, openClawDeviceId }); + const bridgeId = openClawBeeperBridgeId(openClawDeviceId); const appserviceOptions: CreateOpenClawBeeperAppServiceOptions = { accessToken: login.account.accessToken, - homeserver: login.account.homeserver, + bridge: bridgeId, }; const baseDomain = options.baseDomain ?? beeperBaseDomain(options.env); if (options.address !== undefined) appserviceOptions.address = options.address; if (baseDomain !== undefined) appserviceOptions.baseDomain = baseDomain; - if (options.bridge !== undefined) appserviceOptions.bridge = options.bridge; if (options.bridgeManagerToken !== undefined) appserviceOptions.bridgeManagerToken = options.bridgeManagerToken; if (options.bridgeType !== undefined) appserviceOptions.bridgeType = options.bridgeType; if (options.createAppServiceInit !== undefined) appserviceOptions.createAppServiceInit = options.createAppServiceInit; diff --git a/packages/openclaw/src/beeper-stream.test.ts b/packages/openclaw/src/beeper-stream.test.ts index 6ce6954..46596e4 100644 --- a/packages/openclaw/src/beeper-stream.test.ts +++ b/packages/openclaw/src/beeper-stream.test.ts @@ -29,7 +29,7 @@ describe("OpenClaw Beeper native stream publisher", () => { }, "com.beeper.ai.metadata": expect.objectContaining({ data: { agent_id: "codex" }, - model: "openclaw/gateway", + model: "openclaw/plugin", protocol: "ag-ui", runId: "turn_1", schema: "com.beeper.ai.run.v1", diff --git a/packages/openclaw/src/beeper-stream.ts b/packages/openclaw/src/beeper-stream.ts index 8fcd8c2..5a9cfd3 100644 --- a/packages/openclaw/src/beeper-stream.ts +++ b/packages/openclaw/src/beeper-stream.ts @@ -219,7 +219,7 @@ export class BeeperStreamPublisher { }), data: this.#initialMessageMetadata, messageId: this.turnId, - model: "openclaw/gateway", + model: "openclaw/plugin", preview: { text: "", truncated: false, diff --git a/packages/openclaw/src/bridge-agent.ts b/packages/openclaw/src/bridge-agent.ts index 78ebc6c..cbfec12 100644 --- a/packages/openclaw/src/bridge-agent.ts +++ b/packages/openclaw/src/bridge-agent.ts @@ -32,12 +32,15 @@ export class OpenClawMatrixBridgeAgent { readonly registry: OpenClawBridgeRegistry; readonly runtime: OpenClawGatewayRuntime; readonly streams: OpenClawBridgeStreamPublisher; + readonly backgroundStreaming: boolean; constructor(options: { + backgroundStreaming?: boolean; registry: OpenClawBridgeRegistry; runtime: OpenClawGatewayRuntime; streams: OpenClawBridgeStreamPublisher; }) { + this.backgroundStreaming = options.backgroundStreaming ?? false; this.registry = options.registry; this.runtime = options.runtime; this.streams = options.streams; @@ -74,9 +77,16 @@ export class OpenClawMatrixBridgeAgent { sessionKey: run.sessionKey, updatedAt: Date.now(), })); - await this.streamRun({ ...binding, sessionKey: run.sessionKey }, run.runId); this.registry.markDedupe(turn.eventId); await this.registry.save(); + const stream = this.streamRun({ ...binding, sessionKey: run.sessionKey }, run.runId); + if (this.backgroundStreaming) { + void stream.catch((error) => { + console.error("[openclaw-beeper] failed to stream OpenClaw run to Beeper", error); + }); + } else { + await stream; + } } async handleApprovalContent(content: unknown, approvalId?: string): Promise { diff --git a/packages/openclaw/src/cli.test.ts b/packages/openclaw/src/cli.test.ts index adccb70..35204c8 100644 --- a/packages/openclaw/src/cli.test.ts +++ b/packages/openclaw/src/cli.test.ts @@ -6,579 +6,249 @@ import { describe, expect, it, vi } from "vitest"; import { runCli } from "./cli"; describe("pickle-openclaw CLI", () => { - it("writes secure config and registration files", async () => { - const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-cli-")); - const configPath = join(dir, "config.json"); - const registrationPath = join(dir, "registration.json"); - const initIO = captureIO(); - await expect(runCli([ - "init", - "--config", - configPath, - "--data-dir", - dir, - "--homeserver", - "https://matrix.example", - "--access-token", - "secret", - ], initIO)).resolves.toBe(0); - expect(initIO.stdoutText).toContain('"accessToken": ""'); - expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({ - accessToken: "secret", - homeserver: "https://matrix.example", - }); - expect((await stat(configPath)).mode & 0o777).toBe(0o600); - - const registerIO = captureIO(); - await expect(runCli([ - "register", - "--config", - configPath, - "--output", - registrationPath, - "--as-token", - "as", - "--hs-token", - "hs", - ], registerIO)).resolves.toBe(0); - expect(registerIO.stdoutText.trim()).toBe(registrationPath); - expect(JSON.parse(await readFile(registrationPath, "utf8"))).toMatchObject({ - as_token: "as", - hs_token: "hs", - id: "pickle-openclaw", - sender_localpart: "openclawbot", - }); - expect((await stat(registrationPath)).mode & 0o777).toBe(0o600); - }); - - it("reports unknown commands", async () => { - const io = captureIO(); - await expect(runCli(["wat"], io)).resolves.toBe(2); - expect(io.stderrText).toContain("Unknown command: wat"); + 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).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("starts the bridge from persisted Beeper account config", async () => { - const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-start-")); + 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 io = captureIO(); - const startBridge = vi.fn(async () => undefined); - await expect(runCli([ - "init", - "--config", - configPath, - "--data-dir", - dir, - "--access-token", - "mx-token", - "--gateway-url", - "http://127.0.0.1:18789", - "--homeserver", - "https://matrix.beeper.com", - "--matrix-device-id", - "DEVICE", - "--matrix-user-id", - "@batuhan:beeper.com", - ], captureIO())).resolves.toBe(0); - - await expect(runCli(["start", "--config", configPath, "--get-only", "--backfill", "--backfill-limit", "25"], io, { startBridge })).resolves.toBe(0); - - expect(startBridge).toHaveBeenCalledWith(expect.objectContaining({ + const setupBridge = vi.fn(async () => ({ account: { accessToken: "mx-token", deviceId: "DEVICE", homeserver: "https://matrix.beeper.com", userId: "@batuhan:beeper.com", }, - backfill: true, - backfillLimit: 25, - config: expect.objectContaining({ - gatewayUrl: "http://127.0.0.1:18789", - matrixUserId: "@batuhan:beeper.com", - }), - getOnly: true, - })); - expect(io.stdoutText).toContain("OpenClaw bridge started"); - }); - - it("calls arbitrary OpenClaw Gateway RPC methods from config", async () => { - const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-rpc-")); - const configPath = join(dir, "config.json"); - await expect(runCli([ - "init", - "--config", - configPath, - "--data-dir", - dir, - "--gateway-url", - "http://127.0.0.1:18789", - ], captureIO())).resolves.toBe(0); - const runtime = fakeRuntime({ - "config.schema.lookup": { path: ["agents"], type: "object" }, - }); - const io = captureIO(); - - await expect(runCli([ - "rpc", - "--config", - configPath, - "config.schema.lookup", - "--params-json", - "{\"path\":[\"agents\"]}", - ], io, { runtimeFactory: () => runtime })).resolves.toBe(0); - - expect(runtime.call).toHaveBeenCalledWith("config.schema.lookup", { path: ["agents"] }); - expect(runtime.close).toHaveBeenCalledOnce(); - expect(JSON.parse(io.stdoutText)).toEqual({ path: ["agents"], type: "object" }); - }); - - it("prints an OpenClaw Gateway feature snapshot", async () => { - const runtime = fakeRuntime({}, { - agents: { agents: [] }, - status: { ok: true }, - }); - const io = captureIO(); - - await expect(runCli(["features", "--gateway-url", "http://127.0.0.1:18789"], io, { - runtimeFactory: () => runtime, - })).resolves.toBe(0); - - expect(runtime.featureSnapshot).toHaveBeenCalledOnce(); - expect(runtime.close).toHaveBeenCalledOnce(); - expect(JSON.parse(io.stdoutText)).toEqual({ - agents: { agents: [] }, - status: { ok: true }, - }); - }); - - it("reports gateway smoke failures without token setup guidance", async () => { - const io = captureIO(); - const runtime = { - close: vi.fn(async () => undefined), - featureSnapshot: vi.fn(async () => { - throw new Error("OpenClaw gateway request failed: unauthorized: gateway token missing (provide gateway auth token)"); - }), - listAgentContacts: vi.fn(), - listSessions: vi.fn(), - } as never; - - await expect(runCli(["smoke", "--gateway-only"], io, { - runtimeFactory: () => runtime, - })).resolves.toBe(1); - - expect(io.stderrText).toContain("gateway token missing"); - expect(io.stderrText).not.toContain("--gateway-access-token"); - expect(io.stderrText).not.toContain("OPENCLAW_GATEWAY_TOKEN"); - }); - - it("runs a conservative smoke check across Gateway and Beeper bridge setup", async () => { - const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-smoke-")); - const configPath = join(dir, "config.json"); - await expect(runCli([ - "init", - "--config", - configPath, - "--data-dir", - dir, - "--access-token", - "mx-token", - "--gateway-url", - "http://127.0.0.1:18789", - "--homeserver", - "https://matrix.beeper.com", - "--matrix-device-id", - "DEVICE", - "--matrix-user-id", - "@batuhan:beeper.com", - "--registration-url", - "http://127.0.0.1:29391", - ], captureIO())).resolves.toBe(0); - const runtime = fakeRuntime({}, { - agents: { agents: [{ id: "codex" }] }, - status: { ok: true }, - }, { - agents: [{ agentId: "codex", displayName: "Codex" }], - sessions: [{ key: "dashboard:1", label: "Dashboard session" }], - }); - const bridge = { start: vi.fn(async () => undefined), stop: vi.fn(async () => undefined) }; - const createBridge = vi.fn(async () => bridge as never); - const io = captureIO(); - - await expect(runCli(["smoke", "--config", configPath, "--session-limit", "10"], io, { - createBridge, - runtimeFactory: () => runtime, - })).resolves.toBe(0); - - expect(runtime.featureSnapshot).toHaveBeenCalledOnce(); - expect(runtime.listAgentContacts).toHaveBeenCalledOnce(); - expect(runtime.listSessions).toHaveBeenCalledWith({ includeArchived: true, limit: 10 }); - expect(runtime.close).toHaveBeenCalledOnce(); - expect(createBridge).toHaveBeenCalledWith(expect.objectContaining({ - account: { + config: { accessToken: "mx-token", - deviceId: "DEVICE", + appserviceId: "sh-openclaw-device", + asToken: "as-token", + bridgeId: "sh-openclaw-device", homeserver: "https://matrix.beeper.com", - userId: "@batuhan:beeper.com", - }, - config: expect.objectContaining({ - gatewayUrl: "http://127.0.0.1:18789", + hsToken: "hs-token", + matrixDeviceId: "DEVICE", matrixUserId: "@batuhan:beeper.com", - }), - getOnly: true, - })); - expect(bridge.start).not.toHaveBeenCalled(); - expect(bridge.stop).toHaveBeenCalledOnce(); - expect(JSON.parse(io.stdoutText)).toMatchObject({ - beeper: { - bridgeCreated: true, - getOnly: true, - homeserver: "https://matrix.beeper.com", - userId: "@batuhan:beeper.com", + registrationUrl: "http://127.0.0.1:29391", }, - gateway: { - agents: 1, - sessions: 1, - }, - ok: true, - }); - expect(io.stdoutText).not.toContain("mx-token"); - }); - - it("starts and stops the Beeper bridge during smoke checks when requested", async () => { - const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-smoke-start-")); - const configPath = join(dir, "config.json"); - await expect(runCli([ - "init", - "--config", - configPath, - "--data-dir", - dir, - "--access-token", - "mx-token", - "--homeserver", - "https://matrix.beeper.com", - "--matrix-device-id", - "DEVICE", - "--matrix-user-id", - "@batuhan:beeper.com", - "--registration-url", - "http://127.0.0.1:29391", - ], captureIO())).resolves.toBe(0); - const runtime = fakeRuntime({}, { status: { ok: true } }, { - agents: [{ agentId: "codex", displayName: "Codex" }], - sessions: [], - }); - const bridge = { start: vi.fn(async () => undefined), stop: vi.fn(async () => undefined) }; - const createBridge = vi.fn(async () => bridge as never); - const io = captureIO(); - - await expect(runCli(["smoke", "--config", configPath, "--start"], io, { - createBridge, - runtimeFactory: () => runtime, - })).resolves.toBe(0); - - expect(createBridge).toHaveBeenCalledWith(expect.objectContaining({ getOnly: false })); - expect(bridge.start).toHaveBeenCalledOnce(); - expect(bridge.stop).toHaveBeenCalledOnce(); - expect(JSON.parse(io.stdoutText)).toMatchObject({ - beeper: { - bridgeCreated: true, - getOnly: false, - }, - ok: true, - }); - }); - - it("fails smoke checks when Beeper bridge lifecycle methods are missing", async () => { - const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-smoke-invalid-")); - const configPath = join(dir, "config.json"); - await expect(runCli([ - "init", - "--config", - configPath, - "--data-dir", - dir, - "--access-token", - "mx-token", - "--homeserver", - "https://matrix.beeper.com", - "--matrix-device-id", - "DEVICE", - "--matrix-user-id", - "@batuhan:beeper.com", - ], captureIO())).resolves.toBe(0); - const runtime = fakeRuntime({}, { status: { ok: true } }, { - agents: [], - sessions: [], - }); - const io = captureIO(); - - await expect(runCli(["smoke", "--config", configPath], io, { - createBridge: vi.fn(async () => ({}) as never), - runtimeFactory: () => runtime, - })).resolves.toBe(1); - - expect(runtime.close).toHaveBeenCalledOnce(); - expect(io.stderrText).toContain("bridge object is missing start/stop lifecycle methods"); - }); - - it("runs Beeper setup from CLI and persists runtime bridge-manager settings", async () => { - const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-beeper-setup-")); - const configPath = join(dir, "config.json"); - const io = captureIO(); - const setupBridge = vi.fn(async (options) => { - expect(options).toMatchObject({ - baseDomain: "beeper-staging.com", - bridgeManagerToken: "hungry-token", - email: "batuhan@example.com", - env: "staging", - homeserverDomain: "beeper.local", - postState: false, - }); - expect(await options.getLoginCode?.()).toBe("123456"); - return { - account: { - accessToken: "mx-token", - deviceId: "DEV", - homeserver: "https://matrix.beeper-staging.com", - userId: "@batuhan:beeper-staging.com", - }, - config: { - accessToken: "mx-token", - appserviceId: "openclaw", - homeserver: "https://matrix.beeper-staging.com/_hungryserv/batuhan", - hsToken: "hs", - matrixDeviceId: "DEV", - matrixUserId: "@batuhan:beeper-staging.com", - registrationUrl: "http://127.0.0.1:29391", - }, - init: { - homeserver: "https://matrix.beeper-staging.com/_hungryserv/batuhan", - registration: { id: "openclaw", hsToken: "hs", url: "http://127.0.0.1:29391" }, + init: { + homeserver: "https://matrix.beeper.com", + registration: { + asToken: "as-token", + hsToken: "hs-token", + id: "sh-openclaw-device", + senderLocalpart: "openclawbot", + url: "http://127.0.0.1:29391", }, - } as never; - }); + }, + })); + const io = captureIO("123456\n"); await expect(runCli([ - "beeper-setup", + "login", "--config", configPath, "--data-dir", dir, "--email", - "batuhan@example.com", - "--login-code", - "123456", + "you@example.com", "--env", "staging", "--bridge-manager-token", - "hungry-token", - "--homeserver-domain", - "beeper.local", - "--no-post-state", + "bridge-manager-token", ], io, { setupBridge })).resolves.toBe(0); - const written = JSON.parse(await readFile(configPath, "utf8")); - expect(written).toMatchObject({ - accessToken: "mx-token", - appserviceId: "openclaw", + expect(setupBridge).toHaveBeenCalledWith(expect.objectContaining({ baseDomain: "beeper-staging.com", + bridgeManagerToken: "bridge-manager-token", + email: "you@example.com", + env: "staging", + getLoginCode: expect.any(Function), + postState: true, + push: false, + selfHosted: true, + })); + 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({ + accessToken: "mx-token", + appserviceId: "sh-openclaw-device", + asToken: "as-token", + beeperEnv: "staging", + bridgeManagerToken: "bridge-manager-token", + homeserver: "https://matrix.beeper.com", + hsToken: "hs-token", + matrixDeviceId: "DEVICE", + matrixUserId: "@batuhan:beeper.com", + }); + const output = JSON.parse(io.stdoutText); + expect(output.account).toMatchObject({ + appserviceId: "sh-openclaw-device", beeperEnv: "staging", - bridgeManagerPostState: false, - bridgeManagerToken: "hungry-token", - homeserver: "https://matrix.beeper-staging.com/_hungryserv/batuhan", - homeserverDomain: "beeper.local", - hsToken: "hs", - matrixDeviceId: "DEV", - matrixUserId: "@batuhan:beeper-staging.com", + bridgeId: "sh-openclaw-device", + canConnect: true, + deviceId: "DEVICE", + userId: "@batuhan:beeper.com", }); - expect(io.stdoutText).toContain('"bridgeManagerToken": ""'); - expect(io.stdoutText).not.toContain("hungry-token"); + 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 Beeper login OTP in CLI setup when --login-code is omitted", async () => { - const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-beeper-setup-prompt-")); - const configPath = join(dir, "config.json"); - const io = captureIO("654321\n"); - const setupBridge = vi.fn(async (options) => { - expect(await options.getLoginCode?.()).toBe("654321"); - return { - account: { - accessToken: "mx-token", - deviceId: "DEV", - homeserver: "https://matrix.beeper.com", - userId: "@batuhan:beeper.com", - }, - config: { - accessToken: "mx-token", - appserviceId: "openclaw", - homeserver: "https://matrix.beeper.com/_hungryserv/batuhan", - hsToken: "hs", - matrixDeviceId: "DEV", - matrixUserId: "@batuhan:beeper.com", - registrationUrl: "http://127.0.0.1:29391", - }, - init: { - homeserver: "https://matrix.beeper.com/_hungryserv/batuhan", - registration: { id: "openclaw", hsToken: "hs", url: "http://127.0.0.1:29391" }, + 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: { + accessToken: "mx-token", + 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", + registrationUrl: "http://127.0.0.1:29391", + }, + init: { + homeserver: "https://matrix.beeper.com", + registration: { + asToken: "as-token", + hsToken: "hs-token", + id: "sh-openclaw-device", + senderLocalpart: "openclawbot", + url: "http://127.0.0.1:29391", }, - } as never; - }); + }, + })); + const io = captureIO("654321\n"); await expect(runCli([ - "beeper-setup", + "login", "--config", - configPath, - "--data-dir", - dir, + join(dir, "config.json"), "--email", - "batuhan@example.com", + "alice@example.com", ], io, { setupBridge })).resolves.toBe(0); - expect(setupBridge).toHaveBeenCalledOnce(); + await expect(setupBridge.mock.calls[0]?.[0].getLoginCode()).resolves.toBe("654321"); expect(io.stderrText).toContain("Enter Beeper login code:"); - expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({ - accessToken: "mx-token", - matrixDeviceId: "DEV", - }); }); - it("prompts for Beeper login OTP in CLI login when --login-code is omitted", async () => { - const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-beeper-login-prompt-")); + it("prints the saved Beeper bridge identity", async () => { + const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-whoami-")); const configPath = join(dir, "config.json"); - const io = captureIO("111222\n"); - const loginToBeeper = vi.fn(async (options) => { - expect(await options.getLoginCode?.()).toBe("111222"); - return { - account: { - accessToken: "mx-token", - deviceId: "DEV", - homeserver: "https://matrix.beeper.com", - userId: "@batuhan:beeper.com", - }, - config: { - accessToken: "mx-token", - homeserver: "https://matrix.beeper.com", - matrixDeviceId: "DEV", - matrixUserId: "@batuhan:beeper.com", - }, - }; - }); - - await expect(runCli([ - "beeper-login", + await runCli([ + "login", "--config", configPath, - "--data-dir", - dir, "--email", - "batuhan@example.com", - ], io, { loginToBeeper })).resolves.toBe(0); + "you@example.com", + ], captureIO("123456\n"), { setupBridge: successfulSetupBridge() }); + const io = captureIO(); - expect(loginToBeeper).toHaveBeenCalledOnce(); - expect(io.stderrText).toContain("Enter Beeper login code:"); - expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({ - accessToken: "mx-token", - matrixUserId: "@batuhan:beeper.com", + await expect(runCli(["whoami", "--config", configPath], io)).resolves.toBe(0); + + expect(JSON.parse(io.stdoutText)).toEqual({ + appserviceId: "sh-openclaw-device", + beeperEnv: "production", + bridgeId: "sh-openclaw-device", + bridgeManagerPostState: true, + canConnect: true, + deviceId: "DEVICE", + homeserver: "https://matrix.beeper.com", + registrationUrl: "http://127.0.0.1:29391", + userId: "@batuhan:beeper.com", }); }); - it("runs Beeper appservice registration from CLI and preserves existing login config", async () => { - const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-beeper-register-")); - const configPath = join(dir, "config.json"); - await expect(runCli([ - "init", - "--config", - configPath, - "--data-dir", - dir, - "--access-token", - "mx-token", - "--homeserver", - "https://matrix.beeper.com", - ], captureIO())).resolves.toBe(0); - const createAppService = vi.fn(async (options) => { - expect(options).toMatchObject({ - accessToken: "mx-token", - address: "http://127.0.0.1:29391", - bridgeManagerToken: "hungry-token", - getOnly: true, - homeserver: "https://matrix.beeper.com", - postState: false, - selfHosted: true, - }); - return { - config: { - appserviceId: "openclaw", - homeserver: "https://matrix.beeper.com/_hungryserv/batuhan", - hsToken: "hs", - registrationUrl: "http://127.0.0.1:29391", - }, - init: { - homeserver: "https://matrix.beeper.com/_hungryserv/batuhan", - registration: { id: "openclaw", hsToken: "hs", url: "http://127.0.0.1:29391" }, - }, - } as never; - }); + it("reports incomplete identity when no Beeper login is saved", async () => { const io = captureIO(); - await expect(runCli([ - "beeper-register", - "--config", - configPath, - "--bridge-manager-token", - "hungry-token", - "--get-only", - "--no-post-state", - ], io, { createAppService })).resolves.toBe(0); + await expect(runCli(["whoami", "--data-dir", "/tmp/pickle-openclaw-empty"], io)).resolves.toBe(0); - const written = JSON.parse(await readFile(configPath, "utf8")); - expect(written).toMatchObject({ - accessToken: "mx-token", - appserviceId: "openclaw", - bridgeManagerPostState: false, - bridgeManagerToken: "hungry-token", - homeserver: "https://matrix.beeper.com/_hungryserv/batuhan", - hsToken: "hs", + expect(JSON.parse(io.stdoutText)).toMatchObject({ + canConnect: false, + deviceId: null, + homeserver: null, + userId: null, }); - expect(io.stdoutText).toContain('"bridgeManagerToken": ""'); - expect(io.stdoutText).not.toContain("hungry-token"); }); }); -function fakeRuntime(responses: Record, snapshot: unknown = {}, lists: { - agents?: unknown[]; - sessions?: unknown[]; -} = {}) { - return { - call: vi.fn(async (method: string) => responses[method]), - close: vi.fn(async () => undefined), - featureSnapshot: vi.fn(async () => snapshot), - listAgentContacts: vi.fn(async () => lists.agents ?? []), - listSessions: vi.fn(async () => lists.sessions ?? []), - } as never; +function successfulSetupBridge() { + return vi.fn(async () => ({ + account: { + accessToken: "mx-token", + deviceId: "DEVICE", + homeserver: "https://matrix.beeper.com", + userId: "@batuhan:beeper.com", + }, + config: { + accessToken: "mx-token", + 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", + registrationUrl: "http://127.0.0.1:29391", + }, + init: { + homeserver: "https://matrix.beeper.com", + registration: { + asToken: "as-token", + hsToken: "hs-token", + id: "sh-openclaw-device", + senderLocalpart: "openclawbot", + url: "http://127.0.0.1:29391", + }, + }, + })); } -function captureIO(stdinText?: string) { - const io = { - stderrText: "", - stdoutText: "", - stdin: stdinText === undefined ? undefined : Readable.from([stdinText]), +function captureIO(stdin = "") { + const stdout: string[] = []; + const stderr: string[] = []; + return { + get stderrText() { + return stderr.join(""); + }, + get stdoutText() { + return stdout.join(""); + }, stderr: { - write(this: { owner: { stderrText: string } }, chunk: string) { - this.owner.stderrText += chunk; + write: (chunk: string | Uint8Array) => { + stderr.push(String(chunk)); return true; }, - owner: undefined as unknown as { stderrText: string }, }, + stdin: Readable.from([stdin]), stdout: { - write(this: { owner: { stdoutText: string } }, chunk: string) { - this.owner.stdoutText += chunk; + write: (chunk: string | Uint8Array) => { + stdout.push(String(chunk)); return true; }, - owner: undefined as unknown as { stdoutText: string }, }, }; - io.stderr.owner = io; - io.stdout.owner = io; - return io; } diff --git a/packages/openclaw/src/cli.ts b/packages/openclaw/src/cli.ts index 2d96127..db98708 100644 --- a/packages/openclaw/src/cli.ts +++ b/packages/openclaw/src/cli.ts @@ -1,20 +1,9 @@ #!/usr/bin/env node -import { chmod, mkdir, writeFile } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; import { createInterface } from "node:readline/promises"; import type { BeeperEnvironment } from "@beeper/pickle/beeper/auth"; -import { - accountFromOpenClawConfig, - createOpenClawBeeperBridge, - startOpenClawBeeperBridge, - type CreateOpenClawBeeperBridgeOptions, -} from "./appservice"; -import { createOpenClawBeeperAppService, loginToBeeperForOpenClaw, setupOpenClawBeeperBridge } from "./beeper-setup"; -import { createDefaultConfig, defaultConfigPath, readConfig, secretToken, writeConfig } from "./config"; -import { createOpenClawRuntimeFromLogin, userLoginFromOpenClawConfig } from "./connector"; -import type { OpenClawGatewayRuntime } from "./openclaw-runtime"; -import { createAppserviceRegistration } from "./registration"; -import type { AppserviceRegistration, OpenClawBridgeConfig } from "./types"; +import { setupOpenClawBeeperBridge } from "./beeper-setup"; +import { createDefaultConfig, defaultConfigPath, readConfig, writeConfig } from "./config"; +import type { OpenClawBridgeConfig } from "./types"; export interface CliIO { stderr: Pick; @@ -23,12 +12,7 @@ export interface CliIO { } export interface CliDeps { - createAppService?: typeof createOpenClawBeeperAppService; - createBridge?: typeof createOpenClawBeeperBridge; - loginToBeeper?: typeof loginToBeeperForOpenClaw; - runtimeFactory?: (config: OpenClawBridgeConfig) => OpenClawGatewayRuntime; setupBridge?: typeof setupOpenClawBeeperBridge; - startBridge?: (options: CreateOpenClawBeeperBridgeOptions) => Promise; } export async function runCli(argv = process.argv.slice(2), io: CliIO = process, deps: CliDeps = {}): Promise { @@ -38,188 +22,9 @@ export async function runCli(argv = process.argv.slice(2), io: CliIO = process, io.stdout.write(helpText()); return 0; } - if (command === "init") { - const options = parseOptions(args); - const config = createDefaultConfig(configOverridesFromOptions(options)); - await writeConfig(config, stringOption(options, "config") ?? defaultConfigPath(config.dataDir)); - io.stdout.write(`${JSON.stringify(redactConfig(config), null, 2)}\n`); - return 0; - } - if (command === "register") { - const options = parseOptions(args); - const config = await loadConfig(options); - const registration = createAppserviceRegistration(config, { - asToken: stringOption(options, "as-token") ?? config.asToken ?? secretToken(), - hsToken: stringOption(options, "hs-token") ?? config.hsToken ?? secretToken(), - }); - const output = stringOption(options, "output") ?? resolve(config.dataDir, "registration.json"); - await writeRegistration(output, registration); - io.stdout.write(`${output}\n`); - return 0; - } - if (command === "status") { - const config = await loadConfig(parseOptions(args)); - io.stdout.write(`${JSON.stringify(redactConfig(config), null, 2)}\n`); - return 0; - } - if (command === "features") { - const options = parseOptions(args); - const config = await loadConfig(options); - const runtime = deps.runtimeFactory?.(config) ?? runtimeFromConfig(config); - try { - io.stdout.write(`${JSON.stringify(await runtime.featureSnapshot(), null, 2)}\n`); - } finally { - await runtime.close(); - } - return 0; - } - if (command === "smoke") { - const options = parseOptions(args); - const config = await loadConfig(options); - const runtime = deps.runtimeFactory?.(config) ?? runtimeFromConfig(config); - let bridge: unknown; - try { - const [features, agents, sessions] = await Promise.all([ - runtime.featureSnapshot(), - runtime.listAgentContacts(), - runtime.listSessions({ includeArchived: true, limit: numberOption(options, "session-limit") ?? 25 }), - ]); - const includeBeeper = !booleanOption(options, "gateway-only"); - const account = includeBeeper ? accountFromOpenClawConfig(config) : undefined; - if (account) { - bridge = await (deps.createBridge ?? createOpenClawBeeperBridge)({ - account, - config, - getOnly: !booleanOption(options, "start"), - }); - validateSmokeBridgeObject(bridge); - if (booleanOption(options, "start")) { - await startBridgeObject(bridge); - } - } - io.stdout.write(`${JSON.stringify({ - beeper: includeBeeper ? { - bridgeCreated: Boolean(bridge), - getOnly: !booleanOption(options, "start"), - homeserver: account?.homeserver, - userId: account?.userId, - } : { skipped: true }, - config: { - appserviceId: config.appserviceId, - gatewayUrl: config.gatewayUrl, - hasAccessToken: Boolean(config.accessToken), - homeserver: config.homeserver, - matrixUserId: config.matrixUserId, - registrationUrl: config.registrationUrl, - }, - gateway: { - agents: agents.length, - featureSnapshot: features, - sessions: sessions.length, - }, - ok: true, - }, null, 2)}\n`); - } finally { - await stopBridgeObject(bridge); - await runtime.close(); - } - return 0; - } - if (command === "rpc") { - const { paramsText, positional } = splitOptionsAndPositionals(args); - const options = parseOptions(args); - const method = positional[0]; - if (!method) throw new Error("rpc requires a Gateway method name"); - const params = paramsText !== undefined ? parseJsonParam(paramsText) : parseJsonParam(positional[1] ?? "{}"); - const config = await loadConfig(options); - const runtime = deps.runtimeFactory?.(config) ?? runtimeFromConfig(config); - try { - io.stdout.write(`${JSON.stringify(await runtime.call(method, params), null, 2)}\n`); - } finally { - await runtime.close(); - } - return 0; - } - if (command === "start") { - const options = parseOptions(args); - const config = await loadConfig(options); - const startOptions: CreateOpenClawBeeperBridgeOptions = { - account: accountFromOpenClawConfig(config), - config, - }; - if (booleanOption(options, "get-only")) startOptions.getOnly = true; - if (booleanOption(options, "backfill")) startOptions.backfill = true; - const backfillLimit = numberOption(options, "backfill-limit"); - if (backfillLimit !== undefined) startOptions.backfillLimit = backfillLimit; - await (deps.startBridge ?? startOpenClawBeeperBridge)(startOptions); - io.stdout.write("OpenClaw bridge started\n"); - return 0; - } - if (command === "beeper-login") { - const options = parseOptions(args); - const email = requiredStringOption(options, "email"); - const loginCode = stringOption(options, "login-code"); - const loginOptions: Parameters[0] = { - email, - }; - const env = beeperEnvOption(options); - if (env !== undefined) loginOptions.env = env; - if (loginCode !== undefined) loginOptions.getLoginCode = () => loginCode; - else loginOptions.getLoginCode = () => promptForLoginCode(io); - const result = await (deps.loginToBeeper ?? loginToBeeperForOpenClaw)(loginOptions); - const config = createDefaultConfig({ - ...configOverridesFromOptions(options), - ...beeperRuntimeOverridesFromOptions(options), - ...result.config, - }); - await writeConfig(config, stringOption(options, "config") ?? defaultConfigPath(config.dataDir)); - io.stdout.write(`${JSON.stringify(redactConfig(config), null, 2)}\n`); - return 0; - } - if (command === "beeper-register") { - const options = parseOptions(args); - const configPath = stringOption(options, "config"); - const existingConfig = configPath ? await readConfig(configPath) : createDefaultConfig(configOverridesFromOptions(options)); - const accessToken = stringOption(options, "access-token") ?? existingConfig.accessToken; - if (!accessToken) throw new Error("beeper-register requires --access-token or a config with accessToken"); - const registerOptions: Parameters[0] = { - accessToken, - address: stringOption(options, "registration-url") ?? existingConfig.registrationUrl, - getOnly: booleanOption(options, "get-only"), - postState: !booleanOption(options, "no-post-state"), - push: booleanOption(options, "push"), - selfHosted: !booleanOption(options, "not-self-hosted"), - }; - const baseDomain = stringOption(options, "base-domain") ?? beeperBaseDomainOption(options); - const bridge = stringOption(options, "bridge"); - const bridgeManagerToken = stringOption(options, "bridge-manager-token"); - const bridgeType = stringOption(options, "bridge-type"); - const homeserver = stringOption(options, "homeserver") ?? existingConfig.homeserver; - const homeserverDomain = stringOption(options, "homeserver-domain"); - const username = stringOption(options, "username"); - if (baseDomain !== undefined) registerOptions.baseDomain = baseDomain; - if (bridge !== undefined) registerOptions.bridge = bridge; - if (bridgeManagerToken !== undefined) registerOptions.bridgeManagerToken = bridgeManagerToken; - if (bridgeType !== undefined) registerOptions.bridgeType = bridgeType; - if (homeserver !== undefined) registerOptions.homeserver = homeserver; - if (homeserverDomain !== undefined) registerOptions.homeserverDomain = homeserverDomain; - if (username !== undefined) registerOptions.username = username; - const result = await (deps.createAppService ?? createOpenClawBeeperAppService)(registerOptions); - const config = createDefaultConfig({ - ...existingConfig, - ...configOverridesFromOptions(options), - ...beeperRuntimeOverridesFromOptions(options), - ...result.config, - accessToken, - }); - await writeConfig(config, configPath ?? defaultConfigPath(config.dataDir)); - io.stdout.write(`${JSON.stringify({ config: redactConfig(config), init: result.init }, null, 2)}\n`); - return 0; - } - if (command === "beeper-setup") { + if (command === "login") { const options = parseOptions(args); const email = requiredStringOption(options, "email"); - const loginCode = stringOption(options, "login-code"); const setupOptions: Parameters[0] = { email, postState: !booleanOption(options, "no-post-state"), @@ -228,7 +33,6 @@ export async function runCli(argv = process.argv.slice(2), io: CliIO = process, }; const address = stringOption(options, "registration-url"); const baseDomain = stringOption(options, "base-domain") ?? beeperBaseDomainOption(options); - const bridge = stringOption(options, "bridge"); const bridgeManagerToken = stringOption(options, "bridge-manager-token"); const bridgeType = stringOption(options, "bridge-type"); const env = beeperEnvOption(options); @@ -236,12 +40,10 @@ export async function runCli(argv = process.argv.slice(2), io: CliIO = process, const username = stringOption(options, "username"); if (address !== undefined) setupOptions.address = address; if (baseDomain !== undefined) setupOptions.baseDomain = baseDomain; - if (bridge !== undefined) setupOptions.bridge = bridge; if (bridgeManagerToken !== undefined) setupOptions.bridgeManagerToken = bridgeManagerToken; if (bridgeType !== undefined) setupOptions.bridgeType = bridgeType; if (env !== undefined) setupOptions.env = env; - if (loginCode !== undefined) setupOptions.getLoginCode = () => loginCode; - else setupOptions.getLoginCode = () => promptForLoginCode(io); + setupOptions.getLoginCode = () => promptForLoginCode(io); if (homeserverDomain !== undefined) setupOptions.homeserverDomain = homeserverDomain; if (username !== undefined) setupOptions.username = username; const result = await (deps.setupBridge ?? setupOpenClawBeeperBridge)(setupOptions); @@ -251,89 +53,48 @@ export async function runCli(argv = process.argv.slice(2), io: CliIO = process, ...result.config, }); await writeConfig(config, stringOption(options, "config") ?? defaultConfigPath(config.dataDir)); - io.stdout.write(`${JSON.stringify({ config: redactConfig(config), init: result.init }, null, 2)}\n`); + 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(`${formatCliError(error)}\n`); + io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); return 1; } } -export async function writeRegistration(path: string, registration: AppserviceRegistration): Promise { - await mkdir(dirname(path), { recursive: true }); - await writeFile(path, `${JSON.stringify(registration, null, 2)}\n`, { mode: 0o600 }); - await chmod(path, 0o600); -} - function helpText(): string { return [ "pickle-openclaw ", "", "Commands:", - " init Write a secure OpenClaw bridge config", - " register Write a Matrix appservice registration file", - " start Start the OpenClaw Beeper bridge from config", - " status Print the redacted effective config", - " features Probe the documented OpenClaw Gateway feature surface", - " smoke Validate config, Gateway reachability, and Beeper bridge setup", - " rpc Call any OpenClaw Gateway RPC method", - " beeper-login Log in to Beeper and write Matrix credentials", - " beeper-register Register the OpenClaw appservice with Beeper", - " beeper-setup Log in and register the OpenClaw appservice", + " login Log in to Beeper and register the OpenClaw appservice", + " whoami Print the saved Beeper bridge identity", "", "Common options:", " --config ", " --data-dir ", - " --homeserver ", - " --gateway-url ", - " --registration-url ", - " --matrix-device-id ", - " --matrix-user-id ", - " --access-token ", - " --hs-token ", - " --as-token ", - " --output ", " --email
", - " --login-code ", + " --registration-url ", " --bridge-manager-token ", - " --backfill", - " --backfill-limit ", - " --gateway-only", - " --session-limit ", - " --start", - " --params-json ", " --env ", "", ].join("\n"); } -function formatCliError(error: unknown): string { - const message = error instanceof Error ? error.message : String(error); - return message; -} - function configOverridesFromOptions(options: Map): Partial { const overrides: Partial = {}; - const accessToken = stringOption(options, "access-token"); - const asToken = stringOption(options, "as-token"); - const appserviceId = stringOption(options, "appservice-id"); const dataDir = stringOption(options, "data-dir"); - const gatewayUrl = stringOption(options, "gateway-url"); - const homeserver = stringOption(options, "homeserver"); - const matrixDeviceId = stringOption(options, "matrix-device-id"); - const matrixUserId = stringOption(options, "matrix-user-id"); const registrationUrl = stringOption(options, "registration-url"); - if (accessToken) overrides.accessToken = accessToken; - if (asToken) overrides.asToken = asToken; - if (appserviceId) overrides.appserviceId = appserviceId; if (dataDir) overrides.dataDir = dataDir; - if (gatewayUrl) overrides.gatewayUrl = gatewayUrl; - if (homeserver) overrides.homeserver = homeserver; - if (matrixDeviceId) overrides.matrixDeviceId = matrixDeviceId; - if (matrixUserId) overrides.matrixUserId = matrixUserId; if (registrationUrl) overrides.registrationUrl = registrationUrl; return overrides; } @@ -359,13 +120,25 @@ async function loadConfig(options: Map): Promise { return { - ...config, - ...(config.accessToken ? { accessToken: "" } : {}), - ...(config.asToken ? { asToken: "" } : {}), - ...(config.bridgeManagerToken ? { bridgeManagerToken: "" } : {}), - ...(config.hsToken ? { hsToken: "" } : {}), + appserviceId: config.appserviceId, + beeperEnv: config.beeperEnv ?? "production", + bridgeId: config.bridgeId ?? null, + bridgeManagerPostState: config.bridgeManagerPostState ?? true, + canConnect: Boolean( + config.accessToken && + config.asToken && + config.homeserver && + config.hsToken && + config.matrixDeviceId && + config.matrixUserId && + config.registrationUrl + ), + deviceId: config.matrixDeviceId ?? null, + homeserver: config.homeserver ?? null, + registrationUrl: config.registrationUrl, + userId: config.matrixUserId ?? null, }; } @@ -386,35 +159,6 @@ function parseOptions(args: string[]): Map { return options; } -function splitOptionsAndPositionals(args: string[]): { paramsText?: string; positional: string[] } { - const positional: string[] = []; - let paramsText: string | undefined; - for (let index = 0; index < args.length; index += 1) { - const arg = args[index]; - if (!arg) continue; - if (arg === "--params-json") { - paramsText = args[index + 1]; - index += 1; - continue; - } - if (arg.startsWith("--")) { - const next = args[index + 1]; - if (next && !next.startsWith("--")) index += 1; - continue; - } - positional.push(arg); - } - return { ...(paramsText !== undefined ? { paramsText } : {}), positional }; -} - -function parseJsonParam(value: string): unknown { - try { - return JSON.parse(value); - } catch (error) { - throw new Error(`Invalid JSON params: ${error instanceof Error ? error.message : String(error)}`); - } -} - function stringOption(options: Map, key: string): string | undefined { const value = options.get(key); return typeof value === "string" ? value : undefined; @@ -430,14 +174,6 @@ function booleanOption(options: Map, key: string): boo return options.get(key) === true; } -function numberOption(options: Map, key: string): number | undefined { - const value = stringOption(options, key); - if (value === undefined) return undefined; - const parsed = Number(value); - if (!Number.isInteger(parsed) || parsed < 0) throw new Error(`Invalid --${key}: ${value}`); - return parsed; -} - function beeperEnvOption(options: Map): BeeperEnvironment | undefined { const env = stringOption(options, "env"); if (env === undefined) return undefined; @@ -453,31 +189,6 @@ function beeperBaseDomainOption(options: Map): string return undefined; } -async function startBridgeObject(bridge: unknown): Promise { - const start = bridge && typeof bridge === "object" && "start" in bridge ? bridge.start : undefined; - if (typeof start === "function") await start.call(bridge); -} - -async function stopBridgeObject(bridge: unknown): Promise { - const stop = bridge && typeof bridge === "object" && "stop" in bridge ? bridge.stop : undefined; - if (typeof stop === "function") await stop.call(bridge); -} - -function validateSmokeBridgeObject(bridge: unknown): void { - if (!bridge || typeof bridge !== "object") { - throw new Error("Beeper smoke failed: bridge factory did not return a bridge object"); - } - const start = "start" in bridge ? bridge.start : undefined; - const stop = "stop" in bridge ? bridge.stop : undefined; - if (typeof start !== "function" || typeof stop !== "function") { - throw new Error("Beeper smoke failed: bridge object is missing start/stop lifecycle methods"); - } -} - -function runtimeFromConfig(config: OpenClawBridgeConfig): OpenClawGatewayRuntime { - return createOpenClawRuntimeFromLogin(userLoginFromOpenClawConfig(config), config); -} - async function promptForLoginCode(io: CliIO): Promise { const input = io.stdin ?? process.stdin; const rl = createInterface({ diff --git a/packages/openclaw/src/config.test.ts b/packages/openclaw/src/config.test.ts index 088b2c6..0497b46 100644 --- a/packages/openclaw/src/config.test.ts +++ b/packages/openclaw/src/config.test.ts @@ -11,12 +11,15 @@ describe("OpenClaw bridge config", () => { delete process.env.PICKLE_OPENCLAW_ALLOW_USERS; delete process.env.PICKLE_OPENCLAW_APPSERVICE_ID; delete process.env.PICKLE_OPENCLAW_APP_SERVICE_ID; + delete process.env.PICKLE_OPENCLAW_BRIDGE_ID; + delete process.env.PICKLE_OPENCLAW_DEVICE_ID; + 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: "pickle-openclaw", + appserviceId: "sh-openclaw", dataDir: "/tmp/openclaw-bridge", ghostLocalpartPrefix: "openclaw_agent_", nonFederatedRooms: true, @@ -28,6 +31,14 @@ describe("OpenClaw bridge config", () => { }); }); + 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", + bridgeId: "sh-openclaw-openclaw-dev-123", + }); + }); + it("accepts dashboard-derived bridge behavior settings", () => { expect(createDefaultConfig({ backfillLimit: 25, diff --git a/packages/openclaw/src/config.ts b/packages/openclaw/src/config.ts index 7c14f22..9de0883 100644 --- a/packages/openclaw/src/config.ts +++ b/packages/openclaw/src/config.ts @@ -3,10 +3,10 @@ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, resolve } from "node:path"; import { getBeeperChannelSettings, type OpenClawSetupConfig } from "./setup"; +import { openClawBeeperBridgeId } from "./ids"; import type { OpenClawBridgeConfig } from "./types"; -export const DEFAULT_APPSERVICE_ID = "pickle-openclaw"; -export const DEFAULT_GATEWAY_URL = "ws://127.0.0.1:18789"; +export const DEFAULT_APPSERVICE_ID = "sh-openclaw"; export const DEFAULT_GHOST_LOCALPART_PREFIX = "openclaw_agent_"; export const DEFAULT_REGISTRATION_URL = "http://127.0.0.1:29391"; export const DEFAULT_SENDER_LOCALPART = "openclawbot"; @@ -23,6 +23,7 @@ export function defaultConfigPath(dataDir = defaultDataDir()): string { export function createDefaultConfig(overrides: Partial = {}): OpenClawBridgeConfig { const dataDir = overrides.dataDir ?? process.env.PICKLE_OPENCLAW_DATA_DIR ?? defaultDataDir(); + const matrixDeviceId = overrides.matrixDeviceId ?? process.env.PICKLE_OPENCLAW_MATRIX_DEVICE_ID; const config: OpenClawBridgeConfig = { appserviceId: overrides.appserviceId ?? @@ -51,11 +52,11 @@ export function createDefaultConfig(overrides: Partial = { const baseDomain = overrides.baseDomain ?? process.env.PICKLE_OPENCLAW_BASE_DOMAIN; const beeperEnv = overrides.beeperEnv ?? envBeeperEnv(process.env.PICKLE_OPENCLAW_BEEPER_ENV); const bridgeManagerToken = overrides.bridgeManagerToken ?? process.env.PICKLE_OPENCLAW_BRIDGE_MANAGER_TOKEN; - const gatewayUrl = overrides.gatewayUrl ?? process.env.PICKLE_OPENCLAW_GATEWAY_URL ?? DEFAULT_GATEWAY_URL; + const openClawDeviceId = process.env.PICKLE_OPENCLAW_DEVICE_ID ?? process.env.OPENCLAW_DEVICE_ID; + const bridgeId = overrides.bridgeId ?? process.env.PICKLE_OPENCLAW_BRIDGE_ID ?? (openClawDeviceId ? openClawBeeperBridgeId(openClawDeviceId) : undefined); const homeserver = overrides.homeserver ?? process.env.PICKLE_OPENCLAW_HOMESERVER; const homeserverDomain = overrides.homeserverDomain ?? process.env.PICKLE_OPENCLAW_HOMESERVER_DOMAIN; const hsToken = overrides.hsToken ?? process.env.PICKLE_OPENCLAW_HS_TOKEN; - const matrixDeviceId = overrides.matrixDeviceId ?? process.env.PICKLE_OPENCLAW_MATRIX_DEVICE_ID; const matrixUserId = overrides.matrixUserId ?? process.env.PICKLE_OPENCLAW_MATRIX_USER_ID; const backfillLimit = overrides.backfillLimit ?? envNumber(process.env.PICKLE_OPENCLAW_BACKFILL_LIMIT); const contactVisibility = overrides.contactVisibility ?? envContactVisibility(process.env.PICKLE_OPENCLAW_CONTACT_VISIBILITY); @@ -69,8 +70,8 @@ export function createDefaultConfig(overrides: Partial = { if (asToken) config.asToken = asToken; if (baseDomain) config.baseDomain = baseDomain; if (beeperEnv) config.beeperEnv = beeperEnv; + if (bridgeId) config.bridgeId = bridgeId; if (bridgeManagerToken) config.bridgeManagerToken = bridgeManagerToken; - if (gatewayUrl) config.gatewayUrl = gatewayUrl; if (homeserver) config.homeserver = homeserver; if (homeserverDomain) config.homeserverDomain = homeserverDomain; if (hsToken) config.hsToken = hsToken; diff --git a/packages/openclaw/src/connector.test.ts b/packages/openclaw/src/connector.test.ts index 955747b..34c78d2 100644 --- a/packages/openclaw/src/connector.test.ts +++ b/packages/openclaw/src/connector.test.ts @@ -1,4 +1,4 @@ -import type { BridgeRequestContext, MatrixEdit, MatrixMessage, MatrixReaction, MatrixReactionRemove, MatrixRedaction, UserLogin } from "@beeper/pickle-bridge"; +import type { MatrixEdit, MatrixMessage, MatrixReaction, MatrixReactionRemove, MatrixRedaction, UserLogin } from "@beeper/pickle-bridge"; import { describe, expect, it, vi } from "vitest"; import { createDefaultConfig } from "./config"; import { createOpenClawConnector, OpenClawNetworkAPI, parseMatrixTextMessage, userLoginFromOpenClawConfig } from "./connector"; @@ -6,9 +6,9 @@ import { OpenClawGatewayRuntime, type OpenClawGatewayEvent, type OpenClawTranspo import { OpenClawBridgeRegistry } from "./registry"; describe("OpenClawBridgeConnector", () => { - it("exposes bridgev2-shaped metadata, capabilities, and login flow", async () => { + it("exposes bridgev2-shaped metadata and direct plugin capabilities", async () => { const connector = createOpenClawConnector({ - config: createDefaultConfig({ dataDir: "/tmp/openclaw", gatewayUrl: "ws://gateway" }), + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), }); expect(connector.getName()).toMatchObject({ beeperBridgeType: "openclaw", @@ -21,49 +21,40 @@ describe("OpenClawBridgeConnector", () => { createDM: true, lookupUsername: true, }); - expect(connector.getLoginFlows()).toEqual([ - { - description: "Connect to an existing OpenClaw gateway by URL.", - id: "openclaw.gateway", - name: "OpenClaw Gateway", - }, - ]); - - const process = connector.createLogin({} as BridgeRequestContext, { id: "@alice:example.com" }, "openclaw.gateway"); - await expect(process.start()).resolves.toMatchObject({ - stepId: "openclaw.gateway.credentials", - type: "user_input", - }); - await expect( - "submitUserInput" in process - ? process.submitUserInput({ gateway_url: "ws://gateway" }) - : undefined - ).resolves.toMatchObject({ - complete: { - userLogin: { - metadata: { - gatewayUrl: "ws://gateway", - }, - remoteName: "OpenClaw", - userId: "@alice:example.com", - }, - }, - type: "complete", - }); + expect(connector.getLoginFlows()).toEqual([]); + expect(() => connector.createLogin({} as never, { id: "@alice:example.com" }, "openclaw.gateway")).toThrow("direct plugin mode"); }); - it("keeps Beeper Matrix tokens out of OpenClaw gateway metadata", () => { + it("keeps Beeper Matrix tokens out of OpenClaw plugin login metadata", () => { expect(userLoginFromOpenClawConfig(createDefaultConfig({ accessToken: "matrix-token", dataDir: "/tmp/openclaw", - gatewayUrl: "ws://gateway", }))).toMatchObject({ - metadata: { - gatewayUrl: "ws://gateway", - }, + 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("loads a network API that registers OpenClaw agents as ghosts", async () => { const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); const runtime = runtimeWith({ @@ -211,7 +202,6 @@ describe("OpenClawBridgeConnector", () => { }, name: "Codex", roomType: "dm", - sender: "codex", }); expect(registry.getBindingByRoom("!codex-dm:example.com")).toMatchObject({ agentId: "codex", @@ -275,7 +265,7 @@ describe("OpenClawBridgeConnector", () => { portal: { id: "agent:codex", mxid: "!existing-codex-dm:example.com", - portalKey: { id: "agent:codex", receiver: "login" }, + portalKey: { id: "agent:codex", receiver: "openclaw:plugin" }, }, userId: "@codex:example.com", }); @@ -368,7 +358,7 @@ describe("OpenClawBridgeConnector", () => { await expect(api.listContacts({} as BridgeRequestContext, {})).resolves.toEqual({ contacts: [] }); }); - it("drops disallowed rooms, users, and bridge-owned senders before forwarding to OpenClaw", async () => { + it("drops disallowed rooms, users, and 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({ @@ -417,6 +407,43 @@ describe("OpenClawBridgeConnector", () => { expect(runtime.transport.request).not.toHaveBeenCalled(); }); + it("accepts the Beeper owner MXID as a sender in self-hosted cloud 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.send": { runId: "run_owner", sessionKey: "agent:main:main" }, + }, + }); + runtime.config.matrixUserId = "@owner:beeper-staging.com"; + runtime.config.homeserverDomain = "beeper.local"; + const api = new OpenClawNetworkAPI({ + config: runtime.config, + login: login(), + registry, + runtime, + streams: { publish: vi.fn() }, + }); + const sessionKey = "agent:main:main"; + const roomId = `!session:${Buffer.from(sessionKey).toString("base64url")}.openclaw:plugin: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.send", expect.objectContaining({ + key: sessionKey, + message: "hello from owner", + }), { expectFinal: false }); + }); + it("dispatches Matrix text and approval reactions to OpenClaw", async () => { const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); const runtime = runtimeWith({ @@ -951,7 +978,6 @@ describe("OpenClawBridgeConnector", () => { }); runtime.config.importSources = ["dashboard"]; runtime.config.backfillLimit = 5; - runtime.config.gatewayUrl = "ws://gateway"; runtime.config.allowedRoomIds = ["!room:example.com"]; runtime.config.allowedUserIds = ["@alice:example.com"]; runtime.config.beeperEnv = "staging"; @@ -1051,7 +1077,6 @@ describe("OpenClawBridgeConnector", () => { id: "session:YWdlbnQ6Y29kZXg6ZGVza3RvcA", name: "Desktop chat", roomType: "dm", - sender: "codex", })); expect(backfillPortal).toHaveBeenCalledWith(login(), expect.objectContaining({ mxid: "!imported-desktop:example.com", @@ -1083,7 +1108,6 @@ describe("OpenClawBridgeConnector", () => { }, name: "fresh", roomType: "dm", - sender: "codex", }); expect(registry.getBindingByRoom("!new-room:example.com")).toMatchObject({ agentId: "codex", @@ -1149,7 +1173,6 @@ describe("OpenClawBridgeConnector", () => { }, name: "Deep work", roomType: "dm", - sender: "codex", }); expect(registry.getBindingByRoom("!new-management-room:example.com")).toMatchObject({ agentId: "codex", @@ -1318,6 +1341,93 @@ describe("OpenClawBridgeConnector", () => { }); }); + it("rebuilds an OpenClaw room binding from a persisted Pickle session portal without metadata", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-rebuild-binding-test.json"); + const runtime = runtimeWith({ + events: [{ event: "run.completed", payload: { runId: "run_rebuilt", type: "run.completed" } }], + responses: { + "sessions.send": { runId: "run_rebuilt", sessionKey: "agent:codex:dashboard:one" }, + }, + }); + runtime.config.homeserverDomain = "example.com"; + const api = new OpenClawNetworkAPI({ + config: runtime.config, + login: login(), + registry, + runtime, + streams: { publish: vi.fn() }, + }); + const sessionKey = "agent:codex:dashboard:one"; + const portal = { + id: `session:${Buffer.from(sessionKey).toString("base64url")}`, + mxid: "!session-room:example.com", + portalKey: { id: `session:${Buffer.from(sessionKey).toString("base64url")}`, receiver: "openclaw:plugin" }, + receiver: "openclaw:plugin", + }; + + await api.handleMatrixMessage({} as BridgeRequestContext, { + event: { eventId: "$rebuilt" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "hello from persisted portal", + } as MatrixMessage); + + expect(registry.getBindingByRoom("!session-room:example.com")).toMatchObject({ + agentId: "codex", + ghostUserId: "@openclaw_agent_codex:example.com", + owner: "imported", + sessionKey, + }); + expect(runtime.transport.request).toHaveBeenCalledWith("sessions.send", expect.objectContaining({ + key: sessionKey, + message: "hello from persisted portal", + }), { expectFinal: false }); + }); + + it("rebuilds an OpenClaw room binding from a cloud appservice session room id", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-cloud-room-binding-test.json"); + const runtime = runtimeWith({ + events: [{ event: "run.completed", payload: { runId: "run_cloud", type: "run.completed" } }], + responses: { + "sessions.send": { runId: "run_cloud", sessionKey: "agent:main:dashboard:abc" }, + }, + }); + runtime.config.homeserverDomain = "beeper.local"; + const api = new OpenClawNetworkAPI({ + config: runtime.config, + login: login(), + registry, + runtime, + streams: { publish: vi.fn() }, + }); + const sessionKey = "agent:main:dashboard:abc"; + const roomId = `!session:${Buffer.from(sessionKey).toString("base64url")}.openclaw:plugin:beeper.local`; + + await api.handleMatrixMessage({ + log: vi.fn(), + } as unknown as BridgeRequestContext, { + event: { eventId: "$cloud-room" }, + portal: { + id: roomId, + mxid: roomId, + portalKey: { id: roomId }, + }, + sender: { userId: "@alice:example.com" }, + text: "hello from cloud room", + } as MatrixMessage); + + expect(registry.getBindingByRoom(roomId)).toMatchObject({ + agentId: "main", + ghostUserId: "@openclaw_agent_main:beeper.local", + owner: "imported", + sessionKey, + }); + expect(runtime.transport.request).toHaveBeenCalledWith("sessions.send", expect.objectContaining({ + key: sessionKey, + message: "hello from cloud room", + }), { expectFinal: false }); + }); + it("fetches OpenClaw chat history for Pickle backfill", async () => { const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); const runtime = runtimeWith({ @@ -1355,7 +1465,7 @@ describe("OpenClawBridgeConnector", () => { expect(response.hasMore).toBe(false); expect(response.messages).toHaveLength(2); expect(response.messages.map((message) => message.event.getID())).toEqual(["m1", "m2"]); - expect(response.messages.map((message) => message.event.getSender().sender)).toEqual(["login:human", "codex"]); + expect(response.messages.map((message) => message.event.getSender().sender)).toEqual(["@openclawbot:localhost", "@codex:example.com"]); expect(response.messages.map((message) => message.event.getTimestamp())).toEqual([ new Date("2026-05-16T11:59:00.000Z"), new Date(1_779_000_000_000), @@ -1368,7 +1478,7 @@ describe("OpenClawBridgeConnector", () => { }); function login(): UserLogin { - return { id: "login", metadata: { gatewayUrl: "ws://gateway" }, userId: "@alice:example.com" }; + return { id: "openclaw:plugin", metadata: {}, userId: "@alice:example.com" }; } function runtimeWith(options: { diff --git a/packages/openclaw/src/connector.ts b/packages/openclaw/src/connector.ts index 3011b87..81f2d81 100644 --- a/packages/openclaw/src/connector.ts +++ b/packages/openclaw/src/connector.ts @@ -1,4 +1,3 @@ -import { resolve } from "node:path"; import { createRemoteMessage, type BackfillingNetworkAPI, @@ -17,7 +16,6 @@ import { LoginCreateContext, LoginFlow, LoginProcess, - LoginStep, LoadUserLoginContext, MatrixEdit, MatrixMessage, @@ -41,18 +39,18 @@ import { backfillAllOpenClawSessions, buildBackfillImport, discoverOneToOneSessi import { parseApprovalResponseContent } from "./approval"; import { OpenClawBeeperStreamPublisher } from "./beeper-stream"; import { agentPortalSessionKey, OpenClawMatrixBridgeAgent, type OpenClawBridgeStreamPublisher } from "./bridge-agent"; -import { createDefaultConfig, DEFAULT_GATEWAY_URL } from "./config"; -import { createOpenClawHttpTransport, createOpenClawWebSocketTransport, OpenClawGatewayRuntime, type OpenClawMatrixMessageMetadata, type OpenClawTransport } from "./openclaw-runtime"; +import { createDefaultConfig } from "./config"; +import { createOpenClawHostTransport, OpenClawGatewayRuntime, type OpenClawHostRuntime, type OpenClawMatrixMessageMetadata } from "./openclaw-runtime"; import { OpenClawBridgeRegistry } from "./registry"; -import { agentContactFromOpenClawAgent, serviceBotUserId } from "./rooms"; +import { agentContactFromOpenClawAgent, agentGhostUserId, serviceBotUserId } from "./rooms"; import type { OpenClawAgentContact, OpenClawBridgeConfig, OpenClawSessionBinding, OpenClawUserContact } from "./types"; export interface OpenClawConnectorOptions { config?: OpenClawBridgeConfig; registry?: OpenClawBridgeRegistry; - runtimeFactory?: (login: UserLogin, config: OpenClawBridgeConfig) => OpenClawGatewayRuntime; + runtime?: OpenClawGatewayRuntime | OpenClawHostRuntime; + runtimeFactory?: (config: OpenClawBridgeConfig) => OpenClawGatewayRuntime; streams?: OpenClawBridgeStreamPublisher; - transportFactory?: (login: UserLogin, config: OpenClawBridgeConfig) => OpenClawTransport; } export function createOpenClawConnector(options: OpenClawConnectorOptions = {}): OpenClawBridgeConnector { @@ -62,19 +60,26 @@ export function createOpenClawConnector(options: OpenClawConnectorOptions = {}): export class OpenClawBridgeConnector implements BridgeConnector { readonly config: OpenClawBridgeConfig; readonly registry: OpenClawBridgeRegistry; - #runtimeFactory: (login: UserLogin, config: OpenClawBridgeConfig) => OpenClawGatewayRuntime; + readonly runtime: OpenClawGatewayRuntime | undefined; + #runtimeFactory: (config: OpenClawBridgeConfig) => OpenClawGatewayRuntime; #streams: OpenClawBridgeStreamPublisher | undefined; constructor(options: OpenClawConnectorOptions = {}) { this.config = options.config ?? createDefaultConfig(); this.registry = options.registry ?? new OpenClawBridgeRegistry(); this.#streams = options.streams; + const runtime = options.runtime instanceof OpenClawGatewayRuntime + ? options.runtime + : options.runtime + ? new OpenClawGatewayRuntime({ config: this.config, transport: createOpenClawHostTransport(options.runtime) }) + : undefined; + this.runtime = runtime; this.#runtimeFactory = options.runtimeFactory ?? - ((login, config) => new OpenClawGatewayRuntime({ - config, - transport: options.transportFactory?.(login, config) ?? transportFromLogin(login, config), - })); + ((config) => { + if (runtime) return runtime; + throw new Error("OpenClaw direct plugin runtime is required"); + }); } getName() { @@ -117,13 +122,7 @@ export class OpenClawBridgeConnector implements BridgeConnector { @@ -137,13 +136,18 @@ export class OpenClawBridgeConnector implements BridgeConnector { + 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 { - if (flowId !== "openclaw.gateway") throw new Error(`Unsupported OpenClaw login flow: ${flowId}`); - return new OpenClawGatewayLoginProcess(user.id, this.config); + createLogin(_ctx: LoginCreateContext, _user: BridgeUser, flowId: string): LoginProcess { + throw new Error(`Unsupported OpenClaw login flow in direct plugin mode: ${flowId}`); } loadUserLogin(_ctx: LoadUserLoginContext, login: UserLogin): NetworkAPI { @@ -151,64 +155,12 @@ export class OpenClawBridgeConnector implements BridgeConnector undefined }, }); } } -export class OpenClawGatewayLoginProcess implements LoginProcess { - readonly #defaultConfig: OpenClawBridgeConfig; - readonly #userId: string; - - constructor(userId: string, defaultConfig: OpenClawBridgeConfig) { - this.#userId = userId; - this.#defaultConfig = defaultConfig; - } - - cancel(): void {} - - async start(): Promise { - return { - instructions: "Enter your OpenClaw gateway URL.", - stepId: "openclaw.gateway.credentials", - type: "user_input", - userInput: { - fields: [ - { - defaultValue: this.#defaultConfig.gatewayUrl ?? DEFAULT_GATEWAY_URL, - description: "OpenClaw gateway URL.", - id: "gateway_url", - name: "Gateway URL", - type: "url", - }, - ], - }, - }; - } - - async submitUserInput(_ctxOrInput?: BridgeRequestContext | Record, maybeInput?: Record): Promise { - const input = maybeInput ?? (_ctxOrInput as Record | undefined) ?? {}; - const gatewayUrl = input.gateway_url || this.#defaultConfig.gatewayUrl || DEFAULT_GATEWAY_URL; - return { - complete: { - userLogin: { - id: `openclaw:${encodeLoginId(gatewayUrl)}`, - metadata: { - gatewayUrl, - }, - remoteName: "OpenClaw", - userId: this.#userId, - }, - userLoginId: `openclaw:${encodeLoginId(gatewayUrl)}`, - }, - instructions: "OpenClaw gateway configured.", - stepId: "openclaw.gateway.complete", - type: "complete", - }; - } -} - export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetworkAPI, ContactListingNetworkAPI, MessageHandlingNetworkAPI, EditHandlingNetworkAPI, ReactionHandlingNetworkAPI, ReactionRemoveHandlingNetworkAPI, RedactionHandlingNetworkAPI, BackfillingNetworkAPI { readonly #agent: OpenClawMatrixBridgeAgent; readonly #config: OpenClawBridgeConfig; @@ -276,7 +228,6 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor metadata: portal.metadata, name: contact.displayName, roomType: "dm", - sender: contact.agentId, }; const creationContent = openClawPortalCreationContent(this.#runtime.config); if (creationContent) portalOptions.creationContent = creationContent; @@ -320,8 +271,11 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor } async handleMatrixMessage(ctx: BridgeRequestContext, msg: MatrixMessage): Promise { - if (!this.isAllowedMatrixIngress(msg.portal.mxid, msg.sender.userId)) return { pending: false }; - const binding = bindingFromPortal(msg.portal); + if (!this.isAllowedMatrixIngress(msg.portal.mxid, msg.sender.userId)) { + this.logRejectedMatrixIngress(ctx, "message", msg.portal.mxid, msg.sender.userId); + return { pending: false }; + } + const binding = bindingFromPortal(msg.portal, this.#runtime.config); if (binding && !this.#registry.getBindingByRoom(msg.portal.mxid ?? "")) this.#registry.upsertBinding(binding); const currentBinding = msg.portal.mxid ? this.#registry.getBindingByRoom(msg.portal.mxid) ?? binding : binding; const approval = parseApprovalResponseContent(msg.content); @@ -341,7 +295,14 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor return { pending: false }; } if (parsed.command) { - return await this.handleSlashCommand(ctx, parsed.command, binding, msg); + return await this.handleSlashCommand(ctx, parsed.command, currentBinding, msg); + } + if (!currentBinding) { + ctx.log?.("warn", "openclaw_matrix_message_unbound_room", { + portalId: msg.portal.id, + portalKey: msg.portal.portalKey, + roomId: msg.portal.mxid, + }); } await this.#agent.handleMatrixText({ ...(parsed.attachments.length > 0 ? { attachments: parsed.attachments } : {}), @@ -463,7 +424,7 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor } async fetchMessages(_ctx: BridgeRequestContext, params: FetchMessagesParams): Promise { - const binding = bindingFromPortal(params.portal); + const binding = bindingFromPortal(params.portal, this.#runtime.config); if (!this.isAllowedRoom(binding?.roomId ?? params.portal.mxid)) return { hasMore: false, messages: [] }; if (!binding) return { hasMore: false, messages: [] }; const importOptions: { limit?: number; roomId: string } = { roomId: binding.roomId }; @@ -496,8 +457,8 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor id: message.id, portalKey: params.portal.portalKey, sender: { - isFromMe: false, - sender: message.sender === "agent" ? binding.agentId : binding.humanGhostUserId ?? `${this.#login.id}:human`, + isFromMe: message.sender === "agent", + sender: backfillSenderUserId(this.#runtime.config, binding, message.sender), }, timestamp: message.timestamp ?? new Date(0), }), @@ -554,7 +515,6 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor }, name: request.label, roomType: "dm", - sender: request.agentId, }; const creationContent = openClawPortalCreationContent(this.#runtime.config); if (creationContent) portalOptions.creationContent = creationContent; @@ -637,14 +597,24 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor } isBridgeOwnedSender(sender: string): boolean { - return sender === this.#config.matrixUserId - || sender === serviceBotUserId(this.#config) + return sender === serviceBotUserId(this.#config) || this.#registry.data.agents.some((contact) => contact.ghostUserId === sender) || this.#registry.data.users.some((contact) => contact.ghostUserId === sender); } + logRejectedMatrixIngress(ctx: BridgeRequestContext, kind: string, roomId: string | undefined, sender: string | undefined): void { + ctx.log?.("warn", "openclaw_matrix_ingress_rejected", { + allowedRoomCount: this.#config.allowedRoomIds?.length ?? 0, + allowedUserCount: this.#config.allowedUserIds?.length ?? 0, + bridgeOwned: sender ? this.isBridgeOwnedSender(sender) : false, + kind, + roomId, + sender, + }); + } + private upsertPortalBinding(portal: Portal): void { - const binding = bindingFromPortal(portal); + const binding = bindingFromPortal(portal, this.#runtime.config); if (binding && !this.#registry.getBindingByRoom(portal.mxid ?? "")) this.#registry.upsertBinding(binding); } @@ -691,7 +661,7 @@ function commandNotice(ctx: BridgeRequestContext, login: UserLogin, msg: MatrixM function bridgeStatusText(config: OpenClawBridgeConfig, boundRooms: number): string { return [ "OpenClaw Beeper bridge", - `Gateway: ${config.gatewayUrl ?? "not configured"}`, + "Runtime: OpenClaw plugin", `Import sources: ${(config.importSources ?? []).join(", ") || "none"}`, `Approvals: ${describeApprovalBehavior(config.approvalBehavior)}`, `Stream finalization: ${config.streamFinalization ?? "replace"}`, @@ -706,7 +676,7 @@ function bridgeSettingsText(config: OpenClawBridgeConfig, boundRooms: number): s `Beeper environment: ${config.beeperEnv ?? "production"}`, `Homeserver: ${config.homeserver ?? "not configured"}`, `Registration URL: ${config.registrationUrl ?? "not configured"}`, - `Gateway: ${config.gatewayUrl ?? "not configured"}`, + "Runtime: OpenClaw plugin", `Bridge manager token: ${config.bridgeManagerToken ? "configured" : "not configured"}`, `Post bridge state: ${config.bridgeManagerPostState === undefined ? "default" : config.bridgeManagerPostState ? "enabled" : "disabled"}`, `Import sources: ${(config.importSources ?? []).join(", ") || "none"}`, @@ -875,13 +845,14 @@ function userContactResponse(contact: OpenClawUserContact): ResolveIdentifierRes }; } -function bindingFromPortal(portal: Portal): OpenClawSessionBinding | undefined { +function bindingFromPortal(portal: Portal, config: OpenClawBridgeConfig): OpenClawSessionBinding | undefined { const metadata = recordValue(portal.metadata)?.openclaw; const openclaw = recordValue(metadata); const roomId = portal.mxid; - const agentId = stringValue(openclaw?.agentId) ?? portal.id.replace(/^agent:/, ""); - const sessionKey = stringValue(openclaw?.sessionKey) ?? portal.id; - const ghostUserId = stringValue(openclaw?.ghostUserId); + const portalId = openClawPortalId(portal); + const sessionKey = stringValue(openclaw?.sessionKey) ?? sessionKeyFromPortalId(portalId); + const agentId = stringValue(openclaw?.agentId) ?? agentIdFromSessionKey(sessionKey) ?? agentIdFromPortalId(portalId); + const ghostUserId = stringValue(openclaw?.ghostUserId) ?? (agentId ? agentGhostUserId(config, agentId) : undefined); if (!roomId || !agentId || !sessionKey || !ghostUserId) return undefined; const now = Date.now(); return { @@ -890,49 +861,78 @@ function bindingFromPortal(portal: Portal): OpenClawSessionBinding | undefined { ghostUserId, id: Buffer.from(roomId).toString("base64url"), kind: "session", - owner: "bridge", + owner: portalId.startsWith("session:") ? "imported" : "bridge", roomId, sessionKey, updatedAt: now, }; } -function transportFromLogin(login: UserLogin, config: OpenClawBridgeConfig): OpenClawTransport { - const metadata = recordValue(login.metadata); - const gatewayUrl = stringValue(metadata?.gatewayUrl) ?? config.gatewayUrl; - if (!gatewayUrl) throw new Error("OpenClaw gateway URL is not configured"); - const options: Parameters[0] = { url: gatewayUrl }; - if (gatewayUrl.startsWith("ws://") || gatewayUrl.startsWith("wss://")) { - return createOpenClawWebSocketTransport({ - ...options, - deviceIdentityPath: resolve(config.dataDir, "gateway-device.json"), - }); +function openClawPortalId(portal: Portal): string { + return openClawPortalIdFromString(portal.id) + ?? openClawPortalIdFromString(portal.portalKey.id) + ?? openClawPortalIdFromRoomId(portal.mxid) + ?? portal.id; +} + +function openClawPortalIdFromString(value: string | undefined): string | undefined { + if (!value) return undefined; + return value.startsWith("session:") || value.startsWith("agent:") ? value : undefined; +} + +function openClawPortalIdFromRoomId(roomId: string | undefined): string | undefined { + if (!roomId?.startsWith("!")) return undefined; + const serverSeparator = roomId.lastIndexOf(":"); + if (serverSeparator <= 1) return undefined; + const localpart = roomId.slice(1, serverSeparator); + const receiverSeparator = localpart.lastIndexOf("."); + const portalId = receiverSeparator >= 0 ? localpart.slice(0, receiverSeparator) : localpart; + return openClawPortalIdFromString(portalId); +} + +function sessionKeyFromPortalId(portalId: string): string | undefined { + if (portalId.startsWith("session:")) { + try { + return Buffer.from(portalId.slice("session:".length), "base64url").toString("utf8") || undefined; + } catch { + return undefined; + } } - return createOpenClawHttpTransport(options); + if (portalId.startsWith("agent:")) return portalId; + return undefined; +} + +function agentIdFromPortalId(portalId: string): string | undefined { + return portalId.startsWith("agent:") ? portalId.slice("agent:".length) || undefined : undefined; +} + +function agentIdFromSessionKey(sessionKey: string | undefined): string | undefined { + if (!sessionKey?.startsWith("agent:")) return undefined; + const [, agentId] = sessionKey.split(":"); + return agentId || undefined; +} + +function backfillSenderUserId( + config: OpenClawBridgeConfig, + binding: OpenClawSessionBinding, + sender: "agent" | "human" | "system" +): string { + if (sender === "agent") return binding.ghostUserId; + if (sender === "human") return binding.humanGhostUserId ?? serviceBotUserId(config); + return serviceBotUserId(config); } export function userLoginFromOpenClawConfig(config: OpenClawBridgeConfig): UserLogin { - const gatewayUrl = config.gatewayUrl; - if (!gatewayUrl) throw new Error("OpenClaw gateway URL is not configured"); return { - id: `openclaw:${encodeLoginId(gatewayUrl)}`, - metadata: { - gatewayUrl, - }, + id: "openclaw:plugin", + metadata: {}, remoteName: "OpenClaw", userId: config.matrixUserId ?? config.serviceBotLocalpart, }; } -export function createOpenClawRuntimeFromLogin(login: UserLogin, config: OpenClawBridgeConfig): OpenClawGatewayRuntime { - return new OpenClawGatewayRuntime({ - config, - transport: transportFromLogin(login, config), - }); -} - -function encodeLoginId(value: string): string { - return Buffer.from(value).toString("base64url").slice(0, 32); +export function createOpenClawRuntimeFromHost(runtime: OpenClawHostRuntime, config: OpenClawBridgeConfig): OpenClawGatewayRuntime { + return new OpenClawGatewayRuntime({ config, transport: createOpenClawHostTransport(runtime) }); } function recordValue(value: unknown): Record | undefined { 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 index 2d37cce..8922342 100644 --- a/packages/openclaw/src/integration.test.ts +++ b/packages/openclaw/src/integration.test.ts @@ -14,7 +14,6 @@ describe("OpenClaw bridge integration", () => { const dir = await mkdtemp(resolve(tmpdir(), "pickle-openclaw-integration-")); const config = createDefaultConfig({ dataDir: dir, - gatewayUrl: "ws://gateway", homeserver: "https://matrix.example", matrixUserId: "@openclawbot:example", }); @@ -95,7 +94,6 @@ describe("OpenClaw bridge integration", () => { const dir = await mkdtemp(resolve(tmpdir(), "pickle-openclaw-approval-integration-")); const config = createDefaultConfig({ dataDir: dir, - gatewayUrl: "ws://gateway", homeserver: "https://matrix.example", matrixUserId: "@openclawbot:example", }); @@ -154,7 +152,6 @@ describe("OpenClaw bridge integration", () => { const dir = await mkdtemp(resolve(tmpdir(), "pickle-openclaw-relations-integration-")); const config = createDefaultConfig({ dataDir: dir, - gatewayUrl: "ws://gateway", homeserver: "https://matrix.example", matrixUserId: "@openclawbot:example", }); @@ -242,7 +239,6 @@ describe("OpenClaw bridge integration", () => { const config = createDefaultConfig({ accessToken: "mx-token", dataDir: dir, - gatewayUrl: "ws://gateway", homeserver: "https://matrix.example", importSources: ["dashboard"], matrixDeviceId: "DEVICE", @@ -304,7 +300,6 @@ describe("OpenClaw bridge integration", () => { name: "Codex", portalKey: { id: "agent:codex", receiver: login.id }, roomType: "dm", - userId: "@codex:example", })); await expect(bridge.dispatchMatrixEvent(messageEvent({ diff --git a/packages/openclaw/src/openclaw-extension.test.ts b/packages/openclaw/src/openclaw-extension.test.ts index 8174dc7..03a6cda 100644 --- a/packages/openclaw/src/openclaw-extension.test.ts +++ b/packages/openclaw/src/openclaw-extension.test.ts @@ -91,6 +91,7 @@ describe("OpenClaw plugin package metadata", () => { 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.channelEnvVars?.beeper).toContain("PICKLE_OPENCLAW_DEVICE_ID"); expect(manifest.channelEnvVars?.beeper).not.toContain("PICKLE_OPENCLAW_GATEWAY_ACCESS_TOKEN"); expect(manifest.channelEnvVars?.beeper).not.toContain("OPENCLAW_GATEWAY_TOKEN"); expect(manifest.uiHints).toMatchObject({ @@ -109,12 +110,12 @@ describe("OpenClaw plugin package metadata", () => { "backfillLimit", "baseDomain", "beeperEnv", + "bridgeId", "bridgeManagerPostState", "bridgeManagerToken", "contactVisibility", "dataDir", "enabled", - "gatewayUrl", "ghostLocalpartPrefix", "homeserver", "homeserverDomain", @@ -138,7 +139,6 @@ describe("OpenClaw plugin package metadata", () => { schema: { properties: expect.objectContaining({ accessToken: expect.any(Object), - gatewayUrl: expect.any(Object), importSources: expect.any(Object), }), }, @@ -152,6 +152,7 @@ describe("OpenClaw plugin package metadata", () => { const packageJson = JSON.parse(await readFile(resolve("package.json"), "utf8")) as { bin?: Record; dependencies?: Record; + devDependencies?: Record; files?: string[]; main?: string; openclaw?: { @@ -161,6 +162,7 @@ describe("OpenClaw plugin package metadata", () => { }; 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([ @@ -172,13 +174,14 @@ describe("OpenClaw plugin package metadata", () => { 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.arrayContaining([ + expect(dependencies).toEqual([]); + expect(devDependencies).toEqual(expect.arrayContaining([ ["@beeper/pickle", "workspace:^"], ["@beeper/pickle-ag-ui", "workspace:^"], ["@beeper/pickle-bridge", "workspace:^"], ["@beeper/pickle-state-file", "workspace:^"], ])); - expect(dependencies.find(([, version]) => version === "workspace:*")).toBeUndefined(); + expect(devDependencies.find(([, version]) => version === "workspace:*")).toBeUndefined(); }); }); diff --git a/packages/openclaw/src/openclaw-extension.ts b/packages/openclaw/src/openclaw-extension.ts index 908c21e..ea3cacc 100644 --- a/packages/openclaw/src/openclaw-extension.ts +++ b/packages/openclaw/src/openclaw-extension.ts @@ -1,6 +1,7 @@ import { beeperChannelPlugin } from "./setup"; export interface OpenClawPluginApi { + runtime?: unknown; registerChannel?: (registration: { plugin: unknown }) => void; channels?: { register?: (plugin: unknown) => void; @@ -15,9 +16,25 @@ export const openClawBeeperPlugin = { plugin: beeperChannelPlugin, loadChannelPlugin: () => beeperChannelPlugin, register(api: OpenClawPluginApi): void { - api.registerChannel?.({ plugin: beeperChannelPlugin }); - api.channels?.register?.(beeperChannelPlugin); + const plugin = beeperChannelPluginForRuntime(api.runtime); + api.registerChannel?.({ plugin }); + api.channels?.register?.(plugin); }, } as const; export default openClawBeeperPlugin; + +function beeperChannelPluginForRuntime(runtime: unknown): typeof beeperChannelPlugin { + if (!runtime || typeof runtime !== "object") return beeperChannelPlugin; + return { + ...beeperChannelPlugin, + gateway: { + ...beeperChannelPlugin.gateway, + startAccount: (ctx: Parameters[0]) => + beeperChannelPlugin.gateway.startAccount({ + ...ctx, + hostRuntime: runtime, + } as Parameters[0]), + }, + }; +} 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 index b316a9b..720e348 100644 --- a/packages/openclaw/src/openclaw-runtime.test.ts +++ b/packages/openclaw/src/openclaw-runtime.test.ts @@ -1,9 +1,11 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { createDefaultConfig } from "./config"; import { + createOpenClawHostTransport, OpenClawGatewayRuntime, - createOpenClawHttpTransport, - createOpenClawWebSocketTransport, type OpenClawGatewayEvent, type OpenClawTransport, } from "./openclaw-runtime"; @@ -121,254 +123,220 @@ describe("OpenClawGatewayRuntime", () => { }); }); - it("sends OpenClaw requests over the HTTP gateway transport", async () => { - const requests: Array<{ body: unknown; headers: Headers; url: string }> = []; - const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - requests.push({ - body: JSON.parse(String(init?.body)), - headers: new Headers(init?.headers), - url: String(input), - }); - return new Response(JSON.stringify({ result: { runId: "run_1" } }), { status: 200 }); - }); - const transport = createOpenClawHttpTransport({ - fetch: fetchImpl, - url: "ws://127.0.0.1:18789/openclaw", - }); - - await expect(transport.request("sessions.send", { key: "session", message: "hi" }, { expectFinal: false })).resolves.toEqual({ - runId: "run_1", - }); - expect(requests).toEqual([ - { - body: { - expectFinal: false, - method: "sessions.send", - params: { key: "session", message: "hi" }, - }, - headers: expect.any(Headers), - url: "http://127.0.0.1:18789/openclaw/rpc", + it("adapts the in-process OpenClaw plugin runtime request and event surface", 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; + } }, - ]); - expect(requests[0]?.headers.get("authorization")).toBeNull(); - }); + request: vi.fn(async (method: string) => ({ method, runId: "run_1" })), + }; + const transport = createOpenClawHostTransport(host); - it("streams OpenClaw gateway events from SSE frames", async () => { - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode([ - "event: assistant.delta", - "data: {\"payload\":{\"runId\":\"skip\",\"delta\":\"no\"}}", - "", - "event: assistant.delta", - "data: {\"payload\":{\"runId\":\"run_1\",\"delta\":\"yes\"},\"seq\":2}", - "", - "", - ].join("\n"))); - controller.close(); - }, - }); - const transport = createOpenClawHttpTransport({ - fetch: vi.fn(async () => new Response(stream, { status: 200 })), - url: "http://gateway", + await expect(transport.request("sessions.send", { key: "session", message: "hi" })).resolves.toEqual({ + method: "sessions.send", + runId: "run_1", }); + expect(host.request).toHaveBeenCalledWith("sessions.send", { key: "session", message: "hi" }, undefined); - const events: OpenClawGatewayEvent[] = []; + const received: OpenClawGatewayEvent[] = []; for await (const event of transport.events((candidate) => { const payload = candidate.payload as { runId?: string }; return payload.runId === "run_1"; })) { - events.push(event); + received.push(event); } - - expect(events).toEqual([ - { - event: "assistant.delta", - payload: { runId: "run_1", delta: "yes" }, - seq: 2, - }, - ]); + expect(received).toEqual([{ event: "session.message", payload: { runId: "run_1" }, seq: 3 }]); }); - it("uses OpenClaw gateway WebSocket req/res framing and broadcast events", async () => { - FakeWebSocket.instances = []; - const transport = createOpenClawWebSocketTransport({ - WebSocket: FakeWebSocket as unknown as typeof WebSocket, - url: "ws://gateway", - }); - - const request = transport.request("sessions.send", { key: "session", message: "hi" }); - await waitFor(() => FakeWebSocket.instances.length === 1); - const socket = FakeWebSocket.instances[0]; - await sendConnectChallenge(socket); - await waitFor(() => socket?.sent.length === 1); - expect(JSON.parse(socket?.sent[0] ?? "{}")).toMatchObject({ - method: "connect", - params: { - client: { - displayName: "pickle-openclaw", - id: "gateway-client", - mode: "backend", - platform: process.platform, - version: "0.1.0", + it("adapts OpenClaw plugin runtime helpers when no gateway request surface exists", async () => { + const transport = createOpenClawHostTransport({ + 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, + }, + }, + ], }, - device: { - nonce: "nonce-1", - }, - role: "operator", - scopes: ["operator.read", "operator.write", "operator.approvals"], }, - type: "req", - }); - socket?.receive({ id: JSON.parse(socket.sent[0] ?? "{}").id, ok: true, payload: { ok: true }, type: "res" }); - await waitFor(() => socket?.sent.length === 2); - const sent = JSON.parse(socket?.sent[1] ?? "{}"); - expect(sent).toMatchObject({ - method: "sessions.send", - params: { key: "session", message: "hi" }, - type: "req", + config: { + current: () => ({ + agents: { + list: [{ id: "main", name: "Main Agent" }], + }, + }), + }, }); - socket?.receive({ id: sent.id, ok: true, payload: { runId: "run_1" }, type: "res" }); - await expect(request).resolves.toEqual({ runId: "run_1" }); - const events: OpenClawGatewayEvent[] = []; - const iterator = transport.events((event) => { - const payload = event.payload as { runId?: string }; - return payload.runId === "run_1"; + 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: [], }); - const next = iterator[Symbol.asyncIterator]().next(); - await new Promise((resolve) => setTimeout(resolve, 0)); - socket?.receive({ event: "session.message", payload: { runId: "skip" }, type: "event" }); - socket?.receive({ event: "session.message", payload: { runId: "run_1" }, seq: 3, type: "event" }); - events.push((await next).value); - expect(events).toEqual([{ event: "session.message", payload: { runId: "run_1" }, seq: 3 }]); - transport.close(); }); - it("accepts gateway WebSocket events with top-level run metadata", async () => { - FakeWebSocket.instances = []; - const transport = createOpenClawWebSocketTransport({ - WebSocket: FakeWebSocket as unknown as typeof WebSocket, - url: "ws://gateway", + it("runs Beeper-originated sends through the native OpenClaw plugin agent runtime", async () => { + const runEmbeddedAgent = vi.fn(async (params: Record) => { + const onAgentEvent = params.onAgentEvent as ((event: { data: Record; stream: string }) => void) | undefined; + const onPartialReply = params.onPartialReply as ((payload: { text: string }) => void) | undefined; + onAgentEvent?.({ data: { delta: "hello", runId: params.runId as string }, stream: "assistant.delta" }); + onPartialReply?.({ text: "hello from callback" }); + return { payloads: [{ text: "hello from final payload" }] }; + }); + const transport = createOpenClawHostTransport({ + agent: { + ensureAgentWorkspace: () => "/tmp/workspace", + resolveAgentDir: () => "/tmp/agent", + resolveAgentTimeoutMs: () => 1000, + runEmbeddedAgent, + session: { + getSessionEntry: () => ({ + sessionFile: "/tmp/session.jsonl", + sessionId: "session-1", + }), + }, + }, + config: { current: () => ({ agents: { list: [{ id: "main" }] } }) }, }); - const iterator = transport.events((event) => { - const payload = event.payload as { runId?: string }; - return payload.runId === "run_top"; + 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.request("sessions.send", { + key: "agent:main:beeper:room", + message: "from Beeper", + idempotencyKey: "$event", }); - const next = iterator[Symbol.asyncIterator]().next(); - await waitFor(() => FakeWebSocket.instances.length === 1); - const socket = FakeWebSocket.instances[0]!; - await sendConnectChallenge(socket); - await waitFor(() => socket.sent.length === 1); - socket?.receive({ id: JSON.parse(socket.sent[0] ?? "{}").id, ok: true, payload: { ok: true }, type: "res" }); - await new Promise((resolve) => setTimeout(resolve, 0)); - socket?.receive({ event: "session.message", runId: "run_skip", type: "event" }); - socket?.receive({ deltaText: "hi", event: "session.message", runId: "run_top", seq: 4, type: "event" }); + observedRunId = (sent as { runId?: string }).runId; + await done; - await expect(next).resolves.toEqual({ - done: false, - value: { - event: "session.message", - payload: { deltaText: "hi", event: "session.message", runId: "run_top", seq: 4, type: "event" }, - seq: 4, - }, + expect(sent).toMatchObject({ + sessionFile: "/tmp/session.jsonl", + sessionId: "session-1", + sessionKey: "agent:main:beeper:room", }); - transport.close(); + expect(runEmbeddedAgent).toHaveBeenCalledWith(expect.objectContaining({ + agentDir: "/tmp/agent", + agentId: "main", + currentMessageId: "$event", + messageChannel: "beeper", + messageProvider: "beeper", + prompt: "from Beeper", + sessionFile: "/tmp/session.jsonl", + sessionId: "session-1", + sessionKey: "agent:main:beeper:room", + timeoutMs: 1000, + trigger: "user", + workspaceDir: "/tmp/workspace", + })); + expect(received).toEqual(expect.arrayContaining([ + expect.objectContaining({ + event: "assistant.delta", + payload: expect.objectContaining({ delta: "hello from callback" }), + }), + expect.objectContaining({ + event: "assistant.delta", + payload: expect.objectContaining({ delta: "hello from final payload" }), + }), + expect.objectContaining({ event: "run.completed" }), + ])); }); - it("replays early WebSocket run events to late subscribers", async () => { - FakeWebSocket.instances = []; - const transport = createOpenClawWebSocketTransport({ - replayLimit: 10, - WebSocket: FakeWebSocket as unknown as typeof WebSocket, - url: "ws://gateway", + 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 = createOpenClawHostTransport({ + agent: { + session: { + getSessionEntry: () => ({ + sessionFile, + sessionId: "session-1", + }), + }, + }, }); - const request = transport.request("sessions.send", { key: "session", message: "hi" }); - await waitFor(() => FakeWebSocket.instances.length === 1); - const socket = FakeWebSocket.instances[0]!; - await sendConnectChallenge(socket); - await waitFor(() => socket.sent.length === 1); - socket.receive({ id: JSON.parse(socket.sent[0] ?? "{}").id, ok: true, payload: { ok: true }, type: "res" }); - await waitFor(() => socket.sent.length === 2); - const sent = JSON.parse(socket.sent[1] ?? "{}"); - socket.receive({ event: "session.message", payload: { deltaText: "early", runId: "run_early" }, seq: 5, type: "event" }); - socket.receive({ id: sent.id, ok: true, payload: { runId: "run_early" }, type: "res" }); - await expect(request).resolves.toEqual({ runId: "run_early" }); - - const iterator = transport.events((event) => { - const payload = event.payload as { runId?: string }; - return payload.runId === "run_early"; - })[Symbol.asyncIterator](); - await expect(iterator.next()).resolves.toEqual({ - done: false, - value: { - event: "session.message", - payload: { deltaText: "early", runId: "run_early" }, - seq: 5, - }, + 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 }, + ], }); - await iterator.return?.(); - transport.close(); }); -}); -class FakeWebSocket { - static instances: FakeWebSocket[] = []; - readonly sent: string[] = []; - readyState = 0; - #listeners = new Map void>>(); - - constructor(readonly url: string) { - FakeWebSocket.instances.push(this); - queueMicrotask(() => { - this.readyState = 1; - this.#emit("open", {}); + it("adapts plugin transcript lifecycle updates into runtime events", async () => { + let listener: ((update: { sessionKey?: string; messageSeq?: number }) => void) | undefined; + const transport = createOpenClawHostTransport({ + events: { + onSessionTranscriptUpdate: (next) => { + listener = next; + return () => { + listener = undefined; + }; + }, + }, }); - } - - addEventListener(type: string, listener: (event: { data?: string }) => void): void { - const listeners = this.#listeners.get(type) ?? new Set(); - listeners.add(listener); - this.#listeners.set(type, listeners); - } - - removeEventListener(type: string, listener: (event: { data?: string }) => void): void { - this.#listeners.get(type)?.delete(listener); - } - - send(data: string): void { - this.sent.push(data); - } - - close(): void { - this.readyState = 3; - this.#emit("close", {}); - } - - receive(frame: unknown): void { - this.#emit("message", { data: JSON.stringify(frame) }); - } - - #emit(type: string, event: { data?: string }): void { - for (const listener of this.#listeners.get(type) ?? []) listener(event); - } -} -async function waitFor(predicate: () => boolean): Promise { - for (let index = 0; index < 20; index += 1) { - if (predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 0)); - } - throw new Error("Timed out waiting for condition"); -} - -async function sendConnectChallenge(socket: FakeWebSocket | undefined): Promise { - await waitFor(() => socket?.readyState === 1); - await new Promise((resolve) => setTimeout(resolve, 0)); - socket?.receive({ event: "connect.challenge", payload: { nonce: "nonce-1" }, type: "event" }); -} + 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[] = []): OpenClawTransport & { request: ReturnType; diff --git a/packages/openclaw/src/openclaw-runtime.ts b/packages/openclaw/src/openclaw-runtime.ts index 93d3fc2..3aa5bd0 100644 --- a/packages/openclaw/src/openclaw-runtime.ts +++ b/packages/openclaw/src/openclaw-runtime.ts @@ -1,6 +1,6 @@ -import { generateKeyPairSync, createHash, createPrivateKey, createPublicKey, sign } from "node:crypto"; -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname } from "node:path"; +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"; @@ -23,41 +23,49 @@ export interface OpenClawTransport { request(method: string, params?: unknown, options?: GatewayRequestOptions): Promise; } -export interface OpenClawHttpTransportOptions { - eventsPath?: string; - fetch?: typeof fetch; - requestPath?: string; - url: string; -} - -export interface OpenClawWebSocketTransportOptions { - clientId?: string; - deviceIdentityPath?: string; - deviceToken?: string; - clientVersion?: string; - replayLimit?: number; - requestTimeoutMs?: number; - url: string; - WebSocket?: typeof WebSocket; +export interface OpenClawHostRuntime { + agent?: { + ensureAgentWorkspace?: (config: unknown, agentId?: string) => Promise | string; + resolveAgentDir?: (config: unknown, agentId?: string) => string; + resolveAgentTimeoutMs?: (options: Record) => number; + resolveAgentWorkspaceDir?: (config: unknown, agentId?: string) => string; + runEmbeddedAgent?: (params: Record) => Promise; + runEmbeddedPiAgent?: (params: Record) => Promise; + 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; + }; + }; + 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; } -const DEFAULT_GATEWAY_CLIENT_ID = "gateway-client"; -const DEFAULT_GATEWAY_CLIENT_MODE = "backend"; -const DEFAULT_GATEWAY_ROLE = "operator"; -const DEFAULT_GATEWAY_SCOPES = ["operator.read", "operator.write", "operator.approvals"]; -const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); +export type OpenClawHostEvents = + | ((filter?: (event: OpenClawGatewayEvent) => boolean) => AsyncIterable) + | { + onAgentEvent?: (listener: (event: OpenClawAgentRuntimeEvent) => void) => () => void; + onSessionTranscriptUpdate?: (listener: (update: OpenClawSessionTranscriptUpdate) => void) => () => void; + }; -type GatewayDeviceIdentity = { - deviceId: string; - privateKeyPem: string; - publicKeyPem: string; +export type OpenClawAgentRuntimeEvent = { + data?: Record; + sessionKey?: string; + stream?: string; }; -type StoredGatewayDeviceIdentity = GatewayDeviceIdentity & { - createdAtMs: number; - deviceToken?: string; - tokenScopes?: string[]; - version: 1; +export type OpenClawSessionTranscriptUpdate = { + sessionFile?: string; + sessionKey?: string; + message?: unknown; + messageId?: string; + messageSeq?: number; }; export interface OpenClawSessionCreateOptions { @@ -145,6 +153,7 @@ export interface OpenClawSessionRef { key: string; label?: string; raw?: unknown; + sessionFile?: string; sessionId?: string; } @@ -317,6 +326,7 @@ export class OpenClawGatewayRuntime { 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, })]; @@ -402,329 +412,62 @@ export class OpenClawGatewayRuntime { } } -export class OpenClawHttpTransport implements OpenClawTransport { - readonly #baseUrl: URL; - readonly #eventsPath: string; - readonly #fetch: typeof fetch; - readonly #requestPath: string; - #abortController = new AbortController(); - - constructor(options: OpenClawHttpTransportOptions) { - this.#baseUrl = normalizeGatewayUrl(options.url); - this.#eventsPath = options.eventsPath ?? "/events"; - this.#fetch = options.fetch ?? fetch; - this.#requestPath = options.requestPath ?? "/rpc"; - } - - async request(method: string, params?: unknown, options: GatewayRequestOptions = {}): Promise { - const abort = new AbortController(); - const timeout = options.timeoutMs == null ? undefined : setTimeout(() => abort.abort(), options.timeoutMs); - try { - const response = await this.#fetch(endpointUrl(this.#baseUrl, this.#requestPath), { - body: JSON.stringify(stripUndefined({ - expectFinal: options.expectFinal, - method, - params: params ?? {}, - })), - headers: { - ...this.#headers("application/json"), - "content-type": "application/json", - }, - method: "POST", - signal: abort.signal, - }); - const raw = await readGatewayResponse(response); - const record = recordValue(raw); - if (record?.error !== undefined) throw new Error(`OpenClaw gateway ${method} failed: ${errorMessage(record.error)}`); - return (record && "result" in record ? record.result : raw) as T; - } finally { - if (timeout !== undefined) clearTimeout(timeout); - } - } - - async *events(filter?: (event: OpenClawGatewayEvent) => boolean): AsyncIterable { - const response = await this.#fetch(endpointUrl(this.#baseUrl, this.#eventsPath), { - headers: this.#headers("text/event-stream"), - method: "GET", - signal: this.#abortController.signal, - }); - if (!response.ok) throw new Error(`OpenClaw gateway events failed (${response.status}): ${await response.text()}`); - const stream = response.body; - if (!stream) return; - for await (const event of parseEventStream(stream)) { - if (!filter || filter(event)) yield event; - } - } - - close(): void { - this.#abortController.abort(); - this.#abortController = new AbortController(); - } - - #headers(accept: string): Record { - return stripUndefined({ - accept, - }); - } -} - -export function createOpenClawHttpTransport(options: OpenClawHttpTransportOptions): OpenClawHttpTransport { - return new OpenClawHttpTransport(options); -} - -export class OpenClawWebSocketTransport implements OpenClawTransport { - readonly #options: OpenClawWebSocketTransportOptions; - readonly #pending = new Map; - }>(); - readonly #subscribers = new Set<{ - events: OpenClawGatewayEvent[]; - filter: ((event: OpenClawGatewayEvent) => boolean) | undefined; - notify: (() => void) | undefined; - closed: boolean; - }>(); - readonly #replay: OpenClawGatewayEvent[] = []; - #connectPromise: Promise | undefined; - #socket: WebSocket | undefined; - - constructor(options: OpenClawWebSocketTransportOptions) { - this.#options = options; - } - - async request(method: string, params?: unknown, options: GatewayRequestOptions = {}): Promise { - await this.#connect(); - return await this.#sendRequest(method, params, options) as T; - } - - #sendRequest(method: string, params?: unknown, options: GatewayRequestOptions = {}): Promise { - const socket = this.#socket; - if (!socket) throw new Error("OpenClaw gateway socket is not connected"); - const id = `req_${Date.now()}_${Math.random().toString(36).slice(2)}`; - const timeoutMs = options.timeoutMs ?? this.#options.requestTimeoutMs ?? 30_000; - const response = new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - this.#pending.delete(id); - reject(new Error(`OpenClaw gateway request timed out: ${method}`)); - }, timeoutMs); - this.#pending.set(id, { reject, resolve, timeout }); - }); - socket.send(JSON.stringify({ - id, - method, - params: params ?? {}, - type: "req", - })); - return response; - } - - async *events(filter?: (event: OpenClawGatewayEvent) => boolean): AsyncIterable { - await this.#connect(); - const subscriber = { - closed: false, - events: this.#replay.filter((event) => !filter || filter(event)), - filter, - notify: undefined as (() => void) | undefined, - }; - this.#subscribers.add(subscriber); - try { - for (;;) { - const event = subscriber.events.shift(); - if (event) { - yield event; - continue; - } - if (subscriber.closed) return; - await new Promise((resolve) => { - subscriber.notify = resolve; - }); - } - } finally { - subscriber.closed = true; - this.#subscribers.delete(subscriber); - } - } - - close(): void { - const socket = this.#socket; - this.#socket = undefined; - this.#connectPromise = undefined; - socket?.close(); - for (const pending of this.#pending.values()) { - clearTimeout(pending.timeout); - pending.reject(new Error("OpenClaw gateway socket closed")); - } - this.#pending.clear(); - for (const subscriber of this.#subscribers) { - subscriber.closed = true; - subscriber.notify?.(); - } - } - - async #connect(): Promise { - if (this.#socket?.readyState === 1) return; - this.#connectPromise ??= this.#open(); - await this.#connectPromise; - } - - async #open(): Promise { - const WebSocketCtor = this.#options.WebSocket ?? globalThis.WebSocket; - if (!WebSocketCtor) throw new Error("OpenClaw WebSocket transport requires WebSocket"); - const socket = new WebSocketCtor(this.#options.url); - this.#socket = socket; - await new Promise((resolve, reject) => { - const cleanup = () => { - socket.removeEventListener("open", onOpen); - socket.removeEventListener("error", onError); - }; - const onOpen = () => { - cleanup(); - resolve(); - }; - const onError = () => { - cleanup(); - reject(new Error("OpenClaw gateway socket failed to open")); - }; - socket.addEventListener("open", onOpen); - socket.addEventListener("error", onError); - }); - socket.addEventListener("message", (event) => { - this.#handleFrame(String(event.data)); - }); - socket.addEventListener("close", () => { - this.close(); - }); - const challenge = await this.#waitForConnectChallenge(socket); - const identityState = this.#loadDeviceIdentityState(); - const clientId = this.#options.clientId ?? DEFAULT_GATEWAY_CLIENT_ID; - const clientMode = DEFAULT_GATEWAY_CLIENT_MODE; - const role = DEFAULT_GATEWAY_ROLE; - const scopes = [...DEFAULT_GATEWAY_SCOPES]; - const platform = process.platform; - const deviceToken = this.#options.deviceToken ?? identityState.stored.deviceToken; - await this.#sendRequest("connect", { - auth: stripUndefined({ - deviceToken, - }), - client: { - displayName: "pickle-openclaw", - id: clientId, - mode: clientMode, - platform, - version: this.#options.clientVersion ?? "0.1.0", - }, - device: buildGatewayDeviceConnectParams(stripUndefined({ - clientId, - clientMode, - identity: identityState.identity, - nonce: challenge.nonce, - platform, - role, - scopes, - token: deviceToken, - })), - maxProtocol: 4, - minProtocol: 4, - role, - scopes, - }).then((hello) => { - const auth = recordValue(recordValue(hello)?.auth); - const nextDeviceToken = stringValue(auth?.deviceToken); - if (nextDeviceToken && this.#options.deviceIdentityPath) { - writeDeviceIdentityState(this.#options.deviceIdentityPath, stripUndefined({ - ...identityState.stored, - deviceToken: nextDeviceToken, - tokenScopes: arrayValue(auth?.scopes)?.filter((scope): scope is string => typeof scope === "string"), - })); - } - }); - } +export class OpenClawHostTransport implements OpenClawTransport { + readonly #runtime: OpenClawHostRuntime; + readonly #localEvents = new LocalEventBus(); - #waitForConnectChallenge(socket: WebSocket): Promise<{ nonce: string }> { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - cleanup(); - reject(new Error("OpenClaw gateway connect challenge timed out")); - }, this.#options.requestTimeoutMs ?? 30_000); - const cleanup = () => { - clearTimeout(timeout); - socket.removeEventListener("message", onMessage); - socket.removeEventListener("close", onClose); - }; - const onClose = () => { - cleanup(); - reject(new Error("OpenClaw gateway socket closed before connect challenge")); - }; - const onMessage = (event: MessageEvent) => { - const frame = recordValue(safeJsonParse(String(event.data))); - if (frame?.type !== "event" || frame.event !== "connect.challenge") return; - const nonce = stringValue(recordValue(frame.payload)?.nonce); - if (!nonce) { - cleanup(); - reject(new Error("OpenClaw gateway connect challenge missing nonce")); - return; - } - cleanup(); - resolve({ nonce }); - }; - socket.addEventListener("message", onMessage); - socket.addEventListener("close", onClose); - }); + constructor(runtime: OpenClawHostRuntime) { + this.#runtime = runtime; } - #loadDeviceIdentityState(): { identity: GatewayDeviceIdentity; stored: StoredGatewayDeviceIdentity } { - if (this.#options.deviceIdentityPath) return loadOrCreateDeviceIdentityState(this.#options.deviceIdentityPath); - const identity = generateDeviceIdentity(); - return { - identity, - stored: { ...identity, createdAtMs: Date.now(), version: 1 }, - }; + request(method: string, params?: unknown, options?: GatewayRequestOptions): Promise { + const call = this.#runtime.request ?? this.#runtime.call; + if (!call) return this.#pluginRuntimeRequest(method, params, options); + return call(method, params, options); } - #handleFrame(raw: string): void { - const frame = JSON.parse(raw) as Record; - if (frame.type === "res") { - const id = stringValue(frame.id); - const pending = id ? this.#pending.get(id) : undefined; - if (!id || !pending) return; - this.#pending.delete(id); - clearTimeout(pending.timeout); - if (frame.ok === false) pending.reject(new Error(`OpenClaw gateway request failed: ${errorMessage(frame.error)}`)); - else pending.resolve(frame.payload); - return; + 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 (frame.type === "event") { - const event = stripUndefined({ - event: stringValue(frame.event), - payload: frame.payload ?? frame, - seq: typeof frame.seq === "number" ? frame.seq : undefined, - stateVersion: frame.stateVersion, - }); - this.#recordReplay(event); - for (const subscriber of this.#subscribers) { - if (!subscriber.filter || subscriber.filter(event)) { - subscriber.events.push(event); - subscriber.notify?.(); - subscriber.notify = undefined; - } - } + if (typeof this.#runtime.events === "object" && this.#runtime.events?.onSessionTranscriptUpdate) { + return mergeEvents([ + transcriptUpdateEvents(this.#runtime.events.onSessionTranscriptUpdate, filter), + this.#localEvents.events(filter), + ]); } - } - - #recordReplay(event: OpenClawGatewayEvent): void { - this.#replay.push(event); - const limit = this.#options.replayLimit ?? 500; - if (limit <= 0) { - this.#replay.length = 0; - return; + 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 #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.send": + return await sendSessionInPluginRuntime(this.#runtime, this.#localEvents, params, _options) as T; + default: + throw new Error(`OpenClaw plugin runtime does not expose request/call for ${method}`); } - if (this.#replay.length > limit) this.#replay.splice(0, this.#replay.length - limit); } } -export function createOpenClawWebSocketTransport(options: OpenClawWebSocketTransportOptions): OpenClawWebSocketTransport { - return new OpenClawWebSocketTransport(options); +export function createOpenClawHostTransport(runtime: OpenClawHostRuntime): OpenClawHostTransport { + return new OpenClawHostTransport(runtime); } function arrayValue(value: unknown): unknown[] | undefined { @@ -744,203 +487,521 @@ function settledValue(result: PromiseSettledResult): unknown { return result.status === "fulfilled" ? result.value : undefined; } -async function readGatewayResponse(response: Response): Promise { - const text = await response.text(); - if (!response.ok) throw new Error(`OpenClaw gateway request failed (${response.status}): ${text || response.statusText}`); - return text ? JSON.parse(text) : undefined; -} +async function* emptyEvents(): AsyncIterable {} -function normalizeGatewayUrl(value: string): URL { - const url = new URL(value); - if (url.protocol === "ws:") url.protocol = "http:"; - if (url.protocol === "wss:") url.protocol = "https:"; - return url; -} +class LocalEventBus { + readonly #subscribers = new Set<(event: OpenClawGatewayEvent) => void>(); -function endpointUrl(baseUrl: URL, path: string): URL { - if (/^https?:\/\//.test(path)) return new URL(path); - const base = new URL(baseUrl); - base.pathname = joinPath(base.pathname, path); - base.search = ""; - base.hash = ""; - return base; -} + emit(event: OpenClawGatewayEvent): void { + for (const subscriber of this.#subscribers) subscriber(event); + } -function joinPath(basePath: string, path: string): string { - const base = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath; - const next = path.startsWith("/") ? path : `/${path}`; - return `${base}${next}` || "/"; + 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* parseEventStream(stream: ReadableStream): AsyncIterable { - const reader = stream.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; +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 { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - let split = eventBoundary(buffer); - while (split >= 0) { - const frame = buffer.slice(0, split); - buffer = buffer.slice(split + frameBoundaryLength(buffer, split)); - const event = parseEventFrame(frame); - if (event) yield event; - split = eventBoundary(buffer); + 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; } - buffer += decoder.decode(); - const event = parseEventFrame(buffer); - if (event) yield event; } finally { - reader.releaseLock(); - } -} - -function eventBoundary(value: string): number { - const lf = value.indexOf("\n\n"); - const crlf = value.indexOf("\r\n\r\n"); - if (lf < 0) return crlf; - if (crlf < 0) return lf; - return Math.min(lf, crlf); -} - -function frameBoundaryLength(value: string, index: number): number { - return value.slice(index, index + 4) === "\r\n\r\n" ? 4 : 2; -} - -function parseEventFrame(frame: string): OpenClawGatewayEvent | undefined { - const lines = frame.split(/\r?\n/); - let event: string | undefined; - const data: string[] = []; - for (const line of lines) { - if (line.startsWith("event:")) event = line.slice("event:".length).trim(); - if (line.startsWith("data:")) data.push(line.slice("data:".length).trimStart()); - } - if (data.length === 0) return undefined; - const payload = JSON.parse(data.join("\n")) as unknown; - const record = recordValue(payload); - if (record && ("event" in record || "payload" in record || "seq" in record)) { - return stripUndefined({ - event: stringValue(record.event) ?? event, - payload: record.payload ?? payload, - seq: typeof record.seq === "number" ? record.seq : undefined, - stateVersion: record.stateVersion, + 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: "OpenClaw" }]; +} + +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 }); + } } - return stripUndefined({ event, payload }); -} - -function errorMessage(error: unknown): string { - const record = recordValue(error); - return stringValue(record?.message) ?? stringValue(error) ?? JSON.stringify(error); -} - -function safeJsonParse(raw: string): unknown { + 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", + sessionFile: stringValue(entry.sessionFile) ?? resolvePluginSessionFile(runtime, agentId, sessionId, entry), + sessionId, + updatedAt: typeof entry.updatedAt === "number" ? entry.updatedAt : now, + }); + await runtime.agent?.session?.upsertSessionEntry?.({ agentId, entry: next, sessionKey }); + return { agentId, key: sessionKey, label, sessionFile: next.sessionFile, sessionId }; +} + +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 plugin sessions.send requires key"); + if (!message) throw new Error("OpenClaw plugin sessions.send 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?.(); + const runEmbeddedAgent = runtime.agent?.runEmbeddedAgent ?? runtime.agent?.runEmbeddedPiAgent; + if (!runEmbeddedAgent) throw new Error("OpenClaw plugin runtime does not expose agent.runEmbeddedAgent"); + const workspaceDir = await resolvePluginWorkspaceDir(runtime, cfg, agentId); + const timeoutMs = options?.timeoutMs ?? numberValue(record.timeoutMs) ?? runtime.agent?.resolveAgentTimeoutMs?.({ cfg }) ?? 48 * 60 * 60 * 1000; + localEvents.emit({ event: "run.started", payload: { agentId, runId, sessionId, sessionKey } }); + let lastPartialText = ""; + let lastReasoningText = ""; + void runEmbeddedAgent(stripUndefined({ + agentId, + config: cfg, + currentMessageId: stringValue(record.idempotencyKey), + messageChannel: "beeper", + messageProvider: "beeper", + prompt: message, + runId, + sessionFile, + sessionId, + sessionKey, + timeoutMs, + trigger: "user", + workspaceDir, + agentDir: runtime.agent?.resolveAgentDir?.(cfg, agentId), + onAgentEvent: (event: OpenClawAgentRuntimeEvent) => { + const data = recordValue(event.data) ?? {}; + localEvents.emit(stripUndefined({ + event: event.stream, + payload: stripUndefined({ + ...data, + runId: stringValue(data.runId) ?? runId, + sessionKey: event.sessionKey ?? stringValue(data.sessionKey) ?? sessionKey, + }), + seq: numberValue(data.seq), + })); + }, + onAssistantMessageStart: () => { + lastPartialText = ""; + localEvents.emit({ event: "assistant.message.start", payload: { agentId, runId, sessionId, sessionKey } }); + }, + onBlockReply: (payload: unknown) => { + const text = stringValue(recordValue(payload)?.text); + if (!text) return; + const delta = text.startsWith(lastPartialText) ? text.slice(lastPartialText.length) : text; + lastPartialText = text; + if (!delta) return; + localEvents.emit({ event: "assistant.delta", payload: { agentId, delta, runId, sessionId, sessionKey, text } }); + }, + onBlockReplyQueued: (payload: unknown) => { + const text = stringValue(recordValue(payload)?.text); + if (!text) return; + const delta = text.startsWith(lastPartialText) ? text.slice(lastPartialText.length) : text; + lastPartialText = text; + if (!delta) return; + localEvents.emit({ event: "assistant.delta", payload: { agentId, delta, runId, sessionId, sessionKey, text } }); + }, + onPartialReply: (payload: unknown) => { + const text = stringValue(recordValue(payload)?.text); + if (!text) return; + const explicitDelta = stringValue(recordValue(payload)?.delta); + const delta = explicitDelta ?? (text.startsWith(lastPartialText) ? text.slice(lastPartialText.length) : text); + lastPartialText = text; + if (!delta) return; + localEvents.emit({ event: "assistant.delta", payload: { agentId, delta, runId, sessionId, sessionKey, text } }); + }, + onReasoningEnd: () => { + localEvents.emit({ event: "thinking.end", payload: { agentId, runId, sessionId, sessionKey } }); + }, + onReasoningStream: (payload: unknown) => { + const text = stringValue(recordValue(payload)?.text); + if (!text) return; + const explicitDelta = stringValue(recordValue(payload)?.delta); + const delta = explicitDelta ?? (text.startsWith(lastReasoningText) ? text.slice(lastReasoningText.length) : text); + lastReasoningText = text; + if (!delta) return; + localEvents.emit({ event: "thinking.delta", payload: { agentId, delta, runId, sessionId, sessionKey, text } }); + }, + onToolResult: (payload: unknown) => { + const record = recordValue(payload) ?? {}; + localEvents.emit({ + event: "tool.call.completed", + payload: stripUndefined({ + agentId, + output: record.text ?? record.content ?? payload, + runId, + sessionId, + sessionKey, + toolCallId: stringValue(record.toolCallId) ?? stringValue(record.id) ?? "tool_result", + toolName: stringValue(record.toolName) ?? stringValue(record.name), + }), + }); + }, + })).then( + (result) => { + const finalText = finalTextFromEmbeddedRunResult(result); + if (finalText) { + const delta = finalText.startsWith(lastPartialText) ? finalText.slice(lastPartialText.length) : finalText; + lastPartialText = finalText; + if (delta) { + localEvents.emit({ event: "assistant.delta", payload: { agentId, delta, runId, sessionId, sessionKey, text: finalText } }); + } + } + localEvents.emit({ event: "run.completed", payload: { agentId, runId, sessionId, sessionKey } }); + }, + (error) => { + localEvents.emit({ event: "run.failed", payload: { agentId, error: errorText(error), runId, sessionId, sessionKey } }); + }, + ); + return { runId, sessionFile, sessionId, sessionKey }; +} + +function finalTextFromEmbeddedRunResult(result: unknown): string | undefined { + const record = recordValue(result); + const direct = stringValue(record?.text) ?? stringValue(record?.message) ?? stringValue(record?.finalText); + if (direct) return direct; + const payloads = arrayValue(record?.payloads); + if (!payloads) return undefined; + const parts: string[] = []; + for (const payload of payloads) { + const payloadRecord = recordValue(payload); + const text = stringValue(payloadRecord?.text) ?? stringValue(payloadRecord?.content); + if (text) parts.push(text); + } + return parts.length > 0 ? parts.join("\n") : undefined; +} + +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 resolvePluginWorkspaceDir(runtime: OpenClawHostRuntime, cfg: unknown, agentId: string): Promise { + const ensured = await runtime.agent?.ensureAgentWorkspace?.(cfg, agentId); + if (typeof ensured === "string" && ensured) return ensured; + const resolved = runtime.agent?.resolveAgentWorkspaceDir?.(cfg, agentId); + if (resolved) return resolved; + return process.cwd(); +} + +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 { - return JSON.parse(raw) as unknown; + raw = await fs.readFile(sessionFile, "utf8"); } catch { - return undefined; + 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 loadOrCreateDeviceIdentityState(filePath: string): { - identity: GatewayDeviceIdentity; - stored: StoredGatewayDeviceIdentity; -} { - const parsed = readStoredDeviceIdentity(filePath); - if (parsed) return { identity: parsed, stored: parsed }; - const identity = generateDeviceIdentity(); - const stored = { ...identity, createdAtMs: Date.now(), version: 1 as const }; - writeDeviceIdentityState(filePath, stored); - return { identity, stored }; +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 readStoredDeviceIdentity(filePath: string): StoredGatewayDeviceIdentity | undefined { - try { - const parsed = recordValue(JSON.parse(readFileSync(filePath, "utf8")) as unknown); - if (!parsed || parsed.version !== 1) return undefined; - const deviceId = stringValue(parsed.deviceId); - const publicKeyPem = stringValue(parsed.publicKeyPem); - const privateKeyPem = stringValue(parsed.privateKeyPem); - if (!deviceId || !publicKeyPem || !privateKeyPem) return undefined; - return stripUndefined({ - createdAtMs: typeof parsed.createdAtMs === "number" ? parsed.createdAtMs : Date.now(), - deviceId, - deviceToken: stringValue(parsed.deviceToken), - privateKeyPem, - publicKeyPem, - tokenScopes: arrayValue(parsed.tokenScopes)?.filter((scope): scope is string => typeof scope === "string"), - version: 1 as const, - }); - } catch { - return undefined; +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 writeDeviceIdentityState(filePath: string, value: StoredGatewayDeviceIdentity): void { - mkdirSync(dirname(filePath), { recursive: true }); - writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); -} - -function generateDeviceIdentity(): GatewayDeviceIdentity { - const { publicKey, privateKey } = generateKeyPairSync("ed25519"); - const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString(); - const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString(); - return { - deviceId: createHash("sha256").update(publicKeyRawFromPem(publicKeyPem)).digest("hex"), - privateKeyPem, - publicKeyPem, - }; +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 buildGatewayDeviceConnectParams(options: { - clientId: string; - clientMode: string; - identity: GatewayDeviceIdentity; - nonce: string; - platform: string; - role: string; - scopes: string[]; - token?: string; -}): Record { - const signedAt = Date.now(); - const payload = [ - "v3", - options.identity.deviceId, - options.clientId, - options.clientMode, - options.role, - options.scopes.join(","), - String(signedAt), - options.token ?? "", - options.nonce, - options.platform.trim(), - "", - ].join("|"); - return { - id: options.identity.deviceId, - nonce: options.nonce, - publicKey: base64Url(publicKeyRawFromPem(options.identity.publicKeyPem)), - signature: base64Url(sign(null, Buffer.from(payload, "utf8"), createPrivateKey(options.identity.privateKeyPem))), - signedAt, - }; +function agentIdFromSessionKey(sessionKey: string): string | undefined { + return /^agent:([^:]+)/.exec(sessionKey)?.[1]; } -function publicKeyRawFromPem(publicKeyPem: string): Buffer { - const spki = createPublicKey(publicKeyPem).export({ type: "spki", format: "der" }) as Buffer; - if ( - spki.length === ED25519_SPKI_PREFIX.length + 32 && - spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX) - ) { - return spki.subarray(ED25519_SPKI_PREFIX.length); - } - return spki; +function numberValue(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; } -function base64Url(value: Buffer): string { - return value.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, ""); +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error); } type StripUndefined = { diff --git a/packages/openclaw/src/protocol-coverage.test.ts b/packages/openclaw/src/protocol-coverage.test.ts index 316abe6..d21401c 100644 --- a/packages/openclaw/src/protocol-coverage.test.ts +++ b/packages/openclaw/src/protocol-coverage.test.ts @@ -44,7 +44,7 @@ describe("OpenClaw gateway protocol coverage manifest", () => { it("keeps broad feature access routed through generic gateway calls plus wrappers", () => { expect(OPENCLAW_BRIDGE_COVERAGE.methodAccess.genericGatewayCall).toBe("OpenClawGatewayRuntime.call"); - expect(OPENCLAW_BRIDGE_COVERAGE.methodAccess.managementCli).toBe("pickle-openclaw rpc [json-params]"); + expect(OPENCLAW_BRIDGE_COVERAGE.methodAccess.managementSurface).toBe("OpenClaw in-process plugin runtime"); expect(OPENCLAW_BRIDGE_COVERAGE.methodAccess.bridgeSpecificWrappers).toEqual(expect.arrayContaining([ "agents.list", "sessions.send", diff --git a/packages/openclaw/src/protocol-coverage.ts b/packages/openclaw/src/protocol-coverage.ts index e319a22..f2a1149 100644 --- a/packages/openclaw/src/protocol-coverage.ts +++ b/packages/openclaw/src/protocol-coverage.ts @@ -215,7 +215,7 @@ export const OPENCLAW_BRIDGE_COVERAGE = { bridgeSpecificWrappers: ["agents.list", "sessions.list", "sessions.create", "sessions.send", "sessions.steer", "sessions.abort", "chat.history", "exec.approval.resolve", "models.list", "tools.catalog", "tools.effective", "tools.invoke", "tasks.list", "tasks.get", "tasks.cancel", "artifacts.list", "artifacts.get", "artifacts.download"], commonGatewayMethods: OPENCLAW_GATEWAY_COMMON_METHODS, genericGatewayCall: "OpenClawGatewayRuntime.call", - managementCli: "pickle-openclaw rpc [json-params]", + managementSurface: "OpenClaw in-process plugin runtime", snapshotProbe: ["health", "status", "models.list", "channels.status", "sessions.list", "commands.list", "tools.catalog", "skills.status", "tasks.list", "usage.status", "artifacts.list", "cron.list", "agents.list", "config.get"], }, source: ".upstream/openclaw/docs/gateway/protocol.md", diff --git a/packages/openclaw/src/registration.test.ts b/packages/openclaw/src/registration.test.ts index 0b42d92..2e1b608 100644 --- a/packages/openclaw/src/registration.test.ts +++ b/packages/openclaw/src/registration.test.ts @@ -11,9 +11,10 @@ import { describe("OpenClaw appservice registration", () => { it("reserves bridge bot, OpenClaw agent, and human ghost namespaces", () => { const config = createDefaultConfig({ - appserviceId: "pickle-openclaw", + appserviceId: "sh-openclaw-device", dataDir: "/tmp/openclaw", ghostLocalpartPrefix: "oc_agent_", + homeserverDomain: "beeper.local", senderLocalpart: "ocbot", userLocalpartPrefix: "oc_user_", }); @@ -21,19 +22,19 @@ describe("OpenClaw appservice registration", () => { expect(registration).toMatchObject({ as_token: "as", hs_token: "hs", - id: "pickle-openclaw", + id: "sh-openclaw-device", rate_limited: false, receive_ephemeral: true, sender_localpart: "ocbot", url: "http://127.0.0.1:29391", }); expect(registration.namespaces.users).toEqual([ - { exclusive: true, regex: "^@ocbot:.*$" }, - { exclusive: true, regex: "^@oc_agent_.+:.*$" }, - { exclusive: true, regex: "^@oc_user_.+:.*$" }, + { exclusive: true, regex: "^@oc_agent_.+:beeper\\.local$" }, + { exclusive: true, regex: "^@oc_user_.+:beeper\\.local$" }, + { exclusive: true, regex: "^@ocbot:beeper\\.local$" }, ]); expect(registration.namespaces.aliases).toEqual([ - { exclusive: true, regex: "^#pickle-openclaw_.+:.*$" }, + { exclusive: true, regex: "^#sh-openclaw-device_.+:.*$" }, ]); }); @@ -41,7 +42,7 @@ describe("OpenClaw appservice registration", () => { const config = createDefaultConfig({ dataDir: "/tmp/openclaw" }); expect(openClawAgentGhostLocalpart(config, "Codex/Main Agent")).toBe("openclaw_agent_codex/main_agent"); expect(openClawUserGhostLocalpart(config, "@alice:beeper.local")).toBe("openclaw_user_alice_beeper.local"); - expect(openClawAliasLocalpart(config, "session 1")).toBe("pickle-openclaw_session_1"); + expect(openClawAliasLocalpart(config, "session 1")).toBe("sh-openclaw_session_1"); expect(openClawRoomCreationPreset(config)).toEqual({ creation_content: { "m.federate": false }, preset: "private_chat", diff --git a/packages/openclaw/src/registration.ts b/packages/openclaw/src/registration.ts index 4a8d886..3d6f3c5 100644 --- a/packages/openclaw/src/registration.ts +++ b/packages/openclaw/src/registration.ts @@ -10,6 +10,7 @@ export function createAppserviceRegistration( config: OpenClawBridgeConfig, options: CreateRegistrationOptions = {} ): AppserviceRegistration { + const domain = escapeRegex(config.homeserverDomain ?? matrixDomainFromHomeserver(config.homeserver)); const ghostPrefix = escapeRegex(config.ghostLocalpartPrefix); const userPrefix = escapeRegex(config.userLocalpartPrefix); const sender = escapeRegex(config.senderLocalpart); @@ -21,9 +22,9 @@ export function createAppserviceRegistration( aliases: [{ exclusive: true, regex: `^#${escapeRegex(config.appserviceId)}_.+:.*$` }], rooms: [], users: [ - { exclusive: true, regex: `^@${sender}:.*$` }, - { exclusive: true, regex: `^@${ghostPrefix}.+:.*$` }, - { exclusive: true, regex: `^@${userPrefix}.+:.*$` }, + { exclusive: true, regex: `^@${ghostPrefix}.+:${domain}$` }, + { exclusive: true, regex: `^@${userPrefix}.+:${domain}$` }, + { exclusive: true, regex: `^@${sender}:${domain}$` }, ], }, receive_ephemeral: true, @@ -33,6 +34,15 @@ export function createAppserviceRegistration( }; } +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 `${config.ghostLocalpartPrefix}${encodeLocalpartSegment(agentId)}`; } diff --git a/packages/openclaw/src/rooms.ts b/packages/openclaw/src/rooms.ts index 7e012da..90fa470 100644 --- a/packages/openclaw/src/rooms.ts +++ b/packages/openclaw/src/rooms.ts @@ -15,22 +15,26 @@ export function matrixDomainFromHomeserver(homeserver: string | undefined): stri } } -export function agentGhostUserId(config: OpenClawBridgeConfig, agentId: string, domain = matrixDomainFromHomeserver(config.homeserver)): string { +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 userGhostUserId(config: OpenClawBridgeConfig, userId: string, domain = matrixDomainFromHomeserver(config.homeserver)): string { +export function userGhostUserId(config: OpenClawBridgeConfig, userId: string, domain = matrixDomainFromConfig(config)): string { return `@${config.userLocalpartPrefix}${encodeLocalpartSegment(userId)}:${domain}`; } -export function serviceBotUserId(config: OpenClawBridgeConfig, domain = matrixDomainFromHomeserver(config.homeserver)): string { +export function serviceBotUserId(config: OpenClawBridgeConfig, domain = matrixDomainFromConfig(config)): string { return `@${config.serviceBotLocalpart}:${domain}`; } export function agentContactFromOpenClawAgent( config: OpenClawBridgeConfig, agent: Record, - domain = matrixDomainFromHomeserver(config.homeserver) + 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; @@ -56,7 +60,7 @@ export function userContactFromOpenClawSession( origin?: Record; provider?: string; }, - domain = matrixDomainFromHomeserver(config.homeserver) + domain = matrixDomainFromConfig(config) ): OpenClawUserContact | undefined { const userId = session.lastTo ?? session.lastAccountId ?? stringValue(session.origin?.userId) ?? stringValue(session.origin?.accountId); if (!userId) return undefined; diff --git a/packages/openclaw/src/setup.test.ts b/packages/openclaw/src/setup.test.ts index 5fae1fb..fa34454 100644 --- a/packages/openclaw/src/setup.test.ts +++ b/packages/openclaw/src/setup.test.ts @@ -111,13 +111,11 @@ describe("OpenClaw Beeper setup surface", () => { accountId: "default", cfg: {}, input: { - gatewayUrl: "ws://127.0.0.1:18789", registrationUrl: "http://127.0.0.1:29391", }, }); expect(cfg).not.toHaveProperty("then"); expect(getBeeperChannelSettings(cfg)).toMatchObject({ - gatewayUrl: "ws://127.0.0.1:18789", registrationUrl: "http://127.0.0.1:29391", }); }); @@ -133,7 +131,6 @@ describe("OpenClaw Beeper setup surface", () => { backfillLimit: 25, dataDir: "/tmp/openclaw-beeper", enabled: true, - gatewayUrl: "ws://gateway", homeserver: "https://matrix.example", hsToken: "hs", importSources: ["dashboard", "tui"], @@ -152,7 +149,6 @@ describe("OpenClaw Beeper setup surface", () => { expect(appserviceMocks.accountFromOpenClawConfig).toHaveBeenCalledWith(expect.objectContaining({ accessToken: "at", asToken: "as", - gatewayUrl: "ws://gateway", hsToken: "hs", })); expect(appserviceMocks.startOpenClawBeeperBridge).toHaveBeenCalledWith(expect.objectContaining({ @@ -178,7 +174,6 @@ describe("OpenClaw Beeper setup surface", () => { accountId: "default", cfg: applyBeeperChannelSettings({}, { enabled: true, - gatewayUrl: "ws://gateway", registrationUrl: "http://bridge", }), })).rejects.toThrow("not fully configured"); @@ -205,9 +200,9 @@ describe("OpenClaw Beeper setup surface", () => { backfillLimit: "42", baseDomain: "beeper-staging.com", beeperEnv: "staging", + bridgeId: "sh-openclaw-custom", bridgeManagerToken: "hungry", contactVisibility: "agents-and-users", - gatewayUrl: "ws://127.0.0.1:18789", ghostLocalpartPrefix: "oc_agent_", importSources: "dashboard,tui", nonFederatedRooms: "false", @@ -228,10 +223,10 @@ describe("OpenClaw Beeper setup surface", () => { backfillLimit: 42, baseDomain: "beeper-staging.com", beeperEnv: "staging", + bridgeId: "sh-openclaw-custom", bridgeManagerToken: "hungry", contactVisibility: "agents-and-users", enabled: true, - gatewayUrl: "ws://127.0.0.1:18789", ghostLocalpartPrefix: "oc_agent_", importSources: ["dashboard", "tui"], nonFederatedRooms: false, @@ -312,8 +307,9 @@ describe("OpenClaw Beeper setup surface", () => { }, config: { accessToken: "at", - appserviceId: "pickle-openclaw", + appserviceId: "sh-openclaw-dev", asToken: "as", + bridgeId: "sh-openclaw-dev", homeserver: "https://matrix.example", hsToken: "hs", matrixDeviceId: "DEV", @@ -324,7 +320,7 @@ describe("OpenClaw Beeper setup surface", () => { homeserver: "https://matrix.example", registration: { asToken: "as", - id: "pickle-openclaw", + id: "sh-openclaw-dev", hsToken: "hs", url: "http://127.0.0.1:29391", }, @@ -342,7 +338,7 @@ describe("OpenClaw Beeper setup surface", () => { baseDomain: "beeper.localtest.me", bridgeManagerPostState: false, bridgeManagerToken: "hungry", - gatewayUrl: "ws://127.0.0.1:18789", + bridgeId: "sh-openclaw-dev", homeserver: "https://matrix.example", homeserverDomain: "beeper.local", hsToken: "hs", @@ -359,14 +355,12 @@ describe("OpenClaw Beeper setup surface", () => { input: { accessToken: "at", asToken: "as", - gatewayUrl: "ws://127.0.0.1:18789", registrationUrl: "http://127.0.0.1:29391", }, }); expect(getBeeperChannelSettings(cfg)).toMatchObject({ accessToken: "at", asToken: "as", - gatewayUrl: "ws://127.0.0.1:18789", registrationUrl: "http://127.0.0.1:29391", }); }); @@ -374,14 +368,12 @@ describe("OpenClaw Beeper setup surface", () => { it("does not report configured until login, appservice, and gateway details are present", async () => { expect(isBeeperChannelConfigured(applyBeeperChannelSettings({}, { enabled: true, - gatewayUrl: "ws://gateway", registrationUrl: "http://bridge", }))).toBe(false); const cfg = applyBeeperChannelSettings({}, { accessToken: "at", asToken: "as", enabled: true, - gatewayUrl: "ws://gateway", homeserver: "https://matrix.example", hsToken: "hs", matrixDeviceId: "DEV", @@ -399,7 +391,6 @@ describe("OpenClaw Beeper setup surface", () => { beeperEnv: "dev", code: "123456", email: "alice@example.com", - gatewayUrl: "ws://127.0.0.1:18789", registrationUrl: "http://127.0.0.1:29391", }, runtime: { @@ -421,8 +412,9 @@ describe("OpenClaw Beeper setup surface", () => { }, config: { accessToken: "at", - appserviceId: "pickle-openclaw", + appserviceId: "sh-openclaw-dev", asToken: "as", + bridgeId: "sh-openclaw-dev", homeserver: "https://matrix.example", hsToken: "hs", matrixDeviceId: "DEV", @@ -433,7 +425,7 @@ describe("OpenClaw Beeper setup surface", () => { homeserver: "https://matrix.example", registration: { asToken: "as", - id: "pickle-openclaw", + id: "sh-openclaw-dev", hsToken: "hs", url: "http://127.0.0.1:29391", }, @@ -446,7 +438,7 @@ describe("OpenClaw Beeper setup surface", () => { enabled: true, accessToken: "at", asToken: "as", - gatewayUrl: "ws://127.0.0.1:18789", + bridgeId: "sh-openclaw-dev", homeserver: "https://matrix.example", hsToken: "hs", matrixDeviceId: "DEV", @@ -473,7 +465,6 @@ describe("OpenClaw Beeper setup surface", () => { expect(validateBeeperSetupInput({ backfillLimit: "-1" })).toContain("non-negative"); const cfg = applyBeeperChannelSettings({}, { enabled: true, - gatewayUrl: "ws://gateway", importSources: ["dashboard"], registrationUrl: "http://bridge", }); @@ -487,7 +478,6 @@ describe("OpenClaw Beeper setup surface", () => { it("reports lightweight channel status without starting bridge runtime", () => { const account = beeperChannelConfig.resolveAccount(applyBeeperChannelSettings({}, { enabled: true, - gatewayUrl: "ws://gateway", importSources: ["dashboard", "tui"], registrationUrl: "http://bridge", streamFinalization: "replace", @@ -499,7 +489,6 @@ describe("OpenClaw Beeper setup surface", () => { configured: false, enabled: true, extra: { - gatewayUrl: "ws://gateway", importSources: ["dashboard", "tui"], mode: "self-hosted-appservice", registrationUrl: "http://bridge", @@ -509,7 +498,6 @@ describe("OpenClaw Beeper setup surface", () => { expect(beeperStatusAdapter.buildChannelSummary({ snapshot })).toMatchObject({ configured: false, enabled: true, - gatewayUrl: "ws://gateway", mode: "self-hosted-appservice", running: false, }); @@ -527,7 +515,6 @@ describe("OpenClaw Beeper setup surface", () => { channels: { beeper: { dataDir: "/tmp/beeper", - gatewayUrl: "ws://gateway", homeserver: "https://matrix.example", hsToken: "hs", matrixDeviceId: "DEV", @@ -539,7 +526,6 @@ describe("OpenClaw Beeper setup surface", () => { }); expect(cfg).toMatchObject({ dataDir: "/tmp/beeper", - gatewayUrl: "ws://gateway", homeserver: "https://matrix.example", hsToken: "hs", matrixDeviceId: "DEV", @@ -553,7 +539,6 @@ describe("OpenClaw Beeper setup surface", () => { expect(getBeeperChannelSettings({ channels: { beeper: { - gatewayUrl: "ws://channel", importSources: ["dashboard"], }, }, @@ -562,7 +547,6 @@ describe("OpenClaw Beeper setup surface", () => { beeper: { config: { enabled: true, - gatewayUrl: "ws://plugin-entry", registrationUrl: "http://bridge", }, }, @@ -570,7 +554,6 @@ describe("OpenClaw Beeper setup surface", () => { }, })).toEqual({ enabled: true, - gatewayUrl: "ws://channel", importSources: ["dashboard"], registrationUrl: "http://bridge", }); @@ -580,14 +563,12 @@ describe("OpenClaw Beeper setup surface", () => { entries: { beeper: { config: { - gatewayUrl: "ws://plugin-entry", registrationUrl: "http://bridge", }, }, }, }, })).toMatchObject({ - gatewayUrl: "ws://plugin-entry", registrationUrl: "http://bridge", }); }); diff --git a/packages/openclaw/src/setup.ts b/packages/openclaw/src/setup.ts index f113e2c..01e4da3 100644 --- a/packages/openclaw/src/setup.ts +++ b/packages/openclaw/src/setup.ts @@ -1,5 +1,6 @@ -import { createConfigFromOpenClawSetup, DEFAULT_GATEWAY_URL, DEFAULT_REGISTRATION_URL, defaultDataDir } from "./config"; +import { createConfigFromOpenClawSetup, DEFAULT_REGISTRATION_URL, defaultDataDir } from "./config"; import type { setupOpenClawBeeperBridge, SetupOpenClawBeeperBridgeOptions } from "./beeper-setup"; +import type { OpenClawHostRuntime } from "./openclaw-runtime"; export type OpenClawSetupConfig = { channels?: Record; @@ -22,10 +23,10 @@ export interface BeeperChannelSettings { beeperEnv?: "production" | "staging" | "dev" | "local"; bridgeManagerToken?: string; bridgeManagerPostState?: boolean; + bridgeId?: string; contactVisibility?: "agents" | "agents-and-users" | "none"; dataDir?: string; enabled?: boolean; - gatewayUrl?: string; ghostLocalpartPrefix?: string; homeserver?: string; hsToken?: string; @@ -53,12 +54,12 @@ export interface BeeperSetupInput { baseDomain?: string; beeperEnv?: string; bridgeManagerToken?: string; + bridgeId?: string; code?: string; contactVisibility?: string; dataDir?: string; email?: string; getOnly?: boolean | string; - gatewayUrl?: string; ghostLocalpartPrefix?: string; homeserverDomain?: string; importSources?: string[] | string; @@ -87,11 +88,13 @@ type BeeperGatewayContext = { abortSignal: AbortSignal; accountId: string; cfg: OpenClawSetupConfig; + hostRuntime?: unknown; log?: { info?: (message: string) => void; warn?: (message: string) => void; error?: (message: string) => void; }; + runtime?: unknown; setStatus?: (next: Record) => void; }; @@ -133,8 +136,8 @@ export const BeeperChannelConfigSchema = { enabled: { type: "boolean" }, baseDomain: { type: "string" }, beeperEnv: { type: "string", enum: ["production", "staging", "dev", "local"] }, + bridgeId: { type: "string" }, dataDir: { type: "string" }, - gatewayUrl: { type: "string" }, ghostLocalpartPrefix: { type: "string" }, homeserver: { type: "string" }, hsToken: { type: "string" }, @@ -199,7 +202,7 @@ export const beeperSetupAdapter = { runtime?: BeeperSetupRuntime; }): OpenClawSetupConfig => { if (input.email) { - throw new Error("Beeper email login is asynchronous; use the Beeper setup wizard or pickle-openclaw beeper-setup."); + throw new Error("Beeper email login is asynchronous; use the Beeper setup wizard or pickle-openclaw login."); } return applyBeeperChannelSettings(cfg, normalizeBeeperSetupInput(input)); }, @@ -214,7 +217,7 @@ export const beeperSetupWizard = { channel: BEEPER_CHANNEL_ID, configured, statusLines: [ - `Gateway: ${settings.gatewayUrl ?? "not configured"}`, + "Runtime: OpenClaw plugin", `Registration URL: ${settings.registrationUrl ?? "not configured"}`, `Import sources: ${(settings.importSources ?? []).join(", ") || "none"}`, ], @@ -337,7 +340,6 @@ export const beeperSetupWizard = { backfillLimit, code, email, - gatewayUrl: current.gatewayUrl ?? DEFAULT_GATEWAY_URL, importSources, nonFederatedRooms, postState, @@ -382,7 +384,6 @@ export const beeperChannelConfig = { label: "Beeper", configured: account.configured === true, extra: { - gatewayUrl: account.settings?.gatewayUrl, registrationUrl: account.settings?.registrationUrl, }, }), @@ -401,12 +402,11 @@ export const beeperStatusAdapter = { buildChannelSummary: ({ snapshot }: { snapshot: Record }) => ({ configured: snapshot.configured === true, enabled: snapshot.enabled !== false, - gatewayUrl: recordValue(snapshot.extra)?.gatewayUrl, homeserver: recordValue(snapshot.extra)?.homeserver, mode: "self-hosted-appservice", running: snapshot.running === true, }), - buildAccountSnapshot: ({ account }: { account: { accountId?: string; configured?: boolean; settings?: BeeperChannelSettings } }) => { + buildAccountSnapshot: ({ account, runtime }: { account: { accountId?: string; configured?: boolean; settings?: BeeperChannelSettings }; runtime?: Record }) => { const settings = account.settings ?? {}; return { accountId: account.accountId ?? "default", @@ -416,7 +416,6 @@ export const beeperStatusAdapter = { approvalBehavior: settings.approvalBehavior ?? "native", beeperEnv: settings.beeperEnv ?? "production", contactVisibility: settings.contactVisibility ?? "agents", - gatewayUrl: settings.gatewayUrl, homeserver: settings.homeserver, importSources: settings.importSources ?? [], mode: "self-hosted-appservice", @@ -424,7 +423,7 @@ export const beeperStatusAdapter = { streamFinalization: settings.streamFinalization ?? "replace", }, name: "Beeper", - running: false, + running: runtime?.running === true, }; }, resolveAccountState: ({ configured, enabled }: { configured: boolean; enabled: boolean }) => { @@ -460,10 +459,16 @@ export async function applyBeeperSetupConfig(params: { if (result.config.homeserver) setupSettings.homeserver = result.config.homeserver; if (result.config.accessToken) setupSettings.accessToken = result.config.accessToken; if (result.config.asToken) setupSettings.asToken = result.config.asToken; - if (params.input.homeserverDomain) setupSettings.homeserverDomain = params.input.homeserverDomain; + if (result.config.bridgeId) setupSettings.bridgeId = result.config.bridgeId; + if (result.config.ghostLocalpartPrefix) setupSettings.ghostLocalpartPrefix = result.config.ghostLocalpartPrefix; + if (result.config.homeserverDomain) setupSettings.homeserverDomain = result.config.homeserverDomain; + else if (params.input.homeserverDomain) setupSettings.homeserverDomain = params.input.homeserverDomain; if (result.config.hsToken) setupSettings.hsToken = result.config.hsToken; if (result.config.matrixDeviceId) setupSettings.matrixDeviceId = result.config.matrixDeviceId; if (result.config.matrixUserId) setupSettings.matrixUserId = result.config.matrixUserId; + if (result.config.senderLocalpart) setupSettings.senderLocalpart = result.config.senderLocalpart; + if (result.config.serviceBotLocalpart) setupSettings.serviceBotLocalpart = result.config.serviceBotLocalpart; + if (result.config.userLocalpartPrefix) setupSettings.userLocalpartPrefix = result.config.userLocalpartPrefix; return applyBeeperChannelSettings(params.cfg, setupSettings); } @@ -513,12 +518,14 @@ export async function startBeeperGatewayAccount(ctx: BeeperGatewayContext): Prom } const { accountFromOpenClawConfig, startOpenClawBeeperBridge } = await import("./appservice"); const config = createConfigFromOpenClawSetup(ctx.cfg); + const hostRuntime = resolveBeeperHostRuntime(ctx); const bridge = await startOpenClawBeeperBridge({ account: accountFromOpenClawConfig(config), backfill: Boolean(config.importSources?.length), ...(config.backfillLimit !== undefined ? { backfillLimit: config.backfillLimit } : {}), config, dataDir: config.dataDir, + ...(hostRuntime ? { runtime: hostRuntime } : {}), }); const key = gatewayAccountKey(ctx.accountId); startedBridges.set(key, bridge as StartedBeeperBridge); @@ -542,6 +549,21 @@ export async function startBeeperGatewayAccount(ctx: BeeperGatewayContext): Prom } } +function resolveBeeperHostRuntime(ctx: BeeperGatewayContext): OpenClawHostRuntime | undefined { + if (ctx.hostRuntime && typeof ctx.hostRuntime === "object" && hasOpenClawSessionRuntime(ctx.hostRuntime)) return ctx.hostRuntime; + if (ctx.runtime && typeof ctx.runtime === "object" && hasOpenClawSessionRuntime(ctx.runtime)) return ctx.runtime; + return undefined; +} + +function hasOpenClawSessionRuntime(value: object): value is OpenClawHostRuntime { + 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"; +} + export async function stopBeeperGatewayAccount(ctx: BeeperGatewayContext): Promise { const bridge = startedBridges.get(gatewayAccountKey(ctx.accountId)); if (!bridge) return; @@ -569,7 +591,6 @@ export function isBeeperChannelConfigured(cfg: OpenClawSetupConfig): boolean { settings.enabled && settings.accessToken && settings.asToken && - settings.gatewayUrl && settings.homeserver && settings.hsToken && settings.matrixDeviceId && @@ -614,7 +635,6 @@ export function defaultBeeperChannelSettings(): BeeperChannelSettings { contactVisibility: "agents", dataDir: defaultDataDir(), enabled: true, - gatewayUrl: DEFAULT_GATEWAY_URL, importSources: ["dashboard", "tui"], nonFederatedRooms: true, registrationUrl: DEFAULT_REGISTRATION_URL, @@ -656,9 +676,9 @@ export function normalizeBeeperSetupInput(input: BeeperSetupInput): Partial { onlyExistingAccounts: false, }); }); + + 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 14c46ee..9c1cdb0 100644 --- a/packages/pickle/src/beeper/auth.ts +++ b/packages/pickle/src/beeper/auth.ts @@ -131,7 +131,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 { From b38b4d958be4f9de9df46b3446ee9e037b8e25f9 Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Mon, 25 May 2026 14:57:40 +0200 Subject: [PATCH 31/43] Stream OpenClaw runs through AG-UI channel replies --- packages/openclaw/package.json | 8 + .../src/beeper-channel-runtime.test.ts | 100 +++ .../openclaw/src/beeper-channel-runtime.ts | 159 +++++ packages/openclaw/src/connector.test.ts | 44 +- packages/openclaw/src/connector.ts | 213 +------ packages/openclaw/src/index.ts | 2 + packages/openclaw/src/integration.test.ts | 10 +- packages/openclaw/src/matrix-parser.ts | 175 ++++++ .../openclaw/src/openclaw-extension.test.ts | 8 +- packages/openclaw/src/openclaw-runtime.ts | 490 ++++++++++++--- packages/openclaw/src/setup.test.ts | 200 +++++- packages/openclaw/src/setup.ts | 583 +++++++++++++++++- packages/openclaw/tsdown.config.ts | 2 +- 13 files changed, 1705 insertions(+), 289 deletions(-) create mode 100644 packages/openclaw/src/beeper-channel-runtime.test.ts create mode 100644 packages/openclaw/src/beeper-channel-runtime.ts create mode 100644 packages/openclaw/src/matrix-parser.ts diff --git a/packages/openclaw/package.json b/packages/openclaw/package.json index df9ebf8..e95dad4 100644 --- a/packages/openclaw/package.json +++ b/packages/openclaw/package.json @@ -43,6 +43,10 @@ "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" + }, "./beeper-stream": { "types": "./dist/beeper-stream.d.mts", "import": "./dist/beeper-stream.mjs" @@ -59,6 +63,10 @@ "types": "./dist/connector.d.mts", "import": "./dist/connector.mjs" }, + "./matrix-parser": { + "types": "./dist/matrix-parser.d.mts", + "import": "./dist/matrix-parser.mjs" + }, "./openclaw-event-map": { "types": "./dist/openclaw-event-map.d.mts", "import": "./dist/openclaw-event-map.mjs" 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..2049e23 --- /dev/null +++ b/packages/openclaw/src/beeper-channel-runtime.test.ts @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { BeeperChannelRuntime, getBeeperChannelRuntime, setBeeperChannelRuntime } from "./beeper-channel-runtime"; + +function createClient() { + return { + appservice: { + sendMessage: vi.fn(async () => ({ eventId: "$as" })), + }, + 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), + }, + }; +} + +describe("BeeperChannelRuntime", () => { + afterEach(() => { + setBeeperChannelRuntime(undefined); + }); + + it("wraps Pickle message, reaction, redaction, and typing primitives", async () => { + const client = createClient(); + const runtime = new BeeperChannelRuntime({ + client: client as never, + getAgents: () => [{ id: "codex", name: "Codex" }], + }); + + expect(runtime.listAgents()).toEqual([{ id: "codex", name: "Codex" }]); + await expect(runtime.sendText({ replyToId: "$parent", roomId: "!room", text: "hi", threadRoot: "$thread" })) + .resolves.toEqual({ eventId: "$send" }); + expect(client.messages.send).toHaveBeenCalledWith({ + content: { body: "hi", msgtype: "m.text" }, + replyTo: "$parent", + roomId: "!room", + text: "hi", + threadRoot: "$thread", + }); + + await runtime.sendMedia({ bytes: new Uint8Array([1]), caption: "cap", filename: "a.txt", roomId: "!room" }); + expect(client.messages.sendMedia).toHaveBeenCalledWith({ + bytes: new Uint8Array([1]), + caption: "cap", + filename: "a.txt", + kind: "file", + roomId: "!room", + }); + + await runtime.edit({ eventId: "$event", roomId: "!room", text: "edited" }); + expect(client.messages.edit).toHaveBeenCalledWith({ eventId: "$event", roomId: "!room", text: "edited" }); + + await runtime.redact({ eventId: "$event", reason: "oops", roomId: "!room" }); + expect(client.messages.redact).toHaveBeenCalledWith({ eventId: "$event", reason: "oops", roomId: "!room" }); + + await runtime.react({ emoji: "+1", eventId: "$event", roomId: "!room" }); + expect(client.reactions.send).toHaveBeenCalledWith({ eventId: "$event", key: "+1", roomId: "!room" }); + + await runtime.removeReaction({ emoji: "+1", eventId: "$event", roomId: "!room" }); + expect(client.reactions.redact).toHaveBeenCalledWith({ eventId: "$event", key: "+1", roomId: "!room" }); + + await runtime.typing({ roomId: "!room", timeoutMs: 1000 }); + expect(client.typing.set).toHaveBeenCalledWith({ roomId: "!room", timeoutMs: 1000, typing: true }); + }); + + it("uses the appservice ghost sender when a user id is available", async () => { + const client = createClient(); + const runtime = new BeeperChannelRuntime({ + client: client as never, + userId: "@agent:example", + }); + + await runtime.sendText({ replyToId: "$parent", roomId: "!room", text: "from ghost" }); + expect(client.appservice.sendMessage).toHaveBeenCalledWith({ + content: { + body: "from ghost", + msgtype: "m.text", + "m.relates_to": { + "m.in_reply_to": { event_id: "$parent" }, + }, + }, + roomId: "!room", + userId: "@agent:example", + }); + expect(client.messages.send).not.toHaveBeenCalled(); + }); + + it("stores the active runtime for channel adapters", () => { + const runtime = new BeeperChannelRuntime({ client: createClient() as never }); + setBeeperChannelRuntime(runtime); + expect(getBeeperChannelRuntime()).toBe(runtime); + }); +}); diff --git a/packages/openclaw/src/beeper-channel-runtime.ts b/packages/openclaw/src/beeper-channel-runtime.ts new file mode 100644 index 0000000..164538b --- /dev/null +++ b/packages/openclaw/src/beeper-channel-runtime.ts @@ -0,0 +1,159 @@ +import { readFile } from "node:fs/promises"; +import type { MatrixClient, SentEvent } from "@beeper/pickle"; +import type { OpenClawAgentContact } from "./types"; + +export interface BeeperChannelRuntimeOptions { + client: MatrixClient; + getAgents?: () => readonly OpenClawAgentContact[]; + log?: (level: "debug" | "info" | "warn" | "error", message: string, data?: unknown) => void; + userId?: string; +} + +export interface BeeperOutboundMedia { + bytes?: Uint8Array; + caption?: string; + filename?: string; + kind?: "image" | "video" | "audio" | "file"; + path?: string; + threadRoot?: string; +} + +export class BeeperChannelRuntime { + readonly client: MatrixClient; + readonly userId: string | undefined; + #getAgents: () => readonly OpenClawAgentContact[]; + #log: BeeperChannelRuntimeOptions["log"]; + + constructor(options: BeeperChannelRuntimeOptions) { + this.client = options.client; + this.#getAgents = options.getAgents ?? (() => []); + this.#log = options.log; + this.userId = options.userId; + } + + listAgents(): readonly OpenClawAgentContact[] { + return this.#getAgents(); + } + + 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, + }; + if (this.userId) { + return await this.client.appservice.sendMessage({ + content: withReplyRelation(content, options.replyToId), + roomId: options.roomId, + userId: this.userId, + }); + } + return await this.client.messages.send({ + content, + roomId: options.roomId, + text: options.text, + ...(options.replyToId ? { replyTo: options.replyToId } : {}), + ...(options.threadRoot != null ? { threadRoot: String(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.client.messages.sendMedia({ + bytes, + kind: options.kind ?? "file", + roomId: options.roomId, + ...(options.caption !== undefined ? { caption: options.caption } : {}), + ...(options.filename !== undefined ? { filename: options.filename } : {}), + ...(options.threadRoot !== undefined ? { threadRoot: options.threadRoot } : {}), + }); + } + + async edit(options: { + content?: Record; + eventId: string; + roomId: string; + text: string; + }): Promise { + return await this.client.messages.edit({ + eventId: options.eventId, + roomId: options.roomId, + text: options.text, + ...(options.content !== undefined ? { content: options.content } : {}), + }); + } + + async redact(options: { eventId: string; reason?: string; roomId: string }): Promise { + await this.client.messages.redact({ + eventId: options.eventId, + roomId: options.roomId, + ...(options.reason !== undefined ? { reason: options.reason } : {}), + }); + } + + async react(options: { emoji: string; eventId: string; roomId: string }): Promise { + return await this.client.reactions.send({ + eventId: options.eventId, + key: options.emoji, + roomId: options.roomId, + }); + } + + async removeReaction(options: { emoji: string; eventId: string; roomId: string }): Promise { + await this.client.reactions.redact({ + eventId: options.eventId, + key: options.emoji, + roomId: options.roomId, + }); + } + + async typing(options: { roomId: string; timeoutMs?: number; typing?: boolean }): Promise { + await this.client.typing.set({ + roomId: options.roomId, + typing: options.typing ?? true, + ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), + }); + } + + debug(message: string, data?: unknown): void { + this.#log?.("debug", message, data); + } +} + +let currentRuntime: BeeperChannelRuntime | undefined; + +export function setBeeperChannelRuntime(runtime: BeeperChannelRuntime | undefined): void { + currentRuntime = runtime; +} + +export function getBeeperChannelRuntime(): BeeperChannelRuntime | undefined { + return currentRuntime; +} + +export function requireBeeperChannelRuntime(): BeeperChannelRuntime { + if (!currentRuntime) { + throw new Error("Beeper channel runtime is not available; start the Beeper bridge account first."); + } + return currentRuntime; +} + +function withReplyRelation(content: Record, replyToId: string | null | undefined): Record { + if (!replyToId) return content; + return { + ...content, + "m.relates_to": { + "m.in_reply_to": { + event_id: replyToId, + }, + }, + }; +} diff --git a/packages/openclaw/src/connector.test.ts b/packages/openclaw/src/connector.test.ts index 34c78d2..3aa8ac2 100644 --- a/packages/openclaw/src/connector.test.ts +++ b/packages/openclaw/src/connector.test.ts @@ -121,7 +121,7 @@ describe("OpenClawBridgeConnector", () => { config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), login: login(), registry, - runtime: runtimeWith({ responses: {} }), + runtime: runtimeWith({ responses: { "sessions.create": { key: "agent:codex:session_1" } } }), streams: { publish: vi.fn() }, }); await expect(api.resolveIdentifier({ bridge: { createPortal: vi.fn() } } as unknown as BridgeRequestContext, { @@ -145,16 +145,17 @@ describe("OpenClawBridgeConnector", () => { }); const createPortal = vi.fn(async () => ({ - id: "agent:codex", + id: "session:YWdlbnQ6Y29kZXg6c2Vzc2lvbl8x", metadata: { openclaw: { agentId: "codex", + label: "Codex", ghostUserId: "@codex:example.com", - sessionKey: "agent:codex", + sessionKey: "agent:codex:session_1", }, }, mxid: "!codex-dm:example.com", - portalKey: { id: "agent:codex", receiver: "login" }, + portalKey: { id: "session:YWdlbnQ6Y29kZXg6c2Vzc2lvbl8x", receiver: "login" }, receiver: "login", })); await expect(api.resolveIdentifier({ bridge: { createPortal } } as unknown as BridgeRequestContext, { @@ -175,15 +176,16 @@ describe("OpenClawBridgeConnector", () => { mxid: "@codex:example.com", }, portal: { - id: "agent:codex", + id: "session:YWdlbnQ6Y29kZXg6c2Vzc2lvbl8x", metadata: { openclaw: { agentId: "codex", ghostUserId: "@codex:example.com", - sessionKey: "agent:codex", + label: "Codex", + sessionKey: "agent:codex:session_1", }, }, - portalKey: { id: "agent:codex", receiver: "login" }, + portalKey: { id: "session:YWdlbnQ6Y29kZXg6c2Vzc2lvbl8x", receiver: "login" }, receiver: "login", roomType: "dm", mxid: "!codex-dm:example.com", @@ -192,12 +194,13 @@ describe("OpenClawBridgeConnector", () => { }); expect(createPortal).toHaveBeenCalledWith(login(), { creationContent: { "m.federate": false }, - id: "agent:codex", + id: "session:YWdlbnQ6Y29kZXg6c2Vzc2lvbl8x", metadata: { openclaw: { agentId: "codex", ghostUserId: "@codex:example.com", - sessionKey: "agent:codex", + label: "Codex", + sessionKey: "agent:codex:session_1", }, }, name: "Codex", @@ -206,7 +209,7 @@ describe("OpenClawBridgeConnector", () => { expect(registry.getBindingByRoom("!codex-dm:example.com")).toMatchObject({ agentId: "codex", roomId: "!codex-dm:example.com", - sessionKey: "agent:codex", + sessionKey: "agent:codex:session_1", }); }); @@ -234,7 +237,7 @@ describe("OpenClawBridgeConnector", () => { expect(createPortal).not.toHaveBeenCalled(); }); - it("reuses an existing agent DM portal instead of creating duplicate rooms", async () => { + 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({ @@ -252,10 +255,16 @@ describe("OpenClawBridgeConnector", () => { config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), login: login(), registry, - runtime: runtimeWith({ responses: {} }), + runtime: runtimeWith({ responses: { "sessions.create": { key: "agent:codex:session_2" } } }), streams: { publish: vi.fn() }, }); - const createPortal = vi.fn(); + 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, @@ -263,13 +272,14 @@ describe("OpenClawBridgeConnector", () => { type: "username", })).resolves.toMatchObject({ portal: { - id: "agent:codex", - mxid: "!existing-codex-dm:example.com", - portalKey: { id: "agent:codex", receiver: "openclaw:plugin" }, + id: "session:YWdlbnQ6Y29kZXg6c2Vzc2lvbl8y", + mxid: "!second-codex-dm:example.com", + portalKey: { id: "session:YWdlbnQ6Y29kZXg6c2Vzc2lvbl8y", receiver: "openclaw:plugin" }, }, userId: "@codex:example.com", }); - expect(createPortal).not.toHaveBeenCalled(); + expect(createPortal).toHaveBeenCalledOnce(); + expect(registry.getBindingsByAgent("codex")).toHaveLength(2); }); it("lists searchable OpenClaw agent contacts for Beeper contact lists", async () => { diff --git a/packages/openclaw/src/connector.ts b/packages/openclaw/src/connector.ts index 81f2d81..25874c7 100644 --- a/packages/openclaw/src/connector.ts +++ b/packages/openclaw/src/connector.ts @@ -37,9 +37,11 @@ import { } from "@beeper/pickle-bridge"; import { backfillAllOpenClawSessions, buildBackfillImport, discoverOneToOneSessions } from "./backfill"; import { parseApprovalResponseContent } from "./approval"; +import { BeeperChannelRuntime, setBeeperChannelRuntime } from "./beeper-channel-runtime"; import { OpenClawBeeperStreamPublisher } from "./beeper-stream"; import { agentPortalSessionKey, OpenClawMatrixBridgeAgent, type OpenClawBridgeStreamPublisher } from "./bridge-agent"; import { createDefaultConfig } from "./config"; +import { parseMatrixTextMessage, type ParsedMatrixTextMessage } from "./matrix-parser"; import { createOpenClawHostTransport, OpenClawGatewayRuntime, type OpenClawHostRuntime, type OpenClawMatrixMessageMetadata } from "./openclaw-runtime"; import { OpenClawBridgeRegistry } from "./registry"; import { agentContactFromOpenClawAgent, agentGhostUserId, serviceBotUserId } from "./rooms"; @@ -134,6 +136,12 @@ export class OpenClawBridgeConnector implements BridgeConnector this.registry.data.agents, + log: (level, message, data) => ctx.log(level, message, data), + ...(ownUserId ? { userId: ownUserId } : {}), + })); } async start(ctx: BridgeContext): Promise { @@ -180,6 +188,7 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor this.#registry = options.registry; this.#runtime = options.runtime; this.#agent = new OpenClawMatrixBridgeAgent({ + backgroundStreaming: true, registry: options.registry, runtime: options.runtime, streams: options.streams, @@ -220,7 +229,7 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor const contact = findAgentContact(this.#registry.data.agents, params.identifier); if (!contact) return {}; let portal = params.createDM - ? existingAgentPortal(this.#registry.getBindingBySessionKey(agentPortalSessionKey(contact.agentId)), this.#login.id) ?? portalForAgent(contact, this.#login.id) + ? await this.createSessionPortalForAgent(ctx, contact) : undefined; if (portal && params.createDM && !portal.mxid) { const portalOptions: Parameters[1] = { @@ -639,6 +648,15 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor label: labelParts.join(" ") || "Beeper", }; } + + private async createSessionPortalForAgent( + _ctx: BridgeRequestContext, + contact: OpenClawAgentContact, + label = contact.displayName, + ): Promise { + const session = await this.#runtime.createSession({ agentId: contact.agentId, label }); + return portalForAgentSession(contact, this.#login.id, session.key, label); + } } function commandNotice(ctx: BridgeRequestContext, login: UserLogin, msg: MatrixMessage, text: string): MatrixMessageResponse { @@ -770,16 +788,22 @@ function matrixMetadataFromParsed( return metadata; } -function portalForAgent(contact: OpenClawAgentContact, receiver: string): Portal { - const id = `agent:${contact.agentId}`; +function portalForAgentSession( + contact: OpenClawAgentContact, + receiver: string, + sessionKey: string, + label?: string, +): Portal { + const id = portalIdForSession(sessionKey); return { id, metadata: { - openclaw: { + openclaw: stripUndefined({ agentId: contact.agentId, ghostUserId: contact.ghostUserId, - sessionKey: agentPortalSessionKey(contact.agentId), - }, + ...(label ? { label } : {}), + sessionKey, + }), }, portalKey: { id, receiver }, receiver, @@ -797,25 +821,6 @@ function findAgentContact(contacts: readonly OpenClawAgentContact[], identifier: ); } -function existingAgentPortal(binding: OpenClawSessionBinding | undefined, receiver: string): Portal | undefined { - if (!binding) return undefined; - if (!binding.roomId) return undefined; - return { - id: `agent:${binding.agentId}`, - metadata: { - openclaw: { - agentId: binding.agentId, - ghostUserId: binding.ghostUserId, - sessionKey: binding.sessionKey, - }, - }, - mxid: binding.roomId, - portalKey: { id: `agent:${binding.agentId}`, receiver }, - receiver, - roomType: "dm", - }; -} - function portalIdForSession(sessionKey: string): string { return `session:${Buffer.from(sessionKey).toString("base64url")}`; } @@ -855,13 +860,15 @@ function bindingFromPortal(portal: Portal, config: OpenClawBridgeConfig): OpenCl const ghostUserId = stringValue(openclaw?.ghostUserId) ?? (agentId ? agentGhostUserId(config, agentId) : undefined); if (!roomId || !agentId || !sessionKey || !ghostUserId) return undefined; const now = Date.now(); + const label = stringValue(openclaw?.label); return { agentId, createdAt: now, ghostUserId, id: Buffer.from(roomId).toString("base64url"), kind: "session", - owner: portalId.startsWith("session:") ? "imported" : "bridge", + ...(label ? { label } : {}), + owner: openclaw ? "bridge" : "imported", roomId, sessionKey, updatedAt: now, @@ -965,159 +972,7 @@ function senderUserId(sender: unknown): string | undefined { return stringValue(recordValue(sender)?.userId); } -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, ""); -} +export { parseMatrixTextMessage, type ParsedMatrixTextMessage } from "./matrix-parser"; function stripUndefined>(input: T): T { for (const key of Object.keys(input)) { diff --git a/packages/openclaw/src/index.ts b/packages/openclaw/src/index.ts index 6154d27..6c5b5b3 100644 --- a/packages/openclaw/src/index.ts +++ b/packages/openclaw/src/index.ts @@ -1,12 +1,14 @@ export * from "./approval"; export * from "./appservice"; export * from "./backfill"; +export * from "./beeper-channel-runtime"; export * from "./beeper-stream"; export * from "./beeper-setup"; export * from "./bridge-agent"; export * from "./cli"; export * from "./config"; export * from "./connector"; +export * from "./matrix-parser"; export * from "./openclaw-event-map"; export * from "./openclaw-extension"; export * from "./openclaw-runtime"; diff --git a/packages/openclaw/src/integration.test.ts b/packages/openclaw/src/integration.test.ts index 8922342..eda4a5f 100644 --- a/packages/openclaw/src/integration.test.ts +++ b/packages/openclaw/src/integration.test.ts @@ -290,15 +290,15 @@ describe("OpenClaw bridge integration", () => { type: "username", }); expect(resolved.portal).toMatchObject({ - id: "agent:codex", + id: "session:c2Vzc2lvbl8x", mxid: "!created:example", - portalKey: { id: "agent:codex", receiver: login.id }, + portalKey: { id: "session:c2Vzc2lvbl8x", receiver: login.id }, }); expect(client.appservice.createPortalRoom).toHaveBeenCalledWith(expect.objectContaining({ creationContent: { "m.federate": false }, isDirect: true, name: "Codex", - portalKey: { id: "agent:codex", receiver: login.id }, + portalKey: { id: "session:c2Vzc2lvbl8x", receiver: login.id }, roomType: "dm", })); @@ -323,11 +323,11 @@ describe("OpenClaw bridge integration", () => { streamType: "com.beeper.llm", userId: "@openclawbot:example", })); - expect(client.beeper.streams.publishPart).toHaveBeenCalledWith(expect.objectContaining({ + await vi.waitFor(() => expect(client.beeper.streams.publishPart).toHaveBeenCalledWith(expect.objectContaining({ part: expect.objectContaining({ type: "CUSTOM" }), roomId: "!created:example", turnId: expect.any(String), - })); + }))); expect(client.beeper.streams.finalizeMessage).toHaveBeenCalledWith(expect.objectContaining({ content: expect.objectContaining({ "com.beeper.ai": expect.objectContaining({ diff --git a/packages/openclaw/src/matrix-parser.ts b/packages/openclaw/src/matrix-parser.ts new file mode 100644 index 0000000..3179822 --- /dev/null +++ b/packages/openclaw/src/matrix-parser.ts @@ -0,0 +1,175 @@ +import type { MatrixMessage } from "@beeper/pickle-bridge"; + +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 index 03a6cda..1a692d2 100644 --- a/packages/openclaw/src/openclaw-extension.test.ts +++ b/packages/openclaw/src/openclaw-extension.test.ts @@ -30,9 +30,15 @@ describe("OpenClaw plugin package metadata", () => { expect.objectContaining({ capabilities: expect.objectContaining({ reactions: true, - threads: true, + threads: false, }), id: "beeper", + message: expect.objectContaining({ + live: expect.objectContaining({ + capabilities: expect.objectContaining({ nativeStreaming: true }), + }), + }), + messaging: expect.any(Object), setup: expect.any(Object), setupWizard: expect.any(Object), }), diff --git a/packages/openclaw/src/openclaw-runtime.ts b/packages/openclaw/src/openclaw-runtime.ts index 3aa5bd0..6903d5b 100644 --- a/packages/openclaw/src/openclaw-runtime.ts +++ b/packages/openclaw/src/openclaw-runtime.ts @@ -38,6 +38,19 @@ export interface OpenClawHostRuntime { upsertSessionEntry?: (options: Record) => Promise | void; }; }; + channel?: { + reply?: { + dispatchReplyWithBufferedBlockDispatcher?: (params: Record) => Promise; + }; + session?: { + recordInboundSession?: (params: Record) => Promise | void; + resolveStorePath?: (store?: string, options?: Record) => string; + }; + turn?: { + buildContext?: (params: Record) => Record; + runAssembled?: (params: Record) => Promise; + }; + }; call?: (method: string, params?: unknown, options?: GatewayRequestOptions) => Promise; config?: { current?: () => unknown; @@ -753,112 +766,427 @@ async function sendSessionInPluginRuntime( const runId = `beeper:${randomUUID()}`; const cfg = runtime.config?.current?.(); const runEmbeddedAgent = runtime.agent?.runEmbeddedAgent ?? runtime.agent?.runEmbeddedPiAgent; - if (!runEmbeddedAgent) throw new Error("OpenClaw plugin runtime does not expose agent.runEmbeddedAgent"); - const workspaceDir = await resolvePluginWorkspaceDir(runtime, cfg, agentId); + if (!runEmbeddedAgent && !canRunNativeChannelTurn(runtime)) { + throw new Error("OpenClaw plugin runtime does not expose channel turn helpers or agent.runEmbeddedAgent"); + } const timeoutMs = options?.timeoutMs ?? numberValue(record.timeoutMs) ?? runtime.agent?.resolveAgentTimeoutMs?.({ cfg }) ?? 48 * 60 * 60 * 1000; - localEvents.emit({ event: "run.started", payload: { agentId, runId, sessionId, sessionKey } }); - let lastPartialText = ""; - let lastReasoningText = ""; - void runEmbeddedAgent(stripUndefined({ - agentId, - config: cfg, - currentMessageId: stringValue(record.idempotencyKey), + queuePluginRun(() => { + if (canRunNativeChannelTurn(runtime)) { + return runBeeperChannelTurnInPluginRuntime({ + agentId, + cfg, + localEvents, + message, + record, + runId, + runtime, + sessionFile, + sessionId, + sessionKey, + timeoutMs, + }); + } + return runEmbeddedAgentInPluginRuntime({ + agentId, + cfg, + localEvents, + message, + record, + runEmbeddedAgent: runEmbeddedAgent as (params: Record) => Promise, + runId, + runtime, + sessionFile, + sessionId, + sessionKey, + timeoutMs, + }); + }); + return { runId, sessionFile, sessionId, sessionKey }; +} + +function queuePluginRun(run: () => Promise): void { + setTimeout(() => { + void run().catch(() => { + // The runner emits run.failed with details. This catch keeps the timer + // task from surfacing an unhandled rejection in plugin hosts. + }); + }, 0); +} + +function canRunNativeChannelTurn(runtime: OpenClawHostRuntime): boolean { + return Boolean( + runtime.channel?.turn?.buildContext && + runtime.channel.turn.runAssembled && + 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 turn = params.runtime.channel?.turn; + const channelSession = params.runtime.channel?.session; + const channelReply = params.runtime.channel?.reply; + if (!turn?.buildContext || !turn.runAssembled || !channelSession?.recordInboundSession || !channelReply?.dispatchReplyWithBufferedBlockDispatcher) { + throw new Error("OpenClaw plugin runtime channel turn helpers are incomplete"); + } + + const sender = recordValue(recordValue(params.record.matrix)?.sender) ?? {}; + const matrix = recordValue(params.record.matrix) ?? {}; + const senderId = stringValue(matrix.sender) ?? stringValue(sender.id) ?? "beeper"; + 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 = turn.buildContext({ + channel: "beeper", + accountId: "beeper", + 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: "beeper", + 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), + sourceReplyDeliveryMode: "direct", + }, + message: { + body: params.message, + rawBody: params.message, + bodyForAgent: params.message, + commandBody: params.message, + envelopeFrom: senderId, + senderLabel: senderId, + preview: params.message.slice(0, 280), + }, + 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 emit = createBeeperReplyEventEmitter(params.localEvents, { + agentId: params.agentId, + runId: params.runId, + sessionId: params.sessionId, + sessionKey: params.sessionKey, + }); + params.localEvents.emit({ event: "run.started", payload: { agentId: params.agentId, runId: params.runId, sessionId: params.sessionId, sessionKey: params.sessionKey } }); + try { + await turn.runAssembled({ + cfg: params.cfg, + channel: "beeper", + accountId: "beeper", + agentId: params.agentId, + routeSessionKey: params.sessionKey, + storePath, + ctxPayload, + recordInboundSession: channelSession.recordInboundSession, + dispatchReplyWithBufferedBlockDispatcher: channelReply.dispatchReplyWithBufferedBlockDispatcher, + delivery: { + deliver: async (payload: unknown) => { + emit.textPayload(payload); + return { visibleReplySent: true }; + }, + onError: (error: unknown) => { + 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, + timeoutOverrideSeconds: Math.max(1, Math.ceil(params.timeoutMs / 1000)), + suppressDefaultToolProgressMessages: true, + allowProgressCallbacksWhenSourceDeliverySuppressed: true, + onAssistantMessageStart: emit.assistantMessageStart, + onBlockReply: emit.textPayload, + onBlockReplyQueued: emit.textPayload, + onPartialReply: emit.textPayload, + onReasoningEnd: emit.reasoningEnd, + onReasoningStream: emit.reasoningPayload, + onToolStart: emit.toolStart, + onToolResult: emit.toolResult, + onItemEvent: emit.itemEvent, + onPlanUpdate: emit.planUpdate, + onApprovalEvent: emit.approvalEvent, + onCommandOutput: emit.commandOutput, + onPatchSummary: emit.patchSummary, + onCompactionStart: () => emit.itemEvent({ kind: "compaction", phase: "start", title: "Compacting context" }), + onCompactionEnd: () => emit.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: "beeper", + }, + }, + messageId: eventId, + }); + params.localEvents.emit({ event: "run.completed", payload: { agentId: params.agentId, runId: params.runId, sessionId: params.sessionId, sessionKey: params.sessionKey } }); + } catch (error) { + params.localEvents.emit({ event: "run.failed", payload: { agentId: params.agentId, error: errorText(error), runId: params.runId, sessionId: params.sessionId, sessionKey: params.sessionKey } }); + } +} + +async function runEmbeddedAgentInPluginRuntime(params: { + agentId: string; + cfg: unknown; + localEvents: LocalEventBus; + message: string; + record: Record; + runEmbeddedAgent: (params: Record) => Promise; + runId: string; + runtime: OpenClawHostRuntime; + sessionFile: string; + sessionId: string; + sessionKey: string; + timeoutMs: number; +}): Promise { + params.localEvents.emit({ event: "run.started", payload: { agentId: params.agentId, runId: params.runId, sessionId: params.sessionId, sessionKey: params.sessionKey } }); + const emit = createBeeperReplyEventEmitter(params.localEvents, { + agentId: params.agentId, + runId: params.runId, + sessionId: params.sessionId, + sessionKey: params.sessionKey, + }); + await params.runEmbeddedAgent(stripUndefined({ + agentId: params.agentId, + config: params.cfg, + currentMessageId: stringValue(params.record.idempotencyKey), messageChannel: "beeper", messageProvider: "beeper", - prompt: message, - runId, - sessionFile, - sessionId, - sessionKey, - timeoutMs, + prompt: params.message, + runId: params.runId, + sessionFile: params.sessionFile, + sessionId: params.sessionId, + sessionKey: params.sessionKey, + timeoutMs: params.timeoutMs, trigger: "user", - workspaceDir, - agentDir: runtime.agent?.resolveAgentDir?.(cfg, agentId), + workspaceDir: await resolvePluginWorkspaceDir(params.runtime, params.cfg, params.agentId), + agentDir: params.runtime.agent?.resolveAgentDir?.(params.cfg, params.agentId), onAgentEvent: (event: OpenClawAgentRuntimeEvent) => { const data = recordValue(event.data) ?? {}; - localEvents.emit(stripUndefined({ + params.localEvents.emit(stripUndefined({ event: event.stream, payload: stripUndefined({ ...data, - runId: stringValue(data.runId) ?? runId, - sessionKey: event.sessionKey ?? stringValue(data.sessionKey) ?? sessionKey, + runId: stringValue(data.runId) ?? params.runId, + sessionKey: event.sessionKey ?? stringValue(data.sessionKey) ?? params.sessionKey, }), seq: numberValue(data.seq), })); }, - onAssistantMessageStart: () => { - lastPartialText = ""; - localEvents.emit({ event: "assistant.message.start", payload: { agentId, runId, sessionId, sessionKey } }); + onAssistantMessageStart: emit.assistantMessageStart, + onBlockReply: emit.textPayload, + onBlockReplyQueued: emit.textPayload, + onPartialReply: emit.textPayload, + onReasoningEnd: emit.reasoningEnd, + onReasoningStream: emit.reasoningPayload, + onToolResult: emit.toolResult, + })).then( + (result) => { + emit.finalText(finalTextFromEmbeddedRunResult(result)); + params.localEvents.emit({ event: "run.completed", payload: { agentId: params.agentId, runId: params.runId, sessionId: params.sessionId, sessionKey: params.sessionKey } }); }, - onBlockReply: (payload: unknown) => { - const text = stringValue(recordValue(payload)?.text); - if (!text) return; - const delta = text.startsWith(lastPartialText) ? text.slice(lastPartialText.length) : text; - lastPartialText = text; - if (!delta) return; - localEvents.emit({ event: "assistant.delta", payload: { agentId, delta, runId, sessionId, sessionKey, text } }); + (error) => { + params.localEvents.emit({ event: "run.failed", payload: { agentId: params.agentId, error: errorText(error), runId: params.runId, sessionId: params.sessionId, sessionKey: params.sessionKey } }); }, - onBlockReplyQueued: (payload: unknown) => { - const text = stringValue(recordValue(payload)?.text); - if (!text) return; - const delta = text.startsWith(lastPartialText) ? text.slice(lastPartialText.length) : text; - lastPartialText = text; - if (!delta) return; - localEvents.emit({ event: "assistant.delta", payload: { agentId, delta, runId, sessionId, sessionKey, text } }); + ); +} + +function createBeeperReplyEventEmitter(localEvents: LocalEventBus, base: { + agentId: string; + runId: string; + sessionId: string; + sessionKey: string; +}) { + let lastPartialText = ""; + let lastReasoningText = ""; + const emit = (event: string, payload: Record) => { + localEvents.emit({ event, payload: stripUndefined({ ...base, ...payload }) }); + }; + const textPayload = (payload: unknown) => { + const text = stringValue(recordValue(payload)?.text); + if (!text) return; + const explicitDelta = stringValue(recordValue(payload)?.delta); + const delta = explicitDelta ?? (text.startsWith(lastPartialText) ? text.slice(lastPartialText.length) : text); + lastPartialText = text; + if (delta) emit("assistant.delta", { delta, text }); + }; + const reasoningPayload = (payload: unknown) => { + const text = stringValue(recordValue(payload)?.text); + if (!text) return; + const explicitDelta = stringValue(recordValue(payload)?.delta); + const delta = explicitDelta ?? (text.startsWith(lastReasoningText) ? text.slice(lastReasoningText.length) : text); + lastReasoningText = text; + if (delta) emit("thinking.delta", { delta, text }); + }; + const toolIdFor = (payload: Record, fallback: string) => + stringValue(payload.toolCallId) ?? stringValue(payload.itemId) ?? stringValue(payload.approvalId) ?? fallback; + return { + assistantMessageStart: () => { + lastPartialText = ""; + emit("assistant.message.start", {}); }, - onPartialReply: (payload: unknown) => { - const text = stringValue(recordValue(payload)?.text); + finalText: (text: string | undefined) => { if (!text) return; - const explicitDelta = stringValue(recordValue(payload)?.delta); - const delta = explicitDelta ?? (text.startsWith(lastPartialText) ? text.slice(lastPartialText.length) : text); - lastPartialText = text; - if (!delta) return; - localEvents.emit({ event: "assistant.delta", payload: { agentId, delta, runId, sessionId, sessionKey, text } }); + textPayload({ text }); }, - onReasoningEnd: () => { - localEvents.emit({ event: "thinking.end", payload: { agentId, runId, sessionId, sessionKey } }); + reasoningEnd: () => emit("thinking.end", {}), + reasoningPayload, + textPayload, + toolStart: (payload: unknown) => { + const data = recordValue(payload) ?? {}; + emit("tool.call.started", { + args: data.args, + input: data.args, + phase: stringValue(data.phase), + toolCallId: toolIdFor(data, `tool:${stringValue(data.name) ?? "tool"}`), + toolName: stringValue(data.name), + }); }, - onReasoningStream: (payload: unknown) => { - const text = stringValue(recordValue(payload)?.text); - if (!text) return; - const explicitDelta = stringValue(recordValue(payload)?.delta); - const delta = explicitDelta ?? (text.startsWith(lastReasoningText) ? text.slice(lastReasoningText.length) : text); - lastReasoningText = text; - if (!delta) return; - localEvents.emit({ event: "thinking.delta", payload: { agentId, delta, runId, sessionId, sessionKey, text } }); + toolResult: (payload: unknown) => { + const data = recordValue(payload) ?? {}; + emit("tool.call.completed", { + output: data.text ?? data.content ?? payload, + toolCallId: toolIdFor(data, "tool_result"), + toolName: stringValue(data.toolName) ?? stringValue(data.name), + }); }, - onToolResult: (payload: unknown) => { - const record = recordValue(payload) ?? {}; - localEvents.emit({ - event: "tool.call.completed", - payload: stripUndefined({ - agentId, - output: record.text ?? record.content ?? payload, - runId, - sessionId, - sessionKey, - toolCallId: stringValue(record.toolCallId) ?? stringValue(record.id) ?? "tool_result", - toolName: stringValue(record.toolName) ?? stringValue(record.name), - }), + itemEvent: (payload: unknown) => { + const data = recordValue(payload) ?? {}; + emit("tool.call.delta", { + delta: stringValue(data.progressText) ?? stringValue(data.summary) ?? stringValue(data.status) ?? stringValue(data.phase), + inputTextDelta: stringValue(data.progressText) ?? stringValue(data.summary) ?? stringValue(data.status) ?? stringValue(data.phase), + toolCallId: toolIdFor(data, `item:${stringValue(data.name) ?? stringValue(data.kind) ?? "work"}`), + toolName: stringValue(data.name) ?? stringValue(data.kind), }); }, - })).then( - (result) => { - const finalText = finalTextFromEmbeddedRunResult(result); - if (finalText) { - const delta = finalText.startsWith(lastPartialText) ? finalText.slice(lastPartialText.length) : finalText; - lastPartialText = finalText; - if (delta) { - localEvents.emit({ event: "assistant.delta", payload: { agentId, delta, runId, sessionId, sessionKey, text: finalText } }); - } + planUpdate: (payload: unknown) => { + const data = recordValue(payload) ?? {}; + emit("tool.call.delta", { + delta: stringValue(data.title) ?? stringValue(data.explanation) ?? stringValue(data.phase), + inputTextDelta: stringValue(data.title) ?? stringValue(data.explanation) ?? stringValue(data.phase), + toolCallId: "plan", + toolName: "plan", + }); + }, + approvalEvent: (payload: unknown) => { + const data = recordValue(payload) ?? {}; + const phase = stringValue(data.phase); + if (phase === "requested") { + emit("approval.requested", { + approvalId: stringValue(data.approvalId) ?? stringValue(data.approvalSlug), + message: stringValue(data.message) ?? stringValue(data.reason) ?? stringValue(data.title), + toolCallId: stringValue(data.toolCallId) ?? stringValue(data.itemId), + toolName: stringValue(data.kind) ?? stringValue(data.command), + }); + return; + } + if (phase === "resolved" || phase === "complete" || stringValue(data.status)) { + emit("approval.resolved", { + approvalId: stringValue(data.approvalId) ?? stringValue(data.approvalSlug), + approved: stringValue(data.status) === "approved" || stringValue(data.status) === "allow", + decision: stringValue(data.status), + toolCallId: stringValue(data.toolCallId) ?? stringValue(data.itemId), + }); } - localEvents.emit({ event: "run.completed", payload: { agentId, runId, sessionId, sessionKey } }); }, - (error) => { - localEvents.emit({ event: "run.failed", payload: { agentId, error: errorText(error), runId, sessionId, sessionKey } }); + commandOutput: (payload: unknown) => { + const data = recordValue(payload) ?? {}; + const complete = stringValue(data.phase) === "complete" || stringValue(data.status) === "complete"; + emit("tool.call.completed", { + output: stringValue(data.output) ?? data, + preliminary: !complete, + toolCallId: toolIdFor(data, `command:${stringValue(data.name) ?? "output"}`), + toolName: stringValue(data.name) ?? stringValue(data.title) ?? "command", + }); }, - ); - return { runId, sessionFile, sessionId, sessionKey }; + patchSummary: (payload: unknown) => { + const data = recordValue(payload) ?? {}; + emit("tool.call.completed", { + output: data.summary ?? data, + toolCallId: toolIdFor(data, "patch"), + toolName: stringValue(data.name) ?? "patch", + }); + }, + }; +} + +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 finalTextFromEmbeddedRunResult(result: unknown): string | undefined { diff --git a/packages/openclaw/src/setup.test.ts b/packages/openclaw/src/setup.test.ts index fa34454..6f3e6d2 100644 --- a/packages/openclaw/src/setup.test.ts +++ b/packages/openclaw/src/setup.test.ts @@ -1,6 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import extension from "./openclaw-extension"; import setupEntry from "./setup-entry"; +import { + BeeperChannelRuntime, + setBeeperChannelRuntime, +} from "./beeper-channel-runtime"; import { applyBeeperChannelSettings, beeperChannelConfig, @@ -27,6 +31,7 @@ describe("OpenClaw Beeper setup surface", () => { beforeEach(() => { appserviceMocks.accountFromOpenClawConfig.mockClear(); appserviceMocks.startOpenClawBeeperBridge.mockReset(); + setBeeperChannelRuntime(undefined); }); it("exposes a channel plugin through the setup entry shape OpenClaw loads", () => { @@ -40,7 +45,7 @@ describe("OpenClaw Beeper setup surface", () => { capabilities: { media: true, reactions: true, - threads: true, + threads: false, }, reload: { configPrefixes: ["channels.beeper", "plugins.entries.beeper"], @@ -68,7 +73,7 @@ describe("OpenClaw Beeper setup surface", () => { expect(beeperChannelPlugin.setupWizard).toBe(beeperSetupWizard); }); - it("matches the OpenClaw channel contract surface used by the dashboard and runtime", () => { + 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), @@ -78,8 +83,107 @@ describe("OpenClaw Beeper setup surface", () => { selectionLabel: expect.any(String), })); expect(beeperChannelPlugin.capabilities.chatTypes).toEqual( - expect.arrayContaining(["direct", "thread"]), + ["direct"], ); + 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.actions).toEqual(expect.any(Object)); + expect(beeperChannelPlugin.actions.describeMessageTool()).toMatchObject({ + actions: ["send", "edit", "delete", "react"], + capabilities: ["media", "replyTo", "reactions"], + }); + expect(beeperChannelPlugin.actions.extractToolSend({ + args: { action: "send", threadId: "$thread", to: "beeper:!room" }, + })).toEqual({ threadId: "$thread", to: "beeper:!room" }); + expect(beeperChannelPlugin.agentPrompt).toEqual(expect.objectContaining({ + inboundFormattingHints: expect.any(Function), + messageToolCapabilities: expect.any(Function), + reactionGuidance: expect.any(Function), + })); expect(beeperChannelPlugin.config).toEqual(expect.objectContaining({ describeAccount: expect.any(Function), hasConfiguredState: expect.any(Function), @@ -535,6 +639,96 @@ describe("OpenClaw Beeper setup surface", () => { }); }); + it("routes OpenClaw message actions through the active Beeper runtime", async () => { + const client = { + appservice: { sendMessage: vi.fn(async () => ({ eventId: "$as" })) }, + 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) }, + }; + setBeeperChannelRuntime(new BeeperChannelRuntime({ + client: client as never, + getAgents: () => [{ + avatarMxc: "mxc://avatar", + description: "Helpful coding agent", + agentId: "codex", + displayName: "Codex", + ghostUserId: "@codex:example", + }], + })); + + await expect(beeperChannelPlugin.actions.handleAction({ + action: "send", + params: { message: "hello", replyTo: "$parent", to: "!room" }, + })).resolves.toEqual({ content: [{ type: "text", text: "Sent Beeper message $send" }] }); + expect(client.messages.send).toHaveBeenCalledWith({ + content: { body: "hello", msgtype: "m.text" }, + replyTo: "$parent", + roomId: "!room", + text: "hello", + }); + + await beeperChannelPlugin.actions.handleAction({ + action: "send", + mediaReadFile: async () => Buffer.from("file"), + params: { mediaUrl: "/tmp/a.txt", text: "caption", to: "!room" }, + }); + expect(client.messages.sendMedia).toHaveBeenCalledWith({ + bytes: Buffer.from("file"), + caption: "caption", + filename: "a.txt", + kind: "file", + roomId: "!room", + }); + + await beeperChannelPlugin.actions.handleAction({ + action: "edit", + params: { eventId: "$event", text: "updated", to: "!room" }, + }); + expect(client.messages.edit).toHaveBeenCalledWith({ eventId: "$event", roomId: "!room", text: "updated" }); + + await beeperChannelPlugin.actions.handleAction({ + action: "react", + params: { eventId: "$event", key: "+1", to: "!room" }, + }); + expect(client.reactions.send).toHaveBeenCalledWith({ eventId: "$event", key: "+1", roomId: "!room" }); + + await beeperChannelPlugin.actions.handleAction({ + action: "delete", + params: { eventId: "$event", reason: "cleanup", to: "!room" }, + }); + expect(client.messages.redact).toHaveBeenCalledWith({ eventId: "$event", reason: "cleanup", roomId: "!room" }); + + await beeperChannelPlugin.heartbeat.sendTyping({ to: "!room" }); + expect(client.typing.set).toHaveBeenCalledWith({ roomId: "!room", typing: true }); + + 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("reads plugin-entry channel config with channels.beeper taking precedence", () => { expect(getBeeperChannelSettings({ channels: { diff --git a/packages/openclaw/src/setup.ts b/packages/openclaw/src/setup.ts index 01e4da3..e2d117b 100644 --- a/packages/openclaw/src/setup.ts +++ b/packages/openclaw/src/setup.ts @@ -1,5 +1,6 @@ import { createConfigFromOpenClawSetup, DEFAULT_REGISTRATION_URL, defaultDataDir } from "./config"; import type { setupOpenClawBeeperBridge, SetupOpenClawBeeperBridgeOptions } from "./beeper-setup"; +import { requireBeeperChannelRuntime } from "./beeper-channel-runtime"; import type { OpenClawHostRuntime } from "./openclaw-runtime"; export type OpenClawSetupConfig = { @@ -186,6 +187,410 @@ export const BeeperChannelUiHints = { }, } 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: true, + 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: () => "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 sessionKey = [ + "agent", + params.agentId, + BEEPER_CHANNEL_ID, + params.accountId ?? "default", + "direct", + target, + ].join(":"); + return { + baseSessionKey: sessionKey, + chatType: "direct", + from: `beeper:${target}`, + peer: { kind: "direct", 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 }; nowMs: number }) => ({ + body: `Approval requested: ${request.command ?? request.id ?? request.approvalId ?? "OpenClaw tool call"}`, + channelData: { + beeper: { + approvalId: request.approvalId ?? request.id, + createdAt: nowMs, + kind: "exec", + }, + }, + }), + }, + }, +} as const; + +export const beeperMessageActions = { + resolveExecutionMode: () => "gateway", + describeMessageTool: () => ({ + actions: ["send", "edit", "delete", "react"], + capabilities: ["media", "replyTo", "reactions"], + }), + supportsAction: ({ action }: { action: string }) => + action === "send" || action === "edit" || action === "delete" || action === "react", + extractToolSend: ({ args }: { args: Record }) => { + const action = stringValue(args.action)?.trim(); + if (action !== "send" && action !== "sendMessage") return null; + const to = stringValue(args.to); + if (!to) return null; + const accountId = stringValue(args.accountId); + const threadId = stringValue(args.threadId); + return stripUndefined({ accountId, threadId, to }); + }, + handleAction: async (ctx: { action: string; params: Record; mediaReadFile?: (filePath: string) => Promise }) => { + const runtime = requireBeeperChannelRuntime(); + const params = ctx.params; + const roomId = resolveBeeperRoomTarget(readRequiredString(params, "to", "roomId", "channelId")); + if (ctx.action === "send") { + const mediaUrl = stringValue(params.media) ?? stringValue(params.mediaUrl) ?? stringValue(params.filePath) ?? stringValue(params.path); + const text = stringValue(params.message) ?? stringValue(params.text) ?? ""; + const replyToId = stringValue(params.replyTo) ?? stringValue(params.replyToId); + if (mediaUrl) { + const bytes = ctx.mediaReadFile ? await ctx.mediaReadFile(mediaUrl) : undefined; + const filename = mediaUrl.split("/").pop(); + const sent = await runtime.sendMedia({ + roomId, + ...(bytes !== undefined ? { bytes } : {}), + ...(text ? { caption: text } : {}), + ...(filename ? { filename } : {}), + ...(bytes === undefined ? { path: mediaUrl } : {}), + }); + return { content: [{ type: "text", text: `Sent Beeper media ${sent.eventId}` }] }; + } + const sent = await runtime.sendText({ + roomId, + text, + ...(replyToId ? { replyToId } : {}), + }); + return { content: [{ type: "text", text: `Sent Beeper message ${sent.eventId}` }] }; + } + if (ctx.action === "edit") { + const eventId = readRequiredString(params, "messageId", "eventId"); + const text = readRequiredString(params, "message", "text"); + const sent = await runtime.edit({ eventId, roomId, text }); + return { content: [{ type: "text", text: `Edited Beeper message ${sent.eventId}` }] }; + } + if (ctx.action === "delete") { + const eventId = readRequiredString(params, "messageId", "eventId"); + const reason = stringValue(params.reason); + await runtime.redact({ + eventId, + roomId, + ...(reason !== undefined ? { reason } : {}), + }); + return { content: [{ type: "text", text: `Deleted Beeper message ${eventId}` }] }; + } + if (ctx.action === "react") { + const eventId = readRequiredString(params, "messageId", "eventId"); + const emoji = readRequiredString(params, "emoji", "reaction", "key"); + const remove = params.remove === true; + if (remove) { + await runtime.removeReaction({ emoji, eventId, roomId }); + return { content: [{ type: "text", text: `Removed Beeper reaction ${emoji}` }] }; + } + const sent = await runtime.react({ emoji, eventId, roomId }); + return { content: [{ type: "text", text: `Sent Beeper reaction ${sent.eventId}` }] }; + } + throw new Error(`Unsupported Beeper message action: ${ctx.action}`); + }, +} 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: () => ["nativeStreaming", "replyTo", "reactions"], + reactionGuidance: () => ({ channelLabel: "Beeper", level: "minimal" as const }), +} as const; + export const beeperSetupAdapter = { resolveAccountId: () => "default", resolveBindingAccountId: () => "default", @@ -489,16 +894,37 @@ export const beeperChannelPlugin = { quickstartAllowFrom: true, }, capabilities: { - chatTypes: ["direct", "thread"], + chatTypes: ["direct"], media: true, reactions: true, - threads: true, + threads: false, }, reload: { configPrefixes: ["channels.beeper", "plugins.entries.beeper"] }, configSchema: BeeperChannelConfigSchema, uiHints: BeeperChannelUiHints, config: beeperChannelConfig, status: beeperStatusAdapter, + conversationBindings: beeperConversationBindings, + message: beeperMessageAdapter, + messaging: beeperMessagingAdapter, + outbound: beeperOutboundAdapter, + directory: beeperDirectoryAdapter, + resolver: beeperResolverAdapter, + heartbeat: beeperHeartbeatAdapter, + approvalCapability: beeperApprovalCapability, + actions: beeperMessageActions, + agentPrompt: beeperAgentPromptAdapter, + bindings: { + selfParentConversationByDefault: true, + compileConfiguredBinding: ({ conversationId }: { conversationId: string }) => conversationId, + matchInboundConversation: ({ compiledBinding, conversationId }: { compiledBinding: string; conversationId: string }) => + compiledBinding === conversationId, + resolveCommandConversation: ({ originatingTo, commandTo, fallbackTo }: { + originatingTo?: string; + commandTo?: string; + fallbackTo?: string; + }) => commandTo ?? originatingTo ?? fallbackTo, + }, gateway: { startAccount: startBeeperGatewayAccount, stopAccount: stopBeeperGatewayAccount, @@ -507,6 +933,159 @@ export const beeperChannelPlugin = { setupWizard: beeperSetupWizard, }; +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 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; + raw: unknown; +} { + return { + messageId: result.messageId, + 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 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); +} + +function listLiveOrConfiguredAgentDirectoryEntries( + cfg: OpenClawSetupConfig, + query?: string | null, + limit?: number | null, +): Array<{ kind: "user"; id: string; name?: string; handle?: string; avatarUrl?: string; description?: string; raw?: unknown }> { + const runtimeAgents = (() => { + try { + return requireBeeperChannelRuntime().listAgents(); + } catch { + return []; + } + })(); + if (runtimeAgents.length === 0) return listConfiguredAgentDirectoryEntries(cfg, query, limit); + const normalizedQuery = query?.trim().toLowerCase(); + return runtimeAgents.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); +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + export async function startBeeperGatewayAccount(ctx: BeeperGatewayContext): Promise { const settings = getBeeperChannelSettings(ctx.cfg); if (settings.enabled === false) { diff --git a/packages/openclaw/tsdown.config.ts b/packages/openclaw/tsdown.config.ts index 0e98f53..860e805 100644 --- a/packages/openclaw/tsdown.config.ts +++ b/packages/openclaw/tsdown.config.ts @@ -6,6 +6,6 @@ export default defineConfig({ alwaysBundle: [/^@beeper\//], }, dts: true, - entry: ["src/approval.ts", "src/appservice.ts", "src/backfill.ts", "src/beeper-stream.ts", "src/beeper-setup.ts", "src/bridge-agent.ts", "src/cli.ts", "src/config.ts", "src/connector.ts", "src/index.ts", "src/openclaw-event-map.ts", "src/openclaw-extension.ts", "src/openclaw-runtime.ts", "src/plugin-entry.ts", "src/protocol-coverage.ts", "src/registry.ts", "src/registration.ts", "src/rooms.ts", "src/serial.ts", "src/setup.ts", "src/setup-entry.ts", "src/stream-map.ts", "src/types.ts"], + entry: ["src/approval.ts", "src/appservice.ts", "src/backfill.ts", "src/beeper-channel-runtime.ts", "src/beeper-stream.ts", "src/beeper-setup.ts", "src/bridge-agent.ts", "src/cli.ts", "src/config.ts", "src/connector.ts", "src/index.ts", "src/matrix-parser.ts", "src/openclaw-event-map.ts", "src/openclaw-extension.ts", "src/openclaw-runtime.ts", "src/plugin-entry.ts", "src/protocol-coverage.ts", "src/registry.ts", "src/registration.ts", "src/rooms.ts", "src/serial.ts", "src/setup.ts", "src/setup-entry.ts", "src/stream-map.ts", "src/types.ts"], format: ["esm"], }); From b33f1c2d70a374043e0a79ca485556f8026e2425 Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Mon, 25 May 2026 15:12:50 +0200 Subject: [PATCH 32/43] Require native OpenClaw channel turns for Beeper streaming --- packages/bridge/src/bridge.test.ts | 91 ++++++- packages/bridge/src/bridge.ts | 175 ++++++++++++++ packages/openclaw/src/approval.test.ts | 25 +- packages/openclaw/src/approval.ts | 82 ++++++- .../src/beeper-channel-runtime.test.ts | 125 ++++++---- .../openclaw/src/beeper-channel-runtime.ts | 223 ++++++++++++++---- packages/openclaw/src/beeper-stream.test.ts | 28 +++ packages/openclaw/src/beeper-stream.ts | 14 +- packages/openclaw/src/connector.ts | 4 + packages/openclaw/src/integration.test.ts | 12 +- .../openclaw/src/openclaw-event-map.test.ts | 4 +- .../openclaw/src/openclaw-runtime.test.ts | 88 ++++--- packages/openclaw/src/openclaw-runtime.ts | 164 +++---------- packages/openclaw/src/setup.test.ts | 151 ++++++++---- packages/openclaw/src/setup.ts | 41 +++- packages/openclaw/src/stream-map.ts | 3 +- 16 files changed, 911 insertions(+), 319 deletions(-) diff --git a/packages/bridge/src/bridge.test.ts b/packages/bridge/src/bridge.test.ts index c07c283..c60a47b 100644 --- a/packages/bridge/src/bridge.test.ts +++ b/packages/bridge/src/bridge.test.ts @@ -309,6 +309,84 @@ describe("RuntimeBridge", () => { }); }); + it("handles queued remote edits, reactions, deletes, 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" }, + 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" }), + getTimeoutMs: () => 5000, + getType: () => "typing", + isTyping: () => true, + }); + await bridge.flushRemoteEvents(); + + expect(client.messages.edit).toHaveBeenCalledWith({ + content: { body: "edited remote", msgtype: "m.text" }, + eventId: "$sent", + roomId: "!room:example", + text: "edited remote", + }); + 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.typing.set).toHaveBeenCalledWith({ roomId: "!room:example", timeoutMs: 5000, typing: true }); + }); + it("initializes appservice and creates/backfills portal rooms", async () => { const client = createFakeMatrixClient(); const connector = createFakeConnector(createFakeNetworkAPI()); @@ -1118,18 +1196,21 @@ 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(), + redact: vi.fn(async () => undefined), send: vi.fn(), 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"], + reactions: { + redact: vi.fn(async () => undefined), + send: vi.fn(async (options) => ({ eventId: "$reaction", raw: {}, roomId: options.roomId })), + }, receipts: {} as MatrixClient["receipts"], rooms: {} as MatrixClient["rooms"], streams: {} as MatrixClient["streams"], @@ -1137,7 +1218,9 @@ function createFakeMatrixClient(): MatrixClient & { subscription: MatrixSubscrip 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 a7afc80..0934bc1 100644 --- a/packages/bridge/src/bridge.ts +++ b/packages/bridge/src/bridge.ts @@ -54,6 +54,7 @@ import type { RemoteBackfill, RemoteChatDelete, RemoteChatInfoChange, + RemoteEdit, UserProfile, UserProfileUpdate, ResolveIdentifierParams, @@ -80,6 +81,13 @@ import type { MessageCheckpointStep, HTTPProxyHandlingBridgeConnector, LoginStep, + Message, + RemoteMessageRemove, + RemoteReaction, + RemoteReactionRemove, + RemoteTyping, + RemoteEventWithBundledParts, + RemoteEventWithTargetPart, } from "./types"; type GenericMatrixEvent = Extract; kind: string }>; @@ -1262,6 +1270,26 @@ 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 === "typing") { + await this.#handleRemoteTyping(event as RemoteTyping); + return; + } if (type === "backfill") { await this.#handleRemoteBackfill(event as RemoteBackfill); return; @@ -1307,6 +1335,94 @@ 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); + for (const [index, part] of converted.modifiedParts.entries()) { + const target = this.#matchingRemoteTarget(existing.sent, part.id, index); + if (!target?.eventId) continue; + const sent = await this.#matrixClient.messages.edit({ + content: part.content, + eventId: target.eventId, + roomId: portal.mxid, + text: stringValue(part.content.body) ?? "", + }); + const messageKey = messagePartKey(event.getTargetMessage(), part.id ?? String(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 #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; @@ -1367,6 +1483,52 @@ export class RuntimeBridge implements PickleBridge { }; } + async #remoteTargetMessage(event: RemoteEdit | RemoteReaction | RemoteReactionRemove | RemoteMessageRemove): Promise { + 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]; + } + async #sendRemoteMessagePart(roomId: string, sender: string, content: Record, timestamp?: number): Promise { if (this.#appserviceOptions && sender.startsWith("@")) { const sendOptions = stripUndefined({ @@ -1472,6 +1634,10 @@ function hasMethod(value: object, method: T): value is object 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 appserviceBotUserId(options: MatrixAppserviceInitOptions): string { return `@${options.registration.senderLocalpart}:${options.homeserverDomain}`; } @@ -1627,6 +1793,15 @@ function messagePartKey(messageId: string, partId: string): string { return `${messageId}\u0000${partId}`; } +function messageFromSentEvent(messageId: string, partId: string, sent: SentEvent): Message { + return { + id: messageId, + mxid: sent.eventId, + partId, + timestamp: new Date(), + }; +} + 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; diff --git a/packages/openclaw/src/approval.test.ts b/packages/openclaw/src/approval.test.ts index 309067b..8198ebb 100644 --- a/packages/openclaw/src/approval.test.ts +++ b/packages/openclaw/src/approval.test.ts @@ -117,7 +117,30 @@ describe("OpenClaw approval response parsing", () => { messageId: "msg_1", toolCallId: "call_1", toolName: "shell", - })).toEqual({ + })).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-session", id: "allow-session", reactionKey: "approval.allow_session", title: "Allow This Session", variant: "secondary" }, + { decision: "allow-room", id: "allow-room", reactionKey: "approval.allow_room", title: "Allow This Room", variant: "secondary" }, + { decision: "deny", id: "deny", reactionKey: "approval.deny", title: "Cancel", variant: "destructive" }, + ], + id: "approval_1", + }, + state: "approval-requested", + toolCallId: "call_1", + toolName: "shell", + type: "dynamic-tool", + }], + role: "assistant", + }, choices: [ { alias: "✅", key: "approve", label: "Allow once" }, { alias: "☑️", key: "always_approve", label: "Allow always" }, diff --git a/packages/openclaw/src/approval.ts b/packages/openclaw/src/approval.ts index d909529..504ad6d 100644 --- a/packages/openclaw/src/approval.ts +++ b/packages/openclaw/src/approval.ts @@ -57,6 +57,16 @@ export function defaultBeeperApprovalChoices(): BeeperApprovalChoice[] { ]; } +export function defaultBeeperApprovalActions(decisions: readonly ApprovalDecision[] = ["allow_once", "allow_session", "allow_room", "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 { const aiBridgeChoice = resolveBeeperApprovalChoiceKey(key); if (aiBridgeChoice) { @@ -144,18 +154,56 @@ export function approvalChoicesAsAny(choices: readonly BeeperApprovalChoice[] = 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, + }), + input: stripUndefined({ + ...(params.input ?? {}), + approvalActions, + ...(params.expiresAtMs !== undefined ? { expiresAtMs: params.expiresAtMs } : {}), + }), + state: params.state ?? "approval-requested", + toolCallId, + toolName, + type: "dynamic-tool", + }], + role: "assistant", + }, choices: approvalChoicesAsAny(params.choices), id: params.approvalId, messageId: params.messageId, schema: "com.beeper.ai.approval.v1", state: "requested", - toolCallId: params.toolCallId, - toolName: params.toolName, + toolCallId, + toolName, }); } @@ -257,6 +305,36 @@ function approvalResponseForChoice(choiceKey: string): ParsedApprovalResponse | } } +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"; diff --git a/packages/openclaw/src/beeper-channel-runtime.test.ts b/packages/openclaw/src/beeper-channel-runtime.test.ts index 2049e23..ba55431 100644 --- a/packages/openclaw/src/beeper-channel-runtime.test.ts +++ b/packages/openclaw/src/beeper-channel-runtime.test.ts @@ -6,6 +6,9 @@ function createClient() { 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), @@ -27,7 +30,7 @@ describe("BeeperChannelRuntime", () => { setBeeperChannelRuntime(undefined); }); - it("wraps Pickle message, reaction, redaction, and typing primitives", async () => { + it("requires bridge portal routing for outbound message operations", async () => { const client = createClient(); const runtime = new BeeperChannelRuntime({ client: client as never, @@ -35,61 +38,93 @@ describe("BeeperChannelRuntime", () => { }); expect(runtime.listAgents()).toEqual([{ id: "codex", name: "Codex" }]); - await expect(runtime.sendText({ replyToId: "$parent", roomId: "!room", text: "hi", threadRoot: "$thread" })) - .resolves.toEqual({ eventId: "$send" }); - expect(client.messages.send).toHaveBeenCalledWith({ - content: { body: "hi", msgtype: "m.text" }, - replyTo: "$parent", - roomId: "!room", - text: "hi", - threadRoot: "$thread", - }); + await expect(runtime.sendText({ roomId: "!room", text: "hi" })).rejects.toThrow("requires a Pickle bridge"); + expect(client.messages.send).not.toHaveBeenCalled(); + }); - await runtime.sendMedia({ bytes: new Uint8Array([1]), caption: "cap", filename: "a.txt", roomId: "!room" }); - expect(client.messages.sendMedia).toHaveBeenCalledWith({ - bytes: new Uint8Array([1]), - caption: "cap", - filename: "a.txt", - kind: "file", - roomId: "!room", + it("rejects non-OpenClaw message ids for bridge mutation actions", async () => { + const client = createClient(); + const bridge = { + flushRemoteEvents: vi.fn(async () => undefined), + getPortalByMXID: vi.fn(() => ({ portalKey: { id: "session:one", receiver: "openclaw:plugin" } })), + queueRemoteEvent: vi.fn(), + }; + const runtime = new BeeperChannelRuntime({ + bridge: bridge as never, + client: client as never, + login: { id: "openclaw:plugin" }, }); - await runtime.edit({ eventId: "$event", roomId: "!room", text: "edited" }); - expect(client.messages.edit).toHaveBeenCalledWith({ eventId: "$event", roomId: "!room", text: "edited" }); - - await runtime.redact({ eventId: "$event", reason: "oops", roomId: "!room" }); - expect(client.messages.redact).toHaveBeenCalledWith({ eventId: "$event", reason: "oops", roomId: "!room" }); - - await runtime.react({ emoji: "+1", eventId: "$event", roomId: "!room" }); - expect(client.reactions.send).toHaveBeenCalledWith({ eventId: "$event", key: "+1", roomId: "!room" }); - - await runtime.removeReaction({ emoji: "+1", eventId: "$event", roomId: "!room" }); - expect(client.reactions.redact).toHaveBeenCalledWith({ eventId: "$event", key: "+1", roomId: "!room" }); - - await runtime.typing({ roomId: "!room", timeoutMs: 1000 }); - expect(client.typing.set).toHaveBeenCalledWith({ roomId: "!room", timeoutMs: 1000, typing: true }); + await expect(runtime.edit({ eventId: "$matrix", roomId: "!room", text: "edit" })) + .rejects.toThrow("can only target OpenClaw bridge message ids"); + expect(client.messages.edit).not.toHaveBeenCalled(); }); - it("uses the appservice ghost sender when a user id is available", async () => { + it("prefers bridge remote events for bound portal message operations", async () => { const client = createClient(); + const queued: unknown[] = []; + const bridge = { + flushRemoteEvents: vi.fn(async () => undefined), + getPortalByMXID: vi.fn(() => ({ portalKey: { id: "session:one", receiver: "openclaw:plugin" } })), + queueRemoteEvent: vi.fn((_login: unknown, event: unknown) => queued.push(event)), + }; const runtime = new BeeperChannelRuntime({ + bridge: bridge as never, client: client as never, - userId: "@agent:example", + getBindingByRoom: () => ({ + agentId: "codex", + createdAt: 1, + ghostUserId: "@codex:example", + id: "binding", + kind: "session", + owner: "bridge", + roomId: "!room", + sessionKey: "session_1", + updatedAt: 1, + }), + login: { id: "openclaw:plugin" }, + userId: "@bot:example", }); - await runtime.sendText({ replyToId: "$parent", roomId: "!room", text: "from ghost" }); - expect(client.appservice.sendMessage).toHaveBeenCalledWith({ - content: { - body: "from ghost", - msgtype: "m.text", - "m.relates_to": { - "m.in_reply_to": { event_id: "$parent" }, - }, - }, - roomId: "!room", - userId: "@agent: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.sendMedia({ bytes: new Uint8Array([1]), caption: "cap", filename: "a.txt", roomId: "!room" }); + expect(client.media.upload).toHaveBeenCalledWith({ + bytes: new Uint8Array([1]), + filename: "a.txt", }); - expect(client.messages.send).not.toHaveBeenCalled(); + + 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 }); + + expect(queued.slice(1).map((event) => (event as { getType: () => string }).getType())).toEqual([ + "message", + "edit", + "reaction", + "reaction_remove", + "message_remove", + "typing", + ]); + 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("stores the active runtime for channel adapters", () => { diff --git a/packages/openclaw/src/beeper-channel-runtime.ts b/packages/openclaw/src/beeper-channel-runtime.ts index 164538b..fcc4c61 100644 --- a/packages/openclaw/src/beeper-channel-runtime.ts +++ b/packages/openclaw/src/beeper-channel-runtime.ts @@ -1,10 +1,25 @@ import { readFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; import type { MatrixClient, SentEvent } from "@beeper/pickle"; -import type { OpenClawAgentContact } from "./types"; +import { + createRemoteMessage, + type PickleBridge, + type PortalKey, + type RemoteEdit, + type RemoteMessageRemove, + type RemoteReaction, + type RemoteReactionRemove, + type RemoteTyping, + type UserLogin, +} from "@beeper/pickle-bridge"; +import type { OpenClawAgentContact, OpenClawSessionBinding } from "./types"; export interface BeeperChannelRuntimeOptions { + bridge?: PickleBridge; client: MatrixClient; getAgents?: () => readonly OpenClawAgentContact[]; + getBindingByRoom?: (roomId: string) => OpenClawSessionBinding | undefined; + login?: UserLogin; log?: (level: "debug" | "info" | "warn" | "error", message: string, data?: unknown) => void; userId?: string; } @@ -21,12 +36,18 @@ export interface BeeperOutboundMedia { export class BeeperChannelRuntime { readonly client: MatrixClient; readonly userId: string | undefined; + #bridge: PickleBridge | undefined; #getAgents: () => readonly OpenClawAgentContact[]; + #getBindingByRoom: (roomId: string) => OpenClawSessionBinding | undefined; + #login: UserLogin | undefined; #log: BeeperChannelRuntimeOptions["log"]; constructor(options: BeeperChannelRuntimeOptions) { + this.#bridge = options.bridge; this.client = options.client; this.#getAgents = options.getAgents ?? (() => []); + this.#getBindingByRoom = options.getBindingByRoom ?? (() => undefined); + this.#login = options.login; this.#log = options.log; this.userId = options.userId; } @@ -47,20 +68,7 @@ export class BeeperChannelRuntime { msgtype: "m.text", ...options.content, }; - if (this.userId) { - return await this.client.appservice.sendMessage({ - content: withReplyRelation(content, options.replyToId), - roomId: options.roomId, - userId: this.userId, - }); - } - return await this.client.messages.send({ - content, - roomId: options.roomId, - text: options.text, - ...(options.replyToId ? { replyTo: options.replyToId } : {}), - ...(options.threadRoot != null ? { threadRoot: String(options.threadRoot) } : {}), - }); + return await this.#queueRemoteText(options.roomId, withReplyRelation(content, options.replyToId)); } async sendMedia(options: BeeperOutboundMedia & { roomId: string }): Promise { @@ -68,13 +76,11 @@ export class BeeperChannelRuntime { if (!bytes) { throw new Error("Beeper media send requires bytes or a local file path."); } - return await this.client.messages.sendMedia({ + return await this.#queueRemoteMedia(options.roomId, { bytes, kind: options.kind ?? "file", - roomId: options.roomId, ...(options.caption !== undefined ? { caption: options.caption } : {}), ...(options.filename !== undefined ? { filename: options.filename } : {}), - ...(options.threadRoot !== undefined ? { threadRoot: options.threadRoot } : {}), }); } @@ -84,49 +90,153 @@ export class BeeperChannelRuntime { roomId: string; text: string; }): Promise { - return await this.client.messages.edit({ - eventId: options.eventId, - roomId: options.roomId, - text: options.text, - ...(options.content !== undefined ? { content: options.content } : {}), + 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.client.messages.redact({ - eventId: options.eventId, - roomId: options.roomId, - ...(options.reason !== undefined ? { reason: options.reason } : {}), - }); + await this.#queueRemoteMessageRemove(options.roomId, options.eventId); } async react(options: { emoji: string; eventId: string; roomId: string }): Promise { - return await this.client.reactions.send({ - eventId: options.eventId, - key: options.emoji, - roomId: options.roomId, - }); + return await this.#queueRemoteReaction(options.roomId, options.eventId, options.emoji, false); } async removeReaction(options: { emoji: string; eventId: string; roomId: string }): Promise { - await this.client.reactions.redact({ - eventId: options.eventId, - key: options.emoji, - roomId: options.roomId, - }); + await this.#queueRemoteReaction(options.roomId, options.eventId, options.emoji, true); } async typing(options: { roomId: string; timeoutMs?: number; typing?: boolean }): Promise { - await this.client.typing.set({ - roomId: options.roomId, - typing: options.typing ?? true, - ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), - }); + await this.#queueRemoteTyping(options.roomId, options.typing ?? true, options.timeoutMs); } debug(message: string, data?: unknown): void { this.#log?.("debug", message, data); } + + 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(); + return { eventId: messageId, raw: { bridgeQueued: true }, roomId }; + } + + async #queueRemoteMedia(roomId: string, options: { bytes: Uint8Array; caption?: string; filename?: string; kind: NonNullable }): Promise { + const route = this.#bridgeRoute(roomId); + const uploaded = await this.client.media.upload({ + bytes: options.bytes, + ...(options.filename !== undefined ? { filename: options.filename } : {}), + }); + const messageId = openClawRemoteId(); + route.bridge.queueRemoteEvent(route.login, createRemoteMessage({ + convert: () => ({ + parts: [{ + content: mediaMessageContent(options.kind, uploaded.contentUri, options.filename, options.caption), + type: "m.room.message", + }], + }), + data: {}, + id: messageId, + portalKey: route.portalKey, + sender: this.#eventSender(roomId), + })); + await route.bridge.flushRemoteEvents(); + return { eventId: messageId, raw: { bridgeQueued: true }, roomId }; + } + + async #queueRemoteEdit(roomId: string, targetMessageId: string, content: Record): Promise { + const targetId = openClawTargetId(targetMessageId); + const route = this.#bridgeRoute(roomId); + const messageId = openClawRemoteId(); + const event: RemoteEdit = { + convertEdit: async () => ({ + modifiedParts: [{ + content, + type: "m.room.message", + }], + }), + getPortalKey: () => route.portalKey, + getSender: () => this.#eventSender(roomId), + getTargetMessage: () => targetId, + getType: () => "edit", + }; + route.bridge.queueRemoteEvent(route.login, event); + await route.bridge.flushRemoteEvents(); + return { eventId: messageId, raw: { bridgeQueued: true, targetMessageId: targetId }, roomId }; + } + + async #queueRemoteMessageRemove(roomId: string, targetMessageId: string): Promise { + const targetId = openClawTargetId(targetMessageId); + const route = this.#bridgeRoute(roomId); + const event: RemoteMessageRemove = { + getPortalKey: () => route.portalKey, + getSender: () => this.#eventSender(roomId), + getTargetMessage: () => targetId, + getType: () => "message_remove", + }; + route.bridge.queueRemoteEvent(route.login, event); + await route.bridge.flushRemoteEvents(); + } + + async #queueRemoteReaction(roomId: string, targetMessageId: string, emoji: string, remove: boolean): Promise { + const targetId = openClawTargetId(targetMessageId); + const route = this.#bridgeRoute(roomId); + const reactionId = openClawRemoteId("reaction"); + const event: RemoteReaction | RemoteReactionRemove = { + getEmoji: () => emoji, + getID: () => reactionId, + getPortalKey: () => route.portalKey, + getSender: () => this.#eventSender(roomId), + getTargetMessage: () => targetId, + getType: () => remove ? "reaction_remove" : "reaction", + }; + route.bridge.queueRemoteEvent(route.login, event); + await route.bridge.flushRemoteEvents(); + return { eventId: reactionId, raw: { bridgeQueued: true, targetMessageId: targetId }, 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(); + } + + #bridgeRoute(roomId: string): { bridge: PickleBridge; login: UserLogin; portalKey: PortalKey } { + if (!this.#bridge || !this.#login) throw new Error("Beeper channel runtime requires a Pickle bridge and user login for outbound actions."); + const portal = this.#bridge.getPortalByMXID(roomId); + 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 }; + } + + #eventSender(roomId: string): { isFromMe: boolean; sender: string } { + const binding = this.#getBindingByRoom(roomId); + return { + isFromMe: true, + sender: binding?.ghostUserId ?? this.userId ?? "openclaw", + }; + } } let currentRuntime: BeeperChannelRuntime | undefined; @@ -157,3 +267,30 @@ function withReplyRelation(content: Record, replyToId: string | }, }; } + +function openClawRemoteId(prefix = "message"): string { + return `openclaw:${prefix}:${randomUUID()}`; +} + +function openClawTargetId(eventId: string): string { + if (!eventId.startsWith("openclaw:")) { + throw new Error(`Beeper bridge actions can only target OpenClaw bridge message ids, got ${eventId}.`); + } + return eventId; +} + +function mediaMessageContent(kind: NonNullable, contentUri: string, filename: string | undefined, caption: string | undefined): Record { + const msgtype = kind === "image" + ? "m.image" + : kind === "video" + ? "m.video" + : kind === "audio" + ? "m.audio" + : "m.file"; + return { + body: caption ?? filename ?? "attachment", + msgtype, + url: contentUri, + ...(filename ? { filename } : {}), + }; +} diff --git a/packages/openclaw/src/beeper-stream.test.ts b/packages/openclaw/src/beeper-stream.test.ts index 46596e4..af1e09c 100644 --- a/packages/openclaw/src/beeper-stream.test.ts +++ b/packages/openclaw/src/beeper-stream.test.ts @@ -104,6 +104,34 @@ describe("OpenClaw Beeper native stream publisher", () => { expect(finalizeMessage).toHaveBeenCalledTimes(1); }); + it("uses the active binding run id when the first live chunk has no AG-UI run id", async () => { + const { client, finalizeMessage, publishPart, startMessage } = createClient(); + const publisher = new OpenClawBeeperStreamPublisher({ client, userId: "@bot:example.com" }); + const binding = { ...sessionBinding(), lastRunId: "beeper:run_1", lastStreamRunId: "beeper:run_1" }; + + await publisher.publish(binding, [ + { args: "{}", delta: "{}", toolCallId: "tool_1", type: "TOOL_CALL_ARGS" }, + ]); + await publisher.publish(binding, [ + { delta: "answer", messageId: "beeper:run_1", type: "TEXT_MESSAGE_CONTENT" }, + { finishReason: "stop", runId: "beeper:run_1", threadId: "beeper:run_1", type: "RUN_FINISHED" }, + ]); + + expect(startMessage).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.objectContaining({ + "com.beeper.ai": expect.objectContaining({ id: "beeper:run_1" }), + "com.beeper.ai.metadata": expect.objectContaining({ runId: "beeper:run_1" }), + }), + })); + expect(publishPart.mock.calls.every(([options]) => options.turnId === "beeper:run_1")).toBe(true); + expect(finalizeMessage).toHaveBeenCalledWith(expect.objectContaining({ + body: "answer", + content: expect.objectContaining({ + "com.beeper.ai": expect.objectContaining({ id: "beeper:run_1" }), + }), + })); + }); + it("honors native-only stream finalization without sending a replacement edit", async () => { const { client, finalizeMessage, publishPart, startMessage } = createClient(); const publisher = new OpenClawBeeperStreamPublisher({ diff --git a/packages/openclaw/src/beeper-stream.ts b/packages/openclaw/src/beeper-stream.ts index 5a9cfd3..91ac8e1 100644 --- a/packages/openclaw/src/beeper-stream.ts +++ b/packages/openclaw/src/beeper-stream.ts @@ -282,7 +282,7 @@ export class OpenClawBeeperStreamPublisher implements OpenClawBridgeStreamPublis session_key: binding.sessionKey, }, roomId: binding.roomId, - turnId: firstRunId(events) ?? createTurnId(), + turnId: firstRunId(events) ?? binding.lastStreamRunId ?? binding.lastRunId ?? createTurnId(), ...(this.#userId ? { userId: this.#userId } : {}), }); this.#publishers.set(key, publisher); @@ -386,7 +386,17 @@ function customEventToFinalMessageParts(event: AGUIEvent): Record this.registry.data.agents, + getBindingByRoom: (roomId) => this.registry.getBindingByRoom(roomId), + login, log: (level, message, data) => ctx.log(level, message, data), ...(ownUserId ? { userId: ownUserId } : {}), })); diff --git a/packages/openclaw/src/integration.test.ts b/packages/openclaw/src/integration.test.ts index eda4a5f..e7d502a 100644 --- a/packages/openclaw/src/integration.test.ts +++ b/packages/openclaw/src/integration.test.ts @@ -76,13 +76,13 @@ describe("OpenClaw bridge integration", () => { matrix: { sender: "@alice:example" }, message: "hello", }, { expectFinal: false }); - expect(streams.publish).toHaveBeenCalledWith( + await vi.waitFor(() => expect(streams.publish).toHaveBeenCalledWith( expect.objectContaining({ roomId: "!codex:example", sessionKey: "session_1", }), expect.arrayContaining([expect.objectContaining({ type: "TEXT_MESSAGE_CONTENT" })]), - ); + )); expect(registry.getBindingByRoom("!codex:example")).toMatchObject({ lastMatrixEventId: "$hello", lastRunId: "run_1", @@ -313,7 +313,7 @@ describe("OpenClaw bridge integration", () => { roomId: "!created:example", }); - expect(client.beeper.streams.startMessage).toHaveBeenCalledWith(expect.objectContaining({ + await vi.waitFor(() => expect(client.beeper.streams.startMessage).toHaveBeenCalledWith(expect.objectContaining({ content: expect.objectContaining({ "com.beeper.ai": expect.objectContaining({ id: "run_1" }), "com.beeper.ai.metadata": expect.objectContaining({ protocol: "ag-ui", runId: "run_1" }), @@ -322,13 +322,13 @@ describe("OpenClaw bridge integration", () => { roomId: "!created:example", streamType: "com.beeper.llm", userId: "@openclawbot:example", - })); + }))); await vi.waitFor(() => expect(client.beeper.streams.publishPart).toHaveBeenCalledWith(expect.objectContaining({ part: expect.objectContaining({ type: "CUSTOM" }), roomId: "!created:example", turnId: expect.any(String), }))); - expect(client.beeper.streams.finalizeMessage).toHaveBeenCalledWith(expect.objectContaining({ + await vi.waitFor(() => expect(client.beeper.streams.finalizeMessage).toHaveBeenCalledWith(expect.objectContaining({ content: expect.objectContaining({ "com.beeper.ai": expect.objectContaining({ parts: expect.arrayContaining([ @@ -339,7 +339,7 @@ describe("OpenClaw bridge integration", () => { }), eventId: "$stream-root", roomId: "!created:example", - })); + }))); await expect(bridge.dispatchMatrixEvent(reactionEvent({ eventId: "$approve", diff --git a/packages/openclaw/src/openclaw-event-map.test.ts b/packages/openclaw/src/openclaw-event-map.test.ts index 4b8893a..0480bbb 100644 --- a/packages/openclaw/src/openclaw-event-map.test.ts +++ b/packages/openclaw/src/openclaw-event-map.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { defaultBeeperApprovalChoices } from "./approval"; +import { defaultBeeperApprovalActions, defaultBeeperApprovalChoices } from "./approval"; import { createOpenClawStreamState, mapOpenClawEventToBeeperChunks } from "./openclaw-event-map"; describe("OpenClaw event to Beeper stream mapping", () => { @@ -153,6 +153,7 @@ describe("OpenClaw event to Beeper stream mapping", () => { needsApproval: true, }, approvalMessageId: "approval_1", + approvalActions: defaultBeeperApprovalActions(), choices: defaultBeeperApprovalChoices(), message: "Allow shell?", toolCallId: "call_1", @@ -300,6 +301,7 @@ describe("OpenClaw event to Beeper stream mapping", () => { needsApproval: true, }, approvalMessageId: "approval_1", + approvalActions: defaultBeeperApprovalActions(), choices: defaultBeeperApprovalChoices(), message: "Run command?", toolCallId: "tool_1", diff --git a/packages/openclaw/src/openclaw-runtime.test.ts b/packages/openclaw/src/openclaw-runtime.test.ts index 720e348..03df362 100644 --- a/packages/openclaw/src/openclaw-runtime.test.ts +++ b/packages/openclaw/src/openclaw-runtime.test.ts @@ -206,20 +206,10 @@ describe("OpenClawGatewayRuntime", () => { }); }); - it("runs Beeper-originated sends through the native OpenClaw plugin agent runtime", async () => { - const runEmbeddedAgent = vi.fn(async (params: Record) => { - const onAgentEvent = params.onAgentEvent as ((event: { data: Record; stream: string }) => void) | undefined; - const onPartialReply = params.onPartialReply as ((payload: { text: string }) => void) | undefined; - onAgentEvent?.({ data: { delta: "hello", runId: params.runId as string }, stream: "assistant.delta" }); - onPartialReply?.({ text: "hello from callback" }); - return { payloads: [{ text: "hello from final payload" }] }; - }); + it("rejects Beeper-originated sends when the OpenClaw channel runtime is unavailable", async () => { const transport = createOpenClawHostTransport({ agent: { - ensureAgentWorkspace: () => "/tmp/workspace", resolveAgentDir: () => "/tmp/agent", - resolveAgentTimeoutMs: () => 1000, - runEmbeddedAgent, session: { getSessionEntry: () => ({ sessionFile: "/tmp/session.jsonl", @@ -230,6 +220,53 @@ describe("OpenClawGatewayRuntime", () => { config: { current: () => ({ agents: { list: [{ id: "main" }] } }) }, }); + await expect(transport.request("sessions.send", { + key: "agent:main:beeper:room", + message: "from Beeper", + idempotencyKey: "$event", + })).rejects.toThrow("OpenClaw Beeper requires OpenClaw channel turn helpers"); + }); + + it("runs Beeper-originated sends through OpenClaw channel turn helpers for live AG-UI progress", async () => { + const runAssembled = vi.fn(async (params: Record) => { + const replyOptions = params.replyOptions as Record void | Promise>; + await replyOptions.onReasoningStream?.({ text: "checking" }); + await replyOptions.onToolStart?.({ args: { path: "README.md" }, name: "read_file", phase: "start" }); + 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?.({ text: "hello world" }); + return { dispatchResult: { queuedFinal: true } }; + }); + const transport = createOpenClawHostTransport({ + channel: { + reply: { + dispatchReplyWithBufferedBlockDispatcher: vi.fn(), + }, + session: { + recordInboundSession: vi.fn(), + resolveStorePath: () => "/tmp/sessions.json", + }, + turn: { + buildContext: (params: Record) => ({ + Body: "from Beeper", + BodyForAgent: "from Beeper", + From: "beeper", + RawBody: "from Beeper", + SessionKey: (params.route as { routeSessionKey?: string }).routeSessionKey, + To: "beeper", + }), + runAssembled, + }, + }, + config: { current: () => ({ agents: { list: [{ id: "main" }] } }) }, + }); + const received: OpenClawGatewayEvent[] = []; let observedRunId: string | undefined; const done = (async () => { @@ -245,37 +282,28 @@ describe("OpenClawGatewayRuntime", () => { key: "agent:main:beeper:room", message: "from Beeper", idempotencyKey: "$event", + matrix: { sender: "@alice:example" }, }); observedRunId = (sent as { runId?: string }).runId; await done; - expect(sent).toMatchObject({ - sessionFile: "/tmp/session.jsonl", - sessionId: "session-1", - sessionKey: "agent:main:beeper:room", - }); - expect(runEmbeddedAgent).toHaveBeenCalledWith(expect.objectContaining({ - agentDir: "/tmp/agent", + expect(runAssembled).toHaveBeenCalledWith(expect.objectContaining({ + accountId: "beeper", agentId: "main", - currentMessageId: "$event", - messageChannel: "beeper", - messageProvider: "beeper", - prompt: "from Beeper", - sessionFile: "/tmp/session.jsonl", - sessionId: "session-1", - sessionKey: "agent:main:beeper:room", - timeoutMs: 1000, - trigger: "user", - workspaceDir: "/tmp/workspace", + channel: "beeper", + routeSessionKey: "agent:main:beeper:room", })); expect(received).toEqual(expect.arrayContaining([ + expect.objectContaining({ event: "thinking.delta" }), + expect.objectContaining({ event: "tool.call.started" }), + expect.objectContaining({ event: "approval.requested" }), expect.objectContaining({ event: "assistant.delta", - payload: expect.objectContaining({ delta: "hello from callback" }), + payload: expect.objectContaining({ delta: "hello" }), }), expect.objectContaining({ event: "assistant.delta", - payload: expect.objectContaining({ delta: "hello from final payload" }), + payload: expect.objectContaining({ delta: " world" }), }), expect.objectContaining({ event: "run.completed" }), ])); diff --git a/packages/openclaw/src/openclaw-runtime.ts b/packages/openclaw/src/openclaw-runtime.ts index 6903d5b..1e5dc09 100644 --- a/packages/openclaw/src/openclaw-runtime.ts +++ b/packages/openclaw/src/openclaw-runtime.ts @@ -25,12 +25,8 @@ export interface OpenClawTransport { export interface OpenClawHostRuntime { agent?: { - ensureAgentWorkspace?: (config: unknown, agentId?: string) => Promise | string; resolveAgentDir?: (config: unknown, agentId?: string) => string; resolveAgentTimeoutMs?: (options: Record) => number; - resolveAgentWorkspaceDir?: (config: unknown, agentId?: string) => string; - runEmbeddedAgent?: (params: Record) => Promise; - runEmbeddedPiAgent?: (params: Record) => Promise; session?: { getSessionEntry?: (options: Record) => Record | undefined; listSessionEntries?: (options?: Record) => Array<{ entry: Record; sessionKey: string }>; @@ -765,42 +761,25 @@ async function sendSessionInPluginRuntime( const sessionFile = stringValue(entry.sessionFile) ?? resolvePluginSessionFile(runtime, agentId, sessionId, entry); const runId = `beeper:${randomUUID()}`; const cfg = runtime.config?.current?.(); - const runEmbeddedAgent = runtime.agent?.runEmbeddedAgent ?? runtime.agent?.runEmbeddedPiAgent; - if (!runEmbeddedAgent && !canRunNativeChannelTurn(runtime)) { - throw new Error("OpenClaw plugin runtime does not expose channel turn helpers or agent.runEmbeddedAgent"); + if (!canRunNativeChannelTurn(runtime)) { + throw new Error("OpenClaw Beeper requires OpenClaw channel turn helpers (runtime.channel.turn, runtime.channel.reply, and runtime.channel.session)"); } const timeoutMs = options?.timeoutMs ?? numberValue(record.timeoutMs) ?? runtime.agent?.resolveAgentTimeoutMs?.({ cfg }) ?? 48 * 60 * 60 * 1000; - queuePluginRun(() => { - if (canRunNativeChannelTurn(runtime)) { - return runBeeperChannelTurnInPluginRuntime({ - agentId, - cfg, - localEvents, - message, - record, - runId, - runtime, - sessionFile, - sessionId, - sessionKey, - timeoutMs, - }); - } - return runEmbeddedAgentInPluginRuntime({ + queuePluginRun(() => + runBeeperChannelTurnInPluginRuntime({ agentId, cfg, localEvents, message, record, - runEmbeddedAgent: runEmbeddedAgent as (params: Record) => Promise, runId, runtime, sessionFile, sessionId, sessionKey, timeoutMs, - }); - }); + }) + ); return { runId, sessionFile, sessionId, sessionKey }; } @@ -989,72 +968,6 @@ async function runBeeperChannelTurnInPluginRuntime(params: { } } -async function runEmbeddedAgentInPluginRuntime(params: { - agentId: string; - cfg: unknown; - localEvents: LocalEventBus; - message: string; - record: Record; - runEmbeddedAgent: (params: Record) => Promise; - runId: string; - runtime: OpenClawHostRuntime; - sessionFile: string; - sessionId: string; - sessionKey: string; - timeoutMs: number; -}): Promise { - params.localEvents.emit({ event: "run.started", payload: { agentId: params.agentId, runId: params.runId, sessionId: params.sessionId, sessionKey: params.sessionKey } }); - const emit = createBeeperReplyEventEmitter(params.localEvents, { - agentId: params.agentId, - runId: params.runId, - sessionId: params.sessionId, - sessionKey: params.sessionKey, - }); - await params.runEmbeddedAgent(stripUndefined({ - agentId: params.agentId, - config: params.cfg, - currentMessageId: stringValue(params.record.idempotencyKey), - messageChannel: "beeper", - messageProvider: "beeper", - prompt: params.message, - runId: params.runId, - sessionFile: params.sessionFile, - sessionId: params.sessionId, - sessionKey: params.sessionKey, - timeoutMs: params.timeoutMs, - trigger: "user", - workspaceDir: await resolvePluginWorkspaceDir(params.runtime, params.cfg, params.agentId), - agentDir: params.runtime.agent?.resolveAgentDir?.(params.cfg, params.agentId), - onAgentEvent: (event: OpenClawAgentRuntimeEvent) => { - const data = recordValue(event.data) ?? {}; - params.localEvents.emit(stripUndefined({ - event: event.stream, - payload: stripUndefined({ - ...data, - runId: stringValue(data.runId) ?? params.runId, - sessionKey: event.sessionKey ?? stringValue(data.sessionKey) ?? params.sessionKey, - }), - seq: numberValue(data.seq), - })); - }, - onAssistantMessageStart: emit.assistantMessageStart, - onBlockReply: emit.textPayload, - onBlockReplyQueued: emit.textPayload, - onPartialReply: emit.textPayload, - onReasoningEnd: emit.reasoningEnd, - onReasoningStream: emit.reasoningPayload, - onToolResult: emit.toolResult, - })).then( - (result) => { - emit.finalText(finalTextFromEmbeddedRunResult(result)); - params.localEvents.emit({ event: "run.completed", payload: { agentId: params.agentId, runId: params.runId, sessionId: params.sessionId, sessionKey: params.sessionKey } }); - }, - (error) => { - params.localEvents.emit({ event: "run.failed", payload: { agentId: params.agentId, error: errorText(error), runId: params.runId, sessionId: params.sessionId, sessionKey: params.sessionKey } }); - }, - ); -} - function createBeeperReplyEventEmitter(localEvents: LocalEventBus, base: { agentId: string; runId: string; @@ -1067,7 +980,7 @@ function createBeeperReplyEventEmitter(localEvents: LocalEventBus, base: { localEvents.emit({ event, payload: stripUndefined({ ...base, ...payload }) }); }; const textPayload = (payload: unknown) => { - const text = stringValue(recordValue(payload)?.text); + const text = replyPayloadText(payload); if (!text) return; const explicitDelta = stringValue(recordValue(payload)?.delta); const delta = explicitDelta ?? (text.startsWith(lastPartialText) ? text.slice(lastPartialText.length) : text); @@ -1089,10 +1002,6 @@ function createBeeperReplyEventEmitter(localEvents: LocalEventBus, base: { lastPartialText = ""; emit("assistant.message.start", {}); }, - finalText: (text: string | undefined) => { - if (!text) return; - textPayload({ text }); - }, reasoningEnd: () => emit("thinking.end", {}), reasoningPayload, textPayload, @@ -1116,18 +1025,23 @@ function createBeeperReplyEventEmitter(localEvents: LocalEventBus, base: { }, itemEvent: (payload: unknown) => { const data = recordValue(payload) ?? {}; - emit("tool.call.delta", { - delta: stringValue(data.progressText) ?? stringValue(data.summary) ?? stringValue(data.status) ?? stringValue(data.phase), - inputTextDelta: stringValue(data.progressText) ?? stringValue(data.summary) ?? stringValue(data.status) ?? stringValue(data.phase), - toolCallId: toolIdFor(data, `item:${stringValue(data.name) ?? stringValue(data.kind) ?? "work"}`), + const toolCallId = stringValue(data.toolCallId); + const output = stringValue(data.progressText) ?? stringValue(data.summary); + if (!toolCallId || !output) return; + emit("tool.call.completed", { + output, + preliminary: stringValue(data.phase) !== "complete" && stringValue(data.status) !== "complete", + toolCallId, toolName: stringValue(data.name) ?? stringValue(data.kind), }); }, planUpdate: (payload: unknown) => { const data = recordValue(payload) ?? {}; - emit("tool.call.delta", { - delta: stringValue(data.title) ?? stringValue(data.explanation) ?? stringValue(data.phase), - inputTextDelta: stringValue(data.title) ?? stringValue(data.explanation) ?? stringValue(data.phase), + const output = stringValue(data.explanation) ?? stringValue(data.title); + if (!output) return; + emit("tool.call.completed", { + output, + preliminary: stringValue(data.phase) !== "complete", toolCallId: "plan", toolName: "plan", }); @@ -1174,6 +1088,23 @@ function createBeeperReplyEventEmitter(localEvents: LocalEventBus, base: { }; } +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); + 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); + const text = stringValue(partRecord?.text) ?? stringValue(partRecord?.content); + if (text) chunks.push(text); + } + return chunks.length > 0 ? chunks.join("") : undefined; +} + function relationSupplementalContext(matrix: Record): Record | undefined { const relation = recordValue(matrix.relation); const quote = recordValue(relation?.quote); @@ -1189,21 +1120,6 @@ function relationSupplementalContext(matrix: Record): Record 0 ? parts.join("\n") : undefined; -} - function resolvePluginSession(runtime: OpenClawHostRuntime, sessionKey: string, agentId?: string): { entry?: Record; sessionKey: string } { const getSessionEntry = runtime.agent?.session?.getSessionEntry; const direct = recordValue(getSessionEntry?.({ agentId, sessionKey })); @@ -1236,14 +1152,6 @@ function resolvePluginSessionFile( return path.join(process.env.OPENCLAW_STATE_DIR ?? path.join(process.env.HOME ?? ".", ".openclaw"), "agents", agentId, "sessions", `${sessionId}.jsonl`); } -async function resolvePluginWorkspaceDir(runtime: OpenClawHostRuntime, cfg: unknown, agentId: string): Promise { - const ensured = await runtime.agent?.ensureAgentWorkspace?.(cfg, agentId); - if (typeof ensured === "string" && ensured) return ensured; - const resolved = runtime.agent?.resolveAgentWorkspaceDir?.(cfg, agentId); - if (resolved) return resolved; - return process.cwd(); -} - async function historyFromPluginRuntime(runtime: OpenClawHostRuntime, params: unknown): Promise>> { const record = recordValue(params) ?? {}; const sessionKey = stringValue(record.sessionKey) ?? stringValue(record.key); diff --git a/packages/openclaw/src/setup.test.ts b/packages/openclaw/src/setup.test.ts index 6f3e6d2..9a083a8 100644 --- a/packages/openclaw/src/setup.test.ts +++ b/packages/openclaw/src/setup.test.ts @@ -171,6 +171,37 @@ describe("OpenClaw Beeper setup surface", () => { 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", + }, + state: "approval-requested", + toolCallId: "tool_1", + toolName: "shell", + type: "dynamic-tool", + }], + role: "assistant", + }, + }, + }); expect(beeperChannelPlugin.actions).toEqual(expect.any(Object)); expect(beeperChannelPlugin.actions.describeMessageTool()).toMatchObject({ actions: ["send", "edit", "delete", "react"], @@ -640,9 +671,10 @@ describe("OpenClaw Beeper setup surface", () => { }); it("routes OpenClaw message actions through the active Beeper runtime", async () => { - const client = { - appservice: { sendMessage: vi.fn(async () => ({ eventId: "$as" })) }, - messages: { + const client = { + appservice: { sendMessage: vi.fn(async () => ({ eventId: "$as" })) }, + 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" })), @@ -652,63 +684,86 @@ describe("OpenClaw Beeper setup surface", () => { redact: vi.fn(async () => undefined), send: vi.fn(async () => ({ eventId: "$reaction" })), }, - typing: { set: vi.fn(async () => undefined) }, - }; - setBeeperChannelRuntime(new BeeperChannelRuntime({ - client: client as never, + typing: { set: vi.fn(async () => undefined) }, + }; + const queued: unknown[] = []; + const bridge = { + flushRemoteEvents: vi.fn(async () => undefined), + getPortalByMXID: vi.fn(() => ({ portalKey: { id: "session:one", receiver: "openclaw:plugin" } })), + queueRemoteEvent: vi.fn((_login: unknown, event: unknown) => queued.push(event)), + }; + setBeeperChannelRuntime(new BeeperChannelRuntime({ + bridge: bridge as never, + client: client as never, getAgents: () => [{ avatarMxc: "mxc://avatar", description: "Helpful coding agent", agentId: "codex", displayName: "Codex", - ghostUserId: "@codex:example", - }], - })); + ghostUserId: "@codex:example", + }], + getBindingByRoom: () => ({ + agentId: "codex", + createdAt: 1, + ghostUserId: "@codex:example", + id: "binding", + kind: "session", + owner: "bridge", + roomId: "!room", + sessionKey: "session_1", + updatedAt: 1, + }), + login: { id: "openclaw:plugin" }, + })); - await expect(beeperChannelPlugin.actions.handleAction({ - action: "send", - params: { message: "hello", replyTo: "$parent", to: "!room" }, - })).resolves.toEqual({ content: [{ type: "text", text: "Sent Beeper message $send" }] }); - expect(client.messages.send).toHaveBeenCalledWith({ - content: { body: "hello", msgtype: "m.text" }, - replyTo: "$parent", - roomId: "!room", - text: "hello", - }); + const sendResult = await beeperChannelPlugin.actions.handleAction({ + action: "send", + params: { message: "hello", replyTo: "$parent", to: "!room" }, + }); + const sentMessageId = String(sendResult.content[0]?.text).replace("Sent Beeper message ", ""); + expect(sentMessageId).toMatch(/^openclaw:message:/u); + expect(client.messages.send).not.toHaveBeenCalled(); + expect((queued[0] as { getSender: () => unknown }).getSender()).toEqual({ isFromMe: true, sender: "@codex:example" }); - await beeperChannelPlugin.actions.handleAction({ - action: "send", + await beeperChannelPlugin.actions.handleAction({ + action: "send", mediaReadFile: async () => Buffer.from("file"), - params: { mediaUrl: "/tmp/a.txt", text: "caption", to: "!room" }, - }); - expect(client.messages.sendMedia).toHaveBeenCalledWith({ - bytes: Buffer.from("file"), - caption: "caption", - filename: "a.txt", - kind: "file", - roomId: "!room", - }); + params: { mediaUrl: "/tmp/a.txt", text: "caption", to: "!room" }, + }); + expect(client.media.upload).toHaveBeenCalledWith({ + bytes: Buffer.from("file"), + filename: "a.txt", + }); + expect(client.messages.sendMedia).not.toHaveBeenCalled(); - await beeperChannelPlugin.actions.handleAction({ - action: "edit", - params: { eventId: "$event", text: "updated", to: "!room" }, - }); - expect(client.messages.edit).toHaveBeenCalledWith({ eventId: "$event", roomId: "!room", text: "updated" }); + await beeperChannelPlugin.actions.handleAction({ + action: "edit", + params: { eventId: sentMessageId, text: "updated", to: "!room" }, + }); + expect(client.messages.edit).not.toHaveBeenCalled(); - await beeperChannelPlugin.actions.handleAction({ - action: "react", - params: { eventId: "$event", key: "+1", to: "!room" }, - }); - expect(client.reactions.send).toHaveBeenCalledWith({ eventId: "$event", key: "+1", roomId: "!room" }); + await beeperChannelPlugin.actions.handleAction({ + action: "react", + params: { eventId: sentMessageId, key: "+1", to: "!room" }, + }); + expect(client.reactions.send).not.toHaveBeenCalled(); - await beeperChannelPlugin.actions.handleAction({ - action: "delete", - params: { eventId: "$event", reason: "cleanup", to: "!room" }, - }); - expect(client.messages.redact).toHaveBeenCalledWith({ eventId: "$event", reason: "cleanup", roomId: "!room" }); + await beeperChannelPlugin.actions.handleAction({ + action: "delete", + params: { eventId: sentMessageId, reason: "cleanup", to: "!room" }, + }); + expect(client.messages.redact).not.toHaveBeenCalled(); - await beeperChannelPlugin.heartbeat.sendTyping({ to: "!room" }); - expect(client.typing.set).toHaveBeenCalledWith({ roomId: "!room", typing: true }); + await beeperChannelPlugin.heartbeat.sendTyping({ to: "!room" }); + expect(client.typing.set).not.toHaveBeenCalled(); + expect(queued.map((event) => (event as { getType: () => string }).getType())).toEqual([ + "message", + "message", + "edit", + "reaction", + "message_remove", + "typing", + ]); await expect(beeperChannelPlugin.directory.listPeersLive({ cfg: {} as OpenClawSetupConfig, diff --git a/packages/openclaw/src/setup.ts b/packages/openclaw/src/setup.ts index e2d117b..2d66055 100644 --- a/packages/openclaw/src/setup.ts +++ b/packages/openclaw/src/setup.ts @@ -1,5 +1,6 @@ import { createConfigFromOpenClawSetup, DEFAULT_REGISTRATION_URL, defaultDataDir } from "./config"; import type { setupOpenClawBeeperBridge, SetupOpenClawBeeperBridgeOptions } from "./beeper-setup"; +import { createBeeperApprovalNotice } from "./approval"; import { requireBeeperChannelRuntime } from "./beeper-channel-runtime"; import type { OpenClawHostRuntime } from "./openclaw-runtime"; @@ -489,16 +490,40 @@ export const beeperApprovalCapability = { }, render: { exec: { - buildPendingPayload: ({ request, nowMs }: { request: { id?: string; approvalId?: string; command?: string }; nowMs: number }) => ({ - body: `Approval requested: ${request.command ?? request.id ?? request.approvalId ?? "OpenClaw tool call"}`, - channelData: { - beeper: { - approvalId: request.approvalId ?? request.id, - createdAt: nowMs, + 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; diff --git a/packages/openclaw/src/stream-map.ts b/packages/openclaw/src/stream-map.ts index a476f03..af87c10 100644 --- a/packages/openclaw/src/stream-map.ts +++ b/packages/openclaw/src/stream-map.ts @@ -3,7 +3,7 @@ export type { AGUIEvent } from "@beeper/pickle-ag-ui"; import { EventType as AGUIEventType, type AGUIEvent } from "@beeper/pickle-ag-ui"; import type { RunFinishedEvent } from "@beeper/pickle-ag-ui"; -import { defaultBeeperApprovalChoices } from "./approval"; +import { defaultBeeperApprovalActions, defaultBeeperApprovalChoices } from "./approval"; type FinishReason = NonNullable; @@ -246,6 +246,7 @@ export function mapOpenClawApprovalRequest( needsApproval: true, }, approvalMessageId: approvalId, + approvalActions: defaultBeeperApprovalActions(), choices: defaultBeeperApprovalChoices(), message: event.message, toolCallId, From 6a06ccdbed197630f16950f4cb0643f5ef644e27 Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Mon, 25 May 2026 19:18:23 +0200 Subject: [PATCH 33/43] Fix Beeper plugin to stream AG-UI responses natively --- CONTRIBUTING.md | 2 +- package.json | 2 +- .../bridge/src/appservice-websocket.test.ts | 228 +----------- packages/bridge/src/appservice-websocket.ts | 204 +---------- packages/bridge/src/bridge.test.ts | 206 ++++++++++- packages/bridge/src/bridge.ts | 317 ++++++++++++++++- packages/bridge/src/store.test.ts | 29 ++ packages/bridge/src/store.ts | 8 +- packages/bridge/src/types.ts | 3 + packages/openclaw/package.json | 4 - packages/openclaw/src/approval.test.ts | 25 +- packages/openclaw/src/approval.ts | 37 +- packages/openclaw/src/appservice.ts | 70 +++- .../src/beeper-channel-runtime.test.ts | 48 +++ .../openclaw/src/beeper-channel-runtime.ts | 98 +++++- packages/openclaw/src/beeper-setup.test.ts | 1 - packages/openclaw/src/beeper-setup.ts | 4 +- packages/openclaw/src/beeper-stream.test.ts | 136 ++------ packages/openclaw/src/beeper-stream.ts | 76 +--- packages/openclaw/src/bridge-agent.test.ts | 164 +-------- packages/openclaw/src/bridge-agent.ts | 65 +--- packages/openclaw/src/config.test.ts | 2 +- packages/openclaw/src/config.ts | 4 +- packages/openclaw/src/connector.test.ts | 241 +++++++------ packages/openclaw/src/connector.ts | 272 ++++++++++++--- packages/openclaw/src/index.ts | 1 - packages/openclaw/src/integration.test.ts | 114 +++--- .../openclaw/src/openclaw-event-map.test.ts | 330 ------------------ packages/openclaw/src/openclaw-event-map.ts | 271 -------------- .../openclaw/src/openclaw-runtime.test.ts | 69 +++- packages/openclaw/src/openclaw-runtime.ts | 297 ++++++++++++---- packages/openclaw/src/registry.ts | 8 + packages/openclaw/src/setup.test.ts | 15 +- packages/openclaw/src/setup.ts | 129 ++++--- packages/openclaw/src/types.ts | 2 +- packages/openclaw/tsdown.config.ts | 2 +- packages/pickle/native/go.mod | 24 +- packages/pickle/native/go.sum | 48 +-- .../pickle/native/internal/core/appservice.go | 101 ++++-- .../native/internal/core/appservice_test.go | 89 ++++- .../internal/core/persistent_crypto_load.go | 1 - .../core/persistent_crypto_methods.go | 3 +- .../core/persistent_crypto_snapshot.go | 1 - .../internal/core/persistent_crypto_store.go | 1 - packages/pickle/package.json | 3 +- packages/state-file/src/index.test.ts | 15 +- packages/state-file/src/index.ts | 13 +- 47 files changed, 1861 insertions(+), 1922 deletions(-) create mode 100644 packages/bridge/src/store.test.ts delete mode 100644 packages/openclaw/src/openclaw-event-map.test.ts delete mode 100644 packages/openclaw/src/openclaw-event-map.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e170b09..edd826a 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 diff --git a/package.json b/package.json index 8f8915e..156b4fe 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "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: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/src/appservice-websocket.test.ts b/packages/bridge/src/appservice-websocket.test.ts index 61109b7..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,203 +55,7 @@ describe("AppserviceWebsocket", () => { }); }); const websocket = createWebsocket(homeserver, { - dispatch, - log: (() => {}) as BridgeLogger, - }); - websockets.push(websocket); - - websocket.start(); - await connected; - - expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ - eventId: "$event", - kind: "message", - roomId: "!room:example", - text: "hi", - })); - }); - - it("preserves Matrix edit, reply, thread, mention, and formatted body metadata from appservice transactions", 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 connected = new Promise((resolve, reject) => { - wsServer.on("connection", (socket) => { - socket.once("message", () => resolve()); - socket.send(JSON.stringify({ - command: "transaction", - events: [ - { - content: { - body: "* old", - "m.new_content": { - body: "corrected", - formatted_body: "corrected", - "m.mentions": { room: true, user_ids: ["@bob:example"] }, - msgtype: "m.text", - }, - "m.relates_to": { event_id: "$old", rel_type: "m.replace" }, - msgtype: "m.text", - }, - event_id: "$edit", - room_id: "!room:example", - sender: "@alice:example", - type: "m.room.message", - }, - { - content: { - body: "thread reply", - "m.relates_to": { - event_id: "$thread", - is_falling_back: false, - "m.in_reply_to": { event_id: "$parent" }, - rel_type: "m.thread", - }, - msgtype: "m.text", - }, - event_id: "$thread-reply", - room_id: "!room:example", - sender: "@alice:example", - type: "m.room.message", - }, - ], - id: 11, - txn_id: "txn-relations", - })); - }); - }); - const websocket = createWebsocket(homeserver, { - dispatch, - log: (() => {}) as BridgeLogger, - }); - websockets.push(websocket); - - websocket.start(); - await connected; - - expect(dispatch).toHaveBeenNthCalledWith(1, expect.objectContaining({ - edited: true, - eventId: "$edit", - html: "corrected", - mentions: { room: true, userIds: ["@bob:example"] }, - relation: { eventId: "$old", type: "m.replace" }, - replaces: "$old", - text: "corrected", - })); - expect(dispatch).toHaveBeenNthCalledWith(2, expect.objectContaining({ - edited: false, - eventId: "$thread-reply", - relation: { eventId: "$thread", isFallback: false, replyTo: "$parent", type: "m.thread" }, - replyTo: "$parent", - text: "thread reply", - threadRoot: "$thread", - })); - }); - - it("converts appservice Matrix media messages into attachments", 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 connected = new Promise((resolve) => { - wsServer.on("connection", (socket) => { - socket.once("message", () => resolve()); - socket.send(JSON.stringify({ - command: "transaction", - events: [{ - content: { - body: "photo.png", - info: { - h: 480, - mimetype: "image/png", - size: 12345, - w: 640, - }, - msgtype: "m.image", - url: "mxc://example/photo", - }, - event_id: "$image", - room_id: "!room:example", - sender: "@alice:example", - type: "m.room.message", - }], - id: 12, - txn_id: "txn-media", - })); - }); - }); - const websocket = createWebsocket(homeserver, { - dispatch, - log: (() => {}) as BridgeLogger, - }); - websockets.push(websocket); - - websocket.start(); - await connected; - - expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ - attachments: [{ - contentType: "image/png", - contentUri: "mxc://example/photo", - filename: "photo.png", - height: 480, - kind: "image", - size: 12345, - width: 640, - }], - eventId: "$image", - messageType: "m.image", - text: "photo.png", - })); - }); - - it("converts encrypted appservice Matrix media into encrypted attachments", 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 encryptedFile = { - hashes: { sha256: "hash" }, - iv: "iv", - key: { alg: "A256CTR", ext: true, k: "key", key_ops: ["encrypt", "decrypt"], kty: "oct" }, - url: "mxc://example/encrypted", - v: "v2", - }; - const connected = new Promise((resolve) => { - wsServer.on("connection", (socket) => { - socket.once("message", () => resolve()); - socket.send(JSON.stringify({ - command: "transaction", - events: [{ - content: { - body: "secret.pdf", - file: encryptedFile, - filename: "secret.pdf", - info: { - mimetype: "application/pdf", - size: 777, - }, - msgtype: "m.file", - }, - event_id: "$encrypted-file", - room_id: "!room:example", - sender: "@alice:example", - type: "m.room.message", - }], - id: 13, - txn_id: "txn-encrypted-media", - })); - }); - }); - const websocket = createWebsocket(homeserver, { - dispatch, + handleTransaction, log: (() => {}) as BridgeLogger, }); websockets.push(websocket); @@ -259,17 +63,13 @@ describe("AppserviceWebsocket", () => { websocket.start(); await connected; - expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ - attachments: [{ - contentType: "application/pdf", - encryptedFile, - filename: "secret.pdf", - kind: "file", - size: 777, - }], - eventId: "$encrypted-file", - messageType: "m.file", - text: "secret.pdf", + expect(handleTransaction).toHaveBeenCalledWith(expect.objectContaining({ + events: [expect.objectContaining({ + event_id: "$event", + room_id: "!room:example", + type: "m.room.message", + })], + txn_id: "txn-1", })); }); @@ -349,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) => { @@ -385,7 +184,6 @@ describe("AppserviceWebsocket", () => { }); }); const websocket = createWebsocket(homeserver, { - dispatch, handleTransaction, log: (() => {}) as BridgeLogger, }); @@ -394,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", @@ -529,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 4906480..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,88 +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) { - const relates = objectValue(content["m.relates_to"]); - const newContent = objectValue(content["m.new_content"]); - const messageContent = newContent ?? content; - const relation = matrixRelation(relates); - const replyTo = matrixReplyTo(relates); - const threadRoot = relation?.type === "m.thread" ? relation.eventId : undefined; - const mentions = matrixMentions(messageContent); - return stripUndefined({ - attachments: matrixAttachments(messageContent), - class: "message", - content, - edited: Boolean(newContent && relation?.type === "m.replace"), - encrypted: false, - eventId, - html: stringValue(messageContent.formatted_body), - kind: "message", - mentions, - messageType: stringValue(messageContent.msgtype) ?? "m.text", - raw, - relation, - replaces: relation?.type === "m.replace" ? relation.eventId : undefined, - replyTo, - roomId, - sender, - text: stringValue(messageContent.body) ?? "", - threadRoot, - 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; } @@ -489,77 +389,3 @@ function objectValue(value: unknown): Record | undefined { function stringValue(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } - -function matrixRelation(relates: Record | undefined): Record | undefined { - const eventId = stringValue(relates?.event_id); - const type = stringValue(relates?.rel_type); - if (!eventId || !type) return undefined; - if (type === "m.annotation") { - const key = stringValue(relates?.key); - return key ? { eventId, key, type } : undefined; - } - if (type === "m.thread") { - return { - eventId, - ...(typeof relates?.is_falling_back === "boolean" ? { isFallback: relates.is_falling_back } : {}), - ...(stringValue(objectValue(relates?.["m.in_reply_to"])?.event_id) ? { replyTo: stringValue(objectValue(relates?.["m.in_reply_to"])?.event_id) } : {}), - type, - }; - } - if (type === "m.replace" || type === "m.reference") return { eventId, type }; - return { eventId, type }; -} - -function matrixReplyTo(relates: Record | undefined): string | undefined { - return stringValue(objectValue(relates?.["m.in_reply_to"])?.event_id) - ?? (relates?.rel_type === "m.thread" ? stringValue(relates.event_id) : undefined); -} - -function matrixMentions(content: Record): Record | undefined { - const raw = objectValue(content["m.mentions"]); - if (!raw) return undefined; - const userIds = Array.isArray(raw.user_ids) ? raw.user_ids.filter((userId): userId is string => typeof userId === "string") : undefined; - return stripUndefined({ - room: typeof raw.room === "boolean" ? raw.room : undefined, - userIds, - }); -} - -function matrixAttachments(content: Record): Record[] { - const msgtype = stringValue(content.msgtype); - const kind = matrixAttachmentKind(msgtype); - if (!kind) return []; - const info = objectValue(content.info); - const encryptedFile = objectValue(content.file); - const attachment = stripUndefined({ - contentType: stringValue(info?.mimetype) ?? stringValue(content.info_mimetype), - contentUri: stringValue(content.url), - duration: numberValue(info?.duration), - encryptedFile, - filename: stringValue(content.filename) ?? stringValue(content.body), - height: numberValue(info?.h), - kind, - size: numberValue(info?.size), - width: numberValue(info?.w), - }); - return attachment.contentUri || attachment.encryptedFile ? [attachment] : []; -} - -function matrixAttachmentKind(msgtype: string | undefined): "image" | "video" | "audio" | "file" | undefined { - if (msgtype === "m.image") return "image"; - if (msgtype === "m.video") return "video"; - if (msgtype === "m.audio") return "audio"; - if (msgtype === "m.file") return "file"; - return undefined; -} - -function numberValue(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? 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/bridge.test.ts b/packages/bridge/src/bridge.test.ts index c60a47b..3126811 100644 --- a/packages/bridge/src/bridge.test.ts +++ b/packages/bridge/src/bridge.test.ts @@ -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 } ); @@ -309,7 +309,7 @@ describe("RuntimeBridge", () => { }); }); - it("handles queued remote edits, reactions, deletes, and typing through Matrix transport", async () => { + 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); @@ -366,6 +366,32 @@ describe("RuntimeBridge", () => { 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" }), @@ -384,9 +410,143 @@ describe("RuntimeBridge", () => { 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("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 }), + }); + }); + it("initializes appservice and creates/backfills portal rooms", async () => { const client = createFakeMatrixClient(); const connector = createFakeConnector(createFakeNetworkAPI()); @@ -1089,19 +1249,35 @@ 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(), + handleMatrixRoomName: vi.fn(), + handleMatrixRoomTopic: vi.fn(), + handleMatrixTyping: vi.fn(), }; } @@ -1134,6 +1310,28 @@ 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; } @@ -1211,7 +1409,9 @@ function createFakeMatrixClient(): MatrixClient & { subscription: MatrixSubscrip redact: vi.fn(async () => undefined), send: vi.fn(async (options) => ({ eventId: "$reaction", raw: {}, roomId: options.roomId })), }, - receipts: {} as MatrixClient["receipts"], + receipts: { + send: vi.fn(async () => undefined), + }, rooms: {} as MatrixClient["rooms"], streams: {} as MatrixClient["streams"], subscribe: vi.fn(async (_filter, _handler: (event: MatrixClientEvent) => void | Promise) => subscription), diff --git a/packages/bridge/src/bridge.ts b/packages/bridge/src/bridge.ts index 0934bc1..70efec1 100644 --- a/packages/bridge/src/bridge.ts +++ b/packages/bridge/src/bridge.ts @@ -40,7 +40,14 @@ import type { MatrixReaction, MatrixReactionRemove, MatrixRedaction, + MatrixReadReceipt, + MatrixMarkedUnread, MatrixTyping, + MatrixDeleteChat, + MatrixMembership, + MatrixRoomAvatar, + MatrixRoomName, + MatrixRoomTopic, EventSender, MatrixIntent, MatrixCommand, @@ -82,7 +89,10 @@ import type { HTTPProxyHandlingBridgeConnector, LoginStep, Message, + RemoteDeliveryReceipt, + RemoteMarkUnread, RemoteMessageRemove, + RemoteReadReceipt, RemoteReaction, RemoteReactionRemove, RemoteTyping, @@ -90,7 +100,11 @@ import type { RemoteEventWithTargetPart, } from "./types"; -type GenericMatrixEvent = Extract; kind: string }>; +type GenericMatrixEvent = Extract }> & { + kind: string; + stateKey?: string; + unsigned?: Record; +}; export function createBridge(options: CreateBridgeOptions): PickleBridge { return new RuntimeBridge(options, createMatrixClient(options.matrix)); @@ -694,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 }; } @@ -770,7 +805,7 @@ 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) => { @@ -812,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, @@ -1223,6 +1257,140 @@ 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 }; + const msg: MatrixRoomName = stripUndefined({ + name: stringValue(event.content.name), + portal: this.#portalForRoom(roomId), + }); + let handlers = 0; + for (const client of this.#networkClientsForPortal(msg.portal)) { + if (!hasMethod(client, "handleMatrixRoomName")) continue; + handlers += 1; + await client.handleMatrixRoomName(this.#requestContext(), msg); + } + 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 }; + const msg: MatrixRoomTopic = stripUndefined({ + portal: this.#portalForRoom(roomId), + topic: stringValue(event.content.topic), + }); + let handlers = 0; + for (const client of this.#networkClientsForPortal(msg.portal)) { + if (!hasMethod(client, "handleMatrixRoomTopic")) continue; + handlers += 1; + await client.handleMatrixRoomTopic(this.#requestContext(), msg); + } + 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 }; + const msg: MatrixRoomAvatar = stripUndefined({ + avatarUrl: stringValue(event.content.url), + portal: this.#portalForRoom(roomId), + }); + let handlers = 0; + for (const client of this.#networkClientsForPortal(msg.portal)) { + if (!hasMethod(client, "handleMatrixRoomAvatar")) continue; + handlers += 1; + await client.handleMatrixRoomAvatar(this.#requestContext(), msg); + } + 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; @@ -1286,6 +1454,18 @@ export class RuntimeBridge implements PickleBridge { 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; @@ -1413,6 +1593,57 @@ export class RuntimeBridge implements PickleBridge { } } + 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; @@ -1609,6 +1840,50 @@ 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 isMatrixEditEvent(event: MatrixMessageEvent): boolean { return Boolean(event.edited && matrixEditTargetEventId(event)); } @@ -1630,6 +1905,34 @@ function matrixRedactionTargetEventId(event: GenericMatrixEvent): string | undef 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"; } @@ -1638,6 +1941,10 @@ 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}`; } @@ -1913,6 +2220,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 : {}; 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 893a698..8e72882 100644 --- a/packages/bridge/src/types.ts +++ b/packages/bridge/src/types.ts @@ -1041,7 +1041,9 @@ export interface MatrixRedaction { export interface MatrixReadReceipt { portal: Portal; + receiptType?: string; targetMessage: Message; + userId?: string; } export interface MatrixTyping { @@ -1097,6 +1099,7 @@ export interface MatrixTag { export interface MatrixMarkedUnread { portal: Portal; unread: boolean; + userId?: string; } export interface MatrixDeleteChat { diff --git a/packages/openclaw/package.json b/packages/openclaw/package.json index e95dad4..c64d74c 100644 --- a/packages/openclaw/package.json +++ b/packages/openclaw/package.json @@ -67,10 +67,6 @@ "types": "./dist/matrix-parser.d.mts", "import": "./dist/matrix-parser.mjs" }, - "./openclaw-event-map": { - "types": "./dist/openclaw-event-map.d.mts", - "import": "./dist/openclaw-event-map.mjs" - }, "./openclaw-extension": { "types": "./dist/openclaw-extension.d.mts", "import": "./dist/openclaw-extension.mjs" diff --git a/packages/openclaw/src/approval.test.ts b/packages/openclaw/src/approval.test.ts index 8198ebb..8c3fbb0 100644 --- a/packages/openclaw/src/approval.test.ts +++ b/packages/openclaw/src/approval.test.ts @@ -37,7 +37,7 @@ describe("OpenClaw approval response parsing", () => { approvalKind: "plugin", "m.relates_to": { event_id: "plugin:approval_1", - key: "✅", + key: "approval.allow_once", rel_type: "m.annotation", }, }); @@ -68,42 +68,27 @@ describe("OpenClaw approval response parsing", () => { }); }); - it("also accepts ai-bridge/OpenClaw Matrix approval choice keys and emoji as fallback reactions", () => { + it("does not accept legacy ai-bridge/OpenClaw approval choice keys as reactions", () => { expect(parseApprovalReactionContent({ "m.relates_to": { event_id: "approval_ai_1", key: "✅", }, - })).toMatchObject({ - approvalId: "approval_ai_1", - approved: true, - approvedAlways: false, - decision: "allow_once", - }); + })).toBeUndefined(); expect(parseApprovalReactionContent({ "m.relates_to": { event_id: "approval_ai_2", key: "always_approve", }, - })).toMatchObject({ - approvalId: "approval_ai_2", - approved: true, - approvedAlways: true, - decision: "allow_always", - }); + })).toBeUndefined(); expect(parseApprovalReactionContent({ "m.relates_to": { event_id: "approval_ai_3", key: "❌", }, - })).toMatchObject({ - approvalId: "approval_ai_3", - approved: false, - approvedAlways: false, - decision: "deny", - }); + })).toBeUndefined(); }); it("builds the same approval notice shape as ai-bridge matrix content", () => { diff --git a/packages/openclaw/src/approval.ts b/packages/openclaw/src/approval.ts index 504ad6d..5c57332 100644 --- a/packages/openclaw/src/approval.ts +++ b/packages/openclaw/src/approval.ts @@ -68,10 +68,6 @@ export function defaultBeeperApprovalActions(decisions: readonly ApprovalDecisio } export function parseApprovalReactionKey(key: unknown): ParsedApprovalResponse | undefined { - const aiBridgeChoice = resolveBeeperApprovalChoiceKey(key); - if (aiBridgeChoice) { - return approvalResponseForChoice(aiBridgeChoice); - } switch (key) { case APPROVAL_ALLOW_ONCE_REACTION: return { approved: true, approvedAlways: false, decision: "allow_once" }; @@ -124,8 +120,7 @@ export function parseToolApprovalResponseChunk(chunk: unknown): ParsedApprovalRe export function parseApprovalResponseContent(content: unknown): ParsedApprovalResponse | undefined { return parseToolApprovalResponseChunk(content) ?? parseApprovalResponseFromDeltas(content) - ?? parseApprovalResponseFromAIMessage(content) - ?? parseApprovalReactionContent(content); + ?? parseApprovalResponseFromAIMessage(content); } export function toOpenClawApprovalResolvePayload( @@ -292,19 +287,6 @@ function approvalDecisionValue(value: unknown): ApprovalDecision | undefined { } } -function approvalResponseForChoice(choiceKey: string): ParsedApprovalResponse | undefined { - switch (choiceKey) { - case AI_BRIDGE_APPROVAL_CHOICE_APPROVE: - return { approved: true, approvedAlways: false, decision: "allow_once" }; - case AI_BRIDGE_APPROVAL_CHOICE_ALWAYS_APPROVE: - return { approved: true, approvedAlways: true, decision: "allow_always" }; - case AI_BRIDGE_APPROVAL_CHOICE_DENY: - return { approved: false, approvedAlways: false, decision: "deny" }; - default: - return undefined; - } -} - function approvalReactionKey(decision: ApprovalDecision): string { switch (decision) { case "allow_once": @@ -348,23 +330,6 @@ function approvalKindValue(value: unknown): OpenClawApprovalKind | undefined { return undefined; } -function resolveBeeperApprovalChoiceKey(key: unknown): string | undefined { - if (typeof key !== "string") return undefined; - const normalized = normalizeReactionKey(key); - if (!normalized) return undefined; - for (const choice of defaultBeeperApprovalChoices()) { - if (normalizeReactionKey(choice.key) === normalized || normalizeReactionKey(choice.alias) === normalized) { - return choice.key; - } - } - if (normalized === "♾") return AI_BRIDGE_APPROVAL_CHOICE_ALWAYS_APPROVE; - return undefined; -} - -function normalizeReactionKey(key: string): string { - return key.trim().replace(/\ufe0f/gu, "").toLowerCase(); -} - 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/appservice.ts b/packages/openclaw/src/appservice.ts index 370fde9..ec19468 100644 --- a/packages/openclaw/src/appservice.ts +++ b/packages/openclaw/src/appservice.ts @@ -27,6 +27,7 @@ export interface CreateOpenClawBeeperBridgeOptions extends OpenClawConnectorOpti connector?: CreateNodeBeeperBridgeOptions["connector"]; dataDir?: string; getOnly?: boolean; + log?: CreateNodeBeeperBridgeOptions["log"]; matrix?: CreateNodeBeeperBridgeOptions["matrix"]; store?: CreateNodeBeeperBridgeOptions["store"]; } @@ -51,6 +52,7 @@ export async function createOpenClawBeeperBridge(options: CreateOpenClawBeeperBr 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; @@ -64,27 +66,52 @@ export async function startOpenClawBeeperBridge(options: CreateOpenClawBeeperBri await postOpenClawBridgeRunningState(options); await bridge.setBridgeState("running"); if (options.backfill) { - const config = options.config; - if (!config) throw new Error("OpenClaw backfill requires config"); - const registry = options.registry ?? registryFromConnector(bridge.connector); - if (!registry) throw new Error("OpenClaw backfill requires registry"); - const runtime = tryResolveOpenClawRuntime(options, config); - if (!runtime) return bridge; - const login = userLoginFromOpenClawConfig(config); - const backfillOptions: Parameters[0] = { - bridge, - login, - registry, - runtime, - }; - if (config.importSources !== undefined) backfillOptions.importSources = config.importSources; - if (options.backfillLimit !== undefined) backfillOptions.limit = options.backfillLimit; - await backfillAllOpenClawSessions(backfillOptions); - await registry.save(); + await runStartupBackfill(options, bridge); } return bridge; } +async function runStartupBackfill(options: CreateOpenClawBeeperBridgeOptions, bridge: PickleBridge): Promise { + const config = options.config; + if (!config) { + options.log?.("warn", "openclaw_backfill_skipped", { reason: "missing_config" }); + return; + } + const registry = options.registry ?? registryFromConnector(bridge.connector); + if (!registry) { + options.log?.("warn", "openclaw_backfill_skipped", { reason: "missing_registry" }); + return; + } + const runtime = tryResolveOpenClawRuntime(options, config); + if (!runtime) { + options.log?.("warn", "openclaw_backfill_skipped", { reason: "missing_runtime" }); + return; + } + const login = userLoginFromOpenClawConfig(config); + const backfillOptions: Parameters[0] = { + bridge, + login, + registry, + runtime, + }; + if (config.importSources !== undefined) backfillOptions.importSources = config.importSources; + if (options.backfillLimit !== undefined) backfillOptions.limit = options.backfillLimit; + try { + const result = await backfillAllOpenClawSessions(backfillOptions); + await registry.save(); + options.log?.("info", "openclaw_backfill_finished", { + portals: result.portals.length, + sessions: result.sessions.length, + skipped: result.skipped.length, + }); + } catch (error) { + options.log?.("error", "openclaw_backfill_failed", { + error: errorMessage(error), + stack: errorStack(error), + }); + } +} + async function postOpenClawBridgeRunningState(options: CreateOpenClawBeeperBridgeOptions): Promise { const config = options.config; const bridge = options.bridge ?? config?.bridgeId ?? config?.appserviceId; @@ -131,7 +158,6 @@ function connectorOptions(options: CreateOpenClawBeeperBridgeOptions): OpenClawC if (options.config !== undefined) output.config = options.config; if (options.registry !== undefined) output.registry = options.registry; if (options.runtimeFactory !== undefined) output.runtimeFactory = options.runtimeFactory; - if (options.streams !== undefined) output.streams = options.streams; if (options.runtime !== undefined) output.runtime = options.runtime; return output; } @@ -167,6 +193,14 @@ function registryFromConnector(connector: unknown): OpenClawBridgeRegistry | und return registry instanceof OpenClawBridgeRegistry ? registry : undefined; } +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function errorStack(error: unknown): string | undefined { + return error instanceof Error ? error.stack : undefined; +} + function matrixOptionsFromConfig( config: OpenClawBridgeConfig | undefined, input: CreateNodeBeeperBridgeOptions["matrix"] | undefined diff --git a/packages/openclaw/src/beeper-channel-runtime.test.ts b/packages/openclaw/src/beeper-channel-runtime.test.ts index ba55431..db9719d 100644 --- a/packages/openclaw/src/beeper-channel-runtime.test.ts +++ b/packages/openclaw/src/beeper-channel-runtime.test.ts @@ -112,6 +112,9 @@ describe("BeeperChannelRuntime", () => { 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", @@ -120,6 +123,9 @@ describe("BeeperChannelRuntime", () => { "reaction_remove", "message_remove", "typing", + "read_receipt", + "delivery_receipt", + "mark_unread", ]); expect(client.messages.edit).not.toHaveBeenCalled(); expect(client.reactions.send).not.toHaveBeenCalled(); @@ -127,6 +133,48 @@ describe("BeeperChannelRuntime", () => { 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 = { + flushRemoteEvents: vi.fn(async () => undefined), + getPortalByMXID: vi.fn((roomId: string) => + roomId === "!room" + ? { portalKey: { id: "session:one", receiver: "openclaw:plugin" } } + : undefined + ), + queueRemoteEvent: vi.fn((_login: unknown, event: unknown) => queued.push(event)), + }; + const runtime = new BeeperChannelRuntime({ + bridge: bridge as never, + client: client as never, + getBindingBySessionKey: (sessionKey) => + sessionKey === "agent:main:beeper:abc" + ? { + agentId: "main", + createdAt: 1, + ghostUserId: "@main:example", + id: "binding", + kind: "session", + owner: "bridge", + 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("stores the active runtime for channel adapters", () => { const runtime = new BeeperChannelRuntime({ client: createClient() as never }); setBeeperChannelRuntime(runtime); diff --git a/packages/openclaw/src/beeper-channel-runtime.ts b/packages/openclaw/src/beeper-channel-runtime.ts index fcc4c61..8084a31 100644 --- a/packages/openclaw/src/beeper-channel-runtime.ts +++ b/packages/openclaw/src/beeper-channel-runtime.ts @@ -5,13 +5,17 @@ import { createRemoteMessage, type PickleBridge, type PortalKey, + type RemoteDeliveryReceipt, type RemoteEdit, + type RemoteMarkUnread, type RemoteMessageRemove, + type RemoteReadReceipt, type RemoteReaction, type RemoteReactionRemove, type RemoteTyping, type UserLogin, } from "@beeper/pickle-bridge"; +import { BeeperStreamPublisher } from "./beeper-stream"; import type { OpenClawAgentContact, OpenClawSessionBinding } from "./types"; export interface BeeperChannelRuntimeOptions { @@ -19,6 +23,7 @@ export interface BeeperChannelRuntimeOptions { client: MatrixClient; 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; userId?: string; @@ -39,6 +44,7 @@ export class BeeperChannelRuntime { #bridge: PickleBridge | undefined; #getAgents: () => readonly OpenClawAgentContact[]; #getBindingByRoom: (roomId: string) => OpenClawSessionBinding | undefined; + #getBindingBySessionKey: (sessionKey: string) => OpenClawSessionBinding | undefined; #login: UserLogin | undefined; #log: BeeperChannelRuntimeOptions["log"]; @@ -47,6 +53,7 @@ export class BeeperChannelRuntime { this.client = options.client; this.#getAgents = options.getAgents ?? (() => []); this.#getBindingByRoom = options.getBindingByRoom ?? (() => undefined); + this.#getBindingBySessionKey = options.getBindingBySessionKey ?? (() => undefined); this.#login = options.login; this.#log = options.log; this.userId = options.userId; @@ -113,6 +120,39 @@ export class BeeperChannelRuntime { 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); + } + + createStreamPublisher(options: { + agentId?: string; + roomId: string; + runId: string; + sessionKey: string; + threadRoot?: string; + }): BeeperStreamPublisher { + return new BeeperStreamPublisher({ + client: this.client, + initialMessageMetadata: { + agent_id: options.agentId, + session_key: options.sessionKey, + }, + roomId: options.roomId, + turnId: options.runId, + ...(options.agentId ? { agentId: options.agentId } : {}), + ...(options.threadRoot ? { threadRoot: options.threadRoot } : {}), + ...(this.userId ? { userId: this.userId } : {}), + }); + } + debug(message: string, data?: unknown): void { this.#log?.("debug", message, data); } @@ -223,20 +263,59 @@ export class BeeperChannelRuntime { await route.bridge.flushRemoteEvents(); } - #bridgeRoute(roomId: string): { bridge: PickleBridge; login: UserLogin; portalKey: PortalKey } { + async #queueRemoteReceipt(roomId: string, targetMessageId: string, type: "read_receipt" | "delivery_receipt"): Promise { + const targetId = openClawTargetId(targetMessageId); + const route = this.#bridgeRoute(roomId); + const event: RemoteReadReceipt | RemoteDeliveryReceipt = { + getPortalKey: () => route.portalKey, + getSender: () => this.#eventSender(roomId), + getTargetMessage: () => targetId, + getType: () => type, + }; + route.bridge.queueRemoteEvent(route.login, event); + await route.bridge.flushRemoteEvents(); + } + + async #queueRemoteMarkUnread(roomId: string, targetMessageId: string, unread: boolean): Promise { + const targetId = openClawTargetId(targetMessageId); + const route = this.#bridgeRoute(roomId); + const event: RemoteMarkUnread = { + getPortalKey: () => route.portalKey, + getSender: () => this.#eventSender(roomId), + getTargetMessage: () => targetId, + getType: () => "mark_unread", + getUnread: () => unread, + }; + route.bridge.queueRemoteEvent(route.login, event); + await route.bridge.flushRemoteEvents(); + } + + #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 portal = this.#bridge.getPortalByMXID(roomId); + 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 }; + return { bridge: this.#bridge, login: this.#login, portalKey: portal.portalKey, targetRoomId }; } #eventSender(roomId: string): { isFromMe: boolean; sender: string } { - const binding = this.#getBindingByRoom(roomId); + 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; + } } let currentRuntime: BeeperChannelRuntime | undefined; @@ -279,6 +358,17 @@ function openClawTargetId(eventId: string): string { return eventId; } +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]; +} + function mediaMessageContent(kind: NonNullable, contentUri: string, filename: string | undefined, caption: string | undefined): Record { const msgtype = kind === "image" ? "m.image" diff --git a/packages/openclaw/src/beeper-setup.test.ts b/packages/openclaw/src/beeper-setup.test.ts index beb27e5..8fd6b88 100644 --- a/packages/openclaw/src/beeper-setup.test.ts +++ b/packages/openclaw/src/beeper-setup.test.ts @@ -75,7 +75,6 @@ describe("OpenClaw Beeper setup", () => { expect(seen).toEqual([ expect.objectContaining({ - address: "http://127.0.0.1:29391", bridge: "sh-openclaw-dev", bridgeType: "openclaw", selfHosted: true, diff --git a/packages/openclaw/src/beeper-setup.ts b/packages/openclaw/src/beeper-setup.ts index 7693ace..4b2b68a 100644 --- a/packages/openclaw/src/beeper-setup.ts +++ b/packages/openclaw/src/beeper-setup.ts @@ -116,12 +116,12 @@ export async function createOpenClawBeeperAppService( 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 = { - address: options.address ?? DEFAULT_REGISTRATION_URL, bridge, bridgeType: options.bridgeType ?? DEFAULT_BEEPER_BRIDGE_TYPE, selfHosted: options.selfHosted ?? true, token: options.accessToken, }; + if (options.address && options.address !== DEFAULT_REGISTRATION_URL) request.address = options.address; if (options.baseDomain !== undefined) request.baseDomain = options.baseDomain; if (options.bridgeManagerToken !== undefined) request.hungryToken = options.bridgeManagerToken; if (options.fetch !== undefined) request.fetch = options.fetch; @@ -139,7 +139,7 @@ export async function createOpenClawBeeperAppService( ghostLocalpartPrefix: `${bridge}_agent_`, homeserver: init.homeserver, hsToken: init.registration.hsToken, - registrationUrl: options.address ?? init.registration.url ?? DEFAULT_REGISTRATION_URL, + registrationUrl: init.registration.url || options.address || DEFAULT_REGISTRATION_URL, senderLocalpart: init.registration.senderLocalpart, serviceBotLocalpart: init.registration.senderLocalpart, userLocalpartPrefix: `${bridge}_user_`, diff --git a/packages/openclaw/src/beeper-stream.test.ts b/packages/openclaw/src/beeper-stream.test.ts index af1e09c..f5577a8 100644 --- a/packages/openclaw/src/beeper-stream.test.ts +++ b/packages/openclaw/src/beeper-stream.test.ts @@ -1,7 +1,6 @@ import type { MatrixClient } from "@beeper/pickle"; import { describe, expect, it, vi } from "vitest"; -import { BeeperStreamPublisher, OpenClawBeeperStreamPublisher } from "./beeper-stream"; -import type { OpenClawSessionBinding } from "./types"; +import { BeeperStreamPublisher } from "./beeper-stream"; describe("OpenClaw Beeper native stream publisher", () => { it("starts one native Beeper stream, publishes AG-UI events, and finalizes replacement content", async () => { @@ -78,77 +77,23 @@ describe("OpenClaw Beeper native stream publisher", () => { })); }); - it("keeps one room/run publisher open until a terminal event arrives", async () => { - const { client, finalizeMessage, publishPart, startMessage } = createClient(); - const publisher = new OpenClawBeeperStreamPublisher({ client, userId: "@bot:example.com" }); - const binding = sessionBinding(); - - const startResult = await publisher.publish(binding, [ - { runId: "turn_2", threadId: "turn_2", type: "RUN_STARTED" }, - { messageId: "turn_2", role: "assistant", type: "TEXT_MESSAGE_START" }, - ]); - const finishResult = await publisher.publish(binding, [ - { delta: "hi", messageId: "turn_2", type: "TEXT_MESSAGE_CONTENT" }, - { finishReason: "stop", runId: "turn_2", threadId: "turn_2", type: "RUN_FINISHED" }, - ]); - - expect(startResult).toEqual({ targetEventId: "$target" }); - expect(finishResult).toEqual({ targetEventId: "$target" }); - expect(startMessage).toHaveBeenCalledTimes(1); - expect(publishPart.mock.calls.map(([options]) => options.part.type)).toEqual([ - "RUN_STARTED", - "TEXT_MESSAGE_START", - "TEXT_MESSAGE_CONTENT", - "RUN_FINISHED", - ]); - expect(finalizeMessage).toHaveBeenCalledTimes(1); - }); - - it("uses the active binding run id when the first live chunk has no AG-UI run id", async () => { - const { client, finalizeMessage, publishPart, startMessage } = createClient(); - const publisher = new OpenClawBeeperStreamPublisher({ client, userId: "@bot:example.com" }); - const binding = { ...sessionBinding(), lastRunId: "beeper:run_1", lastStreamRunId: "beeper:run_1" }; - - await publisher.publish(binding, [ - { args: "{}", delta: "{}", toolCallId: "tool_1", type: "TOOL_CALL_ARGS" }, - ]); - await publisher.publish(binding, [ - { delta: "answer", messageId: "beeper:run_1", type: "TEXT_MESSAGE_CONTENT" }, - { finishReason: "stop", runId: "beeper:run_1", threadId: "beeper:run_1", type: "RUN_FINISHED" }, - ]); - - expect(startMessage).toHaveBeenCalledWith(expect.objectContaining({ - content: expect.objectContaining({ - "com.beeper.ai": expect.objectContaining({ id: "beeper:run_1" }), - "com.beeper.ai.metadata": expect.objectContaining({ runId: "beeper:run_1" }), - }), - })); - expect(publishPart.mock.calls.every(([options]) => options.turnId === "beeper:run_1")).toBe(true); - expect(finalizeMessage).toHaveBeenCalledWith(expect.objectContaining({ - body: "answer", - content: expect.objectContaining({ - "com.beeper.ai": expect.objectContaining({ id: "beeper:run_1" }), - }), - })); - }); - it("honors native-only stream finalization without sending a replacement edit", async () => { const { client, finalizeMessage, publishPart, startMessage } = createClient(); - const publisher = new OpenClawBeeperStreamPublisher({ + const publisher = new BeeperStreamPublisher({ client, - config: { streamFinalization: "native-only" }, + roomId: "!room:example.com", + turnId: "turn_3", userId: "@bot:example.com", }); - await publisher.publish(sessionBinding(), [ - { runId: "turn_3", threadId: "turn_3", type: "RUN_STARTED" }, - { delta: "native", messageId: "turn_3", type: "TEXT_MESSAGE_CONTENT" }, - { finishReason: "stop", runId: "turn_3", threadId: "turn_3", type: "RUN_FINISHED" }, - ]); + await publisher.publish({ delta: "native", messageId: "turn_3", type: "TEXT_MESSAGE_CONTENT" }); + await publisher.finalize({ + finalization: "native-only", + terminalPart: { finishReason: "stop", runId: "turn_3", threadId: "turn_3", type: "RUN_FINISHED" }, + }); expect(startMessage).toHaveBeenCalledTimes(1); expect(publishPart.mock.calls.map(([options]) => options.part.type)).toEqual([ - "RUN_STARTED", "TEXT_MESSAGE_CONTENT", "RUN_FINISHED", ]); @@ -157,18 +102,20 @@ describe("OpenClaw Beeper native stream publisher", () => { it("honors append stream finalization without suppressing the streamed event", async () => { const { client, finalizeMessage } = createClient(); - const publisher = new OpenClawBeeperStreamPublisher({ + const publisher = new BeeperStreamPublisher({ client, - config: { streamFinalization: "append" }, + roomId: "!room:example.com", + turnId: "turn_append", userId: "@bot:example.com", }); - const result = await publisher.publish(sessionBinding(), [ - { delta: "append me", messageId: "turn_append", type: "TEXT_MESSAGE_CONTENT" }, - { finishReason: "stop", runId: "turn_append", threadId: "turn_append", type: "RUN_FINISHED" }, - ]); + await publisher.publish({ delta: "append me", messageId: "turn_append", type: "TEXT_MESSAGE_CONTENT" }); + const result = await publisher.finalize({ + finalization: "append", + terminalPart: { finishReason: "stop", runId: "turn_append", threadId: "turn_append", type: "RUN_FINISHED" }, + }); - expect(result).toEqual({ targetEventId: "$target" }); + expect(result).toEqual(expect.objectContaining({ eventId: "$target" })); expect(finalizeMessage).toHaveBeenCalledWith(expect.objectContaining({ body: "append me", eventId: "$target", @@ -180,12 +127,17 @@ describe("OpenClaw Beeper native stream publisher", () => { it("suppresses the streamed event when finalizing replacement content by default", async () => { const { client, finalizeMessage } = createClient(); - const publisher = new OpenClawBeeperStreamPublisher({ client, userId: "@bot:example.com" }); + const publisher = new BeeperStreamPublisher({ + client, + roomId: "!room:example.com", + turnId: "turn_replace", + userId: "@bot:example.com", + }); - await publisher.publish(sessionBinding(), [ - { delta: "replace me", messageId: "turn_replace", type: "TEXT_MESSAGE_CONTENT" }, - { finishReason: "stop", runId: "turn_replace", threadId: "turn_replace", type: "RUN_FINISHED" }, - ]); + await publisher.publish({ delta: "replace me", messageId: "turn_replace", type: "TEXT_MESSAGE_CONTENT" }); + await publisher.finalize({ + terminalPart: { finishReason: "stop", runId: "turn_replace", threadId: "turn_replace", type: "RUN_FINISHED" }, + }); expect(finalizeMessage).toHaveBeenCalledWith(expect.objectContaining({ body: "replace me", @@ -193,24 +145,6 @@ describe("OpenClaw Beeper native stream publisher", () => { })); }); - it("drops a terminal run publisher even when Beeper finalization fails", async () => { - const { client, finalizeMessage, startMessage } = createClient(); - finalizeMessage.mockRejectedValueOnce(new Error("finalize failed")); - const publisher = new OpenClawBeeperStreamPublisher({ client, userId: "@bot:example.com" }); - const binding = sessionBinding(); - - await expect(publisher.publish(binding, [ - { delta: "first", messageId: "turn_4", type: "TEXT_MESSAGE_CONTENT" }, - { error: "boom", message: "boom", runId: "turn_4", type: "RUN_ERROR" }, - ])).rejects.toThrow("finalize failed"); - - await publisher.publish(binding, [ - { delta: "second", messageId: "turn_4", type: "TEXT_MESSAGE_CONTENT" }, - ]); - - expect(startMessage).toHaveBeenCalledTimes(2); - }); - it("finalizes run errors with a readable fallback body", async () => { const { client, finalizeMessage } = createClient(); const publisher = new BeeperStreamPublisher({ @@ -317,20 +251,6 @@ describe("OpenClaw Beeper native stream publisher", () => { }); }); -function sessionBinding(): OpenClawSessionBinding { - return { - agentId: "codex", - createdAt: 1, - ghostUserId: "@openclaw_agent_codex:example.com", - id: "binding", - kind: "session", - owner: "bridge", - roomId: "!room:example.com", - sessionKey: "agent:codex:session", - updatedAt: 1, - }; -} - function createClient() { const startMessage = vi.fn(async () => ({ descriptor: { device_id: "DEVICE", type: "com.beeper.llm", user_id: "@bot:example.com" }, diff --git a/packages/openclaw/src/beeper-stream.ts b/packages/openclaw/src/beeper-stream.ts index 91ac8e1..efacb2f 100644 --- a/packages/openclaw/src/beeper-stream.ts +++ b/packages/openclaw/src/beeper-stream.ts @@ -7,10 +7,9 @@ import { getFinalMessageText, type BeeperFinalMessageAccumulator, } from "@beeper/pickle/streams/beeper-message"; -import type { OpenClawBridgeStreamPublisher, OpenClawStreamPublishResult } from "./bridge-agent"; import { SerialQueue } from "./serial"; import { AGUIEventType, createTurnId, type AGUIEvent } from "./stream-map"; -import type { OpenClawBridgeConfig, OpenClawSessionBinding } from "./types"; +import type { OpenClawBridgeConfig } from "./types"; type FinishReason = "stop" | "length" | "content_filter" | "tool_calls" | null; @@ -251,79 +250,6 @@ export class BeeperStreamPublisher { } } -export interface OpenClawBeeperStreamPublisherOptions { - client: BeeperStreamPublisherClient; - config?: Pick; - userId?: string; -} - -export class OpenClawBeeperStreamPublisher implements OpenClawBridgeStreamPublisher { - #client: BeeperStreamPublisherClient; - #config: Pick; - #publishers = new Map(); - #userId: string | undefined; - - constructor(options: OpenClawBeeperStreamPublisherOptions) { - this.#client = options.client; - this.#config = options.config ?? {}; - this.#userId = options.userId; - } - - async publish(binding: OpenClawSessionBinding, events: AGUIEvent[]): Promise { - if (!events.length) return undefined; - const key = streamKey(binding, events); - let publisher = this.#publishers.get(key); - if (!publisher) { - publisher = new BeeperStreamPublisher({ - agentId: binding.agentId, - client: this.#client, - initialMessageMetadata: { - agent_id: binding.agentId, - session_key: binding.sessionKey, - }, - roomId: binding.roomId, - turnId: firstRunId(events) ?? binding.lastStreamRunId ?? binding.lastRunId ?? createTurnId(), - ...(this.#userId ? { userId: this.#userId } : {}), - }); - this.#publishers.set(key, publisher); - } - - const terminal = events.find(isTerminalEvent); - const nonTerminal = terminal ? events.filter((event) => event !== terminal) : events; - await publisher.publishMany(nonTerminal); - if (terminal) { - try { - const finalized = await publisher.finalize({ - finalization: this.#config.streamFinalization, - terminalPart: terminal, - }); - const raw = recordValue(finalized.raw); - return { targetEventId: stringValue(raw?.logicalEventId) ?? finalized.eventId }; - } finally { - this.#publishers.delete(key); - } - } - return publisher.targetEventId ? { targetEventId: publisher.targetEventId } : undefined; - } -} - -function streamKey(binding: OpenClawSessionBinding, events: AGUIEvent[]): string { - return `${binding.roomId}:${firstRunId(events) ?? binding.lastStreamRunId ?? binding.lastRunId ?? binding.sessionKey}`; -} - -function firstRunId(events: AGUIEvent[]): string | undefined { - for (const event of events) { - const record = event as Record; - const runId = stringValue(record.runId) ?? stringValue(record.threadId) ?? stringValue(record.messageId); - if (runId) return runId; - } - return undefined; -} - -function isTerminalEvent(event: AGUIEvent): boolean { - return event.type === AGUIEventType.RUN_FINISHED || event.type === AGUIEventType.RUN_ERROR; -} - function terminalFallbackText(event: AGUIEvent | undefined): string { if (!event) return ""; if (event.type === AGUIEventType.RUN_ERROR) { diff --git a/packages/openclaw/src/bridge-agent.test.ts b/packages/openclaw/src/bridge-agent.test.ts index 8e3636e..c2545fe 100644 --- a/packages/openclaw/src/bridge-agent.test.ts +++ b/packages/openclaw/src/bridge-agent.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { resolve } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { createDefaultConfig } from "./config"; -import { OpenClawMatrixBridgeAgent, type OpenClawBridgeStreamPublisher } from "./bridge-agent"; +import { OpenClawMatrixBridgeAgent } from "./bridge-agent"; import { OpenClawGatewayRuntime, type OpenClawGatewayEvent, type OpenClawTransport } from "./openclaw-runtime"; import { OpenClawBridgeRegistry } from "./registry"; import type { OpenClawSessionBinding } from "./types"; @@ -16,30 +16,19 @@ describe("OpenClawMatrixBridgeAgent", () => { runtime: runtimeWith({ responses: { "agents.list": { agents: [{ id: "codex", name: "Codex" }] } }, }), - streams: { publish: vi.fn() }, }); await agent.syncAgentContacts(); expect(registry.getAgent("codex")?.ghostUserId).toBe("@openclaw_agent_codex:localhost"); }); - it("sends Matrix room text to the bound OpenClaw session and streams run events", async () => { + it("sends Matrix room text to the bound OpenClaw session", async () => { const registry = await tempRegistry(); registry.upsertBinding(testBinding()); - const published: Array<{ binding: OpenClawSessionBinding; chunks: unknown[] }> = []; - const streams: OpenClawBridgeStreamPublisher = { - publish(binding, chunks) { - published.push({ binding, chunks }); - }, - }; const runtime = runtimeWith({ - events: [ - { event: "assistant.delta", payload: { data: { delta: "hi" }, runId: "run_1", type: "assistant.delta" } }, - { event: "run.completed", payload: { runId: "run_1", type: "run.completed" } }, - ], responses: { "sessions.send": { runId: "run_1", sessionKey: "agent:codex:main" } }, }); - const agent = new OpenClawMatrixBridgeAgent({ registry, runtime, streams }); + const agent = new OpenClawMatrixBridgeAgent({ registry, runtime }); await agent.handleMatrixText({ eventId: "$event", @@ -54,45 +43,6 @@ describe("OpenClawMatrixBridgeAgent", () => { message: "hello", }, { expectFinal: false }); expect(registry.getBindingByRoom("!room:example.com")?.lastRunId).toBe("run_1"); - expect(published.flatMap((item) => item.chunks).map((chunk) => (chunk as { type: string }).type)).toEqual([ - "TEXT_MESSAGE_START", - "TEXT_MESSAGE_CONTENT", - "TEXT_MESSAGE_END", - "RUN_FINISHED", - ]); - }); - - it("persists the Beeper stream target event id for later relation handling", async () => { - const registry = await tempRegistry(); - registry.upsertBinding(testBinding()); - const streams: OpenClawBridgeStreamPublisher = { - publish: vi.fn(async () => ({ targetEventId: "$stream-root" })), - }; - const agent = new OpenClawMatrixBridgeAgent({ - registry, - runtime: runtimeWith({ - events: [ - { event: "assistant.delta", payload: { data: { delta: "hi" }, runId: "run_1", type: "assistant.delta" } }, - { event: "run.completed", payload: { runId: "run_1", type: "run.completed" } }, - ], - responses: { "sessions.send": { runId: "run_1", sessionKey: "agent:codex:main" } }, - }), - streams, - }); - - await agent.handleMatrixText({ - eventId: "$event", - roomId: "!room:example.com", - sender: "@alice:example.com", - text: "hello", - }); - - expect(registry.getBindingByRoom("!room:example.com")).toMatchObject({ - lastMatrixEventId: "$event", - lastRunId: "run_1", - lastStreamRunId: "run_1", - lastStreamTargetEventId: "$stream-root", - }); }); it("does not poison message dedupe when OpenClaw send fails before persistence", async () => { @@ -103,7 +53,7 @@ describe("OpenClawMatrixBridgeAgent", () => { "sessions.send": new Error("gateway down"), }, }); - const agent = new OpenClawMatrixBridgeAgent({ registry, runtime, streams: { publish: vi.fn() } }); + const agent = new OpenClawMatrixBridgeAgent({ registry, runtime }); await expect(agent.handleMatrixText({ eventId: "$retryable", @@ -149,7 +99,7 @@ describe("OpenClawMatrixBridgeAgent", () => { "sessions.send": { runId: "run_1", sessionKey: "agent:codex:session_1" }, }, }); - const agent = new OpenClawMatrixBridgeAgent({ registry, runtime, streams: { publish: vi.fn() } }); + const agent = new OpenClawMatrixBridgeAgent({ registry, runtime }); await agent.handleMatrixText({ eventId: "$event", @@ -169,108 +119,6 @@ describe("OpenClawMatrixBridgeAgent", () => { expect(registry.getBindingByRoom("!room:example.com")?.sessionKey).toBe("agent:codex:session_1"); }); - it("preserves gateway event names when streaming protocol-v4 payload frames", async () => { - const registry = await tempRegistry(); - const binding = testBinding(); - registry.upsertBinding(binding); - const published: unknown[] = []; - const streams: OpenClawBridgeStreamPublisher = { - publish(_binding, chunks) { - published.push(...chunks); - }, - }; - const agent = new OpenClawMatrixBridgeAgent({ - registry, - runtime: runtimeWith({ - events: [ - { event: "session.operation", payload: { phase: "started", runId: "run_1" } }, - { event: "session.message", payload: { deltaText: "hello", role: "assistant", runId: "run_1" } }, - { event: "session.tool", payload: { input: { cmd: "pwd" }, name: "shell", phase: "started", runId: "run_1", toolCallId: "tool_1" } }, - { event: "exec.approval.requested", payload: { approvalId: "approval_1", message: "Run command?", runId: "run_1", toolCallId: "tool_1" } }, - { event: "session.operation", payload: { phase: "completed", runId: "run_1" } }, - ], - responses: {}, - }), - streams, - }); - - await agent.streamRun(binding, "run_1"); - - expect(published.map((chunk) => (chunk as { type: string }).type)).toEqual([ - "RUN_STARTED", - "TEXT_MESSAGE_START", - "TEXT_MESSAGE_CONTENT", - "TOOL_CALL_START", - "TOOL_CALL_ARGS", - "TOOL_CALL_END", - "CUSTOM", - "TEXT_MESSAGE_END", - "RUN_FINISHED", - ]); - }); - - it("seeds streaming state with the actual OpenClaw run id", async () => { - const registry = await tempRegistry(); - const binding = testBinding(); - const published: unknown[] = []; - const agent = new OpenClawMatrixBridgeAgent({ - registry, - runtime: runtimeWith({ - events: [ - { event: "session.message", payload: { deltaText: "hello", role: "assistant", runId: "run_actual" } }, - { event: "session.operation", payload: { phase: "completed", runId: "run_actual" } }, - ], - responses: {}, - }), - streams: { - publish(_binding, chunks) { - published.push(...chunks); - }, - }, - }); - - await agent.streamRun(binding, "run_actual"); - - expect(published).toEqual([ - expect.objectContaining({ messageId: "run_actual", type: "TEXT_MESSAGE_START" }), - expect.objectContaining({ messageId: "run_actual", type: "TEXT_MESSAGE_CONTENT" }), - expect.objectContaining({ messageId: "run_actual", type: "TEXT_MESSAGE_END" }), - expect.objectContaining({ runId: "run_actual", type: "RUN_FINISHED" }), - ]); - }); - - it("stops consuming gateway events after a terminal run event", async () => { - const registry = await tempRegistry(); - const binding = testBinding(); - let consumedAfterTerminal = false; - const runtime = new OpenClawGatewayRuntime({ - config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), - transport: { - async *events() { - yield { event: "run.completed", payload: { runId: "run_1", type: "run.completed" } }; - consumedAfterTerminal = true; - yield { event: "assistant.delta", payload: { data: { delta: "late" }, runId: "run_1", type: "assistant.delta" } }; - }, - request: vi.fn(), - }, - }); - const streams: OpenClawBridgeStreamPublisher = { - publish: vi.fn(), - }; - const agent = new OpenClawMatrixBridgeAgent({ registry, runtime, streams }); - - await agent.streamRun(binding, "run_1"); - - expect(consumedAfterTerminal).toBe(false); - expect(streams.publish).toHaveBeenCalledWith(expect.objectContaining({ - ...binding, - lastRunId: "run_1", - lastStreamRunId: "run_1", - }), expect.arrayContaining([ - expect.objectContaining({ type: "RUN_FINISHED" }), - ])); - }); - it("forwards Beeper approval responses back to OpenClaw", async () => { const registry = await tempRegistry(); const runtime = runtimeWith({ @@ -279,7 +127,7 @@ describe("OpenClawMatrixBridgeAgent", () => { "plugin.approval.resolve": { ok: true }, }, }); - const agent = new OpenClawMatrixBridgeAgent({ registry, runtime, streams: { publish: vi.fn() } }); + const agent = new OpenClawMatrixBridgeAgent({ registry, runtime }); await expect(agent.handleApprovalContent({ approvalId: "approval_1", diff --git a/packages/openclaw/src/bridge-agent.ts b/packages/openclaw/src/bridge-agent.ts index cbfec12..27a57fb 100644 --- a/packages/openclaw/src/bridge-agent.ts +++ b/packages/openclaw/src/bridge-agent.ts @@ -4,20 +4,10 @@ import { toOpenClawApprovalResolvePayload, type ParsedApprovalResponse, } from "./approval"; -import { createOpenClawStreamState, mapOpenClawEventToBeeperChunks } from "./openclaw-event-map"; -import type { OpenClawGatewayRuntime, OpenClawGatewayEvent, OpenClawMatrixMessageMetadata } from "./openclaw-runtime"; +import type { OpenClawGatewayRuntime, OpenClawMatrixMessageMetadata } from "./openclaw-runtime"; import type { OpenClawBridgeRegistry } from "./registry"; -import { AGUIEventType, type AGUIEvent } from "./stream-map"; import type { OpenClawSessionBinding } from "./types"; -export interface OpenClawBridgeStreamPublisher { - publish(binding: OpenClawSessionBinding, events: AGUIEvent[]): Promise | OpenClawStreamPublishResult | undefined; -} - -export interface OpenClawStreamPublishResult { - targetEventId?: string; -} - export interface MatrixTextTurn { attachments?: unknown[]; eventId: string; @@ -31,19 +21,13 @@ export interface MatrixTextTurn { export class OpenClawMatrixBridgeAgent { readonly registry: OpenClawBridgeRegistry; readonly runtime: OpenClawGatewayRuntime; - readonly streams: OpenClawBridgeStreamPublisher; - readonly backgroundStreaming: boolean; constructor(options: { - backgroundStreaming?: boolean; registry: OpenClawBridgeRegistry; runtime: OpenClawGatewayRuntime; - streams: OpenClawBridgeStreamPublisher; }) { - this.backgroundStreaming = options.backgroundStreaming ?? false; this.registry = options.registry; this.runtime = options.runtime; - this.streams = options.streams; } async syncAgentContacts(): Promise { @@ -79,14 +63,6 @@ export class OpenClawMatrixBridgeAgent { })); this.registry.markDedupe(turn.eventId); await this.registry.save(); - const stream = this.streamRun({ ...binding, sessionKey: run.sessionKey }, run.runId); - if (this.backgroundStreaming) { - void stream.catch((error) => { - console.error("[openclaw-beeper] failed to stream OpenClaw run to Beeper", error); - }); - } else { - await stream; - } } async handleApprovalContent(content: unknown, approvalId?: string): Promise { @@ -99,30 +75,6 @@ export class OpenClawMatrixBridgeAgent { return response; } - async streamRun(binding: OpenClawSessionBinding, runId: string): Promise { - const state = createOpenClawStreamState(runId); - for await (const gatewayEvent of this.runtime.eventsForRun(runId)) { - const chunks = mapOpenClawEventToBeeperChunks(state, openClawEventFromGateway(gatewayEvent)); - if (chunks.length > 0) { - const result = await this.streams.publish({ - ...binding, - lastRunId: runId, - lastStreamRunId: runId, - }, chunks); - const targetEventId = result?.targetEventId; - if (targetEventId) { - this.registry.updateBinding(binding.id, (current) => ({ - ...current, - lastStreamRunId: runId, - lastStreamTargetEventId: targetEventId, - updatedAt: Date.now(), - })); - } - if (chunks.some(isTerminalStreamEvent)) break; - } - } - } - async ensureSession(binding: OpenClawSessionBinding): Promise { if (binding.sessionKey !== agentPortalSessionKey(binding.agentId)) return binding.sessionKey; const createOptions: { agentId: string; label?: string } = { @@ -142,18 +94,3 @@ export class OpenClawMatrixBridgeAgent { export function agentPortalSessionKey(agentId: string): string { return `agent:${agentId}`; } - -function openClawEventFromGateway(event: OpenClawGatewayEvent): unknown { - if (event.event && event.payload && typeof event.payload === "object") { - return { ...(event.payload as Record), payload: event.payload, type: event.event }; - } - if (event.payload && typeof event.payload === "object") { - return event.payload; - } - if (event.event) return { type: event.event, data: event.payload }; - return event; -} - -function isTerminalStreamEvent(event: AGUIEvent): boolean { - return event.type === AGUIEventType.RUN_FINISHED || event.type === AGUIEventType.RUN_ERROR; -} diff --git a/packages/openclaw/src/config.test.ts b/packages/openclaw/src/config.test.ts index 0497b46..cf92b77 100644 --- a/packages/openclaw/src/config.test.ts +++ b/packages/openclaw/src/config.test.ts @@ -23,7 +23,7 @@ describe("OpenClaw bridge config", () => { dataDir: "/tmp/openclaw-bridge", ghostLocalpartPrefix: "openclaw_agent_", nonFederatedRooms: true, - registrationUrl: "http://127.0.0.1:29391", + registrationUrl: "websocket", senderLocalpart: "openclawbot", serviceBotLocalpart: "openclawbot", storePath: "/tmp/openclaw-bridge/matrix-store", diff --git a/packages/openclaw/src/config.ts b/packages/openclaw/src/config.ts index 9de0883..e197a9b 100644 --- a/packages/openclaw/src/config.ts +++ b/packages/openclaw/src/config.ts @@ -8,7 +8,7 @@ import type { OpenClawBridgeConfig } from "./types"; export const DEFAULT_APPSERVICE_ID = "sh-openclaw"; export const DEFAULT_GHOST_LOCALPART_PREFIX = "openclaw_agent_"; -export const DEFAULT_REGISTRATION_URL = "http://127.0.0.1:29391"; +export const DEFAULT_REGISTRATION_URL = "websocket"; export const DEFAULT_SENDER_LOCALPART = "openclawbot"; export const DEFAULT_SERVICE_BOT_LOCALPART = "openclawbot"; export const DEFAULT_USER_LOCALPART_PREFIX = "openclaw_user_"; @@ -152,7 +152,7 @@ function envStreamFinalization(value: string | undefined): OpenClawBridgeConfig[ } function envApprovalBehavior(value: string | undefined): OpenClawBridgeConfig["approvalBehavior"] | undefined { - if (value === "native" || value === "reactions" || value === "slash" || value === "disabled") return value; + if (value === "native" || value === "disabled") return value; return undefined; } diff --git a/packages/openclaw/src/connector.test.ts b/packages/openclaw/src/connector.test.ts index 3aa8ac2..484fcd2 100644 --- a/packages/openclaw/src/connector.test.ts +++ b/packages/openclaw/src/connector.test.ts @@ -65,7 +65,6 @@ describe("OpenClawBridgeConnector", () => { login: login(), registry, runtime, - streams: { publish: vi.fn() }, }); const registerGhost = vi.fn(); await api.connect({ bridge: { registerGhost }, queue: vi.fn(), queueRemoteEvent: vi.fn() } as unknown as Parameters[0]); @@ -94,7 +93,6 @@ describe("OpenClawBridgeConnector", () => { login: login(), registry, runtime, - streams: { publish: vi.fn() }, }); const registerGhost = vi.fn(); await api.connect({ bridge: { registerGhost }, queue: vi.fn(), queueRemoteEvent: vi.fn() } as unknown as Parameters[0]); @@ -107,7 +105,6 @@ describe("OpenClawBridgeConnector", () => { login: login(), registry, runtime: hidden, - streams: { publish: vi.fn() }, }); const hiddenRegisterGhost = vi.fn(); await hiddenApi.connect({ bridge: { registerGhost: hiddenRegisterGhost }, queue: vi.fn(), queueRemoteEvent: vi.fn() } as unknown as Parameters[0]); @@ -122,7 +119,6 @@ describe("OpenClawBridgeConnector", () => { login: login(), registry, runtime: runtimeWith({ responses: { "sessions.create": { key: "agent:codex:session_1" } } }), - streams: { publish: vi.fn() }, }); await expect(api.resolveIdentifier({ bridge: { createPortal: vi.fn() } } as unknown as BridgeRequestContext, { createDM: false, @@ -224,7 +220,6 @@ describe("OpenClawBridgeConnector", () => { login: login(), registry: new OpenClawBridgeRegistry("/tmp/openclaw-connector-unknown-agent-test.json"), runtime, - streams: { publish: vi.fn() }, }); const createPortal = vi.fn(); @@ -256,7 +251,6 @@ describe("OpenClawBridgeConnector", () => { login: login(), registry, runtime: runtimeWith({ responses: { "sessions.create": { key: "agent:codex:session_2" } } }), - streams: { publish: vi.fn() }, }); const createPortal = vi.fn(async (loginArg, options) => ({ id: options.id, @@ -299,7 +293,6 @@ describe("OpenClawBridgeConnector", () => { login: login(), registry, runtime, - streams: { publish: vi.fn() }, }); await expect(api.listContacts({} as BridgeRequestContext, { query: "code" })).resolves.toEqual({ @@ -342,7 +335,6 @@ describe("OpenClawBridgeConnector", () => { login: login(), registry, runtime, - streams: { publish: vi.fn() }, }); await expect(api.listContacts({} as BridgeRequestContext, { query: "telegram" })).resolves.toEqual({ @@ -385,7 +377,6 @@ describe("OpenClawBridgeConnector", () => { login: login(), registry, runtime, - streams: { publish: vi.fn() }, }); const portal = { id: "agent:codex", @@ -432,7 +423,6 @@ describe("OpenClawBridgeConnector", () => { login: login(), registry, runtime, - streams: { publish: vi.fn() }, }); const sessionKey = "agent:main:main"; const roomId = `!session:${Buffer.from(sessionKey).toString("base64url")}.openclaw:plugin:beeper.local`; @@ -454,7 +444,7 @@ describe("OpenClawBridgeConnector", () => { }), { expectFinal: false }); }); - it("dispatches Matrix text and approval reactions to OpenClaw", async () => { + 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" } }], @@ -469,7 +459,6 @@ describe("OpenClawBridgeConnector", () => { login: login(), registry, runtime, - streams: { publish: vi.fn() }, }); const portal = { id: "agent:codex", @@ -517,10 +506,11 @@ describe("OpenClawBridgeConnector", () => { approvedAlways: false, decision: "deny", }, + ignored: "approval-reactions-disabled", }, }, }); - expect(runtime.transport.request).toHaveBeenCalledWith("exec.approval.resolve", { + expect(runtime.transport.request).not.toHaveBeenCalledWith("exec.approval.resolve", { approvalId: "approval_1", decision: "deny", }); @@ -655,7 +645,6 @@ describe("OpenClawBridgeConnector", () => { login: login(), registry, runtime, - streams: { publish: vi.fn() }, }); const portal = { id: "agent:codex", @@ -720,7 +709,6 @@ describe("OpenClawBridgeConnector", () => { login: login(), registry, runtime, - streams: { publish: vi.fn() }, }); await api.handleMatrixMessage({} as BridgeRequestContext, { @@ -793,7 +781,6 @@ describe("OpenClawBridgeConnector", () => { login: login(), registry, runtime, - streams: { publish: vi.fn() }, }); await expect(api.handleMatrixMessage({} as BridgeRequestContext, { @@ -846,7 +833,6 @@ describe("OpenClawBridgeConnector", () => { login: login(), registry, runtime, - streams: { publish: vi.fn() }, }); const portal = { id: "agent:codex", @@ -999,7 +985,6 @@ describe("OpenClawBridgeConnector", () => { login: login(), registry, runtime, - streams: { publish: vi.fn() }, }); const queueRemoteEvent = vi.fn(); const createPortal = vi.fn(async (_login: UserLogin, options: { id: string }) => ({ @@ -1029,7 +1014,7 @@ describe("OpenClawBridgeConnector", () => { parts: [{ content: { body: expect.stringContaining("Import sources: dashboard") } }], }); await expect(queueRemoteEvent.mock.calls.at(-1)?.[1].convertMessage()).resolves.toMatchObject({ - parts: [{ content: { body: expect.stringContaining("Approvals: native Beeper UI with slash/reaction escape hatches") } }], + parts: [{ content: { body: expect.stringContaining("Approvals: native Beeper UI") } }], }); await api.handleMatrixMessage(ctx, { @@ -1102,10 +1087,11 @@ describe("OpenClawBridgeConnector", () => { sender: { userId: "@alice:example.com" }, text: "/new fresh", } as MatrixMessage); - expect(runtime.transport.request).toHaveBeenCalledWith("sessions.create", { + expect(runtime.transport.request).toHaveBeenCalledWith("sessions.create", expect.objectContaining({ agentId: "codex", + key: expect.stringMatching(/^agent:codex:beeper:/u), label: "fresh", - }); + })); expect(createPortal).toHaveBeenCalledWith(login(), { creationContent: { "m.federate": false }, id: "session:YWdlbnQ6Y29kZXg6bmV3", @@ -1128,9 +1114,21 @@ describe("OpenClawBridgeConnector", () => { parts: [{ content: { body: "Created a new OpenClaw session room: !new-room:example.com" } }], }); expect(runtime.transport.request).not.toHaveBeenCalledWith("sessions.send", expect.anything(), expect.anything()); + + await api.handleMatrixMessage(ctx, { + event: { eventId: "$new-default" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "/new", + } as MatrixMessage); + expect(runtime.transport.request).toHaveBeenCalledWith("sessions.create", expect.objectContaining({ + agentId: "codex", + key: expect.stringMatching(/^agent:codex:beeper:/u), + label: "New OpenClaw Session", + })); }); - it("creates a new agent session room from slash commands in unbound rooms", async () => { + it("binds unbound rooms to new OpenClaw sessions from slash commands", async () => { const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); registry.upsertAgent({ agentId: "codex", displayName: "Codex", ghostUserId: "@codex:example.com" }); const runtime = runtimeWith({ @@ -1143,16 +1141,10 @@ describe("OpenClawBridgeConnector", () => { login: login(), registry, runtime, - streams: { publish: vi.fn() }, }); const queueRemoteEvent = vi.fn(); - const createPortal = vi.fn(async () => ({ - id: "session:YWdlbnQ6Y29kZXg6bmV3LWZyb20tbWFuYWdlbWVudA", - mxid: "!new-management-room:example.com", - portalKey: { id: "session:YWdlbnQ6Y29kZXg6bmV3LWZyb20tbWFuYWdlbWVudA", receiver: "login" }, - receiver: "login", - })); - const ctx = { bridge: { createPortal }, queueRemoteEvent } as unknown as BridgeRequestContext; + const registerPortal = vi.fn(); + const ctx = { bridge: { registerPortal }, queueRemoteEvent } as unknown as BridgeRequestContext; const portal = { id: "management", mxid: "!management:example.com", @@ -1167,54 +1159,132 @@ describe("OpenClawBridgeConnector", () => { text: "/new codex Deep work", } as MatrixMessage); - expect(runtime.transport.request).toHaveBeenCalledWith("sessions.create", { + expect(runtime.transport.request).toHaveBeenCalledWith("sessions.create", expect.objectContaining({ agentId: "codex", + key: expect.stringMatching(/^agent:codex:beeper:/u), label: "Deep work", - }); - expect(createPortal).toHaveBeenCalledWith(login(), { - creationContent: { "m.federate": false }, - id: "session:YWdlbnQ6Y29kZXg6bmV3LWZyb20tbWFuYWdlbWVudA", - metadata: { - openclaw: { - agentId: "codex", - ghostUserId: "@codex:example.com", - sessionKey: "agent:codex:new-from-management", - }, - }, - name: "Deep work", - roomType: "dm", - }); - expect(registry.getBindingByRoom("!new-management-room:example.com")).toMatchObject({ + })); + expect(registry.getBindingByRoom("!management:example.com")).toMatchObject({ agentId: "codex", label: "Deep work", sessionKey: "agent:codex:new-from-management", }); + expect(registerPortal).toHaveBeenCalledWith(expect.objectContaining({ + id: "session:YWdlbnQ6Y29kZXg6bmV3LWZyb20tbWFuYWdlbWVudA", + mxid: "!management:example.com", + portalKey: { + id: "session:YWdlbnQ6Y29kZXg6bmV3LWZyb20tbWFuYWdlbWVudA", + receiver: "openclaw:plugin", + }, + receiver: "openclaw:plugin", + })); + await expect(queueRemoteEvent.mock.calls.at(-1)?.[1].convertMessage()).resolves.toMatchObject({ + parts: [{ content: { body: expect.stringContaining("Created a new OpenClaw session in this room") } }], + }); await api.handleMatrixMessage(ctx, { event: { eventId: "$new-missing-agent" }, - portal, + portal: { + id: "fresh-management", + mxid: "!fresh-management:example.com", + portalKey: { id: "fresh-management", receiver: "login" }, + receiver: "login", + }, sender: { userId: "@alice:example.com" }, text: "/new", } as MatrixMessage); - await expect(queueRemoteEvent.mock.calls.at(-1)?.[1].convertMessage()).resolves.toMatchObject({ - parts: [{ content: { body: expect.stringContaining("Usage: /new [agent-id]") } }], + expect(runtime.transport.request).toHaveBeenCalledWith("sessions.create", expect.objectContaining({ + agentId: "main", + key: expect.stringMatching(/^agent:main:beeper:/u), + label: "New OpenClaw Session", + })); + expect(registry.getBindingByRoom("!fresh-management:example.com")).toMatchObject({ + agentId: "main", + label: "New OpenClaw Session", }); }); - it("honors configured approval behavior for reactions and slash commands", async () => { + 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" }, + "sessions.send": { 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", + key: expect.stringMatching(/^agent:main:beeper:/u), + label: "New OpenClaw Session", + })); + expect(runtime.transport.request).toHaveBeenCalledWith("sessions.send", expect.objectContaining({ + idempotencyKey: "$hello", + key: "agent:main:auto", + message: "hey", + }), { expectFinal: false }); + expect(registry.getBindingByRoom("!cloud-room:example.com")).toMatchObject({ + agentId: "main", + label: "New OpenClaw Session", + sessionKey: "agent:main:auto", + }); + expect(registerPortal).toHaveBeenCalledWith(expect.objectContaining({ + id: "session:YWdlbnQ6bWFpbjphdXRv", + metadata: { + openclaw: { + agentId: "main", + ghostUserId: "@openclaw_agent_main:localhost", + label: "New OpenClaw Session", + sessionKey: "agent:main:auto", + }, + }, + mxid: "!cloud-room:example.com", + portalKey: { + id: "session:YWdlbnQ6bWFpbjphdXRv", + receiver: "openclaw:plugin", + }, + receiver: "openclaw:plugin", + })); + }); + + it("rejects reaction and slash approval fallbacks", async () => { const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); const runtime = runtimeWith({ responses: { "exec.approval.resolve": { ok: true }, }, }); - runtime.config.approvalBehavior = "slash"; + runtime.config.approvalBehavior = "native"; const api = new OpenClawNetworkAPI({ config: runtime.config, login: login(), registry, runtime, - streams: { publish: vi.fn() }, }); const portal = { id: "agent:codex", @@ -1234,6 +1304,7 @@ describe("OpenClawBridgeConnector", () => { }); expect(runtime.transport.request).not.toHaveBeenCalledWith("exec.approval.resolve", expect.anything()); + runtime.config.approvalBehavior = "disabled"; await api.handleMatrixMessage({} as BridgeRequestContext, { content: { approvalId: "approval_native_disabled", @@ -1260,10 +1331,13 @@ describe("OpenClawBridgeConnector", () => { sender: { userId: "@alice:example.com" }, text: "/approve approval_1", } as MatrixMessage); - expect(runtime.transport.request).toHaveBeenCalledWith("exec.approval.resolve", { + expect(runtime.transport.request).not.toHaveBeenCalledWith("exec.approval.resolve", { approvalId: "approval_1", decision: "approve", }); + await expect(queueRemoteEvent.mock.calls.at(-1)?.[1].convertMessage()).resolves.toMatchObject({ + parts: [{ content: { body: "Approval slash commands are disabled for this bridge." } }], + }); await api.handleMatrixMessage({ queueRemoteEvent } as unknown as BridgeRequestContext, { content: { @@ -1277,7 +1351,7 @@ describe("OpenClawBridgeConnector", () => { sender: { userId: "@alice:example.com" }, text: "/deny", } as MatrixMessage); - expect(runtime.transport.request).toHaveBeenCalledWith("exec.approval.resolve", { + expect(runtime.transport.request).not.toHaveBeenCalledWith("exec.approval.resolve", { approvalId: "approval_1_reply", decision: "deny", }); @@ -1293,62 +1367,6 @@ describe("OpenClawBridgeConnector", () => { parts: [{ content: { body: "Approval slash commands are disabled for this bridge." } }], }); - runtime.config.approvalBehavior = "slash"; - await api.handleMatrixMessage({ queueRemoteEvent } as unknown as BridgeRequestContext, { - event: { eventId: "$approve-missing" }, - portal, - sender: { userId: "@alice:example.com" }, - text: "/approve", - } as MatrixMessage); - await expect(queueRemoteEvent.mock.calls.at(-1)?.[1].convertMessage()).resolves.toMatchObject({ - parts: [{ content: { body: "Usage: /approve or reply to an approval message with /approve" } }], - }); - }); - - it("keeps slash and reaction approval escape hatches enabled in native approval mode", async () => { - const runtime = runtimeWith({ - responses: { - "exec.approval.resolve": { ok: true }, - }, - }); - runtime.config.approvalBehavior = "native"; - const api = new OpenClawNetworkAPI({ - config: runtime.config, - login: login(), - registry: new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"), - runtime, - streams: { publish: vi.fn() }, - }); - 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", - }; - const queueRemoteEvent = vi.fn(); - - await api.handleMatrixMessage({ queueRemoteEvent } as unknown as BridgeRequestContext, { - event: { eventId: "$approve-native" }, - portal, - sender: { userId: "@alice:example.com" }, - text: "/approve approval_slash", - } as MatrixMessage); - await api.handleMatrixReaction({} as BridgeRequestContext, { - content: { "m.relates_to": { event_id: "approval_reaction", key: "approval.deny" } }, - event: { eventId: "$reaction-native" }, - portal, - targetMessage: { id: "approval_reaction" }, - } as MatrixReaction); - - expect(runtime.transport.request).toHaveBeenCalledWith("exec.approval.resolve", { - approvalId: "approval_slash", - decision: "approve", - }); - expect(runtime.transport.request).toHaveBeenCalledWith("exec.approval.resolve", { - approvalId: "approval_reaction", - decision: "deny", - }); }); it("rebuilds an OpenClaw room binding from a persisted Pickle session portal without metadata", async () => { @@ -1365,7 +1383,6 @@ describe("OpenClawBridgeConnector", () => { login: login(), registry, runtime, - streams: { publish: vi.fn() }, }); const sessionKey = "agent:codex:dashboard:one"; const portal = { @@ -1408,7 +1425,6 @@ describe("OpenClawBridgeConnector", () => { login: login(), registry, runtime, - streams: { publish: vi.fn() }, }); const sessionKey = "agent:main:dashboard:abc"; const roomId = `!session:${Buffer.from(sessionKey).toString("base64url")}.openclaw:plugin:beeper.local`; @@ -1455,7 +1471,6 @@ describe("OpenClawBridgeConnector", () => { login: login(), registry, runtime, - streams: { publish: vi.fn() }, }); const portal = { id: "agent:codex", diff --git a/packages/openclaw/src/connector.ts b/packages/openclaw/src/connector.ts index 4a45319..7c8f493 100644 --- a/packages/openclaw/src/connector.ts +++ b/packages/openclaw/src/connector.ts @@ -1,3 +1,6 @@ +import { + randomUUID, +} from "node:crypto"; import { createRemoteMessage, type BackfillingNetworkAPI, @@ -23,23 +26,39 @@ import { MatrixReaction, MatrixReactionRemove, MatrixRedaction, + MatrixReadReceipt, + MatrixMarkedUnread, + MatrixDeleteChat, + MatrixMembership, + MatrixRoomAvatar, + MatrixRoomName, + MatrixRoomTopic, + MatrixTyping, MessageHandlingNetworkAPI, + type DeleteChatHandlingNetworkAPI, + type MarkedUnreadHandlingNetworkAPI, + type MembershipHandlingNetworkAPI, NetworkAPI, NetworkGeneralCapabilities, Portal, + type PortalKey, ReactionHandlingNetworkAPI, + type ReadReceiptHandlingNetworkAPI, type ReactionRemoveHandlingNetworkAPI, type RedactionHandlingNetworkAPI, + type RoomAvatarHandlingNetworkAPI, + type RoomNameHandlingNetworkAPI, + type RoomTopicHandlingNetworkAPI, + type TypingHandlingNetworkAPI, Reaction, ResolveIdentifierParams, ResolveIdentifierResponse, UserLogin, } from "@beeper/pickle-bridge"; import { backfillAllOpenClawSessions, buildBackfillImport, discoverOneToOneSessions } from "./backfill"; -import { parseApprovalResponseContent } from "./approval"; +import { parseApprovalReactionContent, parseApprovalResponseContent } from "./approval"; import { BeeperChannelRuntime, setBeeperChannelRuntime } from "./beeper-channel-runtime"; -import { OpenClawBeeperStreamPublisher } from "./beeper-stream"; -import { agentPortalSessionKey, OpenClawMatrixBridgeAgent, type OpenClawBridgeStreamPublisher } from "./bridge-agent"; +import { agentPortalSessionKey, OpenClawMatrixBridgeAgent } from "./bridge-agent"; import { createDefaultConfig } from "./config"; import { parseMatrixTextMessage, type ParsedMatrixTextMessage } from "./matrix-parser"; import { createOpenClawHostTransport, OpenClawGatewayRuntime, type OpenClawHostRuntime, type OpenClawMatrixMessageMetadata } from "./openclaw-runtime"; @@ -47,12 +66,13 @@ import { OpenClawBridgeRegistry } from "./registry"; import { agentContactFromOpenClawAgent, agentGhostUserId, serviceBotUserId } from "./rooms"; import type { OpenClawAgentContact, OpenClawBridgeConfig, OpenClawSessionBinding, OpenClawUserContact } from "./types"; +const DEFAULT_NEW_SESSION_LABEL = "New OpenClaw Session"; + export interface OpenClawConnectorOptions { config?: OpenClawBridgeConfig; registry?: OpenClawBridgeRegistry; runtime?: OpenClawGatewayRuntime | OpenClawHostRuntime; runtimeFactory?: (config: OpenClawBridgeConfig) => OpenClawGatewayRuntime; - streams?: OpenClawBridgeStreamPublisher; } export function createOpenClawConnector(options: OpenClawConnectorOptions = {}): OpenClawBridgeConnector { @@ -64,12 +84,10 @@ export class OpenClawBridgeConnector implements BridgeConnector OpenClawGatewayRuntime; - #streams: OpenClawBridgeStreamPublisher | undefined; constructor(options: OpenClawConnectorOptions = {}) { this.config = options.config ?? createDefaultConfig(); this.registry = options.registry ?? new OpenClawBridgeRegistry(); - this.#streams = options.streams; const runtime = options.runtime instanceof OpenClawGatewayRuntime ? options.runtime : options.runtime @@ -129,19 +147,14 @@ export class OpenClawBridgeConnector implements BridgeConnector { await this.registry.load(); - const streamOptions: ConstructorParameters[0] = { - client: ctx.client, - config: this.config, - }; const ownUserId = ctx.bridge.getOwnUserId(); - if (ownUserId) streamOptions.userId = ownUserId; - this.#streams ??= new OpenClawBeeperStreamPublisher(streamOptions); const login = userLoginFromOpenClawConfig(this.config); setBeeperChannelRuntime(new BeeperChannelRuntime({ bridge: ctx.bridge, client: ctx.client, 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), ...(ownUserId ? { userId: ownUserId } : {}), @@ -168,12 +181,11 @@ export class OpenClawBridgeConnector implements BridgeConnector undefined }, }); } } -export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetworkAPI, ContactListingNetworkAPI, MessageHandlingNetworkAPI, EditHandlingNetworkAPI, ReactionHandlingNetworkAPI, ReactionRemoveHandlingNetworkAPI, RedactionHandlingNetworkAPI, BackfillingNetworkAPI { +export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetworkAPI, ContactListingNetworkAPI, MessageHandlingNetworkAPI, EditHandlingNetworkAPI, ReactionHandlingNetworkAPI, ReactionRemoveHandlingNetworkAPI, RedactionHandlingNetworkAPI, ReadReceiptHandlingNetworkAPI, MarkedUnreadHandlingNetworkAPI, TypingHandlingNetworkAPI, RoomNameHandlingNetworkAPI, RoomTopicHandlingNetworkAPI, RoomAvatarHandlingNetworkAPI, MembershipHandlingNetworkAPI, DeleteChatHandlingNetworkAPI, BackfillingNetworkAPI { readonly #agent: OpenClawMatrixBridgeAgent; readonly #config: OpenClawBridgeConfig; readonly #login: UserLogin; @@ -185,17 +197,14 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor login: UserLogin; registry: OpenClawBridgeRegistry; runtime: OpenClawGatewayRuntime; - streams: OpenClawBridgeStreamPublisher; }) { this.#config = options.config; this.#login = options.login; this.#registry = options.registry; this.#runtime = options.runtime; this.#agent = new OpenClawMatrixBridgeAgent({ - backgroundStreaming: true, registry: options.registry, runtime: options.runtime, - streams: options.streams, }); } @@ -290,7 +299,7 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor } const binding = bindingFromPortal(msg.portal, this.#runtime.config); if (binding && !this.#registry.getBindingByRoom(msg.portal.mxid ?? "")) this.#registry.upsertBinding(binding); - const currentBinding = msg.portal.mxid ? this.#registry.getBindingByRoom(msg.portal.mxid) ?? binding : binding; + let currentBinding = msg.portal.mxid ? this.#registry.getBindingByRoom(msg.portal.mxid) ?? binding : binding; const approval = parseApprovalResponseContent(msg.content); if (approval) { if (approvalNativeEnabled(this.#runtime.config)) { @@ -307,6 +316,7 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor await this.#runtime.abortSession(abortOptions); return { pending: false }; } + if (currentBinding) this.registerCanonicalPortalForBinding(ctx, msg.portal, currentBinding); if (parsed.command) { return await this.handleSlashCommand(ctx, parsed.command, currentBinding, msg); } @@ -316,7 +326,9 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor portalKey: msg.portal.portalKey, roomId: msg.portal.mxid, }); + currentBinding = await this.createBindingForMatrixRoom(msg.portal.mxid, DEFAULT_NEW_SESSION_LABEL); } + this.registerCanonicalPortalForBinding(ctx, msg.portal, currentBinding); await this.#agent.handleMatrixText({ ...(parsed.attachments.length > 0 ? { attachments: parsed.attachments } : {}), eventId: msg.event.eventId, @@ -364,6 +376,10 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor 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); @@ -436,6 +452,84 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor }); } + async handleMatrixReadReceipt(_ctx: BridgeRequestContext, msg: MatrixReadReceipt): 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.targetMessage.id}:read:${msg.userId ?? "unknown"}`, + matrix: { + relation: { + kind: "read_receipt", + ...(msg.receiptType ? { receiptType: msg.receiptType } : {}), + targetEventId: msg.targetMessage.id, + ...streamTargetRelationPatch(binding, msg.targetMessage.id), + }, + sender: msg.userId ?? "receipt", + }, + roomId: msg.portal.mxid, + replyToEventId: msg.targetMessage.id, + sender: msg.userId ?? "receipt", + text: `Read receipt for ${msg.targetMessage.id}`, + }); + } + + async handleMatrixMarkedUnread(_ctx: BridgeRequestContext, msg: MatrixMarkedUnread): Promise { + if (!msg.portal.mxid) return; + if (!this.isAllowedRoom(msg.portal.mxid)) return; + this.upsertPortalBinding(msg.portal); + const eventId = `${msg.portal.mxid}:marked-unread:${msg.unread ? "1" : "0"}:${Date.now()}`; + await this.#agent.handleMatrixText({ + eventId, + matrix: { + relation: { + kind: "marked_unread", + unread: msg.unread, + }, + sender: msg.userId ?? "marked_unread", + }, + roomId: msg.portal.mxid, + sender: msg.userId ?? "marked_unread", + text: msg.unread ? "Marked room unread" : "Unmarked room unread", + }); + } + + 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; + this.#registry.upsertBinding({ ...binding, label: msg.name, updatedAt: Date.now() }); + await this.#registry.save(); + } + + async handleMatrixRoomTopic(_ctx: BridgeRequestContext, msg: MatrixRoomTopic): Promise { + if (!msg.portal.mxid || !this.isAllowedRoom(msg.portal.mxid)) return; + this.upsertPortalBinding(msg.portal); + } + + async handleMatrixRoomAvatar(_ctx: BridgeRequestContext, msg: MatrixRoomAvatar): Promise { + if (!msg.portal.mxid || !this.isAllowedRoom(msg.portal.mxid)) return; + this.upsertPortalBinding(msg.portal); + } + + 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(); + } + async fetchMessages(_ctx: BridgeRequestContext, params: FetchMessagesParams): Promise { const binding = bindingFromPortal(params.portal, this.#runtime.config); if (!this.isAllowedRoom(binding?.roomId ?? params.portal.mxid)) return { hasMore: false, messages: [] }; @@ -485,20 +579,22 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor binding: OpenClawSessionBinding | undefined, msg: MatrixMessage, ): Promise { + const notice = (text: string, noticeBinding = binding) => + commandNotice(ctx, this.#login, msg, text, canonicalPortalKeyForBinding(noticeBinding, this.#login.id) ?? msg.portal.portalKey); switch (command.name) { case "status": - return commandNotice(ctx, this.#login, msg, bridgeStatusText(this.#runtime.config, this.#registry.data.bindings.length)); + return notice(bridgeStatusText(this.#runtime.config, this.#registry.data.bindings.length)); case "settings": - return commandNotice(ctx, this.#login, msg, bridgeSettingsText(this.#runtime.config, this.#registry.data.bindings.length)); + return notice(bridgeSettingsText(this.#runtime.config, this.#registry.data.bindings.length)); case "sessions": { const options: Parameters[1] = {}; if (this.#runtime.config.importSources !== undefined) options.importSources = this.#runtime.config.importSources; const sessions = await discoverOneToOneSessions(this.#runtime, options); - return commandNotice(ctx, this.#login, msg, sessionsSummaryText(sessions)); + return notice(sessionsSummaryText(sessions)); } case "backfill": const count = await this.backfillCurrentRoom(ctx, binding, msg); - return commandNotice(ctx, this.#login, msg, `Queued backfill for ${count} message${count === 1 ? "" : "s"}.`); + return notice(`Queued backfill for ${count} message${count === 1 ? "" : "s"}.`); case "import": { const importOptions: Parameters[0] = { bridge: ctx.bridge, @@ -509,14 +605,23 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor if (this.#runtime.config.importSources !== undefined) importOptions.importSources = this.#runtime.config.importSources; if (this.#runtime.config.backfillLimit !== undefined) importOptions.limit = this.#runtime.config.backfillLimit; const result = await backfillAllOpenClawSessions(importOptions); - return commandNotice(ctx, this.#login, msg, importSummaryText(result)); + return notice(importSummaryText(result)); } case "new": { const request = this.resolveNewSessionCommand(command.args, binding); if (!request) { - return commandNotice(ctx, this.#login, msg, "Usage: /new [agent-id] [session label]. In an agent DM, /new [session label] is enough."); + return notice("Usage: /new [agent-id] [session label]. In an agent DM, /new [session label] is enough."); + } + if (!binding && msg.portal.mxid) { + const created = await this.createBindingForMatrixRoom(msg.portal.mxid, request.label, request.agentId, request.ghostUserId); + this.registerCanonicalPortalForBinding(ctx, msg.portal, created); + return notice(`Created a new OpenClaw session in this room: ${created.sessionKey}`, created); } - const session = await this.#runtime.createSession({ agentId: request.agentId, label: request.label }); + const session = await this.#runtime.createSession({ + agentId: request.agentId, + key: newBeeperSessionKey(request.agentId), + label: request.label, + }); const portalOptions: Parameters[1] = { id: portalIdForSession(session.key), metadata: { @@ -547,29 +652,29 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor }); } await this.#registry.save(); - return commandNotice(ctx, this.#login, msg, portal.mxid + return notice(portal.mxid ? `Created a new OpenClaw session room: ${portal.mxid}` : `Created a new OpenClaw session: ${session.key}`); } case "approve": case "deny": { if (!approvalSlashEnabled(this.#runtime.config)) { - return commandNotice(ctx, this.#login, msg, "Approval slash commands are disabled for this bridge."); + return notice("Approval slash commands are disabled for this bridge."); } const approvalId = command.args.trim() || approvalIdFromMatrixReply(msg); - if (!approvalId) return commandNotice(ctx, this.#login, msg, `Usage: /${command.name} or reply to an approval message with /${command.name}`); + if (!approvalId) return notice(`Usage: /${command.name} or reply to an approval message with /${command.name}`); await this.#agent.handleApprovalContent({ approvalId, approved: command.name === "approve", approvedAlways: false, type: "tool-approval-response", }, approvalId); - return commandNotice(ctx, this.#login, msg, `${command.name === "approve" ? "Approved" : "Denied"} ${approvalId}.`); + return notice(`${command.name === "approve" ? "Approved" : "Denied"} ${approvalId}.`); } case "agent": - return commandNotice(ctx, this.#login, msg, binding ? `Agent: ${binding.agentId}` : "This room is not bound to an OpenClaw agent yet."); + return notice(binding ? `Agent: ${binding.agentId}` : "This room is not bound to an OpenClaw agent yet."); default: - return commandNotice(ctx, this.#login, msg, `Unknown OpenClaw command: /${command.name}`); + return notice(`Unknown OpenClaw command: /${command.name}`); } } @@ -631,6 +736,16 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor 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, @@ -640,17 +755,49 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor return { agentId: binding.agentId, ghostUserId: binding.ghostUserId, - label: trimmed || binding.label || "Beeper", + label: trimmed || DEFAULT_NEW_SESSION_LABEL, }; } const [agentId, ...labelParts] = trimmed.split(/\s+/u).filter(Boolean); - if (!agentId) return undefined; - const contact = this.#registry.getAgent(agentId) ?? agentContactFromOpenClawAgent(this.#runtime.config, { id: agentId }); + 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(" ") || "Beeper", + 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 session = await this.#runtime.createSession({ + agentId, + key: newBeeperSessionKey(agentId), + label, + }); + const now = Date.now(); + const binding: OpenClawSessionBinding = { + agentId, + createdAt: now, + ghostUserId, + id: Buffer.from(roomId).toString("base64url"), + kind: "session", + label, + owner: "bridge", + roomId, + sessionKey: session.key, + updatedAt: now, }; + this.#registry.upsertBinding(binding); + await this.#registry.save(); + return binding; } private async createSessionPortalForAgent( @@ -658,19 +805,27 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor contact: OpenClawAgentContact, label = contact.displayName, ): Promise { - const session = await this.#runtime.createSession({ agentId: contact.agentId, label }); + const session = await this.#runtime.createSession({ + agentId: contact.agentId, + key: newBeeperSessionKey(contact.agentId), + label, + }); return portalForAgentSession(contact, this.#login.id, session.key, label); } } -function commandNotice(ctx: BridgeRequestContext, login: UserLogin, msg: MatrixMessage, text: string): MatrixMessageResponse { +function newBeeperSessionKey(agentId: string): string { + return `agent:${agentId}:beeper:${randomUUID()}`; +} + +function commandNotice(ctx: BridgeRequestContext, login: UserLogin, msg: MatrixMessage, text: string, portalKey = msg.portal.portalKey): MatrixMessageResponse { ctx.queueRemoteEvent(login, createRemoteMessage({ convert: () => ({ parts: [{ content: { body: text, msgtype: "m.notice" }, id: "body", type: "m.text" }], }), data: { text }, id: `${msg.event.eventId}:openclaw-command`, - portalKey: msg.portal.portalKey, + portalKey, sender: { isFromMe: true, sender: "openclawbot", @@ -680,6 +835,33 @@ function commandNotice(ctx: BridgeRequestContext, login: UserLogin, msg: MatrixM return { pending: false }; } +function canonicalPortalForBinding(portal: Portal, binding: OpenClawSessionBinding, receiver: string): Portal { + const id = portalIdForSession(binding.sessionKey); + 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 } : {}), + sessionKey: binding.sessionKey, + }), + }, + mxid: binding.roomId, + portalKey: { id, receiver }, + receiver, + roomType: portal.roomType ?? "dm", + }; +} + +function canonicalPortalKeyForBinding(binding: OpenClawSessionBinding | undefined, receiver: string): PortalKey | undefined { + if (!binding) return undefined; + return { id: portalIdForSession(binding.sessionKey), receiver }; +} + function bridgeStatusText(config: OpenClawBridgeConfig, boundRooms: number): string { return [ "OpenClaw Beeper bridge", @@ -716,22 +898,18 @@ function bridgeSettingsText(config: OpenClawBridgeConfig, boundRooms: number): s function describeApprovalBehavior(behavior: OpenClawBridgeConfig["approvalBehavior"]): string { switch (behavior ?? "native") { case "native": - return "native Beeper UI with slash/reaction escape hatches"; - case "reactions": - return "reaction fallback only"; - case "slash": - return "slash command fallback only"; + return "native Beeper UI"; case "disabled": return "disabled"; } } -function approvalReactionsEnabled(config: OpenClawBridgeConfig): boolean { - return config.approvalBehavior === undefined || config.approvalBehavior === "native" || config.approvalBehavior === "reactions"; +function approvalReactionsEnabled(_config: OpenClawBridgeConfig): boolean { + return false; } -function approvalSlashEnabled(config: OpenClawBridgeConfig): boolean { - return config.approvalBehavior === undefined || config.approvalBehavior === "native" || config.approvalBehavior === "slash"; +function approvalSlashEnabled(_config: OpenClawBridgeConfig): boolean { + return false; } function approvalNativeEnabled(config: OpenClawBridgeConfig): boolean { diff --git a/packages/openclaw/src/index.ts b/packages/openclaw/src/index.ts index 6c5b5b3..18874ff 100644 --- a/packages/openclaw/src/index.ts +++ b/packages/openclaw/src/index.ts @@ -9,7 +9,6 @@ export * from "./cli"; export * from "./config"; export * from "./connector"; export * from "./matrix-parser"; -export * from "./openclaw-event-map"; export * from "./openclaw-extension"; export * from "./openclaw-runtime"; export * from "./plugin-entry"; diff --git a/packages/openclaw/src/integration.test.ts b/packages/openclaw/src/integration.test.ts index e7d502a..c4e10ab 100644 --- a/packages/openclaw/src/integration.test.ts +++ b/packages/openclaw/src/integration.test.ts @@ -10,7 +10,7 @@ import { OpenClawGatewayRuntime, type OpenClawGatewayEvent, type OpenClawTranspo import { OpenClawBridgeRegistry } from "./registry"; describe("OpenClaw bridge integration", () => { - it("dispatches a Matrix DM through Pickle into OpenClaw and publishes native stream chunks", async () => { + it("dispatches a Matrix DM through Pickle into OpenClaw", async () => { const dir = await mkdtemp(resolve(tmpdir(), "pickle-openclaw-integration-")); const config = createDefaultConfig({ dataDir: dir, @@ -18,23 +18,17 @@ describe("OpenClaw bridge integration", () => { matrixUserId: "@openclawbot:example", }); const transport = fakeTransport({ - events: [ - { event: "assistant.delta", payload: { data: { delta: "hi" }, runId: "run_1", type: "assistant.delta" } }, - { event: "run.completed", payload: { runId: "run_1", type: "run.completed" } }, - ], responses: { "agents.list": { agents: [{ id: "codex", name: "Codex" }] }, "sessions.create": { key: "session_1" }, "sessions.send": { runId: "run_1", sessionKey: "session_1" }, }, }); - const streams = { publish: vi.fn(async () => {}) }; const registry = new OpenClawBridgeRegistry(resolve(dir, "registry.json")); const connector = createOpenClawConnector({ config, registry, runtimeFactory: () => new OpenClawGatewayRuntime({ config, transport }), - streams, }); const client = createFakeMatrixClient(); const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, client); @@ -76,13 +70,6 @@ describe("OpenClaw bridge integration", () => { matrix: { sender: "@alice:example" }, message: "hello", }, { expectFinal: false }); - await vi.waitFor(() => expect(streams.publish).toHaveBeenCalledWith( - expect.objectContaining({ - roomId: "!codex:example", - sessionKey: "session_1", - }), - expect.arrayContaining([expect.objectContaining({ type: "TEXT_MESSAGE_CONTENT" })]), - )); expect(registry.getBindingByRoom("!codex:example")).toMatchObject({ lastMatrixEventId: "$hello", lastRunId: "run_1", @@ -90,7 +77,7 @@ describe("OpenClaw bridge integration", () => { }); }); - it("dispatches approval reactions through Pickle into OpenClaw approval resolution", async () => { + 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, @@ -108,7 +95,6 @@ describe("OpenClaw bridge integration", () => { config, registry, runtimeFactory: () => new OpenClawGatewayRuntime({ config, transport }), - streams: { publish: vi.fn(async () => {}) }, }); const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, createFakeMatrixClient()); const login = userLoginFromOpenClawConfig(config); @@ -142,13 +128,13 @@ describe("OpenClaw bridge integration", () => { roomId: "!codex:example", }); - expect(transport.request).toHaveBeenCalledWith("exec.approval.resolve", { + expect(transport.request).not.toHaveBeenCalledWith("exec.approval.resolve", { approvalId: "approval_1", decision: "approve", }); }); - it("dispatches Matrix edits, emoji reactions, and redactions through Pickle into OpenClaw", async () => { + it("dispatches Matrix edits, emoji reactions, redactions, receipts, and unread state through Pickle into OpenClaw", async () => { const dir = await mkdtemp(resolve(tmpdir(), "pickle-openclaw-relations-integration-")); const config = createDefaultConfig({ dataDir: dir, @@ -166,7 +152,6 @@ describe("OpenClaw bridge integration", () => { config, registry, runtimeFactory: () => new OpenClawGatewayRuntime({ config, transport }), - streams: { publish: vi.fn(async () => {}) }, }); const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, createFakeMatrixClient()); const login = userLoginFromOpenClawConfig(config); @@ -207,6 +192,16 @@ describe("OpenClaw bridge integration", () => { 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(transport.request).toHaveBeenCalledWith("sessions.send", expect.objectContaining({ idempotencyKey: "$edit:edit", @@ -232,9 +227,23 @@ describe("OpenClaw bridge integration", () => { message: "Redacted message $old", replyTo: { eventId: "$old", roomId: "!codex:example" }, }), { expectFinal: false }); + expect(transport.request).toHaveBeenCalledWith("sessions.send", expect.objectContaining({ + idempotencyKey: "$old:read:@alice:example", + matrix: expect.objectContaining({ + relation: expect.objectContaining({ kind: "read_receipt", receiptType: "m.read", targetEventId: "$old" }), + }), + message: "Read receipt for $old", + replyTo: { eventId: "$old", roomId: "!codex:example" }, + }), { expectFinal: false }); + expect(transport.request).toHaveBeenCalledWith("sessions.send", expect.objectContaining({ + matrix: expect.objectContaining({ + relation: expect.objectContaining({ kind: "marked_unread", unread: true }), + }), + message: "Marked room unread", + }), { expectFinal: false }); }); - it("smokes contact DM creation, Matrix ingress, native streaming, approval, and backfill with local fakes", async () => { + it("smokes contact DM creation, Matrix ingress, approval, and backfill with local fakes", async () => { const dir = await mkdtemp(resolve(tmpdir(), "pickle-openclaw-local-smoke-")); const config = createDefaultConfig({ accessToken: "mx-token", @@ -245,12 +254,6 @@ describe("OpenClaw bridge integration", () => { matrixUserId: "@openclawbot:example", }); const transport = fakeTransport({ - events: [ - { event: "session.operation", payload: { phase: "started", runId: "run_1", sessionKey: "session_1" } }, - { event: "session.message", payload: { deltaText: "hello from OpenClaw", role: "assistant", runId: "run_1" } }, - { event: "exec.approval.requested", payload: { approvalId: "approval_1", message: "Run tool?", runId: "run_1", toolCallId: "tool_1", toolName: "shell" } }, - { event: "session.operation", payload: { phase: "completed", runId: "run_1" } }, - ], responses: { "agents.list": { agents: [{ id: "codex", name: "Codex" }] }, "chat.history": { messages: [{ content: "older desktop turn", id: "m1", role: "user" }] }, @@ -313,34 +316,6 @@ describe("OpenClaw bridge integration", () => { roomId: "!created:example", }); - await vi.waitFor(() => expect(client.beeper.streams.startMessage).toHaveBeenCalledWith(expect.objectContaining({ - content: expect.objectContaining({ - "com.beeper.ai": expect.objectContaining({ id: "run_1" }), - "com.beeper.ai.metadata": expect.objectContaining({ protocol: "ag-ui", runId: "run_1" }), - "com.beeper.stream": { type: "com.beeper.llm", user_id: "@openclawbot:example" }, - }), - roomId: "!created:example", - streamType: "com.beeper.llm", - userId: "@openclawbot:example", - }))); - await vi.waitFor(() => expect(client.beeper.streams.publishPart).toHaveBeenCalledWith(expect.objectContaining({ - part: expect.objectContaining({ type: "CUSTOM" }), - roomId: "!created:example", - turnId: expect.any(String), - }))); - await vi.waitFor(() => expect(client.beeper.streams.finalizeMessage).toHaveBeenCalledWith(expect.objectContaining({ - content: expect.objectContaining({ - "com.beeper.ai": expect.objectContaining({ - parts: expect.arrayContaining([ - expect.objectContaining({ text: "hello from OpenClaw", type: "text" }), - ]), - }), - "com.beeper.stream": { type: "com.beeper.llm", user_id: "@openclawbot:example" }, - }), - eventId: "$stream-root", - roomId: "!created:example", - }))); - await expect(bridge.dispatchMatrixEvent(reactionEvent({ eventId: "$approve", key: "approval.allow_once", @@ -348,7 +323,7 @@ describe("OpenClaw bridge integration", () => { roomId: "!created:example", sender: "@alice:example", }))).resolves.toMatchObject({ dispatched: true, kind: "reaction" }); - expect(transport.request).toHaveBeenCalledWith("exec.approval.resolve", { + expect(transport.request).not.toHaveBeenCalledWith("exec.approval.resolve", { approvalId: "approval_1", decision: "approve", }); @@ -474,6 +449,35 @@ function redactionEvent(options: { eventId: string; redacts: string; roomId: str } 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 () => {}), diff --git a/packages/openclaw/src/openclaw-event-map.test.ts b/packages/openclaw/src/openclaw-event-map.test.ts deleted file mode 100644 index 0480bbb..0000000 --- a/packages/openclaw/src/openclaw-event-map.test.ts +++ /dev/null @@ -1,330 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { defaultBeeperApprovalActions, defaultBeeperApprovalChoices } from "./approval"; -import { createOpenClawStreamState, mapOpenClawEventToBeeperChunks } from "./openclaw-event-map"; - -describe("OpenClaw event to Beeper stream mapping", () => { - it("maps run lifecycle and assistant deltas into a single Beeper message", () => { - const state = createOpenClawStreamState("turn_oc"); - - expect(mapOpenClawEventToBeeperChunks(state, { - agentId: "codex", - runId: "run_1", - sessionKey: "agent:codex:main", - type: "run.started", - })).toEqual([ - { - metadata: { - agent_id: "codex", - run_id: "run_1", - session_key: "agent:codex:main", - turn_id: "turn_oc", - }, - runId: "turn_oc", - threadId: "turn_oc", - type: "RUN_STARTED", - }, - { - messageId: "turn_oc", - role: "assistant", - type: "TEXT_MESSAGE_START", - }, - ]); - - expect(mapOpenClawEventToBeeperChunks(state, { data: { delta: "Hello" }, type: "assistant.delta" })).toEqual([ - { delta: "Hello", messageId: "turn_oc", type: "TEXT_MESSAGE_CONTENT" }, - ]); - expect(mapOpenClawEventToBeeperChunks(state, { data: { delta: " thinking" }, type: "thinking.delta" })).toEqual([ - { messageId: "turn_oc", type: "REASONING_START" }, - { messageId: "turn_oc", role: "reasoning", type: "REASONING_MESSAGE_START" }, - { delta: " thinking", messageId: "turn_oc", type: "REASONING_MESSAGE_CONTENT" }, - ]); - expect(mapOpenClawEventToBeeperChunks(state, { runId: "run_1", type: "run.completed" })).toEqual([ - { messageId: "turn_oc", type: "REASONING_MESSAGE_END" }, - { messageId: "turn_oc", type: "REASONING_END" }, - { - messageId: "turn_oc", - type: "TEXT_MESSAGE_END", - }, - { - finishReason: "stop", - metadata: { finish_reason: "stop", run_id: "run_1", turn_id: "turn_oc" }, - runId: "turn_oc", - threadId: "turn_oc", - type: "RUN_FINISHED", - }, - ]); - }); - - it("maps tool lifecycle events to Desktop-compatible tool chunks", () => { - const state = createOpenClawStreamState("turn_tools"); - - expect(mapOpenClawEventToBeeperChunks(state, { - data: { arguments: "{\"cmd\":\"pnpm test\"}", id: "call_1", name: "shell" }, - type: "tool.call.started", - })).toEqual([ - { - parentMessageId: "call_1", - state: "awaiting-input", - toolCallId: "call_1", - toolCallName: "shell", - toolName: "shell", - type: "TOOL_CALL_START", - }, - { - args: "{\"cmd\":\"pnpm test\"}", - delta: "{\"cmd\":\"pnpm test\"}", - state: "input-streaming", - toolCallId: "call_1", - type: "TOOL_CALL_ARGS", - }, - { - input: { cmd: "pnpm test" }, - state: "input-complete", - toolCallId: "call_1", - toolCallName: "shell", - toolName: "shell", - type: "TOOL_CALL_END", - }, - ]); - - expect(mapOpenClawEventToBeeperChunks(state, { - data: { delta: "{\"cmd\"", toolCallId: "call_2", toolName: "edit" }, - type: "tool.call.delta", - })).toEqual([ - { - args: "{\"cmd\"", - delta: "{\"cmd\"", - state: "input-streaming", - toolCallId: "call_2", - type: "TOOL_CALL_ARGS", - }, - ]); - - expect(mapOpenClawEventToBeeperChunks(state, { - data: { output: "ok", preliminary: true, toolCallId: "call_1", toolName: "shell" }, - type: "tool.call.completed", - })).toEqual([ - { - content: "ok", - messageId: "call_1", - preliminary: true, - role: "tool", - state: "streaming", - toolCallId: "call_1", - toolName: "shell", - type: "TOOL_CALL_RESULT", - }, - ]); - - expect(mapOpenClawEventToBeeperChunks(state, { - data: { error: { message: "denied" }, toolCallId: "call_3", toolName: "write" }, - type: "tool.call.failed", - })).toEqual([ - { - content: "{\"message\":\"denied\"}", - messageId: "call_3", - role: "tool", - state: "error", - toolCallId: "call_3", - toolName: "write", - type: "TOOL_CALL_RESULT", - }, - ]); - }); - - it("maps OpenClaw approval events to Beeper approval chunks", () => { - const state = createOpenClawStreamState("turn_approvals"); - - expect(mapOpenClawEventToBeeperChunks(state, { - data: { - approvalId: "approval_1", - message: "Allow shell?", - toolCallId: "call_1", - toolName: "shell", - }, - type: "approval.requested", - })).toEqual([ - { - name: "approval-requested", - type: "CUSTOM", - value: { - approval: { - id: "approval_1", - needsApproval: true, - }, - approvalMessageId: "approval_1", - approvalActions: defaultBeeperApprovalActions(), - choices: defaultBeeperApprovalChoices(), - message: "Allow shell?", - toolCallId: "call_1", - toolName: "shell", - }, - }, - ]); - expect(state.toolCallIdToApprovalId.call_1).toBe("approval_1"); - - expect(mapOpenClawEventToBeeperChunks(state, { - data: { - approvalId: "approval_1", - decision: "approve", - toolCallId: "call_1", - }, - type: "approval.resolved", - })).toEqual([ - { - name: "approval-responded", - type: "CUSTOM", - value: { - approval: { - always: false, - approved: true, - id: "approval_1", - }, - toolCallId: "call_1", - }, - }, - ]); - }); - - it("starts text messages when upstream sends deltas before run.started", () => { - const state = createOpenClawStreamState("turn_delta_only"); - - expect(mapOpenClawEventToBeeperChunks(state, { data: { delta: "Hello" }, type: "assistant.delta" })).toEqual([ - { - messageId: "turn_delta_only", - role: "assistant", - type: "TEXT_MESSAGE_START", - }, - { delta: "Hello", messageId: "turn_delta_only", type: "TEXT_MESSAGE_CONTENT" }, - ]); - expect(mapOpenClawEventToBeeperChunks(state, { data: { delta: " again" }, type: "assistant.delta" })).toEqual([ - { delta: " again", messageId: "turn_delta_only", type: "TEXT_MESSAGE_CONTENT" }, - ]); - }); - - it("normalizes upstream gateway session and approval event families", () => { - const state = createOpenClawStreamState("turn_gateway"); - - expect(mapOpenClawEventToBeeperChunks(state, { - event: "session.operation", - payload: { phase: "started", runId: "run_1", sessionKey: "session_1" }, - })).toEqual([ - { - metadata: { - run_id: "run_1", - session_key: "session_1", - turn_id: "turn_gateway", - }, - runId: "turn_gateway", - threadId: "turn_gateway", - type: "RUN_STARTED", - }, - { - messageId: "turn_gateway", - role: "assistant", - type: "TEXT_MESSAGE_START", - }, - ]); - expect(mapOpenClawEventToBeeperChunks(state, { - event: "session.message", - payload: { deltaText: "Hello", role: "assistant", runId: "run_1" }, - })).toEqual([ - { delta: "Hello", messageId: "turn_gateway", type: "TEXT_MESSAGE_CONTENT" }, - ]); - expect(mapOpenClawEventToBeeperChunks(state, { - event: "session.message", - payload: { - message: { - content: [{ text: " from transcript", type: "text" }], - role: "assistant", - }, - messageId: "msg_1", - messageSeq: 1, - runId: "run_1", - sessionKey: "session_1", - }, - })).toEqual([ - { delta: " from transcript", messageId: "turn_gateway", type: "TEXT_MESSAGE_CONTENT" }, - ]); - expect(mapOpenClawEventToBeeperChunks(state, { - event: "session.message", - payload: { - message: { - content: [{ thinking: "checking current files", type: "thinking" }], - role: "assistant", - }, - runId: "run_1", - }, - })).toEqual([ - { messageId: "turn_gateway", type: "REASONING_START" }, - { messageId: "turn_gateway", role: "reasoning", type: "REASONING_MESSAGE_START" }, - { delta: "checking current files", messageId: "turn_gateway", type: "REASONING_MESSAGE_CONTENT" }, - ]); - expect(mapOpenClawEventToBeeperChunks(state, { - event: "session.tool", - payload: { args: { cmd: "pwd" }, phase: "started", tool: "exec", toolCallId: "tool_1" }, - })).toEqual([ - { - parentMessageId: "tool_1", - state: "awaiting-input", - toolCallId: "tool_1", - toolCallName: "exec", - toolName: "exec", - type: "TOOL_CALL_START", - }, - { - args: "{\"cmd\":\"pwd\"}", - delta: "{\"cmd\":\"pwd\"}", - state: "input-streaming", - toolCallId: "tool_1", - type: "TOOL_CALL_ARGS", - }, - { - input: { cmd: "pwd" }, - state: "input-complete", - toolCallId: "tool_1", - toolCallName: "exec", - toolName: "exec", - type: "TOOL_CALL_END", - }, - ]); - expect(mapOpenClawEventToBeeperChunks(state, { - event: "exec.approval.requested", - payload: { id: "approval_1", reason: "Run command?", tool: "exec", toolCallId: "tool_1" }, - })).toEqual([ - { - name: "approval-requested", - type: "CUSTOM", - value: { - approval: { - id: "approval_1", - needsApproval: true, - }, - approvalMessageId: "approval_1", - approvalActions: defaultBeeperApprovalActions(), - choices: defaultBeeperApprovalChoices(), - message: "Run command?", - toolCallId: "tool_1", - toolName: "exec", - }, - }, - ]); - }); - - it("marks cancelled OpenClaw runs as abort terminal stream events", () => { - const state = createOpenClawStreamState("turn_cancel"); - - expect(mapOpenClawEventToBeeperChunks(state, { - event: "session.operation", - payload: { phase: "cancelled", reason: "user stopped it", runId: "run_cancel" }, - })).toEqual([ - { - message: "user stopped it", - reason: "user stopped it", - runId: "turn_cancel", - terminalType: "abort", - type: "RUN_ERROR", - }, - ]); - }); -}); diff --git a/packages/openclaw/src/openclaw-event-map.ts b/packages/openclaw/src/openclaw-event-map.ts deleted file mode 100644 index 5c776d0..0000000 --- a/packages/openclaw/src/openclaw-event-map.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { - closeOpenMessageParts, - createStreamRunState, - finishRunEvents, - mapOpenClawApprovalRequest, - mapOpenClawApprovalResponse, - mapOpenClawMessageDelta, - mapOpenClawToolInput, - mapOpenClawToolInputDelta, - mapOpenClawToolOutput, - startRunEvents, - AGUIEventType, - type AGUIEvent, - type StreamRunState, -} from "./stream-map"; - -type ToolInputChunkInput = Parameters[0]; -type ToolOutputChunkInput = Parameters[0]; -type ApprovalRequestChunkInput = Parameters[1]; -type ApprovalResponseChunkInput = Parameters[0]; - -export function createOpenClawStreamState(turnId: string): StreamRunState { - return createStreamRunState(turnId); -} - -export function mapOpenClawEventToBeeperChunks( - state: StreamRunState, - event: unknown -): AGUIEvent[] { - const record = recordValue(event); - const rawType = stringValue(record?.type) ?? stringValue(record?.event); - const type = normalizeOpenClawEventType(rawType, record); - if (!record || !type) return []; - const data = recordValue(record.data) ?? recordValue(record.payload) ?? record; - const metadata = streamMetadata(record); - - switch (type) { - case "run.created": - case "run.queued": - case "run.started": - return startRunEvents(state, metadata); - case "assistant.delta": { - const delta = stringValue(data.delta) ?? stringValue(data.deltaText) ?? stringValue(data.text) ?? sessionTextDelta(data) ?? stringValue(data.content); - return delta ? mapOpenClawMessageDelta(state, { kind: "text", value: delta }) : []; - } - case "assistant.message": { - const text = stringValue(data.deltaText) ?? stringValue(data.text) ?? sessionTextDelta(data) ?? stringValue(data.content) ?? stringValue(data.message); - return text ? mapOpenClawMessageDelta(state, { kind: "text", value: text }) : []; - } - case "thinking.delta": { - const delta = stringValue(data.delta) ?? stringValue(data.text) ?? sessionThinkingDelta(data) ?? stringValue(data.content); - return delta ? mapOpenClawMessageDelta(state, { kind: "thinking", value: delta }) : []; - } - case "tool.call.started": - return mapOpenClawToolInput(toolInput(data)); - case "tool.call.delta": { - const inputTextDelta = stringValue(data.delta) ?? stringValue(data.inputTextDelta); - const input = inputTextDelta ? undefined : data.input ?? data.args ?? parseMaybeJSONValue(data.arguments); - const delta: Parameters[0] = { - toolCallId: toolCallId(data), - }; - const name = toolName(data); - if (input !== undefined) delta.input = input; - if (inputTextDelta !== undefined) delta.inputTextDelta = inputTextDelta; - if (name !== undefined) delta.toolName = name; - return mapOpenClawToolInputDelta(delta); - } - case "tool.call.completed": - return mapOpenClawToolOutput(toolOutput(data)); - case "tool.call.failed": - return mapOpenClawToolOutput({ ...toolOutput(data), error: data.error ?? data.message ?? data.output }); - case "approval.requested": - return [mapOpenClawApprovalRequest(state, approvalRequest(data))]; - case "approval.resolved": - return [mapOpenClawApprovalResponse(approvalResponse(data))]; - case "run.completed": - return finishRunEvents(state, "stop", metadata); - case "run.failed": - return [...closeOpenMessageParts(state), { message: errorText(data.error ?? data.message ?? data), runId: state.turnId, type: AGUIEventType.RUN_ERROR }]; - case "run.cancelled": - return [ - ...closeOpenMessageParts(state), - { - message: stringValue(data.reason) ?? "OpenClaw run cancelled.", - reason: stringValue(data.reason), - runId: state.turnId, - terminalType: "abort", - type: AGUIEventType.RUN_ERROR, - } as AGUIEvent, - ]; - case "run.timed_out": - return [...closeOpenMessageParts(state), { message: "OpenClaw run timed out.", runId: state.turnId, type: AGUIEventType.RUN_ERROR }]; - default: - return []; - } -} - -export function normalizeOpenClawEventType(type: string | undefined, event?: Record): string | undefined { - if (!type) return undefined; - const payload = recordValue(event?.payload) ?? recordValue(event?.data) ?? event; - const phase = stringValue(payload?.phase) ?? stringValue(payload?.status) ?? stringValue(payload?.kind); - if (type === "chat") return "assistant.delta"; - if (type === "session.message") { - const message = recordValue(payload?.message); - const role = stringValue(payload?.role) ?? stringValue(message?.role); - if (sessionTextDelta(payload ?? {}) !== undefined) return "assistant.delta"; - if (sessionThinkingDelta(payload ?? {}) !== undefined) return "thinking.delta"; - if (role === "assistant") return "assistant.delta"; - if (role === "reasoning" || role === "thinking") return "thinking.delta"; - return "assistant.message"; - } - if (type === "session.operation") { - if (phase === "started" || phase === "queued" || phase === "running") return "run.started"; - if (phase === "completed" || phase === "complete" || phase === "done") return "run.completed"; - if (phase === "failed" || phase === "error") return "run.failed"; - if (phase === "cancelled" || phase === "canceled") return "run.cancelled"; - if (phase === "timed_out" || phase === "timeout") return "run.timed_out"; - return type; - } - if (type === "session.tool") { - if (phase === "delta" || payload?.delta !== undefined || payload?.inputTextDelta !== undefined) return "tool.call.delta"; - if (phase === "completed" || phase === "complete" || phase === "result") return "tool.call.completed"; - if (phase === "failed" || phase === "error") return "tool.call.failed"; - return "tool.call.started"; - } - if (type === "exec.approval.requested" || type === "plugin.approval.requested") return "approval.requested"; - if (type === "exec.approval.resolved" || type === "plugin.approval.resolved") return "approval.resolved"; - return type; -} - -function streamMetadata(event: Record): Record { - const payload = recordValue(event.payload) ?? recordValue(event.data); - return stripUndefined({ - agent_id: stringValue(event.agentId) ?? stringValue(payload?.agentId), - run_id: stringValue(event.runId) ?? stringValue(payload?.runId), - session_id: stringValue(event.sessionId) ?? stringValue(payload?.sessionId), - session_key: stringValue(event.sessionKey) ?? stringValue(payload?.sessionKey), - task_id: stringValue(event.taskId) ?? stringValue(payload?.taskId), - }); -} - -function toolInput(data: Record): ToolInputChunkInput { - const input: ToolInputChunkInput = { toolCallId: toolCallId(data) }; - const toolInputValue = data.input ?? data.args ?? parseMaybeJSONValue(data.arguments); - const providerExecuted = booleanValue(data.providerExecuted); - const startedAtMs = numberValue(data.startedAtMs); - const title = stringValue(data.title); - const name = toolName(data); - if (toolInputValue !== undefined) input.input = toolInputValue; - if (providerExecuted !== undefined) input.providerExecuted = providerExecuted; - if (startedAtMs !== undefined) input.startedAtMs = startedAtMs; - if (title !== undefined) input.title = title; - if (name !== undefined) input.toolName = name; - return input; -} - -function toolOutput(data: Record): ToolOutputChunkInput { - const output: ToolOutputChunkInput = { toolCallId: toolCallId(data) }; - const completedAtMs = numberValue(data.completedAtMs); - const outputValue = data.output ?? data.result ?? data.content; - const preliminary = booleanValue(data.preliminary); - const providerExecuted = booleanValue(data.providerExecuted); - const name = toolName(data); - if (completedAtMs !== undefined) output.completedAtMs = completedAtMs; - if (outputValue !== undefined) output.output = outputValue; - if (preliminary !== undefined) output.preliminary = preliminary; - if (providerExecuted !== undefined) output.providerExecuted = providerExecuted; - if (name !== undefined) output.toolName = name; - return output; -} - -function approvalRequest(data: Record): ApprovalRequestChunkInput { - const request: ApprovalRequestChunkInput = {}; - const approvalId = stringValue(data.approvalId) ?? stringValue(data.id); - const message = stringValue(data.message) ?? stringValue(data.reason); - const callId = stringValue(data.toolCallId) ?? stringValue(data.callId); - const name = toolName(data); - if (approvalId !== undefined) request.approvalId = approvalId; - if (message !== undefined) request.message = message; - if (callId !== undefined) request.toolCallId = callId; - if (name !== undefined) request.toolName = name; - return request; -} - -function approvalResponse(data: Record): ApprovalResponseChunkInput { - const approvalId = stringValue(data.approvalId) ?? stringValue(data.id); - if (!approvalId) throw new Error("OpenClaw approval.resolved event is missing approvalId"); - const response: ApprovalResponseChunkInput = { - approvalId, - approved: data.approved === true || data.decision === "approve" || data.decision === "allow", - approvedAlways: data.approvedAlways === true || data.decision === "approve_always" || data.decision === "allow_always", - }; - const callId = stringValue(data.toolCallId) ?? stringValue(data.callId); - if (callId !== undefined) response.toolCallId = callId; - return response; -} - -function toolCallId(data: Record): string { - return stringValue(data.toolCallId) ?? stringValue(data.callId) ?? stringValue(data.id) ?? "tool_call"; -} - -function toolName(data: Record): string | undefined { - return stringValue(data.toolName) ?? stringValue(data.name) ?? stringValue(data.tool); -} - -function parseMaybeJSONValue(value: unknown): unknown { - if (typeof value !== "string") return value; - try { - return JSON.parse(value); - } catch { - return value; - } -} - -function errorText(error: unknown): string { - if (error instanceof Error) return error.message; - if (typeof error === "string") return error; - return JSON.stringify(error) ?? String(error); -} - -function sessionTextDelta(data: Record): string | undefined { - return sessionContentText(data, "text"); -} - -function sessionThinkingDelta(data: Record): string | undefined { - return sessionContentText(data, "thinking"); -} - -function sessionContentText(data: Record, kind: "text" | "thinking"): string | undefined { - const message = recordValue(data.message) ?? data; - const content = arrayValue(message.content); - if (!content) return undefined; - const chunks: string[] = []; - for (const part of content) { - const record = recordValue(part); - if (!record || record.type !== kind) continue; - const value = kind === "thinking" - ? stringValue(record.thinking) ?? stringValue(record.text) - : stringValue(record.text); - if (value) chunks.push(value); - } - return chunks.length > 0 ? chunks.join("") : undefined; -} - -function stripUndefined>(input: T): T { - for (const key of Object.keys(input)) { - if (input[key] === undefined) delete input[key]; - } - return input; -} - -function recordValue(value: unknown): Record | undefined { - if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; - return value as Record; -} - -function arrayValue(value: unknown): unknown[] | undefined { - return Array.isArray(value) ? value : undefined; -} - -function stringValue(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -function numberValue(value: unknown): number | undefined { - return typeof value === "number" ? value : undefined; -} - -function booleanValue(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined; -} diff --git a/packages/openclaw/src/openclaw-runtime.test.ts b/packages/openclaw/src/openclaw-runtime.test.ts index 03df362..b6d6629 100644 --- a/packages/openclaw/src/openclaw-runtime.test.ts +++ b/packages/openclaw/src/openclaw-runtime.test.ts @@ -1,7 +1,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { BeeperChannelRuntime, setBeeperChannelRuntime } from "./beeper-channel-runtime"; import { createDefaultConfig } from "./config"; import { createOpenClawHostTransport, @@ -11,6 +12,10 @@ import { } from "./openclaw-runtime"; describe("OpenClawGatewayRuntime", () => { + afterEach(() => { + setBeeperChannelRuntime(undefined); + }); + it("lists OpenClaw agents as Matrix ghost contacts", async () => { const transport = fakeTransport({ "agents.list": { agents: [{ description: "Code", id: "codex", name: "Codex" }] }, @@ -108,9 +113,6 @@ describe("OpenClawGatewayRuntime", () => { transport, }); - const received: OpenClawGatewayEvent[] = []; - for await (const event of runtime.eventsForRun("run_1")) received.push(event); - expect(received).toEqual([{ event: "assistant.delta", payload: { delta: "use", runId: "run_1" } }]); await expect(runtime.resolveApproval({ approvalId: "approval_1", decision: "approve" })).resolves.toEqual({ ok: true }); expect(transport.request).toHaveBeenCalledWith("exec.approval.resolve", { approvalId: "approval_1", @@ -123,7 +125,7 @@ describe("OpenClawGatewayRuntime", () => { }); }); - it("adapts the in-process OpenClaw plugin runtime request and event surface", async () => { + 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 }, @@ -138,11 +140,11 @@ describe("OpenClawGatewayRuntime", () => { }; const transport = createOpenClawHostTransport(host); - await expect(transport.request("sessions.send", { key: "session", message: "hi" })).resolves.toEqual({ - method: "sessions.send", + 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("sessions.send", { key: "session", message: "hi" }, undefined); + expect(host.request).toHaveBeenCalledWith("exec.approval.resolve", { approvalId: "approval_1", decision: "approve" }, undefined); const received: OpenClawGatewayEvent[] = []; for await (const event of transport.events((candidate) => { @@ -154,6 +156,19 @@ describe("OpenClawGatewayRuntime", () => { expect(received).toEqual([{ event: "session.message", payload: { runId: "run_1" }, seq: 3 }]); }); + it("does not delegate Beeper session sends to a generic host request", async () => { + const host = { + request: vi.fn(async (method: string) => ({ method, runId: "host_run" })), + }; + const transport = createOpenClawHostTransport({ + ...host, + config: { current: () => ({ agents: { list: [{ id: "main" }] } }) }, + }); + + await expect(transport.request("sessions.send", { key: "session", message: "hi" })).rejects.toThrow("OpenClaw Beeper requires OpenClaw channel turn helpers"); + expect(host.request).not.toHaveBeenCalled(); + }); + it("adapts OpenClaw plugin runtime helpers when no gateway request surface exists", async () => { const transport = createOpenClawHostTransport({ agent: { @@ -228,6 +243,27 @@ describe("OpenClawGatewayRuntime", () => { }); it("runs Beeper-originated sends through OpenClaw channel turn helpers for live AG-UI progress", async () => { + const beeperStreams = { + finalizeMessage: vi.fn(async () => ({ + eventId: "$stream-root", + raw: {}, + replacementEventId: "$stream-final", + roomId: "!room:example", + })), + publishPart: vi.fn(async () => undefined), + startMessage: vi.fn(async () => ({ + descriptor: { type: "com.beeper.llm" }, + eventId: "$stream-root", + roomId: "!room:example", + })), + }; + setBeeperChannelRuntime(new BeeperChannelRuntime({ + client: { + beeper: { streams: beeperStreams }, + media: { upload: vi.fn() }, + } as never, + userId: "@sh-openclaw-bot:example", + })); const runAssembled = vi.fn(async (params: Record) => { const replyOptions = params.replyOptions as Record void | Promise>; await replyOptions.onReasoningStream?.({ text: "checking" }); @@ -282,7 +318,7 @@ describe("OpenClawGatewayRuntime", () => { key: "agent:main:beeper:room", message: "from Beeper", idempotencyKey: "$event", - matrix: { sender: "@alice:example" }, + matrix: { roomId: "!room:example", sender: "@alice:example" }, }); observedRunId = (sent as { runId?: string }).runId; await done; @@ -307,6 +343,21 @@ describe("OpenClawGatewayRuntime", () => { }), expect.objectContaining({ event: "run.completed" }), ])); + expect(beeperStreams.startMessage).toHaveBeenCalledTimes(1); + expect(beeperStreams.publishPart.mock.calls.map(([options]) => options.part.type)).toEqual(expect.arrayContaining([ + "RUN_STARTED", + "TEXT_MESSAGE_START", + "REASONING_MESSAGE_CONTENT", + "TOOL_CALL_START", + "TOOL_CALL_ARGS", + "TOOL_CALL_END", + "CUSTOM", + "TEXT_MESSAGE_CONTENT", + ])); + expect(beeperStreams.finalizeMessage).toHaveBeenCalledWith(expect.objectContaining({ + eventId: "$stream-root", + roomId: "!room:example", + })); }); it("loads plugin runtime history from the OpenClaw session transcript", async () => { diff --git a/packages/openclaw/src/openclaw-runtime.ts b/packages/openclaw/src/openclaw-runtime.ts index 1e5dc09..672dd9e 100644 --- a/packages/openclaw/src/openclaw-runtime.ts +++ b/packages/openclaw/src/openclaw-runtime.ts @@ -4,6 +4,20 @@ import path from "node:path"; import type { OpenClawAgentContact, OpenClawBridgeConfig } from "./types"; import { agentContactFromOpenClawAgent } from "./rooms"; import type { OpenClawApprovalResolvePayload } from "./approval"; +import { getBeeperChannelRuntime } from "./beeper-channel-runtime"; +import { + AGUIEventType, + closeReasoningPart, + createStreamRunState, + finishRunEvents, + mapOpenClawApprovalRequest, + mapOpenClawApprovalResponse, + mapOpenClawMessageDelta, + mapOpenClawToolInput, + mapOpenClawToolOutput, + startRunEvents, +} from "./stream-map"; +import type { AGUIEvent } from "./stream-map"; export type GatewayRequestOptions = { expectFinal?: boolean; @@ -119,17 +133,19 @@ export interface OpenClawMatrixMessageMetadata { }; relation?: { key?: string; - kind?: "reply" | "thread" | "edit" | "reaction" | "reaction_remove" | "redaction"; + 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; }; sender?: string; threadRootEventId?: string; @@ -403,13 +419,6 @@ export class OpenClawGatewayRuntime { })); } - eventsForRun(runId: string): AsyncIterable { - return this.transport.events((event) => { - const payload = recordValue(event.payload); - return stringValue(payload?.runId) === runId || stringValue(payload?.id) === runId; - }); - } - async resolveApproval(payload: OpenClawApprovalResolvePayload): Promise { const { approvalKind, ...requestPayload } = payload; const method = approvalKind === "plugin" ? "plugin.approval.resolve" : "exec.approval.resolve"; @@ -430,6 +439,9 @@ export class OpenClawHostTransport implements OpenClawTransport { } 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); @@ -479,6 +491,14 @@ export function createOpenClawHostTransport(runtime: OpenClawHostRuntime): OpenC return new OpenClawHostTransport(runtime); } +function isDirectPluginRuntimeMethod(method: string): boolean { + return method === "agents.list" + || method === "chat.history" + || method === "sessions.create" + || method === "sessions.list" + || method === "sessions.send"; +} + function arrayValue(value: unknown): unknown[] | undefined { return Array.isArray(value) ? value : undefined; } @@ -492,6 +512,10 @@ 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 settledValue(result: PromiseSettledResult): unknown { return result.status === "fulfilled" ? result.value : undefined; } @@ -863,7 +887,6 @@ async function runBeeperChannelTurnInPluginRuntime(params: { originatingTo: roomId, nativeChannelId: roomId, replyToId: stringValue(recordValue(matrix.relation)?.replyToEventId) ?? stringValue(recordValue(params.record.replyTo)?.eventId), - sourceReplyDeliveryMode: "direct", }, message: { body: params.message, @@ -900,11 +923,15 @@ async function runBeeperChannelTurnInPluginRuntime(params: { }, }); - const emit = createBeeperReplyEventEmitter(params.localEvents, { + const threadRoot = stringValue(recordValue(matrix.relation)?.threadRootEventId) ?? stringValue(recordValue(matrix.relation)?.replyToEventId); + const stream = createBeeperReplyStreamEmitter({ agentId: params.agentId, + 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 } }); try { @@ -919,34 +946,37 @@ async function runBeeperChannelTurnInPluginRuntime(params: { recordInboundSession: channelSession.recordInboundSession, dispatchReplyWithBufferedBlockDispatcher: channelReply.dispatchReplyWithBufferedBlockDispatcher, delivery: { - deliver: async (payload: unknown) => { - emit.textPayload(payload); + deliver: async (payload: unknown, info?: unknown) => { + await stream.textPayload(payload); + if (stringValue(recordValue(info)?.kind) === "final") await stream.finish(payload); return { visibleReplySent: true }; }, - onError: (error: unknown) => { + 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, timeoutOverrideSeconds: Math.max(1, Math.ceil(params.timeoutMs / 1000)), + sourceReplyDeliveryMode: "message_tool_only", suppressDefaultToolProgressMessages: true, allowProgressCallbacksWhenSourceDeliverySuppressed: true, - onAssistantMessageStart: emit.assistantMessageStart, - onBlockReply: emit.textPayload, - onBlockReplyQueued: emit.textPayload, - onPartialReply: emit.textPayload, - onReasoningEnd: emit.reasoningEnd, - onReasoningStream: emit.reasoningPayload, - onToolStart: emit.toolStart, - onToolResult: emit.toolResult, - onItemEvent: emit.itemEvent, - onPlanUpdate: emit.planUpdate, - onApprovalEvent: emit.approvalEvent, - onCommandOutput: emit.commandOutput, - onPatchSummary: emit.patchSummary, - onCompactionStart: () => emit.itemEvent({ kind: "compaction", phase: "start", title: "Compacting context" }), - onCompactionEnd: () => emit.itemEvent({ kind: "compaction", phase: "complete", title: "Compacted context" }), + onAssistantMessageStart: stream.assistantMessageStart, + onBlockReply: stream.textPayload, + onBlockReplyQueued: stream.textPayload, + onPartialReply: stream.textPayload, + onReasoningEnd: stream.reasoningEnd, + onReasoningStream: stream.reasoningPayload, + onToolStart: stream.toolStart, + onToolResult: stream.toolResult, + onItemEvent: stream.itemEvent, + onPlanUpdate: stream.planUpdate, + onApprovalEvent: stream.approvalEvent, + onCommandOutput: stream.commandOutput, + onPatchSummary: stream.patchSummary, + onCompactionStart: () => stream.itemEvent({ kind: "compaction", phase: "start", title: "Compacting context" }), + onCompactionEnd: () => stream.itemEvent({ kind: "compaction", phase: "complete", title: "Compacted context" }), }, record: { createIfMissing: true, @@ -962,38 +992,86 @@ async function runBeeperChannelTurnInPluginRuntime(params: { }, messageId: eventId, }); + await stream.finish(); 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 } }); } } -function createBeeperReplyEventEmitter(localEvents: LocalEventBus, base: { +function createBeeperReplyStreamEmitter(base: { agentId: string; + localEvents: LocalEventBus; + roomId: string; runId: string; sessionId: string; sessionKey: string; + threadRoot?: string; }) { + const channelRuntime = getBeeperChannelRuntime(); + 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 state = createStreamRunState(base.runId); + let hasPublished = false; + let finalized = false; let lastPartialText = ""; let lastReasoningText = ""; const emit = (event: string, payload: Record) => { - localEvents.emit({ event, payload: stripUndefined({ ...base, ...payload }) }); + base.localEvents.emit({ + event, + payload: stripUndefined({ + agentId: base.agentId, + runId: base.runId, + sessionId: base.sessionId, + sessionKey: base.sessionKey, + ...payload, + }), + }); }; - const textPayload = (payload: unknown) => { + const publish = async (parts: Iterable) => { + if (finalized) return; + const list = [...parts]; + if (list.length === 0) return; + const withStart = hasPublished + ? list + : [ + ...startRunEvents(state, { + agent_id: base.agentId, + session_key: base.sessionKey, + }), + ...list, + ]; + hasPublished = true; + await publisher.publishMany(withStart); + }; + const textPayload = async (payload: unknown) => { const text = replyPayloadText(payload); if (!text) return; const explicitDelta = stringValue(recordValue(payload)?.delta); const delta = explicitDelta ?? (text.startsWith(lastPartialText) ? text.slice(lastPartialText.length) : text); lastPartialText = text; - if (delta) emit("assistant.delta", { delta, text }); + if (!delta) return; + emit("assistant.delta", { delta, text }); + await publish(mapOpenClawMessageDelta(state, { kind: "text", value: delta })); }; - const reasoningPayload = (payload: unknown) => { + const reasoningPayload = async (payload: unknown) => { const text = stringValue(recordValue(payload)?.text); if (!text) return; const explicitDelta = stringValue(recordValue(payload)?.delta); const delta = explicitDelta ?? (text.startsWith(lastReasoningText) ? text.slice(lastReasoningText.length) : text); lastReasoningText = text; - if (delta) emit("thinking.delta", { delta, text }); + if (!delta) return; + emit("thinking.delta", { delta, text }); + await publish(mapOpenClawMessageDelta(state, { kind: "thinking", value: delta })); }; const toolIdFor = (payload: Record, fallback: string) => stringValue(payload.toolCallId) ?? stringValue(payload.itemId) ?? stringValue(payload.approvalId) ?? fallback; @@ -1002,87 +1080,178 @@ function createBeeperReplyEventEmitter(localEvents: LocalEventBus, base: { lastPartialText = ""; emit("assistant.message.start", {}); }, - reasoningEnd: () => emit("thinking.end", {}), + reasoningEnd: async () => { + emit("thinking.end", {}); + await publish(closeReasoningPart(state)); + }, reasoningPayload, textPayload, - toolStart: (payload: unknown) => { + toolStart: async (payload: unknown) => { const data = recordValue(payload) ?? {}; + const toolCallId = toolIdFor(data, `tool:${stringValue(data.name) ?? "tool"}`); + const toolName = stringValue(data.name) ?? stringValue(data.toolName); emit("tool.call.started", { args: data.args, input: data.args, phase: stringValue(data.phase), - toolCallId: toolIdFor(data, `tool:${stringValue(data.name) ?? "tool"}`), - toolName: stringValue(data.name), + toolCallId, + toolName, }); + await publish(mapOpenClawToolInput(stripUndefined({ + input: data.args ?? data.input, + providerExecuted: booleanValue(data.providerExecuted), + toolCallId, + toolName, + }))); }, - toolResult: (payload: unknown) => { + toolResult: async (payload: unknown) => { const data = recordValue(payload) ?? {}; + const toolCallId = toolIdFor(data, "tool_result"); + const toolName = stringValue(data.toolName) ?? stringValue(data.name); emit("tool.call.completed", { output: data.text ?? data.content ?? payload, - toolCallId: toolIdFor(data, "tool_result"), - toolName: stringValue(data.toolName) ?? stringValue(data.name), + toolCallId, + toolName, }); + await publish(mapOpenClawToolOutput(stripUndefined({ + error: data.error, + output: data.text ?? data.content ?? data.output ?? payload, + providerExecuted: booleanValue(data.providerExecuted), + toolCallId, + toolName, + }))); }, - itemEvent: (payload: unknown) => { + itemEvent: async (payload: unknown) => { const data = recordValue(payload) ?? {}; - const toolCallId = stringValue(data.toolCallId); - const output = stringValue(data.progressText) ?? stringValue(data.summary); - if (!toolCallId || !output) return; + const toolCallId = toolIdFor(data, stringValue(data.kind) ?? "item"); + const output = stringValue(data.progressText) ?? stringValue(data.summary) ?? stringValue(data.title); + if (!output) return; + const preliminary = stringValue(data.phase) !== "complete" && stringValue(data.status) !== "complete"; emit("tool.call.completed", { output, - preliminary: stringValue(data.phase) !== "complete" && stringValue(data.status) !== "complete", + preliminary, toolCallId, toolName: stringValue(data.name) ?? stringValue(data.kind), }); + await publish(mapOpenClawToolOutput(stripUndefined({ + output, + preliminary, + toolCallId, + toolName: stringValue(data.name) ?? stringValue(data.kind), + }))); }, - planUpdate: (payload: unknown) => { + planUpdate: async (payload: unknown) => { const data = recordValue(payload) ?? {}; const output = stringValue(data.explanation) ?? stringValue(data.title); if (!output) return; + const preliminary = stringValue(data.phase) !== "complete"; emit("tool.call.completed", { output, - preliminary: stringValue(data.phase) !== "complete", + preliminary, toolCallId: "plan", toolName: "plan", }); + await publish(mapOpenClawToolOutput({ + output, + preliminary, + toolCallId: "plan", + toolName: "plan", + })); }, - approvalEvent: (payload: unknown) => { + 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 = stringValue(data.kind) ?? stringValue(data.command); + const message = stringValue(data.message) ?? stringValue(data.reason) ?? stringValue(data.title); emit("approval.requested", { - approvalId: stringValue(data.approvalId) ?? stringValue(data.approvalSlug), - message: stringValue(data.message) ?? stringValue(data.reason) ?? stringValue(data.title), - toolCallId: stringValue(data.toolCallId) ?? stringValue(data.itemId), - toolName: stringValue(data.kind) ?? stringValue(data.command), + approvalId, + message, + toolCallId, + toolName, }); + await publish([mapOpenClawApprovalRequest(state, 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; emit("approval.resolved", { - approvalId: stringValue(data.approvalId) ?? stringValue(data.approvalSlug), - approved: stringValue(data.status) === "approved" || stringValue(data.status) === "allow", - decision: stringValue(data.status), + approvalId, + approved, + decision: status, toolCallId: stringValue(data.toolCallId) ?? stringValue(data.itemId), }); + await publish([mapOpenClawApprovalResponse(stripUndefined({ + approvalId, + approved, + approvedAlways: booleanValue(data.always) ?? booleanValue(data.approvedAlways), + toolCallId: stringValue(data.toolCallId) ?? stringValue(data.itemId), + }))]); } }, - commandOutput: (payload: unknown) => { + commandOutput: async (payload: unknown) => { const data = recordValue(payload) ?? {}; const complete = stringValue(data.phase) === "complete" || stringValue(data.status) === "complete"; + const toolCallId = toolIdFor(data, `command:${stringValue(data.name) ?? "output"}`); + const toolName = stringValue(data.name) ?? stringValue(data.title) ?? "command"; + const output = stringValue(data.output) ?? data; emit("tool.call.completed", { - output: stringValue(data.output) ?? data, + output, preliminary: !complete, - toolCallId: toolIdFor(data, `command:${stringValue(data.name) ?? "output"}`), - toolName: stringValue(data.name) ?? stringValue(data.title) ?? "command", + toolCallId, + toolName, }); + await publish(mapOpenClawToolOutput({ + output, + preliminary: !complete, + toolCallId, + toolName, + })); }, - patchSummary: (payload: unknown) => { + patchSummary: async (payload: unknown) => { const data = recordValue(payload) ?? {}; + const toolCallId = toolIdFor(data, "patch"); + const toolName = stringValue(data.name) ?? "patch"; + const output = data.summary ?? data; emit("tool.call.completed", { - output: data.summary ?? data, - toolCallId: toolIdFor(data, "patch"), - toolName: stringValue(data.name) ?? "patch", + output, + toolCallId, + toolName, + }); + await publish(mapOpenClawToolOutput({ output, toolCallId, toolName })); + }, + finish: async (payload?: unknown) => { + if (payload !== undefined) await textPayload(payload); + if (!hasPublished || finalized) return; + const events = finishRunEvents(state, "stop", { + agent_id: base.agentId, + run_id: base.runId, + session_id: base.sessionId, + session_key: base.sessionKey, + }); + const terminal = events.at(-1); + const preTerminal = events.slice(0, -1); + if (preTerminal.length > 0) await publisher.publishMany(preTerminal); + finalized = true; + await publisher.finalize(stripUndefined({ terminalPart: terminal, finishReason: "stop" })); + }, + fail: async (error: unknown) => { + if (finalized) return; + finalized = true; + await publisher.finalize({ + body: errorText(error), + terminalPart: { + error: { message: errorText(error) }, + message: errorText(error), + runId: base.runId, + threadId: base.runId, + type: AGUIEventType.RUN_ERROR, + }, }); }, }; diff --git a/packages/openclaw/src/registry.ts b/packages/openclaw/src/registry.ts index 1a6d475..278c2ad 100644 --- a/packages/openclaw/src/registry.ts +++ b/packages/openclaw/src/registry.ts @@ -97,6 +97,14 @@ export class OpenClawBridgeRegistry { 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; } diff --git a/packages/openclaw/src/setup.test.ts b/packages/openclaw/src/setup.test.ts index 9a083a8..852c402 100644 --- a/packages/openclaw/src/setup.test.ts +++ b/packages/openclaw/src/setup.test.ts @@ -204,8 +204,8 @@ describe("OpenClaw Beeper setup surface", () => { }); expect(beeperChannelPlugin.actions).toEqual(expect.any(Object)); expect(beeperChannelPlugin.actions.describeMessageTool()).toMatchObject({ - actions: ["send", "edit", "delete", "react"], - capabilities: ["media", "replyTo", "reactions"], + actions: ["send", "edit", "delete", "react", "read", "mark_unread"], + capabilities: ["media", "replyTo", "reactions", "readReceipts", "markedUnread"], }); expect(beeperChannelPlugin.actions.extractToolSend({ args: { action: "send", threadId: "$thread", to: "beeper:!room" }, @@ -572,6 +572,7 @@ describe("OpenClaw Beeper setup surface", () => { expect(getBeeperChannelSettings(cfg)).toMatchObject({ enabled: true, accessToken: "at", + appserviceId: "sh-openclaw-dev", asToken: "as", bridgeId: "sh-openclaw-dev", homeserver: "https://matrix.example", @@ -756,6 +757,14 @@ describe("OpenClaw Beeper setup surface", () => { await beeperChannelPlugin.heartbeat.sendTyping({ to: "!room" }); expect(client.typing.set).not.toHaveBeenCalled(); + await beeperChannelPlugin.actions.handleAction({ + action: "read", + params: { eventId: sentMessageId, to: "!room" }, + }); + await beeperChannelPlugin.actions.handleAction({ + action: "mark_unread", + params: { eventId: sentMessageId, to: "!room" }, + }); expect(queued.map((event) => (event as { getType: () => string }).getType())).toEqual([ "message", "message", @@ -763,6 +772,8 @@ describe("OpenClaw Beeper setup surface", () => { "reaction", "message_remove", "typing", + "read_receipt", + "mark_unread", ]); await expect(beeperChannelPlugin.directory.listPeersLive({ diff --git a/packages/openclaw/src/setup.ts b/packages/openclaw/src/setup.ts index 2d66055..99d119c 100644 --- a/packages/openclaw/src/setup.ts +++ b/packages/openclaw/src/setup.ts @@ -1,3 +1,4 @@ +import type { BridgeLogger } from "@beeper/pickle-bridge"; import { createConfigFromOpenClawSetup, DEFAULT_REGISTRATION_URL, defaultDataDir } from "./config"; import type { setupOpenClawBeeperBridge, SetupOpenClawBeeperBridgeOptions } from "./beeper-setup"; import { createBeeperApprovalNotice } from "./approval"; @@ -19,7 +20,7 @@ export interface BeeperChannelSettings { allowedUserIds?: string[]; appserviceId?: string; asToken?: string; - approvalBehavior?: "native" | "reactions" | "slash" | "disabled"; + approvalBehavior?: "native" | "disabled"; backfillLimit?: number; baseDomain?: string; beeperEnv?: "production" | "staging" | "dev" | "local"; @@ -160,7 +161,7 @@ export const BeeperChannelConfigSchema = { contactVisibility: { type: "string", enum: ["agents", "agents-and-users", "none"] }, homeserverDomain: { type: "string" }, streamFinalization: { type: "string", enum: ["replace", "append", "native-only"] }, - approvalBehavior: { type: "string", enum: ["native", "reactions", "slash", "disabled"] }, + approvalBehavior: { type: "string", enum: ["native", "disabled"] }, userLocalpartPrefix: { type: "string" }, }, } as const; @@ -209,7 +210,7 @@ export const beeperMessageAdapter = { finalizer: { capabilities: { finalEdit: true, - normalFallback: true, + normalFallback: false, previewReceipt: true, retainOnAmbiguousFailure: true, }, @@ -531,11 +532,11 @@ export const beeperApprovalCapability = { export const beeperMessageActions = { resolveExecutionMode: () => "gateway", describeMessageTool: () => ({ - actions: ["send", "edit", "delete", "react"], - capabilities: ["media", "replyTo", "reactions"], + actions: ["send", "edit", "delete", "react", "read", "mark_unread"], + capabilities: ["media", "replyTo", "reactions", "readReceipts", "markedUnread"], }), supportsAction: ({ action }: { action: string }) => - action === "send" || action === "edit" || action === "delete" || action === "react", + action === "send" || action === "edit" || action === "delete" || action === "react" || action === "read" || action === "mark_unread", extractToolSend: ({ args }: { args: Record }) => { const action = stringValue(args.action)?.trim(); if (action !== "send" && action !== "sendMessage") return null; @@ -599,6 +600,17 @@ export const beeperMessageActions = { const sent = await runtime.react({ emoji, eventId, roomId }); return { content: [{ type: "text", text: `Sent Beeper reaction ${sent.eventId}` }] }; } + if (ctx.action === "read") { + const eventId = readRequiredString(params, "messageId", "eventId"); + await runtime.readReceipt({ eventId, roomId }); + return { content: [{ type: "text", text: `Marked Beeper message read ${eventId}` }] }; + } + if (ctx.action === "mark_unread") { + const eventId = readRequiredString(params, "messageId", "eventId"); + const unread = params.unread !== false; + await runtime.markUnread({ eventId, roomId, unread }); + return { content: [{ type: "text", text: `${unread ? "Marked" : "Unmarked"} Beeper room unread` }] }; + } throw new Error(`Unsupported Beeper message action: ${ctx.action}`); }, } as const; @@ -750,8 +762,6 @@ export const beeperSetupWizard = { initialValue: current.approvalBehavior ?? "native", options: [ { value: "native", label: "Native" }, - { value: "reactions", label: "Reactions" }, - { value: "slash", label: "Slash commands" }, { value: "disabled", label: "Disabled" }, ], }); @@ -888,6 +898,7 @@ export async function applyBeeperSetupConfig(params: { }; if (result.config.homeserver) setupSettings.homeserver = result.config.homeserver; if (result.config.accessToken) setupSettings.accessToken = result.config.accessToken; + if (result.config.appserviceId) setupSettings.appserviceId = result.config.appserviceId; if (result.config.asToken) setupSettings.asToken = result.config.asToken; if (result.config.bridgeId) setupSettings.bridgeId = result.config.bridgeId; if (result.config.ghostLocalpartPrefix) setupSettings.ghostLocalpartPrefix = result.config.ghostLocalpartPrefix; @@ -1112,47 +1123,77 @@ function stringValue(value: unknown): string | undefined { } export async function startBeeperGatewayAccount(ctx: BeeperGatewayContext): Promise { - const settings = getBeeperChannelSettings(ctx.cfg); - if (settings.enabled === false) { - ctx.log?.info?.("Beeper bridge is disabled; skipping startup."); - return; - } - if (!isBeeperChannelConfigured(ctx.cfg)) { - throw new Error("Beeper bridge is not fully configured; run Beeper channel setup first."); - } - const { accountFromOpenClawConfig, startOpenClawBeeperBridge } = await import("./appservice"); - const config = createConfigFromOpenClawSetup(ctx.cfg); - const hostRuntime = resolveBeeperHostRuntime(ctx); - const bridge = await startOpenClawBeeperBridge({ - account: accountFromOpenClawConfig(config), - backfill: Boolean(config.importSources?.length), - ...(config.backfillLimit !== undefined ? { backfillLimit: config.backfillLimit } : {}), - config, - dataDir: config.dataDir, - ...(hostRuntime ? { runtime: hostRuntime } : {}), - }); - const key = gatewayAccountKey(ctx.accountId); - 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); - await bridge.stop?.(); + ctx.log?.info?.("Beeper bridge startup beginning."); + const settings = getBeeperChannelSettings(ctx.cfg); + if (settings.enabled === false) { + ctx.log?.info?.("Beeper bridge is disabled; skipping startup."); + return; + } + if (!isBeeperChannelConfigured(ctx.cfg)) { + throw new Error("Beeper bridge is not fully configured; run Beeper channel setup first."); + } + const { accountFromOpenClawConfig, startOpenClawBeeperBridge } = await import("./appservice"); + const config = createConfigFromOpenClawSetup(ctx.cfg); + const hostRuntime = resolveBeeperHostRuntime(ctx); + const bridge = await startOpenClawBeeperBridge({ + account: accountFromOpenClawConfig(config), + backfill: Boolean(config.importSources?.length), + ...(config.backfillLimit !== undefined ? { backfillLimit: config.backfillLimit } : {}), + config, + dataDir: config.dataDir, + log: bridgeLoggerFromChannelContext(ctx), + ...(hostRuntime ? { runtime: hostRuntime } : {}), + }); + const key = gatewayAccountKey(ctx.accountId); + startedBridges.set(key, bridge as StartedBeeperBridge); ctx.setStatus?.({ accountId: ctx.accountId, - running: false, + configured: true, + enabled: true, + running: true, }); - ctx.log?.info?.("Beeper bridge stopped."); + ctx.log?.info?.("Beeper bridge started."); + try { + await waitForAbort(ctx.abortSignal); + } finally { + startedBridges.delete(key); + 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.runtime && typeof ctx.runtime === "object" && hasOpenClawSessionRuntime(ctx.runtime)) return ctx.runtime; @@ -1251,7 +1292,7 @@ export function validateBeeperSetupInput(input: BeeperSetupInput): string | null if (input.beeperEnv !== undefined && normalizeBeeperEnv(input.beeperEnv) === undefined) return "Beeper environment must be production, staging, dev, or local."; if (input.contactVisibility !== undefined && normalizeContactVisibility(input.contactVisibility) === undefined) return "Contact visibility must be agents, agents-and-users, or none."; if (input.streamFinalization !== undefined && normalizeStreamFinalization(input.streamFinalization) === undefined) return "Stream finalization must be replace, append, or native-only."; - if (input.approvalBehavior !== undefined && normalizeApprovalBehavior(input.approvalBehavior) === undefined) return "Approval behavior must be native, reactions, slash, or disabled."; + if (input.approvalBehavior !== undefined && normalizeApprovalBehavior(input.approvalBehavior) === undefined) return "Approval behavior must be native or disabled."; const backfillLimit = normalizeOptionalNumber(input.backfillLimit); if (backfillLimit !== undefined && (!Number.isInteger(backfillLimit) || backfillLimit < 0)) return "Backfill limit must be a non-negative integer."; return null; @@ -1374,7 +1415,7 @@ function normalizeStreamFinalization(value: string | undefined): BeeperChannelSe } function normalizeApprovalBehavior(value: string | undefined): BeeperChannelSettings["approvalBehavior"] | undefined { - if (value === "native" || value === "reactions" || value === "slash" || value === "disabled") return value; + if (value === "native" || value === "disabled") return value; return undefined; } diff --git a/packages/openclaw/src/types.ts b/packages/openclaw/src/types.ts index 0f11e0a..5772512 100644 --- a/packages/openclaw/src/types.ts +++ b/packages/openclaw/src/types.ts @@ -43,7 +43,7 @@ export interface OpenClawBridgeConfig { allowedUserIds?: string[]; asToken?: string; appserviceId: string; - approvalBehavior?: "native" | "reactions" | "slash" | "disabled"; + approvalBehavior?: "native" | "disabled"; backfillLimit?: number; baseDomain?: string; beeperEnv?: "production" | "staging" | "dev" | "local"; diff --git a/packages/openclaw/tsdown.config.ts b/packages/openclaw/tsdown.config.ts index 860e805..e899331 100644 --- a/packages/openclaw/tsdown.config.ts +++ b/packages/openclaw/tsdown.config.ts @@ -6,6 +6,6 @@ export default defineConfig({ alwaysBundle: [/^@beeper\//], }, dts: true, - entry: ["src/approval.ts", "src/appservice.ts", "src/backfill.ts", "src/beeper-channel-runtime.ts", "src/beeper-stream.ts", "src/beeper-setup.ts", "src/bridge-agent.ts", "src/cli.ts", "src/config.ts", "src/connector.ts", "src/index.ts", "src/matrix-parser.ts", "src/openclaw-event-map.ts", "src/openclaw-extension.ts", "src/openclaw-runtime.ts", "src/plugin-entry.ts", "src/protocol-coverage.ts", "src/registry.ts", "src/registration.ts", "src/rooms.ts", "src/serial.ts", "src/setup.ts", "src/setup-entry.ts", "src/stream-map.ts", "src/types.ts"], + entry: ["src/approval.ts", "src/appservice.ts", "src/backfill.ts", "src/beeper-channel-runtime.ts", "src/beeper-stream.ts", "src/beeper-setup.ts", "src/bridge-agent.ts", "src/cli.ts", "src/config.ts", "src/connector.ts", "src/index.ts", "src/matrix-parser.ts", "src/openclaw-extension.ts", "src/openclaw-runtime.ts", "src/plugin-entry.ts", "src/protocol-coverage.ts", "src/registry.ts", "src/registration.ts", "src/rooms.ts", "src/serial.ts", "src/setup.ts", "src/setup-entry.ts", "src/stream-map.ts", "src/types.ts"], format: ["esm"], }); diff --git a/packages/pickle/native/go.mod b/packages/pickle/native/go.mod index fb0ffc7..b1d00b6 100644 --- a/packages/pickle/native/go.mod +++ b/packages/pickle/native/go.mod @@ -3,9 +3,9 @@ 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-20260525012312-44694d3834e5 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 ( @@ -13,20 +13,20 @@ require ( 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/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 + go.mau.fi/util v0.9.9 // 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 ) diff --git a/packages/pickle/native/go.sum b/packages/pickle/native/go.sum index a956fe5..1822137 100644 --- a/packages/pickle/native/go.sum +++ b/packages/pickle/native/go.sum @@ -2,8 +2,8 @@ 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-20260525012312-44694d3834e5 h1:Ji+5ah2h/Dytzv19zfQGp7An4xJ1zQXqh1eyTGshveA= +github.com/beeper/ai-bridge v0.0.0-20260525012312-44694d3834e5/go.mod h1:W9vAVqc/X2AIEWMx+alrOARMYH2uXTSQn6TVGjRRH5Q= 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 +16,14 @@ 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/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 +36,26 @@ 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= +go.mau.fi/util v0.9.9 h1:ujDeXCo07HBor5oQLyO1tHklupmqVmPgasc53d7q/NE= +go.mau.fi/util v0.9.9/go.mod h1:pqt4Vcrt+5gcH/CgrHZg11qSx+b34o6mknGzOEA6waY= +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/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 3690e10..913c7b0 100644 --- a/packages/pickle/native/internal/core/appservice.go +++ b/packages/pickle/native/internal/core/appservice.go @@ -151,8 +151,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 { @@ -228,21 +231,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(). @@ -256,10 +302,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 { @@ -351,25 +411,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: cloneMap(req.CreationContent), - 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 @@ -417,10 +472,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{ diff --git a/packages/pickle/native/internal/core/appservice_test.go b/packages/pickle/native/internal/core/appservice_test.go index a5b669d..ef62e64 100644 --- a/packages/pickle/native/internal/core/appservice_test.go +++ b/packages/pickle/native/internal/core/appservice_test.go @@ -40,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 != createReq.BeeperLocalRoomID { - t.Fatalf("expected fi.mau room ID to match local room ID, got %s", createReq.MeowRoomID) + if createReq.MeowRoomID != "" { + t.Fatalf("expected no fi.mau room ID override, 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") @@ -103,6 +106,67 @@ 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 TestBeeperStreamClientUsesAppserviceBotDevice(t *testing.T) { core := New(nil) mainClient, err := mautrix.NewClient("https://matrix.example/_hungryserv/alice", id.UserID("@bot:example"), "login-token") @@ -391,3 +455,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/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/package.json b/packages/pickle/package.json index e54a82b..8455bc7 100644 --- a/packages/pickle/package.json +++ b/packages/pickle/package.json @@ -63,7 +63,8 @@ "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", + "test:go": "cd native && go test -tags goolm ./...", "test": "vitest run --coverage", "typecheck": "npm run generate:types && tsc --noEmit" }, 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); } } From 7475c36ede9b8520100b5367e9a7639bdc503d33 Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Mon, 25 May 2026 19:58:01 +0200 Subject: [PATCH 34/43] Propagate Matrix room ids into OpenClaw turns --- packages/openclaw/src/bridge-agent.test.ts | 3 + packages/openclaw/src/bridge-agent.ts | 6 +- packages/openclaw/src/connector.test.ts | 7 ++ packages/openclaw/src/connector.ts | 16 ++++ .../openclaw/src/openclaw-runtime.test.ts | 4 + packages/openclaw/src/openclaw-runtime.ts | 96 +++++++++++++++---- packages/openclaw/src/setup.test.ts | 43 +-------- packages/openclaw/src/setup.ts | 57 +---------- 8 files changed, 122 insertions(+), 110 deletions(-) diff --git a/packages/openclaw/src/bridge-agent.test.ts b/packages/openclaw/src/bridge-agent.test.ts index c2545fe..a12480a 100644 --- a/packages/openclaw/src/bridge-agent.test.ts +++ b/packages/openclaw/src/bridge-agent.test.ts @@ -40,6 +40,7 @@ describe("OpenClawMatrixBridgeAgent", () => { expect(runtime.transport.request).toHaveBeenCalledWith("sessions.send", { idempotencyKey: "$event", key: "agent:codex:main", + matrix: { roomId: "!room:example.com" }, message: "hello", }, { expectFinal: false }); expect(registry.getBindingByRoom("!room:example.com")?.lastRunId).toBe("run_1"); @@ -80,6 +81,7 @@ describe("OpenClawMatrixBridgeAgent", () => { expect(runtime.transport.request).toHaveBeenCalledWith("sessions.send", { idempotencyKey: "$retryable", key: "agent:codex:main", + matrix: { roomId: "!room:example.com" }, message: "hello", }, { expectFinal: false }); }); @@ -114,6 +116,7 @@ describe("OpenClawMatrixBridgeAgent", () => { expect(runtime.transport.request).toHaveBeenCalledWith("sessions.send", { idempotencyKey: "$event", key: "agent:codex:session_1", + matrix: { roomId: "!room:example.com" }, message: "hello", }, { expectFinal: false }); expect(registry.getBindingByRoom("!room:example.com")?.sessionKey).toBe("agent:codex:session_1"); diff --git a/packages/openclaw/src/bridge-agent.ts b/packages/openclaw/src/bridge-agent.ts index 27a57fb..1ddf008 100644 --- a/packages/openclaw/src/bridge-agent.ts +++ b/packages/openclaw/src/bridge-agent.ts @@ -46,10 +46,14 @@ export class OpenClawMatrixBridgeAgent { return; } const sessionKey = await this.ensureSession(binding); + const matrix: OpenClawMatrixMessageMetadata = { + ...(turn.matrix ?? {}), + roomId: turn.roomId, + }; const run = await this.runtime.sendMessage({ ...(turn.attachments && turn.attachments.length > 0 ? { attachments: turn.attachments } : {}), idempotencyKey: turn.eventId, - ...(turn.matrix ? { matrix: turn.matrix } : {}), + matrix, message: turn.text, ...(turn.replyToEventId ? { replyTo: { eventId: turn.replyToEventId, roomId: turn.roomId } } : {}), sessionKey, diff --git a/packages/openclaw/src/connector.test.ts b/packages/openclaw/src/connector.test.ts index 484fcd2..ce67702 100644 --- a/packages/openclaw/src/connector.test.ts +++ b/packages/openclaw/src/connector.test.ts @@ -484,6 +484,7 @@ describe("OpenClawBridgeConnector", () => { idempotencyKey: "$message", key: "agent:codex:session_1", matrix: { + roomId: "!room:example.com", sender: "@alice:example.com", }, message: "hello", @@ -688,6 +689,7 @@ describe("OpenClawBridgeConnector", () => { targetRunId: "run_previous", targetSessionKey: "agent:codex:session_2", }, + roomId: "!room:example.com", sender: "@alice:example.com", }, message: "new text", @@ -749,6 +751,7 @@ describe("OpenClawBridgeConnector", () => { replyToEventId: "$thread-root", threadRootEventId: "$thread-root", }, + roomId: "!room:example.com", sender: "@alice:example.com", threadRootEventId: "$thread-root", }, @@ -871,6 +874,7 @@ describe("OpenClawBridgeConnector", () => { targetRunId: "run_streamed", targetSessionKey: "agent:codex:session_1", }, + roomId: "!room:example.com", sender: "@alice:example.com", }, message: "corrected", @@ -896,6 +900,7 @@ describe("OpenClawBridgeConnector", () => { targetRunId: "run_streamed", targetSessionKey: "agent:codex:session_1", }, + roomId: "!room:example.com", sender: "@alice:example.com", }, message: "Reacted 👍 to $old", @@ -920,6 +925,7 @@ describe("OpenClawBridgeConnector", () => { targetRunId: "run_streamed", targetSessionKey: "agent:codex:session_1", }, + roomId: "!room:example.com", sender: "@alice:example.com", }, message: "Removed reaction 👍 from $old", @@ -940,6 +946,7 @@ describe("OpenClawBridgeConnector", () => { targetRunId: "run_streamed", targetSessionKey: "agent:codex:session_1", }, + roomId: "!room:example.com", sender: "redaction", }, message: "Redacted message $old", diff --git a/packages/openclaw/src/connector.ts b/packages/openclaw/src/connector.ts index 7c8f493..da0499a 100644 --- a/packages/openclaw/src/connector.ts +++ b/packages/openclaw/src/connector.ts @@ -327,8 +327,18 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor 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, + sessionKey: currentBinding.sessionKey, + }); } this.registerCanonicalPortalForBinding(ctx, msg.portal, currentBinding); + ctx.log?.("info", "openclaw_matrix_message_dispatching", { + eventId: msg.event.eventId, + roomId: msg.portal.mxid, + sessionKey: currentBinding.sessionKey, + }); await this.#agent.handleMatrixText({ ...(parsed.attachments.length > 0 ? { attachments: parsed.attachments } : {}), eventId: msg.event.eventId, @@ -338,6 +348,12 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor sender: msg.sender.userId, text: parsed.text, }); + ctx.log?.("info", "openclaw_matrix_message_dispatched", { + eventId: msg.event.eventId, + lastRunId: this.#registry.getBindingByRoom(msg.portal.mxid)?.lastRunId, + roomId: msg.portal.mxid, + sessionKey: this.#registry.getBindingByRoom(msg.portal.mxid)?.sessionKey, + }); } return { pending: false }; } diff --git a/packages/openclaw/src/openclaw-runtime.test.ts b/packages/openclaw/src/openclaw-runtime.test.ts index b6d6629..5fe6b34 100644 --- a/packages/openclaw/src/openclaw-runtime.test.ts +++ b/packages/openclaw/src/openclaw-runtime.test.ts @@ -329,6 +329,10 @@ describe("OpenClawGatewayRuntime", () => { channel: "beeper", routeSessionKey: "agent:main:beeper:room", })); + expect((runAssembled.mock.calls[0]?.[0] as { replyOptions?: Record } | undefined)?.replyOptions).not.toMatchObject({ + sourceReplyDeliveryMode: "message_tool_only", + }); + expect(beeperStreams.startMessage.mock.invocationCallOrder[0]).toBeLessThan(runAssembled.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY); expect(received).toEqual(expect.arrayContaining([ expect.objectContaining({ event: "thinking.delta" }), expect.objectContaining({ event: "tool.call.started" }), diff --git a/packages/openclaw/src/openclaw-runtime.ts b/packages/openclaw/src/openclaw-runtime.ts index 672dd9e..fdfbfda 100644 --- a/packages/openclaw/src/openclaw-runtime.ts +++ b/packages/openclaw/src/openclaw-runtime.ts @@ -147,6 +147,7 @@ export interface OpenClawMatrixMessageMetadata { threadRootEventId?: string; unread?: boolean; }; + roomId?: string; sender?: string; threadRootEventId?: string; } @@ -789,7 +790,12 @@ async function sendSessionInPluginRuntime( throw new Error("OpenClaw Beeper requires OpenClaw channel turn helpers (runtime.channel.turn, runtime.channel.reply, and runtime.channel.session)"); } const timeoutMs = options?.timeoutMs ?? numberValue(record.timeoutMs) ?? runtime.agent?.resolveAgentTimeoutMs?.({ cfg }) ?? 48 * 60 * 60 * 1000; - queuePluginRun(() => + startPluginRun(localEvents, { + agentId, + runId, + sessionId, + sessionKey, + }, () => runBeeperChannelTurnInPluginRuntime({ agentId, cfg, @@ -807,13 +813,26 @@ async function sendSessionInPluginRuntime( return { runId, sessionFile, sessionId, sessionKey }; } -function queuePluginRun(run: () => Promise): void { - setTimeout(() => { - void run().catch(() => { - // The runner emits run.failed with details. This catch keeps the timer - // task from surfacing an unhandled rejection in plugin hosts. +function startPluginRun( + localEvents: LocalEventBus, + base: { agentId: string; runId: string; sessionId: string; sessionKey: string }, + run: () => Promise, +): void { + localEvents.emit({ event: "run.queued", payload: base }); + getBeeperChannelRuntime()?.debug("openclaw_beeper_run_queued", base); + void run().catch((error) => { + getBeeperChannelRuntime()?.debug("openclaw_beeper_run_failed", { + ...base, + error: errorText(error), + }); + localEvents.emit({ + event: "run.failed", + payload: { + ...base, + error: errorText(error), + }, }); - }, 0); + }); } function canRunNativeChannelTurn(runtime: OpenClawHostRuntime): boolean { @@ -935,6 +954,9 @@ async function runBeeperChannelTurnInPluginRuntime(params: { }); params.localEvents.emit({ event: "run.started", payload: { agentId: params.agentId, runId: params.runId, sessionId: params.sessionId, sessionKey: params.sessionKey } }); try { + params.localEvents.emit({ event: "stream.starting", payload: { agentId: params.agentId, roomId, runId: params.runId, sessionId: params.sessionId, sessionKey: params.sessionKey } }); + await stream.start(); + params.localEvents.emit({ event: "stream.started", payload: { agentId: params.agentId, roomId, runId: params.runId, sessionId: params.sessionId, sessionKey: params.sessionKey } }); await turn.runAssembled({ cfg: params.cfg, channel: "beeper", @@ -959,7 +981,6 @@ async function runBeeperChannelTurnInPluginRuntime(params: { replyOptions: { runId: params.runId, timeoutOverrideSeconds: Math.max(1, Math.ceil(params.timeoutMs / 1000)), - sourceReplyDeliveryMode: "message_tool_only", suppressDefaultToolProgressMessages: true, allowProgressCallbacksWhenSourceDeliverySuppressed: true, onAssistantMessageStart: stream.assistantMessageStart, @@ -993,6 +1014,7 @@ async function runBeeperChannelTurnInPluginRuntime(params: { 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); @@ -1037,21 +1059,42 @@ function createBeeperReplyStreamEmitter(base: { }), }); }; + const startMetadata = () => ({ + agent_id: base.agentId, + session_key: base.sessionKey, + }); + const ensureStarted = async () => { + if (hasPublished || finalized) return; + hasPublished = true; + channelRuntime.debug("openclaw_beeper_stream_starting", { + agentId: base.agentId, + roomId: base.roomId, + runId: base.runId, + sessionId: base.sessionId, + sessionKey: base.sessionKey, + }); + await publisher.publishMany(startRunEvents(state, startMetadata())); + 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; - const withStart = hasPublished - ? list - : [ - ...startRunEvents(state, { - agent_id: base.agentId, - session_key: base.sessionKey, - }), - ...list, - ]; - hasPublished = true; - await publisher.publishMany(withStart); + await ensureStarted(); + channelRuntime.debug("openclaw_beeper_stream_publish", { + count: list.length, + firstType: stringValue(list[0]?.type), + roomId: base.roomId, + runId: base.runId, + }); + await publisher.publishMany(list); }; const textPayload = async (payload: unknown) => { const text = replyPayloadText(payload); @@ -1076,6 +1119,7 @@ function createBeeperReplyStreamEmitter(base: { const toolIdFor = (payload: Record, fallback: string) => stringValue(payload.toolCallId) ?? stringValue(payload.itemId) ?? stringValue(payload.approvalId) ?? fallback; return { + start: ensureStarted, assistantMessageStart: () => { lastPartialText = ""; emit("assistant.message.start", {}); @@ -1238,11 +1282,25 @@ function createBeeperReplyStreamEmitter(base: { const preTerminal = events.slice(0, -1); if (preTerminal.length > 0) await publisher.publishMany(preTerminal); finalized = true; + channelRuntime.debug("openclaw_beeper_stream_finalizing", { + roomId: base.roomId, + runId: base.runId, + }); await publisher.finalize(stripUndefined({ terminalPart: terminal, finishReason: "stop" })); + channelRuntime.debug("openclaw_beeper_stream_finalized", { + eventId: publisher.targetEventId, + roomId: base.roomId, + runId: base.runId, + }); }, fail: async (error: unknown) => { if (finalized) return; finalized = true; + channelRuntime.debug("openclaw_beeper_stream_failing", { + error: errorText(error), + roomId: base.roomId, + runId: base.runId, + }); await publisher.finalize({ body: errorText(error), terminalPart: { diff --git a/packages/openclaw/src/setup.test.ts b/packages/openclaw/src/setup.test.ts index 852c402..1a3d5a8 100644 --- a/packages/openclaw/src/setup.test.ts +++ b/packages/openclaw/src/setup.test.ts @@ -204,17 +204,18 @@ describe("OpenClaw Beeper setup surface", () => { }); expect(beeperChannelPlugin.actions).toEqual(expect.any(Object)); expect(beeperChannelPlugin.actions.describeMessageTool()).toMatchObject({ - actions: ["send", "edit", "delete", "react", "read", "mark_unread"], - capabilities: ["media", "replyTo", "reactions", "readReceipts", "markedUnread"], + actions: ["react", "read", "mark_unread"], + capabilities: ["reactions", "readReceipts", "markedUnread"], }); expect(beeperChannelPlugin.actions.extractToolSend({ args: { action: "send", threadId: "$thread", to: "beeper:!room" }, - })).toEqual({ 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), @@ -717,31 +718,7 @@ describe("OpenClaw Beeper setup surface", () => { login: { id: "openclaw:plugin" }, })); - const sendResult = await beeperChannelPlugin.actions.handleAction({ - action: "send", - params: { message: "hello", replyTo: "$parent", to: "!room" }, - }); - const sentMessageId = String(sendResult.content[0]?.text).replace("Sent Beeper message ", ""); - expect(sentMessageId).toMatch(/^openclaw:message:/u); - expect(client.messages.send).not.toHaveBeenCalled(); - expect((queued[0] as { getSender: () => unknown }).getSender()).toEqual({ isFromMe: true, sender: "@codex:example" }); - - await beeperChannelPlugin.actions.handleAction({ - action: "send", - mediaReadFile: async () => Buffer.from("file"), - params: { mediaUrl: "/tmp/a.txt", text: "caption", to: "!room" }, - }); - expect(client.media.upload).toHaveBeenCalledWith({ - bytes: Buffer.from("file"), - filename: "a.txt", - }); - expect(client.messages.sendMedia).not.toHaveBeenCalled(); - - await beeperChannelPlugin.actions.handleAction({ - action: "edit", - params: { eventId: sentMessageId, text: "updated", to: "!room" }, - }); - expect(client.messages.edit).not.toHaveBeenCalled(); + const sentMessageId = "openclaw:message:test"; await beeperChannelPlugin.actions.handleAction({ action: "react", @@ -749,12 +726,6 @@ describe("OpenClaw Beeper setup surface", () => { }); expect(client.reactions.send).not.toHaveBeenCalled(); - await beeperChannelPlugin.actions.handleAction({ - action: "delete", - params: { eventId: sentMessageId, reason: "cleanup", to: "!room" }, - }); - expect(client.messages.redact).not.toHaveBeenCalled(); - await beeperChannelPlugin.heartbeat.sendTyping({ to: "!room" }); expect(client.typing.set).not.toHaveBeenCalled(); await beeperChannelPlugin.actions.handleAction({ @@ -766,11 +737,7 @@ describe("OpenClaw Beeper setup surface", () => { params: { eventId: sentMessageId, to: "!room" }, }); expect(queued.map((event) => (event as { getType: () => string }).getType())).toEqual([ - "message", - "message", - "edit", "reaction", - "message_remove", "typing", "read_receipt", "mark_unread", diff --git a/packages/openclaw/src/setup.ts b/packages/openclaw/src/setup.ts index 99d119c..493ad95 100644 --- a/packages/openclaw/src/setup.ts +++ b/packages/openclaw/src/setup.ts @@ -532,63 +532,16 @@ export const beeperApprovalCapability = { export const beeperMessageActions = { resolveExecutionMode: () => "gateway", describeMessageTool: () => ({ - actions: ["send", "edit", "delete", "react", "read", "mark_unread"], - capabilities: ["media", "replyTo", "reactions", "readReceipts", "markedUnread"], + actions: ["react", "read", "mark_unread"], + capabilities: ["reactions", "readReceipts", "markedUnread"], }), supportsAction: ({ action }: { action: string }) => - action === "send" || action === "edit" || action === "delete" || action === "react" || action === "read" || action === "mark_unread", - extractToolSend: ({ args }: { args: Record }) => { - const action = stringValue(args.action)?.trim(); - if (action !== "send" && action !== "sendMessage") return null; - const to = stringValue(args.to); - if (!to) return null; - const accountId = stringValue(args.accountId); - const threadId = stringValue(args.threadId); - return stripUndefined({ accountId, threadId, to }); - }, + action === "react" || action === "read" || action === "mark_unread", + extractToolSend: () => null, handleAction: async (ctx: { action: string; params: Record; mediaReadFile?: (filePath: string) => Promise }) => { const runtime = requireBeeperChannelRuntime(); const params = ctx.params; const roomId = resolveBeeperRoomTarget(readRequiredString(params, "to", "roomId", "channelId")); - if (ctx.action === "send") { - const mediaUrl = stringValue(params.media) ?? stringValue(params.mediaUrl) ?? stringValue(params.filePath) ?? stringValue(params.path); - const text = stringValue(params.message) ?? stringValue(params.text) ?? ""; - const replyToId = stringValue(params.replyTo) ?? stringValue(params.replyToId); - if (mediaUrl) { - const bytes = ctx.mediaReadFile ? await ctx.mediaReadFile(mediaUrl) : undefined; - const filename = mediaUrl.split("/").pop(); - const sent = await runtime.sendMedia({ - roomId, - ...(bytes !== undefined ? { bytes } : {}), - ...(text ? { caption: text } : {}), - ...(filename ? { filename } : {}), - ...(bytes === undefined ? { path: mediaUrl } : {}), - }); - return { content: [{ type: "text", text: `Sent Beeper media ${sent.eventId}` }] }; - } - const sent = await runtime.sendText({ - roomId, - text, - ...(replyToId ? { replyToId } : {}), - }); - return { content: [{ type: "text", text: `Sent Beeper message ${sent.eventId}` }] }; - } - if (ctx.action === "edit") { - const eventId = readRequiredString(params, "messageId", "eventId"); - const text = readRequiredString(params, "message", "text"); - const sent = await runtime.edit({ eventId, roomId, text }); - return { content: [{ type: "text", text: `Edited Beeper message ${sent.eventId}` }] }; - } - if (ctx.action === "delete") { - const eventId = readRequiredString(params, "messageId", "eventId"); - const reason = stringValue(params.reason); - await runtime.redact({ - eventId, - roomId, - ...(reason !== undefined ? { reason } : {}), - }); - return { content: [{ type: "text", text: `Deleted Beeper message ${eventId}` }] }; - } if (ctx.action === "react") { const eventId = readRequiredString(params, "messageId", "eventId"); const emoji = readRequiredString(params, "emoji", "reaction", "key"); @@ -624,7 +577,7 @@ export const beeperAgentPromptAdapter = { ], text_markup: "Matrix-flavored plain text with optional formatted_body metadata", }), - messageToolCapabilities: () => ["nativeStreaming", "replyTo", "reactions"], + messageToolCapabilities: () => ["reactions"], reactionGuidance: () => ({ channelLabel: "Beeper", level: "minimal" as const }), } as const; From d58a809863f7f14cba1b8c19f4cab83e8fa1d373 Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Mon, 25 May 2026 23:18:42 +0200 Subject: [PATCH 35/43] Fix Beeper streaming and approval tool-call shapes --- packages/openclaw/src/approval.test.ts | 7 +-- packages/openclaw/src/approval.ts | 5 +- .../openclaw/src/beeper-channel-runtime.ts | 30 ++++++++++- packages/openclaw/src/beeper-stream.test.ts | 12 +++-- packages/openclaw/src/beeper-stream.ts | 3 +- packages/openclaw/src/connector.ts | 31 ----------- packages/openclaw/src/integration.test.ts | 25 ++++----- .../openclaw/src/openclaw-runtime.test.ts | 4 +- packages/openclaw/src/openclaw-runtime.ts | 3 ++ packages/openclaw/src/setup.test.ts | 42 ++++++++++++--- packages/openclaw/src/setup.ts | 16 ++++-- packages/pickle/src/client.test.ts | 10 ++-- packages/pickle/src/streams/beeper-message.ts | 54 +++++++++---------- 13 files changed, 138 insertions(+), 104 deletions(-) diff --git a/packages/openclaw/src/approval.test.ts b/packages/openclaw/src/approval.test.ts index 8c3fbb0..ed0c67f 100644 --- a/packages/openclaw/src/approval.test.ts +++ b/packages/openclaw/src/approval.test.ts @@ -119,10 +119,11 @@ describe("OpenClaw approval response parsing", () => { ], id: "approval_1", }, + id: "call_1", + name: "shell", state: "approval-requested", toolCallId: "call_1", - toolName: "shell", - type: "dynamic-tool", + type: "tool-call", }], role: "assistant", }, @@ -230,7 +231,7 @@ describe("OpenClaw approval response parsing", () => { }, state: "approval-responded", toolCallId: "call_6", - type: "dynamic-tool", + type: "tool-call", }, ], }, diff --git a/packages/openclaw/src/approval.ts b/packages/openclaw/src/approval.ts index 5c57332..8a63de0 100644 --- a/packages/openclaw/src/approval.ts +++ b/packages/openclaw/src/approval.ts @@ -180,15 +180,16 @@ export function createBeeperApprovalNotice(params: { 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, - toolName, - type: "dynamic-tool", + type: "tool-call", }], role: "assistant", }, diff --git a/packages/openclaw/src/beeper-channel-runtime.ts b/packages/openclaw/src/beeper-channel-runtime.ts index 8084a31..af53290 100644 --- a/packages/openclaw/src/beeper-channel-runtime.ts +++ b/packages/openclaw/src/beeper-channel-runtime.ts @@ -16,6 +16,7 @@ import { type UserLogin, } from "@beeper/pickle-bridge"; import { BeeperStreamPublisher } from "./beeper-stream"; +import { AGUIEventType } from "./stream-map"; import type { OpenClawAgentContact, OpenClawSessionBinding } from "./types"; export interface BeeperChannelRuntimeOptions { @@ -47,6 +48,7 @@ export class BeeperChannelRuntime { #getBindingBySessionKey: (sessionKey: string) => OpenClawSessionBinding | undefined; #login: UserLogin | undefined; #log: BeeperChannelRuntimeOptions["log"]; + #activeStreams = new Map(); constructor(options: BeeperChannelRuntimeOptions) { this.#bridge = options.bridge; @@ -139,7 +141,7 @@ export class BeeperChannelRuntime { sessionKey: string; threadRoot?: string; }): BeeperStreamPublisher { - return new BeeperStreamPublisher({ + const publisher = new BeeperStreamPublisher({ client: this.client, initialMessageMetadata: { agent_id: options.agentId, @@ -151,6 +153,32 @@ export class BeeperChannelRuntime { ...(options.threadRoot ? { threadRoot: options.threadRoot } : {}), ...(this.userId ? { userId: this.userId } : {}), }); + this.#activeStreams.set(options.sessionKey, publisher); + return publisher; + } + + clearActiveStream(sessionKey: string, publisher: BeeperStreamPublisher): 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, + }); + return { + eventId: publisher.targetEventId ?? publisher.turnId, + raw: { nativeStream: true, turnId: publisher.turnId }, + roomId: publisher.roomId, + }; } debug(message: string, data?: unknown): void { diff --git a/packages/openclaw/src/beeper-stream.test.ts b/packages/openclaw/src/beeper-stream.test.ts index f5577a8..93f8f8a 100644 --- a/packages/openclaw/src/beeper-stream.test.ts +++ b/packages/openclaw/src/beeper-stream.test.ts @@ -54,7 +54,7 @@ describe("OpenClaw Beeper native stream publisher", () => { body: "hello", content: expect.objectContaining({ "com.beeper.ai": expect.objectContaining({ - parts: [{ state: "done", text: "hello", type: "text" }], + parts: [{ content: "hello", state: "done", type: "text" }], }), "com.beeper.ai.metadata": expect.objectContaining({ protocol: "ag-ui", @@ -236,17 +236,19 @@ describe("OpenClaw Beeper native stream publisher", () => { const aiMessage = finalizeMessage.mock.calls[0]?.[0].content["com.beeper.ai"]; expect(aiMessage.parts).toEqual(expect.arrayContaining([ - expect.objectContaining({ text: "thinking", type: "reasoning" }), + expect.objectContaining({ content: "thinking", type: "reasoning" }), expect.objectContaining({ approval: { approved: true, id: "approval_1" }, + arguments: "{\"cmd\":\"date\"}", + id: "tool_1", input: { cmd: "date" }, + name: "shell", output: "ok", state: "approval-responded", toolCallId: "tool_1", - toolName: "shell", - type: "dynamic-tool", + type: "tool-call", }), - expect.objectContaining({ text: "done", type: "text" }), + expect.objectContaining({ content: "done", type: "text" }), ])); }); }); diff --git a/packages/openclaw/src/beeper-stream.ts b/packages/openclaw/src/beeper-stream.ts index efacb2f..e08a0fd 100644 --- a/packages/openclaw/src/beeper-stream.ts +++ b/packages/openclaw/src/beeper-stream.ts @@ -199,6 +199,7 @@ export class BeeperStreamPublisher { } async #publishPart(eventId: string, part: AGUIEvent): Promise { + const streamParts = aguiEventToFinalMessageParts(this.turnId, part); await this.#client.beeper.streams.publishPart({ ...(this.#agentId ? { agentId: this.#agentId } : {}), eventId, @@ -206,7 +207,7 @@ export class BeeperStreamPublisher { roomId: this.roomId, turnId: this.turnId, }); - for (const accumulatorPart of aguiEventToFinalMessageParts(this.turnId, part)) { + for (const accumulatorPart of streamParts) { applyFinalMessagePart(this.#accumulator, accumulatorPart); } } diff --git a/packages/openclaw/src/connector.ts b/packages/openclaw/src/connector.ts index da0499a..9427d1b 100644 --- a/packages/openclaw/src/connector.ts +++ b/packages/openclaw/src/connector.ts @@ -472,43 +472,12 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor 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.targetMessage.id}:read:${msg.userId ?? "unknown"}`, - matrix: { - relation: { - kind: "read_receipt", - ...(msg.receiptType ? { receiptType: msg.receiptType } : {}), - targetEventId: msg.targetMessage.id, - ...streamTargetRelationPatch(binding, msg.targetMessage.id), - }, - sender: msg.userId ?? "receipt", - }, - roomId: msg.portal.mxid, - replyToEventId: msg.targetMessage.id, - sender: msg.userId ?? "receipt", - text: `Read receipt for ${msg.targetMessage.id}`, - }); } async handleMatrixMarkedUnread(_ctx: BridgeRequestContext, msg: MatrixMarkedUnread): Promise { if (!msg.portal.mxid) return; if (!this.isAllowedRoom(msg.portal.mxid)) return; this.upsertPortalBinding(msg.portal); - const eventId = `${msg.portal.mxid}:marked-unread:${msg.unread ? "1" : "0"}:${Date.now()}`; - await this.#agent.handleMatrixText({ - eventId, - matrix: { - relation: { - kind: "marked_unread", - unread: msg.unread, - }, - sender: msg.userId ?? "marked_unread", - }, - roomId: msg.portal.mxid, - sender: msg.userId ?? "marked_unread", - text: msg.unread ? "Marked room unread" : "Unmarked room unread", - }); } async handleMatrixTyping(_ctx: BridgeRequestContext, msg: MatrixTyping): Promise { diff --git a/packages/openclaw/src/integration.test.ts b/packages/openclaw/src/integration.test.ts index c4e10ab..961b39b 100644 --- a/packages/openclaw/src/integration.test.ts +++ b/packages/openclaw/src/integration.test.ts @@ -67,7 +67,7 @@ describe("OpenClaw bridge integration", () => { expect(transport.request).toHaveBeenCalledWith("sessions.send", { idempotencyKey: "$hello", key: "session_1", - matrix: { sender: "@alice:example" }, + matrix: { roomId: "!codex:example", sender: "@alice:example" }, message: "hello", }, { expectFinal: false }); expect(registry.getBindingByRoom("!codex:example")).toMatchObject({ @@ -134,7 +134,7 @@ describe("OpenClaw bridge integration", () => { }); }); - it("dispatches Matrix edits, emoji reactions, redactions, receipts, and unread state through Pickle into OpenClaw", async () => { + 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, @@ -227,20 +227,13 @@ describe("OpenClaw bridge integration", () => { message: "Redacted message $old", replyTo: { eventId: "$old", roomId: "!codex:example" }, }), { expectFinal: false }); - expect(transport.request).toHaveBeenCalledWith("sessions.send", expect.objectContaining({ - idempotencyKey: "$old:read:@alice:example", - matrix: expect.objectContaining({ - relation: expect.objectContaining({ kind: "read_receipt", receiptType: "m.read", targetEventId: "$old" }), - }), - message: "Read receipt for $old", - replyTo: { eventId: "$old", roomId: "!codex:example" }, - }), { expectFinal: false }); - expect(transport.request).toHaveBeenCalledWith("sessions.send", expect.objectContaining({ - matrix: expect.objectContaining({ - relation: expect.objectContaining({ kind: "marked_unread", unread: true }), - }), - message: "Marked room unread", - }), { expectFinal: false }); + const sessionSendPayloads = transport.request.mock.calls + .filter(([method]) => method === "sessions.send") + .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, approval, and backfill with local fakes", async () => { diff --git a/packages/openclaw/src/openclaw-runtime.test.ts b/packages/openclaw/src/openclaw-runtime.test.ts index 5fe6b34..d7c8cff 100644 --- a/packages/openclaw/src/openclaw-runtime.test.ts +++ b/packages/openclaw/src/openclaw-runtime.test.ts @@ -329,8 +329,8 @@ describe("OpenClawGatewayRuntime", () => { channel: "beeper", routeSessionKey: "agent:main:beeper:room", })); - expect((runAssembled.mock.calls[0]?.[0] as { replyOptions?: Record } | undefined)?.replyOptions).not.toMatchObject({ - sourceReplyDeliveryMode: "message_tool_only", + expect((runAssembled.mock.calls[0]?.[0] as { replyOptions?: Record } | undefined)?.replyOptions).toMatchObject({ + sourceReplyDeliveryMode: "automatic", }); expect(beeperStreams.startMessage.mock.invocationCallOrder[0]).toBeLessThan(runAssembled.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY); expect(received).toEqual(expect.arrayContaining([ diff --git a/packages/openclaw/src/openclaw-runtime.ts b/packages/openclaw/src/openclaw-runtime.ts index fdfbfda..d7e794b 100644 --- a/packages/openclaw/src/openclaw-runtime.ts +++ b/packages/openclaw/src/openclaw-runtime.ts @@ -980,6 +980,7 @@ async function runBeeperChannelTurnInPluginRuntime(params: { }, replyOptions: { runId: params.runId, + sourceReplyDeliveryMode: "automatic", timeoutOverrideSeconds: Math.max(1, Math.ceil(params.timeoutMs / 1000)), suppressDefaultToolProgressMessages: true, allowProgressCallbacksWhenSourceDeliverySuppressed: true, @@ -1287,6 +1288,7 @@ function createBeeperReplyStreamEmitter(base: { runId: base.runId, }); await publisher.finalize(stripUndefined({ terminalPart: terminal, finishReason: "stop" })); + channelRuntime.clearActiveStream(base.sessionKey, publisher); channelRuntime.debug("openclaw_beeper_stream_finalized", { eventId: publisher.targetEventId, roomId: base.roomId, @@ -1311,6 +1313,7 @@ function createBeeperReplyStreamEmitter(base: { type: AGUIEventType.RUN_ERROR, }, }); + channelRuntime.clearActiveStream(base.sessionKey, publisher); }, }; } diff --git a/packages/openclaw/src/setup.test.ts b/packages/openclaw/src/setup.test.ts index 1a3d5a8..68120fc 100644 --- a/packages/openclaw/src/setup.test.ts +++ b/packages/openclaw/src/setup.test.ts @@ -193,10 +193,11 @@ describe("OpenClaw Beeper setup surface", () => { ]), id: "approval_1", }, + id: "tool_1", + name: "shell", state: "approval-requested", toolCallId: "tool_1", - toolName: "shell", - type: "dynamic-tool", + type: "tool-call", }], role: "assistant", }, @@ -204,8 +205,8 @@ describe("OpenClaw Beeper setup surface", () => { }); expect(beeperChannelPlugin.actions).toEqual(expect.any(Object)); expect(beeperChannelPlugin.actions.describeMessageTool()).toMatchObject({ - actions: ["react", "read", "mark_unread"], - capabilities: ["reactions", "readReceipts", "markedUnread"], + actions: ["send", "react", "read", "mark_unread"], + capabilities: ["text", "reactions", "readReceipts", "markedUnread"], }); expect(beeperChannelPlugin.actions.extractToolSend({ args: { action: "send", threadId: "$thread", to: "beeper:!room" }, @@ -675,6 +676,13 @@ describe("OpenClaw Beeper setup surface", () => { it("routes OpenClaw message actions through the active Beeper runtime", async () => { const client = { appservice: { sendMessage: vi.fn(async () => ({ eventId: "$as" })) }, + beeper: { + 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" })), @@ -694,7 +702,7 @@ describe("OpenClaw Beeper setup surface", () => { getPortalByMXID: vi.fn(() => ({ portalKey: { id: "session:one", receiver: "openclaw:plugin" } })), queueRemoteEvent: vi.fn((_login: unknown, event: unknown) => queued.push(event)), }; - setBeeperChannelRuntime(new BeeperChannelRuntime({ + const runtime = new BeeperChannelRuntime({ bridge: bridge as never, client: client as never, getAgents: () => [{ @@ -716,10 +724,32 @@ describe("OpenClaw Beeper setup surface", () => { updatedAt: 1, }), login: { id: "openclaw:plugin" }, - })); + }); + setBeeperChannelRuntime(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.streams.publishPart).toHaveBeenCalledWith(expect.objectContaining({ + eventId: "$stream", + part: expect.objectContaining({ + delta: "hello from tool", + type: "TEXT_MESSAGE_CONTENT", + }), + roomId: "!room", + turnId: "run_1", + })); + await beeperChannelPlugin.actions.handleAction({ action: "react", params: { eventId: sentMessageId, key: "+1", to: "!room" }, diff --git a/packages/openclaw/src/setup.ts b/packages/openclaw/src/setup.ts index 493ad95..d628f15 100644 --- a/packages/openclaw/src/setup.ts +++ b/packages/openclaw/src/setup.ts @@ -532,15 +532,23 @@ export const beeperApprovalCapability = { export const beeperMessageActions = { resolveExecutionMode: () => "gateway", describeMessageTool: () => ({ - actions: ["react", "read", "mark_unread"], - capabilities: ["reactions", "readReceipts", "markedUnread"], + actions: ["send", "react", "read", "mark_unread"], + capabilities: ["text", "reactions", "readReceipts", "markedUnread"], }), supportsAction: ({ action }: { action: string }) => - action === "react" || action === "read" || action === "mark_unread", + action === "send" || action === "react" || action === "read" || action === "mark_unread", extractToolSend: () => null, - handleAction: async (ctx: { action: string; params: Record; mediaReadFile?: (filePath: string) => Promise }) => { + handleAction: async (ctx: { action: string; params: Record; mediaReadFile?: (filePath: string) => Promise; sessionKey?: string | null }) => { const runtime = requireBeeperChannelRuntime(); const params = ctx.params; + if (ctx.action === "send") { + const text = readRequiredString(params, "message", "text", "body"); + const sent = await runtime.publishActiveText({ + ...(ctx.sessionKey !== undefined ? { sessionKey: ctx.sessionKey } : {}), + text, + }); + return { content: [{ type: "text", text: `Published Beeper native stream text ${sent.eventId}` }] }; + } const roomId = resolveBeeperRoomTarget(readRequiredString(params, "to", "roomId", "channelId")); if (ctx.action === "react") { const eventId = readRequiredString(params, "messageId", "eventId"); diff --git a/packages/pickle/src/client.test.ts b/packages/pickle/src/client.test.ts index 0d946c9..fdaff0c 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", }, }, @@ -998,10 +998,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 +1036,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 +1050,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/streams/beeper-message.ts b/packages/pickle/src/streams/beeper-message.ts index cf68be3..8b26e18 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,8 +252,8 @@ 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(""); } @@ -347,19 +340,23 @@ function compactParts(parts: unknown[], options: { budget?: { remaining: number .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({ + content: typeof content === "string" ? takeText(content, options.budget) : content, state: part.state, - text: typeof part.text === "string" ? takeText(part.text, options.budget) : part.text, 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 ?? part.toolCallId, input: options.keepToolInput ? part.input : undefined, + name: part.name ?? part.toolName, + output: part.output, state: part.state, toolCallId: part.toolCallId, - toolName: part.toolName, - type: part.type, + type: "tool-call", })]; } return []; @@ -389,8 +386,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); } From 94a36cf4c467e8ba2cd71978161a84f2a405bb55 Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Tue, 26 May 2026 18:11:44 +0200 Subject: [PATCH 36/43] Stream Beeper tool output and handle OpenClaw slash commands --- packages/openclaw/src/beeper-stream.test.ts | 6 +- packages/openclaw/src/beeper-stream.ts | 6 + packages/openclaw/src/connector.test.ts | 26 ++- packages/openclaw/src/connector.ts | 51 +++++ .../openclaw/src/openclaw-runtime.test.ts | 182 ++++++++++++++++- packages/openclaw/src/openclaw-runtime.ts | 184 +++++++++++++++--- packages/openclaw/src/stream-map.ts | 68 +++++-- .../native/internal/core/appservice_test.go | 86 ++++++++ .../pickle/native/internal/core/messages.go | 30 ++- 9 files changed, 586 insertions(+), 53 deletions(-) diff --git a/packages/openclaw/src/beeper-stream.test.ts b/packages/openclaw/src/beeper-stream.test.ts index 93f8f8a..64f8857 100644 --- a/packages/openclaw/src/beeper-stream.test.ts +++ b/packages/openclaw/src/beeper-stream.test.ts @@ -36,8 +36,7 @@ describe("OpenClaw Beeper native stream publisher", () => { threadId: "turn_1", }), "com.beeper.stream": { - type: "com.beeper.llm", - user_id: "@openclaw_agent_codex:example.com", + type: "com.beeper.llm.deltas", }, msgtype: "m.text", }, @@ -66,8 +65,7 @@ describe("OpenClaw Beeper native stream publisher", () => { }), }), "com.beeper.stream": { - type: "com.beeper.llm", - user_id: "@openclaw_agent_codex:example.com", + type: "com.beeper.llm.deltas", }, body: "hello", msgtype: "m.text", diff --git a/packages/openclaw/src/beeper-stream.ts b/packages/openclaw/src/beeper-stream.ts index e08a0fd..0f5e3c0 100644 --- a/packages/openclaw/src/beeper-stream.ts +++ b/packages/openclaw/src/beeper-stream.ts @@ -17,6 +17,7 @@ const BEEPER_AI_KEY = "com.beeper.ai"; const BEEPER_AI_METADATA_KEY = "com.beeper.ai.metadata"; const BEEPER_STREAM_DESCRIPTOR_KEY = "com.beeper.stream"; const BEEPER_AI_STREAM_TYPE = "com.beeper.llm"; +const BEEPER_AI_STREAM_DELTAS_TYPE = "com.beeper.llm.deltas"; export interface BeeperStreamPublisherClient { beeper: MatrixBeeper; @@ -244,6 +245,11 @@ export class BeeperStreamPublisher { } #streamDescriptor(): Record { + if (this.#subscribers.length === 0) { + return { + type: BEEPER_AI_STREAM_DELTAS_TYPE, + }; + } return stripUndefined({ type: BEEPER_AI_STREAM_TYPE, user_id: this.#userId, diff --git a/packages/openclaw/src/connector.test.ts b/packages/openclaw/src/connector.test.ts index ce67702..04d96e3 100644 --- a/packages/openclaw/src/connector.test.ts +++ b/packages/openclaw/src/connector.test.ts @@ -1,4 +1,4 @@ -import type { MatrixEdit, MatrixMessage, MatrixReaction, MatrixReactionRemove, MatrixRedaction, UserLogin } from "@beeper/pickle-bridge"; +import type { MatrixCommand, MatrixEdit, MatrixMessage, MatrixReaction, MatrixReactionRemove, MatrixRedaction, UserLogin } from "@beeper/pickle-bridge"; import { describe, expect, it, vi } from "vitest"; import { createDefaultConfig } from "./config"; import { createOpenClawConnector, OpenClawNetworkAPI, parseMatrixTextMessage, userLoginFromOpenClawConfig } from "./connector"; @@ -55,6 +55,30 @@ describe("OpenClawBridgeConnector", () => { })); }); + it("handles slash-prefixed OpenClaw commands through management command fallback", async () => { + const connector = createOpenClawConnector({ + config: createDefaultConfig({ + dataDir: "/tmp/openclaw", + importSources: ["dashboard"], + }), + }); + const response = await connector.handleCommand({} as never, { + args: [], + body: "/status", + command: "/status", + event: { eventId: "$status", kind: "message", roomId: "!management:example" }, + prefix: "!openclaw", + room: { mxid: "!management:example" }, + sender: { userId: "@alice:example.com" }, + text: "/status", + } as MatrixCommand); + + expect(response).toMatchObject({ + handled: true, + text: expect.stringContaining("Import sources: dashboard"), + }); + }); + it("loads a network API that registers OpenClaw agents as ghosts", async () => { const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); const runtime = runtimeWith({ diff --git a/packages/openclaw/src/connector.ts b/packages/openclaw/src/connector.ts index 9427d1b..a74e7a6 100644 --- a/packages/openclaw/src/connector.ts +++ b/packages/openclaw/src/connector.ts @@ -8,6 +8,8 @@ import { BridgeContext, BridgeRequestContext, BridgeUser, + type MatrixCommand, + type MatrixCommandResponse, ConnectContext, type ContactListingNetworkAPI, FetchMessagesParams, @@ -112,6 +114,55 @@ export class OpenClawBridgeConnector implements BridgeConnector { + const name = command.command.startsWith("/") ? command.command.slice(1).toLowerCase() : command.command.toLowerCase(); + switch (name) { + case "status": + return { + handled: true, + text: bridgeStatusText(this.config, this.registry.data.bindings.length), + }; + case "settings": + return { + handled: true, + text: bridgeSettingsText(this.config, this.registry.data.bindings.length), + }; + case "sessions": { + const options: Parameters[1] = {}; + if (this.config.importSources !== undefined) options.importSources = this.config.importSources; + const sessions = await discoverOneToOneSessions(this.#runtimeFactory(this.config), options); + return { handled: true, text: sessionsSummaryText(sessions) }; + } + case "import": { + const importOptions: Parameters[0] = { + bridge: ctx.bridge, + login: userLoginFromOpenClawConfig(this.config), + registry: this.registry, + runtime: this.#runtimeFactory(this.config), + }; + if (this.config.importSources !== undefined) importOptions.importSources = this.config.importSources; + if (this.config.backfillLimit !== undefined) importOptions.limit = this.config.backfillLimit; + const result = await backfillAllOpenClawSessions(importOptions); + return { handled: true, text: importSummaryText(result) }; + } + case "backfill": + return { handled: true, text: "Usage: /backfill inside an OpenClaw session room." }; + case "new": + return { handled: true, text: "Usage: /new inside an OpenClaw session room." }; + case "agent": + return { handled: true, text: "Use /agent inside an OpenClaw session room." }; + case "approve": + case "deny": + return { handled: true, text: "Approval slash commands are disabled for this bridge." }; + case "stop": + case "abort": + await this.#runtimeFactory(this.config).abortSession({}); + return { handled: true }; + default: + return { handled: false }; + } + } + getBridgeInfoVersion() { return { capabilities: 1, info: 1 }; } diff --git a/packages/openclaw/src/openclaw-runtime.test.ts b/packages/openclaw/src/openclaw-runtime.test.ts index d7c8cff..97179fc 100644 --- a/packages/openclaw/src/openclaw-runtime.test.ts +++ b/packages/openclaw/src/openclaw-runtime.test.ts @@ -267,7 +267,8 @@ describe("OpenClawGatewayRuntime", () => { const runAssembled = vi.fn(async (params: Record) => { const replyOptions = params.replyOptions as Record void | Promise>; await replyOptions.onReasoningStream?.({ text: "checking" }); - await replyOptions.onToolStart?.({ args: { path: "README.md" }, name: "read_file", phase: "start" }); + 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?", @@ -330,12 +331,17 @@ describe("OpenClawGatewayRuntime", () => { routeSessionKey: "agent:main:beeper:room", })); expect((runAssembled.mock.calls[0]?.[0] as { replyOptions?: Record } | undefined)?.replyOptions).toMatchObject({ + disableBlockStreaming: false, sourceReplyDeliveryMode: "automatic", }); expect(beeperStreams.startMessage.mock.invocationCallOrder[0]).toBeLessThan(runAssembled.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY); 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", @@ -354,16 +360,190 @@ describe("OpenClawGatewayRuntime", () => { "REASONING_MESSAGE_CONTENT", "TOOL_CALL_START", "TOOL_CALL_ARGS", + "TOOL_CALL_RESULT", "TOOL_CALL_END", "CUSTOM", "TEXT_MESSAGE_CONTENT", ])); + const toolOutput = beeperStreams.publishPart.mock.calls + .map(([options]) => options.part) + .find((part) => part.type === "TOOL_CALL_RESULT" && part.content === "ok"); + expect(toolOutput).toMatchObject({ + state: "complete", + toolCallId: "real-tool-id", + toolName: "read_file", + }); expect(beeperStreams.finalizeMessage).toHaveBeenCalledWith(expect.objectContaining({ eventId: "$stream-root", roomId: "!room:example", })); }); + it("preserves supported dummybridge-style tool ids and avoids replaying duplicate text callbacks", async () => { + const beeperStreams = { + finalizeMessage: vi.fn(async () => ({ + eventId: "$stream-root", + raw: {}, + replacementEventId: "$stream-final", + roomId: "!room:example", + })), + publishPart: vi.fn(async () => undefined), + startMessage: vi.fn(async () => ({ + descriptor: { type: "com.beeper.llm" }, + eventId: "$stream-root", + roomId: "!room:example", + })), + }; + setBeeperChannelRuntime(new BeeperChannelRuntime({ + client: { + beeper: { streams: beeperStreams }, + media: { upload: vi.fn() }, + } as never, + userId: "@sh-openclaw-bot:example", + })); + const runAssembled = 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.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" }, { kind: "final" }); + return { dispatchResult: { queuedFinal: true } }; + }); + const transport = createOpenClawHostTransport({ + channel: { + reply: { dispatchReplyWithBufferedBlockDispatcher: vi.fn() }, + session: { recordInboundSession: vi.fn(), resolveStorePath: () => "/tmp/sessions.json" }, + turn: { + buildContext: (params: Record) => ({ + Body: "from Beeper", + BodyForAgent: "from Beeper", + From: "beeper", + RawBody: "from Beeper", + SessionKey: (params.route as { routeSessionKey?: string }).routeSessionKey, + To: "beeper", + }), + runAssembled, + }, + }, + config: { current: () => ({ agents: { list: [{ id: "main" }] } }) }, + }); + + const done = (async () => { + for await (const event of transport.events()) { + if (event.event === "run.completed") break; + } + })(); + await transport.request("sessions.send", { + key: "agent:main:beeper:room", + message: "from Beeper", + matrix: { roomId: "!room:example", sender: "@alice:example" }, + }); + await done; + + const parts = beeperStreams.publishPart.mock.calls.map(([options]) => options.part); + expect(parts.filter((part) => part.type === "TEXT_MESSAGE_CONTENT").map((part) => part.delta)).toEqual([ + "hel", + "lo", + " world", + ]); + expect(parts.filter((part) => part.type === "TOOL_CALL_START").map((part) => [part.toolCallId, part.toolName])).toEqual([ + ["tool-a", "read_file"], + ["tool-b", "read_file"], + ]); + expect(parts.filter((part) => part.type === "TOOL_CALL_RESULT").map((part) => [part.toolCallId, part.content, part.state])).toEqual([ + ["tool-a", "chunk-a", "streaming"], + ["tool-a", "done-a", "complete"], + ]); + expect(parts.filter((part) => part.type === "TOOL_CALL_END").map((part) => [part.toolCallId, part.toolName])).toEqual([ + ["tool-a", "read_file"], + ["tool-b", "read_file"], + ]); + }); + + it("streams assistant agent events when reply callbacks only deliver the final block", async () => { + const beeperStreams = { + finalizeMessage: vi.fn(async () => ({ + eventId: "$stream-root", + raw: {}, + replacementEventId: "$stream-final", + roomId: "!room:example", + })), + publishPart: vi.fn(async () => undefined), + startMessage: vi.fn(async () => ({ + descriptor: { type: "com.beeper.llm" }, + eventId: "$stream-root", + roomId: "!room:example", + })), + }; + setBeeperChannelRuntime(new BeeperChannelRuntime({ + client: { + beeper: { streams: beeperStreams }, + media: { upload: vi.fn() }, + } as never, + userId: "@sh-openclaw-bot:example", + })); + let agentEventListener: ((event: { data?: Record; runId?: string; stream?: string }) => void) | undefined; + const runAssembled = vi.fn(async (params: Record) => { + const replyOptions = params.replyOptions as { runId?: string }; + agentEventListener?.({ data: { delta: "hel", text: "hel" }, runId: replyOptions.runId, stream: "assistant" }); + agentEventListener?.({ data: { delta: "lo", text: "hello" }, runId: replyOptions.runId, stream: "assistant" }); + const delivery = params.delivery as { deliver?: (payload: unknown, info?: unknown) => Promise }; + await delivery.deliver?.({ text: "hello world" }, { kind: "final" }); + return { dispatchResult: { queuedFinal: true } }; + }); + const transport = createOpenClawHostTransport({ + channel: { + reply: { dispatchReplyWithBufferedBlockDispatcher: vi.fn() }, + session: { recordInboundSession: vi.fn(), resolveStorePath: () => "/tmp/sessions.json" }, + turn: { + buildContext: (params: Record) => ({ + Body: "from Beeper", + BodyForAgent: "from Beeper", + From: "beeper", + RawBody: "from Beeper", + SessionKey: (params.route as { routeSessionKey?: string }).routeSessionKey, + To: "beeper", + }), + runAssembled, + }, + }, + config: { current: () => ({ agents: { list: [{ id: "main" }] } }) }, + events: { + onAgentEvent: (listener) => { + agentEventListener = listener; + return () => { + agentEventListener = undefined; + }; + }, + }, + }); + + const done = (async () => { + for await (const event of transport.events()) { + if (event.event === "run.completed") break; + } + })(); + await transport.request("sessions.send", { + key: "agent:main:beeper:room", + message: "from Beeper", + matrix: { roomId: "!room:example", sender: "@alice:example" }, + }); + await done; + + const parts = beeperStreams.publishPart.mock.calls.map(([options]) => options.part); + expect(parts.filter((part) => part.type === "TEXT_MESSAGE_CONTENT").map((part) => part.delta)).toEqual([ + "hel", + "lo", + " world", + ]); + }); + 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"); diff --git a/packages/openclaw/src/openclaw-runtime.ts b/packages/openclaw/src/openclaw-runtime.ts index d7e794b..7fe4ad2 100644 --- a/packages/openclaw/src/openclaw-runtime.ts +++ b/packages/openclaw/src/openclaw-runtime.ts @@ -13,6 +13,8 @@ import { mapOpenClawApprovalRequest, mapOpenClawApprovalResponse, mapOpenClawMessageDelta, + mapOpenClawStateDelta, + mapOpenClawToolEnd, mapOpenClawToolInput, mapOpenClawToolOutput, startRunEvents, @@ -79,6 +81,9 @@ export type OpenClawHostEvents = export type OpenClawAgentRuntimeEvent = { data?: Record; + runId?: string; + seq?: number; + ts?: number; sessionKey?: string; stream?: string; }; @@ -953,6 +958,11 @@ async function runBeeperChannelTurnInPluginRuntime(params: { ...(threadRoot ? { threadRoot } : {}), }); params.localEvents.emit({ event: "run.started", payload: { agentId: params.agentId, runId: params.runId, sessionId: params.sessionId, sessionKey: params.sessionKey } }); + const unsubscribeAgentEvents = forwardAgentRuntimeStreamEvents({ + runId: params.runId, + runtime: params.runtime, + stream, + }); try { params.localEvents.emit({ event: "stream.starting", payload: { agentId: params.agentId, roomId, runId: params.runId, sessionId: params.sessionId, sessionKey: params.sessionKey } }); await stream.start(); @@ -969,7 +979,7 @@ async function runBeeperChannelTurnInPluginRuntime(params: { dispatchReplyWithBufferedBlockDispatcher: channelReply.dispatchReplyWithBufferedBlockDispatcher, delivery: { deliver: async (payload: unknown, info?: unknown) => { - await stream.textPayload(payload); + await stream.textPayload(payload, stringValue(recordValue(info)?.kind) === "final" ? "final" : "block"); if (stringValue(recordValue(info)?.kind) === "final") await stream.finish(payload); return { visibleReplySent: true }; }, @@ -980,14 +990,15 @@ async function runBeeperChannelTurnInPluginRuntime(params: { }, replyOptions: { runId: params.runId, + disableBlockStreaming: false, sourceReplyDeliveryMode: "automatic", timeoutOverrideSeconds: Math.max(1, Math.ceil(params.timeoutMs / 1000)), suppressDefaultToolProgressMessages: true, allowProgressCallbacksWhenSourceDeliverySuppressed: true, onAssistantMessageStart: stream.assistantMessageStart, - onBlockReply: stream.textPayload, - onBlockReplyQueued: stream.textPayload, - onPartialReply: stream.textPayload, + onBlockReply: (payload: unknown) => stream.textPayload(payload, "block"), + onBlockReplyQueued: (payload: unknown) => stream.textPayload(payload, "block"), + onPartialReply: (payload: unknown) => stream.textPayload(payload, "partial"), onReasoningEnd: stream.reasoningEnd, onReasoningStream: stream.reasoningPayload, onToolStart: stream.toolStart, @@ -1020,9 +1031,46 @@ async function runBeeperChannelTurnInPluginRuntime(params: { } 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: { + runId: string; + runtime: OpenClawHostRuntime; + stream: ReturnType; +}): (() => void) | undefined { + const onAgentEvent = typeof params.runtime.events === "object" ? params.runtime.events?.onAgentEvent : undefined; + if (!onAgentEvent) return undefined; + getBeeperChannelRuntime()?.debug("openclaw_beeper_agent_event_forwarder_attached", { + runId: params.runId, + }); + return onAgentEvent((event) => { + if (event.stream === "assistant" || event.stream === "thinking") { + getBeeperChannelRuntime()?.debug("openclaw_beeper_agent_event_seen", { + dataKeys: Object.keys(recordValue(event.data) ?? {}), + eventRunId: event.runId, + expectedRunId: params.runId, + matchesRun: event.runId === params.runId, + stream: event.stream, + }); + } + if (event.runId !== params.runId) return; + const data = recordValue(event.data) ?? {}; + switch (event.stream) { + case "assistant": + void params.stream.textPayload(data, "partial"); + break; + case "thinking": + void params.stream.reasoningPayload(data); + break; + default: + break; + } + }); +} + function createBeeperReplyStreamEmitter(base: { agentId: string; localEvents: LocalEventBus; @@ -1046,8 +1094,10 @@ function createBeeperReplyStreamEmitter(base: { const state = createStreamRunState(base.runId); let hasPublished = false; let finalized = false; - let lastPartialText = ""; + let lastVisibleText = ""; let lastReasoningText = ""; + const toolInputs = new Map(); + const toolNames = new Map(); const emit = (event: string, payload: Record) => { base.localEvents.emit({ event, @@ -1097,14 +1147,31 @@ function createBeeperReplyStreamEmitter(base: { }); await publisher.publishMany(list); }; - const textPayload = async (payload: unknown) => { + 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 explicitDelta = stringValue(recordValue(payload)?.delta); - const delta = explicitDelta ?? (text.startsWith(lastPartialText) ? text.slice(lastPartialText.length) : text); - lastPartialText = text; - if (!delta) return; - emit("assistant.delta", { delta, text }); + const delta = explicitDelta ?? visibleTextDelta(lastVisibleText, text); + lastVisibleText = nextVisibleText(lastVisibleText, text, delta); + if (!delta) { + 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 }); await publish(mapOpenClawMessageDelta(state, { kind: "text", value: delta })); }; const reasoningPayload = async (payload: unknown) => { @@ -1119,10 +1186,16 @@ function createBeeperReplyStreamEmitter(base: { }; const toolIdFor = (payload: Record, fallback: string) => stringValue(payload.toolCallId) ?? 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; return { start: ensureStarted, assistantMessageStart: () => { - lastPartialText = ""; + lastVisibleText = ""; emit("assistant.message.start", {}); }, reasoningEnd: async () => { @@ -1133,8 +1206,10 @@ function createBeeperReplyStreamEmitter(base: { textPayload, toolStart: async (payload: unknown) => { const data = recordValue(payload) ?? {}; - const toolCallId = toolIdFor(data, `tool:${stringValue(data.name) ?? "tool"}`); const toolName = stringValue(data.name) ?? stringValue(data.toolName); + const toolCallId = toolIdFor(data, fallbackToolIdForName(toolName, "tool")); + const input = data.args ?? data.input; + rememberTool(toolCallId, toolName, input); emit("tool.call.started", { args: data.args, input: data.args, @@ -1143,8 +1218,13 @@ function createBeeperReplyStreamEmitter(base: { toolName, }); await publish(mapOpenClawToolInput(stripUndefined({ + approval: recordValue(data.approval), + index: numberValue(data.index), input: data.args ?? data.input, + metadata: recordValue(data.metadata), providerExecuted: booleanValue(data.providerExecuted), + startedAtMs: numberValue(data.startedAt) ?? numberValue(data.startedAtMs), + title: stringValue(data.title), toolCallId, toolName, }))); @@ -1152,16 +1232,18 @@ function createBeeperReplyStreamEmitter(base: { toolResult: async (payload: unknown) => { const data = recordValue(payload) ?? {}; const toolCallId = toolIdFor(data, "tool_result"); - const toolName = stringValue(data.toolName) ?? stringValue(data.name); + const toolName = rememberedToolName(toolCallId, stringValue(data.toolName) ?? stringValue(data.name)); + const error = data.error ?? (booleanValue(data.isError) ? (data.text ?? data.content ?? data.output ?? payload) : undefined); + const output = data.text ?? data.content ?? data.output ?? data.result ?? payload; emit("tool.call.completed", { - output: data.text ?? data.content ?? payload, + output, toolCallId, toolName, }); - await publish(mapOpenClawToolOutput(stripUndefined({ - error: data.error, - output: data.text ?? data.content ?? data.output ?? payload, - providerExecuted: booleanValue(data.providerExecuted), + await publish(mapOpenClawToolEnd(stripUndefined({ + error, + input: data.input ?? toolInputs.get(toolCallId), + result: error === undefined ? output : undefined, toolCallId, toolName, }))); @@ -1171,25 +1253,30 @@ function createBeeperReplyStreamEmitter(base: { const toolCallId = toolIdFor(data, stringValue(data.kind) ?? "item"); const output = stringValue(data.progressText) ?? stringValue(data.summary) ?? stringValue(data.title); if (!output) return; - const preliminary = stringValue(data.phase) !== "complete" && stringValue(data.status) !== "complete"; + const phase = stringValue(data.phase); + const status = stringValue(data.status); + const preliminary = phase !== "complete" && phase !== "end" && status !== "complete" && status !== "completed"; + const toolName = rememberedToolName(toolCallId, stringValue(data.name) ?? stringValue(data.kind)); + rememberTool(toolCallId, toolName); emit("tool.call.completed", { output, preliminary, toolCallId, - toolName: stringValue(data.name) ?? stringValue(data.kind), + toolName, }); await publish(mapOpenClawToolOutput(stripUndefined({ output, preliminary, toolCallId, - toolName: stringValue(data.name) ?? stringValue(data.kind), + toolName, }))); }, planUpdate: async (payload: unknown) => { const data = recordValue(payload) ?? {}; const output = stringValue(data.explanation) ?? stringValue(data.title); if (!output) return; - const preliminary = stringValue(data.phase) !== "complete"; + const phase = stringValue(data.phase); + const preliminary = phase !== "complete" && phase !== "end"; emit("tool.call.completed", { output, preliminary, @@ -1202,6 +1289,10 @@ function createBeeperReplyStreamEmitter(base: { toolCallId: "plan", toolName: "plan", })); + const steps = arrayValue(data.steps)?.filter((step): step is string => typeof step === "string"); + if (steps?.length) { + await publish(mapOpenClawStateDelta([{ op: "add", path: "/plan", value: steps }])); + } }, approvalEvent: async (payload: unknown) => { const data = recordValue(payload) ?? {}; @@ -1209,8 +1300,9 @@ function createBeeperReplyStreamEmitter(base: { if (phase === "requested") { const approvalId = stringValue(data.approvalId) ?? stringValue(data.approvalSlug); const toolCallId = stringValue(data.toolCallId) ?? stringValue(data.itemId); - const toolName = stringValue(data.kind) ?? stringValue(data.command); + 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, @@ -1225,26 +1317,30 @@ function createBeeperReplyStreamEmitter(base: { 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: stringValue(data.toolCallId) ?? stringValue(data.itemId), + toolCallId, }); await publish([mapOpenClawApprovalResponse(stripUndefined({ approvalId, approved, approvedAlways: booleanValue(data.always) ?? booleanValue(data.approvedAlways), - toolCallId: stringValue(data.toolCallId) ?? stringValue(data.itemId), + toolCallId, }))]); } }, commandOutput: async (payload: unknown) => { const data = recordValue(payload) ?? {}; - const complete = stringValue(data.phase) === "complete" || stringValue(data.status) === "complete"; - const toolCallId = toolIdFor(data, `command:${stringValue(data.name) ?? "output"}`); const toolName = stringValue(data.name) ?? stringValue(data.title) ?? "command"; + const phase = stringValue(data.phase); + const status = stringValue(data.status); + const complete = phase === "complete" || phase === "end" || status === "complete" || status === "completed"; + const toolCallId = toolIdFor(data, fallbackToolIdForName(toolName, "command")); const output = stringValue(data.output) ?? data; + rememberTool(toolCallId, toolName); emit("tool.call.completed", { output, preliminary: !complete, @@ -1257,21 +1353,36 @@ function createBeeperReplyStreamEmitter(base: { toolCallId, toolName, })); + if (complete) { + await publish(mapOpenClawToolEnd(stripUndefined({ + input: toolInputs.get(toolCallId), + result: status ? { output, status } : output, + toolCallId, + toolName, + }))); + } }, patchSummary: async (payload: unknown) => { const data = recordValue(payload) ?? {}; const toolCallId = toolIdFor(data, "patch"); - const toolName = stringValue(data.name) ?? "patch"; + const toolName = rememberedToolName(toolCallId, stringValue(data.name) ?? "patch"); const output = data.summary ?? data; + rememberTool(toolCallId, toolName); emit("tool.call.completed", { output, toolCallId, toolName, }); - await publish(mapOpenClawToolOutput({ output, toolCallId, toolName })); + await publish(mapOpenClawToolOutput(stripUndefined({ output, toolCallId, toolName }))); + await publish(mapOpenClawToolEnd(stripUndefined({ + input: toolInputs.get(toolCallId), + result: output, + toolCallId, + toolName, + }))); }, finish: async (payload?: unknown) => { - if (payload !== undefined) await textPayload(payload); + if (payload !== undefined) await textPayload(payload, "final"); if (!hasPublished || finalized) return; const events = finishRunEvents(state, "stop", { agent_id: base.agentId, @@ -1335,6 +1446,19 @@ function replyPayloadText(payload: unknown): string | undefined { return chunks.length > 0 ? chunks.join("") : undefined; } +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); diff --git a/packages/openclaw/src/stream-map.ts b/packages/openclaw/src/stream-map.ts index af87c10..0383878 100644 --- a/packages/openclaw/src/stream-map.ts +++ b/packages/openclaw/src/stream-map.ts @@ -147,8 +147,11 @@ export function closeReasoningPart(state: StreamRunState): AGUIEvent[] { } export function mapOpenClawToolInput(event: { + approval?: { id?: string; needsApproval?: boolean } | Record; dynamic?: boolean; + index?: number; input?: unknown; + metadata?: Record; providerExecuted?: boolean; startedAtMs?: number; title?: string; @@ -156,35 +159,33 @@ export function mapOpenClawToolInput(event: { toolName?: string; }): AGUIEvent[] { const toolName = event.toolName || "tool"; - return [ + const parts: AGUIEvent[] = [ { parentMessageId: event.toolCallId, - state: "awaiting-input", + state: event.approval ? "approval-requested" : "awaiting-input", toolCallId: event.toolCallId, toolCallName: toolName, toolName, type: AGUIEventType.TOOL_CALL_START, + ...(event.approval !== undefined ? { approval: event.approval } : {}), ...(event.dynamic !== undefined ? { dynamic: event.dynamic } : {}), + ...(event.index !== undefined ? { index: event.index } : {}), + ...(event.metadata !== undefined ? { metadata: event.metadata } : {}), ...(event.providerExecuted !== undefined ? { providerExecuted: event.providerExecuted } : {}), ...(event.startedAtMs !== undefined ? { startedAtMs: event.startedAtMs } : {}), ...(event.title !== undefined ? { title: event.title } : {}), }, - { + ]; + if (event.input !== undefined) { + parts.push({ args: stringifyToolValue(event.input), delta: stringifyToolValue(event.input), state: "input-streaming", toolCallId: event.toolCallId, type: AGUIEventType.TOOL_CALL_ARGS, - }, - { - input: event.input, - state: "input-complete", - toolCallId: event.toolCallId, - toolCallName: toolName, - toolName, - type: AGUIEventType.TOOL_CALL_END, - }, - ]; + } as AGUIEvent); + } + return parts; } export function mapOpenClawToolInputDelta(event: { @@ -204,6 +205,29 @@ export function mapOpenClawToolInputDelta(event: { ]; } +export function mapOpenClawToolEnd(event: { + error?: unknown; + input?: unknown; + result?: unknown; + state?: string; + toolCallId: string; + toolName?: string; +}): AGUIEvent[] { + const result = event.result ?? (event.error !== undefined ? { + reason: stringifyToolValue(event.error), + state: "error", + status: "failed", + } : undefined); + return [{ + ...(event.input !== undefined ? { input: event.input } : {}), + ...(result !== undefined ? { result: stringifyToolValue(result) } : {}), + state: event.state ?? "input-complete", + toolCallId: event.toolCallId, + ...(event.toolName !== undefined ? { toolCallName: event.toolName, toolName: event.toolName } : {}), + type: AGUIEventType.TOOL_CALL_END, + } as AGUIEvent]; +} + export function mapOpenClawToolOutput(event: { completedAtMs?: number; error?: unknown; @@ -230,6 +254,24 @@ export function mapOpenClawToolOutput(event: { ]; } +export function mapOpenClawStep(event: { phase?: string; stepName: string }): AGUIEvent[] { + return [ + { + messageId: event.stepName, + stepName: event.stepName, + type: event.phase === "end" || event.phase === "complete" ? AGUIEventType.STEP_FINISHED : AGUIEventType.STEP_STARTED, + }, + ]; +} + +export function mapOpenClawStateDelta(delta: unknown): AGUIEvent[] { + return [{ delta: Array.isArray(delta) ? delta : [{ op: "add", path: "/state", value: delta }], type: AGUIEventType.STATE_DELTA }]; +} + +export function mapOpenClawCustom(name: string, value: unknown): AGUIEvent[] { + return [{ name, type: AGUIEventType.CUSTOM, value }]; +} + export function mapOpenClawApprovalRequest( state: StreamRunState, event: { approvalId?: string; message?: string; toolCallId?: string; toolName?: string } diff --git a/packages/pickle/native/internal/core/appservice_test.go b/packages/pickle/native/internal/core/appservice_test.go index ef62e64..e39dae7 100644 --- a/packages/pickle/native/internal/core/appservice_test.go +++ b/packages/pickle/native/internal/core/appservice_test.go @@ -332,6 +332,92 @@ func TestBeeperStreamCarrierContentUsesAIBridgeEnvelopeShape(t *testing.T) { } } +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.deltas"}`) { + 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.llm.deltas"`) { + 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 deltas in carrier body, got %s", req.body) + } + return + case <-deadline: + t.Fatal("timed out waiting for room carrier stream event") + } + } +} + func TestRegisterBeeperStreamInjectsDirectSubscribers(t *testing.T) { requests := make(chan recordedRequest, 4) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/packages/pickle/native/internal/core/messages.go b/packages/pickle/native/internal/core/messages.go index 272d4b6..559c9fb 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": aistream.BeeperAIStreamDeltas, + } + } 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(). @@ -230,8 +240,20 @@ func (c *Core) handlePublishBeeperStreamMessagePart(ctx context.Context, payload if err != nil { return nil, err } - if err := c.beeperStream.Publish(ctx, stream.roomID, id.EventID(req.EventID), content); err != nil { - return nil, err + if stream.direct { + if err := c.beeperStream.Publish(ctx, stream.roomID, id.EventID(req.EventID), content); err != nil { + return nil, err + } + } else { + content["body"] = "" + content["msgtype"] = "m.text" + content["m.relates_to"] = map[string]any{ + "rel_type": "m.reference", + "event_id": req.EventID, + } + if _, err := c.sendBeeperStreamMessageEvent(ctx, stream.roomID.String(), "", stream.userID, content); err != nil { + return nil, err + } } stream.nextSeq = seq + 1 return c.empty() From 584b0671586085342efe24dde3c543bc8933c126 Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Wed, 27 May 2026 02:40:34 +0200 Subject: [PATCH 37/43] Add HTML formatting to OpenClaw command replies --- PLAN_OPENCLAW.md | 94 +++++ packages/openclaw/src/connector.test.ts | 21 +- packages/openclaw/src/connector.ts | 467 ++++++++++++++++++++---- 3 files changed, 505 insertions(+), 77 deletions(-) create mode 100644 PLAN_OPENCLAW.md diff --git a/PLAN_OPENCLAW.md b/PLAN_OPENCLAW.md new file mode 100644 index 0000000..0d0de0a --- /dev/null +++ b/PLAN_OPENCLAW.md @@ -0,0 +1,94 @@ +# First-Class Beeper Network Connector Rewrite + +## Summary +Rewrite `@beeper/pickle-openclaw` as a first-class OpenClaw channel plugin, modeled after Telegram’s plugin-SDK architecture, with Beeper native AG-UI streaming backed by the existing Go `ai-bridge` code through Pickle’s WASM bridge. + +This is a nuclear cut: remove the bespoke OpenClaw gateway transport, ad hoc stream mappers, and compatibility command path. The new connector uses OpenClaw’s channel plugin contract for setup, runtime startup, inbound dispatch, outbound delivery, approvals, actions, directory, routing, and message streaming. + +## Key Architecture +- Register the channel with `defineChannelPluginEntry` and `defineSetupPluginEntry` from `openclaw/plugin-sdk/channel-core`. +- Build `beeperChannelPlugin` with `createChannelPluginBase` / `createChatChannelPlugin`, matching Telegram’s shape: + - `config`, `setup`, `setupWizard`, `status`, `gateway` + - `message`, `outbound`, `messaging`, `threading` + - `directory`, `resolver`, `actions`, `approvalCapability`, `agentPrompt` + - `commands` for OpenClaw-native command discovery instead of connector-local slash switches. +- Promote Beeper capabilities to a real network connector surface: + - `chatTypes: ["direct", "group", "thread"]` + - `media: true`, `reactions: true`, `threads: true` + - `nativeCommands: true`, `blockStreaming: true` +- `gateway.startAccount` starts the Pickle/Beeper appservice bridge and registers a Beeper network runtime with `api.runtime.channel.runtimeContexts`. +- Message adapters resolve the active Beeper runtime through the stored OpenClaw `PluginRuntime`, not through a global singleton. +- Inbound Matrix events enter OpenClaw through `runtime.channel.turn.run` / `runAssembled` and SDK-built inbound context, not through custom `sessions.send` RPC emulation. + +## Streaming Design +- Introduce a `BeeperTurnStreamCoordinator` in TypeScript: + - one coordinator per OpenClaw turn + - one or more Beeper native stream anchors per assistant segment + - all text, reasoning, tools, approvals, state, sources, files, data, snapshots, and terminal events pass through one serialized queue +- Use multiple Beeper stream messages when OpenClaw emits multiple assistant messages or when a tool/progress segment needs its own live stream before answer text exists. +- Preserve event order exactly for live streaming. Do not reorder text/tool/progress events in TypeScript. +- Keep durable finalization per stream anchor: + - default finalization is replacement edit with final `com.beeper.ai` + - no `append` or `native-only` mode in the new OpenClaw connector +- Tool lifecycle rules: + - tool start emits `TOOL_CALL_START` + - argument chunks emit `TOOL_CALL_ARGS` + - progress emits `TOOL_CALL_RESULT` with `state: "streaming"` + - final result emits `TOOL_CALL_RESULT` with `state: "complete"` or `"error"` + - close emits `TOOL_CALL_END` + - approval request/response emits both AG-UI custom approval events and matching tool state transitions. + +## Go/WASM `ai-bridge` Usage +- Keep using the existing `github.com/beeper/ai-bridge` dependency already present in `packages/pickle/native/go.mod`. +- Add Pickle WASM operations that expose `ai-stream` run behavior to TypeScript: + - `begin_beeper_ai_run`: creates an `aistream.Run`, returns initial Beeper AI content and start events. + - `append_beeper_ai_run_event`: validates and records one AG-UI event in Go. + - `finish_beeper_ai_run`: calls Go writer finalization, returns final events and final content. + - `error_beeper_ai_run`: finalizes as error or abort and returns final events/content. + - `delete_beeper_ai_run`: releases native run state. +- Move final `com.beeper.ai` and `com.beeper.ai.metadata` construction to Go via `aistream.Run.FinalUIMessage()` and `Run.Metadata()`. +- Update native `publish_beeper_stream_message_part` to use `aistream.PackRunFromSeq` semantics for oversized events, so text/tool/snapshot payloads split into budget-safe envelopes while preserving seq. +- TypeScript remains responsible only for adapting OpenClaw callback/event payloads into canonical AG-UI event intents; Go owns validation, metadata, snapshots, final UI message construction, and carrier budget handling. + +## Implementation Changes +- Replace `openclaw-extension.ts` custom registration with SDK entry helpers and `setRuntime(api.runtime)`. +- Replace `OpenClawGatewayRuntime` and `createOpenClawHostTransport` usage in Beeper-originated turns with OpenClaw plugin runtime/channel helpers. +- Replace `BeeperStreamPublisher` and `stream-map.ts` with the new coordinator plus Go-backed AI run bridge. +- Replace connector-local `/help`, `/tools`, `/models`, `/tasks`, `/stop`, approval command handling with OpenClaw SDK command and approval surfaces. +- Keep the Pickle bridge/appservice mechanics for Matrix transport, portals, contacts, appservice registration, media, reactions, receipts, and backfill where still needed. +- Preserve user work currently present in `packages/openclaw/src/connector.ts` and `packages/openclaw/src/connector.test.ts` only if it still applies after the rewrite; do not silently overwrite it. + +## Test Plan +- Add plugin contract tests proving Beeper registers like Telegram: + - `defineChannelPluginEntry` registration modes + - channel metadata/capabilities + - gateway start/stop lifecycle + - runtime context registration + - message/outbound/action/approval surfaces +- Add Go native tests for: + - begin/append/finish/error/delete AI run operations + - final UI content parity with `ai-bridge` + - carrier splitting with large text, tool output, and `MESSAGES_SNAPSHOT` + - seq continuity after split carriers +- Add TypeScript streaming tests for: + - text and reasoning chunk streaming + - tool args/progress/result/end ordering + - approvals with response state + - plan/state/source/document/file/data/custom events + - multiple assistant messages producing multiple Beeper streams + - abort/error terminal paths +- Add end-to-end-style plugin runtime tests using OpenClaw’s plugin test runtime: + - inbound Beeper message dispatches through `runtime.channel.turn` + - final delivery goes through Beeper message adapter + - live AG-UI deltas arrive before final replacement +- Run: + - `pnpm --filter @beeper/pickle test:go` + - `pnpm --filter @beeper/pickle test` + - `pnpm --filter @beeper/pickle-openclaw test` + - `pnpm --filter @beeper/pickle-openclaw typecheck` + - `pnpm check` + +## Assumptions +- No migration means old internal APIs, tests, config modes, and stream finalization options may be deleted. +- Pickle native Matrix/Beeper transport remains the foundation; only missing `ai-bridge` run-state operations and carrier splitting are added. +- Live streaming fidelity is the highest priority; final content should be Go `ai-bridge` canonical even where that canonical final representation is less interleaved than live events. diff --git a/packages/openclaw/src/connector.test.ts b/packages/openclaw/src/connector.test.ts index 04d96e3..ecebb74 100644 --- a/packages/openclaw/src/connector.test.ts +++ b/packages/openclaw/src/connector.test.ts @@ -74,6 +74,11 @@ describe("OpenClawBridgeConnector", () => { } as MatrixCommand); expect(response).toMatchObject({ + content: { + format: "org.matrix.custom.html", + formatted_body: expect.stringContaining("
Behavior
"), + msgtype: "m.text", + }, handled: true, text: expect.stringContaining("Import sources: dashboard"), }); @@ -1041,11 +1046,20 @@ describe("OpenClawBridgeConnector", () => { text: "/status", } as MatrixMessage)).resolves.toEqual({ pending: false }); expect(queueRemoteEvent.mock.calls.at(-1)?.[1].getID()).toBe("$status:openclaw-command"); + expect(queueRemoteEvent.mock.calls.at(-1)?.[1].getSender()).toEqual({ + isFromMe: true, + sender: "@codex:example.com", + }); await expect(queueRemoteEvent.mock.calls.at(-1)?.[1].convertMessage()).resolves.toMatchObject({ - parts: [{ content: { body: expect.stringContaining("Import sources: dashboard") } }], + parts: [{ content: { body: expect.stringContaining("Import sources: dashboard"), msgtype: "m.text" } }], }); await expect(queueRemoteEvent.mock.calls.at(-1)?.[1].convertMessage()).resolves.toMatchObject({ - parts: [{ content: { body: expect.stringContaining("Approvals: native Beeper UI") } }], + parts: [{ content: { body: expect.stringContaining("Approvals: native Beeper UI"), msgtype: "m.text" } }], + }); + const statusContent = (await queueRemoteEvent.mock.calls.at(-1)?.[1].convertMessage()).parts[0].content; + expect(statusContent).toMatchObject({ + format: "org.matrix.custom.html", + formatted_body: expect.stringContaining("
Behavior
"), }); await api.handleMatrixMessage(ctx, { @@ -1062,6 +1076,9 @@ describe("OpenClawBridgeConnector", () => { expect(settingsBody).toContain("Contact visibility: agents-and-users"); expect(settingsBody).toContain("Allowed rooms: !room:example.com"); expect(settingsBody).toContain("Allowed users: @alice:example.com"); + const settingsContent = (await queueRemoteEvent.mock.calls.at(-1)?.[1].convertMessage()).parts[0].content; + expect(settingsContent.formatted_body).toContain("OpenClaw Beeper settings
"); + expect(settingsContent.formatted_body).toContain("
Allowed rooms: !room:example.com
"); await api.handleMatrixMessage(ctx, { event: { eventId: "$sessions" }, diff --git a/packages/openclaw/src/connector.ts b/packages/openclaw/src/connector.ts index a74e7a6..80b6940 100644 --- a/packages/openclaw/src/connector.ts +++ b/packages/openclaw/src/connector.ts @@ -63,12 +63,47 @@ import { BeeperChannelRuntime, setBeeperChannelRuntime } from "./beeper-channel- import { agentPortalSessionKey, OpenClawMatrixBridgeAgent } from "./bridge-agent"; import { createDefaultConfig } from "./config"; import { parseMatrixTextMessage, type ParsedMatrixTextMessage } from "./matrix-parser"; -import { createOpenClawHostTransport, OpenClawGatewayRuntime, type OpenClawHostRuntime, type OpenClawMatrixMessageMetadata } from "./openclaw-runtime"; +import { createOpenClawHostTransport, OpenClawGatewayRuntime, type OpenClawGatewayFeatureSnapshot, type OpenClawHostRuntime, type OpenClawMatrixMessageMetadata } from "./openclaw-runtime"; import { OpenClawBridgeRegistry } from "./registry"; import { agentContactFromOpenClawAgent, agentGhostUserId, serviceBotUserId } from "./rooms"; import type { OpenClawAgentContact, OpenClawBridgeConfig, OpenClawSessionBinding, OpenClawUserContact } from "./types"; const DEFAULT_NEW_SESSION_LABEL = "New OpenClaw Session"; +const MATRIX_HTML_FORMAT = "org.matrix.custom.html"; + +type CommandReply = { + html?: string; + text: string; +}; + +type CommandSection = { + entries: Array<[string, string | number | boolean | undefined]>; + title: string; +}; + +type SupportedCommandSpec = { + command: string; + description: string; +}; + +const SUPPORTED_COMMON_COMMANDS: SupportedCommandSpec[] = [ + { command: "/help", description: "Show supported OpenClaw commands." }, + { command: "/commands", description: "List supported OpenClaw commands." }, + { command: "/status", description: "Show bridge and current room status." }, + { command: "/settings", description: "Show Beeper bridge settings." }, + { command: "/tools [compact|verbose]", description: "List available runtime tools." }, + { command: "/models", description: "List configured OpenClaw models." }, + { command: "/tasks", description: "List recent OpenClaw tasks." }, + { command: "/sessions", description: "List importable one-to-one OpenClaw sessions." }, + { command: "/backfill", description: "Queue backfill for the current session room." }, + { command: "/import", description: "Import supported OpenClaw session history." }, + { command: "/new [agent-id] [session label]", description: "Create a new OpenClaw session room." }, + { command: "/agent", description: "Show the agent bound to this room." }, + { command: "/stop", description: "Abort the active run in this room." }, + { command: "/abort", description: "Abort the active run in this room." }, + { command: "/approve ", description: "Approve a pending native approval request when enabled." }, + { command: "/deny ", description: "Deny a pending native approval request when enabled." }, +]; export interface OpenClawConnectorOptions { config?: OpenClawBridgeConfig; @@ -117,47 +152,60 @@ export class OpenClawBridgeConnector implements BridgeConnector { const name = command.command.startsWith("/") ? command.command.slice(1).toLowerCase() : command.command.toLowerCase(); switch (name) { + case "help": + case "commands": + return commandResponse(commandsReply()); case "status": - return { - handled: true, - text: bridgeStatusText(this.config, this.registry.data.bindings.length), - }; + return commandResponse(await bridgeStatusReply(this.config, this.registry.data.bindings.length, undefined, this.runtime)); case "settings": - return { - handled: true, - text: bridgeSettingsText(this.config, this.registry.data.bindings.length), - }; + return commandResponse(bridgeSettingsReply(this.config, this.registry.data.bindings.length)); + case "tools": { + const runtime = this.#runtimeFactory(this.config); + return commandResponse(await toolsReply(runtime, command.args.join(" "))); + } + case "models": { + const runtime = this.#runtimeFactory(this.config); + return commandResponse(await modelsReply(runtime)); + } + case "tasks": { + const runtime = this.#runtimeFactory(this.config); + return commandResponse(await tasksReply(runtime, undefined)); + } case "sessions": { + const runtime = this.#runtimeFactory(this.config); const options: Parameters[1] = {}; if (this.config.importSources !== undefined) options.importSources = this.config.importSources; - const sessions = await discoverOneToOneSessions(this.#runtimeFactory(this.config), options); - return { handled: true, text: sessionsSummaryText(sessions) }; + const sessions = await discoverOneToOneSessions(runtime, options); + return commandResponse(sessionsSummaryReply(sessions)); } case "import": { + const runtime = this.#runtimeFactory(this.config); const importOptions: Parameters[0] = { bridge: ctx.bridge, login: userLoginFromOpenClawConfig(this.config), registry: this.registry, - runtime: this.#runtimeFactory(this.config), + runtime, }; if (this.config.importSources !== undefined) importOptions.importSources = this.config.importSources; if (this.config.backfillLimit !== undefined) importOptions.limit = this.config.backfillLimit; const result = await backfillAllOpenClawSessions(importOptions); - return { handled: true, text: importSummaryText(result) }; + return commandResponse(importSummaryReply(result)); } case "backfill": - return { handled: true, text: "Usage: /backfill inside an OpenClaw session room." }; + return commandResponse(simpleReply("Usage", "Use /backfill inside an OpenClaw session room.")); case "new": - return { handled: true, text: "Usage: /new inside an OpenClaw session room." }; + return commandResponse(simpleReply("Usage", "Use /new inside an OpenClaw session room.")); case "agent": - return { handled: true, text: "Use /agent inside an OpenClaw session room." }; + return commandResponse(simpleReply("Usage", "Use /agent inside an OpenClaw session room.")); case "approve": case "deny": - return { handled: true, text: "Approval slash commands are disabled for this bridge." }; + return commandResponse(simpleReply("Approvals", "Approval slash commands are disabled for this bridge.")); case "stop": - case "abort": - await this.#runtimeFactory(this.config).abortSession({}); + case "abort": { + const runtime = this.#runtimeFactory(this.config); + await runtime.abortSession({}); return { handled: true }; + } default: return { handled: false }; } @@ -615,22 +663,31 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor binding: OpenClawSessionBinding | undefined, msg: MatrixMessage, ): Promise { - const notice = (text: string, noticeBinding = binding) => - commandNotice(ctx, this.#login, msg, text, canonicalPortalKeyForBinding(noticeBinding, this.#login.id) ?? msg.portal.portalKey); + const notice = (reply: CommandReply | string, noticeBinding = binding) => + commandNotice(ctx, this.#config, this.#login, msg, reply, noticeBinding); switch (command.name) { + case "help": + case "commands": + return notice(commandsReply()); case "status": - return notice(bridgeStatusText(this.#runtime.config, this.#registry.data.bindings.length)); + return notice(await bridgeStatusReply(this.#runtime.config, this.#registry.data.bindings.length, binding, this.#runtime)); case "settings": - return notice(bridgeSettingsText(this.#runtime.config, this.#registry.data.bindings.length)); + return notice(bridgeSettingsReply(this.#runtime.config, this.#registry.data.bindings.length)); + case "tools": + return notice(await toolsReply(this.#runtime, command.args)); + case "models": + return notice(await modelsReply(this.#runtime)); + case "tasks": + return notice(await tasksReply(this.#runtime, binding)); case "sessions": { const options: Parameters[1] = {}; if (this.#runtime.config.importSources !== undefined) options.importSources = this.#runtime.config.importSources; const sessions = await discoverOneToOneSessions(this.#runtime, options); - return notice(sessionsSummaryText(sessions)); + return notice(sessionsSummaryReply(sessions)); } case "backfill": const count = await this.backfillCurrentRoom(ctx, binding, msg); - return notice(`Queued backfill for ${count} message${count === 1 ? "" : "s"}.`); + return notice(simpleReply("Backfill", `Queued backfill for ${count} message${count === 1 ? "" : "s"}.`)); case "import": { const importOptions: Parameters[0] = { bridge: ctx.bridge, @@ -641,17 +698,17 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor if (this.#runtime.config.importSources !== undefined) importOptions.importSources = this.#runtime.config.importSources; if (this.#runtime.config.backfillLimit !== undefined) importOptions.limit = this.#runtime.config.backfillLimit; const result = await backfillAllOpenClawSessions(importOptions); - return notice(importSummaryText(result)); + return notice(importSummaryReply(result)); } case "new": { const request = this.resolveNewSessionCommand(command.args, binding); if (!request) { - return notice("Usage: /new [agent-id] [session label]. In an agent DM, /new [session label] is enough."); + return notice(simpleReply("Usage", "Usage: /new [agent-id] [session label]. In an agent DM, /new [session label] is enough.")); } if (!binding && msg.portal.mxid) { const created = await this.createBindingForMatrixRoom(msg.portal.mxid, request.label, request.agentId, request.ghostUserId); this.registerCanonicalPortalForBinding(ctx, msg.portal, created); - return notice(`Created a new OpenClaw session in this room: ${created.sessionKey}`, created); + return notice(simpleReply("New Session", `Created a new OpenClaw session in this room: ${created.sessionKey}`), created); } const session = await this.#runtime.createSession({ agentId: request.agentId, @@ -688,29 +745,29 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor }); } await this.#registry.save(); - return notice(portal.mxid + return notice(simpleReply("New Session", portal.mxid ? `Created a new OpenClaw session room: ${portal.mxid}` - : `Created a new OpenClaw session: ${session.key}`); + : `Created a new OpenClaw session: ${session.key}`)); } case "approve": case "deny": { if (!approvalSlashEnabled(this.#runtime.config)) { - return notice("Approval slash commands are disabled for this bridge."); + return notice(simpleReply("Approvals", "Approval slash commands are disabled for this bridge.")); } const approvalId = command.args.trim() || approvalIdFromMatrixReply(msg); - if (!approvalId) return notice(`Usage: /${command.name} or reply to an approval message with /${command.name}`); + if (!approvalId) return notice(simpleReply("Usage", `Usage: /${command.name} or reply to an approval message with /${command.name}`)); await this.#agent.handleApprovalContent({ approvalId, approved: command.name === "approve", approvedAlways: false, type: "tool-approval-response", }, approvalId); - return notice(`${command.name === "approve" ? "Approved" : "Denied"} ${approvalId}.`); + return notice(simpleReply("Approvals", `${command.name === "approve" ? "Approved" : "Denied"} ${approvalId}.`)); } case "agent": - return notice(binding ? `Agent: ${binding.agentId}` : "This room is not bound to an OpenClaw agent yet."); + return notice(simpleReply("Agent", binding ? `Agent: ${binding.agentId}` : "This room is not bound to an OpenClaw agent yet.")); default: - return notice(`Unknown OpenClaw command: /${command.name}`); + return notice(simpleReply("Unknown Command", `Unknown OpenClaw command: /${command.name}`)); } } @@ -854,20 +911,30 @@ function newBeeperSessionKey(agentId: string): string { return `agent:${agentId}:beeper:${randomUUID()}`; } -function commandNotice(ctx: BridgeRequestContext, login: UserLogin, msg: MatrixMessage, text: string, portalKey = msg.portal.portalKey): MatrixMessageResponse { +async function commandNotice( + ctx: BridgeRequestContext, + config: OpenClawBridgeConfig, + login: UserLogin, + msg: MatrixMessage, + reply: CommandReply | string, + binding: OpenClawSessionBinding | undefined, +): Promise { + const formatted = normalizeCommandReply(reply); + const portalKey = canonicalPortalKeyForBinding(binding, login.id) ?? msg.portal.portalKey; ctx.queueRemoteEvent(login, createRemoteMessage({ convert: () => ({ - parts: [{ content: { body: text, msgtype: "m.notice" }, id: "body", type: "m.text" }], + parts: [{ content: commandContent(formatted), id: "body", type: "m.text" }], }), - data: { text }, + data: { text: formatted.text }, id: `${msg.event.eventId}:openclaw-command`, portalKey, sender: { isFromMe: true, - sender: "openclawbot", + sender: binding?.ghostUserId ?? serviceBotUserId(config), }, timestamp: new Date(), })); + await ctx.bridge?.flushRemoteEvents?.(); return { pending: false }; } @@ -898,37 +965,266 @@ function canonicalPortalKeyForBinding(binding: OpenClawSessionBinding | undefine return { id: portalIdForSession(binding.sessionKey), receiver }; } -function bridgeStatusText(config: OpenClawBridgeConfig, boundRooms: number): string { - return [ - "OpenClaw Beeper bridge", - "Runtime: OpenClaw plugin", - `Import sources: ${(config.importSources ?? []).join(", ") || "none"}`, - `Approvals: ${describeApprovalBehavior(config.approvalBehavior)}`, - `Stream finalization: ${config.streamFinalization ?? "replace"}`, - `Backfill limit: ${config.backfillLimit ?? "default"}`, - `Bound rooms: ${boundRooms}`, +function commandResponse(reply: CommandReply | string): MatrixCommandResponse { + const formatted = normalizeCommandReply(reply); + return { + content: commandContent(formatted), + handled: true, + text: formatted.text, + }; +} + +function commandContent(reply: CommandReply): Record { + return stripUndefined({ + body: reply.text, + format: reply.html ? MATRIX_HTML_FORMAT : undefined, + formatted_body: reply.html, + msgtype: "m.text", + }); +} + +function normalizeCommandReply(reply: CommandReply | string): CommandReply { + return typeof reply === "string" ? textReply(reply) : reply; +} + +function textReply(text: string): CommandReply { + return { + html: htmlLines(text.split("\n")), + text, + }; +} + +function simpleReply(title: string, text: string): CommandReply { + return { + html: htmlLines([`${escapeMatrixHtml(title)}`, "", ...text.split("\n").map(escapeMatrixHtml)], { escaped: true }), + text, + }; +} + +function sectionsReply(title: string, sections: CommandSection[]): CommandReply { + const text = [ + title, + ...sections.flatMap((section) => [ + "", + section.title, + ...section.entries.map(([label, value]) => `${label}: ${formatCommandValue(value)}`), + ]), ].join("\n"); + const html = htmlLines([ + `${escapeMatrixHtml(title)}`, + ...sections.flatMap((section) => [ + "", + `${escapeMatrixHtml(section.title)}`, + ...section.entries.map(([label, value]) => + `${escapeMatrixHtml(label)}: ${escapeMatrixHtml(formatCommandValue(value))}`), + ]), + ], { escaped: true }); + return { html, text }; } -function bridgeSettingsText(config: OpenClawBridgeConfig, boundRooms: number): string { - return [ - "OpenClaw Beeper settings", - `Beeper environment: ${config.beeperEnv ?? "production"}`, - `Homeserver: ${config.homeserver ?? "not configured"}`, - `Registration URL: ${config.registrationUrl ?? "not configured"}`, - "Runtime: OpenClaw plugin", - `Bridge manager token: ${config.bridgeManagerToken ? "configured" : "not configured"}`, - `Post bridge state: ${config.bridgeManagerPostState === undefined ? "default" : config.bridgeManagerPostState ? "enabled" : "disabled"}`, - `Import sources: ${(config.importSources ?? []).join(", ") || "none"}`, - `Backfill limit: ${config.backfillLimit ?? "default"}`, - `Contact visibility: ${config.contactVisibility ?? "agents"}`, - `Stream finalization: ${config.streamFinalization ?? "replace"}`, - `Approvals: ${describeApprovalBehavior(config.approvalBehavior)}`, - `Non-federated rooms: ${config.nonFederatedRooms ? "yes" : "no"}`, - `Allowed rooms: ${config.allowedRoomIds?.length ? config.allowedRoomIds.join(", ") : "all"}`, - `Allowed users: ${config.allowedUserIds?.length ? config.allowedUserIds.join(", ") : "all"}`, - `Bound rooms: ${boundRooms}`, +async function bridgeStatusReply( + config: OpenClawBridgeConfig, + boundRooms: number, + binding: OpenClawSessionBinding | undefined, + runtime: OpenClawGatewayRuntime | undefined, +): Promise { + const snapshot = runtime ? await safeFeatureSnapshot(runtime) : undefined; + const status = recordValue(snapshot?.status); + const health = recordValue(snapshot?.health); + const models = arrayFromResponse(snapshot?.models, "models"); + const commands = arrayFromResponse(snapshot?.commands, "commands"); + const tasks = arrayFromResponse(snapshot?.tasks, "tasks"); + const tools = arrayFromResponse(snapshot?.tools, "tools"); + const usage = recordValue(snapshot?.usage); + const sections: CommandSection[] = [ + { + title: "Bridge", + entries: [ + ["Runtime", "OpenClaw plugin"], + ["Gateway", statusTextFromRecord(status) ?? statusTextFromRecord(health) ?? "available"], + ["Beeper environment", config.beeperEnv ?? "production"], + ["Homeserver", config.homeserver ?? "not configured"], + ["Registration URL", config.registrationUrl ?? "not configured"], + ["Bound rooms", boundRooms], + ], + }, + { + title: "Room", + entries: [ + ["Session key", binding?.sessionKey ?? "not bound"], + ["Agent", binding?.agentId ?? "not bound"], + ["Ghost", binding?.ghostUserId ?? "service bot"], + ["Last run", binding?.lastRunId ?? "none"], + ["Last stream run", binding?.lastStreamRunId ?? "none"], + ], + }, + { + title: "Runtime", + entries: [ + ["Models", models ? models.length : "unknown"], + ["Commands", commands ? commands.length : "unknown"], + ["Tools", tools ? tools.length : "unknown"], + ["Tasks", tasks ? tasks.length : "unknown"], + ["Usage", usageSummary(usage) ?? "unknown"], + ], + }, + { + title: "Behavior", + entries: [ + ["Import sources", (config.importSources ?? []).join(", ") || "none"], + ["Approvals", describeApprovalBehavior(config.approvalBehavior)], + ["Stream finalization", config.streamFinalization ?? "replace"], + ["Backfill limit", config.backfillLimit ?? "default"], + ["Contact visibility", config.contactVisibility ?? "agents"], + ], + }, + ]; + return sectionsReply("OpenClaw Beeper status", sections); +} + +function bridgeSettingsReply(config: OpenClawBridgeConfig, boundRooms: number): CommandReply { + return sectionsReply("OpenClaw Beeper settings", [{ + title: "Bridge", + entries: [ + ["Beeper environment", config.beeperEnv ?? "production"], + ["Homeserver", config.homeserver ?? "not configured"], + ["Registration URL", config.registrationUrl ?? "not configured"], + ["Runtime", "OpenClaw plugin"], + ["Bridge manager token", config.bridgeManagerToken ? "configured" : "not configured"], + ["Post bridge state", config.bridgeManagerPostState === undefined ? "default" : config.bridgeManagerPostState ? "enabled" : "disabled"], + ["Import sources", (config.importSources ?? []).join(", ") || "none"], + ["Backfill limit", config.backfillLimit ?? "default"], + ["Contact visibility", config.contactVisibility ?? "agents"], + ["Stream finalization", config.streamFinalization ?? "replace"], + ["Approvals", describeApprovalBehavior(config.approvalBehavior)], + ["Non-federated rooms", config.nonFederatedRooms ? "yes" : "no"], + ["Allowed rooms", config.allowedRoomIds?.length ? config.allowedRoomIds.join(", ") : "all"], + ["Allowed users", config.allowedUserIds?.length ? config.allowedUserIds.join(", ") : "all"], + ["Bound rooms", boundRooms], + ], + }]); +} + +function commandsReply(): CommandReply { + const text = [ + "OpenClaw commands", + "", + ...SUPPORTED_COMMON_COMMANDS.map((command) => `${command.command} - ${command.description}`), ].join("\n"); + const html = [ + "OpenClaw Commands", + "", + ...SUPPORTED_COMMON_COMMANDS.map((command) => + `${escapeMatrixHtml(command.command)} - ${escapeMatrixHtml(command.description)}`), + ]; + return { html: htmlLines(html, { escaped: true }), text }; +} + +function htmlLines(lines: string[], options: { escaped?: boolean } = {}): string { + return lines + .map((line) => options.escaped ? line : escapeMatrixHtml(line)) + .join("
"); +} + +async function toolsReply(runtime: OpenClawGatewayRuntime, args: string): Promise { + const mode = args.trim().toLowerCase(); + if (mode && mode !== "compact" && mode !== "verbose") { + return simpleReply("Usage", "Usage: /tools [compact|verbose]"); + } + const result = await safeRuntimeCall(() => runtime.listTools()); + const tools = arrayFromResponse(result, "tools") ?? []; + if (tools.length === 0) return simpleReply("Available Tools", "No runtime tools are available right now."); + const verbose = mode === "verbose"; + const entries = tools.slice(0, 80).map((tool, index) => { + const record = recordValue(tool); + const name = stringValue(record?.name) ?? stringValue(record?.id) ?? `tool-${index + 1}`; + const description = stringValue(record?.description) ?? stringValue(record?.label) ?? "available"; + return [name, verbose ? description : "available"] as [string, string]; + }); + return sectionsReply("Available Tools", [{ title: verbose ? "Verbose" : "Compact", entries }]); +} + +async function modelsReply(runtime: OpenClawGatewayRuntime): Promise { + const result = await safeRuntimeCall(() => runtime.listModels({ view: "configured" })); + const models = arrayFromResponse(result, "models") ?? []; + if (models.length === 0) return simpleReply("Models", "No configured models were returned by OpenClaw."); + return sectionsReply("Models", [{ + title: "Configured", + entries: models.slice(0, 80).map((model, index) => { + const record = recordValue(model); + const id = stringValue(record?.id) ?? stringValue(record?.model) ?? stringValue(record?.name) ?? (typeof model === "string" ? model : `model-${index + 1}`); + const provider = stringValue(record?.provider) ?? stringValue(record?.owner) ?? "available"; + return [id, provider]; + }), + }]); +} + +async function tasksReply(runtime: OpenClawGatewayRuntime, binding: OpenClawSessionBinding | undefined): Promise { + const result = await safeRuntimeCall(() => runtime.listTasks({ limit: 25, ...(binding?.sessionKey ? { ownerKey: binding.sessionKey } : {}) })); + const tasks = arrayFromResponse(result, "tasks") ?? []; + if (tasks.length === 0) return simpleReply("Tasks", "No recent OpenClaw tasks were returned."); + return sectionsReply("Tasks", [{ + title: "Recent", + entries: tasks.slice(0, 25).map((task, index) => { + const record = recordValue(task); + const id = stringValue(record?.id) ?? stringValue(record?.taskId) ?? `task-${index + 1}`; + const status = stringValue(record?.status) ?? stringValue(record?.state) ?? "unknown"; + return [id, status]; + }), + }]); +} + +async function safeFeatureSnapshot(runtime: OpenClawGatewayRuntime): Promise { + try { + return await runtime.featureSnapshot(); + } catch { + return undefined; + } +} + +async function safeRuntimeCall(call: () => Promise): Promise { + try { + return await call(); + } catch { + return undefined; + } +} + +function arrayFromResponse(response: unknown, key: string): unknown[] | undefined { + return arrayValue(recordValue(response)?.[key]) ?? arrayValue(response); +} + +function statusTextFromRecord(record: Record | undefined): string | undefined { + if (!record) return undefined; + return stringValue(record.status) + ?? stringValue(record.state) + ?? (record.ok === true ? "ok" : record.ok === false ? "not ok" : undefined); +} + +function usageSummary(usage: Record | undefined): string | undefined { + if (!usage) return undefined; + const summary = stringValue(usage.summary) ?? stringValue(usage.status); + if (summary) return summary; + const tokens = numberValue(usage.tokens) ?? numberValue(usage.totalTokens); + const cost = numberValue(usage.cost) ?? numberValue(usage.totalCost); + if (tokens !== undefined && cost !== undefined) return `${tokens} tokens, ${cost} cost`; + if (tokens !== undefined) return `${tokens} tokens`; + if (cost !== undefined) return `${cost} cost`; + return undefined; +} + +function formatCommandValue(value: string | number | boolean | undefined): string { + if (value === undefined || value === "") return "unknown"; + if (typeof value === "boolean") return value ? "yes" : "no"; + return String(value); +} + +function escapeMatrixHtml(value: string): string { + return value + .replace(/&/gu, "&") + .replace(//gu, ">") + .replace(/"/gu, """); } function describeApprovalBehavior(behavior: OpenClawBridgeConfig["approvalBehavior"]): string { @@ -956,19 +1252,32 @@ function openClawPortalCreationContent(config: OpenClawBridgeConfig): Record>): string { - if (sessions.length === 0) return "No importable OpenClaw sessions found for the enabled import sources."; - return sessions.slice(0, 20).map((session) => `${session.label} (${session.source})`).join("\n"); +function sessionsSummaryReply(sessions: Awaited>): CommandReply { + if (sessions.length === 0) return simpleReply("Sessions", "No importable OpenClaw sessions found for the enabled import sources."); + return sectionsReply("Sessions", [{ + title: "Importable", + entries: sessions.slice(0, 20).map((session) => [session.label, session.source]), + }]); } -function importSummaryText(result: Awaited>): string { +function importSummaryReply(result: Awaited>): CommandReply { const imported = result.sessions.length; const skipped = result.skipped.length; - if (imported === 0 && skipped === 0) return "No importable OpenClaw sessions found for the enabled import sources."; - return [ - `Imported ${imported} OpenClaw session${imported === 1 ? "" : "s"}.`, - `Skipped ${skipped} already imported or unavailable session${skipped === 1 ? "" : "s"}.`, - ].join("\n"); + if (imported === 0 && skipped === 0) return simpleReply("Import", "No importable OpenClaw sessions found for the enabled import sources."); + const reply = sectionsReply("Import", [{ + title: "Summary", + entries: [ + ["Imported", `${imported} OpenClaw session${imported === 1 ? "" : "s"}`], + ["Skipped", `${skipped} already imported or unavailable session${skipped === 1 ? "" : "s"}`], + ], + }]); + return { + ...reply, + text: [ + `Imported ${imported} OpenClaw session${imported === 1 ? "" : "s"}.`, + `Skipped ${skipped} already imported or unavailable session${skipped === 1 ? "" : "s"}.`, + ].join("\n"), + }; } function streamTargetRelationPatch( @@ -1165,6 +1474,14 @@ function recordValue(value: unknown): Record | undefined { return value as Record; } +function arrayValue(value: unknown): unknown[] | undefined { + return Array.isArray(value) ? value : undefined; +} + +function numberValue(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + function stringValue(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } From 41644c2178ce148dbef68dca2883c263ed848265 Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Wed, 27 May 2026 03:32:33 +0200 Subject: [PATCH 38/43] Rewrite OpenClaw as a first-class Beeper network connector --- packages/openclaw/openclaw.plugin.json | 21 +- packages/openclaw/package.json | 5 +- packages/openclaw/src/appservice.test.ts | 8 +- packages/openclaw/src/appservice.ts | 18 +- packages/openclaw/src/backfill.test.ts | 10 +- packages/openclaw/src/backfill.ts | 8 +- .../src/beeper-channel-runtime.test.ts | 23 +- .../openclaw/src/beeper-channel-runtime.ts | 24 +- packages/openclaw/src/beeper-stream.test.ts | 149 +- packages/openclaw/src/beeper-stream.ts | 149 +- .../{stream-map.ts => beeper-turn-events.ts} | 19 - packages/openclaw/src/bridge-agent.test.ts | 76 +- packages/openclaw/src/bridge-agent.ts | 11 +- packages/openclaw/src/config.test.ts | 2 - packages/openclaw/src/config.ts | 7 - packages/openclaw/src/connector.test.ts | 495 +-- packages/openclaw/src/connector.ts | 656 +--- packages/openclaw/src/index.ts | 1 - packages/openclaw/src/integration.test.ts | 61 +- .../openclaw/src/openclaw-extension.test.ts | 34 +- packages/openclaw/src/openclaw-extension.ts | 62 +- .../openclaw/src/openclaw-runtime.test.ts | 231 +- packages/openclaw/src/openclaw-runtime.ts | 160 +- .../openclaw/src/protocol-coverage.test.ts | 14 +- packages/openclaw/src/protocol-coverage.ts | 4 +- packages/openclaw/src/registration.test.ts | 2 +- packages/openclaw/src/setup-entry.ts | 8 +- packages/openclaw/src/setup.test.ts | 40 +- packages/openclaw/src/setup.ts | 81 +- packages/openclaw/src/types.ts | 1 - packages/openclaw/tsdown.config.ts | 2 +- .../native/internal/core/beeper_ai_run.go | 178 + .../internal/core/beeper_ai_run_test.go | 192 + packages/pickle/native/internal/core/core.go | 12 + .../pickle/native/internal/core/messages.go | 66 +- .../pickle/native/internal/core/operations.go | 10 + packages/pickle/src/client-types.ts | 13 + packages/pickle/src/client.test.ts | 42 + packages/pickle/src/client.ts | 7 + .../src/generated-runtime-operations.ts | 31 + .../pickle/src/generated-runtime-types.ts | 34 + packages/pickle/src/index.ts | 6 + packages/pickle/src/runtime-types.ts | 6 + pnpm-lock.yaml | 3109 ++++++++++++++++- 44 files changed, 4533 insertions(+), 1555 deletions(-) rename packages/openclaw/src/{stream-map.ts => beeper-turn-events.ts} (94%) create mode 100644 packages/pickle/native/internal/core/beeper_ai_run.go create mode 100644 packages/pickle/native/internal/core/beeper_ai_run_test.go diff --git a/packages/openclaw/openclaw.plugin.json b/packages/openclaw/openclaw.plugin.json index 72f52c1..9f6d1e4 100644 --- a/packages/openclaw/openclaw.plugin.json +++ b/packages/openclaw/openclaw.plugin.json @@ -38,8 +38,7 @@ "PICKLE_OPENCLAW_SENDER_LOCALPART", "PICKLE_OPENCLAW_SERVICE_BOT_LOCALPART", "PICKLE_OPENCLAW_STORE_PATH", - "PICKLE_OPENCLAW_USER_LOCALPART_PREFIX", - "PICKLE_OPENCLAW_STREAM_FINALIZATION" + "PICKLE_OPENCLAW_USER_LOCALPART_PREFIX" ] }, "uiHints": { @@ -202,15 +201,6 @@ ], "description": "Which OpenClaw identities should appear in Beeper contacts." }, - "streamFinalization": { - "type": "string", - "enum": [ - "replace", - "append", - "native-only" - ], - "description": "How native Beeper stream output is finalized." - }, "approvalBehavior": { "type": "string", "enum": [ @@ -363,15 +353,6 @@ ], "description": "Which OpenClaw identities should appear in Beeper contacts." }, - "streamFinalization": { - "type": "string", - "enum": [ - "replace", - "append", - "native-only" - ], - "description": "How native Beeper stream output is finalized." - }, "approvalBehavior": { "type": "string", "enum": [ diff --git a/packages/openclaw/package.json b/packages/openclaw/package.json index c64d74c..c80e537 100644 --- a/packages/openclaw/package.json +++ b/packages/openclaw/package.json @@ -103,10 +103,6 @@ "types": "./dist/setup-entry.d.mts", "import": "./dist/setup-entry.mjs" }, - "./stream-map": { - "types": "./dist/stream-map.d.mts", - "import": "./dist/stream-map.mjs" - }, "./types": { "types": "./dist/types.d.mts", "import": "./dist/types.mjs" @@ -181,6 +177,7 @@ "@beeper/pickle-state-file": "workspace:^", "@types/node": "^20.0.0", "@vitest/coverage-v8": "^4.0.18", + "openclaw": "2026.5.22", "tsdown": "^0.21.10", "typescript": "^5.7.2", "vitest": "^4.0.18" diff --git a/packages/openclaw/src/appservice.test.ts b/packages/openclaw/src/appservice.test.ts index d42b4bb..688eee3 100644 --- a/packages/openclaw/src/appservice.test.ts +++ b/packages/openclaw/src/appservice.test.ts @@ -2,7 +2,7 @@ import type { CreateNodeBeeperBridgeOptions, PickleBridge } from "@beeper/pickle import { describe, expect, it, vi } from "vitest"; import { createDefaultConfig } from "./config"; import { accountFromOpenClawConfig, createOpenClawBeeperBridge, startOpenClawBeeperBridge } from "./appservice"; -import { OpenClawGatewayRuntime, type OpenClawTransport } from "./openclaw-runtime"; +import { OpenClawPluginRuntimeAdapter, type OpenClawRuntimeRequestSurface } from "./openclaw-runtime"; import { OpenClawBridgeRegistry } from "./registry"; describe("OpenClaw Beeper appservice runtime", () => { @@ -289,13 +289,13 @@ function fakeBridge(options: { registry?: OpenClawBridgeRegistry } = {}): Pickle function runtimeWith(options: { responses: Record; -}): OpenClawGatewayRuntime & { transport: OpenClawTransport & { request: ReturnType } } { +}): OpenClawPluginRuntimeAdapter & { transport: OpenClawRuntimeRequestSurface & { request: ReturnType } } { const transport = { async *events() {}, request: vi.fn(async (method: string) => options.responses[method]), }; - return new OpenClawGatewayRuntime({ + return new OpenClawPluginRuntimeAdapter({ config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), transport, - }) as OpenClawGatewayRuntime & { transport: OpenClawTransport & { request: ReturnType } }; + }) as OpenClawPluginRuntimeAdapter & { transport: OpenClawRuntimeRequestSurface & { request: ReturnType } }; } diff --git a/packages/openclaw/src/appservice.ts b/packages/openclaw/src/appservice.ts index ec19468..984a37c 100644 --- a/packages/openclaw/src/appservice.ts +++ b/packages/openclaw/src/appservice.ts @@ -11,7 +11,7 @@ import { backfillAllOpenClawSessions } from "./backfill"; import { beeperBaseDomain } from "./beeper-setup"; import { DEFAULT_BEEPER_BRIDGE_TYPE } from "./ids"; import { createOpenClawConnector, userLoginFromOpenClawConfig, type OpenClawConnectorOptions } from "./connector"; -import { createOpenClawHostTransport, OpenClawGatewayRuntime } from "./openclaw-runtime"; +import { createOpenClawHostRuntimeAdapter, OpenClawPluginRuntimeAdapter, type OpenClawSessionHistoryRuntime } from "./openclaw-runtime"; import { createAppserviceRegistration } from "./registration"; import { OpenClawBridgeRegistry } from "./registry"; import type { OpenClawBridgeConfig } from "./types"; @@ -82,7 +82,7 @@ async function runStartupBackfill(options: CreateOpenClawBeeperBridgeOptions, br options.log?.("warn", "openclaw_backfill_skipped", { reason: "missing_registry" }); return; } - const runtime = tryResolveOpenClawRuntime(options, config); + const runtime = tryResolveOpenClawHistoryRuntime(options, config); if (!runtime) { options.log?.("warn", "openclaw_backfill_skipped", { reason: "missing_runtime" }); return; @@ -162,26 +162,26 @@ function connectorOptions(options: CreateOpenClawBeeperBridgeOptions): OpenClawC return output; } -function resolveOpenClawRuntime(options: CreateOpenClawBeeperBridgeOptions, config: OpenClawBridgeConfig): OpenClawGatewayRuntime { - if (options.runtime instanceof OpenClawGatewayRuntime) return options.runtime; +function resolveOpenClawHistoryRuntime(options: CreateOpenClawBeeperBridgeOptions, config: OpenClawBridgeConfig): OpenClawSessionHistoryRuntime { + if (options.runtime instanceof OpenClawPluginRuntimeAdapter) return options.runtime; if (options.runtime !== undefined) { - return new OpenClawGatewayRuntime({ config, transport: createOpenClawHostTransport(options.runtime) }); + return new OpenClawPluginRuntimeAdapter({ config, transport: createOpenClawHostRuntimeAdapter(options.runtime) }); } if (options.runtimeFactory) return options.runtimeFactory(config); const connector = options.connector; if (connector && typeof connector === "object" && "runtime" in connector) { const runtime = (connector as { runtime?: unknown }).runtime; - if (runtime instanceof OpenClawGatewayRuntime) return runtime; + if (runtime instanceof OpenClawPluginRuntimeAdapter) return runtime; } throw new Error("OpenClaw direct plugin runtime is required"); } -function tryResolveOpenClawRuntime( +function tryResolveOpenClawHistoryRuntime( options: CreateOpenClawBeeperBridgeOptions, config: OpenClawBridgeConfig -): OpenClawGatewayRuntime | undefined { +): OpenClawSessionHistoryRuntime | undefined { try { - return resolveOpenClawRuntime(options, config); + return resolveOpenClawHistoryRuntime(options, config); } catch { return undefined; } diff --git a/packages/openclaw/src/backfill.test.ts b/packages/openclaw/src/backfill.test.ts index f24dcb0..1dafa7e 100644 --- a/packages/openclaw/src/backfill.test.ts +++ b/packages/openclaw/src/backfill.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { backfillAllOpenClawSessions, buildBackfillImport, discoverOneToOneSessions, isOneToOneSession, shouldImportSession } from "./backfill"; import { createDefaultConfig } from "./config"; -import { OpenClawGatewayRuntime, type OpenClawTransport } from "./openclaw-runtime"; +import { OpenClawPluginRuntimeAdapter, type OpenClawRuntimeRequestSurface } from "./openclaw-runtime"; import { OpenClawBridgeRegistry } from "./registry"; describe("OpenClaw backfill", () => { @@ -518,15 +518,15 @@ describe("OpenClaw backfill", () => { }); }); -function runtimeWith(responses: Record): OpenClawGatewayRuntime & { - transport: OpenClawTransport & { request: ReturnType }; +function runtimeWith(responses: Record): OpenClawPluginRuntimeAdapter & { + transport: OpenClawRuntimeRequestSurface & { request: ReturnType }; } { const transport = { async *events() {}, request: vi.fn(async (method: string) => responses[method]), }; - return new OpenClawGatewayRuntime({ + return new OpenClawPluginRuntimeAdapter({ config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), transport, - }) as OpenClawGatewayRuntime & { transport: OpenClawTransport & { request: ReturnType } }; + }) as OpenClawPluginRuntimeAdapter & { transport: OpenClawRuntimeRequestSurface & { request: ReturnType } }; } diff --git a/packages/openclaw/src/backfill.ts b/packages/openclaw/src/backfill.ts index 67d440d..97fa081 100644 --- a/packages/openclaw/src/backfill.ts +++ b/packages/openclaw/src/backfill.ts @@ -1,7 +1,7 @@ import type { BridgeCreatePortalOptions, PickleBridge, Portal, UserLogin } from "@beeper/pickle-bridge"; import type { OpenClawChatHistoryMessage, - OpenClawGatewayRuntime, + OpenClawSessionHistoryRuntime, OpenClawListedSession, } from "./openclaw-runtime"; import { agentContactFromOpenClawAgent, agentGhostUserId, bindingIdForRoom, userContactFromOpenClawSession } from "./rooms"; @@ -39,7 +39,7 @@ export interface BackfillAllOpenClawSessionsOptions { limit?: number; login: UserLogin; registry: OpenClawBridgeRegistry; - runtime: OpenClawGatewayRuntime; + runtime: OpenClawSessionHistoryRuntime; } export interface BackfillAllOpenClawSessionsResult { @@ -49,7 +49,7 @@ export interface BackfillAllOpenClawSessionsResult { } export async function discoverOneToOneSessions( - runtime: OpenClawGatewayRuntime, + runtime: OpenClawSessionHistoryRuntime, options: { importSources?: OpenClawImportSource[] } = {}, ): Promise { const sessions = await runtime.listSessions({ includeArchived: true }); @@ -71,7 +71,7 @@ export async function discoverOneToOneSessions( } export async function buildBackfillImport( - runtime: OpenClawGatewayRuntime, + runtime: Pick, config: OpenClawBridgeConfig, session: OpenClawBackfillSession, options: { limit?: number; roomId: string } diff --git a/packages/openclaw/src/beeper-channel-runtime.test.ts b/packages/openclaw/src/beeper-channel-runtime.test.ts index db9719d..7568cf4 100644 --- a/packages/openclaw/src/beeper-channel-runtime.test.ts +++ b/packages/openclaw/src/beeper-channel-runtime.test.ts @@ -1,5 +1,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { BeeperChannelRuntime, getBeeperChannelRuntime, setBeeperChannelRuntime } from "./beeper-channel-runtime"; +import { + BeeperChannelRuntime, + getBeeperChannelRuntime, + getBeeperChannelRuntimeForHost, + setBeeperChannelRuntime, + setBeeperChannelRuntimeForHost, +} from "./beeper-channel-runtime"; function createClient() { return { @@ -180,4 +186,19 @@ describe("BeeperChannelRuntime", () => { setBeeperChannelRuntime(runtime); expect(getBeeperChannelRuntime()).toBe(runtime); }); + + it("stores Beeper runtimes by OpenClaw host runtime", () => { + const hostRuntime = {}; + const globalRuntime = new BeeperChannelRuntime({ client: createClient() as never }); + const scopedRuntime = new BeeperChannelRuntime({ client: createClient() as never }); + + setBeeperChannelRuntime(globalRuntime); + setBeeperChannelRuntimeForHost(hostRuntime, scopedRuntime); + + expect(getBeeperChannelRuntime()).toBe(globalRuntime); + expect(getBeeperChannelRuntimeForHost(hostRuntime)).toBe(scopedRuntime); + + setBeeperChannelRuntimeForHost(hostRuntime, undefined); + expect(getBeeperChannelRuntimeForHost(hostRuntime)).toBeUndefined(); + }); }); diff --git a/packages/openclaw/src/beeper-channel-runtime.ts b/packages/openclaw/src/beeper-channel-runtime.ts index af53290..323cd0d 100644 --- a/packages/openclaw/src/beeper-channel-runtime.ts +++ b/packages/openclaw/src/beeper-channel-runtime.ts @@ -15,10 +15,12 @@ import { type RemoteTyping, type UserLogin, } from "@beeper/pickle-bridge"; -import { BeeperStreamPublisher } from "./beeper-stream"; -import { AGUIEventType } from "./stream-map"; +import { BeeperTurnStreamCoordinator } from "./beeper-stream"; +import { AGUIEventType } from "./beeper-turn-events"; import type { OpenClawAgentContact, OpenClawSessionBinding } from "./types"; +export const BEEPER_CHANNEL_RUNTIME_CONTEXT_CAPABILITY = "beeper.runtime"; + export interface BeeperChannelRuntimeOptions { bridge?: PickleBridge; client: MatrixClient; @@ -48,7 +50,7 @@ export class BeeperChannelRuntime { #getBindingBySessionKey: (sessionKey: string) => OpenClawSessionBinding | undefined; #login: UserLogin | undefined; #log: BeeperChannelRuntimeOptions["log"]; - #activeStreams = new Map(); + #activeStreams = new Map(); constructor(options: BeeperChannelRuntimeOptions) { this.#bridge = options.bridge; @@ -140,8 +142,8 @@ export class BeeperChannelRuntime { runId: string; sessionKey: string; threadRoot?: string; - }): BeeperStreamPublisher { - const publisher = new BeeperStreamPublisher({ + }): BeeperTurnStreamCoordinator { + const publisher = new BeeperTurnStreamCoordinator({ client: this.client, initialMessageMetadata: { agent_id: options.agentId, @@ -157,7 +159,7 @@ export class BeeperChannelRuntime { return publisher; } - clearActiveStream(sessionKey: string, publisher: BeeperStreamPublisher): void { + clearActiveStream(sessionKey: string, publisher: BeeperTurnStreamCoordinator): void { if (this.#activeStreams.get(sessionKey) === publisher) this.#activeStreams.delete(sessionKey); } @@ -347,6 +349,7 @@ export class BeeperChannelRuntime { } let currentRuntime: BeeperChannelRuntime | undefined; +const runtimeByHost = new WeakMap(); export function setBeeperChannelRuntime(runtime: BeeperChannelRuntime | undefined): void { currentRuntime = runtime; @@ -356,6 +359,15 @@ export function getBeeperChannelRuntime(): BeeperChannelRuntime | undefined { return currentRuntime; } +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 requireBeeperChannelRuntime(): BeeperChannelRuntime { if (!currentRuntime) { throw new Error("Beeper channel runtime is not available; start the Beeper bridge account first."); diff --git a/packages/openclaw/src/beeper-stream.test.ts b/packages/openclaw/src/beeper-stream.test.ts index 64f8857..056f95d 100644 --- a/packages/openclaw/src/beeper-stream.test.ts +++ b/packages/openclaw/src/beeper-stream.test.ts @@ -1,11 +1,11 @@ import type { MatrixClient } from "@beeper/pickle"; import { describe, expect, it, vi } from "vitest"; -import { BeeperStreamPublisher } from "./beeper-stream"; +import { BeeperTurnStreamCoordinator } from "./beeper-stream"; describe("OpenClaw Beeper native stream publisher", () => { it("starts one native Beeper stream, publishes AG-UI events, and finalizes replacement content", async () => { const { client, finalizeMessage, publishPart, startMessage } = createClient(); - const publisher = new BeeperStreamPublisher({ + const publisher = new BeeperTurnStreamCoordinator({ client, initialMessageMetadata: { agent_id: "codex" }, roomId: "!room:example.com", @@ -45,6 +45,8 @@ describe("OpenClaw Beeper native stream publisher", () => { userId: "@openclaw_agent_codex:example.com", }); expect(publishPart.mock.calls.map(([options]) => options.part.type)).toEqual([ + "RUN_STARTED", + "TEXT_MESSAGE_START", "TEXT_MESSAGE_START", "TEXT_MESSAGE_CONTENT", "RUN_FINISHED", @@ -75,57 +77,9 @@ describe("OpenClaw Beeper native stream publisher", () => { })); }); - it("honors native-only stream finalization without sending a replacement edit", async () => { - const { client, finalizeMessage, publishPart, startMessage } = createClient(); - const publisher = new BeeperStreamPublisher({ - client, - roomId: "!room:example.com", - turnId: "turn_3", - userId: "@bot:example.com", - }); - - await publisher.publish({ delta: "native", messageId: "turn_3", type: "TEXT_MESSAGE_CONTENT" }); - await publisher.finalize({ - finalization: "native-only", - terminalPart: { finishReason: "stop", runId: "turn_3", threadId: "turn_3", type: "RUN_FINISHED" }, - }); - - expect(startMessage).toHaveBeenCalledTimes(1); - expect(publishPart.mock.calls.map(([options]) => options.part.type)).toEqual([ - "TEXT_MESSAGE_CONTENT", - "RUN_FINISHED", - ]); - expect(finalizeMessage).not.toHaveBeenCalled(); - }); - - it("honors append stream finalization without suppressing the streamed event", async () => { + it("always finalizes with a replacement edit that suppresses the streamed event", async () => { const { client, finalizeMessage } = createClient(); - const publisher = new BeeperStreamPublisher({ - client, - roomId: "!room:example.com", - turnId: "turn_append", - userId: "@bot:example.com", - }); - - await publisher.publish({ delta: "append me", messageId: "turn_append", type: "TEXT_MESSAGE_CONTENT" }); - const result = await publisher.finalize({ - finalization: "append", - terminalPart: { finishReason: "stop", runId: "turn_append", threadId: "turn_append", type: "RUN_FINISHED" }, - }); - - expect(result).toEqual(expect.objectContaining({ eventId: "$target" })); - expect(finalizeMessage).toHaveBeenCalledWith(expect.objectContaining({ - body: "append me", - eventId: "$target", - roomId: "!room:example.com", - topLevelContent: {}, - userId: "@bot:example.com", - })); - }); - - it("suppresses the streamed event when finalizing replacement content by default", async () => { - const { client, finalizeMessage } = createClient(); - const publisher = new BeeperStreamPublisher({ + const publisher = new BeeperTurnStreamCoordinator({ client, roomId: "!room:example.com", turnId: "turn_replace", @@ -133,19 +87,23 @@ describe("OpenClaw Beeper native stream publisher", () => { }); await publisher.publish({ delta: "replace me", messageId: "turn_replace", type: "TEXT_MESSAGE_CONTENT" }); - await publisher.finalize({ + const result = await publisher.finalize({ terminalPart: { finishReason: "stop", runId: "turn_replace", threadId: "turn_replace", type: "RUN_FINISHED" }, }); + expect(result).toEqual(expect.objectContaining({ eventId: "$target" })); expect(finalizeMessage).toHaveBeenCalledWith(expect.objectContaining({ body: "replace me", + eventId: "$target", + roomId: "!room:example.com", topLevelContent: { "com.beeper.dont_render_edited": true }, + userId: "@bot:example.com", })); }); it("finalizes run errors with a readable fallback body", async () => { const { client, finalizeMessage } = createClient(); - const publisher = new BeeperStreamPublisher({ + const publisher = new BeeperTurnStreamCoordinator({ client, roomId: "!room:example.com", turnId: "turn_error", @@ -170,7 +128,7 @@ describe("OpenClaw Beeper native stream publisher", () => { it("preserves cancelled runs as abort terminal metadata", async () => { const { client, finalizeMessage } = createClient(); - const publisher = new BeeperStreamPublisher({ + const publisher = new BeeperTurnStreamCoordinator({ client, roomId: "!room:example.com", turnId: "turn_abort", @@ -196,7 +154,7 @@ describe("OpenClaw Beeper native stream publisher", () => { it("accumulates reasoning, tool calls, and approval parts into final Beeper AI content", async () => { const { client, finalizeMessage } = createClient(); - const publisher = new BeeperStreamPublisher({ + const publisher = new BeeperTurnStreamCoordinator({ client, roomId: "!room:example.com", turnId: "turn_rich", @@ -252,6 +210,71 @@ describe("OpenClaw Beeper native stream publisher", () => { }); function createClient() { + const runEvents = new Map[]>(); + const snapshot = (runId: string, events: Record[] = [], body = "...") => ({ + body, + events, + finalAIMessage: {}, + initialAIMessage: { + id: runId, + metadata: { turn_id: runId }, + parts: [], + role: "assistant", + }, + metadata: { + messageId: runId, + model: "openclaw/plugin", + protocol: "ag-ui", + runId, + schema: "com.beeper.ai.run.v1", + status: { state: "streaming" }, + threadId: runId, + }, + messageId: runId, + runId, + threadId: runId, + }); + const begin = vi.fn(async (options: { runId?: string }) => { + const runId = options.runId ?? "run"; + const events = [ + { runId, threadId: runId, type: "RUN_STARTED" }, + { messageId: runId, role: "assistant", type: "TEXT_MESSAGE_START" }, + ]; + runEvents.set(runId, events); + return snapshot(runId, events); + }); + const appendEvent = vi.fn(async (options: { event: Record; runId: string }) => { + const events = runEvents.get(options.runId) ?? []; + events.push(options.event); + runEvents.set(options.runId, events); + return snapshot(options.runId, [options.event], textFromEvents(events)); + }); + const finish = vi.fn(async (options: { finishReason?: string; runId: string }) => { + const terminal = { + finishReason: options.finishReason ?? "stop", + runId: options.runId, + threadId: options.runId, + type: "RUN_FINISHED", + }; + const events = runEvents.get(options.runId) ?? []; + events.push(terminal); + runEvents.set(options.runId, events); + return snapshot(options.runId, [terminal], textFromEvents(events)); + }); + const error = vi.fn(async (options: { message?: string; runId: string; type?: "error" | "abort" }) => { + const terminal = { + message: options.message ?? "Run failed", + reason: options.message, + runId: options.runId, + terminalType: options.type === "abort" ? "abort" : undefined, + type: "RUN_ERROR", + }; + const events = runEvents.get(options.runId) ?? []; + events.push(terminal); + runEvents.set(options.runId, events); + return snapshot(options.runId, [terminal], options.message ?? "Run failed"); + }); + const deleteRun = vi.fn(async () => undefined); const startMessage = vi.fn(async () => ({ descriptor: { device_id: "DEVICE", type: "com.beeper.llm", user_id: "@bot:example.com" }, eventId: "$target", @@ -266,6 +289,13 @@ function createClient() { })); const client = { beeper: { + aiRuns: { + appendEvent, + begin, + delete: deleteRun, + error, + finish, + }, streams: { finalizeMessage, publishPart, @@ -275,3 +305,10 @@ function createClient() { } as unknown as MatrixClient; return { client, finalizeMessage, publishPart, startMessage }; } + +function textFromEvents(events: Record[]): string { + return events + .filter((event) => event.type === "TEXT_MESSAGE_CONTENT") + .map((event) => (typeof event.delta === "string" ? event.delta : "")) + .join("") || "..."; +} diff --git a/packages/openclaw/src/beeper-stream.ts b/packages/openclaw/src/beeper-stream.ts index 0f5e3c0..7c634b6 100644 --- a/packages/openclaw/src/beeper-stream.ts +++ b/packages/openclaw/src/beeper-stream.ts @@ -1,4 +1,4 @@ -import type { MatrixBeeper, SentEvent } from "@beeper/pickle"; +import type { MatrixBeeper, MatrixBeeperAIRunSnapshot, SentEvent } from "@beeper/pickle"; import { applyFinalMessagePart, compactFinalContent, @@ -8,8 +8,7 @@ import { type BeeperFinalMessageAccumulator, } from "@beeper/pickle/streams/beeper-message"; import { SerialQueue } from "./serial"; -import { AGUIEventType, createTurnId, type AGUIEvent } from "./stream-map"; -import type { OpenClawBridgeConfig } from "./types"; +import { AGUIEventType, createTurnId, type AGUIEvent } from "./beeper-turn-events"; type FinishReason = "stop" | "length" | "content_filter" | "tool_calls" | null; @@ -19,7 +18,7 @@ const BEEPER_STREAM_DESCRIPTOR_KEY = "com.beeper.stream"; const BEEPER_AI_STREAM_TYPE = "com.beeper.llm"; const BEEPER_AI_STREAM_DELTAS_TYPE = "com.beeper.llm.deltas"; -export interface BeeperStreamPublisherClient { +export interface BeeperTurnStreamCoordinatorClient { beeper: MatrixBeeper; } @@ -28,9 +27,9 @@ export interface BeeperStreamSubscriber { userId: string; } -export interface CreateBeeperStreamPublisherOptions { +export interface CreateBeeperTurnStreamCoordinatorOptions { agentId?: string; - client: BeeperStreamPublisherClient; + client: BeeperTurnStreamCoordinatorClient; initialMessageMetadata?: Record; roomId: string; subscribers?: BeeperStreamSubscriber[]; @@ -48,18 +47,17 @@ export interface BeeperStreamStartResult { export interface BeeperStreamFinalizeOptions { body?: string; finalText?: string; - finalization?: OpenClawBridgeConfig["streamFinalization"]; finishReason?: string; message?: Record; terminalPart?: AGUIEvent; } -export class BeeperStreamPublisher { +export class BeeperTurnStreamCoordinator { readonly roomId: string; readonly turnId: string; #accumulator: BeeperFinalMessageAccumulator; #agentId: string | undefined; - #client: BeeperStreamPublisherClient; + #client: BeeperTurnStreamCoordinatorClient; #descriptor: Record | undefined; #finalized = false; #initialMessageMetadata: Record; @@ -69,7 +67,7 @@ export class BeeperStreamPublisher { #threadRoot: string | undefined; #userId: string | undefined; - constructor(options: CreateBeeperStreamPublisherOptions) { + constructor(options: CreateBeeperTurnStreamCoordinatorOptions) { this.#agentId = options.agentId; this.#client = options.client; this.#initialMessageMetadata = options.initialMessageMetadata ?? {}; @@ -112,37 +110,35 @@ export class BeeperStreamPublisher { if (this.#finalized) throw new Error("Beeper stream is already finalized"); const finishReason = normalizeFinishReason(options.finishReason); const { eventId } = await this.#start(); - await this.#publishPart(eventId, options.terminalPart ?? { + const terminalPart = options.terminalPart ?? { finishReason, runId: this.turnId, threadId: this.turnId, type: AGUIEventType.RUN_FINISHED, - }); - const finalMessage = options.message ?? finalizeAccumulatedAIMessage(this.#accumulator); + }; + const snapshot = terminalPart.type === AGUIEventType.RUN_ERROR + ? await this.#errorRun({ + message: terminalFallbackText(terminalPart), + runId: this.turnId, + type: stringValue((terminalPart as Record).terminalType) === "abort" ? "abort" : "error", + }) + : await this.#finishRun({ + finishReason, + runId: this.turnId, + }); + await this.#publishSnapshotEvents(eventId, snapshot); + const finalMessage = options.message ?? nonEmptyRecordValue(snapshot.finalAIMessage) ?? finalizeAccumulatedAIMessage(this.#accumulator); const accumulatedText = getFinalMessageText(finalMessage); - const finalText = options.body ?? options.finalText ?? (accumulatedText || terminalFallbackText(options.terminalPart)); + const finalText = options.body ?? options.finalText ?? (accumulatedText || snapshot.body || terminalFallbackText(terminalPart)); const finalContent = compactFinalContent({ aiMessage: finalMessage, body: finalText, }); - const finalMetadata = this.#runMetadata(options.terminalPart?.type === AGUIEventType.RUN_ERROR ? "error" : "complete", options.terminalPart); - const finalization = options.finalization ?? "replace"; - if (finalization === "native-only") { - this.#finalized = true; - return { - eventId, - roomId: this.roomId, - raw: { - logicalEventId: eventId, - nativeOnly: true, - }, - }; - } - const topLevelContent = finalization === "append" - ? {} - : { - "com.beeper.dont_render_edited": true, - }; + const finalMetadata = { + ...this.#runMetadata(terminalPart.type === AGUIEventType.RUN_ERROR ? "error" : "complete", terminalPart), + ...(recordValue(snapshot.metadata) ?? {}), + status: this.#runMetadata(terminalPart.type === AGUIEventType.RUN_ERROR ? "error" : "complete", terminalPart).status, + }; const replacement = await this.#client.beeper.streams.finalizeMessage({ body: finalContent.body || "...", content: { @@ -154,7 +150,9 @@ export class BeeperStreamPublisher { }, eventId, roomId: this.roomId, - topLevelContent, + topLevelContent: { + "com.beeper.dont_render_edited": true, + }, ...(this.#userId ? { userId: this.#userId } : {}), }); this.#finalized = true; @@ -174,16 +172,33 @@ export class BeeperStreamPublisher { if (this.#targetEventId && this.#descriptor) { return { descriptor: this.#descriptor, eventId: this.#targetEventId, turnId: this.turnId }; } - const metadata = this.#runMetadata("streaming"); + const snapshot = await this.#beginRun({ + ...(this.#agentId ? { agentId: this.#agentId } : {}), + model: "openclaw/plugin", + runId: this.turnId, + threadId: this.turnId, + }); + const metadata = { + ...this.#runMetadata("streaming"), + ...(recordValue(snapshot.metadata) ?? {}), + data: this.#initialMessageMetadata, + }; + const initialAIMessage = { + id: this.turnId, + metadata: { turn_id: this.turnId, ...this.#initialMessageMetadata }, + parts: [], + role: "assistant", + ...(recordValue(snapshot.initialAIMessage) ?? {}), + }; + initialAIMessage.metadata = { + turn_id: this.turnId, + ...this.#initialMessageMetadata, + ...(recordValue(initialAIMessage.metadata) ?? {}), + }; const target = await this.#client.beeper.streams.startMessage({ content: { - body: "...", - [BEEPER_AI_KEY]: { - id: this.turnId, - metadata: { turn_id: this.turnId, ...this.#initialMessageMetadata }, - parts: [], - role: "assistant", - }, + body: snapshot.body || "...", + [BEEPER_AI_KEY]: initialAIMessage, [BEEPER_AI_METADATA_KEY]: metadata, [BEEPER_STREAM_DESCRIPTOR_KEY]: this.#streamDescriptor(), msgtype: "m.text", @@ -196,20 +211,49 @@ export class BeeperStreamPublisher { }); this.#descriptor = target.descriptor; this.#targetEventId = target.eventId; + await this.#publishSnapshotEvents(target.eventId, snapshot); return { descriptor: target.descriptor, eventId: target.eventId, turnId: this.turnId }; } async #publishPart(eventId: string, part: AGUIEvent): Promise { - const streamParts = aguiEventToFinalMessageParts(this.turnId, part); - await this.#client.beeper.streams.publishPart({ - ...(this.#agentId ? { agentId: this.#agentId } : {}), - eventId, - part, - roomId: this.roomId, - turnId: this.turnId, + const snapshot = await this.#appendRunEvent({ + event: part, + runId: this.turnId, + }); + await this.#publishSnapshotEvents(eventId, snapshot); + } + + async #beginRun(options: { agentId?: string; model?: string; runId: string; threadId: string }): Promise { + return this.#client.beeper.aiRuns.begin(options); + } + + async #appendRunEvent(options: { event: AGUIEvent; runId: string }): Promise { + return this.#client.beeper.aiRuns.appendEvent(options); + } + + async #finishRun(options: { finishReason?: FinishReason; runId: string }): Promise { + return this.#client.beeper.aiRuns.finish({ + runId: options.runId, + ...(options.finishReason ? { finishReason: options.finishReason } : {}), }); - for (const accumulatorPart of streamParts) { - applyFinalMessagePart(this.#accumulator, accumulatorPart); + } + + async #errorRun(options: { message?: string; runId: string; type?: "error" | "abort" }): Promise { + return this.#client.beeper.aiRuns.error(options); + } + + async #publishSnapshotEvents(eventId: string, snapshot: MatrixBeeperAIRunSnapshot): Promise { + for (const part of snapshot.events as AGUIEvent[]) { + await this.#client.beeper.streams.publishPart({ + ...(this.#agentId ? { agentId: this.#agentId } : {}), + eventId, + part, + roomId: this.roomId, + turnId: this.turnId, + }); + for (const accumulatorPart of aguiEventToFinalMessageParts(this.turnId, part)) { + applyFinalMessagePart(this.#accumulator, accumulatorPart); + } } } @@ -359,6 +403,11 @@ function recordValue(value: unknown): Record | undefined { return value as Record; } +function nonEmptyRecordValue(value: unknown): Record | undefined { + const record = recordValue(value); + return record && Object.keys(record).length > 0 ? record : undefined; +} + function stringifyValue(value: unknown): string { if (typeof value === "string") return value; if (value === undefined) return ""; diff --git a/packages/openclaw/src/stream-map.ts b/packages/openclaw/src/beeper-turn-events.ts similarity index 94% rename from packages/openclaw/src/stream-map.ts rename to packages/openclaw/src/beeper-turn-events.ts index 0383878..b0a94b8 100644 --- a/packages/openclaw/src/stream-map.ts +++ b/packages/openclaw/src/beeper-turn-events.ts @@ -29,25 +29,6 @@ export function createTurnId(): string { return `turn_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; } -export function startRunEvents(state: StreamRunState, metadata: Record = {}): AGUIEvent[] { - if (state.messageStarted) return []; - state.messageStarted = true; - state.textStarted = true; - return [ - { - runId: state.turnId, - threadId: state.turnId, - type: AGUIEventType.RUN_STARTED, - ...(Object.keys(metadata).length > 0 ? { metadata: { turn_id: state.turnId, ...metadata } } : {}), - }, - { - messageId: state.turnId, - role: "assistant", - type: AGUIEventType.TEXT_MESSAGE_START, - }, - ]; -} - export function finishRunEvents( state: StreamRunState, finishReason: FinishReason = "stop", diff --git a/packages/openclaw/src/bridge-agent.test.ts b/packages/openclaw/src/bridge-agent.test.ts index a12480a..3d68999 100644 --- a/packages/openclaw/src/bridge-agent.test.ts +++ b/packages/openclaw/src/bridge-agent.test.ts @@ -4,7 +4,7 @@ import { resolve } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { createDefaultConfig } from "./config"; import { OpenClawMatrixBridgeAgent } from "./bridge-agent"; -import { OpenClawGatewayRuntime, type OpenClawGatewayEvent, type OpenClawTransport } from "./openclaw-runtime"; +import { OpenClawPluginRuntimeAdapter, type OpenClawGatewayEvent, type OpenClawRuntimeRequestSurface } from "./openclaw-runtime"; import { OpenClawBridgeRegistry } from "./registry"; import type { OpenClawSessionBinding } from "./types"; @@ -26,9 +26,10 @@ describe("OpenClawMatrixBridgeAgent", () => { const registry = await tempRegistry(); registry.upsertBinding(testBinding()); const runtime = runtimeWith({ - responses: { "sessions.send": { runId: "run_1", sessionKey: "agent:codex:main" } }, + responses: {}, }); - const agent = new OpenClawMatrixBridgeAgent({ registry, runtime }); + const sendTurn = vi.fn(async () => ({ runId: "run_1", sessionKey: "agent:codex:main" })); + const agent = new OpenClawMatrixBridgeAgent({ registry, runtime, sendTurn }); await agent.handleMatrixText({ eventId: "$event", @@ -37,38 +38,59 @@ describe("OpenClawMatrixBridgeAgent", () => { text: "hello", }); - expect(runtime.transport.request).toHaveBeenCalledWith("sessions.send", { + expect(sendTurn).toHaveBeenCalledWith({ idempotencyKey: "$event", - key: "agent:codex:main", matrix: { roomId: "!room:example.com" }, message: "hello", - }, { expectFinal: false }); + sessionKey: "agent:codex:main", + }); 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(runtime.transport.request).not.toHaveBeenCalledWith("sessions.send", expect.anything(), expect.anything()); + 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: { - "sessions.send": new Error("gateway down"), - }, + const runtime = runtimeWith({ responses: {} }); + const sendTurn = vi.fn(async () => { + throw new Error("turn down"); }); - const agent = new OpenClawMatrixBridgeAgent({ registry, runtime }); + 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("gateway down"); + })).rejects.toThrow("turn down"); expect(registry.hasDedupe("$retryable")).toBe(false); - runtime.transport.request.mockImplementation(async (method: string) => { - if (method === "sessions.send") return { runId: "run_retry", sessionKey: "agent:codex:main" }; - return undefined; - }); + sendTurn.mockResolvedValueOnce({ runId: "run_retry", sessionKey: "agent:codex:main" }); await agent.handleMatrixText({ eventId: "$retryable", @@ -78,12 +100,12 @@ describe("OpenClawMatrixBridgeAgent", () => { }); expect(registry.hasDedupe("$retryable")).toBe(true); - expect(runtime.transport.request).toHaveBeenCalledWith("sessions.send", { + expect(sendTurn).toHaveBeenLastCalledWith({ idempotencyKey: "$retryable", - key: "agent:codex:main", matrix: { roomId: "!room:example.com" }, message: "hello", - }, { expectFinal: false }); + sessionKey: "agent:codex:main", + }); }); it("creates an OpenClaw session before sending the first message in an agent contact DM", async () => { @@ -98,10 +120,10 @@ describe("OpenClawMatrixBridgeAgent", () => { ], responses: { "sessions.create": { key: "agent:codex:session_1", sessionId: "session_1" }, - "sessions.send": { runId: "run_1", sessionKey: "agent:codex:session_1" }, }, }); - const agent = new OpenClawMatrixBridgeAgent({ registry, runtime }); + 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", @@ -113,12 +135,12 @@ describe("OpenClawMatrixBridgeAgent", () => { expect(runtime.transport.request).toHaveBeenCalledWith("sessions.create", { agentId: "codex", }); - expect(runtime.transport.request).toHaveBeenCalledWith("sessions.send", { + expect(sendTurn).toHaveBeenCalledWith({ idempotencyKey: "$event", - key: "agent:codex:session_1", matrix: { roomId: "!room:example.com" }, message: "hello", - }, { expectFinal: false }); + sessionKey: "agent:codex:session_1", + }); expect(registry.getBindingByRoom("!room:example.com")?.sessionKey).toBe("agent:codex:session_1"); }); @@ -192,7 +214,7 @@ function testBinding(): OpenClawSessionBinding { function runtimeWith(options: { events?: OpenClawGatewayEvent[]; responses: Record; -}): OpenClawGatewayRuntime & { transport: OpenClawTransport & { request: ReturnType } } { +}): OpenClawPluginRuntimeAdapter & { transport: OpenClawRuntimeRequestSurface & { request: ReturnType } } { const transport = { async *events(filter?: (event: OpenClawGatewayEvent) => boolean) { for (const event of options.events ?? []) { @@ -205,8 +227,8 @@ function runtimeWith(options: { return response; }), }; - return new OpenClawGatewayRuntime({ + return new OpenClawPluginRuntimeAdapter({ config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), transport, - }) as OpenClawGatewayRuntime & { transport: OpenClawTransport & { request: ReturnType } }; + }) as OpenClawPluginRuntimeAdapter & { transport: OpenClawRuntimeRequestSurface & { request: ReturnType } }; } diff --git a/packages/openclaw/src/bridge-agent.ts b/packages/openclaw/src/bridge-agent.ts index 1ddf008..551c35a 100644 --- a/packages/openclaw/src/bridge-agent.ts +++ b/packages/openclaw/src/bridge-agent.ts @@ -4,7 +4,7 @@ import { toOpenClawApprovalResolvePayload, type ParsedApprovalResponse, } from "./approval"; -import type { OpenClawGatewayRuntime, OpenClawMatrixMessageMetadata } from "./openclaw-runtime"; +import type { OpenClawMatrixMessageMetadata, OpenClawRunRef, OpenClawSessionSendOptions, OpenClawSessionTurnRuntime } from "./openclaw-runtime"; import type { OpenClawBridgeRegistry } from "./registry"; import type { OpenClawSessionBinding } from "./types"; @@ -20,14 +20,17 @@ export interface MatrixTextTurn { export class OpenClawMatrixBridgeAgent { readonly registry: OpenClawBridgeRegistry; - readonly runtime: OpenClawGatewayRuntime; + readonly runtime: OpenClawSessionTurnRuntime; + readonly #sendTurn: (options: OpenClawSessionSendOptions) => Promise; constructor(options: { registry: OpenClawBridgeRegistry; - runtime: OpenClawGatewayRuntime; + 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 { @@ -50,7 +53,7 @@ export class OpenClawMatrixBridgeAgent { ...(turn.matrix ?? {}), roomId: turn.roomId, }; - const run = await this.runtime.sendMessage({ + const run = await this.#sendTurn({ ...(turn.attachments && turn.attachments.length > 0 ? { attachments: turn.attachments } : {}), idempotencyKey: turn.eventId, matrix, diff --git a/packages/openclaw/src/config.test.ts b/packages/openclaw/src/config.test.ts index cf92b77..ef480fe 100644 --- a/packages/openclaw/src/config.test.ts +++ b/packages/openclaw/src/config.test.ts @@ -52,7 +52,6 @@ describe("OpenClaw bridge config", () => { homeserverDomain: "beeper.local", importSources: ["dashboard", "tui"], approvalBehavior: "native", - streamFinalization: "replace", })).toMatchObject({ approvalBehavior: "native", backfillLimit: 25, @@ -64,7 +63,6 @@ describe("OpenClaw bridge config", () => { contactVisibility: "agents-and-users", homeserverDomain: "beeper.local", importSources: ["dashboard", "tui"], - streamFinalization: "replace", }); }); diff --git a/packages/openclaw/src/config.ts b/packages/openclaw/src/config.ts index e197a9b..0a18aad 100644 --- a/packages/openclaw/src/config.ts +++ b/packages/openclaw/src/config.ts @@ -61,7 +61,6 @@ export function createDefaultConfig(overrides: Partial = { const backfillLimit = overrides.backfillLimit ?? envNumber(process.env.PICKLE_OPENCLAW_BACKFILL_LIMIT); const contactVisibility = overrides.contactVisibility ?? envContactVisibility(process.env.PICKLE_OPENCLAW_CONTACT_VISIBILITY); const importSources = overrides.importSources ?? envImportSources(process.env.PICKLE_OPENCLAW_IMPORT_SOURCES); - const streamFinalization = overrides.streamFinalization ?? envStreamFinalization(process.env.PICKLE_OPENCLAW_STREAM_FINALIZATION); const approvalBehavior = overrides.approvalBehavior ?? envApprovalBehavior(process.env.PICKLE_OPENCLAW_APPROVAL_BEHAVIOR); const bridgeManagerPostState = overrides.bridgeManagerPostState ?? envBoolean(process.env.PICKLE_OPENCLAW_BRIDGE_MANAGER_POST_STATE); const allowedRoomIds = overrides.allowedRoomIds ?? envStringList(process.env.PICKLE_OPENCLAW_ALLOW_ROOMS); @@ -80,7 +79,6 @@ export function createDefaultConfig(overrides: Partial = { if (backfillLimit !== undefined) config.backfillLimit = backfillLimit; if (contactVisibility !== undefined) config.contactVisibility = contactVisibility; if (importSources !== undefined) config.importSources = importSources; - if (streamFinalization !== undefined) config.streamFinalization = streamFinalization; if (approvalBehavior !== undefined) config.approvalBehavior = approvalBehavior; if (bridgeManagerPostState !== undefined) config.bridgeManagerPostState = bridgeManagerPostState; if (allowedRoomIds) config.allowedRoomIds = allowedRoomIds; @@ -146,11 +144,6 @@ function envStringList(value: string | undefined): string[] | undefined { return values.length > 0 ? values : undefined; } -function envStreamFinalization(value: string | undefined): OpenClawBridgeConfig["streamFinalization"] | undefined { - if (value === "replace" || value === "append" || value === "native-only") return value; - return undefined; -} - function envApprovalBehavior(value: string | undefined): OpenClawBridgeConfig["approvalBehavior"] | undefined { if (value === "native" || value === "disabled") return value; return undefined; diff --git a/packages/openclaw/src/connector.test.ts b/packages/openclaw/src/connector.test.ts index ecebb74..5c6e4b6 100644 --- a/packages/openclaw/src/connector.test.ts +++ b/packages/openclaw/src/connector.test.ts @@ -1,8 +1,8 @@ -import type { MatrixCommand, MatrixEdit, MatrixMessage, MatrixReaction, MatrixReactionRemove, MatrixRedaction, UserLogin } from "@beeper/pickle-bridge"; +import type { MatrixEdit, MatrixMessage, MatrixReaction, MatrixReactionRemove, MatrixRedaction, UserLogin } from "@beeper/pickle-bridge"; import { describe, expect, it, vi } from "vitest"; import { createDefaultConfig } from "./config"; import { createOpenClawConnector, OpenClawNetworkAPI, parseMatrixTextMessage, userLoginFromOpenClawConfig } from "./connector"; -import { OpenClawGatewayRuntime, type OpenClawGatewayEvent, type OpenClawTransport } from "./openclaw-runtime"; +import { OpenClawPluginRuntimeAdapter, type OpenClawGatewayEvent, type OpenClawRuntimeRequestSurface } from "./openclaw-runtime"; import { OpenClawBridgeRegistry } from "./registry"; describe("OpenClawBridgeConnector", () => { @@ -55,33 +55,32 @@ describe("OpenClawBridgeConnector", () => { })); }); - it("handles slash-prefixed OpenClaw commands through management command fallback", async () => { + it("registers the live Beeper runtime in OpenClaw channel runtime contexts", async () => { + const register = vi.fn(); const connector = createOpenClawConnector({ - config: createDefaultConfig({ - dataDir: "/tmp/openclaw", - importSources: ["dashboard"], - }), + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + registry: new OpenClawBridgeRegistry("/tmp/openclaw-connector-runtime-context-test.json"), + runtime: { + channel: { + runtimeContexts: { register }, + }, + } as never, }); - const response = await connector.handleCommand({} as never, { - args: [], - body: "/status", - command: "/status", - event: { eventId: "$status", kind: "message", roomId: "!management:example" }, - prefix: "!openclaw", - room: { mxid: "!management:example" }, - sender: { userId: "@alice:example.com" }, - text: "/status", - } as MatrixCommand); - expect(response).toMatchObject({ - content: { - format: "org.matrix.custom.html", - formatted_body: expect.stringContaining("
Behavior
"), - msgtype: "m.text", + await connector.init({ + bridge: { + getOwnUserId: () => "@openclaw:example.com", }, - handled: true, - text: expect.stringContaining("Import sources: dashboard"), - }); + client: {}, + log: vi.fn(), + } as never); + + expect(register).toHaveBeenCalledWith(expect.objectContaining({ + accountId: "default", + capability: "beeper.runtime", + channelId: "beeper", + context: connector.getChannelRuntime(), + })); }); it("loads a network API that registers OpenClaw agents as ghosts", async () => { @@ -395,7 +394,7 @@ describe("OpenClawBridgeConnector", () => { const runtime = runtimeWith({ responses: { "sessions.create": { key: "agent:codex:session_1" }, - "sessions.send": { runId: "run_1", sessionKey: "agent:codex:session_1" }, + "beeper.turn": { runId: "run_1", sessionKey: "agent:codex:session_1" }, }, }); runtime.config.allowedRoomIds = ["!allowed:example.com"]; @@ -442,7 +441,7 @@ describe("OpenClawBridgeConnector", () => { const runtime = runtimeWith({ events: [{ event: "run.completed", payload: { runId: "run_owner", type: "run.completed" } }], responses: { - "sessions.send": { runId: "run_owner", sessionKey: "agent:main:main" }, + "beeper.turn": { runId: "run_owner", sessionKey: "agent:main:main" }, }, }); runtime.config.matrixUserId = "@owner:beeper-staging.com"; @@ -467,10 +466,10 @@ describe("OpenClawBridgeConnector", () => { text: "hello from owner", } as MatrixMessage); - expect(runtime.transport.request).toHaveBeenCalledWith("sessions.send", expect.objectContaining({ - key: sessionKey, + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + sessionKey, message: "hello from owner", - }), { expectFinal: false }); + })); }); it("dispatches Matrix text and native approval responses to OpenClaw", async () => { @@ -480,7 +479,7 @@ describe("OpenClawBridgeConnector", () => { responses: { "exec.approval.resolve": { ok: true }, "sessions.create": { key: "agent:codex:session_1" }, - "sessions.send": { runId: "run_1", sessionKey: "agent:codex:session_1" }, + "beeper.turn": { runId: "run_1", sessionKey: "agent:codex:session_1" }, }, }); const api = new OpenClawNetworkAPI({ @@ -503,21 +502,22 @@ describe("OpenClawBridgeConnector", () => { receiver: "login", }; - await expect(api.handleMatrixMessage({} as BridgeRequestContext, { + 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.transport.request).toHaveBeenCalledWith("sessions.send", { + expect(runtime.sendMessage).toHaveBeenCalledWith({ idempotencyKey: "$message", - key: "agent:codex:session_1", matrix: { roomId: "!room:example.com", sender: "@alice:example.com", }, message: "hello", - }, { expectFinal: false }); + sessionKey: "agent:codex:session_1", + }); await expect(api.handleMatrixReaction({} as BridgeRequestContext, { content: { @@ -545,7 +545,7 @@ describe("OpenClawBridgeConnector", () => { decision: "deny", }); - await expect(api.handleMatrixMessage({} as BridgeRequestContext, { + await expect(api.handleMatrixMessage({ queueRemoteEvent } as unknown as BridgeRequestContext, { content: { approvalId: "approval_2", approved: true, @@ -563,9 +563,9 @@ describe("OpenClawBridgeConnector", () => { decision: "approve_always", toolCallId: "tool_1", }); - expect(runtime.transport.request).not.toHaveBeenCalledWith("sessions.send", expect.objectContaining({ + expect(runtime.sendMessage).not.toHaveBeenCalledWith(expect.objectContaining({ idempotencyKey: "$native-approval", - }), expect.anything()); + })); }); it("parses Matrix replies and slash commands for OpenClaw turns", async () => { @@ -667,7 +667,7 @@ describe("OpenClawBridgeConnector", () => { events: [{ event: "run.completed", payload: { runId: "run_2", type: "run.completed" } }], responses: { "sessions.create": { key: "agent:codex:session_2" }, - "sessions.send": { runId: "run_2", sessionKey: "agent:codex:session_2" }, + "beeper.turn": { runId: "run_2", sessionKey: "agent:codex:session_2" }, }, }); const api = new OpenClawNetworkAPI({ @@ -702,10 +702,9 @@ describe("OpenClawBridgeConnector", () => { sender: { userId: "@alice:example.com" }, text: "> <@alice> old\n\nnew text", } as MatrixMessage); - expect(runtime.transport.request).toHaveBeenCalledWith("sessions.send", { + expect(runtime.sendMessage).toHaveBeenCalledWith({ attachments: [{ contentType: "image/png", contentUri: "mxc://example/photo", filename: "photo.png", kind: "image" }], idempotencyKey: "$reply", - key: "agent:codex:session_2", matrix: { attachments: [{ contentType: "image/png", contentUri: "mxc://example/photo", filename: "photo.png", kind: "image" }], relation: { @@ -723,7 +722,8 @@ describe("OpenClawBridgeConnector", () => { }, message: "new text", replyTo: { eventId: "$old", roomId: "!room:example.com" }, - }, { expectFinal: false }); + sessionKey: "agent:codex:session_2", + }); }); it("passes Matrix formatted body, mentions, and thread metadata to OpenClaw", async () => { @@ -732,7 +732,7 @@ describe("OpenClawBridgeConnector", () => { events: [{ event: "run.completed", payload: { runId: "run_thread", type: "run.completed" } }], responses: { "sessions.create": { key: "agent:codex:session_thread" }, - "sessions.send": { runId: "run_thread", sessionKey: "agent:codex:session_thread" }, + "beeper.turn": { runId: "run_thread", sessionKey: "agent:codex:session_thread" }, }, }); const api = new OpenClawNetworkAPI({ @@ -769,9 +769,8 @@ describe("OpenClawBridgeConnector", () => { text: "hello", } as MatrixMessage); - expect(runtime.transport.request).toHaveBeenCalledWith("sessions.send", { + expect(runtime.sendMessage).toHaveBeenCalledWith({ idempotencyKey: "$thread-message", - key: "agent:codex:session_thread", matrix: { formattedBody: "hello", mentions: { room: true, userIds: ["@bob:example.com"] }, @@ -786,51 +785,8 @@ describe("OpenClawBridgeConnector", () => { }, message: "hello", replyTo: { eventId: "$thread-root", roomId: "!room:example.com" }, - }, { expectFinal: false }); - }); - - it("maps /stop and /abort slash commands to session abort", async () => { - const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); - registry.upsertBinding({ - agentId: "codex", - createdAt: 1, - ghostUserId: "@codex:example.com", - id: "binding", - kind: "session", - lastRunId: "run_1", - owner: "bridge", - roomId: "!room:example.com", - sessionKey: "agent:codex:session_1", - updatedAt: 1, - }); - const runtime = runtimeWith({ - responses: { - "sessions.abort": { ok: true }, - }, + sessionKey: "agent:codex:session_thread", }); - const api = new OpenClawNetworkAPI({ - config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), - login: login(), - registry, - runtime, - }); - - await expect(api.handleMatrixMessage({} as BridgeRequestContext, { - event: { eventId: "$stop" }, - 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", - }, - sender: { userId: "@alice:example.com" }, - text: "/stop", - } as MatrixMessage)).resolves.toEqual({ pending: false }); - expect(runtime.transport.request).toHaveBeenCalledWith("sessions.abort", { - key: "agent:codex:session_1", - runId: "run_1", - }, undefined); }); it("forwards Matrix edits, redactions, and non-approval reactions as session context", async () => { @@ -857,7 +813,7 @@ describe("OpenClawBridgeConnector", () => { ], responses: { "sessions.create": { key: "agent:codex:session_1" }, - "sessions.send": { runId: "run_edit", sessionKey: "agent:codex:session_1" }, + "beeper.turn": { runId: "run_edit", sessionKey: "agent:codex:session_1" }, }, }); const api = new OpenClawNetworkAPI({ @@ -893,7 +849,7 @@ describe("OpenClawBridgeConnector", () => { targetMessage: { id: "$old" }, text: "* typo", } as MatrixEdit); - expect(runtime.transport.request).toHaveBeenCalledWith("sessions.send", expect.objectContaining({ + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ idempotencyKey: "$edit:edit", matrix: { formattedBody: "corrected", @@ -908,7 +864,7 @@ describe("OpenClawBridgeConnector", () => { }, message: "corrected", replyTo: { eventId: "$old", roomId: "!room:example.com" }, - }), { expectFinal: false }); + })); await expect(api.handleMatrixReaction({} as BridgeRequestContext, { content: { "m.relates_to": { event_id: "$old", key: "👍", rel_type: "m.annotation" } }, @@ -919,7 +875,7 @@ describe("OpenClawBridgeConnector", () => { id: "$react", metadata: { openclaw: { reaction: "👍", targetMessageId: "$old" } }, }); - expect(runtime.transport.request).toHaveBeenCalledWith("sessions.send", expect.objectContaining({ + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ idempotencyKey: "$react", matrix: { relation: { @@ -934,7 +890,7 @@ describe("OpenClawBridgeConnector", () => { }, message: "Reacted 👍 to $old", replyTo: { eventId: "$old", roomId: "!room:example.com" }, - }), { expectFinal: false }); + })); await api.handleMatrixReactionRemove({} as BridgeRequestContext, { content: { "m.relates_to": { event_id: "$old", key: "👍", rel_type: "m.annotation" } }, @@ -943,7 +899,7 @@ describe("OpenClawBridgeConnector", () => { targetMessage: { id: "$old" }, targetReaction: { id: "$react" }, } as MatrixReactionRemove); - expect(runtime.transport.request).toHaveBeenCalledWith("sessions.send", expect.objectContaining({ + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ idempotencyKey: "$react-redact", matrix: { relation: { @@ -959,14 +915,14 @@ describe("OpenClawBridgeConnector", () => { }, message: "Removed reaction 👍 from $old", replyTo: { eventId: "$old", roomId: "!room:example.com" }, - }), { expectFinal: false }); + })); await api.handleMatrixRedaction({} as BridgeRequestContext, { eventId: "$redact", portal, targetMessage: { id: "$old" }, } as MatrixRedaction); - expect(runtime.transport.request).toHaveBeenCalledWith("sessions.send", expect.objectContaining({ + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ idempotencyKey: "$redact", matrix: { relation: { @@ -980,284 +936,15 @@ describe("OpenClawBridgeConnector", () => { }, message: "Redacted message $old", replyTo: { eventId: "$old", roomId: "!room:example.com" }, - }), { expectFinal: false }); - }); - - it("handles bridge slash commands without forwarding them as chat turns", async () => { - const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); - registry.upsertBinding({ - agentId: "codex", - createdAt: 1, - ghostUserId: "@codex:example.com", - id: "binding", - kind: "session", - owner: "bridge", - roomId: "!room:example.com", - sessionKey: "agent:codex:session_1", - updatedAt: 1, - }); - const runtime = runtimeWith({ - responses: { - "chat.history": { messages: [{ content: "hello", id: "m1", role: "user" }] }, - "sessions.create": { key: "agent:codex:new" }, - "sessions.list": { - sessions: [ - { displayName: "Desktop chat", key: "agent:codex:desktop", origin: { surface: "mac-app" } }, - { displayName: "Terminal chat", key: "agent:codex:tui", origin: { surface: "terminal" } }, - ], - }, - }, - }); - runtime.config.importSources = ["dashboard"]; - runtime.config.backfillLimit = 5; - runtime.config.allowedRoomIds = ["!room:example.com"]; - runtime.config.allowedUserIds = ["@alice:example.com"]; - runtime.config.beeperEnv = "staging"; - runtime.config.bridgeManagerPostState = false; - runtime.config.bridgeManagerToken = "hungry-token"; - runtime.config.contactVisibility = "agents-and-users"; - const api = new OpenClawNetworkAPI({ - config: runtime.config, - login: login(), - registry, - runtime, - }); - const queueRemoteEvent = vi.fn(); - const createPortal = vi.fn(async (_login: UserLogin, options: { id: string }) => ({ - id: options.id, - mxid: options.id.includes("ZGVza3RvcA") ? "!imported-desktop:example.com" : "!new-room:example.com", - portalKey: { id: options.id, receiver: "login" }, - receiver: "login", - })); - const backfillPortal = vi.fn(); - const ctx = { bridge: { backfillPortal, createPortal }, queueRemoteEvent } as unknown as BridgeRequestContext; - 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.handleMatrixMessage(ctx, { - event: { eventId: "$status" }, - portal, - sender: { userId: "@alice:example.com" }, - text: "/status", - } as MatrixMessage)).resolves.toEqual({ pending: false }); - expect(queueRemoteEvent.mock.calls.at(-1)?.[1].getID()).toBe("$status:openclaw-command"); - expect(queueRemoteEvent.mock.calls.at(-1)?.[1].getSender()).toEqual({ - isFromMe: true, - sender: "@codex:example.com", - }); - await expect(queueRemoteEvent.mock.calls.at(-1)?.[1].convertMessage()).resolves.toMatchObject({ - parts: [{ content: { body: expect.stringContaining("Import sources: dashboard"), msgtype: "m.text" } }], - }); - await expect(queueRemoteEvent.mock.calls.at(-1)?.[1].convertMessage()).resolves.toMatchObject({ - parts: [{ content: { body: expect.stringContaining("Approvals: native Beeper UI"), msgtype: "m.text" } }], - }); - const statusContent = (await queueRemoteEvent.mock.calls.at(-1)?.[1].convertMessage()).parts[0].content; - expect(statusContent).toMatchObject({ - format: "org.matrix.custom.html", - formatted_body: expect.stringContaining("
Behavior
"), - }); - - await api.handleMatrixMessage(ctx, { - event: { eventId: "$settings" }, - portal, - sender: { userId: "@alice:example.com" }, - text: "/settings", - } as MatrixMessage); - const settingsBody = (await queueRemoteEvent.mock.calls.at(-1)?.[1].convertMessage()).parts[0].content.body; - expect(settingsBody).toContain("OpenClaw Beeper settings"); - expect(settingsBody).toContain("Beeper environment: staging"); - expect(settingsBody).toContain("Bridge manager token: configured"); - expect(settingsBody).toContain("Post bridge state: disabled"); - expect(settingsBody).toContain("Contact visibility: agents-and-users"); - expect(settingsBody).toContain("Allowed rooms: !room:example.com"); - expect(settingsBody).toContain("Allowed users: @alice:example.com"); - const settingsContent = (await queueRemoteEvent.mock.calls.at(-1)?.[1].convertMessage()).parts[0].content; - expect(settingsContent.formatted_body).toContain("OpenClaw Beeper settings
"); - expect(settingsContent.formatted_body).toContain("
Allowed rooms: !room:example.com
"); - - await api.handleMatrixMessage(ctx, { - event: { eventId: "$sessions" }, - portal, - sender: { userId: "@alice:example.com" }, - text: "/sessions", - } as MatrixMessage); - await expect(queueRemoteEvent.mock.calls.at(-1)?.[1].convertMessage()).resolves.toMatchObject({ - parts: [{ content: { body: expect.stringContaining("Desktop chat") } }], - }); - const sessionsBody = (await queueRemoteEvent.mock.calls.at(-1)?.[1].convertMessage()).parts[0].content.body; - expect(sessionsBody).not.toContain("Terminal chat"); - - await api.handleMatrixMessage(ctx, { - event: { eventId: "$backfill" }, - portal, - sender: { userId: "@alice:example.com" }, - text: "/backfill", - } as MatrixMessage); - await expect(queueRemoteEvent.mock.calls.at(-1)?.[1].convertMessage()).resolves.toMatchObject({ - parts: [{ content: { body: "Queued backfill for 1 message." } }], - }); - expect(runtime.transport.request).toHaveBeenCalledWith("chat.history", { - limit: 5, - sessionKey: "agent:codex:session_1", - }); - expect(backfillPortal).toHaveBeenCalledWith(login(), portal, { limit: 5 }); - - await api.handleMatrixMessage(ctx, { - event: { eventId: "$import" }, - portal, - sender: { userId: "@alice:example.com" }, - text: "/import", - } as MatrixMessage); - await expect(queueRemoteEvent.mock.calls.at(-1)?.[1].convertMessage()).resolves.toMatchObject({ - parts: [{ content: { body: "Imported 1 OpenClaw session.\nSkipped 0 already imported or unavailable sessions." } }], - }); - expect(createPortal).toHaveBeenCalledWith(login(), expect.objectContaining({ - id: "session:YWdlbnQ6Y29kZXg6ZGVza3RvcA", - name: "Desktop chat", - roomType: "dm", - })); - expect(backfillPortal).toHaveBeenCalledWith(login(), expect.objectContaining({ - mxid: "!imported-desktop:example.com", - }), { limit: 5 }); - expect(registry.getBindingBySessionKey("agent:codex:desktop")).toMatchObject({ - owner: "imported", - roomId: "!imported-desktop:example.com", - }); - - await api.handleMatrixMessage(ctx, { - event: { eventId: "$new" }, - portal, - sender: { userId: "@alice:example.com" }, - text: "/new fresh", - } as MatrixMessage); - expect(runtime.transport.request).toHaveBeenCalledWith("sessions.create", expect.objectContaining({ - agentId: "codex", - key: expect.stringMatching(/^agent:codex:beeper:/u), - label: "fresh", - })); - expect(createPortal).toHaveBeenCalledWith(login(), { - creationContent: { "m.federate": false }, - id: "session:YWdlbnQ6Y29kZXg6bmV3", - metadata: { - openclaw: { - agentId: "codex", - ghostUserId: "@codex:example.com", - sessionKey: "agent:codex:new", - }, - }, - name: "fresh", - roomType: "dm", - }); - expect(registry.getBindingByRoom("!new-room:example.com")).toMatchObject({ - agentId: "codex", - label: "fresh", - sessionKey: "agent:codex:new", - }); - await expect(queueRemoteEvent.mock.calls.at(-1)?.[1].convertMessage()).resolves.toMatchObject({ - parts: [{ content: { body: "Created a new OpenClaw session room: !new-room:example.com" } }], - }); - expect(runtime.transport.request).not.toHaveBeenCalledWith("sessions.send", expect.anything(), expect.anything()); - - await api.handleMatrixMessage(ctx, { - event: { eventId: "$new-default" }, - portal, - sender: { userId: "@alice:example.com" }, - text: "/new", - } as MatrixMessage); - expect(runtime.transport.request).toHaveBeenCalledWith("sessions.create", expect.objectContaining({ - agentId: "codex", - key: expect.stringMatching(/^agent:codex:beeper:/u), - label: "New OpenClaw Session", })); }); - it("binds unbound rooms to new OpenClaw sessions from slash commands", 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:new-from-management" }, - }, - }); - const api = new OpenClawNetworkAPI({ - config: runtime.config, - login: login(), - registry, - runtime, - }); - const queueRemoteEvent = vi.fn(); - const registerPortal = vi.fn(); - const ctx = { bridge: { registerPortal }, queueRemoteEvent } as unknown as BridgeRequestContext; - const portal = { - id: "management", - mxid: "!management:example.com", - portalKey: { id: "management", receiver: "login" }, - receiver: "login", - }; - - await api.handleMatrixMessage(ctx, { - event: { eventId: "$new-unbound" }, - portal, - sender: { userId: "@alice:example.com" }, - text: "/new codex Deep work", - } as MatrixMessage); - - expect(runtime.transport.request).toHaveBeenCalledWith("sessions.create", expect.objectContaining({ - agentId: "codex", - key: expect.stringMatching(/^agent:codex:beeper:/u), - label: "Deep work", - })); - expect(registry.getBindingByRoom("!management:example.com")).toMatchObject({ - agentId: "codex", - label: "Deep work", - sessionKey: "agent:codex:new-from-management", - }); - expect(registerPortal).toHaveBeenCalledWith(expect.objectContaining({ - id: "session:YWdlbnQ6Y29kZXg6bmV3LWZyb20tbWFuYWdlbWVudA", - mxid: "!management:example.com", - portalKey: { - id: "session:YWdlbnQ6Y29kZXg6bmV3LWZyb20tbWFuYWdlbWVudA", - receiver: "openclaw:plugin", - }, - receiver: "openclaw:plugin", - })); - await expect(queueRemoteEvent.mock.calls.at(-1)?.[1].convertMessage()).resolves.toMatchObject({ - parts: [{ content: { body: expect.stringContaining("Created a new OpenClaw session in this room") } }], - }); - - await api.handleMatrixMessage(ctx, { - event: { eventId: "$new-missing-agent" }, - portal: { - id: "fresh-management", - mxid: "!fresh-management:example.com", - portalKey: { id: "fresh-management", receiver: "login" }, - receiver: "login", - }, - sender: { userId: "@alice:example.com" }, - text: "/new", - } as MatrixMessage); - expect(runtime.transport.request).toHaveBeenCalledWith("sessions.create", expect.objectContaining({ - agentId: "main", - key: expect.stringMatching(/^agent:main:beeper:/u), - label: "New OpenClaw Session", - })); - expect(registry.getBindingByRoom("!fresh-management:example.com")).toMatchObject({ - agentId: "main", - label: "New OpenClaw Session", - }); - }); - 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" }, - "sessions.send": { runId: "run_auto", sessionKey: "agent:main:auto" }, + "beeper.turn": { runId: "run_auto", sessionKey: "agent:main:auto" }, }, }); const api = new OpenClawNetworkAPI({ @@ -1291,11 +978,11 @@ describe("OpenClawBridgeConnector", () => { key: expect.stringMatching(/^agent:main:beeper:/u), label: "New OpenClaw Session", })); - expect(runtime.transport.request).toHaveBeenCalledWith("sessions.send", expect.objectContaining({ + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ idempotencyKey: "$hello", - key: "agent:main:auto", message: "hey", - }), { expectFinal: false }); + sessionKey: "agent:main:auto", + })); expect(registry.getBindingByRoom("!cloud-room:example.com")).toMatchObject({ agentId: "main", label: "New OpenClaw Session", @@ -1320,7 +1007,7 @@ describe("OpenClawBridgeConnector", () => { })); }); - it("rejects reaction and slash approval fallbacks", async () => { + 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: { @@ -1368,12 +1055,11 @@ describe("OpenClawBridgeConnector", () => { approvalId: "approval_native_disabled", decision: "approve", }); - expect(runtime.transport.request).not.toHaveBeenCalledWith("sessions.send", expect.objectContaining({ + expect(runtime.sendMessage).not.toHaveBeenCalledWith(expect.objectContaining({ idempotencyKey: "$native-disabled", - }), expect.anything()); + })); - const queueRemoteEvent = vi.fn(); - await api.handleMatrixMessage({ queueRemoteEvent } as unknown as BridgeRequestContext, { + await api.handleMatrixMessage({} as BridgeRequestContext, { event: { eventId: "$approve" }, portal, sender: { userId: "@alice:example.com" }, @@ -1383,11 +1069,13 @@ describe("OpenClawBridgeConnector", () => { approvalId: "approval_1", decision: "approve", }); - await expect(queueRemoteEvent.mock.calls.at(-1)?.[1].convertMessage()).resolves.toMatchObject({ - parts: [{ content: { body: "Approval slash commands are disabled for this bridge." } }], - }); + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$approve", + message: "/approve approval_1", + sessionKey: "agent:codex:session_1", + })); - await api.handleMatrixMessage({ queueRemoteEvent } as unknown as BridgeRequestContext, { + await api.handleMatrixMessage({} as BridgeRequestContext, { content: { "m.relates_to": { "m.in_reply_to": { event_id: "approval_1_reply" }, @@ -1403,17 +1091,24 @@ describe("OpenClawBridgeConnector", () => { approvalId: "approval_1_reply", decision: "deny", }); + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$deny-reply", + message: "/deny", + sessionKey: "agent:codex:session_1", + })); runtime.config.approvalBehavior = "disabled"; - await api.handleMatrixMessage({ queueRemoteEvent } as unknown as BridgeRequestContext, { + await api.handleMatrixMessage({} as BridgeRequestContext, { event: { eventId: "$approve-disabled" }, portal, sender: { userId: "@alice:example.com" }, text: "/approve approval_2", } as MatrixMessage); - await expect(queueRemoteEvent.mock.calls.at(-1)?.[1].convertMessage()).resolves.toMatchObject({ - parts: [{ content: { body: "Approval slash commands are disabled for this bridge." } }], - }); + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$approve-disabled", + message: "/approve approval_2", + sessionKey: "agent:codex:session_1", + })); }); @@ -1422,7 +1117,7 @@ describe("OpenClawBridgeConnector", () => { const runtime = runtimeWith({ events: [{ event: "run.completed", payload: { runId: "run_rebuilt", type: "run.completed" } }], responses: { - "sessions.send": { runId: "run_rebuilt", sessionKey: "agent:codex:dashboard:one" }, + "beeper.turn": { runId: "run_rebuilt", sessionKey: "agent:codex:dashboard:one" }, }, }); runtime.config.homeserverDomain = "example.com"; @@ -1453,10 +1148,10 @@ describe("OpenClawBridgeConnector", () => { owner: "imported", sessionKey, }); - expect(runtime.transport.request).toHaveBeenCalledWith("sessions.send", expect.objectContaining({ - key: sessionKey, + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ message: "hello from persisted portal", - }), { expectFinal: false }); + sessionKey, + })); }); it("rebuilds an OpenClaw room binding from a cloud appservice session room id", async () => { @@ -1464,7 +1159,7 @@ describe("OpenClawBridgeConnector", () => { const runtime = runtimeWith({ events: [{ event: "run.completed", payload: { runId: "run_cloud", type: "run.completed" } }], responses: { - "sessions.send": { runId: "run_cloud", sessionKey: "agent:main:dashboard:abc" }, + "beeper.turn": { runId: "run_cloud", sessionKey: "agent:main:dashboard:abc" }, }, }); runtime.config.homeserverDomain = "beeper.local"; @@ -1496,10 +1191,10 @@ describe("OpenClawBridgeConnector", () => { owner: "imported", sessionKey, }); - expect(runtime.transport.request).toHaveBeenCalledWith("sessions.send", expect.objectContaining({ - key: sessionKey, + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ message: "hello from cloud room", - }), { expectFinal: false }); + sessionKey, + })); }); it("fetches OpenClaw chat history for Pickle backfill", async () => { @@ -1557,7 +1252,10 @@ function login(): UserLogin { function runtimeWith(options: { events?: OpenClawGatewayEvent[]; responses: Record; -}): OpenClawGatewayRuntime & { transport: OpenClawTransport & { request: ReturnType } } { +}): OpenClawPluginRuntimeAdapter & { + sendMessage: ReturnType; + transport: OpenClawRuntimeRequestSurface & { request: ReturnType }; +} { const transport = { async *events(filter?: (event: OpenClawGatewayEvent) => boolean) { for (const event of options.events ?? []) { @@ -1566,8 +1264,17 @@ function runtimeWith(options: { }, request: vi.fn(async (method: string) => options.responses[method]), }; - return new OpenClawGatewayRuntime({ + const runtime = new OpenClawPluginRuntimeAdapter({ config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), transport, - }) as OpenClawGatewayRuntime & { transport: OpenClawTransport & { request: ReturnType } }; + }) 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 index 80b6940..a08c734 100644 --- a/packages/openclaw/src/connector.ts +++ b/packages/openclaw/src/connector.ts @@ -8,8 +8,6 @@ import { BridgeContext, BridgeRequestContext, BridgeUser, - type MatrixCommand, - type MatrixCommandResponse, ConnectContext, type ContactListingNetworkAPI, FetchMessagesParams, @@ -57,13 +55,28 @@ import { ResolveIdentifierResponse, UserLogin, } from "@beeper/pickle-bridge"; -import { backfillAllOpenClawSessions, buildBackfillImport, discoverOneToOneSessions } from "./backfill"; +import { buildBackfillImport } from "./backfill"; import { parseApprovalReactionContent, parseApprovalResponseContent } from "./approval"; -import { BeeperChannelRuntime, setBeeperChannelRuntime } from "./beeper-channel-runtime"; +import { + BEEPER_CHANNEL_RUNTIME_CONTEXT_CAPABILITY, + BeeperChannelRuntime, + setBeeperChannelRuntime, + setBeeperChannelRuntimeForHost, +} from "./beeper-channel-runtime"; import { agentPortalSessionKey, OpenClawMatrixBridgeAgent } from "./bridge-agent"; import { createDefaultConfig } from "./config"; import { parseMatrixTextMessage, type ParsedMatrixTextMessage } from "./matrix-parser"; -import { createOpenClawHostTransport, OpenClawGatewayRuntime, type OpenClawGatewayFeatureSnapshot, type OpenClawHostRuntime, type OpenClawMatrixMessageMetadata } from "./openclaw-runtime"; +import { + createOpenClawHostRuntimeAdapter, + type OpenClawBridgeRuntime, + OpenClawPluginRuntimeAdapter, + OpenClawHostRuntimeAdapter, + type OpenClawGatewayFeatureSnapshot, + type OpenClawHostRuntime, + type OpenClawMatrixMessageMetadata, + type OpenClawRunRef, + type OpenClawSessionSendOptions, +} from "./openclaw-runtime"; import { OpenClawBridgeRegistry } from "./registry"; import { agentContactFromOpenClawAgent, agentGhostUserId, serviceBotUserId } from "./rooms"; import type { OpenClawAgentContact, OpenClawBridgeConfig, OpenClawSessionBinding, OpenClawUserContact } from "./types"; @@ -71,45 +84,11 @@ import type { OpenClawAgentContact, OpenClawBridgeConfig, OpenClawSessionBinding const DEFAULT_NEW_SESSION_LABEL = "New OpenClaw Session"; const MATRIX_HTML_FORMAT = "org.matrix.custom.html"; -type CommandReply = { - html?: string; - text: string; -}; - -type CommandSection = { - entries: Array<[string, string | number | boolean | undefined]>; - title: string; -}; - -type SupportedCommandSpec = { - command: string; - description: string; -}; - -const SUPPORTED_COMMON_COMMANDS: SupportedCommandSpec[] = [ - { command: "/help", description: "Show supported OpenClaw commands." }, - { command: "/commands", description: "List supported OpenClaw commands." }, - { command: "/status", description: "Show bridge and current room status." }, - { command: "/settings", description: "Show Beeper bridge settings." }, - { command: "/tools [compact|verbose]", description: "List available runtime tools." }, - { command: "/models", description: "List configured OpenClaw models." }, - { command: "/tasks", description: "List recent OpenClaw tasks." }, - { command: "/sessions", description: "List importable one-to-one OpenClaw sessions." }, - { command: "/backfill", description: "Queue backfill for the current session room." }, - { command: "/import", description: "Import supported OpenClaw session history." }, - { command: "/new [agent-id] [session label]", description: "Create a new OpenClaw session room." }, - { command: "/agent", description: "Show the agent bound to this room." }, - { command: "/stop", description: "Abort the active run in this room." }, - { command: "/abort", description: "Abort the active run in this room." }, - { command: "/approve ", description: "Approve a pending native approval request when enabled." }, - { command: "/deny ", description: "Deny a pending native approval request when enabled." }, -]; - export interface OpenClawConnectorOptions { config?: OpenClawBridgeConfig; registry?: OpenClawBridgeRegistry; - runtime?: OpenClawGatewayRuntime | OpenClawHostRuntime; - runtimeFactory?: (config: OpenClawBridgeConfig) => OpenClawGatewayRuntime; + runtime?: OpenClawPluginRuntimeAdapter | OpenClawHostRuntime; + runtimeFactory?: (config: OpenClawBridgeConfig) => OpenClawPluginRuntimeAdapter; } export function createOpenClawConnector(options: OpenClawConnectorOptions = {}): OpenClawBridgeConnector { @@ -119,16 +98,21 @@ export function createOpenClawConnector(options: OpenClawConnectorOptions = {}): export class OpenClawBridgeConnector implements BridgeConnector { readonly config: OpenClawBridgeConfig; readonly registry: OpenClawBridgeRegistry; - readonly runtime: OpenClawGatewayRuntime | undefined; - #runtimeFactory: (config: OpenClawBridgeConfig) => OpenClawGatewayRuntime; + readonly runtime: OpenClawPluginRuntimeAdapter | undefined; + readonly #hostRuntime: OpenClawHostRuntime | undefined; + #channelRuntime: BeeperChannelRuntime | undefined; + #runtimeFactory: (config: OpenClawBridgeConfig) => OpenClawPluginRuntimeAdapter; constructor(options: OpenClawConnectorOptions = {}) { this.config = options.config ?? createDefaultConfig(); this.registry = options.registry ?? new OpenClawBridgeRegistry(); - const runtime = options.runtime instanceof OpenClawGatewayRuntime + this.#hostRuntime = options.runtime && !(options.runtime instanceof OpenClawPluginRuntimeAdapter) + ? options.runtime + : undefined; + const runtime = options.runtime instanceof OpenClawPluginRuntimeAdapter ? options.runtime - : options.runtime - ? new OpenClawGatewayRuntime({ config: this.config, transport: createOpenClawHostTransport(options.runtime) }) + : this.#hostRuntime + ? new OpenClawPluginRuntimeAdapter({ config: this.config, transport: createOpenClawHostRuntimeAdapter(this.#hostRuntime) }) : undefined; this.runtime = runtime; this.#runtimeFactory = @@ -139,6 +123,10 @@ export class OpenClawBridgeConnector implements BridgeConnector { - const name = command.command.startsWith("/") ? command.command.slice(1).toLowerCase() : command.command.toLowerCase(); - switch (name) { - case "help": - case "commands": - return commandResponse(commandsReply()); - case "status": - return commandResponse(await bridgeStatusReply(this.config, this.registry.data.bindings.length, undefined, this.runtime)); - case "settings": - return commandResponse(bridgeSettingsReply(this.config, this.registry.data.bindings.length)); - case "tools": { - const runtime = this.#runtimeFactory(this.config); - return commandResponse(await toolsReply(runtime, command.args.join(" "))); - } - case "models": { - const runtime = this.#runtimeFactory(this.config); - return commandResponse(await modelsReply(runtime)); - } - case "tasks": { - const runtime = this.#runtimeFactory(this.config); - return commandResponse(await tasksReply(runtime, undefined)); - } - case "sessions": { - const runtime = this.#runtimeFactory(this.config); - const options: Parameters[1] = {}; - if (this.config.importSources !== undefined) options.importSources = this.config.importSources; - const sessions = await discoverOneToOneSessions(runtime, options); - return commandResponse(sessionsSummaryReply(sessions)); - } - case "import": { - const runtime = this.#runtimeFactory(this.config); - const importOptions: Parameters[0] = { - bridge: ctx.bridge, - login: userLoginFromOpenClawConfig(this.config), - registry: this.registry, - runtime, - }; - if (this.config.importSources !== undefined) importOptions.importSources = this.config.importSources; - if (this.config.backfillLimit !== undefined) importOptions.limit = this.config.backfillLimit; - const result = await backfillAllOpenClawSessions(importOptions); - return commandResponse(importSummaryReply(result)); - } - case "backfill": - return commandResponse(simpleReply("Usage", "Use /backfill inside an OpenClaw session room.")); - case "new": - return commandResponse(simpleReply("Usage", "Use /new inside an OpenClaw session room.")); - case "agent": - return commandResponse(simpleReply("Usage", "Use /agent inside an OpenClaw session room.")); - case "approve": - case "deny": - return commandResponse(simpleReply("Approvals", "Approval slash commands are disabled for this bridge.")); - case "stop": - case "abort": { - const runtime = this.#runtimeFactory(this.config); - await runtime.abortSession({}); - return { handled: true }; - } - default: - return { handled: false }; - } - } - getBridgeInfoVersion() { return { capabilities: 1, info: 1 }; } @@ -248,7 +174,7 @@ export class OpenClawBridgeConnector implements BridgeConnector this.registry.data.agents, @@ -257,7 +183,11 @@ export class OpenClawBridgeConnector implements BridgeConnector ctx.log(level, message, data), ...(ownUserId ? { userId: ownUserId } : {}), - })); + }); + this.#channelRuntime = channelRuntime; + setBeeperChannelRuntime(channelRuntime); + if (this.#hostRuntime) setBeeperChannelRuntimeForHost(this.#hostRuntime, channelRuntime); + registerBeeperRuntimeContext(this.#hostRuntime, channelRuntime); } async start(ctx: BridgeContext): Promise { @@ -280,8 +210,20 @@ export class OpenClawBridgeConnector implements BridgeConnector { + 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, MessageHandlingNetworkAPI, EditHandlingNetworkAPI, ReactionHandlingNetworkAPI, ReactionRemoveHandlingNetworkAPI, RedactionHandlingNetworkAPI, ReadReceiptHandlingNetworkAPI, MarkedUnreadHandlingNetworkAPI, TypingHandlingNetworkAPI, RoomNameHandlingNetworkAPI, RoomTopicHandlingNetworkAPI, RoomAvatarHandlingNetworkAPI, MembershipHandlingNetworkAPI, DeleteChatHandlingNetworkAPI, BackfillingNetworkAPI { @@ -289,13 +231,14 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor readonly #config: OpenClawBridgeConfig; readonly #login: UserLogin; readonly #registry: OpenClawBridgeRegistry; - readonly #runtime: OpenClawGatewayRuntime; + readonly #runtime: OpenClawBridgeRuntime; constructor(options: { config: OpenClawBridgeConfig; login: UserLogin; registry: OpenClawBridgeRegistry; - runtime: OpenClawGatewayRuntime; + runtime: OpenClawBridgeRuntime; + sendTurn?: (options: OpenClawSessionSendOptions) => Promise; }) { this.#config = options.config; this.#login = options.login; @@ -304,6 +247,7 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor this.#agent = new OpenClawMatrixBridgeAgent({ registry: options.registry, runtime: options.runtime, + ...(options.sendTurn ? { sendTurn: options.sendTurn } : {}), }); } @@ -408,17 +352,7 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor } const parsed = parseMatrixTextMessage(msg.text, msg.content, msg); if (msg.portal.mxid) { - if (parsed.command?.name === "stop" || parsed.command?.name === "abort") { - const abortOptions: { runId?: string; sessionKey?: string } = {}; - if (currentBinding?.lastRunId) abortOptions.runId = currentBinding.lastRunId; - if (currentBinding?.sessionKey) abortOptions.sessionKey = currentBinding.sessionKey; - await this.#runtime.abortSession(abortOptions); - return { pending: false }; - } if (currentBinding) this.registerCanonicalPortalForBinding(ctx, msg.portal, currentBinding); - if (parsed.command) { - return await this.handleSlashCommand(ctx, parsed.command, currentBinding, msg); - } if (!currentBinding) { ctx.log?.("warn", "openclaw_matrix_message_unbound_room", { portalId: msg.portal.id, @@ -657,141 +591,6 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor }; } - async handleSlashCommand( - ctx: BridgeRequestContext, - command: NonNullable, - binding: OpenClawSessionBinding | undefined, - msg: MatrixMessage, - ): Promise { - const notice = (reply: CommandReply | string, noticeBinding = binding) => - commandNotice(ctx, this.#config, this.#login, msg, reply, noticeBinding); - switch (command.name) { - case "help": - case "commands": - return notice(commandsReply()); - case "status": - return notice(await bridgeStatusReply(this.#runtime.config, this.#registry.data.bindings.length, binding, this.#runtime)); - case "settings": - return notice(bridgeSettingsReply(this.#runtime.config, this.#registry.data.bindings.length)); - case "tools": - return notice(await toolsReply(this.#runtime, command.args)); - case "models": - return notice(await modelsReply(this.#runtime)); - case "tasks": - return notice(await tasksReply(this.#runtime, binding)); - case "sessions": { - const options: Parameters[1] = {}; - if (this.#runtime.config.importSources !== undefined) options.importSources = this.#runtime.config.importSources; - const sessions = await discoverOneToOneSessions(this.#runtime, options); - return notice(sessionsSummaryReply(sessions)); - } - case "backfill": - const count = await this.backfillCurrentRoom(ctx, binding, msg); - return notice(simpleReply("Backfill", `Queued backfill for ${count} message${count === 1 ? "" : "s"}.`)); - case "import": { - const importOptions: Parameters[0] = { - bridge: ctx.bridge, - login: this.#login, - registry: this.#registry, - runtime: this.#runtime, - }; - if (this.#runtime.config.importSources !== undefined) importOptions.importSources = this.#runtime.config.importSources; - if (this.#runtime.config.backfillLimit !== undefined) importOptions.limit = this.#runtime.config.backfillLimit; - const result = await backfillAllOpenClawSessions(importOptions); - return notice(importSummaryReply(result)); - } - case "new": { - const request = this.resolveNewSessionCommand(command.args, binding); - if (!request) { - return notice(simpleReply("Usage", "Usage: /new [agent-id] [session label]. In an agent DM, /new [session label] is enough.")); - } - if (!binding && msg.portal.mxid) { - const created = await this.createBindingForMatrixRoom(msg.portal.mxid, request.label, request.agentId, request.ghostUserId); - this.registerCanonicalPortalForBinding(ctx, msg.portal, created); - return notice(simpleReply("New Session", `Created a new OpenClaw session in this room: ${created.sessionKey}`), created); - } - const session = await this.#runtime.createSession({ - agentId: request.agentId, - key: newBeeperSessionKey(request.agentId), - label: request.label, - }); - const portalOptions: Parameters[1] = { - id: portalIdForSession(session.key), - metadata: { - openclaw: stripUndefined({ - agentId: request.agentId, - ghostUserId: request.ghostUserId, - sessionKey: session.key, - }), - }, - name: request.label, - roomType: "dm", - }; - const creationContent = openClawPortalCreationContent(this.#runtime.config); - if (creationContent) portalOptions.creationContent = creationContent; - const portal = await ctx.bridge.createPortal(this.#login, portalOptions); - if (portal.mxid) { - this.#registry.upsertBinding({ - agentId: request.agentId, - createdAt: Date.now(), - ghostUserId: request.ghostUserId, - id: Buffer.from(portal.mxid).toString("base64url"), - kind: "session", - label: request.label, - owner: "bridge", - roomId: portal.mxid, - sessionKey: session.key, - updatedAt: Date.now(), - }); - } - await this.#registry.save(); - return notice(simpleReply("New Session", portal.mxid - ? `Created a new OpenClaw session room: ${portal.mxid}` - : `Created a new OpenClaw session: ${session.key}`)); - } - case "approve": - case "deny": { - if (!approvalSlashEnabled(this.#runtime.config)) { - return notice(simpleReply("Approvals", "Approval slash commands are disabled for this bridge.")); - } - const approvalId = command.args.trim() || approvalIdFromMatrixReply(msg); - if (!approvalId) return notice(simpleReply("Usage", `Usage: /${command.name} or reply to an approval message with /${command.name}`)); - await this.#agent.handleApprovalContent({ - approvalId, - approved: command.name === "approve", - approvedAlways: false, - type: "tool-approval-response", - }, approvalId); - return notice(simpleReply("Approvals", `${command.name === "approve" ? "Approved" : "Denied"} ${approvalId}.`)); - } - case "agent": - return notice(simpleReply("Agent", binding ? `Agent: ${binding.agentId}` : "This room is not bound to an OpenClaw agent yet.")); - default: - return notice(simpleReply("Unknown Command", `Unknown OpenClaw command: /${command.name}`)); - } - } - - async backfillCurrentRoom(ctx: BridgeRequestContext, binding: OpenClawSessionBinding | undefined, msg: MatrixMessage): Promise { - const roomId = msg.portal.mxid; - if (!binding || !roomId) return 0; - const importOptions: { limit?: number; roomId: string } = { roomId }; - if (this.#runtime.config.backfillLimit !== undefined) importOptions.limit = this.#runtime.config.backfillLimit; - const imported = await buildBackfillImport(this.#runtime, this.#runtime.config, { - agentId: binding.agentId, - label: binding.label ?? binding.sessionKey, - session: { key: binding.sessionKey }, - sessionKey: binding.sessionKey, - source: binding.owner === "imported" ? "unknown" : "channel", - }, importOptions); - if (imported.human) this.#registry.upsertUser(imported.human); - this.#registry.upsertBinding(imported.binding); - const backfillOptions: { limit?: number } = {}; - if (this.#runtime.config.backfillLimit !== undefined) backfillOptions.limit = this.#runtime.config.backfillLimit; - await ctx.bridge.backfillPortal(this.#login, msg.portal, backfillOptions); - await this.#registry.save(); - return imported.messages.length; - } - isAllowedMatrixIngress(roomId: string | undefined, sender: string | undefined): boolean { if (!this.isAllowedRoom(roomId)) return false; if (!this.isAllowedUser(sender)) return false; @@ -911,33 +710,6 @@ function newBeeperSessionKey(agentId: string): string { return `agent:${agentId}:beeper:${randomUUID()}`; } -async function commandNotice( - ctx: BridgeRequestContext, - config: OpenClawBridgeConfig, - login: UserLogin, - msg: MatrixMessage, - reply: CommandReply | string, - binding: OpenClawSessionBinding | undefined, -): Promise { - const formatted = normalizeCommandReply(reply); - const portalKey = canonicalPortalKeyForBinding(binding, login.id) ?? msg.portal.portalKey; - ctx.queueRemoteEvent(login, createRemoteMessage({ - convert: () => ({ - parts: [{ content: commandContent(formatted), id: "body", type: "m.text" }], - }), - data: { text: formatted.text }, - id: `${msg.event.eventId}:openclaw-command`, - portalKey, - sender: { - isFromMe: true, - sender: binding?.ghostUserId ?? serviceBotUserId(config), - }, - timestamp: new Date(), - })); - await ctx.bridge?.flushRemoteEvents?.(); - return { pending: false }; -} - function canonicalPortalForBinding(portal: Portal, binding: OpenClawSessionBinding, receiver: string): Portal { const id = portalIdForSession(binding.sessionKey); return { @@ -960,273 +732,6 @@ function canonicalPortalForBinding(portal: Portal, binding: OpenClawSessionBindi }; } -function canonicalPortalKeyForBinding(binding: OpenClawSessionBinding | undefined, receiver: string): PortalKey | undefined { - if (!binding) return undefined; - return { id: portalIdForSession(binding.sessionKey), receiver }; -} - -function commandResponse(reply: CommandReply | string): MatrixCommandResponse { - const formatted = normalizeCommandReply(reply); - return { - content: commandContent(formatted), - handled: true, - text: formatted.text, - }; -} - -function commandContent(reply: CommandReply): Record { - return stripUndefined({ - body: reply.text, - format: reply.html ? MATRIX_HTML_FORMAT : undefined, - formatted_body: reply.html, - msgtype: "m.text", - }); -} - -function normalizeCommandReply(reply: CommandReply | string): CommandReply { - return typeof reply === "string" ? textReply(reply) : reply; -} - -function textReply(text: string): CommandReply { - return { - html: htmlLines(text.split("\n")), - text, - }; -} - -function simpleReply(title: string, text: string): CommandReply { - return { - html: htmlLines([`${escapeMatrixHtml(title)}`, "", ...text.split("\n").map(escapeMatrixHtml)], { escaped: true }), - text, - }; -} - -function sectionsReply(title: string, sections: CommandSection[]): CommandReply { - const text = [ - title, - ...sections.flatMap((section) => [ - "", - section.title, - ...section.entries.map(([label, value]) => `${label}: ${formatCommandValue(value)}`), - ]), - ].join("\n"); - const html = htmlLines([ - `${escapeMatrixHtml(title)}`, - ...sections.flatMap((section) => [ - "", - `${escapeMatrixHtml(section.title)}`, - ...section.entries.map(([label, value]) => - `${escapeMatrixHtml(label)}: ${escapeMatrixHtml(formatCommandValue(value))}`), - ]), - ], { escaped: true }); - return { html, text }; -} - -async function bridgeStatusReply( - config: OpenClawBridgeConfig, - boundRooms: number, - binding: OpenClawSessionBinding | undefined, - runtime: OpenClawGatewayRuntime | undefined, -): Promise { - const snapshot = runtime ? await safeFeatureSnapshot(runtime) : undefined; - const status = recordValue(snapshot?.status); - const health = recordValue(snapshot?.health); - const models = arrayFromResponse(snapshot?.models, "models"); - const commands = arrayFromResponse(snapshot?.commands, "commands"); - const tasks = arrayFromResponse(snapshot?.tasks, "tasks"); - const tools = arrayFromResponse(snapshot?.tools, "tools"); - const usage = recordValue(snapshot?.usage); - const sections: CommandSection[] = [ - { - title: "Bridge", - entries: [ - ["Runtime", "OpenClaw plugin"], - ["Gateway", statusTextFromRecord(status) ?? statusTextFromRecord(health) ?? "available"], - ["Beeper environment", config.beeperEnv ?? "production"], - ["Homeserver", config.homeserver ?? "not configured"], - ["Registration URL", config.registrationUrl ?? "not configured"], - ["Bound rooms", boundRooms], - ], - }, - { - title: "Room", - entries: [ - ["Session key", binding?.sessionKey ?? "not bound"], - ["Agent", binding?.agentId ?? "not bound"], - ["Ghost", binding?.ghostUserId ?? "service bot"], - ["Last run", binding?.lastRunId ?? "none"], - ["Last stream run", binding?.lastStreamRunId ?? "none"], - ], - }, - { - title: "Runtime", - entries: [ - ["Models", models ? models.length : "unknown"], - ["Commands", commands ? commands.length : "unknown"], - ["Tools", tools ? tools.length : "unknown"], - ["Tasks", tasks ? tasks.length : "unknown"], - ["Usage", usageSummary(usage) ?? "unknown"], - ], - }, - { - title: "Behavior", - entries: [ - ["Import sources", (config.importSources ?? []).join(", ") || "none"], - ["Approvals", describeApprovalBehavior(config.approvalBehavior)], - ["Stream finalization", config.streamFinalization ?? "replace"], - ["Backfill limit", config.backfillLimit ?? "default"], - ["Contact visibility", config.contactVisibility ?? "agents"], - ], - }, - ]; - return sectionsReply("OpenClaw Beeper status", sections); -} - -function bridgeSettingsReply(config: OpenClawBridgeConfig, boundRooms: number): CommandReply { - return sectionsReply("OpenClaw Beeper settings", [{ - title: "Bridge", - entries: [ - ["Beeper environment", config.beeperEnv ?? "production"], - ["Homeserver", config.homeserver ?? "not configured"], - ["Registration URL", config.registrationUrl ?? "not configured"], - ["Runtime", "OpenClaw plugin"], - ["Bridge manager token", config.bridgeManagerToken ? "configured" : "not configured"], - ["Post bridge state", config.bridgeManagerPostState === undefined ? "default" : config.bridgeManagerPostState ? "enabled" : "disabled"], - ["Import sources", (config.importSources ?? []).join(", ") || "none"], - ["Backfill limit", config.backfillLimit ?? "default"], - ["Contact visibility", config.contactVisibility ?? "agents"], - ["Stream finalization", config.streamFinalization ?? "replace"], - ["Approvals", describeApprovalBehavior(config.approvalBehavior)], - ["Non-federated rooms", config.nonFederatedRooms ? "yes" : "no"], - ["Allowed rooms", config.allowedRoomIds?.length ? config.allowedRoomIds.join(", ") : "all"], - ["Allowed users", config.allowedUserIds?.length ? config.allowedUserIds.join(", ") : "all"], - ["Bound rooms", boundRooms], - ], - }]); -} - -function commandsReply(): CommandReply { - const text = [ - "OpenClaw commands", - "", - ...SUPPORTED_COMMON_COMMANDS.map((command) => `${command.command} - ${command.description}`), - ].join("\n"); - const html = [ - "OpenClaw Commands", - "", - ...SUPPORTED_COMMON_COMMANDS.map((command) => - `${escapeMatrixHtml(command.command)} - ${escapeMatrixHtml(command.description)}`), - ]; - return { html: htmlLines(html, { escaped: true }), text }; -} - -function htmlLines(lines: string[], options: { escaped?: boolean } = {}): string { - return lines - .map((line) => options.escaped ? line : escapeMatrixHtml(line)) - .join("
"); -} - -async function toolsReply(runtime: OpenClawGatewayRuntime, args: string): Promise { - const mode = args.trim().toLowerCase(); - if (mode && mode !== "compact" && mode !== "verbose") { - return simpleReply("Usage", "Usage: /tools [compact|verbose]"); - } - const result = await safeRuntimeCall(() => runtime.listTools()); - const tools = arrayFromResponse(result, "tools") ?? []; - if (tools.length === 0) return simpleReply("Available Tools", "No runtime tools are available right now."); - const verbose = mode === "verbose"; - const entries = tools.slice(0, 80).map((tool, index) => { - const record = recordValue(tool); - const name = stringValue(record?.name) ?? stringValue(record?.id) ?? `tool-${index + 1}`; - const description = stringValue(record?.description) ?? stringValue(record?.label) ?? "available"; - return [name, verbose ? description : "available"] as [string, string]; - }); - return sectionsReply("Available Tools", [{ title: verbose ? "Verbose" : "Compact", entries }]); -} - -async function modelsReply(runtime: OpenClawGatewayRuntime): Promise { - const result = await safeRuntimeCall(() => runtime.listModels({ view: "configured" })); - const models = arrayFromResponse(result, "models") ?? []; - if (models.length === 0) return simpleReply("Models", "No configured models were returned by OpenClaw."); - return sectionsReply("Models", [{ - title: "Configured", - entries: models.slice(0, 80).map((model, index) => { - const record = recordValue(model); - const id = stringValue(record?.id) ?? stringValue(record?.model) ?? stringValue(record?.name) ?? (typeof model === "string" ? model : `model-${index + 1}`); - const provider = stringValue(record?.provider) ?? stringValue(record?.owner) ?? "available"; - return [id, provider]; - }), - }]); -} - -async function tasksReply(runtime: OpenClawGatewayRuntime, binding: OpenClawSessionBinding | undefined): Promise { - const result = await safeRuntimeCall(() => runtime.listTasks({ limit: 25, ...(binding?.sessionKey ? { ownerKey: binding.sessionKey } : {}) })); - const tasks = arrayFromResponse(result, "tasks") ?? []; - if (tasks.length === 0) return simpleReply("Tasks", "No recent OpenClaw tasks were returned."); - return sectionsReply("Tasks", [{ - title: "Recent", - entries: tasks.slice(0, 25).map((task, index) => { - const record = recordValue(task); - const id = stringValue(record?.id) ?? stringValue(record?.taskId) ?? `task-${index + 1}`; - const status = stringValue(record?.status) ?? stringValue(record?.state) ?? "unknown"; - return [id, status]; - }), - }]); -} - -async function safeFeatureSnapshot(runtime: OpenClawGatewayRuntime): Promise { - try { - return await runtime.featureSnapshot(); - } catch { - return undefined; - } -} - -async function safeRuntimeCall(call: () => Promise): Promise { - try { - return await call(); - } catch { - return undefined; - } -} - -function arrayFromResponse(response: unknown, key: string): unknown[] | undefined { - return arrayValue(recordValue(response)?.[key]) ?? arrayValue(response); -} - -function statusTextFromRecord(record: Record | undefined): string | undefined { - if (!record) return undefined; - return stringValue(record.status) - ?? stringValue(record.state) - ?? (record.ok === true ? "ok" : record.ok === false ? "not ok" : undefined); -} - -function usageSummary(usage: Record | undefined): string | undefined { - if (!usage) return undefined; - const summary = stringValue(usage.summary) ?? stringValue(usage.status); - if (summary) return summary; - const tokens = numberValue(usage.tokens) ?? numberValue(usage.totalTokens); - const cost = numberValue(usage.cost) ?? numberValue(usage.totalCost); - if (tokens !== undefined && cost !== undefined) return `${tokens} tokens, ${cost} cost`; - if (tokens !== undefined) return `${tokens} tokens`; - if (cost !== undefined) return `${cost} cost`; - return undefined; -} - -function formatCommandValue(value: string | number | boolean | undefined): string { - if (value === undefined || value === "") return "unknown"; - if (typeof value === "boolean") return value ? "yes" : "no"; - return String(value); -} - -function escapeMatrixHtml(value: string): string { - return value - .replace(/&/gu, "&") - .replace(//gu, ">") - .replace(/"/gu, """); -} - function describeApprovalBehavior(behavior: OpenClawBridgeConfig["approvalBehavior"]): string { switch (behavior ?? "native") { case "native": @@ -1240,10 +745,6 @@ function approvalReactionsEnabled(_config: OpenClawBridgeConfig): boolean { return false; } -function approvalSlashEnabled(_config: OpenClawBridgeConfig): boolean { - return false; -} - function approvalNativeEnabled(config: OpenClawBridgeConfig): boolean { return config.approvalBehavior === undefined || config.approvalBehavior === "native"; } @@ -1252,34 +753,6 @@ function openClawPortalCreationContent(config: OpenClawBridgeConfig): Record>): CommandReply { - if (sessions.length === 0) return simpleReply("Sessions", "No importable OpenClaw sessions found for the enabled import sources."); - return sectionsReply("Sessions", [{ - title: "Importable", - entries: sessions.slice(0, 20).map((session) => [session.label, session.source]), - }]); -} - -function importSummaryReply(result: Awaited>): CommandReply { - const imported = result.sessions.length; - const skipped = result.skipped.length; - if (imported === 0 && skipped === 0) return simpleReply("Import", "No importable OpenClaw sessions found for the enabled import sources."); - const reply = sectionsReply("Import", [{ - title: "Summary", - entries: [ - ["Imported", `${imported} OpenClaw session${imported === 1 ? "" : "s"}`], - ["Skipped", `${skipped} already imported or unavailable session${skipped === 1 ? "" : "s"}`], - ], - }]); - return { - ...reply, - text: [ - `Imported ${imported} OpenClaw session${imported === 1 ? "" : "s"}.`, - `Skipped ${skipped} already imported or unavailable session${skipped === 1 ? "" : "s"}.`, - ].join("\n"), - }; -} - function streamTargetRelationPatch( binding: OpenClawSessionBinding | undefined, targetEventId: string | undefined, @@ -1465,8 +938,21 @@ export function userLoginFromOpenClawConfig(config: OpenClawBridgeConfig): UserL }; } -export function createOpenClawRuntimeFromHost(runtime: OpenClawHostRuntime, config: OpenClawBridgeConfig): OpenClawGatewayRuntime { - return new OpenClawGatewayRuntime({ config, transport: createOpenClawHostTransport(runtime) }); +export function createOpenClawRuntimeAdapterFromHost(runtime: OpenClawHostRuntime, config: OpenClawBridgeConfig): OpenClawPluginRuntimeAdapter { + return new OpenClawPluginRuntimeAdapter({ config, transport: createOpenClawHostRuntimeAdapter(runtime) }); +} + +function registerBeeperRuntimeContext(hostRuntime: OpenClawHostRuntime | undefined, runtime: BeeperChannelRuntime): void { + const channel = recordValue(hostRuntime)?.channel; + const runtimeContexts = recordValue(channel)?.runtimeContexts; + const register = recordValue(runtimeContexts)?.register; + if (typeof register !== "function") return; + register.call(runtimeContexts, { + accountId: "default", + capability: BEEPER_CHANNEL_RUNTIME_CONTEXT_CAPABILITY, + channelId: "beeper", + context: runtime, + }); } function recordValue(value: unknown): Record | undefined { diff --git a/packages/openclaw/src/index.ts b/packages/openclaw/src/index.ts index 18874ff..693eea1 100644 --- a/packages/openclaw/src/index.ts +++ b/packages/openclaw/src/index.ts @@ -18,5 +18,4 @@ export * from "./registration"; export * from "./rooms"; export * from "./setup"; export * from "./setup-entry"; -export * from "./stream-map"; export * from "./types"; diff --git a/packages/openclaw/src/integration.test.ts b/packages/openclaw/src/integration.test.ts index 961b39b..77fa2c4 100644 --- a/packages/openclaw/src/integration.test.ts +++ b/packages/openclaw/src/integration.test.ts @@ -6,7 +6,7 @@ import { resolve } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { createDefaultConfig } from "./config"; import { createOpenClawConnector, userLoginFromOpenClawConfig } from "./connector"; -import { OpenClawGatewayRuntime, type OpenClawGatewayEvent, type OpenClawTransport } from "./openclaw-runtime"; +import { OpenClawPluginRuntimeAdapter, type OpenClawGatewayEvent, type OpenClawRuntimeRequestSurface } from "./openclaw-runtime"; import { OpenClawBridgeRegistry } from "./registry"; describe("OpenClaw bridge integration", () => { @@ -21,14 +21,16 @@ describe("OpenClaw bridge integration", () => { responses: { "agents.list": { agents: [{ id: "codex", name: "Codex" }] }, "sessions.create": { key: "session_1" }, - "sessions.send": { runId: "run_1", sessionKey: "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: () => new OpenClawGatewayRuntime({ config, transport }), + runtimeFactory: () => runtime, }); const client = createFakeMatrixClient(); const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, client); @@ -64,12 +66,12 @@ describe("OpenClaw bridge integration", () => { expect(transport.request).toHaveBeenCalledWith("sessions.create", { agentId: "codex", }); - expect(transport.request).toHaveBeenCalledWith("sessions.send", { + expect(runtime.sendMessage).toHaveBeenCalledWith({ idempotencyKey: "$hello", - key: "session_1", matrix: { roomId: "!codex:example", sender: "@alice:example" }, message: "hello", - }, { expectFinal: false }); + sessionKey: "session_1", + }); expect(registry.getBindingByRoom("!codex:example")).toMatchObject({ lastMatrixEventId: "$hello", lastRunId: "run_1", @@ -91,10 +93,12 @@ describe("OpenClaw bridge integration", () => { }, }); 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: () => new OpenClawGatewayRuntime({ config, transport }), + runtimeFactory: () => runtime, }); const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, createFakeMatrixClient()); const login = userLoginFromOpenClawConfig(config); @@ -144,14 +148,16 @@ describe("OpenClaw bridge integration", () => { const transport = fakeTransport({ responses: { "agents.list": { agents: [{ id: "codex", name: "Codex" }] }, - "sessions.send": { runId: "run_relation", sessionKey: "agent:codex:session_1" }, + "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: () => new OpenClawGatewayRuntime({ config, transport }), + runtimeFactory: () => runtime, }); const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, createFakeMatrixClient()); const login = userLoginFromOpenClawConfig(config); @@ -203,33 +209,31 @@ describe("OpenClaw bridge integration", () => { unread: true, }))).resolves.toMatchObject({ dispatched: true, handlers: 1, kind: "accountData" }); - expect(transport.request).toHaveBeenCalledWith("sessions.send", expect.objectContaining({ + 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" }, - }), { expectFinal: false }); - expect(transport.request).toHaveBeenCalledWith("sessions.send", expect.objectContaining({ + })); + 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" }, - }), { expectFinal: false }); - expect(transport.request).toHaveBeenCalledWith("sessions.send", expect.objectContaining({ + })); + 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" }, - }), { expectFinal: false }); - const sessionSendPayloads = transport.request.mock.calls - .filter(([method]) => method === "sessions.send") - .map(([, payload]) => payload); + })); + 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" }), @@ -253,15 +257,17 @@ describe("OpenClaw bridge integration", () => { "exec.approval.resolve": { ok: true }, "sessions.create": { key: "session_1" }, "sessions.list": { sessions: [{ displayName: "Desktop chat", key: "agent:codex:desktop", origin: { surface: "mac-app" } }] }, - "sessions.send": { runId: "run_1", sessionKey: "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: () => new OpenClawGatewayRuntime({ config, transport }), + runtimeFactory: () => runtime, }); const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, client); const login = userLoginFromOpenClawConfig(config); @@ -327,22 +333,18 @@ describe("OpenClaw bridge integration", () => { roomId: "!created:example", sender: "@alice:example", }))).resolves.toMatchObject({ dispatched: true }); - expect(client.appservice.batchSend).toHaveBeenCalledWith(expect.objectContaining({ - events: expect.any(Array), - roomId: "!created:example", + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$import", + message: "/import", + sessionKey: "session_1", })); - expect(registry.getBindingBySessionKey("agent:codex:desktop")).toMatchObject({ - label: "Desktop chat", - owner: "imported", - roomId: "!created:example", - }); }); }); function fakeTransport(options: { events?: OpenClawGatewayEvent[]; responses: Record; -}): OpenClawTransport & { request: ReturnType } { +}): OpenClawRuntimeRequestSurface & { request: ReturnType; responses: Record } { return { async *events(filter?: (event: OpenClawGatewayEvent) => boolean) { for (const event of options.events ?? []) { @@ -350,6 +352,7 @@ function fakeTransport(options: { } }, request: vi.fn(async (method: string) => options.responses[method]), + responses: options.responses, }; } diff --git a/packages/openclaw/src/openclaw-extension.test.ts b/packages/openclaw/src/openclaw-extension.test.ts index 1a692d2..d6ea166 100644 --- a/packages/openclaw/src/openclaw-extension.test.ts +++ b/packages/openclaw/src/openclaw-extension.test.ts @@ -1,6 +1,6 @@ import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import extension, { openClawBeeperPlugin } from "./openclaw-extension"; describe("OpenClaw plugin package metadata", () => { @@ -17,7 +17,7 @@ describe("OpenClaw plugin package metadata", () => { }, }); expect(extension.id).toBe("beeper"); - expect(extension.kind).toBe("bundled-channel-entry"); + expect(extension.channelPlugin).toBe(registered[0]); expect(extension.loadChannelPlugin()).toBe(registered[0]); expect(resolveBundledRuntimeChannelRegistration(extension)).toMatchObject({ id: "beeper", @@ -30,7 +30,7 @@ describe("OpenClaw plugin package metadata", () => { expect.objectContaining({ capabilities: expect.objectContaining({ reactions: true, - threads: false, + threads: true, }), id: "beeper", message: expect.objectContaining({ @@ -42,12 +42,28 @@ describe("OpenClaw plugin package metadata", () => { setup: expect.any(Object), setupWizard: expect.any(Object), }), - expect.objectContaining({ - id: "beeper", - }), ]); }); + 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[]; @@ -134,7 +150,6 @@ describe("OpenClaw plugin package metadata", () => { "senderLocalpart", "serviceBotLocalpart", "storePath", - "streamFinalization", "userLocalpartPrefix", ]); expect(manifest.channelConfigs?.beeper).toMatchObject({ @@ -196,11 +211,10 @@ function resolveBundledRuntimeChannelRegistration(moduleExport: unknown): { id?: if (!resolved || typeof resolved !== "object") return {}; const entry = resolved as { id?: unknown; - kind?: unknown; + channelPlugin?: unknown; loadChannelPlugin?: unknown; }; if ( - entry.kind !== "bundled-channel-entry" || typeof entry.id !== "string" || typeof entry.loadChannelPlugin !== "function" ) { @@ -208,7 +222,7 @@ function resolveBundledRuntimeChannelRegistration(moduleExport: unknown): { id?: } return { id: entry.id, - plugin: entry.loadChannelPlugin(), + plugin: entry.channelPlugin ?? entry.loadChannelPlugin(), }; } diff --git a/packages/openclaw/src/openclaw-extension.ts b/packages/openclaw/src/openclaw-extension.ts index ea3cacc..6dd5808 100644 --- a/packages/openclaw/src/openclaw-extension.ts +++ b/packages/openclaw/src/openclaw-extension.ts @@ -1,4 +1,7 @@ -import { beeperChannelPlugin } from "./setup"; +import { defineChannelPluginEntry } from "openclaw/plugin-sdk/channel-core"; +import { BeeperChannelConfigSchema, beeperChannelPlugin } from "./setup"; + +const startBeeperGatewayAccount = beeperChannelPlugin.gateway.startAccount; export interface OpenClawPluginApi { runtime?: unknown; @@ -8,33 +11,50 @@ export interface OpenClawPluginApi { }; } -export const openClawBeeperPlugin = { +const sdkEntry = defineChannelPluginEntry({ id: "beeper", - kind: "bundled-channel-entry", name: "Beeper", description: "Bridge OpenClaw sessions and agents into Beeper.", plugin: beeperChannelPlugin, + configSchema: BeeperChannelConfigSchema as never, + setRuntime: setBeeperChannelRuntime, +} as never) as { + configSchema: unknown; + description: string; + id: string; + name: string; + register: (api: unknown) => void; + setChannelRuntime?: (runtime: unknown) => void; +}; + +export const openClawBeeperPlugin: { + channelPlugin: typeof beeperChannelPlugin; + configSchema: unknown; + description: string; + id: string; + loadChannelPlugin: () => typeof beeperChannelPlugin; + name: string; + plugin: typeof beeperChannelPlugin; + register: (api: OpenClawPluginApi) => void; + setChannelRuntime?: (runtime: unknown) => void; +} = { + id: sdkEntry.id, + name: sdkEntry.name, + description: sdkEntry.description, + configSchema: sdkEntry.configSchema, + register: (api: OpenClawPluginApi) => sdkEntry.register(api), + ...(sdkEntry.setChannelRuntime ? { setChannelRuntime: sdkEntry.setChannelRuntime } : {}), + channelPlugin: beeperChannelPlugin, + plugin: beeperChannelPlugin, loadChannelPlugin: () => beeperChannelPlugin, - register(api: OpenClawPluginApi): void { - const plugin = beeperChannelPluginForRuntime(api.runtime); - api.registerChannel?.({ plugin }); - api.channels?.register?.(plugin); - }, } as const; export default openClawBeeperPlugin; -function beeperChannelPluginForRuntime(runtime: unknown): typeof beeperChannelPlugin { - if (!runtime || typeof runtime !== "object") return beeperChannelPlugin; - return { - ...beeperChannelPlugin, - gateway: { - ...beeperChannelPlugin.gateway, - startAccount: (ctx: Parameters[0]) => - beeperChannelPlugin.gateway.startAccount({ - ...ctx, - hostRuntime: runtime, - } as Parameters[0]), - }, - }; +function setBeeperChannelRuntime(runtime: unknown): void { + beeperChannelPlugin.gateway.startAccount = (ctx: Parameters[0]) => + startBeeperGatewayAccount({ + ...(ctx as Record), + hostRuntime: runtime, + } as Parameters[0]); } diff --git a/packages/openclaw/src/openclaw-runtime.test.ts b/packages/openclaw/src/openclaw-runtime.test.ts index 97179fc..c54cbf9 100644 --- a/packages/openclaw/src/openclaw-runtime.test.ts +++ b/packages/openclaw/src/openclaw-runtime.test.ts @@ -5,13 +5,13 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { BeeperChannelRuntime, setBeeperChannelRuntime } from "./beeper-channel-runtime"; import { createDefaultConfig } from "./config"; import { - createOpenClawHostTransport, - OpenClawGatewayRuntime, + createOpenClawHostRuntimeAdapter, + OpenClawPluginRuntimeAdapter, type OpenClawGatewayEvent, - type OpenClawTransport, + type OpenClawRuntimeRequestSurface, } from "./openclaw-runtime"; -describe("OpenClawGatewayRuntime", () => { +describe("OpenClawPluginRuntimeAdapter", () => { afterEach(() => { setBeeperChannelRuntime(undefined); }); @@ -20,7 +20,7 @@ describe("OpenClawGatewayRuntime", () => { const transport = fakeTransport({ "agents.list": { agents: [{ description: "Code", id: "codex", name: "Codex" }] }, }); - const runtime = new OpenClawGatewayRuntime({ + const runtime = new OpenClawPluginRuntimeAdapter({ config: createDefaultConfig({ dataDir: "/tmp/openclaw", homeserver: "https://matrix.example" }), transport, }); @@ -36,12 +36,11 @@ describe("OpenClawGatewayRuntime", () => { expect(transport.request).toHaveBeenCalledWith("agents.list", {}); }); - it("creates sessions and sends messages through OpenClaw RPC", async () => { + it("creates sessions through OpenClaw RPC and rejects generic Beeper sends", async () => { const transport = fakeTransport({ "sessions.create": { key: "agent:codex:main", sessionId: "session_1" }, - "sessions.send": { runId: "run_1", sessionKey: "agent:codex:main" }, }); - const runtime = new OpenClawGatewayRuntime({ + const runtime = new OpenClawPluginRuntimeAdapter({ config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), transport, }); @@ -53,50 +52,42 @@ describe("OpenClawGatewayRuntime", () => { raw: { key: "agent:codex:main", sessionId: "session_1" }, sessionId: "session_1", }); - await expect(runtime.sendMessage({ message: "hello", sessionKey: "agent:codex:main", timeoutMs: 1000 })).resolves.toEqual({ - raw: { runId: "run_1", sessionKey: "agent:codex:main" }, - runId: "run_1", - sessionKey: "agent:codex:main", - }); - expect(transport.request).toHaveBeenCalledWith("sessions.send", { - key: "agent:codex:main", - message: "hello", - timeoutMs: 1000, - }, { expectFinal: false, timeoutMs: 1000 }); + await expect(runtime.sendMessage({ message: "hello", sessionKey: "agent:codex:main", timeoutMs: 1000 })) + .rejects.toThrow("OpenClaw Beeper turns require OpenClaw channel turn helpers"); + expect(transport.request).not.toHaveBeenCalledWith("sessions.send", expect.anything(), expect.anything()); }); - it("exposes generic OpenClaw gateway feature RPC wrappers", async () => { + it("keeps management probes on the plugin runtime adapter without command wrappers", async () => { const transport = fakeTransport({ "artifacts.list": { artifacts: [{ id: "artifact_1" }] }, + "agents.list": { agents: [{ id: "codex" }] }, + "channels.status": { ok: true }, + "commands.list": { commands: [] }, + "config.get": { config: {} }, + "cron.list": { jobs: [] }, + "health": { ok: true }, "models.list": { models: ["gpt-5.4"] }, - "sessions.abort": { aborted: true }, - "sessions.steer": { runId: "run_steer", sessionKey: "agent:codex:main" }, + "sessions.list": { sessions: [] }, + "skills.status": { skills: [] }, + "status": { state: "ready" }, "tasks.cancel": { cancelled: true }, "tasks.list": { tasks: [] }, "tools.catalog": { tools: [{ name: "exec" }] }, - "tools.effective": { tools: [{ name: "read" }] }, - "tools.invoke": { ok: true }, + "usage.status": { tokens: 1 }, }); - const runtime = new OpenClawGatewayRuntime({ + const runtime = new OpenClawPluginRuntimeAdapter({ config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), transport, }); - await expect(runtime.listModels()).resolves.toEqual({ models: ["gpt-5.4"] }); - await expect(runtime.listTools()).resolves.toEqual({ tools: [{ name: "exec" }] }); - await expect(runtime.effectiveTools("agent:codex:main")).resolves.toEqual({ tools: [{ name: "read" }] }); - await expect(runtime.invokeTool({ name: "read", sessionKey: "agent:codex:main" })).resolves.toEqual({ ok: true }); - await expect(runtime.listTasks()).resolves.toEqual({ tasks: [] }); - await expect(runtime.cancelTask("task_1", "stale")).resolves.toEqual({ cancelled: true }); - await expect(runtime.listArtifacts({ sessionKey: "agent:codex:main" })).resolves.toEqual({ artifacts: [{ id: "artifact_1" }] }); - await expect(runtime.steerSession({ message: "actually do this", sessionKey: "agent:codex:main" })).resolves.toEqual({ - raw: { runId: "run_steer", sessionKey: "agent:codex:main" }, - runId: "run_steer", - sessionKey: "agent:codex:main", + await expect(runtime.featureSnapshot()).resolves.toMatchObject({ + health: { ok: true }, + models: { models: ["gpt-5.4"] }, + tools: { tools: [{ name: "exec" }] }, }); - await expect(runtime.abortSession({ runId: "run_steer" })).resolves.toEqual({ aborted: true }); + await expect(runtime.call("artifacts.list", { sessionKey: "agent:codex:main" })).resolves.toEqual({ artifacts: [{ id: "artifact_1" }] }); + await expect(runtime.call("tasks.cancel", { reason: "stale", taskId: "task_1" })).resolves.toEqual({ cancelled: true }); expect(transport.request).toHaveBeenCalledWith("tasks.cancel", { reason: "stale", taskId: "task_1" }, undefined); - expect(transport.request).toHaveBeenCalledWith("sessions.abort", { runId: "run_steer" }, undefined); }); it("filters gateway events by run id and resolves approvals", async () => { @@ -108,7 +99,7 @@ describe("OpenClawGatewayRuntime", () => { "exec.approval.resolve": { ok: true }, "plugin.approval.resolve": { plugin: true }, }, events); - const runtime = new OpenClawGatewayRuntime({ + const runtime = new OpenClawPluginRuntimeAdapter({ config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), transport, }); @@ -138,7 +129,7 @@ describe("OpenClawGatewayRuntime", () => { }, request: vi.fn(async (method: string) => ({ method, runId: "run_1" })), }; - const transport = createOpenClawHostTransport(host); + const transport = createOpenClawHostRuntimeAdapter(host); await expect(transport.request("exec.approval.resolve", { approvalId: "approval_1", decision: "approve" })).resolves.toEqual({ method: "exec.approval.resolve", @@ -160,17 +151,88 @@ describe("OpenClawGatewayRuntime", () => { const host = { request: vi.fn(async (method: string) => ({ method, runId: "host_run" })), }; - const transport = createOpenClawHostTransport({ + const transport = createOpenClawHostRuntimeAdapter({ ...host, config: { current: () => ({ agents: { list: [{ id: "main" }] } }) }, }); - await expect(transport.request("sessions.send", { key: "session", message: "hi" })).rejects.toThrow("OpenClaw Beeper requires OpenClaw channel turn helpers"); + await expect(transport.request("sessions.send", { key: "session", message: "hi" })).rejects.toThrow("OpenClaw Beeper turns require OpenClaw channel turn helpers"); expect(host.request).not.toHaveBeenCalled(); }); + it("sends host-backed Beeper turns through channel helpers without sessions.send RPC", async () => { + const beeperStreams = { + finalizeMessage: vi.fn(async () => ({ + eventId: "$stream-root", + raw: {}, + replacementEventId: "$stream-final", + roomId: "!room:example", + })), + publishPart: vi.fn(async () => undefined), + startMessage: vi.fn(async () => ({ + descriptor: { type: "com.beeper.llm" }, + eventId: "$stream-root", + roomId: "!room:example", + })), + }; + setBeeperChannelRuntime(new BeeperChannelRuntime({ + client: { + beeper: { aiRuns: createTestBeeperAIRuns(), streams: beeperStreams }, + media: { upload: vi.fn() }, + } as never, + userId: "@sh-openclaw-bot:example", + })); + 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 runAssembled = 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 runtime = new OpenClawPluginRuntimeAdapter({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + transport: createOpenClawHostRuntimeAdapter({ + request, + channel: { + reply: { dispatchReplyWithBufferedBlockDispatcher: vi.fn() }, + session: { + recordInboundSession: vi.fn(), + resolveStorePath: () => "/tmp/openclaw", + }, + turn: { + buildContext: vi.fn((params) => params), + runAssembled, + }, + }, + config: { current: () => ({ agents: { list: [{ id: "main" }] } }) }, + }), + }); + + const sent = await runtime.sendMessage({ + idempotencyKey: "$event", + matrix: { 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.toHaveBeenCalledWith("sessions.send", expect.anything(), expect.anything()); + expect(runAssembled).toHaveBeenCalledTimes(1); + expect(beeperStreams.startMessage).toHaveBeenCalledTimes(1); + expect(beeperStreams.finalizeMessage).toHaveBeenCalledWith(expect.objectContaining({ + body: "direct final", + roomId: "!room:example", + })); + }); + it("adapts OpenClaw plugin runtime helpers when no gateway request surface exists", async () => { - const transport = createOpenClawHostTransport({ + const transport = createOpenClawHostRuntimeAdapter({ agent: { session: { listSessionEntries: () => [ @@ -222,7 +284,28 @@ describe("OpenClawGatewayRuntime", () => { }); it("rejects Beeper-originated sends when the OpenClaw channel runtime is unavailable", async () => { - const transport = createOpenClawHostTransport({ + 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 turn helpers"); + }); + + it("does not expose Beeper-originated sends as host transport RPC", async () => { + const transport = createOpenClawHostRuntimeAdapter({ agent: { resolveAgentDir: () => "/tmp/agent", session: { @@ -239,7 +322,7 @@ describe("OpenClawGatewayRuntime", () => { key: "agent:main:beeper:room", message: "from Beeper", idempotencyKey: "$event", - })).rejects.toThrow("OpenClaw Beeper requires OpenClaw channel turn helpers"); + })).rejects.toThrow("OpenClaw Beeper turns require OpenClaw channel turn helpers"); }); it("runs Beeper-originated sends through OpenClaw channel turn helpers for live AG-UI progress", async () => { @@ -259,7 +342,7 @@ describe("OpenClawGatewayRuntime", () => { }; setBeeperChannelRuntime(new BeeperChannelRuntime({ client: { - beeper: { streams: beeperStreams }, + beeper: { aiRuns: createTestBeeperAIRuns(), streams: beeperStreams }, media: { upload: vi.fn() }, } as never, userId: "@sh-openclaw-bot:example", @@ -280,7 +363,7 @@ describe("OpenClawGatewayRuntime", () => { await delivery.deliver?.({ text: "hello world" }); return { dispatchResult: { queuedFinal: true } }; }); - const transport = createOpenClawHostTransport({ + const transport = createOpenClawHostRuntimeAdapter({ channel: { reply: { dispatchReplyWithBufferedBlockDispatcher: vi.fn(), @@ -315,8 +398,8 @@ describe("OpenClawGatewayRuntime", () => { if (received.some((event) => event.event === "run.completed")) break; } })(); - const sent = await transport.request("sessions.send", { - key: "agent:main:beeper:room", + const sent = await transport.sendMessage({ + sessionKey: "agent:main:beeper:room", message: "from Beeper", idempotencyKey: "$event", matrix: { roomId: "!room:example", sender: "@alice:example" }, @@ -396,7 +479,7 @@ describe("OpenClawGatewayRuntime", () => { }; setBeeperChannelRuntime(new BeeperChannelRuntime({ client: { - beeper: { streams: beeperStreams }, + beeper: { aiRuns: createTestBeeperAIRuns(), streams: beeperStreams }, media: { upload: vi.fn() }, } as never, userId: "@sh-openclaw-bot:example", @@ -415,7 +498,7 @@ describe("OpenClawGatewayRuntime", () => { await delivery.deliver?.({ text: "hello world" }, { kind: "final" }); return { dispatchResult: { queuedFinal: true } }; }); - const transport = createOpenClawHostTransport({ + const transport = createOpenClawHostRuntimeAdapter({ channel: { reply: { dispatchReplyWithBufferedBlockDispatcher: vi.fn() }, session: { recordInboundSession: vi.fn(), resolveStorePath: () => "/tmp/sessions.json" }, @@ -439,8 +522,8 @@ describe("OpenClawGatewayRuntime", () => { if (event.event === "run.completed") break; } })(); - await transport.request("sessions.send", { - key: "agent:main:beeper:room", + await transport.sendMessage({ + sessionKey: "agent:main:beeper:room", message: "from Beeper", matrix: { roomId: "!room:example", sender: "@alice:example" }, }); @@ -483,7 +566,7 @@ describe("OpenClawGatewayRuntime", () => { }; setBeeperChannelRuntime(new BeeperChannelRuntime({ client: { - beeper: { streams: beeperStreams }, + beeper: { aiRuns: createTestBeeperAIRuns(), streams: beeperStreams }, media: { upload: vi.fn() }, } as never, userId: "@sh-openclaw-bot:example", @@ -497,7 +580,7 @@ describe("OpenClawGatewayRuntime", () => { await delivery.deliver?.({ text: "hello world" }, { kind: "final" }); return { dispatchResult: { queuedFinal: true } }; }); - const transport = createOpenClawHostTransport({ + const transport = createOpenClawHostRuntimeAdapter({ channel: { reply: { dispatchReplyWithBufferedBlockDispatcher: vi.fn() }, session: { recordInboundSession: vi.fn(), resolveStorePath: () => "/tmp/sessions.json" }, @@ -529,8 +612,8 @@ describe("OpenClawGatewayRuntime", () => { if (event.event === "run.completed") break; } })(); - await transport.request("sessions.send", { - key: "agent:main:beeper:room", + await transport.sendMessage({ + sessionKey: "agent:main:beeper:room", message: "from Beeper", matrix: { roomId: "!room:example", sender: "@alice:example" }, }); @@ -551,7 +634,7 @@ describe("OpenClawGatewayRuntime", () => { 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 = createOpenClawHostTransport({ + const transport = createOpenClawHostRuntimeAdapter({ agent: { session: { getSessionEntry: () => ({ @@ -572,7 +655,7 @@ describe("OpenClawGatewayRuntime", () => { it("adapts plugin transcript lifecycle updates into runtime events", async () => { let listener: ((update: { sessionKey?: string; messageSeq?: number }) => void) | undefined; - const transport = createOpenClawHostTransport({ + const transport = createOpenClawHostRuntimeAdapter({ events: { onSessionTranscriptUpdate: (next) => { listener = next; @@ -601,7 +684,7 @@ describe("OpenClawGatewayRuntime", () => { }); }); -function fakeTransport(responses: Record, events: OpenClawGatewayEvent[] = []): OpenClawTransport & { +function fakeTransport(responses: Record, events: OpenClawGatewayEvent[] = []): OpenClawRuntimeRequestSurface & { request: ReturnType; } { return { @@ -613,3 +696,33 @@ function fakeTransport(responses: Record, events: OpenClawGatew 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" }, + ])), + }; +} diff --git a/packages/openclaw/src/openclaw-runtime.ts b/packages/openclaw/src/openclaw-runtime.ts index 7fe4ad2..abc9d3d 100644 --- a/packages/openclaw/src/openclaw-runtime.ts +++ b/packages/openclaw/src/openclaw-runtime.ts @@ -4,7 +4,7 @@ import path from "node:path"; import type { OpenClawAgentContact, OpenClawBridgeConfig } from "./types"; import { agentContactFromOpenClawAgent } from "./rooms"; import type { OpenClawApprovalResolvePayload } from "./approval"; -import { getBeeperChannelRuntime } from "./beeper-channel-runtime"; +import { getBeeperChannelRuntime, getBeeperChannelRuntimeForHost } from "./beeper-channel-runtime"; import { AGUIEventType, closeReasoningPart, @@ -17,9 +17,8 @@ import { mapOpenClawToolEnd, mapOpenClawToolInput, mapOpenClawToolOutput, - startRunEvents, -} from "./stream-map"; -import type { AGUIEvent } from "./stream-map"; +} from "./beeper-turn-events"; +import type { AGUIEvent } from "./beeper-turn-events"; export type GatewayRequestOptions = { expectFinal?: boolean; @@ -33,7 +32,7 @@ export type OpenClawGatewayEvent = { stateVersion?: unknown; }; -export interface OpenClawTransport { +export interface OpenClawRuntimeRequestSurface { close?(): Promise | void; events(filter?: (event: OpenClawGatewayEvent) => boolean): AsyncIterable; request(method: string, params?: unknown, options?: GatewayRequestOptions): Promise; @@ -220,11 +219,29 @@ export interface OpenClawChatHistoryMessage { [key: string]: unknown; } -export class OpenClawGatewayRuntime { +export interface OpenClawSessionHistoryRuntime { readonly config: OpenClawBridgeConfig; - readonly transport: OpenClawTransport; + listAgentContacts(): Promise; + listSessions(params?: Record): Promise; + loadHistory(sessionKey: string, limit?: number): Promise; +} + +export interface OpenClawSessionTurnRuntime extends OpenClawSessionHistoryRuntime { + createSession(options: OpenClawSessionCreateOptions): Promise; + resolveApproval(payload: OpenClawApprovalResolvePayload): Promise; + sendMessage(options: OpenClawSessionSendOptions): Promise; +} + +export interface OpenClawBridgeRuntime extends OpenClawSessionTurnRuntime { + close(): Promise; + featureSnapshot(): Promise; +} - constructor(options: { config: OpenClawBridgeConfig; transport: OpenClawTransport }) { +export class OpenClawPluginRuntimeAdapter { + readonly config: OpenClawBridgeConfig; + readonly transport: OpenClawRuntimeRequestSurface; + + constructor(options: { config: OpenClawBridgeConfig; transport: OpenClawRuntimeRequestSurface }) { this.config = options.config; this.transport = options.transport; } @@ -274,46 +291,6 @@ export class OpenClawGatewayRuntime { }); } - listModels(params: Record = { view: "configured" }): Promise { - return this.call("models.list", params); - } - - listTools(params: Record = {}): Promise { - return this.call("tools.catalog", params); - } - - effectiveTools(sessionKey: string): Promise { - return this.call("tools.effective", { sessionKey }); - } - - invokeTool(params: Record, options?: GatewayRequestOptions): Promise { - return this.call("tools.invoke", params, options); - } - - listTasks(params: Record = { limit: 100 }): Promise { - return this.call("tasks.list", params); - } - - getTask(taskId: string): Promise { - return this.call("tasks.get", { taskId }); - } - - cancelTask(taskId: string, reason?: string): Promise { - return this.call("tasks.cancel", stripUndefined({ reason, taskId })); - } - - listArtifacts(params: Record): Promise { - return this.call("artifacts.list", params); - } - - getArtifact(params: Record): Promise { - return this.call("artifacts.get", params); - } - - downloadArtifact(params: Record): Promise { - return this.call("artifacts.download", params); - } - async createSession(options: OpenClawSessionCreateOptions): Promise { const raw = await this.transport.request("sessions.create", stripUndefined({ agentId: options.agentId, @@ -385,44 +362,10 @@ export class OpenClawGatewayRuntime { async sendMessage(options: OpenClawSessionSendOptions): Promise { const requestOptions: GatewayRequestOptions = { expectFinal: false }; if (options.timeoutMs !== undefined) requestOptions.timeoutMs = options.timeoutMs; - const raw = await this.transport.request("sessions.send", { - 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 sessions.send did not return a runId"); - return { raw, runId, sessionKey: stringValue(record.sessionKey) ?? options.sessionKey }; - } - - async steerSession(options: OpenClawSessionSendOptions): Promise { - const requestOptions: GatewayRequestOptions = { expectFinal: false }; - if (options.timeoutMs !== undefined) requestOptions.timeoutMs = options.timeoutMs; - const raw = await this.transport.request("sessions.steer", { - key: options.sessionKey, - message: options.message, - ...(options.attachments ? { attachments: options.attachments } : {}), - ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}), - ...(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 sessions.steer did not return a runId"); - return { raw, runId, sessionKey: stringValue(record.sessionKey) ?? options.sessionKey }; - } - - abortSession(params: { runId?: string; sessionKey?: string }): Promise { - return this.call("sessions.abort", stripUndefined({ - key: params.sessionKey, - runId: params.runId, - })); + if (this.transport instanceof OpenClawHostRuntimeAdapter) { + return this.transport.sendMessage(options, requestOptions); + } + throw new Error("OpenClaw Beeper turns require OpenClaw channel turn helpers"); } async resolveApproval(payload: OpenClawApprovalResolvePayload): Promise { @@ -436,7 +379,7 @@ export class OpenClawGatewayRuntime { } } -export class OpenClawHostTransport implements OpenClawTransport { +export class OpenClawHostRuntimeAdapter implements OpenClawRuntimeRequestSurface { readonly #runtime: OpenClawHostRuntime; readonly #localEvents = new LocalEventBus(); @@ -448,6 +391,9 @@ export class OpenClawHostTransport implements OpenClawTransport { if (isDirectPluginRuntimeMethod(method)) { return this.#pluginRuntimeRequest(method, params, options); } + if (method === "sessions.send") { + return Promise.reject(new Error("OpenClaw Beeper turns require OpenClaw channel turn helpers")); + } const call = this.#runtime.request ?? this.#runtime.call; if (!call) return this.#pluginRuntimeRequest(method, params, options); return call(method, params, options); @@ -471,6 +417,23 @@ export class OpenClawHostTransport implements OpenClawTransport { 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 turn did not return a runId"); + return { raw, runId, sessionKey: stringValue(record.sessionKey) ?? options.sessionKey }; + } + async #pluginRuntimeRequest( method: string, params?: unknown, @@ -485,24 +448,21 @@ export class OpenClawHostTransport implements OpenClawTransport { return await createSessionInPluginRuntime(this.#runtime, params) as T; case "sessions.list": return { sessions: sessionsFromPluginRuntime(this.#runtime, params) } as T; - case "sessions.send": - return await sendSessionInPluginRuntime(this.#runtime, this.#localEvents, params, _options) as T; default: throw new Error(`OpenClaw plugin runtime does not expose request/call for ${method}`); } } } -export function createOpenClawHostTransport(runtime: OpenClawHostRuntime): OpenClawHostTransport { - return new OpenClawHostTransport(runtime); +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.list" - || method === "sessions.send"; + || method === "sessions.list"; } function arrayValue(value: unknown): unknown[] | undefined { @@ -782,8 +742,8 @@ async function sendSessionInPluginRuntime( const record = recordValue(params) ?? {}; const sessionKey = stringValue(record.key) ?? stringValue(record.sessionKey); const message = stringValue(record.message); - if (!sessionKey) throw new Error("OpenClaw plugin sessions.send requires key"); - if (!message) throw new Error("OpenClaw plugin sessions.send requires message"); + if (!sessionKey) throw new Error("OpenClaw channel turn requires session key"); + if (!message) throw new Error("OpenClaw channel turn requires message"); const agentId = agentIdFromSessionKey(sessionKey) ?? "main"; const resolved = resolvePluginSession(runtime, sessionKey, agentId); const entry = resolved.entry ?? {}; @@ -950,6 +910,7 @@ async function runBeeperChannelTurnInPluginRuntime(params: { 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, @@ -1073,6 +1034,7 @@ function forwardAgentRuntimeStreamEvents(params: { function createBeeperReplyStreamEmitter(base: { agentId: string; + hostRuntime?: OpenClawHostRuntime; localEvents: LocalEventBus; roomId: string; runId: string; @@ -1080,7 +1042,7 @@ function createBeeperReplyStreamEmitter(base: { sessionKey: string; threadRoot?: string; }) { - const channelRuntime = getBeeperChannelRuntime(); + const channelRuntime = getBeeperChannelRuntimeForHost(base.hostRuntime) ?? getBeeperChannelRuntime(); if (!channelRuntime) { throw new Error("OpenClaw Beeper requires the Beeper channel runtime for native rich streaming"); } @@ -1110,10 +1072,6 @@ function createBeeperReplyStreamEmitter(base: { }), }); }; - const startMetadata = () => ({ - agent_id: base.agentId, - session_key: base.sessionKey, - }); const ensureStarted = async () => { if (hasPublished || finalized) return; hasPublished = true; @@ -1124,7 +1082,7 @@ function createBeeperReplyStreamEmitter(base: { sessionId: base.sessionId, sessionKey: base.sessionKey, }); - await publisher.publishMany(startRunEvents(state, startMetadata())); + await publisher.start(); channelRuntime.debug("openclaw_beeper_stream_started", { agentId: base.agentId, eventId: publisher.targetEventId, diff --git a/packages/openclaw/src/protocol-coverage.test.ts b/packages/openclaw/src/protocol-coverage.test.ts index d21401c..cdedec6 100644 --- a/packages/openclaw/src/protocol-coverage.test.ts +++ b/packages/openclaw/src/protocol-coverage.test.ts @@ -42,18 +42,16 @@ describe("OpenClaw gateway protocol coverage manifest", () => { expect(OPENCLAW_GATEWAY_EVENT_FAMILIES.every((family) => coveredEvents.has(family))).toBe(true); }); - it("keeps broad feature access routed through generic gateway calls plus wrappers", () => { - expect(OPENCLAW_BRIDGE_COVERAGE.methodAccess.genericGatewayCall).toBe("OpenClawGatewayRuntime.call"); + 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.managementSurface).toBe("OpenClaw in-process plugin runtime"); - expect(OPENCLAW_BRIDGE_COVERAGE.methodAccess.bridgeSpecificWrappers).toEqual(expect.arrayContaining([ + expect(OPENCLAW_BRIDGE_COVERAGE.methodAccess.pluginRuntimeAdapters).toEqual(expect.arrayContaining([ "agents.list", - "sessions.send", - "sessions.steer", - "sessions.abort", + "sessions.list", + "sessions.create", "chat.history", "exec.approval.resolve", - "tools.invoke", - "artifacts.download", + "plugin.approval.resolve", ])); expect(OPENCLAW_GATEWAY_COMMON_METHODS).toEqual(expect.arrayContaining([ "talk.session.create", diff --git a/packages/openclaw/src/protocol-coverage.ts b/packages/openclaw/src/protocol-coverage.ts index f2a1149..ca1aecc 100644 --- a/packages/openclaw/src/protocol-coverage.ts +++ b/packages/openclaw/src/protocol-coverage.ts @@ -212,9 +212,9 @@ export const OPENCLAW_BRIDGE_COVERAGE = { stream: ["chat", "session.message", "session.operation", "session.tool"], }, methodAccess: { - bridgeSpecificWrappers: ["agents.list", "sessions.list", "sessions.create", "sessions.send", "sessions.steer", "sessions.abort", "chat.history", "exec.approval.resolve", "models.list", "tools.catalog", "tools.effective", "tools.invoke", "tasks.list", "tasks.get", "tasks.cancel", "artifacts.list", "artifacts.get", "artifacts.download"], + pluginRuntimeAdapters: ["agents.list", "sessions.list", "sessions.create", "chat.history", "exec.approval.resolve", "plugin.approval.resolve"], commonGatewayMethods: OPENCLAW_GATEWAY_COMMON_METHODS, - genericGatewayCall: "OpenClawGatewayRuntime.call", + beeperTurnDispatch: "runtime.channel.turn.runAssembled", managementSurface: "OpenClaw in-process plugin runtime", snapshotProbe: ["health", "status", "models.list", "channels.status", "sessions.list", "commands.list", "tools.catalog", "skills.status", "tasks.list", "usage.status", "artifacts.list", "cron.list", "agents.list", "config.get"], }, diff --git a/packages/openclaw/src/registration.test.ts b/packages/openclaw/src/registration.test.ts index 2e1b608..861b78e 100644 --- a/packages/openclaw/src/registration.test.ts +++ b/packages/openclaw/src/registration.test.ts @@ -26,7 +26,7 @@ describe("OpenClaw appservice registration", () => { rate_limited: false, receive_ephemeral: true, sender_localpart: "ocbot", - url: "http://127.0.0.1:29391", + url: "websocket", }); expect(registration.namespaces.users).toEqual([ { exclusive: true, regex: "^@oc_agent_.+:beeper\\.local$" }, diff --git a/packages/openclaw/src/setup-entry.ts b/packages/openclaw/src/setup-entry.ts index abadae3..60d9382 100644 --- a/packages/openclaw/src/setup-entry.ts +++ b/packages/openclaw/src/setup-entry.ts @@ -1,6 +1,12 @@ +import { defineSetupPluginEntry } from "openclaw/plugin-sdk/channel-core"; import { beeperChannelPlugin } from "./setup"; -export const openClawBeeperSetupEntry = { +export const openClawBeeperSetupEntry: { + kind: "bundled-channel-setup-entry"; + loadSetupPlugin: () => typeof beeperChannelPlugin; + plugin: typeof beeperChannelPlugin; +} = { + ...defineSetupPluginEntry(beeperChannelPlugin), kind: "bundled-channel-setup-entry", loadSetupPlugin: () => beeperChannelPlugin, } as const; diff --git a/packages/openclaw/src/setup.test.ts b/packages/openclaw/src/setup.test.ts index 68120fc..f8fbed0 100644 --- a/packages/openclaw/src/setup.test.ts +++ b/packages/openclaw/src/setup.test.ts @@ -44,8 +44,9 @@ describe("OpenClaw Beeper setup surface", () => { }, capabilities: { media: true, + nativeCommands: true, reactions: true, - threads: false, + threads: true, }, reload: { configPrefixes: ["channels.beeper", "plugins.entries.beeper"], @@ -82,9 +83,7 @@ describe("OpenClaw Beeper setup surface", () => { label: "Beeper", selectionLabel: expect.any(String), })); - expect(beeperChannelPlugin.capabilities.chatTypes).toEqual( - ["direct"], - ); + expect(beeperChannelPlugin.capabilities.chatTypes).toEqual(["direct", "group", "thread"]); expect(beeperChannelPlugin.message).toEqual(expect.objectContaining({ durableFinal: expect.objectContaining({ capabilities: expect.objectContaining({ @@ -347,7 +346,6 @@ describe("OpenClaw Beeper setup surface", () => { senderLocalpart: "ocbot", serviceBotLocalpart: "ocservice", storePath: "/tmp/openclaw-store", - streamFinalization: "replace", userLocalpartPrefix: "oc_user_", }, }); @@ -371,7 +369,6 @@ describe("OpenClaw Beeper setup surface", () => { senderLocalpart: "ocbot", serviceBotLocalpart: "ocservice", storePath: "/tmp/openclaw-store", - streamFinalization: "replace", userLocalpartPrefix: "oc_user_", }); expect(isBeeperChannelConfigured(cfg)).toBe(false); @@ -413,7 +410,6 @@ describe("OpenClaw Beeper setup surface", () => { select: async ({ message }) => { if (message === "Beeper environment") return "dev"; if (message === "Beeper contact visibility") return "agents"; - if (message === "Stream finalization") return "replace"; if (message === "Approval behavior") return "native"; throw new Error(`unexpected select prompt ${message}`); }, @@ -589,7 +585,6 @@ describe("OpenClaw Beeper setup surface", () => { expect(defaultBeeperChannelSettings()).toMatchObject({ enabled: true, importSources: ["dashboard", "tui"], - streamFinalization: "replace", }); const configured = await beeperSetupWizard.configure({ cfg: {} }); expect(getBeeperChannelSettings(configured.cfg)).toMatchObject({ @@ -618,7 +613,6 @@ describe("OpenClaw Beeper setup surface", () => { enabled: true, importSources: ["dashboard", "tui"], registrationUrl: "http://bridge", - streamFinalization: "replace", })); const snapshot = beeperStatusAdapter.buildAccountSnapshot({ account }); @@ -677,6 +671,7 @@ describe("OpenClaw Beeper setup surface", () => { const client = { appservice: { sendMessage: vi.fn(async () => ({ eventId: "$as" })) }, beeper: { + aiRuns: createTestBeeperAIRuns(), streams: { finalizeMessage: vi.fn(async () => ({ replacementEventId: "$replace", roomId: "!room", raw: {} })), publishPart: vi.fn(async () => undefined), @@ -830,3 +825,30 @@ describe("OpenClaw Beeper setup surface", () => { }); }); }); + +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" }])), + }; +} diff --git a/packages/openclaw/src/setup.ts b/packages/openclaw/src/setup.ts index d628f15..8e16d2b 100644 --- a/packages/openclaw/src/setup.ts +++ b/packages/openclaw/src/setup.ts @@ -1,3 +1,4 @@ +import { createChannelPluginBase } from "openclaw/plugin-sdk/channel-core"; import type { BridgeLogger } from "@beeper/pickle-bridge"; import { createConfigFromOpenClawSetup, DEFAULT_REGISTRATION_URL, defaultDataDir } from "./config"; import type { setupOpenClawBeeperBridge, SetupOpenClawBeeperBridgeOptions } from "./beeper-setup"; @@ -42,7 +43,6 @@ export interface BeeperChannelSettings { senderLocalpart?: string; serviceBotLocalpart?: string; storePath?: string; - streamFinalization?: "replace" | "append" | "native-only"; userLocalpartPrefix?: string; } @@ -74,7 +74,6 @@ export interface BeeperSetupInput { serviceBotLocalpart?: string; selfHosted?: boolean | string; storePath?: string; - streamFinalization?: string; username?: string; userLocalpartPrefix?: string; } @@ -160,7 +159,6 @@ export const BeeperChannelConfigSchema = { storePath: { type: "string" }, contactVisibility: { type: "string", enum: ["agents", "agents-and-users", "none"] }, homeserverDomain: { type: "string" }, - streamFinalization: { type: "string", enum: ["replace", "append", "native-only"] }, approvalBehavior: { type: "string", enum: ["native", "disabled"] }, userLocalpartPrefix: { type: "string" }, }, @@ -576,6 +574,12 @@ export const beeperMessageActions = { }, } as const; +export const beeperCommandAdapter = { + nativeCommandsAutoEnabled: true, + nativeSkillsAutoEnabled: true, + skipWhenConfigEmpty: false, +} as const; + export const beeperAgentPromptAdapter = { inboundFormattingHints: () => ({ rules: [ @@ -709,15 +713,6 @@ export const beeperSetupWizard = { { value: "none", label: "None" }, ], }); - const streamFinalization = await ctx.prompter.select({ - message: "Stream finalization", - initialValue: current.streamFinalization ?? "replace", - options: [ - { value: "replace", label: "Replace final message" }, - { value: "append", label: "Append final message" }, - { value: "native-only", label: "Native stream only" }, - ], - }); const approvalBehavior = await ctx.prompter.select({ message: "Approval behavior", initialValue: current.approvalBehavior ?? "native", @@ -752,7 +747,6 @@ export const beeperSetupWizard = { if (bridgeManagerToken.trim()) input.bridgeManagerToken = bridgeManagerToken.trim(); if (contactVisibility !== undefined) input.contactVisibility = contactVisibility; if (homeserverDomain.trim()) input.homeserverDomain = homeserverDomain.trim(); - if (streamFinalization !== undefined) input.streamFinalization = streamFinalization; const setupParams: Parameters[0] = { cfg: ctx.cfg, input, @@ -821,7 +815,6 @@ export const beeperStatusAdapter = { importSources: settings.importSources ?? [], mode: "self-hosted-appservice", registrationUrl: settings.registrationUrl, - streamFinalization: settings.streamFinalization ?? "replace", }, name: "Beeper", running: runtime?.running === true, @@ -878,28 +871,36 @@ async function loadBeeperSetupBridge(): Promise conversationId, @@ -926,8 +926,6 @@ export const beeperChannelPlugin = { startAccount: startBeeperGatewayAccount, stopAccount: stopBeeperGatewayAccount, }, - setup: beeperSetupAdapter, - setupWizard: beeperSetupWizard, }; function stripUndefined>(input: T): T { @@ -1244,7 +1242,6 @@ export function defaultBeeperChannelSettings(): BeeperChannelSettings { importSources: ["dashboard", "tui"], nonFederatedRooms: true, registrationUrl: DEFAULT_REGISTRATION_URL, - streamFinalization: "replace", }; } @@ -1252,7 +1249,6 @@ export function validateBeeperSetupInput(input: BeeperSetupInput): string | null if (input.email !== undefined && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/u.test(input.email)) return "Beeper email must be a valid email address."; if (input.beeperEnv !== undefined && normalizeBeeperEnv(input.beeperEnv) === undefined) return "Beeper environment must be production, staging, dev, or local."; if (input.contactVisibility !== undefined && normalizeContactVisibility(input.contactVisibility) === undefined) return "Contact visibility must be agents, agents-and-users, or none."; - if (input.streamFinalization !== undefined && normalizeStreamFinalization(input.streamFinalization) === undefined) return "Stream finalization must be replace, append, or native-only."; if (input.approvalBehavior !== undefined && normalizeApprovalBehavior(input.approvalBehavior) === undefined) return "Approval behavior must be native or disabled."; const backfillLimit = normalizeOptionalNumber(input.backfillLimit); if (backfillLimit !== undefined && (!Number.isInteger(backfillLimit) || backfillLimit < 0)) return "Backfill limit must be a non-negative integer."; @@ -1270,7 +1266,6 @@ export function normalizeBeeperSetupInput(input: BeeperSetupInput): Partial"` + 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"` +} + +func (c *Core) handleBeginBeeperAIRun(payload []byte) ([]byte, error) { + var req MatrixBeginBeeperAIRunOptions + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + run := aistream.NewRun(req.RunID, req.ThreadID, req.Model, req.AgentID, req.AgentName, time.Now()) + writer := aistream.NewWriter(run, time.Now) + writer.Start() + c.beeperAIRuns[run.RunID] = &beeperAIRunState{run: run, writer: writer} + 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 + } + event := agui.Event(copyOutboundEvent(req.Event)) + if event["timestamp"] == nil { + event["timestamp"] = time.Now().UnixMilli() + } + if err := agui.ValidateEvent(event); err != nil { + return nil, err + } + before := len(state.run.Events) + state.writer.Add(event) + 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) + } + 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) 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 (c *Core) marshalBeeperAIRunSnapshot(run *aistream.Run, events []OutboundEvent) ([]byte, error) { + body := run.Preview.Text + if body == "" { + body = run.Text() + } + if body == "" { + body = "..." + } + return json.Marshal(MatrixBeeperAIRunSnapshot{ + Body: body, + Events: events, + InitialAIMessage: run.InitialUIMessage(), + FinalAIMessage: run.FinalUIMessage(0, true), + Metadata: run.Metadata(), + 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)) + } + 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..c8163a8 --- /dev/null +++ b/packages/pickle/native/internal/core/beeper_ai_run_test.go @@ -0,0 +1,192 @@ +package core + +import ( + "encoding/json" + "strings" + "testing" + + aistream "github.com/beeper/ai-bridge/pkg/ai-stream" +) + +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,TEXT_MESSAGE_START" { + 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", + "messageId": begin.MessageID, + "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]) + } + + 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, ",") != "TEXT_MESSAGE_END,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 TestBeeperStreamCarrierContentsSplitsLargeEventsAndAdvancesSeq(t *testing.T) { + core := New(nil) + contents, nextSeq, err := core.beeperStreamCarrierContents("com.beeper.llm", MatrixPublishBeeperStreamMessagePartOptions{ + AgentID: "codex", + EventID: "$stream", + Part: OutboundEvent{ + "delta": strings.Repeat("x", aistream.CarrierBudgetBytes*2), + "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) < 2 { + t.Fatalf("expected large event to split into multiple carriers, 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 { + if size := aistream.JSONSize(content); size > aistream.CarrierBudgetBytes { + t.Fatalf("carrier %d size = %d, budget %d", index, size, aistream.CarrierBudgetBytes) + } + envelopes, ok := content[aistream.BeeperAIStreamDeltas].([]aistream.Envelope) + if !ok || len(envelopes) != 1 { + t.Fatalf("carrier %d has unexpected envelope shape: %#v", index, content) + } + wantSeq := 7 + index + if envelopes[0].Seq != wantSeq { + t.Fatalf("carrier %d seq = %d, want %d", index, envelopes[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 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 +} diff --git a/packages/pickle/native/internal/core/core.go b/packages/pickle/native/internal/core/core.go index 6b44715..126e7d4 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), @@ -138,6 +140,16 @@ 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 opSetTyping: return c.handleSetTyping(ctx, payload) case opFetchMessage: diff --git a/packages/pickle/native/internal/core/messages.go b/packages/pickle/native/internal/core/messages.go index 559c9fb..c39a366 100644 --- a/packages/pickle/native/internal/core/messages.go +++ b/packages/pickle/native/internal/core/messages.go @@ -236,30 +236,43 @@ 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 stream.direct { - if err := c.beeperStream.Publish(ctx, stream.roomID, id.EventID(req.EventID), content); err != nil { - return nil, err - } - } else { - content["body"] = "" - content["msgtype"] = "m.text" - content["m.relates_to"] = map[string]any{ - "rel_type": "m.reference", - "event_id": req.EventID, - } - if _, err := c.sendBeeperStreamMessageEvent(ctx, stream.roomID.String(), "", stream.userID, content); err != nil { - return nil, err + for _, content := range contents { + if stream.direct { + if err := c.beeperStream.Publish(ctx, stream.roomID, id.EventID(req.EventID), content); err != nil { + return nil, err + } + } else { + content["body"] = "" + content["msgtype"] = "m.text" + content["m.relates_to"] = map[string]any{ + "rel_type": "m.reference", + "event_id": req.EventID, + } + if _, err := c.sendBeeperStreamMessageEvent(ctx, stream.roomID.String(), "", stream.userID, content); err != nil { + return nil, err + } } } - stream.nextSeq = seq + 1 + stream.nextSeq = nextSeq return c.empty() } 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(nil), nil + } + return contents[0], nil +} + +func (c *Core) beeperStreamCarrierContents(streamType string, req MatrixPublishBeeperStreamMessagePartOptions, seq int) ([]map[string]any, int, error) { run := aistream.Run{ ThreadID: firstString(req.Part["threadId"], req.TurnID), RunID: firstString(req.Part["runId"], req.TurnID), @@ -271,18 +284,23 @@ func (c *Core) beeperStreamCarrierContent(streamType string, req MatrixPublishBe if part["timestamp"] == nil { part["timestamp"] = time.Now().UnixMilli() } - envelope, err := aistream.BuildEnvelope(run, seq, part, req.EventID) + run.Events = []agui.Event{part} + carriers, err := aistream.PackRunFromSeq(run, req.EventID, aistream.CarrierBudgetBytes, seq) if err != nil { - return nil, 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 + return nil, seq, err + } + contents := make([]map[string]any, 0, len(carriers)) + for _, carrier := range carriers { + content := aistream.CarrierContent(carrier.Envelopes) + if streamType != aistream.BeeperAIStreamKey { + if deltas, ok := content[aistream.BeeperAIStreamDeltas]; ok { + delete(content, aistream.BeeperAIStreamDeltas) + content[streamType+".deltas"] = deltas + } } + contents = append(contents, content) } - return content, nil + return contents, aistream.NextSeq(carriers), nil } func (c *Core) handleFinalizeBeeperStreamMessage(ctx context.Context, payload []byte) ([]byte, error) { diff --git a/packages/pickle/native/internal/core/operations.go b/packages/pickle/native/internal/core/operations.go index 5e9c608..230f61c 100644 --- a/packages/pickle/native/internal/core/operations.go +++ b/packages/pickle/native/internal/core/operations.go @@ -69,6 +69,16 @@ 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 setTyping set_typing MatrixTypingOptions void opSetTyping = "set_typing" // ts:operation fetchMessage fetch_message MatrixFetchMessageOptions MatrixFetchMessageResult diff --git a/packages/pickle/src/client-types.ts b/packages/pickle/src/client-types.ts index d89bd6b..3978842 100644 --- a/packages/pickle/src/client-types.ts +++ b/packages/pickle/src/client-types.ts @@ -78,8 +78,14 @@ import type { MatrixAppserviceRoomUserOptions, MatrixAppserviceSendMessageOptions, MatrixAppserviceUserOptions, + MatrixAppendBeeperAIRunEventOptions, + MatrixBeginBeeperAIRunOptions, + MatrixBeeperAIRunSnapshot, + MatrixDeleteBeeperAIRunOptions, + MatrixErrorBeeperAIRunOptions, MatrixFinalizeBeeperStreamMessageOptions, MatrixFinalizeBeeperStreamMessageResult, + MatrixFinishBeeperAIRunOptions, MatrixPublishBeeperStreamMessagePartOptions, MatrixStartBeeperStreamMessageOptions, MatrixStartBeeperStreamMessageResult, @@ -144,6 +150,13 @@ 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; + }; ephemeral: { send(options: SendBeeperEphemeralOptions): Promise; }; diff --git a/packages/pickle/src/client.test.ts b/packages/pickle/src/client.test.ts index fdaff0c..16156e2 100644 --- a/packages/pickle/src/client.test.ts +++ b/packages/pickle/src/client.test.ts @@ -957,6 +957,48 @@ 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" }, + 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" }, + finish_beeper_ai_run: { body: "hello", events: [], finalAIMessage: {}, initialAIMessage: {}, messageId: "msg", metadata: {}, runId: "run", threadId: "thread" }, + init: { deviceId: "DEVICE", userId: "@bot:example.com" }, + }); + 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" }); + + 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", + ]); + 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" }); + }); + 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" }, diff --git a/packages/pickle/src/client.ts b/packages/pickle/src/client.ts index 9f8e76b..b8bc272 100644 --- a/packages/pickle/src/client.ts +++ b/packages/pickle/src/client.ts @@ -86,6 +86,13 @@ class DefaultMatrixClient implements MatrixClient { }), }; 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))), + }, ephemeral: { send: (opts) => this.#withCore((core) => core.sendEphemeralEvent(stripUndefined({ diff --git a/packages/pickle/src/generated-runtime-operations.ts b/packages/pickle/src/generated-runtime-operations.ts index 34872e6..d7746d8 100644 --- a/packages/pickle/src/generated-runtime-operations.ts +++ b/packages/pickle/src/generated-runtime-operations.ts @@ -2,6 +2,7 @@ import type { MatrixAccountDataResult, + MatrixAppendBeeperAIRunEventOptions, MatrixApplySyncResponseOptions, MatrixAppserviceBatchSendOptions, MatrixAppserviceBatchSendResult, @@ -15,16 +16,20 @@ import type { MatrixAppserviceTransactionOptions, MatrixAppserviceUserOptions, MatrixBanUserOptions, + MatrixBeeperAIRunSnapshot, + MatrixBeginBeeperAIRunOptions, MatrixCoreInitOptions, MatrixCreateRoomOptions, MatrixCreateRoomResult, MatrixCryptoStatus, + MatrixDeleteBeeperAIRunOptions, MatrixDeleteMessageOptions, MatrixDownloadEncryptedMediaOptions, MatrixDownloadMediaOptions, MatrixDownloadMediaResult, MatrixDownloadMediaThumbnailOptions, MatrixEditMessageOptions, + MatrixErrorBeeperAIRunOptions, MatrixFetchMessageOptions, MatrixFetchMessageResult, MatrixFetchMessagesOptions, @@ -36,6 +41,7 @@ import type { MatrixFetchRoomStateResult, MatrixFinalizeBeeperStreamMessageOptions, MatrixFinalizeBeeperStreamMessageResult, + MatrixFinishBeeperAIRunOptions, MatrixGetAccountDataOptions, MatrixGetRoomAccountDataOptions, MatrixGetUserOptions, @@ -123,6 +129,11 @@ 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; setTyping(options: MatrixTypingOptions): Promise; fetchMessage(options: MatrixFetchMessageOptions): Promise; fetchMessages(options: MatrixFetchMessagesOptions): Promise; @@ -296,6 +307,26 @@ 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); + } + setTyping(options: MatrixTypingOptions): Promise { return this.call("set_typing", options); } diff --git a/packages/pickle/src/generated-runtime-types.ts b/packages/pickle/src/generated-runtime-types.ts index 3a259dc..0311e52 100644 --- a/packages/pickle/src/generated-runtime-types.ts +++ b/packages/pickle/src/generated-runtime-types.ts @@ -126,6 +126,40 @@ export interface MatrixAppserviceBatchSendResult { export interface MatrixAppserviceTransactionOptions { transaction: { [key: string]: unknown }; } +export interface MatrixBeginBeeperAIRunOptions { + agentId?: string; + agentName?: string; + model?: string; + runId?: string; + threadId?: string; +} +export interface MatrixAppendBeeperAIRunEventOptions { + event: { [key: string]: unknown }; + runId: string; +} +export interface MatrixFinishBeeperAIRunOptions { + finishReason?: string; + runId: string; + usage?: unknown /* agui.Usage */; +} +export interface MatrixErrorBeeperAIRunOptions { + message?: string; + runId: string; + 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 MatrixCryptoStatus { deviceId?: string; hasRecoveryKey: boolean; diff --git a/packages/pickle/src/index.ts b/packages/pickle/src/index.ts index 8201d32..d75e229 100644 --- a/packages/pickle/src/index.ts +++ b/packages/pickle/src/index.ts @@ -35,6 +35,12 @@ export type { MatrixAppserviceRoomUserOptions, MatrixAppserviceSendMessageOptions, MatrixAppserviceUserOptions, + MatrixAppendBeeperAIRunEventOptions, + MatrixBeginBeeperAIRunOptions, + MatrixBeeperAIRunSnapshot, + MatrixDeleteBeeperAIRunOptions, + MatrixErrorBeeperAIRunOptions, + MatrixFinishBeeperAIRunOptions, } from "./runtime-types"; export type { ApplySyncResponseOptions, diff --git a/packages/pickle/src/runtime-types.ts b/packages/pickle/src/runtime-types.ts index 39bb3a2..9684ce9 100644 --- a/packages/pickle/src/runtime-types.ts +++ b/packages/pickle/src/runtime-types.ts @@ -25,12 +25,16 @@ export type { MatrixAppserviceRoomUserOptions, MatrixAppserviceSendMessageOptions, MatrixAppserviceUserOptions, + MatrixAppendBeeperAIRunEventOptions, MatrixApplySyncResponseOptions, MatrixBanUserOptions, + MatrixBeginBeeperAIRunOptions, + MatrixBeeperAIRunSnapshot, MatrixCoreInitOptions, MatrixCryptoStatus, MatrixCreateRoomOptions, MatrixCreateRoomResult, + MatrixDeleteBeeperAIRunOptions, MatrixDeleteMessageOptions, MatrixDownloadEncryptedMediaOptions, MatrixDownloadMediaOptions, @@ -39,6 +43,7 @@ export type { MatrixEditMessageOptions, MatrixEncryptedFile, MatrixEncryptedFileKey, + MatrixErrorBeeperAIRunOptions, MatrixFinalizeBeeperStreamMessageOptions, MatrixFinalizeBeeperStreamMessageResult, MatrixFetchMessageOptions, @@ -50,6 +55,7 @@ export type { MatrixFetchRoomStateEventOptions, MatrixFetchRoomStateOptions, MatrixFetchRoomStateResult, + MatrixFinishBeeperAIRunOptions, MatrixGetAccountDataOptions, MatrixGetRoomAccountDataOptions, MatrixGetUserOptions, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 534f24f..52e69c5 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,10 +215,10 @@ 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: - dependencies: + devDependencies: '@beeper/pickle': specifier: workspace:^ version: link:../pickle @@ -231,13 +231,15 @@ importers: '@beeper/pickle-state-file': specifier: workspace:^ version: link:../state-file - devDependencies: '@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.22 + version: 2026.5.22 tsdown: specifier: ^0.21.10 version: 0.21.10(typescript@5.9.3) @@ -246,7 +248,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/pi: dependencies: @@ -277,7 +279,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: @@ -292,7 +294,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: @@ -314,7 +316,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: @@ -358,7 +360,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: @@ -380,7 +382,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: @@ -402,13 +404,128 @@ 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.91.1': + resolution: {integrity: sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + '@aws-crypto/crc32@5.2.0': + resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/client-bedrock-runtime@3.1048.0': + resolution: {integrity: sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.974.14': + resolution: {integrity: sha512-ppamm04uoj3hhNO5IlQSs5D6rWX1fWkzcn6a4pZrojk8Y6ObY9wzLDdT/Eq3gv6O9hOebi9tYTNB8b8fQj9XJw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.40': + resolution: {integrity: sha512-jjT0p0Y7KZtcvExYiPCLJnqM9lkXDV1KBEg/13OE2DXv/9batzlyJHVKUEnRNJccY0O2Sul17E1su38CgdBhGQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.42': + resolution: {integrity: sha512-+3fsKtWybe5BjKEUA3/07oh7Ayfd82IED2+gyyaVfS/4PU78E3TaOQxSGOJ1t7Imefoidw/ne9QA7apX8wEnJg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.972.44': + resolution: {integrity: sha512-gZFw5wBefCIPg9vpT+gV5FdhfNKhYTVDZa1IsZCcn3SRoYUOJ/E05vwIogkJoonqBL0ttBGi5vhthX7xceekRg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.44': + resolution: {integrity: sha512-QqEGHfQeZgUDqh7zpqHufrZ8T644ELEWvB+4gUdewLyRw4IRF+6CJqeQuRWqucZdQzoQeMh7fNAD9BWxFAdNig==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.45': + resolution: {integrity: sha512-3YCv52ExXIRz3LAVNysevd+s7akSpg9dl39v9LJ7dOQH+s5rHi3jMZYQyxwMmglxQGMuzYRfQ0o1VSP2UOlIRw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.40': + resolution: {integrity: sha512-cXaozlgJCOwmE6D7x4npcPdyk7kiFZdrGjN3D6tXXtItJJMNGPafDfAJn4YQmciMooG/X+b0Y6RTqdVVMx26jg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.972.44': + resolution: {integrity: sha512-YePoj5kQuPmE0MHnyftXCfsO8ZSBd2kDr50XEIUrdejSbGFlayYvUuCohdb8drhGhPm6b65o7H1eC26EZhwUvA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.44': + resolution: {integrity: sha512-Ys/JJe++8Z2Y5meR1taMBaVcrGBA0/XsVTQR+qOKZbdNyg+8Jlv5rYZSwh8SqEHY00goSOZy7PHzZ2rLNQxDLg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/eventstream-handler-node@3.972.17': + resolution: {integrity: sha512-WFwdNcjchKZr7jKYgGimUZO8sSKQF/le7GGqgeCzz/lHozInE6b0gFJ1YMr8NaIeAoWJwgtrF7RE4/qMgosAdQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-eventstream@3.972.13': + resolution: {integrity: sha512-ECfsw7mf6G/sxNbKbGE3/h1xeIArY/yRI1IjDGYkLgDIankh+aDOtDRSr40LVlIHGL9+jEH1cVuxmbJ8NLL/1A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-websocket@3.972.22': + resolution: {integrity: sha512-aumo6pYnvD1/eda3R0UDkRVecwxsuW4zTZLdjbHg7NqYMKmy7vK0bM3NGJzCD+Ys8iqCC7EeDU4LuWVIsXvL+A==} + engines: {node: '>= 14.0.0'} + + '@aws-sdk/nested-clients@3.997.12': + resolution: {integrity: sha512-Js2VYaCM269feB0cs0cGmlIhdOgT9aMqzdBx68lCy6kVCYfzr0T36ovUFDvfUmatkuBeyBJhCwaLBh7P8meH5Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.29': + resolution: {integrity: sha512-Few9FoQqOt/0KSvZYP+qdW0dfOhfQ9N+gl2UUDvCPW6mkPKHli9LMbKxWj+wZ5zKPaOoqxuR3Hhy3OTpndkfSw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1048.0': + resolution: {integrity: sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1054.0': + resolution: {integrity: sha512-hG9YKApmZOw+drJ9Nuoaf/OvC8e5W1+3eoLeN5p2uVCZRWsv27teIS0b4kiH6Sfv3WMmamqYJxmE2WMwyp/L/A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.973.9': + resolution: {integrity: sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-locate-window@3.965.5': + resolution: {integrity: sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.26': + resolution: {integrity: sha512-cDbrqvDS73whl6YAPSPq0U6whzG6UWI9PuWh0wrUuGoZexhWEqhdunbukV7iBoaWnFV1AODutM5hOD6rtn439g==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.2.4': + resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} + engines: {node: '>=18.0.0'} + '@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} @@ -455,6 +572,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==} @@ -516,6 +636,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'} @@ -563,6 +691,24 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@earendil-works/pi-agent-core@0.75.4': + resolution: {integrity: sha512-cGYbysb4EqUf0B28OeqFq2ppm1XF3bYBOP71q9dv38yf/UJfzMjiXBeNelrcio+QWIoVrW+xzYm7sMzYIUc9Og==} + engines: {node: '>=22.19.0'} + + '@earendil-works/pi-ai@0.75.4': + resolution: {integrity: sha512-m/w8Hh3vQ0rAycwJiJWdzkypkn4295f4eq/966lDRy8aX5sk6bgYXH8TQmL16TO7Uwc7MbJG0QoyFHgX8RqXUQ==} + engines: {node: '>=22.19.0'} + hasBin: true + + '@earendil-works/pi-coding-agent@0.75.4': + resolution: {integrity: sha512-Fb+FRo08b5H9pYKbQJ708/5OKL0+K/yclhfCMEhrBzSPTZZ4c85nY1YsBo4qwL20ohBMlBezHMRuHzcJ1ylEoQ==} + engines: {node: '>=22.19.0'} + hasBin: true + + '@earendil-works/pi-tui@0.75.4': + resolution: {integrity: sha512-PDhKU7u6fmEcvHUFHzrRwGc/Ytokj/hO+X4RPf+MWKEGpvg3B1vHv88Ee+Dy33004tYkQF5YeXV4btJZcp5x1g==} + engines: {node: '>=22.19.0'} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -884,6 +1030,49 @@ packages: cpu: [x64] os: [win32] + '@google/genai@1.52.0': + resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.2 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + + '@google/genai@2.5.0': + resolution: {integrity: sha512-qDi3LLh9I3llJK0f9uV8kZ8EdT9oHPxGJJ9yOJ/i5YXYrVwRCs8jHo9x4e99uOeKYDvD3TZwT70p/H/LS3BixQ==} + 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.8': + resolution: {integrity: sha512-lNhpCsZVbdbjz2trFjQdzQ3cUIMZQMIMksi7wd3ntTIYgdaGLqT1Ms97DfVIJYHzRuduf56ISvgU8RRLTpK/ng==} + 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'} @@ -1030,6 +1219,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==} @@ -1046,18 +1239,204 @@ 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==} + '@mariozechner/clipboard-darwin-arm64@0.3.6': + resolution: {integrity: sha512-HjaisYCAbHi/1+N1yDAQHc8ZXGffufIUT5NSOSVR3f3AuMDusxTtnbK8tZ7JFDkShua1oNGZoNwQHsc8MPtE0Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@mariozechner/clipboard-darwin-universal@0.3.6': + resolution: {integrity: sha512-8BWtPjOtJOJoykml3w0fx0zRrfWP31mXrJwfoA7xzNprkZw1uolCNfgmjDiVBseoKjp16EGITz7bN+61qn8dWA==} + engines: {node: '>= 10'} + os: [darwin] + + '@mariozechner/clipboard-darwin-x64@0.3.6': + resolution: {integrity: sha512-p9syiZD1kU4I+1ya7f7g+zD1GiUvR8fdlRlNmgsZNWlyjtc8rlV2EjTLd/35x1LsdBq020GVvtzp0ZmPgBI09Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@mariozechner/clipboard-linux-arm64-gnu@0.3.6': + resolution: {integrity: sha512-5JFf5rGofrm+V29HNF+wLthXphHdQpMbKDUYJ5tML6/Z5DLlLOV/9Ak4kDPtYyZ+Dzf+kAusE0VsFg4+tfP1IA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@mariozechner/clipboard-linux-arm64-musl@0.3.6': + resolution: {integrity: sha512-JlVjxxw0GbGC0djXYWRIqyteO3J1KZ/QG3udlEFaOD5TLOM1FnmXXAPDQBqr+aBVr720ef9K00dirYnJ0LDCtw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@mariozechner/clipboard-linux-riscv64-gnu@0.3.6': + resolution: {integrity: sha512-4t8BUi5zZ+L77otFQVnVSlaTyAX4TVk9EqQm4syMrEQp96trFEHEwwNHcNEBGzYv5+K7mxay50TthYkz47OWzQ==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@mariozechner/clipboard-linux-x64-gnu@0.3.6': + resolution: {integrity: sha512-trtPwcNLW37irwQCJLtCxLw757jjJZk3TSnY/MU9bhtWtA3K9b/eLW0e4RGhUXDoFRds9opNWWaUDuFLa8dm0w==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@mariozechner/clipboard-linux-x64-musl@0.3.6': + resolution: {integrity: sha512-WfnzIvOCCWQiN0MmltCEo6cLceUDbYe+I7xyFZjaps5A+2Op/M2CY7Rey+C4ucQhrvmpoHmTSFgY9ODWk7snoA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@mariozechner/clipboard-win32-arm64-msvc@0.3.6': + resolution: {integrity: sha512-+8+1aHYsBPUjmW3otmWlg+Hijt0iJvoBBs5e0mxFeUd4gDaKMB8Bn6x7c6KVtscg7E5j5NFXnwQqNSIAO4p8zQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@mariozechner/clipboard-win32-x64-msvc@0.3.6': + resolution: {integrity: sha512-S4xfPmERC8ZkiLHe3vekZCjdDwNEETCuvCgQK2kP6/TnvmUkq1y2Pk+DjM4t8uh9KMX9bH4zs5ePcKa8GTXmfg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@mariozechner/clipboard@0.3.6': + resolution: {integrity: sha512-MXdtr+6+ntlIVHdrZYuZNQydu6o8yZswFJ2Ln81j2O/Y9B/LDHvEaIm95xWNPkjGTWriSOeLnQJRFs6dYb60bg==} + engines: {node: '>= 10'} + + '@mistralai/mistralai@2.2.1': + resolution: {integrity: sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==} + + '@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/canvas-android-arm64@0.1.100': + resolution: {integrity: sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@0.1.100': + resolution: {integrity: sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@0.1.100': + resolution: {integrity: sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': + resolution: {integrity: sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@0.1.100': + resolution: {integrity: sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-arm64-musl@0.1.100': + resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': + resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/canvas-linux-x64-gnu@0.1.100': + resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-linux-x64-musl@0.1.100': + resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-win32-arm64-msvc@0.1.100': + resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/canvas-win32-x64-msvc@0.1.100': + resolution: {integrity: sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@0.1.100': + resolution: {integrity: sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@1.1.4': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@nodable/entities@2.1.0': + resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1070,6 +1449,16 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@openclaw/fs-safe@0.2.7': + resolution: {integrity: sha512-l/Yj3K2ChR/gI+bZo1wIe7rjKyTFwGOAw120cTCMRT8LZbVhJhTbiZLGIRBMv0Gc9GQjYE8EjPBza3RdrSSbyQ==} + 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'} @@ -1086,6 +1475,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==} @@ -1181,10 +1600,49 @@ 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'} + '@smithy/core@3.24.4': + resolution: {integrity: sha512-3UNRKEyQyAgVgM0LGlerCLm+ChZWZ1GPfde+jBEW6bm6bSBGU1p0EbblaUV3unbhwvidjLA5Zs3sOs7mnZwvAw==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.3.4': + resolution: {integrity: sha512-vKW0MEFRU4Y3MkVZUkpJm+g9qyPGLCXhc0YLggUdSdBB4g7IaSSsCE75P9rBXyWHrXY1UYSQUl8/DwsTR7QciA==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.4.4': + resolution: {integrity: sha512-qM7AUKI4G6d7lNgaZD3lA1tWSolh5r6gcixfTZAPstVURfjIbvreVTPz+994M0yC3HbX4YYhDRgr31Xy3XwWOQ==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/node-http-handler@4.7.4': + resolution: {integrity: sha512-HIeF+1vrDGzPkkv39Hj2vlHSXHY3p958jd/8ZnePIY6+ZOsQX8coyEUKO5yQu4r0bQIVsbpotVIrXXwyycMStQ==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.4.4': + resolution: {integrity: sha512-e5UtkMvsatzBfbeBZjEOt0k0Z3BEsjTFL/n6fdO5vtBLe67tdy0dX7xw2DU7uZ3acwoHyeCqpU2Fzb7pxwHb6Q==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.14.2': + resolution: {integrity: sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + '@speed-highlight/core@1.2.15': resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==} @@ -1213,6 +1671,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==} @@ -1249,6 +1714,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==} @@ -1299,6 +1767,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'} @@ -1307,6 +1798,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'} @@ -1321,6 +1816,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'} @@ -1335,27 +1833,76 @@ 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==} + + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + + 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==} @@ -1363,6 +1910,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==} @@ -1372,13 +1923,70 @@ 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'} + + 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'} @@ -1390,6 +1998,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==} @@ -1402,12 +2017,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'} @@ -1423,6 +2046,13 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + 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'} @@ -1457,10 +2087,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'} @@ -1469,12 +2116,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'} @@ -1485,6 +2148,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'} @@ -1497,11 +2167,37 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - expect-type@1.3.0: - resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} - engines: {node: '>=12.0.0'} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} - extend@3.0.2: + 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==} extendable-error@0.1.7: @@ -1511,10 +2207,32 @@ 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-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==} + + fast-xml-builder@1.2.0: + resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} + + fast-xml-parser@5.7.3: + resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==} + hasBin: true + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -1527,14 +2245,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'} @@ -1548,6 +2290,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==} @@ -1555,27 +2324,88 @@ 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@10.7.3: + resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + + 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-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + 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 @@ -1584,18 +2414,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'} @@ -1608,6 +2467,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'} @@ -1616,6 +2478,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==} @@ -1631,6 +2496,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==} @@ -1647,13 +2519,50 @@ 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'} + koffi@2.16.2: + resolution: {integrity: sha512-owU0MRwv6xkrVqCd+33uw6BaYppkTRXbO/rVdJNI2dvZG0gzyRhYwW25eWtc5pauwK8TGh3AbkFONSezdykfSA==} + + 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'} @@ -1724,6 +2633,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'} @@ -1731,9 +2652,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==} @@ -1744,14 +2672,27 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + markdown-it@14.1.1: + resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} + 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==} @@ -1785,6 +2726,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'} @@ -1877,11 +2829,37 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + 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'} @@ -1899,6 +2877,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} @@ -1908,15 +2903,67 @@ 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.26.0: + resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + + openai@6.38.0: + resolution: {integrity: sha512-AoMplt2UalrpgUDMh3L09QWjNRlgJPipclQvA6sYAaeF6nHNBMgmikAZGmcYLn8on4d9sQY9Q8bOLfrBS7Lc8g==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + + openclaw@2026.5.22: + resolution: {integrity: sha512-m+zgBELGbCHjWB1IWF5WSWNPr480cMKOMff2OF72c8A0AMD4hC/9+qwYtzjYmGkETcffnB711JymlVsQnh2Tow==} + engines: {node: '>=22.19.0'} + hasBin: true + outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} @@ -1936,6 +2983,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'} @@ -1943,6 +2994,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==} @@ -1950,13 +3008,24 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-expression-matcher@1.5.0: + resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} + engines: {node: '>=14.0.0'} + path-key@3.1.1: 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'} @@ -1964,6 +3033,10 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pdfjs-dist@5.7.284: + resolution: {integrity: sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw==} + engines: {node: '>=22.13.0 || >=24'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1979,6 +3052,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} @@ -1988,6 +3074,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==} @@ -1997,10 +3110,28 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quickjs-wasi@2.2.0: + resolution: {integrity: sha512-zQxXmQMrEoD3S+jQdYsloq4qAuaxKFHZj6hHqOYGwB2iQZH+q9e/lf5zQPXCKOk0WJuAjzRFbO4KwHIp2D05Iw==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + 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==} @@ -2013,6 +3144,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'} @@ -2020,6 +3162,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'} @@ -2048,9 +3198,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==} @@ -2062,6 +3222,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} @@ -2074,13 +3251,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'} @@ -2089,18 +3288,64 @@ 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==} + 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'} @@ -2109,6 +3354,13 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strnum@2.3.0: + resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==} + + 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'} @@ -2117,6 +3369,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'} @@ -2140,6 +3400,19 @@ 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'} + + tokenjuice@0.7.1: + resolution: {integrity: sha512-eO048hm9UcGHASjYkIWEij8QN68amGp+S1nJyo685qB1/ol+VGEYjPglcVPvCbJbZyFHvI+BBAMvOfnqYCtpsQ==} + engines: {node: '>=20'} + hasBin: true + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -2147,9 +3420,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'} @@ -2181,11 +3465,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==} @@ -2199,6 +3509,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==} @@ -2221,6 +3535,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'} @@ -2231,6 +3549,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==} @@ -2321,12 +3646,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'} @@ -2352,9 +3692,32 @@ packages: '@cloudflare/workers-types': optional: true - ws@8.18.0: - resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} - engines: {node: '>=10.0.0'} + 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'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} + engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 utf-8-validate: '>=5.0.2' @@ -2364,15 +3727,59 @@ packages: utf-8-validate: optional: true + xml-naming@0.1.0: + resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} + engines: {node: '>=16.0.0'} + + 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==} @@ -2382,6 +3789,239 @@ snapshots: dependencies: zod: 3.25.76 + '@agentclientprotocol/sdk@0.22.1(zod@4.4.3)': + dependencies: + zod: 4.4.3 + + '@anthropic-ai/sdk@0.91.1(zod@4.4.3)': + dependencies: + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 4.4.3 + + '@aws-crypto/crc32@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.9 + tslib: 2.8.1 + + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.9 + '@aws-sdk/util-locate-window': 3.965.5 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.9 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.973.9 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/client-bedrock-runtime@3.1048.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.14 + '@aws-sdk/credential-provider-node': 3.972.45 + '@aws-sdk/eventstream-handler-node': 3.972.17 + '@aws-sdk/middleware-eventstream': 3.972.13 + '@aws-sdk/middleware-websocket': 3.972.22 + '@aws-sdk/token-providers': 3.1048.0 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/fetch-http-handler': 5.4.4 + '@smithy/node-http-handler': 4.7.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/core@3.974.14': + dependencies: + '@aws-sdk/types': 3.973.9 + '@aws-sdk/xml-builder': 3.972.26 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/core': 3.24.4 + '@smithy/signature-v4': 5.4.4 + '@smithy/types': 4.14.2 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.40': + dependencies: + '@aws-sdk/core': 3.974.14 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.42': + dependencies: + '@aws-sdk/core': 3.974.14 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/fetch-http-handler': 5.4.4 + '@smithy/node-http-handler': 4.7.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.972.44': + dependencies: + '@aws-sdk/core': 3.974.14 + '@aws-sdk/credential-provider-env': 3.972.40 + '@aws-sdk/credential-provider-http': 3.972.42 + '@aws-sdk/credential-provider-login': 3.972.44 + '@aws-sdk/credential-provider-process': 3.972.40 + '@aws-sdk/credential-provider-sso': 3.972.44 + '@aws-sdk/credential-provider-web-identity': 3.972.44 + '@aws-sdk/nested-clients': 3.997.12 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/credential-provider-imds': 4.3.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.44': + dependencies: + '@aws-sdk/core': 3.974.14 + '@aws-sdk/nested-clients': 3.997.12 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.45': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.40 + '@aws-sdk/credential-provider-http': 3.972.42 + '@aws-sdk/credential-provider-ini': 3.972.44 + '@aws-sdk/credential-provider-process': 3.972.40 + '@aws-sdk/credential-provider-sso': 3.972.44 + '@aws-sdk/credential-provider-web-identity': 3.972.44 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/credential-provider-imds': 4.3.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.40': + dependencies: + '@aws-sdk/core': 3.974.14 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.972.44': + dependencies: + '@aws-sdk/core': 3.974.14 + '@aws-sdk/nested-clients': 3.997.12 + '@aws-sdk/token-providers': 3.1054.0 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.44': + dependencies: + '@aws-sdk/core': 3.974.14 + '@aws-sdk/nested-clients': 3.997.12 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/eventstream-handler-node@3.972.17': + dependencies: + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-eventstream@3.972.13': + dependencies: + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-websocket@3.972.22': + dependencies: + '@aws-sdk/core': 3.974.14 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/fetch-http-handler': 5.4.4 + '@smithy/signature-v4': 5.4.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.12': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.14 + '@aws-sdk/signature-v4-multi-region': 3.996.29 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/fetch-http-handler': 5.4.4 + '@smithy/node-http-handler': 4.7.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.29': + dependencies: + '@aws-sdk/types': 3.973.9 + '@smithy/signature-v4': 5.4.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1048.0': + dependencies: + '@aws-sdk/core': 3.974.14 + '@aws-sdk/nested-clients': 3.997.12 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1054.0': + dependencies: + '@aws-sdk/core': 3.974.14 + '@aws-sdk/nested-clients': 3.997.12 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/types@3.973.9': + dependencies: + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.965.5': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.26': + dependencies: + '@smithy/types': 4.14.2 + fast-xml-parser: 5.7.3 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.2.4': {} + '@babel/generator@8.0.0-rc.3': dependencies: '@babel/parser': 8.0.0-rc.3 @@ -2421,6 +4061,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 @@ -2579,6 +4221,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)': @@ -2606,6 +4260,75 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@earendil-works/pi-agent-core@0.75.4(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.1)(zod@4.4.3)': + dependencies: + '@earendil-works/pi-ai': 0.75.4(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.1)(zod@4.4.3) + ignore: 7.0.5 + typebox: 1.1.38 + yaml: 2.9.0 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-ai@0.75.4(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.1)(zod@4.4.3)': + dependencies: + '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) + '@aws-sdk/client-bedrock-runtime': 3.1048.0 + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) + '@mistralai/mistralai': 2.2.1 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + openai: 6.26.0(ws@8.20.1)(zod@4.4.3) + partial-json: 0.1.7 + typebox: 1.1.38 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-coding-agent@0.75.4(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.1)(zod@4.4.3)': + dependencies: + '@earendil-works/pi-agent-core': 0.75.4(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.1)(zod@4.4.3) + '@earendil-works/pi-ai': 0.75.4(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.1)(zod@4.4.3) + '@earendil-works/pi-tui': 0.75.4 + '@silvia-odwyer/photon-node': 0.3.4 + chalk: 5.6.2 + cross-spawn: 7.0.6 + diff: 8.0.4 + glob: 13.0.6 + highlight.js: 10.7.3 + hosted-git-info: 9.0.3 + ignore: 7.0.5 + jiti: 2.7.0 + minimatch: 10.2.5 + proper-lockfile: 4.1.2 + typebox: 1.1.38 + undici: 8.3.0 + yaml: 2.9.0 + optionalDependencies: + '@mariozechner/clipboard': 0.3.6 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-tui@0.75.4': + dependencies: + get-east-asian-width: 1.6.0 + marked: 15.0.12 + optionalDependencies: + koffi: 2.16.2 + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -2778,6 +4501,57 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true + '@google/genai@1.52.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.20.1 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@google/genai@2.5.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.20.1 + 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.8': + 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': @@ -2881,6 +4655,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 @@ -2900,6 +4678,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 @@ -2916,6 +4721,131 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 + '@mariozechner/clipboard-darwin-arm64@0.3.6': + optional: true + + '@mariozechner/clipboard-darwin-universal@0.3.6': + optional: true + + '@mariozechner/clipboard-darwin-x64@0.3.6': + optional: true + + '@mariozechner/clipboard-linux-arm64-gnu@0.3.6': + optional: true + + '@mariozechner/clipboard-linux-arm64-musl@0.3.6': + optional: true + + '@mariozechner/clipboard-linux-riscv64-gnu@0.3.6': + optional: true + + '@mariozechner/clipboard-linux-x64-gnu@0.3.6': + optional: true + + '@mariozechner/clipboard-linux-x64-musl@0.3.6': + optional: true + + '@mariozechner/clipboard-win32-arm64-msvc@0.3.6': + optional: true + + '@mariozechner/clipboard-win32-x64-msvc@0.3.6': + optional: true + + '@mariozechner/clipboard@0.3.6': + optionalDependencies: + '@mariozechner/clipboard-darwin-arm64': 0.3.6 + '@mariozechner/clipboard-darwin-universal': 0.3.6 + '@mariozechner/clipboard-darwin-x64': 0.3.6 + '@mariozechner/clipboard-linux-arm64-gnu': 0.3.6 + '@mariozechner/clipboard-linux-arm64-musl': 0.3.6 + '@mariozechner/clipboard-linux-riscv64-gnu': 0.3.6 + '@mariozechner/clipboard-linux-x64-gnu': 0.3.6 + '@mariozechner/clipboard-linux-x64-musl': 0.3.6 + '@mariozechner/clipboard-win32-arm64-msvc': 0.3.6 + '@mariozechner/clipboard-win32-x64-msvc': 0.3.6 + optional: true + + '@mistralai/mistralai@2.2.1': + dependencies: + ws: 8.20.1 + 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/canvas-android-arm64@0.1.100': + optional: true + + '@napi-rs/canvas-darwin-arm64@0.1.100': + optional: true + + '@napi-rs/canvas-darwin-x64@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@0.1.100': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-x64-musl@0.1.100': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@0.1.100': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@0.1.100': + optional: true + + '@napi-rs/canvas@0.1.100': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 0.1.100 + '@napi-rs/canvas-darwin-arm64': 0.1.100 + '@napi-rs/canvas-darwin-x64': 0.1.100 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.100 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.100 + '@napi-rs/canvas-linux-arm64-musl': 0.1.100 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.100 + '@napi-rs/canvas-linux-x64-gnu': 0.1.100 + '@napi-rs/canvas-linux-x64-musl': 0.1.100 + '@napi-rs/canvas-win32-arm64-msvc': 0.1.100 + '@napi-rs/canvas-win32-x64-msvc': 0.1.100 + optional: true + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -2923,6 +4853,8 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@nodable/entities@2.1.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2935,6 +4867,15 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@openclaw/fs-safe@0.2.7': + 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 @@ -2952,6 +4893,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 @@ -3007,8 +4970,58 @@ snapshots: '@rolldown/pluginutils@1.0.0-rc.17': {} + '@silvia-odwyer/photon-node@0.3.4': {} + '@sindresorhus/is@7.2.0': {} + '@smithy/core@3.24.4': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.3.4': + dependencies: + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.4.4': + dependencies: + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/node-http-handler@4.7.4': + dependencies: + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@smithy/signature-v4@5.4.4': + dependencies: + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@smithy/types@4.14.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + '@speed-highlight/core@1.2.15': {} '@standard-schema/spec@1.1.0': {} @@ -3035,6 +5048,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 @@ -3075,6 +5097,8 @@ snapshots: dependencies: undici-types: 7.19.2 + '@types/retry@0.12.0': {} + '@types/seedrandom@3.0.8': {} '@types/unist@3.0.3': {} @@ -3095,7 +5119,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: @@ -3106,29 +5130,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: @@ -3156,10 +5180,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: @@ -3170,6 +5220,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: @@ -3186,26 +5243,76 @@ 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: {} + + bowser@2.14.1: {} + + 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: {} @@ -3222,10 +5329,55 @@ snapshots: transitivePeerDependencies: - supports-color + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + chownr@3.0.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 @@ -3242,18 +5394,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: {} @@ -3264,6 +5424,10 @@ snapshots: dependencies: dequal: 2.0.3 + diff@8.0.4: {} + + dijkstrajs@1.0.3: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -3292,8 +5456,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 @@ -3301,10 +5481,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 @@ -3364,6 +5554,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: {} @@ -3372,14 +5566,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 @@ -3388,6 +5632,30 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + 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 + + fast-xml-builder@1.2.0: + dependencies: + path-expression-matcher: 1.5.0 + xml-naming: 0.1.0 + + fast-xml-parser@5.7.3: + dependencies: + '@nodable/entities': 2.1.0 + fast-xml-builder: 1.2.0 + path-expression-matcher: 1.5.0 + strnum: 2.3.0 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -3396,15 +5664,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 @@ -3420,6 +5721,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 @@ -3428,6 +5769,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 @@ -3437,28 +5784,116 @@ 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: {} - he@1.2.0: {} + has-symbols@1.1.0: {} + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + he@1.2.0: {} + + highlight.js@10.7.3: {} + + 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-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + 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 @@ -3467,12 +5902,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: {} @@ -3488,6 +5927,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: @@ -3501,12 +5944,54 @@ 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: {} + koffi@2.16.2: + optional: true + + kysely@0.29.2: {} + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lightningcss-android-arm64@1.32.0: optional: true @@ -3556,14 +6041,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 @@ -3578,10 +6079,23 @@ snapshots: dependencies: semver: 7.7.4 + markdown-it@14.1.1: + 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 @@ -3684,6 +6198,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: @@ -3882,6 +6402,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 @@ -3894,6 +6420,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: {} @@ -3902,10 +6442,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.20.1 + 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 @@ -3915,8 +6479,94 @@ 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.26.0(ws@8.20.1)(zod@4.4.3): + optionalDependencies: + ws: 8.20.1 + zod: 4.4.3 + + openai@6.38.0(ws@8.20.1)(zod@4.4.3): + optionalDependencies: + ws: 8.20.1 + zod: 4.4.3 + + openclaw@2026.5.22: + dependencies: + '@agentclientprotocol/sdk': 0.22.1(zod@4.4.3) + '@clack/core': 1.3.1 + '@clack/prompts': 1.4.0 + '@earendil-works/pi-agent-core': 0.75.4(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.1)(zod@4.4.3) + '@earendil-works/pi-ai': 0.75.4(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.1)(zod@4.4.3) + '@earendil-works/pi-coding-agent': 0.75.4(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.1)(zod@4.4.3) + '@earendil-works/pi-tui': 0.75.4 + '@google/genai': 2.5.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.8 + '@lydell/node-pty': 1.2.0-beta.12 + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + '@mozilla/readability': 0.6.0 + '@openclaw/fs-safe': 0.2.7 + '@openclaw/proxyline': 0.3.3(undici@8.3.0) + ajv: 8.20.0 + chalk: 5.6.2 + chokidar: 5.0.0 + commander: 14.0.3 + croner: 10.0.1 + dotenv: 17.4.2 + express: 5.2.1 + file-type: 22.0.1 + grammy: 1.43.0 + 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.1.1 + node-edge-tts: 1.2.10 + openai: 6.38.0(ws@8.20.1)(zod@4.4.3) + pdfjs-dist: 5.7.284 + playwright-core: 1.60.0 + qrcode: 1.5.4 + quickjs-wasi: 2.2.0 + tar: 7.5.15 + tokenjuice: 0.7.1 + 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.20.1 + yaml: 2.9.0 + zod: 4.4.3 + optionalDependencies: + sharp: 0.34.5 + 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: @@ -3933,24 +6583,46 @@ 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-expression-matcher@1.5.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: {} + pdfjs-dist@5.7.284: + optionalDependencies: + '@napi-rs/canvas': 0.1.100 + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -3959,6 +6631,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 @@ -3967,12 +6645,63 @@ 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@2.2.0: {} + + range-parser@1.2.1: {} + + 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 @@ -3980,6 +6709,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 @@ -4008,10 +6749,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): @@ -4053,16 +6804,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 @@ -4100,14 +6896,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 @@ -4115,22 +6950,81 @@ 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: {} + 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: {} + strnum@2.3.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: {} @@ -4148,12 +7042,29 @@ 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 + + tokenjuice@0.7.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 @@ -4181,11 +7092,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 @@ -4197,6 +7125,8 @@ snapshots: undici@7.24.8: {} + undici@8.3.0: {} + unenv@2.0.0-rc.24: dependencies: pathe: 2.0.3 @@ -4232,10 +7162,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 @@ -4246,7 +7182,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 @@ -4257,8 +7193,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 @@ -4269,8 +7207,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 @@ -4281,11 +7221,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 @@ -4302,7 +7244,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 @@ -4311,10 +7253,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 @@ -4331,7 +7273,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 @@ -4340,10 +7282,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 @@ -4360,7 +7302,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 @@ -4369,6 +7311,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: @@ -4376,6 +7332,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 @@ -4409,8 +7367,65 @@ 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.20.1: {} + + xml-naming@0.1.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 @@ -4424,6 +7439,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: {} From cb8fc4074d371c97e7bd571e27a58a96a98b5e93 Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Wed, 27 May 2026 04:16:53 +0200 Subject: [PATCH 39/43] Refactor pickle data flow and UI state handling --- packages/openclaw/README.md | 17 +- packages/openclaw/package.json | 10 +- .../src/beeper-channel-runtime.test.ts | 20 +- .../openclaw/src/beeper-channel-runtime.ts | 16 +- packages/openclaw/src/beeper-stream.test.ts | 53 ++++- packages/openclaw/src/beeper-stream.ts | 223 ++++++++++++------ packages/openclaw/src/beeper-turn-events.ts | 8 + packages/openclaw/src/bridge-agent.test.ts | 1 - packages/openclaw/src/connector.ts | 25 -- packages/openclaw/src/index.ts | 21 -- .../openclaw/src/openclaw-extension.test.ts | 9 +- packages/openclaw/src/openclaw-extension.ts | 62 ++--- .../openclaw/src/openclaw-runtime.test.ts | 198 ++++++---------- packages/openclaw/src/openclaw-runtime.ts | 120 +++------- .../openclaw/src/protocol-coverage.test.ts | 1 - packages/openclaw/src/protocol-coverage.ts | 3 - packages/openclaw/src/setup-entry.ts | 10 +- packages/openclaw/src/setup.test.ts | 24 +- packages/openclaw/src/setup.ts | 221 ++++++++++------- packages/openclaw/tsdown.config.ts | 2 +- packages/pickle/src/streams/beeper-message.ts | 32 +-- scripts/audit-package-surface.mjs | 14 +- 22 files changed, 549 insertions(+), 541 deletions(-) delete mode 100644 packages/openclaw/src/index.ts diff --git a/packages/openclaw/README.md b/packages/openclaw/README.md index aeee301..1b8e232 100644 --- a/packages/openclaw/README.md +++ b/packages/openclaw/README.md @@ -17,14 +17,13 @@ OpenClaw loads the runtime entry from `dist/plugin-entry.mjs` and the lightweigh - Beeper email-code login for existing accounts. - Beeper appservice registration for the OpenClaw bridge. - OpenClaw channel metadata, setup entrypoint, runtime entrypoint, and ClawHub install metadata. -- Pickle bridgev2-style connector for OpenClaw agents, sessions, approvals, and backfill. +- Pickle bridgev2-style transport for Matrix portals, media, reactions, receipts, and backfill. - Direct in-process OpenClaw plugin runtime access. - Agent ghosts for OpenClaw agents and user ghosts for imported one-to-one sessions. - 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. -- Matrix slash commands: `/new`, `/agent`, `/sessions`, `/import`, `/backfill`, `/stop`, `/approve`, `/deny`, `/status`, and `/settings`. `/abort` is accepted as a compatibility alias for `/stop`. - Native Beeper stream publishing for reasoning, text, tool input/output, approvals, errors, aborts, and final replacement messages. -- Native approval UI parsing first, with reactions and `/approve`/`/deny` as escape hatches. +- OpenClaw-native command discovery and approval surfaces. - Non-federated Matrix room creation defaults through the generated appservice registration. - Opt-in backfill/import helpers for dashboard, TUI, channel-origin, and archived one-to-one OpenClaw sessions. @@ -52,11 +51,15 @@ The bridge runtime itself is started by OpenClaw when the installed channel plug ```ts import { - accountFromOpenClawConfig, backfillAllOpenClawSessions, +} from "@beeper/pickle-openclaw/backfill"; +import { createDefaultConfig, +} from "@beeper/pickle-openclaw/config"; +import { + accountFromOpenClawConfig, createOpenClawBeeperBridge, -} from "@beeper/pickle-openclaw"; +} from "@beeper/pickle-openclaw/appservice"; const config = createDefaultConfig({ accessToken: process.env.BEEPER_ACCESS_TOKEN, @@ -73,8 +76,8 @@ const bridge = await createOpenClawBeeperBridge({ await bridge.start(); ``` -The runtime uses the in-process OpenClaw plugin context and exposes wrappers for agents, sessions, models, tools, tasks, artifacts, approvals, and feature snapshots. +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 upstream Gateway method and event families from `.upstream/openclaw/docs/gateway/protocol.md`. The manifest is tested so future changes can audit which families are streamed to Matrix, mapped to approvals, intentionally ignored as operational noise, or available through generic Gateway calls. +`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/package.json b/packages/openclaw/package.json index c80e537..49db158 100644 --- a/packages/openclaw/package.json +++ b/packages/openclaw/package.json @@ -15,13 +15,13 @@ "bin": { "pickle-openclaw": "./dist/cli.mjs" }, - "main": "./dist/index.mjs", - "module": "./dist/index.mjs", - "types": "./dist/index.d.mts", + "main": "./dist/plugin-entry.mjs", + "module": "./dist/plugin-entry.mjs", + "types": "./dist/plugin-entry.d.mts", "exports": { ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" + "types": "./dist/plugin-entry.d.mts", + "import": "./dist/plugin-entry.mjs" }, "./approval": { "types": "./dist/approval.d.mts", diff --git a/packages/openclaw/src/beeper-channel-runtime.test.ts b/packages/openclaw/src/beeper-channel-runtime.test.ts index 7568cf4..c667450 100644 --- a/packages/openclaw/src/beeper-channel-runtime.test.ts +++ b/packages/openclaw/src/beeper-channel-runtime.test.ts @@ -1,9 +1,8 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { BeeperChannelRuntime, - getBeeperChannelRuntime, getBeeperChannelRuntimeForHost, - setBeeperChannelRuntime, + requireBeeperChannelRuntimeForHost, setBeeperChannelRuntimeForHost, } from "./beeper-channel-runtime"; @@ -32,10 +31,6 @@ function createClient() { } describe("BeeperChannelRuntime", () => { - afterEach(() => { - setBeeperChannelRuntime(undefined); - }); - it("requires bridge portal routing for outbound message operations", async () => { const client = createClient(); const runtime = new BeeperChannelRuntime({ @@ -181,24 +176,17 @@ describe("BeeperChannelRuntime", () => { expect(messageEvent.getSender()).toEqual({ isFromMe: true, sender: "@main:example" }); }); - it("stores the active runtime for channel adapters", () => { - const runtime = new BeeperChannelRuntime({ client: createClient() as never }); - setBeeperChannelRuntime(runtime); - expect(getBeeperChannelRuntime()).toBe(runtime); - }); - it("stores Beeper runtimes by OpenClaw host runtime", () => { const hostRuntime = {}; - const globalRuntime = new BeeperChannelRuntime({ client: createClient() as never }); const scopedRuntime = new BeeperChannelRuntime({ client: createClient() as never }); - setBeeperChannelRuntime(globalRuntime); setBeeperChannelRuntimeForHost(hostRuntime, scopedRuntime); - expect(getBeeperChannelRuntime()).toBe(globalRuntime); 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 index 323cd0d..db0c8d4 100644 --- a/packages/openclaw/src/beeper-channel-runtime.ts +++ b/packages/openclaw/src/beeper-channel-runtime.ts @@ -348,17 +348,8 @@ export class BeeperChannelRuntime { } } -let currentRuntime: BeeperChannelRuntime | undefined; const runtimeByHost = new WeakMap(); -export function setBeeperChannelRuntime(runtime: BeeperChannelRuntime | undefined): void { - currentRuntime = runtime; -} - -export function getBeeperChannelRuntime(): BeeperChannelRuntime | undefined { - return currentRuntime; -} - export function setBeeperChannelRuntimeForHost(hostRuntime: object, runtime: BeeperChannelRuntime | undefined): void { if (runtime) runtimeByHost.set(hostRuntime, runtime); else runtimeByHost.delete(hostRuntime); @@ -368,11 +359,12 @@ export function getBeeperChannelRuntimeForHost(hostRuntime: object | undefined): return hostRuntime ? runtimeByHost.get(hostRuntime) : undefined; } -export function requireBeeperChannelRuntime(): BeeperChannelRuntime { - if (!currentRuntime) { +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 currentRuntime; + return runtime; } function withReplyRelation(content: Record, replyToId: string | null | undefined): Record { diff --git a/packages/openclaw/src/beeper-stream.test.ts b/packages/openclaw/src/beeper-stream.test.ts index 056f95d..51cbf2e 100644 --- a/packages/openclaw/src/beeper-stream.test.ts +++ b/packages/openclaw/src/beeper-stream.test.ts @@ -22,7 +22,7 @@ describe("OpenClaw Beeper native stream publisher", () => { body: "...", "com.beeper.ai": { id: "turn_1", - metadata: { agent_id: "codex", turn_id: "turn_1" }, + metadata: { agent_id: "codex", message_id: "turn_1", turn_id: "turn_1" }, parts: [], role: "assistant", }, @@ -67,7 +67,9 @@ describe("OpenClaw Beeper native stream publisher", () => { }), }), "com.beeper.stream": { - type: "com.beeper.llm.deltas", + device_id: "DEVICE", + type: "com.beeper.llm", + user_id: "@bot:example.com", }, body: "hello", msgtype: "m.text", @@ -207,6 +209,39 @@ describe("OpenClaw Beeper native stream publisher", () => { expect.objectContaining({ content: "done", type: "text" }), ])); }); + + it("starts and finalizes another Beeper stream for a second assistant message", async () => { + const { client, finalizeMessage, publishPart, startMessage } = createClient(); + const publisher = new BeeperTurnStreamCoordinator({ + 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(startMessage).toHaveBeenCalledTimes(3); + expect(startMessage.mock.calls.map(([options]) => options.content["com.beeper.ai"].id)).toEqual([ + "turn_multi", + "answer_1", + "answer_2", + ]); + expect(publishPart.mock.calls.map(([options]) => [options.eventId, options.part.type, options.part.delta])).toEqual(expect.arrayContaining([ + ["$target-2", "TEXT_MESSAGE_CONTENT", "first"], + ["$target-3", "TEXT_MESSAGE_CONTENT", "second"], + ])); + expect(finalizeMessage.mock.calls.map(([options]) => [options.eventId, options.body])).toEqual([ + ["$target", "firstsecond"], + ["$target-2", "first"], + ["$target-3", "second"], + ]); + }); }); function createClient() { @@ -275,11 +310,15 @@ function createClient() { return snapshot(options.runId, [terminal], options.message ?? "Run failed"); }); const deleteRun = vi.fn(async () => undefined); - const startMessage = vi.fn(async () => ({ - descriptor: { device_id: "DEVICE", type: "com.beeper.llm", user_id: "@bot:example.com" }, - eventId: "$target", - roomId: "!room:example.com", - })); + let started = 0; + const startMessage = vi.fn(async () => { + started += 1; + return { + descriptor: { device_id: "DEVICE", type: "com.beeper.llm", user_id: "@bot:example.com" }, + eventId: started === 1 ? "$target" : `$target-${started}`, + roomId: "!room:example.com", + }; + }); const publishPart = vi.fn(async () => undefined); const finalizeMessage = vi.fn(async () => ({ eventId: "$target", diff --git a/packages/openclaw/src/beeper-stream.ts b/packages/openclaw/src/beeper-stream.ts index 7c634b6..594ae86 100644 --- a/packages/openclaw/src/beeper-stream.ts +++ b/packages/openclaw/src/beeper-stream.ts @@ -52,18 +52,26 @@ export interface BeeperStreamFinalizeOptions { terminalPart?: AGUIEvent; } +type BeeperStreamAnchor = { + accumulator: BeeperFinalMessageAccumulator; + descriptor?: Record; + eventId?: string; + id: string; +}; + export class BeeperTurnStreamCoordinator { readonly roomId: string; readonly turnId: string; - #accumulator: BeeperFinalMessageAccumulator; + #anchors = new Map(); + #anchorOrder: string[] = []; #agentId: string | undefined; #client: BeeperTurnStreamCoordinatorClient; - #descriptor: Record | undefined; + #currentAnchorId: string; #finalized = false; #initialMessageMetadata: Record; #queue = new SerialQueue(); + #runBegun = false; #subscribers: BeeperStreamSubscriber[]; - #targetEventId: string | undefined; #threadRoot: string | undefined; #userId: string | undefined; @@ -73,25 +81,29 @@ export class BeeperTurnStreamCoordinator { this.#initialMessageMetadata = options.initialMessageMetadata ?? {}; this.roomId = options.roomId; this.turnId = options.turnId ?? createTurnId(); + this.#currentAnchorId = this.turnId; this.#subscribers = options.subscribers ?? []; this.#threadRoot = options.threadRoot; this.#userId = options.userId; - this.#accumulator = createFinalMessageAccumulator(this.turnId); + this.#anchor(this.turnId); } get targetEventId(): string | undefined { - return this.#targetEventId; + return this.#anchor(this.turnId).eventId; } async start(): Promise { - return this.#queue.run(() => this.#start()); + return this.#queue.run(async () => { + const anchor = await this.#startAnchor(this.turnId); + return { descriptor: anchor.descriptor, eventId: anchor.eventId, turnId: this.turnId }; + }); } async publish(part: AGUIEvent): Promise { return this.#queue.run(async () => { if (this.#finalized) throw new Error("Cannot publish to finalized Beeper stream"); - const { eventId } = await this.#start(); - await this.#publishPart(eventId, part); + const anchor = await this.#startAnchor(this.#anchorIdForPart(part)); + await this.#publishPart(anchor, part); }); } @@ -99,8 +111,8 @@ export class BeeperTurnStreamCoordinator { return this.#queue.run(async () => { for (const part of parts) { if (this.#finalized) throw new Error("Cannot publish to finalized Beeper stream"); - const { eventId } = await this.#start(); - await this.#publishPart(eventId, part); + const anchor = await this.#startAnchor(this.#anchorIdForPart(part)); + await this.#publishPart(anchor, part); } }); } @@ -109,13 +121,13 @@ export class BeeperTurnStreamCoordinator { return this.#queue.run(async () => { if (this.#finalized) throw new Error("Beeper stream is already finalized"); const finishReason = normalizeFinishReason(options.finishReason); - const { eventId } = await this.#start(); const terminalPart = options.terminalPart ?? { finishReason, runId: this.turnId, threadId: this.turnId, type: AGUIEventType.RUN_FINISHED, }; + const root = await this.#startAnchor(this.turnId); const snapshot = terminalPart.type === AGUIEventType.RUN_ERROR ? await this.#errorRun({ message: terminalFallbackText(terminalPart), @@ -126,71 +138,83 @@ export class BeeperTurnStreamCoordinator { finishReason, runId: this.turnId, }); - await this.#publishSnapshotEvents(eventId, snapshot); - const finalMessage = options.message ?? nonEmptyRecordValue(snapshot.finalAIMessage) ?? finalizeAccumulatedAIMessage(this.#accumulator); - const accumulatedText = getFinalMessageText(finalMessage); - const finalText = options.body ?? options.finalText ?? (accumulatedText || snapshot.body || terminalFallbackText(terminalPart)); - const finalContent = compactFinalContent({ - aiMessage: finalMessage, - body: finalText, - }); - const finalMetadata = { - ...this.#runMetadata(terminalPart.type === AGUIEventType.RUN_ERROR ? "error" : "complete", terminalPart), - ...(recordValue(snapshot.metadata) ?? {}), - status: this.#runMetadata(terminalPart.type === AGUIEventType.RUN_ERROR ? "error" : "complete", terminalPart).status, - }; - const replacement = await this.#client.beeper.streams.finalizeMessage({ - body: finalContent.body || "...", - content: { - body: finalContent.body || "...", - [BEEPER_AI_KEY]: finalContent.aiMessage, - [BEEPER_AI_METADATA_KEY]: finalMetadata, - [BEEPER_STREAM_DESCRIPTOR_KEY]: this.#streamDescriptor(), - msgtype: "m.text", - }, - eventId, - roomId: this.roomId, - topLevelContent: { - "com.beeper.dont_render_edited": true, - }, - ...(this.#userId ? { userId: this.#userId } : {}), - }); + await this.#publishSnapshotEvents(root, snapshot); + const replacements: SentEvent[] = []; + for (const anchorId of this.#anchorOrder) { + replacements.push(await this.#finalizeAnchor(this.#anchor(anchorId), terminalPart, snapshot, options)); + } this.#finalized = true; + const replacement = replacements[0]; + if (!replacement) throw new Error("Beeper stream did not create a final replacement"); return { - eventId, + eventId: replacement.eventId, roomId: replacement.roomId, - raw: { - logicalEventId: eventId, - raw: replacement.raw, - replacementEventId: replacement.replacementEventId, - }, + raw: replacement.raw, }; }); } - async #start(): Promise { - if (this.#targetEventId && this.#descriptor) { - return { descriptor: this.#descriptor, eventId: this.#targetEventId, turnId: this.turnId }; + #anchor(id: string): BeeperStreamAnchor { + const existing = this.#anchors.get(id); + if (existing) return existing; + const anchor = { + accumulator: createFinalMessageAccumulator(id), + id, + }; + this.#anchors.set(id, anchor); + this.#anchorOrder.push(id); + return anchor; + } + + #anchorIdForPart(part: AGUIEvent): string { + if (part.type === AGUIEventType.TEXT_MESSAGE_START) { + const id = stringValue(part.messageId) ?? this.turnId; + this.#currentAnchorId = id; + return id; } - const snapshot = await this.#beginRun({ - ...(this.#agentId ? { agentId: this.#agentId } : {}), - model: "openclaw/plugin", - runId: this.turnId, - threadId: this.turnId, - }); + if ( + part.type === AGUIEventType.TEXT_MESSAGE_CONTENT || + part.type === AGUIEventType.TEXT_MESSAGE_END + ) { + return stringValue(part.messageId) ?? this.#currentAnchorId; + } + return this.#currentAnchorId; + } + + async #startAnchor(anchorId: string): Promise; eventId: string }> { + const anchor = this.#anchor(anchorId); + if (!this.#runBegun) { + this.#runBegun = true; + const snapshot = await this.#beginRun({ + ...(this.#agentId ? { agentId: this.#agentId } : {}), + model: "openclaw/plugin", + runId: this.turnId, + threadId: this.turnId, + }); + const root = await this.#startAnchorMessage(this.#anchor(this.turnId), snapshot); + await this.#publishSnapshotEvents(root, snapshot); + if (anchor.id === root.id) return root; + } + if (anchor.eventId && anchor.descriptor) return anchor as BeeperStreamAnchor & { descriptor: Record; eventId: string }; + return this.#startAnchorMessage(anchor); + } + + async #startAnchorMessage(anchor: BeeperStreamAnchor, snapshot: MatrixBeeperAIRunSnapshot = emptyRunSnapshot(this.turnId)): Promise; eventId: string }> { const metadata = { ...this.#runMetadata("streaming"), ...(recordValue(snapshot.metadata) ?? {}), data: this.#initialMessageMetadata, }; const initialAIMessage = { - id: this.turnId, - metadata: { turn_id: this.turnId, ...this.#initialMessageMetadata }, + id: anchor.id, + metadata: { message_id: anchor.id, turn_id: this.turnId, ...this.#initialMessageMetadata }, parts: [], role: "assistant", ...(recordValue(snapshot.initialAIMessage) ?? {}), }; + initialAIMessage.id = anchor.id; initialAIMessage.metadata = { + message_id: anchor.id, turn_id: this.turnId, ...this.#initialMessageMetadata, ...(recordValue(initialAIMessage.metadata) ?? {}), @@ -209,18 +233,17 @@ export class BeeperTurnStreamCoordinator { ...(this.#threadRoot ? { threadRootEventId: this.#threadRoot } : {}), ...(this.#userId ? { userId: this.#userId } : {}), }); - this.#descriptor = target.descriptor; - this.#targetEventId = target.eventId; - await this.#publishSnapshotEvents(target.eventId, snapshot); - return { descriptor: target.descriptor, eventId: target.eventId, turnId: this.turnId }; + anchor.descriptor = target.descriptor; + anchor.eventId = target.eventId; + return anchor as BeeperStreamAnchor & { descriptor: Record; eventId: string }; } - async #publishPart(eventId: string, part: AGUIEvent): Promise { + async #publishPart(anchor: BeeperStreamAnchor & { eventId: string }, part: AGUIEvent): Promise { const snapshot = await this.#appendRunEvent({ event: part, runId: this.turnId, }); - await this.#publishSnapshotEvents(eventId, snapshot); + await this.#publishSnapshotEvents(anchor, snapshot); } async #beginRun(options: { agentId?: string; model?: string; runId: string; threadId: string }): Promise { @@ -242,21 +265,76 @@ export class BeeperTurnStreamCoordinator { return this.#client.beeper.aiRuns.error(options); } - async #publishSnapshotEvents(eventId: string, snapshot: MatrixBeeperAIRunSnapshot): Promise { + async #publishSnapshotEvents(anchor: BeeperStreamAnchor & { eventId: string }, snapshot: MatrixBeeperAIRunSnapshot): Promise { for (const part of snapshot.events as AGUIEvent[]) { await this.#client.beeper.streams.publishPart({ ...(this.#agentId ? { agentId: this.#agentId } : {}), - eventId, + eventId: anchor.eventId, part, roomId: this.roomId, turnId: this.turnId, }); for (const accumulatorPart of aguiEventToFinalMessageParts(this.turnId, part)) { - applyFinalMessagePart(this.#accumulator, accumulatorPart); + applyFinalMessagePart(anchor.accumulator, accumulatorPart); } } } + async #finalizeAnchor( + anchor: BeeperStreamAnchor, + terminalPart: AGUIEvent, + snapshot: MatrixBeeperAIRunSnapshot, + options: BeeperStreamFinalizeOptions, + ): Promise { + if (!anchor.eventId) throw new Error(`Beeper stream anchor ${anchor.id} was not started`); + const singleAnchor = this.#anchorOrder.length === 1; + const finalMessage = options.message && anchor.id === this.turnId + ? options.message + : singleAnchor + ? nonEmptyRecordValue(snapshot.finalAIMessage) ?? finalizeAccumulatedAIMessage(anchor.accumulator) + : finalizeAccumulatedAIMessage(anchor.accumulator); + const accumulatedText = getFinalMessageText(finalMessage); + const fallbackText = anchor.id === this.turnId ? snapshot.body : ""; + const finalText = anchor.id === this.turnId + ? options.body ?? options.finalText ?? (accumulatedText || fallbackText || terminalFallbackText(terminalPart)) + : accumulatedText || "..."; + const finalContent = compactFinalContent({ + aiMessage: finalMessage, + body: finalText, + }); + const finalMetadata = { + ...this.#runMetadata(terminalPart.type === AGUIEventType.RUN_ERROR ? "error" : "complete", terminalPart), + ...(recordValue(snapshot.metadata) ?? {}), + messageId: anchor.id, + status: this.#runMetadata(terminalPart.type === AGUIEventType.RUN_ERROR ? "error" : "complete", terminalPart).status, + }; + const replacement = await this.#client.beeper.streams.finalizeMessage({ + body: finalContent.body || "...", + content: { + body: finalContent.body || "...", + [BEEPER_AI_KEY]: finalContent.aiMessage, + [BEEPER_AI_METADATA_KEY]: finalMetadata, + [BEEPER_STREAM_DESCRIPTOR_KEY]: anchor.descriptor ?? this.#streamDescriptor(), + msgtype: "m.text", + }, + eventId: anchor.eventId, + roomId: this.roomId, + topLevelContent: { + "com.beeper.dont_render_edited": true, + }, + ...(this.#userId ? { userId: this.#userId } : {}), + }); + return { + eventId: anchor.eventId, + roomId: replacement.roomId, + raw: { + logicalEventId: anchor.eventId, + raw: replacement.raw, + replacementEventId: replacement.replacementEventId, + }, + }; + } + #runMetadata(state: "streaming" | "complete" | "error", terminalPart?: AGUIEvent): Record { return stripUndefined({ agent: stripUndefined({ @@ -309,6 +387,19 @@ function terminalFallbackText(event: AGUIEvent | undefined): string { return ""; } +function emptyRunSnapshot(runId: string): MatrixBeeperAIRunSnapshot { + return { + body: "...", + events: [], + finalAIMessage: {}, + initialAIMessage: {}, + messageId: runId, + metadata: {}, + runId, + threadId: runId, + }; +} + function aguiEventToFinalMessageParts(turnId: string, event: AGUIEvent): Record[] { switch (event.type) { case AGUIEventType.RUN_STARTED: diff --git a/packages/openclaw/src/beeper-turn-events.ts b/packages/openclaw/src/beeper-turn-events.ts index b0a94b8..005b3bb 100644 --- a/packages/openclaw/src/beeper-turn-events.ts +++ b/packages/openclaw/src/beeper-turn-events.ts @@ -249,6 +249,14 @@ export function mapOpenClawStateDelta(delta: unknown): AGUIEvent[] { return [{ delta: Array.isArray(delta) ? delta : [{ op: "add", path: "/state", value: delta }], type: AGUIEventType.STATE_DELTA }]; } +export function mapOpenClawStateSnapshot(snapshot: unknown): AGUIEvent[] { + return [{ snapshot, type: AGUIEventType.STATE_SNAPSHOT }]; +} + +export function mapOpenClawRaw(source: string, event: unknown): AGUIEvent[] { + return [{ event, source, type: AGUIEventType.RAW } as unknown as AGUIEvent]; +} + export function mapOpenClawCustom(name: string, value: unknown): AGUIEvent[] { return [{ name, type: AGUIEventType.CUSTOM, value }]; } diff --git a/packages/openclaw/src/bridge-agent.test.ts b/packages/openclaw/src/bridge-agent.test.ts index 3d68999..5f0bd75 100644 --- a/packages/openclaw/src/bridge-agent.test.ts +++ b/packages/openclaw/src/bridge-agent.test.ts @@ -67,7 +67,6 @@ describe("OpenClawMatrixBridgeAgent", () => { message: "hello", sessionKey: "agent:codex:main", }); - expect(runtime.transport.request).not.toHaveBeenCalledWith("sessions.send", expect.anything(), expect.anything()); expect(registry.getBindingByRoom("!room:example.com")?.lastRunId).toBe("run_direct"); }); diff --git a/packages/openclaw/src/connector.ts b/packages/openclaw/src/connector.ts index a08c734..abdcacf 100644 --- a/packages/openclaw/src/connector.ts +++ b/packages/openclaw/src/connector.ts @@ -60,7 +60,6 @@ import { parseApprovalReactionContent, parseApprovalResponseContent } from "./ap import { BEEPER_CHANNEL_RUNTIME_CONTEXT_CAPABILITY, BeeperChannelRuntime, - setBeeperChannelRuntime, setBeeperChannelRuntimeForHost, } from "./beeper-channel-runtime"; import { agentPortalSessionKey, OpenClawMatrixBridgeAgent } from "./bridge-agent"; @@ -71,7 +70,6 @@ import { type OpenClawBridgeRuntime, OpenClawPluginRuntimeAdapter, OpenClawHostRuntimeAdapter, - type OpenClawGatewayFeatureSnapshot, type OpenClawHostRuntime, type OpenClawMatrixMessageMetadata, type OpenClawRunRef, @@ -82,7 +80,6 @@ import { agentContactFromOpenClawAgent, agentGhostUserId, serviceBotUserId } fro import type { OpenClawAgentContact, OpenClawBridgeConfig, OpenClawSessionBinding, OpenClawUserContact } from "./types"; const DEFAULT_NEW_SESSION_LABEL = "New OpenClaw Session"; -const MATRIX_HTML_FORMAT = "org.matrix.custom.html"; export interface OpenClawConnectorOptions { config?: OpenClawBridgeConfig; @@ -185,7 +182,6 @@ export class OpenClawBridgeConnector implements BridgeConnector | undefined { return value as Record; } -function arrayValue(value: unknown): unknown[] | undefined { - return Array.isArray(value) ? value : undefined; -} - -function numberValue(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - function stringValue(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } diff --git a/packages/openclaw/src/index.ts b/packages/openclaw/src/index.ts deleted file mode 100644 index 693eea1..0000000 --- a/packages/openclaw/src/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -export * from "./approval"; -export * from "./appservice"; -export * from "./backfill"; -export * from "./beeper-channel-runtime"; -export * from "./beeper-stream"; -export * from "./beeper-setup"; -export * from "./bridge-agent"; -export * from "./cli"; -export * from "./config"; -export * from "./connector"; -export * from "./matrix-parser"; -export * from "./openclaw-extension"; -export * from "./openclaw-runtime"; -export * from "./plugin-entry"; -export * from "./protocol-coverage"; -export * from "./registry"; -export * from "./registration"; -export * from "./rooms"; -export * from "./setup"; -export * from "./setup-entry"; -export * from "./types"; diff --git a/packages/openclaw/src/openclaw-extension.test.ts b/packages/openclaw/src/openclaw-extension.test.ts index d6ea166..c5bf22d 100644 --- a/packages/openclaw/src/openclaw-extension.test.ts +++ b/packages/openclaw/src/openclaw-extension.test.ts @@ -18,7 +18,6 @@ describe("OpenClaw plugin package metadata", () => { }); expect(extension.id).toBe("beeper"); expect(extension.channelPlugin).toBe(registered[0]); - expect(extension.loadChannelPlugin()).toBe(registered[0]); expect(resolveBundledRuntimeChannelRegistration(extension)).toMatchObject({ id: "beeper", plugin: expect.objectContaining({ @@ -41,6 +40,7 @@ describe("OpenClaw plugin package metadata", () => { messaging: expect.any(Object), setup: expect.any(Object), setupWizard: expect.any(Object), + threading: expect.any(Object), }), ]); }); @@ -191,7 +191,7 @@ describe("OpenClaw plugin package metadata", () => { "!dist", "!dist/**", ])); - expect(packageJson.main).toBe("./dist/index.mjs"); + 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"); @@ -212,17 +212,16 @@ function resolveBundledRuntimeChannelRegistration(moduleExport: unknown): { id?: const entry = resolved as { id?: unknown; channelPlugin?: unknown; - loadChannelPlugin?: unknown; }; if ( typeof entry.id !== "string" || - typeof entry.loadChannelPlugin !== "function" + !entry.channelPlugin ) { return {}; } return { id: entry.id, - plugin: entry.channelPlugin ?? entry.loadChannelPlugin(), + plugin: entry.channelPlugin, }; } diff --git a/packages/openclaw/src/openclaw-extension.ts b/packages/openclaw/src/openclaw-extension.ts index 6dd5808..7ef9444 100644 --- a/packages/openclaw/src/openclaw-extension.ts +++ b/packages/openclaw/src/openclaw-extension.ts @@ -1,60 +1,28 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/channel-core"; -import { BeeperChannelConfigSchema, beeperChannelPlugin } from "./setup"; +import type { OpenClawPluginApi, PluginRuntime } from "openclaw/plugin-sdk/channel-core"; +import { BeeperChannelConfigSchemaForSdk, beeperChannelPlugin, setBeeperOpenClawPluginRuntime } from "./setup"; -const startBeeperGatewayAccount = beeperChannelPlugin.gateway.startAccount; - -export interface OpenClawPluginApi { - runtime?: unknown; - registerChannel?: (registration: { plugin: unknown }) => void; - channels?: { - register?: (plugin: unknown) => void; - }; -} - -const sdkEntry = defineChannelPluginEntry({ - id: "beeper", - name: "Beeper", - description: "Bridge OpenClaw sessions and agents into Beeper.", - plugin: beeperChannelPlugin, - configSchema: BeeperChannelConfigSchema as never, - setRuntime: setBeeperChannelRuntime, -} as never) as { - configSchema: unknown; - description: string; - id: string; - name: string; - register: (api: unknown) => void; - setChannelRuntime?: (runtime: unknown) => void; -}; - -export const openClawBeeperPlugin: { +type OpenClawBeeperPluginEntry = { channelPlugin: typeof beeperChannelPlugin; configSchema: unknown; description: string; id: string; - loadChannelPlugin: () => typeof beeperChannelPlugin; name: string; - plugin: typeof beeperChannelPlugin; register: (api: OpenClawPluginApi) => void; - setChannelRuntime?: (runtime: unknown) => void; -} = { - id: sdkEntry.id, - name: sdkEntry.name, - description: sdkEntry.description, - configSchema: sdkEntry.configSchema, - register: (api: OpenClawPluginApi) => sdkEntry.register(api), - ...(sdkEntry.setChannelRuntime ? { setChannelRuntime: sdkEntry.setChannelRuntime } : {}), - channelPlugin: beeperChannelPlugin, + setChannelRuntime?: (runtime: PluginRuntime) => void; +}; + +export const openClawBeeperPlugin: OpenClawBeeperPluginEntry = defineChannelPluginEntry({ + id: "beeper", + name: "Beeper", + description: "Bridge OpenClaw sessions and agents into Beeper.", plugin: beeperChannelPlugin, - loadChannelPlugin: () => beeperChannelPlugin, -} as const; + configSchema: BeeperChannelConfigSchemaForSdk, + setRuntime: setOpenClawRuntime, +}); export default openClawBeeperPlugin; -function setBeeperChannelRuntime(runtime: unknown): void { - beeperChannelPlugin.gateway.startAccount = (ctx: Parameters[0]) => - startBeeperGatewayAccount({ - ...(ctx as Record), - hostRuntime: runtime, - } as Parameters[0]); +function setOpenClawRuntime(runtime: unknown): void { + setBeeperOpenClawPluginRuntime(runtime); } diff --git a/packages/openclaw/src/openclaw-runtime.test.ts b/packages/openclaw/src/openclaw-runtime.test.ts index c54cbf9..5d2cd3e 100644 --- a/packages/openclaw/src/openclaw-runtime.test.ts +++ b/packages/openclaw/src/openclaw-runtime.test.ts @@ -1,8 +1,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { BeeperChannelRuntime, setBeeperChannelRuntime } from "./beeper-channel-runtime"; +import { describe, expect, it, vi } from "vitest"; +import { BeeperChannelRuntime, setBeeperChannelRuntimeForHost } from "./beeper-channel-runtime"; import { createDefaultConfig } from "./config"; import { createOpenClawHostRuntimeAdapter, @@ -12,10 +12,6 @@ import { } from "./openclaw-runtime"; describe("OpenClawPluginRuntimeAdapter", () => { - afterEach(() => { - setBeeperChannelRuntime(undefined); - }); - it("lists OpenClaw agents as Matrix ghost contacts", async () => { const transport = fakeTransport({ "agents.list": { agents: [{ description: "Code", id: "codex", name: "Codex" }] }, @@ -36,7 +32,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { expect(transport.request).toHaveBeenCalledWith("agents.list", {}); }); - it("creates sessions through OpenClaw RPC and rejects generic Beeper sends", async () => { + 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" }, }); @@ -54,40 +50,6 @@ describe("OpenClawPluginRuntimeAdapter", () => { }); await expect(runtime.sendMessage({ message: "hello", sessionKey: "agent:codex:main", timeoutMs: 1000 })) .rejects.toThrow("OpenClaw Beeper turns require OpenClaw channel turn helpers"); - expect(transport.request).not.toHaveBeenCalledWith("sessions.send", expect.anything(), expect.anything()); - }); - - it("keeps management probes on the plugin runtime adapter without command wrappers", async () => { - const transport = fakeTransport({ - "artifacts.list": { artifacts: [{ id: "artifact_1" }] }, - "agents.list": { agents: [{ id: "codex" }] }, - "channels.status": { ok: true }, - "commands.list": { commands: [] }, - "config.get": { config: {} }, - "cron.list": { jobs: [] }, - "health": { ok: true }, - "models.list": { models: ["gpt-5.4"] }, - "sessions.list": { sessions: [] }, - "skills.status": { skills: [] }, - "status": { state: "ready" }, - "tasks.cancel": { cancelled: true }, - "tasks.list": { tasks: [] }, - "tools.catalog": { tools: [{ name: "exec" }] }, - "usage.status": { tokens: 1 }, - }); - const runtime = new OpenClawPluginRuntimeAdapter({ - config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), - transport, - }); - - await expect(runtime.featureSnapshot()).resolves.toMatchObject({ - health: { ok: true }, - models: { models: ["gpt-5.4"] }, - tools: { tools: [{ name: "exec" }] }, - }); - await expect(runtime.call("artifacts.list", { sessionKey: "agent:codex:main" })).resolves.toEqual({ artifacts: [{ id: "artifact_1" }] }); - await expect(runtime.call("tasks.cancel", { reason: "stale", taskId: "task_1" })).resolves.toEqual({ cancelled: true }); - expect(transport.request).toHaveBeenCalledWith("tasks.cancel", { reason: "stale", taskId: "task_1" }, undefined); }); it("filters gateway events by run id and resolves approvals", async () => { @@ -147,20 +109,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { expect(received).toEqual([{ event: "session.message", payload: { runId: "run_1" }, seq: 3 }]); }); - it("does not delegate Beeper session sends to a generic host request", async () => { - const host = { - request: vi.fn(async (method: string) => ({ method, runId: "host_run" })), - }; - const transport = createOpenClawHostRuntimeAdapter({ - ...host, - config: { current: () => ({ agents: { list: [{ id: "main" }] } }) }, - }); - - await expect(transport.request("sessions.send", { key: "session", message: "hi" })).rejects.toThrow("OpenClaw Beeper turns require OpenClaw channel turn helpers"); - expect(host.request).not.toHaveBeenCalled(); - }); - - it("sends host-backed Beeper turns through channel helpers without sessions.send RPC", async () => { + it("sends host-backed Beeper turns through channel helpers", async () => { const beeperStreams = { finalizeMessage: vi.fn(async () => ({ eventId: "$stream-root", @@ -175,13 +124,6 @@ describe("OpenClawPluginRuntimeAdapter", () => { roomId: "!room:example", })), }; - setBeeperChannelRuntime(new BeeperChannelRuntime({ - client: { - beeper: { aiRuns: createTestBeeperAIRuns(), streams: beeperStreams }, - media: { upload: vi.fn() }, - } as never, - userId: "@sh-openclaw-bot:example", - })); const request = vi.fn(async () => { throw new Error("generic request should not be used"); }); @@ -194,23 +136,31 @@ describe("OpenClawPluginRuntimeAdapter", () => { await delivery.deliver?.("direct final", { kind: "final" }); resolveRun?.(); }); + const hostRuntime = { + request, + channel: { + reply: { dispatchReplyWithBufferedBlockDispatcher: vi.fn() }, + session: { + recordInboundSession: vi.fn(), + resolveStorePath: () => "/tmp/openclaw", + }, + turn: { + buildContext: vi.fn((params) => params), + runAssembled, + }, + }, + config: { current: () => ({ agents: { list: [{ id: "main" }] } }) }, + }; + setBeeperChannelRuntimeForHost(hostRuntime, new BeeperChannelRuntime({ + client: { + beeper: { aiRuns: createTestBeeperAIRuns(), streams: beeperStreams }, + media: { upload: vi.fn() }, + } as never, + userId: "@sh-openclaw-bot:example", + })); const runtime = new OpenClawPluginRuntimeAdapter({ config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), - transport: createOpenClawHostRuntimeAdapter({ - request, - channel: { - reply: { dispatchReplyWithBufferedBlockDispatcher: vi.fn() }, - session: { - recordInboundSession: vi.fn(), - resolveStorePath: () => "/tmp/openclaw", - }, - turn: { - buildContext: vi.fn((params) => params), - runAssembled, - }, - }, - config: { current: () => ({ agents: { list: [{ id: "main" }] } }) }, - }), + transport: createOpenClawHostRuntimeAdapter(hostRuntime), }); const sent = await runtime.sendMessage({ @@ -222,13 +172,14 @@ describe("OpenClawPluginRuntimeAdapter", () => { expect(sent.runId).toMatch(/^beeper:/u); await runDone; - expect(request).not.toHaveBeenCalledWith("sessions.send", expect.anything(), expect.anything()); + expect(request).not.toHaveBeenCalled(); expect(runAssembled).toHaveBeenCalledTimes(1); expect(beeperStreams.startMessage).toHaveBeenCalledTimes(1); expect(beeperStreams.finalizeMessage).toHaveBeenCalledWith(expect.objectContaining({ body: "direct final", roomId: "!room:example", })); + setBeeperChannelRuntimeForHost(hostRuntime, undefined); }); it("adapts OpenClaw plugin runtime helpers when no gateway request surface exists", async () => { @@ -304,27 +255,6 @@ describe("OpenClawPluginRuntimeAdapter", () => { })).rejects.toThrow("OpenClaw Beeper requires OpenClaw channel turn helpers"); }); - it("does not expose Beeper-originated sends as host transport RPC", 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.request("sessions.send", { - key: "agent:main:beeper:room", - message: "from Beeper", - idempotencyKey: "$event", - })).rejects.toThrow("OpenClaw Beeper turns require OpenClaw channel turn helpers"); - }); - it("runs Beeper-originated sends through OpenClaw channel turn helpers for live AG-UI progress", async () => { const beeperStreams = { finalizeMessage: vi.fn(async () => ({ @@ -340,13 +270,6 @@ describe("OpenClawPluginRuntimeAdapter", () => { roomId: "!room:example", })), }; - setBeeperChannelRuntime(new BeeperChannelRuntime({ - client: { - beeper: { aiRuns: createTestBeeperAIRuns(), streams: beeperStreams }, - media: { upload: vi.fn() }, - } as never, - userId: "@sh-openclaw-bot:example", - })); const runAssembled = vi.fn(async (params: Record) => { const replyOptions = params.replyOptions as Record void | Promise>; await replyOptions.onReasoningStream?.({ text: "checking" }); @@ -363,7 +286,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { await delivery.deliver?.({ text: "hello world" }); return { dispatchResult: { queuedFinal: true } }; }); - const transport = createOpenClawHostRuntimeAdapter({ + const hostRuntime = { channel: { reply: { dispatchReplyWithBufferedBlockDispatcher: vi.fn(), @@ -385,7 +308,15 @@ describe("OpenClawPluginRuntimeAdapter", () => { }, }, config: { current: () => ({ agents: { list: [{ id: "main" }] } }) }, - }); + }; + setBeeperChannelRuntimeForHost(hostRuntime, new BeeperChannelRuntime({ + client: { + beeper: { aiRuns: createTestBeeperAIRuns(), streams: beeperStreams }, + media: { upload: vi.fn() }, + } as never, + userId: "@sh-openclaw-bot:example", + })); + const transport = createOpenClawHostRuntimeAdapter(hostRuntime); const received: OpenClawGatewayEvent[] = []; let observedRunId: string | undefined; @@ -460,6 +391,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { eventId: "$stream-root", roomId: "!room:example", })); + setBeeperChannelRuntimeForHost(hostRuntime, undefined); }); it("preserves supported dummybridge-style tool ids and avoids replaying duplicate text callbacks", async () => { @@ -477,13 +409,6 @@ describe("OpenClawPluginRuntimeAdapter", () => { roomId: "!room:example", })), }; - setBeeperChannelRuntime(new BeeperChannelRuntime({ - client: { - beeper: { aiRuns: createTestBeeperAIRuns(), streams: beeperStreams }, - media: { upload: vi.fn() }, - } as never, - userId: "@sh-openclaw-bot:example", - })); const runAssembled = vi.fn(async (params: Record) => { const replyOptions = params.replyOptions as Record void | Promise>; await replyOptions.onPartialReply?.({ text: "hel" }); @@ -498,7 +423,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { await delivery.deliver?.({ text: "hello world" }, { kind: "final" }); return { dispatchResult: { queuedFinal: true } }; }); - const transport = createOpenClawHostRuntimeAdapter({ + const hostRuntime = { channel: { reply: { dispatchReplyWithBufferedBlockDispatcher: vi.fn() }, session: { recordInboundSession: vi.fn(), resolveStorePath: () => "/tmp/sessions.json" }, @@ -515,7 +440,15 @@ describe("OpenClawPluginRuntimeAdapter", () => { }, }, config: { current: () => ({ agents: { list: [{ id: "main" }] } }) }, - }); + }; + setBeeperChannelRuntimeForHost(hostRuntime, new BeeperChannelRuntime({ + client: { + beeper: { aiRuns: createTestBeeperAIRuns(), streams: beeperStreams }, + media: { upload: vi.fn() }, + } as never, + userId: "@sh-openclaw-bot:example", + })); + const transport = createOpenClawHostRuntimeAdapter(hostRuntime); const done = (async () => { for await (const event of transport.events()) { @@ -547,6 +480,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { ["tool-a", "read_file"], ["tool-b", "read_file"], ]); + setBeeperChannelRuntimeForHost(hostRuntime, undefined); }); it("streams assistant agent events when reply callbacks only deliver the final block", async () => { @@ -564,23 +498,20 @@ describe("OpenClawPluginRuntimeAdapter", () => { roomId: "!room:example", })), }; - setBeeperChannelRuntime(new BeeperChannelRuntime({ - client: { - beeper: { aiRuns: createTestBeeperAIRuns(), streams: beeperStreams }, - media: { upload: vi.fn() }, - } as never, - userId: "@sh-openclaw-bot:example", - })); let agentEventListener: ((event: { data?: Record; runId?: string; stream?: string }) => void) | undefined; const runAssembled = vi.fn(async (params: Record) => { const replyOptions = params.replyOptions as { runId?: string }; agentEventListener?.({ data: { delta: "hel", text: "hel" }, runId: replyOptions.runId, stream: "assistant" }); agentEventListener?.({ data: { delta: "lo", text: "hello" }, runId: replyOptions.runId, stream: "assistant" }); + 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 transport = createOpenClawHostRuntimeAdapter({ + const hostRuntime = { channel: { reply: { dispatchReplyWithBufferedBlockDispatcher: vi.fn() }, session: { recordInboundSession: vi.fn(), resolveStorePath: () => "/tmp/sessions.json" }, @@ -605,7 +536,15 @@ describe("OpenClawPluginRuntimeAdapter", () => { }; }, }, - }); + }; + setBeeperChannelRuntimeForHost(hostRuntime, new BeeperChannelRuntime({ + client: { + beeper: { aiRuns: createTestBeeperAIRuns(), streams: beeperStreams }, + media: { upload: vi.fn() }, + } as never, + userId: "@sh-openclaw-bot:example", + })); + const transport = createOpenClawHostRuntimeAdapter(hostRuntime); const done = (async () => { for await (const event of transport.events()) { @@ -625,6 +564,13 @@ describe("OpenClawPluginRuntimeAdapter", () => { "lo", " world", ]); + expect(parts).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: "source", type: "CUSTOM", value: { items: [{ title: "Docs", url: "https://example.com" }] } }), + expect.objectContaining({ name: "file", type: "CUSTOM", value: { filename: "report.txt", id: "file_1" } }), + expect.objectContaining({ name: "data", type: "CUSTOM", value: { status: "indexed" } }), + expect.objectContaining({ snapshot: { phase: "retrieval" }, type: "STATE_SNAPSHOT" }), + ])); + setBeeperChannelRuntimeForHost(hostRuntime, undefined); }); it("loads plugin runtime history from the OpenClaw session transcript", async () => { diff --git a/packages/openclaw/src/openclaw-runtime.ts b/packages/openclaw/src/openclaw-runtime.ts index abc9d3d..73a91fc 100644 --- a/packages/openclaw/src/openclaw-runtime.ts +++ b/packages/openclaw/src/openclaw-runtime.ts @@ -4,7 +4,7 @@ import path from "node:path"; import type { OpenClawAgentContact, OpenClawBridgeConfig } from "./types"; import { agentContactFromOpenClawAgent } from "./rooms"; import type { OpenClawApprovalResolvePayload } from "./approval"; -import { getBeeperChannelRuntime, getBeeperChannelRuntimeForHost } from "./beeper-channel-runtime"; +import { getBeeperChannelRuntimeForHost } from "./beeper-channel-runtime"; import { AGUIEventType, closeReasoningPart, @@ -12,8 +12,11 @@ import { finishRunEvents, mapOpenClawApprovalRequest, mapOpenClawApprovalResponse, + mapOpenClawCustom, mapOpenClawMessageDelta, + mapOpenClawRaw, mapOpenClawStateDelta, + mapOpenClawStateSnapshot, mapOpenClawToolEnd, mapOpenClawToolInput, mapOpenClawToolOutput, @@ -161,23 +164,6 @@ export interface OpenClawReplyReference { roomId?: string; } -export interface OpenClawGatewayFeatureSnapshot { - agents?: unknown; - artifacts?: unknown; - channels?: unknown; - commands?: unknown; - config?: unknown; - cron?: unknown; - health?: unknown; - models?: unknown; - sessions?: unknown; - skills?: unknown; - status?: unknown; - tasks?: unknown; - tools?: unknown; - usage?: unknown; -} - export interface OpenClawSessionRef { agentId?: string; key: string; @@ -234,7 +220,6 @@ export interface OpenClawSessionTurnRuntime extends OpenClawSessionHistoryRuntim export interface OpenClawBridgeRuntime extends OpenClawSessionTurnRuntime { close(): Promise; - featureSnapshot(): Promise; } export class OpenClawPluginRuntimeAdapter { @@ -252,45 +237,6 @@ export class OpenClawPluginRuntimeAdapter { return (agents ?? []).map((agent) => agentContactFromOpenClawAgent(this.config, recordValue(agent) ?? {})); } - call(method: string, params?: unknown, options?: GatewayRequestOptions): Promise { - return this.transport.request(method, params, options); - } - - async featureSnapshot(): Promise { - const entries = await Promise.allSettled([ - this.call("health", {}), - this.call("status", {}), - this.call("models.list", { view: "configured" }), - this.call("channels.status", {}), - this.call("sessions.list", { includeArchived: true }), - this.call("commands.list", {}), - this.call("tools.catalog", {}), - this.call("skills.status", {}), - this.call("tasks.list", { limit: 100 }), - this.call("usage.status", {}), - this.call("artifacts.list", {}), - this.call("cron.list", {}), - this.call("agents.list", {}), - this.call("config.get", {}), - ]); - return stripUndefined({ - health: settledValue(entries[0]), - status: settledValue(entries[1]), - models: settledValue(entries[2]), - channels: settledValue(entries[3]), - sessions: settledValue(entries[4]), - commands: settledValue(entries[5]), - tools: settledValue(entries[6]), - skills: settledValue(entries[7]), - tasks: settledValue(entries[8]), - usage: settledValue(entries[9]), - artifacts: settledValue(entries[10]), - cron: settledValue(entries[11]), - agents: settledValue(entries[12]), - config: settledValue(entries[13]), - }); - } - async createSession(options: OpenClawSessionCreateOptions): Promise { const raw = await this.transport.request("sessions.create", stripUndefined({ agentId: options.agentId, @@ -391,9 +337,6 @@ export class OpenClawHostRuntimeAdapter implements OpenClawRuntimeRequestSurface if (isDirectPluginRuntimeMethod(method)) { return this.#pluginRuntimeRequest(method, params, options); } - if (method === "sessions.send") { - return Promise.reject(new Error("OpenClaw Beeper turns require OpenClaw channel turn helpers")); - } const call = this.#runtime.request ?? this.#runtime.call; if (!call) return this.#pluginRuntimeRequest(method, params, options); return call(method, params, options); @@ -482,10 +425,6 @@ function booleanValue(value: unknown): boolean | undefined { return typeof value === "boolean" ? value : undefined; } -function settledValue(result: PromiseSettledResult): unknown { - return result.status === "fulfilled" ? result.value : undefined; -} - async function* emptyEvents(): AsyncIterable {} class LocalEventBus { @@ -784,12 +723,7 @@ function startPluginRun( run: () => Promise, ): void { localEvents.emit({ event: "run.queued", payload: base }); - getBeeperChannelRuntime()?.debug("openclaw_beeper_run_queued", base); void run().catch((error) => { - getBeeperChannelRuntime()?.debug("openclaw_beeper_run_failed", { - ...base, - error: errorText(error), - }); localEvents.emit({ event: "run.failed", payload: { @@ -1004,19 +938,7 @@ function forwardAgentRuntimeStreamEvents(params: { }): (() => void) | undefined { const onAgentEvent = typeof params.runtime.events === "object" ? params.runtime.events?.onAgentEvent : undefined; if (!onAgentEvent) return undefined; - getBeeperChannelRuntime()?.debug("openclaw_beeper_agent_event_forwarder_attached", { - runId: params.runId, - }); return onAgentEvent((event) => { - if (event.stream === "assistant" || event.stream === "thinking") { - getBeeperChannelRuntime()?.debug("openclaw_beeper_agent_event_seen", { - dataKeys: Object.keys(recordValue(event.data) ?? {}), - eventRunId: event.runId, - expectedRunId: params.runId, - matchesRun: event.runId === params.runId, - stream: event.stream, - }); - } if (event.runId !== params.runId) return; const data = recordValue(event.data) ?? {}; switch (event.stream) { @@ -1026,6 +948,26 @@ function forwardAgentRuntimeStreamEvents(params: { case "thinking": void params.stream.reasoningPayload(data); break; + case "state": + case "snapshot": + void params.stream.stateSnapshot(data); + break; + case "source": + case "sources": + void params.stream.customData("source", data); + break; + case "file": + case "files": + case "document": + case "documents": + void params.stream.customData("file", data); + break; + case "data": + void params.stream.customData("data", data); + break; + case "raw": + void params.stream.raw(event.stream, data); + break; default: break; } @@ -1042,7 +984,7 @@ function createBeeperReplyStreamEmitter(base: { sessionKey: string; threadRoot?: string; }) { - const channelRuntime = getBeeperChannelRuntimeForHost(base.hostRuntime) ?? getBeeperChannelRuntime(); + const channelRuntime = getBeeperChannelRuntimeForHost(base.hostRuntime); if (!channelRuntime) { throw new Error("OpenClaw Beeper requires the Beeper channel runtime for native rich streaming"); } @@ -1252,6 +1194,18 @@ function createBeeperReplyStreamEmitter(base: { await publish(mapOpenClawStateDelta([{ op: "add", path: "/plan", value: steps }])); } }, + stateSnapshot: async (payload: unknown) => { + emit("state.snapshot", { snapshot: payload }); + await publish(mapOpenClawStateSnapshot(payload)); + }, + customData: async (name: string, payload: unknown) => { + emit(`${name}.event`, { value: payload }); + await publish(mapOpenClawCustom(name, payload)); + }, + raw: async (source: string, payload: unknown) => { + emit("raw.event", { source, value: payload }); + await publish(mapOpenClawRaw(source, payload)); + }, approvalEvent: async (payload: unknown) => { const data = recordValue(payload) ?? {}; const phase = stringValue(data.phase); diff --git a/packages/openclaw/src/protocol-coverage.test.ts b/packages/openclaw/src/protocol-coverage.test.ts index cdedec6..68a3bc8 100644 --- a/packages/openclaw/src/protocol-coverage.test.ts +++ b/packages/openclaw/src/protocol-coverage.test.ts @@ -44,7 +44,6 @@ describe("OpenClaw gateway protocol coverage manifest", () => { 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.managementSurface).toBe("OpenClaw in-process plugin runtime"); expect(OPENCLAW_BRIDGE_COVERAGE.methodAccess.pluginRuntimeAdapters).toEqual(expect.arrayContaining([ "agents.list", "sessions.list", diff --git a/packages/openclaw/src/protocol-coverage.ts b/packages/openclaw/src/protocol-coverage.ts index ca1aecc..a1fb6a8 100644 --- a/packages/openclaw/src/protocol-coverage.ts +++ b/packages/openclaw/src/protocol-coverage.ts @@ -110,7 +110,6 @@ export const OPENCLAW_GATEWAY_COMMON_METHODS = [ "sessions.describe", "sessions.resolve", "sessions.create", - "sessions.send", "sessions.steer", "sessions.abort", "sessions.patch", @@ -215,8 +214,6 @@ export const OPENCLAW_BRIDGE_COVERAGE = { pluginRuntimeAdapters: ["agents.list", "sessions.list", "sessions.create", "chat.history", "exec.approval.resolve", "plugin.approval.resolve"], commonGatewayMethods: OPENCLAW_GATEWAY_COMMON_METHODS, beeperTurnDispatch: "runtime.channel.turn.runAssembled", - managementSurface: "OpenClaw in-process plugin runtime", - snapshotProbe: ["health", "status", "models.list", "channels.status", "sessions.list", "commands.list", "tools.catalog", "skills.status", "tasks.list", "usage.status", "artifacts.list", "cron.list", "agents.list", "config.get"], }, source: ".upstream/openclaw/docs/gateway/protocol.md", } as const; diff --git a/packages/openclaw/src/setup-entry.ts b/packages/openclaw/src/setup-entry.ts index 60d9382..0fbc3fe 100644 --- a/packages/openclaw/src/setup-entry.ts +++ b/packages/openclaw/src/setup-entry.ts @@ -1,14 +1,6 @@ import { defineSetupPluginEntry } from "openclaw/plugin-sdk/channel-core"; import { beeperChannelPlugin } from "./setup"; -export const openClawBeeperSetupEntry: { - kind: "bundled-channel-setup-entry"; - loadSetupPlugin: () => typeof beeperChannelPlugin; - plugin: typeof beeperChannelPlugin; -} = { - ...defineSetupPluginEntry(beeperChannelPlugin), - kind: "bundled-channel-setup-entry", - loadSetupPlugin: () => beeperChannelPlugin, -} as const; +export const openClawBeeperSetupEntry = defineSetupPluginEntry(beeperChannelPlugin); export default openClawBeeperSetupEntry; diff --git a/packages/openclaw/src/setup.test.ts b/packages/openclaw/src/setup.test.ts index f8fbed0..9a93425 100644 --- a/packages/openclaw/src/setup.test.ts +++ b/packages/openclaw/src/setup.test.ts @@ -3,7 +3,7 @@ import extension from "./openclaw-extension"; import setupEntry from "./setup-entry"; import { BeeperChannelRuntime, - setBeeperChannelRuntime, + setBeeperChannelRuntimeForHost, } from "./beeper-channel-runtime"; import { applyBeeperChannelSettings, @@ -15,6 +15,7 @@ import { defaultBeeperChannelSettings, getBeeperChannelSettings, isBeeperChannelConfigured, + setBeeperOpenClawPluginRuntime, startBeeperGatewayAccount, validateBeeperSetupInput, } from "./setup"; @@ -31,11 +32,11 @@ describe("OpenClaw Beeper setup surface", () => { beforeEach(() => { appserviceMocks.accountFromOpenClawConfig.mockClear(); appserviceMocks.startOpenClawBeeperBridge.mockReset(); - setBeeperChannelRuntime(undefined); + setBeeperOpenClawPluginRuntime(undefined); }); it("exposes a channel plugin through the setup entry shape OpenClaw loads", () => { - expect(extension.plugin).toBe(beeperChannelPlugin); + expect(extension.channelPlugin).toBe(beeperChannelPlugin); expect(beeperChannelPlugin).toMatchObject({ id: "beeper", meta: { @@ -48,6 +49,7 @@ describe("OpenClaw Beeper setup surface", () => { reactions: true, threads: true, }, + threading: expect.any(Object), reload: { configPrefixes: ["channels.beeper", "plugins.entries.beeper"], }, @@ -204,8 +206,8 @@ describe("OpenClaw Beeper setup surface", () => { }); expect(beeperChannelPlugin.actions).toEqual(expect.any(Object)); expect(beeperChannelPlugin.actions.describeMessageTool()).toMatchObject({ - actions: ["send", "react", "read", "mark_unread"], - capabilities: ["text", "reactions", "readReceipts", "markedUnread"], + actions: ["send", "react", "read"], + capabilities: [], }); expect(beeperChannelPlugin.actions.extractToolSend({ args: { action: "send", threadId: "$thread", to: "beeper:!room" }, @@ -317,10 +319,8 @@ describe("OpenClaw Beeper setup surface", () => { it("exposes the lightweight OpenClaw setup-entry contract", () => { expect(setupEntry).toMatchObject({ - kind: "bundled-channel-setup-entry", - loadSetupPlugin: expect.any(Function), + plugin: beeperChannelPlugin, }); - expect(setupEntry.loadSetupPlugin()).toBe(beeperChannelPlugin); }); it("applies dashboard setup input into channels.beeper settings", async () => { @@ -516,7 +516,7 @@ describe("OpenClaw Beeper setup surface", () => { expect(isBeeperChannelConfigured(cfg)).toBe(true); }); - it("legacy direct applyBeeperSetupConfig path still supports test/runtime callers", async () => { + it("applies setup input through the channel setup adapter implementation", async () => { const { applyBeeperSetupConfig } = await import("./setup"); const cfg = await applyBeeperSetupConfig({ cfg: {}, @@ -633,7 +633,7 @@ describe("OpenClaw Beeper setup surface", () => { mode: "self-hosted-appservice", running: false, }); - expect(beeperStatusAdapter.resolveAccountState({ configured: false, enabled: true })).toBe("missing_credentials"); + expect(beeperStatusAdapter.resolveAccountState({ configured: false, enabled: true })).toBe("not configured"); expect(beeperStatusAdapter.collectStatusIssues([snapshot])).toEqual([ expect.objectContaining({ message: expect.stringContaining("not fully configured"), @@ -720,7 +720,9 @@ describe("OpenClaw Beeper setup surface", () => { }), login: { id: "openclaw:plugin" }, }); - setBeeperChannelRuntime(runtime); + const hostRuntime = {}; + setBeeperOpenClawPluginRuntime(hostRuntime); + setBeeperChannelRuntimeForHost(hostRuntime, runtime); runtime.createStreamPublisher({ agentId: "codex", roomId: "!room", diff --git a/packages/openclaw/src/setup.ts b/packages/openclaw/src/setup.ts index 8e16d2b..1f99f14 100644 --- a/packages/openclaw/src/setup.ts +++ b/packages/openclaw/src/setup.ts @@ -1,17 +1,15 @@ -import { createChannelPluginBase } from "openclaw/plugin-sdk/channel-core"; +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 { BridgeLogger } from "@beeper/pickle-bridge"; import { createConfigFromOpenClawSetup, DEFAULT_REGISTRATION_URL, defaultDataDir } from "./config"; import type { setupOpenClawBeeperBridge, SetupOpenClawBeeperBridgeOptions } from "./beeper-setup"; import { createBeeperApprovalNotice } from "./approval"; -import { requireBeeperChannelRuntime } from "./beeper-channel-runtime"; +import { requireBeeperChannelRuntimeForHost } from "./beeper-channel-runtime"; import type { OpenClawHostRuntime } from "./openclaw-runtime"; -export type OpenClawSetupConfig = { - channels?: Record; - plugins?: { - entries?: Record; - }; -}; +export type OpenClawSetupConfig = OpenClawConfig; export type BeeperImportSource = "dashboard" | "tui" | "channels" | "archived"; @@ -97,7 +95,7 @@ type BeeperGatewayContext = { error?: (message: string) => void; }; runtime?: unknown; - setStatus?: (next: Record) => void; + setStatus?: (next: ChannelAccountSnapshot) => void; }; type BeeperWizardPrompter = { @@ -126,6 +124,16 @@ type BeeperWizardPrompter = { 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 = { type: "object", additionalProperties: false, @@ -352,7 +360,7 @@ export const beeperMessagingAdapter = { } : null, resolveSessionTarget: ({ id }: { kind: "group" | "channel"; id: string }) => `beeper:${id}`, - inferTargetChatType: () => "direct", + inferTargetChatType: (): ChatType => "direct", formatTargetDisplay: ({ target, display }: { target: string; display?: string }) => display?.trim() || formatBeeperTargetDisplay(target), resolveOutboundSessionRoute: (params: { @@ -374,9 +382,9 @@ export const beeperMessagingAdapter = { ].join(":"); return { baseSessionKey: sessionKey, - chatType: "direct", + chatType: "direct" as const, from: `beeper:${target}`, - peer: { kind: "direct", id: target }, + peer: { kind: "direct" as const, id: target }, sessionKey, to: `beeper:${target}`, }; @@ -527,14 +535,20 @@ export const beeperApprovalCapability = { }, } as const; +const beeperMessageToolActions = ["send", "react", "read"] as const satisfies readonly ChannelMessageActionName[]; + +function beeperToolTextResult(text: string) { + return { content: [{ type: "text" as const, text }], details: {} }; +} + export const beeperMessageActions = { - resolveExecutionMode: () => "gateway", + resolveExecutionMode: () => "gateway" as const, describeMessageTool: () => ({ - actions: ["send", "react", "read", "mark_unread"], - capabilities: ["text", "reactions", "readReceipts", "markedUnread"], + actions: beeperMessageToolActions, + capabilities: [], }), supportsAction: ({ action }: { action: string }) => - action === "send" || action === "react" || action === "read" || action === "mark_unread", + action === "send" || action === "react" || action === "read", extractToolSend: () => null, handleAction: async (ctx: { action: string; params: Record; mediaReadFile?: (filePath: string) => Promise; sessionKey?: string | null }) => { const runtime = requireBeeperChannelRuntime(); @@ -545,7 +559,7 @@ export const beeperMessageActions = { ...(ctx.sessionKey !== undefined ? { sessionKey: ctx.sessionKey } : {}), text, }); - return { content: [{ type: "text", text: `Published Beeper native stream text ${sent.eventId}` }] }; + return beeperToolTextResult(`Published Beeper native stream text ${sent.eventId}`); } const roomId = resolveBeeperRoomTarget(readRequiredString(params, "to", "roomId", "channelId")); if (ctx.action === "react") { @@ -554,21 +568,21 @@ export const beeperMessageActions = { const remove = params.remove === true; if (remove) { await runtime.removeReaction({ emoji, eventId, roomId }); - return { content: [{ type: "text", text: `Removed Beeper reaction ${emoji}` }] }; + return beeperToolTextResult(`Removed Beeper reaction ${emoji}`); } const sent = await runtime.react({ emoji, eventId, roomId }); - return { content: [{ type: "text", text: `Sent Beeper reaction ${sent.eventId}` }] }; + return beeperToolTextResult(`Sent Beeper reaction ${sent.eventId}`); } if (ctx.action === "read") { const eventId = readRequiredString(params, "messageId", "eventId"); await runtime.readReceipt({ eventId, roomId }); - return { content: [{ type: "text", text: `Marked Beeper message read ${eventId}` }] }; + return beeperToolTextResult(`Marked Beeper message read ${eventId}`); } if (ctx.action === "mark_unread") { const eventId = readRequiredString(params, "messageId", "eventId"); const unread = params.unread !== false; await runtime.markUnread({ eventId, roomId, unread }); - return { content: [{ type: "text", text: `${unread ? "Marked" : "Unmarked"} Beeper room unread` }] }; + return beeperToolTextResult(`${unread ? "Marked" : "Unmarked"} Beeper room unread`); } throw new Error(`Unsupported Beeper message action: ${ctx.action}`); }, @@ -640,7 +654,7 @@ export const beeperSetupWizard = { }, async configureInteractive(ctx: { cfg: OpenClawSetupConfig; - runtime?: BeeperSetupRuntime; + runtime?: unknown; prompter: BeeperWizardPrompter; }) { const current = { @@ -751,7 +765,8 @@ export const beeperSetupWizard = { cfg: ctx.cfg, input, }; - if (ctx.runtime !== undefined) setupParams.runtime = ctx.runtime; + const setupRuntime = beeperSetupRuntime(ctx.runtime); + if (setupRuntime) setupParams.runtime = setupRuntime; const cfg = await applyBeeperSetupConfig(setupParams); progress?.stop("Beeper bridge configured"); return { accountId: "default", cfg }; @@ -775,8 +790,8 @@ export const beeperChannelConfig = { isConfigured: (account: { configured?: boolean }) => account.configured === true, hasConfiguredState: ({ cfg }: { cfg: OpenClawSetupConfig }) => isBeeperChannelConfigured(cfg), describeAccount: (account: { configured?: boolean; settings?: BeeperChannelSettings }) => ({ - id: "default", - label: "Beeper", + accountId: "default", + name: "Beeper", configured: account.configured === true, extra: { registrationUrl: account.settings?.registrationUrl, @@ -822,14 +837,17 @@ export const beeperStatusAdapter = { }, resolveAccountState: ({ configured, enabled }: { configured: boolean; enabled: boolean }) => { if (!enabled) return "disabled"; - return configured ? "configured" : "missing_credentials"; + return configured ? "configured" : "not configured"; }, collectStatusIssues: (accounts: Array<{ configured?: boolean; enabled?: boolean }>) => accounts .filter((account) => account.enabled !== false && account.configured !== true) - .map(() => ({ + .map((account) => ({ + accountId: "accountId" in account && typeof account.accountId === "string" ? account.accountId : "default", + channel: BEEPER_CHANNEL_ID, + kind: "config" as const, message: "Beeper bridge is not fully configured; run Beeper channel setup.", - severity: "warning", + severity: "warning" as const, })), }; @@ -871,63 +889,89 @@ async function loadBeeperSetupBridge(): Promise & { uiHints: typeof BeeperChannelUiHints } = { + ...createChatChannelPlugin({ + base: { + ...createChannelPluginBase({ id: BEEPER_CHANNEL_ID, - label: "Beeper", - selectionLabel: "Beeper bridge", - docsPath: "/channels/beeper", - docsLabel: "beeper", - blurb: "bridges OpenClaw sessions and agents into Beeper.", - order: 90, - quickstartAllowFrom: true, + meta: { + id: BEEPER_CHANNEL_ID, + label: "Beeper", + selectionLabel: "Beeper bridge", + docsPath: "/channels/beeper", + docsLabel: "beeper", + blurb: "bridges OpenClaw sessions and agents into Beeper.", + order: 90, + quickstartAllowFrom: true, + }, + capabilities: BeeperChannelCapabilities, + reload: { configPrefixes: ["channels.beeper", "plugins.entries.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; + }, }, - capabilities: { - chatTypes: ["direct", "group", "thread"], - blockStreaming: true, - media: true, - nativeCommands: true, - reactions: true, - threads: true, + gateway: { + startAccount: startBeeperGatewayAccount, + stopAccount: stopBeeperGatewayAccount, }, - reload: { configPrefixes: ["channels.beeper", "plugins.entries.beeper"] }, - commands: beeperCommandAdapter as never, - configSchema: BeeperChannelConfigSchema as never, - config: beeperChannelConfig as never, - setup: beeperSetupAdapter as never, - setupWizard: beeperSetupWizard as never, - agentPrompt: beeperAgentPromptAdapter as never, + }, + threading: { topLevelReplyToMode: "reply" }, }), uiHints: BeeperChannelUiHints, - 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: string; conversationId: string }) => - compiledBinding === conversationId, - resolveCommandConversation: ({ originatingTo, commandTo, fallbackTo }: { - originatingTo?: string; - commandTo?: string; - fallbackTo?: string; - }) => commandTo ?? originatingTo ?? fallbackTo, - }, - gateway: { - startAccount: startBeeperGatewayAccount, - stopAccount: stopBeeperGatewayAccount, - }, }; +export type BeeperChannelPlugin = typeof beeperChannelPlugin; + function stripUndefined>(input: T): T { for (const key of Object.keys(input)) { if (input[key] === undefined) delete input[key]; @@ -979,10 +1023,20 @@ function beeperOutboundResult(sent: { eventId: string; roomId: string }): { 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, }; } @@ -1081,7 +1135,7 @@ function stringValue(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } -export async function startBeeperGatewayAccount(ctx: BeeperGatewayContext): Promise { +export async function startBeeperGatewayAccount(ctx: BeeperGatewayContext | ChannelGatewayContext<{ accountId: string; configured: boolean; settings: BeeperChannelSettings }>): Promise { try { ctx.log?.info?.("Beeper bridge startup beginning."); const settings = getBeeperChannelSettings(ctx.cfg); @@ -1168,7 +1222,7 @@ function hasOpenClawSessionRuntime(value: object): value is OpenClawHostRuntime || typeof (session as { getSessionEntry?: unknown }).getSessionEntry === "function"; } -export async function stopBeeperGatewayAccount(ctx: BeeperGatewayContext): Promise { +export async function stopBeeperGatewayAccount(ctx: BeeperGatewayContext | ChannelGatewayContext<{ accountId: string; configured: boolean; settings: BeeperChannelSettings }>): Promise { const bridge = startedBridges.get(gatewayAccountKey(ctx.accountId)); if (!bridge) return; startedBridges.delete(gatewayAccountKey(ctx.accountId)); @@ -1348,6 +1402,13 @@ function setupBeeperBaseDomain(env: BeeperChannelSettings["beeperEnv"]): string return "beeper-staging.com"; } +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 accountId || "default"; } diff --git a/packages/openclaw/tsdown.config.ts b/packages/openclaw/tsdown.config.ts index c6f58b7..ed6fefa 100644 --- a/packages/openclaw/tsdown.config.ts +++ b/packages/openclaw/tsdown.config.ts @@ -6,6 +6,6 @@ export default defineConfig({ alwaysBundle: [/^@beeper\//], }, dts: true, - entry: ["src/approval.ts", "src/appservice.ts", "src/backfill.ts", "src/beeper-channel-runtime.ts", "src/beeper-stream.ts", "src/beeper-setup.ts", "src/bridge-agent.ts", "src/cli.ts", "src/config.ts", "src/connector.ts", "src/index.ts", "src/matrix-parser.ts", "src/openclaw-extension.ts", "src/openclaw-runtime.ts", "src/plugin-entry.ts", "src/protocol-coverage.ts", "src/registry.ts", "src/registration.ts", "src/rooms.ts", "src/serial.ts", "src/setup.ts", "src/setup-entry.ts", "src/types.ts"], + entry: ["src/approval.ts", "src/appservice.ts", "src/backfill.ts", "src/beeper-channel-runtime.ts", "src/beeper-stream.ts", "src/beeper-setup.ts", "src/bridge-agent.ts", "src/cli.ts", "src/config.ts", "src/connector.ts", "src/matrix-parser.ts", "src/openclaw-extension.ts", "src/openclaw-runtime.ts", "src/plugin-entry.ts", "src/protocol-coverage.ts", "src/registry.ts", "src/registration.ts", "src/rooms.ts", "src/serial.ts", "src/setup.ts", "src/setup-entry.ts", "src/types.ts"], format: ["esm"], }); diff --git a/packages/pickle/src/streams/beeper-message.ts b/packages/pickle/src/streams/beeper-message.ts index 8b26e18..1c877b3 100644 --- a/packages/pickle/src/streams/beeper-message.ts +++ b/packages/pickle/src/streams/beeper-message.ts @@ -260,19 +260,19 @@ export function getFinalMessageText(message: Record): string { 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; @@ -300,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 } @@ -317,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, @@ -335,28 +336,31 @@ 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({ - content: typeof content === "string" ? takeText(content, options.budget) : content, state: part.state, + ...(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 === "tool-call" || part.type === "dynamic-tool" || (typeof part.type === "string" && part.type.startsWith("tool-"))) { return [stripUndefined({ arguments: part.arguments, - id: part.id ?? part.toolCallId, + id: part.id, input: options.keepToolInput ? part.input : undefined, - name: part.name ?? part.toolName, - output: part.output, + name: part.name, + output: options.keepToolOutput ? part.output : undefined, state: part.state, toolCallId: part.toolCallId, - type: "tool-call", + toolName: part.toolName, + type: part.type, })]; } return []; diff --git a/scripts/audit-package-surface.mjs b/scripts/audit-package-surface.mjs index 2b80b09..55848c4 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,6 +11,9 @@ 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")); const sourceDir = join(packageDir, "src"); for (const file of await sourceFiles(sourceDir)) { @@ -34,6 +37,15 @@ if (failures.length > 0) { process.exit(1); } +async function exists(file) { + try { + await access(file); + return true; + } catch { + return false; + } +} + async function sourceFiles(dir) { const result = []; for (const entry of await readdir(dir, { withFileTypes: true })) { From 739e0ec932ba419fb808c6b1c6a1df0c65d22e95 Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Wed, 27 May 2026 14:45:29 +0200 Subject: [PATCH 40/43] Sync openclaw manifest schema with bridge config --- packages/openclaw/openclaw.plugin.json | 235 ++++++------------ packages/openclaw/package.json | 3 +- .../openclaw/scripts/sync-manifest-schema.mjs | 17 ++ packages/openclaw/src/appservice.test.ts | 9 +- packages/openclaw/src/appservice.ts | 13 +- packages/openclaw/src/backfill.test.ts | 35 ++- packages/openclaw/src/backfill.ts | 4 +- .../src/beeper-channel-config.schema.json | 88 +++++++ packages/openclaw/src/beeper-setup.test.ts | 16 +- packages/openclaw/src/beeper-setup.ts | 22 +- packages/openclaw/src/beeper-stream.test.ts | 4 +- packages/openclaw/src/bridge-agent.test.ts | 4 +- packages/openclaw/src/cli.test.ts | 20 +- packages/openclaw/src/cli.ts | 26 +- packages/openclaw/src/config.test.ts | 21 -- packages/openclaw/src/config.ts | 23 -- packages/openclaw/src/connector.test.ts | 28 +-- packages/openclaw/src/connector.ts | 7 +- packages/openclaw/src/integration.test.ts | 24 +- .../openclaw/src/openclaw-extension.test.ts | 13 +- .../openclaw/src/openclaw-runtime.test.ts | 2 +- packages/openclaw/src/registration.test.ts | 16 +- packages/openclaw/src/registration.ts | 33 ++- packages/openclaw/src/registry.test.ts | 8 +- packages/openclaw/src/rooms.test.ts | 16 +- packages/openclaw/src/rooms.ts | 10 +- packages/openclaw/src/setup.test.ts | 74 +----- packages/openclaw/src/setup.ts | 131 +--------- packages/openclaw/src/types.ts | 9 - 29 files changed, 328 insertions(+), 583 deletions(-) create mode 100644 packages/openclaw/scripts/sync-manifest-schema.mjs create mode 100644 packages/openclaw/src/beeper-channel-config.schema.json diff --git a/packages/openclaw/openclaw.plugin.json b/packages/openclaw/openclaw.plugin.json index 9f6d1e4..7bf98d3 100644 --- a/packages/openclaw/openclaw.plugin.json +++ b/packages/openclaw/openclaw.plugin.json @@ -18,27 +18,18 @@ "PICKLE_OPENCLAW_APPSERVICE_ID", "PICKLE_OPENCLAW_APPROVAL_BEHAVIOR", "PICKLE_OPENCLAW_BACKFILL_LIMIT", - "PICKLE_OPENCLAW_BASE_DOMAIN", "PICKLE_OPENCLAW_BEEPER_ENV", "PICKLE_OPENCLAW_BRIDGE_ID", - "PICKLE_OPENCLAW_BRIDGE_MANAGER_POST_STATE", "PICKLE_OPENCLAW_BRIDGE_MANAGER_TOKEN", "PICKLE_OPENCLAW_CONTACT_VISIBILITY", "PICKLE_OPENCLAW_DATA_DIR", "PICKLE_OPENCLAW_DEVICE_ID", - "PICKLE_OPENCLAW_GHOST_LOCALPART_PREFIX", "PICKLE_OPENCLAW_HOMESERVER", "PICKLE_OPENCLAW_HOMESERVER_DOMAIN", "PICKLE_OPENCLAW_HS_TOKEN", "PICKLE_OPENCLAW_IMPORT_SOURCES", "PICKLE_OPENCLAW_MATRIX_DEVICE_ID", - "PICKLE_OPENCLAW_MATRIX_USER_ID", - "PICKLE_OPENCLAW_NON_FEDERATED_ROOMS", - "PICKLE_OPENCLAW_REGISTRATION_URL", - "PICKLE_OPENCLAW_SENDER_LOCALPART", - "PICKLE_OPENCLAW_SERVICE_BOT_LOCALPART", - "PICKLE_OPENCLAW_STORE_PATH", - "PICKLE_OPENCLAW_USER_LOCALPART_PREFIX" + "PICKLE_OPENCLAW_MATRIX_USER_ID" ] }, "uiHints": { @@ -67,21 +58,45 @@ "type": "object", "additionalProperties": false, "properties": { - "enabled": { - "type": "boolean", - "description": "Enable the Beeper bridge channel." - }, "accessToken": { "type": "string", "description": "Beeper Matrix access token returned by login." }, + "appserviceId": { + "type": "string", + "description": "Matrix appservice id used in registration namespaces." + }, "asToken": { "type": "string", "description": "Appservice token returned by Beeper bridge registration." }, - "appserviceId": { + "allowedRoomIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Optional allow-list of Matrix rooms the bridge may import from." + }, + "allowedUserIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Optional allow-list of Matrix users the bridge may accept commands from." + }, + "enabled": { + "type": "boolean", + "description": "Enable the Beeper bridge channel." + }, + "beeperEnv": { "type": "string", - "description": "Matrix appservice id used in registration namespaces." + "enum": [ + "production", + "staging", + "dev", + "local" + ], + "description": "Beeper environment for login and appservice registration." }, "bridgeId": { "type": "string", @@ -91,10 +106,6 @@ "type": "string", "description": "Directory for bridge config, registration, and runtime state." }, - "registrationUrl": { - "type": "string", - "description": "Public or LAN callback URL for the Matrix appservice." - }, "homeserver": { "type": "string", "description": "Beeper Matrix homeserver URL returned by login." @@ -111,19 +122,9 @@ "type": "string", "description": "Beeper Matrix user id for this bridge." }, - "allowedRoomIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Optional allow-list of Matrix rooms the bridge may import from." - }, - "allowedUserIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Optional allow-list of Matrix users the bridge may accept commands from." + "bridgeManagerToken": { + "type": "string", + "description": "Beeper bridge-manager token used to register the self-hosted bridge." }, "importSources": { "type": "array", @@ -142,56 +143,6 @@ "type": "number", "description": "Maximum OpenClaw messages to backfill per imported session." }, - "nonFederatedRooms": { - "type": "boolean", - "description": "Create Matrix rooms with non-federated room creation content where supported." - }, - "beeperEnv": { - "type": "string", - "enum": [ - "production", - "staging", - "dev", - "local" - ], - "description": "Beeper environment for login and appservice registration." - }, - "bridgeManagerToken": { - "type": "string", - "description": "Beeper bridge-manager token used to register the self-hosted bridge." - }, - "bridgeManagerPostState": { - "type": "boolean", - "description": "Post Beeper bridge state after registering the self-hosted bridge." - }, - "baseDomain": { - "type": "string", - "description": "Beeper API base domain for non-production environments." - }, - "homeserverDomain": { - "type": "string", - "description": "Homeserver domain advertised in the Beeper appservice registration." - }, - "ghostLocalpartPrefix": { - "type": "string", - "description": "Localpart prefix for deterministic OpenClaw ghost users." - }, - "senderLocalpart": { - "type": "string", - "description": "Localpart for the Beeper bridge sender user." - }, - "serviceBotLocalpart": { - "type": "string", - "description": "Localpart for the OpenClaw service bot user." - }, - "storePath": { - "type": "string", - "description": "Path for Matrix client store state." - }, - "userLocalpartPrefix": { - "type": "string", - "description": "Localpart prefix for imported OpenClaw user ghosts." - }, "contactVisibility": { "type": "string", "enum": [ @@ -201,12 +152,14 @@ ], "description": "Which OpenClaw identities should appear in Beeper contacts." }, + "homeserverDomain": { + "type": "string", + "description": "Homeserver domain advertised in the Beeper appservice registration." + }, "approvalBehavior": { "type": "string", "enum": [ "native", - "reactions", - "slash", "disabled" ], "description": "How Beeper approval decisions resolve OpenClaw approval gates." @@ -219,21 +172,45 @@ "type": "object", "additionalProperties": false, "properties": { - "enabled": { - "type": "boolean", - "description": "Enable the Beeper bridge channel." - }, "accessToken": { "type": "string", "description": "Beeper Matrix access token returned by login." }, + "appserviceId": { + "type": "string", + "description": "Matrix appservice id used in registration namespaces." + }, "asToken": { "type": "string", "description": "Appservice token returned by Beeper bridge registration." }, - "appserviceId": { + "allowedRoomIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Optional allow-list of Matrix rooms the bridge may import from." + }, + "allowedUserIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Optional allow-list of Matrix users the bridge may accept commands from." + }, + "enabled": { + "type": "boolean", + "description": "Enable the Beeper bridge channel." + }, + "beeperEnv": { "type": "string", - "description": "Matrix appservice id used in registration namespaces." + "enum": [ + "production", + "staging", + "dev", + "local" + ], + "description": "Beeper environment for login and appservice registration." }, "bridgeId": { "type": "string", @@ -243,10 +220,6 @@ "type": "string", "description": "Directory for bridge config, registration, and runtime state." }, - "registrationUrl": { - "type": "string", - "description": "Public or LAN callback URL for the Matrix appservice." - }, "homeserver": { "type": "string", "description": "Beeper Matrix homeserver URL returned by login." @@ -263,19 +236,9 @@ "type": "string", "description": "Beeper Matrix user id for this bridge." }, - "allowedRoomIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Optional allow-list of Matrix rooms the bridge may import from." - }, - "allowedUserIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Optional allow-list of Matrix users the bridge may accept commands from." + "bridgeManagerToken": { + "type": "string", + "description": "Beeper bridge-manager token used to register the self-hosted bridge." }, "importSources": { "type": "array", @@ -294,56 +257,6 @@ "type": "number", "description": "Maximum OpenClaw messages to backfill per imported session." }, - "nonFederatedRooms": { - "type": "boolean", - "description": "Create Matrix rooms with non-federated room creation content where supported." - }, - "beeperEnv": { - "type": "string", - "enum": [ - "production", - "staging", - "dev", - "local" - ], - "description": "Beeper environment for login and appservice registration." - }, - "bridgeManagerToken": { - "type": "string", - "description": "Beeper bridge-manager token used to register the self-hosted bridge." - }, - "bridgeManagerPostState": { - "type": "boolean", - "description": "Post Beeper bridge state after registering the self-hosted bridge." - }, - "baseDomain": { - "type": "string", - "description": "Beeper API base domain for non-production environments." - }, - "homeserverDomain": { - "type": "string", - "description": "Homeserver domain advertised in the Beeper appservice registration." - }, - "ghostLocalpartPrefix": { - "type": "string", - "description": "Localpart prefix for deterministic OpenClaw ghost users." - }, - "senderLocalpart": { - "type": "string", - "description": "Localpart for the Beeper bridge sender user." - }, - "serviceBotLocalpart": { - "type": "string", - "description": "Localpart for the OpenClaw service bot user." - }, - "storePath": { - "type": "string", - "description": "Path for Matrix client store state." - }, - "userLocalpartPrefix": { - "type": "string", - "description": "Localpart prefix for imported OpenClaw user ghosts." - }, "contactVisibility": { "type": "string", "enum": [ @@ -353,12 +266,14 @@ ], "description": "Which OpenClaw identities should appear in Beeper contacts." }, + "homeserverDomain": { + "type": "string", + "description": "Homeserver domain advertised in the Beeper appservice registration." + }, "approvalBehavior": { "type": "string", "enum": [ "native", - "reactions", - "slash", "disabled" ], "description": "How Beeper approval decisions resolve OpenClaw approval gates." diff --git a/packages/openclaw/package.json b/packages/openclaw/package.json index 49db158..ae718da 100644 --- a/packages/openclaw/package.json +++ b/packages/openclaw/package.json @@ -164,9 +164,10 @@ "access": "public" }, "scripts": { - "build": "tsdown && node scripts/copy-runtime-assets.mjs", + "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" }, diff --git a/packages/openclaw/scripts/sync-manifest-schema.mjs b/packages/openclaw/scripts/sync-manifest-schema.mjs new file mode 100644 index 0000000..e44ed23 --- /dev/null +++ b/packages/openclaw/scripts/sync-manifest-schema.mjs @@ -0,0 +1,17 @@ +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 = schema; +manifest.channelConfigs ??= {}; +manifest.channelConfigs.beeper ??= {}; +manifest.channelConfigs.beeper.schema = schema; + +await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); diff --git a/packages/openclaw/src/appservice.test.ts b/packages/openclaw/src/appservice.test.ts index 688eee3..71188d2 100644 --- a/packages/openclaw/src/appservice.test.ts +++ b/packages/openclaw/src/appservice.test.ts @@ -11,11 +11,9 @@ describe("OpenClaw Beeper appservice runtime", () => { const bridgeFactory = vi.fn(async (_options: CreateNodeBeeperBridgeOptions) => bridge); const config = createDefaultConfig({ beeperEnv: "staging", - bridgeManagerPostState: false, bridgeManagerToken: "hungry-token", dataDir: "/tmp/openclaw", homeserverDomain: "beeper.local", - registrationUrl: "http://127.0.0.1:29391", }); await expect(createOpenClawBeeperBridge({ @@ -28,10 +26,10 @@ describe("OpenClaw Beeper appservice runtime", () => { expect(bridgeFactory).toHaveBeenCalledWith(expect.objectContaining({ account: account(), - address: "http://127.0.0.1:29391", + address: "websocket", baseDomain: "beeper-staging.com", bridge: "sh-openclaw", - bridgeManagerPostState: false, + bridgeManagerPostState: true, bridgeManagerToken: "hungry-token", bridgeType: "openclaw", connector: expect.objectContaining({ @@ -100,7 +98,6 @@ describe("OpenClaw Beeper appservice runtime", () => { hsToken: "hs-token", matrixDeviceId: "DEVICE", matrixUserId: "@batuhan:beeper-staging.com", - registrationUrl: "websocket", }); await expect(startOpenClawBeeperBridge({ @@ -118,7 +115,7 @@ describe("OpenClaw Beeper appservice runtime", () => { asToken: "as-token", hsToken: "hs-token", id: "sh-openclaw-device", - senderLocalpart: "openclawbot", + senderLocalpart: "sh-openclaw-devicebot", url: "websocket", }), }), diff --git a/packages/openclaw/src/appservice.ts b/packages/openclaw/src/appservice.ts index 984a37c..b2510df 100644 --- a/packages/openclaw/src/appservice.ts +++ b/packages/openclaw/src/appservice.ts @@ -41,14 +41,11 @@ export async function createOpenClawBeeperBridge(options: CreateOpenClawBeeperBr bridgeType: options.bridgeType ?? DEFAULT_BEEPER_BRIDGE_TYPE, connector, }; - if (config?.registrationUrl !== undefined) bridgeOptions.address = config.registrationUrl; - if (config?.baseDomain !== undefined) bridgeOptions.baseDomain = config.baseDomain; - else { - const baseDomain = beeperBaseDomain(config?.beeperEnv); - if (baseDomain !== undefined) bridgeOptions.baseDomain = baseDomain; - } + bridgeOptions.address = "websocket"; + const baseDomain = beeperBaseDomain(config?.beeperEnv); + if (baseDomain !== undefined) bridgeOptions.baseDomain = baseDomain; if (config?.bridgeManagerToken !== undefined) bridgeOptions.bridgeManagerToken = config.bridgeManagerToken; - if (config?.bridgeManagerPostState !== undefined) bridgeOptions.bridgeManagerPostState = config.bridgeManagerPostState; + 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; @@ -116,7 +113,7 @@ async function postOpenClawBridgeRunningState(options: CreateOpenClawBeeperBridg const config = options.config; const bridge = options.bridge ?? config?.bridgeId ?? config?.appserviceId; if (!config?.accessToken || !config.asToken || !bridge) return; - const baseDomain = config.baseDomain ?? beeperBaseDomain(config.beeperEnv); + const baseDomain = beeperBaseDomain(config.beeperEnv); const factory = options.bridgeStateClientFactory ?? createBeeperBridgeManagerClient; const clientOptions: { baseDomain?: string; token: string } = { token: config.accessToken }; if (baseDomain !== undefined) clientOptions.baseDomain = baseDomain; diff --git a/packages/openclaw/src/backfill.test.ts b/packages/openclaw/src/backfill.test.ts index 1dafa7e..6c8ffa7 100644 --- a/packages/openclaw/src/backfill.test.ts +++ b/packages/openclaw/src/backfill.test.ts @@ -52,7 +52,7 @@ describe("OpenClaw backfill", () => { agentId: "main", human: { displayName: "user-1", - ghostUserId: "@openclaw_user_user-1:localhost", + ghostUserId: "@sh-openclaw_user_user-1:localhost", userId: "user-1", }, label: "agent:main:whatsapp:user-1", @@ -79,7 +79,7 @@ describe("OpenClaw backfill", () => { agentId: "main", human: { displayName: "Alice", - ghostUserId: "@openclaw_user_alice:localhost", + ghostUserId: "@sh-openclaw_user_alice:localhost", userId: "alice", }, label: "Terminal", @@ -92,8 +92,8 @@ describe("OpenClaw backfill", () => { })).resolves.toMatchObject({ binding: { agentId: "main", - ghostUserId: "@openclaw_agent_main:localhost", - humanGhostUserId: "@openclaw_user_alice:localhost", + ghostUserId: "@sh-openclaw_agent_main:localhost", + humanGhostUserId: "@sh-openclaw_user_alice:localhost", label: "Terminal", owner: "imported", roomId: "!room:example.com", @@ -101,7 +101,7 @@ describe("OpenClaw backfill", () => { }, human: { displayName: "Alice", - ghostUserId: "@openclaw_user_alice:localhost", + ghostUserId: "@sh-openclaw_user_alice:localhost", userId: "alice", }, messages: [ @@ -219,8 +219,8 @@ describe("OpenClaw backfill", () => { metadata: { openclaw: { agentId: "codex", - ghostUserId: "@openclaw_agent_codex:localhost", - humanGhostUserId: "@openclaw_user_alice:localhost", + ghostUserId: "@sh-openclaw_agent_codex:localhost", + humanGhostUserId: "@sh-openclaw_user_alice:localhost", sessionKey: "agent:codex:whatsapp:alice", source: "channel", }, @@ -231,12 +231,12 @@ describe("OpenClaw backfill", () => { expect(bridge.backfillPortal).toHaveBeenCalledWith(login, expect.objectContaining({ mxid: "!room:example.com", }), { limit: 25 }); - expect(registry.getUser("alice")?.ghostUserId).toBe("@openclaw_user_alice:localhost"); - expect(registry.getBindingByRoom("!room:example.com")?.humanGhostUserId).toBe("@openclaw_user_alice:localhost"); + expect(registry.getUser("alice")?.ghostUserId).toBe("@sh-openclaw_user_alice:localhost"); + expect(registry.getBindingByRoom("!room:example.com")?.humanGhostUserId).toBe("@sh-openclaw_user_alice:localhost"); const persisted = new OpenClawBridgeRegistry(registryPath); await persisted.load(); expect(persisted.getBindingBySessionKey("agent:codex:whatsapp:alice")).toMatchObject({ - humanGhostUserId: "@openclaw_user_alice:localhost", + humanGhostUserId: "@sh-openclaw_user_alice:localhost", roomId: "!room:example.com", }); }); @@ -253,7 +253,7 @@ describe("OpenClaw backfill", () => { registry.upsertBinding({ agentId: "codex", createdAt: 1, - ghostUserId: "@openclaw_agent_codex:localhost", + ghostUserId: "@sh-openclaw_agent_codex:localhost", id: "room:existing", kind: "session", label: "Alice", @@ -362,7 +362,7 @@ describe("OpenClaw backfill", () => { expect(registry.getBindingBySessionKey("agent:codex:terminal:alice")).toBeUndefined(); }); - it("omits non-federation creation content when federated rooms are enabled", async () => { + it("always creates non-federated Beeper appservice rooms", async () => { const runtime = runtimeWith({ "chat.history": { messages: [] }, "sessions.list": { @@ -371,7 +371,6 @@ describe("OpenClaw backfill", () => { ], }, }); - runtime.config.nonFederatedRooms = false; const registry = new OpenClawBridgeRegistry("/tmp/openclaw-backfill-federated-test.json"); const bridge = { backfillPortal: vi.fn(async () => ({ eventIds: [] })), @@ -392,7 +391,7 @@ describe("OpenClaw backfill", () => { runtime, }); - expect(bridge.createPortal.mock.calls[0]?.[1]).not.toHaveProperty("creationContent"); + expect(bridge.createPortal.mock.calls[0]?.[1]).toHaveProperty("creationContent", { "m.federate": false }); }); it("creates an initial agent DM when no importable sessions exist", async () => { @@ -450,12 +449,12 @@ describe("OpenClaw backfill", () => { registry.upsertAgent({ agentId: "main", displayName: "Main Agent", - ghostUserId: "@openclaw_agent_main:matrix.beeper-staging.com", + ghostUserId: "@sh-openclaw_agent_main:matrix.beeper-staging.com", }); registry.upsertBinding({ agentId: "main", createdAt: 1, - ghostUserId: "@openclaw_agent_main:matrix.beeper-staging.com", + ghostUserId: "@sh-openclaw_agent_main:matrix.beeper-staging.com", id: "existing", kind: "session", label: "Main Agent", @@ -478,8 +477,8 @@ describe("OpenClaw backfill", () => { }); expect(bridge.createPortal).not.toHaveBeenCalled(); - expect(registry.getAgent("main")?.ghostUserId).toBe("@openclaw_agent_main:beeper.local"); - expect(registry.getBindingBySessionKey("agent:main")?.ghostUserId).toBe("@openclaw_agent_main:beeper.local"); + expect(registry.getAgent("main")?.ghostUserId).toBe("@sh-openclaw_agent_main:beeper.local"); + expect(registry.getBindingBySessionKey("agent:main")?.ghostUserId).toBe("@sh-openclaw_agent_main:beeper.local"); }); it("rebuilds the registry from an existing bridge portal before creating an initial DM", async () => { diff --git a/packages/openclaw/src/backfill.ts b/packages/openclaw/src/backfill.ts index 97fa081..37ce83f 100644 --- a/packages/openclaw/src/backfill.ts +++ b/packages/openclaw/src/backfill.ts @@ -364,6 +364,6 @@ function stripUndefined>(value: T): T { return value; } -function openClawBackfillRoomCreationContent(config: OpenClawBridgeConfig): Record | undefined { - return config.nonFederatedRooms ? { "m.federate": false } : undefined; +function openClawBackfillRoomCreationContent(_config: OpenClawBridgeConfig): Record | undefined { + return { "m.federate": false }; } 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..07bc707 --- /dev/null +++ b/packages/openclaw/src/beeper-channel-config.schema.json @@ -0,0 +1,88 @@ +{ + "type": "object", + "additionalProperties": false, + "properties": { + "accessToken": { + "type": "string", + "description": "Beeper Matrix access token returned by login." + }, + "appserviceId": { + "type": "string", + "description": "Matrix appservice id used in registration namespaces." + }, + "asToken": { + "type": "string", + "description": "Appservice token returned by Beeper bridge registration." + }, + "allowedRoomIds": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional allow-list of Matrix rooms the bridge may import from." + }, + "allowedUserIds": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional allow-list of Matrix users the bridge may accept commands from." + }, + "enabled": { + "type": "boolean", + "description": "Enable the Beeper bridge channel." + }, + "beeperEnv": { + "type": "string", + "enum": ["production", "staging", "dev", "local"], + "description": "Beeper environment for login and appservice registration." + }, + "bridgeId": { + "type": "string", + "description": "Beeper self-hosted bridge id, derived as sh-openclaw-$deviceid by login setup." + }, + "dataDir": { + "type": "string", + "description": "Directory for bridge config, registration, and runtime state." + }, + "homeserver": { + "type": "string", + "description": "Beeper Matrix homeserver URL returned by login." + }, + "hsToken": { + "type": "string", + "description": "Homeserver token returned by Beeper bridge registration." + }, + "matrixDeviceId": { + "type": "string", + "description": "Beeper Matrix device id for this bridge." + }, + "matrixUserId": { + "type": "string", + "description": "Beeper Matrix user id for this bridge." + }, + "bridgeManagerToken": { + "type": "string", + "description": "Beeper bridge-manager token used to register the self-hosted bridge." + }, + "importSources": { + "type": "array", + "items": { "type": "string", "enum": ["dashboard", "tui", "channels", "archived"] }, + "description": "OpenClaw session sources to import and backfill." + }, + "backfillLimit": { + "type": "number", + "description": "Maximum OpenClaw messages to backfill per imported session." + }, + "contactVisibility": { + "type": "string", + "enum": ["agents", "agents-and-users", "none"], + "description": "Which OpenClaw identities should appear in Beeper contacts." + }, + "homeserverDomain": { + "type": "string", + "description": "Homeserver domain advertised in the Beeper appservice registration." + }, + "approvalBehavior": { + "type": "string", + "enum": ["native", "disabled"], + "description": "How Beeper approval decisions resolve OpenClaw approval gates." + } + } +} diff --git a/packages/openclaw/src/beeper-setup.test.ts b/packages/openclaw/src/beeper-setup.test.ts index 8fd6b88..97d5daf 100644 --- a/packages/openclaw/src/beeper-setup.test.ts +++ b/packages/openclaw/src/beeper-setup.test.ts @@ -66,7 +66,7 @@ describe("OpenClaw Beeper setup", () => { hsToken: "hs", id: "appservice-uuid", namespaces: { aliases: [], rooms: [], users: [] }, - senderLocalpart: "openclawbot", + senderLocalpart: "sh-openclawbot", url: "http://127.0.0.1:29391", }, }; @@ -85,14 +85,9 @@ describe("OpenClaw Beeper setup", () => { appserviceId: "appservice-uuid", asToken: "as", bridgeId: "sh-openclaw-dev", - ghostLocalpartPrefix: "sh-openclaw-dev_agent_", homeserver: "https://matrix.beeper.com/_hungryserv/batuhan", homeserverDomain: "beeper.local", hsToken: "hs", - registrationUrl: "http://127.0.0.1:29391", - senderLocalpart: "openclawbot", - serviceBotLocalpart: "openclawbot", - userLocalpartPrefix: "sh-openclaw-dev_user_", }); }); @@ -111,7 +106,7 @@ describe("OpenClaw Beeper setup", () => { hsToken: "hs", id: "appservice-uuid", namespaces: { aliases: [], rooms: [], users: [] }, - senderLocalpart: "openclawbot", + senderLocalpart: "sh-openclawbot", url: "http://127.0.0.1:29391", }, }; @@ -152,7 +147,7 @@ describe("OpenClaw Beeper setup", () => { hsToken: "hs", id: "appservice-uuid", namespaces: { aliases: [], rooms: [], users: [] }, - senderLocalpart: "openclawbot", + senderLocalpart: "sh-openclawbot", url: "http://127.0.0.1:29391", }, }; @@ -164,15 +159,10 @@ describe("OpenClaw Beeper setup", () => { appserviceId: "appservice-uuid", asToken: "as", bridgeId: "sh-openclaw-openclaw-device", - ghostLocalpartPrefix: "sh-openclaw-openclaw-device_agent_", homeserver: "https://matrix.beeper-staging.com/_hungryserv/batuhan", hsToken: "hs", matrixDeviceId: "DEV", matrixUserId: "@batuhan:beeper-staging.com", - registrationUrl: "http://127.0.0.1:29391", - senderLocalpart: "openclawbot", - serviceBotLocalpart: "openclawbot", - userLocalpartPrefix: "sh-openclaw-openclaw-device_user_", }); }); }); diff --git a/packages/openclaw/src/beeper-setup.ts b/packages/openclaw/src/beeper-setup.ts index 4b2b68a..66cfa62 100644 --- a/packages/openclaw/src/beeper-setup.ts +++ b/packages/openclaw/src/beeper-setup.ts @@ -33,7 +33,6 @@ export interface BeeperLoginForOpenClawResult { export interface CreateOpenClawBeeperAppServiceOptions { accessToken: string; - address?: string; baseDomain?: string; bridge?: string; bridgeManagerToken?: string; @@ -44,7 +43,6 @@ export interface CreateOpenClawBeeperAppServiceOptions { homeserver?: string; homeserverDomain?: string; matrixDeviceId?: string; - postState?: boolean; push?: boolean; selfHosted?: boolean; username?: string; @@ -59,13 +57,11 @@ export type CreateOpenClawBeeperAppServiceRequest = CreateAppServiceOptions & { }; export interface CreateOpenClawBeeperAppServiceResult { - config: Pick; + config: Pick; init: MatrixAppserviceInitOptions; } export interface SetupOpenClawBeeperBridgeOptions extends BeeperLoginForOpenClawOptions { - address?: string; - baseDomain?: string; bridge?: string; bridgeManagerToken?: string; bridgeType?: string; @@ -73,7 +69,6 @@ export interface SetupOpenClawBeeperBridgeOptions extends BeeperLoginForOpenClaw getOnly?: boolean; homeserverDomain?: string; openClawDeviceId?: string; - postState?: boolean; push?: boolean; selfHosted?: boolean; username?: string; @@ -81,7 +76,7 @@ export interface SetupOpenClawBeeperBridgeOptions extends BeeperLoginForOpenClaw export interface SetupOpenClawBeeperBridgeResult { account: BeeperSetupAccount; - config: Pick; + config: Pick; init: MatrixAppserviceInitOptions; } @@ -121,14 +116,14 @@ export async function createOpenClawBeeperAppService( selfHosted: options.selfHosted ?? true, token: options.accessToken, }; - if (options.address && options.address !== DEFAULT_REGISTRATION_URL) request.address = options.address; + request.address = DEFAULT_REGISTRATION_URL; if (options.baseDomain !== undefined) request.baseDomain = options.baseDomain; if (options.bridgeManagerToken !== undefined) request.hungryToken = options.bridgeManagerToken; if (options.fetch !== undefined) request.fetch = options.fetch; if (options.getOnly !== undefined) request.getOnly = options.getOnly; if (options.homeserver !== undefined) request.homeserver = options.homeserver; if (options.homeserverDomain !== undefined) request.homeserverDomain = options.homeserverDomain; - if (options.postState !== undefined) request.postState = options.postState; + request.postState = true; if (options.push !== undefined) request.push = options.push; if (options.username !== undefined) request.username = options.username; const init = await createInit(request); @@ -136,13 +131,8 @@ export async function createOpenClawBeeperAppService( appserviceId: init.registration.id, asToken: init.registration.asToken, bridgeId: bridge, - ghostLocalpartPrefix: `${bridge}_agent_`, homeserver: init.homeserver, hsToken: init.registration.hsToken, - registrationUrl: init.registration.url || options.address || DEFAULT_REGISTRATION_URL, - senderLocalpart: init.registration.senderLocalpart, - serviceBotLocalpart: init.registration.senderLocalpart, - userLocalpartPrefix: `${bridge}_user_`, }; if (init.homeserverDomain !== undefined) config.homeserverDomain = init.homeserverDomain; return { @@ -161,8 +151,7 @@ export async function setupOpenClawBeeperBridge( accessToken: login.account.accessToken, bridge: bridgeId, }; - const baseDomain = options.baseDomain ?? beeperBaseDomain(options.env); - if (options.address !== undefined) appserviceOptions.address = options.address; + const baseDomain = beeperBaseDomain(options.env); if (baseDomain !== undefined) appserviceOptions.baseDomain = baseDomain; if (options.bridgeManagerToken !== undefined) appserviceOptions.bridgeManagerToken = options.bridgeManagerToken; if (options.bridgeType !== undefined) appserviceOptions.bridgeType = options.bridgeType; @@ -170,7 +159,6 @@ export async function setupOpenClawBeeperBridge( if (options.fetch !== undefined) appserviceOptions.fetch = options.fetch; if (options.getOnly !== undefined) appserviceOptions.getOnly = options.getOnly; if (options.homeserverDomain !== undefined) appserviceOptions.homeserverDomain = options.homeserverDomain; - if (options.postState !== undefined) appserviceOptions.postState = options.postState; if (options.push !== undefined) appserviceOptions.push = options.push; if (options.selfHosted !== undefined) appserviceOptions.selfHosted = options.selfHosted; if (options.username !== undefined) appserviceOptions.username = options.username; diff --git a/packages/openclaw/src/beeper-stream.test.ts b/packages/openclaw/src/beeper-stream.test.ts index 51cbf2e..8acd56c 100644 --- a/packages/openclaw/src/beeper-stream.test.ts +++ b/packages/openclaw/src/beeper-stream.test.ts @@ -10,7 +10,7 @@ describe("OpenClaw Beeper native stream publisher", () => { initialMessageMetadata: { agent_id: "codex" }, roomId: "!room:example.com", turnId: "turn_1", - userId: "@openclaw_agent_codex:example.com", + userId: "@sh-openclaw_agent_codex:example.com", }); await publisher.publish({ messageId: "turn_1", role: "assistant", type: "TEXT_MESSAGE_START" }); @@ -42,7 +42,7 @@ describe("OpenClaw Beeper native stream publisher", () => { }, roomId: "!room:example.com", streamType: "com.beeper.llm", - userId: "@openclaw_agent_codex:example.com", + userId: "@sh-openclaw_agent_codex:example.com", }); expect(publishPart.mock.calls.map(([options]) => options.part.type)).toEqual([ "RUN_STARTED", diff --git a/packages/openclaw/src/bridge-agent.test.ts b/packages/openclaw/src/bridge-agent.test.ts index 5f0bd75..ddc9423 100644 --- a/packages/openclaw/src/bridge-agent.test.ts +++ b/packages/openclaw/src/bridge-agent.test.ts @@ -19,7 +19,7 @@ describe("OpenClawMatrixBridgeAgent", () => { }); await agent.syncAgentContacts(); - expect(registry.getAgent("codex")?.ghostUserId).toBe("@openclaw_agent_codex:localhost"); + expect(registry.getAgent("codex")?.ghostUserId).toBe("@sh-openclaw_agent_codex:localhost"); }); it("sends Matrix room text to the bound OpenClaw session", async () => { @@ -200,7 +200,7 @@ function testBinding(): OpenClawSessionBinding { return { agentId: "codex", createdAt: 1, - ghostUserId: "@openclaw_agent_codex:example.com", + ghostUserId: "@sh-openclaw_agent_codex:example.com", id: "binding", kind: "session", owner: "bridge", diff --git a/packages/openclaw/src/cli.test.ts b/packages/openclaw/src/cli.test.ts index 35204c8..52a27fa 100644 --- a/packages/openclaw/src/cli.test.ts +++ b/packages/openclaw/src/cli.test.ts @@ -41,7 +41,6 @@ describe("pickle-openclaw CLI", () => { hsToken: "hs-token", matrixDeviceId: "DEVICE", matrixUserId: "@batuhan:beeper.com", - registrationUrl: "http://127.0.0.1:29391", }, init: { homeserver: "https://matrix.beeper.com", @@ -49,8 +48,8 @@ describe("pickle-openclaw CLI", () => { asToken: "as-token", hsToken: "hs-token", id: "sh-openclaw-device", - senderLocalpart: "openclawbot", - url: "http://127.0.0.1:29391", + senderLocalpart: "sh-openclaw-devicebot", + url: "websocket", }, }, })); @@ -71,12 +70,10 @@ describe("pickle-openclaw CLI", () => { ], io, { setupBridge })).resolves.toBe(0); expect(setupBridge).toHaveBeenCalledWith(expect.objectContaining({ - baseDomain: "beeper-staging.com", bridgeManagerToken: "bridge-manager-token", email: "you@example.com", env: "staging", getLoginCode: expect.any(Function), - postState: true, push: false, selfHosted: true, })); @@ -127,7 +124,6 @@ describe("pickle-openclaw CLI", () => { hsToken: "hs-token", matrixDeviceId: "DEVICE", matrixUserId: "@alice:beeper.com", - registrationUrl: "http://127.0.0.1:29391", }, init: { homeserver: "https://matrix.beeper.com", @@ -135,8 +131,8 @@ describe("pickle-openclaw CLI", () => { asToken: "as-token", hsToken: "hs-token", id: "sh-openclaw-device", - senderLocalpart: "openclawbot", - url: "http://127.0.0.1:29391", + senderLocalpart: "sh-openclaw-devicebot", + url: "websocket", }, }, })); @@ -172,11 +168,10 @@ describe("pickle-openclaw CLI", () => { appserviceId: "sh-openclaw-device", beeperEnv: "production", bridgeId: "sh-openclaw-device", - bridgeManagerPostState: true, canConnect: true, deviceId: "DEVICE", homeserver: "https://matrix.beeper.com", - registrationUrl: "http://127.0.0.1:29391", + registrationUrl: "websocket", userId: "@batuhan:beeper.com", }); }); @@ -212,7 +207,6 @@ function successfulSetupBridge() { hsToken: "hs-token", matrixDeviceId: "DEVICE", matrixUserId: "@batuhan:beeper.com", - registrationUrl: "http://127.0.0.1:29391", }, init: { homeserver: "https://matrix.beeper.com", @@ -220,8 +214,8 @@ function successfulSetupBridge() { asToken: "as-token", hsToken: "hs-token", id: "sh-openclaw-device", - senderLocalpart: "openclawbot", - url: "http://127.0.0.1:29391", + senderLocalpart: "sh-openclaw-devicebot", + url: "websocket", }, }, })); diff --git a/packages/openclaw/src/cli.ts b/packages/openclaw/src/cli.ts index db98708..bc1acba 100644 --- a/packages/openclaw/src/cli.ts +++ b/packages/openclaw/src/cli.ts @@ -27,19 +27,14 @@ export async function runCli(argv = process.argv.slice(2), io: CliIO = process, const email = requiredStringOption(options, "email"); const setupOptions: Parameters[0] = { email, - postState: !booleanOption(options, "no-post-state"), push: booleanOption(options, "push"), selfHosted: !booleanOption(options, "not-self-hosted"), }; - const address = stringOption(options, "registration-url"); - const baseDomain = stringOption(options, "base-domain") ?? beeperBaseDomainOption(options); const bridgeManagerToken = stringOption(options, "bridge-manager-token"); const bridgeType = stringOption(options, "bridge-type"); const env = beeperEnvOption(options); const homeserverDomain = stringOption(options, "homeserver-domain"); const username = stringOption(options, "username"); - if (address !== undefined) setupOptions.address = address; - if (baseDomain !== undefined) setupOptions.baseDomain = baseDomain; if (bridgeManagerToken !== undefined) setupOptions.bridgeManagerToken = bridgeManagerToken; if (bridgeType !== undefined) setupOptions.bridgeType = bridgeType; if (env !== undefined) setupOptions.env = env; @@ -83,7 +78,6 @@ function helpText(): string { " --config ", " --data-dir ", " --email
", - " --registration-url ", " --bridge-manager-token ", " --env ", "", @@ -93,24 +87,18 @@ function helpText(): string { function configOverridesFromOptions(options: Map): Partial { const overrides: Partial = {}; const dataDir = stringOption(options, "data-dir"); - const registrationUrl = stringOption(options, "registration-url"); if (dataDir) overrides.dataDir = dataDir; - if (registrationUrl) overrides.registrationUrl = registrationUrl; return overrides; } function beeperRuntimeOverridesFromOptions(options: Map): Partial { const overrides: Partial = {}; - const baseDomain = stringOption(options, "base-domain") ?? beeperBaseDomainOption(options); const bridgeManagerToken = stringOption(options, "bridge-manager-token"); const env = beeperEnvOption(options); const homeserverDomain = stringOption(options, "homeserver-domain"); - if (baseDomain !== undefined) overrides.baseDomain = baseDomain; if (bridgeManagerToken !== undefined) overrides.bridgeManagerToken = bridgeManagerToken; if (env !== undefined) overrides.beeperEnv = env; if (homeserverDomain !== undefined) overrides.homeserverDomain = homeserverDomain; - if (options.has("no-post-state")) overrides.bridgeManagerPostState = false; - else if (options.has("post-state")) overrides.bridgeManagerPostState = true; return overrides; } @@ -125,19 +113,17 @@ function whoamiPayload(config: OpenClawBridgeConfig): Record { appserviceId: config.appserviceId, beeperEnv: config.beeperEnv ?? "production", bridgeId: config.bridgeId ?? null, - bridgeManagerPostState: config.bridgeManagerPostState ?? true, canConnect: Boolean( config.accessToken && config.asToken && config.homeserver && config.hsToken && config.matrixDeviceId && - config.matrixUserId && - config.registrationUrl + config.matrixUserId ), deviceId: config.matrixDeviceId ?? null, homeserver: config.homeserver ?? null, - registrationUrl: config.registrationUrl, + registrationUrl: "websocket", userId: config.matrixUserId ?? null, }; } @@ -181,14 +167,6 @@ function beeperEnvOption(options: Map): BeeperEnvironm throw new Error(`Invalid --env: ${env}`); } -function beeperBaseDomainOption(options: Map): string | undefined { - const env = beeperEnvOption(options); - if (env === "dev") return "beeper-dev.com"; - if (env === "local") return "beeper.localtest.me"; - if (env === "staging") return "beeper-staging.com"; - return undefined; -} - async function promptForLoginCode(io: CliIO): Promise { const input = io.stdin ?? process.stdin; const rl = createInterface({ diff --git a/packages/openclaw/src/config.test.ts b/packages/openclaw/src/config.test.ts index ef480fe..ef23982 100644 --- a/packages/openclaw/src/config.test.ts +++ b/packages/openclaw/src/config.test.ts @@ -21,13 +21,6 @@ describe("OpenClaw bridge config", () => { expect(config).toMatchObject({ appserviceId: "sh-openclaw", dataDir: "/tmp/openclaw-bridge", - ghostLocalpartPrefix: "openclaw_agent_", - nonFederatedRooms: true, - registrationUrl: "websocket", - senderLocalpart: "openclawbot", - serviceBotLocalpart: "openclawbot", - storePath: "/tmp/openclaw-bridge/matrix-store", - userLocalpartPrefix: "openclaw_user_", }); }); @@ -42,9 +35,7 @@ describe("OpenClaw bridge config", () => { it("accepts dashboard-derived bridge behavior settings", () => { expect(createDefaultConfig({ backfillLimit: 25, - baseDomain: "beeper-staging.com", beeperEnv: "staging", - bridgeManagerPostState: false, bridgeManagerToken: "hungry-token", asToken: "as-token", contactVisibility: "agents-and-users", @@ -55,9 +46,7 @@ describe("OpenClaw bridge config", () => { })).toMatchObject({ approvalBehavior: "native", backfillLimit: 25, - baseDomain: "beeper-staging.com", beeperEnv: "staging", - bridgeManagerPostState: false, bridgeManagerToken: "hungry-token", asToken: "as-token", contactVisibility: "agents-and-users", @@ -72,11 +61,6 @@ describe("OpenClaw bridge config", () => { beeper: { appserviceId: "custom-openclaw", dataDir: "/tmp/openclaw-bridge", - ghostLocalpartPrefix: "oc_agent_", - senderLocalpart: "ocbot", - serviceBotLocalpart: "ocservice", - storePath: "/tmp/openclaw-store", - userLocalpartPrefix: "oc_user_", }, }, }); @@ -84,11 +68,6 @@ describe("OpenClaw bridge config", () => { expect(config).toMatchObject({ appserviceId: "custom-openclaw", dataDir: "/tmp/openclaw-bridge", - ghostLocalpartPrefix: "oc_agent_", - senderLocalpart: "ocbot", - serviceBotLocalpart: "ocservice", - storePath: "/tmp/openclaw-store", - userLocalpartPrefix: "oc_user_", }); }); diff --git a/packages/openclaw/src/config.ts b/packages/openclaw/src/config.ts index 0a18aad..e31ec3a 100644 --- a/packages/openclaw/src/config.ts +++ b/packages/openclaw/src/config.ts @@ -7,11 +7,7 @@ import { openClawBeeperBridgeId } from "./ids"; import type { OpenClawBridgeConfig } from "./types"; export const DEFAULT_APPSERVICE_ID = "sh-openclaw"; -export const DEFAULT_GHOST_LOCALPART_PREFIX = "openclaw_agent_"; export const DEFAULT_REGISTRATION_URL = "websocket"; -export const DEFAULT_SENDER_LOCALPART = "openclawbot"; -export const DEFAULT_SERVICE_BOT_LOCALPART = "openclawbot"; -export const DEFAULT_USER_LOCALPART_PREFIX = "openclaw_user_"; export function defaultDataDir(): string { return resolve(homedir(), ".openclaw", "pickle-bridge"); @@ -31,25 +27,9 @@ export function createDefaultConfig(overrides: Partial = { process.env.PICKLE_OPENCLAW_APP_SERVICE_ID ?? DEFAULT_APPSERVICE_ID, dataDir, - ghostLocalpartPrefix: - overrides.ghostLocalpartPrefix ?? - process.env.PICKLE_OPENCLAW_GHOST_LOCALPART_PREFIX ?? - DEFAULT_GHOST_LOCALPART_PREFIX, - nonFederatedRooms: overrides.nonFederatedRooms ?? envBoolean(process.env.PICKLE_OPENCLAW_NON_FEDERATED_ROOMS) ?? true, - registrationUrl: - overrides.registrationUrl ?? process.env.PICKLE_OPENCLAW_REGISTRATION_URL ?? DEFAULT_REGISTRATION_URL, - senderLocalpart: overrides.senderLocalpart ?? process.env.PICKLE_OPENCLAW_SENDER_LOCALPART ?? DEFAULT_SENDER_LOCALPART, - serviceBotLocalpart: - overrides.serviceBotLocalpart ?? - process.env.PICKLE_OPENCLAW_SERVICE_BOT_LOCALPART ?? - DEFAULT_SERVICE_BOT_LOCALPART, - storePath: overrides.storePath ?? process.env.PICKLE_OPENCLAW_STORE_PATH ?? resolve(dataDir, "matrix-store"), - userLocalpartPrefix: - overrides.userLocalpartPrefix ?? process.env.PICKLE_OPENCLAW_USER_LOCALPART_PREFIX ?? DEFAULT_USER_LOCALPART_PREFIX, }; const accessToken = overrides.accessToken ?? process.env.PICKLE_OPENCLAW_ACCESS_TOKEN; const asToken = overrides.asToken ?? process.env.PICKLE_OPENCLAW_AS_TOKEN; - const baseDomain = overrides.baseDomain ?? process.env.PICKLE_OPENCLAW_BASE_DOMAIN; const beeperEnv = overrides.beeperEnv ?? envBeeperEnv(process.env.PICKLE_OPENCLAW_BEEPER_ENV); const bridgeManagerToken = overrides.bridgeManagerToken ?? process.env.PICKLE_OPENCLAW_BRIDGE_MANAGER_TOKEN; const openClawDeviceId = process.env.PICKLE_OPENCLAW_DEVICE_ID ?? process.env.OPENCLAW_DEVICE_ID; @@ -62,12 +42,10 @@ export function createDefaultConfig(overrides: Partial = { const contactVisibility = overrides.contactVisibility ?? envContactVisibility(process.env.PICKLE_OPENCLAW_CONTACT_VISIBILITY); const importSources = overrides.importSources ?? envImportSources(process.env.PICKLE_OPENCLAW_IMPORT_SOURCES); const approvalBehavior = overrides.approvalBehavior ?? envApprovalBehavior(process.env.PICKLE_OPENCLAW_APPROVAL_BEHAVIOR); - const bridgeManagerPostState = overrides.bridgeManagerPostState ?? envBoolean(process.env.PICKLE_OPENCLAW_BRIDGE_MANAGER_POST_STATE); const allowedRoomIds = overrides.allowedRoomIds ?? envStringList(process.env.PICKLE_OPENCLAW_ALLOW_ROOMS); const allowedUserIds = overrides.allowedUserIds ?? envStringList(process.env.PICKLE_OPENCLAW_ALLOW_USERS); if (accessToken) config.accessToken = accessToken; if (asToken) config.asToken = asToken; - if (baseDomain) config.baseDomain = baseDomain; if (beeperEnv) config.beeperEnv = beeperEnv; if (bridgeId) config.bridgeId = bridgeId; if (bridgeManagerToken) config.bridgeManagerToken = bridgeManagerToken; @@ -80,7 +58,6 @@ export function createDefaultConfig(overrides: Partial = { if (contactVisibility !== undefined) config.contactVisibility = contactVisibility; if (importSources !== undefined) config.importSources = importSources; if (approvalBehavior !== undefined) config.approvalBehavior = approvalBehavior; - if (bridgeManagerPostState !== undefined) config.bridgeManagerPostState = bridgeManagerPostState; if (allowedRoomIds) config.allowedRoomIds = allowedRoomIds; if (allowedUserIds) config.allowedUserIds = allowedUserIds; return config; diff --git a/packages/openclaw/src/connector.test.ts b/packages/openclaw/src/connector.test.ts index 5c6e4b6..d61ee5d 100644 --- a/packages/openclaw/src/connector.test.ts +++ b/packages/openclaw/src/connector.test.ts @@ -103,10 +103,10 @@ describe("OpenClawBridgeConnector", () => { openclaw: { agentId: "codex", displayName: "Codex", - ghostUserId: "@openclaw_agent_codex:localhost", + ghostUserId: "@sh-openclaw_agent_codex:localhost", }, }, - mxid: "@openclaw_agent_codex:localhost", + mxid: "@sh-openclaw_agent_codex:localhost", }); }); @@ -332,12 +332,12 @@ describe("OpenClawBridgeConnector", () => { openclaw: { agentId: "codex", displayName: "Codex", - ghostUserId: "@openclaw_agent_codex:localhost", + ghostUserId: "@sh-openclaw_agent_codex:localhost", }, }, - mxid: "@openclaw_agent_codex:localhost", + mxid: "@sh-openclaw_agent_codex:localhost", }, - userId: "@openclaw_agent_codex:localhost", + userId: "@sh-openclaw_agent_codex:localhost", }], }); }); @@ -346,7 +346,7 @@ describe("OpenClawBridgeConnector", () => { const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-contacts-test.json"); registry.upsertUser({ displayName: "Alice from Telegram", - ghostUserId: "@openclaw_user_alice:example.com", + ghostUserId: "@sh-openclaw_user_alice:example.com", source: "telegram", userId: "alice", }); @@ -373,14 +373,14 @@ describe("OpenClawBridgeConnector", () => { metadata: { openclaw: { displayName: "Alice from Telegram", - ghostUserId: "@openclaw_user_alice:example.com", + ghostUserId: "@sh-openclaw_user_alice:example.com", source: "telegram", userId: "alice", }, }, - mxid: "@openclaw_user_alice:example.com", + mxid: "@sh-openclaw_user_alice:example.com", }, - userId: "@openclaw_user_alice:example.com", + userId: "@sh-openclaw_user_alice:example.com", }], }); @@ -399,7 +399,7 @@ describe("OpenClawBridgeConnector", () => { }); runtime.config.allowedRoomIds = ["!allowed:example.com"]; runtime.config.allowedUserIds = ["@alice:example.com"]; - runtime.config.matrixUserId = "@openclawbot:example.com"; + runtime.config.matrixUserId = "@sh-openclawbot:example.com"; const api = new OpenClawNetworkAPI({ config: runtime.config, login: login(), @@ -993,7 +993,7 @@ describe("OpenClawBridgeConnector", () => { metadata: { openclaw: { agentId: "main", - ghostUserId: "@openclaw_agent_main:localhost", + ghostUserId: "@sh-openclaw_agent_main:localhost", label: "New OpenClaw Session", sessionKey: "agent:main:auto", }, @@ -1144,7 +1144,7 @@ describe("OpenClawBridgeConnector", () => { expect(registry.getBindingByRoom("!session-room:example.com")).toMatchObject({ agentId: "codex", - ghostUserId: "@openclaw_agent_codex:example.com", + ghostUserId: "@sh-openclaw_agent_codex:example.com", owner: "imported", sessionKey, }); @@ -1187,7 +1187,7 @@ describe("OpenClawBridgeConnector", () => { expect(registry.getBindingByRoom(roomId)).toMatchObject({ agentId: "main", - ghostUserId: "@openclaw_agent_main:beeper.local", + ghostUserId: "@sh-openclaw_agent_main:beeper.local", owner: "imported", sessionKey, }); @@ -1233,7 +1233,7 @@ describe("OpenClawBridgeConnector", () => { expect(response.hasMore).toBe(false); expect(response.messages).toHaveLength(2); expect(response.messages.map((message) => message.event.getID())).toEqual(["m1", "m2"]); - expect(response.messages.map((message) => message.event.getSender().sender)).toEqual(["@openclawbot:localhost", "@codex:example.com"]); + expect(response.messages.map((message) => message.event.getSender().sender)).toEqual(["@sh-openclawbot:localhost", "@codex:example.com"]); expect(response.messages.map((message) => message.event.getTimestamp())).toEqual([ new Date("2026-05-16T11:59:00.000Z"), new Date(1_779_000_000_000), diff --git a/packages/openclaw/src/connector.ts b/packages/openclaw/src/connector.ts index abdcacf..56f7e79 100644 --- a/packages/openclaw/src/connector.ts +++ b/packages/openclaw/src/connector.ts @@ -77,6 +77,7 @@ import { } from "./openclaw-runtime"; import { OpenClawBridgeRegistry } from "./registry"; import { agentContactFromOpenClawAgent, agentGhostUserId, serviceBotUserId } from "./rooms"; +import { matrixDomainFromHomeserver } from "./rooms"; import type { OpenClawAgentContact, OpenClawBridgeConfig, OpenClawSessionBinding, OpenClawUserContact } from "./types"; const DEFAULT_NEW_SESSION_LABEL = "New OpenClaw Session"; @@ -736,8 +737,8 @@ function approvalNativeEnabled(config: OpenClawBridgeConfig): boolean { return config.approvalBehavior === undefined || config.approvalBehavior === "native"; } -function openClawPortalCreationContent(config: OpenClawBridgeConfig): Record | undefined { - return config.nonFederatedRooms ? { "m.federate": false } : undefined; +function openClawPortalCreationContent(_config: OpenClawBridgeConfig): Record | undefined { + return { "m.federate": false }; } function streamTargetRelationPatch( @@ -921,7 +922,7 @@ export function userLoginFromOpenClawConfig(config: OpenClawBridgeConfig): UserL id: "openclaw:plugin", metadata: {}, remoteName: "OpenClaw", - userId: config.matrixUserId ?? config.serviceBotLocalpart, + userId: config.matrixUserId ?? serviceBotUserId(config, config.homeserverDomain ?? matrixDomainFromHomeserver(config.homeserver)), }; } diff --git a/packages/openclaw/src/integration.test.ts b/packages/openclaw/src/integration.test.ts index 77fa2c4..f38046e 100644 --- a/packages/openclaw/src/integration.test.ts +++ b/packages/openclaw/src/integration.test.ts @@ -15,7 +15,7 @@ describe("OpenClaw bridge integration", () => { const config = createDefaultConfig({ dataDir: dir, homeserver: "https://matrix.example", - matrixUserId: "@openclawbot:example", + matrixUserId: "@sh-openclawbot:example", }); const transport = fakeTransport({ responses: { @@ -43,7 +43,7 @@ describe("OpenClaw bridge integration", () => { metadata: { openclaw: { agentId: "codex", - ghostUserId: "@openclaw_agent_codex:matrix.example", + ghostUserId: "@sh-openclaw_agent_codex:matrix.example", sessionKey: "agent:codex", }, }, @@ -84,7 +84,7 @@ describe("OpenClaw bridge integration", () => { const config = createDefaultConfig({ dataDir: dir, homeserver: "https://matrix.example", - matrixUserId: "@openclawbot:example", + matrixUserId: "@sh-openclawbot:example", }); const transport = fakeTransport({ responses: { @@ -110,7 +110,7 @@ describe("OpenClaw bridge integration", () => { metadata: { openclaw: { agentId: "codex", - ghostUserId: "@openclaw_agent_codex:matrix.example", + ghostUserId: "@sh-openclaw_agent_codex:matrix.example", sessionKey: "agent:codex", }, }, @@ -143,7 +143,7 @@ describe("OpenClaw bridge integration", () => { const config = createDefaultConfig({ dataDir: dir, homeserver: "https://matrix.example", - matrixUserId: "@openclawbot:example", + matrixUserId: "@sh-openclawbot:example", }); const transport = fakeTransport({ responses: { @@ -169,7 +169,7 @@ describe("OpenClaw bridge integration", () => { metadata: { openclaw: { agentId: "codex", - ghostUserId: "@openclaw_agent_codex:matrix.example", + ghostUserId: "@sh-openclaw_agent_codex:matrix.example", sessionKey: "agent:codex:session_1", }, }, @@ -248,7 +248,7 @@ describe("OpenClaw bridge integration", () => { homeserver: "https://matrix.example", importSources: ["dashboard"], matrixDeviceId: "DEVICE", - matrixUserId: "@openclawbot:example", + matrixUserId: "@sh-openclawbot:example", }); const transport = fakeTransport({ responses: { @@ -282,7 +282,7 @@ describe("OpenClaw bridge integration", () => { })).resolves.toMatchObject({ ghost: { displayName: "Codex", - mxid: "@openclaw_agent_codex:matrix.example", + mxid: "@sh-openclaw_agent_codex:matrix.example", }, }); @@ -362,7 +362,7 @@ function matrixConfig() { accessToken: "matrix-token", deviceId: "DEVICE", homeserver: "https://matrix.example", - userId: "@openclawbot:example", + userId: "@sh-openclawbot:example", }, store: {} as never, }; @@ -503,11 +503,11 @@ function createFakeMatrixClient(): MatrixClient & { subscription: MatrixSubscrip createRoom: vi.fn(async () => ({ raw: {}, roomId: "!created:example" })), ensureJoined: vi.fn(async () => {}), ensureRegistered: vi.fn(async () => {}), - init: vi.fn(async () => ({ botUserId: "@openclawbot:example", id: "openclaw" })), + init: vi.fn(async () => ({ botUserId: "@sh-openclawbot:example", id: "openclaw" })), sendMessage: vi.fn(async () => ({ eventId: "$sent", raw: {}, roomId: "!room:example" })), }, beeper: { streams: beeperStreams } as unknown as MatrixClient["beeper"], - boot: vi.fn(async () => ({ deviceId: "DEVICE", userId: "@openclawbot:example" })), + boot: vi.fn(async () => ({ deviceId: "DEVICE", userId: "@sh-openclawbot:example" })), close: vi.fn(async () => {}), crypto: {} as MatrixClient["crypto"], logout: vi.fn(async () => {}), @@ -532,6 +532,6 @@ function createFakeMatrixClient(): MatrixClient & { subscription: MatrixSubscrip setOwnAvatarUrl: vi.fn(async () => {}), setOwnDisplayName: vi.fn(async () => {}), }, - whoami: vi.fn(async () => ({ deviceId: "DEVICE", userId: "@openclawbot:example" })), + whoami: vi.fn(async () => ({ deviceId: "DEVICE", userId: "@sh-openclawbot:example" })), }; } diff --git a/packages/openclaw/src/openclaw-extension.test.ts b/packages/openclaw/src/openclaw-extension.test.ts index c5bf22d..c9b6379 100644 --- a/packages/openclaw/src/openclaw-extension.test.ts +++ b/packages/openclaw/src/openclaw-extension.test.ts @@ -94,6 +94,7 @@ describe("OpenClaw plugin package metadata", () => { 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.openclaw?.extensions).toEqual(["./src/plugin-entry.ts"]); @@ -130,28 +131,22 @@ describe("OpenClaw plugin package metadata", () => { "appserviceId", "asToken", "backfillLimit", - "baseDomain", "beeperEnv", "bridgeId", - "bridgeManagerPostState", "bridgeManagerToken", "contactVisibility", "dataDir", "enabled", - "ghostLocalpartPrefix", "homeserver", "homeserverDomain", "hsToken", "importSources", "matrixDeviceId", "matrixUserId", - "nonFederatedRooms", - "registrationUrl", - "senderLocalpart", - "serviceBotLocalpart", - "storePath", - "userLocalpartPrefix", ]); + expect(manifest.configSchema).toEqual(schema); + expect(manifest.channelConfigs?.beeper?.schema).toEqual(schema); + expect(manifest.configSchema?.properties).not.toHaveProperty("streamFinalization"); expect(manifest.channelConfigs?.beeper).toMatchObject({ commands: { nativeCommandsAutoEnabled: true, diff --git a/packages/openclaw/src/openclaw-runtime.test.ts b/packages/openclaw/src/openclaw-runtime.test.ts index 5d2cd3e..a551e30 100644 --- a/packages/openclaw/src/openclaw-runtime.test.ts +++ b/packages/openclaw/src/openclaw-runtime.test.ts @@ -26,7 +26,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { agentId: "codex", description: "Code", displayName: "Codex", - ghostUserId: "@openclaw_agent_codex:matrix.example", + ghostUserId: "@sh-openclaw_agent_codex:matrix.example", }, ]); expect(transport.request).toHaveBeenCalledWith("agents.list", {}); diff --git a/packages/openclaw/src/registration.test.ts b/packages/openclaw/src/registration.test.ts index 861b78e..261d17a 100644 --- a/packages/openclaw/src/registration.test.ts +++ b/packages/openclaw/src/registration.test.ts @@ -12,11 +12,9 @@ describe("OpenClaw appservice registration", () => { it("reserves bridge bot, OpenClaw agent, and human ghost namespaces", () => { const config = createDefaultConfig({ appserviceId: "sh-openclaw-device", + bridgeId: "sh-openclaw-device", dataDir: "/tmp/openclaw", - ghostLocalpartPrefix: "oc_agent_", homeserverDomain: "beeper.local", - senderLocalpart: "ocbot", - userLocalpartPrefix: "oc_user_", }); const registration = createAppserviceRegistration(config, { asToken: "as", hsToken: "hs" }); expect(registration).toMatchObject({ @@ -25,13 +23,13 @@ describe("OpenClaw appservice registration", () => { id: "sh-openclaw-device", rate_limited: false, receive_ephemeral: true, - sender_localpart: "ocbot", + sender_localpart: "sh-openclaw-devicebot", url: "websocket", }); expect(registration.namespaces.users).toEqual([ - { exclusive: true, regex: "^@oc_agent_.+:beeper\\.local$" }, - { exclusive: true, regex: "^@oc_user_.+:beeper\\.local$" }, - { exclusive: true, regex: "^@ocbot:beeper\\.local$" }, + { exclusive: true, regex: "^@sh-openclaw-device_agent_.+:beeper\\.local$" }, + { exclusive: true, regex: "^@sh-openclaw-device_user_.+:beeper\\.local$" }, + { exclusive: true, regex: "^@sh-openclaw-devicebot:beeper\\.local$" }, ]); expect(registration.namespaces.aliases).toEqual([ { exclusive: true, regex: "^#sh-openclaw-device_.+:.*$" }, @@ -40,8 +38,8 @@ describe("OpenClaw appservice registration", () => { it("derives Matrix-safe localparts and non-federated room presets", () => { const config = createDefaultConfig({ dataDir: "/tmp/openclaw" }); - expect(openClawAgentGhostLocalpart(config, "Codex/Main Agent")).toBe("openclaw_agent_codex/main_agent"); - expect(openClawUserGhostLocalpart(config, "@alice:beeper.local")).toBe("openclaw_user_alice_beeper.local"); + expect(openClawAgentGhostLocalpart(config, "Codex/Main Agent")).toBe("sh-openclaw_agent_codex/main_agent"); + expect(openClawUserGhostLocalpart(config, "@alice:beeper.local")).toBe("sh-openclaw_user_alice_beeper.local"); expect(openClawAliasLocalpart(config, "session 1")).toBe("sh-openclaw_session_1"); expect(openClawRoomCreationPreset(config)).toEqual({ creation_content: { "m.federate": false }, diff --git a/packages/openclaw/src/registration.ts b/packages/openclaw/src/registration.ts index 3d6f3c5..e780523 100644 --- a/packages/openclaw/src/registration.ts +++ b/packages/openclaw/src/registration.ts @@ -11,9 +11,10 @@ export function createAppserviceRegistration( options: CreateRegistrationOptions = {} ): AppserviceRegistration { const domain = escapeRegex(config.homeserverDomain ?? matrixDomainFromHomeserver(config.homeserver)); - const ghostPrefix = escapeRegex(config.ghostLocalpartPrefix); - const userPrefix = escapeRegex(config.userLocalpartPrefix); - const sender = escapeRegex(config.senderLocalpart); + const ghostPrefix = escapeRegex(openClawAgentGhostPrefix(config)); + const userPrefix = escapeRegex(openClawUserGhostPrefix(config)); + const senderLocalpart = openClawSenderLocalpart(config); + const sender = escapeRegex(senderLocalpart); return { as_token: options.asToken ?? config.asToken ?? secretToken(), hs_token: options.hsToken ?? config.hsToken ?? secretToken(), @@ -29,8 +30,8 @@ export function createAppserviceRegistration( }, receive_ephemeral: true, rate_limited: false, - sender_localpart: config.senderLocalpart, - url: config.registrationUrl, + sender_localpart: senderLocalpart, + url: "websocket", }; } @@ -44,11 +45,11 @@ function matrixDomainFromHomeserver(homeserver: string | undefined): string { } export function openClawAgentGhostLocalpart(config: OpenClawBridgeConfig, agentId: string): string { - return `${config.ghostLocalpartPrefix}${encodeLocalpartSegment(agentId)}`; + return `${openClawAgentGhostPrefix(config)}${encodeLocalpartSegment(agentId)}`; } export function openClawUserGhostLocalpart(config: OpenClawBridgeConfig, userId: string): string { - return `${config.userLocalpartPrefix}${encodeLocalpartSegment(userId)}`; + return `${openClawUserGhostPrefix(config)}${encodeLocalpartSegment(userId)}`; } export function openClawAliasLocalpart(config: OpenClawBridgeConfig, roomKey: string): string { @@ -58,12 +59,28 @@ export function openClawAliasLocalpart(config: OpenClawBridgeConfig, roomKey: st export function openClawRoomCreationPreset(config: OpenClawBridgeConfig): Record { return { creation_content: { - "m.federate": !config.nonFederatedRooms, + "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 openClawUserGhostPrefix(config: OpenClawBridgeConfig): string { + return `${openClawBridgeId(config)}_user_`; +} + +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"; } diff --git a/packages/openclaw/src/registry.test.ts b/packages/openclaw/src/registry.test.ts index a0d4760..064bf99 100644 --- a/packages/openclaw/src/registry.test.ts +++ b/packages/openclaw/src/registry.test.ts @@ -13,18 +13,18 @@ describe("OpenClawBridgeRegistry", () => { registry.upsertAgent({ agentId: "codex", displayName: "Codex", - ghostUserId: "@openclaw_agent_codex:example.com", + ghostUserId: "@sh-openclaw_agent_codex:example.com", }); registry.upsertUser({ displayName: "Alice", - ghostUserId: "@openclaw_user_alice:example.com", + ghostUserId: "@sh-openclaw_user_alice:example.com", source: "whatsapp", userId: "alice", }); registry.upsertBinding({ agentId: "codex", createdAt: 1, - ghostUserId: "@openclaw_agent_codex:example.com", + ghostUserId: "@sh-openclaw_agent_codex:example.com", id: "binding", kind: "session", owner: "bridge", @@ -38,7 +38,7 @@ describe("OpenClawBridgeRegistry", () => { const loaded = new OpenClawBridgeRegistry(path); await loaded.load(); expect(loaded.getAgent("codex")?.displayName).toBe("Codex"); - expect(loaded.getUser("alice")?.ghostUserId).toBe("@openclaw_user_alice:example.com"); + expect(loaded.getUser("alice")?.ghostUserId).toBe("@sh-openclaw_user_alice:example.com"); 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); diff --git a/packages/openclaw/src/rooms.test.ts b/packages/openclaw/src/rooms.test.ts index 3861184..5390e66 100644 --- a/packages/openclaw/src/rooms.test.ts +++ b/packages/openclaw/src/rooms.test.ts @@ -16,9 +16,9 @@ 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("@openclaw_agent_codex_main:matrix.example.com"); - expect(userGhostUserId(config, "whatsapp:+1 555")).toBe("@openclaw_user_whatsapp=3a=2b1=20555:matrix.example.com"); - expect(serviceBotUserId(config)).toBe("@openclawbot:matrix.example.com"); + expect(agentGhostUserId(config, "Codex Main")).toBe("@sh-openclaw_agent_codex_main:matrix.example.com"); + expect(userGhostUserId(config, "whatsapp:+1 555")).toBe("@sh-openclaw_user_whatsapp_1_555:matrix.example.com"); + expect(serviceBotUserId(config)).toBe("@sh-openclawbot:matrix.example.com"); expect(agentContactFromOpenClawAgent(config, { avatarMxc: "mxc://example/avatar", description: "Local code agent", @@ -29,7 +29,7 @@ describe("OpenClaw room and contact helpers", () => { avatarMxc: "mxc://example/avatar", description: "Local code agent", displayName: "Codex", - ghostUserId: "@openclaw_agent_codex:matrix.example.com", + ghostUserId: "@sh-openclaw_agent_codex:matrix.example.com", }); expect(userContactFromOpenClawSession(config, { displayName: "Alice", @@ -37,7 +37,7 @@ describe("OpenClaw room and contact helpers", () => { lastTo: "whatsapp:+1 555", })).toEqual({ displayName: "Alice", - ghostUserId: "@openclaw_user_whatsapp=3a=2b1=20555:matrix.example.com", + ghostUserId: "@sh-openclaw_user_whatsapp_1_555:matrix.example.com", source: "whatsapp", userId: "whatsapp:+1 555", }); @@ -59,7 +59,7 @@ describe("OpenClaw room and contact helpers", () => { agent: { agentId: "codex", displayName: "Codex", - ghostUserId: "@openclaw_agent_codex:example.com", + ghostUserId: "@sh-openclaw_agent_codex:example.com", }, cwd: "/repo", label: "Fix tests", @@ -74,14 +74,14 @@ describe("OpenClaw room and contact helpers", () => { name: "Fix tests", preset: "private_chat", topic: "OpenClaw agent: codex\nsession: agent:codex:main\ncwd: /repo", - userId: "@openclawbot:example.com", + userId: "@sh-openclawbot:example.com", visibility: "private", }); expect(binding).toEqual({ agentId: "codex", createdAt: Date.parse("2026-05-16T12:00:00.000Z"), cwd: "/repo", - ghostUserId: "@openclaw_agent_codex:example.com", + ghostUserId: "@sh-openclaw_agent_codex:example.com", id: bindingIdForRoom("!session:example.com"), kind: "session", label: "Fix tests", diff --git a/packages/openclaw/src/rooms.ts b/packages/openclaw/src/rooms.ts index 90fa470..afb3f56 100644 --- a/packages/openclaw/src/rooms.ts +++ b/packages/openclaw/src/rooms.ts @@ -1,6 +1,6 @@ import type { MatrixClient } from "@beeper/pickle"; import type { OpenClawAgentContact, OpenClawBridgeConfig, OpenClawSessionBinding, OpenClawUserContact } from "./types"; -import { openClawAgentGhostLocalpart, openClawRoomCreationPreset } from "./registration"; +import { openClawAgentGhostLocalpart, openClawRoomCreationPreset, openClawSenderLocalpart, openClawUserGhostLocalpart } from "./registration"; export function bindingIdForRoom(roomId: string): string { return Buffer.from(roomId).toString("base64url"); @@ -24,11 +24,11 @@ export function agentGhostUserId(config: OpenClawBridgeConfig, agentId: string, } export function userGhostUserId(config: OpenClawBridgeConfig, userId: string, domain = matrixDomainFromConfig(config)): string { - return `@${config.userLocalpartPrefix}${encodeLocalpartSegment(userId)}:${domain}`; + return `@${openClawUserGhostLocalpart(config, userId)}:${domain}`; } export function serviceBotUserId(config: OpenClawBridgeConfig, domain = matrixDomainFromConfig(config)): string { - return `@${config.serviceBotLocalpart}:${domain}`; + return `@${openClawSenderLocalpart(config)}:${domain}`; } export function agentContactFromOpenClawAgent( @@ -123,7 +123,3 @@ export async function createSessionRoom( function stringValue(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } - -function encodeLocalpartSegment(value: string): string { - return value.toLowerCase().replace(/[^a-z0-9._=-]/g, (char) => `=${char.codePointAt(0)?.toString(16) ?? "00"}`); -} diff --git a/packages/openclaw/src/setup.test.ts b/packages/openclaw/src/setup.test.ts index 9a93425..7049e90 100644 --- a/packages/openclaw/src/setup.test.ts +++ b/packages/openclaw/src/setup.test.ts @@ -51,7 +51,7 @@ describe("OpenClaw Beeper setup surface", () => { }, threading: expect.any(Object), reload: { - configPrefixes: ["channels.beeper", "plugins.entries.beeper"], + configPrefixes: ["channels.beeper"], }, gateway: { startAccount: expect.any(Function), @@ -248,14 +248,10 @@ describe("OpenClaw Beeper setup surface", () => { const cfg = beeperSetupAdapter.applyAccountConfig({ accountId: "default", cfg: {}, - input: { - registrationUrl: "http://127.0.0.1:29391", - }, + input: {}, }); expect(cfg).not.toHaveProperty("then"); - expect(getBeeperChannelSettings(cfg)).toMatchObject({ - registrationUrl: "http://127.0.0.1:29391", - }); + expect(getBeeperChannelSettings(cfg)).toMatchObject({ enabled: true }); }); it("starts the Beeper bridge from OpenClaw gateway lifecycle and stops on abort", async () => { @@ -274,7 +270,6 @@ describe("OpenClaw Beeper setup surface", () => { importSources: ["dashboard", "tui"], matrixDeviceId: "DEV", matrixUserId: "@alice:example", - registrationUrl: "http://bridge", }); const task = startBeeperGatewayAccount({ @@ -312,7 +307,6 @@ describe("OpenClaw Beeper setup surface", () => { accountId: "default", cfg: applyBeeperChannelSettings({}, { enabled: true, - registrationUrl: "http://bridge", }), })).rejects.toThrow("not fully configured"); }); @@ -334,19 +328,11 @@ describe("OpenClaw Beeper setup surface", () => { appserviceId: "custom-openclaw", approvalBehavior: "native", backfillLimit: "42", - baseDomain: "beeper-staging.com", beeperEnv: "staging", bridgeId: "sh-openclaw-custom", bridgeManagerToken: "hungry", contactVisibility: "agents-and-users", - ghostLocalpartPrefix: "oc_agent_", importSources: "dashboard,tui", - nonFederatedRooms: "false", - registrationUrl: "http://127.0.0.1:29391", - senderLocalpart: "ocbot", - serviceBotLocalpart: "ocservice", - storePath: "/tmp/openclaw-store", - userLocalpartPrefix: "oc_user_", }, }); expect(getBeeperChannelSettings(cfg)).toEqual({ @@ -356,25 +342,15 @@ describe("OpenClaw Beeper setup surface", () => { appserviceId: "custom-openclaw", approvalBehavior: "native", backfillLimit: 42, - baseDomain: "beeper-staging.com", beeperEnv: "staging", bridgeId: "sh-openclaw-custom", bridgeManagerToken: "hungry", contactVisibility: "agents-and-users", enabled: true, - ghostLocalpartPrefix: "oc_agent_", importSources: ["dashboard", "tui"], - nonFederatedRooms: false, - registrationUrl: "http://127.0.0.1:29391", - senderLocalpart: "ocbot", - serviceBotLocalpart: "ocservice", - storePath: "/tmp/openclaw-store", - userLocalpartPrefix: "oc_user_", }); expect(isBeeperChannelConfigured(cfg)).toBe(false); - expect(cfg.plugins?.entries?.beeper).toEqual({ - config: getBeeperChannelSettings(cfg), - }); + expect(cfg.plugins?.entries?.beeper).toBeUndefined(); }); it("keeps async Beeper login out of the synchronous OpenClaw setup adapter", () => { @@ -425,12 +401,9 @@ describe("OpenClaw Beeper setup surface", () => { setupBridge: async (options) => { expect(options.email).toBe("alice@example.com"); expect(options.env).toBe("dev"); - expect(options.baseDomain).toBe("beeper.localtest.me"); expect(options.bridgeManagerToken).toBe("hungry"); expect(options.homeserverDomain).toBe("beeper.local"); - expect(options.postState).toBe(false); expect(await options.getLoginCode?.()).toBe("123456"); - expect(options.address).toBe("http://127.0.0.1:29391"); return { account: { accessToken: "at", @@ -447,7 +420,6 @@ describe("OpenClaw Beeper setup surface", () => { hsToken: "hs", matrixDeviceId: "DEV", matrixUserId: "@alice:example", - registrationUrl: "http://127.0.0.1:29391", }, init: { homeserver: "https://matrix.example", @@ -468,8 +440,6 @@ describe("OpenClaw Beeper setup surface", () => { enabled: true, accessToken: "at", asToken: "as", - baseDomain: "beeper.localtest.me", - bridgeManagerPostState: false, bridgeManagerToken: "hungry", bridgeId: "sh-openclaw-dev", homeserver: "https://matrix.example", @@ -477,7 +447,6 @@ describe("OpenClaw Beeper setup surface", () => { hsToken: "hs", matrixDeviceId: "DEV", matrixUserId: "@alice:example", - registrationUrl: "http://127.0.0.1:29391", }); }); @@ -488,20 +457,17 @@ describe("OpenClaw Beeper setup surface", () => { input: { accessToken: "at", asToken: "as", - registrationUrl: "http://127.0.0.1:29391", }, }); expect(getBeeperChannelSettings(cfg)).toMatchObject({ accessToken: "at", asToken: "as", - registrationUrl: "http://127.0.0.1:29391", }); }); it("does not report configured until login, appservice, and gateway details are present", async () => { expect(isBeeperChannelConfigured(applyBeeperChannelSettings({}, { enabled: true, - registrationUrl: "http://bridge", }))).toBe(false); const cfg = applyBeeperChannelSettings({}, { accessToken: "at", @@ -511,7 +477,6 @@ describe("OpenClaw Beeper setup surface", () => { hsToken: "hs", matrixDeviceId: "DEV", matrixUserId: "@alice:example", - registrationUrl: "http://bridge", }); expect(isBeeperChannelConfigured(cfg)).toBe(true); }); @@ -524,18 +489,14 @@ describe("OpenClaw Beeper setup surface", () => { beeperEnv: "dev", code: "123456", email: "alice@example.com", - registrationUrl: "http://127.0.0.1:29391", }, runtime: { setupBridge: async (options) => { expect(options.email).toBe("alice@example.com"); expect(options.env).toBe("dev"); - expect(options.baseDomain).toBeUndefined(); expect(options.bridgeManagerToken).toBeUndefined(); expect(options.homeserverDomain).toBeUndefined(); - expect(options.postState).toBeUndefined(); expect(await options.getLoginCode?.()).toBe("123456"); - expect(options.address).toBe("http://127.0.0.1:29391"); return { account: { accessToken: "at", @@ -552,7 +513,6 @@ describe("OpenClaw Beeper setup surface", () => { hsToken: "hs", matrixDeviceId: "DEV", matrixUserId: "@alice:example", - registrationUrl: "http://127.0.0.1:29391", }, init: { homeserver: "https://matrix.example", @@ -577,7 +537,6 @@ describe("OpenClaw Beeper setup surface", () => { hsToken: "hs", matrixDeviceId: "DEV", matrixUserId: "@alice:example", - registrationUrl: "http://127.0.0.1:29391", }); }); @@ -599,7 +558,6 @@ describe("OpenClaw Beeper setup surface", () => { const cfg = applyBeeperChannelSettings({}, { enabled: true, importSources: ["dashboard"], - registrationUrl: "http://bridge", }); await expect(beeperSetupWizard.getStatus({ cfg })).resolves.toMatchObject({ channel: "beeper", @@ -612,7 +570,6 @@ describe("OpenClaw Beeper setup surface", () => { const account = beeperChannelConfig.resolveAccount(applyBeeperChannelSettings({}, { enabled: true, importSources: ["dashboard", "tui"], - registrationUrl: "http://bridge", })); const snapshot = beeperStatusAdapter.buildAccountSnapshot({ account }); @@ -623,7 +580,7 @@ describe("OpenClaw Beeper setup surface", () => { extra: { importSources: ["dashboard", "tui"], mode: "self-hosted-appservice", - registrationUrl: "http://bridge", + registrationUrl: "websocket", }, running: false, }); @@ -651,8 +608,6 @@ describe("OpenClaw Beeper setup surface", () => { hsToken: "hs", matrixDeviceId: "DEV", matrixUserId: "@alice:example", - nonFederatedRooms: false, - registrationUrl: "http://bridge", }, }, }); @@ -662,8 +617,6 @@ describe("OpenClaw Beeper setup surface", () => { hsToken: "hs", matrixDeviceId: "DEV", matrixUserId: "@alice:example", - nonFederatedRooms: false, - registrationUrl: "http://bridge", }); }); @@ -801,29 +754,16 @@ describe("OpenClaw Beeper setup surface", () => { beeper: { config: { enabled: true, - registrationUrl: "http://bridge", }, }, }, }, })).toEqual({ - enabled: true, importSources: ["dashboard"], - registrationUrl: "http://bridge", }); - expect(createConfigFromOpenClawSetup({ - plugins: { - entries: { - beeper: { - config: { - registrationUrl: "http://bridge", - }, - }, - }, - }, - })).toMatchObject({ - registrationUrl: "http://bridge", + expect(createConfigFromOpenClawSetup({ plugins: { entries: { beeper: { config: { enabled: true } } } } })).toMatchObject({ + appserviceId: "sh-openclaw", }); }); }); diff --git a/packages/openclaw/src/setup.ts b/packages/openclaw/src/setup.ts index 1f99f14..eca6aa4 100644 --- a/packages/openclaw/src/setup.ts +++ b/packages/openclaw/src/setup.ts @@ -3,7 +3,8 @@ import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk/channel- import type { ChatType } from "openclaw/plugin-sdk/core"; import type { ChannelAccountSnapshot, ChannelCapabilities, ChannelGatewayContext, ChannelMessageActionName } from "openclaw/plugin-sdk/channel-contract"; import type { BridgeLogger } from "@beeper/pickle-bridge"; -import { createConfigFromOpenClawSetup, DEFAULT_REGISTRATION_URL, defaultDataDir } from "./config"; +import { createConfigFromOpenClawSetup, defaultDataDir } from "./config"; +import beeperChannelConfigSchema from "./beeper-channel-config.schema.json"; import type { setupOpenClawBeeperBridge, SetupOpenClawBeeperBridgeOptions } from "./beeper-setup"; import { createBeeperApprovalNotice } from "./approval"; import { requireBeeperChannelRuntimeForHost } from "./beeper-channel-runtime"; @@ -21,27 +22,18 @@ export interface BeeperChannelSettings { asToken?: string; approvalBehavior?: "native" | "disabled"; backfillLimit?: number; - baseDomain?: string; beeperEnv?: "production" | "staging" | "dev" | "local"; bridgeManagerToken?: string; - bridgeManagerPostState?: boolean; bridgeId?: string; contactVisibility?: "agents" | "agents-and-users" | "none"; dataDir?: string; enabled?: boolean; - ghostLocalpartPrefix?: string; homeserver?: string; hsToken?: string; importSources?: BeeperImportSource[]; matrixDeviceId?: string; matrixUserId?: string; homeserverDomain?: string; - nonFederatedRooms?: boolean; - registrationUrl?: string; - senderLocalpart?: string; - serviceBotLocalpart?: string; - storePath?: string; - userLocalpartPrefix?: string; } export interface BeeperSetupInput { @@ -52,7 +44,6 @@ export interface BeeperSetupInput { asToken?: string; approvalBehavior?: string; backfillLimit?: number | string; - baseDomain?: string; beeperEnv?: string; bridgeManagerToken?: string; bridgeId?: string; @@ -61,19 +52,12 @@ export interface BeeperSetupInput { dataDir?: string; email?: string; getOnly?: boolean | string; - ghostLocalpartPrefix?: string; homeserverDomain?: string; importSources?: string[] | string; - nonFederatedRooms?: boolean | string; postState?: boolean | string; push?: boolean | string; - registrationUrl?: string; - senderLocalpart?: string; - serviceBotLocalpart?: string; selfHosted?: boolean | string; - storePath?: string; username?: string; - userLocalpartPrefix?: string; } export interface BeeperSetupRuntime { @@ -134,43 +118,7 @@ function requireBeeperChannelRuntime() { return requireBeeperChannelRuntimeForHost(openClawPluginRuntime); } -export const BeeperChannelConfigSchema = { - type: "object", - additionalProperties: false, - properties: { - accessToken: { type: "string" }, - appserviceId: { type: "string" }, - asToken: { type: "string" }, - allowedRoomIds: { type: "array", items: { type: "string" } }, - allowedUserIds: { type: "array", items: { type: "string" } }, - enabled: { type: "boolean" }, - baseDomain: { type: "string" }, - beeperEnv: { type: "string", enum: ["production", "staging", "dev", "local"] }, - bridgeId: { type: "string" }, - dataDir: { type: "string" }, - ghostLocalpartPrefix: { type: "string" }, - homeserver: { type: "string" }, - hsToken: { type: "string" }, - matrixDeviceId: { type: "string" }, - matrixUserId: { type: "string" }, - registrationUrl: { type: "string" }, - bridgeManagerToken: { type: "string" }, - bridgeManagerPostState: { type: "boolean" }, - importSources: { - type: "array", - items: { type: "string", enum: ["dashboard", "tui", "channels", "archived"] }, - }, - backfillLimit: { type: "number" }, - nonFederatedRooms: { type: "boolean" }, - senderLocalpart: { type: "string" }, - serviceBotLocalpart: { type: "string" }, - storePath: { type: "string" }, - contactVisibility: { type: "string", enum: ["agents", "agents-and-users", "none"] }, - homeserverDomain: { type: "string" }, - approvalBehavior: { type: "string", enum: ["native", "disabled"] }, - userLocalpartPrefix: { type: "string" }, - }, -} as const; +export const BeeperChannelConfigSchema = beeperChannelConfigSchema; export const BeeperChannelUiHints = { accessToken: { @@ -639,7 +587,7 @@ export const beeperSetupWizard = { configured, statusLines: [ "Runtime: OpenClaw plugin", - `Registration URL: ${settings.registrationUrl ?? "not configured"}`, + "Registration transport: websocket", `Import sources: ${(settings.importSources ?? []).join(", ") || "none"}`, ], selectionHint: configured ? "Beeper bridge configured" : "Beeper login and bridge registration required", @@ -671,11 +619,6 @@ export const beeperSetupWizard = { sensitive: true, validate: (value) => (value.trim() ? undefined : "Beeper login code is required."), }); - const registrationUrl = await ctx.prompter.text({ - message: "Appservice callback URL", - initialValue: current.registrationUrl ?? DEFAULT_REGISTRATION_URL, - validate: (value) => (value.trim() ? undefined : "Appservice callback URL is required."), - }); const beeperEnv = await ctx.prompter.select({ message: "Beeper environment", initialValue: current.beeperEnv ?? "production", @@ -686,12 +629,6 @@ export const beeperSetupWizard = { { value: "local", label: "Local" }, ], }); - const defaultBaseDomain = current.baseDomain ?? setupBeeperBaseDomain(beeperEnv); - const baseDomain = await ctx.prompter.text({ - message: "Beeper API base domain", - ...(defaultBaseDomain ? { initialValue: defaultBaseDomain } : {}), - placeholder: "leave empty for production default", - }); const bridgeManagerToken = await ctx.prompter.text({ message: "Bridge manager token", ...(current.bridgeManagerToken ? { initialValue: current.bridgeManagerToken } : {}), @@ -735,14 +672,6 @@ export const beeperSetupWizard = { { value: "disabled", label: "Disabled" }, ], }); - const nonFederatedRooms = await ctx.prompter.confirm({ - message: "Create non-federated Matrix rooms", - initialValue: current.nonFederatedRooms ?? true, - }); - const postState = await ctx.prompter.confirm({ - message: "Post bridge state to Beeper", - initialValue: current.bridgeManagerPostState ?? true, - }); const progress = ctx.prompter.progress?.("Setting up Beeper bridge"); progress?.update("Logging in and registering appservice"); try { @@ -751,12 +680,8 @@ export const beeperSetupWizard = { code, email, importSources, - nonFederatedRooms, - postState, - registrationUrl, }; if (approvalBehavior !== undefined) input.approvalBehavior = approvalBehavior; - if (baseDomain.trim()) input.baseDomain = baseDomain.trim(); if (beeperEnv !== undefined) input.beeperEnv = beeperEnv; if (bridgeManagerToken.trim()) input.bridgeManagerToken = bridgeManagerToken.trim(); if (contactVisibility !== undefined) input.contactVisibility = contactVisibility; @@ -794,7 +719,7 @@ export const beeperChannelConfig = { name: "Beeper", configured: account.configured === true, extra: { - registrationUrl: account.settings?.registrationUrl, + registrationUrl: "websocket", }, }), }; @@ -829,7 +754,7 @@ export const beeperStatusAdapter = { homeserver: settings.homeserver, importSources: settings.importSources ?? [], mode: "self-hosted-appservice", - registrationUrl: settings.registrationUrl, + registrationUrl: "websocket", }, name: "Beeper", running: runtime?.running === true, @@ -866,22 +791,17 @@ export async function applyBeeperSetupConfig(params: { const setupSettings: Partial = { ...baseSettings, enabled: true, - registrationUrl: result.config.registrationUrl, }; if (result.config.homeserver) setupSettings.homeserver = result.config.homeserver; if (result.config.accessToken) setupSettings.accessToken = result.config.accessToken; if (result.config.appserviceId) setupSettings.appserviceId = result.config.appserviceId; if (result.config.asToken) setupSettings.asToken = result.config.asToken; if (result.config.bridgeId) setupSettings.bridgeId = result.config.bridgeId; - if (result.config.ghostLocalpartPrefix) setupSettings.ghostLocalpartPrefix = result.config.ghostLocalpartPrefix; if (result.config.homeserverDomain) setupSettings.homeserverDomain = result.config.homeserverDomain; else if (params.input.homeserverDomain) setupSettings.homeserverDomain = params.input.homeserverDomain; if (result.config.hsToken) setupSettings.hsToken = result.config.hsToken; if (result.config.matrixDeviceId) setupSettings.matrixDeviceId = result.config.matrixDeviceId; if (result.config.matrixUserId) setupSettings.matrixUserId = result.config.matrixUserId; - if (result.config.senderLocalpart) setupSettings.senderLocalpart = result.config.senderLocalpart; - if (result.config.serviceBotLocalpart) setupSettings.serviceBotLocalpart = result.config.serviceBotLocalpart; - if (result.config.userLocalpartPrefix) setupSettings.userLocalpartPrefix = result.config.userLocalpartPrefix; return applyBeeperChannelSettings(params.cfg, setupSettings); } @@ -925,7 +845,7 @@ export const beeperChannelPlugin: ChannelPlugin & { uiHin quickstartAllowFrom: true, }, capabilities: BeeperChannelCapabilities, - reload: { configPrefixes: ["channels.beeper", "plugins.entries.beeper"] }, + reload: { configPrefixes: ["channels.beeper"] }, commands: beeperCommandAdapter, configSchema: BeeperChannelConfigSchemaForSdk, config: beeperChannelConfig, @@ -1234,13 +1154,8 @@ export async function stopBeeperGatewayAccount(ctx: BeeperGatewayContext | Chann } export function getBeeperChannelSettings(cfg: OpenClawSetupConfig): BeeperChannelSettings { - const pluginEntry = recordValue(cfg.plugins?.entries?.[BEEPER_CHANNEL_ID]); - const pluginSettings = recordValue(pluginEntry?.config); const channelSettings = recordValue(cfg.channels?.[BEEPER_CHANNEL_ID]); - return { - ...(pluginSettings as BeeperChannelSettings | undefined), - ...(channelSettings as BeeperChannelSettings | undefined), - }; + return (channelSettings as BeeperChannelSettings | undefined) ?? {}; } export function isBeeperChannelConfigured(cfg: OpenClawSetupConfig): boolean { @@ -1252,8 +1167,7 @@ export function isBeeperChannelConfigured(cfg: OpenClawSetupConfig): boolean { settings.homeserver && settings.hsToken && settings.matrixDeviceId && - settings.matrixUserId && - settings.registrationUrl + settings.matrixUserId ); } @@ -1272,16 +1186,6 @@ export function applyBeeperChannelSettings( ...cfg.channels, [BEEPER_CHANNEL_ID]: nextSettings, }, - plugins: { - ...cfg.plugins, - entries: { - ...cfg.plugins?.entries, - [BEEPER_CHANNEL_ID]: { - ...(recordValue(cfg.plugins?.entries?.[BEEPER_CHANNEL_ID]) ?? {}), - config: nextSettings, - }, - }, - }, }; } @@ -1294,8 +1198,6 @@ export function defaultBeeperChannelSettings(): BeeperChannelSettings { dataDir: defaultDataDir(), enabled: true, importSources: ["dashboard", "tui"], - nonFederatedRooms: true, - registrationUrl: DEFAULT_REGISTRATION_URL, }; } @@ -1318,8 +1220,6 @@ export function normalizeBeeperSetupInput(input: BeeperSetupInput): Partial input.code!; if (getOnly !== undefined) options.getOnly = getOnly; if (input.homeserverDomain) options.homeserverDomain = input.homeserverDomain; - if (postState !== undefined) options.postState = postState; if (push !== undefined) options.push = push; - if (input.registrationUrl) options.address = input.registrationUrl; if (selfHosted !== undefined) options.selfHosted = selfHosted; if (input.username) options.username = input.username; return options; diff --git a/packages/openclaw/src/types.ts b/packages/openclaw/src/types.ts index 334267e..c4b9a4f 100644 --- a/packages/openclaw/src/types.ts +++ b/packages/openclaw/src/types.ts @@ -45,26 +45,17 @@ export interface OpenClawBridgeConfig { appserviceId: string; approvalBehavior?: "native" | "disabled"; backfillLimit?: number; - baseDomain?: string; beeperEnv?: "production" | "staging" | "dev" | "local"; bridgeId?: string; - bridgeManagerPostState?: boolean; bridgeManagerToken?: string; contactVisibility?: "agents" | "agents-and-users" | "none"; dataDir: string; - ghostLocalpartPrefix: string; homeserver?: string; hsToken?: string; homeserverDomain?: string; importSources?: OpenClawImportSource[]; matrixDeviceId?: string; matrixUserId?: string; - nonFederatedRooms: boolean; - registrationUrl: string; - senderLocalpart: string; - serviceBotLocalpart: string; - storePath: string; - userLocalpartPrefix: string; } export interface OpenClawBridgeRegistryData { From 2e805e66636f25404f66e719b29ba2681db54b91 Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Wed, 27 May 2026 16:30:44 +0200 Subject: [PATCH 41/43] Enable native Beeper stream identities and tool streaming --- .../src/beeper-channel-runtime.test.ts | 92 ++++++++ .../openclaw/src/beeper-channel-runtime.ts | 7 +- packages/openclaw/src/beeper-stream.ts | 19 +- packages/openclaw/src/beeper-turn-events.ts | 34 --- packages/openclaw/src/connector.test.ts | 18 ++ packages/openclaw/src/connector.ts | 1 + .../openclaw/src/openclaw-runtime.test.ts | 18 +- packages/openclaw/src/openclaw-runtime.ts | 217 ++++++++++++++---- packages/openclaw/src/setup.test.ts | 14 +- packages/openclaw/src/setup.ts | 45 +++- 10 files changed, 376 insertions(+), 89 deletions(-) diff --git a/packages/openclaw/src/beeper-channel-runtime.test.ts b/packages/openclaw/src/beeper-channel-runtime.test.ts index c667450..7c95dba 100644 --- a/packages/openclaw/src/beeper-channel-runtime.test.ts +++ b/packages/openclaw/src/beeper-channel-runtime.test.ts @@ -30,6 +30,51 @@ function createClient() { }; } +function createStreamingClient() { + return { + ...createClient(), + beeper: { + 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", + })), + }, + }, + }; +} + describe("BeeperChannelRuntime", () => { it("requires bridge portal routing for outbound message operations", async () => { const client = createClient(); @@ -176,6 +221,53 @@ describe("BeeperChannelRuntime", () => { expect(messageEvent.getSender()).toEqual({ isFromMe: true, sender: "@main:example" }); }); + it("starts native streams as the bound assistant ghost", async () => { + const client = createStreamingClient(); + const runtime = new BeeperChannelRuntime({ + client: client as never, + getAgents: () => [{ + agentId: "codex", + displayName: "Codex", + ghostUserId: "@codex:example", + }], + getBindingByRoom: () => ({ + agentId: "codex", + createdAt: 1, + ghostUserId: "@codex:example", + id: "binding", + kind: "session", + owner: "bridge", + roomId: "!room", + sessionKey: "agent:codex:desktop", + updatedAt: 1, + }), + userId: "@bot:example", + }); + + const stream = runtime.createStreamPublisher({ + agentId: "codex", + roomId: "!room", + runId: "run_1", + sessionKey: "agent:codex:desktop", + }); + await stream.start(); + + expect(client.beeper.aiRuns.begin).toHaveBeenCalledWith(expect.objectContaining({ + agentId: "codex", + agentName: "Codex", + runId: "run_1", + })); + expect(client.beeper.streams.startMessage).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.objectContaining({ + "com.beeper.per_message_profile": { + displayname: "Codex", + id: "codex", + }, + }), + userId: "@codex:example", + })); + }); + it("stores Beeper runtimes by OpenClaw host runtime", () => { const hostRuntime = {}; const scopedRuntime = new BeeperChannelRuntime({ client: createClient() as never }); diff --git a/packages/openclaw/src/beeper-channel-runtime.ts b/packages/openclaw/src/beeper-channel-runtime.ts index db0c8d4..8ef55aa 100644 --- a/packages/openclaw/src/beeper-channel-runtime.ts +++ b/packages/openclaw/src/beeper-channel-runtime.ts @@ -143,17 +143,22 @@ export class BeeperChannelRuntime { sessionKey: string; threadRoot?: string; }): BeeperTurnStreamCoordinator { + 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 = new BeeperTurnStreamCoordinator({ client: this.client, initialMessageMetadata: { agent_id: options.agentId, + ...(agent?.displayName ? { agent_name: agent.displayName } : {}), session_key: options.sessionKey, }, roomId: options.roomId, turnId: options.runId, ...(options.agentId ? { agentId: options.agentId } : {}), + ...(agent?.displayName ? { agentName: agent.displayName } : {}), ...(options.threadRoot ? { threadRoot: options.threadRoot } : {}), - ...(this.userId ? { userId: this.userId } : {}), + ...(userId ? { userId } : {}), }); this.#activeStreams.set(options.sessionKey, publisher); return publisher; diff --git a/packages/openclaw/src/beeper-stream.ts b/packages/openclaw/src/beeper-stream.ts index 594ae86..87594d6 100644 --- a/packages/openclaw/src/beeper-stream.ts +++ b/packages/openclaw/src/beeper-stream.ts @@ -29,6 +29,7 @@ export interface BeeperStreamSubscriber { export interface CreateBeeperTurnStreamCoordinatorOptions { agentId?: string; + agentName?: string; client: BeeperTurnStreamCoordinatorClient; initialMessageMetadata?: Record; roomId: string; @@ -65,6 +66,7 @@ export class BeeperTurnStreamCoordinator { #anchors = new Map(); #anchorOrder: string[] = []; #agentId: string | undefined; + #agentName: string | undefined; #client: BeeperTurnStreamCoordinatorClient; #currentAnchorId: string; #finalized = false; @@ -77,6 +79,7 @@ export class BeeperTurnStreamCoordinator { constructor(options: CreateBeeperTurnStreamCoordinatorOptions) { this.#agentId = options.agentId; + this.#agentName = options.agentName; this.#client = options.client; this.#initialMessageMetadata = options.initialMessageMetadata ?? {}; this.roomId = options.roomId; @@ -187,6 +190,7 @@ export class BeeperTurnStreamCoordinator { this.#runBegun = true; const snapshot = await this.#beginRun({ ...(this.#agentId ? { agentId: this.#agentId } : {}), + ...(this.#agentName ? { agentName: this.#agentName } : {}), model: "openclaw/plugin", runId: this.turnId, threadId: this.turnId, @@ -219,9 +223,11 @@ export class BeeperTurnStreamCoordinator { ...this.#initialMessageMetadata, ...(recordValue(initialAIMessage.metadata) ?? {}), }; + const perMessageProfile = this.#perMessageProfile(); const target = await this.#client.beeper.streams.startMessage({ content: { body: snapshot.body || "...", + ...(perMessageProfile ? { "com.beeper.per_message_profile": perMessageProfile } : {}), [BEEPER_AI_KEY]: initialAIMessage, [BEEPER_AI_METADATA_KEY]: metadata, [BEEPER_STREAM_DESCRIPTOR_KEY]: this.#streamDescriptor(), @@ -246,7 +252,7 @@ export class BeeperTurnStreamCoordinator { await this.#publishSnapshotEvents(anchor, snapshot); } - async #beginRun(options: { agentId?: string; model?: string; runId: string; threadId: string }): Promise { + async #beginRun(options: { agentId?: string; agentName?: string; model?: string; runId: string; threadId: string }): Promise { return this.#client.beeper.aiRuns.begin(options); } @@ -302,6 +308,7 @@ export class BeeperTurnStreamCoordinator { aiMessage: finalMessage, body: finalText, }); + const perMessageProfile = this.#perMessageProfile(); const finalMetadata = { ...this.#runMetadata(terminalPart.type === AGUIEventType.RUN_ERROR ? "error" : "complete", terminalPart), ...(recordValue(snapshot.metadata) ?? {}), @@ -312,6 +319,7 @@ export class BeeperTurnStreamCoordinator { body: finalContent.body || "...", content: { body: finalContent.body || "...", + ...(perMessageProfile ? { "com.beeper.per_message_profile": perMessageProfile } : {}), [BEEPER_AI_KEY]: finalContent.aiMessage, [BEEPER_AI_METADATA_KEY]: finalMetadata, [BEEPER_STREAM_DESCRIPTOR_KEY]: anchor.descriptor ?? this.#streamDescriptor(), @@ -338,6 +346,7 @@ export class BeeperTurnStreamCoordinator { #runMetadata(state: "streaming" | "complete" | "error", terminalPart?: AGUIEvent): Record { return stripUndefined({ agent: stripUndefined({ + displayName: this.#agentName, id: this.#agentId, }), data: this.#initialMessageMetadata, @@ -366,6 +375,14 @@ export class BeeperTurnStreamCoordinator { }); } + #perMessageProfile(): Record | undefined { + if (!this.#agentId && !this.#agentName) return undefined; + return stripUndefined({ + id: this.#agentId, + displayname: this.#agentName, + }); + } + #streamDescriptor(): Record { if (this.#subscribers.length === 0) { return { diff --git a/packages/openclaw/src/beeper-turn-events.ts b/packages/openclaw/src/beeper-turn-events.ts index 005b3bb..1a776eb 100644 --- a/packages/openclaw/src/beeper-turn-events.ts +++ b/packages/openclaw/src/beeper-turn-events.ts @@ -2,11 +2,8 @@ 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 type { RunFinishedEvent } from "@beeper/pickle-ag-ui"; import { defaultBeeperApprovalActions, defaultBeeperApprovalChoices } from "./approval"; -type FinishReason = NonNullable; - export interface StreamRunState { messageStarted: boolean; reasoningStarted: boolean; @@ -29,27 +26,6 @@ export function createTurnId(): string { return `turn_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; } -export function finishRunEvents( - state: StreamRunState, - finishReason: FinishReason = "stop", - metadata: Record = {} -): AGUIEvent[] { - return [ - ...closeOpenMessageParts(state), - { - messageId: state.turnId, - type: AGUIEventType.TEXT_MESSAGE_END, - }, - { - finishReason, - runId: state.turnId, - threadId: state.turnId, - type: AGUIEventType.RUN_FINISHED, - ...(Object.keys(metadata).length > 0 ? { metadata: { finish_reason: finishReason, turn_id: state.turnId, ...metadata } } : {}), - }, - ]; -} - export function mapOpenClawMessageDelta( state: StreamRunState, delta: { kind: "text" | "thinking"; value: string } @@ -74,10 +50,6 @@ export function mapOpenClawMessageDelta( ]; } -export function closeOpenMessageParts(state: StreamRunState): AGUIEvent[] { - return [...closeReasoningPart(state), ...closeTextPart(state)]; -} - export function openTextPart(state: StreamRunState): AGUIEvent[] { if (state.textStarted) return []; state.textStarted = true; @@ -90,12 +62,6 @@ export function openTextPart(state: StreamRunState): AGUIEvent[] { ]; } -export function closeTextPart(state: StreamRunState): AGUIEvent[] { - if (!state.textStarted) return []; - state.textStarted = false; - return []; -} - export function openReasoningPart(state: StreamRunState): AGUIEvent[] { if (state.reasoningStarted) return []; state.reasoningStarted = true; diff --git a/packages/openclaw/src/connector.test.ts b/packages/openclaw/src/connector.test.ts index d61ee5d..ecf600c 100644 --- a/packages/openclaw/src/connector.test.ts +++ b/packages/openclaw/src/connector.test.ts @@ -724,6 +724,24 @@ describe("OpenClawBridgeConnector", () => { 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 () => { diff --git a/packages/openclaw/src/connector.ts b/packages/openclaw/src/connector.ts index 56f7e79..c75ac69 100644 --- a/packages/openclaw/src/connector.ts +++ b/packages/openclaw/src/connector.ts @@ -761,6 +761,7 @@ function matrixMetadataFromParsed( ): OpenClawMatrixMessageMetadata { const metadata: OpenClawMatrixMessageMetadata = { sender }; 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; diff --git a/packages/openclaw/src/openclaw-runtime.test.ts b/packages/openclaw/src/openclaw-runtime.test.ts index a551e30..2f2aa3e 100644 --- a/packages/openclaw/src/openclaw-runtime.test.ts +++ b/packages/openclaw/src/openclaw-runtime.test.ts @@ -348,7 +348,6 @@ describe("OpenClawPluginRuntimeAdapter", () => { disableBlockStreaming: false, sourceReplyDeliveryMode: "automatic", }); - expect(beeperStreams.startMessage.mock.invocationCallOrder[0]).toBeLessThan(runAssembled.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY); expect(received).toEqual(expect.arrayContaining([ expect.objectContaining({ event: "thinking.delta" }), expect.objectContaining({ event: "tool.call.started" }), @@ -498,11 +497,18 @@ describe("OpenClawPluginRuntimeAdapter", () => { roomId: "!room:example", })), }; - let agentEventListener: ((event: { data?: Record; runId?: string; stream?: string }) => void) | undefined; + let agentEventListener: ((event: { data?: Record; runId?: string; sessionKey?: string; stream?: string }) => void) | undefined; const runAssembled = vi.fn(async (params: Record) => { const replyOptions = params.replyOptions as { runId?: string }; + const sessionKey = params.routeSessionKey as string; agentEventListener?.({ data: { delta: "hel", text: "hel" }, runId: replyOptions.runId, stream: "assistant" }); - agentEventListener?.({ data: { delta: "lo", text: "hello" }, runId: replyOptions.runId, stream: "assistant" }); + agentEventListener?.({ data: { delta: "lo", text: "hello" }, sessionKey, stream: "assistant" }); + 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: { 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: { 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" }); @@ -565,6 +571,12 @@ describe("OpenClawPluginRuntimeAdapter", () => { " world", ]); expect(parts).toEqual(expect.arrayContaining([ + expect.objectContaining({ toolCallId: "codex-tool", toolName: "tool", type: "TOOL_CALL_START" }), + expect.objectContaining({ toolCallId: "codex-tool", toolName: "tool", type: "TOOL_CALL_END" }), + expect.objectContaining({ content: "loading", state: "streaming", toolCallId: "tool-c", toolName: "search", type: "TOOL_CALL_RESULT" }), + expect.objectContaining({ content: "checking docs", state: "streaming", toolCallId: "plan", toolName: "plan", type: "TOOL_CALL_RESULT" }), + expect.objectContaining({ content: "stdout", state: "streaming", toolCallId: "cmd-1", toolName: "shell", type: "TOOL_CALL_RESULT" }), + expect.objectContaining({ content: "changed a.ts", toolCallId: "patch-1", toolName: "patch", type: "TOOL_CALL_RESULT" }), expect.objectContaining({ name: "source", type: "CUSTOM", value: { items: [{ title: "Docs", url: "https://example.com" }] } }), expect.objectContaining({ name: "file", type: "CUSTOM", value: { filename: "report.txt", id: "file_1" } }), expect.objectContaining({ name: "data", type: "CUSTOM", value: { status: "indexed" } }), diff --git a/packages/openclaw/src/openclaw-runtime.ts b/packages/openclaw/src/openclaw-runtime.ts index 73a91fc..78ee059 100644 --- a/packages/openclaw/src/openclaw-runtime.ts +++ b/packages/openclaw/src/openclaw-runtime.ts @@ -9,7 +9,6 @@ import { AGUIEventType, closeReasoningPart, createStreamRunState, - finishRunEvents, mapOpenClawApprovalRequest, mapOpenClawApprovalResponse, mapOpenClawCustom, @@ -133,6 +132,10 @@ export interface OpenClawMatrixAttachmentMetadata { export interface OpenClawMatrixMessageMetadata { attachments?: OpenClawMatrixAttachmentMetadata[]; + command?: { + args?: string; + name: string; + }; formattedBody?: string; mentions?: { room?: boolean; @@ -766,6 +769,10 @@ async function runBeeperChannelTurnInPluginRuntime(params: { const sender = recordValue(recordValue(params.record.matrix)?.sender) ?? {}; const matrix = recordValue(params.record.matrix) ?? {}; 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); @@ -810,11 +817,21 @@ async function runBeeperChannelTurnInPluginRuntime(params: { body: params.message, rawBody: params.message, bodyForAgent: params.message, - commandBody: 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, @@ -856,12 +873,20 @@ async function runBeeperChannelTurnInPluginRuntime(params: { const unsubscribeAgentEvents = forwardAgentRuntimeStreamEvents({ runId: params.runId, runtime: params.runtime, + sessionKey: params.sessionKey, stream, }); + let streamStartError: unknown; try { params.localEvents.emit({ event: "stream.starting", payload: { agentId: params.agentId, roomId, runId: params.runId, sessionId: params.sessionId, sessionKey: params.sessionKey } }); - await stream.start(); - params.localEvents.emit({ event: "stream.started", payload: { agentId: params.agentId, roomId, runId: params.runId, sessionId: params.sessionId, sessionKey: params.sessionKey } }); + const streamStarted = stream.start().then( + () => { + params.localEvents.emit({ event: "stream.started", payload: { agentId: params.agentId, roomId, runId: params.runId, sessionId: params.sessionId, sessionKey: params.sessionKey } }); + }, + (error) => { + streamStartError = error; + }, + ); await turn.runAssembled({ cfg: params.cfg, channel: "beeper", @@ -920,6 +945,8 @@ async function runBeeperChannelTurnInPluginRuntime(params: { }, messageId: eventId, }); + await streamStarted; + if (streamStartError !== undefined) throw streamStartError; 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 } }); @@ -934,20 +961,58 @@ async function runBeeperChannelTurnInPluginRuntime(params: { function forwardAgentRuntimeStreamEvents(params: { runId: string; runtime: OpenClawHostRuntime; + sessionKey: string; stream: ReturnType; }): (() => void) | undefined { const onAgentEvent = typeof params.runtime.events === "object" ? params.runtime.events?.onAgentEvent : undefined; - if (!onAgentEvent) return 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) => { - if (event.runId !== params.runId) return; const data = recordValue(event.data) ?? {}; - switch (event.stream) { + const matched = matchesAgentStreamEvent({ data, event, runId: params.runId, sessionKey: params.sessionKey }); + const stream = normalizeAgentStream(event.stream); + 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; + switch (stream) { case "assistant": void params.stream.textPayload(data, "partial"); break; case "thinking": + case "reasoning": void params.stream.reasoningPayload(data); break; + case "item": + void params.stream.itemEvent(data); + break; + case "plan": + void params.stream.planUpdate(data); + break; + case "approval": + void params.stream.approvalEvent(data); + break; + case "command_output": + case "command-output": + void params.stream.commandOutput(data); + break; + case "patch": + void params.stream.patchSummary(data); + break; case "state": case "snapshot": void params.stream.stateSnapshot(data); @@ -966,7 +1031,7 @@ function forwardAgentRuntimeStreamEvents(params: { void params.stream.customData("data", data); break; case "raw": - void params.stream.raw(event.stream, data); + void params.stream.raw(stream, data); break; default: break; @@ -974,6 +1039,32 @@ function forwardAgentRuntimeStreamEvents(params: { }); } +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 isCompletePhase(value: string | undefined): boolean { + return value === "complete" || value === "completed" || value === "end" || value === "ended" || value === "finish" || value === "finished" || value === "done"; +} + function createBeeperReplyStreamEmitter(base: { agentId: string; hostRuntime?: OpenClawHostRuntime; @@ -1000,8 +1091,10 @@ function createBeeperReplyStreamEmitter(base: { let finalized = false; let lastVisibleText = ""; let lastReasoningText = ""; + let startPromise: Promise | undefined; const toolInputs = new Map(); const toolNames = new Map(); + const startedToolCalls = new Set(); const emit = (event: string, payload: Record) => { base.localEvents.emit({ event, @@ -1016,23 +1109,32 @@ function createBeeperReplyStreamEmitter(base: { }; const ensureStarted = async () => { if (hasPublished || finalized) return; - hasPublished = true; - channelRuntime.debug("openclaw_beeper_stream_starting", { - agentId: base.agentId, - roomId: base.roomId, - runId: base.runId, - sessionId: base.sessionId, - sessionKey: base.sessionKey, - }); - await publisher.start(); - channelRuntime.debug("openclaw_beeper_stream_started", { - agentId: base.agentId, - eventId: publisher.targetEventId, - roomId: base.roomId, - runId: base.runId, - sessionId: base.sessionId, - sessionKey: base.sessionKey, - }); + 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; + state.textStarted = 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 publish = async (parts: Iterable) => { if (finalized) return; @@ -1092,6 +1194,11 @@ function createBeeperReplyStreamEmitter(base: { if (input !== undefined) toolInputs.set(toolCallId, input); }; const rememberedToolName = (toolCallId: string, fallback?: string) => toolNames.get(toolCallId) ?? fallback; + const startToolCall = (event: Parameters[0]) => { + if (startedToolCalls.has(event.toolCallId)) return []; + startedToolCalls.add(event.toolCallId); + return mapOpenClawToolInput(event); + }; return { start: ensureStarted, assistantMessageStart: () => { @@ -1117,7 +1224,7 @@ function createBeeperReplyStreamEmitter(base: { toolCallId, toolName, }); - await publish(mapOpenClawToolInput(stripUndefined({ + await publish(startToolCall(stripUndefined({ approval: recordValue(data.approval), index: numberValue(data.index), input: data.args ?? data.input, @@ -1151,25 +1258,39 @@ function createBeeperReplyStreamEmitter(base: { itemEvent: async (payload: unknown) => { const data = recordValue(payload) ?? {}; const toolCallId = toolIdFor(data, stringValue(data.kind) ?? "item"); - const output = stringValue(data.progressText) ?? stringValue(data.summary) ?? stringValue(data.title); - if (!output) return; + const rawToolName = stringValue(data.name) ?? stringValue(data.toolName); + const itemType = stringValue(data.type); + const kind = stringValue(data.kind); + const toolName = rememberedToolName(toolCallId, rawToolName ?? specificToolName(kind) ?? specificToolName(itemType) ?? "tool"); + const title = stringValue(data.title) ?? stringValue(data.progressText) ?? stringValue(data.summary) ?? rawToolName ?? itemType ?? kind; + const output = stringValue(data.progressText) ?? stringValue(data.summary) ?? stringValue(data.error); const phase = stringValue(data.phase); const status = stringValue(data.status); - const preliminary = phase !== "complete" && phase !== "end" && status !== "complete" && status !== "completed"; - const toolName = rememberedToolName(toolCallId, stringValue(data.name) ?? stringValue(data.kind)); + const preliminary = !isCompletePhase(phase) && !isCompletePhase(status); rememberTool(toolCallId, toolName); - emit("tool.call.completed", { + emit("tool.call.updated", { output, + phase, preliminary, toolCallId, toolName, }); - await publish(mapOpenClawToolOutput(stripUndefined({ - output, - preliminary, - toolCallId, - toolName, - }))); + await publish([ + ...startToolCall(stripUndefined({ title, toolCallId, toolName })), + ...(output ? mapOpenClawToolOutput(stripUndefined({ + error: data.error, + output, + preliminary, + toolCallId, + toolName, + })) : []), + ...(!preliminary ? mapOpenClawToolEnd(stripUndefined({ + error: data.error, + result: output, + toolCallId, + toolName, + })) : []), + ]); }, planUpdate: async (payload: unknown) => { const data = recordValue(payload) ?? {}; @@ -1244,12 +1365,21 @@ function createBeeperReplyStreamEmitter(base: { }))]); } }, + 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 = phase === "complete" || phase === "end" || status === "complete" || status === "completed"; + const complete = isCompletePhase(phase) || isCompletePhase(status); const toolCallId = toolIdFor(data, fallbackToolIdForName(toolName, "command")); const output = stringValue(data.output) ?? data; rememberTool(toolCallId, toolName); @@ -1296,21 +1426,14 @@ function createBeeperReplyStreamEmitter(base: { finish: async (payload?: unknown) => { if (payload !== undefined) await textPayload(payload, "final"); if (!hasPublished || finalized) return; - const events = finishRunEvents(state, "stop", { - agent_id: base.agentId, - run_id: base.runId, - session_id: base.sessionId, - session_key: base.sessionKey, - }); - const terminal = events.at(-1); - const preTerminal = events.slice(0, -1); + const preTerminal = closeReasoningPart(state); if (preTerminal.length > 0) await publisher.publishMany(preTerminal); finalized = true; channelRuntime.debug("openclaw_beeper_stream_finalizing", { roomId: base.roomId, runId: base.runId, }); - await publisher.finalize(stripUndefined({ terminalPart: terminal, finishReason: "stop" })); + await publisher.finalize({ finishReason: "stop" }); channelRuntime.clearActiveStream(base.sessionKey, publisher); channelRuntime.debug("openclaw_beeper_stream_finalized", { eventId: publisher.targetEventId, diff --git a/packages/openclaw/src/setup.test.ts b/packages/openclaw/src/setup.test.ts index 7049e90..4979314 100644 --- a/packages/openclaw/src/setup.test.ts +++ b/packages/openclaw/src/setup.test.ts @@ -259,6 +259,11 @@ describe("OpenClaw Beeper setup surface", () => { appserviceMocks.startOpenClawBeeperBridge.mockResolvedValueOnce({ stop }); const abort = new AbortController(); const statuses: unknown[] = []; + const channelRuntime = { + reply: { dispatchReplyWithBufferedBlockDispatcher: vi.fn() }, + session: { recordInboundSession: vi.fn() }, + turn: { buildContext: vi.fn(), runAssembled: vi.fn() }, + }; const cfg = applyBeeperChannelSettings({}, { accessToken: "at", asToken: "as", @@ -276,8 +281,9 @@ describe("OpenClaw Beeper setup surface", () => { abortSignal: abort.signal, accountId: "default", cfg, + channelRuntime, setStatus: (next) => statuses.push(next), - }); + } as never); await vi.waitFor(() => expect(appserviceMocks.startOpenClawBeeperBridge).toHaveBeenCalledOnce()); expect(appserviceMocks.accountFromOpenClawConfig).toHaveBeenCalledWith(expect.objectContaining({ accessToken: "at", @@ -293,7 +299,13 @@ describe("OpenClaw Beeper setup surface", () => { importSources: ["dashboard", "tui"], }), 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; diff --git a/packages/openclaw/src/setup.ts b/packages/openclaw/src/setup.ts index eca6aa4..96a06c0 100644 --- a/packages/openclaw/src/setup.ts +++ b/packages/openclaw/src/setup.ts @@ -7,7 +7,7 @@ import { createConfigFromOpenClawSetup, defaultDataDir } from "./config"; import beeperChannelConfigSchema from "./beeper-channel-config.schema.json"; import type { setupOpenClawBeeperBridge, SetupOpenClawBeeperBridgeOptions } from "./beeper-setup"; import { createBeeperApprovalNotice } from "./approval"; -import { requireBeeperChannelRuntimeForHost } from "./beeper-channel-runtime"; +import { requireBeeperChannelRuntimeForHost, setBeeperChannelRuntimeForHost } from "./beeper-channel-runtime"; import type { OpenClawHostRuntime } from "./openclaw-runtime"; export type OpenClawSetupConfig = OpenClawConfig; @@ -72,6 +72,7 @@ type BeeperGatewayContext = { abortSignal: AbortSignal; accountId: string; cfg: OpenClawSetupConfig; + channelRuntime?: unknown; hostRuntime?: unknown; log?: { info?: (message: string) => void; @@ -1078,6 +1079,9 @@ export async function startBeeperGatewayAccount(ctx: BeeperGatewayContext | Chan log: bridgeLoggerFromChannelContext(ctx), ...(hostRuntime ? { runtime: hostRuntime } : {}), }); + if (hostRuntime && openClawPluginRuntime && hostRuntime !== openClawPluginRuntime) { + setBeeperChannelRuntimeForHost(openClawPluginRuntime, requireBeeperChannelRuntimeForHost(hostRuntime)); + } const key = gatewayAccountKey(ctx.accountId); startedBridges.set(key, bridge as StartedBeeperBridge); ctx.setStatus?.({ @@ -1091,6 +1095,9 @@ export async function startBeeperGatewayAccount(ctx: BeeperGatewayContext | Chan await waitForAbort(ctx.abortSignal); } finally { startedBridges.delete(key); + if (hostRuntime && openClawPluginRuntime && hostRuntime !== openClawPluginRuntime) { + setBeeperChannelRuntimeForHost(openClawPluginRuntime, undefined); + } await bridge.stop?.(); ctx.setStatus?.({ accountId: ctx.accountId, @@ -1129,11 +1136,36 @@ function formatStartupError(error: unknown): string { function resolveBeeperHostRuntime(ctx: BeeperGatewayContext): OpenClawHostRuntime | undefined { if (ctx.hostRuntime && typeof ctx.hostRuntime === "object" && hasOpenClawSessionRuntime(ctx.hostRuntime)) return ctx.hostRuntime; - if (ctx.runtime && typeof ctx.runtime === "object" && hasOpenClawSessionRuntime(ctx.runtime)) return ctx.runtime; + 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; @@ -1142,6 +1174,15 @@ function hasOpenClawSessionRuntime(value: object): value is OpenClawHostRuntime || 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.turn?.buildContext === "function" + && typeof channel.turn.runAssembled === "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) return; From 7274fad3f5a85d83662e0ad15567c1d633fd1f95 Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Wed, 27 May 2026 16:35:35 +0200 Subject: [PATCH 42/43] Handle Codex tool stream events without false positives --- .../openclaw/src/openclaw-runtime.test.ts | 10 +++++++ packages/openclaw/src/openclaw-runtime.ts | 29 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/packages/openclaw/src/openclaw-runtime.test.ts b/packages/openclaw/src/openclaw-runtime.test.ts index 2f2aa3e..14a00ac 100644 --- a/packages/openclaw/src/openclaw-runtime.test.ts +++ b/packages/openclaw/src/openclaw-runtime.test.ts @@ -503,9 +503,13 @@ describe("OpenClawPluginRuntimeAdapter", () => { const sessionKey = params.routeSessionKey as string; 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: { 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: { 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" }); @@ -573,6 +577,8 @@ describe("OpenClawPluginRuntimeAdapter", () => { expect(parts).toEqual(expect.arrayContaining([ expect.objectContaining({ toolCallId: "codex-tool", toolName: "tool", type: "TOOL_CALL_START" }), expect.objectContaining({ toolCallId: "codex-tool", toolName: "tool", type: "TOOL_CALL_END" }), + expect.objectContaining({ toolCallId: "tool-stream", toolName: "search", type: "TOOL_CALL_START" }), + expect.objectContaining({ toolCallId: "tool-stream", toolName: "search", type: "TOOL_CALL_END" }), expect.objectContaining({ content: "loading", state: "streaming", toolCallId: "tool-c", toolName: "search", type: "TOOL_CALL_RESULT" }), expect.objectContaining({ content: "checking docs", state: "streaming", toolCallId: "plan", toolName: "plan", type: "TOOL_CALL_RESULT" }), expect.objectContaining({ content: "stdout", state: "streaming", toolCallId: "cmd-1", toolName: "shell", type: "TOOL_CALL_RESULT" }), @@ -582,6 +588,10 @@ describe("OpenClawPluginRuntimeAdapter", () => { expect.objectContaining({ name: "data", type: "CUSTOM", value: { status: "indexed" } }), expect.objectContaining({ snapshot: { phase: "retrieval" }, type: "STATE_SNAPSHOT" }), ])); + expect(parts).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ toolCallId: "user-message", type: "TOOL_CALL_START" }), + expect.objectContaining({ toolCallId: "agent-message", type: "TOOL_CALL_START" }), + ])); setBeeperChannelRuntimeForHost(hostRuntime, undefined); }); diff --git a/packages/openclaw/src/openclaw-runtime.ts b/packages/openclaw/src/openclaw-runtime.ts index 78ee059..80612ea 100644 --- a/packages/openclaw/src/openclaw-runtime.ts +++ b/packages/openclaw/src/openclaw-runtime.ts @@ -997,6 +997,19 @@ function forwardAgentRuntimeStreamEvents(params: { case "reasoning": void params.stream.reasoningPayload(data); break; + case "tool": + if (stringValue(data.phase) === "start") { + void params.stream.toolStart(data); + } else if (stringValue(data.phase) === "result" || isCompletePhase(stringValue(data.phase))) { + void params.stream.toolResult(data); + } else { + void params.stream.itemEvent({ + ...data, + kind: "tool", + progressText: stringValue(data.partialResult) ?? stringValue(data.output) ?? stringValue(data.result), + }); + } + break; case "item": void params.stream.itemEvent(data); break; @@ -1061,6 +1074,20 @@ function specificToolName(value: string | undefined): string | undefined { return value; } +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"; } @@ -1261,6 +1288,8 @@ function createBeeperReplyStreamEmitter(base: { const rawToolName = stringValue(data.name) ?? stringValue(data.toolName); const itemType = stringValue(data.type); const kind = stringValue(data.kind); + const hasToolIdentity = Boolean(rawToolName || stringValue(data.toolCallId) || kind === "tool" || kind === "command" || kind === "patch"); + if (!hasToolIdentity && !isToolItemType(itemType)) return; const toolName = rememberedToolName(toolCallId, rawToolName ?? specificToolName(kind) ?? specificToolName(itemType) ?? "tool"); const title = stringValue(data.title) ?? stringValue(data.progressText) ?? stringValue(data.summary) ?? rawToolName ?? itemType ?? kind; const output = stringValue(data.progressText) ?? stringValue(data.summary) ?? stringValue(data.error); From 1c6b06c093f11add03e3a5d9a81f441993ec725a Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Wed, 27 May 2026 16:48:35 +0200 Subject: [PATCH 43/43] Rename OpenClaw package and remove plan files --- PLAN.md | 66 ------------- PLAN_OPENCLAW.md | 94 ------------------- package.json | 2 +- packages/openclaw/README.md | 10 +- packages/openclaw/package.json | 6 +- .../openclaw/scripts/copy-runtime-assets.mjs | 2 +- .../openclaw/src/openclaw-extension.test.ts | 4 +- 7 files changed, 12 insertions(+), 172 deletions(-) delete mode 100644 PLAN.md delete mode 100644 PLAN_OPENCLAW.md diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index 1a41b92..0000000 --- a/PLAN.md +++ /dev/null @@ -1,66 +0,0 @@ -# Production OpenClaw Beeper Bridge - -## Summary - -Build a production ClawHub-installable OpenClaw channel plugin in Pickle that bridges OpenClaw sessions into Beeper through a self-hosted Beeper appservice. The plugin owns Beeper login, appservice registration, settings/setup, contact discovery, DM creation, Matrix event parsing, slash commands, native Beeper live streaming, approvals, reactions, replies, and opt-in session backfill. - -The package remains in Pickle, but ships OpenClaw plugin metadata, setup entrypoints, and runtime entrypoints so users install it with `openclaw plugins install clawhub:` and configure it from the OpenClaw dashboard. - -## Key Changes - -- Package and ClawHub shape: - - Turn `packages/openclaw` into the public OpenClaw plugin package, with `openclaw.plugin.json`, `openclaw` package metadata, `setupEntry`, runtime entry, ClawHub install metadata, peer dependency on OpenClaw, and publish-ready docs. - - Use channel id `beeper`, label `Beeper`, and keep Pickle bridge code as the transport/runtime layer inside the package. - - Default import scope is opt-in per source: dashboard, TUI, channel-origin sessions, archived sessions. - -- Beeper login, registration, and settings: - - Add OpenClaw setup-entry support for dashboard-driven Beeper email/OTP login and self-hosted bridge/appservice registration. - - Store settings under `plugins.entries.beeper.config` / `channels.beeper` as appropriate for OpenClaw channel config conventions. - - Settings include Beeper env, registration URL, bridge manager token, gateway URL, import sources, backfill limit, non-federated rooms, contact visibility, stream/finalization behavior, and approval behavior. - - CLI remains available for scripting, but dashboard setup is the primary path. - -- Contacts, search, and DMs: - - Sync all OpenClaw agents into Beeper ghosts with deterministic fixed MXIDs. - - Expose agents through Pickle `resolveIdentifier` contact-list/search behavior and create one DM room per agent on demand. - - `/new` creates a fresh OpenClaw session and Beeper room; existing agent DMs start a session on first user message. - - Avoid bot-loop/cross-room forwarding: ignore Beeper self/bot-originated events and never forward messages between Beeper rooms. - -- Matrix message parsing and commands: - - Parse Matrix text, replies, threads, edits, reactions, redactions, attachments, emoji, formatted bodies, and relation chains into OpenClaw session input metadata. - - Implement bridge slash commands in Matrix rooms: `/new`, `/agent`, `/sessions`, `/import`, `/backfill`, `/abort`, `/approve`, `/deny`, `/status`, `/settings`. - - Reactions map to OpenClaw reactions where applicable, and approval reactions map to approval decisions. - - Replies preserve target event/message ids and quoted context so OpenClaw can understand conversation references. - -- Live streaming, approvals, and backfill: - - Add the real default Beeper stream publisher using `client.beeper.streams.startMessage`, `publishPart`, and `finalizeMessage`. - - Publish full AG-UI/Beeper native stream lifecycle: reasoning, text deltas, tool inputs, tool outputs, approval requests/responses, errors, aborts, and final replacement message. - - Finalize streams as editable/replaced Beeper messages where supported; keep fallback final text for clients without native rendering. - - Approval gates are end-to-end: Beeper approval UI/reactions/slash commands resolve OpenClaw exec/plugin approvals. - - Backfill imports selected OpenClaw session sources only when enabled in settings, creates room bindings, preserves agent/user ghosts, and avoids duplicate imports via registry state. - -## Test Plan - -- Unit tests: - - Beeper OTP/setup config, appservice registration, ClawHub/package metadata, settings schema, and dashboard setup adapters. - - Agent contact sync/search/DM creation, fixed ghost MXIDs, bot-loop suppression, slash command parsing, and Matrix relation parsing. - - Native stream publisher start/publish/finalize/error/abort behavior with AG-UI parts and final `com.beeper.ai` content. - - Backfill opt-in source filtering, dedupe, registry persistence, and room binding. - -- Integration-style tests: - - Pickle bridge dispatch for messages, replies, reactions, edits, approvals, and backfill. - - OpenClaw plugin setup-entry import safety using `.upstream/openclaw` channel plugin contracts. - - Dashboard channel card/settings behavior via OpenClaw UI patterns where package-level tests can cover it without patching OpenClaw core. - -- Verification gates: - - `pnpm --filter @beeper/pickle-openclaw typecheck` - - `pnpm --filter @beeper/pickle-openclaw test -- --run` - - `pnpm --filter @beeper/pickle-openclaw build` - - Focused Pickle bridge stream/appservice tests - - Package validation for OpenClaw plugin manifest and ClawHub publish dry-run shape - -## Assumptions - -- Implementation stays in Pickle; OpenClaw core/dashboard are not patched. -- Users install from ClawHub, so dashboard integration must come from OpenClaw plugin metadata, setup entrypoints, config schema, channel metadata, and runtime methods. -- Default backfill/import is opt-in by source, not automatic. -- v1 must support at least contact search, create DM, full live streaming, approvals, replies, reactions, slash commands, Beeper login, bridge registration, dashboard setup/settings, and opt-in backfill. diff --git a/PLAN_OPENCLAW.md b/PLAN_OPENCLAW.md deleted file mode 100644 index 0d0de0a..0000000 --- a/PLAN_OPENCLAW.md +++ /dev/null @@ -1,94 +0,0 @@ -# First-Class Beeper Network Connector Rewrite - -## Summary -Rewrite `@beeper/pickle-openclaw` as a first-class OpenClaw channel plugin, modeled after Telegram’s plugin-SDK architecture, with Beeper native AG-UI streaming backed by the existing Go `ai-bridge` code through Pickle’s WASM bridge. - -This is a nuclear cut: remove the bespoke OpenClaw gateway transport, ad hoc stream mappers, and compatibility command path. The new connector uses OpenClaw’s channel plugin contract for setup, runtime startup, inbound dispatch, outbound delivery, approvals, actions, directory, routing, and message streaming. - -## Key Architecture -- Register the channel with `defineChannelPluginEntry` and `defineSetupPluginEntry` from `openclaw/plugin-sdk/channel-core`. -- Build `beeperChannelPlugin` with `createChannelPluginBase` / `createChatChannelPlugin`, matching Telegram’s shape: - - `config`, `setup`, `setupWizard`, `status`, `gateway` - - `message`, `outbound`, `messaging`, `threading` - - `directory`, `resolver`, `actions`, `approvalCapability`, `agentPrompt` - - `commands` for OpenClaw-native command discovery instead of connector-local slash switches. -- Promote Beeper capabilities to a real network connector surface: - - `chatTypes: ["direct", "group", "thread"]` - - `media: true`, `reactions: true`, `threads: true` - - `nativeCommands: true`, `blockStreaming: true` -- `gateway.startAccount` starts the Pickle/Beeper appservice bridge and registers a Beeper network runtime with `api.runtime.channel.runtimeContexts`. -- Message adapters resolve the active Beeper runtime through the stored OpenClaw `PluginRuntime`, not through a global singleton. -- Inbound Matrix events enter OpenClaw through `runtime.channel.turn.run` / `runAssembled` and SDK-built inbound context, not through custom `sessions.send` RPC emulation. - -## Streaming Design -- Introduce a `BeeperTurnStreamCoordinator` in TypeScript: - - one coordinator per OpenClaw turn - - one or more Beeper native stream anchors per assistant segment - - all text, reasoning, tools, approvals, state, sources, files, data, snapshots, and terminal events pass through one serialized queue -- Use multiple Beeper stream messages when OpenClaw emits multiple assistant messages or when a tool/progress segment needs its own live stream before answer text exists. -- Preserve event order exactly for live streaming. Do not reorder text/tool/progress events in TypeScript. -- Keep durable finalization per stream anchor: - - default finalization is replacement edit with final `com.beeper.ai` - - no `append` or `native-only` mode in the new OpenClaw connector -- Tool lifecycle rules: - - tool start emits `TOOL_CALL_START` - - argument chunks emit `TOOL_CALL_ARGS` - - progress emits `TOOL_CALL_RESULT` with `state: "streaming"` - - final result emits `TOOL_CALL_RESULT` with `state: "complete"` or `"error"` - - close emits `TOOL_CALL_END` - - approval request/response emits both AG-UI custom approval events and matching tool state transitions. - -## Go/WASM `ai-bridge` Usage -- Keep using the existing `github.com/beeper/ai-bridge` dependency already present in `packages/pickle/native/go.mod`. -- Add Pickle WASM operations that expose `ai-stream` run behavior to TypeScript: - - `begin_beeper_ai_run`: creates an `aistream.Run`, returns initial Beeper AI content and start events. - - `append_beeper_ai_run_event`: validates and records one AG-UI event in Go. - - `finish_beeper_ai_run`: calls Go writer finalization, returns final events and final content. - - `error_beeper_ai_run`: finalizes as error or abort and returns final events/content. - - `delete_beeper_ai_run`: releases native run state. -- Move final `com.beeper.ai` and `com.beeper.ai.metadata` construction to Go via `aistream.Run.FinalUIMessage()` and `Run.Metadata()`. -- Update native `publish_beeper_stream_message_part` to use `aistream.PackRunFromSeq` semantics for oversized events, so text/tool/snapshot payloads split into budget-safe envelopes while preserving seq. -- TypeScript remains responsible only for adapting OpenClaw callback/event payloads into canonical AG-UI event intents; Go owns validation, metadata, snapshots, final UI message construction, and carrier budget handling. - -## Implementation Changes -- Replace `openclaw-extension.ts` custom registration with SDK entry helpers and `setRuntime(api.runtime)`. -- Replace `OpenClawGatewayRuntime` and `createOpenClawHostTransport` usage in Beeper-originated turns with OpenClaw plugin runtime/channel helpers. -- Replace `BeeperStreamPublisher` and `stream-map.ts` with the new coordinator plus Go-backed AI run bridge. -- Replace connector-local `/help`, `/tools`, `/models`, `/tasks`, `/stop`, approval command handling with OpenClaw SDK command and approval surfaces. -- Keep the Pickle bridge/appservice mechanics for Matrix transport, portals, contacts, appservice registration, media, reactions, receipts, and backfill where still needed. -- Preserve user work currently present in `packages/openclaw/src/connector.ts` and `packages/openclaw/src/connector.test.ts` only if it still applies after the rewrite; do not silently overwrite it. - -## Test Plan -- Add plugin contract tests proving Beeper registers like Telegram: - - `defineChannelPluginEntry` registration modes - - channel metadata/capabilities - - gateway start/stop lifecycle - - runtime context registration - - message/outbound/action/approval surfaces -- Add Go native tests for: - - begin/append/finish/error/delete AI run operations - - final UI content parity with `ai-bridge` - - carrier splitting with large text, tool output, and `MESSAGES_SNAPSHOT` - - seq continuity after split carriers -- Add TypeScript streaming tests for: - - text and reasoning chunk streaming - - tool args/progress/result/end ordering - - approvals with response state - - plan/state/source/document/file/data/custom events - - multiple assistant messages producing multiple Beeper streams - - abort/error terminal paths -- Add end-to-end-style plugin runtime tests using OpenClaw’s plugin test runtime: - - inbound Beeper message dispatches through `runtime.channel.turn` - - final delivery goes through Beeper message adapter - - live AG-UI deltas arrive before final replacement -- Run: - - `pnpm --filter @beeper/pickle test:go` - - `pnpm --filter @beeper/pickle test` - - `pnpm --filter @beeper/pickle-openclaw test` - - `pnpm --filter @beeper/pickle-openclaw typecheck` - - `pnpm check` - -## Assumptions -- No migration means old internal APIs, tests, config modes, and stream finalization options may be deleted. -- Pickle native Matrix/Beeper transport remains the foundation; only missing `ai-bridge` run-state operations and carrier splitting are added. -- Live streaming fidelity is the highest priority; final content should be Go `ai-bridge` canonical even where that canonical final representation is less interleaved than live events. diff --git a/package.json b/package.json index 156b4fe..cfdcd50 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "pickle-monorepo", + "name": "@beeper/openclaw", "private": true, "type": "module", "packageManager": "pnpm@10.25.0", diff --git a/packages/openclaw/README.md b/packages/openclaw/README.md index 1b8e232..7b13c22 100644 --- a/packages/openclaw/README.md +++ b/packages/openclaw/README.md @@ -1,4 +1,4 @@ -# @beeper/pickle-openclaw +# @beeper/openclaw Pickle bridge package for exposing OpenClaw sessions in Beeper/Matrix as an OpenClaw-native channel plugin. @@ -7,7 +7,7 @@ Pickle bridge package for exposing OpenClaw sessions in Beeper/Matrix as an Open Install the Beeper channel plugin from ClawHub: ```sh -openclaw plugins install clawhub:@beeper/pickle-openclaw@0.1.0 +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. @@ -52,14 +52,14 @@ The bridge runtime itself is started by OpenClaw when the installed channel plug ```ts import { backfillAllOpenClawSessions, -} from "@beeper/pickle-openclaw/backfill"; +} from "@beeper/openclaw/backfill"; import { createDefaultConfig, -} from "@beeper/pickle-openclaw/config"; +} from "@beeper/openclaw/config"; import { accountFromOpenClawConfig, createOpenClawBeeperBridge, -} from "@beeper/pickle-openclaw/appservice"; +} from "@beeper/openclaw/appservice"; const config = createDefaultConfig({ accessToken: process.env.BEEPER_ACCESS_TOKEN, diff --git a/packages/openclaw/package.json b/packages/openclaw/package.json index ae718da..bead46e 100644 --- a/packages/openclaw/package.json +++ b/packages/openclaw/package.json @@ -1,5 +1,5 @@ { - "name": "@beeper/pickle-openclaw", + "name": "@beeper/openclaw", "version": "0.1.0", "description": "Beeper Matrix bridge runtime for OpenClaw sessions and agents", "type": "module", @@ -144,8 +144,8 @@ ] }, "install": { - "clawhubSpec": "clawhub:@beeper/pickle-openclaw@0.1.0", - "npmSpec": "@beeper/pickle-openclaw@0.1.0", + "clawhubSpec": "clawhub:@beeper/openclaw@0.1.0", + "npmSpec": "@beeper/openclaw@0.1.0", "defaultChoice": "clawhub", "minHostVersion": ">=2026.5.22" }, diff --git a/packages/openclaw/scripts/copy-runtime-assets.mjs b/packages/openclaw/scripts/copy-runtime-assets.mjs index 04cc7be..5410813 100644 --- a/packages/openclaw/scripts/copy-runtime-assets.mjs +++ b/packages/openclaw/scripts/copy-runtime-assets.mjs @@ -13,7 +13,7 @@ for (const file of ["pickle.wasm", "wasm_exec.js"]) { try { await stat(source); } catch { - throw new Error(`Missing ${file}; run pnpm --filter @beeper/pickle build before building @beeper/pickle-openclaw`); + throw new Error(`Missing ${file}; run pnpm --filter @beeper/pickle build before building @beeper/openclaw`); } await copyFile(source, resolve(outputDir, file)); } diff --git a/packages/openclaw/src/openclaw-extension.test.ts b/packages/openclaw/src/openclaw-extension.test.ts index c9b6379..cc9b792 100644 --- a/packages/openclaw/src/openclaw-extension.test.ts +++ b/packages/openclaw/src/openclaw-extension.test.ts @@ -104,10 +104,10 @@ describe("OpenClaw plugin package metadata", () => { expect(packageJson.openclaw?.channel?.id).toBe("beeper"); expect(packageJson.openclaw?.install?.defaultChoice).toBe("clawhub"); expect(packageJson.openclaw?.install?.clawhubSpec).toBe( - `clawhub:@beeper/pickle-openclaw@${packageJson.version}`, + `clawhub:@beeper/openclaw@${packageJson.version}`, ); expect(packageJson.openclaw?.install?.npmSpec).toBe( - `@beeper/pickle-openclaw@${packageJson.version}`, + `@beeper/openclaw@${packageJson.version}`, ); expect(packageJson.openclaw?.compat?.pluginApi).toBe(">=2026.5.22"); expect(packageJson.peerDependencies?.openclaw).toBe(">=2026.5.22");