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.
@@ -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
- auditLog,
11
- auditLogRateLimit,
12
- checkInterrupt,
13
- startTypingIndicator,
11
+ auditLog,
12
+ auditLogRateLimit,
13
+ checkInterrupt,
14
+ startTypingIndicator,
14
15
  } from "../utils";
15
- import { StreamingState, createStatusCallback } from "./streaming";
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
- 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();
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 ==============