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.
This commit is contained in:
@@ -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';
|
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.
|
||||||
@@ -32,11 +32,33 @@ export class ChannelsController {
|
|||||||
bypassUserIds: Array.isArray(body.bypassUserIds)
|
bypassUserIds: Array.isArray(body.bypassUserIds)
|
||||||
? (body.bypassUserIds as string[])
|
? (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).
|
// Move an order member into the bypass list (discuss/work only).
|
||||||
@Post(':id/bypass')
|
@Post(':id/bypass')
|
||||||
bypass(
|
bypass(
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ type CreateChannelInput = {
|
|||||||
// discuss/work only: members excluded from rotation (no wakeup unless
|
// discuss/work only: members excluded from rotation (no wakeup unless
|
||||||
// @-mentioned). order and bypass partition the members disjointly.
|
// @-mentioned). order and bypass partition the members disjointly.
|
||||||
bypassUserIds?: string[];
|
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()
|
||||||
@@ -166,6 +170,9 @@ export class ChannelsService {
|
|||||||
// allowed (create() always makes a fresh one, no dedup).
|
// allowed (create() always makes a fresh one, no dedup).
|
||||||
const isPublic = xType === 'dm' ? false : Boolean(input.isPublic);
|
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,
|
||||||
@@ -174,6 +181,7 @@ export class ChannelsService {
|
|||||||
kind: input.kind === 'announcement' ? 'announcement' : 'text',
|
kind: input.kind === 'announcement' ? 'announcement' : 'text',
|
||||||
isPrivate: !isPublic,
|
isPrivate: !isPublic,
|
||||||
isPublic,
|
isPublic,
|
||||||
|
purpose,
|
||||||
lastSeq: 0,
|
lastSeq: 0,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -219,6 +227,27 @@ export class ChannelsService {
|
|||||||
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).
|
// Move an order member into the bypass list (discuss/work only).
|
||||||
// Any channel member may do this.
|
// Any channel member may do this.
|
||||||
async moveToBypass(channelId: string, actorUserId: string, targetUserId: string) {
|
async moveToBypass(channelId: string, actorUserId: string, targetUserId: string) {
|
||||||
|
|||||||
@@ -23,6 +23,16 @@ export class Channel {
|
|||||||
@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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user