diff --git a/src/buzz-mcp/tools.test.ts b/src/buzz-mcp/tools.test.ts index be6d611b5b..978e8e6072 100644 --- a/src/buzz-mcp/tools.test.ts +++ b/src/buzz-mcp/tools.test.ts @@ -1006,7 +1006,15 @@ function relay(refused: Readonly> = {}) { } if (kind === 9000 || kind === 9021) { const key = kind === 9021 ? me : target; - const role = template.tags.find((t) => t[0] === "role")?.[1] ?? "member"; + // Like the relay, with no role asked for a member keeps theirs and a + // new one gets the channel's join_role. + const tags = (kind: number) => + state.find((e) => e.kind === kind && e.tags[0]?.[1] === id)?.tags ?? []; + const role = + template.tags.find((t) => t[0] === "role")?.[1] ?? + tags(39002).find((t) => t[0] === "p" && t[1] === key)?.[3] ?? + tags(39000).find((t) => t[0] === "join_role")?.[1] ?? + "member"; put(39002, id, (tags) => [...without(tags, key), ["p", key, "", role]]); } if (kind === 9001 || kind === 9022) @@ -1720,9 +1728,29 @@ it("refuses channel arguments that do not fit, before publishing", async () => { await expect( channel("channel_create", { name: "x", visibility: "open", ttl: 1.5 }), ).rejects.toThrow("ttl must be a whole number of seconds."); + await expect( + channel("channel_create", { + name: "x", + visibility: "open", + join_role: "admin", + }), + ).rejects.toThrow("join_role must be member or guest."); + await expect( + channel("channel_create", { + name: "x", + visibility: "private", + join_role: "guest", + }), + ).rejects.toThrow("join_role guest is for open channels."); + await expect( + channel("channel_update", { visibility: "private", join_role: "guest" }), + ).rejects.toThrow("join_role guest is for open channels."); await expect(channel("channel_update", {})).rejects.toThrow( - "Give name, about, visibility, ttl, topic or purpose.", + "Give name, about, visibility, ttl, join_role, topic or purpose.", ); + await expect( + channel("channel_update", { join_role: "owner" }), + ).rejects.toThrow("join_role must be member or guest."); await expect( callTool(client, { channel: "chan" }, "channel_leave", {}), ).rejects.toThrow("Invalid channel ID"); @@ -1760,6 +1788,151 @@ it("shows a channel's details and changes them as the channel shows", async () = ); }); +it("makes a channel an announce channel and back", async () => { + const { client, published } = relay(); + const context = { channel: room }; + await expect( + callTool(client, context, "channel_update", { join_role: "guest" }), + ).resolves.toBe(`updated ${room}`); + await expect(callTool(client, context, "channel_get", {})).resolves.toBe( + `${room} plans (stream, open, join as guest)\nabout: roadmap\nyou: owner`, + ); + await expect( + callTool(client, context, "channel_update", { join_role: "member" }), + ).resolves.toBe(`updated ${room}`); + expect(published.map((template) => template.tags)).toEqual([ + [ + ["h", room], + ["join_role", "guest"], + ], + [ + ["h", room], + ["join_role", "member"], + ], + ]); +}); + +it("creates an announce channel after adding its template's agents", async () => { + const { client, published, state } = relay(); + client.templates = vi.fn(async () => [ + { name: "Team", canvas: "", agents: [alice] }, + ]); + const answer = await callTool(client, {}, "channel_create", { + name: "team", + visibility: "open", + join_role: "guest", + template: "Team", + }); + const id = published[0]?.tags[0]?.[1] as string; + // Set last: an agent added with no role would otherwise join as a guest. + expect(published.map((template) => [template.kind, template.tags])).toEqual([ + [ + 9007, + [ + ["h", id], + ["name", "team"], + ["visibility", "open"], + ["channel_type", "stream"], + ], + ], + [ + 9000, + [ + ["h", id], + ["p", alice], + ], + ], + [ + 9002, + [ + ["h", id], + ["join_role", "guest"], + ], + ], + ]); + expect(answer).toBe( + [ + `created ${id}`, + `added ${alice} Alice Smith`, + "new people join as guests", + ].join("\n"), + ); + await expect( + callTool(client, { channel: id }, "channel_get", {}), + ).resolves.toBe(`${id} team (stream, open, join as guest)\nyou: owner`); + const roster = () => + state.find((e) => e.kind === 39002 && e.tags[0]?.[1] === id)?.tags; + expect(roster()).toContainEqual(["p", alice, "", "member"]); + // Now anyone added with no role is a guest, and the answer says so. + await expect( + callTool(client, { channel: id }, "members", { add: [bob] }), + ).resolves.toBe(`added ${bob} as guest`); + expect(roster()).toContainEqual(["p", bob, "", "guest"]); +}); + +it("sets join_role when the template's Canvas is refused, and says when it cannot", async () => { + const { client, published } = relay(); + const publish = client.publish; + client.publish = vi.fn(async (template: Template) => { + if (template.kind === 40100) throw new Error("canvas refused"); + return publish(template); + }); + client.templates = vi.fn(async () => [ + { name: "Team", canvas: "# Plan", agents: [alice] }, + ]); + const create = () => + callTool(client, {}, "channel_create", { + name: "team", + visibility: "open", + join_role: "guest", + template: "Team", + }); + let answer = await create(); + let id = published[0]?.tags[0]?.[1] as string; + expect(published.map((template) => template.kind)).toEqual([9007, 9002]); + expect(answer).toBe( + `created ${id}\nCanvas not started, so no agents were added: canvas refused\nnew people join as guests`, + ); + const query = client.query; + client.query = vi.fn(async (filters: readonly Filter[]) => { + if (filters.some((f) => (f.kinds as number[]).includes(39000))) + throw new Error("kind 39000 unavailable"); + return query(filters); + }); + answer = await create(); + id = published.at(-1)?.tags[0]?.[1] as string; + expect(published.at(-1)?.kind).toBe(9007); + expect(answer).toBe( + `The community accepted channel ${id} but its state could not be read back: kind 39000 unavailable; the template was not applied; join_role was not set.`, + ); +}); + +it("reports a new channel whose join_role was refused", async () => { + const { client, published } = relay(); + const publish = client.publish; + client.publish = vi.fn(async (template: Template) => { + if (template.kind === 9002) throw new Error("restricted: admins only"); + return publish(template); + }); + const answer = await callTool(client, {}, "channel_create", { + name: "news", + visibility: "open", + join_role: "guest", + }); + const id = published[0]?.tags[0]?.[1] as string; + expect(published.map((template) => template.kind)).toEqual([9007]); + expect(answer).toBe( + `created ${id}\njoin_role not set: restricted: admins only`, + ); + // member is the default, so it needs no second event. + await callTool(client, {}, "channel_create", { + name: "chat", + visibility: "open", + join_role: "member", + }); + expect(published.map((template) => template.kind)).toEqual([9007, 9007]); +}); + it("leaves, joins, archives, unarchives and deletes a channel", async () => { const { client, published } = relay(); const context = { channel: room }; diff --git a/src/buzz-mcp/tools.ts b/src/buzz-mcp/tools.ts index 63649ac2b4..ef4980c0c1 100644 --- a/src/buzz-mcp/tools.ts +++ b/src/buzz-mcp/tools.ts @@ -25,6 +25,7 @@ import { attestedOwner } from "../features/agents/owner-attestation"; import type { BuzzClient, BuzzEvent, + ChannelTemplate, Context, Filter, Memory, @@ -51,6 +52,12 @@ const ttl = { type: "integer", description: "Seconds without a message before it archives; 0: never.", } as const; +const joinRole = { + type: "string", + enum: ["member", "guest"], + description: + "Role for people who join or are added without one. guest on an open channel: anyone reads, only members post.", +} as const; const tool = ( name: string, description: string, @@ -185,6 +192,7 @@ export const TOOLS: readonly Tool[] = [ "system: left out of ordinary channel lists; anyone can still list an open one.", }, ttl, + join_role: joinRole, template: str, }, ["name", "visibility"], @@ -195,6 +203,7 @@ export const TOOLS: readonly Tool[] = [ about: str, visibility: { type: "string", enum: ["open", "private"] }, ttl, + join_role: joinRole, topic: str, purpose: str, }), @@ -1356,6 +1365,7 @@ class Tools { tag(metadata, "ttl") ? `archives after ${tag(metadata, "ttl")}s idle` : "", + tag(metadata, "join_role") === "guest" ? "join as guest" : "", ].filter(Boolean); return [ `${channel} ${tag(metadata, "name") ?? ""} (${flags.join(", ")})`, @@ -1376,6 +1386,9 @@ class Tools { const type = args.type ?? "stream"; if (type !== "stream" && type !== "forum" && type !== "system") throw new Error("type must be stream, forum or system."); + const role = joinRoleArg(args); + if (role === "guest" && args.visibility === "private") + throw new Error("join_role guest is for open channels."); // Found first, so a wrong name creates nothing. const template = args.template === undefined @@ -1396,9 +1409,25 @@ class Tools { }); const shown = await until(() => this.metadata(id), Boolean).catch(unread); if (!shown || shown instanceof Error) - return `The community accepted channel ${id} but ${shown ? shown.message : "does not show it yet"}${template ? "; the template was not applied" : ""}.`; + return `The community accepted channel ${id} but ${shown ? shown.message : "does not show it yet"}${template ? "; the template was not applied" : ""}${role === "guest" ? "; join_role was not set" : ""}.`; const lines = [`created ${id}`]; - if (!template) return lines.join("\n"); + if (template) lines.push(...(await this.apply(id, template))); + // Last, so the template's agents join as members, not guests. + if (role === "guest") + try { + const answer = await this.update(id, { join_role: role }); + lines.push( + answer === `updated ${id}` ? "new people join as guests" : answer, + ); + } catch (error) { + lines.push(`join_role not set: ${reason(error)}`); + } + return lines.join("\n"); + } + + /** Starts a new channel's Canvas and adds its agents from `template`. */ + async apply(id: string, template: ChannelTemplate) { + const lines: string[] = []; // The template's own agents, as Buzz adds them; each is tried. const agents = template.agents.filter((key) => key !== this.client.pubkey); if (template.canvas.trim()) @@ -1417,10 +1446,10 @@ class Tools { lines.push( `Canvas not started${agents.length ? ", so no agents were added" : ""}: ${reason(error)}`, ); - return lines.join("\n"); + return lines; } if (agents.length) lines.push(...(await this.change(id, agents, [])).lines); - return lines.join("\n"); + return lines; } /** The owner's template named `name`, as one, without guessing. */ @@ -1461,8 +1490,18 @@ class Tools { } if (args.ttl !== undefined) wanted.push(["ttl", String(seconds(args.ttl) ?? "")]); + const role = joinRoleArg(args); + if ( + role === "guest" && + (args.visibility ?? (has(metadata, "private") ? "private" : "open")) === + "private" + ) + throw new Error("join_role guest is for open channels."); + if (role) wanted.push(["join_role", role]); if (!wanted.length) - throw new Error("Give name, about, visibility, ttl, topic or purpose."); + throw new Error( + "Give name, about, visibility, ttl, join_role, topic or purpose.", + ); const value = (key: string) => wanted.find((t) => t[0] === key)?.[1]; checkDetails( value("name"), @@ -1531,13 +1570,20 @@ class Tools { ); const who = (key: string) => names.has(key) ? `${key} ${names.get(key)}` : key; + // With no role asked for, the relay gives a new member the channel's + // join_role, which is guest on an announce channel. + const shown = (key: string) => + role ?? + (roster instanceof Error + ? undefined + : roster.find((m) => m.pubkey === key)?.role); const report = (key: string, verb: string, done?: typeof added) => refused.has(key) ? `not ${verb} ${who(key)}: ${refused.get(key)}` : roster instanceof Error ? `${who(key)}: the community accepted it but ${roster.message}` : done?.(roster, key) - ? `${verb} ${who(key)}${verb === "added" && role ? ` as ${role}` : ""}` + ? `${verb} ${who(key)}${verb === "added" && (role || shown(key) !== "member") ? ` as ${shown(key)}` : ""}` : `${who(key)}: the community accepted it but does not show it yet`; return { changed: refused.size < add.length + remove.length, @@ -2118,6 +2164,15 @@ function seconds(value: unknown) { return value; } +/** A `join_role` argument: undefined when absent. */ +function joinRoleArg(args: Args) { + const value = args.join_role; + if (value === undefined) return undefined; + if (value !== "member" && value !== "guest") + throw new Error("join_role must be member or guest."); + return value; +} + /** A `since` or `before` argument: a Unix time in seconds, inclusive. */ function unixTime(args: Args, key: string) { const value = args[key];