Files
Fabric.Backend.Guild/src/channels/channels.controller.ts
hzhang cb7b3bb5fe feat(channel-discovery): add purpose column + PATCH /api/channels/:id
Adds a free-form 'purpose' text field on Channel so agents (or anyone
creating a channel via API) can describe what the channel is for —
'debate broadcasts', 'security alerts', etc. — and other agents can
later find the right channel by intent rather than channel id.

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

Frontend create form continues to omit the field — purpose stays optional.
This pairs with Fabric.OpenclawPlugin's fabric-channel-set-purpose tool +
fabric-channel-list returning purpose, so agent workflows can say 'find
an announce channel about X' instead of pinning a UUID.
2026-05-23 19:22:00 +01:00

106 lines
3.9 KiB
TypeScript

import { BadRequestException, Body, Controller, Get, Param, Patch, Post, Query, Req, UnauthorizedException } from '@nestjs/common';
import { ChannelsService } from './channels.service.js';
// ApiKeyGuard attaches the introspected Center user id onto the request.
type AuthedRequest = { userId?: string };
@Controller('channels')
export class ChannelsController {
constructor(private readonly channelsService: ChannelsService) {}
@Get()
list(@Req() req: AuthedRequest, @Query('guildId') guildId?: string) {
const userId = req.userId ?? '';
if (!userId) throw new UnauthorizedException('missing user');
return this.channelsService.listForUser(String(guildId ?? ''), userId);
}
@Post()
create(@Req() req: AuthedRequest, @Body() body: Record<string, unknown>) {
const userId = req.userId ?? '';
if (!userId) throw new UnauthorizedException('missing user');
return this.channelsService.create(
{
guildId: body.guildId as string | undefined,
name: body.name as string | undefined,
kind: body.kind as string | undefined,
xType: body.xType as string | undefined,
isPublic: Boolean(body.isPublic),
memberUserIds: Array.isArray(body.memberUserIds) ? (body.memberUserIds as string[]) : [],
onDuty: body.onDuty as string | undefined,
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,
);
}
// 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')
members(@Req() req: AuthedRequest, @Param('id') channelId: string) {
const userId = req.userId ?? '';
if (!userId) throw new UnauthorizedException('missing user');
return this.channelsService.channelMembers(channelId);
}
@Post(':id/join')
join(@Req() req: AuthedRequest, @Param('id') channelId: string) {
const userId = req.userId ?? '';
if (!userId) throw new UnauthorizedException('missing user');
return this.channelsService.joinChannel(channelId, userId);
}
@Post(':id/leave')
leave(@Req() req: AuthedRequest, @Param('id') channelId: string) {
const userId = req.userId ?? '';
if (!userId) throw new UnauthorizedException('missing user');
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);
}
}