agent-messenger 2.33.0 → 2.35.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 (97) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +12 -1
  3. package/dist/package.json +1 -1
  4. package/dist/src/platforms/discord/client.d.ts +16 -1
  5. package/dist/src/platforms/discord/client.d.ts.map +1 -1
  6. package/dist/src/platforms/discord/client.js +50 -3
  7. package/dist/src/platforms/discord/client.js.map +1 -1
  8. package/dist/src/platforms/discord/commands/dm.d.ts +4 -0
  9. package/dist/src/platforms/discord/commands/dm.d.ts.map +1 -1
  10. package/dist/src/platforms/discord/commands/dm.js +56 -4
  11. package/dist/src/platforms/discord/commands/dm.js.map +1 -1
  12. package/dist/src/platforms/discord/index.d.ts +1 -1
  13. package/dist/src/platforms/discord/index.d.ts.map +1 -1
  14. package/dist/src/platforms/discord/index.js.map +1 -1
  15. package/dist/src/platforms/discord/listener.d.ts +4 -2
  16. package/dist/src/platforms/discord/listener.d.ts.map +1 -1
  17. package/dist/src/platforms/discord/listener.js +9 -2
  18. package/dist/src/platforms/discord/listener.js.map +1 -1
  19. package/dist/src/platforms/discord/types.d.ts +19 -0
  20. package/dist/src/platforms/discord/types.d.ts.map +1 -1
  21. package/dist/src/platforms/discord/types.js +5 -1
  22. package/dist/src/platforms/discord/types.js.map +1 -1
  23. package/dist/src/platforms/kakaotalk/auth/kakao-login.d.ts +2 -0
  24. package/dist/src/platforms/kakaotalk/auth/kakao-login.d.ts.map +1 -1
  25. package/dist/src/platforms/kakaotalk/auth/kakao-login.js +2 -2
  26. package/dist/src/platforms/kakaotalk/auth/kakao-login.js.map +1 -1
  27. package/dist/src/platforms/kakaotalk/auth/oauth-refresh.d.ts +28 -0
  28. package/dist/src/platforms/kakaotalk/auth/oauth-refresh.d.ts.map +1 -0
  29. package/dist/src/platforms/kakaotalk/auth/oauth-refresh.js +100 -0
  30. package/dist/src/platforms/kakaotalk/auth/oauth-refresh.js.map +1 -0
  31. package/dist/src/platforms/kakaotalk/client.d.ts +2 -0
  32. package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
  33. package/dist/src/platforms/kakaotalk/client.js +14 -1
  34. package/dist/src/platforms/kakaotalk/client.js.map +1 -1
  35. package/dist/src/platforms/kakaotalk/index.d.ts +3 -1
  36. package/dist/src/platforms/kakaotalk/index.d.ts.map +1 -1
  37. package/dist/src/platforms/kakaotalk/index.js +1 -0
  38. package/dist/src/platforms/kakaotalk/index.js.map +1 -1
  39. package/dist/src/platforms/kakaotalk/protocol/login-response.d.ts +9 -0
  40. package/dist/src/platforms/kakaotalk/protocol/login-response.d.ts.map +1 -0
  41. package/dist/src/platforms/kakaotalk/protocol/login-response.js +32 -0
  42. package/dist/src/platforms/kakaotalk/protocol/login-response.js.map +1 -0
  43. package/dist/src/platforms/kakaotalk/protocol/session.d.ts.map +1 -1
  44. package/dist/src/platforms/kakaotalk/protocol/session.js +29 -20
  45. package/dist/src/platforms/kakaotalk/protocol/session.js.map +1 -1
  46. package/dist/src/platforms/kakaotalk/protocol/types.d.ts +1 -0
  47. package/dist/src/platforms/kakaotalk/protocol/types.d.ts.map +1 -1
  48. package/dist/src/platforms/kakaotalk/protocol/types.js.map +1 -1
  49. package/dist/src/platforms/kakaotalk/types.d.ts +1 -0
  50. package/dist/src/platforms/kakaotalk/types.d.ts.map +1 -1
  51. package/dist/src/platforms/kakaotalk/types.js.map +1 -1
  52. package/docs/content/docs/cli/discord.mdx +17 -0
  53. package/docs/content/docs/cli/kakaotalk.mdx +10 -0
  54. package/docs/content/docs/sdk/discord.mdx +13 -0
  55. package/docs/content/docs/sdk/kakaotalk.mdx +54 -0
  56. package/package.json +1 -1
  57. package/skills/agent-channeltalk/SKILL.md +1 -1
  58. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  59. package/skills/agent-discord/SKILL.md +23 -1
  60. package/skills/agent-discordbot/SKILL.md +1 -1
  61. package/skills/agent-imessage/SKILL.md +1 -1
  62. package/skills/agent-instagram/SKILL.md +1 -1
  63. package/skills/agent-kakaotalk/SKILL.md +27 -1
  64. package/skills/agent-kakaotalk/references/authentication.md +59 -0
  65. package/skills/agent-kakaotalk/references/common-patterns.md +8 -0
  66. package/skills/agent-line/SKILL.md +1 -1
  67. package/skills/agent-slack/SKILL.md +1 -1
  68. package/skills/agent-slackbot/SKILL.md +1 -1
  69. package/skills/agent-teams/SKILL.md +1 -1
  70. package/skills/agent-telegram/SKILL.md +1 -1
  71. package/skills/agent-telegrambot/SKILL.md +1 -1
  72. package/skills/agent-webex/SKILL.md +1 -1
  73. package/skills/agent-webexbot/SKILL.md +1 -1
  74. package/skills/agent-wechatbot/SKILL.md +1 -1
  75. package/skills/agent-whatsapp/SKILL.md +1 -1
  76. package/skills/agent-whatsappbot/SKILL.md +1 -1
  77. package/src/platforms/discord/client.test.ts +151 -1
  78. package/src/platforms/discord/client.ts +76 -3
  79. package/src/platforms/discord/commands/dm.test.ts +126 -1
  80. package/src/platforms/discord/commands/dm.ts +63 -3
  81. package/src/platforms/discord/index.ts +3 -0
  82. package/src/platforms/discord/listener.test.ts +12 -3
  83. package/src/platforms/discord/listener.ts +17 -4
  84. package/src/platforms/discord/types.ts +37 -1
  85. package/src/platforms/kakaotalk/auth/kakao-login.ts +2 -2
  86. package/src/platforms/kakaotalk/auth/oauth-refresh.test.ts +225 -0
  87. package/src/platforms/kakaotalk/auth/oauth-refresh.ts +144 -0
  88. package/src/platforms/kakaotalk/client.test.ts +55 -0
  89. package/src/platforms/kakaotalk/client.ts +19 -2
  90. package/src/platforms/kakaotalk/index.test.ts +11 -0
  91. package/src/platforms/kakaotalk/index.ts +8 -0
  92. package/src/platforms/kakaotalk/protocol/NOTICE.md +19 -4
  93. package/src/platforms/kakaotalk/protocol/login-response.test.ts +64 -0
  94. package/src/platforms/kakaotalk/protocol/login-response.ts +40 -0
  95. package/src/platforms/kakaotalk/protocol/session.ts +29 -21
  96. package/src/platforms/kakaotalk/protocol/types.ts +1 -0
  97. package/src/platforms/kakaotalk/types.ts +1 -0
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-kakaotalk
3
3
  description: Interact with KakaoTalk - send messages, read chats, manage conversations
