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/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "ctb",
3
+ "version": "1.0.0",
4
+ "description": "Control Claude Code from Telegram - run multiple bot instances per project",
5
+ "type": "module",
6
+ "bin": {
7
+ "ctb": "./src/cli.ts"
8
+ },
9
+ "scripts": {
10
+ "start": "bun run src/bot.ts",
11
+ "dev": "bun --watch run src/bot.ts",
12
+ "ctb": "bun run src/cli.ts",
13
+ "typecheck": "bun run --bun tsc --noEmit"
14
+ },
15
+ "keywords": [
16
+ "claude",
17
+ "telegram",
18
+ "bot",
19
+ "cli",
20
+ "ai",
21
+ "anthropic"
22
+ ],
23
+ "author": "",
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/htlin/claude-telegram-bot"
28
+ },
29
+ "engines": {
30
+ "bun": ">=1.0.0"
31
+ },
32
+ "devDependencies": {
33
+ "@types/bun": "latest"
34
+ },
35
+ "peerDependencies": {
36
+ "typescript": "^5"
37
+ },
38
+ "dependencies": {
39
+ "@anthropic-ai/claude-agent-sdk": "^0.1.76",
40
+ "@grammyjs/runner": "^2.0.3",
41
+ "@modelcontextprotocol/sdk": "^1.25.1",
42
+ "grammy": "^1.38.4",
43
+ "openai": "^6.15.0",
44
+ "zod": "^4.2.1"
45
+ }
46
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Unit tests for formatting module.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { convertMarkdownToHtml, escapeHtml } from "../formatting";
7
+
8
+ describe("escapeHtml", () => {
9
+ test("escapes ampersand", () => {
10
+ expect(escapeHtml("a & b")).toBe("a & b");
11
+ });
12
+
13
+ test("escapes less than", () => {
14
+ expect(escapeHtml("a < b")).toBe("a &lt; b");
15
+ });
16
+
17
+ test("escapes greater than", () => {
18
+ expect(escapeHtml("a > b")).toBe("a &gt; b");
19
+ });
20
+
21
+ test("escapes quotes", () => {
22
+ expect(escapeHtml('say "hello"')).toBe("say &quot;hello&quot;");
23
+ });
24
+
25
+ test("escapes multiple special characters", () => {
26
+ expect(escapeHtml('<script>"alert"</script>')).toBe(
27
+ "&lt;script&gt;&quot;alert&quot;&lt;/script&gt;",
28
+ );
29
+ });
30
+
31
+ test("handles empty string", () => {
32
+ expect(escapeHtml("")).toBe("");
33
+ });
34
+
35
+ test("returns unchanged text without special chars", () => {
36
+ expect(escapeHtml("Hello World")).toBe("Hello World");
37
+ });
38
+ });
39
+
40
+ describe("convertMarkdownToHtml", () => {
41
+ test("converts bold with double asterisks", () => {
42
+ expect(convertMarkdownToHtml("**bold**")).toBe("<b>bold</b>");
43
+ });
44
+
45
+ test("converts bold with single asterisks", () => {
46
+ expect(convertMarkdownToHtml("*bold*")).toBe("<b>bold</b>");
47
+ });
48
+
49
+ test("converts bold with double underscores", () => {
50
+ expect(convertMarkdownToHtml("__bold__")).toBe("<b>bold</b>");
51
+ });
52
+
53
+ test("converts italic with single underscores", () => {
54
+ expect(convertMarkdownToHtml("_italic_")).toBe("<i>italic</i>");
55
+ });
56
+
57
+ test("converts inline code", () => {
58
+ expect(convertMarkdownToHtml("`code`")).toBe("<code>code</code>");
59
+ });
60
+
61
+ test("converts code blocks", () => {
62
+ const input = "```\nconst x = 1;\n```";
63
+ expect(convertMarkdownToHtml(input)).toContain("<pre>");
64
+ expect(convertMarkdownToHtml(input)).toContain("const x = 1;");
65
+ expect(convertMarkdownToHtml(input)).toContain("</pre>");
66
+ });
67
+
68
+ test("converts code blocks with language hint", () => {
69
+ const input = "```javascript\nconst x = 1;\n```";
70
+ expect(convertMarkdownToHtml(input)).toContain("<pre>");
71
+ });
72
+
73
+ test("converts headers to bold", () => {
74
+ expect(convertMarkdownToHtml("# Header")).toContain("<b>Header</b>");
75
+ expect(convertMarkdownToHtml("## Header")).toContain("<b>Header</b>");
76
+ expect(convertMarkdownToHtml("### Header")).toContain("<b>Header</b>");
77
+ });
78
+
79
+ test("converts links", () => {
80
+ expect(convertMarkdownToHtml("[text](https://example.com)")).toBe(
81
+ '<a href="https://example.com">text</a>',
82
+ );
83
+ });
84
+
85
+ test("converts bullet lists", () => {
86
+ expect(convertMarkdownToHtml("- item")).toBe("• item");
87
+ expect(convertMarkdownToHtml("* item")).toBe("• item");
88
+ });
89
+
90
+ test("escapes HTML in regular text", () => {
91
+ expect(convertMarkdownToHtml("<script>")).toBe("&lt;script&gt;");
92
+ });
93
+
94
+ test("escapes HTML inside code blocks", () => {
95
+ const input = "```\n<div>test</div>\n```";
96
+ const result = convertMarkdownToHtml(input);
97
+ expect(result).toContain("&lt;div&gt;");
98
+ });
99
+
100
+ test("handles nested formatting", () => {
101
+ // Bold inside text
102
+ const input = "This is **bold** text";
103
+ expect(convertMarkdownToHtml(input)).toBe("This is <b>bold</b> text");
104
+ });
105
+
106
+ test("collapses multiple newlines", () => {
107
+ const input = "line1\n\n\n\nline2";
108
+ expect(convertMarkdownToHtml(input)).toBe("line1\n\nline2");
109
+ });
110
+
111
+ test("handles empty string", () => {
112
+ expect(convertMarkdownToHtml("")).toBe("");
113
+ });
114
+
115
+ test("handles plain text without markdown", () => {
116
+ expect(convertMarkdownToHtml("Hello World")).toBe("Hello World");
117
+ });
118
+ });
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Unit tests for security module.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { checkCommandSafety, isAuthorized, isPathAllowed } from "../security";
7
+
8
+ describe("isPathAllowed", () => {
9
+ test("allows paths under ALLOWED_PATHS", () => {
10
+ // Note: actual allowed paths depend on environment
11
+ expect(isPathAllowed("/tmp/test")).toBe(true);
12
+ expect(isPathAllowed("/tmp/telegram-bot/file.txt")).toBe(true);
13
+ });
14
+
15
+ test("allows temp paths", () => {
16
+ expect(isPathAllowed("/tmp/foo")).toBe(true);
17
+ expect(isPathAllowed("/private/tmp/bar")).toBe(true);
18
+ });
19
+
20
+ test("rejects system paths", () => {
21
+ expect(isPathAllowed("/etc/passwd")).toBe(false);
22
+ expect(isPathAllowed("/usr/bin/bash")).toBe(false);
23
+ expect(isPathAllowed("/root/.ssh")).toBe(false);
24
+ });
25
+
26
+ test("handles path traversal attempts", () => {
27
+ expect(isPathAllowed("/tmp/../etc/passwd")).toBe(false);
28
+ });
29
+
30
+ test("handles tilde expansion", () => {
31
+ // Should expand ~ to home directory
32
+ const result = isPathAllowed("~/Documents/test.txt");
33
+ // Result depends on whether ~/Documents is in ALLOWED_PATHS
34
+ expect(typeof result).toBe("boolean");
35
+ });
36
+
37
+ test("handles non-existent paths", () => {
38
+ // Should not throw for non-existent paths
39
+ expect(() => isPathAllowed("/nonexistent/path/file.txt")).not.toThrow();
40
+ });
41
+ });
42
+
43
+ describe("checkCommandSafety", () => {
44
+ test("allows safe commands", () => {
45
+ expect(checkCommandSafety("ls -la")).toEqual([true, ""]);
46
+ expect(checkCommandSafety("git status")).toEqual([true, ""]);
47
+ expect(checkCommandSafety("cat file.txt")).toEqual([true, ""]);
48
+ });
49
+
50
+ test("blocks fork bomb", () => {
51
+ const [safe, reason] = checkCommandSafety(":(){ :|:& };:");
52
+ expect(safe).toBe(false);
53
+ expect(reason).toContain("Blocked pattern");
54
+ });
55
+
56
+ test("blocks dangerous rm commands", () => {
57
+ const [safe1, reason1] = checkCommandSafety("rm -rf /");
58
+ expect(safe1).toBe(false);
59
+ expect(reason1).toContain("Blocked pattern");
60
+
61
+ const [safe2, reason2] = checkCommandSafety("rm -rf ~");
62
+ expect(safe2).toBe(false);
63
+ expect(reason2).toContain("Blocked pattern");
64
+
65
+ const [safe3, reason3] = checkCommandSafety("sudo rm -rf /home");
66
+ expect(safe3).toBe(false);
67
+ expect(reason3).toContain("Blocked pattern");
68
+ });
69
+
70
+ test("blocks disk destruction commands", () => {
71
+ const [safe1] = checkCommandSafety("> /dev/sda");
72
+ expect(safe1).toBe(false);
73
+
74
+ const [safe2] = checkCommandSafety("mkfs.ext4 /dev/sda");
75
+ expect(safe2).toBe(false);
76
+
77
+ const [safe3] = checkCommandSafety("dd if=/dev/zero of=/dev/sda");
78
+ expect(safe3).toBe(false);
79
+ });
80
+
81
+ test("validates rm paths against allowed list", () => {
82
+ // rm to temp directory should be allowed
83
+ const [safe1] = checkCommandSafety("rm /tmp/test.txt");
84
+ expect(safe1).toBe(true);
85
+
86
+ // rm to system paths should be blocked
87
+ const [safe2, reason2] = checkCommandSafety("rm /etc/passwd");
88
+ expect(safe2).toBe(false);
89
+ expect(reason2).toContain("outside allowed paths");
90
+ });
91
+
92
+ test("handles rm with flags", () => {
93
+ // Should allow rm -f to temp
94
+ const [safe1] = checkCommandSafety("rm -f /tmp/test.txt");
95
+ expect(safe1).toBe(true);
96
+
97
+ // Should block rm -rf to system paths
98
+ const [safe2] = checkCommandSafety("rm -rf /var/log");
99
+ expect(safe2).toBe(false);
100
+ });
101
+ });
102
+
103
+ describe("isAuthorized", () => {
104
+ const allowedUsers = [123, 456, 789];
105
+
106
+ test("returns true for allowed users", () => {
107
+ expect(isAuthorized(123, allowedUsers)).toBe(true);
108
+ expect(isAuthorized(456, allowedUsers)).toBe(true);
109
+ expect(isAuthorized(789, allowedUsers)).toBe(true);
110
+ });
111
+
112
+ test("returns false for unauthorized users", () => {
113
+ expect(isAuthorized(999, allowedUsers)).toBe(false);
114
+ expect(isAuthorized(0, allowedUsers)).toBe(false);
115
+ });
116
+
117
+ test("returns false for undefined userId", () => {
118
+ expect(isAuthorized(undefined, allowedUsers)).toBe(false);
119
+ });
120
+
121
+ test("returns false when allowed users list is empty", () => {
122
+ expect(isAuthorized(123, [])).toBe(false);
123
+ });
124
+ });
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Test setup - sets required environment variables for tests.
3
+ */
4
+
5
+ // Set dummy values for required environment variables
6
+ process.env.TELEGRAM_BOT_TOKEN = "test-token-12345";
7
+ process.env.TELEGRAM_ALLOWED_USERS = "123,456,789";
8
+ process.env.CLAUDE_WORKING_DIR = "/tmp";
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Bookmarks management for Claude Telegram Bot.
3
+ *
4
+ * Stores and retrieves directory bookmarks.
5
+ */
6
+
7
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { resolve } from "node:path";
10
+
11
+ const BOOKMARKS_FILE = "/tmp/claude-telegram-bookmarks.json";
12
+
13
+ export interface Bookmark {
14
+ path: string;
15
+ name: string;
16
+ addedAt: string;
17
+ }
18
+
19
+ /**
20
+ * Load bookmarks from file.
21
+ */
22
+ export function loadBookmarks(): Bookmark[] {
23
+ try {
24
+ if (!existsSync(BOOKMARKS_FILE)) {
25
+ return [];
26
+ }
27
+ const text = readFileSync(BOOKMARKS_FILE, "utf-8");
28
+ const data = JSON.parse(text);
29
+ return Array.isArray(data) ? data : [];
30
+ } catch (error) {
31
+ console.warn("Failed to load bookmarks:", error);
32
+ return [];
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Save bookmarks to file.
38
+ */
39
+ export function saveBookmarks(bookmarks: Bookmark[]): void {
40
+ try {
41
+ writeFileSync(BOOKMARKS_FILE, JSON.stringify(bookmarks, null, 2));
42
+ } catch (error) {
43
+ console.error("Failed to save bookmarks:", error);
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Add a bookmark.
49
+ */
50
+ export function addBookmark(path: string): boolean {
51
+ const bookmarks = loadBookmarks();
52
+
53
+ // Resolve and normalize path
54
+ const resolvedPath = resolvePath(path);
55
+
56
+ // Check if already bookmarked
57
+ if (bookmarks.some((b) => b.path === resolvedPath)) {
58
+ return false;
59
+ }
60
+
61
+ // Extract name from path
62
+ const name = resolvedPath.split("/").pop() || resolvedPath;
63
+
64
+ bookmarks.push({
65
+ path: resolvedPath,
66
+ name,
67
+ addedAt: new Date().toISOString(),
68
+ });
69
+
70
+ saveBookmarks(bookmarks);
71
+ return true;
72
+ }
73
+
74
+ /**
75
+ * Remove a bookmark by path.
76
+ */
77
+ export function removeBookmark(path: string): boolean {
78
+ const bookmarks = loadBookmarks();
79
+ const resolvedPath = resolvePath(path);
80
+
81
+ const index = bookmarks.findIndex((b) => b.path === resolvedPath);
82
+ if (index === -1) {
83
+ return false;
84
+ }
85
+
86
+ bookmarks.splice(index, 1);
87
+ saveBookmarks(bookmarks);
88
+ return true;
89
+ }
90
+
91
+ /**
92
+ * Check if a path is bookmarked.
93
+ */
94
+ export function isBookmarked(path: string): boolean {
95
+ const bookmarks = loadBookmarks();
96
+ const resolvedPath = resolvePath(path);
97
+ return bookmarks.some((b) => b.path === resolvedPath);
98
+ }
99
+
100
+ /**
101
+ * Resolve path with ~ expansion.
102
+ */
103
+ export function resolvePath(path: string): string {
104
+ const expanded = path.replace(/^~/, homedir());
105
+ return resolve(expanded);
106
+ }
package/src/bot.ts ADDED
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Claude Telegram Bot - Bot startup module
3
+ *
4
+ * This module is imported by cli.ts after environment setup.
5
+ * Can also be run directly with `bun run src/bot.ts` for backwards compatibility.
6
+ */
7
+
8
+ import { existsSync, readFileSync, unlinkSync } from "node:fs";
9
+ import { run, sequentialize } from "@grammyjs/runner";
10
+ import { Bot } from "grammy";
11
+ import {
12
+ ALLOWED_USERS,
13
+ RESTART_FILE,
14
+ TELEGRAM_TOKEN,
15
+ WORKING_DIR,
16
+ } from "./config";
17
+ import {
18
+ handleBookmarks,
19
+ handleCallback,
20
+ handleCd,
21
+ handleDocument,
22
+ handleNew,
23
+ handlePhoto,
24
+ handleRestart,
25
+ handleResume,
26
+ handleRetry,
27
+ handleStart,
28
+ handleStatus,
29
+ handleStop,
30
+ handleText,
31
+ handleVoice,
32
+ } from "./handlers";
33
+
34
+ // Create bot instance
35
+ const bot = new Bot(TELEGRAM_TOKEN);
36
+
37
+ // Sequentialize non-command messages per user (prevents race conditions)
38
+ // Commands bypass sequentialization so they work immediately
39
+ bot.use(
40
+ sequentialize((ctx) => {
41
+ // Commands are not sequentialized - they work immediately
42
+ if (ctx.message?.text?.startsWith("/")) {
43
+ return undefined;
44
+ }
45
+ // Messages with ! prefix bypass queue (interrupt)
46
+ if (ctx.message?.text?.startsWith("!")) {
47
+ return undefined;
48
+ }
49
+ // Callback queries (button clicks) are not sequentialized
50
+ if (ctx.callbackQuery) {
51
+ return undefined;
52
+ }
53
+ // Other messages are sequentialized per chat
54
+ return ctx.chat?.id.toString();
55
+ }),
56
+ );
57
+
58
+ // ============== Command Handlers ==============
59
+
60
+ bot.command("start", handleStart);
61
+ bot.command("new", handleNew);
62
+ bot.command("stop", handleStop);
63
+ bot.command("status", handleStatus);
64
+ bot.command("resume", handleResume);
65
+ bot.command("restart", handleRestart);
66
+ bot.command("retry", handleRetry);
67
+ bot.command("cd", handleCd);
68
+ bot.command("bookmarks", handleBookmarks);
69
+
70
+ // ============== Message Handlers ==============
71
+
72
+ // Text messages
73
+ bot.on("message:text", handleText);
74
+
75
+ // Voice messages
76
+ bot.on("message:voice", handleVoice);
77
+
78
+ // Photo messages
79
+ bot.on("message:photo", handlePhoto);
80
+
81
+ // Document messages
82
+ bot.on("message:document", handleDocument);
83
+
84
+ // ============== Callback Queries ==============
85
+
86
+ bot.on("callback_query:data", handleCallback);
87
+
88
+ // ============== Error Handler ==============
89
+
90
+ bot.catch((err) => {
91
+ console.error("Bot error:", err);
92
+ });
93
+
94
+ // ============== Startup ==============
95
+
96
+ console.log("=".repeat(50));
97
+ console.log("Claude Telegram Bot");
98
+ console.log("=".repeat(50));
99
+ console.log(`Working directory: ${WORKING_DIR}`);
100
+ console.log(`Allowed users: ${ALLOWED_USERS.length}`);
101
+ console.log("Starting bot...");
102
+
103
+ // Get bot info first
104
+ const botInfo = await bot.api.getMe();
105
+ console.log(`Bot started: @${botInfo.username}`);
106
+
107
+ // Check for pending restart message to update
108
+ if (existsSync(RESTART_FILE)) {
109
+ try {
110
+ const data = JSON.parse(readFileSync(RESTART_FILE, "utf-8"));
111
+ const age = Date.now() - data.timestamp;
112
+
113
+ // Only update if restart was recent (within 30 seconds)
114
+ if (age < 30000 && data.chat_id && data.message_id) {
115
+ await bot.api.editMessageText(
116
+ data.chat_id,
117
+ data.message_id,
118
+ "✅ Bot restarted",
119
+ );
120
+ }
121
+ unlinkSync(RESTART_FILE);
122
+ } catch (e) {
123
+ console.warn("Failed to update restart message:", e);
124
+ try {
125
+ unlinkSync(RESTART_FILE);
126
+ } catch {}
127
+ }
128
+ }
129
+
130
+ // Start with concurrent runner (commands work immediately)
131
+ const runner = run(bot);
132
+
133
+ // Graceful shutdown
134
+ const stopRunner = () => {
135
+ if (runner.isRunning()) {
136
+ console.log("Stopping bot...");
137
+ runner.stop();
138
+ }
139
+ };
140
+
141
+ process.on("SIGINT", () => {
142
+ console.log("Received SIGINT");
143
+ stopRunner();
144
+ process.exit(0);
145
+ });
146
+
147
+ process.on("SIGTERM", () => {
148
+ console.log("Received SIGTERM");
149
+ stopRunner();
150
+ process.exit(0);
151
+ });