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
@@ -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))
@@ -0,0 +1,118 @@
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 './sticker'
9
+
10
+ let clientListStickersSpy: ReturnType<typeof spyOn>
11
+ let clientCreateStickerSpy: ReturnType<typeof spyOn>
12
+ let clientDeleteStickerSpy: 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-sticker-'))
37
+
38
+ clientListStickersSpy = spyOn(DiscordClient.prototype, 'listStickers').mockResolvedValue([
39
+ { id: 's1', name: 'potato_01', tags: 'potato', type: 2, format_type: 1 },
40
+ ])
41
+
42
+ clientCreateStickerSpy = spyOn(DiscordClient.prototype, 'createSticker').mockResolvedValue({
43
+ id: 's9',
44
+ name: 'potato_13',
45
+ tags: 'potato',
46
+ type: 2,
47
+ format_type: 1,
48
+ })
49
+
50
+ clientDeleteStickerSpy = spyOn(DiscordClient.prototype, 'deleteSticker').mockResolvedValue(undefined)
51
+
52
+ credManagerLoadSpy = spyOn(DiscordCredentialManager.prototype, 'load').mockResolvedValue({
53
+ token: 'test-token',
54
+ current_server: 'server-1',
55
+ servers: {},
56
+ })
57
+ })
58
+
59
+ afterEach(async () => {
60
+ console.log = originalLog
61
+ process.exit = originalExit
62
+ await rm(tempDir, { recursive: true, force: true })
63
+ clientListStickersSpy?.mockRestore()
64
+ clientCreateStickerSpy?.mockRestore()
65
+ clientDeleteStickerSpy?.mockRestore()
66
+ credManagerLoadSpy?.mockRestore()
67
+ })
68
+
69
+ it('list: reports the guild stickers', async () => {
70
+ const consoleSpy = mock((_msg: string) => {})
71
+ console.log = consoleSpy
72
+
73
+ await listAction('g1', { pretty: false })
74
+
75
+ const output = consoleSpy.mock.calls[0][0]
76
+ expect(output).toContain('potato_01')
77
+ expect(output).toContain('"count":1')
78
+ })
79
+
80
+ it('create: uploads a 320x320 PNG with its multipart fields', async () => {
81
+ const consoleSpy = mock((_msg: string) => {})
82
+ console.log = consoleSpy
83
+ const path = await writeTempImage('potato_13.png', pngBytes(320, 320))
84
+
85
+ await createAction('g1', path, { tags: 'potato', description: 'a potato', pretty: false })
86
+
87
+ expect(clientCreateStickerSpy).toHaveBeenCalledWith(
88
+ 'g1',
89
+ { name: 'potato_13', description: 'a potato', tags: 'potato' },
90
+ expect.any(Uint8Array),
91
+ 'potato_13.png',
92
+ )
93
+ expect(consoleSpy.mock.calls[0][0]).toContain('potato_13')
94
+ })
95
+
96
+ it('delete: removes the sticker by id', async () => {
97
+ const consoleSpy = mock((_msg: string) => {})
98
+ console.log = consoleSpy
99
+
100
+ await deleteAction('g1', 's1', { pretty: false })
101
+
102
+ expect(clientDeleteStickerSpy).toHaveBeenCalledWith('g1', 's1')
103
+ expect(consoleSpy.mock.calls[0][0]).toContain('"success":true')
104
+ })
105
+
106
+ it('create: rejects a one-character name without calling the API', async () => {
107
+ const consoleSpy = mock((_msg: string) => {})
108
+ console.log = consoleSpy
109
+ spyOn(process, 'exit').mockImplementation(() => {
110
+ throw new Error('exit')
111
+ })
112
+ const path = await writeTempImage('potato_13.png', pngBytes(320, 320))
113
+
114
+ await expect(createAction('g1', path, { name: '흥', tags: '😤', pretty: false })).rejects.toThrow('exit')
115
+
116
+ expect(clientCreateStickerSpy).not.toHaveBeenCalled()
117
+ expect(consoleSpy.mock.calls[0][0]).toContain('at least 2')
118
+ })
@@ -0,0 +1,135 @@
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 { validateStickerName } 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 stickers = await client.listStickers(serverId)
25
+
26
+ console.log(
27
+ formatOutput(
28
+ {
29
+ server_id: serverId,
30
+ count: stickers.length,
31
+ stickers: stickers.map((sticker) => ({
32
+ id: sticker.id,
33
+ name: sticker.name,
34
+ tags: sticker.tags,
35
+ description: sticker.description ?? null,
36
+ })),
37
+ },
38
+ options.pretty,
39
+ ),
40
+ )
41
+ } catch (error) {
42
+ handleError(error as Error)
43
+ }
44
+ }
45
+
46
+ export async function createAction(
47
+ serverId: string,
48
+ path: string,
49
+ options: { name?: string; description?: string; tags: string; pretty?: boolean },
50
+ ): Promise<void> {
51
+ try {
52
+ const credManager = new DiscordCredentialManager()
53
+ const config = await credManager.load()
54
+
55
+ if (!config.token) {
56
+ console.log(formatOutput({ error: 'Not authenticated. Run "auth extract" first.' }, options.pretty))
57
+ process.exit(1)
58
+ }
59
+
60
+ const client = await new DiscordClient().login({ token: config.token })
61
+
62
+ const filePath = resolve(path)
63
+ const filename = basename(filePath)
64
+ const name = options.name ?? basename(filename, extname(filename))
65
+
66
+ const nameError = validateStickerName(name)
67
+ if (nameError) {
68
+ console.log(formatOutput({ error: nameError }, options.pretty))
69
+ process.exit(1)
70
+ }
71
+
72
+ const image = new Uint8Array(await readFile(filePath))
73
+
74
+ const sticker = await client.createSticker(
75
+ serverId,
76
+ { name, description: options.description ?? '', tags: options.tags },
77
+ image,
78
+ filename,
79
+ )
80
+
81
+ console.log(
82
+ formatOutput({ success: true, server_id: serverId, id: sticker.id, name: sticker.name }, options.pretty),
83
+ )
84
+ } catch (error) {
85
+ handleError(error as Error)
86
+ }
87
+ }
88
+
89
+ export async function deleteAction(serverId: string, stickerId: string, options: { pretty?: boolean }): Promise<void> {
90
+ try {
91
+ const credManager = new DiscordCredentialManager()
92
+ const config = await credManager.load()
93
+
94
+ if (!config.token) {
95
+ console.log(formatOutput({ error: 'Not authenticated. Run "auth extract" first.' }, options.pretty))
96
+ process.exit(1)
97
+ }
98
+
99
+ const client = await new DiscordClient().login({ token: config.token })
100
+ await client.deleteSticker(serverId, stickerId)
101
+
102
+ console.log(formatOutput({ success: true, server_id: serverId, id: stickerId }, options.pretty))
103
+ } catch (error) {
104
+ handleError(error as Error)
105
+ }
106
+ }
107
+
108
+ export const stickerCommand = new Command('sticker')
109
+ .description('Custom sticker commands')
110
+ .addCommand(
111
+ new Command('list')
112
+ .description('List custom stickers in a server')
113
+ .argument('<server-id>', 'Server ID')
114
+ .option('--pretty', 'Pretty print JSON output')
115
+ .action(listAction),
116
+ )
117
+ .addCommand(
118
+ new Command('create')
119
+ .description('Upload a custom sticker to a server')
120
+ .argument('<server-id>', 'Server ID')
121
+ .argument('<file>', 'Image file (PNG, APNG, GIF or Lottie JSON, max 512KB; Discord documents 320x320)')
122
+ .requiredOption('--tags <emoji>', 'Unicode emoji this sticker relates to')
123
+ .option('--name <name>', 'Sticker name (defaults to the filename without extension)')
124
+ .option('--description <text>', 'Sticker description')
125
+ .option('--pretty', 'Pretty print JSON output')
126
+ .action(createAction),
127
+ )
128
+ .addCommand(
129
+ new Command('delete')
130
+ .description('Delete a custom sticker from a server')
131
+ .argument('<server-id>', 'Server ID')
132
+ .argument('<sticker-id>', 'Sticker ID')
133
+ .option('--pretty', 'Pretty print JSON output')
134
+ .action(deleteAction),
135
+ )