agent-messenger 2.32.2 → 2.33.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 (49) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/dist/package.json +1 -1
  3. package/dist/src/platforms/discord/client.d.ts +3 -1
  4. package/dist/src/platforms/discord/client.d.ts.map +1 -1
  5. package/dist/src/platforms/discord/client.js +6 -0
  6. package/dist/src/platforms/discord/client.js.map +1 -1
  7. package/dist/src/platforms/discord/commands/member.d.ts +3 -0
  8. package/dist/src/platforms/discord/commands/member.d.ts.map +1 -1
  9. package/dist/src/platforms/discord/commands/member.js +72 -1
  10. package/dist/src/platforms/discord/commands/member.js.map +1 -1
  11. package/dist/src/platforms/discord/index.d.ts +1 -1
  12. package/dist/src/platforms/discord/index.d.ts.map +1 -1
  13. package/dist/src/platforms/discord/index.js.map +1 -1
  14. package/dist/src/platforms/discord/types.d.ts +29 -0
  15. package/dist/src/platforms/discord/types.d.ts.map +1 -1
  16. package/dist/src/platforms/discord/types.js.map +1 -1
  17. package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
  18. package/dist/src/platforms/kakaotalk/client.js +15 -5
  19. package/dist/src/platforms/kakaotalk/client.js.map +1 -1
  20. package/docs/content/docs/cli/discord.mdx +8 -0
  21. package/docs/content/docs/sdk/discord.mdx +8 -0
  22. package/package.json +1 -1
  23. package/skills/agent-channeltalk/SKILL.md +1 -1
  24. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  25. package/skills/agent-discord/SKILL.md +9 -1
  26. package/skills/agent-discordbot/SKILL.md +1 -1
  27. package/skills/agent-imessage/SKILL.md +1 -1
  28. package/skills/agent-instagram/SKILL.md +1 -1
  29. package/skills/agent-kakaotalk/SKILL.md +1 -1
  30. package/skills/agent-kakaotalk/references/common-patterns.md +1 -1
  31. package/skills/agent-line/SKILL.md +1 -1
  32. package/skills/agent-slack/SKILL.md +1 -1
  33. package/skills/agent-slackbot/SKILL.md +1 -1
  34. package/skills/agent-teams/SKILL.md +1 -1
  35. package/skills/agent-telegram/SKILL.md +1 -1
  36. package/skills/agent-telegrambot/SKILL.md +1 -1
  37. package/skills/agent-webex/SKILL.md +1 -1
  38. package/skills/agent-webexbot/SKILL.md +1 -1
  39. package/skills/agent-wechatbot/SKILL.md +1 -1
  40. package/skills/agent-whatsapp/SKILL.md +1 -1
  41. package/skills/agent-whatsappbot/SKILL.md +1 -1
  42. package/src/platforms/discord/client.test.ts +53 -0
  43. package/src/platforms/discord/client.ts +9 -0
  44. package/src/platforms/discord/commands/member.test.ts +177 -0
  45. package/src/platforms/discord/commands/member.ts +82 -0
  46. package/src/platforms/discord/index.ts +3 -0
  47. package/src/platforms/discord/types.ts +32 -0
  48. package/src/platforms/kakaotalk/client.test.ts +96 -4
  49. package/src/platforms/kakaotalk/client.ts +16 -5
@@ -2,11 +2,52 @@ import { afterEach, beforeEach, expect, spyOn, it } from 'bun:test'
2
2
 
3
3
  import { DiscordClient } from '../client'
4
4
  import { DiscordCredentialManager } from '../credential-manager'
5
+ import { meAction } from './member'
5
6
 
7
+ let clientGetMyGuildMemberSpy: ReturnType<typeof spyOn>
8
+ let clientListRolesSpy: ReturnType<typeof spyOn>
6
9
  let clientSearchMembersSpy: ReturnType<typeof spyOn>
10
+ let consoleLogSpy: ReturnType<typeof spyOn>
7
11
  let credManagerLoadSpy: ReturnType<typeof spyOn>
8
12
 
