yao/integrations/telegram/file.go
Max ea9e070f29 Enhance robot integration with Telegram and improve event handling
- Add Telegram integration support by introducing a dispatcher for handling Telegram events and messages.
- Implement event notifications for robot configuration changes (creation, update, deletion) to facilitate integration with external services.
- Refactor the robot initialization process to load robots into cache and start the dispatcher, improving the overall system setup.
- Update the delivery event structure to include additional metadata for better context during message handling.
- Enhance logging capabilities for better observability during robot execution and event processing.
2026-03-01 22:03:25 +08:00

206 lines
6.2 KiB
Go

package telegram
import (
"bytes"
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/textproto"
"os"
"path/filepath"
"strings"
"github.com/go-telegram/bot/models"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/attachment"
)
const defaultUploader = "__yao.attachment"
// FileResult holds the attachment wrapper and metadata for a downloaded
// Telegram file that has been stored via the attachment manager.
type FileResult struct {
Wrapper string // e.g. __yao.attachment://ccd472d11feb96e03a3fc468f494045c
MimeType string
FileName string
}
// GetFile retrieves file metadata (including download path) for a given file_id.
// Uses raw HTTP for compatibility with both official and local Bot API servers.
func (b *Bot) GetFile(ctx context.Context, fileID string) (*models.File, error) {
body, err := json.Marshal(map[string]string{"file_id": fileID})
if err != nil {
return nil, fmt.Errorf("marshal: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", b.botURL()+"/getFile", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := b.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
var result struct {
OK bool `json:"ok"`
Result models.File `json:"result"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("unmarshal: %w", err)
}
if !result.OK {
return nil, fmt.Errorf("getFile: API returned ok=false, body=%s", string(respBody))
}
return &result.Result, nil
}
// DownloadFile downloads a file given its file_path from GetFile.
// Returns the response body (caller must close), content type, and file size.
// When file_path is an absolute path (local Bot API server --local mode),
// the file is read directly from disk instead of HTTP download.
func (b *Bot) DownloadFile(ctx context.Context, filePath string) (io.ReadCloser, string, int64, error) {
if strings.HasPrefix(filePath, "/") {
f, err := os.Open(filePath)
if err != nil {
return nil, "", 0, fmt.Errorf("open local file: %w", err)
}
info, _ := f.Stat()
var size int64
if info != nil {
size = info.Size()
}
return f, "application/octet-stream", size, nil
}
url := b.fileURL(filePath)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, "", 0, err
}
resp, err := b.httpClient.Do(req)
if err != nil {
return nil, "", 0, err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, "", 0, fmt.Errorf("download file: status %d", resp.StatusCode)
}
return resp.Body, resp.Header.Get("Content-Type"), resp.ContentLength, nil
}
// DownloadAndStore downloads a Telegram file by file_id, stores it through the
// attachment manager, and returns the wrapper string. Uses file_unique_id as
// the Content-Fingerprint so that the same Telegram file is never downloaded
// and stored twice — attachment's built-in fingerprint dedup handles it.
func (b *Bot) DownloadAndStore(ctx context.Context, tgFileID, fileUniqueID, mimeType, filename string, groups []string) (*FileResult, error) {
manager, exists := attachment.Managers[defaultUploader]
if !exists {
return nil, fmt.Errorf("attachment manager %s not found", defaultUploader)
}
probeID := fingerprintFileID(fileUniqueID, groups)
if manager.Exists(ctx, probeID) {
wrapper := fmt.Sprintf("%s://%s", defaultUploader, probeID)
log.Trace("telegram file: cache hit file_unique_id=%s wrapper=%s", fileUniqueID, wrapper)
return &FileResult{Wrapper: wrapper, MimeType: mimeType, FileName: filename}, nil
}
fileMeta, err := b.GetFile(ctx, tgFileID)
if err != nil {
return nil, fmt.Errorf("getFile: %w", err)
}
body, contentType, size, err := b.DownloadFile(ctx, fileMeta.FilePath)
if err != nil {
return nil, fmt.Errorf("download: %w", err)
}
defer body.Close()
if contentType != "" && mimeType == "application/octet-stream" {
mimeType = contentType
}
data, err := io.ReadAll(body)
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
if size <= 0 {
size = int64(len(data))
}
ext := filepath.Ext(filename)
header := &attachment.FileHeader{
FileHeader: &multipart.FileHeader{
Filename: filename,
Size: size,
Header: make(textproto.MIMEHeader),
},
}
header.Header.Set("Content-Type", mimeType)
header.Header.Set("Content-Fingerprint", fileUniqueID)
if ext != "" {
header.Header.Set("Content-Extension", ext)
}
option := attachment.UploadOption{
OriginalFilename: filename,
Groups: groups,
}
uploaded, err := manager.Upload(ctx, header, bytes.NewReader(data), option)
if err != nil {
return nil, fmt.Errorf("attachment upload: %w", err)
}
wrapper := fmt.Sprintf("%s://%s", defaultUploader, uploaded.ID)
return &FileResult{Wrapper: wrapper, MimeType: mimeType, FileName: filename}, nil
}
// ResolveMedia downloads and stores all media items in the ConvertedMessage,
// filling each MediaItem.Wrapper with the attachment wrapper string.
// Items that fail to download are logged and left with an empty Wrapper.
func (b *Bot) ResolveMedia(ctx context.Context, cm *ConvertedMessage, groups []string) {
if cm == nil {
return
}
for i := range cm.MediaItems {
mi := &cm.MediaItems[i]
result, err := b.DownloadAndStore(ctx, mi.FileID, mi.FileUniqueID, mi.MimeType, mi.FileName, groups)
if err != nil {
log.Error("telegram ResolveMedia: %s %s: %v", mi.Type, mi.FileID, err)
continue
}
mi.Wrapper = result.Wrapper
if result.MimeType != "" {
mi.MimeType = result.MimeType
}
}
}
// fingerprintFileID reproduces the file_id that attachment.Manager would
// generate when Content-Fingerprint is set, so we can probe Exists() before
// downloading anything.
func fingerprintFileID(fileUniqueID string, groups []string) string {
parts := make([]string, 0, len(groups)+1)
parts = append(parts, groups...)
parts = append(parts, fileUniqueID)
storagePath := strings.Join(parts, "/")
hash := md5.Sum([]byte(storagePath))
return hex.EncodeToString(hash[:])
}