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
@@ -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
+ )
@@ -0,0 +1,107 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { EMOJI_FORMATS, STICKER_FORMATS, lottieError, mediaTypeOf, sniffFormat } from './expression-format'
4
+
5
+ const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13])
6
+ const gif = new Uint8Array([...Buffer.from('GIF89a'), 0, 0, 0, 0])
7
+ const jpeg = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0])
8
+ const webp = new Uint8Array([...Buffer.from('RIFF'), 0, 0, 0, 0, ...Buffer.from('WEBP')])
9
+ const json = new Uint8Array(Buffer.from(' {"v":"5.5.7","fr":30}'))
10
+
11
+ describe('sniffFormat', () => {
12
+ it('reads PNG from its signature', () => {
13
+ expect(sniffFormat(png)).toBe('png')
14
+ })
15
+
16
+ it('reads GIF, JPEG and WebP from their signatures', () => {
17
+ expect(sniffFormat(gif)).toBe('gif')
18
+ expect(sniffFormat(jpeg)).toBe('jpeg')
19
+ expect(sniffFormat(webp)).toBe('webp')
20
+ })
21
+
22
+ it('reads JSON from a leading brace — a Lottie is a JSON document', () => {
23
+ expect(sniffFormat(json)).toBe('json')
24
+ })
25
+
26
+ it('ignores the filename entirely — a GIF named .png is still a GIF', () => {
27
+ // Discord answers a part whose declared type does not match its bytes
28
+ // with a bare "Invalid Asset".
29
+ expect(sniffFormat(gif)).toBe('gif')
30
+ })
31
+
32
+ it('returns null for bytes that match no supported format', () => {
33
+ expect(sniffFormat(new Uint8Array([0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]))).toBeNull()
34
+ })
35
+
36
+ it('returns null for a truncated file', () => {
37
+ expect(sniffFormat(new Uint8Array([0x89, 0x50]))).toBeNull()
38
+ })
39
+
40
+ it('reads JSON past more leading whitespace than a fixed lookahead', () => {
41
+ const padded = new Uint8Array(Buffer.from(' '.repeat(40) + '{"v":"5.5.7"}'))
42
+ expect(sniffFormat(padded)).toBe('json')
43
+ })
44
+
45
+ it('does not mistake a long run of zero bytes for JSON', () => {
46
+ expect(sniffFormat(new Uint8Array(64))).toBeNull()
47
+ })
48
+ })
49
+
50
+ describe('endpoint format sets', () => {
51
+ it('emoji takes the still and animated image formats, not Lottie', () => {
52
+ expect([...EMOJI_FORMATS].sort()).toEqual(['gif', 'jpeg', 'png', 'webp'])
53
+ })
54
+
55
+ it('sticker takes PNG, GIF and JSON, not JPEG or WebP', () => {
56
+ // A JPEG sticker would otherwise be declared image/jpeg and refused by
57
+ // Discord as "Invalid Asset" — the rejection this check exists to avoid.
58
+ expect([...STICKER_FORMATS].sort()).toEqual(['gif', 'json', 'png'])
59
+ })
60
+ })
61
+
62
+ describe('mediaTypeOf', () => {
63
+ it('maps each format to the type Discord expects', () => {
64
+ expect(mediaTypeOf('png')).toBe('image/png')
65
+ expect(mediaTypeOf('gif')).toBe('image/gif')
66
+ expect(mediaTypeOf('jpeg')).toBe('image/jpeg')
67
+ expect(mediaTypeOf('webp')).toBe('image/webp')
68
+ expect(mediaTypeOf('json')).toBe('application/json')
69
+ })
70
+ })
71
+
72
+ describe('lottieError', () => {
73
+ const bytesOf = (text: string) => new Uint8Array(Buffer.from(text))
74
+
75
+ it('accepts a document with a layers array', () => {
76
+ expect(lottieError(bytesOf('{"v":"5.5.7","fr":30,"layers":[{"ty":4}]}'))).toBeNull()
77
+ })
78
+
79
+ it('accepts an animation with no layers yet', () => {
80
+ expect(lottieError(bytesOf('{"v":"5.5.7","layers":[]}'))).toBeNull()
81
+ })
82
+
83
+ it('rejects an empty object', () => {
84
+ // Discord answers `{}` uploaded as a sticker with a bare "Invalid Asset".
85
+ expect(lottieError(bytesOf('{}'))).toContain('layers')
86
+ })
87
+
88
+ it('rejects JSON that is not an animation', () => {
89
+ expect(lottieError(bytesOf('{"name":"package","version":"1.0.0"}'))).toContain('layers')
90
+ })
91
+
92
+ it('rejects a document whose layers is not an array', () => {
93
+ expect(lottieError(bytesOf('{"layers":"nope"}'))).toContain('layers')
94
+ })
95
+
96
+ it('rejects malformed JSON without throwing', () => {
97
+ expect(lottieError(bytesOf('{"layers":['))).toContain('valid JSON')
98
+ })
99
+
100
+ it('rejects a document carrying invalid UTF-8', () => {
101
+ // A lenient decoder turns the stray byte into U+FFFD, leaving JSON that
102
+ // parses and passes — and Discord then refuses it as "Invalid Asset".
103
+ const broken = new Uint8Array([...Buffer.from('{"layers":[],"nm":"'), 0xff, ...Buffer.from('"}')])
104
+ // The layers array is present, so the message must name the real fault.
105
+ expect(lottieError(broken)).toContain('UTF-8')
106
+ })
107
+ })
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Decide an expression's format from its bytes, not its filename.
3
+ *
4
+ * The upload declares a media type in its multipart part, and Discord answers
5
+ * a part whose declared type does not match its content with a bare
6
+ * "Invalid Asset" — the same opaque rejection a missing type produces. A
7
+ * filename is unverified input, so deriving the type from the extension means
8
+ * one mislabelled file in a batch fails without saying which, or why.
9
+ *
10
+ * A leading `{` proves only that the file is JSON, so a sticker's JSON is
11
+ * checked for the one field every Lottie animation carries. Uploading `{}`
12
+ * otherwise comes back as a bare "Invalid Asset". Whether the animation renders
13
+ * is still Discord's call, and Lottie needs a VERIFIED or PARTNERED guild.
14
+ *
15
+ * This deliberately does not check dimensions. Discord's documentation gives
16
+ * 320x320 for stickers, but a 408x408 PNG uploads and registers fine, so a
17
+ * local size check would refuse files the API accepts.
18
+ */
19
+
20
+ export type ExpressionFormat = 'png' | 'gif' | 'jpeg' | 'webp' | 'json'
21
+
22
+ /**
23
+ * The two endpoints do not take the same formats, so one shared set would let a
24
+ * JPEG reach the sticker endpoint — declared image/jpeg, and refused with the
25
+ * same opaque "Invalid Asset" this check exists to avoid.
26
+ */
27
+ export const EMOJI_FORMATS: ReadonlySet<ExpressionFormat> = new Set(['png', 'gif', 'jpeg', 'webp'])
28
+ /** APNG carries the PNG signature, so it is covered by 'png'. */
29
+ export const STICKER_FORMATS: ReadonlySet<ExpressionFormat> = new Set(['png', 'gif', 'json'])
30
+
31
+ const MEDIA_TYPES: Record<ExpressionFormat, string> = {
32
+ png: 'image/png',
33
+ gif: 'image/gif',
34
+ jpeg: 'image/jpeg',
35
+ webp: 'image/webp',
36
+ json: 'application/json',
37
+ }
38
+
39
+ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
40
+ const JPEG_SIGNATURE = [0xff, 0xd8, 0xff]
41
+
42
+ function startsWith(bytes: Uint8Array, signature: number[]): boolean {
43
+ return bytes.length >= signature.length && signature.every((byte, index) => bytes[index] === byte)
44
+ }
45
+
46
+ function ascii(bytes: Uint8Array, start: number, length: number): string {
47
+ return String.fromCharCode(...bytes.subarray(start, start + length))
48
+ }
49
+
50
+ export function mediaTypeOf(format: ExpressionFormat): string {
51
+ return MEDIA_TYPES[format]
52
+ }
53
+
54
+ export function sniffFormat(bytes: Uint8Array): ExpressionFormat | null {
55
+ // APNG carries the PNG signature too; Discord takes both as image/png.
56
+ if (startsWith(bytes, PNG_SIGNATURE)) return 'png'
57
+ if (startsWith(bytes, JPEG_SIGNATURE)) return 'jpeg'
58
+
59
+ if (bytes.length >= 6 && ascii(bytes, 0, 3) === 'GIF') {
60
+ const version = ascii(bytes, 3, 3)
61
+ if (version === '87a' || version === '89a') return 'gif'
62
+ }
63
+
64
+ if (bytes.length >= 12 && ascii(bytes, 0, 4) === 'RIFF' && ascii(bytes, 8, 4) === 'WEBP') {
65
+ return 'webp'
66
+ }
67
+
68
+ // Lottie is a JSON document. Scan past leading whitespace and a BOM until the
69
+ // first byte that is neither — a fixed lookahead would reject a file that is
70
+ // merely indented. Any binary hits a non-skippable byte almost immediately.
71
+ for (const byte of bytes) {
72
+ if (byte === 0x7b) return 'json'
73
+ const skippable =
74
+ byte === 0x20 ||
75
+ byte === 0x09 ||
76
+ byte === 0x0a ||
77
+ byte === 0x0d ||
78
+ byte === 0xef ||
79
+ byte === 0xbb ||
80
+ byte === 0xbf
81
+ if (!skippable) break
82
+ }
83
+
84
+ return null
85
+ }
86
+
87
+ /**
88
+ * A Lottie animation always carries a `layers` array. Checking that one field
89
+ * rejects an arbitrary JSON document without guessing at the rest of the
90
+ * schema, which would risk refusing animations a newer exporter produces.
91
+ *
92
+ * Returns the reason the document cannot be one, or null when it may be. The
93
+ * two faults are reported apart: a file can hold a layers array and still be
94
+ * broken, and "no layers array" would send its author looking in the wrong
95
+ * place.
96
+ */
97
+ export function lottieError(bytes: Uint8Array): string | null {
98
+ let text: string
99
+ try {
100
+ // A lenient decoder substitutes U+FFFD for a malformed byte, leaving JSON
101
+ // that parses while the file itself is not valid UTF-8.
102
+ text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
103
+ } catch {
104
+ return 'JSON file is not valid UTF-8'
105
+ }
106
+
107
+ let parsed: unknown
108
+ try {
109
+ parsed = JSON.parse(text)
110
+ } catch {
111
+ return 'JSON file is not valid JSON'
112
+ }
113
+
114
+ const hasLayers =
115
+ typeof parsed === 'object' && parsed !== null && Array.isArray((parsed as { layers?: unknown }).layers)
116
+ return hasLayers ? null : 'JSON file is not a Lottie animation (no layers array)'
117
+ }
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { validateEmojiName, validateStickerName } from './expression-names'
4
+
5
+ describe('validateEmojiName', () => {
6
+ it('accepts letters, digits and underscores', () => {
7
+ expect(validateEmojiName('potato_13')).toBeNull()
8
+ })
9
+
10
+ it('rejects a hyphen, naming the allowed characters', () => {
11
+ // A filename like potato-13.png derives this name, and Discord answers
12
+ // "Invalid Form Body" without saying which field was at fault.
13
+ expect(validateEmojiName('potato-13')).toContain('letters, digits, underscores')
14
+ })
15
+
16
+ it('rejects a space', () => {
17
+ expect(validateEmojiName('potato 13')).toContain('letters, digits, underscores')
18
+ })
19
+
20
+ it('rejects a name shorter than 2 characters', () => {
21
+ expect(validateEmojiName('a')).toContain('2')
22
+ })
23
+
24
+ it('rejects a name longer than 32 characters', () => {
25
+ expect(validateEmojiName('p'.repeat(33))).toContain('32')
26
+ })
27
+ })
28
+
29
+ describe('validateStickerName', () => {
30
+ it('accepts a two-character name', () => {
31
+ expect(validateStickerName('빼액')).toBeNull()
32
+ })
33
+
34
+ it('rejects a single character, naming the minimum', () => {
35
+ expect(validateStickerName('흥')).toContain('2')
36
+ })
37
+
38
+ it('rejects a name longer than 30 characters', () => {
39
+ expect(validateStickerName('가'.repeat(31))).toContain('30')
40
+ })
41
+ })
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Discord answers an out-of-range expression name with a bare
3
+ * "Invalid Form Body", naming neither the field nor the file. In a batch
4
+ * upload that says nothing about which one was refused, so the name — the one
5
+ * part of an expression the caller supplies directly — is checked first.
6
+ */
7
+
8
+ const EMOJI_NAME_PATTERN = /^[A-Za-z0-9_]+$/
9
+ const EMOJI_NAME_MIN = 2
10
+ const EMOJI_NAME_MAX = 32
11
+
12
+ const STICKER_NAME_MIN = 2
13
+ const STICKER_NAME_MAX = 30
14
+
15
+ export function validateEmojiName(name: string): string | null {
16
+ if (name.length < EMOJI_NAME_MIN) {
17
+ return `Emoji name must be at least ${EMOJI_NAME_MIN} characters: "${name}"`
18
+ }
19
+ if (name.length > EMOJI_NAME_MAX) {
20
+ return `Emoji name must be at most ${EMOJI_NAME_MAX} characters: "${name}"`
21
+ }
22
+ if (!EMOJI_NAME_PATTERN.test(name)) {
23
+ return `Emoji name may only contain letters, digits, underscores: "${name}"`
24
+ }
25
+ return null
26
+ }
27
+
28
+ export function validateStickerName(name: string): string | null {
29
+ if (name.length < STICKER_NAME_MIN) {
30
+ return `Sticker name must be at least ${STICKER_NAME_MIN} characters: "${name}"`
31
+ }
32
+ if (name.length > STICKER_NAME_MAX) {
33
+ return `Sticker name must be at most ${STICKER_NAME_MAX} characters: "${name}"`
34
+ }
35
+ return null
36
+ }
@@ -8,6 +8,7 @@ export type {
8
8
  DiscordConfig,
9
9
  DiscordCredentials,
10
10
  DiscordDMChannel,
11
+ DiscordEmoji,
11
12
  DiscordFile,
12
13
  DiscordGatewayChannelEvent,
13
14
  DiscordGatewayEvent,
@@ -28,6 +29,7 @@ export type {
28
29
  DiscordReadState,
29
30
  DiscordReadStateSnapshot,
30
31
  DiscordRelationship,
32
+ DiscordSticker,
31
33
  DiscordRole,
32
34
  DiscordRoleColors,
33
35
  DiscordRoleTags,
@@ -8,7 +8,14 @@ export interface DiscordGuild {
8
8
  id: string
9
9
  name: string
10
10
  icon?: string
11
+ /** Only present on GET /users/@me/guilds. */
11
12
  owner?: boolean
13
+ /** Only present on GET /guilds/{id}. */
14
+ owner_id?: string
15
+ premium_tier?: number
16
+ premium_subscription_count?: number
17
+ emojis?: DiscordEmoji[]
18
+ stickers?: DiscordSticker[]
12
19
  }
13
20
 
14
21
  export interface DiscordChannel {
@@ -116,6 +123,30 @@ export interface DiscordFile {
116
123
  width?: number
117
124
  }
118
125
 
126
+ export interface DiscordEmoji {
127
+ id: string
128
+ name: string
129
+ animated?: boolean
130
+ managed?: boolean
131
+ available?: boolean
132
+ roles?: string[]
133
+ user?: DiscordUser
134
+ }
135
+
136
+ export interface DiscordSticker {
137
+ id: string
138
+ name: string
139
+ description?: string | null
140
+ tags: string
141
+ /** STANDARD = 1, GUILD = 2. */
142
+ type: number
143
+ /** PNG = 1, APNG = 2, LOTTIE = 3, GIF = 4. */
144
+ format_type: number
145
+ available?: boolean
146
+ guild_id?: string
147
+ user?: DiscordUser
148
+ }
149
+
119
150
  export interface DiscordMention {
120
151
  id: string
121
152
  channel_id: string