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.
Files changed (157) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +18 -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 +19 -2
  28. package/dist/src/platforms/teams/client.d.ts.map +1 -1
  29. package/dist/src/platforms/teams/client.js +295 -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 +35 -0
  63. package/dist/src/platforms/teams/listener.d.ts.map +1 -0
  64. package/dist/src/platforms/teams/listener.js +236 -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 +31 -0
  79. package/dist/src/platforms/teams/trouter.d.ts.map +1 -0
  80. package/dist/src/platforms/teams/trouter.js +181 -0
  81. package/dist/src/platforms/teams/trouter.js.map +1 -0
  82. package/dist/src/platforms/teams/types.d.ts +94 -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 +22 -0
  97. package/package.json +1 -1
  98. package/skills/agent-channeltalk/SKILL.md +1 -1
  99. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  100. package/skills/agent-discord/SKILL.md +1 -1
  101. package/skills/agent-discordbot/SKILL.md +1 -1
  102. package/skills/agent-imessage/SKILL.md +1 -1
  103. package/skills/agent-instagram/SKILL.md +1 -1
  104. package/skills/agent-instagram/references/authentication.md +11 -0
  105. package/skills/agent-kakaotalk/SKILL.md +1 -1
  106. package/skills/agent-line/SKILL.md +1 -1
  107. package/skills/agent-slack/SKILL.md +1 -1
  108. package/skills/agent-slackbot/SKILL.md +1 -1
  109. package/skills/agent-teams/SKILL.md +59 -6
  110. package/skills/agent-teams/references/authentication.md +31 -1
  111. package/skills/agent-telegram/SKILL.md +1 -1
  112. package/skills/agent-telegrambot/SKILL.md +1 -1
  113. package/skills/agent-webex/SKILL.md +1 -1
  114. package/skills/agent-webexbot/SKILL.md +1 -1
  115. package/skills/agent-wechatbot/SKILL.md +1 -1
  116. package/skills/agent-whatsapp/SKILL.md +1 -1
  117. package/skills/agent-whatsappbot/SKILL.md +1 -1
  118. package/src/platforms/discord/client.test.ts +86 -0
  119. package/src/platforms/discord/client.ts +10 -3
  120. package/src/platforms/instagram/client.test.ts +478 -33
  121. package/src/platforms/instagram/client.ts +449 -68
  122. package/src/platforms/instagram/commands/auth.test.ts +177 -1
  123. package/src/platforms/instagram/commands/auth.ts +182 -11
  124. package/src/platforms/instagram/credential-manager.test.ts +30 -0
  125. package/src/platforms/instagram/credential-manager.ts +23 -1
  126. package/src/platforms/instagram/types.ts +11 -8
  127. package/src/platforms/teams/app-config.ts +92 -0
  128. package/src/platforms/teams/client.test.ts +335 -1
  129. package/src/platforms/teams/client.ts +342 -7
  130. package/src/platforms/teams/commands/auth.test.ts +93 -1
  131. package/src/platforms/teams/commands/auth.ts +182 -3
  132. package/src/platforms/teams/commands/file.test.ts +92 -1
  133. package/src/platforms/teams/commands/file.ts +76 -1
  134. package/src/platforms/teams/commands/message.test.ts +95 -1
  135. package/src/platforms/teams/commands/message.ts +145 -3
  136. package/src/platforms/teams/credential-manager.test.ts +20 -0
  137. package/src/platforms/teams/credential-manager.ts +68 -3
  138. package/src/platforms/teams/device-code.test.ts +311 -0
  139. package/src/platforms/teams/device-code.ts +277 -0
  140. package/src/platforms/teams/device-login.test.ts +288 -0
  141. package/src/platforms/teams/device-login.ts +206 -0
  142. package/src/platforms/teams/ensure-auth.ts +11 -0
  143. package/src/platforms/teams/index.test.ts +10 -0
  144. package/src/platforms/teams/index.ts +16 -1
  145. package/src/platforms/teams/listener.ts +295 -0
  146. package/src/platforms/teams/realm-discovery.test.ts +73 -0
  147. package/src/platforms/teams/realm-discovery.ts +42 -0
  148. package/src/platforms/teams/token-extractor.ts +69 -0
  149. package/src/platforms/teams/token-provider.test.ts +227 -0
  150. package/src/platforms/teams/token-provider.ts +284 -0
  151. package/src/platforms/teams/trouter.test.ts +114 -0
  152. package/src/platforms/teams/trouter.ts +228 -0
  153. package/src/platforms/teams/types.test.ts +17 -0
  154. package/src/platforms/teams/types.ts +86 -0
  155. package/src/tui/adapters/instagram-adapter.ts +13 -0
  156. package/src/tui/adapters/teams-adapter.ts +23 -0
  157. package/src/tui/app.ts +3 -3
