agent-messenger 2.37.2 → 2.38.1

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 (95) 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/slack/cli.d.ts.map +1 -1
  41. package/dist/src/platforms/slack/cli.js +5 -0
  42. package/dist/src/platforms/slack/cli.js.map +1 -1
  43. package/dist/src/platforms/slack/commands/auth.d.ts.map +1 -1
  44. package/dist/src/platforms/slack/commands/auth.js +9 -3
  45. package/dist/src/platforms/slack/commands/auth.js.map +1 -1
  46. package/dist/src/platforms/slack/credential-manager.d.ts +2 -0
  47. package/dist/src/platforms/slack/credential-manager.d.ts.map +1 -1
  48. package/dist/src/platforms/slack/credential-manager.js +17 -3
  49. package/dist/src/platforms/slack/credential-manager.js.map +1 -1
  50. package/docs/content/docs/cli/discord.mdx +73 -0
  51. package/docs/content/docs/cli/slack.mdx +8 -1
  52. package/docs/content/docs/sdk/discord.mdx +50 -0
  53. package/package.json +1 -1
  54. package/skills/agent-channeltalk/SKILL.md +1 -1
  55. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  56. package/skills/agent-discord/SKILL.md +69 -1
  57. package/skills/agent-discord/references/common-patterns.md +77 -0
  58. package/skills/agent-discordbot/SKILL.md +1 -1
  59. package/skills/agent-imessage/SKILL.md +1 -1
  60. package/skills/agent-instagram/SKILL.md +1 -1
  61. package/skills/agent-kakaotalk/SKILL.md +1 -1
  62. package/skills/agent-line/SKILL.md +1 -1
  63. package/skills/agent-slack/SKILL.md +9 -2
  64. package/skills/agent-slack/references/authentication.md +12 -0
  65. package/skills/agent-slack/references/common-patterns.md +18 -0
  66. package/skills/agent-slackbot/SKILL.md +1 -1
  67. package/skills/agent-teams/SKILL.md +1 -1
  68. package/skills/agent-telegram/SKILL.md +1 -1
  69. package/skills/agent-telegrambot/SKILL.md +1 -1
  70. package/skills/agent-webex/SKILL.md +1 -1
  71. package/skills/agent-webexbot/SKILL.md +1 -1
  72. package/skills/agent-wechatbot/SKILL.md +1 -1
  73. package/skills/agent-whatsapp/SKILL.md +1 -1
  74. package/skills/agent-whatsappbot/SKILL.md +1 -1
  75. package/src/platforms/discord/cli.ts +4 -0
  76. package/src/platforms/discord/client.test.ts +133 -0
  77. package/src/platforms/discord/client.ts +72 -0
  78. package/src/platforms/discord/commands/emoji.test.ts +122 -0
  79. package/src/platforms/discord/commands/emoji.ts +124 -0
  80. package/src/platforms/discord/commands/index.ts +2 -0
  81. package/src/platforms/discord/commands/server.test.ts +45 -1
  82. package/src/platforms/discord/commands/server.ts +21 -1
  83. package/src/platforms/discord/commands/sticker.test.ts +118 -0
  84. package/src/platforms/discord/commands/sticker.ts +135 -0
  85. package/src/platforms/discord/expression-format.test.ts +107 -0
  86. package/src/platforms/discord/expression-format.ts +117 -0
  87. package/src/platforms/discord/expression-names.test.ts +41 -0
  88. package/src/platforms/discord/expression-names.ts +36 -0
  89. package/src/platforms/discord/index.ts +2 -0
  90. package/src/platforms/discord/types.ts +31 -0
  91. package/src/platforms/slack/cli.test.ts +111 -1
  92. package/src/platforms/slack/cli.ts +5 -0
  93. package/src/platforms/slack/commands/auth.ts +14 -3
  94. package/src/platforms/slack/credential-manager.ts +21 -3
  95. package/src/platforms/slack/credential-manager.workspace-select.test.ts +70 -0
@@ -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.1
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.1
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.1
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
+ )
@@ -1,6 +1,7 @@
1
1
  export { authCommand } from './auth'
2
2
  export { channelCommand } from './channel'
3
3
  export { dmCommand } from './dm'
4
+ export { emojiCommand } from './emoji'
4
5
  export { fileCommand } from './file'
5
6
  export { friendCommand } from './friend'
6
7
  export { memberCommand } from './member'
@@ -11,6 +12,7 @@ export { profileCommand } from './profile'
11
12
  export { reactionCommand } from './reaction'
12
13
  export { serverCommand } from './server'
13
14
  export { snapshotCommand } from './snapshot'
15
+ export { stickerCommand } from './sticker'
14
16
  export { threadCommand } from './thread'
15
17
  export { userCommand } from './user'
16
18
  export { whoamiCommand } from './whoami'
@@ -1,11 +1,13 @@
1
- import { afterEach, beforeEach, expect, spyOn, it } from 'bun:test'
1
+ import { afterEach, beforeEach, expect, mock, spyOn, it } from 'bun:test'
2
2
 
3
3
  import { DiscordClient } from '../client'
4
4
  import { DiscordCredentialManager } from '../credential-manager'
5
+ import { infoAction } from './server'
5
6
 
