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
|
@@ -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
|
+
}
|