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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +6 -0
- package/dist/package.json +1 -1
- package/dist/src/platforms/discord/cli.d.ts.map +1 -1
- package/dist/src/platforms/discord/cli.js +3 -1
- package/dist/src/platforms/discord/cli.js.map +1 -1
- package/dist/src/platforms/discord/client.d.ts +17 -1
- package/dist/src/platforms/discord/client.d.ts.map +1 -1
- package/dist/src/platforms/discord/client.js +46 -0
- package/dist/src/platforms/discord/client.js.map +1 -1
- package/dist/src/platforms/discord/commands/emoji.d.ts +13 -0
- package/dist/src/platforms/discord/commands/emoji.d.ts.map +1 -0
- package/dist/src/platforms/discord/commands/emoji.js +93 -0
- package/dist/src/platforms/discord/commands/emoji.js.map +1 -0
- package/dist/src/platforms/discord/commands/index.d.ts +2 -0
- package/dist/src/platforms/discord/commands/index.d.ts.map +1 -1
- package/dist/src/platforms/discord/commands/index.js +2 -0
- package/dist/src/platforms/discord/commands/index.js.map +1 -1
- package/dist/src/platforms/discord/commands/server.d.ts.map +1 -1
- package/dist/src/platforms/discord/commands/server.js +16 -1
- package/dist/src/platforms/discord/commands/server.js.map +1 -1
- package/dist/src/platforms/discord/commands/sticker.d.ts +15 -0
- package/dist/src/platforms/discord/commands/sticker.d.ts.map +1 -0
- package/dist/src/platforms/discord/commands/sticker.js +97 -0
- package/dist/src/platforms/discord/commands/sticker.js.map +1 -0
- package/dist/src/platforms/discord/expression-format.d.ts +41 -0
- package/dist/src/platforms/discord/expression-format.d.ts.map +1 -0
- package/dist/src/platforms/discord/expression-format.js +107 -0
- package/dist/src/platforms/discord/expression-format.js.map +1 -0
- package/dist/src/platforms/discord/expression-names.d.ts +9 -0
- package/dist/src/platforms/discord/expression-names.d.ts.map +1 -0
- package/dist/src/platforms/discord/expression-names.js +33 -0
- package/dist/src/platforms/discord/expression-names.js.map +1 -0
- package/dist/src/platforms/discord/index.d.ts +1 -1
- package/dist/src/platforms/discord/index.d.ts.map +1 -1
- package/dist/src/platforms/discord/index.js.map +1 -1
- package/dist/src/platforms/discord/types.d.ts +29 -0
- package/dist/src/platforms/discord/types.d.ts.map +1 -1
- package/dist/src/platforms/discord/types.js.map +1 -1
- package/dist/src/platforms/slack/cli.d.ts.map +1 -1
- package/dist/src/platforms/slack/cli.js +5 -0
- package/dist/src/platforms/slack/cli.js.map +1 -1
- package/dist/src/platforms/slack/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/slack/commands/auth.js +9 -3
- package/dist/src/platforms/slack/commands/auth.js.map +1 -1
- package/dist/src/platforms/slack/credential-manager.d.ts +2 -0
- package/dist/src/platforms/slack/credential-manager.d.ts.map +1 -1
- package/dist/src/platforms/slack/credential-manager.js +17 -3
- package/dist/src/platforms/slack/credential-manager.js.map +1 -1
- package/docs/content/docs/cli/discord.mdx +73 -0
- package/docs/content/docs/cli/slack.mdx +8 -1
- package/docs/content/docs/sdk/discord.mdx +50 -0
- package/package.json +1 -1
- package/skills/agent-channeltalk/SKILL.md +1 -1
- package/skills/agent-channeltalkbot/SKILL.md +1 -1
- package/skills/agent-discord/SKILL.md +69 -1
- package/skills/agent-discord/references/common-patterns.md +77 -0
- package/skills/agent-discordbot/SKILL.md +1 -1
- package/skills/agent-imessage/SKILL.md +1 -1
- package/skills/agent-instagram/SKILL.md +1 -1
- package/skills/agent-kakaotalk/SKILL.md +1 -1
- package/skills/agent-line/SKILL.md +1 -1
- package/skills/agent-slack/SKILL.md +9 -2
- package/skills/agent-slack/references/authentication.md +12 -0
- package/skills/agent-slack/references/common-patterns.md +18 -0
- package/skills/agent-slackbot/SKILL.md +1 -1
- package/skills/agent-teams/SKILL.md +1 -1
- package/skills/agent-telegram/SKILL.md +1 -1
- package/skills/agent-telegrambot/SKILL.md +1 -1
- package/skills/agent-webex/SKILL.md +1 -1
- package/skills/agent-webexbot/SKILL.md +1 -1
- package/skills/agent-wechatbot/SKILL.md +1 -1
- package/skills/agent-whatsapp/SKILL.md +1 -1
- package/skills/agent-whatsappbot/SKILL.md +1 -1
- package/src/platforms/discord/cli.ts +4 -0
- package/src/platforms/discord/client.test.ts +133 -0
- package/src/platforms/discord/client.ts +72 -0
- package/src/platforms/discord/commands/emoji.test.ts +122 -0
- package/src/platforms/discord/commands/emoji.ts +124 -0
- package/src/platforms/discord/commands/index.ts +2 -0
- package/src/platforms/discord/commands/server.test.ts +45 -1
- package/src/platforms/discord/commands/server.ts +21 -1
- package/src/platforms/discord/commands/sticker.test.ts +118 -0
- package/src/platforms/discord/commands/sticker.ts +135 -0
- package/src/platforms/discord/expression-format.test.ts +107 -0
- package/src/platforms/discord/expression-format.ts +117 -0
- package/src/platforms/discord/expression-names.test.ts +41 -0
- package/src/platforms/discord/expression-names.ts +36 -0
- package/src/platforms/discord/index.ts +2 -0
- package/src/platforms/discord/types.ts +31 -0
- package/src/platforms/slack/cli.test.ts +111 -1
- package/src/platforms/slack/cli.ts +5 -0
- package/src/platforms/slack/commands/auth.ts +14 -3
- package/src/platforms/slack/credential-manager.ts +21 -3
- 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
|