agent-messenger 2.34.0 → 2.36.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 (117) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/dist/package.json +1 -1
  3. package/dist/src/platforms/kakaotalk/auth/kakao-login.d.ts +2 -0
  4. package/dist/src/platforms/kakaotalk/auth/kakao-login.d.ts.map +1 -1
  5. package/dist/src/platforms/kakaotalk/auth/kakao-login.js +2 -2
  6. package/dist/src/platforms/kakaotalk/auth/kakao-login.js.map +1 -1
  7. package/dist/src/platforms/kakaotalk/auth/oauth-refresh.d.ts +28 -0
  8. package/dist/src/platforms/kakaotalk/auth/oauth-refresh.d.ts.map +1 -0
  9. package/dist/src/platforms/kakaotalk/auth/oauth-refresh.js +100 -0
  10. package/dist/src/platforms/kakaotalk/auth/oauth-refresh.js.map +1 -0
  11. package/dist/src/platforms/kakaotalk/client.d.ts +2 -0
  12. package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
  13. package/dist/src/platforms/kakaotalk/client.js +14 -1
  14. package/dist/src/platforms/kakaotalk/client.js.map +1 -1
  15. package/dist/src/platforms/kakaotalk/index.d.ts +3 -1
  16. package/dist/src/platforms/kakaotalk/index.d.ts.map +1 -1
  17. package/dist/src/platforms/kakaotalk/index.js +1 -0
  18. package/dist/src/platforms/kakaotalk/index.js.map +1 -1
  19. package/dist/src/platforms/kakaotalk/protocol/login-response.d.ts +9 -0
  20. package/dist/src/platforms/kakaotalk/protocol/login-response.d.ts.map +1 -0
  21. package/dist/src/platforms/kakaotalk/protocol/login-response.js +32 -0
  22. package/dist/src/platforms/kakaotalk/protocol/login-response.js.map +1 -0
  23. package/dist/src/platforms/kakaotalk/protocol/session.d.ts.map +1 -1
  24. package/dist/src/platforms/kakaotalk/protocol/session.js +29 -20
  25. package/dist/src/platforms/kakaotalk/protocol/session.js.map +1 -1
  26. package/dist/src/platforms/kakaotalk/protocol/types.d.ts +1 -0
  27. package/dist/src/platforms/kakaotalk/protocol/types.d.ts.map +1 -1
  28. package/dist/src/platforms/kakaotalk/protocol/types.js.map +1 -1
  29. package/dist/src/platforms/kakaotalk/types.d.ts +1 -0
  30. package/dist/src/platforms/kakaotalk/types.d.ts.map +1 -1
  31. package/dist/src/platforms/kakaotalk/types.js.map +1 -1
  32. package/dist/src/platforms/teams/client.d.ts +4 -4
  33. package/dist/src/platforms/teams/client.d.ts.map +1 -1
  34. package/dist/src/platforms/teams/client.js +14 -14
  35. package/dist/src/platforms/teams/client.js.map +1 -1
  36. package/dist/src/platforms/teams/commands/chat.d.ts +2 -0
  37. package/dist/src/platforms/teams/commands/chat.d.ts.map +1 -1
  38. package/dist/src/platforms/teams/commands/chat.js +7 -2
  39. package/dist/src/platforms/teams/commands/chat.js.map +1 -1
  40. package/dist/src/platforms/teams/commands/format.d.ts +3 -0
  41. package/dist/src/platforms/teams/commands/format.d.ts.map +1 -0
  42. package/dist/src/platforms/teams/commands/format.js +10 -0
  43. package/dist/src/platforms/teams/commands/format.js.map +1 -0
  44. package/dist/src/platforms/teams/commands/message.d.ts +1 -0
  45. package/dist/src/platforms/teams/commands/message.d.ts.map +1 -1
  46. package/dist/src/platforms/teams/commands/message.js +4 -1
  47. package/dist/src/platforms/teams/commands/message.js.map +1 -1
  48. package/dist/src/platforms/teams/html-sanitizer.d.ts +17 -0
  49. package/dist/src/platforms/teams/html-sanitizer.d.ts.map +1 -0
  50. package/dist/src/platforms/teams/html-sanitizer.js +120 -0
  51. package/dist/src/platforms/teams/html-sanitizer.js.map +1 -0
  52. package/dist/src/platforms/teams/index.d.ts +1 -1
  53. package/dist/src/platforms/teams/index.d.ts.map +1 -1
  54. package/dist/src/platforms/teams/index.js.map +1 -1
  55. package/dist/src/platforms/teams/types.d.ts +1 -0
  56. package/dist/src/platforms/teams/types.d.ts.map +1 -1
  57. package/dist/src/platforms/teams/types.js.map +1 -1
  58. package/dist/src/platforms/webex/client.d.ts.map +1 -1
  59. package/dist/src/platforms/webex/client.js +1 -1
  60. package/dist/src/platforms/webex/client.js.map +1 -1
  61. package/dist/src/shared/utils/markdown-to-html.d.ts.map +1 -0
  62. package/dist/src/{platforms/webex → shared/utils}/markdown-to-html.js +0 -0
  63. package/dist/src/shared/utils/markdown-to-html.js.map +1 -0
  64. package/docs/content/docs/cli/kakaotalk.mdx +10 -0
  65. package/docs/content/docs/sdk/kakaotalk.mdx +54 -0
  66. package/package.json +1 -1
  67. package/skills/agent-channeltalk/SKILL.md +1 -1
  68. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  69. package/skills/agent-discord/SKILL.md +1 -1
  70. package/skills/agent-discordbot/SKILL.md +1 -1
  71. package/skills/agent-imessage/SKILL.md +1 -1
  72. package/skills/agent-instagram/SKILL.md +1 -1
  73. package/skills/agent-kakaotalk/SKILL.md +27 -1
  74. package/skills/agent-kakaotalk/references/authentication.md +59 -0
  75. package/skills/agent-kakaotalk/references/common-patterns.md +8 -0
  76. package/skills/agent-line/SKILL.md +1 -1
  77. package/skills/agent-slack/SKILL.md +1 -1
  78. package/skills/agent-slackbot/SKILL.md +1 -1
  79. package/skills/agent-teams/SKILL.md +44 -2
  80. package/skills/agent-teams/references/common-patterns.md +2 -2
  81. package/skills/agent-telegram/SKILL.md +1 -1
  82. package/skills/agent-telegrambot/SKILL.md +1 -1
  83. package/skills/agent-webex/SKILL.md +1 -1
  84. package/skills/agent-webexbot/SKILL.md +1 -1
  85. package/skills/agent-wechatbot/SKILL.md +1 -1
  86. package/skills/agent-whatsapp/SKILL.md +1 -1
  87. package/skills/agent-whatsappbot/SKILL.md +1 -1
  88. package/src/platforms/kakaotalk/auth/kakao-login.ts +2 -2
  89. package/src/platforms/kakaotalk/auth/oauth-refresh.test.ts +225 -0
  90. package/src/platforms/kakaotalk/auth/oauth-refresh.ts +144 -0
  91. package/src/platforms/kakaotalk/client.test.ts +55 -0
  92. package/src/platforms/kakaotalk/client.ts +19 -2
  93. package/src/platforms/kakaotalk/index.test.ts +11 -0
  94. package/src/platforms/kakaotalk/index.ts +8 -0
  95. package/src/platforms/kakaotalk/protocol/NOTICE.md +19 -4
  96. package/src/platforms/kakaotalk/protocol/login-response.test.ts +64 -0
  97. package/src/platforms/kakaotalk/protocol/login-response.ts +40 -0
  98. package/src/platforms/kakaotalk/protocol/session.ts +29 -21
  99. package/src/platforms/kakaotalk/protocol/types.ts +1 -0
  100. package/src/platforms/kakaotalk/types.ts +1 -0
  101. package/src/platforms/teams/client.test.ts +127 -0
  102. package/src/platforms/teams/client.ts +27 -14
  103. package/src/platforms/teams/commands/chat.test.ts +70 -1
  104. package/src/platforms/teams/commands/chat.ts +15 -4
  105. package/src/platforms/teams/commands/format.ts +12 -0
  106. package/src/platforms/teams/commands/message.test.ts +56 -0
  107. package/src/platforms/teams/commands/message.ts +6 -2
  108. package/src/platforms/teams/html-sanitizer.test.ts +123 -0
  109. package/src/platforms/teams/html-sanitizer.ts +134 -0
  110. package/src/platforms/teams/index.ts +1 -0
  111. package/src/platforms/teams/types.ts +2 -0
  112. package/src/platforms/webex/client.ts +2 -1
  113. package/src/{platforms/webex → shared/utils}/markdown-to-html.test.ts +7 -0
  114. package/src/{platforms/webex → shared/utils}/markdown-to-html.ts +0 -0
  115. package/dist/src/platforms/webex/markdown-to-html.d.ts.map +0 -1
  116. package/dist/src/platforms/webex/markdown-to-html.js.map +0 -1
  117. /package/dist/src/{platforms/webex → shared/utils}/markdown-to-html.d.ts +0 -0
