agent-messenger 2.33.0 → 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 (51) 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 +16 -1
  5. package/dist/src/platforms/discord/client.d.ts.map +1 -1
  6. package/dist/src/platforms/discord/client.js +50 -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/index.d.ts +1 -1
  13. package/dist/src/platforms/discord/index.d.ts.map +1 -1
  14. package/dist/src/platforms/discord/index.js.map +1 -1
  15. package/dist/src/platforms/discord/listener.d.ts +4 -2
  16. package/dist/src/platforms/discord/listener.d.ts.map +1 -1
  17. package/dist/src/platforms/discord/listener.js +9 -2
  18. package/dist/src/platforms/discord/listener.js.map +1 -1
  19. package/dist/src/platforms/discord/types.d.ts +19 -0
  20. package/dist/src/platforms/discord/types.d.ts.map +1 -1
  21. package/dist/src/platforms/discord/types.js +5 -1
  22. package/dist/src/platforms/discord/types.js.map +1 -1
  23. package/docs/content/docs/cli/discord.mdx +17 -0
  24. package/docs/content/docs/sdk/discord.mdx +13 -0
  25. package/package.json +1 -1
  26. package/skills/agent-channeltalk/SKILL.md +1 -1
  27. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  28. package/skills/agent-discord/SKILL.md +23 -1
  29. package/skills/agent-discordbot/SKILL.md +1 -1
  30. package/skills/agent-imessage/SKILL.md +1 -1
  31. package/skills/agent-instagram/SKILL.md +1 -1
  32. package/skills/agent-kakaotalk/SKILL.md +1 -1
  33. package/skills/agent-line/SKILL.md +1 -1
  34. package/skills/agent-slack/SKILL.md +1 -1
  35. package/skills/agent-slackbot/SKILL.md +1 -1
  36. package/skills/agent-teams/SKILL.md +1 -1
  37. package/skills/agent-telegram/SKILL.md +1 -1
  38. package/skills/agent-telegrambot/SKILL.md +1 -1
  39. package/skills/agent-webex/SKILL.md +1 -1
  40. package/skills/agent-webexbot/SKILL.md +1 -1
  41. package/skills/agent-wechatbot/SKILL.md +1 -1
  42. package/skills/agent-whatsapp/SKILL.md +1 -1
  43. package/skills/agent-whatsappbot/SKILL.md +1 -1
  44. package/src/platforms/discord/client.test.ts +151 -1
  45. package/src/platforms/discord/client.ts +76 -3
  46. package/src/platforms/discord/commands/dm.test.ts +126 -1
  47. package/src/platforms/discord/commands/dm.ts +63 -3
  48. package/src/platforms/discord/index.ts +3 -0
  49. package/src/platforms/discord/listener.test.ts +12 -3
  50. package/src/platforms/discord/listener.ts +17 -4
  51. package/src/platforms/discord/types.ts +37 -1
@@ -11,10 +11,13 @@ import type {
11
11
  DiscordMention,
12
12
  DiscordMessage,
13
13
  DiscordReadState,
14
+ DiscordReadStateSnapshot,
14
15
  DiscordRelationship,
15
16
  DiscordRole,
16
17
  DiscordSearchOptions,
17
18
  DiscordSearchResult,
19
+ DiscordUnreadDM,
20
+ DiscordUnreadDMsResult,
18
21
  DiscordUnreadMention,
19
22
  DiscordUnreadMentionsResult,
20
23
  DiscordUser,
@@ -34,6 +37,47 @@ function isSnowflakeNewer(candidate: string, marker: string): boolean {
34
37
  }
35
38
  }
36
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
+
37
81
  export class DiscordError extends Error {
38
82
  code: string
39
83
 
@@ -350,6 +394,31 @@ export class DiscordClient {
350
394
  })
351
395
  }
