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,325 @@
1
+ // /model command - Set the preferred model for this channel or session.
2
+ import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, } from 'discord.js';
3
+ import crypto from 'node:crypto';
4
+ import { getDatabase, setChannelModel, setSessionModel, runModelMigrations } from '../database.js';
5
+ import { initializeOpencodeForDirectory } from '../opencode.js';
6
+ import { resolveTextChannel, getDisundayMetadata } from '../discord-utils.js';
7
+ import { abortAndRetrySession } from '../session-handler.js';
8
+ import { createLogger, LogPrefix } from '../logger.js';
9
+ import * as errore from 'errore';
10
+ const modelLogger = createLogger(LogPrefix.MODEL);
11
+ // Store context by hash to avoid customId length limits (Discord max: 100 chars)
12
+ const pendingModelContexts = new Map();
13
+ /**
14
+ * Handle the /model slash command.
15
+ * Shows a select menu with available providers.
16
+ */
17
+ export async function handleModelCommand({ interaction, appId, }) {
18
+ modelLogger.log('[MODEL] handleModelCommand called');
19
+ // Defer reply immediately to avoid 3-second timeout
20
+ await interaction.deferReply({ ephemeral: true });
21
+ modelLogger.log('[MODEL] Deferred reply');
22
+ // Ensure migrations are run
23
+ runModelMigrations();
24
+ const channel = interaction.channel;
25
+ if (!channel) {
26
+ await interaction.editReply({
27
+ content: 'This command can only be used in a channel',
28
+ });
29
+ return;
30
+ }
31
+ // Determine if we're in a thread or text channel
32
+ const isThread = [
33
+ ChannelType.PublicThread,
34
+ ChannelType.PrivateThread,
35
+ ChannelType.AnnouncementThread,
36
+ ].includes(channel.type);
37
+ let projectDirectory;
38
+ let channelAppId;
39
+ let targetChannelId;
40
+ let sessionId;
41
+ if (isThread) {
42
+ const thread = channel;
43
+ const textChannel = await resolveTextChannel(thread);
44
+ const metadata = getDisundayMetadata(textChannel);
45
+ projectDirectory = metadata.projectDirectory;
46
+ channelAppId = metadata.channelAppId;
47
+ targetChannelId = textChannel?.id || channel.id;
48
+ // Get session ID for this thread
49
+ const row = getDatabase()
50
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
51
+ .get(thread.id);
52
+ sessionId = row?.session_id;
53
+ }
54
+ else if (channel.type === ChannelType.GuildText) {
55
+ const textChannel = channel;
56
+ const metadata = getDisundayMetadata(textChannel);
57
+ projectDirectory = metadata.projectDirectory;
58
+ channelAppId = metadata.channelAppId;
59
+ targetChannelId = channel.id;
60
+ }
61
+ else {
62
+ await interaction.editReply({
63
+ content: 'This command can only be used in text channels or threads',
64
+ });
65
+ return;
66
+ }
67
+ if (channelAppId && channelAppId !== appId) {
68
+ await interaction.editReply({
69
+ content: 'This channel is not configured for this bot',
70
+ });
71
+ return;
72
+ }
73
+ if (!projectDirectory) {
74
+ await interaction.editReply({
75
+ content: 'This channel is not configured with a project directory',
76
+ });
77
+ return;
78
+ }
79
+ try {
80
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
81
+ if (getClient instanceof Error) {
82
+ await interaction.editReply({ content: getClient.message });
83
+ return;
84
+ }
85
+ const providersResponse = await getClient().provider.list({
86
+ query: { directory: projectDirectory },
87
+ });
88
+ if (!providersResponse.data) {
89
+ await interaction.editReply({
90
+ content: 'Failed to fetch providers',
91
+ });
92
+ return;
93
+ }
94
+ const { all: allProviders, connected } = providersResponse.data;
95
+ // Filter to only connected providers (have credentials)
96
+ const availableProviders = allProviders.filter((p) => {
97
+ return connected.includes(p.id);
98
+ });
99
+ if (availableProviders.length === 0) {
100
+ await interaction.editReply({
101
+ content: 'No providers with credentials found. Use `/login` to connect a provider and add credentials.',
102
+ });
103
+ return;
104
+ }
105
+ // Store context with a short hash key to avoid customId length limits
106
+ const context = {
107
+ dir: projectDirectory,
108
+ channelId: targetChannelId,
109
+ sessionId: sessionId,
110
+ isThread: isThread,
111
+ thread: isThread ? channel : undefined,
112
+ };
113
+ const contextHash = crypto.randomBytes(8).toString('hex');
114
+ pendingModelContexts.set(contextHash, context);
115
+ const options = availableProviders.slice(0, 25).map((provider) => {
116
+ const modelCount = Object.keys(provider.models || {}).length;
117
+ return {
118
+ label: provider.name.slice(0, 100),
119
+ value: provider.id,
120
+ description: `${modelCount} model${modelCount !== 1 ? 's' : ''} available`.slice(0, 100),
121
+ };
122
+ });
123
+ const selectMenu = new StringSelectMenuBuilder()
124
+ .setCustomId(`model_provider:${contextHash}`)
125
+ .setPlaceholder('Select a provider')
126
+ .addOptions(options);
127
+ const actionRow = new ActionRowBuilder().addComponents(selectMenu);
128
+ await interaction.editReply({
129
+ content: '**Set Model Preference**\nSelect a provider:',
130
+ components: [actionRow],
131
+ });
132
+ }
133
+ catch (error) {
134
+ modelLogger.error('Error loading providers:', error);
135
+ await interaction.editReply({
136
+ content: `Failed to load providers: ${error instanceof Error ? error.message : 'Unknown error'}`,
137
+ });
138
+ }
139
+ }
140
+ /**
141
+ * Handle the provider select menu interaction.
142
+ * Shows a second select menu with models for the chosen provider.
143
+ */
144
+ export async function handleProviderSelectMenu(interaction) {
145
+ const customId = interaction.customId;
146
+ if (!customId.startsWith('model_provider:')) {
147
+ return;
148
+ }
149
+ // Defer update immediately to avoid timeout
150
+ await interaction.deferUpdate();
151
+ const contextHash = customId.replace('model_provider:', '');
152
+ const context = pendingModelContexts.get(contextHash);
153
+ if (!context) {
154
+ await interaction.editReply({
155
+ content: 'Selection expired. Please run /model again.',
156
+ components: [],
157
+ });
158
+ return;
159
+ }
160
+ const selectedProviderId = interaction.values[0];
161
+ if (!selectedProviderId) {
162
+ await interaction.editReply({
163
+ content: 'No provider selected',
164
+ components: [],
165
+ });
166
+ return;
167
+ }
168
+ try {
169
+ const getClient = await initializeOpencodeForDirectory(context.dir);
170
+ if (getClient instanceof Error) {
171
+ await interaction.editReply({
172
+ content: getClient.message,
173
+ components: [],
174
+ });
175
+ return;
176
+ }
177
+ const providersResponse = await getClient().provider.list({
178
+ query: { directory: context.dir },
179
+ });
180
+ if (!providersResponse.data) {
181
+ await interaction.editReply({
182
+ content: 'Failed to fetch providers',
183
+ components: [],
184
+ });
185
+ return;
186
+ }
187
+ const provider = providersResponse.data.all.find((p) => p.id === selectedProviderId);
188
+ if (!provider) {
189
+ await interaction.editReply({
190
+ content: 'Provider not found',
191
+ components: [],
192
+ });
193
+ return;
194
+ }
195
+ const models = Object.entries(provider.models || {})
196
+ .map(([modelId, model]) => ({
197
+ id: modelId,
198
+ name: model.name,
199
+ releaseDate: model.release_date,
200
+ }))
201
+ // Sort by release date descending (most recent first)
202
+ .sort((a, b) => {
203
+ const dateA = a.releaseDate ? new Date(a.releaseDate).getTime() : 0;
204
+ const dateB = b.releaseDate ? new Date(b.releaseDate).getTime() : 0;
205
+ return dateB - dateA;
206
+ });
207
+ if (models.length === 0) {
208
+ await interaction.editReply({
209
+ content: `No models available for ${provider.name}`,
210
+ components: [],
211
+ });
212
+ return;
213
+ }
214
+ // Take first 25 models (most recent since sorted descending)
215
+ const recentModels = models.slice(0, 25);
216
+ // Update context with provider info and reuse the same hash
217
+ context.providerId = selectedProviderId;
218
+ context.providerName = provider.name;
219
+ pendingModelContexts.set(contextHash, context);
220
+ const options = recentModels.map((model) => {
221
+ const dateStr = model.releaseDate
222
+ ? new Date(model.releaseDate).toLocaleDateString()
223
+ : 'Unknown date';
224
+ return {
225
+ label: model.name.slice(0, 100),
226
+ value: model.id,
227
+ description: dateStr.slice(0, 100),
228
+ };
229
+ });
230
+ const selectMenu = new StringSelectMenuBuilder()
231
+ .setCustomId(`model_select:${contextHash}`)
232
+ .setPlaceholder('Select a model')
233
+ .addOptions(options);
234
+ const actionRow = new ActionRowBuilder().addComponents(selectMenu);
235
+ await interaction.editReply({
236
+ content: `**Set Model Preference**\nProvider: **${provider.name}**\nSelect a model:`,
237
+ components: [actionRow],
238
+ });
239
+ }
240
+ catch (error) {
241
+ modelLogger.error('Error loading models:', error);
242
+ await interaction.editReply({
243
+ content: `Failed to load models: ${error instanceof Error ? error.message : 'Unknown error'}`,
244
+ components: [],
245
+ });
246
+ }
247
+ }
248
+ /**
249
+ * Handle the model select menu interaction.
250
+ * Stores the model preference in the database.
251
+ */
252
+ export async function handleModelSelectMenu(interaction) {
253
+ const customId = interaction.customId;
254
+ if (!customId.startsWith('model_select:')) {
255
+ return;
256
+ }
257
+ // Defer update immediately
258
+ await interaction.deferUpdate();
259
+ const contextHash = customId.replace('model_select:', '');
260
+ const context = pendingModelContexts.get(contextHash);
261
+ if (!context || !context.providerId || !context.providerName) {
262
+ await interaction.editReply({
263
+ content: 'Selection expired. Please run /model again.',
264
+ components: [],
265
+ });
266
+ return;
267
+ }
268
+ const selectedModelId = interaction.values[0];
269
+ if (!selectedModelId) {
270
+ await interaction.editReply({
271
+ content: 'No model selected',
272
+ components: [],
273
+ });
274
+ return;
275
+ }
276
+ // Build full model ID: provider_id/model_id
277
+ const fullModelId = `${context.providerId}/${selectedModelId}`;
278
+ try {
279
+ // Store in appropriate table based on context
280
+ if (context.isThread && context.sessionId) {
281
+ // Store for session
282
+ setSessionModel(context.sessionId, fullModelId);
283
+ modelLogger.log(`Set model ${fullModelId} for session ${context.sessionId}`);
284
+ // Check if there's a running request and abort+retry with new model
285
+ let retried = false;
286
+ if (context.thread) {
287
+ retried = await abortAndRetrySession({
288
+ sessionId: context.sessionId,
289
+ thread: context.thread,
290
+ projectDirectory: context.dir,
291
+ });
292
+ }
293
+ if (retried) {
294
+ await interaction.editReply({
295
+ content: `Model changed for this session:\n**${context.providerName}** / **${selectedModelId}**\n\`${fullModelId}\`\n_Retrying current request with new model..._`,
296
+ components: [],
297
+ });
298
+ }
299
+ else {
300
+ await interaction.editReply({
301
+ content: `Model preference set for this session:\n**${context.providerName}** / **${selectedModelId}**\n\`${fullModelId}\``,
302
+ components: [],
303
+ });
304
+ }
305
+ }
306
+ else {
307
+ // Store for channel
308
+ setChannelModel(context.channelId, fullModelId);
309
+ modelLogger.log(`Set model ${fullModelId} for channel ${context.channelId}`);
310
+ await interaction.editReply({
311
+ content: `Model preference set for this channel:\n**${context.providerName}** / **${selectedModelId}**\n\`${fullModelId}\`\nAll new sessions in this channel will use this model.`,
312
+ components: [],
313
+ });
314
+ }
315
+ // Clean up the context from memory
316
+ pendingModelContexts.delete(contextHash);
317
+ }
318
+ catch (error) {
319
+ modelLogger.error('Error saving model preference:', error);
320
+ await interaction.editReply({
321
+ content: `Failed to save model preference: ${error instanceof Error ? error.message : 'Unknown error'}`,
322
+ components: [],
323
+ });
324
+ }
325
+ }
@@ -0,0 +1,140 @@
1
+ // Permission dropdown handler - Shows dropdown for permission requests.
2
+ // When OpenCode asks for permission, this module renders a dropdown
3
+ // with Accept, Accept Always, and Deny options.
4
+ import { StringSelectMenuBuilder, StringSelectMenuInteraction, ActionRowBuilder, } from 'discord.js';
5
+ import crypto from 'node:crypto';
6
+ import { getOpencodeClientV2 } from '../opencode.js';
7
+ import { NOTIFY_MESSAGE_FLAGS } from '../discord-utils.js';
8
+ import { createLogger, LogPrefix } from '../logger.js';
9
+ const logger = createLogger(LogPrefix.PERMISSIONS);
10
+ // Store pending permission contexts by hash
11
+ export const pendingPermissionContexts = new Map();
12
+ /**
13
+ * Show permission dropdown for a permission request.
14
+ * Returns the message ID and context hash for tracking.
15
+ */
16
+ export async function showPermissionDropdown({ thread, permission, directory, subtaskLabel, }) {
17
+ const contextHash = crypto.randomBytes(8).toString('hex');
18
+ const context = {
19
+ permission,
20
+ requestIds: [permission.id],
21
+ directory,
22
+ thread,
23
+ contextHash,
24
+ };
25
+ pendingPermissionContexts.set(contextHash, context);
26
+ const patternStr = permission.patterns.join(', ');
27
+ // Build dropdown options
28
+ const options = [
29
+ {
30
+ label: 'Accept',
31
+ value: 'once',
32
+ description: 'Allow this request only',
33
+ },
34
+ {
35
+ label: 'Accept Always',
36
+ value: 'always',
37
+ description: 'Auto-approve similar requests',
38
+ },
39
+ {
40
+ label: 'Deny',
41
+ value: 'reject',
42
+ description: 'Reject this permission request',
43
+ },
44
+ ];
45
+ const selectMenu = new StringSelectMenuBuilder()
46
+ .setCustomId(`permission:${contextHash}`)
47
+ .setPlaceholder('Choose an action')
48
+ .addOptions(options);
49
+ const actionRow = new ActionRowBuilder().addComponents(selectMenu);
50
+ const subtaskLine = subtaskLabel ? `**From:** \`${subtaskLabel}\`\n` : '';
51
+ const permissionMessage = await thread.send({
52
+ content: `โš ๏ธ **Permission Required**\n\n` +
53
+ subtaskLine +
54
+ `**Type:** \`${permission.permission}\`\n` +
55
+ (patternStr ? `**Pattern:** \`${patternStr}\`` : ''),
56
+ components: [actionRow],
57
+ flags: NOTIFY_MESSAGE_FLAGS,
58
+ });
59
+ logger.log(`Showed permission dropdown for ${permission.id}`);
60
+ return { messageId: permissionMessage.id, contextHash };
61
+ }
62
+ /**
63
+ * Handle dropdown selection for permission.
64
+ */
65
+ export async function handlePermissionSelectMenu(interaction) {
66
+ const customId = interaction.customId;
67
+ if (!customId.startsWith('permission:')) {
68
+ return;
69
+ }
70
+ const contextHash = customId.replace('permission:', '');
71
+ const context = pendingPermissionContexts.get(contextHash);
72
+ if (!context) {
73
+ await interaction.reply({
74
+ content: 'This permission request has expired or was already handled.',
75
+ ephemeral: true,
76
+ });
77
+ return;
78
+ }
79
+ await interaction.deferUpdate();
80
+ const response = interaction.values[0];
81
+ try {
82
+ const clientV2 = getOpencodeClientV2(context.directory);
83
+ if (!clientV2) {
84
+ throw new Error('OpenCode server not found for directory');
85
+ }
86
+ const requestIds = context.requestIds.length > 0 ? context.requestIds : [context.permission.id];
87
+ await Promise.all(requestIds.map((requestId) => {
88
+ return clientV2.permission.reply({
89
+ requestID: requestId,
90
+ reply: response,
91
+ });
92
+ }));
93
+ pendingPermissionContexts.delete(contextHash);
94
+ // Update message: show result and remove dropdown
95
+ const resultText = (() => {
96
+ switch (response) {
97
+ case 'once':
98
+ return 'โœ… Permission **accepted**';
99
+ case 'always':
100
+ return 'โœ… Permission **accepted** (auto-approve similar requests)';
101
+ case 'reject':
102
+ return 'โŒ Permission **rejected**';
103
+ }
104
+ })();
105
+ const patternStr = context.permission.patterns.join(', ');
106
+ await interaction.editReply({
107
+ content: `โš ๏ธ **Permission Required**\n\n` +
108
+ `**Type:** \`${context.permission.permission}\`\n` +
109
+ (patternStr ? `**Pattern:** \`${patternStr}\`\n\n` : '\n') +
110
+ resultText,
111
+ components: [], // Remove the dropdown
112
+ });
113
+ logger.log(`Permission ${context.permission.id} ${response} (${requestIds.length} request(s))`);
114
+ }
115
+ catch (error) {
116
+ logger.error('Error handling permission:', error);
117
+ await interaction.editReply({
118
+ content: `Failed to process permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
119
+ components: [],
120
+ });
121
+ }
122
+ }
123
+ export function addPermissionRequestToContext({ contextHash, requestId, }) {
124
+ const context = pendingPermissionContexts.get(contextHash);
125
+ if (!context) {
126
+ return false;
127
+ }
128
+ if (context.requestIds.includes(requestId)) {
129
+ return false;
130
+ }
131
+ context.requestIds = [...context.requestIds, requestId];
132
+ pendingPermissionContexts.set(contextHash, context);
133
+ return true;
134
+ }
135
+ /**
136
+ * Clean up a pending permission context (e.g., on auto-reject).
137
+ */
138
+ export function cleanupPermissionContext(contextHash) {
139
+ pendingPermissionContexts.delete(contextHash);
140
+ }
@@ -0,0 +1,13 @@
1
+ import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
2
+ export async function handlePingCommand({ command, }) {
3
+ const sent = await command.reply({
4
+ content: '๐Ÿ“ Pinging...',
5
+ fetchReply: true,
6
+ flags: SILENT_MESSAGE_FLAGS,
7
+ });
8
+ const latency = sent.createdTimestamp - command.createdTimestamp;
9
+ const wsLatency = command.client.ws.ping;
10
+ await command.editReply({
11
+ content: `๐Ÿ“ **Pong!**\n\n**Roundtrip:** ${latency}ms\n**WebSocket:** ${wsLatency}ms`,
12
+ });
13
+ }
@@ -0,0 +1,133 @@
1
+ // Queue commands - /queue, /clear-queue
2
+ import { ChannelType } from 'discord.js';
3
+ import { getDatabase } from '../database.js';
4
+ import { resolveTextChannel, getDisundayMetadata, sendThreadMessage, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
5
+ import { handleOpencodeSession, abortControllers, addToQueue, getQueueLength, clearQueue, } from '../session-handler.js';
6
+ import { createLogger, LogPrefix } from '../logger.js';
7
+ const logger = createLogger(LogPrefix.QUEUE);
8
+ export async function handleQueueCommand({ command }) {
9
+ const message = command.options.getString('message', true);
10
+ const channel = command.channel;
11
+ if (!channel) {
12
+ await command.reply({
13
+ content: 'This command can only be used in a channel',
14
+ ephemeral: true,
15
+ flags: SILENT_MESSAGE_FLAGS,
16
+ });
17
+ return;
18
+ }
19
+ const isThread = [
20
+ ChannelType.PublicThread,
21
+ ChannelType.PrivateThread,
22
+ ChannelType.AnnouncementThread,
23
+ ].includes(channel.type);
24
+ if (!isThread) {
25
+ await command.reply({
26
+ content: 'This command can only be used in a thread with an active session',
27
+ ephemeral: true,
28
+ flags: SILENT_MESSAGE_FLAGS,
29
+ });
30
+ return;
31
+ }
32
+ const row = getDatabase()
33
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
34
+ .get(channel.id);
35
+ if (!row?.session_id) {
36
+ await command.reply({
37
+ content: 'No active session in this thread. Send a message directly instead.',
38
+ ephemeral: true,
39
+ flags: SILENT_MESSAGE_FLAGS,
40
+ });
41
+ return;
42
+ }
43
+ // Check if there's an active request running
44
+ const existingController = abortControllers.get(row.session_id);
45
+ const hasActiveRequest = Boolean(existingController && !existingController.signal.aborted);
46
+ if (existingController && existingController.signal.aborted) {
47
+ abortControllers.delete(row.session_id);
48
+ }
49
+ if (!hasActiveRequest) {
50
+ // No active request, send immediately
51
+ const textChannel = await resolveTextChannel(channel);
52
+ const { projectDirectory } = getDisundayMetadata(textChannel);
53
+ if (!projectDirectory) {
54
+ await command.reply({
55
+ content: 'Could not determine project directory',
56
+ ephemeral: true,
57
+ flags: SILENT_MESSAGE_FLAGS,
58
+ });
59
+ return;
60
+ }
61
+ await command.reply({
62
+ content: `ยป **${command.user.displayName}:** ${message.slice(0, 100)}${message.length > 100 ? '...' : ''}`,
63
+ flags: SILENT_MESSAGE_FLAGS,
64
+ });
65
+ logger.log(`[QUEUE] No active request, sending immediately in thread ${channel.id}`);
66
+ handleOpencodeSession({
67
+ prompt: message,
68
+ thread: channel,
69
+ projectDirectory,
70
+ channelId: textChannel?.id || channel.id,
71
+ }).catch(async (e) => {
72
+ logger.error(`[QUEUE] Failed to send message:`, e);
73
+ const errorMsg = e instanceof Error ? e.message : String(e);
74
+ await sendThreadMessage(channel, `โœ— Failed: ${errorMsg.slice(0, 200)}`);
75
+ });
76
+ return;
77
+ }
78
+ // Add to queue
79
+ const queuePosition = addToQueue({
80
+ threadId: channel.id,
81
+ message: {
82
+ prompt: message,
83
+ userId: command.user.id,
84
+ username: command.user.displayName,
85
+ queuedAt: Date.now(),
86
+ },
87
+ });
88
+ await command.reply({
89
+ content: `โœ… Message queued (position: ${queuePosition}). Will be sent after current response.`,
90
+ ephemeral: true,
91
+ flags: SILENT_MESSAGE_FLAGS,
92
+ });
93
+ logger.log(`[QUEUE] User ${command.user.displayName} queued message in thread ${channel.id}`);
94
+ }
95
+ export async function handleClearQueueCommand({ command }) {
96
+ const channel = command.channel;
97
+ if (!channel) {
98
+ await command.reply({
99
+ content: 'This command can only be used in a channel',
100
+ ephemeral: true,
101
+ flags: SILENT_MESSAGE_FLAGS,
102
+ });
103
+ return;
104
+ }
105
+ const isThread = [
106
+ ChannelType.PublicThread,
107
+ ChannelType.PrivateThread,
108
+ ChannelType.AnnouncementThread,
109
+ ].includes(channel.type);
110
+ if (!isThread) {
111
+ await command.reply({
112
+ content: 'This command can only be used in a thread',
113
+ ephemeral: true,
114
+ flags: SILENT_MESSAGE_FLAGS,
115
+ });
116
+ return;
117
+ }
118
+ const queueLength = getQueueLength(channel.id);
119
+ if (queueLength === 0) {
120
+ await command.reply({
121
+ content: 'No messages in queue',
122
+ ephemeral: true,
123
+ flags: SILENT_MESSAGE_FLAGS,
124
+ });
125
+ return;
126
+ }
127
+ clearQueue(channel.id);
128
+ await command.reply({
129
+ content: `๐Ÿ—‘ Cleared ${queueLength} queued message${queueLength > 1 ? 's' : ''}`,
130
+ flags: SILENT_MESSAGE_FLAGS,
131
+ });
132
+ logger.log(`[QUEUE] User ${command.user.displayName} cleared queue in thread ${channel.id}`);
133
+ }