agent-messenger 2.32.3 → 2.34.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 +12 -1
- package/dist/package.json +1 -1
- package/dist/src/platforms/discord/client.d.ts +18 -1
- package/dist/src/platforms/discord/client.d.ts.map +1 -1
- package/dist/src/platforms/discord/client.js +56 -3
- package/dist/src/platforms/discord/client.js.map +1 -1
- package/dist/src/platforms/discord/commands/dm.d.ts +4 -0
- package/dist/src/platforms/discord/commands/dm.d.ts.map +1 -1
- package/dist/src/platforms/discord/commands/dm.js +56 -4
- package/dist/src/platforms/discord/commands/dm.js.map +1 -1
- package/dist/src/platforms/discord/commands/member.d.ts +3 -0
- package/dist/src/platforms/discord/commands/member.d.ts.map +1 -1
- package/dist/src/platforms/discord/commands/member.js +72 -1
- package/dist/src/platforms/discord/commands/member.js.map +1 -1
- 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/listener.d.ts +4 -2
- package/dist/src/platforms/discord/listener.d.ts.map +1 -1
- package/dist/src/platforms/discord/listener.js +9 -2
- package/dist/src/platforms/discord/listener.js.map +1 -1
- package/dist/src/platforms/discord/types.d.ts +48 -0
- package/dist/src/platforms/discord/types.d.ts.map +1 -1
- package/dist/src/platforms/discord/types.js +5 -1
- package/dist/src/platforms/discord/types.js.map +1 -1
- package/docs/content/docs/cli/discord.mdx +25 -0
- package/docs/content/docs/sdk/discord.mdx +21 -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 +31 -1
- 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/client.test.ts +204 -1
- package/src/platforms/discord/client.ts +85 -3
- package/src/platforms/discord/commands/dm.test.ts +126 -1
- package/src/platforms/discord/commands/dm.ts +63 -3
- package/src/platforms/discord/commands/member.test.ts +177 -0
- package/src/platforms/discord/commands/member.ts +82 -0
- package/src/platforms/discord/index.ts +6 -0
- package/src/platforms/discord/listener.test.ts +12 -3
- package/src/platforms/discord/listener.ts +17 -4
- package/src/platforms/discord/types.ts +69 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'
|
|
2
2
|
|
|
3
3
|
import { DiscordClient, DiscordError } from './client'
|
|
4
|
-
import type { DiscordReadState } from './types'
|
|
4
|
+
import type { DiscordDMChannel, DiscordReadState } from './types'
|
|
5
5
|
|
|
6
6
|
describe('DiscordClient', () => {
|
|
7
7
|
const originalFetch = globalThis.fetch
|
|
@@ -362,6 +362,59 @@ describe('DiscordClient', () => {
|
|
|
362
362
|
})
|
|
363
363
|
})
|
|
364
364
|
|
|
365
|
+
describe('getMyGuildMember', () => {
|
|
366
|
+
const member = {
|
|
367
|
+
user: { id: 'user-1', username: 'alice' },
|
|
368
|
+
roles: ['role-1'],
|
|
369
|
+
joined_at: '2024-01-15T10:00:00Z',
|
|
370
|
+
deaf: false,
|
|
371
|
+
mute: false,
|
|
372
|
+
flags: 0,
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
it('gets the current user guild member', async () => {
|
|
376
|
+
mockResponse(member)
|
|
377
|
+
|
|
378
|
+
const client = await new DiscordClient().login({ token: 'test-token' })
|
|
379
|
+
const result = await client.getMyGuildMember('guild-1')
|
|
380
|
+
|
|
381
|
+
expect(result).toEqual(member)
|
|
382
|
+
expect(fetchCalls[0].url).toBe('https://discord.com/api/v10/users/@me/guilds/guild-1/member')
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it('surfaces the error when the user is not in the guild', async () => {
|
|
386
|
+
mockResponse({ message: 'Unknown Guild', code: 10004 }, 404)
|
|
387
|
+
|
|
388
|
+
const client = await new DiscordClient().login({ token: 'test-token' })
|
|
389
|
+
|
|
390
|
+
await expect(client.getMyGuildMember('guild-1')).rejects.toEqual(new DiscordError('Unknown Guild', '10004'))
|
|
391
|
+
expect(fetchCalls).toHaveLength(1)
|
|
392
|
+
})
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
describe('listRoles', () => {
|
|
396
|
+
it('lists guild role definitions', async () => {
|
|
397
|
+
mockResponse([
|
|
398
|
+
{
|
|
399
|
+
id: 'role-1',
|
|
400
|
+
name: 'Member',
|
|
401
|
+
color: 0,
|
|
402
|
+
hoist: false,
|
|
403
|
+
position: 1,
|
|
404
|
+
permissions: '1024',
|
|
405
|
+
managed: false,
|
|
406
|
+
mentionable: false,
|
|
407
|
+
},
|
|
408
|
+
])
|
|
409
|
+
|
|
410
|
+
const client = await new DiscordClient().login({ token: 'test-token' })
|
|
411
|
+
const roles = await client.listRoles('guild-1')
|
|
412
|
+
|
|
413
|
+
expect(roles).toHaveLength(1)
|
|
414
|
+
expect(fetchCalls[0].url).toBe('https://discord.com/api/v10/guilds/guild-1/roles')
|
|
415
|
+
})
|
|
416
|
+
})
|
|
417
|
+
|
|
365
418
|
describe('uploadFile', () => {
|
|
366
419
|
it('uploads file to channel', async () => {
|
|
367
420
|
const tempFile = '/tmp/test-upload.txt'
|
|
@@ -896,4 +949,154 @@ describe('DiscordClient', () => {
|
|
|
896
949
|
expect(result.count).toBe(100)
|
|
897
950
|
})
|
|
898
951
|
})
|
|
952
|
+
|
|
953
|
+
describe('getUnreadDMs', () => {
|
|
954
|
+
const dmChannel = (id: string, lastMessageId?: string, type = 1): DiscordDMChannel => ({
|
|
955
|
+
id,
|
|
956
|
+
type,
|
|
957
|
+
recipients: [{ id: `u${id}`, username: `user${id}` }],
|
|
958
|
+
...(lastMessageId ? { last_message_id: lastMessageId } : {}),
|
|
959
|
+
})
|
|
960
|
+
|
|
961
|
+
const stub = (client: DiscordClient, channels: DiscordDMChannel[], states: DiscordReadState[], partial = false) => {
|
|
962
|
+
spyOn(client, 'listDMChannels').mockResolvedValue(channels)
|
|
963
|
+
spyOn(client, 'fetchReadStateSnapshot').mockResolvedValue({ states, partial })
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
it('keeps channels whose newest message is past the read marker', async () => {
|
|
967
|
+
// given: c1 has a message newer than its marker, c2 is fully read
|
|
968
|
+
const client = await new DiscordClient().login({ token: 'test-token' })
|
|
969
|
+
stub(
|
|
970
|
+
client,
|
|
971
|
+
[dmChannel('c1', '200'), dmChannel('c2', '50')],
|
|
972
|
+
[
|
|
973
|
+
{ channelId: 'c1', lastMessageId: '100', mentionCount: 2 },
|
|
974
|
+
{ channelId: 'c2', lastMessageId: '100', mentionCount: 0 },
|
|
975
|
+
],
|
|
976
|
+
)
|
|
977
|
+
|
|
978
|
+
const result = await client.getUnreadDMs()
|
|
979
|
+
|
|
980
|
+
expect(result.count).toBe(1)
|
|
981
|
+
expect(result.channels[0].id).toBe('c1')
|
|
982
|
+
expect(result.channels[0].unreadCount).toBe(2)
|
|
983
|
+
expect(result.totalUnread).toBe(2)
|
|
984
|
+
})
|
|
985
|
+
|
|
986
|
+
it('treats an equal snowflake as read', async () => {
|
|
987
|
+
const client = await new DiscordClient().login({ token: 'test-token' })
|
|
988
|
+
stub(client, [dmChannel('c1', '100')], [{ channelId: 'c1', lastMessageId: '100', mentionCount: 1 }])
|
|
989
|
+
|
|
990
|
+
const result = await client.getUnreadDMs()
|
|
991
|
+
|
|
992
|
+
expect(result.count).toBe(0)
|
|
993
|
+
})
|
|
994
|
+
|
|
995
|
+
it('compares snowflakes beyond the safe integer range', async () => {
|
|
996
|
+
// given: two 19-digit snowflakes that collapse to the same value as Numbers
|
|
997
|
+
const client = await new DiscordClient().login({ token: 'test-token' })
|
|
998
|
+
stub(
|
|
999
|
+
client,
|
|
1000
|
+
[dmChannel('c1', '1234567890123456789')],
|
|
1001
|
+
[{ channelId: 'c1', lastMessageId: '1234567890123456788', mentionCount: 1 }],
|
|
1002
|
+
)
|
|
1003
|
+
|
|
1004
|
+
const result = await client.getUnreadDMs()
|
|
1005
|
+
|
|
1006
|
+
expect(result.count).toBe(1)
|
|
1007
|
+
})
|
|
1008
|
+
|
|
1009
|
+
it('surfaces a channel with no read-state entry as an unknown count', async () => {
|
|
1010
|
+
const client = await new DiscordClient().login({ token: 'test-token' })
|
|
1011
|
+
stub(client, [dmChannel('c1', '200')], [])
|
|
1012
|
+
|
|
1013
|
+
const result = await client.getUnreadDMs()
|
|
1014
|
+
|
|
1015
|
+
expect(result.count).toBe(1)
|
|
1016
|
+
expect(result.channels[0].unreadCount).toBeNull()
|
|
1017
|
+
// then: an unknown count must not be summed as zero-or-anything-else
|
|
1018
|
+
expect(result.totalUnread).toBe(0)
|
|
1019
|
+
expect(result.complete).toBe(true)
|
|
1020
|
+
})
|
|
1021
|
+
|
|
1022
|
+
it('omits absent entries when the read state is partial', async () => {
|
|
1023
|
+
// given: an incomplete collection, where absence means "not delivered", not "never read"
|
|
1024
|
+
const client = await new DiscordClient().login({ token: 'test-token' })
|
|
1025
|
+
stub(client, [dmChannel('c1', '200')], [], true)
|
|
1026
|
+
|
|
1027
|
+
const result = await client.getUnreadDMs()
|
|
1028
|
+
|
|
1029
|
+
expect(result.count).toBe(0)
|
|
1030
|
+
expect(result.complete).toBe(false)
|
|
1031
|
+
})
|
|
1032
|
+
|
|
1033
|
+
it('still reports channels past their marker when the read state is partial', async () => {
|
|
1034
|
+
// given: a delivered entry is authoritative even inside a partial collection
|
|
1035
|
+
const client = await new DiscordClient().login({ token: 'test-token' })
|
|
1036
|
+
stub(client, [dmChannel('c1', '200')], [{ channelId: 'c1', lastMessageId: '100', mentionCount: 2 }], true)
|
|
1037
|
+
|
|
1038
|
+
const result = await client.getUnreadDMs()
|
|
1039
|
+
|
|
1040
|
+
expect(result.count).toBe(1)
|
|
1041
|
+
expect(result.channels[0].unreadCount).toBe(2)
|
|
1042
|
+
expect(result.complete).toBe(false)
|
|
1043
|
+
})
|
|
1044
|
+
|
|
1045
|
+
it('skips channels that never had a message', async () => {
|
|
1046
|
+
const client = await new DiscordClient().login({ token: 'test-token' })
|
|
1047
|
+
stub(client, [dmChannel('c1')], [])
|
|
1048
|
+
|
|
1049
|
+
const result = await client.getUnreadDMs()
|
|
1050
|
+
|
|
1051
|
+
expect(result.count).toBe(0)
|
|
1052
|
+
})
|
|
1053
|
+
|
|
1054
|
+
it('keeps a muted but unread channel with a zero count', async () => {
|
|
1055
|
+
const client = await new DiscordClient().login({ token: 'test-token' })
|
|
1056
|
+
stub(client, [dmChannel('c1', '200')], [{ channelId: 'c1', lastMessageId: '100', mentionCount: 0 }])
|
|
1057
|
+
|
|
1058
|
+
const result = await client.getUnreadDMs()
|
|
1059
|
+
|
|
1060
|
+
expect(result.count).toBe(1)
|
|
1061
|
+
expect(result.channels[0].unreadCount).toBe(0)
|
|
1062
|
+
})
|
|
1063
|
+
|
|
1064
|
+
it('treats a null read marker as never acknowledged', async () => {
|
|
1065
|
+
const client = await new DiscordClient().login({ token: 'test-token' })
|
|
1066
|
+
stub(client, [dmChannel('c1', '200')], [{ channelId: 'c1', lastMessageId: null, mentionCount: 4 }])
|
|
1067
|
+
|
|
1068
|
+
const result = await client.getUnreadDMs()
|
|
1069
|
+
|
|
1070
|
+
expect(result.count).toBe(1)
|
|
1071
|
+
expect(result.channels[0].unreadCount).toBe(4)
|
|
1072
|
+
})
|
|
1073
|
+
|
|
1074
|
+
it('includes group DMs and orders channels newest first', async () => {
|
|
1075
|
+
const client = await new DiscordClient().login({ token: 'test-token' })
|
|
1076
|
+
stub(client, [dmChannel('c1', '200'), dmChannel('c2', '900', 3)], [])
|
|
1077
|
+
|
|
1078
|
+
const result = await client.getUnreadDMs()
|
|
1079
|
+
|
|
1080
|
+
expect(result.channels.map((channel) => channel.id)).toEqual(['c2', 'c1'])
|
|
1081
|
+
expect(result.channels[0].type).toBe(3)
|
|
1082
|
+
})
|
|
1083
|
+
|
|
1084
|
+
it('caps returned channels by limit while keeping totals unfiltered', async () => {
|
|
1085
|
+
const client = await new DiscordClient().login({ token: 'test-token' })
|
|
1086
|
+
stub(
|
|
1087
|
+
client,
|
|
1088
|
+
[dmChannel('c1', '200'), dmChannel('c2', '900')],
|
|
1089
|
+
[
|
|
1090
|
+
{ channelId: 'c1', lastMessageId: '100', mentionCount: 2 },
|
|
1091
|
+
{ channelId: 'c2', lastMessageId: '100', mentionCount: 5 },
|
|
1092
|
+
],
|
|
1093
|
+
)
|
|
1094
|
+
|
|
1095
|
+
const result = await client.getUnreadDMs({ limit: 1 })
|
|
1096
|
+
|
|
1097
|
+
expect(result.channels).toHaveLength(1)
|
|
1098
|
+
expect(result.count).toBe(2)
|
|
1099
|
+
expect(result.totalUnread).toBe(7)
|
|
1100
|
+
})
|
|
1101
|
+
})
|
|
899
1102
|
})
|
|
@@ -11,9 +11,13 @@ import type {
|
|
|
11
11
|
DiscordMention,
|
|
12
12
|
DiscordMessage,
|
|
13
13
|
DiscordReadState,
|
|
14
|
+
DiscordReadStateSnapshot,
|
|
14
15
|
DiscordRelationship,
|
|
16
|
+
DiscordRole,
|
|
15
17
|
DiscordSearchOptions,
|
|
16
18
|
DiscordSearchResult,
|
|
19
|
+
DiscordUnreadDM,
|
|
20
|
+
DiscordUnreadDMsResult,
|
|
17
21
|
DiscordUnreadMention,
|
|
18
22
|
DiscordUnreadMentionsResult,
|
|
19
23
|
DiscordUser,
|
|
@@ -33,6 +37,47 @@ function isSnowflakeNewer(candidate: string, marker: string): boolean {
|
|
|
33
37
|
}
|
|
34
38
|
}
|
|
35
39
|
|
|
40
|
+
interface ReadStateCandidateChannel {
|
|
41
|
+
id: string
|
|
42
|
+
last_message_id?: string | null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface UnreadChannel<T> {
|
|
46
|
+
channel: T
|
|
47
|
+
lastMessageId: string
|
|
48
|
+
unreadCount: number | null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function selectUnreadChannels<T extends ReadStateCandidateChannel>(
|
|
52
|
+
channels: T[],
|
|
53
|
+
readState: DiscordReadStateSnapshot,
|
|
54
|
+
): UnreadChannel<T>[] {
|
|
55
|
+
const readStateByChannel = new Map(readState.states.map((state) => [state.channelId, state]))
|
|
56
|
+
|
|
57
|
+
const unread: UnreadChannel<T>[] = []
|
|
58
|
+
for (const channel of channels) {
|
|
59
|
+
const lastMessageId = channel.last_message_id
|
|
60
|
+
if (!lastMessageId) continue
|
|
61
|
+
|
|
62
|
+
const state = readStateByChannel.get(channel.id)
|
|
63
|
+
// An absent entry only carries meaning when the collection is complete. In a
|
|
64
|
+
// complete payload Discord omits read states for channels it never tracked, so a
|
|
65
|
+
// channel holding messages with no marker is far more likely never-opened than
|
|
66
|
+
// read: surface it with an unknown count rather than silently hiding it. In a
|
|
67
|
+
// partial payload the entry may simply have been left out, so reporting it would
|
|
68
|
+
// be a false positive on an already-read channel.
|
|
69
|
+
if (!state) {
|
|
70
|
+
if (!readState.partial) unread.push({ channel, lastMessageId, unreadCount: null })
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (state.lastMessageId !== null && !isSnowflakeNewer(lastMessageId, state.lastMessageId)) continue
|
|
75
|
+
unread.push({ channel, lastMessageId, unreadCount: state.mentionCount })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return unread.sort((a, b) => (isSnowflakeNewer(a.lastMessageId, b.lastMessageId) ? -1 : 1))
|
|
79
|
+
}
|
|
80
|
+
|
|
36
81
|
export class DiscordError extends Error {
|
|
37
82
|
code: string
|
|
38
83
|
|
|
@@ -349,6 +394,31 @@ export class DiscordClient {
|
|
|
349
394
|
})
|
|
350
395
|
}
|
|
351
396
|
|
|
397
|
+
async getUnreadDMs(options?: { limit?: number }): Promise<DiscordUnreadDMsResult> {
|
|
398
|
+
const [readState, channels] = await Promise.all([this.fetchReadStateSnapshot(), this.listDMChannels()])
|
|
399
|
+
|
|
400
|
+
const unread = selectUnreadChannels(channels, readState).map(
|
|
401
|
+
({ channel, lastMessageId, unreadCount }): DiscordUnreadDM => ({
|
|
402
|
+
id: channel.id,
|
|
403
|
+
type: channel.type,
|
|
404
|
+
name: channel.name ?? null,
|
|
405
|
+
recipients: channel.recipients,
|
|
406
|
+
lastMessageId,
|
|
407
|
+
unreadCount,
|
|
408
|
+
}),
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
const totalUnread = unread.reduce((sum, channel) => sum + (channel.unreadCount ?? 0), 0)
|
|
412
|
+
const limited = options?.limit === undefined ? unread : unread.slice(0, options.limit)
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
channels: limited,
|
|
416
|
+
count: unread.length,
|
|
417
|
+
totalUnread,
|
|
418
|
+
complete: !readState.partial,
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
352
422
|
async getMentions(options?: { limit?: number; guildId?: string; before?: string }): Promise<DiscordMention[]> {
|
|
353
423
|
const params = new URLSearchParams()
|
|
354
424
|
params.set('limit', (options?.limit ?? 25).toString())
|
|
@@ -366,15 +436,19 @@ export class DiscordClient {
|
|
|
366
436
|
}
|
|
367
437
|
|
|
368
438
|
async fetchReadState(): Promise<DiscordReadState[]> {
|
|
439
|
+
return (await this.fetchReadStateSnapshot()).states
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async fetchReadStateSnapshot(): Promise<DiscordReadStateSnapshot> {
|
|
369
443
|
const { DiscordListener } = await import('./listener')
|
|
370
444
|
const listener = new DiscordListener(this)
|
|
371
445
|
try {
|
|
372
446
|
await listener.start()
|
|
373
|
-
const
|
|
374
|
-
if (
|
|
447
|
+
const snapshot = listener.getReadStateSnapshot()
|
|
448
|
+
if (snapshot === null) {
|
|
375
449
|
throw new DiscordError('Discord gateway did not deliver read state', 'no_read_state')
|
|
376
450
|
}
|
|
377
|
-
return
|
|
451
|
+
return snapshot
|
|
378
452
|
} finally {
|
|
379
453
|
listener.stop()
|
|
380
454
|
}
|
|
@@ -481,6 +555,14 @@ export class DiscordClient {
|
|
|
481
555
|
return this.request<DiscordGuildMember[]>('GET', `/guilds/${guildId}/members/search?${params.toString()}`)
|
|
482
556
|
}
|
|
483
557
|
|
|
558
|
+
async getMyGuildMember(guildId: string): Promise<DiscordGuildMember> {
|
|
559
|
+
return this.request<DiscordGuildMember>('GET', `/users/@me/guilds/${guildId}/member`)
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async listRoles(guildId: string): Promise<DiscordRole[]> {
|
|
563
|
+
return this.request<DiscordRole[]>('GET', `/guilds/${guildId}/roles`)
|
|
564
|
+
}
|
|
565
|
+
|
|
484
566
|
async searchMessages(
|
|
485
567
|
guildId: string,
|
|
486
568
|
query: string,
|
|
@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:te
|
|
|
3
3
|
import { DiscordClient } from '../client'
|
|
4
4
|
import { DiscordCredentialManager } from '../credential-manager'
|
|
5
5
|
import type { DiscordDMChannel } from '../types'
|
|
6
|
-
import { createAction, listAction } from './dm'
|
|
6
|
+
import { createAction, listAction, unreadAction } from './dm'
|
|
7
7
|
|
|
8
8
|
class ProcessExit extends Error {
|
|
9
9
|
constructor(readonly code?: string | number | null) {
|
|
@@ -13,6 +13,7 @@ class ProcessExit extends Error {
|
|
|
13
13
|
|
|
14
14
|
let clientListDMChannelsSpy: ReturnType<typeof spyOn>
|
|
15
15
|
let clientCreateDMSpy: ReturnType<typeof spyOn>
|
|
16
|
+
let clientGetUnreadDMsSpy: ReturnType<typeof spyOn>
|
|
16
17
|
let credManagerLoadSpy: ReturnType<typeof spyOn>
|
|
17
18
|
|
|
18
19
|
const mockChannels: DiscordDMChannel[] = [
|
|
@@ -57,6 +58,30 @@ beforeEach(() => {
|
|
|
57
58
|
],
|
|
58
59
|
})
|
|
59
60
|
|
|
61
|
+
clientGetUnreadDMsSpy = spyOn(DiscordClient.prototype, 'getUnreadDMs').mockResolvedValue({
|
|
62
|
+
channels: [
|
|
63
|
+
{
|
|
64
|
+
id: '123',
|
|
65
|
+
type: 1,
|
|
66
|
+
name: null,
|
|
67
|
+
recipients: [{ id: '456', username: 'testuser' }],
|
|
68
|
+
lastMessageId: '900',
|
|
69
|
+
unreadCount: 3,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: '789',
|
|
73
|
+
type: 3,
|
|
74
|
+
name: 'Group Chat',
|
|
75
|
+
recipients: [{ id: '111', username: 'user1' }],
|
|
76
|
+
lastMessageId: '800',
|
|
77
|
+
unreadCount: null,
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
count: 2,
|
|
81
|
+
totalUnread: 3,
|
|
82
|
+
complete: true,
|
|
83
|
+
})
|
|
84
|
+
|
|
60
85
|
credManagerLoadSpy = spyOn(DiscordCredentialManager.prototype, 'load').mockResolvedValue({
|
|
61
86
|
token: 'test-token',
|
|
62
87
|
current_server: null,
|
|
@@ -67,6 +92,7 @@ beforeEach(() => {
|
|
|
67
92
|
afterEach(() => {
|
|
68
93
|
clientListDMChannelsSpy?.mockRestore()
|
|
69
94
|
clientCreateDMSpy?.mockRestore()
|
|
95
|
+
clientGetUnreadDMsSpy?.mockRestore()
|
|
70
96
|
credManagerLoadSpy?.mockRestore()
|
|
71
97
|
})
|
|
72
98
|
|
|
@@ -150,4 +176,103 @@ describe('dm commands', () => {
|
|
|
150
176
|
}
|
|
151
177
|
})
|
|
152
178
|
})
|
|
179
|
+
|
|
180
|
+
describe('unreadAction', () => {
|
|
181
|
+
it('reports unread channels with labelled types and totals', async () => {
|
|
182
|
+
const consoleSpy = mock(() => {})
|
|
183
|
+
const originalLog = console.log
|
|
184
|
+
console.log = consoleSpy
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
await unreadAction({ pretty: false })
|
|
188
|
+
} finally {
|
|
189
|
+
console.log = originalLog
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const payload = JSON.parse(consoleSpy.mock.calls[0][0] as unknown as string)
|
|
193
|
+
expect(payload.count).toBe(2)
|
|
194
|
+
expect(payload.total_unread).toBe(3)
|
|
195
|
+
expect(payload.complete).toBe(true)
|
|
196
|
+
expect(payload.channels[0]).toEqual({
|
|
197
|
+
id: '123',
|
|
198
|
+
type: 'DM',
|
|
199
|
+
name: null,
|
|
200
|
+
recipients: [{ id: '456', username: 'testuser' }],
|
|
201
|
+
last_message_id: '900',
|
|
202
|
+
unread_count: 3,
|
|
203
|
+
})
|
|
204
|
+
expect(payload.channels[1].type).toBe('Group DM')
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('preserves an unknown unread count as null', async () => {
|
|
208
|
+
const consoleSpy = mock(() => {})
|
|
209
|
+
const originalLog = console.log
|
|
210
|
+
console.log = consoleSpy
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
await unreadAction({ pretty: false })
|
|
214
|
+
} finally {
|
|
215
|
+
console.log = originalLog
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const payload = JSON.parse(consoleSpy.mock.calls[0][0] as unknown as string)
|
|
219
|
+
expect(payload.channels[1].unread_count).toBeNull()
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('surfaces an incomplete read state as complete false', async () => {
|
|
223
|
+
clientGetUnreadDMsSpy.mockResolvedValue({ channels: [], count: 0, totalUnread: 0, complete: false })
|
|
224
|
+
|
|
225
|
+
const consoleSpy = mock(() => {})
|
|
226
|
+
const originalLog = console.log
|
|
227
|
+
console.log = consoleSpy
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
await unreadAction({ pretty: false })
|
|
231
|
+
} finally {
|
|
232
|
+
console.log = originalLog
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const payload = JSON.parse(consoleSpy.mock.calls[0][0] as unknown as string)
|
|
236
|
+
expect(payload.complete).toBe(false)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('forwards the limit to the client', async () => {
|
|
240
|
+
const consoleSpy = mock(() => {})
|
|
241
|
+
const originalLog = console.log
|
|
242
|
+
console.log = consoleSpy
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
await unreadAction({ limit: 5, pretty: false })
|
|
246
|
+
} finally {
|
|
247
|
+
console.log = originalLog
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
expect(clientGetUnreadDMsSpy).toHaveBeenCalledWith({ limit: 5 })
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('handles authentication error', async () => {
|
|
254
|
+
credManagerLoadSpy.mockResolvedValue({
|
|
255
|
+
token: '',
|
|
256
|
+
current_server: null,
|
|
257
|
+
servers: {},
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
const exitSpy = spyOn(process, 'exit').mockImplementation((code?: string | number | null) => {
|
|
261
|
+
throw new ProcessExit(code)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
const consoleSpy = mock(() => {})
|
|
265
|
+
const originalLog = console.log
|
|
266
|
+
console.log = consoleSpy
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
await expect(unreadAction({ pretty: false })).rejects.toThrow(ProcessExit)
|
|
270
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Not authenticated'))
|
|
271
|
+
expect(exitSpy).toHaveBeenCalledWith(1)
|
|
272
|
+
} finally {
|
|
273
|
+
console.log = originalLog
|
|
274
|
+
exitSpy.mockRestore()
|
|
275
|
+
}
|
|
276
|
+
})
|
|
277
|
+
})
|
|
153
278
|
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Command } from 'commander'
|
|
1
|
+
import { Command, InvalidArgumentError } from 'commander'
|
|
2
2
|
|
|
3
3
|
import { handleError } from '@/shared/utils/error-handler'
|
|
4
4
|
import { formatOutput } from '@/shared/utils/output'
|
|
@@ -7,6 +7,18 @@ import { DiscordClient } from '../client'
|
|
|
7
7
|
import { DiscordCredentialManager } from '../credential-manager'
|
|
8
8
|
import type { DiscordDMChannel } from '../types'
|
|
9
9
|
|
|
10
|
+
function parseLimit(value: string): number {
|
|
11
|
+
const parsed = Number(value)
|
|
12
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
13
|
+
throw new InvalidArgumentError('Must be a positive integer.')
|
|
14
|
+
}
|
|
15
|
+
return parsed
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function channelTypeLabel(type: number): string {
|
|
19
|
+
return type === 1 ? 'DM' : 'Group DM'
|
|
20
|
+
}
|
|
21
|
+
|
|
10
22
|
export async function listAction(options: { pretty?: boolean }): Promise<void> {
|
|
11
23
|
try {
|
|
12
24
|
const credManager = new DiscordCredentialManager()
|
|
@@ -22,7 +34,7 @@ export async function listAction(options: { pretty?: boolean }): Promise<void> {
|
|
|
22
34
|
|
|
23
35
|
const output = channels.map((channel: DiscordDMChannel) => ({
|
|
24
36
|
id: channel.id,
|
|
25
|
-
type: channel.type
|
|
37
|
+
type: channelTypeLabel(channel.type),
|
|
26
38
|
name: channel.name || null,
|
|
27
39
|
recipients: channel.recipients.map((user) => ({
|
|
28
40
|
id: user.id,
|
|
@@ -52,7 +64,7 @@ export async function createAction(userId: string, options: { pretty?: boolean }
|
|
|
52
64
|
|
|
53
65
|
const output = {
|
|
54
66
|
id: channel.id,
|
|
55
|
-
type: channel.type
|
|
67
|
+
type: channelTypeLabel(channel.type),
|
|
56
68
|
name: channel.name || null,
|
|
57
69
|
recipients: channel.recipients.map((user) => ({
|
|
58
70
|
id: user.id,
|
|
@@ -66,6 +78,42 @@ export async function createAction(userId: string, options: { pretty?: boolean }
|
|
|
66
78
|
}
|
|
67
79
|
}
|
|
68
80
|
|
|
81
|
+
export async function unreadAction(options: { limit?: number; pretty?: boolean }): Promise<void> {
|
|
82
|
+
try {
|
|
83
|
+
const credManager = new DiscordCredentialManager()
|
|
84
|
+
const config = await credManager.load()
|
|
85
|
+
|
|
86
|
+
if (!config.token) {
|
|
87
|
+
console.log(formatOutput({ error: 'Not authenticated. Run "auth extract" first.' }, options.pretty))
|
|
88
|
+
process.exit(1)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const client = await new DiscordClient().login({ token: config.token })
|
|
92
|
+
const result = await client.getUnreadDMs({ limit: options.limit })
|
|
93
|
+
|
|
94
|
+
const output = {
|
|
95
|
+
channels: result.channels.map((channel) => ({
|
|
96
|
+
id: channel.id,
|
|
97
|
+
type: channelTypeLabel(channel.type),
|
|
98
|
+
name: channel.name,
|
|
99
|
+
recipients: channel.recipients.map((user) => ({
|
|
100
|
+
id: user.id,
|
|
101
|
+
username: user.username,
|
|
102
|
+
})),
|
|
103
|
+
last_message_id: channel.lastMessageId,
|
|
104
|
+
unread_count: channel.unreadCount,
|
|
105
|
+
})),
|
|
106
|
+
count: result.count,
|
|
107
|
+
total_unread: result.totalUnread,
|
|
108
|
+
complete: result.complete,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
console.log(formatOutput(output, options.pretty))
|
|
112
|
+
} catch (error) {
|
|
113
|
+
handleError(error as Error)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
69
117
|
export const dmCommand = new Command('dm')
|
|
70
118
|
.description('DM channel commands')
|
|
71
119
|
.addCommand(
|
|
@@ -81,3 +129,15 @@ export const dmCommand = new Command('dm')
|
|
|
81
129
|
.option('--pretty', 'Pretty print JSON output')
|
|
82
130
|
.action(createAction),
|
|
83
131
|
)
|
|
132
|
+
.addCommand(
|
|
133
|
+
new Command('unread')
|
|
134
|
+
.description('List unread DM and group-DM channels, newest first')
|
|
135
|
+
.option('--limit <n>', 'Max number of channels to return', parseLimit)
|
|
136
|
+
.option('--pretty', 'Pretty print JSON output')
|
|
137
|
+
.action((options) => {
|
|
138
|
+
unreadAction({
|
|
139
|
+
limit: options.limit,
|
|
140
|
+
pretty: options.pretty,
|
|
141
|
+
})
|
|
142
|
+
}),
|
|
143
|
+
)
|