diff --git a/CHANGELOG.md b/CHANGELOG.md index a35366658..723771cf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -120,6 +120,7 @@ - Gateway/CLI: allow dev profile (`clawdbot --dev`) to auto-create the dev config + workspace. — thanks @steipete - Dev templates: ship C-3PO dev workspace defaults as docs templates and use them for dev bootstrap. — thanks @steipete - Config: fix Minimax hosted onboarding to write `agents.defaults` and allow `msteams` as a heartbeat target. — thanks @steipete +- Discord: add channel/category management actions (create/edit/move/delete + category removal). (#487) - thanks @NicholasSpisak ## 2026.1.8 diff --git a/src/agents/tools/discord-actions-guild.ts b/src/agents/tools/discord-actions-guild.ts index 536c820ce..48efa1a15 100644 --- a/src/agents/tools/discord-actions-guild.ts +++ b/src/agents/tools/discord-actions-guild.ts @@ -2,7 +2,10 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { DiscordActionConfig } from "../../config/config.js"; import { addRoleDiscord, + createChannelDiscord, createScheduledEventDiscord, + deleteChannelDiscord, + editChannelDiscord, fetchChannelInfoDiscord, fetchMemberInfoDiscord, fetchRoleInfoDiscord, @@ -10,17 +13,28 @@ import { listGuildChannelsDiscord, listGuildEmojisDiscord, listScheduledEventsDiscord, + moveChannelDiscord, + removeChannelPermissionDiscord, removeRoleDiscord, + setChannelPermissionDiscord, uploadEmojiDiscord, uploadStickerDiscord, } from "../../discord/send.js"; import { type ActionGate, jsonResult, + readNumberParam, readStringArrayParam, readStringParam, } from "./common.js"; +function readParentIdParam( + params: Record, +): string | null | undefined { + if (params.parentId === null) return null; + return readStringParam(params, "parentId"); +} + export async function handleDiscordGuildAction( action: string, params: Record, @@ -207,6 +221,157 @@ export async function handleDiscordGuildAction( const event = await createScheduledEventDiscord(guildId, payload); return jsonResult({ ok: true, event }); } + case "channelCreate": { + if (!isActionEnabled("channels", false)) { + throw new Error("Discord channel management is disabled."); + } + const guildId = readStringParam(params, "guildId", { required: true }); + const name = readStringParam(params, "name", { required: true }); + const type = readNumberParam(params, "type", { integer: true }); + const parentId = readParentIdParam(params); + const topic = readStringParam(params, "topic"); + const position = readNumberParam(params, "position", { integer: true }); + const nsfw = params.nsfw as boolean | undefined; + const channel = await createChannelDiscord({ + guildId, + name, + type: type ?? undefined, + parentId: parentId ?? undefined, + topic: topic ?? undefined, + position: position ?? undefined, + nsfw, + }); + return jsonResult({ ok: true, channel }); + } + case "channelEdit": { + if (!isActionEnabled("channels", false)) { + throw new Error("Discord channel management is disabled."); + } + const channelId = readStringParam(params, "channelId", { + required: true, + }); + const name = readStringParam(params, "name"); + const topic = readStringParam(params, "topic"); + const position = readNumberParam(params, "position", { integer: true }); + const parentId = readParentIdParam(params); + const nsfw = params.nsfw as boolean | undefined; + const rateLimitPerUser = readNumberParam(params, "rateLimitPerUser", { + integer: true, + }); + const channel = await editChannelDiscord({ + channelId, + name: name ?? undefined, + topic: topic ?? undefined, + position: position ?? undefined, + parentId: parentId === undefined ? undefined : parentId, + nsfw, + rateLimitPerUser: rateLimitPerUser ?? undefined, + }); + return jsonResult({ ok: true, channel }); + } + case "channelDelete": { + if (!isActionEnabled("channels", false)) { + throw new Error("Discord channel management is disabled."); + } + const channelId = readStringParam(params, "channelId", { + required: true, + }); + const result = await deleteChannelDiscord(channelId); + return jsonResult(result); + } + case "channelMove": { + if (!isActionEnabled("channels", false)) { + throw new Error("Discord channel management is disabled."); + } + const guildId = readStringParam(params, "guildId", { required: true }); + const channelId = readStringParam(params, "channelId", { + required: true, + }); + const parentId = readParentIdParam(params); + const position = readNumberParam(params, "position", { integer: true }); + await moveChannelDiscord({ + guildId, + channelId, + parentId: parentId === undefined ? undefined : parentId, + position: position ?? undefined, + }); + return jsonResult({ ok: true }); + } + case "categoryCreate": { + if (!isActionEnabled("channels", false)) { + throw new Error("Discord channel management is disabled."); + } + const guildId = readStringParam(params, "guildId", { required: true }); + const name = readStringParam(params, "name", { required: true }); + const position = readNumberParam(params, "position", { integer: true }); + const channel = await createChannelDiscord({ + guildId, + name, + type: 4, + position: position ?? undefined, + }); + return jsonResult({ ok: true, category: channel }); + } + case "categoryEdit": { + if (!isActionEnabled("channels", false)) { + throw new Error("Discord channel management is disabled."); + } + const categoryId = readStringParam(params, "categoryId", { + required: true, + }); + const name = readStringParam(params, "name"); + const position = readNumberParam(params, "position", { integer: true }); + const channel = await editChannelDiscord({ + channelId: categoryId, + name: name ?? undefined, + position: position ?? undefined, + }); + return jsonResult({ ok: true, category: channel }); + } + case "categoryDelete": { + if (!isActionEnabled("channels", false)) { + throw new Error("Discord channel management is disabled."); + } + const categoryId = readStringParam(params, "categoryId", { + required: true, + }); + const result = await deleteChannelDiscord(categoryId); + return jsonResult(result); + } + case "channelPermissionSet": { + if (!isActionEnabled("channels", false)) { + throw new Error("Discord channel management is disabled."); + } + const channelId = readStringParam(params, "channelId", { + required: true, + }); + const targetId = readStringParam(params, "targetId", { required: true }); + const targetTypeRaw = readStringParam(params, "targetType", { + required: true, + }); + const targetType = targetTypeRaw === "member" ? 1 : 0; + const allow = readStringParam(params, "allow"); + const deny = readStringParam(params, "deny"); + await setChannelPermissionDiscord({ + channelId, + targetId, + targetType, + allow: allow ?? undefined, + deny: deny ?? undefined, + }); + return jsonResult({ ok: true }); + } + case "channelPermissionRemove": { + if (!isActionEnabled("channels", false)) { + throw new Error("Discord channel management is disabled."); + } + const channelId = readStringParam(params, "channelId", { + required: true, + }); + const targetId = readStringParam(params, "targetId", { required: true }); + await removeChannelPermissionDiscord(channelId, targetId); + return jsonResult({ ok: true }); + } default: throw new Error(`Unknown action: ${action}`); } diff --git a/src/agents/tools/discord-actions.test.ts b/src/agents/tools/discord-actions.test.ts index ec8371b08..6ba0627ad 100644 --- a/src/agents/tools/discord-actions.test.ts +++ b/src/agents/tools/discord-actions.test.ts @@ -1,38 +1,58 @@ import { describe, expect, it, vi } from "vitest"; import type { DiscordActionConfig } from "../../config/config.js"; +import { handleDiscordGuildAction } from "./discord-actions-guild.js"; import { handleDiscordMessagingAction } from "./discord-actions-messaging.js"; +const createChannelDiscord = vi.fn(async () => ({ + id: "new-channel", + name: "test", + type: 0, +})); const createThreadDiscord = vi.fn(async () => ({})); +const deleteChannelDiscord = vi.fn(async () => ({ ok: true, channelId: "C1" })); const deleteMessageDiscord = vi.fn(async () => ({})); +const editChannelDiscord = vi.fn(async () => ({ + id: "C1", + name: "edited", +})); const editMessageDiscord = vi.fn(async () => ({})); const fetchChannelPermissionsDiscord = vi.fn(async () => ({})); const fetchReactionsDiscord = vi.fn(async () => ({})); const listPinsDiscord = vi.fn(async () => ({})); const listThreadsDiscord = vi.fn(async () => ({})); +const moveChannelDiscord = vi.fn(async () => ({ ok: true })); const pinMessageDiscord = vi.fn(async () => ({})); const reactMessageDiscord = vi.fn(async () => ({})); const readMessagesDiscord = vi.fn(async () => []); +const removeChannelPermissionDiscord = vi.fn(async () => ({ ok: true })); const removeOwnReactionsDiscord = vi.fn(async () => ({ removed: ["👍"] })); const removeReactionDiscord = vi.fn(async () => ({})); const searchMessagesDiscord = vi.fn(async () => ({})); const sendMessageDiscord = vi.fn(async () => ({})); const sendPollDiscord = vi.fn(async () => ({})); const sendStickerDiscord = vi.fn(async () => ({})); +const setChannelPermissionDiscord = vi.fn(async () => ({ ok: true })); const unpinMessageDiscord = vi.fn(async () => ({})); vi.mock("../../discord/send.js", () => ({ + createChannelDiscord: (...args: unknown[]) => createChannelDiscord(...args), createThreadDiscord: (...args: unknown[]) => createThreadDiscord(...args), + deleteChannelDiscord: (...args: unknown[]) => deleteChannelDiscord(...args), deleteMessageDiscord: (...args: unknown[]) => deleteMessageDiscord(...args), + editChannelDiscord: (...args: unknown[]) => editChannelDiscord(...args), editMessageDiscord: (...args: unknown[]) => editMessageDiscord(...args), fetchChannelPermissionsDiscord: (...args: unknown[]) => fetchChannelPermissionsDiscord(...args), fetchReactionsDiscord: (...args: unknown[]) => fetchReactionsDiscord(...args), listPinsDiscord: (...args: unknown[]) => listPinsDiscord(...args), listThreadsDiscord: (...args: unknown[]) => listThreadsDiscord(...args), + moveChannelDiscord: (...args: unknown[]) => moveChannelDiscord(...args), pinMessageDiscord: (...args: unknown[]) => pinMessageDiscord(...args), reactMessageDiscord: (...args: unknown[]) => reactMessageDiscord(...args), readMessagesDiscord: (...args: unknown[]) => readMessagesDiscord(...args), + removeChannelPermissionDiscord: (...args: unknown[]) => + removeChannelPermissionDiscord(...args), removeOwnReactionsDiscord: (...args: unknown[]) => removeOwnReactionsDiscord(...args), removeReactionDiscord: (...args: unknown[]) => removeReactionDiscord(...args), @@ -40,6 +60,8 @@ vi.mock("../../discord/send.js", () => ({ sendMessageDiscord: (...args: unknown[]) => sendMessageDiscord(...args), sendPollDiscord: (...args: unknown[]) => sendPollDiscord(...args), sendStickerDiscord: (...args: unknown[]) => sendStickerDiscord(...args), + setChannelPermissionDiscord: (...args: unknown[]) => + setChannelPermissionDiscord(...args), unpinMessageDiscord: (...args: unknown[]) => unpinMessageDiscord(...args), })); @@ -117,3 +139,214 @@ describe("handleDiscordMessagingAction", () => { ).rejects.toThrow(/Discord reactions are disabled/); }); }); + +const channelsEnabled = (key: keyof DiscordActionConfig) => key === "channels"; +const channelsDisabled = () => false; + +describe("handleDiscordGuildAction - channel management", () => { + it("creates a channel", async () => { + const result = await handleDiscordGuildAction( + "channelCreate", + { + guildId: "G1", + name: "test-channel", + type: 0, + topic: "Test topic", + }, + channelsEnabled, + ); + expect(createChannelDiscord).toHaveBeenCalledWith({ + guildId: "G1", + name: "test-channel", + type: 0, + parentId: undefined, + topic: "Test topic", + position: undefined, + nsfw: undefined, + }); + expect(result.details).toMatchObject({ ok: true }); + }); + + it("respects channel gating for channelCreate", async () => { + await expect( + handleDiscordGuildAction( + "channelCreate", + { guildId: "G1", name: "test" }, + channelsDisabled, + ), + ).rejects.toThrow(/Discord channel management is disabled/); + }); + + it("edits a channel", async () => { + await handleDiscordGuildAction( + "channelEdit", + { + channelId: "C1", + name: "new-name", + topic: "new topic", + }, + channelsEnabled, + ); + expect(editChannelDiscord).toHaveBeenCalledWith({ + channelId: "C1", + name: "new-name", + topic: "new topic", + position: undefined, + parentId: undefined, + nsfw: undefined, + rateLimitPerUser: undefined, + }); + }); + + it("clears the channel parent when parentId is null", async () => { + await handleDiscordGuildAction( + "channelEdit", + { + channelId: "C1", + parentId: null, + }, + channelsEnabled, + ); + expect(editChannelDiscord).toHaveBeenCalledWith({ + channelId: "C1", + name: undefined, + topic: undefined, + position: undefined, + parentId: null, + nsfw: undefined, + rateLimitPerUser: undefined, + }); + }); + + it("deletes a channel", async () => { + await handleDiscordGuildAction( + "channelDelete", + { channelId: "C1" }, + channelsEnabled, + ); + expect(deleteChannelDiscord).toHaveBeenCalledWith("C1"); + }); + + it("moves a channel", async () => { + await handleDiscordGuildAction( + "channelMove", + { + guildId: "G1", + channelId: "C1", + parentId: "P1", + position: 5, + }, + channelsEnabled, + ); + expect(moveChannelDiscord).toHaveBeenCalledWith({ + guildId: "G1", + channelId: "C1", + parentId: "P1", + position: 5, + }); + }); + + it("clears the channel parent on move when parentId is null", async () => { + await handleDiscordGuildAction( + "channelMove", + { + guildId: "G1", + channelId: "C1", + parentId: null, + }, + channelsEnabled, + ); + expect(moveChannelDiscord).toHaveBeenCalledWith({ + guildId: "G1", + channelId: "C1", + parentId: null, + position: undefined, + }); + }); + + it("creates a category with type=4", async () => { + await handleDiscordGuildAction( + "categoryCreate", + { guildId: "G1", name: "My Category" }, + channelsEnabled, + ); + expect(createChannelDiscord).toHaveBeenCalledWith({ + guildId: "G1", + name: "My Category", + type: 4, + position: undefined, + }); + }); + + it("edits a category", async () => { + await handleDiscordGuildAction( + "categoryEdit", + { categoryId: "CAT1", name: "Renamed Category" }, + channelsEnabled, + ); + expect(editChannelDiscord).toHaveBeenCalledWith({ + channelId: "CAT1", + name: "Renamed Category", + position: undefined, + }); + }); + + it("deletes a category", async () => { + await handleDiscordGuildAction( + "categoryDelete", + { categoryId: "CAT1" }, + channelsEnabled, + ); + expect(deleteChannelDiscord).toHaveBeenCalledWith("CAT1"); + }); + + it("sets channel permissions for role", async () => { + await handleDiscordGuildAction( + "channelPermissionSet", + { + channelId: "C1", + targetId: "R1", + targetType: "role", + allow: "1024", + deny: "2048", + }, + channelsEnabled, + ); + expect(setChannelPermissionDiscord).toHaveBeenCalledWith({ + channelId: "C1", + targetId: "R1", + targetType: 0, + allow: "1024", + deny: "2048", + }); + }); + + it("sets channel permissions for member", async () => { + await handleDiscordGuildAction( + "channelPermissionSet", + { + channelId: "C1", + targetId: "U1", + targetType: "member", + allow: "1024", + }, + channelsEnabled, + ); + expect(setChannelPermissionDiscord).toHaveBeenCalledWith({ + channelId: "C1", + targetId: "U1", + targetType: 1, + allow: "1024", + deny: undefined, + }); + }); + + it("removes channel permissions", async () => { + await handleDiscordGuildAction( + "channelPermissionRemove", + { channelId: "C1", targetId: "R1" }, + channelsEnabled, + ); + expect(removeChannelPermissionDiscord).toHaveBeenCalledWith("C1", "R1"); + }); +}); diff --git a/src/agents/tools/discord-actions.ts b/src/agents/tools/discord-actions.ts index 9cb2ec10e..e3104f53b 100644 --- a/src/agents/tools/discord-actions.ts +++ b/src/agents/tools/discord-actions.ts @@ -37,6 +37,15 @@ const guildActions = new Set([ "voiceStatus", "eventList", "eventCreate", + "channelCreate", + "channelEdit", + "channelDelete", + "channelMove", + "categoryCreate", + "categoryEdit", + "categoryDelete", + "channelPermissionSet", + "channelPermissionRemove", ]); const moderationActions = new Set(["timeout", "kick", "ban"]); diff --git a/src/agents/tools/discord-schema.ts b/src/agents/tools/discord-schema.ts index 4e7bb24a0..7bc055298 100644 --- a/src/agents/tools/discord-schema.ts +++ b/src/agents/tools/discord-schema.ts @@ -202,4 +202,67 @@ export const DiscordToolSchema = Type.Union([ reason: Type.Optional(Type.String()), deleteMessageDays: Type.Optional(Type.Number()), }), + // Channel management actions + Type.Object({ + action: Type.Literal("channelCreate"), + guildId: Type.String(), + name: Type.String(), + type: Type.Optional(Type.Number()), + parentId: Type.Optional(Type.String()), + topic: Type.Optional(Type.String()), + position: Type.Optional(Type.Number()), + nsfw: Type.Optional(Type.Boolean()), + }), + Type.Object({ + action: Type.Literal("channelEdit"), + channelId: Type.String(), + name: Type.Optional(Type.String()), + topic: Type.Optional(Type.String()), + position: Type.Optional(Type.Number()), + parentId: Type.Optional(Type.Union([Type.String(), Type.Null()])), + nsfw: Type.Optional(Type.Boolean()), + rateLimitPerUser: Type.Optional(Type.Number()), + }), + Type.Object({ + action: Type.Literal("channelDelete"), + channelId: Type.String(), + }), + Type.Object({ + action: Type.Literal("channelMove"), + guildId: Type.String(), + channelId: Type.String(), + parentId: Type.Optional(Type.Union([Type.String(), Type.Null()])), + position: Type.Optional(Type.Number()), + }), + // Category management actions (convenience aliases) + Type.Object({ + action: Type.Literal("categoryCreate"), + guildId: Type.String(), + name: Type.String(), + position: Type.Optional(Type.Number()), + }), + Type.Object({ + action: Type.Literal("categoryEdit"), + categoryId: Type.String(), + name: Type.Optional(Type.String()), + position: Type.Optional(Type.Number()), + }), + Type.Object({ + action: Type.Literal("categoryDelete"), + categoryId: Type.String(), + }), + // Permission overwrite actions + Type.Object({ + action: Type.Literal("channelPermissionSet"), + channelId: Type.String(), + targetId: Type.String(), + targetType: Type.Union([Type.Literal("role"), Type.Literal("member")]), + allow: Type.Optional(Type.String()), + deny: Type.Optional(Type.String()), + }), + Type.Object({ + action: Type.Literal("channelPermissionRemove"), + channelId: Type.String(), + targetId: Type.String(), + }), ]); diff --git a/src/config/types.ts b/src/config/types.ts index 334f44a02..0ff089b44 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -404,6 +404,7 @@ export type DiscordActionConfig = { moderation?: boolean; emojiUploads?: boolean; stickerUploads?: boolean; + channels?: boolean; }; export type DiscordAccountConfig = { diff --git a/src/discord/send.ts b/src/discord/send.ts index 30091fedf..8a4ff1395 100644 --- a/src/discord/send.ts +++ b/src/discord/send.ts @@ -183,6 +183,41 @@ export type DiscordStickerUpload = { mediaUrl: string; }; +export type DiscordChannelCreate = { + guildId: string; + name: string; + type?: number; + parentId?: string; + topic?: string; + position?: number; + nsfw?: boolean; +}; + +export type DiscordChannelEdit = { + channelId: string; + name?: string; + topic?: string; + position?: number; + parentId?: string | null; + nsfw?: boolean; + rateLimitPerUser?: number; +}; + +export type DiscordChannelMove = { + guildId: string; + channelId: string; + parentId?: string | null; + position?: number; +}; + +export type DiscordChannelPermissionSet = { + channelId: string; + targetId: string; + targetType: 0 | 1; + allow?: string; + deny?: string; +}; + function resolveToken(params: { explicit?: string; accountId: string; @@ -1191,3 +1226,93 @@ export async function banMemberDiscord( }); return { ok: true }; } + +// Channel management functions + +export async function createChannelDiscord( + payload: DiscordChannelCreate, + opts: DiscordReactOpts = {}, +): Promise { + const rest = resolveDiscordRest(opts); + const body: Record = { + name: payload.name, + }; + if (payload.type !== undefined) body.type = payload.type; + if (payload.parentId) body.parent_id = payload.parentId; + if (payload.topic) body.topic = payload.topic; + if (payload.position !== undefined) body.position = payload.position; + if (payload.nsfw !== undefined) body.nsfw = payload.nsfw; + return (await rest.post(Routes.guildChannels(payload.guildId), { + body, + })) as APIChannel; +} + +export async function editChannelDiscord( + payload: DiscordChannelEdit, + opts: DiscordReactOpts = {}, +): Promise { + const rest = resolveDiscordRest(opts); + const body: Record = {}; + if (payload.name !== undefined) body.name = payload.name; + if (payload.topic !== undefined) body.topic = payload.topic; + if (payload.position !== undefined) body.position = payload.position; + if (payload.parentId !== undefined) body.parent_id = payload.parentId; + if (payload.nsfw !== undefined) body.nsfw = payload.nsfw; + if (payload.rateLimitPerUser !== undefined) + body.rate_limit_per_user = payload.rateLimitPerUser; + return (await rest.patch(Routes.channel(payload.channelId), { + body, + })) as APIChannel; +} + +export async function deleteChannelDiscord( + channelId: string, + opts: DiscordReactOpts = {}, +) { + const rest = resolveDiscordRest(opts); + await rest.delete(Routes.channel(channelId)); + return { ok: true, channelId }; +} + +export async function moveChannelDiscord( + payload: DiscordChannelMove, + opts: DiscordReactOpts = {}, +) { + const rest = resolveDiscordRest(opts); + const body: Array> = [ + { + id: payload.channelId, + ...(payload.parentId !== undefined && { parent_id: payload.parentId }), + ...(payload.position !== undefined && { position: payload.position }), + }, + ]; + await rest.patch(Routes.guildChannels(payload.guildId), { body }); + return { ok: true }; +} + +export async function setChannelPermissionDiscord( + payload: DiscordChannelPermissionSet, + opts: DiscordReactOpts = {}, +) { + const rest = resolveDiscordRest(opts); + const body: Record = { + type: payload.targetType, + }; + if (payload.allow !== undefined) body.allow = payload.allow; + if (payload.deny !== undefined) body.deny = payload.deny; + await rest.put( + `/channels/${payload.channelId}/permissions/${payload.targetId}`, + { body }, + ); + return { ok: true }; +} + +export async function removeChannelPermissionDiscord( + channelId: string, + targetId: string, + opts: DiscordReactOpts = {}, +) { + const rest = resolveDiscordRest(opts); + await rest.delete(`/channels/${channelId}/permissions/${targetId}`); + return { ok: true }; +}