agent-messenger 2.33.0 → 2.35.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 (97) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +12 -1
  3. package/dist/package.json +1 -1
  4. package/dist/src/platforms/discord/client.d.ts +16 -1
  5. package/dist/src/platforms/discord/client.d.ts.map +1 -1
  6. package/dist/src/platforms/discord/client.js +50 -3
  7. package/dist/src/platforms/discord/client.js.map +1 -1
  8. package/dist/src/platforms/discord/commands/dm.d.ts +4 -0
  9. package/dist/src/platforms/discord/commands/dm.d.ts.map +1 -1
  10. package/dist/src/platforms/discord/commands/dm.js +56 -4
  11. package/dist/src/platforms/discord/commands/dm.js.map +1 -1
  12. package/dist/src/platforms/discord/index.d.ts +1 -1
  13. package/dist/src/platforms/discord/index.d.ts.map +1 -1
  14. package/dist/src/platforms/discord/index.js.map +1 -1
  15. package/dist/src/platforms/discord/listener.d.ts +4 -2
  16. package/dist/src/platforms/discord/listener.d.ts.map +1 -1
  17. package/dist/src/platforms/discord/listener.js +9 -2
  18. package/dist/src/platforms/discord/listener.js.map +1 -1
  19. package/dist/src/platforms/discord/types.d.ts +19 -0
  20. package/dist/src/platforms/discord/types.d.ts.map +1 -1
  21. package/dist/src/platforms/discord/types.js +5 -1
  22. package/dist/src/platforms/discord/types.js.map +1 -1
  23. package/dist/src/platforms/kakaotalk/auth/kakao-login.d.ts +2 -0
  24. package/dist/src/platforms/kakaotalk/auth/kakao-login.d.ts.map +1 -1
  25. package/dist/src/platforms/kakaotalk/auth/kakao-login.js +2 -2
  26. package/dist/src/platforms/kakaotalk/auth/kakao-login.js.map +1 -1
  27. package/dist/src/platforms/kakaotalk/auth/oauth-refresh.d.ts +28 -0
  28. package/dist/src/platforms/kakaotalk/auth/oauth-refresh.d.ts.map +1 -0
  29. package/dist/src/platforms/kakaotalk/auth/oauth-refresh.js +100 -0
  30. package/dist/src/platforms/kakaotalk/auth/oauth-refresh.js.map +1 -0
  31. package/dist/src/platforms/kakaotalk/client.d.ts +2 -0
  32. package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
  33. package/dist/src/platforms/kakaotalk/client.js +14 -1
  34. package/dist/src/platforms/kakaotalk/client.js.map +1 -1
  35. package/dist/src/platforms/kakaotalk/index.d.ts +3 -1
  36. package/dist/src/platforms/kakaotalk/index.d.ts.map +1 -1
  37. package/dist/src/platforms/kakaotalk/index.js +1 -0
  38. package/dist/src/platforms/kakaotalk/index.js.map +1 -1
  39. package/dist/src/platforms/kakaotalk/protocol/login-response.d.ts +9 -0
  40. package/dist/src/platforms/kakaotalk/protocol/login-response.d.ts.map +1 -0
  41. package/dist/src/platforms/kakaotalk/protocol/login-response.js +32 -0
  42. package/dist/src/platforms/kakaotalk/protocol/login-response.js.map +1 -0
  43. package/dist/src/platforms/kakaotalk/protocol/session.d.ts.map +1 -1
  44. package/dist/src/platforms/kakaotalk/protocol/session.js +29 -20
  45. package/dist/src/platforms/kakaotalk/protocol/session.js.map +1 -1
  46. package/dist/src/platforms/kakaotalk/protocol/types.d.ts +1 -0
  47. package/dist/src/platforms/kakaotalk/protocol/types.d.ts.map +1 -1
  48. package/dist/src/platforms/kakaotalk/protocol/types.js.map +1 -1
  49. package/dist/src/platforms/kakaotalk/types.d.ts +1 -0
  50. package/dist/src/platforms/kakaotalk/types.d.ts.map +1 -1
  51. package/dist/src/platforms/kakaotalk/types.js.map +1 -1
  52. package/docs/content/docs/cli/discord.mdx +17 -0
  53. package/docs/content/docs/cli/kakaotalk.mdx +10 -0
  54. package/docs/content/docs/sdk/discord.mdx +13 -0
  55. package/docs/content/docs/sdk/kakaotalk.mdx +54 -0
  56. package/package.json +1 -1
  57. package/skills/agent-channeltalk/SKILL.md +1 -1
  58. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  59. package/skills/agent-discord/SKILL.md +23 -1
  60. package/skills/agent-discordbot/SKILL.md +1 -1
  61. package/skills/agent-imessage/SKILL.md +1 -1
  62. package/skills/agent-instagram/SKILL.md +1 -1
  63. package/skills/agent-kakaotalk/SKILL.md +27 -1
  64. package/skills/agent-kakaotalk/references/authentication.md +59 -0
  65. package/skills/agent-kakaotalk/references/common-patterns.md +8 -0
  66. package/skills/agent-line/SKILL.md +1 -1
  67. package/skills/agent-slack/SKILL.md +1 -1
  68. package/skills/agent-slackbot/SKILL.md +1 -1
  69. package/skills/agent-teams/SKILL.md +1 -1
  70. package/skills/agent-telegram/SKILL.md +1 -1
  71. package/skills/agent-telegrambot/SKILL.md +1 -1
  72. package/skills/agent-webex/SKILL.md +1 -1
  73. package/skills/agent-webexbot/SKILL.md +1 -1
  74. package/skills/agent-wechatbot/SKILL.md +1 -1
  75. package/skills/agent-whatsapp/SKILL.md +1 -1
  76. package/skills/agent-whatsappbot/SKILL.md +1 -1
  77. package/src/platforms/discord/client.test.ts +151 -1
  78. package/src/platforms/discord/client.ts +76 -3
  79. package/src/platforms/discord/commands/dm.test.ts +126 -1
  80. package/src/platforms/discord/commands/dm.ts +63 -3
  81. package/src/platforms/discord/index.ts +3 -0
  82. package/src/platforms/discord/listener.test.ts +12 -3
  83. package/src/platforms/discord/listener.ts +17 -4
  84. package/src/platforms/discord/types.ts +37 -1
  85. package/src/platforms/kakaotalk/auth/kakao-login.ts +2 -2
  86. package/src/platforms/kakaotalk/auth/oauth-refresh.test.ts +225 -0
  87. package/src/platforms/kakaotalk/auth/oauth-refresh.ts +144 -0
  88. package/src/platforms/kakaotalk/client.test.ts +55 -0
  89. package/src/platforms/kakaotalk/client.ts +19 -2
  90. package/src/platforms/kakaotalk/index.test.ts +11 -0
  91. package/src/platforms/kakaotalk/index.ts +8 -0
  92. package/src/platforms/kakaotalk/protocol/NOTICE.md +19 -4
  93. package/src/platforms/kakaotalk/protocol/login-response.test.ts +64 -0
  94. package/src/platforms/kakaotalk/protocol/login-response.ts +40 -0
  95. package/src/platforms/kakaotalk/protocol/session.ts +29 -21
  96. package/src/platforms/kakaotalk/protocol/types.ts +1 -0
  97. package/src/platforms/kakaotalk/types.ts +1 -0
