agent-messenger 2.31.0 → 2.32.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 (58) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +14 -1
  3. package/dist/package.json +1 -1
  4. package/dist/src/platforms/discord/client.d.ts +11 -2
  5. package/dist/src/platforms/discord/client.d.ts.map +1 -1
  6. package/dist/src/platforms/discord/client.js +109 -4
  7. package/dist/src/platforms/discord/client.js.map +1 -1
  8. package/dist/src/platforms/discord/commands/mention.d.ts +5 -0
  9. package/dist/src/platforms/discord/commands/mention.d.ts.map +1 -1
  10. package/dist/src/platforms/discord/commands/mention.js +60 -4
  11. package/dist/src/platforms/discord/commands/mention.js.map +1 -1
  12. package/dist/src/platforms/discord/commands/reaction.js +1 -1
  13. package/dist/src/platforms/discord/commands/reaction.js.map +1 -1
  14. package/dist/src/platforms/discord/index.d.ts +1 -1
  15. package/dist/src/platforms/discord/index.d.ts.map +1 -1
  16. package/dist/src/platforms/discord/index.js.map +1 -1
  17. package/dist/src/platforms/discord/listener.d.ts +4 -1
  18. package/dist/src/platforms/discord/listener.d.ts.map +1 -1
  19. package/dist/src/platforms/discord/listener.js +28 -1
  20. package/dist/src/platforms/discord/listener.js.map +1 -1
  21. package/dist/src/platforms/discord/types.d.ts +43 -9
  22. package/dist/src/platforms/discord/types.d.ts.map +1 -1
  23. package/dist/src/platforms/discord/types.js +22 -7
  24. package/dist/src/platforms/discord/types.js.map +1 -1
  25. package/docs/content/docs/cli/discord.mdx +7 -0
  26. package/docs/content/docs/sdk/discord.mdx +38 -0
  27. package/e2e/README.md +2 -2
  28. package/e2e/discord.e2e.test.ts +16 -2
  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 +19 -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 +230 -9
  49. package/src/platforms/discord/client.ts +122 -5
  50. package/src/platforms/discord/commands/mention.test.ts +84 -0
  51. package/src/platforms/discord/commands/mention.ts +78 -15
  52. package/src/platforms/discord/commands/reaction.test.ts +1 -1
  53. package/src/platforms/discord/commands/reaction.ts +1 -1
  54. package/src/platforms/discord/index.ts +3 -0
  55. package/src/platforms/discord/listener.test.ts +69 -2
  56. package/src/platforms/discord/listener.ts +31 -2
  57. package/src/platforms/discord/types.test.ts +11 -0
  58. package/src/platforms/discord/types.ts +50 -10
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-whatsappbot
3
3
  description: Interact with WhatsApp using Cloud API credentials - send messages, manage templates