@@ -9,24 +9,101 @@ import {
9
9
  extractMessageText,
10
10
  getMessageType,
11
11
  type InstagramChatSummary,
12
+ type InstagramDevice,
12
13
  type InstagramMessageSummary,
13
14
  type InstagramSessionState,
14
15
  } from './types'
15
16
 
16
17
  const IG_BASE_URL = 'https://i.instagram.com/api/v1'
17
18
  const IG_APP_ID = '567067343352427'
18
- const IG_VERSION = '312.1.0.34.111'
19
- const IG_VERSION_CODE = '556927984'
20
- const ANDROID_VERSION = '13.0'
21
- const ANDROID_RELEASE = '33'
19
+ const IG_VERSION = '428.0.0.47.67'
20
+ const IG_VERSION_CODE = '719358530'
21
+ // Bloks client versioning id; required header for Bloks endpoints (2FA / CAA flows).
22
+ const IG_BLOKS_VERSION_ID = '5f56efad68e1edec7801f630b5c122704ec5378adbee6609a448f105f34a9c73'
23
+ const IG_CAPABILITIES = '3brTv10='
24
+ const IG_LOCALE = 'en_US'
25
+ // Instagram silently holds the socket open (no response) for flagged IPs / fingerprints instead
26
+ // of returning an error, so an unbounded fetch hangs forever. Cap every request and surface a
27
+ // clear, actionable error instead of a frozen process.
28
+ const IG_REQUEST_TIMEOUT_MS = 30_000
29
+ const ANDROID_VERSION = '14'
30
+ const ANDROID_RELEASE = '34'
22
31
  const DEVICE_DPI = '480dpi'
23
- const DEVICE_RESOLUTION = '1080x2340'
24
- const DEVICE_MANUFACTURER = 'samsung'
25
- const DEVICE_MODEL = 'SM-S911B'
26
- const DEVICE_CHIPSET = 'qcom'
32
+ const DEVICE_RESOLUTION = '1344x2992'
33
+ const DEVICE_MANUFACTURER = 'Google/google'
34
+ const DEVICE_MODEL = 'Pixel 8 Pro'
35
+ const DEVICE_NAME = 'husky'
36
+ const DEVICE_CHIPSET = 'husky'
27
37
 
28
38
  export function generateDeviceString(): string {
29
- return `${ANDROID_VERSION}/${ANDROID_RELEASE}; ${DEVICE_DPI}; ${DEVICE_RESOLUTION}; ${DEVICE_MANUFACTURER}; ${DEVICE_MODEL}; ${DEVICE_MODEL}; ${DEVICE_CHIPSET}; en_US; ${IG_VERSION_CODE}`
39
+ return `${ANDROID_RELEASE}/${ANDROID_VERSION}; ${DEVICE_DPI}; ${DEVICE_RESOLUTION}; ${DEVICE_MANUFACTURER}; ${DEVICE_MODEL}; ${DEVICE_NAME}; ${DEVICE_CHIPSET}; en_US; ${IG_VERSION_CODE}`
40
+ }
41
+
42
+ // Instagram no longer verifies the body HMAC; the current mobile API expects a literal
43
+ // "SIGNATURE." prefix (per subzeroid/instagrapi, subzeroid/aiograpi). Sending a real HMAC +
44
+ // ig_sig_key_version is rejected as malformed on /accounts/login/, surfacing as a misleading
45
+ // "account not found" error. URLSearchParams url-encodes the value exactly once.
46
+ function signBody(payload: Record<string, unknown>): Record<string, string> {
47
+ return { signed_body: `SIGNATURE.${JSON.stringify(payload)}` }
48
+ }
49
+
50
+ // jazoest = "2" + sum of ASCII byte values of the input; an anti-bot checksum expected by login.
51
+ function createJazoest(input: string): string {
52
+ let sum = 0
53
+ for (let i = 0; i < input.length; i++) {
54
+ sum += input.charCodeAt(i)
55
+ }
56
+ return `2${sum}`
57
+ }
58
+
59
+ function generateToken(): string {
60
+ return randomBytes(16).toString('hex')
61
+ }
62
+
63
+ function isTimeoutAbort(error: unknown): boolean {
64
+ // AbortSignal.timeout aborts the connect AND the body stream, surfacing as TimeoutError on the
65
+ // fetch and AbortError when a stalled body read is aborted mid-stream; both mean "IG stalled".
66
+ return error instanceof DOMException && (error.name === 'TimeoutError' || error.name === 'AbortError')
67
+ }
68
+
69
+ function stalledError(path: string): InstagramError {
70
+ return new InstagramError(
71
+ `Instagram did not respond within ${IG_REQUEST_TIMEOUT_MS / 1000}s (${path}). The connection stalled, ` +
72
+ 'which usually means the IP is flagged (VPN/datacenter/proxy or prior automated attempts) or the ' +
73
+ 'device fingerprint was rejected. Try "agent-instagram auth extract" from a browser logged into ' +
74
+ 'instagram.com instead of password login.',
75
+ 'request_stalled',
76
+ )
77
+ }
78
+
79
+ interface TimedRequest {
80
+ response: Response
81
+ readText: () => Promise<string>
82
+ }
83
+
84
+ // One AbortSignal.timeout covers the whole request lifecycle: connect, headers, AND the body read.
85
+ // Reading the body through readText() keeps a post-header stall mapped to request_stalled instead
86
+ // of leaking out as a generic parse failure.
87
+ async function fetchWithTimeout(url: string, init: RequestInit, path: string): Promise<TimedRequest> {
88
+ const signal = AbortSignal.timeout(IG_REQUEST_TIMEOUT_MS)
89
+ let response: Response
90
+ try {
91
+ response = await fetch(url, { ...init, signal })
92
+ } catch (error) {
93
+ if (isTimeoutAbort(error)) throw stalledError(path)
94
+ throw error
95
+ }
96
+
97
+ const readText = async (): Promise<string> => {
98
+ try {
99
+ return await response.text()
100
+ } catch (error) {
101
+ if (isTimeoutAbort(error)) throw stalledError(path)
102
+ throw error
103
+ }
104
+ }
105
+
106
+ return { response, readText }
30
107
  }
