agent-messenger 2.31.0 → 2.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +14 -1
- package/dist/package.json +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/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/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 +43 -9
- package/dist/src/platforms/discord/types.d.ts.map +1 -1
- package/dist/src/platforms/discord/types.js +22 -7
- package/dist/src/platforms/discord/types.js.map +1 -1
- 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 +1 -1
- package/skills/agent-channeltalk/SKILL.md +1 -1
- package/skills/agent-channeltalkbot/SKILL.md +1 -1
- package/skills/agent-discord/SKILL.md +19 -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 +230 -9
- package/src/platforms/discord/client.ts +122 -5
- 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/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 +11 -0
- package/src/platforms/discord/types.ts +50 -10
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'
|
|
2
2
|
|
|
3
3
|
import { DiscordClient, DiscordError } from './client'
|
|
4
|
+
import type { DiscordReadState } from './types'
|
|
4
5
|
|
|
5
6
|
describe('DiscordClient', () => {
|
|
6
7
|
const originalFetch = globalThis.fetch
|
|
@@ -226,23 +227,50 @@ describe('DiscordClient', () => {
|
|
|
226
227
|
|
|
227
228
|
expect(fetchCalls[0].url).toBe('https://discord.com/api/v10/channels/ch1/messages?limit=50')
|
|
228
229
|
})
|
|
230
|
+
|
|
231
|
+
it('gets messages around a specific message', async () => {
|
|
232
|
+
mockResponse([])
|
|
233
|
+
|
|
234
|
+
const client = await new DiscordClient().login({ token: 'test-token' })
|
|
235
|
+
await client.getMessages('ch1', 1, { around: 'msg/1' })
|
|
236
|
+
|
|
237
|
+
expect(fetchCalls[0].url).toBe('https://discord.com/api/v10/channels/ch1/messages?around=msg%2F1&limit=1')
|
|
238
|
+
})
|
|
229
239
|
})
|
|
230
240
|
|
|
231
241
|
describe('getMessage', () => {
|
|
232
242
|
it('returns single message', async () => {
|
|
233
|
-
mockResponse(
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
243
|
+
mockResponse([
|
|
244
|
+
{
|
|
245
|
+
id: 'msg1',
|
|
246
|
+
channel_id: 'ch1',
|
|
247
|
+
author: { id: '123', username: 'user1' },
|
|
248
|
+
content: 'Message 1',
|
|
249
|
+
timestamp: '2024-01-01T00:00:00.000Z',
|
|
250
|
+
},
|
|
251
|
+
])
|
|
240
252
|
|
|
241
253
|
const client = await new DiscordClient().login({ token: 'test-token' })
|
|
242
254
|
const message = await client.getMessage('ch1', 'msg1')
|
|
243
255
|
|
|
244
256
|
expect(message.id).toBe('msg1')
|
|
245
|
-
expect(fetchCalls[0].url).toBe('https://discord.com/api/v10/channels/ch1/messages
|
|
257
|
+
expect(fetchCalls[0].url).toBe('https://discord.com/api/v10/channels/ch1/messages?around=msg1&limit=1')
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('throws when the requested message is not returned', async () => {
|
|
261
|
+
mockResponse([
|
|
262
|
+
{
|
|
263
|
+
id: 'neighbor',
|
|
264
|
+
channel_id: 'ch1',
|
|
265
|
+
author: { id: '123', username: 'user1' },
|
|
266
|
+
content: 'Nearby message',
|
|
267
|
+
timestamp: '2024-01-01T00:00:00.000Z',
|
|
268
|
+
},
|
|
269
|
+
])
|
|
270
|
+
|
|
271
|
+
const client = await new DiscordClient().login({ token: 'test-token' })
|
|
272
|
+
|
|
273
|
+
await expect(client.getMessage('ch1', 'missing')).rejects.toEqual(new DiscordError('Message not found', '10008'))
|
|
246
274
|
})
|
|
247
275
|
})
|
|
248
276
|
|
|
@@ -674,5 +702,198 @@ describe('DiscordClient', () => {
|
|
|
674
702
|
|
|
675
703
|
expect(fetchCalls.length).toBe(2)
|
|
676
704
|
})
|
|
705
|
+
|
|
706
|
+
it('normalizes query strings for the same channel route', async () => {
|
|
707
|
+
mockResponse([], 200, {
|
|
708
|
+
'X-RateLimit-Remaining': '0',
|
|
709
|
+
'X-RateLimit-Reset': String(Date.now() / 1000 + 0.1),
|
|
710
|
+
'X-RateLimit-Bucket': 'messages-bucket',
|
|
711
|
+
})
|
|
712
|
+
mockResponse([])
|
|
713
|
+
|
|
714
|
+
const client = await new DiscordClient().login({ token: 'test-token' })
|
|
715
|
+
await client.getMessages('123456789', 1, { around: 'message-1' })
|
|
716
|
+
|
|
717
|
+
const startTime = Date.now()
|
|
718
|
+
await client.getMessages('123456789', 1, { around: 'message-2' })
|
|
719
|
+
const elapsed = Date.now() - startTime
|
|
720
|
+
|
|
721
|
+
expect(elapsed).toBeGreaterThanOrEqual(50)
|
|
722
|
+
expect(fetchCalls.length).toBe(2)
|
|
723
|
+
})
|
|
724
|
+
})
|
|
725
|
+
|
|
726
|
+
describe('getUnreadMentions', () => {
|
|
727
|
+
const mention = (id: string, channelId: string, guildId?: string) => ({
|
|
728
|
+
id,
|
|
729
|
+
channel_id: channelId,
|
|
730
|
+
author: { id: 'a1', username: 'alice' },
|
|
731
|
+
content: `mention ${id}`,
|
|
732
|
+
timestamp: '2024-01-29T10:00:00Z',
|
|
733
|
+
mention_everyone: false,
|
|
734
|
+
mentions: [{ id: 'me', username: 'me' }],
|
|
735
|
+
...(guildId ? { guild_id: guildId } : {}),
|
|
736
|
+
})
|
|
737
|
+
|
|
738
|
+
const stubReadState = (client: DiscordClient, readState: DiscordReadState[]) =>
|
|
739
|
+
spyOn(client, 'fetchReadState').mockResolvedValue(readState)
|
|
740
|
+
|
|
741
|
+
it('keeps mentions newer than the channel read marker', async () => {
|
|
742
|
+
// given: one channel with an unread mention (200 > marker 100) and one already read (50 < 100)
|
|
743
|
+
const client = await new DiscordClient().login({ token: 'test-token' })
|
|
744
|
+
stubReadState(client, [{ channelId: 'c1', lastMessageId: '100', mentionCount: 1 }])
|
|
745
|
+
mockResponse([mention('200', 'c1'), mention('50', 'c1')])
|
|
746
|
+
|
|
747
|
+
const result = await client.getUnreadMentions()
|
|
748
|
+
|
|
749
|
+
// then: only the newer message is unread
|
|
750
|
+
expect(result.count).toBe(1)
|
|
751
|
+
expect(result.mentions[0].id).toBe('200')
|
|
752
|
+
expect(result.mentions[0].mention_count).toBe(1)
|
|
753
|
+
})
|
|
754
|
+
|
|
755
|
+
it('treats an equal snowflake as read', async () => {
|
|
756
|
+
const client = await new DiscordClient().login({ token: 'test-token' })
|
|
757
|
+
stubReadState(client, [{ channelId: 'c1', lastMessageId: '100', mentionCount: 1 }])
|
|
758
|
+
mockResponse([mention('100', 'c1')])
|
|
759
|
+
|
|
760
|
+
const result = await client.getUnreadMentions()
|
|
761
|
+
|
|
762
|
+
expect(result.count).toBe(0)
|
|
763
|
+
})
|
|
764
|
+
|
|
765
|
+
it('omits mentions for channels with no read-state entry', async () => {
|
|
766
|
+
const client = await new DiscordClient().login({ token: 'test-token' })
|
|
767
|
+
stubReadState(client, [])
|
|
768
|
+
mockResponse([mention('200', 'c1')])
|
|
769
|
+
|
|
770
|
+
const result = await client.getUnreadMentions()
|
|
771
|
+
|
|
772
|
+
expect(result.count).toBe(0)
|
|
773
|
+
})
|
|
774
|
+
|
|
775
|
+
it('omits channels whose mention_count is zero', async () => {
|
|
776
|
+
const client = await new DiscordClient().login({ token: 'test-token' })
|
|
777
|
+
stubReadState(client, [{ channelId: 'c1', lastMessageId: '100', mentionCount: 0 }])
|
|
778
|
+
mockResponse([mention('200', 'c1')])
|
|
779
|
+
|
|
780
|
+
const result = await client.getUnreadMentions()
|
|
781
|
+
|
|
782
|
+
expect(result.count).toBe(0)
|
|
783
|
+
})
|
|
784
|
+
|
|
785
|
+
it('includes all mentions when the read marker is null', async () => {
|
|
786
|
+
const client = await new DiscordClient().login({ token: 'test-token' })
|
|
787
|
+
stubReadState(client, [{ channelId: 'c1', lastMessageId: null, mentionCount: 2 }])
|
|
788
|
+
mockResponse([mention('200', 'c1'), mention('50', 'c1')])
|
|
789
|
+
|
|
790
|
+
const result = await client.getUnreadMentions()
|
|
791
|
+
|
|
792
|
+
expect(result.count).toBe(2)
|
|
793
|
+
})
|
|
794
|
+
|
|
795
|
+
it('reports badgeCount from summed read-state mention counts', async () => {
|
|
796
|
+
const client = await new DiscordClient().login({ token: 'test-token' })
|
|
797
|
+
stubReadState(client, [
|
|
798
|
+
{ channelId: 'c1', lastMessageId: '100', mentionCount: 2 },
|
|
799
|
+
{ channelId: 'c2', lastMessageId: '100', mentionCount: 3 },
|
|
800
|
+
])
|
|
801
|
+
mockResponse([mention('200', 'c1')])
|
|
802
|
+
|
|
803
|
+
const result = await client.getUnreadMentions()
|
|
804
|
+
|
|
805
|
+
expect(result.badgeCount).toBe(5)
|
|
806
|
+
expect(result.count).toBe(1)
|
|
807
|
+
})
|
|
808
|
+
|
|
809
|
+
it('paginates mention history with a before cursor', async () => {
|
|
810
|
+
const client = await new DiscordClient().login({ token: 'test-token' })
|
|
811
|
+
stubReadState(client, [{ channelId: 'c1', lastMessageId: '0', mentionCount: 200 }])
|
|
812
|
+
|
|
813
|
+
const firstPage = Array.from({ length: 100 }, (_, i) => mention(String(1000 - i), 'c1'))
|
|
814
|
+
const secondPage = [mention('900', 'c1')]
|
|
815
|
+
mockResponse(firstPage)
|
|
816
|
+
mockResponse(secondPage)
|
|
817
|
+
|
|
818
|
+
const result = await client.getUnreadMentions()
|
|
819
|
+
|
|
820
|
+
expect(fetchCalls.length).toBe(2)
|
|
821
|
+
expect(fetchCalls[1].url).toContain('before=901')
|
|
822
|
+
expect(result.count).toBe(101)
|
|
823
|
+
})
|
|
824
|
+
|
|
825
|
+
it('deduplicates mentions repeated across pages', async () => {
|
|
826
|
+
const client = await new DiscordClient().login({ token: 'test-token' })
|
|
827
|
+
stubReadState(client, [{ channelId: 'c1', lastMessageId: '0', mentionCount: 100 }])
|
|
828
|
+
|
|
829
|
+
const page = Array.from({ length: 100 }, (_, i) => mention(String(1000 - i), 'c1'))
|
|
830
|
+
const overlap = [mention('901', 'c1'), mention('800', 'c1')]
|
|
831
|
+
mockResponse(page)
|
|
832
|
+
mockResponse(overlap)
|
|
833
|
+
|
|
834
|
+
const result = await client.getUnreadMentions()
|
|
835
|
+
|
|
836
|
+
const ids = result.mentions.map((m) => m.id)
|
|
837
|
+
expect(new Set(ids).size).toBe(ids.length)
|
|
838
|
+
})
|
|
839
|
+
|
|
840
|
+
it('caps the scan at limit 1 with a single bounded request', async () => {
|
|
841
|
+
const client = await new DiscordClient().login({ token: 'test-token' })
|
|
842
|
+
stubReadState(client, [{ channelId: 'c1', lastMessageId: '0', mentionCount: 5 }])
|
|
843
|
+
mockResponse([mention('1000', 'c1')])
|
|
844
|
+
|
|
845
|
+
const result = await client.getUnreadMentions({ limit: 1 })
|
|
846
|
+
|
|
847
|
+
// then: exactly one page requested, capped to the limit, incomplete scan
|
|
848
|
+
expect(fetchCalls.length).toBe(1)
|
|
849
|
+
expect(fetchCalls[0].url).toContain('limit=1')
|
|
850
|
+
expect(result.count).toBe(1)
|
|
851
|
+
expect(result.complete).toBe(false)
|
|
852
|
+
})
|
|
853
|
+
|
|
854
|
+
it('caps the scan at a custom limit that spans multiple pages', async () => {
|
|
855
|
+
const client = await new DiscordClient().login({ token: 'test-token' })
|
|
856
|
+
stubReadState(client, [{ channelId: 'c1', lastMessageId: '0', mentionCount: 200 }])
|
|
857
|
+
|
|
858
|
+
const firstPage = Array.from({ length: 100 }, (_, i) => mention(String(1000 - i), 'c1'))
|
|
859
|
+
const secondPage = Array.from({ length: 50 }, (_, i) => mention(String(900 - i), 'c1'))
|
|
860
|
+
mockResponse(firstPage)
|
|
861
|
+
mockResponse(secondPage)
|
|
862
|
+
|
|
863
|
+
const result = await client.getUnreadMentions({ limit: 150 })
|
|
864
|
+
|
|
865
|
+
// then: second page bounded to remaining 50, scan stops at the cap and is incomplete
|
|
866
|
+
expect(fetchCalls.length).toBe(2)
|
|
867
|
+
expect(fetchCalls[1].url).toContain('limit=50')
|
|
868
|
+
expect(result.count).toBe(150)
|
|
869
|
+
expect(result.complete).toBe(false)
|
|
870
|
+
})
|
|
871
|
+
|
|
872
|
+
it('marks the scan complete when a short page ends history', async () => {
|
|
873
|
+
const client = await new DiscordClient().login({ token: 'test-token' })
|
|
874
|
+
stubReadState(client, [{ channelId: 'c1', lastMessageId: '0', mentionCount: 3 }])
|
|
875
|
+
mockResponse([mention('1000', 'c1'), mention('999', 'c1')])
|
|
876
|
+
|
|
877
|
+
const result = await client.getUnreadMentions()
|
|
878
|
+
|
|
879
|
+
expect(result.complete).toBe(true)
|
|
880
|
+
expect(result.count).toBe(2)
|
|
881
|
+
})
|
|
882
|
+
|
|
883
|
+
it('stops and reports incomplete when the cursor fails to advance', async () => {
|
|
884
|
+
const client = await new DiscordClient().login({ token: 'test-token' })
|
|
885
|
+
stubReadState(client, [{ channelId: 'c1', lastMessageId: '0', mentionCount: 200 }])
|
|
886
|
+
|
|
887
|
+
const fullPage = Array.from({ length: 100 }, (_, i) => mention(String(1000 - i), 'c1'))
|
|
888
|
+
const repeatedPage = Array.from({ length: 100 }, (_, i) => mention(String(1000 - i), 'c1'))
|
|
889
|
+
mockResponse(fullPage)
|
|
890
|
+
mockResponse(repeatedPage)
|
|
891
|
+
|
|
892
|
+
const result = await client.getUnreadMentions()
|
|
893
|
+
|
|
894
|
+
// then: the repeated page adds nothing new, so the guard stops the scan as incomplete
|
|
895
|
+
expect(result.complete).toBe(false)
|
|
896
|
+
expect(result.count).toBe(100)
|
|
897
|
+
})
|
|
677
898
|
})
|
|
678
899
|
})
|
|
@@ -10,14 +10,29 @@ import type {
|
|
|
10
10
|
DiscordGuildMember,
|
|
11
11
|
DiscordMention,
|
|
12
12
|
DiscordMessage,
|
|
13
|
+
DiscordReadState,
|
|
13
14
|
DiscordRelationship,
|
|
14
15
|
DiscordSearchOptions,
|
|
15
16
|
DiscordSearchResult,
|
|
17
|
+
DiscordUnreadMention,
|
|
18
|
+
DiscordUnreadMentionsResult,
|
|
16
19
|
DiscordUser,
|
|
17
20
|
DiscordUserNote,
|
|
18
21
|
DiscordUserProfile,
|
|
19
22
|
} from './types'
|
|
20
23
|
|
|
24
|
+
const MENTIONS_WINDOW_DAYS = 7
|
|
25
|
+
const MENTIONS_PAGE_SIZE = 100
|
|
26
|
+
const UNREAD_MENTIONS_MAX = 500
|
|
27
|
+
|
|
28
|
+
function isSnowflakeNewer(candidate: string, marker: string): boolean {
|
|
29
|
+
try {
|
|
30
|
+
return BigInt(candidate) > BigInt(marker)
|
|
31
|
+
} catch {
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
21
36
|
export class DiscordError extends Error {
|
|
22
37
|
code: string
|
|
23
38
|
|
|
@@ -75,7 +90,9 @@ export class DiscordClient {
|
|
|
75
90
|
}
|
|
76
91
|
|
|
77
92
|
private getBucketKey(method: string, path: string): string {
|
|
78
|
-
const
|
|
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}`)
|
|
@@ -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
|
+
)
|
|
@@ -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,
|