ctb 1.0.0 → 1.2.1
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/README.md +42 -11
- package/package.json +4 -2
- package/src/__tests__/callback.test.ts +286 -0
- package/src/__tests__/cli.test.ts +377 -0
- package/src/__tests__/file-detection.test.ts +311 -0
- package/src/__tests__/session.test.ts +399 -0
- package/src/__tests__/shell-command.test.ts +310 -0
- package/src/bookmarks.ts +5 -1
- package/src/bot.ts +41 -0
- package/src/cli.ts +94 -0
- package/src/formatting.ts +289 -237
- package/src/handlers/callback.ts +46 -1
- package/src/handlers/commands.ts +417 -3
- package/src/handlers/index.ts +8 -0
- package/src/handlers/streaming.ts +185 -185
- package/src/handlers/text.ts +191 -113
- package/src/index.ts +19 -0
- package/src/session.ts +140 -6
|
@@ -5,227 +5,227 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { Context } from "grammy";
|
|
8
|
-
import type { Message } from "grammy/types";
|
|
9
8
|
import { InlineKeyboard } from "grammy";
|
|
10
|
-
import type {
|
|
11
|
-
import { convertMarkdownToHtml, escapeHtml } from "../formatting";
|
|
9
|
+
import type { Message } from "grammy/types";
|
|
12
10
|
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
BUTTON_LABEL_MAX_LENGTH,
|
|
12
|
+
STREAMING_THROTTLE_MS,
|
|
13
|
+
TELEGRAM_MESSAGE_LIMIT,
|
|
14
|
+
TELEGRAM_SAFE_LIMIT,
|
|
17
15
|
} from "../config";
|
|
16
|
+
import { convertMarkdownToHtml, escapeHtml } from "../formatting";
|
|
17
|
+
import type { StatusCallback } from "../types";
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* Create inline keyboard for ask_user options.
|
|
21
21
|
*/
|
|
22
22
|
export function createAskUserKeyboard(
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
requestId: string,
|
|
24
|
+
options: string[],
|
|
25
25
|
): InlineKeyboard {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
38
|
}
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
41
|
* Check for pending ask-user requests and send inline keyboards.
|
|
42
42
|
*/
|
|
43
43
|
export async function checkPendingAskUserRequests(
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
ctx: Context,
|
|
45
|
+
chatId: number,
|
|
46
46
|
): Promise<boolean> {
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
const glob = new Bun.Glob("ask-user-*.json");
|
|
48
|
+
let buttonsSent = false;
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
56
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
// Only process pending requests for this chat
|
|
58
|
+
if (data.status !== "pending") continue;
|
|
59
|
+
if (String(data.chat_id) !== String(chatId)) continue;
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
const question = data.question || "Please choose:";
|
|
62
|
+
const options = data.options || [];
|
|
63
|
+
const requestId = data.request_id || "";
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
if (options.length > 0 && requestId) {
|
|
66
|
+
const keyboard = createAskUserKeyboard(requestId, options);
|
|
67
|
+
await ctx.reply(`❓ ${question}`, { reply_markup: keyboard });
|
|
68
|
+
buttonsSent = true;
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
78
|
|
|
79
|
-
|
|
79
|
+
return buttonsSent;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
/**
|
|
83
83
|
* Tracks state for streaming message updates.
|
|
84
84
|
*/
|
|
85
85
|
export class StreamingState {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
90
|
}
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
93
|
* Create a status callback for streaming updates.
|
|
94
94
|
*/
|
|
95
95
|
export function createStatusCallback(
|
|
96
|
-
|
|
97
|
-
|
|
96
|
+
ctx: Context,
|
|
97
|
+
state: StreamingState,
|
|
98
98
|
): StatusCallback {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
116
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
177
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
178
|
+
// Skip if content unchanged
|
|
179
|
+
if (formatted === state.lastContent.get(segmentId)) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
182
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
231
|
}
|