diff --git a/docs/AGENTS.default.md b/docs/AGENTS.default.md index ddf2d0ea1..c307e672b 100644 --- a/docs/AGENTS.default.md +++ b/docs/AGENTS.default.md @@ -94,6 +94,7 @@ git commit -m "Add Clawd workspace" - **eightctl** — Control your sleep, from the terminal. - **imsg** — Send, read, stream iMessage & SMS. - **wacli** — WhatsApp CLI: sync, search, send. +- **discord** — Discord actions: react, stickers, polls. - **gog** — Google Suite CLI: Gmail, Calendar, Drive, Contacts. - **spotify-player** — Terminal Spotify client to search/queue/control playback. - **sag** — ElevenLabs speech with mac-style say UX; streams to speakers by default. diff --git a/docs/configuration.md b/docs/configuration.md index 245a6b670..512ce0972 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -222,6 +222,24 @@ Configure the Discord bot by setting the bot token and optional gating: token: "your-bot-token", mediaMaxMb: 8, // clamp inbound media size enableReactions: true, // allow agent-triggered reactions + actions: { // tool action gates (false disables) + reactions: true, + stickers: true, + polls: true, + permissions: true, + messages: true, + threads: true, + pins: true, + search: true, + memberInfo: true, + roleInfo: true, + roles: false, + channelInfo: true, + voiceStatus: true, + events: true, + moderation: false + }, + replyToMode: "off", // off | first | all slashCommand: { // user-installed app slash commands enabled: true, name: "clawd", diff --git a/docs/discord.md b/docs/discord.md index 2d75bb584..47a6d4420 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -27,7 +27,7 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa 8. Optional guild rules: set `discord.guilds` keyed by guild id (preferred) or slug, with per-channel rules. 9. Optional slash commands: enable `discord.slashCommand` to accept user-installed app commands (ephemeral replies). Slash invocations respect the same DM/guild allowlists. 10. Optional guild context history: set `discord.historyLimit` (default 20) to include the last N guild messages as context when replying to a mention. Set `0` to disable. -11. Reactions (default on): set `discord.enableReactions = false` to disable agent-triggered reactions via the `clawdis_discord` tool. +11. Reactions: the agent can trigger reactions via the `discord` tool (gated by `discord.enableReactions`). 12. Slash commands use isolated session keys (`${sessionPrefix}:${userId}`) rather than the shared `main` session. Note: Discord does not provide a simple username → id lookup without extra guild context, so prefer ids or `<@id>` mentions for DM delivery targets. @@ -51,6 +51,23 @@ Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-rea token: "abc.123", mediaMaxMb: 8, enableReactions: true, + actions: { + reactions: true, + stickers: true, + polls: true, + permissions: true, + messages: true, + threads: true, + pins: true, + search: true, + memberInfo: true, + roleInfo: true, + roles: false, + channelInfo: true, + voiceStatus: true, + events: true, + moderation: false + }, replyToMode: "off", slashCommand: { enabled: true, @@ -93,7 +110,33 @@ Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-rea - `slashCommand`: optional config for user-installed slash commands (ephemeral responses). - `mediaMaxMb`: clamp inbound media saved to disk. - `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20, `0` disables). -- `enableReactions`: allow agent-triggered reactions via the `clawdis_discord` tool (default `true`). +- `enableReactions`: allow agent-triggered reactions via the `discord` tool (default `true`). +- `actions`: per-action tool gates; omit to allow all (set `false` to disable). + - `reactions` (covers react + read reactions) + - `stickers`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search` + - `memberInfo`, `roleInfo`, `channelInfo`, `voiceStatus`, `events` + - `roles` (role add/remove, default `false`) + - `moderation` (timeout/kick/ban, default `false`) + +### Tool action defaults + +| Action group | Default | Notes | +| --- | --- | --- | +| reactions | enabled | React + list reactions + emojiList | +| stickers | enabled | Send stickers | +| polls | enabled | Create polls | +| permissions | enabled | Channel permission snapshot | +| messages | enabled | Read/send/edit/delete | +| threads | enabled | Create/list/reply | +| pins | enabled | Pin/unpin/list | +| search | enabled | Message search (preview spec) | +| memberInfo | enabled | Member info | +| roleInfo | enabled | Role list | +| channelInfo | enabled | Channel info + list | +| voiceStatus | enabled | Voice state lookup | +| events | enabled | List/create scheduled events | +| roles | disabled | Role add/remove | +| moderation | disabled | Timeout/kick/ban | - `replyToMode`: `off` (default), `first`, or `all`. Applies only when the model includes a reply tag. ## Reply tags @@ -119,10 +162,16 @@ Slash command notes: - Slash commands honor the same allowlists as DMs/guild messages (`discord.dm.allowFrom`, `discord.guilds`, per-channel rules). - Clawdis will auto-register `/clawd` (or the configured name) if it doesn't already exist. -## Reactions -When `discord.enableReactions = true`, the agent can call `clawdis_discord` with: -- `action: "react"` -- `channelId`, `messageId`, `emoji` +## Tool actions +The agent can call `discord` with actions like: +- `react` / `reactions` (add or list reactions) +- `sticker`, `poll`, `permissions` +- `readMessages`, `sendMessage`, `editMessage`, `deleteMessage` +- `threadCreate`, `threadList`, `threadReply` +- `pinMessage`, `unpinMessage`, `listPins` +- `searchMessages`, `memberInfo`, `roleInfo`, `roleAdd`, `roleRemove`, `emojiList` +- `channelInfo`, `channelList`, `voiceStatus`, `eventList`, `eventCreate` +- `timeout`, `kick`, `ban` Discord message ids are surfaced in the injected context (`[discord message id: …]` and history lines) so the agent can target them. Emoji can be unicode (e.g., `✅`) or custom emoji syntax like `<:party_blob:1234567890>`. diff --git a/docs/tools.md b/docs/tools.md index 7d3987d62..49bcf6cd7 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -105,6 +105,46 @@ Core actions: Notes: - Use `delayMs` (defaults to 2000) to avoid interrupting an in-flight reply. +### `discord` +Send Discord reactions, stickers, or polls. + +Core actions: +- `react` (`channelId`, `messageId`, `emoji`) +- `reactions` (`channelId`, `messageId`, optional `limit`) +- `sticker` (`to`, `stickerIds`, optional `content`) +- `poll` (`to`, `question`, `answers`, optional `allowMultiselect`, `durationHours`, `content`) +- `permissions` (`channelId`) +- `readMessages` (`channelId`, optional `limit`/`before`/`after`/`around`) +- `sendMessage` (`to`, `content`, optional `mediaUrl`, `replyTo`) +- `editMessage` (`channelId`, `messageId`, `content`) +- `deleteMessage` (`channelId`, `messageId`) +- `threadCreate` (`channelId`, `name`, optional `messageId`, `autoArchiveMinutes`) +- `threadList` (`guildId`, optional `channelId`, `includeArchived`, `before`, `limit`) +- `threadReply` (`channelId`, `content`, optional `mediaUrl`, `replyTo`) +- `pinMessage`/`unpinMessage` (`channelId`, `messageId`) +- `listPins` (`channelId`) +- `searchMessages` (`guildId`, `content`, optional `channelId`/`channelIds`, `authorId`/`authorIds`, `limit`) +- `memberInfo` (`guildId`, `userId`) +- `roleInfo` (`guildId`) +- `emojiList` (`guildId`) +- `roleAdd`/`roleRemove` (`guildId`, `userId`, `roleId`) +- `channelInfo` (`channelId`) +- `channelList` (`guildId`) +- `voiceStatus` (`guildId`, `userId`) +- `eventList` (`guildId`) +- `eventCreate` (`guildId`, `name`, `startTime`, optional `endTime`, `description`, `channelId`, `entityType`, `location`) +- `timeout` (`guildId`, `userId`, optional `durationMinutes`, `until`, `reason`) +- `kick` (`guildId`, `userId`, optional `reason`) +- `ban` (`guildId`, `userId`, optional `reason`, `deleteMessageDays`) + +Notes: +- `to` accepts `channel:` or `user:`. +- Polls require 2–10 answers and default to 24 hours. +- `reactions` returns per-emoji user lists (limited to 100 per reaction). +- Reactions respect `discord.enableReactions` (default `true`). +- `discord.actions.roles` + `discord.actions.moderation` default to `false`. +- `searchMessages` follows the Discord preview spec (limit max 25, channel/author filters accept arrays). + ## Parameters (common) Gateway-backed tools (`clawdis_canvas`, `clawdis_nodes`, `clawdis_cron`): diff --git a/skills/discord/SKILL.md b/skills/discord/SKILL.md new file mode 100644 index 000000000..3594f607e --- /dev/null +++ b/skills/discord/SKILL.md @@ -0,0 +1,272 @@ +--- +name: discord +description: Use when you need to control Discord from Clawdis via the discord tool: add reactions, send stickers, or create polls in Discord DMs or channels. Trigger for tasks like reacting to a message, posting a sticker, or running a quick poll for a decision. +--- + +# Discord Actions + +## Overview + +Use `discord` to manage messages, reactions, threads, and moderation. Reactions are gated by `discord.enableReactions` (default `true`). You can disable groups via `discord.actions.*`. The tool uses the bot token configured for Clawdis. + +## Inputs to collect + +- For reactions: `channelId`, `messageId`, and an `emoji`. +- For stickers/polls: a `to` target (`channel:` or `user:`). Optional `content` text. +- Polls also need a `question` plus 2–10 `answers`. + +Message context lines include `discord message id` and `channel` fields you can reuse directly. + +## Actions + +### React to a message + +```json +{ + "action": "react", + "channelId": "123", + "messageId": "456", + "emoji": "✅" +} +``` + +### List reactions + users + +```json +{ + "action": "reactions", + "channelId": "123", + "messageId": "456", + "limit": 100 +} +``` + +### Send a sticker + +```json +{ + "action": "sticker", + "to": "channel:123", + "stickerIds": ["9876543210"], + "content": "Nice work!" +} +``` + +- Up to 3 sticker IDs per message. +- `to` can be `user:` for DMs. + +### Create a poll + +```json +{ + "action": "poll", + "to": "channel:123", + "question": "Lunch?", + "answers": ["Pizza", "Sushi", "Salad"], + "allowMultiselect": false, + "durationHours": 24, + "content": "Vote now" +} +``` + +- `durationHours` defaults to 24; max 32 days (768 hours). + +### Check bot permissions for a channel + +```json +{ + "action": "permissions", + "channelId": "123" +} +``` + +## Ideas to try + +- React with ✅/⚠️ to mark status updates. +- Post a quick poll for release decisions or meeting times. +- Send celebratory stickers after successful deploys. +- Run weekly “priority check” polls in team channels. +- DM stickers as acknowledgements when a user’s request is completed. + +## Action gating + +Use `discord.actions.*` to disable action groups: +- `reactions` (react + reactions list) +- `stickers`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search` +- `memberInfo`, `roleInfo`, `channelInfo`, `voiceStatus`, `events` +- `roles` (role add/remove, default `false`) +- `moderation` (timeout/kick/ban, default `false`) +### Read recent messages + +```json +{ + "action": "readMessages", + "channelId": "123", + "limit": 20 +} +``` + +### Send/edit/delete a message + +```json +{ + "action": "sendMessage", + "to": "channel:123", + "content": "Hello from Clawdis" +} +``` + +```json +{ + "action": "editMessage", + "channelId": "123", + "messageId": "456", + "content": "Fixed typo" +} +``` + +```json +{ + "action": "deleteMessage", + "channelId": "123", + "messageId": "456" +} +``` + +### Threads + +```json +{ + "action": "threadCreate", + "channelId": "123", + "name": "Bug triage", + "messageId": "456" +} +``` + +```json +{ + "action": "threadList", + "guildId": "999" +} +``` + +```json +{ + "action": "threadReply", + "channelId": "777", + "content": "Replying in thread" +} +``` + +### Pins + +```json +{ + "action": "pinMessage", + "channelId": "123", + "messageId": "456" +} +``` + +```json +{ + "action": "listPins", + "channelId": "123" +} +``` + +### Search messages + +```json +{ + "action": "searchMessages", + "guildId": "999", + "content": "release notes", + "channelIds": ["123", "456"], + "limit": 10 +} +``` + +### Member + role info + +```json +{ + "action": "memberInfo", + "guildId": "999", + "userId": "111" +} +``` + +```json +{ + "action": "roleInfo", + "guildId": "999" +} +``` + +### List available custom emojis + +```json +{ + "action": "emojiList", + "guildId": "999" +} +``` + +### Role changes (disabled by default) + +```json +{ + "action": "roleAdd", + "guildId": "999", + "userId": "111", + "roleId": "222" +} +``` + +### Channel info + +```json +{ + "action": "channelInfo", + "channelId": "123" +} +``` + +```json +{ + "action": "channelList", + "guildId": "999" +} +``` + +### Voice status + +```json +{ + "action": "voiceStatus", + "guildId": "999", + "userId": "111" +} +``` + +### Scheduled events + +```json +{ + "action": "eventList", + "guildId": "999" +} +``` + +### Moderation (disabled by default) + +```json +{ + "action": "timeout", + "guildId": "999", + "userId": "111", + "durationMinutes": 10 +} +``` diff --git a/src/agents/clawdis-tools.ts b/src/agents/clawdis-tools.ts index 9242bb24e..dd07c7879 100644 --- a/src/agents/clawdis-tools.ts +++ b/src/agents/clawdis-tools.ts @@ -41,7 +41,36 @@ import { } from "../cli/nodes-screen.js"; import { parseDurationMs } from "../cli/parse-duration.js"; import { loadConfig } from "../config/config.js"; -import { reactMessageDiscord } from "../discord/send.js"; +import { + addRoleDiscord, + banMemberDiscord, + createScheduledEventDiscord, + createThreadDiscord, + deleteMessageDiscord, + editMessageDiscord, + fetchChannelInfoDiscord, + fetchChannelPermissionsDiscord, + fetchMemberInfoDiscord, + fetchReactionsDiscord, + fetchRoleInfoDiscord, + fetchVoiceStatusDiscord, + kickMemberDiscord, + listGuildChannelsDiscord, + listGuildEmojisDiscord, + listPinsDiscord, + listScheduledEventsDiscord, + listThreadsDiscord, + pinMessageDiscord, + reactMessageDiscord, + readMessagesDiscord, + removeRoleDiscord, + searchMessagesDiscord, + sendMessageDiscord, + sendPollDiscord, + sendStickerDiscord, + timeoutMemberDiscord, + unpinMessageDiscord, +} from "../discord/send.js"; import { callGateway } from "../gateway/call.js"; import { detectMime, imageMimeFromFormat } from "../media/mime.js"; import { sanitizeToolResultImages } from "./tool-images.js"; @@ -108,6 +137,46 @@ function readStringParam( return value; } +function readStringArrayParam( + params: Record, + key: string, + options: StringParamOptions & { required: true }, +): string[]; +function readStringArrayParam( + params: Record, + key: string, + options?: StringParamOptions, +): string[] | undefined; +function readStringArrayParam( + params: Record, + key: string, + options: StringParamOptions = {}, +) { + const { required = false, label = key } = options; + const raw = params[key]; + if (Array.isArray(raw)) { + const values = raw + .filter((entry) => typeof entry === "string") + .map((entry) => entry.trim()) + .filter(Boolean); + if (values.length === 0) { + if (required) throw new Error(`${label} required`); + return undefined; + } + return values; + } + if (typeof raw === "string") { + const value = raw.trim(); + if (!value) { + if (required) throw new Error(`${label} required`); + return undefined; + } + return [value]; + } + if (required) throw new Error(`${label} required`); + return undefined; +} + async function callGatewayTool( method: string, opts: GatewayCallOptions, @@ -1486,37 +1555,714 @@ const DiscordToolSchema = Type.Union([ messageId: Type.String(), emoji: Type.String(), }), + Type.Object({ + action: Type.Literal("reactions"), + channelId: Type.String(), + messageId: Type.String(), + limit: Type.Optional(Type.Number()), + }), + Type.Object({ + action: Type.Literal("sticker"), + to: Type.String(), + stickerIds: Type.Array(Type.String()), + content: Type.Optional(Type.String()), + }), + Type.Object({ + action: Type.Literal("poll"), + to: Type.String(), + question: Type.String(), + answers: Type.Array(Type.String()), + allowMultiselect: Type.Optional(Type.Boolean()), + durationHours: Type.Optional(Type.Number()), + content: Type.Optional(Type.String()), + }), + Type.Object({ + action: Type.Literal("permissions"), + channelId: Type.String(), + }), + Type.Object({ + action: Type.Literal("readMessages"), + channelId: Type.String(), + limit: Type.Optional(Type.Number()), + before: Type.Optional(Type.String()), + after: Type.Optional(Type.String()), + around: Type.Optional(Type.String()), + }), + Type.Object({ + action: Type.Literal("sendMessage"), + to: Type.String(), + content: Type.String(), + mediaUrl: Type.Optional(Type.String()), + replyTo: Type.Optional(Type.String()), + }), + Type.Object({ + action: Type.Literal("editMessage"), + channelId: Type.String(), + messageId: Type.String(), + content: Type.String(), + }), + Type.Object({ + action: Type.Literal("deleteMessage"), + channelId: Type.String(), + messageId: Type.String(), + }), + Type.Object({ + action: Type.Literal("threadCreate"), + channelId: Type.String(), + name: Type.String(), + messageId: Type.Optional(Type.String()), + autoArchiveMinutes: Type.Optional(Type.Number()), + }), + Type.Object({ + action: Type.Literal("threadList"), + guildId: Type.String(), + channelId: Type.Optional(Type.String()), + includeArchived: Type.Optional(Type.Boolean()), + before: Type.Optional(Type.String()), + limit: Type.Optional(Type.Number()), + }), + Type.Object({ + action: Type.Literal("threadReply"), + channelId: Type.String(), + content: Type.String(), + mediaUrl: Type.Optional(Type.String()), + replyTo: Type.Optional(Type.String()), + }), + Type.Object({ + action: Type.Literal("pinMessage"), + channelId: Type.String(), + messageId: Type.String(), + }), + Type.Object({ + action: Type.Literal("unpinMessage"), + channelId: Type.String(), + messageId: Type.String(), + }), + Type.Object({ + action: Type.Literal("listPins"), + channelId: Type.String(), + }), + Type.Object({ + action: Type.Literal("searchMessages"), + guildId: Type.String(), + content: Type.String(), + channelId: Type.Optional(Type.String()), + channelIds: Type.Optional(Type.Array(Type.String())), + authorId: Type.Optional(Type.String()), + authorIds: Type.Optional(Type.Array(Type.String())), + limit: Type.Optional(Type.Number()), + }), + Type.Object({ + action: Type.Literal("memberInfo"), + guildId: Type.String(), + userId: Type.String(), + }), + Type.Object({ + action: Type.Literal("roleInfo"), + guildId: Type.String(), + }), + Type.Object({ + action: Type.Literal("emojiList"), + guildId: Type.String(), + }), + Type.Object({ + action: Type.Literal("roleAdd"), + guildId: Type.String(), + userId: Type.String(), + roleId: Type.String(), + }), + Type.Object({ + action: Type.Literal("roleRemove"), + guildId: Type.String(), + userId: Type.String(), + roleId: Type.String(), + }), + Type.Object({ + action: Type.Literal("channelInfo"), + channelId: Type.String(), + }), + Type.Object({ + action: Type.Literal("channelList"), + guildId: Type.String(), + }), + Type.Object({ + action: Type.Literal("voiceStatus"), + guildId: Type.String(), + userId: Type.String(), + }), + Type.Object({ + action: Type.Literal("eventList"), + guildId: Type.String(), + }), + Type.Object({ + action: Type.Literal("eventCreate"), + guildId: Type.String(), + name: Type.String(), + startTime: Type.String(), + endTime: Type.Optional(Type.String()), + description: Type.Optional(Type.String()), + channelId: Type.Optional(Type.String()), + entityType: Type.Optional( + Type.Union([ + Type.Literal("voice"), + Type.Literal("stage"), + Type.Literal("external"), + ]), + ), + location: Type.Optional(Type.String()), + }), + Type.Object({ + action: Type.Literal("timeout"), + guildId: Type.String(), + userId: Type.String(), + durationMinutes: Type.Optional(Type.Number()), + until: Type.Optional(Type.String()), + reason: Type.Optional(Type.String()), + }), + Type.Object({ + action: Type.Literal("kick"), + guildId: Type.String(), + userId: Type.String(), + reason: Type.Optional(Type.String()), + }), + Type.Object({ + action: Type.Literal("ban"), + guildId: Type.String(), + userId: Type.String(), + reason: Type.Optional(Type.String()), + deleteMessageDays: Type.Optional(Type.Number()), + }), ]); function createDiscordTool(): AnyAgentTool { return { label: "Clawdis Discord", - name: "clawdis_discord", - description: - "React to Discord messages. Controlled by discord.enableReactions (default: true).", + name: "discord", + description: "Manage Discord messages, reactions, and moderation.", parameters: DiscordToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; const action = readStringParam(params, "action", { required: true }); - if (action !== "react") throw new Error(`Unknown action: ${action}`); - const cfg = loadConfig(); - if (cfg.discord?.enableReactions === false) { - throw new Error( - "Discord reactions are disabled (set discord.enableReactions=true).", - ); + const isActionEnabled = ( + key: keyof NonNullable["actions"], + defaultValue = true, + ) => { + const value = cfg.discord?.actions?.[key]; + if (value === undefined) return defaultValue; + return value !== false; + }; + + switch (action) { + case "react": { + if (!isActionEnabled("reactions")) { + throw new Error("Discord reactions are disabled."); + } + if (cfg.discord?.enableReactions === false) { + throw new Error( + "Discord reactions are disabled (set discord.enableReactions=true).", + ); + } + const channelId = readStringParam(params, "channelId", { + required: true, + }); + const messageId = readStringParam(params, "messageId", { + required: true, + }); + const emoji = readStringParam(params, "emoji", { required: true }); + await reactMessageDiscord(channelId, messageId, emoji); + return jsonResult({ ok: true }); + } + case "reactions": { + if (!isActionEnabled("reactions")) { + throw new Error("Discord reactions are disabled."); + } + const channelId = readStringParam(params, "channelId", { + required: true, + }); + const messageId = readStringParam(params, "messageId", { + required: true, + }); + const limitRaw = params.limit; + const limit = + typeof limitRaw === "number" && Number.isFinite(limitRaw) + ? limitRaw + : undefined; + const reactions = await fetchReactionsDiscord(channelId, messageId, { + limit, + }); + return jsonResult({ ok: true, reactions }); + } + case "sticker": { + if (!isActionEnabled("stickers")) { + throw new Error("Discord stickers are disabled."); + } + const to = readStringParam(params, "to", { required: true }); + const content = readStringParam(params, "content"); + const stickerIds = readStringArrayParam(params, "stickerIds", { + required: true, + label: "stickerIds", + }); + await sendStickerDiscord(to, stickerIds, { content }); + return jsonResult({ ok: true }); + } + case "poll": { + if (!isActionEnabled("polls")) { + throw new Error("Discord polls are disabled."); + } + const to = readStringParam(params, "to", { required: true }); + const content = readStringParam(params, "content"); + const question = readStringParam(params, "question", { + required: true, + }); + const answers = readStringArrayParam(params, "answers", { + required: true, + label: "answers", + }); + const allowMultiselectRaw = params.allowMultiselect; + const allowMultiselect = + typeof allowMultiselectRaw === "boolean" + ? allowMultiselectRaw + : undefined; + const durationRaw = params.durationHours; + const durationHours = + typeof durationRaw === "number" && Number.isFinite(durationRaw) + ? durationRaw + : undefined; + await sendPollDiscord( + to, + { question, answers, allowMultiselect, durationHours }, + { content }, + ); + return jsonResult({ ok: true }); + } + case "permissions": { + if (!isActionEnabled("permissions")) { + throw new Error("Discord permissions are disabled."); + } + const channelId = readStringParam(params, "channelId", { + required: true, + }); + const permissions = await fetchChannelPermissionsDiscord(channelId); + return jsonResult({ ok: true, permissions }); + } + case "readMessages": { + if (!isActionEnabled("messages")) { + throw new Error("Discord message reads are disabled."); + } + const channelId = readStringParam(params, "channelId", { + required: true, + }); + const messages = await readMessagesDiscord(channelId, { + limit: + typeof params.limit === "number" && Number.isFinite(params.limit) + ? params.limit + : undefined, + before: readStringParam(params, "before"), + after: readStringParam(params, "after"), + around: readStringParam(params, "around"), + }); + return jsonResult({ ok: true, messages }); + } + case "sendMessage": { + if (!isActionEnabled("messages")) { + throw new Error("Discord message sends are disabled."); + } + const to = readStringParam(params, "to", { required: true }); + const content = readStringParam(params, "content", { + required: true, + }); + const mediaUrl = readStringParam(params, "mediaUrl"); + const replyTo = readStringParam(params, "replyTo"); + const result = await sendMessageDiscord(to, content, { + mediaUrl, + replyTo, + }); + return jsonResult({ ok: true, result }); + } + case "editMessage": { + if (!isActionEnabled("messages")) { + throw new Error("Discord message edits are disabled."); + } + const channelId = readStringParam(params, "channelId", { + required: true, + }); + const messageId = readStringParam(params, "messageId", { + required: true, + }); + const content = readStringParam(params, "content", { + required: true, + }); + const message = await editMessageDiscord(channelId, messageId, { + content, + }); + return jsonResult({ ok: true, message }); + } + case "deleteMessage": { + if (!isActionEnabled("messages")) { + throw new Error("Discord message deletes are disabled."); + } + const channelId = readStringParam(params, "channelId", { + required: true, + }); + const messageId = readStringParam(params, "messageId", { + required: true, + }); + await deleteMessageDiscord(channelId, messageId); + return jsonResult({ ok: true }); + } + case "threadCreate": { + if (!isActionEnabled("threads")) { + throw new Error("Discord threads are disabled."); + } + const channelId = readStringParam(params, "channelId", { + required: true, + }); + const name = readStringParam(params, "name", { required: true }); + const messageId = readStringParam(params, "messageId"); + const autoArchiveMinutesRaw = params.autoArchiveMinutes; + const autoArchiveMinutes = + typeof autoArchiveMinutesRaw === "number" && + Number.isFinite(autoArchiveMinutesRaw) + ? autoArchiveMinutesRaw + : undefined; + const thread = await createThreadDiscord(channelId, { + name, + messageId, + autoArchiveMinutes, + }); + return jsonResult({ ok: true, thread }); + } + case "threadList": { + if (!isActionEnabled("threads")) { + throw new Error("Discord threads are disabled."); + } + const guildId = readStringParam(params, "guildId", { + required: true, + }); + const channelId = readStringParam(params, "channelId"); + const includeArchived = + typeof params.includeArchived === "boolean" + ? params.includeArchived + : undefined; + const before = readStringParam(params, "before"); + const limit = + typeof params.limit === "number" && Number.isFinite(params.limit) + ? params.limit + : undefined; + const threads = await listThreadsDiscord({ + guildId, + channelId, + includeArchived, + before, + limit, + }); + return jsonResult({ ok: true, threads }); + } + case "threadReply": { + if (!isActionEnabled("threads")) { + throw new Error("Discord threads are disabled."); + } + const channelId = readStringParam(params, "channelId", { + required: true, + }); + const content = readStringParam(params, "content", { + required: true, + }); + const mediaUrl = readStringParam(params, "mediaUrl"); + const replyTo = readStringParam(params, "replyTo"); + const result = await sendMessageDiscord( + `channel:${channelId}`, + content, + { + mediaUrl, + replyTo, + }, + ); + return jsonResult({ ok: true, result }); + } + case "pinMessage": { + if (!isActionEnabled("pins")) { + throw new Error("Discord pins are disabled."); + } + const channelId = readStringParam(params, "channelId", { + required: true, + }); + const messageId = readStringParam(params, "messageId", { + required: true, + }); + await pinMessageDiscord(channelId, messageId); + return jsonResult({ ok: true }); + } + case "unpinMessage": { + if (!isActionEnabled("pins")) { + throw new Error("Discord pins are disabled."); + } + const channelId = readStringParam(params, "channelId", { + required: true, + }); + const messageId = readStringParam(params, "messageId", { + required: true, + }); + await unpinMessageDiscord(channelId, messageId); + return jsonResult({ ok: true }); + } + case "listPins": { + if (!isActionEnabled("pins")) { + throw new Error("Discord pins are disabled."); + } + const channelId = readStringParam(params, "channelId", { + required: true, + }); + const pins = await listPinsDiscord(channelId); + return jsonResult({ ok: true, pins }); + } + case "searchMessages": { + if (!isActionEnabled("search")) { + throw new Error("Discord search is disabled."); + } + const guildId = readStringParam(params, "guildId", { + required: true, + }); + const content = readStringParam(params, "content", { + required: true, + }); + const channelId = readStringParam(params, "channelId"); + const channelIds = readStringArrayParam(params, "channelIds"); + const authorId = readStringParam(params, "authorId"); + const authorIds = readStringArrayParam(params, "authorIds"); + const limit = + typeof params.limit === "number" && Number.isFinite(params.limit) + ? params.limit + : undefined; + const channelIdList = [ + ...(channelIds ?? []), + ...(channelId ? [channelId] : []), + ]; + const authorIdList = [ + ...(authorIds ?? []), + ...(authorId ? [authorId] : []), + ]; + const results = await searchMessagesDiscord({ + guildId, + content, + channelIds: channelIdList.length ? channelIdList : undefined, + authorIds: authorIdList.length ? authorIdList : undefined, + limit, + }); + return jsonResult({ ok: true, results }); + } + case "memberInfo": { + if (!isActionEnabled("memberInfo")) { + throw new Error("Discord member info is disabled."); + } + const guildId = readStringParam(params, "guildId", { + required: true, + }); + const userId = readStringParam(params, "userId", { + required: true, + }); + const member = await fetchMemberInfoDiscord(guildId, userId); + return jsonResult({ ok: true, member }); + } + case "roleInfo": { + if (!isActionEnabled("roleInfo")) { + throw new Error("Discord role info is disabled."); + } + const guildId = readStringParam(params, "guildId", { + required: true, + }); + const roles = await fetchRoleInfoDiscord(guildId); + return jsonResult({ ok: true, roles }); + } + case "emojiList": { + if (!isActionEnabled("reactions")) { + throw new Error("Discord reactions are disabled."); + } + const guildId = readStringParam(params, "guildId", { + required: true, + }); + const emojis = await listGuildEmojisDiscord(guildId); + return jsonResult({ ok: true, emojis }); + } + case "roleAdd": { + if (!isActionEnabled("roles", false)) { + throw new Error("Discord role changes are disabled."); + } + const guildId = readStringParam(params, "guildId", { + required: true, + }); + const userId = readStringParam(params, "userId", { + required: true, + }); + const roleId = readStringParam(params, "roleId", { + required: true, + }); + await addRoleDiscord({ guildId, userId, roleId }); + return jsonResult({ ok: true }); + } + case "roleRemove": { + if (!isActionEnabled("roles", false)) { + throw new Error("Discord role changes are disabled."); + } + const guildId = readStringParam(params, "guildId", { + required: true, + }); + const userId = readStringParam(params, "userId", { + required: true, + }); + const roleId = readStringParam(params, "roleId", { + required: true, + }); + await removeRoleDiscord({ guildId, userId, roleId }); + return jsonResult({ ok: true }); + } + case "channelInfo": { + if (!isActionEnabled("channelInfo")) { + throw new Error("Discord channel info is disabled."); + } + const channelId = readStringParam(params, "channelId", { + required: true, + }); + const channel = await fetchChannelInfoDiscord(channelId); + return jsonResult({ ok: true, channel }); + } + case "channelList": { + if (!isActionEnabled("channelInfo")) { + throw new Error("Discord channel info is disabled."); + } + const guildId = readStringParam(params, "guildId", { + required: true, + }); + const channels = await listGuildChannelsDiscord(guildId); + return jsonResult({ ok: true, channels }); + } + case "voiceStatus": { + if (!isActionEnabled("voiceStatus")) { + throw new Error("Discord voice status is disabled."); + } + const guildId = readStringParam(params, "guildId", { + required: true, + }); + const userId = readStringParam(params, "userId", { + required: true, + }); + const voice = await fetchVoiceStatusDiscord(guildId, userId); + return jsonResult({ ok: true, voice }); + } + case "eventList": { + if (!isActionEnabled("events")) { + throw new Error("Discord events are disabled."); + } + const guildId = readStringParam(params, "guildId", { + required: true, + }); + const events = await listScheduledEventsDiscord(guildId); + return jsonResult({ ok: true, events }); + } + case "eventCreate": { + if (!isActionEnabled("events")) { + throw new Error("Discord events are disabled."); + } + const guildId = readStringParam(params, "guildId", { + required: true, + }); + const name = readStringParam(params, "name", { required: true }); + const startTime = readStringParam(params, "startTime", { + required: true, + }); + const endTime = readStringParam(params, "endTime"); + const description = readStringParam(params, "description"); + const channelId = readStringParam(params, "channelId"); + const location = readStringParam(params, "location"); + const entityTypeRaw = readStringParam(params, "entityType"); + const entityType = + entityTypeRaw === "stage" + ? 1 + : entityTypeRaw === "external" + ? 3 + : 2; + const payload = { + name, + description, + scheduled_start_time: startTime, + scheduled_end_time: endTime, + entity_type: entityType, + channel_id: channelId, + entity_metadata: + entityType === 3 && location ? { location } : undefined, + privacy_level: 2, + }; + const event = await createScheduledEventDiscord(guildId, payload); + return jsonResult({ ok: true, event }); + } + case "timeout": { + if (!isActionEnabled("moderation", false)) { + throw new Error("Discord moderation is disabled."); + } + const guildId = readStringParam(params, "guildId", { + required: true, + }); + const userId = readStringParam(params, "userId", { + required: true, + }); + const durationMinutes = + typeof params.durationMinutes === "number" && + Number.isFinite(params.durationMinutes) + ? params.durationMinutes + : undefined; + const until = readStringParam(params, "until"); + const reason = readStringParam(params, "reason"); + const member = await timeoutMemberDiscord({ + guildId, + userId, + durationMinutes, + until, + reason, + }); + return jsonResult({ ok: true, member }); + } + case "kick": { + if (!isActionEnabled("moderation", false)) { + throw new Error("Discord moderation is disabled."); + } + const guildId = readStringParam(params, "guildId", { + required: true, + }); + const userId = readStringParam(params, "userId", { + required: true, + }); + const reason = readStringParam(params, "reason"); + await kickMemberDiscord({ guildId, userId, reason }); + return jsonResult({ ok: true }); + } + case "ban": { + if (!isActionEnabled("moderation", false)) { + throw new Error("Discord moderation is disabled."); + } + const guildId = readStringParam(params, "guildId", { + required: true, + }); + const userId = readStringParam(params, "userId", { + required: true, + }); + const reason = readStringParam(params, "reason"); + const deleteMessageDays = + typeof params.deleteMessageDays === "number" && + Number.isFinite(params.deleteMessageDays) + ? params.deleteMessageDays + : undefined; + await banMemberDiscord({ + guildId, + userId, + reason, + deleteMessageDays, + }); + return jsonResult({ ok: true }); + } + default: + throw new Error(`Unknown action: ${action}`); } - - const channelId = readStringParam(params, "channelId", { - required: true, - }); - const messageId = readStringParam(params, "messageId", { - required: true, - }); - const emoji = readStringParam(params, "emoji", { required: true }); - - await reactMessageDiscord(channelId, messageId, emoji); - return jsonResult({ ok: true }); }, }; } diff --git a/src/config/config.ts b/src/config/config.ts index 77347c4fa..75dd3c03f 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -221,6 +221,24 @@ export type DiscordSlashCommandConfig = { ephemeral?: boolean; }; +export type DiscordActionConfig = { + reactions?: boolean; + stickers?: boolean; + polls?: boolean; + permissions?: boolean; + messages?: boolean; + threads?: boolean; + pins?: boolean; + search?: boolean; + memberInfo?: boolean; + roleInfo?: boolean; + roles?: boolean; + channelInfo?: boolean; + voiceStatus?: boolean; + events?: boolean; + moderation?: boolean; +}; + export type DiscordConfig = { /** If false, do not start the Discord provider. Default: true. */ enabled?: boolean; @@ -231,6 +249,8 @@ export type DiscordConfig = { historyLimit?: number; /** Allow agent-triggered Discord reactions (default: true). */ enableReactions?: boolean; + /** Per-action tool gating (default: true for all). */ + actions?: DiscordActionConfig; /** Control reply threading when reply tags are present (off|first|all). */ replyToMode?: ReplyToMode; slashCommand?: DiscordSlashCommandConfig; @@ -1033,6 +1053,25 @@ const ClawdisSchema = z.object({ mediaMaxMb: z.number().positive().optional(), historyLimit: z.number().int().min(0).optional(), enableReactions: z.boolean().optional(), + actions: z + .object({ + reactions: z.boolean().optional(), + stickers: z.boolean().optional(), + polls: z.boolean().optional(), + permissions: z.boolean().optional(), + messages: z.boolean().optional(), + threads: z.boolean().optional(), + pins: z.boolean().optional(), + search: z.boolean().optional(), + memberInfo: z.boolean().optional(), + roleInfo: z.boolean().optional(), + roles: z.boolean().optional(), + channelInfo: z.boolean().optional(), + voiceStatus: z.boolean().optional(), + events: z.boolean().optional(), + moderation: z.boolean().optional(), + }) + .optional(), replyToMode: ReplyToModeSchema.optional(), dm: z .object({ diff --git a/src/discord/send.test.ts b/src/discord/send.test.ts index 9e469c198..f06c1fd0e 100644 --- a/src/discord/send.test.ts +++ b/src/discord/send.test.ts @@ -1,7 +1,27 @@ -import { Routes } from "discord.js"; +import { PermissionsBitField, Routes } from "discord.js"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { sendMessageDiscord } from "./send.js"; +import { + addRoleDiscord, + banMemberDiscord, + createThreadDiscord, + deleteMessageDiscord, + editMessageDiscord, + fetchChannelPermissionsDiscord, + fetchReactionsDiscord, + listGuildEmojisDiscord, + listThreadsDiscord, + pinMessageDiscord, + reactMessageDiscord, + readMessagesDiscord, + removeRoleDiscord, + searchMessagesDiscord, + sendMessageDiscord, + sendPollDiscord, + sendStickerDiscord, + timeoutMemberDiscord, + unpinMessageDiscord, +} from "./send.js"; vi.mock("../web/media.js", () => ({ loadWebMedia: vi.fn().mockResolvedValue({ @@ -14,11 +34,23 @@ vi.mock("../web/media.js", () => ({ const makeRest = () => { const postMock = vi.fn(); + const putMock = vi.fn(); + const getMock = vi.fn(); + const patchMock = vi.fn(); + const deleteMock = vi.fn(); return { rest: { post: postMock, + put: putMock, + get: getMock, + patch: patchMock, + delete: deleteMock, } as unknown as import("discord.js").REST, postMock, + putMock, + getMock, + patchMock, + deleteMock, }; }; @@ -116,3 +148,369 @@ describe("sendMessageDiscord", () => { expect(secondBody?.message_reference).toBeUndefined(); }); }); + +describe("reactMessageDiscord", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("reacts with unicode emoji", async () => { + const { rest, putMock } = makeRest(); + await reactMessageDiscord("chan1", "msg1", "✅", { rest, token: "t" }); + expect(putMock).toHaveBeenCalledWith( + Routes.channelMessageOwnReaction("chan1", "msg1", "%E2%9C%85"), + ); + }); + + it("normalizes variation selectors in unicode emoji", async () => { + const { rest, putMock } = makeRest(); + await reactMessageDiscord("chan1", "msg1", "⭐️", { rest, token: "t" }); + expect(putMock).toHaveBeenCalledWith( + Routes.channelMessageOwnReaction("chan1", "msg1", "%E2%AD%90"), + ); + }); + + it("reacts with custom emoji syntax", async () => { + const { rest, putMock } = makeRest(); + await reactMessageDiscord("chan1", "msg1", "<:party_blob:123>", { + rest, + token: "t", + }); + expect(putMock).toHaveBeenCalledWith( + Routes.channelMessageOwnReaction("chan1", "msg1", "party_blob%3A123"), + ); + }); +}); + +describe("fetchReactionsDiscord", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns reactions with users", async () => { + const { rest, getMock } = makeRest(); + getMock + .mockResolvedValueOnce({ + reactions: [ + { count: 2, emoji: { name: "✅", id: null } }, + { count: 1, emoji: { name: "party_blob", id: "123" } }, + ], + }) + .mockResolvedValueOnce([ + { id: "u1", username: "alpha", discriminator: "0001" }, + ]) + .mockResolvedValueOnce([{ id: "u2", username: "beta" }]); + const res = await fetchReactionsDiscord("chan1", "msg1", { + rest, + token: "t", + }); + expect(res).toEqual([ + { + emoji: { id: null, name: "✅", raw: "✅" }, + count: 2, + users: [{ id: "u1", username: "alpha", tag: "alpha#0001" }], + }, + { + emoji: { id: "123", name: "party_blob", raw: "party_blob:123" }, + count: 1, + users: [{ id: "u2", username: "beta", tag: "beta" }], + }, + ]); + }); +}); + +describe("fetchChannelPermissionsDiscord", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("calculates permissions from guild roles", async () => { + const { rest, getMock } = makeRest(); + const perms = new PermissionsBitField([ + PermissionsBitField.Flags.ViewChannel, + PermissionsBitField.Flags.SendMessages, + ]); + getMock + .mockResolvedValueOnce({ + id: "chan1", + guild_id: "guild1", + permission_overwrites: [], + }) + .mockResolvedValueOnce({ id: "bot1" }) + .mockResolvedValueOnce({ + id: "guild1", + roles: [ + { id: "guild1", permissions: perms.bitfield.toString() }, + { id: "role2", permissions: "0" }, + ], + }) + .mockResolvedValueOnce({ roles: ["role2"] }); + const res = await fetchChannelPermissionsDiscord("chan1", { + rest, + token: "t", + }); + expect(res.guildId).toBe("guild1"); + expect(res.permissions).toContain("ViewChannel"); + expect(res.permissions).toContain("SendMessages"); + expect(res.isDm).toBe(false); + }); +}); + +describe("readMessagesDiscord", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("passes query params as URLSearchParams", async () => { + const { rest, getMock } = makeRest(); + getMock.mockResolvedValue([]); + await readMessagesDiscord( + "chan1", + { limit: 5, before: "10" }, + { rest, token: "t" }, + ); + const call = getMock.mock.calls[0]; + const options = call?.[1] as { query?: URLSearchParams }; + expect(options.query?.toString()).toBe("limit=5&before=10"); + }); +}); + +describe("edit/delete message helpers", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("edits message content", async () => { + const { rest, patchMock } = makeRest(); + patchMock.mockResolvedValue({ id: "m1" }); + await editMessageDiscord( + "chan1", + "m1", + { content: "hello" }, + { rest, token: "t" }, + ); + expect(patchMock).toHaveBeenCalledWith( + Routes.channelMessage("chan1", "m1"), + expect.objectContaining({ body: { content: "hello" } }), + ); + }); + + it("deletes message", async () => { + const { rest, deleteMock } = makeRest(); + deleteMock.mockResolvedValue({}); + await deleteMessageDiscord("chan1", "m1", { rest, token: "t" }); + expect(deleteMock).toHaveBeenCalledWith( + Routes.channelMessage("chan1", "m1"), + ); + }); +}); + +describe("pin helpers", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("pins and unpins messages", async () => { + const { rest, putMock, deleteMock } = makeRest(); + putMock.mockResolvedValue({}); + deleteMock.mockResolvedValue({}); + await pinMessageDiscord("chan1", "m1", { rest, token: "t" }); + await unpinMessageDiscord("chan1", "m1", { rest, token: "t" }); + expect(putMock).toHaveBeenCalledWith(Routes.channelPin("chan1", "m1")); + expect(deleteMock).toHaveBeenCalledWith(Routes.channelPin("chan1", "m1")); + }); +}); + +describe("searchMessagesDiscord", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("uses URLSearchParams for search", async () => { + const { rest, getMock } = makeRest(); + getMock.mockResolvedValue({ total_results: 0, messages: [] }); + await searchMessagesDiscord( + { guildId: "g1", content: "hello", limit: 5 }, + { rest, token: "t" }, + ); + const call = getMock.mock.calls[0]; + const options = call?.[1] as { query?: URLSearchParams }; + expect(options.query?.toString()).toBe("content=hello&limit=5"); + }); + + it("supports channel/author arrays and clamps limit", async () => { + const { rest, getMock } = makeRest(); + getMock.mockResolvedValue({ total_results: 0, messages: [] }); + await searchMessagesDiscord( + { + guildId: "g1", + content: "hello", + channelIds: ["c1", "c2"], + authorIds: ["u1"], + limit: 99, + }, + { rest, token: "t" }, + ); + const call = getMock.mock.calls[0]; + const options = call?.[1] as { query?: URLSearchParams }; + expect(options.query?.toString()).toBe( + "content=hello&channel_id=c1&channel_id=c2&author_id=u1&limit=25", + ); + }); +}); + +describe("threads and moderation helpers", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("creates a thread", async () => { + const { rest, postMock } = makeRest(); + postMock.mockResolvedValue({ id: "t1" }); + await createThreadDiscord( + "chan1", + { name: "thread", messageId: "m1" }, + { rest, token: "t" }, + ); + expect(postMock).toHaveBeenCalledWith( + Routes.threads("chan1", "m1"), + expect.objectContaining({ body: { name: "thread" } }), + ); + }); + + it("lists active threads by guild", async () => { + const { rest, getMock } = makeRest(); + getMock.mockResolvedValue({ threads: [] }); + await listThreadsDiscord({ guildId: "g1" }, { rest, token: "t" }); + expect(getMock).toHaveBeenCalledWith(Routes.guildActiveThreads("g1")); + }); + + it("times out a member", async () => { + const { rest, patchMock } = makeRest(); + patchMock.mockResolvedValue({ id: "m1" }); + await timeoutMemberDiscord( + { guildId: "g1", userId: "u1", durationMinutes: 10 }, + { rest, token: "t" }, + ); + expect(patchMock).toHaveBeenCalledWith( + Routes.guildMember("g1", "u1"), + expect.objectContaining({ + body: expect.objectContaining({ + communication_disabled_until: expect.any(String), + }), + }), + ); + }); + + it("adds and removes roles", async () => { + const { rest, putMock, deleteMock } = makeRest(); + putMock.mockResolvedValue({}); + deleteMock.mockResolvedValue({}); + await addRoleDiscord( + { guildId: "g1", userId: "u1", roleId: "r1" }, + { rest, token: "t" }, + ); + await removeRoleDiscord( + { guildId: "g1", userId: "u1", roleId: "r1" }, + { rest, token: "t" }, + ); + expect(putMock).toHaveBeenCalledWith( + Routes.guildMemberRole("g1", "u1", "r1"), + ); + expect(deleteMock).toHaveBeenCalledWith( + Routes.guildMemberRole("g1", "u1", "r1"), + ); + }); + + it("bans a member", async () => { + const { rest, putMock } = makeRest(); + putMock.mockResolvedValue({}); + await banMemberDiscord( + { guildId: "g1", userId: "u1", deleteMessageDays: 2 }, + { rest, token: "t" }, + ); + expect(putMock).toHaveBeenCalledWith( + Routes.guildBan("g1", "u1"), + expect.objectContaining({ body: { delete_message_days: 2 } }), + ); + }); +}); + +describe("listGuildEmojisDiscord", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("lists emojis for a guild", async () => { + const { rest, getMock } = makeRest(); + getMock.mockResolvedValue([{ id: "e1", name: "party" }]); + await listGuildEmojisDiscord("g1", { rest, token: "t" }); + expect(getMock).toHaveBeenCalledWith(Routes.guildEmojis("g1")); + }); +}); + +describe("sendStickerDiscord", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("sends sticker payloads", async () => { + const { rest, postMock } = makeRest(); + postMock.mockResolvedValue({ id: "msg1", channel_id: "789" }); + const res = await sendStickerDiscord("channel:789", ["123"], { + rest, + token: "t", + content: "hiya", + }); + expect(res).toEqual({ messageId: "msg1", channelId: "789" }); + expect(postMock).toHaveBeenCalledWith( + Routes.channelMessages("789"), + expect.objectContaining({ + body: { + content: "hiya", + sticker_ids: ["123"], + }, + }), + ); + }); +}); + +describe("sendPollDiscord", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("sends polls with answers", async () => { + const { rest, postMock } = makeRest(); + postMock.mockResolvedValue({ id: "msg1", channel_id: "789" }); + const res = await sendPollDiscord( + "channel:789", + { + question: "Lunch?", + answers: ["Pizza", "Sushi"], + }, + { + rest, + token: "t", + }, + ); + expect(res).toEqual({ messageId: "msg1", channelId: "789" }); + expect(postMock).toHaveBeenCalledWith( + Routes.channelMessages("789"), + expect.objectContaining({ + body: expect.objectContaining({ + poll: { + question: { text: "Lunch?" }, + answers: [ + { poll_media: { text: "Pizza" } }, + { poll_media: { text: "Sushi" } }, + ], + duration: 24, + allow_multiselect: false, + layout_type: 1, + }, + }), + }), + ); + }); +}); diff --git a/src/discord/send.ts b/src/discord/send.ts index 9763dfc94..c1113e42f 100644 --- a/src/discord/send.ts +++ b/src/discord/send.ts @@ -1,4 +1,16 @@ -import { REST, Routes } from "discord.js"; +import { PermissionsBitField, REST, Routes } from "discord.js"; +import { PollLayoutType } from "discord-api-types/payloads/v10"; +import type { RESTAPIPoll } from "discord-api-types/rest/v10"; +import type { + APIChannel, + APIGuild, + APIGuildMember, + APIGuildScheduledEvent, + APIMessage, + APIRole, + APIVoiceState, + RESTPostAPIGuildScheduledEventJSONBody, +} from "discord-api-types/v10"; import { chunkText } from "../auto-reply/chunk.js"; import { loadConfig } from "../config/config.js"; @@ -6,6 +18,10 @@ import { loadWebMedia } from "../web/media.js"; import { normalizeDiscordToken } from "./token.js"; const DISCORD_TEXT_LIMIT = 2000; +const DISCORD_MAX_STICKERS = 3; +const DISCORD_POLL_MIN_ANSWERS = 2; +const DISCORD_POLL_MAX_ANSWERS = 10; +const DISCORD_POLL_MAX_DURATION_HOURS = 32 * 24; type DiscordRecipient = | { @@ -30,11 +46,88 @@ export type DiscordSendResult = { channelId: string; }; +export type DiscordPollInput = { + question: string; + answers: string[]; + allowMultiselect?: boolean; + durationHours?: number; +}; + export type DiscordReactOpts = { token?: string; rest?: REST; }; +export type DiscordReactionUser = { + id: string; + username?: string; + tag?: string; +}; + +export type DiscordReactionSummary = { + emoji: { id?: string | null; name?: string | null; raw: string }; + count: number; + users: DiscordReactionUser[]; +}; + +export type DiscordPermissionsSummary = { + channelId: string; + guildId?: string; + permissions: string[]; + raw: string; + isDm: boolean; +}; + +export type DiscordMessageQuery = { + limit?: number; + before?: string; + after?: string; + around?: string; +}; + +export type DiscordMessageEdit = { + content: string; +}; + +export type DiscordThreadCreate = { + name: string; + messageId?: string; + autoArchiveMinutes?: number; +}; + +export type DiscordThreadList = { + guildId: string; + channelId?: string; + includeArchived?: boolean; + before?: string; + limit?: number; +}; + +export type DiscordSearchQuery = { + guildId: string; + content: string; + channelIds?: string[]; + authorIds?: string[]; + limit?: number; +}; + +export type DiscordRoleChange = { + guildId: string; + userId: string; + roleId: string; +}; + +export type DiscordModerationTarget = { + guildId: string; + userId: string; + reason?: string; +}; + +export type DiscordTimeoutTarget = DiscordModerationTarget & { + durationMinutes?: number; + until?: string; +}; + function resolveToken(explicit?: string) { const cfgToken = loadConfig().discord?.token; const token = normalizeDiscordToken( @@ -56,7 +149,7 @@ function normalizeReactionEmoji(raw: string) { const customMatch = trimmed.match(/^]+):(\d+)>$/); const identifier = customMatch ? `${customMatch[1]}:${customMatch[2]}` - : trimmed; + : trimmed.replace(/[\uFE0E\uFE0F]/g, ""); return encodeURIComponent(identifier); } @@ -90,6 +183,49 @@ function parseRecipient(raw: string): DiscordRecipient { return { kind: "channel", id: trimmed }; } +function normalizeStickerIds(raw: string[]) { + const ids = raw.map((entry) => entry.trim()).filter(Boolean); + if (ids.length === 0) { + throw new Error("At least one sticker id is required"); + } + if (ids.length > DISCORD_MAX_STICKERS) { + throw new Error("Discord supports up to 3 stickers per message"); + } + return ids; +} + +function normalizePollInput(input: DiscordPollInput): RESTAPIPoll { + const question = input.question.trim(); + if (!question) { + throw new Error("Poll question is required"); + } + const answers = (input.answers ?? []) + .map((answer) => answer.trim()) + .filter(Boolean); + if (answers.length < DISCORD_POLL_MIN_ANSWERS) { + throw new Error("Polls require at least 2 answers"); + } + if (answers.length > DISCORD_POLL_MAX_ANSWERS) { + throw new Error("Polls support up to 10 answers"); + } + const durationRaw = + typeof input.durationHours === "number" && + Number.isFinite(input.durationHours) + ? Math.floor(input.durationHours) + : 24; + const duration = Math.min( + Math.max(durationRaw, 1), + DISCORD_POLL_MAX_DURATION_HOURS, + ); + return { + question: { text: question }, + answers: answers.map((answer) => ({ poll_media: { text: answer } })), + duration, + allow_multiselect: input.allowMultiselect ?? false, + layout_type: PollLayoutType.Default, + }; +} + async function resolveChannelId( rest: REST, recipient: DiscordRecipient, @@ -176,6 +312,31 @@ async function sendDiscordMedia( return res; } +function buildReactionIdentifier(emoji: { + id?: string | null; + name?: string | null; +}) { + if (emoji.id && emoji.name) { + return `${emoji.name}:${emoji.id}`; + } + return emoji.name ?? ""; +} + +function formatReactionEmoji(emoji: { + id?: string | null; + name?: string | null; +}) { + return buildReactionIdentifier(emoji); +} + +async function fetchBotUserId(rest: REST) { + const me = (await rest.get(Routes.user("@me"))) as { id?: string }; + if (!me?.id) { + throw new Error("Failed to resolve bot user id"); + } + return me.id; +} + export async function sendMessageDiscord( to: string, text: string, @@ -207,6 +368,52 @@ export async function sendMessageDiscord( }; } +export async function sendStickerDiscord( + to: string, + stickerIds: string[], + opts: DiscordSendOpts & { content?: string } = {}, +): Promise { + const token = resolveToken(opts.token); + const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const recipient = parseRecipient(to); + const { channelId } = await resolveChannelId(rest, recipient); + const content = opts.content?.trim(); + const stickers = normalizeStickerIds(stickerIds); + const res = (await rest.post(Routes.channelMessages(channelId), { + body: { + content: content || undefined, + sticker_ids: stickers, + }, + })) as { id: string; channel_id: string }; + return { + messageId: res.id ? String(res.id) : "unknown", + channelId: String(res.channel_id ?? channelId), + }; +} + +export async function sendPollDiscord( + to: string, + poll: DiscordPollInput, + opts: DiscordSendOpts & { content?: string } = {}, +): Promise { + const token = resolveToken(opts.token); + const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const recipient = parseRecipient(to); + const { channelId } = await resolveChannelId(rest, recipient); + const content = opts.content?.trim(); + const payload = normalizePollInput(poll); + const res = (await rest.post(Routes.channelMessages(channelId), { + body: { + content: content || undefined, + poll: payload, + }, + })) as { id: string; channel_id: string }; + return { + messageId: res.id ? String(res.id) : "unknown", + channelId: String(res.channel_id ?? channelId), + }; +} + export async function reactMessageDiscord( channelId: string, messageId: string, @@ -216,6 +423,428 @@ export async function reactMessageDiscord( const token = resolveToken(opts.token); const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); const encoded = normalizeReactionEmoji(emoji); - await rest.put(Routes.channelMessageReaction(channelId, messageId, encoded)); + await rest.put( + Routes.channelMessageOwnReaction(channelId, messageId, encoded), + ); + return { ok: true }; +} + +export async function fetchReactionsDiscord( + channelId: string, + messageId: string, + opts: DiscordReactOpts & { limit?: number } = {}, +): Promise { + const token = resolveToken(opts.token); + const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const message = (await rest.get( + Routes.channelMessage(channelId, messageId), + )) as { + reactions?: Array<{ + count: number; + emoji: { id?: string | null; name?: string | null }; + }>; + }; + const reactions = message.reactions ?? []; + if (reactions.length === 0) return []; + const limit = + typeof opts.limit === "number" && Number.isFinite(opts.limit) + ? Math.min(Math.max(Math.floor(opts.limit), 1), 100) + : 100; + + const summaries: DiscordReactionSummary[] = []; + for (const reaction of reactions) { + const identifier = buildReactionIdentifier(reaction.emoji); + if (!identifier) continue; + const encoded = encodeURIComponent(identifier); + const users = (await rest.get( + Routes.channelMessageReaction(channelId, messageId, encoded), + { query: new URLSearchParams({ limit: String(limit) }) }, + )) as Array<{ id: string; username?: string; discriminator?: string }>; + summaries.push({ + emoji: { + id: reaction.emoji.id ?? null, + name: reaction.emoji.name ?? null, + raw: formatReactionEmoji(reaction.emoji), + }, + count: reaction.count, + users: users.map((user) => ({ + id: user.id, + username: user.username, + tag: + user.username && user.discriminator + ? `${user.username}#${user.discriminator}` + : user.username, + })), + }); + } + return summaries; +} + +export async function fetchChannelPermissionsDiscord( + channelId: string, + opts: DiscordReactOpts = {}, +): Promise { + const token = resolveToken(opts.token); + const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const channel = (await rest.get(Routes.channel(channelId))) as APIChannel; + const guildId = "guild_id" in channel ? channel.guild_id : undefined; + if (!guildId) { + return { + channelId, + permissions: [], + raw: "0", + isDm: true, + }; + } + + const botId = await fetchBotUserId(rest); + const [guild, member] = await Promise.all([ + rest.get(Routes.guild(guildId)) as Promise, + rest.get(Routes.guildMember(guildId, botId)) as Promise, + ]); + + const rolesById = new Map( + (guild.roles ?? []).map((role) => [role.id, role]), + ); + const base = new PermissionsBitField(); + const everyoneRole = rolesById.get(guildId); + if (everyoneRole?.permissions) { + base.add(BigInt(everyoneRole.permissions)); + } + for (const roleId of member.roles ?? []) { + const role = rolesById.get(roleId); + if (role?.permissions) { + base.add(BigInt(role.permissions)); + } + } + + const permissions = new PermissionsBitField(base); + const overwrites = + "permission_overwrites" in channel + ? (channel.permission_overwrites ?? []) + : []; + for (const overwrite of overwrites) { + if (overwrite.id === guildId) { + permissions.remove(BigInt(overwrite.deny ?? "0")); + permissions.add(BigInt(overwrite.allow ?? "0")); + } + } + for (const overwrite of overwrites) { + if (member.roles?.includes(overwrite.id)) { + permissions.remove(BigInt(overwrite.deny ?? "0")); + permissions.add(BigInt(overwrite.allow ?? "0")); + } + } + for (const overwrite of overwrites) { + if (overwrite.id === botId) { + permissions.remove(BigInt(overwrite.deny ?? "0")); + permissions.add(BigInt(overwrite.allow ?? "0")); + } + } + + return { + channelId, + guildId, + permissions: permissions.toArray(), + raw: permissions.bitfield.toString(), + isDm: false, + }; +} + +export async function readMessagesDiscord( + channelId: string, + query: DiscordMessageQuery = {}, + opts: DiscordReactOpts = {}, +): Promise { + const token = resolveToken(opts.token); + const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const limit = + typeof query.limit === "number" && Number.isFinite(query.limit) + ? Math.min(Math.max(Math.floor(query.limit), 1), 100) + : undefined; + const params = new URLSearchParams(); + if (limit) params.set("limit", String(limit)); + if (query.before) params.set("before", query.before); + if (query.after) params.set("after", query.after); + if (query.around) params.set("around", query.around); + return (await rest.get(Routes.channelMessages(channelId), { + query: params, + })) as APIMessage[]; +} + +export async function editMessageDiscord( + channelId: string, + messageId: string, + payload: DiscordMessageEdit, + opts: DiscordReactOpts = {}, +): Promise { + const token = resolveToken(opts.token); + const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + return (await rest.patch(Routes.channelMessage(channelId, messageId), { + body: { content: payload.content }, + })) as APIMessage; +} + +export async function deleteMessageDiscord( + channelId: string, + messageId: string, + opts: DiscordReactOpts = {}, +) { + const token = resolveToken(opts.token); + const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + await rest.delete(Routes.channelMessage(channelId, messageId)); + return { ok: true }; +} + +export async function pinMessageDiscord( + channelId: string, + messageId: string, + opts: DiscordReactOpts = {}, +) { + const token = resolveToken(opts.token); + const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + await rest.put(Routes.channelPin(channelId, messageId)); + return { ok: true }; +} + +export async function unpinMessageDiscord( + channelId: string, + messageId: string, + opts: DiscordReactOpts = {}, +) { + const token = resolveToken(opts.token); + const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + await rest.delete(Routes.channelPin(channelId, messageId)); + return { ok: true }; +} + +export async function listPinsDiscord( + channelId: string, + opts: DiscordReactOpts = {}, +): Promise { + const token = resolveToken(opts.token); + const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + return (await rest.get(Routes.channelPins(channelId))) as APIMessage[]; +} + +export async function createThreadDiscord( + channelId: string, + payload: DiscordThreadCreate, + opts: DiscordReactOpts = {}, +) { + const token = resolveToken(opts.token); + const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const body: Record = { name: payload.name }; + if (payload.autoArchiveMinutes) { + body.auto_archive_duration = payload.autoArchiveMinutes; + } + const route = Routes.threads(channelId, payload.messageId); + return await rest.post(route, { body }); +} + +export async function listThreadsDiscord( + payload: DiscordThreadList, + opts: DiscordReactOpts = {}, +) { + const token = resolveToken(opts.token); + const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + if (payload.includeArchived) { + if (!payload.channelId) { + throw new Error("channelId required to list archived threads"); + } + const params = new URLSearchParams(); + if (payload.before) params.set("before", payload.before); + if (payload.limit) params.set("limit", String(payload.limit)); + return await rest.get(Routes.channelThreads(payload.channelId, "public"), { + query: params, + }); + } + return await rest.get(Routes.guildActiveThreads(payload.guildId)); +} + +export async function searchMessagesDiscord( + query: DiscordSearchQuery, + opts: DiscordReactOpts = {}, +) { + const token = resolveToken(opts.token); + const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const params = new URLSearchParams(); + params.set("content", query.content); + if (query.channelIds?.length) { + for (const channelId of query.channelIds) { + params.append("channel_id", channelId); + } + } + if (query.authorIds?.length) { + for (const authorId of query.authorIds) { + params.append("author_id", authorId); + } + } + if (query.limit) { + const limit = Math.min(Math.max(Math.floor(query.limit), 1), 25); + params.set("limit", String(limit)); + } + return await rest.get(`/guilds/${query.guildId}/messages/search`, { + query: params, + }); +} + +export async function listGuildEmojisDiscord( + guildId: string, + opts: DiscordReactOpts = {}, +) { + const token = resolveToken(opts.token); + const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + return await rest.get(Routes.guildEmojis(guildId)); +} + +export async function fetchMemberInfoDiscord( + guildId: string, + userId: string, + opts: DiscordReactOpts = {}, +): Promise { + const token = resolveToken(opts.token); + const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + return (await rest.get( + Routes.guildMember(guildId, userId), + )) as APIGuildMember; +} + +export async function fetchRoleInfoDiscord( + guildId: string, + opts: DiscordReactOpts = {}, +): Promise { + const token = resolveToken(opts.token); + const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + return (await rest.get(Routes.guildRoles(guildId))) as APIRole[]; +} + +export async function addRoleDiscord( + payload: DiscordRoleChange, + opts: DiscordReactOpts = {}, +) { + const token = resolveToken(opts.token); + const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + await rest.put( + Routes.guildMemberRole(payload.guildId, payload.userId, payload.roleId), + ); + return { ok: true }; +} + +export async function removeRoleDiscord( + payload: DiscordRoleChange, + opts: DiscordReactOpts = {}, +) { + const token = resolveToken(opts.token); + const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + await rest.delete( + Routes.guildMemberRole(payload.guildId, payload.userId, payload.roleId), + ); + return { ok: true }; +} + +export async function fetchChannelInfoDiscord( + channelId: string, + opts: DiscordReactOpts = {}, +): Promise { + const token = resolveToken(opts.token); + const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + return (await rest.get(Routes.channel(channelId))) as APIChannel; +} + +export async function listGuildChannelsDiscord( + guildId: string, + opts: DiscordReactOpts = {}, +): Promise { + const token = resolveToken(opts.token); + const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + return (await rest.get(Routes.guildChannels(guildId))) as APIChannel[]; +} + +export async function fetchVoiceStatusDiscord( + guildId: string, + userId: string, + opts: DiscordReactOpts = {}, +): Promise { + const token = resolveToken(opts.token); + const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + return (await rest.get( + Routes.guildVoiceState(guildId, userId), + )) as APIVoiceState; +} + +export async function listScheduledEventsDiscord( + guildId: string, + opts: DiscordReactOpts = {}, +): Promise { + const token = resolveToken(opts.token); + const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + return (await rest.get( + Routes.guildScheduledEvents(guildId), + )) as APIGuildScheduledEvent[]; +} + +export async function createScheduledEventDiscord( + guildId: string, + payload: RESTPostAPIGuildScheduledEventJSONBody, + opts: DiscordReactOpts = {}, +): Promise { + const token = resolveToken(opts.token); + const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + return (await rest.post(Routes.guildScheduledEvents(guildId), { + body: payload, + })) as APIGuildScheduledEvent; +} + +export async function timeoutMemberDiscord( + payload: DiscordTimeoutTarget, + opts: DiscordReactOpts = {}, +): Promise { + const token = resolveToken(opts.token); + const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + let until = payload.until; + if (!until && payload.durationMinutes) { + const ms = payload.durationMinutes * 60 * 1000; + until = new Date(Date.now() + ms).toISOString(); + } + return (await rest.patch( + Routes.guildMember(payload.guildId, payload.userId), + { + body: { communication_disabled_until: until ?? null }, + reason: payload.reason, + }, + )) as APIGuildMember; +} + +export async function kickMemberDiscord( + payload: DiscordModerationTarget, + opts: DiscordReactOpts = {}, +) { + const token = resolveToken(opts.token); + const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + await rest.delete(Routes.guildMember(payload.guildId, payload.userId), { + reason: payload.reason, + }); + return { ok: true }; +} + +export async function banMemberDiscord( + payload: DiscordModerationTarget & { deleteMessageDays?: number }, + opts: DiscordReactOpts = {}, +) { + const token = resolveToken(opts.token); + const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const deleteMessageDays = + typeof payload.deleteMessageDays === "number" && + Number.isFinite(payload.deleteMessageDays) + ? Math.min(Math.max(Math.floor(payload.deleteMessageDays), 0), 7) + : undefined; + await rest.put(Routes.guildBan(payload.guildId, payload.userId), { + body: + deleteMessageDays !== undefined + ? { delete_message_days: deleteMessageDays } + : undefined, + reason: payload.reason, + }); return { ok: true }; }