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.
Files changed (83) hide show
  1. package/dist/ai-tool-to-genai.js +208 -0
  2. package/dist/ai-tool-to-genai.test.js +267 -0
  3. package/dist/channel-management.js +96 -0
  4. package/dist/cli.js +1674 -0
  5. package/dist/commands/abort.js +89 -0
  6. package/dist/commands/add-project.js +117 -0
  7. package/dist/commands/agent.js +250 -0
  8. package/dist/commands/ask-question.js +219 -0
  9. package/dist/commands/compact.js +126 -0
  10. package/dist/commands/context-menu.js +171 -0
  11. package/dist/commands/context.js +89 -0
  12. package/dist/commands/cost.js +93 -0
  13. package/dist/commands/create-new-project.js +111 -0
  14. package/dist/commands/diff.js +77 -0
  15. package/dist/commands/export.js +100 -0
  16. package/dist/commands/files.js +73 -0
  17. package/dist/commands/fork.js +199 -0
  18. package/dist/commands/help.js +54 -0
  19. package/dist/commands/login.js +488 -0
  20. package/dist/commands/merge-worktree.js +165 -0
  21. package/dist/commands/model.js +325 -0
  22. package/dist/commands/permissions.js +140 -0
  23. package/dist/commands/ping.js +13 -0
  24. package/dist/commands/queue.js +133 -0
  25. package/dist/commands/remove-project.js +119 -0
  26. package/dist/commands/rename.js +70 -0
  27. package/dist/commands/restart-opencode-server.js +77 -0
  28. package/dist/commands/resume.js +276 -0
  29. package/dist/commands/run-config.js +79 -0
  30. package/dist/commands/run.js +240 -0
  31. package/dist/commands/schedule.js +170 -0
  32. package/dist/commands/session-info.js +58 -0
  33. package/dist/commands/session.js +191 -0
  34. package/dist/commands/settings.js +84 -0
  35. package/dist/commands/share.js +89 -0
  36. package/dist/commands/status.js +79 -0
  37. package/dist/commands/sync.js +119 -0
  38. package/dist/commands/theme.js +53 -0
  39. package/dist/commands/types.js +2 -0
  40. package/dist/commands/undo-redo.js +170 -0
  41. package/dist/commands/user-command.js +135 -0
  42. package/dist/commands/verbosity.js +59 -0
  43. package/dist/commands/worktree-settings.js +50 -0
  44. package/dist/commands/worktree.js +288 -0
  45. package/dist/config.js +139 -0
  46. package/dist/database.js +585 -0
  47. package/dist/discord-bot.js +700 -0
  48. package/dist/discord-utils.js +336 -0
  49. package/dist/discord-utils.test.js +20 -0
  50. package/dist/errors.js +193 -0
  51. package/dist/escape-backticks.test.js +429 -0
  52. package/dist/format-tables.js +96 -0
  53. package/dist/format-tables.test.js +418 -0
  54. package/dist/genai-worker-wrapper.js +109 -0
  55. package/dist/genai-worker.js +299 -0
  56. package/dist/genai.js +230 -0
  57. package/dist/image-utils.js +107 -0
  58. package/dist/interaction-handler.js +289 -0
  59. package/dist/limit-heading-depth.js +25 -0
  60. package/dist/limit-heading-depth.test.js +105 -0
  61. package/dist/logger.js +111 -0
  62. package/dist/markdown.js +323 -0
  63. package/dist/markdown.test.js +269 -0
  64. package/dist/message-formatting.js +447 -0
  65. package/dist/message-formatting.test.js +73 -0
  66. package/dist/openai-realtime.js +226 -0
  67. package/dist/opencode.js +224 -0
  68. package/dist/reaction-handler.js +128 -0
  69. package/dist/scheduler.js +93 -0
  70. package/dist/security.js +200 -0
  71. package/dist/session-handler.js +1436 -0
  72. package/dist/system-message.js +138 -0
  73. package/dist/tools.js +354 -0
  74. package/dist/unnest-code-blocks.js +117 -0
  75. package/dist/unnest-code-blocks.test.js +432 -0
  76. package/dist/utils.js +95 -0
  77. package/dist/voice-handler.js +569 -0
  78. package/dist/voice.js +344 -0
  79. package/dist/worker-types.js +4 -0
  80. package/dist/worktree-utils.js +134 -0
  81. package/dist/xml.js +90 -0
  82. package/dist/xml.test.js +32 -0
  83. package/package.json +84 -0
