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.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/package.json +1 -1
- package/dist/src/platforms/kakaotalk/auth/kakao-login.d.ts +2 -0
- package/dist/src/platforms/kakaotalk/auth/kakao-login.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/auth/kakao-login.js +2 -2
- package/dist/src/platforms/kakaotalk/auth/kakao-login.js.map +1 -1
- package/dist/src/platforms/kakaotalk/auth/oauth-refresh.d.ts +28 -0
- package/dist/src/platforms/kakaotalk/auth/oauth-refresh.d.ts.map +1 -0
- package/dist/src/platforms/kakaotalk/auth/oauth-refresh.js +100 -0
- package/dist/src/platforms/kakaotalk/auth/oauth-refresh.js.map +1 -0
- package/dist/src/platforms/kakaotalk/client.d.ts +2 -0
- package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/client.js +14 -1
- package/dist/src/platforms/kakaotalk/client.js.map +1 -1
- package/dist/src/platforms/kakaotalk/index.d.ts +3 -1
- package/dist/src/platforms/kakaotalk/index.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/index.js +1 -0
- package/dist/src/platforms/kakaotalk/index.js.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/login-response.d.ts +9 -0
- package/dist/src/platforms/kakaotalk/protocol/login-response.d.ts.map +1 -0
- package/dist/src/platforms/kakaotalk/protocol/login-response.js +32 -0
- package/dist/src/platforms/kakaotalk/protocol/login-response.js.map +1 -0
- package/dist/src/platforms/kakaotalk/protocol/session.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/session.js +29 -20
- package/dist/src/platforms/kakaotalk/protocol/session.js.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/types.d.ts +1 -0
- package/dist/src/platforms/kakaotalk/protocol/types.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/types.js.map +1 -1
- package/dist/src/platforms/kakaotalk/types.d.ts +1 -0
- package/dist/src/platforms/kakaotalk/types.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/types.js.map +1 -1
- package/dist/src/platforms/teams/client.d.ts +4 -4
- package/dist/src/platforms/teams/client.d.ts.map +1 -1
- package/dist/src/platforms/teams/client.js +14 -14
- package/dist/src/platforms/teams/client.js.map +1 -1
- package/dist/src/platforms/teams/commands/chat.d.ts +2 -0
- package/dist/src/platforms/teams/commands/chat.d.ts.map +1 -1
- package/dist/src/platforms/teams/commands/chat.js +7 -2
- package/dist/src/platforms/teams/commands/chat.js.map +1 -1
- package/dist/src/platforms/teams/commands/format.d.ts +3 -0
- package/dist/src/platforms/teams/commands/format.d.ts.map +1 -0
- package/dist/src/platforms/teams/commands/format.js +10 -0
- package/dist/src/platforms/teams/commands/format.js.map +1 -0
- package/dist/src/platforms/teams/commands/message.d.ts +1 -0
- package/dist/src/platforms/teams/commands/message.d.ts.map +1 -1
- package/dist/src/platforms/teams/commands/message.js +4 -1
- package/dist/src/platforms/teams/commands/message.js.map +1 -1
- package/dist/src/platforms/teams/html-sanitizer.d.ts +17 -0
- package/dist/src/platforms/teams/html-sanitizer.d.ts.map +1 -0
- package/dist/src/platforms/teams/html-sanitizer.js +120 -0
- package/dist/src/platforms/teams/html-sanitizer.js.map +1 -0
- package/dist/src/platforms/teams/index.d.ts +1 -1
- package/dist/src/platforms/teams/index.d.ts.map +1 -1
- package/dist/src/platforms/teams/index.js.map +1 -1
- package/dist/src/platforms/teams/types.d.ts +1 -0
- package/dist/src/platforms/teams/types.d.ts.map +1 -1
- package/dist/src/platforms/teams/types.js.map +1 -1
- package/dist/src/platforms/webex/client.d.ts.map +1 -1
- package/dist/src/platforms/webex/client.js +1 -1
- package/dist/src/platforms/webex/client.js.map +1 -1
- package/dist/src/shared/utils/markdown-to-html.d.ts.map +1 -0
- package/dist/src/{platforms/webex → shared/utils}/markdown-to-html.js +0 -0
- package/dist/src/shared/utils/markdown-to-html.js.map +1 -0
- package/docs/content/docs/cli/kakaotalk.mdx +10 -0
- package/docs/content/docs/sdk/kakaotalk.mdx +54 -0
- 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-imessage/SKILL.md +1 -1
- package/skills/agent-instagram/SKILL.md +1 -1
- package/skills/agent-kakaotalk/SKILL.md +27 -1
- package/skills/agent-kakaotalk/references/authentication.md +59 -0
- package/skills/agent-kakaotalk/references/common-patterns.md +8 -0
- 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 +44 -2
- package/skills/agent-teams/references/common-patterns.md +2 -2
- 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-webexbot/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/auth/kakao-login.ts +2 -2
- package/src/platforms/kakaotalk/auth/oauth-refresh.test.ts +225 -0
- package/src/platforms/kakaotalk/auth/oauth-refresh.ts +144 -0
- package/src/platforms/kakaotalk/client.test.ts +55 -0
- package/src/platforms/kakaotalk/client.ts +19 -2
- package/src/platforms/kakaotalk/index.test.ts +11 -0
- package/src/platforms/kakaotalk/index.ts +8 -0
- package/src/platforms/kakaotalk/protocol/NOTICE.md +19 -4
- package/src/platforms/kakaotalk/protocol/login-response.test.ts +64 -0
- package/src/platforms/kakaotalk/protocol/login-response.ts +40 -0
- package/src/platforms/kakaotalk/protocol/session.ts +29 -21
- package/src/platforms/kakaotalk/protocol/types.ts +1 -0
- package/src/platforms/kakaotalk/types.ts +1 -0
- package/src/platforms/teams/client.test.ts +127 -0
- package/src/platforms/teams/client.ts +27 -14
- package/src/platforms/teams/commands/chat.test.ts +70 -1
- package/src/platforms/teams/commands/chat.ts +15 -4
- package/src/platforms/teams/commands/format.ts +12 -0
- package/src/platforms/teams/commands/message.test.ts +56 -0
- package/src/platforms/teams/commands/message.ts +6 -2
- package/src/platforms/teams/html-sanitizer.test.ts +123 -0
- package/src/platforms/teams/html-sanitizer.ts +134 -0
- package/src/platforms/teams/index.ts +1 -0
- package/src/platforms/teams/types.ts +2 -0
- package/src/platforms/webex/client.ts +2 -1
- package/src/{platforms/webex → shared/utils}/markdown-to-html.test.ts +7 -0
- package/src/{platforms/webex → shared/utils}/markdown-to-html.ts +0 -0
- package/dist/src/platforms/webex/markdown-to-html.d.ts.map +0 -1
- package/dist/src/platforms/webex/markdown-to-html.js.map +0 -1
- /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
|
-
|
|
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
|
|
4
|
-
The protocol knowledge used to build it comes
|
|
5
|
-
projects and their documentation. No code was
|
|
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,
|
|
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
|
+
}
|