ctb 1.0.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/src/cli.ts ADDED
@@ -0,0 +1,278 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * CLI entry point for ctb (Claude Telegram Bot)
4
+ *
5
+ * Usage:
6
+ * ctb # Start bot using .env in current directory
7
+ * ctb --help # Show usage
8
+ * ctb --version # Show version
9
+ * ctb --token=xxx # Override TELEGRAM_BOT_TOKEN
10
+ * ctb --users=123 # Override TELEGRAM_ALLOWED_USERS
11
+ * ctb --dir=/path # Override working directory
12
+ */
13
+
14
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { resolve } from "node:path";
16
+ import { createInterface } from "node:readline";
17
+
18
+ const VERSION = "1.0.0";
19
+
20
+ interface CliOptions {
21
+ token?: string;
22
+ users?: string;
23
+ dir?: string;
24
+ help?: boolean;
25
+ version?: boolean;
26
+ }
27
+
28
+ function parseArgs(args: string[]): CliOptions {
29
+ const options: CliOptions = {};
30
+
31
+ for (const arg of args) {
32
+ if (arg === "--help" || arg === "-h") {
33
+ options.help = true;
34
+ } else if (arg === "--version" || arg === "-v") {
35
+ options.version = true;
36
+ } else if (arg.startsWith("--token=")) {
37
+ options.token = arg.slice(8);
38
+ } else if (arg.startsWith("--users=")) {
39
+ options.users = arg.slice(8);
40
+ } else if (arg.startsWith("--dir=")) {
41
+ options.dir = arg.slice(6);
42
+ }
43
+ }
44
+
45
+ return options;
46
+ }
47
+
48
+ function showHelp(): void {
49
+ console.log(`
50
+ ctb - Claude Telegram Bot
51
+
52
+ Run a Telegram bot that controls Claude Code in your project directory.
53
+
54
+ USAGE:
55
+ ctb [options]
56
+
57
+ OPTIONS:
58
+ --help, -h Show this help message
59
+ --version, -v Show version
60
+ --token=TOKEN Override TELEGRAM_BOT_TOKEN from .env
61
+ --users=IDS Override TELEGRAM_ALLOWED_USERS (comma-separated)
62
+ --dir=PATH Override working directory (default: current directory)
63
+
64
+ ENVIRONMENT:
65
+ Reads .env from current directory. Required variables:
66
+ TELEGRAM_BOT_TOKEN - Bot token from @BotFather
67
+ TELEGRAM_ALLOWED_USERS - Comma-separated Telegram user IDs
68
+
69
+ EXAMPLES:
70
+ cd ~/my-project && ctb # Start bot for this project
71
+ ctb --dir=/path/to/project # Start bot for specific directory
72
+ ctb --token=xxx --users=123,456 # Override env vars
73
+
74
+ Multiple instances can run simultaneously in different directories.
75
+ `);
76
+ }
77
+
78
+ async function prompt(question: string): Promise<string> {
79
+ const rl = createInterface({
80
+ input: process.stdin,
81
+ output: process.stdout,
82
+ });
83
+
84
+ return new Promise((resolve) => {
85
+ rl.question(question, (answer) => {
86
+ rl.close();
87
+ resolve(answer.trim());
88
+ });
89
+ });
90
+ }
91
+
92
+ function loadEnvFile(dir: string): Record<string, string> {
93
+ const envPath = resolve(dir, ".env");
94
+ const env: Record<string, string> = {};
95
+
96
+ if (!existsSync(envPath)) {
97
+ return env;
98
+ }
99
+
100
+ const content = readFileSync(envPath, "utf-8");
101
+ for (const line of content.split("\n")) {
102
+ const trimmed = line.trim();
103
+ if (!trimmed || trimmed.startsWith("#")) continue;
104
+
105
+ const eqIndex = trimmed.indexOf("=");
106
+ if (eqIndex === -1) continue;
107
+
108
+ const key = trimmed.slice(0, eqIndex).trim();
109
+ let value = trimmed.slice(eqIndex + 1).trim();
110
+
111
+ // Remove quotes if present
112
+ if (
113
+ (value.startsWith('"') && value.endsWith('"')) ||
114
+ (value.startsWith("'") && value.endsWith("'"))
115
+ ) {
116
+ value = value.slice(1, -1);
117
+ }
118
+
119
+ env[key] = value;
120
+ }
121
+
122
+ return env;
123
+ }
124
+
125
+ function saveEnvFile(dir: string, env: Record<string, string>): void {
126
+ const envPath = resolve(dir, ".env");
127
+ const lines: string[] = [];
128
+
129
+ // Preserve existing content
130
+ if (existsSync(envPath)) {
131
+ const existing = readFileSync(envPath, "utf-8");
132
+ const existingKeys = new Set<string>();
133
+
134
+ for (const line of existing.split("\n")) {
135
+ const trimmed = line.trim();
136
+ if (!trimmed || trimmed.startsWith("#")) {
137
+ lines.push(line);
138
+ continue;
139
+ }
140
+
141
+ const eqIndex = trimmed.indexOf("=");
142
+ if (eqIndex !== -1) {
143
+ const key = trimmed.slice(0, eqIndex).trim();
144
+ existingKeys.add(key);
145
+ // Use new value if provided, otherwise keep original
146
+ if (key in env) {
147
+ lines.push(`${key}=${env[key]}`);
148
+ } else {
149
+ lines.push(line);
150
+ }
151
+ } else {
152
+ lines.push(line);
153
+ }
154
+ }
155
+
156
+ // Add new keys not in existing file
157
+ for (const [key, value] of Object.entries(env)) {
158
+ if (!existingKeys.has(key)) {
159
+ lines.push(`${key}=${value}`);
160
+ }
161
+ }
162
+ } else {
163
+ // New file
164
+ for (const [key, value] of Object.entries(env)) {
165
+ lines.push(`${key}=${value}`);
166
+ }
167
+ }
168
+
169
+ writeFileSync(envPath, `${lines.join("\n")}\n`);
170
+ }
171
+
172
+ async function interactiveSetup(
173
+ dir: string,
174
+ existingEnv: Record<string, string>,
175
+ ): Promise<{ token: string; users: string }> {
176
+ console.log(`\nNo .env found or missing required variables in ${dir}\n`);
177
+
178
+ let token = existingEnv.TELEGRAM_BOT_TOKEN || "";
179
+ let users = existingEnv.TELEGRAM_ALLOWED_USERS || "";
180
+
181
+ if (!token) {
182
+ console.log("Get a bot token from @BotFather on Telegram");
183
+ token = await prompt("Enter TELEGRAM_BOT_TOKEN: ");
184
+ if (!token) {
185
+ console.error("Token is required");
186
+ process.exit(1);
187
+ }
188
+ }
189
+
190
+ if (!users) {
191
+ console.log(
192
+ "\nEnter your Telegram user ID(s). Find yours by messaging @userinfobot",
193
+ );
194
+ users = await prompt("Enter TELEGRAM_ALLOWED_USERS (comma-separated): ");
195
+ if (!users) {
196
+ console.error("At least one user ID is required");
197
+ process.exit(1);
198
+ }
199
+ }
200
+
201
+ // Ask to save
202
+ const save = await prompt("\nSave to .env? (Y/n): ");
203
+ if (save.toLowerCase() !== "n") {
204
+ saveEnvFile(dir, {
205
+ ...existingEnv,
206
+ TELEGRAM_BOT_TOKEN: token,
207
+ TELEGRAM_ALLOWED_USERS: users,
208
+ });
209
+ console.log(`Saved to ${resolve(dir, ".env")}\n`);
210
+ }
211
+
212
+ return { token, users };
213
+ }
214
+
215
+ async function main(): Promise<void> {
216
+ const args = process.argv.slice(2);
217
+ const options = parseArgs(args);
218
+
219
+ if (options.help) {
220
+ showHelp();
221
+ process.exit(0);
222
+ }
223
+
224
+ if (options.version) {
225
+ console.log(`ctb version ${VERSION}`);
226
+ process.exit(0);
227
+ }
228
+
229
+ // Determine working directory
230
+ const workingDir = options.dir ? resolve(options.dir) : process.cwd();
231
+
232
+ // Load .env from working directory
233
+ const envFile = loadEnvFile(workingDir);
234
+
235
+ // Merge: CLI args > .env file > process.env
236
+ let token =
237
+ options.token ||
238
+ envFile.TELEGRAM_BOT_TOKEN ||
239
+ process.env.TELEGRAM_BOT_TOKEN ||
240
+ "";
241
+ let users =
242
+ options.users ||
243
+ envFile.TELEGRAM_ALLOWED_USERS ||
244
+ process.env.TELEGRAM_ALLOWED_USERS ||
245
+ "";
246
+
247
+ // Interactive setup if missing required vars
248
+ if (!token || !users) {
249
+ const setup = await interactiveSetup(workingDir, envFile);
250
+ token = token || setup.token;
251
+ users = users || setup.users;
252
+ }
253
+
254
+ // Set environment variables for the bot
255
+ process.env.TELEGRAM_BOT_TOKEN = token;
256
+ process.env.TELEGRAM_ALLOWED_USERS = users;
257
+ process.env.CLAUDE_WORKING_DIR = workingDir;
258
+
259
+ // Pass through other env vars from .env file
260
+ for (const [key, value] of Object.entries(envFile)) {
261
+ if (!(key in process.env)) {
262
+ process.env[key] = value;
263
+ }
264
+ }
265
+
266
+ // Set CTB_INSTANCE_DIR for session isolation
267
+ process.env.CTB_INSTANCE_DIR = workingDir;
268
+
269
+ console.log(`\nStarting ctb in ${workingDir}...\n`);
270
+
271
+ // Import and start the bot
272
+ await import("./bot.js");
273
+ }
274
+
275
+ main().catch((error) => {
276
+ console.error("Fatal error:", error);
277
+ process.exit(1);
278
+ });
package/src/config.ts ADDED
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Configuration for Claude Telegram Bot.
3
+ *
4
+ * All environment variables, paths, constants, and safety settings.
5
+ */
6
+
7
+ import { homedir } from "node:os";
8
+ import { dirname, resolve } from "node:path";
9
+ import type { McpServerConfig } from "./types";
10
+
11
+ // ============== Environment Setup ==============
12
+
13
+ const HOME = homedir();
14
+
15
+ // Ensure necessary paths are available for Claude's bash commands
16
+ // LaunchAgents don't inherit the full shell environment
17
+ const EXTRA_PATHS = [
18
+ `${HOME}/.local/bin`,
19
+ `${HOME}/.bun/bin`,
20
+ "/opt/homebrew/bin",
21
+ "/opt/homebrew/sbin",
22
+ "/usr/local/bin",
23
+ ];
24
+
25
+ const currentPath = process.env.PATH || "";
26
+ const pathParts = currentPath.split(":");
27
+ for (const extraPath of EXTRA_PATHS) {
28
+ if (!pathParts.includes(extraPath)) {
29
+ pathParts.unshift(extraPath);
30
+ }
31
+ }
32
+ process.env.PATH = pathParts.join(":");
33
+
34
+ // ============== Core Configuration ==============
35
+
36
+ export const TELEGRAM_TOKEN = process.env.TELEGRAM_BOT_TOKEN || "";
37
+ export const ALLOWED_USERS: number[] = (
38
+ process.env.TELEGRAM_ALLOWED_USERS || ""
39
+ )
40
+ .split(",")
41
+ .filter((x) => x.trim())
42
+ .map((x) => parseInt(x.trim(), 10))
43
+ .filter((x) => !Number.isNaN(x));
44
+
45
+ export const WORKING_DIR = process.env.CLAUDE_WORKING_DIR || HOME;
46
+ export const OPENAI_API_KEY = process.env.OPENAI_API_KEY || "";
47
+
48
+ // ============== Claude CLI Path ==============
49
+
50
+ // Auto-detect from PATH, or use environment override
51
+ function findClaudeCli(): string {
52
+ const envPath = process.env.CLAUDE_CLI_PATH;
53
+ if (envPath) return envPath;
54
+
55
+ // Try to find claude in PATH using Bun.which
56
+ const whichResult = Bun.which("claude");
57
+ if (whichResult) return whichResult;
58
+
59
+ // Final fallback
60
+ return "/usr/local/bin/claude";
61
+ }
62
+
63
+ export const CLAUDE_CLI_PATH = findClaudeCli();
64
+
65
+ // ============== MCP Configuration ==============
66
+
67
+ // MCP servers loaded from mcp-config.ts
68
+ let MCP_SERVERS: Record<string, McpServerConfig> = {};
69
+
70
+ try {
71
+ // Dynamic import of MCP config
72
+ const mcpConfigPath = resolve(dirname(import.meta.dir), "mcp-config.ts");
73
+ const mcpModule = await import(mcpConfigPath).catch(() => null);
74
+ if (mcpModule?.MCP_SERVERS) {
75
+ MCP_SERVERS = mcpModule.MCP_SERVERS;
76
+ console.log(
77
+ `Loaded ${Object.keys(MCP_SERVERS).length} MCP servers from mcp-config.ts`,
78
+ );
79
+ }
80
+ } catch {
81
+ console.log("No mcp-config.ts found - running without MCPs");
82
+ }
83
+
84
+ export { MCP_SERVERS };
85
+
86
+ // ============== Security Configuration ==============
87
+
88
+ // Allowed directories for file operations
89
+ const defaultAllowedPaths = [
90
+ WORKING_DIR,
91
+ `${HOME}/Documents`,
92
+ `${HOME}/Downloads`,
93
+ `${HOME}/Desktop`,
94
+ `${HOME}/.claude`, // Claude Code data (plans, settings)
95
+ ];
96
+
97
+ const allowedPathsStr = process.env.ALLOWED_PATHS || "";
98
+ export const ALLOWED_PATHS: string[] = allowedPathsStr
99
+ ? allowedPathsStr
100
+ .split(",")
101
+ .map((p) => p.trim())
102
+ .filter(Boolean)
103
+ : defaultAllowedPaths;
104
+
105
+ // Build safety prompt dynamically from ALLOWED_PATHS
106
+ function buildSafetyPrompt(allowedPaths: string[]): string {
107
+ const pathsList = allowedPaths
108
+ .map((p) => ` - ${p} (and subdirectories)`)
109
+ .join("\n");
110
+
111
+ return `
112
+ CRITICAL SAFETY RULES FOR TELEGRAM BOT:
113
+
114
+ 1. NEVER delete, remove, or overwrite files without EXPLICIT confirmation from the user.
115
+ - If user asks to delete something, respond: "Are you sure you want to delete [file]? Reply 'yes delete it' to confirm."
116
+ - Only proceed with deletion if user replies with explicit confirmation like "yes delete it", "confirm delete"
117
+ - This applies to: rm, trash, unlink, shred, or any file deletion
118
+
119
+ 2. You can ONLY access files in these directories:
120
+ ${pathsList}
121
+ - REFUSE any file operations outside these paths
122
+
123
+ 3. NEVER run dangerous commands like:
124
+ - rm -rf (recursive force delete)
125
+ - Any command that affects files outside allowed directories
126
+ - Commands that could damage the system
127
+
128
+ 4. For any destructive or irreversible action, ALWAYS ask for confirmation first.
129
+
130
+ You are running via Telegram, so the user cannot easily undo mistakes. Be extra careful!
131
+ `;
132
+ }
133
+
134
+ export const SAFETY_PROMPT = buildSafetyPrompt(ALLOWED_PATHS);
135
+
136
+ // Dangerous command patterns to block
137
+ export const BLOCKED_PATTERNS = [
138
+ "rm -rf /",
139
+ "rm -rf ~",
140
+ "rm -rf $HOME",
141
+ "sudo rm",
142
+ ":(){ :|:& };:", // Fork bomb
143
+ "> /dev/sd",
144
+ "mkfs.",
145
+ "dd if=",
146
+ ];
147
+
148
+ // Query timeout (3 minutes)
149
+ export const QUERY_TIMEOUT_MS = 180_000;
150
+
151
+ // ============== Voice Transcription ==============
152
+
153
+ const BASE_TRANSCRIPTION_PROMPT = `Transcribe this voice message accurately.
154
+ The speaker may use multiple languages (English, and possibly others).
155
+ Focus on accuracy for proper nouns, technical terms, and commands.`;
156
+
157
+ const TRANSCRIPTION_CONTEXT = process.env.TRANSCRIPTION_CONTEXT || "";
158
+
159
+ export const TRANSCRIPTION_PROMPT = TRANSCRIPTION_CONTEXT
160
+ ? `${BASE_TRANSCRIPTION_PROMPT}\n\nAdditional context:\n${TRANSCRIPTION_CONTEXT}`
161
+ : BASE_TRANSCRIPTION_PROMPT;
162
+
163
+ export const TRANSCRIPTION_AVAILABLE = !!OPENAI_API_KEY;
164
+
165
+ // ============== Thinking Keywords ==============
166
+
167
+ const thinkingKeywordsStr =
168
+ process.env.THINKING_KEYWORDS || "think,pensa,ragiona";
169
+ const thinkingDeepKeywordsStr =
170
+ process.env.THINKING_DEEP_KEYWORDS || "ultrathink,think hard,pensa bene";
171
+
172
+ export const THINKING_KEYWORDS = thinkingKeywordsStr
173
+ .split(",")
174
+ .map((k) => k.trim().toLowerCase());
175
+ export const THINKING_DEEP_KEYWORDS = thinkingDeepKeywordsStr
176
+ .split(",")
177
+ .map((k) => k.trim().toLowerCase());
178
+
179
+ // ============== Media Group Settings ==============
180
+
181
+ export const MEDIA_GROUP_TIMEOUT = 1000; // ms to wait for more photos in a group
182
+
183
+ // ============== Telegram Message Limits ==============
184
+
185
+ export const TELEGRAM_MESSAGE_LIMIT = 4096; // Max characters per message
186
+ export const TELEGRAM_SAFE_LIMIT = 4000; // Safe limit with buffer for formatting
187
+ export const STREAMING_THROTTLE_MS = 500; // Throttle streaming updates
188
+ export const BUTTON_LABEL_MAX_LENGTH = 30; // Max chars for inline button labels
189
+
190
+ // ============== Audit Logging ==============
191
+
192
+ export const AUDIT_LOG_PATH =
193
+ process.env.AUDIT_LOG_PATH || "/tmp/claude-telegram-audit.log";
194
+ export const AUDIT_LOG_JSON =
195
+ (process.env.AUDIT_LOG_JSON || "false").toLowerCase() === "true";
196
+
197
+ // ============== Rate Limiting ==============
198
+
199
+ export const RATE_LIMIT_ENABLED =
200
+ (process.env.RATE_LIMIT_ENABLED || "true").toLowerCase() === "true";
201
+ export const RATE_LIMIT_REQUESTS = parseInt(
202
+ process.env.RATE_LIMIT_REQUESTS || "20",
203
+ 10,
204
+ );
205
+ export const RATE_LIMIT_WINDOW = parseInt(
206
+ process.env.RATE_LIMIT_WINDOW || "60",
207
+ 10,
208
+ );
209
+
210
+ // ============== File Paths ==============
211
+
212
+ // Generate a short hash for instance isolation
213
+ function hashDir(dir: string): string {
214
+ let hash = 0;
215
+ for (let i = 0; i < dir.length; i++) {
216
+ const char = dir.charCodeAt(i);
217
+ hash = ((hash << 5) - hash + char) | 0;
218
+ }
219
+ return Math.abs(hash).toString(36).slice(0, 8);
220
+ }
221
+
222
+ // Instance directory for session isolation (set by CLI or defaults to working dir)
223
+ const INSTANCE_DIR = process.env.CTB_INSTANCE_DIR || WORKING_DIR;
224
+ const INSTANCE_HASH = hashDir(INSTANCE_DIR);
225
+ const INSTANCE_TEMP_DIR = `/tmp/ctb-${INSTANCE_HASH}`;
226
+
227
+ export const SESSION_FILE = `${INSTANCE_TEMP_DIR}/session.json`;
228
+ export const RESTART_FILE = `${INSTANCE_TEMP_DIR}/restart.json`;
229
+ export const TEMP_DIR = `${INSTANCE_TEMP_DIR}/downloads`;
230
+
231
+ // Temp paths that are always allowed for bot operations
232
+ export const TEMP_PATHS = ["/tmp/", "/private/tmp/", "/var/folders/"];
233
+
234
+ // Ensure temp directories exist
235
+ await Bun.write(`${INSTANCE_TEMP_DIR}/.keep`, "");
236
+ await Bun.write(`${TEMP_DIR}/.keep`, "");
237
+
238
+ // ============== Validation ==============
239
+
240
+ if (!TELEGRAM_TOKEN) {
241
+ console.error("ERROR: TELEGRAM_BOT_TOKEN environment variable is required");
242
+ process.exit(1);
243
+ }
244
+
245
+ if (ALLOWED_USERS.length === 0) {
246
+ console.error(
247
+ "ERROR: TELEGRAM_ALLOWED_USERS environment variable is required",
248
+ );
249
+ process.exit(1);
250
+ }
251
+
252
+ console.log(
253
+ `Config loaded: ${ALLOWED_USERS.length} allowed users, working dir: ${WORKING_DIR}`,
254
+ );