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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +14 -1
- package/bun.lock +7 -0
- package/dist/package.json +2 -1
- package/dist/src/platforms/channeltalk/token-extractor.d.ts.map +1 -1
- package/dist/src/platforms/channeltalk/token-extractor.js +67 -18
- package/dist/src/platforms/channeltalk/token-extractor.js.map +1 -1
- package/dist/src/platforms/discord/client.d.ts +11 -2
- package/dist/src/platforms/discord/client.d.ts.map +1 -1
- package/dist/src/platforms/discord/client.js +109 -4
- package/dist/src/platforms/discord/client.js.map +1 -1
- package/dist/src/platforms/discord/commands/channel.d.ts.map +1 -1
- package/dist/src/platforms/discord/commands/channel.js +3 -2
- package/dist/src/platforms/discord/commands/channel.js.map +1 -1
- package/dist/src/platforms/discord/commands/mention.d.ts +5 -0
- package/dist/src/platforms/discord/commands/mention.d.ts.map +1 -1
- package/dist/src/platforms/discord/commands/mention.js +60 -4
- package/dist/src/platforms/discord/commands/mention.js.map +1 -1
- package/dist/src/platforms/discord/commands/reaction.js +1 -1
- package/dist/src/platforms/discord/commands/reaction.js.map +1 -1
- package/dist/src/platforms/discord/commands/snapshot.d.ts.map +1 -1
- package/dist/src/platforms/discord/commands/snapshot.js +7 -6
- package/dist/src/platforms/discord/commands/snapshot.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 -1
- package/dist/src/platforms/discord/listener.d.ts.map +1 -1
- package/dist/src/platforms/discord/listener.js +28 -1
- package/dist/src/platforms/discord/listener.js.map +1 -1
- package/dist/src/platforms/discord/types.d.ts +60 -9
- package/dist/src/platforms/discord/types.d.ts.map +1 -1
- package/dist/src/platforms/discord/types.js +58 -7
- package/dist/src/platforms/discord/types.js.map +1 -1
- package/dist/src/platforms/slack/index.d.ts +1 -0
- package/dist/src/platforms/slack/index.d.ts.map +1 -1
- package/dist/src/platforms/slack/index.js.map +1 -1
- package/dist/src/platforms/slack/qr-confirmation.d.ts +19 -0
- package/dist/src/platforms/slack/qr-confirmation.d.ts.map +1 -0
- package/dist/src/platforms/slack/qr-confirmation.js +68 -0
- package/dist/src/platforms/slack/qr-confirmation.js.map +1 -0
- package/dist/src/platforms/slack/qr-cookie-jar.d.ts +7 -0
- package/dist/src/platforms/slack/qr-cookie-jar.d.ts.map +1 -0
- package/dist/src/platforms/slack/qr-cookie-jar.js +48 -0
- package/dist/src/platforms/slack/qr-cookie-jar.js.map +1 -0
- package/dist/src/platforms/slack/qr-http-login.d.ts +2 -1
- package/dist/src/platforms/slack/qr-http-login.d.ts.map +1 -1
- package/dist/src/platforms/slack/qr-http-login.js +88 -53
- package/dist/src/platforms/slack/qr-http-login.js.map +1 -1
- package/docs/content/docs/cli/channeltalk.mdx +4 -4
- package/docs/content/docs/cli/discord.mdx +7 -0
- package/docs/content/docs/sdk/discord.mdx +38 -0
- package/e2e/README.md +2 -2
- package/e2e/discord.e2e.test.ts +16 -2
- package/package.json +2 -1
- package/skills/agent-channeltalk/SKILL.md +5 -5
- package/skills/agent-channeltalk/references/authentication.md +9 -5
- package/skills/agent-channeltalkbot/SKILL.md +1 -1
- package/skills/agent-discord/SKILL.md +26 -4
- package/skills/agent-discord/references/common-patterns.md +7 -3
- 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 +25 -2
- 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/channeltalk/token-extractor.test.ts +235 -0
- package/src/platforms/channeltalk/token-extractor.ts +81 -22
- package/src/platforms/discord/client.test.ts +230 -9
- package/src/platforms/discord/client.ts +122 -5
- package/src/platforms/discord/commands/channel.test.ts +33 -23
- package/src/platforms/discord/commands/channel.ts +3 -2
- package/src/platforms/discord/commands/mention.test.ts +84 -0
- package/src/platforms/discord/commands/mention.ts +78 -15
- package/src/platforms/discord/commands/reaction.test.ts +1 -1
- package/src/platforms/discord/commands/reaction.ts +1 -1
- package/src/platforms/discord/commands/snapshot.test.ts +80 -2
- package/src/platforms/discord/commands/snapshot.ts +7 -6
- package/src/platforms/discord/index.ts +3 -0
- package/src/platforms/discord/listener.test.ts +69 -2
- package/src/platforms/discord/listener.ts +31 -2
- package/src/platforms/discord/types.test.ts +56 -0
- package/src/platforms/discord/types.ts +91 -10
- package/src/platforms/slack/index.ts +1 -0
- package/src/platforms/slack/qr-confirmation.ts +86 -0
- package/src/platforms/slack/qr-cookie-jar.ts +54 -0
- package/src/platforms/slack/qr-http-login-2fa.test.ts +398 -0
- package/src/platforms/slack/qr-http-login.test.ts +10 -18
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
76
|
-
// given:
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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:
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
// when:
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
23
|
+
const listableChannels = channels.filter(isListableChannel)
|
|
23
24
|
|
|
24
|
-
const output =
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
+
)
|
|
@@ -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 {
|
|
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 =
|
|
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
|
|
54
|
-
const textChannels = channels.filter(isTextChannel)
|
|
55
|
+
const readableChannels = listableChannels.filter(isMessageReadableChannel)
|
|
55
56
|
|
|
56
57
|
const channelMessages = await parallelMap(
|
|
57
|
-
|
|
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
|
|
92
|
-
snapshot.channels =
|
|
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,
|