diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4c1a74637..f3998d892 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -73,11 +73,13 @@ Status: beta.
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
### Fixes
+- 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.
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/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/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-dispatch.ts b/src/telegram/bot-message-dispatch.ts
index cead0628a..c5ade7466 100644
--- a/src/telegram/bot-message-dispatch.ts
+++ b/src/telegram/bot-message-dispatch.ts
@@ -228,6 +228,7 @@ export const dispatchTelegramMessage = async ({
onVoiceRecording: sendRecordVoice,
linkPreview: telegramCfg.linkPreview,
replyQuoteText,
+ notifyEmptyResponse: info.kind === "final",
});
},
onError: (err, info) => {
diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts
index 3415ea927..5f2bbf1e6 100644
--- a/src/telegram/bot-native-commands.ts
+++ b/src/telegram/bot-native-commands.ts
@@ -468,6 +468,7 @@ export const registerTelegramNativeCommands = ({
CommandAuthorized: commandAuthorized,
CommandSource: "native" as const,
SessionKey: `telegram:slash:${senderId || chatId}`,
+ AccountId: route.accountId,
CommandTargetSessionKey: sessionKey,
MessageThreadId: threadIdForSend,
IsForum: isForum,
@@ -487,7 +488,7 @@ export const registerTelegramNativeCommands = ({
cfg,
dispatcherOptions: {
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
- deliver: async (payload) => {
+ deliver: async (payload, info) => {
await deliverReplies({
replies: [payload],
chatId: String(chatId),
@@ -500,6 +501,7 @@ export const registerTelegramNativeCommands = ({
tableMode,
chunkMode,
linkPreview: telegramCfg.linkPreview,
+ notifyEmptyResponse: info.kind === "final",
});
},
onError: (err, info) => {
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 8c1e74b73..648ddeeab 100644
--- a/src/telegram/bot/delivery.ts
+++ b/src/telegram/bot/delivery.ts
@@ -44,7 +44,9 @@ 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,
chatId,
@@ -58,6 +60,7 @@ export async function deliverReplies(params: {
} = params;
const chunkMode = params.chunkMode ?? "length";
let hasReplied = false;
+ let skippedEmpty = 0;
const chunkText = (markdown: string) => {
const markdownChunks =
chunkMode === "newline"
@@ -85,6 +88,7 @@ export async function deliverReplies(params: {
continue;
}
runtime.error?.(danger("reply missing text/media"));
+ skippedEmpty++;
continue;
}
const replyToId = replyToMode === "off" ? undefined : resolveTelegramReplyId(reply.replyToId);
@@ -268,6 +272,18 @@ 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 };
}
export async function resolveMedia(