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.
@@ -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 { StatusCallback } from "../types";
11
- import { convertMarkdownToHtml, escapeHtml } from "../formatting";
9
+ import type { Message } from "grammy/types";
12
10
  import {
13
- TELEGRAM_MESSAGE_LIMIT,
14
- TELEGRAM_SAFE_LIMIT,
15
- STREAMING_THROTTLE_MS,
16
- BUTTON_LABEL_MAX_LENGTH,
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
- requestId: string,
24
- options: string[]
23
+ requestId: string,
24
+ options: string[],
25
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;
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
- ctx: Context,
45
- chatId: number
44
+ ctx: Context,
45
+ chatId: number,
46
46
  ): Promise<boolean> {
47
- const glob = new Bun.Glob("ask-user-*.json");
48
- let buttonsSent = false;
47
+ const glob = new Bun.Glob("ask-user-*.json");
48
+ let buttonsSent = false;
49
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);
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
- // Only process pending requests for this chat
58
- if (data.status !== "pending") continue;
59
- if (String(data.chat_id) !== String(chatId)) continue;
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
- const question = data.question || "Please choose:";
62
- const options = data.options || [];
63
- const requestId = data.request_id || "";
61
+ const question = data.question || "Please choose:";
62
+ const options = data.options || [];
63
+ const requestId = data.request_id || "";
64
64
 
65
- if (options.length > 0 && requestId) {
66
- const keyboard = createAskUserKeyboard(requestId, options);
67
- await ctx.reply(`❓ ${question}`, { reply_markup: keyboard });
68
- buttonsSent = true;
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
- // 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
- }
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
- return buttonsSent;
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
- 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
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
- ctx: Context,
97
- state: StreamingState
96
+ ctx: Context,
97
+ state: StreamingState,
98
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;
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
- 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);
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
- // Skip if content unchanged
179
- if (formatted === state.lastContent.get(segmentId)) {
180
- return;
181
- }
178
+ // Skip if content unchanged
179
+ if (formatted === state.lastContent.get(segmentId)) {
180
+ return;
181
+ }
182
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
- };
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
  }