agent-messenger 2.31.0 → 2.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +14 -1
  3. package/bun.lock +7 -0
  4. package/dist/package.json +2 -1
  5. package/dist/src/platforms/channeltalk/token-extractor.d.ts.map +1 -1
  6. package/dist/src/platforms/channeltalk/token-extractor.js +67 -18
  7. package/dist/src/platforms/channeltalk/token-extractor.js.map +1 -1
  8. package/dist/src/platforms/discord/client.d.ts +11 -2
  9. package/dist/src/platforms/discord/client.d.ts.map +1 -1
  10. package/dist/src/platforms/discord/client.js +109 -4
  11. package/dist/src/platforms/discord/client.js.map +1 -1
  12. package/dist/src/platforms/discord/commands/channel.d.ts.map +1 -1
  13. package/dist/src/platforms/discord/commands/channel.js +3 -2
  14. package/dist/src/platforms/discord/commands/channel.js.map +1 -1
  15. package/dist/src/platforms/discord/commands/mention.d.ts +5 -0
  16. package/dist/src/platforms/discord/commands/mention.d.ts.map +1 -1
  17. package/dist/src/platforms/discord/commands/mention.js +60 -4
  18. package/dist/src/platforms/discord/commands/mention.js.map +1 -1
  19. package/dist/src/platforms/discord/commands/reaction.js +1 -1
  20. package/dist/src/platforms/discord/commands/reaction.js.map +1 -1
  21. package/dist/src/platforms/discord/commands/snapshot.d.ts.map +1 -1
  22. package/dist/src/platforms/discord/commands/snapshot.js +7 -6
  23. package/dist/src/platforms/discord/commands/snapshot.js.map +1 -1
  24. package/dist/src/platforms/discord/index.d.ts +1 -1
  25. package/dist/src/platforms/discord/index.d.ts.map +1 -1
  26. package/dist/src/platforms/discord/index.js.map +1 -1
  27. package/dist/src/platforms/discord/listener.d.ts +4 -1
  28. package/dist/src/platforms/discord/listener.d.ts.map +1 -1
  29. package/dist/src/platforms/discord/listener.js +28 -1
  30. package/dist/src/platforms/discord/listener.js.map +1 -1
  31. package/dist/src/platforms/discord/types.d.ts +60 -9
  32. package/dist/src/platforms/discord/types.d.ts.map +1 -1
  33. package/dist/src/platforms/discord/types.js +58 -7
  34. package/dist/src/platforms/discord/types.js.map +1 -1
  35. package/dist/src/platforms/slack/index.d.ts +1 -0
  36. package/dist/src/platforms/slack/index.d.ts.map +1 -1
  37. package/dist/src/platforms/slack/index.js.map +1 -1
  38. package/dist/src/platforms/slack/qr-confirmation.d.ts +19 -0
  39. package/dist/src/platforms/slack/qr-confirmation.d.ts.map +1 -0
  40. package/dist/src/platforms/slack/qr-confirmation.js +68 -0
  41. package/dist/src/platforms/slack/qr-confirmation.js.map +1 -0
  42. package/dist/src/platforms/slack/qr-cookie-jar.d.ts +7 -0
  43. package/dist/src/platforms/slack/qr-cookie-jar.d.ts.map +1 -0
  44. package/dist/src/platforms/slack/qr-cookie-jar.js +48 -0
  45. package/dist/src/platforms/slack/qr-cookie-jar.js.map +1 -0
  46. package/dist/src/platforms/slack/qr-http-login.d.ts +2 -1
  47. package/dist/src/platforms/slack/qr-http-login.d.ts.map +1 -1
  48. package/dist/src/platforms/slack/qr-http-login.js +88 -53
  49. package/dist/src/platforms/slack/qr-http-login.js.map +1 -1
  50. package/docs/content/docs/cli/channeltalk.mdx +4 -4
  51. package/docs/content/docs/cli/discord.mdx +7 -0
  52. package/docs/content/docs/sdk/discord.mdx +38 -0
  53. package/e2e/README.md +2 -2
  54. package/e2e/discord.e2e.test.ts +16 -2
  55. package/package.json +2 -1
  56. package/skills/agent-channeltalk/SKILL.md +5 -5
  57. package/skills/agent-channeltalk/references/authentication.md +9 -5
  58. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  59. package/skills/agent-discord/SKILL.md +26 -4
  60. package/skills/agent-discord/references/common-patterns.md +7 -3
  61. package/skills/agent-discordbot/SKILL.md +1 -1
  62. package/skills/agent-imessage/SKILL.md +1 -1
  63. package/skills/agent-instagram/SKILL.md +1 -1
  64. package/skills/agent-kakaotalk/SKILL.md +1 -1
  65. package/skills/agent-line/SKILL.md +1 -1
  66. package/skills/agent-slack/SKILL.md +25 -2
  67. package/skills/agent-slackbot/SKILL.md +1 -1
  68. package/skills/agent-teams/SKILL.md +1 -1
  69. package/skills/agent-telegram/SKILL.md +1 -1
  70. package/skills/agent-telegrambot/SKILL.md +1 -1
  71. package/skills/agent-webex/SKILL.md +1 -1
  72. package/skills/agent-webexbot/SKILL.md +1 -1
  73. package/skills/agent-wechatbot/SKILL.md +1 -1
  74. package/skills/agent-whatsapp/SKILL.md +1 -1
  75. package/skills/agent-whatsappbot/SKILL.md +1 -1
  76. package/src/platforms/channeltalk/token-extractor.test.ts +235 -0
  77. package/src/platforms/channeltalk/token-extractor.ts +81 -22
  78. package/src/platforms/discord/client.test.ts +230 -9
  79. package/src/platforms/discord/client.ts +122 -5
  80. package/src/platforms/discord/commands/channel.test.ts +33 -23
  81. package/src/platforms/discord/commands/channel.ts +3 -2
  82. package/src/platforms/discord/commands/mention.test.ts +84 -0
  83. package/src/platforms/discord/commands/mention.ts +78 -15
  84. package/src/platforms/discord/commands/reaction.test.ts +1 -1
  85. package/src/platforms/discord/commands/reaction.ts +1 -1
  86. package/src/platforms/discord/commands/snapshot.test.ts +80 -2
  87. package/src/platforms/discord/commands/snapshot.ts +7 -6
  88. package/src/platforms/discord/index.ts +3 -0
  89. package/src/platforms/discord/listener.test.ts +69 -2
  90. package/src/platforms/discord/listener.ts +31 -2
  91. package/src/platforms/discord/types.test.ts +56 -0
  92. package/src/platforms/discord/types.ts +91 -10
  93. package/src/platforms/slack/index.ts +1 -0
  94. package/src/platforms/slack/qr-confirmation.ts +86 -0
  95. package/src/platforms/slack/qr-cookie-jar.ts +54 -0
  96. package/src/platforms/slack/qr-http-login-2fa.test.ts +398 -0
  97. package/src/platforms/slack/qr-http-login.test.ts +10 -18
  98. package/src/platforms/slack/qr-http-login.ts +109 -52
