agent-messenger 2.17.0 → 2.19.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/bunfig.toml +1 -0
- package/dist/package.json +1 -1
- package/dist/src/platforms/discordbot/client.d.ts +1 -0
- package/dist/src/platforms/discordbot/client.d.ts.map +1 -1
- package/dist/src/platforms/discordbot/client.js +3 -0
- package/dist/src/platforms/discordbot/client.js.map +1 -1
- package/dist/src/platforms/discordbot/commands/message.d.ts +1 -0
- package/dist/src/platforms/discordbot/commands/message.d.ts.map +1 -1
- package/dist/src/platforms/discordbot/commands/message.js +2 -0
- package/dist/src/platforms/discordbot/commands/message.js.map +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/client.d.ts +4 -2
- package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/client.js +16 -2
- package/dist/src/platforms/kakaotalk/client.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/commands/message.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/commands/message.js +23 -1
- package/dist/src/platforms/kakaotalk/commands/message.js.map +1 -1
- package/dist/src/platforms/kakaotalk/index.d.ts +1 -1
- package/dist/src/platforms/kakaotalk/index.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/index.js.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/session.d.ts +2 -1
- package/dist/src/platforms/kakaotalk/protocol/session.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/session.js +15 -0
- package/dist/src/platforms/kakaotalk/protocol/session.js.map +1 -1
- package/dist/src/platforms/kakaotalk/types.d.ts +18 -0
- package/dist/src/platforms/kakaotalk/types.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/types.js +1 -0
- package/dist/src/platforms/kakaotalk/types.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/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/chromium/browsers.js +1 -1
- package/dist/src/shared/chromium/browsers.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/docs/content/docs/cli/discordbot.mdx +6 -0
- package/docs/content/docs/sdk/discordbot.mdx +4 -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 +9 -1
- package/skills/agent-instagram/SKILL.md +1 -1
- package/skills/agent-kakaotalk/SKILL.md +24 -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/discord/commands/dm.test.ts +28 -20
- package/src/platforms/discord/commands/reaction.test.ts +12 -7
- package/src/platforms/discordbot/client.test.ts +17 -0
- package/src/platforms/discordbot/client.ts +9 -2
- package/src/platforms/discordbot/commands/message.test.ts +28 -11
- package/src/platforms/discordbot/commands/message.ts +4 -2
- package/src/platforms/instagram/commands/auth.test.ts +11 -9
- package/src/platforms/instagram/commands/auth.ts +1 -4
- package/src/platforms/instagram/commands/chat.test.ts +8 -6
- package/src/platforms/instagram/commands/message.test.ts +8 -6
- package/src/platforms/kakaotalk/client.test.ts +57 -0
- package/src/platforms/kakaotalk/client.ts +23 -2
- package/src/platforms/kakaotalk/commands/auth.ts +2 -18
- package/src/platforms/kakaotalk/commands/message.test.ts +42 -0
- package/src/platforms/kakaotalk/commands/message.ts +33 -2
- package/src/platforms/kakaotalk/index.ts +2 -0
- package/src/platforms/kakaotalk/protocol/session.ts +15 -1
- package/src/platforms/kakaotalk/types.ts +24 -0
- package/src/platforms/line/commands/auth.ts +2 -5
- package/src/platforms/telegram/commands/auth.ts +5 -8
- package/src/platforms/webex/commands/auth.test.ts +178 -14
- package/src/platforms/webex/commands/auth.ts +102 -3
- package/src/platforms/webex/commands/member.test.ts +14 -20
- package/src/platforms/webex/commands/message.test.ts +11 -20
- package/src/platforms/webex/commands/snapshot.test.ts +11 -20
- package/src/platforms/webex/commands/space.test.ts +15 -23
- package/src/platforms/webex/commands/whoami.test.ts +8 -22
- 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/chromium/browsers.ts +1 -1
- package/src/shared/utils/interactive.test.ts +55 -0
- package/src/shared/utils/interactive.ts +15 -0
- package/src/test-setup.ts +5 -0
- package/tsconfig.json +1 -1
|
@@ -3,6 +3,7 @@ import { Writable } from 'node:stream'
|
|
|
3
3
|
import { Command } from 'commander'
|
|
4
4
|
|
|
5
5
|
import { handleError } from '@/shared/utils/error-handler'
|
|
6
|
+
import { hasTTY, isInteractive } from '@/shared/utils/interactive'
|
|
6
7
|
import { formatOutput } from '@/shared/utils/output'
|
|
7
8
|
import { info, error, debug } from '@/shared/utils/stderr'
|
|
8
9
|
|
|
@@ -11,23 +12,6 @@ import { CredentialManager } from '../credential-manager'
|
|
|
11
12
|
import { KakaoTokenExtractor } from '../token-extractor'
|
|
12
13
|
import { KAKAO_NEXT_ACTIONS, type KakaoAuthOptions, type KakaoDeviceType, type KakaoLoginResult } from '../types'
|
|
13
14
|
|
|
14
|
-
function isInteractiveSession(): boolean {
|
|
15
|
-
return Boolean(process.stdin.isTTY && process.stdout.isTTY)
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function hasTTY(): boolean {
|
|
19
|
-
try {
|
|
20
|
-
const { openSync, closeSync } = require('node:fs') as typeof import('node:fs')
|
|
21
|
-
// CONIN$ is the Windows console input device; /dev/tty is the Unix equivalent
|
|
22
|
-
const ttyDevice = process.platform === 'win32' ? 'CONIN$' : '/dev/tty'
|
|
23
|
-
const fd = openSync(ttyDevice, 'r')
|
|
24
|
-
closeSync(fd)
|
|
25
|
-
return true
|
|
26
|
-
} catch {
|
|
27
|
-
return false
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
15
|
async function promptPasswordGUI(email?: string): Promise<string | undefined> {
|
|
32
16
|
const { execSync } = require('node:child_process') as typeof import('node:child_process')
|
|
33
17
|
|
|
@@ -233,7 +217,7 @@ async function promptHiddenTTY(message: string): Promise<string | undefined> {
|
|
|
233
217
|
async function loginAction(options: KakaoAuthOptions): Promise<void> {
|
|
234
218
|
try {
|
|
235
219
|
const credManager = new CredentialManager()
|
|
236
|
-
const interactive =
|
|
220
|
+
const interactive = isInteractive()
|
|
237
221
|
|
|
238
222
|
let { email, password, deviceType, force } = options
|
|
239
223
|
|
|
@@ -112,6 +112,48 @@ describe('message commands', () => {
|
|
|
112
112
|
expect.any(Function),
|
|
113
113
|
)
|
|
114
114
|
})
|
|
115
|
+
|
|
116
|
+
it('resolves --reply-to from chat history and sends a quoted reply', async () => {
|
|
117
|
+
// given
|
|
118
|
+
mockGetMessages.mockImplementation(() =>
|
|
119
|
+
Promise.resolve([
|
|
120
|
+
{ log_id: '10', type: 1, author_id: 5, author_name: null, message: 'earlier', attachment: null, sent_at: 1 },
|
|
121
|
+
{ log_id: '42', type: 2, author_id: 7, author_name: null, message: 'target', attachment: null, sent_at: 2 },
|
|
122
|
+
]),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
// when
|
|
126
|
+
await messageCommand.parseAsync(['send', 'chat-123', 'replying', '--reply-to', '42'], { from: 'user' })
|
|
127
|
+
|
|
128
|
+
// then
|
|
129
|
+
expect(mockGetMessages).toHaveBeenCalledWith('chat-123', { count: 100 })
|
|
130
|
+
expect(mockSendMessage).toHaveBeenCalledWith('chat-123', 'replying', {
|
|
131
|
+
replyTo: { log_id: '42', author_id: 7, message: 'target', type: 2 },
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('errors when --reply-to log-id is not found in recent history', async () => {
|
|
136
|
+
// given
|
|
137
|
+
mockGetMessages.mockImplementation(() =>
|
|
138
|
+
Promise.resolve([
|
|
139
|
+
{ log_id: '10', type: 1, author_id: 5, author_name: null, message: 'earlier', attachment: null, sent_at: 1 },
|
|
140
|
+
]),
|
|
141
|
+
)
|
|
142
|
+
const exitSpy = mock((_code?: number): never => {
|
|
143
|
+
throw new Error('process.exit called')
|
|
144
|
+
})
|
|
145
|
+
process.exit = exitSpy as unknown as typeof process.exit
|
|
146
|
+
|
|
147
|
+
// when / then
|
|
148
|
+
try {
|
|
149
|
+
await messageCommand.parseAsync(['send', 'chat-123', 'replying', '--reply-to', '999'], { from: 'user' })
|
|
150
|
+
} catch {
|
|
151
|
+
// process.exit stub throws to abort the action
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
expect(mockSendMessage).not.toHaveBeenCalled()
|
|
155
|
+
expect(exitSpy).toHaveBeenCalled()
|
|
156
|
+
})
|
|
115
157
|
})
|
|
116
158
|
|
|
117
159
|
describe('mark-read', () => {
|
|
@@ -6,6 +6,7 @@ import { Command } from 'commander'
|
|
|
6
6
|
import { handleError } from '@/shared/utils/error-handler'
|
|
7
7
|
import { formatOutput } from '@/shared/utils/output'
|
|
8
8
|
|
|
9
|
+
import type { KakaoMessage, KakaoReplyTarget } from '../types'
|
|
9
10
|
import { withKakaoClient } from './shared'
|
|
10
11
|
|
|
11
12
|
async function listAction(
|
|
@@ -26,16 +27,45 @@ async function listAction(
|
|
|
26
27
|
async function sendAction(
|
|
27
28
|
chatId: string,
|
|
28
29
|
text: string,
|
|
29
|
-
options: { account?: string; pretty?: boolean },
|
|
30
|
+
options: { account?: string; pretty?: boolean; replyTo?: string },
|
|
30
31
|
): Promise<void> {
|
|
31
32
|
try {
|
|
32
|
-
const result = await withKakaoClient(options, (client) =>
|
|
33
|
+
const result = await withKakaoClient(options, async (client) => {
|
|
34
|
+
if (options.replyTo === undefined) {
|
|
35
|
+
return client.sendMessage(chatId, text)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const target = await resolveReplyTarget(client, chatId, options.replyTo)
|
|
39
|
+
return client.sendMessage(chatId, text, { replyTo: target })
|
|
40
|
+
})
|
|
33
41
|
console.log(formatOutput(result, options.pretty))
|
|
34
42
|
} catch (error) {
|
|
35
43
|
handleError(error as Error)
|
|
36
44
|
}
|
|
37
45
|
}
|
|
38
46
|
|
|
47
|
+
// A reply attachment needs the source message's author, text, and type — not
|
|
48
|
+
// just its log_id — so we look it up in the chat's recent history.
|
|
49
|
+
async function resolveReplyTarget(
|
|
50
|
+
client: {
|
|
51
|
+
getMessages: (chatId: string, opts: { count: number }) => Promise<KakaoMessage[]>
|
|
52
|
+
},
|
|
53
|
+
chatId: string,
|
|
54
|
+
logId: string,
|
|
55
|
+
): Promise<KakaoReplyTarget> {
|
|
56
|
+
const messages = await client.getMessages(chatId, { count: 100 })
|
|
57
|
+
const source = messages.find((m) => m.log_id === logId)
|
|
58
|
+
if (!source) {
|
|
59
|
+
throw new Error(`Reply target log-id ${logId} not found in the latest 100 messages of chat ${chatId}`)
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
log_id: source.log_id,
|
|
63
|
+
author_id: source.author_id,
|
|
64
|
+
message: source.message,
|
|
65
|
+
type: source.type,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
39
69
|
type UploadKind = 'auto' | 'photo' | 'video' | 'audio' | 'file' | 'multi'
|
|
40
70
|
|
|
41
71
|
async function uploadAction(
|
|
@@ -122,6 +152,7 @@ export const messageCommand = new Command('message')
|
|
|
122
152
|
.argument('<chat-id>', 'Chat room ID')
|
|
123
153
|
.argument('<text>', 'Message text')
|
|
124
154
|
.option('--account <id>', 'Use a specific KakaoTalk account')
|
|
155
|
+
.option('--reply-to <log-id>', 'Send as a quoted reply to this message log ID')
|
|
125
156
|
.option('--pretty', 'Pretty print JSON output')
|
|
126
157
|
.action(sendAction),
|
|
127
158
|
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Binary, Long } from 'bson'
|
|
2
2
|
|
|
3
|
-
import type
|
|
3
|
+
import { KAKAO_MESSAGE_TYPE, type KakaoDeviceType } from '../types'
|
|
4
4
|
import {
|
|
5
5
|
BOOKING_HOST,
|
|
6
6
|
BOOKING_PORT,
|
|
@@ -126,6 +126,20 @@ export class LocoSession {
|
|
|
126
126
|
})
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
// Quoted reply — a WRITE with message_type 26 (REPLY) whose `extra` JSON
|
|
130
|
+
// carries the source-message reference. The reply semantics ride entirely on
|
|
131
|
+
// `type` + `extra`; no extra top-level WRITE fields are needed.
|
|
132
|
+
async sendReply(chatId: Long, text: string, extra: Record<string, unknown>): Promise<LocoPacket> {
|
|
133
|
+
if (!this.connection) throw new Error('Not connected')
|
|
134
|
+
return this.connection.sendPacket('WRITE', {
|
|
135
|
+
chatId,
|
|
136
|
+
msg: text,
|
|
137
|
+
type: KAKAO_MESSAGE_TYPE.REPLY,
|
|
138
|
+
noSeen: false,
|
|
139
|
+
extra: JSON.stringify(extra),
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
129
143
|
// Sends a WRITE with non-text message_type plus the JSON-stringified `extra`
|
|
130
144
|
// payload that KakaoTalk clients render as the attachment (photo, file, etc).
|
|
131
145
|
// See types.ts → KakaoPhotoExtra / KakaoFileExtra for the per-type shape.
|
|
@@ -126,6 +126,7 @@ export const KAKAO_MESSAGE_TYPE = {
|
|
|
126
126
|
VIDEO: 3,
|
|
127
127
|
AUDIO: 5,
|
|
128
128
|
FILE: 18,
|
|
129
|
+
REPLY: 26,
|
|
129
130
|
MULTIPHOTO: 27,
|
|
130
131
|
} as const
|
|
131
132
|
|
|
@@ -176,6 +177,29 @@ export interface KakaoMultiPhotoExtra {
|
|
|
176
177
|
expire?: number
|
|
177
178
|
}
|
|
178
179
|
|
|
180
|
+
// REPLY (type 26) extra. Field names verified against storycraft/node-kakao
|
|
181
|
+
// (src/chat/attachment/reply.ts) and jhleekr/kakao.py: `Id` fields are
|
|
182
|
+
// camelCase (src_logId, NOT src_log_id), and src_mentions/mentions are
|
|
183
|
+
// required even when empty.
|
|
184
|
+
export interface KakaoReplyExtra {
|
|
185
|
+
attach_only: boolean
|
|
186
|
+
attach_type: number
|
|
187
|
+
src_logId: string
|
|
188
|
+
src_userId: number
|
|
189
|
+
src_message: string
|
|
190
|
+
src_type: number
|
|
191
|
+
src_mentions: unknown[]
|
|
192
|
+
mentions: unknown[]
|
|
193
|
+
src_linkId?: string
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export interface KakaoReplyTarget {
|
|
197
|
+
log_id: string
|
|
198
|
+
author_id: number
|
|
199
|
+
message: string
|
|
200
|
+
type: number
|
|
201
|
+
}
|
|
202
|
+
|
|
179
203
|
export interface KakaoMarkReadResult {
|
|
180
204
|
success: boolean
|
|
181
205
|
status_code: number
|
|
@@ -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()
|
|
@@ -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
|
|
|
@@ -7,11 +7,19 @@ import { WebexTokenExtractor } from '../token-extractor'
|
|
|
7
7
|
import { WebexError } from '../types'
|
|
8
8
|
import { extractAction, loginAction, logoutAction, statusAction } from './auth'
|
|
9
9
|
|
|
10
|
+
class ProcessExit extends Error {
|
|
11
|
+
constructor(readonly code?: string | number | null) {
|
|
12
|
+
super(`process.exit(${code})`)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
10
16
|
describe('auth commands', () => {
|
|
11
17
|
let consoleSpy: ReturnType<typeof spyOn>
|
|
12
18
|
let consoleErrorSpy: ReturnType<typeof spyOn>
|
|
13
19
|
let execSpy: ReturnType<typeof spyOn>
|
|
14
20
|
const protoSpies: ReturnType<typeof spyOn>[] = []
|
|
21
|
+
let originalStdinTTY: boolean | undefined
|
|
22
|
+
let originalStdoutTTY: boolean | undefined
|
|
15
23
|
const mockPerson = {
|
|
16
24
|
id: 'person-1',
|
|
17
25
|
displayName: 'Test User',
|
|
@@ -27,10 +35,19 @@ describe('auth commands', () => {
|
|
|
27
35
|
return s
|
|
28
36
|
}
|
|
29
37
|
|
|
38
|
+
function setTTY(value: boolean | undefined): void {
|
|
39
|
+
Object.defineProperty(process.stdin, 'isTTY', { value, writable: true, configurable: true })
|
|
40
|
+
Object.defineProperty(process.stdout, 'isTTY', { value, writable: true, configurable: true })
|
|
41
|
+
}
|
|
42
|
+
|
|
30
43
|
beforeEach(() => {
|
|
31
44
|
consoleSpy = spyOn(console, 'log').mockImplementation(() => {})
|
|
32
45
|
consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {})
|
|
33
46
|
execSpy = spyOn(childProcess, 'exec').mockImplementation((() => {}) as any)
|
|
47
|
+
originalStdinTTY = process.stdin.isTTY
|
|
48
|
+
originalStdoutTTY = process.stdout.isTTY
|
|
49
|
+
// Default to interactive TTY for existing tests; non-interactive tests override.
|
|
50
|
+
setTTY(true)
|
|
34
51
|
})
|
|
35
52
|
|
|
36
53
|
afterEach(() => {
|
|
@@ -39,6 +56,12 @@ describe('auth commands', () => {
|
|
|
39
56
|
execSpy.mockRestore()
|
|
40
57
|
for (const s of protoSpies) s.mockRestore()
|
|
41
58
|
protoSpies.length = 0
|
|
59
|
+
setTTY(originalStdinTTY)
|
|
60
|
+
Object.defineProperty(process.stdout, 'isTTY', {
|
|
61
|
+
value: originalStdoutTTY,
|
|
62
|
+
writable: true,
|
|
63
|
+
configurable: true,
|
|
64
|
+
})
|
|
42
65
|
})
|
|
43
66
|
|
|
44
67
|
describe('loginAction with --token', () => {
|
|
@@ -151,6 +174,143 @@ describe('auth commands', () => {
|
|
|
151
174
|
})
|
|
152
175
|
})
|
|
153
176
|
|
|
177
|
+
describe('loginAction non-interactive (no TTY)', () => {
|
|
178
|
+
const device = {
|
|
179
|
+
deviceCode: 'webex-device-code-abc123',
|
|
180
|
+
userCode: 'USER-CODE',
|
|
181
|
+
verificationUri: 'https://webex.com/verify',
|
|
182
|
+
verificationUriComplete: 'https://webex.com/verify?user_code=USER-CODE',
|
|
183
|
+
expiresIn: 300,
|
|
184
|
+
interval: 1,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
it('first call (no --device-code): requests device code and returns it in JSON', async () => {
|
|
188
|
+
setTTY(false)
|
|
189
|
+
const requestSpy = protoSpy(WebexCredentialManager.prototype, 'requestDeviceCode').mockResolvedValue(device)
|
|
190
|
+
const exchangeSpy = protoSpy(WebexCredentialManager.prototype, 'exchangeDeviceCode')
|
|
191
|
+
const pollSpy = protoSpy(WebexCredentialManager.prototype, 'pollDeviceToken')
|
|
192
|
+
const exitSpy = protoSpy(process, 'exit').mockImplementation(() => undefined as never)
|
|
193
|
+
|
|
194
|
+
await loginAction({ pretty: false })
|
|
195
|
+
|
|
196
|
+
expect(requestSpy).toHaveBeenCalled()
|
|
197
|
+
expect(exchangeSpy).not.toHaveBeenCalled()
|
|
198
|
+
expect(pollSpy).not.toHaveBeenCalled()
|
|
199
|
+
|
|
200
|
+
const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
|
|
201
|
+
const output = JSON.parse(lastCall)
|
|
202
|
+
expect(output.next_action).toBe('authorize_in_browser')
|
|
203
|
+
expect(output.verification_uri).toBe(device.verificationUri)
|
|
204
|
+
expect(output.verification_uri_complete).toBe(device.verificationUriComplete)
|
|
205
|
+
expect(output.user_code).toBe(device.userCode)
|
|
206
|
+
expect(output.device_code).toBe(device.deviceCode)
|
|
207
|
+
expect(output.message).toContain('--device-code')
|
|
208
|
+
expect(exitSpy).toHaveBeenCalledWith(0)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('does not open a browser on the first non-interactive call', async () => {
|
|
212
|
+
setTTY(false)
|
|
213
|
+
protoSpy(WebexCredentialManager.prototype, 'requestDeviceCode').mockResolvedValue(device)
|
|
214
|
+
protoSpy(process, 'exit').mockImplementation(() => undefined as never)
|
|
215
|
+
|
|
216
|
+
await loginAction({ pretty: false })
|
|
217
|
+
|
|
218
|
+
expect(execSpy).not.toHaveBeenCalled()
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('second call (--device-code, pending): returns still_pending and echoes back the device_code', async () => {
|
|
222
|
+
setTTY(false)
|
|
223
|
+
protoSpy(WebexCredentialManager.prototype, 'exchangeDeviceCode').mockResolvedValue({ status: 'pending' })
|
|
224
|
+
const requestSpy = protoSpy(WebexCredentialManager.prototype, 'requestDeviceCode')
|
|
225
|
+
const exitSpy = protoSpy(process, 'exit').mockImplementation(() => undefined as never)
|
|
226
|
+
|
|
227
|
+
await loginAction({ deviceCode: 'webex-device-code-abc123', pretty: false })
|
|
228
|
+
|
|
229
|
+
expect(requestSpy).not.toHaveBeenCalled()
|
|
230
|
+
|
|
231
|
+
const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
|
|
232
|
+
const output = JSON.parse(lastCall)
|
|
233
|
+
expect(output.next_action).toBe('still_pending')
|
|
234
|
+
expect(output.device_code).toBe('webex-device-code-abc123')
|
|
235
|
+
expect(exitSpy).toHaveBeenCalledWith(0)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('second call (--device-code, success): saves token and returns authenticated=true', async () => {
|
|
239
|
+
setTTY(false)
|
|
240
|
+
const exchangeSpy = protoSpy(WebexCredentialManager.prototype, 'exchangeDeviceCode').mockResolvedValue({
|
|
241
|
+
status: 'success',
|
|
242
|
+
config: { accessToken: 'at', refreshToken: 'rt', expiresAt: Date.now() + 3_600_000 },
|
|
243
|
+
})
|
|
244
|
+
const saveConfigSpy = protoSpy(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
|
|
245
|
+
protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
|
|
246
|
+
protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
|
|
247
|
+
|
|
248
|
+
await loginAction({
|
|
249
|
+
deviceCode: 'webex-device-code-abc123',
|
|
250
|
+
clientId: 'my-id',
|
|
251
|
+
clientSecret: 'my-secret',
|
|
252
|
+
pretty: false,
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
expect(exchangeSpy).toHaveBeenCalledWith('webex-device-code-abc123', 'my-id', 'my-secret')
|
|
256
|
+
const savedConfig = saveConfigSpy.mock.calls[0][0] as { tokenType: string; clientId: string }
|
|
257
|
+
expect(savedConfig.tokenType).toBe('oauth')
|
|
258
|
+
expect(savedConfig.clientId).toBe('my-id')
|
|
259
|
+
|
|
260
|
+
const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
|
|
261
|
+
const output = JSON.parse(lastCall)
|
|
262
|
+
expect(output.authenticated).toBe(true)
|
|
263
|
+
expect(output.user.displayName).toBe('Test User')
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('second call (--device-code, expired): returns next_action=restart, exits 1', async () => {
|
|
267
|
+
setTTY(false)
|
|
268
|
+
protoSpy(WebexCredentialManager.prototype, 'exchangeDeviceCode').mockResolvedValue({ status: 'expired' })
|
|
269
|
+
const exitSpy = protoSpy(process, 'exit').mockImplementation(() => undefined as never)
|
|
270
|
+
|
|
271
|
+
await loginAction({ deviceCode: 'webex-device-code-abc123', pretty: false })
|
|
272
|
+
|
|
273
|
+
const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
|
|
274
|
+
const output = JSON.parse(lastCall)
|
|
275
|
+
expect(output.next_action).toBe('restart')
|
|
276
|
+
expect(output.error).toContain('expired')
|
|
277
|
+
expect(exitSpy).toHaveBeenCalledWith(1)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('--device-code works even with a TTY (interactive sessions can also resume)', async () => {
|
|
281
|
+
setTTY(true)
|
|
282
|
+
const exchangeSpy = protoSpy(WebexCredentialManager.prototype, 'exchangeDeviceCode').mockResolvedValue({
|
|
283
|
+
status: 'success',
|
|
284
|
+
config: { accessToken: 'at', refreshToken: 'rt', expiresAt: Date.now() + 3_600_000 },
|
|
285
|
+
})
|
|
286
|
+
const requestSpy = protoSpy(WebexCredentialManager.prototype, 'requestDeviceCode')
|
|
287
|
+
const pollSpy = protoSpy(WebexCredentialManager.prototype, 'pollDeviceToken')
|
|
288
|
+
protoSpy(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
|
|
289
|
+
protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
|
|
290
|
+
protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
|
|
291
|
+
|
|
292
|
+
await loginAction({ deviceCode: 'webex-device-code-abc123', pretty: false })
|
|
293
|
+
|
|
294
|
+
expect(exchangeSpy).toHaveBeenCalled()
|
|
295
|
+
expect(requestSpy).not.toHaveBeenCalled()
|
|
296
|
+
expect(pollSpy).not.toHaveBeenCalled()
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('still allows --token login when non-interactive (bot/PAT path)', async () => {
|
|
300
|
+
setTTY(false)
|
|
301
|
+
protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
|
|
302
|
+
protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
|
|
303
|
+
protoSpy(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
|
|
304
|
+
|
|
305
|
+
await loginAction({ token: 'bot-token-123', pretty: false })
|
|
306
|
+
|
|
307
|
+
const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
|
|
308
|
+
const output = JSON.parse(lastCall)
|
|
309
|
+
expect(output.authenticated).toBe(true)
|
|
310
|
+
expect(output.user.displayName).toBe('Test User')
|
|
311
|
+
})
|
|
312
|
+
})
|
|
313
|
+
|
|
154
314
|
describe('statusAction', () => {
|
|
155
315
|
it('shows authenticated status when token is valid', async () => {
|
|
156
316
|
protoSpy(WebexCredentialManager.prototype, 'loadConfig').mockResolvedValue(null)
|
|
@@ -287,15 +447,17 @@ describe('auth commands', () => {
|
|
|
287
447
|
protoSpy(WebexCredentialManager.prototype, 'refreshToken').mockResolvedValue(null)
|
|
288
448
|
protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
|
|
289
449
|
protoSpy(WebexClient.prototype, 'testAuth').mockRejectedValue(new Error('Network error'))
|
|
290
|
-
protoSpy(process, '
|
|
450
|
+
const stderrWriteSpy = protoSpy(process.stderr, 'write').mockImplementation(() => true)
|
|
451
|
+
const exitSpy = protoSpy(process, 'exit').mockImplementation((code?: string | number | null) => {
|
|
452
|
+
throw new ProcessExit(code)
|
|
453
|
+
})
|
|
291
454
|
|
|
292
|
-
await extractAction({ pretty: false })
|
|
455
|
+
await expect(extractAction({ pretty: false })).rejects.toThrow(ProcessExit)
|
|
293
456
|
|
|
294
|
-
const lastCall =
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
}
|
|
457
|
+
const lastCall = stderrWriteSpy.mock.calls[stderrWriteSpy.mock.calls.length - 1][0] as string
|
|
458
|
+
const output = JSON.parse(lastCall)
|
|
459
|
+
expect(output.error).toContain('Network error')
|
|
460
|
+
expect(exitSpy).toHaveBeenCalledWith(1)
|
|
299
461
|
})
|
|
300
462
|
|
|
301
463
|
it('rethrows non-expiry auth errors', async () => {
|
|
@@ -305,15 +467,17 @@ describe('auth commands', () => {
|
|
|
305
467
|
})
|
|
306
468
|
protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
|
|
307
469
|
protoSpy(WebexClient.prototype, 'testAuth').mockRejectedValue(new Error('Network error'))
|
|
308
|
-
protoSpy(process, '
|
|
470
|
+
const stderrWriteSpy = protoSpy(process.stderr, 'write').mockImplementation(() => true)
|
|
471
|
+
const exitSpy = protoSpy(process, 'exit').mockImplementation((code?: string | number | null) => {
|
|
472
|
+
throw new ProcessExit(code)
|
|
473
|
+
})
|
|
309
474
|
|
|
310
|
-
await extractAction({ pretty: false })
|
|
475
|
+
await expect(extractAction({ pretty: false })).rejects.toThrow(ProcessExit)
|
|
311
476
|
|
|
312
|
-
const lastCall =
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
}
|
|
477
|
+
const lastCall = stderrWriteSpy.mock.calls[stderrWriteSpy.mock.calls.length - 1][0] as string
|
|
478
|
+
const output = JSON.parse(lastCall)
|
|
479
|
+
expect(output.error).toContain('Network error')
|
|
480
|
+
expect(exitSpy).toHaveBeenCalledWith(1)
|
|
317
481
|
})
|
|
318
482
|
|
|
319
483
|
it('outputs no token found when extract returns null', async () => {
|