335 lines
9.4 KiB
Go
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
|
|
}
|