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
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// Discord-specific utility functions.
|
|
2
|
+
// Handles markdown splitting for Discord's 2000-char limit, code block escaping,
|
|
3
|
+
// thread message sending, and channel metadata extraction from topic tags.
|
|
4
|
+
import { ChannelType, PermissionsBitField, } from 'discord.js';
|
|
5
|
+
import { Lexer } from 'marked';
|
|
6
|
+
import { formatMarkdownTables } from './format-tables.js';
|
|
7
|
+
import { getChannelDirectory } from './database.js';
|
|
8
|
+
import { limitHeadingDepth } from './limit-heading-depth.js';
|
|
9
|
+
import { unnestCodeBlocksFromLists } from './unnest-code-blocks.js';
|
|
10
|
+
import { createLogger, LogPrefix } from './logger.js';
|
|
11
|
+
import mime from 'mime';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
const discordLogger = createLogger(LogPrefix.DISCORD);
|
|
15
|
+
export const SILENT_MESSAGE_FLAGS = 4 | 4096;
|
|
16
|
+
// Same as SILENT but without SuppressNotifications - triggers badge/notification
|
|
17
|
+
export const NOTIFY_MESSAGE_FLAGS = 4;
|
|
18
|
+
export function escapeBackticksInCodeBlocks(markdown) {
|
|
19
|
+
const lexer = new Lexer();
|
|
20
|
+
const tokens = lexer.lex(markdown);
|
|
21
|
+
let result = '';
|
|
22
|
+
for (const token of tokens) {
|
|
23
|
+
if (token.type === 'code') {
|
|
24
|
+
const escapedCode = token.text.replace(/`/g, '\\`');
|
|
25
|
+
result += '```' + (token.lang || '') + '\n' + escapedCode + '\n```\n';
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
result += token.raw;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
export function splitMarkdownForDiscord({ content, maxLength, }) {
|
|
34
|
+
if (content.length <= maxLength) {
|
|
35
|
+
return [content];
|
|
36
|
+
}
|
|
37
|
+
const lexer = new Lexer();
|
|
38
|
+
const tokens = lexer.lex(content);
|
|
39
|
+
const lines = [];
|
|
40
|
+
for (const token of tokens) {
|
|
41
|
+
if (token.type === 'code') {
|
|
42
|
+
const lang = token.lang || '';
|
|
43
|
+
lines.push({
|
|
44
|
+
text: '```' + lang + '\n',
|
|
45
|
+
inCodeBlock: false,
|
|
46
|
+
lang,
|
|
47
|
+
isOpeningFence: true,
|
|
48
|
+
isClosingFence: false,
|
|
49
|
+
});
|
|
50
|
+
const codeLines = token.text.split('\n');
|
|
51
|
+
for (const codeLine of codeLines) {
|
|
52
|
+
lines.push({
|
|
53
|
+
text: codeLine + '\n',
|
|
54
|
+
inCodeBlock: true,
|
|
55
|
+
lang,
|
|
56
|
+
isOpeningFence: false,
|
|
57
|
+
isClosingFence: false,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
lines.push({
|
|
61
|
+
text: '```\n',
|
|
62
|
+
inCodeBlock: false,
|
|
63
|
+
lang: '',
|
|
64
|
+
isOpeningFence: false,
|
|
65
|
+
isClosingFence: true,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
const rawLines = token.raw.split('\n');
|
|
70
|
+
for (let i = 0; i < rawLines.length; i++) {
|
|
71
|
+
const isLast = i === rawLines.length - 1;
|
|
72
|
+
const text = isLast ? rawLines[i] : rawLines[i] + '\n';
|
|
73
|
+
if (text) {
|
|
74
|
+
lines.push({
|
|
75
|
+
text,
|
|
76
|
+
inCodeBlock: false,
|
|
77
|
+
lang: '',
|
|
78
|
+
isOpeningFence: false,
|
|
79
|
+
isClosingFence: false,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const chunks = [];
|
|
86
|
+
let currentChunk = '';
|
|
87
|
+
let currentLang = null;
|
|
88
|
+
// helper to split a long line into smaller pieces at word boundaries or hard breaks
|
|
89
|
+
const splitLongLine = (text, available, inCode) => {
|
|
90
|
+
const pieces = [];
|
|
91
|
+
let remaining = text;
|
|
92
|
+
while (remaining.length > available) {
|
|
93
|
+
let splitAt = available;
|
|
94
|
+
// for non-code, try to split at word boundary
|
|
95
|
+
if (!inCode) {
|
|
96
|
+
const lastSpace = remaining.lastIndexOf(' ', available);
|
|
97
|
+
if (lastSpace > available * 0.5) {
|
|
98
|
+
splitAt = lastSpace + 1;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
pieces.push(remaining.slice(0, splitAt));
|
|
102
|
+
remaining = remaining.slice(splitAt);
|
|
103
|
+
}
|
|
104
|
+
if (remaining) {
|
|
105
|
+
pieces.push(remaining);
|
|
106
|
+
}
|
|
107
|
+
return pieces;
|
|
108
|
+
};
|
|
109
|
+
const closingFence = '```\n';
|
|
110
|
+
for (const line of lines) {
|
|
111
|
+
const openingFenceSize = currentChunk.length === 0 && (line.inCodeBlock || line.isOpeningFence)
|
|
112
|
+
? ('```' + line.lang + '\n').length
|
|
113
|
+
: 0;
|
|
114
|
+
const lineLength = line.isOpeningFence ? 0 : line.text.length;
|
|
115
|
+
const activeFenceOverhead = currentLang !== null || openingFenceSize > 0 ? closingFence.length : 0;
|
|
116
|
+
const wouldExceed = currentChunk.length + openingFenceSize + lineLength + activeFenceOverhead > maxLength;
|
|
117
|
+
if (wouldExceed) {
|
|
118
|
+
// handle case where single line is longer than maxLength
|
|
119
|
+
if (line.text.length > maxLength) {
|
|
120
|
+
// first, flush current chunk if any
|
|
121
|
+
if (currentChunk) {
|
|
122
|
+
if (currentLang !== null) {
|
|
123
|
+
currentChunk += '```\n';
|
|
124
|
+
}
|
|
125
|
+
chunks.push(currentChunk);
|
|
126
|
+
currentChunk = '';
|
|
127
|
+
}
|
|
128
|
+
// calculate overhead for code block markers
|
|
129
|
+
const codeBlockOverhead = line.inCodeBlock
|
|
130
|
+
? ('```' + line.lang + '\n').length + '```\n'.length
|
|
131
|
+
: 0;
|
|
132
|
+
// ensure at least 10 chars available, even if maxLength is very small
|
|
133
|
+
const availablePerChunk = Math.max(10, maxLength - codeBlockOverhead - 50);
|
|
134
|
+
const pieces = splitLongLine(line.text, availablePerChunk, line.inCodeBlock);
|
|
135
|
+
for (let i = 0; i < pieces.length; i++) {
|
|
136
|
+
const piece = pieces[i];
|
|
137
|
+
if (line.inCodeBlock) {
|
|
138
|
+
chunks.push('```' + line.lang + '\n' + piece + '```\n');
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
chunks.push(piece);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
currentLang = null;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
// normal case: line fits in a chunk but current chunk would overflow
|
|
148
|
+
if (currentChunk) {
|
|
149
|
+
if (currentLang !== null) {
|
|
150
|
+
currentChunk += '```\n';
|
|
151
|
+
}
|
|
152
|
+
chunks.push(currentChunk);
|
|
153
|
+
if (line.isClosingFence && currentLang !== null) {
|
|
154
|
+
currentChunk = '';
|
|
155
|
+
currentLang = null;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (line.inCodeBlock || line.isOpeningFence) {
|
|
159
|
+
const lang = line.lang;
|
|
160
|
+
currentChunk = '```' + lang + '\n';
|
|
161
|
+
if (!line.isOpeningFence) {
|
|
162
|
+
currentChunk += line.text;
|
|
163
|
+
}
|
|
164
|
+
currentLang = lang;
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
currentChunk = line.text;
|
|
168
|
+
currentLang = null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
// currentChunk is empty but line still exceeds - shouldn't happen after above check
|
|
173
|
+
const openingFence = line.inCodeBlock || line.isOpeningFence;
|
|
174
|
+
const openingFenceSize = openingFence ? ('```' + line.lang + '\n').length : 0;
|
|
175
|
+
if (line.text.length + openingFenceSize + activeFenceOverhead > maxLength) {
|
|
176
|
+
const fencedOverhead = openingFence
|
|
177
|
+
? ('```' + line.lang + '\n').length + closingFence.length
|
|
178
|
+
: 0;
|
|
179
|
+
const availablePerChunk = Math.max(10, maxLength - fencedOverhead - 50);
|
|
180
|
+
const pieces = splitLongLine(line.text, availablePerChunk, line.inCodeBlock);
|
|
181
|
+
for (const piece of pieces) {
|
|
182
|
+
if (openingFence) {
|
|
183
|
+
chunks.push('```' + line.lang + '\n' + piece + closingFence);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
chunks.push(piece);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
currentChunk = '';
|
|
190
|
+
currentLang = null;
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
if (openingFence) {
|
|
194
|
+
currentChunk = '```' + line.lang + '\n';
|
|
195
|
+
if (!line.isOpeningFence) {
|
|
196
|
+
currentChunk += line.text;
|
|
197
|
+
}
|
|
198
|
+
currentLang = line.lang;
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
currentChunk = line.text;
|
|
202
|
+
currentLang = null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
currentChunk += line.text;
|
|
209
|
+
if (line.inCodeBlock || line.isOpeningFence) {
|
|
210
|
+
currentLang = line.lang;
|
|
211
|
+
}
|
|
212
|
+
else if (line.isClosingFence) {
|
|
213
|
+
currentLang = null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (currentChunk) {
|
|
218
|
+
if (currentLang !== null) {
|
|
219
|
+
currentChunk += closingFence;
|
|
220
|
+
}
|
|
221
|
+
chunks.push(currentChunk);
|
|
222
|
+
}
|
|
223
|
+
return chunks;
|
|
224
|
+
}
|
|
225
|
+
export async function sendThreadMessage(thread, content, options) {
|
|
226
|
+
const MAX_LENGTH = 2000;
|
|
227
|
+
content = formatMarkdownTables(content);
|
|
228
|
+
content = unnestCodeBlocksFromLists(content);
|
|
229
|
+
content = limitHeadingDepth(content);
|
|
230
|
+
content = escapeBackticksInCodeBlocks(content);
|
|
231
|
+
// If custom flags provided, send as single message (no chunking)
|
|
232
|
+
if (options?.flags !== undefined) {
|
|
233
|
+
return thread.send({ content, flags: options.flags });
|
|
234
|
+
}
|
|
235
|
+
const chunks = splitMarkdownForDiscord({ content, maxLength: MAX_LENGTH });
|
|
236
|
+
if (chunks.length > 1) {
|
|
237
|
+
discordLogger.log(`MESSAGE: Splitting ${content.length} chars into ${chunks.length} messages`);
|
|
238
|
+
}
|
|
239
|
+
let firstMessage;
|
|
240
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
241
|
+
const chunk = chunks[i];
|
|
242
|
+
if (!chunk) {
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const message = await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS });
|
|
246
|
+
if (i === 0) {
|
|
247
|
+
firstMessage = message;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return firstMessage;
|
|
251
|
+
}
|
|
252
|
+
export async function resolveTextChannel(channel) {
|
|
253
|
+
if (!channel) {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
if (channel.type === ChannelType.GuildText) {
|
|
257
|
+
return channel;
|
|
258
|
+
}
|
|
259
|
+
if (channel.type === ChannelType.PublicThread ||
|
|
260
|
+
channel.type === ChannelType.PrivateThread ||
|
|
261
|
+
channel.type === ChannelType.AnnouncementThread) {
|
|
262
|
+
const parentId = channel.parentId;
|
|
263
|
+
if (parentId) {
|
|
264
|
+
const parent = await channel.guild.channels.fetch(parentId);
|
|
265
|
+
if (parent?.type === ChannelType.GuildText) {
|
|
266
|
+
return parent;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
export function escapeDiscordFormatting(text) {
|
|
273
|
+
return text.replace(/```/g, '\\`\\`\\`').replace(/````/g, '\\`\\`\\`\\`');
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Check if a member has required permissions to use Disunday.
|
|
277
|
+
* Returns true if user is owner, admin, has ManageGuild, or has "Disunday" role.
|
|
278
|
+
* Returns false if user has "no-disunday" role (blocks access).
|
|
279
|
+
*/
|
|
280
|
+
export function hasRequiredPermissions(member, guild) {
|
|
281
|
+
const hasNoDisundayRole = member.roles.cache.some((role) => role.name.toLowerCase() === 'no-disunday');
|
|
282
|
+
if (hasNoDisundayRole) {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
const isOwner = member.id === guild.ownerId;
|
|
286
|
+
const isAdmin = member.permissions.has(PermissionsBitField.Flags.Administrator);
|
|
287
|
+
const canManageServer = member.permissions.has(PermissionsBitField.Flags.ManageGuild);
|
|
288
|
+
const hasDisundayRole = member.roles.cache.some((role) => role.name.toLowerCase() === 'disunday');
|
|
289
|
+
return isOwner || isAdmin || canManageServer || hasDisundayRole;
|
|
290
|
+
}
|
|
291
|
+
export function getDisundayMetadata(textChannel) {
|
|
292
|
+
if (!textChannel) {
|
|
293
|
+
return {};
|
|
294
|
+
}
|
|
295
|
+
const channelConfig = getChannelDirectory(textChannel.id);
|
|
296
|
+
if (!channelConfig) {
|
|
297
|
+
return {};
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
projectDirectory: channelConfig.directory,
|
|
301
|
+
channelAppId: channelConfig.appId || undefined,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Upload files to a Discord thread/channel in a single message.
|
|
306
|
+
* Sending all files in one message causes Discord to display images in a grid layout.
|
|
307
|
+
*/
|
|
308
|
+
export async function uploadFilesToDiscord({ threadId, botToken, files, }) {
|
|
309
|
+
if (files.length === 0) {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
// Build attachments array for all files
|
|
313
|
+
const attachments = files.map((file, index) => ({
|
|
314
|
+
id: index,
|
|
315
|
+
filename: path.basename(file),
|
|
316
|
+
}));
|
|
317
|
+
const formData = new FormData();
|
|
318
|
+
formData.append('payload_json', JSON.stringify({ attachments }));
|
|
319
|
+
// Append each file with its array index, with correct MIME type for grid display
|
|
320
|
+
files.forEach((file, index) => {
|
|
321
|
+
const buffer = fs.readFileSync(file);
|
|
322
|
+
const mimeType = mime.getType(file) || 'application/octet-stream';
|
|
323
|
+
formData.append(`files[${index}]`, new Blob([buffer], { type: mimeType }), path.basename(file));
|
|
324
|
+
});
|
|
325
|
+
const response = await fetch(`https://discord.com/api/v10/channels/${threadId}/messages`, {
|
|
326
|
+
method: 'POST',
|
|
327
|
+
headers: {
|
|
328
|
+
Authorization: `Bot ${botToken}`,
|
|
329
|
+
},
|
|
330
|
+
body: formData,
|
|
331
|
+
});
|
|
332
|
+
if (!response.ok) {
|
|
333
|
+
const error = await response.text();
|
|
334
|
+
throw new Error(`Discord API error: ${response.status} - ${error}`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import { splitMarkdownForDiscord } from './discord-utils.js';
|
|
3
|
+
describe('splitMarkdownForDiscord', () => {
|
|
4
|
+
test('never returns chunks over the max length with code fences', () => {
|
|
5
|
+
const maxLength = 2000;
|
|
6
|
+
const header = '## Summary of Current Architecture\n\n';
|
|
7
|
+
const codeFenceStart = '```\n';
|
|
8
|
+
const codeFenceEnd = '\n```\n';
|
|
9
|
+
const codeLine = 'x'.repeat(180);
|
|
10
|
+
const codeBlock = Array.from({ length: 20 })
|
|
11
|
+
.map(() => codeLine)
|
|
12
|
+
.join('\n');
|
|
13
|
+
const markdown = `${header}${codeFenceStart}${codeBlock}${codeFenceEnd}`;
|
|
14
|
+
const chunks = splitMarkdownForDiscord({ content: markdown, maxLength });
|
|
15
|
+
expect(chunks.length).toBeGreaterThan(1);
|
|
16
|
+
for (const chunk of chunks) {
|
|
17
|
+
expect(chunk.length).toBeLessThanOrEqual(maxLength);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
});
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// TaggedError definitions for type-safe error handling with errore.
|
|
2
|
+
// Errors are grouped by category: infrastructure, domain, and validation.
|
|
3
|
+
// Use errore.matchError() for exhaustive error handling in command handlers.
|
|
4
|
+
import { createTaggedError } from 'errore';
|
|
5
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
6
|
+
// INFRASTRUCTURE ERRORS - Server, filesystem, external services
|
|
7
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
8
|
+
export class DirectoryNotAccessibleError extends createTaggedError({
|
|
9
|
+
name: 'DirectoryNotAccessibleError',
|
|
10
|
+
message: 'Directory does not exist or is not accessible: $directory',
|
|
11
|
+
}) {
|
|
12
|
+
}
|
|
13
|
+
export class ServerStartError extends createTaggedError({
|
|
14
|
+
name: 'ServerStartError',
|
|
15
|
+
message: 'Server failed to start on port $port: $reason',
|
|
16
|
+
}) {
|
|
17
|
+
}
|
|
18
|
+
export class ServerNotFoundError extends createTaggedError({
|
|
19
|
+
name: 'ServerNotFoundError',
|
|
20
|
+
message: 'OpenCode server not found for directory: $directory',
|
|
21
|
+
}) {
|
|
22
|
+
}
|
|
23
|
+
export class ServerNotReadyError extends createTaggedError({
|
|
24
|
+
name: 'ServerNotReadyError',
|
|
25
|
+
message: 'OpenCode server for directory "$directory" is in an error state (no client available)',
|
|
26
|
+
}) {
|
|
27
|
+
}
|
|
28
|
+
export class ApiKeyMissingError extends createTaggedError({
|
|
29
|
+
name: 'ApiKeyMissingError',
|
|
30
|
+
message: '$service API key is required',
|
|
31
|
+
}) {
|
|
32
|
+
}
|
|
33
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
34
|
+
// DOMAIN ERRORS - Sessions, messages, transcription
|
|
35
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
36
|
+
export class SessionNotFoundError extends createTaggedError({
|
|
37
|
+
name: 'SessionNotFoundError',
|
|
38
|
+
message: 'Session $sessionId not found',
|
|
39
|
+
}) {
|
|
40
|
+
}
|
|
41
|
+
export class SessionCreateError extends createTaggedError({
|
|
42
|
+
name: 'SessionCreateError',
|
|
43
|
+
message: '$message',
|
|
44
|
+
}) {
|
|
45
|
+
}
|
|
46
|
+
export class MessagesNotFoundError extends createTaggedError({
|
|
47
|
+
name: 'MessagesNotFoundError',
|
|
48
|
+
message: 'No messages found for session $sessionId',
|
|
49
|
+
}) {
|
|
50
|
+
}
|
|
51
|
+
export class TranscriptionError extends createTaggedError({
|
|
52
|
+
name: 'TranscriptionError',
|
|
53
|
+
message: 'Transcription failed: $reason',
|
|
54
|
+
}) {
|
|
55
|
+
}
|
|
56
|
+
export class GrepSearchError extends createTaggedError({
|
|
57
|
+
name: 'GrepSearchError',
|
|
58
|
+
message: 'Grep search failed for pattern: $pattern',
|
|
59
|
+
}) {
|
|
60
|
+
}
|
|
61
|
+
export class GlobSearchError extends createTaggedError({
|
|
62
|
+
name: 'GlobSearchError',
|
|
63
|
+
message: 'Glob search failed for pattern: $pattern',
|
|
64
|
+
}) {
|
|
65
|
+
}
|
|
66
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
67
|
+
// VALIDATION ERRORS - Input validation, format checks
|
|
68
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
69
|
+
export class InvalidAudioFormatError extends createTaggedError({
|
|
70
|
+
name: 'InvalidAudioFormatError',
|
|
71
|
+
message: 'Invalid audio format',
|
|
72
|
+
}) {
|
|
73
|
+
}
|
|
74
|
+
export class EmptyTranscriptionError extends createTaggedError({
|
|
75
|
+
name: 'EmptyTranscriptionError',
|
|
76
|
+
message: 'Model returned empty transcription',
|
|
77
|
+
}) {
|
|
78
|
+
}
|
|
79
|
+
export class NoResponseContentError extends createTaggedError({
|
|
80
|
+
name: 'NoResponseContentError',
|
|
81
|
+
message: 'No response content from model',
|
|
82
|
+
}) {
|
|
83
|
+
}
|
|
84
|
+
export class NoToolResponseError extends createTaggedError({
|
|
85
|
+
name: 'NoToolResponseError',
|
|
86
|
+
message: 'No valid tool responses',
|
|
87
|
+
}) {
|
|
88
|
+
}
|
|
89
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
90
|
+
// NETWORK ERRORS - Fetch and HTTP
|
|
91
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
92
|
+
export class FetchError extends createTaggedError({
|
|
93
|
+
name: 'FetchError',
|
|
94
|
+
message: 'Fetch failed for $url',
|
|
95
|
+
}) {
|
|
96
|
+
}
|
|
97
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
98
|
+
// API ERRORS - External service responses
|
|
99
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
100
|
+
export class DiscordApiError extends createTaggedError({
|
|
101
|
+
name: 'DiscordApiError',
|
|
102
|
+
message: 'Discord API error: $status $body',
|
|
103
|
+
}) {
|
|
104
|
+
}
|
|
105
|
+
export class OpenCodeApiError extends createTaggedError({
|
|
106
|
+
name: 'OpenCodeApiError',
|
|
107
|
+
message: 'OpenCode API error ($status): $body',
|
|
108
|
+
}) {
|
|
109
|
+
}
|
|
110
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
111
|
+
// USER-SAFE ERROR HANDLING
|
|
112
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
113
|
+
/**
|
|
114
|
+
* Error class for messages that are safe to show to users.
|
|
115
|
+
* Use this when you want the error message to be displayed verbatim.
|
|
116
|
+
*/
|
|
117
|
+
export class UserSafeError extends Error {
|
|
118
|
+
constructor(message) {
|
|
119
|
+
super(message);
|
|
120
|
+
this.name = 'UserSafeError';
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Generic error message shown to users when we can't expose internal details.
|
|
125
|
+
*/
|
|
126
|
+
const GENERIC_ERROR_MESSAGE = 'Something went wrong. Please try again.';
|
|
127
|
+
/**
|
|
128
|
+
* Patterns that indicate sensitive information in error messages.
|
|
129
|
+
*/
|
|
130
|
+
const SENSITIVE_PATTERNS = [
|
|
131
|
+
/SQLITE/i,
|
|
132
|
+
/database/i,
|
|
133
|
+
/token/i,
|
|
134
|
+
/api.?key/i,
|
|
135
|
+
/secret/i,
|
|
136
|
+
/password/i,
|
|
137
|
+
/credential/i,
|
|
138
|
+
/at\s+\S+:\d+:\d+/, // Stack trace lines
|
|
139
|
+
/node_modules/i,
|
|
140
|
+
/internal\//i,
|
|
141
|
+
];
|
|
142
|
+
/**
|
|
143
|
+
* Check if an error message contains sensitive information.
|
|
144
|
+
*/
|
|
145
|
+
function containsSensitiveInfo(message) {
|
|
146
|
+
return SENSITIVE_PATTERNS.some((pattern) => {
|
|
147
|
+
return pattern.test(message);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Sanitize an error for display to Discord users.
|
|
152
|
+
*
|
|
153
|
+
* - UserSafeError: Returns the original message (it's explicitly safe)
|
|
154
|
+
* - Tagged errors (from errore): Returns the error name and message
|
|
155
|
+
* - Other errors with safe messages: Returns the message
|
|
156
|
+
* - Errors with sensitive info: Returns generic message
|
|
157
|
+
*/
|
|
158
|
+
export function sanitizeErrorForUser(error) {
|
|
159
|
+
// UserSafeError is explicitly safe to show
|
|
160
|
+
if (error instanceof UserSafeError) {
|
|
161
|
+
return error.message;
|
|
162
|
+
}
|
|
163
|
+
// Not an error object
|
|
164
|
+
if (!(error instanceof Error)) {
|
|
165
|
+
return GENERIC_ERROR_MESSAGE;
|
|
166
|
+
}
|
|
167
|
+
const message = error.message || '';
|
|
168
|
+
// Check for sensitive patterns
|
|
169
|
+
if (containsSensitiveInfo(message)) {
|
|
170
|
+
return GENERIC_ERROR_MESSAGE;
|
|
171
|
+
}
|
|
172
|
+
// Tagged errors (from errore) are generally safe - they have structured messages
|
|
173
|
+
if ('_tag' in error &&
|
|
174
|
+
typeof error._tag === 'string') {
|
|
175
|
+
return message;
|
|
176
|
+
}
|
|
177
|
+
// Short, simple error messages are probably safe
|
|
178
|
+
if (message.length < 100 && message.indexOf('\n') === -1) {
|
|
179
|
+
return message;
|
|
180
|
+
}
|
|
181
|
+
// Default to generic message for safety
|
|
182
|
+
return GENERIC_ERROR_MESSAGE;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Create a log-safe version of an error (preserves full details for logging).
|
|
186
|
+
* Returns the original error message and stack trace.
|
|
187
|
+
*/
|
|
188
|
+
export function getErrorForLogging(error) {
|
|
189
|
+
if (error instanceof Error) {
|
|
190
|
+
return error.stack || error.message;
|
|
191
|
+
}
|
|
192
|
+
return String(error);
|
|
193
|
+
}
|