Skip to content

Add pickle bridge-building package with bridgev2-style runtime#4

Merged
batuhan merged 21 commits intomainfrom
batuhan/add-bridge-building-package
May 7, 2026
Merged

Add pickle bridge-building package with bridgev2-style runtime#4
batuhan merged 21 commits intomainfrom
batuhan/add-bridge-building-package

Conversation

@batuhan
Copy link
Copy Markdown
Member

@batuhan batuhan commented May 7, 2026

Summary

  • Add a new @beeper/pickle-bridge workspace package for building bridges on top of Pickle
  • Define bridgev2-shaped connector, login, remote event, and Matrix-facing interfaces in TypeScript
  • Add runtime scaffolding for bridge lifecycle, portal registration, remote event queuing, and basic Matrix event dispatch
  • Include a remote message helper, initial tests, package docs, and workspace/type resolution updates

Testing

  • pnpm --filter @beeper/pickle-bridge test
  • pnpm --filter @beeper/pickle-bridge typecheck
  • pnpm --filter @beeper/pickle-bridge build

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 7, 2026

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 584b042e-4a84-49b2-9187-b90cf188e830

📥 Commits

Reviewing files that changed from the base of the PR and between 040f0af and 222bdb0.

📒 Files selected for processing (1)
  • packages/bridge/src/node.ts

Disabled knowledge base sources:

  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.


📝 Walkthrough

Summary by CodeRabbit

  • New Features
    • Introduced a packaged bridge runtime with full lifecycle, Matrix appservice support, portal/management room creation, backfill, remote-event queuing, media helpers, and identity resolution.
  • Documentation
    • Added comprehensive bridge README and usage examples; new dummy-bridge example with run instructions and env templates.
  • Tests
    • Added extensive integration and unit tests covering bridge runtime, appservice websocket, beeper helpers, and example smoke tests.
  • Chores
    • Workspace/config updates and Node type dependency adjustments.

Walkthrough

This PR adds the @beeper/pickle-bridge package (types, runtime, datastore, beeper helpers, appservice websocket/tests), extends @beeper/pickle with Matrix appservice support (Go core + TS types + client wiring), and includes a DummyBridge example with tests and packaging/config.

Changes

Bridge Package Core

Layer / File(s) Summary
Type System & Contracts
packages/bridge/src/types.ts
Comprehensive type model: identifier primitives, portal/bridge config, connector/network API contracts, login flows, remote-event system, PickleBridge runtime interface, Matrix payload types, and backfill shapes.
Runtime Bridge Implementation
packages/bridge/src/bridge.ts
RuntimeBridge class and factories: lifecycle management, Matrix/appservice init, connector lifecycle, Matrix event subscription/dispatch, portal tracking, remote-event queuing/draining, conversion and send/backfill.
Storage Layer
packages/bridge/src/store.ts
BridgeDataStore interface and MatrixBridgeDataStore implementation with namespaced keys, JSON serialization, MXID secondary index, CRUD for portals/accounts/logins, and factory helper.
Beeper Integration
packages/bridge/src/beeper.ts
BeeperBridgeManagerClient with whoami caching, bridge listing/lookup, appservice registration/init helpers, and normalization utilities.
Appservice WebSocket
packages/bridge/src/appservice-websocket.ts, packages/bridge/src/appservice-websocket.test.ts
AppserviceWebsocket client with auth, ping/reconnect/backoff, protocol parsing (transaction/http_proxy/ping), dispatch to MatrixClientEvent, and tests validating behavior.
Remote Event Creation
packages/bridge/src/events.ts
createRemoteMessage helper producing RemoteMessage-like objects with converters, accessors, and optional transaction ID accessor.
Public API / Barrels
packages/bridge/src/index.ts, packages/bridge/src/node.ts
Barrel exports of bridge constructors, datastore, beeper helpers, events, and types; Node entrypoint re-exports and Node-specific factory helpers.
Package Config
packages/bridge/package.json, packages/bridge/tsconfig.json, packages/bridge/tsdown.config.ts, packages/bridge/vitest.config.ts, pnpm-workspace.yaml, tsconfig.base.json
ESM manifest with subpath exports, TypeScript/tsdown/vitest configurations, workspace inclusion, and path aliases.
Tests / Documentation
packages/bridge/src/bridge.test.ts, packages/bridge/src/beeper.test.ts, packages/bridge/README.md, packages/bridge/docs/BRIDGE_TODO.md, packages/bridge/LICENSE
Vitest suites for runtime and beeper helpers, README usage example and bridge-manager notes, BRIDGE_TODO checklist, and LICENSE set to MPL-2.0.

Appservice Support in Pickle

Layer / File(s) Summary
Go Appservice Handlers
packages/pickle/native/internal/core/appservice.go, packages/pickle/native/internal/core/core.go, packages/pickle/native/internal/core/operations.go
Appservice init, ensure_registered/ensure_joined, create_room, send_message, and batch_send; appservice lifecycle helpers and state-store membership handling.
TypeScript Runtime Types & Ops
packages/pickle/src/generated-runtime-types.ts, packages/pickle/src/generated-runtime-operations.ts, packages/pickle/src/client-types.ts, packages/pickle/src/runtime-types.ts, packages/pickle/src/index.ts
New MatrixAppservice types, generated runtime operation signatures and re-exports for appservice-related types.
Client Integration
packages/pickle/src/client.ts, packages/pickle/src/node.ts
DefaultMatrixClient exposes an appservice API wired to core operations; NodeMatrixClient adds an appservice namespace getter.
Auth helper
packages/pickle/src/auth.ts, packages/pickle/src/auth.test.ts
Added loginWithPassword with PasswordAuthOptions and tests for default and explicit homeserver behavior.

DummyBridge Example

Layer / File(s) Summary
Dummy Connector
examples/dummybridge/src/connector.ts
DummyConnector and DummyNetworkAPI implementing login flows, deterministic ghost MXID generation, echoing Matrix messages as remote events via createRemoteMessage, and basic backfill.
Environment Utilities
examples/dummybridge/src/env.ts, examples/dummybridge/.env.example
loadEnv parser (quote stripping, ENOENT ignore), requiredEnv and optionalEnv helpers, and environment templates for credentials and runtime flags.
Startup Entrypoint
examples/dummybridge/src/index.ts
Startup script using loginWithPassword, deriving server domain, creating/registering management and portal rooms, optional backfill, and signal-based shutdown.
Smoke Test
examples/dummybridge/test/smoke.ts
Smoke test with stubbed MatrixClient recording appservice/backfill/createRoom/sendMessage calls and an in-memory MatrixStore for end-to-end validation.
Packaging & Docs
examples/dummybridge/package.json, examples/dummybridge/tsconfig.json, examples/dummybridge/tsdown.config.ts, examples/dummybridge/README.md, examples/dummybridge/.gitignore
Example package scripts (build/smoke/start/typecheck), tsconfig/tsdown configs, README with live-run and smoke instructions, .env example, and .gitignore update.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch batuhan/add-bridge-building-package

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (2)
packages/bridge/src/events.ts (1)

13-13: ⚡ Quick win

Consider overloads to preserve the return type when transactionId is known.

The current union return type RemoteMessage | RemoteMessageWithTransactionID forces callers to narrow the result even when they statically know transactionId is present. Function overloads eliminate the cast:

