diff --git a/CHANGELOG.md b/CHANGELOG.md index f3998d892..3c5321870 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,7 @@ Status: beta. - **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed). ### Fixes +- Telegram: avoid silent empty replies by tracking normalization skips before fallback. (#3796) - Mentions: honor mentionPatterns even when explicit mentions are present. (#3303) Thanks @HirokiKobayashi-R. - Discord: restore username directory lookup in target resolution. (#3131) Thanks @bonald. - Agents: align MiniMax base URL test expectation with default provider config. (#3131) Thanks @bonald. diff --git a/src/auto-reply/reply/normalize-reply.test.ts b/src/auto-reply/reply/normalize-reply.test.ts index 30fb5e3f5..b9547c2b1 100644 --- a/src/auto-reply/reply/normalize-reply.test.ts +++ b/src/auto-reply/reply/normalize-reply.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { SILENT_REPLY_TOKEN } from "../tokens.js"; import { normalizeReplyPayload } from "./normalize-reply.js"; // Keep channelData-only payloads so channel-specific replies survive normalization. @@ -19,4 +20,30 @@ describe("normalizeReplyPayload", () => { expect(normalized?.text).toBeUndefined(); expect(normalized?.channelData).toEqual(payload.channelData); }); + + it("records silent skips", () => { + const reasons: string[] = []; + const normalized = normalizeReplyPayload( + { text: SILENT_REPLY_TOKEN }, + { + onSkip: (reason) => reasons.push(reason), + }, + ); + + expect(normalized).toBeNull(); + expect(reasons).toEqual(["silent"]); + }); + + it("records empty skips", () => { + const reasons: string[] = []; + const normalized = normalizeReplyPayload( + { text: " " }, + { + onSkip: (reason) => reasons.push(reason), + }, + ); + + expect(normalized).toBeNull(); + expect(reasons).toEqual(["empty"]); + }); }); diff --git a/src/auto-reply/reply/normalize-reply.ts b/src/auto-reply/reply/normalize-reply.ts index 7968088bd..9a58bebde 100644 --- a/src/auto-reply/reply/normalize-reply.ts +++ b/src/auto-reply/reply/normalize-reply.ts @@ -8,6 +8,8 @@ import { } from "./response-prefix-template.js"; import { hasLineDirectives, parseLineDirectives } from "./line-directives.js"; +export type NormalizeReplySkipReason = "empty" | "silent" | "heartbeat"; + export type NormalizeReplyOptions = { responsePrefix?: string; /** Context for template variable interpolation in responsePrefix */ @@ -15,6 +17,7 @@ export type NormalizeReplyOptions = { onHeartbeatStrip?: () => void; stripHeartbeat?: boolean; silentToken?: string; + onSkip?: (reason: NormalizeReplySkipReason) => void; }; export function normalizeReplyPayload( @@ -26,12 +29,18 @@ export function normalizeReplyPayload( payload.channelData && Object.keys(payload.channelData).length > 0, ); const trimmed = payload.text?.trim() ?? ""; - if (!trimmed && !hasMedia && !hasChannelData) return null; + if (!trimmed && !hasMedia && !hasChannelData) { + opts.onSkip?.("empty"); + return null; + } const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN; let text = payload.text ?? undefined; if (text && isSilentReplyText(text, silentToken)) { - if (!hasMedia && !hasChannelData) return null; + if (!hasMedia && !hasChannelData) { + opts.onSkip?.("silent"); + return null; + } text = ""; } if (text && !trimmed) { @@ -43,14 +52,20 @@ export function normalizeReplyPayload( if (shouldStripHeartbeat && text?.includes(HEARTBEAT_TOKEN)) { const stripped = stripHeartbeatToken(text, { mode: "message" }); if (stripped.didStrip) opts.onHeartbeatStrip?.(); - if (stripped.shouldSkip && !hasMedia && !hasChannelData) return null; + if (stripped.shouldSkip && !hasMedia && !hasChannelData) { + opts.onSkip?.("heartbeat"); + return null; + } text = stripped.text; } if (text) { text = sanitizeUserFacingText(text); } - if (!text?.trim() && !hasMedia && !hasChannelData) return null; + if (!text?.trim() && !hasMedia && !hasChannelData) { + opts.onSkip?.("empty"); + return null; + } // Parse LINE-specific directives from text (quick_replies, location, confirm, buttons) let enrichedPayload: ReplyPayload = { ...payload, text }; diff --git a/src/auto-reply/reply/reply-dispatcher.ts b/src/auto-reply/reply/reply-dispatcher.ts index f41667802..fd7fb5493 100644 --- a/src/auto-reply/reply/reply-dispatcher.ts +++ b/src/auto-reply/reply/reply-dispatcher.ts @@ -1,6 +1,6 @@ import type { HumanDelayConfig } from "../../config/types.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; -import { normalizeReplyPayload } from "./normalize-reply.js"; +import { normalizeReplyPayload, type NormalizeReplySkipReason } from "./normalize-reply.js"; import type { ResponsePrefixContext } from "./response-prefix-template.js"; import type { TypingController } from "./typing.js"; @@ -8,6 +8,11 @@ export type ReplyDispatchKind = "tool" | "block" | "final"; type ReplyDispatchErrorHandler = (err: unknown, info: { kind: ReplyDispatchKind }) => void; +type ReplyDispatchSkipHandler = ( + payload: ReplyPayload, + info: { kind: ReplyDispatchKind; reason: NormalizeReplySkipReason }, +) => void; + type ReplyDispatchDeliverer = ( payload: ReplyPayload, info: { kind: ReplyDispatchKind }, @@ -42,6 +47,8 @@ export type ReplyDispatcherOptions = { onHeartbeatStrip?: () => void; onIdle?: () => void; onError?: ReplyDispatchErrorHandler; + // AIDEV-NOTE: onSkip lets channels detect silent/empty drops (e.g. Telegram empty-response fallback). + onSkip?: ReplyDispatchSkipHandler; /** Human-like delay between block replies for natural rhythm. */ humanDelay?: HumanDelayConfig; }; @@ -65,15 +72,16 @@ export type ReplyDispatcher = { getQueuedCounts: () => Record; }; +type NormalizeReplyPayloadInternalOptions = Pick< + ReplyDispatcherOptions, + "responsePrefix" | "responsePrefixContext" | "responsePrefixContextProvider" | "onHeartbeatStrip" +> & { + onSkip?: (reason: NormalizeReplySkipReason) => void; +}; + function normalizeReplyPayloadInternal( payload: ReplyPayload, - opts: Pick< - ReplyDispatcherOptions, - | "responsePrefix" - | "responsePrefixContext" - | "responsePrefixContextProvider" - | "onHeartbeatStrip" - >, + opts: NormalizeReplyPayloadInternalOptions, ): ReplyPayload | null { // Prefer dynamic context provider over static context const prefixContext = opts.responsePrefixContextProvider?.() ?? opts.responsePrefixContext; @@ -82,6 +90,7 @@ function normalizeReplyPayloadInternal( responsePrefix: opts.responsePrefix, responsePrefixContext: prefixContext, onHeartbeatStrip: opts.onHeartbeatStrip, + onSkip: opts.onSkip, }); } @@ -99,7 +108,13 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis }; const enqueue = (kind: ReplyDispatchKind, payload: ReplyPayload) => { - const normalized = normalizeReplyPayloadInternal(payload, options); + const normalized = normalizeReplyPayloadInternal(payload, { + responsePrefix: options.responsePrefix, + responsePrefixContext: options.responsePrefixContext, + responsePrefixContextProvider: options.responsePrefixContextProvider, + onHeartbeatStrip: options.onHeartbeatStrip, + onSkip: (reason) => options.onSkip?.(payload, { kind, reason }), + }); if (!normalized) return false; queuedCounts[kind] += 1; pending += 1; diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index c5ade7466..ea006e316 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -21,6 +21,8 @@ import { createTelegramDraftStream } from "./draft-stream.js"; import { cacheSticker, describeStickerImage } from "./sticker-cache.js"; import { resolveAgentDir } from "../agents/agent-scope.js"; +const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again."; + async function resolveStickerVisionSupport(cfg, agentId) { try { const catalog = await loadModelCatalog({ config: cfg }); @@ -198,6 +200,15 @@ export const dispatchTelegramMessage = async ({ } } + const replyQuoteText = + ctxPayload.ReplyToIsQuote && ctxPayload.ReplyToBody + ? ctxPayload.ReplyToBody.trim() || undefined + : undefined; + const deliveryState = { + delivered: false, + skippedNonSilent: 0, + }; + const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg, @@ -209,12 +220,7 @@ export const dispatchTelegramMessage = async ({ await flushDraft(); draftStream?.stop(); } - - const replyQuoteText = - ctxPayload.ReplyToIsQuote && ctxPayload.ReplyToBody - ? ctxPayload.ReplyToBody.trim() || undefined - : undefined; - await deliverReplies({ + const result = await deliverReplies({ replies: [payload], chatId: String(chatId), token: opts.token, @@ -228,8 +234,13 @@ export const dispatchTelegramMessage = async ({ onVoiceRecording: sendRecordVoice, linkPreview: telegramCfg.linkPreview, replyQuoteText, - notifyEmptyResponse: info.kind === "final", }); + if (result.delivered) { + deliveryState.delivered = true; + } + }, + onSkip: (_payload, info) => { + if (info.reason !== "silent") deliveryState.skippedNonSilent += 1; }, onError: (err, info) => { runtime.error?.(danger(`telegram ${info.kind} reply failed: ${String(err)}`)); @@ -261,7 +272,27 @@ export const dispatchTelegramMessage = async ({ }, }); draftStream?.stop(); - if (!queuedFinal) { + let sentFallback = false; + if (!deliveryState.delivered && deliveryState.skippedNonSilent > 0) { + const result = await deliverReplies({ + replies: [{ text: EMPTY_RESPONSE_FALLBACK }], + chatId: String(chatId), + token: opts.token, + runtime, + bot, + replyToMode, + textLimit, + messageThreadId: resolvedThreadId, + tableMode, + chunkMode, + linkPreview: telegramCfg.linkPreview, + replyQuoteText, + }); + sentFallback = result.delivered; + } + + const hasFinalResponse = queuedFinal || sentFallback; + if (!hasFinalResponse) { if (isGroup && historyKey) { clearHistoryEntriesIfEnabled({ historyMap: groupHistories, historyKey, limit: historyLimit }); } diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 5f2bbf1e6..59f109a1f 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -50,6 +50,8 @@ import { import { firstDefined, isSenderAllowed, normalizeAllowFromWithStore } from "./bot-access.js"; import { readTelegramAllowFromStore } from "./pairing-store.js"; +const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again."; + type TelegramNativeCommandContext = Context & { match?: string }; type TelegramCommandAuthResult = { @@ -483,13 +485,18 @@ export const registerTelegramNativeCommands = ({ : undefined; const chunkMode = resolveChunkMode(cfg, "telegram", route.accountId); + const deliveryState = { + delivered: false, + skippedNonSilent: 0, + }; + await dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg, dispatcherOptions: { responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix, - deliver: async (payload, info) => { - await deliverReplies({ + deliver: async (payload, _info) => { + const result = await deliverReplies({ replies: [payload], chatId: String(chatId), token: opts.token, @@ -501,8 +508,13 @@ export const registerTelegramNativeCommands = ({ tableMode, chunkMode, linkPreview: telegramCfg.linkPreview, - notifyEmptyResponse: info.kind === "final", }); + if (result.delivered) { + deliveryState.delivered = true; + } + }, + onSkip: (_payload, info) => { + if (info.reason !== "silent") deliveryState.skippedNonSilent += 1; }, onError: (err, info) => { runtime.error?.(danger(`telegram slash ${info.kind} reply failed: ${String(err)}`)); @@ -513,6 +525,21 @@ export const registerTelegramNativeCommands = ({ disableBlockStreaming, }, }); + if (!deliveryState.delivered && deliveryState.skippedNonSilent > 0) { + await deliverReplies({ + replies: [{ text: EMPTY_RESPONSE_FALLBACK }], + chatId: String(chatId), + token: opts.token, + runtime, + bot, + replyToMode, + textLimit, + messageThreadId: threadIdForSend, + tableMode, + chunkMode, + linkPreview: telegramCfg.linkPreview, + }); + } }); } diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index 648ddeeab..669340b20 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -44,8 +44,6 @@ export async function deliverReplies(params: { linkPreview?: boolean; /** Optional quote text for Telegram reply_parameters. */ replyQuoteText?: string; - /** If true, send a fallback message when all replies are empty. Default: false */ - notifyEmptyResponse?: boolean; }): Promise<{ delivered: boolean }> { const { replies, @@ -60,7 +58,10 @@ export async function deliverReplies(params: { } = params; const chunkMode = params.chunkMode ?? "length"; let hasReplied = false; - let skippedEmpty = 0; + let hasDelivered = false; + const markDelivered = () => { + hasDelivered = true; + }; const chunkText = (markdown: string) => { const markdownChunks = chunkMode === "newline" @@ -88,7 +89,6 @@ export async function deliverReplies(params: { continue; } runtime.error?.(danger("reply missing text/media")); - skippedEmpty++; continue; } const replyToId = replyToMode === "off" ? undefined : resolveTelegramReplyId(reply.replyToId); @@ -118,6 +118,7 @@ export async function deliverReplies(params: { linkPreview, replyMarkup: shouldAttachButtons ? replyMarkup : undefined, }); + markDelivered(); if (replyToId && !hasReplied) { hasReplied = true; } @@ -169,18 +170,21 @@ export async function deliverReplies(params: { runtime, fn: () => bot.api.sendAnimation(chatId, file, { ...mediaParams }), }); + markDelivered(); } else if (kind === "image") { await withTelegramApiErrorLogging({ operation: "sendPhoto", runtime, fn: () => bot.api.sendPhoto(chatId, file, { ...mediaParams }), }); + markDelivered(); } else if (kind === "video") { await withTelegramApiErrorLogging({ operation: "sendVideo", runtime, fn: () => bot.api.sendVideo(chatId, file, { ...mediaParams }), }); + markDelivered(); } else if (kind === "audio") { const { useVoice } = resolveTelegramVoiceSend({ wantsVoice: reply.audioAsVoice === true, // default false (backward compatible) @@ -199,6 +203,7 @@ export async function deliverReplies(params: { shouldLog: (err) => !isVoiceMessagesForbidden(err), fn: () => bot.api.sendVoice(chatId, file, { ...mediaParams }), }); + markDelivered(); } catch (voiceErr) { // Fall back to text if voice messages are forbidden in this chat. // This happens when the recipient has Telegram Premium privacy settings @@ -225,6 +230,7 @@ export async function deliverReplies(params: { replyMarkup, replyQuoteText, }); + markDelivered(); // Skip this media item; continue with next. continue; } @@ -237,6 +243,7 @@ export async function deliverReplies(params: { runtime, fn: () => bot.api.sendAudio(chatId, file, { ...mediaParams }), }); + markDelivered(); } } else { await withTelegramApiErrorLogging({ @@ -244,6 +251,7 @@ export async function deliverReplies(params: { runtime, fn: () => bot.api.sendDocument(chatId, file, { ...mediaParams }), }); + markDelivered(); } if (replyToId && !hasReplied) { hasReplied = true; @@ -264,6 +272,7 @@ export async function deliverReplies(params: { linkPreview, replyMarkup: i === 0 ? replyMarkup : undefined, }); + markDelivered(); if (replyToId && !hasReplied) { hasReplied = true; } @@ -273,17 +282,7 @@ export async function deliverReplies(params: { } } - // If all replies were empty and notifyEmptyResponse is enabled, send a fallback message - // Check both: (1) replies with no content (skippedEmpty), (2) no replies at all (empty array) - if (!hasReplied && (skippedEmpty > 0 || replies.length === 0) && params.notifyEmptyResponse) { - const fallbackText = "No response generated. Please try again."; - await sendTelegramText(bot, chatId, fallbackText, runtime, { - messageThreadId, - }); - hasReplied = true; - } - - return { delivered: hasReplied }; + return { delivered: hasDelivered }; } export async function resolveMedia(