agent-messenger 2.37.2 → 2.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +6 -0
  3. package/dist/package.json +1 -1
  4. package/dist/src/platforms/discord/cli.d.ts.map +1 -1
  5. package/dist/src/platforms/discord/cli.js +3 -1
  6. package/dist/src/platforms/discord/cli.js.map +1 -1
  7. package/dist/src/platforms/discord/client.d.ts +17 -1
  8. package/dist/src/platforms/discord/client.d.ts.map +1 -1
  9. package/dist/src/platforms/discord/client.js +46 -0
  10. package/dist/src/platforms/discord/client.js.map +1 -1
  11. package/dist/src/platforms/discord/commands/emoji.d.ts +13 -0
  12. package/dist/src/platforms/discord/commands/emoji.d.ts.map +1 -0
  13. package/dist/src/platforms/discord/commands/emoji.js +93 -0
  14. package/dist/src/platforms/discord/commands/emoji.js.map +1 -0
  15. package/dist/src/platforms/discord/commands/index.d.ts +2 -0
  16. package/dist/src/platforms/discord/commands/index.d.ts.map +1 -1
  17. package/dist/src/platforms/discord/commands/index.js +2 -0
  18. package/dist/src/platforms/discord/commands/index.js.map +1 -1
  19. package/dist/src/platforms/discord/commands/server.d.ts.map +1 -1
  20. package/dist/src/platforms/discord/commands/server.js +16 -1
  21. package/dist/src/platforms/discord/commands/server.js.map +1 -1
  22. package/dist/src/platforms/discord/commands/sticker.d.ts +15 -0
  23. package/dist/src/platforms/discord/commands/sticker.d.ts.map +1 -0
  24. package/dist/src/platforms/discord/commands/sticker.js +97 -0
  25. package/dist/src/platforms/discord/commands/sticker.js.map +1 -0
  26. package/dist/src/platforms/discord/expression-format.d.ts +41 -0
  27. package/dist/src/platforms/discord/expression-format.d.ts.map +1 -0
  28. package/dist/src/platforms/discord/expression-format.js +107 -0
  29. package/dist/src/platforms/discord/expression-format.js.map +1 -0
  30. package/dist/src/platforms/discord/expression-names.d.ts +9 -0
  31. package/dist/src/platforms/discord/expression-names.d.ts.map +1 -0
  32. package/dist/src/platforms/discord/expression-names.js +33 -0
  33. package/dist/src/platforms/discord/expression-names.js.map +1 -0
  34. package/dist/src/platforms/discord/index.d.ts +1 -1
  35. package/dist/src/platforms/discord/index.d.ts.map +1 -1
  36. package/dist/src/platforms/discord/index.js.map +1 -1
  37. package/dist/src/platforms/discord/types.d.ts +29 -0
  38. package/dist/src/platforms/discord/types.d.ts.map +1 -1
  39. package/dist/src/platforms/discord/types.js.map +1 -1
  40. package/docs/content/docs/cli/discord.mdx +73 -0
  41. package/docs/content/docs/sdk/discord.mdx +50 -0
  42. package/package.json +1 -1
  43. package/skills/agent-channeltalk/SKILL.md +1 -1
  44. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  45. package/skills/agent-discord/SKILL.md +69 -1
  46. package/skills/agent-discord/references/common-patterns.md +77 -0
  47. package/skills/agent-discordbot/SKILL.md +1 -1
  48. package/skills/agent-imessage/SKILL.md +1 -1
  49. package/skills/agent-instagram/SKILL.md +1 -1
  50. package/skills/agent-kakaotalk/SKILL.md +1 -1
  51. package/skills/agent-line/SKILL.md +1 -1
  52. package/skills/agent-slack/SKILL.md +1 -1
  53. package/skills/agent-slackbot/SKILL.md +1 -1
  54. package/skills/agent-teams/SKILL.md +1 -1
  55. package/skills/agent-telegram/SKILL.md +1 -1
  56. package/skills/agent-telegrambot/SKILL.md +1 -1
  57. package/skills/agent-webex/SKILL.md +1 -1
  58. package/skills/agent-webexbot/SKILL.md +1 -1
  59. package/skills/agent-wechatbot/SKILL.md +1 -1
  60. package/skills/agent-whatsapp/SKILL.md +1 -1
  61. package/skills/agent-whatsappbot/SKILL.md +1 -1
  62. package/src/platforms/discord/cli.ts +4 -0
  63. package/src/platforms/discord/client.test.ts +133 -0
  64. package/src/platforms/discord/client.ts +72 -0
  65. package/src/platforms/discord/commands/emoji.test.ts +122 -0
  66. package/src/platforms/discord/commands/emoji.ts +124 -0
  67. package/src/platforms/discord/commands/index.ts +2 -0
  68. package/src/platforms/discord/commands/server.test.ts +45 -1
  69. package/src/platforms/discord/commands/server.ts +21 -1
  70. package/src/platforms/discord/commands/sticker.test.ts +118 -0
  71. package/src/platforms/discord/commands/sticker.ts +135 -0
  72. package/src/platforms/discord/expression-format.test.ts +107 -0
  73. package/src/platforms/discord/expression-format.ts +117 -0
  74. package/src/platforms/discord/expression-names.test.ts +41 -0
  75. package/src/platforms/discord/expression-names.ts +36 -0
  76. package/src/platforms/discord/index.ts +2 -0
  77. package/src/platforms/discord/types.ts +31 -0
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-channeltalk
3
3
  description: Interact with Channel Talk using extracted desktop app or browser credentials - read chats, send messages, search messages, manage groups
