feat(guild): wake_mapping, per-recipient wakeup, discuss/work turn engine, channel join/leave

- wake_mapping table; triage onDuty (auto-added member) / custom listeners
- per-recipient wakeup metadata on message.created (one message-id; added
  only at push). Rules: author=false; triage/custom=wake_mapping only;
  general=all; report=none
- discuss/work rotation: channel_turn_state (order/currentSpeaker/round
  events/cross-round no-reply streak); null activation, queue-jump,
  /no-reply pass, all-/no-reply pause, end-of-round shuffle (trailing
  no-reply run to tail, head shuffled, first != last normal speaker)
- slash-command registry (/no-reply, /force-proceed); registered commands
  intercepted and never delivered; guild-authored /ack persisted
- POST /channels/:id/join|leave; leave cleans channel_members, wake_mapping
  and turn-state order

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-15 14:51:09 +01:00
parent 605d3ac092
commit 6b993522cf
14 changed files with 657 additions and 34 deletions

View File

@@ -11,6 +11,34 @@ import { Logger } from '@nestjs/common';
import { Server, Socket } from 'socket.io';
import { introspectGuildToken } from '../common/center-auth';
type XType = 'general' | 'work' | 'report' | 'discuss' | 'triage' | 'custom';
// 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
// 3. general: wake everyone
// 4. report (and anything else): wake nobody
export function computeWakeup(args: {
xType: XType;
recipientUserId: string;
authorUserId: string;
wakeUserIds: Set<string>;
}): boolean {
const { xType, recipientUserId, authorUserId, wakeUserIds } = args;
if (recipientUserId === authorUserId) return false;
switch (xType) {
case 'general':
return true;
case 'triage':
case 'custom':
return wakeUserIds.has(recipientUserId);
default:
return false;
}
}
@WebSocketGateway({
namespace: '/realtime',
cors: {
@@ -133,4 +161,38 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect
emitChannelEvent(channelId: string, event: string, data: Record<string, unknown>): void {
this.server.to(`channel:${channelId}`).emit(event, data);
}
// Emits message.created per-recipient so each carries its own `wakeup` flag.
async emitMessageCreated(
channelId: string,
data: Record<string, unknown>,
ctx: { xType: XType; authorUserId: string; wakeUserIds: Set<string> },
): 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({
xType: ctx.xType,
recipientUserId,
authorUserId: ctx.authorUserId,
wakeUserIds: ctx.wakeUserIds,
});
s.emit('message.created', { ...data, wakeup });
}
}
// discuss/work + /ack: exactly one recipient (the new current speaker) gets
// wakeup=true; everyone else false. One message-id; metadata at push only.
async emitMessageTargeted(
channelId: string,
data: Record<string, unknown>,
wakeupUserId: 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 = wakeupUserId !== null && recipientUserId === wakeupUserId;
s.emit('message.created', { ...data, wakeup });
}
}
}