Skip to content
Open
Show file tree
Hide file tree
Changes from 44 commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
eced5a1
Add OpenClaw bridge package skeleton
May 16, 2026
a388a23
Add OpenClaw bridge registration config
May 16, 2026
5905c34
Add OpenClaw agent room registry
May 16, 2026
313886f
Add OpenClaw approval response mapping
May 16, 2026
41b9fc6
Add OpenClaw gateway runtime wrapper
May 16, 2026
043966c
Add OpenClaw Matrix bridge coordinator
May 16, 2026
7252973
Add OpenClaw bridge management CLI
May 16, 2026
8b59e29
Add OpenClaw Pickle bridge connector
May 16, 2026
2974170
Add OpenClaw session backfill planning
May 16, 2026
cbf35b6
Wire OpenClaw history into bridge backfill
May 16, 2026
2ba589d
Add OpenClaw Beeper setup helpers
May 16, 2026
4918041
Add OpenClaw HTTP gateway transport
May 16, 2026
5922ca4
Add OpenClaw Beeper bridge runtime
May 16, 2026
c449f8a
Persist OpenClaw Beeper account identity
May 16, 2026
75b2fcb
Add OpenClaw bridge start command
May 16, 2026
d460ca3
Support Beeper account creation in OpenClaw setup
May 16, 2026
c6e401d
Model OpenClaw session users as ghosts
May 16, 2026
afeeabe
Add OpenClaw WebSocket gateway transport
May 16, 2026
b28f9fd
Expose broader OpenClaw gateway features
May 16, 2026
2d89f80
Add OpenClaw session backfill executor
May 16, 2026
adcb310
Normalize OpenClaw gateway stream events
May 16, 2026
58cfd37
Add OpenClaw protocol coverage manifest
May 16, 2026
f75e8b4
Document OpenClaw bridge package usage
May 16, 2026
a7c03ef
Wire OpenClaw startup backfill
batuhan May 16, 2026
7b6fce6
Force non-federated OpenClaw portals
batuhan May 16, 2026
227f644
Expose OpenClaw gateway RPC management
batuhan May 16, 2026
00a8a11
Create sessions for OpenClaw agent DMs
batuhan May 16, 2026
60ba686
Merge remote-tracking branch 'origin/main' into batuhan/oc-2
May 24, 2026
514a1a9
Refactor core workflow and supporting modules
May 24, 2026
51d4bdf
Remove gateway token fallback from OpenClaw bridge
May 25, 2026
485850a
Refine pickle openclaw plugin packaging and session handling
May 25, 2026
b38b4d9
Stream OpenClaw runs through AG-UI channel replies
May 25, 2026
b33f1c2
Require native OpenClaw channel turns for Beeper streaming
May 25, 2026
6a06ccd
Fix Beeper plugin to stream AG-UI responses natively
May 25, 2026
7475c36
Propagate Matrix room ids into OpenClaw turns
May 25, 2026
d58a809
Fix Beeper streaming and approval tool-call shapes
May 25, 2026
94a36cf
Stream Beeper tool output and handle OpenClaw slash commands
May 26, 2026
584b067
Add HTML formatting to OpenClaw command replies
May 27, 2026
41644c2
Rewrite OpenClaw as a first-class Beeper network connector
May 27, 2026
cb8fc40
Refactor pickle data flow and UI state handling
May 27, 2026
739e0ec
Sync openclaw manifest schema with bridge config
May 27, 2026
2e805e6
Enable native Beeper stream identities and tool streaming
May 27, 2026
7274fad
Handle Codex tool stream events without false positives
May 27, 2026
1c6b06c
Rename OpenClaw package and remove plan files
May 27, 2026
97207e4
Update beeper runtime and client operation types
Jun 1, 2026
7bee27a
sync
Jun 2, 2026
0349ca5
Refactor bridge core for bridgev2 room state handling
Jun 2, 2026
28cac3c
Push ghost profiles through bridgev2 appservice intents
Jun 2, 2026
090c8ee
Fix Beeper rich stream finalization
Jun 2, 2026
c4d9f2e
wip
Jun 2, 2026
8e6eb49
Add multi-account Beeper onboarding
batuhan Jun 2, 2026
0de2f1d
Add Beeper account auth presence
batuhan Jun 2, 2026
2dd203d
Fix OpenClaw bridge release checks
batuhan Jun 3, 2026
ad054c3
Build packages before CI tests
batuhan Jun 3, 2026
b15b435
Avoid deep OpenClaw plugin test comparisons
batuhan Jun 3, 2026
8670bca
Increase OpenClaw CI smoke test timeouts
batuhan Jun 3, 2026
14f1468
Harden release pipeline manifests
batuhan Jun 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "pickle-monorepo",
"name": "@beeper/openclaw",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
"private": true,
"type": "module",
"packageManager": "pnpm@10.25.0",
Expand All @@ -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",
Expand Down
26 changes: 10 additions & 16 deletions packages/bridge/src/appservice-websocket.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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<void>((resolve, reject) => {
wsServer.on("connection", (socket, request) => {
try {
Expand Down Expand Up @@ -55,19 +55,21 @@ describe("AppserviceWebsocket", () => {
});
});
const websocket = createWebsocket(homeserver, {
dispatch,
handleTransaction,
log: (() => {}) as BridgeLogger,
});
websockets.push(websocket);

websocket.start();
await connected;

expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({
eventId: "$event",
kind: "message",
roomId: "!room:example",
text: "hi",
expect(handleTransaction).toHaveBeenCalledWith(expect.objectContaining({
events: [expect.objectContaining({
event_id: "$event",
room_id: "!room:example",
type: "m.room.message",
})],
txn_id: "txn-1",
}));
});

Expand Down Expand Up @@ -147,7 +149,6 @@ describe("AppserviceWebsocket", () => {
servers.push(wsServer, httpServer);
await new Promise<void>((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<void>((resolve, reject) => {
wsServer.on("connection", (socket) => {
Expand Down Expand Up @@ -183,7 +184,6 @@ describe("AppserviceWebsocket", () => {
});
});
const websocket = createWebsocket(homeserver, {
dispatch,
handleTransaction,
log: (() => {}) as BridgeLogger,
});
Expand All @@ -192,11 +192,6 @@ describe("AppserviceWebsocket", () => {
websocket.start();
await connected;

expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({
eventId: "$proxied",
kind: "message",
text: "proxied",
}));
expect(handleTransaction).toHaveBeenCalledWith(expect.objectContaining({
events: [expect.objectContaining({ event_id: "$proxied" })],
txn_id: "txn-2",
Expand Down Expand Up @@ -327,7 +322,6 @@ function createWebsocket(
url: "",
},
},
dispatch: vi.fn(async () => {}),
log: (() => {}) as BridgeLogger,
...overrides,
});
Expand Down
124 changes: 15 additions & 109 deletions packages/bridge/src/appservice-websocket.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>;
handleHTTPProxy?(request: HTTPProxyRequest): Promise<HTTPProxyResponse | null>;
handleTransaction?(transaction: Record<string, unknown>): Promise<unknown>;
log: BridgeLogger;
Expand Down Expand Up @@ -40,7 +39,6 @@ export class AppserviceWebsocket {
};

readonly #appservice: MatrixAppserviceInitOptions;
readonly #dispatch: (event: MatrixClientEvent) => Promise<unknown>;
readonly #handleProxy: ((request: HTTPProxyRequest) => Promise<HTTPProxyResponse | null>) | undefined;
readonly #handleTransaction: ((transaction: Record<string, unknown>) => Promise<unknown>) | undefined;
readonly #log: BridgeLogger;
Expand All @@ -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;
Expand Down Expand Up @@ -201,7 +198,19 @@ export class AppserviceWebsocket {
}

async #handleMessage(data: WebSocket.RawData): Promise<void> {
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,
Expand All @@ -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<string, unknown>);
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;
}
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -324,7 +319,7 @@ interface WebsocketRequest {
interface WebsocketMessage {
command?: string;
data?: unknown;
events?: RawMatrixEvent[];
events?: unknown[];
id?: number;
status?: string;
to_device?: unknown;
Expand All @@ -346,19 +341,6 @@ export interface HTTPProxyResponse {
status: number;
}

interface RawMatrixEvent {
[key: string]: unknown;
content?: Record<string, unknown>;
event_id?: string;
origin_server_ts?: number;
redacts?: string;
room_id?: string;
sender?: string;
state_key?: string;
type?: string;
unsigned?: Record<string, unknown>;
}

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 {
Expand Down Expand Up @@ -400,86 +382,10 @@ function eventCount(events: unknown): number | undefined {
return Array.isArray(events) && events.length > 0 ? events.length : undefined;
}

function rawMatrixEvent(raw: RawMatrixEvent): MatrixClientEvent | null {
const type = raw.type ?? "";
const content = raw.content ?? {};
const roomId = raw.room_id;
const eventId = raw.event_id;
const senderId = raw.sender;
const sender = senderId ? { isMe: false, userId: senderId } : undefined;
if (type === "m.room.message" && roomId && eventId && sender) {
return stripUndefined({
attachments: [],
class: "message",
content,
edited: false,
encrypted: false,
eventId,
kind: "message",
messageType: stringValue(content.msgtype) ?? "m.text",
raw,
roomId,
sender,
text: stringValue(content.body) ?? "",
timestamp: raw.origin_server_ts,
type,
unsigned: raw.unsigned,
}) as MatrixClientEvent;
}
if (type === "m.reaction" && roomId && eventId && sender) {
const relates = objectValue(content["m.relates_to"]);
return stripUndefined({
added: true,
class: "message",
content,
eventId,
key: stringValue(relates?.key) ?? "",
kind: "reaction",
raw,
relatesTo: stringValue(relates?.event_id) ?? "",
roomId,
sender,
timestamp: raw.origin_server_ts,
type,
unsigned: raw.unsigned,
}) as MatrixClientEvent;
}
if (type === "m.room.redaction" && roomId) {
return genericEvent("redaction", raw, content);
}
if (type === "m.typing") {
return genericEvent("typing", raw, content);
}
return genericEvent("raw", raw, content);
}

function genericEvent(kind: "raw" | "redaction" | "typing", raw: RawMatrixEvent, content: Record<string, unknown>): 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<string, unknown> | undefined {
return value && typeof value === "object" ? value as Record<string, unknown> : undefined;
}

function stringValue(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}

function stripUndefined<T extends Record<string, unknown>>(value: T): T {
for (const key of Object.keys(value)) {
if (value[key] === undefined) delete value[key];
}
return value;
}
27 changes: 26 additions & 1 deletion packages/bridge/src/beeper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
9 changes: 6 additions & 3 deletions packages/bridge/src/beeper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,19 +112,22 @@ 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,
reason: "SELF_HOST_REGISTERED",
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<void> {
async postBridgeState(options: PostBridgeStateOptions, token?: string): Promise<void> {
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`, {
Expand All @@ -133,7 +136,7 @@ export class BeeperBridgeManagerClient {
isSelfHosted: options.isSelfHosted ?? true,
reason: options.reason,
stateEvent: options.stateEvent,
});
}, undefined, token);
}

async createAppService(options: CreateAppServiceOptions): Promise<RegisteredAppService> {
Expand Down
Loading
Loading