6
7
  let clientListServersSpy: ReturnType<typeof spyOn>
7
8
  let clientGetServerSpy: ReturnType<typeof spyOn>
8
9
  let credManagerLoadSpy: ReturnType<typeof spyOn>
10
+ const originalLog = console.log
9
11
  let credManagerSetCurrentServerSpy: ReturnType<typeof spyOn>
10
12
  let credManagerGetCurrentServerSpy: ReturnType<typeof spyOn>
11
13
 
@@ -46,6 +48,7 @@ beforeEach(() => {
46
48
  })
47
49
 
48
50
  afterEach(() => {
51
+ console.log = originalLog
49
52
  clientListServersSpy?.mockRestore()
50
53
  clientGetServerSpy?.mockRestore()
51
54
  credManagerLoadSpy?.mockRestore()
@@ -132,3 +135,44 @@ it('current: returns current server info', async () => {
132
135
  expect(current).toBe('server-1')
133
136
  expect(config.current_server).toBe('server-1')
134
137
  })
138
+
139
+ it('info: reports the boost tier, expression counts and remaining sticker slots', async () => {
140
+ clientGetServerSpy.mockResolvedValue({
141
+ id: 'server-1',
142
+ name: 'Server One',
143
+ icon: 'icon1',
144
+ owner_id: 'user-9',
145
+ premium_tier: 2,
146
+ premium_subscription_count: 8,
147
+ emojis: [{ id: 'e1', name: 'potato_01', animated: false }],
148
+ stickers: [{ id: 's1', name: 'potato_01', tags: 'potato', type: 2, format_type: 1 }],
149
+ })
150
+ const consoleSpy = mock((_msg: string) => {})
151
+ console.log = consoleSpy
152
+
153
+ await infoAction('server-1', { pretty: false })
154
+
155
+ const output = consoleSpy.mock.calls[0][0]
156
+ expect(output).toContain('"owner_id":"user-9"')
157
+ expect(output).toContain('"premium_tier":2')
158
+ expect(output).toContain('"emoji_count":1')
159
+ expect(output).toContain('"sticker_count":1')
160
+ expect(output).toContain('"sticker_slots":30')
161
+ expect(output).toContain('"static_emoji_slots_remaining":149')
162
+ })
163
+
164
+ it('info: falls back to the base sticker allowance when the server has no boosts', async () => {
165
+ clientGetServerSpy.mockResolvedValue({
166
+ id: 'server-1',
167
+ name: 'Server One',
168
+ premium_tier: 0,
169
+ emojis: [],
170
+ stickers: [],
171
+ })
172
+ const consoleSpy = mock((_msg: string) => {})
173
+ console.log = consoleSpy
174
+
175
+ await infoAction('server-1', { pretty: false })
176
+
177
+ expect(consoleSpy.mock.calls[0][0]).toContain('"sticker_slots":5')
178
+ })
@@ -6,6 +6,10 @@ import { formatOutput } from '@/shared/utils/output'
6
6
  import { DiscordClient } from '../client'
7
7
  import { DiscordCredentialManager } from '../credential-manager'
8
8
 
9
+ /** Discord raises both allowances with the server's boost level. */
10
+ const STATIC_EMOJI_SLOTS_BY_TIER = [50, 100, 150, 250]
11
+ const STICKER_SLOTS_BY_TIER = [5, 15, 30, 60]
12
+
9
13
  export async function listAction(options: { pretty?: boolean }): Promise<void> {
10
14
  try {
11
15
  const credManager = new DiscordCredentialManager()
@@ -37,11 +41,27 @@ export async function infoAction(serverId: string, options: { pretty?: boolean }
37
41
  const client = await new DiscordClient().login({ token: config.token })
38
42
  const server = await client.getServer(serverId)
39
43
 
44
+ const tier = server.premium_tier ?? 0
45
+ const emojis = server.emojis ?? []
46
+ const stickers = server.stickers ?? []
47
+ const staticEmojiCount = emojis.filter((emoji) => !emoji.animated).length
48
+
40
49
  const output = {
41
50
  id: server.id,
42
51
  name: server.name,
43
52
  icon: server.icon,
44
- owner: server.owner,
53
+ owner_id: server.owner_id,
54
+ premium_tier: tier,
55
+ premium_subscription_count: server.premium_subscription_count ?? 0,
56
+ emoji_count: emojis.length,
57
+ sticker_count: stickers.length,
58
+ static_emoji_slots: STATIC_EMOJI_SLOTS_BY_TIER[tier] ?? STATIC_EMOJI_SLOTS_BY_TIER[0],
59
+ static_emoji_slots_remaining: Math.max(
60
+ 0,
61
+ (STATIC_EMOJI_SLOTS_BY_TIER[tier] ?? STATIC_EMOJI_SLOTS_BY_TIER[0]) - staticEmojiCount,
62
+ ),
63
+ sticker_slots: STICKER_SLOTS_BY_TIER[tier] ?? STICKER_SLOTS_BY_TIER[0],
64
+ sticker_slots_remaining: Math.max(0, (STICKER_SLOTS_BY_TIER[tier] ?? STICKER_SLOTS_BY_TIER[0]) - stickers.length),
45
65
  }
46
66
 
47
67
  console.log(formatOutput(output, options.pretty))