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.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/package.json +1 -1
- package/dist/src/platforms/kakaotalk/client.d.ts +11 -1
- package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/client.js +49 -0
- package/dist/src/platforms/kakaotalk/client.js.map +1 -1
- package/dist/src/platforms/kakaotalk/commands/message.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/commands/message.js +21 -1
- package/dist/src/platforms/kakaotalk/commands/message.js.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/config.d.ts +1 -1
- package/dist/src/platforms/kakaotalk/protocol/config.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/config.js +1 -1
- package/dist/src/platforms/kakaotalk/protocol/config.js.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/session.d.ts +6 -0
- package/dist/src/platforms/kakaotalk/protocol/session.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/session.js +14 -0
- package/dist/src/platforms/kakaotalk/protocol/session.js.map +1 -1
- package/dist/src/platforms/kakaotalk/types.d.ts +12 -0
- package/dist/src/platforms/kakaotalk/types.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/types.js +6 -0
- package/dist/src/platforms/kakaotalk/types.js.map +1 -1
- 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 +1 -1
- package/skills/agent-discordbot/SKILL.md +1 -1
- package/skills/agent-instagram/SKILL.md +1 -1
- package/skills/agent-kakaotalk/SKILL.md +25 -1
- 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-wechatbot/SKILL.md +1 -1
- package/skills/agent-whatsapp/SKILL.md +1 -1
- package/skills/agent-whatsappbot/SKILL.md +1 -1
- package/src/platforms/kakaotalk/client.test.ts +171 -0
- package/src/platforms/kakaotalk/client.ts +59 -1
- package/src/platforms/kakaotalk/commands/message.test.ts +79 -0
- package/src/platforms/kakaotalk/commands/message.ts +28 -0
- package/src/platforms/kakaotalk/protocol/config.ts +1 -1
- package/src/platforms/kakaotalk/protocol/session.ts +14 -0
- 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.
|
|
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:
|
|
@@ -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 {
|
|
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
|
+
)
|
|
@@ -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
|