diff --git a/src/index.ts b/src/index.ts index c058e66..12b1260 100644 --- a/src/index.ts +++ b/src/index.ts @@ -376,6 +376,17 @@ async function main(): Promise { }; process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); + // SIGHUP hot-reloads room bindings so a channel registered/deregistered via a + // DB write takes effect immediately — no full restart needed. Send it only to + // the main process, e.g. `kill -HUP `, so agent subprocesses are not + // signalled. + process.on('SIGHUP', () => { + try { + runtimeState.reloadRoomBindings(); + } catch (err) { + logger.error({ err }, 'Failed to reload room bindings on SIGHUP'); + } + }); // Channel callbacks (shared by all channels) const channelOpts = { diff --git a/src/runtime-state.test.ts b/src/runtime-state.test.ts index 85323ce..13adb0c 100644 --- a/src/runtime-state.test.ts +++ b/src/runtime-state.test.ts @@ -34,4 +34,32 @@ describe('createRuntimeState', () => { expect(db.deleteAllSessionsForGroup).toHaveBeenCalledWith('group-a'); expect(db.deleteSession).not.toHaveBeenCalled(); }); + + it('reloadRoomBindings re-reads bindings live without regressing cursors', () => { + db.getAllRoomBindings.mockReturnValue({ + 'dc:a': { name: 'A', folder: 'a' }, + }); + const state = createRuntimeState(); + state.loadState(); + expect(Object.keys(state.getRoomBindings())).toEqual(['dc:a']); + + // Cursor advances in memory after load. + state.setLastTimestamp('cursor-2'); + + // A new channel is registered in the DB (DB write, not live IPC). + db.getAllRoomBindings.mockReturnValue({ + 'dc:a': { name: 'A', folder: 'a' }, + 'dc:b': { name: 'B', folder: 'b' }, + }); + + state.reloadRoomBindings(); + + // Binding picked up live... + expect(Object.keys(state.getRoomBindings()).sort()).toEqual([ + 'dc:a', + 'dc:b', + ]); + // ...without resetting the message cursor (no re-processing). + expect(state.getLastTimestamp()).toBe('cursor-2'); + }); }); diff --git a/src/runtime-state.ts b/src/runtime-state.ts index 597588e..7e70e1d 100644 --- a/src/runtime-state.ts +++ b/src/runtime-state.ts @@ -20,6 +20,13 @@ import type { AgentType, RegisteredGroup } from './types.js'; export interface RuntimeState { loadState: () => void; + /** + * Re-read ONLY the room bindings from the DB (not cursors/sessions), so a + * channel registered/deregistered via a DB write takes effect live without a + * full process restart. Safe to call at runtime — it does not touch the + * message cursors, so no re-processing risk. + */ + reloadRoomBindings: () => void; saveState: () => void; clearSession: (groupFolder: string, opts?: { allRoles?: boolean }) => void; assignRoomForIpc: (jid: string, input: AssignRoomInput) => void; @@ -96,6 +103,14 @@ export function createRuntimeState(): RuntimeState { ); }; + const reloadRoomBindings = (): void => { + roomBindings = getAllRoomBindings(); + logger.info( + { groupCount: Object.keys(roomBindings).length }, + 'Room bindings reloaded', + ); + }; + const assignRoomForIpc = (jid: string, input: AssignRoomInput): void => { const assignedGroup = assignRoom(jid, input); if (!assignedGroup) { @@ -121,6 +136,7 @@ export function createRuntimeState(): RuntimeState { return { loadState, + reloadRoomBindings, saveState, clearSession, assignRoomForIpc,