From eced5a13631e6a3e4df6d3ff719f95e6f8b5c86f Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Sat, 16 May 2026 16:11:34 +0200 Subject: [PATCH 01/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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"); From 97207e430a99133f3b00f6068d51ee433515b146 Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Tue, 2 Jun 2026 01:01:38 +0200 Subject: [PATCH 44/56] Update beeper runtime and client operation types --- packages/openclaw/openclaw.plugin.json | 131 +---- .../openclaw/scripts/sync-manifest-schema.mjs | 7 +- .../src/beeper-channel-runtime.test.ts | 39 +- packages/openclaw/src/beeper-setup.test.ts | 70 +++ packages/openclaw/src/beeper-setup.ts | 44 +- packages/openclaw/src/beeper-stream.test.ts | 373 ++++--------- packages/openclaw/src/beeper-stream.ts | 488 +++--------------- packages/openclaw/src/cli.test.ts | 43 +- packages/openclaw/src/cli.ts | 30 +- packages/openclaw/src/integration.test.ts | 65 ++- .../openclaw/src/openclaw-extension.test.ts | 32 +- packages/openclaw/src/openclaw-extension.ts | 4 +- .../openclaw/src/openclaw-runtime.test.ts | 69 ++- packages/openclaw/src/openclaw-runtime.ts | 55 +- packages/openclaw/src/setup.test.ts | 128 +++-- packages/openclaw/src/setup.ts | 99 ++-- packages/pickle/native/go.mod | 1 + packages/pickle/native/go.sum | 2 + .../native/internal/core/appservice_test.go | 117 +++++ .../native/internal/core/beeper_ai_run.go | 314 ++++++++++- packages/pickle/native/internal/core/core.go | 8 + .../pickle/native/internal/core/messages.go | 38 +- .../pickle/native/internal/core/operations.go | 8 + packages/pickle/src/client-types.ts | 8 + packages/pickle/src/client.test.ts | 22 + packages/pickle/src/client.ts | 6 + .../src/generated-runtime-operations.ts | 22 + .../pickle/src/generated-runtime-types.ts | 18 + packages/pickle/src/index.ts | 3 + packages/pickle/src/runtime-types.ts | 2 + 30 files changed, 1220 insertions(+), 1026 deletions(-) diff --git a/packages/openclaw/openclaw.plugin.json b/packages/openclaw/openclaw.plugin.json index 7bf98d3..6ee4feb 100644 --- a/packages/openclaw/openclaw.plugin.json +++ b/packages/openclaw/openclaw.plugin.json @@ -32,139 +32,10 @@ "PICKLE_OPENCLAW_MATRIX_USER_ID" ] }, - "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 - } - }, "configSchema": { "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." - } - } + "properties": {} }, "channelConfigs": { "beeper": { diff --git a/packages/openclaw/scripts/sync-manifest-schema.mjs b/packages/openclaw/scripts/sync-manifest-schema.mjs index e44ed23..d18a4a8 100644 --- a/packages/openclaw/scripts/sync-manifest-schema.mjs +++ b/packages/openclaw/scripts/sync-manifest-schema.mjs @@ -9,7 +9,12 @@ 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.configSchema = { + type: "object", + additionalProperties: false, + properties: {}, +}; +delete manifest.uiHints; manifest.channelConfigs ??= {}; manifest.channelConfigs.beeper ??= {}; manifest.channelConfigs.beeper.schema = schema; diff --git a/packages/openclaw/src/beeper-channel-runtime.test.ts b/packages/openclaw/src/beeper-channel-runtime.test.ts index 7c95dba..9c10263 100644 --- a/packages/openclaw/src/beeper-channel-runtime.test.ts +++ b/packages/openclaw/src/beeper-channel-runtime.test.ts @@ -34,6 +34,33 @@ function createStreamingClient() { return { ...createClient(), beeper: { + aiRunStreams: { + appendEvent: vi.fn(), + error: vi.fn(), + finish: vi.fn(), + start: vi.fn(async ({ agentId, agentName, runId }: { agentId?: string; agentName?: string; runId: string }) => ({ + body: "...", + descriptor: { type: "com.beeper.llm", user_id: "@codex:example" }, + eventId: "$stream", + events: [ + { runId, threadId: runId, type: "RUN_STARTED" }, + { messageId: `msg-${runId}`, role: "assistant", type: "TEXT_MESSAGE_START" }, + ], + finalAIMessage: {}, + initialAIMessage: {}, + messageId: `msg-${runId}`, + metadata: { + agent: { displayName: agentName, id: agentId }, + runId, + status: { state: "streaming" }, + threadId: runId, + }, + raw: {}, + roomId: "!room", + runId, + threadId: runId, + })), + }, aiRuns: { begin: vi.fn(async ({ agentId, agentName, runId }: { agentId?: string; agentName?: string; runId: string }) => ({ body: "...", @@ -252,17 +279,15 @@ describe("BeeperChannelRuntime", () => { }); await stream.start(); - expect(client.beeper.aiRuns.begin).toHaveBeenCalledWith(expect.objectContaining({ + expect(client.beeper.aiRunStreams.start).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", - }, + expect(client.beeper.aiRunStreams.start).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + agent_id: "codex", + agent_name: "Codex", }), userId: "@codex:example", })); diff --git a/packages/openclaw/src/beeper-setup.test.ts b/packages/openclaw/src/beeper-setup.test.ts index 97d5daf..f91dbaa 100644 --- a/packages/openclaw/src/beeper-setup.test.ts +++ b/packages/openclaw/src/beeper-setup.test.ts @@ -51,6 +51,35 @@ describe("OpenClaw Beeper setup", () => { }); }); + it("infers Beeper Matrix account identity from an access token", async () => { + const seen: Array<{ url: string; authorization?: string }> = []; + const result = await loginToBeeperForOpenClaw({ + accessToken: "mx-token", + env: "staging", + fetch: async (url, init) => { + seen.push({ + url: String(url), + authorization: new Headers(init?.headers).get("authorization") ?? undefined, + }); + return new Response(JSON.stringify({ + device_id: "DEV", + user_id: "@batuhan:beeper-staging.com", + }), { status: 200 }); + }, + }); + + expect(seen).toEqual([{ + authorization: "Bearer mx-token", + url: "https://matrix.beeper-staging.com/_matrix/client/v3/account/whoami", + }]); + expect(result.config).toEqual({ + accessToken: "mx-token", + homeserver: "https://matrix.beeper-staging.com", + matrixDeviceId: "DEV", + matrixUserId: "@batuhan:beeper-staging.com", + }); + }); + it("registers the OpenClaw Beeper appservice with self-hosted defaults", async () => { const seen: unknown[] = []; const result = await createOpenClawBeeperAppService({ @@ -165,4 +194,45 @@ describe("OpenClaw Beeper setup", () => { matrixUserId: "@batuhan:beeper-staging.com", }); }); + + it("combines Beeper access token introspection and appservice registration config", async () => { + const result = await setupOpenClawBeeperBridge({ + accessToken: "mx-token", + env: "staging", + openClawDeviceId: "OPENCLAW-DEVICE", + fetch: async () => new Response(JSON.stringify({ + device_id: "DEV", + user_id: "@batuhan:beeper-staging.com", + }), { status: 200 }), + createAppServiceInit: async (options) => { + expect(options).toMatchObject({ + baseDomain: "beeper-staging.com", + bridge: "sh-openclaw-openclaw-device", + token: "mx-token", + }); + return { + homeserver: "https://matrix.beeper-staging.com/_hungryserv/batuhan", + registration: { + asToken: "as", + hsToken: "hs", + id: "appservice-uuid", + namespaces: { aliases: [], rooms: [], users: [] }, + senderLocalpart: "sh-openclawbot", + url: "http://127.0.0.1:29391", + }, + }; + }, + }); + + expect(result.config).toEqual({ + accessToken: "mx-token", + appserviceId: "appservice-uuid", + asToken: "as", + bridgeId: "sh-openclaw-openclaw-device", + homeserver: "https://matrix.beeper-staging.com/_hungryserv/batuhan", + hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@batuhan:beeper-staging.com", + }); + }); }); diff --git a/packages/openclaw/src/beeper-setup.ts b/packages/openclaw/src/beeper-setup.ts index 66cfa62..92da467 100644 --- a/packages/openclaw/src/beeper-setup.ts +++ b/packages/openclaw/src/beeper-setup.ts @@ -1,4 +1,4 @@ -import type { MatrixAppserviceInitOptions } from "@beeper/pickle"; +import { getMatrixWhoami, 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"; @@ -16,7 +16,8 @@ export interface BeeperSetupAccount { } export interface BeeperLoginForOpenClawOptions { - email: string; + accessToken?: string; + email?: string; env?: BeeperEnvironment; fetch?: typeof fetch; getLoginCode?: () => Promise | string; @@ -62,16 +63,11 @@ export interface CreateOpenClawBeeperAppServiceResult { } export interface SetupOpenClawBeeperBridgeOptions extends BeeperLoginForOpenClawOptions { - bridge?: string; - bridgeManagerToken?: string; - bridgeType?: string; createAppServiceInit?: CreateOpenClawBeeperAppServiceOptions["createAppServiceInit"]; getOnly?: boolean; - homeserverDomain?: string; openClawDeviceId?: string; push?: boolean; selfHosted?: boolean; - username?: string; } export interface SetupOpenClawBeeperBridgeResult { @@ -81,6 +77,32 @@ export interface SetupOpenClawBeeperBridgeResult { } export async function loginToBeeperForOpenClaw(options: BeeperLoginForOpenClawOptions): Promise { + if (options.accessToken) { + const fetchImpl = options.fetch ?? fetch; + const homeserver = beeperMatrixHomeserver(options.env); + const whoami = await getMatrixWhoami(fetchImpl, { + accessToken: options.accessToken, + homeserver, + deviceId: "", + userId: "", + }); + const account: BeeperSetupAccount = { + accessToken: options.accessToken, + deviceId: whoami.deviceId, + homeserver, + userId: whoami.userId, + }; + return { + account, + config: { + accessToken: account.accessToken, + homeserver: account.homeserver, + matrixDeviceId: account.deviceId, + matrixUserId: account.userId, + }, + }; + } + if (!options.email) throw new Error("Beeper setup requires email login or an access token"); const login = options.login ?? createBeeperLogin; const openClawDeviceId = options.openClawDeviceId ?? await resolveOpenClawDeviceId(); const bridgeId = openClawBeeperBridgeId(openClawDeviceId); @@ -153,15 +175,11 @@ export async function setupOpenClawBeeperBridge( }; 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; 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.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, @@ -179,3 +197,7 @@ export function beeperBaseDomain(env: BeeperEnvironment | undefined): string | u if (env === "local") return "beeper.localtest.me"; return "beeper-staging.com"; } + +export function beeperMatrixHomeserver(env: BeeperEnvironment | undefined): string { + return `https://matrix.${beeperBaseDomain(env) ?? "beeper.com"}`; +} diff --git a/packages/openclaw/src/beeper-stream.test.ts b/packages/openclaw/src/beeper-stream.test.ts index 8acd56c..b88fe83 100644 --- a/packages/openclaw/src/beeper-stream.test.ts +++ b/packages/openclaw/src/beeper-stream.test.ts @@ -3,9 +3,11 @@ import { describe, expect, it, vi } from "vitest"; 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(); + it("starts one ai-bridge backed stream and appends canonical AG-UI events", async () => { + const { appendEvent, client, finish, start } = createClient(); const publisher = new BeeperTurnStreamCoordinator({ + agentId: "codex", + agentName: "Codex", client, initialMessageMetadata: { agent_id: "codex" }, roomId: "!room:example.com", @@ -13,98 +15,66 @@ describe("OpenClaw Beeper native stream publisher", () => { userId: "@sh-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(); + await publisher.publish({ messageId: "provider-msg", role: "assistant", type: "TEXT_MESSAGE_START" }); + await publisher.publish({ delta: "hello", messageId: "provider-msg", type: "TEXT_MESSAGE_CONTENT" }); + const result = await publisher.finalize(); - expect(startMessage).toHaveBeenCalledWith({ - content: { - body: "...", - "com.beeper.ai": { - id: "turn_1", - metadata: { agent_id: "codex", message_id: "turn_1", turn_id: "turn_1" }, - parts: [], - role: "assistant", - }, - "com.beeper.ai.metadata": expect.objectContaining({ - data: { agent_id: "codex" }, - model: "openclaw/plugin", - 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.deltas", - }, - msgtype: "m.text", - }, + expect(start).toHaveBeenCalledTimes(1); + expect(start).toHaveBeenCalledWith({ + agentId: "codex", + agentName: "Codex", + data: { agent_id: "codex" }, + model: "openclaw/plugin", roomId: "!room:example.com", + runId: "turn_1", streamType: "com.beeper.llm", + threadId: "turn_1", userId: "@sh-openclaw_agent_codex:example.com", }); - expect(publishPart.mock.calls.map(([options]) => options.part.type)).toEqual([ - "RUN_STARTED", - "TEXT_MESSAGE_START", - "TEXT_MESSAGE_START", - "TEXT_MESSAGE_CONTENT", - "RUN_FINISHED", + expect(appendEvent.mock.calls.map(([options]) => options.event)).toEqual([ + { messageId: "msg-turn_1", role: "assistant", type: "TEXT_MESSAGE_START" }, + { delta: "hello", messageId: "msg-turn_1", type: "TEXT_MESSAGE_CONTENT" }, ]); - expect(finalizeMessage).toHaveBeenCalledWith(expect.objectContaining({ - body: "hello", - content: expect.objectContaining({ - "com.beeper.ai": expect.objectContaining({ - parts: [{ content: "hello", state: "done", 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": { - device_id: "DEVICE", - type: "com.beeper.llm", - user_id: "@bot:example.com", - }, - body: "hello", - msgtype: "m.text", - }), + expect(finish).toHaveBeenCalledWith(expect.objectContaining({ + finishReason: "stop", + runId: "turn_1", + terminal: expect.objectContaining({ type: "RUN_FINISHED" }), + })); + expect(result).toEqual({ eventId: "$target", + raw: { logicalEventId: "$target", raw: {}, replacementEventId: "$edit" }, roomId: "!room:example.com", - })); + }); }); - it("always finalizes with a replacement edit that suppresses the streamed event", async () => { - const { client, finalizeMessage } = createClient(); + it("does not promote provider message ids into additional Matrix stream messages", async () => { + const { appendEvent, client, finish, start } = createClient(); const publisher = new BeeperTurnStreamCoordinator({ client, roomId: "!room:example.com", - turnId: "turn_replace", - userId: "@bot:example.com", + turnId: "turn_multi", }); - await publisher.publish({ delta: "replace me", messageId: "turn_replace", type: "TEXT_MESSAGE_CONTENT" }); - const result = await publisher.finalize({ - terminalPart: { finishReason: "stop", runId: "turn_replace", threadId: "turn_replace", type: "RUN_FINISHED" }, - }); + 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(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", - })); + expect(start).toHaveBeenCalledTimes(1); + expect(appendEvent.mock.calls.map(([options]) => [options.event.type, options.event.messageId, options.event.delta])).toEqual([ + ["TEXT_MESSAGE_START", "msg-turn_multi", undefined], + ["TEXT_MESSAGE_CONTENT", "msg-turn_multi", "first"], + ["TEXT_MESSAGE_START", "msg-turn_multi", undefined], + ["TEXT_MESSAGE_CONTENT", "msg-turn_multi", "second"], + ]); + expect(finish).toHaveBeenCalledTimes(1); }); - it("finalizes run errors with a readable fallback body", async () => { - const { client, finalizeMessage } = createClient(); + it("finalizes run errors through the native stream", async () => { + const { client, error } = createClient(); const publisher = new BeeperTurnStreamCoordinator({ client, roomId: "!room:example.com", @@ -120,234 +90,83 @@ describe("OpenClaw Beeper native stream publisher", () => { }, }); - 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 BeeperTurnStreamCoordinator({ - 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 BeeperTurnStreamCoordinator({ - client, - roomId: "!room:example.com", - turnId: "turn_rich", + expect(error).toHaveBeenCalledWith({ + message: "Tool exploded", + runId: "turn_error", + terminal: expect.objectContaining({ message: "Tool exploded", type: "RUN_ERROR" }), + type: "error", }); - - 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({ 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", - type: "tool-call", - }), - 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(); + it("starts with subscribers, thread root, and ghost sender when provided", async () => { + const { client, start } = createClient(); const publisher = new BeeperTurnStreamCoordinator({ client, roomId: "!room:example.com", - turnId: "turn_multi", + subscribers: [{ deviceId: "DEVICE", userId: "@alice:example.com" }], + threadRoot: "$root", + turnId: "turn_subscribed", + userId: "@agent:example.com", }); - 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(); + await publisher.start(); - 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"], - ]); + expect(start).toHaveBeenCalledWith(expect.objectContaining({ + subscribers: [{ deviceId: "DEVICE", userId: "@alice:example.com" }], + threadRootEventId: "$root", + userId: "@agent:example.com", + })); }); }); function createClient() { - const runEvents = new Map[]>(); - const snapshot = (runId: string, events: Record[] = [], body = "...") => ({ - body, + const result = (runId: string, events: Record[] = []) => ({ + body: "...", + descriptor: { device_id: "DEVICE", type: "com.beeper.llm", user_id: "@bot:example.com" }, + eventId: "$target", events, finalAIMessage: {}, - initialAIMessage: { - 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, + initialAIMessage: {}, + messageId: `msg-${runId}`, + metadata: {}, + raw: {}, + replacementEventId: "$edit", + roomId: "!room:example.com", runId, threadId: runId, }); - const begin = vi.fn(async (options: { runId?: string }) => { - const runId = options.runId ?? "run"; - const events = [ + const start = vi.fn(async ({ runId }: { runId: string }) => + result(runId, [ { 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); - 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", - raw: {}, - replacementEventId: "$edit", - roomId: "!room:example.com", - })); + { messageId: `msg-${runId}`, role: "assistant", type: "TEXT_MESSAGE_START" }, + ])); + const appendEvent = vi.fn(async ({ event, runId }: { event: Record; runId: string }) => + result(runId, [event])); + const finish = vi.fn(async ({ finishReason, runId }: { finishReason?: string; runId: string }) => + result(runId, [{ finishReason: finishReason ?? "stop", runId, threadId: runId, type: "RUN_FINISHED" }])); + const error = vi.fn(async ({ message, runId }: { message?: string; runId: string }) => + result(runId, [{ message, runId, type: "RUN_ERROR" }])); const client = { beeper: { - aiRuns: { + aiRunStreams: { appendEvent, - begin, - delete: deleteRun, error, finish, + start, + }, + aiRuns: { + appendEvent: vi.fn(), + begin: vi.fn(), + delete: vi.fn(), + error: vi.fn(), + finish: vi.fn(), }, streams: { - finalizeMessage, - publishPart, - startMessage, + finalizeMessage: vi.fn(), + publishPart: vi.fn(), + startMessage: vi.fn(), }, }, } 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("") || "..."; + return { appendEvent, client, error, finish, start }; } diff --git a/packages/openclaw/src/beeper-stream.ts b/packages/openclaw/src/beeper-stream.ts index 87594d6..bfd7b5e 100644 --- a/packages/openclaw/src/beeper-stream.ts +++ b/packages/openclaw/src/beeper-stream.ts @@ -1,22 +1,10 @@ -import type { MatrixBeeper, MatrixBeeperAIRunSnapshot, SentEvent } from "@beeper/pickle"; -import { - applyFinalMessagePart, - compactFinalContent, - createFinalMessageAccumulator, - finalizeAccumulatedAIMessage, - getFinalMessageText, - type BeeperFinalMessageAccumulator, -} from "@beeper/pickle/streams/beeper-message"; +import type { MatrixBeeper, MatrixBeeperAIRunStreamResult, SentEvent } from "@beeper/pickle"; import { SerialQueue } from "./serial"; import { AGUIEventType, createTurnId, type AGUIEvent } from "./beeper-turn-events"; -type FinishReason = "stop" | "length" | "content_filter" | "tool_calls" | null; +type FinishReason = "stop" | "length" | "content_filter" | "tool_calls"; -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 BeeperTurnStreamCoordinatorClient { beeper: MatrixBeeper; @@ -53,26 +41,19 @@ export interface BeeperStreamFinalizeOptions { terminalPart?: AGUIEvent; } -type BeeperStreamAnchor = { - accumulator: BeeperFinalMessageAccumulator; - descriptor?: Record; - eventId?: string; - id: string; -}; - export class BeeperTurnStreamCoordinator { readonly roomId: string; readonly turnId: string; - #anchors = new Map(); - #anchorOrder: string[] = []; #agentId: string | undefined; #agentName: string | undefined; #client: BeeperTurnStreamCoordinatorClient; - #currentAnchorId: string; + #descriptor: Record | undefined; + #eventId: string | undefined; #finalized = false; #initialMessageMetadata: Record; + #messageId: string | undefined; #queue = new SerialQueue(); - #runBegun = false; + #started = false; #subscribers: BeeperStreamSubscriber[]; #threadRoot: string | undefined; #userId: string | undefined; @@ -84,29 +65,30 @@ 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.#anchor(this.turnId); } get targetEventId(): string | undefined { - return this.#anchor(this.turnId).eventId; + return this.#eventId; } async start(): Promise { return this.#queue.run(async () => { - const anchor = await this.#startAnchor(this.turnId); - return { descriptor: anchor.descriptor, eventId: anchor.eventId, turnId: this.turnId }; + const result = await this.#ensureStarted(); + return { descriptor: result.descriptor, eventId: result.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 anchor = await this.#startAnchor(this.#anchorIdForPart(part)); - await this.#publishPart(anchor, part); + await this.#ensureStarted(); + await this.#client.beeper.aiRunStreams.appendEvent({ + event: this.#canonicalizePart(part), + runId: this.turnId, + }); }); } @@ -114,8 +96,11 @@ 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 anchor = await this.#startAnchor(this.#anchorIdForPart(part)); - await this.#publishPart(anchor, part); + await this.#ensureStarted(); + await this.#client.beeper.aiRunStreams.appendEvent({ + event: this.#canonicalizePart(part), + runId: this.turnId, + }); } }); } @@ -123,279 +108,109 @@ export class BeeperTurnStreamCoordinator { 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); + await this.#ensureStarted(); const terminalPart = options.terminalPart ?? { - finishReason, + finishReason: normalizeFinishReason(options.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({ + const finishReason = normalizeFinishReason(stringValue((terminalPart as Record).finishReason) ?? options.finishReason); + const result = terminalPart.type === AGUIEventType.RUN_ERROR + ? await this.#client.beeper.aiRunStreams.error({ message: terminalFallbackText(terminalPart), runId: this.turnId, + terminal: terminalPart as Record, type: stringValue((terminalPart as Record).terminalType) === "abort" ? "abort" : "error", }) - : await this.#finishRun({ + : await this.#client.beeper.aiRunStreams.finish({ finishReason, runId: this.turnId, + terminal: terminalPart as Record, }); - 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.#rememberStreamResult(result); this.#finalized = true; - const replacement = replacements[0]; - if (!replacement) throw new Error("Beeper stream did not create a final replacement"); return { - eventId: replacement.eventId, - roomId: replacement.roomId, - raw: replacement.raw, + eventId: result.eventId, + roomId: result.roomId, + raw: { + logicalEventId: result.eventId, + raw: result.raw, + replacementEventId: result.replacementEventId, + }, }; }); } - #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; - } - 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 } : {}), - ...(this.#agentName ? { agentName: this.#agentName } : {}), - 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; + async #ensureStarted(): Promise { + if (this.#started && this.#eventId) { + return { + descriptor: this.#descriptor ?? {}, + eventId: this.#eventId, + turnId: this.turnId, + }; } - 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) ?? {}), + this.#started = true; + const result = await this.#client.beeper.aiRunStreams.start({ + ...(this.#agentId ? { agentId: this.#agentId } : {}), + ...(this.#agentName ? { agentName: this.#agentName } : {}), data: this.#initialMessageMetadata, - }; - const initialAIMessage = { - 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) ?? {}), - }; - 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(), - msgtype: "m.text", - }, + model: "openclaw/plugin", roomId: this.roomId, + runId: this.turnId, streamType: BEEPER_AI_STREAM_TYPE, ...(this.#subscribers.length > 0 ? { subscribers: this.#subscribers } : {}), ...(this.#threadRoot ? { threadRootEventId: this.#threadRoot } : {}), + threadId: this.turnId, ...(this.#userId ? { userId: this.#userId } : {}), }); - anchor.descriptor = target.descriptor; - anchor.eventId = target.eventId; - return anchor as BeeperStreamAnchor & { descriptor: Record; eventId: string }; - } - - async #publishPart(anchor: BeeperStreamAnchor & { eventId: string }, part: AGUIEvent): Promise { - const snapshot = await this.#appendRunEvent({ - event: part, - runId: this.turnId, - }); - await this.#publishSnapshotEvents(anchor, snapshot); - } - - async #beginRun(options: { agentId?: string; agentName?: 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 } : {}), - }); - } - - async #errorRun(options: { message?: string; runId: string; type?: "error" | "abort" }): Promise { - return this.#client.beeper.aiRuns.error(options); - } - - 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: anchor.eventId, - part, - roomId: this.roomId, - turnId: this.turnId, - }); - for (const accumulatorPart of aguiEventToFinalMessageParts(this.turnId, part)) { - 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 perMessageProfile = this.#perMessageProfile(); - 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 || "...", - ...(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(), - msgtype: "m.text", - }, - eventId: anchor.eventId, - roomId: this.roomId, - topLevelContent: { - "com.beeper.dont_render_edited": true, - }, - ...(this.#userId ? { userId: this.#userId } : {}), - }); + this.#rememberStreamResult(result); + if (!this.#eventId) throw new Error("Beeper AI run stream did not return an event ID"); return { - eventId: anchor.eventId, - roomId: replacement.roomId, - raw: { - logicalEventId: anchor.eventId, - raw: replacement.raw, - replacementEventId: replacement.replacementEventId, - }, + descriptor: this.#descriptor ?? {}, + eventId: this.#eventId, + turnId: this.turnId, }; } - #runMetadata(state: "streaming" | "complete" | "error", terminalPart?: AGUIEvent): Record { - return stripUndefined({ - agent: stripUndefined({ - displayName: this.#agentName, - id: this.#agentId, - }), - data: this.#initialMessageMetadata, - messageId: this.turnId, - model: "openclaw/plugin", - 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: {}, - }); + #rememberStreamResult(result: MatrixBeeperAIRunStreamResult): void { + this.#descriptor = recordValue(result.descriptor) ?? this.#descriptor; + this.#eventId = result.eventId || this.#eventId; + this.#messageId = result.messageId || this.#messageId || `msg-${this.turnId}`; } - #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 { - type: BEEPER_AI_STREAM_DELTAS_TYPE, - }; + #canonicalizePart(part: AGUIEvent): AGUIEvent { + const event = { ...(part as Record) }; + const messageId = this.#messageId ?? `msg-${this.turnId}`; + if (event.type === AGUIEventType.RUN_STARTED || event.type === AGUIEventType.RUN_FINISHED) { + event.runId = this.turnId; + event.threadId = this.turnId; } - return stripUndefined({ - type: BEEPER_AI_STREAM_TYPE, - user_id: this.#userId, - }); + if (event.type === AGUIEventType.RUN_ERROR && !stringValue(event.message)) { + event.message = terminalFallbackText(part); + } + if (usesCanonicalMessageId(event.type)) { + event.messageId = messageId; + } + if (event.type === AGUIEventType.TOOL_CALL_START) { + event.parentMessageId = messageId; + } + return stripUndefined(event) as AGUIEvent; } } +function usesCanonicalMessageId(type: unknown): boolean { + return type === AGUIEventType.TEXT_MESSAGE_START || + type === AGUIEventType.TEXT_MESSAGE_CONTENT || + type === AGUIEventType.TEXT_MESSAGE_END || + type === AGUIEventType.REASONING_START || + type === AGUIEventType.REASONING_MESSAGE_START || + type === AGUIEventType.REASONING_MESSAGE_CONTENT || + type === AGUIEventType.REASONING_MESSAGE_END || + type === AGUIEventType.REASONING_END || + type === AGUIEventType.TOOL_CALL_RESULT; +} + function terminalFallbackText(event: AGUIEvent | undefined): string { if (!event) return ""; if (event.type === AGUIEventType.RUN_ERROR) { @@ -404,104 +219,6 @@ 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: - 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 [{ - approval: stripUndefined({ - actions: Array.isArray(value.approvalActions) ? value.approvalActions : undefined, - id: approvalId, - }), - 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; } @@ -511,44 +228,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 ""; - 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"; } -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/cli.test.ts b/packages/openclaw/src/cli.test.ts index 52a27fa..40fe77a 100644 --- a/packages/openclaw/src/cli.test.ts +++ b/packages/openclaw/src/cli.test.ts @@ -65,12 +65,9 @@ describe("pickle-openclaw CLI", () => { "you@example.com", "--env", "staging", - "--bridge-manager-token", - "bridge-manager-token", ], io, { setupBridge })).resolves.toBe(0); expect(setupBridge).toHaveBeenCalledWith(expect.objectContaining({ - bridgeManagerToken: "bridge-manager-token", email: "you@example.com", env: "staging", getLoginCode: expect.any(Function), @@ -84,7 +81,6 @@ describe("pickle-openclaw CLI", () => { appserviceId: "sh-openclaw-device", asToken: "as-token", beeperEnv: "staging", - bridgeManagerToken: "bridge-manager-token", homeserver: "https://matrix.beeper.com", hsToken: "hs-token", matrixDeviceId: "DEVICE", @@ -150,6 +146,45 @@ describe("pickle-openclaw CLI", () => { expect(io.stderrText).toContain("Enter Beeper login code:"); }); + it("can register from an existing Beeper access token without prompting for OTP", async () => { + const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-token-")); + const setupBridge = successfulSetupBridge(); + const io = captureIO(); + + await expect(runCli([ + "login", + "--config", + join(dir, "config.json"), + "--access-token", + "mx-token", + "--env", + "staging", + ], io, { setupBridge })).resolves.toBe(0); + + expect(setupBridge).toHaveBeenCalledWith(expect.objectContaining({ + accessToken: "mx-token", + env: "staging", + push: false, + selfHosted: true, + })); + expect(setupBridge.mock.calls[0]?.[0]).not.toHaveProperty("getLoginCode"); + expect(io.stderrText).not.toContain("Enter Beeper login code:"); + }); + + it("rejects ambiguous login credentials", async () => { + const io = captureIO(); + + await expect(runCli([ + "login", + "--email", + "you@example.com", + "--access-token", + "mx-token", + ], io)).resolves.toBe(1); + + expect(io.stderrText).toContain("Choose either --email or --access-token"); + }); + it("prints the saved Beeper bridge identity", async () => { const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-whoami-")); const configPath = join(dir, "config.json"); diff --git a/packages/openclaw/src/cli.ts b/packages/openclaw/src/cli.ts index bc1acba..61ce8c9 100644 --- a/packages/openclaw/src/cli.ts +++ b/packages/openclaw/src/cli.ts @@ -24,23 +24,19 @@ export async function runCli(argv = process.argv.slice(2), io: CliIO = process, } if (command === "login") { const options = parseOptions(args); - const email = requiredStringOption(options, "email"); + const email = stringOption(options, "email"); + const accessToken = stringOption(options, "access-token"); + if (!email && !accessToken) throw new Error("Missing required option --email or --access-token"); + if (email && accessToken) throw new Error("Choose either --email or --access-token, not both"); const setupOptions: Parameters[0] = { - email, push: booleanOption(options, "push"), selfHosted: !booleanOption(options, "not-self-hosted"), }; - const bridgeManagerToken = stringOption(options, "bridge-manager-token"); - const bridgeType = stringOption(options, "bridge-type"); + if (email !== undefined) setupOptions.email = email; + if (accessToken !== undefined) setupOptions.accessToken = accessToken; const env = beeperEnvOption(options); - const homeserverDomain = stringOption(options, "homeserver-domain"); - const username = stringOption(options, "username"); - if (bridgeManagerToken !== undefined) setupOptions.bridgeManagerToken = bridgeManagerToken; - if (bridgeType !== undefined) setupOptions.bridgeType = bridgeType; if (env !== undefined) setupOptions.env = env; - setupOptions.getLoginCode = () => promptForLoginCode(io); - if (homeserverDomain !== undefined) setupOptions.homeserverDomain = homeserverDomain; - if (username !== undefined) setupOptions.username = username; + if (email !== undefined) setupOptions.getLoginCode = () => promptForLoginCode(io); const result = await (deps.setupBridge ?? setupOpenClawBeeperBridge)(setupOptions); const config = createDefaultConfig({ ...configOverridesFromOptions(options), @@ -78,7 +74,7 @@ function helpText(): string { " --config ", " --data-dir ", " --email
", - " --bridge-manager-token ", + " --access-token ", " --env ", "", ].join("\n"); @@ -93,12 +89,8 @@ function configOverridesFromOptions(options: Map): Par function beeperRuntimeOverridesFromOptions(options: Map): Partial { const overrides: Partial = {}; - const bridgeManagerToken = stringOption(options, "bridge-manager-token"); const env = beeperEnvOption(options); - const homeserverDomain = stringOption(options, "homeserver-domain"); - if (bridgeManagerToken !== undefined) overrides.bridgeManagerToken = bridgeManagerToken; if (env !== undefined) overrides.beeperEnv = env; - if (homeserverDomain !== undefined) overrides.homeserverDomain = homeserverDomain; return overrides; } @@ -150,12 +142,6 @@ 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; } diff --git a/packages/openclaw/src/integration.test.ts b/packages/openclaw/src/integration.test.ts index f38046e..ab20353 100644 --- a/packages/openclaw/src/integration.test.ts +++ b/packages/openclaw/src/integration.test.ts @@ -494,6 +494,69 @@ function createFakeMatrixClient(): MatrixClient & { subscription: MatrixSubscrip roomId: "!created:example", })), }; + const beeperAIRunStreams = { + appendEvent: vi.fn(async ({ event, runId }: { event: Record; runId: string }) => ({ + body: "...", + descriptor: { type: "com.beeper.llm" }, + eventId: "$stream-root", + events: [event], + finalAIMessage: {}, + initialAIMessage: {}, + messageId: `msg-${runId}`, + metadata: {}, + raw: {}, + roomId: "!created:example", + runId, + threadId: runId, + })), + error: vi.fn(async ({ message, runId }: { message?: string; runId: string }) => ({ + body: message ?? "failed", + descriptor: { type: "com.beeper.llm" }, + eventId: "$stream-root", + events: [{ message, runId, type: "RUN_ERROR" }], + finalAIMessage: {}, + initialAIMessage: {}, + messageId: `msg-${runId}`, + metadata: {}, + raw: {}, + replacementEventId: "$stream-final", + roomId: "!created:example", + runId, + threadId: runId, + })), + finish: vi.fn(async ({ finishReason, runId }: { finishReason?: string; runId: string }) => ({ + body: "...", + descriptor: { type: "com.beeper.llm" }, + eventId: "$stream-root", + events: [{ finishReason: finishReason ?? "stop", runId, type: "RUN_FINISHED" }], + finalAIMessage: {}, + initialAIMessage: {}, + messageId: `msg-${runId}`, + metadata: {}, + raw: {}, + replacementEventId: "$stream-final", + roomId: "!created:example", + runId, + threadId: runId, + })), + start: vi.fn(async ({ runId }: { runId: string }) => ({ + body: "...", + descriptor: { type: "com.beeper.llm" }, + eventId: "$stream-root", + events: [ + { runId, threadId: runId, type: "RUN_STARTED" }, + { messageId: `msg-${runId}`, role: "assistant", type: "TEXT_MESSAGE_START" }, + ], + finalAIMessage: {}, + initialAIMessage: {}, + messageId: `msg-${runId}`, + metadata: {}, + raw: {}, + roomId: "!created:example", + runId, + threadId: runId, + })), + }; return { accountData: {} as MatrixClient["accountData"], appservice: { @@ -506,7 +569,7 @@ function createFakeMatrixClient(): MatrixClient & { subscription: MatrixSubscrip 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"], + beeper: { aiRunStreams: beeperAIRunStreams, streams: beeperStreams } as unknown as MatrixClient["beeper"], boot: vi.fn(async () => ({ deviceId: "DEVICE", userId: "@sh-openclawbot:example" })), close: vi.fn(async () => {}), crypto: {} as MatrixClient["crypto"], diff --git a/packages/openclaw/src/openclaw-extension.test.ts b/packages/openclaw/src/openclaw-extension.test.ts index cc9b792..0440006 100644 --- a/packages/openclaw/src/openclaw-extension.test.ts +++ b/packages/openclaw/src/openclaw-extension.test.ts @@ -117,34 +117,12 @@ describe("OpenClaw plugin package metadata", () => { 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({ - accessToken: { sensitive: true }, - asToken: { sensitive: true }, - bridgeManagerToken: { sensitive: true }, - hsToken: { sensitive: true }, + expect(manifest.uiHints).toBeUndefined(); + expect(manifest.configSchema).toEqual({ + type: "object", + additionalProperties: false, + properties: {}, }); - expect(Object.keys(manifest.configSchema?.properties ?? {}).sort()).toEqual([ - "accessToken", - "allowedRoomIds", - "allowedUserIds", - "approvalBehavior", - "appserviceId", - "asToken", - "backfillLimit", - "beeperEnv", - "bridgeId", - "bridgeManagerToken", - "contactVisibility", - "dataDir", - "enabled", - "homeserver", - "homeserverDomain", - "hsToken", - "importSources", - "matrixDeviceId", - "matrixUserId", - ]); - expect(manifest.configSchema).toEqual(schema); expect(manifest.channelConfigs?.beeper?.schema).toEqual(schema); expect(manifest.configSchema?.properties).not.toHaveProperty("streamFinalization"); expect(manifest.channelConfigs?.beeper).toMatchObject({ diff --git a/packages/openclaw/src/openclaw-extension.ts b/packages/openclaw/src/openclaw-extension.ts index 7ef9444..bf7c63f 100644 --- a/packages/openclaw/src/openclaw-extension.ts +++ b/packages/openclaw/src/openclaw-extension.ts @@ -1,6 +1,6 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/channel-core"; import type { OpenClawPluginApi, PluginRuntime } from "openclaw/plugin-sdk/channel-core"; -import { BeeperChannelConfigSchemaForSdk, beeperChannelPlugin, setBeeperOpenClawPluginRuntime } from "./setup"; +import { BeeperPluginConfigSchemaForSdk, beeperChannelPlugin, setBeeperOpenClawPluginRuntime } from "./setup"; type OpenClawBeeperPluginEntry = { channelPlugin: typeof beeperChannelPlugin; @@ -17,7 +17,7 @@ export const openClawBeeperPlugin: OpenClawBeeperPluginEntry = defineChannelPlug name: "Beeper", description: "Bridge OpenClaw sessions and agents into Beeper.", plugin: beeperChannelPlugin, - configSchema: BeeperChannelConfigSchemaForSdk, + configSchema: BeeperPluginConfigSchemaForSdk, setRuntime: setOpenClawRuntime, }); diff --git a/packages/openclaw/src/openclaw-runtime.test.ts b/packages/openclaw/src/openclaw-runtime.test.ts index 14a00ac..f9dbf90 100644 --- a/packages/openclaw/src/openclaw-runtime.test.ts +++ b/packages/openclaw/src/openclaw-runtime.test.ts @@ -124,6 +124,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { roomId: "!room:example", })), }; + const aiRunStreams = createTestBeeperAIRunStreams(); const request = vi.fn(async () => { throw new Error("generic request should not be used"); }); @@ -153,7 +154,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { }; setBeeperChannelRuntimeForHost(hostRuntime, new BeeperChannelRuntime({ client: { - beeper: { aiRuns: createTestBeeperAIRuns(), streams: beeperStreams }, + beeper: { aiRuns: createTestBeeperAIRuns(), aiRunStreams, streams: beeperStreams }, media: { upload: vi.fn() }, } as never, userId: "@sh-openclaw-bot:example", @@ -174,10 +175,9 @@ describe("OpenClawPluginRuntimeAdapter", () => { await runDone; 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", + expect(aiRunStreams.start).toHaveBeenCalledTimes(1); + expect(aiRunStreams.finish).toHaveBeenCalledWith(expect.objectContaining({ + runId: sent.runId, })); setBeeperChannelRuntimeForHost(hostRuntime, undefined); }); @@ -270,6 +270,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { roomId: "!room:example", })), }; + const aiRunStreams = createTestBeeperAIRunStreams(); const runAssembled = vi.fn(async (params: Record) => { const replyOptions = params.replyOptions as Record void | Promise>; await replyOptions.onReasoningStream?.({ text: "checking" }); @@ -311,7 +312,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { }; setBeeperChannelRuntimeForHost(hostRuntime, new BeeperChannelRuntime({ client: { - beeper: { aiRuns: createTestBeeperAIRuns(), streams: beeperStreams }, + beeper: { aiRuns: createTestBeeperAIRuns(), aiRunStreams, streams: beeperStreams }, media: { upload: vi.fn() }, } as never, userId: "@sh-openclaw-bot:example", @@ -366,10 +367,8 @@ describe("OpenClawPluginRuntimeAdapter", () => { }), 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", + expect(aiRunStreams.start).toHaveBeenCalledTimes(1); + expect(aiRunStreams.appendEvent.mock.calls.map(([options]) => options.event.type)).toEqual(expect.arrayContaining([ "REASONING_MESSAGE_CONTENT", "TOOL_CALL_START", "TOOL_CALL_ARGS", @@ -378,17 +377,16 @@ describe("OpenClawPluginRuntimeAdapter", () => { "CUSTOM", "TEXT_MESSAGE_CONTENT", ])); - const toolOutput = beeperStreams.publishPart.mock.calls - .map(([options]) => options.part) + const toolOutput = aiRunStreams.appendEvent.mock.calls + .map(([options]) => options.event) .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", + expect(aiRunStreams.finish).toHaveBeenCalledWith(expect.objectContaining({ + runId: observedRunId, })); setBeeperChannelRuntimeForHost(hostRuntime, undefined); }); @@ -408,6 +406,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { roomId: "!room:example", })), }; + const aiRunStreams = createTestBeeperAIRunStreams(); const runAssembled = vi.fn(async (params: Record) => { const replyOptions = params.replyOptions as Record void | Promise>; await replyOptions.onPartialReply?.({ text: "hel" }); @@ -442,7 +441,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { }; setBeeperChannelRuntimeForHost(hostRuntime, new BeeperChannelRuntime({ client: { - beeper: { aiRuns: createTestBeeperAIRuns(), streams: beeperStreams }, + beeper: { aiRuns: createTestBeeperAIRuns(), aiRunStreams, streams: beeperStreams }, media: { upload: vi.fn() }, } as never, userId: "@sh-openclaw-bot:example", @@ -461,7 +460,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { }); await done; - const parts = beeperStreams.publishPart.mock.calls.map(([options]) => options.part); + const parts = aiRunStreams.appendEvent.mock.calls.map(([options]) => options.event); expect(parts.filter((part) => part.type === "TEXT_MESSAGE_CONTENT").map((part) => part.delta)).toEqual([ "hel", "lo", @@ -497,6 +496,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { roomId: "!room:example", })), }; + const aiRunStreams = createTestBeeperAIRunStreams(); 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 }; @@ -549,7 +549,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { }; setBeeperChannelRuntimeForHost(hostRuntime, new BeeperChannelRuntime({ client: { - beeper: { aiRuns: createTestBeeperAIRuns(), streams: beeperStreams }, + beeper: { aiRuns: createTestBeeperAIRuns(), aiRunStreams, streams: beeperStreams }, media: { upload: vi.fn() }, } as never, userId: "@sh-openclaw-bot:example", @@ -568,7 +568,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { }); await done; - const parts = beeperStreams.publishPart.mock.calls.map(([options]) => options.part); + const parts = aiRunStreams.appendEvent.mock.calls.map(([options]) => options.event); expect(parts.filter((part) => part.type === "TEXT_MESSAGE_CONTENT").map((part) => part.delta)).toEqual([ "hel", "lo", @@ -694,3 +694,34 @@ function createTestBeeperAIRuns() { ])), }; } + +function createTestBeeperAIRunStreams() { + const result = (runId: string, events: Record[] = []) => ({ + body: "...", + descriptor: { type: "com.beeper.llm" }, + eventId: "$stream-root", + events, + finalAIMessage: {}, + initialAIMessage: {}, + messageId: `msg-${runId}`, + metadata: {}, + raw: {}, + replacementEventId: "$stream-final", + roomId: "!room:example", + runId, + threadId: runId, + }); + return { + appendEvent: vi.fn(async ({ event, runId }: { event: Record; runId: string }) => + result(runId, [event])), + error: vi.fn(async ({ message, runId }: { message?: string; runId: string }) => + result(runId, [{ message, runId, type: "RUN_ERROR" }])), + finish: vi.fn(async ({ finishReason, runId }: { finishReason?: string; runId: string }) => + result(runId, [{ finishReason: finishReason ?? "stop", runId, threadId: runId, type: "RUN_FINISHED" }])), + start: vi.fn(async ({ runId }: { runId: string }) => + result(runId, [ + { runId, threadId: runId, type: "RUN_STARTED" }, + { messageId: `msg-${runId}`, role: "assistant", type: "TEXT_MESSAGE_START" }, + ])), + }; +} diff --git a/packages/openclaw/src/openclaw-runtime.ts b/packages/openclaw/src/openclaw-runtime.ts index 80612ea..e1bdad1 100644 --- a/packages/openclaw/src/openclaw-runtime.ts +++ b/packages/openclaw/src/openclaw-runtime.ts @@ -989,62 +989,63 @@ function forwardAgentRuntimeStreamEvents(params: { normalizedStream: stream, }); if (!matched) return; + const track = (promise: Promise) => params.stream.trackExternal(promise); switch (stream) { case "assistant": - void params.stream.textPayload(data, "partial"); + track(params.stream.textPayload(data, "partial")); break; case "thinking": case "reasoning": - void params.stream.reasoningPayload(data); + track(params.stream.reasoningPayload(data)); break; case "tool": if (stringValue(data.phase) === "start") { - void params.stream.toolStart(data); + track(params.stream.toolStart(data)); } else if (stringValue(data.phase) === "result" || isCompletePhase(stringValue(data.phase))) { - void params.stream.toolResult(data); + track(params.stream.toolResult(data)); } else { - void params.stream.itemEvent({ + track(params.stream.itemEvent({ ...data, kind: "tool", progressText: stringValue(data.partialResult) ?? stringValue(data.output) ?? stringValue(data.result), - }); + })); } break; case "item": - void params.stream.itemEvent(data); + track(params.stream.itemEvent(data)); break; case "plan": - void params.stream.planUpdate(data); + track(params.stream.planUpdate(data)); break; case "approval": - void params.stream.approvalEvent(data); + track(params.stream.approvalEvent(data)); break; case "command_output": case "command-output": - void params.stream.commandOutput(data); + track(params.stream.commandOutput(data)); break; case "patch": - void params.stream.patchSummary(data); + track(params.stream.patchSummary(data)); break; case "state": case "snapshot": - void params.stream.stateSnapshot(data); + track(params.stream.stateSnapshot(data)); break; case "source": case "sources": - void params.stream.customData("source", data); + track(params.stream.customData("source", data)); break; case "file": case "files": case "document": case "documents": - void params.stream.customData("file", data); + track(params.stream.customData("file", data)); break; case "data": - void params.stream.customData("data", data); + track(params.stream.customData("data", data)); break; case "raw": - void params.stream.raw(stream, data); + track(params.stream.raw(stream, data)); break; default: break; @@ -1119,6 +1120,7 @@ function createBeeperReplyStreamEmitter(base: { let lastVisibleText = ""; let lastReasoningText = ""; let startPromise: Promise | undefined; + const externalTasks = new Set>(); const toolInputs = new Map(); const toolNames = new Map(); const startedToolCalls = new Set(); @@ -1176,6 +1178,24 @@ function createBeeperReplyStreamEmitter(base: { }); await publisher.publishMany(list); }; + const trackExternal = (promise: Promise) => { + let tracked: Promise; + tracked = promise.catch((error) => { + channelRuntime.debug("openclaw_beeper_external_stream_event_failed", { + error: errorText(error), + roomId: base.roomId, + runId: base.runId, + }); + }).finally(() => { + externalTasks.delete(tracked); + }); + externalTasks.add(tracked); + }; + const drainExternal = async () => { + while (externalTasks.size > 0) { + await Promise.all([...externalTasks]); + } + }; const textPayload = async (payload: unknown, source: "partial" | "block" | "final" = "partial") => { const text = replyPayloadText(payload); channelRuntime.debug("openclaw_beeper_text_payload_received", { @@ -1228,6 +1248,7 @@ function createBeeperReplyStreamEmitter(base: { }; return { start: ensureStarted, + trackExternal, assistantMessageStart: () => { lastVisibleText = ""; emit("assistant.message.start", {}); @@ -1454,6 +1475,7 @@ function createBeeperReplyStreamEmitter(base: { }, finish: async (payload?: unknown) => { if (payload !== undefined) await textPayload(payload, "final"); + await drainExternal(); if (!hasPublished || finalized) return; const preTerminal = closeReasoningPart(state); if (preTerminal.length > 0) await publisher.publishMany(preTerminal); @@ -1472,6 +1494,7 @@ function createBeeperReplyStreamEmitter(base: { }, fail: async (error: unknown) => { if (finalized) return; + await drainExternal(); finalized = true; channelRuntime.debug("openclaw_beeper_stream_failing", { error: errorText(error), diff --git a/packages/openclaw/src/setup.test.ts b/packages/openclaw/src/setup.test.ts index 4979314..cea5756 100644 --- a/packages/openclaw/src/setup.test.ts +++ b/packages/openclaw/src/setup.test.ts @@ -85,7 +85,7 @@ describe("OpenClaw Beeper setup surface", () => { label: "Beeper", selectionLabel: expect.any(String), })); - expect(beeperChannelPlugin.capabilities.chatTypes).toEqual(["direct", "group", "thread"]); + expect(beeperChannelPlugin.capabilities.chatTypes).toEqual(["direct", "thread"]); expect(beeperChannelPlugin.message).toEqual(expect.objectContaining({ durableFinal: expect.objectContaining({ capabilities: expect.objectContaining({ @@ -329,34 +329,26 @@ describe("OpenClaw Beeper setup surface", () => { }); }); - it("applies dashboard setup input into channels.beeper settings", async () => { + it("applies dashboard setup input into non-login 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"], - appserviceId: "custom-openclaw", approvalBehavior: "native", backfillLimit: "42", beeperEnv: "staging", - bridgeId: "sh-openclaw-custom", - bridgeManagerToken: "hungry", contactVisibility: "agents-and-users", importSources: "dashboard,tui", }, }); expect(getBeeperChannelSettings(cfg)).toEqual({ - accessToken: "mx", allowedRoomIds: ["!one:example", "!two:example"], allowedUserIds: ["@alice:example", "@bob:example"], - appserviceId: "custom-openclaw", approvalBehavior: "native", backfillLimit: 42, beeperEnv: "staging", - bridgeId: "sh-openclaw-custom", - bridgeManagerToken: "hungry", contactVisibility: "agents-and-users", enabled: true, importSources: ["dashboard", "tui"], @@ -372,7 +364,15 @@ describe("OpenClaw Beeper setup surface", () => { input: { email: "alice@example.com", }, - })).toThrow("Beeper email login is asynchronous"); + })).toThrow("Beeper login is asynchronous"); + + expect(() => beeperSetupAdapter.applyAccountConfig({ + accountId: "default", + cfg: {}, + input: { + accessToken: "mx-token", + }, + })).toThrow("Beeper login is asynchronous"); }); it("runs Beeper login and appservice registration from dashboard setup wizard input", async () => { @@ -383,10 +383,6 @@ describe("OpenClaw Beeper setup surface", () => { const promptValues: Record = { "Beeper email": "alice@example.com", "Beeper login code": "123456", - "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({ @@ -396,6 +392,7 @@ describe("OpenClaw Beeper setup surface", () => { multiselect: async () => ["dashboard", "tui"], progress: () => progress, select: async ({ message }) => { + if (message === "Beeper login method") return "email"; if (message === "Beeper environment") return "dev"; if (message === "Beeper contact visibility") return "agents"; if (message === "Approval behavior") return "native"; @@ -413,8 +410,8 @@ describe("OpenClaw Beeper setup surface", () => { setupBridge: async (options) => { expect(options.email).toBe("alice@example.com"); expect(options.env).toBe("dev"); - expect(options.bridgeManagerToken).toBe("hungry"); - expect(options.homeserverDomain).toBe("beeper.local"); + expect(options).not.toHaveProperty("bridgeManagerToken"); + expect(options).not.toHaveProperty("homeserverDomain"); expect(await options.getLoginCode?.()).toBe("123456"); return { account: { @@ -452,28 +449,67 @@ describe("OpenClaw Beeper setup surface", () => { enabled: true, accessToken: "at", asToken: "as", - bridgeManagerToken: "hungry", bridgeId: "sh-openclaw-dev", homeserver: "https://matrix.example", - homeserverDomain: "beeper.local", hsToken: "hs", matrixDeviceId: "DEV", matrixUserId: "@alice:example", }); }); - it("keeps manually entered tokens in setup input", async () => { - const cfg = await beeperSetupAdapter.applyAccountConfig({ - accountId: "default", + it("infers generated bridge settings from access token setup input", async () => { + const { applyBeeperSetupConfig } = await import("./setup"); + const cfg = await applyBeeperSetupConfig({ cfg: {}, input: { accessToken: "at", - asToken: "as", + beeperEnv: "dev", + }, + runtime: { + setupBridge: async (options) => { + expect(options.accessToken).toBe("at"); + expect(options.email).toBeUndefined(); + expect(options.env).toBe("dev"); + return { + account: { + accessToken: "at", + deviceId: "DEV", + homeserver: "https://matrix.example", + userId: "@alice:example", + }, + config: { + accessToken: "at", + appserviceId: "sh-openclaw-dev", + asToken: "as", + bridgeId: "sh-openclaw-dev", + homeserver: "https://matrix.example", + hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + }, + init: { + homeserver: "https://matrix.example", + registration: { + asToken: "as", + id: "sh-openclaw-dev", + hsToken: "hs", + url: "http://127.0.0.1:29391", + }, + } as never, + }; + }, }, }); expect(getBeeperChannelSettings(cfg)).toMatchObject({ accessToken: "at", + appserviceId: "sh-openclaw-dev", asToken: "as", + beeperEnv: "dev", + bridgeId: "sh-openclaw-dev", + homeserver: "https://matrix.example", + hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", }); }); @@ -506,8 +542,8 @@ describe("OpenClaw Beeper setup surface", () => { setupBridge: async (options) => { expect(options.email).toBe("alice@example.com"); expect(options.env).toBe("dev"); - expect(options.bridgeManagerToken).toBeUndefined(); - expect(options.homeserverDomain).toBeUndefined(); + expect(options).not.toHaveProperty("bridgeManagerToken"); + expect(options).not.toHaveProperty("homeserverDomain"); expect(await options.getLoginCode?.()).toBe("123456"); return { account: { @@ -566,6 +602,8 @@ describe("OpenClaw Beeper setup surface", () => { it("reports setup status and validates dashboard input", async () => { expect(validateBeeperSetupInput({ email: "not-email" })).toContain("valid email"); + expect(validateBeeperSetupInput({ accessToken: " " })).toContain("access token"); + expect(validateBeeperSetupInput({ email: "alice@example.com", accessToken: "at" })).toContain("either"); expect(validateBeeperSetupInput({ backfillLimit: "-1" })).toContain("non-negative"); const cfg = applyBeeperChannelSettings({}, { enabled: true, @@ -637,6 +675,7 @@ describe("OpenClaw Beeper setup surface", () => { appservice: { sendMessage: vi.fn(async () => ({ eventId: "$as" })) }, beeper: { aiRuns: createTestBeeperAIRuns(), + aiRunStreams: createTestBeeperAIRunStreams(), streams: { finalizeMessage: vi.fn(async () => ({ replacementEventId: "$replace", roomId: "!room", raw: {} })), publishPart: vi.fn(async () => undefined), @@ -702,14 +741,12 @@ describe("OpenClaw Beeper setup surface", () => { params: { message: "hello from tool" }, sessionKey: "session_1", }); - expect(client.beeper.streams.publishPart).toHaveBeenCalledWith(expect.objectContaining({ - eventId: "$stream", - part: expect.objectContaining({ + expect(client.beeper.aiRunStreams.appendEvent).toHaveBeenCalledWith(expect.objectContaining({ + event: expect.objectContaining({ delta: "hello from tool", type: "TEXT_MESSAGE_CONTENT", }), - roomId: "!room", - turnId: "run_1", + runId: "run_1", })); await beeperChannelPlugin.actions.handleAction({ @@ -806,3 +843,34 @@ function createTestBeeperAIRuns() { snapshot(runId, [{ finishReason: finishReason ?? "stop", runId, type: "RUN_FINISHED" }])), }; } + +function createTestBeeperAIRunStreams() { + const result = (runId: string, events: Record[] = []) => ({ + body: "...", + descriptor: { type: "com.beeper.llm" }, + eventId: "$stream", + events, + finalAIMessage: {}, + initialAIMessage: {}, + messageId: `msg-${runId}`, + metadata: {}, + raw: {}, + replacementEventId: "$replace", + roomId: "!room", + runId, + threadId: runId, + }); + return { + appendEvent: vi.fn(async ({ event, runId }: { event: Record; runId: string }) => + result(runId, [event])), + error: vi.fn(async ({ message, runId }: { message?: string; runId: string }) => + result(runId, [{ message, runId, type: "RUN_ERROR" }])), + finish: vi.fn(async ({ finishReason, runId }: { finishReason?: string; runId: string }) => + result(runId, [{ finishReason: finishReason ?? "stop", runId, type: "RUN_FINISHED" }])), + start: vi.fn(async ({ runId }: { runId: string }) => + result(runId, [ + { runId, threadId: runId, type: "RUN_STARTED" }, + { messageId: `msg-${runId}`, role: "assistant", type: "TEXT_MESSAGE_START" }, + ])), + }; +} diff --git a/packages/openclaw/src/setup.ts b/packages/openclaw/src/setup.ts index 96a06c0..44f0f3c 100644 --- a/packages/openclaw/src/setup.ts +++ b/packages/openclaw/src/setup.ts @@ -40,24 +40,18 @@ export interface BeeperSetupInput { accessToken?: string; allowedRoomIds?: string[] | string; allowedUserIds?: string[] | string; - appserviceId?: string; - asToken?: string; approvalBehavior?: string; backfillLimit?: number | string; beeperEnv?: string; - bridgeManagerToken?: string; - bridgeId?: string; code?: string; contactVisibility?: string; dataDir?: string; email?: string; getOnly?: boolean | string; - homeserverDomain?: string; importSources?: string[] | string; postState?: boolean | string; push?: boolean | string; selfHosted?: boolean | string; - username?: string; } export interface BeeperSetupRuntime { @@ -571,8 +565,8 @@ export const beeperSetupAdapter = { input: BeeperSetupInput; runtime?: BeeperSetupRuntime; }): OpenClawSetupConfig => { - if (input.email) { - throw new Error("Beeper email login is asynchronous; use the Beeper setup wizard or pickle-openclaw login."); + if (input.email || input.accessToken) { + throw new Error("Beeper login is asynchronous; use the Beeper setup wizard or pickle-openclaw login."); } return applyBeeperChannelSettings(cfg, normalizeBeeperSetupInput(input)); }, @@ -610,16 +604,35 @@ export const beeperSetupWizard = { ...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 loginMethod = await ctx.prompter.select<"email" | "token">({ + message: "Beeper login method", + initialValue: "email", + options: [ + { value: "email", label: "Email code" }, + { value: "token", label: "Access token" }, + ], }); + let email: string | undefined; + let code: string | undefined; + let accessToken: string | undefined; + if (loginMethod === "email") { + email = await ctx.prompter.text({ + message: "Beeper email", + placeholder: "name@example.com", + validate: (value) => validateBeeperSetupInput({ email: value }) ?? undefined, + }); + code = await ctx.prompter.text({ + message: "Beeper login code", + sensitive: true, + validate: (value) => (value.trim() ? undefined : "Beeper login code is required."), + }); + } else { + accessToken = await ctx.prompter.text({ + message: "Beeper access token", + sensitive: true, + validate: (value) => validateBeeperSetupInput({ accessToken: value }) ?? undefined, + }); + } const beeperEnv = await ctx.prompter.select({ message: "Beeper environment", initialValue: current.beeperEnv ?? "production", @@ -630,17 +643,6 @@ export const beeperSetupWizard = { { value: "local", label: "Local" }, ], }); - 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"], @@ -677,16 +679,15 @@ export const beeperSetupWizard = { progress?.update("Logging in and registering appservice"); try { const input: BeeperSetupInput = { - backfillLimit, - code, - email, + ...(accessToken ? { accessToken } : {}), importSources, + backfillLimit, + ...(code ? { code } : {}), + ...(email ? { email } : {}), }; if (approvalBehavior !== undefined) input.approvalBehavior = approvalBehavior; 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(); const setupParams: Parameters[0] = { cfg: ctx.cfg, input, @@ -785,7 +786,7 @@ export async function applyBeeperSetupConfig(params: { runtime?: BeeperSetupRuntime; }): Promise { const baseSettings = normalizeBeeperSetupInput(params.input); - if (!params.input.email) return applyBeeperChannelSettings(params.cfg, baseSettings); + if (!params.input.email && !params.input.accessToken) return applyBeeperChannelSettings(params.cfg, baseSettings); const setupBridge = params.runtime?.setupBridge ?? (await loadBeeperSetupBridge()); const bridgeOptions = setupOptionsFromInput(params.input); const result = await setupBridge(bridgeOptions); @@ -799,7 +800,6 @@ export async function applyBeeperSetupConfig(params: { if (result.config.asToken) setupSettings.asToken = result.config.asToken; if (result.config.bridgeId) setupSettings.bridgeId = result.config.bridgeId; 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; @@ -815,8 +815,16 @@ export const BeeperChannelConfigSchemaForSdk = { uiHints: BeeperChannelUiHints, } as const; +export const BeeperPluginConfigSchemaForSdk = { + schema: { + type: "object", + additionalProperties: false, + properties: {}, + }, +} as const; + const BeeperChannelCapabilities: ChannelCapabilities = { - chatTypes: ["direct", "group", "thread"], + chatTypes: ["direct", "thread"], blockStreaming: true, media: true, nativeCommands: true, @@ -1243,7 +1251,9 @@ export function defaultBeeperChannelSettings(): BeeperChannelSettings { } export function validateBeeperSetupInput(input: BeeperSetupInput): string | null { + if (input.email && input.accessToken) return "Choose either Beeper email login or access token, not both."; if (input.email !== undefined && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/u.test(input.email)) return "Beeper email must be a valid email address."; + if (input.accessToken !== undefined && !input.accessToken.trim()) return "Beeper access token is required."; 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.approvalBehavior !== undefined && normalizeApprovalBehavior(input.approvalBehavior) === undefined) return "Approval behavior must be native or disabled."; @@ -1262,39 +1272,32 @@ export function normalizeBeeperSetupInput(input: BeeperSetupInput): Partial input.code!; if (getOnly !== undefined) options.getOnly = getOnly; - if (input.homeserverDomain) options.homeserverDomain = input.homeserverDomain; if (push !== undefined) options.push = push; if (selfHosted !== undefined) options.selfHosted = selfHosted; - if (input.username) options.username = input.username; return options; } diff --git a/packages/pickle/native/go.mod b/packages/pickle/native/go.mod index b1d00b6..13eb9d8 100644 --- a/packages/pickle/native/go.mod +++ b/packages/pickle/native/go.mod @@ -20,6 +20,7 @@ require ( github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect + github.com/yuin/goldmark v1.8.2 // 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 diff --git a/packages/pickle/native/go.sum b/packages/pickle/native/go.sum index 1822137..1a024cc 100644 --- a/packages/pickle/native/go.sum +++ b/packages/pickle/native/go.sum @@ -36,6 +36,8 @@ 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= +github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.mau.fi/util v0.9.9 h1:ujDeXCo07HBor5oQLyO1tHklupmqVmPgasc53d7q/NE= go.mau.fi/util v0.9.9/go.mod h1:pqt4Vcrt+5gcH/CgrHZg11qSx+b34o6mknGzOEA6waY= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= diff --git a/packages/pickle/native/internal/core/appservice_test.go b/packages/pickle/native/internal/core/appservice_test.go index e39dae7..329a299 100644 --- a/packages/pickle/native/internal/core/appservice_test.go +++ b/packages/pickle/native/internal/core/appservice_test.go @@ -418,6 +418,108 @@ func TestBeeperStreamPublishWithoutSubscribersSendsRoomCarrierEvent(t *testing.T } } +func TestBeeperAIRunStreamUsesCanonicalAIBridgeRun(t *testing.T) { + requests := make(chan recordedRequest, 16) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + requests <- recordedRequest{body: string(body), path: r.URL.Path} + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"event_id":"$event"}`)) + })) + t.Cleanup(server.Close) + + core := New(nil) + cli, err := mautrix.NewClient(server.URL, id.UserID("@testbot:example"), "device-token") + if err != nil { + t.Fatal(err) + } + cli.DeviceID = id.DeviceID("PICKLE") + cli.StateStore = mautrix.NewMemoryStateStore() + core.client = cli + core.beeperStream, err = beeperstream.New(cli) + if err != nil { + t.Fatal(err) + } + + startReq, err := json.Marshal(MatrixStartBeeperAIRunStreamOptions{ + MatrixBeginBeeperAIRunOptions: MatrixBeginBeeperAIRunOptions{ + AgentID: "codex", + AgentName: "Codex", + Data: OutboundEvent{"session_key": "session-1"}, + Model: "openclaw/plugin", + RunID: "run-1", + ThreadID: "thread-1", + }, + RoomID: "!room:example", + }) + if err != nil { + t.Fatal(err) + } + rawStart, err := core.handleStartBeeperAIRunStream(context.Background(), startReq) + if err != nil { + t.Fatal(err) + } + var startResult MatrixBeeperAIRunStreamResult + if err = json.Unmarshal(rawStart, &startResult); err != nil { + t.Fatal(err) + } + if startResult.EventID != "$event" || startResult.MessageID != "msg-run-1" { + t.Fatalf("unexpected start result: %#v", startResult) + } + + appendReq, err := json.Marshal(MatrixAppendBeeperAIRunEventOptions{ + Event: OutboundEvent{ + "delta": "hello", + "messageId": "provider-msg", + "type": "TEXT_MESSAGE_CONTENT", + }, + RunID: "run-1", + }) + if err != nil { + t.Fatal(err) + } + if _, err = core.handleAppendBeeperAIRunStreamEvent(context.Background(), appendReq); err != nil { + t.Fatal(err) + } + carrierBody := waitForRecordedRequest(t, requests, func(req recordedRequest) bool { + return strings.Contains(req.body, `"delta":"hello"`) + }) + if !strings.Contains(carrierBody, `"messageId":"msg-run-1"`) { + t.Fatalf("expected canonical envelope message id, got %s", carrierBody) + } + if !strings.Contains(carrierBody, `"messageId":"provider-msg"`) { + t.Fatalf("expected original part payload to remain intact, got %s", carrierBody) + } + + finishReq, err := json.Marshal(MatrixFinishBeeperAIRunOptions{ + FinishReason: "stop", + RunID: "run-1", + }) + if err != nil { + t.Fatal(err) + } + rawFinish, err := core.handleFinishBeeperAIRunStream(context.Background(), finishReq) + if err != nil { + t.Fatal(err) + } + var finishResult MatrixBeeperAIRunStreamResult + if err = json.Unmarshal(rawFinish, &finishResult); err != nil { + t.Fatal(err) + } + if finishResult.ReplacementEventID == "" || finishResult.Body != "hello" { + t.Fatalf("unexpected finish result: %#v", finishResult) + } + if _, ok := core.beeperAIRuns["run-1"]; ok { + t.Fatal("expected finalized stream run to be deleted") + } + replacementBody := waitForRecordedRequest(t, requests, func(req recordedRequest) bool { + return strings.Contains(req.body, `"m.new_content"`) + }) + if !strings.Contains(replacementBody, `"com.beeper.ai"`) || !strings.Contains(replacementBody, `"hello"`) { + t.Fatalf("expected final replacement to use ai-bridge final content, got %s", replacementBody) + } +} + func TestRegisterBeeperStreamInjectsDirectSubscribers(t *testing.T) { requests := make(chan recordedRequest, 4) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -503,6 +605,21 @@ type recordedRequest struct { path string } +func waitForRecordedRequest(t *testing.T, requests <-chan recordedRequest, matches func(recordedRequest) bool) string { + t.Helper() + deadline := time.After(time.Second) + for { + select { + case req := <-requests: + if matches(req) { + return req.body + } + case <-deadline: + t.Fatal("timed out waiting for recorded request") + } + } +} + func mustJSON(t *testing.T, value any) json.RawMessage { t.Helper() raw, err := json.Marshal(value) diff --git a/packages/pickle/native/internal/core/beeper_ai_run.go b/packages/pickle/native/internal/core/beeper_ai_run.go index 283599c..3c821fc 100644 --- a/packages/pickle/native/internal/core/beeper_ai_run.go +++ b/packages/pickle/native/internal/core/beeper_ai_run.go @@ -1,26 +1,36 @@ package core import ( + "context" "encoding/json" "errors" + "fmt" "strings" "time" agui "github.com/beeper/ai-bridge/pkg/ag-ui" aistream "github.com/beeper/ai-bridge/pkg/ai-stream" + aimatrix "github.com/beeper/ai-bridge/pkg/ai-stream/matrix" + "maunium.net/go/mautrix/id" ) type beeperAIRunState struct { - run *aistream.Run - writer *aistream.Writer + published int + run *aistream.Run + streamDescriptor any + streamEventID id.EventID + streamRoomID id.RoomID + writer *aistream.Writer } type MatrixBeginBeeperAIRunOptions struct { - AgentID string `json:"agentId,omitempty"` - AgentName string `json:"agentName,omitempty"` - Model string `json:"model,omitempty"` - RunID string `json:"runId,omitempty"` - ThreadID string `json:"threadId,omitempty"` + AgentID string `json:"agentId,omitempty"` + AgentName string `json:"agentName,omitempty"` + Data OutboundEvent `json:"data,omitempty" tstype:"{ [key: string]: unknown }"` + MessageID string `json:"messageId,omitempty"` + Model string `json:"model,omitempty"` + RunID string `json:"runId,omitempty"` + ThreadID string `json:"threadId,omitempty"` } type MatrixAppendBeeperAIRunEventOptions struct { @@ -29,15 +39,17 @@ type MatrixAppendBeeperAIRunEventOptions struct { } type MatrixFinishBeeperAIRunOptions struct { - FinishReason string `json:"finishReason,omitempty"` - RunID string `json:"runId"` - Usage agui.Usage `json:"usage,omitempty"` + FinishReason string `json:"finishReason,omitempty"` + RunID string `json:"runId"` + Terminal OutboundEvent `json:"terminal,omitempty" tstype:"{ [key: string]: unknown }"` + Usage agui.Usage `json:"usage,omitempty"` } type MatrixErrorBeeperAIRunOptions struct { - Message string `json:"message,omitempty"` - RunID string `json:"runId"` - Type string `json:"type,omitempty" tstype:"\"error\" | \"abort\""` + Message string `json:"message,omitempty"` + RunID string `json:"runId"` + Terminal OutboundEvent `json:"terminal,omitempty" tstype:"{ [key: string]: unknown }"` + Type string `json:"type,omitempty" tstype:"\"error\" | \"abort\""` } type MatrixDeleteBeeperAIRunOptions struct { @@ -55,15 +67,31 @@ type MatrixBeeperAIRunSnapshot struct { ThreadID string `json:"threadId"` } +type MatrixStartBeeperAIRunStreamOptions struct { + MatrixBeginBeeperAIRunOptions `json:",inline" tstype:",extends"` + RoomID string `json:"roomId"` + StreamType string `json:"streamType,omitempty"` + Subscribers []MatrixBeeperStreamSubscriber `json:"subscribers,omitempty"` + ThreadRootEventID string `json:"threadRootEventId,omitempty"` + UserID string `json:"userId,omitempty"` +} + +type MatrixBeeperAIRunStreamResult struct { + MatrixBeeperAIRunSnapshot `json:",inline" tstype:",extends"` + Descriptor any `json:"descriptor,omitempty" tstype:"{ [key: string]: unknown }"` + EventID string `json:"eventId"` + Raw any `json:"raw,omitempty"` + ReplacementEventID string `json:"replacementEventId,omitempty"` + RoomID string `json:"roomId"` +} + func (c *Core) handleBeginBeeperAIRun(payload []byte) ([]byte, error) { var req MatrixBeginBeeperAIRunOptions if err := json.Unmarshal(payload, &req); err != nil { return nil, err } - 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} + state := c.beginBeeperAIRun(req) + run := state.run return c.marshalBeeperAIRunSnapshot(run, outboundEventsFromAGUI(run.Events)) } @@ -104,6 +132,9 @@ func (c *Core) handleFinishBeeperAIRun(payload []byte) ([]byte, error) { } else { state.writer.Finish(req.FinishReason) } + if req.Terminal != nil { + state.run.Status.Terminal = req.Terminal + } return c.marshalBeeperAIRunSnapshot(state.run, outboundEventsFromAGUI(state.run.Events[before:])) } @@ -126,6 +157,12 @@ func (c *Core) handleErrorBeeperAIRun(payload []byte) ([]byte, error) { } else { state.writer.Error(message) } + if req.Terminal != nil { + state.run.Status.Terminal = req.Terminal + } + if state.run.Preview.Text == "" { + state.run.Preview = aistream.PreviewFromText(message, aistream.PreviewBudgetBytes) + } return c.marshalBeeperAIRunSnapshot(state.run, outboundEventsFromAGUI(state.run.Events[before:])) } @@ -138,6 +175,230 @@ func (c *Core) handleDeleteBeeperAIRun(payload []byte) ([]byte, error) { return c.empty() } +func (c *Core) handleStartBeeperAIRunStream(ctx context.Context, payload []byte) ([]byte, error) { + if c.beeperStream == nil { + return nil, errors.New("beeper stream helper is not initialized") + } + var req MatrixStartBeeperAIRunStreamOptions + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + if req.RoomID == "" { + return nil, errors.New("missing beeper AI run stream room ID") + } + if req.StreamType == "" { + req.StreamType = "com.beeper.llm" + } + state := c.beginBeeperAIRun(req.MatrixBeginBeeperAIRunOptions) + run := state.run + descriptor, err := c.beeperStream.NewDescriptor(ctx, id.RoomID(req.RoomID), req.StreamType) + if err != nil { + return nil, err + } + content, extra := aimatrix.AnchorContent(*run) + contentMap := messageContentMap(content, OutboundEvent(extra)) + if len(req.Subscribers) > 0 { + contentMap["com.beeper.stream"] = descriptor + } else { + contentMap["com.beeper.stream"] = map[string]any{ + "type": aistream.BeeperAIStreamDeltas, + } + } + resp, err := c.sendBeeperStreamMessageEvent(ctx, req.RoomID, req.ThreadRootEventID, req.UserID, contentMap) + if err != nil { + delete(c.beeperAIRuns, run.RunID) + return nil, err + } + eventID := id.EventID(resp.EventID.String()) + if err = c.beeperStream.Register(ctx, id.RoomID(req.RoomID), eventID, descriptor); err != nil { + delete(c.beeperAIRuns, run.RunID) + return nil, err + } + c.beeperStreamMessages[eventID] = &beeperStreamMessage{ + descriptor: descriptor.Clone(), + direct: len(req.Subscribers) > 0, + nextSeq: 1, + roomID: id.RoomID(req.RoomID), + userID: req.UserID, + } + state.streamEventID = eventID + state.streamRoomID = id.RoomID(req.RoomID) + state.streamDescriptor = descriptor.Clone() + c.addBeeperStreamSubscribers(ctx, id.RoomID(req.RoomID), eventID, req.Subscribers) + events := outboundEventsFromAGUI(run.Events) + if err = c.publishBeeperAIRunStreamPending(ctx, state); err != nil { + delete(c.beeperAIRuns, run.RunID) + delete(c.beeperStreamMessages, eventID) + c.beeperStream.Unregister(id.RoomID(req.RoomID), eventID) + c.beeperStream.Unsubscribe(id.RoomID(req.RoomID), eventID) + return nil, err + } + return c.marshalBeeperAIRunStreamResult(state, events, "", nil) +} + +func (c *Core) handleAppendBeeperAIRunStreamEvent(ctx context.Context, payload []byte) ([]byte, error) { + 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) + events := outboundEventsFromAGUI(state.run.Events[before:]) + if err := c.publishBeeperAIRunStreamPending(ctx, state); err != nil { + return nil, err + } + return c.marshalBeeperAIRunStreamResult(state, events, "", nil) +} + +func (c *Core) handleFinishBeeperAIRunStream(ctx context.Context, payload []byte) ([]byte, error) { + 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) + } + if req.Terminal != nil { + state.run.Status.Terminal = req.Terminal + } + events := outboundEventsFromAGUI(state.run.Events[before:]) + if err := c.publishBeeperAIRunStreamPending(ctx, state); err != nil { + return nil, err + } + return c.finalizeBeeperAIRunStream(ctx, state, events) +} + +func (c *Core) handleErrorBeeperAIRunStream(ctx context.Context, payload []byte) ([]byte, error) { + var req MatrixErrorBeeperAIRunOptions + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + state, err := c.requireBeeperAIRun(req.RunID) + if err != nil { + return nil, err + } + before := len(state.run.Events) + message := strings.TrimSpace(req.Message) + if message == "" { + message = "run failed" + } + if req.Type == "abort" { + state.writer.Abort(message) + } else { + state.writer.Error(message) + } + if req.Terminal != nil { + state.run.Status.Terminal = req.Terminal + } + if state.run.Preview.Text == "" { + state.run.Preview = aistream.PreviewFromText(message, aistream.PreviewBudgetBytes) + } + events := outboundEventsFromAGUI(state.run.Events[before:]) + if err := c.publishBeeperAIRunStreamPending(ctx, state); err != nil { + return nil, err + } + return c.finalizeBeeperAIRunStream(ctx, state, events) +} + +func (c *Core) beginBeeperAIRun(req MatrixBeginBeeperAIRunOptions) *beeperAIRunState { + run := aistream.NewRun(req.RunID, req.ThreadID, req.Model, req.AgentID, req.AgentName, time.Now()) + if strings.TrimSpace(req.MessageID) != "" { + run.MessageID = strings.TrimSpace(req.MessageID) + } + if len(req.Data) > 0 { + run.Data = map[string]any(req.Data) + } + writer := aistream.NewWriter(run, time.Now) + writer.Start() + state := &beeperAIRunState{run: run, writer: writer} + c.beeperAIRuns[run.RunID] = state + return state +} + +func (c *Core) publishBeeperAIRunStreamPending(ctx context.Context, state *beeperAIRunState) error { + if state == nil || state.streamEventID == "" { + return errors.New("Beeper AI run is not attached to a stream message") + } + stream := c.beeperStreamMessages[state.streamEventID] + if stream == nil { + return fmt.Errorf("beeper stream message %s is not registered", state.streamEventID) + } + if state.published >= len(state.run.Events) { + return nil + } + streamType := stream.descriptor.Type + if streamType == "" { + streamType = "com.beeper.llm" + } + partial := *state.run + partial.Events = append([]agui.Event(nil), state.run.Events[state.published:]...) + carriers, err := aistream.PackRunFromSeq(partial, state.streamEventID.String(), aistream.CarrierBudgetBytes, stream.nextSeq) + if err != nil { + return 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) + } + if err := c.publishBeeperStreamCarrierContents(ctx, state.streamEventID, stream, contents); err != nil { + return err + } + stream.nextSeq = aistream.NextSeq(carriers) + state.published = len(state.run.Events) + return nil +} + +func (c *Core) finalizeBeeperAIRunStream(ctx context.Context, state *beeperAIRunState, events []OutboundEvent) ([]byte, error) { + if state == nil || state.streamEventID == "" { + return nil, errors.New("Beeper AI run is not attached to a stream message") + } + stream := c.beeperStreamMessages[state.streamEventID] + if stream == nil { + return nil, fmt.Errorf("beeper stream message %s is not registered", state.streamEventID) + } + content, extra := aimatrix.FinalContent(*state.run) + contentMap := messageContentMap(content, OutboundEvent(extra)) + result, err := c.finalizeBeeperStreamMessage(ctx, MatrixFinalizeBeeperStreamMessageOptions{ + Body: content.Body, + Content: contentMap, + EventID: state.streamEventID.String(), + RoomID: stream.roomID.String(), + TopLevelContent: OutboundEvent{"com.beeper.dont_render_edited": true}, + UserID: stream.userID, + }) + if err != nil { + return nil, err + } + delete(c.beeperAIRuns, state.run.RunID) + return c.marshalBeeperAIRunStreamResult(state, events, result.ReplacementEventID, result.Raw) +} + func (c *Core) requireBeeperAIRun(runID string) (*beeperAIRunState, error) { if strings.TrimSpace(runID) == "" { return nil, errors.New("missing Beeper AI run ID") @@ -150,6 +411,21 @@ func (c *Core) requireBeeperAIRun(runID string) (*beeperAIRunState, error) { } func (c *Core) marshalBeeperAIRunSnapshot(run *aistream.Run, events []OutboundEvent) ([]byte, error) { + return json.Marshal(c.beeperAIRunSnapshot(run, events)) +} + +func (c *Core) marshalBeeperAIRunStreamResult(state *beeperAIRunState, events []OutboundEvent, replacementEventID string, raw any) ([]byte, error) { + return json.Marshal(MatrixBeeperAIRunStreamResult{ + MatrixBeeperAIRunSnapshot: c.beeperAIRunSnapshot(state.run, events), + Descriptor: state.streamDescriptor, + EventID: state.streamEventID.String(), + Raw: raw, + ReplacementEventID: replacementEventID, + RoomID: state.streamRoomID.String(), + }) +} + +func (c *Core) beeperAIRunSnapshot(run *aistream.Run, events []OutboundEvent) MatrixBeeperAIRunSnapshot { body := run.Preview.Text if body == "" { body = run.Text() @@ -157,7 +433,7 @@ func (c *Core) marshalBeeperAIRunSnapshot(run *aistream.Run, events []OutboundEv if body == "" { body = "..." } - return json.Marshal(MatrixBeeperAIRunSnapshot{ + return MatrixBeeperAIRunSnapshot{ Body: body, Events: events, InitialAIMessage: run.InitialUIMessage(), @@ -166,7 +442,7 @@ func (c *Core) marshalBeeperAIRunSnapshot(run *aistream.Run, events []OutboundEv MessageID: run.MessageID, RunID: run.RunID, ThreadID: run.ThreadID, - }) + } } func outboundEventsFromAGUI(events []agui.Event) []OutboundEvent { diff --git a/packages/pickle/native/internal/core/core.go b/packages/pickle/native/internal/core/core.go index 126e7d4..0a9e6c2 100644 --- a/packages/pickle/native/internal/core/core.go +++ b/packages/pickle/native/internal/core/core.go @@ -150,6 +150,14 @@ func (c *Core) Handle(ctx context.Context, op string, payload []byte) ([]byte, e return c.handleErrorBeeperAIRun(payload) case opDeleteBeeperAIRun: return c.handleDeleteBeeperAIRun(payload) + case opStartBeeperAIRunStream: + return c.handleStartBeeperAIRunStream(ctx, payload) + case opAppendBeeperAIRunStreamEvent: + return c.handleAppendBeeperAIRunStreamEvent(ctx, payload) + case opFinishBeeperAIRunStream: + return c.handleFinishBeeperAIRunStream(ctx, payload) + case opErrorBeeperAIRunStream: + return c.handleErrorBeeperAIRunStream(ctx, 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 c39a366..4c4839d 100644 --- a/packages/pickle/native/internal/core/messages.go +++ b/packages/pickle/native/internal/core/messages.go @@ -240,25 +240,35 @@ func (c *Core) handlePublishBeeperStreamMessagePart(ctx context.Context, payload if err != nil { return nil, err } + if err := c.publishBeeperStreamCarrierContents(ctx, id.EventID(req.EventID), stream, contents); err != nil { + return nil, err + } + stream.nextSeq = nextSeq + return c.empty() +} + +func (c *Core) publishBeeperStreamCarrierContents(ctx context.Context, eventID id.EventID, stream *beeperStreamMessage, contents []map[string]any) error { + if stream == nil { + return fmt.Errorf("beeper stream message %s is not registered", eventID) + } for _, content := range contents { if stream.direct { - if err := c.beeperStream.Publish(ctx, stream.roomID, id.EventID(req.EventID), content); err != nil { - return nil, err + if err := c.beeperStream.Publish(ctx, stream.roomID, eventID, content); err != nil { + return err } } else { content["body"] = "" content["msgtype"] = "m.text" content["m.relates_to"] = map[string]any{ "rel_type": "m.reference", - "event_id": req.EventID, + "event_id": eventID.String(), } if _, err := c.sendBeeperStreamMessageEvent(ctx, stream.roomID.String(), "", stream.userID, content); err != nil { - return nil, err + return err } } } - stream.nextSeq = nextSeq - return c.empty() + return nil } func (c *Core) beeperStreamCarrierContent(streamType string, req MatrixPublishBeeperStreamMessagePartOptions, seq int) (map[string]any, error) { @@ -308,8 +318,16 @@ func (c *Core) handleFinalizeBeeperStreamMessage(ctx context.Context, payload [] if err := json.Unmarshal(payload, &req); err != nil { return nil, err } + result, err := c.finalizeBeeperStreamMessage(ctx, req) + if err != nil { + return nil, err + } + return json.Marshal(result) +} + +func (c *Core) finalizeBeeperStreamMessage(ctx context.Context, req MatrixFinalizeBeeperStreamMessageOptions) (MatrixFinalizeBeeperStreamMessageResult, error) { if req.RoomID == "" || req.EventID == "" { - return nil, errors.New("missing beeper stream finalize fields") + return MatrixFinalizeBeeperStreamMessageResult{}, errors.New("missing beeper stream finalize fields") } content := copyOutboundEvent(req.Content) if content["body"] == nil { @@ -323,7 +341,7 @@ func (c *Core) handleFinalizeBeeperStreamMessage(ctx context.Context, payload [] topLevel["com.beeper.stream"] = nil replacement, err := c.sendBeeperStreamReplacementEvent(ctx, req.RoomID, req.EventID, req.UserID, content, topLevel) if err != nil { - return nil, err + return MatrixFinalizeBeeperStreamMessageResult{}, err } targetEventID := id.EventID(req.EventID) if c.beeperStream != nil { @@ -331,12 +349,12 @@ func (c *Core) handleFinalizeBeeperStreamMessage(ctx context.Context, payload [] c.beeperStream.Unsubscribe(id.RoomID(req.RoomID), targetEventID) } delete(c.beeperStreamMessages, targetEventID) - return json.Marshal(MatrixFinalizeBeeperStreamMessageResult{ + return MatrixFinalizeBeeperStreamMessageResult{ EventID: req.EventID, ReplacementEventID: replacement.EventID.String(), RoomID: req.RoomID, Raw: replacement, - }) + }, nil } func (c *Core) sendBeeperStreamReplacementEvent(ctx context.Context, roomID, eventID, userID string, newContent, topLevel OutboundEvent) (*mautrix.RespSendEvent, error) { diff --git a/packages/pickle/native/internal/core/operations.go b/packages/pickle/native/internal/core/operations.go index 230f61c..cda74d7 100644 --- a/packages/pickle/native/internal/core/operations.go +++ b/packages/pickle/native/internal/core/operations.go @@ -79,6 +79,14 @@ const ( opErrorBeeperAIRun = "error_beeper_ai_run" // ts:operation deleteBeeperAIRun delete_beeper_ai_run MatrixDeleteBeeperAIRunOptions void opDeleteBeeperAIRun = "delete_beeper_ai_run" + // ts:operation startBeeperAIRunStream start_beeper_ai_run_stream MatrixStartBeeperAIRunStreamOptions MatrixBeeperAIRunStreamResult + opStartBeeperAIRunStream = "start_beeper_ai_run_stream" + // ts:operation appendBeeperAIRunStreamEvent append_beeper_ai_run_stream_event MatrixAppendBeeperAIRunEventOptions MatrixBeeperAIRunStreamResult + opAppendBeeperAIRunStreamEvent = "append_beeper_ai_run_stream_event" + // ts:operation finishBeeperAIRunStream finish_beeper_ai_run_stream MatrixFinishBeeperAIRunOptions MatrixBeeperAIRunStreamResult + opFinishBeeperAIRunStream = "finish_beeper_ai_run_stream" + // ts:operation errorBeeperAIRunStream error_beeper_ai_run_stream MatrixErrorBeeperAIRunOptions MatrixBeeperAIRunStreamResult + opErrorBeeperAIRunStream = "error_beeper_ai_run_stream" // ts:operation setTyping set_typing MatrixTypingOptions void opSetTyping = "set_typing" // ts:operation fetchMessage fetch_message MatrixFetchMessageOptions MatrixFetchMessageResult diff --git a/packages/pickle/src/client-types.ts b/packages/pickle/src/client-types.ts index 3978842..2024e4c 100644 --- a/packages/pickle/src/client-types.ts +++ b/packages/pickle/src/client-types.ts @@ -81,12 +81,14 @@ import type { MatrixAppendBeeperAIRunEventOptions, MatrixBeginBeeperAIRunOptions, MatrixBeeperAIRunSnapshot, + MatrixBeeperAIRunStreamResult, MatrixDeleteBeeperAIRunOptions, MatrixErrorBeeperAIRunOptions, MatrixFinalizeBeeperStreamMessageOptions, MatrixFinalizeBeeperStreamMessageResult, MatrixFinishBeeperAIRunOptions, MatrixPublishBeeperStreamMessagePartOptions, + MatrixStartBeeperAIRunStreamOptions, MatrixStartBeeperStreamMessageOptions, MatrixStartBeeperStreamMessageResult, } from "./runtime-types"; @@ -157,6 +159,12 @@ export interface MatrixBeeper { error(options: MatrixErrorBeeperAIRunOptions): Promise; finish(options: MatrixFinishBeeperAIRunOptions): Promise; }; + aiRunStreams: { + appendEvent(options: MatrixAppendBeeperAIRunEventOptions): Promise; + error(options: MatrixErrorBeeperAIRunOptions): Promise; + finish(options: MatrixFinishBeeperAIRunOptions): Promise; + start(options: MatrixStartBeeperAIRunStreamOptions): Promise; + }; ephemeral: { send(options: SendBeeperEphemeralOptions): Promise; }; diff --git a/packages/pickle/src/client.test.ts b/packages/pickle/src/client.test.ts index 16156e2..668d99c 100644 --- a/packages/pickle/src/client.test.ts +++ b/packages/pickle/src/client.test.ts @@ -960,11 +960,15 @@ describe("createMatrixClient", () => { it("maps Beeper AI run helpers to the runtime contract", async () => { const calls = installRuntime({ append_beeper_ai_run_event: { body: "hello", events: [], finalAIMessage: {}, initialAIMessage: {}, messageId: "msg", metadata: {}, runId: "run", threadId: "thread" }, + append_beeper_ai_run_stream_event: { body: "hello", descriptor: {}, eventId: "$stream", events: [], finalAIMessage: {}, initialAIMessage: {}, messageId: "msg", metadata: {}, roomId: "!room", runId: "run", threadId: "thread" }, begin_beeper_ai_run: { body: "", events: [], finalAIMessage: {}, initialAIMessage: {}, messageId: "msg", metadata: {}, runId: "run", threadId: "thread" }, delete_beeper_ai_run: {}, error_beeper_ai_run: { body: "failed", events: [], finalAIMessage: {}, initialAIMessage: {}, messageId: "msg", metadata: {}, runId: "run", threadId: "thread" }, + error_beeper_ai_run_stream: { body: "failed", descriptor: {}, eventId: "$stream", events: [], finalAIMessage: {}, initialAIMessage: {}, messageId: "msg", metadata: {}, replacementEventId: "$replace", roomId: "!room", runId: "run", threadId: "thread" }, finish_beeper_ai_run: { body: "hello", events: [], finalAIMessage: {}, initialAIMessage: {}, messageId: "msg", metadata: {}, runId: "run", threadId: "thread" }, + finish_beeper_ai_run_stream: { body: "hello", descriptor: {}, eventId: "$stream", events: [], finalAIMessage: {}, initialAIMessage: {}, messageId: "msg", metadata: {}, replacementEventId: "$replace", roomId: "!room", runId: "run", threadId: "thread" }, init: { deviceId: "DEVICE", userId: "@bot:example.com" }, + start_beeper_ai_run_stream: { body: "", descriptor: {}, eventId: "$stream", events: [], finalAIMessage: {}, initialAIMessage: {}, messageId: "msg", metadata: {}, roomId: "!room", runId: "run", threadId: "thread" }, }); const client = createMatrixClient({ homeserver: "https://matrix.beeper.com", @@ -980,6 +984,13 @@ describe("createMatrixClient", () => { await client.beeper.aiRuns.finish({ finishReason: "stop", runId: "run" }); await client.beeper.aiRuns.error({ message: "failed", runId: "run", type: "error" }); await client.beeper.aiRuns.delete({ runId: "run" }); + await client.beeper.aiRunStreams.start({ agentName: "OpenClaw", roomId: "!room", runId: "run", threadId: "thread" }); + await client.beeper.aiRunStreams.appendEvent({ + event: { delta: "hello", messageId: "msg", type: "TEXT_MESSAGE_CONTENT" }, + runId: "run", + }); + await client.beeper.aiRunStreams.finish({ finishReason: "stop", runId: "run" }); + await client.beeper.aiRunStreams.error({ message: "failed", runId: "run", type: "error" }); expect(calls.map((call) => call.operation)).toEqual([ "init", @@ -988,6 +999,10 @@ describe("createMatrixClient", () => { "finish_beeper_ai_run", "error_beeper_ai_run", "delete_beeper_ai_run", + "start_beeper_ai_run_stream", + "append_beeper_ai_run_stream_event", + "finish_beeper_ai_run_stream", + "error_beeper_ai_run_stream", ]); expect(calls[1]?.payload).toEqual({ agentName: "OpenClaw", runId: "run", threadId: "thread" }); expect(calls[2]?.payload).toEqual({ @@ -997,6 +1012,13 @@ describe("createMatrixClient", () => { expect(calls[3]?.payload).toEqual({ finishReason: "stop", runId: "run" }); expect(calls[4]?.payload).toEqual({ message: "failed", runId: "run", type: "error" }); expect(calls[5]?.payload).toEqual({ runId: "run" }); + expect(calls[6]?.payload).toEqual({ agentName: "OpenClaw", roomId: "!room", runId: "run", threadId: "thread" }); + expect(calls[7]?.payload).toEqual({ + event: { delta: "hello", messageId: "msg", type: "TEXT_MESSAGE_CONTENT" }, + runId: "run", + }); + expect(calls[8]?.payload).toEqual({ finishReason: "stop", runId: "run" }); + expect(calls[9]?.payload).toEqual({ message: "failed", runId: "run", type: "error" }); }); it("keeps accumulated UI message parts in the Beeper final edit", async () => { diff --git a/packages/pickle/src/client.ts b/packages/pickle/src/client.ts index b8bc272..3879450 100644 --- a/packages/pickle/src/client.ts +++ b/packages/pickle/src/client.ts @@ -93,6 +93,12 @@ class DefaultMatrixClient implements MatrixClient { error: (opts) => this.#withCore((core) => core.errorBeeperAIRun(stripUndefined(opts))), finish: (opts) => this.#withCore((core) => core.finishBeeperAIRun(stripUndefined(opts))), }, + aiRunStreams: { + appendEvent: (opts) => this.#withCore((core) => core.appendBeeperAIRunStreamEvent(stripUndefined(opts))), + error: (opts) => this.#withCore((core) => core.errorBeeperAIRunStream(stripUndefined(opts))), + finish: (opts) => this.#withCore((core) => core.finishBeeperAIRunStream(stripUndefined(opts))), + start: (opts) => this.#withCore((core) => core.startBeeperAIRunStream(stripUndefined(opts))), + }, ephemeral: { send: (opts) => this.#withCore((core) => core.sendEphemeralEvent(stripUndefined({ diff --git a/packages/pickle/src/generated-runtime-operations.ts b/packages/pickle/src/generated-runtime-operations.ts index d7746d8..1ae8c3d 100644 --- a/packages/pickle/src/generated-runtime-operations.ts +++ b/packages/pickle/src/generated-runtime-operations.ts @@ -17,6 +17,7 @@ import type { MatrixAppserviceUserOptions, MatrixBanUserOptions, MatrixBeeperAIRunSnapshot, + MatrixBeeperAIRunStreamResult, MatrixBeginBeeperAIRunOptions, MatrixCoreInitOptions, MatrixCreateRoomOptions, @@ -81,6 +82,7 @@ import type { MatrixSetOwnAvatarURLOptions, MatrixSetOwnDisplayNameOptions, MatrixSetRoomAccountDataOptions, + MatrixStartBeeperAIRunStreamOptions, MatrixStartBeeperStreamMessageOptions, MatrixStartBeeperStreamMessageResult, MatrixSyncOnceOptions, @@ -134,6 +136,10 @@ export interface MatrixCoreOperations { finishBeeperAIRun(options: MatrixFinishBeeperAIRunOptions): Promise; errorBeeperAIRun(options: MatrixErrorBeeperAIRunOptions): Promise; deleteBeeperAIRun(options: MatrixDeleteBeeperAIRunOptions): Promise; + startBeeperAIRunStream(options: MatrixStartBeeperAIRunStreamOptions): Promise; + appendBeeperAIRunStreamEvent(options: MatrixAppendBeeperAIRunEventOptions): Promise; + finishBeeperAIRunStream(options: MatrixFinishBeeperAIRunOptions): Promise; + errorBeeperAIRunStream(options: MatrixErrorBeeperAIRunOptions): Promise; setTyping(options: MatrixTypingOptions): Promise; fetchMessage(options: MatrixFetchMessageOptions): Promise; fetchMessages(options: MatrixFetchMessagesOptions): Promise; @@ -327,6 +333,22 @@ export abstract class MatrixCoreOperationCaller implements MatrixCoreOperations return this.call("delete_beeper_ai_run", options); } + startBeeperAIRunStream(options: MatrixStartBeeperAIRunStreamOptions): Promise { + return this.call("start_beeper_ai_run_stream", options); + } + + appendBeeperAIRunStreamEvent(options: MatrixAppendBeeperAIRunEventOptions): Promise { + return this.call("append_beeper_ai_run_stream_event", options); + } + + finishBeeperAIRunStream(options: MatrixFinishBeeperAIRunOptions): Promise { + return this.call("finish_beeper_ai_run_stream", options); + } + + errorBeeperAIRunStream(options: MatrixErrorBeeperAIRunOptions): Promise { + return this.call("error_beeper_ai_run_stream", options); + } + setTyping(options: MatrixTypingOptions): Promise { return this.call("set_typing", options); } diff --git a/packages/pickle/src/generated-runtime-types.ts b/packages/pickle/src/generated-runtime-types.ts index 0311e52..d642cd5 100644 --- a/packages/pickle/src/generated-runtime-types.ts +++ b/packages/pickle/src/generated-runtime-types.ts @@ -129,6 +129,8 @@ export interface MatrixAppserviceTransactionOptions { export interface MatrixBeginBeeperAIRunOptions { agentId?: string; agentName?: string; + data?: { [key: string]: unknown }; + messageId?: string; model?: string; runId?: string; threadId?: string; @@ -140,11 +142,13 @@ export interface MatrixAppendBeeperAIRunEventOptions { export interface MatrixFinishBeeperAIRunOptions { finishReason?: string; runId: string; + terminal?: { [key: string]: unknown }; usage?: unknown /* agui.Usage */; } export interface MatrixErrorBeeperAIRunOptions { message?: string; runId: string; + terminal?: { [key: string]: unknown }; type?: "error" | "abort"; } export interface MatrixDeleteBeeperAIRunOptions { @@ -160,6 +164,20 @@ export interface MatrixBeeperAIRunSnapshot { runId: string; threadId: string; } +export interface MatrixStartBeeperAIRunStreamOptions extends MatrixBeginBeeperAIRunOptions { + roomId: string; + streamType?: string; + subscribers?: MatrixBeeperStreamSubscriber[]; + threadRootEventId?: string; + userId?: string; +} +export interface MatrixBeeperAIRunStreamResult extends MatrixBeeperAIRunSnapshot { + descriptor?: { [key: string]: unknown }; + eventId: string; + raw?: unknown; + replacementEventId?: string; + roomId: string; +} export interface MatrixCryptoStatus { deviceId?: string; hasRecoveryKey: boolean; diff --git a/packages/pickle/src/index.ts b/packages/pickle/src/index.ts index d75e229..759fb2e 100644 --- a/packages/pickle/src/index.ts +++ b/packages/pickle/src/index.ts @@ -1,5 +1,6 @@ export { copyBytes } from "./bytes"; export { createMatrixClient } from "./client"; +export { getMatrixWhoami } from "./auth"; export { onInvite, onMessage, onRawEvent, onReaction } from "./helpers"; export type { MatrixClient, @@ -38,9 +39,11 @@ export type { MatrixAppendBeeperAIRunEventOptions, MatrixBeginBeeperAIRunOptions, MatrixBeeperAIRunSnapshot, + MatrixBeeperAIRunStreamResult, MatrixDeleteBeeperAIRunOptions, MatrixErrorBeeperAIRunOptions, MatrixFinishBeeperAIRunOptions, + MatrixStartBeeperAIRunStreamOptions, } from "./runtime-types"; export type { ApplySyncResponseOptions, diff --git a/packages/pickle/src/runtime-types.ts b/packages/pickle/src/runtime-types.ts index 9684ce9..3f9d20b 100644 --- a/packages/pickle/src/runtime-types.ts +++ b/packages/pickle/src/runtime-types.ts @@ -30,6 +30,7 @@ export type { MatrixBanUserOptions, MatrixBeginBeeperAIRunOptions, MatrixBeeperAIRunSnapshot, + MatrixBeeperAIRunStreamResult, MatrixCoreInitOptions, MatrixCryptoStatus, MatrixCreateRoomOptions, @@ -105,6 +106,7 @@ export type { MatrixSetOwnDisplayNameOptions, MatrixSetAccountDataOptions, MatrixSetRoomAccountDataOptions, + MatrixStartBeeperAIRunStreamOptions, MatrixStartBeeperStreamMessageOptions, MatrixStartBeeperStreamMessageResult, MatrixSyncOnceOptions, From 7bee27a12235508a4db169aa2b984384d5d02fa9 Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Tue, 2 Jun 2026 02:25:28 +0200 Subject: [PATCH 45/56] sync --- packages/bridge/package.json | 8 + .../src/beeper-stream.test.ts | 100 ++- .../{openclaw => bridge}/src/beeper-stream.ts | 180 ++++-- packages/bridge/src/bridge.test.ts | 31 + packages/bridge/src/bridge.ts | 21 +- packages/bridge/src/index.ts | 9 +- packages/bridge/src/media-message.test.ts | 44 ++ packages/bridge/src/media-message.ts | 66 ++ packages/bridge/src/types.ts | 3 +- packages/bridge/tsdown.config.ts | 2 +- packages/openclaw/README.md | 13 +- packages/openclaw/openclaw.plugin.json | 10 - packages/openclaw/package.json | 12 +- packages/openclaw/src/appservice.test.ts | 66 +- packages/openclaw/src/appservice.ts | 61 +- packages/openclaw/src/backfill.test.ts | 4 +- packages/openclaw/src/backfill.ts | 7 +- .../src/beeper-channel-config.schema.json | 4 - .../openclaw/src/beeper-channel-runtime.ts | 60 +- packages/openclaw/src/beeper-setup.test.ts | 84 +-- packages/openclaw/src/beeper-setup.ts | 55 +- packages/openclaw/src/beeper-turn-events.ts | 27 +- packages/openclaw/src/cli.test.ts | 25 +- packages/openclaw/src/cli.ts | 16 +- packages/openclaw/src/config.test.ts | 6 +- packages/openclaw/src/config.ts | 26 +- packages/openclaw/src/connector.test.ts | 138 ++++- packages/openclaw/src/connector.ts | 95 ++- packages/openclaw/src/integration.test.ts | 3 +- .../openclaw/src/openclaw-extension.test.ts | 8 +- .../openclaw/src/openclaw-runtime.test.ts | 79 ++- packages/openclaw/src/openclaw-runtime.ts | 576 +++++++++++++++--- packages/openclaw/src/registration.test.ts | 5 +- packages/openclaw/src/setup.test.ts | 46 +- packages/openclaw/src/setup.ts | 104 ++-- packages/openclaw/src/types.ts | 1 - packages/openclaw/tsdown.config.ts | 2 +- packages/openclaw/vitest.config.ts | 21 +- packages/pickle/native/go.mod | 2 +- packages/pickle/native/go.sum | 4 +- .../native/internal/core/appservice_test.go | 42 +- .../native/internal/core/beeper_ai_run.go | 91 +-- .../internal/core/beeper_ai_run_test.go | 25 +- .../pickle/native/internal/core/messages.go | 22 +- tsconfig.base.json | 2 + 45 files changed, 1529 insertions(+), 677 deletions(-) rename packages/{openclaw => bridge}/src/beeper-stream.test.ts (67%) rename packages/{openclaw => bridge}/src/beeper-stream.ts (53%) create mode 100644 packages/bridge/src/media-message.test.ts create mode 100644 packages/bridge/src/media-message.ts diff --git a/packages/bridge/package.json b/packages/bridge/package.json index 2be8fa5..af7db7a 100644 --- a/packages/bridge/package.json +++ b/packages/bridge/package.json @@ -24,6 +24,14 @@ "types": "./dist/beeper.d.ts", "import": "./dist/beeper.js" }, + "./beeper-stream": { + "types": "./dist/beeper-stream.d.ts", + "import": "./dist/beeper-stream.js" + }, + "./media-message": { + "types": "./dist/media-message.d.ts", + "import": "./dist/media-message.js" + }, "./store": { "types": "./dist/store.d.ts", "import": "./dist/store.js" diff --git a/packages/openclaw/src/beeper-stream.test.ts b/packages/bridge/src/beeper-stream.test.ts similarity index 67% rename from packages/openclaw/src/beeper-stream.test.ts rename to packages/bridge/src/beeper-stream.test.ts index b88fe83..dbc65bb 100644 --- a/packages/openclaw/src/beeper-stream.test.ts +++ b/packages/bridge/src/beeper-stream.test.ts @@ -1,15 +1,16 @@ import type { MatrixClient } from "@beeper/pickle"; import { describe, expect, it, vi } from "vitest"; -import { BeeperTurnStreamCoordinator } from "./beeper-stream"; +import { BeeperTurnStream, runBeeperTurnStream } from "./beeper-stream"; -describe("OpenClaw Beeper native stream publisher", () => { +describe("Beeper AI turn stream publisher", () => { it("starts one ai-bridge backed stream and appends canonical AG-UI events", async () => { const { appendEvent, client, finish, start } = createClient(); - const publisher = new BeeperTurnStreamCoordinator({ + const publisher = new BeeperTurnStream({ agentId: "codex", agentName: "Codex", client, initialMessageMetadata: { agent_id: "codex" }, + model: "openclaw/plugin", roomId: "!room:example.com", turnId: "turn_1", userId: "@sh-openclaw_agent_codex:example.com", @@ -49,7 +50,7 @@ describe("OpenClaw Beeper native stream publisher", () => { it("does not promote provider message ids into additional Matrix stream messages", async () => { const { appendEvent, client, finish, start } = createClient(); - const publisher = new BeeperTurnStreamCoordinator({ + const publisher = new BeeperTurnStream({ client, roomId: "!room:example.com", turnId: "turn_multi", @@ -73,9 +74,38 @@ describe("OpenClaw Beeper native stream publisher", () => { expect(finish).toHaveBeenCalledTimes(1); }); + it("keeps tool result message ids separate from the assistant message", async () => { + const { appendEvent, client } = createClient(); + const publisher = new BeeperTurnStream({ + client, + roomId: "!room:example.com", + turnId: "turn_tool", + }); + + await publisher.publish({ + content: "{\"ok\":true}", + messageId: "tool_1", + role: "tool", + state: "complete", + toolCallId: "tool_1", + type: "TOOL_CALL_RESULT", + }); + + expect(appendEvent.mock.calls.map(([options]) => options.event)).toEqual([ + { + content: "{\"ok\":true}", + messageId: "tool_1", + role: "tool", + state: "complete", + toolCallId: "tool_1", + type: "TOOL_CALL_RESULT", + }, + ]); + }); + it("finalizes run errors through the native stream", async () => { const { client, error } = createClient(); - const publisher = new BeeperTurnStreamCoordinator({ + const publisher = new BeeperTurnStream({ client, roomId: "!room:example.com", turnId: "turn_error", @@ -100,7 +130,7 @@ describe("OpenClaw Beeper native stream publisher", () => { it("starts with subscribers, thread root, and ghost sender when provided", async () => { const { client, start } = createClient(); - const publisher = new BeeperTurnStreamCoordinator({ + const publisher = new BeeperTurnStream({ client, roomId: "!room:example.com", subscribers: [{ deviceId: "DEVICE", userId: "@alice:example.com" }], @@ -117,6 +147,52 @@ describe("OpenClaw Beeper native stream publisher", () => { userId: "@agent:example.com", })); }); + + it("runs mapped provider events through one finalized turn stream", async () => { + const { appendEvent, client, finish } = createClient(); + const publisher = new BeeperTurnStream({ + client, + roomId: "!room:example.com", + turnId: "turn_runner", + }); + + await expect(runBeeperTurnStream({ + events: ["a", "b"], + mapEvent: (delta) => ({ delta, type: "TEXT_MESSAGE_CONTENT" }), + stream: publisher, + })).resolves.toMatchObject({ eventId: "$target" }); + + expect(appendEvent.mock.calls.map(([options]) => options.event)).toEqual([ + { delta: "a", messageId: "msg-turn_runner", type: "TEXT_MESSAGE_CONTENT" }, + { delta: "b", messageId: "msg-turn_runner", type: "TEXT_MESSAGE_CONTENT" }, + ]); + expect(finish).toHaveBeenCalledOnce(); + }); + + it("finalizes mapped provider failures as stream errors before rethrowing", async () => { + const { client, error, finish } = createClient(); + const publisher = new BeeperTurnStream({ + client, + roomId: "!room:example.com", + turnId: "turn_runner_error", + }); + + await expect(runBeeperTurnStream({ + events: ["a"], + mapEvent: () => { + throw new Error("provider exploded"); + }, + stream: publisher, + })).rejects.toThrow("provider exploded"); + + expect(error).toHaveBeenCalledWith(expect.objectContaining({ + message: "provider exploded", + runId: "turn_runner_error", + terminal: expect.objectContaining({ message: "provider exploded", type: "RUN_ERROR" }), + type: "error", + })); + expect(finish).not.toHaveBeenCalled(); + }); }); function createClient() { @@ -154,18 +230,6 @@ function createClient() { finish, start, }, - aiRuns: { - appendEvent: vi.fn(), - begin: vi.fn(), - delete: vi.fn(), - error: vi.fn(), - finish: vi.fn(), - }, - streams: { - finalizeMessage: vi.fn(), - publishPart: vi.fn(), - startMessage: vi.fn(), - }, }, } as unknown as MatrixClient; return { appendEvent, client, error, finish, start }; diff --git a/packages/openclaw/src/beeper-stream.ts b/packages/bridge/src/beeper-stream.ts similarity index 53% rename from packages/openclaw/src/beeper-stream.ts rename to packages/bridge/src/beeper-stream.ts index bfd7b5e..881ee21 100644 --- a/packages/openclaw/src/beeper-stream.ts +++ b/packages/bridge/src/beeper-stream.ts @@ -1,12 +1,27 @@ import type { MatrixBeeper, MatrixBeeperAIRunStreamResult, SentEvent } from "@beeper/pickle"; -import { SerialQueue } from "./serial"; -import { AGUIEventType, createTurnId, type AGUIEvent } from "./beeper-turn-events"; +type AGUIEvent = Record & { type?: string }; type FinishReason = "stop" | "length" | "content_filter" | "tool_calls"; const BEEPER_AI_STREAM_TYPE = "com.beeper.llm"; -export interface BeeperTurnStreamCoordinatorClient { +const EVENT_RUN_ERROR = "RUN_ERROR"; +const EVENT_RUN_FINISHED = "RUN_FINISHED"; +const EVENT_RUN_STARTED = "RUN_STARTED"; +const EVENT_TEXT_MESSAGE_START = "TEXT_MESSAGE_START"; +const EVENT_TEXT_MESSAGE_CONTENT = "TEXT_MESSAGE_CONTENT"; +const EVENT_TEXT_MESSAGE_END = "TEXT_MESSAGE_END"; +const EVENT_REASONING_START = "REASONING_START"; +const EVENT_REASONING_MESSAGE_START = "REASONING_MESSAGE_START"; +const EVENT_REASONING_MESSAGE_CONTENT = "REASONING_MESSAGE_CONTENT"; +const EVENT_REASONING_MESSAGE_END = "REASONING_MESSAGE_END"; +const EVENT_REASONING_END = "REASONING_END"; +const EVENT_TOOL_CALL_START = "TOOL_CALL_START"; +const EVENT_TOOL_CALL_RESULT = "TOOL_CALL_RESULT"; +const EVENT_ACTIVITY_SNAPSHOT = "ACTIVITY_SNAPSHOT"; +const EVENT_ACTIVITY_DELTA = "ACTIVITY_DELTA"; + +export interface BeeperTurnStreamClient { beeper: MatrixBeeper; } @@ -15,15 +30,16 @@ export interface BeeperStreamSubscriber { userId: string; } -export interface CreateBeeperTurnStreamCoordinatorOptions { +export interface CreateBeeperTurnStreamOptions { agentId?: string; agentName?: string; - client: BeeperTurnStreamCoordinatorClient; + client: BeeperTurnStreamClient; initialMessageMetadata?: Record; + model?: string; roomId: string; subscribers?: BeeperStreamSubscriber[]; threadRoot?: string; - turnId?: string; + turnId: string; userId?: string; } @@ -34,37 +50,44 @@ export interface BeeperStreamStartResult { } export interface BeeperStreamFinalizeOptions { - body?: string; - finalText?: string; finishReason?: string; - message?: Record; terminalPart?: AGUIEvent; + usage?: unknown; } -export class BeeperTurnStreamCoordinator { +export interface RunBeeperTurnStreamOptions { + events: Iterable | AsyncIterable; + finishReason?: string; + mapEvent: (event: T, stream: BeeperTurnStream) => Iterable | AGUIEvent | undefined | Promise | AGUIEvent | undefined>; + stream: BeeperTurnStream; +} + +export class BeeperTurnStream { readonly roomId: string; readonly turnId: string; #agentId: string | undefined; #agentName: string | undefined; - #client: BeeperTurnStreamCoordinatorClient; + #client: BeeperTurnStreamClient; #descriptor: Record | undefined; #eventId: string | undefined; #finalized = false; #initialMessageMetadata: Record; #messageId: string | undefined; + #model: string; #queue = new SerialQueue(); #started = false; #subscribers: BeeperStreamSubscriber[]; #threadRoot: string | undefined; #userId: string | undefined; - constructor(options: CreateBeeperTurnStreamCoordinatorOptions) { + constructor(options: CreateBeeperTurnStreamOptions) { this.#agentId = options.agentId; this.#agentName = options.agentName; this.#client = options.client; this.#initialMessageMetadata = options.initialMessageMetadata ?? {}; + this.#model = options.model ?? "bridge/plugin"; this.roomId = options.roomId; - this.turnId = options.turnId ?? createTurnId(); + this.turnId = options.turnId; this.#subscribers = options.subscribers ?? []; this.#threadRoot = options.threadRoot; this.#userId = options.userId; @@ -81,24 +104,17 @@ export class BeeperTurnStreamCoordinator { }); } - async publish(part: AGUIEvent): Promise { - return this.#queue.run(async () => { - if (this.#finalized) throw new Error("Cannot publish to finalized Beeper stream"); - await this.#ensureStarted(); - await this.#client.beeper.aiRunStreams.appendEvent({ - event: this.#canonicalizePart(part), - runId: this.turnId, - }); - }); + async publish(event: AGUIEvent): Promise { + await this.publishMany([event]); } - async publishMany(parts: Iterable): Promise { + async publishMany(events: Iterable): Promise { return this.#queue.run(async () => { - for (const part of parts) { + for (const event of events) { if (this.#finalized) throw new Error("Cannot publish to finalized Beeper stream"); await this.#ensureStarted(); await this.#client.beeper.aiRunStreams.appendEvent({ - event: this.#canonicalizePart(part), + event: this.#canonicalizeEvent(event), runId: this.turnId, }); } @@ -109,24 +125,25 @@ export class BeeperTurnStreamCoordinator { return this.#queue.run(async () => { if (this.#finalized) throw new Error("Beeper stream is already finalized"); await this.#ensureStarted(); - const terminalPart = options.terminalPart ?? { + const terminal = options.terminalPart ?? { finishReason: normalizeFinishReason(options.finishReason), runId: this.turnId, threadId: this.turnId, - type: AGUIEventType.RUN_FINISHED, + type: EVENT_RUN_FINISHED, }; - const finishReason = normalizeFinishReason(stringValue((terminalPart as Record).finishReason) ?? options.finishReason); - const result = terminalPart.type === AGUIEventType.RUN_ERROR + const finishReason = normalizeFinishReason(stringValue(terminal.finishReason) ?? options.finishReason); + const result = terminal.type === EVENT_RUN_ERROR ? await this.#client.beeper.aiRunStreams.error({ - message: terminalFallbackText(terminalPart), + message: terminalFallbackText(terminal), runId: this.turnId, - terminal: terminalPart as Record, - type: stringValue((terminalPart as Record).terminalType) === "abort" ? "abort" : "error", + terminal, + type: stringValue(terminal.terminalType) === "abort" ? "abort" : "error", }) : await this.#client.beeper.aiRunStreams.finish({ finishReason, runId: this.turnId, - terminal: terminalPart as Record, + terminal, + ...(options.usage !== undefined ? { usage: options.usage } : {}), }); this.#rememberStreamResult(result); this.#finalized = true; @@ -155,7 +172,7 @@ export class BeeperTurnStreamCoordinator { ...(this.#agentId ? { agentId: this.#agentId } : {}), ...(this.#agentName ? { agentName: this.#agentName } : {}), data: this.#initialMessageMetadata, - model: "openclaw/plugin", + model: this.#model, roomId: this.roomId, runId: this.turnId, streamType: BEEPER_AI_STREAM_TYPE, @@ -179,42 +196,75 @@ export class BeeperTurnStreamCoordinator { this.#messageId = result.messageId || this.#messageId || `msg-${this.turnId}`; } - #canonicalizePart(part: AGUIEvent): AGUIEvent { - const event = { ...(part as Record) }; + #canonicalizeEvent(event: AGUIEvent): AGUIEvent { + const canonical = { ...event }; const messageId = this.#messageId ?? `msg-${this.turnId}`; - if (event.type === AGUIEventType.RUN_STARTED || event.type === AGUIEventType.RUN_FINISHED) { - event.runId = this.turnId; - event.threadId = this.turnId; + if (canonical.type === EVENT_RUN_STARTED || canonical.type === EVENT_RUN_FINISHED) { + canonical.runId = this.turnId; + canonical.threadId = this.turnId; } - if (event.type === AGUIEventType.RUN_ERROR && !stringValue(event.message)) { - event.message = terminalFallbackText(part); + if (canonical.type === EVENT_RUN_ERROR && !stringValue(canonical.message)) { + canonical.message = terminalFallbackText(event); } - if (usesCanonicalMessageId(event.type)) { - event.messageId = messageId; + if (usesCanonicalMessageId(canonical.type)) { + canonical.messageId = messageId; } - if (event.type === AGUIEventType.TOOL_CALL_START) { - event.parentMessageId = messageId; + if (canonical.type === EVENT_TOOL_CALL_START) { + canonical.parentMessageId = messageId; } - return stripUndefined(event) as AGUIEvent; + return stripUndefined(canonical); + } +} + +export async function runBeeperTurnStream(options: RunBeeperTurnStreamOptions): Promise { + try { + for await (const event of toAsyncIterable(options.events)) { + const mapped = await options.mapEvent(event, options.stream); + const events = mapped === undefined ? [] : isAGUIEvent(mapped) ? [mapped] : [...mapped]; + if (events.length > 0) await options.stream.publishMany(events); + } + return await options.stream.finalize(options.finishReason === undefined ? {} : { finishReason: options.finishReason }); + } catch (error) { + await options.stream.finalize({ + terminalPart: { + error: { message: errorMessage(error) }, + message: errorMessage(error), + runId: options.stream.turnId, + threadId: options.stream.turnId, + type: EVENT_RUN_ERROR, + }, + }); + throw error; + } +} + +class SerialQueue { + #tail = Promise.resolve(); + + run(operation: () => Promise): Promise { + const next = this.#tail.then(operation, operation); + this.#tail = next.then(() => undefined, () => undefined); + return next; } } function usesCanonicalMessageId(type: unknown): boolean { - return type === AGUIEventType.TEXT_MESSAGE_START || - type === AGUIEventType.TEXT_MESSAGE_CONTENT || - type === AGUIEventType.TEXT_MESSAGE_END || - type === AGUIEventType.REASONING_START || - type === AGUIEventType.REASONING_MESSAGE_START || - type === AGUIEventType.REASONING_MESSAGE_CONTENT || - type === AGUIEventType.REASONING_MESSAGE_END || - type === AGUIEventType.REASONING_END || - type === AGUIEventType.TOOL_CALL_RESULT; + return type === EVENT_TEXT_MESSAGE_START || + type === EVENT_TEXT_MESSAGE_CONTENT || + type === EVENT_TEXT_MESSAGE_END || + type === EVENT_REASONING_START || + type === EVENT_REASONING_MESSAGE_START || + type === EVENT_REASONING_MESSAGE_CONTENT || + type === EVENT_REASONING_MESSAGE_END || + type === EVENT_REASONING_END || + type === EVENT_ACTIVITY_SNAPSHOT || + type === EVENT_ACTIVITY_DELTA; } 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"; + if (event.type === EVENT_RUN_ERROR) { + return stringValue(event.message) ?? stringValue(event.error) ?? "Bridge run failed"; } return ""; } @@ -236,3 +286,19 @@ function normalizeFinishReason(reason: string | undefined): FinishReason { function stripUndefined>(record: T): T { return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T; } + +async function* toAsyncIterable(events: Iterable | AsyncIterable): AsyncIterable { + if (Symbol.asyncIterator in events) { + yield* events; + return; + } + yield* events; +} + +function isAGUIEvent(value: unknown): value is AGUIEvent { + return Boolean(recordValue(value)?.type); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/bridge/src/bridge.test.ts b/packages/bridge/src/bridge.test.ts index 3126811..eac87a7 100644 --- a/packages/bridge/src/bridge.test.ts +++ b/packages/bridge/src/bridge.test.ts @@ -596,6 +596,37 @@ describe("RuntimeBridge", () => { expect(backfill.eventIds).toEqual(["$backfilled"]); }); + it("uses full Matrix user IDs directly for appservice portal senders", async () => { + const client = createFakeMatrixClient(); + const bridge = new RuntimeBridge({ + appservice: { + homeserver: "https://matrix.example", + homeserverDomain: "example", + registration: { + asToken: "as", + hsToken: "hs", + id: "test", + namespaces: { users: [{ exclusive: true, regex: "@test_.*:example" }] }, + senderLocalpart: "testbot", + url: "http://localhost:29300", + }, + }, + connector: createFakeConnector(createFakeNetworkAPI()), + matrix: matrixConfig(), + }, client); + + await bridge.start(); + await bridge.createPortal({ id: "remote-room", userId: "@owner:example" }, { + id: "remote-room", + roomType: "dm", + sender: "@test_agent_main:example", + }); + + expect(client.appservice.createPortalRoom).toHaveBeenCalledWith(expect.objectContaining({ + userId: "@test_agent_main:example", + })); + }); + it("adds Beeper room metadata and autojoin members for Beeper bridges", async () => { const client = createFakeMatrixClient(); const connector = createFakeConnector(createFakeNetworkAPI()); diff --git a/packages/bridge/src/bridge.ts b/packages/bridge/src/bridge.ts index 70efec1..7fb9531 100644 --- a/packages/bridge/src/bridge.ts +++ b/packages/bridge/src/bridge.ts @@ -119,7 +119,7 @@ export async function createBeeperBridge(options: CreateBeeperBridgeOptions): Pr bridgeType: options.bridgeType, getOnly: options.getOnly, homeserverDomain: options.homeserverDomain, - token: options.account.accessToken, + token: requiredAccount(options).accessToken, })); const matrix = { ...options.matrix, @@ -143,7 +143,7 @@ export async function createBeeperBridgeWithClient(options: CreateBeeperBridgeOp bridgeType: options.bridgeType, getOnly: options.getOnly, homeserverDomain: options.homeserverDomain, - token: options.account.accessToken, + token: requiredAccount(options).accessToken, })); const matrix = { ...options.matrix, @@ -162,7 +162,7 @@ function createBeeperRuntimeOptions(options: CreateBeeperBridgeOptions, appservi appservice, beeper: { bridge: options.bridge, - ownerUserId: options.account.userId, + ...(options.account?.userId ?? options.ownerUserId ? { ownerUserId: options.account?.userId ?? options.ownerUserId } : {}), ...(options.bridgeType ? { bridgeType: options.bridgeType } : {}), }, connector: options.connector, @@ -173,6 +173,11 @@ function createBeeperRuntimeOptions(options: CreateBeeperBridgeOptions, appservi return runtimeOptions; } +function requiredAccount(options: CreateBeeperBridgeOptions) { + if (!options.account) throw new Error("createBeeperBridge requires account unless matrix.appservice is provided"); + return options.account; +} + export class RuntimeBridge implements PickleBridge { readonly connector: CreateBridgeOptions["connector"]; readonly #appserviceOptions: CreateBridgeOptions["appservice"]; @@ -341,7 +346,7 @@ export class RuntimeBridge implements PickleBridge { return this.createPortalRoom({ ...roomOptions, portalKey: { id, receiver: login.id }, - ...(sender ? { userId: this.ghostUserId(sender) } : {}), + ...(sender ? { userId: this.senderUserId(sender) } : {}), }); } @@ -536,6 +541,10 @@ export class RuntimeBridge implements PickleBridge { return `@${escaped}:${domainFromUserID(this.#ownerUserId ?? this.#ownUserId ?? "@bridge:example")}`; } + senderUserId(sender: string): string { + return sender.startsWith("@") ? sender : this.ghostUserId(sender); + } + registerGhost(ghost: Ghost): void { this.#ghosts.set(ghost.id, ghost); void this.#dataStore?.setGhost(ghost).catch((error: unknown) => { @@ -562,7 +571,7 @@ export class RuntimeBridge implements PickleBridge { } #eventSenderReference(login: UserLogin, sender: string | EventSender): EventSender { - return typeof sender === "string" ? { isFromMe: false, sender: this.ghostUserId(sender), senderLogin: login.id } : sender; + return typeof sender === "string" ? { isFromMe: false, sender: this.senderUserId(sender), senderLogin: login.id } : sender; } getPortalByMXID(mxid: string): Portal | null { @@ -809,7 +818,7 @@ export class RuntimeBridge implements PickleBridge { (event) => { if (this.#traceToDeviceEvent(event)) return; void this.dispatchMatrixEvent(event).catch((error: unknown) => { - this.#log("error", "matrix_dispatch_failed", { error }); + this.#log("error", "matrix_dispatch_failed", { error: errorMessage(error) }); }); }, { live: true } diff --git a/packages/bridge/src/index.ts b/packages/bridge/src/index.ts index 96f5114..14ad498 100644 --- a/packages/bridge/src/index.ts +++ b/packages/bridge/src/index.ts @@ -22,7 +22,7 @@ export async function createBeeperBridge(options: CreateNodeBeeperBridgeOptions) const store = options.store ?? options.matrix?.store ?? createFileMatrixStore(defaultDataDir(options)); const appservice = options.matrix?.appservice ?? await createBeeperAppServiceInit({ bridge: options.bridge, - token: options.account.accessToken, + token: requiredAccount(options).accessToken, ...(options.address ? { address: options.address } : {}), ...(options.baseDomain ? { baseDomain: options.baseDomain } : {}), ...(options.bridgeType ? { bridgeType: options.bridgeType } : {}), @@ -44,7 +44,7 @@ export async function createBeeperBridge(options: CreateNodeBeeperBridgeOptions) appservice, beeper: { bridge: options.bridge, - ownerUserId: options.account.userId, + ...(options.account?.userId ?? options.ownerUserId ? { ownerUserId: options.account?.userId ?? options.ownerUserId } : {}), ...(options.bridgeType ? { bridgeType: options.bridgeType } : {}), }, connector: options.connector, @@ -56,6 +56,11 @@ export async function createBeeperBridge(options: CreateNodeBeeperBridgeOptions) })); } +function requiredAccount(options: CreateNodeBeeperBridgeOptions) { + if (!options.account) throw new Error("createBeeperBridge requires account unless matrix.appservice is provided"); + return options.account; +} + function defaultDataDir(options: { bridge: string; dataDir?: string }): string { return resolve(options.dataDir ?? ".pickle-bridge", options.bridge, "matrix-state"); } diff --git a/packages/bridge/src/media-message.test.ts b/packages/bridge/src/media-message.test.ts new file mode 100644 index 0000000..e5339be --- /dev/null +++ b/packages/bridge/src/media-message.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from "vitest"; +import { bridgeMediaMessageContent, uploadBridgeMediaMessage } from "./media-message"; + +describe("bridge media messages", () => { + it("uploads bytes and returns a Matrix media message part", async () => { + const upload = vi.fn(async () => ({ contentUri: "mxc://example/file", raw: { ok: true } })); + + await expect(uploadBridgeMediaMessage({ + media: { upload }, + }, { + bytes: new Uint8Array([1, 2, 3]), + caption: "caption", + filename: "a.png", + kind: "image", + })).resolves.toEqual({ + content: { + body: "caption", + filename: "a.png", + msgtype: "m.image", + url: "mxc://example/file", + }, + part: { + content: { + body: "caption", + filename: "a.png", + msgtype: "m.image", + url: "mxc://example/file", + }, + type: "m.room.message", + }, + upload: { contentUri: "mxc://example/file", raw: { ok: true } }, + }); + expect(upload).toHaveBeenCalledWith({ + bytes: new Uint8Array([1, 2, 3]), + filename: "a.png", + }); + }); + + it("maps media kinds to Matrix msgtypes", () => { + expect(bridgeMediaMessageContent({ contentUri: "mxc://x", kind: "video" })).toMatchObject({ msgtype: "m.video" }); + expect(bridgeMediaMessageContent({ contentUri: "mxc://x", kind: "audio" })).toMatchObject({ msgtype: "m.audio" }); + expect(bridgeMediaMessageContent({ contentUri: "mxc://x", kind: "file" })).toMatchObject({ body: "attachment", msgtype: "m.file" }); + }); +}); diff --git a/packages/bridge/src/media-message.ts b/packages/bridge/src/media-message.ts new file mode 100644 index 0000000..717599b --- /dev/null +++ b/packages/bridge/src/media-message.ts @@ -0,0 +1,66 @@ +import type { MatrixClient, UploadMediaResult } from "@beeper/pickle"; +import type { ConvertedMessagePart } from "./types"; + +export type BridgeMediaKind = "image" | "video" | "audio" | "file"; + +export interface BridgeMediaUploadClient { + media: Pick; +} + +export interface BridgeMediaMessageOptions { + bytes: Uint8Array; + caption?: string; + filename?: string; + kind?: BridgeMediaKind; +} + +export interface BridgeUploadedMediaMessage { + content: Record; + part: ConvertedMessagePart; + upload: UploadMediaResult; +} + +export async function uploadBridgeMediaMessage( + client: BridgeMediaUploadClient, + options: BridgeMediaMessageOptions, +): Promise { + const upload = await client.media.upload({ + bytes: options.bytes, + ...(options.filename !== undefined ? { filename: options.filename } : {}), + }); + const content = bridgeMediaMessageContent({ + contentUri: upload.contentUri, + kind: options.kind ?? "file", + ...(options.caption !== undefined ? { caption: options.caption } : {}), + ...(options.filename !== undefined ? { filename: options.filename } : {}), + }); + return { + content, + part: { + content, + type: "m.room.message", + }, + upload, + }; +} + +export function bridgeMediaMessageContent(options: { + caption?: string; + contentUri: string; + filename?: string; + kind: BridgeMediaKind; +}): Record { + return { + body: options.caption ?? options.filename ?? "attachment", + msgtype: mediaMsgtype(options.kind), + url: options.contentUri, + ...(options.filename ? { filename: options.filename } : {}), + }; +} + +function mediaMsgtype(kind: BridgeMediaKind): string { + if (kind === "image") return "m.image"; + if (kind === "video") return "m.video"; + if (kind === "audio") return "m.audio"; + return "m.file"; +} diff --git a/packages/bridge/src/types.ts b/packages/bridge/src/types.ts index 8e72882..fdf1348 100644 --- a/packages/bridge/src/types.ts +++ b/packages/bridge/src/types.ts @@ -567,7 +567,7 @@ export interface BridgeBeeperOptions { } export interface CreateBeeperBridgeOptions extends Omit { - account: MatrixAccount; + account?: MatrixAccount; address?: string; baseDomain?: string; bridge: string; @@ -577,6 +577,7 @@ export interface CreateBeeperBridgeOptions extends Omit>; + ownerUserId?: UserID; store?: MatrixStore; } diff --git a/packages/bridge/tsdown.config.ts b/packages/bridge/tsdown.config.ts index 1e39556..952460e 100644 --- a/packages/bridge/tsdown.config.ts +++ b/packages/bridge/tsdown.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsdown"; export default defineConfig({ - entry: ["src/index.ts", "src/types.ts", "src/events.ts", "src/beeper.ts", "src/store.ts", "src/appservice-websocket.ts"], + entry: ["src/index.ts", "src/types.ts", "src/events.ts", "src/beeper.ts", "src/beeper-stream.ts", "src/media-message.ts", "src/store.ts", "src/appservice-websocket.ts"], format: ["esm"], dts: { sourcemap: false, diff --git a/packages/openclaw/README.md b/packages/openclaw/README.md index 7b13c22..20fb450 100644 --- a/packages/openclaw/README.md +++ b/packages/openclaw/README.md @@ -14,7 +14,7 @@ OpenClaw loads the runtime entry from `dist/plugin-entry.mjs` and the lightweigh ## What It Provides -- Beeper email-code login for existing accounts. +- Beeper email-code login for existing accounts, with username/password login available when needed. - Beeper appservice registration for the OpenClaw bridge. - OpenClaw channel metadata, setup entrypoint, runtime entrypoint, and ClawHub install metadata. - Pickle bridgev2-style transport for Matrix portals, media, reactions, receipts, and backfill. @@ -54,19 +54,14 @@ import { backfillAllOpenClawSessions, } from "@beeper/openclaw/backfill"; import { - createDefaultConfig, + readConfig, } from "@beeper/openclaw/config"; import { accountFromOpenClawConfig, createOpenClawBeeperBridge, } from "@beeper/openclaw/appservice"; -const config = createDefaultConfig({ - accessToken: process.env.BEEPER_ACCESS_TOKEN, - homeserver: "https://matrix.beeper.com", - matrixDeviceId: process.env.BEEPER_DEVICE_ID, - matrixUserId: process.env.BEEPER_USER_ID, -}); +const config = await readConfig(); const bridge = await createOpenClawBeeperBridge({ account: accountFromOpenClawConfig(config), @@ -76,6 +71,8 @@ const bridge = await createOpenClawBeeperBridge({ await bridge.start(); ``` +For normal use, run `pickle-openclaw login --email you@example.com` and let setup persist the owned Beeper device credentials. + The runtime uses the in-process OpenClaw plugin context and exposes the Beeper bridge as an OpenClaw channel connector. ## Protocol Coverage diff --git a/packages/openclaw/openclaw.plugin.json b/packages/openclaw/openclaw.plugin.json index 6ee4feb..7125e97 100644 --- a/packages/openclaw/openclaw.plugin.json +++ b/packages/openclaw/openclaw.plugin.json @@ -10,7 +10,6 @@ ], "channelEnvVars": { "beeper": [ - "PICKLE_OPENCLAW_ACCESS_TOKEN", "PICKLE_OPENCLAW_ALLOW_ROOMS", "PICKLE_OPENCLAW_ALLOW_USERS", "PICKLE_OPENCLAW_AS_TOKEN", @@ -43,10 +42,6 @@ "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." @@ -152,11 +147,6 @@ } }, "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.", diff --git a/packages/openclaw/package.json b/packages/openclaw/package.json index bead46e..b675023 100644 --- a/packages/openclaw/package.json +++ b/packages/openclaw/package.json @@ -47,10 +47,6 @@ "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" - }, "./cli": { "types": "./dist/cli.d.mts", "import": "./dist/cli.mjs" @@ -137,6 +133,14 @@ "flags": "--email ", "description": "Beeper account email for login" }, + { + "flags": "--username ", + "description": "Beeper Matrix username for password login" + }, + { + "flags": "--password ", + "description": "Beeper Matrix password for password login" + }, { "flags": "--bridge-manager-token ", "description": "Beeper bridge-manager token for self-hosted appservice registration" diff --git a/packages/openclaw/src/appservice.test.ts b/packages/openclaw/src/appservice.test.ts index 71188d2..c381061 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 { accountFromOpenClawConfig, createOpenClawBeeperBridge, startOpenClawBeeperBridge } from "./appservice"; +import { createOpenClawBeeperBridge, startOpenClawBeeperBridge } from "./appservice"; import { OpenClawPluginRuntimeAdapter, type OpenClawRuntimeRequestSurface } from "./openclaw-runtime"; import { OpenClawBridgeRegistry } from "./registry"; @@ -13,11 +13,14 @@ describe("OpenClaw Beeper appservice runtime", () => { beeperEnv: "staging", bridgeManagerToken: "hungry-token", dataDir: "/tmp/openclaw", + asToken: "as-token", + homeserver: "https://matrix.beeper-staging.com", homeserverDomain: "beeper.local", + hsToken: "hs-token", + matrixUserId: "@batuhan:beeper-staging.com", }); await expect(createOpenClawBeeperBridge({ - account: account(), bridgeFactory, config, dataDir: "/tmp/openclaw-data", @@ -25,7 +28,6 @@ describe("OpenClaw Beeper appservice runtime", () => { })).resolves.toBe(bridge); expect(bridgeFactory).toHaveBeenCalledWith(expect.objectContaining({ - account: account(), address: "websocket", baseDomain: "beeper-staging.com", bridge: "sh-openclaw", @@ -38,58 +40,50 @@ describe("OpenClaw Beeper appservice runtime", () => { dataDir: "/tmp/openclaw-data", getOnly: true, homeserverDomain: "beeper.local", + ownerUserId: "@batuhan:beeper-staging.com", })); }); it("starts the created bridge", async () => { const bridge = fakeBridge(); await expect(startOpenClawBeeperBridge({ - account: account(), bridgeFactory: async () => bridge, - config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + config: createDefaultConfig({ + asToken: "as-token", + dataDir: "/tmp/openclaw", + homeserver: "https://matrix.beeper.com", + hsToken: "hs-token", + }), })).resolves.toBe(bridge); expect(bridge.start).toHaveBeenCalledOnce(); }); - it("marks the self-hosted bridge running after the appservice starts", async () => { + it("marks the 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", + homeserver: "https://matrix.beeper-staging.com", + hsToken: "hs-token", 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"); + expect(bridge.start).toHaveBeenCalledOnce(); + expect(bridge.setBridgeState).toHaveBeenCalledWith("running"); }); it("starts from persisted appservice config without re-registering", async () => { const bridge = fakeBridge(); const bridgeFactory = vi.fn(async (_options: CreateNodeBeeperBridgeOptions) => bridge); const config = createDefaultConfig({ - accessToken: "mx-token", appserviceId: "sh-openclaw-device", asToken: "as-token", dataDir: "/tmp/openclaw", @@ -101,7 +95,6 @@ describe("OpenClaw Beeper appservice runtime", () => { }); await expect(startOpenClawBeeperBridge({ - account: account(), bridgeFactory, config, })).resolves.toBe(bridge); @@ -123,8 +116,10 @@ describe("OpenClaw Beeper appservice runtime", () => { }), })); 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"); + expect(bridgeFactory.mock.calls[0]?.[0]).toMatchObject({ + ownerUserId: "@batuhan:beeper-staging.com", + }); }); it("runs startup backfill with the configured import source scope", async () => { @@ -138,9 +133,10 @@ describe("OpenClaw Beeper appservice runtime", () => { })); bridge.backfillPortal = vi.fn(async () => ({ eventIds: [] })); const config = createDefaultConfig({ - accessToken: "mx-token", + asToken: "as-token", dataDir: "/tmp/openclaw", homeserver: "https://matrix.beeper.com", + hsToken: "hs-token", importSources: ["dashboard"], matrixDeviceId: "DEVICE", matrixUserId: "@batuhan:beeper.com", @@ -158,7 +154,6 @@ describe("OpenClaw Beeper appservice runtime", () => { }); await expect(startOpenClawBeeperBridge({ - account: account(), backfill: true, backfillLimit: 3, bridgeFactory: async () => bridge, @@ -190,16 +185,16 @@ describe("OpenClaw Beeper appservice runtime", () => { })); bridge.backfillPortal = vi.fn(async () => ({ eventIds: [] })); const config = createDefaultConfig({ - accessToken: "mx-token", + asToken: "as-token", dataDir: "/tmp/openclaw", homeserver: "https://matrix.beeper.com", + hsToken: "hs-token", importSources: ["dashboard"], matrixDeviceId: "DEVICE", matrixUserId: "@batuhan:beeper.com", }); await expect(startOpenClawBeeperBridge({ - account: account(), backfill: true, bridgeFactory: async () => bridge, config, @@ -239,9 +234,10 @@ describe("OpenClaw Beeper appservice runtime", () => { backfill: true, bridgeFactory: async () => bridge, config: createDefaultConfig({ - accessToken: "mx-token", + asToken: "as-token", dataDir: "/tmp/openclaw", homeserver: "https://matrix.beeper.com", + hsToken: "hs-token", importSources: ["dashboard"], matrixDeviceId: "DEVICE", matrixUserId: "@batuhan:beeper.com", @@ -252,16 +248,6 @@ describe("OpenClaw Beeper appservice runtime", () => { 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", - 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 b2510df..25abf4a 100644 --- a/packages/openclaw/src/appservice.ts +++ b/packages/openclaw/src/appservice.ts @@ -1,11 +1,8 @@ -import type { MatrixAccount, MatrixAppserviceInitOptions, MatrixAppserviceRegistration } from "@beeper/pickle"; +import type { 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 } from "./beeper-setup"; @@ -17,11 +14,9 @@ import { OpenClawBridgeRegistry } from "./registry"; import type { OpenClawBridgeConfig } from "./types"; export interface CreateOpenClawBeeperBridgeOptions extends OpenClawConnectorOptions { - account: MatrixAccount; backfill?: boolean; backfillLimit?: number; bridge?: string; - bridgeStateClientFactory?: (options: { baseDomain?: string; token: string }) => Pick; bridgeFactory?: (options: CreateNodeBeeperBridgeOptions) => Promise; bridgeType?: string; connector?: CreateNodeBeeperBridgeOptions["connector"]; @@ -36,11 +31,11 @@ export async function createOpenClawBeeperBridge(options: CreateOpenClawBeeperBr const config = options.config; const connector = options.connector ?? createOpenClawConnector(connectorOptions(options)); const bridgeOptions: CreateNodeBeeperBridgeOptions = { - account: options.account, bridge: options.bridge ?? config?.bridgeId ?? config?.appserviceId ?? "sh-openclaw", bridgeType: options.bridgeType ?? DEFAULT_BEEPER_BRIDGE_TYPE, connector, }; + if (config?.matrixUserId !== undefined) bridgeOptions.ownerUserId = config.matrixUserId; bridgeOptions.address = "websocket"; const baseDomain = beeperBaseDomain(config?.beeperEnv); if (baseDomain !== undefined) bridgeOptions.baseDomain = baseDomain; @@ -60,7 +55,6 @@ 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) { await runStartupBackfill(options, bridge); @@ -109,50 +103,10 @@ async function runStartupBackfill(options: CreateOpenClawBeeperBridgeOptions, br } } -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 = 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"); - 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; + if (options.onActivity !== undefined) output.onActivity = options.onActivity; if (options.registry !== undefined) output.registry = options.registry; if (options.runtimeFactory !== undefined) output.runtimeFactory = options.runtimeFactory; if (options.runtime !== undefined) output.runtime = options.runtime; @@ -204,13 +158,10 @@ function matrixOptionsFromConfig( ): 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 } : {}), + ...(appservice && config?.matrixDeviceId && input?.deviceId === undefined ? { deviceId: config.matrixDeviceId } : {}), ...(config?.homeserver && input?.homeserver === undefined ? { homeserver: config.homeserver } : {}), }; } @@ -219,10 +170,6 @@ 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 { diff --git a/packages/openclaw/src/backfill.test.ts b/packages/openclaw/src/backfill.test.ts index 6c8ffa7..48f9930 100644 --- a/packages/openclaw/src/backfill.test.ts +++ b/packages/openclaw/src/backfill.test.ts @@ -108,7 +108,7 @@ describe("OpenClaw backfill", () => { { content: { body: "hello", - msgtype: "m.notice", + msgtype: "m.text", "com.beeper.openclaw.backfill": { messageSeq: 1, role: "user" }, }, id: "m1", @@ -227,6 +227,7 @@ describe("OpenClaw backfill", () => { }, name: "Alice", roomType: "dm", + sender: "@sh-openclaw_agent_codex:localhost", })); expect(bridge.backfillPortal).toHaveBeenCalledWith(login, expect.objectContaining({ mxid: "!room:example.com", @@ -428,6 +429,7 @@ describe("OpenClaw backfill", () => { id: "agent:main", name: "Main Agent", roomType: "dm", + sender: "@sh-openclaw_agent_main:localhost", })); expect(bridge.backfillPortal).not.toHaveBeenCalled(); expect(registry.getBindingBySessionKey("agent:main")).toMatchObject({ diff --git a/packages/openclaw/src/backfill.ts b/packages/openclaw/src/backfill.ts index 37ce83f..4d79a46 100644 --- a/packages/openclaw/src/backfill.ts +++ b/packages/openclaw/src/backfill.ts @@ -140,6 +140,7 @@ export async function backfillAllOpenClawSessions(options: BackfillAllOpenClawSe }, name: session.label, roomType: "dm", + sender: agent.ghostUserId, }; const creationContent = openClawBackfillRoomCreationContent(options.runtime.config); if (creationContent) portalOptions.creationContent = creationContent; @@ -187,6 +188,7 @@ async function createInitialOpenClawRoom(options: BackfillAllOpenClawSessionsOpt }, name: agent.displayName, roomType: "dm", + sender: agent.ghostUserId, }; const creationContent = openClawBackfillRoomCreationContent(options.runtime.config); if (creationContent) portalOptions.creationContent = creationContent; @@ -278,10 +280,11 @@ export function shouldImportSession( function normalizeHistoryMessage(message: OpenClawChatHistoryMessage, index: number): OpenClawBackfillMessage { const role = typeof message.role === "string" ? message.role : "assistant"; const text = contentText(message.content); + const sender = role === "assistant" || role === "tool" ? "agent" : role === "system" ? "system" : "human"; const normalized: OpenClawBackfillMessage = { content: { body: text || JSON.stringify(message.content ?? message), - msgtype: role === "assistant" ? "m.text" : "m.notice", + msgtype: sender === "system" ? "m.notice" : "m.text", "com.beeper.openclaw.backfill": { messageSeq: message.messageSeq ?? index, role, @@ -289,7 +292,7 @@ function normalizeHistoryMessage(message: OpenClawChatHistoryMessage, index: num }, id: typeof message.id === "string" ? message.id : `history_${index}`, role, - sender: role === "assistant" || role === "tool" ? "agent" : role === "system" ? "system" : "human", + sender, seq: typeof message.messageSeq === "number" ? message.messageSeq : index, }; const timestamp = historyTimestamp(message); diff --git a/packages/openclaw/src/beeper-channel-config.schema.json b/packages/openclaw/src/beeper-channel-config.schema.json index 07bc707..a6b446d 100644 --- a/packages/openclaw/src/beeper-channel-config.schema.json +++ b/packages/openclaw/src/beeper-channel-config.schema.json @@ -2,10 +2,6 @@ "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." diff --git a/packages/openclaw/src/beeper-channel-runtime.ts b/packages/openclaw/src/beeper-channel-runtime.ts index 8ef55aa..2164b96 100644 --- a/packages/openclaw/src/beeper-channel-runtime.ts +++ b/packages/openclaw/src/beeper-channel-runtime.ts @@ -15,7 +15,8 @@ import { type RemoteTyping, type UserLogin, } from "@beeper/pickle-bridge"; -import { BeeperTurnStreamCoordinator } from "./beeper-stream"; +import { BeeperTurnStream } from "@beeper/pickle-bridge/beeper-stream"; +import { uploadBridgeMediaMessage, type BridgeMediaKind } from "@beeper/pickle-bridge/media-message"; import { AGUIEventType } from "./beeper-turn-events"; import type { OpenClawAgentContact, OpenClawSessionBinding } from "./types"; @@ -29,6 +30,7 @@ export interface BeeperChannelRuntimeOptions { getBindingBySessionKey?: (sessionKey: string) => OpenClawSessionBinding | undefined; login?: UserLogin; log?: (level: "debug" | "info" | "warn" | "error", message: string, data?: unknown) => void; + onActivity?: (patch: { lastEventAt?: number; lastOutboundAt?: number; lastTransportActivityAt?: number }) => void; userId?: string; } @@ -36,7 +38,7 @@ export interface BeeperOutboundMedia { bytes?: Uint8Array; caption?: string; filename?: string; - kind?: "image" | "video" | "audio" | "file"; + kind?: BridgeMediaKind; path?: string; threadRoot?: string; } @@ -50,7 +52,8 @@ export class BeeperChannelRuntime { #getBindingBySessionKey: (sessionKey: string) => OpenClawSessionBinding | undefined; #login: UserLogin | undefined; #log: BeeperChannelRuntimeOptions["log"]; - #activeStreams = new Map(); + #onActivity: BeeperChannelRuntimeOptions["onActivity"]; + #activeStreams = new Map(); constructor(options: BeeperChannelRuntimeOptions) { this.#bridge = options.bridge; @@ -60,6 +63,7 @@ export class BeeperChannelRuntime { this.#getBindingBySessionKey = options.getBindingBySessionKey ?? (() => undefined); this.#login = options.login; this.#log = options.log; + this.#onActivity = options.onActivity; this.userId = options.userId; } @@ -142,17 +146,18 @@ export class BeeperChannelRuntime { runId: string; sessionKey: string; threadRoot?: string; - }): BeeperTurnStreamCoordinator { + }): BeeperTurnStream { 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({ + const publisher = new BeeperTurnStream({ client: this.client, initialMessageMetadata: { agent_id: options.agentId, ...(agent?.displayName ? { agent_name: agent.displayName } : {}), session_key: options.sessionKey, }, + model: "openclaw/plugin", roomId: options.roomId, turnId: options.runId, ...(options.agentId ? { agentId: options.agentId } : {}), @@ -164,7 +169,7 @@ export class BeeperChannelRuntime { return publisher; } - clearActiveStream(sessionKey: string, publisher: BeeperTurnStreamCoordinator): void { + clearActiveStream(sessionKey: string, publisher: BeeperTurnStream): void { if (this.#activeStreams.get(sessionKey) === publisher) this.#activeStreams.delete(sessionKey); } @@ -181,6 +186,7 @@ export class BeeperChannelRuntime { messageId: publisher.turnId, type: AGUIEventType.TEXT_MESSAGE_CONTENT, }); + this.recordOutboundActivity(); return { eventId: publisher.targetEventId ?? publisher.turnId, raw: { nativeStream: true, turnId: publisher.turnId }, @@ -192,6 +198,14 @@ export class BeeperChannelRuntime { this.#log?.("debug", message, data); } + recordOutboundActivity(now = Date.now()): void { + this.#onActivity?.({ + lastEventAt: now, + lastOutboundAt: now, + lastTransportActivityAt: now, + }); + } + async #queueRemoteText(roomId: string, content: Record): Promise { const route = this.#bridgeRoute(roomId); const messageId = openClawRemoteId(); @@ -208,22 +222,17 @@ export class BeeperChannelRuntime { sender: this.#eventSender(roomId), })); await route.bridge.flushRemoteEvents(); + this.recordOutboundActivity(); return { eventId: messageId, raw: { bridgeQueued: true }, roomId }; } async #queueRemoteMedia(roomId: string, options: { bytes: Uint8Array; caption?: string; filename?: string; kind: NonNullable }): Promise { const route = this.#bridgeRoute(roomId); - const uploaded = await this.client.media.upload({ - bytes: options.bytes, - ...(options.filename !== undefined ? { filename: options.filename } : {}), - }); + const media = await uploadBridgeMediaMessage(this.client, options); 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", - }], + parts: [media.part], }), data: {}, id: messageId, @@ -231,6 +240,7 @@ export class BeeperChannelRuntime { sender: this.#eventSender(roomId), })); await route.bridge.flushRemoteEvents(); + this.recordOutboundActivity(); return { eventId: messageId, raw: { bridgeQueued: true }, roomId }; } @@ -252,6 +262,7 @@ export class BeeperChannelRuntime { }; route.bridge.queueRemoteEvent(route.login, event); await route.bridge.flushRemoteEvents(); + this.recordOutboundActivity(); return { eventId: messageId, raw: { bridgeQueued: true, targetMessageId: targetId }, roomId }; } @@ -266,6 +277,7 @@ export class BeeperChannelRuntime { }; route.bridge.queueRemoteEvent(route.login, event); await route.bridge.flushRemoteEvents(); + this.recordOutboundActivity(); } async #queueRemoteReaction(roomId: string, targetMessageId: string, emoji: string, remove: boolean): Promise { @@ -282,6 +294,7 @@ export class BeeperChannelRuntime { }; route.bridge.queueRemoteEvent(route.login, event); await route.bridge.flushRemoteEvents(); + this.recordOutboundActivity(); return { eventId: reactionId, raw: { bridgeQueued: true, targetMessageId: targetId }, roomId }; } @@ -296,6 +309,7 @@ export class BeeperChannelRuntime { }; route.bridge.queueRemoteEvent(route.login, event); await route.bridge.flushRemoteEvents(); + this.recordOutboundActivity(); } async #queueRemoteReceipt(roomId: string, targetMessageId: string, type: "read_receipt" | "delivery_receipt"): Promise { @@ -309,6 +323,7 @@ export class BeeperChannelRuntime { }; route.bridge.queueRemoteEvent(route.login, event); await route.bridge.flushRemoteEvents(); + this.recordOutboundActivity(); } async #queueRemoteMarkUnread(roomId: string, targetMessageId: string, unread: boolean): Promise { @@ -323,6 +338,7 @@ export class BeeperChannelRuntime { }; route.bridge.queueRemoteEvent(route.login, event); await route.bridge.flushRemoteEvents(); + this.recordOutboundActivity(); } #bridgeRoute(roomId: string): { bridge: PickleBridge; login: UserLogin; portalKey: PortalKey; targetRoomId: string } { @@ -405,19 +421,3 @@ function beeperSessionKeyCandidates(target: string): string[] { } return [...candidates]; } - -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-setup.test.ts b/packages/openclaw/src/beeper-setup.test.ts index f91dbaa..20cad79 100644 --- a/packages/openclaw/src/beeper-setup.test.ts +++ b/packages/openclaw/src/beeper-setup.test.ts @@ -44,36 +44,43 @@ describe("OpenClaw Beeper setup", () => { }), ]); expect(result.config).toEqual({ - accessToken: "mx-token", homeserver: "https://matrix.beeper.com", matrixDeviceId: "DEV", matrixUserId: "@batuhan:beeper.com", }); }); - it("infers Beeper Matrix account identity from an access token", async () => { - const seen: Array<{ url: string; authorization?: string }> = []; + it("logs in with username/password when email login is not used", async () => { + const seen: unknown[] = []; const result = await loginToBeeperForOpenClaw({ - accessToken: "mx-token", env: "staging", - fetch: async (url, init) => { - seen.push({ - url: String(url), - authorization: new Headers(init?.headers).get("authorization") ?? undefined, - }); - return new Response(JSON.stringify({ - device_id: "DEV", - user_id: "@batuhan:beeper-staging.com", - }), { status: 200 }); + openClawDeviceId: "OPENCLAW-DEVICE", + username: "batuhan", + password: "secret", + passwordLogin: async (options) => { + seen.push(options); + return { + accessToken: "mx-token", + deviceId: "DEV", + homeserver: options.homeserver, + userId: "@batuhan:beeper-staging.com", + }; }, }); - expect(seen).toEqual([{ - authorization: "Bearer mx-token", - url: "https://matrix.beeper-staging.com/_matrix/client/v3/account/whoami", - }]); + expect(seen).toEqual([ + expect.objectContaining({ + homeserver: "https://matrix.beeper-staging.com", + password: "secret", + username: "batuhan", + metadata: { + bridge: "sh-openclaw-openclaw-device", + bridgeType: "openclaw", + openClawDeviceId: "OPENCLAW-DEVICE", + }, + }), + ]); expect(result.config).toEqual({ - accessToken: "mx-token", homeserver: "https://matrix.beeper-staging.com", matrixDeviceId: "DEV", matrixUserId: "@batuhan:beeper-staging.com", @@ -184,7 +191,6 @@ describe("OpenClaw Beeper setup", () => { }); expect(result.config).toEqual({ - accessToken: "mx-token", appserviceId: "appservice-uuid", asToken: "as", bridgeId: "sh-openclaw-openclaw-device", @@ -195,44 +201,4 @@ describe("OpenClaw Beeper setup", () => { }); }); - it("combines Beeper access token introspection and appservice registration config", async () => { - const result = await setupOpenClawBeeperBridge({ - accessToken: "mx-token", - env: "staging", - openClawDeviceId: "OPENCLAW-DEVICE", - fetch: async () => new Response(JSON.stringify({ - device_id: "DEV", - user_id: "@batuhan:beeper-staging.com", - }), { status: 200 }), - createAppServiceInit: async (options) => { - expect(options).toMatchObject({ - baseDomain: "beeper-staging.com", - bridge: "sh-openclaw-openclaw-device", - token: "mx-token", - }); - return { - homeserver: "https://matrix.beeper-staging.com/_hungryserv/batuhan", - registration: { - asToken: "as", - hsToken: "hs", - id: "appservice-uuid", - namespaces: { aliases: [], rooms: [], users: [] }, - senderLocalpart: "sh-openclawbot", - url: "http://127.0.0.1:29391", - }, - }; - }, - }); - - expect(result.config).toEqual({ - accessToken: "mx-token", - appserviceId: "appservice-uuid", - asToken: "as", - bridgeId: "sh-openclaw-openclaw-device", - homeserver: "https://matrix.beeper-staging.com/_hungryserv/batuhan", - hsToken: "hs", - matrixDeviceId: "DEV", - matrixUserId: "@batuhan:beeper-staging.com", - }); - }); }); diff --git a/packages/openclaw/src/beeper-setup.ts b/packages/openclaw/src/beeper-setup.ts index 92da467..06ca74a 100644 --- a/packages/openclaw/src/beeper-setup.ts +++ b/packages/openclaw/src/beeper-setup.ts @@ -1,4 +1,5 @@ -import { getMatrixWhoami, type MatrixAppserviceInitOptions } from "@beeper/pickle"; +import type { MatrixAppserviceInitOptions } from "@beeper/pickle"; +import { loginWithMatrixPassword, type MatrixPasswordAuthOptions } from "@beeper/pickle/auth"; 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"; @@ -16,7 +17,6 @@ export interface BeeperSetupAccount { } export interface BeeperLoginForOpenClawOptions { - accessToken?: string; email?: string; env?: BeeperEnvironment; fetch?: typeof fetch; @@ -25,11 +25,14 @@ export interface BeeperLoginForOpenClawOptions { login?: (options: BeeperAuthOptions) => Promise; metadata?: Record; openClawDeviceId?: string; + password?: string; + passwordLogin?: (options: MatrixPasswordAuthOptions) => Promise; + username?: string; } export interface BeeperLoginForOpenClawResult { account: BeeperSetupAccount; - config: Pick; + config: Pick; } export interface CreateOpenClawBeeperAppServiceOptions { @@ -72,53 +75,50 @@ export interface SetupOpenClawBeeperBridgeOptions extends BeeperLoginForOpenClaw export interface SetupOpenClawBeeperBridgeResult { account: BeeperSetupAccount; - config: Pick; + config: Pick; init: MatrixAppserviceInitOptions; } export async function loginToBeeperForOpenClaw(options: BeeperLoginForOpenClawOptions): Promise { - if (options.accessToken) { - const fetchImpl = options.fetch ?? fetch; - const homeserver = beeperMatrixHomeserver(options.env); - const whoami = await getMatrixWhoami(fetchImpl, { - accessToken: options.accessToken, - homeserver, - deviceId: "", - userId: "", - }); - const account: BeeperSetupAccount = { - accessToken: options.accessToken, - deviceId: whoami.deviceId, - homeserver, - userId: whoami.userId, + const env = options.env ?? "production"; + const openClawDeviceId = options.openClawDeviceId ?? await resolveOpenClawDeviceId(); + const bridgeId = openClawBeeperBridgeId(openClawDeviceId); + const metadata = { ...options.metadata, bridge: bridgeId, bridgeType: DEFAULT_BEEPER_BRIDGE_TYPE, openClawDeviceId }; + if (options.username || options.password) { + if (!options.username || !options.password) throw new Error("Beeper username/password login requires both username and password"); + const login = options.passwordLogin ?? loginWithMatrixPassword; + const request: MatrixPasswordAuthOptions = { + homeserver: beeperMatrixHomeserver(env), + initialDeviceDisplayName: options.initialDeviceDisplayName ?? "Pickle OpenClaw", + metadata, + password: options.password, + username: options.username, }; + if (options.fetch !== undefined) request.fetch = options.fetch; + const account = await login(request); return { account, config: { - accessToken: account.accessToken, homeserver: account.homeserver, matrixDeviceId: account.deviceId, matrixUserId: account.userId, }, }; } - if (!options.email) throw new Error("Beeper setup requires email login or an access token"); + if (!options.email) throw new Error("Beeper setup requires email login or username/password login"); 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: bridgeId, bridgeType: DEFAULT_BEEPER_BRIDGE_TYPE, openClawDeviceId }, + metadata, + env, }; - 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, matrixDeviceId: account.deviceId, matrixUserId: account.userId, @@ -166,14 +166,15 @@ export async function createOpenClawBeeperAppService( export async function setupOpenClawBeeperBridge( options: SetupOpenClawBeeperBridgeOptions ): Promise { + const env = options.env ?? "production"; const openClawDeviceId = options.openClawDeviceId ?? await resolveOpenClawDeviceId(); - const login = await loginToBeeperForOpenClaw({ ...options, openClawDeviceId }); + const login = await loginToBeeperForOpenClaw({ ...options, env, openClawDeviceId }); const bridgeId = openClawBeeperBridgeId(openClawDeviceId); const appserviceOptions: CreateOpenClawBeeperAppServiceOptions = { accessToken: login.account.accessToken, bridge: bridgeId, }; - const baseDomain = beeperBaseDomain(options.env); + const baseDomain = beeperBaseDomain(env); if (baseDomain !== undefined) appserviceOptions.baseDomain = baseDomain; if (options.createAppServiceInit !== undefined) appserviceOptions.createAppServiceInit = options.createAppServiceInit; if (options.fetch !== undefined) appserviceOptions.fetch = options.fetch; diff --git a/packages/openclaw/src/beeper-turn-events.ts b/packages/openclaw/src/beeper-turn-events.ts index 1a776eb..c8f897e 100644 --- a/packages/openclaw/src/beeper-turn-events.ts +++ b/packages/openclaw/src/beeper-turn-events.ts @@ -155,20 +155,14 @@ 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.error !== undefined ? { error: stringifyToolValue(event.error) } : {}), ...(event.input !== undefined ? { input: event.input } : {}), - ...(result !== undefined ? { result: stringifyToolValue(result) } : {}), - state: event.state ?? "input-complete", + state: event.state ?? (event.error !== undefined ? "error" : "input-complete"), toolCallId: event.toolCallId, ...(event.toolName !== undefined ? { toolCallName: event.toolName, toolName: event.toolName } : {}), type: AGUIEventType.TOOL_CALL_END, @@ -211,6 +205,23 @@ export function mapOpenClawStep(event: { phase?: string; stepName: string }): AG ]; } +export function mapOpenClawActivitySnapshot( + state: StreamRunState, + event: { + activityType?: string; + content: Record; + replace?: boolean; + }, +): AGUIEvent[] { + return [{ + activityType: event.activityType ?? "activity", + content: event.content, + messageId: state.turnId, + ...(event.replace !== undefined ? { replace: event.replace } : {}), + type: "ACTIVITY_SNAPSHOT", + } as unknown as AGUIEvent]; +} + export function mapOpenClawStateDelta(delta: unknown): AGUIEvent[] { return [{ delta: Array.isArray(delta) ? delta : [{ op: "add", path: "/state", value: delta }], type: AGUIEventType.STATE_DELTA }]; } diff --git a/packages/openclaw/src/cli.test.ts b/packages/openclaw/src/cli.test.ts index 40fe77a..fd0ad14 100644 --- a/packages/openclaw/src/cli.test.ts +++ b/packages/openclaw/src/cli.test.ts @@ -33,7 +33,6 @@ describe("pickle-openclaw CLI", () => { userId: "@batuhan:beeper.com", }, config: { - accessToken: "mx-token", appserviceId: "sh-openclaw-device", asToken: "as-token", bridgeId: "sh-openclaw-device", @@ -77,7 +76,6 @@ describe("pickle-openclaw CLI", () => { 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", @@ -112,7 +110,6 @@ describe("pickle-openclaw CLI", () => { userId: "@alice:beeper.com", }, config: { - accessToken: "mx-token", appserviceId: "sh-openclaw-device", asToken: "as-token", bridgeId: "sh-openclaw-device", @@ -146,8 +143,8 @@ describe("pickle-openclaw CLI", () => { expect(io.stderrText).toContain("Enter Beeper login code:"); }); - it("can register from an existing Beeper access token without prompting for OTP", async () => { - const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-token-")); + it("can log in with username/password without prompting for OTP", async () => { + const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-password-")); const setupBridge = successfulSetupBridge(); const io = captureIO(); @@ -155,17 +152,20 @@ describe("pickle-openclaw CLI", () => { "login", "--config", join(dir, "config.json"), - "--access-token", - "mx-token", + "--username", + "batuhan", + "--password", + "secret", "--env", "staging", ], io, { setupBridge })).resolves.toBe(0); expect(setupBridge).toHaveBeenCalledWith(expect.objectContaining({ - accessToken: "mx-token", env: "staging", + password: "secret", push: false, selfHosted: true, + username: "batuhan", })); expect(setupBridge.mock.calls[0]?.[0]).not.toHaveProperty("getLoginCode"); expect(io.stderrText).not.toContain("Enter Beeper login code:"); @@ -178,11 +178,13 @@ describe("pickle-openclaw CLI", () => { "login", "--email", "you@example.com", - "--access-token", - "mx-token", + "--username", + "batuhan", + "--password", + "secret", ], io)).resolves.toBe(1); - expect(io.stderrText).toContain("Choose either --email or --access-token"); + expect(io.stderrText).toContain("Choose only one login method"); }); it("prints the saved Beeper bridge identity", async () => { @@ -234,7 +236,6 @@ function successfulSetupBridge() { userId: "@batuhan:beeper.com", }, config: { - accessToken: "mx-token", appserviceId: "sh-openclaw-device", asToken: "as-token", bridgeId: "sh-openclaw-device", diff --git a/packages/openclaw/src/cli.ts b/packages/openclaw/src/cli.ts index 61ce8c9..85e52bb 100644 --- a/packages/openclaw/src/cli.ts +++ b/packages/openclaw/src/cli.ts @@ -25,15 +25,19 @@ export async function runCli(argv = process.argv.slice(2), io: CliIO = process, if (command === "login") { const options = parseOptions(args); const email = stringOption(options, "email"); - const accessToken = stringOption(options, "access-token"); - if (!email && !accessToken) throw new Error("Missing required option --email or --access-token"); - if (email && accessToken) throw new Error("Choose either --email or --access-token, not both"); + const username = stringOption(options, "username"); + const password = stringOption(options, "password"); + const authMethods = [email, username || password].filter(Boolean).length; + if (authMethods === 0) throw new Error("Missing required option --email or --username/--password"); + if (authMethods > 1) throw new Error("Choose only one login method"); + if ((username && !password) || (password && !username)) throw new Error("Username/password login requires both --username and --password"); const setupOptions: Parameters[0] = { push: booleanOption(options, "push"), selfHosted: !booleanOption(options, "not-self-hosted"), }; if (email !== undefined) setupOptions.email = email; - if (accessToken !== undefined) setupOptions.accessToken = accessToken; + if (username !== undefined) setupOptions.username = username; + if (password !== undefined) setupOptions.password = password; const env = beeperEnvOption(options); if (env !== undefined) setupOptions.env = env; if (email !== undefined) setupOptions.getLoginCode = () => promptForLoginCode(io); @@ -74,7 +78,8 @@ function helpText(): string { " --config ", " --data-dir ", " --email
", - " --access-token ", + " --username ", + " --password ", " --env ", "", ].join("\n"); @@ -106,7 +111,6 @@ function whoamiPayload(config: OpenClawBridgeConfig): Record { beeperEnv: config.beeperEnv ?? "production", bridgeId: config.bridgeId ?? null, canConnect: Boolean( - config.accessToken && config.asToken && config.homeserver && config.hsToken && diff --git a/packages/openclaw/src/config.test.ts b/packages/openclaw/src/config.test.ts index ef23982..fb39c55 100644 --- a/packages/openclaw/src/config.test.ts +++ b/packages/openclaw/src/config.test.ts @@ -27,7 +27,7 @@ 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", + appserviceId: "sh-openclaw-openclaw-dev-123", bridgeId: "sh-openclaw-openclaw-dev-123", }); }); @@ -90,12 +90,12 @@ describe("OpenClaw bridge config", () => { 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, homeserver: "https://matrix.example" }); + const config = createDefaultConfig({ asToken: "as-secret", dataDir: dir, homeserver: "https://matrix.example", hsToken: "hs-secret" }); await writeConfig(config, path); expect(JSON.parse(await readFile(path, "utf8"))).toMatchObject({ - accessToken: "secret", asToken: "as-secret", homeserver: "https://matrix.example", + hsToken: "hs-secret", }); expect((await stat(path)).mode & 0o777).toBe(0o600); await expect(readConfig(path)).resolves.toMatchObject(config); diff --git a/packages/openclaw/src/config.ts b/packages/openclaw/src/config.ts index e31ec3a..739baad 100644 --- a/packages/openclaw/src/config.ts +++ b/packages/openclaw/src/config.ts @@ -20,20 +20,23 @@ 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 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 config: OpenClawBridgeConfig = { appserviceId: overrides.appserviceId ?? process.env.PICKLE_OPENCLAW_APPSERVICE_ID ?? process.env.PICKLE_OPENCLAW_APP_SERVICE_ID ?? + bridgeId ?? DEFAULT_APPSERVICE_ID, dataDir, + beeperEnv: overrides.beeperEnv ?? envBeeperEnv(process.env.PICKLE_OPENCLAW_BEEPER_ENV) ?? "production", }; - const accessToken = overrides.accessToken ?? process.env.PICKLE_OPENCLAW_ACCESS_TOKEN; const asToken = overrides.asToken ?? process.env.PICKLE_OPENCLAW_AS_TOKEN; - 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; - 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; @@ -44,9 +47,7 @@ export function createDefaultConfig(overrides: Partial = { const approvalBehavior = overrides.approvalBehavior ?? envApprovalBehavior(process.env.PICKLE_OPENCLAW_APPROVAL_BEHAVIOR); 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 (beeperEnv) config.beeperEnv = beeperEnv; if (bridgeId) config.bridgeId = bridgeId; if (bridgeManagerToken) config.bridgeManagerToken = bridgeManagerToken; if (homeserver) config.homeserver = homeserver; @@ -64,7 +65,13 @@ export function createDefaultConfig(overrides: Partial = { } export async function readConfig(path = defaultConfigPath()): Promise { - return createDefaultConfig(JSON.parse(await readFile(path, "utf8")) as Partial); + return createDefaultConfig(channelSettingsFromConfigInput(JSON.parse(await readFile(path, "utf8")))); +} + +function channelSettingsFromConfigInput(input: unknown): Partial { + const record = recordValue(input); + const beeper = recordValue(recordValue(record?.channels)?.beeper); + return (beeper ?? record ?? {}) as Partial; } export function createConfigFromOpenClawSetup( @@ -130,3 +137,8 @@ function envBeeperEnv(value: string | undefined): OpenClawBridgeConfig["beeperEn if (value === "production" || value === "staging" || value === "dev" || value === "local") return value; return undefined; } + +function recordValue(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + return value as Record; +} diff --git a/packages/openclaw/src/connector.test.ts b/packages/openclaw/src/connector.test.ts index ecf600c..3c281b9 100644 --- a/packages/openclaw/src/connector.test.ts +++ b/packages/openclaw/src/connector.test.ts @@ -27,7 +27,6 @@ describe("OpenClawBridgeConnector", () => { it("keeps Beeper Matrix tokens out of OpenClaw plugin login metadata", () => { expect(userLoginFromOpenClawConfig(createDefaultConfig({ - accessToken: "matrix-token", dataDir: "/tmp/openclaw", }))).toMatchObject({ id: "openclaw:plugin", @@ -86,7 +85,10 @@ describe("OpenClawBridgeConnector", () => { 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" }] } }, + responses: { + "agents.list": { agents: [{ id: "codex", name: "Codex" }] }, + "sessions.create": { key: "agent:codex:beeper:bootstrap" }, + }, }); const api = new OpenClawNetworkAPI({ config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), @@ -94,8 +96,8 @@ describe("OpenClawBridgeConnector", () => { registry, runtime, }); - const registerGhost = vi.fn(); - await api.connect({ bridge: { registerGhost }, queue: vi.fn(), queueRemoteEvent: vi.fn() } as unknown as Parameters[0]); + const { ctx, registerGhost } = connectContext(); + await api.connect(ctx); expect(registerGhost).toHaveBeenCalledWith({ displayName: "Codex", id: "codex", @@ -110,11 +112,90 @@ describe("OpenClawBridgeConnector", () => { }); }); + it("creates a default room and sends a Beeper-owner ping when no rooms exist", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-bootstrap-test.json"); + const runtime = runtimeWith({ + responses: { + "agents.list": { agents: [] }, + "sessions.create": { key: "agent:main:beeper:bootstrap" }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime, + }); + const { createPortal, ctx, registerPortal, sendMessage } = connectContext(); + + await api.connect(ctx); + + expect(createPortal).toHaveBeenCalledWith(login(), expect.objectContaining({ + creationContent: { "m.federate": false }, + name: "Main", + roomType: "dm", + sender: "@sh-openclaw_agent_main:localhost", + })); + expect(sendMessage).toHaveBeenCalledWith({ + content: { + body: "hey, are you alive? - sent from my beeper", + msgtype: "m.text", + }, + roomId: "!bootstrap:example.com", + userId: "@alice:example.com", + }); + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$bootstrap", + message: "hey, are you alive? - sent from my beeper", + sessionKey: "agent:main:beeper:bootstrap", + })); + expect(registerPortal).toHaveBeenCalledWith(expect.objectContaining({ + mxid: "!bootstrap:example.com", + portalKey: expect.objectContaining({ receiver: "openclaw:plugin" }), + })); + }); + + it("does not create a bootstrap room when a room binding already exists", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-bootstrap-existing-test.json"); + registry.upsertBinding({ + agentId: "main", + createdAt: 1, + ghostUserId: "@main:example.com", + id: "existing", + kind: "session", + owner: "bridge", + roomId: "!existing:example.com", + sessionKey: "agent:main:existing", + updatedAt: 1, + }); + const runtime = runtimeWith({ + responses: { "agents.list": { agents: [] } }, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime, + }); + const { createPortal, ctx, sendMessage } = connectContext(); + + await api.connect(ctx); + + expect(createPortal).not.toHaveBeenCalled(); + expect(sendMessage).not.toHaveBeenCalled(); + expect(runtime.sendMessage).not.toHaveBeenCalled(); + }); + 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: [] } } }); + const runtime = runtimeWith({ + responses: { + "agents.list": { agents: [] }, + "sessions.create": { key: "agent:codex:beeper:bootstrap" }, + }, + }); runtime.config.contactVisibility = "agents-and-users"; const api = new OpenClawNetworkAPI({ config: runtime.config, @@ -122,11 +203,16 @@ describe("OpenClawBridgeConnector", () => { registry, runtime, }); - const registerGhost = vi.fn(); - await api.connect({ bridge: { registerGhost }, queue: vi.fn(), queueRemoteEvent: vi.fn() } as unknown as Parameters[0]); + const { ctx, registerGhost } = connectContext(); + await api.connect(ctx); expect(registerGhost).toHaveBeenCalledWith(expect.objectContaining({ id: "alice", mxid: "@alice-ghost:example.com" })); - const hidden = runtimeWith({ responses: { "agents.list": { agents: [] } } }); + const hidden = runtimeWith({ + responses: { + "agents.list": { agents: [] }, + "sessions.create": { key: "agent:main:beeper:bootstrap" }, + }, + }); hidden.config.contactVisibility = "none"; const hiddenApi = new OpenClawNetworkAPI({ config: hidden.config, @@ -134,8 +220,8 @@ describe("OpenClawBridgeConnector", () => { registry, runtime: hidden, }); - const hiddenRegisterGhost = vi.fn(); - await hiddenApi.connect({ bridge: { registerGhost: hiddenRegisterGhost }, queue: vi.fn(), queueRemoteEvent: vi.fn() } as unknown as Parameters[0]); + const { ctx: hiddenCtx, registerGhost: hiddenRegisterGhost } = connectContext(); + await hiddenApi.connect(hiddenCtx); expect(hiddenRegisterGhost).not.toHaveBeenCalled(); }); @@ -229,6 +315,7 @@ describe("OpenClawBridgeConnector", () => { }, name: "Codex", roomType: "dm", + sender: "@codex:example.com", }); expect(registry.getBindingByRoom("!codex-dm:example.com")).toMatchObject({ agentId: "codex", @@ -1251,7 +1338,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(["@sh-openclawbot:localhost", "@codex:example.com"]); + expect(response.messages.map((message) => message.event.getSender().sender)).toEqual(["@alice:example.com", "@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), @@ -1267,6 +1354,35 @@ function login(): UserLogin { return { id: "openclaw:plugin", metadata: {}, userId: "@alice:example.com" }; } +function connectContext() { + const registerGhost = vi.fn(); + const registerPortal = vi.fn(); + const createPortal = vi.fn(async (_login: UserLogin, portal: { id: string; metadata?: unknown; portalKey?: { id: string; receiver?: string } }) => ({ + ...portal, + mxid: "!bootstrap:example.com", + portalKey: { id: portal.id, receiver: "openclaw:plugin" }, + receiver: "openclaw:plugin", + })); + const sendMessage = vi.fn(async () => ({ + eventId: "$bootstrap", + raw: {}, + roomId: "!bootstrap:example.com", + })); + return { + createPortal, + ctx: { + bridge: { createPortal, registerGhost, registerPortal }, + client: { appservice: { sendMessage } }, + log: vi.fn(), + queue: vi.fn(), + queueRemoteEvent: vi.fn(), + } as unknown as Parameters[0], + registerGhost, + registerPortal, + sendMessage, + }; +} + function runtimeWith(options: { events?: OpenClawGatewayEvent[]; responses: Record; diff --git a/packages/openclaw/src/connector.ts b/packages/openclaw/src/connector.ts index c75ac69..b517d44 100644 --- a/packages/openclaw/src/connector.ts +++ b/packages/openclaw/src/connector.ts @@ -81,14 +81,23 @@ import { matrixDomainFromHomeserver } from "./rooms"; import type { OpenClawAgentContact, OpenClawBridgeConfig, OpenClawSessionBinding, OpenClawUserContact } from "./types"; const DEFAULT_NEW_SESSION_LABEL = "New OpenClaw Session"; +const DEFAULT_BOOTSTRAP_MESSAGE = "hey, are you alive? - sent from my beeper"; export interface OpenClawConnectorOptions { config?: OpenClawBridgeConfig; + onActivity?: (patch: OpenClawBridgeActivityPatch) => void; registry?: OpenClawBridgeRegistry; runtime?: OpenClawPluginRuntimeAdapter | OpenClawHostRuntime; runtimeFactory?: (config: OpenClawBridgeConfig) => OpenClawPluginRuntimeAdapter; } +export type OpenClawBridgeActivityPatch = { + lastEventAt?: number; + lastInboundAt?: number; + lastOutboundAt?: number; + lastTransportActivityAt?: number; +}; + export function createOpenClawConnector(options: OpenClawConnectorOptions = {}): OpenClawBridgeConnector { return new OpenClawBridgeConnector(options); } @@ -99,11 +108,13 @@ export class OpenClawBridgeConnector implements BridgeConnector void) | undefined; #runtimeFactory: (config: OpenClawBridgeConfig) => OpenClawPluginRuntimeAdapter; constructor(options: OpenClawConnectorOptions = {}) { this.config = options.config ?? createDefaultConfig(); this.registry = options.registry ?? new OpenClawBridgeRegistry(); + this.#onActivity = options.onActivity; this.#hostRuntime = options.runtime && !(options.runtime instanceof OpenClawPluginRuntimeAdapter) ? options.runtime : undefined; @@ -180,6 +191,7 @@ export class OpenClawBridgeConnector implements BridgeConnector this.registry.getBindingBySessionKey(sessionKey), login, log: (level, message, data) => ctx.log(level, message, data), + ...(this.#onActivity ? { onActivity: this.#onActivity } : {}), ...(ownUserId ? { userId: ownUserId } : {}), }); this.#channelRuntime = channelRuntime; @@ -205,6 +217,7 @@ export class OpenClawBridgeConnector implements BridgeConnector void) | undefined; readonly #registry: OpenClawBridgeRegistry; readonly #runtime: OpenClawBridgeRuntime; constructor(options: { config: OpenClawBridgeConfig; login: UserLogin; + onActivity?: (patch: OpenClawBridgeActivityPatch) => void; registry: OpenClawBridgeRegistry; runtime: OpenClawBridgeRuntime; sendTurn?: (options: OpenClawSessionSendOptions) => Promise; }) { this.#config = options.config; this.#login = options.login; + this.#onActivity = options.onActivity; this.#registry = options.registry; this.#runtime = options.runtime; this.#agent = new OpenClawMatrixBridgeAgent({ @@ -250,6 +266,7 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor async connect(ctx: ConnectContext): Promise { await this.#agent.syncAgentContacts(); + const bootstrapContact = this.bootstrapAgentContact(); const contactVisibility = this.#runtime.config.contactVisibility ?? "agents"; if (contactVisibility !== "none") { for (const contact of this.#registry.data.agents) { @@ -271,6 +288,7 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor }); } } + await this.ensureBootstrapRoom(ctx, bootstrapContact); } async disconnect(): Promise { @@ -290,6 +308,7 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor metadata: portal.metadata, name: contact.displayName, roomType: "dm", + sender: contact.ghostUserId, }; const creationContent = openClawPortalCreationContent(this.#runtime.config); if (creationContent) portalOptions.creationContent = creationContent; @@ -337,6 +356,7 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor this.logRejectedMatrixIngress(ctx, "message", msg.portal.mxid, msg.sender.userId); return { pending: false }; } + this.#onActivity?.(inboundActivityPatch()); const binding = bindingFromPortal(msg.portal, this.#runtime.config); if (binding && !this.#registry.getBindingByRoom(msg.portal.mxid ?? "")) this.#registry.upsertBinding(binding); let currentBinding = msg.portal.mxid ? this.#registry.getBindingByRoom(msg.portal.mxid) ?? binding : binding; @@ -580,7 +600,7 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor portalKey: params.portal.portalKey, sender: { isFromMe: message.sender === "agent", - sender: backfillSenderUserId(this.#runtime.config, binding, message.sender), + sender: backfillSenderUserId(this.#runtime.config, this.#login, binding, message.sender), }, timestamp: message.timestamp ?? new Date(0), }), @@ -701,6 +721,76 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor }); return portalForAgentSession(contact, this.#login.id, session.key, label); } + + private bootstrapAgentContact(): OpenClawAgentContact { + const contact = + this.#registry.getAgent("main") ?? + this.#registry.data.agents[0] ?? + agentContactFromOpenClawAgent(this.#runtime.config, { id: "main", name: "Main" }); + if (!this.#registry.getAgent(contact.agentId)) this.#registry.upsertAgent(contact); + return contact; + } + + private async ensureBootstrapRoom(ctx: ConnectContext, contact: OpenClawAgentContact): Promise { + if (this.#registry.data.bindings.length > 0) return; + let portal = await this.createSessionPortalForAgent(ctx, contact, "OpenClaw"); + const portalOptions: Parameters[1] = { + id: portal.id, + metadata: portal.metadata, + name: contact.displayName, + roomType: "dm", + sender: contact.ghostUserId, + }; + const creationContent = openClawPortalCreationContent(this.#runtime.config); + if (creationContent) portalOptions.creationContent = creationContent; + const created = await ctx.bridge.createPortal(this.#login, portalOptions); + portal = { + ...portal, + ...created, + metadata: created.metadata ?? portal.metadata, + portalKey: created.portalKey ?? portal.portalKey, + }; + const receiver = created.receiver ?? portal.receiver; + if (receiver !== undefined) portal.receiver = receiver; + this.upsertPortalBinding(portal); + const binding = portal.mxid ? this.#registry.getBindingByRoom(portal.mxid) : undefined; + if (!portal.mxid || !binding) throw new Error("OpenClaw Beeper bootstrap room was created without a bound Matrix room"); + this.registerCanonicalPortalForBinding(ctx, portal, binding); + const sender = this.#login.userId ?? userLoginFromOpenClawConfig(this.#runtime.config).userId ?? serviceBotUserId(this.#runtime.config); + const sent = await ctx.client.appservice.sendMessage({ + content: { + body: DEFAULT_BOOTSTRAP_MESSAGE, + msgtype: "m.text", + }, + roomId: portal.mxid, + userId: sender, + }); + this.#onActivity?.(outboundActivityPatch()); + await this.#agent.handleMatrixText({ + eventId: sent.eventId, + matrix: { sender }, + roomId: portal.mxid, + sender, + text: DEFAULT_BOOTSTRAP_MESSAGE, + }); + await this.#registry.save(); + } +} + +function inboundActivityPatch(now = Date.now()): OpenClawBridgeActivityPatch { + return { + lastEventAt: now, + lastInboundAt: now, + lastTransportActivityAt: now, + }; +} + +function outboundActivityPatch(now = Date.now()): OpenClawBridgeActivityPatch { + return { + lastEventAt: now, + lastOutboundAt: now, + lastTransportActivityAt: now, + }; } function newBeeperSessionKey(agentId: string): string { @@ -910,11 +1000,12 @@ function agentIdFromSessionKey(sessionKey: string | undefined): string | undefin function backfillSenderUserId( config: OpenClawBridgeConfig, + login: UserLogin, binding: OpenClawSessionBinding, sender: "agent" | "human" | "system" ): string { if (sender === "agent") return binding.ghostUserId; - if (sender === "human") return binding.humanGhostUserId ?? serviceBotUserId(config); + if (sender === "human") return login.userId ?? binding.humanGhostUserId ?? serviceBotUserId(config); return serviceBotUserId(config); } diff --git a/packages/openclaw/src/integration.test.ts b/packages/openclaw/src/integration.test.ts index ab20353..aa06f1d 100644 --- a/packages/openclaw/src/integration.test.ts +++ b/packages/openclaw/src/integration.test.ts @@ -243,9 +243,10 @@ describe("OpenClaw bridge integration", () => { 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", + asToken: "as-token", dataDir: dir, homeserver: "https://matrix.example", + hsToken: "hs-token", importSources: ["dashboard"], matrixDeviceId: "DEVICE", matrixUserId: "@sh-openclawbot:example", diff --git a/packages/openclaw/src/openclaw-extension.test.ts b/packages/openclaw/src/openclaw-extension.test.ts index 0440006..c599a52 100644 --- a/packages/openclaw/src/openclaw-extension.test.ts +++ b/packages/openclaw/src/openclaw-extension.test.ts @@ -115,6 +115,7 @@ describe("OpenClaw plugin package metadata", () => { 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_ACCESS_TOKEN"); expect(manifest.channelEnvVars?.beeper).not.toContain("PICKLE_OPENCLAW_GATEWAY_ACCESS_TOKEN"); expect(manifest.channelEnvVars?.beeper).not.toContain("OPENCLAW_GATEWAY_TOKEN"); expect(manifest.uiHints).toBeUndefined(); @@ -132,13 +133,12 @@ describe("OpenClaw plugin package metadata", () => { }, schema: { properties: expect.objectContaining({ - accessToken: expect.any(Object), importSources: expect.any(Object), }), }, - uiHints: { - accessToken: { sensitive: true }, - }, + uiHints: expect.not.objectContaining({ + accessToken: expect.anything(), + }), }); }); diff --git a/packages/openclaw/src/openclaw-runtime.test.ts b/packages/openclaw/src/openclaw-runtime.test.ts index f9dbf90..d5b5964 100644 --- a/packages/openclaw/src/openclaw-runtime.test.ts +++ b/packages/openclaw/src/openclaw-runtime.test.ts @@ -49,7 +49,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { sessionId: "session_1", }); await expect(runtime.sendMessage({ message: "hello", sessionKey: "agent:codex:main", timeoutMs: 1000 })) - .rejects.toThrow("OpenClaw Beeper turns require OpenClaw channel turn helpers"); + .rejects.toThrow("OpenClaw Beeper turns require OpenClaw channel inbound helpers"); }); it("filters gateway events by run id and resolves approvals", async () => { @@ -132,7 +132,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { const runDone = new Promise((resolve) => { resolveRun = resolve; }); - const runAssembled = vi.fn(async (params: Record) => { + const dispatchReply = vi.fn(async (params: Record) => { const delivery = params.delivery as { deliver?: (payload: unknown, info?: unknown) => Promise }; await delivery.deliver?.("direct final", { kind: "final" }); resolveRun?.(); @@ -145,9 +145,9 @@ describe("OpenClawPluginRuntimeAdapter", () => { recordInboundSession: vi.fn(), resolveStorePath: () => "/tmp/openclaw", }, - turn: { + inbound: { buildContext: vi.fn((params) => params), - runAssembled, + dispatchReply, }, }, config: { current: () => ({ agents: { list: [{ id: "main" }] } }) }, @@ -174,7 +174,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { expect(sent.runId).toMatch(/^beeper:/u); await runDone; expect(request).not.toHaveBeenCalled(); - expect(runAssembled).toHaveBeenCalledTimes(1); + expect(dispatchReply).toHaveBeenCalledTimes(1); expect(aiRunStreams.start).toHaveBeenCalledTimes(1); expect(aiRunStreams.finish).toHaveBeenCalledWith(expect.objectContaining({ runId: sent.runId, @@ -252,10 +252,10 @@ describe("OpenClawPluginRuntimeAdapter", () => { sessionKey: "agent:main:beeper:room", message: "from Beeper", idempotencyKey: "$event", - })).rejects.toThrow("OpenClaw Beeper requires OpenClaw channel turn helpers"); + })).rejects.toThrow("OpenClaw Beeper requires OpenClaw channel inbound helpers"); }); - it("runs Beeper-originated sends through OpenClaw channel turn helpers for live AG-UI progress", async () => { + it("runs Beeper-originated sends through OpenClaw channel inbound helpers for live AG-UI progress", async () => { const beeperStreams = { finalizeMessage: vi.fn(async () => ({ eventId: "$stream-root", @@ -271,7 +271,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { })), }; const aiRunStreams = createTestBeeperAIRunStreams(); - const runAssembled = vi.fn(async (params: Record) => { + const dispatchReply = 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", toolCallId: "real-tool-id" }); @@ -296,7 +296,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { recordInboundSession: vi.fn(), resolveStorePath: () => "/tmp/sessions.json", }, - turn: { + inbound: { buildContext: (params: Record) => ({ Body: "from Beeper", BodyForAgent: "from Beeper", @@ -305,7 +305,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { SessionKey: (params.route as { routeSessionKey?: string }).routeSessionKey, To: "beeper", }), - runAssembled, + dispatchReply, }, }, config: { current: () => ({ agents: { list: [{ id: "main" }] } }) }, @@ -339,13 +339,13 @@ describe("OpenClawPluginRuntimeAdapter", () => { observedRunId = (sent as { runId?: string }).runId; await done; - expect(runAssembled).toHaveBeenCalledWith(expect.objectContaining({ + expect(dispatchReply).toHaveBeenCalledWith(expect.objectContaining({ accountId: "beeper", agentId: "main", channel: "beeper", routeSessionKey: "agent:main:beeper:room", })); - expect((runAssembled.mock.calls[0]?.[0] as { replyOptions?: Record } | undefined)?.replyOptions).toMatchObject({ + expect((dispatchReply.mock.calls[0]?.[0] as { replyOptions?: Record } | undefined)?.replyOptions).toMatchObject({ disableBlockStreaming: false, sourceReplyDeliveryMode: "automatic", }); @@ -407,7 +407,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { })), }; const aiRunStreams = createTestBeeperAIRunStreams(); - const runAssembled = vi.fn(async (params: Record) => { + const dispatchReply = vi.fn(async (params: Record) => { const replyOptions = params.replyOptions as Record void | Promise>; await replyOptions.onPartialReply?.({ text: "hel" }); await replyOptions.onBlockReplyQueued?.({ text: "hel" }); @@ -425,7 +425,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { channel: { reply: { dispatchReplyWithBufferedBlockDispatcher: vi.fn() }, session: { recordInboundSession: vi.fn(), resolveStorePath: () => "/tmp/sessions.json" }, - turn: { + inbound: { buildContext: (params: Record) => ({ Body: "from Beeper", BodyForAgent: "from Beeper", @@ -434,7 +434,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { SessionKey: (params.route as { routeSessionKey?: string }).routeSessionKey, To: "beeper", }), - runAssembled, + dispatchReply, }, }, config: { current: () => ({ agents: { list: [{ id: "main" }] } }) }, @@ -473,6 +473,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { 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"], + ["tool-b", "{\"ok\":true}", "complete"], ]); expect(parts.filter((part) => part.type === "TOOL_CALL_END").map((part) => [part.toolCallId, part.toolName])).toEqual([ ["tool-a", "read_file"], @@ -498,9 +499,11 @@ describe("OpenClawPluginRuntimeAdapter", () => { }; const aiRunStreams = createTestBeeperAIRunStreams(); let agentEventListener: ((event: { data?: Record; runId?: string; sessionKey?: string; stream?: string }) => void) | undefined; - const runAssembled = vi.fn(async (params: Record) => { + const dispatchReply = vi.fn(async (params: Record) => { const replyOptions = params.replyOptions as { runId?: string }; const sessionKey = params.routeSessionKey as string; + agentEventListener?.({ data: { text: "Working..." }, runId: replyOptions.runId, stream: "assistant" }); + agentEventListener?.({ data: { name: "search", phase: "running", text: "Searching docs", type: "tool.progress" }, runId: replyOptions.runId, stream: "tool.progress" }); agentEventListener?.({ data: { delta: "hel", text: "hel" }, runId: replyOptions.runId, stream: "assistant" }); agentEventListener?.({ data: { delta: "lo", text: "hello" }, sessionKey, stream: "assistant" }); agentEventListener?.({ data: { itemId: "user-message", phase: "start", type: "userMessage" }, runId: replyOptions.runId, stream: "codex_app_server.item" }); @@ -510,6 +513,31 @@ describe("OpenClawPluginRuntimeAdapter", () => { agentEventListener?.({ data: { itemId: "codex-tool", phase: "finished", type: "tool_call" }, runId: replyOptions.runId, stream: "codex_app_server.item" }); agentEventListener?.({ data: { args: { query: "docs" }, name: "search", phase: "start", toolCallId: "tool-stream" }, runId: replyOptions.runId, stream: "tool" }); agentEventListener?.({ data: { name: "search", phase: "result", result: "found docs", toolCallId: "tool-stream" }, runId: replyOptions.runId, stream: "tool" }); + agentEventListener?.({ + data: { + metadata: { source: "codex" }, + phase: "start", + providerExecuted: true, + startedAtMs: 123, + toolCall: { arguments: "{\"query\":\"openclaw\"}", id: "nested-tool", name: "search" }, + }, + runId: replyOptions.runId, + stream: "tool", + }); + agentEventListener?.({ + data: { + call: { id: "nested-tool", name: "search" }, + completedAtMs: 456, + phase: "result", + providerExecuted: true, + result: { items: [{ title: "OpenClaw", url: "https://example.com/openclaw" }] }, + }, + runId: replyOptions.runId, + stream: "tool", + }); + agentEventListener?.({ data: { name: "bash", phase: "start", toolCallId: "delta-tool" }, runId: replyOptions.runId, stream: "tool" }); + agentEventListener?.({ data: { delta: "{\"cmd\":\"pwd\"}", name: "bash", phase: "input_delta", toolCallId: "delta-tool" }, runId: replyOptions.runId, stream: "tool" }); + agentEventListener?.({ data: { name: "bash", output: "/tmp/project", phase: "finished", toolCallId: "delta-tool" }, runId: replyOptions.runId, stream: "tool" }); agentEventListener?.({ data: { 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" }); @@ -525,7 +553,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { channel: { reply: { dispatchReplyWithBufferedBlockDispatcher: vi.fn() }, session: { recordInboundSession: vi.fn(), resolveStorePath: () => "/tmp/sessions.json" }, - turn: { + inbound: { buildContext: (params: Record) => ({ Body: "from Beeper", BodyForAgent: "from Beeper", @@ -534,7 +562,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { SessionKey: (params.route as { routeSessionKey?: string }).routeSessionKey, To: "beeper", }), - runAssembled, + dispatchReply, }, }, config: { current: () => ({ agents: { list: [{ id: "main" }] } }) }, @@ -577,15 +605,24 @@ 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({ content: { state: "running", text: "Working..." }, type: "ACTIVITY_SNAPSHOT" }), + expect.objectContaining({ activityType: "tool.progress", content: expect.objectContaining({ label: "search", phase: "running", text: "Searching docs" }), type: "ACTIVITY_SNAPSHOT" }), expect.objectContaining({ toolCallId: "tool-stream", toolName: "search", type: "TOOL_CALL_START" }), expect.objectContaining({ toolCallId: "tool-stream", toolName: "search", type: "TOOL_CALL_END" }), + expect.objectContaining({ metadata: { source: "codex" }, providerExecuted: true, startedAtMs: 123, toolCallId: "nested-tool", toolName: "search", type: "TOOL_CALL_START" }), + expect.objectContaining({ delta: "{\"query\":\"openclaw\"}", toolCallId: "nested-tool", type: "TOOL_CALL_ARGS" }), + expect.objectContaining({ input: { query: "openclaw" }, toolCallId: "nested-tool", toolName: "search", type: "TOOL_CALL_END" }), + expect.objectContaining({ completedAtMs: 456, content: "{\"items\":[{\"title\":\"OpenClaw\",\"url\":\"https://example.com/openclaw\"}]}", providerExecuted: true, state: "complete", toolCallId: "nested-tool", toolName: "search", type: "TOOL_CALL_RESULT" }), + expect.objectContaining({ name: "com.beeper.source", type: "CUSTOM", value: expect.objectContaining({ sourceId: "https://example.com/openclaw", title: "OpenClaw", url: "https://example.com/openclaw" }) }), + expect.objectContaining({ delta: "{\"cmd\":\"pwd\"}", toolCallId: "delta-tool", type: "TOOL_CALL_ARGS" }), + expect.objectContaining({ content: "/tmp/project", state: "complete", toolCallId: "delta-tool", toolName: "bash", type: "TOOL_CALL_RESULT" }), 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" } }), + expect.objectContaining({ name: "com.beeper.source", type: "CUSTOM", value: expect.objectContaining({ sourceId: "https://example.com", title: "Docs", url: "https://example.com" }) }), + expect.objectContaining({ name: "com.beeper.file", type: "CUSTOM", value: { id: "file_1", title: "report.txt" } }), + expect.objectContaining({ name: "com.beeper.data", type: "CUSTOM", value: { name: "openclaw.data", value: { status: "indexed" } } }), expect.objectContaining({ snapshot: { phase: "retrieval" }, type: "STATE_SNAPSHOT" }), ])); expect(parts).not.toEqual(expect.arrayContaining([ diff --git a/packages/openclaw/src/openclaw-runtime.ts b/packages/openclaw/src/openclaw-runtime.ts index e1bdad1..35f2bdc 100644 --- a/packages/openclaw/src/openclaw-runtime.ts +++ b/packages/openclaw/src/openclaw-runtime.ts @@ -9,6 +9,7 @@ import { AGUIEventType, closeReasoningPart, createStreamRunState, + mapOpenClawActivitySnapshot, mapOpenClawApprovalRequest, mapOpenClawApprovalResponse, mapOpenClawCustom, @@ -18,6 +19,7 @@ import { mapOpenClawStateSnapshot, mapOpenClawToolEnd, mapOpenClawToolInput, + mapOpenClawToolInputDelta, mapOpenClawToolOutput, } from "./beeper-turn-events"; import type { AGUIEvent } from "./beeper-turn-events"; @@ -52,6 +54,10 @@ export interface OpenClawHostRuntime { }; }; channel?: { + inbound?: { + buildContext?: (params: Record) => Record; + dispatchReply?: (params: Record) => Promise; + }; reply?: { dispatchReplyWithBufferedBlockDispatcher?: (params: Record) => Promise; }; @@ -59,10 +65,6 @@ export interface OpenClawHostRuntime { 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?: { @@ -314,7 +316,7 @@ export class OpenClawPluginRuntimeAdapter { if (this.transport instanceof OpenClawHostRuntimeAdapter) { return this.transport.sendMessage(options, requestOptions); } - throw new Error("OpenClaw Beeper turns require OpenClaw channel turn helpers"); + throw new Error("OpenClaw Beeper turns require OpenClaw channel inbound helpers"); } async resolveApproval(payload: OpenClawApprovalResolvePayload): Promise { @@ -376,7 +378,7 @@ export class OpenClawHostRuntimeAdapter implements OpenClawRuntimeRequestSurface }, requestOptions); const record = recordValue(raw) ?? {}; const runId = stringValue(record.runId); - if (!runId) throw new Error("OpenClaw channel turn did not return a runId"); + if (!runId) throw new Error("OpenClaw channel inbound turn did not return a runId"); return { raw, runId, sessionKey: stringValue(record.sessionKey) ?? options.sessionKey }; } @@ -684,8 +686,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 channel turn requires session key"); - if (!message) throw new Error("OpenClaw channel turn requires message"); + if (!sessionKey) throw new Error("OpenClaw channel inbound turn requires session key"); + if (!message) throw new Error("OpenClaw channel inbound turn requires message"); const agentId = agentIdFromSessionKey(sessionKey) ?? "main"; const resolved = resolvePluginSession(runtime, sessionKey, agentId); const entry = resolved.entry ?? {}; @@ -694,7 +696,7 @@ async function sendSessionInPluginRuntime( const runId = `beeper:${randomUUID()}`; const cfg = runtime.config?.current?.(); if (!canRunNativeChannelTurn(runtime)) { - throw new Error("OpenClaw Beeper requires OpenClaw channel turn helpers (runtime.channel.turn, runtime.channel.reply, and runtime.channel.session)"); + throw new Error("OpenClaw Beeper requires OpenClaw channel inbound helpers (runtime.channel.inbound, runtime.channel.reply, and runtime.channel.session)"); } const timeoutMs = options?.timeoutMs ?? numberValue(record.timeoutMs) ?? runtime.agent?.resolveAgentTimeoutMs?.({ cfg }) ?? 48 * 60 * 60 * 1000; startPluginRun(localEvents, { @@ -739,8 +741,8 @@ function startPluginRun( function canRunNativeChannelTurn(runtime: OpenClawHostRuntime): boolean { return Boolean( - runtime.channel?.turn?.buildContext && - runtime.channel.turn.runAssembled && + runtime.channel?.inbound?.buildContext && + runtime.channel.inbound.dispatchReply && runtime.channel.session?.recordInboundSession && runtime.channel.reply?.dispatchReplyWithBufferedBlockDispatcher, ); @@ -759,11 +761,11 @@ async function runBeeperChannelTurnInPluginRuntime(params: { sessionKey: string; timeoutMs: number; }): Promise { - const turn = params.runtime.channel?.turn; + const inbound = params.runtime.channel?.inbound; 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"); + if (!inbound?.buildContext || !inbound.dispatchReply || !channelSession?.recordInboundSession || !channelReply?.dispatchReplyWithBufferedBlockDispatcher) { + throw new Error("OpenClaw plugin runtime channel inbound helpers are incomplete"); } const sender = recordValue(recordValue(params.record.matrix)?.sender) ?? {}; @@ -778,7 +780,7 @@ async function runBeeperChannelTurnInPluginRuntime(params: { 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({ + const ctxPayload = inbound.buildContext({ channel: "beeper", accountId: "beeper", provider: "beeper", @@ -876,6 +878,17 @@ async function runBeeperChannelTurnInPluginRuntime(params: { sessionKey: params.sessionKey, stream, }); + let streamCallbackTail = Promise.resolve(); + const enqueueStream = (operation: () => Promise) => { + streamCallbackTail = streamCallbackTail + .catch(() => undefined) + .then(operation); + stream.trackExternal(streamCallbackTail); + return streamCallbackTail; + }; + const scheduleStream = (operation: () => Promise) => { + enqueueStream(operation); + }; let streamStartError: unknown; try { params.localEvents.emit({ event: "stream.starting", payload: { agentId: params.agentId, roomId, runId: params.runId, sessionId: params.sessionId, sessionKey: params.sessionKey } }); @@ -887,7 +900,7 @@ async function runBeeperChannelTurnInPluginRuntime(params: { streamStartError = error; }, ); - await turn.runAssembled({ + await inbound.dispatchReply({ cfg: params.cfg, channel: "beeper", accountId: "beeper", @@ -899,8 +912,9 @@ async function runBeeperChannelTurnInPluginRuntime(params: { dispatchReplyWithBufferedBlockDispatcher: channelReply.dispatchReplyWithBufferedBlockDispatcher, delivery: { deliver: async (payload: unknown, info?: unknown) => { - await stream.textPayload(payload, stringValue(recordValue(info)?.kind) === "final" ? "final" : "block"); - if (stringValue(recordValue(info)?.kind) === "final") await stream.finish(payload); + const final = stringValue(recordValue(info)?.kind) === "final"; + enqueueStream(() => stream.textPayload(payload, final ? "final" : "block")); + if (final) await stream.finish(); return { visibleReplySent: true }; }, onError: async (error: unknown) => { @@ -916,20 +930,20 @@ async function runBeeperChannelTurnInPluginRuntime(params: { suppressDefaultToolProgressMessages: true, allowProgressCallbacksWhenSourceDeliverySuppressed: true, onAssistantMessageStart: stream.assistantMessageStart, - 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, - 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" }), + onBlockReply: (payload: unknown) => scheduleStream(() => stream.textPayload(payload, "block")), + onBlockReplyQueued: (payload: unknown) => scheduleStream(() => stream.textPayload(payload, "block")), + onPartialReply: (payload: unknown) => scheduleStream(() => stream.textPayload(payload, "partial")), + onReasoningEnd: () => scheduleStream(() => stream.reasoningEnd()), + onReasoningStream: (payload: unknown) => scheduleStream(() => stream.reasoningPayload(payload)), + onToolStart: (payload: unknown) => scheduleStream(() => stream.toolStart(payload)), + onToolResult: (payload: unknown) => scheduleStream(() => stream.toolResult(payload)), + onItemEvent: (payload: unknown) => scheduleStream(() => stream.itemEvent(payload)), + onPlanUpdate: (payload: unknown) => scheduleStream(() => stream.planUpdate(payload)), + onApprovalEvent: (payload: unknown) => scheduleStream(() => stream.approvalEvent(payload)), + onCommandOutput: (payload: unknown) => scheduleStream(() => stream.commandOutput(payload)), + onPatchSummary: (payload: unknown) => scheduleStream(() => stream.patchSummary(payload)), + onCompactionStart: () => scheduleStream(() => stream.itemEvent({ kind: "compaction", phase: "start", title: "Compacting context" })), + onCompactionEnd: () => scheduleStream(() => stream.itemEvent({ kind: "compaction", phase: "complete", title: "Compacted context" })), }, record: { createIfMissing: true, @@ -979,7 +993,7 @@ function forwardAgentRuntimeStreamEvents(params: { return onAgentEvent((event) => { const data = recordValue(event.data) ?? {}; const matched = matchesAgentStreamEvent({ data, event, runId: params.runId, sessionKey: params.sessionKey }); - const stream = normalizeAgentStream(event.stream); + const stream = normalizeAgentStream(event.stream) ?? stringValue(data.type); params.stream.debug("openclaw_beeper_agent_event_seen", { dataKeys: Object.keys(data).slice(0, 12), eventRunId: stringValue(event.runId) ?? stringValue(data.runId) ?? stringValue(data.run_id), @@ -994,6 +1008,22 @@ function forwardAgentRuntimeStreamEvents(params: { case "assistant": track(params.stream.textPayload(data, "partial")); break; + case "run.progress": + case "tool.progress": + case "tool_progress": + track(params.stream.activity({ + ...data, + activityType: stream, + text: toolProgressText(data), + })); + break; + case "lifecycle": + case "metadata": + case "model": + case "usage": + case "context": + track(params.stream.lifecycleEvent(data)); + break; case "thinking": case "reasoning": track(params.stream.reasoningPayload(data)); @@ -1001,6 +1031,8 @@ function forwardAgentRuntimeStreamEvents(params: { case "tool": if (stringValue(data.phase) === "start") { track(params.stream.toolStart(data)); + } else if (isToolInputDeltaPhase(stringValue(data.phase)) || stringValue(data.inputTextDelta) || stringValue(data.argsDelta) || stringValue(data.argumentsDelta)) { + track(params.stream.toolInputDelta(data)); } else if (stringValue(data.phase) === "result" || isCompletePhase(stringValue(data.phase))) { track(params.stream.toolResult(data)); } else { @@ -1039,7 +1071,7 @@ function forwardAgentRuntimeStreamEvents(params: { case "files": case "document": case "documents": - track(params.stream.customData("file", data)); + track(params.stream.customData(stream, data)); break; case "data": track(params.stream.customData("data", data)); @@ -1075,6 +1107,246 @@ function specificToolName(value: string | undefined): string | undefined { return value; } +function toolCallIdFromPayload(data: Record): string | undefined { + const toolCall = toolCallRecordFromPayload(data); + return stringValue(data.toolCallId) + ?? stringValue(data.callId) + ?? stringValue(data.id) + ?? stringValue(toolCall?.toolCallId) + ?? stringValue(toolCall?.callId) + ?? stringValue(toolCall?.id); +} + +function toolNameFromPayload(data: Record): string | undefined { + const toolCall = toolCallRecordFromPayload(data); + const fn = recordValue(toolCall?.function) ?? recordValue(data.function); + return stringValue(data.toolName) + ?? stringValue(data.name) + ?? stringValue(data.command) + ?? stringValue(toolCall?.toolName) + ?? stringValue(toolCall?.name) + ?? stringValue(fn?.name); +} + +function toolInputFromPayload(data: Record): unknown { + const toolCall = toolCallRecordFromPayload(data); + const fn = recordValue(toolCall?.function) ?? recordValue(data.function); + const value = data.args + ?? data.input + ?? data.arguments + ?? data.parameters + ?? toolCall?.args + ?? toolCall?.input + ?? toolCall?.arguments + ?? fn?.arguments; + return typeof value === "string" ? parseMaybeJSONValue(value) : value; +} + +function toolOutputFromPayload(data: Record, fallback?: unknown): unknown { + return data.output ?? data.result ?? data.content ?? data.text ?? data.partialResult ?? fallback; +} + +function toolProgressText(data: Record): string | undefined { + return stringValue(data.text) + ?? stringValue(data.progressText) + ?? stringValue(data.progressSummary) + ?? stringValue(data.message) + ?? stringValue(data.summary) + ?? stringValue(data.status) + ?? stringValue(data.phase) + ?? stringValue(data.name); +} + +function toolItemOutput(data: Record): unknown { + return data.progressText ?? data.summary ?? data.output ?? data.result ?? data.partialResult ?? data.error; +} + +function toolCallRecordFromPayload(data: Record): Record | undefined { + const direct = recordValue(data.toolCall) ?? recordValue(data.tool_call) ?? recordValue(data.call); + if (direct) return direct; + const content = arrayValue(data.content); + if (!content) return undefined; + for (const part of content) { + const record = recordValue(part); + const type = stringValue(record?.type); + if (record && (!type || type === "toolCall" || type === "tool_call" || type === "function_call")) return record; + } + return undefined; +} + +function parseMaybeJSONValue(value: string): unknown { + const trimmed = value.trim(); + if (!trimmed) return value; + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return value; + try { + return JSON.parse(trimmed); + } catch { + return value; + } +} + +function isToolInputDeltaPhase(value: string | undefined): boolean { + return value === "delta" || value === "input_delta" || value === "args_delta" || value === "arguments_delta" || value === "toolcall_delta"; +} + +function beeperCustomEvents(name: string, payload: unknown): AGUIEvent[] { + const normalized = name === "sources" ? "source" + : name === "documents" ? "document" + : name === "files" ? "file" + : name; + if (normalized === "source") { + const events = sourceEventsFromPayload(payload, "source"); + return events.length > 0 ? events : mapOpenClawCustom(name, payload); + } + if (normalized === "document") { + const events = documentEventsFromPayload(payload); + return events.length > 0 ? events : mapOpenClawCustom(name, payload); + } + if (normalized === "file") { + const events = fileEventsFromPayload(payload); + return events.length > 0 ? events : mapOpenClawCustom(name, payload); + } + if (normalized === "data") { + const record = recordValue(payload); + return mapOpenClawCustom("com.beeper.data", record && stringValue(record.name) ? payload : { name: "openclaw.data", value: payload }); + } + return mapOpenClawCustom(name, payload); +} + +function toolArtifactEvents(toolName: string | undefined, output: unknown): AGUIEvent[] { + const name = toolName?.toLowerCase(); + if (!name) return []; + const value = typeof output === "string" ? parseMaybeJSONValue(output) : output; + if (name === "web_search" || name === "search" || name.includes("web_search")) { + return sourceEventsFromPayload(value, "web_search"); + } + if (name === "fetch" || name === "web_fetch" || name.includes("fetch")) { + return [ + ...sourceEventsFromPayload(value, "fetch"), + ...documentEventsFromPayload(value), + ]; + } + return []; +} + +function sourceEventsFromPayload(payload: unknown, appearanceKind: string): AGUIEvent[] { + const records = artifactRecords(payload, ["items", "results", "sources", "urls"]); + return records.flatMap((record) => { + const source = sourcePayload(record, appearanceKind); + return source ? mapOpenClawCustom("com.beeper.source", source) : []; + }); +} + +function documentEventsFromPayload(payload: unknown): AGUIEvent[] { + const records = artifactRecords(payload, ["documents", "docs", "items", "results"]); + return records.flatMap((record) => { + const document = documentPayload(record); + return document ? mapOpenClawCustom("com.beeper.document", document) : []; + }); +} + +function fileEventsFromPayload(payload: unknown): AGUIEvent[] { + const records = artifactRecords(payload, ["files", "items", "results"]); + return records.flatMap((record) => { + const url = stringValue(record.url) ?? stringValue(record.mxc) ?? stringValue(record.uri); + const title = stringValue(record.title) ?? stringValue(record.name) ?? stringValue(record.filename); + if (!url && !title) return []; + return mapOpenClawCustom("com.beeper.file", stripUndefined({ + id: stringValue(record.id), + mediaType: stringValue(record.mediaType) ?? stringValue(record.mimeType), + title, + url, + })); + }); +} + +function artifactRecords(payload: unknown, arrayKeys: string[]): Record[] { + const directArray = arrayValue(payload); + if (directArray) return directArray.flatMap((item) => recordValue(item) ? [recordValue(item)!] : []); + const record = recordValue(payload); + if (!record) return []; + for (const key of arrayKeys) { + const values = arrayValue(record[key]); + if (values) return values.flatMap((item) => recordValue(item) ? [recordValue(item)!] : []); + } + return [record]; +} + +function sourcePayload(record: Record, appearanceKind: string): Record | undefined { + const url = stringValue(record.url) ?? stringValue(record.finalUrl) ?? stringValue(record.final_url); + const title = stringValue(record.title) ?? stringValue(record.name); + const sourceId = stringValue(record.sourceId) ?? stringValue(record.id) ?? url ?? title; + if (!sourceId && !url && !title) return undefined; + return stripUndefined({ + appearances: arrayValue(record.appearances) ?? [{ kind: appearanceKind }], + author: stringValue(record.author), + description: stringValue(record.description) ?? stringValue(record.summary) ?? stringValue(record.text), + faviconUrl: stringValue(record.faviconUrl) ?? stringValue(record.favicon_url), + finalUrl: stringValue(record.finalUrl) ?? stringValue(record.final_url), + highlights: arrayValue(record.highlights), + imageUrl: stringValue(record.imageUrl) ?? stringValue(record.image_url), + metadata: recordValue(record.metadata), + publishedAt: stringValue(record.publishedAt) ?? stringValue(record.published_at), + siteName: stringValue(record.siteName) ?? stringValue(record.site_name), + sourceId, + title, + url, + }); +} + +function documentPayload(record: Record): Record | undefined { + const text = stringValue(record.markdown) ?? stringValue(record.text) ?? stringValue(record.content); + const url = stringValue(record.url) ?? stringValue(record.finalUrl) ?? stringValue(record.final_url); + const title = stringValue(record.title) ?? stringValue(record.name); + const id = stringValue(record.id) ?? stringValue(record.requestId) ?? stringValue(record.request_id) ?? url ?? title; + if (!id || !text) return undefined; + return stripUndefined({ + id, + markdown: stringValue(record.markdown), + mediaType: stringValue(record.mediaType) ?? stringValue(record.mimeType) ?? "text/markdown", + metadata: recordValue(record.metadata), + sourceId: stringValue(record.sourceId) ?? id, + text: stringValue(record.text) ?? text, + title, + url, + }); +} + +function usageFromPayload(data: Record): Record | undefined { + const usage = recordValue(data.usage) ?? data; + const promptTokens = intValue(usage.promptTokens) ?? intValue(usage.prompt_tokens) ?? intValue(usage.inputTokens) ?? intValue(usage.input); + const completionTokens = intValue(usage.completionTokens) ?? intValue(usage.completion_tokens) ?? intValue(usage.outputTokens) ?? intValue(usage.output); + const reasoningTokens = intValue(usage.reasoningTokens) ?? intValue(usage.reasoning_tokens); + const totalTokens = intValue(usage.totalTokens) ?? intValue(usage.total_tokens) ?? intValue(usage.total); + const contextLimit = intValue(usage.contextLimit) ?? intValue(usage.context_limit) ?? intValue(data.contextTokenBudget); + const out = stripUndefined({ promptTokens, completionTokens, reasoningTokens, totalTokens, contextLimit }); + return Object.keys(out).length > 0 ? out : undefined; +} + +function lifecycleModelMetadata(data: Record): Record | undefined { + const model = stripUndefined({ + model: stringValue(data.model) ?? stringValue(data.modelId) ?? stringValue(data.selectedModel), + provider: stringValue(data.provider), + reasoning: data.reasoning ?? data.reasoningLevel, + requestId: stringValue(data.requestId) ?? stringValue(data.request_id), + serviceTier: stringValue(data.serviceTier) ?? stringValue(data.service_tier), + systemFingerprint: stringValue(data.systemFingerprint) ?? stringValue(data.system_fingerprint), + }); + return Object.keys(model).length > 0 ? model : undefined; +} + +function lifecycleContextMetadata(data: Record): Record | undefined { + const context = stripUndefined({ + contextTokenBudget: intValue(data.contextTokenBudget), + contextWindowReferenceTokens: intValue(data.contextWindowReferenceTokens), + contextWindowSource: stringValue(data.contextWindowSource), + promptTokens: intValue(data.promptTokens) ?? intValue(data.prompt_tokens), + sessionId: stringValue(data.sessionId), + sessionKey: stringValue(data.sessionKey), + }); + return Object.keys(context).length > 0 ? context : undefined; +} + function isToolItemType(value: string | undefined): boolean { return value === "toolCall" || value === "tool_call" @@ -1124,6 +1396,8 @@ function createBeeperReplyStreamEmitter(base: { const toolInputs = new Map(); const toolNames = new Map(); const startedToolCalls = new Set(); + const endedToolInputs = new Set(); + let latestUsage: unknown; const emit = (event: string, payload: Record) => { base.localEvents.emit({ event, @@ -1177,6 +1451,7 @@ function createBeeperReplyStreamEmitter(base: { runId: base.runId, }); await publisher.publishMany(list); + channelRuntime.recordOutboundActivity(); }; const trackExternal = (promise: Promise) => { let tracked: Promise; @@ -1204,6 +1479,22 @@ function createBeeperReplyStreamEmitter(base: { textLength: text?.length ?? 0, }); if (!text) return; + if (isWorkingPlaceholder(text)) { + channelRuntime.debug("openclaw_beeper_text_payload_suppressed", { + reason: "working_placeholder", + source, + textLength: text.length, + }); + if (source !== "final") { + emit("activity.updated", { activityType: "status", source, text }); + await publish(mapOpenClawActivitySnapshot(state, { + activityType: "status", + content: { state: "running", text }, + replace: true, + })); + } + return; + } const explicitDelta = stringValue(recordValue(payload)?.delta); const delta = explicitDelta ?? visibleTextDelta(lastVisibleText, text); lastVisibleText = nextVisibleText(lastVisibleText, text, delta); @@ -1224,7 +1515,7 @@ function createBeeperReplyStreamEmitter(base: { await publish(mapOpenClawMessageDelta(state, { kind: "text", value: delta })); }; const reasoningPayload = async (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(lastReasoningText) ? text.slice(lastReasoningText.length) : text); @@ -1234,7 +1525,7 @@ function createBeeperReplyStreamEmitter(base: { await publish(mapOpenClawMessageDelta(state, { kind: "thinking", value: delta })); }; const toolIdFor = (payload: Record, fallback: string) => - stringValue(payload.toolCallId) ?? stringValue(payload.itemId) ?? stringValue(payload.approvalId) ?? fallback; + toolCallIdFromPayload(payload) ?? stringValue(payload.itemId) ?? stringValue(payload.approvalId) ?? fallback; const fallbackToolIdForName = (name: string | undefined, fallback: string) => `tool:${name || fallback}`; const rememberTool = (toolCallId: string, toolName: string | undefined, input?: unknown) => { if (toolName) toolNames.set(toolCallId, toolName); @@ -1246,6 +1537,11 @@ function createBeeperReplyStreamEmitter(base: { startedToolCalls.add(event.toolCallId); return mapOpenClawToolInput(event); }; + const endToolInput = (event: Parameters[0]) => { + if (endedToolInputs.has(event.toolCallId)) return []; + endedToolInputs.add(event.toolCallId); + return mapOpenClawToolEnd(event); + }; return { start: ensureStarted, trackExternal, @@ -1259,15 +1555,37 @@ function createBeeperReplyStreamEmitter(base: { }, reasoningPayload, textPayload, + activity: async (payload: unknown) => { + const data = recordValue(payload) ?? {}; + const text = stringValue(data.text) + ?? stringValue(data.progressText) + ?? stringValue(data.progressSummary) + ?? stringValue(data.message) + ?? stringValue(data.status) + ?? stringValue(data.phase) + ?? stringValue(data.title); + if (!text) return; + const activityType = stringValue(data.activityType) ?? stringValue(data.type) ?? "activity"; + emit("activity.updated", { activityType, text }); + await publish(mapOpenClawActivitySnapshot(state, { + activityType, + content: stripUndefined({ + label: stringValue(data.label) ?? stringValue(data.title) ?? stringValue(data.name), + phase: stringValue(data.phase), + state: stringValue(data.state) ?? stringValue(data.status) ?? "running", + text, + }), + replace: true, + })); + }, toolStart: async (payload: unknown) => { const data = recordValue(payload) ?? {}; - const toolName = stringValue(data.name) ?? stringValue(data.toolName); + const toolName = toolNameFromPayload(data); const toolCallId = toolIdFor(data, fallbackToolIdForName(toolName, "tool")); - const input = data.args ?? data.input; + const input = toolInputFromPayload(data); rememberTool(toolCallId, toolName, input); emit("tool.call.started", { - args: data.args, - input: data.args, + input, phase: stringValue(data.phase), toolCallId, toolName, @@ -1275,7 +1593,7 @@ function createBeeperReplyStreamEmitter(base: { await publish(startToolCall(stripUndefined({ approval: recordValue(data.approval), index: numberValue(data.index), - input: data.args ?? data.input, + input, metadata: recordValue(data.metadata), providerExecuted: booleanValue(data.providerExecuted), startedAtMs: numberValue(data.startedAt) ?? numberValue(data.startedAtMs), @@ -1284,40 +1602,88 @@ function createBeeperReplyStreamEmitter(base: { toolName, }))); }, + toolInputDelta: async (payload: unknown) => { + const data = recordValue(payload) ?? {}; + const rawToolName = toolNameFromPayload(data); + const toolCallId = toolIdFor(data, fallbackToolIdForName(rawToolName, "tool_delta")); + const toolName = rememberedToolName(toolCallId, rawToolName); + const input = toolInputFromPayload(data); + const inputTextDelta = stringValue(data.inputTextDelta) ?? stringValue(data.argsDelta) ?? stringValue(data.argumentsDelta) ?? stringValue(data.delta); + rememberTool(toolCallId, toolName, input); + emit("tool.call.input.delta", { + inputTextDelta, + toolCallId, + toolName, + }); + await publish([ + ...startToolCall(stripUndefined({ + metadata: recordValue(data.metadata), + providerExecuted: booleanValue(data.providerExecuted), + toolCallId, + toolName, + })), + ...mapOpenClawToolInputDelta(stripUndefined({ + input, + inputTextDelta, + toolCallId, + toolName, + })), + ]); + }, toolResult: async (payload: unknown) => { const data = recordValue(payload) ?? {}; const toolCallId = toolIdFor(data, "tool_result"); - 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; + const toolName = rememberedToolName(toolCallId, toolNameFromPayload(data)); + const input = data.input ?? toolInputs.get(toolCallId); + const error = data.error ?? (booleanValue(data.isError) ? toolOutputFromPayload(data, payload) : undefined); + const output = toolOutputFromPayload(data, payload); emit("tool.call.completed", { output, toolCallId, toolName, }); - await publish(mapOpenClawToolEnd(stripUndefined({ - error, - input: data.input ?? toolInputs.get(toolCallId), - result: error === undefined ? output : undefined, - toolCallId, - toolName, - }))); + await publish([ + ...startToolCall(stripUndefined({ + input, + providerExecuted: booleanValue(data.providerExecuted), + toolCallId, + toolName, + })), + ...endToolInput(stripUndefined({ + error, + input, + toolCallId, + toolName, + })), + ...mapOpenClawToolOutput(stripUndefined({ + completedAtMs: numberValue(data.completedAt) ?? numberValue(data.completedAtMs), + error, + output: error === undefined ? output : undefined, + providerExecuted: booleanValue(data.providerExecuted), + toolCallId, + toolName, + })), + ...toolArtifactEvents(toolName, output), + ]); }, itemEvent: async (payload: unknown) => { const data = recordValue(payload) ?? {}; - const toolCallId = toolIdFor(data, stringValue(data.kind) ?? "item"); - const rawToolName = stringValue(data.name) ?? stringValue(data.toolName); + const rawToolName = toolNameFromPayload(data); const itemType = stringValue(data.type); const kind = stringValue(data.kind); - const hasToolIdentity = Boolean(rawToolName || stringValue(data.toolCallId) || kind === "tool" || kind === "command" || kind === "patch"); + const hasToolIdentity = Boolean(rawToolName || toolCallIdFromPayload(data) || kind === "tool" || kind === "command" || kind === "patch"); if (!hasToolIdentity && !isToolItemType(itemType)) return; + const toolCallId = toolIdFor(data, stringValue(data.kind) ?? "item"); const toolName = rememberedToolName(toolCallId, rawToolName ?? specificToolName(kind) ?? specificToolName(itemType) ?? "tool"); + const input = toolInputFromPayload(data); + const inputTextDelta = stringValue(data.inputTextDelta) ?? stringValue(data.argsDelta) ?? stringValue(data.argumentsDelta) ?? (isToolInputDeltaPhase(stringValue(data.phase)) ? stringValue(data.delta) : undefined); const title = stringValue(data.title) ?? stringValue(data.progressText) ?? stringValue(data.summary) ?? rawToolName ?? itemType ?? kind; - const output = stringValue(data.progressText) ?? stringValue(data.summary) ?? stringValue(data.error); + const output = toolItemOutput(data); const phase = stringValue(data.phase); const status = stringValue(data.status); const preliminary = !isCompletePhase(phase) && !isCompletePhase(status); - rememberTool(toolCallId, toolName); + const error = data.error; + rememberTool(toolCallId, toolName, input); emit("tool.call.updated", { output, phase, @@ -1326,20 +1692,35 @@ function createBeeperReplyStreamEmitter(base: { toolName, }); await publish([ - ...startToolCall(stripUndefined({ title, toolCallId, toolName })), - ...(output ? mapOpenClawToolOutput(stripUndefined({ - error: data.error, - output, - preliminary, + ...startToolCall(stripUndefined({ + input, + metadata: recordValue(data.metadata), + providerExecuted: booleanValue(data.providerExecuted), + title, + toolCallId, + toolName, + })), + ...(inputTextDelta ? mapOpenClawToolInputDelta(stripUndefined({ + input, + inputTextDelta, toolCallId, toolName, })) : []), - ...(!preliminary ? mapOpenClawToolEnd(stripUndefined({ - error: data.error, - result: output, + ...(!preliminary ? endToolInput(stripUndefined({ + error, + input: input ?? toolInputs.get(toolCallId), toolCallId, toolName, })) : []), + ...(output !== undefined ? mapOpenClawToolOutput(stripUndefined({ + error, + output: error === undefined ? output : undefined, + preliminary, + providerExecuted: booleanValue(data.providerExecuted), + toolCallId, + toolName, + })) : []), + ...(!preliminary ? toolArtifactEvents(toolName, output) : []), ]); }, planUpdate: async (payload: unknown) => { @@ -1371,7 +1752,28 @@ function createBeeperReplyStreamEmitter(base: { }, customData: async (name: string, payload: unknown) => { emit(`${name}.event`, { value: payload }); - await publish(mapOpenClawCustom(name, payload)); + await publish(beeperCustomEvents(name, payload)); + }, + lifecycleEvent: async (payload: unknown) => { + const data = recordValue(payload) ?? {}; + const usage = usageFromPayload(data); + if (usage !== undefined) latestUsage = usage; + const model = lifecycleModelMetadata(data); + const context = lifecycleContextMetadata(data); + const events = [ + ...(model ? mapOpenClawCustom("com.beeper.data", { name: "openclaw.model", value: model }) : []), + ...(context ? mapOpenClawCustom("com.beeper.data", { name: "openclaw.context", value: context }) : []), + ...(usage !== undefined ? mapOpenClawCustom("com.beeper.data", { name: "openclaw.usage", value: usage }) : []), + ]; + if (events.length > 0) { + emit("lifecycle.metadata", { + context, + model, + phase: stringValue(data.phase), + usage, + }); + await publish(events); + } }, raw: async (source: string, payload: unknown) => { emit("raw.event", { source, value: payload }); @@ -1431,7 +1833,7 @@ function createBeeperReplyStreamEmitter(base: { const status = stringValue(data.status); const complete = isCompletePhase(phase) || isCompletePhase(status); const toolCallId = toolIdFor(data, fallbackToolIdForName(toolName, "command")); - const output = stringValue(data.output) ?? data; + const output = data.output ?? data; rememberTool(toolCallId, toolName); emit("tool.call.completed", { output, @@ -1439,6 +1841,13 @@ function createBeeperReplyStreamEmitter(base: { toolCallId, toolName, }); + if (complete) { + await publish(endToolInput(stripUndefined({ + input: toolInputs.get(toolCallId), + toolCallId, + toolName, + }))); + } await publish(mapOpenClawToolOutput({ output, preliminary: !complete, @@ -1446,12 +1855,7 @@ function createBeeperReplyStreamEmitter(base: { toolName, })); if (complete) { - await publish(mapOpenClawToolEnd(stripUndefined({ - input: toolInputs.get(toolCallId), - result: status ? { output, status } : output, - toolCallId, - toolName, - }))); + await publish(toolArtifactEvents(toolName, output)); } }, patchSummary: async (payload: unknown) => { @@ -1465,26 +1869,33 @@ function createBeeperReplyStreamEmitter(base: { toolCallId, toolName, }); - await publish(mapOpenClawToolOutput(stripUndefined({ output, toolCallId, toolName }))); - await publish(mapOpenClawToolEnd(stripUndefined({ + await publish(endToolInput(stripUndefined({ input: toolInputs.get(toolCallId), - result: output, toolCallId, toolName, }))); + await publish(mapOpenClawToolOutput(stripUndefined({ output, toolCallId, toolName }))); + await publish(toolArtifactEvents(toolName, output)); }, finish: async (payload?: unknown) => { if (payload !== undefined) await textPayload(payload, "final"); await drainExternal(); if (!hasPublished || finalized) return; const preTerminal = closeReasoningPart(state); - if (preTerminal.length > 0) await publisher.publishMany(preTerminal); + if (preTerminal.length > 0) { + await publisher.publishMany(preTerminal); + channelRuntime.recordOutboundActivity(); + } finalized = true; channelRuntime.debug("openclaw_beeper_stream_finalizing", { roomId: base.roomId, runId: base.runId, }); - await publisher.finalize({ finishReason: "stop" }); + await publisher.finalize({ + finishReason: "stop", + ...(latestUsage !== undefined ? { usage: latestUsage } : {}), + }); + channelRuntime.recordOutboundActivity(); channelRuntime.clearActiveStream(base.sessionKey, publisher); channelRuntime.debug("openclaw_beeper_stream_finalized", { eventId: publisher.targetEventId, @@ -1502,7 +1913,6 @@ function createBeeperReplyStreamEmitter(base: { runId: base.runId, }); await publisher.finalize({ - body: errorText(error), terminalPart: { error: { message: errorText(error) }, message: errorText(error), @@ -1511,6 +1921,7 @@ function createBeeperReplyStreamEmitter(base: { type: AGUIEventType.RUN_ERROR, }, }); + channelRuntime.recordOutboundActivity(); channelRuntime.clearActiveStream(base.sessionKey, publisher); }, }; @@ -1533,6 +1944,10 @@ function replyPayloadText(payload: unknown): string | undefined { return chunks.length > 0 ? chunks.join("") : undefined; } +function isWorkingPlaceholder(text: string): boolean { + return /^working(?:\.{3}|\u2026)?$/iu.test(text.trim()); +} + function visibleTextDelta(previous: string, next: string): string { if (!next || next === previous) return ""; if (!previous) return next; @@ -1677,6 +2092,11 @@ function numberValue(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } +function intValue(value: unknown): number | undefined { + const number = numberValue(value); + return number === undefined ? undefined : Math.trunc(number); +} + function errorText(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/packages/openclaw/src/registration.test.ts b/packages/openclaw/src/registration.test.ts index 261d17a..a87cca7 100644 --- a/packages/openclaw/src/registration.test.ts +++ b/packages/openclaw/src/registration.test.ts @@ -47,9 +47,8 @@ describe("OpenClaw appservice registration", () => { }); }); - it("keeps appservice tokens independent from the Beeper Matrix access token", () => { + it("uses appservice tokens without Matrix user credentials", () => { const config = createDefaultConfig({ - accessToken: "mx-token", asToken: "as-token", dataDir: "/tmp/openclaw", hsToken: "hs-token", @@ -58,10 +57,8 @@ describe("OpenClaw appservice registration", () => { 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/setup.test.ts b/packages/openclaw/src/setup.test.ts index cea5756..df8bd02 100644 --- a/packages/openclaw/src/setup.test.ts +++ b/packages/openclaw/src/setup.test.ts @@ -22,7 +22,6 @@ import { import { createConfigFromOpenClawSetup } from "./config"; const appserviceMocks = vi.hoisted(() => ({ - accountFromOpenClawConfig: vi.fn((config: unknown) => ({ config, kind: "account" })), startOpenClawBeeperBridge: vi.fn(), })); @@ -30,7 +29,6 @@ vi.mock("./appservice", () => appserviceMocks); describe("OpenClaw Beeper setup surface", () => { beforeEach(() => { - appserviceMocks.accountFromOpenClawConfig.mockClear(); appserviceMocks.startOpenClawBeeperBridge.mockReset(); setBeeperOpenClawPluginRuntime(undefined); }); @@ -58,9 +56,6 @@ describe("OpenClaw Beeper setup surface", () => { stopAccount: expect.any(Function), }, uiHints: { - accessToken: { - sensitive: true, - }, asToken: { sensitive: true, }, @@ -262,10 +257,9 @@ describe("OpenClaw Beeper setup surface", () => { const channelRuntime = { reply: { dispatchReplyWithBufferedBlockDispatcher: vi.fn() }, session: { recordInboundSession: vi.fn() }, - turn: { buildContext: vi.fn(), runAssembled: vi.fn() }, + inbound: { buildContext: vi.fn(), dispatchReply: vi.fn() }, }; const cfg = applyBeeperChannelSettings({}, { - accessToken: "at", asToken: "as", backfillLimit: 25, dataDir: "/tmp/openclaw-beeper", @@ -285,13 +279,7 @@ describe("OpenClaw Beeper setup surface", () => { setStatus: (next) => statuses.push(next), } as never); await vi.waitFor(() => expect(appserviceMocks.startOpenClawBeeperBridge).toHaveBeenCalledOnce()); - expect(appserviceMocks.accountFromOpenClawConfig).toHaveBeenCalledWith(expect.objectContaining({ - accessToken: "at", - asToken: "as", - hsToken: "hs", - })); expect(appserviceMocks.startOpenClawBeeperBridge).toHaveBeenCalledWith(expect.objectContaining({ - account: expect.objectContaining({ kind: "account" }), backfill: true, backfillLimit: 25, config: expect.objectContaining({ @@ -370,7 +358,8 @@ describe("OpenClaw Beeper setup surface", () => { accountId: "default", cfg: {}, input: { - accessToken: "mx-token", + password: "secret", + username: "alice", }, })).toThrow("Beeper login is asynchronous"); }); @@ -383,7 +372,6 @@ describe("OpenClaw Beeper setup surface", () => { const promptValues: Record = { "Beeper email": "alice@example.com", "Beeper login code": "123456", - "Backfill limit per session": "500", }; const result = await beeperSetupWizard.configureInteractive({ cfg: {}, @@ -393,7 +381,6 @@ describe("OpenClaw Beeper setup surface", () => { progress: () => progress, select: async ({ message }) => { if (message === "Beeper login method") return "email"; - if (message === "Beeper environment") return "dev"; if (message === "Beeper contact visibility") return "agents"; if (message === "Approval behavior") return "native"; throw new Error(`unexpected select prompt ${message}`); @@ -409,7 +396,7 @@ describe("OpenClaw Beeper setup surface", () => { runtime: { setupBridge: async (options) => { expect(options.email).toBe("alice@example.com"); - expect(options.env).toBe("dev"); + expect(options.env).toBe("production"); expect(options).not.toHaveProperty("bridgeManagerToken"); expect(options).not.toHaveProperty("homeserverDomain"); expect(await options.getLoginCode?.()).toBe("123456"); @@ -421,7 +408,6 @@ describe("OpenClaw Beeper setup surface", () => { userId: "@alice:example", }, config: { - accessToken: "at", appserviceId: "sh-openclaw-dev", asToken: "as", bridgeId: "sh-openclaw-dev", @@ -447,7 +433,6 @@ describe("OpenClaw Beeper setup surface", () => { expect(result.accountId).toBe("default"); expect(getBeeperChannelSettings(cfg)).toMatchObject({ enabled: true, - accessToken: "at", asToken: "as", bridgeId: "sh-openclaw-dev", homeserver: "https://matrix.example", @@ -457,19 +442,21 @@ describe("OpenClaw Beeper setup surface", () => { }); }); - it("infers generated bridge settings from access token setup input", async () => { + it("infers generated bridge settings from username/password setup input", async () => { const { applyBeeperSetupConfig } = await import("./setup"); const cfg = await applyBeeperSetupConfig({ cfg: {}, input: { - accessToken: "at", beeperEnv: "dev", + password: "secret", + username: "alice", }, runtime: { setupBridge: async (options) => { - expect(options.accessToken).toBe("at"); expect(options.email).toBeUndefined(); expect(options.env).toBe("dev"); + expect(options.password).toBe("secret"); + expect(options.username).toBe("alice"); return { account: { accessToken: "at", @@ -478,7 +465,6 @@ describe("OpenClaw Beeper setup surface", () => { userId: "@alice:example", }, config: { - accessToken: "at", appserviceId: "sh-openclaw-dev", asToken: "as", bridgeId: "sh-openclaw-dev", @@ -501,7 +487,6 @@ describe("OpenClaw Beeper setup surface", () => { }, }); expect(getBeeperChannelSettings(cfg)).toMatchObject({ - accessToken: "at", appserviceId: "sh-openclaw-dev", asToken: "as", beeperEnv: "dev", @@ -518,7 +503,6 @@ describe("OpenClaw Beeper setup surface", () => { enabled: true, }))).toBe(false); const cfg = applyBeeperChannelSettings({}, { - accessToken: "at", asToken: "as", enabled: true, homeserver: "https://matrix.example", @@ -553,7 +537,6 @@ describe("OpenClaw Beeper setup surface", () => { userId: "@alice:example", }, config: { - accessToken: "at", appserviceId: "sh-openclaw-dev", asToken: "as", bridgeId: "sh-openclaw-dev", @@ -577,7 +560,6 @@ describe("OpenClaw Beeper setup surface", () => { }); expect(getBeeperChannelSettings(cfg)).toMatchObject({ enabled: true, - accessToken: "at", appserviceId: "sh-openclaw-dev", asToken: "as", bridgeId: "sh-openclaw-dev", @@ -588,22 +570,22 @@ describe("OpenClaw Beeper setup surface", () => { }); }); - it("keeps default import scope opt-in to dashboard and TUI sessions", async () => { + it("defaults new setup to no historical imports", async () => { expect(defaultBeeperChannelSettings()).toMatchObject({ enabled: true, - importSources: ["dashboard", "tui"], + importSources: [], }); const configured = await beeperSetupWizard.configure({ cfg: {} }); expect(getBeeperChannelSettings(configured.cfg)).toMatchObject({ enabled: true, - importSources: ["dashboard", "tui"], + importSources: [], }); }); it("reports setup status and validates dashboard input", async () => { expect(validateBeeperSetupInput({ email: "not-email" })).toContain("valid email"); - expect(validateBeeperSetupInput({ accessToken: " " })).toContain("access token"); - expect(validateBeeperSetupInput({ email: "alice@example.com", accessToken: "at" })).toContain("either"); + expect(validateBeeperSetupInput({ username: "alice" })).toContain("requires both"); + expect(validateBeeperSetupInput({ email: "alice@example.com", username: "alice", password: "secret" })).toContain("only one"); expect(validateBeeperSetupInput({ backfillLimit: "-1" })).toContain("non-negative"); const cfg = applyBeeperChannelSettings({}, { enabled: true, diff --git a/packages/openclaw/src/setup.ts b/packages/openclaw/src/setup.ts index 44f0f3c..bbfec00 100644 --- a/packages/openclaw/src/setup.ts +++ b/packages/openclaw/src/setup.ts @@ -15,7 +15,6 @@ export type OpenClawSetupConfig = OpenClawConfig; export type BeeperImportSource = "dashboard" | "tui" | "channels" | "archived"; export interface BeeperChannelSettings { - accessToken?: string; allowedRoomIds?: string[]; allowedUserIds?: string[]; appserviceId?: string; @@ -37,7 +36,6 @@ export interface BeeperChannelSettings { } export interface BeeperSetupInput { - accessToken?: string; allowedRoomIds?: string[] | string; allowedUserIds?: string[] | string; approvalBehavior?: string; @@ -49,9 +47,11 @@ export interface BeeperSetupInput { email?: string; getOnly?: boolean | string; importSources?: string[] | string; + password?: string; postState?: boolean | string; push?: boolean | string; selfHosted?: boolean | string; + username?: string; } export interface BeeperSetupRuntime { @@ -116,11 +116,6 @@ function requireBeeperChannelRuntime() { export const BeeperChannelConfigSchema = beeperChannelConfigSchema; 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", @@ -565,7 +560,7 @@ export const beeperSetupAdapter = { input: BeeperSetupInput; runtime?: BeeperSetupRuntime; }): OpenClawSetupConfig => { - if (input.email || input.accessToken) { + if (input.email || input.username || input.password) { throw new Error("Beeper login is asynchronous; use the Beeper setup wizard or pickle-openclaw login."); } return applyBeeperChannelSettings(cfg, normalizeBeeperSetupInput(input)); @@ -604,17 +599,18 @@ export const beeperSetupWizard = { ...defaultBeeperChannelSettings(), ...getBeeperChannelSettings(ctx.cfg), }; - const loginMethod = await ctx.prompter.select<"email" | "token">({ + const loginMethod = await ctx.prompter.select<"email" | "password">({ message: "Beeper login method", initialValue: "email", options: [ { value: "email", label: "Email code" }, - { value: "token", label: "Access token" }, + { value: "password", label: "Username/password" }, ], }); let email: string | undefined; let code: string | undefined; - let accessToken: string | undefined; + let username: string | undefined; + let password: string | undefined; if (loginMethod === "email") { email = await ctx.prompter.text({ message: "Beeper email", @@ -626,38 +622,20 @@ export const beeperSetupWizard = { sensitive: true, validate: (value) => (value.trim() ? undefined : "Beeper login code is required."), }); - } else { - accessToken = await ctx.prompter.text({ - message: "Beeper access token", + } else if (loginMethod === "password") { + username = await ctx.prompter.text({ + message: "Beeper username", + validate: (value) => validateBeeperSetupInput({ username: value, password: "set" }) ?? undefined, + }); + password = await ctx.prompter.text({ + message: "Beeper password", sensitive: true, - validate: (value) => validateBeeperSetupInput({ accessToken: value }) ?? undefined, + validate: (value) => validateBeeperSetupInput({ username: username ?? "set", password: value }) ?? undefined, }); } - 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 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 beeperEnv = current.beeperEnv ?? "production"; + const importSources: BeeperImportSource[] = []; + const backfillLimit = 0; const contactVisibility = await ctx.prompter.select({ message: "Beeper contact visibility", initialValue: current.contactVisibility ?? "agents", @@ -679,11 +657,12 @@ export const beeperSetupWizard = { progress?.update("Logging in and registering appservice"); try { const input: BeeperSetupInput = { - ...(accessToken ? { accessToken } : {}), importSources, backfillLimit, ...(code ? { code } : {}), ...(email ? { email } : {}), + ...(password ? { password } : {}), + ...(username ? { username } : {}), }; if (approvalBehavior !== undefined) input.approvalBehavior = approvalBehavior; if (beeperEnv !== undefined) input.beeperEnv = beeperEnv; @@ -786,7 +765,7 @@ export async function applyBeeperSetupConfig(params: { runtime?: BeeperSetupRuntime; }): Promise { const baseSettings = normalizeBeeperSetupInput(params.input); - if (!params.input.email && !params.input.accessToken) return applyBeeperChannelSettings(params.cfg, baseSettings); + if (!params.input.email && !params.input.username && !params.input.password) return applyBeeperChannelSettings(params.cfg, baseSettings); const setupBridge = params.runtime?.setupBridge ?? (await loadBeeperSetupBridge()); const bridgeOptions = setupOptionsFromInput(params.input); const result = await setupBridge(bridgeOptions); @@ -795,7 +774,6 @@ export async function applyBeeperSetupConfig(params: { enabled: true, }; 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; @@ -1075,16 +1053,27 @@ export async function startBeeperGatewayAccount(ctx: BeeperGatewayContext | Chan 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 { startOpenClawBeeperBridge } = await import("./appservice"); const config = createConfigFromOpenClawSetup(ctx.cfg); const hostRuntime = resolveBeeperHostRuntime(ctx); + const statusSink = (patch: { + lastEventAt?: number; + lastInboundAt?: number; + lastOutboundAt?: number; + lastTransportActivityAt?: number; + }) => { + ctx.setStatus?.({ + accountId: ctx.accountId, + ...patch, + }); + }; const bridge = await startOpenClawBeeperBridge({ - account: accountFromOpenClawConfig(config), backfill: Boolean(config.importSources?.length), ...(config.backfillLimit !== undefined ? { backfillLimit: config.backfillLimit } : {}), config, dataDir: config.dataDir, log: bridgeLoggerFromChannelContext(ctx), + onActivity: statusSink, ...(hostRuntime ? { runtime: hostRuntime } : {}), }); if (hostRuntime && openClawPluginRuntime && hostRuntime !== openClawPluginRuntime) { @@ -1185,8 +1174,8 @@ function hasOpenClawSessionRuntime(value: object): value is OpenClawHostRuntime 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" + return typeof channel.inbound?.buildContext === "function" + && typeof channel.inbound.dispatchReply === "function" && typeof channel.session?.recordInboundSession === "function" && typeof channel.reply?.dispatchReplyWithBufferedBlockDispatcher === "function"; } @@ -1211,7 +1200,6 @@ export function isBeeperChannelConfigured(cfg: OpenClawSetupConfig): boolean { const settings = getBeeperChannelSettings(cfg); return Boolean( settings.enabled && - settings.accessToken && settings.asToken && settings.homeserver && settings.hsToken && @@ -1241,19 +1229,22 @@ export function applyBeeperChannelSettings( export function defaultBeeperChannelSettings(): BeeperChannelSettings { return { approvalBehavior: "native", - backfillLimit: 500, + backfillLimit: 0, beeperEnv: "production", contactVisibility: "agents", dataDir: defaultDataDir(), enabled: true, - importSources: ["dashboard", "tui"], + importSources: [], }; } export function validateBeeperSetupInput(input: BeeperSetupInput): string | null { - if (input.email && input.accessToken) return "Choose either Beeper email login or access token, not both."; + const authMethods = [input.email, input.username || input.password].filter(Boolean).length; + if (authMethods > 1) return "Choose only one Beeper login method."; if (input.email !== undefined && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/u.test(input.email)) return "Beeper email must be a valid email address."; - if (input.accessToken !== undefined && !input.accessToken.trim()) return "Beeper access token is required."; + if (input.username !== undefined && !input.username.trim()) return "Beeper username is required."; + if (input.password !== undefined && !input.password.trim()) return "Beeper password is required."; + if ((input.username && !input.password) || (input.password && !input.username)) return "Beeper username/password login requires both username and password."; if (input.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.approvalBehavior !== undefined && normalizeApprovalBehavior(input.approvalBehavior) === undefined) return "Approval behavior must be native or disabled."; @@ -1271,7 +1262,6 @@ export function normalizeBeeperSetupInput(input: BeeperSetupInput): Partial= len(state.run.Events) { return nil } - streamType := stream.descriptor.Type - if streamType == "" { - streamType = "com.beeper.llm" - } partial := *state.run partial.Events = append([]agui.Event(nil), state.run.Events[state.published:]...) - carriers, err := aistream.PackRunFromSeq(partial, state.streamEventID.String(), aistream.CarrierBudgetBytes, stream.nextSeq) + carriers, err := aistream.PackRunFromSeq(partial, stream.nextSeq) if err != nil { return 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) + contents = append(contents, aistream.CarrierContent(partial, carrier.Envelopes)) } if err := c.publishBeeperStreamCarrierContents(ctx, state.streamEventID, stream, contents); err != nil { return err @@ -382,10 +361,17 @@ func (c *Core) finalizeBeeperAIRunStream(ctx context.Context, state *beeperAIRun if stream == nil { return nil, fmt.Errorf("beeper stream message %s is not registered", state.streamEventID) } - content, extra := aimatrix.FinalContent(*state.run) - contentMap := messageContentMap(content, OutboundEvent(extra)) + projection := aimatrix.ProjectFinal(*state.run, nil) + if projection.NeedsAttachment { + partsRef, err := c.uploadBeeperAIFinalPartsRef(ctx, *state.run, projection.Message) + if err != nil { + return nil, err + } + projection = aimatrix.ProjectFinal(*state.run, partsRef) + } + contentMap := messageContentMap(projection.Content, OutboundEvent(projection.Extra)) result, err := c.finalizeBeeperStreamMessage(ctx, MatrixFinalizeBeeperStreamMessageOptions{ - Body: content.Body, + Body: projection.Content.Body, Content: contentMap, EventID: state.streamEventID.String(), RoomID: stream.roomID.String(), @@ -399,6 +385,33 @@ func (c *Core) finalizeBeeperAIRunStream(ctx context.Context, state *beeperAIRun return c.marshalBeeperAIRunStreamResult(state, events, result.ReplacementEventID, result.Raw) } +func (c *Core) uploadBeeperAIFinalPartsRef(ctx context.Context, run aistream.Run, message aistream.UIMessage) (*aistream.FinalPartsRef, error) { + payload, err := json.Marshal(run.FinalPartsPayload(message)) + if err != nil { + return nil, fmt.Errorf("failed to encode Beeper AI final parts: %w", err) + } + raw, err := c.uploadEncryptedMedia(ctx, MatrixUploadMediaOptions{ + ContentType: aistream.FinalPartsMediaType, + Filename: fmt.Sprintf("ai-final-parts-%s.json", run.RunID), + }, payload) + if err != nil { + return nil, fmt.Errorf("failed to upload Beeper AI final parts: %w", err) + } + var uploaded MatrixUploadEncryptedMediaResult + if err := json.Unmarshal(raw, &uploaded); err != nil { + return nil, err + } + hash := sha256.Sum256(payload) + return &aistream.FinalPartsRef{ + Schema: aistream.FinalPartsRefSchema, + MediaType: aistream.FinalPartsMediaType, + File: uploaded.File, + ByteSize: len(payload), + SHA256: base64.RawURLEncoding.EncodeToString(hash[:]), + PartsCount: len(message.Parts), + }, nil +} + func (c *Core) requireBeeperAIRun(runID string) (*beeperAIRunState, error) { if strings.TrimSpace(runID) == "" { return nil, errors.New("missing Beeper AI run ID") @@ -436,9 +449,9 @@ func (c *Core) beeperAIRunSnapshot(run *aistream.Run, events []OutboundEvent) Ma return MatrixBeeperAIRunSnapshot{ Body: body, Events: events, - InitialAIMessage: run.InitialUIMessage(), - FinalAIMessage: run.FinalUIMessage(0, true), - Metadata: run.Metadata(), + InitialAIMessage: run.InitialBeeperAIMessage(), + FinalAIMessage: run.FinalBeeperAIMessage(0, true), + Metadata: run.AI(aistream.AIKindStream), MessageID: run.MessageID, RunID: run.RunID, ThreadID: run.ThreadID, @@ -448,7 +461,7 @@ func (c *Core) beeperAIRunSnapshot(run *aistream.Run, events []OutboundEvent) Ma func outboundEventsFromAGUI(events []agui.Event) []OutboundEvent { out := make([]OutboundEvent, 0, len(events)) for _, event := range events { - out = append(out, OutboundEvent(event)) + out = append(out, OutboundEvent(event.Map())) } return out } diff --git a/packages/pickle/native/internal/core/beeper_ai_run_test.go b/packages/pickle/native/internal/core/beeper_ai_run_test.go index c8163a8..bab0655 100644 --- a/packages/pickle/native/internal/core/beeper_ai_run_test.go +++ b/packages/pickle/native/internal/core/beeper_ai_run_test.go @@ -28,7 +28,7 @@ func TestBeeperAIRunLifecycleUsesAIBridgeFinalContent(t *testing.T) { 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" { + if got := eventTypes(begin.Events); strings.Join(got, ",") != "RUN_STARTED" { t.Fatalf("unexpected begin events: %#v", got) } if begin.InitialAIMessage == nil || begin.Metadata == nil { @@ -76,7 +76,7 @@ func TestBeeperAIRunLifecycleUsesAIBridgeFinalContent(t *testing.T) { 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" { + if got := eventTypes(finish.Events); strings.Join(got, ",") != "MESSAGES_SNAPSHOT,RUN_FINISHED" { t.Fatalf("unexpected finish events: %#v", got) } finalMessage, ok := finish.FinalAIMessage.(map[string]any) @@ -134,13 +134,13 @@ func TestBeeperAIRunErrorAbortAndDelete(t *testing.T) { } } -func TestBeeperStreamCarrierContentsSplitsLargeEventsAndAdvancesSeq(t *testing.T) { +func TestBeeperStreamCarrierContentsUsesBeeperAIPayloadAndAdvancesSeq(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), + "delta": "hello", "messageId": "msg-1", "runId": "run-1", "threadId": "thread-1", @@ -151,23 +151,20 @@ func TestBeeperStreamCarrierContentsSplitsLargeEventsAndAdvancesSeq(t *testing.T 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 len(contents) != 1 { + t.Fatalf("expected one carrier, got %d", len(contents)) } if nextSeq != 7+len(contents) { t.Fatalf("next seq = %d, want %d", nextSeq, 7+len(contents)) } for index, content := range contents { - 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) + payload, ok := content[aistream.BeeperAIKey].(aistream.BeeperAI) + if !ok || len(payload.Events) != 1 { + t.Fatalf("carrier %d has unexpected payload shape: %#v", index, content) } wantSeq := 7 + index - if envelopes[0].Seq != wantSeq { - t.Fatalf("carrier %d seq = %d, want %d", index, envelopes[0].Seq, wantSeq) + if payload.Events[0].Seq != wantSeq { + t.Fatalf("carrier %d seq = %d, want %d", index, payload.Events[0].Seq, wantSeq) } } } diff --git a/packages/pickle/native/internal/core/messages.go b/packages/pickle/native/internal/core/messages.go index 4c4839d..e496488 100644 --- a/packages/pickle/native/internal/core/messages.go +++ b/packages/pickle/native/internal/core/messages.go @@ -152,7 +152,7 @@ func (c *Core) handleStartBeeperStreamMessage(ctx context.Context, payload []byt content["com.beeper.stream"] = descriptor } else { content["com.beeper.stream"] = map[string]any{ - "type": aistream.BeeperAIStreamDeltas, + "type": req.StreamType, } } resp, err := c.sendBeeperStreamMessageEvent(ctx, req.RoomID, req.ThreadRootEventID, req.UserID, content) @@ -277,12 +277,13 @@ func (c *Core) beeperStreamCarrierContent(streamType string, req MatrixPublishBe return nil, err } if len(contents) == 0 { - return aistream.CarrierContent(nil), nil + return aistream.CarrierContent(aistream.Run{}, nil), nil } return contents[0], nil } func (c *Core) beeperStreamCarrierContents(streamType string, req MatrixPublishBeeperStreamMessagePartOptions, seq int) ([]map[string]any, int, error) { + _ = streamType run := aistream.Run{ ThreadID: firstString(req.Part["threadId"], req.TurnID), RunID: firstString(req.Part["runId"], req.TurnID), @@ -290,25 +291,18 @@ func (c *Core) beeperStreamCarrierContents(streamType string, req MatrixPublishB AgentID: firstNonEmpty(req.AgentID, "ai"), Model: firstString(req.Part["model"], aistream.DefaultModel), } - part := agui.Event(copyOutboundEvent(req.Part)) - if part["timestamp"] == nil { - part["timestamp"] = time.Now().UnixMilli() + part := agui.NewEvent(map[string]any(copyOutboundEvent(req.Part))) + if !part.Has("timestamp") { + part.Set("timestamp", time.Now().UnixMilli()) } run.Events = []agui.Event{part} - carriers, err := aistream.PackRunFromSeq(run, req.EventID, aistream.CarrierBudgetBytes, seq) + carriers, err := aistream.PackRunFromSeq(run, seq) if err != nil { 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) + contents = append(contents, aistream.CarrierContent(run, carrier.Envelopes)) } return contents, aistream.NextSeq(carriers), nil } diff --git a/tsconfig.base.json b/tsconfig.base.json index 773531e..c22a44e 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -20,6 +20,8 @@ "@beeper/pickle/streams/beeper-message": ["packages/pickle/src/streams/beeper-message.ts"], "@beeper/pickle-ag-ui": ["packages/ag-ui/src/index.ts"], "@beeper/pickle-bridge": ["packages/bridge/src/index.ts"], + "@beeper/pickle-bridge/beeper-stream": ["packages/bridge/src/beeper-stream.ts"], + "@beeper/pickle-bridge/media-message": ["packages/bridge/src/media-message.ts"], "@beeper/pickle-bridge/types": ["packages/bridge/src/types.ts"], "@beeper/pickle-chat-adapter": ["packages/chat-adapter/src/index.ts"], "@beeper/pickle-cloudflare": ["packages/cloudflare/src/index.ts"], From 0349ca5dc730c93228ff150da7626b8657b464ad Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Tue, 2 Jun 2026 05:10:21 +0200 Subject: [PATCH 46/56] Refactor bridge core for bridgev2 room state handling --- AGENTS.md | 40 ++ package.json | 2 + packages/bridge/src/beeper-stream.test.ts | 88 ++- packages/bridge/src/beeper-stream.ts | 91 ++- packages/bridge/src/bridge.test.ts | 58 +- packages/bridge/src/bridge.ts | 88 ++- packages/bridge/src/events.ts | 28 + packages/bridge/src/types.ts | 77 ++- packages/openclaw/README.md | 8 +- packages/openclaw/openclaw.plugin.json | 128 +--- packages/openclaw/package.json | 4 - packages/openclaw/src/appservice.test.ts | 141 +--- packages/openclaw/src/appservice.ts | 91 +-- packages/openclaw/src/backfill.test.ts | 533 --------------- packages/openclaw/src/backfill.ts | 372 ----------- .../src/beeper-channel-config.schema.json | 71 +- .../src/beeper-channel-runtime.test.ts | 17 +- .../openclaw/src/beeper-channel-runtime.ts | 65 +- packages/openclaw/src/beeper-setup.test.ts | 30 - packages/openclaw/src/beeper-setup.ts | 20 +- packages/openclaw/src/beeper-turn-events.ts | 244 +------ packages/openclaw/src/bridge-agent.test.ts | 91 ++- packages/openclaw/src/bridge-agent.ts | 91 ++- packages/openclaw/src/cli.test.ts | 5 - packages/openclaw/src/cli.ts | 10 +- packages/openclaw/src/config.test.ts | 37 +- packages/openclaw/src/config.ts | 67 +- packages/openclaw/src/connector.test.ts | 393 ++++++----- packages/openclaw/src/connector.ts | 268 +++----- packages/openclaw/src/integration.test.ts | 15 +- .../openclaw/src/openclaw-extension.test.ts | 18 +- .../openclaw/src/openclaw-runtime.test.ts | 255 +++++-- packages/openclaw/src/openclaw-runtime.ts | 626 +++++++++++++----- packages/openclaw/src/protocol-coverage.ts | 2 +- packages/openclaw/src/registration.test.ts | 5 +- packages/openclaw/src/registration.ts | 10 - packages/openclaw/src/registry.test.ts | 9 +- packages/openclaw/src/registry.ts | 18 +- packages/openclaw/src/rooms.test.ts | 17 +- packages/openclaw/src/rooms.ts | 43 +- packages/openclaw/src/setup.test.ts | 205 ++++-- packages/openclaw/src/setup.ts | 224 ++----- packages/openclaw/src/types.ts | 17 +- packages/openclaw/tsdown.config.ts | 2 +- packages/pickle/native/go.mod | 2 +- packages/pickle/native/go.sum | 2 + .../native/internal/core/appservice_test.go | 75 ++- .../native/internal/core/beeper_ai_run.go | 612 ++++++++++++++++- .../internal/core/beeper_ai_run_test.go | 141 +++- packages/pickle/native/internal/core/core.go | 2 + .../pickle/native/internal/core/operations.go | 2 + packages/pickle/src/client-types.ts | 2 + packages/pickle/src/client.test.ts | 8 +- packages/pickle/src/client.ts | 1 + .../src/generated-runtime-operations.ts | 6 + .../pickle/src/generated-runtime-types.ts | 43 ++ packages/pickle/src/index.ts | 2 + packages/pickle/src/runtime-types.ts | 2 + scripts/openclaw-crabpot-full-test.mjs | 49 ++ 59 files changed, 2817 insertions(+), 2756 deletions(-) create mode 100644 AGENTS.md delete mode 100644 packages/openclaw/src/backfill.test.ts delete mode 100644 packages/openclaw/src/backfill.ts create mode 100644 scripts/openclaw-crabpot-full-test.mjs diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d7d4b85 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,40 @@ +# OpenClaw plugin + +Use Crabpot for OpenClaw plugin and integration compatibility testing. Crabpot is the OpenClaw compatibility testbed for community plugins, plugin API seams, channel registration, lifecycle hooks, provider capabilities, cold imports, workspace planning, and static execution policy. + +Expected sibling checkouts: + +```sh +/Users/batuhan/Projects/pickle +/Users/batuhan/Projects/crabpot +/Users/batuhan/Projects/openclaw +``` + +If Crabpot is missing, set it up from the Pickle repo with: + +```sh +git clone https://github.com/openclaw/crabpot.git ../crabpot +npm --prefix ../crabpot install +npm --prefix ../crabpot test +``` + +Crabpot defaults to `../openclaw` for source-mode OpenClaw checks. Clone `https://github.com/openclaw/openclaw.git` there when the checkout is missing, or set `CRABPOT_DIR=/path/to/crabpot` when Crabpot lives elsewhere. + +Run the full Pickle and OpenClaw plugin compatibility suite with: + +```sh +npm run full-test +``` + +That runs Pickle's existing `pnpm check` first, then `npm run check` in Crabpot through `scripts/openclaw-crabpot-full-test.mjs`. + +Useful narrower commands: + +```sh +npm run test:openclaw:plugins +npm --prefix ../crabpot run check +npm --prefix ../crabpot run report -- --check +npm --prefix ../crabpot run workspace:plan +``` + +Crabpot default checks are credential-free. Do not run opt-in isolated execution commands unless the task explicitly needs side effects, for example `CRABPOT_EXECUTE_ISOLATED=1 npm --prefix ../crabpot run workspace:execute -- --fixture `. diff --git a/package.json b/package.json index cfdcd50..a3111fa 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,14 @@ "check": "pnpm audit:surface && pnpm typecheck && pnpm test && pnpm test:go && pnpm build && pnpm pack:packages && pnpm smoke:consumer && pnpm smoke:cloudflare", "clean": "pnpm -r clean", "changeset": "changeset", + "full-test": "pnpm check && pnpm test:openclaw:plugins", "pack:packages": "mkdir -p .packs && pnpm -r --filter './packages/*' pack --pack-destination ./.packs", "release": "pnpm check && pnpm changeset publish", "smoke:cloudflare": "node scripts/smoke-cloudflare-worker.mjs", "smoke:consumer": "node scripts/package-consumer-smoke.mjs", "smoke:package-consumer": "node scripts/package-consumer-smoke.mjs", "test:go": "pnpm --filter @beeper/pickle test:go", + "test:openclaw:plugins": "node scripts/openclaw-crabpot-full-test.mjs", "test:e2e": "pnpm build && pnpm --dir e2e test", "test:e2e:adapter": "pnpm build && pnpm --dir e2e test:adapter", "test:e2e:browser:serve": "pnpm --dir e2e test:browser:serve", diff --git a/packages/bridge/src/beeper-stream.test.ts b/packages/bridge/src/beeper-stream.test.ts index dbc65bb..0090335 100644 --- a/packages/bridge/src/beeper-stream.test.ts +++ b/packages/bridge/src/beeper-stream.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { BeeperTurnStream, runBeeperTurnStream } from "./beeper-stream"; describe("Beeper AI turn stream publisher", () => { - it("starts one ai-bridge backed stream and appends canonical AG-UI events", async () => { + it("starts one ai-bridge backed stream and appends provider AG-UI events", async () => { const { appendEvent, client, finish, start } = createClient(); const publisher = new BeeperTurnStream({ agentId: "codex", @@ -25,6 +25,9 @@ describe("Beeper AI turn stream publisher", () => { agentId: "codex", agentName: "Codex", data: { agent_id: "codex" }, + initialEvents: [ + { messageId: "provider-msg", role: "assistant", type: "TEXT_MESSAGE_START" }, + ], model: "openclaw/plugin", roomId: "!room:example.com", runId: "turn_1", @@ -33,8 +36,7 @@ describe("Beeper AI turn stream publisher", () => { userId: "@sh-openclaw_agent_codex:example.com", }); expect(appendEvent.mock.calls.map(([options]) => options.event)).toEqual([ - { messageId: "msg-turn_1", role: "assistant", type: "TEXT_MESSAGE_START" }, - { delta: "hello", messageId: "msg-turn_1", type: "TEXT_MESSAGE_CONTENT" }, + { delta: "hello", messageId: "provider-msg", type: "TEXT_MESSAGE_CONTENT" }, ]); expect(finish).toHaveBeenCalledWith(expect.objectContaining({ finishReason: "stop", @@ -48,7 +50,34 @@ describe("Beeper AI turn stream publisher", () => { }); }); - it("does not promote provider message ids into additional Matrix stream messages", async () => { + it("leaves message identity canonicalization to the native ai-bridge run", async () => { + const { appendEvent, client, start } = createClient(); + const publisher = new BeeperTurnStream({ + client, + roomId: "!room:example.com", + turnId: "turn_multi", + }); + + await publisher.publishMany([ + { messageId: "answer_1", role: "assistant", type: "TEXT_MESSAGE_START" }, + { delta: "first", messageId: "answer_1", type: "TEXT_MESSAGE_CONTENT" }, + { messageId: "answer_2", role: "assistant", type: "TEXT_MESSAGE_START" }, + { delta: "second", messageId: "answer_2", type: "TEXT_MESSAGE_CONTENT" }, + ]); + await publisher.finalize(); + + expect(start).toHaveBeenCalledWith(expect.objectContaining({ + initialEvents: [ + { messageId: "answer_1", role: "assistant", type: "TEXT_MESSAGE_START" }, + { delta: "first", messageId: "answer_1", type: "TEXT_MESSAGE_CONTENT" }, + { messageId: "answer_2", role: "assistant", type: "TEXT_MESSAGE_START" }, + { delta: "second", messageId: "answer_2", type: "TEXT_MESSAGE_CONTENT" }, + ], + })); + expect(appendEvent).not.toHaveBeenCalled(); + }); + + it("appends provider events after the native stream is started", async () => { const { appendEvent, client, finish, start } = createClient(); const publisher = new BeeperTurnStream({ client, @@ -56,6 +85,7 @@ describe("Beeper AI turn stream publisher", () => { turnId: "turn_multi", }); + await publisher.start(); await publisher.publishMany([ { messageId: "answer_1", role: "assistant", type: "TEXT_MESSAGE_START" }, { delta: "first", messageId: "answer_1", type: "TEXT_MESSAGE_CONTENT" }, @@ -66,16 +96,16 @@ describe("Beeper AI turn stream publisher", () => { expect(start).toHaveBeenCalledTimes(1); expect(appendEvent.mock.calls.map(([options]) => [options.event.type, options.event.messageId, options.event.delta])).toEqual([ - ["TEXT_MESSAGE_START", "msg-turn_multi", undefined], - ["TEXT_MESSAGE_CONTENT", "msg-turn_multi", "first"], - ["TEXT_MESSAGE_START", "msg-turn_multi", undefined], - ["TEXT_MESSAGE_CONTENT", "msg-turn_multi", "second"], + ["TEXT_MESSAGE_START", "answer_1", undefined], + ["TEXT_MESSAGE_CONTENT", "answer_1", "first"], + ["TEXT_MESSAGE_START", "answer_2", undefined], + ["TEXT_MESSAGE_CONTENT", "answer_2", "second"], ]); expect(finish).toHaveBeenCalledTimes(1); }); it("keeps tool result message ids separate from the assistant message", async () => { - const { appendEvent, client } = createClient(); + const { appendEvent, client, start } = createClient(); const publisher = new BeeperTurnStream({ client, roomId: "!room:example.com", @@ -91,15 +121,35 @@ describe("Beeper AI turn stream publisher", () => { type: "TOOL_CALL_RESULT", }); - expect(appendEvent.mock.calls.map(([options]) => options.event)).toEqual([ - { + expect(appendEvent).not.toHaveBeenCalled(); + expect(start).toHaveBeenCalledWith(expect.objectContaining({ + initialEvents: [{ content: "{\"ok\":true}", messageId: "tool_1", role: "tool", state: "complete", toolCallId: "tool_1", type: "TOOL_CALL_RESULT", - }, + }], + })); + }); + + it("publishes semantic parts through the native ai-bridge writer", async () => { + const { appendPart, client, start } = createClient(); + const publisher = new BeeperTurnStream({ + client, + roomId: "!room:example.com", + turnId: "turn_part", + }); + + await publisher.publishPart({ kind: "text", text: "hello" }); + await publisher.publishPart({ kind: "tool_result", output: { ok: true }, toolCallId: "tool_1", toolName: "search" }); + + expect(start).toHaveBeenCalledWith(expect.objectContaining({ + initialParts: [{ kind: "text", text: "hello" }], + })); + expect(appendPart.mock.calls.map(([options]) => options)).toEqual([ + { kind: "tool_result", output: { ok: true }, runId: "turn_part", toolCallId: "tool_1", toolName: "search" }, ]); }); @@ -149,7 +199,7 @@ describe("Beeper AI turn stream publisher", () => { }); it("runs mapped provider events through one finalized turn stream", async () => { - const { appendEvent, client, finish } = createClient(); + const { appendEvent, client, finish, start } = createClient(); const publisher = new BeeperTurnStream({ client, roomId: "!room:example.com", @@ -162,9 +212,11 @@ describe("Beeper AI turn stream publisher", () => { stream: publisher, })).resolves.toMatchObject({ eventId: "$target" }); + expect(start).toHaveBeenCalledWith(expect.objectContaining({ + initialEvents: [{ delta: "a", type: "TEXT_MESSAGE_CONTENT" }], + })); expect(appendEvent.mock.calls.map(([options]) => options.event)).toEqual([ - { delta: "a", messageId: "msg-turn_runner", type: "TEXT_MESSAGE_CONTENT" }, - { delta: "b", messageId: "msg-turn_runner", type: "TEXT_MESSAGE_CONTENT" }, + { delta: "b", type: "TEXT_MESSAGE_CONTENT" }, ]); expect(finish).toHaveBeenCalledOnce(); }); @@ -193,6 +245,7 @@ describe("Beeper AI turn stream publisher", () => { })); expect(finish).not.toHaveBeenCalled(); }); + }); function createClient() { @@ -218,6 +271,8 @@ function createClient() { ])); const appendEvent = vi.fn(async ({ event, runId }: { event: Record; runId: string }) => result(runId, [event])); + const appendPart = vi.fn(async ({ runId }: { runId: string }) => + result(runId)); const finish = vi.fn(async ({ finishReason, runId }: { finishReason?: string; runId: string }) => result(runId, [{ finishReason: finishReason ?? "stop", runId, threadId: runId, type: "RUN_FINISHED" }])); const error = vi.fn(async ({ message, runId }: { message?: string; runId: string }) => @@ -226,11 +281,12 @@ function createClient() { beeper: { aiRunStreams: { appendEvent, + appendPart, error, finish, start, }, }, } as unknown as MatrixClient; - return { appendEvent, client, error, finish, start }; + return { appendEvent, appendPart, client, error, finish, start }; } diff --git a/packages/bridge/src/beeper-stream.ts b/packages/bridge/src/beeper-stream.ts index 881ee21..537b2a3 100644 --- a/packages/bridge/src/beeper-stream.ts +++ b/packages/bridge/src/beeper-stream.ts @@ -1,25 +1,13 @@ -import type { MatrixBeeper, MatrixBeeperAIRunStreamResult, SentEvent } from "@beeper/pickle"; +import type { MatrixBeeper, MatrixBeeperAIRunPartOptions, MatrixBeeperAIRunStreamResult, SentEvent } from "@beeper/pickle"; type AGUIEvent = Record & { type?: string }; +type BeeperTurnStreamPart = MatrixBeeperAIRunPartOptions; type FinishReason = "stop" | "length" | "content_filter" | "tool_calls"; const BEEPER_AI_STREAM_TYPE = "com.beeper.llm"; const EVENT_RUN_ERROR = "RUN_ERROR"; const EVENT_RUN_FINISHED = "RUN_FINISHED"; -const EVENT_RUN_STARTED = "RUN_STARTED"; -const EVENT_TEXT_MESSAGE_START = "TEXT_MESSAGE_START"; -const EVENT_TEXT_MESSAGE_CONTENT = "TEXT_MESSAGE_CONTENT"; -const EVENT_TEXT_MESSAGE_END = "TEXT_MESSAGE_END"; -const EVENT_REASONING_START = "REASONING_START"; -const EVENT_REASONING_MESSAGE_START = "REASONING_MESSAGE_START"; -const EVENT_REASONING_MESSAGE_CONTENT = "REASONING_MESSAGE_CONTENT"; -const EVENT_REASONING_MESSAGE_END = "REASONING_MESSAGE_END"; -const EVENT_REASONING_END = "REASONING_END"; -const EVENT_TOOL_CALL_START = "TOOL_CALL_START"; -const EVENT_TOOL_CALL_RESULT = "TOOL_CALL_RESULT"; -const EVENT_ACTIVITY_SNAPSHOT = "ACTIVITY_SNAPSHOT"; -const EVENT_ACTIVITY_DELTA = "ACTIVITY_DELTA"; export interface BeeperTurnStreamClient { beeper: MatrixBeeper; @@ -72,7 +60,6 @@ export class BeeperTurnStream { #eventId: string | undefined; #finalized = false; #initialMessageMetadata: Record; - #messageId: string | undefined; #model: string; #queue = new SerialQueue(); #started = false; @@ -108,13 +95,42 @@ export class BeeperTurnStream { await this.publishMany([event]); } + async publishPart(part: BeeperTurnStreamPart): Promise { + await this.publishParts([part]); + } + + async publishParts(parts: Iterable): Promise { + return this.#queue.run(async () => { + if (this.#finalized) throw new Error("Cannot publish to finalized Beeper stream"); + const batch = [...parts].map((part) => stripUndefined({ ...part })); + if (batch.length === 0) return; + if (!this.#started) { + await this.#ensureStarted({ parts: batch }); + return; + } + await this.#ensureStarted(); + for (const part of batch) { + await this.#client.beeper.aiRunStreams.appendPart({ + ...part, + runId: this.turnId, + }); + } + }); + } + async publishMany(events: Iterable): Promise { return this.#queue.run(async () => { - for (const event of events) { - if (this.#finalized) throw new Error("Cannot publish to finalized Beeper stream"); - await this.#ensureStarted(); + if (this.#finalized) throw new Error("Cannot publish to finalized Beeper stream"); + const batch = [...events].map((event) => stripUndefined({ ...event })); + if (batch.length === 0) return; + if (!this.#started) { + await this.#ensureStarted({ events: batch }); + return; + } + await this.#ensureStarted(); + for (const event of batch) { await this.#client.beeper.aiRunStreams.appendEvent({ - event: this.#canonicalizeEvent(event), + event, runId: this.turnId, }); } @@ -159,7 +175,7 @@ export class BeeperTurnStream { }); } - async #ensureStarted(): Promise { + async #ensureStarted(initial?: { events?: AGUIEvent[]; parts?: BeeperTurnStreamPart[] }): Promise { if (this.#started && this.#eventId) { return { descriptor: this.#descriptor ?? {}, @@ -172,6 +188,8 @@ export class BeeperTurnStream { ...(this.#agentId ? { agentId: this.#agentId } : {}), ...(this.#agentName ? { agentName: this.#agentName } : {}), data: this.#initialMessageMetadata, + ...(initial?.events?.length ? { initialEvents: initial.events } : {}), + ...(initial?.parts?.length ? { initialParts: initial.parts } : {}), model: this.#model, roomId: this.roomId, runId: this.turnId, @@ -193,26 +211,6 @@ export class BeeperTurnStream { #rememberStreamResult(result: MatrixBeeperAIRunStreamResult): void { this.#descriptor = recordValue(result.descriptor) ?? this.#descriptor; this.#eventId = result.eventId || this.#eventId; - this.#messageId = result.messageId || this.#messageId || `msg-${this.turnId}`; - } - - #canonicalizeEvent(event: AGUIEvent): AGUIEvent { - const canonical = { ...event }; - const messageId = this.#messageId ?? `msg-${this.turnId}`; - if (canonical.type === EVENT_RUN_STARTED || canonical.type === EVENT_RUN_FINISHED) { - canonical.runId = this.turnId; - canonical.threadId = this.turnId; - } - if (canonical.type === EVENT_RUN_ERROR && !stringValue(canonical.message)) { - canonical.message = terminalFallbackText(event); - } - if (usesCanonicalMessageId(canonical.type)) { - canonical.messageId = messageId; - } - if (canonical.type === EVENT_TOOL_CALL_START) { - canonical.parentMessageId = messageId; - } - return stripUndefined(canonical); } } @@ -248,19 +246,6 @@ class SerialQueue { } } -function usesCanonicalMessageId(type: unknown): boolean { - return type === EVENT_TEXT_MESSAGE_START || - type === EVENT_TEXT_MESSAGE_CONTENT || - type === EVENT_TEXT_MESSAGE_END || - type === EVENT_REASONING_START || - type === EVENT_REASONING_MESSAGE_START || - type === EVENT_REASONING_MESSAGE_CONTENT || - type === EVENT_REASONING_MESSAGE_END || - type === EVENT_REASONING_END || - type === EVENT_ACTIVITY_SNAPSHOT || - type === EVENT_ACTIVITY_DELTA; -} - function terminalFallbackText(event: AGUIEvent | undefined): string { if (!event) return ""; if (event.type === EVENT_RUN_ERROR) { diff --git a/packages/bridge/src/bridge.test.ts b/packages/bridge/src/bridge.test.ts index eac87a7..225ce4e 100644 --- a/packages/bridge/src/bridge.test.ts +++ b/packages/bridge/src/bridge.test.ts @@ -292,6 +292,7 @@ describe("RuntimeBridge", () => { convert: () => ({ parts: [{ content: { body: "hello from remote", msgtype: "m.text" }, + extra: { "com.beeper.ai": { kind: "anchor", schema: "com.beeper.ai.v1" } }, type: "m.room.message", }], }), @@ -303,7 +304,11 @@ describe("RuntimeBridge", () => { await bridge.flushRemoteEvents(); expect(client.raw.request).toHaveBeenCalledWith({ - body: { body: "hello from remote", msgtype: "m.text" }, + body: { + body: "hello from remote", + "com.beeper.ai": { kind: "anchor", schema: "com.beeper.ai.v1" }, + msgtype: "m.text", + }, method: "PUT", path: expect.stringContaining("/rooms/!room%3Aexample/send/m.room.message/pickle-bridge-"), }); @@ -336,6 +341,8 @@ describe("RuntimeBridge", () => { convertEdit: async () => ({ modifiedParts: [{ content: { body: "edited remote", msgtype: "m.text" }, + extra: { "com.beeper.ai": { kind: "final", schema: "com.beeper.ai.v1" } }, + topLevelExtra: { "com.beeper.dont_render_edited": true }, type: "m.room.message", }], }), @@ -402,10 +409,15 @@ describe("RuntimeBridge", () => { await bridge.flushRemoteEvents(); expect(client.messages.edit).toHaveBeenCalledWith({ - content: { body: "edited remote", msgtype: "m.text" }, + content: { + body: "edited remote", + "com.beeper.ai": { kind: "final", schema: "com.beeper.ai.v1" }, + msgtype: "m.text", + }, eventId: "$sent", roomId: "!room:example", text: "edited remote", + topLevelContent: { "com.beeper.dont_render_edited": true }, }); expect(client.reactions.send).toHaveBeenCalledWith({ eventId: "$edit", key: "+1", roomId: "!room:example" }); expect(client.reactions.redact).toHaveBeenCalledWith({ eventId: "$edit", key: "+1", roomId: "!room:example" }); @@ -417,6 +429,48 @@ describe("RuntimeBridge", () => { expect(client.typing.set).toHaveBeenCalledWith({ roomId: "!room:example", timeoutMs: 5000, typing: true }); }); + it("updates bundled Matrix event targets through bridgev2 remote events", async () => { + const client = createFakeMatrixClient(); + const connector = createFakeConnector(createFakeNetworkAPI()); + const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, client); + const login: UserLogin = { id: "login:a" }; + const portalKey = { id: "remote-room", receiver: login.id }; + + await bridge.start(); + bridge.registerPortal({ id: "remote-room", mxid: "!room:example", portalKey }); + bridge.queueRemoteEvent(login, { + convertEdit: async () => ({ + modifiedParts: [{ + content: { body: "stream final", msgtype: "m.text" }, + extra: { "com.beeper.ai": { kind: "final", schema: "com.beeper.ai.v1" } }, + type: "m.room.message", + }], + }), + getPortalKey: () => portalKey, + getSender: () => ({ isFromMe: true, sender: "@bot:example" }), + getTargetDBMessage: () => [{ id: "$stream", mxid: "$stream", partId: "0" }], + getTargetMessage: () => "$stream", + getType: () => "edit", + }); + bridge.queueRemoteEvent(login, { + getEmoji: () => "+1", + getID: () => "reaction-1", + getPortalKey: () => portalKey, + getSender: () => ({ isFromMe: true, sender: "@bot:example" }), + getTargetDBMessage: () => [{ id: "$stream", mxid: "$stream", partId: "0" }], + getTargetMessage: () => "$stream", + getType: () => "reaction", + }); + await bridge.flushRemoteEvents(); + + expect(client.messages.edit).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.objectContaining({ "com.beeper.ai": { kind: "final", schema: "com.beeper.ai.v1" } }), + eventId: "$stream", + roomId: "!room:example", + })); + expect(client.reactions.send).toHaveBeenCalledWith({ eventId: "$stream", key: "+1", roomId: "!room:example" }); + }); + it("dispatches Matrix read receipts and marked-unread account data to network clients", async () => { const client = createFakeMatrixClient(); const network = createFakeNetworkAPI(); diff --git a/packages/bridge/src/bridge.ts b/packages/bridge/src/bridge.ts index 7fb9531..fffc726 100644 --- a/packages/bridge/src/bridge.ts +++ b/packages/bridge/src/bridge.ts @@ -52,7 +52,10 @@ import type { MatrixIntent, MatrixCommand, MatrixCommandResponse, + ConvertedEdit, + ConvertedEditPart, ConvertedMessage, + ConvertedMessagePart, ManagementRoom, MessageRequest, MessageRequestHandlingNetworkAPI, @@ -1502,7 +1505,7 @@ export class RuntimeBridge implements PickleBridge { const converted = await event.convertMessage(this.#requestContext(), portal, this.#matrixIntent()); for (const [index, part] of converted.parts.entries()) { const sender = event.getSender(); - const sent = await this.#sendRemoteMessagePart(portal.mxid, sender.sender, part.content, eventTimestamp(event)); + const sent = await this.#sendRemoteMessagePart(portal.mxid, sender.sender, convertedPartContent(part), eventTimestamp(event)); const messageKey = messagePartKey(event.getID(), part.id ?? String(index)); const message = { eventId: sent.eventId, @@ -1534,24 +1537,59 @@ export class RuntimeBridge implements PickleBridge { throw new Error(`No Matrix message stored for remote edit target ${event.getTargetMessage()}`); } const converted = await event.convertEdit(this.#requestContext(), portal, this.#matrixIntent(), existing.db); + const matrixPortal = portal as Portal & { mxid: string }; + await this.#sendConvertedEdit(event, matrixPortal, converted, existing); + } + + async #sendConvertedEdit( + event: RemoteEdit, + portal: Portal & { mxid: string }, + converted: ConvertedEdit, + existing: { db: Message[]; sent: SentEvent[] } + ): Promise { for (const [index, part] of converted.modifiedParts.entries()) { - const target = this.#matchingRemoteTarget(existing.sent, part.id, index); + const target = this.#convertedEditPartTarget(part, existing, 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)); + let sent = target; + if (!part.dontBridge) { + sent = await this.#matrixClient.messages.edit({ + content: convertedPartContent(part), + eventId: target.eventId, + roomId: portal.mxid ?? target.roomId, + text: stringValue(part.content.body) ?? "", + ...(part.topLevelExtra ? { topLevelContent: part.topLevelExtra } : {}), + }); + } + const messageKey = messagePartKey(event.getTargetMessage(), part.id ?? part.part?.partId ?? String(index)); const message = { eventId: sent.eventId, raw: sent.raw, - roomId: sent.roomId, + roomId: sent.roomId || portal.mxid, }; this.#messages.set(messageKey, message); await this.#dataStore?.setMessage(messageKey, message); } + for (const part of converted.deletedParts ?? []) { + if (!part.mxid) continue; + await this.#matrixClient.messages.redact({ + eventId: part.mxid, + roomId: portal.mxid, + }); + } + if (converted.addedParts) { + for (const [index, part] of converted.addedParts.parts.entries()) { + const sender = event.getSender(); + const sent = await this.#sendRemoteMessagePart(portal.mxid, sender.sender, convertedPartContent(part), eventTimestamp(event)); + const messageKey = messagePartKey(event.getTargetMessage(), part.id ?? `added-${index}`); + const message = { + eventId: sent.eventId, + raw: sent.raw, + roomId: sent.roomId, + }; + this.#messages.set(messageKey, message); + await this.#dataStore?.setMessage(messageKey, message); + } + } } async #handleRemoteReaction(event: RemoteReaction): Promise { @@ -1695,7 +1733,7 @@ export class RuntimeBridge implements PickleBridge { const converted = await message.convertMessage(this.#requestContext(), portal, this.#matrixIntent()); for (const part of converted.parts) { const event: MatrixAppserviceBatchSendOptions["events"][number] = { - content: part.content, + content: convertedPartContent(part), sender: message.getSender().sender, }; const timestamp = eventTimestamp(message); @@ -1724,6 +1762,18 @@ export class RuntimeBridge implements PickleBridge { } async #remoteTargetMessage(event: RemoteEdit | RemoteReaction | RemoteReactionRemove | RemoteMessageRemove): Promise { + if (hasMethod(event, "getTargetDBMessage")) { + const bundled = (event as RemoteEventWithBundledParts).getTargetDBMessage(); + for (const message of bundled) { + if (message.mxid) { + return { + eventId: message.mxid, + raw: message.metadata, + roomId: this.#portalForRemoteEvent(event)?.mxid ?? "", + }; + } + } + } const partId = hasMethod(event, "getTargetMessagePart") ? (event as RemoteEventWithTargetPart).getTargetMessagePart() : "0"; @@ -1769,6 +1819,18 @@ export class RuntimeBridge implements PickleBridge { return existing[index] ?? existing[0]; } + #convertedEditPartTarget(part: ConvertedEditPart, existing: { db: Message[]; sent: SentEvent[] }, index: number): SentEvent | undefined { + if (part.part?.mxid) { + return { + eventId: part.part.mxid, + raw: part.part.metadata, + roomId: existing.sent[index]?.roomId ?? existing.sent[0]?.roomId ?? "", + }; + } + const partId = part.id ?? part.part?.partId; + return this.#matchingRemoteTarget(existing.sent, partId, index); + } + async #sendRemoteMessagePart(roomId: string, sender: string, content: Record, timestamp?: number): Promise { if (this.#appserviceOptions && sender.startsWith("@")) { const sendOptions = stripUndefined({ @@ -2264,3 +2326,7 @@ function convertedMessageFromOptions(options: BridgeRemoteMessageOptions { + return part.extra ? { ...part.content, ...part.extra } : part.content; +} diff --git a/packages/bridge/src/events.ts b/packages/bridge/src/events.ts index bf8e55c..193a685 100644 --- a/packages/bridge/src/events.ts +++ b/packages/bridge/src/events.ts @@ -1,10 +1,13 @@ import type { BridgeRequestContext, + ChatInfoChange, ConvertedMessage, + CreateRemoteChatInfoChangeOptions, CreateRemoteMessageOptions, MatrixIntent, MessageID, Portal, + RemoteChatInfoChange, RemoteEventType, RemoteMessage, RemoteMessageWithTransactionID, @@ -49,3 +52,28 @@ export function createRemoteMessage(options: CreateRemoteMessageOptions): }, }; } + +export function createRemoteChatInfoChange(options: CreateRemoteChatInfoChangeOptions): RemoteChatInfoChange { + const timestamp = options.timestamp ?? new Date(); + const streamOrder = options.streamOrder ?? timestamp.getTime(); + return { + getChatInfoChange(): ChatInfoChange { + return options.chatInfoChange; + }, + getPortalKey() { + return options.portalKey; + }, + getSender() { + return options.sender; + }, + getStreamOrder() { + return streamOrder; + }, + getTimestamp() { + return timestamp; + }, + getType(): RemoteEventType { + return "chat_info_change"; + }, + }; +} diff --git a/packages/bridge/src/types.ts b/packages/bridge/src/types.ts index fdf1348..fb6d0e2 100644 --- a/packages/bridge/src/types.ts +++ b/packages/bridge/src/types.ts @@ -10,6 +10,7 @@ import type { MatrixEventSender, MatrixMessageEvent, MatrixReactionEvent, + RoomStateEvent, MatrixStore, SendMediaMessageOptions, SentEvent, @@ -208,7 +209,7 @@ export interface PollHandlingNetworkAPI extends NetworkAPI { } export interface DisappearTimerChangingNetworkAPI extends NetworkAPI { - handleMatrixDisappearTimer(ctx: BridgeRequestContext, msg: MatrixDisappearTimer): Promise; + handleMatrixDisappearTimer(ctx: BridgeRequestContext, msg: MatrixDisappearTimer): Promise | boolean; } export interface MembershipHandlingNetworkAPI extends NetworkAPI { @@ -216,15 +217,15 @@ export interface MembershipHandlingNetworkAPI extends NetworkAPI { } export interface RoomNameHandlingNetworkAPI extends NetworkAPI { - handleMatrixRoomName(ctx: BridgeRequestContext, msg: MatrixRoomName): Promise; + handleMatrixRoomName(ctx: BridgeRequestContext, msg: MatrixRoomName): Promise | boolean; } export interface RoomTopicHandlingNetworkAPI extends NetworkAPI { - handleMatrixRoomTopic(ctx: BridgeRequestContext, msg: MatrixRoomTopic): Promise; + handleMatrixRoomTopic(ctx: BridgeRequestContext, msg: MatrixRoomTopic): Promise | boolean; } export interface RoomAvatarHandlingNetworkAPI extends NetworkAPI { - handleMatrixRoomAvatar(ctx: BridgeRequestContext, msg: MatrixRoomAvatar): Promise; + handleMatrixRoomAvatar(ctx: BridgeRequestContext, msg: MatrixRoomAvatar): Promise | boolean; } export interface MuteHandlingNetworkAPI extends NetworkAPI { @@ -410,7 +411,7 @@ export interface RemotePostHandler extends RemoteEvent { } export interface RemoteChatInfoChange extends RemoteEvent { - getChatInfoChange(ctx: BridgeRequestContext): Promise; + getChatInfoChange(ctx: BridgeRequestContext): Promise | ChatInfoChange; } export interface RemoteChatResync extends RemoteEvent {} @@ -511,6 +512,7 @@ export interface PickleBridge { readonly client: MatrixClient | null; readonly connector: BridgeConnector; readonly context: BridgeContext | null; + readonly roomState: BridgeRoomStateAPI; acceptMessageRequest(portalKey: PortalKey): Promise; createLogin(user: BridgeUser, flowId: string): Promise; createManagementRoom(options: BridgeCreateManagementRoomOptions): Promise; @@ -551,6 +553,25 @@ export interface PickleBridge { uploadMedia(options: UploadMediaOptions): Promise; } +export interface BridgeRoomStateAPI { + get(options: BridgeRoomStateGetOptions): Promise; + set(options: BridgeRoomStateSetOptions): Promise; +} + +export interface BridgeRoomStateGetOptions { + eventType: string; + roomId: RoomID; + stateKey?: string; +} + +export interface BridgeRoomStateSetOptions { + content: Record; + eventType: string; + portal?: Portal; + roomId?: RoomID; + stateKey?: string; +} + export interface CreateBridgeOptions { appservice?: MatrixAppserviceInitOptions; beeper?: BridgeBeeperOptions; @@ -809,12 +830,15 @@ export interface UserLogin { } export interface Portal { + avatar?: Avatar; id: PortalID; metadata?: unknown; mxid?: string; + name?: string; portalKey: PortalKey; receiver?: UserLoginID; roomType?: "dm" | "group" | "space" | string; + topic?: string; } export interface Ghost { @@ -948,7 +972,19 @@ export interface ConvertedMessagePart { } export interface ConvertedEdit { - modifiedParts: ConvertedMessagePart[]; + addedParts?: ConvertedMessage; + deletedParts?: Message[]; + modifiedParts: ConvertedEditPart[]; +} + +export interface ConvertedEditPart { + content: Record; + dontBridge?: boolean; + extra?: Record; + id?: PartID; + part?: Message; + topLevelExtra?: Record; + type: string; } export interface UpsertResult { @@ -974,6 +1010,14 @@ export interface CreateRemoteMessageOptions { type?: "message" | "message_upsert"; } +export interface CreateRemoteChatInfoChangeOptions { + chatInfoChange: ChatInfoChange; + portalKey: PortalKey; + sender: EventSender; + streamOrder?: number; + timestamp?: Date; +} + export interface BridgeRemoteEventOptions { event: Omit & Record; portal: PortalReference; @@ -1148,17 +1192,28 @@ export interface MessageCheckpoints { export interface ChatInfo { avatar?: Avatar; + canBackfill?: boolean; + extraUpdates?: Record; + members?: ChatMemberList; name?: string; participants?: UserID[]; + roomType?: "dm" | "group" | "space" | string; topic?: string; } export interface ChatInfoChange { - avatar?: Avatar; - name?: string; - participantsAdded?: UserID[]; - participantsRemoved?: UserID[]; - topic?: string; + chatInfo?: ChatInfo; + memberChanges?: ChatMemberList; +} + +export interface ChatMember { + membership?: "join" | "invite" | "leave" | "ban" | "knock" | string; + userId: UserID; +} + +export interface ChatMemberList { + isFull?: boolean; + members: ChatMember[]; } export interface Avatar { diff --git a/packages/openclaw/README.md b/packages/openclaw/README.md index 20fb450..cca34d2 100644 --- a/packages/openclaw/README.md +++ b/packages/openclaw/README.md @@ -17,15 +17,14 @@ OpenClaw loads the runtime entry from `dist/plugin-entry.mjs` and the lightweigh - Beeper email-code login for existing accounts, with username/password login available when needed. - Beeper appservice registration for the OpenClaw bridge. - OpenClaw channel metadata, setup entrypoint, runtime entrypoint, and ClawHub install metadata. -- Pickle bridgev2-style transport for Matrix portals, media, reactions, receipts, and backfill. +- Pickle bridgev2-style transport for Matrix portals, media, reactions, and receipts. - Direct in-process OpenClaw plugin runtime access. -- Agent ghosts for OpenClaw agents and user ghosts for imported one-to-one sessions. +- Agent ghosts for OpenClaw agents. - Beeper contact-list/search and create-DM provisioning for OpenClaw agents. - Matrix parsing for text, formatted bodies, replies, edits, reactions, redactions, attachments, and thread/relation metadata. - Native Beeper stream publishing for reasoning, text, tool input/output, approvals, errors, aborts, and final replacement messages. - OpenClaw-native command discovery and approval surfaces. - Non-federated Matrix room creation defaults through the generated appservice registration. -- Opt-in backfill/import helpers for dashboard, TUI, channel-origin, and archived one-to-one OpenClaw sessions. ## CLI @@ -50,9 +49,6 @@ The bridge runtime itself is started by OpenClaw when the installed channel plug ## Programmatic Runtime ```ts -import { - backfillAllOpenClawSessions, -} from "@beeper/openclaw/backfill"; import { readConfig, } from "@beeper/openclaw/config"; diff --git a/packages/openclaw/openclaw.plugin.json b/packages/openclaw/openclaw.plugin.json index 7125e97..a9b04e6 100644 --- a/packages/openclaw/openclaw.plugin.json +++ b/packages/openclaw/openclaw.plugin.json @@ -10,25 +10,7 @@ ], "channelEnvVars": { "beeper": [ - "PICKLE_OPENCLAW_ALLOW_ROOMS", - "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_BEEPER_ENV", - "PICKLE_OPENCLAW_BRIDGE_ID", - "PICKLE_OPENCLAW_BRIDGE_MANAGER_TOKEN", - "PICKLE_OPENCLAW_CONTACT_VISIBILITY", - "PICKLE_OPENCLAW_DATA_DIR", - "PICKLE_OPENCLAW_DEVICE_ID", - "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_BEEPER_ENV" ] }, "configSchema": { @@ -40,30 +22,8 @@ "beeper": { "schema": { "type": "object", - "additionalProperties": false, + "additionalProperties": true, "properties": { - "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." @@ -77,92 +37,10 @@ "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." } } }, - "uiHints": { - "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 - } - }, + "uiHints": {}, "label": "Beeper", "description": "Bridge OpenClaw sessions and agents into Beeper.", "commands": { diff --git a/packages/openclaw/package.json b/packages/openclaw/package.json index b675023..dbbf66d 100644 --- a/packages/openclaw/package.json +++ b/packages/openclaw/package.json @@ -31,10 +31,6 @@ "types": "./dist/appservice.d.mts", "import": "./dist/appservice.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/appservice.test.ts b/packages/openclaw/src/appservice.test.ts index c381061..86a94d8 100644 --- a/packages/openclaw/src/appservice.test.ts +++ b/packages/openclaw/src/appservice.test.ts @@ -2,7 +2,6 @@ import type { CreateNodeBeeperBridgeOptions, PickleBridge } from "@beeper/pickle import { describe, expect, it, vi } from "vitest"; import { createDefaultConfig } from "./config"; import { createOpenClawBeeperBridge, startOpenClawBeeperBridge } from "./appservice"; -import { OpenClawPluginRuntimeAdapter, type OpenClawRuntimeRequestSurface } from "./openclaw-runtime"; import { OpenClawBridgeRegistry } from "./registry"; describe("OpenClaw Beeper appservice runtime", () => { @@ -11,7 +10,6 @@ describe("OpenClaw Beeper appservice runtime", () => { const bridgeFactory = vi.fn(async (_options: CreateNodeBeeperBridgeOptions) => bridge); const config = createDefaultConfig({ beeperEnv: "staging", - bridgeManagerToken: "hungry-token", dataDir: "/tmp/openclaw", asToken: "as-token", homeserver: "https://matrix.beeper-staging.com", @@ -32,7 +30,6 @@ describe("OpenClaw Beeper appservice runtime", () => { baseDomain: "beeper-staging.com", bridge: "sh-openclaw", bridgeManagerPostState: true, - bridgeManagerToken: "hungry-token", bridgeType: "openclaw", connector: expect.objectContaining({ config, @@ -122,127 +119,16 @@ describe("OpenClaw Beeper appservice runtime", () => { }); }); - 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({ - asToken: "as-token", - dataDir: "/tmp/openclaw", - homeserver: "https://matrix.beeper.com", - hsToken: "hs-token", - 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({ - 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("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({ - asToken: "as-token", - dataDir: "/tmp/openclaw", - homeserver: "https://matrix.beeper.com", - hsToken: "hs-token", - importSources: ["dashboard"], - matrixDeviceId: "DEVICE", - matrixUserId: "@batuhan:beeper.com", - }); - - await expect(startOpenClawBeeperBridge({ - 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 }); - + it("does not run historical imports during bridge startup", async () => { + const bridge = fakeBridge(); await expect(startOpenClawBeeperBridge({ - account: account(), - backfill: true, bridgeFactory: async () => bridge, config: createDefaultConfig({ asToken: "as-token", dataDir: "/tmp/openclaw", homeserver: "https://matrix.beeper.com", hsToken: "hs-token", - importSources: ["dashboard"], - matrixDeviceId: "DEVICE", - matrixUserId: "@batuhan:beeper.com", }), - registry, })).resolves.toBe(bridge); expect(bridge.start).toHaveBeenCalledOnce(); @@ -250,35 +136,12 @@ describe("OpenClaw Beeper appservice runtime", () => { }); }); -function account() { - return { - accessToken: "mx-token", - deviceId: "DEVICE", - homeserver: "https://matrix.beeper.com", - userId: "@batuhan:beeper.com", - }; -} - 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; } - -function runtimeWith(options: { - responses: Record; -}): OpenClawPluginRuntimeAdapter & { transport: OpenClawRuntimeRequestSurface & { request: ReturnType } } { - const transport = { - async *events() {}, - request: vi.fn(async (method: string) => options.responses[method]), - }; - return new OpenClawPluginRuntimeAdapter({ - config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), - transport, - }) as OpenClawPluginRuntimeAdapter & { transport: OpenClawRuntimeRequestSurface & { request: ReturnType } }; -} diff --git a/packages/openclaw/src/appservice.ts b/packages/openclaw/src/appservice.ts index 25abf4a..bbb7994 100644 --- a/packages/openclaw/src/appservice.ts +++ b/packages/openclaw/src/appservice.ts @@ -4,18 +4,13 @@ import { type CreateNodeBeeperBridgeOptions, type PickleBridge, } from "@beeper/pickle-bridge"; -import { backfillAllOpenClawSessions } from "./backfill"; import { beeperBaseDomain } from "./beeper-setup"; import { DEFAULT_BEEPER_BRIDGE_TYPE } from "./ids"; -import { createOpenClawConnector, userLoginFromOpenClawConfig, type OpenClawConnectorOptions } from "./connector"; -import { createOpenClawHostRuntimeAdapter, OpenClawPluginRuntimeAdapter, type OpenClawSessionHistoryRuntime } from "./openclaw-runtime"; +import { createOpenClawConnector, type OpenClawConnectorOptions } from "./connector"; import { createAppserviceRegistration } from "./registration"; -import { OpenClawBridgeRegistry } from "./registry"; import type { OpenClawBridgeConfig } from "./types"; export interface CreateOpenClawBeeperBridgeOptions extends OpenClawConnectorOptions { - backfill?: boolean; - backfillLimit?: number; bridge?: string; bridgeFactory?: (options: CreateNodeBeeperBridgeOptions) => Promise; bridgeType?: string; @@ -39,7 +34,6 @@ export async function createOpenClawBeeperBridge(options: CreateOpenClawBeeperBr bridgeOptions.address = "websocket"; const baseDomain = beeperBaseDomain(config?.beeperEnv); if (baseDomain !== undefined) bridgeOptions.baseDomain = baseDomain; - if (config?.bridgeManagerToken !== undefined) bridgeOptions.bridgeManagerToken = config.bridgeManagerToken; bridgeOptions.bridgeManagerPostState = true; if (config?.homeserverDomain !== undefined) bridgeOptions.homeserverDomain = config.homeserverDomain; if (options.dataDir !== undefined) bridgeOptions.dataDir = options.dataDir; @@ -56,53 +50,9 @@ export async function startOpenClawBeeperBridge(options: CreateOpenClawBeeperBri const bridge = await createOpenClawBeeperBridge(options); await bridge.start(); await bridge.setBridgeState("running"); - if (options.backfill) { - 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 = tryResolveOpenClawHistoryRuntime(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), - }); - } -} - function connectorOptions(options: CreateOpenClawBeeperBridgeOptions): OpenClawConnectorOptions { const output: OpenClawConnectorOptions = {}; if (options.config !== undefined) output.config = options.config; @@ -113,45 +63,6 @@ function connectorOptions(options: CreateOpenClawBeeperBridgeOptions): OpenClawC return output; } -function resolveOpenClawHistoryRuntime(options: CreateOpenClawBeeperBridgeOptions, config: OpenClawBridgeConfig): OpenClawSessionHistoryRuntime { - if (options.runtime instanceof OpenClawPluginRuntimeAdapter) return options.runtime; - if (options.runtime !== undefined) { - 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 OpenClawPluginRuntimeAdapter) return runtime; - } - throw new Error("OpenClaw direct plugin runtime is required"); -} - -function tryResolveOpenClawHistoryRuntime( - options: CreateOpenClawBeeperBridgeOptions, - config: OpenClawBridgeConfig -): OpenClawSessionHistoryRuntime | undefined { - try { - return resolveOpenClawHistoryRuntime(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 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/backfill.test.ts b/packages/openclaw/src/backfill.test.ts deleted file mode 100644 index 48f9930..0000000 --- a/packages/openclaw/src/backfill.test.ts +++ /dev/null @@ -1,533 +0,0 @@ -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"; -import { OpenClawPluginRuntimeAdapter, type OpenClawRuntimeRequestSurface } from "./openclaw-runtime"; -import { OpenClawBridgeRegistry } from "./registry"; - -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: "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" }, - ], - }, - }); - - await expect(discoverOneToOneSessions(runtime, { importSources: ["dashboard", "tui", "channels"] })).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: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: { - displayName: "user-1", - ghostUserId: "@sh-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", - 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", 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 }, - ], - }, - }); - try { - await expect(buildBackfillImport(runtime, createDefaultConfig({ dataDir: "/tmp/openclaw" }), { - agentId: "main", - human: { - displayName: "Alice", - ghostUserId: "@sh-openclaw_user_alice:localhost", - userId: "alice", - }, - 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: "@sh-openclaw_agent_main:localhost", - humanGhostUserId: "@sh-openclaw_user_alice:localhost", - label: "Terminal", - owner: "imported", - roomId: "!room:example.com", - sessionKey: "agent:main:terminal:local", - }, - human: { - displayName: "Alice", - ghostUserId: "@sh-openclaw_user_alice:localhost", - userId: "alice", - }, - messages: [ - { - content: { - body: "hello", - msgtype: "m.text", - "com.beeper.openclaw.backfill": { messageSeq: 1, role: "user" }, - }, - id: "m1", - role: "user", - sender: "human", - seq: 1, - timestamp: new Date("2026-05-16T11:59:00.000Z"), - }, - { - content: { - body: "hi", - msgtype: "m.text", - "com.beeper.openclaw.backfill": { messageSeq: 2, role: "assistant" }, - }, - id: "m2", - role: "assistant", - sender: "agent", - seq: 2, - timestamp: new Date(1_779_000_000_000), - }, - ], - 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); - }); - - 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); - 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: "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" }, - ]); - }); - - 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 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 () => ({ - 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: ["channels"], - limit: 25, - login, - registry, - runtime, - })).resolves.toMatchObject({ - portals: [{ mxid: "!room:example.com" }], - sessions: [{ agentId: "codex", sessionKey: "agent:codex:whatsapp:alice" }], - skipped: [], - }); - - expect(bridge.createPortal).toHaveBeenCalledWith(login, expect.objectContaining({ - creationContent: { "m.federate": false }, - metadata: { - openclaw: { - agentId: "codex", - ghostUserId: "@sh-openclaw_agent_codex:localhost", - humanGhostUserId: "@sh-openclaw_user_alice:localhost", - sessionKey: "agent:codex:whatsapp:alice", - source: "channel", - }, - }, - name: "Alice", - roomType: "dm", - sender: "@sh-openclaw_agent_codex:localhost", - })); - expect(bridge.backfillPortal).toHaveBeenCalledWith(login, expect.objectContaining({ - mxid: "!room:example.com", - }), { limit: 25 }); - 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: "@sh-openclaw_user_alice:localhost", - roomId: "!room:example.com", - }); - }); - - 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: "@sh-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("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("always creates non-federated Beeper appservice rooms", async () => { - const runtime = runtimeWith({ - "chat.history": { messages: [] }, - "sessions.list": { - sessions: [ - { agentId: "codex", chatType: "dm", displayName: "Alice", key: "agent:codex:terminal:alice", origin: { surface: "terminal" } }, - ], - }, - }); - 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]).toHaveProperty("creationContent", { "m.federate": false }); - }); - - 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", - sender: "@sh-openclaw_agent_main:localhost", - })); - 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: "@sh-openclaw_agent_main:matrix.beeper-staging.com", - }); - registry.upsertBinding({ - agentId: "main", - createdAt: 1, - ghostUserId: "@sh-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("@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 () => { - 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): OpenClawPluginRuntimeAdapter & { - transport: OpenClawRuntimeRequestSurface & { request: ReturnType }; -} { - const transport = { - async *events() {}, - request: vi.fn(async (method: string) => responses[method]), - }; - return new OpenClawPluginRuntimeAdapter({ - config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), - transport, - }) as OpenClawPluginRuntimeAdapter & { transport: OpenClawRuntimeRequestSurface & { request: ReturnType } }; -} diff --git a/packages/openclaw/src/backfill.ts b/packages/openclaw/src/backfill.ts deleted file mode 100644 index 4d79a46..0000000 --- a/packages/openclaw/src/backfill.ts +++ /dev/null @@ -1,372 +0,0 @@ -import type { BridgeCreatePortalOptions, PickleBridge, Portal, UserLogin } from "@beeper/pickle-bridge"; -import type { - OpenClawChatHistoryMessage, - OpenClawSessionHistoryRuntime, - OpenClawListedSession, -} from "./openclaw-runtime"; -import { agentContactFromOpenClawAgent, agentGhostUserId, bindingIdForRoom, userContactFromOpenClawSession } from "./rooms"; -import type { OpenClawBridgeRegistry } from "./registry"; -import type { OpenClawBridgeConfig, OpenClawImportSource, OpenClawSessionBinding, OpenClawUserContact } from "./types"; - -export interface OpenClawBackfillSession { - agentId: string; - human?: OpenClawUserContact; - 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; - timestamp?: Date; -} - -export interface OpenClawBackfillImport { - binding: OpenClawSessionBinding; - human?: OpenClawUserContact; - messages: OpenClawBackfillMessage[]; - source: OpenClawBackfillSession["source"]; -} - -export interface BackfillAllOpenClawSessionsOptions { - bridge: PickleBridge; - importSources?: OpenClawImportSource[]; - limit?: number; - login: UserLogin; - registry: OpenClawBridgeRegistry; - runtime: OpenClawSessionHistoryRuntime; -} - -export interface BackfillAllOpenClawSessionsResult { - portals: Portal[]; - sessions: OpenClawBackfillSession[]; - skipped: OpenClawBackfillSession[]; -} - -export async function discoverOneToOneSessions( - runtime: OpenClawSessionHistoryRuntime, - 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, - 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]; - }); -} - -export async function buildBackfillImport( - runtime: Pick, - 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) - ); - const binding: OpenClawSessionBinding = { - 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(), - }; - if (session.human !== undefined) binding.humanGhostUserId = session.human.ghostUserId; - return { - binding, - ...(session.human !== undefined ? { human: session.human } : {}), - messages, - source: session.source, - }; -} - -export async function backfillAllOpenClawSessions(options: BackfillAllOpenClawSessionsOptions): Promise { - 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[] = []; - 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) { - const existingBinding = options.registry.getBindingBySessionKey(session.sessionKey); - if (existingBinding) { - healBindingGhosts(options.runtime.config, options.registry, existingBinding); - skipped.push(session); - continue; - } - 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); - const portalOptions: BridgeCreatePortalOptions = { - 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: agent.ghostUserId, - }; - 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); - portals.push(portal); - 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); - 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 }; -} - -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", - sender: agent.ghostUserId, - }; - 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" || isDashboardSurface(originType); -} - -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) return normalized.has("archived"); - 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); - const sender = role === "assistant" || role === "tool" ? "agent" : role === "system" ? "system" : "human"; - const normalized: OpenClawBackfillMessage = { - content: { - body: text || JSON.stringify(message.content ?? message), - msgtype: sender === "system" ? "m.notice" : "m.text", - "com.beeper.openclaw.backfill": { - messageSeq: message.messageSeq ?? index, - role, - }, - }, - id: typeof message.id === "string" ? message.id : `history_${index}`, - role, - sender, - 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 { - 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); - 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 ""; - return content.map((part) => { - if (typeof part === "string") return part; - const record = recordValue(part); - return stringValue(record?.text) ?? stringValue(record?.content) ?? ""; - }).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; -} - -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; -} - -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 index a6b446d..fcbfcd4 100644 --- a/packages/openclaw/src/beeper-channel-config.schema.json +++ b/packages/openclaw/src/beeper-channel-config.schema.json @@ -1,25 +1,7 @@ { "type": "object", - "additionalProperties": false, + "additionalProperties": true, "properties": { - "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." @@ -28,57 +10,6 @@ "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-channel-runtime.test.ts b/packages/openclaw/src/beeper-channel-runtime.test.ts index 9c10263..13858c2 100644 --- a/packages/openclaw/src/beeper-channel-runtime.test.ts +++ b/packages/openclaw/src/beeper-channel-runtime.test.ts @@ -115,12 +115,13 @@ describe("BeeperChannelRuntime", () => { expect(client.messages.send).not.toHaveBeenCalled(); }); - it("rejects non-OpenClaw message ids for bridge mutation actions", async () => { + it("queues Matrix event ids as bundled bridge update targets", 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(), + queueRemoteEvent: vi.fn((_login: unknown, event: unknown) => queued.push(event)), }; const runtime = new BeeperChannelRuntime({ bridge: bridge as never, @@ -128,8 +129,16 @@ describe("BeeperChannelRuntime", () => { login: { id: "openclaw:plugin" }, }); - await expect(runtime.edit({ eventId: "$matrix", roomId: "!room", text: "edit" })) - .rejects.toThrow("can only target OpenClaw bridge message ids"); + await runtime.edit({ eventId: "$matrix", roomId: "!room", text: "edit" }); + + const event = queued[0] as { + getTargetDBMessage: () => Array<{ id: string; mxid: string; partId: string }>; + getTargetMessage: () => string; + getType: () => string; + }; + expect(event.getType()).toBe("edit"); + expect(event.getTargetMessage()).toBe("$matrix"); + expect(event.getTargetDBMessage()).toEqual([{ id: "$matrix", mxid: "$matrix", partId: "0" }]); expect(client.messages.edit).not.toHaveBeenCalled(); }); diff --git a/packages/openclaw/src/beeper-channel-runtime.ts b/packages/openclaw/src/beeper-channel-runtime.ts index 2164b96..77a66b4 100644 --- a/packages/openclaw/src/beeper-channel-runtime.ts +++ b/packages/openclaw/src/beeper-channel-runtime.ts @@ -4,9 +4,11 @@ import type { MatrixClient, SentEvent } from "@beeper/pickle"; import { createRemoteMessage, type PickleBridge, + type Message, type PortalKey, type RemoteDeliveryReceipt, type RemoteEdit, + type RemoteEventWithBundledParts, type RemoteMarkUnread, type RemoteMessageRemove, type RemoteReadReceipt, @@ -245,34 +247,37 @@ export class BeeperChannelRuntime { } async #queueRemoteEdit(roomId: string, targetMessageId: string, content: Record): Promise { - const targetId = openClawTargetId(targetMessageId); + const target = openClawTarget(targetMessageId); const route = this.#bridgeRoute(roomId); const messageId = openClawRemoteId(); - const event: RemoteEdit = { - convertEdit: async () => ({ + const event: RemoteEdit & Partial = { + convertEdit: async (_ctx, _portal, _intent, existing) => ({ modifiedParts: [{ content, + ...(existing[0] ? { part: existing[0] } : {}), type: "m.room.message", }], }), getPortalKey: () => route.portalKey, getSender: () => this.#eventSender(roomId), - getTargetMessage: () => targetId, + ...bundledTargetMethods(target), + getTargetMessage: () => target.messageId, getType: () => "edit", }; route.bridge.queueRemoteEvent(route.login, event); await route.bridge.flushRemoteEvents(); this.recordOutboundActivity(); - return { eventId: messageId, raw: { bridgeQueued: true, targetMessageId: targetId }, roomId }; + return { eventId: messageId, raw: { bridgeQueued: true, targetMessageId: target.messageId }, roomId }; } async #queueRemoteMessageRemove(roomId: string, targetMessageId: string): Promise { - const targetId = openClawTargetId(targetMessageId); + const target = openClawTarget(targetMessageId); const route = this.#bridgeRoute(roomId); - const event: RemoteMessageRemove = { + const event: RemoteMessageRemove & Partial = { getPortalKey: () => route.portalKey, getSender: () => this.#eventSender(roomId), - getTargetMessage: () => targetId, + ...bundledTargetMethods(target), + getTargetMessage: () => target.messageId, getType: () => "message_remove", }; route.bridge.queueRemoteEvent(route.login, event); @@ -281,21 +286,22 @@ export class BeeperChannelRuntime { } async #queueRemoteReaction(roomId: string, targetMessageId: string, emoji: string, remove: boolean): Promise { - const targetId = openClawTargetId(targetMessageId); + const target = openClawTarget(targetMessageId); const route = this.#bridgeRoute(roomId); const reactionId = openClawRemoteId("reaction"); - const event: RemoteReaction | RemoteReactionRemove = { + const event: (RemoteReaction | RemoteReactionRemove) & Partial = { getEmoji: () => emoji, getID: () => reactionId, getPortalKey: () => route.portalKey, getSender: () => this.#eventSender(roomId), - getTargetMessage: () => targetId, + ...bundledTargetMethods(target), + getTargetMessage: () => target.messageId, getType: () => remove ? "reaction_remove" : "reaction", }; route.bridge.queueRemoteEvent(route.login, event); await route.bridge.flushRemoteEvents(); this.recordOutboundActivity(); - return { eventId: reactionId, raw: { bridgeQueued: true, targetMessageId: targetId }, roomId }; + return { eventId: reactionId, raw: { bridgeQueued: true, targetMessageId: target.messageId }, roomId }; } async #queueRemoteTyping(roomId: string, typing: boolean, timeoutMs: number | undefined): Promise { @@ -313,12 +319,13 @@ export class BeeperChannelRuntime { } async #queueRemoteReceipt(roomId: string, targetMessageId: string, type: "read_receipt" | "delivery_receipt"): Promise { - const targetId = openClawTargetId(targetMessageId); + const target = openClawTarget(targetMessageId); const route = this.#bridgeRoute(roomId); - const event: RemoteReadReceipt | RemoteDeliveryReceipt = { + const event: (RemoteReadReceipt | RemoteDeliveryReceipt) & Partial = { getPortalKey: () => route.portalKey, getSender: () => this.#eventSender(roomId), - getTargetMessage: () => targetId, + ...bundledTargetMethods(target), + getTargetMessage: () => target.messageId, getType: () => type, }; route.bridge.queueRemoteEvent(route.login, event); @@ -327,12 +334,13 @@ export class BeeperChannelRuntime { } async #queueRemoteMarkUnread(roomId: string, targetMessageId: string, unread: boolean): Promise { - const targetId = openClawTargetId(targetMessageId); + const target = openClawTarget(targetMessageId); const route = this.#bridgeRoute(roomId); - const event: RemoteMarkUnread = { + const event: RemoteMarkUnread & Partial = { getPortalKey: () => route.portalKey, getSender: () => this.#eventSender(roomId), - getTargetMessage: () => targetId, + ...bundledTargetMethods(target), + getTargetMessage: () => target.messageId, getType: () => "mark_unread", getUnread: () => unread, }; @@ -404,11 +412,26 @@ function openClawRemoteId(prefix = "message"): string { return `openclaw:${prefix}:${randomUUID()}`; } -function openClawTargetId(eventId: string): string { +function openClawTarget(eventId: string): { dbMessages?: Message[]; messageId: string } { if (!eventId.startsWith("openclaw:")) { - throw new Error(`Beeper bridge actions can only target OpenClaw bridge message ids, got ${eventId}.`); + if (eventId.startsWith("$")) { + return { + dbMessages: [{ + id: eventId, + mxid: eventId, + partId: "0", + }], + messageId: eventId, + }; + } + throw new Error(`Beeper bridge actions can only target OpenClaw bridge or Matrix event ids, got ${eventId}.`); } - return eventId; + return { messageId: eventId }; +} + +function bundledTargetMethods(target: { dbMessages?: Message[] }): Partial> { + const dbMessages = target.dbMessages; + return dbMessages ? { getTargetDBMessage: () => dbMessages } : {}; } function beeperSessionKeyCandidates(target: string): string[] { diff --git a/packages/openclaw/src/beeper-setup.test.ts b/packages/openclaw/src/beeper-setup.test.ts index 20cad79..18dd29b 100644 --- a/packages/openclaw/src/beeper-setup.test.ts +++ b/packages/openclaw/src/beeper-setup.test.ts @@ -127,36 +127,6 @@ describe("OpenClaw Beeper setup", () => { }); }); - it("passes a bridge manager token as the Beeper hungry token", async () => { - const seen: unknown[] = []; - await createOpenClawBeeperAppService({ - accessToken: "mx-token", - bridgeManagerToken: "hungry-token", - matrixDeviceId: "DEV", - createAppServiceInit: async (options) => { - seen.push(options); - return { - homeserver: "https://matrix.beeper.com/_hungryserv/batuhan", - registration: { - asToken: "as", - hsToken: "hs", - id: "appservice-uuid", - namespaces: { aliases: [], rooms: [], users: [] }, - senderLocalpart: "sh-openclawbot", - url: "http://127.0.0.1:29391", - }, - }; - }, - }); - - expect(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", diff --git a/packages/openclaw/src/beeper-setup.ts b/packages/openclaw/src/beeper-setup.ts index 06ca74a..519281a 100644 --- a/packages/openclaw/src/beeper-setup.ts +++ b/packages/openclaw/src/beeper-setup.ts @@ -39,23 +39,16 @@ export interface CreateOpenClawBeeperAppServiceOptions { accessToken: string; baseDomain?: string; bridge?: string; - bridgeManagerToken?: string; bridgeType?: string; createAppServiceInit?: (options: CreateOpenClawBeeperAppServiceRequest) => Promise; fetch?: typeof fetch; - getOnly?: boolean; - homeserver?: string; - homeserverDomain?: string; matrixDeviceId?: string; - push?: boolean; - selfHosted?: boolean; username?: string; } export type CreateOpenClawBeeperAppServiceRequest = CreateAppServiceOptions & { baseDomain?: string; fetch?: typeof fetch; - hungryToken?: string; token: string; username?: string; }; @@ -67,10 +60,7 @@ export interface CreateOpenClawBeeperAppServiceResult { export interface SetupOpenClawBeeperBridgeOptions extends BeeperLoginForOpenClawOptions { createAppServiceInit?: CreateOpenClawBeeperAppServiceOptions["createAppServiceInit"]; - getOnly?: boolean; openClawDeviceId?: string; - push?: boolean; - selfHosted?: boolean; } export interface SetupOpenClawBeeperBridgeResult { @@ -135,18 +125,13 @@ export async function createOpenClawBeeperAppService( const request: CreateOpenClawBeeperAppServiceRequest = { bridge, bridgeType: options.bridgeType ?? DEFAULT_BEEPER_BRIDGE_TYPE, - selfHosted: options.selfHosted ?? true, + selfHosted: true, token: options.accessToken, }; 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; request.postState = true; - if (options.push !== undefined) request.push = options.push; if (options.username !== undefined) request.username = options.username; const init = await createInit(request); const config: CreateOpenClawBeeperAppServiceResult["config"] = { @@ -178,9 +163,6 @@ export async function setupOpenClawBeeperBridge( if (baseDomain !== undefined) appserviceOptions.baseDomain = baseDomain; 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.push !== undefined) appserviceOptions.push = options.push; - if (options.selfHosted !== undefined) appserviceOptions.selfHosted = options.selfHosted; const appservice = await createOpenClawBeeperAppService(appserviceOptions); return { account: login.account, diff --git a/packages/openclaw/src/beeper-turn-events.ts b/packages/openclaw/src/beeper-turn-events.ts index c8f897e..415cf29 100644 --- a/packages/openclaw/src/beeper-turn-events.ts +++ b/packages/openclaw/src/beeper-turn-events.ts @@ -4,234 +4,12 @@ export type { AGUIEvent } from "@beeper/pickle-ag-ui"; import { EventType as AGUIEventType, type AGUIEvent } from "@beeper/pickle-ag-ui"; import { defaultBeeperApprovalActions, defaultBeeperApprovalChoices } from "./approval"; -export interface StreamRunState { - messageStarted: boolean; - reasoningStarted: boolean; - textStarted: boolean; +export interface ApprovalRunState { toolCallIdToApprovalId: Record; - turnId: string; } -export function createStreamRunState(turnId: string): StreamRunState { - 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 mapOpenClawMessageDelta( - state: StreamRunState, - delta: { kind: "text" | "thinking"; value: string } -): AGUIEvent[] { - if (delta.kind === "text") { - return [ - ...openTextPart(state), - { - delta: delta.value, - messageId: state.turnId, - type: AGUIEventType.TEXT_MESSAGE_CONTENT, - }, - ]; - } - return [ - ...openReasoningPart(state), - { - delta: delta.value, - messageId: state.turnId, - type: AGUIEventType.REASONING_MESSAGE_CONTENT, - }, - ]; -} - -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 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): 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: { - approval?: { id?: string; needsApproval?: boolean } | Record; - dynamic?: boolean; - index?: number; - input?: unknown; - metadata?: Record; - providerExecuted?: boolean; - startedAtMs?: number; - title?: string; - toolCallId: string; - toolName?: string; -}): AGUIEvent[] { - const toolName = event.toolName || "tool"; - const parts: AGUIEvent[] = [ - { - parentMessageId: event.toolCallId, - 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, - } as AGUIEvent); - } - return parts; -} - -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 mapOpenClawToolEnd(event: { - error?: unknown; - input?: unknown; - state?: string; - toolCallId: string; - toolName?: string; -}): AGUIEvent[] { - return [{ - ...(event.error !== undefined ? { error: stringifyToolValue(event.error) } : {}), - ...(event.input !== undefined ? { input: event.input } : {}), - state: event.state ?? (event.error !== undefined ? "error" : "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; - output?: unknown; - preliminary?: boolean; - providerExecuted?: boolean; - toolCallId: string; - toolName?: string; -}): 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, - 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 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 mapOpenClawActivitySnapshot( - state: StreamRunState, - event: { - activityType?: string; - content: Record; - replace?: boolean; - }, -): AGUIEvent[] { - return [{ - activityType: event.activityType ?? "activity", - content: event.content, - messageId: state.turnId, - ...(event.replace !== undefined ? { replace: event.replace } : {}), - type: "ACTIVITY_SNAPSHOT", - } as unknown as AGUIEvent]; -} - -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 createApprovalRunState(): ApprovalRunState { + return { toolCallIdToApprovalId: {} }; } export function mapOpenClawCustom(name: string, value: unknown): AGUIEvent[] { @@ -239,8 +17,8 @@ export function mapOpenClawCustom(name: string, value: unknown): AGUIEvent[] { } export function mapOpenClawApprovalRequest( - state: StreamRunState, - event: { approvalId?: string; message?: string; toolCallId?: string; toolName?: string } + state: ApprovalRunState, + event: { approvalId?: string; message?: string; toolCallId?: string; toolName?: string }, ): AGUIEvent { const toolCallId = event.toolCallId ?? event.approvalId ?? "approval"; const approvalId = event.approvalId ?? `approval_${toolCallId}`; @@ -253,8 +31,8 @@ export function mapOpenClawApprovalRequest( id: approvalId, needsApproval: true, }, - approvalMessageId: approvalId, approvalActions: defaultBeeperApprovalActions(), + approvalMessageId: approvalId, choices: defaultBeeperApprovalChoices(), message: event.message, toolCallId, @@ -282,13 +60,3 @@ export function mapOpenClawApprovalResponse(event: { }, }; } - -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/bridge-agent.test.ts b/packages/openclaw/src/bridge-agent.test.ts index ddc9423..e3fba65 100644 --- a/packages/openclaw/src/bridge-agent.test.ts +++ b/packages/openclaw/src/bridge-agent.test.ts @@ -11,15 +11,22 @@ import type { OpenClawSessionBinding } from "./types"; describe("OpenClawMatrixBridgeAgent", () => { it("syncs OpenClaw agents into bridge contacts", async () => { const registry = await tempRegistry(); + registry.upsertAgent({ agentId: "old", displayName: "Old", ghostUserId: "@sh-openclaw_agent_old:localhost" }); const agent = new OpenClawMatrixBridgeAgent({ registry, runtime: runtimeWith({ - responses: { "agents.list": { agents: [{ id: "codex", name: "Codex" }] } }, + responses: { "agents.list": { agents: [{ avatarMxc: "mxc://example/codex", id: "codex", name: "Codex" }] } }, }), }); await agent.syncAgentContacts(); - expect(registry.getAgent("codex")?.ghostUserId).toBe("@sh-openclaw_agent_codex:localhost"); + expect(registry.getAgent("codex")).toMatchObject({ + agentId: "codex", + avatarMxc: "mxc://example/codex", + displayName: "Codex", + ghostUserId: "@sh-openclaw_agent_codex:localhost", + }); + expect(registry.getAgent("old")).toBeUndefined(); }); it("sends Matrix room text to the bound OpenClaw session", async () => { @@ -44,6 +51,11 @@ describe("OpenClawMatrixBridgeAgent", () => { message: "hello", sessionKey: "agent:codex:main", }); + expect(runtime.transport.request).toHaveBeenCalledWith("sessions.patch", { + agentId: "codex", + key: "agent:codex:main", + reasoningLevel: "on", + }); expect(registry.getBindingByRoom("!room:example.com")?.lastRunId).toBe("run_1"); }); @@ -107,6 +119,71 @@ describe("OpenClawMatrixBridgeAgent", () => { }); }); + it("does not start duplicate turns for the same Matrix event while the first send is in flight", async () => { + const registry = await tempRegistry(); + registry.upsertBinding(testBinding()); + const runtime = runtimeWith({ responses: {} }); + let finishTurn!: () => void; + const sendTurn = vi.fn(async () => { + await new Promise((resolve) => { finishTurn = resolve; }); + return { runId: "run_1", sessionKey: "agent:codex:main" }; + }); + const agent = new OpenClawMatrixBridgeAgent({ registry, runtime, sendTurn }); + const turn = { + eventId: "$duplicate", + roomId: "!room:example.com", + sender: "@alice:example.com", + text: "hello", + }; + + const first = agent.handleMatrixText(turn); + await vi.waitFor(() => expect(sendTurn).toHaveBeenCalledOnce()); + await agent.handleMatrixText(turn); + finishTurn(); + await first; + + expect(sendTurn).toHaveBeenCalledOnce(); + expect(registry.hasDedupe("$duplicate")).toBe(true); + expect(registry.getBindingByRoom("!room:example.com")?.lastRunId).toBe("run_1"); + }); + + it("serializes different Matrix events in the same OpenClaw session", async () => { + const registry = await tempRegistry(); + registry.upsertBinding(testBinding()); + const runtime = runtimeWith({ responses: {} }); + let finishFirst!: () => void; + const sendTurn = vi.fn(async (options: { idempotencyKey?: string }) => { + if (options.idempotencyKey === "$first") { + await new Promise((resolve) => { finishFirst = resolve; }); + return { runId: "run_1", sessionKey: "agent:codex:main" }; + } + return { runId: "run_2", sessionKey: "agent:codex:main" }; + }); + const agent = new OpenClawMatrixBridgeAgent({ registry, runtime, sendTurn }); + + const first = agent.handleMatrixText({ + eventId: "$first", + roomId: "!room:example.com", + sender: "@alice:example.com", + text: "one", + }); + await vi.waitFor(() => expect(sendTurn).toHaveBeenCalledOnce()); + const second = agent.handleMatrixText({ + eventId: "$second", + roomId: "!room:example.com", + sender: "@alice:example.com", + text: "two", + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(sendTurn).toHaveBeenCalledOnce(); + + finishFirst(); + await Promise.all([first, second]); + + expect(sendTurn.mock.calls.map(([options]) => options.idempotencyKey)).toEqual(["$first", "$second"]); + expect(registry.getBindingByRoom("!room:example.com")?.lastRunId).toBe("run_2"); + }); + it("creates an OpenClaw session before sending the first message in an agent contact DM", async () => { const registry = await tempRegistry(); registry.upsertBinding({ @@ -134,6 +211,11 @@ describe("OpenClawMatrixBridgeAgent", () => { expect(runtime.transport.request).toHaveBeenCalledWith("sessions.create", { agentId: "codex", }); + expect(runtime.transport.request).toHaveBeenCalledWith("sessions.patch", { + agentId: "codex", + key: "agent:codex:session_1", + reasoningLevel: "on", + }); expect(sendTurn).toHaveBeenCalledWith({ idempotencyKey: "$event", matrix: { roomId: "!room:example.com" }, @@ -212,8 +294,9 @@ function testBinding(): OpenClawSessionBinding { function runtimeWith(options: { events?: OpenClawGatewayEvent[]; - responses: Record; + responses?: Record; }): OpenClawPluginRuntimeAdapter & { transport: OpenClawRuntimeRequestSurface & { request: ReturnType } } { + const responses = options.responses ?? {}; const transport = { async *events(filter?: (event: OpenClawGatewayEvent) => boolean) { for (const event of options.events ?? []) { @@ -221,7 +304,7 @@ function runtimeWith(options: { } }, request: vi.fn(async (method: string) => { - const response = options.responses[method]; + const response = responses[method]; if (response instanceof Error) throw response; return response; }), diff --git a/packages/openclaw/src/bridge-agent.ts b/packages/openclaw/src/bridge-agent.ts index 551c35a..f07456f 100644 --- a/packages/openclaw/src/bridge-agent.ts +++ b/packages/openclaw/src/bridge-agent.ts @@ -4,7 +4,15 @@ import { toOpenClawApprovalResolvePayload, type ParsedApprovalResponse, } from "./approval"; -import type { OpenClawMatrixMessageMetadata, OpenClawRunRef, OpenClawSessionSendOptions, OpenClawSessionTurnRuntime } from "./openclaw-runtime"; +import { + BEEPER_SESSION_REASONING_LEVEL, + type OpenClawMatrixMessageMetadata, + type OpenClawRunRef, + type OpenClawSessionCreateOptions, + type OpenClawSessionPatchOptions, + type OpenClawSessionSendOptions, + type OpenClawSessionTurnRuntime, +} from "./openclaw-runtime"; import type { OpenClawBridgeRegistry } from "./registry"; import type { OpenClawSessionBinding } from "./types"; @@ -22,6 +30,9 @@ export class OpenClawMatrixBridgeAgent { readonly registry: OpenClawBridgeRegistry; readonly runtime: OpenClawSessionTurnRuntime; readonly #sendTurn: (options: OpenClawSessionSendOptions) => Promise; + readonly #configuredSessions = new Set(); + readonly #inFlightEvents = new Set(); + readonly #turnsByBinding = new Map>(); constructor(options: { registry: OpenClawBridgeRegistry; @@ -34,42 +45,53 @@ export class OpenClawMatrixBridgeAgent { } async syncAgentContacts(): Promise { - for (const contact of await this.runtime.listAgentContacts()) { - this.registry.upsertAgent(contact); - } + this.registry.replaceAgents(await this.runtime.listAgentContacts()); await this.registry.save(); } async handleMatrixText(turn: MatrixTextTurn): Promise { if (this.registry.hasDedupe(turn.eventId)) return; + if (this.#inFlightEvents.has(turn.eventId)) return; const binding = this.registry.getBindingByRoom(turn.roomId); if (!binding) { this.registry.markDedupe(turn.eventId); await this.registry.save(); return; } - const sessionKey = await this.ensureSession(binding); - const matrix: OpenClawMatrixMessageMetadata = { - ...(turn.matrix ?? {}), - roomId: turn.roomId, + const processTurn = async () => { + const sessionKey = await this.ensureSession(binding); + const matrix: OpenClawMatrixMessageMetadata = { + ...(turn.matrix ?? {}), + roomId: turn.roomId, + }; + const run = await this.#sendTurn({ + ...(turn.attachments && turn.attachments.length > 0 ? { attachments: turn.attachments } : {}), + idempotencyKey: turn.eventId, + matrix, + message: turn.text, + ...(turn.replyToEventId ? { replyTo: { eventId: turn.replyToEventId, roomId: turn.roomId } } : {}), + sessionKey, + }); + this.registry.updateBinding(binding.id, (current) => ({ + ...current, + lastMatrixEventId: turn.eventId, + lastRunId: run.runId, + sessionKey: run.sessionKey, + updatedAt: Date.now(), + })); + this.registry.markDedupe(turn.eventId); + await this.registry.save(); }; - const run = await this.#sendTurn({ - ...(turn.attachments && turn.attachments.length > 0 ? { attachments: turn.attachments } : {}), - idempotencyKey: turn.eventId, - matrix, - message: turn.text, - ...(turn.replyToEventId ? { replyTo: { eventId: turn.replyToEventId, roomId: turn.roomId } } : {}), - sessionKey, - }); - this.registry.updateBinding(binding.id, (current) => ({ - ...current, - lastMatrixEventId: turn.eventId, - lastRunId: run.runId, - sessionKey: run.sessionKey, - updatedAt: Date.now(), - })); - this.registry.markDedupe(turn.eventId); - await this.registry.save(); + this.#inFlightEvents.add(turn.eventId); + const previous = this.#turnsByBinding.get(binding.id) ?? Promise.resolve(); + const current = previous.catch(() => undefined).then(processTurn); + this.#turnsByBinding.set(binding.id, current); + try { + await current; + } finally { + if (this.#turnsByBinding.get(binding.id) === current) this.#turnsByBinding.delete(binding.id); + this.#inFlightEvents.delete(turn.eventId); + } } async handleApprovalContent(content: unknown, approvalId?: string): Promise { @@ -83,9 +105,17 @@ export class OpenClawMatrixBridgeAgent { } async ensureSession(binding: OpenClawSessionBinding): Promise { - if (binding.sessionKey !== agentPortalSessionKey(binding.agentId)) return binding.sessionKey; - const createOptions: { agentId: string; label?: string } = { + if (binding.sessionKey !== agentPortalSessionKey(binding.agentId)) { + await this.ensureSessionConfiguration({ + agentId: binding.agentId, + key: binding.sessionKey, + reasoningLevel: BEEPER_SESSION_REASONING_LEVEL, + }); + return binding.sessionKey; + } + const createOptions: OpenClawSessionCreateOptions = { agentId: binding.agentId, + reasoningLevel: BEEPER_SESSION_REASONING_LEVEL, }; if (binding.label !== undefined) createOptions.label = binding.label; const session = await this.runtime.createSession(createOptions); @@ -94,8 +124,15 @@ export class OpenClawMatrixBridgeAgent { sessionKey: session.key, updatedAt: Date.now(), })); + this.#configuredSessions.add(session.key); return session.key; } + + private async ensureSessionConfiguration(options: OpenClawSessionPatchOptions): Promise { + if (this.#configuredSessions.has(options.key)) return; + await this.runtime.patchSession(options); + this.#configuredSessions.add(options.key); + } } export function agentPortalSessionKey(agentId: string): string { diff --git a/packages/openclaw/src/cli.test.ts b/packages/openclaw/src/cli.test.ts index fd0ad14..c33fe7e 100644 --- a/packages/openclaw/src/cli.test.ts +++ b/packages/openclaw/src/cli.test.ts @@ -70,8 +70,6 @@ describe("pickle-openclaw CLI", () => { email: "you@example.com", env: "staging", getLoginCode: expect.any(Function), - push: false, - selfHosted: true, })); await expect(setupBridge.mock.calls[0]?.[0].getLoginCode()).resolves.toBe("123456"); expect((await stat(configPath)).mode & 0o777).toBe(0o600); @@ -163,8 +161,6 @@ describe("pickle-openclaw CLI", () => { expect(setupBridge).toHaveBeenCalledWith(expect.objectContaining({ env: "staging", password: "secret", - push: false, - selfHosted: true, username: "batuhan", })); expect(setupBridge.mock.calls[0]?.[0]).not.toHaveProperty("getLoginCode"); @@ -208,7 +204,6 @@ describe("pickle-openclaw CLI", () => { canConnect: true, deviceId: "DEVICE", homeserver: "https://matrix.beeper.com", - registrationUrl: "websocket", userId: "@batuhan:beeper.com", }); }); diff --git a/packages/openclaw/src/cli.ts b/packages/openclaw/src/cli.ts index 85e52bb..e71f118 100644 --- a/packages/openclaw/src/cli.ts +++ b/packages/openclaw/src/cli.ts @@ -31,10 +31,7 @@ export async function runCli(argv = process.argv.slice(2), io: CliIO = process, if (authMethods === 0) throw new Error("Missing required option --email or --username/--password"); if (authMethods > 1) throw new Error("Choose only one login method"); if ((username && !password) || (password && !username)) throw new Error("Username/password login requires both --username and --password"); - const setupOptions: Parameters[0] = { - push: booleanOption(options, "push"), - selfHosted: !booleanOption(options, "not-self-hosted"), - }; + const setupOptions: Parameters[0] = {}; if (email !== undefined) setupOptions.email = email; if (username !== undefined) setupOptions.username = username; if (password !== undefined) setupOptions.password = password; @@ -119,7 +116,6 @@ function whoamiPayload(config: OpenClawBridgeConfig): Record { ), deviceId: config.matrixDeviceId ?? null, homeserver: config.homeserver ?? null, - registrationUrl: "websocket", userId: config.matrixUserId ?? null, }; } @@ -146,10 +142,6 @@ function stringOption(options: Map, key: string): stri return typeof value === "string" ? value : undefined; } -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; diff --git a/packages/openclaw/src/config.test.ts b/packages/openclaw/src/config.test.ts index fb39c55..0ba05e9 100644 --- a/packages/openclaw/src/config.test.ts +++ b/packages/openclaw/src/config.test.ts @@ -7,12 +7,9 @@ import { createDefaultConfig, createConfigFromOpenClawSetup, readConfig, writeCo 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; - delete process.env.PICKLE_OPENCLAW_BRIDGE_ID; + delete process.env.PICKLE_OPENCLAW_BEEPER_ENV; delete process.env.PICKLE_OPENCLAW_DEVICE_ID; + delete process.env.PICKLE_OPENCLAW_HS_TOKEN; delete process.env.OPENCLAW_DEVICE_ID; }); @@ -32,26 +29,16 @@ describe("OpenClaw bridge config", () => { }); }); - it("accepts dashboard-derived bridge behavior settings", () => { + it("accepts saved login and registration state from OpenClaw config", () => { expect(createDefaultConfig({ - backfillLimit: 25, beeperEnv: "staging", - bridgeManagerToken: "hungry-token", asToken: "as-token", - contactVisibility: "agents-and-users", dataDir: "/tmp/openclaw-bridge", homeserverDomain: "beeper.local", - importSources: ["dashboard", "tui"], - approvalBehavior: "native", })).toMatchObject({ - approvalBehavior: "native", - backfillLimit: 25, beeperEnv: "staging", - bridgeManagerToken: "hungry-token", asToken: "as-token", - contactVisibility: "agents-and-users", homeserverDomain: "beeper.local", - importSources: ["dashboard", "tui"], }); }); @@ -71,19 +58,17 @@ describe("OpenClaw bridge config", () => { }); }); - 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"; + it("accepts only Beeper environment and OpenClaw device id from environment variables", () => { + process.env.PICKLE_OPENCLAW_BEEPER_ENV = "staging"; + process.env.PICKLE_OPENCLAW_DEVICE_ID = "openclaw.device"; + process.env.PICKLE_OPENCLAW_HS_TOKEN = "ignored"; expect(createDefaultConfig({ dataDir: "/tmp/openclaw-bridge" })).toMatchObject({ - allowedRoomIds: ["!a:example.com", "!b:example.com"], - allowedUserIds: ["@alice:example.com", "@bob:example.com"], - appserviceId: "manifest-openclaw", + appserviceId: "sh-openclaw-openclaw-device", + beeperEnv: "staging", + bridgeId: "sh-openclaw-openclaw-device", }); - - process.env.PICKLE_OPENCLAW_APPSERVICE_ID = "legacy-openclaw"; - expect(createDefaultConfig({ dataDir: "/tmp/openclaw-bridge" }).appserviceId).toBe("legacy-openclaw"); + expect(createDefaultConfig({ dataDir: "/tmp/openclaw-bridge" }).hsToken).toBeUndefined(); }); diff --git a/packages/openclaw/src/config.ts b/packages/openclaw/src/config.ts index 739baad..c990cb6 100644 --- a/packages/openclaw/src/config.ts +++ b/packages/openclaw/src/config.ts @@ -19,48 +19,31 @@ 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 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 config: OpenClawBridgeConfig = { appserviceId: overrides.appserviceId ?? - process.env.PICKLE_OPENCLAW_APPSERVICE_ID ?? - process.env.PICKLE_OPENCLAW_APP_SERVICE_ID ?? bridgeId ?? DEFAULT_APPSERVICE_ID, dataDir, beeperEnv: overrides.beeperEnv ?? envBeeperEnv(process.env.PICKLE_OPENCLAW_BEEPER_ENV) ?? "production", }; - const asToken = overrides.asToken ?? process.env.PICKLE_OPENCLAW_AS_TOKEN; - const bridgeManagerToken = overrides.bridgeManagerToken ?? process.env.PICKLE_OPENCLAW_BRIDGE_MANAGER_TOKEN; - 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 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 approvalBehavior = overrides.approvalBehavior ?? envApprovalBehavior(process.env.PICKLE_OPENCLAW_APPROVAL_BEHAVIOR); - const allowedRoomIds = overrides.allowedRoomIds ?? envStringList(process.env.PICKLE_OPENCLAW_ALLOW_ROOMS); - const allowedUserIds = overrides.allowedUserIds ?? envStringList(process.env.PICKLE_OPENCLAW_ALLOW_USERS); + const asToken = overrides.asToken; + const homeserver = overrides.homeserver; + const homeserverDomain = overrides.homeserverDomain; + const hsToken = overrides.hsToken; + const matrixDeviceId = overrides.matrixDeviceId; + const matrixUserId = overrides.matrixUserId; if (asToken) config.asToken = asToken; if (bridgeId) config.bridgeId = bridgeId; - if (bridgeManagerToken) config.bridgeManagerToken = bridgeManagerToken; 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 (approvalBehavior !== undefined) config.approvalBehavior = approvalBehavior; - if (allowedRoomIds) config.allowedRoomIds = allowedRoomIds; - if (allowedUserIds) config.allowedUserIds = allowedUserIds; return config; } @@ -95,44 +78,6 @@ 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; -} - -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 { - 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 envApprovalBehavior(value: string | undefined): OpenClawBridgeConfig["approvalBehavior"] | undefined { - if (value === "native" || 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 3c281b9..04b927d 100644 --- a/packages/openclaw/src/connector.test.ts +++ b/packages/openclaw/src/connector.test.ts @@ -86,7 +86,7 @@ describe("OpenClawBridgeConnector", () => { const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); const runtime = runtimeWith({ responses: { - "agents.list": { agents: [{ id: "codex", name: "Codex" }] }, + "agents.list": { agents: [{ avatarMxc: "mxc://example/codex", id: "codex", name: "Codex" }] }, "sessions.create": { key: "agent:codex:beeper:bootstrap" }, }, }); @@ -99,11 +99,18 @@ describe("OpenClawBridgeConnector", () => { const { ctx, registerGhost } = connectContext(); await api.connect(ctx); expect(registerGhost).toHaveBeenCalledWith({ + avatar: { + id: "mxc://example/codex", + mxc: "mxc://example/codex", + url: "mxc://example/codex", + }, displayName: "Codex", id: "codex", metadata: { openclaw: { agentId: "codex", + avatarMxc: "mxc://example/codex", + avatarUrl: "mxc://example/codex", displayName: "Codex", ghostUserId: "@sh-openclaw_agent_codex:localhost", }, @@ -112,12 +119,14 @@ describe("OpenClawBridgeConnector", () => { }); }); - it("creates a default room and sends a Beeper-owner ping when no rooms exist", async () => { + it("creates a stable welcome room for each agent without starting a session turn", async () => { const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-bootstrap-test.json"); const runtime = runtimeWith({ responses: { - "agents.list": { agents: [] }, - "sessions.create": { key: "agent:main:beeper:bootstrap" }, + "agents.list": { agents: [ + { id: "main", name: "Main" }, + { id: "codex", name: "Codex" }, + ] }, }, }); const api = new OpenClawNetworkAPI({ @@ -127,35 +136,135 @@ describe("OpenClawBridgeConnector", () => { runtime, }); const { createPortal, ctx, registerPortal, sendMessage } = connectContext(); + createPortal.mockImplementation(async (_login, portal) => ({ + ...portal, + mxid: portal.id === "agent:main" ? "!main:example.com" : "!codex:example.com", + portalKey: { id: portal.id, receiver: "openclaw:plugin" }, + receiver: "openclaw:plugin", + })); await api.connect(ctx); + expect(createPortal).toHaveBeenCalledTimes(2); expect(createPortal).toHaveBeenCalledWith(login(), expect.objectContaining({ creationContent: { "m.federate": false }, + id: "agent:main", + metadata: { + openclaw: { + agentId: "main", + ghostUserId: "@sh-openclaw_agent_main:localhost", + label: "Main", + sessionKey: "agent:main", + }, + }, name: "Main", roomType: "dm", sender: "@sh-openclaw_agent_main:localhost", })); - expect(sendMessage).toHaveBeenCalledWith({ - content: { - body: "hey, are you alive? - sent from my beeper", - msgtype: "m.text", + expect(createPortal).toHaveBeenCalledWith(login(), expect.objectContaining({ + id: "agent:codex", + metadata: { + openclaw: { + agentId: "codex", + ghostUserId: "@sh-openclaw_agent_codex:localhost", + label: "Codex", + sessionKey: "agent:codex", + }, }, - roomId: "!bootstrap:example.com", - userId: "@alice:example.com", - }); - expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ - idempotencyKey: "$bootstrap", - message: "hey, are you alive? - sent from my beeper", - sessionKey: "agent:main:beeper:bootstrap", + name: "Codex", + roomType: "dm", + sender: "@sh-openclaw_agent_codex:localhost", })); + expect(sendMessage).not.toHaveBeenCalled(); + expect(runtime.sendMessage).not.toHaveBeenCalled(); expect(registerPortal).toHaveBeenCalledWith(expect.objectContaining({ - mxid: "!bootstrap:example.com", + mxid: "!main:example.com", portalKey: expect.objectContaining({ receiver: "openclaw:plugin" }), })); + expect(registry.getBindingByRoom("!main:example.com")).toMatchObject({ + agentId: "main", + id: "agent:main", + kind: "agent", + sessionKey: "agent:main", + }); + expect(registry.getBindingByRoom("!codex:example.com")).toMatchObject({ + agentId: "codex", + id: "agent:codex", + kind: "agent", + sessionKey: "agent:codex", + }); + }); + + it("does not create another welcome room on a later connect", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-bootstrap-repeat-test.json"); + const runtime = runtimeWith({ + responses: { + "agents.list": { agents: [{ id: "main", name: "Main" }] }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime, + }); + const { createPortal, ctx, sendMessage } = connectContext(); + createPortal.mockImplementation(async (_login, portal) => ({ + ...portal, + mxid: "!main:example.com", + portalKey: { id: portal.id, receiver: "openclaw:plugin" }, + receiver: "openclaw:plugin", + })); + + await api.connect(ctx); + await api.connect(ctx); + + expect(createPortal).toHaveBeenCalledOnce(); + expect(sendMessage).not.toHaveBeenCalled(); + expect(registry.getBindingById("agent:main")).toMatchObject({ + agentId: "main", + kind: "agent", + roomId: "!main:example.com", + }); }); - it("does not create a bootstrap room when a room binding already exists", async () => { + it("creates only one welcome room when connect is called concurrently", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-bootstrap-concurrent-test.json"); + const runtime = runtimeWith({ + responses: { + "agents.list": { agents: [] }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime, + }); + const { createPortal, ctx, sendMessage } = connectContext(); + let unblockCreatePortal!: () => void; + createPortal.mockImplementationOnce(async (_login, portal) => { + await new Promise((resolve) => { unblockCreatePortal = resolve; }); + return { + ...portal, + mxid: "!bootstrap:example.com", + portalKey: { id: portal.id, receiver: "openclaw:plugin" }, + receiver: "openclaw:plugin", + }; + }); + + const first = api.connect(ctx); + const second = api.connect(ctx); + await vi.waitFor(() => expect(createPortal).toHaveBeenCalledOnce()); + unblockCreatePortal(); + await Promise.all([first, second]); + + expect(createPortal).toHaveBeenCalledOnce(); + expect(sendMessage).not.toHaveBeenCalled(); + expect(registry.getBindingByRoom("!bootstrap:example.com")).toBeDefined(); + }); + + it("still creates an agent welcome room when only a session room exists", async () => { const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-bootstrap-existing-test.json"); registry.upsertBinding({ agentId: "main", @@ -181,22 +290,31 @@ describe("OpenClawBridgeConnector", () => { await api.connect(ctx); - expect(createPortal).not.toHaveBeenCalled(); + expect(createPortal).toHaveBeenCalledWith(login(), expect.objectContaining({ + id: "agent:main", + metadata: { + openclaw: { + agentId: "main", + ghostUserId: "@sh-openclaw_agent_main:localhost", + label: "Main", + sessionKey: "agent:main", + }, + }, + })); expect(sendMessage).not.toHaveBeenCalled(); expect(runtime.sendMessage).not.toHaveBeenCalled(); + expect(registry.getBindingsByAgent("main")).toHaveLength(2); }); - it("honors contact visibility when registering ghosts", async () => { + it("registers current agent ghosts only", async () => { const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); registry.upsertAgent({ agentId: "codex", displayName: "Codex", ghostUserId: "@codex:example.com" }); - registry.upsertUser({ displayName: "Alice", ghostUserId: "@alice-ghost:example.com", userId: "alice" }); const runtime = runtimeWith({ responses: { - "agents.list": { agents: [] }, + "agents.list": { agents: [{ id: "codex", name: "Codex" }] }, "sessions.create": { key: "agent:codex:beeper:bootstrap" }, }, }); - runtime.config.contactVisibility = "agents-and-users"; const api = new OpenClawNetworkAPI({ config: runtime.config, login: login(), @@ -205,24 +323,42 @@ describe("OpenClawBridgeConnector", () => { }); const { ctx, registerGhost } = connectContext(); await api.connect(ctx); - expect(registerGhost).toHaveBeenCalledWith(expect.objectContaining({ id: "alice", mxid: "@alice-ghost:example.com" })); + expect(registerGhost).toHaveBeenCalledWith(expect.objectContaining({ id: "codex", mxid: "@sh-openclaw_agent_codex:localhost" })); + }); - const hidden = runtimeWith({ + it("keeps existing agent room bindings aligned to the latest ghost profile", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-ghost-sync-test.json"); + registry.upsertBinding({ + agentId: "codex", + createdAt: 1, + ghostUserId: "@old-codex:example.com", + id: "agent:codex", + kind: "agent", + label: "Old Codex", + owner: "bridge", + roomId: "!codex:example.com", + sessionKey: "agent:codex", + updatedAt: 1, + }); + const runtime = runtimeWith({ responses: { - "agents.list": { agents: [] }, - "sessions.create": { key: "agent:main:beeper:bootstrap" }, + "agents.list": { agents: [{ id: "codex", name: "Codex" }] }, }, }); - hidden.config.contactVisibility = "none"; - const hiddenApi = new OpenClawNetworkAPI({ - config: hidden.config, + const api = new OpenClawNetworkAPI({ + config: runtime.config, login: login(), registry, - runtime: hidden, + runtime, + }); + const { ctx } = connectContext(); + + await api.connect(ctx); + + expect(registry.getBindingById("agent:codex")).toMatchObject({ + ghostUserId: "@sh-openclaw_agent_codex:localhost", + label: "Codex", }); - const { ctx: hiddenCtx, registerGhost: hiddenRegisterGhost } = connectContext(); - await hiddenApi.connect(hiddenCtx); - expect(hiddenRegisterGhost).not.toHaveBeenCalled(); }); it("resolves agent identifiers into DM portals", async () => { @@ -232,7 +368,7 @@ describe("OpenClawBridgeConnector", () => { config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), login: login(), registry, - runtime: runtimeWith({ responses: { "sessions.create": { key: "agent:codex:session_1" } } }), + runtime: runtimeWith({ responses: { "agents.list": { agents: [{ id: "codex", name: "Codex" }] } } }), }); await expect(api.resolveIdentifier({ bridge: { createPortal: vi.fn() } } as unknown as BridgeRequestContext, { createDM: false, @@ -246,33 +382,26 @@ describe("OpenClawBridgeConnector", () => { openclaw: { agentId: "codex", displayName: "Codex", - ghostUserId: "@codex:example.com", + ghostUserId: "@sh-openclaw_agent_codex:localhost", }, }, - mxid: "@codex:example.com", + mxid: "@sh-openclaw_agent_codex:localhost", }, - userId: "@codex:example.com", + userId: "@sh-openclaw_agent_codex:localhost", }); - const createPortal = vi.fn(async () => ({ - id: "session:YWdlbnQ6Y29kZXg6c2Vzc2lvbl8x", - metadata: { - openclaw: { - agentId: "codex", - label: "Codex", - ghostUserId: "@codex:example.com", - sessionKey: "agent:codex:session_1", - }, - }, + const createPortal = vi.fn(async (loginArg, options) => ({ + id: options.id, + metadata: options.metadata, mxid: "!codex-dm:example.com", - portalKey: { id: "session:YWdlbnQ6Y29kZXg6c2Vzc2lvbl8x", receiver: "login" }, - receiver: "login", + portalKey: { id: options.id, receiver: loginArg.id }, + receiver: loginArg.id, })); await expect(api.resolveIdentifier({ bridge: { createPortal } } as unknown as BridgeRequestContext, { createDM: true, identifier: "codex", type: "username", - })).resolves.toEqual({ + })).resolves.toMatchObject({ ghost: { displayName: "Codex", id: "codex", @@ -280,47 +409,47 @@ describe("OpenClawBridgeConnector", () => { openclaw: { agentId: "codex", displayName: "Codex", - ghostUserId: "@codex:example.com", + ghostUserId: "@sh-openclaw_agent_codex:localhost", }, }, - mxid: "@codex:example.com", + mxid: "@sh-openclaw_agent_codex:localhost", }, portal: { - id: "session:YWdlbnQ6Y29kZXg6c2Vzc2lvbl8x", + id: expect.stringMatching(/^conversation:/), metadata: { openclaw: { agentId: "codex", - ghostUserId: "@codex:example.com", + ghostUserId: "@sh-openclaw_agent_codex:localhost", label: "Codex", - sessionKey: "agent:codex:session_1", + sessionKey: "agent:codex", }, }, - portalKey: { id: "session:YWdlbnQ6Y29kZXg6c2Vzc2lvbl8x", receiver: "login" }, - receiver: "login", + portalKey: { id: expect.stringMatching(/^conversation:/), receiver: "openclaw:plugin" }, + receiver: "openclaw:plugin", roomType: "dm", mxid: "!codex-dm:example.com", }, - userId: "@codex:example.com", + userId: "@sh-openclaw_agent_codex:localhost", }); - expect(createPortal).toHaveBeenCalledWith(login(), { + expect(createPortal).toHaveBeenCalledWith(login(), expect.objectContaining({ creationContent: { "m.federate": false }, - id: "session:YWdlbnQ6Y29kZXg6c2Vzc2lvbl8x", + id: expect.stringMatching(/^conversation:/), metadata: { openclaw: { agentId: "codex", - ghostUserId: "@codex:example.com", + ghostUserId: "@sh-openclaw_agent_codex:localhost", label: "Codex", - sessionKey: "agent:codex:session_1", + sessionKey: "agent:codex", }, }, name: "Codex", roomType: "dm", - sender: "@codex:example.com", - }); + sender: "@sh-openclaw_agent_codex:localhost", + })); expect(registry.getBindingByRoom("!codex-dm:example.com")).toMatchObject({ agentId: "codex", roomId: "!codex-dm:example.com", - sessionKey: "agent:codex:session_1", + sessionKey: "agent:codex", }); }); @@ -365,7 +494,7 @@ describe("OpenClawBridgeConnector", () => { config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), login: login(), registry, - runtime: runtimeWith({ responses: { "sessions.create": { key: "agent:codex:session_2" } } }), + runtime: runtimeWith({ responses: { "agents.list": { agents: [{ id: "codex", name: "Codex" }] } } }), }); const createPortal = vi.fn(async (loginArg, options) => ({ id: options.id, @@ -381,11 +510,11 @@ describe("OpenClawBridgeConnector", () => { type: "username", })).resolves.toMatchObject({ portal: { - id: "session:YWdlbnQ6Y29kZXg6c2Vzc2lvbl8y", + id: expect.stringMatching(/^conversation:/), mxid: "!second-codex-dm:example.com", - portalKey: { id: "session:YWdlbnQ6Y29kZXg6c2Vzc2lvbl8y", receiver: "openclaw:plugin" }, + portalKey: { id: expect.stringMatching(/^conversation:/), receiver: "openclaw:plugin" }, }, - userId: "@codex:example.com", + userId: "@sh-openclaw_agent_codex:localhost", }); expect(createPortal).toHaveBeenCalledOnce(); expect(registry.getBindingsByAgent("codex")).toHaveLength(2); @@ -429,14 +558,8 @@ describe("OpenClawBridgeConnector", () => { }); }); - it("applies contact visibility to Beeper contact listing", async () => { + it("lists current agent contacts", async () => { const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-contacts-test.json"); - registry.upsertUser({ - displayName: "Alice from Telegram", - ghostUserId: "@sh-openclaw_user_alice:example.com", - source: "telegram", - userId: "alice", - }); const runtime = runtimeWith({ responses: { "agents.list": { @@ -444,7 +567,6 @@ describe("OpenClawBridgeConnector", () => { }, }, }); - runtime.config.contactVisibility = "agents-and-users"; const api = new OpenClawNetworkAPI({ config: runtime.config, login: login(), @@ -452,30 +574,26 @@ describe("OpenClawBridgeConnector", () => { runtime, }); - await expect(api.listContacts({} as BridgeRequestContext, { query: "telegram" })).resolves.toEqual({ + await expect(api.listContacts({} as BridgeRequestContext, { query: "codex" })).resolves.toEqual({ contacts: [{ ghost: { - displayName: "Alice from Telegram", - id: "alice", + displayName: "Codex", + id: "codex", metadata: { openclaw: { - displayName: "Alice from Telegram", - ghostUserId: "@sh-openclaw_user_alice:example.com", - source: "telegram", - userId: "alice", + agentId: "codex", + displayName: "Codex", + ghostUserId: "@sh-openclaw_agent_codex:localhost", }, }, - mxid: "@sh-openclaw_user_alice:example.com", + mxid: "@sh-openclaw_agent_codex:localhost", }, - userId: "@sh-openclaw_user_alice:example.com", + userId: "@sh-openclaw_agent_codex:localhost", }], }); - - runtime.config.contactVisibility = "none"; - await expect(api.listContacts({} as BridgeRequestContext, {})).resolves.toEqual({ contacts: [] }); }); - it("drops disallowed rooms, users, and bridge-owned ghost senders before forwarding to OpenClaw", async () => { + it("drops bridge-owned ghost senders before forwarding to OpenClaw", async () => { const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); registry.upsertAgent({ agentId: "codex", displayName: "Codex", ghostUserId: "@codex:example.com" }); const runtime = runtimeWith({ @@ -484,8 +602,6 @@ describe("OpenClawBridgeConnector", () => { "beeper.turn": { runId: "run_1", sessionKey: "agent:codex:session_1" }, }, }); - runtime.config.allowedRoomIds = ["!allowed:example.com"]; - runtime.config.allowedUserIds = ["@alice:example.com"]; runtime.config.matrixUserId = "@sh-openclawbot:example.com"; const api = new OpenClawNetworkAPI({ config: runtime.config, @@ -496,31 +612,41 @@ describe("OpenClawBridgeConnector", () => { const portal = { id: "agent:codex", metadata: { openclaw: { agentId: "codex", ghostUserId: "@codex:example.com", sessionKey: "agent:codex" } }, - mxid: "!blocked:example.com", + mxid: "!room:example.com", portalKey: { id: "agent:codex", receiver: "login" }, receiver: "login", }; await api.handleMatrixMessage({} as BridgeRequestContext, { - event: { eventId: "$blocked-room" }, + event: { eventId: "$alice" }, 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" }, + event: { eventId: "$mallory" }, + portal, sender: { userId: "@mallory:example.com" }, text: "hello", } as MatrixMessage); await api.handleMatrixMessage({} as BridgeRequestContext, { event: { eventId: "$ghost" }, - portal: { ...portal, mxid: "!allowed:example.com" }, + portal, sender: { userId: "@codex:example.com" }, text: "hello", } as MatrixMessage); - expect(runtime.transport.request).not.toHaveBeenCalled(); + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$alice", + sessionKey: "agent:codex:session_1", + })); + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$mallory", + sessionKey: "agent:codex:session_1", + })); + expect(runtime.sendMessage).not.toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$ghost", + })); }); it("accepts the Beeper owner MXID as a sender in self-hosted cloud rooms", async () => { @@ -1119,7 +1245,6 @@ describe("OpenClawBridgeConnector", () => { "exec.approval.resolve": { ok: true }, }, }); - runtime.config.approvalBehavior = "native"; const api = new OpenClawNetworkAPI({ config: runtime.config, login: login(), @@ -1144,24 +1269,23 @@ 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", + approvalId: "approval_native", approved: true, type: "tool-approval-response", }, - event: { eventId: "$native-disabled" }, + event: { eventId: "$native" }, portal, sender: { userId: "@alice:example.com" }, text: "Approved", } as MatrixMessage); - expect(runtime.transport.request).not.toHaveBeenCalledWith("exec.approval.resolve", { - approvalId: "approval_native_disabled", + expect(runtime.transport.request).toHaveBeenCalledWith("exec.approval.resolve", { + approvalId: "approval_native", decision: "approve", }); expect(runtime.sendMessage).not.toHaveBeenCalledWith(expect.objectContaining({ - idempotencyKey: "$native-disabled", + idempotencyKey: "$native", })); await api.handleMatrixMessage({} as BridgeRequestContext, { @@ -1202,19 +1326,6 @@ describe("OpenClawBridgeConnector", () => { sessionKey: "agent:codex:session_1", })); - runtime.config.approvalBehavior = "disabled"; - await api.handleMatrixMessage({} as BridgeRequestContext, { - event: { eventId: "$approve-disabled" }, - portal, - sender: { userId: "@alice:example.com" }, - text: "/approve approval_2", - } as MatrixMessage); - expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ - idempotencyKey: "$approve-disabled", - message: "/approve approval_2", - sessionKey: "agent:codex:session_1", - })); - }); it("rebuilds an OpenClaw room binding from a persisted Pickle session portal without metadata", async () => { @@ -1302,52 +1413,6 @@ describe("OpenClawBridgeConnector", () => { })); }); - 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", timestamp: "2026-05-16T11:59:00.000Z" }, - { content: "hi", id: "m2", messageSeq: 2, role: "assistant", timestamp: 1_779_000_000 }, - ], - }, - }, - }); - const api = new OpenClawNetworkAPI({ - config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), - login: login(), - registry, - runtime, - }); - const portal = { - id: "agent:codex", - metadata: { - openclaw: { - agentId: "codex", - ghostUserId: "@codex:example.com", - 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(["@alice:example.com", "@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), - ]); - 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 b517d44..bcf8ef2 100644 --- a/packages/openclaw/src/connector.ts +++ b/packages/openclaw/src/connector.ts @@ -2,16 +2,12 @@ import { randomUUID, } from "node:crypto"; import { - createRemoteMessage, - type BackfillingNetworkAPI, BridgeConnector, BridgeContext, BridgeRequestContext, BridgeUser, ConnectContext, type ContactListingNetworkAPI, - FetchMessagesParams, - FetchMessagesResponse, type EditHandlingNetworkAPI, IdentifierResolvingNetworkAPI, type ListContactsParams, @@ -41,6 +37,7 @@ import { NetworkAPI, NetworkGeneralCapabilities, Portal, + type Avatar, type PortalKey, ReactionHandlingNetworkAPI, type ReadReceiptHandlingNetworkAPI, @@ -55,7 +52,6 @@ import { ResolveIdentifierResponse, UserLogin, } from "@beeper/pickle-bridge"; -import { buildBackfillImport } from "./backfill"; import { parseApprovalReactionContent, parseApprovalResponseContent } from "./approval"; import { BEEPER_CHANNEL_RUNTIME_CONTEXT_CAPABILITY, @@ -66,6 +62,7 @@ import { agentPortalSessionKey, OpenClawMatrixBridgeAgent } from "./bridge-agent import { createDefaultConfig } from "./config"; import { parseMatrixTextMessage, type ParsedMatrixTextMessage } from "./matrix-parser"; import { + BEEPER_SESSION_REASONING_LEVEL, createOpenClawHostRuntimeAdapter, type OpenClawBridgeRuntime, OpenClawPluginRuntimeAdapter, @@ -78,10 +75,9 @@ import { import { OpenClawBridgeRegistry } from "./registry"; import { agentContactFromOpenClawAgent, agentGhostUserId, serviceBotUserId } from "./rooms"; import { matrixDomainFromHomeserver } from "./rooms"; -import type { OpenClawAgentContact, OpenClawBridgeConfig, OpenClawSessionBinding, OpenClawUserContact } from "./types"; +import type { OpenClawAgentContact, OpenClawBridgeConfig, OpenClawSessionBinding } from "./types"; const DEFAULT_NEW_SESSION_LABEL = "New OpenClaw Session"; -const DEFAULT_BOOTSTRAP_MESSAGE = "hey, are you alive? - sent from my beeper"; export interface OpenClawConnectorOptions { config?: OpenClawBridgeConfig; @@ -236,13 +232,14 @@ export class OpenClawBridgeConnector implements BridgeConnector void) | undefined; readonly #registry: OpenClawBridgeRegistry; readonly #runtime: OpenClawBridgeRuntime; + #welcomeRooms: Promise | undefined; constructor(options: { config: OpenClawBridgeConfig; @@ -266,29 +263,13 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor async connect(ctx: ConnectContext): Promise { await this.#agent.syncAgentContacts(); - const bootstrapContact = this.bootstrapAgentContact(); - 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, - }); - } - } - 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, - }); - } + this.ensureDefaultAgentContact(); + for (const contact of this.#registry.data.agents) { + ctx.bridge.registerGhost(agentGhost(contact)); } - await this.ensureBootstrapRoom(ctx, bootstrapContact); + this.syncAgentBindings(); + await this.#registry.save(); + await this.ensureAgentWelcomeRooms(ctx); } async disconnect(): Promise { @@ -330,21 +311,12 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor 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) => ({ + 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); @@ -362,9 +334,7 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor 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)) { - await this.#agent.handleApprovalContent(msg.content, approval.approvalId ?? approvalIdFromMatrixReply(msg)); - } + await this.#agent.handleApprovalContent(msg.content, approval.approvalId ?? approvalIdFromMatrixReply(msg)); return { pending: false }; } const parsed = parseMatrixTextMessage(msg.text, msg.content, msg); @@ -565,74 +535,27 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor 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: [] }; - 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 sessionOptions: Parameters[2] = { - agentId: binding.agentId, - label: binding.label ?? binding.sessionKey, - session: { key: binding.sessionKey }, - sessionKey: binding.sessionKey, - source: binding.owner === "imported" ? "unknown" : "channel", - }; - 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) => ({ - 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: backfillSenderUserId(this.#runtime.config, this.#login, binding, message.sender), - }, - timestamp: message.timestamp ?? new Date(0), - }), - })), - }; - } - isAllowedMatrixIngress(roomId: string | undefined, sender: string | undefined): boolean { - if (!this.isAllowedRoom(roomId)) return false; - if (!this.isAllowedUser(sender)) return false; + if (!roomId) 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)); + return Boolean(roomId); } isAllowedUser(sender: string | undefined): boolean { - return !this.#config.allowedUserIds?.length || Boolean(sender && this.#config.allowedUserIds.includes(sender)); + return Boolean(sender); } isBridgeOwnedSender(sender: string): boolean { return sender === serviceBotUserId(this.#config) - || this.#registry.data.agents.some((contact) => contact.ghostUserId === sender) - || this.#registry.data.users.some((contact) => contact.ghostUserId === sender); + || this.#registry.data.agents.some((contact) => contact.ghostUserId === sender); } logRejectedMatrixIngress(ctx: BridgeRequestContext, kind: string, roomId: string | undefined, sender: string | undefined): void { ctx.log?.("warn", "openclaw_matrix_ingress_rejected", { - allowedRoomCount: this.#config.allowedRoomIds?.length ?? 0, - allowedUserCount: this.#config.allowedUserIds?.length ?? 0, bridgeOwned: sender ? this.isBridgeOwnedSender(sender) : false, kind, roomId, @@ -690,6 +613,7 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor agentId, key: newBeeperSessionKey(agentId), label, + reasoningLevel: BEEPER_SESSION_REASONING_LEVEL, }); const now = Date.now(); const binding: OpenClawSessionBinding = { @@ -714,26 +638,49 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor contact: OpenClawAgentContact, label = contact.displayName, ): Promise { - const session = await this.#runtime.createSession({ - agentId: contact.agentId, - key: newBeeperSessionKey(contact.agentId), - label, - }); - return portalForAgentSession(contact, this.#login.id, session.key, label); + return portalForAgentConversation(contact, this.#login.id, label); } - private bootstrapAgentContact(): OpenClawAgentContact { + private ensureDefaultAgentContact(): void { const contact = this.#registry.getAgent("main") ?? this.#registry.data.agents[0] ?? agentContactFromOpenClawAgent(this.#runtime.config, { id: "main", name: "Main" }); if (!this.#registry.getAgent(contact.agentId)) this.#registry.upsertAgent(contact); - return contact; } - private async ensureBootstrapRoom(ctx: ConnectContext, contact: OpenClawAgentContact): Promise { - if (this.#registry.data.bindings.length > 0) return; - let portal = await this.createSessionPortalForAgent(ctx, contact, "OpenClaw"); + private syncAgentBindings(): void { + for (const contact of this.#registry.data.agents) { + for (const binding of this.#registry.getBindingsByAgent(contact.agentId)) { + this.#registry.updateBinding(binding.id, (current) => stripUndefined({ + ...current, + ghostUserId: contact.ghostUserId, + ...(current.kind === "agent" ? { label: contact.displayName } : {}), + updatedAt: Date.now(), + })); + } + } + } + + private async ensureAgentWelcomeRooms(ctx: ConnectContext): Promise { + if (this.#welcomeRooms) return this.#welcomeRooms; + this.#welcomeRooms = this.createMissingAgentWelcomeRooms(ctx); + try { + await this.#welcomeRooms; + } finally { + this.#welcomeRooms = undefined; + } + } + + private async createMissingAgentWelcomeRooms(ctx: ConnectContext): Promise { + for (const contact of this.#registry.data.agents) { + if (this.#registry.getBindingById(agentPortalBindingId(contact.agentId))) continue; + await this.createAgentWelcomeRoom(ctx, contact); + } + } + + private async createAgentWelcomeRoom(ctx: ConnectContext, contact: OpenClawAgentContact): Promise { + let portal = portalForAgentWelcome(contact, this.#login.id); const portalOptions: Parameters[1] = { id: portal.id, metadata: portal.metadata, @@ -754,25 +701,8 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor if (receiver !== undefined) portal.receiver = receiver; this.upsertPortalBinding(portal); const binding = portal.mxid ? this.#registry.getBindingByRoom(portal.mxid) : undefined; - if (!portal.mxid || !binding) throw new Error("OpenClaw Beeper bootstrap room was created without a bound Matrix room"); + if (!portal.mxid || !binding) throw new Error("OpenClaw Beeper agent welcome room was created without a bound Matrix room"); this.registerCanonicalPortalForBinding(ctx, portal, binding); - const sender = this.#login.userId ?? userLoginFromOpenClawConfig(this.#runtime.config).userId ?? serviceBotUserId(this.#runtime.config); - const sent = await ctx.client.appservice.sendMessage({ - content: { - body: DEFAULT_BOOTSTRAP_MESSAGE, - msgtype: "m.text", - }, - roomId: portal.mxid, - userId: sender, - }); - this.#onActivity?.(outboundActivityPatch()); - await this.#agent.handleMatrixText({ - eventId: sent.eventId, - matrix: { sender }, - roomId: portal.mxid, - sender, - text: DEFAULT_BOOTSTRAP_MESSAGE, - }); await this.#registry.save(); } } @@ -823,10 +753,6 @@ function approvalReactionsEnabled(_config: OpenClawBridgeConfig): boolean { return false; } -function approvalNativeEnabled(config: OpenClawBridgeConfig): boolean { - return config.approvalBehavior === undefined || config.approvalBehavior === "native"; -} - function openClawPortalCreationContent(_config: OpenClawBridgeConfig): Record | undefined { return { "m.federate": false }; } @@ -867,20 +793,16 @@ function matrixMetadataFromParsed( return metadata; } -function portalForAgentSession( - contact: OpenClawAgentContact, - receiver: string, - sessionKey: string, - label?: string, -): Portal { - const id = portalIdForSession(sessionKey); +function portalForAgentWelcome(contact: OpenClawAgentContact, receiver: string): Portal { + const sessionKey = agentPortalSessionKey(contact.agentId); + const id = agentPortalBindingId(contact.agentId); return { id, metadata: { openclaw: stripUndefined({ agentId: contact.agentId, ghostUserId: contact.ghostUserId, - ...(label ? { label } : {}), + label: contact.displayName, sessionKey, }), }, @@ -890,6 +812,28 @@ function portalForAgentSession( }; } +function agentPortalBindingId(agentId: string): string { + return `agent:${agentId}`; +} + +function portalForAgentConversation(contact: OpenClawAgentContact, receiver: string, label?: string): Portal { + const id = `conversation:${Buffer.from(randomUUID()).toString("base64url")}`; + return { + id, + metadata: { + openclaw: stripUndefined({ + agentId: contact.agentId, + ghostUserId: contact.ghostUserId, + ...(label ? { label } : {}), + sessionKey: agentPortalSessionKey(contact.agentId), + }), + }, + portalKey: { id, receiver }, + receiver, + roomType: "dm", + }; +} + function findAgentContact(contacts: readonly OpenClawAgentContact[], identifier: string): OpenClawAgentContact | undefined { const normalized = identifier.trim().toLowerCase(); if (!normalized) return undefined; @@ -906,26 +850,31 @@ function portalIdForSession(sessionKey: string): string { function contactResponse(contact: OpenClawAgentContact, portal?: Portal): ResolveIdentifierResponse { return { - ghost: { - displayName: contact.displayName, - id: contact.agentId, - metadata: { openclaw: contact }, - mxid: contact.ghostUserId, - }, + ghost: agentGhost(contact), ...(portal ? { portal } : {}), userId: contact.ghostUserId, }; } -function userContactResponse(contact: OpenClawUserContact): ResolveIdentifierResponse { +function agentGhost(contact: OpenClawAgentContact) { + const avatar = agentAvatar(contact); return { - ghost: { - displayName: contact.displayName, - id: contact.userId, - metadata: { openclaw: contact }, - mxid: contact.ghostUserId, - }, - userId: contact.ghostUserId, + ...(avatar ? { avatar } : {}), + displayName: contact.displayName, + id: contact.agentId, + metadata: { openclaw: contact }, + mxid: contact.ghostUserId, + }; +} + +function agentAvatar(contact: OpenClawAgentContact): Avatar | undefined { + const url = contact.avatarUrl ?? contact.avatarMxc; + const id = contact.avatarMxc ?? url; + if (!id) return undefined; + return { + id, + ...(contact.avatarMxc ? { mxc: contact.avatarMxc } : {}), + ...(url ? { url } : {}), }; } @@ -944,8 +893,8 @@ function bindingFromPortal(portal: Portal, config: OpenClawBridgeConfig): OpenCl agentId, createdAt: now, ghostUserId, - id: Buffer.from(roomId).toString("base64url"), - kind: "session", + id: portalId.startsWith("agent:") ? portalId : Buffer.from(roomId).toString("base64url"), + kind: portalId.startsWith("agent:") ? "agent" : "session", ...(label ? { label } : {}), owner: openclaw ? "bridge" : "imported", roomId, @@ -963,7 +912,7 @@ function openClawPortalId(portal: Portal): string { function openClawPortalIdFromString(value: string | undefined): string | undefined { if (!value) return undefined; - return value.startsWith("session:") || value.startsWith("agent:") ? value : undefined; + return value.startsWith("session:") || value.startsWith("agent:") || value.startsWith("conversation:") ? value : undefined; } function openClawPortalIdFromRoomId(roomId: string | undefined): string | undefined { @@ -998,17 +947,6 @@ function agentIdFromSessionKey(sessionKey: string | undefined): string | undefin return agentId || undefined; } -function backfillSenderUserId( - config: OpenClawBridgeConfig, - login: UserLogin, - binding: OpenClawSessionBinding, - sender: "agent" | "human" | "system" -): string { - if (sender === "agent") return binding.ghostUserId; - if (sender === "human") return login.userId ?? binding.humanGhostUserId ?? serviceBotUserId(config); - return serviceBotUserId(config); -} - export function userLoginFromOpenClawConfig(config: OpenClawBridgeConfig): UserLogin { return { id: "openclaw:plugin", diff --git a/packages/openclaw/src/integration.test.ts b/packages/openclaw/src/integration.test.ts index aa06f1d..a753fdd 100644 --- a/packages/openclaw/src/integration.test.ts +++ b/packages/openclaw/src/integration.test.ts @@ -240,24 +240,21 @@ describe("OpenClaw bridge integration", () => { ])); }); - it("smokes contact DM creation, Matrix ingress, approval, and backfill with local fakes", async () => { + it("smokes contact DM creation, Matrix ingress, and approval with local fakes", async () => { const dir = await mkdtemp(resolve(tmpdir(), "pickle-openclaw-local-smoke-")); const config = createDefaultConfig({ asToken: "as-token", dataDir: dir, homeserver: "https://matrix.example", hsToken: "hs-token", - importSources: ["dashboard"], matrixDeviceId: "DEVICE", matrixUserId: "@sh-openclawbot:example", }); const transport = fakeTransport({ 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" } }] }, "beeper.turn": { runId: "run_1", sessionKey: "session_1" }, }, }); @@ -293,15 +290,15 @@ describe("OpenClaw bridge integration", () => { type: "username", }); expect(resolved.portal).toMatchObject({ - id: "session:c2Vzc2lvbl8x", + id: expect.stringMatching(/^conversation:/), mxid: "!created:example", - portalKey: { id: "session:c2Vzc2lvbl8x", receiver: login.id }, + portalKey: { id: expect.stringMatching(/^conversation:/), receiver: login.id }, }); - expect(client.appservice.createPortalRoom).toHaveBeenCalledWith(expect.objectContaining({ + expect(client.appservice.createPortalRoom).toHaveBeenLastCalledWith(expect.objectContaining({ creationContent: { "m.federate": false }, isDirect: true, name: "Codex", - portalKey: { id: "session:c2Vzc2lvbl8x", receiver: login.id }, + portalKey: { id: expect.stringMatching(/^conversation:/), receiver: login.id }, roomType: "dm", })); @@ -561,7 +558,7 @@ function createFakeMatrixClient(): MatrixClient & { subscription: MatrixSubscrip return { accountData: {} as MatrixClient["accountData"], appservice: { - batchSend: vi.fn(async () => ({ eventIds: ["$backfilled"], raw: {} })), + batchSend: vi.fn(async () => ({ eventIds: ["$batch"], raw: {} })), createManagementRoom: vi.fn(async () => ({ raw: {}, roomId: "!created:example" })), createPortalRoom: vi.fn(async () => ({ raw: {}, roomId: "!created:example" })), createRoom: vi.fn(async () => ({ raw: {}, roomId: "!created:example" })), diff --git a/packages/openclaw/src/openclaw-extension.test.ts b/packages/openclaw/src/openclaw-extension.test.ts index c599a52..a78b991 100644 --- a/packages/openclaw/src/openclaw-extension.test.ts +++ b/packages/openclaw/src/openclaw-extension.test.ts @@ -114,10 +114,11 @@ 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).toEqual(["PICKLE_OPENCLAW_BEEPER_ENV"]); expect(manifest.channelEnvVars?.beeper).not.toContain("PICKLE_OPENCLAW_ACCESS_TOKEN"); expect(manifest.channelEnvVars?.beeper).not.toContain("PICKLE_OPENCLAW_GATEWAY_ACCESS_TOKEN"); expect(manifest.channelEnvVars?.beeper).not.toContain("OPENCLAW_GATEWAY_TOKEN"); + expect(manifest.channelEnvVars?.beeper).not.toContain("PICKLE_OPENCLAW_DEVICE_ID"); expect(manifest.uiHints).toBeUndefined(); expect(manifest.configSchema).toEqual({ type: "object", @@ -132,12 +133,23 @@ describe("OpenClaw plugin package metadata", () => { nativeSkillsAutoEnabled: true, }, schema: { - properties: expect.objectContaining({ - importSources: expect.any(Object), + properties: expect.not.objectContaining({ + appserviceId: expect.anything(), + asToken: expect.anything(), + backfillLimit: expect.anything(), + bridgeId: expect.anything(), + homeserver: expect.anything(), + homeserverDomain: expect.anything(), + hsToken: expect.anything(), + importSources: expect.anything(), + matrixDeviceId: expect.anything(), + matrixUserId: expect.anything(), }), }, uiHints: expect.not.objectContaining({ accessToken: expect.anything(), + asToken: expect.anything(), + hsToken: expect.anything(), }), }); }); diff --git a/packages/openclaw/src/openclaw-runtime.test.ts b/packages/openclaw/src/openclaw-runtime.test.ts index d5b5964..75fa8ac 100644 --- a/packages/openclaw/src/openclaw-runtime.test.ts +++ b/packages/openclaw/src/openclaw-runtime.test.ts @@ -52,6 +52,32 @@ describe("OpenClawPluginRuntimeAdapter", () => { .rejects.toThrow("OpenClaw Beeper turns require OpenClaw channel inbound helpers"); }); + it("patches session reasoning after create when Beeper needs rich reasoning events", async () => { + const transport = fakeTransport({ + "sessions.create": { key: "agent:codex:main", sessionId: "session_1" }, + "sessions.patch": { ok: true }, + }); + const runtime = new OpenClawPluginRuntimeAdapter({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + transport, + }); + + await expect(runtime.createSession({ agentId: "codex", label: "Main", reasoningLevel: "on" })).resolves.toMatchObject({ + agentId: "codex", + key: "agent:codex:main", + label: "Main", + }); + expect(transport.request).toHaveBeenCalledWith("sessions.create", { + agentId: "codex", + label: "Main", + }); + expect(transport.request).toHaveBeenCalledWith("sessions.patch", { + agentId: "codex", + key: "agent:codex:main", + reasoningLevel: "on", + }); + }); + it("filters gateway events by run id and resolves approvals", async () => { const events: OpenClawGatewayEvent[] = [ { event: "assistant.delta", payload: { delta: "skip", runId: "run_other" } }, @@ -284,7 +310,12 @@ describe("OpenClawPluginRuntimeAdapter", () => { }); await replyOptions.onPartialReply?.({ text: "hello" }); const delivery = params.delivery as { deliver?: (payload: unknown) => Promise }; - await delivery.deliver?.({ text: "hello world" }); + await delivery.deliver?.({ + parts: [ + { content: "{\"status\":\"completed\",\"query\":\"docs\"}", type: "tool-call" }, + { content: "hello world", type: "text" }, + ], + }); return { dispatchResult: { queuedFinal: true } }; }); const hostRuntime = { @@ -368,23 +399,22 @@ describe("OpenClawPluginRuntimeAdapter", () => { expect.objectContaining({ event: "run.completed" }), ])); expect(aiRunStreams.start).toHaveBeenCalledTimes(1); - expect(aiRunStreams.appendEvent.mock.calls.map(([options]) => options.event.type)).toEqual(expect.arrayContaining([ - "REASONING_MESSAGE_CONTENT", - "TOOL_CALL_START", - "TOOL_CALL_ARGS", - "TOOL_CALL_RESULT", - "TOOL_CALL_END", - "CUSTOM", - "TEXT_MESSAGE_CONTENT", + const streamParts = startedAndAppendedParts(aiRunStreams); + expect(streamParts.map((part) => part.kind)).toEqual(expect.arrayContaining([ + "reasoning", + "tool_start", + "tool_result", + "text", ])); - const toolOutput = aiRunStreams.appendEvent.mock.calls - .map(([options]) => options.event) - .find((part) => part.type === "TOOL_CALL_RESULT" && part.content === "ok"); + expect(aiRunStreams.appendEvent.mock.calls.map(([options]) => options.event.type)).toContain("CUSTOM"); + const toolOutput = streamParts.find((part) => part.kind === "tool_result" && part.output === "ok"); expect(toolOutput).toMatchObject({ - state: "complete", toolCallId: "real-tool-id", toolName: "read_file", }); + expect(streamParts).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "text", text: expect.stringContaining("\"status\":\"completed\"") }), + ])); expect(aiRunStreams.finish).toHaveBeenCalledWith(expect.objectContaining({ runId: observedRunId, })); @@ -416,9 +446,13 @@ describe("OpenClawPluginRuntimeAdapter", () => { await replyOptions.onToolStart?.({ args: { path: "b.txt" }, name: "read_file", phase: "start", toolCallId: "tool-b" }); await replyOptions.onCommandOutput?.({ name: "read_file", output: "chunk-a", phase: "delta", status: "running", toolCallId: "tool-a" }); await replyOptions.onCommandOutput?.({ name: "read_file", output: "done-a", phase: "end", status: "completed", toolCallId: "tool-a" }); + await replyOptions.onToolStart?.({ args: { query: "docs" }, name: "web_search", phase: "start", toolCallId: "web-1" }); + await replyOptions.onCommandOutput?.({ name: "web_search", phase: "end", queries: ["docs"], query: "docs", status: "completed", toolCallId: "web-1" }); + await replyOptions.onToolStart?.({ args: { query: "blog" }, name: "web_search", phase: "start", toolCallId: "web-2" }); + await replyOptions.onToolResult?.({ output: { queries: ["blog"], query: "blog", state: "complete", status: "completed" }, toolCallId: "web-2", toolName: "web_search" }); await replyOptions.onToolResult?.({ result: { ok: true }, toolCallId: "tool-b", toolName: "read_file" }); const delivery = params.delivery as { deliver?: (payload: unknown, info?: unknown) => Promise }; - await delivery.deliver?.({ text: "hello world" }, { kind: "final" }); + await delivery.deliver?.({ text: "hello world https://example.com/final." }, { kind: "final" }); return { dispatchResult: { queuedFinal: true } }; }); const hostRuntime = { @@ -460,25 +494,30 @@ describe("OpenClawPluginRuntimeAdapter", () => { }); await done; - const parts = aiRunStreams.appendEvent.mock.calls.map(([options]) => options.event); - expect(parts.filter((part) => part.type === "TEXT_MESSAGE_CONTENT").map((part) => part.delta)).toEqual([ + const parts = startedAndAppendedParts(aiRunStreams); + expect(parts.filter((part) => part.kind === "text").map((part) => part.text)).toEqual([ "hel", "lo", - " world", + " world https://example.com/final.", ]); - expect(parts.filter((part) => part.type === "TOOL_CALL_START").map((part) => [part.toolCallId, part.toolName])).toEqual([ + expect(parts.filter((part) => part.kind === "tool_start").map((part) => [part.toolCallId, part.toolName])).toEqual([ ["tool-a", "read_file"], ["tool-b", "read_file"], + ["web-1", "web_search"], + ["web-2", "web_search"], ]); - expect(parts.filter((part) => part.type === "TOOL_CALL_RESULT").map((part) => [part.toolCallId, part.content, part.state])).toEqual([ - ["tool-a", "chunk-a", "streaming"], - ["tool-a", "done-a", "complete"], - ["tool-b", "{\"ok\":true}", "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"], + expect(parts.filter((part) => part.kind === "tool_result").map((part) => [part.toolCallId, part.output, part.preliminary])).toEqual([ + ["tool-a", "chunk-a", true], + ["tool-a", "done-a", false], + ["tool-b", { ok: true }, undefined], ]); + expect(parts).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "custom", name: "com.beeper.source", value: expect.objectContaining({ sourceId: "https://example.com/final", title: "example.com", url: "https://example.com/final" }) }), + ])); + expect(parts).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ output: expect.objectContaining({ query: "docs" }), toolCallId: "web-1", kind: "tool_result" }), + expect.objectContaining({ output: expect.objectContaining({ query: "blog" }), toolCallId: "web-2", kind: "tool_result" }), + ])); setBeeperChannelRuntimeForHost(hostRuntime, undefined); }); @@ -516,9 +555,11 @@ describe("OpenClawPluginRuntimeAdapter", () => { agentEventListener?.({ data: { metadata: { source: "codex" }, + description: "Searches OpenClaw docs", phase: "start", providerExecuted: true, startedAtMs: 123, + title: "Search docs", toolCall: { arguments: "{\"query\":\"openclaw\"}", id: "nested-tool", name: "search" }, }, runId: replyOptions.runId, @@ -538,8 +579,48 @@ describe("OpenClawPluginRuntimeAdapter", () => { agentEventListener?.({ data: { name: "bash", phase: "start", toolCallId: "delta-tool" }, runId: replyOptions.runId, stream: "tool" }); agentEventListener?.({ data: { delta: "{\"cmd\":\"pwd\"}", name: "bash", phase: "input_delta", toolCallId: "delta-tool" }, runId: replyOptions.runId, stream: "tool" }); agentEventListener?.({ data: { name: "bash", output: "/tmp/project", phase: "finished", toolCallId: "delta-tool" }, runId: replyOptions.runId, stream: "tool" }); + agentEventListener?.({ + data: { + name: "bash", + phase: "finished", + response: "tool wrapper response", + result: { + content: "wrapper content", + details: { + aggregated: "stdout\nstderr", + command: "npm test", + cwd: "/tmp/project", + durationMs: 25, + exitCode: 0, + status: "completed", + stderr: "stderr", + stdout: "stdout", + }, + }, + title: "Run tests", + toolCallId: "bash-rich", + }, + runId: replyOptions.runId, + stream: "tool", + }); agentEventListener?.({ data: { phase: "update", title: "Plan", explanation: "checking docs", steps: ["Search", "Answer"] }, runId: replyOptions.runId, stream: "plan" }); agentEventListener?.({ data: { itemId: "cmd-1", phase: "delta", title: "Shell", toolCallId: "cmd-1", name: "shell", output: "stdout" }, runId: replyOptions.runId, stream: "command_output" }); + agentEventListener?.({ + data: { + input: { + command: "/bin/zsh -lc \"date '+%Y-%m-%d %H:%M:%S %Z'\"", + cwd: "/Users/batuhan/.openclaw/workspace", + }, + name: "bash", + output: { status: "completed" }, + phase: "finished", + response: "2026-06-02 03:15:00 CEST", + status: "completed", + toolCallId: "cmd-date", + }, + runId: replyOptions.runId, + stream: "command_output", + }); agentEventListener?.({ data: { itemId: "patch-1", phase: "end", title: "Patch", toolCallId: "patch-1", name: "patch", added: [], modified: ["a.ts"], deleted: [], summary: "changed a.ts" }, runId: replyOptions.runId, stream: "patch" }); agentEventListener?.({ data: { items: [{ title: "Docs", url: "https://example.com" }] }, runId: replyOptions.runId, stream: "source" }); agentEventListener?.({ data: { filename: "report.txt", id: "file_1" }, runId: replyOptions.runId, stream: "file" }); @@ -596,39 +677,101 @@ describe("OpenClawPluginRuntimeAdapter", () => { }); await done; - const parts = aiRunStreams.appendEvent.mock.calls.map(([options]) => options.event); - expect(parts.filter((part) => part.type === "TEXT_MESSAGE_CONTENT").map((part) => part.delta)).toEqual([ + const parts = startedAndAppendedParts(aiRunStreams); + expect(parts.filter((part) => part.kind === "text").map((part) => part.text)).toEqual([ "hel", "lo", " 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: { state: "running", text: "Working..." }, type: "ACTIVITY_SNAPSHOT" }), - expect.objectContaining({ activityType: "tool.progress", content: expect.objectContaining({ label: "search", phase: "running", text: "Searching docs" }), type: "ACTIVITY_SNAPSHOT" }), - expect.objectContaining({ toolCallId: "tool-stream", toolName: "search", type: "TOOL_CALL_START" }), - expect.objectContaining({ toolCallId: "tool-stream", toolName: "search", type: "TOOL_CALL_END" }), - expect.objectContaining({ metadata: { source: "codex" }, providerExecuted: true, startedAtMs: 123, toolCallId: "nested-tool", toolName: "search", type: "TOOL_CALL_START" }), - expect.objectContaining({ delta: "{\"query\":\"openclaw\"}", toolCallId: "nested-tool", type: "TOOL_CALL_ARGS" }), - expect.objectContaining({ input: { query: "openclaw" }, toolCallId: "nested-tool", toolName: "search", type: "TOOL_CALL_END" }), - expect.objectContaining({ completedAtMs: 456, content: "{\"items\":[{\"title\":\"OpenClaw\",\"url\":\"https://example.com/openclaw\"}]}", providerExecuted: true, state: "complete", toolCallId: "nested-tool", toolName: "search", type: "TOOL_CALL_RESULT" }), - expect.objectContaining({ name: "com.beeper.source", type: "CUSTOM", value: expect.objectContaining({ sourceId: "https://example.com/openclaw", title: "OpenClaw", url: "https://example.com/openclaw" }) }), - expect.objectContaining({ delta: "{\"cmd\":\"pwd\"}", toolCallId: "delta-tool", type: "TOOL_CALL_ARGS" }), - expect.objectContaining({ content: "/tmp/project", state: "complete", toolCallId: "delta-tool", toolName: "bash", type: "TOOL_CALL_RESULT" }), - 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: "com.beeper.source", type: "CUSTOM", value: expect.objectContaining({ sourceId: "https://example.com", title: "Docs", url: "https://example.com" }) }), - expect.objectContaining({ name: "com.beeper.file", type: "CUSTOM", value: { id: "file_1", title: "report.txt" } }), - expect.objectContaining({ name: "com.beeper.data", type: "CUSTOM", value: { name: "openclaw.data", value: { status: "indexed" } } }), - expect.objectContaining({ snapshot: { phase: "retrieval" }, type: "STATE_SNAPSHOT" }), + expect.objectContaining({ kind: "tool_result", toolCallId: "codex-tool", toolName: "tool" }), + expect.objectContaining({ activityType: "tool.progress", content: expect.objectContaining({ label: "search", phase: "running", text: "Searching docs" }), kind: "activity" }), + expect.objectContaining({ kind: "tool_start", toolCallId: "tool-stream", toolName: "search" }), + expect.objectContaining({ input: { query: "openclaw" }, kind: "tool_start", metadata: { description: "Searches OpenClaw docs", displayName: "Search docs", source: "codex" }, providerExecuted: true, startedAtMs: 123, title: "Search docs", toolCallId: "nested-tool", toolName: "search" }), + expect.objectContaining({ completedAtMs: 456, kind: "tool_result", output: { items: [{ title: "OpenClaw", url: "https://example.com/openclaw" }] }, providerExecuted: true, toolCallId: "nested-tool", toolName: "search" }), + expect.objectContaining({ kind: "custom", name: "com.beeper.source", value: expect.objectContaining({ sourceId: "https://example.com/openclaw", title: "OpenClaw", url: "https://example.com/openclaw" }) }), + expect.objectContaining({ delta: "{\"cmd\":\"pwd\"}", kind: "tool_input", toolCallId: "delta-tool" }), + expect.objectContaining({ kind: "tool_result", output: "/tmp/project", toolCallId: "delta-tool", toolName: "bash" }), + expect.objectContaining({ + command: "npm test", + details: { + aggregated: "stdout\nstderr", + command: "npm test", + cwd: "/tmp/project", + durationMs: 25, + exitCode: 0, + status: "completed", + stderr: "stderr", + stdout: "stdout", + }, + exitCode: 0, + kind: "tool_result", + metadata: { displayName: "Run tests" }, + output: { + content: "wrapper content", + details: { + aggregated: "stdout\nstderr", + command: "npm test", + cwd: "/tmp/project", + durationMs: 25, + exitCode: 0, + status: "completed", + stderr: "stderr", + stdout: "stdout", + }, + }, + response: "tool wrapper response", + result: { + content: "wrapper content", + details: { + aggregated: "stdout\nstderr", + command: "npm test", + cwd: "/tmp/project", + durationMs: 25, + exitCode: 0, + status: "completed", + stderr: "stderr", + stdout: "stdout", + }, + }, + status: "completed", + title: "Run tests", + toolCallId: "bash-rich", + toolName: "bash", + }), + expect.objectContaining({ kind: "tool_result", output: "loading", preliminary: true, toolCallId: "tool-c", toolName: "search" }), + expect.objectContaining({ kind: "tool_result", output: "checking docs", preliminary: true, toolCallId: "plan", toolName: "plan" }), + expect.objectContaining({ kind: "tool_result", output: "stdout", preliminary: true, toolCallId: "cmd-1", toolName: "shell" }), + expect.objectContaining({ + input: { + command: "/bin/zsh -lc \"date '+%Y-%m-%d %H:%M:%S %Z'\"", + cwd: "/Users/batuhan/.openclaw/workspace", + }, + kind: "tool_result", + command: "/bin/zsh -lc \"date '+%Y-%m-%d %H:%M:%S %Z'\"", + cwd: "/Users/batuhan/.openclaw/workspace", + output: { status: "completed" }, + response: "2026-06-02 03:15:00 CEST", + status: "completed", + toolCallId: "cmd-date", + toolName: "bash", + }), + expect.objectContaining({ kind: "tool_result", output: "changed a.ts", toolCallId: "patch-1", toolName: "patch" }), + expect.objectContaining({ kind: "custom", name: "com.beeper.source", value: expect.objectContaining({ sourceId: "https://example.com", title: "Docs", url: "https://example.com" }) }), + expect.objectContaining({ kind: "custom", name: "com.beeper.file", value: { id: "file_1", title: "report.txt" } }), + expect.objectContaining({ kind: "custom", name: "com.beeper.data", value: { name: "openclaw.data", value: { status: "indexed" } } }), + expect.objectContaining({ kind: "state_snapshot", value: { phase: "retrieval" } }), ])); expect(parts).not.toEqual(expect.arrayContaining([ - expect.objectContaining({ toolCallId: "user-message", type: "TOOL_CALL_START" }), - expect.objectContaining({ toolCallId: "agent-message", type: "TOOL_CALL_START" }), + expect.objectContaining({ content: { state: "running", text: "Working..." }, kind: "activity" }), + expect.objectContaining({ toolCallId: "user-message", kind: "tool_start" }), + expect.objectContaining({ toolCallId: "agent-message", kind: "tool_start" }), ])); + const indexOf = (match: (part: Record) => boolean) => parts.findIndex((part) => match(part as Record)); + expect(aiRunStreams.start.mock.invocationCallOrder[0]).toBeLessThan(aiRunStreams.appendPart.mock.invocationCallOrder[0]); + expect(indexOf((part) => part.kind === "tool_start" && part.toolCallId === "delta-tool")).toBeLessThan(indexOf((part) => part.kind === "tool_input" && part.toolCallId === "delta-tool")); + expect(indexOf((part) => part.kind === "tool_input" && part.toolCallId === "delta-tool")).toBeLessThan(indexOf((part) => part.kind === "tool_result" && part.toolCallId === "delta-tool")); + expect(indexOf((part) => part.kind === "tool_result" && part.toolCallId === "nested-tool")).toBeLessThan(indexOf((part) => part.kind === "custom" && part.name === "com.beeper.source" && (part.value as { sourceId?: string })?.sourceId === "https://example.com/openclaw")); setBeeperChannelRuntimeForHost(hostRuntime, undefined); }); @@ -751,6 +894,8 @@ function createTestBeeperAIRunStreams() { return { appendEvent: vi.fn(async ({ event, runId }: { event: Record; runId: string }) => result(runId, [event])), + appendPart: vi.fn(async ({ runId }: { runId: string }) => + result(runId)), error: vi.fn(async ({ message, runId }: { message?: string; runId: string }) => result(runId, [{ message, runId, type: "RUN_ERROR" }])), finish: vi.fn(async ({ finishReason, runId }: { finishReason?: string; runId: string }) => @@ -762,3 +907,11 @@ function createTestBeeperAIRunStreams() { ])), }; } + +function startedAndAppendedParts(aiRunStreams: ReturnType) { + const startOptions = aiRunStreams.start.mock.calls[0]?.[0] as { initialParts?: Array> } | undefined; + return [ + ...(startOptions?.initialParts ?? []), + ...aiRunStreams.appendPart.mock.calls.map(([options]) => options), + ]; +} diff --git a/packages/openclaw/src/openclaw-runtime.ts b/packages/openclaw/src/openclaw-runtime.ts index 35f2bdc..bb02e45 100644 --- a/packages/openclaw/src/openclaw-runtime.ts +++ b/packages/openclaw/src/openclaw-runtime.ts @@ -7,20 +7,10 @@ import type { OpenClawApprovalResolvePayload } from "./approval"; import { getBeeperChannelRuntimeForHost } from "./beeper-channel-runtime"; import { AGUIEventType, - closeReasoningPart, - createStreamRunState, - mapOpenClawActivitySnapshot, + createApprovalRunState, mapOpenClawApprovalRequest, mapOpenClawApprovalResponse, mapOpenClawCustom, - mapOpenClawMessageDelta, - mapOpenClawRaw, - mapOpenClawStateDelta, - mapOpenClawStateSnapshot, - mapOpenClawToolEnd, - mapOpenClawToolInput, - mapOpenClawToolInputDelta, - mapOpenClawToolOutput, } from "./beeper-turn-events"; import type { AGUIEvent } from "./beeper-turn-events"; @@ -106,9 +96,19 @@ export interface OpenClawSessionCreateOptions { message?: string; model?: string; parentSessionKey?: string; + reasoningLevel?: string; task?: string; } +export interface OpenClawSessionPatchOptions { + agentId: string; + key: string; + label?: string; + reasoningLevel?: string; +} + +export const BEEPER_SESSION_REASONING_LEVEL = "on"; + export interface OpenClawSessionSendOptions { attachments?: unknown[]; idempotencyKey?: string; @@ -219,6 +219,7 @@ export interface OpenClawSessionHistoryRuntime { export interface OpenClawSessionTurnRuntime extends OpenClawSessionHistoryRuntime { createSession(options: OpenClawSessionCreateOptions): Promise; + patchSession(options: OpenClawSessionPatchOptions): Promise; resolveApproval(payload: OpenClawApprovalResolvePayload): Promise; sendMessage(options: OpenClawSessionSendOptions): Promise; } @@ -255,6 +256,13 @@ export class OpenClawPluginRuntimeAdapter { const record = recordValue(raw) ?? {}; const key = stringValue(record.key) ?? stringValue(record.sessionKey) ?? options.key; if (!key) throw new Error("OpenClaw sessions.create did not return a session key"); + if (options.reasoningLevel) { + await this.patchSession({ + agentId: options.agentId, + key, + reasoningLevel: options.reasoningLevel, + }); + } return stripUndefined({ agentId: stringValue(record.agentId) ?? options.agentId, key, @@ -264,6 +272,15 @@ export class OpenClawPluginRuntimeAdapter { }); } + async patchSession(options: OpenClawSessionPatchOptions): Promise { + await this.transport.request("sessions.patch", stripUndefined({ + agentId: options.agentId, + key: options.key, + label: options.label, + reasoningLevel: options.reasoningLevel, + })); + } + async listSessions(params: Record = {}): Promise { const raw = await this.transport.request("sessions.list", params); const sessions = arrayValue(recordValue(raw)?.sessions) ?? []; @@ -396,6 +413,8 @@ export class OpenClawHostRuntimeAdapter implements OpenClawRuntimeRequestSurface return await createSessionInPluginRuntime(this.#runtime, params) as T; case "sessions.list": return { sessions: sessionsFromPluginRuntime(this.#runtime, params) } as T; + case "sessions.patch": + return await patchSessionInPluginRuntime(this.#runtime, params) as T; default: throw new Error(`OpenClaw plugin runtime does not expose request/call for ${method}`); } @@ -410,6 +429,7 @@ function isDirectPluginRuntimeMethod(method: string): boolean { return method === "agents.list" || method === "chat.history" || method === "sessions.create" + || method === "sessions.patch" || method === "sessions.list"; } @@ -669,6 +689,7 @@ async function createSessionInPluginRuntime(runtime: OpenClawHostRuntime, params label: label ?? stringValue(entry.label), origin: recordValue(entry.origin) ?? { provider: "beeper", surface: "beeper", chatType: "direct" }, provider: stringValue(entry.provider) ?? "beeper", + reasoningLevel: stringValue(record.reasoningLevel) ?? stringValue(entry.reasoningLevel), sessionFile: stringValue(entry.sessionFile) ?? resolvePluginSessionFile(runtime, agentId, sessionId, entry), sessionId, updatedAt: typeof entry.updatedAt === "number" ? entry.updatedAt : now, @@ -677,6 +698,23 @@ async function createSessionInPluginRuntime(runtime: OpenClawHostRuntime, params return { agentId, key: sessionKey, label, sessionFile: next.sessionFile, sessionId }; } +async function patchSessionInPluginRuntime(runtime: OpenClawHostRuntime, params: unknown): Promise> { + const record = recordValue(params) ?? {}; + const sessionKey = stringValue(record.key) ?? stringValue(record.sessionKey); + if (!sessionKey) throw new Error("OpenClaw sessions.patch requires session key"); + const agentId = stringValue(record.agentId) ?? agentIdFromSessionKey(sessionKey) ?? "main"; + const resolved = resolvePluginSession(runtime, sessionKey, agentId); + const entry = resolved.entry ?? {}; + const next = stripUndefined({ + ...entry, + ...(record.label !== undefined ? { label: stringValue(record.label) } : {}), + ...(record.reasoningLevel !== undefined ? { reasoningLevel: stringValue(record.reasoningLevel) } : {}), + updatedAt: Date.now(), + }); + await runtime.agent?.session?.upsertSessionEntry?.({ agentId, entry: next, sessionKey }); + return { agentId, entry: next, key: sessionKey, ok: true }; +} + async function sendSessionInPluginRuntime( runtime: OpenClawHostRuntime, localEvents: LocalEventBus, @@ -872,12 +910,7 @@ 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, - sessionKey: params.sessionKey, - stream, - }); + let unsubscribeAgentEvents: (() => void) | undefined; let streamCallbackTail = Promise.resolve(); const enqueueStream = (operation: () => Promise) => { streamCallbackTail = streamCallbackTail @@ -889,17 +922,14 @@ async function runBeeperChannelTurnInPluginRuntime(params: { const scheduleStream = (operation: () => Promise) => { enqueueStream(operation); }; - let streamStartError: unknown; + unsubscribeAgentEvents = forwardAgentRuntimeStreamEvents({ + enqueue: enqueueStream, + runId: params.runId, + runtime: params.runtime, + sessionKey: params.sessionKey, + stream, + }); try { - params.localEvents.emit({ event: "stream.starting", 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 inbound.dispatchReply({ cfg: params.cfg, channel: "beeper", @@ -959,8 +989,6 @@ 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 } }); @@ -973,6 +1001,7 @@ async function runBeeperChannelTurnInPluginRuntime(params: { } function forwardAgentRuntimeStreamEvents(params: { + enqueue: (operation: () => Promise) => Promise; runId: string; runtime: OpenClawHostRuntime; sessionKey: string; @@ -1003,15 +1032,14 @@ function forwardAgentRuntimeStreamEvents(params: { normalizedStream: stream, }); if (!matched) return; - const track = (promise: Promise) => params.stream.trackExternal(promise); switch (stream) { case "assistant": - track(params.stream.textPayload(data, "partial")); + params.enqueue(() => params.stream.textPayload(data, "partial")); break; case "run.progress": case "tool.progress": case "tool_progress": - track(params.stream.activity({ + params.enqueue(() => params.stream.activity({ ...data, activityType: stream, text: toolProgressText(data), @@ -1022,21 +1050,21 @@ function forwardAgentRuntimeStreamEvents(params: { case "model": case "usage": case "context": - track(params.stream.lifecycleEvent(data)); + params.enqueue(() => params.stream.lifecycleEvent(data)); break; case "thinking": case "reasoning": - track(params.stream.reasoningPayload(data)); + params.enqueue(() => params.stream.reasoningPayload(data)); break; case "tool": if (stringValue(data.phase) === "start") { - track(params.stream.toolStart(data)); + params.enqueue(() => params.stream.toolStart(data)); } else if (isToolInputDeltaPhase(stringValue(data.phase)) || stringValue(data.inputTextDelta) || stringValue(data.argsDelta) || stringValue(data.argumentsDelta)) { - track(params.stream.toolInputDelta(data)); + params.enqueue(() => params.stream.toolInputDelta(data)); } else if (stringValue(data.phase) === "result" || isCompletePhase(stringValue(data.phase))) { - track(params.stream.toolResult(data)); + params.enqueue(() => params.stream.toolResult(data)); } else { - track(params.stream.itemEvent({ + params.enqueue(() => params.stream.itemEvent({ ...data, kind: "tool", progressText: stringValue(data.partialResult) ?? stringValue(data.output) ?? stringValue(data.result), @@ -1044,40 +1072,40 @@ function forwardAgentRuntimeStreamEvents(params: { } break; case "item": - track(params.stream.itemEvent(data)); + params.enqueue(() => params.stream.itemEvent(data)); break; case "plan": - track(params.stream.planUpdate(data)); + params.enqueue(() => params.stream.planUpdate(data)); break; case "approval": - track(params.stream.approvalEvent(data)); + params.enqueue(() => params.stream.approvalEvent(data)); break; case "command_output": case "command-output": - track(params.stream.commandOutput(data)); + params.enqueue(() => params.stream.commandOutput(data)); break; case "patch": - track(params.stream.patchSummary(data)); + params.enqueue(() => params.stream.patchSummary(data)); break; case "state": case "snapshot": - track(params.stream.stateSnapshot(data)); + params.enqueue(() => params.stream.stateSnapshot(data)); break; case "source": case "sources": - track(params.stream.customData("source", data)); + params.enqueue(() => params.stream.customData("source", data)); break; case "file": case "files": case "document": case "documents": - track(params.stream.customData(stream, data)); + params.enqueue(() => params.stream.customData(stream, data)); break; case "data": - track(params.stream.customData("data", data)); + params.enqueue(() => params.stream.customData("data", data)); break; case "raw": - track(params.stream.raw(stream, data)); + params.enqueue(() => params.stream.raw(stream, data)); break; default: break; @@ -1128,6 +1156,45 @@ function toolNameFromPayload(data: Record): string | undefined ?? stringValue(fn?.name); } +function toolTitleFromPayload(data: Record, fallback?: string): string | undefined { + const meta = recordValue(data.meta) ?? recordValue(data.metadata); + return stringValue(data.title) + ?? stringValue(data.label) + ?? commandFromPayload(data) + ?? stringValue(meta?.title) + ?? fallback; +} + +function toolDescriptionFromPayload(data: Record): string | undefined { + const meta = recordValue(data.meta) ?? recordValue(data.metadata); + return stringValue(data.description) + ?? stringValue(data.subtitle) + ?? stringValue(meta?.description) + ?? stringValue(meta?.subtitle); +} + +function toolMetadataFromPayload(data: Record, title?: string, description?: string): Record | undefined { + const base = recordValue(data.metadata) ?? recordValue(data.meta); + const providerDisplayName = stringValue(data.providerName) ?? stringValue(recordValue(data.provider)?.displayName); + const providerIconUrl = stringValue(data.providerIconUrl) ?? stringValue(recordValue(data.provider)?.iconUrl); + const provider = providerDisplayName || providerIconUrl + ? stripUndefined({ displayName: providerDisplayName, iconUrl: providerIconUrl }) + : undefined; + const display = stripUndefined({ + displayName: title, + description, + iconUrl: stringValue(data.iconUrl), + provider, + }); + const hasDisplay = Object.keys(display).length > 0; + if (!base && !hasDisplay) return undefined; + return stripUndefined({ + ...(base ?? {}), + ...display, + provider: provider ?? recordValue(base?.provider), + }); +} + function toolInputFromPayload(data: Record): unknown { const toolCall = toolCallRecordFromPayload(data); const fn = recordValue(toolCall?.function) ?? recordValue(data.function); @@ -1142,8 +1209,79 @@ function toolInputFromPayload(data: Record): unknown { return typeof value === "string" ? parseMaybeJSONValue(value) : value; } -function toolOutputFromPayload(data: Record, fallback?: unknown): unknown { - return data.output ?? data.result ?? data.content ?? data.text ?? data.partialResult ?? fallback; +function toolOutputFromPayload(data: Record, fallback?: unknown, toolName?: string): unknown { + void toolName; + const toolResult = recordValue(data.toolResult) ?? recordValue(data.tool_result); + const value = data.output + ?? data.result + ?? data.response + ?? data.content + ?? data.text + ?? data.partialResult + ?? toolResult?.output + ?? toolResult?.result + ?? toolResult?.response + ?? toolResult?.content + ?? toolResult?.text + ?? fallback; + return isStatusOnlyToolOutput(value) ? undefined : value; +} + +function isCommandToolName(toolName: string | undefined): boolean { + const normalized = toolName?.toLowerCase(); + return normalized === "bash" || normalized === "exec" || normalized === "shell" || normalized === "command"; +} + +function commandPartFields(data: Record): Record { + const result = recordValue(data.result); + const output = recordValue(data.output); + const response = recordValue(data.response); + const input = recordValue(data.input) ?? recordValue(data.args) ?? recordValue(data.arguments); + const details = recordValue(data.details) ?? recordValue(result?.details) ?? recordValue(output?.details) ?? recordValue(response?.details); + return stripUndefined({ + aggregated: stringValue(data.aggregated) ?? stringValue(result?.aggregated) ?? stringValue(output?.aggregated), + command: commandFromPayload(data), + cwd: stringValue(data.cwd) ?? stringValue(input?.cwd) ?? stringValue(result?.cwd) ?? stringValue(output?.cwd), + details: data.details ?? result?.details ?? output?.details ?? response?.details, + exitCode: numberValue(data.exitCode) ?? numberValue(data.exit_code) ?? numberValue(details?.exitCode) ?? numberValue(details?.exit_code) ?? numberValue(result?.exitCode) ?? numberValue(result?.exit_code) ?? numberValue(output?.exitCode) ?? numberValue(output?.exit_code), + response: data.response, + result: data.result, + status: stringValue(data.status) ?? stringValue(details?.status), + stderr: stringValue(data.stderr) ?? stringValue(result?.stderr) ?? stringValue(output?.stderr), + stdout: stringValue(data.stdout) ?? stringValue(result?.stdout) ?? stringValue(output?.stdout), + }); +} + +function commandFromPayload(data: Record): string | undefined { + const input = recordValue(data.input) ?? recordValue(data.args) ?? recordValue(data.arguments); + const toolCall = toolCallRecordFromPayload(data); + const toolInput = recordValue(toolCall?.input) ?? recordValue(toolCall?.args) ?? recordValue(toolCall?.arguments); + const details = recordValue(data.details) ?? recordValue(recordValue(data.result)?.details) ?? recordValue(recordValue(data.output)?.details); + return stringValue(data.command) + ?? stringValue(data.cmd) + ?? stringValue(input?.command) + ?? stringValue(input?.cmd) + ?? stringValue(toolInput?.command) + ?? stringValue(toolInput?.cmd) + ?? stringValue(details?.command); +} + +function isStatusOnlyToolOutput(value: unknown): boolean { + const record = recordValue(value); + if (!record) return false; + const keys = Object.keys(record); + return keys.length > 0 && keys.every((key) => + key === "action" || + key === "finalUrl" || + key === "final_url" || + key === "phase" || + key === "queries" || + key === "query" || + key === "queryUnavailable" || + key === "state" || + key === "status" || + key === "url" + ); } function toolProgressText(data: Record): string | undefined { @@ -1245,6 +1383,35 @@ function documentEventsFromPayload(payload: unknown): AGUIEvent[] { }); } +function answerURLSourceEvents(text: string, emitted: Set): AGUIEvent[] { + const matches = text.matchAll(/\bhttps?:\/\/[^\s<>)\]}"]+/giu); + const events: AGUIEvent[] = []; + for (const match of matches) { + const url = trimURLPunctuation(match[0]); + if (!url || emitted.has(url)) continue; + emitted.add(url); + events.push(...mapOpenClawCustom("com.beeper.source", stripUndefined({ + appearances: [{ kind: "answer" }], + sourceId: url, + title: hostnameTitle(url), + url, + }))); + } + return events; +} + +function trimURLPunctuation(url: string): string { + return url.replace(/[.,;:!?]+$/u, ""); +} + +function hostnameTitle(url: string): string | undefined { + try { + return new URL(url).hostname || undefined; + } catch { + return undefined; + } +} + function fileEventsFromPayload(payload: unknown): AGUIEvent[] { const records = artifactRecords(payload, ["files", "items", "results"]); return records.flatMap((record) => { @@ -1386,7 +1553,7 @@ function createBeeperReplyStreamEmitter(base: { sessionKey: base.sessionKey, ...(base.threadRoot ? { threadRoot: base.threadRoot } : {}), }); - const state = createStreamRunState(base.runId); + const approvalState = createApprovalRunState(); let hasPublished = false; let finalized = false; let lastVisibleText = ""; @@ -1395,8 +1562,10 @@ function createBeeperReplyStreamEmitter(base: { const externalTasks = new Set>(); const toolInputs = new Map(); const toolNames = new Map(); + const pendingToolCalls = new Set(); + const pendingToolWaiters = new Set<() => void>(); const startedToolCalls = new Set(); - const endedToolInputs = new Set(); + const emittedSourceUrls = new Set(); let latestUsage: unknown; const emit = (event: string, payload: Record) => { base.localEvents.emit({ @@ -1423,7 +1592,6 @@ function createBeeperReplyStreamEmitter(base: { }); await publisher.start(); hasPublished = true; - state.textStarted = true; channelRuntime.debug("openclaw_beeper_stream_started", { agentId: base.agentId, eventId: publisher.targetEventId, @@ -1439,11 +1607,22 @@ function createBeeperReplyStreamEmitter(base: { } await startPromise; }; + const markPublished = () => { + if (hasPublished) return; + hasPublished = true; + channelRuntime.debug("openclaw_beeper_stream_started", { + agentId: base.agentId, + eventId: publisher.targetEventId, + roomId: base.roomId, + runId: base.runId, + sessionId: base.sessionId, + sessionKey: base.sessionKey, + }); + }; const publish = async (parts: Iterable) => { if (finalized) return; const list = [...parts]; if (list.length === 0) return; - await ensureStarted(); channelRuntime.debug("openclaw_beeper_stream_publish", { count: list.length, firstType: stringValue(list[0]?.type), @@ -1451,8 +1630,43 @@ function createBeeperReplyStreamEmitter(base: { runId: base.runId, }); await publisher.publishMany(list); + markPublished(); + channelRuntime.recordOutboundActivity(); + }; + const publishPart = async (part: Parameters[0]) => { + if (finalized) return; + channelRuntime.debug("openclaw_beeper_stream_publish_part", { + kind: part.kind, + roomId: base.roomId, + runId: base.runId, + }); + await publisher.publishPart(part); + markPublished(); + channelRuntime.recordOutboundActivity(); + }; + const publishParts = async (parts: Array[0]>) => { + if (parts.length === 0 || finalized) return; + await publisher.publishParts(parts); + markPublished(); channelRuntime.recordOutboundActivity(); }; + const publishCustomEvents = async (events: AGUIEvent[]) => { + const customParts: Array[0]> = []; + const rawEvents: AGUIEvent[] = []; + for (const event of events) { + if (event.type === "CUSTOM") { + customParts.push(stripUndefined({ + kind: "custom", + name: stringValue(event.name) ?? "openclaw.data", + value: event.value, + })); + } else { + rawEvents.push(event); + } + } + await publishParts(customParts); + if (rawEvents.length > 0) await publish(rawEvents); + }; const trackExternal = (promise: Promise) => { let tracked: Promise; tracked = promise.catch((error) => { @@ -1479,6 +1693,7 @@ function createBeeperReplyStreamEmitter(base: { textLength: text?.length ?? 0, }); if (!text) return; + const sourceEvents = source === "final" ? answerURLSourceEvents(text, emittedSourceUrls) : []; if (isWorkingPlaceholder(text)) { channelRuntime.debug("openclaw_beeper_text_payload_suppressed", { reason: "working_placeholder", @@ -1486,19 +1701,14 @@ function createBeeperReplyStreamEmitter(base: { textLength: text.length, }); if (source !== "final") { - emit("activity.updated", { activityType: "status", source, text }); - await publish(mapOpenClawActivitySnapshot(state, { - activityType: "status", - content: { state: "running", text }, - replace: true, - })); + await ensureStarted(); } return; } const explicitDelta = stringValue(recordValue(payload)?.delta); const delta = explicitDelta ?? visibleTextDelta(lastVisibleText, text); lastVisibleText = nextVisibleText(lastVisibleText, text, delta); - if (!delta) { + if (!delta && sourceEvents.length === 0) { channelRuntime.debug("openclaw_beeper_text_payload_suppressed", { reason: "empty_delta", source, @@ -1512,7 +1722,8 @@ function createBeeperReplyStreamEmitter(base: { textLength: text.length, }); emit("assistant.delta", { delta, source, text }); - await publish(mapOpenClawMessageDelta(state, { kind: "text", value: delta })); + if (delta) await publishPart({ kind: "text", text: delta }); + if (sourceEvents.length > 0) await publishCustomEvents(sourceEvents); }; const reasoningPayload = async (payload: unknown) => { const text = replyPayloadText(payload); @@ -1522,7 +1733,7 @@ function createBeeperReplyStreamEmitter(base: { lastReasoningText = text; if (!delta) return; emit("thinking.delta", { delta, text }); - await publish(mapOpenClawMessageDelta(state, { kind: "thinking", value: delta })); + await publishPart({ kind: "reasoning", text: delta }); }; const toolIdFor = (payload: Record, fallback: string) => toolCallIdFromPayload(payload) ?? stringValue(payload.itemId) ?? stringValue(payload.approvalId) ?? fallback; @@ -1532,15 +1743,38 @@ 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); + const markToolPending = (toolCallId: string | undefined) => { + if (toolCallId) pendingToolCalls.add(toolCallId); + }; + const markToolComplete = (toolCallId: string | undefined) => { + if (!toolCallId) return; + pendingToolCalls.delete(toolCallId); + if (pendingToolCalls.size === 0) { + for (const resolve of pendingToolWaiters) resolve(); + pendingToolWaiters.clear(); + } }; - const endToolInput = (event: Parameters[0]) => { - if (endedToolInputs.has(event.toolCallId)) return []; - endedToolInputs.add(event.toolCallId); - return mapOpenClawToolEnd(event); + const waitForPendingTools = async (timeoutMs = 1200) => { + if (pendingToolCalls.size === 0) return; + channelRuntime.debug("openclaw_beeper_stream_waiting_for_tools", { + pendingToolCalls: [...pendingToolCalls], + roomId: base.roomId, + runId: base.runId, + timeoutMs, + }); + await Promise.race([ + new Promise((resolve) => { + pendingToolWaiters.add(resolve); + }), + new Promise((resolve) => setTimeout(resolve, timeoutMs)), + ]); + if (pendingToolCalls.size > 0) { + channelRuntime.debug("openclaw_beeper_stream_tools_still_pending", { + pendingToolCalls: [...pendingToolCalls], + roomId: base.roomId, + runId: base.runId, + }); + } }; return { start: ensureStarted, @@ -1551,7 +1785,7 @@ function createBeeperReplyStreamEmitter(base: { }, reasoningEnd: async () => { emit("thinking.end", {}); - await publish(closeReasoningPart(state)); + await publishPart({ kind: "reasoning_end" }); }, reasoningPayload, textPayload, @@ -1567,7 +1801,8 @@ function createBeeperReplyStreamEmitter(base: { if (!text) return; const activityType = stringValue(data.activityType) ?? stringValue(data.type) ?? "activity"; emit("activity.updated", { activityType, text }); - await publish(mapOpenClawActivitySnapshot(state, { + await publishPart({ + kind: "activity", activityType, content: stripUndefined({ label: stringValue(data.label) ?? stringValue(data.title) ?? stringValue(data.name), @@ -1576,13 +1811,16 @@ function createBeeperReplyStreamEmitter(base: { text, }), replace: true, - })); + }); }, toolStart: async (payload: unknown) => { const data = recordValue(payload) ?? {}; const toolName = toolNameFromPayload(data); const toolCallId = toolIdFor(data, fallbackToolIdForName(toolName, "tool")); const input = toolInputFromPayload(data); + const title = toolTitleFromPayload(data); + const description = toolDescriptionFromPayload(data); + const metadata = toolMetadataFromPayload(data, title, description); rememberTool(toolCallId, toolName, input); emit("tool.call.started", { input, @@ -1590,17 +1828,40 @@ function createBeeperReplyStreamEmitter(base: { toolCallId, toolName, }); - await publish(startToolCall(stripUndefined({ - approval: recordValue(data.approval), + if (recordValue(data.approval)) { + if (startedToolCalls.has(toolCallId)) return; + startedToolCalls.add(toolCallId); + markToolPending(toolCallId); + await publishPart(stripUndefined({ + approval: recordValue(data.approval), + description, + dynamic: booleanValue(data.dynamic), + index: numberValue(data.index), + input, + kind: "tool_start", + metadata, + providerExecuted: booleanValue(data.providerExecuted), + startedAtMs: numberValue(data.startedAt) ?? numberValue(data.startedAtMs), + title, + toolCallId, + toolName, + })); + return; + } + markToolPending(toolCallId); + await publishPart(stripUndefined({ + kind: "tool_start", + description, + dynamic: booleanValue(data.dynamic), index: numberValue(data.index), input, - metadata: recordValue(data.metadata), + metadata, providerExecuted: booleanValue(data.providerExecuted), startedAtMs: numberValue(data.startedAt) ?? numberValue(data.startedAtMs), - title: stringValue(data.title), + title, toolCallId, toolName, - }))); + })); }, toolInputDelta: async (payload: unknown) => { const data = recordValue(payload) ?? {}; @@ -1609,62 +1870,63 @@ function createBeeperReplyStreamEmitter(base: { const toolName = rememberedToolName(toolCallId, rawToolName); const input = toolInputFromPayload(data); const inputTextDelta = stringValue(data.inputTextDelta) ?? stringValue(data.argsDelta) ?? stringValue(data.argumentsDelta) ?? stringValue(data.delta); + const title = toolTitleFromPayload(data); + const description = toolDescriptionFromPayload(data); + const metadata = toolMetadataFromPayload(data, title, description); rememberTool(toolCallId, toolName, input); + markToolPending(toolCallId); emit("tool.call.input.delta", { inputTextDelta, toolCallId, toolName, }); - await publish([ - ...startToolCall(stripUndefined({ - metadata: recordValue(data.metadata), - providerExecuted: booleanValue(data.providerExecuted), - toolCallId, - toolName, - })), - ...mapOpenClawToolInputDelta(stripUndefined({ - input, - inputTextDelta, - toolCallId, - toolName, - })), - ]); + await publishPart(stripUndefined({ + description, + delta: inputTextDelta, + input, + kind: "tool_input", + metadata, + providerExecuted: booleanValue(data.providerExecuted), + startedAtMs: numberValue(data.startedAt) ?? numberValue(data.startedAtMs), + title, + toolCallId, + toolName, + })); }, toolResult: async (payload: unknown) => { const data = recordValue(payload) ?? {}; const toolCallId = toolIdFor(data, "tool_result"); const toolName = rememberedToolName(toolCallId, toolNameFromPayload(data)); const input = data.input ?? toolInputs.get(toolCallId); - const error = data.error ?? (booleanValue(data.isError) ? toolOutputFromPayload(data, payload) : undefined); - const output = toolOutputFromPayload(data, payload); + const title = toolTitleFromPayload(data); + const description = toolDescriptionFromPayload(data); + const metadata = toolMetadataFromPayload(data, title, description); + const commandTool = isCommandToolName(toolName); + const error = data.error ?? (booleanValue(data.isError) ? toolOutputFromPayload(data, payload, toolName) : undefined); + const output = commandTool ? firstNonUndefined(data.output, data.result, data.response, data.value) : toolOutputFromPayload(data, payload, toolName); + markToolComplete(toolCallId); emit("tool.call.completed", { output, toolCallId, toolName, }); - await publish([ - ...startToolCall(stripUndefined({ - input, - providerExecuted: booleanValue(data.providerExecuted), - toolCallId, - toolName, - })), - ...endToolInput(stripUndefined({ - error, - input, - toolCallId, - toolName, - })), - ...mapOpenClawToolOutput(stripUndefined({ + if (output !== undefined || error !== undefined) { + await publishPart(stripUndefined({ completedAtMs: numberValue(data.completedAt) ?? numberValue(data.completedAtMs), + description, error, + input, + kind: "tool_result", + metadata, output: error === undefined ? output : undefined, providerExecuted: booleanValue(data.providerExecuted), + ...(commandTool ? commandPartFields(data) : {}), + title, toolCallId, toolName, - })), - ...toolArtifactEvents(toolName, output), - ]); + })); + } + await publishCustomEvents(toolArtifactEvents(toolName, output)); }, itemEvent: async (payload: unknown) => { const data = recordValue(payload) ?? {}; @@ -1677,13 +1939,17 @@ function createBeeperReplyStreamEmitter(base: { const toolName = rememberedToolName(toolCallId, rawToolName ?? specificToolName(kind) ?? specificToolName(itemType) ?? "tool"); const input = toolInputFromPayload(data); const inputTextDelta = stringValue(data.inputTextDelta) ?? stringValue(data.argsDelta) ?? stringValue(data.argumentsDelta) ?? (isToolInputDeltaPhase(stringValue(data.phase)) ? stringValue(data.delta) : undefined); - const title = stringValue(data.title) ?? stringValue(data.progressText) ?? stringValue(data.summary) ?? rawToolName ?? itemType ?? kind; - const output = toolItemOutput(data); + const title = toolTitleFromPayload(data, stringValue(data.progressText) ?? stringValue(data.summary) ?? rawToolName ?? itemType ?? kind); + const description = toolDescriptionFromPayload(data); + const metadata = toolMetadataFromPayload(data, title, description); + const commandTool = isCommandToolName(toolName); + const output = commandTool ? firstNonUndefined(data.output, data.result, data.response, data.value, toolItemOutput(data)) : toolItemOutput(data); const phase = stringValue(data.phase); const status = stringValue(data.status); const preliminary = !isCompletePhase(phase) && !isCompletePhase(status); const error = data.error; rememberTool(toolCallId, toolName, input); + if (!preliminary) markToolComplete(toolCallId); emit("tool.call.updated", { output, phase, @@ -1691,37 +1957,40 @@ function createBeeperReplyStreamEmitter(base: { toolCallId, toolName, }); - await publish([ - ...startToolCall(stripUndefined({ + const parts: Array[0]> = []; + if (inputTextDelta) { + parts.push(stripUndefined({ + description, + delta: inputTextDelta, input, - metadata: recordValue(data.metadata), + kind: "tool_input", + metadata, providerExecuted: booleanValue(data.providerExecuted), + startedAtMs: numberValue(data.startedAt) ?? numberValue(data.startedAtMs), title, toolCallId, toolName, - })), - ...(inputTextDelta ? mapOpenClawToolInputDelta(stripUndefined({ - input, - inputTextDelta, - toolCallId, - toolName, - })) : []), - ...(!preliminary ? endToolInput(stripUndefined({ + })); + } + if (output !== undefined || error !== undefined || !inputTextDelta) { + parts.push(stripUndefined({ + description, error, input: input ?? toolInputs.get(toolCallId), - toolCallId, - toolName, - })) : []), - ...(output !== undefined ? mapOpenClawToolOutput(stripUndefined({ - error, + kind: "tool_result", + metadata, output: error === undefined ? output : undefined, preliminary, + completedAtMs: numberValue(data.completedAt) ?? numberValue(data.completedAtMs), providerExecuted: booleanValue(data.providerExecuted), + ...(commandTool ? commandPartFields(data) : {}), + title, toolCallId, toolName, - })) : []), - ...(!preliminary ? toolArtifactEvents(toolName, output) : []), - ]); + })); + } + await publishParts(parts); + if (!preliminary) await publishCustomEvents(toolArtifactEvents(toolName, output)); }, planUpdate: async (payload: unknown) => { const data = recordValue(payload) ?? {}; @@ -1735,31 +2004,37 @@ function createBeeperReplyStreamEmitter(base: { toolCallId: "plan", toolName: "plan", }); - await publish(mapOpenClawToolOutput({ + await publishPart({ + kind: "tool_result", output, preliminary, toolCallId: "plan", toolName: "plan", - })); + }); const steps = arrayValue(data.steps)?.filter((step): step is string => typeof step === "string"); if (steps?.length) { - await publish(mapOpenClawStateDelta([{ op: "add", path: "/plan", value: steps }])); + await publishPart({ delta: [{ op: "add", path: "/plan", value: steps }], kind: "state_delta" }); } }, stateSnapshot: async (payload: unknown) => { emit("state.snapshot", { snapshot: payload }); - await publish(mapOpenClawStateSnapshot(payload)); + await publishPart({ kind: "state_snapshot", value: payload }); }, customData: async (name: string, payload: unknown) => { emit(`${name}.event`, { value: payload }); - await publish(beeperCustomEvents(name, payload)); + await publishCustomEvents(beeperCustomEvents(name, payload)); }, lifecycleEvent: async (payload: unknown) => { const data = recordValue(payload) ?? {}; + const phase = stringValue(data.phase); const usage = usageFromPayload(data); if (usage !== undefined) latestUsage = usage; const model = lifecycleModelMetadata(data); const context = lifecycleContextMetadata(data); + if (phase) { + emit("lifecycle.phase", { phase }); + if (!isCompletePhase(phase) && phase !== "failed" && phase !== "error") await ensureStarted(); + } const events = [ ...(model ? mapOpenClawCustom("com.beeper.data", { name: "openclaw.model", value: model }) : []), ...(context ? mapOpenClawCustom("com.beeper.data", { name: "openclaw.context", value: context }) : []), @@ -1769,15 +2044,15 @@ function createBeeperReplyStreamEmitter(base: { emit("lifecycle.metadata", { context, model, - phase: stringValue(data.phase), + phase, usage, }); - await publish(events); + await publishCustomEvents(events); } }, raw: async (source: string, payload: unknown) => { emit("raw.event", { source, value: payload }); - await publish(mapOpenClawRaw(source, payload)); + await publishPart({ kind: "raw", source, value: payload }); }, approvalEvent: async (payload: unknown) => { const data = recordValue(payload) ?? {}; @@ -1794,7 +2069,7 @@ function createBeeperReplyStreamEmitter(base: { toolCallId, toolName, }); - await publish([mapOpenClawApprovalRequest(state, stripUndefined({ approvalId, message, toolCallId, toolName }))]); + await publish([mapOpenClawApprovalRequest(approvalState, stripUndefined({ approvalId, message, toolCallId, toolName }))]); return; } if (phase === "resolved" || phase === "complete" || stringValue(data.status)) { @@ -1833,29 +2108,38 @@ function createBeeperReplyStreamEmitter(base: { const status = stringValue(data.status); const complete = isCompletePhase(phase) || isCompletePhase(status); const toolCallId = toolIdFor(data, fallbackToolIdForName(toolName, "command")); - const output = data.output ?? data; - rememberTool(toolCallId, toolName); + const input = toolInputFromPayload(data); + const title = isCommandToolName(toolName) ? undefined : toolTitleFromPayload(data, toolName); + const description = toolDescriptionFromPayload(data); + const metadata = toolMetadataFromPayload(data, title, description); + const output = isCommandToolName(toolName) + ? firstNonUndefined(data.output, data.result, data.response, data.value, complete ? undefined : toolProgressText(data)) + : toolOutputFromPayload(data, complete ? undefined : toolProgressText(data), toolName); + rememberTool(toolCallId, toolName, input); + if (complete) markToolComplete(toolCallId); + else markToolPending(toolCallId); emit("tool.call.completed", { output, preliminary: !complete, toolCallId, toolName, }); - if (complete) { - await publish(endToolInput(stripUndefined({ - input: toolInputs.get(toolCallId), + if (output !== undefined || !complete) { + await publishPart(stripUndefined({ + description, + input: input ?? toolInputs.get(toolCallId), + kind: "tool_result", + metadata, + output, + preliminary: !complete, + ...(isCommandToolName(toolName) ? commandPartFields(data) : {}), + title, toolCallId, toolName, - }))); + })); } - await publish(mapOpenClawToolOutput({ - output, - preliminary: !complete, - toolCallId, - toolName, - })); if (complete) { - await publish(toolArtifactEvents(toolName, output)); + await publishCustomEvents(toolArtifactEvents(toolName, output)); } }, patchSummary: async (payload: unknown) => { @@ -1863,29 +2147,33 @@ function createBeeperReplyStreamEmitter(base: { const toolCallId = toolIdFor(data, "patch"); const toolName = rememberedToolName(toolCallId, stringValue(data.name) ?? "patch"); const output = data.summary ?? data; + const title = toolTitleFromPayload(data, "Patch"); + const description = toolDescriptionFromPayload(data); + const metadata = toolMetadataFromPayload(data, title, description); rememberTool(toolCallId, toolName); emit("tool.call.completed", { output, toolCallId, toolName, }); - await publish(endToolInput(stripUndefined({ + await publishPart(stripUndefined({ + description, input: toolInputs.get(toolCallId), + kind: "tool_result", + metadata, + output, + title, toolCallId, toolName, - }))); - await publish(mapOpenClawToolOutput(stripUndefined({ output, toolCallId, toolName }))); - await publish(toolArtifactEvents(toolName, output)); + })); + await publishCustomEvents(toolArtifactEvents(toolName, output)); }, finish: async (payload?: unknown) => { if (payload !== undefined) await textPayload(payload, "final"); await drainExternal(); + await waitForPendingTools(); + await drainExternal(); if (!hasPublished || finalized) return; - const preTerminal = closeReasoningPart(state); - if (preTerminal.length > 0) { - await publisher.publishMany(preTerminal); - channelRuntime.recordOutboundActivity(); - } finalized = true; channelRuntime.debug("openclaw_beeper_stream_finalizing", { roomId: base.roomId, @@ -1938,12 +2226,18 @@ function replyPayloadText(payload: unknown): string | undefined { const chunks: string[] = []; for (const part of parts) { const partRecord = recordValue(part); + if (partRecord && !isVisibleTextPart(stringValue(partRecord.type))) continue; const text = stringValue(partRecord?.text) ?? stringValue(partRecord?.content); if (text) chunks.push(text); } return chunks.length > 0 ? chunks.join("") : undefined; } +function isVisibleTextPart(type: string | undefined): boolean { + if (!type) return true; + return type === "text" || type === "output_text" || type === "assistant_text" || type === "markdown"; +} + function isWorkingPlaceholder(text: string): boolean { return /^working(?:\.{3}|\u2026)?$/iu.test(text.trim()); } @@ -2092,6 +2386,10 @@ function numberValue(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } +function firstNonUndefined(...values: unknown[]): unknown { + return values.find((value) => value !== undefined); +} + function intValue(value: unknown): number | undefined { const number = numberValue(value); return number === undefined ? undefined : Math.trunc(number); diff --git a/packages/openclaw/src/protocol-coverage.ts b/packages/openclaw/src/protocol-coverage.ts index a1fb6a8..f36e404 100644 --- a/packages/openclaw/src/protocol-coverage.ts +++ b/packages/openclaw/src/protocol-coverage.ts @@ -211,7 +211,7 @@ export const OPENCLAW_BRIDGE_COVERAGE = { stream: ["chat", "session.message", "session.operation", "session.tool"], }, methodAccess: { - pluginRuntimeAdapters: ["agents.list", "sessions.list", "sessions.create", "chat.history", "exec.approval.resolve", "plugin.approval.resolve"], + pluginRuntimeAdapters: ["agents.list", "sessions.list", "sessions.create", "sessions.patch", "chat.history", "exec.approval.resolve", "plugin.approval.resolve"], commonGatewayMethods: OPENCLAW_GATEWAY_COMMON_METHODS, beeperTurnDispatch: "runtime.channel.turn.runAssembled", }, diff --git a/packages/openclaw/src/registration.test.ts b/packages/openclaw/src/registration.test.ts index a87cca7..d31433e 100644 --- a/packages/openclaw/src/registration.test.ts +++ b/packages/openclaw/src/registration.test.ts @@ -5,11 +5,10 @@ import { openClawAgentGhostLocalpart, openClawAliasLocalpart, openClawRoomCreationPreset, - openClawUserGhostLocalpart, } from "./registration"; describe("OpenClaw appservice registration", () => { - it("reserves bridge bot, OpenClaw agent, and human ghost namespaces", () => { + it("reserves bridge bot and OpenClaw agent namespaces", () => { const config = createDefaultConfig({ appserviceId: "sh-openclaw-device", bridgeId: "sh-openclaw-device", @@ -28,7 +27,6 @@ describe("OpenClaw appservice registration", () => { }); expect(registration.namespaces.users).toEqual([ { 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([ @@ -39,7 +37,6 @@ 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("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 e780523..0a77663 100644 --- a/packages/openclaw/src/registration.ts +++ b/packages/openclaw/src/registration.ts @@ -12,7 +12,6 @@ export function createAppserviceRegistration( ): AppserviceRegistration { const domain = escapeRegex(config.homeserverDomain ?? matrixDomainFromHomeserver(config.homeserver)); const ghostPrefix = escapeRegex(openClawAgentGhostPrefix(config)); - const userPrefix = escapeRegex(openClawUserGhostPrefix(config)); const senderLocalpart = openClawSenderLocalpart(config); const sender = escapeRegex(senderLocalpart); return { @@ -24,7 +23,6 @@ export function createAppserviceRegistration( rooms: [], users: [ { exclusive: true, regex: `^@${ghostPrefix}.+:${domain}$` }, - { exclusive: true, regex: `^@${userPrefix}.+:${domain}$` }, { exclusive: true, regex: `^@${sender}:${domain}$` }, ], }, @@ -48,10 +46,6 @@ export function openClawAgentGhostLocalpart(config: OpenClawBridgeConfig, agentI return `${openClawAgentGhostPrefix(config)}${encodeLocalpartSegment(agentId)}`; } -export function openClawUserGhostLocalpart(config: OpenClawBridgeConfig, userId: string): string { - return `${openClawUserGhostPrefix(config)}${encodeLocalpartSegment(userId)}`; -} - export function openClawAliasLocalpart(config: OpenClawBridgeConfig, roomKey: string): string { return `${config.appserviceId}_${encodeLocalpartSegment(roomKey)}`; } @@ -73,10 +67,6 @@ 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`; } diff --git a/packages/openclaw/src/registry.test.ts b/packages/openclaw/src/registry.test.ts index 064bf99..cb359a4 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, user contacts, session bindings, and dedupe keys", async () => { + 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); @@ -15,12 +15,6 @@ describe("OpenClawBridgeRegistry", () => { displayName: "Codex", ghostUserId: "@sh-openclaw_agent_codex:example.com", }); - registry.upsertUser({ - displayName: "Alice", - ghostUserId: "@sh-openclaw_user_alice:example.com", - source: "whatsapp", - userId: "alice", - }); registry.upsertBinding({ agentId: "codex", createdAt: 1, @@ -38,7 +32,6 @@ describe("OpenClawBridgeRegistry", () => { const loaded = new OpenClawBridgeRegistry(path); await loaded.load(); expect(loaded.getAgent("codex")?.displayName).toBe("Codex"); - 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/registry.ts b/packages/openclaw/src/registry.ts index 278c2ad..9189b35 100644 --- a/packages/openclaw/src/registry.ts +++ b/packages/openclaw/src/registry.ts @@ -1,14 +1,15 @@ +import { randomUUID } from "node:crypto"; import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { defaultDataDir } from "./config"; -import type { OpenClawAgentContact, OpenClawBridgeRegistryData, OpenClawSessionBinding, OpenClawUserContact } from "./types"; +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, users: [] }; + return { agents: [], bindings: [], dedupe: {}, schemaVersion: 1 }; } export class OpenClawBridgeRegistry { @@ -34,7 +35,7 @@ export class OpenClawBridgeRegistry { async save(): Promise { await mkdir(dirname(this.path), { recursive: true }); - const tmp = `${this.path}.${process.pid}.tmp`; + const tmp = `${this.path}.${process.pid}.${randomUUID()}.tmp`; await writeFile(tmp, `${JSON.stringify(this.#data, null, 2)}\n`, { mode: 0o600 }); await rename(tmp, this.path); } @@ -53,16 +54,6 @@ 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); } @@ -122,6 +113,5 @@ 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 5390e66..32eff8e 100644 --- a/packages/openclaw/src/rooms.test.ts +++ b/packages/openclaw/src/rooms.test.ts @@ -8,8 +8,6 @@ import { createSessionRoom, matrixDomainFromHomeserver, serviceBotUserId, - userContactFromOpenClawSession, - userGhostUserId, } from "./rooms"; describe("OpenClaw room and contact helpers", () => { @@ -17,7 +15,6 @@ 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("@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", @@ -27,20 +24,11 @@ describe("OpenClaw room and contact helpers", () => { })).toEqual({ agentId: "codex", avatarMxc: "mxc://example/avatar", + avatarUrl: "mxc://example/avatar", description: "Local code agent", displayName: "Codex", ghostUserId: "@sh-openclaw_agent_codex:matrix.example.com", }); - expect(userContactFromOpenClawSession(config, { - displayName: "Alice", - lastProvider: "whatsapp", - lastTo: "whatsapp:+1 555", - })).toEqual({ - displayName: "Alice", - ghostUserId: "@sh-openclaw_user_whatsapp_1_555:matrix.example.com", - source: "whatsapp", - userId: "whatsapp:+1 555", - }); }); it("creates non-federated appservice rooms for OpenClaw sessions", async () => { @@ -49,7 +37,6 @@ describe("OpenClaw room and contact helpers", () => { 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", }); @@ -69,7 +56,7 @@ describe("OpenClaw room and contact helpers", () => { expect(createRoom).toHaveBeenCalledWith({ creation_content: { "m.federate": false }, - invite: ["@owner:example.com"], + invite: [], isDirect: true, name: "Fix tests", preset: "private_chat", diff --git a/packages/openclaw/src/rooms.ts b/packages/openclaw/src/rooms.ts index afb3f56..9fac0fe 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, openClawSenderLocalpart, openClawUserGhostLocalpart } from "./registration"; +import type { OpenClawAgentContact, OpenClawBridgeConfig, OpenClawSessionBinding } from "./types"; +import { openClawAgentGhostLocalpart, openClawRoomCreationPreset, openClawSenderLocalpart } from "./registration"; export function bindingIdForRoom(roomId: string): string { return Buffer.from(roomId).toString("base64url"); @@ -23,10 +23,6 @@ export function agentGhostUserId(config: OpenClawBridgeConfig, agentId: string, return `@${openClawAgentGhostLocalpart(config, agentId)}:${domain}`; } -export function userGhostUserId(config: OpenClawBridgeConfig, userId: string, domain = matrixDomainFromConfig(config)): string { - return `@${openClawUserGhostLocalpart(config, userId)}:${domain}`; -} - export function serviceBotUserId(config: OpenClawBridgeConfig, domain = matrixDomainFromConfig(config)): string { return `@${openClawSenderLocalpart(config)}:${domain}`; } @@ -43,37 +39,16 @@ export function agentContactFromOpenClawAgent( displayName, ghostUserId: agentGhostUserId(config, agentId, domain), }; - const avatarMxc = stringValue(agent.avatarMxc) ?? stringValue(agent.avatar_url) ?? stringValue(agent.avatarUrl); + const rawAvatarUrl = stringValue(agent.avatarUrl) ?? stringValue(agent.avatar_url) ?? stringValue(agent.avatar); + const avatarMxc = stringValue(agent.avatarMxc) ?? mxcAvatarURL(rawAvatarUrl); const description = stringValue(agent.description); if (avatarMxc) contact.avatarMxc = avatarMxc; + const avatarUrl = rawAvatarUrl ?? avatarMxc; + if (avatarUrl) contact.avatarUrl = avatarUrl; if (description) contact.description = description; return contact; } -export function userContactFromOpenClawSession( - config: OpenClawBridgeConfig, - session: { - displayName?: string; - lastAccountId?: string; - lastProvider?: string; - lastTo?: string; - origin?: Record; - provider?: string; - }, - domain = matrixDomainFromConfig(config) -): 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, @@ -96,7 +71,7 @@ export async function createSessionRoom( ].filter(Boolean).join("\n"); const result = await client.appservice.createRoom({ ...openClawRoomCreationPreset(config), - invite: config.allowedUserIds ?? [], + invite: [], isDirect: true, name: roomName, topic, @@ -123,3 +98,7 @@ export async function createSessionRoom( function stringValue(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } + +function mxcAvatarURL(value: string | undefined): string | undefined { + return value?.startsWith("mxc://") ? value : undefined; +} diff --git a/packages/openclaw/src/setup.test.ts b/packages/openclaw/src/setup.test.ts index df8bd02..027ffed 100644 --- a/packages/openclaw/src/setup.test.ts +++ b/packages/openclaw/src/setup.test.ts @@ -1,3 +1,12 @@ +import { + installChannelActionsContractSuite, + installChannelPluginContractSuite, + installChannelSetupContractSuite, + installChannelStatusContractSuite, +} from "openclaw/plugin-sdk/channel-test-helpers"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import extension from "./openclaw-extension"; import setupEntry from "./setup-entry"; @@ -27,6 +36,84 @@ const appserviceMocks = vi.hoisted(() => ({ vi.mock("./appservice", () => appserviceMocks); +describe("OpenClaw Beeper official channel contracts", () => { + installChannelPluginContractSuite({ plugin: beeperChannelPlugin }); + + installChannelActionsContractSuite({ + plugin: beeperChannelPlugin, + cases: [{ + name: "default Beeper message actions", + cfg: {}, + expectedActions: ["send", "react", "read"], + }], + }); + + installChannelSetupContractSuite({ + plugin: beeperChannelPlugin, + cases: [{ + name: "non-login setup environment patch", + cfg: {}, + input: { + beeperEnv: "staging", + }, + assertPatchedConfig: (cfg) => { + expect(getBeeperChannelSettings(cfg)).toMatchObject({ + beeperEnv: "staging", + enabled: true, + }); + }, + assertResolvedAccount: (account) => { + expect(account).toMatchObject({ + accountId: "default", + configured: false, + }); + }, + }], + }); + + installChannelStatusContractSuite({ + plugin: beeperChannelPlugin, + cases: [ + { + name: "configured account", + cfg: applyBeeperChannelSettings({}, { + asToken: "as", + enabled: true, + homeserver: "https://matrix.example", + hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + }), + expectedState: "configured", + runtime: { accountId: "default", configured: true, enabled: true, running: true }, + resolveStateInput: { configured: true, enabled: true }, + assertSnapshot: (snapshot) => { + expect(snapshot).toMatchObject({ + accountId: "default", + configured: true, + enabled: true, + name: "Beeper", + running: true, + }); + }, + assertSummary: (summary) => { + expect(summary).toMatchObject({ + configured: true, + enabled: true, + running: true, + }); + }, + }, + { + name: "disabled account", + cfg: applyBeeperChannelSettings({}, { enabled: false }), + expectedState: "disabled", + resolveStateInput: { configured: false, enabled: false }, + }, + ], + }); +}); + describe("OpenClaw Beeper setup surface", () => { beforeEach(() => { appserviceMocks.startOpenClawBeeperBridge.mockReset(); @@ -55,17 +142,7 @@ describe("OpenClaw Beeper setup surface", () => { startAccount: expect.any(Function), stopAccount: expect.any(Function), }, - uiHints: { - asToken: { - sensitive: true, - }, - bridgeManagerToken: { - sensitive: true, - }, - hsToken: { - sensitive: true, - }, - }, + uiHints: {}, }); expect(beeperChannelPlugin.setup).toBe(beeperSetupAdapter); expect(beeperChannelPlugin.setupWizard).toBe(beeperSetupWizard); @@ -261,12 +338,10 @@ describe("OpenClaw Beeper setup surface", () => { }; const cfg = applyBeeperChannelSettings({}, { asToken: "as", - backfillLimit: 25, dataDir: "/tmp/openclaw-beeper", enabled: true, homeserver: "https://matrix.example", hsToken: "hs", - importSources: ["dashboard", "tui"], matrixDeviceId: "DEV", matrixUserId: "@alice:example", }); @@ -280,11 +355,8 @@ describe("OpenClaw Beeper setup surface", () => { } as never); await vi.waitFor(() => expect(appserviceMocks.startOpenClawBeeperBridge).toHaveBeenCalledOnce()); expect(appserviceMocks.startOpenClawBeeperBridge).toHaveBeenCalledWith(expect.objectContaining({ - backfill: true, - backfillLimit: 25, config: expect.objectContaining({ dataDir: "/tmp/openclaw-beeper", - importSources: ["dashboard", "tui"], }), dataDir: "/tmp/openclaw-beeper", runtime: expect.objectContaining({ @@ -301,6 +373,35 @@ describe("OpenClaw Beeper setup surface", () => { expect(statuses).toContainEqual(expect.objectContaining({ running: false })); }); + it("shares one running Beeper bridge startup per account", async () => { + const stop = vi.fn(async () => undefined); + appserviceMocks.startOpenClawBeeperBridge.mockResolvedValueOnce({ stop }); + const abort = new AbortController(); + const cfg = applyBeeperChannelSettings({}, { + asToken: "as", + dataDir: "/tmp/openclaw-beeper", + enabled: true, + homeserver: "https://matrix.example", + hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + }); + const ctx = { + abortSignal: abort.signal, + accountId: "default", + cfg, + } as never; + + const first = startBeeperGatewayAccount(ctx); + const second = startBeeperGatewayAccount(ctx); + await vi.waitFor(() => expect(appserviceMocks.startOpenClawBeeperBridge).toHaveBeenCalledOnce()); + abort.abort(); + await Promise.all([first, second]); + + expect(appserviceMocks.startOpenClawBeeperBridge).toHaveBeenCalledOnce(); + expect(stop).toHaveBeenCalledOnce(); + }); + it("rejects gateway startup until Beeper setup has complete credentials", async () => { await expect(startBeeperGatewayAccount({ abortSignal: new AbortController().signal, @@ -322,24 +423,12 @@ describe("OpenClaw Beeper setup surface", () => { accountId: "default", cfg: {}, input: { - allowedRoomIds: "!one:example,!two:example,!one:example", - allowedUserIds: ["@alice:example", "@bob:example", "@alice:example"], - approvalBehavior: "native", - backfillLimit: "42", beeperEnv: "staging", - contactVisibility: "agents-and-users", - importSources: "dashboard,tui", }, }); expect(getBeeperChannelSettings(cfg)).toEqual({ - allowedRoomIds: ["!one:example", "!two:example"], - allowedUserIds: ["@alice:example", "@bob:example"], - approvalBehavior: "native", - backfillLimit: 42, beeperEnv: "staging", - contactVisibility: "agents-and-users", enabled: true, - importSources: ["dashboard", "tui"], }); expect(isBeeperChannelConfigured(cfg)).toBe(false); expect(cfg.plugins?.entries?.beeper).toBeUndefined(); @@ -570,15 +659,13 @@ describe("OpenClaw Beeper setup surface", () => { }); }); - it("defaults new setup to no historical imports", async () => { + it("defaults new setup to owned chats only", async () => { expect(defaultBeeperChannelSettings()).toMatchObject({ enabled: true, - importSources: [], }); const configured = await beeperSetupWizard.configure({ cfg: {} }); expect(getBeeperChannelSettings(configured.cfg)).toMatchObject({ enabled: true, - importSources: [], }); }); @@ -586,10 +673,8 @@ describe("OpenClaw Beeper setup surface", () => { expect(validateBeeperSetupInput({ email: "not-email" })).toContain("valid email"); expect(validateBeeperSetupInput({ username: "alice" })).toContain("requires both"); expect(validateBeeperSetupInput({ email: "alice@example.com", username: "alice", password: "secret" })).toContain("only one"); - expect(validateBeeperSetupInput({ backfillLimit: "-1" })).toContain("non-negative"); const cfg = applyBeeperChannelSettings({}, { enabled: true, - importSources: ["dashboard"], }); await expect(beeperSetupWizard.getStatus({ cfg })).resolves.toMatchObject({ channel: "beeper", @@ -601,7 +686,6 @@ describe("OpenClaw Beeper setup surface", () => { it("reports lightweight channel status without starting bridge runtime", () => { const account = beeperChannelConfig.resolveAccount(applyBeeperChannelSettings({}, { enabled: true, - importSources: ["dashboard", "tui"], })); const snapshot = beeperStatusAdapter.buildAccountSnapshot({ account }); @@ -609,17 +693,11 @@ describe("OpenClaw Beeper setup surface", () => { accountId: "default", configured: false, enabled: true, - extra: { - importSources: ["dashboard", "tui"], - mode: "self-hosted-appservice", - registrationUrl: "websocket", - }, running: false, }); expect(beeperStatusAdapter.buildChannelSummary({ snapshot })).toMatchObject({ configured: false, enabled: true, - mode: "self-hosted-appservice", running: false, }); expect(beeperStatusAdapter.resolveAccountState({ configured: false, enabled: true })).toBe("not configured"); @@ -723,13 +801,14 @@ describe("OpenClaw Beeper setup surface", () => { params: { message: "hello from tool" }, sessionKey: "session_1", }); - expect(client.beeper.aiRunStreams.appendEvent).toHaveBeenCalledWith(expect.objectContaining({ - event: expect.objectContaining({ + expect(client.beeper.aiRunStreams.start).toHaveBeenCalledWith(expect.objectContaining({ + initialEvents: [expect.objectContaining({ delta: "hello from tool", type: "TEXT_MESSAGE_CONTENT", - }), + })], runId: "run_1", })); + expect(client.beeper.aiRunStreams.appendEvent).not.toHaveBeenCalled(); await beeperChannelPlugin.actions.handleAction({ action: "react", @@ -773,11 +852,47 @@ describe("OpenClaw Beeper setup surface", () => { }]); }); + it("lists saved bridge registry agents as directory contacts without live runtime", async () => { + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "pickle-openclaw-directory-")); + await fs.writeFile(path.join(dataDir, "registry.json"), JSON.stringify({ + agents: [{ + agentId: "codex", + avatarMxc: "mxc://avatar", + description: "Helpful coding agent", + displayName: "Codex", + ghostUserId: "@codex:example", + }], + bindings: [], + dedupe: {}, + schemaVersion: 1, + })); + setBeeperOpenClawPluginRuntime(undefined); + + await expect(beeperChannelPlugin.directory.listPeers({ + cfg: { channels: { beeper: { dataDir } } } as OpenClawSetupConfig, + query: "helpful", + })).resolves.toEqual([{ + avatarUrl: "mxc://avatar", + description: "Helpful coding agent", + handle: "codex", + id: "codex", + kind: "user", + name: "Codex", + raw: { + agentId: "codex", + avatarMxc: "mxc://avatar", + description: "Helpful coding agent", + displayName: "Codex", + ghostUserId: "@codex:example", + }, + }]); + }); + it("reads plugin-entry channel config with channels.beeper taking precedence", () => { expect(getBeeperChannelSettings({ channels: { beeper: { - importSources: ["dashboard"], + beeperEnv: "staging", }, }, plugins: { @@ -790,7 +905,7 @@ describe("OpenClaw Beeper setup surface", () => { }, }, })).toEqual({ - importSources: ["dashboard"], + beeperEnv: "staging", }); expect(createConfigFromOpenClawSetup({ plugins: { entries: { beeper: { config: { enabled: true } } } } })).toMatchObject({ diff --git a/packages/openclaw/src/setup.ts b/packages/openclaw/src/setup.ts index bbfec00..f9ef219 100644 --- a/packages/openclaw/src/setup.ts +++ b/packages/openclaw/src/setup.ts @@ -9,48 +9,30 @@ import type { setupOpenClawBeeperBridge, SetupOpenClawBeeperBridgeOptions } from import { createBeeperApprovalNotice } from "./approval"; import { requireBeeperChannelRuntimeForHost, setBeeperChannelRuntimeForHost } from "./beeper-channel-runtime"; import type { OpenClawHostRuntime } from "./openclaw-runtime"; +import { OpenClawBridgeRegistry, defaultRegistryPath } from "./registry"; export type OpenClawSetupConfig = OpenClawConfig; -export type BeeperImportSource = "dashboard" | "tui" | "channels" | "archived"; - export interface BeeperChannelSettings { - allowedRoomIds?: string[]; - allowedUserIds?: string[]; appserviceId?: string; asToken?: string; - approvalBehavior?: "native" | "disabled"; - backfillLimit?: number; beeperEnv?: "production" | "staging" | "dev" | "local"; - bridgeManagerToken?: string; bridgeId?: string; - contactVisibility?: "agents" | "agents-and-users" | "none"; dataDir?: string; enabled?: boolean; homeserver?: string; hsToken?: string; - importSources?: BeeperImportSource[]; matrixDeviceId?: string; matrixUserId?: string; homeserverDomain?: string; } export interface BeeperSetupInput { - allowedRoomIds?: string[] | string; - allowedUserIds?: string[] | string; - approvalBehavior?: string; - backfillLimit?: number | string; beeperEnv?: string; code?: string; - contactVisibility?: string; dataDir?: string; email?: string; - getOnly?: boolean | string; - importSources?: string[] | string; password?: string; - postState?: boolean | string; - push?: boolean | string; - selfHosted?: boolean | string; username?: string; } @@ -62,6 +44,10 @@ type StartedBeeperBridge = { stop?: () => Promise | void; }; +type StartingBeeperBridge = { + promise: Promise; +}; + type BeeperGatewayContext = { abortSignal: AbortSignal; accountId: string; @@ -115,23 +101,7 @@ function requireBeeperChannelRuntime() { export const BeeperChannelConfigSchema = beeperChannelConfigSchema; -export const BeeperChannelUiHints = { - bridgeManagerToken: { - help: "Optional Beeper bridge-manager token used to register the self-hosted bridge.", - label: "Bridge Manager 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 BeeperChannelUiHints = {} as const; export const beeperMessageAdapter = { id: BEEPER_CHANNEL_ID, @@ -578,7 +548,6 @@ export const beeperSetupWizard = { statusLines: [ "Runtime: OpenClaw plugin", "Registration transport: websocket", - `Import sources: ${(settings.importSources ?? []).join(", ") || "none"}`, ], selectionHint: configured ? "Beeper bridge configured" : "Beeper login and bridge registration required", quickstartScore: configured ? 100 : 20, @@ -634,39 +603,16 @@ export const beeperSetupWizard = { }); } const beeperEnv = current.beeperEnv ?? "production"; - const importSources: BeeperImportSource[] = []; - const backfillLimit = 0; - 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 approvalBehavior = await ctx.prompter.select({ - message: "Approval behavior", - initialValue: current.approvalBehavior ?? "native", - options: [ - { value: "native", label: "Native" }, - { value: "disabled", label: "Disabled" }, - ], - }); const progress = ctx.prompter.progress?.("Setting up Beeper bridge"); progress?.update("Logging in and registering appservice"); try { const input: BeeperSetupInput = { - importSources, - backfillLimit, ...(code ? { code } : {}), ...(email ? { email } : {}), ...(password ? { password } : {}), ...(username ? { username } : {}), }; - if (approvalBehavior !== undefined) input.approvalBehavior = approvalBehavior; if (beeperEnv !== undefined) input.beeperEnv = beeperEnv; - if (contactVisibility !== undefined) input.contactVisibility = contactVisibility; const setupParams: Parameters[0] = { cfg: ctx.cfg, input, @@ -699,9 +645,6 @@ export const beeperChannelConfig = { accountId: "default", name: "Beeper", configured: account.configured === true, - extra: { - registrationUrl: "websocket", - }, }), }; @@ -710,16 +653,12 @@ export const beeperStatusAdapter = { 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, homeserver: recordValue(snapshot.extra)?.homeserver, - mode: "self-hosted-appservice", running: snapshot.running === true, }), buildAccountSnapshot: ({ account, runtime }: { account: { accountId?: string; configured?: boolean; settings?: BeeperChannelSettings }; runtime?: Record }) => { @@ -729,13 +668,8 @@ export const beeperStatusAdapter = { configured: account.configured === true, enabled: settings.enabled !== false, extra: { - approvalBehavior: settings.approvalBehavior ?? "native", beeperEnv: settings.beeperEnv ?? "production", - contactVisibility: settings.contactVisibility ?? "agents", homeserver: settings.homeserver, - importSources: settings.importSources ?? [], - mode: "self-hosted-appservice", - registrationUrl: "websocket", }, name: "Beeper", running: runtime?.running === true, @@ -757,7 +691,7 @@ export const beeperStatusAdapter = { })), }; -const startedBridges = new Map(); +const startedBridges = new Map(); export async function applyBeeperSetupConfig(params: { cfg: OpenClawSetupConfig; @@ -1002,21 +936,28 @@ function listConfiguredAgentDirectoryEntries( }).slice(0, limit ?? 100); } -function listLiveOrConfiguredAgentDirectoryEntries( +async function listSavedAgentDirectoryEntries( cfg: OpenClawSetupConfig, query?: string | null, limit?: number | null, +): Promise> { + try { + const config = createConfigFromOpenClawSetup(cfg); + const registry = new OpenClawBridgeRegistry(defaultRegistryPath(config.dataDir)); + await registry.load(); + return listAgentContactsDirectoryEntries(registry.data.agents, query, limit); + } catch { + return []; + } +} + +function listAgentContactsDirectoryEntries( + agents: readonly { agentId?: string; avatarMxc?: string; description?: string; displayName?: string; ghostUserId?: string }[], + query?: string | null, + limit?: number | null, ): Array<{ kind: "user"; id: string; name?: string; handle?: string; avatarUrl?: string; description?: string; raw?: unknown }> { - const 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) => { + return agents.flatMap((agent) => { const agentRecord = recordValue(agent); const id = agent.agentId ?? stringValue(agentRecord?.id); if (!id) return []; @@ -1038,11 +979,53 @@ function listLiveOrConfiguredAgentDirectoryEntries( }).slice(0, limit ?? 100); } +async function listLiveOrConfiguredAgentDirectoryEntries( + cfg: OpenClawSetupConfig, + query?: string | null, + limit?: number | null, +): Promise> { + const runtimeAgents = (() => { + try { + return requireBeeperChannelRuntime().listAgents(); + } catch { + return []; + } + })(); + if (runtimeAgents.length > 0) return listAgentContactsDirectoryEntries(runtimeAgents, query, limit); + const savedAgents = await listSavedAgentDirectoryEntries(cfg, query, limit); + if (savedAgents.length > 0) return savedAgents; + return listConfiguredAgentDirectoryEntries(cfg, query, limit); +} + function stringValue(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } export async function startBeeperGatewayAccount(ctx: BeeperGatewayContext | ChannelGatewayContext<{ accountId: string; configured: boolean; settings: BeeperChannelSettings }>): Promise { + const key = gatewayAccountKey(ctx.accountId); + const existing = startedBridges.get(key); + if (existing) { + if ("promise" in existing) return existing.promise; + ctx.setStatus?.({ + accountId: ctx.accountId, + configured: true, + enabled: true, + running: true, + }); + await waitForAbort(ctx.abortSignal); + return; + } + const promise = startBeeperGatewayAccountOnce(ctx, key); + startedBridges.set(key, { promise }); + try { + await promise; + } finally { + const current = startedBridges.get(key); + if (current && "promise" in current && current.promise === promise) startedBridges.delete(key); + } +} + +async function startBeeperGatewayAccountOnce(ctx: BeeperGatewayContext | ChannelGatewayContext<{ accountId: string; configured: boolean; settings: BeeperChannelSettings }>, key: string): Promise { try { ctx.log?.info?.("Beeper bridge startup beginning."); const settings = getBeeperChannelSettings(ctx.cfg); @@ -1068,8 +1051,6 @@ export async function startBeeperGatewayAccount(ctx: BeeperGatewayContext | Chan }); }; const bridge = await startOpenClawBeeperBridge({ - backfill: Boolean(config.importSources?.length), - ...(config.backfillLimit !== undefined ? { backfillLimit: config.backfillLimit } : {}), config, dataDir: config.dataDir, log: bridgeLoggerFromChannelContext(ctx), @@ -1079,7 +1060,6 @@ export async function startBeeperGatewayAccount(ctx: BeeperGatewayContext | Chan if (hostRuntime && openClawPluginRuntime && hostRuntime !== openClawPluginRuntime) { setBeeperChannelRuntimeForHost(openClawPluginRuntime, requireBeeperChannelRuntimeForHost(hostRuntime)); } - const key = gatewayAccountKey(ctx.accountId); startedBridges.set(key, bridge as StartedBeeperBridge); ctx.setStatus?.({ accountId: ctx.accountId, @@ -1182,7 +1162,7 @@ function hasOpenClawChannelRuntime(value: unknown): value is NonNullable): Promise { const bridge = startedBridges.get(gatewayAccountKey(ctx.accountId)); - if (!bridge) return; + if (!bridge || "promise" in bridge) return; startedBridges.delete(gatewayAccountKey(ctx.accountId)); await bridge.stop?.(); ctx.setStatus?.({ @@ -1228,13 +1208,9 @@ export function applyBeeperChannelSettings( export function defaultBeeperChannelSettings(): BeeperChannelSettings { return { - approvalBehavior: "native", - backfillLimit: 0, beeperEnv: "production", - contactVisibility: "agents", dataDir: defaultDataDir(), enabled: true, - importSources: [], }; } @@ -1246,30 +1222,14 @@ export function validateBeeperSetupInput(input: BeeperSetupInput): string | null if (input.password !== undefined && !input.password.trim()) return "Beeper password is required."; if ((input.username && !input.password) || (input.password && !input.username)) return "Beeper username/password login requires both username and password."; if (input.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.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; } 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); - if (allowedRoomIds) settings.allowedRoomIds = allowedRoomIds; - if (allowedUserIds) settings.allowedUserIds = allowedUserIds; - if (approvalBehavior) settings.approvalBehavior = approvalBehavior; - if (backfillLimit !== undefined) settings.backfillLimit = backfillLimit; if (beeperEnv) settings.beeperEnv = beeperEnv; - if (contactVisibility) settings.contactVisibility = contactVisibility; if (input.dataDir) settings.dataDir = input.dataDir; - if (importSources) settings.importSources = importSources; return settings; } @@ -1282,37 +1242,11 @@ export function setupOptionsFromInput(input: BeeperSetupInput): SetupOpenClawBee if (input.username) options.username = input.username; if (input.password) options.password = input.password; const env = normalizeBeeperEnv(input.beeperEnv); - const getOnly = normalizeOptionalBoolean(input.getOnly); - const push = normalizeOptionalBoolean(input.push); - const selfHosted = normalizeOptionalBoolean(input.selfHosted); if (env) options.env = env; if (input.code) options.getLoginCode = () => input.code!; - if (getOnly !== undefined) options.getOnly = getOnly; - if (push !== undefined) options.push = push; - if (selfHosted !== undefined) options.selfHosted = selfHosted; 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; @@ -1343,30 +1277,6 @@ function waitForAbort(signal: AbortSignal): Promise { }); } -function normalizeContactVisibility(value: string | undefined): BeeperChannelSettings["contactVisibility"] | undefined { - if (value === "agents" || value === "agents-and-users" || value === "none") return value; - return undefined; -} - -function normalizeApprovalBehavior(value: string | undefined): BeeperChannelSettings["approvalBehavior"] | undefined { - if (value === "native" || 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/types.ts b/packages/openclaw/src/types.ts index e05564b..bd2ab67 100644 --- a/packages/openclaw/src/types.ts +++ b/packages/openclaw/src/types.ts @@ -1,22 +1,15 @@ 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; displayName: string; ghostUserId: string; avatarMxc?: string; + avatarUrl?: string; description?: string; } -export interface OpenClawUserContact { - displayName: string; - ghostUserId: string; - source?: string; - userId: string; -} - export interface OpenClawSessionBinding { id: string; kind: OpenClawBindingKind; @@ -38,21 +31,14 @@ export interface OpenClawSessionBinding { } export interface OpenClawBridgeConfig { - allowedRoomIds?: string[]; - allowedUserIds?: string[]; asToken?: string; appserviceId: string; - approvalBehavior?: "native" | "disabled"; - backfillLimit?: number; beeperEnv?: "production" | "staging" | "dev" | "local"; bridgeId?: string; - bridgeManagerToken?: string; - contactVisibility?: "agents" | "agents-and-users" | "none"; dataDir: string; homeserver?: string; hsToken?: string; homeserverDomain?: string; - importSources?: OpenClawImportSource[]; matrixDeviceId?: string; matrixUserId?: string; } @@ -62,7 +48,6 @@ export interface OpenClawBridgeRegistryData { bindings: OpenClawSessionBinding[]; dedupe: Record; schemaVersion: 1; - users: OpenClawUserContact[]; } export interface AppserviceRegistration { diff --git a/packages/openclaw/tsdown.config.ts b/packages/openclaw/tsdown.config.ts index 720ac39..c8e8ec6 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-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"], + entry: ["src/approval.ts", "src/appservice.ts", "src/beeper-channel-runtime.ts", "src/beeper-setup.ts", "src/bridge-agent.ts", "src/cli.ts", "src/config.ts", "src/connector.ts", "src/matrix-parser.ts", "src/openclaw-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/native/go.mod b/packages/pickle/native/go.mod index d5ad4b4..71ffabc 100644 --- a/packages/pickle/native/go.mod +++ b/packages/pickle/native/go.mod @@ -3,7 +3,7 @@ module github.com/beeper/pickle/packages/pickle/native go 1.25.0 require ( - github.com/beeper/ai-bridge v0.0.0-20260601222736-fee8bd8892f9 + github.com/beeper/ai-bridge v0.0.0-20260602005818-ab83be648105 github.com/gzuidhof/tygo v0.2.21 maunium.net/go/mautrix v0.27.1-0.20260513120123-5fba7e3afae4 ) diff --git a/packages/pickle/native/go.sum b/packages/pickle/native/go.sum index 36286f7..f7913e8 100644 --- a/packages/pickle/native/go.sum +++ b/packages/pickle/native/go.sum @@ -4,6 +4,8 @@ github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7Oputl github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/beeper/ai-bridge v0.0.0-20260601222736-fee8bd8892f9 h1:axfGFpklgo7yCkuXA9rpgfcKK4eR7wWfhimPMer0zE8= github.com/beeper/ai-bridge v0.0.0-20260601222736-fee8bd8892f9/go.mod h1:+icZV4D9wnp0NTP8bsfS/WXrf/8plzmnp/3bhQEnL3E= +github.com/beeper/ai-bridge v0.0.0-20260602005818-ab83be648105 h1:KMAJrhbTLcF6JmRzpJlKz00lCeHaVEfNTg/BzVhdDaI= +github.com/beeper/ai-bridge v0.0.0-20260602005818-ab83be648105/go.mod h1:+icZV4D9wnp0NTP8bsfS/WXrf/8plzmnp/3bhQEnL3E= 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= diff --git a/packages/pickle/native/internal/core/appservice_test.go b/packages/pickle/native/internal/core/appservice_test.go index 1771313..a07e843 100644 --- a/packages/pickle/native/internal/core/appservice_test.go +++ b/packages/pickle/native/internal/core/appservice_test.go @@ -460,6 +460,12 @@ func TestBeeperAIRunStreamUsesCanonicalAIBridgeRun(t *testing.T) { if startResult.EventID != "$event" || startResult.MessageID != "msg-run-1" { t.Fatalf("unexpected start result: %#v", startResult) } + anchorBody := waitForRecordedRequest(t, requests, func(req recordedRequest) bool { + return strings.Contains(req.body, `"com.beeper.ai"`) && strings.Contains(req.body, `"com.beeper.stream"`) + }) + if strings.Contains(anchorBody, "Working...") || !strings.Contains(anchorBody, `"body":""`) { + t.Fatalf("empty stream anchor should not expose working fallback, got %s", anchorBody) + } appendReq, err := json.Marshal(MatrixAppendBeeperAIRunEventOptions{ Event: OutboundEvent{ @@ -481,8 +487,8 @@ func TestBeeperAIRunStreamUsesCanonicalAIBridgeRun(t *testing.T) { if !strings.Contains(carrierBody, `"messageId":"msg-run-1"`) { t.Fatalf("expected canonical envelope message id, got %s", carrierBody) } - if !strings.Contains(carrierBody, `"messageId":"provider-msg"`) { - t.Fatalf("expected original part payload to remain intact, got %s", carrierBody) + if strings.Contains(carrierBody, `"messageId":"provider-msg"`) { + t.Fatalf("expected provider message id to be canonicalized by native stream, got %s", carrierBody) } finishReq, err := json.Marshal(MatrixFinishBeeperAIRunOptions{ @@ -514,6 +520,71 @@ func TestBeeperAIRunStreamUsesCanonicalAIBridgeRun(t *testing.T) { } } +func TestBeeperAIRunStreamStartUsesInitialTextPartForAnchorPreview(t *testing.T) { + requests := make(chan recordedRequest, 16) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + requests <- recordedRequest{body: string(body), path: r.URL.Path} + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"event_id":"$event"}`)) + })) + t.Cleanup(server.Close) + + core := New(nil) + cli, err := mautrix.NewClient(server.URL, id.UserID("@testbot:example"), "device-token") + if err != nil { + t.Fatal(err) + } + cli.DeviceID = id.DeviceID("PICKLE") + cli.StateStore = mautrix.NewMemoryStateStore() + core.client = cli + core.beeperStream, err = beeperstream.New(cli) + if err != nil { + t.Fatal(err) + } + + startReq, err := json.Marshal(MatrixStartBeeperAIRunStreamOptions{ + MatrixBeginBeeperAIRunOptions: MatrixBeginBeeperAIRunOptions{ + AgentID: "codex", + AgentName: "Codex", + Model: "openclaw/plugin", + RunID: "run-preview", + ThreadID: "thread-preview", + }, + InitialParts: []MatrixBeeperAIRunPartOptions{{ + Kind: "text", + Text: "hello", + }}, + RoomID: "!room:example", + }) + if err != nil { + t.Fatal(err) + } + rawStart, err := core.handleStartBeeperAIRunStream(context.Background(), startReq) + if err != nil { + t.Fatal(err) + } + var startResult MatrixBeeperAIRunStreamResult + if err = json.Unmarshal(rawStart, &startResult); err != nil { + t.Fatal(err) + } + if startResult.Body != "hello" { + t.Fatalf("expected start snapshot body to use initial text, got %#v", startResult) + } + anchorBody := waitForRecordedRequest(t, requests, func(req recordedRequest) bool { + return strings.Contains(req.body, `"com.beeper.ai"`) && strings.Contains(req.body, `"com.beeper.stream"`) + }) + if !strings.Contains(anchorBody, `"body":"hello"`) || strings.Contains(anchorBody, "Working...") { + t.Fatalf("expected anchor preview to use initial text, got %s", anchorBody) + } + carrierBody := waitForRecordedRequest(t, requests, func(req recordedRequest) bool { + return strings.Contains(req.body, `"TEXT_MESSAGE_CONTENT"`) && strings.Contains(req.body, `"delta":"hello"`) + }) + if !strings.Contains(carrierBody, `"seq":`) { + t.Fatalf("expected initial text part to be published as a stream carrier, got %s", carrierBody) + } +} + func TestRegisterBeeperStreamInjectsDirectSubscribers(t *testing.T) { requests := make(chan recordedRequest, 4) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/packages/pickle/native/internal/core/beeper_ai_run.go b/packages/pickle/native/internal/core/beeper_ai_run.go index d1af3de..654b963 100644 --- a/packages/pickle/native/internal/core/beeper_ai_run.go +++ b/packages/pickle/native/internal/core/beeper_ai_run.go @@ -13,18 +13,25 @@ import ( agui "github.com/beeper/ai-bridge/pkg/ag-ui" aistream "github.com/beeper/ai-bridge/pkg/ai-stream" aimatrix "github.com/beeper/ai-bridge/pkg/ai-stream/matrix" + "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" ) type beeperAIRunState struct { published int run *aistream.Run + endedToolInputs map[string]bool streamDescriptor any streamEventID id.EventID streamRoomID id.RoomID + startedToolCalls map[string]bool + toolInputs map[string]any + toolNames map[string]string writer *aistream.Writer } +const beeperAIRunStreamOperationTimeout = 30 * time.Second + type MatrixBeginBeeperAIRunOptions struct { AgentID string `json:"agentId,omitempty"` AgentName string `json:"agentName,omitempty"` @@ -40,6 +47,50 @@ type MatrixAppendBeeperAIRunEventOptions struct { RunID string `json:"runId"` } +type MatrixBeeperAIRunPartOptions struct { + ActivityType string `json:"activityType,omitempty"` + Aggregated string `json:"aggregated,omitempty"` + Approval any `json:"approval,omitempty" tstype:"unknown"` + Command string `json:"command,omitempty"` + CompletedAtMs *int64 `json:"completedAtMs,omitempty"` + Content OutboundEvent `json:"content,omitempty" tstype:"{ [key: string]: unknown }"` + Cwd string `json:"cwd,omitempty"` + Description string `json:"description,omitempty"` + Delta any `json:"delta,omitempty" tstype:"unknown"` + Details any `json:"details,omitempty" tstype:"unknown"` + Dynamic *bool `json:"dynamic,omitempty"` + Error any `json:"error,omitempty" tstype:"unknown"` + ExitCode *int `json:"exitCode,omitempty"` + Index *int `json:"index,omitempty"` + Input any `json:"input,omitempty" tstype:"unknown"` + Kind string `json:"kind" tstype:"\"text\" | \"reasoning\" | \"reasoning_end\" | \"tool_start\" | \"tool_input\" | \"tool_end\" | \"tool_result\" | \"activity\" | \"activity_delta\" | \"state_delta\" | \"state_snapshot\" | \"raw\" | \"custom\" | string"` + Metadata OutboundEvent `json:"metadata,omitempty" tstype:"{ [key: string]: unknown }"` + Name string `json:"name,omitempty"` + Output any `json:"output,omitempty" tstype:"unknown"` + Patch any `json:"patch,omitempty" tstype:"unknown"` + Preliminary bool `json:"preliminary,omitempty"` + ProviderExecuted *bool `json:"providerExecuted,omitempty"` + Replace *bool `json:"replace,omitempty"` + Response any `json:"response,omitempty" tstype:"unknown"` + Result any `json:"result,omitempty" tstype:"unknown"` + Source string `json:"source,omitempty"` + State string `json:"state,omitempty"` + Status string `json:"status,omitempty"` + StartedAtMs *int64 `json:"startedAtMs,omitempty"` + Stderr string `json:"stderr,omitempty"` + Stdout string `json:"stdout,omitempty"` + Text string `json:"text,omitempty"` + Title string `json:"title,omitempty"` + ToolCallID string `json:"toolCallId,omitempty"` + ToolName string `json:"toolName,omitempty"` + Value any `json:"value,omitempty" tstype:"unknown"` +} + +type MatrixAppendBeeperAIRunPartOptions struct { + MatrixBeeperAIRunPartOptions `json:",inline" tstype:",extends"` + RunID string `json:"runId"` +} + type MatrixFinishBeeperAIRunOptions struct { FinishReason string `json:"finishReason,omitempty"` RunID string `json:"runId"` @@ -71,6 +122,8 @@ type MatrixBeeperAIRunSnapshot struct { type MatrixStartBeeperAIRunStreamOptions struct { MatrixBeginBeeperAIRunOptions `json:",inline" tstype:",extends"` + InitialEvents []OutboundEvent `json:"initialEvents,omitempty" tstype:"Array<{ [key: string]: unknown }>"` + InitialParts []MatrixBeeperAIRunPartOptions `json:"initialParts,omitempty"` RoomID string `json:"roomId"` StreamType string `json:"streamType,omitempty"` Subscribers []MatrixBeeperStreamSubscriber `json:"subscribers,omitempty"` @@ -106,15 +159,10 @@ func (c *Core) handleAppendBeeperAIRunEvent(payload []byte) ([]byte, error) { if err != nil { return nil, err } - event := agui.NewEvent(map[string]any(copyOutboundEvent(req.Event))) - if !event.Has("timestamp") { - event.Set("timestamp", time.Now().UnixMilli()) - } - if err := agui.ValidateEvent(event); err != nil { + before := len(state.run.Events) + if err := state.appendEvent(req.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:])) } @@ -172,6 +220,8 @@ func (c *Core) handleDeleteBeeperAIRun(payload []byte) ([]byte, error) { } func (c *Core) handleStartBeeperAIRunStream(ctx context.Context, payload []byte) ([]byte, error) { + ctx, cancel := beeperAIRunStreamContext(ctx) + defer cancel() if c.beeperStream == nil { return nil, errors.New("beeper stream helper is not initialized") } @@ -187,11 +237,24 @@ func (c *Core) handleStartBeeperAIRunStream(ctx context.Context, payload []byte) } state := c.beginBeeperAIRun(req.MatrixBeginBeeperAIRunOptions) run := state.run + for _, eventData := range req.InitialEvents { + if err := state.appendEvent(eventData); err != nil { + delete(c.beeperAIRuns, run.RunID) + return nil, err + } + } + for _, part := range req.InitialParts { + if err := state.appendPart(part); err != nil { + delete(c.beeperAIRuns, run.RunID) + return nil, err + } + } descriptor, err := c.beeperStream.NewDescriptor(ctx, id.RoomID(req.RoomID), req.StreamType) if err != nil { return nil, err } content, extra := aimatrix.AnchorContent(*run) + clearBeeperAIWorkingFallback(content, *run) contentMap := messageContentMap(content, OutboundEvent(extra)) if len(req.Subscribers) > 0 { contentMap["com.beeper.stream"] = descriptor @@ -233,6 +296,8 @@ func (c *Core) handleStartBeeperAIRunStream(ctx context.Context, payload []byte) } func (c *Core) handleAppendBeeperAIRunStreamEvent(ctx context.Context, payload []byte) ([]byte, error) { + ctx, cancel := beeperAIRunStreamContext(ctx) + defer cancel() var req MatrixAppendBeeperAIRunEventOptions if err := json.Unmarshal(payload, &req); err != nil { return nil, err @@ -241,15 +306,32 @@ func (c *Core) handleAppendBeeperAIRunStreamEvent(ctx context.Context, payload [ if err != nil { return nil, err } - event := agui.NewEvent(map[string]any(copyOutboundEvent(req.Event))) - if !event.Has("timestamp") { - event.Set("timestamp", time.Now().UnixMilli()) + before := len(state.run.Events) + if err := state.appendEvent(req.Event); err != nil { + return nil, err } - if err := agui.ValidateEvent(event); err != nil { + events := outboundEventsFromAGUI(state.run.Events[before:]) + if err := c.publishBeeperAIRunStreamPending(ctx, state); err != nil { + return nil, err + } + return c.marshalBeeperAIRunStreamResult(state, events, "", nil) +} + +func (c *Core) handleAppendBeeperAIRunStreamPart(ctx context.Context, payload []byte) ([]byte, error) { + ctx, cancel := beeperAIRunStreamContext(ctx) + defer cancel() + var req MatrixAppendBeeperAIRunPartOptions + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + state, err := c.requireBeeperAIRun(req.RunID) + if err != nil { return nil, err } before := len(state.run.Events) - state.writer.Add(event) + if err := state.appendPart(req.MatrixBeeperAIRunPartOptions); err != nil { + return nil, err + } events := outboundEventsFromAGUI(state.run.Events[before:]) if err := c.publishBeeperAIRunStreamPending(ctx, state); err != nil { return nil, err @@ -258,6 +340,8 @@ func (c *Core) handleAppendBeeperAIRunStreamEvent(ctx context.Context, payload [ } func (c *Core) handleFinishBeeperAIRunStream(ctx context.Context, payload []byte) ([]byte, error) { + ctx, cancel := beeperAIRunStreamContext(ctx) + defer cancel() var req MatrixFinishBeeperAIRunOptions if err := json.Unmarshal(payload, &req); err != nil { return nil, err @@ -281,6 +365,8 @@ func (c *Core) handleFinishBeeperAIRunStream(ctx context.Context, payload []byte } func (c *Core) handleErrorBeeperAIRunStream(ctx context.Context, payload []byte) ([]byte, error) { + ctx, cancel := beeperAIRunStreamContext(ctx) + defer cancel() var req MatrixErrorBeeperAIRunOptions if err := json.Unmarshal(payload, &req); err != nil { return nil, err @@ -319,11 +405,385 @@ func (c *Core) beginBeeperAIRun(req MatrixBeginBeeperAIRunOptions) *beeperAIRunS } writer := aistream.NewWriter(run, time.Now) writer.Start() - state := &beeperAIRunState{run: run, writer: writer} + state := &beeperAIRunState{ + endedToolInputs: map[string]bool{}, + run: run, + startedToolCalls: map[string]bool{}, + toolInputs: map[string]any{}, + toolNames: map[string]string{}, + writer: writer, + } c.beeperAIRuns[run.RunID] = state return state } +func (s *beeperAIRunState) appendEvent(eventData OutboundEvent) error { + event := agui.NewEvent(map[string]any(copyOutboundEvent(eventData))) + if !event.Has("timestamp") { + event.Set("timestamp", time.Now().UnixMilli()) + } + canonicalizeBeeperAIRunEvent(s.run, event) + if err := agui.ValidateEvent(event); err != nil { + return err + } + s.writer.Add(event) + return nil +} + +func (s *beeperAIRunState) appendPart(req MatrixBeeperAIRunPartOptions) error { + if s == nil || s.writer == nil { + return errors.New("Beeper AI run is not initialized") + } + kind := normalizeBeeperAIRunPartKind(req.Kind) + switch kind { + case "text", "text_delta": + s.writer.Text(firstNonEmpty(req.Text, stringFromAny(req.Delta), stringFromAny(req.Value))) + case "text_end": + s.writer.TextEnd(beeperAIRunPartIndex(req)) + case "reasoning", "reasoning_delta", "thinking": + s.writer.ReasoningDelta(beeperAIRunPartIndex(req), firstNonEmpty(req.Text, stringFromAny(req.Delta), stringFromAny(req.Value))) + case "reasoning_end", "thinking_end": + s.writer.ReasoningMessageEnd(beeperAIRunPartIndex(req)) + case "tool_start": + s.ensureToolStarted(req) + case "tool_input", "tool_input_delta": + toolCallID, toolName := s.ensureToolStarted(req) + input := firstNonNil(req.Input, req.Value) + if input != nil { + s.toolInputs[toolCallID] = input + } + delta := firstNonEmpty(req.Text, stringFromAny(req.Delta), beeperAIJSONString(input)) + s.writer.ToolArgs(toolCallID, delta, input) + if toolName != "" { + s.toolNames[toolCallID] = toolName + } + case "tool_end", "tool_input_complete": + toolCallID, toolName := s.ensureToolStarted(req) + s.endToolInput(toolCallID, toolName, firstNonNil(req.Input, s.toolInputs[toolCallID])) + case "tool_result": + toolCallID, toolName := s.ensureToolStarted(req) + input := firstNonNil(req.Input, s.toolInputs[toolCallID]) + if !req.Preliminary { + s.endToolInput(toolCallID, toolName, input) + } + state := req.State + if state == "" { + if req.Error != nil { + state = agui.ToolResultStateError + } else if req.Preliminary { + state = agui.ToolResultStateStreaming + } else { + state = agui.ToolResultStateComplete + } + } + result := firstNonNil(req.Error, req.Output, req.Value) + if isCommandToolName(toolName) { + result = commandToolOutput(req) + } + content := firstNonEmpty(req.Text, beeperAIJSONString(result)) + if content != "" { + before := len(s.run.Events) + s.writer.ToolResult(toolCallID, content, state) + s.annotateToolResult(before, req) + } + case "activity": + content := copyOutboundEvent(req.Content) + if len(content) == 0 { + content = OutboundEvent{} + if req.Text != "" { + content["text"] = req.Text + } + if req.State != "" { + content["state"] = req.State + } + if req.Value != nil { + content["value"] = req.Value + } + } + s.addEvent(agui.NewEvent(map[string]any{ + "type": agui.EventActivitySnapshot, + "messageId": s.run.MessageID, + "activityType": firstNonEmpty(req.ActivityType, req.Name, "activity"), + "content": map[string]any(content), + "replace": req.Replace, + })) + case "activity_delta": + s.addEvent(agui.NewEvent(map[string]any{ + "type": agui.EventActivityDelta, + "messageId": s.run.MessageID, + "activityType": firstNonEmpty(req.ActivityType, req.Name, "activity"), + "patch": firstNonNil(req.Patch, req.Delta, req.Value), + })) + case "state_delta": + s.writer.StateDelta(firstNonNil(req.Delta, req.Value)) + case "state_snapshot": + s.addEvent(agui.NewEvent(map[string]any{ + "type": agui.EventStateSnapshot, + "snapshot": req.Value, + })) + case "raw": + s.addEvent(agui.NewEvent(map[string]any{ + "type": agui.EventRaw, + "source": req.Source, + "event": firstNonNil(req.Value, req.Content), + })) + case "custom": + s.writer.Custom(firstNonEmpty(req.Name, "openclaw.data"), req.Value) + case "step_start", "step": + s.writer.StepStart(firstNonEmpty(req.Name, req.Text, "step")) + case "step_finish", "step_end": + s.writer.StepFinish(firstNonEmpty(req.Name, req.Text, "step")) + default: + return fmt.Errorf("unsupported Beeper AI run stream part kind %q", req.Kind) + } + return nil +} + +func (s *beeperAIRunState) ensureToolStarted(req MatrixBeeperAIRunPartOptions) (string, string) { + toolName := firstNonEmpty(req.ToolName, req.Name, "tool") + toolCallID := firstNonEmpty(req.ToolCallID, "tool:"+toolName) + if req.Input != nil { + s.toolInputs[toolCallID] = req.Input + } + if toolName != "" { + s.toolNames[toolCallID] = toolName + } + if s.startedToolCalls[toolCallID] { + return toolCallID, firstNonEmpty(s.toolNames[toolCallID], toolName) + } + s.startedToolCalls[toolCallID] = true + title := toolTitle(req) + metadata := toolMetadata(req, title) + s.writer.ToolStartWithMetadata(toolCallID, toolName, beeperAIRunPartIndex(req), nil, metadata) + s.annotateToolStart(req, title) + input := firstNonNil(req.Input, req.Value) + if input != nil { + s.toolInputs[toolCallID] = input + if delta := firstNonEmpty(req.Text, stringFromAny(req.Delta), beeperAIJSONString(input)); delta != "" { + s.writer.ToolArgs(toolCallID, delta, input) + } + } + return toolCallID, toolName +} + +func (s *beeperAIRunState) annotateToolStart(req MatrixBeeperAIRunPartOptions, title string) { + if len(s.run.Events) == 0 { + return + } + event := s.run.Events[len(s.run.Events)-1] + if event.Type() != agui.EventToolCallStart { + return + } + if req.Approval != nil { + event.Set("approval", req.Approval) + } + if req.Dynamic != nil { + event.Set("dynamic", *req.Dynamic) + } + if req.ProviderExecuted != nil { + event.Set("providerExecuted", *req.ProviderExecuted) + } + if req.StartedAtMs != nil { + event.Set("startedAtMs", *req.StartedAtMs) + } + if title != "" { + event.Set("title", title) + } +} + +func (s *beeperAIRunState) annotateToolResult(before int, req MatrixBeeperAIRunPartOptions) { + for i := before; i < len(s.run.Events); i++ { + event := s.run.Events[i] + if event.Type() != agui.EventToolCallResult { + continue + } + if req.CompletedAtMs != nil { + event.Set("completedAtMs", *req.CompletedAtMs) + } + if req.Preliminary { + event.Set("preliminary", true) + } + if req.ProviderExecuted != nil { + event.Set("providerExecuted", *req.ProviderExecuted) + } + if req.ToolName != "" { + event.Set("toolName", req.ToolName) + } + } +} + +func (s *beeperAIRunState) endToolInput(toolCallID, toolName string, input any) { + if toolCallID == "" || s.endedToolInputs[toolCallID] { + return + } + s.endedToolInputs[toolCallID] = true + s.writer.ToolInputComplete(toolCallID, firstNonEmpty(toolName, s.toolNames[toolCallID], "tool"), input) +} + +func toolTitle(req MatrixBeeperAIRunPartOptions) string { + return firstNonEmpty(req.Title, commandFromPart(req)) +} + +func toolMetadata(req MatrixBeeperAIRunPartOptions, title string) map[string]any { + metadata := map[string]any(nil) + if len(req.Metadata) > 0 { + metadata = map[string]any(req.Metadata) + } + if title == "" && req.Description == "" { + return metadata + } + if metadata == nil { + metadata = map[string]any{} + } + if title != "" && firstString(metadata["displayName"], "") == "" { + metadata["displayName"] = title + } + if req.Description != "" && firstString(metadata["description"], "") == "" { + metadata["description"] = req.Description + } + return metadata +} + +func isCommandToolName(toolName string) bool { + switch strings.ToLower(strings.TrimSpace(toolName)) { + case "bash", "command", "exec", "shell": + return true + default: + return false + } +} + +func commandToolOutput(req MatrixBeeperAIRunPartOptions) any { + details := mapFromAny(req.Details) + if details == nil { + if result := mapFromAny(req.Result); result != nil { + details = mapFromAny(result["details"]) + } + } + if details == nil { + if output := mapFromAny(req.Output); output != nil { + details = mapFromAny(output["details"]) + } + } + if details == nil { + if response := mapFromAny(req.Response); response != nil { + details = mapFromAny(response["details"]) + } + } + if details != nil { + output := firstNonEmpty( + stringFromAny(details["aggregated"]), + stringFromAny(details["output"]), + stringFromAny(req.Output), + stringFromAny(req.Response), + ) + result := stripNil(map[string]any{ + "status": firstNonEmpty(req.Status, stringFromAny(details["status"])), + "exitCode": firstNonNil(req.ExitCode, intFromAny(details["exitCode"]), intFromAny(details["exit_code"])), + "durationMs": firstNonNil(intFromAny(details["durationMs"]), intFromAny(details["duration_ms"])), + "cwd": firstNonEmpty(req.Cwd, stringFromAny(details["cwd"]), stringFromAny(mapFromAny(req.Input)["cwd"])), + "command": firstNonEmpty(req.Command, stringFromAny(details["command"]), commandFromPart(req)), + "stdout": firstNonEmpty(req.Stdout, stringFromAny(details["stdout"])), + "stderr": firstNonEmpty(req.Stderr, stringFromAny(details["stderr"])), + "aggregated": firstNonEmpty(req.Aggregated, stringFromAny(details["aggregated"])), + "output": output, + }) + if len(result) == 0 { + return nil + } + return result + } + + stdout := firstNonEmpty(req.Stdout, stringFromAny(mapFromAny(req.Result)["stdout"]), stringFromAny(mapFromAny(req.Output)["stdout"])) + stderr := firstNonEmpty(req.Stderr, stringFromAny(mapFromAny(req.Result)["stderr"]), stringFromAny(mapFromAny(req.Output)["stderr"])) + aggregated := firstNonEmpty(req.Aggregated, stringFromAny(mapFromAny(req.Result)["aggregated"]), stringFromAny(mapFromAny(req.Output)["aggregated"])) + output := firstCommandText(aggregated, req.Output, req.Result, req.Response, req.Value) + if output == "" && stdout == "" && stderr == "" { + return nil + } + if req.ExitCode == nil && stdout != "" && stderr == "" { + return stdout + } + if req.ExitCode == nil && stdout == "" && stderr != "" { + return stderr + } + if req.ExitCode == nil && stdout == "" && stderr == "" { + return output + } + return stripNil(map[string]any{ + "status": req.Status, + "exitCode": req.ExitCode, + "cwd": firstNonEmpty(req.Cwd, stringFromAny(mapFromAny(req.Input)["cwd"])), + "command": commandFromPart(req), + "stdout": stdout, + "stderr": stderr, + "aggregated": aggregated, + "output": output, + }) +} + +func commandFromPart(req MatrixBeeperAIRunPartOptions) string { + input := mapFromAny(req.Input) + value := mapFromAny(req.Value) + return firstNonEmpty( + req.Command, + stringFromAny(input["command"]), + stringFromAny(input["cmd"]), + stringFromAny(value["command"]), + stringFromAny(value["cmd"]), + ) +} + +func firstCommandText(values ...any) string { + for _, value := range values { + if text := commandText(value); text != "" { + return text + } + } + return "" +} + +func commandText(value any) string { + if text := stringFromAny(value); text != "" { + return text + } + record := mapFromAny(value) + if len(record) == 0 || isStatusOnlyToolOutput(record) { + return "" + } + return firstNonEmpty( + stringFromAny(record["stdout"]), + stringFromAny(record["stderr"]), + stringFromAny(record["aggregated"]), + stringFromAny(record["output"]), + stringFromAny(record["text"]), + stringFromAny(record["content"]), + stringFromAny(record["response"]), + ) +} + +func isStatusOnlyToolOutput(record map[string]any) bool { + if len(record) == 0 { + return false + } + for key := range record { + switch key { + case "action", "finalUrl", "final_url", "phase", "queries", "query", "queryUnavailable", "state", "status", "url": + default: + return false + } + } + return true +} + +func (s *beeperAIRunState) addEvent(event agui.Event) { + canonicalizeBeeperAIRunEvent(s.run, event) + if !event.Has("timestamp") { + event.Set("timestamp", time.Now().UnixMilli()) + } + s.writer.Add(event) +} + func (c *Core) publishBeeperAIRunStreamPending(ctx context.Context, state *beeperAIRunState) error { if state == nil || state.streamEventID == "" { return errors.New("Beeper AI run is not attached to a stream message") @@ -362,12 +822,14 @@ func (c *Core) finalizeBeeperAIRunStream(ctx context.Context, state *beeperAIRun return nil, fmt.Errorf("beeper stream message %s is not registered", state.streamEventID) } projection := aimatrix.ProjectFinal(*state.run, nil) + clearBeeperAIWorkingFallback(projection.Content, *state.run) if projection.NeedsAttachment { partsRef, err := c.uploadBeeperAIFinalPartsRef(ctx, *state.run, projection.Message) if err != nil { return nil, err } projection = aimatrix.ProjectFinal(*state.run, partsRef) + clearBeeperAIWorkingFallback(projection.Content, *state.run) } contentMap := messageContentMap(projection.Content, OutboundEvent(projection.Extra)) result, err := c.finalizeBeeperStreamMessage(ctx, MatrixFinalizeBeeperStreamMessageOptions{ @@ -412,6 +874,17 @@ func (c *Core) uploadBeeperAIFinalPartsRef(ctx context.Context, run aistream.Run }, nil } +func clearBeeperAIWorkingFallback(content *event.MessageEventContent, run aistream.Run) { + if content == nil || strings.TrimSpace(run.Text()) != "" || strings.TrimSpace(run.Preview.Text) != "" || run.Status.State == "error" { + return + } + if strings.TrimSpace(content.Body) != "Working..." { + return + } + content.Body = "" + content.FormattedBody = "" +} + func (c *Core) requireBeeperAIRun(runID string) (*beeperAIRunState, error) { if strings.TrimSpace(runID) == "" { return nil, errors.New("missing Beeper AI run ID") @@ -423,6 +896,119 @@ func (c *Core) requireBeeperAIRun(runID string) (*beeperAIRunState, error) { return state, nil } +func beeperAIRunStreamContext(ctx context.Context) (context.Context, context.CancelFunc) { + if _, ok := ctx.Deadline(); ok { + return ctx, func() {} + } + return context.WithTimeout(ctx, beeperAIRunStreamOperationTimeout) +} + +func normalizeBeeperAIRunPartKind(kind string) string { + kind = strings.ToLower(strings.TrimSpace(kind)) + kind = strings.ReplaceAll(kind, "-", "_") + kind = strings.ReplaceAll(kind, ".", "_") + return kind +} + +func beeperAIRunPartIndex(req MatrixBeeperAIRunPartOptions) int { + if req.Index == nil { + return 0 + } + return *req.Index +} + +func firstNonNil(values ...any) any { + for _, value := range values { + if value != nil { + return value + } + } + return nil +} + +func stringFromAny(value any) string { + text, _ := value.(string) + return text +} + +func beeperAIJSONString(value any) string { + if value == nil { + return "" + } + if text, ok := value.(string); ok { + return text + } + raw, err := json.Marshal(value) + if err != nil { + return fmt.Sprint(value) + } + return string(raw) +} + +func mapFromAny(value any) map[string]any { + switch typed := value.(type) { + case nil: + return nil + case map[string]any: + return typed + case OutboundEvent: + return map[string]any(typed) + default: + return nil + } +} + +func intFromAny(value any) *int { + switch typed := value.(type) { + case int: + return &typed + case int32: + out := int(typed) + return &out + case int64: + out := int(typed) + return &out + case float64: + out := int(typed) + return &out + case json.Number: + raw, err := typed.Int64() + if err != nil { + return nil + } + out := int(raw) + return &out + default: + return nil + } +} + +func stripNil(input map[string]any) map[string]any { + for key, value := range input { + if value == nil || value == "" { + delete(input, key) + } + } + return input +} + +func canonicalizeBeeperAIRunEvent(run *aistream.Run, event agui.Event) { + if run == nil || event.Len() == 0 { + return + } + switch event.Type() { + case agui.EventRunStarted, agui.EventRunFinished, agui.EventRunError: + event.Set("runId", run.RunID) + event.Set("threadId", run.ThreadID) + case agui.EventTextMessageStart, agui.EventTextMessageContent, agui.EventTextMessageChunk, agui.EventTextMessageEnd, + agui.EventReasoningStart, agui.EventReasoningMsgStart, agui.EventReasoningMsgCont, agui.EventReasoningMsgChunk, agui.EventReasoningMsgEnd, agui.EventReasoningEnd, + agui.EventActivitySnapshot, agui.EventActivityDelta: + event.Set("messageId", run.MessageID) + case agui.EventToolCallStart: + event.Set("parentMessageId", run.MessageID) + } +} + func (c *Core) marshalBeeperAIRunSnapshot(run *aistream.Run, events []OutboundEvent) ([]byte, error) { return json.Marshal(c.beeperAIRunSnapshot(run, events)) } diff --git a/packages/pickle/native/internal/core/beeper_ai_run_test.go b/packages/pickle/native/internal/core/beeper_ai_run_test.go index bab0655..69ba1d3 100644 --- a/packages/pickle/native/internal/core/beeper_ai_run_test.go +++ b/packages/pickle/native/internal/core/beeper_ai_run_test.go @@ -2,10 +2,12 @@ package core import ( "encoding/json" + "fmt" "strings" "testing" aistream "github.com/beeper/ai-bridge/pkg/ai-stream" + aimatrix "github.com/beeper/ai-bridge/pkg/ai-stream/matrix" ) func TestBeeperAIRunLifecycleUsesAIBridgeFinalContent(t *testing.T) { @@ -38,9 +40,8 @@ func TestBeeperAIRunLifecycleUsesAIBridgeFinalContent(t *testing.T) { appendPayload, err := json.Marshal(MatrixAppendBeeperAIRunEventOptions{ RunID: "run-1", Event: OutboundEvent{ - "delta": "hello", - "messageId": begin.MessageID, - "type": "TEXT_MESSAGE_CONTENT", + "delta": "hello", + "type": "TEXT_MESSAGE_CONTENT", }, }) if err != nil { @@ -60,6 +61,9 @@ func TestBeeperAIRunLifecycleUsesAIBridgeFinalContent(t *testing.T) { if _, ok := appendSnap.Events[0]["timestamp"]; !ok { t.Fatalf("append event missing native timestamp: %#v", appendSnap.Events[0]) } + if appendSnap.Events[0]["messageId"] != begin.MessageID { + t.Fatalf("append event messageId = %v, want %s", appendSnap.Events[0]["messageId"], begin.MessageID) + } finishPayload, err := json.Marshal(MatrixFinishBeeperAIRunOptions{ FinishReason: "stop", @@ -134,6 +138,119 @@ func TestBeeperAIRunErrorAbortAndDelete(t *testing.T) { } } +func TestBeeperAIRunSemanticPartsUseAIBridgeWriter(t *testing.T) { + core := New(nil) + state := core.beginBeeperAIRun(MatrixBeginBeeperAIRunOptions{RunID: "run-parts", ThreadID: "thread-parts"}) + providerExecuted := true + startedAtMs := int64(123) + completedAtMs := int64(456) + parts := []MatrixBeeperAIRunPartOptions{ + {Kind: "text", Text: "hello"}, + {Kind: "reasoning", Text: "checking"}, + {Description: "Searches project documentation", Input: map[string]any{"query": "docs"}, Kind: "tool_start", Metadata: OutboundEvent{"source": "codex"}, ProviderExecuted: &providerExecuted, StartedAtMs: &startedAtMs, Title: "Search docs", ToolCallID: "tool-1", ToolName: "search"}, + {CompletedAtMs: &completedAtMs, Kind: "tool_result", Output: map[string]any{"ok": true}, ProviderExecuted: &providerExecuted, ToolCallID: "tool-1", ToolName: "search"}, + {Kind: "activity", ActivityType: "status", Content: OutboundEvent{"text": "Working..."}}, + {Kind: "custom", Name: "com.beeper.source", Value: map[string]any{"url": "https://example.com"}}, + } + for _, part := range parts { + if err := state.appendPart(part); err != nil { + t.Fatalf("appendPart(%s): %v", part.Kind, err) + } + } + types := eventTypes(outboundEventsFromAGUI(state.run.Events)) + want := []string{ + "RUN_STARTED", + "TEXT_MESSAGE_START", + "TEXT_MESSAGE_CONTENT", + "REASONING_START", + "REASONING_MESSAGE_START", + "REASONING_MESSAGE_CONTENT", + "TOOL_CALL_START", + "TOOL_CALL_ARGS", + "TOOL_CALL_END", + "TOOL_CALL_RESULT", + "ACTIVITY_SNAPSHOT", + "CUSTOM", + } + if strings.Join(types, ",") != strings.Join(want, ",") { + t.Fatalf("unexpected semantic event types:\n got %v\nwant %v", types, want) + } + events := outboundEventsFromAGUI(state.run.Events) + start := firstEventOfType(events, "TOOL_CALL_START") + if start["providerExecuted"] != true || fmt.Sprint(start["startedAtMs"]) != "123" || start["title"] != "Search docs" { + t.Fatalf("tool start lost rich fields: %#v", start) + } + if metadata, ok := start["metadata"].(map[string]any); !ok || metadata["source"] != "codex" || metadata["description"] != "Searches project documentation" { + t.Fatalf("tool start lost metadata: %#v", start) + } + result := firstEventOfType(events, "TOOL_CALL_RESULT") + if result["providerExecuted"] != true || fmt.Sprint(result["completedAtMs"]) != "456" || result["toolName"] != "search" { + t.Fatalf("tool result lost rich fields: %#v", result) + } + finalPart := firstToolPart(state.run.FinalBeeperAIMessage(0, true).Parts, "tool-1") + if finalPart == nil { + t.Fatalf("final message is missing tool part: %#v", state.run.FinalBeeperAIMessage(0, true).Parts) + } + finalMetadata, _ := finalPart["metadata"].(map[string]any) + if finalPart["providerExecuted"] != true || fmt.Sprint(finalPart["startedAtMs"]) != "123" || fmt.Sprint(finalPart["completedAtMs"]) != "456" || finalPart["title"] != "Search docs" || finalMetadata["description"] != "Searches project documentation" { + t.Fatalf("final tool part lost rich fields: %#v", finalPart) + } +} + +func TestBeeperAIRunCommandPartUsesCommandAsTitleAndActualOutput(t *testing.T) { + core := New(nil) + state := core.beginBeeperAIRun(MatrixBeginBeeperAIRunOptions{RunID: "run-command", ThreadID: "thread-command"}) + err := state.appendPart(MatrixBeeperAIRunPartOptions{ + Input: map[string]any{ + "command": `/bin/zsh -lc "date '+%Y-%m-%d %H:%M:%S %Z'"`, + "cwd": "/Users/batuhan/.openclaw/workspace", + }, + Kind: "tool_result", + Output: map[string]any{"status": "completed"}, + Response: "2026-06-02 03:15:00 CEST", + Status: "completed", + ToolCallID: "cmd-date", + ToolName: "bash", + }) + if err != nil { + t.Fatal(err) + } + events := outboundEventsFromAGUI(state.run.Events) + start := firstEventOfType(events, "TOOL_CALL_START") + if start["title"] != `/bin/zsh -lc "date '+%Y-%m-%d %H:%M:%S %Z'"` { + t.Fatalf("command title not promoted: %#v", start) + } + metadata, ok := start["metadata"].(map[string]any) + if !ok || metadata["displayName"] != start["title"] { + t.Fatalf("command display metadata missing: %#v", start) + } + result := firstEventOfType(events, "TOOL_CALL_RESULT") + if result["content"] != "2026-06-02 03:15:00 CEST" { + t.Fatalf("command result should be actual output, got %#v", result) + } + if strings.Contains(fmt.Sprint(result["content"]), "completed") { + t.Fatalf("command result leaked status wrapper: %#v", result) + } +} + +func TestBeeperAIEmptyStreamProjectionDoesNotExposeWorkingFallback(t *testing.T) { + core := New(nil) + state := core.beginBeeperAIRun(MatrixBeginBeeperAIRunOptions{RunID: "run-empty", ThreadID: "thread-empty"}) + + anchorContent, _ := aimatrix.AnchorContent(*state.run) + clearBeeperAIWorkingFallback(anchorContent, *state.run) + if strings.Contains(anchorContent.Body, "Working") || strings.Contains(anchorContent.FormattedBody, "Working") { + t.Fatalf("empty stream anchor leaked working fallback: %#v", anchorContent) + } + + state.writer.Finish("stop") + finalProjection := aimatrix.ProjectFinal(*state.run, nil) + clearBeeperAIWorkingFallback(finalProjection.Content, *state.run) + if strings.Contains(finalProjection.Content.Body, "Working") || strings.Contains(finalProjection.Content.FormattedBody, "Working") { + t.Fatalf("empty final projection leaked working fallback: %#v", finalProjection.Content) + } +} + func TestBeeperStreamCarrierContentsUsesBeeperAIPayloadAndAdvancesSeq(t *testing.T) { core := New(nil) contents, nextSeq, err := core.beeperStreamCarrierContents("com.beeper.llm", MatrixPublishBeeperStreamMessagePartOptions{ @@ -187,3 +304,21 @@ func eventTypes(events []OutboundEvent) []string { } return types } + +func firstEventOfType(events []OutboundEvent, eventType string) OutboundEvent { + for _, event := range events { + if event["type"] == eventType { + return event + } + } + return nil +} + +func firstToolPart(parts []aistream.MessagePart, toolCallID string) aistream.MessagePart { + for _, part := range parts { + if part["toolCallId"] == toolCallID { + return part + } + } + return nil +} diff --git a/packages/pickle/native/internal/core/core.go b/packages/pickle/native/internal/core/core.go index 0a9e6c2..a61cc88 100644 --- a/packages/pickle/native/internal/core/core.go +++ b/packages/pickle/native/internal/core/core.go @@ -154,6 +154,8 @@ func (c *Core) Handle(ctx context.Context, op string, payload []byte) ([]byte, e return c.handleStartBeeperAIRunStream(ctx, payload) case opAppendBeeperAIRunStreamEvent: return c.handleAppendBeeperAIRunStreamEvent(ctx, payload) + case opAppendBeeperAIRunStreamPart: + return c.handleAppendBeeperAIRunStreamPart(ctx, payload) case opFinishBeeperAIRunStream: return c.handleFinishBeeperAIRunStream(ctx, payload) case opErrorBeeperAIRunStream: diff --git a/packages/pickle/native/internal/core/operations.go b/packages/pickle/native/internal/core/operations.go index cda74d7..fffb3f8 100644 --- a/packages/pickle/native/internal/core/operations.go +++ b/packages/pickle/native/internal/core/operations.go @@ -83,6 +83,8 @@ const ( opStartBeeperAIRunStream = "start_beeper_ai_run_stream" // ts:operation appendBeeperAIRunStreamEvent append_beeper_ai_run_stream_event MatrixAppendBeeperAIRunEventOptions MatrixBeeperAIRunStreamResult opAppendBeeperAIRunStreamEvent = "append_beeper_ai_run_stream_event" + // ts:operation appendBeeperAIRunStreamPart append_beeper_ai_run_stream_part MatrixAppendBeeperAIRunPartOptions MatrixBeeperAIRunStreamResult + opAppendBeeperAIRunStreamPart = "append_beeper_ai_run_stream_part" // ts:operation finishBeeperAIRunStream finish_beeper_ai_run_stream MatrixFinishBeeperAIRunOptions MatrixBeeperAIRunStreamResult opFinishBeeperAIRunStream = "finish_beeper_ai_run_stream" // ts:operation errorBeeperAIRunStream error_beeper_ai_run_stream MatrixErrorBeeperAIRunOptions MatrixBeeperAIRunStreamResult diff --git a/packages/pickle/src/client-types.ts b/packages/pickle/src/client-types.ts index 2024e4c..1d42d5f 100644 --- a/packages/pickle/src/client-types.ts +++ b/packages/pickle/src/client-types.ts @@ -79,6 +79,7 @@ import type { MatrixAppserviceSendMessageOptions, MatrixAppserviceUserOptions, MatrixAppendBeeperAIRunEventOptions, + MatrixAppendBeeperAIRunPartOptions, MatrixBeginBeeperAIRunOptions, MatrixBeeperAIRunSnapshot, MatrixBeeperAIRunStreamResult, @@ -161,6 +162,7 @@ export interface MatrixBeeper { }; aiRunStreams: { appendEvent(options: MatrixAppendBeeperAIRunEventOptions): Promise; + appendPart(options: MatrixAppendBeeperAIRunPartOptions): Promise; error(options: MatrixErrorBeeperAIRunOptions): Promise; finish(options: MatrixFinishBeeperAIRunOptions): Promise; start(options: MatrixStartBeeperAIRunStreamOptions): Promise; diff --git a/packages/pickle/src/client.test.ts b/packages/pickle/src/client.test.ts index 668d99c..fe47783 100644 --- a/packages/pickle/src/client.test.ts +++ b/packages/pickle/src/client.test.ts @@ -961,6 +961,7 @@ describe("createMatrixClient", () => { const calls = installRuntime({ append_beeper_ai_run_event: { body: "hello", events: [], finalAIMessage: {}, initialAIMessage: {}, messageId: "msg", metadata: {}, runId: "run", threadId: "thread" }, append_beeper_ai_run_stream_event: { body: "hello", descriptor: {}, eventId: "$stream", events: [], finalAIMessage: {}, initialAIMessage: {}, messageId: "msg", metadata: {}, roomId: "!room", runId: "run", threadId: "thread" }, + append_beeper_ai_run_stream_part: { body: "hello", descriptor: {}, eventId: "$stream", events: [], finalAIMessage: {}, initialAIMessage: {}, messageId: "msg", metadata: {}, roomId: "!room", runId: "run", threadId: "thread" }, begin_beeper_ai_run: { body: "", events: [], finalAIMessage: {}, initialAIMessage: {}, messageId: "msg", metadata: {}, runId: "run", threadId: "thread" }, delete_beeper_ai_run: {}, error_beeper_ai_run: { body: "failed", events: [], finalAIMessage: {}, initialAIMessage: {}, messageId: "msg", metadata: {}, runId: "run", threadId: "thread" }, @@ -989,6 +990,7 @@ describe("createMatrixClient", () => { event: { delta: "hello", messageId: "msg", type: "TEXT_MESSAGE_CONTENT" }, runId: "run", }); + await client.beeper.aiRunStreams.appendPart({ kind: "text", runId: "run", text: "hello" }); await client.beeper.aiRunStreams.finish({ finishReason: "stop", runId: "run" }); await client.beeper.aiRunStreams.error({ message: "failed", runId: "run", type: "error" }); @@ -1001,6 +1003,7 @@ describe("createMatrixClient", () => { "delete_beeper_ai_run", "start_beeper_ai_run_stream", "append_beeper_ai_run_stream_event", + "append_beeper_ai_run_stream_part", "finish_beeper_ai_run_stream", "error_beeper_ai_run_stream", ]); @@ -1017,8 +1020,9 @@ describe("createMatrixClient", () => { event: { delta: "hello", messageId: "msg", type: "TEXT_MESSAGE_CONTENT" }, runId: "run", }); - expect(calls[8]?.payload).toEqual({ finishReason: "stop", runId: "run" }); - expect(calls[9]?.payload).toEqual({ message: "failed", runId: "run", type: "error" }); + expect(calls[8]?.payload).toEqual({ kind: "text", runId: "run", text: "hello" }); + expect(calls[9]?.payload).toEqual({ finishReason: "stop", runId: "run" }); + expect(calls[10]?.payload).toEqual({ message: "failed", runId: "run", type: "error" }); }); it("keeps accumulated UI message parts in the Beeper final edit", async () => { diff --git a/packages/pickle/src/client.ts b/packages/pickle/src/client.ts index 3879450..2da0847 100644 --- a/packages/pickle/src/client.ts +++ b/packages/pickle/src/client.ts @@ -95,6 +95,7 @@ class DefaultMatrixClient implements MatrixClient { }, aiRunStreams: { appendEvent: (opts) => this.#withCore((core) => core.appendBeeperAIRunStreamEvent(stripUndefined(opts))), + appendPart: (opts) => this.#withCore((core) => core.appendBeeperAIRunStreamPart(stripUndefined(opts))), error: (opts) => this.#withCore((core) => core.errorBeeperAIRunStream(stripUndefined(opts))), finish: (opts) => this.#withCore((core) => core.finishBeeperAIRunStream(stripUndefined(opts))), start: (opts) => this.#withCore((core) => core.startBeeperAIRunStream(stripUndefined(opts))), diff --git a/packages/pickle/src/generated-runtime-operations.ts b/packages/pickle/src/generated-runtime-operations.ts index 1ae8c3d..9a3ad47 100644 --- a/packages/pickle/src/generated-runtime-operations.ts +++ b/packages/pickle/src/generated-runtime-operations.ts @@ -3,6 +3,7 @@ import type { MatrixAccountDataResult, MatrixAppendBeeperAIRunEventOptions, + MatrixAppendBeeperAIRunPartOptions, MatrixApplySyncResponseOptions, MatrixAppserviceBatchSendOptions, MatrixAppserviceBatchSendResult, @@ -138,6 +139,7 @@ export interface MatrixCoreOperations { deleteBeeperAIRun(options: MatrixDeleteBeeperAIRunOptions): Promise; startBeeperAIRunStream(options: MatrixStartBeeperAIRunStreamOptions): Promise; appendBeeperAIRunStreamEvent(options: MatrixAppendBeeperAIRunEventOptions): Promise; + appendBeeperAIRunStreamPart(options: MatrixAppendBeeperAIRunPartOptions): Promise; finishBeeperAIRunStream(options: MatrixFinishBeeperAIRunOptions): Promise; errorBeeperAIRunStream(options: MatrixErrorBeeperAIRunOptions): Promise; setTyping(options: MatrixTypingOptions): Promise; @@ -341,6 +343,10 @@ export abstract class MatrixCoreOperationCaller implements MatrixCoreOperations return this.call("append_beeper_ai_run_stream_event", options); } + appendBeeperAIRunStreamPart(options: MatrixAppendBeeperAIRunPartOptions): Promise { + return this.call("append_beeper_ai_run_stream_part", options); + } + finishBeeperAIRunStream(options: MatrixFinishBeeperAIRunOptions): Promise { return this.call("finish_beeper_ai_run_stream", options); } diff --git a/packages/pickle/src/generated-runtime-types.ts b/packages/pickle/src/generated-runtime-types.ts index d642cd5..698f6bf 100644 --- a/packages/pickle/src/generated-runtime-types.ts +++ b/packages/pickle/src/generated-runtime-types.ts @@ -139,6 +139,47 @@ export interface MatrixAppendBeeperAIRunEventOptions { event: { [key: string]: unknown }; runId: string; } +export interface MatrixBeeperAIRunPartOptions { + activityType?: string; + aggregated?: string; + approval?: unknown; + command?: string; + completedAtMs?: number /* int64 */; + content?: { [key: string]: unknown }; + cwd?: string; + description?: string; + delta?: unknown; + details?: unknown; + dynamic?: boolean; + error?: unknown; + exitCode?: number /* int */; + index?: number /* int */; + input?: unknown; + kind: "text" | "reasoning" | "reasoning_end" | "tool_start" | "tool_input" | "tool_end" | "tool_result" | "activity" | "activity_delta" | "state_delta" | "state_snapshot" | "raw" | "custom" | string; + metadata?: { [key: string]: unknown }; + name?: string; + output?: unknown; + patch?: unknown; + preliminary?: boolean; + providerExecuted?: boolean; + replace?: boolean; + response?: unknown; + result?: unknown; + source?: string; + state?: string; + status?: string; + startedAtMs?: number /* int64 */; + stderr?: string; + stdout?: string; + text?: string; + title?: string; + toolCallId?: string; + toolName?: string; + value?: unknown; +} +export interface MatrixAppendBeeperAIRunPartOptions extends MatrixBeeperAIRunPartOptions { + runId: string; +} export interface MatrixFinishBeeperAIRunOptions { finishReason?: string; runId: string; @@ -165,6 +206,8 @@ export interface MatrixBeeperAIRunSnapshot { threadId: string; } export interface MatrixStartBeeperAIRunStreamOptions extends MatrixBeginBeeperAIRunOptions { + initialEvents?: Array<{ [key: string]: unknown }>; + initialParts?: MatrixBeeperAIRunPartOptions[]; roomId: string; streamType?: string; subscribers?: MatrixBeeperStreamSubscriber[]; diff --git a/packages/pickle/src/index.ts b/packages/pickle/src/index.ts index 759fb2e..6d2f7d4 100644 --- a/packages/pickle/src/index.ts +++ b/packages/pickle/src/index.ts @@ -37,7 +37,9 @@ export type { MatrixAppserviceSendMessageOptions, MatrixAppserviceUserOptions, MatrixAppendBeeperAIRunEventOptions, + MatrixAppendBeeperAIRunPartOptions, MatrixBeginBeeperAIRunOptions, + MatrixBeeperAIRunPartOptions, MatrixBeeperAIRunSnapshot, MatrixBeeperAIRunStreamResult, MatrixDeleteBeeperAIRunOptions, diff --git a/packages/pickle/src/runtime-types.ts b/packages/pickle/src/runtime-types.ts index 3f9d20b..b483b3b 100644 --- a/packages/pickle/src/runtime-types.ts +++ b/packages/pickle/src/runtime-types.ts @@ -26,9 +26,11 @@ export type { MatrixAppserviceSendMessageOptions, MatrixAppserviceUserOptions, MatrixAppendBeeperAIRunEventOptions, + MatrixAppendBeeperAIRunPartOptions, MatrixApplySyncResponseOptions, MatrixBanUserOptions, MatrixBeginBeeperAIRunOptions, + MatrixBeeperAIRunPartOptions, MatrixBeeperAIRunSnapshot, MatrixBeeperAIRunStreamResult, MatrixCoreInitOptions, diff --git a/scripts/openclaw-crabpot-full-test.mjs b/scripts/openclaw-crabpot-full-test.mjs new file mode 100644 index 0000000..8503e3c --- /dev/null +++ b/scripts/openclaw-crabpot-full-test.mjs @@ -0,0 +1,49 @@ +import { access } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const crabpotDir = resolve(process.env.CRABPOT_DIR ?? resolve(root, "..", "crabpot")); + +if (!await exists(resolve(crabpotDir, "package.json"))) { + console.error(`Missing Crabpot checkout at ${crabpotDir}`); + console.error(""); + console.error("Set it up with:"); + console.error(" git clone https://github.com/openclaw/crabpot.git ../crabpot"); + console.error(" npm --prefix ../crabpot install"); + console.error(" npm --prefix ../crabpot test"); + console.error(""); + console.error("Crabpot also expects an OpenClaw checkout at ../openclaw by default."); + console.error("Override the Crabpot location with CRABPOT_DIR=/path/to/crabpot."); + process.exit(1); +} + +console.log(`Running OpenClaw plugin compatibility tests in ${crabpotDir}`); +const child = spawn("npm", ["run", "check"], { + cwd: crabpotDir, + env: process.env, + stdio: "inherit", +}); + +child.on("exit", (code, signal) => { + if (signal) { + console.error(`Crabpot check terminated by ${signal}`); + process.exit(1); + } + process.exit(code ?? 1); +}); + +child.on("error", (error) => { + console.error(`Failed to run Crabpot check: ${error.message}`); + process.exit(1); +}); + +async function exists(file) { + try { + await access(file); + return true; + } catch { + return false; + } +} From 28cac3c2edc1f27fbbf4de4e85172d96f000536b Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Tue, 2 Jun 2026 05:33:16 +0200 Subject: [PATCH 47/56] Push ghost profiles through bridgev2 appservice intents --- OPENCLAW_BEEPER_HANDOFF.md | 303 ++++++++++++++++++ examples/dummybridge/src/connector.ts | 2 +- packages/bridge/package.json | 4 + packages/bridge/src/beeper.ts | 5 + packages/bridge/src/bridge.test.ts | 118 ++++++- packages/bridge/src/bridge.ts | 148 ++++++++- packages/bridge/src/events.ts | 4 +- packages/bridge/src/index.ts | 1 - packages/bridge/src/types.ts | 3 + packages/bridge/tsdown.config.ts | 2 +- packages/bridge/vitest.config.ts | 1 + packages/openclaw/src/appservice.ts | 2 +- .../src/beeper-channel-runtime.test.ts | 49 +-- .../openclaw/src/beeper-channel-runtime.ts | 50 ++- packages/openclaw/src/beeper-setup.ts | 15 +- packages/openclaw/src/bridge-agent.ts | 1 + packages/openclaw/src/cli.ts | 3 +- packages/openclaw/src/connector.test.ts | 59 ++++ packages/openclaw/src/connector.ts | 37 ++- .../openclaw/src/openclaw-runtime.test.ts | 142 ++++---- packages/openclaw/src/openclaw-runtime.ts | 10 + packages/openclaw/src/rooms.test.ts | 56 +--- packages/openclaw/src/rooms.ts | 51 +-- packages/openclaw/src/setup.test.ts | 73 ++++- packages/openclaw/src/setup.ts | 145 ++++++--- packages/openclaw/vitest.config.ts | 2 + tsconfig.base.json | 2 + 27 files changed, 972 insertions(+), 316 deletions(-) create mode 100644 OPENCLAW_BEEPER_HANDOFF.md diff --git a/OPENCLAW_BEEPER_HANDOFF.md b/OPENCLAW_BEEPER_HANDOFF.md new file mode 100644 index 0000000..9cc19f0 --- /dev/null +++ b/OPENCLAW_BEEPER_HANDOFF.md @@ -0,0 +1,303 @@ +# OpenClaw Beeper Handoff + +Date: 2026-06-02 +Workspace: `/Users/batuhan/Projects/pickle` + +## Installed Locations + +- Pickle/OpenClaw bridge workspace: `/Users/batuhan/Projects/pickle` +- Live installed OpenClaw plugin: `/Users/batuhan/.openclaw/extensions/beeper` +- OpenClaw app source for reference: `/Users/batuhan/Projects/openclaw` +- ai-bridge source for reference and shared-package changes: `/Users/batuhan/Projects/ai-bridge` +- mautrix bridge examples/reference: `/Users/batuhan/Projects/mautrix` +- plugin validation harness: `/Users/batuhan/Projects/crabpot` + +Live channel status at handoff: + +```sh +openclaw channels status +``` + +Reports: + +- Gateway reachable. +- Beeper default: enabled, configured, running. +- Telegram default: enabled, configured, running, connected. This is unrelated to the Beeper/OpenClaw bridge work. + +## Original Product Goal + +Build `@beeper/openclaw` as a first-class Beeper plugin backed by Pickle and bridgev2 semantics. + +Target model: + +- `UserLogin`: one Beeper/OpenClaw account/device. +- `Ghost`: one global OpenClaw agent user. +- `Portal`: one Matrix/Beeper conversation. +- `Session`: OpenClaw runtime state attached to a portal only after the first real user turn. + +Expected behavior: + +- Each configured OpenClaw agent gets a stable global ghost: + `@_agent_:`. +- Each agent gets one welcome DM portal on connect. +- Users can start new DMs/groups with those ghost users. +- A new DM/group creates or claims a portal. +- The first real user message in that portal creates an OpenClaw session and persists `sessionKey` on the portal binding. +- Importing old OpenClaw sessions is explicitly later work and should not shape the core bridge. + +Constraints from Batuhan: + +- Use bridgev2/mautrix mental model as much as possible. +- Move as much logic as possible into Go and generated Go contracts. +- Reuse ai-bridge packages heavily, but do not import ai-bridge `internal` or connector-specific code. +- Keep code simple: no fake layers, no duplicate types, no barrel exports for convenience, no backcompat, no legacy migration baggage. +- Prefer deleting/collapsing code over preserving AI-generated parallel paths. +- Beeper-only setup. Do not expose homeserver/domain/token/appservice id as ordinary user settings. +- Product intent beats current code shape. + +## Current Implementation Status + +Important current git state: + +```sh +git status --short +``` + +At handoff, modified files include: + +- `packages/bridge/src/bridge.ts` +- `packages/bridge/src/bridge.test.ts` +- `packages/bridge/src/index.ts` +- `packages/openclaw/src/bridge-agent.ts` +- `packages/openclaw/src/connector.ts` +- `packages/openclaw/src/connector.test.ts` +- `packages/openclaw/src/openclaw-runtime.ts` + +Do not assume all modifications are from the last agent turn. The tree has been evolving across multiple turns. + +### ai-bridge Dependency + +Pickle native Go helpers are pinned to the latest local ai-bridge commit we used: + +```text +github.com/beeper/ai-bridge v0.0.0-20260602005818-ab83be648105 +``` + +Local ai-bridge HEAD: + +```text +ab83be648105 / ab83be64 Preserve tool metadata in final AI parts +``` + +This is in `packages/pickle/native/go.mod`. `go.sum` still contains the older pseudo-version too, which is normal unless cleaned by `go mod tidy`. + +### Streaming / Beeper AI + +Main stream logic lives in: + +- Go/native: `packages/pickle/native/internal/core/beeper_ai_run.go` +- TS bridge stream adapter: `packages/bridge/src/beeper-stream.ts` +- OpenClaw runtime mapper: `packages/openclaw/src/openclaw-runtime.ts` +- OpenClaw channel runtime: `packages/openclaw/src/beeper-channel-runtime.ts` + +Current state: + +- Native Go path uses ai-bridge writer/projection/finalization. +- Finalization and large final parts upload are in Go through ai-bridge projection helpers. +- Semantic parts include text, reasoning, tool start/input/result, activity, state, raw/custom events. +- Tool result mapping now prefers actual stdout/stderr/response over status wrapper objects. +- Working placeholder suppression exists in both text and activity paths. The latest edit suppresses activity events whose visible text is exactly `Working...`. +- Pending tools are waited on before finalization in the TS runtime path. +- Final projection clears ai-bridge `Working...` fallback bodies for empty streams. + +Known streaming gaps: + +- Need live Beeper Desktop confirmation that rotating progress verbs render exactly like ai-bridge. +- Reasoning/thinking tokens depend on what OpenClaw emits. The bridge now maps reasoning events, but if OpenClaw emits no reasoning stream, Beeper will show none. +- The current rich stream mapper is still partly TypeScript. The long-term direction is more generated Go contracts and less TS mapping. +- Need compare against ai-bridge stream semantics again before calling this done, especially sequence ordering and final replacement behavior. + +### Ghosts / Portals / Sessions + +Main files: + +- `packages/openclaw/src/connector.ts` +- `packages/openclaw/src/bridge-agent.ts` +- `packages/openclaw/src/registry.ts` +- `packages/openclaw/src/rooms.ts` + +Current state: + +- Agent ghosts are registered globally from OpenClaw agents. +- Agent contacts are exposed through contact list / identifier resolving. +- Each agent gets a welcome DM portal on connect. +- Welcome portal bindings are `kind: "agent"` with placeholder `sessionKey: "agent:"`. +- On first real user turn, `OpenClawMatrixBridgeAgent.ensureSession()` creates a real OpenClaw session and now changes the binding to `kind: "session"`. +- The first user message in a welcome room therefore transitions from agent/welcome binding to a real session binding. +- Users can resolve agent ghosts and create fresh DMs. + +Known portal/session gaps: + +- Need deeper audit against bridgev2 examples in WhatsApp/Telegram/Signal for exact portal claiming, invite/group handling, and room metadata lifecycle. +- Need live test starting a new DM/group with an agent ghost from Beeper Desktop. +- Need ensure reconnect does not create duplicate welcome DMs after an agent binding has become a session binding. Current code uses the agent binding as the welcome-room marker, so this area needs attention after the new transition. +- Need improve ghost avatar/name syncing from OpenClaw agent metadata and verify desktop displays them correctly. + +### Slash Commands + +ai-bridge handles slash commands before the AI turn and replies with a command notice. OpenClaw previously parsed slash commands but still passed the slash text to the agent as prompt text. + +Current state: + +- `packages/openclaw/src/matrix-parser.ts` parses slash commands. +- `packages/openclaw/src/connector.ts` now intercepts OpenClaw `/help` and `/session`. +- `/session` is sent as an `m.notice` from the agent ghost or service bot, not as an AI turn. +- `/session` in a welcome room reports that no real session has started yet and does not create one. +- Unknown slash commands still fall through to OpenClaw as agent text for now. + +Known command gaps: + +- Need implement real `/stop`/`/abort` only after finding or adding a real OpenClaw cancellation primitive. Do not fake cancellation. +- Need decide which commands belong to OpenClaw channel runtime vs bridge control. +- Need richer formatting if Beeper Desktop supports a better command-result surface than `m.notice` HTML. + +### Config / Setup + +Current public Beeper channel settings are intentionally minimal: + +- `enabled` +- `beeperEnv` with production as default + +Public schema: + +- `packages/openclaw/src/beeper-channel-config.schema.json` +- `packages/openclaw/openclaw.plugin.json` + +Hidden persisted setup still includes appservice/homeserver/tokens/device data under channel settings. Do not expose these as normal user settings. + +Login/setup direction: + +- Email login is default. +- Username/password is optional. +- Token auth should be removed from user-facing setup. +- The bridge owns and persists its Beeper device. +- Appservice / bridge id should be derived per device. Do not ask for values that can be derived. +- `mode: "self-hosted-appservice"` and `registrationUrl: "websocket"` should be hardcoded through bridgev2/Pickle defaults, not user-configurable. + +## Bridge Manager / Appservice Flow + +Bridge-manager helper code: + +- `packages/bridge/src/beeper.ts` +- Exports from `packages/bridge/src/index.ts` + +The helper mirrors useful `bbctl whoami/register` pieces: + +- `createBeeperBridgeManagerClient({ token })` +- `fetchBeeperBridges({ token })` +- `createBeeperAppService({ token, bridge })` +- `createBeeperAppServiceInit({ token, bridge })` + +Flow: + +1. Call Beeper API `https://api./whoami`. +2. Get username and bridge-manager/Hungryserv metadata. +3. Register or fetch appservice through Hungryserv: + `/_matrix/asmux/mxauth/appservice/:user/:bridge`. +4. Register with `self_hosted: true`, `receive_ephemeral: true`. +5. Post bridge state back to Beeper bridgebox with state events like `STARTING`, `RUNNING`, etc. +6. Produce `MatrixAppserviceInitOptions` with homeserver, homeserver domain, and registration tokens. + +Runtime startup: + +- OpenClaw channel startup is in `packages/openclaw/src/setup.ts`. +- It calls `startOpenClawBeeperBridge()` from `packages/openclaw/src/appservice.ts`. +- `startOpenClawBeeperBridge()` creates the Pickle bridge, starts it, and marks bridge state running. +- `packages/bridge/src/bridge.ts` boots Matrix, initializes appservice, loads persisted portals/logins, subscribes Matrix events, and starts websocket appservice transaction handling. +- If appservice registration URL is websocket/self-hosted, `AppserviceWebsocket` receives appservice transactions and feeds Matrix events into the bridge connector. + +## Restart / Live Sync Commands + +After changing `packages/openclaw`, rebuild and sync the installed plugin: + +```sh +pnpm --filter @beeper/openclaw build +rsync -a --delete --exclude node_modules /Users/batuhan/Projects/pickle/packages/openclaw/ /Users/batuhan/.openclaw/extensions/beeper/ +openclaw plugins registry --refresh +openclaw gateway restart +openclaw channels status +``` + +If native Go/Pickle code changes, build Pickle too: + +```sh +pnpm --filter @beeper/pickle build +pnpm --filter @beeper/openclaw build +rsync -a --delete --exclude node_modules /Users/batuhan/Projects/pickle/packages/openclaw/ /Users/batuhan/.openclaw/extensions/beeper/ +openclaw plugins registry --refresh +openclaw gateway restart +openclaw channels status +``` + +The `rsync --delete` command overwrites the live installed plugin from the workspace package while preserving `node_modules`. + +## Useful Validation Commands + +Focused tests that passed most recently: + +```sh +pnpm --filter @beeper/openclaw test -- src/connector.test.ts src/openclaw-runtime.test.ts +``` + +Result: 16 files passed, 129 tests passed. + +Native Go tests used earlier: + +```sh +cd /Users/batuhan/Projects/pickle/packages/pickle/native +go test ./internal/core -run 'TestBeeperAIRun|TestAppservice' +``` + +OpenClaw broader tests used earlier: + +```sh +pnpm --filter @beeper/openclaw test -- src/openclaw-extension.test.ts src/setup.test.ts src/config.test.ts src/beeper-setup.test.ts src/appservice.test.ts +pnpm --filter @beeper/openclaw build +``` + +Current warning: + +```sh +pnpm --filter @beeper/openclaw typecheck +``` + +Currently fails in `packages/bridge/src/bridge.ts` and `packages/bridge/src/events.ts` with exact optional property/type errors. This was observed after the latest slash/stream edits. Do not treat the typecheck baseline as clean until those bridge-package errors are handled. + +## Recent Decisions + +- Do not include Beeper account secrets in repo docs or config. Credentials were only provided in chat for live login. +- Do not expose homeserver, domain, hs/as token, appservice id, bridge id, Matrix device id, import/backfill, or approval behavior as public OpenClaw channel settings. +- Keep `additionalProperties: true` in the channel schema for hidden setup state, but public schema and manifest only advertise user-facing settings. +- Slash commands that are bridge commands should not enter the agent prompt. +- Do not implement `/stop` as a fake bridge notice. It needs a real OpenClaw cancel API. +- Preserve ai-bridge semantics for streaming and finalization; if behavior differs from ai-bridge, treat that as a bug unless product intent says otherwise. +- Use actual tool output in Beeper UI, not wrapper/status-only payloads. +- Welcome room is not a session until first real user turn. +- Reasoning is enabled for Beeper sessions by patching OpenClaw sessions with `reasoningLevel: "on"` where supported. + +## Highest Priority Next Gaps + +1. Fix `@beeper/openclaw typecheck` by cleaning the bridge package type errors. +2. Re-run build and sync live plugin. +3. Live-test from Beeper Desktop: + - fresh welcome DM per agent; + - first real message creates one session; + - `/session` is a notice and not an AI prompt; + - no visible `Working...` flash; + - tool parts stream in order; + - command output shows actual stdout/response; + - search/fetch/source results render with rich parts. +4. Compare connector lifecycle against `/Users/batuhan/Projects/mautrix` WhatsApp/Telegram/Signal examples and bridgev2 expectations. +5. Move more stream/finalization/contract logic into Go and generated types. +6. Add real cancellation support if OpenClaw exposes or can expose an active-run abort method. +7. Revisit welcome-room marker logic after `kind: "agent"` transitions to `kind: "session"` so reconnect does not create duplicate welcome rooms. diff --git a/examples/dummybridge/src/connector.ts b/examples/dummybridge/src/connector.ts index cc41997..fcc4cad 100644 --- a/examples/dummybridge/src/connector.ts +++ b/examples/dummybridge/src/connector.ts @@ -1,4 +1,4 @@ -import { createRemoteMessage } from "@beeper/pickle-bridge"; +import { createRemoteMessage } from "@beeper/pickle-bridge/events"; import type { BridgeConfigPart, BridgeContext, diff --git a/packages/bridge/package.json b/packages/bridge/package.json index af7db7a..2a16c0a 100644 --- a/packages/bridge/package.json +++ b/packages/bridge/package.json @@ -28,6 +28,10 @@ "types": "./dist/beeper-stream.d.ts", "import": "./dist/beeper-stream.js" }, + "./events": { + "types": "./dist/events.d.ts", + "import": "./dist/events.js" + }, "./media-message": { "types": "./dist/media-message.d.ts", "import": "./dist/media-message.js" diff --git a/packages/bridge/src/beeper.ts b/packages/bridge/src/beeper.ts index ae80778..7b035de 100644 --- a/packages/bridge/src/beeper.ts +++ b/packages/bridge/src/beeper.ts @@ -1,4 +1,9 @@ import type { MatrixAppserviceInitOptions, MatrixAppserviceNamespace, MatrixAppserviceRegistration } from "@beeper/pickle"; +export { loginWithMatrixPassword } from "@beeper/pickle/auth"; +export type { MatrixPasswordAuthOptions } from "@beeper/pickle/auth"; +export { createBeeperLogin } from "@beeper/pickle/beeper/auth"; +export type { BeeperAuthOptions, BeeperEnvironment } from "@beeper/pickle/beeper/auth"; +export type { MatrixAppserviceInitOptions, MatrixAppserviceRegistration } from "@beeper/pickle"; export interface BeeperClientOptions { baseDomain?: string; diff --git a/packages/bridge/src/bridge.test.ts b/packages/bridge/src/bridge.test.ts index 225ce4e..f54e4fe 100644 --- a/packages/bridge/src/bridge.test.ts +++ b/packages/bridge/src/bridge.test.ts @@ -1,7 +1,7 @@ import type { MatrixClient, MatrixClientEvent, MatrixMessageEvent, MatrixSubscription } from "@beeper/pickle"; import { describe, expect, it, vi } from "vitest"; import { RuntimeBridge } from "./bridge"; -import { createRemoteMessage } from "./events"; +import { createRemoteChatInfoChange, createRemoteMessage } from "./events"; import type { BridgeDataStore } from "./store"; import type { BridgeConnector, @@ -429,6 +429,100 @@ describe("RuntimeBridge", () => { expect(client.typing.set).toHaveBeenCalledWith({ roomId: "!room:example", timeoutMs: 5000, typing: true }); }); + it("applies remote chat info changes as bridge-owned Matrix room state", async () => { + const client = createFakeMatrixClient(); + const dataStore = createFakeBridgeDataStore(); + const connector = createFakeConnector(createFakeNetworkAPI()); + const bridge = new RuntimeBridge({ connector, dataStore, matrix: matrixConfig() }, client); + const login: UserLogin = { id: "login:a" }; + const portalKey = { id: "remote-room", receiver: login.id }; + + await bridge.start(); + bridge.registerPortal({ + avatar: { mxc: "mxc://example/old" }, + id: "remote-room", + mxid: "!room:example", + name: "Old", + portalKey, + topic: "Old topic", + }); + bridge.queueRemoteEvent(login, createRemoteChatInfoChange({ + chatInfoChange: { + chatInfo: { + avatar: { mxc: "mxc://example/new" }, + name: "New name", + topic: "New topic", + }, + }, + portalKey, + sender: { isFromMe: false, sender: "remote-user" }, + })); + await bridge.flushRemoteEvents(); + + expect(client.rooms.sendStateEvent).toHaveBeenCalledWith({ + content: { name: "New name" }, + eventType: "m.room.name", + roomId: "!room:example", + stateKey: "", + }); + expect(client.rooms.sendStateEvent).toHaveBeenCalledWith({ + content: { topic: "New topic" }, + eventType: "m.room.topic", + roomId: "!room:example", + stateKey: "", + }); + expect(client.rooms.sendStateEvent).toHaveBeenCalledWith({ + content: { url: "mxc://example/new" }, + eventType: "m.room.avatar", + roomId: "!room:example", + stateKey: "", + }); + expect(bridge.getPortal(portalKey)).toMatchObject({ + avatar: { mxc: "mxc://example/new" }, + name: "New name", + topic: "New topic", + }); + expect(dataStore.setPortal).toHaveBeenCalledWith(expect.objectContaining({ + avatar: { mxc: "mxc://example/new" }, + name: "New name", + topic: "New topic", + })); + }); + + it("exposes generic bridge-owned room state reads and writes", async () => { + const client = createFakeMatrixClient(); + const connector = createFakeConnector(createFakeNetworkAPI()); + const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, client); + + await bridge.start(); + await expect(bridge.roomState.get({ + eventType: "com.example.state", + roomId: "!room:example", + })).resolves.toMatchObject({ + content: { ok: true }, + eventType: "com.example.state", + roomId: "!room:example", + stateKey: "", + }); + await bridge.roomState.set({ + content: { model: "beeper/openai/gpt-5.5" }, + eventType: "com.beeper.ai.model", + roomId: "!room:example", + }); + + expect(client.rooms.getStateEvent).toHaveBeenCalledWith({ + eventType: "com.example.state", + roomId: "!room:example", + stateKey: "", + }); + expect(client.rooms.sendStateEvent).toHaveBeenCalledWith({ + content: { model: "beeper/openai/gpt-5.5" }, + eventType: "com.beeper.ai.model", + roomId: "!room:example", + stateKey: "", + }); + }); + it("updates bundled Matrix event targets through bridgev2 remote events", async () => { const client = createFakeMatrixClient(); const connector = createFakeConnector(createFakeNetworkAPI()); @@ -1359,9 +1453,9 @@ function createFakeNetworkAPI(): FakeNetworkAPI { handleMatrixReaction: vi.fn(), handleMatrixReactionRemove: vi.fn(), handleMatrixReadReceipt: vi.fn(), - handleMatrixRoomAvatar: vi.fn(), - handleMatrixRoomName: vi.fn(), - handleMatrixRoomTopic: vi.fn(), + handleMatrixRoomAvatar: vi.fn(async () => true), + handleMatrixRoomName: vi.fn(async () => true), + handleMatrixRoomTopic: vi.fn(async () => true), handleMatrixTyping: vi.fn(), }; } @@ -1497,7 +1591,21 @@ function createFakeMatrixClient(): MatrixClient & { subscription: MatrixSubscrip receipts: { send: vi.fn(async () => undefined), }, - rooms: {} as MatrixClient["rooms"], + rooms: { + getStateEvent: vi.fn(async (options) => ({ + content: { ok: true }, + eventId: "$state", + eventType: options.eventType, + raw: {}, + roomId: options.roomId, + stateKey: options.stateKey ?? "", + })), + sendStateEvent: vi.fn(async (options) => ({ + eventId: "$state-sent", + raw: {}, + roomId: options.roomId, + })), + } as unknown as MatrixClient["rooms"], streams: {} as MatrixClient["streams"], subscribe: vi.fn(async (_filter, _handler: (event: MatrixClientEvent) => void | Promise) => subscription), subscription, diff --git a/packages/bridge/src/bridge.ts b/packages/bridge/src/bridge.ts index fffc726..6b0e6b6 100644 --- a/packages/bridge/src/bridge.ts +++ b/packages/bridge/src/bridge.ts @@ -1,6 +1,7 @@ import { createMatrixClient } from "@beeper/pickle"; import type { MatrixAppserviceBatchSendOptions, MatrixAppserviceInitOptions, MatrixClient, MatrixClientEvent, MatrixMessageEvent, MatrixReactionEvent, MatrixSubscription, SentEvent } from "@beeper/pickle"; import { AppserviceWebsocket, type HTTPProxyRequest, type HTTPProxyResponse } from "./appservice-websocket"; +import { BeeperTurnStream, type CreateBeeperTurnStreamOptions } from "./beeper-stream"; import { createBeeperAppServiceInit } from "./beeper"; import { createRemoteMessage } from "./events"; import { getOrCreateAppserviceDeviceId } from "./store"; @@ -31,6 +32,9 @@ import type { BridgeSendMediaOptions, BridgeState, BridgeStatus, + BridgeRoomStateAPI, + ChatInfo, + ChatInfoChange, DownloadMediaOptions, DownloadMediaResult, Ghost, @@ -48,6 +52,9 @@ import type { MatrixRoomAvatar, MatrixRoomName, MatrixRoomTopic, + RoomAvatarHandlingNetworkAPI, + RoomNameHandlingNetworkAPI, + RoomTopicHandlingNetworkAPI, EventSender, MatrixIntent, MatrixCommand, @@ -105,6 +112,7 @@ import type { type GenericMatrixEvent = Extract }> & { kind: string; + sender?: { isMe?: boolean; userId?: string }; stateKey?: string; unsigned?: Record; }; @@ -183,6 +191,7 @@ function requiredAccount(options: CreateBeeperBridgeOptions) { export class RuntimeBridge implements PickleBridge { readonly connector: CreateBridgeOptions["connector"]; + readonly roomState: BridgeRoomStateAPI; readonly #appserviceOptions: CreateBridgeOptions["appservice"]; readonly #beeperOptions: BridgeBeeperOptions | undefined; readonly #dataStore: CreateBridgeOptions["dataStore"]; @@ -216,6 +225,23 @@ export class RuntimeBridge implements PickleBridge { this.#dataStore = options.dataStore; this.#log = options.log ?? defaultLogger; this.#matrixClient = client; + this.roomState = { + get: (state) => this.#matrixClient.rooms.getStateEvent({ + eventType: state.eventType, + roomId: state.roomId, + stateKey: state.stateKey ?? "", + }), + set: (state) => { + const roomId = state.roomId ?? state.portal?.mxid; + if (!roomId) throw new Error("Room state writes require a room id or a portal with a Matrix room"); + return this.#matrixClient.rooms.sendStateEvent({ + content: state.content, + eventType: state.eventType, + roomId, + stateKey: state.stateKey ?? "", + }); + }, + }; } get client(): MatrixClient | null { @@ -310,6 +336,13 @@ export class RuntimeBridge implements PickleBridge { return room; } + createBeeperTurnStream(options: Omit): BeeperTurnStream { + return new BeeperTurnStream({ + ...options, + client: this.#matrixClient, + }); + } + async createPortalRoom(options: BridgeCreatePortalRoomOptions): Promise { this.#requestContext(); const invite = autoJoinInvite(options.invite, this.#beeperOptions?.ownerUserId); @@ -334,11 +367,15 @@ export class RuntimeBridge implements PickleBridge { userId: options.userId, })); const portal: Portal = { + ...(info.avatar ? { avatar: info.avatar } : {}), id: options.portalKey.id, metadata: options.metadata, mxid: result.roomId, + ...(name ? { name } : {}), portalKey: options.portalKey, ...(options.portalKey.receiver ? { receiver: options.portalKey.receiver } : {}), + ...(options.roomType ? { roomType: options.roomType } : {}), + ...(topic ? { topic } : {}), }; this.registerPortal(portal); return portal; @@ -1321,48 +1358,72 @@ export class RuntimeBridge implements PickleBridge { async #dispatchMatrixRoomName(event: GenericMatrixEvent): Promise { const roomId = event.roomId; if (!roomId) return { dispatched: false, handlers: 0, kind: event.kind }; + if (event.sender?.isMe || event.sender?.userId === this.#ownUserId) { + return { dispatched: false, handlers: 0, kind: event.kind, roomId }; + } + const name = stringValue(event.content.name); + const portal = this.#portalForRoom(roomId); + if (portal.name === name) return { dispatched: false, handlers: 0, kind: event.kind, roomId }; const msg: MatrixRoomName = stripUndefined({ - name: stringValue(event.content.name), - portal: this.#portalForRoom(roomId), + name, + portal, }); let handlers = 0; + let accepted = false; for (const client of this.#networkClientsForPortal(msg.portal)) { if (!hasMethod(client, "handleMatrixRoomName")) continue; handlers += 1; - await client.handleMatrixRoomName(this.#requestContext(), msg); + accepted = await (client as RoomNameHandlingNetworkAPI).handleMatrixRoomName(this.#requestContext(), msg) || accepted; } + if (accepted && name !== undefined) await this.#updatePortalInfo(portal, { name }); return { dispatched: handlers > 0, handlers, kind: event.kind, roomId }; } async #dispatchMatrixRoomTopic(event: GenericMatrixEvent): Promise { const roomId = event.roomId; if (!roomId) return { dispatched: false, handlers: 0, kind: event.kind }; + if (event.sender?.isMe || event.sender?.userId === this.#ownUserId) { + return { dispatched: false, handlers: 0, kind: event.kind, roomId }; + } + const topic = stringValue(event.content.topic); + const portal = this.#portalForRoom(roomId); + if (portal.topic === topic) return { dispatched: false, handlers: 0, kind: event.kind, roomId }; const msg: MatrixRoomTopic = stripUndefined({ - portal: this.#portalForRoom(roomId), - topic: stringValue(event.content.topic), + portal, + topic, }); let handlers = 0; + let accepted = false; for (const client of this.#networkClientsForPortal(msg.portal)) { if (!hasMethod(client, "handleMatrixRoomTopic")) continue; handlers += 1; - await client.handleMatrixRoomTopic(this.#requestContext(), msg); + accepted = await (client as RoomTopicHandlingNetworkAPI).handleMatrixRoomTopic(this.#requestContext(), msg) || accepted; } + if (accepted && topic !== undefined) await this.#updatePortalInfo(portal, { topic }); return { dispatched: handlers > 0, handlers, kind: event.kind, roomId }; } async #dispatchMatrixRoomAvatar(event: GenericMatrixEvent): Promise { const roomId = event.roomId; if (!roomId) return { dispatched: false, handlers: 0, kind: event.kind }; + if (event.sender?.isMe || event.sender?.userId === this.#ownUserId) { + return { dispatched: false, handlers: 0, kind: event.kind, roomId }; + } + const avatarUrl = stringValue(event.content.url); + const portal = this.#portalForRoom(roomId); + if ((portal.avatar?.mxc ?? portal.avatar?.url) === avatarUrl) return { dispatched: false, handlers: 0, kind: event.kind, roomId }; const msg: MatrixRoomAvatar = stripUndefined({ - avatarUrl: stringValue(event.content.url), - portal: this.#portalForRoom(roomId), + avatarUrl, + portal, }); let handlers = 0; + let accepted = false; for (const client of this.#networkClientsForPortal(msg.portal)) { if (!hasMethod(client, "handleMatrixRoomAvatar")) continue; handlers += 1; - await client.handleMatrixRoomAvatar(this.#requestContext(), msg); + accepted = await (client as RoomAvatarHandlingNetworkAPI).handleMatrixRoomAvatar(this.#requestContext(), msg) || accepted; } + if (accepted) await this.#updatePortalInfo(portal, { avatar: avatarUrl ? { mxc: avatarUrl } : { remove: true } }); return { dispatched: handlers > 0, handlers, kind: event.kind, roomId }; } @@ -1705,11 +1766,65 @@ export class RuntimeBridge implements PickleBridge { const portal = this.#portalForRemoteEvent(event); if (!portal) return; const change = await event.getChatInfoChange(this.#requestContext()); - const metadata = { - ...(typeof portal.metadata === "object" && portal.metadata !== null ? portal.metadata : {}), - chatInfo: change, - }; - await this.setPortalMetadata(portal.portalKey, metadata); + await this.#processChatInfoChange(portal, change); + } + + async #processChatInfoChange(portal: Portal, change: ChatInfoChange): Promise { + const info = change.chatInfo; + if (info) { + await this.#sendPortalInfoState(portal, info); + await this.#updatePortalInfo(portal, info); + } + if (change.memberChanges) { + const metadata = { + ...metadataRecord(portal.metadata), + memberChanges: change.memberChanges, + }; + await this.setPortalMetadata(portal.portalKey, metadata); + } + } + + async #sendPortalInfoState(portal: Portal, info: ChatInfo): Promise { + if (!portal.mxid) return; + if (info.name !== undefined && info.name !== portal.name) { + await this.roomState.set({ + content: { name: info.name }, + eventType: "m.room.name", + portal, + }); + } + if (info.topic !== undefined && info.topic !== portal.topic) { + await this.roomState.set({ + content: { topic: info.topic }, + eventType: "m.room.topic", + portal, + }); + } + if (info.avatar !== undefined && avatarStateValue(info.avatar) !== avatarStateValue(portal.avatar)) { + await this.roomState.set({ + content: { url: info.avatar.remove ? "" : avatarStateValue(info.avatar) ?? "" }, + eventType: "m.room.avatar", + portal, + }); + } + } + + async #updatePortalInfo(portal: Portal, info: ChatInfo): Promise { + const updated = stripUndefined({ + ...portal, + ...(info.avatar !== undefined ? { avatar: info.avatar.remove ? undefined : info.avatar } : {}), + ...(info.name !== undefined ? { name: info.name } : {}), + ...(info.roomType !== undefined ? { roomType: info.roomType } : {}), + ...(info.topic !== undefined ? { topic: info.topic } : {}), + metadata: { + ...metadataRecord(portal.metadata), + ...(info.canBackfill !== undefined ? { canBackfill: info.canBackfill } : {}), + ...(info.extraUpdates !== undefined ? { extraUpdates: info.extraUpdates } : {}), + ...(info.members !== undefined ? { members: info.members } : {}), + }, + }) as Portal; + this.registerPortal(updated); + return updated; } async #handleRemoteChatDelete(event: RemoteChatDelete): Promise { @@ -1955,6 +2070,11 @@ function eventType(event: MatrixClientEvent): string | undefined { return "type" in event && typeof event.type === "string" ? event.type : undefined; } +function avatarStateValue(avatar: { mxc?: string; remove?: boolean; url?: string } | undefined): string | undefined { + if (!avatar || avatar.remove) return ""; + return avatar.mxc ?? avatar.url; +} + function isMatrixEditEvent(event: MatrixMessageEvent): boolean { return Boolean(event.edited && matrixEditTargetEventId(event)); } diff --git a/packages/bridge/src/events.ts b/packages/bridge/src/events.ts index 193a685..fda7a99 100644 --- a/packages/bridge/src/events.ts +++ b/packages/bridge/src/events.ts @@ -8,6 +8,8 @@ import type { MessageID, Portal, RemoteChatInfoChange, + RemoteEventWithStreamOrder, + RemoteEventWithTimestamp, RemoteEventType, RemoteMessage, RemoteMessageWithTransactionID, @@ -53,7 +55,7 @@ export function createRemoteMessage(options: CreateRemoteMessageOptions): }; } -export function createRemoteChatInfoChange(options: CreateRemoteChatInfoChangeOptions): RemoteChatInfoChange { +export function createRemoteChatInfoChange(options: CreateRemoteChatInfoChangeOptions): RemoteChatInfoChange & RemoteEventWithTimestamp & RemoteEventWithStreamOrder { const timestamp = options.timestamp ?? new Date(); const streamOrder = options.streamOrder ?? timestamp.getTime(); return { diff --git a/packages/bridge/src/index.ts b/packages/bridge/src/index.ts index 14ad498..c6ea4aa 100644 --- a/packages/bridge/src/index.ts +++ b/packages/bridge/src/index.ts @@ -8,7 +8,6 @@ import type { CreateNodeBeeperBridgeOptions, CreateNodeBridgeOptions, PickleBrid export { createBridgeDataStore, MatrixBridgeDataStore } from "./store"; export { BeeperBridgeManagerClient, createBeeperAppService, createBeeperAppServiceInit, createBeeperBridgeManagerClient, fetchBeeperBridges } from "./beeper"; -export { createRemoteMessage } from "./events"; export type * from "./beeper"; export type * from "./store"; export type * from "./types"; diff --git a/packages/bridge/src/types.ts b/packages/bridge/src/types.ts index fb6d0e2..4766e27 100644 --- a/packages/bridge/src/types.ts +++ b/packages/bridge/src/types.ts @@ -19,6 +19,7 @@ import type { UserInfo as MatrixUserInfo, } from "@beeper/pickle"; import type { BridgeDataStore } from "./store"; +import type { BeeperTurnStream, CreateBeeperTurnStreamOptions } from "./beeper-stream"; export type BridgeID = string; export type UserID = string; @@ -516,6 +517,7 @@ export interface PickleBridge { acceptMessageRequest(portalKey: PortalKey): Promise; createLogin(user: BridgeUser, flowId: string): Promise; createManagementRoom(options: BridgeCreateManagementRoomOptions): Promise; + createBeeperTurnStream(options: Omit): BeeperTurnStream; backfill(options: BridgeBackfillOptions): Promise; backfillMessages(login: UserLogin, params: FetchMessagesParams): Promise; backfillPortal(login: UserLogin, portal: PortalReference, params?: Omit): Promise; @@ -727,6 +729,7 @@ export interface MatrixCommandResponse { export type { MatrixAppserviceInitOptions, MatrixAppserviceSendMessageOptions, + SentEvent, }; export interface MatrixDispatchResult { diff --git a/packages/bridge/tsdown.config.ts b/packages/bridge/tsdown.config.ts index 952460e..2fcb443 100644 --- a/packages/bridge/tsdown.config.ts +++ b/packages/bridge/tsdown.config.ts @@ -13,7 +13,7 @@ export default defineConfig({ dts: ".d.ts", }), deps: { - neverBundle: ["@beeper/pickle", "@beeper/pickle/auth", "@beeper/pickle/node", "@beeper/pickle-state-file", "ws"], + neverBundle: ["@beeper/pickle", "@beeper/pickle/auth", "@beeper/pickle/beeper/auth", "@beeper/pickle/node", "@beeper/pickle-state-file", "ws"], }, target: false, }); diff --git a/packages/bridge/vitest.config.ts b/packages/bridge/vitest.config.ts index 092149c..8ea6f3e 100644 --- a/packages/bridge/vitest.config.ts +++ b/packages/bridge/vitest.config.ts @@ -4,6 +4,7 @@ export default defineProject({ resolve: { alias: { "@beeper/pickle/auth": new URL("../pickle/src/auth.ts", import.meta.url).pathname, + "@beeper/pickle/beeper/auth": new URL("../pickle/src/beeper/auth.ts", import.meta.url).pathname, "@beeper/pickle/node": new URL("../pickle/src/node.ts", import.meta.url).pathname, "@beeper/pickle": new URL("../pickle/src/index.ts", import.meta.url).pathname, }, diff --git a/packages/openclaw/src/appservice.ts b/packages/openclaw/src/appservice.ts index bbb7994..807ba46 100644 --- a/packages/openclaw/src/appservice.ts +++ b/packages/openclaw/src/appservice.ts @@ -1,9 +1,9 @@ -import type { MatrixAppserviceInitOptions, MatrixAppserviceRegistration } from "@beeper/pickle"; import { createBeeperBridge, type CreateNodeBeeperBridgeOptions, type PickleBridge, } from "@beeper/pickle-bridge"; +import type { MatrixAppserviceInitOptions, MatrixAppserviceRegistration } from "@beeper/pickle-bridge/beeper"; import { beeperBaseDomain } from "./beeper-setup"; import { DEFAULT_BEEPER_BRIDGE_TYPE } from "./ids"; import { createOpenClawConnector, type OpenClawConnectorOptions } from "./connector"; diff --git a/packages/openclaw/src/beeper-channel-runtime.test.ts b/packages/openclaw/src/beeper-channel-runtime.test.ts index 13858c2..907af98 100644 --- a/packages/openclaw/src/beeper-channel-runtime.test.ts +++ b/packages/openclaw/src/beeper-channel-runtime.test.ts @@ -5,6 +5,7 @@ import { requireBeeperChannelRuntimeForHost, setBeeperChannelRuntimeForHost, } from "./beeper-channel-runtime"; +import { BeeperTurnStream } from "@beeper/pickle-bridge/beeper-stream"; function createClient() { return { @@ -102,11 +103,23 @@ function createStreamingClient() { }; } +function createBridge(client: ReturnType | ReturnType, queued: unknown[] = []) { + return { + createBeeperTurnStream: vi.fn((options) => new BeeperTurnStream({ + ...options, + client: client as never, + })), + flushRemoteEvents: vi.fn(async () => undefined), + getPortalByMXID: vi.fn(() => ({ portalKey: { id: "session:one", receiver: "openclaw:plugin" } })), + queueRemoteEvent: vi.fn((_login: unknown, event: unknown) => queued.push(event)), + uploadMedia: vi.fn((options: Parameters["media"]["upload"]>[0]) => client.media.upload(options)), + }; +} + describe("BeeperChannelRuntime", () => { it("requires bridge portal routing for outbound message operations", async () => { const client = createClient(); const runtime = new BeeperChannelRuntime({ - client: client as never, getAgents: () => [{ id: "codex", name: "Codex" }], }); @@ -118,14 +131,9 @@ describe("BeeperChannelRuntime", () => { it("queues Matrix event ids as bundled bridge update targets", 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 bridge = createBridge(client, queued); const runtime = new BeeperChannelRuntime({ bridge: bridge as never, - client: client as never, login: { id: "openclaw:plugin" }, }); @@ -145,14 +153,9 @@ describe("BeeperChannelRuntime", () => { 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 bridge = createBridge(client, queued); const runtime = new BeeperChannelRuntime({ bridge: bridge as never, - client: client as never, getBindingByRoom: () => ({ agentId: "codex", createdAt: 1, @@ -184,6 +187,10 @@ describe("BeeperChannelRuntime", () => { 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(bridge.uploadMedia).toHaveBeenCalledWith({ + bytes: new Uint8Array([1]), + filename: "a.txt", + }); expect(client.media.upload).toHaveBeenCalledWith({ bytes: new Uint8Array([1]), filename: "a.txt", @@ -218,18 +225,14 @@ describe("BeeperChannelRuntime", () => { 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) => + const bridge = createBridge(client, queued); + bridge.getPortalByMXID.mockImplementation((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" ? { @@ -259,8 +262,9 @@ describe("BeeperChannelRuntime", () => { it("starts native streams as the bound assistant ghost", async () => { const client = createStreamingClient(); + const bridge = createBridge(client); const runtime = new BeeperChannelRuntime({ - client: client as never, + bridge: bridge as never, getAgents: () => [{ agentId: "codex", displayName: "Codex", @@ -277,6 +281,7 @@ describe("BeeperChannelRuntime", () => { sessionKey: "agent:codex:desktop", updatedAt: 1, }), + login: { id: "openclaw:plugin" }, userId: "@bot:example", }); @@ -304,7 +309,7 @@ describe("BeeperChannelRuntime", () => { it("stores Beeper runtimes by OpenClaw host runtime", () => { const hostRuntime = {}; - const scopedRuntime = new BeeperChannelRuntime({ client: createClient() as never }); + const scopedRuntime = new BeeperChannelRuntime({}); setBeeperChannelRuntimeForHost(hostRuntime, scopedRuntime); diff --git a/packages/openclaw/src/beeper-channel-runtime.ts b/packages/openclaw/src/beeper-channel-runtime.ts index 77a66b4..4d26976 100644 --- a/packages/openclaw/src/beeper-channel-runtime.ts +++ b/packages/openclaw/src/beeper-channel-runtime.ts @@ -1,9 +1,8 @@ import { readFile } from "node:fs/promises"; import { randomUUID } from "node:crypto"; -import type { MatrixClient, SentEvent } from "@beeper/pickle"; import { - createRemoteMessage, type PickleBridge, + type ChatInfo, type Message, type PortalKey, type RemoteDeliveryReceipt, @@ -15,10 +14,12 @@ import { type RemoteReaction, type RemoteReactionRemove, type RemoteTyping, + type SentEvent, type UserLogin, } from "@beeper/pickle-bridge"; +import { createRemoteChatInfoChange, createRemoteMessage } from "@beeper/pickle-bridge/events"; import { BeeperTurnStream } from "@beeper/pickle-bridge/beeper-stream"; -import { uploadBridgeMediaMessage, type BridgeMediaKind } from "@beeper/pickle-bridge/media-message"; +import { bridgeMediaMessageContent, type BridgeMediaKind } from "@beeper/pickle-bridge/media-message"; import { AGUIEventType } from "./beeper-turn-events"; import type { OpenClawAgentContact, OpenClawSessionBinding } from "./types"; @@ -26,7 +27,6 @@ export const BEEPER_CHANNEL_RUNTIME_CONTEXT_CAPABILITY = "beeper.runtime"; export interface BeeperChannelRuntimeOptions { bridge?: PickleBridge; - client: MatrixClient; getAgents?: () => readonly OpenClawAgentContact[]; getBindingByRoom?: (roomId: string) => OpenClawSessionBinding | undefined; getBindingBySessionKey?: (sessionKey: string) => OpenClawSessionBinding | undefined; @@ -46,7 +46,6 @@ export interface BeeperOutboundMedia { } export class BeeperChannelRuntime { - readonly client: MatrixClient; readonly userId: string | undefined; #bridge: PickleBridge | undefined; #getAgents: () => readonly OpenClawAgentContact[]; @@ -59,7 +58,6 @@ export class BeeperChannelRuntime { constructor(options: BeeperChannelRuntimeOptions) { this.#bridge = options.bridge; - this.client = options.client; this.#getAgents = options.getAgents ?? (() => []); this.#getBindingByRoom = options.getBindingByRoom ?? (() => undefined); this.#getBindingBySessionKey = options.getBindingBySessionKey ?? (() => undefined); @@ -142,6 +140,18 @@ export class BeeperChannelRuntime { await this.#queueRemoteMarkUnread(options.roomId, options.eventId, options.unread); } + async setRoomName(options: { name: string; roomId: string }): Promise { + await this.#queueRemoteChatInfo(options.roomId, { name: options.name }); + } + + async setRoomTopic(options: { roomId: string; topic: string }): Promise { + await this.#queueRemoteChatInfo(options.roomId, { topic: options.topic }); + } + + async setRoomAvatar(options: { avatarMxc: string; roomId: string }): Promise { + await this.#queueRemoteChatInfo(options.roomId, { avatar: { mxc: options.avatarMxc } }); + } + createStreamPublisher(options: { agentId?: string; roomId: string; @@ -149,11 +159,11 @@ export class BeeperChannelRuntime { sessionKey: string; threadRoot?: string; }): BeeperTurnStream { + const route = this.#bridgeRoute(options.roomId); const binding = this.#resolveBinding(options.roomId) ?? this.#getBindingBySessionKey(options.sessionKey); const agent = options.agentId ? this.#getAgents().find((candidate) => candidate.agentId === options.agentId) : undefined; const userId = binding?.ghostUserId ?? agent?.ghostUserId ?? this.userId; - const publisher = new BeeperTurnStream({ - client: this.client, + const publisher = route.bridge.createBeeperTurnStream({ initialMessageMetadata: { agent_id: options.agentId, ...(agent?.displayName ? { agent_name: agent.displayName } : {}), @@ -230,11 +240,20 @@ export class BeeperChannelRuntime { async #queueRemoteMedia(roomId: string, options: { bytes: Uint8Array; caption?: string; filename?: string; kind: NonNullable }): Promise { const route = this.#bridgeRoute(roomId); - const media = await uploadBridgeMediaMessage(this.client, options); + const upload = await route.bridge.uploadMedia({ + bytes: options.bytes, + ...(options.filename !== undefined ? { filename: options.filename } : {}), + }); + const content = bridgeMediaMessageContent({ + contentUri: upload.contentUri, + kind: options.kind, + ...(options.caption !== undefined ? { caption: options.caption } : {}), + ...(options.filename !== undefined ? { filename: options.filename } : {}), + }); const messageId = openClawRemoteId(); route.bridge.queueRemoteEvent(route.login, createRemoteMessage({ convert: () => ({ - parts: [media.part], + parts: [{ content, type: "m.room.message" }], }), data: {}, id: messageId, @@ -246,6 +265,17 @@ export class BeeperChannelRuntime { return { eventId: messageId, raw: { bridgeQueued: true }, roomId }; } + async #queueRemoteChatInfo(roomId: string, chatInfo: ChatInfo): Promise { + const route = this.#bridgeRoute(roomId); + route.bridge.queueRemoteEvent(route.login, createRemoteChatInfoChange({ + chatInfoChange: { chatInfo }, + portalKey: route.portalKey, + sender: this.#eventSender(roomId), + })); + await route.bridge.flushRemoteEvents(); + this.recordOutboundActivity(); + } + async #queueRemoteEdit(roomId: string, targetMessageId: string, content: Record): Promise { const target = openClawTarget(targetMessageId); const route = this.#bridgeRoute(roomId); diff --git a/packages/openclaw/src/beeper-setup.ts b/packages/openclaw/src/beeper-setup.ts index 519281a..2c1f96f 100644 --- a/packages/openclaw/src/beeper-setup.ts +++ b/packages/openclaw/src/beeper-setup.ts @@ -1,13 +1,20 @@ -import type { MatrixAppserviceInitOptions } from "@beeper/pickle"; -import { loginWithMatrixPassword, type MatrixPasswordAuthOptions } from "@beeper/pickle/auth"; -import { createBeeperLogin, type BeeperAuthOptions, type BeeperEnvironment } from "@beeper/pickle/beeper/auth"; -import { createBeeperAppServiceInit, type CreateAppServiceOptions } from "@beeper/pickle-bridge"; +import { + createBeeperAppServiceInit, + createBeeperLogin, + loginWithMatrixPassword, + type BeeperAuthOptions, + type BeeperEnvironment, + type CreateAppServiceOptions, + type MatrixAppserviceInitOptions, + type MatrixPasswordAuthOptions, +} from "@beeper/pickle-bridge/beeper"; 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 { DEFAULT_BEEPER_BRIDGE_TYPE, openClawBeeperBridgeId }; +export type { BeeperEnvironment }; export interface BeeperSetupAccount { accessToken: string; diff --git a/packages/openclaw/src/bridge-agent.ts b/packages/openclaw/src/bridge-agent.ts index f07456f..af9dd14 100644 --- a/packages/openclaw/src/bridge-agent.ts +++ b/packages/openclaw/src/bridge-agent.ts @@ -121,6 +121,7 @@ export class OpenClawMatrixBridgeAgent { const session = await this.runtime.createSession(createOptions); this.registry.updateBinding(binding.id, (current) => ({ ...current, + kind: "session", sessionKey: session.key, updatedAt: Date.now(), })); diff --git a/packages/openclaw/src/cli.ts b/packages/openclaw/src/cli.ts index e71f118..b48a8c2 100644 --- a/packages/openclaw/src/cli.ts +++ b/packages/openclaw/src/cli.ts @@ -1,7 +1,6 @@ #!/usr/bin/env node import { createInterface } from "node:readline/promises"; -import type { BeeperEnvironment } from "@beeper/pickle/beeper/auth"; -import { setupOpenClawBeeperBridge } from "./beeper-setup"; +import { setupOpenClawBeeperBridge, type BeeperEnvironment } from "./beeper-setup"; import { createDefaultConfig, defaultConfigPath, readConfig, writeConfig } from "./config"; import type { OpenClawBridgeConfig } from "./types"; diff --git a/packages/openclaw/src/connector.test.ts b/packages/openclaw/src/connector.test.ts index 04b927d..6a3aa0a 100644 --- a/packages/openclaw/src/connector.test.ts +++ b/packages/openclaw/src/connector.test.ts @@ -731,6 +731,11 @@ describe("OpenClawBridgeConnector", () => { message: "hello", sessionKey: "agent:codex:session_1", }); + expect(registry.getBindingByRoom("!room:example.com")).toMatchObject({ + agentId: "codex", + kind: "session", + sessionKey: "agent:codex:session_1", + }); await expect(api.handleMatrixReaction({} as BridgeRequestContext, { content: { @@ -781,6 +786,60 @@ describe("OpenClawBridgeConnector", () => { })); }); + it("handles OpenClaw slash commands as normal agent turns without Matrix side notices", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-slash-command-test.json"); + const runtime = runtimeWith({ + responses: { + "sessions.create": { key: "agent:codex:session_1" }, + "beeper.turn": { runId: "run_1", sessionKey: "agent:codex:session_1" }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime, + }); + const { ctx, sendMessage } = connectContext(); + const portal = { + id: "agent:codex", + metadata: { + openclaw: { + agentId: "codex", + ghostUserId: "@codex:example.com", + sessionKey: "agent:codex", + }, + }, + mxid: "!room:example.com", + portalKey: { id: "agent:codex", receiver: "login" }, + receiver: "login", + }; + + await expect(api.handleMatrixMessage(ctx as unknown as BridgeRequestContext, { + content: { body: "/session", msgtype: "m.text" }, + event: { eventId: "$session-command" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "/session", + } as MatrixMessage)).resolves.toEqual({ pending: false }); + + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$session-command", + matrix: expect.objectContaining({ + command: { args: "", name: "session" }, + roomId: "!room:example.com", + sender: "@alice:example.com", + }), + message: "/session", + sessionKey: "agent:codex:session_1", + })); + expect(sendMessage).not.toHaveBeenCalled(); + expect(registry.getBindingByRoom("!room:example.com")).toMatchObject({ + kind: "session", + sessionKey: "agent:codex:session_1", + }); + }); + it("parses Matrix replies and slash commands for OpenClaw turns", async () => { expect(parseMatrixTextMessage("> <@alice> old\n\nnew text", { "m.relates_to": { diff --git a/packages/openclaw/src/connector.ts b/packages/openclaw/src/connector.ts index bcf8ef2..0ed16a0 100644 --- a/packages/openclaw/src/connector.ts +++ b/packages/openclaw/src/connector.ts @@ -181,7 +181,6 @@ export class OpenClawBridgeConnector implements BridgeConnector this.registry.data.agents, getBindingByRoom: (roomId) => this.registry.getBindingByRoom(roomId), getBindingBySessionKey: (sessionKey) => this.registry.getBindingBySessionKey(sessionKey), @@ -286,8 +285,8 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor if (portal && params.createDM && !portal.mxid) { const portalOptions: Parameters[1] = { id: portal.id, + ...agentPortalInfo(contact), metadata: portal.metadata, - name: contact.displayName, roomType: "dm", sender: contact.ghostUserId, }; @@ -506,22 +505,25 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor this.upsertPortalBinding(msg.portal); } - async handleMatrixRoomName(_ctx: BridgeRequestContext, msg: MatrixRoomName): Promise { + 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; + if (!roomId || !binding || !msg.name) return false; this.#registry.upsertBinding({ ...binding, label: msg.name, updatedAt: Date.now() }); await this.#registry.save(); + return true; } - async handleMatrixRoomTopic(_ctx: BridgeRequestContext, msg: MatrixRoomTopic): Promise { - if (!msg.portal.mxid || !this.isAllowedRoom(msg.portal.mxid)) return; + async handleMatrixRoomTopic(_ctx: BridgeRequestContext, msg: MatrixRoomTopic): Promise { + if (!msg.portal.mxid || !this.isAllowedRoom(msg.portal.mxid)) return false; this.upsertPortalBinding(msg.portal); + return true; } - async handleMatrixRoomAvatar(_ctx: BridgeRequestContext, msg: MatrixRoomAvatar): Promise { - if (!msg.portal.mxid || !this.isAllowedRoom(msg.portal.mxid)) return; + async handleMatrixRoomAvatar(_ctx: BridgeRequestContext, msg: MatrixRoomAvatar): Promise { + if (!msg.portal.mxid || !this.isAllowedRoom(msg.portal.mxid)) return false; this.upsertPortalBinding(msg.portal); + return true; } async handleMatrixMembership(_ctx: BridgeRequestContext, msg: MatrixMembership): Promise { @@ -683,8 +685,8 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor let portal = portalForAgentWelcome(contact, this.#login.id); const portalOptions: Parameters[1] = { id: portal.id, + ...agentPortalInfo(contact), metadata: portal.metadata, - name: contact.displayName, roomType: "dm", sender: contact.ghostUserId, }; @@ -705,6 +707,7 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor this.registerCanonicalPortalForBinding(ctx, portal, binding); await this.#registry.save(); } + } function inboundActivityPatch(now = Date.now()): OpenClawBridgeActivityPatch { @@ -715,14 +718,6 @@ function inboundActivityPatch(now = Date.now()): OpenClawBridgeActivityPatch { }; } -function outboundActivityPatch(now = Date.now()): OpenClawBridgeActivityPatch { - return { - lastEventAt: now, - lastOutboundAt: now, - lastTransportActivityAt: now, - }; -} - function newBeeperSessionKey(agentId: string): string { return `agent:${agentId}:beeper:${randomUUID()}`; } @@ -867,6 +862,14 @@ function agentGhost(contact: OpenClawAgentContact) { }; } +function agentPortalInfo(contact: OpenClawAgentContact): { avatarUrl?: string; name: string; topic?: string } { + const info: { avatarUrl?: string; name: string; topic?: string } = { name: contact.displayName }; + const avatarUrl = contact.avatarMxc ?? contact.avatarUrl; + if (avatarUrl) info.avatarUrl = avatarUrl; + if (contact.description) info.topic = contact.description; + return info; +} + function agentAvatar(contact: OpenClawAgentContact): Avatar | undefined { const url = contact.avatarUrl ?? contact.avatarMxc; const id = contact.avatarMxc ?? url; diff --git a/packages/openclaw/src/openclaw-runtime.test.ts b/packages/openclaw/src/openclaw-runtime.test.ts index 75fa8ac..338fb43 100644 --- a/packages/openclaw/src/openclaw-runtime.test.ts +++ b/packages/openclaw/src/openclaw-runtime.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { BeeperChannelRuntime, setBeeperChannelRuntimeForHost } from "./beeper-channel-runtime"; +import { BeeperTurnStream } from "@beeper/pickle-bridge/beeper-stream"; import { createDefaultConfig } from "./config"; import { createOpenClawHostRuntimeAdapter, @@ -136,20 +137,6 @@ describe("OpenClawPluginRuntimeAdapter", () => { }); it("sends host-backed Beeper turns through channel helpers", 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", - })), - }; const aiRunStreams = createTestBeeperAIRunStreams(); const request = vi.fn(async () => { throw new Error("generic request should not be used"); @@ -178,13 +165,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { }, config: { current: () => ({ agents: { list: [{ id: "main" }] } }) }, }; - setBeeperChannelRuntimeForHost(hostRuntime, new BeeperChannelRuntime({ - client: { - beeper: { aiRuns: createTestBeeperAIRuns(), aiRunStreams, streams: beeperStreams }, - media: { upload: vi.fn() }, - } as never, - userId: "@sh-openclaw-bot:example", - })); + setBeeperChannelRuntimeForHost(hostRuntime, createTestBeeperChannelRuntime(aiRunStreams)); const runtime = new OpenClawPluginRuntimeAdapter({ config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), transport: createOpenClawHostRuntimeAdapter(hostRuntime), @@ -282,20 +263,6 @@ describe("OpenClawPluginRuntimeAdapter", () => { }); it("runs Beeper-originated sends through OpenClaw channel inbound 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", - })), - }; const aiRunStreams = createTestBeeperAIRunStreams(); const dispatchReply = vi.fn(async (params: Record) => { const replyOptions = params.replyOptions as Record void | Promise>; @@ -341,13 +308,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { }, config: { current: () => ({ agents: { list: [{ id: "main" }] } }) }, }; - setBeeperChannelRuntimeForHost(hostRuntime, new BeeperChannelRuntime({ - client: { - beeper: { aiRuns: createTestBeeperAIRuns(), aiRunStreams, streams: beeperStreams }, - media: { upload: vi.fn() }, - } as never, - userId: "@sh-openclaw-bot:example", - })); + setBeeperChannelRuntimeForHost(hostRuntime, createTestBeeperChannelRuntime(aiRunStreams)); const transport = createOpenClawHostRuntimeAdapter(hostRuntime); const received: OpenClawGatewayEvent[] = []; @@ -422,20 +383,6 @@ describe("OpenClawPluginRuntimeAdapter", () => { }); 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", - })), - }; const aiRunStreams = createTestBeeperAIRunStreams(); const dispatchReply = vi.fn(async (params: Record) => { const replyOptions = params.replyOptions as Record void | Promise>; @@ -473,13 +420,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { }, config: { current: () => ({ agents: { list: [{ id: "main" }] } }) }, }; - setBeeperChannelRuntimeForHost(hostRuntime, new BeeperChannelRuntime({ - client: { - beeper: { aiRuns: createTestBeeperAIRuns(), aiRunStreams, streams: beeperStreams }, - media: { upload: vi.fn() }, - } as never, - userId: "@sh-openclaw-bot:example", - })); + setBeeperChannelRuntimeForHost(hostRuntime, createTestBeeperChannelRuntime(aiRunStreams)); const transport = createOpenClawHostRuntimeAdapter(hostRuntime); const done = (async () => { @@ -522,20 +463,6 @@ describe("OpenClawPluginRuntimeAdapter", () => { }); 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", - })), - }; const aiRunStreams = createTestBeeperAIRunStreams(); let agentEventListener: ((event: { data?: Record; runId?: string; sessionKey?: string; stream?: string }) => void) | undefined; const dispatchReply = vi.fn(async (params: Record) => { @@ -656,13 +583,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { }, }, }; - setBeeperChannelRuntimeForHost(hostRuntime, new BeeperChannelRuntime({ - client: { - beeper: { aiRuns: createTestBeeperAIRuns(), aiRunStreams, streams: beeperStreams }, - media: { upload: vi.fn() }, - } as never, - userId: "@sh-openclaw-bot:example", - })); + setBeeperChannelRuntimeForHost(hostRuntime, createTestBeeperChannelRuntime(aiRunStreams)); const transport = createOpenClawHostRuntimeAdapter(hostRuntime); const done = (async () => { @@ -908,6 +829,59 @@ function createTestBeeperAIRunStreams() { }; } +function createTestBeeperChannelRuntime(aiRunStreams: ReturnType) { + const bridge = { + createBeeperTurnStream: vi.fn((options) => new BeeperTurnStream({ + ...options, + client: { + beeper: { + aiRuns: createTestBeeperAIRuns(), + aiRunStreams, + }, + } as never, + })), + flushRemoteEvents: vi.fn(async () => undefined), + getPortalByMXID: vi.fn(() => ({ portalKey: { id: "session:one", receiver: "openclaw:plugin" } })), + queueRemoteEvent: vi.fn(), + }; + return new BeeperChannelRuntime({ + bridge: bridge as never, + getAgents: () => [{ + agentId: "main", + displayName: "Main", + ghostUserId: "@sh-openclaw_agent_main:example", + }], + getBindingByRoom: (roomId) => roomId === "!room:example" + ? { + agentId: "main", + createdAt: 1, + ghostUserId: "@sh-openclaw_agent_main:example", + id: "binding", + kind: "session", + owner: "bridge", + roomId, + sessionKey: "agent:main:beeper:room", + updatedAt: 1, + } + : undefined, + getBindingBySessionKey: (sessionKey) => sessionKey === "agent:main:beeper:room" + ? { + agentId: "main", + createdAt: 1, + ghostUserId: "@sh-openclaw_agent_main:example", + id: "binding", + kind: "session", + owner: "bridge", + roomId: "!room:example", + sessionKey, + updatedAt: 1, + } + : undefined, + login: { id: "openclaw:plugin" }, + userId: "@sh-openclaw-bot:example", + }); +} + function startedAndAppendedParts(aiRunStreams: ReturnType) { const startOptions = aiRunStreams.start.mock.calls[0]?.[0] as { initialParts?: Array> } | undefined; return [ diff --git a/packages/openclaw/src/openclaw-runtime.ts b/packages/openclaw/src/openclaw-runtime.ts index bb02e45..e22d560 100644 --- a/packages/openclaw/src/openclaw-runtime.ts +++ b/packages/openclaw/src/openclaw-runtime.ts @@ -1800,6 +1800,16 @@ function createBeeperReplyStreamEmitter(base: { ?? stringValue(data.title); if (!text) return; const activityType = stringValue(data.activityType) ?? stringValue(data.type) ?? "activity"; + if (isWorkingPlaceholder(text)) { + channelRuntime.debug("openclaw_beeper_activity_suppressed", { + activityType, + reason: "working_placeholder", + roomId: base.roomId, + runId: base.runId, + }); + await ensureStarted(); + return; + } emit("activity.updated", { activityType, text }); await publishPart({ kind: "activity", diff --git a/packages/openclaw/src/rooms.test.ts b/packages/openclaw/src/rooms.test.ts index 32eff8e..59caed0 100644 --- a/packages/openclaw/src/rooms.test.ts +++ b/packages/openclaw/src/rooms.test.ts @@ -1,11 +1,8 @@ -import type { MatrixClient } from "@beeper/pickle"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { createDefaultConfig } from "./config"; import { agentContactFromOpenClawAgent, agentGhostUserId, - bindingIdForRoom, - createSessionRoom, matrixDomainFromHomeserver, serviceBotUserId, } from "./rooms"; @@ -31,55 +28,4 @@ describe("OpenClaw room and contact helpers", () => { }); }); - 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({ - dataDir: "/tmp/openclaw", - homeserver: "https://example.com", - }); - - try { - const binding = await createSessionRoom(client, config, { - agent: { - agentId: "codex", - displayName: "Codex", - ghostUserId: "@sh-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: [], - isDirect: true, - name: "Fix tests", - preset: "private_chat", - topic: "OpenClaw agent: codex\nsession: agent:codex:main\ncwd: /repo", - userId: "@sh-openclawbot:example.com", - visibility: "private", - }); - expect(binding).toEqual({ - agentId: "codex", - createdAt: Date.parse("2026-05-16T12:00:00.000Z"), - cwd: "/repo", - ghostUserId: "@sh-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 index 9fac0fe..6d7c628 100644 --- a/packages/openclaw/src/rooms.ts +++ b/packages/openclaw/src/rooms.ts @@ -1,6 +1,5 @@ -import type { MatrixClient } from "@beeper/pickle"; -import type { OpenClawAgentContact, OpenClawBridgeConfig, OpenClawSessionBinding } from "./types"; -import { openClawAgentGhostLocalpart, openClawRoomCreationPreset, openClawSenderLocalpart } from "./registration"; +import type { OpenClawAgentContact, OpenClawBridgeConfig } from "./types"; +import { openClawAgentGhostLocalpart, openClawSenderLocalpart } from "./registration"; export function bindingIdForRoom(roomId: string): string { return Buffer.from(roomId).toString("base64url"); @@ -49,52 +48,6 @@ export function agentContactFromOpenClawAgent( 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: [], - 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/src/setup.test.ts b/packages/openclaw/src/setup.test.ts index 027ffed..60fbca3 100644 --- a/packages/openclaw/src/setup.test.ts +++ b/packages/openclaw/src/setup.test.ts @@ -14,6 +14,7 @@ import { BeeperChannelRuntime, setBeeperChannelRuntimeForHost, } from "./beeper-channel-runtime"; +import { BeeperTurnStream } from "@beeper/pickle-bridge/beeper-stream"; import { applyBeeperChannelSettings, beeperChannelConfig, @@ -44,7 +45,17 @@ describe("OpenClaw Beeper official channel contracts", () => { cases: [{ name: "default Beeper message actions", cfg: {}, - expectedActions: ["send", "react", "read"], + expectedActions: [ + "delete", + "edit", + "mark_unread", + "react", + "read", + "send", + "set-room-avatar", + "set-room-name", + "set-room-topic", + ], }], }); @@ -278,7 +289,17 @@ describe("OpenClaw Beeper setup surface", () => { }); expect(beeperChannelPlugin.actions).toEqual(expect.any(Object)); expect(beeperChannelPlugin.actions.describeMessageTool()).toMatchObject({ - actions: ["send", "react", "read"], + actions: [ + "send", + "edit", + "delete", + "react", + "read", + "mark_unread", + "set-room-name", + "set-room-topic", + "set-room-avatar", + ], capabilities: [], }); expect(beeperChannelPlugin.actions.extractToolSend({ @@ -755,15 +776,18 @@ describe("OpenClaw Beeper setup surface", () => { }, 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)), - }; + const queued: unknown[] = []; + const bridge = { + createBeeperTurnStream: vi.fn((options) => new BeeperTurnStream({ + ...options, + client: client as never, + })), + flushRemoteEvents: vi.fn(async () => undefined), + getPortalByMXID: vi.fn(() => ({ portalKey: { id: "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, getAgents: () => [{ avatarMxc: "mxc://avatar", description: "Helpful coding agent", @@ -812,25 +836,50 @@ describe("OpenClaw Beeper setup surface", () => { await beeperChannelPlugin.actions.handleAction({ action: "react", - params: { eventId: sentMessageId, key: "+1", to: "!room" }, + params: { eventId: sentMessageId, emoji: "+1", roomId: "!room" }, }); expect(client.reactions.send).not.toHaveBeenCalled(); await beeperChannelPlugin.heartbeat.sendTyping({ to: "!room" }); expect(client.typing.set).not.toHaveBeenCalled(); + await beeperChannelPlugin.actions.handleAction({ + action: "edit", + params: { eventId: sentMessageId, message: "edited", roomId: "!room" }, + }); + await beeperChannelPlugin.actions.handleAction({ + action: "delete", + params: { eventId: sentMessageId, roomId: "!room" }, + }); await beeperChannelPlugin.actions.handleAction({ action: "read", - params: { eventId: sentMessageId, to: "!room" }, + params: { eventId: sentMessageId, roomId: "!room" }, }); await beeperChannelPlugin.actions.handleAction({ action: "mark_unread", - params: { eventId: sentMessageId, to: "!room" }, + params: { eventId: sentMessageId, roomId: "!room" }, + }); + await beeperChannelPlugin.actions.handleAction({ + action: "set-room-name", + params: { name: "Agent room", roomId: "!room" }, + }); + await beeperChannelPlugin.actions.handleAction({ + action: "set-room-topic", + params: { roomId: "!room", topic: "Planning" }, + }); + await beeperChannelPlugin.actions.handleAction({ + action: "set-room-avatar", + params: { avatarMxc: "mxc://example/avatar2", roomId: "!room" }, }); expect(queued.map((event) => (event as { getType: () => string }).getType())).toEqual([ "reaction", "typing", + "edit", + "message_remove", "read_receipt", "mark_unread", + "chat_info_change", + "chat_info_change", + "chat_info_change", ]); await expect(beeperChannelPlugin.directory.listPeersLive({ diff --git a/packages/openclaw/src/setup.ts b/packages/openclaw/src/setup.ts index f9ef219..de8ae86 100644 --- a/packages/openclaw/src/setup.ts +++ b/packages/openclaw/src/setup.ts @@ -443,55 +443,118 @@ export const beeperApprovalCapability = { }, } as const; -const beeperMessageToolActions = ["send", "react", "read"] as const satisfies readonly ChannelMessageActionName[]; +const beeperMessageToolActions = [ + "send", + "edit", + "delete", + "react", + "read", + "mark_unread", + "set-room-name", + "set-room-topic", + "set-room-avatar", +] as const; + +type BeeperMessageToolAction = typeof beeperMessageToolActions[number]; +type BeeperActionContext = { + action: string; + params: Record; + mediaReadFile?: (filePath: string) => Promise; + sessionKey?: string | null; +}; function beeperToolTextResult(text: string) { return { content: [{ type: "text" as const, text }], details: {} }; } +const beeperActionHandlers: Record Promise>> = { + send: async (ctx) => { + const runtime = requireBeeperChannelRuntime(); + const text = readRequiredString(ctx.params, "message"); + const sent = await runtime.publishActiveText({ + ...(ctx.sessionKey !== undefined ? { sessionKey: ctx.sessionKey } : {}), + text, + }); + return beeperToolTextResult(`Published Beeper native stream text ${sent.eventId}`); + }, + edit: async (ctx) => { + const runtime = requireBeeperChannelRuntime(); + const roomId = readRequiredBeeperRoomId(ctx.params); + const eventId = readRequiredString(ctx.params, "eventId"); + const text = readRequiredString(ctx.params, "message"); + const sent = await runtime.edit({ eventId, roomId, text }); + return beeperToolTextResult(`Edited Beeper message ${sent.eventId}`); + }, + delete: async (ctx) => { + const runtime = requireBeeperChannelRuntime(); + const roomId = readRequiredBeeperRoomId(ctx.params); + const eventId = readRequiredString(ctx.params, "eventId"); + await runtime.redact({ eventId, roomId }); + return beeperToolTextResult(`Deleted Beeper message ${eventId}`); + }, + react: async (ctx) => { + const runtime = requireBeeperChannelRuntime(); + const roomId = readRequiredBeeperRoomId(ctx.params); + const eventId = readRequiredString(ctx.params, "eventId"); + const emoji = readRequiredString(ctx.params, "emoji"); + if (ctx.params.remove === true) { + await runtime.removeReaction({ emoji, eventId, roomId }); + return beeperToolTextResult(`Removed Beeper reaction ${emoji}`); + } + const sent = await runtime.react({ emoji, eventId, roomId }); + return beeperToolTextResult(`Sent Beeper reaction ${sent.eventId}`); + }, + read: async (ctx) => { + const runtime = requireBeeperChannelRuntime(); + const roomId = readRequiredBeeperRoomId(ctx.params); + const eventId = readRequiredString(ctx.params, "eventId"); + await runtime.readReceipt({ eventId, roomId }); + return beeperToolTextResult(`Marked Beeper message read ${eventId}`); + }, + mark_unread: async (ctx) => { + const runtime = requireBeeperChannelRuntime(); + const roomId = readRequiredBeeperRoomId(ctx.params); + const eventId = readRequiredString(ctx.params, "eventId"); + const unread = ctx.params.unread !== false; + await runtime.markUnread({ eventId, roomId, unread }); + return beeperToolTextResult(`${unread ? "Marked" : "Unmarked"} Beeper room unread`); + }, + "set-room-name": async (ctx) => { + const runtime = requireBeeperChannelRuntime(); + const roomId = readRequiredBeeperRoomId(ctx.params); + const name = readRequiredString(ctx.params, "name"); + await runtime.setRoomName({ name, roomId }); + return beeperToolTextResult("Updated Beeper room name"); + }, + "set-room-topic": async (ctx) => { + const runtime = requireBeeperChannelRuntime(); + const roomId = readRequiredBeeperRoomId(ctx.params); + const topic = readRequiredString(ctx.params, "topic"); + await runtime.setRoomTopic({ roomId, topic }); + return beeperToolTextResult("Updated Beeper room topic"); + }, + "set-room-avatar": async (ctx) => { + const runtime = requireBeeperChannelRuntime(); + const roomId = readRequiredBeeperRoomId(ctx.params); + const avatarMxc = readRequiredString(ctx.params, "avatarMxc"); + if (!avatarMxc.startsWith("mxc://")) throw new Error("Beeper room avatar must be an mxc:// URI."); + await runtime.setRoomAvatar({ avatarMxc, roomId }); + return beeperToolTextResult("Updated Beeper room avatar"); + }, +}; + export const beeperMessageActions = { resolveExecutionMode: () => "gateway" as const, describeMessageTool: () => ({ - actions: beeperMessageToolActions, + actions: beeperMessageToolActions as unknown as readonly ChannelMessageActionName[], capabilities: [], }), supportsAction: ({ action }: { action: string }) => - action === "send" || action === "react" || action === "read", + isBeeperMessageToolAction(action), extractToolSend: () => null, - 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 beeperToolTextResult(`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"); - const emoji = readRequiredString(params, "emoji", "reaction", "key"); - const remove = params.remove === true; - if (remove) { - await runtime.removeReaction({ emoji, eventId, roomId }); - return beeperToolTextResult(`Removed Beeper reaction ${emoji}`); - } - const sent = await runtime.react({ emoji, eventId, roomId }); - return beeperToolTextResult(`Sent Beeper reaction ${sent.eventId}`); - } - if (ctx.action === "read") { - const eventId = readRequiredString(params, "messageId", "eventId"); - await runtime.readReceipt({ eventId, roomId }); - 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 beeperToolTextResult(`${unread ? "Marked" : "Unmarked"} Beeper room unread`); - } + handleAction: async (ctx: BeeperActionContext) => { + const handler = isBeeperMessageToolAction(ctx.action) ? beeperActionHandlers[ctx.action] : undefined; + if (handler) return handler(ctx); throw new Error(`Unsupported Beeper message action: ${ctx.action}`); }, } as const; @@ -850,6 +913,14 @@ function resolveBeeperRoomTarget(target: string): string { return normalized; } +function readRequiredBeeperRoomId(params: Record): string { + return resolveBeeperRoomTarget(readRequiredString(params, "roomId")); +} + +function isBeeperMessageToolAction(action: string): action is BeeperMessageToolAction { + return (beeperMessageToolActions as readonly string[]).includes(action); +} + function beeperOutboundResult(sent: { eventId: string; roomId: string }): { channel: string; messageId: string; diff --git a/packages/openclaw/vitest.config.ts b/packages/openclaw/vitest.config.ts index a03d02c..e3ac897 100644 --- a/packages/openclaw/vitest.config.ts +++ b/packages/openclaw/vitest.config.ts @@ -3,7 +3,9 @@ import { defineProject } from "vitest/config"; export default defineProject({ resolve: { alias: [ + { find: "@beeper/pickle-bridge/beeper", replacement: new URL("../bridge/src/beeper.ts", import.meta.url).pathname }, { find: "@beeper/pickle-bridge/beeper-stream", replacement: new URL("../bridge/src/beeper-stream.ts", import.meta.url).pathname }, + { find: "@beeper/pickle-bridge/events", replacement: new URL("../bridge/src/events.ts", import.meta.url).pathname }, { find: "@beeper/pickle-bridge/media-message", replacement: new URL("../bridge/src/media-message.ts", import.meta.url).pathname }, { find: /^@beeper\/pickle-bridge$/, replacement: new URL("../bridge/src/index.ts", import.meta.url).pathname }, { find: /^@beeper\/pickle-ag-ui$/, replacement: new URL("../ag-ui/src/index.ts", import.meta.url).pathname }, diff --git a/tsconfig.base.json b/tsconfig.base.json index c22a44e..cb32379 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -20,7 +20,9 @@ "@beeper/pickle/streams/beeper-message": ["packages/pickle/src/streams/beeper-message.ts"], "@beeper/pickle-ag-ui": ["packages/ag-ui/src/index.ts"], "@beeper/pickle-bridge": ["packages/bridge/src/index.ts"], + "@beeper/pickle-bridge/beeper": ["packages/bridge/src/beeper.ts"], "@beeper/pickle-bridge/beeper-stream": ["packages/bridge/src/beeper-stream.ts"], + "@beeper/pickle-bridge/events": ["packages/bridge/src/events.ts"], "@beeper/pickle-bridge/media-message": ["packages/bridge/src/media-message.ts"], "@beeper/pickle-bridge/types": ["packages/bridge/src/types.ts"], "@beeper/pickle-chat-adapter": ["packages/chat-adapter/src/index.ts"], From 090c8ee736c654edc494ecc5087fc7bdeacc2cda Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Tue, 2 Jun 2026 16:55:34 +0200 Subject: [PATCH 48/56] Fix Beeper rich stream finalization --- examples/dummybridge/src/connector.ts | 8 +- examples/dummybridge/src/index.ts | 2 +- examples/dummybridge/test/smoke.ts | 2 +- packages/bridge/README.md | 4 +- packages/bridge/package.json | 19 +- packages/bridge/src/beeper.ts | 2 +- packages/bridge/src/bridge.test.ts | 82 +++++-- packages/bridge/src/bridge.ts | 166 ++++---------- packages/bridge/src/{index.ts => node.ts} | 11 +- packages/bridge/src/room-state.ts | 29 +++ packages/bridge/src/types.ts | 7 +- packages/bridge/tsdown.config.ts | 2 +- packages/openclaw/package.json | 1 - packages/openclaw/src/appservice.test.ts | 2 +- packages/openclaw/src/appservice.ts | 2 +- .../src/beeper-channel-runtime.test.ts | 10 +- .../openclaw/src/beeper-channel-runtime.ts | 16 +- packages/openclaw/src/bridge-agent.test.ts | 13 +- packages/openclaw/src/bridge-agent.ts | 14 +- packages/openclaw/src/config.test.ts | 36 ++- packages/openclaw/src/config.ts | 23 +- packages/openclaw/src/connector.test.ts | 213 ++++-------------- packages/openclaw/src/connector.ts | 114 +++------- packages/openclaw/src/integration.test.ts | 7 +- packages/openclaw/src/matrix-parser.ts | 2 +- .../openclaw/src/openclaw-extension.test.ts | 2 +- .../openclaw/src/openclaw-runtime.test.ts | 29 ++- packages/openclaw/src/openclaw-runtime.ts | 51 ++++- packages/openclaw/src/registry.test.ts | 2 - packages/openclaw/src/registry.ts | 27 ++- packages/openclaw/src/setup.test.ts | 137 ++++++----- packages/openclaw/src/setup.ts | 110 +++++---- packages/openclaw/src/types.ts | 14 +- packages/openclaw/vitest.config.ts | 9 +- .../pickle/native/internal/core/appservice.go | 89 +++++++- .../native/internal/core/appservice_test.go | 76 +++++++ .../native/internal/core/beeper_ai_run.go | 2 +- .../internal/core/beeper_ai_run_test.go | 29 +++ packages/pickle/native/internal/core/core.go | 4 + .../pickle/native/internal/core/messages.go | 2 +- .../pickle/native/internal/core/operations.go | 4 + packages/pickle/native/internal/core/rooms.go | 102 ++++++--- .../pickle/native/internal/core/rooms_test.go | 50 ++++ packages/pickle/src/client-types.ts | 2 + packages/pickle/src/client.ts | 45 +--- .../src/generated-runtime-operations.ts | 13 ++ .../pickle/src/generated-runtime-types.ts | 30 ++- packages/pickle/src/index.ts | 1 + packages/pickle/src/runtime-types.ts | 1 + packages/pickle/src/types.ts | 16 +- pnpm-lock.yaml | 3 - tsconfig.base.json | 3 +- 52 files changed, 951 insertions(+), 689 deletions(-) rename packages/bridge/src/{index.ts => node.ts} (86%) create mode 100644 packages/bridge/src/room-state.ts diff --git a/examples/dummybridge/src/connector.ts b/examples/dummybridge/src/connector.ts index fcc4cad..9f46361 100644 --- a/examples/dummybridge/src/connector.ts +++ b/examples/dummybridge/src/connector.ts @@ -154,10 +154,10 @@ export class DummyConnector implements CommandHandlingBridgeConnector { return reply(ctx.bridge.ghostUserId(localId)); } case "kick-me": - await ctx.client.raw.request({ - body: { reason: "DummyBridge kick-me command", user_id: command.sender.userId }, - method: "POST", - path: `/_matrix/client/v3/rooms/${encodeURIComponent(command.room.mxid)}/kick`, + await ctx.client.rooms.kick({ + reason: "DummyBridge kick-me command", + roomId: command.room.mxid, + userId: command.sender.userId, }); return { handled: true }; case "file": diff --git a/examples/dummybridge/src/index.ts b/examples/dummybridge/src/index.ts index ed77f13..b921f10 100644 --- a/examples/dummybridge/src/index.ts +++ b/examples/dummybridge/src/index.ts @@ -1,7 +1,7 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { loginWithPassword } from "@beeper/pickle/auth"; -import { createBeeperBridge } from "@beeper/pickle-bridge"; +import { createBeeperBridge } from "@beeper/pickle-bridge/node"; import type { CreateNodeBeeperBridgeOptions, Portal } from "@beeper/pickle-bridge/types"; import { DUMMY_CHAT_IDS, DummyConnector, LOGIN_ID, PORTAL_ID } from "./connector"; import { loadEnv, optionalEnv, requiredEnv } from "./env"; diff --git a/examples/dummybridge/test/smoke.ts b/examples/dummybridge/test/smoke.ts index b207bf2..86c2525 100644 --- a/examples/dummybridge/test/smoke.ts +++ b/examples/dummybridge/test/smoke.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { RuntimeBridge } from "@beeper/pickle-bridge"; +import { RuntimeBridge } from "@beeper/pickle-bridge/bridge"; import type { MatrixClient, MatrixClientEvent, MatrixStore } from "@beeper/pickle"; import type { BridgeConnector, BridgeMatrixConfig, MatrixAppserviceInitOptions } from "@beeper/pickle-bridge/types"; import { DummyConnector, LOGIN_ID, PORTAL_ID } from "../src/connector"; diff --git a/packages/bridge/README.md b/packages/bridge/README.md index af83331..97d986e 100644 --- a/packages/bridge/README.md +++ b/packages/bridge/README.md @@ -6,7 +6,7 @@ bridgev2-shaped connector interfaces and bridge runtime orchestration. ```ts import { loginWithPassword } from "@beeper/pickle/auth"; -import { createBeeperBridge } from "@beeper/pickle-bridge"; +import { createBeeperBridge } from "@beeper/pickle-bridge/node"; import type { BridgeConnector } from "@beeper/pickle-bridge/types"; const account = await loginWithPassword({ @@ -76,7 +76,7 @@ The bridge package is Node-only and uses the same Pickle WASM mechanism as ## Bridge-manager helpers -`@beeper/pickle-bridge` also exposes bridge-manager-compatible helpers: +`@beeper/pickle-bridge/beeper` exposes bridge-manager-compatible helpers: - `createBeeperBridgeManagerClient({ token })` - `fetchBeeperBridges({ token })` diff --git a/packages/bridge/package.json b/packages/bridge/package.json index 2a16c0a..c9fca08 100644 --- a/packages/bridge/package.json +++ b/packages/bridge/package.json @@ -12,14 +12,7 @@ "bugs": { "url": "https://github.com/beeper/pickle/issues" }, - "main": "./dist/index.js", - "module": "./dist/index.js", - "types": "./dist/index.d.ts", "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - }, "./beeper": { "types": "./dist/beeper.d.ts", "import": "./dist/beeper.js" @@ -28,6 +21,10 @@ "types": "./dist/beeper-stream.d.ts", "import": "./dist/beeper-stream.js" }, + "./bridge": { + "types": "./dist/bridge.d.ts", + "import": "./dist/bridge.js" + }, "./events": { "types": "./dist/events.d.ts", "import": "./dist/events.js" @@ -36,6 +33,14 @@ "types": "./dist/media-message.d.ts", "import": "./dist/media-message.js" }, + "./node": { + "types": "./dist/node.d.ts", + "import": "./dist/node.js" + }, + "./room-state": { + "types": "./dist/room-state.d.ts", + "import": "./dist/room-state.js" + }, "./store": { "types": "./dist/store.d.ts", "import": "./dist/store.js" diff --git a/packages/bridge/src/beeper.ts b/packages/bridge/src/beeper.ts index 7b035de..7bf456d 100644 --- a/packages/bridge/src/beeper.ts +++ b/packages/bridge/src/beeper.ts @@ -3,7 +3,7 @@ export { loginWithMatrixPassword } from "@beeper/pickle/auth"; export type { MatrixPasswordAuthOptions } from "@beeper/pickle/auth"; export { createBeeperLogin } from "@beeper/pickle/beeper/auth"; export type { BeeperAuthOptions, BeeperEnvironment } from "@beeper/pickle/beeper/auth"; -export type { MatrixAppserviceInitOptions, MatrixAppserviceRegistration } from "@beeper/pickle"; +export type { MatrixAppserviceInitOptions, MatrixAppserviceRegistration, MatrixAppserviceSetProfileOptions } from "@beeper/pickle"; export interface BeeperClientOptions { baseDomain?: string; diff --git a/packages/bridge/src/bridge.test.ts b/packages/bridge/src/bridge.test.ts index f54e4fe..691ecf9 100644 --- a/packages/bridge/src/bridge.test.ts +++ b/packages/bridge/src/bridge.test.ts @@ -303,14 +303,15 @@ describe("RuntimeBridge", () => { })); await bridge.flushRemoteEvents(); - expect(client.raw.request).toHaveBeenCalledWith({ - body: { + expect(client.messages.send).toHaveBeenCalledWith({ + content: { body: "hello from remote", "com.beeper.ai": { kind: "anchor", schema: "com.beeper.ai.v1" }, msgtype: "m.text", }, - method: "PUT", - path: expect.stringContaining("/rooms/!room%3Aexample/send/m.room.message/pickle-bridge-"), + messageType: "m.text", + roomId: "!room:example", + text: "hello from remote", }); }); @@ -775,6 +776,47 @@ describe("RuntimeBridge", () => { })); }); + it("syncs appservice ghost profile when registering ghosts", async () => { + const client = createFakeMatrixClient(); + const bridge = new RuntimeBridge({ + appservice: { + homeserver: "https://matrix.example", + homeserverDomain: "example", + registration: { + asToken: "as", + hsToken: "hs", + id: "test", + namespaces: { users: [{ exclusive: true, regex: "@test_.*:example" }] }, + senderLocalpart: "testbot", + url: "http://localhost:29300", + }, + }, + connector: createFakeConnector(createFakeNetworkAPI()), + matrix: matrixConfig(), + }, client); + + await bridge.start(); + await bridge.registerGhost({ + avatar: { mxc: "mxc://example/agent" }, + displayName: "Agent Main", + id: "agent_main", + }); + + expect(bridge.getGhost("agent_main")).toEqual(expect.objectContaining({ + displayName: "Agent Main", + mxid: "@test_agent_main:example", + })); + expect(client.appservice.setProfile).toHaveBeenCalledWith(expect.objectContaining({ + avatarUrl: "mxc://example/agent", + displayName: "Agent Main", + isBridgeBot: false, + network: "test", + remoteId: "agent_main", + service: "test", + userId: "@test_agent_main:example", + })); + }); + it("adds Beeper room metadata and autojoin members for Beeper bridges", async () => { const client = createFakeMatrixClient(); const connector = createFakeConnector(createFakeNetworkAPI()); @@ -977,7 +1019,7 @@ describe("RuntimeBridge", () => { const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, client); await bridge.start(); - bridge.registerGhost({ displayName: "Alice", id: "alice", mxid: "@dummy_alice:example" }); + await bridge.registerGhost({ displayName: "Alice", id: "alice", mxid: "@dummy_alice:example" }); bridge.registerPortal({ id: "remote-room", mxid: "!room:example", portalKey: { id: "remote-room", receiver: "login:a" } }); const portal = await bridge.setPortalMetadata({ id: "remote-room", receiver: "login:a" }, { unread: true }); await bridge.setMessageRequest({ @@ -1086,10 +1128,11 @@ describe("RuntimeBridge", () => { }) ); expect(network.handleMatrixMessage).not.toHaveBeenCalled(); - expect(client.raw.request).toHaveBeenCalledWith({ - body: { body: "pong", msgtype: "m.notice" }, - method: "PUT", - path: expect.stringContaining("/rooms/!created%3Aexample/send/m.room.message/pickle-bridge-"), + expect(client.messages.send).toHaveBeenCalledWith({ + content: { body: "pong", msgtype: "m.notice" }, + messageType: "m.notice", + roomId: "!created:example", + text: "pong", }); }); @@ -1138,9 +1181,7 @@ describe("RuntimeBridge", () => { roomId: "!management:example", userId: "@testbot:example", }); - expect(client.raw.request).not.toHaveBeenCalledWith(expect.objectContaining({ - path: expect.stringContaining("/rooms/!management%3Aexample/send/m.room.message/"), - })); + expect(client.messages.send).not.toHaveBeenCalled(); }); it("handles built-in commands before connector command fallback", async () => { @@ -1162,8 +1203,8 @@ describe("RuntimeBridge", () => { expect(result).toEqual({ dispatched: true, eventId: "$help", handlers: 1, kind: "message", roomId: "!management:example" }); expect(connector.handleCommand).not.toHaveBeenCalled(); - expect(client.raw.request).toHaveBeenCalledWith(expect.objectContaining({ - body: expect.objectContaining({ body: expect.stringContaining("Available commands:") }), + expect(client.messages.send).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.objectContaining({ body: expect.stringContaining("Available commands:") }), })); }); @@ -1261,7 +1302,7 @@ describe("RuntimeBridge", () => { expect.anything(), expect.objectContaining({ portal }) ); - expect(client.raw.request).not.toHaveBeenCalled(); + expect(client.messages.send).not.toHaveBeenCalled(); }); it("promotes and persists management rooms through the built-in command", async () => { @@ -1288,9 +1329,9 @@ describe("RuntimeBridge", () => { })); expect(dataStore.setManagementRoom).toHaveBeenCalledWith({ mxid: "!ordinary:example" }); - expect(client.raw.request).toHaveBeenCalledTimes(2); - expect(client.raw.request).toHaveBeenNthCalledWith(2, expect.objectContaining({ - body: expect.objectContaining({ body: expect.stringContaining("Available commands:") }), + expect(client.messages.send).toHaveBeenCalledTimes(2); + expect(client.messages.send).toHaveBeenNthCalledWith(2, expect.objectContaining({ + content: expect.objectContaining({ body: expect.stringContaining("Available commands:") }), })); }); @@ -1512,7 +1553,7 @@ function genericEvent(options: { } function commandReplyBody(client: ReturnType, index: number): string { - return (client.raw.request as ReturnType).mock.calls[index]?.[0]?.body?.body; + return (client.messages.send as ReturnType).mock.calls[index]?.[0]?.content?.body; } function createFakeDataStore() { @@ -1559,6 +1600,7 @@ function createFakeMatrixClient(): MatrixClient & { subscription: MatrixSubscrip ensureRegistered: vi.fn(async () => {}), init: vi.fn(async () => ({ botUserId: "@testbot:example", id: "test" })), sendMessage: vi.fn(async () => ({ eventId: "$sent", raw: {}, roomId: "!room:example" })), + setProfile: vi.fn(async () => {}), }, beeper: {} as MatrixClient["beeper"], boot: vi.fn(async () => ({ deviceId: "DEVICE", userId: "@bridge:example" })), @@ -1578,7 +1620,7 @@ function createFakeMatrixClient(): MatrixClient & { subscription: MatrixSubscrip list: vi.fn(), markRead: vi.fn(), redact: vi.fn(async () => undefined), - send: vi.fn(), + send: vi.fn(async (options) => ({ eventId: "$sent", raw: {}, roomId: options.roomId })), sendMedia: vi.fn(async (options) => ({ eventId: "$media", raw: {}, roomId: options.roomId })), }, raw: { diff --git a/packages/bridge/src/bridge.ts b/packages/bridge/src/bridge.ts index 6b0e6b6..91e41dc 100644 --- a/packages/bridge/src/bridge.ts +++ b/packages/bridge/src/bridge.ts @@ -1,16 +1,12 @@ -import { createMatrixClient } from "@beeper/pickle"; import type { MatrixAppserviceBatchSendOptions, MatrixAppserviceInitOptions, MatrixClient, MatrixClientEvent, MatrixMessageEvent, MatrixReactionEvent, MatrixSubscription, SentEvent } from "@beeper/pickle"; import { AppserviceWebsocket, type HTTPProxyRequest, type HTTPProxyResponse } from "./appservice-websocket"; import { BeeperTurnStream, type CreateBeeperTurnStreamOptions } from "./beeper-stream"; -import { createBeeperAppServiceInit } from "./beeper"; import { createRemoteMessage } from "./events"; -import { getOrCreateAppserviceDeviceId } from "./store"; import { handleProvisioningHTTPProxy } from "./provisioning"; import type { BridgeContext, BridgeLogger, BridgeRequestContext, - CreateBeeperBridgeOptions, CreateBridgeOptions, BridgeBackfillOptions, BridgeCreateManagementRoomOptions, @@ -86,7 +82,6 @@ import type { BridgeStateEvent, BridgeStatePayload, BridgeBeeperOptions, - BridgeMatrixConfig, BridgeRemoteBackfillOptions, BridgeRemoteEventOptions, BridgeRemoteMessageOptions, @@ -117,78 +112,6 @@ type GenericMatrixEvent = Extract; }; -export function createBridge(options: CreateBridgeOptions): PickleBridge { - return new RuntimeBridge(options, createMatrixClient(options.matrix)); -} - -export async function createBeeperBridge(options: CreateBeeperBridgeOptions): Promise { - if (!options.store) throw new Error("createBeeperBridge requires store outside the Node entrypoint"); - const appservice = options.matrix?.appservice ?? await createBeeperAppServiceInit(beeperAppServiceOptions({ - address: options.address, - baseDomain: options.baseDomain, - bridge: options.bridge, - bridgeType: options.bridgeType, - getOnly: options.getOnly, - homeserverDomain: options.homeserverDomain, - token: requiredAccount(options).accessToken, - })); - const matrix = { - ...options.matrix, - appservice: options.matrix?.appservice ?? appservice, - beeper: true, - deviceId: options.matrix?.deviceId ?? await getOrCreateAppserviceDeviceId(options.store, options.bridge), - homeserver: options.matrix?.homeserver ?? appservice.homeserver, - store: options.store, - token: options.matrix?.token ?? appservice.registration.asToken, - }; - return new RuntimeBridge(createBeeperRuntimeOptions(options, appservice, matrix), createMatrixClient(matrix)); -} - -export async function createBeeperBridgeWithClient(options: CreateBeeperBridgeOptions, client: MatrixClient): Promise { - const store = options.store ?? options.matrix?.store; - if (!store) throw new Error("createBeeperBridgeWithClient requires store"); - const appservice = options.matrix?.appservice ?? await createBeeperAppServiceInit(beeperAppServiceOptions({ - address: options.address, - baseDomain: options.baseDomain, - bridge: options.bridge, - bridgeType: options.bridgeType, - getOnly: options.getOnly, - homeserverDomain: options.homeserverDomain, - token: requiredAccount(options).accessToken, - })); - const matrix = { - ...options.matrix, - appservice: options.matrix?.appservice ?? appservice, - beeper: true, - deviceId: options.matrix?.deviceId ?? await getOrCreateAppserviceDeviceId(store, options.bridge), - homeserver: options.matrix?.homeserver ?? appservice.homeserver, - store, - token: options.matrix?.token ?? appservice.registration.asToken, - }; - return new RuntimeBridge(createBeeperRuntimeOptions(options, appservice, matrix), client); -} - -function createBeeperRuntimeOptions(options: CreateBeeperBridgeOptions, appservice: NonNullable, matrix: BridgeMatrixConfig): CreateBridgeOptions { - const runtimeOptions: CreateBridgeOptions = { - appservice, - beeper: { - bridge: options.bridge, - ...(options.account?.userId ?? options.ownerUserId ? { ownerUserId: options.account?.userId ?? options.ownerUserId } : {}), - ...(options.bridgeType ? { bridgeType: options.bridgeType } : {}), - }, - connector: options.connector, - matrix, - }; - if (options.dataStore) runtimeOptions.dataStore = options.dataStore; - if (options.log) runtimeOptions.log = options.log; - return runtimeOptions; -} - -function requiredAccount(options: CreateBeeperBridgeOptions) { - if (!options.account) throw new Error("createBeeperBridge requires account unless matrix.appservice is provided"); - return options.account; -} - export class RuntimeBridge implements PickleBridge { readonly connector: CreateBridgeOptions["connector"]; readonly roomState: BridgeRoomStateAPI; @@ -585,11 +508,39 @@ export class RuntimeBridge implements PickleBridge { return sender.startsWith("@") ? sender : this.ghostUserId(sender); } - registerGhost(ghost: Ghost): void { - this.#ghosts.set(ghost.id, ghost); - void this.#dataStore?.setGhost(ghost).catch((error: unknown) => { + async registerGhost(ghost: Ghost): Promise { + const registeredGhost = { + ...ghost, + mxid: ghost.mxid ?? this.ghostUserId(ghost.id), + }; + this.#ghosts.set(registeredGhost.id, registeredGhost); + await this.#dataStore?.setGhost(registeredGhost).catch((error: unknown) => { this.#log("warn", "ghost_store_failed", { error }); }); + await this.#syncGhostProfile(registeredGhost); + } + + async #syncGhostProfile(ghost: Ghost): Promise { + if (!this.#appserviceOptions) return; + const userId = ghost.mxid ?? this.ghostUserId(ghost.id); + const bridgeName = this.connector.getName(); + try { + await this.#matrixClient.appservice.setProfile(stripUndefined({ + avatarUrl: ghostAvatarURL(ghost), + displayName: ghost.displayName, + extra: ghost.profile, + identifiers: ghost.identifiers, + isBridgeBot: false, + isNetworkBot: ghost.isBot, + network: bridgeName.networkId, + remoteId: ghost.id, + service: bridgeName.beeperBridgeType ?? bridgeName.networkId, + userId, + })); + this.#log("info", "ghost_profile_synced", { displayName: ghost.displayName, ghostId: ghost.id, userId }); + } catch (error: unknown) { + this.#log("warn", "ghost_profile_sync_failed", { error, ghostId: ghost.id, userId }); + } } getPortal(portalKey: { id: string; receiver?: string }): Portal | null { @@ -1862,16 +1813,16 @@ export class RuntimeBridge implements PickleBridge { #matrixIntent(): MatrixIntent { return { client: this.#matrixClient, - sendMessage: async (roomId, content) => { - const type = "m.room.message"; - const transactionId = `pickle-bridge-${Date.now()}-${Math.random().toString(16).slice(2)}`; - const result = await this.#matrixClient.raw.request({ - body: content, - method: "PUT", - path: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/${encodeURIComponent(type)}/${transactionId}`, + sendMessage: (roomId, content) => { + const body = stringContent(content.body); + const msgtype = stringContent(content.msgtype); + const messageType = msgtype === "m.notice" || msgtype === "m.emote" ? msgtype : "m.text"; + return this.#matrixClient.messages.send({ + content, + messageType, + roomId, + text: body ?? "", }); - const eventId = eventIdFromRaw(result.body); - return { eventId, raw: result.raw ?? result.body ?? result, roomId }; }, }; } @@ -2075,6 +2026,12 @@ function avatarStateValue(avatar: { mxc?: string; remove?: boolean; url?: string return avatar.mxc ?? avatar.url; } +function ghostAvatarURL(ghost: Ghost): string | undefined { + if (!ghost.avatar) return undefined; + if (ghost.avatar.remove) return ""; + return ghost.avatar.mxc; +} + function isMatrixEditEvent(event: MatrixMessageEvent): boolean { return Boolean(event.edited && matrixEditTargetEventId(event)); } @@ -2300,16 +2257,6 @@ function messageFromSentEvent(messageId: string, partId: string, sent: SentEvent }; } -function eventIdFromRaw(body: unknown): string { - if (body && typeof body === "object" && typeof (body as { event_id?: unknown }).event_id === "string") { - return (body as { event_id: string }).event_id; - } - if (body && typeof body === "object" && typeof (body as { eventId?: unknown }).eventId === "string") { - return (body as { eventId: string }).eventId; - } - return ""; -} - function eventTimestamp(event: RemoteEvent): number | undefined { if ("getTimestamp" in event && typeof event.getTimestamp === "function") { const timestamp = event.getTimestamp(); @@ -2364,27 +2311,6 @@ function domainFromUserID(userId: string): string { return userId.slice(index + 1); } -function beeperAppServiceOptions(input: { - address: string | undefined; - baseDomain: string | undefined; - bridge: string; - bridgeType: string | undefined; - getOnly: boolean | undefined; - homeserverDomain: string | undefined; - token: string; -}) { - const output = { - bridge: input.bridge, - token: input.token, - } as Parameters[0]; - if (input.address !== undefined) output.address = input.address; - if (input.baseDomain !== undefined) output.baseDomain = input.baseDomain; - if (input.bridgeType !== undefined) output.bridgeType = input.bridgeType; - if (input.getOnly !== undefined) output.getOnly = input.getOnly; - if (input.homeserverDomain !== undefined) output.homeserverDomain = input.homeserverDomain; - return output; -} - function normalizeHTTPProxyResponse(response: { body?: unknown; headers?: Record; status: number }): HTTPProxyResponse { const headers: Record = {}; for (const [key, value] of Object.entries(response.headers ?? {})) { diff --git a/packages/bridge/src/index.ts b/packages/bridge/src/node.ts similarity index 86% rename from packages/bridge/src/index.ts rename to packages/bridge/src/node.ts index c6ea4aa..dcd179f 100644 --- a/packages/bridge/src/index.ts +++ b/packages/bridge/src/node.ts @@ -6,12 +6,7 @@ import { RuntimeBridge } from "./bridge"; import { createBridgeDataStore, getOrCreateAppserviceDeviceId } from "./store"; import type { CreateNodeBeeperBridgeOptions, CreateNodeBridgeOptions, PickleBridge } from "./types"; -export { createBridgeDataStore, MatrixBridgeDataStore } from "./store"; -export { BeeperBridgeManagerClient, createBeeperAppService, createBeeperAppServiceInit, createBeeperBridgeManagerClient, fetchBeeperBridges } from "./beeper"; -export type * from "./beeper"; -export type * from "./store"; -export type * from "./types"; -export { RuntimeBridge } from "./bridge"; +export type { CreateNodeBeeperBridgeOptions, CreateNodeBridgeOptions, PickleBridge }; export function createBridge(options: CreateNodeBridgeOptions): PickleBridge { return new RuntimeBridge(options, createMatrixClient(options.matrix)); @@ -50,9 +45,7 @@ export async function createBeeperBridge(options: CreateNodeBeeperBridgeOptions) dataStore: options.dataStore ?? createBridgeDataStore(store), ...(options.log ? { log: options.log } : {}), matrix, - }, createMatrixClient({ - ...matrix, - })); + }, createMatrixClient(matrix)); } function requiredAccount(options: CreateNodeBeeperBridgeOptions) { diff --git a/packages/bridge/src/room-state.ts b/packages/bridge/src/room-state.ts new file mode 100644 index 0000000..9100d60 --- /dev/null +++ b/packages/bridge/src/room-state.ts @@ -0,0 +1,29 @@ +export const BeeperAIRoomStateEvent = { + additionalPrompt: "com.beeper.ai.additional_prompt", + model: "com.beeper.ai.model", + tools: "com.beeper.ai.tools", +} as const; + +export type BeeperAIRoomStateEventType = typeof BeeperAIRoomStateEvent[keyof typeof BeeperAIRoomStateEvent]; + +export interface BeeperAIRoomModelState { + model: string; + name?: string; + reasoning?: string; + reasoning_mode?: string; +} + +export interface BeeperAIRoomPromptState { + prompt: string; +} + +export interface BeeperAIRoomToolsState { + disabled?: string[]; + fetch?: "beeper" | "native" | "off" | string; + search?: "beeper" | "native" | "off" | string; +} + +export type BeeperAIRoomStateContent = + | BeeperAIRoomModelState + | BeeperAIRoomPromptState + | BeeperAIRoomToolsState; diff --git a/packages/bridge/src/types.ts b/packages/bridge/src/types.ts index 4766e27..af10596 100644 --- a/packages/bridge/src/types.ts +++ b/packages/bridge/src/types.ts @@ -21,6 +21,8 @@ import type { import type { BridgeDataStore } from "./store"; import type { BeeperTurnStream, CreateBeeperTurnStreamOptions } from "./beeper-stream"; +export type { MatrixClient, MatrixClientEvent, MatrixMessageEvent, MatrixSubscription } from "@beeper/pickle"; + export type BridgeID = string; export type UserID = string; export type UserLoginID = string; @@ -539,7 +541,7 @@ export interface PickleBridge { loadUserLogin(login: UserLogin): Promise; queue(login: UserLogin): RemoteEventQueue; queueRemoteEvent(login: UserLogin, event: RemoteEvent): QueueRemoteEventResult; - registerGhost(ghost: Ghost): void; + registerGhost(ghost: Ghost): Promise; registerManagementRoom(room: ManagementRoom): void; registerPortal(portal: Portal): void; resolveIdentifier(login: UserLogin, identifier: ResolveIdentifierParams): Promise; @@ -848,8 +850,11 @@ export interface Ghost { avatar?: Avatar; displayName?: string; id: GhostID; + identifiers?: string[]; + isBot?: boolean; metadata?: unknown; mxid?: string; + profile?: Record; } export type BridgeState = "starting" | "running" | "stopping" | "stopped" | "degraded" | "error"; diff --git a/packages/bridge/tsdown.config.ts b/packages/bridge/tsdown.config.ts index 2fcb443..62a9bda 100644 --- a/packages/bridge/tsdown.config.ts +++ b/packages/bridge/tsdown.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsdown"; export default defineConfig({ - entry: ["src/index.ts", "src/types.ts", "src/events.ts", "src/beeper.ts", "src/beeper-stream.ts", "src/media-message.ts", "src/store.ts", "src/appservice-websocket.ts"], + entry: ["src/node.ts", "src/bridge.ts", "src/types.ts", "src/events.ts", "src/beeper.ts", "src/beeper-stream.ts", "src/media-message.ts", "src/room-state.ts", "src/store.ts", "src/appservice-websocket.ts"], format: ["esm"], dts: { sourcemap: false, diff --git a/packages/openclaw/package.json b/packages/openclaw/package.json index dbbf66d..6d62ac5 100644 --- a/packages/openclaw/package.json +++ b/packages/openclaw/package.json @@ -172,7 +172,6 @@ "typecheck": "tsc --noEmit" }, "devDependencies": { - "@beeper/pickle": "workspace:^", "@beeper/pickle-ag-ui": "workspace:^", "@beeper/pickle-bridge": "workspace:^", "@beeper/pickle-state-file": "workspace:^", diff --git a/packages/openclaw/src/appservice.test.ts b/packages/openclaw/src/appservice.test.ts index 86a94d8..20d6b9d 100644 --- a/packages/openclaw/src/appservice.test.ts +++ b/packages/openclaw/src/appservice.test.ts @@ -1,4 +1,4 @@ -import type { CreateNodeBeeperBridgeOptions, PickleBridge } from "@beeper/pickle-bridge"; +import type { CreateNodeBeeperBridgeOptions, PickleBridge } from "@beeper/pickle-bridge/node"; import { describe, expect, it, vi } from "vitest"; import { createDefaultConfig } from "./config"; import { createOpenClawBeeperBridge, startOpenClawBeeperBridge } from "./appservice"; diff --git a/packages/openclaw/src/appservice.ts b/packages/openclaw/src/appservice.ts index 807ba46..438886f 100644 --- a/packages/openclaw/src/appservice.ts +++ b/packages/openclaw/src/appservice.ts @@ -2,7 +2,7 @@ import { createBeeperBridge, type CreateNodeBeeperBridgeOptions, type PickleBridge, -} from "@beeper/pickle-bridge"; +} from "@beeper/pickle-bridge/node"; import type { MatrixAppserviceInitOptions, MatrixAppserviceRegistration } from "@beeper/pickle-bridge/beeper"; import { beeperBaseDomain } from "./beeper-setup"; import { DEFAULT_BEEPER_BRIDGE_TYPE } from "./ids"; diff --git a/packages/openclaw/src/beeper-channel-runtime.test.ts b/packages/openclaw/src/beeper-channel-runtime.test.ts index 907af98..08c568e 100644 --- a/packages/openclaw/src/beeper-channel-runtime.test.ts +++ b/packages/openclaw/src/beeper-channel-runtime.test.ts @@ -110,7 +110,7 @@ function createBridge(client: ReturnType | ReturnType undefined), - getPortalByMXID: vi.fn(() => ({ portalKey: { id: "session:one", receiver: "openclaw:plugin" } })), + getPortalByMXID: vi.fn(() => ({ portalKey: { id: "conversation:one", receiver: "openclaw:plugin" } })), queueRemoteEvent: vi.fn((_login: unknown, event: unknown) => queued.push(event)), uploadMedia: vi.fn((options: Parameters["media"]["upload"]>[0]) => client.media.upload(options)), }; @@ -161,8 +161,6 @@ describe("BeeperChannelRuntime", () => { createdAt: 1, ghostUserId: "@codex:example", id: "binding", - kind: "session", - owner: "bridge", roomId: "!room", sessionKey: "session_1", updatedAt: 1, @@ -228,7 +226,7 @@ describe("BeeperChannelRuntime", () => { const bridge = createBridge(client, queued); bridge.getPortalByMXID.mockImplementation((roomId: string) => roomId === "!room" - ? { portalKey: { id: "session:one", receiver: "openclaw:plugin" } } + ? { portalKey: { id: "conversation:one", receiver: "openclaw:plugin" } } : undefined ); const runtime = new BeeperChannelRuntime({ @@ -240,8 +238,6 @@ describe("BeeperChannelRuntime", () => { createdAt: 1, ghostUserId: "@main:example", id: "binding", - kind: "session", - owner: "bridge", roomId: "!room", sessionKey, updatedAt: 1, @@ -275,8 +271,6 @@ describe("BeeperChannelRuntime", () => { createdAt: 1, ghostUserId: "@codex:example", id: "binding", - kind: "session", - owner: "bridge", roomId: "!room", sessionKey: "agent:codex:desktop", updatedAt: 1, diff --git a/packages/openclaw/src/beeper-channel-runtime.ts b/packages/openclaw/src/beeper-channel-runtime.ts index 4d26976..9147c85 100644 --- a/packages/openclaw/src/beeper-channel-runtime.ts +++ b/packages/openclaw/src/beeper-channel-runtime.ts @@ -16,12 +16,12 @@ import { type RemoteTyping, type SentEvent, type UserLogin, -} from "@beeper/pickle-bridge"; +} from "@beeper/pickle-bridge/types"; import { createRemoteChatInfoChange, createRemoteMessage } from "@beeper/pickle-bridge/events"; import { BeeperTurnStream } from "@beeper/pickle-bridge/beeper-stream"; import { bridgeMediaMessageContent, type BridgeMediaKind } from "@beeper/pickle-bridge/media-message"; import { AGUIEventType } from "./beeper-turn-events"; -import type { OpenClawAgentContact, OpenClawSessionBinding } from "./types"; +import type { OpenClawAgentContact, OpenClawBeeperChannelInfo, OpenClawSessionBinding } from "./types"; export const BEEPER_CHANNEL_RUNTIME_CONTEXT_CAPABILITY = "beeper.runtime"; @@ -71,6 +71,18 @@ export class BeeperChannelRuntime { return this.#getAgents(); } + getRoomInfo(options: { roomId: string }): OpenClawBeeperChannelInfo { + const route = this.#bridgeRoute(options.roomId); + const binding = this.#resolveBinding(options.roomId); + const agent = binding?.agentId ? this.#getAgents().find((candidate) => candidate.agentId === binding.agentId) : undefined; + return { + ...(agent ? { agent } : {}), + ...(binding ? { binding } : {}), + portalKey: route.portalKey, + roomId: route.targetRoomId, + }; + } + async sendText(options: { content?: Record; replyToId?: string | null; diff --git a/packages/openclaw/src/bridge-agent.test.ts b/packages/openclaw/src/bridge-agent.test.ts index e3fba65..7dc16e0 100644 --- a/packages/openclaw/src/bridge-agent.test.ts +++ b/packages/openclaw/src/bridge-agent.test.ts @@ -54,7 +54,8 @@ describe("OpenClawMatrixBridgeAgent", () => { expect(runtime.transport.request).toHaveBeenCalledWith("sessions.patch", { agentId: "codex", key: "agent:codex:main", - reasoningLevel: "on", + reasoningLevel: "stream", + verboseLevel: "full", }); expect(registry.getBindingByRoom("!room:example.com")?.lastRunId).toBe("run_1"); }); @@ -186,9 +187,10 @@ describe("OpenClawMatrixBridgeAgent", () => { it("creates an OpenClaw session before sending the first message in an agent contact DM", async () => { const registry = await tempRegistry(); + const pendingBinding = testBinding(); + delete pendingBinding.sessionKey; registry.upsertBinding({ - ...testBinding(), - sessionKey: "agent:codex", + ...pendingBinding, }); const runtime = runtimeWith({ events: [ @@ -214,7 +216,8 @@ describe("OpenClawMatrixBridgeAgent", () => { expect(runtime.transport.request).toHaveBeenCalledWith("sessions.patch", { agentId: "codex", key: "agent:codex:session_1", - reasoningLevel: "on", + reasoningLevel: "stream", + verboseLevel: "full", }); expect(sendTurn).toHaveBeenCalledWith({ idempotencyKey: "$event", @@ -284,8 +287,6 @@ function testBinding(): OpenClawSessionBinding { createdAt: 1, ghostUserId: "@sh-openclaw_agent_codex:example.com", id: "binding", - kind: "session", - owner: "bridge", roomId: "!room:example.com", sessionKey: "agent:codex:main", updatedAt: 1, diff --git a/packages/openclaw/src/bridge-agent.ts b/packages/openclaw/src/bridge-agent.ts index af9dd14..a34317c 100644 --- a/packages/openclaw/src/bridge-agent.ts +++ b/packages/openclaw/src/bridge-agent.ts @@ -5,7 +5,6 @@ import { type ParsedApprovalResponse, } from "./approval"; import { - BEEPER_SESSION_REASONING_LEVEL, type OpenClawMatrixMessageMetadata, type OpenClawRunRef, type OpenClawSessionCreateOptions, @@ -105,23 +104,24 @@ export class OpenClawMatrixBridgeAgent { } async ensureSession(binding: OpenClawSessionBinding): Promise { - if (binding.sessionKey !== agentPortalSessionKey(binding.agentId)) { + if (binding.sessionKey) { await this.ensureSessionConfiguration({ agentId: binding.agentId, key: binding.sessionKey, - reasoningLevel: BEEPER_SESSION_REASONING_LEVEL, + reasoningLevel: "stream", + verboseLevel: "full", }); return binding.sessionKey; } const createOptions: OpenClawSessionCreateOptions = { agentId: binding.agentId, - reasoningLevel: BEEPER_SESSION_REASONING_LEVEL, + reasoningLevel: "stream", + verboseLevel: "full", }; if (binding.label !== undefined) createOptions.label = binding.label; const session = await this.runtime.createSession(createOptions); this.registry.updateBinding(binding.id, (current) => ({ ...current, - kind: "session", sessionKey: session.key, updatedAt: Date.now(), })); @@ -135,7 +135,3 @@ export class OpenClawMatrixBridgeAgent { this.#configuredSessions.add(options.key); } } - -export function agentPortalSessionKey(agentId: string): string { - return `agent:${agentId}`; -} diff --git a/packages/openclaw/src/config.test.ts b/packages/openclaw/src/config.test.ts index 0ba05e9..fa9d3a5 100644 --- a/packages/openclaw/src/config.test.ts +++ b/packages/openclaw/src/config.test.ts @@ -1,4 +1,4 @@ -import { readFile, stat } from "node:fs/promises"; +import { readFile, stat, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { mkdtemp } from "node:fs/promises"; @@ -46,7 +46,7 @@ describe("OpenClaw bridge config", () => { const config = createConfigFromOpenClawSetup({ channels: { beeper: { - appserviceId: "custom-openclaw", + bridge: { appserviceId: "custom-openclaw" }, dataDir: "/tmp/openclaw-bridge", }, }, @@ -85,4 +85,36 @@ describe("OpenClaw bridge config", () => { expect((await stat(path)).mode & 0o777).toBe(0o600); await expect(readConfig(path)).resolves.toMatchObject(config); }); + + it("reads setup-shaped config from generated channels.beeper.bridge state", async () => { + const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-setup-config-")); + const path = join(dir, "config.json"); + await writeFile(path, `${JSON.stringify({ + channels: { + beeper: { + beeperEnv: "staging", + dataDir: dir, + bridge: { + appserviceId: "sh-openclaw-device", + asToken: "as-secret", + homeserver: "https://matrix.example", + hsToken: "hs-secret", + matrixDeviceId: "DEVICE", + matrixUserId: "@alice:example", + }, + }, + }, + }, null, 2)}\n`); + + await expect(readConfig(path)).resolves.toMatchObject({ + appserviceId: "sh-openclaw-device", + asToken: "as-secret", + beeperEnv: "staging", + dataDir: dir, + homeserver: "https://matrix.example", + hsToken: "hs-secret", + matrixDeviceId: "DEVICE", + matrixUserId: "@alice:example", + }); + }); }); diff --git a/packages/openclaw/src/config.ts b/packages/openclaw/src/config.ts index c990cb6..c65520c 100644 --- a/packages/openclaw/src/config.ts +++ b/packages/openclaw/src/config.ts @@ -48,13 +48,22 @@ export function createDefaultConfig(overrides: Partial = { } export async function readConfig(path = defaultConfigPath()): Promise { - return createDefaultConfig(channelSettingsFromConfigInput(JSON.parse(await readFile(path, "utf8")))); + return createDefaultConfig(configInput(JSON.parse(await readFile(path, "utf8")))); } -function channelSettingsFromConfigInput(input: unknown): Partial { +function configInput(input: unknown): Partial { const record = recordValue(input); const beeper = recordValue(recordValue(record?.channels)?.beeper); - return (beeper ?? record ?? {}) as Partial; + if (beeper) { + const beeperEnv = envBeeperEnv(stringValue(beeper.beeperEnv)); + const bridge = recordValue(beeper.bridge) as Partial | undefined; + const config: Partial = { ...(bridge ?? {}) }; + if (beeperEnv) config.beeperEnv = beeperEnv; + const dataDir = stringValue(beeper.dataDir); + if (dataDir) config.dataDir = dataDir; + return config; + } + return (record ?? {}) as Partial; } export function createConfigFromOpenClawSetup( @@ -63,7 +72,9 @@ export function createConfigFromOpenClawSetup( ): OpenClawBridgeConfig { const settings = getBeeperChannelSettings(cfg); return createDefaultConfig({ - ...settings, + ...settings.bridge, + ...(settings.beeperEnv ? { beeperEnv: settings.beeperEnv } : {}), + ...(settings.dataDir ? { dataDir: settings.dataDir } : {}), ...overrides, }); } @@ -87,3 +98,7 @@ function recordValue(value: unknown): Record | undefined { if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; return value as Record; } + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} diff --git a/packages/openclaw/src/connector.test.ts b/packages/openclaw/src/connector.test.ts index 6a3aa0a..7f182f6 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 { MatrixEdit, MatrixMessage, MatrixReaction, MatrixReactionRemove, MatrixRedaction, UserLogin } from "@beeper/pickle-bridge/types"; import { describe, expect, it, vi } from "vitest"; import { createDefaultConfig } from "./config"; import { createOpenClawConnector, OpenClawNetworkAPI, parseMatrixTextMessage, userLoginFromOpenClawConfig } from "./connector"; @@ -6,7 +6,7 @@ import { OpenClawPluginRuntimeAdapter, type OpenClawGatewayEvent, type OpenClawR import { OpenClawBridgeRegistry } from "./registry"; describe("OpenClawBridgeConnector", () => { - it("exposes bridgev2-shaped metadata and direct plugin capabilities", async () => { + it("exposes bridgev2-shaped metadata and Beeper channel capabilities", async () => { const connector = createOpenClawConnector({ config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), }); @@ -22,7 +22,7 @@ describe("OpenClawBridgeConnector", () => { lookupUsername: true, }); expect(connector.getLoginFlows()).toEqual([]); - expect(() => connector.createLogin({} as never, { id: "@alice:example.com" }, "openclaw.gateway")).toThrow("direct plugin mode"); + expect(() => connector.createLogin({} as never, { id: "@alice:example.com" }, "openclaw.gateway")).toThrow("Beeper channel runtime"); }); it("keeps Beeper Matrix tokens out of OpenClaw plugin login metadata", () => { @@ -106,6 +106,8 @@ describe("OpenClawBridgeConnector", () => { }, displayName: "Codex", id: "codex", + identifiers: ["openclaw:agent:codex", "@sh-openclaw_agent_codex:localhost"], + isBot: true, metadata: { openclaw: { agentId: "codex", @@ -116,6 +118,15 @@ describe("OpenClawBridgeConnector", () => { }, }, mxid: "@sh-openclaw_agent_codex:localhost", + profile: { + "com.beeper.openclaw.agent": { + agentId: "codex", + avatarMxc: "mxc://example/codex", + avatarUrl: "mxc://example/codex", + displayName: "Codex", + ghostUserId: "@sh-openclaw_agent_codex:localhost", + }, + }, }); }); @@ -154,7 +165,6 @@ describe("OpenClawBridgeConnector", () => { agentId: "main", ghostUserId: "@sh-openclaw_agent_main:localhost", label: "Main", - sessionKey: "agent:main", }, }, name: "Main", @@ -168,7 +178,6 @@ describe("OpenClawBridgeConnector", () => { agentId: "codex", ghostUserId: "@sh-openclaw_agent_codex:localhost", label: "Codex", - sessionKey: "agent:codex", }, }, name: "Codex", @@ -184,14 +193,10 @@ describe("OpenClawBridgeConnector", () => { expect(registry.getBindingByRoom("!main:example.com")).toMatchObject({ agentId: "main", id: "agent:main", - kind: "agent", - sessionKey: "agent:main", }); expect(registry.getBindingByRoom("!codex:example.com")).toMatchObject({ agentId: "codex", id: "agent:codex", - kind: "agent", - sessionKey: "agent:codex", }); }); @@ -223,7 +228,6 @@ describe("OpenClawBridgeConnector", () => { expect(sendMessage).not.toHaveBeenCalled(); expect(registry.getBindingById("agent:main")).toMatchObject({ agentId: "main", - kind: "agent", roomId: "!main:example.com", }); }); @@ -271,8 +275,6 @@ describe("OpenClawBridgeConnector", () => { createdAt: 1, ghostUserId: "@main:example.com", id: "existing", - kind: "session", - owner: "bridge", roomId: "!existing:example.com", sessionKey: "agent:main:existing", updatedAt: 1, @@ -296,8 +298,7 @@ describe("OpenClawBridgeConnector", () => { openclaw: { agentId: "main", ghostUserId: "@sh-openclaw_agent_main:localhost", - label: "Main", - sessionKey: "agent:main", + label: "main", }, }, })); @@ -333,11 +334,8 @@ describe("OpenClawBridgeConnector", () => { createdAt: 1, ghostUserId: "@old-codex:example.com", id: "agent:codex", - kind: "agent", label: "Old Codex", - owner: "bridge", roomId: "!codex:example.com", - sessionKey: "agent:codex", updatedAt: 1, }); const runtime = runtimeWith({ @@ -375,18 +373,7 @@ describe("OpenClawBridgeConnector", () => { identifier: "codex", type: "username", })).resolves.toEqual({ - ghost: { - displayName: "Codex", - id: "codex", - metadata: { - openclaw: { - agentId: "codex", - displayName: "Codex", - ghostUserId: "@sh-openclaw_agent_codex:localhost", - }, - }, - mxid: "@sh-openclaw_agent_codex:localhost", - }, + ghost: codexGhost(), userId: "@sh-openclaw_agent_codex:localhost", }); @@ -421,7 +408,6 @@ describe("OpenClawBridgeConnector", () => { agentId: "codex", ghostUserId: "@sh-openclaw_agent_codex:localhost", label: "Codex", - sessionKey: "agent:codex", }, }, portalKey: { id: expect.stringMatching(/^conversation:/), receiver: "openclaw:plugin" }, @@ -439,7 +425,6 @@ describe("OpenClawBridgeConnector", () => { agentId: "codex", ghostUserId: "@sh-openclaw_agent_codex:localhost", label: "Codex", - sessionKey: "agent:codex", }, }, name: "Codex", @@ -449,7 +434,6 @@ describe("OpenClawBridgeConnector", () => { expect(registry.getBindingByRoom("!codex-dm:example.com")).toMatchObject({ agentId: "codex", roomId: "!codex-dm:example.com", - sessionKey: "agent:codex", }); }); @@ -484,10 +468,7 @@ describe("OpenClawBridgeConnector", () => { 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({ @@ -541,18 +522,7 @@ describe("OpenClawBridgeConnector", () => { await expect(api.listContacts({} as BridgeRequestContext, { query: "code" })).resolves.toEqual({ contacts: [{ - ghost: { - displayName: "Codex", - id: "codex", - metadata: { - openclaw: { - agentId: "codex", - displayName: "Codex", - ghostUserId: "@sh-openclaw_agent_codex:localhost", - }, - }, - mxid: "@sh-openclaw_agent_codex:localhost", - }, + ghost: codexGhost(), userId: "@sh-openclaw_agent_codex:localhost", }], }); @@ -576,18 +546,7 @@ describe("OpenClawBridgeConnector", () => { await expect(api.listContacts({} as BridgeRequestContext, { query: "codex" })).resolves.toEqual({ contacts: [{ - ghost: { - displayName: "Codex", - id: "codex", - metadata: { - openclaw: { - agentId: "codex", - displayName: "Codex", - ghostUserId: "@sh-openclaw_agent_codex:localhost", - }, - }, - mxid: "@sh-openclaw_agent_codex:localhost", - }, + ghost: codexGhost(), userId: "@sh-openclaw_agent_codex:localhost", }], }); @@ -611,7 +570,7 @@ describe("OpenClawBridgeConnector", () => { }); const portal = { id: "agent:codex", - metadata: { openclaw: { agentId: "codex", ghostUserId: "@codex:example.com", sessionKey: "agent:codex" } }, + metadata: { openclaw: { agentId: "codex", ghostUserId: "@codex:example.com" } }, mxid: "!room:example.com", portalKey: { id: "agent:codex", receiver: "login" }, receiver: "login", @@ -649,12 +608,13 @@ describe("OpenClawBridgeConnector", () => { })); }); - it("accepts the Beeper owner MXID as a sender in self-hosted cloud rooms", async () => { + it("accepts the Beeper owner MXID as a sender in self-hosted rooms", async () => { const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-owner-sender-test.json"); const runtime = runtimeWith({ events: [{ event: "run.completed", payload: { runId: "run_owner", type: "run.completed" } }], responses: { - "beeper.turn": { runId: "run_owner", sessionKey: "agent:main:main" }, + "sessions.create": { key: "agent:main:owner" }, + "beeper.turn": { runId: "run_owner", sessionKey: "agent:main:owner" }, }, }); runtime.config.matrixUserId = "@owner:beeper-staging.com"; @@ -665,8 +625,7 @@ describe("OpenClawBridgeConnector", () => { registry, runtime, }); - const sessionKey = "agent:main:main"; - const roomId = `!session:${Buffer.from(sessionKey).toString("base64url")}.openclaw:plugin:beeper.local`; + const roomId = "!owner-room:beeper.local"; await api.handleMatrixMessage({} as BridgeRequestContext, { event: { eventId: "$owner" }, @@ -679,9 +638,12 @@ describe("OpenClawBridgeConnector", () => { text: "hello from owner", } as MatrixMessage); + expect(runtime.transport.request).toHaveBeenCalledWith("sessions.create", expect.objectContaining({ + agentId: "main", + })); expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ - sessionKey, message: "hello from owner", + sessionKey: "agent:main:owner", })); }); @@ -707,7 +669,6 @@ describe("OpenClawBridgeConnector", () => { openclaw: { agentId: "codex", ghostUserId: "@codex:example.com", - sessionKey: "agent:codex", }, }, mxid: "!room:example.com", @@ -733,7 +694,6 @@ describe("OpenClawBridgeConnector", () => { }); expect(registry.getBindingByRoom("!room:example.com")).toMatchObject({ agentId: "codex", - kind: "session", sessionKey: "agent:codex:session_1", }); @@ -807,7 +767,6 @@ describe("OpenClawBridgeConnector", () => { openclaw: { agentId: "codex", ghostUserId: "@codex:example.com", - sessionKey: "agent:codex", }, }, mxid: "!room:example.com", @@ -835,7 +794,6 @@ describe("OpenClawBridgeConnector", () => { })); expect(sendMessage).not.toHaveBeenCalled(); expect(registry.getBindingByRoom("!room:example.com")).toMatchObject({ - kind: "session", sessionKey: "agent:codex:session_1", }); }); @@ -926,11 +884,9 @@ describe("OpenClawBridgeConnector", () => { 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, @@ -954,7 +910,6 @@ describe("OpenClawBridgeConnector", () => { openclaw: { agentId: "codex", ghostUserId: "@codex:example.com", - sessionKey: "agent:codex", }, }, mxid: "!room:example.com", @@ -1048,7 +1003,6 @@ describe("OpenClawBridgeConnector", () => { openclaw: { agentId: "codex", ghostUserId: "@codex:example.com", - sessionKey: "agent:codex", }, }, mxid: "!room:example.com", @@ -1086,11 +1040,9 @@ describe("OpenClawBridgeConnector", () => { 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, @@ -1114,7 +1066,7 @@ describe("OpenClawBridgeConnector", () => { }); const portal = { id: "agent:codex", - metadata: { openclaw: { agentId: "codex", ghostUserId: "@codex:example.com", sessionKey: "agent:codex" } }, + metadata: { openclaw: { agentId: "codex", ghostUserId: "@codex:example.com" } }, mxid: "!room:example.com", portalKey: { id: "agent:codex", receiver: "login" }, receiver: "login", @@ -1265,7 +1217,6 @@ describe("OpenClawBridgeConnector", () => { })); expect(runtime.transport.request).toHaveBeenCalledWith("sessions.create", expect.objectContaining({ agentId: "main", - key: expect.stringMatching(/^agent:main:beeper:/u), label: "New OpenClaw Session", })); expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ @@ -1279,7 +1230,7 @@ describe("OpenClawBridgeConnector", () => { sessionKey: "agent:main:auto", }); expect(registerPortal).toHaveBeenCalledWith(expect.objectContaining({ - id: "session:YWdlbnQ6bWFpbjphdXRv", + id: "!cloud-room:example.com", metadata: { openclaw: { agentId: "main", @@ -1290,7 +1241,7 @@ describe("OpenClawBridgeConnector", () => { }, mxid: "!cloud-room:example.com", portalKey: { - id: "session:YWdlbnQ6bWFpbjphdXRv", + id: "!cloud-room:example.com", receiver: "openclaw:plugin", }, receiver: "openclaw:plugin", @@ -1387,99 +1338,31 @@ 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: { - "beeper.turn": { runId: "run_rebuilt", sessionKey: "agent:codex:dashboard:one" }, - }, - }); - runtime.config.homeserverDomain = "example.com"; - const api = new OpenClawNetworkAPI({ - config: runtime.config, - login: login(), - registry, - runtime, - }); - 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: "@sh-openclaw_agent_codex:example.com", - owner: "imported", - sessionKey, - }); - expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ - message: "hello from persisted portal", - sessionKey, - })); - }); - - 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: { - "beeper.turn": { runId: "run_cloud", sessionKey: "agent:main:dashboard:abc" }, - }, - }); - runtime.config.homeserverDomain = "beeper.local"; - const api = new OpenClawNetworkAPI({ - config: runtime.config, - login: login(), - registry, - runtime, - }); - 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: "@sh-openclaw_agent_main:beeper.local", - owner: "imported", - sessionKey, - }); - expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ - message: "hello from cloud room", - sessionKey, - })); - }); - }); function login(): UserLogin { return { id: "openclaw:plugin", metadata: {}, userId: "@alice:example.com" }; } +function codexGhost() { + const contact = { + agentId: "codex", + displayName: "Codex", + ghostUserId: "@sh-openclaw_agent_codex:localhost", + }; + return { + displayName: "Codex", + id: "codex", + identifiers: ["openclaw:agent:codex", "@sh-openclaw_agent_codex:localhost"], + isBot: true, + metadata: { openclaw: contact }, + mxid: "@sh-openclaw_agent_codex:localhost", + profile: { "com.beeper.openclaw.agent": contact }, + }; +} + function connectContext() { - const registerGhost = vi.fn(); + const registerGhost = vi.fn(async () => {}); const registerPortal = vi.fn(); const createPortal = vi.fn(async (_login: UserLogin, portal: { id: string; metadata?: unknown; portalKey?: { id: string; receiver?: string } }) => ({ ...portal, diff --git a/packages/openclaw/src/connector.ts b/packages/openclaw/src/connector.ts index 0ed16a0..271977b 100644 --- a/packages/openclaw/src/connector.ts +++ b/packages/openclaw/src/connector.ts @@ -51,18 +51,17 @@ import { ResolveIdentifierParams, ResolveIdentifierResponse, UserLogin, -} from "@beeper/pickle-bridge"; +} from "@beeper/pickle-bridge/types"; import { parseApprovalReactionContent, parseApprovalResponseContent } from "./approval"; import { BEEPER_CHANNEL_RUNTIME_CONTEXT_CAPABILITY, BeeperChannelRuntime, setBeeperChannelRuntimeForHost, } from "./beeper-channel-runtime"; -import { agentPortalSessionKey, OpenClawMatrixBridgeAgent } from "./bridge-agent"; +import { OpenClawMatrixBridgeAgent } from "./bridge-agent"; import { createDefaultConfig } from "./config"; import { parseMatrixTextMessage, type ParsedMatrixTextMessage } from "./matrix-parser"; import { - BEEPER_SESSION_REASONING_LEVEL, createOpenClawHostRuntimeAdapter, type OpenClawBridgeRuntime, OpenClawPluginRuntimeAdapter, @@ -124,7 +123,7 @@ export class OpenClawBridgeConnector implements BridgeConnector { if (runtime) return runtime; - throw new Error("OpenClaw direct plugin runtime is required"); + throw new Error("OpenClaw host runtime is required"); }); } @@ -205,7 +204,7 @@ export class OpenClawBridgeConnector implements BridgeConnector 0 ? { attachments: parsed.attachments } : {}), @@ -367,11 +365,13 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor sender: msg.sender.userId, text: parsed.text, }); + const updatedBinding = this.#registry.getBindingByRoom(msg.portal.mxid); + if (updatedBinding) this.registerCanonicalPortalForBinding(ctx, msg.portal, updatedBinding); ctx.log?.("info", "openclaw_matrix_message_dispatched", { eventId: msg.event.eventId, - lastRunId: this.#registry.getBindingByRoom(msg.portal.mxid)?.lastRunId, + lastRunId: updatedBinding?.lastRunId, roomId: msg.portal.mxid, - sessionKey: this.#registry.getBindingByRoom(msg.portal.mxid)?.sessionKey, + sessionKey: updatedBinding?.sessionKey, }); } return { pending: false }; @@ -611,23 +611,14 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor ): Promise { const existing = this.#registry.getBindingByRoom(roomId); if (existing) return existing; - const session = await this.#runtime.createSession({ - agentId, - key: newBeeperSessionKey(agentId), - label, - reasoningLevel: BEEPER_SESSION_REASONING_LEVEL, - }); 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); @@ -647,7 +638,7 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor const contact = this.#registry.getAgent("main") ?? this.#registry.data.agents[0] ?? - agentContactFromOpenClawAgent(this.#runtime.config, { id: "main", name: "Main" }); + agentContactFromOpenClawAgent(this.#runtime.config, { id: "main" }); if (!this.#registry.getAgent(contact.agentId)) this.#registry.upsertAgent(contact); } @@ -657,7 +648,7 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor this.#registry.updateBinding(binding.id, (current) => stripUndefined({ ...current, ghostUserId: contact.ghostUserId, - ...(current.kind === "agent" ? { label: contact.displayName } : {}), + ...(!current.sessionKey ? { label: contact.displayName } : {}), updatedAt: Date.now(), })); } @@ -718,12 +709,8 @@ function inboundActivityPatch(now = Date.now()): OpenClawBridgeActivityPatch { }; } -function newBeeperSessionKey(agentId: string): string { - return `agent:${agentId}:beeper:${randomUUID()}`; -} - function canonicalPortalForBinding(portal: Portal, binding: OpenClawSessionBinding, receiver: string): Portal { - const id = portalIdForSession(binding.sessionKey); + const id = portal.portalKey.id || portal.id; return { ...portal, id, @@ -734,7 +721,7 @@ function canonicalPortalForBinding(portal: Portal, binding: OpenClawSessionBindi agentId: binding.agentId, ghostUserId: binding.ghostUserId, ...(binding.label ? { label: binding.label } : {}), - sessionKey: binding.sessionKey, + ...(binding.sessionKey ? { sessionKey: binding.sessionKey } : {}), }), }, mxid: binding.roomId, @@ -758,7 +745,7 @@ function streamTargetRelationPatch( ): Partial> { if (!binding?.lastStreamTargetEventId || binding.lastStreamTargetEventId !== targetEventId) return {}; const patch: Partial> = { - targetSessionKey: binding.sessionKey, + ...(binding.sessionKey ? { targetSessionKey: binding.sessionKey } : {}), }; const targetRunId = binding.lastStreamRunId ?? binding.lastRunId; if (targetRunId) patch.targetRunId = targetRunId; @@ -789,7 +776,6 @@ function matrixMetadataFromParsed( } function portalForAgentWelcome(contact: OpenClawAgentContact, receiver: string): Portal { - const sessionKey = agentPortalSessionKey(contact.agentId); const id = agentPortalBindingId(contact.agentId); return { id, @@ -798,7 +784,6 @@ function portalForAgentWelcome(contact: OpenClawAgentContact, receiver: string): agentId: contact.agentId, ghostUserId: contact.ghostUserId, label: contact.displayName, - sessionKey, }), }, portalKey: { id, receiver }, @@ -820,7 +805,6 @@ function portalForAgentConversation(contact: OpenClawAgentContact, receiver: str agentId: contact.agentId, ghostUserId: contact.ghostUserId, ...(label ? { label } : {}), - sessionKey: agentPortalSessionKey(contact.agentId), }), }, portalKey: { id, receiver }, @@ -839,10 +823,6 @@ function findAgentContact(contacts: readonly OpenClawAgentContact[], identifier: ); } -function portalIdForSession(sessionKey: string): string { - return `session:${Buffer.from(sessionKey).toString("base64url")}`; -} - function contactResponse(contact: OpenClawAgentContact, portal?: Portal): ResolveIdentifierResponse { return { ghost: agentGhost(contact), @@ -857,8 +837,11 @@ function agentGhost(contact: OpenClawAgentContact) { ...(avatar ? { avatar } : {}), displayName: contact.displayName, id: contact.agentId, + identifiers: [`openclaw:agent:${contact.agentId}`, contact.ghostUserId], + isBot: true, metadata: { openclaw: contact }, mxid: contact.ghostUserId, + profile: { "com.beeper.openclaw.agent": contact }, }; } @@ -884,72 +867,27 @@ function agentAvatar(contact: OpenClawAgentContact): Avatar | undefined { function bindingFromPortal(portal: Portal, config: OpenClawBridgeConfig): OpenClawSessionBinding | undefined { const metadata = recordValue(portal.metadata)?.openclaw; const openclaw = recordValue(metadata); + if (!openclaw) return undefined; const roomId = portal.mxid; - const portalId = 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 portalId = portal.portalKey.id || portal.id; + const sessionKey = stringValue(openclaw.sessionKey); + const agentId = stringValue(openclaw.agentId); + const ghostUserId = stringValue(openclaw.ghostUserId) ?? (agentId ? agentGhostUserId(config, agentId) : undefined); + if (!roomId || !agentId || !ghostUserId) return undefined; const now = Date.now(); - const label = stringValue(openclaw?.label); + const label = stringValue(openclaw.label); return { agentId, createdAt: now, ghostUserId, id: portalId.startsWith("agent:") ? portalId : Buffer.from(roomId).toString("base64url"), - kind: portalId.startsWith("agent:") ? "agent" : "session", ...(label ? { label } : {}), - owner: openclaw ? "bridge" : "imported", roomId, - sessionKey, + ...(sessionKey ? { sessionKey } : {}), updatedAt: now, }; } -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.startsWith("conversation:") ? 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; - } - } - 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; -} - export function userLoginFromOpenClawConfig(config: OpenClawBridgeConfig): UserLogin { return { id: "openclaw:plugin", diff --git a/packages/openclaw/src/integration.test.ts b/packages/openclaw/src/integration.test.ts index a753fdd..d4fa35d 100644 --- a/packages/openclaw/src/integration.test.ts +++ b/packages/openclaw/src/integration.test.ts @@ -1,5 +1,5 @@ -import type { MatrixClient, MatrixClientEvent, MatrixMessageEvent, MatrixSubscription } from "@beeper/pickle"; -import { RuntimeBridge } from "@beeper/pickle-bridge"; +import { RuntimeBridge } from "@beeper/pickle-bridge/bridge"; +import type { MatrixClient, MatrixClientEvent, MatrixMessageEvent, MatrixSubscription } from "@beeper/pickle-bridge/types"; import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { resolve } from "node:path"; @@ -44,7 +44,6 @@ describe("OpenClaw bridge integration", () => { openclaw: { agentId: "codex", ghostUserId: "@sh-openclaw_agent_codex:matrix.example", - sessionKey: "agent:codex", }, }, mxid: "!codex:example", @@ -111,7 +110,6 @@ describe("OpenClaw bridge integration", () => { openclaw: { agentId: "codex", ghostUserId: "@sh-openclaw_agent_codex:matrix.example", - sessionKey: "agent:codex", }, }, mxid: "!codex:example", @@ -566,6 +564,7 @@ function createFakeMatrixClient(): MatrixClient & { subscription: MatrixSubscrip ensureRegistered: vi.fn(async () => {}), init: vi.fn(async () => ({ botUserId: "@sh-openclawbot:example", id: "openclaw" })), sendMessage: vi.fn(async () => ({ eventId: "$sent", raw: {}, roomId: "!room:example" })), + setProfile: vi.fn(async () => {}), }, beeper: { aiRunStreams: beeperAIRunStreams, streams: beeperStreams } as unknown as MatrixClient["beeper"], boot: vi.fn(async () => ({ deviceId: "DEVICE", userId: "@sh-openclawbot:example" })), diff --git a/packages/openclaw/src/matrix-parser.ts b/packages/openclaw/src/matrix-parser.ts index 3179822..01d249b 100644 --- a/packages/openclaw/src/matrix-parser.ts +++ b/packages/openclaw/src/matrix-parser.ts @@ -1,4 +1,4 @@ -import type { MatrixMessage } from "@beeper/pickle-bridge"; +import type { MatrixMessage } from "@beeper/pickle-bridge/types"; export interface ParsedMatrixTextMessage { attachments: unknown[]; diff --git a/packages/openclaw/src/openclaw-extension.test.ts b/packages/openclaw/src/openclaw-extension.test.ts index a78b991..cdb7d69 100644 --- a/packages/openclaw/src/openclaw-extension.test.ts +++ b/packages/openclaw/src/openclaw-extension.test.ts @@ -182,11 +182,11 @@ describe("OpenClaw plugin package metadata", () => { expect(packageJson.openclaw?.runtimeSetupEntry).toBe("./dist/setup-entry.mjs"); 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(devDependencies.some(([name]) => name === "@beeper/pickle")).toBe(false); expect(devDependencies.find(([, version]) => version === "workspace:*")).toBeUndefined(); }); }); diff --git a/packages/openclaw/src/openclaw-runtime.test.ts b/packages/openclaw/src/openclaw-runtime.test.ts index 338fb43..aa8c53c 100644 --- a/packages/openclaw/src/openclaw-runtime.test.ts +++ b/packages/openclaw/src/openclaw-runtime.test.ts @@ -33,6 +33,22 @@ describe("OpenClawPluginRuntimeAdapter", () => { expect(transport.request).toHaveBeenCalledWith("agents.list", {}); }); + it("uses the agent id as the default ghost name when OpenClaw has no explicit agent list", async () => { + const transport = createOpenClawHostRuntimeAdapter({ + config: { + current: () => ({ + agents: { + defaults: { workspace: "/tmp/openclaw/workspace" }, + }, + }), + }, + }); + + await expect(transport.request("agents.list", {})).resolves.toEqual({ + agents: [{ id: "main", displayName: "main" }], + }); + }); + it("creates sessions through OpenClaw RPC and rejects sends without a host channel runtime", async () => { const transport = fakeTransport({ "sessions.create": { key: "agent:codex:main", sessionId: "session_1" }, @@ -63,7 +79,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { transport, }); - await expect(runtime.createSession({ agentId: "codex", label: "Main", reasoningLevel: "on" })).resolves.toMatchObject({ + await expect(runtime.createSession({ agentId: "codex", label: "Main", reasoningLevel: "stream" })).resolves.toMatchObject({ agentId: "codex", key: "agent:codex:main", label: "Main", @@ -75,7 +91,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { expect(transport.request).toHaveBeenCalledWith("sessions.patch", { agentId: "codex", key: "agent:codex:main", - reasoningLevel: "on", + reasoningLevel: "stream", }); }); @@ -663,6 +679,9 @@ describe("OpenClawPluginRuntimeAdapter", () => { expect.objectContaining({ kind: "tool_result", output: "loading", preliminary: true, toolCallId: "tool-c", toolName: "search" }), expect.objectContaining({ kind: "tool_result", output: "checking docs", preliminary: true, toolCallId: "plan", toolName: "plan" }), expect.objectContaining({ kind: "tool_result", output: "stdout", preliminary: true, toolCallId: "cmd-1", toolName: "shell" }), + expect.objectContaining({ kind: "tool_result", state: "complete", toolCallId: "tool-c", toolName: "search" }), + expect.objectContaining({ kind: "tool_result", state: "complete", toolCallId: "plan", toolName: "plan" }), + expect.objectContaining({ kind: "tool_result", state: "complete", toolCallId: "cmd-1", toolName: "shell" }), expect.objectContaining({ input: { command: "/bin/zsh -lc \"date '+%Y-%m-%d %H:%M:%S %Z'\"", @@ -841,7 +860,7 @@ function createTestBeeperChannelRuntime(aiRunStreams: ReturnType undefined), - getPortalByMXID: vi.fn(() => ({ portalKey: { id: "session:one", receiver: "openclaw:plugin" } })), + getPortalByMXID: vi.fn(() => ({ portalKey: { id: "conversation:one", receiver: "openclaw:plugin" } })), queueRemoteEvent: vi.fn(), }; return new BeeperChannelRuntime({ @@ -857,8 +876,6 @@ function createTestBeeperChannelRuntime(aiRunStreams: ReturnType> description: stringValue(record.description), })]; }); - return normalized.length > 0 ? normalized : [{ id: "main", displayName: "OpenClaw" }]; + return normalized.length > 0 ? normalized : [{ id: "main", displayName: "main" }]; } function sessionsFromPluginRuntime(runtime: OpenClawHostRuntime, params: unknown): Array> { @@ -690,9 +697,11 @@ async function createSessionInPluginRuntime(runtime: OpenClawHostRuntime, params origin: recordValue(entry.origin) ?? { provider: "beeper", surface: "beeper", chatType: "direct" }, provider: stringValue(entry.provider) ?? "beeper", reasoningLevel: stringValue(record.reasoningLevel) ?? stringValue(entry.reasoningLevel), + thinkingLevel: stringValue(record.thinkingLevel) ?? stringValue(entry.thinkingLevel), sessionFile: stringValue(entry.sessionFile) ?? resolvePluginSessionFile(runtime, agentId, sessionId, entry), sessionId, updatedAt: typeof entry.updatedAt === "number" ? entry.updatedAt : now, + verboseLevel: stringValue(record.verboseLevel) ?? stringValue(entry.verboseLevel), }); await runtime.agent?.session?.upsertSessionEntry?.({ agentId, entry: next, sessionKey }); return { agentId, key: sessionKey, label, sessionFile: next.sessionFile, sessionId }; @@ -709,6 +718,8 @@ async function patchSessionInPluginRuntime(runtime: OpenClawHostRuntime, params: ...entry, ...(record.label !== undefined ? { label: stringValue(record.label) } : {}), ...(record.reasoningLevel !== undefined ? { reasoningLevel: stringValue(record.reasoningLevel) } : {}), + ...(record.thinkingLevel !== undefined ? { thinkingLevel: stringValue(record.thinkingLevel) } : {}), + ...(record.verboseLevel !== undefined ? { verboseLevel: stringValue(record.verboseLevel) } : {}), updatedAt: Date.now(), }); await runtime.agent?.session?.upsertSessionEntry?.({ agentId, entry: next, sessionKey }); @@ -1776,6 +1787,25 @@ function createBeeperReplyStreamEmitter(base: { }); } }; + const completePendingToolsForFinal = async () => { + if (pendingToolCalls.size === 0) return; + const toolCallIds = [...pendingToolCalls]; + channelRuntime.debug("openclaw_beeper_stream_completing_pending_tools", { + pendingToolCalls: toolCallIds, + roomId: base.roomId, + runId: base.runId, + }); + for (const toolCallId of toolCallIds) { + const toolName = rememberedToolName(toolCallId, "tool") ?? "tool"; + await publishPart({ + kind: "tool_result", + state: "complete", + toolCallId, + toolName, + }); + markToolComplete(toolCallId); + } + }; return { start: ensureStarted, trackExternal, @@ -1959,7 +1989,8 @@ function createBeeperReplyStreamEmitter(base: { const preliminary = !isCompletePhase(phase) && !isCompletePhase(status); const error = data.error; rememberTool(toolCallId, toolName, input); - if (!preliminary) markToolComplete(toolCallId); + if (preliminary) markToolPending(toolCallId); + else markToolComplete(toolCallId); emit("tool.call.updated", { output, phase, @@ -2008,6 +2039,9 @@ function createBeeperReplyStreamEmitter(base: { if (!output) return; const phase = stringValue(data.phase); const preliminary = phase !== "complete" && phase !== "end"; + rememberTool("plan", "plan"); + if (preliminary) markToolPending("plan"); + else markToolComplete("plan"); emit("tool.call.completed", { output, preliminary, @@ -2183,6 +2217,7 @@ function createBeeperReplyStreamEmitter(base: { await drainExternal(); await waitForPendingTools(); await drainExternal(); + await completePendingToolsForFinal(); if (!hasPublished || finalized) return; finalized = true; channelRuntime.debug("openclaw_beeper_stream_finalizing", { diff --git a/packages/openclaw/src/registry.test.ts b/packages/openclaw/src/registry.test.ts index cb359a4..85fe331 100644 --- a/packages/openclaw/src/registry.test.ts +++ b/packages/openclaw/src/registry.test.ts @@ -20,8 +20,6 @@ describe("OpenClawBridgeRegistry", () => { createdAt: 1, ghostUserId: "@sh-openclaw_agent_codex:example.com", id: "binding", - kind: "session", - owner: "bridge", roomId: "!room:example.com", sessionKey: "agent:codex:main", updatedAt: 1, diff --git a/packages/openclaw/src/registry.ts b/packages/openclaw/src/registry.ts index 9189b35..e5095f9 100644 --- a/packages/openclaw/src/registry.ts +++ b/packages/openclaw/src/registry.ts @@ -72,8 +72,8 @@ export class OpenClawBridgeRegistry { 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; + if (index === -1) this.#data.bindings.push(normalizeBinding(binding)); + else this.#data.bindings[index] = normalizeBinding(binding); } updateBinding( @@ -110,8 +110,29 @@ function normalizeRegistry(value: unknown): OpenClawBridgeRegistryData { const data = value as Partial; return { agents: Array.isArray(data.agents) ? data.agents : [], - bindings: Array.isArray(data.bindings) ? data.bindings : [], + bindings: Array.isArray(data.bindings) ? data.bindings.map(normalizeBinding).filter(Boolean) : [], dedupe: data.dedupe && typeof data.dedupe === "object" ? data.dedupe : {}, schemaVersion: 1, }; } + +function normalizeBinding(value: unknown): OpenClawSessionBinding { + const binding = value as OpenClawSessionBinding; + return { + agentId: binding.agentId, + createdAt: binding.createdAt, + ghostUserId: binding.ghostUserId, + id: binding.id, + ...(binding.cwd ? { cwd: binding.cwd } : {}), + ...(binding.humanGhostUserId ? { humanGhostUserId: binding.humanGhostUserId } : {}), + ...(binding.label ? { label: binding.label } : {}), + ...(binding.lastMatrixEventId ? { lastMatrixEventId: binding.lastMatrixEventId } : {}), + ...(binding.lastRunId ? { lastRunId: binding.lastRunId } : {}), + ...(binding.lastStreamRunId ? { lastStreamRunId: binding.lastStreamRunId } : {}), + ...(binding.lastStreamTargetEventId ? { lastStreamTargetEventId: binding.lastStreamTargetEventId } : {}), + roomId: binding.roomId, + ...(binding.sessionKey ? { sessionKey: binding.sessionKey } : {}), + ...(binding.spaceId ? { spaceId: binding.spaceId } : {}), + updatedAt: binding.updatedAt, + }; +} diff --git a/packages/openclaw/src/setup.test.ts b/packages/openclaw/src/setup.test.ts index 60fbca3..a5b6381 100644 --- a/packages/openclaw/src/setup.test.ts +++ b/packages/openclaw/src/setup.test.ts @@ -46,15 +46,14 @@ describe("OpenClaw Beeper official channel contracts", () => { name: "default Beeper message actions", cfg: {}, expectedActions: [ + "channel-edit", + "channel-info", "delete", "edit", "mark_unread", "react", "read", "send", - "set-room-avatar", - "set-room-name", - "set-room-topic", ], }], }); @@ -88,12 +87,14 @@ describe("OpenClaw Beeper official channel contracts", () => { { name: "configured account", cfg: applyBeeperChannelSettings({}, { - asToken: "as", enabled: true, - homeserver: "https://matrix.example", - hsToken: "hs", - matrixDeviceId: "DEV", - matrixUserId: "@alice:example", + bridge: { + asToken: "as", + homeserver: "https://matrix.example", + hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + }, }), expectedState: "configured", runtime: { accountId: "default", configured: true, enabled: true, running: true }, @@ -296,9 +297,8 @@ describe("OpenClaw Beeper setup surface", () => { "react", "read", "mark_unread", - "set-room-name", - "set-room-topic", - "set-room-avatar", + "channel-info", + "channel-edit", ], capabilities: [], }); @@ -358,13 +358,15 @@ describe("OpenClaw Beeper setup surface", () => { inbound: { buildContext: vi.fn(), dispatchReply: vi.fn() }, }; const cfg = applyBeeperChannelSettings({}, { - asToken: "as", dataDir: "/tmp/openclaw-beeper", enabled: true, - homeserver: "https://matrix.example", - hsToken: "hs", - matrixDeviceId: "DEV", - matrixUserId: "@alice:example", + bridge: { + asToken: "as", + homeserver: "https://matrix.example", + hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + }, }); const task = startBeeperGatewayAccount({ @@ -399,13 +401,15 @@ describe("OpenClaw Beeper setup surface", () => { appserviceMocks.startOpenClawBeeperBridge.mockResolvedValueOnce({ stop }); const abort = new AbortController(); const cfg = applyBeeperChannelSettings({}, { - asToken: "as", dataDir: "/tmp/openclaw-beeper", enabled: true, - homeserver: "https://matrix.example", - hsToken: "hs", - matrixDeviceId: "DEV", - matrixUserId: "@alice:example", + bridge: { + asToken: "as", + homeserver: "https://matrix.example", + hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + }, }); const ctx = { abortSignal: abort.signal, @@ -543,12 +547,14 @@ describe("OpenClaw Beeper setup surface", () => { expect(result.accountId).toBe("default"); expect(getBeeperChannelSettings(cfg)).toMatchObject({ enabled: true, - asToken: "as", - bridgeId: "sh-openclaw-dev", - homeserver: "https://matrix.example", - hsToken: "hs", - matrixDeviceId: "DEV", - matrixUserId: "@alice:example", + bridge: { + asToken: "as", + bridgeId: "sh-openclaw-dev", + homeserver: "https://matrix.example", + hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + }, }); }); @@ -597,14 +603,16 @@ describe("OpenClaw Beeper setup surface", () => { }, }); expect(getBeeperChannelSettings(cfg)).toMatchObject({ - appserviceId: "sh-openclaw-dev", - asToken: "as", beeperEnv: "dev", - bridgeId: "sh-openclaw-dev", - homeserver: "https://matrix.example", - hsToken: "hs", - matrixDeviceId: "DEV", - matrixUserId: "@alice:example", + bridge: { + appserviceId: "sh-openclaw-dev", + asToken: "as", + bridgeId: "sh-openclaw-dev", + homeserver: "https://matrix.example", + hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + }, }); }); @@ -613,12 +621,14 @@ describe("OpenClaw Beeper setup surface", () => { enabled: true, }))).toBe(false); const cfg = applyBeeperChannelSettings({}, { - asToken: "as", enabled: true, - homeserver: "https://matrix.example", - hsToken: "hs", - matrixDeviceId: "DEV", - matrixUserId: "@alice:example", + bridge: { + asToken: "as", + homeserver: "https://matrix.example", + hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + }, }); expect(isBeeperChannelConfigured(cfg)).toBe(true); }); @@ -670,13 +680,15 @@ describe("OpenClaw Beeper setup surface", () => { }); expect(getBeeperChannelSettings(cfg)).toMatchObject({ enabled: true, - appserviceId: "sh-openclaw-dev", - asToken: "as", - bridgeId: "sh-openclaw-dev", - homeserver: "https://matrix.example", - hsToken: "hs", - matrixDeviceId: "DEV", - matrixUserId: "@alice:example", + bridge: { + appserviceId: "sh-openclaw-dev", + asToken: "as", + bridgeId: "sh-openclaw-dev", + homeserver: "https://matrix.example", + hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + }, }); }); @@ -735,10 +747,12 @@ describe("OpenClaw Beeper setup surface", () => { channels: { beeper: { dataDir: "/tmp/beeper", - homeserver: "https://matrix.example", - hsToken: "hs", - matrixDeviceId: "DEV", - matrixUserId: "@alice:example", + bridge: { + homeserver: "https://matrix.example", + hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + }, }, }, }); @@ -783,7 +797,7 @@ describe("OpenClaw Beeper setup surface", () => { client: client as never, })), flushRemoteEvents: vi.fn(async () => undefined), - getPortalByMXID: vi.fn(() => ({ portalKey: { id: "session:one", receiver: "openclaw:plugin" } })), + getPortalByMXID: vi.fn(() => ({ portalKey: { id: "conversation:one", receiver: "openclaw:plugin" } })), queueRemoteEvent: vi.fn((_login: unknown, event: unknown) => queued.push(event)), }; const runtime = new BeeperChannelRuntime({ @@ -800,8 +814,6 @@ describe("OpenClaw Beeper setup surface", () => { createdAt: 1, ghostUserId: "@codex:example", id: "binding", - kind: "session", - owner: "bridge", roomId: "!room", sessionKey: "session_1", updatedAt: 1, @@ -858,17 +870,18 @@ describe("OpenClaw Beeper setup surface", () => { action: "mark_unread", params: { eventId: sentMessageId, roomId: "!room" }, }); - await beeperChannelPlugin.actions.handleAction({ - action: "set-room-name", - params: { name: "Agent room", roomId: "!room" }, - }); - await beeperChannelPlugin.actions.handleAction({ - action: "set-room-topic", - params: { roomId: "!room", topic: "Planning" }, + await expect(beeperChannelPlugin.actions.handleAction({ + action: "channel-info", + params: { channelId: "!room" }, + })).resolves.toMatchObject({ + details: { + action: "channel-info", + ok: true, + }, }); await beeperChannelPlugin.actions.handleAction({ - action: "set-room-avatar", - params: { avatarMxc: "mxc://example/avatar2", roomId: "!room" }, + action: "channel-edit", + params: { avatarMxc: "mxc://example/avatar2", channelId: "!room", name: "Agent room", topic: "Planning" }, }); expect(queued.map((event) => (event as { getType: () => string }).getType())).toEqual([ "reaction", diff --git a/packages/openclaw/src/setup.ts b/packages/openclaw/src/setup.ts index de8ae86..4c1a352 100644 --- a/packages/openclaw/src/setup.ts +++ b/packages/openclaw/src/setup.ts @@ -2,7 +2,7 @@ import { createChannelPluginBase, createChatChannelPlugin } from "openclaw/plugi 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 type { BridgeLogger } from "@beeper/pickle-bridge/types"; import { createConfigFromOpenClawSetup, defaultDataDir } from "./config"; import beeperChannelConfigSchema from "./beeper-channel-config.schema.json"; import type { setupOpenClawBeeperBridge, SetupOpenClawBeeperBridgeOptions } from "./beeper-setup"; @@ -14,17 +14,21 @@ import { OpenClawBridgeRegistry, defaultRegistryPath } from "./registry"; export type OpenClawSetupConfig = OpenClawConfig; export interface BeeperChannelSettings { - appserviceId?: string; - asToken?: string; beeperEnv?: "production" | "staging" | "dev" | "local"; - bridgeId?: string; + bridge?: BeeperGeneratedBridgeSettings; dataDir?: string; enabled?: boolean; +} + +export interface BeeperGeneratedBridgeSettings { + appserviceId?: string; + asToken?: string; + bridgeId?: string; homeserver?: string; + homeserverDomain?: string; hsToken?: string; matrixDeviceId?: string; matrixUserId?: string; - homeserverDomain?: string; } export interface BeeperSetupInput { @@ -450,9 +454,8 @@ const beeperMessageToolActions = [ "react", "read", "mark_unread", - "set-room-name", - "set-room-topic", - "set-room-avatar", + "channel-info", + "channel-edit", ] as const; type BeeperMessageToolAction = typeof beeperMessageToolActions[number]; @@ -463,8 +466,8 @@ type BeeperActionContext = { sessionKey?: string | null; }; -function beeperToolTextResult(text: string) { - return { content: [{ type: "text" as const, text }], details: {} }; +function beeperToolTextResult(text: string, details: Record = {}) { + return { content: [{ type: "text" as const, text }], details }; } const beeperActionHandlers: Record Promise>> = { @@ -519,27 +522,35 @@ const beeperActionHandlers: Record { - const runtime = requireBeeperChannelRuntime(); - const roomId = readRequiredBeeperRoomId(ctx.params); - const name = readRequiredString(ctx.params, "name"); - await runtime.setRoomName({ name, roomId }); - return beeperToolTextResult("Updated Beeper room name"); - }, - "set-room-topic": async (ctx) => { + "channel-info": async (ctx) => { const runtime = requireBeeperChannelRuntime(); - const roomId = readRequiredBeeperRoomId(ctx.params); - const topic = readRequiredString(ctx.params, "topic"); - await runtime.setRoomTopic({ roomId, topic }); - return beeperToolTextResult("Updated Beeper room topic"); + const roomId = readRequiredBeeperRoomId(ctx.params, "channelId", "roomId"); + const info = runtime.getRoomInfo({ roomId }); + return beeperToolTextResult(`Beeper channel ${roomId}`, { + action: "channel-info", + channel: info, + ok: true, + }); }, - "set-room-avatar": async (ctx) => { + "channel-edit": async (ctx) => { const runtime = requireBeeperChannelRuntime(); - const roomId = readRequiredBeeperRoomId(ctx.params); - const avatarMxc = readRequiredString(ctx.params, "avatarMxc"); - if (!avatarMxc.startsWith("mxc://")) throw new Error("Beeper room avatar must be an mxc:// URI."); - await runtime.setRoomAvatar({ avatarMxc, roomId }); - return beeperToolTextResult("Updated Beeper room avatar"); + const roomId = readRequiredBeeperRoomId(ctx.params, "channelId", "roomId"); + const name = readOptionalString(ctx.params, "name", "displayName", "title"); + const topic = readOptionalString(ctx.params, "topic", "description"); + const avatarMxc = readOptionalString(ctx.params, "avatarMxc", "avatarUrl", "icon"); + if (!name && !topic && !avatarMxc) throw new Error("Beeper channel-edit requires name, topic, or avatarMxc."); + if (name) await runtime.setRoomName({ name, roomId }); + if (topic) await runtime.setRoomTopic({ roomId, topic }); + if (avatarMxc) { + if (!avatarMxc.startsWith("mxc://")) throw new Error("Beeper channel avatar must be an mxc:// URI."); + await runtime.setRoomAvatar({ avatarMxc, roomId }); + } + return beeperToolTextResult("Updated Beeper channel", { + action: "channel-edit", + channelId: roomId, + ok: true, + updates: stripUndefined({ avatarMxc, name, topic }), + }); }, }; @@ -732,7 +743,7 @@ export const beeperStatusAdapter = { enabled: settings.enabled !== false, extra: { beeperEnv: settings.beeperEnv ?? "production", - homeserver: settings.homeserver, + homeserver: settings.bridge?.homeserver, }, name: "Beeper", running: runtime?.running === true, @@ -770,14 +781,16 @@ export async function applyBeeperSetupConfig(params: { ...baseSettings, enabled: true, }; - if (result.config.homeserver) setupSettings.homeserver = result.config.homeserver; - 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.homeserverDomain) setupSettings.homeserverDomain = result.config.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; + const bridgeSettings: BeeperGeneratedBridgeSettings = {}; + if (result.config.appserviceId) bridgeSettings.appserviceId = result.config.appserviceId; + if (result.config.asToken) bridgeSettings.asToken = result.config.asToken; + if (result.config.bridgeId) bridgeSettings.bridgeId = result.config.bridgeId; + if (result.config.homeserver) bridgeSettings.homeserver = result.config.homeserver; + if (result.config.homeserverDomain) bridgeSettings.homeserverDomain = result.config.homeserverDomain; + if (result.config.hsToken) bridgeSettings.hsToken = result.config.hsToken; + if (result.config.matrixDeviceId) bridgeSettings.matrixDeviceId = result.config.matrixDeviceId; + if (result.config.matrixUserId) bridgeSettings.matrixUserId = result.config.matrixUserId; + setupSettings.bridge = bridgeSettings; return applyBeeperChannelSettings(params.cfg, setupSettings); } @@ -913,8 +926,8 @@ function resolveBeeperRoomTarget(target: string): string { return normalized; } -function readRequiredBeeperRoomId(params: Record): string { - return resolveBeeperRoomTarget(readRequiredString(params, "roomId")); +function readRequiredBeeperRoomId(params: Record, ...keys: string[]): string { + return resolveBeeperRoomTarget(readRequiredString(params, ...(keys.length > 0 ? keys : ["roomId"]))); } function isBeeperMessageToolAction(action: string): action is BeeperMessageToolAction { @@ -977,6 +990,14 @@ function readRequiredString(params: Record, ...keys: string[]): throw new Error(`Missing required Beeper action parameter: ${keys.join(" or ")}`); } +function readOptionalString(params: Record, ...keys: string[]): string | undefined { + for (const key of keys) { + const value = stringValue(params[key]); + if (value) return value; + } + return undefined; +} + function stringifyOptional(value: string | number | null | undefined): string | undefined { return value == null ? undefined : String(value); } @@ -1249,13 +1270,14 @@ export function getBeeperChannelSettings(cfg: OpenClawSetupConfig): BeeperChanne export function isBeeperChannelConfigured(cfg: OpenClawSetupConfig): boolean { const settings = getBeeperChannelSettings(cfg); + const bridge = settings.bridge; return Boolean( settings.enabled && - settings.asToken && - settings.homeserver && - settings.hsToken && - settings.matrixDeviceId && - settings.matrixUserId + bridge?.asToken && + bridge.homeserver && + bridge.hsToken && + bridge.matrixDeviceId && + bridge.matrixUserId ); } diff --git a/packages/openclaw/src/types.ts b/packages/openclaw/src/types.ts index bd2ab67..1005de3 100644 --- a/packages/openclaw/src/types.ts +++ b/packages/openclaw/src/types.ts @@ -1,6 +1,3 @@ -export type OpenClawBindingOwner = "bridge" | "terminal" | "mac-app" | "imported"; -export type OpenClawBindingKind = "session" | "agent"; - export interface OpenClawAgentContact { agentId: string; displayName: string; @@ -10,13 +7,18 @@ export interface OpenClawAgentContact { description?: string; } +export interface OpenClawBeeperChannelInfo { + agent?: OpenClawAgentContact; + binding?: OpenClawSessionBinding; + portalKey?: { id: string; receiver?: string }; + roomId: string; +} + export interface OpenClawSessionBinding { id: string; - kind: OpenClawBindingKind; - owner: OpenClawBindingOwner; roomId: string; spaceId?: string; - sessionKey: string; + sessionKey?: string; agentId: string; ghostUserId: string; humanGhostUserId?: string; diff --git a/packages/openclaw/vitest.config.ts b/packages/openclaw/vitest.config.ts index e3ac897..f4a6f68 100644 --- a/packages/openclaw/vitest.config.ts +++ b/packages/openclaw/vitest.config.ts @@ -5,16 +5,13 @@ export default defineProject({ alias: [ { find: "@beeper/pickle-bridge/beeper", replacement: new URL("../bridge/src/beeper.ts", import.meta.url).pathname }, { find: "@beeper/pickle-bridge/beeper-stream", replacement: new URL("../bridge/src/beeper-stream.ts", import.meta.url).pathname }, + { find: "@beeper/pickle-bridge/bridge", replacement: new URL("../bridge/src/bridge.ts", import.meta.url).pathname }, { find: "@beeper/pickle-bridge/events", replacement: new URL("../bridge/src/events.ts", import.meta.url).pathname }, { find: "@beeper/pickle-bridge/media-message", replacement: new URL("../bridge/src/media-message.ts", import.meta.url).pathname }, - { find: /^@beeper\/pickle-bridge$/, replacement: new URL("../bridge/src/index.ts", import.meta.url).pathname }, + { find: "@beeper/pickle-bridge/node", replacement: new URL("../bridge/src/node.ts", import.meta.url).pathname }, + { find: "@beeper/pickle-bridge/types", replacement: new URL("../bridge/src/types.ts", import.meta.url).pathname }, { find: /^@beeper\/pickle-ag-ui$/, replacement: new URL("../ag-ui/src/index.ts", import.meta.url).pathname }, { find: /^@beeper\/pickle-state-file$/, replacement: new URL("../state-file/src/index.ts", import.meta.url).pathname }, - { find: "@beeper/pickle/streams/beeper-message", replacement: new URL("../pickle/src/streams/beeper-message.ts", import.meta.url).pathname }, - { find: "@beeper/pickle/beeper/auth", replacement: new URL("../pickle/src/beeper/auth.ts", import.meta.url).pathname }, - { find: "@beeper/pickle/auth", replacement: new URL("../pickle/src/auth.ts", import.meta.url).pathname }, - { find: "@beeper/pickle/node", replacement: new URL("../pickle/src/node.ts", import.meta.url).pathname }, - { find: /^@beeper\/pickle$/, replacement: new URL("../pickle/src/index.ts", import.meta.url).pathname }, ], }, test: { diff --git a/packages/pickle/native/internal/core/appservice.go b/packages/pickle/native/internal/core/appservice.go index 913c7b0..77dbbfe 100644 --- a/packages/pickle/native/internal/core/appservice.go +++ b/packages/pickle/native/internal/core/appservice.go @@ -66,6 +66,19 @@ type MatrixAppserviceRoomUserOptions struct { UserID string `json:"userId"` } +type MatrixAppserviceSetProfileOptions struct { + AvatarURL *string `json:"avatarUrl,omitempty"` + DisplayName *string `json:"displayName,omitempty"` + Extra OutboundEvent `json:"extra,omitempty" tstype:"{ [key: string]: unknown }"` + Identifiers []string `json:"identifiers,omitempty"` + IsBridgeBot *bool `json:"isBridgeBot,omitempty"` + IsNetworkBot *bool `json:"isNetworkBot,omitempty"` + Network string `json:"network,omitempty"` + RemoteID string `json:"remoteId,omitempty"` + Service string `json:"service,omitempty"` + UserID string `json:"userId"` +} + type MatrixAppserviceCreateRoomOptions struct { MatrixCreateRoomOptions UserID string `json:"userId,omitempty"` @@ -340,6 +353,78 @@ func (c *Core) handleAppserviceEnsureJoined(ctx context.Context, payload []byte) return c.emptyIfNil(c.appservice.ensureJoined(ctx, intent, id.RoomID(req.RoomID))) } +func (c *Core) handleAppserviceSetProfile(ctx context.Context, payload []byte) ([]byte, error) { + var req MatrixAppserviceSetProfileOptions + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + intent, err := c.requireAppserviceIntent(req.UserID) + if err != nil { + return nil, err + } + if err := c.appservice.ensureRegistered(ctx, intent); err != nil { + return nil, err + } + if req.DisplayName != nil { + if err := retryMatrixVoid(ctx, func() error { + return intent.SetDisplayName(ctx, *req.DisplayName) + }); err != nil { + return nil, err + } + } + if req.AvatarURL != nil { + var avatarURL id.ContentURI + if *req.AvatarURL != "" { + parsedAvatarURL, err := id.ParseContentURI(*req.AvatarURL) + if err != nil { + return nil, err + } + avatarURL = parsedAvatarURL + } + if err := retryMatrixVoid(ctx, func() error { + return intent.SetAvatarURL(ctx, avatarURL) + }); err != nil { + return nil, err + } + } + if extra := appserviceProfileExtra(req); extra != nil { + if err := retryMatrixVoid(ctx, func() error { + return intent.BeeperUpdateProfile(ctx, extra) + }); err != nil { + return nil, err + } + } + return c.empty() +} + +func appserviceProfileExtra(req MatrixAppserviceSetProfileOptions) OutboundEvent { + extra := OutboundEvent{} + for key, value := range req.Extra { + extra[key] = value + } + baseExtra := event.BeeperProfileExtra{ + RemoteID: req.RemoteID, + Identifiers: req.Identifiers, + Service: req.Service, + Network: req.Network, + } + if req.IsNetworkBot != nil { + baseExtra.IsNetworkBot = *req.IsNetworkBot + } + if req.IsBridgeBot != nil { + baseExtra.IsBridgeBot = *req.IsBridgeBot + } + if baseExtra.RemoteID != "" || len(baseExtra.Identifiers) > 0 || baseExtra.Service != "" || baseExtra.Network != "" || baseExtra.IsNetworkBot || baseExtra.IsBridgeBot { + if payload, err := json.Marshal(baseExtra); err == nil { + _ = json.Unmarshal(payload, &extra) + } + } + if len(extra) == 0 { + return nil + } + return extra +} + func (c *Core) handleAppserviceCreateRoom(ctx context.Context, payload []byte) ([]byte, error) { var req MatrixAppserviceCreateRoomOptions if err := json.Unmarshal(payload, &req); err != nil { @@ -440,7 +525,7 @@ func (as *matrixAppservice) makePortalCreateRoomRequest(req MatrixAppserviceCrea } bridgeInfo := bridgeInfoContent(req, bridgeBot, roomType) for _, state := range req.InitialState { - stateKey := state.StateKey + stateKey := stringValue(state.StateKey) createReq.InitialState = append(createReq.InitialState, &event.Event{ Type: event.NewEventType(state.Type), StateKey: &stateKey, @@ -661,7 +746,7 @@ func makeCreateRoomRequest(req MatrixCreateRoomOptions) *mautrix.ReqCreateRoom { invitees := toUserIDs(req.Invite) initialState := make([]*event.Event, 0, len(req.InitialState)) for _, state := range req.InitialState { - stateKey := state.StateKey + stateKey := stringValue(state.StateKey) initialState = append(initialState, &event.Event{ Type: event.NewEventType(state.Type), StateKey: &stateKey, diff --git a/packages/pickle/native/internal/core/appservice_test.go b/packages/pickle/native/internal/core/appservice_test.go index a07e843..cb97432 100644 --- a/packages/pickle/native/internal/core/appservice_test.go +++ b/packages/pickle/native/internal/core/appservice_test.go @@ -167,6 +167,62 @@ func TestAppserviceTransactionEmitsMautrixClassifiedEvents(t *testing.T) { assertEmittedSyncEvent(t, emitted, "account_data", "m.marked_unread", "!room:example") } +func TestAppserviceSetProfileUpdatesGhostProfile(t *testing.T) { + requests := make(chan recordedRequest, 8) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + requests <- recordedRequest{body: string(body), path: r.Method + " " + r.URL.RequestURI()} + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + t.Cleanup(server.Close) + + core := New(nil) + initPayload, err := json.Marshal(MatrixAppserviceInitOptions{ + Homeserver: server.URL, + HomeserverDomain: "example", + Registration: MatrixAppserviceRegistration{ + AppToken: "as-token", + ID: "test", + SenderLocalpart: "testbot", + }, + }) + if err != nil { + t.Fatal(err) + } + if _, err = core.handleInitAppservice(context.Background(), initPayload); err != nil { + t.Fatal(err) + } + + payload, err := json.Marshal(MatrixAppserviceSetProfileOptions{ + AvatarURL: ptr("mxc://example/avatar"), + DisplayName: ptr("Agent Main"), + Identifiers: []string{"openclaw:agent:main"}, + IsBridgeBot: ptr(false), + IsNetworkBot: ptr(true), + Network: "openclaw", + RemoteID: "agent_main", + Service: "openclaw", + UserID: "@test_agent_main:example", + }) + if err != nil { + t.Fatal(err) + } + if _, err = core.handleAppserviceSetProfile(context.Background(), payload); err != nil { + t.Fatal(err) + } + + expectRecordedRequest(t, requests, "POST", "/register", `"username":"test_agent_main"`) + expectRecordedRequest(t, requests, "PUT", "/displayname", `"displayname":"Agent Main"`) + expectRecordedRequest(t, requests, "PUT", "/avatar_url", `"avatar_url":"mxc://example/avatar"`) + waitForRecordedRequest(t, requests, func(req recordedRequest) bool { + return strings.HasPrefix(req.path, "PATCH ") && + strings.Contains(req.path, "/profile/") && + strings.Contains(req.body, `"com.beeper.bridge.remote_id":"agent_main"`) && + strings.Contains(req.body, `"com.beeper.bridge.identifiers":["openclaw:agent:main"]`) + }) +} + func TestBeeperStreamClientUsesAppserviceBotDevice(t *testing.T) { core := New(nil) mainClient, err := mautrix.NewClient("https://matrix.example/_hungryserv/alice", id.UserID("@bot:example"), "login-token") @@ -518,6 +574,13 @@ func TestBeeperAIRunStreamUsesCanonicalAIBridgeRun(t *testing.T) { if !strings.Contains(replacementBody, `"com.beeper.ai"`) || !strings.Contains(replacementBody, `"hello"`) { t.Fatalf("expected final replacement to use ai-bridge final content, got %s", replacementBody) } + var replacementContent map[string]any + if err = json.Unmarshal([]byte(replacementBody), &replacementContent); err != nil { + t.Fatal(err) + } + if replacementContent["body"] != "hello" { + t.Fatalf("expected final replacement top-level body to preserve rendered text, got %#v", replacementContent["body"]) + } } func TestBeeperAIRunStreamStartUsesInitialTextPartForAnchorPreview(t *testing.T) { @@ -685,6 +748,19 @@ func waitForRecordedRequest(t *testing.T, requests <-chan recordedRequest, match } } +func expectRecordedRequest(t *testing.T, requests <-chan recordedRequest, method string, pathFragment string, bodyFragment string) { + t.Helper() + waitForRecordedRequest(t, requests, func(req recordedRequest) bool { + return strings.HasPrefix(req.path, method+" ") && + strings.Contains(req.path, pathFragment) && + strings.Contains(req.body, bodyFragment) + }) +} + +func ptr[T any](value T) *T { + return &value +} + func mustJSON(t *testing.T, value any) json.RawMessage { t.Helper() raw, err := json.Marshal(value) diff --git a/packages/pickle/native/internal/core/beeper_ai_run.go b/packages/pickle/native/internal/core/beeper_ai_run.go index 654b963..9ea496c 100644 --- a/packages/pickle/native/internal/core/beeper_ai_run.go +++ b/packages/pickle/native/internal/core/beeper_ai_run.go @@ -481,7 +481,7 @@ func (s *beeperAIRunState) appendPart(req MatrixBeeperAIRunPartOptions) error { result = commandToolOutput(req) } content := firstNonEmpty(req.Text, beeperAIJSONString(result)) - if content != "" { + if content != "" || !req.Preliminary { before := len(s.run.Events) s.writer.ToolResult(toolCallID, content, state) s.annotateToolResult(before, req) diff --git a/packages/pickle/native/internal/core/beeper_ai_run_test.go b/packages/pickle/native/internal/core/beeper_ai_run_test.go index 69ba1d3..3132c5d 100644 --- a/packages/pickle/native/internal/core/beeper_ai_run_test.go +++ b/packages/pickle/native/internal/core/beeper_ai_run_test.go @@ -197,6 +197,35 @@ func TestBeeperAIRunSemanticPartsUseAIBridgeWriter(t *testing.T) { } } +func TestBeeperAIRunEmptyToolResultCompletesToolCall(t *testing.T) { + core := New(nil) + state := core.beginBeeperAIRun(MatrixBeginBeeperAIRunOptions{RunID: "run-empty-tool-result", ThreadID: "thread-empty-tool-result"}) + parts := []MatrixBeeperAIRunPartOptions{ + {Input: map[string]any{"command": "gog auth list --json --no-input"}, Kind: "tool_start", ToolCallID: "cmd-1", ToolName: "bash"}, + {Kind: "tool_result", State: "complete", ToolCallID: "cmd-1", ToolName: "bash"}, + } + for _, part := range parts { + if err := state.appendPart(part); err != nil { + t.Fatalf("appendPart(%s): %v", part.Kind, err) + } + } + events := outboundEventsFromAGUI(state.run.Events) + result := firstEventOfType(events, "TOOL_CALL_RESULT") + if result == nil { + t.Fatalf("empty tool result was dropped: %#v", events) + } + if fmt.Sprint(result["content"]) != "" { + t.Fatalf("empty tool result should not invent output: %#v", result) + } + finalPart := firstToolPart(state.run.FinalBeeperAIMessage(0, true).Parts, "cmd-1") + if finalPart == nil { + t.Fatalf("final message is missing completed tool part: %#v", state.run.FinalBeeperAIMessage(0, true).Parts) + } + if strings.Contains(fmt.Sprint(finalPart), "run finalized before tool completed") || strings.Contains(fmt.Sprint(finalPart), "failed") { + t.Fatalf("completed tool part leaked synthetic failure: %#v", finalPart) + } +} + func TestBeeperAIRunCommandPartUsesCommandAsTitleAndActualOutput(t *testing.T) { core := New(nil) state := core.beginBeeperAIRun(MatrixBeginBeeperAIRunOptions{RunID: "run-command", ThreadID: "thread-command"}) diff --git a/packages/pickle/native/internal/core/core.go b/packages/pickle/native/internal/core/core.go index a61cc88..3eb895f 100644 --- a/packages/pickle/native/internal/core/core.go +++ b/packages/pickle/native/internal/core/core.go @@ -94,6 +94,8 @@ func (c *Core) Handle(ctx context.Context, op string, payload []byte) ([]byte, e return c.handleAppserviceEnsureRegistered(ctx, payload) case opAppserviceEnsureJoined: return c.handleAppserviceEnsureJoined(ctx, payload) + case opAppserviceSetProfile: + return c.handleAppserviceSetProfile(ctx, payload) case opAppserviceCreateRoom: return c.handleAppserviceCreateRoom(ctx, payload) case opAppserviceCreatePortalRoom: @@ -182,6 +184,8 @@ func (c *Core) Handle(ctx context.Context, op string, payload []byte) ([]byte, e return c.handleCreateRoom(ctx, payload) case opFetchRoom: return c.handleFetchRoom(ctx, payload) + case opFetchRoomPowerLevels: + return c.handleFetchRoomPowerLevels(ctx, payload) case opFetchRoomState: return c.handleFetchRoomState(ctx, payload) case opFetchRoomStateEvent: diff --git a/packages/pickle/native/internal/core/messages.go b/packages/pickle/native/internal/core/messages.go index e496488..70f3d01 100644 --- a/packages/pickle/native/internal/core/messages.go +++ b/packages/pickle/native/internal/core/messages.go @@ -353,7 +353,7 @@ func (c *Core) finalizeBeeperStreamMessage(ctx context.Context, req MatrixFinali func (c *Core) sendBeeperStreamReplacementEvent(ctx context.Context, roomID, eventID, userID string, newContent, topLevel OutboundEvent) (*mautrix.RespSendEvent, error) { content := copyOutboundEvent(topLevel) - content["body"] = "" + content["body"] = firstString(newContent["body"], "") content["msgtype"] = firstString(newContent["msgtype"], "m.text") content["m.new_content"] = newContent content["m.relates_to"] = map[string]any{ diff --git a/packages/pickle/native/internal/core/operations.go b/packages/pickle/native/internal/core/operations.go index fffb3f8..b6a380e 100644 --- a/packages/pickle/native/internal/core/operations.go +++ b/packages/pickle/native/internal/core/operations.go @@ -23,6 +23,8 @@ const ( opAppserviceEnsureRegistered = "appservice_ensure_registered" // ts:operation appserviceEnsureJoined appservice_ensure_joined MatrixAppserviceRoomUserOptions void opAppserviceEnsureJoined = "appservice_ensure_joined" + // ts:operation appserviceSetProfile appservice_set_profile MatrixAppserviceSetProfileOptions void + opAppserviceSetProfile = "appservice_set_profile" // ts:operation appserviceCreateRoom appservice_create_room MatrixAppserviceCreateRoomOptions MatrixCreateRoomResult opAppserviceCreateRoom = "appservice_create_room" // ts:operation appserviceCreatePortalRoom appservice_create_portal_room MatrixAppserviceCreatePortalRoomOptions MatrixCreateRoomResult @@ -111,6 +113,8 @@ const ( opCreateRoom = "create_room" // ts:operation fetchRoom fetch_room MatrixFetchRoomOptions MatrixRoomInfo opFetchRoom = "fetch_room" + // ts:operation fetchRoomPowerLevels fetch_room_power_levels MatrixFetchRoomPowerLevelsOptions MatrixRoomPowerLevels + opFetchRoomPowerLevels = "fetch_room_power_levels" // ts:operation fetchRoomState fetch_room_state MatrixFetchRoomStateOptions MatrixFetchRoomStateResult opFetchRoomState = "fetch_room_state" // ts:operation fetchRoomStateEvent fetch_room_state_event MatrixFetchRoomStateEventOptions MatrixRoomStateEvent diff --git a/packages/pickle/native/internal/core/rooms.go b/packages/pickle/native/internal/core/rooms.go index 168d744..1357a3e 100644 --- a/packages/pickle/native/internal/core/rooms.go +++ b/packages/pickle/native/internal/core/rooms.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "math" "strings" "time" @@ -17,9 +18,27 @@ type MatrixFetchRoomOptions struct { RoomID string `json:"roomId"` } +type MatrixFetchRoomPowerLevelsOptions struct { + RoomID string `json:"roomId"` +} + +type MatrixRoomPowerLevels struct { + Ban *float64 `json:"ban,omitempty"` + Events map[string]float64 `json:"events,omitempty"` + EventsDefault *float64 `json:"eventsDefault,omitempty"` + Invite *float64 `json:"invite,omitempty"` + Kick *float64 `json:"kick,omitempty"` + Notifications map[string]float64 `json:"notifications,omitempty"` + Raw map[string]any `json:"raw"` + Redact *float64 `json:"redact,omitempty"` + StateDefault *float64 `json:"stateDefault,omitempty"` + Users map[string]float64 `json:"users,omitempty"` + UsersDefault *float64 `json:"usersDefault,omitempty"` +} + type MatrixRoomStateInput struct { Content OutboundEvent `json:"content" tstype:"{ [key: string]: unknown }"` - StateKey string `json:"stateKey"` + StateKey *string `json:"stateKey,omitempty"` Type string `json:"type"` } @@ -124,32 +143,8 @@ func (c *Core) handleCreateRoom(ctx context.Context, payload []byte) ([]byte, er if err := json.Unmarshal(payload, &req); err != nil { return nil, err } - invitees := make([]id.UserID, 0, len(req.Invite)) - for _, userID := range req.Invite { - invitees = append(invitees, id.UserID(userID)) - } - initialState := make([]*event.Event, 0, len(req.InitialState)) - for _, state := range req.InitialState { - stateKey := state.StateKey - initialState = append(initialState, &event.Event{ - Type: event.NewEventType(state.Type), - StateKey: &stateKey, - Content: event.Content{Raw: state.Content}, - }) - } resp, err := retryMatrix(ctx, func() (*mautrix.RespCreateRoom, error) { - return cli.CreateRoom(ctx, &mautrix.ReqCreateRoom{ - CreationContent: req.CreationContent, - InitialState: initialState, - Invite: invitees, - IsDirect: req.IsDirect, - Name: req.Name, - Preset: req.Preset, - RoomAliasName: req.RoomAliasName, - RoomVersion: id.RoomVersion(req.RoomVersion), - Topic: req.Topic, - Visibility: req.Visibility, - }) + return cli.CreateRoom(ctx, makeCreateRoomRequest(req)) }) if err != nil { return nil, err @@ -277,6 +272,36 @@ func (c *Core) handleFetchRoomState(ctx context.Context, payload []byte) ([]byte return json.Marshal(MatrixFetchRoomStateResult{Events: converted, Raw: events}) } +func (c *Core) handleFetchRoomPowerLevels(ctx context.Context, payload []byte) ([]byte, error) { + cli, err := c.requireClient() + if err != nil { + return nil, err + } + var req MatrixFetchRoomPowerLevelsOptions + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + var content map[string]any + if err := retryMatrixVoid(ctx, func() error { + return cli.StateEvent(ctx, id.RoomID(req.RoomID), event.StatePowerLevels, "", &content) + }); err != nil { + return nil, err + } + return json.Marshal(MatrixRoomPowerLevels{ + Ban: finiteNumber(content["ban"]), + Events: finiteNumberRecord(content["events"]), + EventsDefault: finiteNumber(content["events_default"]), + Invite: finiteNumber(content["invite"]), + Kick: finiteNumber(content["kick"]), + Notifications: finiteNumberRecord(content["notifications"]), + Raw: content, + Redact: finiteNumber(content["redact"]), + StateDefault: finiteNumber(content["state_default"]), + Users: finiteNumberRecord(content["users"]), + UsersDefault: finiteNumber(content["users_default"]), + }) +} + func (c *Core) handleFetchRoomStateEvent(ctx context.Context, payload []byte) ([]byte, error) { cli, err := c.requireClient() if err != nil { @@ -704,6 +729,31 @@ func (c *Core) convertRoomStateEvent(roomID string, evt *event.Event) MatrixRoom } } +func finiteNumber(value any) *float64 { + number, ok := value.(float64) + if !ok || math.IsNaN(number) || math.IsInf(number, 0) { + return nil + } + return &number +} + +func finiteNumberRecord(value any) map[string]float64 { + raw, ok := value.(map[string]any) + if !ok { + return nil + } + result := map[string]float64{} + for key, entry := range raw { + if number := finiteNumber(entry); number != nil { + result[key] = *number + } + } + if len(result) == 0 { + return nil + } + return result +} + func (c *Core) updateDirectChats(ctx context.Context, cli *mautrix.Client, userID id.UserID, roomID id.RoomID) { directChats := event.DirectChatsEventContent{} if err := retryMatrixVoid(ctx, func() error { diff --git a/packages/pickle/native/internal/core/rooms_test.go b/packages/pickle/native/internal/core/rooms_test.go index 11ff2ff..56f91a7 100644 --- a/packages/pickle/native/internal/core/rooms_test.go +++ b/packages/pickle/native/internal/core/rooms_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "maunium.net/go/mautrix" @@ -136,6 +137,55 @@ func TestFetchRoomUsesDirectAccountDataBeforeMemberCountFallback(t *testing.T) { } } +func TestFetchRoomPowerLevelsParsesFiniteNumericContent(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/state/m.room.power_levels/") { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "ban": 50, + "events": map[string]any{"m.room.name": 75, "bad": "ignored"}, + "events_default": 1, + "invite": 2, + "kick": 3, + "notifications": map[string]any{"room": 20}, + "redact": 4, + "state_default": 5, + "users": map[string]any{"@admin:example": 100}, + "users_default": 6, + }) + })) + defer server.Close() + + core := New(nil) + core.client, _ = mautrix.NewClient(server.URL, id.UserID("@alice:example"), "token") + + raw, err := core.handleFetchRoomPowerLevels(context.Background(), []byte(`{"roomId":"!room:example"}`)) + if err != nil { + t.Fatal(err) + } + var levels MatrixRoomPowerLevels + if err := json.Unmarshal(raw, &levels); err != nil { + t.Fatal(err) + } + if levels.Ban == nil || *levels.Ban != 50 { + t.Fatalf("expected ban 50, got %#v", levels.Ban) + } + if _, ok := levels.Events["bad"]; ok || levels.Events["m.room.name"] != 75 { + t.Fatalf("expected filtered events, got %#v", levels.Events) + } + if levels.Users["@admin:example"] != 100 { + t.Fatalf("expected user power 100, got %#v", levels.Users) + } + if levels.Notifications["room"] != 20 { + t.Fatalf("expected room notification power 20, got %#v", levels.Notifications) + } + if levels.StateDefault == nil || *levels.StateDefault != 5 || levels.UsersDefault == nil || *levels.UsersDefault != 6 { + t.Fatalf("expected defaults to be parsed, got state=%#v users=%#v", levels.StateDefault, levels.UsersDefault) + } +} + func writeMatrixNotFound(w http.ResponseWriter) { w.WriteHeader(http.StatusNotFound) _ = json.NewEncoder(w).Encode(map[string]string{ diff --git a/packages/pickle/src/client-types.ts b/packages/pickle/src/client-types.ts index 1d42d5f..62cc9ad 100644 --- a/packages/pickle/src/client-types.ts +++ b/packages/pickle/src/client-types.ts @@ -77,6 +77,7 @@ import type { MatrixAppserviceInitOptions, MatrixAppserviceRoomUserOptions, MatrixAppserviceSendMessageOptions, + MatrixAppserviceSetProfileOptions, MatrixAppserviceUserOptions, MatrixAppendBeeperAIRunEventOptions, MatrixAppendBeeperAIRunPartOptions, @@ -131,6 +132,7 @@ export interface MatrixAppservice { init(options: MatrixAppserviceInitOptions): Promise; applyTransaction(options: { transaction: Record }): Promise; sendMessage(options: MatrixAppserviceSendMessageOptions): Promise; + setProfile(options: MatrixAppserviceSetProfileOptions): Promise; } export interface MatrixRaw { diff --git a/packages/pickle/src/client.ts b/packages/pickle/src/client.ts index 2da0847..4ca3a4f 100644 --- a/packages/pickle/src/client.ts +++ b/packages/pickle/src/client.ts @@ -84,6 +84,7 @@ class DefaultMatrixClient implements MatrixClient { const result = await core.appserviceSendMessage(stripUndefined(opts)); return { eventId: result.eventId, raw: result.raw, roomId: result.roomId }; }), + setProfile: (opts) => this.#withCore((core) => core.appserviceSetProfile(stripUndefined(opts))), }; this.beeper = { aiRuns: { @@ -190,11 +191,7 @@ class DefaultMatrixClient implements MatrixClient { ban: (opts) => this.#withCore((core) => core.banUser(opts)), create: (opts) => this.#withCore((core) => core.createRoom(stripUndefined({ creationContent: opts.creationContent, - initialState: opts.initialState?.map((state) => ({ - content: state.content, - stateKey: state.stateKey ?? "", - type: state.type, - })), + initialState: opts.initialState, invite: opts.invite, isDirect: opts.isDirect, name: opts.name, @@ -205,26 +202,7 @@ class DefaultMatrixClient implements MatrixClient { visibility: opts.visibility, }))), get: (opts) => this.#withCore((core) => core.fetchRoom(opts)), - getPowerLevels: async (opts) => { - const event = await this.#withCore((core) => core.fetchRoomStateEvent({ - eventType: "m.room.power_levels", - roomId: opts.roomId, - stateKey: "", - })); - return stripUndefined({ - ban: readNumber(event.content.ban), - events: readNumberRecord(event.content.events), - eventsDefault: readNumber(event.content.events_default), - invite: readNumber(event.content.invite), - kick: readNumber(event.content.kick), - notifications: readNumberRecord(event.content.notifications), - raw: event.content, - redact: readNumber(event.content.redact), - stateDefault: readNumber(event.content.state_default), - users: readNumberRecord(event.content.users), - usersDefault: readNumber(event.content.users_default), - }); - }, + getPowerLevels: (opts) => this.#withCore((core) => core.fetchRoomPowerLevels(opts)), getState: (opts) => this.#withCore((core) => core.fetchRoomState(opts)), getStateEvent: (opts) => this.#withCore((core) => core.fetchRoomStateEvent(stripUndefined({ eventType: opts.eventType, @@ -554,20 +532,3 @@ function eventRelationEventId(event: MatrixClientEvent): string | undefined { if ("relatesTo" in event) return event.relatesTo; return undefined; } - -function readNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -function readNumberRecord(value: unknown): Record | undefined { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return undefined; - } - const result: Record = {}; - for (const [key, entry] of Object.entries(value)) { - if (typeof entry === "number" && Number.isFinite(entry)) { - result[key] = entry; - } - } - return Object.keys(result).length > 0 ? result : undefined; -} diff --git a/packages/pickle/src/generated-runtime-operations.ts b/packages/pickle/src/generated-runtime-operations.ts index 9a3ad47..58f4cc0 100644 --- a/packages/pickle/src/generated-runtime-operations.ts +++ b/packages/pickle/src/generated-runtime-operations.ts @@ -14,6 +14,7 @@ import type { MatrixAppserviceInitOptions, MatrixAppserviceRoomUserOptions, MatrixAppserviceSendMessageOptions, + MatrixAppserviceSetProfileOptions, MatrixAppserviceTransactionOptions, MatrixAppserviceUserOptions, MatrixBanUserOptions, @@ -38,6 +39,7 @@ import type { MatrixFetchMessagesResult, MatrixFetchRoomMembersOptions, MatrixFetchRoomOptions, + MatrixFetchRoomPowerLevelsOptions, MatrixFetchRoomStateEventOptions, MatrixFetchRoomStateOptions, MatrixFetchRoomStateResult, @@ -71,6 +73,7 @@ import type { MatrixResolveRoomAliasResult, MatrixRoomInfo, MatrixRoomMembersResult, + MatrixRoomPowerLevels, MatrixRoomStateEvent, MatrixSendEphemeralEventOptions, MatrixSendMediaMessageOptions, @@ -109,6 +112,7 @@ export interface MatrixCoreOperations { initAppservice(options: MatrixAppserviceInitOptions): Promise; appserviceEnsureRegistered(options: MatrixAppserviceUserOptions): Promise; appserviceEnsureJoined(options: MatrixAppserviceRoomUserOptions): Promise; + appserviceSetProfile(options: MatrixAppserviceSetProfileOptions): Promise; appserviceCreateRoom(options: MatrixAppserviceCreateRoomOptions): Promise; appserviceCreatePortalRoom(options: MatrixAppserviceCreatePortalRoomOptions): Promise; appserviceCreateManagementRoom(options: MatrixAppserviceCreateManagementRoomOptions): Promise; @@ -153,6 +157,7 @@ export interface MatrixCoreOperations { downloadEncryptedMedia(options: MatrixDownloadEncryptedMediaOptions): Promise; createRoom(options: MatrixCreateRoomOptions): Promise; fetchRoom(options: MatrixFetchRoomOptions): Promise; + fetchRoomPowerLevels(options: MatrixFetchRoomPowerLevelsOptions): Promise; fetchRoomState(options: MatrixFetchRoomStateOptions): Promise; fetchRoomStateEvent(options: MatrixFetchRoomStateEventOptions): Promise; sendRoomStateEvent(options: MatrixSendRoomStateEventOptions): Promise; @@ -223,6 +228,10 @@ export abstract class MatrixCoreOperationCaller implements MatrixCoreOperations return this.call("appservice_ensure_joined", options); } + appserviceSetProfile(options: MatrixAppserviceSetProfileOptions): Promise { + return this.call("appservice_set_profile", options); + } + appserviceCreateRoom(options: MatrixAppserviceCreateRoomOptions): Promise { return this.call("appservice_create_room", options); } @@ -399,6 +408,10 @@ export abstract class MatrixCoreOperationCaller implements MatrixCoreOperations return this.call("fetch_room", options); } + fetchRoomPowerLevels(options: MatrixFetchRoomPowerLevelsOptions): Promise { + return this.call("fetch_room_power_levels", options); + } + fetchRoomState(options: MatrixFetchRoomStateOptions): Promise { return this.call("fetch_room_state", options); } diff --git a/packages/pickle/src/generated-runtime-types.ts b/packages/pickle/src/generated-runtime-types.ts index 698f6bf..a829968 100644 --- a/packages/pickle/src/generated-runtime-types.ts +++ b/packages/pickle/src/generated-runtime-types.ts @@ -53,6 +53,18 @@ export interface MatrixAppserviceRoomUserOptions { roomId: string; userId: string; } +export interface MatrixAppserviceSetProfileOptions { + avatarUrl?: string; + displayName?: string; + extra?: { [key: string]: unknown }; + identifiers?: string[]; + isBridgeBot?: boolean; + isNetworkBot?: boolean; + network?: string; + remoteId?: string; + service?: string; + userId: string; +} export interface MatrixAppserviceCreateRoomOptions extends MatrixCreateRoomOptions { userId?: string; } @@ -385,9 +397,25 @@ export interface MatrixReactionOptions { export interface MatrixFetchRoomOptions { roomId: string; } +export interface MatrixFetchRoomPowerLevelsOptions { + roomId: string; +} +export interface MatrixRoomPowerLevels { + ban?: number /* float64 */; + events?: { [key: string]: number /* float64 */}; + eventsDefault?: number /* float64 */; + invite?: number /* float64 */; + kick?: number /* float64 */; + notifications?: { [key: string]: number /* float64 */}; + raw: { [key: string]: unknown}; + redact?: number /* float64 */; + stateDefault?: number /* float64 */; + users?: { [key: string]: number /* float64 */}; + usersDefault?: number /* float64 */; +} export interface MatrixRoomStateInput { content: { [key: string]: unknown }; - stateKey: string; + stateKey?: string; type: string; } export interface MatrixCreateRoomOptions { diff --git a/packages/pickle/src/index.ts b/packages/pickle/src/index.ts index 6d2f7d4..c999242 100644 --- a/packages/pickle/src/index.ts +++ b/packages/pickle/src/index.ts @@ -35,6 +35,7 @@ export type { MatrixAppserviceRegistration, MatrixAppserviceRoomUserOptions, MatrixAppserviceSendMessageOptions, + MatrixAppserviceSetProfileOptions, MatrixAppserviceUserOptions, MatrixAppendBeeperAIRunEventOptions, MatrixAppendBeeperAIRunPartOptions, diff --git a/packages/pickle/src/runtime-types.ts b/packages/pickle/src/runtime-types.ts index b483b3b..79f85c7 100644 --- a/packages/pickle/src/runtime-types.ts +++ b/packages/pickle/src/runtime-types.ts @@ -24,6 +24,7 @@ export type { MatrixAppserviceRegistration, MatrixAppserviceRoomUserOptions, MatrixAppserviceSendMessageOptions, + MatrixAppserviceSetProfileOptions, MatrixAppserviceUserOptions, MatrixAppendBeeperAIRunEventOptions, MatrixAppendBeeperAIRunPartOptions, diff --git a/packages/pickle/src/types.ts b/packages/pickle/src/types.ts index 7ce12da..f70cb07 100644 --- a/packages/pickle/src/types.ts +++ b/packages/pickle/src/types.ts @@ -1,4 +1,4 @@ -import type { MatrixAppserviceInitOptions } from "./generated-runtime-types"; +import type { MatrixAppserviceInitOptions, MatrixRoomPowerLevels } from "./generated-runtime-types"; export interface MatrixStore { delete(key: string): Promise; @@ -561,19 +561,7 @@ export interface RoomStateEvent { type: string; } -export interface RoomPowerLevels { - ban?: number; - events?: Record; - eventsDefault?: number; - invite?: number; - kick?: number; - notifications?: Record; - redact?: number; - raw: Record; - stateDefault?: number; - users?: Record; - usersDefault?: number; -} +export type RoomPowerLevels = MatrixRoomPowerLevels; export interface FetchRoomPowerLevelsOptions { roomId: string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52e69c5..3174ade 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -219,9 +219,6 @@ importers: packages/openclaw: devDependencies: - '@beeper/pickle': - specifier: workspace:^ - version: link:../pickle '@beeper/pickle-ag-ui': specifier: workspace:^ version: link:../ag-ui diff --git a/tsconfig.base.json b/tsconfig.base.json index cb32379..05d007e 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -19,11 +19,12 @@ "@beeper/pickle/streams": ["packages/pickle/src/streams/index.ts"], "@beeper/pickle/streams/beeper-message": ["packages/pickle/src/streams/beeper-message.ts"], "@beeper/pickle-ag-ui": ["packages/ag-ui/src/index.ts"], - "@beeper/pickle-bridge": ["packages/bridge/src/index.ts"], "@beeper/pickle-bridge/beeper": ["packages/bridge/src/beeper.ts"], "@beeper/pickle-bridge/beeper-stream": ["packages/bridge/src/beeper-stream.ts"], + "@beeper/pickle-bridge/bridge": ["packages/bridge/src/bridge.ts"], "@beeper/pickle-bridge/events": ["packages/bridge/src/events.ts"], "@beeper/pickle-bridge/media-message": ["packages/bridge/src/media-message.ts"], + "@beeper/pickle-bridge/node": ["packages/bridge/src/node.ts"], "@beeper/pickle-bridge/types": ["packages/bridge/src/types.ts"], "@beeper/pickle-chat-adapter": ["packages/chat-adapter/src/index.ts"], "@beeper/pickle-cloudflare": ["packages/cloudflare/src/index.ts"], From c4d9f2ea0d90a549739b97b25019868423452e72 Mon Sep 17 00:00:00 2001 From: Batuhan Icoz Date: Tue, 2 Jun 2026 17:04:26 +0200 Subject: [PATCH 49/56] wip --- packages/openclaw/src/openclaw-runtime.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/openclaw/src/openclaw-runtime.ts b/packages/openclaw/src/openclaw-runtime.ts index 2c682e6..76ff70e 100644 --- a/packages/openclaw/src/openclaw-runtime.ts +++ b/packages/openclaw/src/openclaw-runtime.ts @@ -98,7 +98,6 @@ export interface OpenClawSessionCreateOptions { parentSessionKey?: string; reasoningLevel?: string; task?: string; - thinkingLevel?: string; verboseLevel?: string; } @@ -107,7 +106,6 @@ export interface OpenClawSessionPatchOptions { key: string; label?: string; reasoningLevel?: string; - thinkingLevel?: string; verboseLevel?: string; } @@ -258,13 +256,12 @@ export class OpenClawPluginRuntimeAdapter { const record = recordValue(raw) ?? {}; const key = stringValue(record.key) ?? stringValue(record.sessionKey) ?? options.key; if (!key) throw new Error("OpenClaw sessions.create did not return a session key"); - if (options.reasoningLevel || options.thinkingLevel || options.verboseLevel) { + if (options.reasoningLevel || options.verboseLevel) { const patch: OpenClawSessionPatchOptions = { agentId: options.agentId, key, }; if (options.reasoningLevel) patch.reasoningLevel = options.reasoningLevel; - if (options.thinkingLevel) patch.thinkingLevel = options.thinkingLevel; if (options.verboseLevel) patch.verboseLevel = options.verboseLevel; await this.patchSession(patch); } @@ -283,7 +280,6 @@ export class OpenClawPluginRuntimeAdapter { key: options.key, label: options.label, reasoningLevel: options.reasoningLevel, - thinkingLevel: options.thinkingLevel, verboseLevel: options.verboseLevel, })); } @@ -697,7 +693,6 @@ async function createSessionInPluginRuntime(runtime: OpenClawHostRuntime, params origin: recordValue(entry.origin) ?? { provider: "beeper", surface: "beeper", chatType: "direct" }, provider: stringValue(entry.provider) ?? "beeper", reasoningLevel: stringValue(record.reasoningLevel) ?? stringValue(entry.reasoningLevel), - thinkingLevel: stringValue(record.thinkingLevel) ?? stringValue(entry.thinkingLevel), sessionFile: stringValue(entry.sessionFile) ?? resolvePluginSessionFile(runtime, agentId, sessionId, entry), sessionId, updatedAt: typeof entry.updatedAt === "number" ? entry.updatedAt : now, @@ -718,7 +713,6 @@ async function patchSessionInPluginRuntime(runtime: OpenClawHostRuntime, params: ...entry, ...(record.label !== undefined ? { label: stringValue(record.label) } : {}), ...(record.reasoningLevel !== undefined ? { reasoningLevel: stringValue(record.reasoningLevel) } : {}), - ...(record.thinkingLevel !== undefined ? { thinkingLevel: stringValue(record.thinkingLevel) } : {}), ...(record.verboseLevel !== undefined ? { verboseLevel: stringValue(record.verboseLevel) } : {}), updatedAt: Date.now(), }); From 8e6eb49645585309103157c91013375e706d0bca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Tue, 2 Jun 2026 18:25:46 +0200 Subject: [PATCH 50/56] Add multi-account Beeper onboarding --- packages/bridge/src/bridge.ts | 14 +- packages/bridge/src/provisioning.test.ts | 38 +- packages/bridge/src/provisioning.ts | 47 +- packages/bridge/src/types.ts | 15 + packages/openclaw/AGENTS.md | 149 +++ packages/openclaw/openclaw.plugin.json | 238 ++++- packages/openclaw/package.json | 54 +- .../openclaw/scripts/copy-runtime-assets.mjs | 9 + packages/openclaw/skills/beeper-cli/SKILL.md | 25 + packages/openclaw/src/appservice.test.ts | 4 +- packages/openclaw/src/appservice.ts | 2 +- .../src/beeper-channel-config.schema.json | 120 ++- packages/openclaw/src/beeper-cli/tool.test.ts | 46 + packages/openclaw/src/beeper-cli/tool.ts | 210 ++++ packages/openclaw/src/beeper-setup.ts | 18 +- packages/openclaw/src/cli.test.ts | 10 +- packages/openclaw/src/cli.ts | 19 +- packages/openclaw/src/config.test.ts | 18 +- packages/openclaw/src/config.ts | 49 +- packages/openclaw/src/connector.test.ts | 34 + packages/openclaw/src/connector.ts | 23 +- packages/openclaw/src/function-entry.test.ts | 25 + packages/openclaw/src/function-entry.ts | 34 + .../openclaw/src/openclaw-extension.test.ts | 66 +- packages/openclaw/src/openclaw-extension.ts | 28 - .../openclaw/src/openclaw-runtime.test.ts | 41 + packages/openclaw/src/openclaw-runtime.ts | 108 +- packages/openclaw/src/plugin-entry.ts | 13 +- packages/openclaw/src/secret-contract.ts | 114 ++ packages/openclaw/src/setup-entry.ts | 10 +- packages/openclaw/src/setup.test.ts | 173 ++- packages/openclaw/src/setup.ts | 272 +++-- packages/openclaw/src/types.ts | 8 +- packages/openclaw/tsdown.config.ts | 2 +- packages/pickle/native/go.mod | 8 +- packages/pickle/native/go.sum | 18 +- .../pickle/native/internal/core/messages.go | 14 +- .../native/internal/core/messages_test.go | 67 ++ pnpm-lock.yaml | 999 ++---------------- 39 files changed, 1964 insertions(+), 1178 deletions(-) create mode 100644 packages/openclaw/AGENTS.md create mode 100644 packages/openclaw/skills/beeper-cli/SKILL.md create mode 100644 packages/openclaw/src/beeper-cli/tool.test.ts create mode 100644 packages/openclaw/src/beeper-cli/tool.ts create mode 100644 packages/openclaw/src/function-entry.test.ts create mode 100644 packages/openclaw/src/function-entry.ts delete mode 100644 packages/openclaw/src/openclaw-extension.ts create mode 100644 packages/openclaw/src/secret-contract.ts diff --git a/packages/bridge/src/bridge.ts b/packages/bridge/src/bridge.ts index 91e41dc..96f4d2d 100644 --- a/packages/bridge/src/bridge.ts +++ b/packages/bridge/src/bridge.ts @@ -898,18 +898,22 @@ export class RuntimeBridge implements PickleBridge { ...params, portal: this.#portalForRoom(roomId), }), - listContacts: async (login, query, limit) => { + listContacts: async (login) => { const client = await this.loadUserLogin(login); if (!hasMethod(client, "listContacts")) { throw new Error(`Login ${login.id} does not support contact listing`); } - return (client as import("./types").ContactListingNetworkAPI).listContacts(this.#requestContext(), { - ...(limit !== undefined ? { limit } : {}), - ...(query !== undefined ? { query } : {}), - }); + return (client as import("./types").ContactListingNetworkAPI).listContacts(this.#requestContext(), {}); }, requestContext: () => this.#requestContext(), resolveIdentifier: (login, identifier, createDM) => this.resolveIdentifier(login, { createDM, identifier }), + searchUsers: async (login, query) => { + const client = await this.loadUserLogin(login); + if (!hasMethod(client, "searchUsers")) { + throw new Error(`Login ${login.id} does not support user search`); + } + return (client as import("./types").UserSearchingNetworkAPI).searchUsers(this.#requestContext(), { query }); + }, }, { logins: this.#provisioningLogins }, request); } diff --git a/packages/bridge/src/provisioning.test.ts b/packages/bridge/src/provisioning.test.ts index 9c8ad44..3c6743e 100644 --- a/packages/bridge/src/provisioning.test.ts +++ b/packages/bridge/src/provisioning.test.ts @@ -12,7 +12,7 @@ describe("handleProvisioningHTTPProxy", () => { })).resolves.toMatchObject({ body: { group_creation: {}, - resolve_identifier: { createDM: true }, + resolve_identifier: { create_dm: true }, }, status: 200, }); @@ -52,11 +52,12 @@ describe("handleProvisioningHTTPProxy", () => { await expect(handleProvisioningHTTPProxy(runtime, { logins: new Map() }, { method: "GET", path: "/_matrix/provision/v3/contacts", - query: "q=codex&limit=10", + query: "login_id=intern", })).resolves.toMatchObject({ body: { contacts: [{ id: "intern", + identifiers: ["openclaw:agent:intern", "@intern:example"], mxid: "@intern:example", name: "Intern", }], @@ -64,7 +65,30 @@ describe("handleProvisioningHTTPProxy", () => { status: 200, }); - expect(runtime.listContacts).toHaveBeenCalledWith({ id: "intern" }, "codex", 10); + expect(runtime.listContacts).toHaveBeenCalledWith({ id: "intern" }); + }); + + it("searches users through the BridgeV2 search_users endpoint", async () => { + const runtime = provisioningRuntime(); + + await expect(handleProvisioningHTTPProxy(runtime, { logins: new Map() }, { + body: { query: "codex" }, + method: "POST", + path: "/_matrix/provision/v3/search_users", + query: "login_id=intern", + })).resolves.toMatchObject({ + body: { + results: [{ + id: "intern", + identifiers: ["openclaw:agent:intern", "@intern:example"], + mxid: "@intern:example", + name: "Intern", + }], + }, + status: 200, + }); + + expect(runtime.searchUsers).toHaveBeenCalledWith({ id: "intern" }, "codex"); }); it("runs room backfill through provisioning", async () => { @@ -147,7 +171,13 @@ function provisioningRuntime(): ProvisioningRuntime { listLogins: () => [login], listContacts: vi.fn(async () => ({ contacts: [{ - ghost: { displayName: "Intern", id: "intern", mxid: "@intern:example" }, + ghost: { displayName: "Intern", id: "intern", identifiers: ["openclaw:agent:intern", "@intern:example"], mxid: "@intern:example" }, + userId: "@intern:example", + }], + })), + searchUsers: vi.fn(async () => ({ + results: [{ + ghost: { displayName: "Intern", id: "intern", identifiers: ["openclaw:agent:intern", "@intern:example"], mxid: "@intern:example" }, userId: "@intern:example", }], })), diff --git a/packages/bridge/src/provisioning.ts b/packages/bridge/src/provisioning.ts index 7abf3dc..c2d26c6 100644 --- a/packages/bridge/src/provisioning.ts +++ b/packages/bridge/src/provisioning.ts @@ -13,6 +13,8 @@ import type { ResolveIdentifierResponse, BackfillQueueResult, BackfillQueueParams, + ResolveIdentifierCapabilities, + SearchUsersResponse, UserLogin, } from "./types"; @@ -22,9 +24,10 @@ export interface ProvisioningRuntime { listLogins(): UserLogin[]; loginFlows(): unknown[]; loadLogin(login: UserLogin): Promise; - listContacts?(login: UserLogin, query?: string, limit?: number): Promise; + listContacts?(login: UserLogin): Promise; requestContext(): BridgeRequestContext; resolveIdentifier(login: UserLogin, identifier: string, createDM: boolean): Promise; + searchUsers?(login: UserLogin, query: string): Promise; backfill?(login: UserLogin, roomId: string, params: ProvisioningBackfillParams): Promise; } @@ -49,19 +52,22 @@ export async function handleProvisioningHTTPProxy(runtime: ProvisioningRuntime, } if (method === "GET" && path === "/_matrix/provision/v3/contacts") { - if (!runtime.listContacts) return jsonHTTPResponse(404, matrixError("M_UNSUPPORTED", "Contact listing is not supported")); + if (!runtime.listContacts) return notSupportedResponse("Contact listing is not supported"); const login = provisioningLogin(runtime, request); if (!login) return jsonHTTPResponse(404, matrixError("M_NOT_FOUND", "Login not found")); - return jsonHTTPResponse(200, contactsListResponse(await runtime.listContacts( - login, - queryParam(request.query, "q"), - intQueryParam(request.query, "limit"), - ))); + return jsonHTTPResponse(200, contactsListResponse(await runtime.listContacts(login))); + } + + if (method === "POST" && path === "/_matrix/provision/v3/search_users") { + if (!runtime.searchUsers) return notSupportedResponse("User search is not supported"); + const login = provisioningLogin(runtime, request); + if (!login) return jsonHTTPResponse(404, matrixError("M_NOT_FOUND", "Login not found")); + return jsonHTTPResponse(200, searchUsersResponse(await runtime.searchUsers(login, bodyStringParam(request, "query") ?? ""))); } const backfill = match(path, /^\/_matrix\/provision\/v3\/backfill\/([^/]+)$/); if ((method === "GET" || method === "POST") && backfill) { - if (!runtime.backfill) return jsonHTTPResponse(404, matrixError("M_UNSUPPORTED", "Backfill is not supported")); + if (!runtime.backfill) return notSupportedResponse("Backfill is not supported"); const [roomId] = backfill; if (!roomId) return null; const login = provisioningLogin(runtime, request); @@ -157,15 +163,28 @@ export function jsonHTTPResponse(status: number, body: unknown): HTTPProxyRespon function capabilitiesResponse(capabilities: NetworkGeneralCapabilities): unknown { return { group_creation: capabilities.provisioning?.groupCreation ?? {}, - resolve_identifier: capabilities.provisioning?.resolveIdentifier ?? {}, + resolve_identifier: resolveIdentifierCapabilitiesResponse(capabilities.provisioning?.resolveIdentifier), }; } +function resolveIdentifierCapabilitiesResponse(capabilities?: ResolveIdentifierCapabilities): Record { + return stripUndefined({ + any_phone: capabilities?.anyPhone, + contact_list: capabilities?.contactList, + create_dm: capabilities?.createDM, + lookup_email: capabilities?.lookupEmail, + lookup_phone: capabilities?.lookupPhone, + lookup_username: capabilities?.lookupUsername, + search: capabilities?.search, + }); +} + function resolvedIdentifierResponse(resolved: ResolveIdentifierResponse): Record { return stripUndefined({ avatar_url: resolved.ghost?.avatar?.url, dm_room_mxid: resolved.portal?.mxid, id: resolved.ghost?.id ?? resolved.userId, + identifiers: resolved.ghost?.identifiers, mxid: resolved.userId ?? resolved.ghost?.mxid, name: resolved.ghost?.displayName, }); @@ -178,6 +197,12 @@ function contactsListResponse(response: ListContactsResponse): Record { + return { + results: response.results.map((result) => resolvedIdentifierResponse(result)), + }; +} + function backfillResponse(response: BackfillQueueResult): Record { return stripUndefined({ cursor: response.cursor, @@ -260,6 +285,10 @@ function matrixError(errcode: string, error: string): Record { return { errcode, error }; } +function notSupportedResponse(message: string): HTTPProxyResponse { + return jsonHTTPResponse(501, matrixError("M_UNRECOGNIZED", message)); +} + function match(path: string, regex: RegExp): string[] | null { const result = regex.exec(path); const captures = result?.slice(1); diff --git a/packages/bridge/src/types.ts b/packages/bridge/src/types.ts index af10596..7ac6287 100644 --- a/packages/bridge/src/types.ts +++ b/packages/bridge/src/types.ts @@ -168,6 +168,10 @@ export interface ContactListingNetworkAPI extends NetworkAPI { listContacts(ctx: BridgeRequestContext, params: ListContactsParams): Promise; } +export interface UserSearchingNetworkAPI extends NetworkAPI { + searchUsers(ctx: BridgeRequestContext, params: SearchUsersParams): Promise; +} + export interface MessageRequestHandlingNetworkAPI extends NetworkAPI { handleMessageRequest(ctx: BridgeRequestContext, request: MessageRequest): Promise; } @@ -761,10 +765,13 @@ export interface ProvisioningCapabilities { } export interface ResolveIdentifierCapabilities { + anyPhone?: boolean; contactList?: boolean; createDM?: boolean; + lookupEmail?: boolean; lookupPhone?: boolean; lookupUsername?: boolean; + search?: boolean; } export interface GroupTypeCapabilities { @@ -934,6 +941,14 @@ export interface ListContactsResponse { nextBatch?: string; } +export interface SearchUsersParams { + query: string; +} + +export interface SearchUsersResponse { + results: ResolveIdentifierResponse[]; +} + export interface UserProfile { avatarUrl?: string; displayName?: string; diff --git a/packages/openclaw/AGENTS.md b/packages/openclaw/AGENTS.md new file mode 100644 index 0000000..784a994 --- /dev/null +++ b/packages/openclaw/AGENTS.md @@ -0,0 +1,149 @@ +# OpenClaw Beeper plugin + +This package is the OpenClaw channel plugin for the Beeper bridge. Treat it as a +first-class OpenClaw network plugin backed by Pickle and bridgev2 semantics. + +## Local development + +From the Pickle repo root: + +```sh +pnpm --filter @beeper/openclaw build +``` + +From this package directory: + +```sh +pnpm build +``` + +OpenClaw loads the runtime entry from `dist/plugin-entry.mjs` and the setup +entry from `dist/setup-entry.mjs`, so rebuild before installing or restarting a +locally linked plugin. + +## Install or update the plugin + +For a published install: + +```sh +openclaw plugins install clawhub:@beeper/openclaw@0.1.0 +``` + +For local development from this package directory: + +```sh +pnpm build +openclaw plugins install --force --link . +``` + +If working from the Pickle repo root, pass the package path instead: + +```sh +pnpm --filter @beeper/openclaw build +openclaw plugins install --force --link packages/openclaw +``` + +Check that OpenClaw discovered the plugin: + +```sh +openclaw plugins list +openclaw plugins inspect beeper +openclaw plugins doctor +``` + +Configure the channel through OpenClaw's setup UI or CLI: + +```sh +openclaw channels add +``` + +If the installed OpenClaw version exposes plugin channels through the channel +CLI, `openclaw channels add --channel beeper` may also work. + +## Restart and inspect runtime state + +Restart the gateway after changing plugin code or configuration: + +```sh +openclaw gateway restart +``` + +Inspect gateway and channel status: + +```sh +openclaw gateway status +openclaw channels list +openclaw channels status --probe +openclaw channels logs --channel beeper +``` + +For runtime debugging, run the gateway in the foreground: + +```sh +openclaw gateway run --verbose +``` + +Use raw stream logging only when investigating OpenClaw stream exposure: + +```sh +openclaw gateway run --verbose --raw-stream +``` + +## Testing + +Run focused package tests while iterating: + +```sh +pnpm --filter @beeper/openclaw exec vitest run +pnpm --filter @beeper/openclaw typecheck +pnpm --filter @beeper/openclaw build +``` + +Run Pickle native tests when changing generated contracts, appservice behavior, +room state, or bridge transport: + +```sh +cd packages/pickle/native +go test ./internal/core +cd - +pnpm --filter @beeper/pickle build:wasm +``` + +Run OpenClaw plugin compatibility through Crabpot from the Pickle repo root: + +```sh +npm run test:openclaw:plugins +``` + +The full suite is: + +```sh +npm run full-test +``` + +Do not run opt-in isolated execution checks unless the task explicitly requires +side effects: + +```sh +CRABPOT_EXECUTE_ISOLATED=1 npm --prefix ../crabpot run workspace:execute -- --fixture +``` + +## Product and integration rules + +- This is a bridge, not an open Matrix client. Users talk to OpenClaw agents + from the Beeper/OpenClaw bridge instance; do not add outside-world Matrix + semantics unless bridgev2 requires them. +- Every configured OpenClaw agent is a global ghost with the agent's attributes, + including display name and avatar when exposed. +- Each Beeper turn should correspond to one OpenClaw/Beeper stream turn. Do not + emit extra progress messages except approval anchors or bridge-required state. +- Anything OpenClaw exposes through callbacks, hooks, or runtime events must be + mapped and streamed. Pickle cannot recover drops that OpenClaw never exposes. +- Prefer bridgev2 and generated Pickle Go contracts over direct TypeScript + Matrix writes. Direct Matrix client usage is acceptable only where it matches + bridgev2/mautrix bridge practice and keeps the system simpler. +- Keep the code small and direct: no fake layers, no convenience barrel exports, + no duplicated types, no compatibility aliases for unreleased shapes. +- Do not patch the host OpenClaw source while working in this package. Use + OpenClaw as a reference checkout and validate plugin behavior through the + plugin SDK, CLI, and Crabpot. diff --git a/packages/openclaw/openclaw.plugin.json b/packages/openclaw/openclaw.plugin.json index a9b04e6..baba47a 100644 --- a/packages/openclaw/openclaw.plugin.json +++ b/packages/openclaw/openclaw.plugin.json @@ -1,18 +1,31 @@ { "id": "beeper", "name": "Beeper", - "description": "Bridge OpenClaw sessions and agents into Beeper.", + "description": "Chat with your OpenClaw agents on Beeper.", "activation": { - "onStartup": true + "onStartup": false }, "channels": [ "beeper" ], - "channelEnvVars": { - "beeper": [ - "PICKLE_OPENCLAW_BEEPER_ENV" + "contracts": { + "tools": [ + "beeper_cli" ] }, + "toolMetadata": { + "beeper_cli": { + "optional": true + } + }, + "commandAliases": [ + { + "name": "beeper" + } + ], + "skills": [ + "./skills" + ], "configSchema": { "type": "object", "additionalProperties": false, @@ -26,23 +39,226 @@ "properties": { "enabled": { "type": "boolean", - "description": "Enable the Beeper bridge channel." + "description": "Enable Beeper agent chat." }, - "beeperEnv": { + "defaultAccount": { + "type": "string", + "description": "Default Beeper account id for outbound agent DMs when no agent-specific default is set." + }, + "accounts": { + "type": "object", + "description": "Named Beeper accounts. Each account is set up through the normal OpenClaw channel setup flow.", + "additionalProperties": { + "type": "object", + "additionalProperties": true, + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable this Beeper account." + }, + "name": { + "type": "string", + "description": "Display name for this Beeper account in settings and status." + }, + "serverEnv": { + "type": "string", + "enum": [ + "prod", + "staging", + "dev", + "local" + ], + "default": "prod", + "description": "Beeper server environment to use before login. Changing it after login requires logging out and logging back in." + }, + "asToken": { + "description": "OpenClaw-managed Beeper appservice token.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "source", + "provider", + "id" + ], + "properties": { + "source": { + "type": "string", + "enum": [ + "env", + "file", + "exec" + ] + }, + "provider": { + "type": "string" + }, + "id": { + "type": "string" + } + } + } + ] + }, + "hsToken": { + "description": "OpenClaw-managed Beeper homeserver token.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "source", + "provider", + "id" + ], + "properties": { + "source": { + "type": "string", + "enum": [ + "env", + "file", + "exec" + ] + }, + "provider": { + "type": "string" + }, + "id": { + "type": "string" + } + } + } + ] + } + } + } + }, + "agents": { + "type": "object", + "description": "Per-agent Beeper account assignments. Account ids are not mutually exclusive.", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "accountIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Beeper account ids this agent may use." + }, + "defaultAccount": { + "type": "string", + "description": "Preferred Beeper account for this agent when multiple assigned accounts are available." + } + } + } + }, + "serverEnv": { "type": "string", "enum": [ - "production", + "prod", "staging", "dev", "local" ], - "description": "Beeper environment for login and appservice registration." + "default": "prod", + "description": "Beeper server environment to use before login. Changing it after login requires logging out and logging back in." + }, + "asToken": { + "description": "OpenClaw-managed Beeper appservice token.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "source", + "provider", + "id" + ], + "properties": { + "source": { + "type": "string", + "enum": [ + "env", + "file", + "exec" + ] + }, + "provider": { + "type": "string" + }, + "id": { + "type": "string" + } + } + } + ] + }, + "hsToken": { + "description": "OpenClaw-managed Beeper homeserver token.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "source", + "provider", + "id" + ], + "properties": { + "source": { + "type": "string", + "enum": [ + "env", + "file", + "exec" + ] + }, + "provider": { + "type": "string" + }, + "id": { + "type": "string" + } + } + } + ] } } }, - "uiHints": {}, + "uiHints": { + "asToken": { + "sensitive": true, + "tags": [ + "hidden" + ] + }, + "hsToken": { + "sensitive": true, + "tags": [ + "hidden" + ] + }, + "serverEnv": { + "help": "Choose before Beeper login. To change it after connecting, log out and log back in." + } + }, "label": "Beeper", - "description": "Bridge OpenClaw sessions and agents into Beeper.", + "description": "Chat with your OpenClaw agents on Beeper.", "commands": { "nativeCommandsAutoEnabled": true, "nativeSkillsAutoEnabled": true diff --git a/packages/openclaw/package.json b/packages/openclaw/package.json index 6d62ac5..00832c7 100644 --- a/packages/openclaw/package.json +++ b/packages/openclaw/package.json @@ -47,6 +47,10 @@ "types": "./dist/cli.d.mts", "import": "./dist/cli.mjs" }, + "./function-entry": { + "types": "./dist/function-entry.d.mts", + "import": "./dist/function-entry.mjs" + }, "./config": { "types": "./dist/config.d.mts", "import": "./dist/config.mjs" @@ -59,10 +63,6 @@ "types": "./dist/matrix-parser.d.mts", "import": "./dist/matrix-parser.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" @@ -102,58 +102,43 @@ }, "files": [ "dist", + "skills", "openclaw.plugin.json", "README.md", "LICENSE" ], "openclaw": { "extensions": [ - "./src/plugin-entry.ts" + "./src/plugin-entry.ts", + "./src/function-entry.ts" ], "runtimeExtensions": [ - "./dist/plugin-entry.mjs" + "./dist/plugin-entry.mjs", + "./dist/function-entry.mjs" ], "setupEntry": "./src/setup-entry.ts", "runtimeSetupEntry": "./dist/setup-entry.mjs", "channel": { "id": "beeper", "label": "Beeper", - "selectionLabel": "Beeper bridge", - "detailLabel": "Beeper Matrix bridge", + "selectionLabel": "Beeper agent DMs", + "detailLabel": "Beeper agent chat", "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": "--username ", - "description": "Beeper Matrix username for password login" - }, - { - "flags": "--password ", - "description": "Beeper Matrix password for password login" - }, - { - "flags": "--bridge-manager-token ", - "description": "Beeper bridge-manager token for self-hosted appservice registration" - } - ] + "blurb": "lets you chat with your OpenClaw agents on Beeper.", + "systemImage": "message" }, "install": { "clawhubSpec": "clawhub:@beeper/openclaw@0.1.0", "npmSpec": "@beeper/openclaw@0.1.0", "defaultChoice": "clawhub", - "minHostVersion": ">=2026.5.22" + "minHostVersion": ">=2026.6.2" }, "compat": { - "pluginApi": ">=2026.5.22" + "pluginApi": ">=2026.6.2" }, "build": { - "openclawVersion": "2026.5.24" + "openclawVersion": "2026.6.2" }, "release": { "publishToClawHub": true, @@ -177,13 +162,16 @@ "@beeper/pickle-state-file": "workspace:^", "@types/node": "^20.0.0", "@vitest/coverage-v8": "^4.0.18", - "openclaw": "2026.5.22", + "openclaw": "2026.5.28", "tsdown": "^0.21.10", "typescript": "^5.7.2", "vitest": "^4.0.18" }, + "dependencies": { + "beeper-cli": "^0.6.2" + }, "peerDependencies": { - "openclaw": ">=2026.5.22" + "openclaw": ">=2026.6.2" }, "peerDependenciesMeta": { "openclaw": { diff --git a/packages/openclaw/scripts/copy-runtime-assets.mjs b/packages/openclaw/scripts/copy-runtime-assets.mjs index 5410813..a0bf218 100644 --- a/packages/openclaw/scripts/copy-runtime-assets.mjs +++ b/packages/openclaw/scripts/copy-runtime-assets.mjs @@ -17,3 +17,12 @@ for (const file of ["pickle.wasm", "wasm_exec.js"]) { } await copyFile(source, resolve(outputDir, file)); } + +for (const file of ["setup", "secret-contract"]) { + await copyFile(resolve(outputDir, `${file}.mjs`), resolve(outputDir, `${file}.js`)); + try { + await copyFile(resolve(outputDir, `${file}.mjs.map`), resolve(outputDir, `${file}.js.map`)); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } +} diff --git a/packages/openclaw/skills/beeper-cli/SKILL.md b/packages/openclaw/skills/beeper-cli/SKILL.md new file mode 100644 index 0000000..8cb337b --- /dev/null +++ b/packages/openclaw/skills/beeper-cli/SKILL.md @@ -0,0 +1,25 @@ +# Beeper CLI + +Use the `beeper_cli` function when the user asks to inspect, search, read, or send Beeper chats through the bundled Beeper CLI. + +## Usage + +- Pass only CLI arguments in `args`; omit the executable name. +- Prefer JSON output flags when the Beeper CLI supports them. +- Use narrow commands first, such as listing chats or searching recent messages, before reading larger histories. +- Do not send messages, edit data, or mutate Beeper state unless the user explicitly asks for that action. + +## Examples + +```json +{ + "args": ["chats", "list", "--output", "json"] +} +``` + +```json +{ + "args": ["messages", "search", "alice", "--output", "json"], + "timeoutMs": 30000 +} +``` diff --git a/packages/openclaw/src/appservice.test.ts b/packages/openclaw/src/appservice.test.ts index 20d6b9d..96a4c30 100644 --- a/packages/openclaw/src/appservice.test.ts +++ b/packages/openclaw/src/appservice.test.ts @@ -9,13 +9,13 @@ describe("OpenClaw Beeper appservice runtime", () => { const bridge = fakeBridge(); const bridgeFactory = vi.fn(async (_options: CreateNodeBeeperBridgeOptions) => bridge); const config = createDefaultConfig({ - beeperEnv: "staging", dataDir: "/tmp/openclaw", asToken: "as-token", homeserver: "https://matrix.beeper-staging.com", homeserverDomain: "beeper.local", hsToken: "hs-token", matrixUserId: "@batuhan:beeper-staging.com", + serverEnv: "staging", }); await expect(createOpenClawBeeperBridge({ @@ -60,12 +60,12 @@ describe("OpenClaw Beeper appservice runtime", () => { const config = createDefaultConfig({ appserviceId: "sh-openclaw-device", asToken: "as-token", - beeperEnv: "staging", bridgeId: "sh-openclaw-device", dataDir: "/tmp/openclaw", homeserver: "https://matrix.beeper-staging.com", hsToken: "hs-token", matrixUserId: "@batuhan:beeper-staging.com", + serverEnv: "staging", }); await expect(startOpenClawBeeperBridge({ diff --git a/packages/openclaw/src/appservice.ts b/packages/openclaw/src/appservice.ts index 438886f..cab826b 100644 --- a/packages/openclaw/src/appservice.ts +++ b/packages/openclaw/src/appservice.ts @@ -32,7 +32,7 @@ export async function createOpenClawBeeperBridge(options: CreateOpenClawBeeperBr }; if (config?.matrixUserId !== undefined) bridgeOptions.ownerUserId = config.matrixUserId; bridgeOptions.address = "websocket"; - const baseDomain = beeperBaseDomain(config?.beeperEnv); + const baseDomain = beeperBaseDomain(config?.serverEnv === "prod" ? "production" : config?.serverEnv); if (baseDomain !== undefined) bridgeOptions.baseDomain = baseDomain; bridgeOptions.bridgeManagerPostState = true; if (config?.homeserverDomain !== undefined) bridgeOptions.homeserverDomain = config.homeserverDomain; diff --git a/packages/openclaw/src/beeper-channel-config.schema.json b/packages/openclaw/src/beeper-channel-config.schema.json index fcbfcd4..f1e55f9 100644 --- a/packages/openclaw/src/beeper-channel-config.schema.json +++ b/packages/openclaw/src/beeper-channel-config.schema.json @@ -4,12 +4,124 @@ "properties": { "enabled": { "type": "boolean", - "description": "Enable the Beeper bridge channel." + "description": "Enable Beeper agent chat." }, - "beeperEnv": { + "defaultAccount": { "type": "string", - "enum": ["production", "staging", "dev", "local"], - "description": "Beeper environment for login and appservice registration." + "description": "Default Beeper account id for outbound agent DMs when no agent-specific default is set." + }, + "accounts": { + "type": "object", + "description": "Named Beeper accounts. Each account is set up through the normal OpenClaw channel setup flow.", + "additionalProperties": { + "type": "object", + "additionalProperties": true, + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable this Beeper account." + }, + "name": { + "type": "string", + "description": "Display name for this Beeper account in settings and status." + }, + "serverEnv": { + "type": "string", + "enum": ["prod", "staging", "dev", "local"], + "default": "prod", + "description": "Beeper server environment to use before login. Changing it after login requires logging out and logging back in." + }, + "asToken": { + "description": "OpenClaw-managed Beeper appservice token.", + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "additionalProperties": false, + "required": ["source", "provider", "id"], + "properties": { + "source": { "type": "string", "enum": ["env", "file", "exec"] }, + "provider": { "type": "string" }, + "id": { "type": "string" } + } + } + ] + }, + "hsToken": { + "description": "OpenClaw-managed Beeper homeserver token.", + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "additionalProperties": false, + "required": ["source", "provider", "id"], + "properties": { + "source": { "type": "string", "enum": ["env", "file", "exec"] }, + "provider": { "type": "string" }, + "id": { "type": "string" } + } + } + ] + } + } + } + }, + "agents": { + "type": "object", + "description": "Per-agent Beeper account assignments. Account ids are not mutually exclusive.", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "accountIds": { + "type": "array", + "items": { "type": "string" }, + "description": "Beeper account ids this agent may use." + }, + "defaultAccount": { + "type": "string", + "description": "Preferred Beeper account for this agent when multiple assigned accounts are available." + } + } + } + }, + "serverEnv": { + "type": "string", + "enum": ["prod", "staging", "dev", "local"], + "default": "prod", + "description": "Beeper server environment to use before login. Changing it after login requires logging out and logging back in." + }, + "asToken": { + "description": "OpenClaw-managed Beeper appservice token.", + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "additionalProperties": false, + "required": ["source", "provider", "id"], + "properties": { + "source": { "type": "string", "enum": ["env", "file", "exec"] }, + "provider": { "type": "string" }, + "id": { "type": "string" } + } + } + ] + }, + "hsToken": { + "description": "OpenClaw-managed Beeper homeserver token.", + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "additionalProperties": false, + "required": ["source", "provider", "id"], + "properties": { + "source": { "type": "string", "enum": ["env", "file", "exec"] }, + "provider": { "type": "string" }, + "id": { "type": "string" } + } + } + ] } } } diff --git a/packages/openclaw/src/beeper-cli/tool.test.ts b/packages/openclaw/src/beeper-cli/tool.test.ts new file mode 100644 index 0000000..b3e1067 --- /dev/null +++ b/packages/openclaw/src/beeper-cli/tool.test.ts @@ -0,0 +1,46 @@ +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createBeeperCliTool, runBeeperCli } from "./tool"; + +describe("Beeper CLI function", () => { + it("runs an explicitly configured Beeper CLI JS launcher", async () => { + const dir = await mkdtemp(join(tmpdir(), "beeper-cli-tool-")); + const script = join(dir, "beeper.js"); + await writeFile(script, "for (const arg of process.argv.slice(2)) console.log(arg);\n"); + + await expect(runBeeperCli({ + args: ["chats", "list", "--output", "json"], + env: { ...process.env, BEEPER_CLI: script }, + })).resolves.toMatchObject({ + args: ["chats", "list", "--output", "json"], + code: 0, + stdout: "chats\nlist\n--output\njson\n", + }); + }); + + it("exposes a dedicated optional tool schema", async () => { + const tool = createBeeperCliTool(); + expect(tool).toMatchObject({ + name: "beeper_cli", + parameters: { + required: ["args"], + properties: { + args: expect.objectContaining({ type: "array" }), + }, + }, + }); + await expect(tool.execute("call-1", { args: "chats list" })).rejects.toThrow( + "args must be an array of strings", + ); + }); + + it("resolves the bundled beeper-cli npm launcher without PATH lookup", async () => { + await expect(runBeeperCli({ + args: ["--help"], + env: { ...process.env, PATH: "" }, + timeoutMs: 1, + })).rejects.toThrow("Beeper CLI timed out"); + }); +}); diff --git a/packages/openclaw/src/beeper-cli/tool.ts b/packages/openclaw/src/beeper-cli/tool.ts new file mode 100644 index 0000000..c5939e4 --- /dev/null +++ b/packages/openclaw/src/beeper-cli/tool.ts @@ -0,0 +1,210 @@ +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import { resolve } from "node:path"; + +const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_MAX_OUTPUT_BYTES = 512_000; + +const requireFromHere = createRequire(import.meta.url); + +export interface BeeperCliRunOptions { + args?: string[]; + cwd?: string; + env?: NodeJS.ProcessEnv; + maxOutputBytes?: number; + timeoutMs?: number; +} + +export interface BeeperCliRunResult { + command: string; + args: string[]; + code: number | null; + signal: NodeJS.Signals | null; + stderr: string; + stdout: string; +} + +export function createBeeperCliTool() { + return { + name: "beeper_cli", + label: "Beeper CLI", + description: + "Run the bundled Beeper CLI as a dedicated local function. Use for Beeper Desktop chat search, reads, and sends.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + args: { + type: "array", + items: { type: "string" }, + description: "Arguments passed to the Beeper CLI, excluding the executable name.", + }, + cwd: { + type: "string", + description: "Working directory for the CLI process. Defaults to the current process directory.", + }, + timeoutMs: { + type: "integer", + minimum: 1, + description: "Maximum process runtime in milliseconds.", + }, + maxOutputBytes: { + type: "integer", + minimum: 1, + description: "Maximum stdout plus stderr bytes to retain.", + }, + }, + required: ["args"], + }, + async execute(_id: string, params: Record) { + const runOptions: BeeperCliRunOptions = { + args: readStringArray(params.args, "args"), + }; + const cwd = readOptionalString(params.cwd, "cwd"); + const timeoutMs = readOptionalPositiveInteger(params.timeoutMs, "timeoutMs"); + const maxOutputBytes = readOptionalPositiveInteger(params.maxOutputBytes, "maxOutputBytes"); + if (cwd !== undefined) runOptions.cwd = cwd; + if (timeoutMs !== undefined) runOptions.timeoutMs = timeoutMs; + if (maxOutputBytes !== undefined) runOptions.maxOutputBytes = maxOutputBytes; + return runBeeperCli(runOptions); + }, + }; +} + +export function registerBeeperCli(program: unknown): void { + const commandProgram = program as { + command?: (name: string) => { + description?: (text: string) => unknown; + allowUnknownOption?: (value?: boolean) => unknown; + argument?: (flags: string, description?: string) => unknown; + option?: (flags: string, description?: string) => unknown; + action?: (handler: (...args: unknown[]) => unknown) => unknown; + }; + }; + const command = commandProgram.command?.("beeper"); + if (!command) return; + command.description?.("Run the bundled Beeper CLI."); + command.allowUnknownOption?.(true); + command.argument?.("[args...]", "Arguments passed to the Beeper CLI"); + command.option?.("--timeout-ms ", "Maximum process runtime in milliseconds"); + command.option?.("--max-output-bytes ", "Maximum stdout plus stderr bytes to retain"); + command.action?.(async (...actionArgs: unknown[]) => { + const args = actionArgs[0]; + const options = isRecord(actionArgs[1]) ? actionArgs[1] : {}; + const runOptions: BeeperCliRunOptions = { + args: readStringArray(args, "args"), + }; + const timeoutMs = parseOptionalPositiveIntegerOption(options.timeoutMs, "timeout-ms"); + const maxOutputBytes = parseOptionalPositiveIntegerOption(options.maxOutputBytes, "max-output-bytes"); + if (timeoutMs !== undefined) runOptions.timeoutMs = timeoutMs; + if (maxOutputBytes !== undefined) runOptions.maxOutputBytes = maxOutputBytes; + const result = await runBeeperCli(runOptions); + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + process.exitCode = result.code ?? 1; + }); +} + +export async function runBeeperCli(options: BeeperCliRunOptions): Promise { + const args = options.args ?? []; + const env = options.env ?? process.env; + const launcher = resolveBeeperCliLauncher(env); + return spawnAndCollect(process.execPath, [launcher, ...args], { + cwd: options.cwd ? resolve(options.cwd) : process.cwd(), + env, + maxOutputBytes: options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES, + timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + }, launcher); +} + +function resolveBeeperCliLauncher(env: NodeJS.ProcessEnv): string { + if (env.BEEPER_CLI) return env.BEEPER_CLI; + return requireFromHere.resolve("beeper-cli/bin/beeper.js"); +} + +function spawnAndCollect( + command: string, + args: string[], + options: { cwd: string; env: NodeJS.ProcessEnv; maxOutputBytes: number; timeoutMs: number }, + reportedCommand = command, +): Promise { + return new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = Buffer.alloc(0); + let stderr = Buffer.alloc(0); + let settled = false; + const timer = setTimeout(() => { + child.kill("SIGTERM"); + reject(new Error(`Beeper CLI timed out after ${options.timeoutMs}ms`)); + }, options.timeoutMs); + + const append = (current: Buffer, chunk: Buffer) => { + const next = Buffer.concat([current, chunk]); + if (next.byteLength <= options.maxOutputBytes) return next; + return next.subarray(next.byteLength - options.maxOutputBytes); + }; + + child.stdout?.on("data", (chunk: Buffer) => { + stdout = append(stdout, chunk); + }); + child.stderr?.on("data", (chunk: Buffer) => { + stderr = append(stderr, chunk); + }); + child.on("error", (error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(error); + }); + child.on("close", (code, signal) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolvePromise({ + command: reportedCommand, + args: args.slice(1), + code, + signal, + stdout: stdout.toString("utf8"), + stderr: stderr.toString("utf8"), + }); + }); + }); +} + +function readStringArray(value: unknown, key: string): string[] { + if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) { + throw new Error(`${key} must be an array of strings`); + } + return value; +} + +function readOptionalString(value: unknown, key: string): string | undefined { + if (value === undefined) return undefined; + if (typeof value !== "string") throw new Error(`${key} must be a string`); + return value; +} + +function readOptionalPositiveInteger(value: unknown, key: string): number | undefined { + if (value === undefined) return undefined; + if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) { + throw new Error(`${key} must be a positive integer`); + } + return value; +} + +function parseOptionalPositiveIntegerOption(value: unknown, key: string): number | undefined { + if (value === undefined) return undefined; + if (typeof value !== "string") throw new Error(`${key} must be a positive integer`); + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${key} must be a positive integer`); + return parsed; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/packages/openclaw/src/beeper-setup.ts b/packages/openclaw/src/beeper-setup.ts index 2c1f96f..1304f6d 100644 --- a/packages/openclaw/src/beeper-setup.ts +++ b/packages/openclaw/src/beeper-setup.ts @@ -11,10 +11,10 @@ import { import { DEFAULT_REGISTRATION_URL } from "./config"; import { DEFAULT_BEEPER_BRIDGE_TYPE, openClawBeeperBridgeId } from "./ids"; import { resolveOpenClawDeviceId } from "./openclaw-identity"; -import type { OpenClawBridgeConfig } from "./types"; +import type { BeeperServerEnv, OpenClawBridgeConfig } from "./types"; export { DEFAULT_BEEPER_BRIDGE_TYPE, openClawBeeperBridgeId }; -export type { BeeperEnvironment }; +export type { BeeperEnvironment, BeeperServerEnv }; export interface BeeperSetupAccount { accessToken: string; @@ -25,7 +25,7 @@ export interface BeeperSetupAccount { export interface BeeperLoginForOpenClawOptions { email?: string; - env?: BeeperEnvironment; + env?: BeeperServerEnv; fetch?: typeof fetch; getLoginCode?: () => Promise | string; initialDeviceDisplayName?: string; @@ -77,7 +77,7 @@ export interface SetupOpenClawBeeperBridgeResult { } export async function loginToBeeperForOpenClaw(options: BeeperLoginForOpenClawOptions): Promise { - const env = options.env ?? "production"; + const env = beeperAuthEnv(options.env); const openClawDeviceId = options.openClawDeviceId ?? await resolveOpenClawDeviceId(); const bridgeId = openClawBeeperBridgeId(openClawDeviceId); const metadata = { ...options.metadata, bridge: bridgeId, bridgeType: DEFAULT_BEEPER_BRIDGE_TYPE, openClawDeviceId }; @@ -158,7 +158,8 @@ export async function createOpenClawBeeperAppService( export async function setupOpenClawBeeperBridge( options: SetupOpenClawBeeperBridgeOptions ): Promise { - const env = options.env ?? "production"; + const env = options.env ?? "prod"; + const authEnv = beeperAuthEnv(env); const openClawDeviceId = options.openClawDeviceId ?? await resolveOpenClawDeviceId(); const login = await loginToBeeperForOpenClaw({ ...options, env, openClawDeviceId }); const bridgeId = openClawBeeperBridgeId(openClawDeviceId); @@ -166,7 +167,7 @@ export async function setupOpenClawBeeperBridge( accessToken: login.account.accessToken, bridge: bridgeId, }; - const baseDomain = beeperBaseDomain(env); + const baseDomain = beeperBaseDomain(authEnv); if (baseDomain !== undefined) appserviceOptions.baseDomain = baseDomain; if (options.createAppServiceInit !== undefined) appserviceOptions.createAppServiceInit = options.createAppServiceInit; if (options.fetch !== undefined) appserviceOptions.fetch = options.fetch; @@ -191,3 +192,8 @@ export function beeperBaseDomain(env: BeeperEnvironment | undefined): string | u export function beeperMatrixHomeserver(env: BeeperEnvironment | undefined): string { return `https://matrix.${beeperBaseDomain(env) ?? "beeper.com"}`; } + +function beeperAuthEnv(env: BeeperServerEnv | undefined): BeeperEnvironment { + if (env === undefined || env === "prod") return "production"; + return env; +} diff --git a/packages/openclaw/src/cli.test.ts b/packages/openclaw/src/cli.test.ts index c33fe7e..8e83927 100644 --- a/packages/openclaw/src/cli.test.ts +++ b/packages/openclaw/src/cli.test.ts @@ -62,7 +62,7 @@ describe("pickle-openclaw CLI", () => { dir, "--email", "you@example.com", - "--env", + "--server-env", "staging", ], io, { setupBridge })).resolves.toBe(0); @@ -76,19 +76,19 @@ describe("pickle-openclaw CLI", () => { expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({ appserviceId: "sh-openclaw-device", asToken: "as-token", - beeperEnv: "staging", homeserver: "https://matrix.beeper.com", hsToken: "hs-token", matrixDeviceId: "DEVICE", matrixUserId: "@batuhan:beeper.com", + serverEnv: "staging", }); const output = JSON.parse(io.stdoutText); expect(output.account).toMatchObject({ appserviceId: "sh-openclaw-device", - beeperEnv: "staging", bridgeId: "sh-openclaw-device", canConnect: true, deviceId: "DEVICE", + serverEnv: "staging", userId: "@batuhan:beeper.com", }); expect(output).not.toHaveProperty("init"); @@ -154,7 +154,7 @@ describe("pickle-openclaw CLI", () => { "batuhan", "--password", "secret", - "--env", + "--server-env", "staging", ], io, { setupBridge })).resolves.toBe(0); @@ -199,11 +199,11 @@ describe("pickle-openclaw CLI", () => { expect(JSON.parse(io.stdoutText)).toEqual({ appserviceId: "sh-openclaw-device", - beeperEnv: "production", bridgeId: "sh-openclaw-device", canConnect: true, deviceId: "DEVICE", homeserver: "https://matrix.beeper.com", + serverEnv: "prod", userId: "@batuhan:beeper.com", }); }); diff --git a/packages/openclaw/src/cli.ts b/packages/openclaw/src/cli.ts index b48a8c2..7088d28 100644 --- a/packages/openclaw/src/cli.ts +++ b/packages/openclaw/src/cli.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node import { createInterface } from "node:readline/promises"; -import { setupOpenClawBeeperBridge, type BeeperEnvironment } from "./beeper-setup"; +import { setupOpenClawBeeperBridge, type BeeperServerEnv } from "./beeper-setup"; import { createDefaultConfig, defaultConfigPath, readConfig, writeConfig } from "./config"; import type { OpenClawBridgeConfig } from "./types"; @@ -34,7 +34,7 @@ export async function runCli(argv = process.argv.slice(2), io: CliIO = process, if (email !== undefined) setupOptions.email = email; if (username !== undefined) setupOptions.username = username; if (password !== undefined) setupOptions.password = password; - const env = beeperEnvOption(options); + const env = serverEnvOption(options); if (env !== undefined) setupOptions.env = env; if (email !== undefined) setupOptions.getLoginCode = () => promptForLoginCode(io); const result = await (deps.setupBridge ?? setupOpenClawBeeperBridge)(setupOptions); @@ -76,7 +76,6 @@ function helpText(): string { " --email
", " --username ", " --password ", - " --env ", "", ].join("\n"); } @@ -90,8 +89,8 @@ function configOverridesFromOptions(options: Map): Par function beeperRuntimeOverridesFromOptions(options: Map): Partial { const overrides: Partial = {}; - const env = beeperEnvOption(options); - if (env !== undefined) overrides.beeperEnv = env; + const env = serverEnvOption(options); + if (env !== undefined) overrides.serverEnv = env; return overrides; } @@ -104,7 +103,6 @@ async function loadConfig(options: Map): Promise { return { appserviceId: config.appserviceId, - beeperEnv: config.beeperEnv ?? "production", bridgeId: config.bridgeId ?? null, canConnect: Boolean( config.asToken && @@ -115,6 +113,7 @@ function whoamiPayload(config: OpenClawBridgeConfig): Record { ), deviceId: config.matrixDeviceId ?? null, homeserver: config.homeserver ?? null, + serverEnv: config.serverEnv ?? "prod", userId: config.matrixUserId ?? null, }; } @@ -141,11 +140,11 @@ function stringOption(options: Map, key: string): stri return typeof value === "string" ? value : undefined; } -function beeperEnvOption(options: Map): BeeperEnvironment | undefined { - const env = stringOption(options, "env"); +function serverEnvOption(options: Map): BeeperServerEnv | undefined { + const env = stringOption(options, "server-env"); if (env === undefined) return undefined; - if (env === "production" || env === "staging" || env === "dev" || env === "local") return env; - throw new Error(`Invalid --env: ${env}`); + if (env === "prod" || env === "staging" || env === "dev" || env === "local") return env; + throw new Error(`Invalid --server-env: ${env}`); } async function promptForLoginCode(io: CliIO): Promise { diff --git a/packages/openclaw/src/config.test.ts b/packages/openclaw/src/config.test.ts index fa9d3a5..95a64f6 100644 --- a/packages/openclaw/src/config.test.ts +++ b/packages/openclaw/src/config.test.ts @@ -7,7 +7,6 @@ import { createDefaultConfig, createConfigFromOpenClawSetup, readConfig, writeCo describe("OpenClaw bridge config", () => { afterEach(() => { - delete process.env.PICKLE_OPENCLAW_BEEPER_ENV; delete process.env.PICKLE_OPENCLAW_DEVICE_ID; delete process.env.PICKLE_OPENCLAW_HS_TOKEN; delete process.env.OPENCLAW_DEVICE_ID; @@ -31,14 +30,14 @@ describe("OpenClaw bridge config", () => { it("accepts saved login and registration state from OpenClaw config", () => { expect(createDefaultConfig({ - beeperEnv: "staging", asToken: "as-token", dataDir: "/tmp/openclaw-bridge", homeserverDomain: "beeper.local", + serverEnv: "staging", })).toMatchObject({ - beeperEnv: "staging", asToken: "as-token", homeserverDomain: "beeper.local", + serverEnv: "staging", }); }); @@ -58,15 +57,14 @@ describe("OpenClaw bridge config", () => { }); }); - it("accepts only Beeper environment and OpenClaw device id from environment variables", () => { - process.env.PICKLE_OPENCLAW_BEEPER_ENV = "staging"; + it("accepts only OpenClaw device id from environment variables", () => { process.env.PICKLE_OPENCLAW_DEVICE_ID = "openclaw.device"; process.env.PICKLE_OPENCLAW_HS_TOKEN = "ignored"; expect(createDefaultConfig({ dataDir: "/tmp/openclaw-bridge" })).toMatchObject({ appserviceId: "sh-openclaw-openclaw-device", - beeperEnv: "staging", bridgeId: "sh-openclaw-openclaw-device", + serverEnv: "prod", }); expect(createDefaultConfig({ dataDir: "/tmp/openclaw-bridge" }).hsToken).toBeUndefined(); }); @@ -92,13 +90,13 @@ describe("OpenClaw bridge config", () => { await writeFile(path, `${JSON.stringify({ channels: { beeper: { - beeperEnv: "staging", + asToken: "as-secret", dataDir: dir, + hsToken: "hs-secret", + serverEnv: "staging", bridge: { appserviceId: "sh-openclaw-device", - asToken: "as-secret", homeserver: "https://matrix.example", - hsToken: "hs-secret", matrixDeviceId: "DEVICE", matrixUserId: "@alice:example", }, @@ -109,12 +107,12 @@ describe("OpenClaw bridge config", () => { await expect(readConfig(path)).resolves.toMatchObject({ appserviceId: "sh-openclaw-device", asToken: "as-secret", - beeperEnv: "staging", dataDir: dir, homeserver: "https://matrix.example", hsToken: "hs-secret", matrixDeviceId: "DEVICE", matrixUserId: "@alice:example", + serverEnv: "staging", }); }); }); diff --git a/packages/openclaw/src/config.ts b/packages/openclaw/src/config.ts index c65520c..c98e71a 100644 --- a/packages/openclaw/src/config.ts +++ b/packages/openclaw/src/config.ts @@ -2,9 +2,10 @@ 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 { getBeeperAccountSettings, getBeeperChannelSettings, type OpenClawSetupConfig } from "./setup"; import { openClawBeeperBridgeId } from "./ids"; import type { OpenClawBridgeConfig } from "./types"; +import { resolveConfiguredSecretInputString } from "openclaw/plugin-sdk/secret-input-runtime"; export const DEFAULT_APPSERVICE_ID = "sh-openclaw"; export const DEFAULT_REGISTRATION_URL = "websocket"; @@ -29,7 +30,7 @@ export function createDefaultConfig(overrides: Partial = { bridgeId ?? DEFAULT_APPSERVICE_ID, dataDir, - beeperEnv: overrides.beeperEnv ?? envBeeperEnv(process.env.PICKLE_OPENCLAW_BEEPER_ENV) ?? "production", + serverEnv: overrides.serverEnv ?? "prod", }; const asToken = overrides.asToken; const homeserver = overrides.homeserver; @@ -55,10 +56,14 @@ function configInput(input: unknown): Partial { const record = recordValue(input); const beeper = recordValue(recordValue(record?.channels)?.beeper); if (beeper) { - const beeperEnv = envBeeperEnv(stringValue(beeper.beeperEnv)); + const serverEnv = normalizeServerEnv(stringValue(beeper.serverEnv)); const bridge = recordValue(beeper.bridge) as Partial | undefined; const config: Partial = { ...(bridge ?? {}) }; - if (beeperEnv) config.beeperEnv = beeperEnv; + const asToken = stringValue(beeper.asToken); + const hsToken = stringValue(beeper.hsToken); + if (serverEnv) config.serverEnv = serverEnv; + if (asToken) config.asToken = asToken; + if (hsToken) config.hsToken = hsToken; const dataDir = stringValue(beeper.dataDir); if (dataDir) config.dataDir = dataDir; return config; @@ -69,16 +74,44 @@ function configInput(input: unknown): Partial { export function createConfigFromOpenClawSetup( cfg: OpenClawSetupConfig, overrides: Partial = {}, + accountId?: string | null, ): OpenClawBridgeConfig { - const settings = getBeeperChannelSettings(cfg); + const settings = getBeeperAccountSettings(cfg, accountId); return createDefaultConfig({ ...settings.bridge, - ...(settings.beeperEnv ? { beeperEnv: settings.beeperEnv } : {}), + ...(typeof settings.asToken === "string" ? { asToken: settings.asToken } : {}), + ...(typeof settings.hsToken === "string" ? { hsToken: settings.hsToken } : {}), + ...(settings.serverEnv ? { serverEnv: settings.serverEnv } : {}), ...(settings.dataDir ? { dataDir: settings.dataDir } : {}), ...overrides, }); } +export async function createRuntimeConfigFromOpenClawSetup( + cfg: OpenClawSetupConfig, + overrides: Partial = {}, + accountId?: string | null, +): Promise { + const settings = getBeeperAccountSettings(cfg, accountId); + const accountPrefix = accountId && accountId !== "default" ? `channels.beeper.accounts.${accountId}` : "channels.beeper"; + const config = createConfigFromOpenClawSetup(cfg, overrides, accountId); + const asToken = await resolveConfiguredSecretInputString({ + config: cfg, + env: process.env, + value: settings.asToken, + path: `${accountPrefix}.asToken`, + }); + if (asToken.value) config.asToken = asToken.value; + const hsToken = await resolveConfiguredSecretInputString({ + config: cfg, + env: process.env, + value: settings.hsToken, + path: `${accountPrefix}.hsToken`, + }); + if (hsToken.value) config.hsToken = hsToken.value; + return config; +} + export async function writeConfig(config: OpenClawBridgeConfig, path = defaultConfigPath(config.dataDir)): Promise { await mkdir(dirname(path), { recursive: true }); await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); @@ -89,8 +122,8 @@ export function secretToken(bytes = 32): string { return randomBytes(bytes).toString("hex"); } -function envBeeperEnv(value: string | undefined): OpenClawBridgeConfig["beeperEnv"] | undefined { - if (value === "production" || value === "staging" || value === "dev" || value === "local") return value; +function normalizeServerEnv(value: string | undefined): OpenClawBridgeConfig["serverEnv"] | undefined { + if (value === "prod" || 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 7f182f6..14547af 100644 --- a/packages/openclaw/src/connector.test.ts +++ b/packages/openclaw/src/connector.test.ts @@ -20,6 +20,7 @@ describe("OpenClawBridgeConnector", () => { contactList: true, createDM: true, lookupUsername: true, + search: true, }); expect(connector.getLoginFlows()).toEqual([]); expect(() => connector.createLogin({} as never, { id: "@alice:example.com" }, "openclaw.gateway")).toThrow("Beeper channel runtime"); @@ -528,6 +529,39 @@ describe("OpenClawBridgeConnector", () => { }); }); + it("searches OpenClaw agent contacts for BridgeV2 user search", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-search-test.json"); + const runtime = runtimeWith({ + responses: { + "agents.list": { + agents: [ + { id: "codex", name: "Codex" }, + { id: "planner", name: "Planner" }, + ], + }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime, + }); + + await expect(api.searchUsers({} as BridgeRequestContext, { query: "plan" })).resolves.toEqual({ + results: [{ + ghost: expect.objectContaining({ + displayName: "Planner", + id: "planner", + identifiers: ["openclaw:agent:planner", "@sh-openclaw_agent_planner:localhost"], + isBot: true, + mxid: "@sh-openclaw_agent_planner:localhost", + }), + userId: "@sh-openclaw_agent_planner:localhost", + }], + }); + }); + it("lists current agent contacts", async () => { const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-contacts-test.json"); const runtime = runtimeWith({ diff --git a/packages/openclaw/src/connector.ts b/packages/openclaw/src/connector.ts index 271977b..0ad1b10 100644 --- a/packages/openclaw/src/connector.ts +++ b/packages/openclaw/src/connector.ts @@ -50,7 +50,10 @@ import { Reaction, ResolveIdentifierParams, ResolveIdentifierResponse, + type SearchUsersParams, + type SearchUsersResponse, UserLogin, + type UserSearchingNetworkAPI, } from "@beeper/pickle-bridge/types"; import { parseApprovalReactionContent, parseApprovalResponseContent } from "./approval"; import { @@ -165,6 +168,7 @@ export class OpenClawBridgeConnector implements BridgeConnector { + return { contacts: await this.#agentContactResponses(params.query, params.limit) }; + } + + async searchUsers(_ctx: BridgeRequestContext, params: SearchUsersParams): Promise { + return { results: await this.#agentContactResponses(params.query) }; + } + + async #agentContactResponses(query?: string, limit?: number): Promise { await this.#agent.syncAgentContacts(); - const query = params.query?.trim().toLowerCase(); - const contacts = this.#registry.data.agents + const normalizedQuery = query?.trim().toLowerCase(); + return this.#registry.data.agents .map((contact) => ({ response: contactResponse(contact), text: `${contact.agentId} ${contact.displayName}`.toLowerCase(), })) - .filter((contact) => !query || contact.text.includes(query)) - .slice(0, params.limit ?? 100) + .filter((contact) => !normalizedQuery || contact.text.includes(normalizedQuery)) + .slice(0, limit ?? 100) .map((contact) => contact.response); - return { contacts }; } async handleMatrixMessage(ctx: BridgeRequestContext, msg: MatrixMessage): Promise { diff --git a/packages/openclaw/src/function-entry.test.ts b/packages/openclaw/src/function-entry.test.ts new file mode 100644 index 0000000..808a7c8 --- /dev/null +++ b/packages/openclaw/src/function-entry.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from "vitest"; +import entry, { openClawBeeperFunctionPlugin } from "./function-entry"; + +describe("OpenClaw Beeper function plugin", () => { + it("registers the Beeper CLI tool and CLI command", () => { + const registerTool = vi.fn(); + const registerCli = vi.fn(); + + openClawBeeperFunctionPlugin.register({ + registerCli, + registerTool, + } as never); + + expect(entry.id).toBe("beeper-cli"); + expect(registerTool).toHaveBeenCalledWith(expect.any(Function), { optional: true }); + expect(registerCli).toHaveBeenCalledWith(expect.any(Function), expect.objectContaining({ + commands: ["beeper"], + descriptors: [expect.objectContaining({ name: "beeper", hasSubcommands: true })], + })); + + const factory = registerTool.mock.calls[0]?.[0]; + expect(factory({ sandboxed: true })).toBeNull(); + expect(factory({ sandboxed: false })).toMatchObject({ name: "beeper_cli" }); + }); +}); diff --git a/packages/openclaw/src/function-entry.ts b/packages/openclaw/src/function-entry.ts new file mode 100644 index 0000000..9210547 --- /dev/null +++ b/packages/openclaw/src/function-entry.ts @@ -0,0 +1,34 @@ +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +import { createBeeperCliTool, registerBeeperCli } from "./beeper-cli/tool"; + +export const openClawBeeperFunctionPlugin = definePluginEntry({ + id: "beeper-cli", + name: "Beeper CLI", + description: "Optional Beeper CLI function and command surface for OpenClaw agents.", + register(api) { + api.registerTool( + ((ctx: { sandboxed?: boolean }) => { + if (ctx.sandboxed) return null; + return createBeeperCliTool(); + }) as never, + { optional: true }, + ); + api.registerCli( + async ({ program }: { program: unknown }) => { + registerBeeperCli(program); + }, + { + commands: ["beeper"], + descriptors: [ + { + name: "beeper", + description: "Run the bundled Beeper CLI from OpenClaw.", + hasSubcommands: true, + }, + ], + }, + ); + }, +}); + +export default openClawBeeperFunctionPlugin; diff --git a/packages/openclaw/src/openclaw-extension.test.ts b/packages/openclaw/src/openclaw-extension.test.ts index cdb7d69..13cf7ed 100644 --- a/packages/openclaw/src/openclaw-extension.test.ts +++ b/packages/openclaw/src/openclaw-extension.test.ts @@ -1,7 +1,7 @@ import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { describe, expect, it, vi } from "vitest"; -import extension, { openClawBeeperPlugin } from "./openclaw-extension"; +import extension, { openClawBeeperPlugin } from "./plugin-entry"; describe("OpenClaw plugin package metadata", () => { it("exports a loadable OpenClaw plugin object", () => { @@ -17,7 +17,14 @@ describe("OpenClaw plugin package metadata", () => { }, }); expect(extension.id).toBe("beeper"); - expect(extension.channelPlugin).toBe(registered[0]); + expect(extension.kind).toBe("bundled-channel-entry"); + expect(extension.loadChannelPlugin()).toMatchObject({ id: "beeper" }); + expect(extension.loadChannelSecrets()).toMatchObject({ + secretTargetRegistryEntries: [ + expect.objectContaining({ pathPattern: "channels.beeper.asToken" }), + expect.objectContaining({ pathPattern: "channels.beeper.hsToken" }), + ], + }); expect(resolveBundledRuntimeChannelRegistration(extension)).toMatchObject({ id: "beeper", plugin: expect.objectContaining({ @@ -43,7 +50,7 @@ describe("OpenClaw plugin package metadata", () => { threading: expect.any(Object), }), ]); - }); + }, 15_000); it("honors SDK channel registration modes", () => { const registerChannel = vi.fn(); @@ -81,6 +88,7 @@ describe("OpenClaw plugin package metadata", () => { version?: string; }; const manifest = JSON.parse(await readFile(resolve("openclaw.plugin.json"), "utf8")) as { + activation?: { onStartup?: boolean }; id?: string; channels?: string[]; channelConfigs?: 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"]); - expect(packageJson.openclaw?.runtimeExtensions).toEqual(["./dist/plugin-entry.mjs"]); + expect(packageJson.files).toContain("skills"); + expect(packageJson.openclaw?.extensions).toEqual(["./src/plugin-entry.ts", "./src/function-entry.ts"]); + expect(packageJson.openclaw?.runtimeExtensions).toEqual(["./dist/plugin-entry.mjs", "./dist/function-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"); @@ -109,16 +118,20 @@ describe("OpenClaw plugin package metadata", () => { expect(packageJson.openclaw?.install?.npmSpec).toBe( `@beeper/openclaw@${packageJson.version}`, ); - expect(packageJson.openclaw?.compat?.pluginApi).toBe(">=2026.5.22"); - expect(packageJson.peerDependencies?.openclaw).toBe(">=2026.5.22"); + expect(packageJson.openclaw?.compat?.pluginApi).toBe(">=2026.6.2"); + expect(packageJson.peerDependencies?.openclaw).toBe(">=2026.6.2"); expect(packageJson.scripts?.prepublishOnly).toBe("node ../../scripts/guard-pnpm-publish.mjs"); expect(packageJson.files).toContain("dist"); - expect(manifest).toEqual(expect.objectContaining({ id: "beeper", channels: ["beeper"] })); - expect(manifest.channelEnvVars?.beeper).toEqual(["PICKLE_OPENCLAW_BEEPER_ENV"]); - expect(manifest.channelEnvVars?.beeper).not.toContain("PICKLE_OPENCLAW_ACCESS_TOKEN"); - expect(manifest.channelEnvVars?.beeper).not.toContain("PICKLE_OPENCLAW_GATEWAY_ACCESS_TOKEN"); - expect(manifest.channelEnvVars?.beeper).not.toContain("OPENCLAW_GATEWAY_TOKEN"); - expect(manifest.channelEnvVars?.beeper).not.toContain("PICKLE_OPENCLAW_DEVICE_ID"); + expect(manifest).toEqual(expect.objectContaining({ + commandAliases: [{ name: "beeper" }], + contracts: { tools: ["beeper_cli"] }, + id: "beeper", + channels: ["beeper"], + skills: ["./skills"], + toolMetadata: { beeper_cli: { optional: true } }, + })); + expect(manifest.activation?.onStartup).toBe(false); + expect(manifest.channelEnvVars).toBeUndefined(); expect(manifest.uiHints).toBeUndefined(); expect(manifest.configSchema).toEqual({ type: "object", @@ -135,23 +148,28 @@ describe("OpenClaw plugin package metadata", () => { schema: { properties: expect.not.objectContaining({ appserviceId: expect.anything(), - asToken: expect.anything(), backfillLimit: expect.anything(), bridgeId: expect.anything(), homeserver: expect.anything(), homeserverDomain: expect.anything(), - hsToken: expect.anything(), importSources: expect.anything(), matrixDeviceId: expect.anything(), matrixUserId: expect.anything(), }), }, - uiHints: expect.not.objectContaining({ - accessToken: expect.anything(), - asToken: expect.anything(), - hsToken: expect.anything(), + uiHints: expect.objectContaining({ + asToken: expect.objectContaining({ sensitive: true, tags: ["hidden"] }), + hsToken: expect.objectContaining({ sensitive: true, tags: ["hidden"] }), + serverEnv: expect.objectContaining({ + help: expect.stringContaining("Choose before Beeper login"), + }), }), }); + expect(manifest.channelConfigs?.beeper?.schema?.properties).toEqual(expect.objectContaining({ + asToken: expect.any(Object), + hsToken: expect.any(Object), + serverEnv: expect.objectContaining({ enum: ["prod", "staging", "dev", "local"] }), + })); }); it("keeps the public package manifest publishable and installable from built files", async () => { @@ -178,9 +196,9 @@ describe("OpenClaw plugin package metadata", () => { ])); 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?.runtimeExtensions).toEqual(["./dist/plugin-entry.mjs", "./dist/function-entry.mjs"]); expect(packageJson.openclaw?.runtimeSetupEntry).toBe("./dist/setup-entry.mjs"); - expect(dependencies).toEqual([]); + expect(dependencies).toEqual([["beeper-cli", "^0.6.2"]]); expect(devDependencies).toEqual(expect.arrayContaining([ ["@beeper/pickle-ag-ui", "workspace:^"], ["@beeper/pickle-bridge", "workspace:^"], @@ -196,17 +214,17 @@ function resolveBundledRuntimeChannelRegistration(moduleExport: unknown): { id?: if (!resolved || typeof resolved !== "object") return {}; const entry = resolved as { id?: unknown; - channelPlugin?: unknown; + loadChannelPlugin?: () => unknown; }; if ( typeof entry.id !== "string" || - !entry.channelPlugin + typeof entry.loadChannelPlugin !== "function" ) { return {}; } return { id: entry.id, - plugin: entry.channelPlugin, + plugin: entry.loadChannelPlugin(), }; } diff --git a/packages/openclaw/src/openclaw-extension.ts b/packages/openclaw/src/openclaw-extension.ts deleted file mode 100644 index bf7c63f..0000000 --- a/packages/openclaw/src/openclaw-extension.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { defineChannelPluginEntry } from "openclaw/plugin-sdk/channel-core"; -import type { OpenClawPluginApi, PluginRuntime } from "openclaw/plugin-sdk/channel-core"; -import { BeeperPluginConfigSchemaForSdk, beeperChannelPlugin, setBeeperOpenClawPluginRuntime } from "./setup"; - -type OpenClawBeeperPluginEntry = { - channelPlugin: typeof beeperChannelPlugin; - configSchema: unknown; - description: string; - id: string; - name: string; - register: (api: OpenClawPluginApi) => void; - setChannelRuntime?: (runtime: PluginRuntime) => void; -}; - -export const openClawBeeperPlugin: OpenClawBeeperPluginEntry = defineChannelPluginEntry({ - id: "beeper", - name: "Beeper", - description: "Bridge OpenClaw sessions and agents into Beeper.", - plugin: beeperChannelPlugin, - configSchema: BeeperPluginConfigSchemaForSdk, - setRuntime: setOpenClawRuntime, -}); - -export default openClawBeeperPlugin; - -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 aa8c53c..00be13b 100644 --- a/packages/openclaw/src/openclaw-runtime.test.ts +++ b/packages/openclaw/src/openclaw-runtime.test.ts @@ -283,6 +283,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { const dispatchReply = vi.fn(async (params: Record) => { const replyOptions = params.replyOptions as Record void | Promise>; await replyOptions.onReasoningStream?.({ text: "checking" }); + await replyOptions.onReasoningStream?.({ delta: " delta-only thinking" }); await replyOptions.onToolStart?.({ args: { path: "README.md" }, name: "read_file", phase: "start", toolCallId: "real-tool-id" }); await replyOptions.onCommandOutput?.({ name: "read_file", output: "ok", phase: "end", status: "completed", toolCallId: "real-tool-id" }); await replyOptions.onApprovalEvent?.({ @@ -355,7 +356,9 @@ describe("OpenClawPluginRuntimeAdapter", () => { })); expect((dispatchReply.mock.calls[0]?.[0] as { replyOptions?: Record } | undefined)?.replyOptions).toMatchObject({ disableBlockStreaming: false, + reasoningLevelOverride: "stream", sourceReplyDeliveryMode: "automatic", + verboseLevelOverride: "full", }); expect(received).toEqual(expect.arrayContaining([ expect.objectContaining({ event: "thinking.delta" }), @@ -383,6 +386,10 @@ describe("OpenClawPluginRuntimeAdapter", () => { "tool_result", "text", ])); + expect(streamParts.filter((part) => part.kind === "reasoning").map((part) => part.text)).toEqual([ + "checking", + " delta-only thinking", + ]); expect(aiRunStreams.appendEvent.mock.calls.map(([options]) => options.event.type)).toContain("CUSTOM"); const toolOutput = streamParts.find((part) => part.kind === "tool_result" && part.output === "ok"); expect(toolOutput).toMatchObject({ @@ -508,6 +515,35 @@ describe("OpenClawPluginRuntimeAdapter", () => { runId: replyOptions.runId, stream: "tool", }); + agentEventListener?.({ + data: { delta: "raw reasoning delta" }, + runId: replyOptions.runId, + stream: "reasoning", + }); + agentEventListener?.({ + data: { + method: "rawResponseItem/completed", + item: { + type: "reasoning", + summary: [{ type: "summary_text", text: "raw checked" }], + content: [{ type: "reasoning_text", text: "raw thought" }], + }, + }, + runId: replyOptions.runId, + stream: "raw", + }); + agentEventListener?.({ + data: { + item: { + type: "reasoning", + summary: ["typed checked"], + content: ["typed thought"], + }, + method: "item/completed", + }, + runId: replyOptions.runId, + stream: "item", + }); agentEventListener?.({ data: { call: { id: "nested-tool", name: "search" }, @@ -620,6 +656,11 @@ describe("OpenClawPluginRuntimeAdapter", () => { "lo", " world", ]); + expect(parts.filter((part) => part.kind === "reasoning").map((part) => part.text)).toEqual([ + "raw reasoning delta", + "raw checked\n\nraw thought", + "typed checked\n\ntyped thought", + ]); expect(parts).toEqual(expect.arrayContaining([ expect.objectContaining({ kind: "tool_result", toolCallId: "codex-tool", toolName: "tool" }), expect.objectContaining({ activityType: "tool.progress", content: expect.objectContaining({ label: "search", phase: "running", text: "Searching docs" }), kind: "activity" }), diff --git a/packages/openclaw/src/openclaw-runtime.ts b/packages/openclaw/src/openclaw-runtime.ts index 76ff70e..b17c5dd 100644 --- a/packages/openclaw/src/openclaw-runtime.ts +++ b/packages/openclaw/src/openclaw-runtime.ts @@ -960,6 +960,8 @@ async function runBeeperChannelTurnInPluginRuntime(params: { replyOptions: { runId: params.runId, disableBlockStreaming: false, + reasoningLevelOverride: "stream", + verboseLevelOverride: "full", sourceReplyDeliveryMode: "automatic", timeoutOverrideSeconds: Math.max(1, Math.ceil(params.timeoutMs / 1000)), suppressDefaultToolProgressMessages: true, @@ -1037,6 +1039,14 @@ function forwardAgentRuntimeStreamEvents(params: { normalizedStream: stream, }); if (!matched) return; + const exposedReasoningText = exposedCodexReasoningText(stream, data); + if (exposedReasoningText) { + params.enqueue(() => params.stream.reasoningPayload({ + text: exposedReasoningText, + isReasoningSnapshot: true, + })); + return; + } switch (stream) { case "assistant": params.enqueue(() => params.stream.textPayload(data, "partial")); @@ -1731,11 +1741,17 @@ function createBeeperReplyStreamEmitter(base: { if (sourceEvents.length > 0) await publishCustomEvents(sourceEvents); }; const reasoningPayload = async (payload: unknown) => { - const text = replyPayloadText(payload); + const text = reasoningPayloadText(payload); if (!text) return; - const explicitDelta = stringValue(recordValue(payload)?.delta); - const delta = explicitDelta ?? (text.startsWith(lastReasoningText) ? text.slice(lastReasoningText.length) : text); - lastReasoningText = text; + const payloadRecord = recordValue(payload); + const explicitDelta = reasoningDeltaText(payloadRecord); + const isSnapshot = booleanValue(payloadRecord?.isReasoningSnapshot) === true; + const delta = explicitDelta && !isSnapshot + ? explicitDelta + : (text.startsWith(lastReasoningText) ? text.slice(lastReasoningText.length) : text); + lastReasoningText = explicitDelta && !isSnapshot + ? `${lastReasoningText}${explicitDelta}` + : text; if (!delta) return; emit("thinking.delta", { delta, text }); await publishPart({ kind: "reasoning", text: delta }); @@ -2258,7 +2274,13 @@ 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); + const direct = + stringValue(record.text) ?? + stringValue(record.body) ?? + stringValue(record.content) ?? + stringValue(record.textDelta) ?? + stringValue(record.text_delta) ?? + stringValue(record.delta); if (direct) return direct; const parts = arrayValue(record.parts) ?? arrayValue(record.content); if (!parts) return undefined; @@ -2272,6 +2294,82 @@ function replyPayloadText(payload: unknown): string | undefined { return chunks.length > 0 ? chunks.join("") : undefined; } +function reasoningPayloadText(payload: unknown): string | undefined { + if (typeof payload === "string") return payload; + const record = recordValue(payload); + if (!record) return undefined; + return stringValue(record.text) + ?? stringValue(record.body) + ?? stringValue(record.reasoningText) + ?? stringValue(record.reasoning_text) + ?? stringValue(record.thinking) + ?? stringValue(record.reasoning) + ?? stringValue(record.summaryText) + ?? stringValue(record.summary_text) + ?? reasoningDeltaText(record) + ?? reasoningTextFromRecord(recordValue(record.item) ?? record, true); +} + +function reasoningDeltaText(record: Record | undefined): string | undefined { + if (!record) return undefined; + return stringValue(record.delta) + ?? stringValue(record.reasoningDelta) + ?? stringValue(record.reasoning_delta) + ?? stringValue(record.thinkingDelta) + ?? stringValue(record.thinking_delta) + ?? stringValue(record.summaryTextDelta) + ?? stringValue(record.summary_text_delta); +} + +function exposedCodexReasoningText(stream: string | undefined, data: Record): string | undefined { + const method = stringValue(data.method); + const item = recordValue(data.item) ?? recordValue(recordValue(data.params)?.item); + if ( + stream === "raw" || + stream === "item" || + method === "rawResponseItem/completed" || + method === "item/completed" + ) { + return reasoningTextFromRecord(item ?? data, false); + } + if (stream === "reasoning") { + return reasoningTextFromRecord(item ?? data, true); + } + return undefined; +} + +function reasoningTextFromRecord(record: Record | undefined, allowUntyped: boolean): string | undefined { + if (!record) return undefined; + if (stringValue(record.type) !== "reasoning" && !allowUntyped) { + return undefined; + } + if (!record.summary && !record.content) { + return undefined; + } + const chunks = [ + ...reasoningTextEntries(record.summary), + ...reasoningTextEntries(record.content), + ].filter((text) => text.trim().length > 0); + return chunks.length > 0 ? chunks.join("\n\n") : undefined; +} + +function reasoningTextEntries(value: unknown): string[] { + if (typeof value === "string") return [value]; + const entries = arrayValue(value); + if (!entries) return []; + return entries.flatMap((entry) => { + if (typeof entry === "string") return [entry]; + const record = recordValue(entry); + if (!record) return []; + const type = stringValue(record.type); + if (type && type !== "summary_text" && type !== "reasoning_text" && type !== "text") { + return []; + } + const text = stringValue(record.text) ?? stringValue(record.content); + return text ? [text] : []; + }); +} + function isVisibleTextPart(type: string | undefined): boolean { if (!type) return true; return type === "text" || type === "output_text" || type === "assistant_text" || type === "markdown"; diff --git a/packages/openclaw/src/plugin-entry.ts b/packages/openclaw/src/plugin-entry.ts index e2c5484..daff394 100644 --- a/packages/openclaw/src/plugin-entry.ts +++ b/packages/openclaw/src/plugin-entry.ts @@ -1,4 +1,13 @@ -import { openClawBeeperPlugin } from "./openclaw-extension"; +import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract"; + +export const openClawBeeperPlugin = defineBundledChannelEntry({ + id: "beeper", + name: "Beeper", + description: "Chat with your OpenClaw agents on Beeper.", + importMetaUrl: import.meta.url, + plugin: { specifier: "./setup.js", exportName: "beeperChannelPlugin" }, + runtime: { specifier: "./setup.js", exportName: "setBeeperOpenClawPluginRuntime" }, + secrets: { specifier: "./secret-contract.js", exportName: "channelSecrets" }, +}); export default openClawBeeperPlugin; -export { openClawBeeperPlugin }; diff --git a/packages/openclaw/src/secret-contract.ts b/packages/openclaw/src/secret-contract.ts new file mode 100644 index 0000000..96835aa --- /dev/null +++ b/packages/openclaw/src/secret-contract.ts @@ -0,0 +1,114 @@ +import { + collectSecretInputAssignment, + getChannelSurface, + type ResolverContext, + type SecretDefaults, + type SecretTargetRegistryEntry, +} from "openclaw/plugin-sdk/channel-secret-basic-runtime"; + +export const secretTargetRegistryEntries: SecretTargetRegistryEntry[] = [ + { + id: "channels.beeper.asToken", + targetType: "channels.beeper.asToken", + configFile: "openclaw.json", + pathPattern: "channels.beeper.asToken", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + { + id: "channels.beeper.hsToken", + targetType: "channels.beeper.hsToken", + configFile: "openclaw.json", + pathPattern: "channels.beeper.hsToken", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + { + id: "channels.beeper.accounts.*.asToken", + targetType: "channels.beeper.asToken", + configFile: "openclaw.json", + pathPattern: "channels.beeper.accounts.*.asToken", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + { + id: "channels.beeper.accounts.*.hsToken", + targetType: "channels.beeper.hsToken", + configFile: "openclaw.json", + pathPattern: "channels.beeper.accounts.*.hsToken", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, +]; + +export function collectRuntimeConfigAssignments(params: { + config: { channels?: Record }; + defaults?: SecretDefaults; + context: ResolverContext; +}): void { + const resolved = getChannelSurface(params.config, "beeper"); + if (!resolved) return; + const { channel, surface } = resolved; + for (const field of ["asToken", "hsToken"] as const) { + collectSecretInputAssignment({ + value: channel[field], + path: `channels.beeper.${field}`, + expected: "string", + defaults: params.defaults, + context: params.context, + active: surface.channelEnabled, + inactiveReason: "Beeper channel is disabled.", + apply: (value) => { + channel[field] = value; + }, + }); + } + const accounts = recordValue(channel.accounts); + if (!accounts) return; + for (const [accountId, value] of Object.entries(accounts)) { + const account = recordValue(value); + if (!account) continue; + const accountEnabled = surface.channelEnabled && account.enabled !== false; + for (const field of ["asToken", "hsToken"] as const) { + const assignment = { + value: account[field], + path: `channels.beeper.accounts.${accountId}.${field}`, + expected: "string" as const, + defaults: params.defaults, + context: params.context, + active: accountEnabled, + apply: (nextValue: unknown) => { + account[field] = nextValue; + }, + }; + if (!accountEnabled) { + Object.assign(assignment, { inactiveReason: `Beeper account "${accountId}" is disabled.` }); + } + collectSecretInputAssignment({ + ...assignment, + }); + } + } +} + +function recordValue(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + return value as Record; +} + +export const channelSecrets = { + secretTargetRegistryEntries, + collectRuntimeConfigAssignments, +}; diff --git a/packages/openclaw/src/setup-entry.ts b/packages/openclaw/src/setup-entry.ts index 0fbc3fe..8328995 100644 --- a/packages/openclaw/src/setup-entry.ts +++ b/packages/openclaw/src/setup-entry.ts @@ -1,6 +1,10 @@ -import { defineSetupPluginEntry } from "openclaw/plugin-sdk/channel-core"; -import { beeperChannelPlugin } from "./setup"; +import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract"; -export const openClawBeeperSetupEntry = defineSetupPluginEntry(beeperChannelPlugin); +export const openClawBeeperSetupEntry = defineBundledChannelSetupEntry({ + importMetaUrl: import.meta.url, + plugin: { specifier: "./setup.js", exportName: "beeperChannelPlugin" }, + runtime: { specifier: "./setup.js", exportName: "setBeeperOpenClawPluginRuntime" }, + secrets: { specifier: "./secret-contract.js", exportName: "channelSecrets" }, +}); export default openClawBeeperSetupEntry; diff --git a/packages/openclaw/src/setup.test.ts b/packages/openclaw/src/setup.test.ts index a5b6381..01e8fba 100644 --- a/packages/openclaw/src/setup.test.ts +++ b/packages/openclaw/src/setup.test.ts @@ -8,7 +8,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import extension from "./openclaw-extension"; +import extension from "./plugin-entry"; import setupEntry from "./setup-entry"; import { BeeperChannelRuntime, @@ -17,14 +17,19 @@ import { import { BeeperTurnStream } from "@beeper/pickle-bridge/beeper-stream"; import { applyBeeperChannelSettings, + applyBeeperAccountSettings, beeperChannelConfig, beeperChannelPlugin, beeperStatusAdapter, beeperSetupAdapter, beeperSetupWizard, defaultBeeperChannelSettings, + getBeeperAccountSettings, getBeeperChannelSettings, isBeeperChannelConfigured, + listBeeperAccountIds, + resolveBeeperAgentAccountId, + resolveDefaultBeeperAccountId, setBeeperOpenClawPluginRuntime, startBeeperGatewayAccount, validateBeeperSetupInput, @@ -64,11 +69,11 @@ describe("OpenClaw Beeper official channel contracts", () => { name: "non-login setup environment patch", cfg: {}, input: { - beeperEnv: "staging", + serverEnv: "staging", }, assertPatchedConfig: (cfg) => { expect(getBeeperChannelSettings(cfg)).toMatchObject({ - beeperEnv: "staging", + serverEnv: "staging", enabled: true, }); }, @@ -88,10 +93,10 @@ describe("OpenClaw Beeper official channel contracts", () => { name: "configured account", cfg: applyBeeperChannelSettings({}, { enabled: true, + asToken: "as", + hsToken: "hs", bridge: { - asToken: "as", homeserver: "https://matrix.example", - hsToken: "hs", matrixDeviceId: "DEV", matrixUserId: "@alice:example", }, @@ -133,7 +138,7 @@ describe("OpenClaw Beeper setup surface", () => { }); it("exposes a channel plugin through the setup entry shape OpenClaw loads", () => { - expect(extension.channelPlugin).toBe(beeperChannelPlugin); + expect(extension.loadChannelPlugin()).toMatchObject({ id: "beeper" }); expect(beeperChannelPlugin).toMatchObject({ id: "beeper", meta: { @@ -154,7 +159,13 @@ describe("OpenClaw Beeper setup surface", () => { startAccount: expect.any(Function), stopAccount: expect.any(Function), }, - uiHints: {}, + uiHints: expect.objectContaining({ + asToken: expect.objectContaining({ sensitive: true, tags: ["hidden"] }), + hsToken: expect.objectContaining({ sensitive: true, tags: ["hidden"] }), + serverEnv: expect.objectContaining({ + help: expect.stringContaining("Choose before Beeper login"), + }), + }), }); expect(beeperChannelPlugin.setup).toBe(beeperSetupAdapter); expect(beeperChannelPlugin.setupWizard).toBe(beeperSetupWizard); @@ -169,6 +180,7 @@ describe("OpenClaw Beeper setup surface", () => { label: "Beeper", selectionLabel: expect.any(String), })); + expect(beeperChannelPlugin.meta).not.toHaveProperty("quickstartAllowFrom"); expect(beeperChannelPlugin.capabilities.chatTypes).toEqual(["direct", "thread"]); expect(beeperChannelPlugin.message).toEqual(expect.objectContaining({ durableFinal: expect.objectContaining({ @@ -358,12 +370,12 @@ describe("OpenClaw Beeper setup surface", () => { inbound: { buildContext: vi.fn(), dispatchReply: vi.fn() }, }; const cfg = applyBeeperChannelSettings({}, { + asToken: "as", dataDir: "/tmp/openclaw-beeper", enabled: true, + hsToken: "hs", bridge: { - asToken: "as", homeserver: "https://matrix.example", - hsToken: "hs", matrixDeviceId: "DEV", matrixUserId: "@alice:example", }, @@ -401,12 +413,12 @@ describe("OpenClaw Beeper setup surface", () => { appserviceMocks.startOpenClawBeeperBridge.mockResolvedValueOnce({ stop }); const abort = new AbortController(); const cfg = applyBeeperChannelSettings({}, { + asToken: "as", dataDir: "/tmp/openclaw-beeper", enabled: true, + hsToken: "hs", bridge: { - asToken: "as", homeserver: "https://matrix.example", - hsToken: "hs", matrixDeviceId: "DEV", matrixUserId: "@alice:example", }, @@ -439,8 +451,10 @@ describe("OpenClaw Beeper setup surface", () => { it("exposes the lightweight OpenClaw setup-entry contract", () => { expect(setupEntry).toMatchObject({ - plugin: beeperChannelPlugin, + kind: "bundled-channel-setup-entry", + loadSetupPlugin: expect.any(Function), }); + expect(setupEntry.loadSetupPlugin()).toMatchObject({ id: "beeper" }); }); it("applies dashboard setup input into non-login channels.beeper settings", async () => { @@ -448,12 +462,12 @@ describe("OpenClaw Beeper setup surface", () => { accountId: "default", cfg: {}, input: { - beeperEnv: "staging", + serverEnv: "staging", }, }); expect(getBeeperChannelSettings(cfg)).toEqual({ - beeperEnv: "staging", enabled: true, + serverEnv: "staging", }); expect(isBeeperChannelConfigured(cfg)).toBe(false); expect(cfg.plugins?.entries?.beeper).toBeUndefined(); @@ -466,7 +480,7 @@ describe("OpenClaw Beeper setup surface", () => { input: { email: "alice@example.com", }, - })).toThrow("Beeper login is asynchronous"); + })).toThrow("Beeper login runs through"); expect(() => beeperSetupAdapter.applyAccountConfig({ accountId: "default", @@ -475,7 +489,7 @@ describe("OpenClaw Beeper setup surface", () => { password: "secret", username: "alice", }, - })).toThrow("Beeper login is asynchronous"); + })).toThrow("Beeper login runs through"); }); it("runs Beeper login and appservice registration from dashboard setup wizard input", async () => { @@ -485,7 +499,7 @@ describe("OpenClaw Beeper setup surface", () => { }; const promptValues: Record = { "Beeper email": "alice@example.com", - "Beeper login code": "123456", + "Beeper sign in code": "123456", }; const result = await beeperSetupWizard.configureInteractive({ cfg: {}, @@ -494,6 +508,7 @@ describe("OpenClaw Beeper setup surface", () => { multiselect: async () => ["dashboard", "tui"], progress: () => progress, select: async ({ message }) => { + if (message === "Beeper server environment") return "prod"; if (message === "Beeper login method") return "email"; if (message === "Beeper contact visibility") return "agents"; if (message === "Approval behavior") return "native"; @@ -510,7 +525,7 @@ describe("OpenClaw Beeper setup surface", () => { runtime: { setupBridge: async (options) => { expect(options.email).toBe("alice@example.com"); - expect(options.env).toBe("production"); + expect(options.env).toBe("prod"); expect(options).not.toHaveProperty("bridgeManagerToken"); expect(options).not.toHaveProperty("homeserverDomain"); expect(await options.getLoginCode?.()).toBe("123456"); @@ -547,14 +562,14 @@ describe("OpenClaw Beeper setup surface", () => { expect(result.accountId).toBe("default"); expect(getBeeperChannelSettings(cfg)).toMatchObject({ enabled: true, + asToken: "as", bridge: { - asToken: "as", bridgeId: "sh-openclaw-dev", homeserver: "https://matrix.example", - hsToken: "hs", matrixDeviceId: "DEV", matrixUserId: "@alice:example", }, + hsToken: "hs", }); }); @@ -563,8 +578,8 @@ describe("OpenClaw Beeper setup surface", () => { const cfg = await applyBeeperSetupConfig({ cfg: {}, input: { - beeperEnv: "dev", password: "secret", + serverEnv: "dev", username: "alice", }, runtime: { @@ -603,16 +618,16 @@ describe("OpenClaw Beeper setup surface", () => { }, }); expect(getBeeperChannelSettings(cfg)).toMatchObject({ - beeperEnv: "dev", + asToken: "as", bridge: { appserviceId: "sh-openclaw-dev", - asToken: "as", bridgeId: "sh-openclaw-dev", homeserver: "https://matrix.example", - hsToken: "hs", matrixDeviceId: "DEV", matrixUserId: "@alice:example", }, + hsToken: "hs", + serverEnv: "dev", }); }); @@ -621,11 +636,11 @@ describe("OpenClaw Beeper setup surface", () => { enabled: true, }))).toBe(false); const cfg = applyBeeperChannelSettings({}, { + asToken: "as", enabled: true, + hsToken: "hs", bridge: { - asToken: "as", homeserver: "https://matrix.example", - hsToken: "hs", matrixDeviceId: "DEV", matrixUserId: "@alice:example", }, @@ -638,9 +653,9 @@ describe("OpenClaw Beeper setup surface", () => { const cfg = await applyBeeperSetupConfig({ cfg: {}, input: { - beeperEnv: "dev", - code: "123456", email: "alice@example.com", + getLoginCode: () => "123456", + serverEnv: "dev", }, runtime: { setupBridge: async (options) => { @@ -680,15 +695,15 @@ describe("OpenClaw Beeper setup surface", () => { }); expect(getBeeperChannelSettings(cfg)).toMatchObject({ enabled: true, + asToken: "as", bridge: { appserviceId: "sh-openclaw-dev", - asToken: "as", bridgeId: "sh-openclaw-dev", homeserver: "https://matrix.example", - hsToken: "hs", matrixDeviceId: "DEV", matrixUserId: "@alice:example", }, + hsToken: "hs", }); }); @@ -713,6 +728,33 @@ describe("OpenClaw Beeper setup surface", () => { channel: "beeper", configured: false, quickstartScore: 20, + statusLines: expect.arrayContaining([ + "Server environment: prod", + ]), + }); + }); + + it("reports read-only Beeper login identity after setup", async () => { + const cfg = applyBeeperChannelSettings({}, { + asToken: "as", + bridge: { + homeserver: "https://matrix.example", + homeserverDomain: "matrix.example", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + }, + enabled: true, + hsToken: "hs", + serverEnv: "staging", + }); + + await expect(beeperSetupWizard.getStatus({ cfg })).resolves.toMatchObject({ + configured: true, + statusLines: expect.arrayContaining([ + "Server environment: staging (change requires logout and login)", + "Beeper user: @alice:example", + "Homeserver: matrix.example", + ]), }); }); @@ -736,7 +778,7 @@ describe("OpenClaw Beeper setup surface", () => { expect(beeperStatusAdapter.resolveAccountState({ configured: false, enabled: true })).toBe("not configured"); expect(beeperStatusAdapter.collectStatusIssues([snapshot])).toEqual([ expect.objectContaining({ - message: expect.stringContaining("not fully configured"), + message: expect.stringContaining("not connected"), severity: "warning", }), ]); @@ -746,10 +788,10 @@ describe("OpenClaw Beeper setup surface", () => { const cfg = createConfigFromOpenClawSetup({ channels: { beeper: { + hsToken: "hs", dataDir: "/tmp/beeper", bridge: { homeserver: "https://matrix.example", - hsToken: "hs", matrixDeviceId: "DEV", matrixUserId: "@alice:example", }, @@ -954,7 +996,7 @@ describe("OpenClaw Beeper setup surface", () => { expect(getBeeperChannelSettings({ channels: { beeper: { - beeperEnv: "staging", + serverEnv: "staging", }, }, plugins: { @@ -967,13 +1009,76 @@ describe("OpenClaw Beeper setup surface", () => { }, }, })).toEqual({ - beeperEnv: "staging", + serverEnv: "staging", }); expect(createConfigFromOpenClawSetup({ plugins: { entries: { beeper: { config: { enabled: true } } } } })).toMatchObject({ appserviceId: "sh-openclaw", }); }); + + it("uses official channels.beeper accounts for multiple Beeper accounts", () => { + const cfg = applyBeeperAccountSettings({ + channels: { + beeper: { + defaultAccount: "work", + dataDir: "/legacy/default", + serverEnv: "prod", + }, + }, + } as OpenClawSetupConfig, "work", { + dataDir: "/work", + enabled: true, + name: "Work Beeper", + serverEnv: "staging", + }); + + expect(listBeeperAccountIds(cfg)).toEqual(["default", "work"]); + expect(resolveDefaultBeeperAccountId(cfg)).toBe("work"); + expect(getBeeperAccountSettings(cfg, "default")).toMatchObject({ + dataDir: "/legacy/default", + serverEnv: "prod", + }); + expect(getBeeperAccountSettings(cfg, "work")).toMatchObject({ + dataDir: "/work", + name: "Work Beeper", + serverEnv: "staging", + }); + expect(beeperChannelConfig.resolveAccount(cfg, "work")).toMatchObject({ + accountId: "work", + settings: { name: "Work Beeper" }, + }); + }); + + it("allows non-exclusive agent account assignment with per-agent defaults", () => { + const cfg = { + channels: { + beeper: { + defaultAccount: "personal", + accounts: { + personal: { enabled: true }, + work: { enabled: true }, + alerts: { enabled: true }, + }, + agents: { + codex: { + accountIds: ["work", "alerts"], + defaultAccount: "alerts", + }, + helper: { + accountIds: ["work"], + }, + }, + }, + }, + } as OpenClawSetupConfig; + + expect(resolveBeeperAgentAccountId(cfg, "codex")).toBe("alerts"); + expect(resolveBeeperAgentAccountId(cfg, "codex", "work")).toBe("work"); + expect(resolveBeeperAgentAccountId(cfg, "helper")).toBe("work"); + expect(resolveBeeperAgentAccountId(cfg, "unassigned")).toBe("personal"); + expect(() => resolveBeeperAgentAccountId(cfg, "helper", "alerts")).toThrow(/not assigned/); + }); }); function createTestBeeperAIRuns() { diff --git a/packages/openclaw/src/setup.ts b/packages/openclaw/src/setup.ts index 4c1a352..2d94bc3 100644 --- a/packages/openclaw/src/setup.ts +++ b/packages/openclaw/src/setup.ts @@ -2,41 +2,62 @@ import { createChannelPluginBase, createChatChannelPlugin } from "openclaw/plugi import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk/channel-core"; import type { ChatType } from "openclaw/plugin-sdk/core"; import type { ChannelAccountSnapshot, ChannelCapabilities, ChannelGatewayContext, ChannelMessageActionName } from "openclaw/plugin-sdk/channel-contract"; +import type { SecretInput } from "openclaw/plugin-sdk/secret-input-runtime"; +import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input-runtime"; import type { BridgeLogger } from "@beeper/pickle-bridge/types"; -import { createConfigFromOpenClawSetup, defaultDataDir } from "./config"; +import { createConfigFromOpenClawSetup, createRuntimeConfigFromOpenClawSetup, defaultDataDir } from "./config"; import beeperChannelConfigSchema from "./beeper-channel-config.schema.json"; import type { setupOpenClawBeeperBridge, SetupOpenClawBeeperBridgeOptions } from "./beeper-setup"; import { createBeeperApprovalNotice } from "./approval"; import { requireBeeperChannelRuntimeForHost, setBeeperChannelRuntimeForHost } from "./beeper-channel-runtime"; import type { OpenClawHostRuntime } from "./openclaw-runtime"; import { OpenClawBridgeRegistry, defaultRegistryPath } from "./registry"; +import type { BeeperServerEnv } from "./types"; export type OpenClawSetupConfig = OpenClawConfig; export interface BeeperChannelSettings { - beeperEnv?: "production" | "staging" | "dev" | "local"; + accounts?: Record; + asToken?: SecretInput; bridge?: BeeperGeneratedBridgeSettings; dataDir?: string; + defaultAccount?: string; enabled?: boolean; + hsToken?: SecretInput; + agents?: Record; + serverEnv?: BeeperServerEnv; +} + +export interface BeeperAccountSettings { + asToken?: SecretInput; + bridge?: BeeperGeneratedBridgeSettings; + dataDir?: string; + enabled?: boolean; + hsToken?: SecretInput; + name?: string; + serverEnv?: BeeperServerEnv; +} + +export interface BeeperAgentAccountSettings { + accountIds?: string[]; + defaultAccount?: string; } export interface BeeperGeneratedBridgeSettings { appserviceId?: string; - asToken?: string; bridgeId?: string; homeserver?: string; homeserverDomain?: string; - hsToken?: string; matrixDeviceId?: string; matrixUserId?: string; } export interface BeeperSetupInput { - beeperEnv?: string; - code?: string; dataDir?: string; email?: string; + getLoginCode?: () => Promise | string; password?: string; + serverEnv?: string; username?: string; } @@ -105,7 +126,13 @@ function requireBeeperChannelRuntime() { export const BeeperChannelConfigSchema = beeperChannelConfigSchema; -export const BeeperChannelUiHints = {} as const; +export const BeeperChannelUiHints = { + asToken: { sensitive: true, tags: ["hidden"] as string[] }, + hsToken: { sensitive: true, tags: ["hidden"] as string[] }, + serverEnv: { + help: "Choose before Beeper login. To change it after connecting, log out and log back in.", + }, +} as const; export const beeperMessageAdapter = { id: BEEPER_CHANNEL_ID, @@ -284,11 +311,12 @@ export const beeperMessagingAdapter = { }) => { const target = normalizeBeeperMessagingTarget(params.resolvedTarget?.to ?? params.target); if (!target) return null; + const accountId = resolveBeeperAgentAccountId(params.cfg, params.agentId, params.accountId); const sessionKey = [ "agent", params.agentId, BEEPER_CHANNEL_ID, - params.accountId ?? "default", + accountId, "direct", target, ].join(":"); @@ -590,14 +618,15 @@ export const beeperAgentPromptAdapter = { } as const; export const beeperSetupAdapter = { - resolveAccountId: () => "default", - resolveBindingAccountId: () => "default", + resolveAccountId: ({ accountId }: { accountId?: string | null } = {}) => normalizeAccountId(accountId) ?? "default", + resolveBindingAccountId: ({ accountId, agentId }: { accountId?: string | null; agentId?: string | null; cfg?: OpenClawSetupConfig } = {}) => + normalizeAccountId(accountId) ?? normalizeAccountId(agentId) ?? "default", applyAccountName: ({ cfg }: { cfg: OpenClawSetupConfig }) => cfg, validateInput: ({ input }: { input: BeeperSetupInput }) => validateBeeperSetupInput(input), applyAccountConfig: ({ + accountId, cfg, input, - runtime, }: { cfg: OpenClawSetupConfig; accountId: string; @@ -605,25 +634,33 @@ export const beeperSetupAdapter = { runtime?: BeeperSetupRuntime; }): OpenClawSetupConfig => { if (input.email || input.username || input.password) { - throw new Error("Beeper login is asynchronous; use the Beeper setup wizard or pickle-openclaw login."); + throw new Error("Beeper login runs through OpenClaw channel setup."); } - return applyBeeperChannelSettings(cfg, normalizeBeeperSetupInput(input)); + return applyBeeperAccountSettings(cfg, accountId, normalizeBeeperSetupInput(input)); }, }; export const beeperSetupWizard = { channel: BEEPER_CHANNEL_ID, async getStatus(ctx: { cfg: OpenClawSetupConfig }) { - const settings = getBeeperChannelSettings(ctx.cfg); - const configured = isBeeperChannelConfigured(ctx.cfg); + const accountId = "default"; + const settings = getBeeperAccountSettings(ctx.cfg, accountId); + const configured = isBeeperChannelConfigured(ctx.cfg, accountId); + const serverEnv = settings.serverEnv ?? "prod"; + const bridge = settings.bridge; return { channel: BEEPER_CHANNEL_ID, configured, statusLines: [ - "Runtime: OpenClaw plugin", - "Registration transport: websocket", + `Connected: ${configured ? "yes" : "no"}`, + `Server environment: ${serverEnv}${configured ? " (change requires logout and login)" : ""}`, + ...(configured && bridge?.matrixUserId ? [`Beeper user: ${bridge.matrixUserId}`] : []), + ...(configured && bridge?.homeserverDomain ? [`Homeserver: ${bridge.homeserverDomain}`] : []), + "Requires an existing Beeper account.", + "Pickle creates OpenClaw agent DMs on Beeper.", + "It does not give OpenClaw access to your Beeper chats. For that, ask your agent to use the Beeper CLI.", ], - selectionHint: configured ? "Beeper bridge configured" : "Beeper login and bridge registration required", + selectionHint: configured ? "Connected to Beeper" : "Connect Beeper", quickstartScore: configured ? 100 : 20, }; }, @@ -640,8 +677,21 @@ export const beeperSetupWizard = { }) { const current = { ...defaultBeeperChannelSettings(), - ...getBeeperChannelSettings(ctx.cfg), + ...getBeeperAccountSettings(ctx.cfg, "default"), }; + if (isBeeperChannelConfigured(ctx.cfg, "default")) { + throw new Error("Beeper account \"default\" is already connected. Add another account or log out before changing its Beeper server environment or login account."); + } + const serverEnv = await ctx.prompter.select({ + message: "Beeper server environment", + initialValue: current.serverEnv ?? "prod", + options: [ + { value: "prod", label: "Production" }, + { value: "staging", label: "Staging" }, + { value: "dev", label: "Development" }, + { value: "local", label: "Local" }, + ], + }); const loginMethod = await ctx.prompter.select<"email" | "password">({ message: "Beeper login method", initialValue: "email", @@ -651,7 +701,6 @@ export const beeperSetupWizard = { ], }); let email: string | undefined; - let code: string | undefined; let username: string | undefined; let password: string | undefined; if (loginMethod === "email") { @@ -660,11 +709,6 @@ export const beeperSetupWizard = { placeholder: "name@example.com", validate: (value) => validateBeeperSetupInput({ email: value }) ?? undefined, }); - code = await ctx.prompter.text({ - message: "Beeper login code", - sensitive: true, - validate: (value) => (value.trim() ? undefined : "Beeper login code is required."), - }); } else if (loginMethod === "password") { username = await ctx.prompter.text({ message: "Beeper username", @@ -676,17 +720,25 @@ export const beeperSetupWizard = { validate: (value) => validateBeeperSetupInput({ username: username ?? "set", password: value }) ?? undefined, }); } - const beeperEnv = current.beeperEnv ?? "production"; const progress = ctx.prompter.progress?.("Setting up Beeper bridge"); - progress?.update("Logging in and registering appservice"); + progress?.update(loginMethod === "email" ? "Sending Beeper sign in code" : "Signing in to Beeper"); try { const input: BeeperSetupInput = { - ...(code ? { code } : {}), ...(email ? { email } : {}), + ...(email ? { + getLoginCode: async () => { + progress?.update("Waiting for Beeper sign in code"); + return ctx.prompter.text({ + message: "Beeper sign in code", + sensitive: true, + validate: (value) => (value.trim() ? undefined : "Beeper sign in code is required."), + }); + }, + } : {}), ...(password ? { password } : {}), ...(username ? { username } : {}), }; - if (beeperEnv !== undefined) input.beeperEnv = beeperEnv; + if (serverEnv !== undefined) input.serverEnv = serverEnv; const setupParams: Parameters[0] = { cfg: ctx.cfg, input, @@ -705,19 +757,19 @@ export const beeperSetupWizard = { }; export const beeperChannelConfig = { - listAccountIds: () => ["default"], - defaultAccountId: () => "default", - resolveAccount: (cfg: OpenClawSetupConfig) => ({ - accountId: "default", - configured: isBeeperChannelConfigured(cfg), - settings: getBeeperChannelSettings(cfg), + listAccountIds: (cfg: OpenClawSetupConfig) => listBeeperAccountIds(cfg), + defaultAccountId: (cfg: OpenClawSetupConfig) => resolveDefaultBeeperAccountId(cfg), + resolveAccount: (cfg: OpenClawSetupConfig, accountId?: string | null) => ({ + accountId: normalizeAccountId(accountId) ?? resolveDefaultBeeperAccountId(cfg), + configured: isBeeperChannelConfigured(cfg, normalizeAccountId(accountId) ?? resolveDefaultBeeperAccountId(cfg)), + settings: getBeeperAccountSettings(cfg, normalizeAccountId(accountId) ?? resolveDefaultBeeperAccountId(cfg)), }), - isEnabled: (account: { settings?: BeeperChannelSettings }) => account.settings?.enabled !== false, + isEnabled: (account: { settings?: BeeperAccountSettings }) => account.settings?.enabled !== false, isConfigured: (account: { configured?: boolean }) => account.configured === true, hasConfiguredState: ({ cfg }: { cfg: OpenClawSetupConfig }) => isBeeperChannelConfigured(cfg), - describeAccount: (account: { configured?: boolean; settings?: BeeperChannelSettings }) => ({ - accountId: "default", - name: "Beeper", + describeAccount: (account: { accountId?: string; configured?: boolean; settings?: BeeperAccountSettings }) => ({ + accountId: "accountId" in account && typeof account.accountId === "string" ? account.accountId : "default", + name: account.settings?.name ?? "Beeper", configured: account.configured === true, }), }; @@ -730,6 +782,7 @@ export const beeperStatusAdapter = { running: false, }, buildChannelSummary: ({ snapshot }: { snapshot: Record }) => ({ + connected: snapshot.running === true, configured: snapshot.configured === true, enabled: snapshot.enabled !== false, homeserver: recordValue(snapshot.extra)?.homeserver, @@ -742,8 +795,9 @@ export const beeperStatusAdapter = { configured: account.configured === true, enabled: settings.enabled !== false, extra: { - beeperEnv: settings.beeperEnv ?? "production", + connected: runtime?.running === true, homeserver: settings.bridge?.homeserver, + serverEnv: settings.serverEnv ?? "prod", }, name: "Beeper", running: runtime?.running === true, @@ -760,7 +814,7 @@ export const beeperStatusAdapter = { 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.", + message: "Beeper is not connected; run Beeper setup with an existing Beeper account.", severity: "warning" as const, })), }; @@ -768,12 +822,14 @@ export const beeperStatusAdapter = { const startedBridges = new Map(); export async function applyBeeperSetupConfig(params: { + accountId?: string; cfg: OpenClawSetupConfig; input: BeeperSetupInput; runtime?: BeeperSetupRuntime; }): Promise { const baseSettings = normalizeBeeperSetupInput(params.input); - if (!params.input.email && !params.input.username && !params.input.password) return applyBeeperChannelSettings(params.cfg, baseSettings); + const accountId = normalizeAccountId(params.accountId) ?? "default"; + if (!params.input.email && !params.input.username && !params.input.password) return applyBeeperAccountSettings(params.cfg, accountId, baseSettings); const setupBridge = params.runtime?.setupBridge ?? (await loadBeeperSetupBridge()); const bridgeOptions = setupOptionsFromInput(params.input); const result = await setupBridge(bridgeOptions); @@ -783,15 +839,15 @@ export async function applyBeeperSetupConfig(params: { }; const bridgeSettings: BeeperGeneratedBridgeSettings = {}; if (result.config.appserviceId) bridgeSettings.appserviceId = result.config.appserviceId; - if (result.config.asToken) bridgeSettings.asToken = result.config.asToken; + if (result.config.asToken) setupSettings.asToken = result.config.asToken; if (result.config.bridgeId) bridgeSettings.bridgeId = result.config.bridgeId; if (result.config.homeserver) bridgeSettings.homeserver = result.config.homeserver; if (result.config.homeserverDomain) bridgeSettings.homeserverDomain = result.config.homeserverDomain; - if (result.config.hsToken) bridgeSettings.hsToken = result.config.hsToken; + if (result.config.hsToken) setupSettings.hsToken = result.config.hsToken; if (result.config.matrixDeviceId) bridgeSettings.matrixDeviceId = result.config.matrixDeviceId; if (result.config.matrixUserId) bridgeSettings.matrixUserId = result.config.matrixUserId; setupSettings.bridge = bridgeSettings; - return applyBeeperChannelSettings(params.cfg, setupSettings); + return applyBeeperAccountSettings(params.cfg, accountId, setupSettings); } async function loadBeeperSetupBridge(): Promise { @@ -834,12 +890,11 @@ export const beeperChannelPlugin: ChannelPlugin & { uiHin meta: { id: BEEPER_CHANNEL_ID, label: "Beeper", - selectionLabel: "Beeper bridge", + selectionLabel: "Beeper agent DMs", docsPath: "/channels/beeper", docsLabel: "beeper", - blurb: "bridges OpenClaw sessions and agents into Beeper.", + blurb: "lets you chat with your OpenClaw agents on Beeper.", order: 90, - quickstartAllowFrom: true, }, capabilities: BeeperChannelCapabilities, reload: { configPrefixes: ["channels.beeper"] }, @@ -1120,16 +1175,16 @@ export async function startBeeperGatewayAccount(ctx: BeeperGatewayContext | Chan async function startBeeperGatewayAccountOnce(ctx: BeeperGatewayContext | ChannelGatewayContext<{ accountId: string; configured: boolean; settings: BeeperChannelSettings }>, key: string): Promise { try { ctx.log?.info?.("Beeper bridge startup beginning."); - const settings = getBeeperChannelSettings(ctx.cfg); + const settings = getBeeperAccountSettings(ctx.cfg, ctx.accountId); if (settings.enabled === false) { ctx.log?.info?.("Beeper bridge is disabled; skipping startup."); return; } - if (!isBeeperChannelConfigured(ctx.cfg)) { - throw new Error("Beeper bridge is not fully configured; run Beeper channel setup first."); + if (!isBeeperChannelConfigured(ctx.cfg, ctx.accountId)) { + throw new Error(`Beeper account "${ctx.accountId}" is not fully configured; run Beeper channel setup first.`); } const { startOpenClawBeeperBridge } = await import("./appservice"); - const config = createConfigFromOpenClawSetup(ctx.cfg); + const config = await createRuntimeConfigFromOpenClawSetup(ctx.cfg, {}, ctx.accountId); const hostRuntime = resolveBeeperHostRuntime(ctx); const statusSink = (patch: { lastEventAt?: number; @@ -1268,14 +1323,67 @@ export function getBeeperChannelSettings(cfg: OpenClawSetupConfig): BeeperChanne return (channelSettings as BeeperChannelSettings | undefined) ?? {}; } -export function isBeeperChannelConfigured(cfg: OpenClawSetupConfig): boolean { +export function getBeeperAccountSettings(cfg: OpenClawSetupConfig, accountId?: string | null): BeeperAccountSettings { + const channelSettings = getBeeperChannelSettings(cfg); + const normalized = normalizeAccountId(accountId) ?? resolveDefaultBeeperAccountId(cfg); + const accountSettings = recordValue(channelSettings.accounts?.[normalized]) as BeeperAccountSettings | undefined; + const legacyDefaults: BeeperAccountSettings = { + ...(channelSettings.asToken !== undefined ? { asToken: channelSettings.asToken } : {}), + ...(channelSettings.bridge !== undefined ? { bridge: channelSettings.bridge } : {}), + ...(channelSettings.dataDir !== undefined ? { dataDir: channelSettings.dataDir } : {}), + ...(channelSettings.enabled !== undefined ? { enabled: channelSettings.enabled } : {}), + ...(channelSettings.hsToken !== undefined ? { hsToken: channelSettings.hsToken } : {}), + ...(channelSettings.serverEnv !== undefined ? { serverEnv: channelSettings.serverEnv } : {}), + }; + if (normalized === "default") return { ...legacyDefaults, ...(accountSettings ?? {}) }; + return { ...(accountSettings ?? {}) }; +} + +export function listBeeperAccountIds(cfg: OpenClawSetupConfig): string[] { const settings = getBeeperChannelSettings(cfg); + const ids = new Set(); + if (hasLegacyBeeperAccountSettings(settings)) ids.add("default"); + for (const id of Object.keys(settings.accounts ?? {})) { + const normalized = normalizeAccountId(id); + if (normalized) ids.add(normalized); + } + if (ids.size === 0) ids.add("default"); + return [...ids]; +} + +export function resolveDefaultBeeperAccountId(cfg: OpenClawSetupConfig): string { + const settings = getBeeperChannelSettings(cfg); + const configured = normalizeAccountId(settings.defaultAccount); + if (configured && listBeeperAccountIds(cfg).includes(configured)) return configured; + return listBeeperAccountIds(cfg)[0] ?? "default"; +} + +export function resolveBeeperAgentAccountId(cfg: OpenClawSetupConfig, agentId: string, requestedAccountId?: string | null): string { + const requested = normalizeAccountId(requestedAccountId); + const accountIds = listBeeperAccountIds(cfg); + const agentSettings = getBeeperChannelSettings(cfg).agents?.[agentId]; + const allowed = (agentSettings?.accountIds ?? []).map(normalizeAccountId).filter((id): id is string => Boolean(id)); + if (requested) { + if (allowed.length > 0 && !allowed.includes(requested)) { + throw new Error(`Beeper account "${requested}" is not assigned to agent "${agentId}".`); + } + return requested; + } + const preferred = normalizeAccountId(agentSettings?.defaultAccount); + if (preferred && (allowed.length === 0 || allowed.includes(preferred)) && accountIds.includes(preferred)) return preferred; + const firstAllowed = allowed.find((id) => accountIds.includes(id)); + if (firstAllowed) return firstAllowed; + return resolveDefaultBeeperAccountId(cfg); +} + +export function isBeeperChannelConfigured(cfg: OpenClawSetupConfig, accountId?: string | null): boolean { + const settings = getBeeperAccountSettings(cfg, accountId); const bridge = settings.bridge; return Boolean( - settings.enabled && - bridge?.asToken && - bridge.homeserver && - bridge.hsToken && + settings.enabled !== false && + hasConfiguredSecretInput(settings.asToken, cfg.secrets?.defaults) && + bridge?.homeserver && + hasConfiguredSecretInput(settings.hsToken, cfg.secrets?.defaults) && bridge.matrixDeviceId && bridge.matrixUserId ); @@ -1299,11 +1407,33 @@ export function applyBeeperChannelSettings( }; } +export function applyBeeperAccountSettings( + cfg: OpenClawSetupConfig, + accountId: string, + patch: Partial, +): OpenClawSetupConfig { + const normalized = normalizeAccountId(accountId) ?? "default"; + const current = getBeeperChannelSettings(cfg); + if (normalized === "default" && !current.accounts) { + return applyBeeperChannelSettings(cfg, patch); + } + return applyBeeperChannelSettings(cfg, { + accounts: { + ...(current.accounts ?? {}), + [normalized]: { + ...(current.accounts?.[normalized] ?? {}), + ...patch, + }, + }, + defaultAccount: current.defaultAccount ?? normalized, + }); +} + export function defaultBeeperChannelSettings(): BeeperChannelSettings { return { - beeperEnv: "production", dataDir: defaultDataDir(), enabled: true, + serverEnv: "prod", }; } @@ -1314,14 +1444,14 @@ export function validateBeeperSetupInput(input: BeeperSetupInput): string | null if (input.username !== undefined && !input.username.trim()) return "Beeper username is required."; if (input.password !== undefined && !input.password.trim()) return "Beeper password is required."; if ((input.username && !input.password) || (input.password && !input.username)) return "Beeper username/password login requires both username and password."; - if (input.beeperEnv !== undefined && normalizeBeeperEnv(input.beeperEnv) === undefined) return "Beeper environment must be production, staging, dev, or local."; + if (input.serverEnv !== undefined && normalizeServerEnv(input.serverEnv) === undefined) return "Beeper server environment must be prod, staging, dev, or local."; return null; } export function normalizeBeeperSetupInput(input: BeeperSetupInput): Partial { const settings: Partial = { enabled: true }; - const beeperEnv = normalizeBeeperEnv(input.beeperEnv); - if (beeperEnv) settings.beeperEnv = beeperEnv; + const serverEnv = normalizeServerEnv(input.serverEnv); + if (serverEnv) settings.serverEnv = serverEnv; if (input.dataDir) settings.dataDir = input.dataDir; return settings; } @@ -1334,22 +1464,24 @@ export function setupOptionsFromInput(input: BeeperSetupInput): SetupOpenClawBee if (input.email) options.email = input.email; if (input.username) options.username = input.username; if (input.password) options.password = input.password; - const env = normalizeBeeperEnv(input.beeperEnv); + const env = normalizeServerEnv(input.serverEnv); if (env) options.env = env; - if (input.code) options.getLoginCode = () => input.code!; + if (input.getLoginCode) options.getLoginCode = input.getLoginCode; return options; } -function normalizeBeeperEnv(value: string | undefined): BeeperChannelSettings["beeperEnv"] | undefined { - if (value === "production" || value === "staging" || value === "dev" || value === "local") return value; +function normalizeServerEnv(value: string | undefined): BeeperChannelSettings["serverEnv"] | undefined { + if (value === "prod" || 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 normalizeAccountId(value: string | null | undefined): string | undefined { + const trimmed = value?.trim().toLowerCase(); + return trimmed ? trimmed.replace(/[^a-z0-9_.-]+/gu, "-").replace(/^-+|-+$/gu, "") || undefined : undefined; +} + +function hasLegacyBeeperAccountSettings(settings: BeeperChannelSettings): boolean { + return Boolean(settings.asToken || settings.bridge || settings.dataDir || settings.hsToken || settings.serverEnv || settings.enabled !== undefined); } function beeperSetupRuntime(value: unknown): BeeperSetupRuntime | undefined { diff --git a/packages/openclaw/src/types.ts b/packages/openclaw/src/types.ts index 1005de3..351ca7e 100644 --- a/packages/openclaw/src/types.ts +++ b/packages/openclaw/src/types.ts @@ -1,3 +1,7 @@ +import type { SecretInput } from "openclaw/plugin-sdk/secret-input-runtime"; + +export type BeeperServerEnv = "prod" | "staging" | "dev" | "local"; + export interface OpenClawAgentContact { agentId: string; displayName: string; @@ -35,7 +39,6 @@ export interface OpenClawSessionBinding { export interface OpenClawBridgeConfig { asToken?: string; appserviceId: string; - beeperEnv?: "production" | "staging" | "dev" | "local"; bridgeId?: string; dataDir: string; homeserver?: string; @@ -43,8 +46,11 @@ export interface OpenClawBridgeConfig { homeserverDomain?: string; matrixDeviceId?: string; matrixUserId?: string; + serverEnv?: BeeperServerEnv; } +export type OpenClawBridgeSecretInput = SecretInput; + export interface OpenClawBridgeRegistryData { agents: OpenClawAgentContact[]; bindings: OpenClawSessionBinding[]; diff --git a/packages/openclaw/tsdown.config.ts b/packages/openclaw/tsdown.config.ts index c8e8ec6..7ae5c3a 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/beeper-channel-runtime.ts", "src/beeper-setup.ts", "src/bridge-agent.ts", "src/cli.ts", "src/config.ts", "src/connector.ts", "src/matrix-parser.ts", "src/openclaw-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/beeper-channel-runtime.ts", "src/beeper-cli/tool.ts", "src/beeper-setup.ts", "src/bridge-agent.ts", "src/cli.ts", "src/config.ts", "src/connector.ts", "src/function-entry.ts", "src/matrix-parser.ts", "src/openclaw-runtime.ts", "src/plugin-entry.ts", "src/protocol-coverage.ts", "src/registry.ts", "src/registration.ts", "src/rooms.ts", "src/secret-contract.ts", "src/serial.ts", "src/setup.ts", "src/setup-entry.ts", "src/types.ts"], format: ["esm"], }); diff --git a/packages/pickle/native/go.mod b/packages/pickle/native/go.mod index 71ffabc..9089c3d 100644 --- a/packages/pickle/native/go.mod +++ b/packages/pickle/native/go.mod @@ -3,18 +3,21 @@ module github.com/beeper/pickle/packages/pickle/native go 1.25.0 require ( - github.com/beeper/ai-bridge v0.0.0-20260602005818-ab83be648105 + github.com/beeper/ai-bridge v0.0.0-20260602153000-75057637d3ab github.com/gzuidhof/tygo v0.2.21 maunium.net/go/mautrix v0.27.1-0.20260513120123-5fba7e3afae4 ) require ( filippo.io/edwards25519 v1.2.0 // indirect + github.com/coder/websocket v1.8.14 // indirect + github.com/coreos/go-systemd/v22 v22.7.0 // indirect github.com/fatih/structtag v1.2.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v1.14.44 // indirect github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect + github.com/rs/xid v1.6.0 // indirect github.com/rs/zerolog v1.35.1 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect @@ -22,6 +25,7 @@ require ( github.com/tidwall/sjson v1.2.5 // indirect github.com/yuin/goldmark v1.8.2 // indirect go.mau.fi/util v0.9.9 // indirect + go.mau.fi/zeroconfig v0.2.0 // indirect golang.org/x/crypto v0.51.0 // indirect golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect golang.org/x/mod v0.36.0 // indirect @@ -30,4 +34,6 @@ require ( golang.org/x/sys v0.44.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/tools v0.45.0 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/packages/pickle/native/go.sum b/packages/pickle/native/go.sum index f7913e8..447d8ff 100644 --- a/packages/pickle/native/go.sum +++ b/packages/pickle/native/go.sum @@ -2,10 +2,12 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= -github.com/beeper/ai-bridge v0.0.0-20260601222736-fee8bd8892f9 h1:axfGFpklgo7yCkuXA9rpgfcKK4eR7wWfhimPMer0zE8= -github.com/beeper/ai-bridge v0.0.0-20260601222736-fee8bd8892f9/go.mod h1:+icZV4D9wnp0NTP8bsfS/WXrf/8plzmnp/3bhQEnL3E= -github.com/beeper/ai-bridge v0.0.0-20260602005818-ab83be648105 h1:KMAJrhbTLcF6JmRzpJlKz00lCeHaVEfNTg/BzVhdDaI= -github.com/beeper/ai-bridge v0.0.0-20260602005818-ab83be648105/go.mod h1:+icZV4D9wnp0NTP8bsfS/WXrf/8plzmnp/3bhQEnL3E= +github.com/beeper/ai-bridge v0.0.0-20260602153000-75057637d3ab h1:Vs4NbdfxAkSexNR0fwDCMrCK/XIlmHGuV1Bj+p40olo= +github.com/beeper/ai-bridge v0.0.0-20260602153000-75057637d3ab/go.mod h1:+icZV4D9wnp0NTP8bsfS/WXrf/8plzmnp/3bhQEnL3E= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= +github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= @@ -24,6 +26,8 @@ github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VR 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/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= @@ -42,6 +46,8 @@ github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.mau.fi/util v0.9.9 h1:ujDeXCo07HBor5oQLyO1tHklupmqVmPgasc53d7q/NE= go.mau.fi/util v0.9.9/go.mod h1:pqt4Vcrt+5gcH/CgrHZg11qSx+b34o6mknGzOEA6waY= +go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= +go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw= @@ -59,6 +65,10 @@ golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= maunium.net/go/mautrix v0.27.1-0.20260513120123-5fba7e3afae4 h1:zNC9eVAhw8FhKpM3AxNAh/iy75UEYX91uJUvqqAYlvo= diff --git a/packages/pickle/native/internal/core/messages.go b/packages/pickle/native/internal/core/messages.go index 70f3d01..55284ed 100644 --- a/packages/pickle/native/internal/core/messages.go +++ b/packages/pickle/native/internal/core/messages.go @@ -12,6 +12,7 @@ import ( agui "github.com/beeper/ai-bridge/pkg/ag-ui" aistream "github.com/beeper/ai-bridge/pkg/ai-stream" + aibridgev2 "github.com/beeper/ai-bridge/pkg/ai-stream/bridgev2" "maunium.net/go/mautrix" mautrixbeeperstream "maunium.net/go/mautrix/beeperstream" "maunium.net/go/mautrix/event" @@ -330,9 +331,8 @@ func (c *Core) finalizeBeeperStreamMessage(ctx context.Context, req MatrixFinali if content["msgtype"] == nil { content["msgtype"] = "m.text" } - content["com.beeper.stream"] = nil - topLevel := copyOutboundEvent(req.TopLevelContent) - topLevel["com.beeper.stream"] = nil + content = OutboundEvent(aibridgev2.FinalEditExtra(content)) + topLevel := mergeOutboundEvent(req.TopLevelContent, OutboundEvent(aibridgev2.FinalEditTopLevelExtra())) replacement, err := c.sendBeeperStreamReplacementEvent(ctx, req.RoomID, req.EventID, req.UserID, content, topLevel) if err != nil { return MatrixFinalizeBeeperStreamMessageResult{}, err @@ -871,6 +871,14 @@ func (c *Core) handleFetchThreadMessages(ctx context.Context, cli *mautrix.Clien return json.Marshal(OutboundEvent{"messages": messages, "nextCursor": nextCursor}) } +func mergeOutboundEvent(base, extra OutboundEvent) OutboundEvent { + out := copyOutboundEvent(base) + for key, value := range extra { + out[key] = value + } + return out +} + func (c *Core) applyLatestReplacement(ctx context.Context, cli *mautrix.Client, roomID id.RoomID, msg *MatrixMessageEvent) *MatrixMessageEvent { if msg == nil || boolValue(msg.IsEdited) { return msg diff --git a/packages/pickle/native/internal/core/messages_test.go b/packages/pickle/native/internal/core/messages_test.go index 6e5ed79..79dd850 100644 --- a/packages/pickle/native/internal/core/messages_test.go +++ b/packages/pickle/native/internal/core/messages_test.go @@ -3,8 +3,12 @@ package core import ( "context" "encoding/json" + "io" + "net/http" + "net/http/httptest" "testing" + "maunium.net/go/mautrix" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" ) @@ -93,6 +97,69 @@ func TestProcessEventSkipsDuplicateTimelineEvents(t *testing.T) { } } +func TestFinalizeBeeperStreamMessageUsesAIBridgeFinalEditEnvelope(t *testing.T) { + requests := make(chan map[string]any, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var content map[string]any + if err := json.Unmarshal(body, &content); err != nil { + t.Errorf("failed to decode request body: %v", err) + } + requests <- content + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"event_id":"$edit"}`)) + })) + t.Cleanup(server.Close) + + core := New(nil) + cli, err := mautrix.NewClient(server.URL, id.UserID("@bot:example"), "token") + if err != nil { + t.Fatal(err) + } + core.client = cli + + _, err = core.finalizeBeeperStreamMessage(context.Background(), MatrixFinalizeBeeperStreamMessageOptions{ + Content: OutboundEvent{ + "body": "done", + "com.beeper.ai": map[string]any{"kind": "final"}, + "msgtype": "m.text", + }, + EventID: "$stream", + RoomID: "!room:example", + TopLevelContent: OutboundEvent{ + "custom": "value", + }, + }) + if err != nil { + t.Fatal(err) + } + + replacement := <-requests + if replacement["body"] != "done" || replacement["msgtype"] != "m.text" { + t.Fatalf("unexpected replacement fallback content: %#v", replacement) + } + if replacement["com.beeper.dont_render_edited"] != true || replacement["custom"] != "value" { + t.Fatalf("replacement missing top-level final edit markers: %#v", replacement) + } + if stream, ok := replacement["com.beeper.stream"]; !ok || stream != nil { + t.Fatalf("replacement must clear top-level stream descriptor: %#v", replacement) + } + newContent, ok := replacement["m.new_content"].(map[string]any) + if !ok { + t.Fatalf("replacement missing m.new_content: %#v", replacement) + } + if stream, ok := newContent["com.beeper.stream"]; !ok || stream != nil { + t.Fatalf("replacement must clear stream descriptor in m.new_content: %#v", newContent) + } + if ai, ok := newContent["com.beeper.ai"].(map[string]any); !ok || ai["kind"] != "final" { + t.Fatalf("replacement lost final AI payload: %#v", newContent) + } + relatesTo, ok := replacement["m.relates_to"].(map[string]any) + if !ok || relatesTo["rel_type"] != "m.replace" || relatesTo["event_id"] != "$stream" { + t.Fatalf("replacement has unexpected relation: %#v", replacement["m.relates_to"]) + } +} + func TestProcessEncryptedEventEmitsDecryptionError(t *testing.T) { ctx := context.Background() var emitted []OutboundEvent diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3174ade..d1df24d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -218,6 +218,10 @@ importers: 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: + beeper-cli: + specifier: ^0.6.2 + version: 0.6.2 devDependencies: '@beeper/pickle-ag-ui': specifier: workspace:^ @@ -235,8 +239,8 @@ importers: specifier: ^4.0.18 version: 4.1.5(vitest@4.1.5) openclaw: - specifier: 2026.5.22 - version: 2026.5.22 + specifier: 2026.5.28 + version: 2026.5.28 tsdown: specifier: ^0.21.10 version: 0.21.10(typescript@5.9.3) @@ -413,8 +417,8 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 - '@anthropic-ai/sdk@0.91.1': - resolution: {integrity: sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==} + '@anthropic-ai/sdk@0.98.0': + resolution: {integrity: sha512-N7aXtCvC5g6T1Y4V29lJjceu/zTkVkIZF0jdBvagr0TRFHuKeImffalGWEfqZKrvjH+IQbzJWw6TmSmUzrlMgg==} hasBin: true peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -422,107 +426,6 @@ packages: 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} @@ -688,22 +591,8 @@ 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==} + '@earendil-works/pi-tui@0.76.0': + resolution: {integrity: sha512-TWQEWqc38gVRYr/VTrlfePQPpJk938gNNLL1xuv0M+9cTkVr880/Q3baZQrxKoLrmN/MmVx7TyR8knYZGxTqqg==} engines: {node: '>=22.19.0'} '@emnapi/core@1.10.0': @@ -1027,17 +916,8 @@ 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==} + '@google/genai@2.6.0': + resolution: {integrity: sha512-HjoW3mPuEn7pnuKABJl9VbDoWDSF4nbwYKYvYYor7YjPeDxrrBxHzu2d1Prcd+BAuC4w+85UP6y7ZdcrQAoO7g==} engines: {node: '>=20.0.0'} peerDependencies: '@modelcontextprotocol/sdk': ^1.25.2 @@ -1060,8 +940,8 @@ packages: '@grammyjs/types@3.27.3': resolution: {integrity: sha512-yUKMLliGsGbnxu96YUJ7km7B0zy4PzeH/Jvti5705R/LeKDMqkDV4DckMSt+OrliWQpTwQljHE0QLol5zgxBkg==} - '@homebridge/ciao@1.3.8': - resolution: {integrity: sha512-lNhpCsZVbdbjz2trFjQdzQ3cUIMZQMIMksi7wd3ntTIYgdaGLqT1Ms97DfVIJYHzRuduf56ISvgU8RRLTpK/ng==} + '@homebridge/ciao@1.3.9': + resolution: {integrity: sha512-TMy9zy173jDOpnFXDqL3BPIQn5lfcAkSsivYQatCCakoHk4fLGd7QjfAaNGYE3Ox+/ZI6Lq0e1gGcz1qdw/IbA==} hasBin: true '@hono/node-server@1.19.14': @@ -1275,71 +1155,8 @@ packages: '@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==} + '@mistralai/mistralai@2.2.5': + resolution: {integrity: sha512-ATbWzKkNzNAZ+gtw9MI/c/ULTMG80tKUiRNIbQFfg4OP0uEZZpTfXZeBCNfs5Dq0uqMQ/tQWc4o6RRJQtMrpDA==} '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} @@ -1355,85 +1172,12 @@ packages: 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'} @@ -1446,8 +1190,8 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@openclaw/fs-safe@0.2.7': - resolution: {integrity: sha512-l/Yj3K2ChR/gI+bZo1wIe7rjKyTFwGOAw120cTCMRT8LZbVhJhTbiZLGIRBMv0Gc9GQjYE8EjPBza3RdrSSbyQ==} + '@openclaw/fs-safe@0.3.0': + resolution: {integrity: sha512-uIBE441CIt1kIURoP9qRGKZ8LkGyfD9ZzeESjwAd29ZPWtghws/5GR3Pjb67jKdcJHP1I6roNXcvnhzAU7lHlA==} engines: {node: '>=20.11'} '@openclaw/proxyline@0.3.3': @@ -1604,45 +1348,12 @@ packages: 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==} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1837,6 +1548,10 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + beeper-cli@0.6.2: + resolution: {integrity: sha512-yVhaKTzO2ZuMfatwSfHaa/v8zfp4IthuEkah2zbUGeBPjPHW/L0rVP/6dFzxYUFE/hLMJ9u8X6yj38Nh3KmzkQ==} + hasBin: true + better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} @@ -1863,9 +1578,6 @@ packages: 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} @@ -1928,6 +1640,10 @@ packages: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} + clawpdf@0.2.0: + resolution: {integrity: sha512-Za4HD3CMRHNqOXOOyVJiQLnEuezRZR/oXiBzraTwL5XEQZuBwFxnyC1UzN4AjQWV2JrLN3ItbzfPRGE0gGOVwg==} + engines: {node: '>=20'} + cliui@6.0.0: resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} @@ -2043,8 +1759,8 @@ 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==} + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} engines: {node: '>=0.3.1'} dijkstrajs@1.0.3: @@ -2211,6 +1927,9 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} @@ -2223,13 +1942,6 @@ packages: 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==} @@ -2364,8 +2076,9 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true - highlight.js@10.7.3: - resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} hono@4.12.23: resolution: {integrity: sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==} @@ -2391,10 +2104,6 @@ packages: 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'} @@ -2550,9 +2259,6 @@ packages: 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'} @@ -2669,8 +2375,8 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} - markdown-it@14.1.1: - resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} + markdown-it@14.2.0: + resolution: {integrity: sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==} hasBin: true markdown-table@3.0.4: @@ -2932,20 +2638,8 @@ packages: 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==} + openai@6.39.0: + resolution: {integrity: sha512-O61LIsimY3acVabwvomwFhwrnN36yvHY2quIfy9keEcFytGgWeV35yLHQ6NVMLSBxRpHmcg2yuhCnlu2HT4pLQ==} hasBin: true peerDependencies: ws: ^8.18.0 @@ -2956,8 +2650,8 @@ packages: zod: optional: true - openclaw@2026.5.22: - resolution: {integrity: sha512-m+zgBELGbCHjWB1IWF5WSWNPr480cMKOMff2OF72c8A0AMD4hC/9+qwYtzjYmGkETcffnB711JymlVsQnh2Tow==} + openclaw@2026.5.28: + resolution: {integrity: sha512-p7jGN9wzCrqEvHNI6Y7+eh6DWoYDzJ1iQGKTm8xqQ2uQ9/2mY1CCf87WoZeb0+m3eHKSGchlI3tN33fE1lMtEA==} engines: {node: '>=22.19.0'} hasBin: true @@ -3005,10 +2699,6 @@ 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'} @@ -3030,10 +2720,6 @@ 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==} @@ -3107,13 +2793,17 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - quickjs-wasi@2.2.0: - resolution: {integrity: sha512-zQxXmQMrEoD3S+jQdYsloq4qAuaxKFHZj6hHqOYGwB2iQZH+q9e/lf5zQPXCKOk0WJuAjzRFbO4KwHIp2D05Iw==} + quickjs-wasi@3.0.0: + resolution: {integrity: sha512-X7ouKC4ZVf9bXQ8rsE7+L6TeBbesejAJH61x16xRaGAQGfBHHRcniWgzJZZVtHc8rS9yVsY+Tvk8/usAosg4bg==} range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} + rastermill@0.3.0: + resolution: {integrity: sha512-4g2i0I7M5sba//lFBh19Wi0hDGw8o+isnt/BtEyqQXIZaYclhcNBwL/Fw/6gDCp7aaLwQHADuUvyHCB0Oat5Vw==} + engines: {node: '>=20'} + raw-body@3.0.2: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} @@ -3329,6 +3019,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -3351,9 +3044,6 @@ 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'} @@ -3405,11 +3095,6 @@ packages: 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==} @@ -3712,8 +3397,8 @@ packages: utf-8-validate: optional: true - ws@8.20.1: - resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -3724,10 +3409,6 @@ 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==} @@ -3790,235 +3471,13 @@ snapshots: dependencies: zod: 4.4.3 - '@anthropic-ai/sdk@0.91.1(zod@4.4.3)': + '@anthropic-ai/sdk@0.98.0(zod@4.4.3)': dependencies: json-schema-to-ts: 3.1.1 + standardwebhooks: 1.0.0 optionalDependencies: zod: 4.4.3 - '@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 @@ -4257,74 +3716,10 @@ 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': + '@earendil-works/pi-tui@0.76.0': dependencies: get-east-asian-width: 1.6.0 marked: 15.0.12 - optionalDependencies: - koffi: 2.16.2 '@emnapi/core@1.10.0': dependencies: @@ -4498,25 +3893,12 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true - '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))': + '@google/genai@2.6.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))': dependencies: google-auth-library: 10.6.2 p-retry: 4.6.2 protobufjs: 7.6.1 - ws: 8.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 + ws: 8.21.0 optionalDependencies: '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) transitivePeerDependencies: @@ -4536,7 +3918,7 @@ snapshots: '@grammyjs/types@3.27.3': {} - '@homebridge/ciao@1.3.8': + '@homebridge/ciao@1.3.9': dependencies: debug: 4.4.3 fast-deep-equal: 3.1.3 @@ -4718,53 +4100,9 @@ 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': + '@mistralai/mistralai@2.2.5': dependencies: - ws: 8.20.1 + ws: 8.21.0 zod: 4.4.3 zod-to-json-schema: 3.25.2(zod@4.4.3) transitivePeerDependencies: @@ -4795,54 +4133,6 @@ snapshots: '@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 @@ -4850,8 +4140,6 @@ 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 @@ -4864,7 +4152,7 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@openclaw/fs-safe@0.2.7': + '@openclaw/fs-safe@0.3.0': optionalDependencies: jszip: 3.10.1 tar: 7.5.13 @@ -4971,56 +4259,10 @@ snapshots: '@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': {} + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@tanstack/ai-client@0.10.0(@opentelemetry/api@1.9.0)': @@ -5244,6 +4486,8 @@ snapshots: base64-js@1.5.1: {} + beeper-cli@0.6.2: {} + better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 @@ -5274,8 +4518,6 @@ snapshots: bottleneck@2.19.5: {} - bowser@2.14.1: {} - brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -5332,6 +4574,8 @@ snapshots: chownr@3.0.0: {} + clawpdf@0.2.0: {} + cliui@6.0.0: dependencies: string-width: 4.2.3 @@ -5421,7 +4665,7 @@ snapshots: dependencies: dequal: 2.0.3 - diff@8.0.4: {} + diff@9.0.0: {} dijkstrajs@1.0.3: {} @@ -5629,6 +4873,8 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-sha256@1.3.0: {} + fast-string-truncated-width@3.0.3: {} fast-string-width@3.0.2: @@ -5641,18 +4887,6 @@ snapshots: 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 @@ -5818,7 +5052,7 @@ snapshots: he@1.2.0: {} - highlight.js@10.7.3: {} + highlight.js@11.11.1: {} hono@4.12.23: {} @@ -5847,13 +5081,6 @@ snapshots: 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: @@ -5980,9 +5207,6 @@ snapshots: kleur@4.1.5: {} - koffi@2.16.2: - optional: true - kysely@0.29.2: {} lie@3.3.0: @@ -6076,7 +5300,7 @@ snapshots: dependencies: semver: 7.7.4 - markdown-it@14.1.1: + markdown-it@14.2.0: dependencies: argparse: 2.0.1 entities: 4.5.0 @@ -6448,7 +5672,7 @@ snapshots: node-edge-tts@1.2.10: dependencies: https-proxy-agent: 7.0.6 - ws: 8.20.1 + ws: 8.21.0 yargs: 17.7.2 transitivePeerDependencies: - bufferutil @@ -6490,58 +5714,61 @@ snapshots: dependencies: wrappy: 1.0.2 - openai@6.26.0(ws@8.20.1)(zod@4.4.3): + openai@6.39.0(ws@8.21.0)(zod@4.4.3): optionalDependencies: - ws: 8.20.1 + ws: 8.21.0 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: + openclaw@2026.5.28: dependencies: '@agentclientprotocol/sdk': 0.22.1(zod@4.4.3) + '@anthropic-ai/sdk': 0.98.0(zod@4.4.3) '@clack/core': 1.3.1 '@clack/prompts': 1.4.0 - '@earendil-works/pi-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)) + '@earendil-works/pi-tui': 0.76.0 + '@google/genai': 2.6.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) '@grammyjs/runner': 2.0.3(grammy@1.43.0) '@grammyjs/transformer-throttler': 1.2.1(grammy@1.43.0) - '@homebridge/ciao': 1.3.8 + '@homebridge/ciao': 1.3.9 '@lydell/node-pty': 1.2.0-beta.12 + '@mistralai/mistralai': 2.2.5 '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) '@mozilla/readability': 0.6.0 - '@openclaw/fs-safe': 0.2.7 + '@openclaw/fs-safe': 0.3.0 '@openclaw/proxyline': 0.3.3(undici@8.3.0) - ajv: 8.20.0 + '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2 chokidar: 5.0.0 + clawpdf: 0.2.0 commander: 14.0.3 croner: 10.0.1 + cross-spawn: 7.0.6 + diff: 9.0.0 dotenv: 17.4.2 express: 5.2.1 file-type: 22.0.1 + glob: 13.0.6 grammy: 1.43.0 + highlight.js: 11.11.1 + hosted-git-info: 9.0.3 + ignore: 7.0.5 ipaddr.js: 2.4.0 jiti: 2.7.0 json5: 2.2.3 jszip: 3.10.1 kysely: 0.29.2 linkedom: 0.18.12 - markdown-it: 14.1.1 + markdown-it: 14.2.0 + minimatch: 10.2.5 node-edge-tts: 1.2.10 - openai: 6.38.0(ws@8.20.1)(zod@4.4.3) - pdfjs-dist: 5.7.284 + openai: 6.39.0(ws@8.21.0)(zod@4.4.3) + partial-json: 0.1.7 playwright-core: 1.60.0 + proper-lockfile: 4.1.2 qrcode: 1.5.4 - quickjs-wasi: 2.2.0 + quickjs-wasi: 3.0.0 + rastermill: 0.3.0 tar: 7.5.15 - tokenjuice: 0.7.1 tree-sitter-bash: 0.25.1 tslog: 4.10.2 typebox: 1.1.38 @@ -6549,11 +5776,10 @@ snapshots: undici: 8.3.0 web-push: 3.6.7 web-tree-sitter: 0.26.9 - ws: 8.20.1 + ws: 8.21.0 yaml: 2.9.0 zod: 4.4.3 optionalDependencies: - sharp: 0.34.5 sqlite-vec: 0.1.9 transitivePeerDependencies: - '@cfworker/json-schema' @@ -6599,8 +5825,6 @@ snapshots: path-exists@4.0.0: {} - path-expression-matcher@1.5.0: {} - path-key@3.1.1: {} path-scurry@2.0.2: @@ -6616,10 +5840,6 @@ snapshots: pathe@2.0.3: {} - pdfjs-dist@5.7.284: - optionalDependencies: - '@napi-rs/canvas': 0.1.100 - picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -6688,10 +5908,14 @@ snapshots: queue-microtask@1.2.3: {} - quickjs-wasi@2.2.0: {} + quickjs-wasi@3.0.0: {} range-parser@1.2.1: {} + rastermill@0.3.0: + dependencies: + '@silvia-odwyer/photon-node': 0.3.4 + raw-body@3.0.2: dependencies: bytes: 3.1.2 @@ -6973,6 +6197,11 @@ snapshots: stackback@0.0.2: {} + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + statuses@2.0.2: {} std-env@4.1.0: {} @@ -6993,8 +6222,6 @@ snapshots: strip-bom@3.0.0: {} - strnum@2.3.0: {} - strtok3@10.3.5: dependencies: '@tokenizer/token': 0.3.0 @@ -7047,8 +6274,6 @@ snapshots: '@tokenizer/token': 0.3.0 ieee754: 1.2.1 - tokenjuice@0.7.1: {} - tr46@0.0.3: {} tree-kill@1.2.2: {} @@ -7380,9 +6605,7 @@ snapshots: ws@8.18.0: {} - ws@8.20.1: {} - - xml-naming@0.1.0: {} + ws@8.21.0: {} y18n@4.0.3: {} From 0de2f1dc9a2f35d7e10e88988ef856bcd1577503 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Tue, 2 Jun 2026 23:54:28 +0200 Subject: [PATCH 51/56] Add Beeper account auth presence --- packages/openclaw/README.md | 2 - packages/openclaw/openclaw.plugin.json | 129 ++---- packages/openclaw/package.json | 20 +- .../openclaw/scripts/sync-manifest-schema.mjs | 7 + packages/openclaw/src/account-id.ts | 21 + packages/openclaw/src/auth-presence.test.ts | 47 +++ packages/openclaw/src/auth-presence.ts | 52 +++ .../src/beeper-channel-config.schema.json | 72 ++-- packages/openclaw/src/config.test.ts | 32 +- packages/openclaw/src/config.ts | 23 +- packages/openclaw/src/connector.test.ts | 4 +- packages/openclaw/src/connector.ts | 16 +- packages/openclaw/src/integration.test.ts | 2 +- .../openclaw/src/openclaw-extension.test.ts | 67 ++-- .../openclaw/src/openclaw-runtime.test.ts | 72 +++- packages/openclaw/src/openclaw-runtime.ts | 19 +- packages/openclaw/src/secret-contract.ts | 40 +- packages/openclaw/src/setup.test.ts | 370 +++++++++++------- packages/openclaw/src/setup.ts | 181 +++++---- packages/openclaw/tsdown.config.ts | 2 +- 20 files changed, 711 insertions(+), 467 deletions(-) create mode 100644 packages/openclaw/src/account-id.ts create mode 100644 packages/openclaw/src/auth-presence.test.ts create mode 100644 packages/openclaw/src/auth-presence.ts diff --git a/packages/openclaw/README.md b/packages/openclaw/README.md index cca34d2..984231e 100644 --- a/packages/openclaw/README.md +++ b/packages/openclaw/README.md @@ -53,14 +53,12 @@ import { readConfig, } from "@beeper/openclaw/config"; import { - accountFromOpenClawConfig, createOpenClawBeeperBridge, } from "@beeper/openclaw/appservice"; const config = await readConfig(); const bridge = await createOpenClawBeeperBridge({ - account: accountFromOpenClawConfig(config), config, }); diff --git a/packages/openclaw/openclaw.plugin.json b/packages/openclaw/openclaw.plugin.json index baba47a..50efa5c 100644 --- a/packages/openclaw/openclaw.plugin.json +++ b/packages/openclaw/openclaw.plugin.json @@ -35,22 +35,18 @@ "beeper": { "schema": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { - "enabled": { - "type": "boolean", - "description": "Enable Beeper agent chat." - }, "defaultAccount": { "type": "string", - "description": "Default Beeper account id for outbound agent DMs when no agent-specific default is set." + "description": "Default Beeper account Matrix user ID for outbound agent DMs when no agent-specific default is set." }, "accounts": { "type": "object", - "description": "Named Beeper accounts. Each account is set up through the normal OpenClaw channel setup flow.", + "description": "Beeper accounts keyed by full Matrix user ID, for example @batuhan:beeper.com.", "additionalProperties": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "enabled": { "type": "boolean", @@ -60,6 +56,10 @@ "type": "string", "description": "Display name for this Beeper account in settings and status." }, + "dataDir": { + "type": "string", + "description": "Beeper bridge state directory for this account." + }, "serverEnv": { "type": "string", "enum": [ @@ -136,13 +136,37 @@ } } ] + }, + "bridge": { + "type": "object", + "additionalProperties": false, + "properties": { + "appserviceId": { + "type": "string" + }, + "bridgeId": { + "type": "string" + }, + "homeserver": { + "type": "string" + }, + "homeserverDomain": { + "type": "string" + }, + "matrixDeviceId": { + "type": "string" + }, + "matrixUserId": { + "type": "string" + } + } } } } }, "agents": { "type": "object", - "description": "Per-agent Beeper account assignments. Account ids are not mutually exclusive.", + "description": "Per-agent Beeper account assignments. Account IDs are full Matrix user IDs and are not mutually exclusive.", "additionalProperties": { "type": "object", "additionalProperties": false, @@ -152,108 +176,31 @@ "items": { "type": "string" }, - "description": "Beeper account ids this agent may use." + "description": "Beeper account Matrix user IDs this agent may use." }, "defaultAccount": { "type": "string", - "description": "Preferred Beeper account for this agent when multiple assigned accounts are available." + "description": "Preferred Beeper account Matrix user ID for this agent when multiple assigned accounts are available." } } } - }, - "serverEnv": { - "type": "string", - "enum": [ - "prod", - "staging", - "dev", - "local" - ], - "default": "prod", - "description": "Beeper server environment to use before login. Changing it after login requires logging out and logging back in." - }, - "asToken": { - "description": "OpenClaw-managed Beeper appservice token.", - "oneOf": [ - { - "type": "string" - }, - { - "type": "object", - "additionalProperties": false, - "required": [ - "source", - "provider", - "id" - ], - "properties": { - "source": { - "type": "string", - "enum": [ - "env", - "file", - "exec" - ] - }, - "provider": { - "type": "string" - }, - "id": { - "type": "string" - } - } - } - ] - }, - "hsToken": { - "description": "OpenClaw-managed Beeper homeserver token.", - "oneOf": [ - { - "type": "string" - }, - { - "type": "object", - "additionalProperties": false, - "required": [ - "source", - "provider", - "id" - ], - "properties": { - "source": { - "type": "string", - "enum": [ - "env", - "file", - "exec" - ] - }, - "provider": { - "type": "string" - }, - "id": { - "type": "string" - } - } - } - ] } } }, "uiHints": { - "asToken": { + "accounts.*.asToken": { "sensitive": true, "tags": [ "hidden" ] }, - "hsToken": { + "accounts.*.hsToken": { "sensitive": true, "tags": [ "hidden" ] }, - "serverEnv": { + "accounts.*.serverEnv": { "help": "Choose before Beeper login. To change it after connecting, log out and log back in." } }, diff --git a/packages/openclaw/package.json b/packages/openclaw/package.json index 00832c7..b549837 100644 --- a/packages/openclaw/package.json +++ b/packages/openclaw/package.json @@ -31,6 +31,10 @@ "types": "./dist/appservice.d.mts", "import": "./dist/appservice.mjs" }, + "./auth-presence": { + "types": "./dist/auth-presence.d.mts", + "import": "./dist/auth-presence.mjs" + }, "./bridge-agent": { "types": "./dist/bridge-agent.d.mts", "import": "./dist/bridge-agent.mjs" @@ -126,7 +130,21 @@ "docsPath": "/channels/beeper", "docsLabel": "beeper", "blurb": "lets you chat with your OpenClaw agents on Beeper.", - "systemImage": "message" + "systemImage": "message", + "configuredState": { + "specifier": "./auth-presence", + "exportName": "hasAnyBeeperConfiguredState" + }, + "persistedAuthState": { + "specifier": "./auth-presence", + "exportName": "hasAnyBeeperAuth" + }, + "cliAddOptions": [ + { + "flags": "--server-env ", + "description": "Beeper server environment: prod, staging, dev, or local" + } + ] }, "install": { "clawhubSpec": "clawhub:@beeper/openclaw@0.1.0", diff --git a/packages/openclaw/scripts/sync-manifest-schema.mjs b/packages/openclaw/scripts/sync-manifest-schema.mjs index d18a4a8..ca7edd9 100644 --- a/packages/openclaw/scripts/sync-manifest-schema.mjs +++ b/packages/openclaw/scripts/sync-manifest-schema.mjs @@ -18,5 +18,12 @@ delete manifest.uiHints; manifest.channelConfigs ??= {}; manifest.channelConfigs.beeper ??= {}; manifest.channelConfigs.beeper.schema = schema; +manifest.channelConfigs.beeper.uiHints = { + "accounts.*.asToken": { sensitive: true, tags: ["hidden"] }, + "accounts.*.hsToken": { sensitive: true, tags: ["hidden"] }, + "accounts.*.serverEnv": { + help: "Choose before Beeper login. To change it after connecting, log out and log back in.", + }, +}; await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); diff --git a/packages/openclaw/src/account-id.ts b/packages/openclaw/src/account-id.ts new file mode 100644 index 0000000..d8a85bc --- /dev/null +++ b/packages/openclaw/src/account-id.ts @@ -0,0 +1,21 @@ +export function normalizeBeeperAccountId(value: string | null | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) return undefined; + if (trimmed.startsWith("@")) return trimmed; + if (trimmed.includes(":")) return `@${trimmed}`; + return undefined; +} + +export function requireBeeperAccountId(value: string | null | undefined): string { + const accountId = normalizeBeeperAccountId(value); + if (!accountId) throw new Error("Beeper account ID must be a full Matrix user ID like @batuhan:beeper.com."); + return accountId; +} + +export function beeperAccountIdFromMatrixUserId(userId: string | undefined): string | undefined { + const accountId = normalizeBeeperAccountId(userId); + if (!accountId || !accountId.includes(":")) return undefined; + const localpart = accountId.startsWith("@") ? accountId.slice(1).split(":")[0] : accountId.split(":")[0]; + const serverName = accountId.split(":").slice(1).join(":"); + return localpart && serverName ? accountId : undefined; +} diff --git a/packages/openclaw/src/auth-presence.test.ts b/packages/openclaw/src/auth-presence.test.ts new file mode 100644 index 0000000..d71a438 --- /dev/null +++ b/packages/openclaw/src/auth-presence.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { hasAnyBeeperAuth, hasAnyBeeperConfiguredState } from "./auth-presence"; + +describe("Beeper auth presence metadata probe", () => { + it("detects configured Beeper accounts", () => { + const configuredAccount = { + asToken: "as", + enabled: true, + hsToken: "hs", + bridge: { + homeserver: "https://matrix.example", + matrixDeviceId: "DEV", + matrixUserId: "@alice:beeper.com", + }, + }; + + expect(hasAnyBeeperConfiguredState({ + cfg: { + channels: { + beeper: { + accounts: { + "@alice:beeper.com": configuredAccount, + }, + }, + }, + }, + })).toBe(true); + }); + + it("does not treat partial bridge config as persisted auth", () => { + expect(hasAnyBeeperAuth({ + channels: { + beeper: { + accounts: { + "@alice:beeper.com": { + enabled: true, + bridge: { + homeserver: "https://matrix.example", + matrixUserId: "@alice:beeper.com", + }, + }, + }, + }, + }, + })).toBe(false); + }); +}); diff --git a/packages/openclaw/src/auth-presence.ts b/packages/openclaw/src/auth-presence.ts new file mode 100644 index 0000000..f338870 --- /dev/null +++ b/packages/openclaw/src/auth-presence.ts @@ -0,0 +1,52 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import type { SecretInput } from "openclaw/plugin-sdk/secret-input-runtime"; +import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input-runtime"; + +type BeeperAuthPresenceParams = + | { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; + } + | OpenClawConfig; + +type BeeperAuthAccount = { + asToken?: SecretInput; + bridge?: { + homeserver?: string; + matrixDeviceId?: string; + matrixUserId?: string; + }; + enabled?: boolean; + hsToken?: SecretInput; +}; + +type BeeperAuthChannel = { + accounts?: Record; +}; + +export function hasAnyBeeperAuth( + params: BeeperAuthPresenceParams, +): boolean { + const cfg = params && typeof params === "object" && "cfg" in params ? params.cfg : params; + const channel = cfg.channels?.beeper as BeeperAuthChannel | undefined; + if (!channel) return false; + return listBeeperAuthAccounts(channel).some((account) => hasBeeperAuthAccount(account, cfg)); +} + +export const hasAnyBeeperConfiguredState = hasAnyBeeperAuth; + +function listBeeperAuthAccounts(channel: BeeperAuthChannel): readonly BeeperAuthAccount[] { + return Object.values(channel.accounts ?? {}).filter((account): account is BeeperAuthAccount => Boolean(account)); +} + +function hasBeeperAuthAccount(account: BeeperAuthAccount, cfg: OpenClawConfig): boolean { + const bridge = account.bridge; + return Boolean( + account.enabled !== false && + hasConfiguredSecretInput(account.asToken, cfg.secrets?.defaults) && + hasConfiguredSecretInput(account.hsToken, cfg.secrets?.defaults) && + bridge?.homeserver && + bridge.matrixDeviceId && + bridge.matrixUserId + ); +} diff --git a/packages/openclaw/src/beeper-channel-config.schema.json b/packages/openclaw/src/beeper-channel-config.schema.json index f1e55f9..3ec9170 100644 --- a/packages/openclaw/src/beeper-channel-config.schema.json +++ b/packages/openclaw/src/beeper-channel-config.schema.json @@ -1,21 +1,17 @@ { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { - "enabled": { - "type": "boolean", - "description": "Enable Beeper agent chat." - }, "defaultAccount": { "type": "string", - "description": "Default Beeper account id for outbound agent DMs when no agent-specific default is set." + "description": "Default Beeper account Matrix user ID for outbound agent DMs when no agent-specific default is set." }, "accounts": { "type": "object", - "description": "Named Beeper accounts. Each account is set up through the normal OpenClaw channel setup flow.", + "description": "Beeper accounts keyed by full Matrix user ID, for example @batuhan:beeper.com.", "additionalProperties": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "enabled": { "type": "boolean", @@ -25,6 +21,10 @@ "type": "string", "description": "Display name for this Beeper account in settings and status." }, + "dataDir": { + "type": "string", + "description": "Beeper bridge state directory for this account." + }, "serverEnv": { "type": "string", "enum": ["prod", "staging", "dev", "local"], @@ -62,13 +62,25 @@ } } ] + }, + "bridge": { + "type": "object", + "additionalProperties": false, + "properties": { + "appserviceId": { "type": "string" }, + "bridgeId": { "type": "string" }, + "homeserver": { "type": "string" }, + "homeserverDomain": { "type": "string" }, + "matrixDeviceId": { "type": "string" }, + "matrixUserId": { "type": "string" } + } } } } }, "agents": { "type": "object", - "description": "Per-agent Beeper account assignments. Account ids are not mutually exclusive.", + "description": "Per-agent Beeper account assignments. Account IDs are full Matrix user IDs and are not mutually exclusive.", "additionalProperties": { "type": "object", "additionalProperties": false, @@ -76,52 +88,14 @@ "accountIds": { "type": "array", "items": { "type": "string" }, - "description": "Beeper account ids this agent may use." + "description": "Beeper account Matrix user IDs this agent may use." }, "defaultAccount": { "type": "string", - "description": "Preferred Beeper account for this agent when multiple assigned accounts are available." + "description": "Preferred Beeper account Matrix user ID for this agent when multiple assigned accounts are available." } } } - }, - "serverEnv": { - "type": "string", - "enum": ["prod", "staging", "dev", "local"], - "default": "prod", - "description": "Beeper server environment to use before login. Changing it after login requires logging out and logging back in." - }, - "asToken": { - "description": "OpenClaw-managed Beeper appservice token.", - "oneOf": [ - { "type": "string" }, - { - "type": "object", - "additionalProperties": false, - "required": ["source", "provider", "id"], - "properties": { - "source": { "type": "string", "enum": ["env", "file", "exec"] }, - "provider": { "type": "string" }, - "id": { "type": "string" } - } - } - ] - }, - "hsToken": { - "description": "OpenClaw-managed Beeper homeserver token.", - "oneOf": [ - { "type": "string" }, - { - "type": "object", - "additionalProperties": false, - "required": ["source", "provider", "id"], - "properties": { - "source": { "type": "string", "enum": ["env", "file", "exec"] }, - "provider": { "type": "string" }, - "id": { "type": "string" } - } - } - ] } } } diff --git a/packages/openclaw/src/config.test.ts b/packages/openclaw/src/config.test.ts index 95a64f6..70d8775 100644 --- a/packages/openclaw/src/config.test.ts +++ b/packages/openclaw/src/config.test.ts @@ -45,8 +45,12 @@ describe("OpenClaw bridge config", () => { const config = createConfigFromOpenClawSetup({ channels: { beeper: { - bridge: { appserviceId: "custom-openclaw" }, - dataDir: "/tmp/openclaw-bridge", + accounts: { + "@alice:example": { + bridge: { appserviceId: "custom-openclaw" }, + dataDir: "/tmp/openclaw-bridge", + }, + }, }, }, }); @@ -84,21 +88,25 @@ describe("OpenClaw bridge config", () => { await expect(readConfig(path)).resolves.toMatchObject(config); }); - it("reads setup-shaped config from generated channels.beeper.bridge state", async () => { + it("reads setup-shaped config from generated channels.beeper account state", async () => { const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-setup-config-")); const path = join(dir, "config.json"); await writeFile(path, `${JSON.stringify({ channels: { beeper: { - asToken: "as-secret", - dataDir: dir, - hsToken: "hs-secret", - serverEnv: "staging", - bridge: { - appserviceId: "sh-openclaw-device", - homeserver: "https://matrix.example", - matrixDeviceId: "DEVICE", - matrixUserId: "@alice:example", + accounts: { + "@alice:example": { + asToken: "as-secret", + dataDir: dir, + hsToken: "hs-secret", + serverEnv: "staging", + bridge: { + appserviceId: "sh-openclaw-device", + homeserver: "https://matrix.example", + matrixDeviceId: "DEVICE", + matrixUserId: "@alice:example", + }, + }, }, }, }, diff --git a/packages/openclaw/src/config.ts b/packages/openclaw/src/config.ts index c98e71a..0849ef8 100644 --- a/packages/openclaw/src/config.ts +++ b/packages/openclaw/src/config.ts @@ -2,7 +2,8 @@ import { randomBytes } from "node:crypto"; import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, resolve } from "node:path"; -import { getBeeperAccountSettings, getBeeperChannelSettings, type OpenClawSetupConfig } from "./setup"; +import { getBeeperAccountSettings, getBeeperChannelSettings, resolveDefaultBeeperAccountId, type OpenClawSetupConfig } from "./setup"; +import { requireBeeperAccountId } from "./account-id"; import { openClawBeeperBridgeId } from "./ids"; import type { OpenClawBridgeConfig } from "./types"; import { resolveConfiguredSecretInputString } from "openclaw/plugin-sdk/secret-input-runtime"; @@ -56,15 +57,22 @@ function configInput(input: unknown): Partial { const record = recordValue(input); const beeper = recordValue(recordValue(record?.channels)?.beeper); if (beeper) { - const serverEnv = normalizeServerEnv(stringValue(beeper.serverEnv)); - const bridge = recordValue(beeper.bridge) as Partial | undefined; + const accounts = recordValue(beeper.accounts); + const defaultAccount = stringValue(beeper.defaultAccount); + const account = recordValue( + defaultAccount && accounts?.[defaultAccount] + ? accounts[defaultAccount] + : Object.values(accounts ?? {})[0], + ); + const serverEnv = normalizeServerEnv(stringValue(account?.serverEnv)); + const bridge = recordValue(account?.bridge) as Partial | undefined; const config: Partial = { ...(bridge ?? {}) }; - const asToken = stringValue(beeper.asToken); - const hsToken = stringValue(beeper.hsToken); + const asToken = stringValue(account?.asToken); + const hsToken = stringValue(account?.hsToken); if (serverEnv) config.serverEnv = serverEnv; if (asToken) config.asToken = asToken; if (hsToken) config.hsToken = hsToken; - const dataDir = stringValue(beeper.dataDir); + const dataDir = stringValue(account?.dataDir); if (dataDir) config.dataDir = dataDir; return config; } @@ -93,7 +101,8 @@ export async function createRuntimeConfigFromOpenClawSetup( accountId?: string | null, ): Promise { const settings = getBeeperAccountSettings(cfg, accountId); - const accountPrefix = accountId && accountId !== "default" ? `channels.beeper.accounts.${accountId}` : "channels.beeper"; + const resolvedAccountId = requireBeeperAccountId(accountId ?? resolveDefaultBeeperAccountId(cfg)); + const accountPrefix = `channels.beeper.accounts.${resolvedAccountId}`; const config = createConfigFromOpenClawSetup(cfg, overrides, accountId); const asToken = await resolveConfiguredSecretInputString({ config: cfg, diff --git a/packages/openclaw/src/connector.test.ts b/packages/openclaw/src/connector.test.ts index 14547af..1c96764 100644 --- a/packages/openclaw/src/connector.test.ts +++ b/packages/openclaw/src/connector.test.ts @@ -58,7 +58,7 @@ describe("OpenClawBridgeConnector", () => { it("registers the live Beeper runtime in OpenClaw channel runtime contexts", async () => { const register = vi.fn(); const connector = createOpenClawConnector({ - config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + config: createDefaultConfig({ dataDir: "/tmp/openclaw", matrixUserId: "@batuhan:beeper.com" }), registry: new OpenClawBridgeRegistry("/tmp/openclaw-connector-runtime-context-test.json"), runtime: { channel: { @@ -76,7 +76,7 @@ describe("OpenClawBridgeConnector", () => { } as never); expect(register).toHaveBeenCalledWith(expect.objectContaining({ - accountId: "default", + accountId: "@batuhan:beeper.com", capability: "beeper.runtime", channelId: "beeper", context: connector.getChannelRuntime(), diff --git a/packages/openclaw/src/connector.ts b/packages/openclaw/src/connector.ts index 0ad1b10..e27eb85 100644 --- a/packages/openclaw/src/connector.ts +++ b/packages/openclaw/src/connector.ts @@ -61,6 +61,7 @@ import { BeeperChannelRuntime, setBeeperChannelRuntimeForHost, } from "./beeper-channel-runtime"; +import { beeperAccountIdFromMatrixUserId } from "./account-id"; import { OpenClawMatrixBridgeAgent } from "./bridge-agent"; import { createDefaultConfig } from "./config"; import { parseMatrixTextMessage, type ParsedMatrixTextMessage } from "./matrix-parser"; @@ -194,7 +195,7 @@ export class OpenClawBridgeConnector implements BridgeConnector { @@ -370,7 +371,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, streamTargetRelationPatch(currentBinding, parsed.replyToEventId)), + matrix: matrixMetadataFromParsed(this.#config, parsed, msg.sender.userId, streamTargetRelationPatch(currentBinding, parsed.replyToEventId)), roomId: msg.portal.mxid, ...(parsed.replyToEventId ? { replyToEventId: parsed.replyToEventId } : {}), sender: msg.sender.userId, @@ -398,7 +399,7 @@ export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetwor await this.#agent.handleMatrixText({ ...(parsed.attachments.length > 0 ? { attachments: parsed.attachments } : {}), eventId: `${msg.event.eventId}:edit`, - matrix: matrixMetadataFromParsed(parsed, msg.sender.userId, { + matrix: matrixMetadataFromParsed(this.#config, parsed, msg.sender.userId, { kind: "edit", targetEventId: targetId, ...streamTargetRelationPatch(binding, targetId), @@ -764,11 +765,14 @@ function streamTargetRelationPatch( } function matrixMetadataFromParsed( + config: OpenClawBridgeConfig, parsed: ParsedMatrixTextMessage, sender: string, relationPatch: NonNullable = {}, ): OpenClawMatrixMessageMetadata { const metadata: OpenClawMatrixMessageMetadata = { sender }; + const accountId = beeperAccountIdFromMatrixUserId(config.matrixUserId); + if (accountId) metadata.accountId = accountId; if (parsed.attachments.length > 0) metadata.attachments = parsed.attachments as NonNullable; if (parsed.command) metadata.command = parsed.command; if (parsed.formattedBody) metadata.formattedBody = parsed.formattedBody; @@ -908,13 +912,15 @@ export function userLoginFromOpenClawConfig(config: OpenClawBridgeConfig): UserL }; } -function registerBeeperRuntimeContext(hostRuntime: OpenClawHostRuntime | undefined, runtime: BeeperChannelRuntime): void { +function registerBeeperRuntimeContext(hostRuntime: OpenClawHostRuntime | undefined, runtime: BeeperChannelRuntime, config: OpenClawBridgeConfig): void { const channel = recordValue(hostRuntime)?.channel; const runtimeContexts = recordValue(channel)?.runtimeContexts; const register = recordValue(runtimeContexts)?.register; if (typeof register !== "function") return; + const accountId = beeperAccountIdFromMatrixUserId(config.matrixUserId); + if (!accountId) return; register.call(runtimeContexts, { - accountId: "default", + accountId, capability: BEEPER_CHANNEL_RUNTIME_CONTEXT_CAPABILITY, channelId: "beeper", context: runtime, diff --git a/packages/openclaw/src/integration.test.ts b/packages/openclaw/src/integration.test.ts index d4fa35d..1026581 100644 --- a/packages/openclaw/src/integration.test.ts +++ b/packages/openclaw/src/integration.test.ts @@ -67,7 +67,7 @@ describe("OpenClaw bridge integration", () => { }); expect(runtime.sendMessage).toHaveBeenCalledWith({ idempotencyKey: "$hello", - matrix: { roomId: "!codex:example", sender: "@alice:example" }, + matrix: { accountId: "@sh-openclawbot:example", roomId: "!codex:example", sender: "@alice:example" }, message: "hello", sessionKey: "session_1", }); diff --git a/packages/openclaw/src/openclaw-extension.test.ts b/packages/openclaw/src/openclaw-extension.test.ts index 13cf7ed..f6a459a 100644 --- a/packages/openclaw/src/openclaw-extension.test.ts +++ b/packages/openclaw/src/openclaw-extension.test.ts @@ -20,10 +20,10 @@ describe("OpenClaw plugin package metadata", () => { expect(extension.kind).toBe("bundled-channel-entry"); expect(extension.loadChannelPlugin()).toMatchObject({ id: "beeper" }); expect(extension.loadChannelSecrets()).toMatchObject({ - secretTargetRegistryEntries: [ - expect.objectContaining({ pathPattern: "channels.beeper.asToken" }), - expect.objectContaining({ pathPattern: "channels.beeper.hsToken" }), - ], + secretTargetRegistryEntries: expect.arrayContaining([ + expect.objectContaining({ pathPattern: "channels.beeper.accounts.*.asToken" }), + expect.objectContaining({ pathPattern: "channels.beeper.accounts.*.hsToken" }), + ]), }); expect(resolveBundledRuntimeChannelRegistration(extension)).toMatchObject({ id: "beeper", @@ -79,7 +79,12 @@ describe("OpenClaw plugin package metadata", () => { runtimeExtensions?: string[]; setupEntry?: string; runtimeSetupEntry?: string; - channel?: { id?: string }; + channel?: { + cliAddOptions?: Array<{ flags?: string; description?: string }>; + configuredState?: { specifier?: string; exportName?: string }; + id?: string; + persistedAuthState?: { specifier?: string; exportName?: string }; + }; install?: { clawhubSpec?: string; defaultChoice?: string; npmSpec?: string }; compat?: { pluginApi?: string }; }; @@ -111,6 +116,20 @@ describe("OpenClaw plugin package metadata", () => { expect(packageJson.openclaw?.setupEntry).toBe("./src/setup-entry.ts"); expect(packageJson.openclaw?.runtimeSetupEntry).toBe("./dist/setup-entry.mjs"); expect(packageJson.openclaw?.channel?.id).toBe("beeper"); + expect(packageJson.openclaw?.channel?.configuredState).toEqual({ + specifier: "./auth-presence", + exportName: "hasAnyBeeperConfiguredState", + }); + expect(packageJson.openclaw?.channel?.persistedAuthState).toEqual({ + specifier: "./auth-presence", + exportName: "hasAnyBeeperAuth", + }); + expect(packageJson.openclaw?.channel?.cliAddOptions).toEqual([ + { + flags: "--server-env ", + description: "Beeper server environment: prod, staging, dev, or local", + }, + ]); expect(packageJson.openclaw?.install?.defaultChoice).toBe("clawhub"); expect(packageJson.openclaw?.install?.clawhubSpec).toBe( `clawhub:@beeper/openclaw@${packageJson.version}`, @@ -146,30 +165,34 @@ describe("OpenClaw plugin package metadata", () => { nativeSkillsAutoEnabled: true, }, schema: { - properties: expect.not.objectContaining({ - appserviceId: expect.anything(), - backfillLimit: expect.anything(), - bridgeId: expect.anything(), - homeserver: expect.anything(), - homeserverDomain: expect.anything(), - importSources: expect.anything(), - matrixDeviceId: expect.anything(), - matrixUserId: expect.anything(), + additionalProperties: false, + properties: expect.objectContaining({ + accounts: expect.any(Object), + agents: expect.any(Object), + defaultAccount: expect.any(Object), }), }, uiHints: expect.objectContaining({ - asToken: expect.objectContaining({ sensitive: true, tags: ["hidden"] }), - hsToken: expect.objectContaining({ sensitive: true, tags: ["hidden"] }), - serverEnv: expect.objectContaining({ + "accounts.*.asToken": expect.objectContaining({ sensitive: true, tags: ["hidden"] }), + "accounts.*.hsToken": expect.objectContaining({ sensitive: true, tags: ["hidden"] }), + "accounts.*.serverEnv": expect.objectContaining({ help: expect.stringContaining("Choose before Beeper login"), }), }), }); - expect(manifest.channelConfigs?.beeper?.schema?.properties).toEqual(expect.objectContaining({ - asToken: expect.any(Object), - hsToken: expect.any(Object), - serverEnv: expect.objectContaining({ enum: ["prod", "staging", "dev", "local"] }), - })); + expect(manifest.channelConfigs?.beeper?.schema?.properties).not.toHaveProperty("asToken"); + expect(manifest.channelConfigs?.beeper?.schema?.properties).not.toHaveProperty("hsToken"); + expect(manifest.channelConfigs?.beeper?.schema?.properties).not.toHaveProperty("serverEnv"); + expect(manifest.channelConfigs?.beeper?.schema?.properties?.accounts).toMatchObject({ + additionalProperties: { + properties: { + asToken: expect.any(Object), + bridge: expect.any(Object), + hsToken: expect.any(Object), + serverEnv: expect.objectContaining({ enum: ["prod", "staging", "dev", "local"] }), + }, + }, + }); }); it("keeps the public package manifest publishable and installable from built files", async () => { diff --git a/packages/openclaw/src/openclaw-runtime.test.ts b/packages/openclaw/src/openclaw-runtime.test.ts index 00be13b..c809b41 100644 --- a/packages/openclaw/src/openclaw-runtime.test.ts +++ b/packages/openclaw/src/openclaw-runtime.test.ts @@ -189,7 +189,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { const sent = await runtime.sendMessage({ idempotencyKey: "$event", - matrix: { roomId: "!room:example", sender: "@alice:example" }, + matrix: { accountId: "@batuhan:beeper.com", roomId: "!room:example", sender: "@alice:example" }, message: "hello", sessionKey: "agent:main:beeper:default:direct:!room:example", }); @@ -343,13 +343,13 @@ describe("OpenClawPluginRuntimeAdapter", () => { sessionKey: "agent:main:beeper:room", message: "from Beeper", idempotencyKey: "$event", - matrix: { roomId: "!room:example", sender: "@alice:example" }, + matrix: { accountId: "@batuhan:beeper.com", roomId: "!room:example", sender: "@alice:example" }, }); observedRunId = (sent as { runId?: string }).runId; await done; expect(dispatchReply).toHaveBeenCalledWith(expect.objectContaining({ - accountId: "beeper", + accountId: "@batuhan:beeper.com", agentId: "main", channel: "beeper", routeSessionKey: "agent:main:beeper:room", @@ -454,7 +454,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { await transport.sendMessage({ sessionKey: "agent:main:beeper:room", message: "from Beeper", - matrix: { roomId: "!room:example", sender: "@alice:example" }, + matrix: { accountId: "@batuhan:beeper.com", roomId: "!room:example", sender: "@alice:example" }, }); await done; @@ -485,6 +485,62 @@ describe("OpenClawPluginRuntimeAdapter", () => { setBeeperChannelRuntimeForHost(hostRuntime, undefined); }); + it("does not replay visible text when OpenClaw emits duplicate assistant starts", async () => { + const aiRunStreams = createTestBeeperAIRunStreams(); + const dispatchReply = vi.fn(async (params: Record) => { + const replyOptions = params.replyOptions as Record void | Promise>; + await replyOptions.onAssistantMessageStart?.(); + await replyOptions.onPartialReply?.({ text: "New session started" }); + await replyOptions.onAssistantMessageStart?.(); + await replyOptions.onBlockReply?.({ text: "New session started" }); + const delivery = params.delivery as { deliver?: (payload: unknown, info?: unknown) => Promise }; + await delivery.deliver?.({ text: "New session started" }, { kind: "final" }); + return { dispatchResult: { queuedFinal: true } }; + }); + const hostRuntime = { + channel: { + reply: { dispatchReplyWithBufferedBlockDispatcher: vi.fn() }, + session: { recordInboundSession: vi.fn(), resolveStorePath: () => "/tmp/sessions.json" }, + inbound: { + buildContext: (params: Record) => ({ + Body: "/new", + BodyForAgent: "/new", + From: "beeper", + RawBody: "/new", + SessionKey: (params.route as { routeSessionKey?: string }).routeSessionKey, + To: "beeper", + }), + dispatchReply, + }, + }, + config: { current: () => ({ agents: { list: [{ id: "main" }] } }) }, + }; + setBeeperChannelRuntimeForHost(hostRuntime, createTestBeeperChannelRuntime(aiRunStreams)); + const transport = createOpenClawHostRuntimeAdapter(hostRuntime); + + const done = (async () => { + for await (const event of transport.events()) { + if (event.event === "run.completed") break; + } + })(); + await transport.sendMessage({ + sessionKey: "agent:main:beeper:room", + message: "/new", + matrix: { + accountId: "@batuhan:beeper.com", + command: { name: "new" }, + roomId: "!room:example", + sender: "@alice:example", + }, + }); + await done; + + expect(startedAndAppendedParts(aiRunStreams).filter((part) => part.kind === "text").map((part) => part.text)).toEqual([ + "New session started", + ]); + setBeeperChannelRuntimeForHost(hostRuntime, undefined); + }); + it("streams assistant agent events when reply callbacks only deliver the final block", async () => { const aiRunStreams = createTestBeeperAIRunStreams(); let agentEventListener: ((event: { data?: Record; runId?: string; sessionKey?: string; stream?: string }) => void) | undefined; @@ -646,7 +702,7 @@ describe("OpenClawPluginRuntimeAdapter", () => { await transport.sendMessage({ sessionKey: "agent:main:beeper:room", message: "from Beeper", - matrix: { roomId: "!room:example", sender: "@alice:example" }, + matrix: { accountId: "@batuhan:beeper.com", roomId: "!room:example", sender: "@alice:example" }, }); await done; @@ -720,9 +776,9 @@ describe("OpenClawPluginRuntimeAdapter", () => { expect.objectContaining({ kind: "tool_result", output: "loading", preliminary: true, toolCallId: "tool-c", toolName: "search" }), expect.objectContaining({ kind: "tool_result", output: "checking docs", preliminary: true, toolCallId: "plan", toolName: "plan" }), expect.objectContaining({ kind: "tool_result", output: "stdout", preliminary: true, toolCallId: "cmd-1", toolName: "shell" }), - expect.objectContaining({ kind: "tool_result", state: "complete", toolCallId: "tool-c", toolName: "search" }), - expect.objectContaining({ kind: "tool_result", state: "complete", toolCallId: "plan", toolName: "plan" }), - expect.objectContaining({ kind: "tool_result", state: "complete", toolCallId: "cmd-1", toolName: "shell" }), + expect.objectContaining({ kind: "tool_result", state: "complete", text: "{}", toolCallId: "tool-c", toolName: "search" }), + expect.objectContaining({ kind: "tool_result", state: "complete", text: "{}", toolCallId: "plan", toolName: "plan" }), + expect.objectContaining({ kind: "tool_result", state: "complete", text: "{}", toolCallId: "cmd-1", toolName: "shell" }), expect.objectContaining({ input: { command: "/bin/zsh -lc \"date '+%Y-%m-%d %H:%M:%S %Z'\"", diff --git a/packages/openclaw/src/openclaw-runtime.ts b/packages/openclaw/src/openclaw-runtime.ts index b17c5dd..ac1304a 100644 --- a/packages/openclaw/src/openclaw-runtime.ts +++ b/packages/openclaw/src/openclaw-runtime.ts @@ -133,6 +133,7 @@ export interface OpenClawMatrixAttachmentMetadata { } export interface OpenClawMatrixMessageMetadata { + accountId?: string; attachments?: OpenClawMatrixAttachmentMetadata[]; command?: { args?: string; @@ -813,6 +814,8 @@ async function runBeeperChannelTurnInPluginRuntime(params: { const sender = recordValue(recordValue(params.record.matrix)?.sender) ?? {}; const matrix = recordValue(params.record.matrix) ?? {}; + const accountId = stringValue(matrix.accountId); + if (!accountId) throw new Error("OpenClaw Beeper inbound turns require matrix.accountId."); const senderId = stringValue(matrix.sender) ?? stringValue(sender.id) ?? "beeper"; const command = recordValue(matrix.command); const commandName = stringValue(command?.name); @@ -825,7 +828,7 @@ async function runBeeperChannelTurnInPluginRuntime(params: { ?? path.dirname(params.sessionFile); const ctxPayload = inbound.buildContext({ channel: "beeper", - accountId: "beeper", + accountId, provider: "beeper", surface: "beeper", messageId: eventId, @@ -847,7 +850,7 @@ async function runBeeperChannelTurnInPluginRuntime(params: { }, route: { agentId: params.agentId, - accountId: "beeper", + accountId, routeSessionKey: params.sessionKey, dispatchSessionKey: params.sessionKey, createIfMissing: true, @@ -938,7 +941,7 @@ async function runBeeperChannelTurnInPluginRuntime(params: { await inbound.dispatchReply({ cfg: params.cfg, channel: "beeper", - accountId: "beeper", + accountId, agentId: params.agentId, routeSessionKey: params.sessionKey, storePath, @@ -991,7 +994,7 @@ async function runBeeperChannelTurnInPluginRuntime(params: { sessionKey: params.sessionKey, channel: "beeper", to: roomId, - accountId: "beeper", + accountId, }, }, messageId: eventId, @@ -1547,6 +1550,10 @@ function isCompletePhase(value: string | undefined): boolean { return value === "complete" || value === "completed" || value === "end" || value === "ended" || value === "finish" || value === "finished" || value === "done"; } +function emptyToolResultContent(output: unknown, error: unknown): string | undefined { + return output === undefined && error === undefined ? "{}" : undefined; +} + function createBeeperReplyStreamEmitter(base: { agentId: string; hostRuntime?: OpenClawHostRuntime; @@ -1810,6 +1817,7 @@ function createBeeperReplyStreamEmitter(base: { await publishPart({ kind: "tool_result", state: "complete", + text: "{}", toolCallId, toolName, }); @@ -1820,7 +1828,6 @@ function createBeeperReplyStreamEmitter(base: { start: ensureStarted, trackExternal, assistantMessageStart: () => { - lastVisibleText = ""; emit("assistant.message.start", {}); }, reasoningEnd: async () => { @@ -2032,6 +2039,7 @@ function createBeeperReplyStreamEmitter(base: { metadata, output: error === undefined ? output : undefined, preliminary, + text: emptyToolResultContent(output, error), completedAtMs: numberValue(data.completedAt) ?? numberValue(data.completedAtMs), providerExecuted: booleanValue(data.providerExecuted), ...(commandTool ? commandPartFields(data) : {}), @@ -2186,6 +2194,7 @@ function createBeeperReplyStreamEmitter(base: { metadata, output, preliminary: !complete, + text: emptyToolResultContent(output, undefined), ...(isCommandToolName(toolName) ? commandPartFields(data) : {}), title, toolCallId, diff --git a/packages/openclaw/src/secret-contract.ts b/packages/openclaw/src/secret-contract.ts index 96835aa..4e830c6 100644 --- a/packages/openclaw/src/secret-contract.ts +++ b/packages/openclaw/src/secret-contract.ts @@ -7,31 +7,9 @@ import { } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; export const secretTargetRegistryEntries: SecretTargetRegistryEntry[] = [ - { - id: "channels.beeper.asToken", - targetType: "channels.beeper.asToken", - configFile: "openclaw.json", - pathPattern: "channels.beeper.asToken", - secretShape: "secret_input", - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, - { - id: "channels.beeper.hsToken", - targetType: "channels.beeper.hsToken", - configFile: "openclaw.json", - pathPattern: "channels.beeper.hsToken", - secretShape: "secret_input", - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, { id: "channels.beeper.accounts.*.asToken", - targetType: "channels.beeper.asToken", + targetType: "channels.beeper.accounts.*.asToken", configFile: "openclaw.json", pathPattern: "channels.beeper.accounts.*.asToken", secretShape: "secret_input", @@ -42,7 +20,7 @@ export const secretTargetRegistryEntries: SecretTargetRegistryEntry[] = [ }, { id: "channels.beeper.accounts.*.hsToken", - targetType: "channels.beeper.hsToken", + targetType: "channels.beeper.accounts.*.hsToken", configFile: "openclaw.json", pathPattern: "channels.beeper.accounts.*.hsToken", secretShape: "secret_input", @@ -61,20 +39,6 @@ export function collectRuntimeConfigAssignments(params: { const resolved = getChannelSurface(params.config, "beeper"); if (!resolved) return; const { channel, surface } = resolved; - for (const field of ["asToken", "hsToken"] as const) { - collectSecretInputAssignment({ - value: channel[field], - path: `channels.beeper.${field}`, - expected: "string", - defaults: params.defaults, - context: params.context, - active: surface.channelEnabled, - inactiveReason: "Beeper channel is disabled.", - apply: (value) => { - channel[field] = value; - }, - }); - } const accounts = recordValue(channel.accounts); if (!accounts) return; for (const [accountId, value] of Object.entries(accounts)) { diff --git a/packages/openclaw/src/setup.test.ts b/packages/openclaw/src/setup.test.ts index 01e8fba..575d7f3 100644 --- a/packages/openclaw/src/setup.test.ts +++ b/packages/openclaw/src/setup.test.ts @@ -1,9 +1,3 @@ -import { - installChannelActionsContractSuite, - installChannelPluginContractSuite, - installChannelSetupContractSuite, - installChannelStatusContractSuite, -} from "openclaw/plugin-sdk/channel-test-helpers"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -23,11 +17,12 @@ import { beeperStatusAdapter, beeperSetupAdapter, beeperSetupWizard, - defaultBeeperChannelSettings, + defaultBeeperAccountSettings, getBeeperAccountSettings, getBeeperChannelSettings, isBeeperChannelConfigured, listBeeperAccountIds, + beeperAccountIdFromMatrixUserId, resolveBeeperAgentAccountId, resolveDefaultBeeperAccountId, setBeeperOpenClawPluginRuntime, @@ -43,14 +38,42 @@ const appserviceMocks = vi.hoisted(() => ({ vi.mock("./appservice", () => appserviceMocks); describe("OpenClaw Beeper official channel contracts", () => { - installChannelPluginContractSuite({ plugin: beeperChannelPlugin }); + it("satisfies the base channel plugin contract", () => { + expect(typeof beeperChannelPlugin.id).toBe("string"); + expect(beeperChannelPlugin.id.trim()).not.toBe(""); + expect(beeperChannelPlugin.meta.id).toBe(beeperChannelPlugin.id); + expect(beeperChannelPlugin.meta.label.trim()).not.toBe(""); + expect(beeperChannelPlugin.meta.selectionLabel.trim()).not.toBe(""); + expect(beeperChannelPlugin.meta.docsPath).toMatch(/^\/channels\//); + expect(beeperChannelPlugin.meta.blurb.trim()).not.toBe(""); + expect(beeperChannelPlugin.capabilities.chatTypes.length).toBeGreaterThan(0); + expect(typeof beeperChannelPlugin.config.listAccountIds).toBe("function"); + expect(typeof beeperChannelPlugin.config.resolveAccount).toBe("function"); + }); - installChannelActionsContractSuite({ - plugin: beeperChannelPlugin, - cases: [{ - name: "default Beeper message actions", - cfg: {}, - expectedActions: [ + it("exposes the base message actions contract", () => { + expect(beeperChannelPlugin.actions).toBeDefined(); + expect(typeof beeperChannelPlugin.actions?.describeMessageTool).toBe("function"); + }); + + it("actions contract: default Beeper message actions", () => { + const discovery = beeperChannelPlugin.actions?.describeMessageTool({ cfg: {} }) ?? null; + const actions = Array.isArray(discovery?.actions) ? [...discovery.actions] : []; + const capabilities = Array.isArray(discovery?.capabilities) ? discovery.capabilities : []; + expect(actions).toEqual([...new Set(actions)]); + expect(capabilities).toEqual([...new Set(capabilities)]); + expect([...actions].sort()).toEqual([ + "channel-edit", + "channel-info", + "delete", + "edit", + "mark_unread", + "react", + "read", + "send", + ]); + expect([...capabilities].sort()).toEqual([]); + for (const action of [ "channel-edit", "channel-info", "delete", @@ -59,75 +82,106 @@ describe("OpenClaw Beeper official channel contracts", () => { "react", "read", "send", - ], - }], + ] as const) { + expect(beeperChannelPlugin.actions?.supportsAction?.({ action })).toBe(true); + } }); - installChannelSetupContractSuite({ - plugin: beeperChannelPlugin, - cases: [{ - name: "non-login setup environment patch", + it("exposes the base setup contract", () => { + expect(beeperChannelPlugin.setup).toBeDefined(); + expect(typeof beeperChannelPlugin.setup?.applyAccountConfig).toBe("function"); + }); + + it("setup contract: non-login setup environment patch", () => { + const resolvedAccountId = + beeperChannelPlugin.setup?.resolveAccountId?.({ + cfg: {}, + accountId: "@alice:beeper.com", + input: { serverEnv: "staging" }, + }) ?? "@alice:beeper.com"; + expect(resolvedAccountId).toBe("@alice:beeper.com"); + expect(beeperChannelPlugin.setup?.validateInput?.({ + accountId: "@alice:beeper.com", cfg: {}, input: { serverEnv: "staging", }, - assertPatchedConfig: (cfg) => { - expect(getBeeperChannelSettings(cfg)).toMatchObject({ - serverEnv: "staging", - enabled: true, - }); - }, - assertResolvedAccount: (account) => { - expect(account).toMatchObject({ - accountId: "default", - configured: false, - }); + }) ?? null).toBeNull(); + + const cfg = beeperChannelPlugin.setup?.applyAccountConfig({ + accountId: "@alice:beeper.com", + cfg: {}, + input: { + serverEnv: "staging", }, - }], + }); + expect(cfg).toBeDefined(); + expect(getBeeperAccountSettings(cfg!, "@alice:beeper.com")).toMatchObject({ + serverEnv: "staging", + enabled: true, + }); + expect(beeperChannelPlugin.config.resolveAccount(cfg!, "@alice:beeper.com")).toMatchObject({ + accountId: "@alice:beeper.com", + configured: false, + }); }); - installChannelStatusContractSuite({ - plugin: beeperChannelPlugin, - cases: [ - { - name: "configured account", - cfg: applyBeeperChannelSettings({}, { - enabled: true, - asToken: "as", - hsToken: "hs", - bridge: { - homeserver: "https://matrix.example", - matrixDeviceId: "DEV", - matrixUserId: "@alice:example", - }, - }), - expectedState: "configured", - runtime: { accountId: "default", configured: true, enabled: true, running: true }, - resolveStateInput: { configured: true, enabled: true }, - assertSnapshot: (snapshot) => { - expect(snapshot).toMatchObject({ - accountId: "default", - configured: true, - enabled: true, - name: "Beeper", - running: true, - }); - }, - assertSummary: (summary) => { - expect(summary).toMatchObject({ - configured: true, - enabled: true, - running: true, - }); - }, - }, - { - name: "disabled account", - cfg: applyBeeperChannelSettings({}, { enabled: false }), - expectedState: "disabled", - resolveStateInput: { configured: false, enabled: false }, + it("exposes the base status contract", () => { + expect(beeperChannelPlugin.status).toBeDefined(); + expect(typeof beeperChannelPlugin.status?.buildAccountSnapshot).toBe("function"); + }); + + it("status contract: configured account", async () => { + const cfg = applyBeeperAccountSettings({}, "@alice:beeper.com", { + enabled: true, + asToken: "as", + hsToken: "hs", + bridge: { + homeserver: "https://matrix.example", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", }, - ], + }); + const account = beeperChannelPlugin.config.resolveAccount(cfg, "@alice:beeper.com"); + const snapshot = await beeperChannelPlugin.status!.buildAccountSnapshot!({ + account, + cfg, + runtime: { accountId: "@alice:beeper.com", configured: true, enabled: true, running: true }, + }); + expect(snapshot).toMatchObject({ + accountId: "@alice:beeper.com", + configured: true, + enabled: true, + name: "Beeper", + running: true, + }); + expect(beeperChannelPlugin.status!.buildChannelSummary!({ + account, + cfg, + defaultAccountId: "@alice:beeper.com", + snapshot, + })).toMatchObject({ + configured: true, + enabled: true, + running: true, + }); + expect(beeperChannelPlugin.status!.resolveAccountState!({ + account, + cfg, + configured: true, + enabled: true, + })).toBe("configured"); + }); + + it("status contract: disabled account", () => { + const cfg = applyBeeperAccountSettings({}, "@alice:beeper.com", { enabled: false }); + const account = beeperChannelPlugin.config.resolveAccount(cfg, "@alice:beeper.com"); + expect(beeperChannelPlugin.status!.resolveAccountState!({ + account, + cfg, + configured: false, + enabled: false, + })).toBe("disabled"); }); }); @@ -160,9 +214,9 @@ describe("OpenClaw Beeper setup surface", () => { stopAccount: expect.any(Function), }, uiHints: expect.objectContaining({ - asToken: expect.objectContaining({ sensitive: true, tags: ["hidden"] }), - hsToken: expect.objectContaining({ sensitive: true, tags: ["hidden"] }), - serverEnv: expect.objectContaining({ + "accounts.*.asToken": expect.objectContaining({ sensitive: true, tags: ["hidden"] }), + "accounts.*.hsToken": expect.objectContaining({ sensitive: true, tags: ["hidden"] }), + "accounts.*.serverEnv": expect.objectContaining({ help: expect.stringContaining("Choose before Beeper login"), }), }), @@ -351,12 +405,12 @@ describe("OpenClaw Beeper setup surface", () => { expect(beeperChannelPlugin.status).toBe(beeperStatusAdapter); const cfg = beeperSetupAdapter.applyAccountConfig({ - accountId: "default", + accountId: "@alice:beeper.com", cfg: {}, input: {}, }); expect(cfg).not.toHaveProperty("then"); - expect(getBeeperChannelSettings(cfg)).toMatchObject({ enabled: true }); + expect(getBeeperAccountSettings(cfg, "@alice:beeper.com")).toMatchObject({ enabled: true }); }); it("starts the Beeper bridge from OpenClaw gateway lifecycle and stops on abort", async () => { @@ -369,7 +423,7 @@ describe("OpenClaw Beeper setup surface", () => { session: { recordInboundSession: vi.fn() }, inbound: { buildContext: vi.fn(), dispatchReply: vi.fn() }, }; - const cfg = applyBeeperChannelSettings({}, { + const cfg = applyBeeperAccountSettings({}, "@alice:example", { asToken: "as", dataDir: "/tmp/openclaw-beeper", enabled: true, @@ -383,7 +437,7 @@ describe("OpenClaw Beeper setup surface", () => { const task = startBeeperGatewayAccount({ abortSignal: abort.signal, - accountId: "default", + accountId: "@alice:example", cfg, channelRuntime, setStatus: (next) => statuses.push(next), @@ -412,7 +466,7 @@ describe("OpenClaw Beeper setup surface", () => { const stop = vi.fn(async () => undefined); appserviceMocks.startOpenClawBeeperBridge.mockResolvedValueOnce({ stop }); const abort = new AbortController(); - const cfg = applyBeeperChannelSettings({}, { + const cfg = applyBeeperAccountSettings({}, "@alice:example", { asToken: "as", dataDir: "/tmp/openclaw-beeper", enabled: true, @@ -425,7 +479,7 @@ describe("OpenClaw Beeper setup surface", () => { }); const ctx = { abortSignal: abort.signal, - accountId: "default", + accountId: "@alice:example", cfg, } as never; @@ -442,8 +496,8 @@ describe("OpenClaw Beeper setup surface", () => { it("rejects gateway startup until Beeper setup has complete credentials", async () => { await expect(startBeeperGatewayAccount({ abortSignal: new AbortController().signal, - accountId: "default", - cfg: applyBeeperChannelSettings({}, { + accountId: "@alice:example", + cfg: applyBeeperAccountSettings({}, "@alice:example", { enabled: true, }), })).rejects.toThrow("not fully configured"); @@ -459,23 +513,23 @@ describe("OpenClaw Beeper setup surface", () => { it("applies dashboard setup input into non-login channels.beeper settings", async () => { const cfg = await beeperSetupAdapter.applyAccountConfig({ - accountId: "default", + accountId: "@alice:beeper.com", cfg: {}, input: { serverEnv: "staging", }, }); - expect(getBeeperChannelSettings(cfg)).toEqual({ + expect(getBeeperAccountSettings(cfg, "@alice:beeper.com")).toEqual({ enabled: true, serverEnv: "staging", }); - expect(isBeeperChannelConfigured(cfg)).toBe(false); + expect(isBeeperChannelConfigured(cfg, "@alice:beeper.com")).toBe(false); expect(cfg.plugins?.entries?.beeper).toBeUndefined(); }); it("keeps async Beeper login out of the synchronous OpenClaw setup adapter", () => { expect(() => beeperSetupAdapter.applyAccountConfig({ - accountId: "default", + accountId: "@alice:beeper.com", cfg: {}, input: { email: "alice@example.com", @@ -483,7 +537,7 @@ describe("OpenClaw Beeper setup surface", () => { })).toThrow("Beeper login runs through"); expect(() => beeperSetupAdapter.applyAccountConfig({ - accountId: "default", + accountId: "@alice:beeper.com", cfg: {}, input: { password: "secret", @@ -559,8 +613,8 @@ describe("OpenClaw Beeper setup surface", () => { }, }); const cfg = result.cfg; - expect(result.accountId).toBe("default"); - expect(getBeeperChannelSettings(cfg)).toMatchObject({ + expect(result.accountId).toBe("@alice:example"); + expect(getBeeperAccountSettings(cfg, "@alice:example")).toMatchObject({ enabled: true, asToken: "as", bridge: { @@ -575,7 +629,7 @@ describe("OpenClaw Beeper setup surface", () => { it("infers generated bridge settings from username/password setup input", async () => { const { applyBeeperSetupConfig } = await import("./setup"); - const cfg = await applyBeeperSetupConfig({ + const result = await applyBeeperSetupConfig({ cfg: {}, input: { password: "secret", @@ -617,7 +671,8 @@ describe("OpenClaw Beeper setup surface", () => { }, }, }); - expect(getBeeperChannelSettings(cfg)).toMatchObject({ + expect(result.accountId).toBe("@alice:example"); + expect(getBeeperAccountSettings(result.cfg, "@alice:example")).toMatchObject({ asToken: "as", bridge: { appserviceId: "sh-openclaw-dev", @@ -632,10 +687,10 @@ describe("OpenClaw Beeper setup surface", () => { }); it("does not report configured until login, appservice, and gateway details are present", async () => { - expect(isBeeperChannelConfigured(applyBeeperChannelSettings({}, { + expect(isBeeperChannelConfigured(applyBeeperAccountSettings({}, "@alice:example", { enabled: true, - }))).toBe(false); - const cfg = applyBeeperChannelSettings({}, { + }), "@alice:example")).toBe(false); + const cfg = applyBeeperAccountSettings({}, "@alice:example", { asToken: "as", enabled: true, hsToken: "hs", @@ -645,12 +700,12 @@ describe("OpenClaw Beeper setup surface", () => { matrixUserId: "@alice:example", }, }); - expect(isBeeperChannelConfigured(cfg)).toBe(true); + expect(isBeeperChannelConfigured(cfg, "@alice:example")).toBe(true); }); it("applies setup input through the channel setup adapter implementation", async () => { const { applyBeeperSetupConfig } = await import("./setup"); - const cfg = await applyBeeperSetupConfig({ + const result = await applyBeeperSetupConfig({ cfg: {}, input: { email: "alice@example.com", @@ -693,7 +748,8 @@ describe("OpenClaw Beeper setup surface", () => { }, }, }); - expect(getBeeperChannelSettings(cfg)).toMatchObject({ + expect(result.accountId).toBe("@alice:example"); + expect(getBeeperAccountSettings(result.cfg, "@alice:example")).toMatchObject({ enabled: true, asToken: "as", bridge: { @@ -707,12 +763,13 @@ describe("OpenClaw Beeper setup surface", () => { }); }); - it("defaults new setup to owned chats only", async () => { - expect(defaultBeeperChannelSettings()).toMatchObject({ + it("defaults new account setup to owned chats only", async () => { + expect(defaultBeeperAccountSettings()).toMatchObject({ enabled: true, }); - const configured = await beeperSetupWizard.configure({ cfg: {} }); - expect(getBeeperChannelSettings(configured.cfg)).toMatchObject({ + const configured = await beeperSetupWizard.configure({ accountId: "@alice:beeper.com", cfg: {} }); + expect(configured.accountId).toBe("@alice:beeper.com"); + expect(getBeeperAccountSettings(configured.cfg, "@alice:beeper.com")).toMatchObject({ enabled: true, }); }); @@ -721,7 +778,7 @@ describe("OpenClaw Beeper setup surface", () => { expect(validateBeeperSetupInput({ email: "not-email" })).toContain("valid email"); expect(validateBeeperSetupInput({ username: "alice" })).toContain("requires both"); expect(validateBeeperSetupInput({ email: "alice@example.com", username: "alice", password: "secret" })).toContain("only one"); - const cfg = applyBeeperChannelSettings({}, { + const cfg = applyBeeperAccountSettings({}, "@alice:beeper.com", { enabled: true, }); await expect(beeperSetupWizard.getStatus({ cfg })).resolves.toMatchObject({ @@ -729,13 +786,14 @@ describe("OpenClaw Beeper setup surface", () => { configured: false, quickstartScore: 20, statusLines: expect.arrayContaining([ + "Account: @alice:beeper.com", "Server environment: prod", ]), }); }); it("reports read-only Beeper login identity after setup", async () => { - const cfg = applyBeeperChannelSettings({}, { + const cfg = applyBeeperAccountSettings({}, "@alice:example", { asToken: "as", bridge: { homeserver: "https://matrix.example", @@ -759,13 +817,13 @@ describe("OpenClaw Beeper setup surface", () => { }); it("reports lightweight channel status without starting bridge runtime", () => { - const account = beeperChannelConfig.resolveAccount(applyBeeperChannelSettings({}, { + const account = beeperChannelConfig.resolveAccount(applyBeeperAccountSettings({}, "@alice:beeper.com", { enabled: true, })); const snapshot = beeperStatusAdapter.buildAccountSnapshot({ account }); expect(snapshot).toMatchObject({ - accountId: "default", + accountId: "@alice:beeper.com", configured: false, enabled: true, running: false, @@ -788,16 +846,20 @@ describe("OpenClaw Beeper setup surface", () => { const cfg = createConfigFromOpenClawSetup({ channels: { beeper: { - hsToken: "hs", - dataDir: "/tmp/beeper", - bridge: { - homeserver: "https://matrix.example", - matrixDeviceId: "DEV", - matrixUserId: "@alice:example", + accounts: { + "@alice:example": { + hsToken: "hs", + dataDir: "/tmp/beeper", + bridge: { + homeserver: "https://matrix.example", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + }, + }, }, }, }, - }); + }, {}, "@alice:example"); expect(cfg).toMatchObject({ dataDir: "/tmp/beeper", homeserver: "https://matrix.example", @@ -973,7 +1035,7 @@ describe("OpenClaw Beeper setup surface", () => { setBeeperOpenClawPluginRuntime(undefined); await expect(beeperChannelPlugin.directory.listPeers({ - cfg: { channels: { beeper: { dataDir } } } as OpenClawSetupConfig, + cfg: { channels: { beeper: { accounts: { "@alice:beeper.com": { dataDir } } } } } as OpenClawSetupConfig, query: "helpful", })).resolves.toEqual([{ avatarUrl: "mxc://avatar", @@ -992,11 +1054,15 @@ describe("OpenClaw Beeper setup surface", () => { }]); }); - it("reads plugin-entry channel config with channels.beeper taking precedence", () => { + it("reads plugin-entry channel account config", () => { expect(getBeeperChannelSettings({ channels: { beeper: { - serverEnv: "staging", + accounts: { + "@alice:beeper.com": { + serverEnv: "staging", + }, + }, }, }, plugins: { @@ -1009,10 +1075,28 @@ describe("OpenClaw Beeper setup surface", () => { }, }, })).toEqual({ + accounts: { + "@alice:beeper.com": { + serverEnv: "staging", + }, + }, + }); + + expect(getBeeperAccountSettings({ + channels: { + beeper: { + accounts: { + "@alice:beeper.com": { + serverEnv: "staging", + }, + }, + }, + }, + }, "@alice:beeper.com")).toEqual({ serverEnv: "staging", }); - expect(createConfigFromOpenClawSetup({ plugins: { entries: { beeper: { config: { enabled: true } } } } })).toMatchObject({ + expect(createConfigFromOpenClawSetup({ plugins: { entries: { beeper: { config: {} } } } })).toMatchObject({ appserviceId: "sh-openclaw", }); }); @@ -1021,31 +1105,25 @@ describe("OpenClaw Beeper setup surface", () => { const cfg = applyBeeperAccountSettings({ channels: { beeper: { - defaultAccount: "work", - dataDir: "/legacy/default", - serverEnv: "prod", + defaultAccount: "@work:beeper.com", }, }, - } as OpenClawSetupConfig, "work", { + } as OpenClawSetupConfig, "work:beeper.com", { dataDir: "/work", enabled: true, name: "Work Beeper", serverEnv: "staging", }); - expect(listBeeperAccountIds(cfg)).toEqual(["default", "work"]); - expect(resolveDefaultBeeperAccountId(cfg)).toBe("work"); - expect(getBeeperAccountSettings(cfg, "default")).toMatchObject({ - dataDir: "/legacy/default", - serverEnv: "prod", - }); - expect(getBeeperAccountSettings(cfg, "work")).toMatchObject({ + expect(listBeeperAccountIds(cfg)).toEqual(["@work:beeper.com"]); + expect(resolveDefaultBeeperAccountId(cfg)).toBe("@work:beeper.com"); + expect(getBeeperAccountSettings(cfg, "@work:beeper.com")).toMatchObject({ dataDir: "/work", name: "Work Beeper", serverEnv: "staging", }); - expect(beeperChannelConfig.resolveAccount(cfg, "work")).toMatchObject({ - accountId: "work", + expect(beeperChannelConfig.resolveAccount(cfg, "work:beeper.com")).toMatchObject({ + accountId: "@work:beeper.com", settings: { name: "Work Beeper" }, }); }); @@ -1054,30 +1132,36 @@ describe("OpenClaw Beeper setup surface", () => { const cfg = { channels: { beeper: { - defaultAccount: "personal", + defaultAccount: "@personal:beeper.com", accounts: { - personal: { enabled: true }, - work: { enabled: true }, - alerts: { enabled: true }, + "@personal:beeper.com": { enabled: true }, + "@work:beeper.com": { enabled: true }, + "@alerts:beeper.com": { enabled: true }, }, agents: { codex: { - accountIds: ["work", "alerts"], - defaultAccount: "alerts", + accountIds: ["@work:beeper.com", "alerts:beeper.com"], + defaultAccount: "@alerts:beeper.com", }, helper: { - accountIds: ["work"], + accountIds: ["work:beeper.com"], }, }, }, }, } as OpenClawSetupConfig; - expect(resolveBeeperAgentAccountId(cfg, "codex")).toBe("alerts"); - expect(resolveBeeperAgentAccountId(cfg, "codex", "work")).toBe("work"); - expect(resolveBeeperAgentAccountId(cfg, "helper")).toBe("work"); - expect(resolveBeeperAgentAccountId(cfg, "unassigned")).toBe("personal"); - expect(() => resolveBeeperAgentAccountId(cfg, "helper", "alerts")).toThrow(/not assigned/); + expect(resolveBeeperAgentAccountId(cfg, "codex")).toBe("@alerts:beeper.com"); + expect(resolveBeeperAgentAccountId(cfg, "codex", "work:beeper.com")).toBe("@work:beeper.com"); + expect(resolveBeeperAgentAccountId(cfg, "helper")).toBe("@work:beeper.com"); + expect(resolveBeeperAgentAccountId(cfg, "unassigned")).toBe("@personal:beeper.com"); + expect(() => resolveBeeperAgentAccountId(cfg, "helper", "alerts:beeper.com")).toThrow(/not assigned/); + }); + + it("derives account ids from Beeper Matrix user ids", () => { + expect(beeperAccountIdFromMatrixUserId("@alice:beeper.com")).toBe("@alice:beeper.com"); + expect(beeperAccountIdFromMatrixUserId("Alice.Work:beeper.com")).toBe("@Alice.Work:beeper.com"); + expect(beeperAccountIdFromMatrixUserId("batuhan")).toBeUndefined(); }); }); diff --git a/packages/openclaw/src/setup.ts b/packages/openclaw/src/setup.ts index 2d94bc3..9d2bfa2 100644 --- a/packages/openclaw/src/setup.ts +++ b/packages/openclaw/src/setup.ts @@ -5,6 +5,7 @@ import type { ChannelAccountSnapshot, ChannelCapabilities, ChannelGatewayContext import type { SecretInput } from "openclaw/plugin-sdk/secret-input-runtime"; import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input-runtime"; import type { BridgeLogger } from "@beeper/pickle-bridge/types"; +import { beeperAccountIdFromMatrixUserId, normalizeBeeperAccountId, requireBeeperAccountId } from "./account-id"; import { createConfigFromOpenClawSetup, createRuntimeConfigFromOpenClawSetup, defaultDataDir } from "./config"; import beeperChannelConfigSchema from "./beeper-channel-config.schema.json"; import type { setupOpenClawBeeperBridge, SetupOpenClawBeeperBridgeOptions } from "./beeper-setup"; @@ -14,18 +15,14 @@ import type { OpenClawHostRuntime } from "./openclaw-runtime"; import { OpenClawBridgeRegistry, defaultRegistryPath } from "./registry"; import type { BeeperServerEnv } from "./types"; +export { beeperAccountIdFromMatrixUserId } from "./account-id"; + export type OpenClawSetupConfig = OpenClawConfig; export interface BeeperChannelSettings { accounts?: Record; - asToken?: SecretInput; - bridge?: BeeperGeneratedBridgeSettings; - dataDir?: string; defaultAccount?: string; - enabled?: boolean; - hsToken?: SecretInput; agents?: Record; - serverEnv?: BeeperServerEnv; } export interface BeeperAccountSettings { @@ -127,9 +124,9 @@ function requireBeeperChannelRuntime() { export const BeeperChannelConfigSchema = beeperChannelConfigSchema; export const BeeperChannelUiHints = { - asToken: { sensitive: true, tags: ["hidden"] as string[] }, - hsToken: { sensitive: true, tags: ["hidden"] as string[] }, - serverEnv: { + "accounts.*.asToken": { sensitive: true, tags: ["hidden"] as string[] }, + "accounts.*.hsToken": { sensitive: true, tags: ["hidden"] as string[] }, + "accounts.*.serverEnv": { help: "Choose before Beeper login. To change it after connecting, log out and log back in.", }, } as const; @@ -618,9 +615,9 @@ export const beeperAgentPromptAdapter = { } as const; export const beeperSetupAdapter = { - resolveAccountId: ({ accountId }: { accountId?: string | null } = {}) => normalizeAccountId(accountId) ?? "default", - resolveBindingAccountId: ({ accountId, agentId }: { accountId?: string | null; agentId?: string | null; cfg?: OpenClawSetupConfig } = {}) => - normalizeAccountId(accountId) ?? normalizeAccountId(agentId) ?? "default", + resolveAccountId: ({ accountId }: { accountId?: string | null } = {}) => normalizeBeeperAccountId(accountId) ?? "", + resolveBindingAccountId: ({ accountId, cfg }: { accountId?: string | null; agentId?: string | null; cfg?: OpenClawSetupConfig } = {}) => + normalizeBeeperAccountId(accountId) ?? (cfg ? resolveDefaultBeeperAccountId(cfg) : undefined) ?? "", applyAccountName: ({ cfg }: { cfg: OpenClawSetupConfig }) => cfg, validateInput: ({ input }: { input: BeeperSetupInput }) => validateBeeperSetupInput(input), applyAccountConfig: ({ @@ -629,21 +626,21 @@ export const beeperSetupAdapter = { input, }: { cfg: OpenClawSetupConfig; - accountId: string; + accountId?: string | null; input: BeeperSetupInput; runtime?: BeeperSetupRuntime; }): OpenClawSetupConfig => { if (input.email || input.username || input.password) { throw new Error("Beeper login runs through OpenClaw channel setup."); } - return applyBeeperAccountSettings(cfg, accountId, normalizeBeeperSetupInput(input)); + return applyBeeperAccountSettings(cfg, requireBeeperAccountId(accountId), normalizeBeeperSetupInput(input)); }, }; export const beeperSetupWizard = { channel: BEEPER_CHANNEL_ID, - async getStatus(ctx: { cfg: OpenClawSetupConfig }) { - const accountId = "default"; + async getStatus(ctx: { accountId?: string | null; cfg: OpenClawSetupConfig }) { + const accountId = resolveSetupStatusAccountId(ctx.cfg, ctx.accountId); const settings = getBeeperAccountSettings(ctx.cfg, accountId); const configured = isBeeperChannelConfigured(ctx.cfg, accountId); const serverEnv = settings.serverEnv ?? "prod"; @@ -653,6 +650,7 @@ export const beeperSetupWizard = { configured, statusLines: [ `Connected: ${configured ? "yes" : "no"}`, + `Account: ${accountId || "not configured"}`, `Server environment: ${serverEnv}${configured ? " (change requires logout and login)" : ""}`, ...(configured && bridge?.matrixUserId ? [`Beeper user: ${bridge.matrixUserId}`] : []), ...(configured && bridge?.homeserverDomain ? [`Homeserver: ${bridge.homeserverDomain}`] : []), @@ -664,23 +662,27 @@ export const beeperSetupWizard = { quickstartScore: configured ? 100 : 20, }; }, - async configure(ctx: { cfg: OpenClawSetupConfig }) { + async configure(ctx: { accountId?: string | null; cfg: OpenClawSetupConfig }) { + const accountId = requireBeeperAccountId(ctx.accountId); return { - accountId: "default", - cfg: applyBeeperChannelSettings(ctx.cfg, defaultBeeperChannelSettings()), + accountId, + cfg: applyBeeperAccountSettings(ctx.cfg, accountId, defaultBeeperAccountSettings()), }; }, async configureInteractive(ctx: { + accountId?: string | null; cfg: OpenClawSetupConfig; runtime?: unknown; prompter: BeeperWizardPrompter; }) { + const requestedAccountId = normalizeBeeperAccountId(ctx.accountId); + const currentAccountId = requestedAccountId ?? resolveDefaultBeeperAccountId(ctx.cfg); const current = { - ...defaultBeeperChannelSettings(), - ...getBeeperAccountSettings(ctx.cfg, "default"), + ...defaultBeeperAccountSettings(), + ...(currentAccountId ? getBeeperAccountSettings(ctx.cfg, currentAccountId) : {}), }; - if (isBeeperChannelConfigured(ctx.cfg, "default")) { - throw new Error("Beeper account \"default\" is already connected. Add another account or log out before changing its Beeper server environment or login account."); + if (requestedAccountId && isBeeperChannelConfigured(ctx.cfg, requestedAccountId)) { + throw new Error(`Beeper account "${requestedAccountId}" is already connected. Add another account or log out before changing its Beeper server environment or login account.`); } const serverEnv = await ctx.prompter.select({ message: "Beeper server environment", @@ -743,32 +745,36 @@ export const beeperSetupWizard = { cfg: ctx.cfg, input, }; + if (requestedAccountId) setupParams.accountId = requestedAccountId; const setupRuntime = beeperSetupRuntime(ctx.runtime); if (setupRuntime) setupParams.runtime = setupRuntime; - const cfg = await applyBeeperSetupConfig(setupParams); + const result = await applyBeeperSetupConfig(setupParams); progress?.stop("Beeper bridge configured"); - return { accountId: "default", cfg }; + return result; } catch (error) { progress?.stop("Beeper bridge setup failed"); throw error; } }, - disable: (cfg: OpenClawSetupConfig) => applyBeeperChannelSettings(cfg, { enabled: false }), + disable: (cfg: OpenClawSetupConfig) => disableAllBeeperAccounts(cfg), }; export const beeperChannelConfig = { listAccountIds: (cfg: OpenClawSetupConfig) => listBeeperAccountIds(cfg), - defaultAccountId: (cfg: OpenClawSetupConfig) => resolveDefaultBeeperAccountId(cfg), - resolveAccount: (cfg: OpenClawSetupConfig, accountId?: string | null) => ({ - accountId: normalizeAccountId(accountId) ?? resolveDefaultBeeperAccountId(cfg), - configured: isBeeperChannelConfigured(cfg, normalizeAccountId(accountId) ?? resolveDefaultBeeperAccountId(cfg)), - settings: getBeeperAccountSettings(cfg, normalizeAccountId(accountId) ?? resolveDefaultBeeperAccountId(cfg)), - }), + defaultAccountId: (cfg: OpenClawSetupConfig) => resolveDefaultBeeperAccountId(cfg) ?? "", + resolveAccount: (cfg: OpenClawSetupConfig, accountId?: string | null) => { + const resolvedAccountId = normalizeBeeperAccountId(accountId) ?? resolveDefaultBeeperAccountId(cfg); + return { + accountId: resolvedAccountId ?? "", + configured: resolvedAccountId ? isBeeperChannelConfigured(cfg, resolvedAccountId) : false, + settings: resolvedAccountId ? getBeeperAccountSettings(cfg, resolvedAccountId) : {}, + }; + }, isEnabled: (account: { settings?: BeeperAccountSettings }) => account.settings?.enabled !== false, isConfigured: (account: { configured?: boolean }) => account.configured === true, hasConfiguredState: ({ cfg }: { cfg: OpenClawSetupConfig }) => isBeeperChannelConfigured(cfg), describeAccount: (account: { accountId?: string; configured?: boolean; settings?: BeeperAccountSettings }) => ({ - accountId: "accountId" in account && typeof account.accountId === "string" ? account.accountId : "default", + accountId: "accountId" in account && typeof account.accountId === "string" ? account.accountId : "", name: account.settings?.name ?? "Beeper", configured: account.configured === true, }), @@ -776,7 +782,7 @@ export const beeperChannelConfig = { export const beeperStatusAdapter = { defaultRuntime: { - accountId: "default", + accountId: "", configured: false, enabled: false, running: false, @@ -788,10 +794,10 @@ export const beeperStatusAdapter = { homeserver: recordValue(snapshot.extra)?.homeserver, running: snapshot.running === true, }), - buildAccountSnapshot: ({ account, runtime }: { account: { accountId?: string; configured?: boolean; settings?: BeeperChannelSettings }; runtime?: Record }) => { + buildAccountSnapshot: ({ account, runtime }: { account: { accountId?: string; configured?: boolean; settings?: BeeperAccountSettings }; runtime?: Record }) => { const settings = account.settings ?? {}; return { - accountId: account.accountId ?? "default", + accountId: account.accountId ?? "", configured: account.configured === true, enabled: settings.enabled !== false, extra: { @@ -811,7 +817,7 @@ export const beeperStatusAdapter = { accounts .filter((account) => account.enabled !== false && account.configured !== true) .map((account) => ({ - accountId: "accountId" in account && typeof account.accountId === "string" ? account.accountId : "default", + accountId: "accountId" in account && typeof account.accountId === "string" ? account.accountId : "", channel: BEEPER_CHANNEL_ID, kind: "config" as const, message: "Beeper is not connected; run Beeper setup with an existing Beeper account.", @@ -826,14 +832,17 @@ export async function applyBeeperSetupConfig(params: { cfg: OpenClawSetupConfig; input: BeeperSetupInput; runtime?: BeeperSetupRuntime; -}): Promise { +}): Promise<{ accountId: string; cfg: OpenClawSetupConfig }> { const baseSettings = normalizeBeeperSetupInput(params.input); - const accountId = normalizeAccountId(params.accountId) ?? "default"; - if (!params.input.email && !params.input.username && !params.input.password) return applyBeeperAccountSettings(params.cfg, accountId, baseSettings); + const requestedAccountId = normalizeBeeperAccountId(params.accountId); + if (!params.input.email && !params.input.username && !params.input.password) { + const accountId = requireBeeperAccountId(requestedAccountId); + return { accountId, cfg: applyBeeperAccountSettings(params.cfg, accountId, baseSettings) }; + } const setupBridge = params.runtime?.setupBridge ?? (await loadBeeperSetupBridge()); const bridgeOptions = setupOptionsFromInput(params.input); const result = await setupBridge(bridgeOptions); - const setupSettings: Partial = { + const setupSettings: Partial = { ...baseSettings, enabled: true, }; @@ -847,7 +856,9 @@ export async function applyBeeperSetupConfig(params: { if (result.config.matrixDeviceId) bridgeSettings.matrixDeviceId = result.config.matrixDeviceId; if (result.config.matrixUserId) bridgeSettings.matrixUserId = result.config.matrixUserId; setupSettings.bridge = bridgeSettings; - return applyBeeperAccountSettings(params.cfg, accountId, setupSettings); + const accountId = requestedAccountId ?? beeperAccountIdFromMatrixUserId(result.config.matrixUserId); + if (!accountId) throw new Error("Beeper setup did not return a Matrix user ID for the configured account."); + return { accountId, cfg: applyBeeperAccountSettings(params.cfg, accountId, setupSettings) }; } async function loadBeeperSetupBridge(): Promise { @@ -879,7 +890,7 @@ const BeeperChannelCapabilities: ChannelCapabilities = { type BeeperResolvedAccount = { accountId: string; configured: boolean; - settings: BeeperChannelSettings; + settings: BeeperAccountSettings; }; export const beeperChannelPlugin: ChannelPlugin & { uiHints: typeof BeeperChannelUiHints } = { @@ -1325,58 +1336,50 @@ export function getBeeperChannelSettings(cfg: OpenClawSetupConfig): BeeperChanne export function getBeeperAccountSettings(cfg: OpenClawSetupConfig, accountId?: string | null): BeeperAccountSettings { const channelSettings = getBeeperChannelSettings(cfg); - const normalized = normalizeAccountId(accountId) ?? resolveDefaultBeeperAccountId(cfg); - const accountSettings = recordValue(channelSettings.accounts?.[normalized]) as BeeperAccountSettings | undefined; - const legacyDefaults: BeeperAccountSettings = { - ...(channelSettings.asToken !== undefined ? { asToken: channelSettings.asToken } : {}), - ...(channelSettings.bridge !== undefined ? { bridge: channelSettings.bridge } : {}), - ...(channelSettings.dataDir !== undefined ? { dataDir: channelSettings.dataDir } : {}), - ...(channelSettings.enabled !== undefined ? { enabled: channelSettings.enabled } : {}), - ...(channelSettings.hsToken !== undefined ? { hsToken: channelSettings.hsToken } : {}), - ...(channelSettings.serverEnv !== undefined ? { serverEnv: channelSettings.serverEnv } : {}), - }; - if (normalized === "default") return { ...legacyDefaults, ...(accountSettings ?? {}) }; - return { ...(accountSettings ?? {}) }; + const normalized = normalizeBeeperAccountId(accountId) ?? resolveDefaultBeeperAccountId(cfg); + if (!normalized) return {}; + return { ...(getNamedBeeperAccountSettings(channelSettings, normalized) ?? {}) }; } export function listBeeperAccountIds(cfg: OpenClawSetupConfig): string[] { const settings = getBeeperChannelSettings(cfg); const ids = new Set(); - if (hasLegacyBeeperAccountSettings(settings)) ids.add("default"); for (const id of Object.keys(settings.accounts ?? {})) { - const normalized = normalizeAccountId(id); + const normalized = normalizeBeeperAccountId(id); if (normalized) ids.add(normalized); } - if (ids.size === 0) ids.add("default"); return [...ids]; } -export function resolveDefaultBeeperAccountId(cfg: OpenClawSetupConfig): string { +export function resolveDefaultBeeperAccountId(cfg: OpenClawSetupConfig): string | undefined { const settings = getBeeperChannelSettings(cfg); - const configured = normalizeAccountId(settings.defaultAccount); + const configured = normalizeBeeperAccountId(settings.defaultAccount); if (configured && listBeeperAccountIds(cfg).includes(configured)) return configured; - return listBeeperAccountIds(cfg)[0] ?? "default"; + return listBeeperAccountIds(cfg)[0]; } export function resolveBeeperAgentAccountId(cfg: OpenClawSetupConfig, agentId: string, requestedAccountId?: string | null): string { - const requested = normalizeAccountId(requestedAccountId); + const requested = normalizeBeeperAccountId(requestedAccountId); const accountIds = listBeeperAccountIds(cfg); const agentSettings = getBeeperChannelSettings(cfg).agents?.[agentId]; - const allowed = (agentSettings?.accountIds ?? []).map(normalizeAccountId).filter((id): id is string => Boolean(id)); + const allowed = (agentSettings?.accountIds ?? []).map(normalizeBeeperAccountId).filter((id): id is string => Boolean(id)); if (requested) { if (allowed.length > 0 && !allowed.includes(requested)) { throw new Error(`Beeper account "${requested}" is not assigned to agent "${agentId}".`); } return requested; } - const preferred = normalizeAccountId(agentSettings?.defaultAccount); + const preferred = normalizeBeeperAccountId(agentSettings?.defaultAccount); if (preferred && (allowed.length === 0 || allowed.includes(preferred)) && accountIds.includes(preferred)) return preferred; const firstAllowed = allowed.find((id) => accountIds.includes(id)); if (firstAllowed) return firstAllowed; - return resolveDefaultBeeperAccountId(cfg); + const defaultAccountId = resolveDefaultBeeperAccountId(cfg); + if (!defaultAccountId) throw new Error("No Beeper accounts are configured."); + return defaultAccountId; } export function isBeeperChannelConfigured(cfg: OpenClawSetupConfig, accountId?: string | null): boolean { + if (!accountId) return listBeeperAccountIds(cfg).some((id) => isBeeperChannelConfigured(cfg, id)); const settings = getBeeperAccountSettings(cfg, accountId); const bridge = settings.bridge; return Boolean( @@ -1412,16 +1415,13 @@ export function applyBeeperAccountSettings( accountId: string, patch: Partial, ): OpenClawSetupConfig { - const normalized = normalizeAccountId(accountId) ?? "default"; + const normalized = requireBeeperAccountId(accountId); const current = getBeeperChannelSettings(cfg); - if (normalized === "default" && !current.accounts) { - return applyBeeperChannelSettings(cfg, patch); - } return applyBeeperChannelSettings(cfg, { accounts: { ...(current.accounts ?? {}), [normalized]: { - ...(current.accounts?.[normalized] ?? {}), + ...(getNamedBeeperAccountSettings(current, normalized) ?? {}), ...patch, }, }, @@ -1429,7 +1429,7 @@ export function applyBeeperAccountSettings( }); } -export function defaultBeeperChannelSettings(): BeeperChannelSettings { +export function defaultBeeperAccountSettings(): BeeperAccountSettings { return { dataDir: defaultDataDir(), enabled: true, @@ -1448,8 +1448,8 @@ export function validateBeeperSetupInput(input: BeeperSetupInput): string | null return null; } -export function normalizeBeeperSetupInput(input: BeeperSetupInput): Partial { - const settings: Partial = { enabled: true }; +export function normalizeBeeperSetupInput(input: BeeperSetupInput): Partial { + const settings: Partial = { enabled: true }; const serverEnv = normalizeServerEnv(input.serverEnv); if (serverEnv) settings.serverEnv = serverEnv; if (input.dataDir) settings.dataDir = input.dataDir; @@ -1470,18 +1470,39 @@ export function setupOptionsFromInput(input: BeeperSetupInput): SetupOpenClawBee return options; } -function normalizeServerEnv(value: string | undefined): BeeperChannelSettings["serverEnv"] | undefined { +function normalizeServerEnv(value: string | undefined): BeeperAccountSettings["serverEnv"] | undefined { if (value === "prod" || value === "staging" || value === "dev" || value === "local") return value; return undefined; } -function normalizeAccountId(value: string | null | undefined): string | undefined { - const trimmed = value?.trim().toLowerCase(); - return trimmed ? trimmed.replace(/[^a-z0-9_.-]+/gu, "-").replace(/^-+|-+$/gu, "") || undefined : undefined; +function getNamedBeeperAccountSettings(settings: BeeperChannelSettings, accountId: string): BeeperAccountSettings | undefined { + const direct = recordValue(settings.accounts?.[accountId]) as BeeperAccountSettings | undefined; + if (direct) return direct; + if (accountId.startsWith("@")) return recordValue(settings.accounts?.[accountId.slice(1)]) as BeeperAccountSettings | undefined; + if (accountId.includes(":")) return recordValue(settings.accounts?.[`@${accountId}`]) as BeeperAccountSettings | undefined; + return undefined; +} + +function resolveSetupStatusAccountId(cfg: OpenClawSetupConfig, accountId?: string | null): string { + const normalized = normalizeBeeperAccountId(accountId); + if (normalized) return normalized; + const accountIds = listBeeperAccountIds(cfg); + const configured = accountIds.find((id) => isBeeperChannelConfigured(cfg, id)); + return configured ?? resolveDefaultBeeperAccountId(cfg) ?? ""; } -function hasLegacyBeeperAccountSettings(settings: BeeperChannelSettings): boolean { - return Boolean(settings.asToken || settings.bridge || settings.dataDir || settings.hsToken || settings.serverEnv || settings.enabled !== undefined); +function disableAllBeeperAccounts(cfg: OpenClawSetupConfig): OpenClawSetupConfig { + const current = getBeeperChannelSettings(cfg); + const accounts = Object.fromEntries( + listBeeperAccountIds(cfg).map((accountId) => [ + accountId, + { + ...getBeeperAccountSettings(cfg, accountId), + enabled: false, + }, + ]), + ); + return applyBeeperChannelSettings(cfg, { accounts }); } function beeperSetupRuntime(value: unknown): BeeperSetupRuntime | undefined { @@ -1492,7 +1513,7 @@ function beeperSetupRuntime(value: unknown): BeeperSetupRuntime | undefined { } function gatewayAccountKey(accountId: string): string { - return accountId || "default"; + return requireBeeperAccountId(accountId); } function waitForAbort(signal: AbortSignal): Promise { diff --git a/packages/openclaw/tsdown.config.ts b/packages/openclaw/tsdown.config.ts index 7ae5c3a..abad0ea 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/beeper-channel-runtime.ts", "src/beeper-cli/tool.ts", "src/beeper-setup.ts", "src/bridge-agent.ts", "src/cli.ts", "src/config.ts", "src/connector.ts", "src/function-entry.ts", "src/matrix-parser.ts", "src/openclaw-runtime.ts", "src/plugin-entry.ts", "src/protocol-coverage.ts", "src/registry.ts", "src/registration.ts", "src/rooms.ts", "src/secret-contract.ts", "src/serial.ts", "src/setup.ts", "src/setup-entry.ts", "src/types.ts"], + entry: ["src/account-id.ts", "src/approval.ts", "src/appservice.ts", "src/auth-presence.ts", "src/beeper-channel-runtime.ts", "src/beeper-cli/tool.ts", "src/beeper-setup.ts", "src/bridge-agent.ts", "src/cli.ts", "src/config.ts", "src/connector.ts", "src/function-entry.ts", "src/matrix-parser.ts", "src/openclaw-runtime.ts", "src/plugin-entry.ts", "src/protocol-coverage.ts", "src/registry.ts", "src/registration.ts", "src/rooms.ts", "src/secret-contract.ts", "src/serial.ts", "src/setup.ts", "src/setup-entry.ts", "src/types.ts"], format: ["esm"], }); From 2dd203dbd9350c4083d4ff9ecae8fc511cbedad3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Wed, 3 Jun 2026 06:06:23 +0200 Subject: [PATCH 52/56] Fix OpenClaw bridge release checks --- CHANGELOG.md | 4 + package.json | 4 +- packages/openclaw/AGENTS.md | 6 +- packages/openclaw/README.md | 6 + packages/openclaw/openclaw.plugin.json | 18 -- packages/openclaw/package.json | 18 +- packages/openclaw/skills/beeper-cli/SKILL.md | 25 --- packages/openclaw/src/approval.test.ts | 3 +- packages/openclaw/src/approval.ts | 2 +- packages/openclaw/src/appservice.test.ts | 2 +- packages/openclaw/src/appservice.ts | 1 - .../src/beeper-channel-runtime.test.ts | 19 +- .../openclaw/src/beeper-channel-runtime.ts | 40 +++- packages/openclaw/src/beeper-cli/tool.test.ts | 46 ---- packages/openclaw/src/beeper-cli/tool.ts | 210 ------------------ packages/openclaw/src/beeper-setup.test.ts | 1 + packages/openclaw/src/beeper-setup.ts | 2 - packages/openclaw/src/cli.test.ts | 4 +- packages/openclaw/src/cli.ts | 1 + packages/openclaw/src/function-entry.test.ts | 25 --- packages/openclaw/src/function-entry.ts | 34 --- .../openclaw/src/openclaw-extension.test.ts | 22 +- packages/openclaw/src/registry.test.ts | 11 +- packages/openclaw/tsdown.config.ts | 2 +- .../native/internal/core/beeper_ai_run.go | 23 +- .../internal/core/beeper_ai_run_test.go | 50 ++++- packages/pickle/native/internal/core/core.go | 1 + .../pickle/native/internal/core/messages.go | 21 +- packages/pickle/src/beeper/auth.test.ts | 15 +- packages/pickle/src/beeper/auth.ts | 65 +++++- pnpm-lock.yaml | 10 - 31 files changed, 253 insertions(+), 438 deletions(-) delete mode 100644 packages/openclaw/skills/beeper-cli/SKILL.md delete mode 100644 packages/openclaw/src/beeper-cli/tool.test.ts delete mode 100644 packages/openclaw/src/beeper-cli/tool.ts delete mode 100644 packages/openclaw/src/function-entry.test.ts delete mode 100644 packages/openclaw/src/function-entry.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7300f70..755fa10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,3 +7,7 @@ - Node file storage, Cloudflare KV storage, and Durable Object storage helpers. - Matrix messages, room threads, reactions, media, DMs, typing, read receipts, long polling, and Chat SDK adapter mapping. +- OpenClaw Beeper channel plugin for exposing OpenClaw agents and sessions over + Beeper/Matrix, including setup metadata, appservice registration, agent ghost + contacts, command discovery, approval handling, and native Beeper turn + streaming. diff --git a/package.json b/package.json index a3111fa..0afc95a 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "@beeper/openclaw", + "name": "@beeper/pickle-workspace", "private": true, "type": "module", "packageManager": "pnpm@10.25.0", @@ -7,7 +7,7 @@ "build": "pnpm -r build", "build:wasm": "pnpm --filter @beeper/pickle build:wasm", "audit:surface": "node scripts/audit-package-surface.mjs", - "check": "pnpm audit:surface && pnpm typecheck && pnpm test && pnpm test:go && pnpm build && pnpm pack:packages && pnpm smoke:consumer && pnpm smoke:cloudflare", + "check": "pnpm audit:surface && pnpm typecheck && pnpm build && pnpm test && pnpm test:go && pnpm pack:packages && pnpm smoke:consumer && pnpm smoke:cloudflare", "clean": "pnpm -r clean", "changeset": "changeset", "full-test": "pnpm check && pnpm test:openclaw:plugins", diff --git a/packages/openclaw/AGENTS.md b/packages/openclaw/AGENTS.md index 784a994..2be29df 100644 --- a/packages/openclaw/AGENTS.md +++ b/packages/openclaw/AGENTS.md @@ -26,21 +26,21 @@ locally linked plugin. For a published install: ```sh -openclaw plugins install clawhub:@beeper/openclaw@0.1.0 +openclaw plugins install clawhub:@beeper/openclaw ``` For local development from this package directory: ```sh pnpm build -openclaw plugins install --force --link . +openclaw plugins install --link . ``` If working from the Pickle repo root, pass the package path instead: ```sh pnpm --filter @beeper/openclaw build -openclaw plugins install --force --link packages/openclaw +openclaw plugins install --link packages/openclaw ``` Check that OpenClaw discovered the plugin: diff --git a/packages/openclaw/README.md b/packages/openclaw/README.md index 984231e..7692ac2 100644 --- a/packages/openclaw/README.md +++ b/packages/openclaw/README.md @@ -6,6 +6,12 @@ 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/openclaw +``` + +For a pinned install: + ```sh openclaw plugins install clawhub:@beeper/openclaw@0.1.0 ``` diff --git a/packages/openclaw/openclaw.plugin.json b/packages/openclaw/openclaw.plugin.json index 50efa5c..dd15787 100644 --- a/packages/openclaw/openclaw.plugin.json +++ b/packages/openclaw/openclaw.plugin.json @@ -8,24 +8,6 @@ "channels": [ "beeper" ], - "contracts": { - "tools": [ - "beeper_cli" - ] - }, - "toolMetadata": { - "beeper_cli": { - "optional": true - } - }, - "commandAliases": [ - { - "name": "beeper" - } - ], - "skills": [ - "./skills" - ], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/packages/openclaw/package.json b/packages/openclaw/package.json index b549837..6e8f46f 100644 --- a/packages/openclaw/package.json +++ b/packages/openclaw/package.json @@ -51,10 +51,6 @@ "types": "./dist/cli.d.mts", "import": "./dist/cli.mjs" }, - "./function-entry": { - "types": "./dist/function-entry.d.mts", - "import": "./dist/function-entry.mjs" - }, "./config": { "types": "./dist/config.d.mts", "import": "./dist/config.mjs" @@ -106,19 +102,16 @@ }, "files": [ "dist", - "skills", "openclaw.plugin.json", "README.md", "LICENSE" ], "openclaw": { "extensions": [ - "./src/plugin-entry.ts", - "./src/function-entry.ts" + "./src/plugin-entry.ts" ], "runtimeExtensions": [ - "./dist/plugin-entry.mjs", - "./dist/function-entry.mjs" + "./dist/plugin-entry.mjs" ], "setupEntry": "./src/setup-entry.ts", "runtimeSetupEntry": "./dist/setup-entry.mjs", @@ -147,8 +140,8 @@ ] }, "install": { - "clawhubSpec": "clawhub:@beeper/openclaw@0.1.0", - "npmSpec": "@beeper/openclaw@0.1.0", + "clawhubSpec": "clawhub:@beeper/openclaw", + "npmSpec": "@beeper/openclaw", "defaultChoice": "clawhub", "minHostVersion": ">=2026.6.2" }, @@ -185,9 +178,6 @@ "typescript": "^5.7.2", "vitest": "^4.0.18" }, - "dependencies": { - "beeper-cli": "^0.6.2" - }, "peerDependencies": { "openclaw": ">=2026.6.2" }, diff --git a/packages/openclaw/skills/beeper-cli/SKILL.md b/packages/openclaw/skills/beeper-cli/SKILL.md deleted file mode 100644 index 8cb337b..0000000 --- a/packages/openclaw/skills/beeper-cli/SKILL.md +++ /dev/null @@ -1,25 +0,0 @@ -# Beeper CLI - -Use the `beeper_cli` function when the user asks to inspect, search, read, or send Beeper chats through the bundled Beeper CLI. - -## Usage - -- Pass only CLI arguments in `args`; omit the executable name. -- Prefer JSON output flags when the Beeper CLI supports them. -- Use narrow commands first, such as listing chats or searching recent messages, before reading larger histories. -- Do not send messages, edit data, or mutate Beeper state unless the user explicitly asks for that action. - -## Examples - -```json -{ - "args": ["chats", "list", "--output", "json"] -} -``` - -```json -{ - "args": ["messages", "search", "alice", "--output", "json"], - "timeoutMs": 30000 -} -``` diff --git a/packages/openclaw/src/approval.test.ts b/packages/openclaw/src/approval.test.ts index ed0c67f..cc285a1 100644 --- a/packages/openclaw/src/approval.test.ts +++ b/packages/openclaw/src/approval.test.ts @@ -113,8 +113,7 @@ describe("OpenClaw approval response parsing", () => { 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: "allow-always", id: "allow-always", reactionKey: "approval.allow_always", title: "Allow Always", variant: "secondary" }, { decision: "deny", id: "deny", reactionKey: "approval.deny", title: "Cancel", variant: "destructive" }, ], id: "approval_1", diff --git a/packages/openclaw/src/approval.ts b/packages/openclaw/src/approval.ts index 8a63de0..7ad44f5 100644 --- a/packages/openclaw/src/approval.ts +++ b/packages/openclaw/src/approval.ts @@ -57,7 +57,7 @@ export function defaultBeeperApprovalChoices(): BeeperApprovalChoice[] { ]; } -export function defaultBeeperApprovalActions(decisions: readonly ApprovalDecision[] = ["allow_once", "allow_session", "allow_room", "deny"]): Record[] { +export function defaultBeeperApprovalActions(decisions: readonly ApprovalDecision[] = ["allow_once", "allow_always", "deny"]): Record[] { return decisions.map((decision) => ({ decision: decision.replace(/_/gu, "-"), id: decision.replace(/_/gu, "-"), diff --git a/packages/openclaw/src/appservice.test.ts b/packages/openclaw/src/appservice.test.ts index 96a4c30..ed72cc2 100644 --- a/packages/openclaw/src/appservice.test.ts +++ b/packages/openclaw/src/appservice.test.ts @@ -26,7 +26,6 @@ describe("OpenClaw Beeper appservice runtime", () => { })).resolves.toBe(bridge); expect(bridgeFactory).toHaveBeenCalledWith(expect.objectContaining({ - address: "websocket", baseDomain: "beeper-staging.com", bridge: "sh-openclaw", bridgeManagerPostState: true, @@ -39,6 +38,7 @@ describe("OpenClaw Beeper appservice runtime", () => { homeserverDomain: "beeper.local", ownerUserId: "@batuhan:beeper-staging.com", })); + expect(bridgeFactory.mock.calls[0]?.[0]).not.toHaveProperty("address"); }); it("starts the created bridge", async () => { diff --git a/packages/openclaw/src/appservice.ts b/packages/openclaw/src/appservice.ts index cab826b..c99c3a9 100644 --- a/packages/openclaw/src/appservice.ts +++ b/packages/openclaw/src/appservice.ts @@ -31,7 +31,6 @@ export async function createOpenClawBeeperBridge(options: CreateOpenClawBeeperBr connector, }; if (config?.matrixUserId !== undefined) bridgeOptions.ownerUserId = config.matrixUserId; - bridgeOptions.address = "websocket"; const baseDomain = beeperBaseDomain(config?.serverEnv === "prod" ? "production" : config?.serverEnv); if (baseDomain !== undefined) bridgeOptions.baseDomain = baseDomain; bridgeOptions.bridgeManagerPostState = true; diff --git a/packages/openclaw/src/beeper-channel-runtime.test.ts b/packages/openclaw/src/beeper-channel-runtime.test.ts index 08c568e..7d31ae4 100644 --- a/packages/openclaw/src/beeper-channel-runtime.test.ts +++ b/packages/openclaw/src/beeper-channel-runtime.test.ts @@ -184,7 +184,16 @@ describe("BeeperChannelRuntime", () => { 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" }); + await runtime.sendText({ replyToId: "$reply", roomId: "!room", text: "threaded", threadRoot: "$thread" }); + const threadedTextEvent = queued[1] as { + convertMessage: () => Promise<{ parts: Array<{ content: Record }> }>; + }; + expect((await threadedTextEvent.convertMessage()).parts[0]?.content["m.relates_to"]).toEqual({ + "m.in_reply_to": { event_id: "$reply" }, + "m.thread": { event_id: "$thread" }, + }); + + await runtime.sendMedia({ bytes: new Uint8Array([1]), caption: "cap", filename: "a.txt", replyToId: "$reply", roomId: "!room", threadRoot: "$thread" }); expect(bridge.uploadMedia).toHaveBeenCalledWith({ bytes: new Uint8Array([1]), filename: "a.txt", @@ -193,6 +202,13 @@ describe("BeeperChannelRuntime", () => { bytes: new Uint8Array([1]), filename: "a.txt", }); + const mediaEvent = queued[2] as { + convertMessage: () => Promise<{ parts: Array<{ content: Record }> }>; + }; + expect((await mediaEvent.convertMessage()).parts[0]?.content["m.relates_to"]).toEqual({ + "m.in_reply_to": { event_id: "$reply" }, + "m.thread": { event_id: "$thread" }, + }); await runtime.edit({ eventId: sent.eventId, roomId: "!room", text: "edited" }); await runtime.react({ emoji: "+1", eventId: sent.eventId, roomId: "!room" }); @@ -204,6 +220,7 @@ describe("BeeperChannelRuntime", () => { await runtime.markUnread({ eventId: sent.eventId, roomId: "!room", unread: true }); expect(queued.slice(1).map((event) => (event as { getType: () => string }).getType())).toEqual([ + "message", "message", "edit", "reaction", diff --git a/packages/openclaw/src/beeper-channel-runtime.ts b/packages/openclaw/src/beeper-channel-runtime.ts index 9147c85..e6e4233 100644 --- a/packages/openclaw/src/beeper-channel-runtime.ts +++ b/packages/openclaw/src/beeper-channel-runtime.ts @@ -42,6 +42,7 @@ export interface BeeperOutboundMedia { filename?: string; kind?: BridgeMediaKind; path?: string; + replyToId?: string | null; threadRoot?: string; } @@ -95,7 +96,10 @@ export class BeeperChannelRuntime { msgtype: "m.text", ...options.content, }; - return await this.#queueRemoteText(options.roomId, withReplyRelation(content, options.replyToId)); + return await this.#queueRemoteText(options.roomId, withMessageRelations(content, { + replyToId: options.replyToId, + threadRoot: options.threadRoot, + })); } async sendMedia(options: BeeperOutboundMedia & { roomId: string }): Promise { @@ -108,6 +112,8 @@ export class BeeperChannelRuntime { kind: options.kind ?? "file", ...(options.caption !== undefined ? { caption: options.caption } : {}), ...(options.filename !== undefined ? { filename: options.filename } : {}), + ...(options.replyToId !== undefined ? { replyToId: options.replyToId } : {}), + ...(options.threadRoot !== undefined ? { threadRoot: options.threadRoot } : {}), }); } @@ -250,17 +256,27 @@ export class BeeperChannelRuntime { return { eventId: messageId, raw: { bridgeQueued: true }, roomId }; } - async #queueRemoteMedia(roomId: string, options: { bytes: Uint8Array; caption?: string; filename?: string; kind: NonNullable }): Promise { + async #queueRemoteMedia(roomId: string, options: { + bytes: Uint8Array; + caption?: string; + filename?: string; + kind: NonNullable; + replyToId?: string | null; + threadRoot?: string; + }): Promise { const route = this.#bridgeRoute(roomId); const upload = await route.bridge.uploadMedia({ bytes: options.bytes, ...(options.filename !== undefined ? { filename: options.filename } : {}), }); - const content = bridgeMediaMessageContent({ + const content = withMessageRelations(bridgeMediaMessageContent({ contentUri: upload.contentUri, kind: options.kind, ...(options.caption !== undefined ? { caption: options.caption } : {}), ...(options.filename !== undefined ? { filename: options.filename } : {}), + }), { + replyToId: options.replyToId, + threadRoot: options.threadRoot, }); const messageId = openClawRemoteId(); route.bridge.queueRemoteEvent(route.login, createRemoteMessage({ @@ -438,18 +454,26 @@ export function requireBeeperChannelRuntimeForHost(hostRuntime: object | undefin return runtime; } -function withReplyRelation(content: Record, replyToId: string | null | undefined): Record { - if (!replyToId) return content; +function withMessageRelations( + content: Record, + options: { replyToId?: string | number | null | undefined; threadRoot?: string | number | null | undefined }, +): Record { + if (!options.replyToId && options.threadRoot == null) return content; + const relatesTo = recordValue(content["m.relates_to"]) ?? {}; return { ...content, "m.relates_to": { - "m.in_reply_to": { - event_id: replyToId, - }, + ...relatesTo, + ...(options.replyToId ? { "m.in_reply_to": { event_id: String(options.replyToId) } } : {}), + ...(options.threadRoot != null ? { "m.thread": { event_id: String(options.threadRoot) } } : {}), }, }; } +function recordValue(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined; +} + function openClawRemoteId(prefix = "message"): string { return `openclaw:${prefix}:${randomUUID()}`; } diff --git a/packages/openclaw/src/beeper-cli/tool.test.ts b/packages/openclaw/src/beeper-cli/tool.test.ts deleted file mode 100644 index b3e1067..0000000 --- a/packages/openclaw/src/beeper-cli/tool.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { mkdtemp, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { createBeeperCliTool, runBeeperCli } from "./tool"; - -describe("Beeper CLI function", () => { - it("runs an explicitly configured Beeper CLI JS launcher", async () => { - const dir = await mkdtemp(join(tmpdir(), "beeper-cli-tool-")); - const script = join(dir, "beeper.js"); - await writeFile(script, "for (const arg of process.argv.slice(2)) console.log(arg);\n"); - - await expect(runBeeperCli({ - args: ["chats", "list", "--output", "json"], - env: { ...process.env, BEEPER_CLI: script }, - })).resolves.toMatchObject({ - args: ["chats", "list", "--output", "json"], - code: 0, - stdout: "chats\nlist\n--output\njson\n", - }); - }); - - it("exposes a dedicated optional tool schema", async () => { - const tool = createBeeperCliTool(); - expect(tool).toMatchObject({ - name: "beeper_cli", - parameters: { - required: ["args"], - properties: { - args: expect.objectContaining({ type: "array" }), - }, - }, - }); - await expect(tool.execute("call-1", { args: "chats list" })).rejects.toThrow( - "args must be an array of strings", - ); - }); - - it("resolves the bundled beeper-cli npm launcher without PATH lookup", async () => { - await expect(runBeeperCli({ - args: ["--help"], - env: { ...process.env, PATH: "" }, - timeoutMs: 1, - })).rejects.toThrow("Beeper CLI timed out"); - }); -}); diff --git a/packages/openclaw/src/beeper-cli/tool.ts b/packages/openclaw/src/beeper-cli/tool.ts deleted file mode 100644 index c5939e4..0000000 --- a/packages/openclaw/src/beeper-cli/tool.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { spawn } from "node:child_process"; -import { createRequire } from "node:module"; -import { resolve } from "node:path"; - -const DEFAULT_TIMEOUT_MS = 30_000; -const DEFAULT_MAX_OUTPUT_BYTES = 512_000; - -const requireFromHere = createRequire(import.meta.url); - -export interface BeeperCliRunOptions { - args?: string[]; - cwd?: string; - env?: NodeJS.ProcessEnv; - maxOutputBytes?: number; - timeoutMs?: number; -} - -export interface BeeperCliRunResult { - command: string; - args: string[]; - code: number | null; - signal: NodeJS.Signals | null; - stderr: string; - stdout: string; -} - -export function createBeeperCliTool() { - return { - name: "beeper_cli", - label: "Beeper CLI", - description: - "Run the bundled Beeper CLI as a dedicated local function. Use for Beeper Desktop chat search, reads, and sends.", - parameters: { - type: "object", - additionalProperties: false, - properties: { - args: { - type: "array", - items: { type: "string" }, - description: "Arguments passed to the Beeper CLI, excluding the executable name.", - }, - cwd: { - type: "string", - description: "Working directory for the CLI process. Defaults to the current process directory.", - }, - timeoutMs: { - type: "integer", - minimum: 1, - description: "Maximum process runtime in milliseconds.", - }, - maxOutputBytes: { - type: "integer", - minimum: 1, - description: "Maximum stdout plus stderr bytes to retain.", - }, - }, - required: ["args"], - }, - async execute(_id: string, params: Record) { - const runOptions: BeeperCliRunOptions = { - args: readStringArray(params.args, "args"), - }; - const cwd = readOptionalString(params.cwd, "cwd"); - const timeoutMs = readOptionalPositiveInteger(params.timeoutMs, "timeoutMs"); - const maxOutputBytes = readOptionalPositiveInteger(params.maxOutputBytes, "maxOutputBytes"); - if (cwd !== undefined) runOptions.cwd = cwd; - if (timeoutMs !== undefined) runOptions.timeoutMs = timeoutMs; - if (maxOutputBytes !== undefined) runOptions.maxOutputBytes = maxOutputBytes; - return runBeeperCli(runOptions); - }, - }; -} - -export function registerBeeperCli(program: unknown): void { - const commandProgram = program as { - command?: (name: string) => { - description?: (text: string) => unknown; - allowUnknownOption?: (value?: boolean) => unknown; - argument?: (flags: string, description?: string) => unknown; - option?: (flags: string, description?: string) => unknown; - action?: (handler: (...args: unknown[]) => unknown) => unknown; - }; - }; - const command = commandProgram.command?.("beeper"); - if (!command) return; - command.description?.("Run the bundled Beeper CLI."); - command.allowUnknownOption?.(true); - command.argument?.("[args...]", "Arguments passed to the Beeper CLI"); - command.option?.("--timeout-ms ", "Maximum process runtime in milliseconds"); - command.option?.("--max-output-bytes ", "Maximum stdout plus stderr bytes to retain"); - command.action?.(async (...actionArgs: unknown[]) => { - const args = actionArgs[0]; - const options = isRecord(actionArgs[1]) ? actionArgs[1] : {}; - const runOptions: BeeperCliRunOptions = { - args: readStringArray(args, "args"), - }; - const timeoutMs = parseOptionalPositiveIntegerOption(options.timeoutMs, "timeout-ms"); - const maxOutputBytes = parseOptionalPositiveIntegerOption(options.maxOutputBytes, "max-output-bytes"); - if (timeoutMs !== undefined) runOptions.timeoutMs = timeoutMs; - if (maxOutputBytes !== undefined) runOptions.maxOutputBytes = maxOutputBytes; - const result = await runBeeperCli(runOptions); - if (result.stdout) process.stdout.write(result.stdout); - if (result.stderr) process.stderr.write(result.stderr); - process.exitCode = result.code ?? 1; - }); -} - -export async function runBeeperCli(options: BeeperCliRunOptions): Promise { - const args = options.args ?? []; - const env = options.env ?? process.env; - const launcher = resolveBeeperCliLauncher(env); - return spawnAndCollect(process.execPath, [launcher, ...args], { - cwd: options.cwd ? resolve(options.cwd) : process.cwd(), - env, - maxOutputBytes: options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES, - timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, - }, launcher); -} - -function resolveBeeperCliLauncher(env: NodeJS.ProcessEnv): string { - if (env.BEEPER_CLI) return env.BEEPER_CLI; - return requireFromHere.resolve("beeper-cli/bin/beeper.js"); -} - -function spawnAndCollect( - command: string, - args: string[], - options: { cwd: string; env: NodeJS.ProcessEnv; maxOutputBytes: number; timeoutMs: number }, - reportedCommand = command, -): Promise { - return new Promise((resolvePromise, reject) => { - const child = spawn(command, args, { - cwd: options.cwd, - env: options.env, - stdio: ["ignore", "pipe", "pipe"], - }); - let stdout = Buffer.alloc(0); - let stderr = Buffer.alloc(0); - let settled = false; - const timer = setTimeout(() => { - child.kill("SIGTERM"); - reject(new Error(`Beeper CLI timed out after ${options.timeoutMs}ms`)); - }, options.timeoutMs); - - const append = (current: Buffer, chunk: Buffer) => { - const next = Buffer.concat([current, chunk]); - if (next.byteLength <= options.maxOutputBytes) return next; - return next.subarray(next.byteLength - options.maxOutputBytes); - }; - - child.stdout?.on("data", (chunk: Buffer) => { - stdout = append(stdout, chunk); - }); - child.stderr?.on("data", (chunk: Buffer) => { - stderr = append(stderr, chunk); - }); - child.on("error", (error) => { - if (settled) return; - settled = true; - clearTimeout(timer); - reject(error); - }); - child.on("close", (code, signal) => { - if (settled) return; - settled = true; - clearTimeout(timer); - resolvePromise({ - command: reportedCommand, - args: args.slice(1), - code, - signal, - stdout: stdout.toString("utf8"), - stderr: stderr.toString("utf8"), - }); - }); - }); -} - -function readStringArray(value: unknown, key: string): string[] { - if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) { - throw new Error(`${key} must be an array of strings`); - } - return value; -} - -function readOptionalString(value: unknown, key: string): string | undefined { - if (value === undefined) return undefined; - if (typeof value !== "string") throw new Error(`${key} must be a string`); - return value; -} - -function readOptionalPositiveInteger(value: unknown, key: string): number | undefined { - if (value === undefined) return undefined; - if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) { - throw new Error(`${key} must be a positive integer`); - } - return value; -} - -function parseOptionalPositiveIntegerOption(value: unknown, key: string): number | undefined { - if (value === undefined) return undefined; - if (typeof value !== "string") throw new Error(`${key} must be a positive integer`); - const parsed = Number(value); - if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${key} must be a positive integer`); - return parsed; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} diff --git a/packages/openclaw/src/beeper-setup.test.ts b/packages/openclaw/src/beeper-setup.test.ts index 18dd29b..ee5e0f0 100644 --- a/packages/openclaw/src/beeper-setup.test.ts +++ b/packages/openclaw/src/beeper-setup.test.ts @@ -145,6 +145,7 @@ describe("OpenClaw Beeper setup", () => { bridge: "sh-openclaw-openclaw-device", token: "mx-token", }); + expect(options).not.toHaveProperty("address"); expect(options.homeserver).toBeUndefined(); return { homeserver: "https://matrix.beeper-staging.com/_hungryserv/batuhan", diff --git a/packages/openclaw/src/beeper-setup.ts b/packages/openclaw/src/beeper-setup.ts index 1304f6d..e2fcc5d 100644 --- a/packages/openclaw/src/beeper-setup.ts +++ b/packages/openclaw/src/beeper-setup.ts @@ -8,7 +8,6 @@ import { type MatrixAppserviceInitOptions, type MatrixPasswordAuthOptions, } from "@beeper/pickle-bridge/beeper"; -import { DEFAULT_REGISTRATION_URL } from "./config"; import { DEFAULT_BEEPER_BRIDGE_TYPE, openClawBeeperBridgeId } from "./ids"; import { resolveOpenClawDeviceId } from "./openclaw-identity"; import type { BeeperServerEnv, OpenClawBridgeConfig } from "./types"; @@ -135,7 +134,6 @@ export async function createOpenClawBeeperAppService( selfHosted: true, token: options.accessToken, }; - request.address = DEFAULT_REGISTRATION_URL; if (options.baseDomain !== undefined) request.baseDomain = options.baseDomain; if (options.fetch !== undefined) request.fetch = options.fetch; request.postState = true; diff --git a/packages/openclaw/src/cli.test.ts b/packages/openclaw/src/cli.test.ts index 8e83927..d45ac3c 100644 --- a/packages/openclaw/src/cli.test.ts +++ b/packages/openclaw/src/cli.test.ts @@ -11,6 +11,7 @@ describe("pickle-openclaw CLI", () => { await expect(runCli(["--help"], helpIO)).resolves.toBe(0); expect(helpIO.stdoutText).toContain("login"); expect(helpIO.stdoutText).toContain("whoami"); + expect(helpIO.stdoutText).toContain("--server-env "); expect(helpIO.stdoutText).not.toContain("beeper-login"); expect(helpIO.stdoutText).not.toContain("beeper-register"); expect(helpIO.stdoutText).not.toContain("rpc"); @@ -209,9 +210,10 @@ describe("pickle-openclaw CLI", () => { }); it("reports incomplete identity when no Beeper login is saved", async () => { + const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-empty-")); const io = captureIO(); - await expect(runCli(["whoami", "--data-dir", "/tmp/pickle-openclaw-empty"], io)).resolves.toBe(0); + await expect(runCli(["whoami", "--data-dir", dir], io)).resolves.toBe(0); expect(JSON.parse(io.stdoutText)).toMatchObject({ canConnect: false, diff --git a/packages/openclaw/src/cli.ts b/packages/openclaw/src/cli.ts index 7088d28..8fd4278 100644 --- a/packages/openclaw/src/cli.ts +++ b/packages/openclaw/src/cli.ts @@ -76,6 +76,7 @@ function helpText(): string { " --email
", " --username ", " --password ", + " --server-env ", "", ].join("\n"); } diff --git a/packages/openclaw/src/function-entry.test.ts b/packages/openclaw/src/function-entry.test.ts deleted file mode 100644 index 808a7c8..0000000 --- a/packages/openclaw/src/function-entry.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import entry, { openClawBeeperFunctionPlugin } from "./function-entry"; - -describe("OpenClaw Beeper function plugin", () => { - it("registers the Beeper CLI tool and CLI command", () => { - const registerTool = vi.fn(); - const registerCli = vi.fn(); - - openClawBeeperFunctionPlugin.register({ - registerCli, - registerTool, - } as never); - - expect(entry.id).toBe("beeper-cli"); - expect(registerTool).toHaveBeenCalledWith(expect.any(Function), { optional: true }); - expect(registerCli).toHaveBeenCalledWith(expect.any(Function), expect.objectContaining({ - commands: ["beeper"], - descriptors: [expect.objectContaining({ name: "beeper", hasSubcommands: true })], - })); - - const factory = registerTool.mock.calls[0]?.[0]; - expect(factory({ sandboxed: true })).toBeNull(); - expect(factory({ sandboxed: false })).toMatchObject({ name: "beeper_cli" }); - }); -}); diff --git a/packages/openclaw/src/function-entry.ts b/packages/openclaw/src/function-entry.ts deleted file mode 100644 index 9210547..0000000 --- a/packages/openclaw/src/function-entry.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; -import { createBeeperCliTool, registerBeeperCli } from "./beeper-cli/tool"; - -export const openClawBeeperFunctionPlugin = definePluginEntry({ - id: "beeper-cli", - name: "Beeper CLI", - description: "Optional Beeper CLI function and command surface for OpenClaw agents.", - register(api) { - api.registerTool( - ((ctx: { sandboxed?: boolean }) => { - if (ctx.sandboxed) return null; - return createBeeperCliTool(); - }) as never, - { optional: true }, - ); - api.registerCli( - async ({ program }: { program: unknown }) => { - registerBeeperCli(program); - }, - { - commands: ["beeper"], - descriptors: [ - { - name: "beeper", - description: "Run the bundled Beeper CLI from OpenClaw.", - hasSubcommands: true, - }, - ], - }, - ); - }, -}); - -export default openClawBeeperFunctionPlugin; diff --git a/packages/openclaw/src/openclaw-extension.test.ts b/packages/openclaw/src/openclaw-extension.test.ts index f6a459a..37b9184 100644 --- a/packages/openclaw/src/openclaw-extension.test.ts +++ b/packages/openclaw/src/openclaw-extension.test.ts @@ -110,9 +110,9 @@ describe("OpenClaw plugin package metadata", () => { const schema = JSON.parse(await readFile(resolve("src/beeper-channel-config.schema.json"), "utf8")); expect(packageJson.files).toContain("openclaw.plugin.json"); - expect(packageJson.files).toContain("skills"); - expect(packageJson.openclaw?.extensions).toEqual(["./src/plugin-entry.ts", "./src/function-entry.ts"]); - expect(packageJson.openclaw?.runtimeExtensions).toEqual(["./dist/plugin-entry.mjs", "./dist/function-entry.mjs"]); + expect(packageJson.files).not.toContain("skills"); + expect(packageJson.openclaw?.extensions).toEqual(["./src/plugin-entry.ts"]); + expect(packageJson.openclaw?.runtimeExtensions).toEqual(["./dist/plugin-entry.mjs"]); expect(packageJson.openclaw?.setupEntry).toBe("./src/setup-entry.ts"); expect(packageJson.openclaw?.runtimeSetupEntry).toBe("./dist/setup-entry.mjs"); expect(packageJson.openclaw?.channel?.id).toBe("beeper"); @@ -131,23 +131,15 @@ describe("OpenClaw plugin package metadata", () => { }, ]); expect(packageJson.openclaw?.install?.defaultChoice).toBe("clawhub"); - expect(packageJson.openclaw?.install?.clawhubSpec).toBe( - `clawhub:@beeper/openclaw@${packageJson.version}`, - ); - expect(packageJson.openclaw?.install?.npmSpec).toBe( - `@beeper/openclaw@${packageJson.version}`, - ); + expect(packageJson.openclaw?.install?.clawhubSpec).toBe("clawhub:@beeper/openclaw"); + expect(packageJson.openclaw?.install?.npmSpec).toBe("@beeper/openclaw"); expect(packageJson.openclaw?.compat?.pluginApi).toBe(">=2026.6.2"); expect(packageJson.peerDependencies?.openclaw).toBe(">=2026.6.2"); expect(packageJson.scripts?.prepublishOnly).toBe("node ../../scripts/guard-pnpm-publish.mjs"); expect(packageJson.files).toContain("dist"); expect(manifest).toEqual(expect.objectContaining({ - commandAliases: [{ name: "beeper" }], - contracts: { tools: ["beeper_cli"] }, id: "beeper", channels: ["beeper"], - skills: ["./skills"], - toolMetadata: { beeper_cli: { optional: true } }, })); expect(manifest.activation?.onStartup).toBe(false); expect(manifest.channelEnvVars).toBeUndefined(); @@ -219,9 +211,9 @@ describe("OpenClaw plugin package metadata", () => { ])); 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", "./dist/function-entry.mjs"]); + expect(packageJson.openclaw?.runtimeExtensions).toEqual(["./dist/plugin-entry.mjs"]); expect(packageJson.openclaw?.runtimeSetupEntry).toBe("./dist/setup-entry.mjs"); - expect(dependencies).toEqual([["beeper-cli", "^0.6.2"]]); + expect(dependencies).toEqual([]); expect(devDependencies).toEqual(expect.arrayContaining([ ["@beeper/pickle-ag-ui", "workspace:^"], ["@beeper/pickle-bridge", "workspace:^"], diff --git a/packages/openclaw/src/registry.test.ts b/packages/openclaw/src/registry.test.ts index 85fe331..fbccd8e 100644 --- a/packages/openclaw/src/registry.test.ts +++ b/packages/openclaw/src/registry.test.ts @@ -1,12 +1,19 @@ -import { mkdtemp } from "node:fs/promises"; +import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { resolve } from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { OpenClawBridgeRegistry } from "./registry"; describe("OpenClawBridgeRegistry", () => { + const cleanup: string[] = []; + + afterEach(async () => { + await Promise.all(cleanup.splice(0).map((dir) => rm(dir, { force: true, recursive: true }))); + }); + it("persists agent contacts, session bindings, and dedupe keys", async () => { const dir = await mkdtemp(resolve(tmpdir(), "pickle-openclaw-")); + cleanup.push(dir); const path = resolve(dir, "registry.json"); const registry = new OpenClawBridgeRegistry(path); await registry.load(); diff --git a/packages/openclaw/tsdown.config.ts b/packages/openclaw/tsdown.config.ts index abad0ea..022da36 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/account-id.ts", "src/approval.ts", "src/appservice.ts", "src/auth-presence.ts", "src/beeper-channel-runtime.ts", "src/beeper-cli/tool.ts", "src/beeper-setup.ts", "src/bridge-agent.ts", "src/cli.ts", "src/config.ts", "src/connector.ts", "src/function-entry.ts", "src/matrix-parser.ts", "src/openclaw-runtime.ts", "src/plugin-entry.ts", "src/protocol-coverage.ts", "src/registry.ts", "src/registration.ts", "src/rooms.ts", "src/secret-contract.ts", "src/serial.ts", "src/setup.ts", "src/setup-entry.ts", "src/types.ts"], + entry: ["src/account-id.ts", "src/approval.ts", "src/appservice.ts", "src/auth-presence.ts", "src/beeper-channel-runtime.ts", "src/beeper-setup.ts", "src/bridge-agent.ts", "src/cli.ts", "src/config.ts", "src/connector.ts", "src/matrix-parser.ts", "src/openclaw-runtime.ts", "src/plugin-entry.ts", "src/protocol-coverage.ts", "src/registry.ts", "src/registration.ts", "src/rooms.ts", "src/secret-contract.ts", "src/serial.ts", "src/setup.ts", "src/setup-entry.ts", "src/types.ts"], format: ["esm"], }); diff --git a/packages/pickle/native/internal/core/beeper_ai_run.go b/packages/pickle/native/internal/core/beeper_ai_run.go index 9ea496c..5646f58 100644 --- a/packages/pickle/native/internal/core/beeper_ai_run.go +++ b/packages/pickle/native/internal/core/beeper_ai_run.go @@ -145,7 +145,10 @@ func (c *Core) handleBeginBeeperAIRun(payload []byte) ([]byte, error) { if err := json.Unmarshal(payload, &req); err != nil { return nil, err } - state := c.beginBeeperAIRun(req) + state, err := c.beginBeeperAIRun(req) + if err != nil { + return nil, err + } run := state.run return c.marshalBeeperAIRunSnapshot(run, outboundEventsFromAGUI(run.Events)) } @@ -235,7 +238,10 @@ func (c *Core) handleStartBeeperAIRunStream(ctx context.Context, payload []byte) if req.StreamType == "" { req.StreamType = "com.beeper.llm" } - state := c.beginBeeperAIRun(req.MatrixBeginBeeperAIRunOptions) + state, err := c.beginBeeperAIRun(req.MatrixBeginBeeperAIRunOptions) + if err != nil { + return nil, err + } run := state.run for _, eventData := range req.InitialEvents { if err := state.appendEvent(eventData); err != nil { @@ -395,8 +401,15 @@ func (c *Core) handleErrorBeeperAIRunStream(ctx context.Context, payload []byte) return c.finalizeBeeperAIRunStream(ctx, state, events) } -func (c *Core) beginBeeperAIRun(req MatrixBeginBeeperAIRunOptions) *beeperAIRunState { - run := aistream.NewRun(req.RunID, req.ThreadID, req.Model, req.AgentID, req.AgentName, time.Now()) +func (c *Core) beginBeeperAIRun(req MatrixBeginBeeperAIRunOptions) (*beeperAIRunState, error) { + runID := strings.TrimSpace(req.RunID) + if runID == "" { + return nil, errors.New("missing Beeper AI run ID") + } + if c.beeperAIRuns[runID] != nil { + return nil, errors.New("Beeper AI run is already registered") + } + run := aistream.NewRun(runID, req.ThreadID, req.Model, req.AgentID, req.AgentName, time.Now()) if strings.TrimSpace(req.MessageID) != "" { run.MessageID = strings.TrimSpace(req.MessageID) } @@ -414,7 +427,7 @@ func (c *Core) beginBeeperAIRun(req MatrixBeginBeeperAIRunOptions) *beeperAIRunS writer: writer, } c.beeperAIRuns[run.RunID] = state - return state + return state, nil } func (s *beeperAIRunState) appendEvent(eventData OutboundEvent) error { diff --git a/packages/pickle/native/internal/core/beeper_ai_run_test.go b/packages/pickle/native/internal/core/beeper_ai_run_test.go index 3132c5d..f0986da 100644 --- a/packages/pickle/native/internal/core/beeper_ai_run_test.go +++ b/packages/pickle/native/internal/core/beeper_ai_run_test.go @@ -138,9 +138,42 @@ func TestBeeperAIRunErrorAbortAndDelete(t *testing.T) { } } +func TestBeeperAIRunBeginRejectsMissingAndDuplicateRunIDs(t *testing.T) { + core := New(nil) + if _, err := core.handleBeginBeeperAIRun([]byte(`{"runId":" "}`)); err == nil { + t.Fatal("expected missing run ID to fail") + } + payload, err := json.Marshal(MatrixBeginBeeperAIRunOptions{RunID: "run-duplicate", ThreadID: "thread-duplicate"}) + if err != nil { + t.Fatal(err) + } + if _, err := core.handleBeginBeeperAIRun(payload); err != nil { + t.Fatal(err) + } + if _, err := core.handleBeginBeeperAIRun(payload); err == nil { + t.Fatal("expected duplicate run ID to fail") + } +} + +func TestBeeperAIRunCloseClearsRunState(t *testing.T) { + core := New(func(OutboundEvent) {}) + if _, err := core.handleBeginBeeperAIRun([]byte(`{"runId":"run-close","threadId":"thread-close"}`)); err != nil { + t.Fatal(err) + } + if len(core.beeperAIRuns) != 1 { + t.Fatalf("expected one active run before close, got %d", len(core.beeperAIRuns)) + } + if _, err := core.handleClose(); err != nil { + t.Fatal(err) + } + if len(core.beeperAIRuns) != 0 { + t.Fatalf("expected close to clear active runs, got %d", len(core.beeperAIRuns)) + } +} + func TestBeeperAIRunSemanticPartsUseAIBridgeWriter(t *testing.T) { core := New(nil) - state := core.beginBeeperAIRun(MatrixBeginBeeperAIRunOptions{RunID: "run-parts", ThreadID: "thread-parts"}) + state := mustBeginBeeperAIRun(t, core, MatrixBeginBeeperAIRunOptions{RunID: "run-parts", ThreadID: "thread-parts"}) providerExecuted := true startedAtMs := int64(123) completedAtMs := int64(456) @@ -199,7 +232,7 @@ func TestBeeperAIRunSemanticPartsUseAIBridgeWriter(t *testing.T) { func TestBeeperAIRunEmptyToolResultCompletesToolCall(t *testing.T) { core := New(nil) - state := core.beginBeeperAIRun(MatrixBeginBeeperAIRunOptions{RunID: "run-empty-tool-result", ThreadID: "thread-empty-tool-result"}) + state := mustBeginBeeperAIRun(t, core, MatrixBeginBeeperAIRunOptions{RunID: "run-empty-tool-result", ThreadID: "thread-empty-tool-result"}) parts := []MatrixBeeperAIRunPartOptions{ {Input: map[string]any{"command": "gog auth list --json --no-input"}, Kind: "tool_start", ToolCallID: "cmd-1", ToolName: "bash"}, {Kind: "tool_result", State: "complete", ToolCallID: "cmd-1", ToolName: "bash"}, @@ -228,7 +261,7 @@ func TestBeeperAIRunEmptyToolResultCompletesToolCall(t *testing.T) { func TestBeeperAIRunCommandPartUsesCommandAsTitleAndActualOutput(t *testing.T) { core := New(nil) - state := core.beginBeeperAIRun(MatrixBeginBeeperAIRunOptions{RunID: "run-command", ThreadID: "thread-command"}) + state := mustBeginBeeperAIRun(t, core, MatrixBeginBeeperAIRunOptions{RunID: "run-command", ThreadID: "thread-command"}) err := state.appendPart(MatrixBeeperAIRunPartOptions{ Input: map[string]any{ "command": `/bin/zsh -lc "date '+%Y-%m-%d %H:%M:%S %Z'"`, @@ -264,7 +297,7 @@ func TestBeeperAIRunCommandPartUsesCommandAsTitleAndActualOutput(t *testing.T) { func TestBeeperAIEmptyStreamProjectionDoesNotExposeWorkingFallback(t *testing.T) { core := New(nil) - state := core.beginBeeperAIRun(MatrixBeginBeeperAIRunOptions{RunID: "run-empty", ThreadID: "thread-empty"}) + state := mustBeginBeeperAIRun(t, core, MatrixBeginBeeperAIRunOptions{RunID: "run-empty", ThreadID: "thread-empty"}) anchorContent, _ := aimatrix.AnchorContent(*state.run) clearBeeperAIWorkingFallback(anchorContent, *state.run) @@ -324,6 +357,15 @@ func decodeBeeperAIRunSnapshot(t *testing.T, raw []byte) MatrixBeeperAIRunSnapsh return snapshot } +func mustBeginBeeperAIRun(t *testing.T, core *Core, req MatrixBeginBeeperAIRunOptions) *beeperAIRunState { + t.Helper() + state, err := core.beginBeeperAIRun(req) + if err != nil { + t.Fatal(err) + } + return state +} + func eventTypes(events []OutboundEvent) []string { types := make([]string, 0, len(events)) for _, event := range events { diff --git a/packages/pickle/native/internal/core/core.go b/packages/pickle/native/internal/core/core.go index 3eb895f..b43addc 100644 --- a/packages/pickle/native/internal/core/core.go +++ b/packages/pickle/native/internal/core/core.go @@ -282,6 +282,7 @@ func (c *Core) handleClose() ([]byte, error) { _ = c.beeperStream.Close() } c.beeperStream = nil + c.beeperAIRuns = make(map[string]*beeperAIRunState) c.appserviceProcessor = nil c.nextBatch = "" c.pendingDecryptions = nil diff --git a/packages/pickle/native/internal/core/messages.go b/packages/pickle/native/internal/core/messages.go index 55284ed..cd73bf3 100644 --- a/packages/pickle/native/internal/core/messages.go +++ b/packages/pickle/native/internal/core/messages.go @@ -12,7 +12,6 @@ import ( agui "github.com/beeper/ai-bridge/pkg/ag-ui" aistream "github.com/beeper/ai-bridge/pkg/ai-stream" - aibridgev2 "github.com/beeper/ai-bridge/pkg/ai-stream/bridgev2" "maunium.net/go/mautrix" mautrixbeeperstream "maunium.net/go/mautrix/beeperstream" "maunium.net/go/mautrix/event" @@ -331,8 +330,8 @@ func (c *Core) finalizeBeeperStreamMessage(ctx context.Context, req MatrixFinali if content["msgtype"] == nil { content["msgtype"] = "m.text" } - content = OutboundEvent(aibridgev2.FinalEditExtra(content)) - topLevel := mergeOutboundEvent(req.TopLevelContent, OutboundEvent(aibridgev2.FinalEditTopLevelExtra())) + content = beeperStreamFinalEditExtra(content) + topLevel := mergeOutboundEvent(req.TopLevelContent, beeperStreamFinalEditTopLevelExtra()) replacement, err := c.sendBeeperStreamReplacementEvent(ctx, req.RoomID, req.EventID, req.UserID, content, topLevel) if err != nil { return MatrixFinalizeBeeperStreamMessageResult{}, err @@ -351,6 +350,22 @@ func (c *Core) finalizeBeeperStreamMessage(ctx context.Context, req MatrixFinali }, nil } +func beeperStreamFinalEditExtra(extra OutboundEvent) OutboundEvent { + out := make(OutboundEvent, len(extra)+1) + for key, value := range extra { + out[key] = value + } + out["com.beeper.stream"] = nil + return out +} + +func beeperStreamFinalEditTopLevelExtra() OutboundEvent { + return OutboundEvent{ + "com.beeper.dont_render_edited": true, + "com.beeper.stream": nil, + } +} + func (c *Core) sendBeeperStreamReplacementEvent(ctx context.Context, roomID, eventID, userID string, newContent, topLevel OutboundEvent) (*mautrix.RespSendEvent, error) { content := copyOutboundEvent(topLevel) content["body"] = firstString(newContent["body"], "") diff --git a/packages/pickle/src/beeper/auth.test.ts b/packages/pickle/src/beeper/auth.test.ts index c9a5601..d14499f 100644 --- a/packages/pickle/src/beeper/auth.test.ts +++ b/packages/pickle/src/beeper/auth.test.ts @@ -72,7 +72,8 @@ describe("beeper auth", () => { 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 === "/user/login/response") return Response.json({ leadToken: "lead-token", usernameSuggestions: ["qatest123"] }); + if (path === "/user/register") return Response.json({ token: "beeper-jwt" }); if (path === "/_matrix/client/v3/login") { return Response.json({ access_token: "access", @@ -88,6 +89,7 @@ describe("beeper auth", () => { fetch: fetchImpl as typeof fetch, getLoginCode: () => "123456", onlyExistingAccounts: false, + username: "bot", })).resolves.toMatchObject({ accessToken: "access", userId: "@bot:beeper.com", @@ -99,6 +101,17 @@ describe("beeper auth", () => { expect(await requestBody(fetchImpl, 2)).toMatchObject({ onlyExistingAccounts: false, }); + expect(await requestBody(fetchImpl, 3)).toEqual({ + acceptTerms: true, + appType: "pickle", + leadToken: "lead-token", + userLoginRequestId: "request-id", + username: "bot", + }); + expect(await requestBody(fetchImpl, 4)).toMatchObject({ + token: "beeper-jwt", + type: "org.matrix.login.jwt", + }); }); it("accepts empty successful Beeper auth responses", async () => { diff --git a/packages/pickle/src/beeper/auth.ts b/packages/pickle/src/beeper/auth.ts index 9c1cdb0..991eb71 100644 --- a/packages/pickle/src/beeper/auth.ts +++ b/packages/pickle/src/beeper/auth.ts @@ -3,6 +3,7 @@ import { loginWithMatrixToken, type MatrixAuthenticatedAccount } from "../auth"; export type BeeperEnvironment = "production" | "staging" | "dev" | "local"; export interface BeeperAuthOptions { + acceptTerms?: boolean; email: string; env?: BeeperEnvironment; fetch?: typeof fetch; @@ -10,6 +11,7 @@ export interface BeeperAuthOptions { initialDeviceDisplayName?: string; metadata?: Record; onlyExistingAccounts?: boolean; + username?: string; } export interface BeeperAuthStartResult { @@ -40,7 +42,12 @@ export async function createBeeperLogin(options: BeeperAuthOptions): Promise { const raw = await beeperRequest(fetchImpl, domain, "/user/login/response", { appType: "pickle", @@ -108,12 +120,46 @@ export async function sendBeeperLoginCode( request: requestId, response: code, }); + const loginToken = readOptionalString(raw, "token"); + if (loginToken) { + return { + loginToken, + raw, + }; + } + const leadToken = readOptionalString(raw, "leadToken"); + if (leadToken && options.onlyExistingAccounts === false) { + const registered = await registerBeeperUser(fetchImpl, domain, { + acceptTerms: options.acceptTerms ?? true, + leadToken, + requestId, + username: options.username ?? firstString(readArray(raw, "usernameSuggestions")) ?? usernameFromEmail(options.email), + }); + return { + loginToken: readRequiredString(registered, "token"), + raw: registered, + }; + } return { loginToken: readRequiredString(raw, "token"), raw, }; } +async function registerBeeperUser( + fetchImpl: typeof fetch, + domain: string, + options: { acceptTerms: boolean; leadToken: string; requestId: string; username: string } +): Promise { + return beeperRequest(fetchImpl, domain, "/user/register", { + acceptTerms: options.acceptTerms, + appType: "pickle", + leadToken: options.leadToken, + userLoginRequestId: options.requestId, + username: options.username, + }); +} + async function beeperRequest( fetchImpl: typeof fetch, domain: string, @@ -157,9 +203,22 @@ function readOptionalString(value: unknown, key: string): string | undefined { } function readStringArray(value: unknown, key: string): string[] { + return readArray(value, key).filter((item): item is string => typeof item === "string"); +} + +function readArray(value: unknown, key: string): unknown[] { if (!value || typeof value !== "object") { return []; } const field = (value as Record)[key]; - return Array.isArray(field) ? field.filter((item): item is string => typeof item === "string") : []; + return Array.isArray(field) ? field : []; +} + +function firstString(values: unknown[]): string | undefined { + return values.find((value): value is string => typeof value === "string" && value.trim().length > 0)?.trim(); +} + +function usernameFromEmail(email: string | undefined): string { + const local = email?.split("@")[0]?.replace(/\+/gu, "") ?? `pickle${Date.now()}`; + return local.toLowerCase().replace(/[^a-z0-9._=-]+/gu, "").slice(0, 30) || `pickle${Date.now()}`; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d1df24d..6ca4f7f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -218,10 +218,6 @@ importers: 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: - beeper-cli: - specifier: ^0.6.2 - version: 0.6.2 devDependencies: '@beeper/pickle-ag-ui': specifier: workspace:^ @@ -1548,10 +1544,6 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - beeper-cli@0.6.2: - resolution: {integrity: sha512-yVhaKTzO2ZuMfatwSfHaa/v8zfp4IthuEkah2zbUGeBPjPHW/L0rVP/6dFzxYUFE/hLMJ9u8X6yj38Nh3KmzkQ==} - hasBin: true - better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} @@ -4486,8 +4478,6 @@ snapshots: base64-js@1.5.1: {} - beeper-cli@0.6.2: {} - better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 From ad054c34ddbe8b0f8a2fd0101346a341d965fb76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Wed, 3 Jun 2026 06:09:12 +0200 Subject: [PATCH 53/56] Build packages before CI tests --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd25e70..572c878 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,15 +37,15 @@ jobs: - name: Typecheck run: pnpm typecheck + - name: Build + run: pnpm build + - name: Test run: pnpm test - name: Go test run: pnpm test:go - - name: Build - run: pnpm build - - name: Pack packages run: pnpm pack:packages From b15b43558215275ec773add4a827e357e701ffca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Wed, 3 Jun 2026 06:14:59 +0200 Subject: [PATCH 54/56] Avoid deep OpenClaw plugin test comparisons --- .../openclaw/src/openclaw-extension.test.ts | 42 ++++++---------- packages/openclaw/src/setup.test.ts | 50 ++++++++----------- 2 files changed, 37 insertions(+), 55 deletions(-) diff --git a/packages/openclaw/src/openclaw-extension.test.ts b/packages/openclaw/src/openclaw-extension.test.ts index 37b9184..c8fbf4c 100644 --- a/packages/openclaw/src/openclaw-extension.test.ts +++ b/packages/openclaw/src/openclaw-extension.test.ts @@ -18,38 +18,28 @@ describe("OpenClaw plugin package metadata", () => { }); expect(extension.id).toBe("beeper"); expect(extension.kind).toBe("bundled-channel-entry"); - expect(extension.loadChannelPlugin()).toMatchObject({ id: "beeper" }); + const loadedPlugin = extension.loadChannelPlugin(); + expect(loadedPlugin.id).toBe("beeper"); expect(extension.loadChannelSecrets()).toMatchObject({ secretTargetRegistryEntries: expect.arrayContaining([ expect.objectContaining({ pathPattern: "channels.beeper.accounts.*.asToken" }), expect.objectContaining({ pathPattern: "channels.beeper.accounts.*.hsToken" }), ]), }); - expect(resolveBundledRuntimeChannelRegistration(extension)).toMatchObject({ - id: "beeper", - plugin: expect.objectContaining({ - id: "beeper", - setupWizard: expect.any(Object), - }), - }); - expect(registered).toEqual([ - expect.objectContaining({ - capabilities: expect.objectContaining({ - reactions: true, - threads: true, - }), - 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), - threading: expect.any(Object), - }), - ]); + const runtimeRegistration = resolveBundledRuntimeChannelRegistration(extension); + expect(runtimeRegistration.id).toBe("beeper"); + expect(runtimeRegistration.plugin.id).toBe("beeper"); + expect(runtimeRegistration.plugin.setupWizard).toEqual(expect.any(Object)); + expect(registered).toHaveLength(1); + const [registeredPlugin] = registered as Array; + expect(registeredPlugin.id).toBe("beeper"); + expect(registeredPlugin.capabilities.reactions).toBe(true); + expect(registeredPlugin.capabilities.threads).toBe(true); + expect(registeredPlugin.message?.live?.capabilities.nativeStreaming).toBe(true); + expect(registeredPlugin.messaging).toEqual(expect.any(Object)); + expect(registeredPlugin.setup).toEqual(expect.any(Object)); + expect(registeredPlugin.setupWizard).toEqual(expect.any(Object)); + expect(registeredPlugin.threading).toEqual(expect.any(Object)); }, 15_000); it("honors SDK channel registration modes", () => { diff --git a/packages/openclaw/src/setup.test.ts b/packages/openclaw/src/setup.test.ts index 575d7f3..bd4693e 100644 --- a/packages/openclaw/src/setup.test.ts +++ b/packages/openclaw/src/setup.test.ts @@ -192,35 +192,27 @@ describe("OpenClaw Beeper setup surface", () => { }); it("exposes a channel plugin through the setup entry shape OpenClaw loads", () => { - expect(extension.loadChannelPlugin()).toMatchObject({ id: "beeper" }); - expect(beeperChannelPlugin).toMatchObject({ - id: "beeper", - meta: { - id: "beeper", - label: "Beeper", - }, - capabilities: { - media: true, - nativeCommands: true, - reactions: true, - threads: true, - }, - threading: expect.any(Object), - reload: { - configPrefixes: ["channels.beeper"], - }, - gateway: { - startAccount: expect.any(Function), - stopAccount: expect.any(Function), - }, - uiHints: expect.objectContaining({ - "accounts.*.asToken": expect.objectContaining({ sensitive: true, tags: ["hidden"] }), - "accounts.*.hsToken": expect.objectContaining({ sensitive: true, tags: ["hidden"] }), - "accounts.*.serverEnv": expect.objectContaining({ - help: expect.stringContaining("Choose before Beeper login"), - }), - }), - }); + expect(extension.loadChannelPlugin().id).toBe("beeper"); + expect(beeperChannelPlugin.id).toBe("beeper"); + expect(beeperChannelPlugin.meta.id).toBe("beeper"); + expect(beeperChannelPlugin.meta.label).toBe("Beeper"); + expect(beeperChannelPlugin.capabilities.media).toBe(true); + expect(beeperChannelPlugin.capabilities.nativeCommands).toBe(true); + expect(beeperChannelPlugin.capabilities.reactions).toBe(true); + expect(beeperChannelPlugin.capabilities.threads).toBe(true); + expect(beeperChannelPlugin.threading).toEqual(expect.any(Object)); + expect(beeperChannelPlugin.reload?.configPrefixes).toEqual(["channels.beeper"]); + expect(beeperChannelPlugin.gateway?.startAccount).toEqual(expect.any(Function)); + expect(beeperChannelPlugin.gateway?.stopAccount).toEqual(expect.any(Function)); + expect(beeperChannelPlugin.uiHints["accounts.*.asToken"]).toEqual( + expect.objectContaining({ sensitive: true, tags: ["hidden"] }), + ); + expect(beeperChannelPlugin.uiHints["accounts.*.hsToken"]).toEqual( + expect.objectContaining({ sensitive: true, tags: ["hidden"] }), + ); + expect(beeperChannelPlugin.uiHints["accounts.*.serverEnv"]).toEqual(expect.objectContaining({ + help: expect.stringContaining("Choose before Beeper login"), + })); expect(beeperChannelPlugin.setup).toBe(beeperSetupAdapter); expect(beeperChannelPlugin.setupWizard).toBe(beeperSetupWizard); }); From 8670bcabd418fe3d37e122ab2649c27e37cdc2bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Wed, 3 Jun 2026 06:18:23 +0200 Subject: [PATCH 55/56] Increase OpenClaw CI smoke test timeouts --- packages/openclaw/src/openclaw-extension.test.ts | 2 +- packages/openclaw/src/setup.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/openclaw/src/openclaw-extension.test.ts b/packages/openclaw/src/openclaw-extension.test.ts index c8fbf4c..5bb71aa 100644 --- a/packages/openclaw/src/openclaw-extension.test.ts +++ b/packages/openclaw/src/openclaw-extension.test.ts @@ -40,7 +40,7 @@ describe("OpenClaw plugin package metadata", () => { expect(registeredPlugin.setup).toEqual(expect.any(Object)); expect(registeredPlugin.setupWizard).toEqual(expect.any(Object)); expect(registeredPlugin.threading).toEqual(expect.any(Object)); - }, 15_000); + }, 60_000); it("honors SDK channel registration modes", () => { const registerChannel = vi.fn(); diff --git a/packages/openclaw/src/setup.test.ts b/packages/openclaw/src/setup.test.ts index bd4693e..f4d39b1 100644 --- a/packages/openclaw/src/setup.test.ts +++ b/packages/openclaw/src/setup.test.ts @@ -215,7 +215,7 @@ describe("OpenClaw Beeper setup surface", () => { })); expect(beeperChannelPlugin.setup).toBe(beeperSetupAdapter); expect(beeperChannelPlugin.setupWizard).toBe(beeperSetupWizard); - }); + }, 60_000); it("matches the OpenClaw channel contract surface used by the dashboard and runtime", async () => { expect(beeperChannelPlugin.id).toBe("beeper"); From 14f1468ebc0684a5e37df791cf0be20ef5ada041 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Wed, 3 Jun 2026 17:05:40 +0200 Subject: [PATCH 56/56] Harden release pipeline manifests --- .github/workflows/ci.yml | 8 ++-- .github/workflows/release.yml | 8 ++-- CONTRIBUTING.md | 5 +++ package.json | 2 +- packages/cloudflare/package.json | 1 + packages/pi/LICENSE | 8 ++++ packages/pi/package.json | 1 + packages/pickle/package.json | 1 + packages/state-file/package.json | 6 +++ packages/state-simple/package.json | 6 +++ packages/state-sqlite/package.json | 6 +++ scripts/audit-package-surface.mjs | 41 ++++++++++++++++++ scripts/guard-pnpm-publish.mjs | 2 +- scripts/smoke-cloudflare-worker.mjs | 29 +++++++++---- scripts/smoke-consumer.mjs | 64 +---------------------------- 15 files changed, 107 insertions(+), 81 deletions(-) create mode 100644 packages/pi/LICENSE diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 572c878..9e0e198 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,21 +12,21 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: version: 10.25.0 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 22 cache: pnpm - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version-file: packages/pickle/native/go.mod cache-dependency-path: packages/pickle/native/go.sum diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 87732fe..add90cd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,24 +20,24 @@ jobs: pull-requests: write steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: version: 10.25.0 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 24 registry-url: https://registry.npmjs.org cache: pnpm - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version-file: packages/pickle/native/go.mod cache-dependency-path: packages/pickle/native/go.sum diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index edd826a..84650d9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,6 +32,11 @@ When changes land on `main`, GitHub Actions opens or updates a release PR. Merging that release PR runs the full `pnpm check` gate, publishes changed packages with `pnpm changeset publish`, and creates GitHub Releases. +For the initial public release, package manifests already start at `0.1.0`. +Publish those unpublished `0.1.0` packages directly through the release +workflow; after that first publish, every user-facing package change should +carry a normal changeset. + Publishing uses npm Trusted Publishing through GitHub Actions OIDC. Each npm package must configure this trusted publisher on npmjs.com: diff --git a/package.json b/package.json index 0afc95a..42dded2 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "clean": "pnpm -r clean", "changeset": "changeset", "full-test": "pnpm check && pnpm test:openclaw:plugins", - "pack:packages": "mkdir -p .packs && pnpm -r --filter './packages/*' pack --pack-destination ./.packs", + "pack:packages": "rm -rf .packs && mkdir -p .packs && pnpm -r --filter './packages/*' pack --pack-destination ./.packs", "release": "pnpm check && pnpm changeset publish", "smoke:cloudflare": "node scripts/smoke-cloudflare-worker.mjs", "smoke:consumer": "node scripts/package-consumer-smoke.mjs", diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 7e9eb32..5feed8a 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -1,6 +1,7 @@ { "name": "@beeper/pickle-cloudflare", "version": "0.1.0", + "private": true, "description": "Cloudflare Workers and Durable Objects helpers for Pickle", "type": "module", "homepage": "https://github.com/beeper/pickle#readme", diff --git a/packages/pi/LICENSE b/packages/pi/LICENSE new file mode 100644 index 0000000..d04286b --- /dev/null +++ b/packages/pi/LICENSE @@ -0,0 +1,8 @@ +Mozilla Public License Version 2.0 +================================== + +This package is licensed under the Mozilla Public License, version 2.0. + +The full license text is available at: + +https://www.mozilla.org/MPL/2.0/ diff --git a/packages/pi/package.json b/packages/pi/package.json index b16004d..857359d 100644 --- a/packages/pi/package.json +++ b/packages/pi/package.json @@ -71,6 +71,7 @@ "scripts": { "build": "tsdown", "clean": "rm -rf dist", + "prepublishOnly": "node ../../scripts/guard-pnpm-publish.mjs", "test": "vitest run --coverage", "typecheck": "tsc --noEmit" }, diff --git a/packages/pickle/package.json b/packages/pickle/package.json index 8455bc7..20dcb98 100644 --- a/packages/pickle/package.json +++ b/packages/pickle/package.json @@ -64,6 +64,7 @@ "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 -tags goolm ./cmd/matrix-ts-types", + "prepublishOnly": "node ../../scripts/guard-pnpm-publish.mjs", "test:go": "cd native && go test -tags goolm ./...", "test": "vitest run --coverage", "typecheck": "npm run generate:types && tsc --noEmit" diff --git a/packages/state-file/package.json b/packages/state-file/package.json index f2a5f08..b659247 100644 --- a/packages/state-file/package.json +++ b/packages/state-file/package.json @@ -2,6 +2,12 @@ "name": "@beeper/pickle-state-file", "version": "0.1.0", "description": "Filesystem Matrix state adapter for Pickle", + "repository": { + "type": "git", + "url": "git+https://github.com/beeper/pickle.git", + "directory": "packages/state-file" + }, + "license": "MPL-2.0", "type": "module", "main": "./dist/index.js", "module": "./dist/index.js", diff --git a/packages/state-simple/package.json b/packages/state-simple/package.json index 39e3203..af414ec 100644 --- a/packages/state-simple/package.json +++ b/packages/state-simple/package.json @@ -2,6 +2,12 @@ "name": "@beeper/pickle-state-simple", "version": "0.1.0", "description": "Simple getter/setter Matrix state adapter for Pickle", + "repository": { + "type": "git", + "url": "git+https://github.com/beeper/pickle.git", + "directory": "packages/state-simple" + }, + "license": "MPL-2.0", "type": "module", "main": "./dist/index.js", "module": "./dist/index.js", diff --git a/packages/state-sqlite/package.json b/packages/state-sqlite/package.json index 0f092da..df248f0 100644 --- a/packages/state-sqlite/package.json +++ b/packages/state-sqlite/package.json @@ -2,6 +2,12 @@ "name": "@beeper/pickle-state-sqlite", "version": "0.1.0", "description": "SQLite Matrix state adapter for Pickle", + "repository": { + "type": "git", + "url": "git+https://github.com/beeper/pickle.git", + "directory": "packages/state-sqlite" + }, + "license": "MPL-2.0", "type": "module", "main": "./dist/index.js", "module": "./dist/index.js", diff --git a/scripts/audit-package-surface.mjs b/scripts/audit-package-surface.mjs index 55848c4..51edc7e 100644 --- a/scripts/audit-package-surface.mjs +++ b/scripts/audit-package-surface.mjs @@ -15,6 +15,9 @@ for (const entry of packages) { continue; } const packageJson = JSON.parse(await readFile(join(packageDir, "package.json"), "utf8")); + if (!packageJson.private) { + await auditPublishManifest(packageJson, packageDir); + } const sourceDir = join(packageDir, "src"); for (const file of await sourceFiles(sourceDir)) { const source = await readFile(file, "utf8"); @@ -46,6 +49,44 @@ async function exists(file) { } } +async function auditPublishManifest(packageJson, packageDir) { + const packagePath = relative(root, join(packageDir, "package.json")); + const requiredFields = ["name", "version", "description", "license", "type", "exports", "files", "publishConfig"]; + for (const field of requiredFields) { + if (packageJson[field] === undefined) { + failures.push(`${packagePath} missing ${field}`); + } + } + + const packageDirectory = relative(root, packageDir); + if (packageJson.repository?.type !== "git") { + failures.push(`${packagePath} missing repository.type=git`); + } + if (packageJson.repository?.url !== "git+https://github.com/beeper/pickle.git") { + failures.push(`${packagePath} missing repository.url`); + } + if (packageJson.repository?.directory !== packageDirectory) { + failures.push(`${packagePath} missing repository.directory=${packageDirectory}`); + } + if (packageJson.publishConfig?.access !== "public") { + failures.push(`${packagePath} missing publishConfig.access=public`); + } + if (!packageJson.files?.includes("README.md")) { + failures.push(`${packagePath} files missing README.md`); + } else if (!await exists(join(packageDir, "README.md"))) { + failures.push(`${packagePath} lists README.md but the file is missing`); + } + if (!packageJson.files?.includes("LICENSE")) { + failures.push(`${packagePath} files missing LICENSE`); + } else if (!await exists(join(packageDir, "LICENSE"))) { + failures.push(`${packagePath} lists LICENSE but the file is missing`); + } + if (packageJson.scripts?.prepublishOnly !== "node ../../scripts/guard-pnpm-publish.mjs" + && packageJson.scripts?.prepublishOnly !== "node ../../scripts/guard-pnpm-publish.mjs && pnpm build") { + failures.push(`${packagePath} missing workspace publish guard`); + } +} + async function sourceFiles(dir) { const result = []; for (const entry of await readdir(dir, { withFileTypes: true })) { diff --git a/scripts/guard-pnpm-publish.mjs b/scripts/guard-pnpm-publish.mjs index 3d009e4..f40d4d9 100644 --- a/scripts/guard-pnpm-publish.mjs +++ b/scripts/guard-pnpm-publish.mjs @@ -2,7 +2,7 @@ const execPath = process.env.npm_execpath ?? ""; if (!execPath.includes("pnpm")) { console.error( - "Publish this workspace with `pnpm publish:packages` so workspace dependencies are rewritten for npm." + "Publish this workspace with `pnpm release` or `pnpm changeset publish` so workspace dependencies are rewritten for npm." ); process.exit(1); } diff --git a/scripts/smoke-cloudflare-worker.mjs b/scripts/smoke-cloudflare-worker.mjs index 4ee5079..b587fe2 100644 --- a/scripts/smoke-cloudflare-worker.mjs +++ b/scripts/smoke-cloudflare-worker.mjs @@ -1,4 +1,4 @@ -import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { execFile, spawn } from "node:child_process"; @@ -13,17 +13,16 @@ const workerDir = join(temp, "worker"); const srcDir = join(workerDir, "src"); await mkdir(packDir, { recursive: true }); -await execFileAsync( - "pnpm", - ["-r", "--filter", "@beeper/pickle", "--filter", "@beeper/pickle-cloudflare", "pack", "--pack-destination", packDir], - { cwd: rootPath } -); +const picklePackage = await readPackage(join(rootPath, "packages/pickle/package.json")); +const cloudflarePackage = await readPackage(join(rootPath, "packages/cloudflare/package.json")); +const pickleTarball = await packPackage(picklePackage.name, packDir); +const cloudflareTarball = await packPackage(cloudflarePackage.name, packDir); await mkdir(srcDir, { recursive: true }); await execFileAsync("npm", ["init", "-y"], { cwd: workerDir }); await execFileAsync("npm", [ "install", - join(packDir, "beeper-pickle-0.1.0.tgz"), - join(packDir, "beeper-pickle-cloudflare-0.1.0.tgz"), + pickleTarball, + cloudflareTarball, ], { cwd: workerDir }); await writeFile( @@ -150,6 +149,20 @@ async function waitFor(predicate, timeoutMs) { } } +async function readPackage(path) { + return JSON.parse(await readFile(path, "utf8")); +} + +async function packPackage(packageName, destination) { + const { stdout } = await execFileAsync( + "pnpm", + ["--filter", packageName, "pack", "--pack-destination", destination, "--json"], + { cwd: rootPath } + ); + const packResult = JSON.parse(stdout); + return packResult.filename; +} + async function waitForHttp(url, timeoutMs) { const started = Date.now(); let lastError; diff --git a/scripts/smoke-consumer.mjs b/scripts/smoke-consumer.mjs index b158beb..40cc089 100644 --- a/scripts/smoke-consumer.mjs +++ b/scripts/smoke-consumer.mjs @@ -1,63 +1 @@ -import { mkdtemp } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; - -const execFileAsync = promisify(execFile); - -const root = new URL("..", import.meta.url); -const rootPath = root.pathname; -const temp = await mkdtemp(join(tmpdir(), "pickle-consumer-")); -const packDir = join(temp, "packs"); -const consumerDir = join(temp, "consumer"); - -await mkdirp(packDir); -await execFileAsync("pnpm", ["-r", "--filter", "./packages/*", "pack", "--pack-destination", packDir], { - cwd: rootPath, -}); -await execFileAsync("npm", ["init", "-y"], { cwd: await mkdirp(consumerDir) }); - -const pickleTarball = join(packDir, "beeper-pickle-0.1.0.tgz"); -const cloudflareTarball = join(packDir, "beeper-pickle-cloudflare-0.1.0.tgz"); -const adapterTarball = join(packDir, "beeper-pickle-chat-adapter-0.1.0.tgz"); - -await execFileAsync("npm", ["install", pickleTarball, cloudflareTarball, adapterTarball, "chat@4.26.0"], { - cwd: consumerDir, -}); - -const { stdout } = await execFileAsync( - process.execPath, - [ - "--input-type=module", - "--eval", - ` - import * as pickle from "@beeper/pickle"; - import * as auth from "@beeper/pickle/auth"; - import * as beeperAuth from "@beeper/pickle/beeper/auth"; - import * as node from "@beeper/pickle/node"; - import * as cf from "@beeper/pickle-cloudflare"; - import * as adapter from "@beeper/pickle-chat-adapter"; - const checks = { - pickle: ["createMatrixClient"].every((key) => key in pickle), - auth: ["loginWithMatrixPassword", "loginWithMatrixToken"].every((key) => key in auth), - beeperAuth: ["createBeeperLogin"].every((key) => key in beeperAuth), - node: ["createMatrixClient"].every((key) => key in node), - cloudflare: ["createCloudflareKVMatrixStore", "createDurableObjectMatrixStore", "MatrixSyncDurableObject"].every((key) => key in cf), - adapter: ["createMatrixAdapter"].every((key) => key in adapter), - }; - if (!Object.values(checks).every(Boolean)) { - throw new Error(JSON.stringify(checks)); - } - console.log(JSON.stringify(checks)); - `, - ], - { cwd: consumerDir } -); - -console.log(stdout.trim()); - -async function mkdirp(path) { - await import("node:fs/promises").then(({ mkdir }) => mkdir(path, { recursive: true })); - return path; -} +import "./package-consumer-smoke.mjs";