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.
- package/LICENSE +21 -0
- package/README.md +64 -97
- package/dist/cli/index.js +1972 -0
- package/package.json +13 -9
- package/slack-manifest.json +3 -3
- package/src/cli/discord.ts +0 -183
- package/src/cli/index.ts +0 -83
- package/src/cli/run.ts +0 -126
- package/src/cli/slack.ts +0 -193
- package/src/discord/channel-manager.ts +0 -191
- package/src/discord/discord-app.ts +0 -359
- package/src/discord/types.ts +0 -4
- package/src/slack/channel-manager.ts +0 -175
- package/src/slack/index.ts +0 -58
- package/src/slack/message-formatter.ts +0 -91
- package/src/slack/session-manager.ts +0 -567
- package/src/slack/slack-app.ts +0 -443
- package/src/slack/types.ts +0 -6
- package/src/types/index.ts +0 -6
- package/src/utils/image-extractor.ts +0 -72
|
@@ -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
|
-
}
|
package/src/discord/types.ts
DELETED