agent-messenger 2.30.0 → 2.32.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 (258) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +14 -1
  3. package/bun.lock +42 -2
  4. package/dist/package.json +2 -2
  5. package/dist/src/cli.d.ts.map +1 -1
  6. package/dist/src/cli.js.map +1 -1
  7. package/dist/src/platforms/channeltalk/cli.d.ts.map +1 -1
  8. package/dist/src/platforms/channeltalk/client.d.ts.map +1 -1
  9. package/dist/src/platforms/channeltalk/commands/auth.js.map +1 -1
  10. package/dist/src/platforms/channeltalk/commands/bot.js.map +1 -1
  11. package/dist/src/platforms/channeltalk/commands/chat.js.map +1 -1
  12. package/dist/src/platforms/channeltalk/commands/group.js.map +1 -1
  13. package/dist/src/platforms/channeltalk/commands/manager.js.map +1 -1
  14. package/dist/src/platforms/channeltalk/commands/message.js.map +1 -1
  15. package/dist/src/platforms/channeltalk/commands/shared.js.map +1 -1
  16. package/dist/src/platforms/channeltalk/commands/snapshot.js.map +1 -1
  17. package/dist/src/platforms/channeltalk/commands/whoami.js.map +1 -1
  18. package/dist/src/platforms/channeltalk/credential-manager.d.ts.map +1 -1
  19. package/dist/src/platforms/channeltalk/ensure-auth.d.ts.map +1 -1
  20. package/dist/src/platforms/channeltalk/token-extractor.d.ts.map +1 -1
  21. package/dist/src/platforms/channeltalk/token-extractor.js.map +1 -1
  22. package/dist/src/platforms/channeltalk/types.d.ts.map +1 -1
  23. package/dist/src/platforms/channeltalkbot/cli.d.ts.map +1 -1
  24. package/dist/src/platforms/channeltalkbot/client.d.ts.map +1 -1
  25. package/dist/src/platforms/channeltalkbot/client.js.map +1 -1
  26. package/dist/src/platforms/channeltalkbot/credential-manager.d.ts.map +1 -1
  27. package/dist/src/platforms/channeltalkbot/types.d.ts +1 -1
  28. package/dist/src/platforms/channeltalkbot/types.d.ts.map +1 -1
  29. package/dist/src/platforms/discord/cli.d.ts.map +1 -1
  30. package/dist/src/platforms/discord/client.d.ts +11 -2
  31. package/dist/src/platforms/discord/client.d.ts.map +1 -1
  32. package/dist/src/platforms/discord/client.js +145 -21
  33. package/dist/src/platforms/discord/client.js.map +1 -1
  34. package/dist/src/platforms/discord/commands/mention.d.ts +5 -0
  35. package/dist/src/platforms/discord/commands/mention.d.ts.map +1 -1
  36. package/dist/src/platforms/discord/commands/mention.js +60 -4
  37. package/dist/src/platforms/discord/commands/mention.js.map +1 -1
  38. package/dist/src/platforms/discord/commands/reaction.js +1 -1
  39. package/dist/src/platforms/discord/commands/reaction.js.map +1 -1
  40. package/dist/src/platforms/discord/credential-manager.d.ts.map +1 -1
  41. package/dist/src/platforms/discord/index.d.ts +1 -1
  42. package/dist/src/platforms/discord/index.d.ts.map +1 -1
  43. package/dist/src/platforms/discord/index.js.map +1 -1
  44. package/dist/src/platforms/discord/listener.d.ts +4 -1
  45. package/dist/src/platforms/discord/listener.d.ts.map +1 -1
  46. package/dist/src/platforms/discord/listener.js +28 -1
  47. package/dist/src/platforms/discord/listener.js.map +1 -1
  48. package/dist/src/platforms/discord/remote-auth.js.map +1 -1
  49. package/dist/src/platforms/discord/super-properties.js.map +1 -1
  50. package/dist/src/platforms/discord/token-extractor.d.ts.map +1 -1
  51. package/dist/src/platforms/discord/token-extractor.js.map +1 -1
  52. package/dist/src/platforms/discord/types.d.ts +56 -9
  53. package/dist/src/platforms/discord/types.d.ts.map +1 -1
  54. package/dist/src/platforms/discord/types.js +28 -7
  55. package/dist/src/platforms/discord/types.js.map +1 -1
  56. package/dist/src/platforms/discordbot/cli.d.ts.map +1 -1
  57. package/dist/src/platforms/discordbot/client.d.ts.map +1 -1
  58. package/dist/src/platforms/discordbot/client.js.map +1 -1
  59. package/dist/src/platforms/discordbot/credential-manager.d.ts.map +1 -1
  60. package/dist/src/platforms/discordbot/listener.d.ts.map +1 -1
  61. package/dist/src/platforms/discordbot/listener.js.map +1 -1
  62. package/dist/src/platforms/discordbot/types.d.ts.map +1 -1
  63. package/dist/src/platforms/imessage/cli.d.ts.map +1 -1
  64. package/dist/src/platforms/imessage/client.d.ts.map +1 -1
  65. package/dist/src/platforms/imessage/client.js.map +1 -1
  66. package/dist/src/platforms/imessage/credential-manager.d.ts.map +1 -1
  67. package/dist/src/platforms/imessage/errors.js.map +1 -1
  68. package/dist/src/platforms/imessage/rpc.d.ts.map +1 -1
  69. package/dist/src/platforms/imessage/rpc.js.map +1 -1
  70. package/dist/src/platforms/imessage/types.d.ts.map +1 -1
  71. package/dist/src/platforms/imessage/types.js.map +1 -1
  72. package/dist/src/platforms/instagram/cli.d.ts.map +1 -1
  73. package/dist/src/platforms/instagram/client.d.ts.map +1 -1
  74. package/dist/src/platforms/instagram/client.js.map +1 -1
  75. package/dist/src/platforms/instagram/credential-manager.d.ts.map +1 -1
  76. package/dist/src/platforms/instagram/hybrid-listener.d.ts.map +1 -1
  77. package/dist/src/platforms/instagram/hybrid-listener.js.map +1 -1
  78. package/dist/src/platforms/instagram/listener.d.ts.map +1 -1
  79. package/dist/src/platforms/instagram/mqtt/thrift.d.ts.map +1 -1
  80. package/dist/src/platforms/instagram/mqtt/transport.d.ts.map +1 -1
  81. package/dist/src/platforms/instagram/mqtt/transport.js.map +1 -1
  82. package/dist/src/platforms/instagram/realtime-listener.d.ts.map +1 -1
  83. package/dist/src/platforms/instagram/realtime-listener.js.map +1 -1
  84. package/dist/src/platforms/instagram/token-extractor.d.ts.map +1 -1
  85. package/dist/src/platforms/instagram/types.d.ts.map +1 -1
  86. package/dist/src/platforms/instagram/types.js.map +1 -1
  87. package/dist/src/platforms/kakaotalk/cli.d.ts.map +1 -1
  88. package/dist/src/platforms/kakaotalk/client.d.ts +5 -1
  89. package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
  90. package/dist/src/platforms/kakaotalk/client.js +55 -0
  91. package/dist/src/platforms/kakaotalk/client.js.map +1 -1
  92. package/dist/src/platforms/kakaotalk/commands/chat.d.ts.map +1 -1
  93. package/dist/src/platforms/kakaotalk/commands/chat.js +16 -1
  94. package/dist/src/platforms/kakaotalk/commands/chat.js.map +1 -1
  95. package/dist/src/platforms/kakaotalk/credential-manager.d.ts.map +1 -1
  96. package/dist/src/platforms/kakaotalk/index.d.ts +2 -2
  97. package/dist/src/platforms/kakaotalk/index.d.ts.map +1 -1
  98. package/dist/src/platforms/kakaotalk/index.js +1 -1
  99. package/dist/src/platforms/kakaotalk/index.js.map +1 -1
  100. package/dist/src/platforms/kakaotalk/listener.d.ts.map +1 -1
  101. package/dist/src/platforms/kakaotalk/listener.js.map +1 -1
  102. package/dist/src/platforms/kakaotalk/protocol/connection.d.ts.map +1 -1
  103. package/dist/src/platforms/kakaotalk/protocol/connection.js.map +1 -1
  104. package/dist/src/platforms/kakaotalk/protocol/crypto.d.ts.map +1 -1
  105. package/dist/src/platforms/kakaotalk/protocol/session.d.ts +23 -0
  106. package/dist/src/platforms/kakaotalk/protocol/session.d.ts.map +1 -1
  107. package/dist/src/platforms/kakaotalk/protocol/session.js +50 -0
  108. package/dist/src/platforms/kakaotalk/protocol/session.js.map +1 -1
  109. package/dist/src/platforms/kakaotalk/token-extractor.d.ts.map +1 -1
  110. package/dist/src/platforms/kakaotalk/types.d.ts +30 -10
  111. package/dist/src/platforms/kakaotalk/types.d.ts.map +1 -1
  112. package/dist/src/platforms/kakaotalk/types.js +10 -0
  113. package/dist/src/platforms/kakaotalk/types.js.map +1 -1
  114. package/dist/src/platforms/line/cli.d.ts.map +1 -1
  115. package/dist/src/platforms/line/client.d.ts.map +1 -1
  116. package/dist/src/platforms/line/credential-manager.d.ts.map +1 -1
  117. package/dist/src/platforms/line/listener.d.ts.map +1 -1
  118. package/dist/src/platforms/line/types.d.ts +6 -6
  119. package/dist/src/platforms/line/types.d.ts.map +1 -1
  120. package/dist/src/platforms/slack/cli.d.ts.map +1 -1
  121. package/dist/src/platforms/slack/client.d.ts.map +1 -1
  122. package/dist/src/platforms/slack/client.js.map +1 -1
  123. package/dist/src/platforms/slack/credential-manager.d.ts.map +1 -1
  124. package/dist/src/platforms/slack/ensure-auth.js.map +1 -1
  125. package/dist/src/platforms/slack/listener.d.ts.map +1 -1
  126. package/dist/src/platforms/slack/qr-http-login.js.map +1 -1
  127. package/dist/src/platforms/slack/token-extractor.d.ts.map +1 -1
  128. package/dist/src/platforms/slack/token-extractor.js.map +1 -1
  129. package/dist/src/platforms/slackbot/cli.d.ts.map +1 -1
  130. package/dist/src/platforms/slackbot/client.d.ts.map +1 -1
  131. package/dist/src/platforms/slackbot/credential-manager.d.ts.map +1 -1
  132. package/dist/src/platforms/slackbot/listener.d.ts.map +1 -1
  133. package/dist/src/platforms/slackbot/listener.js.map +1 -1
  134. package/dist/src/platforms/slackbot/types.d.ts.map +1 -1
  135. package/dist/src/platforms/teams/app-config.js.map +1 -1
  136. package/dist/src/platforms/teams/cli.d.ts.map +1 -1
  137. package/dist/src/platforms/teams/client.d.ts.map +1 -1
  138. package/dist/src/platforms/teams/client.js.map +1 -1
  139. package/dist/src/platforms/teams/credential-manager.d.ts.map +1 -1
  140. package/dist/src/platforms/teams/device-code.js.map +1 -1
  141. package/dist/src/platforms/teams/device-login.d.ts.map +1 -1
  142. package/dist/src/platforms/teams/device-login.js.map +1 -1
  143. package/dist/src/platforms/teams/listener.d.ts.map +1 -1
  144. package/dist/src/platforms/teams/token-extractor.d.ts.map +1 -1
  145. package/dist/src/platforms/teams/token-extractor.js.map +1 -1
  146. package/dist/src/platforms/teams/token-provider.d.ts.map +1 -1
  147. package/dist/src/platforms/teams/token-provider.js.map +1 -1
  148. package/dist/src/platforms/teams/types.d.ts +14 -14
  149. package/dist/src/platforms/teams/types.d.ts.map +1 -1
  150. package/dist/src/platforms/telegram/cli.d.ts.map +1 -1
  151. package/dist/src/platforms/telegram/client.d.ts.map +1 -1
  152. package/dist/src/platforms/telegram/client.js.map +1 -1
  153. package/dist/src/platforms/telegram/commands/shared.js.map +1 -1
  154. package/dist/src/platforms/telegram/credential-manager.d.ts.map +1 -1
  155. package/dist/src/platforms/telegram/tdlib.d.ts.map +1 -1
  156. package/dist/src/platforms/telegram/types.d.ts.map +1 -1
  157. package/dist/src/platforms/telegram/types.js.map +1 -1
  158. package/dist/src/platforms/telegrambot/cli.d.ts.map +1 -1
  159. package/dist/src/platforms/telegrambot/client.d.ts.map +1 -1
  160. package/dist/src/platforms/telegrambot/credential-manager.d.ts.map +1 -1
  161. package/dist/src/platforms/telegrambot/listener.d.ts.map +1 -1
  162. package/dist/src/platforms/telegrambot/types.d.ts.map +1 -1
  163. package/dist/src/platforms/webex/cli.d.ts.map +1 -1
  164. package/dist/src/platforms/webex/client.d.ts.map +1 -1
  165. package/dist/src/platforms/webex/client.js.map +1 -1
  166. package/dist/src/platforms/webex/credential-manager.d.ts.map +1 -1
  167. package/dist/src/platforms/webex/encryption.d.ts.map +1 -1
  168. package/dist/src/platforms/webex/kms-key-provider.d.ts.map +1 -1
  169. package/dist/src/platforms/webex/listener.d.ts.map +1 -1
  170. package/dist/src/platforms/webex/listener.js.map +1 -1
  171. package/dist/src/platforms/webex/token-extractor.d.ts.map +1 -1
  172. package/dist/src/platforms/webex/types.d.ts +4 -4
  173. package/dist/src/platforms/webex/types.d.ts.map +1 -1
  174. package/dist/src/platforms/webexbot/cli.d.ts.map +1 -1
  175. package/dist/src/platforms/webexbot/client.d.ts.map +1 -1
  176. package/dist/src/platforms/webexbot/credential-manager.d.ts.map +1 -1
  177. package/dist/src/platforms/webexbot/listener.d.ts.map +1 -1
  178. package/dist/src/platforms/webexbot/types.d.ts.map +1 -1
  179. package/dist/src/platforms/wechatbot/cli.d.ts.map +1 -1
  180. package/dist/src/platforms/wechatbot/client.d.ts.map +1 -1
  181. package/dist/src/platforms/wechatbot/credential-manager.d.ts.map +1 -1
  182. package/dist/src/platforms/wechatbot/types.d.ts.map +1 -1
  183. package/dist/src/platforms/whatsapp/cli.d.ts.map +1 -1
  184. package/dist/src/platforms/whatsapp/client.d.ts.map +1 -1
  185. package/dist/src/platforms/whatsapp/credential-manager.d.ts.map +1 -1
  186. package/dist/src/platforms/whatsapp/types.d.ts.map +1 -1
  187. package/dist/src/platforms/whatsapp/types.js.map +1 -1
  188. package/dist/src/platforms/whatsappbot/cli.d.ts.map +1 -1
  189. package/dist/src/platforms/whatsappbot/client.d.ts.map +1 -1
  190. package/dist/src/platforms/whatsappbot/credential-manager.d.ts.map +1 -1
  191. package/dist/src/platforms/whatsappbot/types.d.ts.map +1 -1
  192. package/dist/src/shared/chromium/browsers.js.map +1 -1
  193. package/dist/src/shared/chromium/cli-options.js.map +1 -1
  194. package/dist/src/shared/chromium/cookie-reader.d.ts.map +1 -1
  195. package/dist/src/shared/chromium/decryptor.d.ts.map +1 -1
  196. package/dist/src/shared/sqlite.js.map +1 -1
  197. package/dist/src/shared/utils/derived-key-cache.d.ts.map +1 -1
  198. package/dist/src/tui/adapters/channeltalk-adapter.d.ts.map +1 -1
  199. package/dist/src/tui/adapters/discord-adapter.d.ts.map +1 -1
  200. package/dist/src/tui/adapters/imessage-adapter.d.ts.map +1 -1
  201. package/dist/src/tui/adapters/instagram-adapter.d.ts.map +1 -1
  202. package/dist/src/tui/adapters/kakaotalk-adapter.d.ts.map +1 -1
  203. package/dist/src/tui/adapters/line-adapter.d.ts.map +1 -1
  204. package/dist/src/tui/adapters/slack-adapter.d.ts.map +1 -1
  205. package/dist/src/tui/adapters/teams-adapter.d.ts.map +1 -1
  206. package/dist/src/tui/adapters/telegram-adapter.d.ts.map +1 -1
  207. package/dist/src/tui/adapters/telegram-adapter.js.map +1 -1
  208. package/dist/src/tui/adapters/webex-adapter.d.ts.map +1 -1
  209. package/dist/src/tui/adapters/whatsapp-adapter.d.ts.map +1 -1
  210. package/dist/src/tui/app.js.map +1 -1
  211. package/dist/src/tui/views/channel-picker.d.ts.map +1 -1
  212. package/dist/src/tui/views/workspace-picker.d.ts.map +1 -1
  213. package/docs/content/docs/cli/discord.mdx +7 -0
  214. package/docs/content/docs/cli/kakaotalk.mdx +16 -1
  215. package/docs/content/docs/sdk/discord.mdx +38 -0
  216. package/docs/content/docs/sdk/kakaotalk.mdx +39 -0
  217. package/e2e/README.md +2 -2
  218. package/e2e/discord.e2e.test.ts +16 -2
  219. package/package.json +2 -2
  220. package/skills/agent-channeltalk/SKILL.md +1 -1
  221. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  222. package/skills/agent-discord/SKILL.md +19 -1
  223. package/skills/agent-discordbot/SKILL.md +1 -1
  224. package/skills/agent-imessage/SKILL.md +1 -1
  225. package/skills/agent-instagram/SKILL.md +1 -1
  226. package/skills/agent-kakaotalk/SKILL.md +19 -2
  227. package/skills/agent-line/SKILL.md +1 -1
  228. package/skills/agent-slack/SKILL.md +1 -1
  229. package/skills/agent-slackbot/SKILL.md +1 -1
  230. package/skills/agent-teams/SKILL.md +1 -1
  231. package/skills/agent-telegram/SKILL.md +1 -1
  232. package/skills/agent-telegrambot/SKILL.md +1 -1
  233. package/skills/agent-webex/SKILL.md +1 -1
  234. package/skills/agent-webexbot/SKILL.md +1 -1
  235. package/skills/agent-wechatbot/SKILL.md +1 -1
  236. package/skills/agent-whatsapp/SKILL.md +1 -1
  237. package/skills/agent-whatsappbot/SKILL.md +1 -1
  238. package/src/platforms/discord/client.test.ts +351 -9
  239. package/src/platforms/discord/client.ts +166 -26
  240. package/src/platforms/discord/commands/mention.test.ts +84 -0
  241. package/src/platforms/discord/commands/mention.ts +78 -15
  242. package/src/platforms/discord/commands/reaction.test.ts +1 -1
  243. package/src/platforms/discord/commands/reaction.ts +1 -1
  244. package/src/platforms/discord/index.ts +3 -0
  245. package/src/platforms/discord/listener.test.ts +69 -2
  246. package/src/platforms/discord/listener.ts +31 -2
  247. package/src/platforms/discord/types.test.ts +11 -0
  248. package/src/platforms/discord/types.ts +66 -10
  249. package/src/platforms/kakaotalk/client.test.ts +194 -1
  250. package/src/platforms/kakaotalk/client.ts +58 -0
  251. package/src/platforms/kakaotalk/commands/chat.test.ts +24 -0
  252. package/src/platforms/kakaotalk/commands/chat.ts +23 -0
  253. package/src/platforms/kakaotalk/index.test.ts +10 -0
  254. package/src/platforms/kakaotalk/index.ts +4 -0
  255. package/src/platforms/kakaotalk/protocol/session.test.ts +113 -0
  256. package/src/platforms/kakaotalk/protocol/session.ts +56 -0
  257. package/src/platforms/kakaotalk/types.ts +24 -0
  258. package/tsconfig.json +8 -8