♻️ Proposed refactor
+export function createRemoteMessage<T>(options: CreateRemoteMessageOptions<T> & { transactionId: string }): RemoteMessageWithTransactionID;
+export function createRemoteMessage<T>(options: CreateRemoteMessageOptions<T>): RemoteMessage;
 export function createRemoteMessage<T>(options: CreateRemoteMessageOptions<T>): RemoteMessage | RemoteMessageWithTransactionID {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/bridge/src/events.ts` at line 13, The function createRemoteMessage
currently returns a union RemoteMessage | RemoteMessageWithTransactionID which
forces callers to narrow; add two TypeScript overloads — one overload taking
CreateRemoteMessageOptions<T> with a required transactionId (or a discriminant
indicating presence) that returns RemoteMessageWithTransactionID, and another
overload for options without transactionId that returns RemoteMessage — and keep
the existing implementation signature (the broad one returning the union)
unchanged; update the overloads to reference CreateRemoteMessageOptions,
RemoteMessage, and RemoteMessageWithTransactionID so callers who pass a
transactionId get the narrower return type without casts.
packages/bridge/tsdown.config.ts (1)

4-4: ⚡ Quick win

Remove src/events.ts from the build entry points in tsdown.config.ts.

src/events.ts is built as an entry point, but package.json has no ./events subpath export—only ".", "./node", and "./types". Since createRemoteMessage is already re-exported via both index.ts and node.ts, the events.ts entry produces an unreachable dist/events.js file. Removing it from the entry array will clean up the build without affecting the public API.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/bridge/tsdown.config.ts` at line 4, Remove "src/events.ts" from the
entry array in tsdown.config.ts so it is no longer built as an independent entry
point; keep the remaining entries ["src/index.ts", "src/node.ts",
"src/types.ts"], and verify that createRemoteMessage (and any public exports
from events.ts) are still re-exported via index.ts and node.ts so the
package.json exports (".", "./node", "./types") continue to cover the public API
and no unreachable dist/events.js is produced.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/bridge/docs/BRIDGE_TODO.md`:
- Around line 140-143: Update the checklist in BRIDGE_TODO.md to reflect the
tests added in packages/bridge/src/bridge.test.ts: mark "Runtime start/stop
tests", "Remote event queue tests" (queued remote event draining), and "Matrix
sync dispatch tests" as done/checked; leave or separately track "WASM option
forwarding tests" if still unimplemented. Reference the test file name
bridge.test.ts and the specific behaviors (runtime start/stop, queued remote
event draining, Matrix event dispatch) so future contributors see these items
are covered.

In `@packages/bridge/LICENSE`:
- Line 1: The LICENSE file currently contains only the title; replace its
contents with the full Mozilla Public License Version 2.0 text (the complete
license body, including the disclaimer, terms, and how to obtain the license),
ensuring you include the SPDX identifier "MPL-2.0" and any required
copyright/attribution placeholders; update the repository's LICENSE file so it
contains the canonical MPL 2.0 text in its entirety rather than just the title.

In `@packages/bridge/src/bridge.ts`:
- Around line 273-277: The drain logic removes items from the queue before the
handler completes, so failed conversions/sends are lost; modify the drain loop
in `#drainRemoteEvents` (invoked by `#scheduleDrain`) to peek at the next item from
this.#remoteEvents instead of shifting it immediately, call
this.#handleRemoteEvent(item) and only shift/remove the item after the handler
resolves successfully, and if the handler rejects requeue or leave the item in
place (or push it back) inside a catch so it remains for retry; ensure
`#scheduleDrain`'s retry semantics remain unchanged and reference
this.#remoteEvents, `#drainRemoteEvents`, and `#handleRemoteEvent` when implementing
the change.
- Around line 91-98: The current loadUserLogin implementation caches the client
in `#networkClients` before await client.connect(), causing half-initialized
clients to be reused if connect() rejects; change it to either memoize the
in-flight Promise or only insert the client into `#networkClients` after connect()
resolves. Concretely, in loadUserLogin (and where you call
this.connector.loadUserLogin and client.connect) either store a Promise for the
whole load flow in `#networkClients` (so concurrent callers await the same
promise) or defer calling this.#networkClients.set(login.id, client) until after
await client.connect(...) completes and has succeeded (ensure login.client is
set after successful connect as well). Ensure failed connect() removes any
in-flight entry so retries can occur.
- Around line 101-105: queueRemoteEvent currently always calls `#scheduleDrain`
even when the bridge is stopped, which causes `#drainRemoteEvents` to immediately
return (since `#context` is null) and the finally block to reschedule another
drain, potentially spinning; update the logic so queuing only triggers
scheduling when the bridge is running: check the bridge state (e.g., that
`#context` is non-null or start() has been called) before calling `#scheduleDrain`
in queueRemoteEvent and in the other enqueue sites (the similar blocks around
the other queue calls referenced), or move the guard into `#scheduleDrain` itself
so it no-ops when `#context` is null; ensure you still push to `#remoteEvents` but
avoid scheduling when stopped to prevent the microtask loop.
- Around line 183-187: The current loops broadcast Matrix events to every loaded
NetworkAPI (this.#networkClients) which causes events to be handled by
non-owning logins; change the dispatch to first determine the portal's owning
receiver (portalKey.receiver or portal.receiver) and if present, look up the
matching network client (keyed by that login id) and invoke its handler (e.g.,
handleMatrixMessage via client.handleMatrixMessage(this.#requestContext(),
msg)); if no owner exists, fall back to broadcasting to all clients that
implement the handler. Apply the same change to the other similar broadcast
sites that call handleMatrixMessage / handleMatrixTyping /
handleMatrixReadReceipt so each event routes to the owning login when a receiver
is set.

In `@packages/bridge/src/events.ts`:
- Around line 27-32: getTimestamp() and getStreamOrder() compute Date.now()/new
Date() lazily and can diverge across calls; capture a single fallback timestamp
at construction and have both getTimestamp() and getStreamOrder() return values
derived from that captured fallback (e.g., store const fallbackTimestamp =
options.timestamp ?? new Date() or fallbackMillis = options.timestamp?.getTime()
?? Date.now()) so getTimestamp() returns the same Date instance and
getStreamOrder() uses the same millis, updating the implementations of
getTimestamp and getStreamOrder to reference that stored value instead of
recomputing.

---

Nitpick comments:
In `@packages/bridge/src/events.ts`:
- Line 13: The function createRemoteMessage currently returns a union
RemoteMessage | RemoteMessageWithTransactionID which forces callers to narrow;
add two TypeScript overloads — one overload taking CreateRemoteMessageOptions<T>
with a required transactionId (or a discriminant indicating presence) that
returns RemoteMessageWithTransactionID, and another overload for options without
transactionId that returns RemoteMessage — and keep the existing implementation
signature (the broad one returning the union) unchanged; update the overloads to
reference CreateRemoteMessageOptions, RemoteMessage, and
RemoteMessageWithTransactionID so callers who pass a transactionId get the
narrower return type without casts.

In `@packages/bridge/tsdown.config.ts`:
- Line 4: Remove "src/events.ts" from the entry array in tsdown.config.ts so it
is no longer built as an independent entry point; keep the remaining entries
["src/index.ts", "src/node.ts", "src/types.ts"], and verify that
createRemoteMessage (and any public exports from events.ts) are still
re-exported via index.ts and node.ts so the package.json exports (".", "./node",
"./types") continue to cover the public API and no unreachable dist/events.js is
produced.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 72490ef0-b37d-4b28-a195-631c0d0096dc

📥 Commits

Reviewing files that changed from the base of the PR and between 7068df5 and 0d70469.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (15)
  • packages/bridge/LICENSE
  • packages/bridge/README.md
  • packages/bridge/docs/BRIDGE_TODO.md
  • packages/bridge/package.json
  • packages/bridge/src/bridge.test.ts
  • packages/bridge/src/bridge.ts
  • packages/bridge/src/events.ts
  • packages/bridge/src/index.ts
  • packages/bridge/src/node.ts
  • packages/bridge/src/types.ts
  • packages/bridge/tsconfig.json
  • packages/bridge/tsdown.config.ts
  • packages/bridge/vitest.config.ts
  • pnpm-workspace.yaml
  • tsconfig.base.json
📜 Review details
🧰 Additional context used
🪛 LanguageTool
packages/bridge/docs/BRIDGE_TODO.md

[style] ~46-~46: This phrase is redundant (‘I’ stands for ‘Interfaces’). Use simply “APIInterfaces”.
Context: ...ta registration/migrations. ## Network API Interfaces - [x] NetworkAPI. - [x] `PushableNet...

(ACRONYM_TAUTOLOGY)

🔇 Additional comments (6)
pnpm-workspace.yaml (1)

3-3: LGTM — workspace entry correctly inserted.

packages/bridge/tsconfig.json (1)

1-8: LGTM — standard tsconfig for the package.

packages/bridge/vitest.config.ts (1)

1-18: LGTM — alias ordering and coverage config are correct.

packages/bridge/src/index.ts (1)

1-3: LGTM — clean barrel export.

packages/bridge/src/node.ts (1)

1-10: LGTM — clean Node.js integration layer.

tsconfig.base.json (1)

20-22: LGTM — path aliases are consistent with the package's subpath export structure.

Comment thread packages/bridge/docs/BRIDGE_TODO.md Outdated
Comment thread packages/bridge/LICENSE
Comment thread packages/bridge/src/bridge.ts
Comment thread packages/bridge/src/bridge.ts
Comment thread packages/bridge/src/bridge.ts Outdated
Comment thread packages/bridge/src/events.ts
batuhan added 2 commits May 7, 2026 19:45
Introduce a minimal TypeScript Pickle example bridge (examples/dummybridge) including connector, index, env loader, file-backed state (FileState/MatrixState), smoke test, build and config files. Add Beeper bridge-manager client and helpers (packages/bridge/src/beeper.ts) with tests and export them from the bridge package. Update RuntimeBridge to initialize appservice on startup, create/backfill portal rooms via appservice, and send remote messages through appservice ghost users when appropriate. Also update docs, tests, and package exports to expose the new Beeper APIs.
Add a new createBeeperBridge helper to the bridge runtime that boots a Matrix client, infers homeserver domain, and initializes/fetches Beeper appservice registration via createBeeperAppServiceInit. Export the new API from package entry points and provide corresponding node wrapper. Introduce CreateBeeperBridgeOptions and CreateNodeBeeperBridgeOptions types and factor option-building helpers. Refactor the dummybridge example to use createBeeperBridge (simplified .env.example and README) and remove the local appservice fallback and related helper code.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/pickle/native/internal/core/core.go (1)

235-263: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reset appservice state during close.

handleClose clears core state but leaves c.appservice intact. That allows appservice calls after close to use stale credentials/config from the prior session. Please clear it in the close path as well.

Suggested fix
 func (c *Core) handleClose() ([]byte, error) {
@@
 	c.client = nil
+	c.appservice = nil
 	c.crypto = nil
 	c.cryptoStore = nil
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/pickle/native/internal/core/core.go` around lines 235 - 263,
handleClose currently resets many Core fields but leaves c.appservice populated,
which can leak stale appservice credentials; update the handleClose method to
clear the appservice reference by closing it if it exposes a Close-like cleanup
(call its Close/Shutdown method if present) and then set c.appservice = nil (add
this alongside the other nil clears such as c.client = nil and c.crypto = nil)
so no appservice state remains after close.
🧹 Nitpick comments (2)
examples/dummybridge/package.json (1)

16-22: ⚡ Quick win

Align Node typings with the declared runtime floor.

@types/node at major 25 with engines.node: ">=20" can let newer Node APIs typecheck even when runtime is Node 20. Either lower typings to 20.x or raise the engine floor if 25-only APIs are intended. This misalignment is present across most packages in the monorepo and should be addressed consistently.

Proposed adjustment
   "devDependencies": {
-    "@types/node": "^25.3.2",
+    "@types/node": "^20",
     "tsdown": "^0.21.10",
     "typescript": "^5.7.2"
   },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/dummybridge/package.json` around lines 16 - 22, The package.json
currently lists "@types/node" at major version 25 while "engines": { "node":
">=20" } — update these to align: either change the dev dependency "@types/node"
to a 20.x spec (e.g., ^20.x) so typings match the declared runtime floor, or
raise the "engines.node" floor to ">=25" if you intend to use Node 25 APIs;
apply the same consistent decision across other monorepo packages (look for the
"@types/node" entry and the "engines": "node" field in package.json files).
packages/bridge/src/beeper.test.ts (1)

72-97: ⚡ Quick win

Add a regression test for explicit username override.

A focused case for createBeeperBridgeManagerClient({ username: ... }) would lock down behavior when the caller overrides whoami-derived usernames.

Suggested test shape
+  it("uses explicit username override consistently", async () => {
+    const fetch = vi.fn(async (url: URL) => {
+      if (String(url) === "https://api.example/whoami") {
+        return jsonResponse({
+          user: { bridges: {} },
+          userInfo: { username: "alice" },
+        });
+      }
+      expect(String(url)).toContain("/_hungryserv/bob/");
+      return jsonResponse({
+        as_token: "as",
+        hs_token: "hs",
+        id: "sh-dummy",
+        namespaces: { user_ids: [{ exclusive: true, regex: "@dummy_.*:beeper.local" }] },
+        sender_localpart: "dummybot",
+        url: "https://bridge.example",
+      });
+    });
+
+    const client = createBeeperBridgeManagerClient({
+      baseDomain: "example",
+      token: "token",
+      username: "bob",
+      fetch: fetch as never,
+    });
+
+    await expect(client.createAppServiceInit({ bridge: "sh-dummy" })).resolves.toBeDefined();
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/bridge/src/beeper.test.ts` around lines 72 - 97, Add a regression
test that calls createBeeperBridgeManagerClient with an explicit username option
to ensure the client uses the provided username instead of the whoami-derived
one: mock fetch so the first whoami call returns a different username, create
the client with { username: "alice" } and then assert getBridge("sh-dummy") and
registerAppService({ bridge: "sh-dummy", getOnly: true }) behave as before
(e.g., return the expected bridge version and appservice tokens/id), referencing
createBeeperBridgeManagerClient, client.getBridge, and client.registerAppService
to locate where to add the test.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@examples/dummybridge/src/env.ts`:
- Around line 12-17: The parser currently assigns process.env[key] ??= value
even when key is empty (e.g., a line like "=value"); modify the logic around the
computed key/value (variables key and value) to skip any malformed entries with
an empty key by checking if key is a non-empty string before performing the
assignment to process.env; locate the block that computes key = trimmed.slice(0,
index).trim() and add a guard (skip/continue/return) so process.env[key] ??=
value only runs when key.length > 0.

In `@packages/bridge/README.md`:
- Around line 17-21: The README fallback snippet references registration but
never declares it; declare or import a sample registration object before it's
used so the example is runnable — e.g., add a preceding line that defines a
const registration with the expected fields (or show an import of a registration
object) so the object literal that uses homeserver, homeserverDomain and
registration is valid; ensure the declaration name matches "registration" used
in the snippet.

In `@packages/bridge/src/beeper.ts`:
- Around line 110-115: The homeserver URL is always built from
whoami.userInfo.username which ignores an explicitly set client username; in
createAppService replace the hardcoded whoami.userInfo.username usage with the
effective username that prefers this.#username when present (i.e., use
this.#username ?? whoami.userInfo.username) so hungryHomeserver(...) is called
with the same username used for requests; keep the rest of the return shape and
still call registerAppService and whoami as before.

In `@packages/pickle/native/internal/core/appservice.go`:
- Around line 339-345: The fallback error handling in ensureJoined incorrectly
returns the original join error (err) instead of the actual failure; update the
branches that call as.client(as.botUserID) and bot.InviteUser(...) so they
return botErr and inviteErr respectively when those calls fail (i.e., when
botErr != nil return botErr, and when inviteErr != nil return inviteErr),
referencing the ensureJoined function and the as.client/as.botUserID call and
bot.InviteUser(ctx, roomID, &mautrix.ReqInviteUser{UserID: cli.UserID}) to
locate the code to change.

---

Outside diff comments:
In `@packages/pickle/native/internal/core/core.go`:
- Around line 235-263: handleClose currently resets many Core fields but leaves
c.appservice populated, which can leak stale appservice credentials; update the
handleClose method to clear the appservice reference by closing it if it exposes
a Close-like cleanup (call its Close/Shutdown method if present) and then set
c.appservice = nil (add this alongside the other nil clears such as c.client =
nil and c.crypto = nil) so no appservice state remains after close.

---

Nitpick comments:
In `@examples/dummybridge/package.json`:
- Around line 16-22: The package.json currently lists "@types/node" at major
version 25 while "engines": { "node": ">=20" } — update these to align: either
change the dev dependency "@types/node" to a 20.x spec (e.g., ^20.x) so typings
match the declared runtime floor, or raise the "engines.node" floor to ">=25" if
you intend to use Node 25 APIs; apply the same consistent decision across other
monorepo packages (look for the "@types/node" entry and the "engines": "node"
field in package.json files).

In `@packages/bridge/src/beeper.test.ts`:
- Around line 72-97: Add a regression test that calls
createBeeperBridgeManagerClient with an explicit username option to ensure the
client uses the provided username instead of the whoami-derived one: mock fetch
so the first whoami call returns a different username, create the client with {
username: "alice" } and then assert getBridge("sh-dummy") and
registerAppService({ bridge: "sh-dummy", getOnly: true }) behave as before
(e.g., return the expected bridge version and appservice tokens/id), referencing
createBeeperBridgeManagerClient, client.getBridge, and client.registerAppService
to locate where to add the test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 52903561-7dde-4e0a-8428-52ab213dfcca

📥 Commits

Reviewing files that changed from the base of the PR and between 0d70469 and 0328fe0.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (31)
  • examples/dummybridge/.env.example
  • examples/dummybridge/README.md
  • examples/dummybridge/package.json
  • examples/dummybridge/src/connector.ts
  • examples/dummybridge/src/env.ts
  • examples/dummybridge/src/index.ts
  • examples/dummybridge/src/store.ts
  • examples/dummybridge/test/smoke.ts
  • examples/dummybridge/tsconfig.json
  • examples/dummybridge/tsdown.config.ts
  • packages/bridge/README.md
  • packages/bridge/docs/BRIDGE_TODO.md
  • packages/bridge/package.json
  • packages/bridge/src/beeper.test.ts
  • packages/bridge/src/beeper.ts
  • packages/bridge/src/bridge.test.ts
  • packages/bridge/src/bridge.ts
  • packages/bridge/src/index.ts
  • packages/bridge/src/node.ts
  • packages/bridge/src/types.ts
  • packages/bridge/tsdown.config.ts
  • packages/pickle/native/internal/core/appservice.go
  • packages/pickle/native/internal/core/core.go
  • packages/pickle/native/internal/core/operations.go
  • packages/pickle/src/client-types.ts
  • packages/pickle/src/client.ts
  • packages/pickle/src/generated-runtime-operations.ts
  • packages/pickle/src/generated-runtime-types.ts
  • packages/pickle/src/index.ts
  • packages/pickle/src/node.ts
  • packages/pickle/src/runtime-types.ts
✅ Files skipped from review due to trivial changes (8)
  • packages/bridge/src/node.ts
  • examples/dummybridge/src/index.ts
  • packages/bridge/src/index.ts
  • packages/pickle/src/generated-runtime-types.ts
  • examples/dummybridge/test/smoke.ts
  • examples/dummybridge/src/connector.ts
  • examples/dummybridge/tsdown.config.ts
  • packages/bridge/src/bridge.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/bridge/src/bridge.ts
  • packages/bridge/src/types.ts
  • packages/bridge/package.json
📜 Review details
🧰 Additional context used
🪛 dotenv-linter (4.0.0)
examples/dummybridge/.env.example

[warning] 2-2: [UnorderedKey] The MATRIX_ACCESS_TOKEN key should go before the MATRIX_HOMESERVER key

(UnorderedKey)

🪛 LanguageTool
packages/bridge/docs/BRIDGE_TODO.md

[grammar] ~17-~17: Ensure spelling is correct
Context: ... Appservice portal room creation. - [x] Appservice batch backfill. - [x] Basic live Matrix...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)


[style] ~50-~50: This phrase is redundant (‘I’ stands for ‘Interfaces’). Use simply “APIInterfaces”.
Context: ...ta registration/migrations. ## Network API Interfaces - [x] NetworkAPI. - [x] `PushableNet...

(ACRONYM_TAUTOLOGY)

examples/dummybridge/README.md

[grammar] ~34-~34: Ensure spelling is correct
Context: ...AppServiceInit()` to fetch/register the appservice through Beeper's bridge-manager-compati...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)

🔇 Additional comments (18)
examples/dummybridge/src/env.ts (2)

19-21: Good error boundary in file loading.

Ignoring only ENOENT while rethrowing everything else is the right behavior for a best-effort .env loader.


24-28: requiredEnv is clean and fail-fast.

The helper is simple and gives a clear missing-variable error path.

examples/dummybridge/.env.example (1)

1-19: Config template is clear and practical.

Required vs optional env vars are separated well, and placeholder values reduce accidental secret leakage in examples.

examples/dummybridge/README.md (1)

1-47: README flow is well-structured and runnable.

The setup, smoke test, and live-run paths are documented clearly and map cleanly to the package scripts/env flags.

examples/dummybridge/tsconfig.json (1)

1-9: TypeScript project config looks correct for this example package.

Includes/excludes and output path are aligned with the documented build and smoke workflow.

packages/pickle/src/runtime-types.ts (1)

12-23: Appservice runtime type exports look consistent.

These additions fit the existing generated-runtime-types re-export pattern and keep the public surface coherent.

packages/pickle/native/internal/core/operations.go (1)

20-31: New appservice operation declarations are cleanly integrated.

Operation IDs and ts:operation metadata match the existing convention and should generate the expected runtime bindings.

packages/pickle/src/client-types.ts (1)

73-82: MatrixAppservice typing surface is well-defined.

The new namespace contract is explicit and aligns with the rest of the client API shape.

Also applies to: 86-88, 111-118

packages/pickle/src/node.ts (1)

38-40: Node client appservice getter matches existing namespace pattern.

No concerns with this addition.

packages/pickle/src/client.ts (1)

41-42: Client-side appservice wiring is consistent with existing namespaces.

The mapping is straightforward and follows established core delegation patterns.

Also applies to: 74-84

packages/pickle/src/index.ts (1)

7-7: Public exports for appservice types are properly surfaced.

No issues found in this export expansion.

Also applies to: 21-34

packages/pickle/src/generated-runtime-operations.ts (3)

6-13: Appservice type imports are clean and complete.

Line 6-Line 13 add only the new appservice-related type imports and keep them type-only, which matches the generated-runtime typing pattern.


96-101: Interface extension is consistent with existing operation contracts.

Line 96-Line 101 adds a coherent appservice surface to MatrixCoreOperations with clear option/result typing and no contract ambiguity.


189-211: Caller delegation wiring looks correct.

Line 189-Line 211 correctly delegates each new appservice method to the expected runtime operation string using the same call pattern as the rest of the class.

packages/bridge/docs/BRIDGE_TODO.md (1)

143-147: Test checklist is still stale versus current suite coverage.

The unchecked items for runtime start/stop, remote queue draining, and Matrix dispatch were already flagged and still appear unresolved.

packages/bridge/tsdown.config.ts (1)

3-19: Build config looks consistent with the new package surface.

Entry points, ESM output, and externalization choices are aligned with the added bridge/beeper exports.

examples/dummybridge/src/store.ts (2)

11-95: FileState LGTM.

The lock acquire/release logic is sound: the check and in-memory mutation are both synchronous before the first await, so the Node.js single-threaded event loop prevents any JS-level race on lock state. #cleanExpiredLocks is a nice defensive touch on both connect() and acquireLock.


97-143: MatrixState LGTM.

Namespace isolation, base64 round-trip, and the index-backed list() with incremental updates are all correct. Key namespacing is applied consistently in get/set/delete while the index stores raw keys, so list(prefix) filters correctly without any prefix confusion.

Comment thread examples/dummybridge/src/env.ts
Comment thread packages/bridge/README.md Outdated
Comment thread packages/bridge/src/beeper.ts
Comment thread packages/pickle/native/internal/core/appservice.go
batuhan added 2 commits May 7, 2026 19:56
Introduce a persistent BridgeDataStore and MatrixBridgeDataStore (createBridgeDataStore) and wire it into the runtime so portals, messages and user logins can be persisted. Add high-level APIs to create bridges from a Beeper token or username/password (createBeeperBridgeFromToken / createBeeperBridgeFromPassword) and corresponding node helpers that default to a file-backed Matrix store. Update RuntimeBridge to accept an optional dataStore and persist relevant state; export store types/creators. Update example dummybridge to use BEEPER_* env vars and the new state-file package (remove the example FileState), and update package exports, deps, tsdown and vitest config accordingly.
Replace token/password entrypoints with a unified account-based flow: add loginWithPassword helper and accept a MatrixAccount on createBeeperBridge. Remove createBeeperBridgeFromToken/createBeeperBridgeFromPassword plumbing and consolidate token/homeserver handling from the account. Update types, node helpers, README and example to use loginWithPassword/createBeeperBridge, adjust .env.example to prefer username/password, and add tests for password login behavior. Also introduce loginWithBeeperPassword helper and minor refactors around appservice initialization and runtime options.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@examples/dummybridge/src/index.ts`:
- Around line 16-30: The example ignores documented manager settings
BEEPER_BASE_DOMAIN and DUMMYBRIDGE_URL; thread these into bridge creation by
adding them to the bridge options passed into createBeeperBridgeFromPassword and
createBeeperBridgeFromToken. Update bridgeOptions (the object used when calling
createBeeperBridgeFromPassword and createBeeperBridgeFromToken) to include
values from optionalEnv/requiredEnv for BEEPER_BASE_DOMAIN (e.g. baseDomain) and
DUMMYBRIDGE_URL (e.g. connectorUrl or homeserverUrl) so the factories receive
and propagate those manager settings.
- Around line 78-82: The signal handlers installed with process.once currently
call await bridge.stop() without handling rejection, leaving an unobserved
promise that can prevent process.exit(0); update the handler defined for each
signal (the process.once(async () => { ... }) block) to wrap the bridge.stop()
call in a try/catch (or use .catch()) so any rejection is caught, log the error
(e.g., console.error or the project's logger), and ensure process.exit is always
called (use exit code 0 on success and non‑zero on failure) in the
finally/after-catch branch so shutdown completes even if bridge.stop() fails.

In `@packages/bridge/src/bridge.ts`:
- Around line 77-81: The cache key for createBeeperBridgeFromPassword currently
only uses baseDomain and username (accountKey), causing different
matrix.homeserver values to collide; update the key generation (accountKey) to
also include the homeserver from options.matrix?.homeserver (or a sensible
default) so getAccount/setAccount use a unique key per homeserver, ensuring
loginWithMatrixPassword and stored account entries are not incorrectly reused
across environments.
- Around line 126-140: The startup path in start() never reloads persisted
portals from the bridge's data store into the in-memory maps, so entries written
to `#dataStore` aren't populated into `#portalsByKey` and `#portalsByRoom` after a
restart; add logic (e.g. a private method like `#hydratePortalsFromStore`) that
reads all saved portal records from this.#dataStore, recreates the Portal
objects/state and inserts them into this.#portalsByKey and this.#portalsByRoom,
and call that method in start() after this.#context is created (and before await
this.#subscribeMatrixEvents()) so lookups work for incoming matrix events.
Ensure duplicate keys are handled and any errors are logged but do not prevent
initialization.
- Around line 232-233: flushRemoteEvents currently calls `#drainRemoteEvents`
directly which can start a concurrent drain; instead have flushRemoteEvents join
the in-flight scheduled drain by checking the private `#drainPromise` and awaiting
it if present, otherwise kick off the drain in the same serialized path so
`#drainPromise` is set and awaited (e.g., await this.#drainPromise if defined, or
set this.#drainPromise = this.#drainRemoteEvents() and await it, ensuring
`#drainPromise` is cleared after completion). This preserves
ordering/serialization and avoids starting a second concurrent drain.
- Around line 222-226: When registering a portal in registerPortal, first lookup
the existing portal via
this.#portalsByKey.get(portalKeyString(portal.portalKey)); if an existing portal
exists and has an mxid different from the incoming portal.mxid, remove the old
room mapping from this.#portalsByRoom (delete the entry keyed by the old
portal.mxid) before updating this.#portalsByKey and adding the new
this.#portalsByRoom entry; this ensures stale room IDs are not left pointing to
the moved portal.

In `@packages/bridge/src/store.ts`:
- Around line 66-69: setPortal currently only writes the new portal-mxid entry,
leaving an old mxid -> portalKey mapping when a portal moves rooms; update
setPortal to first load the existing portal (use portalStoreKey and the store
get method used elsewhere), and if an existingPortal.mxid exists and !==
portal.mxid, remove the old index by deleting key("portal-mxid",
existingPortal.mxid) before writing the new mapping with this.#set; also ensure
you delete the old mapping when the incoming portal has no mxid. Use the same
internal primitives (e.g., this.#set and the store delete method) and
key("portal-mxid", ...) to locate entries.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a9cb16d9-16b5-4b98-bc2e-43a86de673b4

📥 Commits

Reviewing files that changed from the base of the PR and between 0328fe0 and f2b00ec.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (13)
  • examples/dummybridge/.env.example
  • examples/dummybridge/README.md
  • examples/dummybridge/package.json
  • examples/dummybridge/src/index.ts
  • packages/bridge/README.md
  • packages/bridge/package.json
  • packages/bridge/src/bridge.ts
  • packages/bridge/src/index.ts
  • packages/bridge/src/node.ts
  • packages/bridge/src/store.ts
  • packages/bridge/src/types.ts
  • packages/bridge/tsdown.config.ts
  • packages/bridge/vitest.config.ts
✅ Files skipped from review due to trivial changes (6)
  • packages/bridge/tsdown.config.ts
  • examples/dummybridge/package.json
  • packages/bridge/vitest.config.ts
  • examples/dummybridge/README.md
  • packages/bridge/README.md
  • packages/bridge/package.json
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/bridge/src/index.ts
  • packages/bridge/src/types.ts

Comment thread examples/dummybridge/src/index.ts Outdated
Comment thread examples/dummybridge/src/index.ts
Comment thread packages/bridge/src/bridge.ts Outdated
Comment thread packages/bridge/src/bridge.ts
Comment thread packages/bridge/src/bridge.ts
Comment thread packages/bridge/src/bridge.ts Outdated
Comment thread packages/bridge/src/store.ts
batuhan added 3 commits May 7, 2026 20:13
Add support for management rooms and matrix-side command handling across the bridge. Introduces CommandHandlingBridgeConnector and implements command dispatching, text replies and management-room creation in RuntimeBridge; updates the dummy example connector to handle commands and several new login flows (password, cookies, local_storage, display_and_wait). Persist and expose bridge state, ghosts and message requests via the data store; add backfillMessages, remote backfill/chat-info/delete handling, media upload/download wrappers and identifier/message-request APIs. Bind login process lifecycle calls to the bridge request context and update tests to cover the new behaviours.
Add support for posting bridge state to the bridge manager and introduce a bridgeType option. registerAppService now posts a bridge_state POST after hungry registration (unless postState is false) and a new postBridgeState method and PostBridgeStateOptions type were added. The internal request helper now accepts POST and an optional token and safely handles empty responses. bridgeType was threaded through types, beeper, bridge, and index/entry exports; bridgeStateEvent logic selects STARTING/RUNNING based on bridgeType and known bridge names. The Node entrypoint was consolidated into the package index (exports adjusted) and createBeeperBridge/createBridge now auto-wire matrix clients and default file stores; examples updated to import from the package root and handle optional address. Tests and README were updated to reflect the new behaviour, and a .gitignore was added to the dummybridge example.
Introduce AppserviceWebsocket (src/appservice-websocket.ts) to handle appservice websocket connections, parse transactions/http_proxy requests, dispatch Matrix events to the bridge, and manage ping/reconnect behavior. Add unit tests (src/appservice-websocket.test.ts) covering normal transactions and http_proxy handling. Wire the websocket into the runtime bridge (start/stop and dispatch integration), and add a guard to skip starting when a push URL is configured. Add runtime/dev dependencies for ws and @types/ws, update tsdown config to include the new module and avoid bundling ws, and update the pnpm lockfile accordingly.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

♻️ Duplicate comments (7)
packages/bridge/src/bridge.ts (7)

495-499: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Route Matrix events to the owning login instead of broadcasting them.

These handlers still fan each Matrix event out to every loaded NetworkAPI. Once multiple logins are active, a room owned by portalKey.receiver = login:a will also invoke handlers on login:b, which can duplicate or misroute outbound network actions. Resolve the owner from the portal receiver and dispatch only to that client when one is set.

Also applies to: 551-555, 571-575, 596-600

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/bridge/src/bridge.ts` around lines 495 - 499, The loop broadcasting
Matrix events to every NetworkAPI (iterating this.#networkClients and calling
client.handleMatrixMessage) must instead resolve the portal owner from the
incoming msg (use portalKey.receiver or equivalent field on the event) and
dispatch only to the matching network client for that login; if a portal
receiver is set, lookup the correct client in this.#networkClients by that login
key and call handleMatrixMessage(this.#requestContext(), msg) only on that
client, otherwise fall back to iterating all clients; apply the same change to
the other similar blocks (the loops at the other noted ranges) and keep using
hasMethod to guard handleMatrixMessage as before.

265-273: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't cache a login before connect() succeeds.

This publishes the client to #networkClients before await client.connect(...) finishes. A failed connect leaves a half-initialized client cached, and concurrent callers can still race into duplicate sessions. Store an in-flight promise here, or only cache the client after a successful connect and clear failures for retry.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/bridge/src/bridge.ts` around lines 265 - 273, The current
loadUserLogin stores the client in `#networkClients` before client.connect
finishes; change loadUserLogin to avoid caching a half-initialized client by
either (a) store an in-flight promise in `#networkClients` keyed by login.id (the
promise resolves to the connected client) so concurrent callers await the same
connect, or (b) only set `#networkClients` and call `#dataStore.setUserLogin` after
await client.connect(...) succeeds, and ensure you remove the entry from
`#networkClients` if connect throws so retries can proceed; modify references in
loadUserLogin, connector.loadUserLogin, client.connect, and
`#dataStore`?.setUserLogin accordingly.

396-400: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Drop the previous room mapping when re-registering a portal.

If the same portal key is registered with a new mxid, the old #portalsByRoom entry is left behind. Dispatch for the stale room ID will still resolve to this portal and misroute later Matrix events.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/bridge/src/bridge.ts` around lines 396 - 400, When re-registering a
portal in registerPortal, clear any stale room mapping: lookup the existing
portal via this.#portalsByKey.get(portalKeyString(portal.portalKey)); if an
existing portal has an mxid different from portal.mxid, call
this.#portalsByRoom.delete(existing.mxid) before setting the new entries. Then
continue to set this.#portalsByKey and (if portal.mxid) this.#portalsByRoom as
currently implemented to ensure the old room key is not left behind.

136-150: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Hydrate persisted portals during startup.

registerPortal() persists portals, but start() never rebuilds #portalsByKey / #portalsByRoom from #dataStore. After a restart, #portalForRemoteEvent() cannot resolve previously registered portals, so remote message/backfill/delete handling fails until something re-registers them in memory.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/bridge/src/bridge.ts` around lines 136 - 150, start() never rebuilds
in-memory portal indexes from persisted storage, so `#portalsByKey` and
`#portalsByRoom` remain empty after a restart; after creating this.#context (and
before connector.init/start) iterate the persisted portals in this.#dataStore
and hydrate those maps (or call an internal helper used by registerPortal
without re-persisting) so that existing entries can be resolved by
`#portalForRemoteEvent`; reference registerPortal, `#portalsByKey`, `#portalsByRoom`,
`#dataStore` and `#portalForRemoteEvent` when adding the hydration logic and ensure
you avoid duplicating persistence for already-stored portals.

410-411: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make flushRemoteEvents() join the serialized drain.

Calling #drainRemoteEvents() directly bypasses #drainPromise. If a scheduled drain is already running, flushRemoteEvents() can process the queue concurrently and break ordering/serialization. Reuse #scheduleDrain() and await the shared promise instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/bridge/src/bridge.ts` around lines 410 - 411, Change
flushRemoteEvents to use the shared drain scheduling promise instead of invoking
the private drain directly: replace the direct call to this.#drainRemoteEvents()
with a call to this.#scheduleDrain() and await the resulting this.#drainPromise
so the method participates in the serialized drain flow; ensure you reference
the existing private members `#scheduleDrain`, `#drainPromise`, and
`#drainRemoteEvents` when making this change so concurrent drains are joined
rather than run concurrently.

390-393: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Avoid scheduling the remote-event drain while the bridge is stopped.

queueRemoteEvent() always kicks #scheduleDrain(), but #drainRemoteEvents() immediately returns when #context is null. The finally block then reschedules because the backlog is still non-empty, so queuing before start() or after stop() can spin forever. Gate scheduling on the bridge being active, and kick the drain once startup finishes if backlog already exists.

Also applies to: 621-625, 628-629

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/bridge/src/bridge.ts` around lines 390 - 393, queueRemoteEvent
currently always calls `#scheduleDrain` even when the bridge is stopped, which can
cause an infinite reschedule loop because `#drainRemoteEvents` returns early if
`#context` is null; update queueRemoteEvent to check bridge active state (e.g.,
ensure `#context` is non-null or a boolean isRunning) before calling
`#scheduleDrain`, and if the bridge is not active do not schedule the drain but
leave the event queued and ensure start() triggers a single `#scheduleDrain` if
backlog exists; apply the same guard to the other queuing call sites referenced
(the other methods that push into `#remoteEvents` and currently call
`#scheduleDrain`) so scheduling only happens when the bridge is active and startup
triggers the backlog drain.

630-633: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep queued remote events until their handler succeeds.

#drainRemoteEvents() removes the item before #handleRemoteEvent() resolves, so any conversion/send failure drops that event permanently. Peek first and remove only after success, or requeue on failure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/bridge/src/bridge.ts` around lines 630 - 633, The drain loop in
`#drainRemoteEvents` removes items from this.#remoteEvents before awaiting
`#handleRemoteEvent`, which loses events on conversion/send failures; change the
loop to peek at the head (use this.#remoteEvents[0] or similar), await
this.#handleRemoteEvent(item.login, item.event), and only remove the item
(shift) after the await succeeds; on error, do not drop the item—either requeue
it or break/return so it can be retried later (ensure you reference
`#drainRemoteEvents`, this.#remoteEvents and `#handleRemoteEvent` when updating the
logic).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/bridge/package.json`:
- Line 61: The `@types/node` devDependency currently pins major version 25
("@types/node": "^25.3.2") which mismatches the package's Node engine (">=20");
update the dependency to a `@types/node` major that matches the minimum engine
(for example a 20.x series like "^20.0.0") in this package.json and do the same
for the other occurrences referenced (lines 75–77) so TypeScript only exposes
Node APIs compatible with Node >=20.
- Line 51: prepublishOnly currently only runs the guard script and doesn't
ensure dist/ is rebuilt; update the prepublishOnly script in package.json to run
the package's build (e.g., invoke the repo or workspace build command such as a
pnpm build step or the package's "build" script) before calling node
../../scripts/guard-pnpm-publish.mjs so a fresh dist/ is produced prior to the
publish check; reference the prepublishOnly script and the
guard-pnpm-publish.mjs invocation when making the change.

In `@packages/bridge/src/appservice-websocket.test.ts`:
- Line 2: Replace the value import of AddressInfo with a type-only import to
reflect its usage in type casts: change the import to use "type" (i.e., import
type { AddressInfo } from "node:net";) so the compiler/tooling treats it as a
type-only dependency; update the existing import statement that brings in
AddressInfo (used in the test casts) accordingly.

In `@packages/bridge/src/appservice-websocket.ts`:
- Around line 74-94: The `#handleMessage` method can throw if await
this.#dispatch(...) or await this.#handleHTTPProxy(...) rejects, leaving the
homeserver without a response; wrap the async work for the "transaction" loop
and the "http_proxy" branch in try/catch blocks and on error call
this.#send(messageResponse(message, false, { code: "M_UNKNOWN", message:
error.message || String(error), details: error })) so each incoming message
(identified by message.txn_id/message.id) always gets either a success or an
error frame; ensure you catch errors from the per-event await
this.#dispatch(...) loop as well as the await this.#handleHTTPProxy(...) call
and send the error response before rethrowing or swallowing as appropriate.
- Around line 174-180: The check in messageResponse incorrectly treats id===0 as
falsy and ignores valid requests; change the guard in messageResponse to only
reject null/undefined ids (e.g., use message.id == null or message.id ===
undefined || message.id === null) instead of !message.id so that id 0 is handled
and the function still returns response/error for valid requests (keep the rest
of the logic and returned object using message.id).

In `@packages/pickle/src/auth.ts`:
- Around line 45-46: The error messages in loginWithPassword are misleading
because they only mention BEEPER_USERNAME/BEEPER_PASSWORD; update the thrown
Error texts (the checks that use username and password) to reference both
supported environment variable fallbacks (BEEPER_USERNAME and MATRIX_USERNAME
for username, BEEPER_PASSWORD and MATRIX_PASSWORD for password) so callers see
the correct env var options when misconfigured; locate the checks in
loginWithPassword where it throws on missing username/password and change the
messages accordingly.

---

Duplicate comments:
In `@packages/bridge/src/bridge.ts`:
- Around line 495-499: The loop broadcasting Matrix events to every NetworkAPI
(iterating this.#networkClients and calling client.handleMatrixMessage) must
instead resolve the portal owner from the incoming msg (use portalKey.receiver
or equivalent field on the event) and dispatch only to the matching network
client for that login; if a portal receiver is set, lookup the correct client in
this.#networkClients by that login key and call
handleMatrixMessage(this.#requestContext(), msg) only on that client, otherwise
fall back to iterating all clients; apply the same change to the other similar
blocks (the loops at the other noted ranges) and keep using hasMethod to guard
handleMatrixMessage as before.
- Around line 265-273: The current loadUserLogin stores the client in
`#networkClients` before client.connect finishes; change loadUserLogin to avoid
caching a half-initialized client by either (a) store an in-flight promise in
`#networkClients` keyed by login.id (the promise resolves to the connected client)
so concurrent callers await the same connect, or (b) only set `#networkClients`
and call `#dataStore.setUserLogin` after await client.connect(...) succeeds, and
ensure you remove the entry from `#networkClients` if connect throws so retries
can proceed; modify references in loadUserLogin, connector.loadUserLogin,
client.connect, and `#dataStore`?.setUserLogin accordingly.
- Around line 396-400: When re-registering a portal in registerPortal, clear any
stale room mapping: lookup the existing portal via
this.#portalsByKey.get(portalKeyString(portal.portalKey)); if an existing portal
has an mxid different from portal.mxid, call
this.#portalsByRoom.delete(existing.mxid) before setting the new entries. Then
continue to set this.#portalsByKey and (if portal.mxid) this.#portalsByRoom as
currently implemented to ensure the old room key is not left behind.
- Around line 136-150: start() never rebuilds in-memory portal indexes from
persisted storage, so `#portalsByKey` and `#portalsByRoom` remain empty after a
restart; after creating this.#context (and before connector.init/start) iterate
the persisted portals in this.#dataStore and hydrate those maps (or call an
internal helper used by registerPortal without re-persisting) so that existing
entries can be resolved by `#portalForRemoteEvent`; reference registerPortal,
`#portalsByKey`, `#portalsByRoom`, `#dataStore` and `#portalForRemoteEvent` when adding
the hydration logic and ensure you avoid duplicating persistence for
already-stored portals.
- Around line 410-411: Change flushRemoteEvents to use the shared drain
scheduling promise instead of invoking the private drain directly: replace the
direct call to this.#drainRemoteEvents() with a call to this.#scheduleDrain()
and await the resulting this.#drainPromise so the method participates in the
serialized drain flow; ensure you reference the existing private members
`#scheduleDrain`, `#drainPromise`, and `#drainRemoteEvents` when making this change so
concurrent drains are joined rather than run concurrently.
- Around line 390-393: queueRemoteEvent currently always calls `#scheduleDrain`
even when the bridge is stopped, which can cause an infinite reschedule loop
because `#drainRemoteEvents` returns early if `#context` is null; update
queueRemoteEvent to check bridge active state (e.g., ensure `#context` is non-null
or a boolean isRunning) before calling `#scheduleDrain`, and if the bridge is not
active do not schedule the drain but leave the event queued and ensure start()
triggers a single `#scheduleDrain` if backlog exists; apply the same guard to the
other queuing call sites referenced (the other methods that push into
`#remoteEvents` and currently call `#scheduleDrain`) so scheduling only happens when
the bridge is active and startup triggers the backlog drain.
- Around line 630-633: The drain loop in `#drainRemoteEvents` removes items from
this.#remoteEvents before awaiting `#handleRemoteEvent`, which loses events on
conversion/send failures; change the loop to peek at the head (use
this.#remoteEvents[0] or similar), await this.#handleRemoteEvent(item.login,
item.event), and only remove the item (shift) after the await succeeds; on
error, do not drop the item—either requeue it or break/return so it can be
retried later (ensure you reference `#drainRemoteEvents`, this.#remoteEvents and
`#handleRemoteEvent` when updating the logic).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: effce4a8-ad0d-497c-b8d9-d55eed35570d

📥 Commits

Reviewing files that changed from the base of the PR and between f2b00ec and 7fb4469.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (20)
  • examples/dummybridge/.env.example
  • examples/dummybridge/.gitignore
  • examples/dummybridge/README.md
  • examples/dummybridge/src/connector.ts
  • examples/dummybridge/src/index.ts
  • packages/bridge/README.md
  • packages/bridge/package.json
  • packages/bridge/src/appservice-websocket.test.ts
  • packages/bridge/src/appservice-websocket.ts
  • packages/bridge/src/beeper.test.ts
  • packages/bridge/src/beeper.ts
  • packages/bridge/src/bridge.test.ts
  • packages/bridge/src/bridge.ts
  • packages/bridge/src/index.ts
  • packages/bridge/src/node.ts
  • packages/bridge/src/store.ts
  • packages/bridge/src/types.ts
  • packages/bridge/tsdown.config.ts
  • packages/pickle/src/auth.test.ts
  • packages/pickle/src/auth.ts
✅ Files skipped from review due to trivial changes (7)
  • examples/dummybridge/.gitignore
  • packages/bridge/tsdown.config.ts
  • examples/dummybridge/README.md
  • packages/bridge/README.md
  • packages/bridge/src/node.ts
  • packages/bridge/src/beeper.ts
  • packages/bridge/src/store.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/bridge/src/index.ts
  • examples/dummybridge/src/connector.ts
  • packages/bridge/src/types.ts
📜 Review details
🧰 Additional context used
🪛 dotenv-linter (4.0.0)
examples/dummybridge/.env.example

[warning] 2-2: [UnorderedKey] The BEEPER_PASSWORD key should go before the BEEPER_USERNAME key

(UnorderedKey)

🪛 OpenGrep (1.20.0)
packages/bridge/src/appservice-websocket.ts

[ERROR] 100-100: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)

🔇 Additional comments (1)
examples/dummybridge/src/index.ts (1)

90-94: Still unresolved: observe shutdown failures in the signal handler.

bridge.stop() can still reject out of this async listener, which leaves the shutdown path unhandled and can skip the intended exit flow.

Comment thread packages/bridge/package.json Outdated
Comment thread packages/bridge/package.json Outdated
Comment thread packages/bridge/src/appservice-websocket.test.ts Outdated
Comment thread packages/bridge/src/appservice-websocket.ts
Comment thread packages/bridge/src/appservice-websocket.ts
Comment thread packages/pickle/src/auth.ts Outdated
batuhan added 3 commits May 7, 2026 20:39
Introduce HTTP proxy handling and richer logging for the appservice websocket, including onOpen hooks and message/http proxy debug logs. Implement provisioning endpoints and login flow handling in the bridge (start/step/complete), plus bridge status broadcasting over the websocket and management command replies sent via the appservice bot when available. Add implicit management room creation, command parsing/logging, and helper utilities for JSON responses and login step serialization. Update example dummy bridge to support configurable bridge name/sender, create/backfill multiple dummy chats on startup, and generate multiple historical messages. Adjust createBeeperBridge/store types and validation to require/accept a store in Node usage. Add a test verifying management command replies go through the appservice bot.
Add robust AppserviceWebsocket features and bridge lifecycle/command handling. The websocket now supports configurable timing, capped exponential reconnect backoff, ping/ping-timeout detection, replacement-close handling, and improved close/reconnect bookkeeping; tests for reconnect, ping timeouts, and replacement behavior were added and helper utilities extracted. RuntimeBridge now persists and restores bridge status and user logins, auto-loads persisted logins on startup, tracks per-login bridge state, supports bridgev2-style backfill queueing and message checkpoints, and implements built-in management commands (help, login, logout, cancel-login, set-management-room) with persistence for management rooms; numerous tests and helper mocks were added. Related updates include new/extended types, store interactions, and small API helpers for error/timestamp/TTL handling to support the above.
Move ghost MXID generation into the bridge API and update connectors/examples accordingly.

- Remove makeGhostMxid and per-connector sender/server options; DummyConnector now calls ctx.bridge.ghostUserId and injects a ghostUserId function into DummyNetworkAPI.
- Simplify remote message helpers to accept ghostUserId callbacks and adjust signatures/usages across the example and test code.
- Add RuntimeBridge.ghostUserId and helper functions to derive ghost IDs from appservice registration (ghostUserIdFromRegistration, userIdFromNamespaceRegex, escapeMatrixLocalpart, unescapeRegexLiteral).
- Improve backfill task/response handling to use stripUndefined and produce a cleaner task/result shape; add messageCheckpointPayload helper.
- Make homeserverDomain optional in CreateBeeperBridgeOptions and propagate it properly when present.
- Native side: set host on matrixAppservice and configure HTTP client with host when initializing appservice.

These changes centralize ghost user ID logic in the bridge, simplify connector configuration, and ensure consistent ghost ID generation from appservice registrations.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
packages/pickle/native/internal/core/appservice.go (1)

342-348: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Return fallback failures directly in ensureJoined.

At Line 344 and Line 347, the code returns the original join error instead of botErr/inviteErr, which masks the real failure source.

Suggested fix
 	if err != nil {
 		bot, botErr := as.client(as.botUserID)
 		if botErr != nil {
-			return err
+			return botErr
 		}
 		if _, inviteErr := bot.InviteUser(ctx, roomID, &mautrix.ReqInviteUser{UserID: cli.UserID}); inviteErr != nil {
-			return err
+			return inviteErr
 		}
 		resp, err = cli.JoinRoomByID(ctx, roomID)
 		if err != nil {
 			return err
 		}
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/pickle/native/internal/core/appservice.go` around lines 342 - 348,
In ensureJoined, the function currently returns the wrong error variable when
the bot client creation or invite fails; update the error returns to propagate
the actual failures by returning botErr after the as.client(as.botUserID)
failure and returning inviteErr (or the invite error) after the bot.InviteUser
call so the real failure source from as.client and bot.InviteUser is exposed.
🧹 Nitpick comments (1)
packages/bridge/src/bridge.ts (1)

1106-1108: 💤 Low value

Remove dead ternary expression.

This ternary always evaluates to "m.room.message" regardless of the condition, making the check pointless.

-const type = typeof content.msgtype === "string" ? "m.room.message" : "m.room.message";
+const type = "m.room.message";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/bridge/src/bridge.ts` around lines 1106 - 1108, In sendMessage, the
ternary used to set the local variable "type" inside the sendMessage method is
dead (it always resolves to "m.room.message"); remove the ternary and assign the
constant directly by replacing the conditional expression with a single constant
assignment (e.g., set type to "m.room.message") so the code in sendMessage is
simpler and clearer.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/pickle/native/internal/core/appservice.go`:
- Around line 118-123: The current validation incorrectly treats
req.HomeserverDomain as mandatory and returns an error when it's empty; update
the validation in the function that checks req to only require req.Homeserver
(not req.HomeserverDomain), leaving homeserverDomain optional, and keep the
registration checks for req.Registration.AppToken,
req.Registration.SenderLocalpart and req.Registration.ID intact; also update the
error message to accurately reflect which fields are required so callers and
logs are clear (reference the req.Homeserver, req.HomeserverDomain, and
req.Registration.* symbols).

---

Duplicate comments:
In `@packages/pickle/native/internal/core/appservice.go`:
- Around line 342-348: In ensureJoined, the function currently returns the wrong
error variable when the bot client creation or invite fails; update the error
returns to propagate the actual failures by returning botErr after the
as.client(as.botUserID) failure and returning inviteErr (or the invite error)
after the bot.InviteUser call so the real failure source from as.client and
bot.InviteUser is exposed.

---

Nitpick comments:
In `@packages/bridge/src/bridge.ts`:
- Around line 1106-1108: In sendMessage, the ternary used to set the local
variable "type" inside the sendMessage method is dead (it always resolves to
"m.room.message"); remove the ternary and assign the constant directly by
replacing the conditional expression with a single constant assignment (e.g.,
set type to "m.room.message") so the code in sendMessage is simpler and clearer.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5eccd9b6-d07f-48c6-87ef-d66f81ca0f61

📥 Commits

Reviewing files that changed from the base of the PR and between 7fb4469 and ea3826f.

📒 Files selected for processing (11)
  • examples/dummybridge/src/connector.ts
  • examples/dummybridge/src/index.ts
  • examples/dummybridge/test/smoke.ts
  • packages/bridge/src/appservice-websocket.test.ts
  • packages/bridge/src/appservice-websocket.ts
  • packages/bridge/src/bridge.test.ts
  • packages/bridge/src/bridge.ts
  • packages/bridge/src/index.ts
  • packages/bridge/src/store.ts
  • packages/bridge/src/types.ts
  • packages/pickle/native/internal/core/appservice.go
✅ Files skipped from review due to trivial changes (1)
  • packages/bridge/src/store.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • examples/dummybridge/src/index.ts
📜 Review details
🧰 Additional context used
🪛 OpenGrep (1.20.0)
packages/bridge/src/bridge.ts

[ERROR] 650-650: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)


[ERROR] 660-660: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)


[ERROR] 1218-1218: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)


[ERROR] 1220-1220: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)

packages/bridge/src/bridge.test.ts

[ERROR] 727-727: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)

packages/bridge/src/appservice-websocket.ts

[ERROR] 249-249: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)

🔇 Additional comments (17)
packages/bridge/src/appservice-websocket.ts (4)

344-351: Treat websocket request id 0 as valid.

The !message.id check at line 345 drops legitimate request IDs like 0, so those commands will never get an ack/error back. This check should only reject undefined/null.

Suggested fix
 function messageResponse(message: WebsocketMessage, ok: boolean, data: unknown): WebsocketRequest | null {
-  if (!message.id || message.command === "response" || message.command === "error") return null;
+  if (message.id == null || message.command === "response" || message.command === "error") return null;
   return {
     command: ok ? "response" : "error",
     data,
     id: message.id,
   };
 }

200-237: Reply with an error frame when message handling throws.

If #dispatch() or #handleHTTPProxy() fails, this method rejects and the caller only logs it. The homeserver never gets a response for that request id, so the transaction can hang or be retried indefinitely. Wrap the async work in try/catch blocks and send an error frame on failure.

Suggested fix
   async `#handleMessage`(data: WebSocket.RawData): Promise<void> {
     const message = JSON.parse(data.toString()) as WebsocketMessage;
     this.#log("debug", "appservice_websocket_message", {
       command: message.command ?? "transaction",
       eventCount: message.events?.length,
       id: message.id,
       txnId: message.txn_id,
     });
     if (this.#closeFromCommand(message)) return;
     if (this.#handlePingResponse(message)) return;
     if (message.command === "connect") return;
     if (message.command === "ping") {
       this.#send(messageResponse(message, true, message.data ?? { timestamp: Date.now() }));
       return;
     }
     if (message.command === "response" || message.command === "error") return;
     if (!message.command || message.command === "transaction") {
-      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);
+      try {
+        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 }));
+      } catch (error: unknown) {
+        this.#send(messageResponse(message, false, { code: "M_UNKNOWN", message: error instanceof Error ? error.message : String(error) }));
       }
