feat(discord): add channel/category management actions (#487)

Co-authored-by: Shadow <shadow@clawd.bot>
This commit is contained in:
NickSpisak_
2026-01-09 12:05:58 -05:00
committed by GitHub
parent d588bb00d4
commit cfcff68e91
7 changed files with 597 additions and 0 deletions

View File

@@ -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, unknown>,
): string | null | undefined {
if (params.parentId === null) return null;
return readStringParam(params, "parentId");
}
export async function handleDiscordGuildAction(
action: string,
params: Record<string, unknown>,
@@ -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}`);
}

View File

@@ -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");
});
});

View File

@@ -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"]);

View File

@@ -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(),
}),
]);