diff --git a/CHANGELOG.md b/CHANGELOG.md
index bb52cc8e6..3c5321870 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -73,11 +73,15 @@ 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.
- Agents: prevent retries on oversized image errors and surface size limits. (#2871) Thanks @Suksham-sharma.
- Agents: inherit provider baseUrl/api for inline models. (#2740) Thanks @lploc94.
- Memory Search: keep auto provider model defaults and only include remote when configured. (#2576) Thanks @papago2355.
+- Telegram: include AccountId in native command context for multi-agent routing. (#2942) Thanks @Chloe-VP.
+- Telegram: handle video note attachments in media extraction. (#2905) Thanks @mylukin.
- TTS: read OPENAI_TTS_BASE_URL at runtime instead of module load to honor config.env. (#3341) Thanks @hclsys.
- macOS: auto-scroll to bottom when sending a new message while scrolled up. (#2471) Thanks @kennyklee.
- Web UI: auto-expand the chat compose textarea while typing (with sensible max height). (#2950) Thanks @shivamraut101.
diff --git a/README.md b/README.md
index 7e884be33..70ca70157 100644
--- a/README.md
+++ b/README.md
@@ -479,36 +479,38 @@ Thanks to all clawtributors:
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts
index c080ef55f..2604038ec 100644
--- a/src/auto-reply/reply/dispatch-from-config.test.ts
+++ b/src/auto-reply/reply/dispatch-from-config.test.ts
@@ -138,7 +138,7 @@ describe("dispatchReplyFromConfig", () => {
);
});
- it("does not provide onToolResult when routing cross-provider", async () => {
+ it("provides onToolResult in DM sessions", async () => {
mocks.tryFastAbortFromMessage.mockResolvedValue({
handled: false,
aborted: false,
@@ -147,9 +147,34 @@ describe("dispatchReplyFromConfig", () => {
const cfg = {} as MoltbotConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
- Provider: "slack",
- OriginatingChannel: "telegram",
- OriginatingTo: "telegram:999",
+ Provider: "telegram",
+ ChatType: "direct",
+ });
+
+ const replyResolver = async (
+ _ctx: MsgContext,
+ opts: GetReplyOptions | undefined,
+ _cfg: ClawdbotConfig,
+ ) => {
+ expect(opts?.onToolResult).toBeDefined();
+ expect(typeof opts?.onToolResult).toBe("function");
+ return { text: "hi" } satisfies ReplyPayload;
+ };
+
+ await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
+ expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not provide onToolResult in group sessions", async () => {
+ mocks.tryFastAbortFromMessage.mockResolvedValue({
+ handled: false,
+ aborted: false,
+ });
+ const cfg = {} as ClawdbotConfig;
+ const dispatcher = createDispatcher();
+ const ctx = buildTestCtx({
+ Provider: "telegram",
+ ChatType: "group",
});
const replyResolver = async (
@@ -162,12 +187,62 @@ describe("dispatchReplyFromConfig", () => {
};
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
+ expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
+ });
- expect(mocks.routeReply).toHaveBeenCalledWith(
- expect.objectContaining({
- payload: expect.objectContaining({ text: "hi" }),
- }),
+ it("sends tool results via dispatcher in DM sessions", async () => {
+ mocks.tryFastAbortFromMessage.mockResolvedValue({
+ handled: false,
+ aborted: false,
+ });
+ const cfg = {} as ClawdbotConfig;
+ const dispatcher = createDispatcher();
+ const ctx = buildTestCtx({
+ Provider: "telegram",
+ ChatType: "direct",
+ });
+
+ const replyResolver = async (
+ _ctx: MsgContext,
+ opts: GetReplyOptions | undefined,
+ _cfg: ClawdbotConfig,
+ ) => {
+ // Simulate tool result emission
+ await opts?.onToolResult?.({ text: "🔧 exec: ls" });
+ return { text: "done" } satisfies ReplyPayload;
+ };
+
+ await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
+ expect(dispatcher.sendToolResult).toHaveBeenCalledWith(
+ expect.objectContaining({ text: "🔧 exec: ls" }),
);
+ expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not provide onToolResult for native slash commands", async () => {
+ mocks.tryFastAbortFromMessage.mockResolvedValue({
+ handled: false,
+ aborted: false,
+ });
+ const cfg = {} as ClawdbotConfig;
+ const dispatcher = createDispatcher();
+ const ctx = buildTestCtx({
+ Provider: "telegram",
+ ChatType: "direct",
+ CommandSource: "native",
+ });
+
+ const replyResolver = async (
+ _ctx: MsgContext,
+ opts: GetReplyOptions | undefined,
+ _cfg: ClawdbotConfig,
+ ) => {
+ expect(opts?.onToolResult).toBeUndefined();
+ return { text: "hi" } satisfies ReplyPayload;
+ };
+
+ await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
+ expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
});
it("fast-aborts without calling the reply resolver", async () => {
diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts
index 58d5d71b5..c85e654de 100644
--- a/src/auto-reply/reply/dispatch-from-config.ts
+++ b/src/auto-reply/reply/dispatch-from-config.ts
@@ -276,6 +276,27 @@ export async function dispatchReplyFromConfig(params: {
ctx,
{
...params.replyOptions,
+ onToolResult:
+ ctx.ChatType !== "group" && ctx.CommandSource !== "native"
+ ? (payload: ReplyPayload) => {
+ const run = async () => {
+ const ttsPayload = await maybeApplyTtsToPayload({
+ payload,
+ cfg,
+ channel: ttsChannel,
+ kind: "tool",
+ inboundAudio,
+ ttsAuto: sessionTtsAuto,
+ });
+ if (shouldRouteToOriginating) {
+ await sendPayloadAsync(ttsPayload, undefined, false);
+ } else {
+ dispatcher.sendToolResult(ttsPayload);
+ }
+ };
+ return run();
+ }
+ : undefined,
onBlockReply: (payload: ReplyPayload, context) => {
const run = async () => {
// Accumulate block text for TTS generation after streaming
diff --git a/src/auto-reply/reply/mentions.test.ts b/src/auto-reply/reply/mentions.test.ts
index 7742b4f30..07cff23a9 100644
--- a/src/auto-reply/reply/mentions.test.ts
+++ b/src/auto-reply/reply/mentions.test.ts
@@ -4,7 +4,7 @@ import { matchesMentionWithExplicit } from "./mentions.js";
describe("matchesMentionWithExplicit", () => {
const mentionRegexes = [/\bclawd\b/i];
- it("prefers explicit mentions when other mentions are present", () => {
+ it("checks mentionPatterns even when explicit mention is available", () => {
const result = matchesMentionWithExplicit({
text: "@clawd hello",
mentionRegexes,
@@ -14,6 +14,19 @@ describe("matchesMentionWithExplicit", () => {
canResolveExplicit: true,
},
});
+ expect(result).toBe(true);
+ });
+
+ it("returns false when explicit is false and no regex match", () => {
+ const result = matchesMentionWithExplicit({
+ text: "<@999999> hello",
+ mentionRegexes,
+ explicit: {
+ hasAnyMention: true,
+ isExplicitlyMentioned: false,
+ canResolveExplicit: true,
+ },
+ });
expect(result).toBe(false);
});
diff --git a/src/auto-reply/reply/mentions.ts b/src/auto-reply/reply/mentions.ts
index 71964ac5f..9554a3c7b 100644
--- a/src/auto-reply/reply/mentions.ts
+++ b/src/auto-reply/reply/mentions.ts
@@ -90,7 +90,9 @@ export function matchesMentionWithExplicit(params: {
const explicit = params.explicit?.isExplicitlyMentioned === true;
const explicitAvailable = params.explicit?.canResolveExplicit === true;
const hasAnyMention = params.explicit?.hasAnyMention === true;
- if (hasAnyMention && explicitAvailable) return explicit;
+ if (hasAnyMention && explicitAvailable) {
+ return explicit || params.mentionRegexes.some((re) => re.test(cleaned));
+ }
if (!cleaned) return explicit;
return explicit || params.mentionRegexes.some((re) => re.test(cleaned));
}
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/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts b/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts
index 80bb5ff8f..bd4ec38ca 100644
--- a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts
+++ b/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts
@@ -135,7 +135,7 @@ describe("discord tool result dispatch", () => {
expect(sendMock).toHaveBeenCalledTimes(1);
}, 20_000);
- it("skips guild messages when another user is explicitly mentioned", async () => {
+ it("accepts guild messages when mentionPatterns match even if another user is mentioned", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js");
const cfg = {
agents: {
@@ -211,8 +211,8 @@ describe("discord tool result dispatch", () => {
client,
);
- expect(dispatchMock).not.toHaveBeenCalled();
- expect(sendMock).not.toHaveBeenCalled();
+ expect(dispatchMock).toHaveBeenCalledTimes(1);
+ expect(sendMock).toHaveBeenCalledTimes(1);
}, 20_000);
it("accepts guild reply-to-bot messages as implicit mentions", async () => {
diff --git a/src/media-understanding/apply.test.ts b/src/media-understanding/apply.test.ts
index e06adbc24..7a4d68136 100644
--- a/src/media-understanding/apply.test.ts
+++ b/src/media-understanding/apply.test.ts
@@ -550,10 +550,11 @@ describe("applyMediaUnderstanding", () => {
it("escapes XML special characters in filenames to prevent injection", async () => {
const { applyMediaUnderstanding } = await loadApply();
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
- // Create file with XML special characters in the name (what filesystem allows)
+ // Use & in filename — valid on all platforms (including Windows, which
+ // forbids < and > in NTFS filenames) and still requires XML escaping.
// Note: The sanitizeFilename in store.ts would strip most dangerous chars,
// but we test that even if some slip through, they get escaped in output
- const filePath = path.join(dir, "file.txt");
+ const filePath = path.join(dir, "file&test.txt");
await fs.writeFile(filePath, "safe content");
const ctx: MsgContext = {
@@ -575,10 +576,9 @@ describe("applyMediaUnderstanding", () => {
expect(result.appliedFile).toBe(true);
// Verify XML special chars are escaped in the output
- expect(ctx.Body).toContain("<");
- expect(ctx.Body).toContain(">");
- // The raw < and > should not appear unescaped in the name attribute
- expect(ctx.Body).not.toMatch(/name="[^"]*<[^"]*"/);
+ expect(ctx.Body).toContain("&");
+ // The name attribute should contain the escaped form, not a raw unescaped &
+ expect(ctx.Body).toMatch(/name="file&test\.txt"/);
});
it("normalizes MIME types to prevent attribute injection", async () => {
diff --git a/src/slack/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts b/src/slack/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts
index ce7015399..4481d7589 100644
--- a/src/slack/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts
+++ b/src/slack/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts
@@ -392,7 +392,7 @@ describe("monitorSlackProvider tool results", () => {
expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true);
});
- it("skips channel messages when another user is explicitly mentioned", async () => {
+ it("accepts channel messages when mentionPatterns match even if another user is mentioned", async () => {
slackTestState.config = {
messages: {
responsePrefix: "PFX",
@@ -433,8 +433,8 @@ describe("monitorSlackProvider tool results", () => {
controller.abort();
await run;
- expect(replyMock).not.toHaveBeenCalled();
- expect(sendMock).not.toHaveBeenCalled();
+ expect(replyMock).toHaveBeenCalledTimes(1);
+ expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true);
});
it("treats replies to bot threads as implicit mentions", async () => {
diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts
index 832a4413d..abd06cdef 100644
--- a/src/telegram/bot-message-context.ts
+++ b/src/telegram/bot-message-context.ts
@@ -335,6 +335,7 @@ export const buildTelegramMessageContext = async ({
let placeholder = "";
if (msg.photo) placeholder = "";
else if (msg.video) placeholder = "";
+ else if (msg.video_note) placeholder = "";
else if (msg.audio || msg.voice) placeholder = "";
else if (msg.document) placeholder = "";
else if (msg.sticker) placeholder = "";
diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts
index cead0628a..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,
@@ -229,6 +235,12 @@ export const dispatchTelegramMessage = async ({
linkPreview: telegramCfg.linkPreview,
replyQuoteText,
});
+ 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)}`));
@@ -260,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 3415ea927..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 = {
@@ -468,6 +470,7 @@ export const registerTelegramNativeCommands = ({
CommandAuthorized: commandAuthorized,
CommandSource: "native" as const,
SessionKey: `telegram:slash:${senderId || chatId}`,
+ AccountId: route.accountId,
CommandTargetSessionKey: sessionKey,
MessageThreadId: threadIdForSend,
IsForum: isForum,
@@ -482,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) => {
- await deliverReplies({
+ deliver: async (payload, _info) => {
+ const result = await deliverReplies({
replies: [payload],
chatId: String(chatId),
token: opts.token,
@@ -501,6 +509,12 @@ export const registerTelegramNativeCommands = ({
chunkMode,
linkPreview: telegramCfg.linkPreview,
});
+ 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)}`));
@@ -511,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.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts
index 8bfe1fdd3..03aaeebd7 100644
--- a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts
+++ b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts
@@ -212,7 +212,7 @@ describe("createTelegramBot", () => {
);
});
- it("skips group messages when another user is explicitly mentioned", async () => {
+ it("accepts group messages when mentionPatterns match even if another user is mentioned", async () => {
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType;
replySpy.mockReset();
@@ -249,7 +249,8 @@ describe("createTelegramBot", () => {
getFile: async () => ({ download: async () => new Uint8Array() }),
});
- expect(replySpy).not.toHaveBeenCalled();
+ expect(replySpy).toHaveBeenCalledTimes(1);
+ expect(replySpy.mock.calls[0][0].WasMentioned).toBe(true);
});
it("keeps group envelope headers stable (sender identity is separate)", async () => {
diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts
index c6d731d2e..669340b20 100644
--- a/src/telegram/bot/delivery.ts
+++ b/src/telegram/bot/delivery.ts
@@ -44,7 +44,7 @@ export async function deliverReplies(params: {
linkPreview?: boolean;
/** Optional quote text for Telegram reply_parameters. */
replyQuoteText?: string;
-}) {
+}): Promise<{ delivered: boolean }> {
const {
replies,
chatId,
@@ -58,6 +58,10 @@ export async function deliverReplies(params: {
} = params;
const chunkMode = params.chunkMode ?? "length";
let hasReplied = false;
+ let hasDelivered = false;
+ const markDelivered = () => {
+ hasDelivered = true;
+ };
const chunkText = (markdown: string) => {
const markdownChunks =
chunkMode === "newline"
@@ -114,6 +118,7 @@ export async function deliverReplies(params: {
linkPreview,
replyMarkup: shouldAttachButtons ? replyMarkup : undefined,
});
+ markDelivered();
if (replyToId && !hasReplied) {
hasReplied = true;
}
@@ -165,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)
@@ -195,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
@@ -221,6 +230,7 @@ export async function deliverReplies(params: {
replyMarkup,
replyQuoteText,
});
+ markDelivered();
// Skip this media item; continue with next.
continue;
}
@@ -233,6 +243,7 @@ export async function deliverReplies(params: {
runtime,
fn: () => bot.api.sendAudio(chatId, file, { ...mediaParams }),
});
+ markDelivered();
}
} else {
await withTelegramApiErrorLogging({
@@ -240,6 +251,7 @@ export async function deliverReplies(params: {
runtime,
fn: () => bot.api.sendDocument(chatId, file, { ...mediaParams }),
});
+ markDelivered();
}
if (replyToId && !hasReplied) {
hasReplied = true;
@@ -260,6 +272,7 @@ export async function deliverReplies(params: {
linkPreview,
replyMarkup: i === 0 ? replyMarkup : undefined,
});
+ markDelivered();
if (replyToId && !hasReplied) {
hasReplied = true;
}
@@ -268,6 +281,8 @@ export async function deliverReplies(params: {
}
}
}
+
+ return { delivered: hasDelivered };
}
export async function resolveMedia(
@@ -368,7 +383,12 @@ export async function resolveMedia(
}
const m =
- msg.photo?.[msg.photo.length - 1] ?? msg.video ?? msg.document ?? msg.audio ?? msg.voice;
+ msg.photo?.[msg.photo.length - 1] ??
+ msg.video ??
+ msg.video_note ??
+ msg.document ??
+ msg.audio ??
+ msg.voice;
if (!m?.file_id) return null;
const file = await ctx.getFile();
if (!file.file_path) {
@@ -395,6 +415,7 @@ export async function resolveMedia(
let placeholder = "";
if (msg.photo) placeholder = "";
else if (msg.video) placeholder = "";
+ else if (msg.video_note) placeholder = "";
else if (msg.audio || msg.voice) placeholder = "";
return { path: saved.path, contentType: saved.contentType, placeholder };
}