@cordbot/agent 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.
@@ -0,0 +1,283 @@
1
+ import { TextChannel } from 'discord.js';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { streamToDiscord } from '../agent/stream.js';
5
+ import { getChannelMapping, syncNewChannel, updateChannelClaudeMdTopic } from './sync.js';
6
+ import { DiscordPermissionManager } from '../permissions/discord.js';
7
+ // Global permission manager instance
8
+ export const permissionManager = new DiscordPermissionManager();
9
+ // Queue to prevent concurrent processing of messages in the same thread
10
+ const threadLocks = new Map();
11
+ export function setupEventHandlers(client, sessionManager, channelMappings, basePath, guildId, cronRunner) {
12
+ // Handle new messages
13
+ client.on('messageCreate', async (message) => {
14
+ await handleMessageWithLock(message, sessionManager, channelMappings);
15
+ });
16
+ // Handle new channels being created
17
+ client.on('channelCreate', async (channel) => {
18
+ // Only handle text channels in the configured guild
19
+ if (!(channel instanceof TextChannel))
20
+ return;
21
+ if (channel.guildId !== guildId)
22
+ return;
23
+ try {
24
+ console.log(`\nšŸ†• New channel detected: #${channel.name}`);
25
+ // Sync the new channel
26
+ const mapping = await syncNewChannel(channel, basePath);
27
+ // Add to mappings array so it's immediately available
28
+ channelMappings.push(mapping);
29
+ // Start watching the cron file for this channel
30
+ cronRunner.addChannel(mapping);
31
+ console.log(`āœ… Channel #${channel.name} synced and ready\n`);
32
+ }
33
+ catch (error) {
34
+ console.error(`āŒ Error syncing new channel #${channel.name}:`, error);
35
+ }
36
+ });
37
+ // Handle channels being deleted
38
+ client.on('channelDelete', async (channel) => {
39
+ // Only handle text channels in the configured guild
40
+ if (!(channel instanceof TextChannel))
41
+ return;
42
+ if (channel.guildId !== guildId)
43
+ return;
44
+ try {
45
+ console.log(`\nšŸ—‘ļø Channel deleted: #${channel.name}`);
46
+ // Find the mapping for this channel
47
+ const mappingIndex = channelMappings.findIndex(m => m.channelId === channel.id);
48
+ if (mappingIndex === -1) {
49
+ console.log(`Channel #${channel.name} was not synced, skipping cleanup`);
50
+ return;
51
+ }
52
+ const mapping = channelMappings[mappingIndex];
53
+ // Stop watching the cron file
54
+ cronRunner.removeChannel(channel.id);
55
+ // Remove from mappings array
56
+ channelMappings.splice(mappingIndex, 1);
57
+ // Delete the folder if it exists
58
+ if (fs.existsSync(mapping.folderPath)) {
59
+ fs.rmSync(mapping.folderPath, { recursive: true, force: true });
60
+ console.log(`šŸ“ Deleted folder: ${mapping.folderPath}`);
61
+ }
62
+ console.log(`āœ… Channel #${channel.name} cleanup complete\n`);
63
+ }
64
+ catch (error) {
65
+ console.error(`āŒ Error cleaning up channel #${channel.name}:`, error);
66
+ }
67
+ });
68
+ // Handle channel updates (e.g., topic changes)
69
+ client.on('channelUpdate', async (oldChannel, newChannel) => {
70
+ // Only handle text channels in the configured guild
71
+ if (!(newChannel instanceof TextChannel))
72
+ return;
73
+ if (newChannel.guildId !== guildId)
74
+ return;
75
+ // Check if topic changed
76
+ const oldTopic = oldChannel.topic || '';
77
+ const newTopic = newChannel.topic || '';
78
+ if (oldTopic !== newTopic) {
79
+ const mapping = getChannelMapping(newChannel.id, channelMappings);
80
+ if (mapping) {
81
+ try {
82
+ console.log(`\nšŸ“ Topic updated for #${newChannel.name}`);
83
+ await updateChannelClaudeMdTopic(mapping.claudeMdPath, newTopic);
84
+ console.log(`āœ… Synced topic to CLAUDE.md\n`);
85
+ }
86
+ catch (error) {
87
+ console.error(`āŒ Error syncing topic for #${newChannel.name}:`, error);
88
+ }
89
+ }
90
+ }
91
+ });
92
+ // Handle errors
93
+ client.on('error', (error) => {
94
+ console.error('Discord client error:', error);
95
+ });
96
+ // Handle warnings
97
+ client.on('warn', (warning) => {
98
+ console.warn('Discord client warning:', warning);
99
+ });
100
+ // Handle button interactions for permissions
101
+ client.on('interactionCreate', async (interaction) => {
102
+ if (!interaction.isButton())
103
+ return;
104
+ const customId = interaction.customId;
105
+ if (customId.startsWith('permission_')) {
106
+ // Parse customId - handle underscores in requestId
107
+ const parts = customId.split('_');
108
+ const [, action, ...requestIdParts] = parts;
109
+ const requestId = requestIdParts.join('_'); // Rejoin in case requestId has underscores
110
+ const approved = action === 'approve';
111
+ // Try to handle the permission response
112
+ const handled = permissionManager.handlePermissionResponse(requestId, approved);
113
+ if (handled) {
114
+ // Successfully handled - update the message
115
+ await interaction.update({
116
+ content: `${interaction.message.content}\n\n${approved ? 'āœ… Approved' : 'āŒ Denied'}`,
117
+ components: [], // Remove buttons
118
+ });
119
+ }
120
+ else {
121
+ // Not found or already handled - respond ephemerally
122
+ await interaction.reply({
123
+ content: 'āš ļø This permission request has already been handled or expired.',
124
+ ephemeral: true,
125
+ });
126
+ }
127
+ }
128
+ });
129
+ }
130
+ async function handleMessageWithLock(message, sessionManager, channelMappings) {
131
+ // Determine thread ID for locking
132
+ const threadId = message.channel.isThread()
133
+ ? message.channel.id
134
+ : message.id; // For new threads, use message ID temporarily
135
+ // Wait for any existing processing on this thread to complete
136
+ const existingLock = threadLocks.get(threadId);
137
+ if (existingLock) {
138
+ await existingLock;
139
+ }
140
+ // Create new lock for this message
141
+ const newLock = handleMessage(message, sessionManager, channelMappings)
142
+ .finally(() => {
143
+ // Remove lock when done
144
+ if (threadLocks.get(threadId) === newLock) {
145
+ threadLocks.delete(threadId);
146
+ }
147
+ });
148
+ threadLocks.set(threadId, newLock);
149
+ await newLock;
150
+ }
151
+ async function handleMessage(message, sessionManager, channelMappings) {
152
+ // Ignore bot messages
153
+ if (message.author.bot)
154
+ return;
155
+ // Determine the parent channel ID
156
+ let parentChannelId;
157
+ if (message.channel.isThread()) {
158
+ parentChannelId = message.channel.parentId || message.channel.id;
159
+ }
160
+ else {
161
+ parentChannelId = message.channelId;
162
+ }
163
+ // Get channel mapping
164
+ const mapping = getChannelMapping(parentChannelId, channelMappings);
165
+ if (!mapping) {
166
+ // Not a synced channel - ignore
167
+ return;
168
+ }
169
+ try {
170
+ // Check if there's a pending cron session for this channel
171
+ let pendingCronSession = null;
172
+ if (!message.channel.isThread()) {
173
+ pendingCronSession = sessionManager.getPendingCronSession(parentChannelId);
174
+ }
175
+ // Determine thread context
176
+ let threadId;
177
+ let threadChannel;
178
+ if (message.channel.isThread()) {
179
+ // User is replying in an existing thread
180
+ threadId = message.channel.id;
181
+ threadChannel = message.channel;
182
+ }
183
+ else {
184
+ // User is writing in the channel - create a new thread
185
+ const textChannel = message.channel;
186
+ // Create thread name from message content
187
+ const threadName = `${message.author.username}: ${message.content.slice(0, 50)}${message.content.length > 50 ? '...' : ''}`;
188
+ const thread = await textChannel.threads.create({
189
+ name: threadName,
190
+ autoArchiveDuration: 1440, // 24 hours
191
+ reason: 'Claude conversation',
192
+ startMessage: message,
193
+ });
194
+ threadId = thread.id;
195
+ threadChannel = thread;
196
+ }
197
+ // Determine session ID and working directory
198
+ let sessionId;
199
+ let isNew;
200
+ let workingDir;
201
+ if (pendingCronSession) {
202
+ // Continue the cron session in this new thread
203
+ sessionId = pendingCronSession.sessionId;
204
+ workingDir = pendingCronSession.workingDir;
205
+ isNew = false;
206
+ // Map the thread to the existing cron session
207
+ sessionManager.createMappingWithSessionId(threadId, parentChannelId, message.id, sessionId, workingDir);
208
+ console.log(`šŸ”— Continuing cron session ${sessionId} in thread ${threadId}`);
209
+ }
210
+ else {
211
+ // Normal flow: get or create agent session tied to this thread
212
+ const result = await sessionManager.getOrCreateSession(threadId, parentChannelId, message.id, mapping.folderPath);
213
+ sessionId = result.sessionId;
214
+ isNew = result.isNew;
215
+ workingDir = mapping.folderPath;
216
+ if (!isNew) {
217
+ console.log(`šŸ“– Resuming session ${sessionId} for thread ${threadId}`);
218
+ }
219
+ else {
220
+ console.log(`✨ Created new session ${sessionId} for thread ${threadId}`);
221
+ }
222
+ }
223
+ // Set channel context for permission requests
224
+ sessionManager.setChannelContext(sessionId, threadChannel);
225
+ // Set working directory context for built-in tools (cron, etc.)
226
+ sessionManager.setWorkingDirContext(sessionId, workingDir);
227
+ try {
228
+ // Handle attachments if present
229
+ let userMessage = message.content;
230
+ if (message.attachments.size > 0) {
231
+ const attachmentInfo = await downloadAttachments(message, workingDir);
232
+ if (attachmentInfo.length > 0) {
233
+ userMessage = `${message.content}\n\n[Files attached and saved to working directory: ${attachmentInfo.join(', ')}]`;
234
+ }
235
+ }
236
+ // Create query for Claude
237
+ // For new sessions, pass null to let SDK create a fresh session
238
+ // For existing sessions, pass the real SDK session ID to resume
239
+ const queryResult = sessionManager.createQuery(userMessage, isNew ? null : sessionId, workingDir);
240
+ // Stream response from Claude agent to Discord
241
+ await streamToDiscord(queryResult, threadChannel, sessionManager, sessionId, workingDir);
242
+ }
243
+ finally {
244
+ // Clear contexts after execution
245
+ sessionManager.clearChannelContext(sessionId);
246
+ sessionManager.clearWorkingDirContext(sessionId);
247
+ }
248
+ }
249
+ catch (error) {
250
+ console.error('Error handling message:', error);
251
+ // Try to send error message to user
252
+ try {
253
+ await message.reply(`āŒ Sorry, I encountered an error processing your message: ${error instanceof Error ? error.message : 'Unknown error'}`);
254
+ }
255
+ catch (replyError) {
256
+ console.error('Failed to send error message to user:', replyError);
257
+ }
258
+ }
259
+ }
260
+ async function downloadAttachments(message, workingDir) {
261
+ const attachmentNames = [];
262
+ for (const attachment of message.attachments.values()) {
263
+ try {
264
+ // Fetch the attachment
265
+ const response = await fetch(attachment.url);
266
+ if (!response.ok) {
267
+ console.error(`Failed to download attachment ${attachment.name}: ${response.statusText}`);
268
+ continue;
269
+ }
270
+ // Get the file content
271
+ const buffer = Buffer.from(await response.arrayBuffer());
272
+ // Save to channel folder (overwrite if exists)
273
+ const filePath = path.join(workingDir, attachment.name);
274
+ fs.writeFileSync(filePath, buffer);
275
+ attachmentNames.push(attachment.name);
276
+ console.log(`šŸ“Ž Downloaded attachment: ${attachment.name} (${buffer.length} bytes)`);
277
+ }
278
+ catch (error) {
279
+ console.error(`Failed to download attachment ${attachment.name}:`, error);
280
+ }
281
+ }
282
+ return attachmentNames;
283
+ }
@@ -0,0 +1,148 @@
1
+ import { TextChannel } from 'discord.js';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ const __filename = fileURLToPath(import.meta.url);
6
+ const __dirname = path.dirname(__filename);
7
+ export async function syncChannelsOnStartup(client, guildId, basePath) {
8
+ const guild = client.guilds.cache.get(guildId);
9
+ if (!guild) {
10
+ throw new Error(`Guild ${guildId} not found. Make sure the bot is added to the server.`);
11
+ }
12
+ console.log(`šŸ”„ Syncing channels for guild: ${guild.name}`);
13
+ const mappings = [];
14
+ // Fetch all text channels
15
+ const channels = guild.channels.cache.filter(channel => channel.isTextBased() && !channel.isThread());
16
+ for (const [, channel] of channels) {
17
+ if (!(channel instanceof TextChannel))
18
+ continue;
19
+ const channelName = channel.name;
20
+ const folderPath = path.join(basePath, channelName);
21
+ const claudeMdPath = path.join(folderPath, 'CLAUDE.md');
22
+ const cronPath = path.join(folderPath, '.claude-cron');
23
+ const claudeFolderPath = path.join(folderPath, '.claude');
24
+ const skillsPath = path.join(claudeFolderPath, 'skills');
25
+ // Create folder structure if it doesn't exist
26
+ if (!fs.existsSync(folderPath)) {
27
+ fs.mkdirSync(folderPath, { recursive: true });
28
+ console.log(`šŸ“ Created folder for #${channelName}`);
29
+ }
30
+ if (!fs.existsSync(skillsPath)) {
31
+ fs.mkdirSync(skillsPath, { recursive: true });
32
+ console.log(`šŸ“ Created .claude/skills for #${channelName}`);
33
+ }
34
+ // Create CLAUDE.md if missing and sync Discord topic to it (one-way sync)
35
+ if (!fs.existsSync(claudeMdPath)) {
36
+ await createChannelClaudeMd(claudeMdPath, channelName, channel.topic || '');
37
+ console.log(`šŸ“„ Created CLAUDE.md for #${channelName}`);
38
+ }
39
+ else {
40
+ // Update existing CLAUDE.md with Discord topic
41
+ await updateChannelClaudeMdTopic(claudeMdPath, channel.topic || '');
42
+ }
43
+ // Create empty .claude-cron if missing (Claude will manage via skill)
44
+ if (!fs.existsSync(cronPath)) {
45
+ fs.writeFileSync(cronPath, 'jobs: []\n', 'utf-8');
46
+ console.log(`ā° Created .claude-cron for #${channelName}`);
47
+ }
48
+ // Copy cron management skill to .claude/skills
49
+ const skillTemplatePath = path.join(__dirname, '..', '..', 'templates', 'cron-skill.md');
50
+ const skillDestPath = path.join(skillsPath, 'cron.md');
51
+ if (!fs.existsSync(skillDestPath)) {
52
+ fs.copyFileSync(skillTemplatePath, skillDestPath);
53
+ console.log(`šŸ”§ Added cron management skill for #${channelName}`);
54
+ }
55
+ mappings.push({
56
+ channelId: channel.id,
57
+ channelName,
58
+ folderPath,
59
+ claudeMdPath,
60
+ cronPath,
61
+ });
62
+ }
63
+ console.log(`āœ… Synced ${mappings.length} channels`);
64
+ return mappings;
65
+ }
66
+ async function createChannelClaudeMd(claudeMdPath, channelName, discordTopic) {
67
+ // Create a minimal CLAUDE.md with Discord topic synced
68
+ const content = discordTopic
69
+ ? `# ${channelName}\n\n> ${discordTopic}\n\n`
70
+ : `# ${channelName}\n\n`;
71
+ fs.writeFileSync(claudeMdPath, content, 'utf-8');
72
+ }
73
+ export async function updateChannelClaudeMdTopic(claudeMdPath, discordTopic) {
74
+ const content = fs.readFileSync(claudeMdPath, 'utf-8');
75
+ const lines = content.split('\n');
76
+ // Find or update the topic line (should be after the heading)
77
+ let updatedLines = [];
78
+ let foundHeading = false;
79
+ let foundTopic = false;
80
+ for (let i = 0; i < lines.length; i++) {
81
+ const line = lines[i];
82
+ if (!foundHeading && line.trim().startsWith('#')) {
83
+ updatedLines.push(line);
84
+ updatedLines.push('');
85
+ if (discordTopic) {
86
+ updatedLines.push(`> ${discordTopic}`);
87
+ updatedLines.push('');
88
+ }
89
+ foundHeading = true;
90
+ foundTopic = true;
91
+ continue;
92
+ }
93
+ // Skip old topic line
94
+ if (foundHeading && !foundTopic && line.trim().startsWith('>')) {
95
+ foundTopic = true;
96
+ if (discordTopic) {
97
+ updatedLines.push(`> ${discordTopic}`);
98
+ }
99
+ continue;
100
+ }
101
+ updatedLines.push(line);
102
+ }
103
+ fs.writeFileSync(claudeMdPath, updatedLines.join('\n'), 'utf-8');
104
+ }
105
+ export function getChannelMapping(channelId, mappings) {
106
+ return mappings.find(m => m.channelId === channelId);
107
+ }
108
+ export async function syncNewChannel(channel, basePath) {
109
+ const channelName = channel.name;
110
+ const folderPath = path.join(basePath, channelName);
111
+ const claudeMdPath = path.join(folderPath, 'CLAUDE.md');
112
+ const cronPath = path.join(folderPath, '.claude-cron');
113
+ const claudeFolderPath = path.join(folderPath, '.claude');
114
+ const skillsPath = path.join(claudeFolderPath, 'skills');
115
+ // Create folder structure
116
+ if (!fs.existsSync(folderPath)) {
117
+ fs.mkdirSync(folderPath, { recursive: true });
118
+ console.log(`šŸ“ Created folder for #${channelName}`);
119
+ }
120
+ if (!fs.existsSync(skillsPath)) {
121
+ fs.mkdirSync(skillsPath, { recursive: true });
122
+ console.log(`šŸ“ Created .claude/skills for #${channelName}`);
123
+ }
124
+ // Create CLAUDE.md
125
+ if (!fs.existsSync(claudeMdPath)) {
126
+ await createChannelClaudeMd(claudeMdPath, channelName, channel.topic || '');
127
+ console.log(`šŸ“„ Created CLAUDE.md for #${channelName}`);
128
+ }
129
+ // Create empty .claude-cron
130
+ if (!fs.existsSync(cronPath)) {
131
+ fs.writeFileSync(cronPath, 'jobs: []\n', 'utf-8');
132
+ console.log(`ā° Created .claude-cron for #${channelName}`);
133
+ }
134
+ // Copy cron management skill
135
+ const skillTemplatePath = path.join(__dirname, '..', '..', 'templates', 'cron-skill.md');
136
+ const skillDestPath = path.join(skillsPath, 'cron.md');
137
+ if (!fs.existsSync(skillDestPath)) {
138
+ fs.copyFileSync(skillTemplatePath, skillDestPath);
139
+ console.log(`šŸ”§ Added cron management skill for #${channelName}`);
140
+ }
141
+ return {
142
+ channelId: channel.id,
143
+ channelName,
144
+ folderPath,
145
+ claudeMdPath,
146
+ cronPath,
147
+ };
148
+ }
package/dist/index.js ADDED
@@ -0,0 +1,72 @@
1
+ import { initializeClaudeFolder } from "./init.js";
2
+ import { createDiscordClient } from "./discord/client.js";
3
+ import { syncChannelsOnStartup } from "./discord/sync.js";
4
+ import { setupEventHandlers } from "./discord/events.js";
5
+ import { SessionDatabase } from "./storage/database.js";
6
+ import { SessionManager } from "./agent/manager.js";
7
+ import { CronRunner } from "./scheduler/runner.js";
8
+ export async function startBot(cwd) {
9
+ console.log("šŸš€ Initializing Cordbot...\n");
10
+ // Initialize .claude folder and database
11
+ const { dbPath, sessionsDir, claudeDir, isFirstRun } = initializeClaudeFolder(cwd);
12
+ if (isFirstRun) {
13
+ console.log("\n✨ First run detected - initialized project structure\n");
14
+ }
15
+ // Validate environment variables
16
+ const token = process.env.DISCORD_BOT_TOKEN;
17
+ const guildId = process.env.DISCORD_GUILD_ID;
18
+ const apiKey = process.env.ANTHROPIC_API_KEY;
19
+ if (!token || !guildId || !apiKey) {
20
+ throw new Error("Missing required environment variables");
21
+ }
22
+ // Initialize database
23
+ const db = new SessionDatabase(dbPath);
24
+ console.log(`šŸ“Š Active sessions: ${db.getActiveCount()}\n`);
25
+ // Initialize session manager
26
+ const sessionManager = new SessionManager(db, sessionsDir);
27
+ await sessionManager.initialize(token);
28
+ console.log("");
29
+ // Connect to Discord
30
+ console.log("šŸ”Œ Connecting to Discord...\n");
31
+ const client = await createDiscordClient({ token, guildId });
32
+ // Sync channels with folders
33
+ const channelMappings = await syncChannelsOnStartup(client, guildId, cwd);
34
+ console.log("");
35
+ // Start cron scheduler
36
+ const cronRunner = new CronRunner(client, sessionManager);
37
+ cronRunner.start(channelMappings);
38
+ console.log("");
39
+ // Setup event handlers (after cron runner is initialized)
40
+ setupEventHandlers(client, sessionManager, channelMappings, cwd, guildId, cronRunner);
41
+ console.log("āœ… Event handlers registered\n");
42
+ // Setup graceful shutdown
43
+ const shutdown = async () => {
44
+ console.log("\nāøļø Shutting down Cordbot...");
45
+ // Stop cron scheduler
46
+ cronRunner.stop();
47
+ // Stop token refresh
48
+ sessionManager.shutdown();
49
+ // Close database
50
+ db.close();
51
+ console.log("šŸ—„ļø Database closed");
52
+ // Destroy Discord client
53
+ client.destroy();
54
+ console.log("šŸ”Œ Discord client disconnected");
55
+ console.log("\nšŸ‘‹ Cordbot stopped");
56
+ process.exit(0);
57
+ };
58
+ process.on("SIGINT", shutdown);
59
+ process.on("SIGTERM", shutdown);
60
+ // Archive old sessions periodically (every 24 hours)
61
+ const archiveDays = parseInt(process.env.ARCHIVE_AFTER_DAYS || "30");
62
+ setInterval(async () => {
63
+ const archived = await sessionManager.archiveOldSessions(archiveDays);
64
+ if (archived > 0) {
65
+ console.log(`šŸ—„ļø Archived ${archived} inactive sessions`);
66
+ }
67
+ }, 24 * 60 * 60 * 1000);
68
+ console.log("āœ… Cordbot is now running!\n");
69
+ console.log(`šŸ“Š Watching ${channelMappings.length} channels`);
70
+ console.log(`šŸ’¬ Bot is ready to receive messages\n`);
71
+ console.log("Press Ctrl+C to stop\n");
72
+ }
package/dist/init.js ADDED
@@ -0,0 +1,82 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import Database from 'better-sqlite3';
5
+ const __filename = fileURLToPath(import.meta.url);
6
+ const __dirname = path.dirname(__filename);
7
+ export function initializeClaudeFolder(cwd) {
8
+ const claudeDir = path.join(cwd, '.claude');
9
+ const configPath = path.join(claudeDir, 'config.json');
10
+ const dbPath = path.join(claudeDir, 'mappings.db');
11
+ const sessionsDir = path.join(claudeDir, 'sessions');
12
+ const isFirstRun = !fs.existsSync(claudeDir);
13
+ // Create .claude directory if it doesn't exist
14
+ if (!fs.existsSync(claudeDir)) {
15
+ fs.mkdirSync(claudeDir, { recursive: true });
16
+ console.log('šŸ“ Created .claude directory');
17
+ }
18
+ // Create sessions directory
19
+ if (!fs.existsSync(sessionsDir)) {
20
+ fs.mkdirSync(sessionsDir, { recursive: true });
21
+ console.log('šŸ“ Created sessions directory');
22
+ }
23
+ // Initialize config.json if it doesn't exist
24
+ if (!fs.existsSync(configPath)) {
25
+ const defaultConfig = {
26
+ version: '1.0.0',
27
+ created: new Date().toISOString(),
28
+ lastStarted: new Date().toISOString(),
29
+ };
30
+ fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2), 'utf-8');
31
+ console.log('āš™ļø Created config.json');
32
+ }
33
+ else {
34
+ // Update lastStarted
35
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
36
+ config.lastStarted = new Date().toISOString();
37
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
38
+ }
39
+ // Initialize SQLite database
40
+ initializeDatabase(dbPath);
41
+ // Create root CLAUDE.md if it doesn't exist
42
+ ensureRootClaudeMd(cwd);
43
+ return {
44
+ claudeDir,
45
+ configPath,
46
+ dbPath,
47
+ sessionsDir,
48
+ isFirstRun,
49
+ };
50
+ }
51
+ function initializeDatabase(dbPath) {
52
+ const db = new Database(dbPath);
53
+ // Create session_mappings table
54
+ db.exec(`
55
+ CREATE TABLE IF NOT EXISTS session_mappings (
56
+ discord_thread_id TEXT PRIMARY KEY,
57
+ discord_channel_id TEXT NOT NULL,
58
+ discord_message_id TEXT NOT NULL,
59
+ session_id TEXT NOT NULL,
60
+ working_directory TEXT NOT NULL,
61
+ created_at TEXT NOT NULL,
62
+ last_active_at TEXT NOT NULL,
63
+ status TEXT NOT NULL CHECK(status IN ('active', 'archived'))
64
+ );
65
+
66
+ CREATE INDEX IF NOT EXISTS idx_session_id ON session_mappings(session_id);
67
+ CREATE INDEX IF NOT EXISTS idx_channel_id ON session_mappings(discord_channel_id);
68
+ `);
69
+ db.close();
70
+ console.log('šŸ—„ļø Initialized database');
71
+ }
72
+ function ensureRootClaudeMd(cwd) {
73
+ const claudeMdPath = path.join(cwd, 'CLAUDE.md');
74
+ if (!fs.existsSync(claudeMdPath)) {
75
+ const templatePath = path.join(__dirname, '..', 'templates', 'root-CLAUDE.md.template');
76
+ let template = fs.readFileSync(templatePath, 'utf-8');
77
+ // Replace placeholders
78
+ template = template.replace(/{{WORKING_DIRECTORY}}/g, cwd);
79
+ fs.writeFileSync(claudeMdPath, template, 'utf-8');
80
+ console.log('šŸ“„ Created root CLAUDE.md with Discord bot instructions');
81
+ }
82
+ }
@@ -0,0 +1,66 @@
1
+ import { ButtonBuilder, ActionRowBuilder, ButtonStyle } from 'discord.js';
2
+ export class DiscordPermissionManager {
3
+ pendingRequests = new Map();
4
+ /**
5
+ * Request permission from user via Discord buttons
6
+ * Returns a promise that resolves if approved, rejects if denied
7
+ */
8
+ async requestPermission(channel, message, requestId) {
9
+ // Create Approve/Deny buttons
10
+ const approveButton = new ButtonBuilder()
11
+ .setCustomId(`permission_approve_${requestId}`)
12
+ .setLabel('Approve')
13
+ .setStyle(ButtonStyle.Success);
14
+ const denyButton = new ButtonBuilder()
15
+ .setCustomId(`permission_deny_${requestId}`)
16
+ .setLabel('Deny')
17
+ .setStyle(ButtonStyle.Danger);
18
+ const row = new ActionRowBuilder().addComponents(approveButton, denyButton);
19
+ // Send permission request message
20
+ const permissionMsg = await channel.send({
21
+ content: `šŸ” **Permission Required**\n${message}`,
22
+ components: [row],
23
+ });
24
+ // Wait for user response
25
+ return new Promise((resolve, reject) => {
26
+ this.pendingRequests.set(requestId, {
27
+ resolve,
28
+ reject,
29
+ messageId: permissionMsg.id
30
+ });
31
+ // Timeout after 5 minutes
32
+ setTimeout(() => {
33
+ if (this.pendingRequests.has(requestId)) {
34
+ this.pendingRequests.delete(requestId);
35
+ permissionMsg.delete().catch(() => { });
36
+ reject(new Error('Permission request timed out'));
37
+ }
38
+ }, 5 * 60 * 1000);
39
+ });
40
+ }
41
+ /**
42
+ * Handle permission response from button interaction
43
+ * Returns true if handled successfully, false if not found
44
+ */
45
+ handlePermissionResponse(requestId, approved) {
46
+ const request = this.pendingRequests.get(requestId);
47
+ if (!request) {
48
+ return false;
49
+ }
50
+ // Remove from map and resolve/reject
51
+ this.pendingRequests.delete(requestId);
52
+ if (approved) {
53
+ request.resolve();
54
+ }
55
+ else {
56
+ request.reject(new Error('Permission denied by user'));
57
+ }
58
+ return true;
59
+ }
60
+ /**
61
+ * Check if a request ID is pending
62
+ */
63
+ hasPendingRequest(requestId) {
64
+ return this.pendingRequests.has(requestId);
65
+ }
66
+ }