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 }