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>