• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

popstas / telegram-functions-bot / 26594219875

28 May 2026 06:29PM UTC coverage: 73.96% (+0.9%) from 73.047%
26594219875

Pull #183

github

web-flow
Merge f8ee46cf6 into 592ce156e
Pull Request #183: feat: add inline, secretary, and guest bot modes

1682 of 2641 branches covered (63.69%)

Branch coverage included in aggregate %.

261 of 280 new or added lines in 9 files covered. (93.21%)

3081 of 3799 relevant lines covered (81.1%)

7.37 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

83.54
/src/handlers/onInlineQuery.ts
1
import { Context } from "telegraf";
2
import type { InlineQueryResultArticle } from "telegraf/types";
3
import type { Message } from "telegraf/types";
4
import { useConfig } from "../config.ts";
5
import { log } from "../helpers.ts";
6
import { requestGptAnswer } from "../helpers/gpt/llm.ts";
7
import { useThreads } from "../threads.ts";
8
import type { ConfigChatType } from "../types.ts";
9

10
const DEFAULT_DEBOUNCE_MS = 1000;
26✔
11
const LIVE_RESULT_ID = "live";
26✔
12
// Telegram caps message text at 4096 characters.
13
const TELEGRAM_MAX_MESSAGE_LENGTH = 4096;
26✔
14

15
// Monotonic counter used to build a unique, throwaway thread id per inline run.
16
let inlineThreadCounter = 0;
26✔
17

18
// Cache of computed live answers keyed by query string. Bounded to avoid
19
// unbounded growth: each distinct query produces a new key, so without a cap
20
// the map would grow for the lifetime of the process. Oldest entries are
21
// evicted first (Map preserves insertion order).
22
const LIVE_ANSWER_CACHE_MAX = 500;
26✔
23
const liveAnswerCache = new Map<string, string>();
26✔
24

25
function setLiveAnswer(key: string, answer: string) {
26
  // Refresh insertion order so recently-used keys survive eviction.
27
  if (liveAnswerCache.has(key)) liveAnswerCache.delete(key);
1!
28
  liveAnswerCache.set(key, answer);
1✔
29
  while (liveAnswerCache.size > LIVE_ANSWER_CACHE_MAX) {
1✔
NEW
30
    const oldest = liveAnswerCache.keys().next().value;
×
NEW
31
    if (oldest === undefined) break;
×
NEW
32
    liveAnswerCache.delete(oldest);
×
33
  }
34
}
35
// Active debounce timers keyed by user id.
36
const liveTimers = new Map<number, NodeJS.Timeout>();
26✔
37

38
export function __resetInlineState() {
39
  liveAnswerCache.clear();
16✔
40
  for (const timer of liveTimers.values()) clearTimeout(timer);
16✔
41
  liveTimers.clear();
16✔
42
}
43

44
// Build the list of inline buttons, always including a default "Ask" button
45
// whose prompt defaults to the default chat's systemMessage.
46
export function getInlineButtons(): { name: string; prompt: string }[] {
47
  const config = useConfig();
11✔
48
  const buttons = config.inlineMode?.buttons ? [...config.inlineMode.buttons] : [];
11!
49
  if (!buttons.some((b) => b.name === "Ask")) {
11✔
50
    const defaultChat = config.chats.find((c) => c.name === "default");
10✔
51
    buttons.unshift({ name: "Ask", prompt: defaultChat?.systemMessage || "" });
10!
52
  }
53
  return buttons;
11✔
54
}
55

56
function buildInlineChatConfig(prompt: string): ConfigChatType {
57
  const config = useConfig();
6✔
58
  const defaultChat = config.chats.find((c) => c.name === "default");
6✔
59
  const base = defaultChat
6!
60
    ? ({ ...defaultChat } as ConfigChatType)
61
    : ({
62
        name: "inline",
63
        completionParams: { model: "gpt-5-mini" },
64
        chatParams: {},
65
        toolParams: {},
66
      } as ConfigChatType);
67
  base.systemMessage = prompt;
6✔
68
  return base;
6✔
69
}
70