-      this.#send(messageResponse(message, true, { txn_id: message.txn_id }));
       return;
     }
     if (message.command === "http_proxy") {
-      const response = await this.#handleHTTPProxy(message.data);
-      this.#log("debug", "appservice_websocket_http_proxy_response", { id: message.id, status: response.status });
-      this.#send(messageResponse(message, true, response));
+      try {
+        const response = await this.#handleHTTPProxy(message.data);
+        this.#log("debug", "appservice_websocket_http_proxy_response", { id: message.id, status: response.status });
+        this.#send(messageResponse(message, true, response));
+      } catch (error: unknown) {
+        this.#send(messageResponse(message, false, { code: "M_UNKNOWN", message: error instanceof Error ? error.message : String(error) }));
+      }
       return;
     }
     this.#send(messageResponse(message, false, { code: "M_UNKNOWN", message: `unknown websocket command ${message.command}` }));
   }

1-70: LGTM!

The WebSocket client setup with typed options, timing configuration, and private state is well-structured. The default timing values are reasonable for production use.


272-289: LGTM!

The ping logic correctly handles stale pings by terminating the socket, sets up the timeout timer, and cleans up state appropriately. The guard against sending a new ping while one is pending is a good safeguard.

packages/bridge/src/appservice-websocket.test.ts (2)

