agent-messenger 2.37.1 → 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 (82) 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/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
  41. package/dist/src/platforms/kakaotalk/client.js +15 -2
  42. package/dist/src/platforms/kakaotalk/client.js.map +1 -1
  43. package/docs/content/docs/cli/discord.mdx +73 -0
  44. package/docs/content/docs/sdk/discord.mdx +50 -0
  45. package/package.json +1 -1
  46. package/skills/agent-channeltalk/SKILL.md +1 -1
  47. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  48. package/skills/agent-discord/SKILL.md +69 -1
  49. package/skills/agent-discord/references/common-patterns.md +77 -0
  50. package/skills/agent-discordbot/SKILL.md +1 -1
  51. package/skills/agent-imessage/SKILL.md +1 -1
  52. package/skills/agent-instagram/SKILL.md +1 -1
  53. package/skills/agent-kakaotalk/SKILL.md +1 -1
  54. package/skills/agent-line/SKILL.md +1 -1
  55. package/skills/agent-slack/SKILL.md +1 -1
  56. package/skills/agent-slackbot/SKILL.md +1 -1
  57. package/skills/agent-teams/SKILL.md +1 -1
  58. package/skills/agent-telegram/SKILL.md +1 -1
  59. package/skills/agent-telegrambot/SKILL.md +1 -1
  60. package/skills/agent-webex/SKILL.md +1 -1
  61. package/skills/agent-webexbot/SKILL.md +1 -1
  62. package/skills/agent-wechatbot/SKILL.md +1 -1
  63. package/skills/agent-whatsapp/SKILL.md +1 -1
  64. package/skills/agent-whatsappbot/SKILL.md +1 -1
  65. package/src/platforms/discord/cli.ts +4 -0
  66. package/src/platforms/discord/client.test.ts +133 -0
  67. package/src/platforms/discord/client.ts +72 -0
  68. package/src/platforms/discord/commands/emoji.test.ts +122 -0
  69. package/src/platforms/discord/commands/emoji.ts +124 -0
  70. package/src/platforms/discord/commands/index.ts +2 -0
  71. package/src/platforms/discord/commands/server.test.ts +45 -1
  72. package/src/platforms/discord/commands/server.ts +21 -1
  73. package/src/platforms/discord/commands/sticker.test.ts +118 -0
  74. package/src/platforms/discord/commands/sticker.ts +135 -0
  75. package/src/platforms/discord/expression-format.test.ts +107 -0
  76. package/src/platforms/discord/expression-format.ts +117 -0
  77. package/src/platforms/discord/expression-names.test.ts +41 -0
  78. package/src/platforms/discord/expression-names.ts +36 -0
  79. package/src/platforms/discord/index.ts +2 -0
  80. package/src/platforms/discord/types.ts +31 -0
  81. package/src/platforms/kakaotalk/client.test.ts +66 -0
  82. package/src/platforms/kakaotalk/client.ts +14 -2
@@ -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.1
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.1
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.1
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.1
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.1
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.1
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.1
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.1
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.1
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.1
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.1
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.1
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.1
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.1
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.1
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
  }
