5 Commits

Author SHA1 Message Date
ca20df7618 refactor(guild): drop system-key bypass + announce-only-system limit
Pairs with Dialectic.Backend@5cf4302 which removes the backend-driven
broadcaster that was the only consumer of the x-fabric-system-key
header path. Backend cleanup is complete on the consumer side; this
removes the producer-side surface.

Removed:
  - ApiKeyGuard: x-fabric-system-key bypass branch (sysExpected /
    sysProvided / req.isSystem flag) — only Bearer flow remains.
  - messaging.controller.create(): the entire 'if (req.isSystem)'
    branch including the SYSTEM_USER_ID='00000000-...-0000' sentinel
    persistence path.
  - messaging.controller.create(): the 'if (xType === announce) throw
    announce_system_only' gate. Announce channels are now ordinary
    channels — any participant can POST. Use case: agents post one-off
    recruitment broadcasts via fabric-send-message (e.g. dialectic
    'come participate in topic X' messages).
  - cli/gen-system-api-key.ts: deleted (was the generator for the env
    that's no longer read).

Kept:
  - channel.purpose field + PATCH /api/channels/:id (member auth for
    setting purpose — agents use this to label channels for
    fabric-channel-list discoverability).
  - cli/print-commands-sync-key.ts (separate key, separate lifecycle).
  - GuildRole.isSystem flag (unrelated — system-role permission gate).
2026-05-23 23:49:47 +01:00
cb7b3bb5fe feat(channel-discovery): add purpose column + PATCH /api/channels/:id
Adds a free-form 'purpose' text field on Channel so agents (or anyone
creating a channel via API) can describe what the channel is for —
'debate broadcasts', 'security alerts', etc. — and other agents can
later find the right channel by intent rather than channel id.

Wire:
  - Channel.purpose (text, nullable; TypeORM synchronize auto-adds)
  - POST /api/channels accepts optional 'purpose' in body
  - GET /api/channels returns purpose on every row (already returns the
    full entity via {...c})
  - PATCH /api/channels/:id { purpose } — member-or-public auth (mirrors
    the close() rule). Today only 'purpose' is patchable; other fields
    would get their own typed branch.

Frontend create form continues to omit the field — purpose stays optional.
This pairs with Fabric.OpenclawPlugin's fabric-channel-set-purpose tool +
fabric-channel-list returning purpose, so agent workflows can say 'find
an announce channel about X' instead of pinning a UUID.
2026-05-23 19:22:00 +01:00
985b06a886 feat(guild): system-key bypass + announce-only system path + gen CLI
Three coupled changes that let Dialectic.Backend (and future system
broadcasters) post to announce channels without needing a Fabric user
bearer.

1. ApiKeyGuard: when x-fabric-system-key matches
   FABRIC_BACKEND_GUILD_SYSTEM_API_KEY env, skip the Bearer requirement
   and set req.isSystem=true. Pre-Bearer system bypass; no per-user
   session token needed. Empty env -> bypass disabled (closed by default).

2. messaging.controller POST /channels/:id/messages: when req.isSystem,
   skip assertParticipant + fetch channel directly. Enforce xType=announce
   (system key only writes to announce channels - never to regular chats).
   Persist with sentinel author 00000000-0000-0000-0000-000000000000.
   Emit message.created + realtime.emitMessageCreated with xType=announce
   so the Phase 1 busy-discard logic kicks in for recipients.

3. New cli: src/cli/gen-system-api-key.ts. Generates a random 32-byte
   hex key (same shape as agent + admin keys) and prints it. Does NOT
   store - operator pastes into compose env and restarts guild. Pattern
   mirrors the existing print-commands-sync-key.ts.

Removes the need for a FABRIC_BOT_BEARER_TOKEN concept entirely - the
system key alone is sufficient. announce-channel posts by regular
authenticated users (who happen to know channel id but no system key)
are now 403 announce_system_only.
2026-05-23 17:49:53 +01:00
80ee9082f3 feat(guild): announce channel type + agent-presence + busy-discard
Phase 1 of DIALECTIC-V2 — adds Fabric infrastructure for
system-broadcast channels with HF-status-aware delivery filtering.

New channel x_type 'announce':
- channels.entity.ts + channels.service.ts + realtime.gateway.ts
  enum + union extended.
- computeDelivery() adds an 'announce' case: recipient with
  presence='busy' → 'skip' (discarded silently); other presences →
  'observer' (delivered, no wake). System-broadcast semantics —
  agents proactively check their announce inbox when they're ready,
  not interrupted out of band.
- messaging.controller POST guard: announce-type channels reject
  posts that don't present x-fabric-system-key header matching
  FABRIC_BACKEND_GUILD_SYSTEM_API_KEY env. Empty env = no system
  caller is valid (closed-by-default).

New entity + module agent_presences:
- agent-presence.entity.ts: per-user (userId PK) status enum
  (idle/on_call/busy/exhausted/offline/unknown), source tag, updatedAt
- agent-presence.service.ts: getStatus/getStatusMap (bulk for
  delivery-time fanout) + setStatus (upsert)
- agent-presence.controller.ts: GET + PUT /agents/:userId/presence
- agent-presence.module.ts: TypeORM forFeature + wired into AppModule
- buildTypeOrmConfig() entities list extended

RealtimeGateway wiring:
- New optional  field on the gateway (typed loosely to avoid
  circular import). RealtimeModule.onModuleInit() assigns from the
  injected AgentPresenceService — degrades gracefully (no busy-discard,
  treat all as 'unknown') if presence wiring is ever removed.
- emitMessageCreated pre-loads presence per fanout only when xType is
  'announce' (other xTypes bypass the lookup entirely).

Note: actual presence data writes come from Fabric.OpenclawPlugin's
presence-sync loop (separate commit on that submodule); without it,
all rows are 'unknown' and announce delivery falls through to the
default observer behavior (no busy filtering). System-only POST gate
is independent and works immediately.

See /home/hzhang/arch/DIALECTIC-V2-DESIGN.md sections 7 + 10 Phase 1.
2026-05-23 11:31:47 +01:00
801b562999 Merge pull request 'feat(triage): 3-state delivery + admin observer + admin cache' (#2) from feat/triage-3state-delivery into main 2026-05-22 21:59:19 +00:00
12 changed files with 280 additions and 9 deletions

View File

@@ -0,0 +1,42 @@
import { BadRequestException, Body, Controller, Get, Param, Put } from '@nestjs/common';
import { AgentPresenceService, PresenceStatus } from './agent-presence.service.js';
const VALID: PresenceStatus[] = ['idle', 'on_call', 'busy', 'exhausted', 'offline', 'unknown'];
interface PutBody {
status?: string;
source?: string;
}
@Controller('agents/:userId/presence')
export class AgentPresenceController {
constructor(private readonly svc: AgentPresenceService) {}
/**
* Read a user's current presence cache row.
* Auth: ApiKeyGuard (global). Any introspected center user can read.
*/
@Get()
async get(@Param('userId') userId: string): Promise<{ userId: string; status: PresenceStatus }> {
const status = await this.svc.getStatus(userId);
return { userId, status };
}
/**
* Push a presence update. Called by Fabric.OpenclawPlugin's
* `presence-sync` loop on each delta. Auth: ApiKeyGuard (global) +
* the plugin uses its center-introspected api key.
*
* `source` is a debug tag describing who pushed (e.g. 'hf-plugin',
* 'manual'). Stored verbatim for trail.
*/
@Put()
async put(@Param('userId') userId: string, @Body() body: PutBody): Promise<{ userId: string; status: PresenceStatus }> {
if (!body?.status || !VALID.includes(body.status as PresenceStatus)) {
throw new BadRequestException(`status must be one of ${VALID.join('|')}`);
}
const source = (body.source ?? 'unknown').slice(0, 64);
const row = await this.svc.setStatus(userId, body.status as PresenceStatus, source);
return { userId: row.userId, status: row.status };
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AgentPresence } from '../entities/agent-presence.entity.js';
import { AgentPresenceController } from './agent-presence.controller.js';
import { AgentPresenceService } from './agent-presence.service.js';
@Module({
imports: [TypeOrmModule.forFeature([AgentPresence])],
controllers: [AgentPresenceController],
providers: [AgentPresenceService],
exports: [AgentPresenceService],
})
export class AgentPresenceModule {}

View File

@@ -0,0 +1,53 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AgentPresence } from '../entities/agent-presence.entity.js';
export type PresenceStatus = 'idle' | 'on_call' | 'busy' | 'exhausted' | 'offline' | 'unknown';
@Injectable()
export class AgentPresenceService {
constructor(
@InjectRepository(AgentPresence)
private readonly repo: Repository<AgentPresence>,
) {}
/**
* Get a user's current presence. Returns 'unknown' if no row.
* Used by `RealtimeGateway` per-recipient when xType === 'announce'.
*/
async getStatus(userId: string): Promise<PresenceStatus> {
if (!userId) return 'unknown';
const row = await this.repo.findOne({ where: { userId } });
return row?.status ?? 'unknown';
}
/** Bulk variant for delivery-time lookups across many recipients in one trip. */
async getStatusMap(userIds: string[]): Promise<Map<string, PresenceStatus>> {
const out = new Map<string, PresenceStatus>();
for (const id of userIds) out.set(id, 'unknown');
if (userIds.length === 0) return out;
const rows = await this.repo
.createQueryBuilder('p')
.where('p.userId IN (:...ids)', { ids: userIds })
.getMany();
for (const r of rows) out.set(r.userId, r.status);
return out;
}
/**
* Upsert a user's presence. Source is a free-text tag for debugging
* (e.g. "hf-plugin", "manual", "test"). PUT /agents/:id/presence
* calls this; the plugin pushes only on diff so writes are sparse.
*/
async setStatus(userId: string, status: PresenceStatus, source: string): Promise<AgentPresence> {
const existing = await this.repo.findOne({ where: { userId } });
if (existing) {
existing.status = status;
existing.source = source;
return this.repo.save(existing);
}
const row = this.repo.create({ userId, status, source });
return this.repo.save(row);
}
}

View File

@@ -16,6 +16,7 @@ import { MembersModule } from './members/members.module.js';
import { FilesModule } from './files/files.module.js';
import { CanvasModule } from './canvas/canvas.module.js';
import { CommandsModule } from './commands/commands.module.js';
import { AgentPresenceModule } from './agents/agent-presence.module.js';
@Module({
imports: [
@@ -30,6 +31,7 @@ import { CommandsModule } from './commands/commands.module.js';
FilesModule,
CanvasModule,
CommandsModule,
AgentPresenceModule,
],
controllers: [HealthController, MetricsController],
providers: [

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Post, Query, Req, UnauthorizedException } from '@nestjs/common';
import { BadRequestException, Body, Controller, Get, Param, Patch, Post, Query, Req, UnauthorizedException } from '@nestjs/common';
import { ChannelsService } from './channels.service.js';
// ApiKeyGuard attaches the introspected Center user id onto the request.
@@ -32,11 +32,33 @@ export class ChannelsController {
bypassUserIds: Array.isArray(body.bypassUserIds)
? (body.bypassUserIds as string[])
: [],
purpose: body.purpose as string | undefined,
},
userId,
);
}
// Patch a channel's free-form purpose. Body: { purpose: string }. Pass
// empty string to clear. Auth: channel member (or anyone for public
// channels, mirroring close()). Frontend doesn't call this today —
// intended for agent-side use (fabric-channel-set-purpose tool).
@Patch(':id')
patch(
@Req() req: AuthedRequest,
@Param('id') channelId: string,
@Body() body: Record<string, unknown>,
) {
const userId = req.userId ?? '';
if (!userId) throw new UnauthorizedException('missing user');
// Only `purpose` is patchable today. Future patchable fields would
// get their own typed branch; we explicitly NOT allow {} no-op patches
// because that signals a caller bug.
if (typeof body.purpose !== 'string') {
throw new BadRequestException('purpose (string) is required');
}
return this.channelsService.updatePurpose(channelId, userId, body.purpose);
}
// Move an order member into the bypass list (discuss/work only).
@Post(':id/bypass')
bypass(

View File

@@ -7,7 +7,7 @@ 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;
const X_TYPES = ['general', 'work', 'report', 'discuss', 'triage', 'custom', 'dm', 'announce'] as const;
type XType = (typeof X_TYPES)[number];
type CreateChannelInput = {
@@ -24,6 +24,10 @@ type CreateChannelInput = {
// discuss/work only: members excluded from rotation (no wakeup unless
// @-mentioned). order and bypass partition the members disjointly.
bypassUserIds?: string[];
// Free-form description of what this channel is for. Optional; agents
// typically fill it when creating, members can later edit via
// PATCH /api/channels/:id.
purpose?: string;
};
@Injectable()
@@ -166,6 +170,9 @@ export class ChannelsService {
// allowed (create() always makes a fresh one, no dedup).
const isPublic = xType === 'dm' ? false : Boolean(input.isPublic);
const purposeRaw = String(input.purpose ?? '').trim();
const purpose = purposeRaw === '' ? null : purposeRaw;
const channel = await this.channelRepo.save(
this.channelRepo.create({
guildId,
@@ -174,6 +181,7 @@ export class ChannelsService {
kind: input.kind === 'announcement' ? 'announcement' : 'text',
isPrivate: !isPublic,
isPublic,
purpose,
lastSeq: 0,
}),
);
@@ -219,6 +227,27 @@ export class ChannelsService {
return channel;
}
// Update a channel's free-form purpose. Any channel member may do this
// (or any guild user if the channel is public, mirroring closeChannel's
// member-or-public rule). Pass an empty string to clear.
async updatePurpose(channelId: string, actorUserId: string, purpose: string) {
const channel = await this.channelRepo.findOne({ where: { id: channelId } });
if (!channel) throw new NotFoundException('channel not found');
const member = await this.memberRepo.findOne({ where: { channelId, userId: actorUserId } });
if (!member && !channel.isPublic) {
throw new ForbiddenException('not a channel member');
}
const trimmed = String(purpose ?? '').trim();
channel.purpose = trimmed === '' ? null : trimmed;
const saved = await this.channelRepo.save(channel);
return {
id: saved.id,
name: saved.name,
xType: saved.xType,
purpose: saved.purpose,
};
}
// Move an order member into the bypass list (discuss/work only).
// Any channel member may do this.
async moveToBypass(channelId: string, actorUserId: string, targetUserId: string) {

View File

@@ -14,6 +14,7 @@ import { IdempotencyRecord } from './entities/idempotency-record.entity.js';
import { StoredFile } from './entities/stored-file.entity.js';
import { ChannelCanvas } from './entities/channel-canvas.entity.js';
import { GuildCommand } from './entities/guild-command.entity.js';
import { AgentPresence } from './entities/agent-presence.entity.js';
export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
type: 'mysql',
@@ -38,6 +39,7 @@ export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
StoredFile,
ChannelCanvas,
GuildCommand,
AgentPresence,
],
synchronize: (process.env.FABRIC_BACKEND_GUILD_DB_SYNC ?? 'true') === 'true',
logging: (process.env.FABRIC_BACKEND_GUILD_DB_LOGGING ?? 'false') === 'true',

View File

@@ -0,0 +1,35 @@
import { Column, Entity, PrimaryColumn, UpdateDateColumn } from 'typeorm';
/**
* Per-user (typically agent) presence cache.
*
* Populated by Fabric.OpenclawPlugin's presence-sync loop: every ~30s
* it reads each connected agent's HF status from the cross-plugin
* `globalThis.__hfAgentStatus.get(agentId)` (exposed by
* HarborForge.OpenclawPlugin) and pushes diffs via
* `PUT /agents/:userId/presence`.
*
* Used by `RealtimeGateway.computeDelivery` for `announce`-type
* channels to skip delivery to recipients whose status is `busy`.
* Defaults to `unknown` if no row exists (treated as not-busy).
*/
@Entity('agent_presences')
export class AgentPresence {
// Same id as the Fabric Center user id (UUID v4 string, char(36)).
@PrimaryColumn({ type: 'char', length: 36 })
userId!: string;
@Column({
type: 'enum',
enum: ['idle', 'on_call', 'busy', 'exhausted', 'offline', 'unknown'],
default: 'unknown',
})
status!: 'idle' | 'on_call' | 'busy' | 'exhausted' | 'offline' | 'unknown';
/** Free-text source tag for debugging ("hf-plugin", "manual", etc.). */
@Column({ type: 'varchar', length: 64, default: 'unknown' })
source!: string;
@UpdateDateColumn()
updatedAt!: Date;
}

View File

@@ -16,13 +16,23 @@ export class Channel {
@Column({
name: 'x_type',
type: 'enum',
enum: ['general', 'work', 'report', 'discuss', 'triage', 'custom', 'dm'],
enum: ['general', 'work', 'report', 'discuss', 'triage', 'custom', 'dm', 'announce'],
})
xType!: 'general' | 'work' | 'report' | 'discuss' | 'triage' | 'custom' | 'dm';
xType!: 'general' | 'work' | 'report' | 'discuss' | 'triage' | 'custom' | 'dm' | 'announce';
@Column({ type: 'varchar', length: 16, default: 'text' })
kind!: 'text' | 'announcement';
// Free-form description of what this channel is for — what topics get
// posted, who participates, why it exists. Surfaced via GET /api/channels
// so agents can pick a channel by intent ("which announce channel is for
// debate broadcasts?") without channel id hard-coded into workflows.
// Any channel member can set it via PATCH /api/channels/:id (writes
// require membership the same way moveToBypass / close do). The frontend
// create form does NOT post this today — purpose stays optional.
@Column({ type: 'text', nullable: true })
purpose!: string | null;
@Column({ type: 'boolean', default: false })
isPrivate!: boolean;

View File

@@ -166,6 +166,11 @@ export class MessagingController {
// Guild C-1: caller must be a participant of the channel, and the
// author is always the authenticated user — body.authorUserId is
// ignored so a caller can never post as someone else.
//
// announce channels: any participant can POST. Use case is one-off
// recruitment / broadcast messages posted by the agent that just
// created the originating topic (e.g. dialectic invites). No
// server-side privileged path — author is always a real user.
const userId = String(req.userId ?? '');
if (!userId) throw new ForbiddenException('missing user');
const channel = await this.assertParticipant(channelId, userId);

View File

@@ -11,7 +11,17 @@ import { Logger } from '@nestjs/common';
import { Server, Socket } from 'socket.io';
import { introspectGuildToken } from '../common/center-auth.js';
type XType = 'general' | 'work' | 'report' | 'discuss' | 'triage' | 'custom' | 'dm';
type XType = 'general' | 'work' | 'report' | 'discuss' | 'triage' | 'custom' | 'dm' | 'announce';
/**
* Cross-presence info needed by `announce`-type delivery: a recipient
* with hf-side status === 'busy' has the message discarded silently
* (don't enter their session, no UI emit). Other statuses + non-announce
* channels are unaffected. Presence is sourced from the
* `agent_presences` table populated by Fabric.OpenclawPlugin's
* presence-sync loop (which reads from HF plugin's `__hfAgentStatus`).
*/
export type PresenceStatus = 'idle' | 'on_call' | 'busy' | 'exhausted' | 'offline' | 'unknown';
/**
* Per-recipient delivery decision for a non-rotating channel message.
@@ -43,10 +53,12 @@ export interface ComputeDeliveryArgs {
mentionUserIds?: Set<string>;
/** Single Center-scoped admin userId, or null. */
adminUserId?: string | null;
/** Recipient's current presence; only consulted for `announce` xType. Defaults to 'unknown' (treated as not-busy). */
recipientPresence?: PresenceStatus;
}
export function computeDelivery(args: ComputeDeliveryArgs): DeliveryDecision {
const { xType, recipientUserId, authorUserId, wakeUserIds, mentionUserIds, adminUserId } = args;
const { xType, recipientUserId, authorUserId, wakeUserIds, mentionUserIds, adminUserId, recipientPresence } = args;
if (recipientUserId === authorUserId) return 'skip';
switch (xType) {
@@ -67,6 +79,16 @@ export function computeDelivery(args: ComputeDeliveryArgs): DeliveryDecision {
return wakeUserIds.has(recipientUserId) ? 'wake' : 'observer';
case 'dm':
return 'wake';
case 'announce':
// System-broadcast channels (e.g. Dialectic topic announcements).
// Recipients with HF status === 'busy' have the message discarded
// silently — busy agents should not be distracted by signup pings
// they can't act on. All other presences (idle/on_call/exhausted/
// offline/unknown) get the message as 'observer' (no wake): the
// channel itself is browsable; agents proactively decide what to
// do with announcements when they next look at their inbox.
if (recipientPresence === 'busy') return 'skip';
return 'observer';
default:
// report (and anything else): deliver as observer, no wake
return 'observer';
@@ -100,6 +122,12 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect
private readonly logger = new Logger(RealtimeGateway.name);
private readonly onlineUsers = new Set<string>();
// Optional: injected at module wiring time. Used by emitMessageCreated
// to pre-load recipient presence for announce-type channels.
// Typed loosely to avoid a circular import between realtime and agents
// modules; the actual interface lives in agents/agent-presence.service.
presence?: { getStatusMap(ids: string[]): Promise<Map<string, PresenceStatus>> };
private userIdFromClient(client: Socket): string {
const authUser = client.handshake.auth?.userId;
const headerUser = client.handshake.headers['x-user-id'];
@@ -225,7 +253,8 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect
// 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.
// / non-admin users completely out of the loop, and by announce
// channels to suppress delivery to recipients whose presence is busy.
async emitMessageCreated(
channelId: string,
data: Record<string, unknown>,
@@ -239,6 +268,18 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect
},
): Promise<void> {
const sockets = await this.server.in(`channel:${channelId}`).fetchSockets();
// For announce-type channels, pre-load presence for all recipients
// in one query so the per-recipient loop doesn't fan out to N round
// trips. For other xTypes, presence is irrelevant — skip the lookup.
let presenceMap: Map<string, PresenceStatus> | undefined;
if (ctx.xType === 'announce' && this.presence) {
const recipientIds = sockets
.map((s) => (typeof s.data.userId === 'string' ? (s.data.userId as string) : ''))
.filter((id) => id && !id.startsWith('anon:'));
presenceMap = await this.presence.getStatusMap(recipientIds);
}
for (const s of sockets) {
const recipientUserId = typeof s.data.userId === 'string' ? s.data.userId : `anon:${s.id}`;
const decision = computeDelivery({
@@ -248,6 +289,7 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect
wakeUserIds: ctx.wakeUserIds,
mentionUserIds: ctx.mentionUserIds,
adminUserId: ctx.adminUserId,
recipientPresence: presenceMap?.get(recipientUserId) ?? 'unknown',
});
if (decision === 'skip') continue;
s.emit('message.created', {

View File

@@ -1,9 +1,25 @@
import { Global, Module } from '@nestjs/common';
import { Global, Module, OnModuleInit } from '@nestjs/common';
import { RealtimeGateway } from './realtime.gateway.js';
import { AgentPresenceModule } from '../agents/agent-presence.module.js';
import { AgentPresenceService } from '../agents/agent-presence.service.js';
@Global()
@Module({
imports: [AgentPresenceModule],
providers: [RealtimeGateway],
exports: [RealtimeGateway],
})
export class RealtimeModule {}
export class RealtimeModule implements OnModuleInit {
// Wire presence into the gateway at startup. Using assignment (vs
// constructor injection) keeps the gateway free of the agents-module
// import — no risk of circular dependency, and announce-channel
// delivery degrades gracefully (presence stays undefined → 'unknown'
// status → no busy-discard) if AgentPresenceModule is ever removed.
constructor(
private readonly gateway: RealtimeGateway,
private readonly presence: AgentPresenceService,
) {}
onModuleInit(): void {
this.gateway.presence = this.presence;
}
}