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/dist/commands/login.js
CHANGED
|
@@ -1,18 +1,35 @@
|
|
|
1
|
-
// /login command
|
|
2
|
-
//
|
|
3
|
-
|
|
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
|
|
11
|
+
import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ModalSubmitInteraction, ButtonBuilder, ButtonStyle, ChannelType, MessageFlags, } from 'discord.js';
|
|
4
12
|
import crypto from 'node:crypto';
|
|
5
|
-
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
13
|
+
import { initializeOpencodeForDirectory, getOpencodeServerPort, } from '../opencode.js';
|
|
6
14
|
import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js';
|
|
7
15
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
16
|
+
import { buildPaginatedOptions, parsePaginationValue } from './paginated-select.js';
|
|
8
17
|
const loginLogger = createLogger(LogPrefix.LOGIN);
|
|
9
|
-
//
|
|
10
|
-
//
|
|
18
|
+
// ── Context store ───────────────────────────────────────────────
|
|
19
|
+
// Keyed by random hash to stay under Discord's 100-char customId limit.
|
|
20
|
+
// TTL prevents unbounded growth when users open /login and never interact.
|
|
11
21
|
const LOGIN_CONTEXT_TTL_MS = 10 * 60 * 1000;
|
|
12
22
|
const pendingLoginContexts = new Map();
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
23
|
+
function createContextHash(context) {
|
|
24
|
+
const hash = crypto.randomBytes(8).toString('hex');
|
|
25
|
+
pendingLoginContexts.set(hash, context);
|
|
26
|
+
setTimeout(() => {
|
|
27
|
+
pendingLoginContexts.delete(hash);
|
|
28
|
+
}, LOGIN_CONTEXT_TTL_MS).unref();
|
|
29
|
+
return hash;
|
|
30
|
+
}
|
|
31
|
+
// ── Provider popularity order ───────────────────────────────────
|
|
32
|
+
// Discord select menus cap at 25 options, so we show popular ones first.
|
|
16
33
|
// IDs sourced from opencode's provider.list() API (scripts/list-providers.ts).
|
|
17
34
|
const PROVIDER_POPULARITY_ORDER = [
|
|
18
35
|
'anthropic',
|
|
@@ -41,15 +58,38 @@ const PROVIDER_POPULARITY_ORDER = [
|
|
|
41
58
|
'lmstudio',
|
|
42
59
|
'llama',
|
|
43
60
|
];
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
61
|
+
// ── Helpers ─────────────────────────────────────────────────────
|
|
62
|
+
function extractErrorMessage({ error, fallback, }) {
|
|
63
|
+
if (!error || typeof error !== 'object') {
|
|
64
|
+
return fallback;
|
|
65
|
+
}
|
|
66
|
+
const parsed = error;
|
|
67
|
+
return parsed.data?.message || parsed.message || fallback;
|
|
68
|
+
}
|
|
69
|
+
function shouldShowPrompt(prompt, inputs) {
|
|
70
|
+
if (!prompt.when) {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
const value = inputs[prompt.when.key];
|
|
74
|
+
if (prompt.when.op === 'eq') {
|
|
75
|
+
return value === prompt.when.value;
|
|
76
|
+
}
|
|
77
|
+
if (prompt.when.op === 'neq') {
|
|
78
|
+
return value !== prompt.when.value;
|
|
79
|
+
}
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
function buildSelectMenu({ customId, placeholder, options, }) {
|
|
83
|
+
const menu = new StringSelectMenuBuilder()
|
|
84
|
+
.setCustomId(customId)
|
|
85
|
+
.setPlaceholder(placeholder)
|
|
86
|
+
.addOptions(options);
|
|
87
|
+
return new ActionRowBuilder().addComponents(menu);
|
|
88
|
+
}
|
|
89
|
+
// ── /login command ──────────────────────────────────────────────
|
|
48
90
|
export async function handleLoginCommand({ interaction, }) {
|
|
49
91
|
loginLogger.log('[LOGIN] handleLoginCommand called');
|
|
50
|
-
// Defer reply immediately to avoid 3-second timeout
|
|
51
92
|
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
|
52
|
-
loginLogger.log('[LOGIN] Deferred reply');
|
|
53
93
|
const channel = interaction.channel;
|
|
54
94
|
if (!channel) {
|
|
55
95
|
await interaction.editReply({
|
|
@@ -57,7 +97,6 @@ export async function handleLoginCommand({ interaction, }) {
|
|
|
57
97
|
});
|
|
58
98
|
return;
|
|
59
99
|
}
|
|
60
|
-
// Determine if we're in a thread or text channel
|
|
61
100
|
const isThread = [
|
|
62
101
|
ChannelType.PublicThread,
|
|
63
102
|
ChannelType.PrivateThread,
|
|
@@ -100,21 +139,15 @@ export async function handleLoginCommand({ interaction, }) {
|
|
|
100
139
|
directory: projectDirectory,
|
|
101
140
|
});
|
|
102
141
|
if (!providersResponse.data) {
|
|
103
|
-
await interaction.editReply({
|
|
104
|
-
content: 'Failed to fetch providers',
|
|
105
|
-
});
|
|
142
|
+
await interaction.editReply({ content: 'Failed to fetch providers' });
|
|
106
143
|
return;
|
|
107
144
|
}
|
|
108
145
|
const { all: allProviders, connected } = providersResponse.data;
|
|
109
146
|
if (allProviders.length === 0) {
|
|
110
|
-
await interaction.editReply({
|
|
111
|
-
content: 'No providers available.',
|
|
112
|
-
});
|
|
147
|
+
await interaction.editReply({ content: 'No providers available.' });
|
|
113
148
|
return;
|
|
114
149
|
}
|
|
115
|
-
|
|
116
|
-
// Discord select menus cap at 25, so we show the most popular providers.
|
|
117
|
-
const options = [...allProviders]
|
|
150
|
+
const allProviderOptions = [...allProviders]
|
|
118
151
|
.sort((a, b) => {
|
|
119
152
|
const rankA = PROVIDER_POPULARITY_ORDER.indexOf(a.id);
|
|
120
153
|
const rankB = PROVIDER_POPULARITY_ORDER.indexOf(b.id);
|
|
@@ -125,7 +158,6 @@ export async function handleLoginCommand({ interaction, }) {
|
|
|
125
158
|
}
|
|
126
159
|
return a.name.localeCompare(b.name);
|
|
127
160
|
})
|
|
128
|
-
.slice(0, 25)
|
|
129
161
|
.map((provider) => {
|
|
130
162
|
const isConnected = connected.includes(provider.id);
|
|
131
163
|
return {
|
|
@@ -136,24 +168,27 @@ export async function handleLoginCommand({ interaction, }) {
|
|
|
136
168
|
: 'Not connected',
|
|
137
169
|
};
|
|
138
170
|
});
|
|
139
|
-
|
|
171
|
+
const { options } = buildPaginatedOptions({
|
|
172
|
+
allOptions: allProviderOptions,
|
|
173
|
+
page: 0,
|
|
174
|
+
});
|
|
140
175
|
const context = {
|
|
141
176
|
dir: projectDirectory,
|
|
142
177
|
channelId: targetChannelId,
|
|
178
|
+
steps: [{ type: 'provider' }],
|
|
179
|
+
stepIndex: 0,
|
|
180
|
+
inputs: {},
|
|
143
181
|
};
|
|
144
|
-
const
|
|
145
|
-
pendingLoginContexts.set(contextHash, context);
|
|
146
|
-
setTimeout(() => {
|
|
147
|
-
pendingLoginContexts.delete(contextHash);
|
|
148
|
-
}, LOGIN_CONTEXT_TTL_MS).unref();
|
|
149
|
-
const selectMenu = new StringSelectMenuBuilder()
|
|
150
|
-
.setCustomId(`login_provider:${contextHash}`)
|
|
151
|
-
.setPlaceholder('Select a provider to authenticate')
|
|
152
|
-
.addOptions(options);
|
|
153
|
-
const actionRow = new ActionRowBuilder().addComponents(selectMenu);
|
|
182
|
+
const hash = createContextHash(context);
|
|
154
183
|
await interaction.editReply({
|
|
155
184
|
content: '**Authenticate with Provider**\nSelect a provider:',
|
|
156
|
-
components: [
|
|
185
|
+
components: [
|
|
186
|
+
buildSelectMenu({
|
|
187
|
+
customId: `login_select:${hash}`,
|
|
188
|
+
placeholder: 'Select a provider to authenticate',
|
|
189
|
+
options,
|
|
190
|
+
}),
|
|
191
|
+
],
|
|
157
192
|
});
|
|
158
193
|
}
|
|
159
194
|
catch (error) {
|
|
@@ -163,18 +198,17 @@ export async function handleLoginCommand({ interaction, }) {
|
|
|
163
198
|
});
|
|
164
199
|
}
|
|
165
200
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
export async function
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
if (!context) {
|
|
201
|
+
// ── Unified select handler ──────────────────────────────────────
|
|
202
|
+
// Handles all select menu interactions for the login flow.
|
|
203
|
+
// Reads the current step from context, processes the answer,
|
|
204
|
+
// then either shows the next step or proceeds to authorize/API key.
|
|
205
|
+
export async function handleLoginSelect(interaction) {
|
|
206
|
+
if (!interaction.customId.startsWith('login_select:')) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const hash = interaction.customId.replace('login_select:', '');
|
|
210
|
+
const ctx = pendingLoginContexts.get(hash);
|
|
211
|
+
if (!ctx) {
|
|
178
212
|
await interaction.deferUpdate();
|
|
179
213
|
await interaction.editReply({
|
|
180
214
|
content: 'Selection expired. Please run /login again.',
|
|
@@ -182,189 +216,391 @@ export async function handleLoginProviderSelectMenu(interaction) {
|
|
|
182
216
|
});
|
|
183
217
|
return;
|
|
184
218
|
}
|
|
185
|
-
const
|
|
186
|
-
if (!
|
|
219
|
+
const value = interaction.values[0];
|
|
220
|
+
if (!value) {
|
|
221
|
+
await interaction.deferUpdate();
|
|
222
|
+
await interaction.editReply({
|
|
223
|
+
content: 'No option selected.',
|
|
224
|
+
components: [],
|
|
225
|
+
});
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const step = ctx.steps[ctx.stepIndex];
|
|
229
|
+
if (!step) {
|
|
187
230
|
await interaction.deferUpdate();
|
|
188
231
|
await interaction.editReply({
|
|
189
|
-
content: '
|
|
232
|
+
content: 'Invalid state. Please run /login again.',
|
|
190
233
|
components: [],
|
|
191
234
|
});
|
|
192
235
|
return;
|
|
193
236
|
}
|
|
194
237
|
try {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
await interaction.deferUpdate();
|
|
198
|
-
await interaction.editReply({
|
|
199
|
-
content: getClient.message,
|
|
200
|
-
components: [],
|
|
201
|
-
});
|
|
202
|
-
return;
|
|
238
|
+
if (step.type === 'provider') {
|
|
239
|
+
await handleProviderStep(interaction, ctx, hash, value);
|
|
203
240
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
directory: context.dir,
|
|
207
|
-
});
|
|
208
|
-
const provider = providersResponse.data?.all.find((p) => p.id === selectedProviderId);
|
|
209
|
-
const providerName = provider?.name || selectedProviderId;
|
|
210
|
-
// Get auth methods for all providers
|
|
211
|
-
const authMethodsResponse = await getClient().provider.auth({
|
|
212
|
-
directory: context.dir,
|
|
213
|
-
});
|
|
214
|
-
if (!authMethodsResponse.data) {
|
|
215
|
-
await interaction.deferUpdate();
|
|
216
|
-
await interaction.editReply({
|
|
217
|
-
content: 'Failed to fetch authentication methods',
|
|
218
|
-
components: [],
|
|
219
|
-
});
|
|
220
|
-
return;
|
|
241
|
+
else if (step.type === 'method') {
|
|
242
|
+
await handleMethodStep(interaction, ctx, hash, value, step);
|
|
221
243
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
244
|
+
else if (step.type === 'prompt') {
|
|
245
|
+
await handlePromptStep(interaction, ctx, hash, value, step);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
loginLogger.error('Error in login select:', error);
|
|
250
|
+
if (!interaction.deferred && !interaction.replied) {
|
|
225
251
|
await interaction.deferUpdate();
|
|
226
|
-
await interaction.editReply({
|
|
227
|
-
content: `No authentication methods available for ${providerName}`,
|
|
228
|
-
components: [],
|
|
229
|
-
});
|
|
230
|
-
return;
|
|
231
252
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
253
|
+
await interaction.editReply({
|
|
254
|
+
content: `Login error: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
255
|
+
components: [],
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// ── Step handlers ───────────────────────────────────────────────
|
|
260
|
+
async function handleProviderStep(interaction, ctx, hash, providerId) {
|
|
261
|
+
// Handle pagination nav — re-render the same provider select with new page
|
|
262
|
+
const navPage = parsePaginationValue(providerId);
|
|
263
|
+
if (navPage !== undefined) {
|
|
264
|
+
await interaction.deferUpdate();
|
|
265
|
+
ctx.providerPage = navPage;
|
|
266
|
+
const getClient = await initializeOpencodeForDirectory(ctx.dir);
|
|
267
|
+
if (getClient instanceof Error) {
|
|
268
|
+
await interaction.editReply({ content: getClient.message, components: [] });
|
|
244
269
|
return;
|
|
245
270
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
if (methods.length === 1) {
|
|
250
|
-
const method = methods[0];
|
|
251
|
-
context.methodIndex = 0;
|
|
252
|
-
context.methodType = method.type;
|
|
253
|
-
context.methodLabel = method.label;
|
|
254
|
-
pendingLoginContexts.set(contextHash, context);
|
|
255
|
-
await startOAuthFlow(interaction, context, contextHash);
|
|
271
|
+
const providersResponse = await getClient().provider.list({ directory: ctx.dir });
|
|
272
|
+
if (!providersResponse.data) {
|
|
273
|
+
await interaction.editReply({ content: 'Failed to fetch providers', components: [] });
|
|
256
274
|
return;
|
|
257
275
|
}
|
|
258
|
-
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
.
|
|
269
|
-
|
|
270
|
-
|
|
276
|
+
const { all: allProviders, connected } = providersResponse.data;
|
|
277
|
+
const allProviderOptions = [...allProviders]
|
|
278
|
+
.sort((a, b) => {
|
|
279
|
+
const rankA = PROVIDER_POPULARITY_ORDER.indexOf(a.id);
|
|
280
|
+
const rankB = PROVIDER_POPULARITY_ORDER.indexOf(b.id);
|
|
281
|
+
const posA = rankA === -1 ? Infinity : rankA;
|
|
282
|
+
const posB = rankB === -1 ? Infinity : rankB;
|
|
283
|
+
if (posA !== posB) {
|
|
284
|
+
return posA - posB;
|
|
285
|
+
}
|
|
286
|
+
return a.name.localeCompare(b.name);
|
|
287
|
+
})
|
|
288
|
+
.map((p) => {
|
|
289
|
+
const isConnected = connected.includes(p.id);
|
|
290
|
+
return {
|
|
291
|
+
label: `${p.name}${isConnected ? ' ✓' : ''}`.slice(0, 100),
|
|
292
|
+
value: p.id,
|
|
293
|
+
description: isConnected ? 'Connected - select to re-authenticate' : 'Not connected',
|
|
294
|
+
};
|
|
295
|
+
});
|
|
296
|
+
const { options } = buildPaginatedOptions({ allOptions: allProviderOptions, page: navPage });
|
|
271
297
|
await interaction.editReply({
|
|
272
|
-
content:
|
|
273
|
-
components: [
|
|
298
|
+
content: '**Authenticate with Provider**\nSelect a provider:',
|
|
299
|
+
components: [
|
|
300
|
+
buildSelectMenu({
|
|
301
|
+
customId: `login_select:${hash}`,
|
|
302
|
+
placeholder: 'Select a provider to authenticate',
|
|
303
|
+
options,
|
|
304
|
+
}),
|
|
305
|
+
],
|
|
274
306
|
});
|
|
307
|
+
return;
|
|
275
308
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
309
|
+
const getClient = await initializeOpencodeForDirectory(ctx.dir);
|
|
310
|
+
if (getClient instanceof Error) {
|
|
311
|
+
await interaction.deferUpdate();
|
|
312
|
+
await interaction.editReply({ content: getClient.message, components: [] });
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const providersResponse = await getClient().provider.list({
|
|
316
|
+
directory: ctx.dir,
|
|
317
|
+
});
|
|
318
|
+
const provider = providersResponse.data?.all.find((p) => p.id === providerId);
|
|
319
|
+
const providerName = provider?.name || providerId;
|
|
320
|
+
const authResponse = await getClient().provider.auth({ directory: ctx.dir });
|
|
321
|
+
if (!authResponse.data) {
|
|
322
|
+
await interaction.deferUpdate();
|
|
281
323
|
await interaction.editReply({
|
|
282
|
-
content:
|
|
324
|
+
content: 'Failed to fetch authentication methods',
|
|
283
325
|
components: [],
|
|
284
326
|
});
|
|
327
|
+
return;
|
|
285
328
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
if (
|
|
294
|
-
return;
|
|
295
|
-
}
|
|
296
|
-
const contextHash = customId.replace('login_method:', '');
|
|
297
|
-
const context = pendingLoginContexts.get(contextHash);
|
|
298
|
-
if (!context || !context.providerId || !context.providerName) {
|
|
329
|
+
// The server returns prompts in the auth response when the opencode
|
|
330
|
+
// version supports it (dev branch, not yet released as of v1.2.27).
|
|
331
|
+
// Once released, plugin-defined prompts will be collected and passed
|
|
332
|
+
// as inputs to the authorize call automatically.
|
|
333
|
+
const methods = authResponse.data[providerId] || [
|
|
334
|
+
{ type: 'api', label: 'API Key' },
|
|
335
|
+
];
|
|
336
|
+
if (methods.length === 0) {
|
|
299
337
|
await interaction.deferUpdate();
|
|
300
338
|
await interaction.editReply({
|
|
301
|
-
content:
|
|
339
|
+
content: `No authentication methods available for ${providerName}`,
|
|
302
340
|
components: [],
|
|
303
341
|
});
|
|
304
342
|
return;
|
|
305
343
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
344
|
+
ctx.providerId = providerId;
|
|
345
|
+
ctx.providerName = providerName;
|
|
346
|
+
if (methods.length === 1) {
|
|
347
|
+
// Single method — skip method select, go straight to prompts or action
|
|
348
|
+
const method = methods[0];
|
|
349
|
+
ctx.methodIndex = 0;
|
|
350
|
+
ctx.methodType = method.type;
|
|
351
|
+
const promptSteps = buildPromptSteps(method);
|
|
352
|
+
if (promptSteps.length > 0) {
|
|
353
|
+
// Has prompts — defer and show first prompt
|
|
354
|
+
ctx.steps = promptSteps;
|
|
355
|
+
ctx.stepIndex = 0;
|
|
310
356
|
await interaction.deferUpdate();
|
|
311
|
-
await interaction
|
|
312
|
-
content: getClient.message,
|
|
313
|
-
components: [],
|
|
314
|
-
});
|
|
315
|
-
return;
|
|
357
|
+
await showNextStep(interaction, ctx, hash);
|
|
316
358
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
if (!selectedMethod) {
|
|
359
|
+
else if (method.type === 'api') {
|
|
360
|
+
// API key with no prompts — show modal directly (don't defer)
|
|
361
|
+
await showApiKeyModal(interaction, hash, providerName);
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
// OAuth with no prompts — defer and authorize
|
|
324
365
|
await interaction.deferUpdate();
|
|
366
|
+
await startOAuthFlow(interaction, ctx, hash);
|
|
367
|
+
}
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
// Multiple methods — show method select
|
|
371
|
+
ctx.steps = [
|
|
372
|
+
{ type: 'method', methods },
|
|
373
|
+
];
|
|
374
|
+
ctx.stepIndex = 0;
|
|
375
|
+
await interaction.deferUpdate();
|
|
376
|
+
await showNextStep(interaction, ctx, hash);
|
|
377
|
+
}
|
|
378
|
+
async function handleMethodStep(interaction, ctx, hash, value, step) {
|
|
379
|
+
const methodIndex = parseInt(value, 10);
|
|
380
|
+
const method = step.methods[methodIndex];
|
|
381
|
+
if (!method) {
|
|
382
|
+
await interaction.deferUpdate();
|
|
383
|
+
await interaction.editReply({
|
|
384
|
+
content: 'Invalid method selected.',
|
|
385
|
+
components: [],
|
|
386
|
+
});
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
ctx.methodIndex = methodIndex;
|
|
390
|
+
ctx.methodType = method.type;
|
|
391
|
+
const promptSteps = buildPromptSteps(method);
|
|
392
|
+
if (promptSteps.length > 0) {
|
|
393
|
+
// Replace remaining steps with prompt steps
|
|
394
|
+
ctx.steps = promptSteps;
|
|
395
|
+
ctx.stepIndex = 0;
|
|
396
|
+
await interaction.deferUpdate();
|
|
397
|
+
await showNextStep(interaction, ctx, hash);
|
|
398
|
+
}
|
|
399
|
+
else if (method.type === 'api') {
|
|
400
|
+
// API key with no prompts — show modal directly (don't defer)
|
|
401
|
+
await showApiKeyModal(interaction, hash, ctx.providerName || '');
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
// OAuth with no prompts
|
|
405
|
+
await interaction.deferUpdate();
|
|
406
|
+
await startOAuthFlow(interaction, ctx, hash);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
async function handlePromptStep(interaction, ctx, hash, value, step) {
|
|
410
|
+
// Store the answer
|
|
411
|
+
ctx.inputs[step.prompt.key] = value;
|
|
412
|
+
ctx.stepIndex++;
|
|
413
|
+
// Find the next prompt step that passes its `when` condition
|
|
414
|
+
await interaction.deferUpdate();
|
|
415
|
+
await showNextStep(interaction, ctx, hash);
|
|
416
|
+
}
|
|
417
|
+
// ── Step rendering ──────────────────────────────────────────────
|
|
418
|
+
// Advances through steps, skipping prompts whose `when` condition
|
|
419
|
+
// fails, until it finds one to show or reaches the end.
|
|
420
|
+
async function showNextStep(interaction, ctx, hash) {
|
|
421
|
+
// Skip prompts whose `when` condition doesn't match
|
|
422
|
+
while (ctx.stepIndex < ctx.steps.length) {
|
|
423
|
+
const step = ctx.steps[ctx.stepIndex];
|
|
424
|
+
if (step.type === 'prompt' && !shouldShowPrompt(step.prompt, ctx.inputs)) {
|
|
425
|
+
ctx.stepIndex++;
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
break;
|
|
429
|
+
}
|
|
430
|
+
if (ctx.stepIndex >= ctx.steps.length) {
|
|
431
|
+
// All steps done — proceed to action
|
|
432
|
+
if (ctx.methodType === 'api') {
|
|
433
|
+
// We're deferred, so show a button that opens the API key modal
|
|
434
|
+
const button = new ButtonBuilder()
|
|
435
|
+
.setCustomId(`login_apikey_btn:${hash}`)
|
|
436
|
+
.setLabel('Enter API Key')
|
|
437
|
+
.setStyle(ButtonStyle.Primary);
|
|
325
438
|
await interaction.editReply({
|
|
326
|
-
content:
|
|
327
|
-
components: [
|
|
439
|
+
content: `**Authenticate with ${ctx.providerName}**\nClick to enter your API key.`,
|
|
440
|
+
components: [
|
|
441
|
+
new ActionRowBuilder().addComponents(button),
|
|
442
|
+
],
|
|
328
443
|
});
|
|
329
|
-
return;
|
|
330
|
-
}
|
|
331
|
-
// Update context
|
|
332
|
-
context.methodIndex = selectedMethodIndex;
|
|
333
|
-
context.methodType = selectedMethod.type;
|
|
334
|
-
context.methodLabel = selectedMethod.label;
|
|
335
|
-
pendingLoginContexts.set(contextHash, context);
|
|
336
|
-
if (selectedMethod.type === 'api') {
|
|
337
|
-
// Show API key modal (don't defer for modals)
|
|
338
|
-
await showApiKeyModal(interaction, contextHash, context.providerName);
|
|
339
444
|
}
|
|
340
445
|
else {
|
|
341
|
-
|
|
342
|
-
await interaction.deferUpdate();
|
|
343
|
-
await startOAuthFlow(interaction, context, contextHash);
|
|
446
|
+
await startOAuthFlow(interaction, ctx, hash);
|
|
344
447
|
}
|
|
448
|
+
return;
|
|
345
449
|
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
450
|
+
const step = ctx.steps[ctx.stepIndex];
|
|
451
|
+
pendingLoginContexts.set(hash, ctx);
|
|
452
|
+
if (step.type === 'method') {
|
|
453
|
+
const options = step.methods.slice(0, 25).map((method, index) => ({
|
|
454
|
+
label: method.label.slice(0, 100),
|
|
455
|
+
value: String(index),
|
|
456
|
+
description: method.type === 'oauth'
|
|
457
|
+
? 'OAuth authentication'
|
|
458
|
+
: 'Enter API key manually',
|
|
459
|
+
}));
|
|
460
|
+
await interaction.editReply({
|
|
461
|
+
content: `**Authenticate with ${ctx.providerName}**\nSelect authentication method:`,
|
|
462
|
+
components: [
|
|
463
|
+
buildSelectMenu({
|
|
464
|
+
customId: `login_select:${hash}`,
|
|
465
|
+
placeholder: 'Select authentication method',
|
|
466
|
+
options,
|
|
467
|
+
}),
|
|
468
|
+
],
|
|
469
|
+
});
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
if (step.type === 'prompt') {
|
|
473
|
+
const prompt = step.prompt;
|
|
474
|
+
if (prompt.type === 'select') {
|
|
475
|
+
const options = prompt.options.slice(0, 25).map((opt) => ({
|
|
476
|
+
label: opt.label.slice(0, 100),
|
|
477
|
+
value: opt.value,
|
|
478
|
+
description: opt.hint?.slice(0, 100),
|
|
479
|
+
}));
|
|
352
480
|
await interaction.editReply({
|
|
353
|
-
content:
|
|
354
|
-
components: [
|
|
481
|
+
content: `**Authenticate with ${ctx.providerName}**\n${prompt.message}`,
|
|
482
|
+
components: [
|
|
483
|
+
buildSelectMenu({
|
|
484
|
+
customId: `login_select:${hash}`,
|
|
485
|
+
placeholder: prompt.message.slice(0, 150),
|
|
486
|
+
options,
|
|
487
|
+
}),
|
|
488
|
+
],
|
|
355
489
|
});
|
|
490
|
+
return;
|
|
356
491
|
}
|
|
357
|
-
|
|
358
|
-
//
|
|
492
|
+
if (prompt.type === 'text') {
|
|
493
|
+
// Text prompts need a modal, but we're deferred. Show a button.
|
|
494
|
+
const button = new ButtonBuilder()
|
|
495
|
+
.setCustomId(`login_text_btn:${hash}`)
|
|
496
|
+
.setLabel(prompt.message.slice(0, 80))
|
|
497
|
+
.setStyle(ButtonStyle.Primary);
|
|
498
|
+
await interaction.editReply({
|
|
499
|
+
content: `**Authenticate with ${ctx.providerName}**\n${prompt.message}`,
|
|
500
|
+
components: [
|
|
501
|
+
new ActionRowBuilder().addComponents(button),
|
|
502
|
+
],
|
|
503
|
+
});
|
|
504
|
+
return;
|
|
359
505
|
}
|
|
360
506
|
}
|
|
361
507
|
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
508
|
+
function buildPromptSteps(method) {
|
|
509
|
+
return (method.prompts || []).map((prompt) => ({
|
|
510
|
+
type: 'prompt',
|
|
511
|
+
prompt,
|
|
512
|
+
}));
|
|
513
|
+
}
|
|
514
|
+
// ── Text prompt button + modal ──────────────────────────────────
|
|
515
|
+
// When a text prompt needs to be shown but we're in a deferred state,
|
|
516
|
+
// we show a button. Clicking it opens a modal for text input.
|
|
517
|
+
export async function handleLoginTextButton(interaction) {
|
|
518
|
+
if (!interaction.customId.startsWith('login_text_btn:')) {
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const hash = interaction.customId.replace('login_text_btn:', '');
|
|
522
|
+
const ctx = pendingLoginContexts.get(hash);
|
|
523
|
+
if (!ctx) {
|
|
524
|
+
await interaction.reply({
|
|
525
|
+
content: 'Selection expired. Please run /login again.',
|
|
526
|
+
flags: MessageFlags.Ephemeral,
|
|
527
|
+
});
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
const step = ctx.steps[ctx.stepIndex];
|
|
531
|
+
if (!step || step.type !== 'prompt' || step.prompt.type !== 'text') {
|
|
532
|
+
await interaction.reply({
|
|
533
|
+
content: 'Invalid state. Please run /login again.',
|
|
534
|
+
flags: MessageFlags.Ephemeral,
|
|
535
|
+
});
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
const modal = new ModalBuilder()
|
|
539
|
+
.setCustomId(`login_text:${hash}`)
|
|
540
|
+
.setTitle(`${ctx.providerName || 'Provider'} Login`.slice(0, 45));
|
|
541
|
+
const textInput = new TextInputBuilder()
|
|
542
|
+
.setCustomId('prompt_value')
|
|
543
|
+
.setLabel(step.prompt.message.slice(0, 45))
|
|
544
|
+
.setPlaceholder(step.prompt.type === 'text' ? (step.prompt.placeholder || '') : '')
|
|
545
|
+
.setStyle(TextInputStyle.Short)
|
|
546
|
+
.setRequired(true);
|
|
547
|
+
modal.addComponents(new ActionRowBuilder().addComponents(textInput));
|
|
548
|
+
await interaction.showModal(modal);
|
|
549
|
+
}
|
|
550
|
+
export async function handleLoginTextModalSubmit(interaction) {
|
|
551
|
+
if (!interaction.customId.startsWith('login_text:')) {
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
await interaction.deferUpdate();
|
|
555
|
+
const hash = interaction.customId.replace('login_text:', '');
|
|
556
|
+
const ctx = pendingLoginContexts.get(hash);
|
|
557
|
+
if (!ctx) {
|
|
558
|
+
await interaction.editReply({
|
|
559
|
+
content: 'Selection expired. Please run /login again.',
|
|
560
|
+
components: [],
|
|
561
|
+
});
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
const step = ctx.steps[ctx.stepIndex];
|
|
565
|
+
if (!step || step.type !== 'prompt' || step.prompt.type !== 'text') {
|
|
566
|
+
await interaction.editReply({
|
|
567
|
+
content: 'Invalid state. Please run /login again.',
|
|
568
|
+
components: [],
|
|
569
|
+
});
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
const value = interaction.fields.getTextInputValue('prompt_value');
|
|
573
|
+
if (!value?.trim()) {
|
|
574
|
+
await interaction.editReply({
|
|
575
|
+
content: 'A value is required.',
|
|
576
|
+
components: [],
|
|
577
|
+
});
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
ctx.inputs[step.prompt.key] = value.trim();
|
|
581
|
+
ctx.stepIndex++;
|
|
582
|
+
await showNextStep(interaction, ctx, hash);
|
|
583
|
+
}
|
|
584
|
+
// ── API key button + modal ──────────────────────────────────────
|
|
585
|
+
// When we're deferred and need an API key modal, show a button first.
|
|
586
|
+
export async function handleLoginApiKeyButton(interaction) {
|
|
587
|
+
if (!interaction.customId.startsWith('login_apikey_btn:')) {
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
const hash = interaction.customId.replace('login_apikey_btn:', '');
|
|
591
|
+
const ctx = pendingLoginContexts.get(hash);
|
|
592
|
+
if (!ctx || !ctx.providerName) {
|
|
593
|
+
await interaction.reply({
|
|
594
|
+
content: 'Selection expired. Please run /login again.',
|
|
595
|
+
flags: MessageFlags.Ephemeral,
|
|
596
|
+
});
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
await showApiKeyModal(interaction, hash, ctx.providerName);
|
|
600
|
+
}
|
|
601
|
+
async function showApiKeyModal(interaction, hash, providerName) {
|
|
366
602
|
const modal = new ModalBuilder()
|
|
367
|
-
.setCustomId(`login_apikey:${
|
|
603
|
+
.setCustomId(`login_apikey:${hash}`)
|
|
368
604
|
.setTitle(`${providerName} API Key`.slice(0, 45));
|
|
369
605
|
const apiKeyInput = new TextInputBuilder()
|
|
370
606
|
.setCustomId('apikey')
|
|
@@ -372,23 +608,62 @@ async function showApiKeyModal(interaction, contextHash, providerName) {
|
|
|
372
608
|
.setPlaceholder('sk-...')
|
|
373
609
|
.setStyle(TextInputStyle.Short)
|
|
374
610
|
.setRequired(true);
|
|
375
|
-
|
|
376
|
-
|
|
611
|
+
modal.addComponents(new ActionRowBuilder().addComponents(apiKeyInput));
|
|
612
|
+
await interaction.showModal(modal);
|
|
613
|
+
}
|
|
614
|
+
// ── OAuth code submission (code mode) ───────────────────────────
|
|
615
|
+
// When the OAuth flow returns method="code", the user completes login
|
|
616
|
+
// in a browser (possibly on a different machine) and pastes the final
|
|
617
|
+
// callback URL or authorization code here.
|
|
618
|
+
export async function handleOAuthCodeButton(interaction) {
|
|
619
|
+
if (!interaction.customId.startsWith('login_oauth_code_btn:')) {
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
const hash = interaction.customId.replace('login_oauth_code_btn:', '');
|
|
623
|
+
const ctx = pendingLoginContexts.get(hash);
|
|
624
|
+
if (!ctx || !ctx.providerId || !ctx.providerName) {
|
|
625
|
+
await interaction.reply({
|
|
626
|
+
content: 'Selection expired. Please run /login again.',
|
|
627
|
+
flags: MessageFlags.Ephemeral,
|
|
628
|
+
});
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
const modal = new ModalBuilder()
|
|
632
|
+
.setCustomId(`login_oauth_code:${hash}`)
|
|
633
|
+
.setTitle(`${ctx.providerName} Authorization`.slice(0, 45));
|
|
634
|
+
const codeInput = new TextInputBuilder()
|
|
635
|
+
.setCustomId('oauth_code')
|
|
636
|
+
.setLabel('Authorization code or callback URL')
|
|
637
|
+
.setPlaceholder('Paste the code or full callback URL')
|
|
638
|
+
.setStyle(TextInputStyle.Paragraph)
|
|
639
|
+
.setRequired(true);
|
|
640
|
+
modal.addComponents(new ActionRowBuilder().addComponents(codeInput));
|
|
377
641
|
await interaction.showModal(modal);
|
|
378
642
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
643
|
+
export async function handleOAuthCodeModalSubmit(interaction) {
|
|
644
|
+
if (!interaction.customId.startsWith('login_oauth_code:')) {
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
await interaction.deferUpdate();
|
|
648
|
+
const hash = interaction.customId.replace('login_oauth_code:', '');
|
|
649
|
+
const ctx = pendingLoginContexts.get(hash);
|
|
650
|
+
if (!ctx || !ctx.providerId || !ctx.providerName || ctx.methodIndex === undefined) {
|
|
384
651
|
await interaction.editReply({
|
|
385
|
-
content: '
|
|
652
|
+
content: 'Session expired. Please run /login again.',
|
|
653
|
+
components: [],
|
|
654
|
+
});
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
const code = interaction.fields.getTextInputValue('oauth_code')?.trim();
|
|
658
|
+
if (!code) {
|
|
659
|
+
await interaction.editReply({
|
|
660
|
+
content: 'Authorization code is required.',
|
|
386
661
|
components: [],
|
|
387
662
|
});
|
|
388
663
|
return;
|
|
389
664
|
}
|
|
390
665
|
try {
|
|
391
|
-
const getClient = await initializeOpencodeForDirectory(
|
|
666
|
+
const getClient = await initializeOpencodeForDirectory(ctx.dir);
|
|
392
667
|
if (getClient instanceof Error) {
|
|
393
668
|
await interaction.editReply({
|
|
394
669
|
content: getClient.message,
|
|
@@ -397,92 +672,47 @@ async function startOAuthFlow(interaction, context, contextHash) {
|
|
|
397
672
|
return;
|
|
398
673
|
}
|
|
399
674
|
await interaction.editReply({
|
|
400
|
-
content: `**Authenticating with ${
|
|
675
|
+
content: `**Authenticating with ${ctx.providerName}**\nVerifying authorization...`,
|
|
401
676
|
components: [],
|
|
402
677
|
});
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
directory:
|
|
678
|
+
const callbackResponse = await getClient().provider.oauth.callback({
|
|
679
|
+
providerID: ctx.providerId,
|
|
680
|
+
method: ctx.methodIndex,
|
|
681
|
+
code,
|
|
682
|
+
directory: ctx.dir,
|
|
408
683
|
});
|
|
409
|
-
if (
|
|
410
|
-
|
|
684
|
+
if (callbackResponse.error) {
|
|
685
|
+
pendingLoginContexts.delete(hash);
|
|
411
686
|
await interaction.editReply({
|
|
412
|
-
content:
|
|
687
|
+
content: `**Authentication Failed**\n${extractErrorMessage({ error: callbackResponse.error, fallback: 'Authorization code was invalid or expired' })}`,
|
|
413
688
|
components: [],
|
|
414
689
|
});
|
|
415
690
|
return;
|
|
416
691
|
}
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
let message = `**Authenticating with ${context.providerName}**\n\n`;
|
|
420
|
-
message += `Open this URL to authorize:\n${url}\n\n`;
|
|
421
|
-
if (instructions) {
|
|
422
|
-
// Extract code from instructions like "Enter code: ABC-123"
|
|
423
|
-
const codeMatch = instructions.match(/code[:\s]+([A-Z0-9-]+)/i);
|
|
424
|
-
if (codeMatch) {
|
|
425
|
-
message += `**Code:** \`${codeMatch[1]}\`\n\n`;
|
|
426
|
-
}
|
|
427
|
-
else {
|
|
428
|
-
message += `${instructions}\n\n`;
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
if (method === 'auto') {
|
|
432
|
-
message += '_Waiting for authorization to complete..._';
|
|
433
|
-
}
|
|
692
|
+
await getClient().instance.dispose({ directory: ctx.dir });
|
|
693
|
+
pendingLoginContexts.delete(hash);
|
|
434
694
|
await interaction.editReply({
|
|
435
|
-
content:
|
|
695
|
+
content: `✅ **Successfully authenticated with ${ctx.providerName}!**\n\nYou can now use models from this provider.`,
|
|
436
696
|
components: [],
|
|
437
697
|
});
|
|
438
|
-
if (method === 'auto') {
|
|
439
|
-
// Poll for completion (device flow)
|
|
440
|
-
const callbackResponse = await getClient().provider.oauth.callback({
|
|
441
|
-
providerID: context.providerId,
|
|
442
|
-
method: context.methodIndex,
|
|
443
|
-
directory: context.dir,
|
|
444
|
-
});
|
|
445
|
-
if (callbackResponse.error) {
|
|
446
|
-
const errorData = callbackResponse.error;
|
|
447
|
-
await interaction.editReply({
|
|
448
|
-
content: `**Authentication Failed**\n${errorData?.data?.message || 'Authorization was not completed'}`,
|
|
449
|
-
components: [],
|
|
450
|
-
});
|
|
451
|
-
return;
|
|
452
|
-
}
|
|
453
|
-
// Dispose to refresh provider state so new credentials are recognized
|
|
454
|
-
await getClient().instance.dispose({ directory: context.dir });
|
|
455
|
-
await interaction.editReply({
|
|
456
|
-
content: `✅ **Successfully authenticated with ${context.providerName}!**\n\nYou can now use models from this provider.`,
|
|
457
|
-
components: [],
|
|
458
|
-
});
|
|
459
|
-
}
|
|
460
|
-
// For 'code' method, we would need to prompt for code input
|
|
461
|
-
// But Discord modals can't be shown after deferUpdate, so we'd need a different flow
|
|
462
|
-
// For now, most providers use 'auto' (device flow) which works well for Discord
|
|
463
|
-
// Clean up context
|
|
464
|
-
pendingLoginContexts.delete(contextHash);
|
|
465
698
|
}
|
|
466
699
|
catch (error) {
|
|
467
|
-
loginLogger.error('OAuth
|
|
700
|
+
loginLogger.error('OAuth code submission error:', error);
|
|
701
|
+
pendingLoginContexts.delete(hash);
|
|
468
702
|
await interaction.editReply({
|
|
469
703
|
content: `**Authentication Failed**\n${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
470
704
|
components: [],
|
|
471
705
|
});
|
|
472
706
|
}
|
|
473
707
|
}
|
|
474
|
-
/**
|
|
475
|
-
* Handle API key modal submission.
|
|
476
|
-
*/
|
|
477
708
|
export async function handleApiKeyModalSubmit(interaction) {
|
|
478
|
-
|
|
479
|
-
if (!customId.startsWith('login_apikey:')) {
|
|
709
|
+
if (!interaction.customId.startsWith('login_apikey:')) {
|
|
480
710
|
return;
|
|
481
711
|
}
|
|
482
712
|
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
|
483
|
-
const
|
|
484
|
-
const
|
|
485
|
-
if (!
|
|
713
|
+
const hash = interaction.customId.replace('login_apikey:', '');
|
|
714
|
+
const ctx = pendingLoginContexts.get(hash);
|
|
715
|
+
if (!ctx || !ctx.providerId || !ctx.providerName) {
|
|
486
716
|
await interaction.editReply({
|
|
487
717
|
content: 'Session expired. Please run /login again.',
|
|
488
718
|
});
|
|
@@ -490,39 +720,166 @@ export async function handleApiKeyModalSubmit(interaction) {
|
|
|
490
720
|
}
|
|
491
721
|
const apiKey = interaction.fields.getTextInputValue('apikey');
|
|
492
722
|
if (!apiKey?.trim()) {
|
|
723
|
+
await interaction.editReply({ content: 'API key is required.' });
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
try {
|
|
727
|
+
const getClient = await initializeOpencodeForDirectory(ctx.dir);
|
|
728
|
+
if (getClient instanceof Error) {
|
|
729
|
+
await interaction.editReply({ content: getClient.message });
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
await getClient().auth.set({
|
|
733
|
+
providerID: ctx.providerId,
|
|
734
|
+
auth: { type: 'api', key: apiKey.trim() },
|
|
735
|
+
});
|
|
736
|
+
// Dispose to refresh provider state so new credentials are recognized
|
|
737
|
+
await getClient().instance.dispose({ directory: ctx.dir });
|
|
738
|
+
await interaction.editReply({
|
|
739
|
+
content: `✅ **Successfully authenticated with ${ctx.providerName}!**\n\nYou can now use models from this provider.`,
|
|
740
|
+
});
|
|
741
|
+
pendingLoginContexts.delete(hash);
|
|
742
|
+
}
|
|
743
|
+
catch (error) {
|
|
744
|
+
loginLogger.error('API key save error:', error);
|
|
745
|
+
await interaction.editReply({
|
|
746
|
+
content: `**Failed to save API key**\n${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
// ── OAuth flow ──────────────────────────────────────────────────
|
|
751
|
+
async function startOAuthFlow(interaction, ctx, hash) {
|
|
752
|
+
if (!ctx.providerId || ctx.methodIndex === undefined) {
|
|
493
753
|
await interaction.editReply({
|
|
494
|
-
content: '
|
|
754
|
+
content: 'Invalid context for OAuth flow',
|
|
755
|
+
components: [],
|
|
495
756
|
});
|
|
496
757
|
return;
|
|
497
758
|
}
|
|
498
759
|
try {
|
|
499
|
-
const getClient = await initializeOpencodeForDirectory(
|
|
760
|
+
const getClient = await initializeOpencodeForDirectory(ctx.dir);
|
|
500
761
|
if (getClient instanceof Error) {
|
|
501
762
|
await interaction.editReply({
|
|
502
763
|
content: getClient.message,
|
|
764
|
+
components: [],
|
|
503
765
|
});
|
|
504
766
|
return;
|
|
505
767
|
}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
auth: {
|
|
510
|
-
type: 'api',
|
|
511
|
-
key: apiKey.trim(),
|
|
512
|
-
},
|
|
768
|
+
await interaction.editReply({
|
|
769
|
+
content: `**Authenticating with ${ctx.providerName}**\nStarting authorization...`,
|
|
770
|
+
components: [],
|
|
513
771
|
});
|
|
514
|
-
//
|
|
515
|
-
|
|
772
|
+
// Direct fetch to the server because the SDK's buildClientParams drops
|
|
773
|
+
// unknown keys — `inputs` would be silently stripped. The server accepts
|
|
774
|
+
// `inputs` in the body (see opencode server/routes/provider.ts).
|
|
775
|
+
const port = getOpencodeServerPort();
|
|
776
|
+
if (!port) {
|
|
777
|
+
await interaction.editReply({
|
|
778
|
+
content: 'OpenCode server is not running. Please try again.',
|
|
779
|
+
components: [],
|
|
780
|
+
});
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
const hasInputs = Object.keys(ctx.inputs).length > 0;
|
|
784
|
+
const authorizeUrl = new URL(`/provider/${encodeURIComponent(ctx.providerId)}/oauth/authorize`, `http://127.0.0.1:${port}`);
|
|
785
|
+
authorizeUrl.searchParams.set('directory', ctx.dir);
|
|
786
|
+
// Include basic auth if OPENCODE_SERVER_PASSWORD is set,
|
|
787
|
+
// matching the opencode server's optional basicAuth middleware.
|
|
788
|
+
const fetchHeaders = {
|
|
789
|
+
'Content-Type': 'application/json',
|
|
790
|
+
'x-opencode-directory': ctx.dir,
|
|
791
|
+
};
|
|
792
|
+
const serverPassword = process.env.OPENCODE_SERVER_PASSWORD;
|
|
793
|
+
if (serverPassword) {
|
|
794
|
+
const username = process.env.OPENCODE_SERVER_USERNAME || 'opencode';
|
|
795
|
+
fetchHeaders['Authorization'] =
|
|
796
|
+
`Basic ${Buffer.from(`${username}:${serverPassword}`).toString('base64')}`;
|
|
797
|
+
}
|
|
798
|
+
const authorizeRes = await fetch(authorizeUrl, {
|
|
799
|
+
method: 'POST',
|
|
800
|
+
headers: fetchHeaders,
|
|
801
|
+
body: JSON.stringify({
|
|
802
|
+
method: ctx.methodIndex,
|
|
803
|
+
...(hasInputs ? { inputs: ctx.inputs } : {}),
|
|
804
|
+
}),
|
|
805
|
+
});
|
|
806
|
+
if (!authorizeRes.ok) {
|
|
807
|
+
const errorText = await authorizeRes.text().catch(() => '');
|
|
808
|
+
let errorMessage = 'Unknown error';
|
|
809
|
+
try {
|
|
810
|
+
const parsed = JSON.parse(errorText);
|
|
811
|
+
errorMessage = parsed?.data?.message || parsed?.message || errorMessage;
|
|
812
|
+
}
|
|
813
|
+
catch {
|
|
814
|
+
errorMessage = errorText || errorMessage;
|
|
815
|
+
}
|
|
816
|
+
await interaction.editReply({
|
|
817
|
+
content: `Failed to start authorization: ${errorMessage}`,
|
|
818
|
+
components: [],
|
|
819
|
+
});
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
const { url, method, instructions } = (await authorizeRes.json());
|
|
823
|
+
let message = `**Authenticating with ${ctx.providerName}**\n\n`;
|
|
824
|
+
message += `Open this URL to authorize:\n${url}\n\n`;
|
|
825
|
+
if (instructions) {
|
|
826
|
+
// Match "code: ABC-123" or "code: WXYZ1234" but not natural language
|
|
827
|
+
// like "code will". Require a colon separator and uppercase alphanum code.
|
|
828
|
+
const codeMatch = instructions.match(/code:\s*([A-Z0-9][A-Z0-9-]+)/);
|
|
829
|
+
if (codeMatch) {
|
|
830
|
+
message += `**Code:** \`${codeMatch[1]}\`\n\n`;
|
|
831
|
+
}
|
|
832
|
+
else {
|
|
833
|
+
message += `${instructions}\n\n`;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
if (method === 'auto') {
|
|
837
|
+
message += '_Waiting for authorization to complete..._';
|
|
838
|
+
}
|
|
839
|
+
if (method === 'code') {
|
|
840
|
+
// Code mode: show a button to paste the auth code/URL after
|
|
841
|
+
// completing login in a browser (possibly on a different machine).
|
|
842
|
+
const button = new ButtonBuilder()
|
|
843
|
+
.setCustomId(`login_oauth_code_btn:${hash}`)
|
|
844
|
+
.setLabel('Paste authorization code')
|
|
845
|
+
.setStyle(ButtonStyle.Primary);
|
|
846
|
+
await interaction.editReply({
|
|
847
|
+
content: message,
|
|
848
|
+
components: [
|
|
849
|
+
new ActionRowBuilder().addComponents(button),
|
|
850
|
+
],
|
|
851
|
+
});
|
|
852
|
+
// Don't delete context — we need it for the code submission
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
await interaction.editReply({ content: message, components: [] });
|
|
856
|
+
// Auto mode: poll for completion (device flow / localhost callback)
|
|
857
|
+
const callbackResponse = await getClient().provider.oauth.callback({
|
|
858
|
+
providerID: ctx.providerId,
|
|
859
|
+
method: ctx.methodIndex,
|
|
860
|
+
directory: ctx.dir,
|
|
861
|
+
});
|
|
862
|
+
if (callbackResponse.error) {
|
|
863
|
+
pendingLoginContexts.delete(hash);
|
|
864
|
+
await interaction.editReply({
|
|
865
|
+
content: `**Authentication Failed**\n${extractErrorMessage({ error: callbackResponse.error, fallback: 'Authorization was not completed' })}`,
|
|
866
|
+
components: [],
|
|
867
|
+
});
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
await getClient().instance.dispose({ directory: ctx.dir });
|
|
871
|
+
pendingLoginContexts.delete(hash);
|
|
516
872
|
await interaction.editReply({
|
|
517
|
-
content: `✅ **Successfully authenticated with ${
|
|
873
|
+
content: `✅ **Successfully authenticated with ${ctx.providerName}!**\n\nYou can now use models from this provider.`,
|
|
874
|
+
components: [],
|
|
518
875
|
});
|
|
519
|
-
// Clean up context
|
|
520
|
-
pendingLoginContexts.delete(contextHash);
|
|
521
876
|
}
|
|
522
877
|
catch (error) {
|
|
523
|
-
loginLogger.error('
|
|
878
|
+
loginLogger.error('OAuth flow error:', error);
|
|
879
|
+
pendingLoginContexts.delete(hash);
|
|
524
880
|
await interaction.editReply({
|
|
525
|
-
content: `**Failed
|
|
881
|
+
content: `**Authentication Failed**\n${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
882
|
+
components: [],
|
|
526
883
|
});
|
|
527
884
|
}
|
|
528
885
|
}
|