feat(telegram): add outbound media support for sending images and files

Add Media field to OutboundMessage and update Telegram channel Send()
to handle image/file attachments via SendPhoto and SendDocument.

- Stream local files directly without buffering in memory
- URL media delegated to Telegram server-side download
- Image detection via file extension (.jpg/.png/.gif/.webp)
- Fallback to text-only message if all media sends fail
- Delete placeholder instead of editing for media messages
This commit is contained in:
muava12 2026-02-24 17:47:29 +08:00
parent 0870815af2
commit 869acc2932
2 changed files with 111 additions and 3 deletions

View file

@ -11,9 +11,10 @@ type InboundMessage struct {
} }
type OutboundMessage struct { type OutboundMessage struct {
Channel string `json:"channel"` Channel string `json:"channel"`
ChatID string `json:"chat_id"` ChatID string `json:"chat_id"`
Content string `json:"content"` Content string `json:"content"`
Media []string `json:"media,omitempty"`
} }
type MessageHandler func(InboundMessage) error type MessageHandler func(InboundMessage) error

View file

@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"path/filepath"
"regexp" "regexp"
"strings" "strings"
"sync" "sync"
@ -164,6 +165,19 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
c.stopThinking.Delete(msg.ChatID) c.stopThinking.Delete(msg.ChatID)
} }
// If media is present, send media (skip placeholder edit — can't edit text into photo)
if len(msg.Media) > 0 {
// Delete placeholder instead of editing it
if pID, ok := c.placeholders.Load(msg.ChatID); ok {
c.placeholders.Delete(msg.ChatID)
_ = c.bot.DeleteMessage(ctx, &telego.DeleteMessageParams{
ChatID: tu.ID(chatID),
MessageID: pID.(int),
})
}
return c.sendMedia(ctx, chatID, msg)
}
htmlContent := markdownToTelegramHTML(msg.Content) htmlContent := markdownToTelegramHTML(msg.Content)
// Try to edit placeholder // Try to edit placeholder
@ -193,6 +207,99 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
return nil return nil
} }
// sendMedia sends media files (images/documents) to a Telegram chat.
// For images (.jpg/.jpeg/.png/.gif/.webp), uses SendPhoto.
// For all other file types, uses SendDocument.
// Supports both local file paths and URLs. Files are streamed, not buffered.
func (c *TelegramChannel) sendMedia(ctx context.Context, chatID int64, msg bus.OutboundMessage) error {
caption := msg.Content
var firstErr error
for i, mediaPath := range msg.Media {
// Only first media gets the caption
itemCaption := ""
if i == 0 {
itemCaption = caption
}
err := c.sendSingleMedia(ctx, chatID, mediaPath, itemCaption)
if err != nil {
logger.ErrorCF("telegram", "Failed to send media", map[string]any{
"path": mediaPath,
"error": err.Error(),
})
if firstErr == nil {
firstErr = err
}
}
}
// If all media failed and we have text content, fall back to text
if firstErr != nil && caption != "" {
tgMsg := tu.Message(tu.ID(chatID), markdownToTelegramHTML(caption))
tgMsg.ParseMode = telego.ModeHTML
if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil {
tgMsg.ParseMode = ""
_, _ = c.bot.SendMessage(ctx, tgMsg)
}
}
return firstErr
}
// sendSingleMedia sends one media file. It determines the input source (URL vs local file)
// and the send method (photo vs document) based on the path and file extension.
func (c *TelegramChannel) sendSingleMedia(ctx context.Context, chatID int64, mediaPath, caption string) error {
isURL := strings.HasPrefix(mediaPath, "http://") || strings.HasPrefix(mediaPath, "https://")
isImage := isImageExtension(mediaPath)
if isURL {
input := tu.FileFromURL(mediaPath)
if isImage {
params := tu.Photo(tu.ID(chatID), input)
params.Caption = caption
_, err := c.bot.SendPhoto(ctx, params)
return err
}
params := tu.Document(tu.ID(chatID), input)
params.Caption = caption
_, err := c.bot.SendDocument(ctx, params)
return err
}
// Local file — stream directly, don't buffer in memory
file, err := os.Open(mediaPath)
if err != nil {
return fmt.Errorf("open media file: %w", err)
}
defer file.Close()
fileName := filepath.Base(mediaPath)
input := tu.File(tu.NameReader(file, fileName))
if isImage {
params := tu.Photo(tu.ID(chatID), input)
params.Caption = caption
_, err = c.bot.SendPhoto(ctx, params)
return err
}
params := tu.Document(tu.ID(chatID), input)
params.Caption = caption
_, err = c.bot.SendDocument(ctx, params)
return err
}
// isImageExtension returns true for common image file extensions.
func isImageExtension(path string) bool {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".jpg", ".jpeg", ".png", ".gif", ".webp":
return true
}
return false
}
func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error { func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error {
if message == nil { if message == nil {
return fmt.Errorf("message is nil") return fmt.Errorf("message is nil")