mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 18:18:37 +00:00
- 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>
178 lines
5.9 KiB
Go
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
|
|
}
|