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
package/src/cli/slack.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { homedir } from 'os';
|
|
2
|
+
import { mkdir } from 'fs/promises';
|
|
3
|
+
import * as readline from 'readline';
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIR = `${homedir()}/.afk-code`;
|
|
6
|
+
const SLACK_CONFIG_FILE = `${CONFIG_DIR}/slack.env`;
|
|
7
|
+
const MANIFEST_URL = 'https://github.com/clharman/afk-code/blob/main/slack-manifest.json';
|
|
8
|
+
|
|
9
|
+
function prompt(question: string): Promise<string> {
|
|
10
|
+
const rl = readline.createInterface({
|
|
11
|
+
input: process.stdin,
|
|
12
|
+
output: process.stdout,
|
|
13
|
+
});
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
rl.question(question, (answer) => {
|
|
16
|
+
rl.close();
|
|
17
|
+
resolve(answer.trim());
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function slackSetup(): Promise<void> {
|
|
23
|
+
console.log(`
|
|
24
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
25
|
+
│ AFK Code Slack Setup │
|
|
26
|
+
└─────────────────────────────────────────────────────────────┘
|
|
27
|
+
|
|
28
|
+
This will guide you through setting up the Slack bot for
|
|
29
|
+
monitoring Claude Code sessions.
|
|
30
|
+
|
|
31
|
+
Step 1: Create a Slack App
|
|
32
|
+
──────────────────────────
|
|
33
|
+
1. Go to: https://api.slack.com/apps
|
|
34
|
+
2. Click "Create New App" → "From manifest"
|
|
35
|
+
3. Select your workspace
|
|
36
|
+
4. Paste the manifest from: ${MANIFEST_URL}
|
|
37
|
+
(Or copy from slack-manifest.json in this repo)
|
|
38
|
+
5. Click "Create"
|
|
39
|
+
|
|
40
|
+
Step 2: Install the App
|
|
41
|
+
───────────────────────
|
|
42
|
+
1. Go to "Install App" in the sidebar
|
|
43
|
+
2. Click "Install to Workspace"
|
|
44
|
+
3. Authorize the app
|
|
45
|
+
|
|
46
|
+
Step 3: Get Your Tokens
|
|
47
|
+
───────────────────────
|
|
48
|
+
`);
|
|
49
|
+
|
|
50
|
+
await prompt('Press Enter when you have created and installed the app...');
|
|
51
|
+
|
|
52
|
+
console.log(`
|
|
53
|
+
Now let's collect your tokens:
|
|
54
|
+
|
|
55
|
+
• Bot Token: "OAuth & Permissions" → "Bot User OAuth Token" (starts with xoxb-)
|
|
56
|
+
• App Token: "Basic Information" → "App-Level Tokens" → Generate one with
|
|
57
|
+
"connections:write" scope (starts with xapp-)
|
|
58
|
+
• User ID: Click your profile in Slack → "..." → "Copy member ID"
|
|
59
|
+
`);
|
|
60
|
+
|
|
61
|
+
const botToken = await prompt('Bot Token (xoxb-...): ');
|
|
62
|
+
if (!botToken.startsWith('xoxb-')) {
|
|
63
|
+
console.error('Invalid bot token. Should start with xoxb-');
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const appToken = await prompt('App Token (xapp-...): ');
|
|
68
|
+
if (!appToken.startsWith('xapp-')) {
|
|
69
|
+
console.error('Invalid app token. Should start with xapp-');
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const userId = await prompt('Your Slack User ID (U...): ');
|
|
74
|
+
if (!userId.startsWith('U')) {
|
|
75
|
+
console.error('Invalid user ID. Should start with U');
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Save configuration
|
|
80
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
81
|
+
|
|
82
|
+
const envContent = `# AFK Code Slack Configuration
|
|
83
|
+
SLACK_BOT_TOKEN=${botToken}
|
|
84
|
+
SLACK_APP_TOKEN=${appToken}
|
|
85
|
+
SLACK_USER_ID=${userId}
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
await Bun.write(SLACK_CONFIG_FILE, envContent);
|
|
89
|
+
console.log(`
|
|
90
|
+
✓ Configuration saved to ${SLACK_CONFIG_FILE}
|
|
91
|
+
|
|
92
|
+
To start the Slack bot, run:
|
|
93
|
+
afk-code slack
|
|
94
|
+
|
|
95
|
+
Then start a Claude Code session with:
|
|
96
|
+
afk-code run -- claude
|
|
97
|
+
`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function loadEnvFile(path: string): Promise<Record<string, string>> {
|
|
101
|
+
const file = Bun.file(path);
|
|
102
|
+
if (!(await file.exists())) return {};
|
|
103
|
+
|
|
104
|
+
const content = await file.text();
|
|
105
|
+
const config: Record<string, string> = {};
|
|
106
|
+
|
|
107
|
+
for (const line of content.split('\n')) {
|
|
108
|
+
if (line.startsWith('#') || !line.includes('=')) continue;
|
|
109
|
+
const [key, ...valueParts] = line.split('=');
|
|
110
|
+
config[key.trim()] = valueParts.join('=').trim();
|
|
111
|
+
}
|
|
112
|
+
return config;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function slackRun(): Promise<void> {
|
|
116
|
+
// Load config from multiple sources (in order of precedence):
|
|
117
|
+
// 1. Environment variables (highest priority)
|
|
118
|
+
// 2. Local .env file
|
|
119
|
+
// 3. ~/.afk-code/slack.env (lowest priority)
|
|
120
|
+
|
|
121
|
+
const globalConfig = await loadEnvFile(SLACK_CONFIG_FILE);
|
|
122
|
+
const localConfig = await loadEnvFile(`${process.cwd()}/.env`);
|
|
123
|
+
|
|
124
|
+
// Merge configs (local overrides global, env vars override both)
|
|
125
|
+
const config: Record<string, string> = {
|
|
126
|
+
...globalConfig,
|
|
127
|
+
...localConfig,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// Environment variables take highest precedence
|
|
131
|
+
if (process.env.SLACK_BOT_TOKEN) config.SLACK_BOT_TOKEN = process.env.SLACK_BOT_TOKEN;
|
|
132
|
+
if (process.env.SLACK_APP_TOKEN) config.SLACK_APP_TOKEN = process.env.SLACK_APP_TOKEN;
|
|
133
|
+
if (process.env.SLACK_USER_ID) config.SLACK_USER_ID = process.env.SLACK_USER_ID;
|
|
134
|
+
|
|
135
|
+
// Validate required config
|
|
136
|
+
const required = ['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN', 'SLACK_USER_ID'];
|
|
137
|
+
const missing = required.filter((key) => !config[key]);
|
|
138
|
+
|
|
139
|
+
if (missing.length > 0) {
|
|
140
|
+
console.error(`Missing config: ${missing.join(', ')}`);
|
|
141
|
+
console.error('');
|
|
142
|
+
console.error('Provide tokens via:');
|
|
143
|
+
console.error(' - Environment variables (SLACK_BOT_TOKEN, SLACK_APP_TOKEN, SLACK_USER_ID)');
|
|
144
|
+
console.error(' - Local .env file');
|
|
145
|
+
console.error(' - Run "afk-code slack setup" for guided configuration');
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Set environment variables and start the bot
|
|
150
|
+
process.env.SLACK_BOT_TOKEN = config.SLACK_BOT_TOKEN;
|
|
151
|
+
process.env.SLACK_APP_TOKEN = config.SLACK_APP_TOKEN;
|
|
152
|
+
process.env.SLACK_USER_ID = config.SLACK_USER_ID;
|
|
153
|
+
|
|
154
|
+
// Import and run the slack bot
|
|
155
|
+
const { createSlackApp } = await import('../slack/slack-app');
|
|
156
|
+
|
|
157
|
+
// Show where config was loaded from
|
|
158
|
+
const localEnvExists = await Bun.file(`${process.cwd()}/.env`).exists();
|
|
159
|
+
const globalEnvExists = await Bun.file(SLACK_CONFIG_FILE).exists();
|
|
160
|
+
const source = localEnvExists ? '.env' : globalEnvExists ? SLACK_CONFIG_FILE : 'environment';
|
|
161
|
+
console.log(`[AFK Code] Loaded config from ${source}`);
|
|
162
|
+
console.log('[AFK Code] Starting Slack bot...');
|
|
163
|
+
|
|
164
|
+
const slackConfig = {
|
|
165
|
+
botToken: config.SLACK_BOT_TOKEN,
|
|
166
|
+
appToken: config.SLACK_APP_TOKEN,
|
|
167
|
+
signingSecret: '',
|
|
168
|
+
userId: config.SLACK_USER_ID,
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const { app, sessionManager } = createSlackApp(slackConfig);
|
|
172
|
+
|
|
173
|
+
// Start session manager (Unix socket server for CLI connections)
|
|
174
|
+
try {
|
|
175
|
+
await sessionManager.start();
|
|
176
|
+
console.log('[AFK Code] Session manager started');
|
|
177
|
+
} catch (err) {
|
|
178
|
+
console.error('[AFK Code] Failed to start session manager:', err);
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Start Slack app
|
|
183
|
+
try {
|
|
184
|
+
await app.start();
|
|
185
|
+
console.log('[AFK Code] Slack bot is running!');
|
|
186
|
+
console.log('');
|
|
187
|
+
console.log('Start a Claude Code session with: afk-code run -- claude');
|
|
188
|
+
console.log('Each session will create a private #afk-* channel');
|
|
189
|
+
} catch (err) {
|
|
190
|
+
console.error('[AFK Code] Failed to start Slack app:', err);
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
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
|
+
}
|