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.
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Text message handler for Claude Telegram Bot.
3
+ */
4
+
5
+ import type { Context } from "grammy";
6
+ import { session } from "../session";
7
+ import { ALLOWED_USERS } from "../config";
8
+ import { isAuthorized, rateLimiter } from "../security";
9
+ import {
10
+ auditLog,
11
+ auditLogRateLimit,
12
+ checkInterrupt,
13
+ startTypingIndicator,
14
+ } from "../utils";
15
+ import { StreamingState, createStatusCallback } from "./streaming";
16
+
17
+ /**
18
+ * Handle incoming text messages.
19
+ */
20
+ export async function handleText(ctx: Context): Promise<void> {
21
+ const userId = ctx.from?.id;
22
+ const username = ctx.from?.username || "unknown";
23
+ const chatId = ctx.chat?.id;
24
+ let message = ctx.message?.text;
25
+
26
+ if (!userId || !message || !chatId) {
27
+ return;
28
+ }
29
+
30
+ // 1. Authorization check
31
+ if (!isAuthorized(userId, ALLOWED_USERS)) {
32
+ await ctx.reply("Unauthorized. Contact the bot owner for access.");
33
+ return;
34
+ }
35
+
36
+ // 2. Check for interrupt prefix
37
+ message = await checkInterrupt(message);
38
+ if (!message.trim()) {
39
+ return;
40
+ }
41
+
42
+ // 3. Rate limit check
43
+ const [allowed, retryAfter] = rateLimiter.check(userId);
44
+ if (!allowed) {
45
+ await auditLogRateLimit(userId, username, retryAfter!);
46
+ await ctx.reply(
47
+ `⏳ Rate limited. Please wait ${retryAfter!.toFixed(1)} seconds.`
48
+ );
49
+ return;
50
+ }
51
+
52
+ // 4. Store message for retry
53
+ session.lastMessage = message;
54
+
55
+ // 5. Mark processing started
56
+ const stopProcessing = session.startProcessing();
57
+
58
+ // 6. Start typing indicator
59
+ const typing = startTypingIndicator(ctx);
60
+
61
+ // 7. Create streaming state and callback
62
+ let state = new StreamingState();
63
+ let statusCallback = createStatusCallback(ctx, state);
64
+
65
+ // 8. Send to Claude with retry logic for crashes
66
+ const MAX_RETRIES = 1;
67
+
68
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
69
+ try {
70
+ const response = await session.sendMessageStreaming(
71
+ message,
72
+ username,
73
+ userId,
74
+ statusCallback,
75
+ chatId,
76
+ ctx
77
+ );
78
+
79
+ // 9. Audit log
80
+ await auditLog(userId, username, "TEXT", message, response);
81
+ break; // Success - exit retry loop
82
+ } catch (error) {
83
+ const errorStr = String(error);
84
+ const isClaudeCodeCrash = errorStr.includes("exited with code");
85
+
86
+ // Clean up any partial messages from this attempt
87
+ for (const toolMsg of state.toolMessages) {
88
+ try {
89
+ await ctx.api.deleteMessage(toolMsg.chat.id, toolMsg.message_id);
90
+ } catch {
91
+ // Ignore cleanup errors
92
+ }
93
+ }
94
+
95
+ // Retry on Claude Code crash (not user cancellation)
96
+ if (isClaudeCodeCrash && attempt < MAX_RETRIES) {
97
+ console.log(
98
+ `Claude Code crashed, retrying (attempt ${attempt + 2}/${MAX_RETRIES + 1})...`
99
+ );
100
+ await session.kill(); // Clear corrupted session
101
+ await ctx.reply(`⚠️ Claude crashed, retrying...`);
102
+ // Reset state for retry
103
+ state = new StreamingState();
104
+ statusCallback = createStatusCallback(ctx, state);
105
+ continue;
106
+ }
107
+
108
+ // Final attempt failed or non-retryable error
109
+ console.error("Error processing message:", error);
110
+
111
+ // Check if it was a cancellation
112
+ if (errorStr.includes("abort") || errorStr.includes("cancel")) {
113
+ // Only show "Query stopped" if it was an explicit stop, not an interrupt from a new message
114
+ const wasInterrupt = session.consumeInterruptFlag();
115
+ if (!wasInterrupt) {
116
+ await ctx.reply("🛑 Query stopped.");
117
+ }
118
+ } else {
119
+ await ctx.reply(`❌ Error: ${errorStr.slice(0, 200)}`);
120
+ }
121
+ break; // Exit loop after handling error
122
+ }
123
+ }
124
+
125
+ // 10. Cleanup
126
+ stopProcessing();
127
+ typing.stop();
128
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Voice message handler for Claude Telegram Bot.
3
+ */
4
+
5
+ import { unlinkSync } from "node:fs";
6
+ import type { Context } from "grammy";
7
+ import { ALLOWED_USERS, TEMP_DIR, TRANSCRIPTION_AVAILABLE } from "../config";
8
+ import { isAuthorized, rateLimiter } from "../security";
9
+ import { session } from "../session";
10
+ import {
11
+ auditLog,
12
+ auditLogRateLimit,
13
+ startTypingIndicator,
14
+ transcribeVoice,
15
+ } from "../utils";
16
+ import { createStatusCallback, StreamingState } from "./streaming";
17
+
18
+ /**
19
+ * Handle incoming voice messages.
20
+ */
21
+ export async function handleVoice(ctx: Context): Promise<void> {
22
+ const userId = ctx.from?.id;
23
+ const username = ctx.from?.username || "unknown";
24
+ const chatId = ctx.chat?.id;
25
+ const voice = ctx.message?.voice;
26
+
27
+ if (!userId || !voice || !chatId) {
28
+ return;
29
+ }
30
+
31
+ // 1. Authorization check
32
+ if (!isAuthorized(userId, ALLOWED_USERS)) {
33
+ await ctx.reply("Unauthorized. Contact the bot owner for access.");
34
+ return;
35
+ }
36
+
37
+ // 2. Check if transcription is available
38
+ if (!TRANSCRIPTION_AVAILABLE) {
39
+ await ctx.reply(
40
+ "Voice transcription is not configured. Set OPENAI_API_KEY in .env",
41
+ );
42
+ return;
43
+ }
44
+
45
+ // 3. Rate limit check
46
+ const [allowed, retryAfter] = rateLimiter.check(userId);
47
+ if (!allowed && retryAfter !== undefined) {
48
+ await auditLogRateLimit(userId, username, retryAfter);
49
+ await ctx.reply(
50
+ `⏳ Rate limited. Please wait ${retryAfter.toFixed(1)} seconds.`,
51
+ );
52
+ return;
53
+ }
54
+
55
+ // 4. Mark processing started (allows /stop to work during transcription/classification)
56
+ const stopProcessing = session.startProcessing();
57
+
58
+ // 5. Start typing indicator for transcription
59
+ const typing = startTypingIndicator(ctx);
60
+
61
+ let voicePath: string | null = null;
62
+
63
+ try {
64
+ // 6. Download voice file
65
+ const file = await ctx.getFile();
66
+ const timestamp = Date.now();
67
+ voicePath = `${TEMP_DIR}/voice_${timestamp}.ogg`;
68
+
69
+ // Download the file
70
+ const downloadRes = await fetch(
71
+ `https://api.telegram.org/file/bot${ctx.api.token}/${file.file_path}`,
72
+ );
73
+ const buffer = await downloadRes.arrayBuffer();
74
+ await Bun.write(voicePath, buffer);
75
+
76
+ // 7. Transcribe
77
+ const statusMsg = await ctx.reply("🎤 Transcribing...");
78
+
79
+ const transcript = await transcribeVoice(voicePath);
80
+ if (!transcript) {
81
+ await ctx.api.editMessageText(
82
+ chatId,
83
+ statusMsg.message_id,
84
+ "❌ Transcription failed.",
85
+ );
86
+ stopProcessing();
87
+ return;
88
+ }
89
+
90
+ // 8. Show transcript
91
+ await ctx.api.editMessageText(
92
+ chatId,
93
+ statusMsg.message_id,
94
+ `🎤 "${transcript}"`,
95
+ );
96
+
97
+ // 9. Create streaming state and callback
98
+ const state = new StreamingState();
99
+ const statusCallback = createStatusCallback(ctx, state);
100
+
101
+ // 10. Send to Claude
102
+ const claudeResponse = await session.sendMessageStreaming(
103
+ transcript,
104
+ username,
105
+ userId,
106
+ statusCallback,
107
+ chatId,
108
+ ctx,
109
+ );
110
+
111
+ // 11. Audit log
112
+ await auditLog(userId, username, "VOICE", transcript, claudeResponse);
113
+ } catch (error) {
114
+ console.error("Error processing voice:", error);
115
+
116
+ if (String(error).includes("abort") || String(error).includes("cancel")) {
117
+ // Only show "Query stopped" if it was an explicit stop, not an interrupt from a new message
118
+ const wasInterrupt = session.consumeInterruptFlag();
119
+ if (!wasInterrupt) {
120
+ await ctx.reply("🛑 Query stopped.");
121
+ }
122
+ } else {
123
+ await ctx.reply(`❌ Error: ${String(error).slice(0, 200)}`);
124
+ }
125
+ } finally {
126
+ stopProcessing();
127
+ typing.stop();
128
+
129
+ // Clean up voice file
130
+ if (voicePath) {
131
+ try {
132
+ unlinkSync(voicePath);
133
+ } catch (error) {
134
+ console.debug("Failed to delete voice file:", error);
135
+ }
136
+ }
137
+ }
138
+ }
package/src/index.ts ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Claude Telegram Bot - TypeScript/Bun Edition
3
+ *
4
+ * Control Claude Code from your phone via Telegram.
5
+ */
6
+
7
+ import { existsSync, readFileSync, unlinkSync } from "node:fs";
8
+ import { run, sequentialize } from "@grammyjs/runner";
9
+ import { Bot } from "grammy";
10
+ import {
11
+ ALLOWED_USERS,
12
+ RESTART_FILE,
13
+ TELEGRAM_TOKEN,
14
+ WORKING_DIR,
15
+ } from "./config";
16
+ import {
17
+ handleBookmarks,
18
+ handleCallback,
19
+ handleCd,
20
+ handleDocument,
21
+ handleNew,
22
+ handlePhoto,
23
+ handleRestart,
24
+ handleResume,
25
+ handleRetry,
26
+ handleStart,
27
+ handleStatus,
28
+ handleStop,
29
+ handleText,
30
+ handleVoice,
31
+ } from "./handlers";
32
+
33
+ // Create bot instance
34
+ const bot = new Bot(TELEGRAM_TOKEN);
35
+
36
+ // Sequentialize non-command messages per user (prevents race conditions)
37
+ // Commands bypass sequentialization so they work immediately
38
+ bot.use(
39
+ sequentialize((ctx) => {
40
+ // Commands are not sequentialized - they work immediately
41
+ if (ctx.message?.text?.startsWith("/")) {
42
+ return undefined;
43
+ }
44
+ // Messages with ! prefix bypass queue (interrupt)
45
+ if (ctx.message?.text?.startsWith("!")) {
46
+ return undefined;
47
+ }
48
+ // Callback queries (button clicks) are not sequentialized
49
+ if (ctx.callbackQuery) {
50
+ return undefined;
51
+ }
52
+ // Other messages are sequentialized per chat
53
+ return ctx.chat?.id.toString();
54
+ }),
55
+ );
56
+
57
+ // ============== Command Handlers ==============
58
+
59
+ bot.command("start", handleStart);
60
+ bot.command("new", handleNew);
61
+ bot.command("stop", handleStop);
62
+ bot.command("status", handleStatus);
63
+ bot.command("resume", handleResume);
64
+ bot.command("restart", handleRestart);
65
+ bot.command("retry", handleRetry);
66
+ bot.command("cd", handleCd);
67
+ bot.command("bookmarks", handleBookmarks);
68
+
69
+ // ============== Message Handlers ==============
70
+
71
+ // Text messages
72
+ bot.on("message:text", handleText);
73
+
74
+ // Voice messages
75
+ bot.on("message:voice", handleVoice);
76
+
77
+ // Photo messages
78
+ bot.on("message:photo", handlePhoto);
79
+
80
+ // Document messages
81
+ bot.on("message:document", handleDocument);
82
+
83
+ // ============== Callback Queries ==============
84
+
85
+ bot.on("callback_query:data", handleCallback);
86
+
87
+ // ============== Error Handler ==============
88
+
89
+ bot.catch((err) => {
90
+ console.error("Bot error:", err);
91
+ });
92
+
93
+ // ============== Startup ==============
94
+
95
+ console.log("=".repeat(50));
96
+ console.log("Claude Telegram Bot - TypeScript Edition");
97
+ console.log("=".repeat(50));
98
+ console.log(`Working directory: ${WORKING_DIR}`);
99
+ console.log(`Allowed users: ${ALLOWED_USERS.length}`);
100
+ console.log("Starting bot...");
101
+
102
+ // Get bot info first
103
+ const botInfo = await bot.api.getMe();
104
+ console.log(`Bot started: @${botInfo.username}`);
105
+
106
+ // Check for pending restart message to update
107
+ if (existsSync(RESTART_FILE)) {
108
+ try {
109
+ const data = JSON.parse(readFileSync(RESTART_FILE, "utf-8"));
110
+ const age = Date.now() - data.timestamp;
111
+
112
+ // Only update if restart was recent (within 30 seconds)
113
+ if (age < 30000 && data.chat_id && data.message_id) {
114
+ await bot.api.editMessageText(
115
+ data.chat_id,
116
+ data.message_id,
117
+ "✅ Bot restarted",
118
+ );
119
+ }
120
+ unlinkSync(RESTART_FILE);
121
+ } catch (e) {
122
+ console.warn("Failed to update restart message:", e);
123
+ try {
124
+ unlinkSync(RESTART_FILE);
125
+ } catch {}
126
+ }
127
+ }
128
+
129
+ // Start with concurrent runner (commands work immediately)
130
+ const runner = run(bot);
131
+
132
+ // Graceful shutdown
133
+ const stopRunner = () => {
134
+ if (runner.isRunning()) {
135
+ console.log("Stopping bot...");
136
+ runner.stop();
137
+ }
138
+ };
139
+
140
+ process.on("SIGINT", () => {
141
+ console.log("Received SIGINT");
142
+ stopRunner();
143
+ process.exit(0);
144
+ });
145
+
146
+ process.on("SIGTERM", () => {
147
+ console.log("Received SIGTERM");
148
+ stopRunner();
149
+ process.exit(0);
150
+ });
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Security module for Claude Telegram Bot.
3
+ *
4
+ * Rate limiting, path validation, command safety.
5
+ */
6
+
7
+ import { realpathSync } from "fs";
8
+ import { normalize, resolve } from "path";
9
+ import {
10
+ ALLOWED_PATHS,
11
+ BLOCKED_PATTERNS,
12
+ RATE_LIMIT_ENABLED,
13
+ RATE_LIMIT_REQUESTS,
14
+ RATE_LIMIT_WINDOW,
15
+ TEMP_PATHS,
16
+ } from "./config";
17
+ import type { RateLimitBucket } from "./types";
18
+
19
+ // ============== Rate Limiter ==============
20
+
21
+ // Bucket expiration time (1 hour) - prevents unbounded memory growth
22
+ const BUCKET_EXPIRATION_MS = 60 * 60 * 1000;
23
+ // Cleanup interval (10 minutes)
24
+ const CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
25
+
26
+ class RateLimiter {
27
+ private buckets = new Map<number, RateLimitBucket>();
28
+ private maxTokens: number;
29
+ private refillRate: number; // tokens per second
30
+ private cleanupTimer: ReturnType<typeof setInterval> | null = null;
31
+
32
+ constructor() {
33
+ this.maxTokens = RATE_LIMIT_REQUESTS;
34
+ this.refillRate = RATE_LIMIT_REQUESTS / RATE_LIMIT_WINDOW;
35
+
36
+ // Start periodic cleanup
37
+ this.startCleanup();
38
+ }
39
+
40
+ /**
41
+ * Start periodic cleanup of expired buckets.
42
+ */
43
+ private startCleanup(): void {
44
+ this.cleanupTimer = setInterval(() => {
45
+ this.cleanupExpiredBuckets();
46
+ }, CLEANUP_INTERVAL_MS);
47
+
48
+ // Don't prevent process from exiting
49
+ if (this.cleanupTimer.unref) {
50
+ this.cleanupTimer.unref();
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Remove buckets that haven't been used recently.
56
+ */
57
+ private cleanupExpiredBuckets(): void {
58
+ const now = Date.now();
59
+ let removed = 0;
60
+
61
+ for (const [userId, bucket] of this.buckets) {
62
+ if (now - bucket.lastUpdate > BUCKET_EXPIRATION_MS) {
63
+ this.buckets.delete(userId);
64
+ removed++;
65
+ }
66
+ }
67
+
68
+ if (removed > 0) {
69
+ console.debug(`Rate limiter cleanup: removed ${removed} expired buckets`);
70
+ }
71
+ }
72
+
73
+ check(userId: number): [allowed: boolean, retryAfter?: number] {
74
+ if (!RATE_LIMIT_ENABLED) {
75
+ return [true];
76
+ }
77
+
78
+ const now = Date.now();
79
+ let bucket = this.buckets.get(userId);
80
+
81
+ if (!bucket) {
82
+ bucket = { tokens: this.maxTokens, lastUpdate: now };
83
+ this.buckets.set(userId, bucket);
84
+ }
85
+
86
+ // Refill tokens based on time elapsed
87
+ const elapsed = (now - bucket.lastUpdate) / 1000;
88
+ bucket.tokens = Math.min(
89
+ this.maxTokens,
90
+ bucket.tokens + elapsed * this.refillRate,
91
+ );
92
+ bucket.lastUpdate = now;
93
+
94
+ if (bucket.tokens >= 1) {
95
+ bucket.tokens -= 1;
96
+ return [true];
97
+ }
98
+
99
+ // Calculate time until next token
100
+ const retryAfter = (1 - bucket.tokens) / this.refillRate;
101
+ return [false, retryAfter];
102
+ }
103
+
104
+ getStatus(userId: number): {
105
+ tokens: number;
106
+ max: number;
107
+ refillRate: number;
108
+ } {
109
+ const bucket = this.buckets.get(userId);
110
+ return {
111
+ tokens: bucket?.tokens ?? this.maxTokens,
112
+ max: this.maxTokens,
113
+ refillRate: this.refillRate,
114
+ };
115
+ }
116
+ }
117
+
118
+ export const rateLimiter = new RateLimiter();
119
+
120
+ // ============== Path Validation ==============
121
+
122
+ export function isPathAllowed(path: string): boolean {
123
+ try {
124
+ // Expand ~ and resolve to absolute path
125
+ const expanded = path.replace(/^~/, process.env.HOME || "");
126
+ const normalized = normalize(expanded);
127
+
128
+ // Try to resolve symlinks (may fail if path doesn't exist yet)
129
+ let resolved: string;
130
+ try {
131
+ resolved = realpathSync(normalized);
132
+ } catch {
133
+ resolved = resolve(normalized);
134
+ }
135
+
136
+ // Always allow temp paths (for bot's own files)
137
+ for (const tempPath of TEMP_PATHS) {
138
+ if (resolved.startsWith(tempPath)) {
139
+ return true;
140
+ }
141
+ }
142
+
143
+ // Check against allowed paths using proper containment
144
+ for (const allowed of ALLOWED_PATHS) {
145
+ const allowedResolved = resolve(allowed);
146
+ if (
147
+ resolved === allowedResolved ||
148
+ resolved.startsWith(allowedResolved + "/")
149
+ ) {
150
+ return true;
151
+ }
152
+ }
153
+
154
+ return false;
155
+ } catch {
156
+ return false;
157
+ }
158
+ }
159
+
160
+ // ============== Command Safety ==============
161
+
162
+ export function checkCommandSafety(
163
+ command: string,
164
+ ): [safe: boolean, reason: string] {
165
+ const lowerCommand = command.toLowerCase();
166
+
167
+ // Check blocked patterns
168
+ for (const pattern of BLOCKED_PATTERNS) {
169
+ if (lowerCommand.includes(pattern.toLowerCase())) {
170
+ return [false, `Blocked pattern: ${pattern}`];
171
+ }
172
+ }
173
+
174
+ // Special handling for rm commands - validate paths
175
+ if (lowerCommand.includes("rm ")) {
176
+ try {
177
+ // Simple parsing: extract arguments after rm
178
+ const rmMatch = command.match(/rm\s+(.+)/i);
179
+ if (rmMatch) {
180
+ const args = rmMatch[1]!.split(/\s+/);
181
+ for (const arg of args) {
182
+ // Skip flags
183
+ if (arg.startsWith("-") || arg.length <= 1) continue;
184
+
185
+ // Check if path is allowed
186
+ if (!isPathAllowed(arg)) {
187
+ return [false, `rm target outside allowed paths: ${arg}`];
188
+ }
189
+ }
190
+ }
191
+ } catch {
192
+ // If parsing fails, be cautious
193
+ return [false, "Could not parse rm command for safety check"];
194
+ }
195
+ }
196
+
197
+ return [true, ""];
198
+ }
199
+
200
+ // ============== Authorization ==============
201
+
202
+ export function isAuthorized(
203
+ userId: number | undefined,
204
+ allowedUsers: number[],
205
+ ): boolean {
206
+ if (!userId) return false;
207
+ if (allowedUsers.length === 0) return false;
208
+ return allowedUsers.includes(userId);
209
+ }