31
108
 
32
109
  function buildUserAgent(deviceString: string): string {
@@ -37,6 +114,29 @@ export function generateAndroidDeviceId(): string {
37
114
  return `android-${randomBytes(8).toString('hex')}`
38
115
  }
39
116
 
117
+ export function generateDevice(): InstagramDevice {
118
+ return {
119
+ phone_id: randomUUID(),
120
+ uuid: randomUUID(),
121
+ android_device_id: generateAndroidDeviceId(),
122
+ advertising_id: randomUUID(),
123
+ client_session_id: randomUUID(),
124
+ device_string: generateDeviceString(),
125
+ }
126
+ }
127
+
128
+ export function parseOneClickLoginLink(input: string): { uid: string; token: string } | null {
129
+ const trimmed = input.trim()
130
+ let query = trimmed.includes('?') ? trimmed.slice(trimmed.indexOf('?') + 1) : trimmed
131
+ const hashIdx = query.indexOf('#')
132
+ if (hashIdx !== -1) query = query.slice(0, hashIdx)
133
+ const params = new URLSearchParams(query)
134
+ const uid = params.get('uid')
135
+ const token = params.get('token')
136
+ if (uid && token) return { uid, token }
137
+ return null
138
+ }
139
+
40
140
  // Instagram DM timestamps are in microseconds
41
141
  function microsecondsToISO(us: number): string {
42
142
  return new Date(us / 1000).toISOString()
@@ -48,14 +148,40 @@ export class InstagramClient {
48
148
  private sessionPath: string | null = null
49
149
  private userId: string | null = null
50
150
  private cookies: Map<string, string> = new Map()
151
+ private countryCode = '1'
51
152
 
52
153
  constructor(credentialManager?: InstagramCredentialManager) {
53
154
  this.credentialManager = credentialManager ?? new InstagramCredentialManager()
54
155
  }
55
156
 
157
+ setCountryCode(code: string): void {
158
+ this.countryCode = code
159
+ }
160
+
56
161
  async login(credentials?: { username: string; password: string }, accountId?: string): Promise<this> {
57
162
  if (credentials) {
58
- await this.authenticate(credentials.username, credentials.password)
163
+ const result = await this.authenticate(credentials.username, credentials.password)
164
+ if (!result.userId) {
165
+ if (result.requiresTwoFactor) {
166
+ throw new InstagramError(
167
+ 'Two-factor authentication required. Use the CLI (auth login/verify) to complete 2FA.',
168
+ 'two_factor_required',
169
+ )
170
+ }
171
+ if (result.challengeRequired) {
172
+ throw new InstagramError(
173
+ 'Instagram requires a security challenge. Use the CLI (auth login/challenge) to resolve it.',
174
+ 'challenge_required',
175
+ )
176
+ }
177
+ if (result.oneClickEmailAvailable) {
178
+ throw new InstagramError(
179
+ 'Password login was rejected; this account can log in by email. Use the CLI "auth login-email" to complete it.',
180
+ 'one_click_email_available',
181
+ )
182
+ }
183
+ throw new InstagramError('Login did not complete.', 'login_incomplete')
184
+ }
59
185
  return this
60
186
  }
61
187
 
@@ -100,42 +226,40 @@ export class InstagramClient {
100
226
  twoFactorInfo?: Record<string, unknown>
101
227
  challengeRequired?: boolean
102
228
  challengePath?: string
229
+ oneClickEmailAvailable?: boolean
103
230
  }> {
104
- const deviceString = generateDeviceString()
105
- const device = {
106
- phone_id: randomUUID(),
107
- uuid: randomUUID(),
108
- android_device_id: generateAndroidDeviceId(),
109
- advertising_id: randomUUID(),
110
- client_session_id: randomUUID(),
111
- device_string: deviceString,
112
- }
113
-
231
+ const device = await this.resolveDevice()
114
232
  this.session = { cookies: '', device }
115
233
 
116
234
  const encryptionKey = await this.preLoginFlow()
117
- let encPassword: string
118
- try {
119
- if (encryptionKey) {
120
- this.debugLog?.(`encrypting password with key_id=${encryptionKey.keyId}`)
121
- encPassword = this.encryptPassword(password, encryptionKey)
122
- } else {
123
- this.debugLog?.('no encryption key, using plaintext password format')
124
- encPassword = this.plaintextPassword(password)
125
- }
126
- } catch (err) {
127
- this.debugLog?.(`encryption failed: ${err}, falling back to plaintext`)
128
- encPassword = this.plaintextPassword(password)
235
+ if (!encryptionKey) {
236
+ throw new InstagramError(
237
+ 'Instagram did not return a password encryption key. Login cannot proceed safely.',
238
+ 'encryption_key_missing',
239
+ )
129
240
  }
130
241
 
131
- const { status, data } = await this.request('POST', '/accounts/login/', {
132
- username,
133
- enc_password: encPassword,
134
- guid: device.uuid,
135
- phone_id: device.phone_id,
136
- device_id: device.android_device_id,
137
- login_attempt_count: '0',
138
- })
242
+ this.debugLog?.(`encrypting password with key_id=${encryptionKey.keyId}`)
243
+ const encPassword = this.encryptPassword(password, encryptionKey)
244
+
245
+ const { status, data } = await this.request(
246
+ 'POST',
247
+ '/accounts/login/',
248
+ {
249
+ username,
250
+ enc_password: encPassword,
251
+ guid: device.uuid,
252
+ phone_id: device.phone_id,
253
+ _csrftoken: this.cookies.get('csrftoken') ?? generateToken(),
254
+ device_id: device.android_device_id,
255
+ adid: device.advertising_id,
256
+ google_tokens: '[]',
257
+ login_attempt_count: '0',
258
+ country_codes: JSON.stringify([{ country_code: this.countryCode, source: ['default'] }]),
259
+ jazoest: createJazoest(device.phone_id),
260
+ },
261
+ { signed: true },
262
+ )
139
263
 
140
264
  this.debugLog?.(`login response status=${status} body=${JSON.stringify(data)}`)
141
265
 
@@ -156,6 +280,25 @@ export class InstagramClient {
156
280
  return { userId: '', challengeRequired: true, challengePath: challengeApiPath }
157
281
  }
158
282
 
283
+ if (this.isFacebookLinkedLogin(data)) {
284
+ throw new InstagramError(
285
+ 'This account appears to sign in with Facebook and has no usable Instagram password for CLI login. ' +
286
+ 'Most reliable fix: log in to instagram.com in your browser, then run "agent-instagram auth extract". ' +
287
+ 'Alternatively, give the account its own password. Most reliably, unlink Facebook first: in the Instagram app go to ' +
288
+ 'Menu > Settings and privacy > Accounts Center > Manage accounts > (your Facebook profile) Manage > ' +
289
+ 'Move out of this Account Center > Move account > Continue; removing it prompts you to create an Instagram password. ' +
290
+ 'If Facebook is already unlinked, set one directly under ' +
291
+ 'Menu > Settings and privacy > Accounts Center > Password and security > Change password. ' +
292
+ 'Setting a password may enable "auth login", though Instagram can still reject automated logins from a new device.',
293
+ 'facebook_linked',
294
+ )
295
+ }
296
+
297
+ if (this.hasLoginButton(data, 'send_one_click_login_email')) {
298
+ this.session.cookies = this.serializeCookies()
299
+ return { userId: '', oneClickEmailAvailable: true }
300
+ }
301
+
159
302
  if (status !== 200 || data['status'] !== 'ok') {
160
303
  const message = (data['message'] as string) ?? 'Login failed'
161
304
  throw new InstagramError(message, errorType ?? 'login_failed')
@@ -166,14 +309,32 @@ export class InstagramClient {
166
309
  }
167
310
 
168
311
  async twoFactorLogin(username: string, code: string, twoFactorIdentifier: string): Promise<{ userId: string }> {
169
- const { status, data } = await this.request('POST', '/accounts/two_factor_login/', {
170
- username,
171
- verification_code: code,
172
- two_factor_identifier: twoFactorIdentifier,
173
- trust_this_device: '1',
174
- guid: this.session?.device.uuid ?? '',
175
- device_id: this.session?.device.android_device_id ?? '',
176
- })
312
+ const device = this.session?.device
313
+ const { status, data } = await this.request(
314
+ 'POST',
315
+ '/accounts/two_factor_login/',
316
+ {
317
+ username,
318
+ verification_code: code,
319
+ two_factor_identifier: twoFactorIdentifier,
320
+ trust_this_device: '1',
321
+ verification_method: '3',
322
+ _csrftoken: this.cookies.get('csrftoken') ?? generateToken(),
323
+ guid: device?.uuid ?? '',
324
+ phone_id: device?.phone_id ?? '',
325
+ device_id: device?.android_device_id ?? '',
326
+ },
327
+ { signed: true },
328
+ )
329
+
330
+ if (this.isBloksTwoFactorFallback(status, data)) {
331
+ this.debugLog?.('legacy two_factor_login rejected, retrying via Bloks flow')
332
+ const bloksContext =
333
+ typeof data['two_step_verification_context'] === 'string'
334
+ ? data['two_step_verification_context']
335
+ : twoFactorIdentifier
336
+ return this.bloksTwoFactorLogin(code, bloksContext)
337
+ }
177
338
 
178
339
  if (status !== 200 || data['status'] !== 'ok') {
179
340
  const message = (data['message'] as string) ?? 'Two-factor authentication failed'
@@ -184,6 +345,157 @@ export class InstagramClient {
184
345
  return { userId: this.userId ?? '' }
185
346
  }
186
347
 
348
+ // The login dialog's "send_one_click_login_email" button triggers /accounts/one_click_login/
349
+ // with auto_send=true (NOT /accounts/send_recovery_flow_email/, which is the generic password
350
+ // reset). It needs the account's numeric user id, resolved via a pre-login /users/lookup/.
351
+ async sendOneClickLoginEmail(username: string): Promise<{ sent: boolean; contactPoint: string }> {
352
+ const device = await this.ensureDeviceSession()
353
+ const uid = await this.lookupUserId(username)
354
+
355
+ this.debugLog?.(`one_click_login auto_send for uid=${uid}`)
356
+ const { status, data } = await this.request('POST', '/accounts/one_click_login/', {
357
+ uid,
358
+ source: 'one_click_login_email',
359
+ auto_send: 'true',
360
+ guid: device.uuid,
361
+ device_id: device.android_device_id,
362
+ adid: device.advertising_id,
363
+ })
364
+ this.debugLog?.(`one_click_login response status=${status} body=${JSON.stringify(data)}`)
365
+
366
+ if (status !== 200 || data['status'] === 'fail') {
367
+ const message = (data['message'] as string) ?? 'Failed to send login email'
368
+ throw new InstagramError(message, 'one_click_email_failed')
369
+ }
370
+
371
+ const contactPoint =
372
+ (data['obfuscated_email'] as string) ?? (data['email'] as string) ?? (data['contact_point'] as string) ?? ''
373
+ return { sent: true, contactPoint }
374
+ }
375
+
376
+ private async lookupUserId(username: string): Promise<string> {
377
+ const device = await this.ensureDeviceSession()
378
+
379
+ this.debugLog?.(`users/lookup for ${username}`)
380
+ const { status, data } = await this.request(
381
+ 'POST',
382
+ '/users/lookup/',
383
+ {
384
+ q: username,
385
+ directly_sign_in: 'true',
386
+ _csrftoken: this.cookies.get('csrftoken') ?? generateToken(),
387
+ guid: device.uuid,
388
+ device_id: device.android_device_id,
389
+ country_codes: JSON.stringify([{ country_code: this.countryCode, source: ['default'] }]),
390
+ },
391
+ { signed: true },
392
+ )
393
+ this.debugLog?.(`users/lookup response status=${status} body=${JSON.stringify(data)}`)
394
+
395
+ const userId = (data['user_id'] ?? data['uid'] ?? data['pk']) as string | number | undefined
396
+ if (userId == null || String(userId).length === 0) {
397
+ const message = (data['message'] as string) ?? `Could not find an Instagram account for "${username}".`
398
+ throw new InstagramError(message, 'user_lookup_failed')
399
+ }
400
+ return String(userId)
401
+ }
402
+
403
+ async oneClickLogin(uid: string, token: string, source = 'one_click_login_email'): Promise<{ userId: string }> {
404
+ const device = await this.ensureDeviceSession()
405
+
406
+ const { status, data } = await this.request('POST', '/accounts/one_click_login/', {
407
+ uid,
408
+ token,
409
+ source,
410
+ device_id: device.android_device_id,
411
+ guid: device.uuid,
412
+ adid: device.advertising_id,
413
+ })
414
+
415
+ if (status !== 200 || (data['status'] != null && data['status'] !== 'ok')) {
416
+ const message = (data['message'] as string) ?? 'One-click login failed'
417
+ throw new InstagramError(message, 'one_click_login_failed')
418
+ }
419
+
420
+ if (!data['logged_in_user']) {
421
+ throw new InstagramError('One-click login did not return a session', 'one_click_login_failed')
422
+ }
423
+
424
+ await this.finalizeLogin(data)
425
+ return { userId: this.userId ?? '' }
426
+ }
427
+
428
+ private async ensureDeviceSession(): Promise<InstagramDevice> {
429
+ if (!this.session) {
430
+ this.session = { cookies: '', device: await this.resolveDevice() }
431
+ }
432
+ return this.session.device
433
+ }
434
+
435
+ private isFacebookLinkedLogin(data: Record<string, unknown>): boolean {
436
+ return this.hasLoginButton(data, 'login_with_facebook')
437
+ }
438
+
439
+ private hasLoginButton(data: Record<string, unknown>, action: string): boolean {
440
+ const buttons = data['buttons']
441
+ if (!Array.isArray(buttons)) return false
442
+ return buttons.some((button) => (button as Record<string, unknown>)?.['action'] === action)
443
+ }
444
+
445
+ private isBloksTwoFactorFallback(status: number, data: Record<string, unknown>): boolean {
446
+ if (status === 200 && data['status'] === 'ok') return false
447
+ const message = ((data['message'] as string) ?? '').trim().toLowerCase()
448
+ return message === 'invalid parameters' || data['two_step_verification_context'] != null
449
+ }
450
+
451
+ private async bloksTwoFactorLogin(code: string, twoFactorIdentifier: string): Promise<{ userId: string }> {
452
+ const device = this.session?.device
453
+ const params = {
454
+ client_input_params: {
455
+ code,
456
+ should_trust_device: 1,
457
+ family_device_id: device?.phone_id ?? '',
458
+ device_id: device?.android_device_id ?? '',
459
+ machine_id: this.session?.mid ?? '',
460
+ },
461
+ server_params: {
462
+ challenge: 'totp',
463
+ two_step_verification_context: twoFactorIdentifier,
464
+ flow_source: 'two_factor_login',
465
+ },
466
+ }
467
+
468
+ const { status, data } = await this.request(
469
+ 'POST',
470
+ '/bloks/async_action/com.bloks.www.two_step_verification.verify_code.async/',
471
+ { params: JSON.stringify(params), bk_client_context: JSON.stringify({ bloks_version: IG_BLOKS_VERSION_ID }) },
472
+ )
473
+
474
+ if (status !== 200 || (data['status'] != null && data['status'] !== 'ok')) {
475
+ const message = (data['message'] as string) ?? 'Two-factor authentication failed'
476
+ throw new InstagramError(message, 'two_factor_failed')
477
+ }
478
+
479
+ const loggedInUser = data['logged_in_user'] as Record<string, unknown> | undefined
480
+ if (loggedInUser) {
481
+ await this.finalizeLogin(data)
482
+ return { userId: this.userId ?? '' }
483
+ }
484
+
485
+ if (this.session?.authorization) {
486
+ const userId = this.session.user_id ?? this.cookies.get('ds_user_id')
487
+ if (!userId) {
488
+ throw new InstagramError('Two-factor authentication failed', 'two_factor_failed')
489
+ }
490
+ this.userId = userId
491
+ this.session.user_id = userId
492
+ await this.saveSession()
493
+ return { userId }
494
+ }
495
+
496
+ throw new InstagramError('Two-factor authentication failed', 'two_factor_failed')
497
+ }
498
+
187
499
  async challengeSendCode(
188
500
  apiPath: string,
189
501
  method: 'email' | 'sms' = 'email',
@@ -450,16 +762,28 @@ export class InstagramClient {
450
762
  const url = `${IG_BASE_URL}/qe/sync/`
451
763
  const headers = this.buildHeaders()
452
764
 
453
- const response = await fetch(url, {
454
- method: 'POST',
455
- headers,
456
- body: new URLSearchParams({
457
- id: this.session!.device.uuid,
458
- experiments: 'ig_android_fci,ig_android_device_detection_info_upload,ig_android_device_verification_fb_signup',
459
- }).toString(),
460
- })
765
+ const { response } = await fetchWithTimeout(
766
+ url,
767
+ {
768
+ method: 'POST',
769
+ headers,
770
+ body: new URLSearchParams(
771
+ signBody({
772
+ id: this.session!.device.uuid,
773
+ experiments:
774
+ 'ig_android_fci,ig_android_device_detection_info_upload,ig_android_device_verification_fb_signup',
775
+ }),
776
+ ).toString(),
777
+ },
778
+ '/qe/sync/',
779
+ )
461
780
  this.extractResponseCookies(response.headers)
462
781
 
782
+ const wwwClaim = response.headers.get('x-ig-set-www-claim')
783
+ if (wwwClaim && this.session) {
784
+ this.session.www_claim = wwwClaim
785
+ }
786
+
463
787
  const pubKeyHeader = response.headers.get('ig-set-password-encryption-pub-key')
464
788
  const keyIdHeader = response.headers.get('ig-set-password-encryption-key-id')
465
789
 
@@ -487,8 +811,15 @@ export class InstagramClient {
487
811
  const pubKeyPem = Buffer.from(key.publicKey, 'base64').toString('utf-8')
488
812
  const rsaEncrypted = publicEncrypt({ key: pubKeyPem, padding: constants.RSA_PKCS1_PADDING }, aesKey)
489
813
 
814
+ if (!/^(?:0|[1-9]\d*)$/.test(key.keyId)) {
815
+ throw new InstagramError(`Invalid password encryption key id: ${key.keyId}`, 'encryption_key_invalid')
816
+ }
817
+ const keyIdNum = Number(key.keyId)
818
+ if (keyIdNum > 255) {
819
+ throw new InstagramError(`Invalid password encryption key id: ${key.keyId}`, 'encryption_key_invalid')
820
+ }
821
+
490
822
  // Wire format: version(1) | keyId(1) | iv(12) | rsaLen(2 LE) | rsaEncrypted | tag(16) | aesEncrypted
491
- const keyIdNum = Number.parseInt(key.keyId, 10)
492
823
  const buf = Buffer.alloc(1 + 1 + 12 + 2 + rsaEncrypted.length + 16 + encrypted.length)
493
824
  let offset = 0
494
825
  buf.writeUInt8(1, offset)
@@ -508,25 +839,22 @@ export class InstagramClient {
508
839
  return `#PWD_INSTAGRAM:4:${timestamp}:${buf.toString('base64')}`
509
840
  }
510
841
 
511
- private plaintextPassword(password: string): string {
512
- const timestamp = Math.floor(Date.now() / 1000).toString()
513
- return `#PWD_INSTAGRAM:0:${timestamp}:${password}`
514
- }
515
-
516
842
  private async request(
517
843
  method: string,
518
844
  path: string,
519
845
  body?: Record<string, string>,
846
+ options?: { signed?: boolean },
520
847
  ): Promise<{ status: number; data: Record<string, unknown> }> {
521
848
  const url = `${IG_BASE_URL}${path}`
522
849
  const headers = this.buildHeaders()
523
850
 
524
- const options: RequestInit = { method, headers }
851
+ const requestInit: RequestInit = { method, headers }
525
852
  if (body) {
526
- options.body = new URLSearchParams(body).toString()
853
+ const payload = options?.signed ? signBody(body) : body
854
+ requestInit.body = new URLSearchParams(payload).toString()
527
855
  }
528
856
 
529
- const response = await fetch(url, options)
857
+ const { response, readText } = await fetchWithTimeout(url, requestInit, path)
530
858
  this.extractResponseCookies(response.headers)
531
859
 
532
860
  const authHeader = response.headers.get('ig-set-authorization')
@@ -534,13 +862,27 @@ export class InstagramClient {
534
862
  this.session.authorization = authHeader
535
863
  }
536
864
 
865
+ const wwwClaim = response.headers.get('x-ig-set-www-claim')
866
+ if (wwwClaim && this.session) {
867
+ this.session.www_claim = wwwClaim
868
+ }
869
+
870
+ const rawBody = await readText()
871
+
537
872
  if (response.status === 429) {
538
- throw new InstagramError('Rate limited by Instagram. Try again later.', 'rate_limited')
873
+ this.debugLog?.(`429 from ${path} body=${rawBody}`)
874
+ const igMessage = this.extractJsonMessage(rawBody)
875
+ throw new InstagramError(
876
+ igMessage
877
+ ? `Rate limited by Instagram: ${igMessage} Wait before trying again.`
878
+ : 'Rate limited by Instagram. Try again later.',
879
+ 'rate_limited',
880
+ )
539
881
  }
540
882
 
541
883
  let data: Record<string, unknown>
542
884
  try {
543
- data = (await response.json()) as Record<string, unknown>
885
+ data = JSON.parse(rawBody) as Record<string, unknown>
544
886
  } catch {
545
887
  throw new InstagramError(`Failed to parse response from ${path}`, response.status)
546
888
  }
@@ -548,22 +890,51 @@ export class InstagramClient {
548
890
  return { status: response.status, data }
549
891
  }
550
892
 
893
+ private extractJsonMessage(body: string): string {
894
+ try {
895
+ const parsed = JSON.parse(body) as Record<string, unknown>
896
+ return (parsed['message'] as string) ?? ''
897
+ } catch {
898
+ return ''
899
+ }
900
+ }
901
+
551
902
  private buildHeaders(): Record<string, string> {
552
903
  const deviceString = this.session?.device.device_string ?? generateDeviceString()
904
+ // Instagram's edge silently drops connections whose fingerprint headers don't match a real
905
+ // Android client. This is a partial alignment toward the current app profile
906
+ // (per subzeroid/instagrapi) covering the highest-signal headers, not a full fingerprint match
907
+ // (e.g. X-Tigon-Is-Retry, X-Zero-*, X-IG-Nav-Chain, X-IG-SALT-IDS are intentionally omitted).
553
908
  const headers: Record<string, string> = {
554
909
  'User-Agent': buildUserAgent(deviceString),
910
+ 'X-IG-App-Locale': IG_LOCALE,
911
+ 'X-IG-Device-Locale': IG_LOCALE,
912
+ 'X-IG-Mapped-Locale': IG_LOCALE,
913
+ 'X-Pigeon-Session-Id': `UFS-${randomUUID()}-0`,
914
+ 'X-Pigeon-Rawclienttime': (Date.now() / 1000).toFixed(3),
915
+ 'X-IG-Bandwidth-Speed-KBPS': '-1.000',
916
+ 'X-IG-Bandwidth-TotalBytes-B': '0',
917
+ 'X-IG-Bandwidth-TotalTime-MS': '0',
918
+ 'X-IG-App-Startup-Country': 'US',
555
919
  'X-IG-App-ID': IG_APP_ID,
556
- 'X-IG-Capabilities': '3brTv10=',
920
+ 'X-IG-Capabilities': IG_CAPABILITIES,
557
921
  'X-IG-Connection-Type': 'WIFI',
558
922
  'X-IG-Timezone-Offset': String(new Date().getTimezoneOffset() * -60),
923
+ 'X-Bloks-Version-Id': IG_BLOKS_VERSION_ID,
924
+ 'X-FB-HTTP-Engine': 'Tigon/MNS/TCP',
925
+ Priority: 'u=3',
926
+ 'IG-INTENDED-USER-ID': this.userId ?? '0',
559
927
  'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
560
928
  Accept: '*/*',
561
929
  'Accept-Language': 'en-US,en;q=0.9',
562
930
  }
563
931
 
932
+ headers['X-IG-WWW-Claim'] = this.session?.www_claim ?? '0'
933
+
564
934
  if (this.session) {
565
935
  headers['X-IG-Device-ID'] = this.session.device.uuid
566
936
  headers['X-IG-Android-ID'] = this.session.device.android_device_id
937
+ headers['X-IG-Family-Device-ID'] = this.session.device.phone_id
567
938
  if (this.session.authorization) {
568
939
  headers['Authorization'] = this.session.authorization
569
940
  }
@@ -636,6 +1007,16 @@ export class InstagramClient {
636
1007
  await this.saveSession()
637
1008
  }
638
1009
 
1010
+ // Reuse one persisted device per machine so repeat logins present a consistent fingerprint.
1011
+ // Regenerating device ids each attempt looks like a brand-new phone and hurts login trust.
1012
+ private async resolveDevice(): Promise<InstagramDevice> {
1013
+ const existing = await this.credentialManager.loadDevice()
1014
+ if (existing) return existing
1015
+ const device = generateDevice()
1016
+ await this.credentialManager.saveDevice(device)
1017
+ return device
1018
+ }
1019
+
639
1020
  private async saveSession(): Promise<void> {
640
1021
  if (!this.session || !this.sessionPath) return
641
1022
  this.session.cookies = this.serializeCookies()