@@ -0,0 +1,700 @@
1
+ // Core Discord bot module that handles message events and bot lifecycle.
2
+ // Bridges Discord messages to OpenCode sessions, manages voice connections,
3
+ // and orchestrates the main event loop for the Disunday bot.
4
+ import { getDatabase, closeDatabase, getThreadWorktree, createPendingWorktree, setWorktreeReady, setWorktreeError, getChannelWorktreesEnabled, getChannelDirectory, } from './database.js';
5
+ import { initializeOpencodeForDirectory, getOpencodeServers, getOpencodeClientV2, } from './opencode.js';
6
+ import { formatWorktreeName } from './commands/worktree.js';
7
+ import { WORKTREE_PREFIX } from './commands/merge-worktree.js';
8
+ import { createWorktreeWithSubmodules } from './worktree-utils.js';
9
+ import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, SILENT_MESSAGE_FLAGS, } from './discord-utils.js';
10
+ import { getOpencodeSystemMessage } from './system-message.js';
11
+ import { getFileAttachments, getTextAttachments } from './message-formatting.js';
12
+ import { ensureDisundayCategory, ensureDisundayAudioCategory, createProjectChannels, getChannelsWithDescriptions, } from './channel-management.js';
13
+ import { voiceConnections, cleanupVoiceConnection, processVoiceAttachment, registerVoiceStateHandler, } from './voice-handler.js';
14
+ import { getCompactSessionContext, getLastSessionId } from './markdown.js';
15
+ import { handleOpencodeSession } from './session-handler.js';
16
+ import { registerInteractionHandler } from './interaction-handler.js';
17
+ import { registerReactionHandler } from './reaction-handler.js';
18
+ import { startScheduler } from './scheduler.js';
19
+ import { refreshSessionCache } from './commands/resume.js';
20
+ import { sanitizeForXml } from './security.js';
21
+ import { sanitizeErrorForUser, getErrorForLogging } from './errors.js';
22
+ import { getRateLimitConfig } from './config.js';
23
+ export { getDatabase, closeDatabase, getChannelDirectory } from './database.js';
24
+ export { initializeOpencodeForDirectory } from './opencode.js';
25
+ export { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, } from './discord-utils.js';
26
+ export { getOpencodeSystemMessage } from './system-message.js';
27
+ export { ensureDisundayCategory, ensureDisundayAudioCategory, createProjectChannels, getChannelsWithDescriptions, } from './channel-management.js';
28
+ import { ChannelType, Client, Events, GatewayIntentBits, Partials, PermissionsBitField, ThreadAutoArchiveDuration, } from 'discord.js';
29
+ import fs from 'node:fs';
30
+ import * as errore from 'errore';
31
+ import { createLogger, LogPrefix } from './logger.js';
32
+ import { setGlobalDispatcher, Agent } from 'undici';
33
+ // Increase connection pool to prevent deadlock when multiple sessions have open SSE streams.
34
+ // Each session's event.subscribe() holds a connection; without enough connections,
35
+ // regular HTTP requests (question.reply, session.prompt) get blocked → deadlock.
36
+ setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0, connections: 500 }));
37
+ const discordLogger = createLogger(LogPrefix.DISCORD);
38
+ const voiceLogger = createLogger(LogPrefix.VOICE);
39
+ function prefixWithDiscordUser({ username, prompt, }) {
40
+ return `${prompt}\n<discord-user name="${sanitizeForXml(username)}" />`;
41
+ }
42
+ // In-memory rate limit tracker: userId -> timestamps of recent interactions
43
+ const rateLimitMap = new Map();
44
+ // Cleanup old entries every 5 minutes
45
+ setInterval(() => {
46
+ const now = Date.now();
47
+ const oneMinuteAgo = now - 60_000;
48
+ for (const [userId, entry] of rateLimitMap) {
49
+ entry.timestamps = entry.timestamps.filter((ts) => {
50
+ return ts > oneMinuteAgo;
51
+ });
52
+ if (entry.timestamps.length === 0) {
53
+ rateLimitMap.delete(userId);
54
+ }
55
+ }
56
+ }, 5 * 60_000);
57
+ /**
58
+ * Check if a user is rate limited.
59
+ * Returns null if not limited, or a message string if limited.
60
+ */
61
+ function checkRateLimit(userId) {
62
+ const config = getRateLimitConfig();
63
+ const now = Date.now();
64
+ const oneMinuteAgo = now - 60_000;
65
+ let entry = rateLimitMap.get(userId);
66
+ if (!entry) {
67
+ entry = { timestamps: [] };
68
+ rateLimitMap.set(userId, entry);
69
+ }
70
+ // Filter to only recent timestamps
71
+ entry.timestamps = entry.timestamps.filter((ts) => {
72
+ return ts > oneMinuteAgo;
73
+ });
74
+ if (entry.timestamps.length >= config.messagesPerMinute) {
75
+ const oldestTs = entry.timestamps[0] || now;
76
+ const waitSeconds = Math.ceil((oldestTs + 60_000 - now) / 1000);
77
+ return `Rate limited. Please wait ${waitSeconds} second${waitSeconds === 1 ? '' : 's'} before sending another message.`;
78
+ }
79
+ // Record this interaction
80
+ entry.timestamps.push(now);
81
+ return null;
82
+ }
83
+ export async function createDiscordClient() {
84
+ return new Client({
85
+ intents: [
86
+ GatewayIntentBits.Guilds,
87
+ GatewayIntentBits.GuildMessages,
88
+ GatewayIntentBits.MessageContent,
89
+ GatewayIntentBits.GuildVoiceStates,
90
+ GatewayIntentBits.GuildMessageReactions,
91
+ ],
92
+ partials: [
93
+ Partials.Channel,
94
+ Partials.Message,
95
+ Partials.User,
96
+ Partials.ThreadMember,
97
+ Partials.Reaction,
98
+ ],
99
+ });
100
+ }
101
+ export async function startDiscordBot({ token, appId, discordClient, useWorktrees, }) {
102
+ if (!discordClient) {
103
+ discordClient = await createDiscordClient();
104
+ }
105
+ let currentAppId = appId;
106
+ const setupHandlers = async (c) => {
107
+ discordLogger.log(`Discord bot logged in as ${c.user.tag}`);
108
+ discordLogger.log(`Connected to ${c.guilds.cache.size} guild(s)`);
109
+ discordLogger.log(`Bot user ID: ${c.user.id}`);
110
+ if (!currentAppId) {
111
+ await c.application?.fetch();
112
+ currentAppId = c.application?.id;
113
+ if (!currentAppId) {
114
+ discordLogger.error('Could not get application ID');
115
+ throw new Error('Failed to get bot application ID');
116
+ }
117
+ discordLogger.log(`Bot Application ID (fetched): ${currentAppId}`);
118
+ }
119
+ else {
120
+ discordLogger.log(`Bot Application ID (provided): ${currentAppId}`);
121
+ }
122
+ for (const guild of c.guilds.cache.values()) {
123
+ discordLogger.log(`${guild.name} (${guild.id})`);
124
+ const channels = await getChannelsWithDescriptions(guild);
125
+ const disundayChannels = channels.filter((ch) => ch.disundayDirectory &&
126
+ (!ch.disundayApp || ch.disundayApp === currentAppId));
127
+ if (disundayChannels.length > 0) {
128
+ discordLogger.log(` Found ${disundayChannels.length} channel(s) for this bot:`);
129
+ for (const channel of disundayChannels) {
130
+ discordLogger.log(` - #${channel.name}: ${channel.disundayDirectory}`);
131
+ }
132
+ }
133
+ else {
134
+ discordLogger.log(` No channels for this bot`);
135
+ }
136
+ }
137
+ voiceLogger.log(`[READY] Bot is ready and will only respond to channels with app ID: ${currentAppId}`);
138
+ registerInteractionHandler({ discordClient: c, appId: currentAppId });
139
+ registerVoiceStateHandler({ discordClient: c, appId: currentAppId });
140
+ registerReactionHandler({ discordClient: c, appId: currentAppId });
141
+ startScheduler(c);
142
+ const projectDirectories = new Set();
143
+ for (const guild of c.guilds.cache.values()) {
144
+ const channels = await getChannelsWithDescriptions(guild);
145
+ for (const ch of channels) {
146
+ if (ch.disundayDirectory &&
147
+ (!ch.disundayApp || ch.disundayApp === currentAppId)) {
148
+ projectDirectories.add(ch.disundayDirectory);
149
+ }
150
+ }
151
+ }
152
+ if (projectDirectories.size > 0) {
153
+ discordLogger.log(`[CACHE] Warming session cache for ${projectDirectories.size} project(s)...`);
154
+ void Promise.all(Array.from(projectDirectories).map((dir) => {
155
+ return refreshSessionCache(dir).catch(() => { });
156
+ })).then(() => {
157
+ discordLogger.log(`[CACHE] Session cache warmed for ${projectDirectories.size} project(s)`);
158
+ });
159
+ }
160
+ };
161
+ // If client is already ready (was logged in before being passed to us),
162
+ // run setup immediately. Otherwise wait for the ClientReady event.
163
+ if (discordClient.isReady()) {
164
+ await setupHandlers(discordClient);
165
+ }
166
+ else {
167
+ discordClient.once(Events.ClientReady, setupHandlers);
168
+ }
169
+ discordClient.on(Events.MessageCreate, async (message) => {
170
+ try {
171
+ if (message.author?.bot) {
172
+ return;
173
+ }
174
+ // Ignore messages that start with a mention of another user (not the bot).
175
+ // These are likely users talking to each other, not the bot.
176
+ const leadingMentionMatch = message.content?.match(/^<@!?(\d+)>/);
177
+ if (leadingMentionMatch) {
178
+ const mentionedUserId = leadingMentionMatch[1];
179
+ if (mentionedUserId !== discordClient.user?.id) {
180
+ return;
181
+ }
182
+ }
183
+ if (message.partial) {
184
+ discordLogger.log(`Fetching partial message ${message.id}`);
185
+ const fetched = await errore.tryAsync({
186
+ try: () => message.fetch(),
187
+ catch: (e) => e,
188
+ });
189
+ if (fetched instanceof Error) {
190
+ discordLogger.log(`Failed to fetch partial message ${message.id}:`, fetched.message);
191
+ return;
192
+ }
193
+ }
194
+ if (message.guild && message.member) {
195
+ // Check for "no-disunday" role first - blocks user regardless of other permissions.
196
+ // This implements the "four-eyes principle": even owners must remove this role
197
+ // to use the bot, adding friction to prevent accidental usage.
198
+ const hasNoDisundayRole = message.member.roles.cache.some((role) => role.name.toLowerCase() === 'no-disunday');
199
+ if (hasNoDisundayRole) {
200
+ await message.reply({
201
+ content: `You have the **no-disunday** role which blocks bot access.\nRemove this role to use Disunday.`,
202
+ flags: SILENT_MESSAGE_FLAGS,
203
+ });
204
+ return;
205
+ }
206
+ const isOwner = message.member.id === message.guild.ownerId;
207
+ const isAdmin = message.member.permissions.has(PermissionsBitField.Flags.Administrator);
208
+ const canManageServer = message.member.permissions.has(PermissionsBitField.Flags.ManageGuild);
209
+ const hasDisundayRole = message.member.roles.cache.some((role) => role.name.toLowerCase() === 'disunday');
210
+ if (!isOwner && !isAdmin && !canManageServer && !hasDisundayRole) {
211
+ await message.reply({
212
+ content: `You don't have permission to start sessions.\nTo use Disunday, ask a server admin to give you the **Disunday** role.`,
213
+ flags: SILENT_MESSAGE_FLAGS,
214
+ });
215
+ return;
216
+ }
217
+ }
218
+ const rateLimitMessage = checkRateLimit(message.author.id);
219
+ if (rateLimitMessage) {
220
+ await message.reply({
221
+ content: rateLimitMessage,
222
+ flags: SILENT_MESSAGE_FLAGS,
223
+ });
224
+ return;
225
+ }
226
+ const channel = message.channel;
227
+ const isThread = [
228
+ ChannelType.PublicThread,
229
+ ChannelType.PrivateThread,
230
+ ChannelType.AnnouncementThread,
231
+ ].includes(channel.type);
232
+ if (isThread) {
233
+ const thread = channel;
234
+ discordLogger.log(`Message in thread ${thread.name} (${thread.id})`);
235
+ const parent = thread.parent;
236
+ let projectDirectory;
237
+ let channelAppId;
238
+ if (parent) {
239
+ const channelConfig = getChannelDirectory(parent.id);
240
+ if (channelConfig) {
241
+ projectDirectory = channelConfig.directory;
242
+ channelAppId = channelConfig.appId || undefined;
243
+ }
244
+ }
245
+ // Check if this thread is a worktree thread
246
+ const worktreeInfo = getThreadWorktree(thread.id);
247
+ if (worktreeInfo) {
248
+ if (worktreeInfo.status === 'pending') {
249
+ await message.reply({
250
+ content: '⏳ Worktree is still being created. Please wait...',
251
+ flags: SILENT_MESSAGE_FLAGS,
252
+ });
253
+ return;
254
+ }
255
+ if (worktreeInfo.status === 'error') {
256
+ await message.reply({
257
+ content: `❌ Worktree creation failed: ${worktreeInfo.error_message}`,
258
+ flags: SILENT_MESSAGE_FLAGS,
259
+ });
260
+ return;
261
+ }
262
+ // Use original project directory for OpenCode server (session lives there)
263
+ // The worktree directory is passed via query.directory in prompt/command calls
264
+ if (worktreeInfo.project_directory) {
265
+ projectDirectory = worktreeInfo.project_directory;
266
+ discordLogger.log(`Using project directory: ${projectDirectory} (worktree: ${worktreeInfo.worktree_directory})`);
267
+ }
268
+ }
269
+ if (channelAppId && channelAppId !== currentAppId) {
270
+ voiceLogger.log(`[IGNORED] Thread belongs to different bot app (expected: ${currentAppId}, got: ${channelAppId})`);
271
+ return;
272
+ }
273
+ if (projectDirectory && !fs.existsSync(projectDirectory)) {
274
+ discordLogger.error(`Directory does not exist: ${projectDirectory}`);
275
+ await message.reply({
276
+ content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory)}`,
277
+ flags: SILENT_MESSAGE_FLAGS,
278
+ });
279
+ return;
280
+ }
281
+ const row = getDatabase()
282
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
283
+ .get(thread.id);
284
+ // No existing session - start a new one (e.g., replying to a notification thread)
285
+ if (!row) {
286
+ discordLogger.log(`No session for thread ${thread.id}, starting new session`);
287
+ if (!projectDirectory) {
288
+ discordLogger.log(`Cannot start session: no project directory for thread ${thread.id}`);
289
+ return;
290
+ }
291
+ // Include starter message as context for the session
292
+ let prompt = message.content;
293
+ const starterMessage = await thread
294
+ .fetchStarterMessage()
295
+ .catch((error) => {
296
+ discordLogger.warn(`[SESSION] Failed to fetch starter message for thread ${thread.id}:`, error instanceof Error ? error.message : String(error));
297
+ return null;
298
+ });
299
+ if (starterMessage?.content &&
300
+ starterMessage.content !== message.content) {
301
+ prompt = `Context from thread:\n${starterMessage.content}\n\nUser request:\n${message.content}`;
302
+ }
303
+ await handleOpencodeSession({
304
+ prompt: prefixWithDiscordUser({
305
+ username: message.member?.displayName || message.author.displayName,
306
+ prompt,
307
+ }),
308
+ thread,
309
+ projectDirectory,
310
+ channelId: parent?.id || '',
311
+ });
312
+ return;
313
+ }
314
+ voiceLogger.log(`[SESSION] Found session ${row.session_id} for thread ${thread.id}`);
315
+ let messageContent = message.content || '';
316
+ let currentSessionContext;
317
+ let lastSessionContext;
318
+ if (projectDirectory) {
319
+ try {
320
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
321
+ if (getClient instanceof Error) {
322
+ voiceLogger.error(`[SESSION] Failed to initialize OpenCode client:`, getClient.message);
323
+ throw new Error(getClient.message);
324
+ }
325
+ const client = getClient();
326
+ // get current session context (without system prompt, it would be duplicated)
327
+ if (row.session_id) {
328
+ const result = await getCompactSessionContext({
329
+ client,
330
+ sessionId: row.session_id,
331
+ includeSystemPrompt: false,
332
+ maxMessages: 15,
333
+ });
334
+ if (errore.isOk(result)) {
335
+ currentSessionContext = result;
336
+ }
337
+ }
338
+ // get last session context (with system prompt for project context)
339
+ const lastSessionResult = await getLastSessionId({
340
+ client,
341
+ excludeSessionId: row.session_id,
342
+ });
343
+ const lastSessionId = errore.unwrapOr(lastSessionResult, null);
344
+ if (lastSessionId) {
345
+ const result = await getCompactSessionContext({
346
+ client,
347
+ sessionId: lastSessionId,
348
+ includeSystemPrompt: true,
349
+ maxMessages: 10,
350
+ });
351
+ if (errore.isOk(result)) {
352
+ lastSessionContext = result;
353
+ }
354
+ }
355
+ }
356
+ catch (e) {
357
+ voiceLogger.error(`Could not get session context:`, e);
358
+ }
359
+ }
360
+ const transcription = await processVoiceAttachment({
361
+ message,
362
+ thread,
363
+ projectDirectory,
364
+ appId: currentAppId,
365
+ currentSessionContext,
366
+ lastSessionContext,
367
+ });
368
+ if (transcription) {
369
+ messageContent = transcription;
370
+ }
371
+ const fileAttachments = await getFileAttachments(message);
372
+ const textAttachmentsContent = await getTextAttachments(message);
373
+ const promptWithAttachments = textAttachmentsContent
374
+ ? `${messageContent}\n\n${textAttachmentsContent}`
375
+ : messageContent;
376
+ await handleOpencodeSession({
377
+ prompt: prefixWithDiscordUser({
378
+ username: message.member?.displayName || message.author.displayName,
379
+ prompt: promptWithAttachments,
380
+ }),
381
+ thread,
382
+ projectDirectory,
383
+ originalMessage: message,
384
+ images: fileAttachments,
385
+ channelId: parent?.id,
386
+ });
387
+ return;
388
+ }
389
+ if (channel.type === ChannelType.GuildText) {
390
+ const textChannel = channel;
391
+ voiceLogger.log(`[GUILD_TEXT] Message in text channel #${textChannel.name} (${textChannel.id})`);
392
+ const channelConfig = getChannelDirectory(textChannel.id);
393
+ if (!channelConfig) {
394
+ voiceLogger.log(`[IGNORED] Channel #${textChannel.name} has no project directory configured`);
395
+ return;
396
+ }
397
+ const projectDirectory = channelConfig.directory;
398
+ const channelAppId = channelConfig.appId || undefined;
399
+ if (channelAppId && channelAppId !== currentAppId) {
400
+ voiceLogger.log(`[IGNORED] Channel belongs to different bot app (expected: ${currentAppId}, got: ${channelAppId})`);
401
+ return;
402
+ }
403
+ discordLogger.log(`DIRECTORY: Found disunday.directory: ${projectDirectory}`);
404
+ if (channelAppId) {
405
+ discordLogger.log(`APP: Channel app ID: ${channelAppId}`);
406
+ }
407
+ if (!fs.existsSync(projectDirectory)) {
408
+ discordLogger.error(`Directory does not exist: ${projectDirectory}`);
409
+ await message.reply({
410
+ content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory)}`,
411
+ flags: SILENT_MESSAGE_FLAGS,
412
+ });
413
+ return;
414
+ }
415
+ const hasVoice = message.attachments.some((a) => a.contentType?.startsWith('audio/'));
416
+ const baseThreadName = hasVoice
417
+ ? 'Voice Message'
418
+ : message.content?.replace(/\s+/g, ' ').trim() || 'Claude Thread';
419
+ // Check if worktrees should be enabled (CLI flag OR channel setting)
420
+ const shouldUseWorktrees = useWorktrees || getChannelWorktreesEnabled(textChannel.id);
421
+ // Add worktree prefix if worktrees are enabled
422
+ const threadName = shouldUseWorktrees
423
+ ? `${WORKTREE_PREFIX}${baseThreadName}`
424
+ : baseThreadName;
425
+ const thread = await message.startThread({
426
+ name: threadName.slice(0, 80),
427
+ autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
428
+ reason: 'Start Claude session',
429
+ });
430
+ // Add user to thread so it appears in their sidebar
431
+ await thread.members.add(message.author.id);
432
+ discordLogger.log(`Created thread "${thread.name}" (${thread.id})`);
433
+ // Create worktree if worktrees are enabled (CLI flag OR channel setting)
434
+ let sessionDirectory = projectDirectory;
435
+ if (shouldUseWorktrees) {
436
+ const worktreeName = formatWorktreeName(hasVoice ? `voice-${Date.now()}` : threadName.slice(0, 50));
437
+ discordLogger.log(`[WORKTREE] Creating worktree: ${worktreeName}`);
438
+ // Store pending worktree immediately so bot knows about it
439
+ createPendingWorktree({
440
+ threadId: thread.id,
441
+ worktreeName,
442
+ projectDirectory,
443
+ });
444
+ // Initialize OpenCode and create worktree
445
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
446
+ if (getClient instanceof Error) {
447
+ discordLogger.error(`[WORKTREE] Failed to init OpenCode: ${getClient.message}`);
448
+ setWorktreeError({
449
+ threadId: thread.id,
450
+ errorMessage: getClient.message,
451
+ });
452
+ await thread.send({
453
+ content: `⚠️ Failed to create worktree: ${getClient.message}\nUsing main project directory instead.`,
454
+ flags: SILENT_MESSAGE_FLAGS,
455
+ });
456
+ }
457
+ else {
458
+ const clientV2 = getOpencodeClientV2(projectDirectory);
459
+ if (!clientV2) {
460
+ discordLogger.error(`[WORKTREE] No v2 client for ${projectDirectory}`);
461
+ setWorktreeError({
462
+ threadId: thread.id,
463
+ errorMessage: 'No OpenCode v2 client',
464
+ });
465
+ }
466
+ else {
467
+ const worktreeResult = await createWorktreeWithSubmodules({
468
+ clientV2,
469
+ directory: projectDirectory,
470
+ name: worktreeName,
471
+ });
472
+ if (worktreeResult instanceof Error) {
473
+ const errMsg = worktreeResult.message;
474
+ discordLogger.error(`[WORKTREE] Creation failed: ${errMsg}`);
475
+ setWorktreeError({ threadId: thread.id, errorMessage: errMsg });
476
+ await thread.send({
477
+ content: `⚠️ Failed to create worktree: ${errMsg}\nUsing main project directory instead.`,
478
+ flags: SILENT_MESSAGE_FLAGS,
479
+ });
480
+ }
481
+ else {
482
+ setWorktreeReady({
483
+ threadId: thread.id,
484
+ worktreeDirectory: worktreeResult.directory,
485
+ });
486
+ sessionDirectory = worktreeResult.directory;
487
+ discordLogger.log(`[WORKTREE] Created: ${worktreeResult.directory} (branch: ${worktreeResult.branch})`);
488
+ }
489
+ }
490
+ }
491
+ }
492
+ let messageContent = message.content || '';
493
+ const transcription = await processVoiceAttachment({
494
+ message,
495
+ thread,
496
+ projectDirectory: sessionDirectory,
497
+ isNewThread: true,
498
+ appId: currentAppId,
499
+ });
500
+ if (transcription) {
501
+ messageContent = transcription;
502
+ }
503
+ const fileAttachments = await getFileAttachments(message);
504
+ const textAttachmentsContent = await getTextAttachments(message);
505
+ const promptWithAttachments = textAttachmentsContent
506
+ ? `${messageContent}\n\n${textAttachmentsContent}`
507
+ : messageContent;
508
+ await handleOpencodeSession({
509
+ prompt: prefixWithDiscordUser({
510
+ username: message.member?.displayName || message.author.displayName,
511
+ prompt: promptWithAttachments,
512
+ }),
513
+ thread,
514
+ projectDirectory: sessionDirectory,
515
+ originalMessage: message,
516
+ images: fileAttachments,
517
+ channelId: textChannel.id,
518
+ });
519
+ }
520
+ else {
521
+ discordLogger.log(`Channel type ${channel.type} is not supported`);
522
+ }
523
+ }
524
+ catch (error) {
525
+ voiceLogger.error('Discord handler error:', getErrorForLogging(error));
526
+ try {
527
+ const errMsg = sanitizeErrorForUser(error);
528
+ await message.reply({
529
+ content: `Error: ${errMsg}`,
530
+ flags: SILENT_MESSAGE_FLAGS,
531
+ });
532
+ }
533
+ catch (sendError) {
534
+ voiceLogger.error('Discord handler error (fallback):', getErrorForLogging(sendError));
535
+ }
536
+ }
537
+ });
538
+ // Handle bot-initiated threads created by `disunday send` (without --notify-only)
539
+ // Uses embed marker instead of database to avoid race conditions
540
+ const AUTO_START_MARKER = 'disunday:start';
541
+ discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
542
+ try {
543
+ if (!newlyCreated) {
544
+ return;
545
+ }
546
+ // Only handle threads in text channels
547
+ const parent = thread.parent;
548
+ if (!parent || parent.type !== ChannelType.GuildText) {
549
+ return;
550
+ }
551
+ // Get the starter message to check for auto-start marker
552
+ const starterMessage = await thread
553
+ .fetchStarterMessage()
554
+ .catch((error) => {
555
+ discordLogger.warn(`[THREAD_CREATE] Failed to fetch starter message for thread ${thread.id}:`, error instanceof Error ? error.message : String(error));
556
+ return null;
557
+ });
558
+ if (!starterMessage) {
559
+ discordLogger.log(`[THREAD_CREATE] Could not fetch starter message for thread ${thread.id}`);
560
+ return;
561
+ }
562
+ // Check if starter message has the auto-start embed marker
563
+ const hasAutoStartMarker = starterMessage.embeds.some((embed) => embed.footer?.text === AUTO_START_MARKER);
564
+ if (!hasAutoStartMarker) {
565
+ return; // Not a CLI-initiated auto-start thread
566
+ }
567
+ discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`);
568
+ const prompt = starterMessage.content.trim();
569
+ if (!prompt) {
570
+ discordLogger.log(`[BOT_SESSION] No prompt found in starter message`);
571
+ return;
572
+ }
573
+ // Get directory from database
574
+ const channelConfig = getChannelDirectory(parent.id);
575
+ if (!channelConfig) {
576
+ discordLogger.log(`[BOT_SESSION] No project directory configured for parent channel`);
577
+ return;
578
+ }
579
+ const projectDirectory = channelConfig.directory;
580
+ const channelAppId = channelConfig.appId || undefined;
581
+ if (channelAppId && channelAppId !== currentAppId) {
582
+ discordLogger.log(`[BOT_SESSION] Channel belongs to different bot app`);
583
+ return;
584
+ }
585
+ if (!fs.existsSync(projectDirectory)) {
586
+ discordLogger.error(`[BOT_SESSION] Directory does not exist: ${projectDirectory}`);
587
+ await thread.send({
588
+ content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory)}`,
589
+ flags: SILENT_MESSAGE_FLAGS,
590
+ });
591
+ return;
592
+ }
593
+ discordLogger.log(`[BOT_SESSION] Starting session for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}..."`);
594
+ await handleOpencodeSession({
595
+ prompt,
596
+ thread,
597
+ projectDirectory,
598
+ channelId: parent.id,
599
+ });
600
+ }
601
+ catch (error) {
602
+ voiceLogger.error('[BOT_SESSION] Error handling bot-initiated thread:', getErrorForLogging(error));
603
+ try {
604
+ const errMsg = sanitizeErrorForUser(error);
605
+ await thread.send({
606
+ content: `Error: ${errMsg}`,
607
+ flags: SILENT_MESSAGE_FLAGS,
608
+ });
609
+ }
610
+ catch (sendError) {
611
+ voiceLogger.error('[BOT_SESSION] Failed to send error message:', getErrorForLogging(sendError));
612
+ }
613
+ }
614
+ });
615
+ await discordClient.login(token);
616
+ const handleShutdown = async (signal, { skipExit = false } = {}) => {
617
+ discordLogger.log(`Received ${signal}, cleaning up...`);
618
+ if (global.shuttingDown) {
619
+ discordLogger.log('Already shutting down, ignoring duplicate signal');
620
+ return;
621
+ }
622
+ ;
623
+ global.shuttingDown = true;
624
+ try {
625
+ const cleanupPromises = [];
626
+ for (const [guildId] of voiceConnections) {
627
+ voiceLogger.log(`[SHUTDOWN] Cleaning up voice connection for guild ${guildId}`);
628
+ cleanupPromises.push(cleanupVoiceConnection(guildId));
629
+ }
630
+ if (cleanupPromises.length > 0) {
631
+ voiceLogger.log(`[SHUTDOWN] Waiting for ${cleanupPromises.length} voice connection(s) to clean up...`);
632
+ await Promise.allSettled(cleanupPromises);
633
+ discordLogger.log(`All voice connections cleaned up`);
634
+ }
635
+ for (const [dir, server] of getOpencodeServers()) {
636
+ if (!server.process.killed) {
637
+ voiceLogger.log(`[SHUTDOWN] Stopping OpenCode server on port ${server.port} for ${dir}`);
638
+ server.process.kill('SIGTERM');
639
+ }
640
+ }
641
+ getOpencodeServers().clear();
642
+ discordLogger.log('Closing database...');
643
+ closeDatabase();
644
+ discordLogger.log('Destroying Discord client...');
645
+ discordClient.destroy();
646
+ discordLogger.log('Cleanup complete.');
647
+ if (!skipExit) {
648
+ process.exit(0);
649
+ }
650
+ }
651
+ catch (error) {
652
+ voiceLogger.error('[SHUTDOWN] Error during cleanup:', error);
653
+ if (!skipExit) {
654
+ process.exit(1);
655
+ }
656
+ }
657
+ };
658
+ process.on('SIGTERM', async () => {
659
+ try {
660
+ await handleShutdown('SIGTERM');
661
+ }
662
+ catch (error) {
663
+ voiceLogger.error('[SIGTERM] Error during shutdown:', error);
664
+ process.exit(1);
665
+ }
666
+ });
667
+ process.on('SIGINT', async () => {
668
+ try {
669
+ await handleShutdown('SIGINT');
670
+ }
671
+ catch (error) {
672
+ voiceLogger.error('[SIGINT] Error during shutdown:', error);
673
+ process.exit(1);
674
+ }
675
+ });
676
+ process.on('SIGUSR2', async () => {
677
+ discordLogger.log('Received SIGUSR2, restarting after cleanup...');
678
+ try {
679
+ await handleShutdown('SIGUSR2', { skipExit: true });
680
+ }
681
+ catch (error) {
682
+ voiceLogger.error('[SIGUSR2] Error during shutdown:', error);
683
+ }
684
+ const { spawn } = await import('node:child_process');
685
+ spawn(process.argv[0], [...process.execArgv, ...process.argv.slice(1)], {
686
+ stdio: 'inherit',
687
+ detached: true,
688
+ cwd: process.cwd(),
689
+ env: process.env,
690
+ }).unref();
691
+ process.exit(0);
692
+ });
693
+ process.on('unhandledRejection', (reason, promise) => {
694
+ if (global.shuttingDown) {
695
+ discordLogger.log('Ignoring unhandled rejection during shutdown:', reason);
696
+ return;
697
+ }
698
+ discordLogger.error('Unhandled Rejection at:', promise, 'reason:', reason);
699
+ });
700
+ }