agent-messenger 2.13.1 → 2.14.1

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 (45) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/dist/package.json +1 -1
  3. package/dist/src/platforms/kakaotalk/client.d.ts +11 -1
  4. package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
  5. package/dist/src/platforms/kakaotalk/client.js +49 -0
  6. package/dist/src/platforms/kakaotalk/client.js.map +1 -1
  7. package/dist/src/platforms/kakaotalk/commands/message.d.ts.map +1 -1
  8. package/dist/src/platforms/kakaotalk/commands/message.js +21 -1
  9. package/dist/src/platforms/kakaotalk/commands/message.js.map +1 -1
  10. package/dist/src/platforms/kakaotalk/protocol/config.d.ts +1 -1
  11. package/dist/src/platforms/kakaotalk/protocol/config.d.ts.map +1 -1
  12. package/dist/src/platforms/kakaotalk/protocol/config.js +1 -1
  13. package/dist/src/platforms/kakaotalk/protocol/config.js.map +1 -1
  14. package/dist/src/platforms/kakaotalk/protocol/session.d.ts +6 -0
  15. package/dist/src/platforms/kakaotalk/protocol/session.d.ts.map +1 -1
  16. package/dist/src/platforms/kakaotalk/protocol/session.js +14 -0
  17. package/dist/src/platforms/kakaotalk/protocol/session.js.map +1 -1
  18. package/dist/src/platforms/kakaotalk/types.d.ts +12 -0
  19. package/dist/src/platforms/kakaotalk/types.d.ts.map +1 -1
  20. package/dist/src/platforms/kakaotalk/types.js +6 -0
  21. package/dist/src/platforms/kakaotalk/types.js.map +1 -1
  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 +1 -1
  26. package/skills/agent-discordbot/SKILL.md +1 -1
  27. package/skills/agent-instagram/SKILL.md +1 -1
  28. package/skills/agent-kakaotalk/SKILL.md +25 -1
  29. package/skills/agent-line/SKILL.md +1 -1
  30. package/skills/agent-slack/SKILL.md +1 -1
  31. package/skills/agent-slackbot/SKILL.md +1 -1
  32. package/skills/agent-teams/SKILL.md +1 -1
  33. package/skills/agent-telegram/SKILL.md +1 -1
  34. package/skills/agent-telegrambot/SKILL.md +1 -1
  35. package/skills/agent-webex/SKILL.md +1 -1
  36. package/skills/agent-wechatbot/SKILL.md +1 -1
  37. package/skills/agent-whatsapp/SKILL.md +1 -1
  38. package/skills/agent-whatsappbot/SKILL.md +1 -1
  39. package/src/platforms/kakaotalk/client.test.ts +171 -0
  40. package/src/platforms/kakaotalk/client.ts +59 -1
  41. package/src/platforms/kakaotalk/commands/message.test.ts +79 -0
  42. package/src/platforms/kakaotalk/commands/message.ts +28 -0
  43. package/src/platforms/kakaotalk/protocol/config.ts +1 -1
  44. package/src/platforms/kakaotalk/protocol/session.ts +14 -0
  45. package/src/platforms/kakaotalk/types.ts +14 -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.13.1
4
+ version: 2.14.1
5
5
  allowed-tools: Bash(agent-kakaotalk:*)
6
6
  metadata:
7
7
  openclaw:
@@ -335,11 +335,35 @@ agent-kakaotalk message list <chat-id> --pretty
335
335
  agent-kakaotalk message send <chat-id> "Hello world"
336
336
  agent-kakaotalk message send <chat-id> "Hello world" --pretty
337
337
 
338
+ # Mark messages as read up to a given log ID
339
+ agent-kakaotalk message mark-read <chat-id> <log-id>
340
+ agent-kakaotalk message mark-read <chat-id> <log-id> --pretty
341
+ agent-kakaotalk message mark-read <chat-id> <log-id> --link-id <li> # open chats only
342
+
338
343
  # Use a specific account