@@ -0,0 +1,225 @@
1
+ import { describe, expect, it, mock } from 'bun:test'
2
+
3
+ import { KakaoOAuthRefreshError, refreshKakaoOAuthToken } from './oauth-refresh'
4
+
5
+ const HEX_DEVICE_UUID = 'a'.repeat(64)
6
+ const credentials = {
7
+ accessToken: 'access-secret',
8
+ refreshToken: 'refresh-secret',
9
+ deviceUuid: HEX_DEVICE_UUID,
10
+ }
11
+
12
+ function jsonResponse(body: unknown, status = 200): Response {
13
+ return new Response(JSON.stringify(body), {
14
+ status,
15
+ headers: { 'Content-Type': 'application/json' },
16
+ })
17
+ }
18
+
19
+ function captureError(promise: Promise<unknown>): Promise<KakaoOAuthRefreshError> {
20
+ return promise.then(
21
+ () => {
22
+ throw new Error('Expected refresh to fail')
23
+ },
24
+ (error: unknown) => {
25
+ expect(error).toBeInstanceOf(KakaoOAuthRefreshError)
26
+ return error as KakaoOAuthRefreshError
27
+ },
28
+ )
29
+ }
30
+
31
+ function expectSecretsRedacted(error: Error, ...secrets: string[]): void {
32
+ const rendered = `${String(error)}\n${error.stack ?? ''}\n${JSON.stringify(error)}`
33
+ for (const secret of secrets) {
34
+ expect(rendered).not.toContain(secret)
35
+ }
36
+ }
37
+
38
+ describe('refreshKakaoOAuthToken', () => {
39
+ it('sends the Android sub-device refresh request and returns rotated tokens', async () => {
40
+ const fetchImpl = mock(async () =>
41
+ jsonResponse({
42
+ status: 0,
43
+ access_token: 'new-access',
44
+ refresh_token: 'new-refresh',
45
+ token_type: 'bearer',
46
+ expires_in: 3600,
47
+ }),
48
+ )
49
+
50
+ const result = await refreshKakaoOAuthToken(credentials, {
51
+ fetchImpl,
52
+ requestId: () => 'request-id',
53
+ })
54
+
55
+ expect(result).toEqual({
56
+ accessToken: 'new-access',
57
+ refreshToken: 'new-refresh',
58
+ tokenType: 'bearer',
59
+ expiresIn: 3600,
60
+ })
61
+ expect(fetchImpl).toHaveBeenCalledTimes(1)
62
+
63
+ const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]
64
+ expect(url).toBe('https://katalk.kakao.com/android/account/oauth2_token.json')
65
+ expect(init.method).toBe('POST')
66
+ expect(init.headers).toEqual({
67
+ A: 'android/25.9.2/ko',
68
+ Authorization: `access-secret-${HEX_DEVICE_UUID}`,
69
+ 'User-Agent': 'KT/25.9.2 An/13 ko',
70
+ 'Accept-Language': 'ko',
71
+ 'Content-Type': 'application/json; charset=utf-8',
72
+ C: 'request-id',
73
+ Connection: 'Close',
74
+ })
75
+ expect(JSON.parse(String(init.body))).toEqual({
76
+ access_token: credentials.accessToken,
77
+ refresh_token: credentials.refreshToken,
78
+ grant_type: 'refresh_token',
79
+ })
80
+ expect(init.signal).toBeInstanceOf(AbortSignal)
81
+ })
82
+
83
+ it('derives the Android device id for a non-hex UUID', async () => {
84
+ const fetchImpl = mock(async () => jsonResponse({ status: 0, access_token: 'new-access' }))
85
+
86
+ await refreshKakaoOAuthToken({ ...credentials, deviceUuid: 'device-uuid' }, { fetchImpl })
87
+
88
+ const [, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]
89
+ expect(init.headers).toMatchObject({
90
+ Authorization: 'access-secret-db214b8bbba42ef7a5fbc4c1bbba91305178ade87b29f1378f55b69fc04caac5',
91
+ })
92
+ })
93
+
94
+ it('preserves the input refresh token when Kakao does not rotate it', async () => {
95
+ const fetchImpl = mock(async () => jsonResponse({ status: 0, access_token: 'new-access' }))
96
+
97
+ await expect(refreshKakaoOAuthToken(credentials, { fetchImpl })).resolves.toEqual({
98
+ accessToken: 'new-access',
99
+ refreshToken: credentials.refreshToken,
100
+ tokenType: undefined,
101
+ expiresIn: undefined,
102
+ })
103
+ })
104
+
105
+ it('rejects missing credentials before making a request', async () => {
106
+ const fetchImpl = mock(async () => jsonResponse({ status: 0, access_token: 'unused' }))
107
+
108
+ const error = await captureError(refreshKakaoOAuthToken({ ...credentials, refreshToken: '' }, { fetchImpl }))
109
+
110
+ expect(error).toMatchObject({ code: 'refresh_credentials_missing' })
111
+ expect(fetchImpl).not.toHaveBeenCalled()
112
+ })
113
+
114
+ it('classifies a Kakao rejection with safe status metadata', async () => {
115
+ const fetchImpl = mock(async () =>
116
+ jsonResponse({ status: -950, message: 'rejected', raw: 'raw-response-secret' }, 401),
117
+ )
118
+
119
+ const error = await captureError(refreshKakaoOAuthToken(credentials, { fetchImpl }))
120
+
121
+ expect(error).toMatchObject({ code: 'refresh_rejected', serverStatus: -950, httpStatus: 401 })
122
+ expectSecretsRedacted(error, credentials.accessToken, credentials.refreshToken, 'raw-response-secret')
123
+ })
124
+
125
+ it('classifies an HTTP failure without a Kakao status', async () => {
126
+ const fetchImpl = mock(async () => jsonResponse({ message: 'unavailable' }, 503))
127
+
128
+ const error = await captureError(refreshKakaoOAuthToken(credentials, { fetchImpl }))
129
+
130
+ expect(error).toMatchObject({ code: 'refresh_http_error', httpStatus: 503 })
131
+ expect(error.serverStatus).toBeUndefined()
132
+ })
133
+
134
+ it('rejects a malformed successful response without exposing the body', async () => {
135
+ const fetchImpl = mock(async () => jsonResponse({ status: 0, access_token: '', raw: 'do-not-log' }))
136
+
137
+ const error = await captureError(refreshKakaoOAuthToken(credentials, { fetchImpl }))
138
+
139
+ expect(error).toMatchObject({ code: 'refresh_malformed_response', httpStatus: 200 })
140
+ expectSecretsRedacted(error, credentials.accessToken, credentials.refreshToken, 'do-not-log')
141
+ })
142
+
143
+ it('rejects a non-JSON response without exposing its text', async () => {
144
+ const fetchImpl = mock(async () => new Response('body-secret', { status: 200 }))
145
+
146
+ const error = await captureError(refreshKakaoOAuthToken(credentials, { fetchImpl }))
147
+
148
+ expect(error).toMatchObject({ code: 'refresh_malformed_response', httpStatus: 200 })
149
+ expectSecretsRedacted(error, credentials.accessToken, credentials.refreshToken, 'body-secret')
150
+ })
151
+
152
+ it('aborts a request after the configured timeout', async () => {
153
+ const fetchImpl = mock(
154
+ async (_url: string | URL | Request, init?: RequestInit): Promise<Response> =>
155
+ await new Promise((_resolve, reject) => {
156
+ init?.signal?.addEventListener('abort', () => {
157
+ reject(Object.assign(new Error('timed out with access-secret'), { name: 'AbortError' }))
158
+ })
159
+ }),
160
+ )
161
+
162
+ const error = await captureError(refreshKakaoOAuthToken(credentials, { fetchImpl, timeoutMs: 5 }))
163
+
164
+ expect(error).toMatchObject({ code: 'refresh_timeout' })
165
+ expectSecretsRedacted(error, credentials.accessToken, credentials.refreshToken)
166
+ })
167
+
168
+ it('keeps the timeout active while consuming a stalled response body', async () => {
169
+ const fetchImpl = mock(async (_url: string | URL | Request, init?: RequestInit): Promise<Response> => {
170
+ const body = new ReadableStream<Uint8Array>({
171
+ start(controller) {
172
+ const fallback = setTimeout(() => {
173
+ controller.error(Object.assign(new Error('stalled body access-secret'), { name: 'AbortError' }))
174
+ }, 50)
175
+
176
+ init?.signal?.addEventListener(
177
+ 'abort',
178
+ () => {
179
+ clearTimeout(fallback)
180
+ controller.error(Object.assign(new Error('stalled body access-secret'), { name: 'AbortError' }))
181
+ },
182
+ { once: true },
183
+ )
184
+ },
185
+ })
186
+
187
+ return new Response(body, { status: 200, headers: { 'Content-Type': 'application/json' } })
188
+ })
189
+
190
+ const error = await captureError(refreshKakaoOAuthToken(credentials, { fetchImpl, timeoutMs: 5 }))
191
+
192
+ expect(error).toMatchObject({ code: 'refresh_timeout' })
193
+ expectSecretsRedacted(error, credentials.accessToken, credentials.refreshToken)
194
+ })
195
+
196
+ it('classifies response body transport failures without retaining a secret-bearing cause', async () => {
197
+ const fetchImpl = mock(async (): Promise<Response> => {
198
+ const body = new ReadableStream<Uint8Array>({
199
+ start(controller) {
200
+ controller.error(new Error(`body failure ${credentials.accessToken} ${credentials.refreshToken}`))
201
+ },
202
+ })
203
+
204
+ return new Response(body, { status: 200, headers: { 'Content-Type': 'application/json' } })
205
+ })
206
+
207
+ const error = await captureError(refreshKakaoOAuthToken(credentials, { fetchImpl }))
208
+
209
+ expect(error).toMatchObject({ code: 'refresh_request_failed' })
210
+ expect(error.cause).toBeUndefined()
211
+ expectSecretsRedacted(error, credentials.accessToken, credentials.refreshToken)
212
+ })
213
+
214
+ it('classifies network failures without retaining a secret-bearing cause', async () => {
215
+ const fetchImpl = mock(async () => {
216
+ throw new Error(`network failure ${credentials.accessToken} ${credentials.refreshToken}`)
217
+ })
218
+
219
+ const error = await captureError(refreshKakaoOAuthToken(credentials, { fetchImpl }))
220
+
221
+ expect(error).toMatchObject({ code: 'refresh_request_failed' })
222
+ expect(error.cause).toBeUndefined()
223
+ expectSecretsRedacted(error, credentials.accessToken, credentials.refreshToken)
224
+ })
225
+ })
@@ -0,0 +1,144 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+
3
+ import { ANDROID_AGENT, ANDROID_USER_AGENT } from './kakao-login'
4
+
5
+ const ANDROID_OAUTH_REFRESH_URL = 'https://katalk.kakao.com/android/account/oauth2_token.json'
6
+ const DEFAULT_REFRESH_TIMEOUT_MS = 15_000
7
+
8
+ export type KakaoOAuthRefreshErrorCode =
9
+ | 'refresh_credentials_missing'
10
+ | 'refresh_http_error'
11
+ | 'refresh_malformed_response'
12
+ | 'refresh_rejected'
13
+ | 'refresh_request_failed'
14
+ | 'refresh_timeout'
15
+
16
+ export class KakaoOAuthRefreshError extends Error {
17
+ readonly code: KakaoOAuthRefreshErrorCode
18
+ readonly serverStatus?: number
19
+ readonly httpStatus?: number
20
+
21
+ constructor(code: KakaoOAuthRefreshErrorCode, options?: { serverStatus?: number; httpStatus?: number }) {
22
+ super(`KakaoTalk OAuth token refresh failed (${code})`)
23
+ this.name = 'KakaoOAuthRefreshError'
24
+ this.code = code
25
+ this.serverStatus = options?.serverStatus
26
+ this.httpStatus = options?.httpStatus
27
+ }
28
+ }
29
+
30
+ export interface KakaoOAuthRefreshInput {
31
+ accessToken: string
32
+ refreshToken: string
33
+ deviceUuid: string
34
+ }
35
+
36
+ export interface KakaoOAuthRefreshResult {
37
+ accessToken: string
38
+ refreshToken: string
39
+ tokenType?: string
40
+ expiresIn?: number
41
+ }
42
+
43
+ export interface KakaoOAuthRefreshOptions {
44
+ fetchImpl?: typeof fetch
45
+ requestId?: () => string
46
+ timeoutMs?: number
47
+ }
48
+
49
+ function buildDeviceId(deviceUuid: string): string {
50
+ if (/^[a-f0-9]{40,64}$/i.test(deviceUuid)) return deviceUuid
51
+ return createHash('sha256').update(`dkljleskljfeisflssljeif ${deviceUuid}`, 'utf8').digest('hex')
52
+ }
53
+
54
+ function readRecord(value: unknown): Record<string, unknown> | null {
55
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
56
+ ? (value as Record<string, unknown>)
57
+ : null
58
+ }
59
+
60
+ function transportError(error: unknown, signal: AbortSignal): KakaoOAuthRefreshError {
61
+ const timedOut = signal.aborted || (error instanceof Error && error.name === 'AbortError')
62
+ return new KakaoOAuthRefreshError(timedOut ? 'refresh_timeout' : 'refresh_request_failed')
63
+ }
64
+
65
+ export async function refreshKakaoOAuthToken(
66
+ input: KakaoOAuthRefreshInput,
67
+ options: KakaoOAuthRefreshOptions = {},
68
+ ): Promise<KakaoOAuthRefreshResult> {
69
+ if (!input.accessToken || !input.refreshToken || !input.deviceUuid) {
70
+ throw new KakaoOAuthRefreshError('refresh_credentials_missing')
71
+ }
72
+
73
+ const fetchImpl = options.fetchImpl ?? fetch
74
+ const controller = new AbortController()
75
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_REFRESH_TIMEOUT_MS)
76
+
77
+ try {
78
+ let response: Response
79
+ try {
80
+ response = await fetchImpl(ANDROID_OAUTH_REFRESH_URL, {
81
+ method: 'POST',
82
+ headers: {
83
+ A: ANDROID_AGENT,
84
+ Authorization: `${input.accessToken}-${buildDeviceId(input.deviceUuid)}`,
85
+ 'User-Agent': ANDROID_USER_AGENT,
86
+ 'Accept-Language': 'ko',
87
+ 'Content-Type': 'application/json; charset=utf-8',
88
+ C: (options.requestId ?? randomUUID)(),
89
+ Connection: 'Close',
90
+ },
91
+ body: JSON.stringify({
92
+ access_token: input.accessToken,
93
+ refresh_token: input.refreshToken,
94
+ grant_type: 'refresh_token',
95
+ }),
96
+ signal: controller.signal,
97
+ })
98
+ } catch (error) {
99
+ throw transportError(error, controller.signal)
100
+ }
101
+
102
+ let body: Record<string, unknown> | null
103
+ try {
104
+ body = readRecord(await response.json())
105
+ } catch (error) {
106
+ if (!(error instanceof SyntaxError)) throw transportError(error, controller.signal)
107
+ body = null
108
+ }
109
+
110
+ const serverStatus = body && typeof body.status === 'number' ? body.status : undefined
111
+ if (!response.ok) {
112
+ throw new KakaoOAuthRefreshError(
113
+ serverStatus !== undefined && serverStatus !== 0 ? 'refresh_rejected' : 'refresh_http_error',
114
+ { serverStatus: serverStatus !== 0 ? serverStatus : undefined, httpStatus: response.status },
115
+ )
116
+ }
117
+
118
+ if (!body) {
119
+ throw new KakaoOAuthRefreshError('refresh_malformed_response', { httpStatus: response.status })
120
+ }
121
+
122
+ if (serverStatus !== undefined && serverStatus !== 0) {
123
+ throw new KakaoOAuthRefreshError('refresh_rejected', {
124
+ serverStatus,
125
+ httpStatus: response.status,
126
+ })
127
+ }
128
+
129
+ const accessToken = typeof body.access_token === 'string' ? body.access_token : ''
130
+ if (!accessToken) {
131
+ throw new KakaoOAuthRefreshError('refresh_malformed_response', { httpStatus: response.status })
132
+ }
133
+
134
+ return {
135
+ accessToken,
136
+ refreshToken:
137
+ typeof body.refresh_token === 'string' && body.refresh_token ? body.refresh_token : input.refreshToken,
138
+ tokenType: typeof body.token_type === 'string' && body.token_type ? body.token_type : undefined,
139
+ expiresIn: typeof body.expires_in === 'number' ? body.expires_in : undefined,
140
+ }
141
+ } finally {
142
+ clearTimeout(timeout)
143
+ }
144
+ }
@@ -144,6 +144,61 @@ describe('KakaoTalkClient', () => {
144
144
  expect((e as KakaoTalkError).code).toBe('missing_user_id')
145
145
  }
146
146
  })
