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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +12 -1
- package/dist/package.json +1 -1
- package/dist/src/platforms/discord/client.d.ts +16 -1
- package/dist/src/platforms/discord/client.d.ts.map +1 -1
- package/dist/src/platforms/discord/client.js +50 -3
- package/dist/src/platforms/discord/client.js.map +1 -1
- package/dist/src/platforms/discord/commands/dm.d.ts +4 -0
- package/dist/src/platforms/discord/commands/dm.d.ts.map +1 -1
- package/dist/src/platforms/discord/commands/dm.js +56 -4
- package/dist/src/platforms/discord/commands/dm.js.map +1 -1
- package/dist/src/platforms/discord/index.d.ts +1 -1
- package/dist/src/platforms/discord/index.d.ts.map +1 -1
- package/dist/src/platforms/discord/index.js.map +1 -1
- package/dist/src/platforms/discord/listener.d.ts +4 -2
- package/dist/src/platforms/discord/listener.d.ts.map +1 -1
- package/dist/src/platforms/discord/listener.js +9 -2
- package/dist/src/platforms/discord/listener.js.map +1 -1
- package/dist/src/platforms/discord/types.d.ts +19 -0
- package/dist/src/platforms/discord/types.d.ts.map +1 -1
- package/dist/src/platforms/discord/types.js +5 -1
- package/dist/src/platforms/discord/types.js.map +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/docs/content/docs/cli/discord.mdx +17 -0
- package/docs/content/docs/cli/kakaotalk.mdx +10 -0
- package/docs/content/docs/sdk/discord.mdx +13 -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 +23 -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 +1 -1
- package/skills/agent-telegram/SKILL.md +1 -1
- package/skills/agent-telegrambot/SKILL.md +1 -1
- package/skills/agent-webex/SKILL.md +1 -1
- package/skills/agent-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/discord/client.test.ts +151 -1
- package/src/platforms/discord/client.ts +76 -3
- package/src/platforms/discord/commands/dm.test.ts +126 -1
- package/src/platforms/discord/commands/dm.ts +63 -3
- package/src/platforms/discord/index.ts +3 -0
- package/src/platforms/discord/listener.test.ts +12 -3
- package/src/platforms/discord/listener.ts +17 -4
- package/src/platforms/discord/types.ts +37 -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
|
@@ -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
|
|
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
|
|
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 {
|
|
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):
|
|
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
|
-
|
|
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({
|
|
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
|
+
}
|