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
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 < b");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("escapes greater than", () => {
|
|
18
|
+
expect(escapeHtml("a > b")).toBe("a > b");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("escapes quotes", () => {
|
|
22
|
+
expect(escapeHtml('say "hello"')).toBe("say "hello"");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("escapes multiple special characters", () => {
|
|
26
|
+
expect(escapeHtml('<script>"alert"</script>')).toBe(
|
|
27
|
+
"<script>"alert"</script>",
|
|
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("<script>");
|
|
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("<div>");
|
|
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";
|
package/src/bookmarks.ts
ADDED
|
@@ -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
|
+
});
|