afk-code 0.1.0 → 0.1.3

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.
@@ -1,191 +0,0 @@
1
- import type { Client, TextChannel, CategoryChannel, Guild } from 'discord.js';
2
- import { ChannelType, PermissionFlagsBits } from 'discord.js';
3
-
4
- export interface ChannelMapping {
5
- sessionId: string;
6
- channelId: string;
7
- channelName: string;
8
- sessionName: string;
9
- status: 'running' | 'idle' | 'ended';
10
- createdAt: Date;
11
- }
12
-
13
- /**
14
- * Sanitize a string for use as a Discord channel name.
15
- * Rules: lowercase, no spaces, max 100 chars, only letters/numbers/hyphens/underscores
16
- */
17
- function sanitizeChannelName(name: string): string {
18
- return name
19
- .toLowerCase()
20
- .replace(/[^a-z0-9-_\s]/g, '') // Remove invalid chars
21
- .replace(/\s+/g, '-') // Spaces to hyphens
22
- .replace(/-+/g, '-') // Collapse multiple hyphens
23
- .replace(/^-|-$/g, '') // Trim hyphens from ends
24
- .slice(0, 90); // Leave room for "afk-" prefix and uniqueness suffix
25
- }
26
-
27
- export class ChannelManager {
28
- private channels = new Map<string, ChannelMapping>();
29
- private channelToSession = new Map<string, string>();
30
- private client: Client;
31
- private userId: string;
32
- private guild: Guild | null = null;
33
- private category: CategoryChannel | null = null;
34
-
35
- constructor(client: Client, userId: string) {
36
- this.client = client;
37
- this.userId = userId;
38
- }
39
-
40
- async initialize(): Promise<void> {
41
- // Find the first guild the bot is in
42
- const guilds = await this.client.guilds.fetch();
43
- if (guilds.size === 0) {
44
- throw new Error('Bot is not in any servers. Please invite the bot first.');
45
- }
46
-
47
- const guildId = guilds.first()!.id;
48
- this.guild = await this.client.guilds.fetch(guildId);
49
-
50
- // Find or create AFK Code category
51
- const existingCategory = this.guild.channels.cache.find(
52
- (ch) => ch.type === ChannelType.GuildCategory && ch.name.toLowerCase() === 'afk code sessions'
53
- ) as CategoryChannel | undefined;
54
-
55
- if (existingCategory) {
56
- this.category = existingCategory;
57
- } else {
58
- this.category = await this.guild.channels.create({
59
- name: 'AFK Code Sessions',
60
- type: ChannelType.GuildCategory,
61
- });
62
- }
63
-
64
- console.log(`[ChannelManager] Using guild: ${this.guild.name}`);
65
- console.log(`[ChannelManager] Using category: ${this.category.name}`);
66
- }
67
-
68
- async createChannel(
69
- sessionId: string,
70
- sessionName: string,
71
- cwd: string
72
- ): Promise<ChannelMapping | null> {
73
- if (!this.guild || !this.category) {
74
- console.error('[ChannelManager] Not initialized');
75
- return null;
76
- }
77
-
78
- // Check if channel already exists for this session
79
- if (this.channels.has(sessionId)) {
80
- return this.channels.get(sessionId)!;
81
- }
82
-
83
- // Extract just the folder name from the path
84
- const folderName = cwd.split('/').filter(Boolean).pop() || 'session';
85
- const baseName = `afk-${sanitizeChannelName(folderName)}`;
86
-
87
- // Try to create channel, incrementing suffix if name is taken
88
- let channelName = baseName;
89
- let suffix = 1;
90
- let channel: TextChannel | null = null;
91
-
92
- while (true) {
93
- const nameToTry = channelName.length > 100 ? channelName.slice(0, 100) : channelName;
94
-
95
- // Check if name exists
96
- const existing = this.guild.channels.cache.find(
97
- (ch) => ch.name === nameToTry && ch.parentId === this.category!.id
98
- );
99
-
100
- if (!existing) {
101
- try {
102
- channel = await this.guild.channels.create({
103
- name: nameToTry,
104
- type: ChannelType.GuildText,
105
- parent: this.category,
106
- topic: `Claude Code session: ${sessionName}`,
107
- });
108
- channelName = nameToTry;
109
- break;
110
- } catch (err: any) {
111
- console.error('[ChannelManager] Failed to create channel:', err.message);
112
- return null;
113
- }
114
- } else {
115
- suffix++;
116
- channelName = `${baseName}-${suffix}`;
117
- }
118
- }
119
-
120
- if (!channel) {
121
- return null;
122
- }
123
-
124
- const mapping: ChannelMapping = {
125
- sessionId,
126
- channelId: channel.id,
127
- channelName,
128
- sessionName,
129
- status: 'running',
130
- createdAt: new Date(),
131
- };
132
-
133
- this.channels.set(sessionId, mapping);
134
- this.channelToSession.set(channel.id, sessionId);
135
-
136
- console.log(`[ChannelManager] Created channel #${channelName} for session ${sessionId}`);
137
- return mapping;
138
- }
139
-
140
- async archiveChannel(sessionId: string): Promise<boolean> {
141
- if (!this.guild) return false;
142
-
143
- const mapping = this.channels.get(sessionId);
144
- if (!mapping) return false;
145
-
146
- try {
147
- const channel = await this.guild.channels.fetch(mapping.channelId);
148
- if (channel && channel.type === ChannelType.GuildText) {
149
- // Rename with archived suffix
150
- const timestamp = Date.now().toString(36);
151
- const archivedName = `${mapping.channelName}-archived-${timestamp}`.slice(0, 100);
152
-
153
- await channel.setName(archivedName);
154
-
155
- // Move out of category or delete (Discord doesn't have archive)
156
- // For now, just rename to indicate it's archived
157
- console.log(`[ChannelManager] Archived channel #${mapping.channelName}`);
158
- }
159
- return true;
160
- } catch (err: any) {
161
- console.error('[ChannelManager] Failed to archive channel:', err.message);
162
- return false;
163
- }
164
- }
165
-
166
- getChannel(sessionId: string): ChannelMapping | undefined {
167
- return this.channels.get(sessionId);
168
- }
169
-
170
- getSessionByChannel(channelId: string): string | undefined {
171
- return this.channelToSession.get(channelId);
172
- }
173
-
174
- updateStatus(sessionId: string, status: 'running' | 'idle' | 'ended'): void {
175
- const mapping = this.channels.get(sessionId);
176
- if (mapping) {
177
- mapping.status = status;
178
- }
179
- }
180
-
181
- updateName(sessionId: string, name: string): void {
182
- const mapping = this.channels.get(sessionId);
183
- if (mapping) {
184
- mapping.sessionName = name;
185
- }
186
- }
187
-
188
- getAllActive(): ChannelMapping[] {
189
- return Array.from(this.channels.values()).filter((c) => c.status !== 'ended');
190
- }
191
- }
@@ -1,359 +0,0 @@
1
- import { Client, GatewayIntentBits, Events, ChannelType, AttachmentBuilder, REST, Routes, SlashCommandBuilder } from 'discord.js';
2
- import type { DiscordConfig } from './types';
3
- import { SessionManager, type SessionInfo, type ToolCallInfo, type ToolResultInfo } from '../slack/session-manager';
4
- import { ChannelManager } from './channel-manager';
5
- import { markdownToSlack, chunkMessage, formatSessionStatus, formatTodos } from '../slack/message-formatter';
6
- import { extractImagePaths } from '../utils/image-extractor';
7
-
8
- export function createDiscordApp(config: DiscordConfig) {
9
- const client = new Client({
10
- intents: [
11
- GatewayIntentBits.Guilds,
12
- GatewayIntentBits.GuildMessages,
13
- GatewayIntentBits.MessageContent,
14
- ],
15
- });
16
-
17
- const channelManager = new ChannelManager(client, config.userId);
18
-
19
- // Track messages sent from Discord to avoid re-posting
20
- const discordSentMessages = new Set<string>();
21
-
22
- // Track tool call messages for threading results
23
- const toolCallMessages = new Map<string, string>(); // toolUseId -> message id
24
-
25
- // Create session manager with event handlers that post to Discord
26
- const sessionManager = new SessionManager({
27
- onSessionStart: async (session) => {
28
- const channel = await channelManager.createChannel(session.id, session.name, session.cwd);
29
- if (channel) {
30
- const discordChannel = await client.channels.fetch(channel.channelId);
31
- if (discordChannel?.type === ChannelType.GuildText) {
32
- await discordChannel.send(
33
- `${formatSessionStatus(session.status)} **Session started**\n\`${session.cwd}\``
34
- );
35
- }
36
- }
37
- },
38
-
39
- onSessionEnd: async (sessionId) => {
40
- const channel = channelManager.getChannel(sessionId);
41
- if (channel) {
42
- channelManager.updateStatus(sessionId, 'ended');
43
-
44
- const discordChannel = await client.channels.fetch(channel.channelId);
45
- if (discordChannel?.type === ChannelType.GuildText) {
46
- await discordChannel.send('🛑 **Session ended** - this channel will be archived');
47
- }
48
-
49
- await channelManager.archiveChannel(sessionId);
50
- }
51
- },
52
-
53
- onSessionUpdate: async (sessionId, name) => {
54
- const channel = channelManager.getChannel(sessionId);
55
- if (channel) {
56
- channelManager.updateName(sessionId, name);
57
- // Update channel topic
58
- try {
59
- const discordChannel = await client.channels.fetch(channel.channelId);
60
- if (discordChannel?.type === ChannelType.GuildText) {
61
- await discordChannel.setTopic(`Claude Code session: ${name}`);
62
- }
63
- } catch (err) {
64
- console.error('[Discord] Failed to update channel topic:', err);
65
- }
66
- }
67
- },
68
-
69
- onSessionStatus: async (sessionId, status) => {
70
- const channel = channelManager.getChannel(sessionId);
71
- if (channel) {
72
- channelManager.updateStatus(sessionId, status);
73
- }
74
- },
75
-
76
- onMessage: async (sessionId, role, content) => {
77
- const channel = channelManager.getChannel(sessionId);
78
- if (channel) {
79
- // Discord markdown is similar to Slack's mrkdwn but uses standard markdown
80
- const formatted = content; // Discord uses standard markdown
81
-
82
- if (role === 'user') {
83
- // Skip messages that originated from Discord
84
- const contentKey = content.trim();
85
- if (discordSentMessages.has(contentKey)) {
86
- discordSentMessages.delete(contentKey);
87
- return;
88
- }
89
-
90
- // User message from terminal
91
- const discordChannel = await client.channels.fetch(channel.channelId);
92
- if (discordChannel?.type === ChannelType.GuildText) {
93
- const chunks = chunkMessage(formatted);
94
- for (const chunk of chunks) {
95
- await discordChannel.send(`**User:** ${chunk}`);
96
- }
97
- }
98
- } else {
99
- // Claude's response
100
- const discordChannel = await client.channels.fetch(channel.channelId);
101
- if (discordChannel?.type === ChannelType.GuildText) {
102
- const chunks = chunkMessage(formatted);
103
- for (const chunk of chunks) {
104
- await discordChannel.send(chunk);
105
- }
106
-
107
- // Extract and upload any images mentioned in the response
108
- const session = sessionManager.getSession(sessionId);
109
- const images = extractImagePaths(content, session?.cwd);
110
- for (const image of images) {
111
- try {
112
- console.log(`[Discord] Uploading image: ${image.resolvedPath}`);
113
- const attachment = new AttachmentBuilder(image.resolvedPath);
114
- await discordChannel.send({
115
- content: `📎 ${image.originalPath}`,
116
- files: [attachment],
117
- });
118
- } catch (err) {
119
- console.error('[Discord] Failed to upload image:', err);
120
- }
121
- }
122
- }
123
- }
124
- }
125
- },
126
-
127
- onTodos: async (sessionId, todos) => {
128
- const channel = channelManager.getChannel(sessionId);
129
- if (channel && todos.length > 0) {
130
- const todosText = formatTodos(todos);
131
- try {
132
- const discordChannel = await client.channels.fetch(channel.channelId);
133
- if (discordChannel?.type === ChannelType.GuildText) {
134
- await discordChannel.send(`**Tasks:**\n${todosText}`);
135
- }
136
- } catch (err) {
137
- console.error('[Discord] Failed to post todos:', err);
138
- }
139
- }
140
- },
141
-
142
- onToolCall: async (sessionId, tool) => {
143
- const channel = channelManager.getChannel(sessionId);
144
- if (!channel) return;
145
-
146
- // Format tool call summary
147
- let inputSummary = '';
148
- if (tool.name === 'Bash' && tool.input.command) {
149
- inputSummary = `\`${tool.input.command.slice(0, 100)}${tool.input.command.length > 100 ? '...' : ''}\``;
150
- } else if (tool.name === 'Read' && tool.input.file_path) {
151
- inputSummary = `\`${tool.input.file_path}\``;
152
- } else if (tool.name === 'Edit' && tool.input.file_path) {
153
- inputSummary = `\`${tool.input.file_path}\``;
154
- } else if (tool.name === 'Write' && tool.input.file_path) {
155
- inputSummary = `\`${tool.input.file_path}\``;
156
- } else if (tool.name === 'Grep' && tool.input.pattern) {
157
- inputSummary = `\`${tool.input.pattern}\``;
158
- } else if (tool.name === 'Glob' && tool.input.pattern) {
159
- inputSummary = `\`${tool.input.pattern}\``;
160
- } else if (tool.name === 'Task' && tool.input.description) {
161
- inputSummary = tool.input.description;
162
- }
163
-
164
- const text = inputSummary
165
- ? `🔧 **${tool.name}**: ${inputSummary}`
166
- : `🔧 **${tool.name}**`;
167
-
168
- try {
169
- const discordChannel = await client.channels.fetch(channel.channelId);
170
- if (discordChannel?.type === ChannelType.GuildText) {
171
- const message = await discordChannel.send(text);
172
- // Store the message id for threading results
173
- toolCallMessages.set(tool.id, message.id);
174
- }
175
- } catch (err) {
176
- console.error('[Discord] Failed to post tool call:', err);
177
- }
178
- },
179
-
180
- onToolResult: async (sessionId, result) => {
181
- const channel = channelManager.getChannel(sessionId);
182
- if (!channel) return;
183
-
184
- const parentMessageId = toolCallMessages.get(result.toolUseId);
185
- if (!parentMessageId) return; // No parent message to reply to
186
-
187
- // Truncate long results
188
- const maxLen = 1800; // Discord has 2000 char limit
189
- let content = result.content;
190
- if (content.length > maxLen) {
191
- content = content.slice(0, maxLen) + '\n... (truncated)';
192
- }
193
-
194
- const prefix = result.isError ? '❌ Error:' : '✅ Result:';
195
- const text = `${prefix}\n\`\`\`\n${content}\n\`\`\``;
196
-
197
- try {
198
- const discordChannel = await client.channels.fetch(channel.channelId);
199
- if (discordChannel?.type === ChannelType.GuildText) {
200
- // Fetch the parent message and create a thread
201
- const parentMessage = await discordChannel.messages.fetch(parentMessageId);
202
- if (parentMessage) {
203
- // Create a thread if one doesn't exist, or use existing
204
- let thread = parentMessage.thread;
205
- if (!thread) {
206
- thread = await parentMessage.startThread({
207
- name: 'Result',
208
- autoArchiveDuration: 60,
209
- });
210
- }
211
- await thread.send(text);
212
- }
213
-
214
- // Clean up the mapping
215
- toolCallMessages.delete(result.toolUseId);
216
- }
217
- } catch (err) {
218
- console.error('[Discord] Failed to post tool result:', err);
219
- }
220
- },
221
-
222
- onPlanModeChange: async (sessionId, inPlanMode) => {
223
- const channel = channelManager.getChannel(sessionId);
224
- if (!channel) return;
225
-
226
- const emoji = inPlanMode ? '📋' : '🔨';
227
- const status = inPlanMode ? 'Planning mode - Claude is designing a solution' : 'Execution mode - Claude is implementing';
228
-
229
- try {
230
- const discordChannel = await client.channels.fetch(channel.channelId);
231
- if (discordChannel?.type === ChannelType.GuildText) {
232
- await discordChannel.send(`${emoji} ${status}`);
233
- }
234
- } catch (err) {
235
- console.error('[Discord] Failed to post plan mode change:', err);
236
- }
237
- },
238
- });
239
-
240
- // Handle messages in session channels (user sending input to Claude)
241
- client.on(Events.MessageCreate, async (message) => {
242
- // Ignore bot's own messages
243
- if (message.author.bot) return;
244
-
245
- // Ignore DMs
246
- if (!message.guild) return;
247
-
248
- const sessionId = channelManager.getSessionByChannel(message.channelId);
249
- if (!sessionId) return; // Not a session channel
250
-
251
- const channel = channelManager.getChannel(sessionId);
252
- if (!channel || channel.status === 'ended') {
253
- await message.reply('⚠️ This session has ended.');
254
- return;
255
- }
256
-
257
- console.log(`[Discord] Sending input to session ${sessionId}: ${message.content.slice(0, 50)}...`);
258
-
259
- // Track this message so we don't re-post it
260
- discordSentMessages.add(message.content.trim());
261
-
262
- const sent = sessionManager.sendInput(sessionId, message.content);
263
- if (!sent) {
264
- discordSentMessages.delete(message.content.trim());
265
- await message.reply('⚠️ Failed to send input - session not connected.');
266
- }
267
- });
268
-
269
- // When bot is ready
270
- client.once(Events.ClientReady, async (c) => {
271
- console.log(`[Discord] Logged in as ${c.user.tag}`);
272
- await channelManager.initialize();
273
-
274
- // Register slash commands
275
- const commands = [
276
- new SlashCommandBuilder()
277
- .setName('background')
278
- .setDescription('Send Claude to background mode (Ctrl+B)'),
279
- new SlashCommandBuilder()
280
- .setName('interrupt')
281
- .setDescription('Interrupt Claude (Escape)'),
282
- new SlashCommandBuilder()
283
- .setName('mode')
284
- .setDescription('Toggle Claude mode (Shift+Tab)'),
285
- new SlashCommandBuilder()
286
- .setName('afk')
287
- .setDescription('List active Claude Code sessions'),
288
- ];
289
-
290
- try {
291
- const rest = new REST({ version: '10' }).setToken(config.botToken);
292
- await rest.put(Routes.applicationCommands(c.user.id), {
293
- body: commands.map((cmd) => cmd.toJSON()),
294
- });
295
- console.log('[Discord] Slash commands registered');
296
- } catch (err) {
297
- console.error('[Discord] Failed to register slash commands:', err);
298
- }
299
- });
300
-
301
- // Handle slash commands
302
- client.on(Events.InteractionCreate, async (interaction) => {
303
- if (!interaction.isChatInputCommand()) return;
304
-
305
- const { commandName, channelId } = interaction;
306
-
307
- if (commandName === 'afk') {
308
- const active = channelManager.getAllActive();
309
- if (active.length === 0) {
310
- await interaction.reply('No active sessions. Start a session with `afk-code run -- claude`');
311
- return;
312
- }
313
-
314
- const text = active
315
- .map((c) => `<#${c.channelId}> - ${formatSessionStatus(c.status)}`)
316
- .join('\n');
317
-
318
- await interaction.reply(`**Active Sessions:**\n${text}`);
319
- return;
320
- }
321
-
322
- if (commandName === 'background' || commandName === 'interrupt' || commandName === 'mode') {
323
- const sessionId = channelManager.getSessionByChannel(channelId);
324
- if (!sessionId) {
325
- await interaction.reply('⚠️ This channel is not associated with an active session.');
326
- return;
327
- }
328
-
329
- const channel = channelManager.getChannel(sessionId);
330
- if (!channel || channel.status === 'ended') {
331
- await interaction.reply('⚠️ This session has ended.');
332
- return;
333
- }
334
-
335
- // Send the appropriate escape sequence
336
- let key: string;
337
- let message: string;
338
- if (commandName === 'background') {
339
- key = '\x02'; // Ctrl+B
340
- message = '⬇️ Sent background command (Ctrl+B)';
341
- } else if (commandName === 'interrupt') {
342
- key = '\x1b'; // Escape
343
- message = '🛑 Sent interrupt (Escape)';
344
- } else {
345
- key = '\x1b[Z'; // Shift+Tab
346
- message = '🔄 Sent mode toggle (Shift+Tab)';
347
- }
348
-
349
- const sent = sessionManager.sendInput(sessionId, key);
350
- if (sent) {
351
- await interaction.reply(message);
352
- } else {
353
- await interaction.reply('⚠️ Failed to send command - session not connected.');
354
- }
355
- }
356
- });
357
-
358
- return { client, sessionManager, channelManager };
359
- }
@@ -1,4 +0,0 @@
1
- export interface DiscordConfig {
2
- botToken: string;
3
- userId: string; // User to notify
4
- }