2-2: Use a type-only import for AddressInfo.

AddressInfo is only used in type casts (lines 24 and 79). Using type imports makes the intent explicit.

-import { AddressInfo } from "node:net";
+import type { AddressInfo } from "node:net";

18-234: LGTM!

The test suite provides excellent coverage of the AppserviceWebsocket behaviors: transaction dispatch with response matching, HTTP proxy handling, exponential backoff with stable-connection reset, ping timeout reconnection, and replacement close handling. The shared cleanup pattern in afterEach prevents resource leaks between tests.

packages/bridge/src/bridge.ts (6)

343-358: Avoid caching a login before connect() succeeds.

Line 350 adds the client to #networkClients before await client.connect() at line 354. If connect() rejects, later loadUserLogin() calls return that half-initialized client and never retry; concurrent callers can also race past the first get() and establish duplicate sessions.

Consider memoizing the in-flight Promise or only inserting into #networkClients after a successful connect.


495-498: Don't reschedule the drain while the bridge is stopped.

queueRemoteEvent() always kicks #scheduleDrain(), but #drainRemoteEvents() exits immediately when #context is null. The finally block at line 996 then reschedules another drain while backlog is still non-empty, so queuing events before start() or after stop() can spin an endless microtask loop.

Guard the schedule call:

 queueRemoteEvent(login: UserLogin, event: RemoteEvent): QueueRemoteEventResult {
   this.#remoteEvents.push({ event, login });
-  this.#scheduleDrain();
+  if (this.#context) this.#scheduleDrain();
   return { event, queued: true };
 }

1000-1007: Keep queued remote events until their handler succeeds.

The item is removed from #remoteEvents at line 1003 before #handleRemoteEvent() resolves. Any conversion/send failure drops that event permanently, and the scheduled drain then has nothing left to retry.

Peek first and shift() only after success, or requeue in a catch:

 async `#drainRemoteEvents`(): Promise<void> {
   if (!this.#context) return;
   while (this.#remoteEvents.length > 0) {
-    const item = this.#remoteEvents.shift();
-    if (!item) continue;
-    await this.#handleRemoteEvent(item.login, item.event);
+    const item = this.#remoteEvents[0];
+    if (!item) break;
+    await this.#handleRemoteEvent(item.login, item.event);
+    this.#remoteEvents.shift();
   }
 }

501-509: Drop the previous room mapping when re-registering a portal.

If the same portal key is registered with a new mxid, the old room entry stays in #portalsByRoom. That leaves dispatch for the stale room ID pointing at the moved portal.

 registerPortal(portal: Portal): void {
+  const key = portalKeyString(portal.portalKey);
+  const previous = this.#portalsByKey.get(key);
+  if (previous?.mxid && previous.mxid !== portal.mxid) {
+    this.#portalsByRoom.delete(previous.mxid);
+  }
-  this.#portalsByKey.set(portalKeyString(portal.portalKey), portal);
+  this.#portalsByKey.set(key, portal);
   if (portal.mxid) {
     this.#portalsByRoom.set(portal.mxid, portal);
   }

519-521: Make flushRemoteEvents() join the scheduled drain instead of starting a second one.

Calling #drainRemoteEvents() directly bypasses #drainPromise. If a scheduled drain is already running, flushRemoteEvents() can process the queue concurrently and break the intended ordering/serialization.

 async flushRemoteEvents(): Promise<void> {
-  await this.#drainRemoteEvents();
+  if (this.#remoteEvents.length === 0) return;
+  this.#scheduleDrain();
+  await this.#drainPromise;
 }

70-112: LGTM!

The factory functions createBridge, createBeeperBridge, and createBeeperBridgeWithClient provide clean entry points for different bridge configurations with appropriate fallback handling for store and homeserver options.

examples/dummybridge/test/smoke.ts (1)

1-157: LGTM!

This smoke test provides good end-to-end coverage of the RuntimeBridge lifecycle including appservice initialization, portal creation, backfill, and message dispatch. The in-memory store and stubbed MatrixClient are well-structured for testing purposes.

packages/bridge/src/bridge.test.ts (1)

1-957: LGTM!

This is an excellent, comprehensive test suite covering the RuntimeBridge lifecycle, login handling, Matrix event dispatch, remote event processing, backfill flows, and management command handling. The fake implementations are well-structured and the test cases cover important edge cases like ignoring bridge-user messages and command prefix requirements.

examples/dummybridge/src/connector.ts (1)

1-394: LGTM!

This is a well-structured example connector that demonstrates the full range of bridge capabilities: multiple login flows (password, cookies, local storage, display-and-wait), command handling, message echoing, and ghost user management. It serves as good documentation for implementers of real connectors.

packages/bridge/src/index.ts (1)

1-40: LGTM!

Clean module structure with appropriate re-exports. The createBeeperBridge factory handles store creation with sensible defaults, and the defaultDataDir helper provides a reasonable fallback path structure.

packages/bridge/src/types.ts (1)

1-1151: LGTM!

This is a comprehensive and well-organized type model for the bridge runtime. The separation of network API capabilities into specific interfaces (BackfillingNetworkAPI, MessageHandlingNetworkAPI, etc.) provides excellent type safety and documentation. The login flow types and remote event types cover the full range of bridging scenarios.

Comment thread packages/pickle/native/internal/core/appservice.go Outdated
batuhan added 3 commits May 7, 2026 21:24
Introduce Beeper support across the bridge and native appservice: add BridgeBeeperOptions to CreateBridgeOptions, thread beeper options into RuntimeBridge, and automatically include the Beeper owner in room invites. Extend createManagementRoom/createPortalRoom to populate beeper-specific create options (auto-join, initial members, portal metadata) and add an autoJoinInvite helper.

On the native side, extend MatrixAppserviceCreateRoomOptions with beeper portal types and implement applyBeeperPortalCreateDefaults to populate local room IDs, power level overrides, initial bridge state, and invites. Add unit tests for both the bridge JS and native Go logic, and update generated/runtime TypeScript types and exports to include the new portal and portal key types.
Introduce new portal/management room APIs and migrate to bridge v2 payloads across the stack. Bridge package: switch createPortalRoom to accept an info object (name/avatar/topic), update createManagementRoom/createPortalRoom callsites, refine stripUndefined typing. Pickle runtime: add MatrixAppserviceCreatePortalRoomOptions and MatrixAppserviceCreateManagementRoomOptions types, expose createPortalRoom/createManagementRoom on the client, and wire new core operations. Native core (Go): implement handlers and helper builders for appservice_create_portal_room and appservice_create_management_room, replace legacy beeper portal fields with the new bridge/portal shape, and add deterministicPortalRoomID and helper utilities. Update tests and README to reflect the new signatures and behaviors.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (1)
packages/bridge/src/store.ts (1)

120-124: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

setPortal leaves a stale portal-mxid reverse-index entry when the portal's room ID changes.

If a portal is re-saved with a different mxid, the old portal-mxid:<old-mxid> key is never deleted, so getPortalByMXID(oldMxid) keeps resolving the portal long after it has moved rooms.

🐛 Proposed fix
  async setPortal(portal: Portal): Promise<void> {
    const portalKey = portalStoreKey(portal);
+   const existing = await this.getPortal(portalKey);
    await this.#set(key("portal", portalKey), portal);
+   if (existing?.mxid && existing.mxid !== portal.mxid) {
+     await this.#store.delete(key("portal-mxid", existing.mxid));
+   }
    if (portal.mxid) await this.#set(key("portal-mxid", portal.mxid), portalKey);
  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/bridge/src/store.ts` around lines 120 - 124, setPortal currently
writes the new portal and its portal-mxid reverse index but never removes a
previous portal-mxid entry, leaving stale reverse mappings; to fix, read the
existing portal record (e.g., via this.#get(key("portal",
portalStoreKey(portal))) or this.getPortal) before writing, compare its mxid to
portal.mxid, and if an old mxid exists and differs, remove the old reverse-index
key (call the store delete method, e.g., this.#del(key("portal-mxid", oldMxid)))
before setting the new portal-mxid entry and then proceed to write the updated
portal with this.#set.
🧹 Nitpick comments (1)
packages/bridge/src/bridge.ts (1)

1107-1122: 💤 Low value

Unused variable in #matrixIntent.

Line 1111 computes type but it's always "m.room.message" regardless of the ternary condition. This appears to be dead code from a partially implemented feature.

Suggested simplification
  `#matrixIntent`(): MatrixIntent {
    return {
      client: this.#matrixClient,
      sendMessage: async (roomId, content) => {
-       const type = typeof content.msgtype === "string" ? "m.room.message" : "m.room.message";
+       const type = "m.room.message";
        const transactionId = `pickle-bridge-${Date.now()}-${Math.random().toString(16).slice(2)}`;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/bridge/src/bridge.ts` around lines 1107 - 1122, The local variable
`type` in the private method `#matrixIntent()` (inside the `sendMessage`
implementation) is computed via a redundant ternary and always equals
"m.room.message"; remove the dead ternary and simplify by using a single
constant or inlining the literal when building the request path in `sendMessage`
(keep `transactionId` and the call to `this.#matrixClient.raw.request`, and
preserve `eventIdFromRaw(result.body)` and the returned object).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/bridge/README.md`:
- Around line 7-46: The example references connector when calling
createBeeperBridge but never declares it; add a minimal stub declaration for
connector (e.g., a const connector = { /* minimal stub or TODO: implement
connector methods */ }) before calling createBeeperBridge so the snippet is
copy-paste runnable, and note in the comment that the stub should be replaced
with the real connector matching the expected type/signature used by
createBeeperBridge.

In `@packages/bridge/src/bridge.test.ts`:
- Around line 969-992: createFakeDataStore is missing the listUserLogins stub
used by bridge.start(), causing tests to throw or skip auto-loading logins; fix
by adding a listUserLogins: vi.fn(async () => []) property to
createFakeDataStore (or replace calls to createFakeDataStore with
createFakeBridgeDataStore if that already implements the full BridgeDataStore
interface) so the fake implements BridgeDataStore and bridge.start() can call
dataStore.listUserLogins() safely.
- Around line 763-791: The BridgeDataStore interface is missing
setManagementRoom which prevents `#persistManagementRoom` from actually persisting
rooms; add the signature setManagementRoom(room: ManagementRoom): Promise<void>
to the BridgeDataStore interface and implement it on MatrixBridgeDataStore
(implement a concrete async setManagementRoom that persists the ManagementRoom
to the same backing store used by other methods, e.g., delegate to the existing
persistence layer or db client used by MatrixBridgeDataStore), ensure
MatrixBridgeDataStore's type matches the new interface and update any
constructor/initialization to handle the method so hasMethod(store,
"setManagementRoom") will succeed and `#persistManagementRoom` can call it in
production.

---

Duplicate comments:
In `@packages/bridge/src/store.ts`:
- Around line 120-124: setPortal currently writes the new portal and its
portal-mxid reverse index but never removes a previous portal-mxid entry,
leaving stale reverse mappings; to fix, read the existing portal record (e.g.,
via this.#get(key("portal", portalStoreKey(portal))) or this.getPortal) before
writing, compare its mxid to portal.mxid, and if an old mxid exists and differs,
remove the old reverse-index key (call the store delete method, e.g.,
this.#del(key("portal-mxid", oldMxid))) before setting the new portal-mxid entry
and then proceed to write the updated portal with this.#set.

---

Nitpick comments:
In `@packages/bridge/src/bridge.ts`:
- Around line 1107-1122: The local variable `type` in the private method
`#matrixIntent()` (inside the `sendMessage` implementation) is computed via a
redundant ternary and always equals "m.room.message"; remove the dead ternary
and simplify by using a single constant or inlining the literal when building
the request path in `sendMessage` (keep `transactionId` and the call to
`this.#matrixClient.raw.request`, and preserve `eventIdFromRaw(result.body)` and
the returned object).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 937d6d2d-2b61-4d67-998a-56628d950b6d

📥 Commits

Reviewing files that changed from the base of the PR and between 7fb4469 and 2c58dc0.

📒 Files selected for processing (21)
  • examples/dummybridge/src/connector.ts
  • examples/dummybridge/src/index.ts
  • examples/dummybridge/test/smoke.ts
  • packages/bridge/README.md
  • packages/bridge/src/appservice-websocket.test.ts
  • packages/bridge/src/appservice-websocket.ts
  • packages/bridge/src/bridge.test.ts
  • packages/bridge/src/bridge.ts
  • packages/bridge/src/index.ts
  • packages/bridge/src/store.ts
  • packages/bridge/src/types.ts
  • packages/pickle/native/internal/core/appservice.go
  • packages/pickle/native/internal/core/appservice_test.go
  • packages/pickle/native/internal/core/core.go
  • packages/pickle/native/internal/core/operations.go
  • packages/pickle/src/client-types.ts
  • packages/pickle/src/client.ts
  • packages/pickle/src/generated-runtime-operations.ts
  • packages/pickle/src/generated-runtime-types.ts
  • packages/pickle/src/index.ts
  • packages/pickle/src/runtime-types.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/pickle/native/internal/core/appservice_test.go
🚧 Files skipped from review as they are similar to previous changes (8)
  • packages/pickle/src/client.ts
  • packages/pickle/src/generated-runtime-operations.ts
  • packages/pickle/src/index.ts
  • packages/pickle/native/internal/core/core.go
  • packages/pickle/src/generated-runtime-types.ts
  • packages/pickle/src/runtime-types.ts
  • examples/dummybridge/src/index.ts
  • packages/bridge/src/types.ts
📜 Review details
🧰 Additional context used
🪛 OpenGrep (1.20.0)
packages/bridge/src/bridge.test.ts

[ERROR] 822-822: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)

packages/bridge/src/appservice-websocket.ts

[ERROR] 249-249: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)

packages/bridge/src/bridge.ts

[ERROR] 654-654: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)


[ERROR] 664-664: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)


[ERROR] 1222-1222: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)


[ERROR] 1224-1224: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)

🔇 Additional comments (24)
packages/pickle/native/internal/core/appservice.go (3)

152-157: homeserverDomain is enforced as required, contradicting the optional-init contract.

This validation rejects callers when homeserverDomain is omitted, but the PR objectives mention making homeserverDomain optional. Consider deriving it from the homeserver URL when not provided.


560-567: Return the actual fallback failure in ensureJoined.

When the fallback invite/join flow fails, the code returns the original join error (err) instead of botErr or inviteErr, obscuring the actual root cause of the failure.


250-297: LGTM!

The makePortalCreateRoomRequest implementation correctly handles bridgev2 portal room creation with deterministic room IDs, auto-join invites, bridge state events, and functional member configuration.

packages/bridge/src/bridge.ts (7)

334-350: Avoid caching a login before connect() succeeds.

The client is added to #networkClients (line 341) before connect() is awaited (line 345). If connect() rejects, later loadUserLogin() calls will return the half-initialized client and never retry.


486-490: Guard queueRemoteEvent scheduling when the bridge is stopped.

queueRemoteEvent() unconditionally calls #scheduleDrain(), but #drainRemoteEvents() exits immediately when #context is null. The finally block in #scheduleDrain() then reschedules while the backlog remains non-empty, potentially causing an endless microtask loop when events are queued before start() or after stop().


492-500: Drop the previous room mapping when re-registering a portal.

If the same portal key is registered with a new mxid, the old room entry remains in #portalsByRoom, leaving stale dispatch routing.


510-512: Make flushRemoteEvents() join the scheduled drain instead of starting a second one.

Calling #drainRemoteEvents() directly bypasses #drainPromise, which can cause concurrent queue processing and break ordering guarantees.


1004-1011: Keep queued remote events until their handler succeeds.

The item is removed from #remoteEvents via shift() before #handleRemoteEvent() resolves. Any conversion/send failure drops that event permanently with no retry opportunity.


724-729: Route Matrix events to the owning login instead of broadcasting.

These loops fan each Matrix event out to every loaded NetworkAPI. Once two logins are active, events for a portal registered with portalKey.receiver = login:a will also invoke handlers on login:b, potentially duplicating or misrouting outbound actions.


161-192: LGTM!

The start() lifecycle correctly sequences status loading, Matrix client boot, appservice initialization, context creation, persisted data hydration, connector initialization, and event subscription before marking the bridge as started.

packages/bridge/src/appservice-websocket.ts (4)

200-237: Reply with an error frame when message handling throws.

If #dispatch() or #handleHTTPProxy() fails, this method rejects and the caller only logs it. The homeserver never receives a response for that request id, so the transaction can hang or be retried indefinitely.


344-350: Treat websocket request id 0 as valid.

!message.id evaluates to true when id is 0, causing legitimate requests with id 0 to never receive an ack/error response.


272-289: LGTM!

The ping logic correctly guards against stale pings, terminates the socket on timeout, and uses a single in-flight ping ID to prevent overlapping ping cycles.


123-157: LGTM!

The close handler correctly detects replacement closes (code 4001 or conn_replaced status), prevents reconnection for replaced connections, invokes lifecycle hooks, and implements capped exponential backoff for normal reconnects.

packages/bridge/src/appservice-websocket.test.ts (3)

2-2: Use a type-only import for AddressInfo.

AddressInfo is only used in type casts. Using import type makes the intent explicit.


130-167: LGTM!

The exponential backoff test effectively validates capped reconnect delays (5→10→20→20) and reset to initial delay (5) after a stable connection period, with appropriate timing configuration for fast test execution.


194-234: LGTM!

The replacement close test correctly verifies that conn_replaced status prevents reconnection and triggers both onClose and onReplaced callbacks with the expected event properties.

packages/pickle/native/internal/core/operations.go (1)

20-35: LGTM!

The new appservice operation identifiers follow the established naming conventions and align with the corresponding handler implementations in appservice.go and TypeScript bindings.

examples/dummybridge/src/connector.ts (2)

274-280: LGTM!

The overloaded method signatures correctly handle both the legacy (input) and the context-aware (ctx, input) calling conventions, enabling backward compatibility while supporting the new BridgeRequestContext pattern.


369-390: LGTM!

The remoteMessage helper cleanly constructs bridgev2-style remote events with proper portal key binding and ghost sender resolution.

packages/pickle/src/client-types.ts (1)

113-122: LGTM!

The MatrixAppservice interface provides a well-typed surface for appservice operations, with methods that align with the native Go handlers and generated runtime operations.

examples/dummybridge/test/smoke.ts (2)

23-60: LGTM!

The mock MatrixClient provides a clean test double that captures calls for assertion while returning appropriate response shapes, effectively isolating the bridge runtime behavior for smoke testing.


116-137: LGTM!

The Matrix event dispatch and remote event flush sequence correctly validates the end-to-end message echo flow, asserting that inbound Matrix messages are routed through the connector and produce outbound appservice messages with the expected ghost sender and transformed body.

packages/bridge/src/index.ts (1)

20-36: LGTM — createBeeperBridge store-derivation and Matrix config merging look correct.

The ??-chained store selection ensures a file-backed store is only created when no explicit store is provided, and the homeserver/token fallbacks to account fields are a sensible progressive disclosure pattern.

Comment thread packages/bridge/README.md
Comment thread packages/bridge/src/bridge.test.ts
Comment thread packages/bridge/src/bridge.test.ts
When creating a portal room, use top-level options as fallbacks for info fields: name and topic now fall back to options.name/options.topic, and avatarUrl falls back to options.avatarUrl. Update the BridgeCreatePortalRoomOptions type to include avatarUrl, name, and topic so callers can provide these values at the top level.
@batuhan batuhan marked this pull request as ready for review May 7, 2026 20:30
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (3)
packages/pickle/native/internal/core/appservice.go (2)

560-567: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Return the actual fallback failure in ensureJoined.

When fallback steps fail, the code currently returns the original join error instead of botErr/inviteErr, which hides the root cause and makes debugging difficult.

Suggested fix
 	if err != nil {
 		bot, botErr := as.client(as.botUserID)
 		if botErr != nil {
-			return err
+			return botErr
 		}
 		if _, inviteErr := bot.InviteUser(ctx, roomID, &mautrix.ReqInviteUser{UserID: cli.UserID}); inviteErr != nil {
-			return err
+			return inviteErr
 		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/pickle/native/internal/core/appservice.go` around lines 560 - 567,
The error handling in ensureJoined returns the original join error when the
fallback bot lookup or invite fails; change the returns so that if
as.client(as.botUserID) fails you return botErr, and if bot.InviteUser fails you
return inviteErr, preserving the actual fallback failure (refer to as.client,
bot.InviteUser, botErr, inviteErr and the ensureJoined flow).

152-157: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

homeserverDomain is enforced as required, which may break optional-init contract.

The validation requires both homeserver and homeserverDomain, but based on past review feedback, homeserverDomain should be derivable from the homeserver URL when not provided.

Suggested fix
+import (
+	"net/url"
+	// ... existing imports
+)

-	if req.Homeserver == "" || req.HomeserverDomain == "" {
-		return nil, errors.New("homeserver and homeserverDomain are required")
+	if req.Homeserver == "" {
+		return nil, errors.New("homeserver is required")
+	}
+	if req.HomeserverDomain == "" {
+		hsURL, parseErr := url.Parse(req.Homeserver)
+		if parseErr != nil || hsURL.Hostname() == "" {
+			return nil, errors.New("homeserverDomain is required when homeserver cannot be parsed")
+		}
+		req.HomeserverDomain = hsURL.Hostname()
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/pickle/native/internal/core/appservice.go` around lines 152 - 157,
The current validation wrongly requires req.HomeserverDomain; instead, if
req.HomeserverDomain is empty, derive it from req.Homeserver by parsing the
homeserver URL and extracting the host (stripping any port) and assign it to
req.HomeserverDomain before continuing validation; if URL parsing fails or
req.Homeserver is empty, return the appropriate error. Keep the other checks for
req.Registration.AppToken, req.Registration.SenderLocalpart and
req.Registration.ID unchanged. Use the existing req variable and the validation
block around Homeserver/HomeserverDomain to locate where to add the parsing and
assignment.
packages/bridge/src/store.ts (1)

120-124: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clear the old MXID index when a portal moves rooms.

When updating an existing portal with a different mxid, the old portal-mxid entry is not deleted, leaving a stale mapping that can cause incorrect portal resolution.

Suggested fix
  async setPortal(portal: Portal): Promise<void> {
    const portalKey = portalStoreKey(portal);
+   const existing = await this.getPortal(portalKey);
    await this.#set(key("portal", portalKey), portal);
+   if (existing?.mxid && existing.mxid !== portal.mxid) {
+     await this.#store.delete(key("portal-mxid", existing.mxid));
+   }
    if (portal.mxid) await this.#set(key("portal-mxid", portal.mxid), portalKey);
  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/bridge/src/store.ts` around lines 120 - 124, In setPortal, clear any
stale portal-mxid index for a portal that changes mxid: before calling
this.#set, fetch the existing portal with this.#get(key("portal", portalKey))
(where portalKey = portalStoreKey(portal)), if existingPortal?.mxid exists and
is different from portal.mxid call this.#del(key("portal-mxid",
existingPortal.mxid)); then continue to this.#set(key("portal", portalKey),
portal) and if portal.mxid set the new mapping with this.#set(key("portal-mxid",
portal.mxid), portalKey); also ensure the deletion handles the case where
portal.mxid was removed (undefined) so the old index is removed.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@packages/bridge/src/store.ts`:
- Around line 120-124: In setPortal, clear any stale portal-mxid index for a
portal that changes mxid: before calling this.#set, fetch the existing portal
with this.#get(key("portal", portalKey)) (where portalKey =
portalStoreKey(portal)), if existingPortal?.mxid exists and is different from
portal.mxid call this.#del(key("portal-mxid", existingPortal.mxid)); then
continue to this.#set(key("portal", portalKey), portal) and if portal.mxid set
the new mapping with this.#set(key("portal-mxid", portal.mxid), portalKey); also
ensure the deletion handles the case where portal.mxid was removed (undefined)
so the old index is removed.

In `@packages/pickle/native/internal/core/appservice.go`:
- Around line 560-567: The error handling in ensureJoined returns the original
join error when the fallback bot lookup or invite fails; change the returns so
that if as.client(as.botUserID) fails you return botErr, and if bot.InviteUser
fails you return inviteErr, preserving the actual fallback failure (refer to
as.client, bot.InviteUser, botErr, inviteErr and the ensureJoined flow).
- Around line 152-157: The current validation wrongly requires
req.HomeserverDomain; instead, if req.HomeserverDomain is empty, derive it from
req.Homeserver by parsing the homeserver URL and extracting the host (stripping
any port) and assign it to req.HomeserverDomain before continuing validation; if
URL parsing fails or req.Homeserver is empty, return the appropriate error. Keep
the other checks for req.Registration.AppToken, req.Registration.SenderLocalpart
and req.Registration.ID unchanged. Use the existing req variable and the
validation block around Homeserver/HomeserverDomain to locate where to add the
parsing and assignment.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 364de67c-b129-47b0-a22a-c81e4528d4bf

📥 Commits

Reviewing files that changed from the base of the PR and between 7fb4469 and e777234.

📒 Files selected for processing (21)
  • examples/dummybridge/src/connector.ts
  • examples/dummybridge/src/index.ts
  • examples/dummybridge/test/smoke.ts
  • packages/bridge/README.md
  • packages/bridge/src/appservice-websocket.test.ts
  • packages/bridge/src/appservice-websocket.ts
  • packages/bridge/src/bridge.test.ts
  • packages/bridge/src/bridge.ts
  • packages/bridge/src/index.ts
  • packages/bridge/src/store.ts
  • packages/bridge/src/types.ts
  • packages/pickle/native/internal/core/appservice.go
  • packages/pickle/native/internal/core/appservice_test.go
  • packages/pickle/native/internal/core/core.go
  • packages/pickle/native/internal/core/operations.go
  • packages/pickle/src/client-types.ts
  • packages/pickle/src/client.ts
  • packages/pickle/src/generated-runtime-operations.ts
  • packages/pickle/src/generated-runtime-types.ts
  • packages/pickle/src/index.ts
  • packages/pickle/src/runtime-types.ts
✅ Files skipped from review due to trivial changes (3)
  • packages/pickle/native/internal/core/appservice_test.go
  • packages/bridge/README.md
  • examples/dummybridge/src/index.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • packages/pickle/src/generated-runtime-operations.ts
  • packages/pickle/src/runtime-types.ts
  • packages/pickle/native/internal/core/core.go
  • packages/pickle/src/generated-runtime-types.ts
📜 Review details
🧰 Additional context used
🪛 OpenGrep (1.20.0)
packages/bridge/src/appservice-websocket.ts

[ERROR] 249-249: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)

packages/bridge/src/bridge.test.ts

[ERROR] 822-822: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)

packages/bridge/src/bridge.ts

[ERROR] 656-656: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)


[ERROR] 666-666: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)


