afk-code 0.1.0 → 0.1.1
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 +5 -9
- package/dist/cli/index.js +1970 -0
- package/package.json +12 -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,175 +0,0 @@
|
|
|
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
|
-
}
|
package/src/slack/index.ts
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,91 +0,0 @@
|
|
|
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
|
-
}
|