147
+
148
+ it('preserves invalid_access_token and never continues to LCHATLIST', async () => {
149
+ mockLogin.mockRejectedValue(
150
+ Object.assign(new Error('KakaoTalk LOGINLIST invalid access token (status -950)'), {
151
+ code: 'invalid_access_token',
152
+ serverStatus: -950,
153
+ }),
154
+ )
155
+ const client = await new KakaoTalkClient().login({
156
+ oauthToken: 'expired-access-token',
157
+ userId: 'user1',
158
+ deviceUuid: 'device1',
159
+ })
160
+
161
+ let error: unknown
162
+ try {
163
+ await client.getChats({ all: true })
164
+ } catch (cause) {
165
+ error = cause
166
+ }
167
+
168
+ expect(error).toBeInstanceOf(KakaoTalkError)
169
+ expect(error).toMatchObject({ code: 'invalid_access_token', serverStatus: -950 })
170
+ expect(mockGetChatList).not.toHaveBeenCalled()
171
+ expect(mockClose).toHaveBeenCalledTimes(1)
172
+ })
173
+
174
+ it('preserves other typed LOGINLIST rejections', async () => {
175
+ mockLogin.mockRejectedValue(
176
+ Object.assign(new Error('KakaoTalk LOGINLIST rejected (status -777)'), {
177
+ code: 'login_rejected',
178
+ serverStatus: -777,
179
+ }),
180
+ )
181
+ const client = await new KakaoTalkClient().login({
182
+ oauthToken: 'rejected-access-token',
183
+ userId: 'user1',
184
+ deviceUuid: 'device1',
185
+ })
186
+
187
+ await expect(client.getChats()).rejects.toMatchObject({ code: 'login_rejected', serverStatus: -777 })
188
+ expect(mockGetChatList).not.toHaveBeenCalled()
189
+ })
190
+
191
+ it('keeps interrupted LOGINLIST connections classified as login_failed', async () => {
192
+ mockLogin.mockRejectedValue(new Error('KakaoTalk LOGINLIST transport failed: connection closed'))
193
+ const client = await new KakaoTalkClient().login({
194
+ oauthToken: 'token',
195
+ userId: 'user1',
196
+ deviceUuid: 'device1',
197
+ })
198
+
199
+ await expect(client.getChats()).rejects.toMatchObject({ code: 'login_failed' })
200
+ expect(mockGetChatList).not.toHaveBeenCalled()
201
+ })
147
202
  })
