feat(realtime): push channel.joined/left events to user-scoped rooms #1

Merged
hzhang merged 1 commits from feat/push-channel-membership-events into main 2026-05-21 07:12:51 +00:00
Contributor

Backend half of push-based membership sync (companion to OpenClaw fabric plugin). Before: clients learned about new channel membership only by re-polling /api/channels?guildId=.... Now the server emits realtime events on every membership change.

Changes

  • realtime.gateway.ts:
    • handleConnection joins each socket into a user:<userId> room
    • New emitToUser(userId, event, data) helper that emits into that room (no-op for offline users)
  • channels.service.ts:
    • Inject RealtimeGateway (RealtimeModule is @Global, no module plumbing)
    • Private notifyMembership(kind, channelId, userIds, extra) helper emits channel.<kind> payload {channelId, userId, xType, occurredAt}
    • create(): emit channel.joined to every seeded member (creator + invitees + triage on-duty)
    • joinChannel(): emit on actual insert only (idempotent)
    • leaveChannel(): emit on actual delete only

Verified in sim

T0 (channel create with member) 07:11:31.569 → T1 (plugin push recv) 07:11:31.791 = 222ms.
T0 (leave channel) 07:12:08.323 → T1 (plugin push recv) 07:12:08.788 = 465ms.
vs prior polling worst-case 60s.

🤖 Generated with Claude Code

Backend half of push-based membership sync (companion to OpenClaw fabric plugin). Before: clients learned about new channel membership only by re-polling `/api/channels?guildId=...`. Now the server emits realtime events on every membership change. ## Changes - `realtime.gateway.ts`: - `handleConnection` joins each socket into a `user:<userId>` room - New `emitToUser(userId, event, data)` helper that emits into that room (no-op for offline users) - `channels.service.ts`: - Inject `RealtimeGateway` (RealtimeModule is `@Global`, no module plumbing) - Private `notifyMembership(kind, channelId, userIds, extra)` helper emits `channel.<kind>` payload `{channelId, userId, xType, occurredAt}` - `create()`: emit `channel.joined` to every seeded member (creator + invitees + triage on-duty) - `joinChannel()`: emit on actual insert only (idempotent) - `leaveChannel()`: emit on actual delete only ## Verified in sim T0 (channel create with member) 07:11:31.569 → T1 (plugin push recv) 07:11:31.791 = **222ms**. T0 (leave channel) 07:12:08.323 → T1 (plugin push recv) 07:12:08.788 = **465ms**. vs prior polling worst-case 60s. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
hzhang added 1 commit 2026-05-21 07:12:45 +00:00
Backend half of the plugin push-based channel sync (companion to
nav/Fabric.OpenclawPlugin#1 follow-up). Before this, the OpenClaw
fabric inbound had to poll `/api/channels?guildId=...` every 60s to
discover newly-joined channels (any DM another user just dragged the
agent into). Now the server tells the agent's socket directly so
sub/unsub is realtime.

Changes:
- realtime.gateway.ts:
  * handleConnection joins the socket into a `user:<userId>` room.
    All of a user's connected sockets now share that room.
  * New `emitToUser(userId, event, data)` helper that emits into
    that room. No-op for offline users (next connect resyncs via the
    plugin's initial channel-list fetch).
- channels.service.ts:
  * Inject RealtimeGateway (RealtimeModule is @Global, no module
    plumbing needed).
  * Private `notifyMembership(kind, channelId, userIds, extra)`
    helper that emits `channel.<kind>` (joined|left) with payload
    {channelId, userId, xType, occurredAt}.
  * create(): emit channel.joined to every seeded member (creator +
    explicit memberUserIds + triage on-duty).
  * joinChannel(): emit channel.joined to userId (only if the row was
    actually inserted, idempotent on existing membership).
  * leaveChannel(): emit channel.left to userId iff a row was
    actually deleted.

Event shape:
  {
    channelId: string,
    userId: string,
    xType?: string,
    occurredAt: ISO string,
  }

Client-side contract (fabric plugin):
  socket.on('channel.joined', m => socket.emit('join_channel', {channelId: m.channelId}))
  socket.on('channel.left',   m => socket.emit('leave_channel', {channelId: m.channelId}))

The plugin keeps its 60s polling resync as a safety net for missed
events (transient socket drops between emit and reconnect, partial
failures, etc).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
hzhang merged commit 5b835e0871 into main 2026-05-21 07:12:51 +00:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: nav/Fabric.Backend.Guild#1