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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +12 -1
- package/dist/package.json +1 -1
- package/dist/src/platforms/discord/client.d.ts +16 -1
- package/dist/src/platforms/discord/client.d.ts.map +1 -1
- package/dist/src/platforms/discord/client.js +50 -3
- package/dist/src/platforms/discord/client.js.map +1 -1
- package/dist/src/platforms/discord/commands/dm.d.ts +4 -0
- package/dist/src/platforms/discord/commands/dm.d.ts.map +1 -1
- package/dist/src/platforms/discord/commands/dm.js +56 -4
- package/dist/src/platforms/discord/commands/dm.js.map +1 -1
- package/dist/src/platforms/discord/index.d.ts +1 -1
- package/dist/src/platforms/discord/index.d.ts.map +1 -1
- package/dist/src/platforms/discord/index.js.map +1 -1
- package/dist/src/platforms/discord/listener.d.ts +4 -2
- package/dist/src/platforms/discord/listener.d.ts.map +1 -1
- package/dist/src/platforms/discord/listener.js +9 -2
- package/dist/src/platforms/discord/listener.js.map +1 -1
- package/dist/src/platforms/discord/types.d.ts +19 -0
- package/dist/src/platforms/discord/types.d.ts.map +1 -1
- package/dist/src/platforms/discord/types.js +5 -1
- package/dist/src/platforms/discord/types.js.map +1 -1
- package/dist/src/platforms/kakaotalk/auth/kakao-login.d.ts +2 -0
- package/dist/src/platforms/kakaotalk/auth/kakao-login.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/auth/kakao-login.js +2 -2
- package/dist/src/platforms/kakaotalk/auth/kakao-login.js.map +1 -1
- package/dist/src/platforms/kakaotalk/auth/oauth-refresh.d.ts +28 -0
- package/dist/src/platforms/kakaotalk/auth/oauth-refresh.d.ts.map +1 -0
- package/dist/src/platforms/kakaotalk/auth/oauth-refresh.js +100 -0
- package/dist/src/platforms/kakaotalk/auth/oauth-refresh.js.map +1 -0
- package/dist/src/platforms/kakaotalk/client.d.ts +2 -0
- package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/client.js +14 -1
- package/dist/src/platforms/kakaotalk/client.js.map +1 -1
- package/dist/src/platforms/kakaotalk/index.d.ts +3 -1
- package/dist/src/platforms/kakaotalk/index.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/index.js +1 -0
- package/dist/src/platforms/kakaotalk/index.js.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/login-response.d.ts +9 -0
- package/dist/src/platforms/kakaotalk/protocol/login-response.d.ts.map +1 -0
- package/dist/src/platforms/kakaotalk/protocol/login-response.js +32 -0
- package/dist/src/platforms/kakaotalk/protocol/login-response.js.map +1 -0
- package/dist/src/platforms/kakaotalk/protocol/session.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/session.js +29 -20
- package/dist/src/platforms/kakaotalk/protocol/session.js.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/types.d.ts +1 -0
- package/dist/src/platforms/kakaotalk/protocol/types.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/types.js.map +1 -1
- package/dist/src/platforms/kakaotalk/types.d.ts +1 -0
- package/dist/src/platforms/kakaotalk/types.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/types.js.map +1 -1
- package/docs/content/docs/cli/discord.mdx +17 -0
- package/docs/content/docs/cli/kakaotalk.mdx +10 -0
- package/docs/content/docs/sdk/discord.mdx +13 -0
- package/docs/content/docs/sdk/kakaotalk.mdx +54 -0
- package/package.json +1 -1
- package/skills/agent-channeltalk/SKILL.md +1 -1
- package/skills/agent-channeltalkbot/SKILL.md +1 -1
- package/skills/agent-discord/SKILL.md +23 -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 +27 -1
- package/skills/agent-kakaotalk/references/authentication.md +59 -0
- package/skills/agent-kakaotalk/references/common-patterns.md +8 -0
- 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 +151 -1
- package/src/platforms/discord/client.ts +76 -3
- package/src/platforms/discord/commands/dm.test.ts +126 -1
- package/src/platforms/discord/commands/dm.ts +63 -3
- package/src/platforms/discord/index.ts +3 -0
- package/src/platforms/discord/listener.test.ts +12 -3
- package/src/platforms/discord/listener.ts +17 -4
- package/src/platforms/discord/types.ts +37 -1
- package/src/platforms/kakaotalk/auth/kakao-login.ts +2 -2
- package/src/platforms/kakaotalk/auth/oauth-refresh.test.ts +225 -0
- package/src/platforms/kakaotalk/auth/oauth-refresh.ts +144 -0
- package/src/platforms/kakaotalk/client.test.ts +55 -0
- package/src/platforms/kakaotalk/client.ts +19 -2
- package/src/platforms/kakaotalk/index.test.ts +11 -0
- package/src/platforms/kakaotalk/index.ts +8 -0
- package/src/platforms/kakaotalk/protocol/NOTICE.md +19 -4
- package/src/platforms/kakaotalk/protocol/login-response.test.ts +64 -0
- package/src/platforms/kakaotalk/protocol/login-response.ts +40 -0
- package/src/platforms/kakaotalk/protocol/session.ts +29 -21
- package/src/platforms/kakaotalk/protocol/types.ts +1 -0
- 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.
|
|
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-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.
|
|
4
|
+
version: 2.35.0
|
|
5
5
|
allowed-tools: Bash(agent-webexbot:*)
|
|
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
|
|
375
|
-
if (
|
|
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
|
|
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
|
})
|