352
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
+
353
422
  async getMentions(options?: { limit?: number; guildId?: string; before?: string }): Promise<DiscordMention[]> {
354
423
  const params = new URLSearchParams()
355
424
  params.set('limit', (options?.limit ?? 25).toString())
@@ -367,15 +436,19 @@ export class DiscordClient {
367
436
  }
368
437
 
369
438
  async fetchReadState(): Promise<DiscordReadState[]> {
439
+ return (await this.fetchReadStateSnapshot()).states
440
+ }
441
+
442
+ async fetchReadStateSnapshot(): Promise<DiscordReadStateSnapshot> {
370
443
  const { DiscordListener } = await import('./listener')
371
444
  const listener = new DiscordListener(this)
372
445
  try {
373
446
  await listener.start()
374
- const readState = listener.getReadState()
375
- if (readState === null) {
447
+ const snapshot = listener.getReadStateSnapshot()
448
+ if (snapshot === null) {
376
449
  throw new DiscordError('Discord gateway did not deliver read state', 'no_read_state')
377
450
  }
378
- return readState
451
+ return snapshot
379
452
  } finally {
380
453
  listener.stop()
381
454
  }
@@ -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
+ )
@@ -26,6 +26,7 @@ export type {
26
26
  DiscordMessage,
27
27
  DiscordReaction,
28
28
  DiscordReadState,
29
+ DiscordReadStateSnapshot,
29
30
  DiscordRelationship,
30
31
  DiscordRole,
31
32
  DiscordRoleColors,
@@ -33,6 +34,8 @@ export type {
33
34
  DiscordSearchOptions,
34
35
  DiscordSearchResponse,
35
36
  DiscordSearchResult,
37
+ DiscordUnreadDM,
38
+ DiscordUnreadDMsResult,
36
39
  DiscordUnreadMention,
37
40
  DiscordUnreadMentionsResult,
38
41
  DiscordUser,
@@ -143,19 +143,28 @@ describe('parseReadStates', () => {
143
143
  { id: 'c2', read_state_type: 2, mention_count: 5 },
144
144
  ])
145
145
 
146
- expect(result).toEqual([{ channelId: 'c1', lastMessageId: '100', mentionCount: 1 }])
146
+ expect(result.states).toEqual([{ channelId: 'c1', lastMessageId: '100', mentionCount: 1 }])
147
147
  })
148
148
 
149
149
  it('normalizes missing last_message_id and mention_count', () => {
150
150
  const result = parseReadStates([{ id: 'c1' }])
151
151
 
152
- expect(result).toEqual([{ channelId: 'c1', lastMessageId: null, mentionCount: 0 }])
152
+ expect(result.states).toEqual([{ channelId: 'c1', lastMessageId: null, mentionCount: 0 }])
153
153
  })
154
154
 
155
155
  it('skips malformed entries but keeps valid ones', () => {
156
156
  const result = parseReadStates([{ id: 'c1', mention_count: 1 }, { mention_count: 3 }])
157
157
 
158
- expect(result).toEqual([{ channelId: 'c1', lastMessageId: null, mentionCount: 1 }])
158
+ expect(result.states).toEqual([{ channelId: 'c1', lastMessageId: null, mentionCount: 1 }])
159
+ })
160
+
161
+ it('treats a bare array payload as complete', () => {
162
+ expect(parseReadStates([{ id: 'c1' }]).partial).toBe(false)
163
+ })
164
+
165
+ it('reads the partial flag from a versioned payload', () => {
166
+ expect(parseReadStates({ entries: [{ id: 'c1' }], version: 7, partial: true }).partial).toBe(true)
167
+ expect(parseReadStates({ entries: [{ id: 'c1' }], version: 7 }).partial).toBe(false)
159
168
  })
160
169
 
161
170
  it('throws on a non-array, non-versioned payload', () => {
@@ -3,16 +3,22 @@ import { EventEmitter } from 'events'
3
3
  import WebSocket from 'ws'
4
4
 
5
5
  import type { DiscordClient } from './client'
6
- import type { DiscordListenerEventMap, DiscordGatewayGenericEvent, DiscordReadState } from './types'
6
+ import type {
7
+ DiscordListenerEventMap,
8
+ DiscordGatewayGenericEvent,
9
+ DiscordReadState,
10
+ DiscordReadStateSnapshot,
11
+ } from './types'
7
12
  import { DiscordGatewayOpcode, DiscordRawReadStateSchema, DiscordReadyReadStateSchema } from './types'
8
13
 
9
- export function parseReadStates(value: unknown): DiscordReadState[] {
14
+ export function parseReadStates(value: unknown): DiscordReadStateSnapshot {
10
15
  const parsed = DiscordReadyReadStateSchema.safeParse(value)
11
16
  if (!parsed.success) {
12
17
  throw new Error('Discord READY contained an invalid read_state payload')
13
18
  }
14
19
 
15
20
  const entries = Array.isArray(parsed.data) ? parsed.data : parsed.data.entries
21
+ const partial = Array.isArray(parsed.data) ? false : (parsed.data.partial ?? false)
16
22
 
17
23
  const readStates: DiscordReadState[] = []
18
24
  for (const entry of entries) {
@@ -25,7 +31,7 @@ export function parseReadStates(value: unknown): DiscordReadState[] {
25
31
  mentionCount: state.data.mention_count ?? 0,
26
32
  })
27
33
  }
28
- return readStates
34
+ return { states: readStates, partial }
29
35
  }
30
36
 
31
37
  const GATEWAY_URL = 'wss://gateway.discord.gg/?v=10&encoding=json'
@@ -80,6 +86,7 @@ export class DiscordListener {
80
86
  private token: string | null = null
81
87
  private cachedUser: { id: string; username: string } | null = null
82
88
  private readState: DiscordReadState[] | null = null
89
+ private readStatePartial = false
83
90
  private generation = 0
84
91
  private startPromise: Promise<void> | null = null
85
92
  private pendingStartReject: ((error: Error) => void) | null = null
@@ -202,6 +209,10 @@ export class DiscordListener {
202
209
  return this.readState
203
210
  }
204
211
 
212
+ getReadStateSnapshot(): DiscordReadStateSnapshot | null {
213
+ return this.readState === null ? null : { states: this.readState, partial: this.readStatePartial }
214
+ }
215
+
205
216
  private isCurrent(generation: number, ws?: WebSocket): boolean {
206
217
  if (generation !== this.generation || !this.running) return false
207
218
  if (ws !== undefined && this.ws !== ws) return false
@@ -326,7 +337,9 @@ export class DiscordListener {
326
337
  this.sessionId = d.session_id
327
338
  this.resumeGatewayUrl = d.resume_gateway_url
328
339
  this.cachedUser = d.user
329
- this.readState = d.read_state === undefined ? [] : parseReadStates(d.read_state)
340
+ const snapshot = d.read_state === undefined ? null : parseReadStates(d.read_state)
341
+ this.readState = snapshot === null ? [] : snapshot.states
342
+ this.readStatePartial = snapshot?.partial ?? false
330
343
  this.reconnectAttempts = 0
331
344
  this.emitter.emit('connected', { user: d.user, sessionId: d.session_id })
332
345
  return
@@ -139,6 +139,13 @@ export interface DiscordReadState {
139
139
  mentionCount: number
140
140
  }
141
141
 
142
+ // When partial is true the entries are an incomplete slice, so an absent channel
143
+ // carries no information: it may simply have been left out of this payload.
144
+ export interface DiscordReadStateSnapshot {
145
+ states: DiscordReadState[]
146
+ partial: boolean
147
+ }
148
+
142
149
  export interface DiscordUnreadMention extends DiscordMention {
143
150
  mention_count: number
144
151
  }
@@ -153,6 +160,31 @@ export interface DiscordUnreadMentionsResult {
153
160
  windowDays: number
154
161
  }
155
162
 
163
+ export interface DiscordUnreadDM {
164
+ id: string
165
+ type: number
166
+ name: string | null
167
+ recipients: DiscordUser[]
168
+ lastMessageId: string
169
+ // Discord's own badge counter, which counts every message in a DM rather than
170
+ // only mentions. null when the channel has no READY read-state entry (count
171
+ // unknown); 0 when the DM is muted, since muting stops the counter but still
172
+ // leaves the channel unread.
173
+ unreadCount: number | null
174
+ }
175
+
176
+ // count and totalUnread describe every unread DM found, so they stay truthful when
177
+ // `channels` is capped by a limit. totalUnread sums known counts only, meaning
178
+ // channels with an unknown count contribute nothing to it. complete is false when
179
+ // Discord delivered a partial read state, in which case never-opened DMs may be
180
+ // missing from the result.
181
+ export interface DiscordUnreadDMsResult {
182
+ channels: DiscordUnreadDM[]
183
+ count: number
184
+ totalUnread: number
185
+ complete: boolean
186
+ }
187
+
156
188
  export interface DiscordSearchResult {
157
189
  id: string
158
190
  channel_id: string
@@ -512,7 +544,11 @@ export type DiscordRawReadState = z.infer<typeof DiscordRawReadStateSchema>
512
544
  // VERSIONED_READ_STATES gateway capability is negotiated.
513
545
  export const DiscordReadyReadStateSchema = z.union([
514
546
  z.array(z.unknown()),
515
- z.object({ entries: z.array(z.unknown()), version: z.number().optional() }),
547
+ z.object({
548
+ entries: z.array(z.unknown()),
549
+ version: z.number().optional(),
550
+ partial: z.boolean().optional(),
551
+ }),
516
552
  ])
517
553
 
518
554
  export type DiscordGatewayEvent =