4
- version: 2.37.2
4
+ version: 2.38.0
5
5
  allowed-tools: Bash(agent-channeltalk:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-channeltalkbot
3
3
  description: Interact with Channel Talk workspaces using API credentials - send messages, read chats, manage groups and bots
4
- version: 2.37.2
4
+ version: 2.38.0
5
5
  allowed-tools: Bash(agent-channeltalkbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-discord
3
3
  description: Interact with Discord servers - send messages, read channels, manage reactions
4
- version: 2.37.2
4
+ version: 2.38.0
5
5
  allowed-tools: Bash(agent-discord:*)
6
6
  metadata:
7
7
  openclaw:
@@ -241,6 +241,9 @@ agent-discord server switch <server-id>
241
241
  agent-discord server current
242
242
  ```
243
243
 
244
+ `server info` also reports the boost tier with the custom emoji and sticker
245
+ counts and the slots still free at that tier.
246
+
244
247
  ### User Commands
245
248
 
246
249
  ```bash
@@ -383,6 +386,54 @@ agent-discord file list <channel-id>
383
386
  agent-discord file info <channel-id> <file-id>
384
387
  ```
385
388
 
389
+ ### Emoji Commands
390
+
391
+ Creating needs `CREATE_GUILD_EXPRESSIONS`; deleting someone else's expression
392
+ needs `MANAGE_GUILD_EXPRESSIONS`; listing needs neither. Name: letters, digits
393
+ and underscores only, 2–32 characters. Image at most 256KB.
394
+
395
+ ```bash
396
+ # List custom emoji
397
+ agent-discord emoji list <server-id>
398
+
399
+ # Upload (name defaults to the filename without extension)
400
+ agent-discord emoji create <server-id> <path>
401
+ agent-discord emoji create 1234567890123456789 ./potato.png --name potato
402
+
403
+ # Delete
404
+ agent-discord emoji delete <server-id> <emoji-id>
405
+ ```
406
+
407
+ ### Sticker Commands
408
+
409
+ Same permissions as emoji. PNG, APNG, GIF or Lottie JSON, at most 512KB. Name
410
+ 2–30 characters. Lottie needs a `VERIFIED` or `PARTNERED` guild. Discord
411
+ documents 320x320 but accepted a 408x408 PNG, so the CLI does not enforce a
412
+ size — it checks the format from the file's bytes instead of its extension.
413
+
414
+ ```bash
415
+ # List custom stickers
416
+ agent-discord sticker list <server-id>
417
+
418
+ # Upload — --tags is the unicode emoji the sticker relates to
419
+ agent-discord sticker create <server-id> <path> --tags <emoji>
420
+ agent-discord sticker create 1234567890123456789 ./potato.png --tags 🥔 --name potato
421
+
422
+ # Delete
423
+ agent-discord sticker delete <server-id> <sticker-id>
424
+ ```
425
+
426
+ The media type is read from the file's bytes, not its extension, and each
427
+ command takes only its endpoint's formats — emoji accepts PNG, GIF, JPEG and
428
+ WebP; stickers accept PNG, APNG, GIF and a JSON document for Lottie. Anything
429
+ else fails locally with a named error before a request is sent, so a batch says
430
+ which file was wrong instead of returning a bare `Invalid Asset`.
431
+
432
+ Check the remaining slots before a batch upload — `agent-discord server info
433
+ <server-id>` reports the boost tier with the counts and free slots. Discord
434
+ raises both allowances with the boost level: static emoji 50/100/150/250 and
435
+ stickers 5/15/30/60 for tiers 0–3.
436
+
386
437
  ### Snapshot Command
387
438
 
388
439
  Get server overview for AI agents (brief by default):
@@ -480,6 +531,23 @@ Common errors:
480
531
  - `No current server set`: Run `server switch <id>` first
481
532
  - `Message not found`: Invalid message ID
482
533
  - `Unknown Channel`: Invalid channel ID
534
+ - `File is not one of ...`: The file's bytes match no format this endpoint
535
+ takes. Refused locally — the request was never sent, so retrying is pointless.
536
+ - `File is a <format>; this endpoint takes ...`: The file is a real image, but
537
+ not one this endpoint accepts — a JPEG or WebP sticker, or a JSON emoji.
538
+ Convert it; also refused locally.
539
+ - `Emoji name may only contain ...` / `... name must be at least 2 characters`:
540
+ The name broke Discord's rules and was refused locally. Names come from the
541
+ filename unless `--name` is given, so `potato-13.png` fails on the hyphen.
542
+ - `JSON file is not a Lottie animation (no layers array)` / `... is not valid
543
+ JSON` / `... is not valid UTF-8`: A JSON sticker that cannot be an animation.
544
+ Each names its own fault, because a file can carry a `layers` array and still
545
+ be broken. All three are refused locally; Discord answers such a file with a
546
+ bare `Invalid Asset`.
547
+ - `Invalid Asset` (from Discord): The format was accepted locally but Discord
548
+ refused the content itself — a truncated or corrupt image.
549
+ - `Maximum number of stickers reached (N)` (from Discord): No slots left. Check
550
+ `server info` for the tier's allowance before a batch.
483
551
 
484
552
  ## Configuration
485
553
 
@@ -333,6 +333,83 @@ agent-discord channel list
333
333
 
334
334
  **When to use**: Managing multiple servers, cross-server operations.
335
335
 
336
+ ## Pattern 11: Bulk Upload Custom Emoji
337
+
338
+ **Use case**: Register a folder of images as server emoji without running out of slots halfway through
339
+
340
+ ```bash
341
+ #!/bin/bash
342
+
343
+ SERVER_ID="123456789012345678"
344
+
345
+ # Check the allowance first — Discord raises it with the boost level,
346
+ # so the free slots depend on premium_tier, not a fixed number.
347
+ INFO=$(agent-discord server info "$SERVER_ID")
348
+ FREE=$(echo "$INFO" | jq -r '.static_emoji_slots_remaining')
349
+ echo "Free emoji slots: $FREE"
350
+
351
+ UPLOADED=0
352
+ for FILE in ./emoji/*.png; do
353
+ if [ "$UPLOADED" -ge "$FREE" ]; then
354
+ echo "Out of slots — stopping. Remaining files were not uploaded."
355
+ break
356
+ fi
357
+
358
+ # The name defaults to the filename without its extension, and Discord only
359
+ # accepts letters, digits and underscores. Rename potato-01.png beforehand,
360
+ # or pass --name.
361
+ RESULT=$(agent-discord emoji create "$SERVER_ID" "$FILE")
362
+
363
+ if echo "$RESULT" | jq -e '.success' > /dev/null 2>&1; then
364
+ UPLOADED=$((UPLOADED + 1))
365
+ else
366
+ echo "Failed on $FILE: $(echo "$RESULT" | jq -r '.error')"
367
+ fi
368
+ done
369
+
370
+ echo "Uploaded $UPLOADED emoji"
371
+ agent-discord emoji list "$SERVER_ID" | jq '{count, static_count}'
372
+ ```
373
+
374
+ **Notes**:
375
+
376
+ - `emoji create` rejects a name outside Discord's rules before the request, so a
377
+ bad filename names itself instead of returning `Invalid Form Body`.
378
+ - Roll back a mistake with `agent-discord emoji delete <server-id> <emoji-id>`.
379
+
380
+ ## Pattern 12: Upload a Server Sticker
381
+
382
+ **Use case**: Add a sticker, which has a stricter format than emoji
383
+
384
+ ```bash
385
+ #!/bin/bash
386
+
387
+ SERVER_ID="123456789012345678"
388
+
389
+ # Discord documents 320x320 for stickers (it accepted a larger PNG in
390
+ # practice, but stay on spec). Max 512KB.
391
+ sips -z 320 320 ./art/potato.png --out /tmp/potato_320.png
392
+
393
+ # --tags is the unicode emoji the sticker relates to, and is required.
394
+ RESULT=$(agent-discord sticker create "$SERVER_ID" /tmp/potato_320.png \
395
+ --tags 🥔 --name potato --description "A potato")
396
+
397
+ if echo "$RESULT" | jq -e '.success' > /dev/null 2>&1; then
398
+ echo "Sticker id: $(echo "$RESULT" | jq -r '.id')"
399
+ else
400
+ echo "Failed: $(echo "$RESULT" | jq -r '.error')"
401
+ fi
402
+
403
+ agent-discord sticker list "$SERVER_ID"
404
+ ```
405
+
406
+ **Notes**:
407
+
408
+ - Base sticker slots are 5, rising to 15/30/60 with boost tiers 1/2/3.
409
+ `agent-discord server info` reports `sticker_slots_remaining`.
410
+ - Sticker names are 2–30 characters. A one-character name is refused locally.
411
+ - Lottie JSON uploads only on `VERIFIED` or `PARTNERED` guilds.
412
+
336
413
  ## Best Practices
337
414
 
338
415
  ### 1. Always Get Channel IDs First
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-discordbot
3
3
  description: Interact with Discord servers using bot tokens - send messages, read channels, manage reactions
4
- version: 2.37.2
4
+ version: 2.38.0
5
5
  allowed-tools: Bash(agent-discordbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-imessage
3
3
  description: Interact with iMessage on a Mac via the imsg tool - send messages, read chats, watch for new messages
4
- version: 2.37.2
4
+ version: 2.38.0
5
5
  allowed-tools: Bash(agent-imessage:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-instagram
3
3
  description: Interact with Instagram DMs - send messages, read conversations, manage accounts
4
- version: 2.37.2
4
+ version: 2.38.0
5
5
  allowed-tools: Bash(agent-instagram:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-kakaotalk
3
3
  description: Interact with KakaoTalk - send messages, read chats, manage conversations
4
- version: 2.37.2
4
+ version: 2.38.0
5
5
  allowed-tools: Bash(agent-kakaotalk:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-line
3
3
  description: Interact with LINE - send messages, read chats, manage conversations
4
- version: 2.37.2
4
+ version: 2.38.0
5
5
  allowed-tools: Bash(agent-line:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-slack
3
3
  description: Interact with Slack workspaces - send messages, read channels, manage reactions
4
- version: 2.37.2
4
+ version: 2.38.0
5
5
  allowed-tools: Bash(agent-slack:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-slackbot
3
3
  description: Interact with Slack workspaces using bot tokens - send messages, read channels, manage reactions
4
- version: 2.37.2
4
+ version: 2.38.0
5
5
  allowed-tools: Bash(agent-slackbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-teams
3
3
  description: Interact with Microsoft Teams - send messages, read channels, manage reactions
4
- version: 2.37.2
4
+ version: 2.38.0
5
5
  allowed-tools: Bash(agent-teams:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-telegram
3
3
  description: Interact with Telegram through TDLib - authenticate, inspect chats, and send messages
4
- version: 2.37.2
4
+ version: 2.38.0
5
5
  allowed-tools: Bash(agent-telegram:*)
6
6
  ---
7
7
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-telegrambot
3
3
  description: Interact with Telegram using bot tokens - send messages, read chats, manage reactions
4
- version: 2.37.2
4
+ version: 2.38.0
5
5
  allowed-tools: Bash(agent-telegrambot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-webex
3
3
  description: Interact with Cisco Webex - send messages, read spaces, manage memberships
4
- version: 2.37.2
4
+ version: 2.38.0
5
5
  allowed-tools: Bash(agent-webex:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-webexbot
3
3
  description: Interact with Cisco Webex using bot tokens - send messages, reply in threads, upload and download files, look up people, read spaces, manage memberships, stream real-time events
4
- version: 2.37.2
4
+ version: 2.38.0
5
5
  allowed-tools: Bash(agent-webexbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-wechatbot
3
3
  description: Interact with WeChat Official Account using API credentials - send messages, manage templates, list followers
4
- version: 2.37.2
4
+ version: 2.38.0
5
5
  allowed-tools: Bash(agent-wechatbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-whatsapp
3
3
  description: Interact with WhatsApp - send messages, read chats, manage conversations
4
- version: 2.37.2
4
+ version: 2.38.0
5
5
  allowed-tools: Bash(agent-whatsapp:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-whatsappbot
3
3
  description: Interact with WhatsApp using Cloud API credentials - send messages, manage templates
4
- version: 2.37.2
4
+ version: 2.38.0
5
5
  allowed-tools: Bash(agent-whatsappbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -8,6 +8,7 @@ import {
8
8
  authCommand,
9
9
  channelCommand,
10
10
  dmCommand,
11
+ emojiCommand,
11
12
  fileCommand,
12
13
  friendCommand,
13
14
  memberCommand,
@@ -18,6 +19,7 @@ import {
18
19
  reactionCommand,
19
20
  serverCommand,
20
21
  snapshotCommand,
22
+ stickerCommand,
21
23
  threadCommand,
22
24
  userCommand,
23
25
  whoamiCommand,
@@ -50,6 +52,7 @@ program.addCommand(authCommand)
50
52
  program.addCommand(serverCommand)
51
53
  program.addCommand(channelCommand)
52
54
  program.addCommand(dmCommand)
55
+ program.addCommand(emojiCommand)
53
56
  program.addCommand(fileCommand)
54
57
  program.addCommand(friendCommand)
55
58
  program.addCommand(memberCommand)
@@ -59,6 +62,7 @@ program.addCommand(noteCommand)
59
62
  program.addCommand(profileCommand)
60
63
  program.addCommand(reactionCommand)
61
64
  program.addCommand(snapshotCommand)
65
+ program.addCommand(stickerCommand)
62
66
  program.addCommand(threadCommand)
63
67
  program.addCommand(userCommand)
64
68
  program.addCommand(whoamiCommand)
@@ -1099,4 +1099,137 @@ describe('DiscordClient', () => {
1099
1099
  expect(result.totalUnread).toBe(7)
1100
1100
  })
1101
1101
  })
1102
+ describe('expressions', () => {
1103
+ const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
1104
+
1105
+ it('listEmojis requests the guild emoji collection', async () => {
1106
+ const client = await new DiscordClient().login({ token: 'test-token' })
1107
+ mockResponse([{ id: 'e1', name: 'potato_01', animated: false }])
1108
+
1109
+ const emojis = await client.listEmojis('g1')
1110
+
1111
+ expect(fetchCalls[0].url).toBe('https://discord.com/api/v10/guilds/g1/emojis')
1112
+ expect(fetchCalls[0].options?.method).toBe('GET')
1113
+ expect(emojis[0].name).toBe('potato_01')
1114
+ })
1115
+
1116
+ it('createEmoji posts the image as a base64 data URI', async () => {
1117
+ const client = await new DiscordClient().login({ token: 'test-token' })
1118
+ mockResponse({ id: 'e1', name: 'potato_01', animated: false })
1119
+
1120
+ const emoji = await client.createEmoji('g1', 'potato_01', png, 'potato_01.png')
1121
+
1122
+ expect(fetchCalls[0].url).toBe('https://discord.com/api/v10/guilds/g1/emojis')
1123
+ expect(fetchCalls[0].options?.method).toBe('POST')
1124
+ const body = JSON.parse(fetchCalls[0].options?.body as string)
1125
+ expect(body.name).toBe('potato_01')
1126
+ expect(body.image).toBe(`data:image/png;base64,${Buffer.from(png).toString('base64')}`)
1127
+ expect(emoji.id).toBe('e1')
1128
+ })
1129
+
1130
+ it('deleteEmoji issues a DELETE for the emoji', async () => {
1131
+ const client = await new DiscordClient().login({ token: 'test-token' })
1132
+ mockResponse(null, 204)
1133
+
1134
+ await client.deleteEmoji('g1', 'e1')
1135
+
1136
+ expect(fetchCalls[0].url).toBe('https://discord.com/api/v10/guilds/g1/emojis/e1')
1137
+ expect(fetchCalls[0].options?.method).toBe('DELETE')
1138
+ })
1139
+
1140
+ it('listStickers requests the guild sticker collection', async () => {
1141
+ const client = await new DiscordClient().login({ token: 'test-token' })
1142
+ mockResponse([{ id: 's1', name: 'potato_01', tags: 'potato', type: 2, format_type: 1 }])
1143
+
1144
+ const stickers = await client.listStickers('g1')
1145
+
1146
+ expect(fetchCalls[0].url).toBe('https://discord.com/api/v10/guilds/g1/stickers')
1147
+ expect(fetchCalls[0].options?.method).toBe('GET')
1148
+ expect(stickers[0].name).toBe('potato_01')
1149
+ })
1150
+
1151
+ it('createSticker posts multipart fields alongside the file', async () => {
1152
+ const client = await new DiscordClient().login({ token: 'test-token' })
1153
+ mockResponse({ id: 's1', name: 'potato_01', tags: 'potato', type: 2, format_type: 1 })
1154
+
1155
+ const sticker = await client.createSticker(
1156
+ 'g1',
1157
+ { name: 'potato_01', description: 'a potato', tags: 'potato' },
1158
+ png,
1159
+ 'potato_01.png',
1160
+ )
1161
+
1162
+ expect(fetchCalls[0].url).toBe('https://discord.com/api/v10/guilds/g1/stickers')
1163
+ expect(fetchCalls[0].options?.method).toBe('POST')
1164
+ const form = fetchCalls[0].options?.body as FormData
1165
+ expect(form.get('name')).toBe('potato_01')
1166
+ expect(form.get('description')).toBe('a potato')
1167
+ expect(form.get('tags')).toBe('potato')
1168
+ expect((form.get('file') as File).name).toBe('potato_01.png')
1169
+ // Discord rejects a sticker part with no media type as "Invalid Asset".
1170
+ expect((form.get('file') as File).type).toBe('image/png')
1171
+ expect(sticker.id).toBe('s1')
1172
+ })
1173
+
1174
+ it('createSticker types the part from the bytes, not the filename', async () => {
1175
+ const client = await new DiscordClient().login({ token: 'test-token' })
1176
+ mockResponse({ id: 's2', name: 'potato_02', tags: 'potato', type: 2, format_type: 4 })
1177
+ const gif = new Uint8Array([...Buffer.from('GIF89a'), 0, 0, 0, 0])
1178
+
1179
+ // The filename claims PNG; the bytes are a GIF. Discord answers a part
1180
+ // whose type does not match its content with "Invalid Asset".
1181
+ await client.createSticker('g1', { name: 'potato_02', tags: 'potato' }, gif, 'potato_02.png')
1182
+
1183
+ const form = fetchCalls[0].options?.body as FormData
1184
+ expect((form.get('file') as File).type).toBe('image/gif')
1185
+ })
1186
+
1187
+ it('createSticker refuses bytes that are not a supported format', async () => {
1188
+ const client = await new DiscordClient().login({ token: 'test-token' })
1189
+ const junk = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7])
1190
+
1191
+ await expect(
1192
+ client.createSticker('g1', { name: 'potato_02', tags: 'potato' }, junk, 'potato_02.png'),
1193
+ ).rejects.toThrow('not one of')
1194
+ expect(fetchCalls).toHaveLength(0)
1195
+ })
1196
+
1197
+ it('createSticker refuses a JPEG, which the sticker endpoint does not take', async () => {
1198
+ const client = await new DiscordClient().login({ token: 'test-token' })
1199
+ const jpeg = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0])
1200
+
1201
+ await expect(
1202
+ client.createSticker('g1', { name: 'potato_02', tags: 'potato' }, jpeg, 'potato_02.jpg'),
1203
+ ).rejects.toThrow('File is a jpeg')
1204
+ expect(fetchCalls).toHaveLength(0)
1205
+ })
1206
+
1207
+ it('createEmoji refuses a JSON document, which the emoji endpoint does not take', async () => {
1208
+ const client = await new DiscordClient().login({ token: 'test-token' })
1209
+ const json = new Uint8Array(Buffer.from('{"v":"5.5.7"}'))
1210
+
1211
+ await expect(client.createEmoji('g1', 'potato_01', json, 'potato_01.json')).rejects.toThrow('File is a json')
1212
+ expect(fetchCalls).toHaveLength(0)
1213
+ })
1214
+
1215
+ it('createSticker refuses JSON that is not a Lottie animation', async () => {
1216
+ const client = await new DiscordClient().login({ token: 'test-token' })
1217
+ const empty = new Uint8Array(Buffer.from('{}'))
1218
+
1219
+ await expect(
1220
+ client.createSticker('g1', { name: 'potato_02', tags: 'potato' }, empty, 'potato_02.json'),
1221
+ ).rejects.toThrow('not a Lottie animation')
1222
+ expect(fetchCalls).toHaveLength(0)
1223
+ })
1224
+
1225
+ it('deleteSticker issues a DELETE for the sticker', async () => {
1226
+ const client = await new DiscordClient().login({ token: 'test-token' })
1227
+ mockResponse(null, 204)
1228
+
1229
+ await client.deleteSticker('g1', 's1')
1230
+
1231
+ expect(fetchCalls[0].url).toBe('https://discord.com/api/v10/guilds/g1/stickers/s1')
1232
+ expect(fetchCalls[0].options?.method).toBe('DELETE')
1233
+ })
1234
+ })
1102
1235
  })
@@ -1,10 +1,13 @@
1
1
  import { readFile } from 'node:fs/promises'
2
2
 
3
+ import type { ExpressionFormat } from './expression-format'
4
+ import { EMOJI_FORMATS, STICKER_FORMATS, lottieError, mediaTypeOf, sniffFormat } from './expression-format'
3
5
  import { getDiscordHeaders } from './super-properties'
4
6
  import { DiscordSearchIndexNotReadyResponseSchema, DiscordSearchResponseSchema } from './types'
5
7
  import type {
6
8
  DiscordChannel,
7
9
  DiscordDMChannel,
10
+ DiscordEmoji,
8
11
  DiscordFile,
9
12
  DiscordGuild,
10
13
  DiscordGuildMember,
@@ -16,6 +19,7 @@ import type {
16
19
  DiscordRole,
17
20
  DiscordSearchOptions,
18
21
  DiscordSearchResult,
22
+ DiscordSticker,
19
23
  DiscordUnreadDM,
20
24
  DiscordUnreadDMsResult,
21
25
  DiscordUnreadMention,
@@ -95,6 +99,7 @@ interface RateLimitBucket {
95
99
  }
96
100
 
97
101
  const BASE_URL = 'https://discord.com/api/v10'
102
+
98
103
  const MAX_RETRIES = 3
99
104
  const BASE_BACKOFF_MS = 100
100
105
  const MAX_SEARCH_INDEX_RETRY_MS = 30_000
@@ -384,6 +389,73 @@ export class DiscordClient {
384
389
  return files
385
390
  }
386
391
 
392
+ /**
393
+ * The media type the part declares comes from the bytes, never the filename,
394
+ * and only from the formats the target endpoint actually takes — a JPEG
395
+ * declared to the sticker endpoint is refused by Discord as "Invalid Asset".
396
+ */
397
+ private mediaTypeOrThrow(image: Uint8Array, allowed: ReadonlySet<ExpressionFormat>): string {
398
+ const format = sniffFormat(image)
399
+ if (!format || !allowed.has(format)) {
400
+ const names = [...allowed].join(', ')
401
+ throw new DiscordError(
402
+ format ? `File is a ${format}; this endpoint takes ${names}` : `File is not one of ${names}`,
403
+ 'unsupported_asset',
404
+ )
405
+ }
406
+ if (format === 'json') {
407
+ const problem = lottieError(image)
408
+ if (problem) throw new DiscordError(problem, 'unsupported_asset')
409
+ }
410
+
411
+ return mediaTypeOf(format)
412
+ }
413
+
414
+ async listEmojis(guildId: string): Promise<DiscordEmoji[]> {
415
+ return this.request<DiscordEmoji[]>('GET', `/guilds/${guildId}/emojis`)
416
+ }
417
+
418
+ async createEmoji(
419
+ guildId: string,
420
+ name: string,
421
+ image: Uint8Array,
422
+ filename: string,
423
+ roles: string[] = [],
424
+ ): Promise<DiscordEmoji> {
425
+ return this.request<DiscordEmoji>('POST', `/guilds/${guildId}/emojis`, {
426
+ name,
427
+ image: `data:${this.mediaTypeOrThrow(image, EMOJI_FORMATS)};base64,${Buffer.from(image).toString('base64')}`,
428
+ roles,
429
+ })
430
+ }
431
+
432
+ async deleteEmoji(guildId: string, emojiId: string): Promise<void> {
433
+ await this.request<void>('DELETE', `/guilds/${guildId}/emojis/${emojiId}`)
434
+ }
435
+
436
+ async listStickers(guildId: string): Promise<DiscordSticker[]> {
437
+ return this.request<DiscordSticker[]>('GET', `/guilds/${guildId}/stickers`)
438
+ }
439
+
440
+ async createSticker(
441
+ guildId: string,
442
+ fields: { name: string; description?: string; tags: string },
443
+ image: Uint8Array,
444
+ filename: string,
445
+ ): Promise<DiscordSticker> {
446
+ const formData = new FormData()
447
+ formData.append('name', fields.name)
448
+ formData.append('description', fields.description ?? '')
449
+ formData.append('tags', fields.tags)
450
+ formData.append('file', new Blob([image], { type: this.mediaTypeOrThrow(image, STICKER_FORMATS) }), filename)
451
+
452
+ return this.requestFormData<DiscordSticker>(`/guilds/${guildId}/stickers`, formData)
453
+ }
454
+
455
+ async deleteSticker(guildId: string, stickerId: string): Promise<void> {
456
+ await this.request<void>('DELETE', `/guilds/${guildId}/stickers/${stickerId}`)
457
+ }
458
+
387
459
  async listDMChannels(): Promise<DiscordDMChannel[]> {
388
460
  return this.request<DiscordDMChannel[]>('GET', '/users/@me/channels')
389
461
  }