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.
Files changed (258) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +14 -1
  3. package/bun.lock +42 -2
  4. package/dist/package.json +2 -2
  5. package/dist/src/cli.d.ts.map +1 -1
  6. package/dist/src/cli.js.map +1 -1
  7. package/dist/src/platforms/channeltalk/cli.d.ts.map +1 -1
  8. package/dist/src/platforms/channeltalk/client.d.ts.map +1 -1
  9. package/dist/src/platforms/channeltalk/commands/auth.js.map +1 -1
  10. package/dist/src/platforms/channeltalk/commands/bot.js.map +1 -1
  11. package/dist/src/platforms/channeltalk/commands/chat.js.map +1 -1
  12. package/dist/src/platforms/channeltalk/commands/group.js.map +1 -1
  13. package/dist/src/platforms/channeltalk/commands/manager.js.map +1 -1
  14. package/dist/src/platforms/channeltalk/commands/message.js.map +1 -1
  15. package/dist/src/platforms/channeltalk/commands/shared.js.map +1 -1
  16. package/dist/src/platforms/channeltalk/commands/snapshot.js.map +1 -1
  17. package/dist/src/platforms/channeltalk/commands/whoami.js.map +1 -1
  18. package/dist/src/platforms/channeltalk/credential-manager.d.ts.map +1 -1
  19. package/dist/src/platforms/channeltalk/ensure-auth.d.ts.map +1 -1
  20. package/dist/src/platforms/channeltalk/token-extractor.d.ts.map +1 -1
  21. package/dist/src/platforms/channeltalk/token-extractor.js.map +1 -1
  22. package/dist/src/platforms/channeltalk/types.d.ts.map +1 -1
  23. package/dist/src/platforms/channeltalkbot/cli.d.ts.map +1 -1
  24. package/dist/src/platforms/channeltalkbot/client.d.ts.map +1 -1
  25. package/dist/src/platforms/channeltalkbot/client.js.map +1 -1
  26. package/dist/src/platforms/channeltalkbot/credential-manager.d.ts.map +1 -1
  27. package/dist/src/platforms/channeltalkbot/types.d.ts +1 -1
  28. package/dist/src/platforms/channeltalkbot/types.d.ts.map +1 -1
  29. package/dist/src/platforms/discord/cli.d.ts.map +1 -1
  30. package/dist/src/platforms/discord/client.d.ts +11 -2
  31. package/dist/src/platforms/discord/client.d.ts.map +1 -1
  32. package/dist/src/platforms/discord/client.js +145 -21
  33. package/dist/src/platforms/discord/client.js.map +1 -1
  34. package/dist/src/platforms/discord/commands/mention.d.ts +5 -0
  35. package/dist/src/platforms/discord/commands/mention.d.ts.map +1 -1
  36. package/dist/src/platforms/discord/commands/mention.js +60 -4
  37. package/dist/src/platforms/discord/commands/mention.js.map +1 -1
  38. package/dist/src/platforms/discord/commands/reaction.js +1 -1
  39. package/dist/src/platforms/discord/commands/reaction.js.map +1 -1
  40. package/dist/src/platforms/discord/credential-manager.d.ts.map +1 -1
  41. package/dist/src/platforms/discord/index.d.ts +1 -1
  42. package/dist/src/platforms/discord/index.d.ts.map +1 -1
  43. package/dist/src/platforms/discord/index.js.map +1 -1
  44. package/dist/src/platforms/discord/listener.d.ts +4 -1
  45. package/dist/src/platforms/discord/listener.d.ts.map +1 -1
  46. package/dist/src/platforms/discord/listener.js +28 -1
  47. package/dist/src/platforms/discord/listener.js.map +1 -1
  48. package/dist/src/platforms/discord/remote-auth.js.map +1 -1
  49. package/dist/src/platforms/discord/super-properties.js.map +1 -1
  50. package/dist/src/platforms/discord/token-extractor.d.ts.map +1 -1
  51. package/dist/src/platforms/discord/token-extractor.js.map +1 -1
  52. package/dist/src/platforms/discord/types.d.ts +56 -9
  53. package/dist/src/platforms/discord/types.d.ts.map +1 -1
  54. package/dist/src/platforms/discord/types.js +28 -7
  55. package/dist/src/platforms/discord/types.js.map +1 -1
  56. package/dist/src/platforms/discordbot/cli.d.ts.map +1 -1
  57. package/dist/src/platforms/discordbot/client.d.ts.map +1 -1
  58. package/dist/src/platforms/discordbot/client.js.map +1 -1
  59. package/dist/src/platforms/discordbot/credential-manager.d.ts.map +1 -1
  60. package/dist/src/platforms/discordbot/listener.d.ts.map +1 -1
  61. package/dist/src/platforms/discordbot/listener.js.map +1 -1
  62. package/dist/src/platforms/discordbot/types.d.ts.map +1 -1
  63. package/dist/src/platforms/imessage/cli.d.ts.map +1 -1
  64. package/dist/src/platforms/imessage/client.d.ts.map +1 -1
  65. package/dist/src/platforms/imessage/client.js.map +1 -1
  66. package/dist/src/platforms/imessage/credential-manager.d.ts.map +1 -1
  67. package/dist/src/platforms/imessage/errors.js.map +1 -1
  68. package/dist/src/platforms/imessage/rpc.d.ts.map +1 -1
  69. package/dist/src/platforms/imessage/rpc.js.map +1 -1
  70. package/dist/src/platforms/imessage/types.d.ts.map +1 -1
  71. package/dist/src/platforms/imessage/types.js.map +1 -1
  72. package/dist/src/platforms/instagram/cli.d.ts.map +1 -1
  73. package/dist/src/platforms/instagram/client.d.ts.map +1 -1
  74. package/dist/src/platforms/instagram/client.js.map +1 -1
  75. package/dist/src/platforms/instagram/credential-manager.d.ts.map +1 -1
  76. package/dist/src/platforms/instagram/hybrid-listener.d.ts.map +1 -1
  77. package/dist/src/platforms/instagram/hybrid-listener.js.map +1 -1
  78. package/dist/src/platforms/instagram/listener.d.ts.map +1 -1
  79. package/dist/src/platforms/instagram/mqtt/thrift.d.ts.map +1 -1
  80. package/dist/src/platforms/instagram/mqtt/transport.d.ts.map +1 -1
  81. package/dist/src/platforms/instagram/mqtt/transport.js.map +1 -1
  82. package/dist/src/platforms/instagram/realtime-listener.d.ts.map +1 -1
  83. package/dist/src/platforms/instagram/realtime-listener.js.map +1 -1
  84. package/dist/src/platforms/instagram/token-extractor.d.ts.map +1 -1
  85. package/dist/src/platforms/instagram/types.d.ts.map +1 -1
  86. package/dist/src/platforms/instagram/types.js.map +1 -1
  87. package/dist/src/platforms/kakaotalk/cli.d.ts.map +1 -1
  88. package/dist/src/platforms/kakaotalk/client.d.ts +5 -1
  89. package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
  90. package/dist/src/platforms/kakaotalk/client.js +55 -0
  91. package/dist/src/platforms/kakaotalk/client.js.map +1 -1
  92. package/dist/src/platforms/kakaotalk/commands/chat.d.ts.map +1 -1
  93. package/dist/src/platforms/kakaotalk/commands/chat.js +16 -1
  94. package/dist/src/platforms/kakaotalk/commands/chat.js.map +1 -1
  95. package/dist/src/platforms/kakaotalk/credential-manager.d.ts.map +1 -1
  96. package/dist/src/platforms/kakaotalk/index.d.ts +2 -2
  97. package/dist/src/platforms/kakaotalk/index.d.ts.map +1 -1
  98. package/dist/src/platforms/kakaotalk/index.js +1 -1
  99. package/dist/src/platforms/kakaotalk/index.js.map +1 -1
  100. package/dist/src/platforms/kakaotalk/listener.d.ts.map +1 -1
  101. package/dist/src/platforms/kakaotalk/listener.js.map +1 -1
  102. package/dist/src/platforms/kakaotalk/protocol/connection.d.ts.map +1 -1
  103. package/dist/src/platforms/kakaotalk/protocol/connection.js.map +1 -1
  104. package/dist/src/platforms/kakaotalk/protocol/crypto.d.ts.map +1 -1
  105. package/dist/src/platforms/kakaotalk/protocol/session.d.ts +23 -0
  106. package/dist/src/platforms/kakaotalk/protocol/session.d.ts.map +1 -1
  107. package/dist/src/platforms/kakaotalk/protocol/session.js +50 -0
  108. package/dist/src/platforms/kakaotalk/protocol/session.js.map +1 -1
  109. package/dist/src/platforms/kakaotalk/token-extractor.d.ts.map +1 -1
  110. package/dist/src/platforms/kakaotalk/types.d.ts +30 -10
  111. package/dist/src/platforms/kakaotalk/types.d.ts.map +1 -1
  112. package/dist/src/platforms/kakaotalk/types.js +10 -0
  113. package/dist/src/platforms/kakaotalk/types.js.map +1 -1
  114. package/dist/src/platforms/line/cli.d.ts.map +1 -1
  115. package/dist/src/platforms/line/client.d.ts.map +1 -1
  116. package/dist/src/platforms/line/credential-manager.d.ts.map +1 -1
  117. package/dist/src/platforms/line/listener.d.ts.map +1 -1
  118. package/dist/src/platforms/line/types.d.ts +6 -6
  119. package/dist/src/platforms/line/types.d.ts.map +1 -1
  120. package/dist/src/platforms/slack/cli.d.ts.map +1 -1
  121. package/dist/src/platforms/slack/client.d.ts.map +1 -1
  122. package/dist/src/platforms/slack/client.js.map +1 -1
  123. package/dist/src/platforms/slack/credential-manager.d.ts.map +1 -1
  124. package/dist/src/platforms/slack/ensure-auth.js.map +1 -1
  125. package/dist/src/platforms/slack/listener.d.ts.map +1 -1
  126. package/dist/src/platforms/slack/qr-http-login.js.map +1 -1
  127. package/dist/src/platforms/slack/token-extractor.d.ts.map +1 -1
  128. package/dist/src/platforms/slack/token-extractor.js.map +1 -1
  129. package/dist/src/platforms/slackbot/cli.d.ts.map +1 -1
  130. package/dist/src/platforms/slackbot/client.d.ts.map +1 -1
  131. package/dist/src/platforms/slackbot/credential-manager.d.ts.map +1 -1
  132. package/dist/src/platforms/slackbot/listener.d.ts.map +1 -1
  133. package/dist/src/platforms/slackbot/listener.js.map +1 -1
  134. package/dist/src/platforms/slackbot/types.d.ts.map +1 -1
  135. package/dist/src/platforms/teams/app-config.js.map +1 -1
  136. package/dist/src/platforms/teams/cli.d.ts.map +1 -1
  137. package/dist/src/platforms/teams/client.d.ts.map +1 -1
  138. package/dist/src/platforms/teams/client.js.map +1 -1
  139. package/dist/src/platforms/teams/credential-manager.d.ts.map +1 -1
  140. package/dist/src/platforms/teams/device-code.js.map +1 -1
  141. package/dist/src/platforms/teams/device-login.d.ts.map +1 -1
  142. package/dist/src/platforms/teams/device-login.js.map +1 -1
  143. package/dist/src/platforms/teams/listener.d.ts.map +1 -1
  144. package/dist/src/platforms/teams/token-extractor.d.ts.map +1 -1
  145. package/dist/src/platforms/teams/token-extractor.js.map +1 -1
  146. package/dist/src/platforms/teams/token-provider.d.ts.map +1 -1
  147. package/dist/src/platforms/teams/token-provider.js.map +1 -1
  148. package/dist/src/platforms/teams/types.d.ts +14 -14
  149. package/dist/src/platforms/teams/types.d.ts.map +1 -1
  150. package/dist/src/platforms/telegram/cli.d.ts.map +1 -1
  151. package/dist/src/platforms/telegram/client.d.ts.map +1 -1
  152. package/dist/src/platforms/telegram/client.js.map +1 -1
  153. package/dist/src/platforms/telegram/commands/shared.js.map +1 -1
  154. package/dist/src/platforms/telegram/credential-manager.d.ts.map +1 -1
  155. package/dist/src/platforms/telegram/tdlib.d.ts.map +1 -1
  156. package/dist/src/platforms/telegram/types.d.ts.map +1 -1
  157. package/dist/src/platforms/telegram/types.js.map +1 -1
  158. package/dist/src/platforms/telegrambot/cli.d.ts.map +1 -1
  159. package/dist/src/platforms/telegrambot/client.d.ts.map +1 -1
  160. package/dist/src/platforms/telegrambot/credential-manager.d.ts.map +1 -1
  161. package/dist/src/platforms/telegrambot/listener.d.ts.map +1 -1
  162. package/dist/src/platforms/telegrambot/types.d.ts.map +1 -1
  163. package/dist/src/platforms/webex/cli.d.ts.map +1 -1
  164. package/dist/src/platforms/webex/client.d.ts.map +1 -1
  165. package/dist/src/platforms/webex/client.js.map +1 -1
  166. package/dist/src/platforms/webex/credential-manager.d.ts.map +1 -1
  167. package/dist/src/platforms/webex/encryption.d.ts.map +1 -1
  168. package/dist/src/platforms/webex/kms-key-provider.d.ts.map +1 -1
  169. package/dist/src/platforms/webex/listener.d.ts.map +1 -1
  170. package/dist/src/platforms/webex/listener.js.map +1 -1
  171. package/dist/src/platforms/webex/token-extractor.d.ts.map +1 -1
  172. package/dist/src/platforms/webex/types.d.ts +4 -4
  173. package/dist/src/platforms/webex/types.d.ts.map +1 -1
  174. package/dist/src/platforms/webexbot/cli.d.ts.map +1 -1
  175. package/dist/src/platforms/webexbot/client.d.ts.map +1 -1
  176. package/dist/src/platforms/webexbot/credential-manager.d.ts.map +1 -1
  177. package/dist/src/platforms/webexbot/listener.d.ts.map +1 -1
  178. package/dist/src/platforms/webexbot/types.d.ts.map +1 -1
  179. package/dist/src/platforms/wechatbot/cli.d.ts.map +1 -1
  180. package/dist/src/platforms/wechatbot/client.d.ts.map +1 -1
  181. package/dist/src/platforms/wechatbot/credential-manager.d.ts.map +1 -1
  182. package/dist/src/platforms/wechatbot/types.d.ts.map +1 -1
  183. package/dist/src/platforms/whatsapp/cli.d.ts.map +1 -1
  184. package/dist/src/platforms/whatsapp/client.d.ts.map +1 -1
  185. package/dist/src/platforms/whatsapp/credential-manager.d.ts.map +1 -1
  186. package/dist/src/platforms/whatsapp/types.d.ts.map +1 -1
  187. package/dist/src/platforms/whatsapp/types.js.map +1 -1
  188. package/dist/src/platforms/whatsappbot/cli.d.ts.map +1 -1
  189. package/dist/src/platforms/whatsappbot/client.d.ts.map +1 -1
  190. package/dist/src/platforms/whatsappbot/credential-manager.d.ts.map +1 -1
  191. package/dist/src/platforms/whatsappbot/types.d.ts.map +1 -1
  192. package/dist/src/shared/chromium/browsers.js.map +1 -1
  193. package/dist/src/shared/chromium/cli-options.js.map +1 -1
  194. package/dist/src/shared/chromium/cookie-reader.d.ts.map +1 -1
  195. package/dist/src/shared/chromium/decryptor.d.ts.map +1 -1
  196. package/dist/src/shared/sqlite.js.map +1 -1
  197. package/dist/src/shared/utils/derived-key-cache.d.ts.map +1 -1
  198. package/dist/src/tui/adapters/channeltalk-adapter.d.ts.map +1 -1
  199. package/dist/src/tui/adapters/discord-adapter.d.ts.map +1 -1
  200. package/dist/src/tui/adapters/imessage-adapter.d.ts.map +1 -1
  201. package/dist/src/tui/adapters/instagram-adapter.d.ts.map +1 -1
  202. package/dist/src/tui/adapters/kakaotalk-adapter.d.ts.map +1 -1
  203. package/dist/src/tui/adapters/line-adapter.d.ts.map +1 -1
  204. package/dist/src/tui/adapters/slack-adapter.d.ts.map +1 -1
  205. package/dist/src/tui/adapters/teams-adapter.d.ts.map +1 -1
  206. package/dist/src/tui/adapters/telegram-adapter.d.ts.map +1 -1
  207. package/dist/src/tui/adapters/telegram-adapter.js.map +1 -1
  208. package/dist/src/tui/adapters/webex-adapter.d.ts.map +1 -1
  209. package/dist/src/tui/adapters/whatsapp-adapter.d.ts.map +1 -1
  210. package/dist/src/tui/app.js.map +1 -1
  211. package/dist/src/tui/views/channel-picker.d.ts.map +1 -1
  212. package/dist/src/tui/views/workspace-picker.d.ts.map +1 -1
  213. package/docs/content/docs/cli/discord.mdx +7 -0
  214. package/docs/content/docs/cli/kakaotalk.mdx +16 -1
  215. package/docs/content/docs/sdk/discord.mdx +38 -0
  216. package/docs/content/docs/sdk/kakaotalk.mdx +39 -0
  217. package/e2e/README.md +2 -2
  218. package/e2e/discord.e2e.test.ts +16 -2
  219. package/package.json +2 -2
  220. package/skills/agent-channeltalk/SKILL.md +1 -1
  221. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  222. package/skills/agent-discord/SKILL.md +19 -1
  223. package/skills/agent-discordbot/SKILL.md +1 -1
  224. package/skills/agent-imessage/SKILL.md +1 -1
  225. package/skills/agent-instagram/SKILL.md +1 -1
  226. package/skills/agent-kakaotalk/SKILL.md +19 -2
  227. package/skills/agent-line/SKILL.md +1 -1
  228. package/skills/agent-slack/SKILL.md +1 -1
  229. package/skills/agent-slackbot/SKILL.md +1 -1
  230. package/skills/agent-teams/SKILL.md +1 -1
  231. package/skills/agent-telegram/SKILL.md +1 -1
  232. package/skills/agent-telegrambot/SKILL.md +1 -1
  233. package/skills/agent-webex/SKILL.md +1 -1
  234. package/skills/agent-webexbot/SKILL.md +1 -1
  235. package/skills/agent-wechatbot/SKILL.md +1 -1
  236. package/skills/agent-whatsapp/SKILL.md +1 -1
  237. package/skills/agent-whatsappbot/SKILL.md +1 -1
  238. package/src/platforms/discord/client.test.ts +351 -9
  239. package/src/platforms/discord/client.ts +166 -26
  240. package/src/platforms/discord/commands/mention.test.ts +84 -0
  241. package/src/platforms/discord/commands/mention.ts +78 -15
  242. package/src/platforms/discord/commands/reaction.test.ts +1 -1
  243. package/src/platforms/discord/commands/reaction.ts +1 -1
  244. package/src/platforms/discord/index.ts +3 -0
  245. package/src/platforms/discord/listener.test.ts +69 -2
  246. package/src/platforms/discord/listener.ts +31 -2
  247. package/src/platforms/discord/types.test.ts +11 -0
  248. package/src/platforms/discord/types.ts +66 -10
  249. package/src/platforms/kakaotalk/client.test.ts +194 -1
  250. package/src/platforms/kakaotalk/client.ts +58 -0
  251. package/src/platforms/kakaotalk/commands/chat.test.ts +24 -0
  252. package/src/platforms/kakaotalk/commands/chat.ts +23 -0
  253. package/src/platforms/kakaotalk/index.test.ts +10 -0
  254. package/src/platforms/kakaotalk/index.ts +4 -0
  255. package/src/platforms/kakaotalk/protocol/session.test.ts +113 -0
  256. package/src/platforms/kakaotalk/protocol/session.ts +56 -0
  257. package/src/platforms/kakaotalk/types.ts +24 -0
  258. 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
- id: 'msg1',
235
- channel_id: 'ch1',
236
- author: { id: '123', username: 'user1' },
237
- content: 'Message 1',
238
- timestamp: '2024-01-01T00:00:00.000Z',
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/msg1')
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 normalized = path
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
- return this.request<DiscordMessage[]>('GET', `/channels/${channelId}/messages?limit=${limit}`)
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
- return this.request<DiscordMessage>('GET', `/channels/${channelId}/messages/${messageId}`)
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 response = await this.request<DiscordSearchResponse>(
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
- return {
415
- results,
416
- total: response.total_results,
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> {