disunday 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai-tool-to-genai.js +208 -0
- package/dist/ai-tool-to-genai.test.js +267 -0
- package/dist/channel-management.js +96 -0
- package/dist/cli.js +1674 -0
- package/dist/commands/abort.js +89 -0
- package/dist/commands/add-project.js +117 -0
- package/dist/commands/agent.js +250 -0
- package/dist/commands/ask-question.js +219 -0
- package/dist/commands/compact.js +126 -0
- package/dist/commands/context-menu.js +171 -0
- package/dist/commands/context.js +89 -0
- package/dist/commands/cost.js +93 -0
- package/dist/commands/create-new-project.js +111 -0
- package/dist/commands/diff.js +77 -0
- package/dist/commands/export.js +100 -0
- package/dist/commands/files.js +73 -0
- package/dist/commands/fork.js +199 -0
- package/dist/commands/help.js +54 -0
- package/dist/commands/login.js +488 -0
- package/dist/commands/merge-worktree.js +165 -0
- package/dist/commands/model.js +325 -0
- package/dist/commands/permissions.js +140 -0
- package/dist/commands/ping.js +13 -0
- package/dist/commands/queue.js +133 -0
- package/dist/commands/remove-project.js +119 -0
- package/dist/commands/rename.js +70 -0
- package/dist/commands/restart-opencode-server.js +77 -0
- package/dist/commands/resume.js +276 -0
- package/dist/commands/run-config.js +79 -0
- package/dist/commands/run.js +240 -0
- package/dist/commands/schedule.js +170 -0
- package/dist/commands/session-info.js +58 -0
- package/dist/commands/session.js +191 -0
- package/dist/commands/settings.js +84 -0
- package/dist/commands/share.js +89 -0
- package/dist/commands/status.js +79 -0
- package/dist/commands/sync.js +119 -0
- package/dist/commands/theme.js +53 -0
- package/dist/commands/types.js +2 -0
- package/dist/commands/undo-redo.js +170 -0
- package/dist/commands/user-command.js +135 -0
- package/dist/commands/verbosity.js +59 -0
- package/dist/commands/worktree-settings.js +50 -0
- package/dist/commands/worktree.js +288 -0
- package/dist/config.js +139 -0
- package/dist/database.js +585 -0
- package/dist/discord-bot.js +700 -0
- package/dist/discord-utils.js +336 -0
- package/dist/discord-utils.test.js +20 -0
- package/dist/errors.js +193 -0
- package/dist/escape-backticks.test.js +429 -0
- package/dist/format-tables.js +96 -0
- package/dist/format-tables.test.js +418 -0
- package/dist/genai-worker-wrapper.js +109 -0
- package/dist/genai-worker.js +299 -0
- package/dist/genai.js +230 -0
- package/dist/image-utils.js +107 -0
- package/dist/interaction-handler.js +289 -0
- package/dist/limit-heading-depth.js +25 -0
- package/dist/limit-heading-depth.test.js +105 -0
- package/dist/logger.js +111 -0
- package/dist/markdown.js +323 -0
- package/dist/markdown.test.js +269 -0
- package/dist/message-formatting.js +447 -0
- package/dist/message-formatting.test.js +73 -0
- package/dist/openai-realtime.js +226 -0
- package/dist/opencode.js +224 -0
- package/dist/reaction-handler.js +128 -0
- package/dist/scheduler.js +93 -0
- package/dist/security.js +200 -0
- package/dist/session-handler.js +1436 -0
- package/dist/system-message.js +138 -0
- package/dist/tools.js +354 -0
- package/dist/unnest-code-blocks.js +117 -0
- package/dist/unnest-code-blocks.test.js +432 -0
- package/dist/utils.js +95 -0
- package/dist/voice-handler.js +569 -0
- package/dist/voice.js +344 -0
- package/dist/worker-types.js +4 -0
- package/dist/worktree-utils.js +134 -0
- package/dist/xml.js +90 -0
- package/dist/xml.test.js +32 -0
- package/package.json +84 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1674 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Main CLI entrypoint for the Disunday Discord bot.
|
|
3
|
+
// Handles interactive setup, Discord OAuth, slash command registration,
|
|
4
|
+
// project channel creation, and launching the bot with opencode integration.
|
|
5
|
+
import { cac } from 'cac';
|
|
6
|
+
import { intro, outro, text, password, note, cancel, isCancel, confirm, log, multiselect, spinner, } from '@clack/prompts';
|
|
7
|
+
import { deduplicateByKey, generateBotInstallUrl, abbreviatePath, } from './utils.js';
|
|
8
|
+
import { getChannelsWithDescriptions, createDiscordClient, getDatabase, getChannelDirectory, startDiscordBot, initializeOpencodeForDirectory, ensureDisundayCategory, createProjectChannels, } from './discord-bot.js';
|
|
9
|
+
import { Events, ChannelType, ApplicationCommandType, ContextMenuCommandBuilder, REST, Routes, SlashCommandBuilder, AttachmentBuilder, } from 'discord.js';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import * as errore from 'errore';
|
|
13
|
+
import { createLogger, LogPrefix } from './logger.js';
|
|
14
|
+
import { uploadFilesToDiscord } from './discord-utils.js';
|
|
15
|
+
import { spawn, spawnSync, execSync, } from 'node:child_process';
|
|
16
|
+
import http from 'node:http';
|
|
17
|
+
import { setDataDir, getDataDir, getLockPort, setDefaultVerbosity, } from './config.js';
|
|
18
|
+
import { sanitizeAgentName } from './commands/agent.js';
|
|
19
|
+
const cliLogger = createLogger(LogPrefix.CLI);
|
|
20
|
+
// Strip bracketed paste escape sequences from terminal input.
|
|
21
|
+
// iTerm2 and other terminals wrap pasted content with \x1b[200~ and \x1b[201~
|
|
22
|
+
// which can cause validation to fail on macOS. See: https://github.com/remorses/kimaki/issues/18
|
|
23
|
+
function stripBracketedPaste(value) {
|
|
24
|
+
if (!value) {
|
|
25
|
+
return '';
|
|
26
|
+
}
|
|
27
|
+
return value
|
|
28
|
+
.replace(/\x1b\[200~/g, '')
|
|
29
|
+
.replace(/\x1b\[201~/g, '')
|
|
30
|
+
.trim();
|
|
31
|
+
}
|
|
32
|
+
// Spawn caffeinate on macOS to prevent system sleep while bot is running.
|
|
33
|
+
// Not detached, so it dies automatically with the parent process.
|
|
34
|
+
function startCaffeinate() {
|
|
35
|
+
if (process.platform !== 'darwin') {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const proc = spawn('caffeinate', ['-i'], {
|
|
40
|
+
stdio: 'ignore',
|
|
41
|
+
detached: false,
|
|
42
|
+
});
|
|
43
|
+
proc.on('error', (err) => {
|
|
44
|
+
cliLogger.warn('Failed to start caffeinate:', err.message);
|
|
45
|
+
});
|
|
46
|
+
cliLogger.log('Started caffeinate to prevent system sleep');
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
cliLogger.warn('Failed to spawn caffeinate:', err instanceof Error ? err.message : String(err));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async function migrateFromLegacy() {
|
|
53
|
+
const os = await import('node:os');
|
|
54
|
+
const oldDataDir = path.join(os.default.homedir(), '.kimaki');
|
|
55
|
+
const newDataDir = getDataDir();
|
|
56
|
+
if (!fs.existsSync(oldDataDir)) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
const oldDbPath = path.join(oldDataDir, 'discord-sessions.db');
|
|
60
|
+
const newDbPath = path.join(newDataDir, 'discord-sessions.db');
|
|
61
|
+
if (!fs.existsSync(oldDbPath)) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
if (fs.existsSync(newDbPath)) {
|
|
65
|
+
const stats = fs.statSync(newDbPath);
|
|
66
|
+
if (stats.size > 1000) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const shouldMigrate = await confirm({
|
|
71
|
+
message: `Found existing legacy data at ~/.kimaki. Migrate to ~/.disunday?`,
|
|
72
|
+
initialValue: true,
|
|
73
|
+
});
|
|
74
|
+
if (isCancel(shouldMigrate) || !shouldMigrate) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
const s = spinner();
|
|
78
|
+
s.start('Migrating data from ~/.kimaki...');
|
|
79
|
+
try {
|
|
80
|
+
if (!fs.existsSync(newDataDir)) {
|
|
81
|
+
fs.mkdirSync(newDataDir, { recursive: true });
|
|
82
|
+
}
|
|
83
|
+
const items = fs.readdirSync(oldDataDir);
|
|
84
|
+
for (const item of items) {
|
|
85
|
+
const srcPath = path.join(oldDataDir, item);
|
|
86
|
+
const destPath = path.join(newDataDir, item);
|
|
87
|
+
if (!fs.existsSync(destPath)) {
|
|
88
|
+
fs.cpSync(srcPath, destPath, { recursive: true });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
s.stop('Migration complete!');
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
s.stop('Migration failed');
|
|
96
|
+
cliLogger.error('Migration error:', error);
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const cli = cac('disunday');
|
|
101
|
+
process.title = 'disunday';
|
|
102
|
+
async function killProcessOnPort(port) {
|
|
103
|
+
const isWindows = process.platform === 'win32';
|
|
104
|
+
const myPid = process.pid;
|
|
105
|
+
try {
|
|
106
|
+
if (isWindows) {
|
|
107
|
+
// Windows: find PID using netstat, then kill
|
|
108
|
+
const result = spawnSync('cmd', [
|
|
109
|
+
'/c',
|
|
110
|
+
`for /f "tokens=5" %a in ('netstat -ano ^| findstr :${port} ^| findstr LISTENING') do @echo %a`,
|
|
111
|
+
], {
|
|
112
|
+
shell: false,
|
|
113
|
+
encoding: 'utf-8',
|
|
114
|
+
});
|
|
115
|
+
const pids = result.stdout
|
|
116
|
+
?.trim()
|
|
117
|
+
.split('\n')
|
|
118
|
+
.map((p) => p.trim())
|
|
119
|
+
.filter((p) => /^\d+$/.test(p));
|
|
120
|
+
// Filter out our own PID and take the first (oldest)
|
|
121
|
+
const targetPid = pids?.find((p) => parseInt(p, 10) !== myPid);
|
|
122
|
+
if (targetPid) {
|
|
123
|
+
cliLogger.log(`Killing existing disunday process (PID: ${targetPid})`);
|
|
124
|
+
spawnSync('taskkill', ['/F', '/PID', targetPid], { shell: false });
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
// Unix: use lsof with -sTCP:LISTEN to only find the listening process
|
|
130
|
+
const result = spawnSync('lsof', ['-i', `:${port}`, '-sTCP:LISTEN', '-t'], {
|
|
131
|
+
shell: false,
|
|
132
|
+
encoding: 'utf-8',
|
|
133
|
+
});
|
|
134
|
+
const pids = result.stdout
|
|
135
|
+
?.trim()
|
|
136
|
+
.split('\n')
|
|
137
|
+
.map((p) => p.trim())
|
|
138
|
+
.filter((p) => /^\d+$/.test(p));
|
|
139
|
+
// Filter out our own PID and take the first (oldest)
|
|
140
|
+
const targetPid = pids?.find((p) => parseInt(p, 10) !== myPid);
|
|
141
|
+
if (targetPid) {
|
|
142
|
+
const pid = parseInt(targetPid, 10);
|
|
143
|
+
cliLogger.log(`Stopping existing disunday process (PID: ${pid})`);
|
|
144
|
+
process.kill(pid, 'SIGKILL');
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch (e) {
|
|
150
|
+
cliLogger.debug(`Failed to kill process on port ${port}:`, e);
|
|
151
|
+
}
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
async function checkSingleInstance() {
|
|
155
|
+
const lockPort = getLockPort();
|
|
156
|
+
try {
|
|
157
|
+
const response = await fetch(`http://127.0.0.1:${lockPort}`, {
|
|
158
|
+
signal: AbortSignal.timeout(1000),
|
|
159
|
+
});
|
|
160
|
+
if (response.ok) {
|
|
161
|
+
cliLogger.log(`Another disunday instance detected for data dir: ${getDataDir()}`);
|
|
162
|
+
await killProcessOnPort(lockPort);
|
|
163
|
+
// Wait a moment for port to be released
|
|
164
|
+
await new Promise((resolve) => {
|
|
165
|
+
setTimeout(resolve, 500);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
cliLogger.debug('Lock port check failed:', error instanceof Error ? error.message : String(error));
|
|
171
|
+
cliLogger.debug('No other disunday instance detected on lock port');
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
async function startLockServer() {
|
|
175
|
+
const lockPort = getLockPort();
|
|
176
|
+
return new Promise((resolve, reject) => {
|
|
177
|
+
const server = http.createServer((req, res) => {
|
|
178
|
+
res.writeHead(200);
|
|
179
|
+
res.end('disunday');
|
|
180
|
+
});
|
|
181
|
+
server.listen(lockPort, '127.0.0.1');
|
|
182
|
+
server.once('listening', () => {
|
|
183
|
+
cliLogger.debug(`Lock server started on port ${lockPort}`);
|
|
184
|
+
resolve();
|
|
185
|
+
});
|
|
186
|
+
server.on('error', async (err) => {
|
|
187
|
+
if (err.code === 'EADDRINUSE') {
|
|
188
|
+
cliLogger.log('Port still in use, retrying...');
|
|
189
|
+
await killProcessOnPort(lockPort);
|
|
190
|
+
await new Promise((r) => {
|
|
191
|
+
setTimeout(r, 500);
|
|
192
|
+
});
|
|
193
|
+
// Retry once
|
|
194
|
+
server.listen(lockPort, '127.0.0.1');
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
reject(err);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
const EXIT_NO_RESTART = 64;
|
|
203
|
+
// Commands to skip when registering user commands (reserved names)
|
|
204
|
+
const SKIP_USER_COMMANDS = ['init'];
|
|
205
|
+
async function registerCommands({ token, appId, userCommands = [], agents = [], }) {
|
|
206
|
+
const commands = [
|
|
207
|
+
new SlashCommandBuilder()
|
|
208
|
+
.setName('resume')
|
|
209
|
+
.setDescription('Resume an existing OpenCode session')
|
|
210
|
+
.addStringOption((option) => {
|
|
211
|
+
option
|
|
212
|
+
.setName('session')
|
|
213
|
+
.setDescription('Session ID (paste directly or select from list)')
|
|
214
|
+
.setRequired(true)
|
|
215
|
+
.setAutocomplete(true);
|
|
216
|
+
return option;
|
|
217
|
+
})
|
|
218
|
+
.toJSON(),
|
|
219
|
+
new SlashCommandBuilder()
|
|
220
|
+
.setName('new-session')
|
|
221
|
+
.setDescription('Start a new OpenCode session')
|
|
222
|
+
.addStringOption((option) => {
|
|
223
|
+
option
|
|
224
|
+
.setName('prompt')
|
|
225
|
+
.setDescription('Prompt content for the session')
|
|
226
|
+
.setRequired(true);
|
|
227
|
+
return option;
|
|
228
|
+
})
|
|
229
|
+
.addStringOption((option) => {
|
|
230
|
+
option
|
|
231
|
+
.setName('files')
|
|
232
|
+
.setDescription('Files to mention (comma or space separated; autocomplete)')
|
|
233
|
+
.setAutocomplete(true)
|
|
234
|
+
.setMaxLength(6000);
|
|
235
|
+
return option;
|
|
236
|
+
})
|
|
237
|
+
.addStringOption((option) => {
|
|
238
|
+
option
|
|
239
|
+
.setName('agent')
|
|
240
|
+
.setDescription('Agent to use for this session')
|
|
241
|
+
.setAutocomplete(true);
|
|
242
|
+
return option;
|
|
243
|
+
})
|
|
244
|
+
.toJSON(),
|
|
245
|
+
new SlashCommandBuilder()
|
|
246
|
+
.setName('new-worktree')
|
|
247
|
+
.setDescription('Create a new git worktree (in thread: uses thread name if no name given)')
|
|
248
|
+
.addStringOption((option) => {
|
|
249
|
+
option
|
|
250
|
+
.setName('name')
|
|
251
|
+
.setDescription('Name for worktree (optional in threads - uses thread name)')
|
|
252
|
+
.setRequired(false);
|
|
253
|
+
return option;
|
|
254
|
+
})
|
|
255
|
+
.toJSON(),
|
|
256
|
+
new SlashCommandBuilder()
|
|
257
|
+
.setName('merge-worktree')
|
|
258
|
+
.setDescription('Merge the worktree branch into the default branch')
|
|
259
|
+
.toJSON(),
|
|
260
|
+
new SlashCommandBuilder()
|
|
261
|
+
.setName('toggle-worktrees')
|
|
262
|
+
.setDescription('Toggle automatic git worktree creation for new sessions in this channel')
|
|
263
|
+
.toJSON(),
|
|
264
|
+
new SlashCommandBuilder()
|
|
265
|
+
.setName('add-project')
|
|
266
|
+
.setDescription('Create Discord channels for a project. Use `npx disunday add-project` for unlisted projects')
|
|
267
|
+
.addStringOption((option) => {
|
|
268
|
+
option
|
|
269
|
+
.setName('project')
|
|
270
|
+
.setDescription('Select a project. Use `npx disunday add-project` if not listed')
|
|
271
|
+
.setRequired(true)
|
|
272
|
+
.setAutocomplete(true);
|
|
273
|
+
return option;
|
|
274
|
+
})
|
|
275
|
+
.toJSON(),
|
|
276
|
+
new SlashCommandBuilder()
|
|
277
|
+
.setName('remove-project')
|
|
278
|
+
.setDescription('Remove Discord channels for a project')
|
|
279
|
+
.addStringOption((option) => {
|
|
280
|
+
option
|
|
281
|
+
.setName('project')
|
|
282
|
+
.setDescription('Select a project to remove')
|
|
283
|
+
.setRequired(true)
|
|
284
|
+
.setAutocomplete(true);
|
|
285
|
+
return option;
|
|
286
|
+
})
|
|
287
|
+
.toJSON(),
|
|
288
|
+
new SlashCommandBuilder()
|
|
289
|
+
.setName('create-new-project')
|
|
290
|
+
.setDescription('Create a new project folder, initialize git, and start a session')
|
|
291
|
+
.addStringOption((option) => {
|
|
292
|
+
option
|
|
293
|
+
.setName('name')
|
|
294
|
+
.setDescription('Name for the new project folder')
|
|
295
|
+
.setRequired(true);
|
|
296
|
+
return option;
|
|
297
|
+
})
|
|
298
|
+
.toJSON(),
|
|
299
|
+
new SlashCommandBuilder()
|
|
300
|
+
.setName('abort')
|
|
301
|
+
.setDescription('Abort the current OpenCode request in this thread')
|
|
302
|
+
.toJSON(),
|
|
303
|
+
new SlashCommandBuilder()
|
|
304
|
+
.setName('compact')
|
|
305
|
+
.setDescription('Compact the session context by summarizing conversation history')
|
|
306
|
+
.toJSON(),
|
|
307
|
+
new SlashCommandBuilder()
|
|
308
|
+
.setName('stop')
|
|
309
|
+
.setDescription('Abort the current OpenCode request in this thread')
|
|
310
|
+
.toJSON(),
|
|
311
|
+
new SlashCommandBuilder()
|
|
312
|
+
.setName('share')
|
|
313
|
+
.setDescription('Share the current session as a public URL')
|
|
314
|
+
.toJSON(),
|
|
315
|
+
new SlashCommandBuilder()
|
|
316
|
+
.setName('rename')
|
|
317
|
+
.setDescription('Rename the current session (also renames thread)')
|
|
318
|
+
.addStringOption((option) => {
|
|
319
|
+
return option
|
|
320
|
+
.setName('title')
|
|
321
|
+
.setDescription('New session title')
|
|
322
|
+
.setRequired(true);
|
|
323
|
+
})
|
|
324
|
+
.toJSON(),
|
|
325
|
+
new SlashCommandBuilder()
|
|
326
|
+
.setName('session-info')
|
|
327
|
+
.setDescription('Show session ID and terminal command to continue in terminal')
|
|
328
|
+
.toJSON(),
|
|
329
|
+
new SlashCommandBuilder()
|
|
330
|
+
.setName('sync')
|
|
331
|
+
.setDescription('Sync recent terminal activity to this Discord thread')
|
|
332
|
+
.toJSON(),
|
|
333
|
+
new SlashCommandBuilder()
|
|
334
|
+
.setName('fork')
|
|
335
|
+
.setDescription('Fork the session from a past user message')
|
|
336
|
+
.toJSON(),
|
|
337
|
+
new SlashCommandBuilder()
|
|
338
|
+
.setName('model')
|
|
339
|
+
.setDescription('Set the preferred model for this channel or session')
|
|
340
|
+
.toJSON(),
|
|
341
|
+
new SlashCommandBuilder()
|
|
342
|
+
.setName('login')
|
|
343
|
+
.setDescription('Authenticate with an AI provider (OAuth or API key). Use this instead of /connect')
|
|
344
|
+
.toJSON(),
|
|
345
|
+
new SlashCommandBuilder()
|
|
346
|
+
.setName('agent')
|
|
347
|
+
.setDescription('Set the preferred agent for this channel or session')
|
|
348
|
+
.toJSON(),
|
|
349
|
+
new SlashCommandBuilder()
|
|
350
|
+
.setName('queue')
|
|
351
|
+
.setDescription('Queue a message to be sent after the current response finishes')
|
|
352
|
+
.addStringOption((option) => {
|
|
353
|
+
option
|
|
354
|
+
.setName('message')
|
|
355
|
+
.setDescription('The message to queue')
|
|
356
|
+
.setRequired(true);
|
|
357
|
+
return option;
|
|
358
|
+
})
|
|
359
|
+
.toJSON(),
|
|
360
|
+
new SlashCommandBuilder()
|
|
361
|
+
.setName('clear-queue')
|
|
362
|
+
.setDescription('Clear all queued messages in this thread')
|
|
363
|
+
.toJSON(),
|
|
364
|
+
new SlashCommandBuilder()
|
|
365
|
+
.setName('undo')
|
|
366
|
+
.setDescription('Undo the last assistant message (revert file changes)')
|
|
367
|
+
.toJSON(),
|
|
368
|
+
new SlashCommandBuilder()
|
|
369
|
+
.setName('redo')
|
|
370
|
+
.setDescription('Redo previously undone changes')
|
|
371
|
+
.toJSON(),
|
|
372
|
+
new SlashCommandBuilder()
|
|
373
|
+
.setName('verbosity')
|
|
374
|
+
.setDescription('Set output verbosity for new sessions in this channel')
|
|
375
|
+
.addStringOption((option) => {
|
|
376
|
+
option
|
|
377
|
+
.setName('level')
|
|
378
|
+
.setDescription('Verbosity level')
|
|
379
|
+
.setRequired(true)
|
|
380
|
+
.addChoices({ name: 'tools-and-text (default)', value: 'tools-and-text' }, {
|
|
381
|
+
name: 'text-and-essential-tools',
|
|
382
|
+
value: 'text-and-essential-tools',
|
|
383
|
+
}, { name: 'text-only', value: 'text-only' });
|
|
384
|
+
return option;
|
|
385
|
+
})
|
|
386
|
+
.toJSON(),
|
|
387
|
+
new SlashCommandBuilder()
|
|
388
|
+
.setName('theme')
|
|
389
|
+
.setDescription('Set message formatting theme for this channel')
|
|
390
|
+
.addStringOption((option) => {
|
|
391
|
+
option
|
|
392
|
+
.setName('style')
|
|
393
|
+
.setDescription('Theme style')
|
|
394
|
+
.setRequired(true)
|
|
395
|
+
.addChoices({ name: 'default', value: 'default' }, { name: 'minimal', value: 'minimal' }, { name: 'detailed', value: 'detailed' }, { name: 'plain', value: 'plain' });
|
|
396
|
+
return option;
|
|
397
|
+
})
|
|
398
|
+
.toJSON(),
|
|
399
|
+
new SlashCommandBuilder()
|
|
400
|
+
.setName('restart-opencode-server')
|
|
401
|
+
.setDescription('Restart the opencode server for this channel only (fixes state/auth/plugins)')
|
|
402
|
+
.toJSON(),
|
|
403
|
+
new SlashCommandBuilder()
|
|
404
|
+
.setName('run')
|
|
405
|
+
.setDescription('Execute a terminal command')
|
|
406
|
+
.addStringOption((option) => {
|
|
407
|
+
return option
|
|
408
|
+
.setName('command')
|
|
409
|
+
.setDescription('The command to execute')
|
|
410
|
+
.setRequired(true)
|
|
411
|
+
.setAutocomplete(true);
|
|
412
|
+
})
|
|
413
|
+
.addBooleanOption((option) => {
|
|
414
|
+
return option
|
|
415
|
+
.setName('background')
|
|
416
|
+
.setDescription('Run in background and notify when complete')
|
|
417
|
+
.setRequired(false);
|
|
418
|
+
})
|
|
419
|
+
.addIntegerOption((option) => {
|
|
420
|
+
return option
|
|
421
|
+
.setName('timeout')
|
|
422
|
+
.setDescription('Timeout in seconds (default: 30, max: 300)')
|
|
423
|
+
.setRequired(false)
|
|
424
|
+
.setMinValue(1)
|
|
425
|
+
.setMaxValue(300);
|
|
426
|
+
})
|
|
427
|
+
.addStringOption((option) => {
|
|
428
|
+
return option
|
|
429
|
+
.setName('directory')
|
|
430
|
+
.setDescription('Subdirectory to run command in')
|
|
431
|
+
.setRequired(false);
|
|
432
|
+
})
|
|
433
|
+
.toJSON(),
|
|
434
|
+
new SlashCommandBuilder()
|
|
435
|
+
.setName('run-config')
|
|
436
|
+
.setDescription('Configure /run notification settings')
|
|
437
|
+
.addSubcommand((sub) => {
|
|
438
|
+
return sub
|
|
439
|
+
.setName('show')
|
|
440
|
+
.setDescription('Show current notification settings');
|
|
441
|
+
})
|
|
442
|
+
.addSubcommand((sub) => {
|
|
443
|
+
return sub
|
|
444
|
+
.setName('discord')
|
|
445
|
+
.setDescription('Toggle Discord notifications')
|
|
446
|
+
.addBooleanOption((opt) => {
|
|
447
|
+
return opt
|
|
448
|
+
.setName('enabled')
|
|
449
|
+
.setDescription('Enable Discord notifications')
|
|
450
|
+
.setRequired(true);
|
|
451
|
+
});
|
|
452
|
+
})
|
|
453
|
+
.addSubcommand((sub) => {
|
|
454
|
+
return sub
|
|
455
|
+
.setName('system')
|
|
456
|
+
.setDescription('Toggle system notifications')
|
|
457
|
+
.addBooleanOption((opt) => {
|
|
458
|
+
return opt
|
|
459
|
+
.setName('enabled')
|
|
460
|
+
.setDescription('Enable system notifications')
|
|
461
|
+
.setRequired(true);
|
|
462
|
+
});
|
|
463
|
+
})
|
|
464
|
+
.addSubcommand((sub) => {
|
|
465
|
+
return sub
|
|
466
|
+
.setName('webhook')
|
|
467
|
+
.setDescription('Set webhook URL for notifications')
|
|
468
|
+
.addStringOption((opt) => {
|
|
469
|
+
return opt
|
|
470
|
+
.setName('url')
|
|
471
|
+
.setDescription('Webhook URL (leave empty to clear)')
|
|
472
|
+
.setRequired(false);
|
|
473
|
+
});
|
|
474
|
+
})
|
|
475
|
+
.toJSON(),
|
|
476
|
+
new SlashCommandBuilder()
|
|
477
|
+
.setName('status')
|
|
478
|
+
.setDescription('Check bot and session status')
|
|
479
|
+
.toJSON(),
|
|
480
|
+
new SlashCommandBuilder()
|
|
481
|
+
.setName('help')
|
|
482
|
+
.setDescription('Show available commands')
|
|
483
|
+
.toJSON(),
|
|
484
|
+
new SlashCommandBuilder()
|
|
485
|
+
.setName('ping')
|
|
486
|
+
.setDescription('Check connection latency')
|
|
487
|
+
.toJSON(),
|
|
488
|
+
new SlashCommandBuilder()
|
|
489
|
+
.setName('context')
|
|
490
|
+
.setDescription('Show context window usage for current session')
|
|
491
|
+
.toJSON(),
|
|
492
|
+
new SlashCommandBuilder()
|
|
493
|
+
.setName('cost')
|
|
494
|
+
.setDescription('Show estimated API cost for current session')
|
|
495
|
+
.toJSON(),
|
|
496
|
+
new SlashCommandBuilder()
|
|
497
|
+
.setName('diff')
|
|
498
|
+
.setDescription('Show recent file changes in project')
|
|
499
|
+
.toJSON(),
|
|
500
|
+
new SlashCommandBuilder()
|
|
501
|
+
.setName('export')
|
|
502
|
+
.setDescription('Export session to markdown file')
|
|
503
|
+
.toJSON(),
|
|
504
|
+
new SlashCommandBuilder()
|
|
505
|
+
.setName('files')
|
|
506
|
+
.setDescription('List project files')
|
|
507
|
+
.toJSON(),
|
|
508
|
+
new SlashCommandBuilder()
|
|
509
|
+
.setName('settings')
|
|
510
|
+
.setDescription('Configure bot settings')
|
|
511
|
+
.addSubcommand((sub) => {
|
|
512
|
+
return sub
|
|
513
|
+
.setName('hub-channel')
|
|
514
|
+
.setDescription('Set the central notification channel for session completions')
|
|
515
|
+
.addChannelOption((opt) => {
|
|
516
|
+
return opt
|
|
517
|
+
.setName('channel')
|
|
518
|
+
.setDescription('The channel to receive notifications')
|
|
519
|
+
.setRequired(false);
|
|
520
|
+
})
|
|
521
|
+
.addBooleanOption((opt) => {
|
|
522
|
+
return opt
|
|
523
|
+
.setName('clear')
|
|
524
|
+
.setDescription('Clear the hub channel setting')
|
|
525
|
+
.setRequired(false);
|
|
526
|
+
});
|
|
527
|
+
})
|
|
528
|
+
.addSubcommand((sub) => {
|
|
529
|
+
return sub
|
|
530
|
+
.setName('view')
|
|
531
|
+
.setDescription('View current bot settings');
|
|
532
|
+
})
|
|
533
|
+
.toJSON(),
|
|
534
|
+
new SlashCommandBuilder()
|
|
535
|
+
.setName('schedule')
|
|
536
|
+
.setDescription('Schedule a message to run later')
|
|
537
|
+
.addSubcommand((sub) => {
|
|
538
|
+
return sub
|
|
539
|
+
.setName('add')
|
|
540
|
+
.setDescription('Schedule a new message')
|
|
541
|
+
.addStringOption((opt) => {
|
|
542
|
+
return opt
|
|
543
|
+
.setName('prompt')
|
|
544
|
+
.setDescription('The message/prompt to schedule')
|
|
545
|
+
.setRequired(true);
|
|
546
|
+
})
|
|
547
|
+
.addStringOption((opt) => {
|
|
548
|
+
return opt
|
|
549
|
+
.setName('time')
|
|
550
|
+
.setDescription('When to run (e.g., 30m, 2h, 3:00pm)')
|
|
551
|
+
.setRequired(true);
|
|
552
|
+
});
|
|
553
|
+
})
|
|
554
|
+
.addSubcommand((sub) => {
|
|
555
|
+
return sub
|
|
556
|
+
.setName('list')
|
|
557
|
+
.setDescription('List pending schedules in this channel');
|
|
558
|
+
})
|
|
559
|
+
.addSubcommand((sub) => {
|
|
560
|
+
return sub
|
|
561
|
+
.setName('cancel')
|
|
562
|
+
.setDescription('Cancel a scheduled message')
|
|
563
|
+
.addIntegerOption((opt) => {
|
|
564
|
+
return opt
|
|
565
|
+
.setName('id')
|
|
566
|
+
.setDescription('Schedule ID to cancel')
|
|
567
|
+
.setRequired(true);
|
|
568
|
+
});
|
|
569
|
+
})
|
|
570
|
+
.toJSON(),
|
|
571
|
+
];
|
|
572
|
+
// Add user-defined commands with -cmd suffix
|
|
573
|
+
for (const cmd of userCommands) {
|
|
574
|
+
if (SKIP_USER_COMMANDS.includes(cmd.name)) {
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
// Sanitize command name: oh-my-opencode uses MCP commands with colons, which Discord doesn't allow
|
|
578
|
+
const sanitizedName = cmd.name.replace(/:/g, '-');
|
|
579
|
+
const commandName = `${sanitizedName}-cmd`;
|
|
580
|
+
const description = cmd.description || `Run /${cmd.name} command`;
|
|
581
|
+
commands.push(new SlashCommandBuilder()
|
|
582
|
+
.setName(commandName.slice(0, 32)) // Discord limits to 32 chars
|
|
583
|
+
.setDescription(description.slice(0, 100)) // Discord limits to 100 chars
|
|
584
|
+
.addStringOption((option) => {
|
|
585
|
+
option
|
|
586
|
+
.setName('arguments')
|
|
587
|
+
.setDescription('Arguments to pass to the command')
|
|
588
|
+
.setRequired(false);
|
|
589
|
+
return option;
|
|
590
|
+
})
|
|
591
|
+
.toJSON());
|
|
592
|
+
}
|
|
593
|
+
// Add agent-specific quick commands like /plan-agent, /build-agent
|
|
594
|
+
// Filter to primary/all mode agents (same as /agent command shows), excluding hidden agents
|
|
595
|
+
const primaryAgents = agents.filter((a) => (a.mode === 'primary' || a.mode === 'all') && !a.hidden);
|
|
596
|
+
for (const agent of primaryAgents) {
|
|
597
|
+
const sanitizedName = sanitizeAgentName(agent.name);
|
|
598
|
+
const commandName = `${sanitizedName}-agent`;
|
|
599
|
+
const description = agent.description || `Switch to ${agent.name} agent`;
|
|
600
|
+
commands.push(new SlashCommandBuilder()
|
|
601
|
+
.setName(commandName.slice(0, 32)) // Discord limits to 32 chars
|
|
602
|
+
.setDescription(description.slice(0, 100))
|
|
603
|
+
.toJSON());
|
|
604
|
+
}
|
|
605
|
+
// Context menu commands (right-click on message)
|
|
606
|
+
commands.push(new ContextMenuCommandBuilder()
|
|
607
|
+
.setName('Retry this prompt')
|
|
608
|
+
.setType(ApplicationCommandType.Message)
|
|
609
|
+
.toJSON(), new ContextMenuCommandBuilder()
|
|
610
|
+
.setName('Fork from here')
|
|
611
|
+
.setType(ApplicationCommandType.Message)
|
|
612
|
+
.toJSON());
|
|
613
|
+
const rest = new REST().setToken(token);
|
|
614
|
+
try {
|
|
615
|
+
const data = (await rest.put(Routes.applicationCommands(appId), {
|
|
616
|
+
body: commands,
|
|
617
|
+
}));
|
|
618
|
+
cliLogger.info(`COMMANDS: Successfully registered ${data.length} slash commands`);
|
|
619
|
+
}
|
|
620
|
+
catch (error) {
|
|
621
|
+
cliLogger.error('COMMANDS: Failed to register slash commands: ' + String(error));
|
|
622
|
+
throw error;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Store channel-directory mappings in the database.
|
|
627
|
+
* Called after Discord login to persist channel configurations.
|
|
628
|
+
*/
|
|
629
|
+
function storeChannelDirectories({ disundayChannels, db, }) {
|
|
630
|
+
for (const { guild, channels } of disundayChannels) {
|
|
631
|
+
for (const channel of channels) {
|
|
632
|
+
if (channel.disundayDirectory) {
|
|
633
|
+
db.prepare('INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)').run(channel.id, channel.disundayDirectory, 'text', channel.disundayApp || null);
|
|
634
|
+
const voiceChannel = guild.channels.cache.find((ch) => ch.type === ChannelType.GuildVoice && ch.name === channel.name);
|
|
635
|
+
if (voiceChannel) {
|
|
636
|
+
db.prepare('INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)').run(voiceChannel.id, channel.disundayDirectory, 'voice', channel.disundayApp || null);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Show the ready message with channel links.
|
|
644
|
+
* Called at the end of startup to display available channels.
|
|
645
|
+
*/
|
|
646
|
+
function showReadyMessage({ disundayChannels, createdChannels, appId, }) {
|
|
647
|
+
const allChannels = [];
|
|
648
|
+
allChannels.push(...createdChannels);
|
|
649
|
+
disundayChannels.forEach(({ guild, channels }) => {
|
|
650
|
+
channels.forEach((ch) => {
|
|
651
|
+
allChannels.push({
|
|
652
|
+
name: ch.name,
|
|
653
|
+
id: ch.id,
|
|
654
|
+
guildId: guild.id,
|
|
655
|
+
directory: ch.disundayDirectory,
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
});
|
|
659
|
+
if (allChannels.length > 0) {
|
|
660
|
+
const channelLinks = allChannels
|
|
661
|
+
.map((ch) => `⢠#${ch.name}: https://discord.com/channels/${ch.guildId}/${ch.id}`)
|
|
662
|
+
.join('\n');
|
|
663
|
+
note(`Your disunday channels are ready! Click any link below to open in Discord:\n\n${channelLinks}\n\nSend a message in any channel to start using OpenCode!`, 'š Ready to Use');
|
|
664
|
+
}
|
|
665
|
+
note('Leave this process running to keep the bot active.\n\nIf you close this process or restart your machine, run `npx disunday` again to start the bot.', 'ā ļø Keep Running');
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Background initialization for quick start mode.
|
|
669
|
+
* Starts OpenCode server and registers slash commands without blocking bot startup.
|
|
670
|
+
*/
|
|
671
|
+
async function backgroundInit({ currentDir, token, appId, }) {
|
|
672
|
+
try {
|
|
673
|
+
const opencodeResult = await initializeOpencodeForDirectory(currentDir);
|
|
674
|
+
if (opencodeResult instanceof Error) {
|
|
675
|
+
cliLogger.warn('Background OpenCode init failed:', opencodeResult.message);
|
|
676
|
+
// Still try to register basic commands without user commands/agents
|
|
677
|
+
await registerCommands({ token, appId, userCommands: [], agents: [] });
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
const getClient = opencodeResult;
|
|
681
|
+
const [userCommands, agents] = await Promise.all([
|
|
682
|
+
getClient()
|
|
683
|
+
.command.list({ query: { directory: currentDir } })
|
|
684
|
+
.then((r) => r.data || [])
|
|
685
|
+
.catch((error) => {
|
|
686
|
+
cliLogger.warn('Failed to load user commands during background init:', error instanceof Error ? error.message : String(error));
|
|
687
|
+
return [];
|
|
688
|
+
}),
|
|
689
|
+
getClient()
|
|
690
|
+
.app.agents({ query: { directory: currentDir } })
|
|
691
|
+
.then((r) => r.data || [])
|
|
692
|
+
.catch((error) => {
|
|
693
|
+
cliLogger.warn('Failed to load agents during background init:', error instanceof Error ? error.message : String(error));
|
|
694
|
+
return [];
|
|
695
|
+
}),
|
|
696
|
+
]);
|
|
697
|
+
await registerCommands({ token, appId, userCommands, agents });
|
|
698
|
+
cliLogger.log('Slash commands registered!');
|
|
699
|
+
}
|
|
700
|
+
catch (error) {
|
|
701
|
+
cliLogger.error('Background init failed:', error instanceof Error ? error.message : String(error));
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
async function run({ restart, addChannels, useWorktrees, enableVoiceChannels, }) {
|
|
705
|
+
startCaffeinate();
|
|
706
|
+
const forceSetup = Boolean(restart);
|
|
707
|
+
intro('š¤ Discord Bot Setup');
|
|
708
|
+
// Step 0: Check if OpenCode CLI is available
|
|
709
|
+
const opencodeCheck = spawnSync('which', ['opencode'], { shell: true });
|
|
710
|
+
if (opencodeCheck.status !== 0) {
|
|
711
|
+
note('OpenCode CLI is required but not found in your PATH.', 'ā ļø OpenCode Not Found');
|
|
712
|
+
const shouldInstall = await confirm({
|
|
713
|
+
message: 'Would you like to install OpenCode right now?',
|
|
714
|
+
});
|
|
715
|
+
if (isCancel(shouldInstall) || !shouldInstall) {
|
|
716
|
+
cancel('OpenCode CLI is required to run this bot');
|
|
717
|
+
process.exit(0);
|
|
718
|
+
}
|
|
719
|
+
const s = spinner();
|
|
720
|
+
s.start('Installing OpenCode CLI...');
|
|
721
|
+
try {
|
|
722
|
+
execSync('curl -fsSL https://opencode.ai/install | bash', {
|
|
723
|
+
stdio: 'inherit',
|
|
724
|
+
shell: '/bin/bash',
|
|
725
|
+
});
|
|
726
|
+
s.stop('OpenCode CLI installed successfully!');
|
|
727
|
+
// The install script adds opencode to PATH via shell configuration
|
|
728
|
+
// For the current process, we need to check common installation paths
|
|
729
|
+
const possiblePaths = [
|
|
730
|
+
`${process.env.HOME}/.local/bin/opencode`,
|
|
731
|
+
`${process.env.HOME}/.opencode/bin/opencode`,
|
|
732
|
+
'/usr/local/bin/opencode',
|
|
733
|
+
'/opt/opencode/bin/opencode',
|
|
734
|
+
];
|
|
735
|
+
const installedPath = possiblePaths.find((p) => {
|
|
736
|
+
try {
|
|
737
|
+
fs.accessSync(p, fs.constants.F_OK);
|
|
738
|
+
return true;
|
|
739
|
+
}
|
|
740
|
+
catch (error) {
|
|
741
|
+
cliLogger.debug(`OpenCode path not found at ${p}:`, error instanceof Error ? error.message : String(error));
|
|
742
|
+
return false;
|
|
743
|
+
}
|
|
744
|
+
});
|
|
745
|
+
if (!installedPath) {
|
|
746
|
+
note('OpenCode was installed but may not be available in this session.\n' +
|
|
747
|
+
'Please restart your terminal and run this command again.', 'ā ļø Restart Required');
|
|
748
|
+
process.exit(0);
|
|
749
|
+
}
|
|
750
|
+
// For subsequent spawn calls in this session, we can use the full path
|
|
751
|
+
process.env.OPENCODE_PATH = installedPath;
|
|
752
|
+
}
|
|
753
|
+
catch (error) {
|
|
754
|
+
s.stop('Failed to install OpenCode CLI');
|
|
755
|
+
cliLogger.error('Installation error:', error instanceof Error ? error.message : String(error));
|
|
756
|
+
process.exit(EXIT_NO_RESTART);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
const db = getDatabase();
|
|
760
|
+
let appId;
|
|
761
|
+
let token;
|
|
762
|
+
const existingBot = db
|
|
763
|
+
.prepare('SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
|
|
764
|
+
.get();
|
|
765
|
+
const shouldAddChannels = !existingBot?.token || forceSetup || Boolean(addChannels);
|
|
766
|
+
if (existingBot && !forceSetup) {
|
|
767
|
+
appId = existingBot.app_id;
|
|
768
|
+
token = existingBot.token;
|
|
769
|
+
note(`Using saved bot credentials:\nApp ID: ${appId}\n\nTo use different credentials, run with --restart`, 'Existing Bot Found');
|
|
770
|
+
note(`Bot install URL (in case you need to add it to another server):\n${generateBotInstallUrl({ clientId: appId })}`, 'Install URL');
|
|
771
|
+
}
|
|
772
|
+
else {
|
|
773
|
+
if (forceSetup && existingBot) {
|
|
774
|
+
note('Ignoring saved credentials due to --restart flag', 'Restart Setup');
|
|
775
|
+
}
|
|
776
|
+
note('1. Go to https://discord.com/developers/applications\n' +
|
|
777
|
+
'2. Click "New Application"\n' +
|
|
778
|
+
'3. Give your application a name\n' +
|
|
779
|
+
'4. Copy the Application ID from the "General Information" section', 'Step 1: Create Discord Application');
|
|
780
|
+
const appIdInput = await text({
|
|
781
|
+
message: 'Enter your Discord Application ID:',
|
|
782
|
+
placeholder: 'e.g., 1234567890123456789',
|
|
783
|
+
validate(value) {
|
|
784
|
+
const cleaned = stripBracketedPaste(value);
|
|
785
|
+
if (!cleaned) {
|
|
786
|
+
return 'Application ID is required';
|
|
787
|
+
}
|
|
788
|
+
if (!/^\d{17,20}$/.test(cleaned)) {
|
|
789
|
+
return 'Invalid Application ID format (should be 17-20 digits)';
|
|
790
|
+
}
|
|
791
|
+
},
|
|
792
|
+
});
|
|
793
|
+
if (isCancel(appIdInput)) {
|
|
794
|
+
cancel('Setup cancelled');
|
|
795
|
+
process.exit(0);
|
|
796
|
+
}
|
|
797
|
+
appId = stripBracketedPaste(appIdInput);
|
|
798
|
+
note('1. Go to the "Bot" section in the left sidebar\n' +
|
|
799
|
+
'2. Scroll down to "Privileged Gateway Intents"\n' +
|
|
800
|
+
'3. Enable these intents by toggling them ON:\n' +
|
|
801
|
+
' ⢠SERVER MEMBERS INTENT\n' +
|
|
802
|
+
' ⢠MESSAGE CONTENT INTENT\n' +
|
|
803
|
+
'4. Click "Save Changes" at the bottom', 'Step 2: Enable Required Intents');
|
|
804
|
+
const intentsConfirmed = await text({
|
|
805
|
+
message: 'Press Enter after enabling both intents:',
|
|
806
|
+
placeholder: 'Enter',
|
|
807
|
+
});
|
|
808
|
+
if (isCancel(intentsConfirmed)) {
|
|
809
|
+
cancel('Setup cancelled');
|
|
810
|
+
process.exit(0);
|
|
811
|
+
}
|
|
812
|
+
note('1. Still in the "Bot" section\n' +
|
|
813
|
+
'2. Click "Reset Token" to generate a new bot token (in case of errors try again)\n' +
|
|
814
|
+
"3. Copy the token (you won't be able to see it again!)", 'Step 3: Get Bot Token');
|
|
815
|
+
const tokenInput = await password({
|
|
816
|
+
message: 'Enter your Discord Bot Token (from "Bot" section - click "Reset Token" if needed):',
|
|
817
|
+
validate(value) {
|
|
818
|
+
const cleaned = stripBracketedPaste(value);
|
|
819
|
+
if (!cleaned) {
|
|
820
|
+
return 'Bot token is required';
|
|
821
|
+
}
|
|
822
|
+
if (cleaned.length < 50) {
|
|
823
|
+
return 'Invalid token format (too short)';
|
|
824
|
+
}
|
|
825
|
+
},
|
|
826
|
+
});
|
|
827
|
+
if (isCancel(tokenInput)) {
|
|
828
|
+
cancel('Setup cancelled');
|
|
829
|
+
process.exit(0);
|
|
830
|
+
}
|
|
831
|
+
token = stripBracketedPaste(tokenInput);
|
|
832
|
+
note(`You can get a Gemini api Key at https://aistudio.google.com/apikey`, `Gemini API Key`);
|
|
833
|
+
const geminiApiKeyInput = await password({
|
|
834
|
+
message: 'Enter your Gemini API Key for voice channels and audio transcription (optional, press Enter to skip):',
|
|
835
|
+
validate(value) {
|
|
836
|
+
const cleaned = stripBracketedPaste(value);
|
|
837
|
+
if (cleaned && cleaned.length < 10) {
|
|
838
|
+
return 'Invalid API key format';
|
|
839
|
+
}
|
|
840
|
+
return undefined;
|
|
841
|
+
},
|
|
842
|
+
});
|
|
843
|
+
if (isCancel(geminiApiKeyInput)) {
|
|
844
|
+
cancel('Setup cancelled');
|
|
845
|
+
process.exit(0);
|
|
846
|
+
}
|
|
847
|
+
const geminiApiKey = stripBracketedPaste(geminiApiKeyInput) || null;
|
|
848
|
+
// Store API key in database
|
|
849
|
+
if (geminiApiKey) {
|
|
850
|
+
db.prepare('INSERT OR REPLACE INTO bot_api_keys (app_id, gemini_api_key) VALUES (?, ?)').run(appId, geminiApiKey);
|
|
851
|
+
note('API key saved successfully', 'API Key Stored');
|
|
852
|
+
}
|
|
853
|
+
note(`Bot install URL:\n${generateBotInstallUrl({ clientId: appId })}\n\nYou MUST install the bot in your Discord server before continuing.`, 'Step 4: Install Bot to Server');
|
|
854
|
+
const installed = await text({
|
|
855
|
+
message: 'Press Enter AFTER you have installed the bot in your server:',
|
|
856
|
+
placeholder: 'Enter',
|
|
857
|
+
});
|
|
858
|
+
if (isCancel(installed)) {
|
|
859
|
+
cancel('Setup cancelled');
|
|
860
|
+
process.exit(0);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
const s = spinner();
|
|
864
|
+
// Start OpenCode server EARLY - let it initialize in parallel with Discord login.
|
|
865
|
+
// This is the biggest startup bottleneck (can take 1-30 seconds to spawn and wait for ready)
|
|
866
|
+
const currentDir = process.cwd();
|
|
867
|
+
s.start('Starting OpenCode server...');
|
|
868
|
+
const opencodePromise = initializeOpencodeForDirectory(currentDir).then((result) => {
|
|
869
|
+
if (result instanceof Error) {
|
|
870
|
+
throw new Error(result.message);
|
|
871
|
+
}
|
|
872
|
+
return result;
|
|
873
|
+
});
|
|
874
|
+
s.message('Connecting to Discord...');
|
|
875
|
+
const discordClient = await createDiscordClient();
|
|
876
|
+
const guilds = [];
|
|
877
|
+
const disundayChannels = [];
|
|
878
|
+
const createdChannels = [];
|
|
879
|
+
try {
|
|
880
|
+
await new Promise((resolve, reject) => {
|
|
881
|
+
discordClient.once(Events.ClientReady, async (c) => {
|
|
882
|
+
guilds.push(...Array.from(c.guilds.cache.values()));
|
|
883
|
+
// Process all guilds in parallel for faster startup
|
|
884
|
+
const guildResults = await Promise.all(guilds.map(async (guild) => {
|
|
885
|
+
// Create Kimaki role if it doesn't exist, or fix its position (fire-and-forget)
|
|
886
|
+
guild.roles
|
|
887
|
+
.fetch()
|
|
888
|
+
.then(async (roles) => {
|
|
889
|
+
const existingRole = roles.find((role) => role.name.toLowerCase() === 'disunday');
|
|
890
|
+
if (existingRole) {
|
|
891
|
+
// Move to bottom if not already there
|
|
892
|
+
if (existingRole.position > 1) {
|
|
893
|
+
await existingRole.setPosition(1);
|
|
894
|
+
cliLogger.info(`Moved "Disunday" role to bottom in ${guild.name}`);
|
|
895
|
+
}
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
return guild.roles.create({
|
|
899
|
+
name: 'Disunday',
|
|
900
|
+
position: 1, // Place at bottom so anyone with Manage Roles can assign it
|
|
901
|
+
reason: 'Disunday bot permission role - assign to users who can start sessions, send messages in threads, and use voice features',
|
|
902
|
+
});
|
|
903
|
+
})
|
|
904
|
+
.then((role) => {
|
|
905
|
+
if (role) {
|
|
906
|
+
cliLogger.info(`Created "Disunday" role in ${guild.name}`);
|
|
907
|
+
}
|
|
908
|
+
})
|
|
909
|
+
.catch((error) => {
|
|
910
|
+
cliLogger.warn(`Could not create Kimaki role in ${guild.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
911
|
+
});
|
|
912
|
+
const channels = await getChannelsWithDescriptions(guild);
|
|
913
|
+
const disundayChans = channels.filter((ch) => ch.disundayDirectory && (!ch.disundayApp || ch.disundayApp === appId));
|
|
914
|
+
return { guild, channels: disundayChans };
|
|
915
|
+
}));
|
|
916
|
+
// Collect results
|
|
917
|
+
for (const result of guildResults) {
|
|
918
|
+
if (result.channels.length > 0) {
|
|
919
|
+
disundayChannels.push(result);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
resolve(null);
|
|
923
|
+
});
|
|
924
|
+
discordClient.once(Events.Error, reject);
|
|
925
|
+
discordClient.login(token).catch(reject);
|
|
926
|
+
});
|
|
927
|
+
s.stop('Connected to Discord!');
|
|
928
|
+
}
|
|
929
|
+
catch (error) {
|
|
930
|
+
s.stop('Failed to connect to Discord');
|
|
931
|
+
cliLogger.error('Error: ' + (error instanceof Error ? error.message : String(error)));
|
|
932
|
+
process.exit(EXIT_NO_RESTART);
|
|
933
|
+
}
|
|
934
|
+
db.prepare('INSERT OR REPLACE INTO bot_tokens (app_id, token) VALUES (?, ?)').run(appId, token);
|
|
935
|
+
// Store channel-directory mappings
|
|
936
|
+
storeChannelDirectories({ disundayChannels, db });
|
|
937
|
+
if (disundayChannels.length > 0) {
|
|
938
|
+
const channelList = disundayChannels
|
|
939
|
+
.flatMap(({ guild, channels }) => channels.map((ch) => {
|
|
940
|
+
const appInfo = ch.disundayApp === appId
|
|
941
|
+
? ' (this bot)'
|
|
942
|
+
: ch.disundayApp
|
|
943
|
+
? ` (app: ${ch.disundayApp})`
|
|
944
|
+
: '';
|
|
945
|
+
return `#${ch.name} in ${guild.name}: ${ch.disundayDirectory}${appInfo}`;
|
|
946
|
+
}))
|
|
947
|
+
.join('\n');
|
|
948
|
+
note(channelList, 'Existing Kimaki Channels');
|
|
949
|
+
}
|
|
950
|
+
// Quick start: if setup is already done, start bot immediately and background the rest
|
|
951
|
+
const isQuickStart = existingBot && !forceSetup && !addChannels;
|
|
952
|
+
if (isQuickStart) {
|
|
953
|
+
s.start('Starting Discord bot...');
|
|
954
|
+
await startDiscordBot({ token, appId, discordClient, useWorktrees });
|
|
955
|
+
s.stop('Discord bot is running!');
|
|
956
|
+
// Background: OpenCode init + slash command registration (non-blocking)
|
|
957
|
+
void backgroundInit({ currentDir, token, appId });
|
|
958
|
+
showReadyMessage({ disundayChannels, createdChannels, appId });
|
|
959
|
+
outro('⨠Bot ready! Listening for messages...');
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
// Full setup path: wait for OpenCode, show prompts, create channels if needed
|
|
963
|
+
// Await the OpenCode server that was started in parallel with Discord login
|
|
964
|
+
s.start('Waiting for OpenCode server...');
|
|
965
|
+
const getClient = await opencodePromise;
|
|
966
|
+
s.stop('OpenCode server ready!');
|
|
967
|
+
s.start('Fetching OpenCode data...');
|
|
968
|
+
// Fetch projects, commands, and agents in parallel
|
|
969
|
+
const [projects, allUserCommands, allAgents] = await Promise.all([
|
|
970
|
+
getClient()
|
|
971
|
+
.project.list({})
|
|
972
|
+
.then((r) => r.data || [])
|
|
973
|
+
.catch((error) => {
|
|
974
|
+
s.stop('Failed to fetch projects');
|
|
975
|
+
cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
|
|
976
|
+
discordClient.destroy();
|
|
977
|
+
process.exit(EXIT_NO_RESTART);
|
|
978
|
+
}),
|
|
979
|
+
getClient()
|
|
980
|
+
.command.list({ query: { directory: currentDir } })
|
|
981
|
+
.then((r) => r.data || [])
|
|
982
|
+
.catch((error) => {
|
|
983
|
+
cliLogger.warn('Failed to load user commands during setup:', error instanceof Error ? error.message : String(error));
|
|
984
|
+
return [];
|
|
985
|
+
}),
|
|
986
|
+
getClient()
|
|
987
|
+
.app.agents({ query: { directory: currentDir } })
|
|
988
|
+
.then((r) => r.data || [])
|
|
989
|
+
.catch((error) => {
|
|
990
|
+
cliLogger.warn('Failed to load agents during setup:', error instanceof Error ? error.message : String(error));
|
|
991
|
+
return [];
|
|
992
|
+
}),
|
|
993
|
+
]);
|
|
994
|
+
s.stop(`Found ${projects.length} OpenCode project(s)`);
|
|
995
|
+
const existingDirs = disundayChannels.flatMap(({ channels }) => channels
|
|
996
|
+
.filter((ch) => ch.disundayDirectory && ch.disundayApp === appId)
|
|
997
|
+
.map((ch) => ch.disundayDirectory)
|
|
998
|
+
.filter(Boolean));
|
|
999
|
+
const availableProjects = deduplicateByKey(projects.filter((project) => {
|
|
1000
|
+
if (existingDirs.includes(project.worktree)) {
|
|
1001
|
+
return false;
|
|
1002
|
+
}
|
|
1003
|
+
if (path.basename(project.worktree).startsWith('opencode-test-')) {
|
|
1004
|
+
return false;
|
|
1005
|
+
}
|
|
1006
|
+
return true;
|
|
1007
|
+
}), (x) => x.worktree);
|
|
1008
|
+
if (availableProjects.length === 0) {
|
|
1009
|
+
note('All OpenCode projects already have Discord channels', 'No New Projects');
|
|
1010
|
+
}
|
|
1011
|
+
if ((!existingDirs?.length && availableProjects.length > 0) ||
|
|
1012
|
+
shouldAddChannels) {
|
|
1013
|
+
const selectedProjects = await multiselect({
|
|
1014
|
+
message: 'Select projects to create Discord channels for:',
|
|
1015
|
+
options: availableProjects.map((project) => ({
|
|
1016
|
+
value: project.id,
|
|
1017
|
+
label: `${path.basename(project.worktree)} (${abbreviatePath(project.worktree)})`,
|
|
1018
|
+
})),
|
|
1019
|
+
required: false,
|
|
1020
|
+
});
|
|
1021
|
+
if (!isCancel(selectedProjects) && selectedProjects.length > 0) {
|
|
1022
|
+
let targetGuild;
|
|
1023
|
+
if (guilds.length === 0) {
|
|
1024
|
+
cliLogger.error('No Discord servers found! The bot must be installed in at least one server.');
|
|
1025
|
+
process.exit(EXIT_NO_RESTART);
|
|
1026
|
+
}
|
|
1027
|
+
if (guilds.length === 1) {
|
|
1028
|
+
targetGuild = guilds[0];
|
|
1029
|
+
note(`Using server: ${targetGuild.name}`, 'Server Selected');
|
|
1030
|
+
}
|
|
1031
|
+
else {
|
|
1032
|
+
const guildSelection = await multiselect({
|
|
1033
|
+
message: 'Select a Discord server to create channels in:',
|
|
1034
|
+
options: guilds.map((guild) => ({
|
|
1035
|
+
value: guild.id,
|
|
1036
|
+
label: `${guild.name} (${guild.memberCount} members)`,
|
|
1037
|
+
})),
|
|
1038
|
+
required: true,
|
|
1039
|
+
maxItems: 1,
|
|
1040
|
+
});
|
|
1041
|
+
if (isCancel(guildSelection)) {
|
|
1042
|
+
cancel('Setup cancelled');
|
|
1043
|
+
process.exit(0);
|
|
1044
|
+
}
|
|
1045
|
+
targetGuild = guilds.find((g) => g.id === guildSelection[0]);
|
|
1046
|
+
}
|
|
1047
|
+
s.start('Creating Discord channels...');
|
|
1048
|
+
for (const projectId of selectedProjects) {
|
|
1049
|
+
const project = projects.find((p) => p.id === projectId);
|
|
1050
|
+
if (!project)
|
|
1051
|
+
continue;
|
|
1052
|
+
try {
|
|
1053
|
+
const { textChannelId, channelName } = await createProjectChannels({
|
|
1054
|
+
guild: targetGuild,
|
|
1055
|
+
projectDirectory: project.worktree,
|
|
1056
|
+
appId,
|
|
1057
|
+
botName: discordClient.user?.username,
|
|
1058
|
+
enableVoiceChannels,
|
|
1059
|
+
});
|
|
1060
|
+
createdChannels.push({
|
|
1061
|
+
name: channelName,
|
|
1062
|
+
id: textChannelId,
|
|
1063
|
+
guildId: targetGuild.id,
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
catch (error) {
|
|
1067
|
+
cliLogger.error(`Failed to create channels for ${path.basename(project.worktree)}:`, error);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
s.stop(`Created ${createdChannels.length} channel(s)`);
|
|
1071
|
+
if (createdChannels.length > 0) {
|
|
1072
|
+
note(createdChannels.map((ch) => `#${ch.name}`).join('\n'), 'Created Channels');
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
// Log available user commands
|
|
1077
|
+
const registrableCommands = allUserCommands.filter((cmd) => !SKIP_USER_COMMANDS.includes(cmd.name));
|
|
1078
|
+
if (registrableCommands.length > 0) {
|
|
1079
|
+
const commandList = registrableCommands
|
|
1080
|
+
.map((cmd) => ` /${cmd.name}-cmd - ${cmd.description || 'No description'}`)
|
|
1081
|
+
.join('\n');
|
|
1082
|
+
note(`Found ${registrableCommands.length} user-defined command(s):\n${commandList}`, 'OpenCode Commands');
|
|
1083
|
+
}
|
|
1084
|
+
cliLogger.log('Registering slash commands asynchronously...');
|
|
1085
|
+
void registerCommands({
|
|
1086
|
+
token,
|
|
1087
|
+
appId,
|
|
1088
|
+
userCommands: allUserCommands,
|
|
1089
|
+
agents: allAgents,
|
|
1090
|
+
})
|
|
1091
|
+
.then(() => {
|
|
1092
|
+
cliLogger.log('Slash commands registered!');
|
|
1093
|
+
})
|
|
1094
|
+
.catch((error) => {
|
|
1095
|
+
cliLogger.error('Failed to register slash commands:', error instanceof Error ? error.message : String(error));
|
|
1096
|
+
});
|
|
1097
|
+
s.start('Starting Discord bot...');
|
|
1098
|
+
await startDiscordBot({ token, appId, discordClient, useWorktrees });
|
|
1099
|
+
s.stop('Discord bot is running!');
|
|
1100
|
+
showReadyMessage({ disundayChannels, createdChannels, appId });
|
|
1101
|
+
outro('⨠Setup complete! Listening for new messages... do not close this process.');
|
|
1102
|
+
}
|
|
1103
|
+
cli
|
|
1104
|
+
.command('', 'Set up and run the Disunday Discord bot')
|
|
1105
|
+
.option('--restart', 'Prompt for new credentials even if saved')
|
|
1106
|
+
.option('--add-channels', 'Select OpenCode projects to create Discord channels before starting')
|
|
1107
|
+
.option('--data-dir <path>', 'Data directory for config and database (default: ~/.disunday)')
|
|
1108
|
+
.option('--install-url', 'Print the bot install URL and exit')
|
|
1109
|
+
.option('--use-worktrees', 'Create git worktrees for all new sessions started from channel messages')
|
|
1110
|
+
.option('--enable-voice-channels', 'Create voice channels for projects (disabled by default)')
|
|
1111
|
+
.option('--verbosity <level>', 'Default verbosity for all channels (tools-and-text, text-and-essential-tools, or text-only)')
|
|
1112
|
+
.action(async (options) => {
|
|
1113
|
+
try {
|
|
1114
|
+
// Set data directory early, before any database access
|
|
1115
|
+
if (options.dataDir) {
|
|
1116
|
+
setDataDir(options.dataDir);
|
|
1117
|
+
cliLogger.log(`Using data directory: ${getDataDir()}`);
|
|
1118
|
+
}
|
|
1119
|
+
if (options.verbosity) {
|
|
1120
|
+
const validLevels = [
|
|
1121
|
+
'tools-and-text',
|
|
1122
|
+
'text-and-essential-tools',
|
|
1123
|
+
'text-only',
|
|
1124
|
+
];
|
|
1125
|
+
if (!validLevels.includes(options.verbosity)) {
|
|
1126
|
+
cliLogger.error(`Invalid verbosity level: ${options.verbosity}. Use one of: ${validLevels.join(', ')}`);
|
|
1127
|
+
process.exit(EXIT_NO_RESTART);
|
|
1128
|
+
}
|
|
1129
|
+
setDefaultVerbosity(options.verbosity);
|
|
1130
|
+
cliLogger.log(`Default verbosity: ${options.verbosity}`);
|
|
1131
|
+
}
|
|
1132
|
+
await migrateFromLegacy();
|
|
1133
|
+
if (options.installUrl) {
|
|
1134
|
+
const db = getDatabase();
|
|
1135
|
+
const existingBot = db
|
|
1136
|
+
.prepare('SELECT app_id FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
|
|
1137
|
+
.get();
|
|
1138
|
+
if (!existingBot) {
|
|
1139
|
+
cliLogger.error('No bot configured yet. Run `disunday` first to set up.');
|
|
1140
|
+
process.exit(EXIT_NO_RESTART);
|
|
1141
|
+
}
|
|
1142
|
+
console.log(generateBotInstallUrl({ clientId: existingBot.app_id }));
|
|
1143
|
+
process.exit(0);
|
|
1144
|
+
}
|
|
1145
|
+
await checkSingleInstance();
|
|
1146
|
+
await startLockServer();
|
|
1147
|
+
await run({
|
|
1148
|
+
restart: options.restart,
|
|
1149
|
+
addChannels: options.addChannels,
|
|
1150
|
+
dataDir: options.dataDir,
|
|
1151
|
+
useWorktrees: options.useWorktrees,
|
|
1152
|
+
enableVoiceChannels: options.enableVoiceChannels,
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
catch (error) {
|
|
1156
|
+
cliLogger.error('Unhandled error:', error instanceof Error ? error.message : String(error));
|
|
1157
|
+
process.exit(EXIT_NO_RESTART);
|
|
1158
|
+
}
|
|
1159
|
+
});
|
|
1160
|
+
cli
|
|
1161
|
+
.command('upload-to-discord [...files]', 'Upload files to a Discord thread for a session')
|
|
1162
|
+
.option('-s, --session <sessionId>', 'OpenCode session ID')
|
|
1163
|
+
.action(async (files, options) => {
|
|
1164
|
+
try {
|
|
1165
|
+
const { session: sessionId } = options;
|
|
1166
|
+
if (!sessionId) {
|
|
1167
|
+
cliLogger.error('Session ID is required. Use --session <sessionId>');
|
|
1168
|
+
process.exit(EXIT_NO_RESTART);
|
|
1169
|
+
}
|
|
1170
|
+
if (!files || files.length === 0) {
|
|
1171
|
+
cliLogger.error('At least one file path is required');
|
|
1172
|
+
process.exit(EXIT_NO_RESTART);
|
|
1173
|
+
}
|
|
1174
|
+
const resolvedFiles = files.map((f) => path.resolve(f));
|
|
1175
|
+
for (const file of resolvedFiles) {
|
|
1176
|
+
if (!fs.existsSync(file)) {
|
|
1177
|
+
cliLogger.error(`File not found: ${file}`);
|
|
1178
|
+
process.exit(EXIT_NO_RESTART);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
const db = getDatabase();
|
|
1182
|
+
const threadRow = db
|
|
1183
|
+
.prepare('SELECT thread_id FROM thread_sessions WHERE session_id = ?')
|
|
1184
|
+
.get(sessionId);
|
|
1185
|
+
if (!threadRow) {
|
|
1186
|
+
cliLogger.error(`No Discord thread found for session: ${sessionId}`);
|
|
1187
|
+
process.exit(EXIT_NO_RESTART);
|
|
1188
|
+
}
|
|
1189
|
+
const botRow = db
|
|
1190
|
+
.prepare('SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
|
|
1191
|
+
.get();
|
|
1192
|
+
if (!botRow) {
|
|
1193
|
+
cliLogger.error('No bot credentials found. Run `disunday` first to set up the bot.');
|
|
1194
|
+
process.exit(EXIT_NO_RESTART);
|
|
1195
|
+
}
|
|
1196
|
+
const s = spinner();
|
|
1197
|
+
s.start(`Uploading ${resolvedFiles.length} file(s)...`);
|
|
1198
|
+
await uploadFilesToDiscord({
|
|
1199
|
+
threadId: threadRow.thread_id,
|
|
1200
|
+
botToken: botRow.token,
|
|
1201
|
+
files: resolvedFiles,
|
|
1202
|
+
});
|
|
1203
|
+
s.stop(`Uploaded ${resolvedFiles.length} file(s)!`);
|
|
1204
|
+
note(`Files uploaded to Discord thread!\n\nFiles: ${resolvedFiles.map((f) => path.basename(f)).join(', ')}`, 'ā
Success');
|
|
1205
|
+
process.exit(0);
|
|
1206
|
+
}
|
|
1207
|
+
catch (error) {
|
|
1208
|
+
cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
|
|
1209
|
+
process.exit(EXIT_NO_RESTART);
|
|
1210
|
+
}
|
|
1211
|
+
});
|
|
1212
|
+
cli
|
|
1213
|
+
.command('send', 'Send a message to Discord channel, creating a thread. Use --notify-only to skip AI session.')
|
|
1214
|
+
.alias('start-session') // backwards compatibility
|
|
1215
|
+
.option('-c, --channel <channelId>', 'Discord channel ID')
|
|
1216
|
+
.option('-d, --project <path>', 'Project directory (alternative to --channel)')
|
|
1217
|
+
.option('-p, --prompt <prompt>', 'Message content')
|
|
1218
|
+
.option('-n, --name [name]', 'Thread name (optional, defaults to prompt preview)')
|
|
1219
|
+
.option('-a, --app-id [appId]', 'Bot application ID (required if no local database)')
|
|
1220
|
+
.option('--notify-only', 'Create notification thread without starting AI session')
|
|
1221
|
+
.action(async (options) => {
|
|
1222
|
+
try {
|
|
1223
|
+
let { channel: channelId, prompt, name, appId: optionAppId, notifyOnly, } = options;
|
|
1224
|
+
const { project: projectPath } = options;
|
|
1225
|
+
// Get raw channel ID from argv to prevent JS number precision loss on large Discord IDs
|
|
1226
|
+
// cac parses large numbers and loses precision, so we extract the original string value
|
|
1227
|
+
if (channelId) {
|
|
1228
|
+
const channelArgIndex = process.argv.findIndex((arg) => arg === '--channel' || arg === '-c');
|
|
1229
|
+
if (channelArgIndex !== -1 && process.argv[channelArgIndex + 1]) {
|
|
1230
|
+
channelId = process.argv[channelArgIndex + 1];
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
if (!channelId && !projectPath) {
|
|
1234
|
+
cliLogger.error('Either --channel or --project is required');
|
|
1235
|
+
process.exit(EXIT_NO_RESTART);
|
|
1236
|
+
}
|
|
1237
|
+
if (!prompt) {
|
|
1238
|
+
cliLogger.error('Prompt is required. Use --prompt <prompt>');
|
|
1239
|
+
process.exit(EXIT_NO_RESTART);
|
|
1240
|
+
}
|
|
1241
|
+
// Get bot token from env var or database
|
|
1242
|
+
const envToken = process.env.DISUNDAY_BOT_TOKEN || process.env.DISUNDAY_BOT_TOKEN;
|
|
1243
|
+
let botToken;
|
|
1244
|
+
let appId = optionAppId;
|
|
1245
|
+
if (envToken) {
|
|
1246
|
+
botToken = envToken;
|
|
1247
|
+
if (!appId) {
|
|
1248
|
+
// Try to get app_id from database if available (optional in CI)
|
|
1249
|
+
try {
|
|
1250
|
+
const db = getDatabase();
|
|
1251
|
+
const botRow = db
|
|
1252
|
+
.prepare('SELECT app_id FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
|
|
1253
|
+
.get();
|
|
1254
|
+
appId = botRow?.app_id;
|
|
1255
|
+
}
|
|
1256
|
+
catch (error) {
|
|
1257
|
+
cliLogger.debug('Database lookup failed while resolving app ID:', error instanceof Error ? error.message : String(error));
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
else {
|
|
1262
|
+
// Fall back to database
|
|
1263
|
+
try {
|
|
1264
|
+
const db = getDatabase();
|
|
1265
|
+
const botRow = db
|
|
1266
|
+
.prepare('SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
|
|
1267
|
+
.get();
|
|
1268
|
+
if (botRow) {
|
|
1269
|
+
botToken = botRow.token;
|
|
1270
|
+
appId = appId || botRow.app_id;
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
catch (e) {
|
|
1274
|
+
// Database error - will fall through to the check below
|
|
1275
|
+
cliLogger.error('Database error:', e instanceof Error ? e.message : String(e));
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
if (!botToken) {
|
|
1279
|
+
cliLogger.error('No bot token found. Set DISUNDAY_BOT_TOKEN env var or run `disunday` first to set up.');
|
|
1280
|
+
process.exit(EXIT_NO_RESTART);
|
|
1281
|
+
}
|
|
1282
|
+
const s = spinner();
|
|
1283
|
+
// If --project provided, resolve to channel ID
|
|
1284
|
+
if (projectPath) {
|
|
1285
|
+
const absolutePath = path.resolve(projectPath);
|
|
1286
|
+
if (!fs.existsSync(absolutePath)) {
|
|
1287
|
+
cliLogger.error(`Directory does not exist: ${absolutePath}`);
|
|
1288
|
+
process.exit(EXIT_NO_RESTART);
|
|
1289
|
+
}
|
|
1290
|
+
s.start('Looking up channel for project...');
|
|
1291
|
+
// Check if channel already exists for this directory or a parent directory
|
|
1292
|
+
// This allows running from subfolders of a registered project
|
|
1293
|
+
try {
|
|
1294
|
+
const db = getDatabase();
|
|
1295
|
+
// Helper to find channel for a path (prefers current bot's channel)
|
|
1296
|
+
const findChannelForPath = (dirPath) => {
|
|
1297
|
+
const withAppId = db
|
|
1298
|
+
.prepare('SELECT channel_id, directory FROM channel_directories WHERE directory = ? AND channel_type = ? AND app_id = ?')
|
|
1299
|
+
.get(dirPath, 'text', appId);
|
|
1300
|
+
if (withAppId)
|
|
1301
|
+
return withAppId;
|
|
1302
|
+
return db
|
|
1303
|
+
.prepare('SELECT channel_id, directory FROM channel_directories WHERE directory = ? AND channel_type = ?')
|
|
1304
|
+
.get(dirPath, 'text');
|
|
1305
|
+
};
|
|
1306
|
+
// Try exact match first, then walk up parent directories
|
|
1307
|
+
let existingChannel;
|
|
1308
|
+
let searchPath = absolutePath;
|
|
1309
|
+
while (searchPath !== path.dirname(searchPath)) {
|
|
1310
|
+
existingChannel = findChannelForPath(searchPath);
|
|
1311
|
+
if (existingChannel)
|
|
1312
|
+
break;
|
|
1313
|
+
searchPath = path.dirname(searchPath);
|
|
1314
|
+
}
|
|
1315
|
+
if (existingChannel) {
|
|
1316
|
+
channelId = existingChannel.channel_id;
|
|
1317
|
+
if (existingChannel.directory !== absolutePath) {
|
|
1318
|
+
s.message(`Found parent project channel: ${existingChannel.directory}`);
|
|
1319
|
+
}
|
|
1320
|
+
else {
|
|
1321
|
+
s.message(`Found existing channel: ${channelId}`);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
else {
|
|
1325
|
+
// Need to create a new channel
|
|
1326
|
+
s.message('Creating new channel...');
|
|
1327
|
+
if (!appId) {
|
|
1328
|
+
s.stop('Missing app ID');
|
|
1329
|
+
cliLogger.error('App ID is required to create channels. Use --app-id or run `disunday` first.');
|
|
1330
|
+
process.exit(EXIT_NO_RESTART);
|
|
1331
|
+
}
|
|
1332
|
+
const client = await createDiscordClient();
|
|
1333
|
+
await new Promise((resolve, reject) => {
|
|
1334
|
+
client.once(Events.ClientReady, () => {
|
|
1335
|
+
resolve();
|
|
1336
|
+
});
|
|
1337
|
+
client.once(Events.Error, reject);
|
|
1338
|
+
client.login(botToken);
|
|
1339
|
+
});
|
|
1340
|
+
// Get guild from existing channels or first available
|
|
1341
|
+
const guild = await (async () => {
|
|
1342
|
+
// Try to find a guild from existing channels belonging to this bot
|
|
1343
|
+
const existingChannelRow = db
|
|
1344
|
+
.prepare('SELECT channel_id FROM channel_directories WHERE app_id = ? ORDER BY created_at DESC LIMIT 1')
|
|
1345
|
+
.get(appId);
|
|
1346
|
+
if (existingChannelRow) {
|
|
1347
|
+
try {
|
|
1348
|
+
const ch = await client.channels.fetch(existingChannelRow.channel_id);
|
|
1349
|
+
if (ch && 'guild' in ch && ch.guild) {
|
|
1350
|
+
return ch.guild;
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
catch (error) {
|
|
1354
|
+
cliLogger.debug('Failed to fetch existing channel while selecting guild:', error instanceof Error ? error.message : String(error));
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
// Fall back to first guild the bot is in
|
|
1358
|
+
const firstGuild = client.guilds.cache.first();
|
|
1359
|
+
if (!firstGuild) {
|
|
1360
|
+
throw new Error('No guild found. Add the bot to a server first.');
|
|
1361
|
+
}
|
|
1362
|
+
return firstGuild;
|
|
1363
|
+
})();
|
|
1364
|
+
const { textChannelId } = await createProjectChannels({
|
|
1365
|
+
guild,
|
|
1366
|
+
projectDirectory: absolutePath,
|
|
1367
|
+
appId,
|
|
1368
|
+
botName: client.user?.username,
|
|
1369
|
+
});
|
|
1370
|
+
channelId = textChannelId;
|
|
1371
|
+
s.message(`Created channel: ${channelId}`);
|
|
1372
|
+
client.destroy();
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
catch (e) {
|
|
1376
|
+
s.stop('Failed to resolve project');
|
|
1377
|
+
throw e;
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
s.start('Fetching channel info...');
|
|
1381
|
+
// Get channel info to extract directory from topic
|
|
1382
|
+
const channelResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}`, {
|
|
1383
|
+
headers: {
|
|
1384
|
+
Authorization: `Bot ${botToken}`,
|
|
1385
|
+
},
|
|
1386
|
+
});
|
|
1387
|
+
if (!channelResponse.ok) {
|
|
1388
|
+
const error = await channelResponse.text();
|
|
1389
|
+
s.stop('Failed to fetch channel');
|
|
1390
|
+
throw new Error(`Discord API error: ${channelResponse.status} - ${error}`);
|
|
1391
|
+
}
|
|
1392
|
+
const channelData = (await channelResponse.json());
|
|
1393
|
+
const channelConfig = getChannelDirectory(channelData.id);
|
|
1394
|
+
if (!channelConfig) {
|
|
1395
|
+
s.stop('Channel not configured');
|
|
1396
|
+
throw new Error(`Channel #${channelData.name} is not configured with a project directory. Run the bot first to sync channel data.`);
|
|
1397
|
+
}
|
|
1398
|
+
const projectDirectory = channelConfig.directory;
|
|
1399
|
+
const channelAppId = channelConfig.appId || undefined;
|
|
1400
|
+
// Verify app ID matches if both are present
|
|
1401
|
+
if (channelAppId && appId && channelAppId !== appId) {
|
|
1402
|
+
s.stop('Channel belongs to different bot');
|
|
1403
|
+
throw new Error(`Channel belongs to a different bot (expected: ${appId}, got: ${channelAppId})`);
|
|
1404
|
+
}
|
|
1405
|
+
s.message('Creating starter message...');
|
|
1406
|
+
// Discord has a 2000 character limit for messages.
|
|
1407
|
+
// If prompt exceeds this, send it as a file attachment instead.
|
|
1408
|
+
const DISCORD_MAX_LENGTH = 2000;
|
|
1409
|
+
let starterMessage;
|
|
1410
|
+
// Embed marker for auto-start sessions (unless --notify-only)
|
|
1411
|
+
// Bot checks for this embed footer to know it should start a session
|
|
1412
|
+
const AUTO_START_MARKER = 'disunday:start';
|
|
1413
|
+
const autoStartEmbed = notifyOnly
|
|
1414
|
+
? undefined
|
|
1415
|
+
: [{ color: 0x2b2d31, footer: { text: AUTO_START_MARKER } }];
|
|
1416
|
+
if (prompt.length > DISCORD_MAX_LENGTH) {
|
|
1417
|
+
// Send as file attachment with a short summary
|
|
1418
|
+
const preview = prompt.slice(0, 100).replace(/\n/g, ' ');
|
|
1419
|
+
const summaryContent = `š **Prompt attached as file** (${prompt.length} chars)\n\n> ${preview}...`;
|
|
1420
|
+
// Write prompt to a temp file
|
|
1421
|
+
const tmpDir = path.join(process.cwd(), 'tmp');
|
|
1422
|
+
if (!fs.existsSync(tmpDir)) {
|
|
1423
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
1424
|
+
}
|
|
1425
|
+
const tmpFile = path.join(tmpDir, `prompt-${Date.now()}.md`);
|
|
1426
|
+
fs.writeFileSync(tmpFile, prompt);
|
|
1427
|
+
try {
|
|
1428
|
+
// Create message with file attachment
|
|
1429
|
+
const formData = new FormData();
|
|
1430
|
+
formData.append('payload_json', JSON.stringify({
|
|
1431
|
+
content: summaryContent,
|
|
1432
|
+
attachments: [{ id: 0, filename: 'prompt.md' }],
|
|
1433
|
+
embeds: autoStartEmbed,
|
|
1434
|
+
}));
|
|
1435
|
+
const buffer = fs.readFileSync(tmpFile);
|
|
1436
|
+
formData.append('files[0]', new Blob([buffer], { type: 'text/markdown' }), 'prompt.md');
|
|
1437
|
+
const starterMessageResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
|
|
1438
|
+
method: 'POST',
|
|
1439
|
+
headers: {
|
|
1440
|
+
Authorization: `Bot ${botToken}`,
|
|
1441
|
+
},
|
|
1442
|
+
body: formData,
|
|
1443
|
+
});
|
|
1444
|
+
if (!starterMessageResponse.ok) {
|
|
1445
|
+
const error = await starterMessageResponse.text();
|
|
1446
|
+
s.stop('Failed to create message');
|
|
1447
|
+
throw new Error(`Discord API error: ${starterMessageResponse.status} - ${error}`);
|
|
1448
|
+
}
|
|
1449
|
+
starterMessage = (await starterMessageResponse.json());
|
|
1450
|
+
}
|
|
1451
|
+
finally {
|
|
1452
|
+
// Clean up temp file
|
|
1453
|
+
fs.unlinkSync(tmpFile);
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
else {
|
|
1457
|
+
// Normal case: send prompt inline
|
|
1458
|
+
const starterMessageResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
|
|
1459
|
+
method: 'POST',
|
|
1460
|
+
headers: {
|
|
1461
|
+
Authorization: `Bot ${botToken}`,
|
|
1462
|
+
'Content-Type': 'application/json',
|
|
1463
|
+
},
|
|
1464
|
+
body: JSON.stringify({
|
|
1465
|
+
content: prompt,
|
|
1466
|
+
embeds: autoStartEmbed,
|
|
1467
|
+
}),
|
|
1468
|
+
});
|
|
1469
|
+
if (!starterMessageResponse.ok) {
|
|
1470
|
+
const error = await starterMessageResponse.text();
|
|
1471
|
+
s.stop('Failed to create message');
|
|
1472
|
+
throw new Error(`Discord API error: ${starterMessageResponse.status} - ${error}`);
|
|
1473
|
+
}
|
|
1474
|
+
starterMessage = (await starterMessageResponse.json());
|
|
1475
|
+
}
|
|
1476
|
+
s.message('Creating thread...');
|
|
1477
|
+
// Create thread from the message
|
|
1478
|
+
const threadName = name || (prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt);
|
|
1479
|
+
const threadResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages/${starterMessage.id}/threads`, {
|
|
1480
|
+
method: 'POST',
|
|
1481
|
+
headers: {
|
|
1482
|
+
Authorization: `Bot ${botToken}`,
|
|
1483
|
+
'Content-Type': 'application/json',
|
|
1484
|
+
},
|
|
1485
|
+
body: JSON.stringify({
|
|
1486
|
+
name: threadName.slice(0, 100),
|
|
1487
|
+
auto_archive_duration: 1440, // 1 day
|
|
1488
|
+
}),
|
|
1489
|
+
});
|
|
1490
|
+
if (!threadResponse.ok) {
|
|
1491
|
+
const error = await threadResponse.text();
|
|
1492
|
+
s.stop('Failed to create thread');
|
|
1493
|
+
throw new Error(`Discord API error: ${threadResponse.status} - ${error}`);
|
|
1494
|
+
}
|
|
1495
|
+
const threadData = (await threadResponse.json());
|
|
1496
|
+
s.stop('Thread created!');
|
|
1497
|
+
const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`;
|
|
1498
|
+
const successMessage = notifyOnly
|
|
1499
|
+
? `Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nNotification created. Reply to start a session.\n\nURL: ${threadUrl}`
|
|
1500
|
+
: `Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nThe running bot will pick this up and start the session.\n\nURL: ${threadUrl}`;
|
|
1501
|
+
note(successMessage, 'ā
Thread Created');
|
|
1502
|
+
console.log(threadUrl);
|
|
1503
|
+
process.exit(0);
|
|
1504
|
+
}
|
|
1505
|
+
catch (error) {
|
|
1506
|
+
cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
|
|
1507
|
+
process.exit(EXIT_NO_RESTART);
|
|
1508
|
+
}
|
|
1509
|
+
});
|
|
1510
|
+
cli
|
|
1511
|
+
.command('add-project [directory]', 'Create Discord channels for a project directory (e.g. ./folder)')
|
|
1512
|
+
.option('-g, --guild <guildId>', 'Discord guild/server ID (auto-detects if bot is in only one server)')
|
|
1513
|
+
.option('-a, --app-id <appId>', 'Bot application ID (reads from database if available)')
|
|
1514
|
+
.action(async (directory, options) => {
|
|
1515
|
+
try {
|
|
1516
|
+
const absolutePath = path.resolve(directory || '.');
|
|
1517
|
+
if (!fs.existsSync(absolutePath)) {
|
|
1518
|
+
cliLogger.error(`Directory does not exist: ${absolutePath}`);
|
|
1519
|
+
process.exit(EXIT_NO_RESTART);
|
|
1520
|
+
}
|
|
1521
|
+
// Get bot token from env var or database
|
|
1522
|
+
const envToken = process.env.DISUNDAY_BOT_TOKEN || process.env.DISUNDAY_BOT_TOKEN;
|
|
1523
|
+
let botToken;
|
|
1524
|
+
let appId = options.appId;
|
|
1525
|
+
if (envToken) {
|
|
1526
|
+
botToken = envToken;
|
|
1527
|
+
if (!appId) {
|
|
1528
|
+
try {
|
|
1529
|
+
const db = getDatabase();
|
|
1530
|
+
const botRow = db
|
|
1531
|
+
.prepare('SELECT app_id FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
|
|
1532
|
+
.get();
|
|
1533
|
+
appId = botRow?.app_id;
|
|
1534
|
+
}
|
|
1535
|
+
catch (error) {
|
|
1536
|
+
cliLogger.debug('Database lookup failed while resolving app ID:', error instanceof Error ? error.message : String(error));
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
else {
|
|
1541
|
+
try {
|
|
1542
|
+
const db = getDatabase();
|
|
1543
|
+
const botRow = db
|
|
1544
|
+
.prepare('SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
|
|
1545
|
+
.get();
|
|
1546
|
+
if (botRow) {
|
|
1547
|
+
botToken = botRow.token;
|
|
1548
|
+
appId = appId || botRow.app_id;
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
catch (e) {
|
|
1552
|
+
cliLogger.error('Database error:', e instanceof Error ? e.message : String(e));
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
if (!botToken) {
|
|
1556
|
+
cliLogger.error('No bot token found. Set DISUNDAY_BOT_TOKEN env var or run `disunday` first to set up.');
|
|
1557
|
+
process.exit(EXIT_NO_RESTART);
|
|
1558
|
+
}
|
|
1559
|
+
if (!appId) {
|
|
1560
|
+
cliLogger.error('App ID is required to create channels. Use --app-id or run `disunday` first.');
|
|
1561
|
+
process.exit(EXIT_NO_RESTART);
|
|
1562
|
+
}
|
|
1563
|
+
const s = spinner();
|
|
1564
|
+
s.start('Connecting to Discord...');
|
|
1565
|
+
const client = await createDiscordClient();
|
|
1566
|
+
await new Promise((resolve, reject) => {
|
|
1567
|
+
client.once(Events.ClientReady, () => {
|
|
1568
|
+
resolve();
|
|
1569
|
+
});
|
|
1570
|
+
client.once(Events.Error, reject);
|
|
1571
|
+
client.login(botToken);
|
|
1572
|
+
});
|
|
1573
|
+
s.message('Finding guild...');
|
|
1574
|
+
// Find guild
|
|
1575
|
+
let guild;
|
|
1576
|
+
if (options.guild) {
|
|
1577
|
+
// Get raw guild ID from argv to avoid cac's number coercion losing precision on large IDs
|
|
1578
|
+
const guildArgIndex = process.argv.findIndex((arg) => arg === '-g' || arg === '--guild');
|
|
1579
|
+
const rawGuildArg = guildArgIndex >= 0 ? process.argv[guildArgIndex + 1] : undefined;
|
|
1580
|
+
const guildId = rawGuildArg || String(options.guild);
|
|
1581
|
+
const foundGuild = client.guilds.cache.get(guildId);
|
|
1582
|
+
if (!foundGuild) {
|
|
1583
|
+
s.stop('Guild not found');
|
|
1584
|
+
cliLogger.error(`Guild not found: ${guildId}`);
|
|
1585
|
+
client.destroy();
|
|
1586
|
+
process.exit(EXIT_NO_RESTART);
|
|
1587
|
+
}
|
|
1588
|
+
guild = foundGuild;
|
|
1589
|
+
}
|
|
1590
|
+
else {
|
|
1591
|
+
// Auto-detect: prefer guild with existing channels for this bot, else first guild
|
|
1592
|
+
const db = getDatabase();
|
|
1593
|
+
const existingChannelRow = db
|
|
1594
|
+
.prepare('SELECT channel_id FROM channel_directories WHERE app_id = ? ORDER BY created_at DESC LIMIT 1')
|
|
1595
|
+
.get(appId);
|
|
1596
|
+
if (existingChannelRow) {
|
|
1597
|
+
try {
|
|
1598
|
+
const ch = await client.channels.fetch(existingChannelRow.channel_id);
|
|
1599
|
+
if (ch && 'guild' in ch && ch.guild) {
|
|
1600
|
+
guild = ch.guild;
|
|
1601
|
+
}
|
|
1602
|
+
else {
|
|
1603
|
+
throw new Error('Channel has no guild');
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
catch (error) {
|
|
1607
|
+
cliLogger.debug('Failed to fetch existing channel while selecting guild:', error instanceof Error ? error.message : String(error));
|
|
1608
|
+
const firstGuild = client.guilds.cache.first();
|
|
1609
|
+
if (!firstGuild) {
|
|
1610
|
+
s.stop('No guild found');
|
|
1611
|
+
cliLogger.error('No guild found. Add the bot to a server first.');
|
|
1612
|
+
client.destroy();
|
|
1613
|
+
process.exit(EXIT_NO_RESTART);
|
|
1614
|
+
}
|
|
1615
|
+
guild = firstGuild;
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
else {
|
|
1619
|
+
const firstGuild = client.guilds.cache.first();
|
|
1620
|
+
if (!firstGuild) {
|
|
1621
|
+
s.stop('No guild found');
|
|
1622
|
+
cliLogger.error('No guild found. Add the bot to a server first.');
|
|
1623
|
+
client.destroy();
|
|
1624
|
+
process.exit(EXIT_NO_RESTART);
|
|
1625
|
+
}
|
|
1626
|
+
guild = firstGuild;
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
// Check if channel already exists in this guild
|
|
1630
|
+
s.message('Checking for existing channel...');
|
|
1631
|
+
try {
|
|
1632
|
+
const db = getDatabase();
|
|
1633
|
+
const existingChannels = db
|
|
1634
|
+
.prepare('SELECT channel_id FROM channel_directories WHERE directory = ? AND channel_type = ? AND app_id = ?')
|
|
1635
|
+
.all(absolutePath, 'text', appId);
|
|
1636
|
+
for (const existingChannel of existingChannels) {
|
|
1637
|
+
try {
|
|
1638
|
+
const ch = await client.channels.fetch(existingChannel.channel_id);
|
|
1639
|
+
if (ch && 'guild' in ch && ch.guild?.id === guild.id) {
|
|
1640
|
+
s.stop('Channel already exists');
|
|
1641
|
+
note(`Channel already exists for this directory in ${guild.name}.\n\nChannel: <#${existingChannel.channel_id}>\nDirectory: ${absolutePath}`, 'ā ļø Already Exists');
|
|
1642
|
+
client.destroy();
|
|
1643
|
+
process.exit(0);
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
catch (error) {
|
|
1647
|
+
cliLogger.debug(`Failed to fetch channel ${existingChannel.channel_id} while checking existing channels:`, error instanceof Error ? error.message : String(error));
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
catch (error) {
|
|
1652
|
+
cliLogger.debug('Database lookup failed while checking existing channels:', error instanceof Error ? error.message : String(error));
|
|
1653
|
+
}
|
|
1654
|
+
s.message(`Creating channels in ${guild.name}...`);
|
|
1655
|
+
const { textChannelId, voiceChannelId, channelName } = await createProjectChannels({
|
|
1656
|
+
guild,
|
|
1657
|
+
projectDirectory: absolutePath,
|
|
1658
|
+
appId,
|
|
1659
|
+
botName: client.user?.username,
|
|
1660
|
+
});
|
|
1661
|
+
client.destroy();
|
|
1662
|
+
s.stop('Channels created!');
|
|
1663
|
+
const channelUrl = `https://discord.com/channels/${guild.id}/${textChannelId}`;
|
|
1664
|
+
note(`Created channels for project:\n\nš Text: #${channelName}\nš Voice: #${channelName}\nš Directory: ${absolutePath}\n\nURL: ${channelUrl}`, 'ā
Success');
|
|
1665
|
+
console.log(channelUrl);
|
|
1666
|
+
process.exit(0);
|
|
1667
|
+
}
|
|
1668
|
+
catch (error) {
|
|
1669
|
+
cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
|
|
1670
|
+
process.exit(EXIT_NO_RESTART);
|
|
1671
|
+
}
|
|
1672
|
+
});
|
|
1673
|
+
cli.help();
|
|
1674
|
+
cli.parse();
|