[ERROR] 1224-1224: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)


[ERROR] 1226-1226: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)

🔇 Additional comments (68)
packages/bridge/src/appservice-websocket.test.ts (2)

2-2: 💤 Low value

Use a type-only import for AddressInfo.

AddressInfo is only used in type casts (lines 24, 79, 135, 174, 199). Using import type makes the intent explicit.

Suggested change
-import { AddressInfo } from "node:net";
+import type { AddressInfo } from "node:net";

18-234: LGTM on test coverage.

Comprehensive test suite covering the key websocket behaviors: connection establishment, transaction dispatch, HTTP proxy handling, exponential backoff with reset, ping timeout reconnection, and replacement close handling. The shared cleanup infrastructure ensures proper resource management across tests.

packages/pickle/src/client.ts (1)

74-86: LGTM!

The appservice namespace implementation follows the established patterns in this file correctly. Methods properly delegate to core operations, use stripUndefined for optional parameters, and the sendMessage result transformation aligns with the SentEvent type contract.

packages/pickle/native/internal/core/operations.go (1)

20-35: LGTM!

The new appservice operation constants follow the established naming conventions and the ts:operation comments are properly formatted for code generation.

examples/dummybridge/test/smoke.ts (1)

1-157: LGTM!

Well-structured smoke test that validates the key bridge integration points: appservice initialization, portal room creation, backfill operations, and remote event dispatch. The stub client and in-memory store implementations are appropriate for testing purposes.

