From e392fca14de3a25013cc062c44481b2f75359d2d Mon Sep 17 00:00:00 2001 From: viettranx Date: Mon, 20 Apr 2026 21:35:41 +0700 Subject: [PATCH] fix(telegram): strip own @mention from inbound content Agents previously saw their own Telegram handle (e.g. "@viet_super_bot") in user messages and mistook it for a different bot, replying NO_REPLY. The username was only used for the mention gate, never removed from the content passed to the LLM. Slack and Feishu already strip their own bot mentions (handlers_mention.go stripBotMention, bot_parse.go resolveMentions); Telegram was the odd one out. Implementation: - Add stripBotMention helper with leading/trailing word-boundary anchors so inline matches inside words (e.g. contact@viet_super_bot.com) are not falsely stripped. - Apply in handleMessage right after the mention gate, before pairing/media processing, so history recording for unmentioned messages keeps raw text. - Restore "[empty message]" placeholder when a message consisting only of "@botname" becomes empty after stripping. --- internal/channels/telegram/handlers.go | 9 +++ internal/channels/telegram/handlers_utils.go | 19 ++++++ .../channels/telegram/handlers_utils_test.go | 64 +++++++++++++++++++ 3 files changed, 92 insertions(+) diff --git a/internal/channels/telegram/handlers.go b/internal/channels/telegram/handlers.go index 01586cec..9d268cfe 100644 --- a/internal/channels/telegram/handlers.go +++ b/internal/channels/telegram/handlers.go @@ -361,6 +361,15 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) { } } + // Strip bot's own @mention so the LLM sees clean content and does not + // mistake itself for another bot (cross-channel parity with Slack/Feishu). + // Re-check empty state: a message containing only "@botname" becomes empty + // after stripping, so we restore the placeholder used for originally-empty inbounds. + content = stripBotMention(content, c.bot.Username()) + if content == "" { + content = "[empty message]" + } + // --- Group pairing gate (only reached when bot is mentioned) --- if isGroup && topicCfg.groupPolicy == "pairing" && c.PairingService() != nil { if !c.IsGroupApproved(chatIDStr) { diff --git a/internal/channels/telegram/handlers_utils.go b/internal/channels/telegram/handlers_utils.go index bb30507c..020cdec3 100644 --- a/internal/channels/telegram/handlers_utils.go +++ b/internal/channels/telegram/handlers_utils.go @@ -1,11 +1,30 @@ package telegram import ( + "regexp" "strings" "github.com/mymmrac/telego" ) +// stripBotMention removes @botUsername tokens from text (case-insensitive). +// Applied after the mention gate passes so the LLM does not see its own Telegram handle +// and mistake itself for another bot (e.g. persona "Tiểu Hổ" receiving "@viet_super_bot vẽ..."). +// +// Boundary rules match valid Telegram mentions: +// - Leading: start-of-string OR a non-word char (whitespace/punct). Prevents false strips +// inside words like "contact@viet_super_bot.com". +// - Trailing: \b (word-boundary). Prevents matching "@bot" inside "@bot_2". +// +// The leading non-word char is preserved via capture group $1. +func stripBotMention(text, botUsername string) string { + if botUsername == "" || text == "" { + return text + } + pattern := `(?i)(^|[^\w])@` + regexp.QuoteMeta(botUsername) + `\b` + return strings.TrimSpace(regexp.MustCompile(pattern).ReplaceAllString(text, "$1")) +} + // detectMention checks if a Telegram message mentions the bot. // Checks both msg.Text/Entities (text messages) and msg.Caption/CaptionEntities (photo/media messages). func (c *Channel) detectMention(msg *telego.Message, botUsername string) bool { diff --git a/internal/channels/telegram/handlers_utils_test.go b/internal/channels/telegram/handlers_utils_test.go index 3f100a1f..c0c71139 100644 --- a/internal/channels/telegram/handlers_utils_test.go +++ b/internal/channels/telegram/handlers_utils_test.go @@ -208,6 +208,70 @@ func TestHasOtherMention_CaptionWithOtherMention(t *testing.T) { } } +// --- stripBotMention --- + +func TestStripBotMention_RemovesMention(t *testing.T) { + got := stripBotMention("@viet_super_bot vẽ ảnh minh họa", "viet_super_bot") + want := "vẽ ảnh minh họa" + if got != want { + t.Errorf("stripBotMention = %q, want %q", got, want) + } +} + +func TestStripBotMention_CaseInsensitive(t *testing.T) { + got := stripBotMention("@Viet_Super_Bot hello", "viet_super_bot") + if got != "hello" { + t.Errorf("stripBotMention case-insensitive = %q, want %q", got, "hello") + } +} + +func TestStripBotMention_PreservesOtherMentions(t *testing.T) { + got := stripBotMention("@viet_super_bot hỏi @alice về X", "viet_super_bot") + want := "hỏi @alice về X" + if got != want { + t.Errorf("stripBotMention = %q, want %q", got, want) + } +} + +func TestStripBotMention_WordBoundary(t *testing.T) { + // @viet_super_bot2 must NOT match @viet_super_bot (different bot with similar prefix). + got := stripBotMention("@viet_super_bot2 hello", "viet_super_bot") + if got != "@viet_super_bot2 hello" { + t.Errorf("stripBotMention should not match prefix; got %q", got) + } +} + +func TestStripBotMention_EmptyUsername(t *testing.T) { + text := "@anything else" + if got := stripBotMention(text, ""); got != text { + t.Errorf("stripBotMention with empty botUsername should return text unchanged; got %q", got) + } +} + +func TestStripBotMention_MultipleOccurrences(t *testing.T) { + got := stripBotMention("hey @viet_super_bot, @viet_super_bot help!", "viet_super_bot") + // Both removed; internal spacing/punctuation preserved. + want := "hey , help!" + if got != want { + t.Errorf("stripBotMention multi = %q, want %q", got, want) + } +} + +func TestStripBotMention_PreservesEmailLike(t *testing.T) { + // "@viet_super_bot" embedded inside a word (e.g. email/URL) must NOT be stripped. + // Telegram mentions require a leading word-boundary, so inline matches are false positives. + in := "contact@viet_super_bot.com please" + if got := stripBotMention(in, "viet_super_bot"); got != in { + t.Errorf("stripBotMention should not strip mention embedded in word; got %q, want %q", got, in) + } +} + +func TestStripBotMention_OnlyMentionBecomesEmpty(t *testing.T) { + if got := stripBotMention("@viet_super_bot", "viet_super_bot"); got != "" { + t.Errorf("mention-only input should become empty; got %q", got) + } +} + // --- isServiceMessage --- func TestIsServiceMessage_WithText(t *testing.T) {