4
- version: 2.31.0
4
+ version: 2.32.0
5
5
  allowed-tools: Bash(agent-whatsappbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,6 +1,7 @@
1
- import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
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
5
 
5
6
  describe('DiscordClient', () => {
6
7
  const originalFetch = globalThis.fetch
@@ -226,23 +227,50 @@ describe('DiscordClient', () => {
226
227
 
227
228
  expect(fetchCalls[0].url).toBe('https://discord.com/api/v10/channels/ch1/messages?limit=50')
228
229
  })
230
+
231
+ it('gets messages around a specific message', async () => {
232
+ mockResponse([])
233
+
234
+ const client = await new DiscordClient().login({ token: 'test-token' })
235
+ await client.getMessages('ch1', 1, { around: 'msg/1' })
236
+
237
+ expect(fetchCalls[0].url).toBe('https://discord.com/api/v10/channels/ch1/messages?around=msg%2F1&limit=1')
238
+ })
229
239
  })
230
240
 
231
241
  describe('getMessage', () => {
232
242
  it('returns single message', async () => {
233
- mockResponse({
234
- id: 'msg1',
235
- channel_id: 'ch1',
236
- author: { id: '123', username: 'user1' },
237
- content: 'Message 1',
238
- timestamp: '2024-01-01T00:00:00.000Z',
239
- })
243
+ mockResponse([
244
+ {
245
+ id: 'msg1',
246
+ channel_id: 'ch1',
247
+ author: { id: '123', username: 'user1' },
248
+ content: 'Message 1',
249
+ timestamp: '2024-01-01T00:00:00.000Z',
250
+ },
251
+ ])
240
252
 
241
253
  const client = await new DiscordClient().login({ token: 'test-token' })
242
254
  const message = await client.getMessage('ch1', 'msg1')
243
255
 
244
256
  expect(message.id).toBe('msg1')
245
- expect(fetchCalls[0].url).toBe('https://discord.com/api/v10/channels/ch1/messages/msg1')
257
+ expect(fetchCalls[0].url).toBe('https://discord.com/api/v10/channels/ch1/messages?around=msg1&limit=1')
258
+ })
259
+
260
+ it('throws when the requested message is not returned', async () => {
261
+ mockResponse([
262
+ {
263
+ id: 'neighbor',
264
+ channel_id: 'ch1',
265
+ author: { id: '123', username: 'user1' },
266
+ content: 'Nearby message',
267
+ timestamp: '2024-01-01T00:00:00.000Z',
268
+ },
269
+ ])
270
+
271
+ const client = await new DiscordClient().login({ token: 'test-token' })
272
+
273
+ await expect(client.getMessage('ch1', 'missing')).rejects.toEqual(new DiscordError('Message not found', '10008'))
246
274
  })
247
275
  })
248
276
 
@@ -674,5 +702,198 @@ describe('DiscordClient', () => {
674
702
 
675
703
  expect(fetchCalls.length).toBe(2)
676
704
  })
705
+
706
+ it('normalizes query strings for the same channel route', async () => {
707
+ mockResponse([], 200, {
708
+ 'X-RateLimit-Remaining': '0',
709
+ 'X-RateLimit-Reset': String(Date.now() / 1000 + 0.1),
710
+ 'X-RateLimit-Bucket': 'messages-bucket',
711
+ })
712
+ mockResponse([])
713
+
714
+ const client = await new DiscordClient().login({ token: 'test-token' })
715
+ await client.getMessages('123456789', 1, { around: 'message-1' })
716
+
717
+ const startTime = Date.now()
718
+ await client.getMessages('123456789', 1, { around: 'message-2' })
719
+ const elapsed = Date.now() - startTime
720
+
721
+ expect(elapsed).toBeGreaterThanOrEqual(50)
722
+ expect(fetchCalls.length).toBe(2)
723
+ })
724
+ })
725
+
726
+ describe('getUnreadMentions', () => {
727
+ const mention = (id: string, channelId: string, guildId?: string) => ({
728
+ id,
729
+ channel_id: channelId,
730
+ author: { id: 'a1', username: 'alice' },
731
+ content: `mention ${id}`,
732
+ timestamp: '2024-01-29T10:00:00Z',
733
+ mention_everyone: false,
734
+ mentions: [{ id: 'me', username: 'me' }],
735
+ ...(guildId ? { guild_id: guildId } : {}),
736
+ })
737
+
738
+ const stubReadState = (client: DiscordClient, readState: DiscordReadState[]) =>
739
+ spyOn(client, 'fetchReadState').mockResolvedValue(readState)
740
+
741
+ it('keeps mentions newer than the channel read marker', async () => {
742
+ // given: one channel with an unread mention (200 > marker 100) and one already read (50 < 100)
743
+ const client = await new DiscordClient().login({ token: 'test-token' })
744
+ stubReadState(client, [{ channelId: 'c1', lastMessageId: '100', mentionCount: 1 }])
745
+ mockResponse([mention('200', 'c1'), mention('50', 'c1')])
746
+
747
+ const result = await client.getUnreadMentions()
748
+
749
+ // then: only the newer message is unread
750
+ expect(result.count).toBe(1)
751
+ expect(result.mentions[0].id).toBe('200')
752
+ expect(result.mentions[0].mention_count).toBe(1)
753
+ })
754
+
755
+ it('treats an equal snowflake as read', async () => {
756
+ const client = await new DiscordClient().login({ token: 'test-token' })
757
+ stubReadState(client, [{ channelId: 'c1', lastMessageId: '100', mentionCount: 1 }])
758
+ mockResponse([mention('100', 'c1')])
759
+
760
+ const result = await client.getUnreadMentions()
761
+
762
+ expect(result.count).toBe(0)
763
+ })
764
+
765
+ it('omits mentions for channels with no read-state entry', async () => {
766
+ const client = await new DiscordClient().login({ token: 'test-token' })
767
+ stubReadState(client, [])
768
+ mockResponse([mention('200', 'c1')])
769
+
770
+ const result = await client.getUnreadMentions()
771
+
772
+ expect(result.count).toBe(0)
773
+ })
774
+
775
+ it('omits channels whose mention_count is zero', async () => {
776
+ const client = await new DiscordClient().login({ token: 'test-token' })
777
+ stubReadState(client, [{ channelId: 'c1', lastMessageId: '100', mentionCount: 0 }])
778
+ mockResponse([mention('200', 'c1')])
779
+
780
+ const result = await client.getUnreadMentions()
781
+
782
+ expect(result.count).toBe(0)
783
+ })
784
+
785
+ it('includes all mentions when the read marker is null', async () => {
786
+ const client = await new DiscordClient().login({ token: 'test-token' })
787
+ stubReadState(client, [{ channelId: 'c1', lastMessageId: null, mentionCount: 2 }])
788
+ mockResponse([mention('200', 'c1'), mention('50', 'c1')])
789
+
790
+ const result = await client.getUnreadMentions()
791
+
792
+ expect(result.count).toBe(2)
793
+ })
794
+
795
+ it('reports badgeCount from summed read-state mention counts', async () => {
796
+ const client = await new DiscordClient().login({ token: 'test-token' })
797
+ stubReadState(client, [
798
+ { channelId: 'c1', lastMessageId: '100', mentionCount: 2 },
799
+ { channelId: 'c2', lastMessageId: '100', mentionCount: 3 },
800
+ ])
801
+ mockResponse([mention('200', 'c1')])
802
+
803
+ const result = await client.getUnreadMentions()
804
+
805
+ expect(result.badgeCount).toBe(5)
806
+ expect(result.count).toBe(1)
807
+ })
808
+
809
+ it('paginates mention history with a before cursor', async () => {
810
+ const client = await new DiscordClient().login({ token: 'test-token' })
811
+ stubReadState(client, [{ channelId: 'c1', lastMessageId: '0', mentionCount: 200 }])
812
+
813
+ const firstPage = Array.from({ length: 100 }, (_, i) => mention(String(1000 - i), 'c1'))
814
+ const secondPage = [mention('900', 'c1')]
815
+ mockResponse(firstPage)
816
+ mockResponse(secondPage)
817
+
818
+ const result = await client.getUnreadMentions()
819
+
820
+ expect(fetchCalls.length).toBe(2)
821
+ expect(fetchCalls[1].url).toContain('before=901')
822
+ expect(result.count).toBe(101)
823
+ })
824
+
825
+ it('deduplicates mentions repeated across pages', async () => {
826
+ const client = await new DiscordClient().login({ token: 'test-token' })
827
+ stubReadState(client, [{ channelId: 'c1', lastMessageId: '0', mentionCount: 100 }])
828
+
829
+ const page = Array.from({ length: 100 }, (_, i) => mention(String(1000 - i), 'c1'))
830
+ const overlap = [mention('901', 'c1'), mention('800', 'c1')]
831
+ mockResponse(page)
832
+ mockResponse(overlap)
833
+
834
+ const result = await client.getUnreadMentions()
835
+
836
+ const ids = result.mentions.map((m) => m.id)
837
+ expect(new Set(ids).size).toBe(ids.length)
838
+ })
839
+
840
+ it('caps the scan at limit 1 with a single bounded request', async () => {
841
+ const client = await new DiscordClient().login({ token: 'test-token' })
842
+ stubReadState(client, [{ channelId: 'c1', lastMessageId: '0', mentionCount: 5 }])
843
+ mockResponse([mention('1000', 'c1')])
844
+
845
+ const result = await client.getUnreadMentions({ limit: 1 })
846
+
847
+ // then: exactly one page requested, capped to the limit, incomplete scan
848
+ expect(fetchCalls.length).toBe(1)
849
+ expect(fetchCalls[0].url).toContain('limit=1')
850
+ expect(result.count).toBe(1)
851
+ expect(result.complete).toBe(false)
852
+ })
853
+
854
+ it('caps the scan at a custom limit that spans multiple pages', async () => {
855
+ const client = await new DiscordClient().login({ token: 'test-token' })
856
+ stubReadState(client, [{ channelId: 'c1', lastMessageId: '0', mentionCount: 200 }])
857
+
858
+ const firstPage = Array.from({ length: 100 }, (_, i) => mention(String(1000 - i), 'c1'))
859
+ const secondPage = Array.from({ length: 50 }, (_, i) => mention(String(900 - i), 'c1'))
860
+ mockResponse(firstPage)
861
+ mockResponse(secondPage)
862
+
863
+ const result = await client.getUnreadMentions({ limit: 150 })
864
+
865
+ // then: second page bounded to remaining 50, scan stops at the cap and is incomplete
866
+ expect(fetchCalls.length).toBe(2)
867
+ expect(fetchCalls[1].url).toContain('limit=50')
868
+ expect(result.count).toBe(150)
869
+ expect(result.complete).toBe(false)
870
+ })
871
+
872
+ it('marks the scan complete when a short page ends history', async () => {
873
+ const client = await new DiscordClient().login({ token: 'test-token' })
874
+ stubReadState(client, [{ channelId: 'c1', lastMessageId: '0', mentionCount: 3 }])
875
+ mockResponse([mention('1000', 'c1'), mention('999', 'c1')])
876
+
877
+ const result = await client.getUnreadMentions()
878
+
879
+ expect(result.complete).toBe(true)
880
+ expect(result.count).toBe(2)
881
+ })
882
+
883
+ it('stops and reports incomplete when the cursor fails to advance', async () => {
884
+ const client = await new DiscordClient().login({ token: 'test-token' })
885
+ stubReadState(client, [{ channelId: 'c1', lastMessageId: '0', mentionCount: 200 }])
886
+
887
+ const fullPage = Array.from({ length: 100 }, (_, i) => mention(String(1000 - i), 'c1'))
888
+ const repeatedPage = Array.from({ length: 100 }, (_, i) => mention(String(1000 - i), 'c1'))
889
+ mockResponse(fullPage)
890
+ mockResponse(repeatedPage)
891
+
892
+ const result = await client.getUnreadMentions()
893
+
894
+ // then: the repeated page adds nothing new, so the guard stops the scan as incomplete
895
+ expect(result.complete).toBe(false)
896
+ expect(result.count).toBe(100)
897
+ })
677
898
  })
