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.
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Shared media group handling for Claude Telegram Bot.
3
+ *
4
+ * Provides a generic buffer for handling Telegram media groups (albums)
5
+ * with configurable processing callbacks.
6
+ */
7
+
8
+ import type { Context } from "grammy";
9
+ import type { Message } from "grammy/types";
10
+ import type { PendingMediaGroup } from "../types";
11
+ import { MEDIA_GROUP_TIMEOUT } from "../config";
12
+ import { rateLimiter } from "../security";
13
+ import { auditLogRateLimit } from "../utils";
14
+ import { session } from "../session";
15
+
16
+ /**
17
+ * Configuration for a media group handler.
18
+ */
19
+ export interface MediaGroupConfig {
20
+ /** Emoji for status messages (e.g., "📷" or "📄") */
21
+ emoji: string;
22
+ /** Label for items (e.g., "photo" or "document") */
23
+ itemLabel: string;
24
+ /** Plural label for items (e.g., "photos" or "documents") */
25
+ itemLabelPlural: string;
26
+ }
27
+
28
+ /**
29
+ * Callback to process a completed media group.
30
+ */
31
+ export type ProcessGroupCallback = (
32
+ ctx: Context,
33
+ items: string[],
34
+ caption: string | undefined,
35
+ userId: number,
36
+ username: string,
37
+ chatId: number
38
+ ) => Promise<void>;
39
+
40
+ /**
41
+ * Creates a media group buffer with the specified configuration.
42
+ *
43
+ * Returns functions for adding items and processing groups.
44
+ */
45
+ export function createMediaGroupBuffer(config: MediaGroupConfig) {
46
+ const pendingGroups = new Map<string, PendingMediaGroup>();
47
+
48
+ /**
49
+ * Process a completed media group.
50
+ */
51
+ async function processGroup(
52
+ groupId: string,
53
+ processCallback: ProcessGroupCallback
54
+ ): Promise<void> {
55
+ const group = pendingGroups.get(groupId);
56
+ if (!group) return;
57
+
58
+ pendingGroups.delete(groupId);
59
+
60
+ const userId = group.ctx.from?.id;
61
+ const username = group.ctx.from?.username || "unknown";
62
+ const chatId = group.ctx.chat?.id;
63
+
64
+ if (!userId || !chatId) return;
65
+
66
+ console.log(
67
+ `Processing ${group.items.length} ${config.itemLabelPlural} from @${username}`
68
+ );
69
+
70
+ // Update status message
71
+ if (group.statusMsg) {
72
+ try {
73
+ await group.ctx.api.editMessageText(
74
+ group.statusMsg.chat.id,
75
+ group.statusMsg.message_id,
76
+ `${config.emoji} Processing ${group.items.length} ${config.itemLabelPlural}...`
77
+ );
78
+ } catch (error) {
79
+ console.debug("Failed to update status message:", error);
80
+ }
81
+ }
82
+
83
+ await processCallback(
84
+ group.ctx,
85
+ group.items,
86
+ group.caption,
87
+ userId,
88
+ username,
89
+ chatId
90
+ );
91
+
92
+ // Delete status message
93
+ if (group.statusMsg) {
94
+ try {
95
+ await group.ctx.api.deleteMessage(
96
+ group.statusMsg.chat.id,
97
+ group.statusMsg.message_id
98
+ );
99
+ } catch (error) {
100
+ console.debug("Failed to delete status message:", error);
101
+ }
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Add an item to a media group buffer.
107
+ *
108
+ * @returns true if the item was added successfully, false if rate limited
109
+ */
110
+ async function addToGroup(
111
+ mediaGroupId: string,
112
+ itemPath: string,
113
+ ctx: Context,
114
+ userId: number,
115
+ username: string,
116
+ processCallback: ProcessGroupCallback
117
+ ): Promise<boolean> {
118
+ if (!pendingGroups.has(mediaGroupId)) {
119
+ // Rate limit on first item only
120
+ const [allowed, retryAfter] = rateLimiter.check(userId);
121
+ if (!allowed) {
122
+ await auditLogRateLimit(userId, username, retryAfter!);
123
+ await ctx.reply(
124
+ `⏳ Rate limited. Please wait ${retryAfter!.toFixed(1)} seconds.`
125
+ );
126
+ return false;
127
+ }
128
+
129
+ // Create new group
130
+ console.log(`Receiving ${config.itemLabel} album from @${username}`);
131
+ const statusMsg = await ctx.reply(
132
+ `${config.emoji} Receiving ${config.itemLabelPlural}...`
133
+ );
134
+
135
+ pendingGroups.set(mediaGroupId, {
136
+ items: [itemPath],
137
+ ctx,
138
+ caption: ctx.message?.caption,
139
+ statusMsg,
140
+ timeout: setTimeout(
141
+ () => processGroup(mediaGroupId, processCallback),
142
+ MEDIA_GROUP_TIMEOUT
143
+ ),
144
+ });
145
+ } else {
146
+ // Add to existing group
147
+ const group = pendingGroups.get(mediaGroupId)!;
148
+ group.items.push(itemPath);
149
+
150
+ // Update caption if this message has one
151
+ if (ctx.message?.caption && !group.caption) {
152
+ group.caption = ctx.message.caption;
153
+ }
154
+
155
+ // Reset timeout
156
+ clearTimeout(group.timeout);
157
+ group.timeout = setTimeout(
158
+ () => processGroup(mediaGroupId, processCallback),
159
+ MEDIA_GROUP_TIMEOUT
160
+ );
161
+ }
162
+
163
+ return true;
164
+ }
165
+
166
+ return {
167
+ addToGroup,
168
+ processGroup,
169
+ pendingGroups,
170
+ };
171
+ }
172
+
173
+ /**
174
+ * Shared error handler for media processing.
175
+ *
176
+ * Cleans up tool messages and sends appropriate error response.
177
+ */
178
+ export async function handleProcessingError(
179
+ ctx: Context,
180
+ error: unknown,
181
+ toolMessages: Message[]
182
+ ): Promise<void> {
183
+ console.error("Error processing media:", error);
184
+
185
+ // Clean up tool messages
186
+ for (const toolMsg of toolMessages) {
187
+ try {
188
+ await ctx.api.deleteMessage(toolMsg.chat.id, toolMsg.message_id);
189
+ } catch (cleanupError) {
190
+ console.debug("Failed to delete tool message:", cleanupError);
191
+ }
192
+ }
193
+
194
+ // Send error message
195
+ const errorStr = String(error);
196
+ if (errorStr.includes("abort") || errorStr.includes("cancel")) {
197
+ // Only show "Query stopped" if it was an explicit stop, not an interrupt from a new message
198
+ const wasInterrupt = session.consumeInterruptFlag();
199
+ if (!wasInterrupt) {
200
+ await ctx.reply("🛑 Query stopped.");
201
+ }
202
+ } else {
203
+ await ctx.reply(`❌ Error: ${errorStr.slice(0, 200)}`);
204
+ }
205
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Photo message handler for Claude Telegram Bot.
3
+ *
4
+ * Supports single photos and media groups (albums) with 1s buffering.
5
+ */
6
+
7
+ import { unlinkSync } from "node:fs";
8
+ import type { Context } from "grammy";
9
+ import { ALLOWED_USERS, TEMP_DIR } from "../config";
10
+ import { isAuthorized, rateLimiter } from "../security";
11
+ import { session } from "../session";
12
+ import { auditLog, auditLogRateLimit, startTypingIndicator } from "../utils";
13
+ import { createMediaGroupBuffer, handleProcessingError } from "./media-group";
14
+ import { createStatusCallback, StreamingState } from "./streaming";
15
+
16
+ /**
17
+ * Safely delete a temp file, ignoring errors.
18
+ */
19
+ function cleanupTempFile(filePath: string): void {
20
+ try {
21
+ unlinkSync(filePath);
22
+ } catch {
23
+ // Ignore cleanup errors
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Cleanup multiple temp files.
29
+ */
30
+ function cleanupTempFiles(filePaths: string[]): void {
31
+ for (const path of filePaths) {
32
+ cleanupTempFile(path);
33
+ }
34
+ }
35
+
36
+ // Create photo-specific media group buffer
37
+ const photoBuffer = createMediaGroupBuffer({
38
+ emoji: "📷",
39
+ itemLabel: "photo",
40
+ itemLabelPlural: "photos",
41
+ });
42
+
43
+ /**
44
+ * Download a photo and return the local path.
45
+ */
46
+ async function downloadPhoto(ctx: Context): Promise<string> {
47
+ const photos = ctx.message?.photo;
48
+ if (!photos || photos.length === 0) {
49
+ throw new Error("No photo in message");
50
+ }
51
+
52
+ // Get the largest photo
53
+ const file = await ctx.getFile();
54
+
55
+ const timestamp = Date.now();
56
+ const random = Math.random().toString(36).slice(2, 8);
57
+ const photoPath = `${TEMP_DIR}/photo_${timestamp}_${random}.jpg`;
58
+
59
+ // Download
60
+ const response = await fetch(
61
+ `https://api.telegram.org/file/bot${ctx.api.token}/${file.file_path}`,
62
+ );
63
+ const buffer = await response.arrayBuffer();
64
+ await Bun.write(photoPath, buffer);
65
+
66
+ return photoPath;
67
+ }
68
+
69
+ /**
70
+ * Process photos with Claude.
71
+ */
72
+ async function processPhotos(
73
+ ctx: Context,
74
+ photoPaths: string[],
75
+ caption: string | undefined,
76
+ userId: number,
77
+ username: string,
78
+ chatId: number,
79
+ ): Promise<void> {
80
+ // Mark processing started
81
+ const stopProcessing = session.startProcessing();
82
+
83
+ // Build prompt
84
+ let prompt: string;
85
+ if (photoPaths.length === 1) {
86
+ prompt = caption
87
+ ? `[Photo: ${photoPaths[0]}]\n\n${caption}`
88
+ : `Please analyze this image: ${photoPaths[0]}`;
89
+ } else {
90
+ const pathsList = photoPaths.map((p, i) => `${i + 1}. ${p}`).join("\n");
91
+ prompt = caption
92
+ ? `[Photos:\n${pathsList}]\n\n${caption}`
93
+ : `Please analyze these ${photoPaths.length} images:\n${pathsList}`;
94
+ }
95
+
96
+ // Start typing
97
+ const typing = startTypingIndicator(ctx);
98
+
99
+ // Create streaming state
100
+ const state = new StreamingState();
101
+ const statusCallback = createStatusCallback(ctx, state);
102
+
103
+ try {
104
+ const response = await session.sendMessageStreaming(
105
+ prompt,
106
+ username,
107
+ userId,
108
+ statusCallback,
109
+ chatId,
110
+ ctx,
111
+ );
112
+
113
+ await auditLog(userId, username, "PHOTO", prompt, response);
114
+ } catch (error) {
115
+ await handleProcessingError(ctx, error, state.toolMessages);
116
+ } finally {
117
+ stopProcessing();
118
+ typing.stop();
119
+ // Clean up temp files
120
+ cleanupTempFiles(photoPaths);
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Handle incoming photo messages.
126
+ */
127
+ export async function handlePhoto(ctx: Context): Promise<void> {
128
+ const userId = ctx.from?.id;
129
+ const username = ctx.from?.username || "unknown";
130
+ const chatId = ctx.chat?.id;
131
+ const mediaGroupId = ctx.message?.media_group_id;
132
+
133
+ if (!userId || !chatId) {
134
+ return;
135
+ }
136
+
137
+ // 1. Authorization check
138
+ if (!isAuthorized(userId, ALLOWED_USERS)) {
139
+ await ctx.reply("Unauthorized. Contact the bot owner for access.");
140
+ return;
141
+ }
142
+
143
+ // 2. For single photos, show status and rate limit early
144
+ let statusMsg: Awaited<ReturnType<typeof ctx.reply>> | null = null;
145
+ if (!mediaGroupId) {
146
+ console.log(`Received photo from @${username}`);
147
+ // Rate limit
148
+ const [allowed, retryAfter] = rateLimiter.check(userId);
149
+ if (!allowed && retryAfter !== undefined) {
150
+ await auditLogRateLimit(userId, username, retryAfter);
151
+ await ctx.reply(
152
+ `⏳ Rate limited. Please wait ${retryAfter.toFixed(1)} seconds.`,
153
+ );
154
+ return;
155
+ }
156
+
157
+ // Show status immediately
158
+ statusMsg = await ctx.reply("📷 Processing image...");
159
+ }
160
+
161
+ // 3. Download photo
162
+ let photoPath: string;
163
+ try {
164
+ photoPath = await downloadPhoto(ctx);
165
+ } catch (error) {
166
+ console.error("Failed to download photo:", error);
167
+ if (statusMsg) {
168
+ try {
169
+ await ctx.api.editMessageText(
170
+ statusMsg.chat.id,
171
+ statusMsg.message_id,
172
+ "❌ Failed to download photo.",
173
+ );
174
+ } catch (editError) {
175
+ console.debug("Failed to edit status message:", editError);
176
+ await ctx.reply("❌ Failed to download photo.");
177
+ }
178
+ } else {
179
+ await ctx.reply("❌ Failed to download photo.");
180
+ }
181
+ return;
182
+ }
183
+
184
+ // 4. Single photo - process immediately
185
+ if (!mediaGroupId && statusMsg) {
186
+ await processPhotos(
187
+ ctx,
188
+ [photoPath],
189
+ ctx.message?.caption,
190
+ userId,
191
+ username,
192
+ chatId,
193
+ );
194
+
195
+ // Clean up status message
196
+ try {
197
+ await ctx.api.deleteMessage(statusMsg.chat.id, statusMsg.message_id);
198
+ } catch (error) {
199
+ console.debug("Failed to delete status message:", error);
200
+ }
201
+ return;
202
+ }
203
+
204
+ // 5. Media group - buffer with timeout
205
+ if (!mediaGroupId) return; // TypeScript guard
206
+
207
+ await photoBuffer.addToGroup(
208
+ mediaGroupId,
209
+ photoPath,
210
+ ctx,
211
+ userId,
212
+ username,
213
+ processPhotos,
214
+ );
215
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Shared streaming callback for Claude Telegram Bot handlers.
3
+ *
4
+ * Provides a reusable status callback for streaming Claude responses.
5
+ */
6
+
7
+ import type { Context } from "grammy";
8
+ import type { Message } from "grammy/types";
9
+ import { InlineKeyboard } from "grammy";
10
+ import type { StatusCallback } from "../types";
11
+ import { convertMarkdownToHtml, escapeHtml } from "../formatting";
12
+ import {
13
+ TELEGRAM_MESSAGE_LIMIT,
14
+ TELEGRAM_SAFE_LIMIT,
15
+ STREAMING_THROTTLE_MS,
16
+ BUTTON_LABEL_MAX_LENGTH,
17
+ } from "../config";
18
+
19
+ /**
20
+ * Create inline keyboard for ask_user options.
21
+ */
22
+ export function createAskUserKeyboard(
23
+ requestId: string,
24
+ options: string[]
25
+ ): InlineKeyboard {
26
+ const keyboard = new InlineKeyboard();
27
+ for (let idx = 0; idx < options.length; idx++) {
28
+ const option = options[idx]!;
29
+ // Truncate long options for button display
30
+ const display =
31
+ option.length > BUTTON_LABEL_MAX_LENGTH
32
+ ? option.slice(0, BUTTON_LABEL_MAX_LENGTH) + "..."
33
+ : option;
34
+ const callbackData = `askuser:${requestId}:${idx}`;
35
+ keyboard.text(display, callbackData).row();
36
+ }
37
+ return keyboard;
38
+ }
39
+
40
+ /**
41
+ * Check for pending ask-user requests and send inline keyboards.
42
+ */
43
+ export async function checkPendingAskUserRequests(
44
+ ctx: Context,
45
+ chatId: number
46
+ ): Promise<boolean> {
47
+ const glob = new Bun.Glob("ask-user-*.json");
48
+ let buttonsSent = false;
49
+
50
+ for await (const filename of glob.scan({ cwd: "/tmp", absolute: false })) {
51
+ const filepath = `/tmp/${filename}`;
52
+ try {
53
+ const file = Bun.file(filepath);
54
+ const text = await file.text();
55
+ const data = JSON.parse(text);
56
+
57
+ // Only process pending requests for this chat
58
+ if (data.status !== "pending") continue;
59
+ if (String(data.chat_id) !== String(chatId)) continue;
60
+
61
+ const question = data.question || "Please choose:";
62
+ const options = data.options || [];
63
+ const requestId = data.request_id || "";
64
+
65
+ if (options.length > 0 && requestId) {
66
+ const keyboard = createAskUserKeyboard(requestId, options);
67
+ await ctx.reply(`❓ ${question}`, { reply_markup: keyboard });
68
+ buttonsSent = true;
69
+
70
+ // Mark as sent
71
+ data.status = "sent";
72
+ await Bun.write(filepath, JSON.stringify(data));
73
+ }
74
+ } catch (error) {
75
+ console.warn(`Failed to process ask-user file ${filepath}:`, error);
76
+ }
77
+ }
78
+
79
+ return buttonsSent;
80
+ }
81
+
82
+ /**
83
+ * Tracks state for streaming message updates.
84
+ */
85
+ export class StreamingState {
86
+ textMessages = new Map<number, Message>(); // segment_id -> telegram message
87
+ toolMessages: Message[] = []; // ephemeral tool status messages
88
+ lastEditTimes = new Map<number, number>(); // segment_id -> last edit time
89
+ lastContent = new Map<number, string>(); // segment_id -> last sent content
90
+ }
91
+
92
+ /**
93
+ * Create a status callback for streaming updates.
94
+ */
95
+ export function createStatusCallback(
96
+ ctx: Context,
97
+ state: StreamingState
98
+ ): StatusCallback {
99
+ return async (statusType: string, content: string, segmentId?: number) => {
100
+ try {
101
+ if (statusType === "thinking") {
102
+ // Show thinking inline, compact (first 500 chars)
103
+ const preview =
104
+ content.length > 500 ? content.slice(0, 500) + "..." : content;
105
+ const escaped = escapeHtml(preview);
106
+ const thinkingMsg = await ctx.reply(`🧠 <i>${escaped}</i>`, {
107
+ parse_mode: "HTML",
108
+ });
109
+ state.toolMessages.push(thinkingMsg);
110
+ } else if (statusType === "tool") {
111
+ const toolMsg = await ctx.reply(content, { parse_mode: "HTML" });
112
+ state.toolMessages.push(toolMsg);
113
+ } else if (statusType === "text" && segmentId !== undefined) {
114
+ const now = Date.now();
115
+ const lastEdit = state.lastEditTimes.get(segmentId) || 0;
116
+
117
+ if (!state.textMessages.has(segmentId)) {
118
+ // New segment - create message
119
+ const display =
120
+ content.length > TELEGRAM_SAFE_LIMIT
121
+ ? content.slice(0, TELEGRAM_SAFE_LIMIT) + "..."
122
+ : content;
123
+ const formatted = convertMarkdownToHtml(display);
124
+ try {
125
+ const msg = await ctx.reply(formatted, { parse_mode: "HTML" });
126
+ state.textMessages.set(segmentId, msg);
127
+ state.lastContent.set(segmentId, formatted);
128
+ } catch (htmlError) {
129
+ // HTML parse failed, fall back to plain text
130
+ console.debug("HTML reply failed, using plain text:", htmlError);
131
+ const msg = await ctx.reply(formatted);
132
+ state.textMessages.set(segmentId, msg);
133
+ state.lastContent.set(segmentId, formatted);
134
+ }
135
+ state.lastEditTimes.set(segmentId, now);
136
+ } else if (now - lastEdit > STREAMING_THROTTLE_MS) {
137
+ // Update existing segment message (throttled)
138
+ const msg = state.textMessages.get(segmentId)!;
139
+ const display =
140
+ content.length > TELEGRAM_SAFE_LIMIT
141
+ ? content.slice(0, TELEGRAM_SAFE_LIMIT) + "..."
142
+ : content;
143
+ const formatted = convertMarkdownToHtml(display);
144
+ // Skip if content unchanged
145
+ if (formatted === state.lastContent.get(segmentId)) {
146
+ return;
147
+ }
148
+ try {
149
+ await ctx.api.editMessageText(
150
+ msg.chat.id,
151
+ msg.message_id,
152
+ formatted,
153
+ {
154
+ parse_mode: "HTML",
155
+ }
156
+ );
157
+ state.lastContent.set(segmentId, formatted);
158
+ } catch (htmlError) {
159
+ console.debug("HTML edit failed, trying plain text:", htmlError);
160
+ try {
161
+ await ctx.api.editMessageText(
162
+ msg.chat.id,
163
+ msg.message_id,
164
+ formatted
165
+ );
166
+ state.lastContent.set(segmentId, formatted);
167
+ } catch (editError) {
168
+ console.debug("Edit message failed:", editError);
169
+ }
170
+ }
171
+ state.lastEditTimes.set(segmentId, now);
172
+ }
173
+ } else if (statusType === "segment_end" && segmentId !== undefined) {
174
+ if (state.textMessages.has(segmentId) && content) {
175
+ const msg = state.textMessages.get(segmentId)!;
176
+ const formatted = convertMarkdownToHtml(content);
177
+
178
+ // Skip if content unchanged
179
+ if (formatted === state.lastContent.get(segmentId)) {
180
+ return;
181
+ }
182
+
183
+ if (formatted.length <= TELEGRAM_MESSAGE_LIMIT) {
184
+ try {
185
+ await ctx.api.editMessageText(
186
+ msg.chat.id,
187
+ msg.message_id,
188
+ formatted,
189
+ {
190
+ parse_mode: "HTML",
191
+ }
192
+ );
193
+ } catch (error) {
194
+ console.debug("Failed to edit final message:", error);
195
+ }
196
+ } else {
197
+ // Too long - delete and split
198
+ try {
199
+ await ctx.api.deleteMessage(msg.chat.id, msg.message_id);
200
+ } catch (error) {
201
+ console.debug("Failed to delete message for splitting:", error);
202
+ }
203
+ for (let i = 0; i < formatted.length; i += TELEGRAM_SAFE_LIMIT) {
204
+ const chunk = formatted.slice(i, i + TELEGRAM_SAFE_LIMIT);
205
+ try {
206
+ await ctx.reply(chunk, { parse_mode: "HTML" });
207
+ } catch (htmlError) {
208
+ console.debug(
209
+ "HTML chunk failed, using plain text:",
210
+ htmlError
211
+ );
212
+ await ctx.reply(chunk);
213
+ }
214
+ }
215
+ }
216
+ }
217
+ } else if (statusType === "done") {
218
+ // Delete tool messages - text messages stay
219
+ for (const toolMsg of state.toolMessages) {
220
+ try {
221
+ await ctx.api.deleteMessage(toolMsg.chat.id, toolMsg.message_id);
222
+ } catch (error) {
223
+ console.debug("Failed to delete tool message:", error);
224
+ }
225
+ }
226
+ }
227
+ } catch (error) {
228
+ console.error("Status callback error:", error);
229
+ }
230
+ };
231
+ }