From cb7b3bb5fef527ee2178134d1370ab814bcc7caa Mon Sep 17 00:00:00 2001 From: hzhang Date: Sat, 23 May 2026 19:22:00 +0100 Subject: [PATCH] feat(channel-discovery): add purpose column + PATCH /api/channels/:id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/channels/channels.controller.ts | 24 +++++++++++++++++++++++- src/channels/channels.service.ts | 29 +++++++++++++++++++++++++++++ src/entities/channel.entity.ts | 10 ++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/channels/channels.controller.ts b/src/channels/channels.controller.ts index f0b83e0..2eed767 100644 --- a/src/channels/channels.controller.ts +++ b/src/channels/channels.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Post, Query, Req, UnauthorizedException } from '@nestjs/common'; +import { BadRequestException, Body, Controller, Get, Param, Patch, Post, Query, Req, UnauthorizedException } from '@nestjs/common'; import { ChannelsService } from './channels.service.js'; // ApiKeyGuard attaches the introspected Center user id onto the request. @@ -32,11 +32,33 @@ export class ChannelsController { bypassUserIds: Array.isArray(body.bypassUserIds) ? (body.bypassUserIds as string[]) : [], + purpose: body.purpose as string | undefined, }, userId, ); } + // Patch a channel's free-form purpose. Body: { purpose: string }. Pass + // empty string to clear. Auth: channel member (or anyone for public + // channels, mirroring close()). Frontend doesn't call this today — + // intended for agent-side use (fabric-channel-set-purpose tool). + @Patch(':id') + patch( + @Req() req: AuthedRequest, + @Param('id') channelId: string, + @Body() body: Record, + ) { + 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( diff --git a/src/channels/channels.service.ts b/src/channels/channels.service.ts index 3952e25..a962e35 100644 --- a/src/channels/channels.service.ts +++ b/src/channels/channels.service.ts @@ -24,6 +24,10 @@ type CreateChannelInput = { // discuss/work only: members excluded from rotation (no wakeup unless // @-mentioned). order and bypass partition the members disjointly. bypassUserIds?: string[]; + // Free-form description of what this channel is for. Optional; agents + // typically fill it when creating, members can later edit via + // PATCH /api/channels/:id. + purpose?: string; }; @Injectable() @@ -166,6 +170,9 @@ export class ChannelsService { // allowed (create() always makes a fresh one, no dedup). const isPublic = xType === 'dm' ? false : Boolean(input.isPublic); + const purposeRaw = String(input.purpose ?? '').trim(); + const purpose = purposeRaw === '' ? null : purposeRaw; + const channel = await this.channelRepo.save( this.channelRepo.create({ guildId, @@ -174,6 +181,7 @@ export class ChannelsService { kind: input.kind === 'announcement' ? 'announcement' : 'text', isPrivate: !isPublic, isPublic, + purpose, lastSeq: 0, }), ); @@ -219,6 +227,27 @@ export class ChannelsService { return channel; } + // Update a channel's free-form purpose. Any channel member may do this + // (or any guild user if the channel is public, mirroring closeChannel's + // member-or-public rule). Pass an empty string to clear. + async updatePurpose(channelId: string, actorUserId: string, purpose: string) { + const channel = await this.channelRepo.findOne({ where: { id: channelId } }); + if (!channel) throw new NotFoundException('channel not found'); + const member = await this.memberRepo.findOne({ where: { channelId, userId: actorUserId } }); + if (!member && !channel.isPublic) { + throw new ForbiddenException('not a channel member'); + } + const trimmed = String(purpose ?? '').trim(); + channel.purpose = trimmed === '' ? null : trimmed; + const saved = await this.channelRepo.save(channel); + return { + id: saved.id, + name: saved.name, + xType: saved.xType, + purpose: saved.purpose, + }; + } + // Move an order member into the bypass list (discuss/work only). // Any channel member may do this. async moveToBypass(channelId: string, actorUserId: string, targetUserId: string) { diff --git a/src/entities/channel.entity.ts b/src/entities/channel.entity.ts index d37c1da..f7cd1b8 100644 --- a/src/entities/channel.entity.ts +++ b/src/entities/channel.entity.ts @@ -23,6 +23,16 @@ export class Channel { @Column({ type: 'varchar', length: 16, default: 'text' }) kind!: 'text' | 'announcement'; + // Free-form description of what this channel is for — what topics get + // posted, who participates, why it exists. Surfaced via GET /api/channels + // so agents can pick a channel by intent ("which announce channel is for + // debate broadcasts?") without channel id hard-coded into workflows. + // Any channel member can set it via PATCH /api/channels/:id (writes + // require membership the same way moveToBypass / close do). The frontend + // create form does NOT post this today — purpose stays optional. + @Column({ type: 'text', nullable: true }) + purpose!: string | null; + @Column({ type: 'boolean', default: false }) isPrivate!: boolean;