Compare commits
3 Commits
b1f7467161
...
feat/triag
| Author | SHA1 | Date | |
|---|---|---|---|
| 7cb046d785 | |||
| e635faea9c | |||
| 30069377e7 |
@@ -5,6 +5,7 @@ import { Channel } from '../entities/channel.entity.js';
|
||||
import { ChannelMember } from '../entities/channel-member.entity.js';
|
||||
import { WakeMapping } from '../entities/wake-mapping.entity.js';
|
||||
import { TurnService } from './turn.service.js';
|
||||
import { RealtimeGateway } from '../realtime/realtime.gateway.js';
|
||||
|
||||
const X_TYPES = ['general', 'work', 'report', 'discuss', 'triage', 'custom', 'dm'] as const;
|
||||
type XType = (typeof X_TYPES)[number];
|
||||
@@ -35,8 +36,34 @@ export class ChannelsService {
|
||||
@InjectRepository(WakeMapping)
|
||||
private readonly wakeRepo: Repository<WakeMapping>,
|
||||
private readonly turnService: TurnService,
|
||||
// RealtimeGateway is provided by the global RealtimeModule. Used to
|
||||
// push channel.joined / channel.left so connected clients (e.g. the
|
||||
// OpenClaw fabric plugin) can sub/unsub socket.io rooms immediately
|
||||
// instead of waiting for the polling fallback.
|
||||
private readonly realtime: RealtimeGateway,
|
||||
) {}
|
||||
|
||||
// Push a channel membership change to each affected user's socket-room.
|
||||
// Best-effort: offline users see the new state on their next connect
|
||||
// (the inbound runs an initial channel-list fetch on connect).
|
||||
private notifyMembership(
|
||||
kind: 'joined' | 'left',
|
||||
channelId: string,
|
||||
userIds: string[] | Set<string>,
|
||||
extra: Record<string, unknown> = {},
|
||||
): void {
|
||||
const ids = userIds instanceof Set ? [...userIds] : userIds;
|
||||
const payload = {
|
||||
channelId,
|
||||
...extra,
|
||||
occurredAt: new Date().toISOString(),
|
||||
};
|
||||
for (const u of ids) {
|
||||
if (!u) continue;
|
||||
this.realtime.emitToUser(u, `channel.${kind}`, { ...payload, userId: u });
|
||||
}
|
||||
}
|
||||
|
||||
// Channels visible to a user within a guild:
|
||||
// - every public channel of the guild (incl. ones created before the user
|
||||
// joined the guild), OR
|
||||
@@ -93,6 +120,7 @@ export class ChannelsService {
|
||||
if (channel.xType === 'discuss' || channel.xType === 'work') {
|
||||
await this.turnService.onMemberAdded(channelId, userId);
|
||||
}
|
||||
this.notifyMembership('joined', channelId, [userId], { xType: channel.xType });
|
||||
}
|
||||
return { status: 'ok', channelId, userId, member: true };
|
||||
}
|
||||
@@ -102,11 +130,14 @@ export class ChannelsService {
|
||||
if (!channel) throw new NotFoundException('channel not found');
|
||||
|
||||
// remove every channel-scoped row that references this user
|
||||
await this.memberRepo.delete({ channelId, userId });
|
||||
const deleted = await this.memberRepo.delete({ channelId, userId });
|
||||
await this.wakeRepo.delete({ channelId, userId });
|
||||
if (channel.xType === 'discuss' || channel.xType === 'work') {
|
||||
await this.turnService.onMemberRemoved(channelId, userId);
|
||||
}
|
||||
if ((deleted.affected ?? 0) > 0) {
|
||||
this.notifyMembership('left', channelId, [userId], { xType: channel.xType });
|
||||
}
|
||||
return { status: 'ok', channelId, userId, member: false };
|
||||
}
|
||||
|
||||
@@ -160,6 +191,12 @@ export class ChannelsService {
|
||||
[...memberIds].map((userId) => this.memberRepo.create({ channelId: channel.id, userId })),
|
||||
);
|
||||
|
||||
// Push channel.joined to every seeded member (creator + invitees +
|
||||
// triage on-duty) so their connected sockets sub the new room
|
||||
// immediately. Skips offline users — next connect's channel-list
|
||||
// fetch covers them.
|
||||
this.notifyMembership('joined', channel.id, memberIds, { xType });
|
||||
|
||||
// wake_mapping: triage -> the on-duty user; custom -> each listener
|
||||
const wakeUserIds = new Set<string>();
|
||||
if (xType === 'triage') wakeUserIds.add(onDuty);
|
||||
|
||||
39
src/cli/admin-refresh.ts
Normal file
39
src/cli/admin-refresh.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// Operator convenience: force-refresh the in-memory Center admin cache
|
||||
// without waiting for the 1-day TTL. Used after `center user set-admin`
|
||||
// to make new admin visible immediately to triage delivery.
|
||||
//
|
||||
// Usage (inside the deployed container):
|
||||
// docker exec fabric-backend-guild node dist/cli/admin-refresh.js
|
||||
//
|
||||
// Prints the (possibly null) result as JSON. Exit 0 always — a "no
|
||||
// admin" outcome is a valid state, not an error.
|
||||
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from '../app.module.js';
|
||||
import { AdminCacheService } from '../common/admin-cache.service.js';
|
||||
|
||||
async function main() {
|
||||
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error', 'warn'] });
|
||||
try {
|
||||
const cache = app.get(AdminCacheService);
|
||||
const before = cache.snapshot();
|
||||
const after = await cache.get(true);
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
before,
|
||||
after,
|
||||
changed: JSON.stringify(before) !== JSON.stringify(after),
|
||||
}) + '\n',
|
||||
);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
|
||||
void main().catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : 'unknown error';
|
||||
process.stderr.write(JSON.stringify({ ok: false, error: message }) + '\n');
|
||||
process.exit(1);
|
||||
});
|
||||
73
src/common/admin-cache.service.ts
Normal file
73
src/common/admin-cache.service.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Center-scoped admin cache.
|
||||
*
|
||||
* Holds the at-most-one admin user (email + userId) fetched from Center.
|
||||
* Used to decide who to deliver triage messages to as a silent observer
|
||||
* (wake=false), regardless of on-duty / mention status.
|
||||
*
|
||||
* Refresh policy (per spec, 2026-05-22):
|
||||
* • TTL = 1 day. Center admin changes are rare; agents tolerate a
|
||||
* day's stale cache without surprises
|
||||
* • on first lookup the cache lazy-fetches
|
||||
* • cli `admin refresh` forces an out-of-band refresh without waiting
|
||||
* for TTL expiry
|
||||
*
|
||||
* Failure mode: a Center fetch error is treated identically to "no
|
||||
* admin" — guild keeps operating without an observer. The cache holds
|
||||
* the failed-fetch decision for the same TTL so we don't hammer Center.
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { fetchAdminEmail } from './center-auth.js';
|
||||
|
||||
const ADMIN_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface CachedAdmin {
|
||||
email: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminCacheService {
|
||||
private readonly logger = new Logger(AdminCacheService.name);
|
||||
private cached: CachedAdmin | null = null;
|
||||
private cachedAt = 0;
|
||||
private inflight: Promise<CachedAdmin | null> | null = null;
|
||||
|
||||
/**
|
||||
* Return the cached admin, fetching from Center if the cache is empty
|
||||
* or older than the TTL. Returns null if no admin is set.
|
||||
*
|
||||
* `force=true` bypasses the cache and refreshes immediately — used by
|
||||
* the cli refresh command.
|
||||
*/
|
||||
async get(force = false): Promise<CachedAdmin | null> {
|
||||
const fresh = Date.now() - this.cachedAt < ADMIN_CACHE_TTL_MS;
|
||||
if (!force && this.cachedAt > 0 && fresh) {
|
||||
return this.cached;
|
||||
}
|
||||
if (this.inflight) return this.inflight;
|
||||
|
||||
this.inflight = (async () => {
|
||||
try {
|
||||
const result = await fetchAdminEmail();
|
||||
this.cached = result;
|
||||
this.cachedAt = Date.now();
|
||||
this.logger.log(
|
||||
`admin cache refreshed: ${result ? `${result.email} (${result.userId})` : 'no admin set'}`,
|
||||
);
|
||||
return result;
|
||||
} finally {
|
||||
this.inflight = null;
|
||||
}
|
||||
})();
|
||||
return this.inflight;
|
||||
}
|
||||
|
||||
/** Snapshot of the cached admin (no fetch). Returns null if not yet
|
||||
* populated. Used by the hot delivery path which doesn't want to
|
||||
* block on a Center round-trip. */
|
||||
snapshot(): CachedAdmin | null {
|
||||
return this.cached;
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,31 @@ export async function introspectGuildToken(token: string): Promise<{ active: boo
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>> {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { ChannelMember } from '../entities/channel-member.entity.js';
|
||||
import { Message } from '../entities/message.entity.js';
|
||||
import { IdempotencyRecord } from '../entities/idempotency-record.entity.js';
|
||||
import { WakeMapping } from '../entities/wake-mapping.entity.js';
|
||||
import { AdminCacheService } from '../common/admin-cache.service.js';
|
||||
import { parseSlashCommand } from '../channels/slash-commands.js';
|
||||
import { parseMentions, extractNameMentions, replaceNameMentions } from '../channels/mentions.js';
|
||||
import { resolveUserNames } from '../common/center-auth.js';
|
||||
@@ -50,6 +51,7 @@ export class MessagingController {
|
||||
private readonly turn: TurnService,
|
||||
private readonly events: EventsService,
|
||||
private readonly realtime: RealtimeGateway,
|
||||
private readonly adminCache: AdminCacheService,
|
||||
) {}
|
||||
|
||||
private async getIdempotentResponse(
|
||||
@@ -225,16 +227,19 @@ export class MessagingController {
|
||||
const decision = await this.turn.onNormalMessage(channelId, authorUserId, mentionIds);
|
||||
await this.realtime.emitMessageTargeted(channelId, responseBody, decision.wakeupUserId);
|
||||
} else {
|
||||
// general/report/triage/custom: wakeup from x_type + wake_mapping;
|
||||
// general also honors the message's at-list
|
||||
// general/report/triage/custom: 3-state delivery
|
||||
// (wake / observer / skip) — see realtime.gateway.computeDelivery.
|
||||
// Center-scoped admin (cached, 1d TTL) gets `observer` on triage.
|
||||
const wakeRows = await this.wakeRepo.find({ where: { channelId } });
|
||||
const wakeUserIds = new Set(wakeRows.map((w) => w.userId));
|
||||
const mentionUserIds = new Set(mentionIds.filter((id) => id !== authorUserId));
|
||||
const admin = await this.adminCache.get();
|
||||
await this.realtime.emitMessageCreated(channelId, responseBody, {
|
||||
xType,
|
||||
authorUserId,
|
||||
wakeUserIds,
|
||||
mentionUserIds,
|
||||
adminUserId: admin?.userId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,12 @@ import { ChannelMember } from '../entities/channel-member.entity.js';
|
||||
import { Message } from '../entities/message.entity.js';
|
||||
import { IdempotencyRecord } from '../entities/idempotency-record.entity.js';
|
||||
import { WakeMapping } from '../entities/wake-mapping.entity.js';
|
||||
import { AdminCacheService } from '../common/admin-cache.service.js';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Channel, ChannelMember, Message, IdempotencyRecord, WakeMapping])],
|
||||
controllers: [MessagingController],
|
||||
providers: [AdminCacheService],
|
||||
exports: [AdminCacheService],
|
||||
})
|
||||
export class MessagingModule {}
|
||||
|
||||
@@ -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({
|
||||
@@ -96,6 +135,10 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect
|
||||
const userId = result.user.id || this.userIdFromClient(client);
|
||||
client.data.userId = userId;
|
||||
this.onlineUsers.add(userId);
|
||||
// Per-user room: lets server code emit user-scoped events (e.g.
|
||||
// channel.joined when membership changes) without bookkeeping a
|
||||
// userId→sockets map. All of this user's sockets receive the event.
|
||||
client.join(`user:${userId}`);
|
||||
this.server.emit('presence.online', {
|
||||
userId,
|
||||
onlineCount: this.onlineUsers.size,
|
||||
@@ -171,7 +214,18 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect
|
||||
this.server.to(`channel:${channelId}`).emit(event, data);
|
||||
}
|
||||
|
||||
// Emits message.created per-recipient so each carries its own `wakeup` flag.
|
||||
// Emit a user-scoped event to all sockets currently connected for `userId`
|
||||
// (via the `user:<userId>` room joined in handleConnection). No-op for
|
||||
// offline users — the next connect's initial channel-list fetch covers it.
|
||||
emitToUser(userId: string, event: string, data: Record<string, unknown>): void {
|
||||
if (!userId) return;
|
||||
this.server.to(`user:${userId}`).emit(event, data);
|
||||
}
|
||||
|
||||
// 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>,
|
||||
@@ -180,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