@@ -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}`)
@@ -1,7 +1,8 @@
1
- import { afterEach, beforeEach, expect, spyOn, it } from 'bun:test'
1
+ import { afterEach, beforeEach, expect, mock, spyOn, it } from 'bun:test'
2
2
 
3
3
  import { DiscordClient } from '../client'
4
4
  import { DiscordCredentialManager } from '../credential-manager'
5
+ import { listAction } from './channel'
5
6
 
6
7
  let clientListChannelsSpy: ReturnType<typeof spyOn>
7
8
  let clientGetChannelSpy: ReturnType<typeof spyOn>
@@ -12,8 +13,10 @@ beforeEach(() => {
12
13
  // Spy on DiscordClient.prototype methods
13
14
  clientListChannelsSpy = spyOn(DiscordClient.prototype, 'listChannels').mockResolvedValue([
14
15
  { id: 'ch-1', guild_id: 'guild-1', name: 'general', type: 0, topic: 'General discussion' },
15
- { id: 'ch-2', guild_id: 'guild-1', name: 'announcements', type: 0, topic: 'Announcements' },
16
+ { id: 'ch-2', guild_id: 'guild-1', name: 'announcements', type: 5, topic: 'Announcements' },
16
17
  { id: 'ch-3', guild_id: 'guild-1', name: 'voice-channel', type: 2, topic: undefined },
18
+ { id: 'ch-4', guild_id: 'guild-1', name: 'Text Channels', type: 4, topic: undefined },
19
+ { id: 'ch-5', guild_id: 'guild-1', name: 'a-thread', type: 11, topic: undefined },
17
20
  ])
18
21
 
19
22
  clientGetChannelSpy = spyOn(DiscordClient.prototype, 'getChannel').mockImplementation(async (channelId: string) => {
@@ -72,30 +75,37 @@ afterEach(() => {
72
75
  credManagerLoadSpy?.mockRestore()
73
76
  })
74
77
 
75
- it('list: returns text channels (type=0) from server', async () => {
76
- // given: discord client with channels
77
- const client = await new DiscordClient().login({ token: 'test-token' })
78
- const channels = await client.listChannels('server-1')
79
-
80
- // when: filtering text channels
81
- const textChannels = channels.filter((ch) => ch.type === 0)
82
-
83
- // then: only text channels are returned
84
- expect(textChannels).toHaveLength(2)
85
- expect(textChannels[0].name).toBe('general')
86
- expect(textChannels[1].name).toBe('announcements')
78
+ it('list: includes text, announcement, and voice channels but excludes categories and threads', async () => {
79
+ // given: a server with mixed channel types
80
+ const consoleSpy = mock(() => {})
81
+ const originalLog = console.log
82
+ console.log = consoleSpy
83
+
84
+ // when: listing channels
85
+ await listAction({ pretty: false })
86
+ console.log = originalLog
87
+
88
+ // then: only listable channels are output
89
+ const output = JSON.parse(consoleSpy.mock.calls[0][0] as string) as Array<{ name: string; type: number }>
90
+ const names = output.map((ch) => ch.name)
91
+ expect(names).toEqual(['general', 'announcements', 'voice-channel'])
92
+ expect(names).not.toContain('Text Channels')
93
+ expect(names).not.toContain('a-thread')
87
94
  })
88
95
 
89
96
  it('list: includes channel metadata', async () => {
90
- // given: discord client with channels
91
- const client = await new DiscordClient().login({ token: 'test-token' })
92
- const channels = await client.listChannels('server-1')
93
- const textChannels = channels.filter((ch) => ch.type === 0)
94
-
95
- // when: checking channel properties
96
- const channel = textChannels[0]
97
-
98
- // then: channel has id, name, type, topic
97
+ // given: a server with channels
98
+ const consoleSpy = mock(() => {})
99
+ const originalLog = console.log
100
+ console.log = consoleSpy
101
+
102
+ // when: listing channels
103
+ await listAction({ pretty: false })
104
+ console.log = originalLog
105
+
106
+ // then: each channel carries id, name, type, parent_id, topic
107
+ const output = JSON.parse(consoleSpy.mock.calls[0][0] as string) as Array<Record<string, unknown>>
108
+ const channel = output[0]
99
109
  expect(channel.id).toBeDefined()
100
110
  expect(channel.name).toBeDefined()
101
111
  expect(channel.type).toBe(0)
@@ -5,6 +5,7 @@ import { formatOutput } from '@/shared/utils/output'
5
5
 
6
6
  import { DiscordClient } from '../client'
7
7
  import { DiscordCredentialManager } from '../credential-manager'
8
+ import { isListableChannel } from '../types'
8
9
 
9
10
  export async function listAction(options: { pretty?: boolean }): Promise<void> {
10
11
  try {
@@ -19,9 +20,9 @@ export async function listAction(options: { pretty?: boolean }): Promise<void> {
19
20
  const client = await new DiscordClient().login({ token: config.token })
20
21
  const channels = await client.listChannels(config.current_server)
21
22
 
22
- const textChannels = channels.filter((ch) => ch.type === 0)
23
+ const listableChannels = channels.filter(isListableChannel)
23
24
 
24
- const output = textChannels.map((ch) => ({
25
+ const output = listableChannels.map((ch) => ({
25
26
  id: ch.id,
26
27
  name: ch.name,
27
28
  type: ch.type,
@@ -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(
@@ -1,6 +1,84 @@
1
- import { expect, it } from 'bun:test'
1
+ import { afterEach, beforeEach, expect, spyOn, it } from 'bun:test'
2
2
 
3
- import { snapshotCommand } from './snapshot'
3
+ import { DiscordClient } from '../client'
4
+ import { DiscordCredentialManager } from '../credential-manager'
5
+ import { DiscordChannelType } from '../types'
6
+ import { snapshotAction, snapshotCommand } from './snapshot'
7
+
8
+ let listChannelsSpy: ReturnType<typeof spyOn>
9
+ let getMessagesSpy: ReturnType<typeof spyOn>
10
+ let getServerSpy: ReturnType<typeof spyOn>
11
+ let listUsersSpy: ReturnType<typeof spyOn>
12
+ let credManagerLoadSpy: ReturnType<typeof spyOn>
13
+
14
+ const mixedChannels = [
15
+ { id: 'text', guild_id: 'g1', name: 'general', type: DiscordChannelType.GUILD_TEXT },
16
+ { id: 'announce', guild_id: 'g1', name: 'news', type: DiscordChannelType.GUILD_ANNOUNCEMENT },
17
+ { id: 'voice', guild_id: 'g1', name: 'lounge', type: DiscordChannelType.GUILD_VOICE },
18
+ { id: 'forum', guild_id: 'g1', name: 'help', type: DiscordChannelType.GUILD_FORUM },
19
+ { id: 'directory', guild_id: 'g1', name: 'hub', type: DiscordChannelType.GUILD_DIRECTORY },
20
+ { id: 'category', guild_id: 'g1', name: 'Section', type: DiscordChannelType.GUILD_CATEGORY },
21
+ { id: 'thread', guild_id: 'g1', name: 'a-thread', type: DiscordChannelType.PUBLIC_THREAD },
22
+ ]
23
+
24
+ function parseSnapshot(consoleSpy: ReturnType<typeof spyOn>): Record<string, any> {
25
+ const lastCall = consoleSpy.mock.calls.at(-1)
26
+ return JSON.parse(lastCall![0] as string)
27
+ }
28
+
29
+ beforeEach(() => {
30
+ listChannelsSpy = spyOn(DiscordClient.prototype, 'listChannels').mockResolvedValue(mixedChannels)
31
+ getMessagesSpy = spyOn(DiscordClient.prototype, 'getMessages').mockResolvedValue([])
32
+ getServerSpy = spyOn(DiscordClient.prototype, 'getServer').mockResolvedValue({ id: 'g1', name: 'Guild One' })
33
+ listUsersSpy = spyOn(DiscordClient.prototype, 'listUsers').mockResolvedValue([])
34
+ credManagerLoadSpy = spyOn(DiscordCredentialManager.prototype, 'load').mockResolvedValue({
35
+ token: 'test-token',
36
+ current_server: 'g1',
37
+ servers: { g1: { server_id: 'g1', server_name: 'Guild One' } },
38
+ })
39
+ })
40
+
41
+ afterEach(() => {
42
+ listChannelsSpy?.mockRestore()
43
+ getMessagesSpy?.mockRestore()
44
+ getServerSpy?.mockRestore()
45
+ listUsersSpy?.mockRestore()
46
+ credManagerLoadSpy?.mockRestore()
47
+ })
48
+
49
+ it('snapshot brief: lists listable channels and excludes categories and threads', async () => {
50
+ // given: a server with mixed channel types
51
+ const consoleSpy = spyOn(console, 'log').mockImplementation(() => {})
52
+
53
+ // when: running a brief snapshot
54
+ await snapshotAction({ pretty: false })
55
+ const snapshot = parseSnapshot(consoleSpy)
56
+ consoleSpy.mockRestore()
57
+
58
+ // then: only listable channels appear, no messages are fetched
59
+ const names = snapshot.channels.map((ch: { name: string }) => ch.name)
60
+ expect(names).toEqual(['general', 'news', 'lounge', 'help', 'hub'])
61
+ expect(getMessagesSpy).not.toHaveBeenCalled()
62
+ })
63
+
64
+ it('snapshot full: fetches messages only from message-readable channels', async () => {
65
+ // given: a server with mixed channel types
66
+ const consoleSpy = spyOn(console, 'log').mockImplementation(() => {})
67
+
68
+ // when: running a full snapshot
69
+ await snapshotAction({ full: true, pretty: false })
70
+ const snapshot = parseSnapshot(consoleSpy)
71
+ consoleSpy.mockRestore()
72
+
73
+ // then: listing includes containers but getMessages is called only for readable IDs
74
+ const listedNames = snapshot.channels.map((ch: { name: string }) => ch.name)
75
+ expect(listedNames).toEqual(['general', 'news', 'lounge', 'help', 'hub'])
76
+
77
+ const fetchedIds = getMessagesSpy.mock.calls.map((call) => call[0])
78
+ expect(fetchedIds.sort()).toEqual(['announce', 'text', 'voice'])
79
+ expect(fetchedIds).not.toContain('forum')
80
+ expect(fetchedIds).not.toContain('directory')
81
+ })
4
82
 
5
83
  it('snapshot: command is defined', () => {
6
84
  expect(snapshotCommand).toBeDefined()
@@ -6,6 +6,7 @@ import { formatOutput } from '@/shared/utils/output'
6
6
 
7
7
  import { DiscordClient } from '../client'
8
8
  import { DiscordCredentialManager } from '../credential-manager'
9
+ import { isListableChannel, isMessageReadableChannel } from '../types'
9
10
  import type { DiscordChannel } from '../types'
10
11
 
11
12
  export async function snapshotAction(options: {
@@ -41,8 +42,9 @@ export async function snapshotAction(options: {
41
42
 
42
43
  if (!options.usersOnly) {
43
44
  const channels = await client.listChannels(serverId)
45
+ const listableChannels = channels.filter(isListableChannel)
44
46
 
45
- snapshot.channels = channels.map((ch) => ({
47
+ snapshot.channels = listableChannels.map((ch) => ({
46
48
  id: ch.id,
47
49
  name: ch.name,
48
50
  type: ch.type,
@@ -50,11 +52,10 @@ export async function snapshotAction(options: {
50
52
  }))
51
53
 
52
54
  if (!options.channelsOnly) {
53
- const isTextChannel = (ch: DiscordChannel) => ch.type === 0 || ch.type === 5
54
- const textChannels = channels.filter(isTextChannel)
55
+ const readableChannels = listableChannels.filter(isMessageReadableChannel)
55
56
 
56
57
  const channelMessages = await parallelMap(
57
- textChannels,
58
+ readableChannels,
58
59
  async (channel: DiscordChannel) => {
59
60
  const messages = await client.getMessages(channel.id, messageLimit)
60
61
  return messages.map((msg) => ({
@@ -88,8 +89,8 @@ export async function snapshotAction(options: {
88
89
  } else {
89
90
  if (!options.usersOnly) {
90
91
  const channels = await client.listChannels(serverId)
91
- const textChannels = channels.filter((ch: DiscordChannel) => ch.type === 0 || ch.type === 5)
92
- snapshot.channels = textChannels.map((ch) => ({ id: ch.id, name: ch.name }))
92
+ const listableChannels = channels.filter(isListableChannel)
93
+ snapshot.channels = listableChannels.map((ch) => ({ id: ch.id, name: ch.name }))
93
94
  }
94
95
 
95
96
  snapshot.hint =
@@ -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,