Compare commits

...

4 Commits

Author SHA1 Message Date
Ayaan Zaidi
9bc7cb7977 fix: handle telegram empty replies (#3483) (thanks @kiranjd) 2026-01-29 11:13:00 +05:30
Ayaan Zaidi
92f7fa3918 fix: handle empty telegram replies 2026-01-29 11:06:54 +05:30
kiranjd
151503872a fix(telegram): handle empty reply array in notifyEmptyResponse
Previous fix only checked skippedEmpty > 0, but when model returns
content: [] no payloads are created at all. Now also checks
replies.length === 0 to catch this case.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 11:05:59 +05:30
kiranjd
80480132d2 fix(telegram): notify users when agent returns empty response
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 11:05:59 +05:30
7 changed files with 155 additions and 23 deletions

View File

@ -73,6 +73,7 @@ Status: beta.
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed). - **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
### Fixes ### Fixes
- Telegram: send a fallback reply when delivery is empty to avoid silent errors. (#3483) Thanks @kiranjd.
- Mentions: honor mentionPatterns even when explicit mentions are present. (#3303) Thanks @HirokiKobayashi-R. - Mentions: honor mentionPatterns even when explicit mentions are present. (#3303) Thanks @HirokiKobayashi-R.
- Discord: restore username directory lookup in target resolution. (#3131) Thanks @bonald. - 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. - Agents: align MiniMax base URL test expectation with default provider config. (#3131) Thanks @bonald.

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
import { normalizeReplyPayload } from "./normalize-reply.js"; import { normalizeReplyPayload } from "./normalize-reply.js";
// Keep channelData-only payloads so channel-specific replies survive normalization. // Keep channelData-only payloads so channel-specific replies survive normalization.
@ -19,4 +20,30 @@ describe("normalizeReplyPayload", () => {
expect(normalized?.text).toBeUndefined(); expect(normalized?.text).toBeUndefined();
expect(normalized?.channelData).toEqual(payload.channelData); 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"]);
});
}); });

View File