@@ -15,6 +15,8 @@ const mockSyncMessages = mock(() => Promise.resolve({}))
15
15
  const mockSendMessage = mock(() => Promise.resolve({}))
16
16
  const mockSendReply = mock(() => Promise.resolve({}))
17
17
  const mockMarkRead = mock(() => Promise.resolve({}))
18
+ const mockLeaveChat = mock(() => Promise.resolve({}))
19
+ const mockSendTyping = mock(() => Promise.resolve({}))
18
20
  const mockClose = mock(() => {})
19
21
  const mockOnClose = mock((_handler: () => void) => {})
20
22
  const mockOnPush = mock((_handler: (packet: unknown) => void) => {})
@@ -33,6 +35,8 @@ mock.module('./protocol/session', () => ({
33
35
  sendMessage = mockSendMessage
34
36
  sendReply = mockSendReply
35
37
  markRead = mockMarkRead
38
+ leaveChat = mockLeaveChat
39
+ sendTyping = mockSendTyping
36
40
  close = mockClose
37
41
  onClose = mockOnClose
38
42
  onPush = mockOnPush
@@ -56,6 +60,8 @@ function resetAllMocks() {
56
60
  mockSendMessage.mockReset()
57
61
  mockSendReply.mockReset()
58
62
  mockMarkRead.mockReset()
63
+ mockLeaveChat.mockReset()
64
+ mockSendTyping.mockReset()
59
65
  mockClose.mockReset()
60
66
  mockOnClose.mockReset()
61
67
  mockOnPush.mockReset()
@@ -96,7 +102,9 @@ const DEFAULT_LOGIN_RESULT = {
96
102
  describe('KakaoTalkClient', () => {
97
103
  beforeEach(() => {
98
104
  resetAllMocks()
99
- mockLogin.mockResolvedValue(DEFAULT_LOGIN_RESULT)
105
+ // Deep-clone so tests that mutate loginResult.chatDatas (e.g. leaveChat)
106
+ // don't leak into subsequent tests.
107
+ mockLogin.mockResolvedValue(structuredClone(DEFAULT_LOGIN_RESULT))
100
108
  mockGetChatLogs.mockResolvedValue({ body: { status: 0, chatLogs: [], eof: true } })
101
109
  mockGetChatInfo.mockResolvedValue({ body: { l: makeLong(99999) } })
102
110
  })
@@ -1239,6 +1247,191 @@ describe('KakaoTalkClient', () => {
1239
1247
  })
1240
1248
  })
1241
1249
 
1250
+ describe('leaveChat', () => {
1251
+ it('calls session.leaveChat with parsed chatId and returns success', async () => {
1252
+ mockLeaveChat.mockResolvedValueOnce({ statusCode: 0, body: {} })
1253
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1254
+
1255
+ const result = await client.leaveChat('100')
1256
+
1257
+ expect(result).toEqual({ success: true, status_code: 0, chat_id: '100' })
1258
+ expect(mockLeaveChat).toHaveBeenCalledWith(expect.objectContaining({ low: 100, high: 0 }))
1259
+
1260
+ client.close()
1261
+ })
1262
+
1263
+ it('treats synthetic disconnect packet (statusCode: -1) as transport failure (throws)', async () => {
1264
+ mockLeaveChat.mockResolvedValueOnce({ statusCode: -1, body: { error: 'connection closed' } })
1265
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1266
+
1267
+ try {
1268
+ await client.leaveChat('100')
1269
+ throw new Error('expected to throw')
1270
+ } catch (e) {
1271
+ expect(e).toBeInstanceOf(KakaoTalkError)
1272
+ expect((e as KakaoTalkError).code).toBe('leave_chat_failed')
1273
+ }
1274
+
1275
+ client.close()
1276
+ })
1277
+
1278
+ it('evicts the departed chat from getChats cache', async () => {
1279
+ mockLeaveChat.mockResolvedValueOnce({ statusCode: 0, body: {} })
1280
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1281
+
1282
+ const before = await client.getChats()
1283
+ expect(before).toHaveLength(2)
1284
+
1285
+ await client.leaveChat('100')
1286
+
1287
+ const after = await client.getChats()
1288
+ expect(after).toHaveLength(1)
1289
+ expect(after[0].chat_id).toBe('200')
1290
+
1291
+ client.close()
1292
+ })
1293
+
1294
+ it('reconnects and retries leaveChat after silent disconnect (full executeWithReconnect path)', async () => {
1295
+ const closeHandlers: Array<() => void> = []
1296
+ mockOnClose.mockImplementation((handler: () => void) => {
1297
+ closeHandlers.push(handler)
1298
+ })
1299
+
1300
+ mockLeaveChat.mockImplementationOnce(() => {
1301
+ const handler = closeHandlers[0]
1302
+ if (handler) handler()
1303
+ return Promise.resolve({ statusCode: -1, body: { error: 'connection closed' } })
1304
+ })
1305
+ mockLeaveChat.mockResolvedValueOnce({ statusCode: 0, body: {} })
1306
+
1307
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1308
+ const result = await client.leaveChat('100')
1309
+
1310
+ expect(result.success).toBe(true)
1311
+ expect(mockLeaveChat).toHaveBeenCalledTimes(2)
1312
+ expect(mockLogin).toHaveBeenCalledTimes(2)
1313
+
1314
+ client.close()
1315
+ })
1316
+
1317
+ it('throws KakaoTalkError(invalid_chat_id) for non-numeric chatId', async () => {
1318
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1319
+
1320
+ try {
1321
+ await client.leaveChat('not-a-number')
1322
+ throw new Error('expected to throw')
1323
+ } catch (e) {
1324
+ expect(e).toBeInstanceOf(KakaoTalkError)
1325
+ expect((e as KakaoTalkError).code).toBe('invalid_chat_id')
1326
+ }
1327
+
1328
+ client.close()
1329
+ })
1330
+
1331
+ it('wraps transport errors as KakaoTalkError(leave_chat_failed)', async () => {
1332
+ mockLeaveChat.mockRejectedValue(new Error('Socket closed'))
1333
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1334
+
1335
+ try {
1336
+ await client.leaveChat('100')
1337
+ throw new Error('expected to throw')
1338
+ } catch (e) {
1339
+ expect(e).toBeInstanceOf(KakaoTalkError)
1340
+ expect((e as KakaoTalkError).code).toBe('leave_chat_failed')
1341
+ }
1342
+
1343
+ client.close()
1344
+ })
1345
+ })
1346
+
1347
+ describe('sendTyping', () => {
1348
+ it('forwards parsed chatId to session.sendTyping and reports success on statusCode 0', async () => {
1349
+ mockSendTyping.mockResolvedValueOnce({ statusCode: 0, body: {} })
1350
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1351
+
1352
+ const result = await client.sendTyping('12345')
1353
+
1354
+ expect(mockSendTyping).toHaveBeenCalledTimes(1)
1355
+ const [chatIdArg, linkIdArg] = mockSendTyping.mock.calls[0] as [{ toString(): string }, unknown]
1356
+ expect(chatIdArg.toString()).toBe('12345')
1357
+ expect(linkIdArg).toBeUndefined()
1358
+ expect(result).toEqual({ success: true, status_code: 0, chat_id: '12345' })
1359
+
1360
+ client.close()
1361
+ })
1362
+
1363
+ it('passes linkId through when provided (open-chat typing)', async () => {
1364
+ mockSendTyping.mockResolvedValueOnce({ statusCode: 0, body: {} })
1365
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1366
+
1367
+ await client.sendTyping('12345', { linkId: '77777' })
1368
+
1369
+ const [, linkIdArg] = mockSendTyping.mock.calls[0] as [unknown, { toString(): string }]
1370
+ expect(linkIdArg).toBeDefined()
1371
+ expect(linkIdArg.toString()).toBe('77777')
1372
+
1373
+ client.close()
1374
+ })
1375
+
1376
+ it('throws KakaoTalkError(send_typing_failed) on transport-level failure (non-zero statusCode)', async () => {
1377
+ mockSendTyping.mockResolvedValueOnce({ statusCode: -100, body: {} })
1378
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1379
+
1380
+ try {
1381
+ await client.sendTyping('12345')
1382
+ throw new Error('expected to throw')
1383
+ } catch (e) {
1384
+ expect(e).toBeInstanceOf(KakaoTalkError)
1385
+ expect((e as KakaoTalkError).code).toBe('send_typing_failed')
1386
+ }
1387
+
1388
+ client.close()
1389
+ })
1390
+
1391
+ it('throws KakaoTalkError(send_typing_failed) when the session throws (transport error)', async () => {
1392
+ mockSendTyping.mockRejectedValue(new Error('ACTION failed: statusCode=-1'))
1393
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1394
+
1395
+ try {
1396
+ await client.sendTyping('12345')
1397
+ throw new Error('expected to throw')
1398
+ } catch (e) {
1399
+ expect(e).toBeInstanceOf(KakaoTalkError)
1400
+ expect((e as KakaoTalkError).code).toBe('send_typing_failed')
1401
+ }
1402
+
1403
+ client.close()
1404
+ })
1405
+
1406
+ it('throws KakaoTalkError(invalid_chat_id) for non-numeric chatId', async () => {
1407
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1408
+
1409
+ try {
1410
+ await client.sendTyping('not-a-number')
1411
+ throw new Error('expected to throw')
1412
+ } catch (e) {
1413
+ expect(e).toBeInstanceOf(KakaoTalkError)
1414
+ expect((e as KakaoTalkError).code).toBe('invalid_chat_id')
1415
+ }
1416
+
1417
+ client.close()
1418
+ })
1419
+
1420
+ it('throws KakaoTalkError(invalid_link_id) for non-numeric linkId', async () => {
1421
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1422
+
1423
+ try {
1424
+ await client.sendTyping('12345', { linkId: 'not-a-number' })
1425
+ throw new Error('expected to throw')
1426
+ } catch (e) {
1427
+ expect(e).toBeInstanceOf(KakaoTalkError)
1428
+ expect((e as KakaoTalkError).code).toBe('invalid_link_id')
1429
+ }
1430
+
1431
+ client.close()
1432
+ })
1433
+ })
1434
+
1242
1435
  describe('getMembers / getMembersByIds', () => {
1243
1436
  it('returns formatted members from GETMEM with normalized fields', async () => {
1244
1437
  mockGetAllMembers.mockResolvedValueOnce({
@@ -18,6 +18,7 @@ import {
18
18
  KAKAO_MESSAGE_TYPE,
19
19
  type KakaoChat,
20
20
  type KakaoDeviceType,
21
+ type KakaoLeaveChatResult,
21
22
  type KakaoMarkReadResult,
22
23
  type KakaoMember,
23
24
  type KakaoMessage,
@@ -26,6 +27,7 @@ import {
26
27
  type KakaoReplyExtra,
27
28
  type KakaoReplyTarget,
28
29
  type KakaoSendResult,
30
+ type KakaoTypingResult,
29
31
  } from './types'
30
32
 
31
33
  export type KakaoSessionEvent =
@@ -982,6 +984,30 @@ export class KakaoTalkClient {
982
984
  })
983
985
  }
984
986
 
987
+ async sendTyping(chatId: string, opts?: { linkId?: string }): Promise<KakaoTypingResult> {
988
+ const parsedChatId = parseChatId(chatId)
989
+ const parsedLinkId = opts?.linkId !== undefined ? parseLinkId(opts.linkId) : undefined
990
+
991
+ return this.executeWithReconnect(async ({ session }) => {
992
+ try {
993
+ const response = await session.sendTyping(parsedChatId, parsedLinkId)
994
+ // Throw on transport-level failure (incl. synthetic { statusCode: -1 }
995
+ // from LocoConnection.handleClose) so executeWithReconnect retries on
996
+ // a fresh session — matches the markRead pattern.
997
+ if (response.statusCode !== 0) {
998
+ throw new Error(`ACTION failed: statusCode=${response.statusCode}`)
999
+ }
1000
+ return {
1001
+ success: true,
1002
+ status_code: response.statusCode,
1003
+ chat_id: chatId,
1004
+ }
1005
+ } catch (error) {
1006
+ throw wrapError(error, 'send_typing_failed')
1007
+ }
1008
+ })
1009
+ }
1010
+
985
1011
  async sendAttachment(
986
1012
  chatId: string,
987
1013
  data: Uint8Array | Buffer,
@@ -1317,6 +1343,38 @@ export class KakaoTalkClient {
1317
1343
  })
1318
1344
  }
1319
1345
 
1346
+ async leaveChat(chatId: string): Promise<KakaoLeaveChatResult> {
1347
+ const parsedChatId = parseChatId(chatId)
1348
+
1349
+ return this.executeWithReconnect(async ({ session, loginResult }) => {
1350
+ try {
1351
+ const response = await session.leaveChat(parsedChatId)
1352
+ // Throw on transport-level failure (incl. synthetic { statusCode: -1 }
1353
+ // from LocoConnection.handleClose) so executeWithReconnect retries on
1354
+ // a fresh session. The LEAVE command result lives in statusCode.
1355
+ if (response.statusCode !== 0) {
1356
+ throw new Error(`LEAVE failed: statusCode=${response.statusCode}`)
1357
+ }
1358
+ // Evict the departed chat from in-memory caches so subsequent getChats()
1359
+ // calls do not return stale entries for this room. Normalize via
1360
+ // longToString(parsedChatId) so the cache key matches the canonical
1361
+ // format used by the server (e.g. "0100" → "100").
1362
+ const normalizedChatId = longToString(parsedChatId)
1363
+ const raw = (loginResult.chatDatas ?? []) as ChatData[]
1364
+ const idx = raw.findIndex((c) => longToString(c.c) === normalizedChatId)
1365
+ if (idx !== -1) raw.splice(idx, 1)
1366
+ this.nameCache.forget(normalizedChatId)
1367
+ return {
1368
+ success: true,
1369
+ status_code: 0,
1370
+ chat_id: chatId,
1371
+ }
1372
+ } catch (error) {
1373
+ throw wrapError(error, 'leave_chat_failed')
1374
+ }
1375
+ })
1376
+ }
1377
+
1320
1378
  async getProfile(): Promise<KakaoProfile> {
1321
1379
  this.ensureAuth()
1322
1380
  try {
@@ -13,8 +13,11 @@ const mockGetChats = mock(() =>
13
13
  ]),
14
14
  )
15
15
 
16
+ const mockLeaveChat = mock(() => Promise.resolve({ success: true, status_code: 0, chat_id: 'chat-1' }))
17
+
16
18
  const mockClient = {
17
19
  getChats: mockGetChats,
20
+ leaveChat: mockLeaveChat,
18
21
  }
19
22
 
20
23
  mock.module('./shared', () => ({
@@ -29,6 +32,7 @@ describe('chat commands', () => {
29
32
  beforeEach(() => {
30
33
  mockWithKakaoClient.mockReset()
31
34
  mockGetChats.mockReset()
35
+ mockLeaveChat.mockReset()
32
36
 
33
37
  mockWithKakaoClient.mockImplementation(async (_options: unknown, fn: (client: unknown) => Promise<unknown>) => {
34
38
  return fn(mockClient)
@@ -39,6 +43,7 @@ describe('chat commands', () => {
39
43
  { chat_id: 'chat-2', name: 'Direct', type: 'direct', member_count: 2 },
40
44
  ]),
41
45
  )
46
+ mockLeaveChat.mockImplementation(() => Promise.resolve({ success: true, status_code: 0, chat_id: 'chat-1' }))
42
47
 
43
48
  consoleLogSpy = mock((..._args: unknown[]) => {})
44
49
  console.log = consoleLogSpy
@@ -91,4 +96,23 @@ describe('chat commands', () => {
91
96
  expect(output).toEqual([])
92
97
  })
93
98
  })
99
+
100
+ describe('leave', () => {
101
+ it('leaves a chat room', async () => {
102
+ await chatCommand.parseAsync(['leave', 'chat-1'], { from: 'user' })
103
+
104
+ expect(mockLeaveChat).toHaveBeenCalledWith('chat-1')
105
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0])
106
+ expect(output).toEqual({ success: true, status_code: 0, chat_id: 'chat-1' })
107
+ })
108
+
109
+ it('passes account option to withKakaoClient', async () => {
110
+ await chatCommand.parseAsync(['leave', 'chat-1', '--account', 'my-account'], { from: 'user' })
111
+
112
+ expect(mockWithKakaoClient).toHaveBeenCalledWith(
113
+ expect.objectContaining({ account: 'my-account' }),
114
+ expect.any(Function),
115
+ )
116
+ })
117
+ })
94
118
  })
@@ -22,6 +22,21 @@ async function listAction(options: {
22
22
  }
23
23
  }
24
24
 
25
+ async function leaveAction(
26
+ chatId: string,
27
+ options: {
28
+ account?: string
29
+ pretty?: boolean
30
+ },
31
+ ): Promise<void> {
32
+ try {
33
+ const result = await withKakaoClient(options, (client) => client.leaveChat(chatId))
34
+ console.log(formatOutput(result, options.pretty))
35
+ } catch (error) {
36
+ handleError(error as Error)
37
+ }
38
+ }
39
+
25
40
  export const chatCommand = new Command('chat')
26
41
  .description('KakaoTalk chat commands')
27
42
  .addCommand(
@@ -34,3 +49,11 @@ export const chatCommand = new Command('chat')
34
49
  .option('--pretty', 'Pretty print JSON output')
35
50
  .action(listAction),
36
51
  )
52
+ .addCommand(
53
+ new Command('leave')
54
+ .description('Leave a chat room')
55
+ .argument('<chat-id>', 'Chat room ID')
56
+ .option('--account <id>', 'Use a specific KakaoTalk account')
57
+ .option('--pretty', 'Pretty print JSON output')
58
+ .action(leaveAction),
59
+ )
@@ -7,6 +7,7 @@ import {
7
7
  KakaoCredentialManager,
8
8
  KakaoChatSchema,
9
9
  KakaoConfigSchema,
10
+ KakaoLeaveChatResultSchema,
10
11
  KakaoMessageSchema,
11
12
  KakaoSendResultSchema,
12
13
  KakaoTalkClient,
@@ -16,6 +17,7 @@ import {
16
17
  KakaoTalkPushMessageEventSchema,
17
18
  KakaoProfileSchema,
18
19
  KakaoTalkPushReadEventSchema,
20
+ KakaoTypingResultSchema,
19
21
  } from '@/platforms/kakaotalk/index'
20
22
 
21
23
  it('KakaoTalkClient is exported from barrel', () => {
@@ -77,3 +79,11 @@ it('KakaoProfileSchema is exported from barrel', () => {
77
79
  it('classifyKakaoChat is exported from barrel', () => {
78
80
  expect(typeof classifyKakaoChat).toBe('function')
79
81
  })
82
+
83
+ it('KakaoLeaveChatResultSchema is exported from barrel', () => {
84
+ expect(typeof KakaoLeaveChatResultSchema.parse).toBe('function')
85
+ })
86
+
87
+ it('KakaoTypingResultSchema is exported from barrel', () => {
88
+ expect(typeof KakaoTypingResultSchema.parse).toBe('function')
89
+ })
@@ -13,6 +13,7 @@ export type {
13
13
  KakaoEmoticonKind,
14
14
  KakaoEmoticonMessageType,
15
15
  KakaoFileExtra,
16
+ KakaoLeaveChatResult,
16
17
  KakaoLoginResult,
17
18
  KakaoMarkReadResult,
18
19
  KakaoMember,
@@ -30,6 +31,7 @@ export type {
30
31
  KakaoTalkPushMemberEvent,
31
32
  KakaoTalkPushMessageEvent,
32
33
  KakaoTalkPushReadEvent,
34
+ KakaoTypingResult,
33
35
  } from './types'
34
36
  export {
35
37
  KAKAO_EMOTICON_KIND_BY_TYPE,
@@ -38,6 +40,7 @@ export {
38
40
  KakaoAccountCredentialsSchema,
39
41
  KakaoChatSchema,
40
42
  KakaoConfigSchema,
43
+ KakaoLeaveChatResultSchema,
41
44
  KakaoMarkReadResultSchema,
42
45
  KakaoMemberSchema,
43
46
  KakaoMessageSchema,
@@ -47,6 +50,7 @@ export {
47
50
  KakaoTalkPushMemberEventSchema,
48
51
  KakaoTalkPushMessageEventSchema,
49
52
  KakaoTalkPushReadEventSchema,
53
+ KakaoTypingResultSchema,
50
54
  } from './types'
51
55
  export { attemptLogin, generateDeviceUuid, loginFlow, registerDevice, requestPasscode } from './auth/kakao-login'
52
56
  export type { LoginCredentials } from './auth/kakao-login'
@@ -0,0 +1,113 @@
1
+ import { describe, expect, it, mock } from 'bun:test'
2
+
3
+ import { Long } from 'bson'
4
+
5
+ import { buildTypingActionBody, sendTypingPacket, TYPING_ACTION_METHOD } from './session'
6
+ import type { LocoPacket } from './types'
7
+
8
+ type SentPacket = { method: string; body: Record<string, unknown> }
9
+
10
+ // Fake connection object that captures every sendPacket call. We test
11
+ // sendTypingPacket (the extracted wire-boundary function) directly rather than
12
+ // the LocoSession class method — LocoSession.sendTyping is a two-line delegation
13
+ // to this helper, and testing the class would require mock.module('./session',
14
+ // …) which collides with client.test.ts's LocoSession mock.
15
+ function fakeConnection(returnPacket: LocoPacket = { statusCode: 0, body: {} }): {
16
+ connection: { sendPacket: (method: string, body: Record<string, unknown>) => Promise<LocoPacket> }
17
+ sent: SentPacket[]
18
+ sendPacketMock: ReturnType<typeof mock>
19
+ } {
20
+ const sent: SentPacket[] = []
21
+ const sendPacketMock = mock(async (method: string, body: Record<string, unknown>) => {
22
+ sent.push({ method, body })
23
+ return returnPacket
24
+ })
25
+ return { connection: { sendPacket: sendPacketMock }, sent, sendPacketMock }
26
+ }
27
+
28
+ describe('typing indicator wire contract', () => {
29
+ it('TYPING_ACTION_METHOD is the exact string "ACTION" that KakaoTalk expects on the wire', () => {
30
+ expect(TYPING_ACTION_METHOD).toBe('ACTION')
31
+ })
32
+
33
+ it('emits type=1 and omits linkId entirely for normal chats', () => {
34
+ const body = buildTypingActionBody(Long.fromString('459750513901477'))
35
+
36
+ expect((body.chatId as { toString(): string }).toString()).toBe('459750513901477')
37
+ expect(body.type).toBe(1)
38
+ expect('linkId' in body).toBe(false)
39
+ })
40
+
41
+ it('includes linkId when provided (open-chat pulse)', () => {
42
+ const body = buildTypingActionBody(Long.fromString('123'), Long.fromString('77777'))
43
+
44
+ expect(body.type).toBe(1)
45
+ expect((body.linkId as { toString(): string }).toString()).toBe('77777')
46
+ })
47
+
48
+ it('never sets linkId=null when caller passes undefined (would change server routing)', () => {
49
+ const body = buildTypingActionBody(Long.fromString('123'), undefined)
50
+
51
+ expect(body.linkId).toBeUndefined()
52
+ expect('linkId' in body).toBe(false)
53
+ })
54
+
55
+ it('body only carries chatId + type for normal chats (no stray fields)', () => {
56
+ const body = buildTypingActionBody(Long.fromString('123'))
57
+
58
+ expect(Object.keys(body).sort()).toEqual(['chatId', 'type'])
59
+ })
60
+
61
+ it('body only carries chatId + type + linkId for open chats (no stray fields)', () => {
62
+ const body = buildTypingActionBody(Long.fromString('123'), Long.fromString('77777'))
63
+
64
+ expect(Object.keys(body).sort()).toEqual(['chatId', 'linkId', 'type'])
65
+ })
66
+ })
67
+
68
+ describe('sendTypingPacket → sendPacket wire boundary', () => {
69
+ it('calls connection.sendPacket("ACTION", body) exactly once for a normal chat, with full-width Long chatId preserved', async () => {
70
+ const { connection, sent, sendPacketMock } = fakeConnection()
71
+
72
+ await sendTypingPacket(connection, Long.fromString('459750513901477'))
73
+
74
+ expect(sendPacketMock).toHaveBeenCalledTimes(1)
75
+ expect(sent).toHaveLength(1)
76
+ const packet = sent[0]!
77
+ expect(packet.method).toBe('ACTION')
78
+ expect((packet.body.chatId as Long).toString()).toBe('459750513901477')
79
+ expect(packet.body.type).toBe(1)
80
+ expect('linkId' in packet.body).toBe(false)
81
+ })
82
+
83
+ it('calls sendPacket with full-width Long linkId preserved for an open-chat pulse', async () => {
84
+ const { connection, sent } = fakeConnection()
85
+
86
+ await sendTypingPacket(connection, Long.fromString('459750513901477'), Long.fromString('280368495100000'))
87
+
88
+ const packet = sent[0]!
89
+ expect(packet.method).toBe('ACTION')
90
+ expect((packet.body.chatId as Long).toString()).toBe('459750513901477')
91
+ expect((packet.body.linkId as Long).toString()).toBe('280368495100000')
92
+ expect(packet.body.type).toBe(1)
93
+ })
94
+
95
+ it('propagates the LocoPacket returned by sendPacket verbatim', async () => {
96
+ const expected = { statusCode: 0, body: { hello: 'world' } }
97
+ const { connection } = fakeConnection(expected)
98
+
99
+ const result = await sendTypingPacket(connection, Long.fromString('123'))
100
+
101
+ expect(result).toBe(expected)
102
+ })
103
+
104
+ it('emits the same wire packet the reverse-engineered client builds (spec-lock)', async () => {
105
+ const { connection, sent } = fakeConnection()
106
+
107
+ await sendTypingPacket(connection, Long.fromString('123'), Long.fromString('456'))
108
+
109
+ const packet = sent[0]!
110
+ expect(packet.method).toBe(TYPING_ACTION_METHOD)
111
+ expect(packet.body).toEqual(buildTypingActionBody(Long.fromString('123'), Long.fromString('456')))
112
+ })
113
+ })
@@ -17,6 +17,36 @@ import {
17
17
  import { LocoConnection } from './connection'
18
18
  import type { BookingResponse, CheckinResponse, LoginListResponse, LocoPacket, SyncState } from './types'
19
19
 
20
+ // LOCO opcode string emitted on the wire for typing indicator pulses.
21
+ // Reverse-engineered from KakaoTalk 25.x — see `LocoSession.sendTyping` docs.
22
+ export const TYPING_ACTION_METHOD = 'ACTION'
23
+
24
+ // Builds the BSON body the official KakaoTalk client emits for the typing
25
+ // indicator subtype of the `ACTION` opcode. Exported so protocol tests can lock
26
+ // the exact field shape.
27
+ export function buildTypingActionBody(chatId: Long, linkId?: Long): Record<string, unknown> {
28
+ const body: Record<string, unknown> = { chatId, type: 1 }
29
+ // Must omit the field entirely (not `null`) for normal chats — the official
30
+ // client only populates linkId for OpenChat rooms, and sending null here
31
+ // would change server routing.
32
+ if (linkId !== undefined) body.linkId = linkId
33
+ return body
34
+ }
35
+
36
+ // The one-and-only delegation from `LocoSession.sendTyping` to a `sendPacket`
37
+ // implementation. Extracting this makes the wire boundary directly testable
38
+ // with a fake connection object — no `LocoSession` instantiation required,
39
+ // which side-steps the `mock.module('./session', …)` set up by `client.test.ts`.
40
+ // `LocoSession.sendTyping` is a 2-line delegation to this helper, so any test
41
+ // that pins this function's behaviour also pins the class method's contract.
42
+ export async function sendTypingPacket(
43
+ connection: { sendPacket: (method: string, body: Record<string, unknown>) => Promise<LocoPacket> },
44
+ chatId: Long,
45
+ linkId?: Long,
46
+ ): Promise<LocoPacket> {
47
+ return connection.sendPacket(TYPING_ACTION_METHOD, buildTypingActionBody(chatId, linkId))
48
+ }
49
+
20
50
  export class LocoSession {
21
51
  private connection: LocoConnection | null = null
22
52
  private pingTimer: ReturnType<typeof setInterval> | null = null
@@ -289,6 +319,11 @@ export class LocoSession {
289
319
  * supply `linkId`; normal chats must omit it (the wire `li` key must be
290
320
  * absent, not null/0).
291
321
  */
322
+ async leaveChat(chatId: Long): Promise<LocoPacket> {
323
+ if (!this.connection) throw new Error('Not connected')
324
+ return this.connection.sendPacket('LEAVE', { chatId })
325
+ }
326
+
292
327
  async markRead(chatId: Long, watermark: Long, linkId?: Long): Promise<LocoPacket> {
293
328
  if (!this.connection) throw new Error('Not connected')
294
329
  const body: Record<string, unknown> = { chatId, watermark }
@@ -298,6 +333,27 @@ export class LocoSession {
298
333
  return this.connection.sendPacket('NOTIREAD', body)
299
334
  }
300
335
 
336
+ /**
337
+ * Fire a single typing pulse to peers of a chat — this is what triggers the
338
+ * animated "…" dots the recipient sees while somebody is composing.
339
+ *
340
+ * Under the hood this sends the LOCO `ACTION` opcode with `type=1`. The
341
+ * KakaoTalk client-side lifetime is ~5s per pulse, so callers wanting a
342
+ * sustained indicator must re-fire faster than that.
343
+ *
344
+ * Reverse-engineered from KakaoTalk 25.x: the app's LOCO layer emits an
345
+ * `ACTION` packet with a BSON body of `{chatId, type, linkId?}` when the
346
+ * user types in a chat. `type=1` is the value the app uses for the typing
347
+ * indicator; other `type` values may exist for other in-composition states
348
+ * (recording audio, attaching media, etc.) but haven't been verified. The
349
+ * `linkId` field is omitted entirely (not sent as null) for normal chats;
350
+ * it is only populated for OpenChat rooms.
351
+ */
352
+ async sendTyping(chatId: Long, linkId?: Long): Promise<LocoPacket> {
353
+ if (!this.connection) throw new Error('Not connected')
354
+ return sendTypingPacket(this.connection, chatId, linkId)
355
+ }
356
+
301
357
  onPush(handler: (packet: LocoPacket) => void): void {
302
358
  this.pushHandler = handler
303
359
  if (this.connection) {
@@ -207,6 +207,18 @@ export interface KakaoMarkReadResult {
207
207
  watermark: string
208
208
  }
209
209
 
210
+ export interface KakaoLeaveChatResult {
211
+ success: boolean
212
+ status_code: number
213
+ chat_id: string
214
+ }
215
+
216
+ export interface KakaoTypingResult {
217
+ success: boolean
218
+ status_code: number
219
+ chat_id: string
220
+ }
221
+
210
222
  export const KakaoChatSchema = z.object({
211
223
  chat_id: z.string(),
212
224
  type: z.number(),
@@ -263,6 +275,18 @@ export const KakaoMarkReadResultSchema = z.object({
263
275
  watermark: z.string(),
264
276
  })
265
277
 
278
+ export const KakaoLeaveChatResultSchema = z.object({
279
+ success: z.boolean(),
280
+ status_code: z.number(),
281
+ chat_id: z.string(),
282
+ })
283
+
284
+ export const KakaoTypingResultSchema = z.object({
285
+ success: z.boolean(),
286
+ status_code: z.number(),
287
+ chat_id: z.string(),
288
+ })
289
+
266
290
  export interface KakaoProfile {
267
291
  user_id: string
268
292
  nickname: string