4
- version: 2.33.0
4
+ version: 2.35.0
5
5
  allowed-tools: Bash(agent-kakaotalk:*)
6
6
  metadata:
7
7
  openclaw:
@@ -599,6 +599,32 @@ if (!account) throw new Error('Not authenticated')
599
599
  const client = await new KakaoTalkClient().login({ oauthToken: account.oauth_token, userId: account.user_id, deviceUuid: account.device_uuid })
600
600
  ```
601
601
 
602
+ For caller-managed token renewal, use the explicit SDK primitive:
603
+
604
+ ```typescript
605
+ import { refreshKakaoOAuthToken } from 'agent-messenger/kakaotalk'
606
+
607
+ if (!account.refresh_token) throw new Error('No KakaoTalk refresh token')
608
+
609
+ const refreshed = await refreshKakaoOAuthToken({
610
+ accessToken: account.oauth_token,
611
+ refreshToken: account.refresh_token,
612
+ deviceUuid: account.device_uuid,
613
+ })
614
+
615
+ const nextAccount = {
616
+ ...account,
617
+ oauth_token: refreshed.accessToken,
618
+ refresh_token: refreshed.refreshToken,
619
+ updated_at: new Date().toISOString(),
620
+ }
621
+
622
+ // Persist nextAccount atomically before another client uses this credential.
623
+ // The refresh primitive intentionally does not write credential files or retry login.
624
+ ```
625
+
626
+ Kakao can rotate the refresh token. Never refresh a copied production credential and discard the returned token set. See [references/authentication.md](references/authentication.md) for error codes and the controlled canary procedure.
627
+
602
628
  ### Example
603
629
 
604
630
  ```typescript