71
// Run a button prompt against the typed query through the LLM.
72
export async function computeInlineAnswer(
73
  prompt: string,
74
  query: string,
75
  from: { id: number; first_name?: string } | undefined,
76
): Promise<string> {
77
  const chatConfig = buildInlineChatConfig(prompt);
6✔
78
  // Use an isolated, throwaway thread id. In a private chat Telegram sets
79
  // chat.id === user.id, so reusing from.id here would read from and write into
80
  // the user's real DM history. A unique synthetic key keeps inline runs
81
  // isolated and is removed in the finally block below.
82
  const inlineChatId = `inline:${from?.id ?? 0}:${inlineThreadCounter++}` as unknown as number;
6!
83
  const threads = useThreads();
6✔
84
  const name = from?.first_name || "inline";
6✔
85
  const msg = {
6✔
86
    text: query,
87
    chat: { id: inlineChatId, type: "private", first_name: name },
88
    from: from || { id: 0, is_bot: false, first_name: "inline" },
6!
89
    message_id: Date.now(),
90
    date: Math.floor(Date.now() / 1000),
91
  } as unknown as Message.TextMessage;
92

93
  // Seed the thread with the user's query. requestGptAnswer builds the prompt
94
  // from thread.messages (not msg.text), so without this the model would never
95
  // see what the user typed.
96
  threads[inlineChatId] = {
6✔
97
    id: inlineChatId,
98
    msgs: [],
99
    messages: query ? [{ role: "user", content: query, name }] : [],
6!
100
    completionParams: chatConfig.completionParams,
101
  };
102

103
  try {
6✔
104
    const result = await requestGptAnswer(
6✔
105
      msg,
106
      chatConfig,
107
      { noSendTelegram: true } as Context & { noSendTelegram?: boolean },
108
      { skipEvaluators: true },
109
    );
110
    return result?.content || "";
4!
111
  } finally {
112
    delete threads[inlineChatId];
6✔
113
  }
114
}
115

116
// Schedule a debounced live-answer computation for the given query.
117
// Cache key is scoped per user so one user's live answer is never surfaced to
118
// another user who happens to type the same query string.
119
function liveCacheKey(userId: number, query: string): string {
120
  return `${userId}:${query}`;
3✔
121
}
122

123
function scheduleLiveAnswer(
124
  query: string,
125
  from: { id: number; first_name?: string },
126
  prompt: string,
127
  debounceMs: number,
128
) {
129
  const existing = liveTimers.get(from.id);
1✔
130
  if (existing) clearTimeout(existing);
1!
131
  const timer = setTimeout(() => {
1✔
132
    liveTimers.delete(from.id);
1✔
133
    void (async () => {
1✔
134
      try {
1✔
135
        const answer = await computeInlineAnswer(prompt, query, from);
1✔
136
        if (answer) setLiveAnswer(liveCacheKey(from.id, query), answer);
1!
137
      } catch (e) {
NEW
138
        log({ msg: `inline live answer error: ${(e as Error).message}`, logLevel: "warn" });
×
139
      }
140
    })();
141
  }, debounceMs);
142
  liveTimers.set(from.id, timer);
1✔
143
}
144

