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
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, mock, spyOn, it } from 'bun:te
3
3
  const originalConsoleLog = console.log
4
4
  import type { Command } from 'commander'
5
5
 
6
+ import { InstagramClient } from '../client'
6
7
  import { InstagramCredentialManager } from '../credential-manager'
7
8
 
8
9
  const mockGetAccount = mock(() => Promise.resolve(null))
@@ -10,7 +11,7 @@ const mockListAccounts = mock(() => Promise.resolve([]))
10
11
  const mockSetCurrent = mock(() => Promise.resolve(true))
11
12
  const mockRemoveAccount = mock(() => Promise.resolve(true))
12
13
 
13
- import { authCommand } from './auth'
14
+ import { authCommand, sanitizePassword } from './auth'
14
15
 
15
16
  function resetCommandState(cmd: Command): void {
16
17
  for (const sub of cmd.commands) {
@@ -216,4 +217,179 @@ describe('auth commands', () => {
216
217
  expect(output.error).toContain('missing_account')
217
218
  })
218
219
  })
220
+
221
+ describe('sanitizePassword', () => {
222
+ it('preserves leading and trailing whitespace', () => {
223
+ expect(sanitizePassword(' pass word ')).toBe(' pass word ')
224
+ expect(sanitizePassword('\tpw\t')).toBe('\tpw\t')
225
+ })
226
+
227
+ it('preserves internal characters exactly', () => {
228
+ expect(sanitizePassword('p@$$ w0rd!#')).toBe('p@$$ w0rd!#')
229
+ })
230
+
231
+ it('strips only a trailing carriage return', () => {
232
+ expect(sanitizePassword('secret\r')).toBe('secret')
233
+ expect(sanitizePassword('sec\rret')).toBe('sec\rret')
234
+ })
235
+
236
+ it('returns undefined for empty input', () => {
237
+ expect(sanitizePassword('')).toBeUndefined()
238
+ expect(sanitizePassword('\r')).toBeUndefined()
239
+ })
240
+
241
+ it('keeps a whitespace-only password (not treated as empty)', () => {
242
+ expect(sanitizePassword(' ')).toBe(' ')
243
+ })
244
+ })
245
+
246
+ describe('login-email', () => {
247
+ it('redeems uid/token non-interactively and saves the account', async () => {
248
+ const oneClickSpy = spyOn(InstagramClient.prototype, 'oneClickLogin').mockResolvedValue({ userId: '4242' })
249
+ const setSessionPathSpy = spyOn(InstagramClient.prototype, 'setSessionPath').mockImplementation(() => {})
250
+ const ensurePathsSpy = spyOn(InstagramCredentialManager.prototype, 'ensureAccountPaths').mockResolvedValue({
251
+ account_dir: '/tmp/x',
252
+ session_path: '/tmp/x/session.json',
253
+ })
254
+ const setAccountSpy = spyOn(InstagramCredentialManager.prototype, 'setAccount').mockResolvedValue(undefined)
255
+
256
+ await authCommand.parseAsync(
257
+ ['login-email', '--username', 'alice', '--uid', 'ENCODED_UID', '--token', 'NONCE123', '--pretty'],
258
+ { from: 'user' },
259
+ )
260
+
261
+ expect(oneClickSpy).toHaveBeenCalledWith('ENCODED_UID', 'NONCE123')
262
+ expect(setAccountSpy).toHaveBeenCalled()
263
+ const output = JSON.parse(consoleLogSpy.mock.calls[0]![0] as string)
264
+ expect(output.authenticated).toBe(true)
265
+ expect(output.username).toBe('alice')
266
+
267
+ for (const spy of [oneClickSpy, setSessionPathSpy, ensurePathsSpy, setAccountSpy]) spy.mockRestore()
268
+ })
269
+
270
+ it('parses uid/token from a pasted --link', async () => {
271
+ const oneClickSpy = spyOn(InstagramClient.prototype, 'oneClickLogin').mockResolvedValue({ userId: '7' })
272
+ const setSessionPathSpy = spyOn(InstagramClient.prototype, 'setSessionPath').mockImplementation(() => {})
273
+ const ensurePathsSpy = spyOn(InstagramCredentialManager.prototype, 'ensureAccountPaths').mockResolvedValue({
274
+ account_dir: '/tmp/x',
275
+ session_path: '/tmp/x/session.json',
276
+ })
277
+ const setAccountSpy = spyOn(InstagramCredentialManager.prototype, 'setAccount').mockResolvedValue(undefined)
278
+
279
+ await authCommand.parseAsync(
280
+ [
281
+ 'login-email',
282
+ '--username',
283
+ 'bob',
284
+ '--link',
285
+ 'https://www.instagram.com/_n/web_emaillogin?uid=U1&token=T1&auto_send=0',
286
+ ],
287
+ { from: 'user' },
288
+ )
289
+
290
+ expect(oneClickSpy).toHaveBeenCalledWith('U1', 'T1')
291
+
292
+ for (const spy of [oneClickSpy, setSessionPathSpy, ensurePathsSpy, setAccountSpy]) spy.mockRestore()
293
+ })
294
+
295
+ it('errors on an invalid --link instead of sending a new email', async () => {
296
+ const oneClickSpy = spyOn(InstagramClient.prototype, 'oneClickLogin').mockResolvedValue({ userId: '1' })
297
+ const sendEmailSpy = spyOn(InstagramClient.prototype, 'sendOneClickLoginEmail').mockResolvedValue({
298
+ sent: true,
299
+ contactPoint: '',
300
+ })
301
+
302
+ await expect(
303
+ authCommand.parseAsync(['login-email', '--username', 'alice', '--link', 'not-a-link', '--pretty'], {
304
+ from: 'user',
305
+ }),
306
+ ).rejects.toThrow('process.exit called')
307
+
308
+ expect(sendEmailSpy).not.toHaveBeenCalled()
309
+ expect(oneClickSpy).not.toHaveBeenCalled()
310
+ const output = JSON.parse(consoleLogSpy.mock.calls[0]![0] as string)
311
+ expect(output.error).toContain('Invalid login link')
312
+
313
+ for (const spy of [oneClickSpy, sendEmailSpy]) spy.mockRestore()
314
+ })
315
+
316
+ it('errors when only --uid is provided without --token', async () => {
317
+ const sendEmailSpy = spyOn(InstagramClient.prototype, 'sendOneClickLoginEmail').mockResolvedValue({
318
+ sent: true,
319
+ contactPoint: '',
320
+ })
321
+
322
+ await expect(
323
+ authCommand.parseAsync(['login-email', '--username', 'alice', '--uid', 'U1', '--pretty'], { from: 'user' }),
324
+ ).rejects.toThrow('process.exit called')
325
+
326
+ expect(sendEmailSpy).not.toHaveBeenCalled()
327
+ const output = JSON.parse(consoleLogSpy.mock.calls[0]![0] as string)
328
+ expect(output.error).toContain('Invalid login link')
329
+
330
+ sendEmailSpy.mockRestore()
331
+ })
332
+
333
+ it('treats an explicit empty --link as redeem intent and fails closed', async () => {
334
+ const oneClickSpy = spyOn(InstagramClient.prototype, 'oneClickLogin').mockResolvedValue({ userId: '1' })
335
+ const sendEmailSpy = spyOn(InstagramClient.prototype, 'sendOneClickLoginEmail').mockResolvedValue({
336
+ sent: true,
337
+ contactPoint: '',
338
+ })
339
+
340
+ await expect(
341
+ authCommand.parseAsync(['login-email', '--username', 'alice', '--link', '', '--pretty'], { from: 'user' }),
342
+ ).rejects.toThrow('process.exit called')
343
+
344
+ expect(sendEmailSpy).not.toHaveBeenCalled()
345
+ expect(oneClickSpy).not.toHaveBeenCalled()
346
+ const output = JSON.parse(consoleLogSpy.mock.calls[0]![0] as string)
347
+ expect(output.error).toContain('Invalid login link')
348
+
349
+ for (const spy of [oneClickSpy, sendEmailSpy]) spy.mockRestore()
350
+ })
351
+
352
+ it('requires --username when redeeming non-interactively', async () => {
353
+ const oneClickSpy = spyOn(InstagramClient.prototype, 'oneClickLogin').mockResolvedValue({ userId: '1' })
354
+
355
+ await expect(
356
+ authCommand.parseAsync(['login-email', '--uid', 'U1', '--token', 'T1', '--pretty'], { from: 'user' }),
357
+ ).rejects.toThrow('process.exit called')
358
+
359
+ expect(oneClickSpy).not.toHaveBeenCalled()
360
+ const output = JSON.parse(consoleLogSpy.mock.calls[0]![0] as string)
361
+ expect(output.error).toContain('--username is required')
362
+
363
+ oneClickSpy.mockRestore()
364
+ })
365
+ })
366
+
367
+ describe('login email fallback', () => {
368
+ it('non-interactively sends the login email when password login offers it', async () => {
369
+ const authSpy = spyOn(InstagramClient.prototype, 'authenticate').mockResolvedValue({
370
+ userId: '',
371
+ oneClickEmailAvailable: true,
372
+ })
373
+ const sendEmailSpy = spyOn(InstagramClient.prototype, 'sendOneClickLoginEmail').mockResolvedValue({
374
+ sent: true,
375
+ contactPoint: 'j***@example.com',
376
+ })
377
+ const ensurePathsSpy = spyOn(InstagramCredentialManager.prototype, 'ensureAccountPaths').mockResolvedValue({
378
+ account_dir: '/tmp/x',
379
+ session_path: '/tmp/x/session.json',
380
+ })
381
+
382
+ await authCommand.parseAsync(['login', '--username', 'alice', '--password', 'wrong', '--pretty'], {
383
+ from: 'user',
384
+ })
385
+
386
+ expect(sendEmailSpy).toHaveBeenCalledWith('alice')
387
+ const output = JSON.parse(consoleLogSpy.mock.calls[0]![0] as string)
388
+ expect(output.one_click_email_available).toBe(true)
389
+ expect(output.email_sent).toBe(true)
390
+ expect(output.contact_point).toBe('j***@example.com')
391
+
392
+ for (const spy of [authSpy, sendEmailSpy, ensurePathsSpy]) spy.mockRestore()
393
+ })
394
+ })
219
395
  })