packages/pickle/src/client-types.ts (1)

113-122: LGTM!

The MatrixAppservice interface is well-typed with appropriate parameter and return types that align with the implementation in client.ts and the corresponding core operations.

packages/pickle/src/index.ts (1)

21-38: LGTM!

The new appservice type exports are comprehensive and properly sourced from ./runtime-types, making the appservice API surface available to package consumers.

packages/pickle/native/internal/core/appservice.go (1)

250-297: LGTM on portal room creation logic.

The makePortalCreateRoomRequest function properly constructs the room creation request with appropriate bridge state events, power levels, and deterministic room ID derivation. The room type handling and bridge info content generation follow bridgev2 conventions.

Also applies to: 316-318

packages/bridge/src/store.ts (1)

35-39: LGTM on deletion and key generation.

The deletePortal method correctly cleans up both the portal entry and its MXID index. The portalStoreKey function uses a null delimiter to ensure uniqueness when receiver is empty.

Also applies to: 144-146

packages/bridge/src/bridge.test.ts (12)

763-791: setManagementRoom mock is inlined but still absent from BridgeDataStore interface.

The test at line 768 mocks setManagementRoom by extending createFakeDataStore(). However, as noted in past reviews, this method is not part of the BridgeDataStore interface in store.ts. The runtime's #persistManagementRoom uses hasMethod to defensively check, so it works in tests when mocked but won't persist in production with MatrixBridgeDataStore.

This is a pre-existing issue flagged in past reviews.


969-992: createFakeDataStore is missing listUserLogins stub.

This function lacks listUserLogins, which is required by bridge.start() when a dataStore is present. Any test using this factory with bridge.start() would either fail or skip auto-loading logins silently.

Currently, line 770 uses it for the management room persistence test but doesn't call bridge.start() before the dispatch calls—wait, looking again at lines 772-778, bridge.start() IS called.

However, #loadPersistedUserLogins guards with hasMethod(this.#dataStore, "listUserLogins"), so the missing method causes silent skip rather than failure.

This is a pre-existing issue flagged in past reviews.


1-23: LGTM!

The imports are well-organized, covering the necessary types from @beeper/pickle and the bridge package modules for comprehensive testing.


24-106: LGTM!

The lifecycle and login persistence tests thoroughly verify bridge startup, connector initialization, Matrix subscription setup, login idempotency, persisted login auto-load, and bridgev2 status payload structure.


108-196: LGTM!

The login process binding and Matrix message dispatch tests correctly verify that login lifecycle methods receive the bridge request context and that messages are properly routed to network clients while ignoring bridge-user messages.


198-323: LGTM!

The remote event queuing, appservice portal creation, backfill, and Beeper metadata tests comprehensively cover the portal/management room creation flows with appropriate assertions on the appservice method calls and Beeper-specific options like autoJoinInvites and initialMembers.


325-498: LGTM!

The backfill pagination, bridgev2 queue/checkpoint type assertions, and ghost/portal/message-request/status registration tests thoroughly verify the expected shapes and behaviors of the bridge runtime's metadata management capabilities.


500-640: LGTM!

The user profile/media helper wrappers and management command dispatch tests correctly verify the underlying Matrix client calls and the command routing/reply behavior including appservice bot sender selection.


642-761: LGTM!

The command prefix enforcement, implicit management room detection, and portal vs management room distinction tests correctly verify that commands are only processed when appropriate (with prefix outside management rooms, from owner for implicit management) and that persisted portals are not mistakenly treated as management rooms.


793-855: LGTM!

The login lifecycle built-in command tests comprehensively cover the login, cancel-login, list-logins, and logout commands including pending login management, network disconnect on logout, and reply content verification.


858-945: LGTM!

The test fixture factories (matrixConfig, createFakeBridgeDataStore, createFakeConnector, createFakeNetworkAPI, loginStep) provide well-structured mocks with appropriate Vitest spy functions for verifying bridge behavior.


994-1054: LGTM!

The createFakeMatrixClient factory provides a comprehensive mock of the Matrix client interface with appropriate stub implementations for all the methods exercised by the bridge runtime tests.

packages/bridge/src/appservice-websocket.ts (7)

200-237: Error handling gap: exceptions from dispatch/proxy don't produce error frames.

If #dispatch() or #handleHTTPProxy() throws, the outer catch at line 111-113 logs the error but the homeserver never receives an error response for that transaction/request. This can cause the server to hang waiting or retry indefinitely.

This is a pre-existing issue flagged in past reviews.


344-350: !message.id treats websocket request id 0 as invalid.

The check at line 345 uses !message.id which evaluates to true when id === 0, causing legitimate requests with id 0 to not receive responses.

This is a pre-existing issue flagged in past reviews.


1-70: LGTM!

The interface definitions and constructor setup are well-structured. The timing options with sensible defaults (2s initial reconnect, 120s max, 180s ping interval, 30s ping timeout, 60s stable connection threshold) provide good resilience configuration.


72-121: LGTM!

The start/stop lifecycle and #connect method correctly manage WebSocket state. The connection setup with Bearer auth, custom headers (User-Agent, Process-ID, Websocket-Version), and event handlers (open, message, close, error) follows the expected appservice websocket protocol.


123-198: LGTM!

The close handling with status detection (conn_replaced), reconnect scheduling with exponential backoff, and ping/pong lifecycle management are correctly implemented. The WeakSet for #handledCloses prevents duplicate close handling for the same socket instance.


239-299: LGTM!

The HTTP proxy handling correctly routes transaction PUTs and appservice query endpoints with appropriate fallback responses. The ping mechanism with timeout watchdog and the public send method are well-implemented.


353-463: LGTM!

The helper functions for HTTP response formatting, URL construction, close status parsing, Matrix event normalization, and utility operations are all correctly implemented. The rawMatrixEvent function properly maps different event types to their corresponding MatrixClientEvent variants.

packages/bridge/src/bridge.ts (24)

336-351: Client cached before connect() succeeds.

Line 343 sets this.#networkClients.set(login.id, client) before awaiting client.connect() at line 347. If connect() rejects, the half-initialized client remains cached, and subsequent loadUserLogin() calls return it without retry.

This is a pre-existing issue flagged in past reviews.


488-492: queueRemoteEvent always schedules drain even when bridge is stopped.

Line 490 calls #scheduleDrain() unconditionally. When #context is null (bridge stopped), #drainRemoteEvents exits immediately but the finally block reschedules if backlog exists, potentially spinning.

This is a pre-existing issue flagged in past reviews.


494-498: registerPortal doesn't clean up stale room mapping.

If a portal is re-registered with a different mxid, the old room entry remains in #portalsByRoom. This leaves the stale room ID pointing at the moved portal.

This is a pre-existing issue flagged in past reviews.


512-513: flushRemoteEvents() bypasses the scheduled drain.

Calling #drainRemoteEvents() directly can process the queue concurrently with an in-flight #drainPromise, breaking serialization.

This is a pre-existing issue flagged in past reviews.


726-731: Matrix events broadcast to all logins instead of routing to owner.

The loop iterates over all #networkClients values and invokes handlers on each. When multiple logins are active, events for a room registered with portalKey.receiver = login:a will also invoke handlers on login:b, potentially causing duplicates or misrouting.

This is a pre-existing issue flagged in past reviews.


999-1003: Remote events removed from queue before handler succeeds.

Line 1009 uses shift() to remove the item before #handleRemoteEvent completes. If the handler fails, the event is lost with no retry.

This is a pre-existing issue flagged in past reviews.


1-67: LGTM!

The imports are comprehensive, covering the Matrix client types from @beeper/pickle, the appservice websocket, Beeper helpers, and the extensive type definitions for bridge runtime.


70-117: LGTM!

The createBridge, createBeeperBridge, and createBeeperBridgeWithClient factory functions correctly wire up the bridge options with appropriate store validation and Beeper-specific appservice initialization.


119-159: LGTM!

The RuntimeBridge class structure with private fields for state management (network clients, portals, ghosts, logins, remote events queue, etc.) and the public accessors for client and context are well-organized.


161-214: LGTM!

The start and stop lifecycle methods correctly sequence: status persistence loading, Matrix client boot, appservice initialization, connector init/start, portal/login hydration, Matrix event subscription, and websocket startup. The stop sequence properly disconnects clients, stops subscriptions, and cleans up state.


216-270: LGTM!

The createLogin, createManagementRoom, and createPortalRoom methods correctly delegate to the connector and appservice with appropriate Beeper-specific options (auto-join invites, initial members).


272-334: LGTM!

The backfill, backfillMessages, and queueBackfill methods correctly handle backfill fetching through the network API, message conversion, and batch sending with appropriate login state updates and progress tracking.


354-440: LGTM!

The bridge state/status management, ghost registration, portal accessors, message request handling, and identifier resolution are correctly implemented with appropriate data store persistence.


450-486: LGTM!

The user profile helpers, media upload/download, and send methods correctly delegate to the underlying Matrix client APIs.


504-518: LGTM!

The management room registration, remote event flush, and backlog accessor are correctly implemented.


520-561: LGTM!

The dispatchMatrixEvent entry point and #requestContext/#createContext helpers correctly route events by kind and provide the bridge context for downstream handlers.


563-640: LGTM!

