Files
SaveAny-Bot/api/tgfiles.go
krau 3eb3b6e3c8 feat(api): implement task management API with handlers for creating, listing, retrieving, and canceling tasks
- Added Handlers struct and methods for task operations
- Implemented task progress tracking and storage
- Created server setup with middleware for logging and recovery
- Added support for Telegram file extraction and Telegraph image extraction
- Introduced webhook functionality for task status updates
- Defined request and response types for API interactions
2026-03-05 19:11:30 +08:00

273 lines
7.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"context"
"fmt"
"net/url"
"strconv"
"strings"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot"
userclient "github.com/krau/SaveAny-Bot/client/user"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/pkg/tfile"
)
// MessageContext 保存消息和获取它所用的 context
type MessageContext struct {
Message *tg.Message
Client *ext.Context
}
// getClientContext 获取可用的客户端上下文
// 优先使用 Bot失败后回退到 Userbot
func getClientContext() (*ext.Context, error) {
// 首先尝试获取 Bot context
if botCtx := bot.ExtContext(); botCtx != nil {
return botCtx, nil
}
// 回退到 Userbot
if uc := userclient.GetCtx(); uc != nil {
return uc, nil
}
return nil, fmt.Errorf("no client available (bot and userbot are not initialized)")
}
// resolveChatID 解析聊天 ID
func resolveChatID(_ context.Context, idOrUsername string) (int64, error) {
// 如果是数字 ID
if id, err := strconv.ParseInt(idOrUsername, 10, 64); err == nil {
// 私有频道 ID 需要加上 -100 前缀
if id > 0 {
return -1000000000000 - id, nil
}
return id, nil
}
// 获取可用的客户端上下文
clientCtx, err := getClientContext()
if err != nil {
return 0, err
}
// 使用 tgutil 的 ParseChatID
return tgutil.ParseChatID(clientCtx, idOrUsername)
}
// ParseMessageLink 解析 Telegram 消息链接
// 支持格式:
// - https://t.me/username/123
// - https://t.me/c/123456789/123
// - https://t.me/c/123456789/111/456 (topic id)
// - https://t.me/username/123?comment=2 (评论)
func ParseMessageLink(ctx context.Context, link string) (int64, int, error) {
u, err := url.Parse(link)
if err != nil {
return 0, 0, fmt.Errorf("invalid URL: %w", err)
}
paths := strings.Split(strings.TrimPrefix(u.Path, "/"), "/")
if cmt := u.Query().Get("comment"); cmt != "" {
// 频道评论的消息链接
if len(paths) < 1 {
return 0, 0, fmt.Errorf("invalid message link format: %s", link)
}
// 简化处理:返回错误,提示不支持评论链接
return 0, 0, fmt.Errorf("comment links are not supported")
}
switch len(paths) {
case 2: // https://t.me/username/123
chatID, err := resolveChatID(ctx, paths[0])
if err != nil {
return 0, 0, fmt.Errorf("failed to resolve chat ID: %w", err)
}
msgID, err := strconv.Atoi(paths[1])
if err != nil {
return 0, 0, fmt.Errorf("failed to parse message ID: %w", err)
}
return chatID, msgID, nil
case 3:
// https://t.me/c/123456789/123
// https://t.me/username/123/456 , 123: topic id
chatPart, msgPart := paths[1], paths[2]
if paths[0] != "c" {
chatPart = paths[0]
}
chatID, err := resolveChatID(ctx, chatPart)
if err != nil {
return 0, 0, fmt.Errorf("failed to resolve chat ID: %w", err)
}
msgID, err := strconv.Atoi(msgPart)
if err != nil {
return 0, 0, fmt.Errorf("failed to parse message ID: %w", err)
}
return chatID, msgID, nil
case 4:
// https://t.me/c/123456789/111/456 111: topic id
if paths[0] != "c" {
return 0, 0, fmt.Errorf("invalid message link format: %s", link)
}
chatID, err := resolveChatID(ctx, paths[1])
if err != nil {
return 0, 0, fmt.Errorf("failed to resolve chat ID: %w", err)
}
msgID, err := strconv.Atoi(paths[3])
if err != nil {
return 0, 0, fmt.Errorf("failed to parse message ID: %w", err)
}
return chatID, msgID, nil
}
return 0, 0, fmt.Errorf("invalid message link format: %s", link)
}
// getMessageWithContext 通过 ID 获取消息,返回消息和使用的 context
// 确保消息获取和后续文件创建使用同一个 context
func getMessageWithContext(_ context.Context, chatID int64, msgID int) (*MessageContext, error) {
// 首先尝试使用 Bot
if botCtx := bot.ExtContext(); botCtx != nil {
msg, err := tgutil.GetMessageByID(botCtx, chatID, msgID)
if err == nil {
return &MessageContext{Message: msg, Client: botCtx}, nil
}
}
// 回退到 Userbot
uc := userclient.GetCtx()
if uc == nil {
return nil, fmt.Errorf("userbot not initialized and bot cannot access this message")
}
msg, err := tgutil.GetMessageByID(uc, chatID, msgID)
if err != nil {
return nil, err
}
return &MessageContext{Message: msg, Client: uc}, nil
}
// getGroupedMessagesWithContext 获取媒体组消息,返回消息列表和使用的 context
// 确保消息获取和后续文件创建使用同一个 context
func getGroupedMessagesWithContext(ctx *MessageContext, chatID int64) ([]*tg.Message, error) {
msg := ctx.Message
clientCtx := ctx.Client
groupID, ok := msg.GetGroupedID()
if !ok || groupID == 0 {
return []*tg.Message{msg}, nil
}
// 使用获取原始消息的同一个 client 获取媒体组
msgs, err := tgutil.GetGroupedMessages(clientCtx, chatID, msg)
if err != nil || len(msgs) == 0 {
// 如果获取失败,至少返回原始消息
return []*tg.Message{msg}, nil
}
return msgs, nil
}
// ExtractFilesFromLinks 从消息链接中提取文件
// 每个文件的处理流程:解析链接 -> 获取消息 -> 获取媒体组 -> 创建文件对象
// 对于单个文件,全程使用同一个 client context不会交叉
func ExtractFilesFromLinks(ctx context.Context, links []string) ([]tfile.TGFileMessage, error) {
logger := log.FromContext(ctx)
var files []tfile.TGFileMessage
for _, link := range links {
link = strings.TrimSpace(link)
if link == "" {
continue
}
// 验证链接格式
if !isValidMessageLink(link) {
logger.Errorf("Invalid message link format: %s", link)
continue
}
chatID, msgID, err := ParseMessageLink(ctx, link)
if err != nil {
logger.Errorf("Failed to parse message link %s: %v", link, err)
continue
}
// 解析链接 URL 检查是否有 single 参数
u, _ := url.Parse(link)
single := u != nil && u.Query().Has("single")
// 获取消息和使用的 contextBot 优先,失败回退 Userbot
msgCtx, err := getMessageWithContext(ctx, chatID, msgID)
if err != nil {
logger.Errorf("Failed to get message %d from chat %d: %v", msgID, chatID, err)
continue
}
msg := msgCtx.Message
clientCtx := msgCtx.Client
if msg.Media == nil {
logger.Warnf("Message %d has no media", msgID)
continue
}
media, ok := msg.GetMedia()
if !ok {
logger.Warnf("Failed to get media from message %d", msgID)
continue
}
// 检查是否是媒体组
groupID, isGroup := msg.GetGroupedID()
if isGroup && groupID != 0 && !single {
// 使用同一个 client context 获取媒体组
groupMsgs, err := getGroupedMessagesWithContext(msgCtx, chatID)
if err != nil {
logger.Errorf("Failed to get grouped messages: %v", err)
} else {
for _, gmsg := range groupMsgs {
if gmsg.Media == nil {
continue
}
gmedia, ok := gmsg.GetMedia()
if !ok {
continue
}
// 使用获取消息时使用的同一个 client context 创建文件
file, err := tfile.FromMediaMessage(gmedia, clientCtx.Raw, gmsg)
if err != nil {
logger.Errorf("Failed to create file from media: %v", err)
continue
}
files = append(files, file)
}
continue
}
}
// 单个文件 - 使用获取消息时使用的同一个 client context 创建文件
file, err := tfile.FromMediaMessage(media, clientCtx.Raw, msg)
if err != nil {
logger.Errorf("Failed to create file from media: %v", err)
continue
}
files = append(files, file)
}
if len(files) == 0 {
return nil, fmt.Errorf("no files found in provided links")
}
return files, nil
}
// isValidMessageLink 检查是否是有效的 Telegram 消息链接
func isValidMessageLink(link string) bool {
return strings.HasPrefix(link, "https://t.me/") || strings.HasPrefix(link, "http://t.me/")
}