678
899
  })
@@ -10,14 +10,29 @@ import type {
10
10
  DiscordGuildMember,
11
11
  DiscordMention,
12
12
  DiscordMessage,
13
+ DiscordReadState,
13
14
  DiscordRelationship,
14
15
  DiscordSearchOptions,
15
16
  DiscordSearchResult,
17
+ DiscordUnreadMention,
18
+ DiscordUnreadMentionsResult,
16
19
  DiscordUser,
17
20
  DiscordUserNote,
18
21
  DiscordUserProfile,
19
22
  } from './types'
20
23
 
24
+ const MENTIONS_WINDOW_DAYS = 7
25
+ const MENTIONS_PAGE_SIZE = 100
26
+ const UNREAD_MENTIONS_MAX = 500
27
+
28
+ function isSnowflakeNewer(candidate: string, marker: string): boolean {
29
+ try {
30
+ return BigInt(candidate) > BigInt(marker)
31
+ } catch {
32
+ return false
33
+ }
34
+ }
35
+
21
36
  export class DiscordError extends Error {
22
37
  code: string
23
38
 
@@ -75,7 +90,9 @@ export class DiscordClient {
75
90
  }
76
91
 
77
92
  private getBucketKey(method: string, path: string): string {
78
- const normalized = path
93
+ const queryIndex = path.indexOf('?')
94
+ const route = queryIndex === -1 ? path : path.slice(0, queryIndex)
95
+ const normalized = route
79
96
  .replace(/\/channels\/\d+/, '/channels/{channel_id}')
80
97
  .replace(/\/guilds\/\d+/, '/guilds/{guild_id}')
81
98
  .replace(/\/users\/\d+/, '/users/{user_id}')
@@ -235,12 +252,22 @@ export class DiscordClient {
235
252
  return this.request<DiscordMessage>('POST', `/channels/${channelId}/messages`, body)
236
253
  }
237
254
 
238
- async getMessages(channelId: string, limit: number = 50): Promise<DiscordMessage[]> {
239
- return this.request<DiscordMessage[]>('GET', `/channels/${channelId}/messages?limit=${limit}`)
255
+ async getMessages(channelId: string, limit: number = 50, options?: { around?: string }): Promise<DiscordMessage[]> {
256
+ const params = new URLSearchParams()
257
+ if (options?.around) {
258
+ params.set('around', options.around)
259
+ }
260
+ params.set('limit', limit.toString())
261
+ return this.request<DiscordMessage[]>('GET', `/channels/${channelId}/messages?${params}`)
240
262
  }
241
263
 
242
264
  async getMessage(channelId: string, messageId: string): Promise<DiscordMessage> {
243
- return this.request<DiscordMessage>('GET', `/channels/${channelId}/messages/${messageId}`)
265
+ const messages = await this.getMessages(channelId, 1, { around: messageId })
266
+ const message = messages[0]
267
+ if (!message || message.id !== messageId) {
268
+ throw new DiscordError('Message not found', '10008')
269
+ }
270
+ return message
244
271
  }
245
272
 
246
273
  async editMessage(channelId: string, messageId: string, content: string): Promise<DiscordMessage> {
@@ -322,7 +349,7 @@ export class DiscordClient {
322
349
  })
323
350
  }
324
351
 
325
- async getMentions(options?: { limit?: number; guildId?: string }): Promise<DiscordMention[]> {
352
+ async getMentions(options?: { limit?: number; guildId?: string; before?: string }): Promise<DiscordMention[]> {
326
353
  const params = new URLSearchParams()
327
354
  params.set('limit', (options?.limit ?? 25).toString())
328
355
  params.set('roles', 'true')
@@ -331,10 +358,100 @@ export class DiscordClient {
331
358
  if (options?.guildId) {
332
359
  params.set('guild_id', options.guildId)
333
360
  }
361
+ if (options?.before) {
362
+ params.set('before', options.before)
363
+ }
334
364
 
335
365
  return this.request<DiscordMention[]>('GET', `/users/@me/mentions?${params.toString()}`)
336
366
  }
337
367
 
368
+ async fetchReadState(): Promise<DiscordReadState[]> {
369
+ const { DiscordListener } = await import('./listener')
370
+ const listener = new DiscordListener(this)
371
+ try {
372
+ await listener.start()
373
+ const readState = listener.getReadState()
374
+ if (readState === null) {
375
+ throw new DiscordError('Discord gateway did not deliver read state', 'no_read_state')
376
+ }
377
+ return readState
378
+ } finally {
379
+ listener.stop()
380
+ }
381
+ }
382
+
383
+ async getUnreadMentions(options?: { guildId?: string; limit?: number }): Promise<DiscordUnreadMentionsResult> {
384
+ const limit = options?.limit ?? UNREAD_MENTIONS_MAX
385
+ const [readState, scan] = await Promise.all([
386
+ this.fetchReadState(),
387
+ this.collectMentions({ guildId: options?.guildId, limit }),
388
+ ])
389
+
390
+ const readStateByChannel = new Map(readState.map((state) => [state.channelId, state]))
391
+
392
+ const unread: DiscordUnreadMention[] = []
393
+ for (const mention of scan.mentions) {
394
+ const state = readStateByChannel.get(mention.channel_id)
395
+ // A channel with no read-state entry is unknown, not unread: omit rather than assume unread.
396
+ if (!state || state.mentionCount === 0) continue
397
+ if (state.lastMessageId !== null && !isSnowflakeNewer(mention.id, state.lastMessageId)) continue
398
+ unread.push({ ...mention, mention_count: state.mentionCount })
399
+ }
400
+
401
+ // READY read states carry no guild association, so badgeCount is always the
402
+ // account-wide unread-mention total even when mentions are guild-filtered.
403
+ const badgeCount = readState.reduce((sum, state) => sum + state.mentionCount, 0)
404
+
405
+ return {
406
+ mentions: unread,
407
+ count: unread.length,
408
+ badgeCount,
409
+ complete: scan.complete,
410
+ windowDays: MENTIONS_WINDOW_DAYS,
411
+ }
412
+ }
413
+
414
+ private async collectMentions(options: {
415
+ guildId?: string
416
+ limit: number
417
+ }): Promise<{ mentions: DiscordMention[]; complete: boolean }> {
418
+ const collected: DiscordMention[] = []
419
+ const seen = new Set<string>()
420
+ let before: string | undefined
421
+
422
+ while (collected.length < options.limit) {
423
+ const pageSize = Math.min(MENTIONS_PAGE_SIZE, options.limit - collected.length)
424
+ const page = await this.getMentions({ limit: pageSize, guildId: options.guildId, before })
425
+
426
+ // An empty or short page is the natural end of history: the scan is exhaustive.
427
+ if (page.length < pageSize) {
428
+ for (const mention of page) {
429
+ if (seen.has(mention.id)) continue
430
+ seen.add(mention.id)
431
+ collected.push(mention)
432
+ }
433
+ return { mentions: collected, complete: true }
434
+ }
435
+
436
+ let added = 0
437
+ for (const mention of page) {
438
+ if (collected.length >= options.limit) break
439
+ if (seen.has(mention.id)) continue
440
+ seen.add(mention.id)
441
+ collected.push(mention)
442
+ added++
443
+ }
444
+
445
+ // Discord returns pages newest-first; a non-advancing cursor means a repeated
446
+ // page, so stop to avoid looping. Either way the scan did not reach history's end.
447
+ const oldest = page[page.length - 1].id
448
+ if (added === 0 || oldest === before) return { mentions: collected, complete: false }
449
+ before = oldest
450
+ }
451
+
452
+ return { mentions: collected, complete: false }
453
+ }
454
+
338
455
  async getUserNote(userId: string): Promise<DiscordUserNote | null> {
339
456
  try {
340
457
  return await this.request<DiscordUserNote>('GET', `/users/@me/notes/${userId}`)
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, expect, spyOn, it } from 'bun:test'
2
2
 
3
3
  import { DiscordClient } from '../client'
4
4
  import { DiscordCredentialManager } from '../credential-manager'
5
+ import { unreadAction } from './mention'
5
6
 
6
7
  let clientGetMentionsSpy: ReturnType<typeof spyOn>
7
8
  let credManagerLoadSpy: ReturnType<typeof spyOn>
@@ -128,3 +129,86 @@ it('getMentions: includes mention metadata', async () => {
128
129
  expect(mention.mention_everyone).toBeDefined()
129
130
  expect(mention.mentions).toBeDefined()
130
131
  })
132
+
133
+ it('unread command: emits snake_case JSON and forwards guild/limit options', async () => {
134
+ // given: an authenticated client whose getUnreadMentions returns one unread mention
135
+ credManagerLoadSpy.mockResolvedValue({
136
+ token: 'test-token',
137
+ current_server: null,
138
+ servers: {},
139
+ })
140
+ const loginSpy = spyOn(DiscordClient.prototype, 'login').mockImplementation(async function (this: DiscordClient) {
141
+ return this
142
+ })
143
+ const getUnreadSpy = spyOn(DiscordClient.prototype, 'getUnreadMentions').mockResolvedValue({
144
+ mentions: [
145
+ {
146
+ id: 'msg-1',
147
+ channel_id: 'ch-1',
148
+ author: { id: 'user-1', username: 'alice' },
149
+ content: 'Hey @testuser',
150
+ timestamp: '2024-01-29T10:00:00Z',
151
+ mention_everyone: false,
152
+ mentions: [{ id: 'user-me', username: 'testuser' }],
153
+ guild_id: 'guild-1',
154
+ mention_count: 2,
155
+ },
156
+ ],
157
+ count: 1,
158
+ badgeCount: 2,
159
+ complete: true,
160
+ windowDays: 7,
161
+ })
162
+ const consoleLogSpy = spyOn(console, 'log').mockImplementation(() => {})
163
+
164
+ // when: running the unread action with guild and limit
165
+ await unreadAction({ guild: 'guild-1', limit: 200 })
166
+
167
+ // then: options are forwarded and the emitted JSON uses documented snake_case keys
168
+ expect(getUnreadSpy).toHaveBeenCalledWith({ guildId: 'guild-1', limit: 200 })
169
+ const lastCall = consoleLogSpy.mock.calls.at(-1) as [string]
170
+ const emitted = JSON.parse(lastCall[0])
171
+ expect(emitted).toEqual({
172
+ mentions: [
173
+ {
174
+ id: 'msg-1',
175
+ channel_id: 'ch-1',
176
+ author: 'alice',
177
+ content: 'Hey @testuser',
178
+ timestamp: '2024-01-29T10:00:00Z',
179
+ mention_everyone: false,
180
+ mentioned_users: ['testuser'],
181
+ guild_id: 'guild-1',
182
+ },
183
+ ],
184
+ count: 1,
185
+ badge_count: 2,
186
+ complete: true,
187
+ window_days: 7,
188
+ })
189
+
190
+ loginSpy.mockRestore()
191
+ getUnreadSpy.mockRestore()
192
+ consoleLogSpy.mockRestore()
193
+ })
194
+
195
+ it('unread command: exits with error when not authenticated', async () => {
196
+ // given: no token in credentials
197
+ credManagerLoadSpy.mockResolvedValue({
198
+ token: undefined,
199
+ current_server: null,
200
+ servers: {},
201
+ })
202
+ const consoleLogSpy = spyOn(console, 'log').mockImplementation(() => {})
203
+ const processExitSpy = spyOn(process, 'exit').mockImplementation((_code?: number) => undefined as never)
204
+
205
+ // when: running the unread action
206
+ await unreadAction({})
207
+
208
+ // then: it reports the auth error and exits
209
+ expect(consoleLogSpy).toHaveBeenCalledWith(JSON.stringify({ error: 'Not authenticated. Run "auth extract" first.' }))
210
+ expect(processExitSpy).toHaveBeenCalledWith(1)
211
+
212
+ consoleLogSpy.mockRestore()
213
+ processExitSpy.mockRestore()
214
+ })
@@ -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'
@@ -6,6 +6,14 @@ import { formatOutput } from '@/shared/utils/output'
6
6
  import { DiscordClient } from '../client'
7
7
  import { DiscordCredentialManager } from '../credential-manager'
8
8
 
9
+ function parseLimit(value: string): number {
10
+ const parsed = Number(value)
11
+ if (!Number.isInteger(parsed) || parsed < 1) {
12
+ throw new InvalidArgumentError('Must be a positive integer.')
13
+ }
14
+ return parsed
15
+ }
16
+
9
17
  export async function listAction(options: { limit?: number; guild?: string; pretty?: boolean }): Promise<void> {
10
18
  try {
11
19
  const credManager = new DiscordCredentialManager()
@@ -39,17 +47,72 @@ export async function listAction(options: { limit?: number; guild?: string; pret
39
47
  }
40
48
  }
41
49
 
42
- export const mentionCommand = new Command('mention').description('Mention commands').addCommand(
43
- new Command('list')
44
- .description('List mentions')
45
- .option('--limit <n>', 'Number of mentions to fetch', '25')
46
- .option('--guild <guild-id>', 'Filter by guild ID')
47
- .option('--pretty', 'Pretty print JSON output')
48
- .action((options) => {
49
- listAction({
50
- limit: options.limit ? parseInt(options.limit, 10) : undefined,
51
- guild: options.guild,
52
- pretty: options.pretty,
53
- })
54
- }),
55
- )
50
+ export async function unreadAction(options: { guild?: string; limit?: number; pretty?: boolean }): 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
+ const result = await client.getUnreadMentions({
62
+ guildId: options.guild,
63
+ limit: options.limit,
64
+ })
65
+
66
+ const output = {
67
+ mentions: result.mentions.map((mention) => ({
68
+ id: mention.id,
69
+ channel_id: mention.channel_id,
70
+ author: mention.author.username,
71
+ content: mention.content,
72
+ timestamp: mention.timestamp,
73
+ mention_everyone: mention.mention_everyone,
74
+ mentioned_users: mention.mentions.map((u) => u.username),
75
+ guild_id: mention.guild_id || null,
76
+ })),
77
+ count: result.count,
78
+ badge_count: result.badgeCount,
79
+ complete: result.complete,
80
+ window_days: result.windowDays,
81
+ }
82
+
83
+ console.log(formatOutput(output, options.pretty))
84
+ } catch (error) {
85
+ handleError(error as Error)
86
+ }
87
+ }
88
+
89
+ export const mentionCommand = new Command('mention')
90
+ .description('Mention commands')
91
+ .addCommand(
92
+ new Command('list')
93
+ .description('List mentions')
94
+ .option('--limit <n>', 'Number of mentions to fetch', parseLimit, 25)
95
+ .option('--guild <guild-id>', 'Filter by guild ID')
96
+ .option('--pretty', 'Pretty print JSON output')
97
+ .action((options) => {
98
+ listAction({
99
+ limit: options.limit,
100
+ guild: options.guild,
101
+ pretty: options.pretty,
102
+ })
103
+ }),
104
+ )
105
+ .addCommand(
106
+ new Command('unread')
107
+ .description('List unread mentions (correlates mention history with per-channel read state)')
108
+ .option('--limit <n>', 'Max number of mentions to scan', parseLimit)
109
+ .option('--guild <guild-id>', 'Filter by guild ID')
110
+ .option('--pretty', 'Pretty print JSON output')
111
+ .action((options) => {
112
+ unreadAction({
113
+ limit: options.limit,
114
+ guild: options.guild,
115
+ pretty: options.pretty,
116
+ })
117
+ }),
118
+ )
@@ -37,7 +37,7 @@ beforeEach(() => {
37
37
  count: 1,
38
38
  },
39
39
  ],
40
- } as any)
40
+ })
41
41
 
42
42
  // Spy on DiscordCredentialManager.prototype methods
43
43
  credManagerLoadSpy = spyOn(DiscordCredentialManager.prototype, 'load').mockResolvedValue({
@@ -101,7 +101,7 @@ export async function listAction(channelId: string, messageId: string, options:
101
101
  process.exit(1)
102
102
  }
103
103
 
104
- const reactions = (message as any).reactions || []
104
+ const reactions = message.reactions ?? []
105
105
 
106
106
  console.log(
107
107
  formatOutput(
@@ -25,10 +25,13 @@ export type {
25
25
  DiscordMention,
26
26
  DiscordMessage,
27
27
  DiscordReaction,
28
+ DiscordReadState,
28
29
  DiscordRelationship,
29
30
  DiscordSearchOptions,
30
31
  DiscordSearchResponse,
31
32
  DiscordSearchResult,
33
+ DiscordUnreadMention,
34
+ DiscordUnreadMentionsResult,
32
35
  DiscordUser,
33
36
  DiscordUserNote,
34
37
  DiscordUserProfile,