@@ -0,0 +1,122 @@
1
+ import { afterEach, beforeEach, expect, mock, spyOn, it } from 'bun:test'
2
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import { DiscordClient } from '../client'
7
+ import { DiscordCredentialManager } from '../credential-manager'
8
+ import { createAction, deleteAction, listAction } from './emoji'
9
+
10
+ let clientListEmojisSpy: ReturnType<typeof spyOn>
11
+ let clientCreateEmojiSpy: ReturnType<typeof spyOn>
12
+ let clientDeleteEmojiSpy: ReturnType<typeof spyOn>
13
+ let credManagerLoadSpy: ReturnType<typeof spyOn>
14
+ let tempDir: string
15
+ const originalLog = console.log
16
+ const originalExit = process.exit
17
+
18
+ function pngBytes(width: number, height: number, padding = 0): Uint8Array {
19
+ const bytes = new Uint8Array(24 + padding)
20
+ bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0)
21
+ bytes.set([0x00, 0x00, 0x00, 0x0d], 8)
22
+ bytes.set([0x49, 0x48, 0x44, 0x52], 12)
23
+ const view = new DataView(bytes.buffer)
24
+ view.setUint32(16, width)
25
+ view.setUint32(20, height)
26
+ return bytes
27
+ }
28
+
29
+ async function writeTempImage(filename: string, bytes: Uint8Array): Promise<string> {
30
+ const path = join(tempDir, filename)
31
+ await writeFile(path, bytes)
32
+ return path
33
+ }
34
+
35
+ beforeEach(async () => {
36
+ tempDir = await mkdtemp(join(tmpdir(), 'discord-emoji-'))
37
+
38
+ clientListEmojisSpy = spyOn(DiscordClient.prototype, 'listEmojis').mockResolvedValue([
39
+ { id: 'e1', name: 'potato_01', animated: false },
40
+ { id: 'e2', name: 'potato_02', animated: false },
41
+ ])
42
+
43
+ clientCreateEmojiSpy = spyOn(DiscordClient.prototype, 'createEmoji').mockResolvedValue({
44
+ id: 'e9',
45
+ name: 'potato_13',
46
+ animated: false,
47
+ })
48
+
49
+ clientDeleteEmojiSpy = spyOn(DiscordClient.prototype, 'deleteEmoji').mockResolvedValue(undefined)
50
+
51
+ credManagerLoadSpy = spyOn(DiscordCredentialManager.prototype, 'load').mockResolvedValue({
52
+ token: 'test-token',
53
+ current_server: 'server-1',
54
+ servers: {},
55
+ })
56
+ })
57
+
58
+ afterEach(async () => {
59
+ console.log = originalLog
60
+ process.exit = originalExit
61
+ await rm(tempDir, { recursive: true, force: true })
62
+ clientListEmojisSpy?.mockRestore()
63
+ clientCreateEmojiSpy?.mockRestore()
64
+ clientDeleteEmojiSpy?.mockRestore()
65
+ credManagerLoadSpy?.mockRestore()
66
+ })
67
+
68
+ it('list: reports the guild emoji with static and animated counts', async () => {
69
+ const consoleSpy = mock((_msg: string) => {})
70
+ console.log = consoleSpy
71
+
72
+ await listAction('g1', { pretty: false })
73
+
74
+ const output = consoleSpy.mock.calls[0][0]
75
+ expect(output).toContain('potato_01')
76
+ expect(output).toContain('"static_count":2')
77
+ expect(output).toContain('"animated_count":0')
78
+ })
79
+
80
+ it('create: uploads the image with its content type and derives the name from the filename', async () => {
81
+ const consoleSpy = mock((_msg: string) => {})
82
+ console.log = consoleSpy
83
+ const path = await writeTempImage('potato_13.png', pngBytes(128, 128))
84
+
85
+ await createAction('g1', path, { pretty: false })
86
+
87
+ expect(clientCreateEmojiSpy).toHaveBeenCalledWith('g1', 'potato_13', expect.any(Uint8Array), 'potato_13.png')
88
+ expect(consoleSpy.mock.calls[0][0]).toContain('potato_13')
89
+ })
90
+
91
+ it('create: prefers an explicit --name over the filename', async () => {
92
+ console.log = mock((_msg: string) => {})
93
+ const path = await writeTempImage('potato_13.png', pngBytes(128, 128))
94
+
95
+ await createAction('g1', path, { name: 'spud', pretty: false })
96
+
97
+ expect(clientCreateEmojiSpy.mock.calls[0][1]).toBe('spud')
98
+ })
99
+
100
+ it('delete: removes the emoji by id', async () => {
101
+ const consoleSpy = mock((_msg: string) => {})
102
+ console.log = consoleSpy
103
+
104
+ await deleteAction('g1', 'e1', { pretty: false })
105
+
106
+ expect(clientDeleteEmojiSpy).toHaveBeenCalledWith('g1', 'e1')
107
+ expect(consoleSpy.mock.calls[0][0]).toContain('"success":true')
108
+ })
109
+
110
+ it('create: rejects a hyphenated name without calling the API', async () => {
111
+ const consoleSpy = mock((_msg: string) => {})
112
+ console.log = consoleSpy
113
+ spyOn(process, 'exit').mockImplementation(() => {
114
+ throw new Error('exit')
115
+ })
116
+ const path = await writeTempImage('potato-13.png', pngBytes(128, 128))
117
+
118
+ await expect(createAction('g1', path, { pretty: false })).rejects.toThrow('exit')
119
+
120
+ expect(clientCreateEmojiSpy).not.toHaveBeenCalled()
121
+ expect(consoleSpy.mock.calls[0][0]).toContain('letters, digits, underscores')
122
+ })
@@ -0,0 +1,124 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { basename, extname, resolve } from 'node:path'
3
+
4
+ import { Command } from 'commander'
5
+
6
+ import { handleError } from '@/shared/utils/error-handler'
7
+ import { formatOutput } from '@/shared/utils/output'
8
+
9
+ import { DiscordClient } from '../client'
10
+ import { DiscordCredentialManager } from '../credential-manager'
11
+ import { validateEmojiName } from '../expression-names'
12
+
13
+ export async function listAction(serverId: string, options: { pretty?: boolean }): Promise<void> {
14
+ try {
15
+ const credManager = new DiscordCredentialManager()
16
+ const config = await credManager.load()
17
+
18
+ if (!config.token) {
19
+ console.log(formatOutput({ error: 'Not authenticated. Run "auth extract" first.' }, options.pretty))
20
+ process.exit(1)
21
+ }
22
+
23
+ const client = await new DiscordClient().login({ token: config.token })
24
+ const emojis = await client.listEmojis(serverId)
25
+ const staticCount = emojis.filter((emoji) => !emoji.animated).length
26
+
27
+ console.log(
28
+ formatOutput(
29
+ {
30
+ server_id: serverId,
31
+ count: emojis.length,
32
+ static_count: staticCount,
33
+ animated_count: emojis.length - staticCount,
34
+ emojis: emojis.map((emoji) => ({ id: emoji.id, name: emoji.name, animated: emoji.animated ?? false })),
35
+ },
36
+ options.pretty,
37
+ ),
38
+ )
39
+ } catch (error) {
40
+ handleError(error as Error)
41
+ }
42
+ }
43
+
44
+ export async function createAction(
45
+ serverId: string,
46
+ path: string,
47
+ options: { name?: string; pretty?: boolean },
48
+ ): Promise<void> {
49
+ try {
50
+ const credManager = new DiscordCredentialManager()
51
+ const config = await credManager.load()
52
+
53
+ if (!config.token) {
54
+ console.log(formatOutput({ error: 'Not authenticated. Run "auth extract" first.' }, options.pretty))
55
+ process.exit(1)
56
+ }
57
+
58
+ const client = await new DiscordClient().login({ token: config.token })
59
+
60
+ const filePath = resolve(path)
61
+ const filename = basename(filePath)
62
+ const name = options.name ?? basename(filename, extname(filename))
63
+
64
+ const nameError = validateEmojiName(name)
65
+ if (nameError) {
66
+ console.log(formatOutput({ error: nameError }, options.pretty))
67
+ process.exit(1)
68
+ }
69
+
70
+ const image = new Uint8Array(await readFile(filePath))
71
+
72
+ const emoji = await client.createEmoji(serverId, name, image, filename)
73
+
74
+ console.log(formatOutput({ success: true, server_id: serverId, id: emoji.id, name: emoji.name }, options.pretty))
75
+ } catch (error) {
76
+ handleError(error as Error)
77
+ }
78
+ }
79
+
80
+ export async function deleteAction(serverId: string, emojiId: string, options: { pretty?: boolean }): Promise<void> {
81
+ try {
82
+ const credManager = new DiscordCredentialManager()
83
+ const config = await credManager.load()
84
+
85
+ if (!config.token) {
86
+ console.log(formatOutput({ error: 'Not authenticated. Run "auth extract" first.' }, options.pretty))
87
+ process.exit(1)
88
+ }
89
+
90
+ const client = await new DiscordClient().login({ token: config.token })
91
+ await client.deleteEmoji(serverId, emojiId)
92
+
93
+ console.log(formatOutput({ success: true, server_id: serverId, id: emojiId }, options.pretty))
94
+ } catch (error) {
95
+ handleError(error as Error)
96
+ }
97
+ }
98
+
99
+ export const emojiCommand = new Command('emoji')
100
+ .description('Custom emoji commands')
101
+ .addCommand(
102
+ new Command('list')
103
+ .description('List custom emoji in a server')
104
+ .argument('<server-id>', 'Server ID')
105
+ .option('--pretty', 'Pretty print JSON output')
106
+ .action(listAction),
107
+ )
108
+ .addCommand(
109
+ new Command('create')
110
+ .description('Upload a custom emoji to a server')
111
+ .argument('<server-id>', 'Server ID')
112
+ .argument('<file>', 'Image file (PNG, JPEG, GIF or WebP, max 256KB)')
113
+ .option('--name <name>', 'Emoji name (defaults to the filename without extension)')
114
+ .option('--pretty', 'Pretty print JSON output')
115
+ .action(createAction),
116
+ )
117
+ .addCommand(
118
+ new Command('delete')
119
+ .description('Delete a custom emoji from a server')
120
+ .argument('<server-id>', 'Server ID')
121
+ .argument('<emoji-id>', 'Emoji ID')
122
+ .option('--pretty', 'Pretty print JSON output')
123
+ .action(deleteAction),
124
+ )