agent-messenger 2.30.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/bun.lock +42 -2
- package/dist/package.json +2 -2
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js.map +1 -1
- package/dist/src/platforms/channeltalk/cli.d.ts.map +1 -1
- package/dist/src/platforms/channeltalk/client.d.ts.map +1 -1
- package/dist/src/platforms/channeltalk/commands/auth.js.map +1 -1
- package/dist/src/platforms/channeltalk/commands/bot.js.map +1 -1
- package/dist/src/platforms/channeltalk/commands/chat.js.map +1 -1
- package/dist/src/platforms/channeltalk/commands/group.js.map +1 -1
- package/dist/src/platforms/channeltalk/commands/manager.js.map +1 -1
- package/dist/src/platforms/channeltalk/commands/message.js.map +1 -1
- package/dist/src/platforms/channeltalk/commands/shared.js.map +1 -1
- package/dist/src/platforms/channeltalk/commands/snapshot.js.map +1 -1
- package/dist/src/platforms/channeltalk/commands/whoami.js.map +1 -1
- package/dist/src/platforms/channeltalk/credential-manager.d.ts.map +1 -1
- package/dist/src/platforms/channeltalk/ensure-auth.d.ts.map +1 -1
- package/dist/src/platforms/channeltalk/token-extractor.d.ts.map +1 -1
- package/dist/src/platforms/channeltalk/token-extractor.js.map +1 -1
- package/dist/src/platforms/channeltalk/types.d.ts.map +1 -1
- package/dist/src/platforms/channeltalkbot/cli.d.ts.map +1 -1
- package/dist/src/platforms/channeltalkbot/client.d.ts.map +1 -1
- package/dist/src/platforms/channeltalkbot/client.js.map +1 -1
- package/dist/src/platforms/channeltalkbot/credential-manager.d.ts.map +1 -1
- package/dist/src/platforms/channeltalkbot/types.d.ts +1 -1
- package/dist/src/platforms/channeltalkbot/types.d.ts.map +1 -1
- package/dist/src/platforms/discord/cli.d.ts.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 +145 -21
- 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/credential-manager.d.ts.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/remote-auth.js.map +1 -1
- package/dist/src/platforms/discord/super-properties.js.map +1 -1
- package/dist/src/platforms/discord/token-extractor.d.ts.map +1 -1
- package/dist/src/platforms/discord/token-extractor.js.map +1 -1
- package/dist/src/platforms/discord/types.d.ts +56 -9
- package/dist/src/platforms/discord/types.d.ts.map +1 -1
- package/dist/src/platforms/discord/types.js +28 -7
- package/dist/src/platforms/discord/types.js.map +1 -1
- package/dist/src/platforms/discordbot/cli.d.ts.map +1 -1
- package/dist/src/platforms/discordbot/client.d.ts.map +1 -1
- package/dist/src/platforms/discordbot/client.js.map +1 -1
- package/dist/src/platforms/discordbot/credential-manager.d.ts.map +1 -1
- package/dist/src/platforms/discordbot/listener.d.ts.map +1 -1
- package/dist/src/platforms/discordbot/listener.js.map +1 -1
- package/dist/src/platforms/discordbot/types.d.ts.map +1 -1
- package/dist/src/platforms/imessage/cli.d.ts.map +1 -1
- package/dist/src/platforms/imessage/client.d.ts.map +1 -1
- package/dist/src/platforms/imessage/client.js.map +1 -1
- package/dist/src/platforms/imessage/credential-manager.d.ts.map +1 -1
- package/dist/src/platforms/imessage/errors.js.map +1 -1
- package/dist/src/platforms/imessage/rpc.d.ts.map +1 -1
- package/dist/src/platforms/imessage/rpc.js.map +1 -1
- package/dist/src/platforms/imessage/types.d.ts.map +1 -1
- package/dist/src/platforms/imessage/types.js.map +1 -1
- package/dist/src/platforms/instagram/cli.d.ts.map +1 -1
- package/dist/src/platforms/instagram/client.d.ts.map +1 -1
- package/dist/src/platforms/instagram/client.js.map +1 -1
- package/dist/src/platforms/instagram/credential-manager.d.ts.map +1 -1
- package/dist/src/platforms/instagram/hybrid-listener.d.ts.map +1 -1
- package/dist/src/platforms/instagram/hybrid-listener.js.map +1 -1
- package/dist/src/platforms/instagram/listener.d.ts.map +1 -1
- package/dist/src/platforms/instagram/mqtt/thrift.d.ts.map +1 -1
- package/dist/src/platforms/instagram/mqtt/transport.d.ts.map +1 -1
- package/dist/src/platforms/instagram/mqtt/transport.js.map +1 -1
- package/dist/src/platforms/instagram/realtime-listener.d.ts.map +1 -1
- package/dist/src/platforms/instagram/realtime-listener.js.map +1 -1
- package/dist/src/platforms/instagram/token-extractor.d.ts.map +1 -1
- package/dist/src/platforms/instagram/types.d.ts.map +1 -1
- package/dist/src/platforms/instagram/types.js.map +1 -1
- package/dist/src/platforms/kakaotalk/cli.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/client.d.ts +5 -1
- package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/client.js +55 -0
- package/dist/src/platforms/kakaotalk/client.js.map +1 -1
- package/dist/src/platforms/kakaotalk/commands/chat.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/commands/chat.js +16 -1
- package/dist/src/platforms/kakaotalk/commands/chat.js.map +1 -1
- package/dist/src/platforms/kakaotalk/credential-manager.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/index.d.ts +2 -2
- package/dist/src/platforms/kakaotalk/index.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/index.js +1 -1
- package/dist/src/platforms/kakaotalk/index.js.map +1 -1
- package/dist/src/platforms/kakaotalk/listener.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/listener.js.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/connection.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/connection.js.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/crypto.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/session.d.ts +23 -0
- package/dist/src/platforms/kakaotalk/protocol/session.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/session.js +50 -0
- package/dist/src/platforms/kakaotalk/protocol/session.js.map +1 -1
- package/dist/src/platforms/kakaotalk/token-extractor.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/types.d.ts +30 -10
- package/dist/src/platforms/kakaotalk/types.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/types.js +10 -0
- package/dist/src/platforms/kakaotalk/types.js.map +1 -1
- package/dist/src/platforms/line/cli.d.ts.map +1 -1
- package/dist/src/platforms/line/client.d.ts.map +1 -1
- package/dist/src/platforms/line/credential-manager.d.ts.map +1 -1
- package/dist/src/platforms/line/listener.d.ts.map +1 -1
- package/dist/src/platforms/line/types.d.ts +6 -6
- package/dist/src/platforms/line/types.d.ts.map +1 -1
- package/dist/src/platforms/slack/cli.d.ts.map +1 -1
- package/dist/src/platforms/slack/client.d.ts.map +1 -1
- package/dist/src/platforms/slack/client.js.map +1 -1
- package/dist/src/platforms/slack/credential-manager.d.ts.map +1 -1
- package/dist/src/platforms/slack/ensure-auth.js.map +1 -1
- package/dist/src/platforms/slack/listener.d.ts.map +1 -1
- package/dist/src/platforms/slack/qr-http-login.js.map +1 -1
- package/dist/src/platforms/slack/token-extractor.d.ts.map +1 -1
- package/dist/src/platforms/slack/token-extractor.js.map +1 -1
- package/dist/src/platforms/slackbot/cli.d.ts.map +1 -1
- package/dist/src/platforms/slackbot/client.d.ts.map +1 -1
- package/dist/src/platforms/slackbot/credential-manager.d.ts.map +1 -1
- package/dist/src/platforms/slackbot/listener.d.ts.map +1 -1
- package/dist/src/platforms/slackbot/listener.js.map +1 -1
- package/dist/src/platforms/slackbot/types.d.ts.map +1 -1
- package/dist/src/platforms/teams/app-config.js.map +1 -1
- package/dist/src/platforms/teams/cli.d.ts.map +1 -1
- package/dist/src/platforms/teams/client.d.ts.map +1 -1
- package/dist/src/platforms/teams/client.js.map +1 -1
- package/dist/src/platforms/teams/credential-manager.d.ts.map +1 -1
- package/dist/src/platforms/teams/device-code.js.map +1 -1
- package/dist/src/platforms/teams/device-login.d.ts.map +1 -1
- package/dist/src/platforms/teams/device-login.js.map +1 -1
- package/dist/src/platforms/teams/listener.d.ts.map +1 -1
- package/dist/src/platforms/teams/token-extractor.d.ts.map +1 -1
- package/dist/src/platforms/teams/token-extractor.js.map +1 -1
- package/dist/src/platforms/teams/token-provider.d.ts.map +1 -1
- package/dist/src/platforms/teams/token-provider.js.map +1 -1
- package/dist/src/platforms/teams/types.d.ts +14 -14
- package/dist/src/platforms/teams/types.d.ts.map +1 -1
- package/dist/src/platforms/telegram/cli.d.ts.map +1 -1
- package/dist/src/platforms/telegram/client.d.ts.map +1 -1
- package/dist/src/platforms/telegram/client.js.map +1 -1
- package/dist/src/platforms/telegram/commands/shared.js.map +1 -1
- package/dist/src/platforms/telegram/credential-manager.d.ts.map +1 -1
- package/dist/src/platforms/telegram/tdlib.d.ts.map +1 -1
- package/dist/src/platforms/telegram/types.d.ts.map +1 -1
- package/dist/src/platforms/telegram/types.js.map +1 -1
- package/dist/src/platforms/telegrambot/cli.d.ts.map +1 -1
- package/dist/src/platforms/telegrambot/client.d.ts.map +1 -1
- package/dist/src/platforms/telegrambot/credential-manager.d.ts.map +1 -1
- package/dist/src/platforms/telegrambot/listener.d.ts.map +1 -1
- package/dist/src/platforms/telegrambot/types.d.ts.map +1 -1
- package/dist/src/platforms/webex/cli.d.ts.map +1 -1
- package/dist/src/platforms/webex/client.d.ts.map +1 -1
- package/dist/src/platforms/webex/client.js.map +1 -1
- package/dist/src/platforms/webex/credential-manager.d.ts.map +1 -1
- package/dist/src/platforms/webex/encryption.d.ts.map +1 -1
- package/dist/src/platforms/webex/kms-key-provider.d.ts.map +1 -1
- package/dist/src/platforms/webex/listener.d.ts.map +1 -1
- package/dist/src/platforms/webex/listener.js.map +1 -1
- package/dist/src/platforms/webex/token-extractor.d.ts.map +1 -1
- package/dist/src/platforms/webex/types.d.ts +4 -4
- package/dist/src/platforms/webex/types.d.ts.map +1 -1
- package/dist/src/platforms/webexbot/cli.d.ts.map +1 -1
- package/dist/src/platforms/webexbot/client.d.ts.map +1 -1
- package/dist/src/platforms/webexbot/credential-manager.d.ts.map +1 -1
- package/dist/src/platforms/webexbot/listener.d.ts.map +1 -1
- package/dist/src/platforms/webexbot/types.d.ts.map +1 -1
- package/dist/src/platforms/wechatbot/cli.d.ts.map +1 -1
- package/dist/src/platforms/wechatbot/client.d.ts.map +1 -1
- package/dist/src/platforms/wechatbot/credential-manager.d.ts.map +1 -1
- package/dist/src/platforms/wechatbot/types.d.ts.map +1 -1
- package/dist/src/platforms/whatsapp/cli.d.ts.map +1 -1
- package/dist/src/platforms/whatsapp/client.d.ts.map +1 -1
- package/dist/src/platforms/whatsapp/credential-manager.d.ts.map +1 -1
- package/dist/src/platforms/whatsapp/types.d.ts.map +1 -1
- package/dist/src/platforms/whatsapp/types.js.map +1 -1
- package/dist/src/platforms/whatsappbot/cli.d.ts.map +1 -1
- package/dist/src/platforms/whatsappbot/client.d.ts.map +1 -1
- package/dist/src/platforms/whatsappbot/credential-manager.d.ts.map +1 -1
- package/dist/src/platforms/whatsappbot/types.d.ts.map +1 -1
- package/dist/src/shared/chromium/browsers.js.map +1 -1
- package/dist/src/shared/chromium/cli-options.js.map +1 -1
- package/dist/src/shared/chromium/cookie-reader.d.ts.map +1 -1
- package/dist/src/shared/chromium/decryptor.d.ts.map +1 -1
- package/dist/src/shared/sqlite.js.map +1 -1
- package/dist/src/shared/utils/derived-key-cache.d.ts.map +1 -1
- package/dist/src/tui/adapters/channeltalk-adapter.d.ts.map +1 -1
- package/dist/src/tui/adapters/discord-adapter.d.ts.map +1 -1
- package/dist/src/tui/adapters/imessage-adapter.d.ts.map +1 -1
- package/dist/src/tui/adapters/instagram-adapter.d.ts.map +1 -1
- package/dist/src/tui/adapters/kakaotalk-adapter.d.ts.map +1 -1
- package/dist/src/tui/adapters/line-adapter.d.ts.map +1 -1
- package/dist/src/tui/adapters/slack-adapter.d.ts.map +1 -1
- package/dist/src/tui/adapters/teams-adapter.d.ts.map +1 -1
- package/dist/src/tui/adapters/telegram-adapter.d.ts.map +1 -1
- package/dist/src/tui/adapters/telegram-adapter.js.map +1 -1
- package/dist/src/tui/adapters/webex-adapter.d.ts.map +1 -1
- package/dist/src/tui/adapters/whatsapp-adapter.d.ts.map +1 -1
- package/dist/src/tui/app.js.map +1 -1
- package/dist/src/tui/views/channel-picker.d.ts.map +1 -1
- package/dist/src/tui/views/workspace-picker.d.ts.map +1 -1
- package/docs/content/docs/cli/discord.mdx +7 -0
- package/docs/content/docs/cli/kakaotalk.mdx +16 -1
- package/docs/content/docs/sdk/discord.mdx +38 -0
- package/docs/content/docs/sdk/kakaotalk.mdx +39 -0
- package/e2e/README.md +2 -2
- package/e2e/discord.e2e.test.ts +16 -2
- package/package.json +2 -2
- 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 +19 -2
- 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 +351 -9
- package/src/platforms/discord/client.ts +166 -26
- 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 +66 -10
- package/src/platforms/kakaotalk/client.test.ts +194 -1
- package/src/platforms/kakaotalk/client.ts +58 -0
- package/src/platforms/kakaotalk/commands/chat.test.ts +24 -0
- package/src/platforms/kakaotalk/commands/chat.ts +23 -0
- package/src/platforms/kakaotalk/index.test.ts +10 -0
- package/src/platforms/kakaotalk/index.ts +4 -0
- package/src/platforms/kakaotalk/protocol/session.test.ts +113 -0
- package/src/platforms/kakaotalk/protocol/session.ts +56 -0
- package/src/platforms/kakaotalk/types.ts +24 -0
- package/tsconfig.json +8 -8
|
@@ -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
|
|
|
@@ -447,6 +475,127 @@ describe('DiscordClient', () => {
|
|
|
447
475
|
})
|
|
448
476
|
})
|
|
449
477
|
|
|
478
|
+
describe('searchMessages', () => {
|
|
479
|
+
const searchResult = {
|
|
480
|
+
id: 'msg1',
|
|
481
|
+
channel_id: 'ch1',
|
|
482
|
+
guild_id: 'guild1',
|
|
483
|
+
author: { id: 'user1', username: 'user1' },
|
|
484
|
+
content: 'matching message',
|
|
485
|
+
timestamp: '2026-01-01T00:00:00.000Z',
|
|
486
|
+
hit: true,
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
it('flattens grouped search results and keeps hits', async () => {
|
|
490
|
+
mockResponse({
|
|
491
|
+
doing_deep_historical_index: false,
|
|
492
|
+
total_results: 1,
|
|
493
|
+
messages: [[searchResult, { ...searchResult, id: 'context', hit: false }]],
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
const client = await new DiscordClient().login({ token: 'test-token' })
|
|
497
|
+
const result = await client.searchMessages('guild1', 'matching', { limit: 25 })
|
|
498
|
+
|
|
499
|
+
expect(result).toEqual({ results: [searchResult], total: 1 })
|
|
500
|
+
expect(fetchCalls[0].url).toBe(
|
|
501
|
+
'https://discord.com/api/v10/guilds/guild1/messages/search?content=matching&limit=25',
|
|
502
|
+
)
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
it('retries when the search index is not ready', async () => {
|
|
506
|
+
mockResponse(
|
|
507
|
+
{
|
|
508
|
+
message: 'Index not yet available. Try again later',
|
|
509
|
+
code: 110000,
|
|
510
|
+
documents_indexed: 0,
|
|
511
|
+
retry_after: 0,
|
|
512
|
+
},
|
|
513
|
+
202,
|
|
514
|
+
)
|
|
515
|
+
mockResponse({
|
|
516
|
+
doing_deep_historical_index: false,
|
|
517
|
+
total_results: 1,
|
|
518
|
+
messages: [[searchResult]],
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
const client = await new DiscordClient().login({ token: 'test-token' })
|
|
522
|
+
const startTime = Date.now()
|
|
523
|
+
const result = await client.searchMessages('guild1', 'matching')
|
|
524
|
+
const elapsed = Date.now() - startTime
|
|
525
|
+
|
|
526
|
+
expect(result).toEqual({ results: [searchResult], total: 1 })
|
|
527
|
+
expect(elapsed).toBeGreaterThanOrEqual(50)
|
|
528
|
+
expect(fetchCalls).toHaveLength(2)
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
it('throws a clear error when the search index remains unavailable', async () => {
|
|
532
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
533
|
+
mockResponse(
|
|
534
|
+
{
|
|
535
|
+
message: 'Index not yet available. Try again later',
|
|
536
|
+
code: 110000,
|
|
537
|
+
documents_indexed: 0,
|
|
538
|
+
retry_after: 0.001,
|
|
539
|
+
},
|
|
540
|
+
202,
|
|
541
|
+
)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const client = await new DiscordClient().login({ token: 'test-token' })
|
|
545
|
+
|
|
546
|
+
await expect(client.searchMessages('guild1', 'matching')).rejects.toThrow(
|
|
547
|
+
'Search index is not ready after retries',
|
|
548
|
+
)
|
|
549
|
+
expect(fetchCalls).toHaveLength(4)
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
it('rejects unexpected successful response payloads', async () => {
|
|
553
|
+
mockResponse(
|
|
554
|
+
{
|
|
555
|
+
message: 'Unexpected response',
|
|
556
|
+
code: 999999,
|
|
557
|
+
retry_after: 0,
|
|
558
|
+
},
|
|
559
|
+
202,
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
const client = await new DiscordClient().login({ token: 'test-token' })
|
|
563
|
+
|
|
564
|
+
try {
|
|
565
|
+
await client.searchMessages('guild1', 'matching')
|
|
566
|
+
throw new Error('Expected searchMessages to throw')
|
|
567
|
+
} catch (error) {
|
|
568
|
+
expect(error).toBeInstanceOf(DiscordError)
|
|
569
|
+
if (!(error instanceof DiscordError)) throw error
|
|
570
|
+
expect(error.code).toBe('invalid_search_response')
|
|
571
|
+
}
|
|
572
|
+
expect(fetchCalls).toHaveLength(1)
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
it('rejects malformed message and retry payloads', async () => {
|
|
576
|
+
mockResponse({ total_results: 1, messages: null })
|
|
577
|
+
mockResponse(
|
|
578
|
+
{
|
|
579
|
+
message: 'Index not yet available. Try again later',
|
|
580
|
+
code: 110000,
|
|
581
|
+
documents_indexed: 0,
|
|
582
|
+
retry_after: -1,
|
|
583
|
+
},
|
|
584
|
+
202,
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
const client = await new DiscordClient().login({ token: 'test-token' })
|
|
588
|
+
|
|
589
|
+
await expect(client.searchMessages('guild1', 'matching')).rejects.toThrow(
|
|
590
|
+
'Discord returned an invalid search response',
|
|
591
|
+
)
|
|
592
|
+
await expect(client.searchMessages('guild1', 'matching')).rejects.toThrow(
|
|
593
|
+
'Discord returned an invalid search response',
|
|
594
|
+
)
|
|
595
|
+
expect(fetchCalls).toHaveLength(2)
|
|
596
|
+
})
|
|
597
|
+
})
|
|
598
|
+
|
|
450
599
|
describe('rate limiting', () => {
|
|
451
600
|
it('waits when bucket is exhausted before making request', async () => {
|
|
452
601
|
mockResponse({ id: '1', username: 'user1' }, 200, {
|
|
@@ -553,5 +702,198 @@ describe('DiscordClient', () => {
|
|
|
553
702
|
|
|
554
703
|
expect(fetchCalls.length).toBe(2)
|
|
555
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
|
+
})
|
|
556
898
|
})
|
|
557
899
|
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises'
|
|
2
2
|
|
|
3
3
|
import { getDiscordHeaders } from './super-properties'
|
|
4
|
+
import { DiscordSearchIndexNotReadyResponseSchema, DiscordSearchResponseSchema } from './types'
|
|
4
5
|
import type {
|
|
5
6
|
DiscordChannel,
|
|
6
7
|
DiscordDMChannel,
|
|
@@ -9,15 +10,29 @@ import type {
|
|
|
9
10
|
DiscordGuildMember,
|
|
10
11
|
DiscordMention,
|
|
11
12
|
DiscordMessage,
|
|
13
|
+
DiscordReadState,
|
|
12
14
|
DiscordRelationship,
|
|
13
15
|
DiscordSearchOptions,
|
|
14
|
-
DiscordSearchResponse,
|
|
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
|
|
|
@@ -37,6 +52,7 @@ interface RateLimitBucket {
|
|
|
37
52
|
const BASE_URL = 'https://discord.com/api/v10'
|
|
38
53
|
const MAX_RETRIES = 3
|
|
39
54
|
const BASE_BACKOFF_MS = 100
|
|
55
|
+
const MAX_SEARCH_INDEX_RETRY_MS = 30_000
|
|
40
56
|
|
|
41
57
|
export class DiscordClient {
|
|
42
58
|
private token: string | null = null
|
|
@@ -74,7 +90,9 @@ export class DiscordClient {
|
|
|
74
90
|
}
|
|
75
91
|
|
|
76
92
|
private getBucketKey(method: string, path: string): string {
|
|
77
|
-
const
|
|
93
|
+
const queryIndex = path.indexOf('?')
|
|
94
|
+
const route = queryIndex === -1 ? path : path.slice(0, queryIndex)
|
|
95
|
+
const normalized = route
|
|
78
96
|
.replace(/\/channels\/\d+/, '/channels/{channel_id}')
|
|
79
97
|
.replace(/\/guilds\/\d+/, '/guilds/{guild_id}')
|
|
80
98
|
.replace(/\/users\/\d+/, '/users/{user_id}')
|
|
@@ -234,12 +252,22 @@ export class DiscordClient {
|
|
|
234
252
|
return this.request<DiscordMessage>('POST', `/channels/${channelId}/messages`, body)
|
|
235
253
|
}
|
|
236
254
|
|
|
237
|
-
async getMessages(channelId: string, limit: number = 50): Promise<DiscordMessage[]> {
|
|
238
|
-
|
|
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}`)
|
|
239
262
|
}
|
|
240
263
|
|
|
241
264
|
async getMessage(channelId: string, messageId: string): Promise<DiscordMessage> {
|
|
242
|
-
|
|
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
|
|
243
271
|
}
|
|
244
272
|
|
|
245
273
|
async editMessage(channelId: string, messageId: string, content: string): Promise<DiscordMessage> {
|
|
@@ -321,7 +349,7 @@ export class DiscordClient {
|
|
|
321
349
|
})
|
|
322
350
|
}
|
|
323
351
|
|
|
324
|
-
async getMentions(options?: { limit?: number; guildId?: string }): Promise<DiscordMention[]> {
|
|
352
|
+
async getMentions(options?: { limit?: number; guildId?: string; before?: string }): Promise<DiscordMention[]> {
|
|
325
353
|
const params = new URLSearchParams()
|
|
326
354
|
params.set('limit', (options?.limit ?? 25).toString())
|
|
327
355
|
params.set('roles', 'true')
|
|
@@ -330,10 +358,100 @@ export class DiscordClient {
|
|
|
330
358
|
if (options?.guildId) {
|
|
331
359
|
params.set('guild_id', options.guildId)
|
|
332
360
|
}
|
|
361
|
+
if (options?.before) {
|
|
362
|
+
params.set('before', options.before)
|
|
363
|
+
}
|
|
333
364
|
|
|
334
365
|
return this.request<DiscordMention[]>('GET', `/users/@me/mentions?${params.toString()}`)
|
|
335
366
|
}
|
|
336
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
|
+
|
|
337
455
|
async getUserNote(userId: string): Promise<DiscordUserNote | null> {
|
|
338
456
|
try {
|
|
339
457
|
return await this.request<DiscordUserNote>('GET', `/users/@me/notes/${userId}`)
|
|
@@ -393,28 +511,50 @@ export class DiscordClient {
|
|
|
393
511
|
params.set('offset', options.offset.toString())
|
|
394
512
|
}
|
|
395
513
|
|
|
396
|
-
const
|
|
397
|
-
'GET',
|
|
398
|
-
`/guilds/${guildId}/messages/search?${params.toString()}`,
|
|
399
|
-
)
|
|
400
|
-
|
|
401
|
-
const results = response.messages
|
|
402
|
-
.flat()
|
|
403
|
-
.filter((msg) => msg.hit)
|
|
404
|
-
.map((msg) => ({
|
|
405
|
-
id: msg.id,
|
|
406
|
-
channel_id: msg.channel_id,
|
|
407
|
-
guild_id: msg.guild_id,
|
|
408
|
-
content: msg.content,
|
|
409
|
-
author: msg.author,
|
|
410
|
-
timestamp: msg.timestamp,
|
|
411
|
-
hit: msg.hit,
|
|
412
|
-
}))
|
|
514
|
+
const path = `/guilds/${guildId}/messages/search?${params.toString()}`
|
|
413
515
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
516
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
517
|
+
const response: unknown = await this.request('GET', path)
|
|
518
|
+
const searchResponse = DiscordSearchResponseSchema.safeParse(response)
|
|
519
|
+
|
|
520
|
+
if (searchResponse.success) {
|
|
521
|
+
const results = searchResponse.data.messages
|
|
522
|
+
.flat()
|
|
523
|
+
.filter((msg) => msg.hit)
|
|
524
|
+
.map((msg) => ({
|
|
525
|
+
id: msg.id,
|
|
526
|
+
channel_id: msg.channel_id,
|
|
527
|
+
guild_id: msg.guild_id,
|
|
528
|
+
content: msg.content,
|
|
529
|
+
author: msg.author,
|
|
530
|
+
timestamp: msg.timestamp,
|
|
531
|
+
hit: msg.hit,
|
|
532
|
+
}))
|
|
533
|
+
|
|
534
|
+
return {
|
|
535
|
+
results,
|
|
536
|
+
total: searchResponse.data.total_results,
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const indexNotReadyResponse = DiscordSearchIndexNotReadyResponseSchema.safeParse(response)
|
|
541
|
+
if (!indexNotReadyResponse.success) {
|
|
542
|
+
throw new DiscordError('Discord returned an invalid search response', 'invalid_search_response')
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (attempt === MAX_RETRIES) {
|
|
546
|
+
throw new DiscordError('Search index is not ready after retries', 'search_index_not_ready')
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const requestedDelayMs = indexNotReadyResponse.data.retry_after * 1000
|
|
550
|
+
const retryDelayMs = Math.min(
|
|
551
|
+
requestedDelayMs === 0 ? BASE_BACKOFF_MS : requestedDelayMs,
|
|
552
|
+
MAX_SEARCH_INDEX_RETRY_MS,
|
|
553
|
+
)
|
|
554
|
+
await this.sleep(retryDelayMs)
|
|
417
555
|
}
|
|
556
|
+
|
|
557
|
+
throw new DiscordError('Search failed after retries', 'max_retries')
|
|
418
558
|
}
|
|
419
559
|
|
|
420
560
|
async getUserProfile(userId: string): Promise<DiscordUserProfile> {
|