agent-messenger 2.28.0 → 2.29.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 +18 -1
- package/dist/package.json +1 -1
- package/dist/src/platforms/discord/client.d.ts +6 -2
- package/dist/src/platforms/discord/client.d.ts.map +1 -1
- package/dist/src/platforms/discord/client.js +10 -3
- package/dist/src/platforms/discord/client.js.map +1 -1
- package/dist/src/platforms/instagram/client.d.ts +24 -2
- package/dist/src/platforms/instagram/client.d.ts.map +1 -1
- package/dist/src/platforms/instagram/client.js +343 -53
- package/dist/src/platforms/instagram/client.js.map +1 -1
- package/dist/src/platforms/instagram/commands/auth.d.ts +1 -0
- package/dist/src/platforms/instagram/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/instagram/commands/auth.js +123 -11
- package/dist/src/platforms/instagram/commands/auth.js.map +1 -1
- package/dist/src/platforms/instagram/credential-manager.d.ts +4 -1
- package/dist/src/platforms/instagram/credential-manager.d.ts.map +1 -1
- package/dist/src/platforms/instagram/credential-manager.js +17 -1
- package/dist/src/platforms/instagram/credential-manager.js.map +1 -1
- package/dist/src/platforms/instagram/types.d.ts +10 -8
- package/dist/src/platforms/instagram/types.d.ts.map +1 -1
- package/dist/src/platforms/instagram/types.js.map +1 -1
- package/dist/src/platforms/teams/app-config.d.ts +36 -0
- package/dist/src/platforms/teams/app-config.d.ts.map +1 -0
- package/dist/src/platforms/teams/app-config.js +69 -0
- package/dist/src/platforms/teams/app-config.js.map +1 -0
- package/dist/src/platforms/teams/client.d.ts +19 -2
- package/dist/src/platforms/teams/client.d.ts.map +1 -1
- package/dist/src/platforms/teams/client.js +295 -7
- package/dist/src/platforms/teams/client.js.map +1 -1
- package/dist/src/platforms/teams/commands/auth.d.ts +10 -0
- package/dist/src/platforms/teams/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/teams/commands/auth.js +137 -3
- package/dist/src/platforms/teams/commands/auth.js.map +1 -1
- package/dist/src/platforms/teams/commands/file.d.ts +3 -0
- package/dist/src/platforms/teams/commands/file.d.ts.map +1 -1
- package/dist/src/platforms/teams/commands/file.js +59 -2
- package/dist/src/platforms/teams/commands/file.js.map +1 -1
- package/dist/src/platforms/teams/commands/message.d.ts +11 -0
- package/dist/src/platforms/teams/commands/message.d.ts.map +1 -1
- package/dist/src/platforms/teams/commands/message.js +121 -2
- package/dist/src/platforms/teams/commands/message.js.map +1 -1
- package/dist/src/platforms/teams/credential-manager.d.ts +23 -1
- package/dist/src/platforms/teams/credential-manager.d.ts.map +1 -1
- package/dist/src/platforms/teams/credential-manager.js +46 -2
- package/dist/src/platforms/teams/credential-manager.js.map +1 -1
- package/dist/src/platforms/teams/device-code.d.ts +48 -0
- package/dist/src/platforms/teams/device-code.d.ts.map +1 -0
- package/dist/src/platforms/teams/device-code.js +199 -0
- package/dist/src/platforms/teams/device-code.js.map +1 -0
- package/dist/src/platforms/teams/device-login.d.ts +37 -0
- package/dist/src/platforms/teams/device-login.d.ts.map +1 -0
- package/dist/src/platforms/teams/device-login.js +129 -0
- package/dist/src/platforms/teams/device-login.js.map +1 -0
- package/dist/src/platforms/teams/ensure-auth.d.ts.map +1 -1
- package/dist/src/platforms/teams/ensure-auth.js +12 -0
- package/dist/src/platforms/teams/ensure-auth.js.map +1 -1
- package/dist/src/platforms/teams/index.d.ts +7 -3
- package/dist/src/platforms/teams/index.d.ts.map +1 -1
- package/dist/src/platforms/teams/index.js +5 -2
- package/dist/src/platforms/teams/index.js.map +1 -1
- package/dist/src/platforms/teams/listener.d.ts +35 -0
- package/dist/src/platforms/teams/listener.d.ts.map +1 -0
- package/dist/src/platforms/teams/listener.js +236 -0
- package/dist/src/platforms/teams/listener.js.map +1 -0
- package/dist/src/platforms/teams/realm-discovery.d.ts +3 -0
- package/dist/src/platforms/teams/realm-discovery.d.ts.map +1 -0
- package/dist/src/platforms/teams/realm-discovery.js +33 -0
- package/dist/src/platforms/teams/realm-discovery.js.map +1 -0
- package/dist/src/platforms/teams/token-extractor.d.ts +3 -0
- package/dist/src/platforms/teams/token-extractor.d.ts.map +1 -1
- package/dist/src/platforms/teams/token-extractor.js +60 -0
- package/dist/src/platforms/teams/token-extractor.js.map +1 -1
- package/dist/src/platforms/teams/token-provider.d.ts +25 -0
- package/dist/src/platforms/teams/token-provider.d.ts.map +1 -0
- package/dist/src/platforms/teams/token-provider.js +190 -0
- package/dist/src/platforms/teams/token-provider.js.map +1 -0
- package/dist/src/platforms/teams/trouter.d.ts +31 -0
- package/dist/src/platforms/teams/trouter.d.ts.map +1 -0
- package/dist/src/platforms/teams/trouter.js +181 -0
- package/dist/src/platforms/teams/trouter.js.map +1 -0
- package/dist/src/platforms/teams/types.d.ts +94 -0
- package/dist/src/platforms/teams/types.d.ts.map +1 -1
- package/dist/src/platforms/teams/types.js +30 -0
- package/dist/src/platforms/teams/types.js.map +1 -1
- package/dist/src/platforms/webex/types.d.ts +1 -1
- package/dist/src/tui/adapters/instagram-adapter.d.ts.map +1 -1
- package/dist/src/tui/adapters/instagram-adapter.js +16 -0
- package/dist/src/tui/adapters/instagram-adapter.js.map +1 -1
- package/dist/src/tui/adapters/teams-adapter.d.ts +3 -0
- package/dist/src/tui/adapters/teams-adapter.d.ts.map +1 -1
- package/dist/src/tui/adapters/teams-adapter.js +21 -0
- package/dist/src/tui/adapters/teams-adapter.js.map +1 -1
- package/dist/src/tui/app.js +3 -3
- package/docs/content/docs/cli/instagram.mdx +1 -0
- package/docs/content/docs/cli/teams.mdx +22 -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-instagram/references/authentication.md +11 -0
- package/skills/agent-kakaotalk/SKILL.md +1 -1
- 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 +59 -6
- package/skills/agent-teams/references/authentication.md +31 -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 +86 -0
- package/src/platforms/discord/client.ts +10 -3
- package/src/platforms/instagram/client.test.ts +478 -33
- package/src/platforms/instagram/client.ts +449 -68
- package/src/platforms/instagram/commands/auth.test.ts +177 -1
- package/src/platforms/instagram/commands/auth.ts +182 -11
- package/src/platforms/instagram/credential-manager.test.ts +30 -0
- package/src/platforms/instagram/credential-manager.ts +23 -1
- package/src/platforms/instagram/types.ts +11 -8
- package/src/platforms/teams/app-config.ts +92 -0
- package/src/platforms/teams/client.test.ts +335 -1
- package/src/platforms/teams/client.ts +342 -7
- package/src/platforms/teams/commands/auth.test.ts +93 -1
- package/src/platforms/teams/commands/auth.ts +182 -3
- package/src/platforms/teams/commands/file.test.ts +92 -1
- package/src/platforms/teams/commands/file.ts +76 -1
- package/src/platforms/teams/commands/message.test.ts +95 -1
- package/src/platforms/teams/commands/message.ts +145 -3
- package/src/platforms/teams/credential-manager.test.ts +20 -0
- package/src/platforms/teams/credential-manager.ts +68 -3
- package/src/platforms/teams/device-code.test.ts +311 -0
- package/src/platforms/teams/device-code.ts +277 -0
- package/src/platforms/teams/device-login.test.ts +288 -0
- package/src/platforms/teams/device-login.ts +206 -0
- package/src/platforms/teams/ensure-auth.ts +11 -0
- package/src/platforms/teams/index.test.ts +10 -0
- package/src/platforms/teams/index.ts +16 -1
- package/src/platforms/teams/listener.ts +295 -0
- package/src/platforms/teams/realm-discovery.test.ts +73 -0
- package/src/platforms/teams/realm-discovery.ts +42 -0
- package/src/platforms/teams/token-extractor.ts +69 -0
- package/src/platforms/teams/token-provider.test.ts +227 -0
- package/src/platforms/teams/token-provider.ts +284 -0
- package/src/platforms/teams/trouter.test.ts +114 -0
- package/src/platforms/teams/trouter.ts +228 -0
- package/src/platforms/teams/types.test.ts +17 -0
- package/src/platforms/teams/types.ts +86 -0
- package/src/tui/adapters/instagram-adapter.ts +13 -0
- package/src/tui/adapters/teams-adapter.ts +23 -0
- package/src/tui/app.ts +3 -3
|
@@ -3,7 +3,8 @@ import { generateKeyPairSync } from 'node:crypto'
|
|
|
3
3
|
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
|
|
6
|
-
import { InstagramClient } from '@/platforms/instagram/client'
|
|
6
|
+
import { InstagramClient, parseOneClickLoginLink } from '@/platforms/instagram/client'
|
|
7
|
+
import { InstagramCredentialManager } from '@/platforms/instagram/credential-manager'
|
|
7
8
|
import { InstagramError, type InstagramSessionState } from '@/platforms/instagram/types'
|
|
8
9
|
|
|
9
10
|
const testDir = join(import.meta.dir, `.test-client-${Date.now()}`)
|
|
@@ -23,10 +24,13 @@ const SESSION: InstagramSessionState = {
|
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
let rsaPublicKeyBase64: string
|
|
27
|
+
const originalConfigDir = process.env['AGENT_MESSENGER_CONFIG_DIR']
|
|
26
28
|
|
|
27
29
|
beforeAll(() => {
|
|
28
30
|
mkdirSync(testDir, { recursive: true })
|
|
29
31
|
writeFileSync(sessionPath, JSON.stringify(SESSION))
|
|
32
|
+
// Sandbox default-manager writes (e.g. device.json) so tests never touch the real config dir.
|
|
33
|
+
process.env['AGENT_MESSENGER_CONFIG_DIR'] = join(testDir, 'config')
|
|
30
34
|
|
|
31
35
|
const { publicKey } = generateKeyPairSync('rsa', { modulusLength: 1024 })
|
|
32
36
|
const pem = publicKey.export({ type: 'pkcs1', format: 'pem' }) as string
|
|
@@ -34,6 +38,8 @@ beforeAll(() => {
|
|
|
34
38
|
})
|
|
35
39
|
|
|
36
40
|
afterAll(() => {
|
|
41
|
+
if (originalConfigDir === undefined) delete process.env['AGENT_MESSENGER_CONFIG_DIR']
|
|
42
|
+
else process.env['AGENT_MESSENGER_CONFIG_DIR'] = originalConfigDir
|
|
37
43
|
rmSync(testDir, { recursive: true, force: true })
|
|
38
44
|
})
|
|
39
45
|
|
|
@@ -74,6 +80,15 @@ function urlParamsBody(callIndex = 0): Record<string, string> {
|
|
|
74
80
|
return Object.fromEntries(new URLSearchParams(raw))
|
|
75
81
|
}
|
|
76
82
|
|
|
83
|
+
function signedBody(callIndex = 0): Record<string, string> {
|
|
84
|
+
const raw = fetchCalls[callIndex]?.init?.body
|
|
85
|
+
if (!raw || typeof raw !== 'string') return {}
|
|
86
|
+
const signed = new URLSearchParams(raw).get('signed_body')
|
|
87
|
+
if (!signed) return {}
|
|
88
|
+
const json = signed.slice(signed.indexOf('.') + 1)
|
|
89
|
+
return JSON.parse(json) as Record<string, string>
|
|
90
|
+
}
|
|
91
|
+
|
|
77
92
|
async function loadedClient(): Promise<InstagramClient> {
|
|
78
93
|
const client = new InstagramClient()
|
|
79
94
|
await client.loadSession(sessionPath)
|
|
@@ -115,43 +130,39 @@ describe('InstagramClient', () => {
|
|
|
115
130
|
})
|
|
116
131
|
})
|
|
117
132
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
133
|
+
function encryptionKeyResponse(): Response {
|
|
134
|
+
return new Response(null, {
|
|
135
|
+
status: 200,
|
|
136
|
+
headers: new Headers({
|
|
137
|
+
'ig-set-password-encryption-pub-key': rsaPublicKeyBase64,
|
|
138
|
+
'ig-set-password-encryption-key-id': '7',
|
|
139
|
+
}),
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
describe('authenticate password encryption', () => {
|
|
144
|
+
it('fails closed when Instagram returns no encryption key', async () => {
|
|
145
|
+
fetchResponses.push(new Response(null, { status: 200, headers: {} }))
|
|
127
146
|
|
|
128
147
|
const client = new InstagramClient()
|
|
129
|
-
const
|
|
130
|
-
await client.authenticate('user', 'mypassword')
|
|
131
|
-
const after = Math.floor(Date.now() / 1000)
|
|
148
|
+
const err = await client.authenticate('user', 'mypassword').catch((e: unknown) => e)
|
|
132
149
|
|
|
133
|
-
|
|
134
|
-
|
|
150
|
+
expect(err).toBeInstanceOf(InstagramError)
|
|
151
|
+
expect((err as InstagramError).code).toBe('encryption_key_missing')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('never sends a plaintext password', async () => {
|
|
155
|
+
fetchResponses.push(new Response(null, { status: 200, headers: {} }))
|
|
156
|
+
|
|
157
|
+
const client = new InstagramClient()
|
|
158
|
+
await client.authenticate('user', 'supersecret').catch(() => undefined)
|
|
135
159
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
expect(Number(ts)).toBeGreaterThanOrEqual(before)
|
|
139
|
-
expect(Number(ts)).toBeLessThanOrEqual(after)
|
|
140
|
-
expect(raw).toBe('mypassword')
|
|
160
|
+
const preLoginBody = fetchCalls[0]?.init?.body
|
|
161
|
+
expect(String(preLoginBody ?? '')).not.toContain('supersecret')
|
|
141
162
|
})
|
|
142
|
-
})
|
|
143
163
|
|
|
144
|
-
describe('encryptPassword format', () => {
|
|
145
164
|
it('produces #PWD_INSTAGRAM:4:timestamp:base64 format when encryption key provided', async () => {
|
|
146
|
-
fetchResponses.push(
|
|
147
|
-
new Response(null, {
|
|
148
|
-
status: 200,
|
|
149
|
-
headers: new Headers({
|
|
150
|
-
'ig-set-password-encryption-pub-key': rsaPublicKeyBase64,
|
|
151
|
-
'ig-set-password-encryption-key-id': '7',
|
|
152
|
-
}),
|
|
153
|
-
}),
|
|
154
|
-
)
|
|
165
|
+
fetchResponses.push(encryptionKeyResponse())
|
|
155
166
|
fetchResponses.push(jsonResponse({ status: 'ok', logged_in_user: { pk: '99' } }))
|
|
156
167
|
|
|
157
168
|
const client = new InstagramClient()
|
|
@@ -159,7 +170,7 @@ describe('InstagramClient', () => {
|
|
|
159
170
|
await client.authenticate('user', 'secret')
|
|
160
171
|
const after = Math.floor(Date.now() / 1000)
|
|
161
172
|
|
|
162
|
-
const loginBody =
|
|
173
|
+
const loginBody = signedBody(1)
|
|
163
174
|
const encPassword = loginBody['enc_password'] ?? ''
|
|
164
175
|
|
|
165
176
|
expect(encPassword).toMatch(/^#PWD_INSTAGRAM:4:\d+:.+/)
|
|
@@ -170,11 +181,295 @@ describe('InstagramClient', () => {
|
|
|
170
181
|
expect(parts[3]).toBeTruthy()
|
|
171
182
|
expect(() => Buffer.from(parts[3] ?? '', 'base64')).not.toThrow()
|
|
172
183
|
})
|
|
184
|
+
|
|
185
|
+
it('signs the login body and includes anti-bot login fields', async () => {
|
|
186
|
+
fetchResponses.push(encryptionKeyResponse())
|
|
187
|
+
fetchResponses.push(jsonResponse({ status: 'ok', logged_in_user: { pk: '99' } }))
|
|
188
|
+
|
|
189
|
+
const client = new InstagramClient()
|
|
190
|
+
await client.authenticate('user', 'secret')
|
|
191
|
+
|
|
192
|
+
const raw = fetchCalls[1]?.init?.body
|
|
193
|
+
const params = new URLSearchParams(String(raw ?? ''))
|
|
194
|
+
const signedBodyRaw = params.get('signed_body') ?? ''
|
|
195
|
+
|
|
196
|
+
expect(params.get('ig_sig_key_version')).toBeNull()
|
|
197
|
+
expect(signedBodyRaw).toMatch(/^SIGNATURE\./)
|
|
198
|
+
|
|
199
|
+
const login = signedBody(1)
|
|
200
|
+
expect(login['jazoest']).toMatch(/^2\d+$/)
|
|
201
|
+
expect(login['google_tokens']).toBe('[]')
|
|
202
|
+
expect(login['adid']).toBeTruthy()
|
|
203
|
+
expect(login['country_codes']).toContain('country_code')
|
|
204
|
+
expect(login['_csrftoken']).toBeTruthy()
|
|
205
|
+
expect(login['_csrftoken']).not.toBe('missing')
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('signs bodies with the literal SIGNATURE. prefix and no key version', async () => {
|
|
209
|
+
fetchResponses.push(encryptionKeyResponse())
|
|
210
|
+
fetchResponses.push(jsonResponse({ status: 'ok', logged_in_user: { pk: '99' } }))
|
|
211
|
+
|
|
212
|
+
const client = new InstagramClient()
|
|
213
|
+
await client.authenticate('user', 'secret')
|
|
214
|
+
|
|
215
|
+
const preLogin = new URLSearchParams(String(fetchCalls[0]?.init?.body ?? ''))
|
|
216
|
+
expect(preLogin.get('signed_body')).toMatch(/^SIGNATURE\./)
|
|
217
|
+
expect(preLogin.get('ig_sig_key_version')).toBeNull()
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('uses the configured country code in the login body', async () => {
|
|
221
|
+
fetchResponses.push(encryptionKeyResponse())
|
|
222
|
+
fetchResponses.push(jsonResponse({ status: 'ok', logged_in_user: { pk: '99' } }))
|
|
223
|
+
|
|
224
|
+
const client = new InstagramClient()
|
|
225
|
+
client.setCountryCode('82')
|
|
226
|
+
await client.authenticate('user', 'secret')
|
|
227
|
+
|
|
228
|
+
const login = signedBody(1)
|
|
229
|
+
const countryCodes = JSON.parse(login['country_codes'] ?? '[]') as Array<{ country_code: string }>
|
|
230
|
+
expect(countryCodes[0]?.country_code).toBe('82')
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it.each(['7abc', '1.5', '-1', '256'])('rejects malformed encryption key id %p', async (keyId) => {
|
|
234
|
+
fetchResponses.push(
|
|
235
|
+
new Response(null, {
|
|
236
|
+
status: 200,
|
|
237
|
+
headers: new Headers({
|
|
238
|
+
'ig-set-password-encryption-pub-key': rsaPublicKeyBase64,
|
|
239
|
+
'ig-set-password-encryption-key-id': keyId,
|
|
240
|
+
}),
|
|
241
|
+
}),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
const client = new InstagramClient()
|
|
245
|
+
const err = await client.authenticate('user', 'secret').catch((e: unknown) => e)
|
|
246
|
+
|
|
247
|
+
expect(err).toBeInstanceOf(InstagramError)
|
|
248
|
+
expect((err as InstagramError).code).toBe('encryption_key_invalid')
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
describe('authenticate Facebook-linked accounts', () => {
|
|
253
|
+
it('throws a clear facebook_linked error when Instagram offers login_with_facebook', async () => {
|
|
254
|
+
fetchResponses.push(encryptionKeyResponse())
|
|
255
|
+
fetchResponses.push(
|
|
256
|
+
jsonResponse(
|
|
257
|
+
{
|
|
258
|
+
status: 'fail',
|
|
259
|
+
error_type: 'bad_password',
|
|
260
|
+
message: 'You can log in with your linked Facebook account.',
|
|
261
|
+
buttons: [
|
|
262
|
+
{ title: 'Use Facebook', action: 'login_with_facebook' },
|
|
263
|
+
{ title: 'Try again', action: 'dismiss' },
|
|
264
|
+
],
|
|
265
|
+
},
|
|
266
|
+
400,
|
|
267
|
+
),
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
const client = new InstagramClient()
|
|
271
|
+
const err = await client.authenticate('user', 'secret').catch((e: unknown) => e)
|
|
272
|
+
|
|
273
|
+
expect(err).toBeInstanceOf(InstagramError)
|
|
274
|
+
expect((err as InstagramError).code).toBe('facebook_linked')
|
|
275
|
+
expect((err as InstagramError).message).toContain('auth extract')
|
|
276
|
+
expect((err as InstagramError).message).toContain('password')
|
|
277
|
+
expect((err as InstagramError).message).toContain('unlink Facebook')
|
|
278
|
+
expect((err as InstagramError).message).toContain('Move out of this Account Center')
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('throws the generic login error for a real bad_password without the Facebook button', async () => {
|
|
282
|
+
fetchResponses.push(encryptionKeyResponse())
|
|
283
|
+
fetchResponses.push(
|
|
284
|
+
jsonResponse(
|
|
285
|
+
{ status: 'fail', error_type: 'bad_password', message: 'The password you entered is incorrect.' },
|
|
286
|
+
400,
|
|
287
|
+
),
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
const client = new InstagramClient()
|
|
291
|
+
const err = await client.authenticate('user', 'secret').catch((e: unknown) => e)
|
|
292
|
+
|
|
293
|
+
expect(err).toBeInstanceOf(InstagramError)
|
|
294
|
+
expect((err as InstagramError).code).toBe('bad_password')
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('surfaces oneClickEmailAvailable when Instagram offers send_one_click_login_email', async () => {
|
|
298
|
+
fetchResponses.push(encryptionKeyResponse())
|
|
299
|
+
fetchResponses.push(
|
|
300
|
+
jsonResponse(
|
|
301
|
+
{
|
|
302
|
+
status: 'fail',
|
|
303
|
+
error_type: 'bad_password',
|
|
304
|
+
message: 'We can send you an email to help you get back into your account.',
|
|
305
|
+
buttons: [
|
|
306
|
+
{ title: 'Send email', action: 'send_one_click_login_email' },
|
|
307
|
+
{ title: 'Try again', action: 'dismiss' },
|
|
308
|
+
],
|
|
309
|
+
},
|
|
310
|
+
400,
|
|
311
|
+
),
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
const client = new InstagramClient()
|
|
315
|
+
const result = await client.authenticate('user', 'secret')
|
|
316
|
+
|
|
317
|
+
expect(result.oneClickEmailAvailable).toBe(true)
|
|
318
|
+
expect(result.userId).toBe('')
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('login(credentials) throws instead of returning an unauthenticated client on one-click email', async () => {
|
|
322
|
+
fetchResponses.push(encryptionKeyResponse())
|
|
323
|
+
fetchResponses.push(
|
|
324
|
+
jsonResponse(
|
|
325
|
+
{
|
|
326
|
+
status: 'fail',
|
|
327
|
+
error_type: 'bad_password',
|
|
328
|
+
buttons: [{ title: 'Send email', action: 'send_one_click_login_email' }],
|
|
329
|
+
},
|
|
330
|
+
400,
|
|
331
|
+
),
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
const client = new InstagramClient()
|
|
335
|
+
const err = await client.login({ username: 'user', password: 'secret' }).catch((e: unknown) => e)
|
|
336
|
+
|
|
337
|
+
expect(err).toBeInstanceOf(InstagramError)
|
|
338
|
+
expect((err as InstagramError).code).toBe('one_click_email_available')
|
|
339
|
+
expect(client.getUserId()).toBeNull()
|
|
340
|
+
})
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
describe('device persistence', () => {
|
|
344
|
+
function loginResponses(): void {
|
|
345
|
+
fetchResponses.push(encryptionKeyResponse())
|
|
346
|
+
fetchResponses.push(jsonResponse({ status: 'ok', logged_in_user: { pk: '99' } }))
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
it('reuses the same persisted device across separate authenticate calls', async () => {
|
|
350
|
+
const dir = join(testDir, `device-reuse-${Math.random().toString(36).slice(2)}`)
|
|
351
|
+
const manager = new InstagramCredentialManager(dir)
|
|
352
|
+
|
|
353
|
+
loginResponses()
|
|
354
|
+
await new InstagramClient(manager).authenticate('user', 'secret')
|
|
355
|
+
const firstHeaders = (fetchCalls[1]?.init?.headers ?? {}) as Record<string, string>
|
|
356
|
+
const firstAndroidId = firstHeaders['X-IG-Android-ID']
|
|
357
|
+
|
|
358
|
+
loginResponses()
|
|
359
|
+
await new InstagramClient(manager).authenticate('user', 'secret')
|
|
360
|
+
const secondHeaders = (fetchCalls[3]?.init?.headers ?? {}) as Record<string, string>
|
|
361
|
+
const secondAndroidId = secondHeaders['X-IG-Android-ID']
|
|
362
|
+
|
|
363
|
+
expect(firstAndroidId).toBeTruthy()
|
|
364
|
+
expect(secondAndroidId).toBe(firstAndroidId)
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('persists a device the first time authenticate runs', async () => {
|
|
368
|
+
const dir = join(testDir, `device-create-${Math.random().toString(36).slice(2)}`)
|
|
369
|
+
const manager = new InstagramCredentialManager(dir)
|
|
370
|
+
expect(await manager.loadDevice()).toBeNull()
|
|
371
|
+
|
|
372
|
+
loginResponses()
|
|
373
|
+
await new InstagramClient(manager).authenticate('user', 'secret')
|
|
374
|
+
|
|
375
|
+
const saved = await manager.loadDevice()
|
|
376
|
+
expect(saved).not.toBeNull()
|
|
377
|
+
expect(saved?.android_device_id).toMatch(/^android-[0-9a-f]{16}$/)
|
|
378
|
+
})
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
describe('parseOneClickLoginLink', () => {
|
|
382
|
+
it('extracts uid and token from a full web_emaillogin URL', () => {
|
|
383
|
+
const url = 'https://www.instagram.com/_n/web_emaillogin?uid=ENCODED_UID&token=NONCE123&auto_send=0'
|
|
384
|
+
expect(parseOneClickLoginLink(url)).toEqual({ uid: 'ENCODED_UID', token: 'NONCE123' })
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
it('extracts from a bare query string without a scheme', () => {
|
|
388
|
+
expect(parseOneClickLoginLink('uid=abc&token=xyz')).toEqual({ uid: 'abc', token: 'xyz' })
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it('trims surrounding whitespace', () => {
|
|
392
|
+
expect(parseOneClickLoginLink(' https://x/?uid=a&token=b ')).toEqual({ uid: 'a', token: 'b' })
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
it('strips a trailing #fragment so token is not corrupted', () => {
|
|
396
|
+
const url = 'https://www.instagram.com/_n/web_emaillogin?uid=U1&token=T1#tracking-anchor'
|
|
397
|
+
expect(parseOneClickLoginLink(url)).toEqual({ uid: 'U1', token: 'T1' })
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
it('returns null when uid or token is missing', () => {
|
|
401
|
+
expect(parseOneClickLoginLink('https://www.instagram.com/?uid=only')).toBeNull()
|
|
402
|
+
expect(parseOneClickLoginLink('not a link')).toBeNull()
|
|
403
|
+
})
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
describe('email login flow', () => {
|
|
407
|
+
it('looks up the user id then triggers one_click_login with auto_send', async () => {
|
|
408
|
+
fetchResponses.push(jsonResponse({ status: 'ok', user_id: '4242' }))
|
|
409
|
+
fetchResponses.push(jsonResponse({ status: 'ok', obfuscated_email: 'j***@example.com' }))
|
|
410
|
+
|
|
411
|
+
const client = new InstagramClient()
|
|
412
|
+
const result = await client.sendOneClickLoginEmail('user')
|
|
413
|
+
|
|
414
|
+
expect(fetchCalls[0]?.url).toContain('/users/lookup/')
|
|
415
|
+
expect(fetchCalls[1]?.url).toContain('/accounts/one_click_login/')
|
|
416
|
+
const body = urlParamsBody(1)
|
|
417
|
+
expect(body['uid']).toBe('4242')
|
|
418
|
+
expect(body['source']).toBe('one_click_login_email')
|
|
419
|
+
expect(body['auto_send']).toBe('true')
|
|
420
|
+
expect(body['token']).toBeUndefined()
|
|
421
|
+
expect(result).toEqual({ sent: true, contactPoint: 'j***@example.com' })
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
it('throws when the username cannot be resolved to a user id', async () => {
|
|
425
|
+
fetchResponses.push(jsonResponse({ status: 'fail', message: 'User not found' }))
|
|
426
|
+
|
|
427
|
+
const client = new InstagramClient()
|
|
428
|
+
const err = await client.sendOneClickLoginEmail('nobody').catch((e: unknown) => e)
|
|
429
|
+
|
|
430
|
+
expect(err).toBeInstanceOf(InstagramError)
|
|
431
|
+
expect((err as InstagramError).code).toBe('user_lookup_failed')
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
it('throws when the one_click_login auto_send request fails', async () => {
|
|
435
|
+
fetchResponses.push(jsonResponse({ status: 'ok', user_id: '4242' }))
|
|
436
|
+
fetchResponses.push(jsonResponse({ status: 'fail', message: 'Please wait a few minutes' }))
|
|
437
|
+
|
|
438
|
+
const client = new InstagramClient()
|
|
439
|
+
const err = await client.sendOneClickLoginEmail('user').catch((e: unknown) => e)
|
|
440
|
+
|
|
441
|
+
expect(err).toBeInstanceOf(InstagramError)
|
|
442
|
+
expect((err as InstagramError).code).toBe('one_click_email_failed')
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
it('redeems a one-click login token for a session', async () => {
|
|
446
|
+
fetchResponses.push(jsonResponse({ status: 'ok', logged_in_user: { pk: '4242' } }))
|
|
447
|
+
|
|
448
|
+
const client = new InstagramClient()
|
|
449
|
+
const result = await client.oneClickLogin('ENCODED_UID', 'NONCE123')
|
|
450
|
+
|
|
451
|
+
expect(fetchCalls[0]?.url).toContain('/accounts/one_click_login/')
|
|
452
|
+
const body = urlParamsBody(0)
|
|
453
|
+
expect(body['uid']).toBe('ENCODED_UID')
|
|
454
|
+
expect(body['token']).toBe('NONCE123')
|
|
455
|
+
expect(body['source']).toBe('one_click_login_email')
|
|
456
|
+
expect(result.userId).toBe('4242')
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
it('throws when one-click login returns no session', async () => {
|
|
460
|
+
fetchResponses.push(jsonResponse({ status: 'fail', message: 'Invalid token' }, 400))
|
|
461
|
+
|
|
462
|
+
const client = new InstagramClient()
|
|
463
|
+
const err = await client.oneClickLogin('uid', 'bad').catch((e: unknown) => e)
|
|
464
|
+
|
|
465
|
+
expect(err).toBeInstanceOf(InstagramError)
|
|
466
|
+
expect((err as InstagramError).code).toBe('one_click_login_failed')
|
|
467
|
+
})
|
|
173
468
|
})
|
|
174
469
|
|
|
175
470
|
describe('buildHeaders', () => {
|
|
176
471
|
it('includes required Instagram headers', async () => {
|
|
177
|
-
fetchResponses.push(
|
|
472
|
+
fetchResponses.push(encryptionKeyResponse())
|
|
178
473
|
fetchResponses.push(jsonResponse({ status: 'ok', logged_in_user: { pk: '99' } }))
|
|
179
474
|
|
|
180
475
|
const client = new InstagramClient()
|
|
@@ -186,9 +481,27 @@ describe('InstagramClient', () => {
|
|
|
186
481
|
expect(headers['User-Agent']).toMatch(/^Instagram \d+\.\d+\.\d+\.\d+\.\d+ Android \(/)
|
|
187
482
|
expect(headers['X-IG-Capabilities']).toBeTruthy()
|
|
188
483
|
expect(headers['X-IG-Connection-Type']).toBe('WIFI')
|
|
484
|
+
expect(headers['X-Bloks-Version-Id']).toBeTruthy()
|
|
485
|
+
expect(headers['X-IG-WWW-Claim']).toBe('0')
|
|
189
486
|
expect(headers['Content-Type']).toContain('application/x-www-form-urlencoded')
|
|
190
487
|
})
|
|
191
488
|
|
|
489
|
+
it('sends the current Android fingerprint headers so IG does not silently drop the login', async () => {
|
|
490
|
+
fetchResponses.push(encryptionKeyResponse())
|
|
491
|
+
fetchResponses.push(jsonResponse({ status: 'ok', logged_in_user: { pk: '99' } }))
|
|
492
|
+
|
|
493
|
+
const client = new InstagramClient()
|
|
494
|
+
await client.authenticate('user', 'pass')
|
|
495
|
+
|
|
496
|
+
const headers = fetchCalls[0]?.init?.headers as Record<string, string>
|
|
497
|
+
|
|
498
|
+
expect(headers['X-FB-HTTP-Engine']).toBe('Tigon/MNS/TCP')
|
|
499
|
+
expect(headers['IG-INTENDED-USER-ID']).toBe('0')
|
|
500
|
+
expect(headers['X-IG-App-Locale']).toBe('en_US')
|
|
501
|
+
expect(headers['X-Pigeon-Session-Id']).toMatch(/^UFS-/)
|
|
502
|
+
expect(headers['Priority']).toBe('u=3')
|
|
503
|
+
})
|
|
504
|
+
|
|
192
505
|
it('includes device headers when session is set', async () => {
|
|
193
506
|
fetchResponses.push(jsonResponse({ status: 'ok', inbox: { threads: [] } }))
|
|
194
507
|
|
|
@@ -310,6 +623,21 @@ describe('InstagramClient', () => {
|
|
|
310
623
|
expect((err as InstagramError).code).toBe('rate_limited')
|
|
311
624
|
})
|
|
312
625
|
|
|
626
|
+
it('surfaces Instagram message from a 429 JSON body', async () => {
|
|
627
|
+
fetchResponses.push(
|
|
628
|
+
new Response(JSON.stringify({ message: 'Please wait a few minutes before you try again.', status: 'fail' }), {
|
|
629
|
+
status: 429,
|
|
630
|
+
}),
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
const client = await loadedClient()
|
|
634
|
+
|
|
635
|
+
const err = await client.listChats().catch((e: unknown) => e)
|
|
636
|
+
expect(err).toBeInstanceOf(InstagramError)
|
|
637
|
+
expect((err as InstagramError).code).toBe('rate_limited')
|
|
638
|
+
expect((err as InstagramError).message).toContain('Please wait a few minutes')
|
|
639
|
+
})
|
|
640
|
+
|
|
313
641
|
it('throws on JSON parse error', async () => {
|
|
314
642
|
fetchResponses.push(new Response('not json', { status: 200 }))
|
|
315
643
|
|
|
@@ -326,6 +654,43 @@ describe('InstagramClient', () => {
|
|
|
326
654
|
|
|
327
655
|
expect(chats).toEqual([])
|
|
328
656
|
})
|
|
657
|
+
|
|
658
|
+
it('passes an abort signal so a stalled connection cannot hang forever', async () => {
|
|
659
|
+
fetchResponses.push(jsonResponse({ status: 'ok', inbox: { threads: [] } }))
|
|
660
|
+
|
|
661
|
+
const client = await loadedClient()
|
|
662
|
+
await client.listChats()
|
|
663
|
+
|
|
664
|
+
expect(fetchCalls[0]?.init?.signal).toBeInstanceOf(AbortSignal)
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
it('surfaces request_stalled when the request times out', async () => {
|
|
668
|
+
;(globalThis as Record<string, unknown>).fetch = (): Promise<Response> =>
|
|
669
|
+
Promise.reject(new DOMException('The operation timed out.', 'TimeoutError'))
|
|
670
|
+
|
|
671
|
+
const client = await loadedClient()
|
|
672
|
+
|
|
673
|
+
const err = await client.listChats().catch((e: unknown) => e)
|
|
674
|
+
expect(err).toBeInstanceOf(InstagramError)
|
|
675
|
+
expect((err as InstagramError).code).toBe('request_stalled')
|
|
676
|
+
expect((err as InstagramError).message).toContain('auth extract')
|
|
677
|
+
})
|
|
678
|
+
|
|
679
|
+
it('surfaces request_stalled when the body stalls after headers arrive', async () => {
|
|
680
|
+
const stalledBody = {
|
|
681
|
+
status: 200,
|
|
682
|
+
headers: new Headers(),
|
|
683
|
+
text: () => Promise.reject(new DOMException('The operation was aborted.', 'AbortError')),
|
|
684
|
+
} as unknown as Response
|
|
685
|
+
;(globalThis as Record<string, unknown>).fetch = (): Promise<Response> => Promise.resolve(stalledBody)
|
|
686
|
+
|
|
687
|
+
const client = await loadedClient()
|
|
688
|
+
|
|
689
|
+
const err = await client.listChats().catch((e: unknown) => e)
|
|
690
|
+
expect(err).toBeInstanceOf(InstagramError)
|
|
691
|
+
expect((err as InstagramError).code).toBe('request_stalled')
|
|
692
|
+
expect((err as InstagramError).message).toContain('auth extract')
|
|
693
|
+
})
|
|
329
694
|
})
|
|
330
695
|
|
|
331
696
|
describe('searchUsers', () => {
|
|
@@ -388,4 +753,84 @@ describe('InstagramClient', () => {
|
|
|
388
753
|
expect(body['client_context']).toBeTruthy()
|
|
389
754
|
})
|
|
390
755
|
})
|
|
756
|
+
|
|
757
|
+
describe('twoFactorLogin', () => {
|
|
758
|
+
it('completes via the legacy endpoint and signs the body', async () => {
|
|
759
|
+
fetchResponses.push(jsonResponse({ status: 'ok', logged_in_user: { pk: '77' } }))
|
|
760
|
+
|
|
761
|
+
const client = await loadedClient()
|
|
762
|
+
const result = await client.twoFactorLogin('user', '123456', 'ident-1')
|
|
763
|
+
|
|
764
|
+
expect(result.userId).toBe('77')
|
|
765
|
+
expect(fetchCalls[0]?.url).toContain('/accounts/two_factor_login/')
|
|
766
|
+
|
|
767
|
+
const params = new URLSearchParams(String(fetchCalls[0]?.init?.body ?? ''))
|
|
768
|
+
expect(params.get('signed_body')).toMatch(/^SIGNATURE\./)
|
|
769
|
+
expect(params.get('ig_sig_key_version')).toBeNull()
|
|
770
|
+
const body = signedBody(0)
|
|
771
|
+
expect(body['verification_code']).toBe('123456')
|
|
772
|
+
expect(body['two_factor_identifier']).toBe('ident-1')
|
|
773
|
+
})
|
|
774
|
+
|
|
775
|
+
it('falls back to the Bloks flow when the legacy endpoint rejects params', async () => {
|
|
776
|
+
fetchResponses.push(jsonResponse({ status: 'fail', message: 'Invalid parameters' }, 400))
|
|
777
|
+
fetchResponses.push(jsonResponse({ status: 'ok', logged_in_user: { pk: '88' } }))
|
|
778
|
+
|
|
779
|
+
const client = await loadedClient()
|
|
780
|
+
const result = await client.twoFactorLogin('user', '654321', 'ctx-9')
|
|
781
|
+
|
|
782
|
+
expect(result.userId).toBe('88')
|
|
783
|
+
expect(fetchCalls[1]?.url).toContain('two_step_verification.verify_code.async')
|
|
784
|
+
|
|
785
|
+
const params = new URLSearchParams(String(fetchCalls[1]?.init?.body ?? ''))
|
|
786
|
+
expect(params.get('signed_body')).toBeNull()
|
|
787
|
+
expect(params.get('params')).toContain('654321')
|
|
788
|
+
})
|
|
789
|
+
|
|
790
|
+
it('passes the Bloks context from the failed legacy response, not the legacy identifier', async () => {
|
|
791
|
+
fetchResponses.push(jsonResponse({ status: 'fail', two_step_verification_context: 'real-bloks-ctx' }, 400))
|
|
792
|
+
fetchResponses.push(jsonResponse({ status: 'ok', logged_in_user: { pk: '88' } }))
|
|
793
|
+
|
|
794
|
+
const client = await loadedClient()
|
|
795
|
+
await client.twoFactorLogin('user', '654321', 'legacy-ident')
|
|
796
|
+
|
|
797
|
+
const params = new URLSearchParams(String(fetchCalls[1]?.init?.body ?? ''))
|
|
798
|
+
const bloksParams = JSON.parse(params.get('params') ?? '{}') as {
|
|
799
|
+
server_params: { two_step_verification_context: string }
|
|
800
|
+
}
|
|
801
|
+
expect(bloksParams.server_params.two_step_verification_context).toBe('real-bloks-ctx')
|
|
802
|
+
})
|
|
803
|
+
|
|
804
|
+
it('resolves the Bloks 2FA user id from the ds_user_id cookie when no logged_in_user is returned', async () => {
|
|
805
|
+
fetchResponses.push(jsonResponse({ status: 'fail', message: 'Invalid parameters' }, 400))
|
|
806
|
+
fetchResponses.push(
|
|
807
|
+
jsonResponse({ status: 'ok' }, 200, {
|
|
808
|
+
'ig-set-authorization': 'Bearer IGT:2:token',
|
|
809
|
+
'set-cookie': 'ds_user_id=555',
|
|
810
|
+
}),
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
const client = await loadedClient()
|
|
814
|
+
client.getSessionState().user_id = undefined
|
|
815
|
+
const result = await client.twoFactorLogin('user', '654321', 'ctx-9')
|
|
816
|
+
|
|
817
|
+
expect(result.userId).toBe('555')
|
|
818
|
+
})
|
|
819
|
+
|
|
820
|
+
it('fails the Bloks 2FA flow when no user id can be resolved', async () => {
|
|
821
|
+
const noUserSession: InstagramSessionState = { ...SESSION, cookies: '', user_id: undefined }
|
|
822
|
+
const noUserPath = join(testDir, 'no-user-session.json')
|
|
823
|
+
writeFileSync(noUserPath, JSON.stringify(noUserSession))
|
|
824
|
+
|
|
825
|
+
fetchResponses.push(jsonResponse({ status: 'fail', message: 'Invalid parameters' }, 400))
|
|
826
|
+
fetchResponses.push(jsonResponse({ status: 'ok' }, 200, { 'ig-set-authorization': 'Bearer IGT:2:token' }))
|
|
827
|
+
|
|
828
|
+
const client = new InstagramClient()
|
|
829
|
+
await client.loadSession(noUserPath)
|
|
830
|
+
const err = await client.twoFactorLogin('user', '654321', 'ctx-9').catch((e: unknown) => e)
|
|
831
|
+
|
|
832
|
+
expect(err).toBeInstanceOf(InstagramError)
|
|
833
|
+
expect((err as InstagramError).code).toBe('two_factor_failed')
|
|
834
|
+
})
|
|
835
|
+
})
|
|
391
836
|
})
|