agent-messenger 2.28.0 → 2.29.1

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 (159) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +29 -1
  3. package/dist/package.json +1 -1
  4. package/dist/src/platforms/discord/client.d.ts +6 -2
  5. package/dist/src/platforms/discord/client.d.ts.map +1 -1
  6. package/dist/src/platforms/discord/client.js +10 -3
  7. package/dist/src/platforms/discord/client.js.map +1 -1
  8. package/dist/src/platforms/instagram/client.d.ts +24 -2
  9. package/dist/src/platforms/instagram/client.d.ts.map +1 -1
  10. package/dist/src/platforms/instagram/client.js +343 -53
  11. package/dist/src/platforms/instagram/client.js.map +1 -1
  12. package/dist/src/platforms/instagram/commands/auth.d.ts +1 -0
  13. package/dist/src/platforms/instagram/commands/auth.d.ts.map +1 -1
  14. package/dist/src/platforms/instagram/commands/auth.js +123 -11
  15. package/dist/src/platforms/instagram/commands/auth.js.map +1 -1
  16. package/dist/src/platforms/instagram/credential-manager.d.ts +4 -1
  17. package/dist/src/platforms/instagram/credential-manager.d.ts.map +1 -1
  18. package/dist/src/platforms/instagram/credential-manager.js +17 -1
  19. package/dist/src/platforms/instagram/credential-manager.js.map +1 -1
  20. package/dist/src/platforms/instagram/types.d.ts +10 -8
  21. package/dist/src/platforms/instagram/types.d.ts.map +1 -1
  22. package/dist/src/platforms/instagram/types.js.map +1 -1
  23. package/dist/src/platforms/teams/app-config.d.ts +36 -0
  24. package/dist/src/platforms/teams/app-config.d.ts.map +1 -0
  25. package/dist/src/platforms/teams/app-config.js +69 -0
  26. package/dist/src/platforms/teams/app-config.js.map +1 -0
  27. package/dist/src/platforms/teams/client.d.ts +20 -2
  28. package/dist/src/platforms/teams/client.d.ts.map +1 -1
  29. package/dist/src/platforms/teams/client.js +311 -7
  30. package/dist/src/platforms/teams/client.js.map +1 -1
  31. package/dist/src/platforms/teams/commands/auth.d.ts +10 -0
  32. package/dist/src/platforms/teams/commands/auth.d.ts.map +1 -1
  33. package/dist/src/platforms/teams/commands/auth.js +137 -3
  34. package/dist/src/platforms/teams/commands/auth.js.map +1 -1
  35. package/dist/src/platforms/teams/commands/file.d.ts +3 -0
  36. package/dist/src/platforms/teams/commands/file.d.ts.map +1 -1
  37. package/dist/src/platforms/teams/commands/file.js +59 -2
  38. package/dist/src/platforms/teams/commands/file.js.map +1 -1
  39. package/dist/src/platforms/teams/commands/message.d.ts +11 -0
  40. package/dist/src/platforms/teams/commands/message.d.ts.map +1 -1
  41. package/dist/src/platforms/teams/commands/message.js +121 -2
  42. package/dist/src/platforms/teams/commands/message.js.map +1 -1
  43. package/dist/src/platforms/teams/credential-manager.d.ts +23 -1
  44. package/dist/src/platforms/teams/credential-manager.d.ts.map +1 -1
  45. package/dist/src/platforms/teams/credential-manager.js +46 -2
  46. package/dist/src/platforms/teams/credential-manager.js.map +1 -1
  47. package/dist/src/platforms/teams/device-code.d.ts +48 -0
  48. package/dist/src/platforms/teams/device-code.d.ts.map +1 -0
  49. package/dist/src/platforms/teams/device-code.js +199 -0
  50. package/dist/src/platforms/teams/device-code.js.map +1 -0
  51. package/dist/src/platforms/teams/device-login.d.ts +37 -0
  52. package/dist/src/platforms/teams/device-login.d.ts.map +1 -0
  53. package/dist/src/platforms/teams/device-login.js +129 -0
  54. package/dist/src/platforms/teams/device-login.js.map +1 -0
  55. package/dist/src/platforms/teams/ensure-auth.d.ts.map +1 -1
  56. package/dist/src/platforms/teams/ensure-auth.js +12 -0
  57. package/dist/src/platforms/teams/ensure-auth.js.map +1 -1
  58. package/dist/src/platforms/teams/index.d.ts +7 -3
  59. package/dist/src/platforms/teams/index.d.ts.map +1 -1
  60. package/dist/src/platforms/teams/index.js +5 -2
  61. package/dist/src/platforms/teams/index.js.map +1 -1
  62. package/dist/src/platforms/teams/listener.d.ts +40 -0
  63. package/dist/src/platforms/teams/listener.d.ts.map +1 -0
  64. package/dist/src/platforms/teams/listener.js +286 -0
  65. package/dist/src/platforms/teams/listener.js.map +1 -0
  66. package/dist/src/platforms/teams/realm-discovery.d.ts +3 -0
  67. package/dist/src/platforms/teams/realm-discovery.d.ts.map +1 -0
  68. package/dist/src/platforms/teams/realm-discovery.js +33 -0
  69. package/dist/src/platforms/teams/realm-discovery.js.map +1 -0
  70. package/dist/src/platforms/teams/token-extractor.d.ts +3 -0
  71. package/dist/src/platforms/teams/token-extractor.d.ts.map +1 -1
  72. package/dist/src/platforms/teams/token-extractor.js +60 -0
  73. package/dist/src/platforms/teams/token-extractor.js.map +1 -1
  74. package/dist/src/platforms/teams/token-provider.d.ts +25 -0
  75. package/dist/src/platforms/teams/token-provider.d.ts.map +1 -0
  76. package/dist/src/platforms/teams/token-provider.js +190 -0
  77. package/dist/src/platforms/teams/token-provider.js.map +1 -0
  78. package/dist/src/platforms/teams/trouter.d.ts +38 -0
  79. package/dist/src/platforms/teams/trouter.d.ts.map +1 -0
  80. package/dist/src/platforms/teams/trouter.js +266 -0
  81. package/dist/src/platforms/teams/trouter.js.map +1 -0
  82. package/dist/src/platforms/teams/types.d.ts +103 -0
  83. package/dist/src/platforms/teams/types.d.ts.map +1 -1
  84. package/dist/src/platforms/teams/types.js +30 -0
  85. package/dist/src/platforms/teams/types.js.map +1 -1
  86. package/dist/src/platforms/webex/types.d.ts +1 -1
  87. package/dist/src/tui/adapters/instagram-adapter.d.ts.map +1 -1
  88. package/dist/src/tui/adapters/instagram-adapter.js +16 -0
  89. package/dist/src/tui/adapters/instagram-adapter.js.map +1 -1
  90. package/dist/src/tui/adapters/teams-adapter.d.ts +3 -0
  91. package/dist/src/tui/adapters/teams-adapter.d.ts.map +1 -1
  92. package/dist/src/tui/adapters/teams-adapter.js +21 -0
  93. package/dist/src/tui/adapters/teams-adapter.js.map +1 -1
  94. package/dist/src/tui/app.js +3 -3
  95. package/docs/content/docs/cli/instagram.mdx +1 -0
  96. package/docs/content/docs/cli/teams.mdx +36 -0
  97. package/docs/content/docs/sdk/teams.mdx +30 -0
  98. package/package.json +1 -1
  99. package/skills/agent-channeltalk/SKILL.md +1 -1
  100. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  101. package/skills/agent-discord/SKILL.md +1 -1
  102. package/skills/agent-discordbot/SKILL.md +1 -1
  103. package/skills/agent-imessage/SKILL.md +1 -1
  104. package/skills/agent-instagram/SKILL.md +1 -1
  105. package/skills/agent-instagram/references/authentication.md +11 -0
  106. package/skills/agent-kakaotalk/SKILL.md +1 -1
  107. package/skills/agent-line/SKILL.md +1 -1
  108. package/skills/agent-slack/SKILL.md +1 -1
  109. package/skills/agent-slackbot/SKILL.md +1 -1
  110. package/skills/agent-teams/SKILL.md +82 -6
  111. package/skills/agent-teams/references/authentication.md +31 -1
  112. package/skills/agent-telegram/SKILL.md +1 -1
  113. package/skills/agent-telegrambot/SKILL.md +1 -1
  114. package/skills/agent-webex/SKILL.md +1 -1
  115. package/skills/agent-webexbot/SKILL.md +1 -1
  116. package/skills/agent-wechatbot/SKILL.md +1 -1
  117. package/skills/agent-whatsapp/SKILL.md +1 -1
  118. package/skills/agent-whatsappbot/SKILL.md +1 -1
  119. package/src/platforms/discord/client.test.ts +86 -0
  120. package/src/platforms/discord/client.ts +10 -3
  121. package/src/platforms/instagram/client.test.ts +478 -33
  122. package/src/platforms/instagram/client.ts +449 -68
  123. package/src/platforms/instagram/commands/auth.test.ts +177 -1
  124. package/src/platforms/instagram/commands/auth.ts +182 -11
  125. package/src/platforms/instagram/credential-manager.test.ts +30 -0
  126. package/src/platforms/instagram/credential-manager.ts +23 -1
  127. package/src/platforms/instagram/types.ts +11 -8
  128. package/src/platforms/teams/app-config.ts +92 -0
  129. package/src/platforms/teams/client.test.ts +335 -1
  130. package/src/platforms/teams/client.ts +370 -7
  131. package/src/platforms/teams/commands/auth.test.ts +93 -1
  132. package/src/platforms/teams/commands/auth.ts +182 -3
  133. package/src/platforms/teams/commands/file.test.ts +92 -1
  134. package/src/platforms/teams/commands/file.ts +76 -1
  135. package/src/platforms/teams/commands/message.test.ts +95 -1
  136. package/src/platforms/teams/commands/message.ts +145 -3
  137. package/src/platforms/teams/credential-manager.test.ts +20 -0
  138. package/src/platforms/teams/credential-manager.ts +68 -3
  139. package/src/platforms/teams/device-code.test.ts +311 -0
  140. package/src/platforms/teams/device-code.ts +277 -0
  141. package/src/platforms/teams/device-login.test.ts +288 -0
  142. package/src/platforms/teams/device-login.ts +206 -0
  143. package/src/platforms/teams/ensure-auth.ts +11 -0
  144. package/src/platforms/teams/index.test.ts +10 -0
  145. package/src/platforms/teams/index.ts +17 -1
  146. package/src/platforms/teams/listener.test.ts +243 -0
  147. package/src/platforms/teams/listener.ts +351 -0
  148. package/src/platforms/teams/realm-discovery.test.ts +73 -0
  149. package/src/platforms/teams/realm-discovery.ts +42 -0
  150. package/src/platforms/teams/token-extractor.ts +69 -0
  151. package/src/platforms/teams/token-provider.test.ts +227 -0
  152. package/src/platforms/teams/token-provider.ts +284 -0
  153. package/src/platforms/teams/trouter.test.ts +174 -0
  154. package/src/platforms/teams/trouter.ts +323 -0
  155. package/src/platforms/teams/types.test.ts +17 -0
  156. package/src/platforms/teams/types.ts +96 -0
  157. package/src/tui/adapters/instagram-adapter.ts +13 -0
  158. package/src/tui/adapters/teams-adapter.ts +23 -0
  159. 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
- describe('plaintextPassword format', () => {
119
- it('produces #PWD_INSTAGRAM:0:timestamp:rawpassword format', async () => {
120
- fetchResponses.push(
121
- new Response(null, {
122
- status: 200,
123
- headers: {},
124
- }),
125
- )
126
- fetchResponses.push(jsonResponse({ status: 'ok', logged_in_user: { pk: '99' } }))
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 before = Math.floor(Date.now() / 1000)
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
- const loginBody = urlParamsBody(1)
134
- const encPassword = loginBody['enc_password'] ?? ''
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
- expect(encPassword).toMatch(/^#PWD_INSTAGRAM:0:\d+:/)
137
- const [, , ts, raw] = encPassword.split(':')
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 = urlParamsBody(1)
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(new Response(null, { status: 200, headers: {} }))
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
  })