kimaki 0.4.78 → 0.4.80
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/dist/anthropic-auth-plugin.js +628 -0
- package/dist/channel-management.js +2 -2
- package/dist/cli.js +316 -129
- package/dist/commands/action-buttons.js +1 -1
- package/dist/commands/login.js +634 -277
- package/dist/commands/model.js +91 -6
- package/dist/commands/paginated-select.js +57 -0
- package/dist/commands/resume.js +2 -2
- package/dist/commands/tasks.js +205 -0
- package/dist/commands/undo-redo.js +80 -18
- package/dist/context-awareness-plugin.js +347 -0
- package/dist/database.js +103 -7
- package/dist/db.js +39 -1
- package/dist/discord-bot.js +42 -19
- package/dist/discord-urls.js +11 -0
- package/dist/discord-ws-proxy.js +350 -0
- package/dist/discord-ws-proxy.test.js +500 -0
- package/dist/errors.js +1 -1
- package/dist/gateway-session.js +163 -0
- package/dist/hrana-server.js +114 -4
- package/dist/interaction-handler.js +30 -7
- package/dist/ipc-tools-plugin.js +186 -0
- package/dist/message-preprocessing.js +56 -11
- package/dist/onboarding-welcome.js +1 -1
- package/dist/opencode-interrupt-plugin.js +133 -75
- package/dist/opencode-plugin.js +12 -389
- package/dist/opencode.js +59 -5
- package/dist/parse-permission-rules.test.js +117 -0
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
- package/dist/session-handler/thread-session-runtime.js +68 -29
- package/dist/startup-time.e2e.test.js +295 -0
- package/dist/store.js +1 -0
- package/dist/system-message.js +3 -1
- package/dist/task-runner.js +7 -3
- package/dist/task-schedule.js +12 -0
- package/dist/thread-message-queue.e2e.test.js +13 -1
- package/dist/undo-redo.e2e.test.js +166 -0
- package/dist/utils.js +4 -1
- package/dist/voice-attachment.js +34 -0
- package/dist/voice-handler.js +11 -9
- package/dist/voice-message.e2e.test.js +78 -0
- package/dist/voice.test.js +31 -0
- package/package.json +12 -7
- package/skills/egaki/SKILL.md +80 -15
- package/skills/errore/SKILL.md +13 -0
- package/skills/lintcn/SKILL.md +749 -0
- package/skills/npm-package/SKILL.md +17 -3
- package/skills/spiceflow/SKILL.md +14 -0
- package/skills/zele/SKILL.md +9 -0
- package/src/anthropic-auth-plugin.ts +732 -0
- package/src/channel-management.ts +2 -2
- package/src/cli.ts +354 -132
- package/src/commands/action-buttons.ts +1 -0
- package/src/commands/login.ts +836 -337
- package/src/commands/model.ts +102 -7
- package/src/commands/paginated-select.ts +81 -0
- package/src/commands/resume.ts +6 -1
- package/src/commands/tasks.ts +293 -0
- package/src/commands/undo-redo.ts +87 -20
- package/src/context-awareness-plugin.ts +469 -0
- package/src/database.ts +138 -7
- package/src/db.ts +40 -1
- package/src/discord-bot.ts +46 -19
- package/src/discord-urls.ts +12 -0
- package/src/errors.ts +1 -1
- package/src/hrana-server.ts +124 -3
- package/src/interaction-handler.ts +41 -9
- package/src/ipc-tools-plugin.ts +228 -0
- package/src/message-preprocessing.ts +82 -11
- package/src/onboarding-welcome.ts +1 -1
- package/src/opencode-interrupt-plugin.ts +164 -91
- package/src/opencode-plugin.ts +13 -483
- package/src/opencode.ts +60 -5
- package/src/parse-permission-rules.test.ts +127 -0
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
- package/src/session-handler/thread-runtime-state.ts +4 -1
- package/src/session-handler/thread-session-runtime.ts +82 -20
- package/src/startup-time.e2e.test.ts +372 -0
- package/src/store.ts +8 -0
- package/src/system-message.ts +10 -1
- package/src/task-runner.ts +9 -22
- package/src/task-schedule.ts +15 -0
- package/src/thread-message-queue.e2e.test.ts +14 -1
- package/src/undo-redo.e2e.test.ts +207 -0
- package/src/utils.ts +7 -0
- package/src/voice-attachment.ts +51 -0
- package/src/voice-handler.ts +15 -7
- package/src/voice-message.e2e.test.ts +95 -0
- package/src/voice.test.ts +36 -0
- package/src/onboarding-tutorial-plugin.ts +0 -93
package/src/commands/login.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
// /login command
|
|
2
|
-
//
|
|
1
|
+
// /login command — authenticate with AI providers (OAuth or API key).
|
|
2
|
+
//
|
|
3
|
+
// Uses a unified select handler (`login_select:<hash>`) for all sequential
|
|
4
|
+
// select menus (provider → method → plugin prompts). The context tracks a
|
|
5
|
+
// `step` field so one handler drives the whole flow.
|
|
6
|
+
//
|
|
7
|
+
// CustomId patterns:
|
|
8
|
+
// login_select:<hash> — all select menus (provider, method, prompts)
|
|
9
|
+
// login_apikey:<hash> — API key modal submission
|
|
10
|
+
// login_text:<hash> — text prompt modal submission
|
|
3
11
|
|
|
4
12
|
import {
|
|
5
13
|
ChatInputCommandInteraction,
|
|
@@ -10,37 +18,108 @@ import {
|
|
|
10
18
|
TextInputBuilder,
|
|
11
19
|
TextInputStyle,
|
|
12
20
|
ModalSubmitInteraction,
|
|
21
|
+
ButtonBuilder,
|
|
22
|
+
ButtonStyle,
|
|
23
|
+
type ButtonInteraction,
|
|
13
24
|
ChannelType,
|
|
14
25
|
type ThreadChannel,
|
|
15
26
|
type TextChannel,
|
|
16
27
|
MessageFlags,
|
|
17
28
|
} from 'discord.js'
|
|
29
|
+
import type { AuthHook } from '@opencode-ai/plugin'
|
|
18
30
|
import crypto from 'node:crypto'
|
|
19
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
initializeOpencodeForDirectory,
|
|
33
|
+
getOpencodeServerPort,
|
|
34
|
+
} from '../opencode.js'
|
|
20
35
|
import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js'
|
|
21
36
|
import { createLogger, LogPrefix } from '../logger.js'
|
|
37
|
+
import { buildPaginatedOptions, parsePaginationValue } from './paginated-select.js'
|
|
22
38
|
|
|
23
39
|
const loginLogger = createLogger(LogPrefix.LOGIN)
|
|
24
40
|
|
|
25
|
-
//
|
|
26
|
-
//
|
|
41
|
+
// ── Types ───────────────────────────────────────────────────────
|
|
42
|
+
// Derive prompt types from the plugin package so they stay in sync.
|
|
43
|
+
// Strip runtime-only callback fields (validate, condition) that
|
|
44
|
+
// aren't present in the REST response from the opencode server.
|
|
45
|
+
// Add `when` rule — the server's zod schema includes it but the
|
|
46
|
+
// published plugin package hasn't been updated yet.
|
|
47
|
+
|
|
48
|
+
type WhenRule = { key: string; op: 'eq' | 'neq'; value: string }
|
|
49
|
+
|
|
50
|
+
// Extract prompt option type from the plugin's select prompt
|
|
51
|
+
type PluginMethod = AuthHook['methods'][number]
|
|
52
|
+
type PluginSelectPrompt = Extract<
|
|
53
|
+
NonNullable<PluginMethod['prompts']>[number],
|
|
54
|
+
{ type: 'select' }
|
|
55
|
+
>
|
|
56
|
+
type PromptOption = PluginSelectPrompt['options'][number]
|
|
57
|
+
|
|
58
|
+
type AuthPromptText = {
|
|
59
|
+
type: 'text'
|
|
60
|
+
key: string
|
|
61
|
+
message: string
|
|
62
|
+
placeholder?: string
|
|
63
|
+
when?: WhenRule
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type AuthPromptSelect = {
|
|
67
|
+
type: 'select'
|
|
68
|
+
key: string
|
|
69
|
+
message: string
|
|
70
|
+
options: PromptOption[]
|
|
71
|
+
when?: WhenRule
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type AuthPrompt = AuthPromptText | AuthPromptSelect
|
|
75
|
+
|
|
76
|
+
type ProviderAuthMethod = {
|
|
77
|
+
type: 'oauth' | 'api'
|
|
78
|
+
label: string
|
|
79
|
+
prompts?: AuthPrompt[]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Login step state machine ────────────────────────────────────
|
|
83
|
+
// Each step describes what the next select menu should show.
|
|
84
|
+
// Steps are built lazily: provider step is set by /login, method
|
|
85
|
+
// and prompt steps are added after the provider is selected.
|
|
86
|
+
|
|
87
|
+
type StepProvider = { type: 'provider' }
|
|
88
|
+
type StepMethod = { type: 'method'; methods: ProviderAuthMethod[] }
|
|
89
|
+
type StepPrompt = { type: 'prompt'; prompt: AuthPrompt }
|
|
90
|
+
type LoginStep = StepProvider | StepMethod | StepPrompt
|
|
91
|
+
|
|
92
|
+
type LoginContext = {
|
|
93
|
+
dir: string
|
|
94
|
+
channelId: string
|
|
95
|
+
providerId?: string
|
|
96
|
+
providerName?: string
|
|
97
|
+
methodIndex?: number
|
|
98
|
+
methodType?: 'oauth' | 'api'
|
|
99
|
+
steps: LoginStep[]
|
|
100
|
+
stepIndex: number
|
|
101
|
+
inputs: Record<string, string>
|
|
102
|
+
providerPage?: number
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Context store ───────────────────────────────────────────────
|
|
106
|
+
// Keyed by random hash to stay under Discord's 100-char customId limit.
|
|
107
|
+
// TTL prevents unbounded growth when users open /login and never interact.
|
|
108
|
+
|
|
27
109
|
const LOGIN_CONTEXT_TTL_MS = 10 * 60 * 1000
|
|
28
|
-
const pendingLoginContexts = new Map<
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
// Popularity-ordered provider IDs for the select menu.
|
|
42
|
-
// Discord select menus cap at 25 options, so we show these first,
|
|
43
|
-
// then fill remaining slots with unlisted providers alphabetically.
|
|
110
|
+
const pendingLoginContexts = new Map<string, LoginContext>()
|
|
111
|
+
|
|
112
|
+
function createContextHash(context: LoginContext): string {
|
|
113
|
+
const hash = crypto.randomBytes(8).toString('hex')
|
|
114
|
+
pendingLoginContexts.set(hash, context)
|
|
115
|
+
setTimeout(() => {
|
|
116
|
+
pendingLoginContexts.delete(hash)
|
|
117
|
+
}, LOGIN_CONTEXT_TTL_MS).unref()
|
|
118
|
+
return hash
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Provider popularity order ───────────────────────────────────
|
|
122
|
+
// Discord select menus cap at 25 options, so we show popular ones first.
|
|
44
123
|
// IDs sourced from opencode's provider.list() API (scripts/list-providers.ts).
|
|
45
124
|
const PROVIDER_POPULARITY_ORDER: string[] = [
|
|
46
125
|
'anthropic',
|
|
@@ -70,15 +149,57 @@ const PROVIDER_POPULARITY_ORDER: string[] = [
|
|
|
70
149
|
'llama',
|
|
71
150
|
]
|
|
72
151
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
152
|
+
// ── Helpers ─────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
function extractErrorMessage({
|
|
155
|
+
error,
|
|
156
|
+
fallback,
|
|
157
|
+
}: {
|
|
158
|
+
error: unknown
|
|
159
|
+
fallback: string
|
|
160
|
+
}): string {
|
|
161
|
+
if (!error || typeof error !== 'object') {
|
|
162
|
+
return fallback
|
|
163
|
+
}
|
|
164
|
+
const parsed = error as { message?: string; data?: { message?: string } }
|
|
165
|
+
return parsed.data?.message || parsed.message || fallback
|
|
76
166
|
}
|
|
77
167
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
168
|
+
function shouldShowPrompt(
|
|
169
|
+
prompt: AuthPrompt,
|
|
170
|
+
inputs: Record<string, string>,
|
|
171
|
+
): boolean {
|
|
172
|
+
if (!prompt.when) {
|
|
173
|
+
return true
|
|
174
|
+
}
|
|
175
|
+
const value = inputs[prompt.when.key]
|
|
176
|
+
if (prompt.when.op === 'eq') {
|
|
177
|
+
return value === prompt.when.value
|
|
178
|
+
}
|
|
179
|
+
if (prompt.when.op === 'neq') {
|
|
180
|
+
return value !== prompt.when.value
|
|
181
|
+
}
|
|
182
|
+
return true
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function buildSelectMenu({
|
|
186
|
+
customId,
|
|
187
|
+
placeholder,
|
|
188
|
+
options,
|
|
189
|
+
}: {
|
|
190
|
+
customId: string
|
|
191
|
+
placeholder: string
|
|
192
|
+
options: Array<{ label: string; value: string; description?: string }>
|
|
193
|
+
}): ActionRowBuilder<StringSelectMenuBuilder> {
|
|
194
|
+
const menu = new StringSelectMenuBuilder()
|
|
195
|
+
.setCustomId(customId)
|
|
196
|
+
.setPlaceholder(placeholder)
|
|
197
|
+
.addOptions(options)
|
|
198
|
+
return new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(menu)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── /login command ──────────────────────────────────────────────
|
|
202
|
+
|
|
82
203
|
export async function handleLoginCommand({
|
|
83
204
|
interaction,
|
|
84
205
|
}: {
|
|
@@ -87,12 +208,9 @@ export async function handleLoginCommand({
|
|
|
87
208
|
}): Promise<void> {
|
|
88
209
|
loginLogger.log('[LOGIN] handleLoginCommand called')
|
|
89
210
|
|
|
90
|
-
// Defer reply immediately to avoid 3-second timeout
|
|
91
211
|
await interaction.deferReply({ flags: MessageFlags.Ephemeral })
|
|
92
|
-
loginLogger.log('[LOGIN] Deferred reply')
|
|
93
212
|
|
|
94
213
|
const channel = interaction.channel
|
|
95
|
-
|
|
96
214
|
if (!channel) {
|
|
97
215
|
await interaction.editReply({
|
|
98
216
|
content: 'This command can only be used in a channel',
|
|
@@ -100,7 +218,6 @@ export async function handleLoginCommand({
|
|
|
100
218
|
return
|
|
101
219
|
}
|
|
102
220
|
|
|
103
|
-
// Determine if we're in a thread or text channel
|
|
104
221
|
const isThread = [
|
|
105
222
|
ChannelType.PublicThread,
|
|
106
223
|
ChannelType.PrivateThread,
|
|
@@ -147,24 +264,18 @@ export async function handleLoginCommand({
|
|
|
147
264
|
})
|
|
148
265
|
|
|
149
266
|
if (!providersResponse.data) {
|
|
150
|
-
await interaction.editReply({
|
|
151
|
-
content: 'Failed to fetch providers',
|
|
152
|
-
})
|
|
267
|
+
await interaction.editReply({ content: 'Failed to fetch providers' })
|
|
153
268
|
return
|
|
154
269
|
}
|
|
155
270
|
|
|
156
271
|
const { all: allProviders, connected } = providersResponse.data
|
|
157
272
|
|
|
158
273
|
if (allProviders.length === 0) {
|
|
159
|
-
await interaction.editReply({
|
|
160
|
-
content: 'No providers available.',
|
|
161
|
-
})
|
|
274
|
+
await interaction.editReply({ content: 'No providers available.' })
|
|
162
275
|
return
|
|
163
276
|
}
|
|
164
277
|
|
|
165
|
-
|
|
166
|
-
// Discord select menus cap at 25, so we show the most popular providers.
|
|
167
|
-
const options = [...allProviders]
|
|
278
|
+
const allProviderOptions = [...allProviders]
|
|
168
279
|
.sort((a, b) => {
|
|
169
280
|
const rankA = PROVIDER_POPULARITY_ORDER.indexOf(a.id)
|
|
170
281
|
const rankB = PROVIDER_POPULARITY_ORDER.indexOf(b.id)
|
|
@@ -175,14 +286,10 @@ export async function handleLoginCommand({
|
|
|
175
286
|
}
|
|
176
287
|
return a.name.localeCompare(b.name)
|
|
177
288
|
})
|
|
178
|
-
.slice(0, 25)
|
|
179
289
|
.map((provider) => {
|
|
180
290
|
const isConnected = connected.includes(provider.id)
|
|
181
291
|
return {
|
|
182
|
-
label: `${provider.name}${isConnected ? ' ✓' : ''}`.slice(
|
|
183
|
-
0,
|
|
184
|
-
100,
|
|
185
|
-
),
|
|
292
|
+
label: `${provider.name}${isConnected ? ' ✓' : ''}`.slice(0, 100),
|
|
186
293
|
value: provider.id,
|
|
187
294
|
description: isConnected
|
|
188
295
|
? 'Connected - select to re-authenticate'
|
|
@@ -190,28 +297,29 @@ export async function handleLoginCommand({
|
|
|
190
297
|
}
|
|
191
298
|
})
|
|
192
299
|
|
|
193
|
-
|
|
194
|
-
|
|
300
|
+
const { options } = buildPaginatedOptions({
|
|
301
|
+
allOptions: allProviderOptions,
|
|
302
|
+
page: 0,
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
const context: LoginContext = {
|
|
195
306
|
dir: projectDirectory,
|
|
196
307
|
channelId: targetChannelId,
|
|
308
|
+
steps: [{ type: 'provider' }],
|
|
309
|
+
stepIndex: 0,
|
|
310
|
+
inputs: {},
|
|
197
311
|
}
|
|
198
|
-
const
|
|
199
|
-
pendingLoginContexts.set(contextHash, context)
|
|
200
|
-
setTimeout(() => {
|
|
201
|
-
pendingLoginContexts.delete(contextHash)
|
|
202
|
-
}, LOGIN_CONTEXT_TTL_MS).unref()
|
|
203
|
-
|
|
204
|
-
const selectMenu = new StringSelectMenuBuilder()
|
|
205
|
-
.setCustomId(`login_provider:${contextHash}`)
|
|
206
|
-
.setPlaceholder('Select a provider to authenticate')
|
|
207
|
-
.addOptions(options)
|
|
208
|
-
|
|
209
|
-
const actionRow =
|
|
210
|
-
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
|
|
312
|
+
const hash = createContextHash(context)
|
|
211
313
|
|
|
212
314
|
await interaction.editReply({
|
|
213
315
|
content: '**Authenticate with Provider**\nSelect a provider:',
|
|
214
|
-
components: [
|
|
316
|
+
components: [
|
|
317
|
+
buildSelectMenu({
|
|
318
|
+
customId: `login_select:${hash}`,
|
|
319
|
+
placeholder: 'Select a provider to authenticate',
|
|
320
|
+
options,
|
|
321
|
+
}),
|
|
322
|
+
],
|
|
215
323
|
})
|
|
216
324
|
} catch (error) {
|
|
217
325
|
loginLogger.error('Error loading providers:', error)
|
|
@@ -221,23 +329,22 @@ export async function handleLoginCommand({
|
|
|
221
329
|
}
|
|
222
330
|
}
|
|
223
331
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
332
|
+
// ── Unified select handler ──────────────────────────────────────
|
|
333
|
+
// Handles all select menu interactions for the login flow.
|
|
334
|
+
// Reads the current step from context, processes the answer,
|
|
335
|
+
// then either shows the next step or proceeds to authorize/API key.
|
|
336
|
+
|
|
337
|
+
export async function handleLoginSelect(
|
|
229
338
|
interaction: StringSelectMenuInteraction,
|
|
230
339
|
): Promise<void> {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
if (!customId.startsWith('login_provider:')) {
|
|
340
|
+
if (!interaction.customId.startsWith('login_select:')) {
|
|
234
341
|
return
|
|
235
342
|
}
|
|
236
343
|
|
|
237
|
-
const
|
|
238
|
-
const
|
|
344
|
+
const hash = interaction.customId.replace('login_select:', '')
|
|
345
|
+
const ctx = pendingLoginContexts.get(hash)
|
|
239
346
|
|
|
240
|
-
if (!
|
|
347
|
+
if (!ctx) {
|
|
241
348
|
await interaction.deferUpdate()
|
|
242
349
|
await interaction.editReply({
|
|
243
350
|
content: 'Selection expired. Please run /login again.',
|
|
@@ -246,97 +353,280 @@ export async function handleLoginProviderSelectMenu(
|
|
|
246
353
|
return
|
|
247
354
|
}
|
|
248
355
|
|
|
249
|
-
const
|
|
250
|
-
if (!
|
|
356
|
+
const value = interaction.values[0]
|
|
357
|
+
if (!value) {
|
|
358
|
+
await interaction.deferUpdate()
|
|
359
|
+
await interaction.editReply({
|
|
360
|
+
content: 'No option selected.',
|
|
361
|
+
components: [],
|
|
362
|
+
})
|
|
363
|
+
return
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const step = ctx.steps[ctx.stepIndex]
|
|
367
|
+
if (!step) {
|
|
251
368
|
await interaction.deferUpdate()
|
|
252
369
|
await interaction.editReply({
|
|
253
|
-
content: '
|
|
370
|
+
content: 'Invalid state. Please run /login again.',
|
|
254
371
|
components: [],
|
|
255
372
|
})
|
|
256
373
|
return
|
|
257
374
|
}
|
|
258
375
|
|
|
259
376
|
try {
|
|
260
|
-
|
|
261
|
-
|
|
377
|
+
if (step.type === 'provider') {
|
|
378
|
+
await handleProviderStep(interaction, ctx, hash, value)
|
|
379
|
+
} else if (step.type === 'method') {
|
|
380
|
+
await handleMethodStep(interaction, ctx, hash, value, step)
|
|
381
|
+
} else if (step.type === 'prompt') {
|
|
382
|
+
await handlePromptStep(interaction, ctx, hash, value, step)
|
|
383
|
+
}
|
|
384
|
+
} catch (error) {
|
|
385
|
+
loginLogger.error('Error in login select:', error)
|
|
386
|
+
if (!interaction.deferred && !interaction.replied) {
|
|
262
387
|
await interaction.deferUpdate()
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
388
|
+
}
|
|
389
|
+
await interaction.editReply({
|
|
390
|
+
content: `Login error: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
391
|
+
components: [],
|
|
392
|
+
})
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ── Step handlers ───────────────────────────────────────────────
|
|
397
|
+
|
|
398
|
+
async function handleProviderStep(
|
|
399
|
+
interaction: StringSelectMenuInteraction,
|
|
400
|
+
ctx: LoginContext,
|
|
401
|
+
hash: string,
|
|
402
|
+
providerId: string,
|
|
403
|
+
): Promise<void> {
|
|
404
|
+
// Handle pagination nav — re-render the same provider select with new page
|
|
405
|
+
const navPage = parsePaginationValue(providerId)
|
|
406
|
+
if (navPage !== undefined) {
|
|
407
|
+
await interaction.deferUpdate()
|
|
408
|
+
ctx.providerPage = navPage
|
|
409
|
+
|
|
410
|
+
const getClient = await initializeOpencodeForDirectory(ctx.dir)
|
|
411
|
+
if (getClient instanceof Error) {
|
|
412
|
+
await interaction.editReply({ content: getClient.message, components: [] })
|
|
413
|
+
return
|
|
414
|
+
}
|
|
415
|
+
const providersResponse = await getClient().provider.list({ directory: ctx.dir })
|
|
416
|
+
if (!providersResponse.data) {
|
|
417
|
+
await interaction.editReply({ content: 'Failed to fetch providers', components: [] })
|
|
267
418
|
return
|
|
268
419
|
}
|
|
420
|
+
const { all: allProviders, connected } = providersResponse.data
|
|
421
|
+
const allProviderOptions = [...allProviders]
|
|
422
|
+
.sort((a, b) => {
|
|
423
|
+
const rankA = PROVIDER_POPULARITY_ORDER.indexOf(a.id)
|
|
424
|
+
const rankB = PROVIDER_POPULARITY_ORDER.indexOf(b.id)
|
|
425
|
+
const posA = rankA === -1 ? Infinity : rankA
|
|
426
|
+
const posB = rankB === -1 ? Infinity : rankB
|
|
427
|
+
if (posA !== posB) {
|
|
428
|
+
return posA - posB
|
|
429
|
+
}
|
|
430
|
+
return a.name.localeCompare(b.name)
|
|
431
|
+
})
|
|
432
|
+
.map((p) => {
|
|
433
|
+
const isConnected = connected.includes(p.id)
|
|
434
|
+
return {
|
|
435
|
+
label: `${p.name}${isConnected ? ' ✓' : ''}`.slice(0, 100),
|
|
436
|
+
value: p.id,
|
|
437
|
+
description: isConnected ? 'Connected - select to re-authenticate' : 'Not connected',
|
|
438
|
+
}
|
|
439
|
+
})
|
|
440
|
+
const { options } = buildPaginatedOptions({ allOptions: allProviderOptions, page: navPage })
|
|
441
|
+
await interaction.editReply({
|
|
442
|
+
content: '**Authenticate with Provider**\nSelect a provider:',
|
|
443
|
+
components: [
|
|
444
|
+
buildSelectMenu({
|
|
445
|
+
customId: `login_select:${hash}`,
|
|
446
|
+
placeholder: 'Select a provider to authenticate',
|
|
447
|
+
options,
|
|
448
|
+
}),
|
|
449
|
+
],
|
|
450
|
+
})
|
|
451
|
+
return
|
|
452
|
+
}
|
|
269
453
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
454
|
+
const getClient = await initializeOpencodeForDirectory(ctx.dir)
|
|
455
|
+
if (getClient instanceof Error) {
|
|
456
|
+
await interaction.deferUpdate()
|
|
457
|
+
await interaction.editReply({ content: getClient.message, components: [] })
|
|
458
|
+
return
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const providersResponse = await getClient().provider.list({
|
|
462
|
+
directory: ctx.dir,
|
|
463
|
+
})
|
|
464
|
+
const provider = providersResponse.data?.all.find(
|
|
465
|
+
(p) => p.id === providerId,
|
|
466
|
+
)
|
|
467
|
+
const providerName = provider?.name || providerId
|
|
468
|
+
|
|
469
|
+
const authResponse = await getClient().provider.auth({ directory: ctx.dir })
|
|
470
|
+
if (!authResponse.data) {
|
|
471
|
+
await interaction.deferUpdate()
|
|
472
|
+
await interaction.editReply({
|
|
473
|
+
content: 'Failed to fetch authentication methods',
|
|
474
|
+
components: [],
|
|
273
475
|
})
|
|
476
|
+
return
|
|
477
|
+
}
|
|
274
478
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
479
|
+
// The server returns prompts in the auth response when the opencode
|
|
480
|
+
// version supports it (dev branch, not yet released as of v1.2.27).
|
|
481
|
+
// Once released, plugin-defined prompts will be collected and passed
|
|
482
|
+
// as inputs to the authorize call automatically.
|
|
483
|
+
const methods: ProviderAuthMethod[] = authResponse.data[providerId] || [
|
|
484
|
+
{ type: 'api', label: 'API Key' },
|
|
485
|
+
]
|
|
279
486
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
487
|
+
if (methods.length === 0) {
|
|
488
|
+
await interaction.deferUpdate()
|
|
489
|
+
await interaction.editReply({
|
|
490
|
+
content: `No authentication methods available for ${providerName}`,
|
|
491
|
+
components: [],
|
|
283
492
|
})
|
|
493
|
+
return
|
|
494
|
+
}
|
|
284
495
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
await interaction.editReply({
|
|
288
|
-
content: 'Failed to fetch authentication methods',
|
|
289
|
-
components: [],
|
|
290
|
-
})
|
|
291
|
-
return
|
|
292
|
-
}
|
|
496
|
+
ctx.providerId = providerId
|
|
497
|
+
ctx.providerName = providerName
|
|
293
498
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
499
|
+
if (methods.length === 1) {
|
|
500
|
+
// Single method — skip method select, go straight to prompts or action
|
|
501
|
+
const method = methods[0]!
|
|
502
|
+
ctx.methodIndex = 0
|
|
503
|
+
ctx.methodType = method.type
|
|
298
504
|
|
|
299
|
-
|
|
505
|
+
const promptSteps = buildPromptSteps(method)
|
|
506
|
+
if (promptSteps.length > 0) {
|
|
507
|
+
// Has prompts — defer and show first prompt
|
|
508
|
+
ctx.steps = promptSteps
|
|
509
|
+
ctx.stepIndex = 0
|
|
300
510
|
await interaction.deferUpdate()
|
|
301
|
-
await interaction
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
511
|
+
await showNextStep(interaction, ctx, hash)
|
|
512
|
+
} else if (method.type === 'api') {
|
|
513
|
+
// API key with no prompts — show modal directly (don't defer)
|
|
514
|
+
await showApiKeyModal(interaction, hash, providerName)
|
|
515
|
+
} else {
|
|
516
|
+
// OAuth with no prompts — defer and authorize
|
|
517
|
+
await interaction.deferUpdate()
|
|
518
|
+
await startOAuthFlow(interaction, ctx, hash)
|
|
306
519
|
}
|
|
520
|
+
return
|
|
521
|
+
}
|
|
307
522
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
context.methodIndex = 0
|
|
317
|
-
context.methodType = method.type
|
|
318
|
-
context.methodLabel = method.label
|
|
319
|
-
pendingLoginContexts.set(contextHash, context)
|
|
320
|
-
await showApiKeyModal(interaction, contextHash, providerName)
|
|
321
|
-
return
|
|
322
|
-
}
|
|
523
|
+
// Multiple methods — show method select
|
|
524
|
+
ctx.steps = [
|
|
525
|
+
{ type: 'method', methods },
|
|
526
|
+
]
|
|
527
|
+
ctx.stepIndex = 0
|
|
528
|
+
await interaction.deferUpdate()
|
|
529
|
+
await showNextStep(interaction, ctx, hash)
|
|
530
|
+
}
|
|
323
531
|
|
|
324
|
-
|
|
532
|
+
async function handleMethodStep(
|
|
533
|
+
interaction: StringSelectMenuInteraction,
|
|
534
|
+
ctx: LoginContext,
|
|
535
|
+
hash: string,
|
|
536
|
+
value: string,
|
|
537
|
+
step: StepMethod,
|
|
538
|
+
): Promise<void> {
|
|
539
|
+
const methodIndex = parseInt(value, 10)
|
|
540
|
+
const method = step.methods[methodIndex]
|
|
541
|
+
if (!method) {
|
|
325
542
|
await interaction.deferUpdate()
|
|
543
|
+
await interaction.editReply({
|
|
544
|
+
content: 'Invalid method selected.',
|
|
545
|
+
components: [],
|
|
546
|
+
})
|
|
547
|
+
return
|
|
548
|
+
}
|
|
326
549
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
550
|
+
ctx.methodIndex = methodIndex
|
|
551
|
+
ctx.methodType = method.type
|
|
552
|
+
|
|
553
|
+
const promptSteps = buildPromptSteps(method)
|
|
554
|
+
if (promptSteps.length > 0) {
|
|
555
|
+
// Replace remaining steps with prompt steps
|
|
556
|
+
ctx.steps = promptSteps
|
|
557
|
+
ctx.stepIndex = 0
|
|
558
|
+
await interaction.deferUpdate()
|
|
559
|
+
await showNextStep(interaction, ctx, hash)
|
|
560
|
+
} else if (method.type === 'api') {
|
|
561
|
+
// API key with no prompts — show modal directly (don't defer)
|
|
562
|
+
await showApiKeyModal(interaction, hash, ctx.providerName || '')
|
|
563
|
+
} else {
|
|
564
|
+
// OAuth with no prompts
|
|
565
|
+
await interaction.deferUpdate()
|
|
566
|
+
await startOAuthFlow(interaction, ctx, hash)
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async function handlePromptStep(
|
|
571
|
+
interaction: StringSelectMenuInteraction,
|
|
572
|
+
ctx: LoginContext,
|
|
573
|
+
hash: string,
|
|
574
|
+
value: string,
|
|
575
|
+
step: StepPrompt,
|
|
576
|
+
): Promise<void> {
|
|
577
|
+
// Store the answer
|
|
578
|
+
ctx.inputs[step.prompt.key] = value
|
|
579
|
+
ctx.stepIndex++
|
|
580
|
+
|
|
581
|
+
// Find the next prompt step that passes its `when` condition
|
|
582
|
+
await interaction.deferUpdate()
|
|
583
|
+
await showNextStep(interaction, ctx, hash)
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// ── Step rendering ──────────────────────────────────────────────
|
|
587
|
+
// Advances through steps, skipping prompts whose `when` condition
|
|
588
|
+
// fails, until it finds one to show or reaches the end.
|
|
589
|
+
|
|
590
|
+
async function showNextStep(
|
|
591
|
+
interaction: StringSelectMenuInteraction | ModalSubmitInteraction,
|
|
592
|
+
ctx: LoginContext,
|
|
593
|
+
hash: string,
|
|
594
|
+
): Promise<void> {
|
|
595
|
+
// Skip prompts whose `when` condition doesn't match
|
|
596
|
+
while (ctx.stepIndex < ctx.steps.length) {
|
|
597
|
+
const step = ctx.steps[ctx.stepIndex]!
|
|
598
|
+
if (step.type === 'prompt' && !shouldShowPrompt(step.prompt, ctx.inputs)) {
|
|
599
|
+
ctx.stepIndex++
|
|
600
|
+
continue
|
|
336
601
|
}
|
|
602
|
+
break
|
|
603
|
+
}
|
|
337
604
|
|
|
338
|
-
|
|
339
|
-
|
|
605
|
+
if (ctx.stepIndex >= ctx.steps.length) {
|
|
606
|
+
// All steps done — proceed to action
|
|
607
|
+
if (ctx.methodType === 'api') {
|
|
608
|
+
// We're deferred, so show a button that opens the API key modal
|
|
609
|
+
const button = new ButtonBuilder()
|
|
610
|
+
.setCustomId(`login_apikey_btn:${hash}`)
|
|
611
|
+
.setLabel('Enter API Key')
|
|
612
|
+
.setStyle(ButtonStyle.Primary)
|
|
613
|
+
await interaction.editReply({
|
|
614
|
+
content: `**Authenticate with ${ctx.providerName}**\nClick to enter your API key.`,
|
|
615
|
+
components: [
|
|
616
|
+
new ActionRowBuilder<ButtonBuilder>().addComponents(button),
|
|
617
|
+
],
|
|
618
|
+
})
|
|
619
|
+
} else {
|
|
620
|
+
await startOAuthFlow(interaction, ctx, hash)
|
|
621
|
+
}
|
|
622
|
+
return
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const step = ctx.steps[ctx.stepIndex]!
|
|
626
|
+
pendingLoginContexts.set(hash, ctx)
|
|
627
|
+
|
|
628
|
+
if (step.type === 'method') {
|
|
629
|
+
const options = step.methods.slice(0, 25).map((method, index) => ({
|
|
340
630
|
label: method.label.slice(0, 100),
|
|
341
631
|
value: String(index),
|
|
342
632
|
description:
|
|
@@ -345,48 +635,129 @@ export async function handleLoginProviderSelectMenu(
|
|
|
345
635
|
: 'Enter API key manually',
|
|
346
636
|
}))
|
|
347
637
|
|
|
348
|
-
const selectMenu = new StringSelectMenuBuilder()
|
|
349
|
-
.setCustomId(`login_method:${contextHash}`)
|
|
350
|
-
.setPlaceholder('Select authentication method')
|
|
351
|
-
.addOptions(options)
|
|
352
|
-
|
|
353
|
-
const actionRow =
|
|
354
|
-
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
|
|
355
|
-
|
|
356
638
|
await interaction.editReply({
|
|
357
|
-
content: `**Authenticate with ${providerName}**\nSelect authentication method:`,
|
|
358
|
-
components: [
|
|
639
|
+
content: `**Authenticate with ${ctx.providerName}**\nSelect authentication method:`,
|
|
640
|
+
components: [
|
|
641
|
+
buildSelectMenu({
|
|
642
|
+
customId: `login_select:${hash}`,
|
|
643
|
+
placeholder: 'Select authentication method',
|
|
644
|
+
options,
|
|
645
|
+
}),
|
|
646
|
+
],
|
|
359
647
|
})
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
648
|
+
return
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (step.type === 'prompt') {
|
|
652
|
+
const prompt = step.prompt
|
|
653
|
+
if (prompt.type === 'select') {
|
|
654
|
+
const options = prompt.options.slice(0, 25).map((opt) => ({
|
|
655
|
+
label: opt.label.slice(0, 100),
|
|
656
|
+
value: opt.value,
|
|
657
|
+
description: opt.hint?.slice(0, 100),
|
|
658
|
+
}))
|
|
659
|
+
|
|
660
|
+
await interaction.editReply({
|
|
661
|
+
content: `**Authenticate with ${ctx.providerName}**\n${prompt.message}`,
|
|
662
|
+
components: [
|
|
663
|
+
buildSelectMenu({
|
|
664
|
+
customId: `login_select:${hash}`,
|
|
665
|
+
placeholder: prompt.message.slice(0, 150),
|
|
666
|
+
options,
|
|
667
|
+
}),
|
|
668
|
+
],
|
|
669
|
+
})
|
|
670
|
+
return
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (prompt.type === 'text') {
|
|
674
|
+
// Text prompts need a modal, but we're deferred. Show a button.
|
|
675
|
+
const button = new ButtonBuilder()
|
|
676
|
+
.setCustomId(`login_text_btn:${hash}`)
|
|
677
|
+
.setLabel(prompt.message.slice(0, 80))
|
|
678
|
+
.setStyle(ButtonStyle.Primary)
|
|
679
|
+
|
|
680
|
+
await interaction.editReply({
|
|
681
|
+
content: `**Authenticate with ${ctx.providerName}**\n${prompt.message}`,
|
|
682
|
+
components: [
|
|
683
|
+
new ActionRowBuilder<ButtonBuilder>().addComponents(button),
|
|
684
|
+
],
|
|
685
|
+
})
|
|
686
|
+
return
|
|
364
687
|
}
|
|
365
|
-
await interaction.editReply({
|
|
366
|
-
content: `Failed to load auth methods: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
367
|
-
components: [],
|
|
368
|
-
})
|
|
369
688
|
}
|
|
370
689
|
}
|
|
371
690
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
691
|
+
function buildPromptSteps(method: ProviderAuthMethod): StepPrompt[] {
|
|
692
|
+
return (method.prompts || []).map((prompt) => ({
|
|
693
|
+
type: 'prompt' as const,
|
|
694
|
+
prompt,
|
|
695
|
+
}))
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// ── Text prompt button + modal ──────────────────────────────────
|
|
699
|
+
// When a text prompt needs to be shown but we're in a deferred state,
|
|
700
|
+
// we show a button. Clicking it opens a modal for text input.
|
|
701
|
+
|
|
702
|
+
export async function handleLoginTextButton(
|
|
703
|
+
interaction: ButtonInteraction,
|
|
378
704
|
): Promise<void> {
|
|
379
|
-
|
|
705
|
+
if (!interaction.customId.startsWith('login_text_btn:')) {
|
|
706
|
+
return
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const hash = interaction.customId.replace('login_text_btn:', '')
|
|
710
|
+
const ctx = pendingLoginContexts.get(hash)
|
|
380
711
|
|
|
381
|
-
if (!
|
|
712
|
+
if (!ctx) {
|
|
713
|
+
await interaction.reply({
|
|
714
|
+
content: 'Selection expired. Please run /login again.',
|
|
715
|
+
flags: MessageFlags.Ephemeral,
|
|
716
|
+
})
|
|
382
717
|
return
|
|
383
718
|
}
|
|
384
719
|
|
|
385
|
-
const
|
|
386
|
-
|
|
720
|
+
const step = ctx.steps[ctx.stepIndex]
|
|
721
|
+
if (!step || step.type !== 'prompt' || step.prompt.type !== 'text') {
|
|
722
|
+
await interaction.reply({
|
|
723
|
+
content: 'Invalid state. Please run /login again.',
|
|
724
|
+
flags: MessageFlags.Ephemeral,
|
|
725
|
+
})
|
|
726
|
+
return
|
|
727
|
+
}
|
|
387
728
|
|
|
388
|
-
|
|
389
|
-
|
|
729
|
+
const modal = new ModalBuilder()
|
|
730
|
+
.setCustomId(`login_text:${hash}`)
|
|
731
|
+
.setTitle(`${ctx.providerName || 'Provider'} Login`.slice(0, 45))
|
|
732
|
+
|
|
733
|
+
const textInput = new TextInputBuilder()
|
|
734
|
+
.setCustomId('prompt_value')
|
|
735
|
+
.setLabel(step.prompt.message.slice(0, 45))
|
|
736
|
+
.setPlaceholder(
|
|
737
|
+
step.prompt.type === 'text' ? (step.prompt.placeholder || '') : '',
|
|
738
|
+
)
|
|
739
|
+
.setStyle(TextInputStyle.Short)
|
|
740
|
+
.setRequired(true)
|
|
741
|
+
|
|
742
|
+
modal.addComponents(
|
|
743
|
+
new ActionRowBuilder<TextInputBuilder>().addComponents(textInput),
|
|
744
|
+
)
|
|
745
|
+
await interaction.showModal(modal)
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
export async function handleLoginTextModalSubmit(
|
|
749
|
+
interaction: ModalSubmitInteraction,
|
|
750
|
+
): Promise<void> {
|
|
751
|
+
if (!interaction.customId.startsWith('login_text:')) {
|
|
752
|
+
return
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
await interaction.deferUpdate()
|
|
756
|
+
|
|
757
|
+
const hash = interaction.customId.replace('login_text:', '')
|
|
758
|
+
const ctx = pendingLoginContexts.get(hash)
|
|
759
|
+
|
|
760
|
+
if (!ctx) {
|
|
390
761
|
await interaction.editReply({
|
|
391
762
|
content: 'Selection expired. Please run /login again.',
|
|
392
763
|
components: [],
|
|
@@ -394,78 +765,60 @@ export async function handleLoginMethodSelectMenu(
|
|
|
394
765
|
return
|
|
395
766
|
}
|
|
396
767
|
|
|
397
|
-
const
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
components: [],
|
|
406
|
-
})
|
|
407
|
-
return
|
|
408
|
-
}
|
|
768
|
+
const step = ctx.steps[ctx.stepIndex]
|
|
769
|
+
if (!step || step.type !== 'prompt' || step.prompt.type !== 'text') {
|
|
770
|
+
await interaction.editReply({
|
|
771
|
+
content: 'Invalid state. Please run /login again.',
|
|
772
|
+
components: [],
|
|
773
|
+
})
|
|
774
|
+
return
|
|
775
|
+
}
|
|
409
776
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
777
|
+
const value = interaction.fields.getTextInputValue('prompt_value')
|
|
778
|
+
if (!value?.trim()) {
|
|
779
|
+
await interaction.editReply({
|
|
780
|
+
content: 'A value is required.',
|
|
781
|
+
components: [],
|
|
413
782
|
})
|
|
783
|
+
return
|
|
784
|
+
}
|
|
414
785
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
786
|
+
ctx.inputs[step.prompt.key] = value.trim()
|
|
787
|
+
ctx.stepIndex++
|
|
788
|
+
await showNextStep(interaction, ctx, hash)
|
|
789
|
+
}
|
|
418
790
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
await interaction.deferUpdate()
|
|
422
|
-
await interaction.editReply({
|
|
423
|
-
content: 'Invalid method selected',
|
|
424
|
-
components: [],
|
|
425
|
-
})
|
|
426
|
-
return
|
|
427
|
-
}
|
|
791
|
+
// ── API key button + modal ──────────────────────────────────────
|
|
792
|
+
// When we're deferred and need an API key modal, show a button first.
|
|
428
793
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
794
|
+
export async function handleLoginApiKeyButton(
|
|
795
|
+
interaction: ButtonInteraction,
|
|
796
|
+
): Promise<void> {
|
|
797
|
+
if (!interaction.customId.startsWith('login_apikey_btn:')) {
|
|
798
|
+
return
|
|
799
|
+
}
|
|
434
800
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
loginLogger.error('Error processing auth method:', error)
|
|
445
|
-
try {
|
|
446
|
-
if (!interaction.deferred && !interaction.replied) {
|
|
447
|
-
await interaction.deferUpdate()
|
|
448
|
-
}
|
|
449
|
-
await interaction.editReply({
|
|
450
|
-
content: `Failed to process auth method: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
451
|
-
components: [],
|
|
452
|
-
})
|
|
453
|
-
} catch {
|
|
454
|
-
// Ignore follow-up errors
|
|
455
|
-
}
|
|
801
|
+
const hash = interaction.customId.replace('login_apikey_btn:', '')
|
|
802
|
+
const ctx = pendingLoginContexts.get(hash)
|
|
803
|
+
|
|
804
|
+
if (!ctx || !ctx.providerName) {
|
|
805
|
+
await interaction.reply({
|
|
806
|
+
content: 'Selection expired. Please run /login again.',
|
|
807
|
+
flags: MessageFlags.Ephemeral,
|
|
808
|
+
})
|
|
809
|
+
return
|
|
456
810
|
}
|
|
811
|
+
|
|
812
|
+
await showApiKeyModal(interaction, hash, ctx.providerName)
|
|
457
813
|
}
|
|
458
814
|
|
|
459
|
-
/**
|
|
460
|
-
* Show API key input modal.
|
|
461
|
-
*/
|
|
462
815
|
async function showApiKeyModal(
|
|
463
|
-
interaction: StringSelectMenuInteraction,
|
|
464
|
-
|
|
816
|
+
interaction: StringSelectMenuInteraction | ButtonInteraction,
|
|
817
|
+
hash: string,
|
|
465
818
|
providerName: string,
|
|
466
819
|
): Promise<void> {
|
|
467
820
|
const modal = new ModalBuilder()
|
|
468
|
-
.setCustomId(`login_apikey:${
|
|
821
|
+
.setCustomId(`login_apikey:${hash}`)
|
|
469
822
|
.setTitle(`${providerName} API Key`.slice(0, 45))
|
|
470
823
|
|
|
471
824
|
const apiKeyInput = new TextInputBuilder()
|
|
@@ -475,38 +828,83 @@ async function showApiKeyModal(
|
|
|
475
828
|
.setStyle(TextInputStyle.Short)
|
|
476
829
|
.setRequired(true)
|
|
477
830
|
|
|
478
|
-
|
|
479
|
-
apiKeyInput,
|
|
831
|
+
modal.addComponents(
|
|
832
|
+
new ActionRowBuilder<TextInputBuilder>().addComponents(apiKeyInput),
|
|
480
833
|
)
|
|
481
|
-
|
|
834
|
+
await interaction.showModal(modal)
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// ── OAuth code submission (code mode) ───────────────────────────
|
|
838
|
+
// When the OAuth flow returns method="code", the user completes login
|
|
839
|
+
// in a browser (possibly on a different machine) and pastes the final
|
|
840
|
+
// callback URL or authorization code here.
|
|
482
841
|
|
|
842
|
+
export async function handleOAuthCodeButton(
|
|
843
|
+
interaction: ButtonInteraction,
|
|
844
|
+
): Promise<void> {
|
|
845
|
+
if (!interaction.customId.startsWith('login_oauth_code_btn:')) {
|
|
846
|
+
return
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
const hash = interaction.customId.replace('login_oauth_code_btn:', '')
|
|
850
|
+
const ctx = pendingLoginContexts.get(hash)
|
|
851
|
+
|
|
852
|
+
if (!ctx || !ctx.providerId || !ctx.providerName) {
|
|
853
|
+
await interaction.reply({
|
|
854
|
+
content: 'Selection expired. Please run /login again.',
|
|
855
|
+
flags: MessageFlags.Ephemeral,
|
|
856
|
+
})
|
|
857
|
+
return
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const modal = new ModalBuilder()
|
|
861
|
+
.setCustomId(`login_oauth_code:${hash}`)
|
|
862
|
+
.setTitle(`${ctx.providerName} Authorization`.slice(0, 45))
|
|
863
|
+
|
|
864
|
+
const codeInput = new TextInputBuilder()
|
|
865
|
+
.setCustomId('oauth_code')
|
|
866
|
+
.setLabel('Authorization code or callback URL')
|
|
867
|
+
.setPlaceholder('Paste the code or full callback URL')
|
|
868
|
+
.setStyle(TextInputStyle.Paragraph)
|
|
869
|
+
.setRequired(true)
|
|
870
|
+
|
|
871
|
+
modal.addComponents(
|
|
872
|
+
new ActionRowBuilder<TextInputBuilder>().addComponents(codeInput),
|
|
873
|
+
)
|
|
483
874
|
await interaction.showModal(modal)
|
|
484
875
|
}
|
|
485
876
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
*/
|
|
489
|
-
async function startOAuthFlow(
|
|
490
|
-
interaction: StringSelectMenuInteraction,
|
|
491
|
-
context: {
|
|
492
|
-
dir: string
|
|
493
|
-
providerId?: string
|
|
494
|
-
providerName?: string
|
|
495
|
-
methodIndex?: number
|
|
496
|
-
methodLabel?: string
|
|
497
|
-
},
|
|
498
|
-
contextHash: string,
|
|
877
|
+
export async function handleOAuthCodeModalSubmit(
|
|
878
|
+
interaction: ModalSubmitInteraction,
|
|
499
879
|
): Promise<void> {
|
|
500
|
-
if (!
|
|
880
|
+
if (!interaction.customId.startsWith('login_oauth_code:')) {
|
|
881
|
+
return
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
await interaction.deferUpdate()
|
|
885
|
+
|
|
886
|
+
const hash = interaction.customId.replace('login_oauth_code:', '')
|
|
887
|
+
const ctx = pendingLoginContexts.get(hash)
|
|
888
|
+
|
|
889
|
+
if (!ctx || !ctx.providerId || !ctx.providerName || ctx.methodIndex === undefined) {
|
|
501
890
|
await interaction.editReply({
|
|
502
|
-
content: '
|
|
891
|
+
content: 'Session expired. Please run /login again.',
|
|
892
|
+
components: [],
|
|
893
|
+
})
|
|
894
|
+
return
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const code = interaction.fields.getTextInputValue('oauth_code')?.trim()
|
|
898
|
+
if (!code) {
|
|
899
|
+
await interaction.editReply({
|
|
900
|
+
content: 'Authorization code is required.',
|
|
503
901
|
components: [],
|
|
504
902
|
})
|
|
505
903
|
return
|
|
506
904
|
}
|
|
507
905
|
|
|
508
906
|
try {
|
|
509
|
-
const getClient = await initializeOpencodeForDirectory(
|
|
907
|
+
const getClient = await initializeOpencodeForDirectory(ctx.dir)
|
|
510
908
|
if (getClient instanceof Error) {
|
|
511
909
|
await interaction.editReply({
|
|
512
910
|
content: getClient.message,
|
|
@@ -516,88 +914,36 @@ async function startOAuthFlow(
|
|
|
516
914
|
}
|
|
517
915
|
|
|
518
916
|
await interaction.editReply({
|
|
519
|
-
content: `**Authenticating with ${
|
|
917
|
+
content: `**Authenticating with ${ctx.providerName}**\nVerifying authorization...`,
|
|
520
918
|
components: [],
|
|
521
919
|
})
|
|
522
920
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
directory:
|
|
921
|
+
const callbackResponse = await getClient().provider.oauth.callback({
|
|
922
|
+
providerID: ctx.providerId,
|
|
923
|
+
method: ctx.methodIndex,
|
|
924
|
+
code,
|
|
925
|
+
directory: ctx.dir,
|
|
528
926
|
})
|
|
529
927
|
|
|
530
|
-
if (
|
|
531
|
-
|
|
532
|
-
| { data?: { message?: string } }
|
|
533
|
-
| undefined
|
|
928
|
+
if (callbackResponse.error) {
|
|
929
|
+
pendingLoginContexts.delete(hash)
|
|
534
930
|
await interaction.editReply({
|
|
535
|
-
content:
|
|
931
|
+
content: `**Authentication Failed**\n${extractErrorMessage({ error: callbackResponse.error, fallback: 'Authorization code was invalid or expired' })}`,
|
|
536
932
|
components: [],
|
|
537
933
|
})
|
|
538
934
|
return
|
|
539
935
|
}
|
|
540
936
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
// Show authorization URL and instructions
|
|
544
|
-
let message = `**Authenticating with ${context.providerName}**\n\n`
|
|
545
|
-
message += `Open this URL to authorize:\n${url}\n\n`
|
|
546
|
-
|
|
547
|
-
if (instructions) {
|
|
548
|
-
// Extract code from instructions like "Enter code: ABC-123"
|
|
549
|
-
const codeMatch = instructions.match(/code[:\s]+([A-Z0-9-]+)/i)
|
|
550
|
-
if (codeMatch) {
|
|
551
|
-
message += `**Code:** \`${codeMatch[1]}\`\n\n`
|
|
552
|
-
} else {
|
|
553
|
-
message += `${instructions}\n\n`
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
if (method === 'auto') {
|
|
558
|
-
message += '_Waiting for authorization to complete..._'
|
|
559
|
-
}
|
|
937
|
+
await getClient().instance.dispose({ directory: ctx.dir })
|
|
938
|
+
pendingLoginContexts.delete(hash)
|
|
560
939
|
|
|
561
940
|
await interaction.editReply({
|
|
562
|
-
content:
|
|
941
|
+
content: `✅ **Successfully authenticated with ${ctx.providerName}!**\n\nYou can now use models from this provider.`,
|
|
563
942
|
components: [],
|
|
564
943
|
})
|
|
565
|
-
|
|
566
|
-
if (method === 'auto') {
|
|
567
|
-
// Poll for completion (device flow)
|
|
568
|
-
const callbackResponse = await getClient().provider.oauth.callback({
|
|
569
|
-
providerID: context.providerId,
|
|
570
|
-
method: context.methodIndex,
|
|
571
|
-
directory: context.dir,
|
|
572
|
-
})
|
|
573
|
-
|
|
574
|
-
if (callbackResponse.error) {
|
|
575
|
-
const errorData = callbackResponse.error as
|
|
576
|
-
| { data?: { message?: string } }
|
|
577
|
-
| undefined
|
|
578
|
-
await interaction.editReply({
|
|
579
|
-
content: `**Authentication Failed**\n${errorData?.data?.message || 'Authorization was not completed'}`,
|
|
580
|
-
components: [],
|
|
581
|
-
})
|
|
582
|
-
return
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
// Dispose to refresh provider state so new credentials are recognized
|
|
586
|
-
await getClient().instance.dispose({ directory: context.dir })
|
|
587
|
-
|
|
588
|
-
await interaction.editReply({
|
|
589
|
-
content: `✅ **Successfully authenticated with ${context.providerName}!**\n\nYou can now use models from this provider.`,
|
|
590
|
-
components: [],
|
|
591
|
-
})
|
|
592
|
-
}
|
|
593
|
-
// For 'code' method, we would need to prompt for code input
|
|
594
|
-
// But Discord modals can't be shown after deferUpdate, so we'd need a different flow
|
|
595
|
-
// For now, most providers use 'auto' (device flow) which works well for Discord
|
|
596
|
-
|
|
597
|
-
// Clean up context
|
|
598
|
-
pendingLoginContexts.delete(contextHash)
|
|
599
944
|
} catch (error) {
|
|
600
|
-
loginLogger.error('OAuth
|
|
945
|
+
loginLogger.error('OAuth code submission error:', error)
|
|
946
|
+
pendingLoginContexts.delete(hash)
|
|
601
947
|
await interaction.editReply({
|
|
602
948
|
content: `**Authentication Failed**\n${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
603
949
|
components: [],
|
|
@@ -605,24 +951,19 @@ async function startOAuthFlow(
|
|
|
605
951
|
}
|
|
606
952
|
}
|
|
607
953
|
|
|
608
|
-
/**
|
|
609
|
-
* Handle API key modal submission.
|
|
610
|
-
*/
|
|
611
954
|
export async function handleApiKeyModalSubmit(
|
|
612
955
|
interaction: ModalSubmitInteraction,
|
|
613
956
|
): Promise<void> {
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
if (!customId.startsWith('login_apikey:')) {
|
|
957
|
+
if (!interaction.customId.startsWith('login_apikey:')) {
|
|
617
958
|
return
|
|
618
959
|
}
|
|
619
960
|
|
|
620
961
|
await interaction.deferReply({ flags: MessageFlags.Ephemeral })
|
|
621
962
|
|
|
622
|
-
const
|
|
623
|
-
const
|
|
963
|
+
const hash = interaction.customId.replace('login_apikey:', '')
|
|
964
|
+
const ctx = pendingLoginContexts.get(hash)
|
|
624
965
|
|
|
625
|
-
if (!
|
|
966
|
+
if (!ctx || !ctx.providerId || !ctx.providerName) {
|
|
626
967
|
await interaction.editReply({
|
|
627
968
|
content: 'Session expired. Please run /login again.',
|
|
628
969
|
})
|
|
@@ -632,39 +973,30 @@ export async function handleApiKeyModalSubmit(
|
|
|
632
973
|
const apiKey = interaction.fields.getTextInputValue('apikey')
|
|
633
974
|
|
|
634
975
|
if (!apiKey?.trim()) {
|
|
635
|
-
await interaction.editReply({
|
|
636
|
-
content: 'API key is required.',
|
|
637
|
-
})
|
|
976
|
+
await interaction.editReply({ content: 'API key is required.' })
|
|
638
977
|
return
|
|
639
978
|
}
|
|
640
979
|
|
|
641
980
|
try {
|
|
642
|
-
const getClient = await initializeOpencodeForDirectory(
|
|
981
|
+
const getClient = await initializeOpencodeForDirectory(ctx.dir)
|
|
643
982
|
if (getClient instanceof Error) {
|
|
644
|
-
await interaction.editReply({
|
|
645
|
-
content: getClient.message,
|
|
646
|
-
})
|
|
983
|
+
await interaction.editReply({ content: getClient.message })
|
|
647
984
|
return
|
|
648
985
|
}
|
|
649
986
|
|
|
650
|
-
// Set the API key
|
|
651
987
|
await getClient().auth.set({
|
|
652
|
-
providerID:
|
|
653
|
-
auth: {
|
|
654
|
-
type: 'api',
|
|
655
|
-
key: apiKey.trim(),
|
|
656
|
-
},
|
|
988
|
+
providerID: ctx.providerId,
|
|
989
|
+
auth: { type: 'api', key: apiKey.trim() },
|
|
657
990
|
})
|
|
658
991
|
|
|
659
992
|
// Dispose to refresh provider state so new credentials are recognized
|
|
660
|
-
await getClient().instance.dispose({ directory:
|
|
993
|
+
await getClient().instance.dispose({ directory: ctx.dir })
|
|
661
994
|
|
|
662
995
|
await interaction.editReply({
|
|
663
|
-
content: `✅ **Successfully authenticated with ${
|
|
996
|
+
content: `✅ **Successfully authenticated with ${ctx.providerName}!**\n\nYou can now use models from this provider.`,
|
|
664
997
|
})
|
|
665
998
|
|
|
666
|
-
|
|
667
|
-
pendingLoginContexts.delete(contextHash)
|
|
999
|
+
pendingLoginContexts.delete(hash)
|
|
668
1000
|
} catch (error) {
|
|
669
1001
|
loginLogger.error('API key save error:', error)
|
|
670
1002
|
await interaction.editReply({
|
|
@@ -672,3 +1004,170 @@ export async function handleApiKeyModalSubmit(
|
|
|
672
1004
|
})
|
|
673
1005
|
}
|
|
674
1006
|
}
|
|
1007
|
+
|
|
1008
|
+
// ── OAuth flow ──────────────────────────────────────────────────
|
|
1009
|
+
|
|
1010
|
+
async function startOAuthFlow(
|
|
1011
|
+
interaction: StringSelectMenuInteraction | ModalSubmitInteraction,
|
|
1012
|
+
ctx: LoginContext,
|
|
1013
|
+
hash: string,
|
|
1014
|
+
): Promise<void> {
|
|
1015
|
+
if (!ctx.providerId || ctx.methodIndex === undefined) {
|
|
1016
|
+
await interaction.editReply({
|
|
1017
|
+
content: 'Invalid context for OAuth flow',
|
|
1018
|
+
components: [],
|
|
1019
|
+
})
|
|
1020
|
+
return
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
try {
|
|
1024
|
+
const getClient = await initializeOpencodeForDirectory(ctx.dir)
|
|
1025
|
+
if (getClient instanceof Error) {
|
|
1026
|
+
await interaction.editReply({
|
|
1027
|
+
content: getClient.message,
|
|
1028
|
+
components: [],
|
|
1029
|
+
})
|
|
1030
|
+
return
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
await interaction.editReply({
|
|
1034
|
+
content: `**Authenticating with ${ctx.providerName}**\nStarting authorization...`,
|
|
1035
|
+
components: [],
|
|
1036
|
+
})
|
|
1037
|
+
|
|
1038
|
+
// Direct fetch to the server because the SDK's buildClientParams drops
|
|
1039
|
+
// unknown keys — `inputs` would be silently stripped. The server accepts
|
|
1040
|
+
// `inputs` in the body (see opencode server/routes/provider.ts).
|
|
1041
|
+
const port = getOpencodeServerPort()
|
|
1042
|
+
if (!port) {
|
|
1043
|
+
await interaction.editReply({
|
|
1044
|
+
content: 'OpenCode server is not running. Please try again.',
|
|
1045
|
+
components: [],
|
|
1046
|
+
})
|
|
1047
|
+
return
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
const hasInputs = Object.keys(ctx.inputs).length > 0
|
|
1051
|
+
const authorizeUrl = new URL(
|
|
1052
|
+
`/provider/${encodeURIComponent(ctx.providerId)}/oauth/authorize`,
|
|
1053
|
+
`http://127.0.0.1:${port}`,
|
|
1054
|
+
)
|
|
1055
|
+
authorizeUrl.searchParams.set('directory', ctx.dir)
|
|
1056
|
+
|
|
1057
|
+
// Include basic auth if OPENCODE_SERVER_PASSWORD is set,
|
|
1058
|
+
// matching the opencode server's optional basicAuth middleware.
|
|
1059
|
+
const fetchHeaders: Record<string, string> = {
|
|
1060
|
+
'Content-Type': 'application/json',
|
|
1061
|
+
'x-opencode-directory': ctx.dir,
|
|
1062
|
+
}
|
|
1063
|
+
const serverPassword = process.env.OPENCODE_SERVER_PASSWORD
|
|
1064
|
+
if (serverPassword) {
|
|
1065
|
+
const username = process.env.OPENCODE_SERVER_USERNAME || 'opencode'
|
|
1066
|
+
fetchHeaders['Authorization'] =
|
|
1067
|
+
`Basic ${Buffer.from(`${username}:${serverPassword}`).toString('base64')}`
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
const authorizeRes = await fetch(authorizeUrl, {
|
|
1071
|
+
method: 'POST',
|
|
1072
|
+
headers: fetchHeaders,
|
|
1073
|
+
body: JSON.stringify({
|
|
1074
|
+
method: ctx.methodIndex,
|
|
1075
|
+
...(hasInputs ? { inputs: ctx.inputs } : {}),
|
|
1076
|
+
}),
|
|
1077
|
+
})
|
|
1078
|
+
|
|
1079
|
+
if (!authorizeRes.ok) {
|
|
1080
|
+
const errorText = await authorizeRes.text().catch(() => '')
|
|
1081
|
+
let errorMessage = 'Unknown error'
|
|
1082
|
+
try {
|
|
1083
|
+
const parsed = JSON.parse(errorText) as {
|
|
1084
|
+
message?: string
|
|
1085
|
+
data?: { message?: string }
|
|
1086
|
+
}
|
|
1087
|
+
errorMessage = parsed?.data?.message || parsed?.message || errorMessage
|
|
1088
|
+
} catch {
|
|
1089
|
+
errorMessage = errorText || errorMessage
|
|
1090
|
+
}
|
|
1091
|
+
await interaction.editReply({
|
|
1092
|
+
content: `Failed to start authorization: ${errorMessage}`,
|
|
1093
|
+
components: [],
|
|
1094
|
+
})
|
|
1095
|
+
return
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
const { url, method, instructions } = (await authorizeRes.json()) as {
|
|
1099
|
+
url: string
|
|
1100
|
+
method: 'auto' | 'code'
|
|
1101
|
+
instructions: string
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
let message = `**Authenticating with ${ctx.providerName}**\n\n`
|
|
1105
|
+
message += `Open this URL to authorize:\n${url}\n\n`
|
|
1106
|
+
|
|
1107
|
+
if (instructions) {
|
|
1108
|
+
// Match "code: ABC-123" or "code: WXYZ1234" but not natural language
|
|
1109
|
+
// like "code will". Require a colon separator and uppercase alphanum code.
|
|
1110
|
+
const codeMatch = instructions.match(/code:\s*([A-Z0-9][A-Z0-9-]+)/)
|
|
1111
|
+
if (codeMatch) {
|
|
1112
|
+
message += `**Code:** \`${codeMatch[1]}\`\n\n`
|
|
1113
|
+
} else {
|
|
1114
|
+
message += `${instructions}\n\n`
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
if (method === 'auto') {
|
|
1119
|
+
message += '_Waiting for authorization to complete..._'
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
if (method === 'code') {
|
|
1123
|
+
// Code mode: show a button to paste the auth code/URL after
|
|
1124
|
+
// completing login in a browser (possibly on a different machine).
|
|
1125
|
+
const button = new ButtonBuilder()
|
|
1126
|
+
.setCustomId(`login_oauth_code_btn:${hash}`)
|
|
1127
|
+
.setLabel('Paste authorization code')
|
|
1128
|
+
.setStyle(ButtonStyle.Primary)
|
|
1129
|
+
|
|
1130
|
+
await interaction.editReply({
|
|
1131
|
+
content: message,
|
|
1132
|
+
components: [
|
|
1133
|
+
new ActionRowBuilder<ButtonBuilder>().addComponents(button),
|
|
1134
|
+
],
|
|
1135
|
+
})
|
|
1136
|
+
// Don't delete context — we need it for the code submission
|
|
1137
|
+
return
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
await interaction.editReply({ content: message, components: [] })
|
|
1141
|
+
|
|
1142
|
+
// Auto mode: poll for completion (device flow / localhost callback)
|
|
1143
|
+
const callbackResponse = await getClient().provider.oauth.callback({
|
|
1144
|
+
providerID: ctx.providerId,
|
|
1145
|
+
method: ctx.methodIndex,
|
|
1146
|
+
directory: ctx.dir,
|
|
1147
|
+
})
|
|
1148
|
+
|
|
1149
|
+
if (callbackResponse.error) {
|
|
1150
|
+
pendingLoginContexts.delete(hash)
|
|
1151
|
+
await interaction.editReply({
|
|
1152
|
+
content: `**Authentication Failed**\n${extractErrorMessage({ error: callbackResponse.error, fallback: 'Authorization was not completed' })}`,
|
|
1153
|
+
components: [],
|
|
1154
|
+
})
|
|
1155
|
+
return
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
await getClient().instance.dispose({ directory: ctx.dir })
|
|
1159
|
+
pendingLoginContexts.delete(hash)
|
|
1160
|
+
|
|
1161
|
+
await interaction.editReply({
|
|
1162
|
+
content: `✅ **Successfully authenticated with ${ctx.providerName}!**\n\nYou can now use models from this provider.`,
|
|
1163
|
+
components: [],
|
|
1164
|
+
})
|
|
1165
|
+
} catch (error) {
|
|
1166
|
+
loginLogger.error('OAuth flow error:', error)
|
|
1167
|
+
pendingLoginContexts.delete(hash)
|
|
1168
|
+
await interaction.editReply({
|
|
1169
|
+
content: `**Authentication Failed**\n${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
1170
|
+
components: [],
|
|
1171
|
+
})
|
|
1172
|
+
}
|
|
1173
|
+
}
|