339
344
  agent-kakaotalk message list <chat-id> --account <account-id>
340
345
  agent-kakaotalk message send <chat-id> "Hello" --account <account-id>
346
+ agent-kakaotalk message mark-read <chat-id> <log-id> --account <account-id>
341
347
  ```
342
348
 
349
+ #### Mark as Read
350
+
351
+ `message mark-read` sends a LOCO `NOTIREAD` packet to advance the server-side read watermark for a chat. The watermark is a `log_id` — typically the latest message's `log_id` from `message list` — not a timestamp.
352
+
353
+ Output (JSON by default; `--pretty` pretty-prints the same JSON):
354
+
355
+ ```json
356
+ { "success": true, "status_code": 0, "chat_id": "9876543210", "watermark": "123456789" }
357
+ ```
358
+
359
+ The process exits non-zero when `success` is `false` so shell scripts can branch on it.
360
+
361
+ Notes:
362
+ - Caller-driven and explicit — there is no auto-mark-on-receive behavior. Blind acking incoming messages would be a distinct behavioral fingerprint against an undocumented protocol and is intentionally out of scope.
363
+ - Observed in testing on a sub-device tablet slot (the default `agent-messenger` slot). KakaoTalk does not support third-party clients; server behavior may vary by chat type, account region, or client version. Use sparingly; avoid tight loops.
364
+ - The phone's home-screen OS unread badge may lag until the phone client foregrounds; the in-app counter updates faster. Observed quirk, not a guaranteed contract.
365
+ - For open chats (오픈채팅) pass `--link-id <li>` (the `li` field on the open chat). Without it the server returns a non-zero `body.status`, which the CLI reports as `"success": false` with the server's status code, and exits non-zero.
366
+
343
367
  #### Message List Output
344
368
 
345
369
  Each message includes:
@@ -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.13.1
4
+ version: 2.14.1
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.13.1
4
+ version: 2.14.1
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.13.1
4
+ version: 2.14.1
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.13.1
4
+ version: 2.14.1
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.13.1
4
+ version: 2.14.1
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.13.1
4
+ version: 2.14.1
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.13.1
4
+ version: 2.14.1
5
5
  allowed-tools: Bash(agent-webex:*)
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.13.1
4
+ version: 2.14.1
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.13.1
4
+ version: 2.14.1
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.13.1
4
+ version: 2.14.1
5
5
  allowed-tools: Bash(agent-whatsappbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -13,6 +13,7 @@ const mockGetAllMembers = mock(() => Promise.resolve({}))
13
13
  const mockGetMembersByIds = mock(() => Promise.resolve({}))
14
14
  const mockSyncMessages = mock(() => Promise.resolve({}))
15
15
  const mockSendMessage = mock(() => Promise.resolve({}))
16
+ const mockMarkRead = mock(() => Promise.resolve({}))
16
17
  const mockClose = mock(() => {})
17
18
  const mockOnClose = mock((_handler: () => void) => {})
18
19
  const mockOnPush = mock((_handler: (packet: unknown) => void) => {})
@@ -29,6 +30,7 @@ mock.module('./protocol/session', () => ({
29
30
  getMembersByIds = mockGetMembersByIds
30
31
  syncMessages = mockSyncMessages
31
32
  sendMessage = mockSendMessage
33
+ markRead = mockMarkRead
32
34
  close = mockClose
33
35
  onClose = mockOnClose
34
36
  onPush = mockOnPush
@@ -50,6 +52,7 @@ function resetAllMocks() {
50
52
  mockGetMembersByIds.mockReset()
51
53
  mockSyncMessages.mockReset()
52
54
  mockSendMessage.mockReset()
55
+ mockMarkRead.mockReset()
53
56
  mockClose.mockReset()
54
57
  mockOnClose.mockReset()
55
58
  mockOnPush.mockReset()
@@ -910,6 +913,174 @@ describe('KakaoTalkClient', () => {
910
913
  })
911
914
  })
912
915
 
916
+ describe('markRead', () => {
917
+ it('calls session.markRead with parsed chatId/watermark and no linkId for normal chat', async () => {
918
+ // given
919
+ mockMarkRead.mockResolvedValueOnce({ statusCode: 0, body: { status: 0 } })
920
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
921
+
922
+ // when
923
+ const result = await client.markRead('100', '42')
924
+
925
+ // then
926
+ expect(mockMarkRead).toHaveBeenCalledTimes(1)
927
+ const [chatIdArg, watermarkArg, linkIdArg] = mockMarkRead.mock.calls[0] as [
928
+ { toString(): string },
929
+ { toString(): string },
930
+ unknown,
931
+ ]
932
+ expect(chatIdArg.toString()).toBe('100')
933
+ expect(watermarkArg.toString()).toBe('42')
934
+ expect(linkIdArg).toBeUndefined()
935
+ expect(result).toEqual({ success: true, status_code: 0, chat_id: '100', watermark: '42' })
936
+
937
+ client.close()
938
+ })
939
+
940
+ it('forwards linkId when caller passes it (open chat)', async () => {
941
+ // given
942
+ mockMarkRead.mockResolvedValueOnce({ statusCode: 0, body: { status: 0 } })
943
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
944
+
945
+ // when
946
+ await client.markRead('200', '999', { linkId: '77777' })
947
+
948
+ // then
949
+ const [, , linkIdArg] = mockMarkRead.mock.calls[0] as [unknown, unknown, { toString(): string }]
950
+ expect(linkIdArg).toBeDefined()
951
+ expect(linkIdArg.toString()).toBe('77777')
952
+
953
+ client.close()
954
+ })
955
+
956
+ it('reports success when body.status is absent (treated as 0)', async () => {
957
+ // given
958
+ mockMarkRead.mockResolvedValueOnce({ statusCode: 0, body: {} })
959
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
960
+
961
+ // when
962
+ const result = await client.markRead('100', '42')
963
+
964
+ // then
965
+ expect(result).toEqual({ success: true, status_code: 0, chat_id: '100', watermark: '42' })
966
+
967
+ client.close()
968
+ })
969
+
970
+ it('reports failure from body.status (not transport statusCode) - the open-chat-missing-link case', async () => {
971
+ // given
972
+ mockMarkRead.mockResolvedValueOnce({ statusCode: 0, body: { status: -500 } })
973
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
974
+
975
+ // when
976
+ const result = await client.markRead('100', '42')
977
+
978
+ // then
979
+ expect(result).toEqual({ success: false, status_code: -500, chat_id: '100', watermark: '42' })
980
+
981
+ client.close()
982
+ })
983
+
984
+ it('throws (so executeWithReconnect retries) on transport-level statusCode != 0', async () => {
985
+ // given - simulates LocoConnection.handleClose synthetic packet
986
+ mockMarkRead.mockRejectedValue(new Error('NOTIREAD failed: statusCode=-1'))
987
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
988
+
989
+ // when / then
990
+ try {
991
+ await client.markRead('100', '42')
992
+ throw new Error('expected to throw')
993
+ } catch (e) {
994
+ expect(e).toBeInstanceOf(KakaoTalkError)
995
+ expect((e as KakaoTalkError).code).toBe('mark_read_failed')
996
+ }
997
+
998
+ client.close()
999
+ })
1000
+
1001
+ it('treats synthetic disconnect packet (statusCode: -1) as transport failure (throws)', async () => {
1002
+ // given
1003
+ mockMarkRead.mockResolvedValueOnce({ statusCode: -1, body: { error: 'connection closed' } })
1004
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1005
+
1006
+ // when / then
1007
+ try {
1008
+ await client.markRead('100', '42')
1009
+ throw new Error('expected to throw')
1010
+ } catch (e) {
1011
+ expect(e).toBeInstanceOf(KakaoTalkError)
1012
+ expect((e as KakaoTalkError).code).toBe('mark_read_failed')
1013
+ }
1014
+
1015
+ client.close()
1016
+ })
1017
+
1018
+ it('throws KakaoTalkError(invalid_chat_id) for non-numeric chatId', async () => {
1019
+ // given
1020
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1021
+
1022
+ // when / then
1023
+ try {
1024
+ await client.markRead('not-a-number', '42')
1025
+ throw new Error('expected to throw')
1026
+ } catch (e) {
1027
+ expect(e).toBeInstanceOf(KakaoTalkError)
1028
+ expect((e as KakaoTalkError).code).toBe('invalid_chat_id')
1029
+ }
1030
+
1031
+ client.close()
1032
+ })
1033
+
1034
+ it('throws KakaoTalkError(invalid_log_id) for non-numeric logId', async () => {
1035
+ // given
1036
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1037
+
1038
+ // when / then
1039
+ try {
1040
+ await client.markRead('100', 'not-a-number')
1041
+ throw new Error('expected to throw')
1042
+ } catch (e) {
1043
+ expect(e).toBeInstanceOf(KakaoTalkError)
1044
+ expect((e as KakaoTalkError).code).toBe('invalid_log_id')
1045
+ }
1046
+
1047
+ client.close()
1048
+ })
1049
+
1050
+ it('throws KakaoTalkError(invalid_link_id) for non-numeric linkId', async () => {
1051
+ // given
1052
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1053
+
1054
+ // when / then
1055
+ try {
1056
+ await client.markRead('100', '42', { linkId: 'not-a-number' })
1057
+ throw new Error('expected to throw')
1058
+ } catch (e) {
1059
+ expect(e).toBeInstanceOf(KakaoTalkError)
1060
+ expect((e as KakaoTalkError).code).toBe('invalid_link_id')
1061
+ }
1062
+
1063
+ client.close()
1064
+ })
1065
+
1066
+ it('wraps transport errors as KakaoTalkError(mark_read_failed)', async () => {
1067
+ // given
1068
+ mockMarkRead.mockRejectedValue(new Error('Socket closed'))
1069
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1070
+
1071
+ // when / then
1072
+ try {
1073
+ await client.markRead('100', '42')
1074
+ throw new Error('expected to throw')
1075
+ } catch (e) {
1076
+ expect(e).toBeInstanceOf(KakaoTalkError)
1077
+ expect((e as KakaoTalkError).code).toBe('mark_read_failed')
1078
+ }
1079
+
1080
+ client.close()
1081
+ })
1082
+ })
1083
+
913
1084
  describe('getMembers / getMembersByIds', () => {
914
1085
  it('returns formatted members from GETMEM with normalized fields', async () => {
915
1086
  mockGetAllMembers.mockResolvedValueOnce({
@@ -10,7 +10,15 @@ import { warn } from '@/shared/utils/stderr'
10
10
  import { LANG, PC_OS_NAME, getLocoDeviceConfig } from './protocol/config'
11
11
  import { LocoSession } from './protocol/session'
12
12
  import type { ChatListResponse, LocoPacket, LoginListResponse, SyncState } from './protocol/types'
13
- import type { KakaoChat, KakaoDeviceType, KakaoMember, KakaoMessage, KakaoProfile, KakaoSendResult } from './types'
13
+ import type {
14
+ KakaoChat,
15
+ KakaoDeviceType,
16
+ KakaoMarkReadResult,
17
+ KakaoMember,
18
+ KakaoMessage,
19
+ KakaoProfile,
20
+ KakaoSendResult,
21
+ } from './types'
14
22
 
15
23
  export type KakaoSessionEvent =
16
24
  | { type: 'connected'; userId: string }
@@ -128,6 +136,22 @@ function parseUserId(userId: string): Long {
128
136
  }
129
137
  }
130
138
 
139
+ function parseLogId(logId: string): Long {
140
+ try {
141
+ return parseLong(logId)
142
+ } catch (cause) {
143
+ throw new KakaoTalkError(`Invalid logId: ${logId}`, 'invalid_log_id', { cause })
144
+ }
145
+ }
146
+
147
+ function parseLinkId(linkId: string): Long {
148
+ try {
149
+ return parseLong(linkId)
150
+ } catch (cause) {
151
+ throw new KakaoTalkError(`Invalid linkId: ${linkId}`, 'invalid_link_id', { cause })
152
+ }
153
+ }
154
+
131
155
  function formatChat(chat: ChatData, title: string | null, nameCache: MemberNameCache): KakaoChat {
132
156
  const memberNames = (chat.k ?? []) as string[]
133
157
  const lastLog = chat.l as Record<string, unknown> | null
@@ -858,6 +882,40 @@ export class KakaoTalkClient {
858
882
  })
859
883
  }
860
884
 
885
+ /**
886
+ * Advance the read watermark for `chatId` up to and including `logId`.
887
+ * The caller decides open vs normal: pass `opts.linkId` for open chats
888
+ * (오픈채팅) and omit it for normal chats. Open chats without a `linkId`
889
+ * are rejected by the server with a non-zero `body.status` — this method
890
+ * does not auto-detect.
891
+ */
892
+ async markRead(chatId: string, logId: string, opts?: { linkId?: string }): Promise<KakaoMarkReadResult> {
893
+ const parsedChatId = parseChatId(chatId)
894
+ const parsedWatermark = parseLogId(logId)
895
+ const parsedLinkId = opts?.linkId !== undefined ? parseLinkId(opts.linkId) : undefined
896
+
897
+ return this.executeWithReconnect(async ({ session }) => {
898
+ try {
899
+ const response = await session.markRead(parsedChatId, parsedWatermark, parsedLinkId)
900
+ // Throw on transport-level failure (incl. synthetic { statusCode: -1 }
901
+ // from LocoConnection.handleClose) so executeWithReconnect retries on
902
+ // a fresh session. The NOTIREAD command result lives in body.status.
903
+ if (response.statusCode !== 0) {
904
+ throw new Error(`NOTIREAD failed: statusCode=${response.statusCode}`)
905
+ }
906
+ const bodyStatus = typeof response.body.status === 'number' ? response.body.status : 0
907
+ return {
908
+ success: bodyStatus === 0,
909
+ status_code: bodyStatus,
910
+ chat_id: chatId,
911
+ watermark: logId,
912
+ }
913
+ } catch (error) {
914
+ throw wrapError(error, 'mark_read_failed')
915
+ }
916
+ })
917
+ }
918
+
861
919
  async getProfile(): Promise<KakaoProfile> {
862
920
  this.ensureAuth()
863
921
  try {
@@ -12,9 +12,16 @@ const mockGetMessages = mock(() =>
12
12
 
13
13
  const mockSendMessage = mock(() => Promise.resolve({ log_id: '2', message: 'Hi there', created_at: 2000 }))
14
14
 
15
+ const mockMarkRead = mock(() =>
16
+ Promise.resolve({ success: true, status_code: 0, chat_id: 'chat-123', watermark: '42' }),
17
+ )
18
+
19
+ const originalExit = process.exit
20
+
15
21
  const mockClient = {
16
22
  getMessages: mockGetMessages,
17
23
  sendMessage: mockSendMessage,
24
+ markRead: mockMarkRead,
18
25
  }
19
26
 
20
27
  mock.module('./shared', () => ({
@@ -30,6 +37,7 @@ describe('message commands', () => {
30
37
  mockWithKakaoClient.mockReset()
31
38
  mockGetMessages.mockReset()
32
39
  mockSendMessage.mockReset()
40
+ mockMarkRead.mockReset()
33
41
 
34
42
  mockWithKakaoClient.mockImplementation(async (_options: unknown, fn: (client: unknown) => Promise<unknown>) => {
35
43
  return fn(mockClient)
@@ -38,6 +46,9 @@ describe('message commands', () => {
38
46
  Promise.resolve([{ log_id: '1', message: 'Hello', sender_id: 'user-1', created_at: 1000 }]),
39
47
  )
40
48
  mockSendMessage.mockImplementation(() => Promise.resolve({ log_id: '2', message: 'Hi there', created_at: 2000 }))
49
+ mockMarkRead.mockImplementation(() =>
50
+ Promise.resolve({ success: true, status_code: 0, chat_id: 'chat-123', watermark: '42' }),
51
+ )
41
52
 
42
53
  consoleLogSpy = mock((..._args: unknown[]) => {})
43
54
  console.log = consoleLogSpy
@@ -45,6 +56,7 @@ describe('message commands', () => {
45
56
 
46
57
  afterEach(() => {
47
58
  console.log = originalConsoleLog
59
+ process.exit = originalExit
48
60
  })
49
61
 
50
62
  describe('list', () => {
@@ -101,4 +113,71 @@ describe('message commands', () => {
101
113
  )
102
114
  })
103
115
  })
116
+
117
+ describe('mark-read', () => {
118
+ it('calls markRead with chat-id and log-id, no opts for normal chat', async () => {
119
+ await messageCommand.parseAsync(['mark-read', 'chat-123', '42'], { from: 'user' })
120
+
121
+ expect(mockMarkRead).toHaveBeenCalledWith('chat-123', '42', undefined)
122
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0])
123
+ expect(output).toEqual({ success: true, status_code: 0, chat_id: 'chat-123', watermark: '42' })
124
+ })
125
+
126
+ it('forwards --link-id when provided (open chat)', async () => {
127
+ await messageCommand.parseAsync(['mark-read', 'chat-123', '42', '--link-id', '77777'], { from: 'user' })
128
+
129
+ expect(mockMarkRead).toHaveBeenCalledWith('chat-123', '42', { linkId: '77777' })
130
+ })
131
+
132
+ it('--pretty prints pretty-formatted JSON (consistent with list/send)', async () => {
133
+ await messageCommand.parseAsync(['mark-read', 'chat-123', '42', '--pretty'], { from: 'user' })
134
+
135
+ const printed = consoleLogSpy.mock.calls[0][0] as string
136
+ expect(printed).toContain('\n')
137
+ expect(JSON.parse(printed)).toEqual({
138
+ success: true,
139
+ status_code: 0,
140
+ chat_id: 'chat-123',
141
+ watermark: '42',
142
+ })
143
+ })
144
+
145
+ it('exits non-zero when result.success is false (e.g. open chat missing --link-id)', async () => {
146
+ const exitSpy = mock((_code?: number): never => {
147
+ throw new Error('process.exit called')
148
+ })
149
+ process.exit = exitSpy as unknown as typeof process.exit
150
+ mockMarkRead.mockImplementationOnce(() =>
151
+ Promise.resolve({ success: false, status_code: -500, chat_id: 'chat-123', watermark: '42' }),
152
+ )
153
+
154
+ try {
155
+ await messageCommand.parseAsync(['mark-read', 'chat-123', '42'], { from: 'user' })
156
+ } catch {
157
+ // process.exit stub throws to abort the action
158
+ }
159
+
160
+ expect(exitSpy).toHaveBeenCalledWith(1)
161
+ })
162
+
163
+ it('exits zero on success', async () => {
164
+ const exitSpy = mock((_code?: number): never => {
165
+ throw new Error('process.exit called')
166
+ })
167
+ process.exit = exitSpy as unknown as typeof process.exit
168
+
169
+ await messageCommand.parseAsync(['mark-read', 'chat-123', '42'], { from: 'user' })
170
+
171
+ expect(exitSpy).not.toHaveBeenCalled()
172
+ })
173
+
174
+ it('passes account option to withKakaoClient', async () => {
175
+ await messageCommand.parseAsync(['mark-read', 'chat-123', '42', '--account', 'my-account'], { from: 'user' })
176
+
177
+ expect(mockWithKakaoClient).toHaveBeenCalledWith(
178
+ expect.objectContaining({ account: 'my-account' }),
179
+ expect.any(Function),
180
+ )
181
+ })
182
+ })
104
183
  })
@@ -33,6 +33,24 @@ async function sendAction(
33
33
  }
34
34
  }
35
35
 
36
+ async function markReadAction(
37
+ chatId: string,
38
+ logId: string,
39
+ options: { account?: string; linkId?: string; pretty?: boolean },
40
+ ): Promise<void> {
41
+ try {
42
+ const result = await withKakaoClient(options, (client) =>
43
+ client.markRead(chatId, logId, options.linkId !== undefined ? { linkId: options.linkId } : undefined),
44
+ )
45
+ console.log(formatOutput(result, options.pretty))
46
+ if (!result.success) {
47
+ process.exit(1)
48
+ }
49
+ } catch (error) {
50
+ handleError(error as Error)
51
+ }
52
+ }
53
+
36
54
  export const messageCommand = new Command('message')
37
55
  .description('KakaoTalk message commands')
38
56
  .addCommand(
@@ -54,3 +72,13 @@ export const messageCommand = new Command('message')
54
72
  .option('--pretty', 'Pretty print JSON output')
55
73
  .action(sendAction),
56
74
  )
75
+ .addCommand(
76
+ new Command('mark-read')
77
+ .description('Mark messages in a chat room as read up to a given log ID')
78
+ .argument('<chat-id>', 'Chat room ID')
79
+ .argument('<log-id>', 'Watermark log ID (mark messages up to and including this log_id as read)')
80
+ .option('--account <id>', 'Use a specific KakaoTalk account')
81
+ .option('--link-id <li>', 'Open-chat link ID (REQUIRED for open chats / 오픈채팅)')
82
+ .option('--pretty', 'Pretty print JSON output')
83
+ .action(markReadAction),
84
+ )
@@ -36,7 +36,7 @@ export const LANG = 'ko'
36
36
  export const COUNTRY_ISO = 'KR'
37
37
  export const PROTOCOL_VERSION = '1'
38
38
 
39
- export const PING_INTERVAL_MS = 300_000
39
+ export const PING_INTERVAL_MS = 20_000
40
40
 
41
41
  export interface LocoDeviceConfig {
42
42
  os: string
@@ -203,6 +203,20 @@ export class LocoSession {
203
203
  })
204
204
  }
205
205
 
206
+ /**
207
+ * Advance the server-side read watermark for a chat (NOTIREAD). Open chats
208
+ * supply `linkId`; normal chats must omit it (the wire `li` key must be
209
+ * absent, not null/0).
210
+ */
211
+ async markRead(chatId: Long, watermark: Long, linkId?: Long): Promise<LocoPacket> {
212
+ if (!this.connection) throw new Error('Not connected')
213
+ const body: Record<string, unknown> = { chatId, watermark }
214
+ if (linkId !== undefined) {
215
+ body.li = linkId
216
+ }
217
+ return this.connection.sendPacket('NOTIREAD', body)
218
+ }
219
+
206
220
  onPush(handler: (packet: LocoPacket) => void): void {
207
221
  this.pushHandler = handler
208
222
  if (this.connection) {
@@ -118,6 +118,13 @@ export interface KakaoSendResult {
118
118
  sent_at: number
119
119
  }
120
120
 
121
+ export interface KakaoMarkReadResult {
122
+ success: boolean
123
+ status_code: number
124
+ chat_id: string
125
+ watermark: string
126
+ }
127
+
121
128
  export const KakaoChatSchema = z.object({
122
129
  chat_id: z.string(),
123
130
  type: z.number(),
@@ -166,6 +173,13 @@ export const KakaoSendResultSchema = z.object({
166
173
  sent_at: z.number(),
167
174
  })
168
175
 
176
+ export const KakaoMarkReadResultSchema = z.object({
177
+ success: z.boolean(),
178
+ status_code: z.number(),
179
+ chat_id: z.string(),
180
+ watermark: z.string(),
181
+ })
182
+
169
183
  export interface KakaoProfile {
170
184
  user_id: string
171
185
  nickname: string