Discord: tools for uploading emojis and stickers!
This commit is contained in:
@@ -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 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:<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 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`)
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
@@ -265,6 +265,8 @@ export type DiscordActionConfig = {
|
||||
voiceStatus?: boolean;
|
||||
events?: boolean;
|
||||
moderation?: boolean;
|
||||
emojiUploads?: boolean;
|
||||
stickerUploads?: boolean;
|
||||
};
|
||||
|
||||
export type DiscordConfig = {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user