@ -8,6 +8,8 @@ import {
} from "./response-prefix-template.js"; } from "./response-prefix-template.js";
import { hasLineDirectives, parseLineDirectives } from "./line-directives.js"; import { hasLineDirectives, parseLineDirectives } from "./line-directives.js";
export type NormalizeReplySkipReason = "empty" | "silent" | "heartbeat";
export type NormalizeReplyOptions = { export type NormalizeReplyOptions = {
responsePrefix?: string; responsePrefix?: string;
/** Context for template variable interpolation in responsePrefix */ /** Context for template variable interpolation in responsePrefix */
@ -15,6 +17,7 @@ export type NormalizeReplyOptions = {
onHeartbeatStrip?: () => void; onHeartbeatStrip?: () => void;
stripHeartbeat?: boolean; stripHeartbeat?: boolean;
silentToken?: string; silentToken?: string;
onSkip?: (reason: NormalizeReplySkipReason) => void;
}; };
export function normalizeReplyPayload( export function normalizeReplyPayload(
@ -26,12 +29,18 @@ export function normalizeReplyPayload(
payload.channelData && Object.keys(payload.channelData).length > 0, payload.channelData && Object.keys(payload.channelData).length > 0,
); );
const trimmed = payload.text?.trim() ?? ""; 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; const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN;
let text = payload.text ?? undefined; let text = payload.text ?? undefined;
if (text && isSilentReplyText(text, silentToken)) { if (text && isSilentReplyText(text, silentToken)) {
if (!hasMedia && !hasChannelData) return null; if (!hasMedia && !hasChannelData) {
opts.onSkip?.("silent");
return null;
}
text = ""; text = "";
} }
if (text && !trimmed) { if (text && !trimmed) {
@ -43,14 +52,20 @@ export function normalizeReplyPayload(
if (shouldStripHeartbeat && text?.includes(HEARTBEAT_TOKEN)) { if (shouldStripHeartbeat && text?.includes(HEARTBEAT_TOKEN)) {
const stripped = stripHeartbeatToken(text, { mode: "message" }); const stripped = stripHeartbeatToken(text, { mode: "message" });
if (stripped.didStrip) opts.onHeartbeatStrip?.(); 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; text = stripped.text;
} }
if (text) { if (text) {
text = sanitizeUserFacingText(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) // Parse LINE-specific directives from text (quick_replies, location, confirm, buttons)
let enrichedPayload: ReplyPayload = { ...payload, text }; let enrichedPayload: ReplyPayload = { ...payload, text };

View File

@ -1,6 +1,6 @@
import type { HumanDelayConfig } from "../../config/types.js"; import type { HumanDelayConfig } from "../../config/types.js";
import type { GetReplyOptions, ReplyPayload } from "../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 { ResponsePrefixContext } from "./response-prefix-template.js";
import type { TypingController } from "./typing.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 ReplyDispatchErrorHandler = (err: unknown, info: { kind: ReplyDispatchKind }) => void;
type ReplyDispatchSkipHandler = (
payload: ReplyPayload,
info: { kind: ReplyDispatchKind; reason: NormalizeReplySkipReason },
) => void;
type ReplyDispatchDeliverer = ( type ReplyDispatchDeliverer = (
payload: ReplyPayload, payload: ReplyPayload,
info: { kind: ReplyDispatchKind }, info: { kind: ReplyDispatchKind },
@ -42,6 +47,8 @@ export type ReplyDispatcherOptions = {
onHeartbeatStrip?: () => void; onHeartbeatStrip?: () => void;
onIdle?: () => void; onIdle?: () => void;
onError?: ReplyDispatchErrorHandler; onError?: ReplyDispatchErrorHandler;
/** onSkip lets channels detect silent/empty drops (e.g. Telegram empty-response fallback). */
onSkip?: ReplyDispatchSkipHandler;
/** Human-like delay between block replies for natural rhythm. */ /** Human-like delay between block replies for natural rhythm. */
humanDelay?: HumanDelayConfig; humanDelay?: HumanDelayConfig;
}; };
@ -65,15 +72,16 @@ export type ReplyDispatcher = {
getQueuedCounts: () => Record<ReplyDispatchKind, number>; getQueuedCounts: () => Record<ReplyDispatchKind, number>;
}; };
type NormalizeReplyPayloadInternalOptions = Pick<
ReplyDispatcherOptions,
"responsePrefix" | "responsePrefixContext" | "responsePrefixContextProvider" | "onHeartbeatStrip"
> & {
onSkip?: (reason: NormalizeReplySkipReason) => void;
};
function normalizeReplyPayloadInternal( function normalizeReplyPayloadInternal(
payload: ReplyPayload, payload: ReplyPayload,
opts: Pick< opts: NormalizeReplyPayloadInternalOptions,
ReplyDispatcherOptions,
| "responsePrefix"
| "responsePrefixContext"
| "responsePrefixContextProvider"
| "onHeartbeatStrip"
>,
): ReplyPayload | null { ): ReplyPayload | null {
// Prefer dynamic context provider over static context // Prefer dynamic context provider over static context
const prefixContext = opts.responsePrefixContextProvider?.() ?? opts.responsePrefixContext; const prefixContext = opts.responsePrefixContextProvider?.() ?? opts.responsePrefixContext;
@ -82,6 +90,7 @@ function normalizeReplyPayloadInternal(
responsePrefix: opts.responsePrefix, responsePrefix: opts.responsePrefix,
responsePrefixContext: prefixContext, responsePrefixContext: prefixContext,
onHeartbeatStrip: opts.onHeartbeatStrip, onHeartbeatStrip: opts.onHeartbeatStrip,
onSkip: opts.onSkip,
}); });
} }
@ -99,7 +108,13 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis
}; };
const enqueue = (kind: ReplyDispatchKind, payload: ReplyPayload) => { 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; if (!normalized) return false;
queuedCounts[kind] += 1; queuedCounts[kind] += 1;
pending += 1; pending += 1;

View File

@ -21,6 +21,8 @@ import { createTelegramDraftStream } from "./draft-stream.js";
import { cacheSticker, describeStickerImage } from "./sticker-cache.js"; import { cacheSticker, describeStickerImage } from "./sticker-cache.js";
import { resolveAgentDir } from "../agents/agent-scope.js"; import { resolveAgentDir } from "../agents/agent-scope.js";
const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again.";
async function resolveStickerVisionSupport(cfg, agentId) { async function resolveStickerVisionSupport(cfg, agentId) {
try { try {
const catalog = await loadModelCatalog({ config: cfg }); 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({ const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload, ctx: ctxPayload,
cfg, cfg,
@ -209,12 +220,7 @@ export const dispatchTelegramMessage = async ({
await flushDraft(); await flushDraft();
draftStream?.stop(); draftStream?.stop();
} }
const result = await deliverReplies({
const replyQuoteText =
ctxPayload.ReplyToIsQuote && ctxPayload.ReplyToBody
? ctxPayload.ReplyToBody.trim() || undefined
: undefined;
await deliverReplies({
replies: [payload], replies: [payload],
chatId: String(chatId), chatId: String(chatId),
token: opts.token, token: opts.token,
@ -229,6 +235,12 @@ export const dispatchTelegramMessage = async ({
linkPreview: telegramCfg.linkPreview, linkPreview: telegramCfg.linkPreview,
replyQuoteText, replyQuoteText,
}); });
if (result.delivered) {
deliveryState.delivered = true;
}
},
onSkip: (_payload, info) => {
if (info.reason !== "silent") deliveryState.skippedNonSilent += 1;
}, },
onError: (err, info) => { onError: (err, info) => {
runtime.error?.(danger(`telegram ${info.kind} reply failed: ${String(err)}`)); runtime.error?.(danger(`telegram ${info.kind} reply failed: ${String(err)}`));
@ -260,7 +272,27 @@ export const dispatchTelegramMessage = async ({
}, },
}); });
draftStream?.stop(); 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) { if (isGroup && historyKey) {
clearHistoryEntriesIfEnabled({ historyMap: groupHistories, historyKey, limit: historyLimit }); clearHistoryEntriesIfEnabled({ historyMap: groupHistories, historyKey, limit: historyLimit });
} }

View File

@ -50,6 +50,8 @@ import {
import { firstDefined, isSenderAllowed, normalizeAllowFromWithStore } from "./bot-access.js"; import { firstDefined, isSenderAllowed, normalizeAllowFromWithStore } from "./bot-access.js";
import { readTelegramAllowFromStore } from "./pairing-store.js"; import { readTelegramAllowFromStore } from "./pairing-store.js";
const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again.";
type TelegramNativeCommandContext = Context & { match?: string }; type TelegramNativeCommandContext = Context & { match?: string };
type TelegramCommandAuthResult = { type TelegramCommandAuthResult = {
@ -483,13 +485,18 @@ export const registerTelegramNativeCommands = ({
: undefined; : undefined;
const chunkMode = resolveChunkMode(cfg, "telegram", route.accountId); const chunkMode = resolveChunkMode(cfg, "telegram", route.accountId);
const deliveryState = {
delivered: false,
skippedNonSilent: 0,
};
await dispatchReplyWithBufferedBlockDispatcher({ await dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload, ctx: ctxPayload,
cfg, cfg,
dispatcherOptions: { dispatcherOptions: {
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix, responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
deliver: async (payload) => { deliver: async (payload, _info) => {
await deliverReplies({ const result = await deliverReplies({
replies: [payload], replies: [payload],
chatId: String(chatId), chatId: String(chatId),
token: opts.token, token: opts.token,
@ -502,6 +509,12 @@ export const registerTelegramNativeCommands = ({
chunkMode, chunkMode,
linkPreview: telegramCfg.linkPreview, linkPreview: telegramCfg.linkPreview,
}); });
if (result.delivered) {
deliveryState.delivered = true;
}
},
onSkip: (_payload, info) => {
if (info.reason !== "silent") deliveryState.skippedNonSilent += 1;
}, },
onError: (err, info) => { onError: (err, info) => {
runtime.error?.(danger(`telegram slash ${info.kind} reply failed: ${String(err)}`)); runtime.error?.(danger(`telegram slash ${info.kind} reply failed: ${String(err)}`));
@ -512,6 +525,21 @@ export const registerTelegramNativeCommands = ({
disableBlockStreaming, 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,
});
}
}); });
} }

View File

@ -44,7 +44,7 @@ export async function deliverReplies(params: {
linkPreview?: boolean; linkPreview?: boolean;
/** Optional quote text for Telegram reply_parameters. */ /** Optional quote text for Telegram reply_parameters. */
replyQuoteText?: string; replyQuoteText?: string;
}) { }): Promise<{ delivered: boolean }> {
const { const {
replies, replies,
chatId, chatId,
@ -58,6 +58,10 @@ export async function deliverReplies(params: {
} = params; } = params;
const chunkMode = params.chunkMode ?? "length"; const chunkMode = params.chunkMode ?? "length";
let hasReplied = false; let hasReplied = false;
let hasDelivered = false;
const markDelivered = () => {
hasDelivered = true;
};
const chunkText = (markdown: string) => { const chunkText = (markdown: string) => {
const markdownChunks = const markdownChunks =
chunkMode === "newline" chunkMode === "newline"
@ -114,6 +118,7 @@ export async function deliverReplies(params: {
linkPreview, linkPreview,
replyMarkup: shouldAttachButtons ? replyMarkup : undefined, replyMarkup: shouldAttachButtons ? replyMarkup : undefined,
}); });
markDelivered();
if (replyToId && !hasReplied) { if (replyToId && !hasReplied) {
hasReplied = true; hasReplied = true;
} }
@ -165,18 +170,21 @@ export async function deliverReplies(params: {
runtime, runtime,
fn: () => bot.api.sendAnimation(chatId, file, { ...mediaParams }), fn: () => bot.api.sendAnimation(chatId, file, { ...mediaParams }),
}); });
markDelivered();
} else if (kind === "image") { } else if (kind === "image") {
await withTelegramApiErrorLogging({ await withTelegramApiErrorLogging({
operation: "sendPhoto", operation: "sendPhoto",
runtime, runtime,
fn: () => bot.api.sendPhoto(chatId, file, { ...mediaParams }), fn: () => bot.api.sendPhoto(chatId, file, { ...mediaParams }),
}); });
markDelivered();
} else if (kind === "video") { } else if (kind === "video") {
await withTelegramApiErrorLogging({ await withTelegramApiErrorLogging({
operation: "sendVideo", operation: "sendVideo",
runtime, runtime,
fn: () => bot.api.sendVideo(chatId, file, { ...mediaParams }), fn: () => bot.api.sendVideo(chatId, file, { ...mediaParams }),
}); });
markDelivered();
} else if (kind === "audio") { } else if (kind === "audio") {
const { useVoice } = resolveTelegramVoiceSend({ const { useVoice } = resolveTelegramVoiceSend({
wantsVoice: reply.audioAsVoice === true, // default false (backward compatible) wantsVoice: reply.audioAsVoice === true, // default false (backward compatible)
@ -195,6 +203,7 @@ export async function deliverReplies(params: {
shouldLog: (err) => !isVoiceMessagesForbidden(err), shouldLog: (err) => !isVoiceMessagesForbidden(err),
fn: () => bot.api.sendVoice(chatId, file, { ...mediaParams }), fn: () => bot.api.sendVoice(chatId, file, { ...mediaParams }),
}); });
markDelivered();
} catch (voiceErr) { } catch (voiceErr) {
// Fall back to text if voice messages are forbidden in this chat. // Fall back to text if voice messages are forbidden in this chat.
// This happens when the recipient has Telegram Premium privacy settings // This happens when the recipient has Telegram Premium privacy settings
@ -221,6 +230,7 @@ export async function deliverReplies(params: {
replyMarkup, replyMarkup,
replyQuoteText, replyQuoteText,
}); });
markDelivered();
// Skip this media item; continue with next. // Skip this media item; continue with next.
continue; continue;
} }
@ -233,6 +243,7 @@ export async function deliverReplies(params: {
runtime, runtime,
fn: () => bot.api.sendAudio(chatId, file, { ...mediaParams }), fn: () => bot.api.sendAudio(chatId, file, { ...mediaParams }),
}); });
markDelivered();
} }
} else { } else {
await withTelegramApiErrorLogging({ await withTelegramApiErrorLogging({
@ -240,6 +251,7 @@ export async function deliverReplies(params: {
runtime, runtime,
fn: () => bot.api.sendDocument(chatId, file, { ...mediaParams }), fn: () => bot.api.sendDocument(chatId, file, { ...mediaParams }),
}); });
markDelivered();
} }
if (replyToId && !hasReplied) { if (replyToId && !hasReplied) {
hasReplied = true; hasReplied = true;
@ -260,6 +272,7 @@ export async function deliverReplies(params: {
linkPreview, linkPreview,
replyMarkup: i === 0 ? replyMarkup : undefined, replyMarkup: i === 0 ? replyMarkup : undefined,
}); });
markDelivered();
if (replyToId && !hasReplied) { if (replyToId && !hasReplied) {
hasReplied = true; hasReplied = true;
} }
@ -268,6 +281,7 @@ export async function deliverReplies(params: {
} }
} }
} }
return { delivered: hasDelivered };
} }
export async function resolveMedia( export async function resolveMedia(