agent-messenger 2.37.1 → 2.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/kakaotalk/client.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/client.js +15 -2
- package/dist/src/platforms/kakaotalk/client.js.map +1 -1
- package/docs/content/docs/cli/discord.mdx +73 -0
- 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 +1 -1
- 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/kakaotalk/client.test.ts +66 -0
- package/src/platforms/kakaotalk/client.ts +14 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: agent-webexbot
|
|
3
3
|
description: Interact with Cisco Webex using bot tokens - send messages, reply in threads, upload and download files, look up people, read spaces, manage memberships, stream real-time events
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.38.0
|
|
5
5
|
allowed-tools: Bash(agent-webexbot:*)
|
|
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
|
+
)
|