ctb 1.0.0 → 1.1.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 +4 -2
- package/src/__tests__/callback.test.ts +286 -0
- package/src/__tests__/cli.test.ts +365 -0
- package/src/__tests__/file-detection.test.ts +311 -0
- package/src/__tests__/shell-command.test.ts +310 -0
- package/src/bookmarks.ts +5 -1
- package/src/bot.ts +17 -0
- package/src/formatting.ts +289 -237
- package/src/handlers/callback.ts +46 -1
- package/src/handlers/commands.ts +83 -2
- package/src/handlers/index.ts +1 -0
- package/src/handlers/streaming.ts +185 -185
- package/src/handlers/text.ts +191 -113
- package/src/index.ts +2 -0
package/src/handlers/text.ts
CHANGED
|
@@ -2,127 +2,205 @@
|
|
|
2
2
|
* Text message handler for Claude Telegram Bot.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
5
6
|
import type { Context } from "grammy";
|
|
6
|
-
import { session } from "../session";
|
|
7
7
|
import { ALLOWED_USERS } from "../config";
|
|
8
8
|
import { isAuthorized, rateLimiter } from "../security";
|
|
9
|
+
import { session } from "../session";
|
|
9
10
|
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
auditLog,
|
|
12
|
+
auditLogRateLimit,
|
|
13
|
+
checkInterrupt,
|
|
14
|
+
startTypingIndicator,
|
|
14
15
|
} from "../utils";
|
|
15
|
-
import {
|
|
16
|
+
import { createStatusCallback, StreamingState } from "./streaming";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Execute a shell command and return output.
|
|
20
|
+
*/
|
|
21
|
+
async function execShellCommand(
|
|
22
|
+
command: string,
|
|
23
|
+
cwd: string,
|
|
24
|
+
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
const proc = spawn("bash", ["-c", command], {
|
|
27
|
+
cwd,
|
|
28
|
+
timeout: 30000, // 30s timeout
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
let stdout = "";
|
|
32
|
+
let stderr = "";
|
|
33
|
+
|
|
34
|
+
proc.stdout.on("data", (data) => {
|
|
35
|
+
stdout += data.toString();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
proc.stderr.on("data", (data) => {
|
|
39
|
+
stderr += data.toString();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
proc.on("close", (code) => {
|
|
43
|
+
resolve({ stdout, stderr, exitCode: code ?? 0 });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
proc.on("error", (err) => {
|
|
47
|
+
resolve({ stdout, stderr: err.message, exitCode: 1 });
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|
|
16
51
|
|
|
17
52
|
/**
|
|
18
53
|
* Handle incoming text messages.
|
|
19
54
|
*/
|
|
20
55
|
export async function handleText(ctx: Context): Promise<void> {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
56
|
+
const userId = ctx.from?.id;
|
|
57
|
+
const username = ctx.from?.username || "unknown";
|
|
58
|
+
const chatId = ctx.chat?.id;
|
|
59
|
+
let message = ctx.message?.text;
|
|
60
|
+
|
|
61
|
+
if (!userId || !message || !chatId) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 1. Authorization check
|
|
66
|
+
if (!isAuthorized(userId, ALLOWED_USERS)) {
|
|
67
|
+
await ctx.reply("Unauthorized. Contact the bot owner for access.");
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 2. Shell command shortcut: !command executes directly
|
|
72
|
+
if (message.startsWith("!")) {
|
|
73
|
+
const shellCmd = message.slice(1).trim();
|
|
74
|
+
if (shellCmd) {
|
|
75
|
+
const cwd = session.workingDir;
|
|
76
|
+
await ctx.reply(
|
|
77
|
+
`⚡ Running in <code>${cwd}</code>:\n<code>${shellCmd}</code>`,
|
|
78
|
+
{
|
|
79
|
+
parse_mode: "HTML",
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const { stdout, stderr, exitCode } = await execShellCommand(
|
|
84
|
+
shellCmd,
|
|
85
|
+
cwd,
|
|
86
|
+
);
|
|
87
|
+
const output = (stdout + stderr).trim();
|
|
88
|
+
const maxLen = 4000;
|
|
89
|
+
const truncated =
|
|
90
|
+
output.length > maxLen
|
|
91
|
+
? `${output.slice(0, maxLen)}...(truncated)`
|
|
92
|
+
: output;
|
|
93
|
+
|
|
94
|
+
if (exitCode === 0) {
|
|
95
|
+
await ctx.reply(
|
|
96
|
+
`✅ Exit code: ${exitCode}\n<pre>${truncated || "(no output)"}</pre>`,
|
|
97
|
+
{
|
|
98
|
+
parse_mode: "HTML",
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
} else {
|
|
102
|
+
await ctx.reply(
|
|
103
|
+
`❌ Exit code: ${exitCode}\n<pre>${truncated || "(no output)"}</pre>`,
|
|
104
|
+
{
|
|
105
|
+
parse_mode: "HTML",
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
await auditLog(userId, username, "SHELL", shellCmd, `exit=${exitCode}`);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 3. Check for interrupt prefix
|
|
115
|
+
message = await checkInterrupt(message);
|
|
116
|
+
if (!message.trim()) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 3. Rate limit check
|
|
121
|
+
const [allowed, retryAfter] = rateLimiter.check(userId);
|
|
122
|
+
if (!allowed) {
|
|
123
|
+
await auditLogRateLimit(userId, username, retryAfter!);
|
|
124
|
+
await ctx.reply(
|
|
125
|
+
`⏳ Rate limited. Please wait ${retryAfter?.toFixed(1)} seconds.`,
|
|
126
|
+
);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 4. Store message for retry
|
|
131
|
+
session.lastMessage = message;
|
|
132
|
+
|
|
133
|
+
// 5. Mark processing started
|
|
134
|
+
const stopProcessing = session.startProcessing();
|
|
135
|
+
|
|
136
|
+
// 6. Start typing indicator
|
|
137
|
+
const typing = startTypingIndicator(ctx);
|
|
138
|
+
|
|
139
|
+
// 7. Create streaming state and callback
|
|
140
|
+
let state = new StreamingState();
|
|
141
|
+
let statusCallback = createStatusCallback(ctx, state);
|
|
142
|
+
|
|
143
|
+
// 8. Send to Claude with retry logic for crashes
|
|
144
|
+
const MAX_RETRIES = 1;
|
|
145
|
+
|
|
146
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
147
|
+
try {
|
|
148
|
+
const response = await session.sendMessageStreaming(
|
|
149
|
+
message,
|
|
150
|
+
username,
|
|
151
|
+
userId,
|
|
152
|
+
statusCallback,
|
|
153
|
+
chatId,
|
|
154
|
+
ctx,
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
// 9. Audit log
|
|
158
|
+
await auditLog(userId, username, "TEXT", message, response);
|
|
159
|
+
break; // Success - exit retry loop
|
|
160
|
+
} catch (error) {
|
|
161
|
+
const errorStr = String(error);
|
|
162
|
+
const isClaudeCodeCrash = errorStr.includes("exited with code");
|
|
163
|
+
|
|
164
|
+
// Clean up any partial messages from this attempt
|
|
165
|
+
for (const toolMsg of state.toolMessages) {
|
|
166
|
+
try {
|
|
167
|
+
await ctx.api.deleteMessage(toolMsg.chat.id, toolMsg.message_id);
|
|
168
|
+
} catch {
|
|
169
|
+
// Ignore cleanup errors
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Retry on Claude Code crash (not user cancellation)
|
|
174
|
+
if (isClaudeCodeCrash && attempt < MAX_RETRIES) {
|
|
175
|
+
console.log(
|
|
176
|
+
`Claude Code crashed, retrying (attempt ${attempt + 2}/${MAX_RETRIES + 1})...`,
|
|
177
|
+
);
|
|
178
|
+
await session.kill(); // Clear corrupted session
|
|
179
|
+
await ctx.reply(`⚠️ Claude crashed, retrying...`);
|
|
180
|
+
// Reset state for retry
|
|
181
|
+
state = new StreamingState();
|
|
182
|
+
statusCallback = createStatusCallback(ctx, state);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Final attempt failed or non-retryable error
|
|
187
|
+
console.error("Error processing message:", error);
|
|
188
|
+
|
|
189
|
+
// Check if it was a cancellation
|
|
190
|
+
if (errorStr.includes("abort") || errorStr.includes("cancel")) {
|
|
191
|
+
// Only show "Query stopped" if it was an explicit stop, not an interrupt from a new message
|
|
192
|
+
const wasInterrupt = session.consumeInterruptFlag();
|
|
193
|
+
if (!wasInterrupt) {
|
|
194
|
+
await ctx.reply("🛑 Query stopped.");
|
|
195
|
+
}
|
|
196
|
+
} else {
|
|
197
|
+
await ctx.reply(`❌ Error: ${errorStr.slice(0, 200)}`);
|
|
198
|
+
}
|
|
199
|
+
break; // Exit loop after handling error
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 10. Cleanup
|
|
204
|
+
stopProcessing();
|
|
205
|
+
typing.stop();
|
|
128
206
|
}
|
package/src/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
handleDocument,
|
|
21
21
|
handleNew,
|
|
22
22
|
handlePhoto,
|
|
23
|
+
handlePreview,
|
|
23
24
|
handleRestart,
|
|
24
25
|
handleResume,
|
|
25
26
|
handleRetry,
|
|
@@ -64,6 +65,7 @@ bot.command("resume", handleResume);
|
|
|
64
65
|
bot.command("restart", handleRestart);
|
|
65
66
|
bot.command("retry", handleRetry);
|
|
66
67
|
bot.command("cd", handleCd);
|
|
68
|
+
bot.command("preview", handlePreview);
|
|
67
69
|
bot.command("bookmarks", handleBookmarks);
|
|
68
70
|
|
|
69
71
|
// ============== Message Handlers ==============
|