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,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
- }
@@ -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
- }