afk-code 0.1.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/README.md +134 -0
- package/package.json +45 -0
- package/slack-manifest.json +70 -0
- package/src/cli/discord.ts +183 -0
- package/src/cli/index.ts +83 -0
- package/src/cli/run.ts +126 -0
- package/src/cli/slack.ts +193 -0
- package/src/discord/channel-manager.ts +191 -0
- package/src/discord/discord-app.ts +359 -0
- package/src/discord/types.ts +4 -0
- package/src/slack/channel-manager.ts +175 -0
- package/src/slack/index.ts +58 -0
- package/src/slack/message-formatter.ts +91 -0
- package/src/slack/session-manager.ts +567 -0
- package/src/slack/slack-app.ts +443 -0
- package/src/slack/types.ts +6 -0
- package/src/types/index.ts +6 -0
- package/src/utils/image-extractor.ts +72 -0
|
@@ -0,0 +1,359 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { WebClient } from '@slack/web-api';
|
|
2
|
+
|
|
3
|
+
export interface ChannelMapping {
|
|
4
|
+
sessionId: string;
|
|
5
|
+
channelId: string;
|
|
6
|
+
channelName: string;
|
|
7
|
+
sessionName: string;
|
|
8
|
+
status: 'running' | 'idle' | 'ended';
|
|
9
|
+
createdAt: Date;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Sanitize a string for use as a Slack channel name.
|
|
14
|
+
* Rules: lowercase, no spaces, max 80 chars, only letters/numbers/hyphens/underscores
|
|
15
|
+
*/
|
|
16
|
+
function sanitizeChannelName(name: string): string {
|
|
17
|
+
return name
|
|
18
|
+
.toLowerCase()
|
|
19
|
+
.replace(/[^a-z0-9-_\s]/g, '') // Remove invalid chars
|
|
20
|
+
.replace(/\s+/g, '-') // Spaces to hyphens
|
|
21
|
+
.replace(/-+/g, '-') // Collapse multiple hyphens
|
|
22
|
+
.replace(/^-|-$/g, '') // Trim hyphens from ends
|
|
23
|
+
.slice(0, 70); // Leave room for "afk-" prefix and uniqueness suffix
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class ChannelManager {
|
|
27
|
+
private channels = new Map<string, ChannelMapping>();
|
|
28
|
+
private channelToSession = new Map<string, string>();
|
|
29
|
+
private client: WebClient;
|
|
30
|
+
private userId: string;
|
|
31
|
+
|
|
32
|
+
constructor(client: WebClient, userId: string) {
|
|
33
|
+
this.client = client;
|
|
34
|
+
this.userId = userId;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async createChannel(
|
|
38
|
+
sessionId: string,
|
|
39
|
+
sessionName: string,
|
|
40
|
+
cwd: string
|
|
41
|
+
): Promise<ChannelMapping | null> {
|
|
42
|
+
// Check if channel already exists for this session
|
|
43
|
+
if (this.channels.has(sessionId)) {
|
|
44
|
+
return this.channels.get(sessionId)!;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Extract just the folder name from the path
|
|
48
|
+
const folderName = cwd.split('/').filter(Boolean).pop() || 'session';
|
|
49
|
+
const baseName = `afk-${sanitizeChannelName(folderName)}`;
|
|
50
|
+
|
|
51
|
+
// Try to create channel, incrementing suffix if name is taken
|
|
52
|
+
let channelName = baseName;
|
|
53
|
+
let suffix = 1;
|
|
54
|
+
let result;
|
|
55
|
+
|
|
56
|
+
while (true) {
|
|
57
|
+
// Ensure max 80 chars
|
|
58
|
+
const nameToTry = channelName.length > 80 ? channelName.slice(0, 80) : channelName;
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
result = await this.client.conversations.create({
|
|
62
|
+
name: nameToTry,
|
|
63
|
+
is_private: true,
|
|
64
|
+
});
|
|
65
|
+
channelName = nameToTry;
|
|
66
|
+
break; // Success!
|
|
67
|
+
} catch (err: any) {
|
|
68
|
+
if (err.data?.error === 'name_taken') {
|
|
69
|
+
// Try next number
|
|
70
|
+
suffix++;
|
|
71
|
+
channelName = `${baseName}-${suffix}`;
|
|
72
|
+
} else {
|
|
73
|
+
throw err; // Different error, rethrow
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!result?.channel?.id) {
|
|
79
|
+
console.error('[ChannelManager] Failed to create channel - no ID returned');
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const mapping: ChannelMapping = {
|
|
84
|
+
sessionId,
|
|
85
|
+
channelId: result.channel.id,
|
|
86
|
+
channelName,
|
|
87
|
+
sessionName,
|
|
88
|
+
status: 'running',
|
|
89
|
+
createdAt: new Date(),
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
this.channels.set(sessionId, mapping);
|
|
93
|
+
this.channelToSession.set(result.channel.id, sessionId);
|
|
94
|
+
|
|
95
|
+
// Set channel topic
|
|
96
|
+
try {
|
|
97
|
+
await this.client.conversations.setTopic({
|
|
98
|
+
channel: result.channel.id,
|
|
99
|
+
topic: `Claude Code session: ${sessionName}`,
|
|
100
|
+
});
|
|
101
|
+
} catch (err: any) {
|
|
102
|
+
console.error('[ChannelManager] Failed to set topic:', err.message);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Invite user to channel
|
|
106
|
+
if (this.userId) {
|
|
107
|
+
try {
|
|
108
|
+
await this.client.conversations.invite({
|
|
109
|
+
channel: result.channel.id,
|
|
110
|
+
users: this.userId,
|
|
111
|
+
});
|
|
112
|
+
console.log(`[ChannelManager] Invited user to channel`);
|
|
113
|
+
} catch (err: any) {
|
|
114
|
+
// Ignore "already_in_channel" error
|
|
115
|
+
if (err.data?.error !== 'already_in_channel') {
|
|
116
|
+
console.error('[ChannelManager] Failed to invite user:', err.message);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
console.log(`[ChannelManager] Created channel #${channelName} for session ${sessionId}`);
|
|
122
|
+
return mapping;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async archiveChannel(sessionId: string): Promise<boolean> {
|
|
126
|
+
const mapping = this.channels.get(sessionId);
|
|
127
|
+
if (!mapping) return false;
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
// Rename channel before archiving to free up the name for reuse
|
|
131
|
+
const timestamp = Date.now().toString(36);
|
|
132
|
+
const archivedName = `${mapping.channelName}-archived-${timestamp}`.slice(0, 80);
|
|
133
|
+
|
|
134
|
+
await this.client.conversations.rename({
|
|
135
|
+
channel: mapping.channelId,
|
|
136
|
+
name: archivedName,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
await this.client.conversations.archive({
|
|
140
|
+
channel: mapping.channelId,
|
|
141
|
+
});
|
|
142
|
+
console.log(`[ChannelManager] Archived channel #${mapping.channelName}`);
|
|
143
|
+
return true;
|
|
144
|
+
} catch (err: any) {
|
|
145
|
+
console.error('[ChannelManager] Failed to archive channel:', err.message);
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
getChannel(sessionId: string): ChannelMapping | undefined {
|
|
151
|
+
return this.channels.get(sessionId);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
getSessionByChannel(channelId: string): string | undefined {
|
|
155
|
+
return this.channelToSession.get(channelId);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
updateStatus(sessionId: string, status: 'running' | 'idle' | 'ended'): void {
|
|
159
|
+
const mapping = this.channels.get(sessionId);
|
|
160
|
+
if (mapping) {
|
|
161
|
+
mapping.status = status;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
updateName(sessionId: string, name: string): void {
|
|
166
|
+
const mapping = this.channels.get(sessionId);
|
|
167
|
+
if (mapping) {
|
|
168
|
+
mapping.sessionName = name;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
getAllActive(): ChannelMapping[] {
|
|
173
|
+
return Array.from(this.channels.values()).filter((c) => c.status !== 'ended');
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createSlackApp } from './slack-app';
|
|
2
|
+
import type { SlackConfig } from './types';
|
|
3
|
+
|
|
4
|
+
async function main() {
|
|
5
|
+
const config: SlackConfig = {
|
|
6
|
+
botToken: process.env.SLACK_BOT_TOKEN || '',
|
|
7
|
+
appToken: process.env.SLACK_APP_TOKEN || '',
|
|
8
|
+
signingSecret: process.env.SLACK_SIGNING_SECRET || '',
|
|
9
|
+
userId: process.env.SLACK_USER_ID || '',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// Validate required config
|
|
13
|
+
const required: (keyof SlackConfig)[] = ['botToken', 'appToken', 'userId'];
|
|
14
|
+
|
|
15
|
+
const missing = required.filter((key) => !config[key]);
|
|
16
|
+
if (missing.length > 0) {
|
|
17
|
+
console.error(`[Slack] Missing required config: ${missing.join(', ')}`);
|
|
18
|
+
console.error('');
|
|
19
|
+
console.error('Required environment variables:');
|
|
20
|
+
console.error(' SLACK_BOT_TOKEN - Bot User OAuth Token (xoxb-...)');
|
|
21
|
+
console.error(' SLACK_APP_TOKEN - App-Level Token for Socket Mode (xapp-...)');
|
|
22
|
+
console.error(' SLACK_USER_ID - Your Slack user ID (U...)');
|
|
23
|
+
console.error('');
|
|
24
|
+
console.error('Optional:');
|
|
25
|
+
console.error(' SLACK_SIGNING_SECRET - Signing secret (for request verification)');
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
console.log('[Slack] Starting AFK Code bot...');
|
|
30
|
+
|
|
31
|
+
const { app, sessionManager } = createSlackApp(config);
|
|
32
|
+
|
|
33
|
+
// Start session manager (Unix socket server for CLI connections)
|
|
34
|
+
try {
|
|
35
|
+
await sessionManager.start();
|
|
36
|
+
console.log('[Slack] Session manager started');
|
|
37
|
+
} catch (err) {
|
|
38
|
+
console.error('[Slack] Failed to start session manager:', err);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Start Slack app
|
|
43
|
+
try {
|
|
44
|
+
await app.start();
|
|
45
|
+
console.log('[Slack] Bot is running!');
|
|
46
|
+
console.log('');
|
|
47
|
+
console.log('Start a Claude Code session with: afk-code run -- claude');
|
|
48
|
+
console.log('Each session will create a private #afk-* channel');
|
|
49
|
+
} catch (err) {
|
|
50
|
+
console.error('[Slack] Failed to start app:', err);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
main().catch((err) => {
|
|
56
|
+
console.error('[Slack] Fatal error:', err);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { TodoItem } from '../types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Convert GitHub-flavored markdown to Slack mrkdwn format
|
|
5
|
+
*/
|
|
6
|
+
export function markdownToSlack(markdown: string): string {
|
|
7
|
+
let text = markdown;
|
|
8
|
+
|
|
9
|
+
// Bold: **text** -> *text*
|
|
10
|
+
text = text.replace(/\*\*(.+?)\*\*/g, '*$1*');
|
|
11
|
+
|
|
12
|
+
// Headers: # Header -> *Header*
|
|
13
|
+
text = text.replace(/^#{1,6}\s+(.+)$/gm, '*$1*');
|
|
14
|
+
|
|
15
|
+
// Links: [text](url) -> <url|text>
|
|
16
|
+
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<$2|$1>');
|
|
17
|
+
|
|
18
|
+
// Strikethrough: ~~text~~ -> ~text~
|
|
19
|
+
text = text.replace(/~~(.+?)~~/g, '~$1~');
|
|
20
|
+
|
|
21
|
+
return text;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Split long messages to fit within Slack's 40k char limit
|
|
26
|
+
*/
|
|
27
|
+
export function chunkMessage(text: string, maxLength = 39000): string[] {
|
|
28
|
+
if (text.length <= maxLength) return [text];
|
|
29
|
+
|
|
30
|
+
const chunks: string[] = [];
|
|
31
|
+
let remaining = text;
|
|
32
|
+
|
|
33
|
+
while (remaining.length > 0) {
|
|
34
|
+
if (remaining.length <= maxLength) {
|
|
35
|
+
chunks.push(remaining);
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Find a good break point (newline or space)
|
|
40
|
+
let breakPoint = remaining.lastIndexOf('\n', maxLength);
|
|
41
|
+
if (breakPoint === -1 || breakPoint < maxLength / 2) {
|
|
42
|
+
breakPoint = remaining.lastIndexOf(' ', maxLength);
|
|
43
|
+
}
|
|
44
|
+
if (breakPoint === -1 || breakPoint < maxLength / 2) {
|
|
45
|
+
breakPoint = maxLength;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
chunks.push(remaining.slice(0, breakPoint));
|
|
49
|
+
remaining = remaining.slice(breakPoint).trimStart();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return chunks;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Format session status with emoji
|
|
57
|
+
*/
|
|
58
|
+
export function formatSessionStatus(status: 'running' | 'idle' | 'ended'): string {
|
|
59
|
+
const icons: Record<string, string> = {
|
|
60
|
+
running: ':hourglass_flowing_sand:',
|
|
61
|
+
idle: ':white_check_mark:',
|
|
62
|
+
ended: ':stop_sign:',
|
|
63
|
+
};
|
|
64
|
+
const labels: Record<string, string> = {
|
|
65
|
+
running: 'Running',
|
|
66
|
+
idle: 'Idle',
|
|
67
|
+
ended: 'Ended',
|
|
68
|
+
};
|
|
69
|
+
return `${icons[status]} ${labels[status]}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Format todo list with status icons
|
|
74
|
+
*/
|
|
75
|
+
export function formatTodos(todos: TodoItem[]): string {
|
|
76
|
+
if (todos.length === 0) return '';
|
|
77
|
+
|
|
78
|
+
const icons: Record<string, string> = {
|
|
79
|
+
pending: ':white_circle:',
|
|
80
|
+
in_progress: ':large_blue_circle:',
|
|
81
|
+
completed: ':white_check_mark:',
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return todos
|
|
85
|
+
.map((t) => {
|
|
86
|
+
const icon = icons[t.status] || ':white_circle:';
|
|
87
|
+
const text = t.status === 'in_progress' && t.activeForm ? t.activeForm : t.content;
|
|
88
|
+
return `${icon} ${text}`;
|
|
89
|
+
})
|
|
90
|
+
.join('\n');
|
|
91
|
+
}
|