Files
ba621391c2 feat(telegram): trigger-words, channel posts, message edit & topic posting (#1383)
- Fix image_generation nil-pointer crash on codex agents: the sentinel now
  carries a name-only Function so the many sites reading td.Function.Name never
  nil-deref; codex_build still branches on Type.
- Agent-declared trigger words in IDENTITY.md wake the bot in groups without an
  @mention (whole-word, Cyrillic-aware; text + caption), cached per-agent 60s.
- channel_post support with a synthetic sender + a recover() guard so a
  malformed update can't crash the gateway.
- message tool action=edit (editMessageText + editMessageCaption fallback),
  targeting the replied-to message via reply_to_message_id.
- message tool topic=<name> posts into a named forum topic; topics learned from
  forum_topic_created into channel_contacts and resolved by name.

Co-authored-by: skensel <skensel@MacBook-Pro-skensel.local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 12:28:18 +07:00

178 lines
5.9 KiB
Go

package telegram
import (
"fmt"
"regexp"
"strings"
"github.com/mymmrac/telego"
)
// buildSelfIdentityPrompt returns a short system-prompt snippet telling the LLM
// which Telegram handle represents itself, so it does not confuse its own
// @mention for a different bot — especially useful in multi-bot groups where
// other bots' mentions remain in the content after stripBotMention.
// Returns empty string when the bot username has not been resolved yet.
func buildSelfIdentityPrompt(botUsername, displayName string) string {
if botUsername == "" {
return ""
}
if displayName != "" {
return fmt.Sprintf("You are @%s (%s) on this Telegram channel.", botUsername, displayName)
}
return fmt.Sprintf("You are @%s on this Telegram channel.", botUsername)
}
// 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"))
}
// resolveMessageSender returns the effective sender for a message and whether it
// is a channel post. Channel posts are authored by the channel and carry no From,
// so a synthetic sender is derived from the channel itself, letting channel posts
// flow through the same group-style gate and routing as group messages.
func resolveMessageSender(message *telego.Message) (*telego.User, bool) {
isChannel := message.Chat.Type == "channel"
user := message.From
if user == nil && isChannel {
user = &telego.User{
ID: message.Chat.ID,
FirstName: message.Chat.Title,
Username: message.Chat.Username,
}
}
return user, isChannel
}
// 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 {
if botUsername == "" {
return false
}
lowerBot := strings.ToLower(botUsername)
// Check both text entities and caption entities (photos use Caption, not Text).
for _, pair := range []struct {
entities []telego.MessageEntity
text string
}{
{msg.Entities, msg.Text},
{msg.CaptionEntities, msg.Caption},
} {
if pair.text == "" {
continue
}
for _, entity := range pair.entities {
if entity.Type == "mention" {
mentioned := pair.text[entity.Offset : entity.Offset+entity.Length]
if strings.EqualFold(mentioned, "@"+botUsername) {
return true
}
}
if entity.Type == "bot_command" {
cmdText := pair.text[entity.Offset : entity.Offset+entity.Length]
if strings.Contains(strings.ToLower(cmdText), "@"+lowerBot) {
return true
}
}
}
}
// Fallback: substring check in both text and caption
if msg.Text != "" && strings.Contains(strings.ToLower(msg.Text), "@"+lowerBot) {
return true
}
if msg.Caption != "" && strings.Contains(strings.ToLower(msg.Caption), "@"+lowerBot) {
return true
}
// Reply to bot's message = implicit mention
if msg.ReplyToMessage != nil && msg.ReplyToMessage.From != nil {
if msg.ReplyToMessage.From.Username == botUsername {
return true
}
}
return false
}
// hasOtherMention checks if the message mentions any user/bot other than ours.
// Used by "yield" mention mode: if another entity is explicitly @mentioned, this bot yields.
// Checks both "mention" entities (@username) and "bot_command" entities (/cmd@bot).
func (c *Channel) hasOtherMention(msg *telego.Message, myUsername string) bool {
lowerMy := strings.ToLower(myUsername)
for _, pair := range []struct {
entities []telego.MessageEntity
text string
}{
{msg.Entities, msg.Text},
{msg.CaptionEntities, msg.Caption},
} {
if pair.text == "" {
continue
}
for _, entity := range pair.entities {
if entity.Type == "mention" {
mentioned := pair.text[entity.Offset : entity.Offset+entity.Length]
mentionedLower := strings.ToLower(mentioned)
// Skip our own mention
if mentionedLower == "@"+lowerMy {
continue
}
// Any other @mention in the message → another bot/user was called
return true
}
if entity.Type == "bot_command" {
cmdText := pair.text[entity.Offset : entity.Offset+entity.Length]
cmdLower := strings.ToLower(cmdText)
// Commands addressed to another bot: /cmd@other_bot
if atIdx := strings.Index(cmdLower, "@"); atIdx > 0 {
target := cmdLower[atIdx+1:]
if target != lowerMy {
return true
}
}
}
}
}
return false
}
// isServiceMessage returns true if the Telegram message is a service/system message
// (member added/removed, title changed, pinned, etc.) rather than a user-sent message.
// Service messages have no text, caption, or media content.
func isServiceMessage(msg *telego.Message) bool {
// Has text or caption → user message
if msg.Text != "" || msg.Caption != "" {
return false
}
// Has media → user message (photo, audio, video, document, sticker, etc.)
if msg.Photo != nil || msg.Audio != nil || msg.Video != nil ||
msg.Document != nil || msg.Voice != nil || msg.VideoNote != nil ||
msg.Sticker != nil || msg.Animation != nil || msg.Contact != nil ||
msg.Location != nil || msg.Venue != nil || msg.Poll != nil {
return false
}
// No user content — likely a service message (new_chat_members, left_chat_member,
// new_chat_title, new_chat_photo, pinned_message, etc.)
return true
}