agent-messenger 2.16.0 → 2.18.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/dist/package.json +1 -1
- package/dist/src/platforms/instagram/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/instagram/commands/auth.js +1 -3
- package/dist/src/platforms/instagram/commands/auth.js.map +1 -1
- package/dist/src/platforms/kakaotalk/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/commands/auth.js +2 -17
- package/dist/src/platforms/kakaotalk/commands/auth.js.map +1 -1
- package/dist/src/platforms/kakaotalk/index.d.ts +4 -2
- package/dist/src/platforms/kakaotalk/index.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/index.js +2 -1
- package/dist/src/platforms/kakaotalk/index.js.map +1 -1
- package/dist/src/platforms/line/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/line/commands/auth.js +2 -4
- package/dist/src/platforms/line/commands/auth.js.map +1 -1
- package/dist/src/platforms/slackbot/types.d.ts +4 -0
- package/dist/src/platforms/slackbot/types.d.ts.map +1 -1
- package/dist/src/platforms/telegram/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/telegram/commands/auth.js +5 -7
- package/dist/src/platforms/telegram/commands/auth.js.map +1 -1
- package/dist/src/platforms/webex/commands/auth.d.ts +5 -2
- package/dist/src/platforms/webex/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/webex/commands/auth.js +59 -1
- package/dist/src/platforms/webex/commands/auth.js.map +1 -1
- package/dist/src/platforms/webex/credential-manager.d.ts +11 -0
- package/dist/src/platforms/webex/credential-manager.d.ts.map +1 -1
- package/dist/src/platforms/webex/credential-manager.js +37 -0
- package/dist/src/platforms/webex/credential-manager.js.map +1 -1
- package/dist/src/platforms/whatsapp/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/whatsapp/commands/auth.js +2 -4
- package/dist/src/platforms/whatsapp/commands/auth.js.map +1 -1
- package/dist/src/shared/utils/interactive.d.ts +3 -0
- package/dist/src/shared/utils/interactive.d.ts.map +1 -0
- package/dist/src/shared/utils/interactive.js +16 -0
- package/dist/src/shared/utils/interactive.js.map +1 -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-instagram/SKILL.md +1 -1
- 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 +1 -1
- package/skills/agent-telegram/SKILL.md +1 -1
- package/skills/agent-telegrambot/SKILL.md +1 -1
- package/skills/agent-webex/SKILL.md +32 -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/instagram/commands/auth.ts +1 -4
- package/src/platforms/kakaotalk/commands/auth.ts +2 -18
- package/src/platforms/kakaotalk/index.ts +6 -0
- package/src/platforms/line/commands/auth.ts +2 -5
- package/src/platforms/slackbot/types.ts +16 -0
- package/src/platforms/telegram/commands/auth.ts +5 -8
- package/src/platforms/webex/commands/auth.test.ts +154 -0
- package/src/platforms/webex/commands/auth.ts +102 -3
- package/src/platforms/webex/credential-manager.test.ts +78 -0
- package/src/platforms/webex/credential-manager.ts +59 -0
- package/src/platforms/whatsapp/commands/auth.ts +2 -5
- package/src/shared/utils/interactive.test.ts +55 -0
- package/src/shared/utils/interactive.ts +15 -0
|
@@ -13,8 +13,11 @@ export type {
|
|
|
13
13
|
KakaoEmoticonKind,
|
|
14
14
|
KakaoEmoticonMessageType,
|
|
15
15
|
KakaoFileExtra,
|
|
16
|
+
KakaoLoginResult,
|
|
17
|
+
KakaoMarkReadResult,
|
|
16
18
|
KakaoMember,
|
|
17
19
|
KakaoMessage,
|
|
20
|
+
KakaoMultiPhotoExtra,
|
|
18
21
|
KakaoPhotoExtra,
|
|
19
22
|
KakaoProfile,
|
|
20
23
|
KakaoSendResult,
|
|
@@ -33,6 +36,7 @@ export {
|
|
|
33
36
|
KakaoAccountCredentialsSchema,
|
|
34
37
|
KakaoChatSchema,
|
|
35
38
|
KakaoConfigSchema,
|
|
39
|
+
KakaoMarkReadResultSchema,
|
|
36
40
|
KakaoMemberSchema,
|
|
37
41
|
KakaoMessageSchema,
|
|
38
42
|
KakaoProfileSchema,
|
|
@@ -42,6 +46,8 @@ export {
|
|
|
42
46
|
KakaoTalkPushMessageEventSchema,
|
|
43
47
|
KakaoTalkPushReadEventSchema,
|
|
44
48
|
} from './types'
|
|
49
|
+
export { attemptLogin, generateDeviceUuid, loginFlow, registerDevice, requestPasscode } from './auth/kakao-login'
|
|
50
|
+
export type { LoginCredentials } from './auth/kakao-login'
|
|
45
51
|
export { sha1Hex } from './media-upload'
|
|
46
52
|
export { detectImageDimensions } from './image-meta'
|
|
47
53
|
export type { AttachmentInput, AttachmentPlan, ResolvedAttachment, SingleAttachmentKind } from './attachment-router'
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Command } from 'commander'
|
|
2
2
|
|
|
3
3
|
import { handleError } from '@/shared/utils/error-handler'
|
|
4
|
+
import { isInteractive } from '@/shared/utils/interactive'
|
|
4
5
|
import { formatOutput } from '@/shared/utils/output'
|
|
5
6
|
import { displayQR } from '@/shared/utils/qr'
|
|
6
7
|
import { info } from '@/shared/utils/stderr'
|
|
@@ -9,10 +10,6 @@ import { LineClient } from '../client'
|
|
|
9
10
|
import { LineCredentialManager } from '../credential-manager'
|
|
10
11
|
import type { LineDevice } from '../types'
|
|
11
12
|
|
|
12
|
-
function isInteractiveSession(): boolean {
|
|
13
|
-
return Boolean(process.stdin.isTTY && process.stdout.isTTY)
|
|
14
|
-
}
|
|
15
|
-
|
|
16
13
|
function getDefaultDevice(): LineDevice {
|
|
17
14
|
return 'ANDROIDSECONDARY'
|
|
18
15
|
}
|
|
@@ -28,7 +25,7 @@ async function loginAction(options: {
|
|
|
28
25
|
const credManager = new LineCredentialManager()
|
|
29
26
|
const client = new LineClient(credManager)
|
|
30
27
|
const device: LineDevice = (options.device as LineDevice | undefined) ?? getDefaultDevice()
|
|
31
|
-
const interactive =
|
|
28
|
+
const interactive = isInteractive()
|
|
32
29
|
|
|
33
30
|
if (options.token) {
|
|
34
31
|
const now = new Date().toISOString()
|
|
@@ -334,6 +334,18 @@ export interface SlackSocketModeMessageEvent {
|
|
|
334
334
|
event_ts?: string
|
|
335
335
|
edited?: { user: string; ts: string }
|
|
336
336
|
hidden?: boolean
|
|
337
|
+
// Set on every reply within a thread; identifies the author of the message
|
|
338
|
+
// the thread is rooted at. Useful for deciding whether a reply targets the
|
|
339
|
+
// bot, another human, or an unknown parent.
|
|
340
|
+
parent_user_id?: string
|
|
341
|
+
// Client-generated UUID on user-authored messages, stable across Slack-side
|
|
342
|
+
// resends of the same gesture. Primary dedupe key for the "one user action
|
|
343
|
+
// surfaces as two events" case.
|
|
344
|
+
client_msg_id?: string
|
|
345
|
+
// Attachments delivered inline on the same message event. Slack does not
|
|
346
|
+
// fire a separate file_share envelope for messages we receive over Socket
|
|
347
|
+
// Mode, so consumers reading attachments off `message` events look here.
|
|
348
|
+
files?: SlackFile[]
|
|
337
349
|
[key: string]: unknown
|
|
338
350
|
}
|
|
339
351
|
|
|
@@ -345,6 +357,10 @@ export interface SlackSocketModeAppMentionEvent {
|
|
|
345
357
|
ts: string
|
|
346
358
|
thread_ts?: string
|
|
347
359
|
event_ts?: string
|
|
360
|
+
// `app_mention` envelopes do not always carry `client_msg_id`, but typing
|
|
361
|
+
// it keeps the promotion to a message-shaped event lossless if Slack ever
|
|
362
|
+
// starts sending it on this event.
|
|
363
|
+
client_msg_id?: string
|
|
348
364
|
[key: string]: unknown
|
|
349
365
|
}
|
|
350
366
|
|
|
@@ -2,6 +2,7 @@ import { Writable } from 'node:stream'
|
|
|
2
2
|
|
|
3
3
|
import { Command } from 'commander'
|
|
4
4
|
|
|
5
|
+
import { isInteractive } from '@/shared/utils/interactive'
|
|
5
6
|
import { info, error as stderrError } from '@/shared/utils/stderr'
|
|
6
7
|
|
|
7
8
|
import { handleError } from '../../../shared/utils/error-handler'
|
|
@@ -57,10 +58,6 @@ function parseApiId(apiId?: string): number | undefined {
|
|
|
57
58
|
return parsed
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
function isInteractiveSession(): boolean {
|
|
61
|
-
return Boolean(process.stdin.isTTY && process.stdout.isTTY)
|
|
62
|
-
}
|
|
63
|
-
|
|
64
61
|
async function promptLine(message: string): Promise<string | undefined> {
|
|
65
62
|
const { createInterface } = await import('node:readline/promises')
|
|
66
63
|
const rl = createInterface({
|
|
@@ -107,7 +104,7 @@ async function promptHidden(message: string): Promise<string | undefined> {
|
|
|
107
104
|
}
|
|
108
105
|
|
|
109
106
|
function shouldUseInteractivePrompts(): boolean {
|
|
110
|
-
return
|
|
107
|
+
return isInteractive()
|
|
111
108
|
}
|
|
112
109
|
|
|
113
110
|
async function fillMissingBootstrappingInputs(
|
|
@@ -168,7 +165,7 @@ async function fillMissingBootstrappingInputs(
|
|
|
168
165
|
}
|
|
169
166
|
|
|
170
167
|
if (!resolved.apiHash && !existing?.api_hash) {
|
|
171
|
-
if (!
|
|
168
|
+
if (!isInteractive()) {
|
|
172
169
|
console.log(formatOutput({ error: 'missing_credentials', message: 'Provide --api-hash flag.' }, options.pretty))
|
|
173
170
|
process.exit(1)
|
|
174
171
|
}
|
|
@@ -176,7 +173,7 @@ async function fillMissingBootstrappingInputs(
|
|
|
176
173
|
}
|
|
177
174
|
|
|
178
175
|
if (!existing && !resolved.phone) {
|
|
179
|
-
if (!
|
|
176
|
+
if (!isInteractive()) {
|
|
180
177
|
console.log(formatOutput({ next_action: 'provide_phone', message: 'Provide --phone flag.' }, options.pretty))
|
|
181
178
|
process.exit(0)
|
|
182
179
|
}
|
|
@@ -288,7 +285,7 @@ export async function promptNextLoginInput(
|
|
|
288
285
|
result: { next_action?: string },
|
|
289
286
|
options: AuthOptions,
|
|
290
287
|
): Promise<AuthOptions | null> {
|
|
291
|
-
if (!
|
|
288
|
+
if (!isInteractive() && result.next_action) {
|
|
292
289
|
return null
|
|
293
290
|
}
|
|
294
291
|
|
|
@@ -12,6 +12,8 @@ describe('auth commands', () => {
|
|
|
12
12
|
let consoleErrorSpy: ReturnType<typeof spyOn>
|
|
13
13
|
let execSpy: ReturnType<typeof spyOn>
|
|
14
14
|
const protoSpies: ReturnType<typeof spyOn>[] = []
|
|
15
|
+
let originalStdinTTY: boolean | undefined
|
|
16
|
+
let originalStdoutTTY: boolean | undefined
|
|
15
17
|
const mockPerson = {
|
|
16
18
|
id: 'person-1',
|
|
17
19
|
displayName: 'Test User',
|
|
@@ -27,10 +29,19 @@ describe('auth commands', () => {
|
|
|
27
29
|
return s
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
function setTTY(value: boolean | undefined): void {
|
|
33
|
+
Object.defineProperty(process.stdin, 'isTTY', { value, writable: true, configurable: true })
|
|
34
|
+
Object.defineProperty(process.stdout, 'isTTY', { value, writable: true, configurable: true })
|
|
35
|
+
}
|
|
36
|
+
|
|
30
37
|
beforeEach(() => {
|
|
31
38
|
consoleSpy = spyOn(console, 'log').mockImplementation(() => {})
|
|
32
39
|
consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {})
|
|
33
40
|
execSpy = spyOn(childProcess, 'exec').mockImplementation((() => {}) as any)
|
|
41
|
+
originalStdinTTY = process.stdin.isTTY
|
|
42
|
+
originalStdoutTTY = process.stdout.isTTY
|
|
43
|
+
// Default to interactive TTY for existing tests; non-interactive tests override.
|
|
44
|
+
setTTY(true)
|
|
34
45
|
})
|
|
35
46
|
|
|
36
47
|
afterEach(() => {
|
|
@@ -39,6 +50,12 @@ describe('auth commands', () => {
|
|
|
39
50
|
execSpy.mockRestore()
|
|
40
51
|
for (const s of protoSpies) s.mockRestore()
|
|
41
52
|
protoSpies.length = 0
|
|
53
|
+
setTTY(originalStdinTTY)
|
|
54
|
+
Object.defineProperty(process.stdout, 'isTTY', {
|
|
55
|
+
value: originalStdoutTTY,
|
|
56
|
+
writable: true,
|
|
57
|
+
configurable: true,
|
|
58
|
+
})
|
|
42
59
|
})
|
|
43
60
|
|
|
44
61
|
describe('loginAction with --token', () => {
|
|
@@ -151,6 +168,143 @@ describe('auth commands', () => {
|
|
|
151
168
|
})
|
|
152
169
|
})
|
|
153
170
|
|
|
171
|
+
describe('loginAction non-interactive (no TTY)', () => {
|
|
172
|
+
const device = {
|
|
173
|
+
deviceCode: 'webex-device-code-abc123',
|
|
174
|
+
userCode: 'USER-CODE',
|
|
175
|
+
verificationUri: 'https://webex.com/verify',
|
|
176
|
+
verificationUriComplete: 'https://webex.com/verify?user_code=USER-CODE',
|
|
177
|
+
expiresIn: 300,
|
|
178
|
+
interval: 1,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
it('first call (no --device-code): requests device code and returns it in JSON', async () => {
|
|
182
|
+
setTTY(false)
|
|
183
|
+
const requestSpy = protoSpy(WebexCredentialManager.prototype, 'requestDeviceCode').mockResolvedValue(device)
|
|
184
|
+
const exchangeSpy = protoSpy(WebexCredentialManager.prototype, 'exchangeDeviceCode')
|
|
185
|
+
const pollSpy = protoSpy(WebexCredentialManager.prototype, 'pollDeviceToken')
|
|
186
|
+
const exitSpy = protoSpy(process, 'exit').mockImplementation(() => undefined as never)
|
|
187
|
+
|
|
188
|
+
await loginAction({ pretty: false })
|
|
189
|
+
|
|
190
|
+
expect(requestSpy).toHaveBeenCalled()
|
|
191
|
+
expect(exchangeSpy).not.toHaveBeenCalled()
|
|
192
|
+
expect(pollSpy).not.toHaveBeenCalled()
|
|
193
|
+
|
|
194
|
+
const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
|
|
195
|
+
const output = JSON.parse(lastCall)
|
|
196
|
+
expect(output.next_action).toBe('authorize_in_browser')
|
|
197
|
+
expect(output.verification_uri).toBe(device.verificationUri)
|
|
198
|
+
expect(output.verification_uri_complete).toBe(device.verificationUriComplete)
|
|
199
|
+
expect(output.user_code).toBe(device.userCode)
|
|
200
|
+
expect(output.device_code).toBe(device.deviceCode)
|
|
201
|
+
expect(output.message).toContain('--device-code')
|
|
202
|
+
expect(exitSpy).toHaveBeenCalledWith(0)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('does not open a browser on the first non-interactive call', async () => {
|
|
206
|
+
setTTY(false)
|
|
207
|
+
protoSpy(WebexCredentialManager.prototype, 'requestDeviceCode').mockResolvedValue(device)
|
|
208
|
+
protoSpy(process, 'exit').mockImplementation(() => undefined as never)
|
|
209
|
+
|
|
210
|
+
await loginAction({ pretty: false })
|
|
211
|
+
|
|
212
|
+
expect(execSpy).not.toHaveBeenCalled()
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('second call (--device-code, pending): returns still_pending and echoes back the device_code', async () => {
|
|
216
|
+
setTTY(false)
|
|
217
|
+
protoSpy(WebexCredentialManager.prototype, 'exchangeDeviceCode').mockResolvedValue({ status: 'pending' })
|
|
218
|
+
const requestSpy = protoSpy(WebexCredentialManager.prototype, 'requestDeviceCode')
|
|
219
|
+
const exitSpy = protoSpy(process, 'exit').mockImplementation(() => undefined as never)
|
|
220
|
+
|
|
221
|
+
await loginAction({ deviceCode: 'webex-device-code-abc123', pretty: false })
|
|
222
|
+
|
|
223
|
+
expect(requestSpy).not.toHaveBeenCalled()
|
|
224
|
+
|
|
225
|
+
const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
|
|
226
|
+
const output = JSON.parse(lastCall)
|
|
227
|
+
expect(output.next_action).toBe('still_pending')
|
|
228
|
+
expect(output.device_code).toBe('webex-device-code-abc123')
|
|
229
|
+
expect(exitSpy).toHaveBeenCalledWith(0)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('second call (--device-code, success): saves token and returns authenticated=true', async () => {
|
|
233
|
+
setTTY(false)
|
|
234
|
+
const exchangeSpy = protoSpy(WebexCredentialManager.prototype, 'exchangeDeviceCode').mockResolvedValue({
|
|
235
|
+
status: 'success',
|
|
236
|
+
config: { accessToken: 'at', refreshToken: 'rt', expiresAt: Date.now() + 3_600_000 },
|
|
237
|
+
})
|
|
238
|
+
const saveConfigSpy = protoSpy(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
|
|
239
|
+
protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
|
|
240
|
+
protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
|
|
241
|
+
|
|
242
|
+
await loginAction({
|
|
243
|
+
deviceCode: 'webex-device-code-abc123',
|
|
244
|
+
clientId: 'my-id',
|
|
245
|
+
clientSecret: 'my-secret',
|
|
246
|
+
pretty: false,
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
expect(exchangeSpy).toHaveBeenCalledWith('webex-device-code-abc123', 'my-id', 'my-secret')
|
|
250
|
+
const savedConfig = saveConfigSpy.mock.calls[0][0] as { tokenType: string; clientId: string }
|
|
251
|
+
expect(savedConfig.tokenType).toBe('oauth')
|
|
252
|
+
expect(savedConfig.clientId).toBe('my-id')
|
|
253
|
+
|
|
254
|
+
const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
|
|
255
|
+
const output = JSON.parse(lastCall)
|
|
256
|
+
expect(output.authenticated).toBe(true)
|
|
257
|
+
expect(output.user.displayName).toBe('Test User')
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('second call (--device-code, expired): returns next_action=restart, exits 1', async () => {
|
|
261
|
+
setTTY(false)
|
|
262
|
+
protoSpy(WebexCredentialManager.prototype, 'exchangeDeviceCode').mockResolvedValue({ status: 'expired' })
|
|
263
|
+
const exitSpy = protoSpy(process, 'exit').mockImplementation(() => undefined as never)
|
|
264
|
+
|
|
265
|
+
await loginAction({ deviceCode: 'webex-device-code-abc123', pretty: false })
|
|
266
|
+
|
|
267
|
+
const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
|
|
268
|
+
const output = JSON.parse(lastCall)
|
|
269
|
+
expect(output.next_action).toBe('restart')
|
|
270
|
+
expect(output.error).toContain('expired')
|
|
271
|
+
expect(exitSpy).toHaveBeenCalledWith(1)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('--device-code works even with a TTY (interactive sessions can also resume)', async () => {
|
|
275
|
+
setTTY(true)
|
|
276
|
+
const exchangeSpy = protoSpy(WebexCredentialManager.prototype, 'exchangeDeviceCode').mockResolvedValue({
|
|
277
|
+
status: 'success',
|
|
278
|
+
config: { accessToken: 'at', refreshToken: 'rt', expiresAt: Date.now() + 3_600_000 },
|
|
279
|
+
})
|
|
280
|
+
const requestSpy = protoSpy(WebexCredentialManager.prototype, 'requestDeviceCode')
|
|
281
|
+
const pollSpy = protoSpy(WebexCredentialManager.prototype, 'pollDeviceToken')
|
|
282
|
+
protoSpy(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
|
|
283
|
+
protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
|
|
284
|
+
protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
|
|
285
|
+
|
|
286
|
+
await loginAction({ deviceCode: 'webex-device-code-abc123', pretty: false })
|
|
287
|
+
|
|
288
|
+
expect(exchangeSpy).toHaveBeenCalled()
|
|
289
|
+
expect(requestSpy).not.toHaveBeenCalled()
|
|
290
|
+
expect(pollSpy).not.toHaveBeenCalled()
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('still allows --token login when non-interactive (bot/PAT path)', async () => {
|
|
294
|
+
setTTY(false)
|
|
295
|
+
protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
|
|
296
|
+
protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
|
|
297
|
+
protoSpy(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
|
|
298
|
+
|
|
299
|
+
await loginAction({ token: 'bot-token-123', pretty: false })
|
|
300
|
+
|
|
301
|
+
const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
|
|
302
|
+
const output = JSON.parse(lastCall)
|
|
303
|
+
expect(output.authenticated).toBe(true)
|
|
304
|
+
expect(output.user.displayName).toBe('Test User')
|
|
305
|
+
})
|
|
306
|
+
})
|
|
307
|
+
|
|
154
308
|
describe('statusAction', () => {
|
|
155
309
|
it('shows authenticated status when token is valid', async () => {
|
|
156
310
|
protoSpy(WebexCredentialManager.prototype, 'loadConfig').mockResolvedValue(null)
|
|
@@ -2,6 +2,7 @@ import { Command } from 'commander'
|
|
|
2
2
|
|
|
3
3
|
import { collectBrowserProfileOption } from '@/shared/chromium'
|
|
4
4
|
import { handleError } from '@/shared/utils/error-handler'
|
|
5
|
+
import { isInteractive } from '@/shared/utils/interactive'
|
|
5
6
|
import { formatOutput } from '@/shared/utils/output'
|
|
6
7
|
import { info, debug } from '@/shared/utils/stderr'
|
|
7
8
|
|
|
@@ -43,12 +44,15 @@ async function resolveClientCredentials(options: {
|
|
|
43
44
|
return getWebexAppCredentials()
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
|
|
47
|
+
interface LoginOptions {
|
|
47
48
|
token?: string
|
|
49
|
+
deviceCode?: string
|
|
48
50
|
clientId?: string
|
|
49
51
|
clientSecret?: string
|
|
50
52
|
pretty?: boolean
|
|
51
|
-
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function loginAction(options: LoginOptions): Promise<void> {
|
|
52
56
|
try {
|
|
53
57
|
const credManager = new WebexCredentialManager()
|
|
54
58
|
|
|
@@ -73,6 +77,16 @@ export async function loginAction(options: {
|
|
|
73
77
|
return
|
|
74
78
|
}
|
|
75
79
|
|
|
80
|
+
if (options.deviceCode) {
|
|
81
|
+
await finishDeviceGrant(credManager, options)
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!isInteractive()) {
|
|
86
|
+
await startNonInteractiveDeviceGrant(credManager, options)
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
|
|
76
90
|
const { clientId, clientSecret } = await resolveClientCredentials(options)
|
|
77
91
|
|
|
78
92
|
const device = await credManager.requestDeviceCode(clientId)
|
|
@@ -110,6 +124,81 @@ export async function loginAction(options: {
|
|
|
110
124
|
}
|
|
111
125
|
}
|
|
112
126
|
|
|
127
|
+
async function startNonInteractiveDeviceGrant(
|
|
128
|
+
credManager: WebexCredentialManager,
|
|
129
|
+
options: LoginOptions,
|
|
130
|
+
): Promise<void> {
|
|
131
|
+
const { clientId } = await resolveClientCredentials(options)
|
|
132
|
+
const device = await credManager.requestDeviceCode(clientId)
|
|
133
|
+
const expiresAt = Date.now() + device.expiresIn * 1000
|
|
134
|
+
|
|
135
|
+
console.log(
|
|
136
|
+
formatOutput(
|
|
137
|
+
{
|
|
138
|
+
next_action: 'authorize_in_browser',
|
|
139
|
+
verification_uri: device.verificationUri,
|
|
140
|
+
verification_uri_complete: device.verificationUriComplete,
|
|
141
|
+
user_code: device.userCode,
|
|
142
|
+
device_code: device.deviceCode,
|
|
143
|
+
expires_at: expiresAt,
|
|
144
|
+
message:
|
|
145
|
+
'Show the user `verification_uri` and `user_code` (or just `verification_uri_complete`) and ask them to approve access in any browser. After they approve, run `agent-webex auth login --device-code <device_code>` to retrieve the token. The device code expires at `expires_at`. If you passed `--client-id`/`--client-secret`, pass them again on the second call.',
|
|
146
|
+
},
|
|
147
|
+
options.pretty,
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
process.exit(0)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function finishDeviceGrant(credManager: WebexCredentialManager, options: LoginOptions): Promise<void> {
|
|
154
|
+
const { clientId, clientSecret } = await resolveClientCredentials(options)
|
|
155
|
+
const result = await credManager.exchangeDeviceCode(options.deviceCode!, clientId, clientSecret)
|
|
156
|
+
|
|
157
|
+
if (result.status === 'success') {
|
|
158
|
+
await credManager.saveConfig({ ...result.config, clientId, clientSecret, tokenType: 'oauth' })
|
|
159
|
+
const client = await new WebexClient().login({ token: result.config.accessToken })
|
|
160
|
+
const person = await client.testAuth()
|
|
161
|
+
console.log(
|
|
162
|
+
formatOutput(
|
|
163
|
+
{
|
|
164
|
+
authenticated: true,
|
|
165
|
+
user: { id: person.id, displayName: person.displayName, emails: person.emails },
|
|
166
|
+
},
|
|
167
|
+
options.pretty,
|
|
168
|
+
),
|
|
169
|
+
)
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (result.status === 'pending') {
|
|
174
|
+
console.log(
|
|
175
|
+
formatOutput(
|
|
176
|
+
{
|
|
177
|
+
next_action: 'still_pending',
|
|
178
|
+
device_code: options.deviceCode,
|
|
179
|
+
message:
|
|
180
|
+
'User has not approved access yet. Confirm with the user that they completed authorization in the browser, then run `agent-webex auth login --device-code <device_code>` again to retry.',
|
|
181
|
+
},
|
|
182
|
+
options.pretty,
|
|
183
|
+
),
|
|
184
|
+
)
|
|
185
|
+
process.exit(0)
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
console.log(
|
|
190
|
+
formatOutput(
|
|
191
|
+
{
|
|
192
|
+
next_action: 'restart',
|
|
193
|
+
error: result.status === 'expired' ? 'Device code expired.' : `Device code exchange failed: ${result.message}`,
|
|
194
|
+
message: 'This device code is no longer valid. Run `agent-webex auth login` to start a new login.',
|
|
195
|
+
},
|
|
196
|
+
options.pretty,
|
|
197
|
+
),
|
|
198
|
+
)
|
|
199
|
+
process.exit(1)
|
|
200
|
+
}
|
|
201
|
+
|
|
113
202
|
export async function statusAction(options: { pretty?: boolean }): Promise<void> {
|
|
114
203
|
try {
|
|
115
204
|
const credManager = new WebexCredentialManager()
|
|
@@ -276,8 +365,18 @@ export const authCommand = new Command('auth')
|
|
|
276
365
|
.description('Authentication commands')
|
|
277
366
|
.addCommand(
|
|
278
367
|
new Command('login')
|
|
279
|
-
.description(
|
|
368
|
+
.description(
|
|
369
|
+
'Log in to Webex. In a TTY this opens a browser and waits for approval. ' +
|
|
370
|
+
'When non-interactive (AI agents, CI/CD): first call starts the OAuth Device Grant flow ' +
|
|
371
|
+
'and returns a verification URL, user code, and device code. After the user approves in a ' +
|
|
372
|
+
'browser, call again with --device-code to exchange it for a token. Pass --token for ' +
|
|
373
|
+
'fully unattended auth.',
|
|
374
|
+
)
|
|
280
375
|
.option('--token <token>', 'Use a bot token or personal access token directly')
|
|
376
|
+
.option(
|
|
377
|
+
'--device-code <code>',
|
|
378
|
+
'OAuth Device Grant code returned from a previous non-interactive `auth login` call',
|
|
379
|
+
)
|
|
281
380
|
.option('--client-id <id>', 'Webex Integration client ID')
|
|
282
381
|
.option('--client-secret <secret>', 'Webex Integration client secret')
|
|
283
382
|
.option('--pretty', 'Pretty print JSON output')
|
|
@@ -373,4 +373,82 @@ describe('WebexCredentialManager', () => {
|
|
|
373
373
|
expect(loaded?.clientId).toBeUndefined()
|
|
374
374
|
expect(loaded?.clientSecret).toBeUndefined()
|
|
375
375
|
})
|
|
376
|
+
|
|
377
|
+
describe('exchangeDeviceCode', () => {
|
|
378
|
+
let originalFetch: typeof globalThis.fetch
|
|
379
|
+
|
|
380
|
+
beforeEach(() => {
|
|
381
|
+
originalFetch = globalThis.fetch
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
afterEach(() => {
|
|
385
|
+
globalThis.fetch = originalFetch
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
it('returns success with config on 200', async () => {
|
|
389
|
+
globalThis.fetch = mock(() =>
|
|
390
|
+
Promise.resolve(
|
|
391
|
+
new Response(JSON.stringify({ access_token: 'at', refresh_token: 'rt', expires_in: 3600 }), {
|
|
392
|
+
status: 200,
|
|
393
|
+
headers: { 'Content-Type': 'application/json' },
|
|
394
|
+
}),
|
|
395
|
+
),
|
|
396
|
+
) as unknown as typeof globalThis.fetch
|
|
397
|
+
|
|
398
|
+
const result = await credManager.exchangeDeviceCode('dc', 'cid', 'csec')
|
|
399
|
+
expect(result.status).toBe('success')
|
|
400
|
+
if (result.status === 'success') {
|
|
401
|
+
expect(result.config.accessToken).toBe('at')
|
|
402
|
+
expect(result.config.refreshToken).toBe('rt')
|
|
403
|
+
}
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
it('returns pending on 428', async () => {
|
|
407
|
+
globalThis.fetch = mock(() =>
|
|
408
|
+
Promise.resolve(new Response('', { status: 428 })),
|
|
409
|
+
) as unknown as typeof globalThis.fetch
|
|
410
|
+
const result = await credManager.exchangeDeviceCode('dc', 'cid', 'csec')
|
|
411
|
+
expect(result.status).toBe('pending')
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
it('returns pending when error description includes authorization_pending', async () => {
|
|
415
|
+
globalThis.fetch = mock(() =>
|
|
416
|
+
Promise.resolve(
|
|
417
|
+
new Response(JSON.stringify({ errors: [{ description: 'authorization_pending' }] }), {
|
|
418
|
+
status: 400,
|
|
419
|
+
headers: { 'Content-Type': 'application/json' },
|
|
420
|
+
}),
|
|
421
|
+
),
|
|
422
|
+
) as unknown as typeof globalThis.fetch
|
|
423
|
+
const result = await credManager.exchangeDeviceCode('dc', 'cid', 'csec')
|
|
424
|
+
expect(result.status).toBe('pending')
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
it('returns expired when error description signals expiry', async () => {
|
|
428
|
+
globalThis.fetch = mock(() =>
|
|
429
|
+
Promise.resolve(
|
|
430
|
+
new Response(JSON.stringify({ errors: [{ description: 'expired_token' }] }), {
|
|
431
|
+
status: 400,
|
|
432
|
+
headers: { 'Content-Type': 'application/json' },
|
|
433
|
+
}),
|
|
434
|
+
),
|
|
435
|
+
) as unknown as typeof globalThis.fetch
|
|
436
|
+
const result = await credManager.exchangeDeviceCode('dc', 'cid', 'csec')
|
|
437
|
+
expect(result.status).toBe('expired')
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
it('returns error on other failures', async () => {
|
|
441
|
+
globalThis.fetch = mock(() =>
|
|
442
|
+
Promise.resolve(
|
|
443
|
+
new Response(JSON.stringify({ errors: [{ description: 'access_denied' }] }), {
|
|
444
|
+
status: 403,
|
|
445
|
+
headers: { 'Content-Type': 'application/json' },
|
|
446
|
+
}),
|
|
447
|
+
),
|
|
448
|
+
) as unknown as typeof globalThis.fetch
|
|
449
|
+
const result = await credManager.exchangeDeviceCode('dc', 'cid', 'csec')
|
|
450
|
+
expect(result.status).toBe('error')
|
|
451
|
+
if (result.status === 'error') expect(result.message).toContain('access_denied')
|
|
452
|
+
})
|
|
453
|
+
})
|
|
376
454
|
})
|
|
@@ -212,6 +212,65 @@ export class WebexCredentialManager {
|
|
|
212
212
|
throw new Error('Device authorization timed out')
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
+
async exchangeDeviceCode(
|
|
216
|
+
deviceCode: string,
|
|
217
|
+
clientId: string,
|
|
218
|
+
clientSecret?: string,
|
|
219
|
+
): Promise<
|
|
220
|
+
| { status: 'success'; config: Pick<WebexConfig, 'accessToken' | 'refreshToken' | 'expiresAt'> }
|
|
221
|
+
| { status: 'pending' }
|
|
222
|
+
| { status: 'expired' }
|
|
223
|
+
| { status: 'error'; message: string }
|
|
224
|
+
> {
|
|
225
|
+
const basicAuth = btoa(`${clientId}:${clientSecret ?? ''}`)
|
|
226
|
+
|
|
227
|
+
const response = await fetch(OAUTH_DEVICE_TOKEN_URL, {
|
|
228
|
+
method: 'POST',
|
|
229
|
+
headers: {
|
|
230
|
+
Authorization: `Basic ${basicAuth}`,
|
|
231
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
232
|
+
},
|
|
233
|
+
body: new URLSearchParams({
|
|
234
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
235
|
+
device_code: deviceCode,
|
|
236
|
+
client_id: clientId,
|
|
237
|
+
}),
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
if (response.ok) {
|
|
241
|
+
const data = (await response.json()) as {
|
|
242
|
+
access_token: string
|
|
243
|
+
refresh_token: string
|
|
244
|
+
expires_in: number
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
status: 'success',
|
|
248
|
+
config: {
|
|
249
|
+
accessToken: data.access_token,
|
|
250
|
+
refreshToken: data.refresh_token,
|
|
251
|
+
expiresAt: Date.now() + data.expires_in * 1000,
|
|
252
|
+
},
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (response.status === 428) return { status: 'pending' }
|
|
257
|
+
|
|
258
|
+
const errorBody = (await response.json().catch(() => null)) as {
|
|
259
|
+
errors?: Array<{ description: string }>
|
|
260
|
+
} | null
|
|
261
|
+
const errorDesc = errorBody?.errors?.[0]?.description ?? ''
|
|
262
|
+
|
|
263
|
+
if (errorDesc.includes('authorization_pending') || errorDesc.includes('slow_down')) {
|
|
264
|
+
return { status: 'pending' }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (errorDesc.includes('expired_token') || errorDesc.includes('expired')) {
|
|
268
|
+
return { status: 'expired' }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return { status: 'error', message: errorDesc || `http_${response.status}` }
|
|
272
|
+
}
|
|
273
|
+
|
|
215
274
|
async clearCredentials(): Promise<void> {
|
|
216
275
|
if (existsSync(this.credentialsPath)) {
|
|
217
276
|
await rm(this.credentialsPath)
|
|
@@ -3,6 +3,7 @@ import { rm } from 'node:fs/promises'
|
|
|
3
3
|
import { Command } from 'commander'
|
|
4
4
|
|
|
5
5
|
import { handleError } from '@/shared/utils/error-handler'
|
|
6
|
+
import { isInteractive } from '@/shared/utils/interactive'
|
|
6
7
|
import { formatOutput } from '@/shared/utils/output'
|
|
7
8
|
import { displayQR } from '@/shared/utils/qr'
|
|
8
9
|
import { info } from '@/shared/utils/stderr'
|
|
@@ -22,10 +23,6 @@ interface StatusOptions {
|
|
|
22
23
|
pretty?: boolean
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
function isInteractiveSession(): boolean {
|
|
26
|
-
return Boolean(process.stdin.isTTY && process.stdout.isTTY)
|
|
27
|
-
}
|
|
28
|
-
|
|
29
26
|
async function loginWithPairingCode(options: LoginOptions & { phone: string }): Promise<void> {
|
|
30
27
|
const manager = new WhatsAppCredentialManager()
|
|
31
28
|
const accountId = createAccountId(options.phone)
|
|
@@ -95,7 +92,7 @@ async function loginWithQR(options: LoginOptions): Promise<void> {
|
|
|
95
92
|
await rm(existingPaths.auth_dir, { recursive: true, force: true })
|
|
96
93
|
const paths = await manager.ensureAccountPaths(accountId)
|
|
97
94
|
const client = await new WhatsAppClient().login({ authDir: paths.auth_dir })
|
|
98
|
-
const interactive =
|
|
95
|
+
const interactive = isInteractive()
|
|
99
96
|
|
|
100
97
|
let waitForAuth: () => Promise<void>
|
|
101
98
|
let browserOpened = false
|