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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +12 -1
- package/dist/package.json +1 -1
- package/dist/src/platforms/discord/client.d.ts +16 -1
- package/dist/src/platforms/discord/client.d.ts.map +1 -1
- package/dist/src/platforms/discord/client.js +50 -3
- package/dist/src/platforms/discord/client.js.map +1 -1
- package/dist/src/platforms/discord/commands/dm.d.ts +4 -0
- package/dist/src/platforms/discord/commands/dm.d.ts.map +1 -1
- package/dist/src/platforms/discord/commands/dm.js +56 -4
- package/dist/src/platforms/discord/commands/dm.js.map +1 -1
- package/dist/src/platforms/discord/index.d.ts +1 -1
- package/dist/src/platforms/discord/index.d.ts.map +1 -1
- package/dist/src/platforms/discord/index.js.map +1 -1
- package/dist/src/platforms/discord/listener.d.ts +4 -2
- package/dist/src/platforms/discord/listener.d.ts.map +1 -1
- package/dist/src/platforms/discord/listener.js +9 -2
- package/dist/src/platforms/discord/listener.js.map +1 -1
- package/dist/src/platforms/discord/types.d.ts +19 -0
- package/dist/src/platforms/discord/types.d.ts.map +1 -1
- package/dist/src/platforms/discord/types.js +5 -1
- package/dist/src/platforms/discord/types.js.map +1 -1
- package/docs/content/docs/cli/discord.mdx +17 -0
- package/docs/content/docs/sdk/discord.mdx +13 -0
- package/package.json +1 -1
- package/skills/agent-channeltalk/SKILL.md +1 -1
- package/skills/agent-channeltalkbot/SKILL.md +1 -1
- package/skills/agent-discord/SKILL.md +23 -1
- package/skills/agent-discordbot/SKILL.md +1 -1
- package/skills/agent-imessage/SKILL.md +1 -1
- package/skills/agent-instagram/SKILL.md +1 -1
- package/skills/agent-kakaotalk/SKILL.md +1 -1
- package/skills/agent-line/SKILL.md +1 -1
- package/skills/agent-slack/SKILL.md +1 -1
- package/skills/agent-slackbot/SKILL.md +1 -1
- package/skills/agent-teams/SKILL.md +1 -1
- package/skills/agent-telegram/SKILL.md +1 -1
- package/skills/agent-telegrambot/SKILL.md +1 -1
- package/skills/agent-webex/SKILL.md +1 -1
- package/skills/agent-webexbot/SKILL.md +1 -1
- package/skills/agent-wechatbot/SKILL.md +1 -1
- package/skills/agent-whatsapp/SKILL.md +1 -1
- package/skills/agent-whatsappbot/SKILL.md +1 -1
- package/src/platforms/discord/client.test.ts +151 -1
- package/src/platforms/discord/client.ts +76 -3
- package/src/platforms/discord/commands/dm.test.ts +126 -1
- package/src/platforms/discord/commands/dm.ts +63 -3
- package/src/platforms/discord/index.ts +3 -0
- package/src/platforms/discord/listener.test.ts +12 -3
- package/src/platforms/discord/listener.ts +17 -4
- 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
|
|
375
|
-
if (
|
|
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
|
|
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
|
|
37
|
+
type: channelTypeLabel(channel.type),
|
|
26
38
|
name: channel.name || null,
|
|
27
39
|
recipients: channel.recipients.map((user) => ({
|
|
28
40
|
id: user.id,
|
|
@@ -52,7 +64,7 @@ export async function createAction(userId: string, options: { pretty?: boolean }
|
|
|
52
64
|
|
|
53
65
|
const output = {
|
|
54
66
|
id: channel.id,
|
|
55
|
-
type: channel.type
|
|
67
|
+
type: channelTypeLabel(channel.type),
|
|
56
68
|
name: channel.name || null,
|
|
57
69
|
recipients: channel.recipients.map((user) => ({
|
|
58
70
|
id: user.id,
|
|
@@ -66,6 +78,42 @@ export async function createAction(userId: string, options: { pretty?: boolean }
|
|
|
66
78
|
}
|
|
67
79
|
}
|
|
68
80
|
|
|
81
|
+
export async function unreadAction(options: { limit?: number; pretty?: boolean }): Promise<void> {
|
|
82
|
+
try {
|
|
83
|
+
const credManager = new DiscordCredentialManager()
|
|
84
|
+
const config = await credManager.load()
|
|
85
|
+
|
|
86
|
+
if (!config.token) {
|
|
87
|
+
console.log(formatOutput({ error: 'Not authenticated. Run "auth extract" first.' }, options.pretty))
|
|
88
|
+
process.exit(1)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const client = await new DiscordClient().login({ token: config.token })
|
|
92
|
+
const result = await client.getUnreadDMs({ limit: options.limit })
|
|
93
|
+
|
|
94
|
+
const output = {
|
|
95
|
+
channels: result.channels.map((channel) => ({
|
|
96
|
+
id: channel.id,
|
|
97
|
+
type: channelTypeLabel(channel.type),
|
|
98
|
+
name: channel.name,
|
|
99
|
+
recipients: channel.recipients.map((user) => ({
|
|
100
|
+
id: user.id,
|
|
101
|
+
username: user.username,
|
|
102
|
+
})),
|
|
103
|
+
last_message_id: channel.lastMessageId,
|
|
104
|
+
unread_count: channel.unreadCount,
|
|
105
|
+
})),
|
|
106
|
+
count: result.count,
|
|
107
|
+
total_unread: result.totalUnread,
|
|
108
|
+
complete: result.complete,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
console.log(formatOutput(output, options.pretty))
|
|
112
|
+
} catch (error) {
|
|
113
|
+
handleError(error as Error)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
69
117
|
export const dmCommand = new Command('dm')
|
|
70
118
|
.description('DM channel commands')
|
|
71
119
|
.addCommand(
|
|
@@ -81,3 +129,15 @@ export const dmCommand = new Command('dm')
|
|
|
81
129
|
.option('--pretty', 'Pretty print JSON output')
|
|
82
130
|
.action(createAction),
|
|
83
131
|
)
|
|
132
|
+
.addCommand(
|
|
133
|
+
new Command('unread')
|
|
134
|
+
.description('List unread DM and group-DM channels, newest first')
|
|
135
|
+
.option('--limit <n>', 'Max number of channels to return', parseLimit)
|
|
136
|
+
.option('--pretty', 'Pretty print JSON output')
|
|
137
|
+
.action((options) => {
|
|
138
|
+
unreadAction({
|
|
139
|
+
limit: options.limit,
|
|
140
|
+
pretty: options.pretty,
|
|
141
|
+
})
|
|
142
|
+
}),
|
|
143
|
+
)
|
|
@@ -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 {
|
|
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):
|
|
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
|
-
|
|
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({
|
|
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 =
|