From 3a28e3562c8d21b0aa6af2b8ab733002b10171ff Mon Sep 17 00:00:00 2001 From: Shadow Date: Sat, 3 Jan 2026 21:19:18 -0600 Subject: [PATCH] Discord: tools for uploading emojis and stickers! --- skills/discord/SKILL.md | 37 +++++++++++++- src/agents/clawdis-tools.ts | 61 +++++++++++++++++++++++ src/agents/tool-display.json | 2 + src/config/config.ts | 2 + src/discord/send.test.ts | 75 ++++++++++++++++++++++++++++ src/discord/send.ts | 95 +++++++++++++++++++++++++++++++++++- src/web/media.ts | 54 +++++++++++++++----- 7 files changed, 313 insertions(+), 13 deletions(-) diff --git a/skills/discord/SKILL.md b/skills/discord/SKILL.md index 09ef16c5a..f7b44f990 100644 --- a/skills/discord/SKILL.md +++ b/skills/discord/SKILL.md @@ -1,6 +1,6 @@ --- name: discord -description: Use when you need to control Discord from Clawdis via the discord tool: send messages, react, post stickers, run polls, manage threads/pins/search, fetch permissions or member/role/channel info, or handle moderation actions in Discord DMs or channels. +description: Use when you need to control Discord from Clawdis via the discord tool: send messages, react, post or upload stickers, upload emojis, run polls, manage threads/pins/search, fetch permissions or member/role/channel info, or handle moderation actions in Discord DMs or channels. --- # Discord Actions @@ -15,6 +15,8 @@ Use `discord` to manage messages, reactions, threads, polls, and moderation. You - For stickers/polls/sendMessage: a `to` target (`channel:` or `user:`). Optional `content` text. - Polls also need a `question` plus 2–10 `answers`. - For media: `mediaUrl` with `file:///path` for local files or `https://...` for remote. +- For emoji uploads: `guildId`, `name`, `mediaUrl`, optional `roleIds` (limit 256KB, PNG/JPG/GIF). +- For sticker uploads: `guildId`, `name`, `description`, `tags`, `mediaUrl` (limit 512KB, PNG/APNG/Lottie JSON). Message context lines include `discord message id` and `channel` fields you can reuse directly. @@ -58,6 +60,37 @@ Message context lines include `discord message id` and `channel` fields you can - Up to 3 sticker IDs per message. - `to` can be `user:` for DMs. +### Upload a custom emoji + +```json +{ + "action": "emojiUpload", + "guildId": "999", + "name": "party_blob", + "mediaUrl": "file:///tmp/party.png", + "roleIds": ["222"] +} +``` + +- Emoji images must be PNG/JPG/GIF and <= 256KB. +- `roleIds` is optional; omit to make the emoji available to everyone. + +### Upload a sticker + +```json +{ + "action": "stickerUpload", + "guildId": "999", + "name": "clawdis_wave", + "description": "Clawdis waving hello", + "tags": "👋", + "mediaUrl": "file:///tmp/wave.png" +} +``` + +- Stickers require `name`, `description`, and `tags`. +- Uploads must be PNG/APNG/Lottie JSON and <= 512KB. + ### Create a poll ```json @@ -88,6 +121,7 @@ Message context lines include `discord message id` and `channel` fields you can - React with ✅/⚠️ to mark status updates. - Post a quick poll for release decisions or meeting times. - Send celebratory stickers after successful deploys. +- Upload new emojis/stickers for release moments. - Run weekly “priority check” polls in team channels. - DM stickers as acknowledgements when a user’s request is completed. @@ -96,6 +130,7 @@ Message context lines include `discord message id` and `channel` fields you can Use `discord.actions.*` to disable action groups: - `reactions` (react + reactions list + emojiList) - `stickers`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search` +- `emojiUploads`, `stickerUploads` - `memberInfo`, `roleInfo`, `channelInfo`, `voiceStatus`, `events` - `roles` (role add/remove, default `false`) - `moderation` (timeout/kick/ban, default `false`) diff --git a/src/agents/clawdis-tools.ts b/src/agents/clawdis-tools.ts index 7dd46e644..4bc1f59a6 100644 --- a/src/agents/clawdis-tools.ts +++ b/src/agents/clawdis-tools.ts @@ -75,6 +75,8 @@ import { sendStickerDiscord, timeoutMemberDiscord, unpinMessageDiscord, + uploadEmojiDiscord, + uploadStickerDiscord, } from "../discord/send.js"; import { callGateway } from "../gateway/call.js"; import { detectMime, imageMimeFromFormat } from "../media/mime.js"; @@ -1845,6 +1847,21 @@ const DiscordToolSchema = Type.Union([ action: Type.Literal("emojiList"), guildId: Type.String(), }), + Type.Object({ + action: Type.Literal("emojiUpload"), + guildId: Type.String(), + name: Type.String(), + mediaUrl: Type.String(), + roleIds: Type.Optional(Type.Array(Type.String())), + }), + Type.Object({ + action: Type.Literal("stickerUpload"), + guildId: Type.String(), + name: Type.String(), + description: Type.String(), + tags: Type.String(), + mediaUrl: Type.String(), + }), Type.Object({ action: Type.Literal("roleAdd"), guildId: Type.String(), @@ -2260,6 +2277,50 @@ function createDiscordTool(): AnyAgentTool { const emojis = await listGuildEmojisDiscord(guildId); return jsonResult({ ok: true, emojis }); } + case "emojiUpload": { + if (!isActionEnabled("emojiUploads")) { + throw new Error("Discord emoji uploads are disabled."); + } + const guildId = readStringParam(params, "guildId", { + required: true, + }); + const name = readStringParam(params, "name", { required: true }); + const mediaUrl = readStringParam(params, "mediaUrl", { + required: true, + }); + const roleIds = readStringArrayParam(params, "roleIds"); + const emoji = await uploadEmojiDiscord({ + guildId, + name, + mediaUrl, + roleIds: roleIds?.length ? roleIds : undefined, + }); + return jsonResult({ ok: true, emoji }); + } + case "stickerUpload": { + if (!isActionEnabled("stickerUploads")) { + throw new Error("Discord sticker uploads are disabled."); + } + const guildId = readStringParam(params, "guildId", { + required: true, + }); + const name = readStringParam(params, "name", { required: true }); + const description = readStringParam(params, "description", { + required: true, + }); + const tags = readStringParam(params, "tags", { required: true }); + const mediaUrl = readStringParam(params, "mediaUrl", { + required: true, + }); + const sticker = await uploadStickerDiscord({ + guildId, + name, + description, + tags, + mediaUrl, + }); + return jsonResult({ ok: true, sticker }); + } case "roleAdd": { if (!isActionEnabled("roles", false)) { throw new Error("Discord role changes are disabled."); diff --git a/src/agents/tool-display.json b/src/agents/tool-display.json index a3834ef2f..ec941b431 100644 --- a/src/agents/tool-display.json +++ b/src/agents/tool-display.json @@ -196,6 +196,8 @@ "memberInfo": { "label": "member", "detailKeys": ["guildId", "userId"] }, "roleInfo": { "label": "roles", "detailKeys": ["guildId"] }, "emojiList": { "label": "emoji list", "detailKeys": ["guildId"] }, + "emojiUpload": { "label": "emoji upload", "detailKeys": ["guildId", "name"] }, + "stickerUpload": { "label": "sticker upload", "detailKeys": ["guildId", "name"] }, "roleAdd": { "label": "role add", "detailKeys": ["guildId", "userId", "roleId"] }, "roleRemove": { "label": "role remove", "detailKeys": ["guildId", "userId", "roleId"] }, "channelInfo": { "label": "channel", "detailKeys": ["channelId"] }, diff --git a/src/config/config.ts b/src/config/config.ts index 68af18c09..4fd444a95 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -265,6 +265,8 @@ export type DiscordActionConfig = { voiceStatus?: boolean; events?: boolean; moderation?: boolean; + emojiUploads?: boolean; + stickerUploads?: boolean; }; export type DiscordConfig = { diff --git a/src/discord/send.test.ts b/src/discord/send.test.ts index f06c1fd0e..d26dd3b4d 100644 --- a/src/discord/send.test.ts +++ b/src/discord/send.test.ts @@ -21,6 +21,8 @@ import { sendStickerDiscord, timeoutMemberDiscord, unpinMessageDiscord, + uploadEmojiDiscord, + uploadStickerDiscord, } from "./send.js"; vi.mock("../web/media.js", () => ({ @@ -30,6 +32,12 @@ vi.mock("../web/media.js", () => ({ contentType: "image/jpeg", kind: "image", }), + loadWebMediaRaw: vi.fn().mockResolvedValue({ + buffer: Buffer.from("img"), + fileName: "asset.png", + contentType: "image/png", + kind: "image", + }), })); const makeRest = () => { @@ -449,6 +457,73 @@ describe("listGuildEmojisDiscord", () => { }); }); +describe("uploadEmojiDiscord", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("uploads emoji assets", async () => { + const { rest, postMock } = makeRest(); + postMock.mockResolvedValue({ id: "e1" }); + await uploadEmojiDiscord( + { + guildId: "g1", + name: "party_blob", + mediaUrl: "file:///tmp/party.png", + roleIds: ["r1"], + }, + { rest, token: "t" }, + ); + expect(postMock).toHaveBeenCalledWith( + Routes.guildEmojis("g1"), + expect.objectContaining({ + body: { + name: "party_blob", + image: "data:image/png;base64,aW1n", + roles: ["r1"], + }, + }), + ); + }); +}); + +describe("uploadStickerDiscord", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("uploads sticker assets", async () => { + const { rest, postMock } = makeRest(); + postMock.mockResolvedValue({ id: "s1" }); + await uploadStickerDiscord( + { + guildId: "g1", + name: "clawdis_wave", + description: "Clawdis waving", + tags: "👋", + mediaUrl: "file:///tmp/wave.png", + }, + { rest, token: "t" }, + ); + expect(postMock).toHaveBeenCalledWith( + Routes.guildStickers("g1"), + expect.objectContaining({ + body: { + name: "clawdis_wave", + description: "Clawdis waving", + tags: "👋", + }, + files: [ + expect.objectContaining({ + name: "asset.png", + contentType: "image/png", + }), + ], + }), + ); + }); +}); + describe("sendStickerDiscord", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/src/discord/send.ts b/src/discord/send.ts index c1113e42f..9aebd7f2b 100644 --- a/src/discord/send.ts +++ b/src/discord/send.ts @@ -14,11 +14,13 @@ import type { import { chunkText } from "../auto-reply/chunk.js"; import { loadConfig } from "../config/config.js"; -import { loadWebMedia } from "../web/media.js"; +import { loadWebMedia, loadWebMediaRaw } from "../web/media.js"; import { normalizeDiscordToken } from "./token.js"; const DISCORD_TEXT_LIMIT = 2000; const DISCORD_MAX_STICKERS = 3; +const DISCORD_MAX_EMOJI_BYTES = 256 * 1024; +const DISCORD_MAX_STICKER_BYTES = 512 * 1024; const DISCORD_POLL_MIN_ANSWERS = 2; const DISCORD_POLL_MAX_ANSWERS = 10; const DISCORD_POLL_MAX_DURATION_HOURS = 32 * 24; @@ -128,6 +130,21 @@ export type DiscordTimeoutTarget = DiscordModerationTarget & { until?: string; }; +export type DiscordEmojiUpload = { + guildId: string; + name: string; + mediaUrl: string; + roleIds?: string[]; +}; + +export type DiscordStickerUpload = { + guildId: string; + name: string; + description: string; + tags: string; + mediaUrl: string; +}; + function resolveToken(explicit?: string) { const cfgToken = loadConfig().discord?.token; const token = normalizeDiscordToken( @@ -194,6 +211,14 @@ function normalizeStickerIds(raw: string[]) { return ids; } +function normalizeEmojiName(raw: string, label: string) { + const name = raw.trim(); + if (!name) { + throw new Error(`${label} is required`); + } + return name; +} + function normalizePollInput(input: DiscordPollInput): RESTAPIPoll { const question = input.question.trim(); if (!question) { @@ -698,6 +723,74 @@ export async function listGuildEmojisDiscord( return await rest.get(Routes.guildEmojis(guildId)); } +export async function uploadEmojiDiscord( + payload: DiscordEmojiUpload, + opts: DiscordReactOpts = {}, +) { + const token = resolveToken(opts.token); + const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const media = await loadWebMediaRaw( + payload.mediaUrl, + DISCORD_MAX_EMOJI_BYTES, + ); + const contentType = media.contentType?.toLowerCase(); + if ( + !contentType || + !["image/png", "image/jpeg", "image/jpg", "image/gif"].includes(contentType) + ) { + throw new Error("Discord emoji uploads require a PNG, JPG, or GIF image"); + } + const image = `data:${contentType};base64,${media.buffer.toString("base64")}`; + const roleIds = (payload.roleIds ?? []) + .map((id) => id.trim()) + .filter(Boolean); + return await rest.post(Routes.guildEmojis(payload.guildId), { + body: { + name: normalizeEmojiName(payload.name, "Emoji name"), + image, + roles: roleIds.length ? roleIds : undefined, + }, + }); +} + +export async function uploadStickerDiscord( + payload: DiscordStickerUpload, + opts: DiscordReactOpts = {}, +) { + const token = resolveToken(opts.token); + const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const media = await loadWebMediaRaw( + payload.mediaUrl, + DISCORD_MAX_STICKER_BYTES, + ); + const contentType = media.contentType?.toLowerCase(); + if ( + !contentType || + !["image/png", "image/apng", "application/json"].includes(contentType) + ) { + throw new Error( + "Discord sticker uploads require a PNG, APNG, or Lottie JSON file", + ); + } + return await rest.post(Routes.guildStickers(payload.guildId), { + body: { + name: normalizeEmojiName(payload.name, "Sticker name"), + description: normalizeEmojiName( + payload.description, + "Sticker description", + ), + tags: normalizeEmojiName(payload.tags, "Sticker tags"), + }, + files: [ + { + data: media.buffer, + name: media.fileName ?? "sticker", + contentType, + }, + ], + }); +} + export async function fetchMemberInfoDiscord( guildId: string, userId: string, diff --git a/src/web/media.ts b/src/web/media.ts index c973e0c09..97743f3d0 100644 --- a/src/web/media.ts +++ b/src/web/media.ts @@ -10,15 +10,23 @@ import { import { resizeToJpeg } from "../media/image-ops.js"; import { detectMime, extensionForMime } from "../media/mime.js"; -export async function loadWebMedia( - mediaUrl: string, - maxBytes?: number, -): Promise<{ +type WebMediaResult = { buffer: Buffer; contentType?: string; kind: MediaKind; fileName?: string; -}> { +}; + +type WebMediaOptions = { + maxBytes?: number; + optimizeImages?: boolean; +}; + +async function loadWebMediaInternal( + mediaUrl: string, + options: WebMediaOptions = {}, +): Promise { + const { maxBytes, optimizeImages = true } = options; if (mediaUrl.startsWith("file://")) { mediaUrl = mediaUrl.replace("file://", ""); } @@ -74,11 +82,13 @@ export async function loadWebMedia( maxBytesForKind(kind), ); if (kind === "image") { - // Skip optimization for GIFs to preserve animation - if (contentType === "image/gif") { + // Skip optimization for GIFs to preserve animation. + if (contentType === "image/gif" || !optimizeImages) { if (array.length > cap) { throw new Error( - `GIF exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB limit (got ${( + `${ + contentType === "image/gif" ? "GIF" : "Media" + } exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB limit (got ${( array.length / (1024 * 1024) ).toFixed(2)}MB)`, ); @@ -116,11 +126,13 @@ export async function loadWebMedia( maxBytesForKind(kind), ); if (kind === "image") { - // Skip optimization for GIFs to preserve animation - if (mime === "image/gif") { + // Skip optimization for GIFs to preserve animation. + if (mime === "image/gif" || !optimizeImages) { if (data.length > cap) { throw new Error( - `GIF exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB limit (got ${( + `${ + mime === "image/gif" ? "GIF" : "Media" + } exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB limit (got ${( data.length / (1024 * 1024) ).toFixed(2)}MB)`, ); @@ -139,6 +151,26 @@ export async function loadWebMedia( return { buffer: data, contentType: mime, kind, fileName }; } +export async function loadWebMedia( + mediaUrl: string, + maxBytes?: number, +): Promise { + return await loadWebMediaInternal(mediaUrl, { + maxBytes, + optimizeImages: true, + }); +} + +export async function loadWebMediaRaw( + mediaUrl: string, + maxBytes?: number, +): Promise { + return await loadWebMediaInternal(mediaUrl, { + maxBytes, + optimizeImages: false, + }); +} + export async function optimizeImageToJpeg( buffer: Buffer, maxBytes: number,