@@ -1,4 +1,4 @@
1
- import { Command } from 'commander'
1
+ import { Command, InvalidArgumentError } from 'commander'
2
2
 
3
3
  import { handleError } from '@/shared/utils/error-handler'
4
4
  import { formatOutput } from '@/shared/utils/output'
@@ -7,6 +7,18 @@ import { DiscordClient } from '../client'
7
7
  import { DiscordCredentialManager } from '../credential-manager'
8
8
  import type { DiscordDMChannel } from '../types'
9
9
 
10
+ function parseLimit(value: string): number {
11
+ const parsed = Number(value)
12
+ if (!Number.isInteger(parsed) || parsed < 1) {
13
+ throw new InvalidArgumentError('Must be a positive integer.')
14
+ }
15
+ return parsed
16
+ }
17
+
18
+ function channelTypeLabel(type: number): string {
19
+ return type === 1 ? 'DM' : 'Group DM'
20
+ }
21
+
10
22
  export async function listAction(options: { pretty?: boolean }): Promise<void> {
11
23
  try {
12
24
  const credManager = new DiscordCredentialManager()
@@ -22,7 +34,7 @@ export async function listAction(options: { pretty?: boolean }): Promise<void> {
22
34
 
23
35
  const output = channels.map((channel: DiscordDMChannel) => ({
24
36
  id: channel.id,
25
- type: channel.type === 1 ? 'DM' : 'Group DM',
37
+ type: channelTypeLabel(channel.type),
26
38
  name: channel.name || null,
27
39
  recipients: channel.recipients.map((user) => ({
28
40
  id: user.id,
@@ -52,7 +64,7 @@ export async function createAction(userId: string, options: { pretty?: boolean }
52
64
 
53
65
  const output = {
54
66
  id: channel.id,
55
- type: channel.type === 1 ? 'DM' : 'Group DM',
67
+ type: channelTypeLabel(channel.type),
56
68
  name: channel.name || null,
57
69
  recipients: channel.recipients.map((user) => ({
58
70
  id: user.id,
@@ -66,6 +78,42 @@ export async function createAction(userId: string, options: { pretty?: boolean }
66
78
  }
67
79
  }
68
80
 
81
+ export async function unreadAction(options: { limit?: number; pretty?: boolean }): Promise<void> {
82
+ try {
83
+ const credManager = new DiscordCredentialManager()
84
+ const config = await credManager.load()
85
+
86
+ if (!config.token) {
87
+ console.log(formatOutput({ error: 'Not authenticated. Run "auth extract" first.' }, options.pretty))
88
+ process.exit(1)
89
+ }
90
+
91
+ const client = await new DiscordClient().login({ token: config.token })
92
+ const result = await client.getUnreadDMs({ limit: options.limit })
93
+
94
+ const output = {
95
+ channels: result.channels.map((channel) => ({
96
+ id: channel.id,
97
+ type: channelTypeLabel(channel.type),
98
+ name: channel.name,
99
+ recipients: channel.recipients.map((user) => ({
100
+ id: user.id,
101
+ username: user.username,
102
+ })),
103
+ last_message_id: channel.lastMessageId,
104
+ unread_count: channel.unreadCount,
105
+ })),
106
+ count: result.count,
107
+ total_unread: result.totalUnread,
108
+ complete: result.complete,
109
+ }
110
+
111
+ console.log(formatOutput(output, options.pretty))
112
+ } catch (error) {
113
+ handleError(error as Error)
114
+ }
115
+ }
116
+
69
117
  export const dmCommand = new Command('dm')
70
118
  .description('DM channel commands')
71
119
  .addCommand(
@@ -81,3 +129,15 @@ export const dmCommand = new Command('dm')
81
129
  .option('--pretty', 'Pretty print JSON output')
82
130
  .action(createAction),
83
131
  )
132
+ .addCommand(
133
+ new Command('unread')
134
+ .description('List unread DM and group-DM channels, newest first')
135
+ .option('--limit <n>', 'Max number of channels to return', parseLimit)
136
+ .option('--pretty', 'Pretty print JSON output')
137
+ .action((options) => {
138
+ unreadAction({
139
+ limit: options.limit,
140
+ pretty: options.pretty,
141
+ })
142
+ }),
143
+ )
@@ -26,6 +26,7 @@ export type {
26
26
  DiscordMessage,
27
27
  DiscordReaction,
28
28
  DiscordReadState,
29
+ DiscordReadStateSnapshot,
29
30
  DiscordRelationship,
30
31
  DiscordRole,
31
32
  DiscordRoleColors,
@@ -33,6 +34,8 @@ export type {
33
34
  DiscordSearchOptions,
34
35
  DiscordSearchResponse,
35
36
  DiscordSearchResult,
37
+ DiscordUnreadDM,
38
+ DiscordUnreadDMsResult,
36
39
  DiscordUnreadMention,
37
40
  DiscordUnreadMentionsResult,
38
41
  DiscordUser,
@@ -143,19 +143,28 @@ describe('parseReadStates', () => {
143
143
  { id: 'c2', read_state_type: 2, mention_count: 5 },
144
144
  ])
145
145
 
146
- expect(result).toEqual([{ channelId: 'c1', lastMessageId: '100', mentionCount: 1 }])
146
+ expect(result.states).toEqual([{ channelId: 'c1', lastMessageId: '100', mentionCount: 1 }])
147
147
  })
148
148
 
149
149
  it('normalizes missing last_message_id and mention_count', () => {
150
150
  const result = parseReadStates([{ id: 'c1' }])
151
151
 
152
- expect(result).toEqual([{ channelId: 'c1', lastMessageId: null, mentionCount: 0 }])
152
+ expect(result.states).toEqual([{ channelId: 'c1', lastMessageId: null, mentionCount: 0 }])
153
153
  })
154
154
 
155
155
  it('skips malformed entries but keeps valid ones', () => {
156
156
  const result = parseReadStates([{ id: 'c1', mention_count: 1 }, { mention_count: 3 }])
157
157
 
158
- expect(result).toEqual([{ channelId: 'c1', lastMessageId: null, mentionCount: 1 }])
158
+ expect(result.states).toEqual([{ channelId: 'c1', lastMessageId: null, mentionCount: 1 }])
159
+ })
160
+
161
+ it('treats a bare array payload as complete', () => {
162
+ expect(parseReadStates([{ id: 'c1' }]).partial).toBe(false)
163
+ })
164
+
165
+ it('reads the partial flag from a versioned payload', () => {
166
+ expect(parseReadStates({ entries: [{ id: 'c1' }], version: 7, partial: true }).partial).toBe(true)
167
+ expect(parseReadStates({ entries: [{ id: 'c1' }], version: 7 }).partial).toBe(false)
159
168
  })
160
169
 
161
170
  it('throws on a non-array, non-versioned payload', () => {
@@ -3,16 +3,22 @@ import { EventEmitter } from 'events'
3
3
  import WebSocket from 'ws'
4
4
 
5
5
  import type { DiscordClient } from './client'
6
- import type { DiscordListenerEventMap, DiscordGatewayGenericEvent, DiscordReadState } from './types'
6
+ import type {
7
+ DiscordListenerEventMap,
8
+ DiscordGatewayGenericEvent,
9
+ DiscordReadState,
10
+ DiscordReadStateSnapshot,
11
+ } from './types'
7
12
  import { DiscordGatewayOpcode, DiscordRawReadStateSchema, DiscordReadyReadStateSchema } from './types'
8
13
 
9
- export function parseReadStates(value: unknown): DiscordReadState[] {
14
+ export function parseReadStates(value: unknown): DiscordReadStateSnapshot {
10
15
  const parsed = DiscordReadyReadStateSchema.safeParse(value)
11
16
  if (!parsed.success) {
12
17
  throw new Error('Discord READY contained an invalid read_state payload')
13
18
  }
14
19
 
15
20
  const entries = Array.isArray(parsed.data) ? parsed.data : parsed.data.entries
21
+ const partial = Array.isArray(parsed.data) ? false : (parsed.data.partial ?? false)
16
22
 
17
23
  const readStates: DiscordReadState[] = []
18
24
  for (const entry of entries) {
@@ -25,7 +31,7 @@ export function parseReadStates(value: unknown): DiscordReadState[] {
25
31
  mentionCount: state.data.mention_count ?? 0,
26
32
  })
27
33
  }
28
- return readStates
34
+ return { states: readStates, partial }
29
35
  }
30
36
 
31
37
  const GATEWAY_URL = 'wss://gateway.discord.gg/?v=10&encoding=json'
@@ -80,6 +86,7 @@ export class DiscordListener {
80
86
  private token: string | null = null
81
87
  private cachedUser: { id: string; username: string } | null = null
82
88
  private readState: DiscordReadState[] | null = null
89
+ private readStatePartial = false
83
90
  private generation = 0
84
91
  private startPromise: Promise<void> | null = null
85
92
  private pendingStartReject: ((error: Error) => void) | null = null
@@ -202,6 +209,10 @@ export class DiscordListener {
202
209
  return this.readState
203
210
  }
204
211
 
212
+ getReadStateSnapshot(): DiscordReadStateSnapshot | null {
213
+ return this.readState === null ? null : { states: this.readState, partial: this.readStatePartial }
214
+ }
215
+
205
216
  private isCurrent(generation: number, ws?: WebSocket): boolean {
206
217
  if (generation !== this.generation || !this.running) return false
207
218
  if (ws !== undefined && this.ws !== ws) return false
@@ -326,7 +337,9 @@ export class DiscordListener {
326
337
  this.sessionId = d.session_id
327
338
  this.resumeGatewayUrl = d.resume_gateway_url
328
339
  this.cachedUser = d.user
329
- this.readState = d.read_state === undefined ? [] : parseReadStates(d.read_state)
340
+ const snapshot = d.read_state === undefined ? null : parseReadStates(d.read_state)
341
+ this.readState = snapshot === null ? [] : snapshot.states
342
+ this.readStatePartial = snapshot?.partial ?? false
330
343
  this.reconnectAttempts = 0
331
344
  this.emitter.emit('connected', { user: d.user, sessionId: d.session_id })
332
345
  return
@@ -139,6 +139,13 @@ export interface DiscordReadState {
139
139
  mentionCount: number
140
140
  }
141
141
 
142
+ // When partial is true the entries are an incomplete slice, so an absent channel
143
+ // carries no information: it may simply have been left out of this payload.
144
+ export interface DiscordReadStateSnapshot {
145
+ states: DiscordReadState[]
146
+ partial: boolean
147
+ }
148
+
142
149
  export interface DiscordUnreadMention extends DiscordMention {
143
150
  mention_count: number
144
151
  }
@@ -153,6 +160,31 @@ export interface DiscordUnreadMentionsResult {
153
160
  windowDays: number
154
161
  }
155
162
 
163
+ export interface DiscordUnreadDM {
164
+ id: string
165
+ type: number
166
+ name: string | null
167
+ recipients: DiscordUser[]
168
+ lastMessageId: string
169
+ // Discord's own badge counter, which counts every message in a DM rather than
170
+ // only mentions. null when the channel has no READY read-state entry (count
171
+ // unknown); 0 when the DM is muted, since muting stops the counter but still
172
+ // leaves the channel unread.
173
+ unreadCount: number | null
174
+ }
175
+
176
+ // count and totalUnread describe every unread DM found, so they stay truthful when
177
+ // `channels` is capped by a limit. totalUnread sums known counts only, meaning
178
+ // channels with an unknown count contribute nothing to it. complete is false when
179
+ // Discord delivered a partial read state, in which case never-opened DMs may be
180
+ // missing from the result.
181
+ export interface DiscordUnreadDMsResult {
182
+ channels: DiscordUnreadDM[]
183
+ count: number
184
+ totalUnread: number
185
+ complete: boolean
186
+ }
187
+
156
188
  export interface DiscordSearchResult {
157
189
  id: string
158
190
  channel_id: string
@@ -512,7 +544,11 @@ export type DiscordRawReadState = z.infer<typeof DiscordRawReadStateSchema>
512
544
  // VERSIONED_READ_STATES gateway capability is negotiated.
513
545
  export const DiscordReadyReadStateSchema = z.union([
514
546
  z.array(z.unknown()),
515
- z.object({ entries: z.array(z.unknown()), version: z.number().optional() }),
547
+ z.object({
548
+ entries: z.array(z.unknown()),
549
+ version: z.number().optional(),
550
+ partial: z.boolean().optional(),
551
+ }),
516
552
  ])
517
553
 
518
554
  export type DiscordGatewayEvent =
@@ -7,8 +7,8 @@ import type { KakaoDeviceType, KakaoLoginResult } from '../types'
7
7
  const ANDROID_APP_VERSION = '25.9.2'
8
8
  const ANDROID_OS_VERSION = '13'
9
9
  const ANDROID_API_LEVEL = '33'
10
- const ANDROID_AGENT = `android/${ANDROID_APP_VERSION}/ko`
11
- const ANDROID_USER_AGENT = `KT/${ANDROID_APP_VERSION} An/${ANDROID_OS_VERSION} ko`
10
+ export const ANDROID_AGENT = `android/${ANDROID_APP_VERSION}/ko`
11
+ export const ANDROID_USER_AGENT = `KT/${ANDROID_APP_VERSION} An/${ANDROID_OS_VERSION} ko`
12
12
  const ANDROID_LOGIN_URL = 'https://katalk.kakao.com/android/account/login.json'
13
13
  const ANDROID_PASSCODE_URL = 'https://katalk.kakao.com/android/account/passcodeLogin/generate'
14
14
  const ANDROID_REGISTER_URL = 'https://katalk.kakao.com/android/account/passcodeLogin/registerDevice'
@@ -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
+ }