@@ -77,6 +77,22 @@ Re-run with `--device-type pc --force` or `--device-type tablet --force`.
77
77
 
78
78
  Check email/password and try again.
79
79
 
80
+ ### Runtime authentication failures
81
+
82
+ Stored credentials are validated by Kakao only when a command opens a LOCO session. `auth status` confirms that a credential is present; it does not prove that the access token is still accepted by the server.
83
+
84
+ An expired or revoked access token keeps the existing CLI error shape and reports the server status in the message:
85
+
86
+ ```json
87
+ {
88
+ "error": "KakaoTalk LOGINLIST invalid access token (status -950)"
89
+ }
90
+ ```
91
+
92
+ Run `agent-kakaotalk auth login` to replace the credential before retrying the command. Other non-zero Kakao authentication responses preserve the returned status in the error message.
93
+
94
+ A socket close, timeout, or other interrupted `LOGINLIST` remains a transport failure and must not be treated as an invalid token. Rejected authentication closes the provisional connection and does not continue to `LCHATLIST` or sync-state work.
95
+
80
96
  ### Device Slots
81
97
 
82
98
  KakaoTalk allows these simultaneous sessions:
@@ -246,6 +262,49 @@ KakaoTalk OAuth tokens can expire or be invalidated when:
246
262
  - The token naturally expires
247
263
  - KakaoTalk's server revokes the session
248
264
 
265
+ ### Explicit SDK Refresh
266
+
267
+ The SDK exports `refreshKakaoOAuthToken()` for callers that need to exchange a stored Android sub-device refresh token for a new token set:
268
+
269
+ ```typescript
270
+ import { refreshKakaoOAuthToken } from 'agent-messenger/kakaotalk'
271
+
272
+ const refreshed = await refreshKakaoOAuthToken({
273
+ accessToken: account.oauth_token,
274
+ refreshToken: account.refresh_token,
275
+ deviceUuid: account.device_uuid,
276
+ })
277
+ ```
278
+
279
+ This is an explicit, stateless operation. It does not retry `LOGINLIST`, update the credentials file, or run automatically when a command receives `invalid_access_token`.
280
+
281
+ The result always contains an access token and refresh token. Kakao may rotate the refresh token; if the response omits it, the SDK preserves the input refresh token in the returned value. The caller must persist both returned tokens atomically before the process exits or another client uses the same credential.
282
+
283
+ Do not refresh a copied production credential and discard the result. A successful refresh can invalidate the previously stored refresh token on the server.
284
+
285
+ #### Controlled Live Canary
286
+
287
+ Use a dedicated test account and device when possible. If a production credential is the only available canary, use this sequence:
288
+
289
+ 1. Stop every collector, CLI process, and service that can use the credential. Confirm there is only one refresh caller.
290
+ 2. Prepare an atomic persistence path before making the request. Credential files must keep `0600` permissions.
291
+ 3. Call `refreshKakaoOAuthToken()` exactly once. Record only success, token-rotation booleans, and expiry metadata — never tokens, authorization headers, account IDs, or the raw response.
292
+ 4. If a token set is returned, immediately persist both returned tokens with a temporary file, `fsync`, and atomic rename. Do not let the process exit or another client start first.
293
+ 5. Start a fresh process, reload the persisted credential, and verify that `LOGINLIST` and a minimal chat-list request succeed.
294
+ 6. Scan application logs and temporary files for the old and new token values, confirm the credential still has `0600` permissions, then restart the stopped caller.
295
+
296
+ If the refresh request fails before returning a token set, leave the stored credential unchanged and stop the canary. Do not loop blindly: an ambiguous network failure may have occurred after server-side rotation, in which case re-authentication can be required.
297
+
298
+ Failures throw `KakaoOAuthRefreshError` with safe metadata only:
299
+
300
+ - `refresh_credentials_missing` — an access token, refresh token, or device UUID was not provided.
301
+ - `refresh_rejected` — Kakao returned an explicit non-zero status; inspect `serverStatus`.
302
+ - `refresh_http_error` — the endpoint returned an HTTP failure without a Kakao status; inspect `httpStatus`.
303
+ - `refresh_timeout` / `refresh_request_failed` — the request did not complete.
304
+ - `refresh_malformed_response` — a successful response did not contain a usable access token.
305
+
306
+ Token values, authorization headers, and raw response bodies are not included in these errors. The endpoint is an undocumented Android API and can change without notice.
307
+
249
308
  ### Re-authentication
