Discord: tools for uploading emojis and stickers!

This commit is contained in:
Shadow
2026-01-03 21:19:18 -06:00
parent 24aa3e3311
commit 3a28e3562c
7 changed files with 313 additions and 13 deletions

View File

@@ -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:<id>` or `user:<id>`). Optional `content` text.
- Polls also need a `question` plus 210 `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:<id>` 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 users 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`)

View File

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

View File

@@ -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"] },

View File

@@ -265,6 +265,8 @@ export type DiscordActionConfig = {
voiceStatus?: boolean;
events?: boolean;
moderation?: boolean;
emojiUploads?: boolean;
stickerUploads?: boolean;
};
export type DiscordConfig = {

View File

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

View File

@@ -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,

View File

@@ -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<WebMediaResult> {
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<WebMediaResult> {
return await loadWebMediaInternal(mediaUrl, {
maxBytes,
optimizeImages: true,
});
}
export async function loadWebMediaRaw(
mediaUrl: string,
maxBytes?: number,
): Promise<WebMediaResult> {
return await loadWebMediaInternal(mediaUrl, {
maxBytes,
optimizeImages: false,
});
}
export async function optimizeImageToJpeg(
buffer: Buffer,
maxBytes: number,