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

183 lines
5.0 KiB
Go

package telegram
import (
"fmt"
"strings"
"time"
"github.com/mymmrac/telego"
)
// MessageContext holds enriched context extracted from a Telegram message.
// Ref: TS src/telegram/bot-message-context.ts → buildTelegramMessageContext()
type MessageContext struct {
ForwardInfo *ForwardInfo
ReplyInfo *ReplyInfo
LocationInfo *LocationInfo
}
// ForwardInfo contains metadata about a forwarded message.
// Ref: TS normalizeForwardedContext()
type ForwardInfo struct {
From string // sender name or channel title
FromType string // "user", "channel", "supergroup", "hidden"
Date time.Time // original message date
}
// ReplyInfo contains metadata about the message being replied to.
// Ref: TS describeReplyTarget()
type ReplyInfo struct {
Sender string // sender name
Body string // quoted message text
IsBotReply bool // true if replying to bot's own message
MessageID int // id of the replied-to message (edit target)
}
// LocationInfo contains geographic coordinates.
// Ref: TS extractTelegramLocation()
type LocationInfo struct {
Latitude float64
Longitude float64
}
// buildMessageContext extracts forward, reply, and location context from a Telegram message.
func buildMessageContext(msg *telego.Message, botUsername string) *MessageContext {
ctx := &MessageContext{}
ctx.ForwardInfo = extractForwardInfo(msg)
ctx.ReplyInfo = extractReplyInfo(msg, botUsername)
ctx.LocationInfo = extractLocationInfo(msg)
return ctx
}
// enrichContentWithContext appends forward/reply/location context to the message content.
func enrichContentWithContext(content string, msgCtx *MessageContext) string {
if msgCtx == nil {
return content
}
var result strings.Builder
// Prepend forward context
if msgCtx.ForwardInfo != nil {
dateStr := msgCtx.ForwardInfo.Date.Format("2006-01-02 15:04")
result.WriteString(fmt.Sprintf("[Forwarded from %s at %s]\n", msgCtx.ForwardInfo.From, dateStr))
}
result.WriteString(content)
// Append reply context. Include the replied-to message id so the agent can
// target it with message(action=edit) — e.g. flip a status marker in place.
if msgCtx.ReplyInfo != nil && msgCtx.ReplyInfo.Body != "" {
result.WriteString(fmt.Sprintf("\n\n[Replying to %s | reply_to_message_id=%d]\n%s\n[/Replying]",
msgCtx.ReplyInfo.Sender, msgCtx.ReplyInfo.MessageID, msgCtx.ReplyInfo.Body))
}
// Append location
if msgCtx.LocationInfo != nil {
result.WriteString(fmt.Sprintf("\n\nCoordinates: %.6f, %.6f",
msgCtx.LocationInfo.Latitude, msgCtx.LocationInfo.Longitude))
}
return result.String()
}
// extractForwardInfo extracts forwarded message metadata.
// Ref: TS normalizeForwardedContext()
func extractForwardInfo(msg *telego.Message) *ForwardInfo {
if msg.ForwardOrigin == nil {
return nil
}
info := &ForwardInfo{}
switch origin := msg.ForwardOrigin.(type) {
case *telego.MessageOriginUser:
user := origin.SenderUser
info.From = buildUserName(&user)
info.FromType = "user"
info.Date = time.Unix(origin.Date, 0)
case *telego.MessageOriginChat:
info.From = origin.SenderChat.Title
info.FromType = string(origin.SenderChat.Type)
info.Date = time.Unix(origin.Date, 0)
case *telego.MessageOriginChannel:
info.From = origin.Chat.Title
info.FromType = "channel"
info.Date = time.Unix(origin.Date, 0)
case *telego.MessageOriginHiddenUser:
info.From = origin.SenderUserName
info.FromType = "hidden"
info.Date = time.Unix(origin.Date, 0)
default:
return nil
}
return info
}
// extractReplyInfo extracts replied-to message metadata.
// Ref: TS describeReplyTarget()
func extractReplyInfo(msg *telego.Message, botUsername string) *ReplyInfo {
reply := msg.ReplyToMessage
if reply == nil {
return nil
}
info := &ReplyInfo{MessageID: reply.MessageID}
// Determine sender name
if reply.From != nil {
info.Sender = buildUserName(reply.From)
info.IsBotReply = (reply.From.Username == botUsername)
} else {
info.Sender = "unknown"
}
// Extract reply body
if reply.Text != "" {
info.Body = reply.Text
} else if reply.Caption != "" {
info.Body = reply.Caption
}
// Truncate long reply bodies
if len(info.Body) > 500 {
info.Body = info.Body[:500] + "..."
}
// Hint for bot replies: the full response is already in session history,
// so the LLM doesn't need the full text here — just enough to identify it.
if info.IsBotReply && info.Body != "" {
info.Body += "\n(This is your previous response — full content is in session history above)"
}
return info
}
// extractLocationInfo extracts location data from a message.
// Ref: TS extractTelegramLocation()
func extractLocationInfo(msg *telego.Message) *LocationInfo {
if msg.Location == nil {
return nil
}
return &LocationInfo{
Latitude: msg.Location.Latitude,
Longitude: msg.Location.Longitude,
}
}
// buildUserName formats a Telegram user's display name.
func buildUserName(user *telego.User) string {
if user == nil {
return "unknown"
}
name := user.FirstName
if user.LastName != "" {
name += " " + user.LastName
}
return name
}