The persistence loading methods (#loadPersistedStatus, #loadPersistedUserLogins, #loadPersistedPortals), login state management, Matrix event subscription, and appservice websocket startup are correctly implemented with appropriate error handling and logging.


643-696: LGTM!

The HTTP provisioning proxy handling for capabilities, login flows, login list, and login start/step endpoints correctly implements the provisioning API with appropriate error responses and login process state management.


698-760: LGTM!

The Matrix message dispatch correctly handles own-user filtering, command parsing, portal lookup, and network client routing with checkpoint reporting.


762-895: LGTM!

The management command parsing, built-in command handling (help, list-logins, login, logout, cancel-login, set-management-room), and implicit management room detection are well-implemented with appropriate user feedback.


917-993: LGTM!

The Matrix reaction, redaction, and typing dispatch methods correctly handle own-user filtering and route events to network clients with the handler capability check.


1006-1107: LGTM!

The remote event handling for messages, backfill, chat info changes, and chat deletes correctly converts events and dispatches to the Matrix room with appropriate portal lookup.


1109-1194: LGTM!

The Matrix intent helper, remote message sending (with appservice vs direct path selection), command reply sending, and bridge status/checkpoint dispatch are correctly implemented.


1197-1540: LGTM!

The helper functions for logging, type checking, appservice bot user ID derivation, ghost user ID generation, bridge state mapping, error formatting, provisioning capabilities, login binding, portal key stringification, and various utility operations are all correctly implemented.

examples/dummybridge/src/connector.ts (4)

1-27: LGTM!

The imports, constants, and module structure are well-organized. The exports provide clear identifiers for testing purposes.


29-189: LGTM!

The DummyConnector implementation is clean and follows the CommandHandlingBridgeConnector interface correctly. The command handling covers various test scenarios (create-room, message, messages, ghost, kick-me, etc.) and the #remoteMessage helper properly wraps the shared remoteMessage factory.


195-312: LGTM!

The DummyLoginProcess correctly implements multiple login flow interfaces (LoginProcess, LoginProcessUserInput, LoginProcessCookies, LoginProcessDisplayAndWait). The cancellation handling via #throwIfCancelled() is properly checked at each step, and the overloaded method signatures handle both context-based and direct invocation patterns.


319-394: LGTM!

The DummyNetworkAPI implementation correctly echoes Matrix messages back as remote events for testing. The fetchMessages method provides predictable historical data, and the helper functions (remoteMessage, stringBody) are appropriately scoped.

packages/bridge/src/index.ts (2)

16-18: LGTM!

The createBridge factory is a straightforward wrapper that creates a RuntimeBridge with the configured Matrix client.


20-40: LGTM!

The createBeeperBridge factory correctly handles store selection with appropriate fallback chain, creates a data store when not provided, and properly merges Matrix configuration with account credentials. The defaultDataDir helper provides sensible defaults for file-backed storage.

packages/bridge/src/types.ts (10)

1-70: LGTM!

The imports from @beeper/pickle and ./store, along with the ID type aliases and foundational interfaces (PortalKey, BridgeName, BridgeInfoContent, BridgeConfigPart, DBMetaTypes), provide a clean type foundation.


71-130: LGTM!

The BridgeConnector interface and its extensions (CommandHandlingBridgeConnector, StoppableNetwork, DirectMediableNetwork, etc.) provide a well-structured hierarchy for different connector capabilities.


126-249: LGTM!

The NetworkAPI interface and its numerous extensions for specific capabilities (backfilling, message handling, reactions, typing, polls, etc.) follow a clean composition pattern that allows connectors to implement only the features they support.


251-355: LGTM!

The login process interfaces (LoginProcess, LoginProcessWithOverride, LoginProcessDisplayAndWait, LoginProcessUserInput, LoginProcessCookies) and related types (LoginFlow, LoginStep, cookie/input params) are comprehensive and support the various login flow patterns.


357-494: LGTM!

The RemoteEvent interface hierarchy with its variants for context mutation, pre/post handlers, chat changes, backfill, deletion, and message targeting provides good extensibility for different remote event types.


496-604: LGTM!

The PickleBridge interface defines a comprehensive public API for bridge consumers. The configuration types (CreateBridgeOptions, CreateBeeperBridgeOptions, BridgeMatrixConfig, NodeBridgeMatrixConfig) and context types provide clear contracts.


606-768: LGTM!

The logger interface, remote event result types, room creation options, management room/command types, dispatch result, capability interfaces, and entity types (UserLogin, Portal, Ghost) are well-defined.


778-930: LGTM!

The bridge state types (BridgeState, BridgeStateEvent, BridgeStatePayload, BridgeStatus), message request, identifier resolution, profile, media, and message types are comprehensive and cover the bridgev2 requirements.


931-1030: LGTM!

The Matrix-facing payload types for messages, reactions, edits, redactions, read receipts, typing, polls, disappearing timer, membership, room metadata, mute, tags, and chat deletion are well-structured.


1032-1158: LGTM!

The message checkpoint types (MessageCheckpoint, MessageCheckpoints), chat info types, and backfill/fetch types (FetchMessagesParams, FetchMessagesResponse, BackfillQueueTask, BackfillQueueParams, BackfillQueueResult) are comprehensive and match the bridgev2 specification.

batuhan added 2 commits May 7, 2026 22:43
Update @types/node to ^20.0.0 across packages and adjust code and tests to improve bridge runtime stability and behavior. Notable changes:

- Bridge: add try/catch and improved handling in AppserviceWebsocket, guard messageResponse for missing ids, deduplicate network client loads, schedule/drain remote events with a drainPromise, add portal-specific network client filtering, handle portal registration mxid cleanup, and simplify sendMessage type handling.
- Store: add management room support and ensure portal mxid mapping is cleaned when changed.
- Tests: add missing mocked store methods and update tests to reflect new behavior.
- Examples: guard against empty keys when loading env, and respect BEEPER_BASE_DOMAIN in dummy bridge example.
- Pickle (TS/Go): make homeserverDomain optional in types and Go init, derive homeserverDomain from URL when omitted, and improve error returns; clarify loginWithPassword error messages.
- Documentation/license: add full MPL2 LICENSE and a connector stub to the bridge README.

These changes fix race conditions around user login loading and remote event draining, add some missing persistence APIs, and improve robustness of websocket/error handling.
Update packages/state-sqlite devDependency @types/node from ^20.0.0 to ^22.5.0 and regenerate pnpm-lock.yaml. The lockfile now includes @types/node@22.19.17 and updates related vitest/vite snapshots and optional dependency references to use the newer typings. No runtime code changes.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/bridge/src/bridge.ts`:
- Around line 887-899: The logout handler (`#handleLogoutCommand`) removes the
client and persisted login but fails to clear in-memory login state and the
bridge status entry; update the function to also remove any entry for the
loginId from `#loginStates` and delete or update the persisted
`#bridgeStatus.logins` so subsequent `#sendCurrentBridgeStatus`() won't report it as
connected, ensuring you call the existing `#deleteStoredUserLogin`(loginId) (or an
analogous persistence cleanup method) and then remove from the `#loginStates` map
before sending the current bridge status.
- Around line 1240-1250: The helpers appserviceBotUserId and
ghostUserIdFromRegistration currently interpolate options.homeserverDomain
directly which can be undefined; change them to compute a domain variable first:
use options.homeserverDomain if present, otherwise parse options.homeserver
(e.g., new URL(options.homeserver).hostname) to derive the domain, and use that
domain for the MXID construction in both functions; ensure you handle a missing
options.homeserver with a clear error or fallback so you never produce
`:undefined` in the returned user IDs.
- Around line 964-973: The current MatrixRedaction message sets eventId to the
redaction event's ID (event.eventId) but should set it to the redacted/target
event ID found on the incoming event object (event.raw.redacts); update the
construction of the msg in bridge.ts to use event.raw.redacts (guarding for
existence) before iterating clients via `#networkClientsForPortal` and calling
client.handleMatrixRedaction(this.#requestContext(), msg) so handlers receive
the target event ID rather than the redaction event ID.
- Around line 352-361: The code stores and connects using the incoming UserLogin
as-is, which can be a partial { id } and overwrite canonical fields; before
calling connector.loadUserLogin / client.connect and persisting, resolve and
merge the canonical login record: if this.#userLogins has an entry for login.id
or this.#dataStore exposes a getter (e.g., getUserLogin), load that full record
and shallow-merge its userId, remoteName and connector-specific metadata into
the incoming login object, then use the mergedLogin for client.connect,
this.#userLogins.set and this.#dataStore.setUserLogin so you don't discard
existing fields; keep references to methods shown (`#loadUserLogin`,
`#setLoginBridgeState`, connector.loadUserLogin, client.connect,
this.#userLogins.set, this.#dataStore.setUserLogin).
- Around line 1020-1025: The current `#scheduleDrain` sets `#drainPromise` to
this.#drainRemoteEvents().finally(...) which always reschedules immediately on
failure, causing a hot loop because failed items remain at `#remoteEvents`[0];
change the logic so you only reschedule when `#drainRemoteEvents` resolves
successfully. Replace the finally block with a then/catch (or await try/catch)
pattern: on success clear `#drainPromise` and if this.#context &&
this.#remoteEvents.length > 0 call `#scheduleDrain`(); on failure clear
`#drainPromise` but do not immediately reschedule (optionally set a retry/backoff
if desired). Make changes in `#scheduleDrain`, referencing `#drainPromise`,
`#drainRemoteEvents`, and `#remoteEvents`.

In `@packages/pickle/native/internal/core/appservice.go`:
- Around line 325-326: The deterministicPortalRoomID construction in
deterministicPortalRoomID currently concatenates portalKey.ID and
portalKey.Receiver verbatim, causing collisions and invalid RoomIDs; update
deterministicPortalRoomID to first encode each PortalKey field (e.g., use
URL-safe base64 or percent-encoding) so delimiters like '.' and ':' cannot
collide or produce illegal characters, then compose the RoomID from the encoded
id and receiver along with as.homeserverDomain; ensure whatever encoder you pick
is reversible/consistent and does not emit ':' so existing RoomID formatting
remains valid and unique for MatrixAppservicePortalKey.

In `@packages/pickle/src/auth.ts`:
- Around line 119-123: In authOptions, the homeserver field currently allows an
empty string to pass through and later produce an invalid URL; normalize blank
or whitespace-only homeserver inputs by treating them as unset (e.g., trim and
check length === 0) before applying the fallback logic so the computed
homeserver uses process.env or the default; update the homeserver assignment in
authOptions to check for a falsy/empty trimmed homeserver from
PasswordAuthOptions and only then fall back to process.env.MATRIX_HOMESERVER or
the generated default, preserving the rest of MatrixAuthOptions (including
optional fetch).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e9879950-b3e0-4817-bce7-c48447f3382f

📥 Commits

Reviewing files that changed from the base of the PR and between e777234 and ae17650.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (28)
  • examples/dummybridge/package.json
  • examples/dummybridge/src/env.ts
  • examples/dummybridge/src/index.ts
  • examples/dummybridge/test/smoke.ts
  • package.json
  • packages/ai-sdk/package.json
  • packages/bridge/LICENSE
  • packages/bridge/README.md
  • packages/bridge/docs/BRIDGE_TODO.md
  • packages/bridge/package.json
  • packages/bridge/src/appservice-websocket.test.ts
  • packages/bridge/src/appservice-websocket.ts
  • packages/bridge/src/beeper.ts
  • packages/bridge/src/bridge.test.ts
  • packages/bridge/src/bridge.ts
  • packages/bridge/src/events.ts
  • packages/bridge/src/store.ts
  • packages/chat-adapter/package.json
  • packages/cloudflare/package.json
  • packages/pickle/native/internal/core/appservice.go
  • packages/pickle/package.json
  • packages/pickle/src/auth.ts
  • packages/pickle/src/generated-runtime-types.ts
  • packages/state-file/package.json
  • packages/state-indexeddb/package.json
  • packages/state-memory/package.json
  • packages/state-simple/package.json
  • packages/state-sqlite/package.json
✅ Files skipped from review due to trivial changes (18)
  • packages/cloudflare/package.json
  • packages/state-memory/package.json
  • packages/state-file/package.json
  • packages/pickle/package.json
  • packages/state-simple/package.json
  • packages/ai-sdk/package.json
  • packages/chat-adapter/package.json
  • packages/state-sqlite/package.json
  • packages/state-indexeddb/package.json
  • packages/bridge/LICENSE
  • examples/dummybridge/package.json
  • packages/bridge/src/events.ts
  • examples/dummybridge/src/env.ts
  • examples/dummybridge/test/smoke.ts
  • packages/pickle/src/generated-runtime-types.ts
  • examples/dummybridge/src/index.ts
  • packages/bridge/src/beeper.ts
  • packages/bridge/README.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/bridge/src/store.ts
📜 Review details
🧰 Additional context used
🪛 LanguageTool
packages/bridge/docs/BRIDGE_TODO.md

[grammar] ~17-~17: Ensure spelling is correct
Context: ... Appservice portal room creation. - [x] Appservice batch backfill. - [x] Basic live Matrix...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)


[style] ~50-~50: This phrase is redundant (‘I’ stands for ‘Interfaces’). Use simply “APIInterfaces”.
Context: ...ta registration/migrations. ## Network API Interfaces - [x] NetworkAPI. - [x] `PushableNet...

(ACRONYM_TAUTOLOGY)

🪛 OpenGrep (1.20.0)
packages/bridge/src/bridge.ts

[ERROR] 676-676: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)


[ERROR] 686-686: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)


[ERROR] 1254-1254: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)


[ERROR] 1256-1256: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)

packages/bridge/src/appservice-websocket.ts

[ERROR] 255-255: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)

packages/bridge/src/bridge.test.ts

[ERROR] 822-822: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)

🔇 Additional comments (4)
packages/pickle/src/auth.ts (2)

19-24: PasswordAuthOptions shape looks clean and consistent.

Good use of Omit<MatrixAuthOptions, "homeserver"> plus optional overrides for ergonomic auth input.


42-52: loginWithPassword fallback and validation flow is solid.

The credential resolution order and explicit missing-input errors are clear and practical.

packages/pickle/native/internal/core/appservice.go (2)

159-176: Nice compatibility fix for optional homeserverDomain.

Deriving the domain from homeserver keeps the native init path aligned with the TS contract and still fails fast on malformed URLs.


568-585: Good ensureJoined fallback cleanup.

Returning the actual fallback error and updating the membership cache after a successful join makes this path much easier to debug.