145
export async function onInlineQuery(ctx: Context) {
146
  const config = useConfig();
5✔
147
  if (!config.inlineMode) return;
5✔
148
  const inlineQuery = ctx.inlineQuery;
4✔
149
  if (!inlineQuery) return;
4!
150

151
  const query = inlineQuery.query || "";
4!
152
  const buttons = getInlineButtons();
4✔
153
  log({ msg: `inline query: "${query}" from ${inlineQuery.from?.id}, buttons=${buttons.length}` });
4✔
154

155
  const results: InlineQueryResultArticle[] = buttons.map((button, index) => ({
4✔
156
    type: "article",
157
    id: `btn:${index}`,
158
    title: button.name,
159
    description: query ? `${button.name}: ${query}` : button.name,
4!
160
    input_message_content: {
161
      message_text: query || button.name,
4!
162
    },
163
    reply_markup: {
164
      inline_keyboard: [[{ text: "⏳", callback_data: "inline_noop" }]],
165
    },
166
  }));
167

168
  let liveIncluded = false;
4✔
169
  if (config.inlineMode.live_answer && query) {
4✔
170
    const from = inlineQuery.from;
2✔
171
    const askPrompt = buttons.find((b) => b.name === "Ask")?.prompt || buttons[0]?.prompt || "";
2!
172
    const cached = liveAnswerCache.get(liveCacheKey(from.id, query));
2✔
173
    if (cached) {
2✔
174
      results.unshift({
1✔
175
        type: "article",
176
        id: LIVE_RESULT_ID,
177
        title: "Live answer",
178
        description: cached.slice(0, 100),
179
        input_message_content: { message_text: cached },
180
      });
181
      liveIncluded = true;
1✔
182
    } else {
183
      scheduleLiveAnswer(
1✔
184
        query,
185
        from,
186
        askPrompt,
187
        config.inlineMode.debounce_ms || DEFAULT_DEBOUNCE_MS,
1!
188
      );
189
    }
190
  }
191

192
  log({ msg: `inline query: answering ${results.length} results (live=${liveIncluded})` });
4✔
193
  await ctx.answerInlineQuery(results, { cache_time: 0 });
4✔
194
}
195

196
export async function onChosenInlineResult(ctx: Context) {
197
  const config = useConfig();
7✔
198
  if (!config.inlineMode) return;
7!
199
  const chosen = (
200
    ctx.update as {
7✔
201
      chosen_inline_result?: import("telegraf/types").Update.ChosenInlineResultUpdate["chosen_inline_result"];
202
    }
203
  ).chosen_inline_result;
204
  if (!chosen) return;
7!
205

206
  const { result_id, query, inline_message_id, from } = chosen;
7✔
207
  log({
7✔
208
    msg: `inline chosen result: id=${result_id}, inline_message_id=${inline_message_id ? "yes" : "no"}, from=${from?.id}`,
7✔
209
  });
210
  if (!inline_message_id) {
7✔
211
    log({
1✔
212
      msg: "inline chosen result: no inline_message_id (set /setinlinefeedback to 100% in BotFather)",
213
      logLevel: "warn",
214
    });
215
    return;
1✔
216
  }
217

218
  if (result_id === LIVE_RESULT_ID) {
6✔
219
    log({ msg: "inline chosen result: live answer picked, nothing to compute" });
1✔
220
    return;
1✔
221
  }
222

223
  const match = /^btn:(\d+)$/.exec(result_id);
5✔
224
  if (!match) {
5✔
225
    log({ msg: `inline chosen result: unrecognized result_id "${result_id}"` });
1✔
226
    return;
1✔
227
  }
228
  const index = parseInt(match[1], 10);
4✔
229
  const buttons = getInlineButtons();
4✔
230
  const button = buttons[index];
4✔
231
  if (!button) {
4✔
232
    log({ msg: `inline chosen result: button index ${index} out of range` });
1✔
233
    return;
1✔
234
  }
235

236
  try {
3✔
237
    const answer = await computeInlineAnswer(button.prompt, query || "", from);
3!
238
    const text = (answer || "(empty answer)").slice(0, TELEGRAM_MAX_MESSAGE_LENGTH);
1!
239
    await ctx.telegram.editMessageText(undefined, undefined, inline_message_id, text);
1✔
240
    log({ msg: `inline answer delivered (${text.length} chars) for "${button.name}"` });
1✔
241
  } catch (e) {
242
    const message = (e as Error).message;
2✔
243
    log({ msg: `inline chosen result error: ${message}`, logLevel: "warn" });
2✔
244
    // Replace the ⏳ placeholder with an error so the user is not left waiting
245
    // forever. Guard the edit itself so a failed edit cannot throw out of here.
246
    try {
2✔
247
      const text = `Error: ${message}`.slice(0, TELEGRAM_MAX_MESSAGE_LENGTH);
2✔
248
      await ctx.telegram.editMessageText(undefined, undefined, inline_message_id, text);
2✔
249
    } catch {
250
      // ignore — nothing more we can do to update the inline message
251
    }
252
  }
253
}
254

255
export default onInlineQuery;
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc