agent-messenger 2.28.0 → 2.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +18 -1
- package/dist/package.json +1 -1
- package/dist/src/platforms/discord/client.d.ts +6 -2
- package/dist/src/platforms/discord/client.d.ts.map +1 -1
- package/dist/src/platforms/discord/client.js +10 -3
- package/dist/src/platforms/discord/client.js.map +1 -1
- package/dist/src/platforms/instagram/client.d.ts +24 -2
- package/dist/src/platforms/instagram/client.d.ts.map +1 -1
- package/dist/src/platforms/instagram/client.js +343 -53
- package/dist/src/platforms/instagram/client.js.map +1 -1
- package/dist/src/platforms/instagram/commands/auth.d.ts +1 -0
- package/dist/src/platforms/instagram/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/instagram/commands/auth.js +123 -11
- package/dist/src/platforms/instagram/commands/auth.js.map +1 -1
- package/dist/src/platforms/instagram/credential-manager.d.ts +4 -1
- package/dist/src/platforms/instagram/credential-manager.d.ts.map +1 -1
- package/dist/src/platforms/instagram/credential-manager.js +17 -1
- package/dist/src/platforms/instagram/credential-manager.js.map +1 -1
- package/dist/src/platforms/instagram/types.d.ts +10 -8
- package/dist/src/platforms/instagram/types.d.ts.map +1 -1
- package/dist/src/platforms/instagram/types.js.map +1 -1
- package/dist/src/platforms/teams/app-config.d.ts +36 -0
- package/dist/src/platforms/teams/app-config.d.ts.map +1 -0
- package/dist/src/platforms/teams/app-config.js +69 -0
- package/dist/src/platforms/teams/app-config.js.map +1 -0
- package/dist/src/platforms/teams/client.d.ts +19 -2
- package/dist/src/platforms/teams/client.d.ts.map +1 -1
- package/dist/src/platforms/teams/client.js +295 -7
- package/dist/src/platforms/teams/client.js.map +1 -1
- package/dist/src/platforms/teams/commands/auth.d.ts +10 -0
- package/dist/src/platforms/teams/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/teams/commands/auth.js +137 -3
- package/dist/src/platforms/teams/commands/auth.js.map +1 -1
- package/dist/src/platforms/teams/commands/file.d.ts +3 -0
- package/dist/src/platforms/teams/commands/file.d.ts.map +1 -1
- package/dist/src/platforms/teams/commands/file.js +59 -2
- package/dist/src/platforms/teams/commands/file.js.map +1 -1
- package/dist/src/platforms/teams/commands/message.d.ts +11 -0
- package/dist/src/platforms/teams/commands/message.d.ts.map +1 -1
- package/dist/src/platforms/teams/commands/message.js +121 -2
- package/dist/src/platforms/teams/commands/message.js.map +1 -1
- package/dist/src/platforms/teams/credential-manager.d.ts +23 -1
- package/dist/src/platforms/teams/credential-manager.d.ts.map +1 -1
- package/dist/src/platforms/teams/credential-manager.js +46 -2
- package/dist/src/platforms/teams/credential-manager.js.map +1 -1
- package/dist/src/platforms/teams/device-code.d.ts +48 -0
- package/dist/src/platforms/teams/device-code.d.ts.map +1 -0
- package/dist/src/platforms/teams/device-code.js +199 -0
- package/dist/src/platforms/teams/device-code.js.map +1 -0
- package/dist/src/platforms/teams/device-login.d.ts +37 -0
- package/dist/src/platforms/teams/device-login.d.ts.map +1 -0
- package/dist/src/platforms/teams/device-login.js +129 -0
- package/dist/src/platforms/teams/device-login.js.map +1 -0
- package/dist/src/platforms/teams/ensure-auth.d.ts.map +1 -1
- package/dist/src/platforms/teams/ensure-auth.js +12 -0
- package/dist/src/platforms/teams/ensure-auth.js.map +1 -1
- package/dist/src/platforms/teams/index.d.ts +7 -3
- package/dist/src/platforms/teams/index.d.ts.map +1 -1
- package/dist/src/platforms/teams/index.js +5 -2
- package/dist/src/platforms/teams/index.js.map +1 -1
- package/dist/src/platforms/teams/listener.d.ts +35 -0
- package/dist/src/platforms/teams/listener.d.ts.map +1 -0
- package/dist/src/platforms/teams/listener.js +236 -0
- package/dist/src/platforms/teams/listener.js.map +1 -0
- package/dist/src/platforms/teams/realm-discovery.d.ts +3 -0
- package/dist/src/platforms/teams/realm-discovery.d.ts.map +1 -0
- package/dist/src/platforms/teams/realm-discovery.js +33 -0
- package/dist/src/platforms/teams/realm-discovery.js.map +1 -0
- package/dist/src/platforms/teams/token-extractor.d.ts +3 -0
- package/dist/src/platforms/teams/token-extractor.d.ts.map +1 -1
- package/dist/src/platforms/teams/token-extractor.js +60 -0
- package/dist/src/platforms/teams/token-extractor.js.map +1 -1
- package/dist/src/platforms/teams/token-provider.d.ts +25 -0
- package/dist/src/platforms/teams/token-provider.d.ts.map +1 -0
- package/dist/src/platforms/teams/token-provider.js +190 -0
- package/dist/src/platforms/teams/token-provider.js.map +1 -0
- package/dist/src/platforms/teams/trouter.d.ts +31 -0
- package/dist/src/platforms/teams/trouter.d.ts.map +1 -0
- package/dist/src/platforms/teams/trouter.js +181 -0
- package/dist/src/platforms/teams/trouter.js.map +1 -0
- package/dist/src/platforms/teams/types.d.ts +94 -0
- package/dist/src/platforms/teams/types.d.ts.map +1 -1
- package/dist/src/platforms/teams/types.js +30 -0
- package/dist/src/platforms/teams/types.js.map +1 -1
- package/dist/src/platforms/webex/types.d.ts +1 -1
- package/dist/src/tui/adapters/instagram-adapter.d.ts.map +1 -1
- package/dist/src/tui/adapters/instagram-adapter.js +16 -0
- package/dist/src/tui/adapters/instagram-adapter.js.map +1 -1
- package/dist/src/tui/adapters/teams-adapter.d.ts +3 -0
- package/dist/src/tui/adapters/teams-adapter.d.ts.map +1 -1
- package/dist/src/tui/adapters/teams-adapter.js +21 -0
- package/dist/src/tui/adapters/teams-adapter.js.map +1 -1
- package/dist/src/tui/app.js +3 -3
- package/docs/content/docs/cli/instagram.mdx +1 -0
- package/docs/content/docs/cli/teams.mdx +22 -0
- package/package.json +1 -1
- package/skills/agent-channeltalk/SKILL.md +1 -1
- package/skills/agent-channeltalkbot/SKILL.md +1 -1
- package/skills/agent-discord/SKILL.md +1 -1
- package/skills/agent-discordbot/SKILL.md +1 -1
- package/skills/agent-imessage/SKILL.md +1 -1
- package/skills/agent-instagram/SKILL.md +1 -1
- package/skills/agent-instagram/references/authentication.md +11 -0
- package/skills/agent-kakaotalk/SKILL.md +1 -1
- package/skills/agent-line/SKILL.md +1 -1
- package/skills/agent-slack/SKILL.md +1 -1
- package/skills/agent-slackbot/SKILL.md +1 -1
- package/skills/agent-teams/SKILL.md +59 -6
- package/skills/agent-teams/references/authentication.md +31 -1
- package/skills/agent-telegram/SKILL.md +1 -1
- package/skills/agent-telegrambot/SKILL.md +1 -1
- package/skills/agent-webex/SKILL.md +1 -1
- package/skills/agent-webexbot/SKILL.md +1 -1
- package/skills/agent-wechatbot/SKILL.md +1 -1
- package/skills/agent-whatsapp/SKILL.md +1 -1
- package/skills/agent-whatsappbot/SKILL.md +1 -1
- package/src/platforms/discord/client.test.ts +86 -0
- package/src/platforms/discord/client.ts +10 -3
- package/src/platforms/instagram/client.test.ts +478 -33
- package/src/platforms/instagram/client.ts +449 -68
- package/src/platforms/instagram/commands/auth.test.ts +177 -1
- package/src/platforms/instagram/commands/auth.ts +182 -11
- package/src/platforms/instagram/credential-manager.test.ts +30 -0
- package/src/platforms/instagram/credential-manager.ts +23 -1
- package/src/platforms/instagram/types.ts +11 -8
- package/src/platforms/teams/app-config.ts +92 -0
- package/src/platforms/teams/client.test.ts +335 -1
- package/src/platforms/teams/client.ts +342 -7
- package/src/platforms/teams/commands/auth.test.ts +93 -1
- package/src/platforms/teams/commands/auth.ts +182 -3
- package/src/platforms/teams/commands/file.test.ts +92 -1
- package/src/platforms/teams/commands/file.ts +76 -1
- package/src/platforms/teams/commands/message.test.ts +95 -1
- package/src/platforms/teams/commands/message.ts +145 -3
- package/src/platforms/teams/credential-manager.test.ts +20 -0
- package/src/platforms/teams/credential-manager.ts +68 -3
- package/src/platforms/teams/device-code.test.ts +311 -0
- package/src/platforms/teams/device-code.ts +277 -0
- package/src/platforms/teams/device-login.test.ts +288 -0
- package/src/platforms/teams/device-login.ts +206 -0
- package/src/platforms/teams/ensure-auth.ts +11 -0
- package/src/platforms/teams/index.test.ts +10 -0
- package/src/platforms/teams/index.ts +16 -1
- package/src/platforms/teams/listener.ts +295 -0
- package/src/platforms/teams/realm-discovery.test.ts +73 -0
- package/src/platforms/teams/realm-discovery.ts +42 -0
- package/src/platforms/teams/token-extractor.ts +69 -0
- package/src/platforms/teams/token-provider.test.ts +227 -0
- package/src/platforms/teams/token-provider.ts +284 -0
- package/src/platforms/teams/trouter.test.ts +114 -0
- package/src/platforms/teams/trouter.ts +228 -0
- package/src/platforms/teams/types.test.ts +17 -0
- package/src/platforms/teams/types.ts +86 -0
- package/src/tui/adapters/instagram-adapter.ts +13 -0
- package/src/tui/adapters/teams-adapter.ts +23 -0
- package/src/tui/app.ts +3 -3
|
@@ -3,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,
|
|
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
|
|
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 {
|
|
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
|
+
}
|