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:

steipete plum-dawg bohdanpodvirnyi iHildy jaydenfyi joaohlisboa mneves75 MatthieuBizien MaudeBot Glucksberg - rahthakor vrknetha radek-paclt Tobias Bischoff joshp123 vignesh07 czekaj mukhtharcm sebslight maxsumrall - xadenryan rodrigouroz juanpablodlc hsrvc magimetal zerone0x meaningfool tyler6204 patelhiren NicholasSpisak - jonisjongithub abhisekbasu1 jamesgroat claude JustYannicc Hyaxia dantelex SocialNerd42069 daveonkels google-labs-jules[bot] - lc0rp mousberg adam91holt hougangdev gumadeiras mteam88 hirefrank joeynyc orlyjamie dbhurley - Mariano Belinky Eng. Juan Combetto TSavo julianengel bradleypriest benithors rohannagpal timolins f-trycua benostein - nachx639 shakkernerd pvoo sreekaransrinath gupsammy cristip73 stefangalescu nachoiacovino Vasanth Rao Naik Sabavat petter-b - cpojer scald thewilloftheshadow andranik-sahakyan davidguttman sleontenko denysvitali sircrumpet peschee rafaelreis-r - dominicnunez ratulsarna lutr0 danielz1z AdeboyeDN Alg0rix papago2355 emanuelst KristijanJovanovski rdev - rhuanssauro joshrad-dev kiranjd osolmaz adityashaw2 CashWilliams sheeek artuskg Takhoffman onutc - pauloportella neooriginal manuelhettich minghinmatthewlam myfunc travisirby buddyh connorshea kyleok obviyus - mcinteerj dependabot[bot] John-Rood timkrase uos-status gerardward2007 roshanasingh4 tosh-hamburg azade-c dlauer - JonUleis bjesuiter cheeeee robbyczgw-cla Josh Phillips YuriNachos pookNast Whoaa512 chriseidhof ngutman - ysqander aj47 kennyklee superman32432432 Yurii Chukhlib grp06 antons austinm911 blacksmith-sh[bot] damoahdominic - dan-dr HeimdallStrategy imfing jalehman jarvis-medmatic kkarimi mahmoudashraf93 pkrmf RandyVentures Ryan Lisse - dougvk erikpr1994 fal3 Ghost jonasjancarik Keith the Silly Goose L36 Server Marc mitschabaude-bot mkbehr - neist sibbl chrisrodz Friederike Seiler gabriel-trigo iamadig Jonathan D. Rhyne (DJ-D) Joshua Mitchell Kit koala73 - manmal ogulcancelik pasogott petradonka rubyrunsstuff siddhantjain suminhthanh svkozak VACInc wes-davis - zats 24601 ameno- Chris Taylor dguido Django Navarro evalexpr henrino3 humanwritten larlyssa - odysseus0 oswalpalash pcty-nextgen-service-account rmorse Syhids Aaron Konyer aaronveklabs andreabadesso Andrii cash-echo-bot - Clawd ClawdFx EnzeD erik-agens Evizero fcatuhe itsjaydesu ivancasco ivanrvpereira jayhickey - jeffersonwarrior jeffersonwarrior jverdi longmaba mickahouan mjrussell odnxe p6l-richard philipp-spiess Pocket Clawd - robaxelsen Sash Catanzarite T5-AndyML travisp VAC william arzt zknicker 0oAstro abhaymundhara alejandro maza - Alex-Alaniz alexstyl andrewting19 anpoirier arthyn Asleep123 bolismauro chenyuan99 Clawdbot Maintainers conhecendoia - dasilva333 David-Marsh-Photo Developer Dimitrios Ploutarchos Drake Thomsen Felix Krause foeken ganghyun kim grrowl gtsifrikas - HazAT hrdwdmrbl hugobarauna Jamie Openshaw Jane Jarvis Jefferson Nunn jogi47 kentaro Kevin Lin - kitze Kiwitwitter levifig Lloyd longjos loukotal louzhixian martinpucik Matt mini mertcicekci0 - Miles mrdbstn MSch Mustafa Tag Eldeen ndraiman nexty5870 Noctivoro ppamment prathamdby ptn1411 - reeltimeapps RLTCmpe Rolf Fredheim Rony Kelner Samrat Jha senoldogann Seredeep sergical shiv19 shiyuanhai - siraht snopoke Suksham-sharma techboss testingabc321 The Admiral thesash Ubuntu voidserf Vultr-Clawd Admin - Wimmie wolfred wstock yazinsai YiWang24 ymat19 Zach Knickerbocker 0xJonHoldsCrypto aaronn Alphonse-arianee - atalovesyou Azade carlulsoe ddyo Erik latitudeki5223 Manuel Maly Mourad Boustani odrobnik pcty-nextgen-ios-builder - Quentin Randy Torres rhjoh ronak-guliani William Stock + rahthakor vrknetha radek-paclt vignesh07 Tobias Bischoff joshp123 czekaj mukhtharcm sebslight maxsumrall + xadenryan rodrigouroz juanpablodlc hsrvc magimetal zerone0x tyler6204 meaningfool patelhiren NicholasSpisak + jonisjongithub abhisekbasu1 jamesgroat claude JustYannicc Mariano Belinky Hyaxia dantelex SocialNerd42069 daveonkels + google-labs-jules[bot] lc0rp mousberg adam91holt hougangdev shakkernerd gumadeiras mteam88 hirefrank joeynyc + orlyjamie dbhurley Eng. Juan Combetto TSavo julianengel bradleypriest benithors rohannagpal timolins f-trycua + benostein elliotsecops nachx639 pvoo sreekaransrinath gupsammy cristip73 stefangalescu nachoiacovino Vasanth Rao Naik Sabavat + petter-b thewilloftheshadow cpojer scald andranik-sahakyan davidguttman sleontenko denysvitali sircrumpet peschee + nonggialiang rafaelreis-r dominicnunez lploc94 ratulsarna lutr0 danielz1z AdeboyeDN Alg0rix papago2355 + emanuelst KristijanJovanovski rdev rhuanssauro joshrad-dev kiranjd osolmaz adityashaw2 CashWilliams sheeek + ryancontent artuskg Takhoffman onutc pauloportella neooriginal manuelhettich minghinmatthewlam myfunc travisirby + obviyus buddyh connorshea kyleok mcinteerj dependabot[bot] John-Rood timkrase uos-status gerardward2007 + roshanasingh4 tosh-hamburg azade-c dlauer JonUleis shivamraut101 bjesuiter cheeeee robbyczgw-cla Josh Phillips + YuriNachos pookNast Whoaa512 chriseidhof ngutman ysqander aj47 kennyklee superman32432432 Yurii Chukhlib + grp06 antons austinm911 blacksmith-sh[bot] damoahdominic dan-dr HeimdallStrategy imfing jalehman jarvis-medmatic + kkarimi mahmoudashraf93 pkrmf RandyVentures Ryan Lisse dougvk erikpr1994 fal3 Ghost jonasjancarik + Keith the Silly Goose L36 Server Marc mitschabaude-bot mkbehr neist sibbl chrisrodz Friederike Seiler gabriel-trigo + iamadig Jonathan D. Rhyne (DJ-D) Joshua Mitchell Kit koala73 manmal ogulcancelik pasogott petradonka rubyrunsstuff + siddhantjain suminhthanh svkozak VACInc wes-davis zats 24601 ameno- Chris Taylor dguido + Django Navarro evalexpr henrino3 humanwritten larlyssa Lukavyi odysseus0 oswalpalash pcty-nextgen-service-account pi0 + rmorse Roopak Nijhara Syhids Aaron Konyer aaronveklabs andreabadesso Andrii cash-echo-bot Clawd ClawdFx + EnzeD erik-agens Evizero fcatuhe itsjaydesu ivancasco ivanrvpereira Jarvis jayhickey jeffersonwarrior + jeffersonwarrior jverdi longmaba MarvinCui mickahouan mjrussell odnxe p6l-richard philipp-spiess Pocket Clawd + robaxelsen Sash Catanzarite Suksham-sharma T5-AndyML tewatia travisp VAC william arzt zknicker 0oAstro + abhaymundhara aduk059 alejandro maza Alex-Alaniz alexstyl andrewting19 anpoirier araa47 arthyn Asleep123 + bguidolim bolismauro chenyuan99 Clawdbot Maintainers conhecendoia dasilva333 David-Marsh-Photo Developer Dimitrios Ploutarchos Drake Thomsen + dylanneve1 Felix Krause foeken frankekn ganghyun kim grrowl gtsifrikas HazAT hrdwdmrbl hugobarauna + Jamie Openshaw Jane Jarvis Deploy Jefferson Nunn jogi47 kentaro Kevin Lin kira-ariaki kitze Kiwitwitter + levifig Lloyd longjos loukotal louzhixian martinpucik Matt mini mertcicekci0 Miles mrdbstn + MSch Mustafa Tag Eldeen ndraiman nexty5870 Noctivoro ppamment prathamdby ptn1411 reeltimeapps RLTCmpe + Rolf Fredheim Rony Kelner Samrat Jha senoldogann sergical shiv19 shiyuanhai siraht snopoke techboss + testingabc321 The Admiral thesash Ubuntu voidserf Vultr-Clawd Admin Wimmie wolfred wstock YangHuang2280 + yazinsai YiWang24 ymat19 Zach Knickerbocker zackerthescar 0xJonHoldsCrypto aaronn Alphonse-arianee atalovesyou Azade + carlulsoe ddyo Erik latitudeki5223 Manuel Maly Mourad Boustani odrobnik pcty-nextgen-ios-builder Quentin Randy Torres + rhjoh ronak-guliani William Stock

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 }; }