package telegram import ( "context" "fmt" "net/http" "net/url" "os" "regexp" "strconv" "strings" "time" "github.com/mymmrac/telego" th "github.com/mymmrac/telego/telegohandler" tu "github.com/mymmrac/telego/telegoutil" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/commands" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" ) var ( reHeading = regexp.MustCompile(`^#{1,6}\s+(.+)$`) reBlockquote = regexp.MustCompile(`^>\s*(.*)$`) reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`) reBoldStar = regexp.MustCompile(`\*\*(.+?)\*\*`) reBoldUnder = regexp.MustCompile(`__(.+?)__`) reItalic = regexp.MustCompile(`_([^_]+)_`) reStrike = regexp.MustCompile(`~~(.+?)~~`) reListItem = regexp.MustCompile(`^[-*]\s+`) reCodeBlock = regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```") reInlineCode = regexp.MustCompile("`([^`]+)`") ) type TelegramChannel struct { *channels.BaseChannel bot *telego.Bot bh *th.BotHandler config *config.Config chatIDs map[string]int64 ctx context.Context cancel context.CancelFunc registerFunc func(context.Context, []commands.Definition) error commandRegCancel context.CancelFunc } func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) { var opts []telego.BotOption telegramCfg := cfg.Channels.Telegram if telegramCfg.Proxy != "" { proxyURL, parseErr := url.Parse(telegramCfg.Proxy) if parseErr != nil { return nil, fmt.Errorf("invalid proxy URL %q: %w", telegramCfg.Proxy, parseErr) } opts = append(opts, telego.WithHTTPClient(&http.Client{ Transport: &http.Transport{ Proxy: http.ProxyURL(proxyURL), }, })) } else if os.Getenv("HTTP_PROXY") != "" || os.Getenv("HTTPS_PROXY") != "" { // Use environment proxy if configured opts = append(opts, telego.WithHTTPClient(&http.Client{ Transport: &http.Transport{ Proxy: http.ProxyFromEnvironment, }, })) } if baseURL := strings.TrimRight(strings.TrimSpace(telegramCfg.BaseURL), "/"); baseURL != "" { opts = append(opts, telego.WithAPIServer(baseURL)) } bot, err := telego.NewBot(telegramCfg.Token, opts...) if err != nil { return nil, fmt.Errorf("failed to create telegram bot: %w", err) } base := channels.NewBaseChannel( "telegram", telegramCfg, bus, telegramCfg.AllowFrom, channels.WithMaxMessageLength(4096), channels.WithGroupTrigger(telegramCfg.GroupTrigger), channels.WithReasoningChannelID(telegramCfg.ReasoningChannelID), ) return &TelegramChannel{ BaseChannel: base, bot: bot, config: cfg, chatIDs: make(map[string]int64), }, nil } func (c *TelegramChannel) Start(ctx context.Context) error { logger.InfoC("telegram", "Starting Telegram bot (polling mode)...") c.ctx, c.cancel = context.WithCancel(ctx) updates, err := c.bot.UpdatesViaLongPolling(c.ctx, &telego.GetUpdatesParams{ Timeout: 30, }) if err != nil { c.cancel() return fmt.Errorf("failed to start long polling: %w", err) } bh, err := th.NewBotHandler(c.bot, updates) if err != nil { c.cancel() return fmt.Errorf("failed to create bot handler: %w", err) } c.bh = bh bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { return c.handleMessage(ctx, &message) }, th.AnyMessage()) c.SetRunning(true) logger.InfoCF("telegram", "Telegram bot connected", map[string]any{ "username": c.bot.Username(), }) c.startCommandRegistration(c.ctx, commands.BuiltinDefinitions()) go func() { if err = bh.Start(); err != nil { logger.ErrorCF("telegram", "Bot handler failed", map[string]any{ "error": err.Error(), }) } }() return nil } func (c *TelegramChannel) Stop(ctx context.Context) error { logger.InfoC("telegram", "Stopping Telegram bot...") c.SetRunning(false) // Stop the bot handler if c.bh != nil { _ = c.bh.StopWithContext(ctx) } // Cancel our context (stops long polling) if c.cancel != nil { c.cancel() } if c.commandRegCancel != nil { c.commandRegCancel() } return nil } func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } chatID, err := parseChatID(msg.ChatID) if err != nil { return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) } htmlContent := markdownToTelegramHTML(msg.Content) // Typing/placeholder handled by Manager.preSend — just send the message tgMsg := tu.Message(tu.ID(chatID), htmlContent) tgMsg.ParseMode = telego.ModeHTML if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil { logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{ "error": err.Error(), }) tgMsg.ParseMode = "" if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil { return fmt.Errorf("telegram send: %w", channels.ErrTemporary) } } return nil } // StartTyping implements channels.TypingCapable. // It sends ChatAction(typing) immediately and then repeats every 4 seconds // (Telegram's typing indicator expires after ~5s) in a background goroutine. // The returned stop function is idempotent and cancels the goroutine. func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { cid, err := parseChatID(chatID) if err != nil { return func() {}, err } // Send the first typing action immediately _ = c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)) typingCtx, cancel := context.WithCancel(ctx) go func() { ticker := time.NewTicker(4 * time.Second) defer ticker.Stop() for { select { case <-typingCtx.Done(): return case <-ticker.C: _ = c.bot.SendChatAction(typingCtx, tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)) } } }() return cancel, nil } // EditMessage implements channels.MessageEditor. func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { cid, err := parseChatID(chatID) if err != nil { return err } mid, err := strconv.Atoi(messageID) if err != nil { return err } htmlContent := markdownToTelegramHTML(content) editMsg := tu.EditMessageText(tu.ID(cid), mid, htmlContent) editMsg.ParseMode = telego.ModeHTML _, err = c.bot.EditMessageText(ctx, editMsg) return err } // SendPlaceholder implements channels.PlaceholderCapable. // It sends a placeholder message (e.g. "Thinking... 💭") that will later be // edited to the actual response via EditMessage (channels.MessageEditor). func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { phCfg := c.config.Channels.Telegram.Placeholder if !phCfg.Enabled { return "", nil } text := phCfg.Text if text == "" { text = "Thinking... 💭" } cid, err := parseChatID(chatID) if err != nil { return "", err } pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(cid), text)) if err != nil { return "", err } return fmt.Sprintf("%d", pMsg.MessageID), nil } // SendMedia implements the channels.MediaSender interface. func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } chatID, err := parseChatID(msg.ChatID) if err != nil { return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) } store := c.GetMediaStore() if store == nil { return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) } for _, part := range msg.Parts { localPath, err := store.Resolve(part.Ref) if err != nil { logger.ErrorCF("telegram", "Failed to resolve media ref", map[string]any{ "ref": part.Ref, "error": err.Error(), }) continue } file, err := os.Open(localPath) if err != nil { logger.ErrorCF("telegram", "Failed to open media file", map[string]any{ "path": localPath, "error": err.Error(), }) continue } switch part.Type { case "image": params := &telego.SendPhotoParams{ ChatID: tu.ID(chatID), Photo: telego.InputFile{File: file}, Caption: part.Caption, } _, err = c.bot.SendPhoto(ctx, params) case "audio": params := &telego.SendAudioParams{ ChatID: tu.ID(chatID), Audio: telego.InputFile{File: file}, Caption: part.Caption, } _, err = c.bot.SendAudio(ctx, params) case "video": params := &telego.SendVideoParams{ ChatID: tu.ID(chatID), Video: telego.InputFile{File: file}, Caption: part.Caption, } _, err = c.bot.SendVideo(ctx, params) default: // "file" or unknown types params := &telego.SendDocumentParams{ ChatID: tu.ID(chatID), Document: telego.InputFile{File: file}, Caption: part.Caption, } _, err = c.bot.SendDocument(ctx, params) } file.Close() if err != nil { logger.ErrorCF("telegram", "Failed to send media", map[string]any{ "type": part.Type, "error": err.Error(), }) return fmt.Errorf("telegram send media: %w", channels.ErrTemporary) } } return nil } func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error { if message == nil { return fmt.Errorf("message is nil") } user := message.From if user == nil { return fmt.Errorf("message sender (user) is nil") } platformID := fmt.Sprintf("%d", user.ID) sender := bus.SenderInfo{ Platform: "telegram", PlatformID: platformID, CanonicalID: identity.BuildCanonicalID("telegram", platformID), Username: user.Username, DisplayName: user.FirstName, } // check allowlist to avoid downloading attachments for rejected users if !c.IsAllowedSender(sender) { logger.DebugCF("telegram", "Message rejected by allowlist", map[string]any{ "user_id": platformID, }) return nil } chatID := message.Chat.ID c.chatIDs[platformID] = chatID content := "" mediaPaths := []string{} chatIDStr := fmt.Sprintf("%d", chatID) messageIDStr := fmt.Sprintf("%d", message.MessageID) scope := channels.BuildMediaScope("telegram", chatIDStr, messageIDStr) // Helper to register a local file with the media store storeMedia := func(localPath, filename string) string { if store := c.GetMediaStore(); store != nil { ref, err := store.Store(localPath, media.MediaMeta{ Filename: filename, Source: "telegram", }, scope) if err == nil { return ref } } return localPath // fallback: use raw path } if message.Text != "" { content += message.Text } if message.Caption != "" { if content != "" { content += "\n" } content += message.Caption } if len(message.Photo) > 0 { photo := message.Photo[len(message.Photo)-1] photoPath := c.downloadPhoto(ctx, photo.FileID) if photoPath != "" { mediaPaths = append(mediaPaths, storeMedia(photoPath, "photo.jpg")) if content != "" { content += "\n" } content += "[image: photo]" } } if message.Voice != nil { voicePath := c.downloadFile(ctx, message.Voice.FileID, ".ogg") if voicePath != "" { mediaPaths = append(mediaPaths, storeMedia(voicePath, "voice.ogg")) if content != "" { content += "\n" } content += "[voice]" } } if message.Audio != nil { audioPath := c.downloadFile(ctx, message.Audio.FileID, ".mp3") if audioPath != "" { mediaPaths = append(mediaPaths, storeMedia(audioPath, "audio.mp3")) if content != "" { content += "\n" } content += "[audio]" } } if message.Document != nil { docPath := c.downloadFile(ctx, message.Document.FileID, "") if docPath != "" { mediaPaths = append(mediaPaths, storeMedia(docPath, "document")) if content != "" { content += "\n" } content += "[file]" } } if content == "" { content = "[empty message]" } // In group chats, apply unified group trigger filtering if message.Chat.Type != "private" { isMentioned := c.isBotMentioned(message) if isMentioned { content = c.stripBotMention(content) } respond, cleaned := c.ShouldRespondInGroup(isMentioned, content) if !respond { return nil } content = cleaned } logger.DebugCF("telegram", "Received message", map[string]any{ "sender_id": sender.CanonicalID, "chat_id": fmt.Sprintf("%d", chatID), "preview": utils.Truncate(content, 50), }) // Placeholder is now auto-triggered by BaseChannel.HandleMessage via PlaceholderCapable peerKind := "direct" peerID := fmt.Sprintf("%d", user.ID) if message.Chat.Type != "private" { peerKind = "group" peerID = fmt.Sprintf("%d", chatID) } peer := bus.Peer{Kind: peerKind, ID: peerID} messageID := fmt.Sprintf("%d", message.MessageID) metadata := map[string]string{ "user_id": fmt.Sprintf("%d", user.ID), "username": user.Username, "first_name": user.FirstName, "is_group": fmt.Sprintf("%t", message.Chat.Type != "private"), } c.HandleMessage(c.ctx, peer, messageID, platformID, fmt.Sprintf("%d", chatID), content, mediaPaths, metadata, sender, ) return nil } func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string { file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID}) if err != nil { logger.ErrorCF("telegram", "Failed to get photo file", map[string]any{ "error": err.Error(), }) return "" } return c.downloadFileWithInfo(file, ".jpg") } func (c *TelegramChannel) downloadFileWithInfo(file *telego.File, ext string) string { if file.FilePath == "" { return "" } url := c.bot.FileDownloadURL(file.FilePath) logger.DebugCF("telegram", "File URL", map[string]any{"url": url}) // Use FilePath as filename for better identification filename := file.FilePath + ext return utils.DownloadFile(url, filename, utils.DownloadOptions{ LoggerPrefix: "telegram", }) } func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string) string { file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID}) if err != nil { logger.ErrorCF("telegram", "Failed to get file", map[string]any{ "error": err.Error(), }) return "" } return c.downloadFileWithInfo(file, ext) } func parseChatID(chatIDStr string) (int64, error) { var id int64 _, err := fmt.Sscanf(chatIDStr, "%d", &id) return id, err } func markdownToTelegramHTML(text string) string { if text == "" { return "" } codeBlocks := extractCodeBlocks(text) text = codeBlocks.text inlineCodes := extractInlineCodes(text) text = inlineCodes.text text = reHeading.ReplaceAllString(text, "$1") text = reBlockquote.ReplaceAllString(text, "$1") text = escapeHTML(text) text = reLink.ReplaceAllString(text, `$1`) text = reBoldStar.ReplaceAllString(text, "$1") text = reBoldUnder.ReplaceAllString(text, "$1") text = reItalic.ReplaceAllStringFunc(text, func(s string) string { match := reItalic.FindStringSubmatch(s) if len(match) < 2 { return s } return "" + match[1] + "" }) text = reStrike.ReplaceAllString(text, "$1") text = reListItem.ReplaceAllString(text, "• ") for i, code := range inlineCodes.codes { escaped := escapeHTML(code) text = strings.ReplaceAll(text, fmt.Sprintf("\x00IC%d\x00", i), fmt.Sprintf("%s", escaped)) } for i, code := range codeBlocks.codes { escaped := escapeHTML(code) text = strings.ReplaceAll( text, fmt.Sprintf("\x00CB%d\x00", i), fmt.Sprintf("
%s
", escaped), ) } return text } type codeBlockMatch struct { text string codes []string } func extractCodeBlocks(text string) codeBlockMatch { matches := reCodeBlock.FindAllStringSubmatch(text, -1) codes := make([]string, 0, len(matches)) for _, match := range matches { codes = append(codes, match[1]) } i := 0 text = reCodeBlock.ReplaceAllStringFunc(text, func(m string) string { placeholder := fmt.Sprintf("\x00CB%d\x00", i) i++ return placeholder }) return codeBlockMatch{text: text, codes: codes} } type inlineCodeMatch struct { text string codes []string } func extractInlineCodes(text string) inlineCodeMatch { matches := reInlineCode.FindAllStringSubmatch(text, -1) codes := make([]string, 0, len(matches)) for _, match := range matches { codes = append(codes, match[1]) } i := 0 text = reInlineCode.ReplaceAllStringFunc(text, func(m string) string { placeholder := fmt.Sprintf("\x00IC%d\x00", i) i++ return placeholder }) return inlineCodeMatch{text: text, codes: codes} } func escapeHTML(text string) string { text = strings.ReplaceAll(text, "&", "&") text = strings.ReplaceAll(text, "<", "<") text = strings.ReplaceAll(text, ">", ">") return text } // isBotMentioned checks if the bot is mentioned in the message via entities. func (c *TelegramChannel) isBotMentioned(message *telego.Message) bool { text, entities := telegramEntityTextAndList(message) if text == "" || len(entities) == 0 { return false } botUsername := "" if c.bot != nil { botUsername = c.bot.Username() } runes := []rune(text) for _, entity := range entities { entityText, ok := telegramEntityText(runes, entity) if !ok { continue } switch entity.Type { case telego.EntityTypeMention: if botUsername != "" && strings.EqualFold(entityText, "@"+botUsername) { return true } case telego.EntityTypeTextMention: if botUsername != "" && entity.User != nil && strings.EqualFold(entity.User.Username, botUsername) { return true } case telego.EntityTypeBotCommand: if isBotCommandEntityForThisBot(entityText, botUsername) { return true } } } return false } func telegramEntityTextAndList(message *telego.Message) (string, []telego.MessageEntity) { if message.Text != "" { return message.Text, message.Entities } return message.Caption, message.CaptionEntities } func telegramEntityText(runes []rune, entity telego.MessageEntity) (string, bool) { if entity.Offset < 0 || entity.Length <= 0 { return "", false } end := entity.Offset + entity.Length if entity.Offset >= len(runes) || end > len(runes) { return "", false } return string(runes[entity.Offset:end]), true } func isBotCommandEntityForThisBot(entityText, botUsername string) bool { if !strings.HasPrefix(entityText, "/") { return false } command := strings.TrimPrefix(entityText, "/") if command == "" { return false } at := strings.IndexRune(command, '@') if at == -1 { // A bare /command delivered to this bot is intended for this bot. return true } mentionUsername := command[at+1:] if mentionUsername == "" || botUsername == "" { return false } return strings.EqualFold(mentionUsername, botUsername) } // stripBotMention removes the @bot mention from the content. func (c *TelegramChannel) stripBotMention(content string) string { botUsername := c.bot.Username() if botUsername == "" { return content } // Case-insensitive replacement re := regexp.MustCompile(`(?i)@` + regexp.QuoteMeta(botUsername)) content = re.ReplaceAllString(content, "") return strings.TrimSpace(content) }