@@ -1,4 +1,3 @@
1
- import { randomUUID } from 'node:crypto'
2
1
  import { mkdir, writeFile } from 'node:fs/promises'
3
2
  import { Writable } from 'node:stream'
4
3
 
@@ -10,7 +9,7 @@ import { isInteractive } from '@/shared/utils/interactive'
10
9
  import { formatOutput } from '@/shared/utils/output'
11
10
  import { info, warn, error as stderrError, debug } from '@/shared/utils/stderr'
12
11
 
13
- import { InstagramClient, generateAndroidDeviceId, generateDeviceString } from '../client'
12
+ import { InstagramClient, generateDevice, parseOneClickLoginLink } from '../client'
14
13
  import { InstagramCredentialManager } from '../credential-manager'
15
14
  import { InstagramTokenExtractor } from '../token-extractor'
16
15
  import { createAccountId } from '../types'
@@ -26,6 +25,13 @@ async function promptText(message: string): Promise<string | undefined> {
26
25
  }
27
26
  }
28
27
 
28
+ // Never trim passwords: they may contain leading/trailing spaces. Trimming corrupts them and
29
+ // Instagram rejects the login as bad_password. Only strip the trailing CR some terminals add.
30
+ export function sanitizePassword(raw: string): string | undefined {
31
+ const password = raw.replace(/\r$/, '')
32
+ return password.length > 0 ? password : undefined
33
+ }
34
+
29
35
  async function promptHidden(message: string): Promise<string | undefined> {
30
36
  const { createInterface } = await import('node:readline/promises')
31
37
  const hiddenOutput = new (class extends Writable {
@@ -41,7 +47,7 @@ async function promptHidden(message: string): Promise<string | undefined> {
41
47
  process.stdout.write(`${message}: `)
42
48
  const answer = await rl.question('')
43
49
  process.stdout.write('\n')
44
- return answer.trim() || undefined
50
+ return sanitizePassword(answer)
45
51
  } finally {
46
52
  hiddenOutput.muted = false
47
53
  rl.close()
@@ -187,12 +193,167 @@ async function loginAction(options: LoginOptions): Promise<void> {
187
193
  return
188
194
  }
189
195
 
196
+ if (result.oneClickEmailAvailable) {
197
+ if (interactive) {
198
+ await runEmailLogin(manager, username, options, client)
199
+ } else {
200
+ const { contactPoint } = await client.sendOneClickLoginEmail(username)
201
+ console.log(
202
+ formatOutput(
203
+ {
204
+ one_click_email_available: true,
205
+ email_sent: true,
206
+ contact_point: contactPoint,
207
+ message:
208
+ 'Password login was rejected, but this account can log in by email. A login email was sent. Open the "Login as <username>" link and run "agent-instagram auth login-email --username <username> --link <url>" to finish.',
209
+ },
210
+ options.pretty,
211
+ ),
212
+ )
213
+ }
214
+ return
215
+ }
216
+
190
217
  await saveAccountAndPrint(manager, accountId, username, result.userId, options.pretty)
191
218
  } catch (error) {
192
219
  handleError(error as Error)
193
220
  }
194
221
  }
195
222
 
223
+ interface LoginEmailOptions {
224
+ username?: string
225
+ link?: string
226
+ uid?: string
227
+ token?: string
228
+ pretty?: boolean
229
+ debug?: boolean
230
+ }
231
+
232
+ async function loginEmailAction(options: LoginEmailOptions): Promise<void> {
233
+ try {
234
+ const interactive = isInteractive()
235
+ const manager = new InstagramCredentialManager()
236
+
237
+ // Presence (not truthiness) signals redeem intent: an explicit empty --link "" must still
238
+ // fail closed here rather than falling through to the send-email branch and re-emailing.
239
+ const redeemAttempted = options.link !== undefined || options.uid !== undefined || options.token !== undefined
240
+ if (redeemAttempted) {
241
+ if (!options.username) {
242
+ console.log(
243
+ formatOutput({ error: '--username is required when redeeming a login email link.' }, options.pretty),
244
+ )
245
+ process.exit(1)
246
+ }
247
+ const redeem = resolveOneClickCredentials(options)
248
+ if (!redeem) {
249
+ console.log(
250
+ formatOutput(
251
+ { error: 'Invalid login link. Provide a valid --link <url>, or both --uid and --token.' },
252
+ options.pretty,
253
+ ),
254
+ )
255
+ process.exit(1)
256
+ }
257
+ await redeemLoginEmail(manager, redeem.uid, redeem.token, options.username, options)
258
+ return
259
+ }
260
+
261
+ let username = options.username
262
+ if (!username) {
263
+ if (!interactive) {
264
+ console.log(
265
+ formatOutput(
266
+ {
267
+ error:
268
+ 'Username required. Use --username <username> to send the login email, then --link <url> (or --uid and --token) to complete.',
269
+ },
270
+ options.pretty,
271
+ ),
272
+ )
273
+ process.exit(1)
274
+ }
275
+ username = await promptText('Instagram username')
276
+ if (!username) {
277
+ stderrError('Username is required.')
278
+ process.exit(1)
279
+ }
280
+ }
281
+
282
+ const client = new InstagramClient(manager)
283
+ if (options.debug) client.setDebugLog((msg) => debug(`[debug] ${msg}`))
284
+
285
+ if (!interactive) {
286
+ const { contactPoint } = await client.sendOneClickLoginEmail(username)
287
+ console.log(
288
+ formatOutput(
289
+ {
290
+ email_sent: true,
291
+ contact_point: contactPoint,
292
+ message:
293
+ 'Login email sent. Open the "Login as <username>" link and run "agent-instagram auth login-email --username <username> --link <url>" to finish.',
294
+ },
295
+ options.pretty,
296
+ ),
297
+ )
298
+ return
299
+ }
300
+
301
+ await runEmailLogin(manager, username, options, client)
302
+ } catch (error) {
303
+ handleError(error as Error)
304
+ }
305
+ }
306
+
307
+ async function runEmailLogin(
308
+ manager: InstagramCredentialManager,
309
+ username: string,
310
+ options: LoginEmailOptions,
311
+ client: InstagramClient,
312
+ ): Promise<void> {
313
+ const { contactPoint } = await client.sendOneClickLoginEmail(username)
314
+
315
+ info(contactPoint ? `\n Login email sent to: ${contactPoint}` : '\n Login email sent.')
316
+ info(' Open the email, copy the "Login as ..." link, and paste it here.')
317
+ const link = await promptText('Login link')
318
+ if (!link) {
319
+ stderrError('Login link is required.')
320
+ process.exit(1)
321
+ }
322
+
323
+ const parsed = parseOneClickLoginLink(link)
324
+ if (!parsed) {
325
+ stderrError('Could not read uid and token from the pasted link.')
326
+ process.exit(1)
327
+ }
328
+
329
+ await redeemLoginEmail(manager, parsed.uid, parsed.token, username, options, client)
330
+ }
331
+
332
+ function resolveOneClickCredentials(options: LoginEmailOptions): { uid: string; token: string } | null {
333
+ if (options.link) return parseOneClickLoginLink(options.link)
334
+ if (options.uid && options.token) return { uid: options.uid, token: options.token }
335
+ return null
336
+ }
337
+
338
+ async function redeemLoginEmail(
339
+ manager: InstagramCredentialManager,
340
+ uid: string,
341
+ token: string,
342
+ username: string,
343
+ options: LoginEmailOptions,
344
+ existingClient?: InstagramClient,
345
+ ): Promise<void> {
346
+ const client = existingClient ?? new InstagramClient(manager)
347
+ if (!existingClient && options.debug) client.setDebugLog((msg) => debug(`[debug] ${msg}`))
348
+
349
+ const accountId = createAccountId(username)
350
+ const paths = await manager.ensureAccountPaths(accountId)
351
+ client.setSessionPath(paths.session_path)
352
+
353
+ const result = await client.oneClickLogin(uid, token)
354
+ await saveAccountAndPrint(manager, accountId, username, result.userId, options.pretty)
355
+ }
356
+
196
357
  async function handleInteractiveChallenge(
197
358
  client: InstagramClient,
198
359
  manager: InstagramCredentialManager,
@@ -414,6 +575,12 @@ async function extractAction(options: { pretty?: boolean; debug?: boolean; brows
414
575
 
415
576
  const manager = new InstagramCredentialManager()
416
577
 
578
+ let device = await manager.loadDevice()
579
+ if (!device) {
580
+ device = generateDevice()
581
+ await manager.saveDevice(device)
582
+ }
583
+
417
584
  for (const extracted of results) {
418
585
  const session = {
419
586
  cookies: [
@@ -426,14 +593,7 @@ async function extractAction(options: { pretty?: boolean; debug?: boolean; brows
426
593
  ]
427
594
  .filter(Boolean)
428
595
  .join('; '),
429
- device: {
430
- phone_id: randomUUID(),
431
- uuid: randomUUID(),
432
- android_device_id: generateAndroidDeviceId(),
433
- advertising_id: randomUUID(),
434
- client_session_id: randomUUID(),
435
- device_string: generateDeviceString(),
436
- },
596
+ device,
437
597
  user_id: extracted.ds_user_id,
438
598
  mid: extracted.mid,
439
599
  }
@@ -530,6 +690,17 @@ export const authCommand = new Command('auth')
530
690
  .option('--debug', 'Show raw API responses')
531
691
  .action(loginAction),
532
692
  )
693
+ .addCommand(
694
+ new Command('login-email')
695
+ .description('Log in via a one-click login email link (no password needed)')
696
+ .option('--username <username>', 'Instagram username (sends the login email)')
697
+ .option('--link <url>', 'The "Login as <username>" URL from the email')
698
+ .option('--uid <uid>', 'uid from the email link (alternative to --link)')
699
+ .option('--token <token>', 'token from the email link (alternative to --link)')
700
+ .option('--pretty', 'Pretty print JSON output')
701
+ .option('--debug', 'Show raw API responses')
702
+ .action(loginEmailAction),
703
+ )
533
704
  .addCommand(
534
705
  new Command('extract')
535
706
  .description('Extract Instagram cookies from browser (Chrome, Edge, Arc, Brave)')
@@ -274,4 +274,34 @@ describe('InstagramCredentialManager', () => {
274
274
  expect(paths.session_path).toBe(join(testConfigDir, 'instagram', 'my-account', 'session.json'))
275
275
  })
276
276
  })
277
+
278
+ describe('device persistence', () => {
279
+ const device = {
280
+ phone_id: 'phone-1',
281
+ uuid: 'uuid-1',
282
+ android_device_id: 'android-abcdef0123456789',
283
+ advertising_id: 'adid-1',
284
+ client_session_id: 'csid-1',
285
+ device_string: '34/14; 480dpi; 1344x2992; Google/google; Pixel 8 Pro; husky; husky; en_US; 719358530',
286
+ }
287
+
288
+ it('returns null when no device is stored', async () => {
289
+ const manager = setup()
290
+ expect(await manager.loadDevice()).toBeNull()
291
+ })
292
+
293
+ it('persists and reloads the same device', async () => {
294
+ const manager = setup()
295
+ await manager.saveDevice(device)
296
+ expect(await manager.loadDevice()).toEqual(device)
297
+ })
298
+
299
+ it('shares one device across accounts (machine-level, not per-account)', async () => {
300
+ const manager = setup()
301
+ await manager.saveDevice(device)
302
+ await manager.setAccount(makeAccount({ account_id: 'a' }))
303
+ await manager.setAccount(makeAccount({ account_id: 'b' }))
304
+ expect(await manager.loadDevice()).toEqual(device)
305
+ })
306
+ })
277
307
  })
@@ -3,17 +3,39 @@ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
3
3
  import { join } from 'node:path'
4
4
 
5
5
  import { getConfigDir } from '../../shared/utils/config-dir'
6
- import { createAccountId, type InstagramAccount, type InstagramAccountPaths, type InstagramConfig } from './types'
6
+ import {
7
+ createAccountId,
8
+ type InstagramAccount,
9
+ type InstagramAccountPaths,
10
+ type InstagramConfig,
11
+ type InstagramDevice,
12
+ } from './types'
7
13
 
8
14
  export class InstagramCredentialManager {
9
15
  private configDir: string
10
16
  private credentialsPath: string
11
17
  private instagramRootDir: string
18
+ private devicePath: string
12
19
 
13
20
  constructor(configDir?: string) {
14
21
  this.configDir = configDir ?? getConfigDir()
15
22
  this.credentialsPath = join(this.configDir, 'instagram-credentials.json')
16
23
  this.instagramRootDir = join(this.configDir, 'instagram')
24
+ this.devicePath = join(this.instagramRootDir, 'device.json')
25
+ }
26
+
27
+ async loadDevice(): Promise<InstagramDevice | null> {
28
+ if (!existsSync(this.devicePath)) return null
29
+ try {
30
+ return JSON.parse(await readFile(this.devicePath, 'utf-8')) as InstagramDevice
31
+ } catch {
32
+ return null
33
+ }
34
+ }
35
+
36
+ async saveDevice(device: InstagramDevice): Promise<void> {
37
+ await mkdir(this.instagramRootDir, { recursive: true })
38
+ await writeFile(this.devicePath, JSON.stringify(device, null, 2), { mode: 0o600 })
17
39
  }
18
40
 
19
41
  async loadConfig(): Promise<InstagramConfig> {
@@ -60,19 +60,22 @@ export interface InstagramMessageSummary {
60
60
  media_url?: string
61
61
  }
62
62
 
63
+ export interface InstagramDevice {
64
+ phone_id: string
65
+ uuid: string
66
+ android_device_id: string
67
+ advertising_id: string
68
+ client_session_id: string
69
+ device_string: string
70
+ }
71
+
63
72
  export interface InstagramSessionState {
64
73
  cookies: string
65
- device: {
66
- phone_id: string
67
- uuid: string
68
- android_device_id: string
69
- advertising_id: string
70
- client_session_id: string
71
- device_string: string
72
- }
74
+ device: InstagramDevice
73
75
  authorization?: string
74
76
  user_id?: string
75
77
  mid?: string
78
+ www_claim?: string
76
79
  challenge_path?: string
77
80
  }
78
81
 
@@ -0,0 +1,92 @@
1
+ import type { TeamsAccountType } from './types'
2
+
3
+ export const TEAMS_DESKTOP_CLIENT_ID = '1fec8e78-bce4-4aaf-ab1b-5451cc387264'
4
+ export const TEAMS_WEB_CLIENT_ID = '5e3ce6c0-2b1f-4285-8d4b-75ee78787346'
5
+
6
+ export const CONSUMER_TENANT_ID = '9188040d-6c67-4c5b-b112-36a304b66dad'
7
+ // The Microsoft Services (MSA passthrough) tenant. Consumer/MSA tokens for the
8
+ // Skype resource carry this `tid`, so it is a placeholder that still requires
9
+ // tenant discovery before the skype-token exchange.
10
+ export const MICROSOFT_SERVICES_TENANT_ID = 'f8cdef31-a31e-4b4a-93e4-5f571e91255a'
11
+ export const WORK_TENANT_ID = 'organizations'
12
+
13
+ export const DEVICE_CODE_SCOPE_TEAMS = 'service::api.fl.teams.microsoft.com::MBI_SSL openid profile offline_access'
14
+ export const DEVICE_CODE_SCOPE_SKYPE = 'service::api.fl.spaces.skype.com::MBI_SSL openid profile offline_access'
15
+ export const WORK_DEVICE_CODE_SCOPE_TEAMS = 'https://api.spaces.skype.com/.default openid profile offline_access'
16
+ export const WORK_DEVICE_CODE_SCOPE_SKYPE = 'https://api.spaces.skype.com/.default openid profile offline_access'
17
+
18
+ export const SUBSTRATE_SEARCH_URL = 'https://substrate.office.com/searchservice/api/v2/query'
19
+ export const TEAMS_WEB_ORIGIN = 'https://teams.microsoft.com'
20
+ export const AAD_SCOPE_SUBSTRATE = 'https://substrate.office.com/.default offline_access'
21
+ export const AAD_SCOPE_GRAPH = 'https://graph.microsoft.com/.default offline_access'
22
+ export const AAD_AUDIENCE_SUBSTRATE = 'https://substrate.office.com'
23
+ export const AAD_AUDIENCE_GRAPH = 'https://graph.microsoft.com'
24
+
25
+ export const AUTHZ_CONSUMER_URL = 'https://teams.live.com/api/auth/v1.0/authz/consumer'
26
+ export const AUTHZ_WORK_URL = 'https://teams.microsoft.com/api/authsvc/v1.0/authz'
27
+
28
+ // Control-plane tenant-discovery endpoint. The region segment is not
29
+ // authoritative for this call (it lists every tenant the user belongs to
30
+ // regardless of home region), so we pin the value fossteams uses.
31
+ export const TEAMS_TENANTS_URL = 'https://teams.microsoft.com/api/mt/emea/beta/users/tenants'
32
+
33
+ export const GET_CREDENTIAL_TYPE_URL = 'https://login.microsoftonline.com/common/GetCredentialType'
34
+
35
+ export function consumerDeviceCodeUrl(): string {
36
+ return `https://login.microsoftonline.com/${CONSUMER_TENANT_ID}/oauth2/v2.0/devicecode`
37
+ }
38
+
39
+ export function consumerTokenUrl(): string {
40
+ return `https://login.microsoftonline.com/${CONSUMER_TENANT_ID}/oauth2/v2.0/token`
41
+ }
42
+
43
+ export function organizationsDeviceCodeUrl(): string {
44
+ return `https://login.microsoftonline.com/${WORK_TENANT_ID}/oauth2/v2.0/devicecode`
45
+ }
46
+
47
+ export function organizationsTokenUrl(): string {
48
+ return `https://login.microsoftonline.com/${WORK_TENANT_ID}/oauth2/v2.0/token`
49
+ }
50
+
51
+ export function tenantTokenUrl(tenantId: string): string {
52
+ return `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`
53
+ }
54
+
55
+ export function deviceCodeUrl(accountType: TeamsAccountType): string {
56
+ return accountType === 'personal' ? consumerDeviceCodeUrl() : organizationsDeviceCodeUrl()
57
+ }
58
+
59
+ export function deviceCodeTokenUrl(accountType: TeamsAccountType, tenantId?: string): string {
60
+ if (tenantId) return tenantTokenUrl(tenantId)
61
+ return accountType === 'personal' ? consumerTokenUrl() : organizationsTokenUrl()
62
+ }
63
+
64
+ export function deviceCodeTeamsScope(accountType: TeamsAccountType): string {
65
+ return accountType === 'personal' ? DEVICE_CODE_SCOPE_TEAMS : WORK_DEVICE_CODE_SCOPE_TEAMS
66
+ }
67
+
68
+ export function deviceCodeSkypeScope(accountType: TeamsAccountType): string {
69
+ return accountType === 'personal' ? DEVICE_CODE_SCOPE_SKYPE : WORK_DEVICE_CODE_SCOPE_SKYPE
70
+ }
71
+
72
+ export function authzUrl(accountType: TeamsAccountType): string {
73
+ return accountType === 'personal' ? AUTHZ_CONSUMER_URL : AUTHZ_WORK_URL
74
+ }
75
+
76
+ export interface TeamsAppClient {
77
+ clientId: string
78
+ source: 'env' | 'builtin'
79
+ }
80
+
81
+ function parseTrimmed(value: string | undefined): string | undefined {
82
+ const normalized = value?.trim()
83
+ return normalized ? normalized : undefined
84
+ }
85
+
86
+ export function getTeamsAppClientId(accountType: TeamsAccountType = 'personal'): TeamsAppClient {
87
+ const envClientId = parseTrimmed(process.env.AGENT_TEAMS_CLIENT_ID)
88
+ if (envClientId) {
89
+ return { clientId: envClientId, source: 'env' }
90
+ }
91
+ return { clientId: accountType === 'work' ? TEAMS_DESKTOP_CLIENT_ID : TEAMS_WEB_CLIENT_ID, source: 'builtin' }
92
+ }