feat(triage): 3-state delivery + admin observer + admin cache
Triage channels now compute a 3-state delivery decision per recipient
(wake / observer / skip) instead of the binary wakeup flag, and route
according to:
1. author never gets back their own message → skip
2. wake_mapping member (on-duty) → wake
3. mention (NEW: was 'skip' for triage before) → wake
4. Center-scoped admin (at most 1) → observer
5. anyone else → skip
(was 'deliver wake=false')
Skipping means the websocket emit is omitted entirely — the recipient's
openclaw plugin never sees the message and the agent's session stays
free of background noise. Observer means delivered with wakeup=false
(silent UI / no model dispatch on the plugin side).
## What this PR ships
### realtime/realtime.gateway.ts
- new `computeDelivery()` returns DeliveryDecision = 'wake'|'observer'|'skip'
- old `computeWakeup()` kept as a deprecated wrapper for callers that
still want the boolean answer (treats observer + skip as false)
- `emitMessageCreated` accepts `adminUserId?: string|null` and now
short-circuits on 'skip' (no socket emit at all)
- general kept its current behavior; custom kept its current behavior
(members not in wake_mapping become observer instead of `wake=false`)
— the user-visible bit is just that the response field is the same
`wakeup: boolean`; the explicit 'skip' is new for triage
### common/center-auth.ts
- `fetchAdminEmail()` calls GET `${center}/auth/admin-email` with the
existing x-api-key (same auth as introspect/resolve-names). Returns
`{email, userId}` or `null` on either "no admin" or any error
### common/admin-cache.service.ts (NEW)
- `AdminCacheService` — in-memory cache, 1-day TTL, lazy refresh.
`get(force=true)` bypasses TTL for cli-triggered refresh
- exposed by MessagingModule
### messaging/messaging.controller.ts
- non-rotating branch threads `adminUserId` into emitMessageCreated
### cli/admin-refresh.ts (NEW)
- `node dist/cli/admin-refresh.js` — force-refresh cache and print
before/after JSON. Use after a Center `user set-admin` so triage
delivery picks up the new admin without waiting for 24h TTL
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,15 +13,70 @@ import { introspectGuildToken } from '../common/center-auth.js';
|
||||
|
||||
type XType = 'general' | 'work' | 'report' | 'discuss' | 'triage' | 'custom' | 'dm';
|
||||
|
||||
// Wakeup for non-rotating channels only (general/report/triage/custom).
|
||||
// discuss/work go through TurnService + emitMessageTargeted, never here.
|
||||
// Precedence:
|
||||
// 1. the author never gets woken by their own message
|
||||
// 2. triage/custom: only wake users in the channel's wake_mapping
|
||||
// (mentions change nothing here)
|
||||
// 3. general: if the message has an at-list, wake only the at'd users;
|
||||
// otherwise wake everyone
|
||||
// 4. report (and anything else): wake nobody
|
||||
/**
|
||||
* Per-recipient delivery decision for a non-rotating channel message.
|
||||
*
|
||||
* • `wake` — push the event AND wake the recipient (model turn fires)
|
||||
* • `observer` — push the event with wakeup=false (silent; UI displays
|
||||
* but the openclaw plugin records-only without dispatch). Currently
|
||||
* used for the Center admin observing triage traffic
|
||||
* • `skip` — don't even emit the event to this recipient
|
||||
*
|
||||
* Wakeup-only channels (general/report/dm/custom) never return
|
||||
* 'observer'; the legacy behaviour is preserved end-to-end.
|
||||
*
|
||||
* Precedence for triage (the only place 'skip' / 'observer' fire):
|
||||
* 1. author never gets back their own message
|
||||
* 2. wake_mapping (on-duty) → wake
|
||||
* 3. mention → wake (NEW: was 'skip' before — see Fabric PR 'triage
|
||||
* mention exception')
|
||||
* 4. admin (Center-scoped, at most one) → observer
|
||||
* 5. everyone else → skip (was 'deliver, wakeup=false' before)
|
||||
*/
|
||||
export type DeliveryDecision = 'wake' | 'observer' | 'skip';
|
||||
|
||||
export interface ComputeDeliveryArgs {
|
||||
xType: XType;
|
||||
recipientUserId: string;
|
||||
authorUserId: string;
|
||||
wakeUserIds: Set<string>;
|
||||
mentionUserIds?: Set<string>;
|
||||
/** Single Center-scoped admin userId, or null. */
|
||||
adminUserId?: string | null;
|
||||
}
|
||||
|
||||
export function computeDelivery(args: ComputeDeliveryArgs): DeliveryDecision {
|
||||
const { xType, recipientUserId, authorUserId, wakeUserIds, mentionUserIds, adminUserId } = args;
|
||||
if (recipientUserId === authorUserId) return 'skip';
|
||||
|
||||
switch (xType) {
|
||||
case 'triage':
|
||||
if (wakeUserIds.has(recipientUserId)) return 'wake';
|
||||
if (mentionUserIds?.has(recipientUserId)) return 'wake';
|
||||
if (adminUserId && recipientUserId === adminUserId) return 'observer';
|
||||
return 'skip';
|
||||
case 'general':
|
||||
if (mentionUserIds && mentionUserIds.size > 0) {
|
||||
return mentionUserIds.has(recipientUserId) ? 'wake' : 'observer';
|
||||
}
|
||||
return 'wake';
|
||||
case 'custom':
|
||||
// wake_mapping decides who wakes; everyone else still sees the
|
||||
// message (observer) — preserves the legacy "deliver to all, wake
|
||||
// some" contract for custom channels.
|
||||
return wakeUserIds.has(recipientUserId) ? 'wake' : 'observer';
|
||||
case 'dm':
|
||||
return 'wake';
|
||||
default:
|
||||
// report (and anything else): deliver as observer, no wake
|
||||
return 'observer';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use computeDelivery (returns 3-state). Kept for any
|
||||
* external callers; treats 'observer' and 'skip' both as `false`.
|
||||
*/
|
||||
export function computeWakeup(args: {
|
||||
xType: XType;
|
||||
recipientUserId: string;
|
||||
@@ -29,23 +84,7 @@ export function computeWakeup(args: {
|
||||
wakeUserIds: Set<string>;
|
||||
mentionUserIds?: Set<string>;
|
||||
}): boolean {
|
||||
const { xType, recipientUserId, authorUserId, wakeUserIds, mentionUserIds } = args;
|
||||
if (recipientUserId === authorUserId) return false;
|
||||
switch (xType) {
|
||||
case 'general':
|
||||
if (mentionUserIds && mentionUserIds.size > 0) {
|
||||
return mentionUserIds.has(recipientUserId);
|
||||
}
|
||||
return true;
|
||||
case 'triage':
|
||||
case 'custom':
|
||||
return wakeUserIds.has(recipientUserId);
|
||||
case 'dm':
|
||||
// 1:1 conversation: every non-author participant is always woken.
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return computeDelivery(args) === 'wake';
|
||||
}
|
||||
|
||||
@WebSocketGateway({
|
||||
@@ -183,7 +222,10 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect
|
||||
this.server.to(`user:${userId}`).emit(event, data);
|
||||
}
|
||||
|
||||
// Emits message.created per-recipient so each carries its own `wakeup` flag.
|
||||
// Emits message.created per-recipient using the 3-state delivery
|
||||
// decision (wake / observer / skip). Skipped recipients receive
|
||||
// nothing — used by triage channels to keep non-on-duty / non-mention
|
||||
// / non-admin users completely out of the loop.
|
||||
async emitMessageCreated(
|
||||
channelId: string,
|
||||
data: Record<string, unknown>,
|
||||
@@ -192,19 +234,28 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect
|
||||
authorUserId: string;
|
||||
wakeUserIds: Set<string>;
|
||||
mentionUserIds?: Set<string>;
|
||||
/** Single Center-scoped admin userId (or null). */
|
||||
adminUserId?: string | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
const sockets = await this.server.in(`channel:${channelId}`).fetchSockets();
|
||||
for (const s of sockets) {
|
||||
const recipientUserId = typeof s.data.userId === 'string' ? s.data.userId : `anon:${s.id}`;
|
||||
const wakeup = computeWakeup({
|
||||
const decision = computeDelivery({
|
||||
xType: ctx.xType,
|
||||
recipientUserId,
|
||||
authorUserId: ctx.authorUserId,
|
||||
wakeUserIds: ctx.wakeUserIds,
|
||||
mentionUserIds: ctx.mentionUserIds,
|
||||
adminUserId: ctx.adminUserId,
|
||||
});
|
||||
if (decision === 'skip') continue;
|
||||
s.emit('message.created', {
|
||||
...data,
|
||||
channelId,
|
||||
wakeup: decision === 'wake',
|
||||
xType: ctx.xType,
|
||||
});
|
||||
s.emit('message.created', { ...data, channelId, wakeup, xType: ctx.xType });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user