@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.
- package/LICENSE +21 -0
- package/README.md +112 -0
- package/bin/cordbot.js +10 -0
- package/dist/agent/manager.js +256 -0
- package/dist/agent/stream.js +359 -0
- package/dist/auth.js +161 -0
- package/dist/cli.js +128 -0
- package/dist/discord/client.js +24 -0
- package/dist/discord/events.js +283 -0
- package/dist/discord/sync.js +148 -0
- package/dist/index.js +72 -0
- package/dist/init.js +82 -0
- package/dist/permissions/discord.js +66 -0
- package/dist/scheduler/parser.js +62 -0
- package/dist/scheduler/runner.js +201 -0
- package/dist/service/config.js +2 -0
- package/dist/service/manifest.js +27 -0
- package/dist/service/token-manager.js +143 -0
- package/dist/service/types.js +1 -0
- package/dist/storage/database.js +122 -0
- package/dist/tools/builtin-loader.js +21 -0
- package/dist/tools/cron/add_job.js +96 -0
- package/dist/tools/cron/list_jobs.js +55 -0
- package/dist/tools/cron/remove_job.js +64 -0
- package/dist/tools/cron/update_job.js +97 -0
- package/dist/tools/gmail/list_messages.js +87 -0
- package/dist/tools/gmail/send_email.js +118 -0
- package/dist/tools/loader.js +84 -0
- package/dist/tools/share_file.js +61 -0
- package/package.json +65 -0
- package/templates/.claude-cron.template +3 -0
- package/templates/channel-CLAUDE.md.template +26 -0
- package/templates/cron-skill.md +63 -0
- package/templates/root-CLAUDE.md.template +78 -0
|
@@ -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
|
+
}
|