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,84 @@
|
|
|
1
|
+
import { ChatInputCommandInteraction, ChannelType, EmbedBuilder, } from 'discord.js';
|
|
2
|
+
import { getBotSettings, setBotSettings } from '../database.js';
|
|
3
|
+
import { createLogger, LogPrefix } from '../logger.js';
|
|
4
|
+
const settingsLogger = createLogger(LogPrefix.SETTINGS);
|
|
5
|
+
export async function handleSettingsCommand({ command, appId, }) {
|
|
6
|
+
const subcommand = command.options.getSubcommand();
|
|
7
|
+
switch (subcommand) {
|
|
8
|
+
case 'hub-channel': {
|
|
9
|
+
await handleHubChannelSetting({ command, appId });
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
case 'view': {
|
|
13
|
+
await handleViewSettings({ command, appId });
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
default: {
|
|
17
|
+
await command.reply({
|
|
18
|
+
content: `Unknown setting: ${subcommand}`,
|
|
19
|
+
ephemeral: true,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
async function handleHubChannelSetting({ command, appId, }) {
|
|
25
|
+
const channel = command.options.getChannel('channel');
|
|
26
|
+
const clear = command.options.getBoolean('clear');
|
|
27
|
+
if (clear) {
|
|
28
|
+
setBotSettings(appId, { hub_channel_id: null });
|
|
29
|
+
settingsLogger.log(`[SETTINGS] Cleared hub channel for app ${appId}`);
|
|
30
|
+
await command.reply({
|
|
31
|
+
content: '✓ Hub channel cleared. Session completion notifications will no longer be sent to a central channel.',
|
|
32
|
+
ephemeral: true,
|
|
33
|
+
});
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (!channel) {
|
|
37
|
+
const currentSettings = getBotSettings(appId);
|
|
38
|
+
if (currentSettings.hub_channel_id) {
|
|
39
|
+
await command.reply({
|
|
40
|
+
content: `Current hub channel: <#${currentSettings.hub_channel_id}>`,
|
|
41
|
+
ephemeral: true,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
await command.reply({
|
|
46
|
+
content: 'No hub channel configured. Use `/settings hub-channel channel:#channel` to set one.',
|
|
47
|
+
ephemeral: true,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (channel.type !== ChannelType.GuildText) {
|
|
53
|
+
await command.reply({
|
|
54
|
+
content: 'Hub channel must be a text channel.',
|
|
55
|
+
ephemeral: true,
|
|
56
|
+
});
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
setBotSettings(appId, { hub_channel_id: channel.id });
|
|
60
|
+
settingsLogger.log(`[SETTINGS] Set hub channel to ${channel.id} for app ${appId}`);
|
|
61
|
+
await command.reply({
|
|
62
|
+
content: `✓ Hub channel set to <#${channel.id}>.\nSession completion notifications will be sent here.`,
|
|
63
|
+
ephemeral: true,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
async function handleViewSettings({ command, appId, }) {
|
|
67
|
+
const settings = getBotSettings(appId);
|
|
68
|
+
const embed = new EmbedBuilder()
|
|
69
|
+
.setTitle('Bot Settings')
|
|
70
|
+
.setColor(0x5865f2)
|
|
71
|
+
.addFields({
|
|
72
|
+
name: 'Hub Channel',
|
|
73
|
+
value: settings.hub_channel_id
|
|
74
|
+
? `<#${settings.hub_channel_id}>`
|
|
75
|
+
: '_Not configured_',
|
|
76
|
+
inline: true,
|
|
77
|
+
})
|
|
78
|
+
.setFooter({ text: `App ID: ${appId}` })
|
|
79
|
+
.setTimestamp();
|
|
80
|
+
await command.reply({
|
|
81
|
+
embeds: [embed],
|
|
82
|
+
ephemeral: true,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// /share command - Share the current session as a public URL.
|
|
2
|
+
import { ChannelType } from 'discord.js';
|
|
3
|
+
import { getDatabase } from '../database.js';
|
|
4
|
+
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
5
|
+
import { resolveTextChannel, getDisundayMetadata, SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
6
|
+
import { createLogger, LogPrefix } from '../logger.js';
|
|
7
|
+
import * as errore from 'errore';
|
|
8
|
+
const logger = createLogger(LogPrefix.SHARE);
|
|
9
|
+
export async function handleShareCommand({ command }) {
|
|
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 textChannel = await resolveTextChannel(channel);
|
|
33
|
+
const { projectDirectory: directory } = getDisundayMetadata(textChannel);
|
|
34
|
+
if (!directory) {
|
|
35
|
+
await command.reply({
|
|
36
|
+
content: 'Could not determine project directory for this channel',
|
|
37
|
+
ephemeral: true,
|
|
38
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
39
|
+
});
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const row = getDatabase()
|
|
43
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
44
|
+
.get(channel.id);
|
|
45
|
+
if (!row?.session_id) {
|
|
46
|
+
await command.reply({
|
|
47
|
+
content: 'No active session in this thread',
|
|
48
|
+
ephemeral: true,
|
|
49
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
50
|
+
});
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const sessionId = row.session_id;
|
|
54
|
+
const getClient = await initializeOpencodeForDirectory(directory);
|
|
55
|
+
if (getClient instanceof Error) {
|
|
56
|
+
await command.reply({
|
|
57
|
+
content: `Failed to share session: ${getClient.message}`,
|
|
58
|
+
ephemeral: true,
|
|
59
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
60
|
+
});
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const response = await getClient().session.share({
|
|
65
|
+
path: { id: sessionId },
|
|
66
|
+
});
|
|
67
|
+
if (!response.data?.share?.url) {
|
|
68
|
+
await command.reply({
|
|
69
|
+
content: 'Failed to generate share URL',
|
|
70
|
+
ephemeral: true,
|
|
71
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
72
|
+
});
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
await command.reply({
|
|
76
|
+
content: `🔗 **Session shared:** ${response.data.share.url}`,
|
|
77
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
78
|
+
});
|
|
79
|
+
logger.log(`Session ${sessionId} shared: ${response.data.share.url}`);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
logger.error('[SHARE] Error:', error);
|
|
83
|
+
await command.reply({
|
|
84
|
+
content: `Failed to share session: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
85
|
+
ephemeral: true,
|
|
86
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { ChannelType } from 'discord.js';
|
|
2
|
+
import { getDatabase, getThreadWorktree } from '../database.js';
|
|
3
|
+
import { resolveTextChannel, getDisundayMetadata, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
|
|
4
|
+
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
5
|
+
export async function handleStatusCommand({ command, }) {
|
|
6
|
+
const channel = command.channel;
|
|
7
|
+
if (!channel) {
|
|
8
|
+
await command.reply({
|
|
9
|
+
content: 'This command can only be used in a channel',
|
|
10
|
+
ephemeral: true,
|
|
11
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
12
|
+
});
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const isThread = [
|
|
16
|
+
ChannelType.PublicThread,
|
|
17
|
+
ChannelType.PrivateThread,
|
|
18
|
+
ChannelType.AnnouncementThread,
|
|
19
|
+
].includes(channel.type);
|
|
20
|
+
let sessionInfo = '';
|
|
21
|
+
let worktreeInfo = '';
|
|
22
|
+
let serverStatus = '🔴 Not connected';
|
|
23
|
+
if (isThread) {
|
|
24
|
+
const textChannel = await resolveTextChannel(channel);
|
|
25
|
+
const { projectDirectory: directory } = getDisundayMetadata(textChannel);
|
|
26
|
+
if (directory) {
|
|
27
|
+
const row = getDatabase()
|
|
28
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
29
|
+
.get(channel.id);
|
|
30
|
+
if (row?.session_id) {
|
|
31
|
+
sessionInfo = `\n**Session ID:** \`${row.session_id}\``;
|
|
32
|
+
const worktree = getThreadWorktree(channel.id);
|
|
33
|
+
if (worktree?.status === 'ready' && worktree.worktree_directory) {
|
|
34
|
+
worktreeInfo = `\n**Worktree:** \`${worktree.worktree_directory}\``;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const getClient = await initializeOpencodeForDirectory(directory);
|
|
38
|
+
if (!(getClient instanceof Error)) {
|
|
39
|
+
try {
|
|
40
|
+
await getClient().session.list({});
|
|
41
|
+
serverStatus = '🟢 Connected';
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
serverStatus = '🟡 Server error';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
const { projectDirectory: directory } = getDisundayMetadata(channel);
|
|
51
|
+
if (directory) {
|
|
52
|
+
const getClient = await initializeOpencodeForDirectory(directory);
|
|
53
|
+
if (!(getClient instanceof Error)) {
|
|
54
|
+
try {
|
|
55
|
+
await getClient().session.list({});
|
|
56
|
+
serverStatus = '🟢 Connected';
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
serverStatus = '🟡 Server error';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const statusMessage = [
|
|
65
|
+
'📊 **Bot Status**',
|
|
66
|
+
'',
|
|
67
|
+
`**OpenCode Server:** ${serverStatus}`,
|
|
68
|
+
sessionInfo,
|
|
69
|
+
worktreeInfo,
|
|
70
|
+
'',
|
|
71
|
+
`**Channel:** ${isThread ? 'Thread' : 'Channel'}`,
|
|
72
|
+
]
|
|
73
|
+
.filter(Boolean)
|
|
74
|
+
.join('\n');
|
|
75
|
+
await command.reply({
|
|
76
|
+
content: statusMessage,
|
|
77
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { ChannelType } from 'discord.js';
|
|
2
|
+
import { getDatabase, getThreadWorktree } from '../database.js';
|
|
3
|
+
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
4
|
+
import { resolveTextChannel, getDisundayMetadata, sendThreadMessage, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
|
|
5
|
+
import { collectLastAssistantParts } from '../message-formatting.js';
|
|
6
|
+
import { createLogger, LogPrefix } from '../logger.js';
|
|
7
|
+
const logger = createLogger(LogPrefix.SESSION);
|
|
8
|
+
export async function handleSyncCommand({ command, }) {
|
|
9
|
+
const channel = command.channel;
|
|
10
|
+
if (!channel) {
|
|
11
|
+
await command.reply({
|
|
12
|
+
content: 'This command can only be used in a channel',
|
|
13
|
+
ephemeral: true,
|
|
14
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
15
|
+
});
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const isThread = [
|
|
19
|
+
ChannelType.PublicThread,
|
|
20
|
+
ChannelType.PrivateThread,
|
|
21
|
+
ChannelType.AnnouncementThread,
|
|
22
|
+
].includes(channel.type);
|
|
23
|
+
if (!isThread) {
|
|
24
|
+
await command.reply({
|
|
25
|
+
content: 'This command can only be used in a thread with an active session',
|
|
26
|
+
ephemeral: true,
|
|
27
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
28
|
+
});
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
|
|
32
|
+
const thread = channel;
|
|
33
|
+
const textChannel = await resolveTextChannel(thread);
|
|
34
|
+
const { projectDirectory: directory } = getDisundayMetadata(textChannel);
|
|
35
|
+
if (!directory) {
|
|
36
|
+
await command.editReply('Could not determine project directory for this channel');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const row = getDatabase()
|
|
40
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
41
|
+
.get(channel.id);
|
|
42
|
+
if (!row?.session_id) {
|
|
43
|
+
await command.editReply('No active session in this thread');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const sessionId = row.session_id;
|
|
47
|
+
const worktreeInfo = getThreadWorktree(channel.id);
|
|
48
|
+
const sdkDirectory = worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
|
|
49
|
+
? worktreeInfo.worktree_directory
|
|
50
|
+
: directory;
|
|
51
|
+
try {
|
|
52
|
+
const getClient = await initializeOpencodeForDirectory(directory);
|
|
53
|
+
if (getClient instanceof Error) {
|
|
54
|
+
await command.editReply(`Failed to sync: ${getClient.message}`);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const [sessionResponse, messagesResponse] = await Promise.all([
|
|
58
|
+
getClient().session.get({
|
|
59
|
+
path: { id: sessionId },
|
|
60
|
+
query: { directory: sdkDirectory },
|
|
61
|
+
}),
|
|
62
|
+
getClient().session.messages({
|
|
63
|
+
path: { id: sessionId },
|
|
64
|
+
query: { directory: sdkDirectory },
|
|
65
|
+
}),
|
|
66
|
+
]);
|
|
67
|
+
const sessionTitle = sessionResponse.data?.title;
|
|
68
|
+
const originalThreadName = thread.name;
|
|
69
|
+
let wasRenamed = false;
|
|
70
|
+
logger.log(`[SYNC] Session title: "${sessionTitle}", Thread name: "${originalThreadName}"`);
|
|
71
|
+
if (sessionTitle && sessionTitle !== originalThreadName) {
|
|
72
|
+
const renameResult = await thread
|
|
73
|
+
.setName(sessionTitle.slice(0, 100))
|
|
74
|
+
.then(() => true)
|
|
75
|
+
.catch((err) => {
|
|
76
|
+
logger.error(`[SYNC] Failed to rename thread: ${err}`);
|
|
77
|
+
return false;
|
|
78
|
+
});
|
|
79
|
+
wasRenamed = renameResult;
|
|
80
|
+
if (wasRenamed) {
|
|
81
|
+
logger.log(`[SYNC] Updated thread name to "${sessionTitle}" for session ${sessionId}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (!messagesResponse.data) {
|
|
85
|
+
await command.editReply('Failed to fetch session messages');
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const messages = messagesResponse.data;
|
|
89
|
+
const { partIds, content, skippedCount } = collectLastAssistantParts({
|
|
90
|
+
messages,
|
|
91
|
+
limit: 3,
|
|
92
|
+
});
|
|
93
|
+
if (!content.trim()) {
|
|
94
|
+
const titleNote = wasRenamed
|
|
95
|
+
? ` (thread renamed to "${sessionTitle}")`
|
|
96
|
+
: '';
|
|
97
|
+
await command.editReply(`No recent assistant messages to sync${titleNote}`);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const titleNote = wasRenamed ? ` | renamed to "${sessionTitle}"` : '';
|
|
101
|
+
await command.editReply(`🔄 **Synced from terminal** (${messages.length} total messages${titleNote})`);
|
|
102
|
+
if (skippedCount > 0) {
|
|
103
|
+
await sendThreadMessage(thread, `*Skipped ${skippedCount} older assistant parts...*`);
|
|
104
|
+
}
|
|
105
|
+
const discordMessage = await sendThreadMessage(thread, content);
|
|
106
|
+
const stmt = getDatabase().prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)');
|
|
107
|
+
const transaction = getDatabase().transaction((ids) => {
|
|
108
|
+
for (const partId of ids) {
|
|
109
|
+
stmt.run(partId, discordMessage.id, thread.id);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
transaction(partIds);
|
|
113
|
+
logger.log(`[SYNC] Synced ${partIds.length} parts from session ${sessionId}`);
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
logger.error('[SYNC] Error:', error);
|
|
117
|
+
await command.editReply(`Failed to sync: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { ChatInputCommandInteraction, ChannelType, } from 'discord.js';
|
|
2
|
+
import { getChannelTheme, setChannelTheme, } from '../database.js';
|
|
3
|
+
import { createLogger, LogPrefix } from '../logger.js';
|
|
4
|
+
const themeLogger = createLogger(LogPrefix.THEME);
|
|
5
|
+
export async function handleThemeCommand({ command, appId, }) {
|
|
6
|
+
themeLogger.log('[THEME] Command called');
|
|
7
|
+
const channel = command.channel;
|
|
8
|
+
if (!channel) {
|
|
9
|
+
await command.reply({
|
|
10
|
+
content: 'Could not determine channel.',
|
|
11
|
+
ephemeral: true,
|
|
12
|
+
});
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const channelId = (() => {
|
|
16
|
+
if (channel.type === ChannelType.GuildText) {
|
|
17
|
+
return channel.id;
|
|
18
|
+
}
|
|
19
|
+
if (channel.type === ChannelType.PublicThread ||
|
|
20
|
+
channel.type === ChannelType.PrivateThread ||
|
|
21
|
+
channel.type === ChannelType.AnnouncementThread) {
|
|
22
|
+
return channel.parentId || channel.id;
|
|
23
|
+
}
|
|
24
|
+
return channel.id;
|
|
25
|
+
})();
|
|
26
|
+
const theme = command.options.getString('style', true);
|
|
27
|
+
const currentTheme = getChannelTheme(channelId);
|
|
28
|
+
if (currentTheme === theme) {
|
|
29
|
+
await command.reply({
|
|
30
|
+
content: `Theme is already set to **${theme}** for this channel.`,
|
|
31
|
+
ephemeral: true,
|
|
32
|
+
});
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
setChannelTheme(channelId, theme);
|
|
36
|
+
themeLogger.log(`[THEME] Set channel ${channelId} to ${theme}`);
|
|
37
|
+
const description = (() => {
|
|
38
|
+
switch (theme) {
|
|
39
|
+
case 'minimal':
|
|
40
|
+
return 'Minimal formatting with reduced bullets and no emoji. Thinking is hidden.';
|
|
41
|
+
case 'detailed':
|
|
42
|
+
return 'Rich formatting with emoji icons for different message types.';
|
|
43
|
+
case 'plain':
|
|
44
|
+
return 'Text-based formatting using brackets like [file], [tool], [edit].';
|
|
45
|
+
default:
|
|
46
|
+
return 'Default formatting with diamond bullets and emoji icons.';
|
|
47
|
+
}
|
|
48
|
+
})();
|
|
49
|
+
await command.reply({
|
|
50
|
+
content: `Theme set to **${theme}** for this channel.\n${description}\nThis is a per-channel setting and applies immediately, including any active sessions.`,
|
|
51
|
+
ephemeral: true,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Undo/Redo commands - /undo, /redo
|
|
2
|
+
import { ChannelType } from 'discord.js';
|
|
3
|
+
import { getDatabase } from '../database.js';
|
|
4
|
+
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
5
|
+
import { resolveTextChannel, getDisundayMetadata, SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
6
|
+
import { createLogger, LogPrefix } from '../logger.js';
|
|
7
|
+
import * as errore from 'errore';
|
|
8
|
+
const logger = createLogger(LogPrefix.UNDO_REDO);
|
|
9
|
+
export async function handleUndoCommand({ command }) {
|
|
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 textChannel = await resolveTextChannel(channel);
|
|
33
|
+
const { projectDirectory: directory } = getDisundayMetadata(textChannel);
|
|
34
|
+
if (!directory) {
|
|
35
|
+
await command.reply({
|
|
36
|
+
content: 'Could not determine project directory for this channel',
|
|
37
|
+
ephemeral: true,
|
|
38
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
39
|
+
});
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const row = getDatabase()
|
|
43
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
44
|
+
.get(channel.id);
|
|
45
|
+
if (!row?.session_id) {
|
|
46
|
+
await command.reply({
|
|
47
|
+
content: 'No active session in this thread',
|
|
48
|
+
ephemeral: true,
|
|
49
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
50
|
+
});
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const sessionId = row.session_id;
|
|
54
|
+
await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
|
|
55
|
+
const getClient = await initializeOpencodeForDirectory(directory);
|
|
56
|
+
if (getClient instanceof Error) {
|
|
57
|
+
await command.editReply(`Failed to undo: ${getClient.message}`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
// Fetch messages to find the last assistant message
|
|
62
|
+
const messagesResponse = await getClient().session.messages({
|
|
63
|
+
path: { id: sessionId },
|
|
64
|
+
});
|
|
65
|
+
if (!messagesResponse.data || messagesResponse.data.length === 0) {
|
|
66
|
+
await command.editReply('No messages to undo');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
// Find the last assistant message
|
|
70
|
+
const lastAssistantMessage = [...messagesResponse.data]
|
|
71
|
+
.reverse()
|
|
72
|
+
.find((m) => m.info.role === 'assistant');
|
|
73
|
+
if (!lastAssistantMessage) {
|
|
74
|
+
await command.editReply('No assistant message to undo');
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const response = await getClient().session.revert({
|
|
78
|
+
path: { id: sessionId },
|
|
79
|
+
body: { messageID: lastAssistantMessage.info.id },
|
|
80
|
+
});
|
|
81
|
+
if (response.error) {
|
|
82
|
+
await command.editReply(`Failed to undo: ${JSON.stringify(response.error)}`);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const diffInfo = response.data?.revert?.diff
|
|
86
|
+
? `\n\`\`\`diff\n${response.data.revert.diff.slice(0, 1500)}\n\`\`\``
|
|
87
|
+
: '';
|
|
88
|
+
await command.editReply(`⏪ **Undone** - reverted last assistant message${diffInfo}`);
|
|
89
|
+
logger.log(`Session ${sessionId} reverted message ${lastAssistantMessage.info.id}`);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
logger.error('[UNDO] Error:', error);
|
|
93
|
+
await command.editReply(`Failed to undo: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export async function handleRedoCommand({ command }) {
|
|
97
|
+
const channel = command.channel;
|
|
98
|
+
if (!channel) {
|
|
99
|
+
await command.reply({
|
|
100
|
+
content: 'This command can only be used in a channel',
|
|
101
|
+
ephemeral: true,
|
|
102
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
103
|
+
});
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const isThread = [
|
|
107
|
+
ChannelType.PublicThread,
|
|
108
|
+
ChannelType.PrivateThread,
|
|
109
|
+
ChannelType.AnnouncementThread,
|
|
110
|
+
].includes(channel.type);
|
|
111
|
+
if (!isThread) {
|
|
112
|
+
await command.reply({
|
|
113
|
+
content: 'This command can only be used in a thread with an active session',
|
|
114
|
+
ephemeral: true,
|
|
115
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
116
|
+
});
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const textChannel = await resolveTextChannel(channel);
|
|
120
|
+
const { projectDirectory: directory } = getDisundayMetadata(textChannel);
|
|
121
|
+
if (!directory) {
|
|
122
|
+
await command.reply({
|
|
123
|
+
content: 'Could not determine project directory for this channel',
|
|
124
|
+
ephemeral: true,
|
|
125
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
126
|
+
});
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const row = getDatabase()
|
|
130
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
131
|
+
.get(channel.id);
|
|
132
|
+
if (!row?.session_id) {
|
|
133
|
+
await command.reply({
|
|
134
|
+
content: 'No active session in this thread',
|
|
135
|
+
ephemeral: true,
|
|
136
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
137
|
+
});
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const sessionId = row.session_id;
|
|
141
|
+
await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
|
|
142
|
+
const getClient = await initializeOpencodeForDirectory(directory);
|
|
143
|
+
if (getClient instanceof Error) {
|
|
144
|
+
await command.editReply(`Failed to redo: ${getClient.message}`);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
// Check if session has reverted state
|
|
149
|
+
const sessionResponse = await getClient().session.get({
|
|
150
|
+
path: { id: sessionId },
|
|
151
|
+
});
|
|
152
|
+
if (!sessionResponse.data?.revert) {
|
|
153
|
+
await command.editReply('Nothing to redo - no previous undo found');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const response = await getClient().session.unrevert({
|
|
157
|
+
path: { id: sessionId },
|
|
158
|
+
});
|
|
159
|
+
if (response.error) {
|
|
160
|
+
await command.editReply(`Failed to redo: ${JSON.stringify(response.error)}`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
await command.editReply(`⏩ **Restored** - session back to previous state`);
|
|
164
|
+
logger.log(`Session ${sessionId} unrevert completed`);
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
logger.error('[REDO] Error:', error);
|
|
168
|
+
await command.editReply(`Failed to redo: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
169
|
+
}
|
|
170
|
+
}
|