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,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
+ }