148
203
 
149
204
  describe('getChats', () => {
@@ -40,11 +40,13 @@ export type KakaoSessionEventHandler = (event: KakaoSessionEvent) => void
40
40
 
41
41
  export class KakaoTalkError extends Error {
42
42
  code: string
43
+ readonly serverStatus?: number
43
44
 
44
- constructor(message: string, code: string, options?: { cause?: unknown }) {
45
+ constructor(message: string, code: string, options?: { cause?: unknown; serverStatus?: number }) {
45
46
  super(message, options)
46
47
  this.name = 'KakaoTalkError'
47
48
  this.code = code
49
+ this.serverStatus = options?.serverStatus
48
50
  }
49
51
  }
50
52
 
@@ -280,6 +282,17 @@ function wrapError(error: unknown, code: string): KakaoTalkError {
280
282
  return new KakaoTalkError(message, code, { cause: error })
281
283
  }
282
284
 
285
+ function isLoginResponseError(
286
+ error: unknown,
287
+ ): error is Error & { code: 'invalid_access_token' | 'login_rejected'; serverStatus: number } {
288
+ if (!(error instanceof Error)) return false
289
+ const candidate = error as Error & { code?: unknown; serverStatus?: unknown }
290
+ return (
291
+ (candidate.code === 'invalid_access_token' || candidate.code === 'login_rejected') &&
292
+ typeof candidate.serverStatus === 'number'
293
+ )
294
+ }
295
+
283
296
  const MAX_PAGES = 50
284
297
 
285
298
  function syncStatePath(deviceUuid: string): string {
@@ -675,7 +688,11 @@ export class KakaoTalkClient {
675
688
  return { session, loginResult }
676
689
  } catch (error) {
677
690
  session.close()
678
- throw new KakaoTalkError(error instanceof Error ? error.message : String(error), 'login_failed', { cause: error })
691
+ const code = isLoginResponseError(error) ? error.code : 'login_failed'
692
+ throw new KakaoTalkError(error instanceof Error ? error.message : String(error), code, {
693
+ cause: error,
694
+ serverStatus: isLoginResponseError(error) ? error.serverStatus : undefined,
695
+ })
679
696
  }
680
697
  }
681
698
 
@@ -1,8 +1,10 @@
1
1
  import { expect, it } from 'bun:test'
2
2
 
3
+ import type { KakaoOAuthRefreshOptions } from '@/platforms/kakaotalk/index'
3
4
  import {
4
5
  classifyKakaoChat,
5
6
  CredentialManager,
7
+ KakaoOAuthRefreshError,
6
8
  KakaoAccountCredentialsSchema,
7
9
  KakaoCredentialManager,
8
10
  KakaoChatSchema,
@@ -18,6 +20,7 @@ import {
18
20
  KakaoProfileSchema,
19
21
  KakaoTalkPushReadEventSchema,
20
22
  KakaoTypingResultSchema,
23
+ refreshKakaoOAuthToken,
21
24
  } from '@/platforms/kakaotalk/index'
22
25
 
23
26
  it('KakaoTalkClient is exported from barrel', () => {
@@ -87,3 +90,11 @@ it('KakaoLeaveChatResultSchema is exported from barrel', () => {
87
90
  it('KakaoTypingResultSchema is exported from barrel', () => {
88
91
  expect(typeof KakaoTypingResultSchema.parse).toBe('function')
89
92
  })
93
+
94
+ it('Kakao OAuth refresh API is exported from barrel', () => {
95
+ expect(typeof refreshKakaoOAuthToken).toBe('function')
96
+ expect(typeof KakaoOAuthRefreshError).toBe('function')
97
+
98
+ const options: KakaoOAuthRefreshOptions = { timeoutMs: 5_000 }
99
+ expect(options.timeoutMs).toBe(5_000)
100
+ })
@@ -6,6 +6,7 @@ export { KakaoTalkListener } from './listener'
6
6
  export type { PendingLoginState } from './credential-manager'
7
7
  export type {
8
8
  KakaoAccountCredentials,
9
+ KakaoAuthErrorCode,
9
10
  KakaoAuthMethod,
10
11
  KakaoChat,
11
12
  KakaoConfig,
@@ -54,6 +55,13 @@ export {
54
55
  } from './types'
55
56
  export { attemptLogin, generateDeviceUuid, loginFlow, registerDevice, requestPasscode } from './auth/kakao-login'
56
57
  export type { LoginCredentials } from './auth/kakao-login'
58
+ export { KakaoOAuthRefreshError, refreshKakaoOAuthToken } from './auth/oauth-refresh'
59
+ export type {
60
+ KakaoOAuthRefreshErrorCode,
61
+ KakaoOAuthRefreshInput,
62
+ KakaoOAuthRefreshOptions,
63
+ KakaoOAuthRefreshResult,
64
+ } from './auth/oauth-refresh'
57
65
  export { sha1Hex } from './media-upload'
58
66
  export { detectImageDimensions } from './image-meta'
59
67
  export type { AttachmentInput, AttachmentPlan, ResolvedAttachment, SingleAttachmentKind } from './attachment-router'
@@ -1,8 +1,9 @@
1
1
  # Third-Party Notices
2
2
 
3
- This LOCO protocol implementation was written from scratch for agent-messenger.
4
- The protocol knowledge used to build it comes from the following open-source
5
- projects and their documentation. No code was copied from these projects.
3
+ This KakaoTalk protocol and Android authentication implementation was written
4
+ from scratch for agent-messenger. The protocol knowledge used to build it comes
5
+ from the following open-source projects and their documentation. No code was
6
+ copied from these projects.
6
7
 
7
8
  ---
8
9
 
@@ -37,7 +38,21 @@ Android sub-device login flow, and connection patterns.
37
38
  - Copyright (c) 2020 storycraft
38
39
 
39
40
  Referenced for: LOCO packet format, BSON command schemas, authentication flow,
40
- X-VC signature algorithm, channel/chat type enumerations.
41
+ Android OAuth refresh request/response behavior, X-VC signature algorithm,
42
+ channel/chat type enumerations.
43
+
44
+ ---
45
+
46
+ ## KakaoForge
47
+
48
+ - Repository: https://github.com/play2fly/KakaoForge
49
+ - Reference commit: 4b774ea40b1347280fadb685415436584093118b
50
+ - License: KakaoForge Non-Commercial / No Abuse License
51
+ - Copyright (c) 2026 aodjo
52
+
53
+ Referenced for: current Android `oauth2_token.json` request fields, client
54
+ headers, device-id handling, and token rotation/expiry response fields. Protocol
55
+ behavior only; no source code was copied or adapted.
41
56
 
42
57
  ---
43
58
 
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { validateLoginListResponse } from './login-response'
4
+ import type { LocoPacket } from './types'
5
+
6
+ function packet(statusCode: number, body: Record<string, unknown>): LocoPacket {
7
+ return { packetId: 1, statusCode, method: 'LOGINLIST', bodyType: 0, body }
8
+ }
9
+
10
+ function captureError(fn: () => unknown): unknown {
11
+ try {
12
+ fn()
13
+ return undefined
14
+ } catch (error) {
15
+ return error
16
+ }
17
+ }
18
+
19
+ describe('LOGINLIST response classification', () => {
20
+ it('accepts status=0 and returns the login snapshot', () => {
21
+ const body = { status: 0, chatDatas: [], eof: true }
22
+
23
+ expect(validateLoginListResponse(packet(0, body))).toBe(body)
24
+ })
25
+
26
+ it('accepts a successful response when body.status is omitted', () => {
27
+ const body = { chatDatas: [], eof: true }
28
+
29
+ expect(validateLoginListResponse(packet(0, body))).toBe(body)
30
+ })
31
+
32
+ it('classifies body.status=-950 as invalid_access_token', () => {
33
+ expect(() => validateLoginListResponse(packet(0, { status: -950 }))).toThrow(
34
+ expect.objectContaining({ code: 'invalid_access_token', serverStatus: -950 }),
35
+ )
36
+ })
37
+
38
+ it('classifies packet statusCode=-950 as invalid_access_token', () => {
39
+ expect(() => validateLoginListResponse(packet(-950, {}))).toThrow(
40
+ expect.objectContaining({ code: 'invalid_access_token', serverStatus: -950 }),
41
+ )
42
+ })
43
+
44
+ it('classifies an unknown non-zero body status as login_rejected', () => {
45
+ expect(() => validateLoginListResponse(packet(0, { status: -777 }))).toThrow(
46
+ expect.objectContaining({ code: 'login_rejected', serverStatus: -777 }),
47
+ )
48
+ })
49
+
50
+ it('classifies an unknown non-zero packet status as login_rejected', () => {
51
+ expect(() => validateLoginListResponse(packet(-777, {}))).toThrow(
52
+ expect.objectContaining({ code: 'login_rejected', serverStatus: -777 }),
53
+ )
54
+ })
55
+
56
+ it('keeps a synthetic socket close on the transport-error path', () => {
57
+ const error = captureError(() => validateLoginListResponse(packet(-1, { error: 'connection closed' })))
58
+
59
+ expect(error).toBeInstanceOf(Error)
60
+ expect(error).toMatchObject({ message: 'KakaoTalk LOGINLIST transport failed: connection closed' })
61
+ expect(error).not.toHaveProperty('code')
62
+ expect(error).not.toHaveProperty('serverStatus')
63
+ })
64
+ })
@@ -0,0 +1,40 @@
1
+ import type { KakaoAuthErrorCode } from '../types'
2
+ import type { LoginListResponse, LocoPacket } from './types'
3
+
4
+ const CONNECTION_CLOSED_STATUS = -1
5
+ const INVALID_ACCESS_TOKEN_STATUS = -950
6
+
7
+ export class KakaoLoginResponseError extends Error {
8
+ readonly code: KakaoAuthErrorCode
9
+ readonly serverStatus: number
10
+
11
+ constructor(code: KakaoAuthErrorCode, serverStatus: number) {
12
+ const reason = code === 'invalid_access_token' ? 'invalid access token' : 'rejected'
13
+ super(`KakaoTalk LOGINLIST ${reason} (status ${serverStatus})`)
14
+ this.name = 'KakaoLoginResponseError'
15
+ this.code = code
16
+ this.serverStatus = serverStatus
17
+ }
18
+ }
19
+
20
+ function isSyntheticConnectionClose(response: LocoPacket): boolean {
21
+ return response.statusCode === CONNECTION_CLOSED_STATUS && response.body.error === 'connection closed'
22
+ }
23
+
24
+ function throwAuthenticationError(serverStatus: number): never {
25
+ const code: KakaoAuthErrorCode =
26
+ serverStatus === INVALID_ACCESS_TOKEN_STATUS ? 'invalid_access_token' : 'login_rejected'
27
+ throw new KakaoLoginResponseError(code, serverStatus)
28
+ }
29
+
30
+ export function validateLoginListResponse(response: LocoPacket): LoginListResponse {
31
+ if (isSyntheticConnectionClose(response)) {
32
+ throw new Error('KakaoTalk LOGINLIST transport failed: connection closed')
33
+ }
34
+
35
+ const bodyStatus = typeof response.body.status === 'number' ? response.body.status : 0
36
+ if (bodyStatus !== 0) throwAuthenticationError(bodyStatus)
37
+ if (response.statusCode !== 0) throwAuthenticationError(response.statusCode)
38
+
39
+ return response.body as unknown as LoginListResponse
40
+ }