9
13
  beforeEach(() => {
14
+ clientGetMyGuildMemberSpy = spyOn(DiscordClient.prototype, 'getMyGuildMember').mockResolvedValue({
15
+ user: {
16
+ id: 'user-1',
17
+ username: 'alice',
18
+ global_name: 'Alice Smith',
19
+ avatar: 'avatar-hash-1',
20
+ bot: false,
21
+ },
22
+ nick: 'AliceNick',
23
+ roles: ['role-low', 'role-missing', 'role-high'],
24
+ joined_at: '2024-01-15T10:00:00Z',
25
+ deaf: false,
26
+ mute: false,
27
+ flags: 0,
28
+ })
29
+ clientListRolesSpy = spyOn(DiscordClient.prototype, 'listRoles').mockResolvedValue([
30
+ {
31
+ id: 'role-low',
32
+ name: 'Member',
33
+ color: 0,
34
+ hoist: false,
35
+ position: 1,
36
+ permissions: '1024',
37
+ managed: false,
38
+ mentionable: false,
39
+ },
40
+ {
41
+ id: 'role-high',
42
+ name: 'Admin',
43
+ color: 16711680,
44
+ hoist: true,
45
+ position: 10,
46
+ permissions: '8',
47
+ managed: false,
48
+ mentionable: true,
49
+ },
50
+ ])
10
51
  clientSearchMembersSpy = spyOn(DiscordClient.prototype, 'searchMembers').mockResolvedValue([
11
52
  {
12
53
  user: {
@@ -47,10 +88,14 @@ beforeEach(() => {
47
88
  'guild-1': { server_id: 'guild-1', server_name: 'Test Guild' },
48
89
  },
49
90
  })
91
+ consoleLogSpy = spyOn(console, 'log').mockImplementation(() => {})
50
92
  })
51
93
 
52
94
  afterEach(() => {
95
+ clientGetMyGuildMemberSpy?.mockRestore()
96
+ clientListRolesSpy?.mockRestore()
53
97
  clientSearchMembersSpy?.mockRestore()
98
+ consoleLogSpy?.mockRestore()
54
99
  credManagerLoadSpy?.mockRestore()
55
100
  })
56
101
 
@@ -97,3 +142,135 @@ it('search: uses default limit of 10', async () => {
97
142
 
98
143
  expect(members).toHaveLength(2)
99
144
  })
145
+
146
+ it("me: returns the current user's member object", async () => {
147
+ await meAction(undefined, {})
148
+
149
+ const output = JSON.parse(consoleLogSpy.mock.calls.at(-1)?.[0] as string)
150
+ expect(output).toMatchObject({
151
+ user: {
152
+ id: 'user-1',
153
+ username: 'alice',
154
+ global_name: 'Alice Smith',
155
+ avatar: 'avatar-hash-1',
156
+ bot: false,
157
+ },
158
+ nick: 'AliceNick',
159
+ joined_at: '2024-01-15T10:00:00Z',
160
+ deaf: false,
161
+ mute: false,
162
+ flags: 0,
163
+ })
164
+ expect(clientGetMyGuildMemberSpy).toHaveBeenCalledWith('guild-1')
165
+ expect(clientListRolesSpy).toHaveBeenCalledWith('guild-1')
166
+ })
167
+
168
+ it('me: returns roles resolved to names (not raw IDs)', async () => {
169
+ await meAction('guild-2', {})
170
+
171
+ const output = JSON.parse(consoleLogSpy.mock.calls.at(-1)?.[0] as string)
172
+ expect(output.roles.map((role: { name: string }) => role.name)).toEqual(['Admin', 'Member'])
173
+ expect(output.roles).not.toContain('role-high')
174
+ expect(clientGetMyGuildMemberSpy).toHaveBeenCalledWith('guild-2')
175
+ })
176
+
177
+ it('me: roles are sorted by position descending', async () => {
178
+ await meAction(undefined, {})
179
+
180
+ const output = JSON.parse(consoleLogSpy.mock.calls.at(-1)?.[0] as string)
181
+ expect(output.roles.map((role: { position: number }) => role.position)).toEqual([10, 1])
182
+ })
183
+
184
+ it('me: role IDs with no matching role definition are dropped', async () => {
185
+ await meAction(undefined, {})
186
+
187
+ const output = JSON.parse(consoleLogSpy.mock.calls.at(-1)?.[0] as string)
188
+ expect(output.roles).toHaveLength(2)
189
+ expect(output.roles.map((role: { id: string }) => role.id)).not.toContain('role-missing')
190
+ })
191
+
192
+ it('me: breaks equal-position ties by snowflake, ranking the smaller ID higher', async () => {
193
+ // given two roles sharing a position whose IDs collide once coerced to Number
194
+ clientGetMyGuildMemberSpy.mockResolvedValue({
195
+ user: { id: 'user-1', username: 'alice', global_name: 'Alice Smith', avatar: 'avatar-hash-1', bot: false },
196
+ nick: null,
197
+ roles: ['1234567890123456789', '1234567890123456788'],
198
+ joined_at: '2024-01-15T10:00:00Z',
199
+ deaf: false,
200
+ mute: false,
201
+ flags: 0,
202
+ })
203
+ clientListRolesSpy.mockResolvedValue([
204
+ {
205
+ id: '1234567890123456789',
206
+ name: 'Newer',
207
+ color: 0,
208
+ position: 5,
209
+ hoist: false,
210
+ managed: false,
211
+ mentionable: false,
212
+ permissions: '0',
213
+ },
214
+ {
215
+ id: '1234567890123456788',
216
+ name: 'Older',
217
+ color: 0,
218
+ position: 5,
219
+ hoist: false,
220
+ managed: false,
221
+ mentionable: false,
222
+ permissions: '0',
223
+ },
224
+ ])
225
+
226
+ await meAction(undefined, {})
227
+
228
+ const output = JSON.parse(consoleLogSpy.mock.calls.at(-1)?.[0] as string)
229
+ expect(output.roles.map((role: { name: string }) => role.name)).toEqual(['Older', 'Newer'])
230
+ })
231
+
232
+ it('me: ranks by position before falling back to the snowflake', async () => {
233
+ clientGetMyGuildMemberSpy.mockResolvedValue({
234
+ user: { id: 'user-1', username: 'alice', global_name: 'Alice Smith', avatar: 'avatar-hash-1', bot: false },
235
+ nick: null,
236
+ roles: ['100', '200'],
237
+ joined_at: '2024-01-15T10:00:00Z',
238
+ deaf: false,
239
+ mute: false,
240
+ flags: 0,
241
+ })
242
+ clientListRolesSpy.mockResolvedValue([
243
+ {
244
+ id: '100',
245
+ name: 'Low',
246
+ color: 0,
247
+ position: 1,
248
+ hoist: false,
249
+ managed: false,
250
+ mentionable: false,
251
+ permissions: '0',
252
+ },
253
+ {
254
+ id: '200',
255
+ name: 'High',
256
+ color: 0,
257
+ position: 9,
258
+ hoist: false,
259
+ managed: false,
260
+ mentionable: false,
261
+ permissions: '0',
262
+ },
263
+ ])
264
+
265
+ await meAction(undefined, {})
266
+
267
+ const output = JSON.parse(consoleLogSpy.mock.calls.at(-1)?.[0] as string)
268
+ expect(output.roles.map((role: { name: string }) => role.name)).toEqual(['High', 'Low'])
269
+ })
270
+
271
+ it('me: preserves the raw role IDs even when a definition is unresolvable', async () => {
272
+ await meAction(undefined, {})
273
+
274
+ const output = JSON.parse(consoleLogSpy.mock.calls.at(-1)?.[0] as string)
275
+ expect(output.role_ids).toEqual(['role-low', 'role-missing', 'role-high'])
276
+ })
@@ -5,6 +5,7 @@ import { formatOutput } from '@/shared/utils/output'
5
5
 
6
6
  import { DiscordClient } from '../client'
7
7
  import { DiscordCredentialManager } from '../credential-manager'
8
+ import type { DiscordRole } from '../types'
8
9
 
9
10
  async function searchAction(
10
11
  guildId: string,
@@ -46,6 +47,80 @@ async function searchAction(
46
47
  }
47
48
  }
48
49
 
50
+ export async function meAction(guildId: string | undefined, options: { pretty?: boolean }): Promise<void> {
51
+ try {
52
+ const credManager = new DiscordCredentialManager()
53
+ const config = await credManager.load()
54
+
55
+ if (!config.token) {
56
+ console.log(formatOutput({ error: 'Not authenticated. Run "auth extract" first.' }, options.pretty))
57
+ process.exit(1)
58
+ }
59
+
60
+ const targetGuildId = guildId ?? config.current_server
61
+ if (!targetGuildId) {
62
+ console.log(formatOutput({ error: 'No current server set. Run "server switch <id>" first.' }, options.pretty))
63
+ process.exit(1)
64
+ }
65
+
66
+ const client = await new DiscordClient().login({ token: config.token })
67
+ const [member, roles] = await Promise.all([client.getMyGuildMember(targetGuildId), client.listRoles(targetGuildId)])
68
+ const rolesById = new Map<string, DiscordRole>(roles.map((role) => [role.id, role]))
69
+ const resolvedRoles = member.roles
70
+ .map((roleId) => rolesById.get(roleId))
71
+ .filter((role): role is DiscordRole => role !== undefined)
72
+ .sort(compareRolesByHierarchy)
73
+ .map((role) => ({
74
+ id: role.id,
75
+ name: role.name,
76
+ color: role.color,
77
+ position: role.position,
78
+ hoist: role.hoist,
79
+ managed: role.managed,
80
+ mentionable: role.mentionable,
81
+ permissions: role.permissions,
82
+ }))
83
+
84
+ const output = {
85
+ user: {
86
+ id: member.user.id,
87
+ username: member.user.username,
88
+ global_name: member.user.global_name,
89
+ avatar: member.user.avatar,
90
+ bot: member.user.bot,
91
+ },
92
+ nick: member.nick,
93
+ role_ids: member.roles,
94
+ roles: resolvedRoles,
95
+ joined_at: member.joined_at,
96
+ deaf: member.deaf,
97
+ mute: member.mute,
98
+ flags: member.flags,
99
+ }
100
+
101
+ console.log(formatOutput(output, options.pretty))
102
+ } catch (error) {
103
+ handleError(error as Error)
104
+ }
105
+ }
106
+
107
+ // Discord ranks roles by descending position and breaks ties on the snowflake,
108
+ // where the smaller ID is the higher role. Snowflakes exceed Number.MAX_SAFE_INTEGER,
109
+ // so they are compared as BigInt rather than subtracted.
110
+ function compareRolesByHierarchy(left: DiscordRole, right: DiscordRole): number {
111
+ if (left.position !== right.position) {
112
+ return right.position - left.position
113
+ }
114
+
115
+ const leftId = BigInt(left.id)
116
+ const rightId = BigInt(right.id)
117
+ if (leftId === rightId) {
118
+ return 0
119
+ }
120
+
121
+ return leftId < rightId ? -1 : 1
122
+ }
123
+
49
124
  export const memberCommand = new Command('member')
50
125
  .description('Member commands')
51
126
  .addCommand(
@@ -57,3 +132,10 @@ export const memberCommand = new Command('member')
57
132
  .option('--pretty', 'Pretty print JSON output')
58
133
  .action(searchAction),
59
134
  )
135
+ .addCommand(
136
+ new Command('me')
137
+ .description('Get your own membership and roles in a guild')
138
+ .argument('[guild-id]', 'Guild ID (defaults to current server)')
139
+ .option('--pretty', 'Pretty print JSON output')
140
+ .action(meAction),
141
+ )
@@ -27,6 +27,9 @@ export type {
27
27
  DiscordReaction,
28
28
  DiscordReadState,
29
29
  DiscordRelationship,
30
+ DiscordRole,
31
+ DiscordRoleColors,
32
+ DiscordRoleTags,
30
33
  DiscordSearchOptions,
31
34
  DiscordSearchResponse,
32
35
  DiscordSearchResult,
@@ -207,6 +207,38 @@ export interface DiscordGuildMember {
207
207
  flags: number
208
208
  }
209
209
 
210
+ export interface DiscordRoleColors {
211
+ primary_color: number
212
+ secondary_color: number | null
213
+ tertiary_color: number | null
214
+ }
215
+
216
+ export interface DiscordRoleTags {
217
+ bot_id?: string
218
+ integration_id?: string
219
+ premium_subscriber?: null
220
+ subscription_listing_id?: string
221
+ available_for_purchase?: null
222
+ guild_connections?: null
223
+ }
224
+
225
+ export interface DiscordRole {
226
+ id: string
227
+ name: string
228
+ /** @deprecated Discord still returns this; prefer {@link DiscordRoleColors.primary_color}. */
229
+ color: number
230
+ colors: DiscordRoleColors
231
+ hoist: boolean
232
+ position: number
233
+ permissions: string
234
+ managed: boolean
235
+ mentionable: boolean
236
+ flags: number
237
+ icon?: string | null
238
+ unicode_emoji?: string | null
239
+ tags?: DiscordRoleTags
240
+ }
241
+
210
242
  export interface DiscordUserProfile {
211
243
  user: DiscordUser & { bio?: string }
212
244
  connected_accounts: Array<{
@@ -805,12 +805,12 @@ describe('KakaoTalkClient', () => {
805
805
 
806
806
  describe('getMessages', () => {
807
807
  it('falls back to SYNCMSG when MCHATLOGS succeeds with an empty result', async () => {
808
+ const loginResult = structuredClone(DEFAULT_LOGIN_RESULT)
809
+ loginResult.chatDatas[0]!.ll = makeLong(10)
810
+ mockLogin.mockResolvedValueOnce(loginResult)
808
811
  mockGetChatLogs.mockResolvedValueOnce({
809
812
  body: { status: 0, chatLogs: [], eof: true },
810
813
  })
811
- mockGetChatInfo.mockResolvedValueOnce({
812
- body: { status: 0, l: makeLong(10) },
813
- })
814
814
  mockSyncMessages.mockResolvedValueOnce({
815
815
  body: {
816
816
  status: 0,
@@ -826,8 +826,100 @@ describe('KakaoTalkClient', () => {
826
826
 
827
827
  expect(messages).toHaveLength(1)
828
828
  expect(messages[0]?.message).toBe('fallback')
829
- expect(mockGetChatInfo).toHaveBeenCalledTimes(1)
829
+ expect(mockGetChatInfo).not.toHaveBeenCalled()
830
830
  expect(mockSyncMessages).toHaveBeenCalledTimes(1)
831
+ expect(mockSyncMessages.mock.calls[0]?.[3]?.toString()).toBe('10')
832
+
833
+ client.close()
834
+ })
835
+
836
+ it('does not enter or sync a room when the MCHATLOGS cursor is already current', async () => {
837
+ mockGetChatLogs.mockResolvedValueOnce({
838
+ body: { status: 0, chatLogs: [], eof: true },
839
+ })
840
+
841
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
842
+ const messages = await client.getMessages('100', { from: '999' })
843
+
844
+ expect(messages).toEqual([])
845
+ expect(mockGetChatInfo).not.toHaveBeenCalled()
846
+ expect(mockSyncMessages).not.toHaveBeenCalled()
847
+
848
+ client.close()
849
+ })
850
+
851
+ it('uses SYNCMSG without entering the room when the chat-list watermark is ahead of the cursor', async () => {
852
+ const loginResult = structuredClone(DEFAULT_LOGIN_RESULT)
853
+ loginResult.chatDatas[0]!.ll = makeLong(10)
854
+ mockLogin.mockResolvedValueOnce(loginResult)
855
+ mockGetChatLogs.mockResolvedValueOnce({
856
+ body: { status: 0, chatLogs: [], eof: true },
857
+ })
858
+ mockSyncMessages.mockResolvedValueOnce({
859
+ body: {
860
+ status: 0,
861
+ isOK: true,
862
+ chatLogs: [
863
+ { logId: makeLong(10), chatId: 100, type: 1, authorId: 42, message: 'incremental', sendAt: 1700000001 },
864
+ ],
865
+ },
866
+ })
867
+
868
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
869
+ const messages = await client.getMessages('100', { from: '9' })
870
+
871
+ expect(messages).toHaveLength(1)
872
+ expect(messages[0]?.message).toBe('incremental')
873
+ expect(mockGetChatInfo).not.toHaveBeenCalled()
874
+ expect(mockSyncMessages.mock.calls[0]?.[2]?.toString()).toBe('9')
875
+ expect(mockSyncMessages.mock.calls[0]?.[3]?.toString()).toBe('10')
876
+
877
+ client.close()
878
+ })
879
+
880
+ it('uses the last-message log ID when the primary chat-list watermark is missing', async () => {
881
+ const loginResult = structuredClone(DEFAULT_LOGIN_RESULT)
882
+ const chat = loginResult.chatDatas[0]! as Record<string, unknown>
883
+ delete chat.ll
884
+ chat.l = { logId: makeLong(12) }
885
+ mockLogin.mockResolvedValueOnce(loginResult)
886
+ mockGetChatLogs.mockResolvedValueOnce({
887
+ body: { status: 0, chatLogs: [], eof: true },
888
+ })
889
+ mockSyncMessages.mockResolvedValueOnce({
890
+ body: {
891
+ status: 0,
892
+ isOK: true,
893
+ chatLogs: [
894
+ { logId: makeLong(12), chatId: 100, type: 1, authorId: 42, message: 'last-log', sendAt: 1700000001 },
895
+ ],
896
+ },
897
+ })
898
+
899
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
900
+ const messages = await client.getMessages('100', { from: '11' })
901
+
902
+ expect(messages).toHaveLength(1)
903
+ expect(messages[0]?.message).toBe('last-log')
904
+ expect(mockGetChatInfo).not.toHaveBeenCalled()
905
+ expect(mockSyncMessages.mock.calls[0]?.[2]?.toString()).toBe('11')
906
+ expect(mockSyncMessages.mock.calls[0]?.[3]?.toString()).toBe('12')
907
+
908
+ client.close()
909
+ })
910
+
911
+ it('returns empty without entering the room when no chat-list watermark is available', async () => {
912
+ mockLogin.mockResolvedValueOnce({ ...structuredClone(DEFAULT_LOGIN_RESULT), chatDatas: [] })
913
+ mockGetChatLogs.mockResolvedValueOnce({
914
+ body: { status: 0, chatLogs: [], eof: true },
915
+ })
916
+
917
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
918
+ const messages = await client.getMessages('100')
919
+
920
+ expect(messages).toEqual([])
921
+ expect(mockGetChatInfo).not.toHaveBeenCalled()
922
+ expect(mockSyncMessages).not.toHaveBeenCalled()
831
923
 
832
924
  client.close()
833
925
  })
@@ -242,6 +242,14 @@ function getOpenLinkId(chat: ChatData): Long | null {
242
242
  return bsonToLong(chat.li) ?? null
243
243
  }
244
244
 
245
+ function getLoginMaxLogId(loginResult: LoginListResponse, chatId: string): Long | null {
246
+ const chat = (loginResult.chatDatas ?? []).find((entry) => longToString(entry.c) === chatId)
247
+ if (!chat) return null
248
+
249
+ const lastLog = chat.l as Record<string, unknown> | null | undefined
250
+ return bsonToLong(chat.ll) ?? bsonToLong(lastLog?.logId) ?? null
251
+ }
252
+
245
253
  function matchesSearch(chat: ChatData, term: string): boolean {
246
254
  const names = (chat.k ?? []) as string[]
247
255
  const lower = term.toLowerCase()
@@ -827,7 +835,7 @@ export class KakaoTalkClient {
827
835
  }
828
836
 
829
837
  async getMessages(chatId: string, options?: { count?: number; from?: string }): Promise<KakaoMessage[]> {
830
- return this.executeWithReconnect(async ({ session }) => {
838
+ return this.executeWithReconnect(async ({ session, loginResult }) => {
831
839
  try {
832
840
  const count = options?.count ?? 20
833
841
  const cursor = options?.from ? parseLong(options.from) : undefined
@@ -880,10 +888,13 @@ export class KakaoTalkClient {
880
888
  return formatMessages(allMessages, count, chatId, this.nameCache)
881
889
  }
882
890
 
883
- // Fetch fresh lastLogId via CHATONROOM (not the stale login-time snapshot)
884
- const chatInfo = await session.getChatInfo(cid)
885
- const chatBody = chatInfo.body as Record<string, unknown>
886
- const maxLogId = bsonToLong(chatBody.l)
891
+ // CHATONROOM represents entering a room and can advance unread state on
892
+ // the server. Use the login-time chat-list watermark for the read-only
893
+ // SYNCMSG fallback instead, and skip sync when the cursor is current.
894
+ const maxLogId = getLoginMaxLogId(loginResult, chatId)
895
+ if (!maxLogId || maxLogId.lessThanOrEqual(cur)) {
896
+ return []
897
+ }
887
898
 
888
899
  let reachedEnd = false
889
900
  for (let page = 0; page < MAX_PAGES; page++) {