atlas-iac/services/hermes/router/telegram_media.go
2026-08-17 14:39:21 +00:00

335 lines
9.4 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
)
const (
telegramCaptionLimit = 1000
tenantMediaLimit = 50 << 20
)
var (
telegramMediaMarker = regexp.MustCompile(`(?i)MEDIA:\s*([^\s\)\]\}"']+)`)
telegramMediaLabel = regexp.MustCompile(`(?i)MEDIA:`)
telegramPrivatePath = regexp.MustCompile(`(?i)(?:/opt/data/(?:cache/images|workspace)|/workspace)/[^\s\)\]\}"']+`)
)
type telegramReply struct {
Text string
MediaPaths []string
}
type tenantMedia struct {
Name string
MIME string
Path string
}
func newTenantMediaClient() *http.Client {
return &http.Client{Timeout: 70 * time.Second}
}
func parseTelegramReply(text string) telegramReply {
matches := telegramMediaMarker.FindAllStringSubmatch(text, -1)
paths := make([]string, 0, len(matches))
for _, match := range matches {
if len(match) == 2 {
paths = append(paths, match[1])
}
}
cleaned := telegramMediaMarker.ReplaceAllString(text, "")
cleaned = telegramPrivatePath.ReplaceAllString(cleaned, "[private attachment]")
cleaned = telegramMediaLabel.ReplaceAllString(cleaned, "")
lines := strings.Split(cleaned, "\n")
compact := make([]string, 0, len(lines))
blank := false
for _, line := range lines {
line = strings.TrimRight(line, " \t")
if strings.TrimSpace(line) == "" {
if len(compact) > 0 && !blank {
compact = append(compact, "")
blank = true
}
continue
}
compact = append(compact, line)
blank = false
}
return telegramReply{
Text: strings.TrimSpace(strings.Join(compact, "\n")),
MediaPaths: paths,
}
}
func hasParentTraversal(path string) bool {
for _, component := range strings.Split(filepath.ToSlash(path), "/") {
if component == ".." {
return true
}
}
return false
}
func pathWithin(path, root string) bool {
relative, err := filepath.Rel(root, path)
return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))
}
func normalizeTenantMediaPath(raw string) (string, error) {
path := strings.TrimSpace(raw)
if path == "" || strings.ContainsRune(path, '\x00') || strings.Contains(path, `\`) || hasParentTraversal(path) {
return "", errors.New("invalid media path")
}
if path == "/workspace" || strings.HasPrefix(path, "/workspace/") {
path = "/opt/data/workspace" + strings.TrimPrefix(path, "/workspace")
}
if !filepath.IsAbs(path) {
return "", errors.New("media path must be absolute")
}
path = filepath.Clean(path)
for _, root := range []string{"/opt/data/cache/images", "/opt/data/workspace"} {
if pathWithin(path, root) {
return path, nil
}
}
return "", errors.New("media path is outside the tenant media roots")
}
func (bot *telegramBot) tenantMediaRequest(ctx context.Context, slot int, path string) (*http.Response, error) {
if bot.router == nil || bot.router.backendMediaURL == nil {
return nil, errors.New("tenant media API unavailable")
}
endpoint := bot.router.backendMediaURL(slot) + "/media?" + url.Values{
"path": {path},
}.Encode()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, errors.New("create tenant media request")
}
request.Header.Set("Authorization", "Bearer "+bot.config.RelayKey)
request.Header.Set("X-Hermes-Tenant-Slot", strconv.Itoa(slot))
return bot.mediaClient.Do(request)
}
func (bot *telegramBot) fetchTenantMedia(ctx context.Context, slot int, path string) (tenantMedia, error) {
if bot.mediaClient == nil {
bot.mediaClient = newTenantMediaClient()
}
response, err := bot.tenantMediaRequest(ctx, slot, path)
if err != nil {
return tenantMedia{}, errors.New("tenant media unavailable")
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return tenantMedia{}, errors.New("tenant media rejected")
}
temporary, err := os.CreateTemp("", "hermes-telegram-media-*")
if err != nil {
return tenantMedia{}, errors.New("stage tenant media")
}
temporaryPath := temporary.Name()
keep := false
defer func() {
_ = temporary.Close()
if !keep {
_ = os.Remove(temporaryPath)
}
}()
written, err := io.Copy(temporary, io.LimitReader(response.Body, tenantMediaLimit+1))
if err != nil {
return tenantMedia{}, errors.New("read tenant media")
}
if written > tenantMediaLimit {
return tenantMedia{}, errors.New("tenant media exceeds upload limit")
}
declaredType, _, _ := mime.ParseMediaType(response.Header.Get("Content-Type"))
if _, err := temporary.Seek(0, io.SeekStart); err != nil {
return tenantMedia{}, errors.New("inspect tenant media")
}
header := make([]byte, 512)
read, readErr := temporary.Read(header)
if readErr != nil && readErr != io.EOF {
return tenantMedia{}, errors.New("inspect tenant media")
}
mediaType := http.DetectContentType(header[:read])
if mediaType == "application/octet-stream" && declaredType != "" {
mediaType = declaredType
}
if err := temporary.Close(); err != nil {
return tenantMedia{}, errors.New("stage tenant media")
}
keep = true
return tenantMedia{Name: filepath.Base(path), MIME: mediaType, Path: temporaryPath}, nil
}
func (bot *telegramBot) callMultipart(
ctx context.Context,
method string,
fields map[string]string,
fileField string,
media tenantMedia,
) error {
reader, writer := io.Pipe()
multipartWriter := multipart.NewWriter(writer)
request, err := http.NewRequestWithContext(ctx, http.MethodPost, bot.apiBase+"/"+method, reader)
if err != nil {
reader.Close()
writer.Close()
return errors.New("create Telegram request")
}
request.Header.Set("Content-Type", multipartWriter.FormDataContentType())
writeDone := make(chan error, 1)
go func() {
var writeErr error
mediaFile, openErr := os.Open(media.Path)
if openErr != nil {
writeErr = openErr
} else {
defer mediaFile.Close()
}
for key, value := range fields {
if writeErr == nil {
writeErr = multipartWriter.WriteField(key, value)
}
}
if writeErr == nil {
var part io.Writer
part, writeErr = multipartWriter.CreateFormFile(fileField, media.Name)
if writeErr == nil {
_, writeErr = io.Copy(part, mediaFile)
}
}
if closeErr := multipartWriter.Close(); writeErr == nil {
writeErr = closeErr
}
if writeErr != nil {
_ = writer.CloseWithError(writeErr)
} else {
_ = writer.Close()
}
writeDone <- writeErr
}()
response, err := bot.client.Do(request)
if err != nil {
_ = reader.Close()
<-writeDone
return errors.New("Telegram API unavailable")
}
defer response.Body.Close()
if err := <-writeDone; err != nil {
return errors.New("encode Telegram upload")
}
body, err := io.ReadAll(io.LimitReader(response.Body, 2<<20))
if err != nil {
return errors.New("read Telegram response")
}
var envelope struct {
OK bool `json:"ok"`
Description string `json:"description"`
ErrorCode int `json:"error_code"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
return fmt.Errorf("Telegram API returned undecodable status %d", response.StatusCode)
}
if response.StatusCode != http.StatusOK || !envelope.OK {
description := strings.TrimSpace(envelope.Description)
if description == "" {
description = http.StatusText(response.StatusCode)
}
return fmt.Errorf("Telegram API error %d: %s", envelope.ErrorCode, description)
}
return nil
}
func telegramPhotoMIME(mediaType string) bool {
return mediaType == "image/jpeg" || mediaType == "image/png"
}
func (bot *telegramBot) sendMedia(chatID int64, media tenantMedia, caption string) error {
fields := map[string]string{"chat_id": strconv.FormatInt(chatID, 10)}
if caption != "" {
fields["caption"] = caption
}
if telegramPhotoMIME(media.MIME) {
ctx, cancel := context.WithTimeout(context.Background(), 70*time.Second)
err := bot.callMultipart(ctx, "sendPhoto", fields, "photo", media)
cancel()
if err == nil {
return nil
}
}
ctx, cancel := context.WithTimeout(context.Background(), 70*time.Second)
defer cancel()
return bot.callMultipart(ctx, "sendDocument", fields, "document", media)
}
func (bot *telegramBot) sendReply(chatID int64, slot int, text string) error {
reply := parseTelegramReply(text)
if len(reply.MediaPaths) == 0 {
return bot.sendText(chatID, reply.Text)
}
captionAvailable := reply.Text != "" && utf8.RuneCountInString(reply.Text) <= telegramCaptionLimit
captionSent := false
var firstError error
if reply.Text != "" && !captionAvailable {
if err := bot.sendText(chatID, reply.Text); err != nil {
firstError = err
} else {
captionSent = true
}
}
for _, rawPath := range reply.MediaPaths {
path, err := normalizeTenantMediaPath(rawPath)
if err == nil {
ctx, cancel := context.WithTimeout(context.Background(), 70*time.Second)
media, fetchErr := bot.fetchTenantMedia(ctx, slot, path)
cancel()
err = fetchErr
if err == nil {
caption := ""
if captionAvailable && !captionSent {
caption = reply.Text
}
err = bot.sendMedia(chatID, media, caption)
_ = os.Remove(media.Path)
if err == nil && caption != "" {
captionSent = true
}
}
}
if err != nil {
if firstError == nil {
firstError = err
}
if fallbackErr := bot.sendText(chatID, "I created an attachment, but Telegram could not deliver it. You can still open it in Hermes WebUI."); fallbackErr != nil && firstError == nil {
firstError = fallbackErr
}
}
}
if reply.Text != "" && !captionSent {
if err := bot.sendText(chatID, reply.Text); err != nil && firstError == nil {
firstError = err
}
}
return firstError
}