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/.env.example +76 -0
- package/CLAUDE.md +116 -0
- package/LICENSE +21 -0
- package/Makefile +142 -0
- package/README.md +268 -0
- package/SECURITY.md +177 -0
- package/ask_user_mcp/server.ts +115 -0
- package/assets/demo-research.gif +0 -0
- package/assets/demo-video-summary.gif +0 -0
- package/assets/demo-workout.gif +0 -0
- package/assets/demo.gif +0 -0
- package/bun.lock +266 -0
- package/bunfig.toml +2 -0
- package/docs/personal-assistant-guide.md +549 -0
- package/launchagent/com.claude-telegram-ts.plist.template +76 -0
- package/launchagent/start.sh +14 -0
- package/mcp-config.example.ts +42 -0
- package/package.json +46 -0
- package/src/__tests__/formatting.test.ts +118 -0
- package/src/__tests__/security.test.ts +124 -0
- package/src/__tests__/setup.ts +8 -0
- package/src/bookmarks.ts +106 -0
- package/src/bot.ts +151 -0
- package/src/cli.ts +278 -0
- package/src/config.ts +254 -0
- package/src/formatting.ts +309 -0
- package/src/handlers/callback.ts +248 -0
- package/src/handlers/commands.ts +392 -0
- package/src/handlers/document.ts +585 -0
- package/src/handlers/index.ts +21 -0
- package/src/handlers/media-group.ts +205 -0
- package/src/handlers/photo.ts +215 -0
- package/src/handlers/streaming.ts +231 -0
- package/src/handlers/text.ts +128 -0
- package/src/handlers/voice.ts +138 -0
- package/src/index.ts +150 -0
- package/src/security.ts +209 -0
- package/src/session.ts +565 -0
- package/src/types.ts +77 -0
- package/src/utils.ts +246 -0
- package/tsconfig.json +29 -0
|
@@ -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
|
+
});
|
package/src/security.ts
ADDED
|
@@ -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
|
+
}
|