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.
Files changed (57) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +12 -1
  3. package/dist/package.json +1 -1
  4. package/dist/src/platforms/discord/client.d.ts +18 -1
  5. package/dist/src/platforms/discord/client.d.ts.map +1 -1
  6. package/dist/src/platforms/discord/client.js +56 -3
  7. package/dist/src/platforms/discord/client.js.map +1 -1
  8. package/dist/src/platforms/discord/commands/dm.d.ts +4 -0
  9. package/dist/src/platforms/discord/commands/dm.d.ts.map +1 -1
  10. package/dist/src/platforms/discord/commands/dm.js +56 -4
  11. package/dist/src/platforms/discord/commands/dm.js.map +1 -1
  12. package/dist/src/platforms/discord/commands/member.d.ts +3 -0
  13. package/dist/src/platforms/discord/commands/member.d.ts.map +1 -1
  14. package/dist/src/platforms/discord/commands/member.js +72 -1
  15. package/dist/src/platforms/discord/commands/member.js.map +1 -1
  16. package/dist/src/platforms/discord/index.d.ts +1 -1
  17. package/dist/src/platforms/discord/index.d.ts.map +1 -1
  18. package/dist/src/platforms/discord/index.js.map +1 -1
  19. package/dist/src/platforms/discord/listener.d.ts +4 -2
  20. package/dist/src/platforms/discord/listener.d.ts.map +1 -1
  21. package/dist/src/platforms/discord/listener.js +9 -2
  22. package/dist/src/platforms/discord/listener.js.map +1 -1
  23. package/dist/src/platforms/discord/types.d.ts +48 -0
  24. package/dist/src/platforms/discord/types.d.ts.map +1 -1
  25. package/dist/src/platforms/discord/types.js +5 -1
  26. package/dist/src/platforms/discord/types.js.map +1 -1
  27. package/docs/content/docs/cli/discord.mdx +25 -0
  28. package/docs/content/docs/sdk/discord.mdx +21 -0
  29. package/package.json +1 -1
  30. package/skills/agent-channeltalk/SKILL.md +1 -1
  31. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  32. package/skills/agent-discord/SKILL.md +31 -1
  33. package/skills/agent-discordbot/SKILL.md +1 -1
  34. package/skills/agent-imessage/SKILL.md +1 -1
  35. package/skills/agent-instagram/SKILL.md +1 -1
  36. package/skills/agent-kakaotalk/SKILL.md +1 -1
  37. package/skills/agent-line/SKILL.md +1 -1
  38. package/skills/agent-slack/SKILL.md +1 -1
  39. package/skills/agent-slackbot/SKILL.md +1 -1
  40. package/skills/agent-teams/SKILL.md +1 -1
  41. package/skills/agent-telegram/SKILL.md +1 -1
  42. package/skills/agent-telegrambot/SKILL.md +1 -1
  43. package/skills/agent-webex/SKILL.md +1 -1
  44. package/skills/agent-webexbot/SKILL.md +1 -1
  45. package/skills/agent-wechatbot/SKILL.md +1 -1
  46. package/skills/agent-whatsapp/SKILL.md +1 -1
  47. package/skills/agent-whatsappbot/SKILL.md +1 -1
  48. package/src/platforms/discord/client.test.ts +204 -1
  49. package/src/platforms/discord/client.ts +85 -3
  50. package/src/platforms/discord/commands/dm.test.ts +126 -1
  51. package/src/platforms/discord/commands/dm.ts +63 -3
  52. package/src/platforms/discord/commands/member.test.ts +177 -0
  53. package/src/platforms/discord/commands/member.ts +82 -0
  54. package/src/platforms/discord/index.ts +6 -0
  55. package/src/platforms/discord/listener.test.ts +12 -3
  56. package/src/platforms/discord/listener.ts +17 -4
  57. 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 readState = listener.getReadState()
374
- if (readState === null) {
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 readState
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 === 1 ? 'DM' : 'Group DM',
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 === 1 ? 'DM' : 'Group DM',
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
+ )