250
309
 
251
310
  If commands start failing with auth errors:
@@ -497,6 +497,14 @@ The `deviceType` determines the LOCO protocol identity: `'tablet'` sends `os: 'a
497
497
 
498
498
  Reconnect only triggers on actual session death. Operation-level errors (invalid chat ID, server rejection, etc.) are thrown immediately without retry, so side effects like `sendMessage` or `sendAttachment` are never duplicated.
499
499
 
500
+ Initial `LOGINLIST` failures are classified separately from reconnectable session death:
501
+
502
+ - `invalid_access_token` with `serverStatus === -950`: the stored Kakao access token has expired or been revoked. Re-authenticate before retrying.
503
+ - `login_rejected`: Kakao explicitly rejected authentication for another server status. Inspect `serverStatus` and the device-slot/account state.
504
+ - `login_failed`: booking, check-in, socket close, timeout, or another transport/protocol interruption. This is not proof that the token is invalid.
505
+
506
+ Rejected authentication closes the provisional session before it can be adopted. Callers must not continue to `LCHATLIST` or other LOCO operations after either authentication error.
507
+
500
508
  ## See Also
501
509
 
502
510
  - [Authentication Guide](authentication.md) - Setting up credentials
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-line
3
3
  description: Interact with LINE - send messages, read chats, manage conversations
4
- version: 2.33.0
4
+ version: 2.35.0
5
5
  allowed-tools: Bash(agent-line:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-slack
3
3
  description: Interact with Slack workspaces - send messages, read channels, manage reactions
4
- version: 2.33.0
4
+ version: 2.35.0
5
5
  allowed-tools: Bash(agent-slack:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-slackbot
3
3
  description: Interact with Slack workspaces using bot tokens - send messages, read channels, manage reactions
4
- version: 2.33.0
4
+ version: 2.35.0
5
5
  allowed-tools: Bash(agent-slackbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-teams
3
3
  description: Interact with Microsoft Teams - send messages, read channels, manage reactions
4
- version: 2.33.0
4
+ version: 2.35.0
5
5
  allowed-tools: Bash(agent-teams:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-telegram
3
3
  description: Interact with Telegram through TDLib - authenticate, inspect chats, and send messages
4
- version: 2.33.0
4
+ version: 2.35.0
5
5
  allowed-tools: Bash(agent-telegram:*)
6
6
  ---
7
7
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-telegrambot
3
3
  description: Interact with Telegram using bot tokens - send messages, read chats, manage reactions
4
- version: 2.33.0
4
+ version: 2.35.0
5
5
  allowed-tools: Bash(agent-telegrambot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-webex
3
3
  description: Interact with Cisco Webex - send messages, read spaces, manage memberships
4
- version: 2.33.0
4
+ version: 2.35.0
5
5
  allowed-tools: Bash(agent-webex:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-webexbot
3
3
  description: Interact with Cisco Webex using bot tokens - send messages, reply in threads, upload and download files, look up people, read spaces, manage memberships, stream real-time events
4
- version: 2.33.0
4
+ version: 2.35.0
5
5
  allowed-tools: Bash(agent-webexbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-wechatbot
3
3
  description: Interact with WeChat Official Account using API credentials - send messages, manage templates, list followers
4
- version: 2.33.0
4
+ version: 2.35.0
5
5
  allowed-tools: Bash(agent-wechatbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-whatsapp
3
3
  description: Interact with WhatsApp - send messages, read chats, manage conversations
4
- version: 2.33.0
4
+ version: 2.35.0
5
5
  allowed-tools: Bash(agent-whatsapp:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-whatsappbot
3
3
  description: Interact with WhatsApp using Cloud API credentials - send messages, manage templates
4
- version: 2.33.0
4
+ version: 2.35.0
5
5
  allowed-tools: Bash(agent-whatsappbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
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
+ import type { DiscordDMChannel, DiscordReadState } from './types'
5
5
 
6
6
  describe('DiscordClient', () => {
7
7
  const originalFetch = globalThis.fetch
@@ -949,4 +949,154 @@ describe('DiscordClient', () => {
949
949
  expect(result.count).toBe(100)
950
950
  })
951
951
  })
952
+
953
+ describe('getUnreadDMs', () => {
954
+ const dmChannel = (id: string, lastMessageId?: string, type = 1): DiscordDMChannel => ({
955
+ id,
956
+ type,
957
+ recipients: [{ id: `u${id}`, username: `user${id}` }],
958
+ ...(lastMessageId ? { last_message_id: lastMessageId } : {}),
959
+ })
960
+
961
+ const stub = (client: DiscordClient, channels: DiscordDMChannel[], states: DiscordReadState[], partial = false) => {
962
+ spyOn(client, 'listDMChannels').mockResolvedValue(channels)
963
+ spyOn(client, 'fetchReadStateSnapshot').mockResolvedValue({ states, partial })
964
+ }
965
+
966
+ it('keeps channels whose newest message is past the read marker', async () => {
967
+ // given: c1 has a message newer than its marker, c2 is fully read
968
+ const client = await new DiscordClient().login({ token: 'test-token' })
969
+ stub(
970
+ client,
971
+ [dmChannel('c1', '200'), dmChannel('c2', '50')],
972
+ [
973
+ { channelId: 'c1', lastMessageId: '100', mentionCount: 2 },
974
+ { channelId: 'c2', lastMessageId: '100', mentionCount: 0 },
975
+ ],
976
+ )
977
+
978
+ const result = await client.getUnreadDMs()
979
+
980
+ expect(result.count).toBe(1)
981
+ expect(result.channels[0].id).toBe('c1')
982
+ expect(result.channels[0].unreadCount).toBe(2)
983
+ expect(result.totalUnread).toBe(2)
984
+ })
985
+
986
+ it('treats an equal snowflake as read', async () => {
987
+ const client = await new DiscordClient().login({ token: 'test-token' })
988
+ stub(client, [dmChannel('c1', '100')], [{ channelId: 'c1', lastMessageId: '100', mentionCount: 1 }])
989
+
990
+ const result = await client.getUnreadDMs()
991
+
992
+ expect(result.count).toBe(0)
993
+ })
994
+
995
+ it('compares snowflakes beyond the safe integer range', async () => {
996
+ // given: two 19-digit snowflakes that collapse to the same value as Numbers
997
+ const client = await new DiscordClient().login({ token: 'test-token' })
998
+ stub(
999
+ client,
1000
+ [dmChannel('c1', '1234567890123456789')],
1001
+ [{ channelId: 'c1', lastMessageId: '1234567890123456788', mentionCount: 1 }],
1002
+ )
1003
+
1004
+ const result = await client.getUnreadDMs()
1005
+
1006
+ expect(result.count).toBe(1)
1007
+ })
1008
+
1009
+ it('surfaces a channel with no read-state entry as an unknown count', async () => {
1010
+ const client = await new DiscordClient().login({ token: 'test-token' })
1011
+ stub(client, [dmChannel('c1', '200')], [])
1012
+
1013
+ const result = await client.getUnreadDMs()
1014
+
1015
+ expect(result.count).toBe(1)
1016
+ expect(result.channels[0].unreadCount).toBeNull()
1017
+ // then: an unknown count must not be summed as zero-or-anything-else
1018
+ expect(result.totalUnread).toBe(0)
1019
+ expect(result.complete).toBe(true)
1020
+ })
1021
+
1022
+ it('omits absent entries when the read state is partial', async () => {
1023
+ // given: an incomplete collection, where absence means "not delivered", not "never read"
1024
+ const client = await new DiscordClient().login({ token: 'test-token' })
1025
+ stub(client, [dmChannel('c1', '200')], [], true)
1026
+
1027
+ const result = await client.getUnreadDMs()
1028
+
1029
+ expect(result.count).toBe(0)
1030
+ expect(result.complete).toBe(false)
1031
+ })
1032
+
1033
+ it('still reports channels past their marker when the read state is partial', async () => {
1034
+ // given: a delivered entry is authoritative even inside a partial collection
1035
+ const client = await new DiscordClient().login({ token: 'test-token' })
1036
+ stub(client, [dmChannel('c1', '200')], [{ channelId: 'c1', lastMessageId: '100', mentionCount: 2 }], true)
1037
+
1038
+ const result = await client.getUnreadDMs()
1039
+
1040
+ expect(result.count).toBe(1)
1041
+ expect(result.channels[0].unreadCount).toBe(2)
1042
+ expect(result.complete).toBe(false)
1043
+ })
1044
+
1045
+ it('skips channels that never had a message', async () => {
1046
+ const client = await new DiscordClient().login({ token: 'test-token' })
1047
+ stub(client, [dmChannel('c1')], [])
1048
+
1049
+ const result = await client.getUnreadDMs()
1050
+
1051
+ expect(result.count).toBe(0)
1052
+ })
1053
+
1054
+ it('keeps a muted but unread channel with a zero count', async () => {
1055
+ const client = await new DiscordClient().login({ token: 'test-token' })
1056
+ stub(client, [dmChannel('c1', '200')], [{ channelId: 'c1', lastMessageId: '100', mentionCount: 0 }])
1057
+
1058
+ const result = await client.getUnreadDMs()
1059
+
1060
+ expect(result.count).toBe(1)
1061
+ expect(result.channels[0].unreadCount).toBe(0)
1062
+ })
1063
+
1064
+ it('treats a null read marker as never acknowledged', async () => {
1065
+ const client = await new DiscordClient().login({ token: 'test-token' })
1066
+ stub(client, [dmChannel('c1', '200')], [{ channelId: 'c1', lastMessageId: null, mentionCount: 4 }])
1067
+
1068
+ const result = await client.getUnreadDMs()
1069
+
1070
+ expect(result.count).toBe(1)
1071
+ expect(result.channels[0].unreadCount).toBe(4)
1072
+ })
1073
+
1074
+ it('includes group DMs and orders channels newest first', async () => {
1075
+ const client = await new DiscordClient().login({ token: 'test-token' })
1076
+ stub(client, [dmChannel('c1', '200'), dmChannel('c2', '900', 3)], [])
1077
+
1078
+ const result = await client.getUnreadDMs()
1079
+
1080
+ expect(result.channels.map((channel) => channel.id)).toEqual(['c2', 'c1'])
1081
+ expect(result.channels[0].type).toBe(3)
1082
+ })
1083
+
1084
+ it('caps returned channels by limit while keeping totals unfiltered', async () => {
1085
+ const client = await new DiscordClient().login({ token: 'test-token' })
1086
+ stub(
1087
+ client,
1088
+ [dmChannel('c1', '200'), dmChannel('c2', '900')],
1089
+ [
1090
+ { channelId: 'c1', lastMessageId: '100', mentionCount: 2 },
1091
+ { channelId: 'c2', lastMessageId: '100', mentionCount: 5 },
1092
+ ],
1093
+ )
1094
+
1095
+ const result = await client.getUnreadDMs({ limit: 1 })
1096
+
1097
+ expect(result.channels).toHaveLength(1)
1098
+ expect(result.count).toBe(2)
1099
+ expect(result.totalUnread).toBe(7)
1100
+ })
1101
+ })
952
1102
  })
@@ -11,10 +11,13 @@ import type {
11
11
  DiscordMention,
12
12
  DiscordMessage,
13
13
  DiscordReadState,
14
+ DiscordReadStateSnapshot,
14
15
  DiscordRelationship,
15
16
  DiscordRole,
16
17
  DiscordSearchOptions,
17
18
  DiscordSearchResult,
19
+ DiscordUnreadDM,
20
+ DiscordUnreadDMsResult,
18
21
  DiscordUnreadMention,
19
22
  DiscordUnreadMentionsResult,
20
23
  DiscordUser,
@@ -34,6 +37,47 @@ function isSnowflakeNewer(candidate: string, marker: string): boolean {
34
37
  }
35
38
  }
36
39
 
40
+ interface ReadStateCandidateChannel {
41
+ id: string
42
+ last_message_id?: string | null
43
+ }
44
+
45
+ export interface UnreadChannel<T> {
46
+ channel: T
47
+ lastMessageId: string
48
+ unreadCount: number | null
49
+ }
50
+
51
+ export function selectUnreadChannels<T extends ReadStateCandidateChannel>(
52
+ channels: T[],
53
+ readState: DiscordReadStateSnapshot,
54
+ ): UnreadChannel<T>[] {
55
+ const readStateByChannel = new Map(readState.states.map((state) => [state.channelId, state]))
56
+
57
+ const unread: UnreadChannel<T>[] = []
58
+ for (const channel of channels) {
59
+ const lastMessageId = channel.last_message_id
60
+ if (!lastMessageId) continue
61
+
62
+ const state = readStateByChannel.get(channel.id)
63
+ // An absent entry only carries meaning when the collection is complete. In a
64
+ // complete payload Discord omits read states for channels it never tracked, so a
65
+ // channel holding messages with no marker is far more likely never-opened than
66
+ // read: surface it with an unknown count rather than silently hiding it. In a
67
+ // partial payload the entry may simply have been left out, so reporting it would
68
+ // be a false positive on an already-read channel.
69
+ if (!state) {
70
+ if (!readState.partial) unread.push({ channel, lastMessageId, unreadCount: null })
71
+ continue
72
+ }
73
+
74
+ if (state.lastMessageId !== null && !isSnowflakeNewer(lastMessageId, state.lastMessageId)) continue
75
+ unread.push({ channel, lastMessageId, unreadCount: state.mentionCount })
76
+ }
77
+
78
+ return unread.sort((a, b) => (isSnowflakeNewer(a.lastMessageId, b.lastMessageId) ? -1 : 1))
79
+ }
80
+
37
81
  export class DiscordError extends Error {
38
82
  code: string
39
83
 
@@ -350,6 +394,31 @@ export class DiscordClient {
350
394
  })
351
395
  }
352
396
 
397
+ async getUnreadDMs(options?: { limit?: number }): Promise<DiscordUnreadDMsResult> {
398
+ const [readState, channels] = await Promise.all([this.fetchReadStateSnapshot(), this.listDMChannels()])
399
+
400
+ const unread = selectUnreadChannels(channels, readState).map(
401
+ ({ channel, lastMessageId, unreadCount }): DiscordUnreadDM => ({
402
+ id: channel.id,
403
+ type: channel.type,
404
+ name: channel.name ?? null,
405
+ recipients: channel.recipients,
406
+ lastMessageId,
407
+ unreadCount,
408
+ }),
409
+ )
410
+
411
+ const totalUnread = unread.reduce((sum, channel) => sum + (channel.unreadCount ?? 0), 0)
412
+ const limited = options?.limit === undefined ? unread : unread.slice(0, options.limit)
413
+
414
+ return {
415
+ channels: limited,
416
+ count: unread.length,
417
+ totalUnread,
418
+ complete: !readState.partial,
419
+ }
420
+ }
421
+
353
422
  async getMentions(options?: { limit?: number; guildId?: string; before?: string }): Promise<DiscordMention[]> {
354
423
  const params = new URLSearchParams()
355
424
  params.set('limit', (options?.limit ?? 25).toString())
@@ -367,15 +436,19 @@ export class DiscordClient {
367
436
  }
368
437
 
369
438
  async fetchReadState(): Promise<DiscordReadState[]> {
439
+ return (await this.fetchReadStateSnapshot()).states
440
+ }
441
+
442
+ async fetchReadStateSnapshot(): Promise<DiscordReadStateSnapshot> {
370
443
  const { DiscordListener } = await import('./listener')
371
444
  const listener = new DiscordListener(this)
372
445
  try {
373
446
  await listener.start()
374
- const readState = listener.getReadState()
375
- if (readState === null) {
447
+ const snapshot = listener.getReadStateSnapshot()
448
+ if (snapshot === null) {
376
449
  throw new DiscordError('Discord gateway did not deliver read state', 'no_read_state')
377
450
  }
378
- return readState
451
+ return snapshot
379
452
  } finally {
380
453
  listener.stop()
381
454
  }
@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:te
3
3
  import { DiscordClient } from '../client'
4
4
  import { DiscordCredentialManager } from '../credential-manager'
5
5
  import type { DiscordDMChannel } from '../types'
6
- import { createAction, listAction } from './dm'
6
+ import { createAction, listAction, unreadAction } from './dm'
7
7
 
8
8
  class ProcessExit extends Error {
9
9
  constructor(readonly code?: string | number | null) {
@@ -13,6 +13,7 @@ class ProcessExit extends Error {
13
13
 
14
14
  let clientListDMChannelsSpy: ReturnType<typeof spyOn>
15
15
  let clientCreateDMSpy: ReturnType<typeof spyOn>
16
+ let clientGetUnreadDMsSpy: ReturnType<typeof spyOn>
16
17
  let credManagerLoadSpy: ReturnType<typeof spyOn>
17
18
 
18
19
  const mockChannels: DiscordDMChannel[] = [
@@ -57,6 +58,30 @@ beforeEach(() => {
57
58
  ],
58
59
  })
59
60
 
61
+ clientGetUnreadDMsSpy = spyOn(DiscordClient.prototype, 'getUnreadDMs').mockResolvedValue({
62
+ channels: [
63
+ {
64
+ id: '123',
65
+ type: 1,
66
+ name: null,
67
+ recipients: [{ id: '456', username: 'testuser' }],
68
+ lastMessageId: '900',
69
+ unreadCount: 3,
70
+ },
71
+ {
72
+ id: '789',
73
+ type: 3,
74
+ name: 'Group Chat',
75
+ recipients: [{ id: '111', username: 'user1' }],
76
+ lastMessageId: '800',
77
+ unreadCount: null,
78
+ },
79
+ ],
80
+ count: 2,
81
+ totalUnread: 3,
82
+ complete: true,
83
+ })
84
+
60
85
  credManagerLoadSpy = spyOn(DiscordCredentialManager.prototype, 'load').mockResolvedValue({
61
86
  token: 'test-token',
62
87
  current_server: null,
@@ -67,6 +92,7 @@ beforeEach(() => {
67
92
  afterEach(() => {
68
93
  clientListDMChannelsSpy?.mockRestore()
69
94
  clientCreateDMSpy?.mockRestore()
95
+ clientGetUnreadDMsSpy?.mockRestore()
70
96
  credManagerLoadSpy?.mockRestore()
71
97
  })
72
98
 
@@ -150,4 +176,103 @@ describe('dm commands', () => {
150
176
  }
151
177
  })
152
178
  })
179
+
180
+ describe('unreadAction', () => {
181
+ it('reports unread channels with labelled types and totals', async () => {
182
+ const consoleSpy = mock(() => {})
183
+ const originalLog = console.log
184
+ console.log = consoleSpy
185
+
186
+ try {
187
+ await unreadAction({ pretty: false })
188
+ } finally {
189
+ console.log = originalLog
190
+ }
191
+
192
+ const payload = JSON.parse(consoleSpy.mock.calls[0][0] as unknown as string)
193
+ expect(payload.count).toBe(2)
194
+ expect(payload.total_unread).toBe(3)
195
+ expect(payload.complete).toBe(true)
196
+ expect(payload.channels[0]).toEqual({
197
+ id: '123',
198
+ type: 'DM',
199
+ name: null,
200
+ recipients: [{ id: '456', username: 'testuser' }],
201
+ last_message_id: '900',
202
+ unread_count: 3,
203
+ })
204
+ expect(payload.channels[1].type).toBe('Group DM')
205
+ })
206
+
207
+ it('preserves an unknown unread count as null', async () => {
208
+ const consoleSpy = mock(() => {})
209
+ const originalLog = console.log
210
+ console.log = consoleSpy
211
+
212
+ try {
213
+ await unreadAction({ pretty: false })
214
+ } finally {
215
+ console.log = originalLog
216
+ }
217
+
218
+ const payload = JSON.parse(consoleSpy.mock.calls[0][0] as unknown as string)
219
+ expect(payload.channels[1].unread_count).toBeNull()
220
+ })
221
+
222
+ it('surfaces an incomplete read state as complete false', async () => {
223
+ clientGetUnreadDMsSpy.mockResolvedValue({ channels: [], count: 0, totalUnread: 0, complete: false })
224
+
225
+ const consoleSpy = mock(() => {})
226
+ const originalLog = console.log
227
+ console.log = consoleSpy
228
+
229
+ try {
230
+ await unreadAction({ pretty: false })
231
+ } finally {
232
+ console.log = originalLog
233
+ }
234
+
235
+ const payload = JSON.parse(consoleSpy.mock.calls[0][0] as unknown as string)
236
+ expect(payload.complete).toBe(false)
237
+ })
238
+
239
+ it('forwards the limit to the client', async () => {
240
+ const consoleSpy = mock(() => {})
241
+ const originalLog = console.log
242
+ console.log = consoleSpy
243
+
244
+ try {
245
+ await unreadAction({ limit: 5, pretty: false })
246
+ } finally {
247
+ console.log = originalLog
248
+ }
249
+
250
+ expect(clientGetUnreadDMsSpy).toHaveBeenCalledWith({ limit: 5 })
251
+ })
252
+
253
+ it('handles authentication error', async () => {
254
+ credManagerLoadSpy.mockResolvedValue({
255
+ token: '',
256
+ current_server: null,
257
+ servers: {},
258
+ })
259
+
260
+ const exitSpy = spyOn(process, 'exit').mockImplementation((code?: string | number | null) => {
261
+ throw new ProcessExit(code)
262
+ })
263
+
264
+ const consoleSpy = mock(() => {})
265
+ const originalLog = console.log
266
+ console.log = consoleSpy
267
+
268
+ try {
269
+ await expect(unreadAction({ pretty: false })).rejects.toThrow(ProcessExit)
270
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Not authenticated'))
271
+ expect(exitSpy).toHaveBeenCalledWith(1)
272
+ } finally {
273
+ console.log = originalLog
274
+ exitSpy.mockRestore()
275
+ }
276
+ })
277
+ })
153
278
  })