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>
75 lines
2.8 KiB
TypeScript
75 lines
2.8 KiB
TypeScript
export async function introspectGuildToken(token: string): Promise<{ active: boolean; user?: { id: string; email?: string } }> {
|
|
const centerBaseUrl = process.env.FABRIC_BACKEND_GUILD_CENTER_BASE_URL;
|
|
const guildNodeId = process.env.FABRIC_BACKEND_GUILD_NODE_ID;
|
|
const centerApiKey = process.env.FABRIC_BACKEND_GUILD_CENTER_API_KEY;
|
|
|
|
if (!centerBaseUrl || !guildNodeId || !centerApiKey) {
|
|
return { active: false };
|
|
}
|
|
|
|
const res = await fetch(`${centerBaseUrl}/api/auth/introspect`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'x-api-key': centerApiKey,
|
|
},
|
|
body: JSON.stringify({ token, guildNodeId }),
|
|
});
|
|
|
|
if (!res.ok) return { active: false };
|
|
const data = (await res.json()) as { active?: boolean; user?: { id: string; email?: string } };
|
|
if (!data.active) return { active: false };
|
|
|
|
return {
|
|
active: true,
|
|
user: data.user,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fetch the single Center-scoped admin user (if any).
|
|
* Same x-api-key auth as introspect / resolve-names.
|
|
* Returns `null` when no admin is set OR the request fails (treated
|
|
* identically — the guild simply falls back to "no admin observer").
|
|
*/
|
|
export async function fetchAdminEmail(): Promise<{ email: string; userId: string } | null> {
|
|
const centerBaseUrl = process.env.FABRIC_BACKEND_GUILD_CENTER_BASE_URL;
|
|
const centerApiKey = process.env.FABRIC_BACKEND_GUILD_CENTER_API_KEY;
|
|
if (!centerBaseUrl || !centerApiKey) return null;
|
|
|
|
try {
|
|
const res = await fetch(`${centerBaseUrl}/api/auth/admin-email`, {
|
|
method: 'GET',
|
|
headers: { 'x-api-key': centerApiKey },
|
|
});
|
|
if (!res.ok) return null;
|
|
const data = (await res.json()) as { email?: string; userId?: string } | null;
|
|
if (!data || !data.email || !data.userId) return null;
|
|
return { email: data.email, userId: data.userId };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Resolve <@user.name:NAME> names to userIds within this guild node via
|
|
// Center. Unresolved names are simply absent from the returned map.
|
|
export async function resolveUserNames(names: string[]): Promise<Record<string, string>> {
|
|
const centerBaseUrl = process.env.FABRIC_BACKEND_GUILD_CENTER_BASE_URL;
|
|
const guildNodeId = process.env.FABRIC_BACKEND_GUILD_NODE_ID;
|
|
const centerApiKey = process.env.FABRIC_BACKEND_GUILD_CENTER_API_KEY;
|
|
if (!centerBaseUrl || !guildNodeId || !centerApiKey || !names.length) return {};
|
|
|
|
try {
|
|
const res = await fetch(`${centerBaseUrl}/api/auth/resolve-names`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json', 'x-api-key': centerApiKey },
|
|
body: JSON.stringify({ guildNodeId, names }),
|
|
});
|
|
if (!res.ok) return {};
|
|
const data = (await res.json()) as { resolved?: Record<string, string> };
|
|
return data.resolved ?? {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|