Comment on lines +352 to +361
async #loadUserLogin(login: UserLogin): Promise<NetworkAPI> {
await this.#setLoginBridgeState(login, "CONNECTING");
const client = await this.connector.loadUserLogin(this.#requestContext(), login);
await client.connect({ ...this.#requestContext(), login });
login.client = client;
this.#userLogins.set(login.id, login);
this.#networkClients.set(login.id, client);
if (this.#dataStore && hasMethod(this.#dataStore, "setUserLogin")) {
await this.#dataStore.setUserLogin(login);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Resolve the canonical UserLogin before persisting it.

This path can be reached with only { id } (for example after a login flow completes), and then it writes that partial object back into #userLogins/dataStore and passes it into client.connect(). That can silently discard userId, remoteName, and connector-specific metadata from an existing login record.

🛠️ Suggested fix
  async `#loadUserLogin`(login: UserLogin): Promise<NetworkAPI> {
-    await this.#setLoginBridgeState(login, "CONNECTING");
-    const client = await this.connector.loadUserLogin(this.#requestContext(), login);
-    await client.connect({ ...this.#requestContext(), login });
-    login.client = client;
-    this.#userLogins.set(login.id, login);
-    this.#networkClients.set(login.id, client);
+    const storedLogin = this.#dataStore && hasMethod(this.#dataStore, "getUserLogin")
+      ? await this.#dataStore.getUserLogin(login.id)
+      : null;
+    const resolvedLogin = storedLogin ? { ...storedLogin, ...login } : login;
+    await this.#setLoginBridgeState(resolvedLogin, "CONNECTING");
+    const client = await this.connector.loadUserLogin(this.#requestContext(), resolvedLogin);
+    await client.connect({ ...this.#requestContext(), login: resolvedLogin });
+    resolvedLogin.client = client;
+    this.#userLogins.set(resolvedLogin.id, resolvedLogin);
+    this.#networkClients.set(resolvedLogin.id, client);
     if (this.#dataStore && hasMethod(this.#dataStore, "setUserLogin")) {
-      await this.#dataStore.setUserLogin(login);
+      await this.#dataStore.setUserLogin(resolvedLogin);
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/bridge/src/bridge.ts` around lines 352 - 361, The code stores and
connects using the incoming UserLogin as-is, which can be a partial { id } and
overwrite canonical fields; before calling connector.loadUserLogin /
client.connect and persisting, resolve and merge the canonical login record: if
this.#userLogins has an entry for login.id or this.#dataStore exposes a getter
(e.g., getUserLogin), load that full record and shallow-merge its userId,
remoteName and connector-specific metadata into the incoming login object, then
use the mergedLogin for client.connect, this.#userLogins.set and
this.#dataStore.setUserLogin so you don't discard existing fields; keep
references to methods shown (`#loadUserLogin`, `#setLoginBridgeState`,
connector.loadUserLogin, client.connect, this.#userLogins.set,
this.#dataStore.setUserLogin).

Comment on lines +887 to +899
async #handleLogoutCommand(command: MatrixCommand): Promise<MatrixCommandResponse> {
const loginId = command.args[0];
if (!loginId) return { handled: true, text: "Usage: logout <login-id>" };
const client = this.#networkClients.get(loginId);
const login = this.#userLogins.get(loginId);
if (!client && !login) return { handled: true, text: `Login not found: ${loginId}` };
if (client) await client.disconnect();
this.#networkClients.delete(loginId);
this.#userLogins.delete(loginId);
await this.#deleteStoredUserLogin(loginId);
this.#sendCurrentBridgeStatus();
return { handled: true, text: `Logged out: ${loginId}` };
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clear the login state when logging out.

This removes the session from #networkClients/#userLogins, but #loginStates and the persisted #bridgeStatus.logins entry are left behind. Subsequent bridge_status updates can still report the just-logged-out login as connected.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/bridge/src/bridge.ts` around lines 887 - 899, The logout handler
(`#handleLogoutCommand`) removes the client and persisted login but fails to clear
in-memory login state and the bridge status entry; update the function to also
remove any entry for the loginId from `#loginStates` and delete or update the
persisted `#bridgeStatus.logins` so subsequent `#sendCurrentBridgeStatus`() won't
report it as connected, ensuring you call the existing
`#deleteStoredUserLogin`(loginId) (or an analogous persistence cleanup method) and
then remove from the `#loginStates` map before sending the current bridge status.

Comment on lines +964 to +973
const msg: MatrixRedaction = {
eventId: event.eventId,
portal: this.#portalForRoom(roomId),
};
let handlers = 0;
for (const client of this.#networkClientsForPortal(msg.portal)) {
if (!hasMethod(client, "handleMatrixRedaction")) continue;
handlers += 1;
await client.handleMatrixRedaction(this.#requestContext(), msg);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use the redacted event ID here, not the redaction event ID.

For Matrix redactions, event.eventId is the ID of the redaction event itself. The message being removed is in event.raw.redacts; forwarding the current value leaves handleMatrixRedaction() without the target it needs.

🛠️ Suggested fix
   async `#dispatchMatrixRedaction`(event: GenericMatrixEvent): Promise<MatrixDispatchResult> {
     const roomId = event.roomId;
     if (!roomId || !event.eventId) {
       return roomId
         ? { dispatched: false, handlers: 0, kind: event.kind, roomId }
         : { dispatched: false, handlers: 0, kind: event.kind };
     }
+    const redacts =
+      event.raw && typeof event.raw === "object" && typeof (event.raw as { redacts?: unknown }).redacts === "string"
+        ? (event.raw as { redacts: string }).redacts
+        : null;
+    if (!redacts) {
+      return { dispatched: false, eventId: event.eventId, handlers: 0, kind: event.kind, roomId };
+    }
     const msg: MatrixRedaction = {
-      eventId: event.eventId,
+      eventId: redacts,
       portal: this.#portalForRoom(roomId),
     };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/bridge/src/bridge.ts` around lines 964 - 973, The current
MatrixRedaction message sets eventId to the redaction event's ID (event.eventId)
but should set it to the redacted/target event ID found on the incoming event
object (event.raw.redacts); update the construction of the msg in bridge.ts to
use event.raw.redacts (guarding for existence) before iterating clients via
`#networkClientsForPortal` and calling
client.handleMatrixRedaction(this.#requestContext(), msg) so handlers receive
the target event ID rather than the redaction event ID.

Comment on lines +1020 to +1025
#scheduleDrain(): void {
if (!this.#context) return;
this.#drainPromise ??= this.#drainRemoteEvents().finally(() => {
this.#drainPromise = null;
if (this.#context && this.#remoteEvents.length > 0) this.#scheduleDrain();
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't immediately reschedule a failed remote-event drain.

Because failed items stay at #remoteEvents[0], this finally will requeue the same drain again right away. One permanently failing remote event will spin a hot retry loop until shutdown.

🛠️ Suggested fix
   `#scheduleDrain`(): void {
     if (!this.#context) return;
-    this.#drainPromise ??= this.#drainRemoteEvents().finally(() => {
-      this.#drainPromise = null;
-      if (this.#context && this.#remoteEvents.length > 0) this.#scheduleDrain();
-    });
+    let completed = false;
+    this.#drainPromise ??= this.#drainRemoteEvents()
+      .then(() => {
+        completed = true;
+      })
+      .finally(() => {
+        this.#drainPromise = null;
+        if (completed && this.#context && this.#remoteEvents.length > 0) {
+          this.#scheduleDrain();
+        }
+      });
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/bridge/src/bridge.ts` around lines 1020 - 1025, The current
`#scheduleDrain` sets `#drainPromise` to this.#drainRemoteEvents().finally(...)
which always reschedules immediately on failure, causing a hot loop because
failed items remain at `#remoteEvents`[0]; change the logic so you only reschedule
when `#drainRemoteEvents` resolves successfully. Replace the finally block with a
then/catch (or await try/catch) pattern: on success clear `#drainPromise` and if
this.#context && this.#remoteEvents.length > 0 call `#scheduleDrain`(); on failure
clear `#drainPromise` but do not immediately reschedule (optionally set a
retry/backoff if desired). Make changes in `#scheduleDrain`, referencing
`#drainPromise`, `#drainRemoteEvents`, and `#remoteEvents`.

Comment on lines +1240 to +1250
function appserviceBotUserId(options: MatrixAppserviceInitOptions): string {
return `@${options.registration.senderLocalpart}:${options.homeserverDomain}`;
}

function ghostUserIdFromRegistration(options: MatrixAppserviceInitOptions, escapedLocalId: string): string {
const botUserId = appserviceBotUserId(options);
for (const namespace of options.registration.namespaces.users ?? []) {
const userId = userIdFromNamespaceRegex(namespace.regex, escapedLocalId);
if (userId && userId !== botUserId) return userId;
}
return `@${options.registration.senderLocalpart}_${escapedLocalId}:${options.homeserverDomain}`;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fallback to the homeserver URL when homeserverDomain is omitted.

homeserverDomain is optional now, but these helpers still interpolate it directly. Callers that rely on the new optional flow will produce bot/ghost MXIDs like @testbot:undefined even though the domain is derivable from options.homeserver.

🛠️ Suggested fix
+function appserviceDomain(options: MatrixAppserviceInitOptions): string {
+  return options.homeserverDomain ?? new URL(options.homeserver).hostname;
+}
+
 function appserviceBotUserId(options: MatrixAppserviceInitOptions): string {
-  return `@${options.registration.senderLocalpart}:${options.homeserverDomain}`;
+  return `@${options.registration.senderLocalpart}:${appserviceDomain(options)}`;
 }
 
 function ghostUserIdFromRegistration(options: MatrixAppserviceInitOptions, escapedLocalId: string): string {
   const botUserId = appserviceBotUserId(options);
@@
-  return `@${options.registration.senderLocalpart}_${escapedLocalId}:${options.homeserverDomain}`;
+  return `@${options.registration.senderLocalpart}_${escapedLocalId}:${appserviceDomain(options)}`;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/bridge/src/bridge.ts` around lines 1240 - 1250, The helpers
appserviceBotUserId and ghostUserIdFromRegistration currently interpolate
options.homeserverDomain directly which can be undefined; change them to compute
a domain variable first: use options.homeserverDomain if present, otherwise
parse options.homeserver (e.g., new URL(options.homeserver).hostname) to derive
the domain, and use that domain for the MXID construction in both functions;
ensure you handle a missing options.homeserver with a clear error or fallback so
you never produce `:undefined` in the returned user IDs.

Comment on lines +325 to +326
func (as *matrixAppservice) deterministicPortalRoomID(portalKey MatrixAppservicePortalKey) id.RoomID {
return id.RoomID(fmt.Sprintf("!%s.%s:%s", portalKey.ID, portalKey.Receiver, as.homeserverDomain))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Encode PortalKey parts before composing the deterministic room ID.

This concatenation is ambiguous for arbitrary external IDs. For example, {"id":"foo.bar","receiver":"baz"} and {"id":"foo","receiver":"bar.baz"} both collapse to the same value, and : inside either field produces a malformed id.RoomID. Because this value is then reused as both BeeperLocalRoomID and MeowRoomID, two distinct portals can end up sharing the same local-room identity.

Proposed fix
 import (
 	"context"
+	"encoding/base64"
 	"encoding/json"
 	"errors"
 	"fmt"
 	"net/http"
 	"net/url"
@@
 func (as *matrixAppservice) deterministicPortalRoomID(portalKey MatrixAppservicePortalKey) id.RoomID {
-	return id.RoomID(fmt.Sprintf("!%s.%s:%s", portalKey.ID, portalKey.Receiver, as.homeserverDomain))
+	encodedID := base64.RawURLEncoding.EncodeToString([]byte(portalKey.ID))
+	encodedReceiver := base64.RawURLEncoding.EncodeToString([]byte(portalKey.Receiver))
+	return id.RoomID(fmt.Sprintf("!%s.%s:%s", encodedID, encodedReceiver, as.homeserverDomain))
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func (as *matrixAppservice) deterministicPortalRoomID(portalKey MatrixAppservicePortalKey) id.RoomID {
return id.RoomID(fmt.Sprintf("!%s.%s:%s", portalKey.ID, portalKey.Receiver, as.homeserverDomain))
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
...
)
func (as *matrixAppservice) deterministicPortalRoomID(portalKey MatrixAppservicePortalKey) id.RoomID {
encodedID := base64.RawURLEncoding.EncodeToString([]byte(portalKey.ID))
encodedReceiver := base64.RawURLEncoding.EncodeToString([]byte(portalKey.Receiver))
return id.RoomID(fmt.Sprintf("!%s.%s:%s", encodedID, encodedReceiver, as.homeserverDomain))
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/pickle/native/internal/core/appservice.go` around lines 325 - 326,
The deterministicPortalRoomID construction in deterministicPortalRoomID
currently concatenates portalKey.ID and portalKey.Receiver verbatim, causing
collisions and invalid RoomIDs; update deterministicPortalRoomID to first encode
each PortalKey field (e.g., use URL-safe base64 or percent-encoding) so
delimiters like '.' and ':' cannot collide or produce illegal characters, then
compose the RoomID from the encoded id and receiver along with
as.homeserverDomain; ensure whatever encoder you pick is reversible/consistent
and does not emit ':' so existing RoomID formatting remains valid and unique for
MatrixAppservicePortalKey.

Comment on lines +119 to +123
function authOptions(options: PasswordAuthOptions): MatrixAuthOptions {
const output: MatrixAuthOptions = {
homeserver: options.homeserver ?? process.env.MATRIX_HOMESERVER ?? `https://matrix.${options.baseDomain ?? process.env.BEEPER_BASE_DOMAIN ?? "beeper.com"}`,
};
if (options.fetch !== undefined) output.fetch = options.fetch;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Handle empty-string homeserver inputs as “unset”.

Right now, a blank homeserver value can pass through and fail later with a non-actionable URL error. Normalize blank strings before fallback selection.

Suggested hardening
 function authOptions(options: PasswordAuthOptions): MatrixAuthOptions {
+  const nonEmpty = (value: string | undefined): string | undefined => {
+    const trimmed = value?.trim();
+    return trimmed ? trimmed : undefined;
+  };
+
+  const baseDomain = nonEmpty(options.baseDomain) ?? nonEmpty(process.env.BEEPER_BASE_DOMAIN) ?? "beeper.com";
   const output: MatrixAuthOptions = {
-    homeserver: options.homeserver ?? process.env.MATRIX_HOMESERVER ?? `https://matrix.${options.baseDomain ?? process.env.BEEPER_BASE_DOMAIN ?? "beeper.com"}`,
+    homeserver: nonEmpty(options.homeserver) ?? nonEmpty(process.env.MATRIX_HOMESERVER) ?? `https://matrix.${baseDomain}`,
   };
   if (options.fetch !== undefined) output.fetch = options.fetch;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/pickle/src/auth.ts` around lines 119 - 123, In authOptions, the
homeserver field currently allows an empty string to pass through and later
produce an invalid URL; normalize blank or whitespace-only homeserver inputs by
treating them as unset (e.g., trim and check length === 0) before applying the
fallback logic so the computed homeserver uses process.env or the default;
update the homeserver assignment in authOptions to check for a falsy/empty
trimmed homeserver from PasswordAuthOptions and only then fall back to
process.env.MATRIX_HOMESERVER or the generated default, preserving the rest of
MatrixAuthOptions (including optional fetch).

batuhan added 3 commits May 7, 2026 23:04
Introduce a higher-level API for queuing remote events and portal handling: add RuntimeBridge.createPortal and backfillPortal, plus queue(login).message/event helpers and queueMessage/queueEvent methods. Add convertedMessageFromOptions and textMessage helpers to convert simple message options into ConvertedMessage parts. Expand types with PortalReference, BridgeCreatePortalOptions, RemoteEventQueue, BridgeRemoteEventOptions and BridgeRemoteMessageOptions to accept portal IDs, keys or Portal objects and simplify sender handling. Update runtime internals to resolve portal/sender references, expose queue helpers in BridgeContext, and wire new methods through the runtime. Update examples (dummybridge) and smoke test to use the new APIs, and update README usage accordingly.
Add explicit backfill support to the bridge API and consolidate remote message creation. Introduce RemoteEventQueue.backfill and new types BridgeRemoteBackfillOptions / BridgeRemoteBackfillMessageOptions. Refactor RuntimeBridge to use private helpers (#queueEvent, #queueMessage, #queueBackfillEvent and #remoteMessageEvent) and import RemoteMessageWithTransactionID; queueMessage/queueEvent were converted to private implementations and removed from the public PickleBridge/BridgeContext surface. These changes centralize message construction and enable batching backfill payloads when queuing remote events.
Refactor #queueBackfillEvent to construct a RemoteBackfill object and pass it to queueRemoteEvent. The backfill payload is now wrapped with stripUndefined to remove undefined properties before being resolved, and message portal values fall back to options.portal. This cleans up the queued backfill payload and makes the code clearer by using an explicit event object.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (5)
packages/bridge/src/bridge.ts (5)

1058-1060: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use the redacted target event ID for redaction dispatch

At Line 1058-1060, MatrixRedaction.eventId is set to event.eventId (redaction event). Handlers typically need the redacted target (event.raw.redacts).

Suggested fix
 async `#dispatchMatrixRedaction`(event: GenericMatrixEvent): Promise<MatrixDispatchResult> {
@@
+  const redacts =
+    event.raw && typeof event.raw === "object" && typeof (event.raw as { redacts?: unknown }).redacts === "string"
+      ? (event.raw as { redacts: string }).redacts
+      : null;
+  if (!redacts) {
+    return { dispatched: false, eventId: event.eventId, handlers: 0, kind: event.kind, roomId };
+  }
   const msg: MatrixRedaction = {
-    eventId: event.eventId,
+    eventId: redacts,
     portal: this.#portalForRoom(roomId),
   };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/bridge/src/bridge.ts` around lines 1058 - 1060, The redaction
dispatch is currently using the redaction event's own ID (event.eventId) instead
of the ID of the event being redacted; update the MatrixRedaction construction
(the msg object) to use the redacted target ID (e.g. event.raw.redacts) for
MatrixRedaction.eventId so downstream handlers receive the actual target event
ID (ensure you reference the same variable names: MatrixRedaction, msg, and
event).

981-992: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clear login state/status entries on logout

At Line 981-992, logout removes the client/login, but leaves #loginStates untouched. Bridge status can keep reporting a logged-out login.

Suggested fix
 async `#handleLogoutCommand`(command: MatrixCommand): Promise<MatrixCommandResponse> {
   const loginId = command.args[0];
   if (!loginId) return { handled: true, text: "Usage: logout <login-id>" };
@@
   this.#networkClients.delete(loginId);
   this.#userLogins.delete(loginId);
+  this.#loginStates.delete(loginId);
+  if (this.#bridgeStatus) {
+    this.#bridgeStatus = { ...this.#bridgeStatus, logins: this.#loginStatesRecord() };
+  }
   await this.#deleteStoredUserLogin(loginId);
   this.#sendCurrentBridgeStatus();
   return { handled: true, text: `Logged out: ${loginId}` };
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/bridge/src/bridge.ts` around lines 981 - 992, The logout handler in
`#handleLogoutCommand` removes the client and login but leaves the in-memory
`#loginStates` entry, causing stale status in `#sendCurrentBridgeStatus`; update
`#handleLogoutCommand` to also remove the login's state (e.g., delete
this.#loginStates[loginId] or this.#loginStates.delete(loginId) depending on its
type) after disconnecting and before calling this.#sendCurrentBridgeStatus, and
ensure any related per-login state maps (such as this.#networkClients,
this.#userLogins) are cleared consistently and persisted by awaiting
this.#deleteStoredUserLogin(loginId).

1114-1119: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid immediate retry loop after drain failure

At Line 1116-1119, rescheduling in finally runs even when #drainRemoteEvents() throws. A permanently failing first event can cause a hot retry loop.

Suggested fix
 `#scheduleDrain`(): void {
   if (!this.#context) return;
-  this.#drainPromise ??= this.#drainRemoteEvents().finally(() => {
-    this.#drainPromise = null;
-    if (this.#context && this.#remoteEvents.length > 0) this.#scheduleDrain();
-  });
+  if (this.#drainPromise) return;
+  this.#drainPromise = this.#drainRemoteEvents()
+    .then(() => {
+      this.#drainPromise = null;
+      if (this.#context && this.#remoteEvents.length > 0) this.#scheduleDrain();
+    })
+    .catch(() => {
+      this.#drainPromise = null;
+    });
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/bridge/src/bridge.ts` around lines 1114 - 1119, The reschedule logic
in `#scheduleDrain` currently runs in the finally of this.#drainRemoteEvents() so
it retries immediately even on errors; change it to only reschedule immediately
when the drain succeeds, and on rejection avoid a hot loop by scheduling a
delayed retry (e.g., setTimeout/backoff) or not rescheduling at all; update the
implementation around `#scheduleDrain` and the call to this.#drainRemoteEvents()
so the success path re-invokes `#scheduleDrain` immediately when
this.#remoteEvents.length>0 while the error path uses a controlled backoff delay
before re-enqueueing scheduling.

427-436: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Resolve canonical login data before persisting/loading client context

At Line 427-436, this path still persists the incoming login object as-is. If callers pass { id }, canonical fields (userId, remoteName, connector metadata) can be lost in memory/store and downstream connect context.

Suggested fix
 async `#loadUserLogin`(login: UserLogin): Promise<NetworkAPI> {
-  await this.#setLoginBridgeState(login, "CONNECTING");
-  const client = await this.connector.loadUserLogin(this.#requestContext(), login);
-  await client.connect({ ...this.#requestContext(), login });
-  login.client = client;
-  this.#userLogins.set(login.id, login);
-  this.#networkClients.set(login.id, client);
+  const stored = this.#userLogins.get(login.id)
+    ?? (this.#dataStore && hasMethod(this.#dataStore, "getUserLogin")
+      ? await this.#dataStore.getUserLogin(login.id)
+      : undefined);
+  const resolved = stored ? { ...stored, ...login } : login;
+  await this.#setLoginBridgeState(resolved, "CONNECTING");
+  const client = await this.connector.loadUserLogin(this.#requestContext(), resolved);
+  await client.connect({ ...this.#requestContext(), login: resolved });
+  resolved.client = client;
+  this.#userLogins.set(resolved.id, resolved);
+  this.#networkClients.set(resolved.id, client);
   if (this.#dataStore && hasMethod(this.#dataStore, "setUserLogin")) {
-    await this.#dataStore.setUserLogin(login);
+    await this.#dataStore.setUserLogin(resolved);
   }
-  await this.#setLoginBridgeState(login, "CONNECTED");
+  await this.#setLoginBridgeState(resolved, "CONNECTED");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/bridge/src/bridge.ts` around lines 427 - 436, The code is persisting
the raw incoming login object instead of a canonicalized version; update async
`#loadUserLogin` so you first resolve the canonical login (via the connector or
client normalization API — e.g., call a canonicalize/resolveLogin helper on
this.connector or client) and then use that canonicalLogin everywhere: pass
canonicalLogin into client.connect, assign canonicalLogin.client and store
canonicalLogin into this.#userLogins and this.#networkClients, and call
this.#dataStore.setUserLogin(canonicalLogin) if present; ensure the original
incoming object is not persisted but replaced by the resolved canonicalLogin
before any map/store updates.

1334-1345: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Derive appservice domain when homeserverDomain is omitted

At Line 1334-1345, MXID helpers interpolate options.homeserverDomain directly. With optional-domain flows, this can produce :undefined.

Suggested fix
+function appserviceDomain(options: MatrixAppserviceInitOptions): string {
+  return options.homeserverDomain ?? new URL(options.homeserver).hostname;
+}
+
 function appserviceBotUserId(options: MatrixAppserviceInitOptions): string {
-  return `@${options.registration.senderLocalpart}:${options.homeserverDomain}`;
+  return `@${options.registration.senderLocalpart}:${appserviceDomain(options)}`;
 }
@@
-  return `@${options.registration.senderLocalpart}_${escapedLocalId}:${options.homeserverDomain}`;
+  return `@${options.registration.senderLocalpart}_${escapedLocalId}:${appserviceDomain(options)}`;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/bridge/src/bridge.ts` around lines 1334 - 1345, The helpers
appserviceBotUserId and ghostUserIdFromRegistration currently interpolate
options.homeserverDomain and can produce ":undefined"; change them to derive a
safe domain first (e.g. const domain = options.homeserverDomain ?? (() => { try
{ return new URL(options.registration.url).hostname } catch { return undefined }
})() ?? throw new Error('missing homeserver domain')) and then use that domain
variable when building MXIDs; update appserviceBotUserId (use domain) and
ghostUserIdFromRegistration (use domain when returning the fallback MXID) so
they never interpolate undefined.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@packages/bridge/src/bridge.ts`:
- Around line 1058-1060: The redaction dispatch is currently using the redaction
event's own ID (event.eventId) instead of the ID of the event being redacted;
update the MatrixRedaction construction (the msg object) to use the redacted
target ID (e.g. event.raw.redacts) for MatrixRedaction.eventId so downstream
handlers receive the actual target event ID (ensure you reference the same
variable names: MatrixRedaction, msg, and event).
- Around line 981-992: The logout handler in `#handleLogoutCommand` removes the
client and login but leaves the in-memory `#loginStates` entry, causing stale
status in `#sendCurrentBridgeStatus`; update `#handleLogoutCommand` to also remove
the login's state (e.g., delete this.#loginStates[loginId] or
this.#loginStates.delete(loginId) depending on its type) after disconnecting and
before calling this.#sendCurrentBridgeStatus, and ensure any related per-login
state maps (such as this.#networkClients, this.#userLogins) are cleared
consistently and persisted by awaiting this.#deleteStoredUserLogin(loginId).
- Around line 1114-1119: The reschedule logic in `#scheduleDrain` currently runs
in the finally of this.#drainRemoteEvents() so it retries immediately even on
errors; change it to only reschedule immediately when the drain succeeds, and on
rejection avoid a hot loop by scheduling a delayed retry (e.g.,
setTimeout/backoff) or not rescheduling at all; update the implementation around
`#scheduleDrain` and the call to this.#drainRemoteEvents() so the success path
re-invokes `#scheduleDrain` immediately when this.#remoteEvents.length>0 while the
error path uses a controlled backoff delay before re-enqueueing scheduling.
- Around line 427-436: The code is persisting the raw incoming login object
instead of a canonicalized version; update async `#loadUserLogin` so you first
resolve the canonical login (via the connector or client normalization API —
e.g., call a canonicalize/resolveLogin helper on this.connector or client) and
then use that canonicalLogin everywhere: pass canonicalLogin into
client.connect, assign canonicalLogin.client and store canonicalLogin into
this.#userLogins and this.#networkClients, and call
this.#dataStore.setUserLogin(canonicalLogin) if present; ensure the original
incoming object is not persisted but replaced by the resolved canonicalLogin
before any map/store updates.
- Around line 1334-1345: The helpers appserviceBotUserId and
ghostUserIdFromRegistration currently interpolate options.homeserverDomain and
can produce ":undefined"; change them to derive a safe domain first (e.g. const
domain = options.homeserverDomain ?? (() => { try { return new
URL(options.registration.url).hostname } catch { return undefined } })() ??
throw new Error('missing homeserver domain')) and then use that domain variable
when building MXIDs; update appserviceBotUserId (use domain) and
ghostUserIdFromRegistration (use domain when returning the fallback MXID) so
they never interpolate undefined.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e00c2152-4f20-40c4-9a6e-ccfcaa7b577f

📥 Commits

Reviewing files that changed from the base of the PR and between 1806b86 and 040f0af.

📒 Files selected for processing (3)
  • packages/bridge/README.md
  • packages/bridge/src/bridge.ts
  • packages/bridge/src/types.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/bridge/README.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/bridge/src/types.ts
📜 Review details
🧰 Additional context used
🪛 OpenGrep (1.20.0)
packages/bridge/src/bridge.ts

[ERROR] 770-770: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)


[ERROR] 780-780: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)


[ERROR] 1348-1348: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)


[ERROR] 1350-1350: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)

@batuhan batuhan merged commit e39c4f7 into main May 7, 2026
1 of 2 checks passed
@batuhan batuhan deleted the batuhan/add-bridge-building-package branch May 7, 2026 22:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant