• 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

85.59
/src/handlers/onTextMessage.ts
1
import { Context, Markup } from "telegraf";
2
import { Chat, Message } from "telegraf/types";
3
import { useThreads } from "../threads.ts";
4
import { ConfigChatType, ThreadStateType } from "../types.ts";
5
import { syncButtons, useConfig } from "../config.ts";
6
import { log } from "../helpers.ts";
7
import {
8
  addToHistory,
9
  forgetHistoryOnTimeout,
10
  forgetHistory,
11
  initThread,
12
} from "../helpers/history.ts";
13
import { rememberSave, isRememberCommand, stripRememberPrefix } from "../helpers/memory.ts";
14
import { setLastCtx } from "../helpers/lastCtx.ts";
15
import { addOauthToThread, ensureAuth } from "../helpers/google.ts";
16
import { generateButtonsFromAgent, requestGptAnswer } from "../helpers/gpt.ts";
17
import checkAccessLevel, { isGuestModeReply } from "./access.ts";
18
import resolveChatButtons from "./resolveChatButtons.ts";
19
import { handleFormFlow } from "./formFlow.ts";
20
import { editTelegramMessage, sendTelegramMessage } from "../telegram/send.ts";
21

22
// Track active responses per chat to allow cancellation
23
interface ActiveResponse {
24
  abortController: AbortController;
25
  buttonsAbortController?: AbortController;
26
  isCompleted: boolean;
27
}
28

29
const activeResponses = new Map<number, ActiveResponse>();
20✔
30

31
// Secretary mode: per-chat debounce state. While a timer is pending, incoming
32
// messages are added to history without triggering an answer; on expiry the
33
// bot answers once using the latest message context.
34
interface SecretaryState {
35
  timer: ReturnType<typeof setTimeout>;
36
  ctx: Context & { secondTry?: boolean };
37
  msg: Message.TextMessage;
38
  chat: ConfigChatType;
39
  callback?: (msg: Message.TextMessage) => Promise<void> | void;
40
}
41

42
const secretaryTimers = new Map<number, SecretaryState>();
20✔
43

44
// Per-chat secretary session. A "session" stays alive while messages keep arriving;
45
// it expires after sessionDurationSeconds of inactivity (sliding window). The
46
// firstAnswerDelay debounce applies only to the first message of a session.
47
// `handover` is set when the owner replies manually (Telegram Business): while a
48
// handed-over session is active the bot stays silent in that chat.
49
interface SecretarySession {
50
  lastActivity: number;
51
  handover: boolean;
52
}
53
const DEFAULT_SESSION_DURATION_SECONDS = 600;
20✔
54
const secretarySessions = new Map<number, SecretarySession>();
20✔
55

56
// Called when the connection owner replies manually in a Business chat: cancel any
57
// pending opening answer and mark the session handed over so auto-answers pause until
58
// the session expires.
59
export function noteSecretaryHumanReply(chatId: number) {
60
  const timer = secretaryTimers.get(chatId);
1✔
61
  if (timer) {
1!
NEW
62
    clearTimeout(timer.timer);
×
NEW
63
    secretaryTimers.delete(chatId);
×
64
  }
65
  secretarySessions.set(chatId, { lastActivity: Date.now(), handover: true });
1✔
66
}
67

68
// Exposed for tests to reset module-level state between runs.
69
export const __testSecretary = {
20✔
70
  clear() {
71
    for (const state of secretaryTimers.values()) clearTimeout(state.timer);
28✔
72
    secretaryTimers.clear();
28✔
73
    secretarySessions.clear();
28✔
74
  },
75
  has(chatId: number) {
76
    return secretaryTimers.has(chatId);
4✔
77
  },
78
};
79

80
function escapeRegExp(value: string): string {
81
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1✔
82
}
83

84
// Mark an incoming Business message as read on behalf of the connected business account.
85
// Telegram's Bot API exposes this only via `readBusinessMessage` (Bot API 9.0), which
86
// requires a business_connection_id — there is no mark-as-read for regular chats.
87
// telegraf 4.16.3 has no typings for this method, so call it via the raw callApi cast
88
// (same pattern as onBusinessMessage.ts).
89
async function markBusinessMessageRead(
90
  ctx: Context,
91
  chatId: number,
92
  messageId: number,
93
  businessConnectionId: string,
94
) {
95
  try {
1✔
96
    await (
1✔
97
      ctx.telegram as unknown as { callApi: (m: string, p: object) => Promise<unknown> }
98
    ).callApi("readBusinessMessage", {
99
      business_connection_id: businessConnectionId,
100
      chat_id: chatId,
101
      message_id: messageId,
102
    });
103
  } catch (e) {
NEW
104
    log({
×
105
      msg: `readBusinessMessage failed: ${(e as Error).message}`,
106
      logLevel: "warn",
107
      chatId,
108
      role: "system",
109
    });
110
  }
111
}
112

113
// Guest mode: when the bot is mentioned in a reply to another user, apply the
114
// configured guest prompt as the per-turn system instruction. Applied at the
115
// point the answer is launched (not on batched secretary turns) so the override
116
// always matches the message actually being answered.
117
function applyGuestModeOverride(
118
  thread: ThreadStateType,
119
  msg: Message.TextMessage,
120
  chat: ConfigChatType,
121
) {
122
  const guestPrompt = useConfig().guestMode?.prompt;
17✔
123
  if (guestPrompt && isGuestModeReply(msg, chat)) {
17!
NEW
124
    thread.nextSystemMessage = guestPrompt;
×
125
  }
126
}
127

128
// Resolve the per-turn system override for a secretary answer: the secretary prompt
129
// takes precedence; otherwise fall back to the guest-mode override evaluated against
130
// the message actually being answered. Used by both the debounced (session start) and
131
// the immediate (in-session) secretary paths.
132
function applySecretaryTurnOverride(
133
  thread: ThreadStateType,
134
  msg: Message.TextMessage,
135
  chat: ConfigChatType,
136
) {
137
  const secretary = chat.chatParams?.secretary;
15✔
138
  const base = secretary?.prompt;
15✔
139
  // Per-customer override: append the matched user's prompt to the base prompt, or
140
  // replace it entirely when override is true. Match the message sender's username
141
  // case-insensitively (consistent with includesUser).
142
  const username = msg.from?.username?.toLowerCase();
15✔
143
  const match = username
15✔
144
    ? secretary?.usernames?.find((u) => u.username.toLowerCase() === username)
5✔
145
    : undefined;
146
  const prompt = match
15✔
147
    ? match.override
2✔
148
      ? match.prompt
149
      : [base, match.prompt].filter(Boolean).join("\n\n")
150
    : base;
151
  if (prompt) {
15✔
152
    thread.nextSystemMessage = prompt;
4✔
153
  } else {
154
    applyGuestModeOverride(thread, msg, chat);
11✔
155
  }
156
}
157

158
// Cancel any in-flight answer for the chat, then start a new one. Bookkeeping
159
// for cancellation lives in `activeResponses`.
160
function launchAnswer(
161
  ctx: Context & { secondTry?: boolean },
162
  msg: Message.TextMessage,
163
  chat: ConfigChatType,
164
  callback?: (msg: Message.TextMessage) => Promise<void> | void,
165
) {
166
  const chatId = msg.chat.id;
21✔
167
  const answerId = msg.message_id?.toString() || "";
21✔
168
  const businessConnectionId = (ctx as { businessConnectionId?: string }).businessConnectionId;
21✔
169
  const extraMessageParams = {
21✔
170
    ...(ctx.message?.message_id ? { reply_to_message_id: ctx.message?.message_id } : {}),
21✔
171
    ...(businessConnectionId ? { business_connection_id: businessConnectionId } : {}),
21✔
172
  };
173

174
  // Cancel any existing response for this chat
175
  const existingResponse = activeResponses.get(chatId);
21✔
176
  if (existingResponse) {
21✔
177
    log({
2✔
178
      msg: "cancelling previous response",
179
      chatId,
180
      answerId,
181
      chatTitle: (msg.chat as Chat.TitleChat).title,
182
      role: "system",
183
      username: msg?.from?.username,
184
      logLevel: "debug",
185
    });
186
    if (!existingResponse.isCompleted) {
2!
187
      existingResponse.abortController.abort();
2✔
188
      existingResponse.buttonsAbortController?.abort();
2✔
189
    }
190
    activeResponses.delete(chatId);
2✔
191
  }
192

193
  // Create a new abort controller for this response
194
  const abortController = new AbortController();
21✔
195

196
  // Start responding immediately
197
  const responsePromise = answerToMessage(ctx, msg, chat, {
21✔
198
    ...extraMessageParams,
199
    signal: abortController.signal,
200
  });
201

202
  // Store the active response for potential cancellation
203
  const activeResponse: ActiveResponse = {
21✔
204
    abortController,
205
    isCompleted: false,
206
  };
207
  activeResponses.set(chatId, activeResponse);
21✔
208

209
  responsePromise
21✔
210
    .then(async (msgSent) => {
211
      // Mark the answered message as read (Business chats only). Only when we actually
212
      // answered (msgSent truthy) and a business connection is present — there is no
213
      // Bot API mark-as-read for regular chats.
214
      if (msgSent && chat.chatParams?.secretary?.markAsReaded) {
20✔
215
        if (businessConnectionId) {
2✔
216
          await markBusinessMessageRead(ctx, chatId, msg.message_id, businessConnectionId);
1✔
217
        } else {
218
          log({
1✔
219
            msg: "secretary: markAsReaded skipped (not a Business chat, no business_connection_id)",
220
            logLevel: "debug",
221
            chatId,
222
            answerId,
223
            role: "system",
224
          });
225
        }
226
      }
227
      if (msgSent && typeof callback === "function") {
20✔
228
        return callback(msgSent);
4✔
229
      }
230
    })
231
    .catch((error) => {
232
      // Ignore errors from aborted requests (superseded responses abort on purpose).
NEW
233
      if (!abortController.signal.aborted) {
×
NEW
234
        log({
×
235
          msg: `response handler error: ${error instanceof Error ? error.message : String(error)}`,
×
236
          logLevel: "error",
237
          chatId,
238
          answerId,
239
          chatTitle: (msg.chat as Chat.TitleChat).title,
240
          role: "system",
241
          username: msg?.from?.username,
242
        });
243
      }
244
    })
245
    .finally(() => {
246
      const currentResponse = activeResponses.get(chatId);
20✔
247
      if (currentResponse === activeResponse) {
20✔
248
        activeResponse.isCompleted = true;
18✔
249
        activeResponses.delete(chatId);
18✔
250
      }
251
    });
252
}
253

254
export default async function onTextMessage(
255
  ctx: Context & { secondTry?: boolean },
256
  next?: () => Promise<void> | void,
257
  callback?: (msg: Message.TextMessage) => Promise<void> | void,
258
) {
259
  setLastCtx(ctx);
31✔
260

261
  const access = await checkAccessLevel(ctx);
31✔
262
  if (!access) return;
31✔
263
  const { msg, chat } = access;
30✔
264

265
  const chatTitle = (ctx.message?.chat as Chat.TitleChat).title || "";
30✔
266
  const chatId = msg.chat.id;
30✔
267
  const answerId = msg.message_id?.toString() || "";
30✔
268

269
  log({
30✔
270
    msg: msg.text,
271
    logLevel: "info",
272
    chatId,
273
    answerId,
274
    chatTitle,
275
    role: "user",
276
    username: msg?.from?.username,
277
  });
278

279
  // ensure thread exists before processing buttons
280
  const thread = initThread(msg, chat);
29✔
281

282
  const extraMessageParams = ctx.message?.message_id
29✔
283
    ? { reply_to_message_id: ctx.message?.message_id }
284
    : {};
285

286
  // may replace msg.text
287
  const buttonResponse = await resolveChatButtons(ctx, msg, chat, thread, extraMessageParams);
29✔
288
  if (buttonResponse) return buttonResponse;
29✔
289

290
  // Handle form flow if configured
291
  const formResult = await handleFormFlow(ctx, msg, chat, thread, extraMessageParams);
26✔
292
  if (formResult !== undefined) return formResult;
26!
293

294
  const originalText = msg.text ?? "";
26!
295
  const textWithoutPrefix = chat.prefix
26✔
296
    ? originalText.replace(new RegExp(`^${escapeRegExp(chat.prefix)}[\\s\\p{P}]*`, "iu"), "")
297
    : originalText;
298

299
  if (chat.chatParams?.vector_memory && isRememberCommand(textWithoutPrefix)) {
26✔
300
    const text = stripRememberPrefix(textWithoutPrefix);
2✔
301
    const confirmation = await rememberSave({ text, msg, chat });
2✔
302
    await sendTelegramMessage(msg.chat.id, confirmation, undefined, ctx, chat);
2✔
303
    return;
2✔
304
  }
305

306
  // addToHistory should be after replace msg.text
307
  addToHistory(msg, chat);
24✔
308
  forgetHistoryOnTimeout(chat, msg);
24✔
309

310
  // Secretary mode: debounce answers per chat. The first message starts a
311
  // timer; messages arriving within the window are added to history above but
312
  // do not trigger an answer. On expiry the bot answers once using the latest
313
  // message context.
314
  // On the context_length_exceeded retry (secondTry), the answer should fire
315
  // immediately rather than wait another debounce window.
316
  // When a callback is supplied (HTTP interface), bypass debounce entirely:
317
  // the callback ends the HTTP response, so deferring it into the timer would
318
  // hang the request for the delay window and orphan superseded responses.
319
  const secretary = chat.chatParams?.secretary;
24✔
320
  if (secretary && secretary.firstAnswerDelay > 0 && !ctx.secondTry && !callback) {
24✔
321
    const existing = secretaryTimers.get(chatId);
18✔
322
    if (existing) {
18✔
323
      // Already waiting — update to the latest message and keep batching.
324
      existing.ctx = ctx;
2✔
325
      existing.msg = msg;
2✔
326
      existing.chat = chat;
2✔
327
      existing.callback = callback;
2✔
328
      log({
2✔
329
        msg: "secretary: batched message into pending answer",
330
        logLevel: "debug",
331
        chatId,
332
        answerId,
333
        chatTitle,
334
        role: "system",
335
        username: msg?.from?.username,
336
      });
337
      return;
2✔
338
    }
339

340
    // The firstAnswerDelay debounce applies only to the first message of a session.
341
    // A session stays alive while messages keep arriving; it expires after
342
    // sessionDurationSeconds of inactivity (sliding window).
343
    const sessionMs = (secretary.sessionDurationSeconds ?? DEFAULT_SESSION_DURATION_SECONDS) * 1000;
16✔
344
    const sess = secretarySessions.get(chatId);
16✔
345
    const sessionActive = sess !== undefined && Date.now() - sess.lastActivity <= sessionMs;
16✔
346

347
    if (sessionActive && sess.handover) {
16✔
348
      // The owner is handling this chat manually — stay silent, but keep the session
349
      // alive so suppression lasts until the conversation goes quiet.
350
      sess.lastActivity = Date.now();
1✔
351
      log({
1✔
352
        msg: "secretary: suppressed (owner handling this session)",
353
        logLevel: "debug",
354
        chatId,
355
        answerId,
356
        chatTitle,
357
        role: "system",
358
        username: msg?.from?.username,
359
      });
360
      return;
1✔
361
    }
362

363
    if (sessionActive) {
15✔
364
      // Mid-session: answer immediately and slide the session window forward.
365
      sess.lastActivity = Date.now();
1✔
366
      applySecretaryTurnOverride(thread, msg, chat);
1✔
367
      log({
1✔
368
        msg: "secretary: session active, answering immediately",
369
        logLevel: "debug",
370
        chatId,
371
        answerId,
372
        chatTitle,
373
        role: "system",
374
        username: msg?.from?.username,
375
      });
376
      launchAnswer(ctx, msg, chat, callback);
1✔
377
      return;
1✔
378
    }
379

380
    // New session: a fresh session clears any prior handover (bot resumes).
381
    secretarySessions.set(chatId, { lastActivity: Date.now(), handover: false });
14✔
382
    const state: SecretaryState = { timer: undefined as never, ctx, msg, chat, callback };
14✔
383
    state.timer = setTimeout(() => {
14✔
384
      secretaryTimers.delete(chatId);
14✔
385
      try {
14✔
386
        // The session becomes active once the opening answer fires.
387
        const current = secretarySessions.get(chatId);
14✔
388
        if (current) current.lastActivity = Date.now();
14!
NEW
389
        else secretarySessions.set(chatId, { lastActivity: Date.now(), handover: false });
×
390
        log({
14✔
391
          msg: "secretary: delay elapsed, answering",
392
          logLevel: "info",
393
          chatId,
394
          chatTitle,
395
          role: "system",
396
        });
397
        // Resolve the per-turn system override from the final batched message, so a
398
        // guest prompt that applied to an earlier batched turn cannot leak into a
399
        // non-guest answer.
400
        applySecretaryTurnOverride(thread, state.msg, state.chat);
14✔
401
        launchAnswer(state.ctx, state.msg, state.chat, state.callback);
14✔
402
      } catch (e) {
403
        // A synchronous throw here would otherwise be an unhandled exception:
404
        // no log, no answer. Record it so the failing step is visible.
NEW
405
        log({
×
406
          msg: `secretary timer error: ${(e as Error).message}`,
407
          logLevel: "error",
408
          chatId,
409
          chatTitle,
410
          role: "system",
411
        });
412
      }
413
    }, secretary.firstAnswerDelay * 1000);
414
    secretaryTimers.set(chatId, state);
14✔
415
    log({
14✔
416
      msg: `secretary: waiting ${secretary.firstAnswerDelay}s before answering`,
417
      logLevel: "info",
418
      chatId,
419
      answerId,
420
      chatTitle,
421
      role: "system",
422
      username: msg?.from?.username,
423
    });
424
    return;
14✔
425
  }
426

427
  // Non-debounced path (no secretary, HTTP callback, or secondTry retry):
428
  // apply the guest-mode override for this exact message before answering.
429
  applyGuestModeOverride(thread, msg, chat);
6✔
430
  launchAnswer(ctx, msg, chat, callback);
6✔
431
}
432

433
export async function answerToMessage(
434
  ctx: Context & { secondTry?: boolean },
435
  msg: Message.TextMessage,
436
  chat: ConfigChatType,
437
  extraMessageParams: Record<string, unknown> & { signal?: AbortSignal },
438
): Promise<Message.TextMessage | undefined> {
439
  if (
27✔
440
    useConfig().auth.oauth_google?.client_id ||
53✔
441
    useConfig().auth.google_service_account?.private_key
442
  ) {
443
    const authClient = await ensureAuth(msg.from?.id || 0);
1!
444
    addOauthToThread(authClient, useThreads(), msg);
1✔
445

446
    if (chat.buttonsSync && msg.text === "sync" && msg) {
1!
447
      let syncResult: Message.TextMessage | undefined;
448
      await ctx.persistentChatAction("typing", async () => {
1✔
449
        if (!msg) return;
1!
450
        const buttons = await syncButtons(chat, authClient);
1✔
451
        if (!buttons) {
1!
452
          syncResult = await sendTelegramMessage(
×
453
            msg.chat.id,
454
            "Ошибка синхронизации",
455
            undefined,
456
            ctx,
457
            chat,
458
          );
459
          return;
×
460
        }
461

462
        const extraParams = Markup.keyboard(buttons.map((b) => b.name)).resize();
1✔
463
        const answer = `Готово: ${buttons.map((b) => b.name).join(", ")}`;
1✔
464
        syncResult = await sendTelegramMessage(msg.chat.id, answer, extraParams, ctx, chat);
1✔
465
      });
466
      return syncResult;
1✔
467
    }
468
  }
469

470
  try {
26✔
471
    let msgSent: Message.TextMessage | undefined;
472
    await ctx.persistentChatAction("typing", async () => {
26✔
473
      if (!msg || extraMessageParams.signal?.aborted) {
26!
474
        return;
×
475
      }
476

477
      const responseFormat = chat.chatParams?.responseButtons
26✔
478
        ? {
479
            type: "json_schema" as const,
480
            json_schema: {
481
              name: "response",
482
              schema: {
483
                type: "object",
484
                additionalProperties: false,
485
                properties: {
486
                  message: { type: "string" },
487
                  buttons: {
488
                    type: "array",
489
                    items: {
490
                      type: "object",
491
                      additionalProperties: false,
492
                      properties: {
493
                        name: { type: "string", description: "Short name" },
494
                        prompt: { type: "string" },
495
                      },
496
                      required: ["name", "prompt"],
497
                    },
498
                  },
499
                },
500
                required: ["message", "buttons"],
501
              },
502
            },
503
          }
504
        : undefined;
505

506
      const res = await requestGptAnswer(msg, chat, ctx, {
26✔
507
        signal: extraMessageParams.signal,
508
        responseFormat,
509
      });
510

511
      if (extraMessageParams.signal?.aborted) {
25✔
512
        return;
1✔
513
      }
514

515
      const thread = useThreads()[msg.chat.id];
24✔
516
      const text = res?.content || "бот не ответил";
24✔
517
      const extraParams: Record<string, unknown> = {
24✔
518
        ...extraMessageParams,
519
      };
520
      const buttons = res?.buttons || chat.buttonsSynced || chat.buttons;
24✔
521
      thread.dynamicButtons = res?.buttons;
24✔
522
      if (buttons) {
24✔
523
        const extraParamsButtons = Markup.keyboard(buttons.map((b) => b.name)).resize();
2✔
524
        Object.assign(extraParams, extraParamsButtons);
2✔
525
      }
526
      const chatTitle = (msg.chat as Chat.TitleChat).title;
24✔
527
      const answerId = msg.message_id?.toString() || "";
24✔
528
      log({
24✔
529
        msg: text,
530
        logLevel: "info",
531
        chatId: msg.chat.id,
532
        answerId,
533
        chatTitle,
534
        role: "system",
535
      });
536
      msgSent = await sendTelegramMessage(msg.chat.id, text, extraParams, ctx, chat);
24✔
537
      if (msgSent?.chat.id) useThreads()[msgSent.chat.id].msgs.push(msgSent);
24!
538

539
      if (chat.chatParams?.responseButtonsAgent && msgSent && !res?.buttons?.length) {
24✔
540
        const buttonsAbortController = new AbortController();
3✔
541
        const activeResponse = activeResponses.get(msg.chat.id);
3✔
542
        if (activeResponse) {
3✔
543
          activeResponse.buttonsAbortController = buttonsAbortController;
2✔
544
        }
545
        await applyResponseButtonsAgent({
3✔
546
          answerText: msgSent.text || text,
3!
547
          baseExtraParams: extraParams,
548
          chat,
549
          ctx,
550
          msg,
551
          originalMessage: msgSent,
552
          signal: buttonsAbortController.signal,
553
          thread,
554
        });
555
        const currentResponse = activeResponses.get(msg.chat.id);
2✔
556
        if (currentResponse?.buttonsAbortController === buttonsAbortController) {
2!
557
          currentResponse.buttonsAbortController = undefined;
×
558
        }
559
      }
560
    });
561
    return msgSent;
24✔
562
  } catch (e) {
563
    const error = e as { message: string };
1✔
564
    console.log("error:", error);
1✔
565
    await ctx.persistentChatAction("typing", async () => {});
1✔
566
    if (ctx.secondTry) return;
1!
567
    if (!ctx.secondTry && error.message.includes("context_length_exceeded")) {
1!
568
      ctx.secondTry = true;
×
569
      forgetHistory(msg.chat.id);
×
570
      void onTextMessage(ctx);
×
571
    }
572
    return await sendTelegramMessage(
1✔
573
      msg.chat.id,
574
      `${error.message}${ctx.secondTry ? "\n\nПовторная отправка последнего сообщения..." : ""}`,
1!
575
      extraMessageParams,
576
      ctx,
577
      chat,
578
    );
579
  }
580
}
581

582
async function applyResponseButtonsAgent({
583
  answerText,
584
  baseExtraParams,
585
  chat,
586
  ctx,
587
  msg,
588
  originalMessage,
589
  signal,
590
  thread,
591
}: {
592
  answerText: string;
593
  baseExtraParams: Record<string, unknown>;
594
  chat: ConfigChatType;
595
  ctx: Context;
596
  msg: Message.TextMessage;
597
  originalMessage: Message.TextMessage;
598
  signal?: AbortSignal;
599
  thread: ReturnType<typeof useThreads>[number];
600
}) {
601
  if (signal?.aborted) return;
3!
602

603
  try {
3✔
604
    const generatedButtons = await generateButtonsFromAgent(answerText, msg, { signal });
3✔
605
    if (!generatedButtons?.length) return;
2✔
606

607
    if (signal?.aborted) return;
1!
608

609
    thread.dynamicButtons = generatedButtons;
1✔
610

611
    const extraParamsWithButtons = {
1✔
612
      ...baseExtraParams,
613
      ...Markup.keyboard(generatedButtons.map((b) => b.name)).resize(),
1✔
614
    };
615

616
    const shouldSendButtonsMessage = chat.chatParams?.responseButtonsMessage ?? true;
1✔
617
    if (shouldSendButtonsMessage) {
1!
618
      const buttonsText = generatedButtons.map((b) => `- ${b.name}: ${b.prompt}`).join("\n");
1✔
619
      await sendTelegramMessage(msg.chat.id, buttonsText, extraParamsWithButtons, ctx, chat);
1✔
620
      return;
1✔
621
    }
622

623
    const updated = await editTelegramMessage(
×
624
      originalMessage,
625
      answerText,
626
      extraParamsWithButtons,
627
      ctx,
628
      chat,
629
    );
630

631
    if (updated?.chat.id) {
×
632
      useThreads()[updated.chat.id].msgs.push(updated);
×
633
    }
634
  } catch (error) {
635
    if (signal?.aborted) return;
×
636
    throw error;
×
637
  }
638
}
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