Compare commits
23 Commits
182cfb3c41
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 340eed8aa3 | |||
| 3f77c0e35d | |||
| 38b4665321 | |||
| ca20df7618 | |||
| cb7b3bb5fe | |||
| 985b06a886 | |||
| 80ee9082f3 | |||
| 801b562999 | |||
| 7cb046d785 | |||
| e635faea9c | |||
| 30069377e7 | |||
| b1f7467161 | |||
| 7e944a08f6 | |||
| e45ad91340 | |||
| 3e96de730a | |||
| f54ed6abb5 | |||
| 8de5736a59 | |||
| 58badf328c | |||
| b3fcefb5ec | |||
| 8c41d23a9c | |||
| 9670da400e | |||
| 22fd834ed0 | |||
| 02b7c72e70 |
75
README.md
75
README.md
@@ -1,22 +1,71 @@
|
|||||||
# Fabric.Backend.Guild
|
# Fabric.Backend.Guild
|
||||||
|
|
||||||
Guild Node service for Fabric.
|
A **guild node** for Fabric (NestJS, ES modules, MySQL/TypeORM,
|
||||||
|
socket.io). Default port `7002`, global prefix `/api`. Many independent
|
||||||
|
guild nodes can run; each registers with `Fabric.Backend.Center` and
|
||||||
|
introspects the user/guild tokens Center issues.
|
||||||
|
|
||||||
## Scope (MVP)
|
## Responsibilities
|
||||||
- Workspace/Guild/Channel/DM
|
|
||||||
- Message create/edit/delete/reply/@mention
|
|
||||||
- Per-channel/DM seq ordering + gap backfill API
|
|
||||||
- Webhook/Bot integration surface
|
|
||||||
- Guild-level audit logs
|
|
||||||
|
|
||||||
## Next
|
- **Guilds / channels / messaging** — per-channel `seq` ordering, edit
|
||||||
- API skeleton (NestJS)
|
window, soft delete, reply, `<@id>` mentions (backtick-aware) plus
|
||||||
- Chat domain models
|
`<@user.name:NAME>` → `<@userId>` translation via Center.
|
||||||
- Seq allocator and range query endpoints
|
- **Channel `x_type`** (required on create): `general`, `work`, `report`,
|
||||||
|
`discuss`, `triage`, `custom`. Plus `isPublic` and `closed` (closed →
|
||||||
|
history readable, posting returns `409`).
|
||||||
|
- **`wake_mapping`** — explicit wake list for `triage` (on-duty) and
|
||||||
|
`custom` (listeners) channels.
|
||||||
|
- **Per-recipient `wakeup`** — `message.created` is emitted per socket with
|
||||||
|
its own `wakeup` flag (author=false; general→all; report→none;
|
||||||
|
triage/custom→wake_mapping; discuss/work→the current speaker only). This
|
||||||
|
is **push-only metadata for the OpenClaw plugin**; UIs ignore it.
|
||||||
|
- **discuss/work turn engine** (`channel_turn_state`): speaking order and a
|
||||||
|
disjoint **bypass list** (bypass members aren't woken unless @-mentioned);
|
||||||
|
activation from idle, queue-jump, cross-round `/no-reply` pause,
|
||||||
|
`/force-proceed`, end-of-round shuffle, guild `/ack`, and a mention
|
||||||
|
sub-frame stack with a 5-level nesting cap (root + 4). `moveToBypass`
|
||||||
|
mid-rotation.
|
||||||
|
- **Files** — `POST /files` (multipart, configurable max size, default
|
||||||
|
100 MB), `GET /files/:id` (Bearer **or** `?access_token=` for browser
|
||||||
|
`<img>/<a>`), automatic retention sweep (default 7 days). Messages carry
|
||||||
|
`attachments[]`.
|
||||||
|
- **Channel canvas** — one pinned document per channel (`md`/`html`/`text`),
|
||||||
|
re-share replaces, only the original sharer may update/remove; emits
|
||||||
|
`canvas.updated` / `canvas.removed`.
|
||||||
|
- **Slash-command registry** — guild-global catalog: `PUT /api/commands`
|
||||||
|
(the OpenClaw plugin syncs OpenClaw's native-command specs here),
|
||||||
|
`GET /api/commands` (frontend `/` autocomplete). Stored verbatim;
|
||||||
|
execution is unchanged (a `/<cmd>` message flows normally to the plugin →
|
||||||
|
OpenClaw command system; only `/no-reply`,`/force-proceed` are
|
||||||
|
server-intercepted).
|
||||||
|
- **Realtime** — socket.io `/realtime`; `join_channel`/`leave_channel`,
|
||||||
|
`message.created/updated/deleted`, `canvas.*`, presence, typing.
|
||||||
|
|
||||||
|
## Required env (hard-checked at startup)
|
||||||
|
|
||||||
## Required env (startup hard checks)
|
|
||||||
- `FABRIC_BACKEND_GUILD_CENTER_BASE_URL`
|
- `FABRIC_BACKEND_GUILD_CENTER_BASE_URL`
|
||||||
- `FABRIC_BACKEND_GUILD_CENTER_API_KEY`
|
- `FABRIC_BACKEND_GUILD_CENTER_API_KEY`
|
||||||
- `FABRIC_BACKEND_GUILD_NODE_ID`
|
- `FABRIC_BACKEND_GUILD_NODE_ID`
|
||||||
|
|
||||||
If any of the above is missing, service startup fails immediately.
|
Missing any of these aborts startup.
|
||||||
|
|
||||||
|
## Other env
|
||||||
|
|
||||||
|
- `FABRIC_BACKEND_GUILD_PORT` (default 7002)
|
||||||
|
- `FABRIC_BACKEND_GUILD_DB_*`, `FABRIC_BACKEND_GUILD_DB_SYNC`
|
||||||
|
- `FABRIC_BACKEND_GUILD_FILE_DIR` (storage root),
|
||||||
|
`FABRIC_BACKEND_GUILD_FILE_MAX_BYTES` (default 100 MB),
|
||||||
|
`FABRIC_BACKEND_GUILD_FILE_TTL_DAYS` (default 7)
|
||||||
|
- `FABRIC_BACKEND_GUILD_CORS_ORIGINS` (empty = allow all; `null` origin —
|
||||||
|
`file://` desktop — is always allowed)
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build && npm start # or: npm run start:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Usually run via the root `docker-compose.local.yml` (`backend-guild1`
|
||||||
|
`test-guild1` :7002, `backend-guild2` `test-guild2` :7003). Schema is
|
||||||
|
auto-managed (`DB_SYNC`). ES modules (`NodeNext`).
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
"name": "fabric-backend-guild",
|
"name": "fabric-backend-guild",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"description": "Fabric Guild Node service",
|
"description": "Fabric Guild Node service",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.build.json",
|
"build": "tsc -p tsconfig.build.json",
|
||||||
"start": "node dist/main.js",
|
"start": "node dist/main.js",
|
||||||
|
"print:commands-key": "node dist/cli/print-commands-sync-key.js",
|
||||||
"start:dev": "ts-node src/main.ts",
|
"start:dev": "ts-node src/main.ts",
|
||||||
"lint": "eslint 'src/**/*.ts'",
|
"lint": "eslint 'src/**/*.ts'",
|
||||||
"lint:fix": "eslint 'src/**/*.ts' --fix",
|
"lint:fix": "eslint 'src/**/*.ts' --fix",
|
||||||
|
|||||||
42
src/agents/agent-presence.controller.ts
Normal file
42
src/agents/agent-presence.controller.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/agents/agent-presence.module.ts
Normal file
13
src/agents/agent-presence.module.ts
Normal 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 {}
|
||||||
61
src/agents/agent-presence.service.ts
Normal file
61
src/agents/agent-presence.service.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
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.
|
||||||
|
*
|
||||||
|
* Implementation note: the older findOne+save split was a read-modify-
|
||||||
|
* write race — two concurrent first-time writes for the same userId
|
||||||
|
* would both read no row, both INSERT, second hits unique-key dup
|
||||||
|
* (`agent_presences.PRIMARY`) and 500s. Fabric.OpenclawPlugin's
|
||||||
|
* presence-sync occasionally fires two PUTs for the same agent within
|
||||||
|
* ~10 ms (tick overlap on its side — separate fix in the plugin),
|
||||||
|
* which surfaced this race in prod.
|
||||||
|
*
|
||||||
|
* `repo.upsert(values, conflictPaths)` compiles to MySQL
|
||||||
|
* `INSERT … ON DUPLICATE KEY UPDATE` and is atomic at the storage
|
||||||
|
* engine level — no read needed, no race window. We synthesize the
|
||||||
|
* returned entity from what we just wrote rather than round-tripping
|
||||||
|
* a SELECT — the controller only reads {userId, status} off it.
|
||||||
|
*/
|
||||||
|
async setStatus(userId: string, status: PresenceStatus, source: string): Promise<AgentPresence> {
|
||||||
|
await this.repo.upsert({ userId, status, source }, ['userId']);
|
||||||
|
return this.repo.create({ userId, status, source });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,22 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { APP_GUARD } from '@nestjs/core';
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { buildTypeOrmConfig } from './database.config';
|
import { buildTypeOrmConfig } from './database.config.js';
|
||||||
import { HealthController } from './common/health.controller';
|
import { HealthController } from './common/health.controller.js';
|
||||||
import { MetricsController } from './common/metrics.controller';
|
import { MetricsController } from './common/metrics.controller.js';
|
||||||
import { MetricsService } from './common/metrics.service';
|
import { MetricsService } from './common/metrics.service.js';
|
||||||
import { ApiKeyGuard } from './common/api-key.guard';
|
import { ApiKeyGuard } from './common/api-key.guard.js';
|
||||||
import { GuildsModule } from './guilds/guilds.module';
|
import { GuildsModule } from './guilds/guilds.module.js';
|
||||||
import { ChannelsModule } from './channels/channels.module';
|
import { ChannelsModule } from './channels/channels.module.js';
|
||||||
import { TurnModule } from './channels/turn.module';
|
import { TurnModule } from './channels/turn.module.js';
|
||||||
import { MessagingModule } from './messaging/messaging.module';
|
import { MessagingModule } from './messaging/messaging.module.js';
|
||||||
import { EventsModule } from './events/events.module';
|
import { EventsModule } from './events/events.module.js';
|
||||||
import { RealtimeModule } from './realtime/realtime.module';
|
import { RealtimeModule } from './realtime/realtime.module.js';
|
||||||
import { MembersModule } from './members/members.module';
|
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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -24,6 +28,10 @@ import { MembersModule } from './members/members.module';
|
|||||||
ChannelsModule,
|
ChannelsModule,
|
||||||
MembersModule,
|
MembersModule,
|
||||||
MessagingModule,
|
MessagingModule,
|
||||||
|
FilesModule,
|
||||||
|
CanvasModule,
|
||||||
|
CommandsModule,
|
||||||
|
AgentPresenceModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController, MetricsController],
|
controllers: [HealthController, MetricsController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
58
src/canvas/canvas.controller.ts
Normal file
58
src/canvas/canvas.controller.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Patch,
|
||||||
|
Req,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { CanvasService } from './canvas.service.js';
|
||||||
|
|
||||||
|
type AuthedRequest = { userId?: string };
|
||||||
|
type CanvasBody = { title?: string; format?: string; source?: string };
|
||||||
|
|
||||||
|
@Controller('channels/:id/canvas')
|
||||||
|
export class CanvasController {
|
||||||
|
constructor(private readonly canvas: CanvasService) {}
|
||||||
|
|
||||||
|
private uid(req: AuthedRequest): string {
|
||||||
|
const userId = req.userId ?? '';
|
||||||
|
if (!userId) throw new UnauthorizedException('missing user');
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
get(@Req() req: AuthedRequest, @Param('id') channelId: string) {
|
||||||
|
return this.canvas.get(channelId, this.uid(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
// share / replace (caller becomes the sharer)
|
||||||
|
@Put()
|
||||||
|
@Post()
|
||||||
|
share(
|
||||||
|
@Req() req: AuthedRequest,
|
||||||
|
@Param('id') channelId: string,
|
||||||
|
@Body() body: CanvasBody,
|
||||||
|
) {
|
||||||
|
return this.canvas.share(channelId, this.uid(req), body ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// update in place (original sharer only)
|
||||||
|
@Patch()
|
||||||
|
update(
|
||||||
|
@Req() req: AuthedRequest,
|
||||||
|
@Param('id') channelId: string,
|
||||||
|
@Body() body: CanvasBody,
|
||||||
|
) {
|
||||||
|
return this.canvas.update(channelId, this.uid(req), body ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete()
|
||||||
|
remove(@Req() req: AuthedRequest, @Param('id') channelId: string) {
|
||||||
|
return this.canvas.remove(channelId, this.uid(req));
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/canvas/canvas.module.ts
Normal file
14
src/canvas/canvas.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Channel } from '../entities/channel.entity.js';
|
||||||
|
import { ChannelMember } from '../entities/channel-member.entity.js';
|
||||||
|
import { ChannelCanvas } from '../entities/channel-canvas.entity.js';
|
||||||
|
import { CanvasController } from './canvas.controller.js';
|
||||||
|
import { CanvasService } from './canvas.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Channel, ChannelMember, ChannelCanvas])],
|
||||||
|
controllers: [CanvasController],
|
||||||
|
providers: [CanvasService],
|
||||||
|
})
|
||||||
|
export class CanvasModule {}
|
||||||
147
src/canvas/canvas.service.ts
Normal file
147
src/canvas/canvas.service.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Channel } from '../entities/channel.entity.js';
|
||||||
|
import { ChannelMember } from '../entities/channel-member.entity.js';
|
||||||
|
import {
|
||||||
|
ChannelCanvas,
|
||||||
|
type CanvasFormat,
|
||||||
|
} from '../entities/channel-canvas.entity.js';
|
||||||
|
import { RealtimeGateway } from '../realtime/realtime.gateway.js';
|
||||||
|
|
||||||
|
const FORMATS: CanvasFormat[] = ['md', 'html', 'text'];
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CanvasService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Channel)
|
||||||
|
private readonly channelRepo: Repository<Channel>,
|
||||||
|
@InjectRepository(ChannelMember)
|
||||||
|
private readonly memberRepo: Repository<ChannelMember>,
|
||||||
|
@InjectRepository(ChannelCanvas)
|
||||||
|
private readonly canvasRepo: Repository<ChannelCanvas>,
|
||||||
|
private readonly realtime: RealtimeGateway,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private view(c: ChannelCanvas) {
|
||||||
|
return {
|
||||||
|
channelId: c.channelId,
|
||||||
|
sharerUserId: c.sharerUserId,
|
||||||
|
title: c.title,
|
||||||
|
format: c.format,
|
||||||
|
source: c.source,
|
||||||
|
version: c.version,
|
||||||
|
createdAt: c.createdAt.toISOString(),
|
||||||
|
updatedAt: c.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertChannel(channelId: string) {
|
||||||
|
const channel = await this.channelRepo.findOne({ where: { id: channelId } });
|
||||||
|
if (!channel) throw new NotFoundException('channel not found');
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertParticipant(channelId: string, userId: string) {
|
||||||
|
const channel = await this.assertChannel(channelId);
|
||||||
|
if (channel.isPublic) return channel;
|
||||||
|
const member = await this.memberRepo.findOne({ where: { channelId, userId } });
|
||||||
|
if (!member) throw new ForbiddenException('not a channel member');
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(channelId: string, userId: string) {
|
||||||
|
await this.assertParticipant(channelId, userId);
|
||||||
|
const c = await this.canvasRepo.findOne({ where: { channelId } });
|
||||||
|
return c ? this.view(c) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalize(input: {
|
||||||
|
title?: string;
|
||||||
|
format?: string;
|
||||||
|
source?: string;
|
||||||
|
}) {
|
||||||
|
const title = String(input.title ?? '').trim().slice(0, 200) || 'Untitled';
|
||||||
|
const format = String(input.format ?? 'md') as CanvasFormat;
|
||||||
|
if (!FORMATS.includes(format)) {
|
||||||
|
throw new BadRequestException(`format must be one of: ${FORMATS.join(', ')}`);
|
||||||
|
}
|
||||||
|
const source = String(input.source ?? '');
|
||||||
|
return { title, format, source };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Share / replace the channel's single active canvas (caller becomes sharer).
|
||||||
|
async share(
|
||||||
|
channelId: string,
|
||||||
|
userId: string,
|
||||||
|
input: { title?: string; format?: string; source?: string },
|
||||||
|
) {
|
||||||
|
await this.assertParticipant(channelId, userId);
|
||||||
|
const { title, format, source } = this.normalize(input);
|
||||||
|
let c = await this.canvasRepo.findOne({ where: { channelId } });
|
||||||
|
if (c) {
|
||||||
|
c.sharerUserId = userId;
|
||||||
|
c.title = title;
|
||||||
|
c.format = format;
|
||||||
|
c.source = source;
|
||||||
|
c.version = 1;
|
||||||
|
} else {
|
||||||
|
c = this.canvasRepo.create({
|
||||||
|
channelId,
|
||||||
|
sharerUserId: userId,
|
||||||
|
title,
|
||||||
|
format,
|
||||||
|
source,
|
||||||
|
version: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
c = await this.canvasRepo.save(c);
|
||||||
|
const v = this.view(c);
|
||||||
|
this.realtime.emitChannelEvent(channelId, 'canvas.updated', v);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the existing canvas in place — only the original sharer.
|
||||||
|
async update(
|
||||||
|
channelId: string,
|
||||||
|
userId: string,
|
||||||
|
input: { title?: string; format?: string; source?: string },
|
||||||
|
) {
|
||||||
|
await this.assertParticipant(channelId, userId);
|
||||||
|
const c = await this.canvasRepo.findOne({ where: { channelId } });
|
||||||
|
if (!c) throw new NotFoundException('no canvas shared in this channel');
|
||||||
|
if (c.sharerUserId !== userId) {
|
||||||
|
throw new ForbiddenException('only the original sharer may update the canvas');
|
||||||
|
}
|
||||||
|
const { title, format, source } = this.normalize({
|
||||||
|
title: input.title ?? c.title,
|
||||||
|
format: input.format ?? c.format,
|
||||||
|
source: input.source ?? c.source,
|
||||||
|
});
|
||||||
|
c.title = title;
|
||||||
|
c.format = format;
|
||||||
|
c.source = source;
|
||||||
|
c.version += 1;
|
||||||
|
const saved = await this.canvasRepo.save(c);
|
||||||
|
const v = this.view(saved);
|
||||||
|
this.realtime.emitChannelEvent(channelId, 'canvas.updated', v);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(channelId: string, userId: string) {
|
||||||
|
await this.assertParticipant(channelId, userId);
|
||||||
|
const c = await this.canvasRepo.findOne({ where: { channelId } });
|
||||||
|
if (!c) return { status: 'ok' };
|
||||||
|
if (c.sharerUserId !== userId) {
|
||||||
|
throw new ForbiddenException('only the original sharer may remove the canvas');
|
||||||
|
}
|
||||||
|
await this.canvasRepo.delete({ id: c.id });
|
||||||
|
this.realtime.emitChannelEvent(channelId, 'canvas.removed', { channelId });
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
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';
|
import { ChannelsService } from './channels.service.js';
|
||||||
|
|
||||||
// ApiKeyGuard attaches the introspected Center user id onto the request.
|
// ApiKeyGuard attaches the introspected Center user id onto the request.
|
||||||
type AuthedRequest = { userId?: string };
|
type AuthedRequest = { userId?: string };
|
||||||
@@ -29,11 +29,52 @@ export class ChannelsController {
|
|||||||
memberUserIds: Array.isArray(body.memberUserIds) ? (body.memberUserIds as string[]) : [],
|
memberUserIds: Array.isArray(body.memberUserIds) ? (body.memberUserIds as string[]) : [],
|
||||||
onDuty: body.onDuty as string | undefined,
|
onDuty: body.onDuty as string | undefined,
|
||||||
listeners: Array.isArray(body.listeners) ? (body.listeners as string[]) : [],
|
listeners: Array.isArray(body.listeners) ? (body.listeners as string[]) : [],
|
||||||
|
bypassUserIds: Array.isArray(body.bypassUserIds)
|
||||||
|
? (body.bypassUserIds as string[])
|
||||||
|
: [],
|
||||||
|
purpose: body.purpose as string | undefined,
|
||||||
},
|
},
|
||||||
userId,
|
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(
|
||||||
|
@Req() req: AuthedRequest,
|
||||||
|
@Param('id') channelId: string,
|
||||||
|
@Body() body: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
const userId = req.userId ?? '';
|
||||||
|
if (!userId) throw new UnauthorizedException('missing user');
|
||||||
|
return this.channelsService.moveToBypass(
|
||||||
|
channelId,
|
||||||
|
userId,
|
||||||
|
String(body.userId ?? ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id/members')
|
@Get(':id/members')
|
||||||
members(@Req() req: AuthedRequest, @Param('id') channelId: string) {
|
members(@Req() req: AuthedRequest, @Param('id') channelId: string) {
|
||||||
const userId = req.userId ?? '';
|
const userId = req.userId ?? '';
|
||||||
@@ -54,4 +95,11 @@ export class ChannelsController {
|
|||||||
if (!userId) throw new UnauthorizedException('missing user');
|
if (!userId) throw new UnauthorizedException('missing user');
|
||||||
return this.channelsService.leaveChannel(channelId, userId);
|
return this.channelsService.leaveChannel(channelId, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/close')
|
||||||
|
close(@Req() req: AuthedRequest, @Param('id') channelId: string) {
|
||||||
|
const userId = req.userId ?? '';
|
||||||
|
if (!userId) throw new UnauthorizedException('missing user');
|
||||||
|
return this.channelsService.closeChannel(channelId, userId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { ChannelsController } from './channels.controller';
|
import { ChannelsController } from './channels.controller.js';
|
||||||
import { Channel } from '../entities/channel.entity';
|
import { Channel } from '../entities/channel.entity.js';
|
||||||
import { ChannelMember } from '../entities/channel-member.entity';
|
import { ChannelMember } from '../entities/channel-member.entity.js';
|
||||||
import { WakeMapping } from '../entities/wake-mapping.entity';
|
import { WakeMapping } from '../entities/wake-mapping.entity.js';
|
||||||
import { ChannelsService } from './channels.service';
|
import { ChannelsService } from './channels.service.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Channel, ChannelMember, WakeMapping])],
|
imports: [TypeOrmModule.forFeature([Channel, ChannelMember, WakeMapping])],
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { In, Repository } from 'typeorm';
|
import { In, Repository } from 'typeorm';
|
||||||
import { Channel } from '../entities/channel.entity';
|
import { Channel } from '../entities/channel.entity.js';
|
||||||
import { ChannelMember } from '../entities/channel-member.entity';
|
import { ChannelMember } from '../entities/channel-member.entity.js';
|
||||||
import { WakeMapping } from '../entities/wake-mapping.entity';
|
import { WakeMapping } from '../entities/wake-mapping.entity.js';
|
||||||
import { TurnService } from './turn.service';
|
import { TurnService } from './turn.service.js';
|
||||||
|
import { RealtimeGateway } from '../realtime/realtime.gateway.js';
|
||||||
|
|
||||||
const X_TYPES = ['general', 'work', 'report', 'discuss', 'triage', 'custom'] as const;
|
const X_TYPES = ['general', 'work', 'report', 'discuss', 'triage', 'custom', 'dm', 'announce'] as const;
|
||||||
type XType = (typeof X_TYPES)[number];
|
type XType = (typeof X_TYPES)[number];
|
||||||
|
|
||||||
type CreateChannelInput = {
|
type CreateChannelInput = {
|
||||||
@@ -20,6 +21,13 @@ type CreateChannelInput = {
|
|||||||
onDuty?: string;
|
onDuty?: string;
|
||||||
// optional when xType === 'custom': users to wake on this channel
|
// optional when xType === 'custom': users to wake on this channel
|
||||||
listeners?: string[];
|
listeners?: string[];
|
||||||
|
// 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()
|
@Injectable()
|
||||||
@@ -32,8 +40,34 @@ export class ChannelsService {
|
|||||||
@InjectRepository(WakeMapping)
|
@InjectRepository(WakeMapping)
|
||||||
private readonly wakeRepo: Repository<WakeMapping>,
|
private readonly wakeRepo: Repository<WakeMapping>,
|
||||||
private readonly turnService: TurnService,
|
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:
|
// Channels visible to a user within a guild:
|
||||||
// - every public channel of the guild (incl. ones created before the user
|
// - every public channel of the guild (incl. ones created before the user
|
||||||
// joined the guild), OR
|
// joined the guild), OR
|
||||||
@@ -56,12 +90,25 @@ export class ChannelsService {
|
|||||||
.map((c) => ({ ...c, isMember: memberChannelIds.has(c.id) }));
|
.map((c) => ({ ...c, isMember: memberChannelIds.has(c.id) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async channelMembers(channelId: string): Promise<{ userId: string }[]> {
|
async channelMembers(channelId: string): Promise<{ userId: string; bypass: boolean }[]> {
|
||||||
const rows = await this.memberRepo.find({
|
const rows = await this.memberRepo.find({
|
||||||
where: { channelId },
|
where: { channelId },
|
||||||
order: { createdAt: 'ASC' },
|
order: { createdAt: 'ASC' },
|
||||||
});
|
});
|
||||||
return rows.map((r) => ({ userId: r.userId }));
|
const bypass = new Set(await this.turnService.getBypassUserIds(channelId));
|
||||||
|
return rows.map((r) => ({ userId: r.userId, bypass: bypass.has(r.userId) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async closeChannel(channelId: string, userId: 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 } });
|
||||||
|
if (!member && !channel.isPublic) {
|
||||||
|
throw new ForbiddenException('not a channel member');
|
||||||
|
}
|
||||||
|
channel.closed = true;
|
||||||
|
await this.channelRepo.save(channel);
|
||||||
|
return { status: 'ok', channelId, closed: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
async joinChannel(channelId: string, userId: string) {
|
async joinChannel(channelId: string, userId: string) {
|
||||||
@@ -77,6 +124,7 @@ export class ChannelsService {
|
|||||||
if (channel.xType === 'discuss' || channel.xType === 'work') {
|
if (channel.xType === 'discuss' || channel.xType === 'work') {
|
||||||
await this.turnService.onMemberAdded(channelId, userId);
|
await this.turnService.onMemberAdded(channelId, userId);
|
||||||
}
|
}
|
||||||
|
this.notifyMembership('joined', channelId, [userId], { xType: channel.xType });
|
||||||
}
|
}
|
||||||
return { status: 'ok', channelId, userId, member: true };
|
return { status: 'ok', channelId, userId, member: true };
|
||||||
}
|
}
|
||||||
@@ -86,11 +134,14 @@ export class ChannelsService {
|
|||||||
if (!channel) throw new NotFoundException('channel not found');
|
if (!channel) throw new NotFoundException('channel not found');
|
||||||
|
|
||||||
// remove every channel-scoped row that references this user
|
// 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 });
|
await this.wakeRepo.delete({ channelId, userId });
|
||||||
if (channel.xType === 'discuss' || channel.xType === 'work') {
|
if (channel.xType === 'discuss' || channel.xType === 'work') {
|
||||||
await this.turnService.onMemberRemoved(channelId, userId);
|
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 };
|
return { status: 'ok', channelId, userId, member: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,14 +165,23 @@ export class ChannelsService {
|
|||||||
.map((x) => String(x ?? '').trim())
|
.map((x) => String(x ?? '').trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
|
// dm channels are always private (a 1:1 conversation); never public.
|
||||||
|
// dm is not unique — multiple dm channels between the same users are
|
||||||
|
// 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(
|
const channel = await this.channelRepo.save(
|
||||||
this.channelRepo.create({
|
this.channelRepo.create({
|
||||||
guildId,
|
guildId,
|
||||||
name,
|
name,
|
||||||
xType,
|
xType,
|
||||||
kind: input.kind === 'announcement' ? 'announcement' : 'text',
|
kind: input.kind === 'announcement' ? 'announcement' : 'text',
|
||||||
isPrivate: !input.isPublic,
|
isPrivate: !isPublic,
|
||||||
isPublic: Boolean(input.isPublic),
|
isPublic,
|
||||||
|
purpose,
|
||||||
lastSeq: 0,
|
lastSeq: 0,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -139,6 +199,12 @@ export class ChannelsService {
|
|||||||
[...memberIds].map((userId) => this.memberRepo.create({ channelId: channel.id, userId })),
|
[...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
|
// wake_mapping: triage -> the on-duty user; custom -> each listener
|
||||||
const wakeUserIds = new Set<string>();
|
const wakeUserIds = new Set<string>();
|
||||||
if (xType === 'triage') wakeUserIds.add(onDuty);
|
if (xType === 'triage') wakeUserIds.add(onDuty);
|
||||||
@@ -152,9 +218,49 @@ export class ChannelsService {
|
|||||||
// discuss/work: initialize rotation state (order = members sorted by id,
|
// discuss/work: initialize rotation state (order = members sorted by id,
|
||||||
// currentSpeaker = null until someone proactively speaks)
|
// currentSpeaker = null until someone proactively speaks)
|
||||||
if (xType === 'discuss' || xType === 'work') {
|
if (xType === 'discuss' || xType === 'work') {
|
||||||
await this.turnService.initForChannel(channel.id, [...memberIds]);
|
const bypass = (input.bypassUserIds ?? [])
|
||||||
|
.map((x) => String(x ?? '').trim())
|
||||||
|
.filter((x) => x && memberIds.has(x));
|
||||||
|
await this.turnService.initForChannel(channel.id, [...memberIds], bypass);
|
||||||
}
|
}
|
||||||
|
|
||||||
return channel;
|
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) {
|
||||||
|
const channel = await this.channelRepo.findOne({ where: { id: channelId } });
|
||||||
|
if (!channel) throw new NotFoundException('channel not found');
|
||||||
|
if (channel.xType !== 'discuss' && channel.xType !== 'work') {
|
||||||
|
throw new BadRequestException('bypass only applies to discuss/work channels');
|
||||||
|
}
|
||||||
|
const actor = await this.memberRepo.findOne({ where: { channelId, userId: actorUserId } });
|
||||||
|
if (!actor && !channel.isPublic) throw new ForbiddenException('not a channel member');
|
||||||
|
const target = await this.memberRepo.findOne({ where: { channelId, userId: targetUserId } });
|
||||||
|
if (!target) throw new BadRequestException('target is not a channel member');
|
||||||
|
await this.turnService.moveToBypass(channelId, targetUserId);
|
||||||
|
return { status: 'ok', channelId, userId: targetUserId, bypass: true };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
80
src/channels/mentions.ts
Normal file
80
src/channels/mentions.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
// Split content into parts, tagging which are inside backtick spans so we
|
||||||
|
// only touch mentions in non-code regions. Splitting keeps backtick runs.
|
||||||
|
function codeAwareParts(content: string): { text: string; code: boolean }[] {
|
||||||
|
const raw = content.split(/(`+)/);
|
||||||
|
const parts: { text: string; code: boolean }[] = [];
|
||||||
|
let inCode = false;
|
||||||
|
for (const seg of raw) {
|
||||||
|
if (/^`+$/.test(seg)) {
|
||||||
|
parts.push({ text: seg, code: true });
|
||||||
|
inCode = !inCode;
|
||||||
|
} else {
|
||||||
|
parts.push({ text: seg, code: inCode });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAME_MENTION_RE = /<@user\.name:([^>]+)>/g;
|
||||||
|
|
||||||
|
// Names referenced via <@user.name:NAME> outside backtick spans.
|
||||||
|
export function extractNameMentions(content: string): string[] {
|
||||||
|
if (typeof content !== 'string' || !content) return [];
|
||||||
|
const out: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const p of codeAwareParts(content)) {
|
||||||
|
if (p.code) continue;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
NAME_MENTION_RE.lastIndex = 0;
|
||||||
|
while ((m = NAME_MENTION_RE.exec(p.text)) !== null) {
|
||||||
|
const name = m[1].trim();
|
||||||
|
if (name && !seen.has(name)) {
|
||||||
|
seen.add(name);
|
||||||
|
out.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace <@user.name:NAME> with <@userId> for resolved names (outside
|
||||||
|
// backticks only); unresolved tokens are left untouched.
|
||||||
|
export function replaceNameMentions(content: string, resolved: Record<string, string>): string {
|
||||||
|
if (typeof content !== 'string' || !content) return content;
|
||||||
|
return codeAwareParts(content)
|
||||||
|
.map((p) =>
|
||||||
|
p.code
|
||||||
|
? p.text
|
||||||
|
: p.text.replace(NAME_MENTION_RE, (full, name: string) => {
|
||||||
|
const id = resolved[String(name).trim()];
|
||||||
|
return id ? `<@${id}>` : full;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse <@user-id> mentions from message content. A mention does NOT count
|
||||||
|
// when it sits inside a backtick span (single ` or triple ``` — any backtick
|
||||||
|
// run toggles a code region). Returns unique ids in first-seen order.
|
||||||
|
export function parseMentions(content: string): string[] {
|
||||||
|
if (typeof content !== 'string' || !content) return [];
|
||||||
|
|
||||||
|
// strip backtick-delimited regions: split on runs of backticks; odd
|
||||||
|
// segments (between an opening and closing run) are code -> dropped.
|
||||||
|
const segments = content.split(/`+/);
|
||||||
|
let outside = '';
|
||||||
|
for (let i = 0; i < segments.length; i += 2) outside += segments[i] + ' ';
|
||||||
|
|
||||||
|
const ids: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const re = /<@([^>`\s]+)>/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(outside)) !== null) {
|
||||||
|
const id = m[1];
|
||||||
|
if (!seen.has(id)) {
|
||||||
|
seen.add(id);
|
||||||
|
ids.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { RoundEvent } from '../entities/channel-turn-state.entity';
|
import { RoundEvent } from '../entities/channel-turn-state.entity.js';
|
||||||
|
|
||||||
export type ShuffleResult = { paused: true } | { paused: false; newOrder: string[] };
|
export type ShuffleResult = { paused: true } | { paused: false; newOrder: string[] };
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
import { TurnService } from './turn.service';
|
import { TurnService } from './turn.service.js';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource, EntityManager } from 'typeorm';
|
import { DataSource, EntityManager } from 'typeorm';
|
||||||
import { ChannelTurnState } from '../entities/channel-turn-state.entity';
|
import { ChannelTurnState, TurnFrame } from '../entities/channel-turn-state.entity.js';
|
||||||
import { ChannelMember } from '../entities/channel-member.entity';
|
import { ChannelMember } from '../entities/channel-member.entity.js';
|
||||||
import { computeShuffle } from './turn-shuffle';
|
import { computeShuffle } from './turn-shuffle.js';
|
||||||
|
|
||||||
// wakeupUserId: the single user who should receive wakeup=true on the
|
// wakeupUserId: the single user who should receive wakeup=true on the
|
||||||
// resulting push (null = nobody / paused). For commands, `ack` present means
|
// resulting push (null = nobody / paused). For commands, `ack` present means
|
||||||
@@ -18,11 +18,13 @@ export class TurnService {
|
|||||||
manager: EntityManager,
|
manager: EntityManager,
|
||||||
channelId: string,
|
channelId: string,
|
||||||
): Promise<ChannelTurnState | null> {
|
): Promise<ChannelTurnState | null> {
|
||||||
return manager
|
const state = await manager
|
||||||
.createQueryBuilder(ChannelTurnState, 's')
|
.createQueryBuilder(ChannelTurnState, 's')
|
||||||
.setLock('pessimistic_write')
|
.setLock('pessimistic_write')
|
||||||
.where('s.channelId = :channelId', { channelId })
|
.where('s.channelId = :channelId', { channelId })
|
||||||
.getOne();
|
.getOne();
|
||||||
|
if (state && !Array.isArray(state.frames)) state.frames = [];
|
||||||
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureState(
|
private async ensureState(
|
||||||
@@ -31,7 +33,6 @@ export class TurnService {
|
|||||||
): Promise<ChannelTurnState> {
|
): Promise<ChannelTurnState> {
|
||||||
let state = await this.loadLocked(manager, channelId);
|
let state = await this.loadLocked(manager, channelId);
|
||||||
if (state) return state;
|
if (state) return state;
|
||||||
// lazy init from current channel members (sorted by userId)
|
|
||||||
const members = await manager.find(ChannelMember, { where: { channelId } });
|
const members = await manager.find(ChannelMember, { where: { channelId } });
|
||||||
const order = members.map((m) => m.userId).sort();
|
const order = members.map((m) => m.userId).sort();
|
||||||
state = manager.create(ChannelTurnState, {
|
state = manager.create(ChannelTurnState, {
|
||||||
@@ -41,75 +42,205 @@ export class TurnService {
|
|||||||
roundEvents: [],
|
roundEvents: [],
|
||||||
norepStreak: [],
|
norepStreak: [],
|
||||||
lastNormalSpeaker: null,
|
lastNormalSpeaker: null,
|
||||||
|
frames: [],
|
||||||
|
bypassUserIds: [],
|
||||||
});
|
});
|
||||||
return manager.save(ChannelTurnState, state);
|
return manager.save(ChannelTurnState, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called when a discuss/work channel is created.
|
private frames(state: ChannelTurnState): TurnFrame[] {
|
||||||
async initForChannel(channelId: string, memberUserIds: string[]): Promise<void> {
|
if (!Array.isArray(state.frames)) state.frames = [];
|
||||||
|
return state.frames;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bypass(state: ChannelTurnState): string[] {
|
||||||
|
if (!Array.isArray(state.bypassUserIds)) state.bypassUserIds = [];
|
||||||
|
return state.bypassUserIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push a mention sub-frame, enforcing the nesting cap. Max 4 sub-frames
|
||||||
|
// (5 levels incl. root); a 5th push evicts the bottom-most sub-frame
|
||||||
|
// (the one directly above root) and shifts the rest down:
|
||||||
|
// root->A->B->C->D + E => root->B->C->D->E
|
||||||
|
private pushFrame(state: ChannelTurnState, order: string[]): void {
|
||||||
|
const fr = this.frames(state);
|
||||||
|
while (fr.length >= 4) fr.shift();
|
||||||
|
fr.push({ order, idx: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// effective current speaker = top sub-frame's pointer, else root speaker
|
||||||
|
private effectiveCurrent(state: ChannelTurnState): string | null {
|
||||||
|
const fr = this.frames(state);
|
||||||
|
while (fr.length) {
|
||||||
|
const top = fr[fr.length - 1];
|
||||||
|
if (!top.order.length) {
|
||||||
|
fr.pop();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const idx = Math.min(top.idx, top.order.length - 1);
|
||||||
|
return top.order[idx];
|
||||||
|
}
|
||||||
|
return state.currentSpeaker;
|
||||||
|
}
|
||||||
|
|
||||||
|
// advance / pop the active sub-frame; returns the new effective speaker.
|
||||||
|
// A single linear pass: acting at the last index pops the frame.
|
||||||
|
private advanceSubFrame(state: ChannelTurnState): string | null {
|
||||||
|
const fr = this.frames(state);
|
||||||
|
const top = fr[fr.length - 1];
|
||||||
|
if (top.idx >= top.order.length - 1) {
|
||||||
|
fr.pop();
|
||||||
|
} else {
|
||||||
|
top.idx += 1;
|
||||||
|
}
|
||||||
|
return this.effectiveCurrent(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
async initForChannel(
|
||||||
|
channelId: string,
|
||||||
|
memberUserIds: string[],
|
||||||
|
bypassUserIds: string[] = [],
|
||||||
|
): Promise<void> {
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
const existing = await manager.findOne(ChannelTurnState, { where: { channelId } });
|
const existing = await manager.findOne(ChannelTurnState, { where: { channelId } });
|
||||||
const order = [...new Set(memberUserIds)].sort();
|
const members = [...new Set(memberUserIds)];
|
||||||
|
const bypassSet = new Set(bypassUserIds.filter((u) => members.includes(u)));
|
||||||
|
// order and bypass are a disjoint partition of members
|
||||||
|
const order = members.filter((u) => !bypassSet.has(u)).sort();
|
||||||
|
const base = {
|
||||||
|
orderUserIds: order,
|
||||||
|
currentSpeaker: null,
|
||||||
|
roundEvents: [] as ChannelTurnState['roundEvents'],
|
||||||
|
norepStreak: [] as string[],
|
||||||
|
lastNormalSpeaker: null,
|
||||||
|
frames: [] as TurnFrame[],
|
||||||
|
bypassUserIds: [...bypassSet],
|
||||||
|
};
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.orderUserIds = order;
|
Object.assign(existing, base);
|
||||||
existing.currentSpeaker = null;
|
|
||||||
existing.roundEvents = [];
|
|
||||||
existing.norepStreak = [];
|
|
||||||
existing.lastNormalSpeaker = null;
|
|
||||||
await manager.save(ChannelTurnState, existing);
|
await manager.save(ChannelTurnState, existing);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await manager.save(
|
await manager.save(ChannelTurnState, manager.create(ChannelTurnState, { channelId, ...base }));
|
||||||
ChannelTurnState,
|
|
||||||
manager.create(ChannelTurnState, {
|
|
||||||
channelId,
|
|
||||||
orderUserIds: order,
|
|
||||||
currentSpeaker: null,
|
|
||||||
roundEvents: [],
|
|
||||||
norepStreak: [],
|
|
||||||
lastNormalSpeaker: null,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read-only: userIds currently in the bypass list (no rotation wakeup
|
||||||
|
// unless @-mentioned). Empty if no turn state / not discuss-work.
|
||||||
|
async getBypassUserIds(channelId: string): Promise<string[]> {
|
||||||
|
const state = await this.dataSource
|
||||||
|
.getRepository(ChannelTurnState)
|
||||||
|
.findOne({ where: { channelId } });
|
||||||
|
return state && Array.isArray(state.bypassUserIds) ? state.bypassUserIds : [];
|
||||||
|
}
|
||||||
|
|
||||||
async onMemberAdded(channelId: string, userId: string): Promise<void> {
|
async onMemberAdded(channelId: string, userId: string): Promise<void> {
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
const state = await this.ensureState(manager, channelId);
|
const state = await this.ensureState(manager, channelId);
|
||||||
if (!state.orderUserIds.includes(userId)) {
|
const inBypass = this.bypass(state).includes(userId);
|
||||||
state.orderUserIds = [...state.orderUserIds, userId]; // append to tail
|
if (!state.orderUserIds.includes(userId) && !inBypass) {
|
||||||
|
state.orderUserIds = [...state.orderUserIds, userId];
|
||||||
await manager.save(ChannelTurnState, state);
|
await manager.save(ChannelTurnState, state);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Move an order member into the bypass list (any channel member may do
|
||||||
|
// this). If they are the current speaker, the next one takes over.
|
||||||
|
async moveToBypass(channelId: string, userId: string): Promise<void> {
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
const state = await this.ensureState(manager, channelId);
|
||||||
|
const order = state.orderUserIds;
|
||||||
|
const idx = order.indexOf(userId);
|
||||||
|
if (idx === -1) return; // not in rotation (already bypass / unknown)
|
||||||
|
|
||||||
|
if (state.currentSpeaker === userId) {
|
||||||
|
const next = order.length > 1 ? order[(idx + 1) % order.length] : null;
|
||||||
|
state.currentSpeaker = next === userId ? null : next;
|
||||||
|
}
|
||||||
|
state.orderUserIds = order.filter((u) => u !== userId);
|
||||||
|
if (!state.orderUserIds.length) state.currentSpeaker = null;
|
||||||
|
state.norepStreak = state.norepStreak.filter((u) => u !== userId);
|
||||||
|
// remove from active sub-frames (re-enters only via a future mention)
|
||||||
|
state.frames = this.frames(state)
|
||||||
|
.map((f) => ({ order: f.order.filter((u) => u !== userId), idx: f.idx }))
|
||||||
|
.filter((f) => f.order.length > 0)
|
||||||
|
.map((f) => ({ order: f.order, idx: Math.min(f.idx, f.order.length - 1) }));
|
||||||
|
const bp = this.bypass(state);
|
||||||
|
if (!bp.includes(userId)) bp.push(userId);
|
||||||
|
await manager.save(ChannelTurnState, state);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async onMemberRemoved(channelId: string, userId: string): Promise<void> {
|
async onMemberRemoved(channelId: string, userId: string): Promise<void> {
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
const state = await this.loadLocked(manager, channelId);
|
const state = await this.loadLocked(manager, channelId);
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
const order = state.orderUserIds;
|
const order = state.orderUserIds;
|
||||||
const idx = order.indexOf(userId);
|
const idx = order.indexOf(userId);
|
||||||
if (idx === -1) return;
|
|
||||||
// if the leaver is the current speaker, the next one takes over
|
if (idx !== -1) {
|
||||||
let nextCurrent = state.currentSpeaker;
|
let nextCurrent = state.currentSpeaker;
|
||||||
if (state.currentSpeaker === userId) {
|
if (state.currentSpeaker === userId) {
|
||||||
nextCurrent = order.length > 1 ? order[(idx + 1) % order.length] : null;
|
nextCurrent = order.length > 1 ? order[(idx + 1) % order.length] : null;
|
||||||
if (nextCurrent === userId) nextCurrent = null;
|
if (nextCurrent === userId) nextCurrent = null;
|
||||||
|
}
|
||||||
|
state.orderUserIds = order.filter((u) => u !== userId);
|
||||||
|
state.currentSpeaker = state.orderUserIds.length ? nextCurrent : null;
|
||||||
}
|
}
|
||||||
state.orderUserIds = order.filter((u) => u !== userId);
|
|
||||||
state.norepStreak = state.norepStreak.filter((u) => u !== userId);
|
state.norepStreak = state.norepStreak.filter((u) => u !== userId);
|
||||||
state.currentSpeaker = state.orderUserIds.length ? nextCurrent : null;
|
state.bypassUserIds = this.bypass(state).filter((u) => u !== userId);
|
||||||
|
|
||||||
|
// strip the leaver from every sub-frame; drop emptied frames; clamp idx
|
||||||
|
const fr = this.frames(state)
|
||||||
|
.map((f) => ({ order: f.order.filter((u) => u !== userId), idx: f.idx }))
|
||||||
|
.filter((f) => f.order.length > 0)
|
||||||
|
.map((f) => ({ order: f.order, idx: Math.min(f.idx, f.order.length - 1) }));
|
||||||
|
state.frames = fr;
|
||||||
await manager.save(ChannelTurnState, state);
|
await manager.save(ChannelTurnState, state);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// A normal (non-command) message delivered to a discuss/work channel.
|
// A normal (non-command) message in a discuss/work channel.
|
||||||
async onNormalMessage(channelId: string, authorUserId: string): Promise<TurnDecision> {
|
// mentionIds = raw parsed mentions; the at-list is (mentions - author)
|
||||||
|
// intersected with channel members.
|
||||||
|
async onNormalMessage(
|
||||||
|
channelId: string,
|
||||||
|
authorUserId: string,
|
||||||
|
mentionIds: string[] = [],
|
||||||
|
): Promise<TurnDecision> {
|
||||||
return this.dataSource.transaction(async (manager) => {
|
return this.dataSource.transaction(async (manager) => {
|
||||||
const state = await this.ensureState(manager, channelId);
|
const state = await this.ensureState(manager, channelId);
|
||||||
// any normal message clears the cross-round /no-reply streak
|
|
||||||
state.norepStreak = [];
|
state.norepStreak = [];
|
||||||
|
|
||||||
|
const memberRows = await manager.find(ChannelMember, { where: { channelId } });
|
||||||
|
const memberSet = new Set(memberRows.map((m) => m.userId));
|
||||||
|
const atList = [...new Set(mentionIds)].filter(
|
||||||
|
(id) => id !== authorUserId && memberSet.has(id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const fr = this.frames(state);
|
||||||
|
|
||||||
|
// ---- a sub-frame is active
|
||||||
|
if (fr.length) {
|
||||||
|
const top = fr[fr.length - 1];
|
||||||
|
const cur = top.order[Math.min(top.idx, top.order.length - 1)];
|
||||||
|
if (authorUserId === cur) {
|
||||||
|
if (atList.length) {
|
||||||
|
this.pushFrame(state, atList);
|
||||||
|
await manager.save(ChannelTurnState, state);
|
||||||
|
return { wakeupUserId: atList[0] };
|
||||||
|
}
|
||||||
|
const next = this.advanceSubFrame(state);
|
||||||
|
await manager.save(ChannelTurnState, state);
|
||||||
|
return { wakeupUserId: next };
|
||||||
|
}
|
||||||
|
// queue-jump within the sub-frame: delivered, no advance, no push
|
||||||
|
await manager.save(ChannelTurnState, state);
|
||||||
|
return { wakeupUserId: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- root rotation active
|
||||||
const order = state.orderUserIds;
|
const order = state.orderUserIds;
|
||||||
const n = order.length;
|
const n = order.length;
|
||||||
if (n <= 1) {
|
if (n <= 1) {
|
||||||
@@ -119,7 +250,7 @@ export class TurnService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (state.currentSpeaker === null) {
|
if (state.currentSpeaker === null) {
|
||||||
// activation: mover goes to front, rotation starts at order[1]
|
// activation: mover to front, rotation starts at order[1]
|
||||||
const newOrder = [authorUserId, ...order.filter((u) => u !== authorUserId)];
|
const newOrder = [authorUserId, ...order.filter((u) => u !== authorUserId)];
|
||||||
state.orderUserIds = newOrder;
|
state.orderUserIds = newOrder;
|
||||||
state.currentSpeaker = newOrder[1];
|
state.currentSpeaker = newOrder[1];
|
||||||
@@ -130,6 +261,14 @@ export class TurnService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (authorUserId === state.currentSpeaker) {
|
if (authorUserId === state.currentSpeaker) {
|
||||||
|
// current speaker mentioning -> push a sub-frame; root pointer (this
|
||||||
|
// speaker) is left as-is and resumes after the sub-frame pops
|
||||||
|
if (atList.length) {
|
||||||
|
this.pushFrame(state, atList);
|
||||||
|
await manager.save(ChannelTurnState, state);
|
||||||
|
return { wakeupUserId: atList[0] };
|
||||||
|
}
|
||||||
|
|
||||||
const idx = order.indexOf(authorUserId);
|
const idx = order.indexOf(authorUserId);
|
||||||
const isLast = idx === n - 1;
|
const isLast = idx === n - 1;
|
||||||
state.roundEvents = [...state.roundEvents, { u: authorUserId, a: 'normal' }];
|
state.roundEvents = [...state.roundEvents, { u: authorUserId, a: 'normal' }];
|
||||||
@@ -163,14 +302,23 @@ export class TurnService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// /no-reply command in a discuss/work channel.
|
|
||||||
async onNoReply(channelId: string, senderUserId: string): Promise<CommandDecision> {
|
async onNoReply(channelId: string, senderUserId: string): Promise<CommandDecision> {
|
||||||
return this.dataSource.transaction(async (manager) => {
|
return this.dataSource.transaction(async (manager) => {
|
||||||
const state = await this.ensureState(manager, channelId);
|
const state = await this.ensureState(manager, channelId);
|
||||||
|
const fr = this.frames(state);
|
||||||
|
|
||||||
|
// sub-frame: /no-reply counts as "acted"; advance/pop, no shuffle/pause
|
||||||
|
if (fr.length) {
|
||||||
|
const top = fr[fr.length - 1];
|
||||||
|
const cur = top.order[Math.min(top.idx, top.order.length - 1)];
|
||||||
|
if (senderUserId !== cur) return { ack: null };
|
||||||
|
const next = this.advanceSubFrame(state);
|
||||||
|
await manager.save(ChannelTurnState, state);
|
||||||
|
return { ack: { wakeupUserId: next } };
|
||||||
|
}
|
||||||
|
|
||||||
const order = state.orderUserIds;
|
const order = state.orderUserIds;
|
||||||
const n = order.length;
|
const n = order.length;
|
||||||
|
|
||||||
// only the current speaker's /no-reply has any effect
|
|
||||||
if (n <= 1 || state.currentSpeaker === null || senderUserId !== state.currentSpeaker) {
|
if (n <= 1 || state.currentSpeaker === null || senderUserId !== state.currentSpeaker) {
|
||||||
return { ack: null };
|
return { ack: null };
|
||||||
}
|
}
|
||||||
@@ -182,7 +330,6 @@ export class TurnService {
|
|||||||
state.norepStreak = [...state.norepStreak, senderUserId];
|
state.norepStreak = [...state.norepStreak, senderUserId];
|
||||||
}
|
}
|
||||||
|
|
||||||
// pause when every current member has consecutively /no-reply'd
|
|
||||||
const allCovered = order.every((u) => state.norepStreak.includes(u));
|
const allCovered = order.every((u) => state.norepStreak.includes(u));
|
||||||
if (allCovered) {
|
if (allCovered) {
|
||||||
state.currentSpeaker = null;
|
state.currentSpeaker = null;
|
||||||
@@ -214,11 +361,19 @@ export class TurnService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// /force-proceed command in a discuss/work channel: skip the stuck current
|
|
||||||
// speaker (not recorded, streak untouched), advance to the next one.
|
|
||||||
async onForceProceed(channelId: string): Promise<CommandDecision> {
|
async onForceProceed(channelId: string): Promise<CommandDecision> {
|
||||||
return this.dataSource.transaction(async (manager) => {
|
return this.dataSource.transaction(async (manager) => {
|
||||||
const state = await this.ensureState(manager, channelId);
|
const state = await this.ensureState(manager, channelId);
|
||||||
|
const fr = this.frames(state);
|
||||||
|
|
||||||
|
if (fr.length) {
|
||||||
|
const top = fr[fr.length - 1];
|
||||||
|
if (!top.order.length) return { ack: null };
|
||||||
|
const next = this.advanceSubFrame(state);
|
||||||
|
await manager.save(ChannelTurnState, state);
|
||||||
|
return { ack: { wakeupUserId: next } };
|
||||||
|
}
|
||||||
|
|
||||||
const order = state.orderUserIds;
|
const order = state.orderUserIds;
|
||||||
const n = order.length;
|
const n = order.length;
|
||||||
if (n <= 1 || state.currentSpeaker === null) return { ack: null };
|
if (n <= 1 || state.currentSpeaker === null) return { ack: null };
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
37
src/cli/print-commands-sync-key.ts
Normal file
37
src/cli/print-commands-sync-key.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// Operator convenience (Guild C-2): print the commands-sync key that this
|
||||||
|
// guild process actually has in its environment, so it can be copied into
|
||||||
|
// the OpenClaw plugin's FABRIC_COMMANDS_SYNC_KEY.
|
||||||
|
//
|
||||||
|
// Usage (inside the deployed container — authoritative, reflects compose):
|
||||||
|
// docker exec fabric-backend-guild node dist/cli/print-commands-sync-key.js
|
||||||
|
// docker exec fabric-backend-guild node dist/cli/print-commands-sync-key.js --export
|
||||||
|
//
|
||||||
|
// Default: prints the raw value only (so KEY=$(... ) works).
|
||||||
|
// --export: prints `FABRIC_COMMANDS_SYNC_KEY=<value>` for pasting.
|
||||||
|
// Exit 1 (no stdout) when unset — guild is then in the weaker
|
||||||
|
// "any authenticated user" fallback for PUT /commands.
|
||||||
|
|
||||||
|
const args = new Set(process.argv.slice(2));
|
||||||
|
|
||||||
|
if (args.has('--help') || args.has('-h')) {
|
||||||
|
process.stderr.write(
|
||||||
|
'print-commands-sync-key: outputs FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY\n' +
|
||||||
|
' (no flag) print the raw key value\n' +
|
||||||
|
' --export print FABRIC_COMMANDS_SYNC_KEY=<value>\n',
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = (process.env.FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY ?? '').trim();
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
process.stderr.write(
|
||||||
|
'FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY is not set — PUT /commands is in ' +
|
||||||
|
'the fallback mode (any authenticated user). Set it to harden (Guild C-2).\n',
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write(
|
||||||
|
(args.has('--export') ? `FABRIC_COMMANDS_SYNC_KEY=${key}` : key) + '\n',
|
||||||
|
);
|
||||||
50
src/commands/commands.controller.ts
Normal file
50
src/commands/commands.controller.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
|
Headers,
|
||||||
|
Put,
|
||||||
|
Req,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { CommandsService } from './commands.service.js';
|
||||||
|
import { SyncCommandsDto } from './dto.sync-commands.dto.js';
|
||||||
|
import { safeEqual } from '../common/safe-equal.js';
|
||||||
|
|
||||||
|
type AuthedRequest = { userId?: string };
|
||||||
|
|
||||||
|
@Controller('commands')
|
||||||
|
export class CommandsController {
|
||||||
|
constructor(private readonly commands: CommandsService) {}
|
||||||
|
|
||||||
|
// Guild C-2: catalog write is privileged. When
|
||||||
|
// FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY is configured (recommended in
|
||||||
|
// production), the caller must present a matching x-commands-sync-key
|
||||||
|
// header — this restricts writes to the OpenClaw plugin. When unset, we
|
||||||
|
// fall back to "any authenticated agent/user" (never weaker than before).
|
||||||
|
// The body is always strictly validated + size-capped via SyncCommandsDto.
|
||||||
|
@Put()
|
||||||
|
sync(
|
||||||
|
@Req() req: AuthedRequest,
|
||||||
|
@Body() body: SyncCommandsDto,
|
||||||
|
@Headers('x-commands-sync-key') syncKey?: string,
|
||||||
|
) {
|
||||||
|
const configured = process.env.FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY ?? '';
|
||||||
|
if (configured) {
|
||||||
|
if (!syncKey || !safeEqual(syncKey, configured)) {
|
||||||
|
throw new ForbiddenException('invalid commands sync key');
|
||||||
|
}
|
||||||
|
} else if (!req.userId) {
|
||||||
|
throw new UnauthorizedException('missing user');
|
||||||
|
}
|
||||||
|
return this.commands.sync(body.commands as unknown[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frontend reads the catalog to drive `/` autocomplete.
|
||||||
|
@Get()
|
||||||
|
list(@Req() req: AuthedRequest) {
|
||||||
|
if (!req.userId) throw new UnauthorizedException('missing user');
|
||||||
|
return this.commands.list();
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/commands/commands.module.ts
Normal file
12
src/commands/commands.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { GuildCommand } from '../entities/guild-command.entity.js';
|
||||||
|
import { CommandsController } from './commands.controller.js';
|
||||||
|
import { CommandsService } from './commands.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([GuildCommand])],
|
||||||
|
controllers: [CommandsController],
|
||||||
|
providers: [CommandsService],
|
||||||
|
})
|
||||||
|
export class CommandsModule {}
|
||||||
39
src/commands/commands.service.ts
Normal file
39
src/commands/commands.service.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { GuildCommand } from '../entities/guild-command.entity.js';
|
||||||
|
|
||||||
|
// This node's guild id (one guild per node).
|
||||||
|
function guildId(): string {
|
||||||
|
return process.env.FABRIC_BACKEND_GUILD_NODE_ID ?? 'guild';
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CommandsService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(GuildCommand)
|
||||||
|
private readonly repo: Repository<GuildCommand>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// Replace the whole guild-global slash-command catalog (idempotent;
|
||||||
|
// the plugin re-PUTs the full set on every gateway start).
|
||||||
|
async sync(commands: unknown[]): Promise<{ status: string; count: number }> {
|
||||||
|
const gid = guildId();
|
||||||
|
let row = await this.repo.findOne({ where: { guildId: gid } });
|
||||||
|
if (row) {
|
||||||
|
row.commands = commands;
|
||||||
|
} else {
|
||||||
|
row = this.repo.create({ guildId: gid, commands });
|
||||||
|
}
|
||||||
|
await this.repo.save(row);
|
||||||
|
return { status: 'ok', count: commands.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(): Promise<{ commands: unknown[]; updatedAt: string | null }> {
|
||||||
|
const row = await this.repo.findOne({ where: { guildId: guildId() } });
|
||||||
|
return {
|
||||||
|
commands: row?.commands ?? [],
|
||||||
|
updatedAt: row?.updatedAt ? row.updatedAt.toISOString() : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/commands/dto.sync-commands.dto.ts
Normal file
102
src/commands/dto.sync-commands.dto.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
// Guild C-2: the slash-command catalog is guild-global and rendered by the
|
||||||
|
// frontend `/` autocomplete. Without a strict schema + caps a single
|
||||||
|
// authenticated caller could poison it or blow up the DB / clients.
|
||||||
|
// The global ValidationPipe runs with { whitelist, forbidNonWhitelisted },
|
||||||
|
// so any unknown field is rejected.
|
||||||
|
|
||||||
|
class CommandChoiceDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
value!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
label!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CommandArgDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(40)
|
||||||
|
type?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
required?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
captureRemaining?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
preferAutocomplete?: boolean;
|
||||||
|
|
||||||
|
// null when there are no choices (plugin sends explicit null).
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(100)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CommandChoiceDto)
|
||||||
|
choices?: CommandChoiceDto[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CommandSpecDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
nativeName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
acceptsArgs?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(50)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CommandArgDto)
|
||||||
|
args?: CommandArgDto[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
argsParsing?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SyncCommandsDto {
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(200)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CommandSpecDto)
|
||||||
|
commands!: CommandSpecDto[];
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,17 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { introspectGuildToken } from './center-auth';
|
import { introspectGuildToken } from './center-auth.js';
|
||||||
|
import { safeEqual } from './safe-equal.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ApiKeyGuard implements CanActivate {
|
export class ApiKeyGuard implements CanActivate {
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const req = context.switchToHttp().getRequest<{ path?: string; headers: Record<string, string | string[] | undefined> }>();
|
const req = context.switchToHttp().getRequest<{
|
||||||
|
path?: string;
|
||||||
|
headers: Record<string, string | string[] | undefined>;
|
||||||
|
query?: Record<string, string | string[] | undefined>;
|
||||||
|
}>();
|
||||||
const path = req.path ?? '';
|
const path = req.path ?? '';
|
||||||
|
|
||||||
// allow health check without auth
|
// allow health check without auth
|
||||||
@@ -17,9 +22,34 @@ export class ApiKeyGuard implements CanActivate {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// System-key bypass: when a caller presents x-fabric-system-key
|
||||||
|
// matching FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY (intentionally the
|
||||||
|
// same shared secret as x-commands-sync-key — both legitimate
|
||||||
|
// consumers are Fabric.OpenclawPlugin), skip the Bearer requirement
|
||||||
|
// and mark this as a system caller. Downstream handlers (e.g.
|
||||||
|
// messaging.controller POST /channels/:id/messages) gate on
|
||||||
|
// req.isSystem to take the system-author code path.
|
||||||
|
//
|
||||||
|
// Empty env → bypass disabled (no system caller ever valid; closed
|
||||||
|
// by default). Header carries the secret as-is; we constant-time
|
||||||
|
// compare against the env value.
|
||||||
|
const sysExpected = (process.env.FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY ?? '').trim();
|
||||||
|
const sysHeader = req.headers['x-fabric-system-key'];
|
||||||
|
const sysProvided = Array.isArray(sysHeader) ? sysHeader[0] : sysHeader;
|
||||||
|
if (sysExpected && sysProvided && safeEqual(sysProvided, sysExpected)) {
|
||||||
|
(req as { isSystem?: boolean }).isSystem = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const auth = req.headers['authorization'];
|
const auth = req.headers['authorization'];
|
||||||
const authValue = Array.isArray(auth) ? auth[0] : auth;
|
const authValue = Array.isArray(auth) ? auth[0] : auth;
|
||||||
const token = authValue?.startsWith('Bearer ') ? authValue.slice(7) : '';
|
let token = authValue?.startsWith('Bearer ') ? authValue.slice(7) : '';
|
||||||
|
// Browsers can't set Authorization on <img>/<a> (file downloads); accept
|
||||||
|
// the guild token via ?access_token= as a fallback. Still introspected.
|
||||||
|
if (!token) {
|
||||||
|
const qt = req.query?.['access_token'];
|
||||||
|
token = (Array.isArray(qt) ? qt[0] : qt) ?? '';
|
||||||
|
}
|
||||||
if (!token) throw new UnauthorizedException('missing bearer token');
|
if (!token) throw new UnauthorizedException('missing bearer token');
|
||||||
|
|
||||||
const result = await introspectGuildToken(token);
|
const result = await introspectGuildToken(token);
|
||||||
|
|||||||
@@ -25,3 +25,50 @@ export async function introspectGuildToken(token: string): Promise<{ active: boo
|
|||||||
user: data.user,
|
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 {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
import { MetricsService } from './metrics.service';
|
import { MetricsService } from './metrics.service.js';
|
||||||
|
|
||||||
@Controller('metrics')
|
@Controller('metrics')
|
||||||
export class MetricsController {
|
export class MetricsController {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { NextFunction, Request, Response } from 'express';
|
import { NextFunction, Request, Response } from 'express';
|
||||||
import { MetricsService } from './metrics.service';
|
import { MetricsService } from './metrics.service.js';
|
||||||
|
|
||||||
type ReqWithId = Request & { requestId?: string };
|
type ReqWithId = Request & { requestId?: string };
|
||||||
|
|
||||||
|
|||||||
25
src/common/safe-equal.spec.ts
Normal file
25
src/common/safe-equal.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { safeEqual } from './safe-equal.js';
|
||||||
|
|
||||||
|
describe('safeEqual', () => {
|
||||||
|
it('returns true for identical non-empty strings', () => {
|
||||||
|
expect(safeEqual('abc123', 'abc123')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for different strings of same length', () => {
|
||||||
|
expect(safeEqual('abc123', 'abc124')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for differing lengths', () => {
|
||||||
|
expect(safeEqual('abc', 'abcd')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty strings', () => {
|
||||||
|
// both empty is technically equal — but downstream callers should
|
||||||
|
// explicitly check for empty expected before invoking. We just
|
||||||
|
// document the constant-time-comparison primitive's behavior.
|
||||||
|
expect(safeEqual('', '')).toBe(true);
|
||||||
|
expect(safeEqual('a', '')).toBe(false);
|
||||||
|
expect(safeEqual('', 'a')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
12
src/common/safe-equal.ts
Normal file
12
src/common/safe-equal.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
// Constant-time string comparison. Returns false for length mismatch (the
|
||||||
|
// length difference itself is observable, but the per-byte loop isn't).
|
||||||
|
// Used for shared-secret header checks (commands-sync-key, system-key,
|
||||||
|
// etc.) to keep timing-oracle attacks off the table.
|
||||||
|
export function safeEqual(a: string, b: string): boolean {
|
||||||
|
const ab = Buffer.from(a);
|
||||||
|
const bb = Buffer.from(b);
|
||||||
|
if (ab.length !== bb.length) return false;
|
||||||
|
return timingSafeEqual(ab, bb);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import { DataSource, DataSourceOptions } from 'typeorm';
|
import { DataSource, DataSourceOptions } from 'typeorm';
|
||||||
import { buildTypeOrmConfig } from './database.config';
|
import { buildTypeOrmConfig } from './database.config.js';
|
||||||
|
|
||||||
const cfg = buildTypeOrmConfig();
|
const cfg = buildTypeOrmConfig();
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||||
import { Guild } from './entities/guild.entity';
|
import { Guild } from './entities/guild.entity.js';
|
||||||
import { Channel } from './entities/channel.entity';
|
import { Channel } from './entities/channel.entity.js';
|
||||||
import { ChannelMember } from './entities/channel-member.entity';
|
import { ChannelMember } from './entities/channel-member.entity.js';
|
||||||
import { WakeMapping } from './entities/wake-mapping.entity';
|
import { WakeMapping } from './entities/wake-mapping.entity.js';
|
||||||
import { ChannelTurnState } from './entities/channel-turn-state.entity';
|
import { ChannelTurnState } from './entities/channel-turn-state.entity.js';
|
||||||
import { Message } from './entities/message.entity';
|
import { Message } from './entities/message.entity.js';
|
||||||
import { DmConversation } from './entities/dm-conversation.entity';
|
import { DmConversation } from './entities/dm-conversation.entity.js';
|
||||||
import { DmParticipant } from './entities/dm-participant.entity';
|
import { DmParticipant } from './entities/dm-participant.entity.js';
|
||||||
import { GuildRole } from './entities/guild-role.entity';
|
import { GuildRole } from './entities/guild-role.entity.js';
|
||||||
import { GuildMember } from './entities/guild-member.entity';
|
import { GuildMember } from './entities/guild-member.entity.js';
|
||||||
import { GuildMemberRole } from './entities/guild-member-role.entity';
|
import { GuildMemberRole } from './entities/guild-member-role.entity.js';
|
||||||
import { IdempotencyRecord } from './entities/idempotency-record.entity';
|
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 => ({
|
export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
|
||||||
type: 'mysql',
|
type: 'mysql',
|
||||||
@@ -32,6 +36,10 @@ export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
|
|||||||
GuildMember,
|
GuildMember,
|
||||||
GuildMemberRole,
|
GuildMemberRole,
|
||||||
IdempotencyRecord,
|
IdempotencyRecord,
|
||||||
|
StoredFile,
|
||||||
|
ChannelCanvas,
|
||||||
|
GuildCommand,
|
||||||
|
AgentPresence,
|
||||||
],
|
],
|
||||||
synchronize: (process.env.FABRIC_BACKEND_GUILD_DB_SYNC ?? 'true') === 'true',
|
synchronize: (process.env.FABRIC_BACKEND_GUILD_DB_SYNC ?? 'true') === 'true',
|
||||||
logging: (process.env.FABRIC_BACKEND_GUILD_DB_LOGGING ?? 'false') === 'true',
|
logging: (process.env.FABRIC_BACKEND_GUILD_DB_LOGGING ?? 'false') === 'true',
|
||||||
|
|||||||
35
src/entities/agent-presence.entity.ts
Normal file
35
src/entities/agent-presence.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
46
src/entities/channel-canvas.entity.ts
Normal file
46
src/entities/channel-canvas.entity.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export type CanvasFormat = 'md' | 'html' | 'text';
|
||||||
|
|
||||||
|
// One active shared document per channel (ChatGPT-canvas-like). Re-sharing
|
||||||
|
// replaces it; only the original sharer may update it in place. Pinned in
|
||||||
|
// the channel UI, independent of the message scroll.
|
||||||
|
@Entity('channel_canvas')
|
||||||
|
export class ChannelCanvas {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Index({ unique: true })
|
||||||
|
@Column({ name: 'channel_id', type: 'char', length: 36 })
|
||||||
|
channelId!: string;
|
||||||
|
|
||||||
|
// who shared it; only this user may PATCH/DELETE
|
||||||
|
@Column({ name: 'sharer_user_id', type: 'varchar', length: 64 })
|
||||||
|
sharerUserId!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 200 })
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 8 })
|
||||||
|
format!: CanvasFormat;
|
||||||
|
|
||||||
|
// raw document source (rendered client-side per format)
|
||||||
|
@Column({ type: 'mediumtext' })
|
||||||
|
source!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'int', default: 1 })
|
||||||
|
version!: number;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
||||||
@@ -2,6 +2,11 @@ import { Column, Entity, Index, PrimaryGeneratedColumn, UpdateDateColumn } from
|
|||||||
|
|
||||||
export type RoundEvent = { u: string; a: 'normal' | 'noreply' };
|
export type RoundEvent = { u: string; a: 'normal' | 'noreply' };
|
||||||
|
|
||||||
|
// A mention sub-rotation frame pushed on top of the root rotation.
|
||||||
|
// currentSpeaker of an active sub-frame = order[idx]. Single linear pass:
|
||||||
|
// after the member at the last index acts, the frame pops.
|
||||||
|
export type TurnFrame = { order: string[]; idx: number };
|
||||||
|
|
||||||
// Per-channel rotation state for discuss/work x_type channels.
|
// Per-channel rotation state for discuss/work x_type channels.
|
||||||
// All mutations must be serialized per channel (pessimistic row lock).
|
// All mutations must be serialized per channel (pessimistic row lock).
|
||||||
@Entity('channel_turn_state')
|
@Entity('channel_turn_state')
|
||||||
@@ -13,10 +18,17 @@ export class ChannelTurnState {
|
|||||||
@Column({ name: 'channel_id', type: 'char', length: 36 })
|
@Column({ name: 'channel_id', type: 'char', length: 36 })
|
||||||
channelId!: string;
|
channelId!: string;
|
||||||
|
|
||||||
// speaking order; userIds
|
// speaking order; userIds. order and bypass are a DISJOINT partition of
|
||||||
|
// the channel's members.
|
||||||
@Column({ name: 'order_user_ids', type: 'json' })
|
@Column({ name: 'order_user_ids', type: 'json' })
|
||||||
orderUserIds!: string[];
|
orderUserIds!: string[];
|
||||||
|
|
||||||
|
// members excluded from rotation: never woken by normal rotation, only when
|
||||||
|
// @-mentioned (then transiently pulled into a sub-frame; back to bypass on
|
||||||
|
// pop). discuss/work only.
|
||||||
|
@Column({ name: 'bypass_user_ids', type: 'json', nullable: true })
|
||||||
|
bypassUserIds!: string[] | null;
|
||||||
|
|
||||||
// null = paused (created, or all-members-consecutively-/no-reply)
|
// null = paused (created, or all-members-consecutively-/no-reply)
|
||||||
@Column({ name: 'current_speaker', type: 'varchar', length: 64, nullable: true })
|
@Column({ name: 'current_speaker', type: 'varchar', length: 64, nullable: true })
|
||||||
currentSpeaker!: string | null;
|
currentSpeaker!: string | null;
|
||||||
@@ -36,6 +48,12 @@ export class ChannelTurnState {
|
|||||||
@Column({ name: 'last_normal_speaker', type: 'varchar', length: 64, nullable: true })
|
@Column({ name: 'last_normal_speaker', type: 'varchar', length: 64, nullable: true })
|
||||||
lastNormalSpeaker!: string | null;
|
lastNormalSpeaker!: string | null;
|
||||||
|
|
||||||
|
// mention sub-rotation stack on top of the root rotation. Empty = root
|
||||||
|
// active. Top of stack is the active frame; the root rotation
|
||||||
|
// (order/currentSpeaker/round/streak) is paused while it is non-empty.
|
||||||
|
@Column({ name: 'frames', type: 'json', nullable: true })
|
||||||
|
frames!: TurnFrame[] | null;
|
||||||
|
|
||||||
@UpdateDateColumn()
|
@UpdateDateColumn()
|
||||||
updatedAt!: Date;
|
updatedAt!: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,13 +16,23 @@ export class Channel {
|
|||||||
@Column({
|
@Column({
|
||||||
name: 'x_type',
|
name: 'x_type',
|
||||||
type: 'enum',
|
type: 'enum',
|
||||||
enum: ['general', 'work', 'report', 'discuss', 'triage', 'custom'],
|
enum: ['general', 'work', 'report', 'discuss', 'triage', 'custom', 'dm', 'announce'],
|
||||||
})
|
})
|
||||||
xType!: 'general' | 'work' | 'report' | 'discuss' | 'triage' | 'custom';
|
xType!: 'general' | 'work' | 'report' | 'discuss' | 'triage' | 'custom' | 'dm' | 'announce';
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 16, default: 'text' })
|
@Column({ type: 'varchar', length: 16, default: 'text' })
|
||||||
kind!: 'text' | 'announcement';
|
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 })
|
@Column({ type: 'boolean', default: false })
|
||||||
isPrivate!: boolean;
|
isPrivate!: boolean;
|
||||||
|
|
||||||
@@ -31,6 +41,10 @@ export class Channel {
|
|||||||
@Column({ type: 'boolean', default: false })
|
@Column({ type: 'boolean', default: false })
|
||||||
isPublic!: boolean;
|
isPublic!: boolean;
|
||||||
|
|
||||||
|
// closed (e.g. discussion-complete): history readable, new posts rejected
|
||||||
|
@Column({ type: 'boolean', default: false })
|
||||||
|
closed!: boolean;
|
||||||
|
|
||||||
@Index()
|
@Index()
|
||||||
@Column({ default: 0 })
|
@Column({ default: 0 })
|
||||||
lastSeq!: number;
|
lastSeq!: number;
|
||||||
|
|||||||
25
src/entities/guild-command.entity.ts
Normal file
25
src/entities/guild-command.entity.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Column, Entity, Index, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
|
||||||
|
|
||||||
|
// Guild-global slash-command catalog. One row per guild (this node's
|
||||||
|
// FABRIC_BACKEND_GUILD_NODE_ID). The OpenClaw plugin PUTs the OpenClaw
|
||||||
|
// native-command specs here (the same data Discord registers as slash
|
||||||
|
// commands); the frontend GETs it to drive `/` autocomplete. The guild
|
||||||
|
// node stores the catalog opaquely — it does not interpret command bodies.
|
||||||
|
@Entity('guild_commands')
|
||||||
|
export class GuildCommand {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Index({ unique: true })
|
||||||
|
@Column({ name: 'guild_id', type: 'varchar', length: 80 })
|
||||||
|
guildId!: string;
|
||||||
|
|
||||||
|
// NativeCommandSpec[]-shaped (name, nativeName, description, acceptsArgs,
|
||||||
|
// args[{name,description,type,required,choices:[{value,label}],
|
||||||
|
// captureRemaining,preferAutocomplete}], argsParsing). Stored verbatim.
|
||||||
|
@Column({ type: 'json' })
|
||||||
|
commands!: unknown[];
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
||||||
43
src/entities/stored-file.entity.ts
Normal file
43
src/entities/stored-file.entity.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
|
||||||
|
// An uploaded or canvas-shared file held on the guild node. Retained for a
|
||||||
|
// configurable window (default 7 days) then purged by FilesService.
|
||||||
|
@Entity('stored_files')
|
||||||
|
export class StoredFile {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
// public, URL-safe id used in /api/files/:fileId
|
||||||
|
@Index({ unique: true })
|
||||||
|
@Column({ name: 'file_id', type: 'varchar', length: 64 })
|
||||||
|
fileId!: string;
|
||||||
|
|
||||||
|
// owning channel (best-effort context; null = not channel-scoped)
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'channel_id', type: 'char', length: 36, nullable: true })
|
||||||
|
channelId!: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'uploader_user_id', type: 'varchar', length: 64 })
|
||||||
|
uploaderUserId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'original_name', type: 'varchar', length: 255 })
|
||||||
|
originalName!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'mime_type', type: 'varchar', length: 150 })
|
||||||
|
mimeType!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'size_bytes', type: 'bigint' })
|
||||||
|
sizeBytes!: number;
|
||||||
|
|
||||||
|
// path on disk relative to the storage root
|
||||||
|
@Column({ name: 'storage_path', type: 'varchar', length: 300 })
|
||||||
|
storagePath!: string;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
// hard-delete deadline; rows past this are purged with their blob
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'expires_at', type: 'datetime' })
|
||||||
|
expiresAt!: Date;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
import { EventsService } from './events.service';
|
import { EventsService } from './events.service.js';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { createHmac, randomUUID } from 'crypto';
|
import { createHmac, randomUUID } from 'crypto';
|
||||||
import { FabricEventEnvelope } from './event-envelope';
|
import { FabricEventEnvelope } from './event-envelope.js';
|
||||||
|
|
||||||
type RetryTask = {
|
type RetryTask = {
|
||||||
envelope: FabricEventEnvelope;
|
envelope: FabricEventEnvelope;
|
||||||
|
|||||||
84
src/files/files.controller.ts
Normal file
84
src/files/files.controller.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
UnauthorizedException,
|
||||||
|
UploadedFile,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
import { FilesService } from './files.service.js';
|
||||||
|
|
||||||
|
type AuthedRequest = { userId?: string };
|
||||||
|
type UploadedMulterFile = {
|
||||||
|
originalname: string;
|
||||||
|
mimetype: string;
|
||||||
|
size: number;
|
||||||
|
buffer: Buffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Controller('files')
|
||||||
|
export class FilesController {
|
||||||
|
constructor(private readonly files: FilesService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
async upload(
|
||||||
|
@Req() req: AuthedRequest,
|
||||||
|
@UploadedFile() file: UploadedMulterFile | undefined,
|
||||||
|
@Query('channelId') channelId?: string,
|
||||||
|
) {
|
||||||
|
const userId = req.userId ?? '';
|
||||||
|
if (!userId) throw new UnauthorizedException('missing user');
|
||||||
|
if (!file || !file.buffer?.length) throw new BadRequestException('no file');
|
||||||
|
if (this.files.maxBytes > 0 && file.size > this.files.maxBytes) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`file exceeds limit of ${this.files.maxBytes} bytes`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const row = await this.files.store({
|
||||||
|
channelId: channelId ? String(channelId) : null,
|
||||||
|
uploaderUserId: userId,
|
||||||
|
originalName: file.originalname || 'file',
|
||||||
|
mimeType: file.mimetype,
|
||||||
|
buffer: file.buffer,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
fileId: row.fileId,
|
||||||
|
url: `/api/files/${row.fileId}`,
|
||||||
|
name: row.originalName,
|
||||||
|
mimeType: row.mimeType,
|
||||||
|
size: Number(row.sizeBytes),
|
||||||
|
expiresAt: row.expiresAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':fileId')
|
||||||
|
async download(
|
||||||
|
@Param('fileId') fileId: string,
|
||||||
|
@Res() res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const row = await this.files.find(fileId);
|
||||||
|
if (!row) {
|
||||||
|
res.status(404).json({ error: 'file_not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blob = await this.files.readBlob(row);
|
||||||
|
const inline = /^(image|audio|video)\//.test(row.mimeType) || row.mimeType === 'application/pdf';
|
||||||
|
const safeName = row.originalName.replace(/["\r\n]/g, '_');
|
||||||
|
res.setHeader('Content-Type', row.mimeType);
|
||||||
|
res.setHeader('Content-Length', String(blob.length));
|
||||||
|
res.setHeader(
|
||||||
|
'Content-Disposition',
|
||||||
|
`${inline ? 'inline' : 'attachment'}; filename="${safeName}"`,
|
||||||
|
);
|
||||||
|
res.setHeader('Cache-Control', 'private, max-age=3600');
|
||||||
|
res.end(blob);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/files/files.module.ts
Normal file
13
src/files/files.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { StoredFile } from '../entities/stored-file.entity.js';
|
||||||
|
import { FilesController } from './files.controller.js';
|
||||||
|
import { FilesService } from './files.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([StoredFile])],
|
||||||
|
controllers: [FilesController],
|
||||||
|
providers: [FilesService],
|
||||||
|
exports: [FilesService],
|
||||||
|
})
|
||||||
|
export class FilesModule {}
|
||||||
98
src/files/files.service.ts
Normal file
98
src/files/files.service.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { LessThan, Repository } from 'typeorm';
|
||||||
|
import { StoredFile } from '../entities/stored-file.entity.js';
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // hourly
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FilesService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly log = new Logger('FilesService');
|
||||||
|
private timer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
// Storage root; the guild operator may relocate / resize this freely.
|
||||||
|
readonly dir = resolve(
|
||||||
|
process.env.FABRIC_BACKEND_GUILD_FILE_DIR ?? join(process.cwd(), '.data', 'files'),
|
||||||
|
);
|
||||||
|
// 0 / unset => no cap (default per product: 100MB, operator-configurable).
|
||||||
|
readonly maxBytes = Number(
|
||||||
|
process.env.FABRIC_BACKEND_GUILD_FILE_MAX_BYTES ?? 100 * 1024 * 1024,
|
||||||
|
);
|
||||||
|
readonly ttlDays = Number(process.env.FABRIC_BACKEND_GUILD_FILE_TTL_DAYS ?? 7);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(StoredFile)
|
||||||
|
private readonly repo: Repository<StoredFile>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
await fs.mkdir(this.dir, { recursive: true });
|
||||||
|
this.log.log(
|
||||||
|
`files dir=${this.dir} maxBytes=${this.maxBytes} ttlDays=${this.ttlDays}`,
|
||||||
|
);
|
||||||
|
// sweep on boot, then hourly
|
||||||
|
void this.cleanup();
|
||||||
|
this.timer = setInterval(() => void this.cleanup(), CLEANUP_INTERVAL_MS);
|
||||||
|
this.timer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleDestroy(): void {
|
||||||
|
if (this.timer) clearInterval(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async store(input: {
|
||||||
|
channelId: string | null;
|
||||||
|
uploaderUserId: string;
|
||||||
|
originalName: string;
|
||||||
|
mimeType: string;
|
||||||
|
buffer: Buffer;
|
||||||
|
}): Promise<StoredFile> {
|
||||||
|
const fileId = randomBytes(18).toString('base64url');
|
||||||
|
const storagePath = fileId; // flat layout, opaque name
|
||||||
|
await fs.writeFile(join(this.dir, storagePath), input.buffer);
|
||||||
|
const row = this.repo.create({
|
||||||
|
fileId,
|
||||||
|
channelId: input.channelId,
|
||||||
|
uploaderUserId: input.uploaderUserId,
|
||||||
|
originalName: input.originalName.slice(0, 255),
|
||||||
|
mimeType: (input.mimeType || 'application/octet-stream').slice(0, 150),
|
||||||
|
sizeBytes: input.buffer.length,
|
||||||
|
storagePath,
|
||||||
|
expiresAt: new Date(Date.now() + this.ttlDays * DAY_MS),
|
||||||
|
});
|
||||||
|
return this.repo.save(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async find(fileId: string): Promise<StoredFile | null> {
|
||||||
|
const row = await this.repo.findOne({ where: { fileId } });
|
||||||
|
if (!row) return null;
|
||||||
|
if (row.expiresAt.getTime() <= Date.now()) return null; // treat as gone
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async readBlob(row: StoredFile): Promise<Buffer> {
|
||||||
|
return fs.readFile(join(this.dir, row.storagePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purge every row past its retention deadline together with its blob.
|
||||||
|
async cleanup(): Promise<number> {
|
||||||
|
const expired = await this.repo.find({ where: { expiresAt: LessThan(new Date()) } });
|
||||||
|
let removed = 0;
|
||||||
|
for (const row of expired) {
|
||||||
|
try {
|
||||||
|
await fs.rm(join(this.dir, row.storagePath), { force: true });
|
||||||
|
} catch {
|
||||||
|
/* best effort: drop the row regardless */
|
||||||
|
}
|
||||||
|
await this.repo.delete({ id: row.id });
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
if (removed) this.log.log(`retention sweep removed ${removed} expired file(s)`);
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Body, Controller, Get, Post } from '@nestjs/common';
|
import { Body, Controller, Get, Post } from '@nestjs/common';
|
||||||
import { GuildsService } from './guilds.service';
|
import { GuildsService } from './guilds.service.js';
|
||||||
|
|
||||||
@Controller('guilds')
|
@Controller('guilds')
|
||||||
export class GuildsController {
|
export class GuildsController {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { GuildsController } from './guilds.controller';
|
import { GuildsController } from './guilds.controller.js';
|
||||||
import { Guild } from '../entities/guild.entity';
|
import { Guild } from '../entities/guild.entity.js';
|
||||||
import { GuildsService } from './guilds.service';
|
import { GuildsService } from './guilds.service.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Guild])],
|
imports: [TypeOrmModule.forFeature([Guild])],
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { Guild } from '../entities/guild.entity';
|
import { Guild } from '../entities/guild.entity.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GuildsService {
|
export class GuildsService {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Test } from '@nestjs/testing';
|
|||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { Channel } from './entities/channel.entity';
|
import { Channel } from './entities/channel.entity.js';
|
||||||
|
|
||||||
process.env.DB_HOST = '127.0.0.1';
|
process.env.DB_HOST = '127.0.0.1';
|
||||||
process.env.DB_PORT = '3308';
|
process.env.DB_PORT = '3308';
|
||||||
@@ -18,7 +18,7 @@ describe('guild integration (mysql + api)', () => {
|
|||||||
let dataSource: DataSource;
|
let dataSource: DataSource;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const { AppModule } = await import('./app.module');
|
const { AppModule } = await import('./app.module.js');
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
imports: [AppModule],
|
imports: [AppModule],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import 'reflect-metadata';
|
|||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module.js';
|
||||||
import { createRequestContextMiddleware } from './common/request-context.middleware';
|
import { createRequestContextMiddleware } from './common/request-context.middleware.js';
|
||||||
import { MetricsService } from './common/metrics.service';
|
import { MetricsService } from './common/metrics.service.js';
|
||||||
|
|
||||||
function requireEnv(name: string): string {
|
function requireEnv(name: string): string {
|
||||||
const value = process.env[name];
|
const value = process.env[name];
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Controller, Get, Query } from '@nestjs/common';
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
import { MembersService } from './members.service';
|
import { MembersService } from './members.service.js';
|
||||||
|
|
||||||
@Controller('members')
|
@Controller('members')
|
||||||
export class MembersController {
|
export class MembersController {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { GuildMember } from '../entities/guild-member.entity';
|
import { GuildMember } from '../entities/guild-member.entity.js';
|
||||||
import { MembersController } from './members.controller';
|
import { MembersController } from './members.controller.js';
|
||||||
import { MembersService } from './members.service';
|
import { MembersService } from './members.service.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([GuildMember])],
|
imports: [TypeOrmModule.forFeature([GuildMember])],
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { GuildMember } from '../entities/guild-member.entity';
|
import { GuildMember } from '../entities/guild-member.entity.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MembersService {
|
export class MembersService {
|
||||||
|
|||||||
@@ -56,4 +56,14 @@ export class CreateMessageDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(64)
|
@MaxLength(64)
|
||||||
authorUserId?: string;
|
authorUserId?: string;
|
||||||
|
|
||||||
|
// System-author path only (x-fabric-system-key gated). When set, the
|
||||||
|
// message is delivered via emitMessageTargeted so this single recipient
|
||||||
|
// gets wakeup=true; everyone else in the channel sees wakeup=false. For
|
||||||
|
// regular (user-bearer) posts this field is ignored. Used by
|
||||||
|
// close-sub-discussion to precisely wake the host on callback.
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
wakeupUserId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
|
ConflictException,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
|
ForbiddenException,
|
||||||
Get,
|
Get,
|
||||||
Headers,
|
Headers,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
@@ -9,19 +11,24 @@ import {
|
|||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
|
Req,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { DataSource, Repository } from 'typeorm';
|
import { DataSource, Repository } from 'typeorm';
|
||||||
import { CreateMessageDto } from './dto.create-message.dto';
|
import { CreateMessageDto } from './dto.create-message.dto.js';
|
||||||
import { Channel } from '../entities/channel.entity';
|
import { Channel } from '../entities/channel.entity.js';
|
||||||
import { Message } from '../entities/message.entity';
|
import { ChannelMember } from '../entities/channel-member.entity.js';
|
||||||
import { IdempotencyRecord } from '../entities/idempotency-record.entity';
|
import { Message } from '../entities/message.entity.js';
|
||||||
import { WakeMapping } from '../entities/wake-mapping.entity';
|
import { IdempotencyRecord } from '../entities/idempotency-record.entity.js';
|
||||||
import { parseSlashCommand } from '../channels/slash-commands';
|
import { WakeMapping } from '../entities/wake-mapping.entity.js';
|
||||||
import { TurnService } from '../channels/turn.service';
|
import { AdminCacheService } from '../common/admin-cache.service.js';
|
||||||
import { EventsService } from '../events/events.service';
|
import { parseSlashCommand } from '../channels/slash-commands.js';
|
||||||
import { clampLimit, computeNextExpectedSeq } from './pagination.util';
|
import { parseMentions, extractNameMentions, replaceNameMentions } from '../channels/mentions.js';
|
||||||
import { RealtimeGateway } from '../realtime/realtime.gateway';
|
import { resolveUserNames } from '../common/center-auth.js';
|
||||||
|
import { TurnService } from '../channels/turn.service.js';
|
||||||
|
import { EventsService } from '../events/events.service.js';
|
||||||
|
import { clampLimit, computeNextExpectedSeq } from './pagination.util.js';
|
||||||
|
import { RealtimeGateway } from '../realtime/realtime.gateway.js';
|
||||||
|
|
||||||
const EDIT_WINDOW_MS = 15 * 60 * 1000;
|
const EDIT_WINDOW_MS = 15 * 60 * 1000;
|
||||||
const DEFAULT_PAGE_LIMIT = 50;
|
const DEFAULT_PAGE_LIMIT = 50;
|
||||||
@@ -33,6 +40,8 @@ export class MessagingController {
|
|||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
@InjectRepository(Channel)
|
@InjectRepository(Channel)
|
||||||
private readonly channelRepo: Repository<Channel>,
|
private readonly channelRepo: Repository<Channel>,
|
||||||
|
@InjectRepository(ChannelMember)
|
||||||
|
private readonly memberRepo: Repository<ChannelMember>,
|
||||||
@InjectRepository(Message)
|
@InjectRepository(Message)
|
||||||
private readonly messageRepo: Repository<Message>,
|
private readonly messageRepo: Repository<Message>,
|
||||||
@InjectRepository(IdempotencyRecord)
|
@InjectRepository(IdempotencyRecord)
|
||||||
@@ -42,6 +51,7 @@ export class MessagingController {
|
|||||||
private readonly turn: TurnService,
|
private readonly turn: TurnService,
|
||||||
private readonly events: EventsService,
|
private readonly events: EventsService,
|
||||||
private readonly realtime: RealtimeGateway,
|
private readonly realtime: RealtimeGateway,
|
||||||
|
private readonly adminCache: AdminCacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async getIdempotentResponse(
|
private async getIdempotentResponse(
|
||||||
@@ -83,6 +93,19 @@ export class MessagingController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Channel-participant gate (Guild C-1): public channels are readable/
|
||||||
|
// writable by any authenticated user; private channels require explicit
|
||||||
|
// channel_members membership. Returns the channel so callers can reuse it.
|
||||||
|
private async assertParticipant(channelId: string, userId: string): Promise<Channel> {
|
||||||
|
const channel = await this.channelRepo.findOne({ where: { id: channelId } });
|
||||||
|
if (!channel) throw new NotFoundException('channel not found');
|
||||||
|
if (channel.isPublic) return channel;
|
||||||
|
if (!userId) throw new ForbiddenException('not a channel member');
|
||||||
|
const member = await this.memberRepo.findOne({ where: { channelId, userId } });
|
||||||
|
if (!member) throw new ForbiddenException('not a channel member');
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
|
||||||
// Persists one message (allocates a seq under a channel row lock) and
|
// Persists one message (allocates a seq under a channel row lock) and
|
||||||
// returns its view. Used for normal messages and for guild /ack messages.
|
// returns its view. Used for normal messages and for guild /ack messages.
|
||||||
private async persistMessage(
|
private async persistMessage(
|
||||||
@@ -133,20 +156,99 @@ export class MessagingController {
|
|||||||
async create(
|
async create(
|
||||||
@Param('id') channelId: string,
|
@Param('id') channelId: string,
|
||||||
@Body() body: CreateMessageDto,
|
@Body() body: CreateMessageDto,
|
||||||
|
@Req() req: { userId?: string; isSystem?: boolean },
|
||||||
@Headers('idempotency-key') idempotencyKey?: string,
|
@Headers('idempotency-key') idempotencyKey?: string,
|
||||||
) {
|
) {
|
||||||
const scope = `POST:/channels/${channelId}/messages`;
|
const scope = `POST:/channels/${channelId}/messages`;
|
||||||
const existed = await this.getIdempotentResponse(scope, idempotencyKey);
|
const existed = await this.getIdempotentResponse(scope, idempotencyKey);
|
||||||
if (existed) return existed;
|
if (existed) return existed;
|
||||||
|
|
||||||
const channel = await this.channelRepo.findOne({ where: { id: channelId } });
|
// System caller (ApiKeyGuard set isSystem from x-fabric-system-key):
|
||||||
if (!channel) throw new NotFoundException('channel not found');
|
// skip the per-user participant check; resolve channel directly.
|
||||||
|
// System posts are allowed into any non-closed channel — used by
|
||||||
|
// Fabric.OpenclawPlugin to write `close-sub-discussion` callbacks
|
||||||
|
// back to a parent channel that the host agent may not be currently
|
||||||
|
// "in" from the backend's perspective, and to deliver guide-injected
|
||||||
|
// system intros into sub-discussion channels without needing to log
|
||||||
|
// in as a real user. Author is a sentinel UUID that no real user
|
||||||
|
// ever has; `wakeupUserId` (optional) lets the caller precisely wake
|
||||||
|
// one recipient (e.g. the host of a closing sub-discussion).
|
||||||
|
if (req.isSystem) {
|
||||||
|
const sysChannel = await this.channelRepo.findOne({ where: { id: channelId } });
|
||||||
|
if (!sysChannel) throw new NotFoundException('channel not found');
|
||||||
|
if (sysChannel.closed) {
|
||||||
|
throw new ConflictException({ error: 'channel_closed', message: 'channel is closed' });
|
||||||
|
}
|
||||||
|
const SYSTEM_USER_ID = '00000000-0000-0000-0000-000000000000';
|
||||||
|
let sysContent = body.content ?? '';
|
||||||
|
const sysNames = extractNameMentions(sysContent);
|
||||||
|
if (sysNames.length) {
|
||||||
|
const nameMap = await resolveUserNames(sysNames);
|
||||||
|
sysContent = replaceNameMentions(sysContent, nameMap);
|
||||||
|
}
|
||||||
|
const sysMessage = await this.persistMessage(channelId, {
|
||||||
|
authorUserId: SYSTEM_USER_ID,
|
||||||
|
content: sysContent,
|
||||||
|
clientMessageId: body.clientMessageId,
|
||||||
|
replyToMessageId: body.replyToMessageId,
|
||||||
|
mentions: body.mentions,
|
||||||
|
attachments: body.attachments,
|
||||||
|
});
|
||||||
|
const sysView = this.toView(sysMessage) as Record<string, unknown>;
|
||||||
|
await this.saveIdempotentResponse(scope, idempotencyKey, sysView);
|
||||||
|
await this.events.emit({
|
||||||
|
eventType: 'message.created',
|
||||||
|
channelId,
|
||||||
|
actorId: SYSTEM_USER_ID,
|
||||||
|
data: sysView,
|
||||||
|
});
|
||||||
|
// wakeupUserId set -> emitMessageTargeted wakes exactly that user
|
||||||
|
// (everyone else gets the same message with wakeup=false).
|
||||||
|
// wakeupUserId omitted/null -> emitMessageCreated routes via the
|
||||||
|
// channel's xType-specific 3-state delivery with empty wakeSet, so
|
||||||
|
// nobody is woken (the message lands in history only).
|
||||||
|
const wakeupUserId = typeof body.wakeupUserId === 'string' ? body.wakeupUserId.trim() : '';
|
||||||
|
if (wakeupUserId) {
|
||||||
|
await this.realtime.emitMessageTargeted(channelId, sysView, wakeupUserId);
|
||||||
|
} else {
|
||||||
|
await this.realtime.emitMessageCreated(channelId, sysView, {
|
||||||
|
xType: sysChannel.xType ?? 'general',
|
||||||
|
authorUserId: SYSTEM_USER_ID,
|
||||||
|
wakeUserIds: new Set<string>(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return sysView;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
if (channel.closed) {
|
||||||
|
throw new ConflictException({ error: 'channel_closed', message: 'channel is closed' });
|
||||||
|
}
|
||||||
const xType = channel.xType ?? 'general';
|
const xType = channel.xType ?? 'general';
|
||||||
const isRotating = xType === 'discuss' || xType === 'work';
|
const isRotating = xType === 'discuss' || xType === 'work';
|
||||||
const authorUserId = String(body.authorUserId ?? 'anonymous');
|
const authorUserId = userId;
|
||||||
|
|
||||||
|
// ---- translate <@user.name:NAME> -> <@userId> (outside backticks) via
|
||||||
|
// Center before anything else persists/parses the content
|
||||||
|
let content = body.content ?? '';
|
||||||
|
const names = extractNameMentions(content);
|
||||||
|
if (names.length) {
|
||||||
|
const map = await resolveUserNames(names);
|
||||||
|
content = replaceNameMentions(content, map);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- command interception: registered slash commands are never delivered
|
// ---- command interception: registered slash commands are never delivered
|
||||||
const cmd = parseSlashCommand(body.content ?? '');
|
const cmd = parseSlashCommand(content);
|
||||||
if (cmd) {
|
if (cmd) {
|
||||||
if (isRotating && cmd.name === 'no-reply') {
|
if (isRotating && cmd.name === 'no-reply') {
|
||||||
const { ack } = await this.turn.onNoReply(channelId, authorUserId);
|
const { ack } = await this.turn.onNoReply(channelId, authorUserId);
|
||||||
@@ -162,7 +264,7 @@ export class MessagingController {
|
|||||||
// ---- normal message
|
// ---- normal message
|
||||||
const message = await this.persistMessage(channelId, {
|
const message = await this.persistMessage(channelId, {
|
||||||
authorUserId,
|
authorUserId,
|
||||||
content: body.content,
|
content,
|
||||||
clientMessageId: body.clientMessageId,
|
clientMessageId: body.clientMessageId,
|
||||||
replyToMessageId: body.replyToMessageId,
|
replyToMessageId: body.replyToMessageId,
|
||||||
mentions: body.mentions,
|
mentions: body.mentions,
|
||||||
@@ -179,18 +281,27 @@ export class MessagingController {
|
|||||||
data: responseBody,
|
data: responseBody,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// mentions: <@id> outside backtick spans (post name-translation)
|
||||||
|
const mentionIds = parseMentions(content);
|
||||||
|
|
||||||
if (isRotating) {
|
if (isRotating) {
|
||||||
// discuss/work: rotation decides the single wakeup target
|
// discuss/work: rotation (incl. mention sub-frames) picks the target
|
||||||
const decision = await this.turn.onNormalMessage(channelId, authorUserId);
|
const decision = await this.turn.onNormalMessage(channelId, authorUserId, mentionIds);
|
||||||
await this.realtime.emitMessageTargeted(channelId, responseBody, decision.wakeupUserId);
|
await this.realtime.emitMessageTargeted(channelId, responseBody, decision.wakeupUserId);
|
||||||
} else {
|
} else {
|
||||||
// general/report/triage/custom: wakeup from x_type + wake_mapping
|
// 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 wakeRows = await this.wakeRepo.find({ where: { channelId } });
|
||||||
const wakeUserIds = new Set(wakeRows.map((w) => w.userId));
|
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, {
|
await this.realtime.emitMessageCreated(channelId, responseBody, {
|
||||||
xType,
|
xType,
|
||||||
authorUserId,
|
authorUserId,
|
||||||
wakeUserIds,
|
wakeUserIds,
|
||||||
|
mentionUserIds,
|
||||||
|
adminUserId: admin?.userId ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,14 +313,23 @@ export class MessagingController {
|
|||||||
@Param('id') channelId: string,
|
@Param('id') channelId: string,
|
||||||
@Param('messageId') messageId: string,
|
@Param('messageId') messageId: string,
|
||||||
@Body() body: { content?: string },
|
@Body() body: { content?: string },
|
||||||
|
@Req() req: { userId?: string },
|
||||||
@Headers('idempotency-key') idempotencyKey?: string,
|
@Headers('idempotency-key') idempotencyKey?: string,
|
||||||
) {
|
) {
|
||||||
const scope = `PATCH:/channels/${channelId}/messages/${messageId}`;
|
const scope = `PATCH:/channels/${channelId}/messages/${messageId}`;
|
||||||
const existed = await this.getIdempotentResponse(scope, idempotencyKey);
|
const existed = await this.getIdempotentResponse(scope, idempotencyKey);
|
||||||
if (existed) return existed;
|
if (existed) return existed;
|
||||||
|
|
||||||
|
// Guild C-1: participant + author-ownership.
|
||||||
|
const userId = String(req.userId ?? '');
|
||||||
|
if (!userId) throw new ForbiddenException('missing user');
|
||||||
|
await this.assertParticipant(channelId, userId);
|
||||||
|
|
||||||
const item = await this.messageRepo.findOne({ where: { channelId, messageId } });
|
const item = await this.messageRepo.findOne({ where: { channelId, messageId } });
|
||||||
if (!item) return { status: 'not_found' };
|
if (!item) return { status: 'not_found' };
|
||||||
|
if (item.authorUserId !== userId) {
|
||||||
|
throw new ForbiddenException('not the message author');
|
||||||
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const createdAt = new Date(item.createdAt).getTime();
|
const createdAt = new Date(item.createdAt).getTime();
|
||||||
@@ -238,14 +358,23 @@ export class MessagingController {
|
|||||||
async remove(
|
async remove(
|
||||||
@Param('id') channelId: string,
|
@Param('id') channelId: string,
|
||||||
@Param('messageId') messageId: string,
|
@Param('messageId') messageId: string,
|
||||||
|
@Req() req: { userId?: string },
|
||||||
@Headers('idempotency-key') idempotencyKey?: string,
|
@Headers('idempotency-key') idempotencyKey?: string,
|
||||||
) {
|
) {
|
||||||
const scope = `DELETE:/channels/${channelId}/messages/${messageId}`;
|
const scope = `DELETE:/channels/${channelId}/messages/${messageId}`;
|
||||||
const existed = await this.getIdempotentResponse(scope, idempotencyKey);
|
const existed = await this.getIdempotentResponse(scope, idempotencyKey);
|
||||||
if (existed) return existed;
|
if (existed) return existed;
|
||||||
|
|
||||||
|
// Guild C-1: participant + author-ownership.
|
||||||
|
const userId = String(req.userId ?? '');
|
||||||
|
if (!userId) throw new ForbiddenException('missing user');
|
||||||
|
await this.assertParticipant(channelId, userId);
|
||||||
|
|
||||||
const item = await this.messageRepo.findOne({ where: { channelId, messageId } });
|
const item = await this.messageRepo.findOne({ where: { channelId, messageId } });
|
||||||
if (!item) return { status: 'not_found' };
|
if (!item) return { status: 'not_found' };
|
||||||
|
if (item.authorUserId !== userId) {
|
||||||
|
throw new ForbiddenException('not the message author');
|
||||||
|
}
|
||||||
|
|
||||||
item.isDeleted = true;
|
item.isDeleted = true;
|
||||||
item.deletedAt = new Date();
|
item.deletedAt = new Date();
|
||||||
@@ -283,10 +412,14 @@ export class MessagingController {
|
|||||||
@Get()
|
@Get()
|
||||||
async listBySeq(
|
async listBySeq(
|
||||||
@Param('id') channelId: string,
|
@Param('id') channelId: string,
|
||||||
|
@Req() req: { userId?: string },
|
||||||
@Query('seq_from') seqFrom?: string,
|
@Query('seq_from') seqFrom?: string,
|
||||||
@Query('seq_to') seqTo?: string,
|
@Query('seq_to') seqTo?: string,
|
||||||
@Query('limit') limit?: string,
|
@Query('limit') limit?: string,
|
||||||
) {
|
) {
|
||||||
|
// Guild C-1: only participants may read channel history.
|
||||||
|
const userId = String(req.userId ?? '');
|
||||||
|
if (!userId) throw new ForbiddenException('missing user');
|
||||||
const from = seqFrom ? Number(seqFrom) : 1;
|
const from = seqFrom ? Number(seqFrom) : 1;
|
||||||
const to = seqTo ? Number(seqTo) : Number.MAX_SAFE_INTEGER;
|
const to = seqTo ? Number(seqTo) : Number.MAX_SAFE_INTEGER;
|
||||||
const safeLimit = clampLimit(limit, DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT);
|
const safeLimit = clampLimit(limit, DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT);
|
||||||
@@ -306,10 +439,7 @@ export class MessagingController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const channel = await this.channelRepo.findOne({ where: { id: channelId } });
|
const channel = await this.assertParticipant(channelId, userId);
|
||||||
if (!channel) {
|
|
||||||
throw new NotFoundException('channel not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const qb = this.messageRepo
|
const qb = this.messageRepo
|
||||||
.createQueryBuilder('m')
|
.createQueryBuilder('m')
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { MessagingController } from './messaging.controller';
|
import { MessagingController } from './messaging.controller.js';
|
||||||
import { Channel } from '../entities/channel.entity';
|
import { Channel } from '../entities/channel.entity.js';
|
||||||
import { Message } from '../entities/message.entity';
|
import { ChannelMember } from '../entities/channel-member.entity.js';
|
||||||
import { IdempotencyRecord } from '../entities/idempotency-record.entity';
|
import { Message } from '../entities/message.entity.js';
|
||||||
import { WakeMapping } from '../entities/wake-mapping.entity';
|
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({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Channel, Message, IdempotencyRecord, WakeMapping])],
|
imports: [TypeOrmModule.forFeature([Channel, ChannelMember, Message, IdempotencyRecord, WakeMapping])],
|
||||||
controllers: [MessagingController],
|
controllers: [MessagingController],
|
||||||
|
providers: [AdminCacheService],
|
||||||
|
exports: [AdminCacheService],
|
||||||
})
|
})
|
||||||
export class MessagingModule {}
|
export class MessagingModule {}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { clampLimit, computeNextExpectedSeq } from './pagination.util';
|
import { clampLimit, computeNextExpectedSeq } from './pagination.util.js';
|
||||||
|
|
||||||
describe('pagination utils', () => {
|
describe('pagination utils', () => {
|
||||||
it('clamps limit safely', () => {
|
it('clamps limit safely', () => {
|
||||||
|
|||||||
@@ -9,34 +9,104 @@ import {
|
|||||||
} from '@nestjs/websockets';
|
} from '@nestjs/websockets';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { Server, Socket } from 'socket.io';
|
import { Server, Socket } from 'socket.io';
|
||||||
import { introspectGuildToken } from '../common/center-auth';
|
import { introspectGuildToken } from '../common/center-auth.js';
|
||||||
|
|
||||||
type XType = 'general' | 'work' | 'report' | 'discuss' | 'triage' | 'custom';
|
type XType = 'general' | 'work' | 'report' | 'discuss' | 'triage' | 'custom' | 'dm' | 'announce';
|
||||||
|
|
||||||
// Wakeup for non-rotating channels only (general/report/triage/custom).
|
/**
|
||||||
// discuss/work go through TurnService + emitMessageTargeted, never here.
|
* Cross-presence info needed by `announce`-type delivery: a recipient
|
||||||
// Precedence:
|
* with hf-side status === 'busy' has the message discarded silently
|
||||||
// 1. the author never gets woken by their own message
|
* (don't enter their session, no UI emit). Other statuses + non-announce
|
||||||
// 2. triage/custom: only wake users in the channel's wake_mapping
|
* channels are unaffected. Presence is sourced from the
|
||||||
// 3. general: wake everyone
|
* `agent_presences` table populated by Fabric.OpenclawPlugin's
|
||||||
// 4. report (and anything else): wake nobody
|
* 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.
|
||||||
|
*
|
||||||
|
* • `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;
|
||||||
|
/** 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, recipientPresence } = 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';
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use computeDelivery (returns 3-state). Kept for any
|
||||||
|
* external callers; treats 'observer' and 'skip' both as `false`.
|
||||||
|
*/
|
||||||
export function computeWakeup(args: {
|
export function computeWakeup(args: {
|
||||||
xType: XType;
|
xType: XType;
|
||||||
recipientUserId: string;
|
recipientUserId: string;
|
||||||
authorUserId: string;
|
authorUserId: string;
|
||||||
wakeUserIds: Set<string>;
|
wakeUserIds: Set<string>;
|
||||||
|
mentionUserIds?: Set<string>;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
const { xType, recipientUserId, authorUserId, wakeUserIds } = args;
|
return computeDelivery(args) === 'wake';
|
||||||
if (recipientUserId === authorUserId) return false;
|
|
||||||
switch (xType) {
|
|
||||||
case 'general':
|
|
||||||
return true;
|
|
||||||
case 'triage':
|
|
||||||
case 'custom':
|
|
||||||
return wakeUserIds.has(recipientUserId);
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@WebSocketGateway({
|
@WebSocketGateway({
|
||||||
@@ -52,6 +122,12 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect
|
|||||||
private readonly logger = new Logger(RealtimeGateway.name);
|
private readonly logger = new Logger(RealtimeGateway.name);
|
||||||
private readonly onlineUsers = new Set<string>();
|
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 {
|
private userIdFromClient(client: Socket): string {
|
||||||
const authUser = client.handshake.auth?.userId;
|
const authUser = client.handshake.auth?.userId;
|
||||||
const headerUser = client.handshake.headers['x-user-id'];
|
const headerUser = client.handshake.headers['x-user-id'];
|
||||||
@@ -87,6 +163,10 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect
|
|||||||
const userId = result.user.id || this.userIdFromClient(client);
|
const userId = result.user.id || this.userIdFromClient(client);
|
||||||
client.data.userId = userId;
|
client.data.userId = userId;
|
||||||
this.onlineUsers.add(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', {
|
this.server.emit('presence.online', {
|
||||||
userId,
|
userId,
|
||||||
onlineCount: this.onlineUsers.size,
|
onlineCount: this.onlineUsers.size,
|
||||||
@@ -162,22 +242,62 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect
|
|||||||
this.server.to(`channel:${channelId}`).emit(event, data);
|
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, and by announce
|
||||||
|
// channels to suppress delivery to recipients whose presence is busy.
|
||||||
async emitMessageCreated(
|
async emitMessageCreated(
|
||||||
channelId: string,
|
channelId: string,
|
||||||
data: Record<string, unknown>,
|
data: Record<string, unknown>,
|
||||||
ctx: { xType: XType; authorUserId: string; wakeUserIds: Set<string> },
|
ctx: {
|
||||||
|
xType: XType;
|
||||||
|
authorUserId: string;
|
||||||
|
wakeUserIds: Set<string>;
|
||||||
|
mentionUserIds?: Set<string>;
|
||||||
|
/** Single Center-scoped admin userId (or null). */
|
||||||
|
adminUserId?: string | null;
|
||||||
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const sockets = await this.server.in(`channel:${channelId}`).fetchSockets();
|
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) {
|
for (const s of sockets) {
|
||||||
const recipientUserId = typeof s.data.userId === 'string' ? s.data.userId : `anon:${s.id}`;
|
const recipientUserId = typeof s.data.userId === 'string' ? s.data.userId : `anon:${s.id}`;
|
||||||
const wakeup = computeWakeup({
|
const decision = computeDelivery({
|
||||||
xType: ctx.xType,
|
xType: ctx.xType,
|
||||||
recipientUserId,
|
recipientUserId,
|
||||||
authorUserId: ctx.authorUserId,
|
authorUserId: ctx.authorUserId,
|
||||||
wakeUserIds: ctx.wakeUserIds,
|
wakeUserIds: ctx.wakeUserIds,
|
||||||
|
mentionUserIds: ctx.mentionUserIds,
|
||||||
|
adminUserId: ctx.adminUserId,
|
||||||
|
recipientPresence: presenceMap?.get(recipientUserId) ?? 'unknown',
|
||||||
|
});
|
||||||
|
if (decision === 'skip') continue;
|
||||||
|
s.emit('message.created', {
|
||||||
|
...data,
|
||||||
|
channelId,
|
||||||
|
wakeup: decision === 'wake',
|
||||||
|
xType: ctx.xType,
|
||||||
});
|
});
|
||||||
s.emit('message.created', { ...data, wakeup });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +312,7 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect
|
|||||||
for (const s of sockets) {
|
for (const s of sockets) {
|
||||||
const recipientUserId = typeof s.data.userId === 'string' ? s.data.userId : `anon:${s.id}`;
|
const recipientUserId = typeof s.data.userId === 'string' ? s.data.userId : `anon:${s.id}`;
|
||||||
const wakeup = wakeupUserId !== null && recipientUserId === wakeupUserId;
|
const wakeup = wakeupUserId !== null && recipientUserId === wakeupUserId;
|
||||||
s.emit('message.created', { ...data, wakeup });
|
s.emit('message.created', { ...data, channelId, wakeup });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,25 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module, OnModuleInit } from '@nestjs/common';
|
||||||
import { RealtimeGateway } from './realtime.gateway';
|
import { RealtimeGateway } from './realtime.gateway.js';
|
||||||
|
import { AgentPresenceModule } from '../agents/agent-presence.module.js';
|
||||||
|
import { AgentPresenceService } from '../agents/agent-presence.service.js';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [AgentPresenceModule],
|
||||||
providers: [RealtimeGateway],
|
providers: [RealtimeGateway],
|
||||||
exports: [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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"module": "commonjs",
|
"module": "NodeNext",
|
||||||
"target": "es2020",
|
"moduleResolution": "NodeNext",
|
||||||
|
"target": "es2022",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user