Compare commits

...

20 Commits

Author SHA1 Message Date
krau
91814a83c7 fix: deprecate minio and introduce s3 storage backend 2025-12-04 22:59:23 +08:00
krau
685047e463 fix: compatibility between tdlib and bot api style chatID 2025-12-04 22:43:22 +08:00
krau
37e9c79ceb fix: replace huh package with bufio and term for terminal input handling 2025-12-03 22:29:02 +08:00
krau
494d1bf51c fix: migrate to unvgo/ghselfupdate for version management and prevent major version selfupgrade 2025-12-02 10:57:23 +08:00
krau
a6f194aedd feat: add global proxy config 2025-12-02 10:11:58 +08:00
krau
acd16a91a3 chore: re gen enum code using new version go-enum 2025-11-28 10:27:40 +08:00
krau
75f79e8abc fix: add ffmpeg to Dockerfile and update error message in entrypoint.sh to English 2025-11-28 10:26:35 +08:00
krau
1065acfdb8 feat: auto generate thumbnail for video uploaded to telegram storage 2025-11-28 10:24:15 +08:00
krau
fef7d37a7e fix: ensure media group timeout is set to a minimum of 1 second during setup 2025-11-27 12:16:38 +08:00
krau
b5e9cf987a fix: update media group timeout calculation to ensure minimum value is enforced 2025-11-27 11:43:14 +08:00
krau
c58fa454bb fix: remove redundant configuration call in Add function to config parsers correctly 2025-11-23 17:41:44 +08:00
krau
2c5d6f0e57 fix: upgrade golang.org/x/crypto for security 2025-11-22 17:43:52 +08:00
krau
7d57ad30a9 feat: add MP4 metadata extraction and integrate gomedia for video handling 2025-11-22 15:42:02 +08:00
krau
4f314bd37f feat: implement configurable media group handling timeout, close #137 2025-11-16 21:44:51 +08:00
krau
131dfeb4cd refactor: js plugin api 2025-11-16 21:38:30 +08:00
krau
3f40acff55 feat: add directory management functionality and update handlers to utilize default directory 2025-11-16 21:09:55 +08:00
krau
fe47ee3b51 fix: upgrade sqlite driver version 2025-11-14 09:04:14 +08:00
krau
4a6f63e58f test: add tests for string utility functions 2025-11-09 11:48:29 +08:00
krau
16c71e6384 fix: enhance ParseArgsRespectQuotes to handle escaped quotes and backslashes 2025-11-09 11:48:15 +08:00
krau
0c2d116708 feat: parse rule command with quotes respecting 2025-11-09 11:43:28 +08:00
73 changed files with 1565 additions and 733 deletions

View File

@@ -25,7 +25,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
FROM alpine:latest
RUN apk add --no-cache curl
RUN apk add --no-cache curl ffmpeg
WORKDIR /app

View File

@@ -29,7 +29,7 @@
- 使用 js 编写解析器插件以转存任意网站的文件
- 存储端支持:
- Alist
- S3 (MinioSDK)
- S3
- WebDAV
- 本地磁盘
- Telegram (重传回指定聊天)

View File

@@ -9,14 +9,12 @@ import (
"github.com/celestix/gotgproto/ext"
"github.com/celestix/gotgproto/sessionMaker"
"github.com/charmbracelet/log"
"github.com/gotd/td/telegram/dcs"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers"
"github.com/krau/SaveAny-Bot/client/middleware"
"github.com/krau/SaveAny-Bot/common/utils/netutil"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/ncruces/go-sqlite3/gormlite"
"golang.org/x/net/proxy"
)
func Init(ctx context.Context) <-chan struct{} {
@@ -26,22 +24,15 @@ func Init(ctx context.Context) <-chan struct{} {
err error
})
shouldRestart := make(chan struct{})
go func() {
var resolver dcs.Resolver
if config.C().Telegram.Proxy.Enable && config.C().Telegram.Proxy.URL != "" {
dialer, err := netutil.NewProxyDialer(config.C().Telegram.Proxy.URL)
if err != nil {
resultChan <- struct {
client *gotgproto.Client
err error
}{nil, err}
return
}
resolver = dcs.Plain(dcs.PlainOptions{
Dial: dialer.(proxy.ContextDialer).DialContext,
})
} else {
resolver = dcs.DefaultResolver()
resolver, err := tgutil.NewConfigProxyResolver()
if err != nil {
resultChan <- struct {
client *gotgproto.Client
err error
}{nil, err}
return
}
client, err := gotgproto.NewClient(
config.C().Telegram.AppID,

View File

@@ -43,7 +43,7 @@ func handleAddCallback(ctx *ext.Context, update *ext.Update) error {
if !data.SettedDir && len(dirs) != 0 {
// ask for directory selection
markup, err := msgelem.BuildSetDirKeyboard(dirs, dataid)
markup, err := msgelem.BuildSetDirMarkupForAdd(dirs, dataid)
if err != nil {
log.FromContext(ctx).Errorf("Failed to build directory keyboard: %s", err)
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, "目录键盘构建失败: "+err.Error()))

View File

@@ -6,6 +6,7 @@ import (
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/dirutil"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
@@ -43,20 +44,14 @@ func handleMessageLink(ctx *ext.Context, update *ext.Update) error {
}
func handleSilentSaveLink(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
stor := storage.FromContext(ctx)
if stor == nil {
logger.Warn("Context storage is nil")
ctx.Reply(update, ext.ReplyTextString("未找到存储"), nil)
return dispatcher.EndGroups
}
replied, files, _, err := shortcut.GetFilesFromUpdateLinkMessageWithReplyEdit(ctx, update)
if err != nil {
return err
}
userId := update.GetUserChat().GetID()
if len(files) == 1 {
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userId, stor, "", files[0], replied.ID)
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userId, stor, dirutil.PathFromContext(ctx), files[0], replied.ID)
}
return shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, userId, stor, "", files, replied.ID)
return shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, userId, stor, dirutil.PathFromContext(ctx), files, replied.ID)
}

View File

@@ -1,21 +1,14 @@
package handlers
import (
"fmt"
"sync"
"time"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/dirutil"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
"github.com/krau/SaveAny-Bot/pkg/tfile"
"github.com/krau/SaveAny-Bot/storage"
)
@@ -32,12 +25,6 @@ func handleMediaMessage(ctx *ext.Context, update *ext.Update) error {
if err != nil {
return err
}
// tfOpts := make([]tfile.TGFileOption, 0)
// switch userDB.FilenameStrategy {
// case fnamest.Message.String():
// tfOpts = append(tfOpts, tfile.WithName(tgutil.GenFileNameFromMessage(*message)))
// default:
// }
tfOpts := mediautil.TfileOptions(ctx, userDB, message)
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, message, tfOpts...)
if err != nil {
@@ -58,11 +45,6 @@ func handleMediaMessage(ctx *ext.Context, update *ext.Update) error {
func handleSilentSaveMedia(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
stor := storage.FromContext(ctx)
if stor == nil {
logger.Warn("Context storage is nil")
ctx.Reply(update, ext.ReplyTextString("未找到存储"), nil)
return dispatcher.EndGroups
}
message := update.EffectiveMessage.Message
groupID, isGroup := message.GetGroupedID()
if isGroup && groupID != 0 {
@@ -74,109 +56,10 @@ func handleSilentSaveMedia(ctx *ext.Context, update *ext.Update) error {
if err != nil {
return err
}
// tfOpts := make([]tfile.TGFileOption, 0)
// switch userDB.FilenameStrategy {
// case fnamest.Message.String():
// tfOpts = append(tfOpts, tfile.WithName(tgutil.GenFileNameFromMessage(*message)))
// default:
// }
tfOpts := mediautil.TfileOptions(ctx, userDB, message)
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, message, tfOpts...)
if err != nil {
return err
}
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userID, stor, "", file, msg.ID)
}
type MediaGroupHandler struct {
groups map[int64][]tfile.TGFileMessage
timers map[int64]*time.Timer
mu sync.Mutex
timeout time.Duration
}
var mediaGroupHandler = &MediaGroupHandler{
groups: make(map[int64][]tfile.TGFileMessage),
timers: make(map[int64]*time.Timer),
timeout: 1 * time.Second,
}
func handleGroupMediaMessage(ctx *ext.Context, update *ext.Update, message *tg.Message, groupID int64) error {
logger := log.FromContext(ctx)
media := message.Media
supported := mediautil.IsSupported(media)
if !supported {
return dispatcher.EndGroups
}
file, err := tfile.FromMediaMessage(media, ctx.Raw, message, tfile.WithNameIfEmpty(
tgutil.GenFileNameFromMessage(*message),
))
if err != nil {
logger.Errorf("Failed to get file from media: %s", err)
return dispatcher.EndGroups
}
mediaGroupHandler.mu.Lock()
defer mediaGroupHandler.mu.Unlock()
if mediaGroupHandler.groups[groupID] == nil {
mediaGroupHandler.groups[groupID] = make([]tfile.TGFileMessage, 0)
}
mediaGroupHandler.groups[groupID] = append(mediaGroupHandler.groups[groupID], file)
if timer, exists := mediaGroupHandler.timers[groupID]; exists {
timer.Stop()
}
mediaGroupHandler.timers[groupID] = time.AfterFunc(mediaGroupHandler.timeout, func() {
processMediaGroup(ctx, update, groupID)
})
return dispatcher.EndGroups
}
func processMediaGroup(ctx *ext.Context, update *ext.Update, groupID int64) {
logger := log.FromContext(ctx)
mediaGroupHandler.mu.Lock()
items := mediaGroupHandler.groups[groupID]
delete(mediaGroupHandler.groups, groupID)
delete(mediaGroupHandler.timers, groupID)
mediaGroupHandler.mu.Unlock()
if len(items) == 0 {
logger.Warn("No media items to process for group", "groupID", groupID)
return
}
logger.Debugf("Processing media group %d with %d items", groupID, len(items))
userId := update.GetUserChat().GetID()
msg, err := ctx.Reply(update, ext.ReplyTextString("正在保存文件..."), nil)
if err != nil {
logger.Errorf("Failed to reply: %s", err)
return
}
stor := storage.FromContext(ctx)
if stor != nil {
// In silent mode
if len(items) == 1 {
shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userId, stor, "", items[0], msg.ID)
return
}
shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, userId, stor, "", items, msg.ID)
return
}
stors := storage.GetUserStorages(ctx, userId)
markup, err := msgelem.BuildAddSelectStorageKeyboard(stors, tcbdata.Add{
Files: items,
AsBatch: len(items) > 1,
})
if err != nil {
logger.Errorf("构建存储选择键盘失败: %s", err)
ctx.EditMessage(userId, &tg.MessagesEditMessageRequest{
ID: msg.ID,
Message: "构建存储选择键盘失败: " + err.Error(),
})
return
}
ctx.EditMessage(userId, &tg.MessagesEditMessageRequest{
ID: msg.ID,
Message: fmt.Sprintf("共 %d 个文件, 请选择存储位置", len(items)),
ReplyMarkup: markup,
})
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userID, stor, dirutil.PathFromContext(ctx), file, msg.ID)
}

View File

@@ -0,0 +1,126 @@
package handlers
import (
"fmt"
"sync"
"time"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
"github.com/krau/SaveAny-Bot/pkg/tfile"
"github.com/krau/SaveAny-Bot/storage"
)
type MediaGroupHandler struct {
groups map[int64][]tfile.TGFileMessage
timers map[int64]*time.Timer
mu sync.Mutex
timeout time.Duration
setupOnce sync.Once
}
func (m *MediaGroupHandler) SetupTimeout(timeoutSec int) {
m.setupOnce.Do(func() {
if timeoutSec < 1 {
timeoutSec = 1
}
m.timeout = time.Duration(timeoutSec) * time.Second
})
}
var (
mediaGroupHandler = &MediaGroupHandler{
groups: make(map[int64][]tfile.TGFileMessage),
timers: make(map[int64]*time.Timer),
mu: sync.Mutex{},
}
)
func handleGroupMediaMessage(ctx *ext.Context, update *ext.Update, message *tg.Message, groupID int64) error {
mediaGroupHandler.SetupTimeout(max(config.C().Telegram.MediaGroupTimeout, 1))
logger := log.FromContext(ctx)
media := message.Media
supported := mediautil.IsSupported(media)
if !supported {
return dispatcher.EndGroups
}
file, err := tfile.FromMediaMessage(media, ctx.Raw, message, tfile.WithNameIfEmpty(
tgutil.GenFileNameFromMessage(*message),
))
if err != nil {
logger.Errorf("Failed to get file from media: %s", err)
return dispatcher.EndGroups
}
mediaGroupHandler.mu.Lock()
defer mediaGroupHandler.mu.Unlock()
if mediaGroupHandler.groups[groupID] == nil {
mediaGroupHandler.groups[groupID] = make([]tfile.TGFileMessage, 0)
}
mediaGroupHandler.groups[groupID] = append(mediaGroupHandler.groups[groupID], file)
if timer, exists := mediaGroupHandler.timers[groupID]; exists {
timer.Stop()
}
mediaGroupHandler.timers[groupID] = time.AfterFunc(mediaGroupHandler.timeout, func() {
processMediaGroup(ctx, update, groupID)
})
return dispatcher.EndGroups
}
func processMediaGroup(ctx *ext.Context, update *ext.Update, groupID int64) {
logger := log.FromContext(ctx)
mediaGroupHandler.mu.Lock()
items := mediaGroupHandler.groups[groupID]
delete(mediaGroupHandler.groups, groupID)
delete(mediaGroupHandler.timers, groupID)
mediaGroupHandler.mu.Unlock()
if len(items) == 0 {
logger.Warn("No media items to process for group", "groupID", groupID)
return
}
logger.Debugf("Processing media group %d with %d items", groupID, len(items))
userId := update.GetUserChat().GetID()
msg, err := ctx.Reply(update, ext.ReplyTextString("正在保存文件..."), nil)
if err != nil {
logger.Errorf("Failed to reply: %s", err)
return
}
stor := storage.FromContext(ctx)
if stor != nil {
// In silent mode
if len(items) == 1 {
shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userId, stor, "", items[0], msg.ID)
return
}
shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, userId, stor, "", items, msg.ID)
return
}
stors := storage.GetUserStorages(ctx, userId)
markup, err := msgelem.BuildAddSelectStorageKeyboard(stors, tcbdata.Add{
Files: items,
AsBatch: len(items) > 1,
})
if err != nil {
logger.Errorf("构建存储选择键盘失败: %s", err)
ctx.EditMessage(userId, &tg.MessagesEditMessageRequest{
ID: msg.ID,
Message: "构建存储选择键盘失败: " + err.Error(),
})
return
}
ctx.EditMessage(userId, &tg.MessagesEditMessageRequest{
ID: msg.ID,
Message: fmt.Sprintf("共 %d 个文件, 请选择存储位置", len(items)),
ReplyMarkup: markup,
})
}

View File

@@ -4,6 +4,7 @@ import (
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/duke-git/lancet/v2/slice"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/dirutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/storage"
@@ -43,6 +44,14 @@ func handleSilentMode(next func(*ext.Context, *ext.Update) error, handler func(*
ctx.Reply(update, ext.ReplyTextString("获取默认存储失败: "+err.Error()), nil)
return dispatcher.EndGroups
}
if user.DefaultDir != 0 {
dir, err := database.GetDirByID(ctx, user.DefaultDir)
if err != nil {
ctx.Reply(update, ext.ReplyTextString("获取默认文件夹失败: "+err.Error()), nil)
return next(ctx, update)
}
ctx.Context = dirutil.WithContext(ctx.Context, dir)
}
ctx.Context = storage.WithContext(ctx.Context, stor)
return handler(ctx, update)
}

View File

@@ -4,12 +4,14 @@ package handlers
import (
"errors"
"path"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/dirutil"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
@@ -75,11 +77,6 @@ func handleTextMessage(ctx *ext.Context, u *ext.Update) error {
func handleSilentSaveText(ctx *ext.Context, u *ext.Update) error {
logger := log.FromContext(ctx)
stor := storage.FromContext(ctx)
if stor == nil {
logger.Warn("Context storage is nil")
ctx.Reply(u, ext.ReplyTextString("未找到存储"), nil)
return dispatcher.EndGroups
}
text := u.EffectiveMessage.Text
if text == "" {
return dispatcher.EndGroups
@@ -117,5 +114,8 @@ func handleSilentSaveText(ctx *ext.Context, u *ext.Update) error {
if len(item.Resources) > 1 {
dirPath = fsutil.NormalizePathname(item.Title)
}
if p := dirutil.PathFromContext(ctx); p != "" {
dirPath = path.Join(p, dirPath)
}
return shortcut.CreateAndAddParsedTaskWithEdit(ctx, stor, dirPath, item, msg.ID, userID)
}

View File

@@ -10,13 +10,14 @@ import (
"github.com/charmbracelet/log"
"github.com/duke-git/lancet/v2/slice"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/common/utils/strutil"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/rule"
)
func handleRuleCmd(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
args := strings.Split(update.EffectiveMessage.Text, " ")
args := strutil.ParseArgsRespectQuotes(update.EffectiveMessage.Text)
userChatID := update.GetUserChat().GetID()
user, err := database.GetUserByChatID(ctx, userChatID)
if err != nil {

View File

@@ -9,6 +9,7 @@ import (
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/dirutil"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
@@ -75,29 +76,12 @@ func handleSilentSaveReplied(ctx *ext.Context, update *ext.Update) error {
if len(args) >= 3 {
return handleBatchSave(ctx, update, args[1:])
}
logger := log.FromContext(ctx)
stor := storage.FromContext(ctx)
if stor == nil {
logger.Warn("Context storage is nil")
ctx.Reply(update, ext.ReplyTextString("未找到存储"), nil)
return dispatcher.EndGroups
}
replyTo := update.EffectiveMessage.ReplyToMessage
if replyTo == nil || replyTo.Message == nil {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgSaveHelpText)), nil)
return dispatcher.EndGroups
}
// genFilename := func() string {
// if len(args) > 1 {
// return args[1]
// }
// filename := tgutil.GenFileNameFromMessage(*replyTo.Message)
// return filename
// }()
// option := tfile.WithNameIfEmpty(genFilename)
// if len(args) > 1 {
// option = tfile.WithName(genFilename)
// }
userDB, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
if err != nil {
return err
@@ -111,7 +95,7 @@ func handleSilentSaveReplied(ctx *ext.Context, update *ext.Update) error {
if err != nil {
return err
}
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, update.GetUserChat().GetID(), stor, "", file, msg.GetID())
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, update.GetUserChat().GetID(), stor, dirutil.PathFromContext(ctx), file, msg.GetID())
}
func handleBatchSave(ctx *ext.Context, update *ext.Update, args []string) error {

View File

@@ -1,6 +1,7 @@
package handlers
import (
"fmt"
"strings"
"github.com/celestix/gotgproto/dispatcher"
@@ -36,51 +37,71 @@ func handleSilentCmd(ctx *ext.Context, update *ext.Update) error {
func handleSetDefaultCallback(ctx *ext.Context, update *ext.Update) error {
dataid := strings.Split(string(update.CallbackQuery.Data), " ")[1]
data, ok := cache.Get[tcbdata.SetDefaultStorage](dataid)
if !ok {
failedAnswer := func(message string) error {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.GetQueryID(),
Alert: true,
Message: "数据已过期",
Message: message,
CacheTime: 5,
})
return dispatcher.EndGroups
}
if !ok {
return failedAnswer("数据已过期")
}
userID := update.CallbackQuery.GetUserID()
storageName := data.StorageName
selectedStorage, err := storage.GetStorageByUserIDAndName(ctx, userID, storageName)
if err != nil {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.GetQueryID(),
Alert: true,
Message: "存储获取失败: " + err.Error(),
CacheTime: 5,
})
return dispatcher.EndGroups
return failedAnswer("存储获取失败: " + err.Error())
}
user, err := database.GetUserByChatID(ctx, userID)
if err != nil {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.GetQueryID(),
Alert: true,
Message: "获取用户信息失败: " + err.Error(),
CacheTime: 5,
})
return dispatcher.EndGroups
return failedAnswer("获取用户信息失败: " + err.Error())
}
var dir *database.Dir
if data.DirID != 0 {
// 已经选择了文件夹
var err error
dir, err = database.GetDirByID(ctx, data.DirID)
if err != nil {
return failedAnswer("获取文件夹信息失败: " + err.Error())
}
user.DefaultDir = dir.ID
} else {
// 检查是否有可用的文件夹
dirs, err := database.GetDirsByUserIDAndStorageName(ctx, user.ID, storageName)
if err != nil {
return failedAnswer("获取目录失败: " + err.Error())
}
if len(dirs) > 0 {
// 要求选择文件夹
markup, err := msgelem.BuildSetDefaultDirMarkup(ctx, storageName, dirs)
if err != nil {
return failedAnswer("构建目录选择失败: " + err.Error())
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: update.CallbackQuery.GetMsgID(),
Message: "请选择要保存到的默认文件夹",
ReplyMarkup: markup,
})
return dispatcher.EndGroups
}
}
user.DefaultStorage = selectedStorage.Name()
if err := database.UpdateUser(ctx, user); err != nil {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.GetQueryID(),
Alert: true,
Message: "更新用户信息失败: " + err.Error(),
CacheTime: 5,
})
return dispatcher.EndGroups
return failedAnswer("更新用户信息失败: " + err.Error())
}
msg := fmt.Sprintf("已将默认存储位置设为: %s", selectedStorage.Name())
if dir != nil {
msg += fmt.Sprintf(":/%s", strings.TrimPrefix(dir.Path, "/"))
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: update.CallbackQuery.GetMsgID(),
Message: "已将默认存储位置设置为: " + selectedStorage.Name(),
Message: msg,
})
return dispatcher.EndGroups
}
@@ -92,7 +113,7 @@ func handleStorageCmd(ctx *ext.Context, update *ext.Update) error {
ctx.Reply(update, ext.ReplyTextString("无可用的存储"), nil)
return nil
}
markup, err := msgelem.BuildSetDefaultStorageMarkup(ctx, userID, storages)
markup, err := msgelem.BuildSetDefaultStorageMarkup(ctx, storages)
if err != nil {
ctx.Reply(update, ext.ReplyTextString("获取存储失败: "+err.Error()), nil)
return nil

View File

@@ -2,6 +2,7 @@ package handlers
import (
"fmt"
"path"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
@@ -9,6 +10,7 @@ import (
"github.com/gotd/td/telegram/message/entity"
"github.com/gotd/td/telegram/message/styling"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/dirutil"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
@@ -59,18 +61,16 @@ func handleTelegraphUrlMessage(ctx *ext.Context, update *ext.Update) error {
}
func handleSilentSaveTelegraph(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
stor := storage.FromContext(ctx)
if stor == nil {
logger.Warn("Context storage is nil")
ctx.Reply(update, ext.ReplyTextString("未找到存储"), nil)
return dispatcher.EndGroups
}
msg, result, err := shortcut.GetTphPicsFromMessageWithReply(ctx, update)
if err != nil {
return err
}
userID := update.GetUserChat().GetID()
return shortcut.CreateAndAddtelegraphWithEdit(ctx, userID, result.Page, result.TphDir, result.Pics, stor, msg.ID)
dirpath := result.TphDir
if p := dirutil.PathFromContext(ctx); p != "" {
dirpath = path.Join(p, dirpath)
}
return shortcut.CreateAndAddtelegraphWithEdit(ctx, userID, result.Page, dirpath, result.Pics, stor, msg.ID)
}

View File

@@ -12,7 +12,7 @@ import (
"github.com/gotd/td/telegram/message/html"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/config"
"github.com/rhysd/go-github-selfupdate/selfupdate"
"github.com/unvgo/ghselfupdate"
)
func handleUpdateCmd(ctx *ext.Context, u *ext.Update) error {
@@ -21,7 +21,7 @@ func handleUpdateCmd(ctx *ext.Context, u *ext.Update) error {
ctx.Reply(u, ext.ReplyTextString(fmt.Sprintf("You are in dev or the version var failed to inject: %v", err)), nil)
return dispatcher.EndGroups
}
latest, ok, err := selfupdate.DetectLatest(config.GitRepo)
latest, ok, err := ghselfupdate.DetectLatest(config.GitRepo)
if err != nil {
ctx.Reply(u, ext.ReplyTextString(fmt.Sprintf("检测最新版本失败: %v", err)), nil)
return dispatcher.EndGroups
@@ -30,6 +30,10 @@ func handleUpdateCmd(ctx *ext.Context, u *ext.Update) error {
ctx.Reply(u, ext.ReplyTextString("没有找到版本信息"), nil)
return dispatcher.EndGroups
}
if latest.Version.Major != currentV.Major {
ctx.Reply(u, ext.ReplyTextString(fmt.Sprintf("检测到大版本更新: %s -> %s , 请前往 GitHub 手动下载最新版本并查看迁移指南", currentV, latest.Version)), nil)
return dispatcher.EndGroups
}
if latest.Version.LT(currentV) || latest.Version.Equals(currentV) {
ctx.Reply(u, ext.ReplyTextString(fmt.Sprintf("当前已经是最新版本: %s", config.Version)), nil)
return dispatcher.EndGroups
@@ -86,7 +90,7 @@ func handleUpdateCallback(ctx *ext.Context, u *ext.Update) error {
ID: u.CallbackQuery.GetMsgID(),
Message: fmt.Sprintf("正在升级中, 当前版本: %s", config.Version),
})
latest, err := selfupdate.UpdateSelf(currentV, config.GitRepo)
latest, err := ghselfupdate.UpdateSelf(currentV, config.GitRepo)
if err != nil {
ctx.EditMessage(u.GetUserChat().GetID(), &tg.MessagesEditMessageRequest{
ID: u.CallbackQuery.GetMsgID(),

View File

@@ -0,0 +1,37 @@
package dirutil
import (
"context"
"github.com/krau/SaveAny-Bot/database"
)
type contextKey struct{}
var dirContextKey = contextKey{}
func WithContext(ctx context.Context, dir *database.Dir) context.Context {
if dir == nil {
return ctx
}
return context.WithValue(ctx, dirContextKey, dir)
}
func FromContext(ctx context.Context) *database.Dir {
dir, ok := ctx.Value(dirContextKey).(*database.Dir)
if !ok {
return nil
}
return dir
}
// PathFromContext returns the directory path stored in the context.
//
// If no directory is found, an empty string is returned.
func PathFromContext(ctx context.Context) string {
dir := FromContext(ctx)
if dir == nil {
return ""
}
return dir.Path
}

View File

@@ -94,7 +94,10 @@ func BuildAddOneSelectStorageMessage(ctx context.Context, stors []storage.Storag
}, nil
}
func BuildSetDefaultStorageMarkup(ctx context.Context, userID int64, stors []storage.Storage) (*tg.ReplyInlineMarkup, error) {
// Builds the inline keyboard for setting default storage
func BuildSetDefaultStorageMarkup(
ctx context.Context,
stors []storage.Storage) (*tg.ReplyInlineMarkup, error) {
buttons := make([]tg.KeyboardButtonClass, 0)
for _, storage := range stors {
data := tcbdata.SetDefaultStorage{
@@ -119,7 +122,35 @@ func BuildSetDefaultStorageMarkup(ctx context.Context, userID int64, stors []sto
return markup, nil
}
func BuildSetDirKeyboard(dirs []database.Dir, dataid string) (*tg.ReplyInlineMarkup, error) {
func BuildSetDefaultDirMarkup(ctx context.Context,
seletedStorage string,
dirs []database.Dir) (*tg.ReplyInlineMarkup, error) {
buttons := make([]tg.KeyboardButtonClass, 0)
for _, dir := range dirs {
dataid := xid.New().String()
data := tcbdata.SetDefaultStorage{
StorageName: seletedStorage,
DirID: dir.ID,
}
err := cache.Set(dataid, data)
if err != nil {
return nil, err
}
buttons = append(buttons, &tg.KeyboardButtonCallback{
Text: dir.Path,
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeSetDefault, dataid),
})
}
markup := &tg.ReplyInlineMarkup{}
for i := 0; i < len(buttons); i += 3 {
row := tg.KeyboardButtonRow{}
row.Buttons = buttons[i:min(i+3, len(buttons))]
markup.Rows = append(markup.Rows, row)
}
return markup, nil
}
func BuildSetDirMarkupForAdd(dirs []database.Dir, dataid string) (*tg.ReplyInlineMarkup, error) {
data, ok := cache.Get[tcbdata.Add](dataid)
if !ok {
return nil, fmt.Errorf("failed to get data from cache: %s", dataid)

View File

@@ -126,7 +126,7 @@ func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Up
}
msg, err := tgutil.GetMessageByID(tctx, chatId, msgId)
if err != nil {
logger.Errorf("failed to get message by ID: %s", err)
logger.Error(err)
continue
}
groupID, isGroup := msg.GetGroupedID()

View File

@@ -1,80 +1,57 @@
package user
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/celestix/gotgproto"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/log"
"github.com/fatih/color"
"golang.org/x/term"
)
type terminalAuthConversator struct{}
func (t *terminalAuthConversator) AskPhoneNumber() (string, error) {
phone := ""
err := huh.NewInput().Title("Your Phone Number").
Placeholder("+44 123456").
Prompt("> ").
Value(&phone).
WithTheme(huh.ThemeCatppuccin()).
Run()
func readLine(prompt string) (string, error) {
fmt.Print(prompt)
reader := bufio.NewReader(os.Stdin)
text, err := reader.ReadString('\n')
if err != nil {
return "", err
}
return strings.TrimSpace(text), nil
}
log.Info("Sending code to your phone number...")
return strings.TrimSpace(phone), nil
func (t *terminalAuthConversator) AskPhoneNumber() (string, error) {
fmt.Println("Your Phone Number (e.g. +44 123456):")
return readLine("> ")
}
func (t *terminalAuthConversator) AskCode() (string, error) {
code := ""
err := huh.NewInput().Title("Your Code").
Placeholder("123456").
Value(&code).
Prompt("> ").
WithTheme(huh.ThemeCatppuccin()).
Run()
if err != nil {
return "", err
}
return strings.TrimSpace(code), nil
fmt.Println("Your Code (e.g. 123456):")
return readLine("> ")
}
func (t *terminalAuthConversator) AskPassword() (string, error) {
pwd := ""
err := huh.NewInput().Title("Your 2FA Password").
EchoMode(huh.EchoModePassword).
Value(&pwd).
Prompt("> ").
WithTheme(huh.ThemeCatppuccin()).
Run()
fmt.Println("Your 2FA Password:")
fmt.Print("> ")
bytePwd, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if err != nil {
return "", err
}
return strings.TrimSpace(pwd), nil
return strings.TrimSpace(string(bytePwd)), nil
}
func (t *terminalAuthConversator) AuthStatus(authStatus gotgproto.AuthStatus) {
switch authStatus.Event {
case gotgproto.AuthStatusPhoneRetrial:
color.Red("The phone number you just entered seems to be incorrect,")
color.Red("Attempts Left: %d", authStatus.AttemptsLeft)
color.Red("Please try again....")
fmt.Printf("The phone number is incorrect. Attempts left: %d\n", authStatus.AttemptsLeft)
case gotgproto.AuthStatusPasswordRetrial:
color.Red("The 2FA password you just entered seems to be incorrect,")
color.Red("Attempts Left: %d", authStatus.AttemptsLeft)
color.Red("Please try again....")
fmt.Printf("The 2FA password is incorrect. Attempts left: %d\n", authStatus.AttemptsLeft)
case gotgproto.AuthStatusPhoneCodeRetrial:
color.Red("The OTP you just entered seems to be incorrect,")
color.Red("Attempts Left: %d", authStatus.AttemptsLeft)
color.Red("Please try again....")
fmt.Printf("The OTP code is incorrect. Attempts left: %d\n", authStatus.AttemptsLeft)
default:
}
}

View File

@@ -12,14 +12,12 @@ import (
"github.com/celestix/gotgproto/sessionMaker"
"github.com/charmbracelet/log"
"github.com/gotd/td/telegram/dcs"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/middleware"
"github.com/krau/SaveAny-Bot/common/utils/netutil"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/database"
"github.com/ncruces/go-sqlite3/gormlite"
"golang.org/x/net/proxy"
)
var uc *gotgproto.Client
@@ -53,21 +51,13 @@ func Login(ctx context.Context) (*gotgproto.Client, error) {
err error
})
go func() {
var resolver dcs.Resolver
if config.C().Telegram.Proxy.Enable && config.C().Telegram.Proxy.URL != "" {
dialer, err := netutil.NewProxyDialer(config.C().Telegram.Proxy.URL)
if err != nil {
res <- struct {
client *gotgproto.Client
err error
}{nil, err}
return
}
resolver = dcs.Plain(dcs.PlainOptions{
Dial: dialer.(proxy.ContextDialer).DialContext,
})
} else {
resolver = dcs.DefaultResolver()
resolver, err := tgutil.NewConfigProxyResolver()
if err != nil {
res <- struct {
client *gotgproto.Client
err error
}{nil, err}
return
}
tclient, err := gotgproto.NewClient(
config.C().Telegram.AppID,

View File

@@ -5,7 +5,7 @@ import (
"runtime"
"github.com/krau/SaveAny-Bot/config"
"github.com/rhysd/go-github-selfupdate/selfupdate"
"github.com/unvgo/ghselfupdate"
"github.com/blang/semver"
"github.com/spf13/cobra"
@@ -26,17 +26,32 @@ var upgradeCmd = &cobra.Command{
Short: "Upgrade saveany-bot to the latest version",
Run: func(cmd *cobra.Command, args []string) {
v := semver.MustParse(config.Version)
latest, err := selfupdate.UpdateSelf(v, config.GitRepo)
latest, found, err := ghselfupdate.DetectLatest(config.GitRepo)
if err != nil {
fmt.Println("Error occurred while detecting latest version:", err)
return
}
if !found {
fmt.Println("No releases found")
return
}
if latest.Version.Major != v.Major {
fmt.Printf("Major version upgrade detected: %s -> %s. Please manually download the latest version and check the migration guide.\n", v, latest.Version)
return
}
if latest.Version.Equals(v) || latest.Version.LT(v) {
fmt.Println("Current binary is the latest version", config.Version)
return
}
fmt.Printf("Updating to version %s...\n", latest.Version)
latest, err = ghselfupdate.UpdateSelf(v, config.GitRepo)
if err != nil {
fmt.Println("Update failed:", err)
return
}
if latest.Version.Equals(v) {
fmt.Println("Current binary is the latest version", config.Version)
} else {
fmt.Println("Successfully updated to version", latest.Version)
fmt.Println("Release note:\n", latest.ReleaseNotes)
}
fmt.Println("Successfully updated to version", latest.Version)
fmt.Println("Release note:\n", latest.ReleaseNotes)
},
}

View File

@@ -1,13 +1,14 @@
package tfile
package tdler
import (
"github.com/gotd/td/telegram/downloader"
"github.com/krau/SaveAny-Bot/common/utils/dlutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/consts/tglimit"
"github.com/krau/SaveAny-Bot/pkg/tfile"
)
func NewDownloader(file TGFile) *downloader.Builder {
func NewDownloader(file tfile.TGFile) *downloader.Builder {
return downloader.NewDownloader().WithPartSize(tglimit.MaxPartSize).
Download(file.Dler(), file.Location()).WithThreads(dlutil.BestThreads(file.Size(), config.C().Threads))
}

View File

@@ -7,56 +7,24 @@ import (
"net/http"
"net/url"
"sync"
"time"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/config"
"golang.org/x/net/proxy"
)
func NewProxyDialer(proxyUrl string) (proxy.Dialer, error) {
url, err := url.Parse(proxyUrl)
if err != nil {
return nil, err
}
return proxy.FromURL(url, proxy.Direct)
}
func NewProxyHTTPClient(proxyUrl string) (*http.Client, error) {
if proxyUrl == "" {
return &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
},
}, nil
return http.DefaultClient, nil
}
u, err := url.Parse(proxyUrl)
transport, err := NewProxyTransport(proxyUrl)
if err != nil {
return nil, err
}
switch u.Scheme {
case "http", "https":
return &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyURL(u),
},
}, nil
case "socks5":
dialer, err := proxy.FromURL(u, proxy.Direct)
if err != nil {
return nil, err
}
return &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.Dial(network, addr)
},
},
}, nil
default:
return nil, fmt.Errorf("unsupported proxy scheme: %s", u.Scheme)
}
return &http.Client{
Transport: transport,
}, nil
}
var (
@@ -76,3 +44,35 @@ func DefaultParserHTTPClient() *http.Client {
})
return defaultProxyHttpClient
}
func NewProxyTransport(proxyStr string) (*http.Transport, error) {
proxyURL, err := url.Parse(proxyStr)
if err != nil {
return nil, err
}
transport := &http.Transport{
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
switch proxyURL.Scheme {
case "http", "https":
transport.Proxy = http.ProxyURL(proxyURL)
case "socks5", "socks5h":
dialer, err := proxy.FromURL(proxyURL, proxy.Direct)
if err != nil {
return nil, err
}
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.(proxy.ContextDialer).DialContext(ctx, network, addr)
}
default:
return nil, fmt.Errorf("unsupported proxy type: %s", proxyURL.Scheme)
}
return transport, nil
}

View File

@@ -48,3 +48,46 @@ func ParseIntStrRange(input string, sep string) (int64, int64, error) {
}
return min, max, nil
}
func ParseArgsRespectQuotes(input string) []string {
var args []string
var current strings.Builder
inQuotes := false
escaped := false
for _, r := range input {
switch {
case escaped:
if r == '"' || r == '\\' {
current.WriteRune(r)
} else {
current.WriteRune('\\')
current.WriteRune(r)
}
escaped = false
case r == '\\':
escaped = true
case r == '"':
inQuotes = !inQuotes
case r == ' ' || r == '\t':
if inQuotes {
current.WriteRune(r)
} else if current.Len() > 0 {
args = append(args, current.String())
current.Reset()
}
default:
current.WriteRune(r)
}
}
if current.Len() > 0 {
args = append(args, current.String())
}
return args
}

View File

@@ -0,0 +1,148 @@
package strutil_test
import (
"reflect"
"testing"
"github.com/krau/SaveAny-Bot/common/utils/strutil"
)
func TestExtractTagsFromText(t *testing.T) {
tests := []struct {
text string
expected []string
}{
{
text: `初音ミクHappy 16th Birthday -Dear Creators-
✨エンドイラスト公開!✨
https://piapro.net/miku16thbd/
#初音ミク #miku16th`,
expected: []string{"初音ミク", "miku16th"},
},
{
text: `ひっつきむし
#創作百合`,
expected: []string{"創作百合"},
},
{
text: `#創作百合 #原创`,
expected: []string{"創作百合", "原创"},
},
{
text: `プラニャ #ブルアカ`,
expected: []string{"ブルアカ"},
},
{
text: `原神是一款#开放世界#冒险游戏,由中国著名游戏公司#miHoYo开发。`,
expected: []string{},
},
}
for _, test := range tests {
result := strutil.ExtractTagsFromText(test.text)
if !reflect.DeepEqual(result, test.expected) {
t.Fatalf("ExtractTagsFromText(%s) = %v, expected %v", test.text, result, test.expected)
}
}
}
func TestParseIntStrRange(t *testing.T) {
tests := []struct {
name string
input string
sep string
wantMin int64
wantMax int64
wantErr bool
}{
{
name: "normal range",
input: "10-20",
sep: "-",
wantMin: 10,
wantMax: 20,
},
{
name: "reverse order",
input: "30 - 10",
sep: "-",
wantMin: 10,
wantMax: 30,
},
{
name: "invalid format",
input: "10",
sep: "-",
wantErr: true,
},
{
name: "invalid number",
input: "a-b",
sep: "-",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
min, max, err := strutil.ParseIntStrRange(tt.input, tt.sep)
if (err != nil) != tt.wantErr {
t.Errorf("ParseIntStrRange(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
return
}
if !tt.wantErr {
if min != tt.wantMin || max != tt.wantMax {
t.Errorf("ParseIntStrRange(%q) = (%d, %d), want (%d, %d)", tt.input, min, max, tt.wantMin, tt.wantMax)
}
}
})
}
}
func TestParseArgsRespectQuotes(t *testing.T) {
tests := []struct {
name string
input string
want []string
}{
{
name: "simple split",
input: `/rule add FILENAME-REGEX (?i)\.(mp4|mkv)$ "我的 Alist" /视频`,
want: []string{"/rule", "add", "FILENAME-REGEX", "(?i)\\.(mp4|mkv)$", "我的 Alist", "/视频"},
},
{
name: "escaped quotes",
input: `/rule add "My \"Awesome\" Folder"`,
want: []string{"/rule", "add", `My "Awesome" Folder`},
},
{
name: "escaped backslash",
input: `/cmd "C:\\Users\\Admin" test`,
want: []string{"/cmd", `C:\Users\Admin`, "test"},
},
{
name: "multiple quoted parts",
input: `"Hello World" "你好 世界"`,
want: []string{"Hello World", "你好 世界"},
},
{
name: "unquoted words",
input: "a b c",
want: []string{"a", "b", "c"},
},
{
name: "mixed quotes and plain",
input: `cmd "quoted arg" plain`,
want: []string{"cmd", "quoted arg", "plain"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := strutil.ParseArgsRespectQuotes(tt.input)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("ParseArgsRespectQuotes(%q) = %#v, want %#v", tt.input, got, tt.want)
}
})
}
}

View File

@@ -9,12 +9,12 @@ import (
"github.com/celestix/gotgproto/ext"
"github.com/duke-git/lancet/v2/maputil"
"github.com/duke-git/lancet/v2/mathutil"
"github.com/duke-git/lancet/v2/slice"
lcstrutil "github.com/duke-git/lancet/v2/strutil"
"github.com/duke-git/lancet/v2/validator"
"github.com/gabriel-vasile/mimetype"
"github.com/gotd/td/constant"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common/cache"
"github.com/krau/SaveAny-Bot/common/utils/strutil"
@@ -112,6 +112,31 @@ func InputMessageClassSliceFromInt(ids []int) []tg.InputMessageClass {
}
func GetMessagesRange(ctx *ext.Context, chatID int64, minId, maxId int) ([]*tg.Message, error) {
if msg, err := getMessagesRange(ctx, chatID, minId, maxId); err == nil {
return msg, nil
}
in := constant.TDLibPeerID(chatID)
plain := in.ToPlain()
var channel constant.TDLibPeerID
channel.Channel(plain)
if msg, err := getMessagesRange(ctx, int64(channel), minId, maxId); err == nil {
return msg, nil
}
var userID constant.TDLibPeerID
userID.User(plain)
if msg, err := getMessagesRange(ctx, int64(userID), minId, maxId); err == nil {
return msg, nil
}
var chat constant.TDLibPeerID
chat.Chat(plain)
if msg, err := getMessagesRange(ctx, int64(chat), minId, maxId); err == nil {
return msg, nil
}
return nil, fmt.Errorf("failed to get messages range for chatID %d", chatID)
}
func getMessagesRange(ctx *ext.Context, chatID int64, minId, maxId int) ([]*tg.Message, error) {
if minId > maxId {
return nil, fmt.Errorf("minId (%d) cannot be greater than maxId (%d)", minId, maxId)
}
@@ -167,97 +192,98 @@ func GetMessagesRange(ctx *ext.Context, chatID int64, minId, maxId int) ([]*tg.M
return result, nil
}
type MessageItem struct {
Message *tg.Message
Error error
}
// [TODO]
// type MessageItem struct {
// Message *tg.Message
// Error error
// }
func IterMessages(ctx *ext.Context, chatID int64, minId, maxId int) (<-chan MessageItem, error) {
total := maxId - minId + 1
ch := make(chan MessageItem, 100)
// func IterMessages(ctx *ext.Context, chatID int64, minId, maxId int) (<-chan MessageItem, error) {
// total := maxId - minId + 1
// ch := make(chan MessageItem, 100)
go func() {
defer close(ch)
if !ctx.Self.Bot {
perr := ctx.PeerStorage.GetInputPeerById(chatID)
if perr == nil || perr.(*tg.InputPeerEmpty) != nil {
ch <- MessageItem{
Error: fmt.Errorf("peer not found: %d", chatID),
}
return
}
// go func() {
// defer close(ch)
// if !ctx.Self.Bot {
// perr := ctx.PeerStorage.GetInputPeerById(chatID)
// if perr == nil || perr.(*tg.InputPeerEmpty) != nil {
// ch <- MessageItem{
// Error: fmt.Errorf("peer not found: %d", chatID),
// }
// return
// }
for i := 0; i < total; i += 100 {
start := minId + i
end := min(start+100, maxId)
msgs, err := ctx.Raw.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{
Peer: perr,
OffsetID: start,
AddOffset: start - end,
Limit: 100,
})
if err != nil {
ch <- MessageItem{
Error: fmt.Errorf("failed to get messages: %w", err),
}
return
}
var msgClass []tg.MessageClass
switch msgsv := msgs.(type) {
case *tg.MessagesMessages:
msgClass = msgsv.GetMessages()
case *tg.MessagesMessagesSlice:
msgClass = msgsv.GetMessages()
case *tg.MessagesChannelMessages:
msgClass = msgsv.GetMessages()
default:
ch <- MessageItem{
Error: fmt.Errorf("unsupported message type: %T", msgsv),
}
continue
}
for _, msg := range msgClass {
msg, ok := msg.AsNotEmpty()
if !ok {
continue
}
switch msg := msg.(type) {
case *tg.Message:
key := fmt.Sprintf("tgmsg:%d:%d:%d", ctx.Self.ID, chatID, msg.GetID())
cache.Set(key, msg)
ch <- MessageItem{
Message: msg,
}
}
}
}
} else {
for i := 0; i < total; i += 100 {
start := minId + i
end := min(start+100, maxId)
msgs, err := GetMessagesRange(ctx, chatID, start, end)
if err != nil {
ch <- MessageItem{
Error: fmt.Errorf("failed to get messages: %w", err),
}
return
}
for _, msg := range msgs {
if msg == nil {
continue
}
ch <- MessageItem{
Message: msg,
}
}
}
}
}()
// for i := 0; i < total; i += 100 {
// start := minId + i
// end := min(start+100, maxId)
// msgs, err := ctx.Raw.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{
// Peer: perr,
// OffsetID: start,
// AddOffset: start - end,
// Limit: 100,
// })
// if err != nil {
// ch <- MessageItem{
// Error: fmt.Errorf("failed to get messages: %w", err),
// }
// return
// }
// var msgClass []tg.MessageClass
// switch msgsv := msgs.(type) {
// case *tg.MessagesMessages:
// msgClass = msgsv.GetMessages()
// case *tg.MessagesMessagesSlice:
// msgClass = msgsv.GetMessages()
// case *tg.MessagesChannelMessages:
// msgClass = msgsv.GetMessages()
// default:
// ch <- MessageItem{
// Error: fmt.Errorf("unsupported message type: %T", msgsv),
// }
// continue
// }
// for _, msg := range msgClass {
// msg, ok := msg.AsNotEmpty()
// if !ok {
// continue
// }
// switch msg := msg.(type) {
// case *tg.Message:
// key := fmt.Sprintf("tgmsg:%d:%d:%d", ctx.Self.ID, chatID, msg.GetID())
// cache.Set(key, msg)
// ch <- MessageItem{
// Message: msg,
// }
// }
// }
// }
// } else {
// for i := 0; i < total; i += 100 {
// start := minId + i
// end := min(start+100, maxId)
// msgs, err := GetMessagesRange(ctx, chatID, start, end)
// if err != nil {
// ch <- MessageItem{
// Error: fmt.Errorf("failed to get messages: %w", err),
// }
// return
// }
// for _, msg := range msgs {
// if msg == nil {
// continue
// }
// ch <- MessageItem{
// Message: msg,
// }
// }
// }
// }
// }()
return ch, nil
}
// return ch, nil
// }
func GetMessageByID(ctx *ext.Context, chatID int64, msgID int) (*tg.Message, error) {
func getMessageByID(ctx *ext.Context, chatID int64, msgID int) (*tg.Message, error) {
key := fmt.Sprintf("tgmsg:%d:%d:%d", ctx.Self.ID, chatID, msgID)
if msg, ok := cache.Get[*tg.Message](key); ok {
return msg, nil
@@ -280,6 +306,33 @@ func GetMessageByID(ctx *ext.Context, chatID int64, msgID int) (*tg.Message, err
return tgm, nil
}
// f**k gotgproto's breaking changes
func GetMessageByID(ctx *ext.Context, chatID int64, msgID int) (*tg.Message, error) {
// we don't know what the input chatID is bot api style(e.g. channel with -100 prefix) or plain tdlib style(no any prefix and every id is positive)
if msg, err := getMessageByID(ctx, chatID, msgID); err == nil {
return msg, nil
}
in := constant.TDLibPeerID(chatID)
plain := in.ToPlain()
var channel constant.TDLibPeerID
channel.Channel(plain)
if msg, err := getMessageByID(ctx, int64(channel), msgID); err == nil {
return msg, nil
}
var chat constant.TDLibPeerID
chat.Chat(plain)
if msg, err := getMessageByID(ctx, int64(chat), msgID); err == nil {
return msg, nil
}
var userID constant.TDLibPeerID
userID.User(plain)
if msg, err := getMessageByID(ctx, int64(userID), msgID); err == nil {
return msg, nil
}
return nil, fmt.Errorf("failed to get message by ID: chatID=%d, msgID=%d", chatID, msgID)
}
func GetGroupedMessages(ctx *ext.Context, chatID int64, msg *tg.Message) ([]*tg.Message, error) {
groupID, isGroup := msg.GetGroupedID()
if !isGroup || groupID == 0 {

View File

@@ -0,0 +1,41 @@
package tgutil
import (
"net/url"
"github.com/gotd/td/telegram/dcs"
"github.com/krau/SaveAny-Bot/config"
"golang.org/x/net/proxy"
)
func newProxyDialer(proxyUrl string) (proxy.Dialer, error) {
url, err := url.Parse(proxyUrl)
if err != nil {
return nil, err
}
return proxy.FromURL(url, proxy.Direct)
}
func NewConfigProxyResolver() (dcs.Resolver, error) {
resolver := dcs.DefaultResolver()
if config.C().Proxy != "" {
// gloabl proxy, which has lower priority
dialer, err := newProxyDialer(config.C().Proxy)
if err != nil {
return nil, err
}
resolver = dcs.Plain(dcs.PlainOptions{
Dial: dialer.(proxy.ContextDialer).DialContext,
})
}
if config.C().Telegram.Proxy.Enable && config.C().Telegram.Proxy.URL != "" {
dialer, err := newProxyDialer(config.C().Telegram.Proxy.URL)
if err != nil {
return nil, err
}
resolver = dcs.Plain(dcs.PlainOptions{
Dial: dialer.(proxy.ContextDialer).DialContext,
})
}
return resolver, nil
}

View File

@@ -22,7 +22,7 @@ url = "socks5://127.0.0.1:7890"
[[storages]]
# 标识名, 需要唯一
name = "本机1"
# 存储类型, 目前可用: local, alist, webdav, minio, telegram
# 存储类型, 目前可用: local, alist, webdav, s3, telegram
type = "local"
# 启用存储
enable = true

View File

@@ -14,6 +14,7 @@ var storageFactories = map[storenum.StorageType]func(cfg *BaseConfig) (StorageCo
storenum.Alist: createStorageConfig(&AlistStorageConfig{}),
storenum.Webdav: createStorageConfig(&WebdavStorageConfig{}),
storenum.Minio: createStorageConfig(&MinioStorageConfig{}),
storenum.S3: createStorageConfig(&S3StorageConfig{}),
storenum.Telegram: createStorageConfig(&TelegramStorageConfig{}),
}

42
config/storage/s3.go Normal file
View File

@@ -0,0 +1,42 @@
package storage
import (
"fmt"
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
)
type S3StorageConfig struct {
BaseConfig
Endpoint string `toml:"endpoint" mapstructure:"endpoint" json:"endpoint"`
AccessKeyID string `toml:"access_key_id" mapstructure:"access_key_id" json:"access_key_id"`
SecretAccessKey string `toml:"secret_access_key" mapstructure:"secret_access_key" json:"secret_access_key"`
BucketName string `toml:"bucket_name" mapstructure:"bucket_name" json:"bucket_name"`
UseSSL bool `toml:"use_ssl" mapstructure:"use_ssl" json:"use_ssl"`
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
Region string `toml:"region" mapstructure:"region" json:"region"`
}
func (m *S3StorageConfig) Validate() error {
if m.Endpoint == "" {
return fmt.Errorf("endpoint is required for s3 storage")
}
if m.AccessKeyID == "" || m.SecretAccessKey == "" {
return fmt.Errorf("access_key_id and secret_access_key are required for s3 storage")
}
if m.BucketName == "" {
return fmt.Errorf("bucket_name is required for s3 storage")
}
if m.BasePath == "" {
return fmt.Errorf("base_path is required for s3 storage")
}
return nil
}
func (m *S3StorageConfig) GetType() storenum.StorageType {
return storenum.S3
}
func (m *S3StorageConfig) GetName() string {
return m.Name
}

View File

@@ -1,12 +1,13 @@
package config
type telegramConfig struct {
Token string `toml:"token" mapstructure:"token"`
AppID int `toml:"app_id" mapstructure:"app_id" json:"app_id"`
AppHash string `toml:"app_hash" mapstructure:"app_hash" json:"app_hash"`
Proxy tgProxyConfig `toml:"proxy" mapstructure:"proxy"`
RpcRetry int `toml:"rpc_retry" mapstructure:"rpc_retry" json:"rpc_retry"`
Userbot userbotConfig `toml:"userbot" mapstructure:"userbot" json:"userbot"`
Token string `toml:"token" mapstructure:"token"`
AppID int `toml:"app_id" mapstructure:"app_id" json:"app_id"`
AppHash string `toml:"app_hash" mapstructure:"app_hash" json:"app_hash"`
Proxy tgProxyConfig `toml:"proxy" mapstructure:"proxy"`
RpcRetry int `toml:"rpc_retry" mapstructure:"rpc_retry" json:"rpc_retry"`
Userbot userbotConfig `toml:"userbot" mapstructure:"userbot" json:"userbot"`
MediaGroupTimeout int `toml:"media_group_timeout" mapstructure:"media_group_timeout" json:"media_group_timeout"`
}
type userbotConfig struct {

View File

@@ -4,13 +4,18 @@ import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/duke-git/lancet/v2/slice"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/config/storage"
"github.com/spf13/viper"
"golang.org/x/net/proxy"
)
type Config struct {
@@ -20,6 +25,7 @@ type Config struct {
NoCleanCache bool `toml:"no_clean_cache" mapstructure:"no_clean_cache" json:"no_clean_cache"`
Threads int `toml:"threads" mapstructure:"threads" json:"threads"`
Stream bool `toml:"stream" mapstructure:"stream" json:"stream"`
Proxy string `toml:"proxy" mapstructure:"proxy" json:"proxy"`
Cache cacheConfig `toml:"cache" mapstructure:"cache" json:"cache"`
Users []userConfig `toml:"users" mapstructure:"users" json:"users"`
@@ -147,5 +153,43 @@ func Init(ctx context.Context) error {
userStorages[user.ID] = user.Storages
}
}
if cfg.Proxy != "" {
http.DefaultTransport, err = newProxyTransport(cfg.Proxy)
if err != nil {
return fmt.Errorf("failed to create proxy transport: %w", err)
}
}
return nil
}
func newProxyTransport(proxyStr string) (*http.Transport, error) {
proxyURL, err := url.Parse(proxyStr)
if err != nil {
return nil, err
}
transport := &http.Transport{
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
switch proxyURL.Scheme {
case "http", "https":
transport.Proxy = http.ProxyURL(proxyURL)
case "socks5", "socks5h":
dialer, err := proxy.FromURL(proxyURL, proxy.Direct)
if err != nil {
return nil, err
}
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.(proxy.ContextDialer).DialContext(ctx, network, addr)
}
default:
return nil, fmt.Errorf("unsupported proxy type: %s", proxyURL.Scheme)
}
return transport, nil
}

View File

@@ -9,11 +9,11 @@ import (
"github.com/charmbracelet/log"
"github.com/duke-git/lancet/v2/retry"
"github.com/krau/SaveAny-Bot/common/tdler"
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
"github.com/krau/SaveAny-Bot/common/utils/ioutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
"github.com/krau/SaveAny-Bot/pkg/tfile"
"golang.org/x/sync/errgroup"
)
@@ -68,7 +68,7 @@ func (t *Task) processElement(ctx context.Context, elem TaskElement) error {
errg.Go(func() error {
defer pw.Close()
logger.Info("Starting file download in stream mode")
_, err := tfile.NewDownloader(elem.File).Stream(uploadCtx, wr)
_, err := tdler.NewDownloader(elem.File).Stream(uploadCtx, wr)
if err != nil {
logger.Errorf("Failed to download file: %v", err)
pw.CloseWithError(err)
@@ -95,7 +95,7 @@ func (t *Task) processElement(ctx context.Context, elem TaskElement) error {
t.downloaded.Add(int64(n))
t.Progress.OnProgress(ctx, t)
})
_, err = tfile.NewDownloader(elem.File).Parallel(ctx, wrAt)
_, err = tdler.NewDownloader(elem.File).Parallel(ctx, wrAt)
if err != nil {
return fmt.Errorf("failed to download file: %w", err)
}

View File

@@ -11,7 +11,6 @@ import (
"github.com/duke-git/lancet/v2/retry"
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
"github.com/krau/SaveAny-Bot/config"
"go.uber.org/multierr"
"golang.org/x/sync/errgroup"
)
@@ -48,13 +47,10 @@ func (t *Task) processPic(ctx context.Context, picUrl string, index int) error {
retry.Context(ctx),
retry.RetryTimes(uint(config.C().Retry)),
}
var lastErr error
err := retry.Retry(func() error {
var body io.ReadCloser
body, lastErr = t.client.Download(ctx, picUrl)
if lastErr != nil {
lastErr = fmt.Errorf("failed to download picture %s: %w", picUrl, lastErr)
return lastErr
body, err := t.client.Download(ctx, picUrl)
if err != nil {
return fmt.Errorf("failed to download picture %s: %w", picUrl, err)
}
defer body.Close()
filename := fmt.Sprintf("%d%s", index+1, path.Ext(picUrl))
@@ -63,8 +59,7 @@ func (t *Task) processPic(ctx context.Context, picUrl string, index int) error {
fmt.Sprintf("tph_%s_%s", t.TaskID(), filename),
))
if err != nil {
lastErr = fmt.Errorf("failed to create cache file for picture %s: %w", filename, err)
return lastErr
return fmt.Errorf("failed to create cache file for picture %s: %w", filename, err)
}
defer func() {
if err := cacheFile.CloseAndRemove(); err != nil {
@@ -72,26 +67,26 @@ func (t *Task) processPic(ctx context.Context, picUrl string, index int) error {
logger.Errorf("Failed to close and remove cache file for picture %s: %v", filename, err)
}
}()
_, lastErr = io.Copy(cacheFile, body)
if lastErr != nil {
lastErr = fmt.Errorf("failed to copy picture %s to cache file: %w", filename, lastErr)
return lastErr
_, err = io.Copy(cacheFile, body)
if err != nil {
return fmt.Errorf("failed to copy picture %s to cache file: %w", filename, err)
}
_, err = cacheFile.Seek(0, 0)
if err != nil {
lastErr = fmt.Errorf("failed to seek cache file for picture %s: %w", filename, err)
return lastErr
return fmt.Errorf("failed to seek cache file for picture %s: %w", filename, err)
}
err = t.Stor.Save(ctx, cacheFile, path.Join(t.StorPath, filename))
if err != nil {
return fmt.Errorf("failed to save picture %s: %w", filename, err)
}
lastErr = t.Stor.Save(ctx, cacheFile, path.Join(t.StorPath, filename))
} else {
lastErr = t.Stor.Save(ctx, body, path.Join(t.StorPath, filename))
err = t.Stor.Save(ctx, body, path.Join(t.StorPath, filename))
}
if lastErr != nil {
lastErr = fmt.Errorf("failed to save picture %s: %w", filename, lastErr)
return lastErr
if err != nil {
return fmt.Errorf("failed to save picture %s: %w", filename, err)
}
return nil
}, retryOpts...)
return multierr.Combine(err, lastErr)
return err
}

View File

@@ -5,13 +5,13 @@ import (
"fmt"
"os"
"path"
"time"
"github.com/charmbracelet/log"
"github.com/duke-git/lancet/v2/retry"
"github.com/krau/SaveAny-Bot/common/tdler"
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
"github.com/krau/SaveAny-Bot/pkg/tfile"
)
func (t *Task) Execute(ctx context.Context) error {
@@ -40,7 +40,7 @@ func (t *Task) Execute(ctx context.Context) error {
t.Progress.OnDone(ctx, t, err)
}
}()
_, err = tfile.NewDownloader(t.File).Parallel(ctx, wrAt)
_, err = tdler.NewDownloader(t.File).Parallel(ctx, wrAt)
if err != nil {
return fmt.Errorf("failed to download file: %w", err)
}
@@ -57,30 +57,19 @@ func (t *Task) Execute(ctx context.Context) error {
return fmt.Errorf("failed to get file stat: %w", err)
}
vctx := context.WithValue(ctx, ctxkey.ContentLength, fileStat.Size())
for i := range config.C().Retry + 1 {
if err = vctx.Err(); err != nil {
return fmt.Errorf("context canceled while saving file: %w", err)
}
var file *os.File
file, err = os.Open(t.localPath)
err = retry.Retry(func() error {
file, err := os.Open(t.localPath)
if err != nil {
return fmt.Errorf("failed to open cache file: %w", err)
}
defer file.Close()
if err = t.Storage.Save(vctx, file, t.Path); err != nil {
if i == config.C().Retry {
return fmt.Errorf("failed to save file: %w", err)
}
logger.Errorf("Failed to save file: %s, retrying...", err)
select {
case <-vctx.Done():
return fmt.Errorf("context canceled during retry delay: %w", vctx.Err())
case <-time.After(time.Duration(i*500) * time.Millisecond):
}
continue
return fmt.Errorf("failed to save file: %w", err)
}
return nil
}, retry.RetryTimes(uint(config.C().Retry)), retry.Context(vctx))
if err != nil {
return fmt.Errorf("failed to save file after retries: %w", err)
}
return fmt.Errorf("failed to save file after retries")
return nil
}

View File

@@ -6,7 +6,7 @@ import (
"io"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/pkg/tfile"
"github.com/krau/SaveAny-Bot/common/tdler"
"golang.org/x/sync/errgroup"
)
@@ -23,7 +23,7 @@ func executeStream(ctx context.Context, task *Task) error {
errg.Go(func() error {
defer pw.Close()
logger.Info("Starting file download in stream mode")
_, err := tfile.NewDownloader(task.File).Stream(uploadCtx, wr)
_, err := tdler.NewDownloader(task.File).Stream(uploadCtx, wr)
if err != nil {
logger.Errorf("Failed to download file: %v", err)
pw.CloseWithError(err)

View File

@@ -9,6 +9,7 @@ type User struct {
ChatID int64 `gorm:"uniqueIndex;not null"`
Silent bool
DefaultStorage string
DefaultDir uint // Dir.ID
Dirs []Dir
ApplyRule bool
Rules []Rule

View File

@@ -21,7 +21,7 @@ Save Any Bot is a tool that allows you to save files from Telegram to various st
- Automatic organization based on storage rules
- Supports multiple storage backends:
- Alist
- Minio (S3 compatible)
- S3
- WebDAV
- Telegram (re-upload to specified chat)
- Local disk

View File

@@ -79,7 +79,7 @@ Each storage endpoint requires at least the following fields:
- `local`: Local disk
- `alist`: Alist
- `webdav`: WebDAV
- `minio`: MinIO (compatible with S3 API)
- `s3`: aws S3 and other S3 compatible services
- `telegram`: Upload to Telegram
Example, this is a configuration that includes local storage and webdav storage:

View File

@@ -41,17 +41,18 @@ password = "your_password" # Password for WebDAV
base_path = "/path/to/webdav" # Base path in WebDAV, all files will be stored under this path
```
## MinIO (S3)
## S3
`type=minio`
`type=s3`
```toml
endpoint = "minio.example.com" # Endpoint for MinIO or S3
access_key_id = "your_access_key_id" # Access key ID for MinIO or S3
secret_access_key = "your_secret_access_key" # Secret access key for MinIO or S3
bucket_name = "your_bucket_name" # Bucket name for MinIO or S3
endpoint = "s3.example.com" # Endpoint for S3
region = "us-east-1" # Region for S3
access_key_id = "your_access_key_id" # Access key ID for S3
secret_access_key = "your_secret_access_key" # Secret access key for S3
bucket_name = "your_bucket_name" # Bucket name for S3
use_ssl = true # Whether to use SSL, default is true
base_path = "/path/to/minio" # Base path in MinIO, all files will be stored under this path
base_path = "/path/to/s3" # Base path in S3, all files will be stored under this path
```
## Telegram

View File

@@ -23,7 +23,7 @@ title: 介绍
- 使用 js 编写解析器插件以转存任意网站的文件
- 存储端支持:
- Alist
- S3 (MinioSDK)
- S3
- WebDAV
- 本地磁盘
- Telegram (重传回指定聊天)

View File

@@ -93,7 +93,7 @@ session = "data/usersession.db"
- `local`: 本地磁盘
- `alist`: Alist
- `webdav`: WebDAV
- `minio`: MinIO (兼容 S3 API)
- `s3`: aws S3 及其他兼容 S3 的服务
- `telegram`: 上传到 Telegram
示例, 这是一个包含本地存储和 webdav 存储的配置:

View File

@@ -41,17 +41,18 @@ password = "your_password" # WebDAV 的密码
base_path = "/path/to/webdav" # WebDAV 中的基础路径, 所有文件将存储在此路径下
```
## MinIO (S3)
## S3
`type=minio`
`type=s3`
```toml
endpoint = "minio.example.com" # MinIO 或 S3 的端点
access_key_id = "your_access_key_id" # MinIO 或 S3 的访问密钥 ID
secret_access_key = "your_secret_access_key" # MinIO 或 S3 的秘密访问密钥
bucket_name = "your_bucket_name" # MinIO 或 S3 的存储桶名称
endpoint = "s3.example.com" # S3 的端点
region = "us-east-1" # S3 的区域
access_key_id = "your_access_key_id" # S3 的访问密钥 ID
secret_access_key = "your_secret_access_key" # S3 的秘密访问密钥
bucket_name = "your_bucket_name" # S3 的存储桶名称
use_ssl = true # 是否使用 SSL, 默认为 true
base_path = "/path/to/minio" # MinIO 中的基础路径, 所有文件将存储在此路径下
base_path = "/path/to/s3" # S3 中的基础路径, 所有文件将存储在此路径下
```
## Telegram

View File

@@ -11,7 +11,7 @@ if [ -n "$CONFIG_URL" ]; then
fi
if [ ! -f /app/config.toml ]; then
echo "[ERROR] Missing config.toml: 请通过挂载或 CONFIG_URL 提供配置文件"
echo "[ERROR] Missing config.toml: Please provide the configuration file via mounting or CONFIG_URL"
exit 1
fi

60
go.mod
View File

@@ -3,46 +3,61 @@ module github.com/krau/SaveAny-Bot
go 1.24.0
require (
github.com/aws/aws-sdk-go-v2 v1.40.1
github.com/aws/aws-sdk-go-v2/config v1.32.3
github.com/aws/aws-sdk-go-v2/credentials v1.19.3
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.0
github.com/blang/semver v3.5.1+incompatible
github.com/celestix/gotgproto v1.0.0-beta22
github.com/cenkalti/backoff/v4 v4.3.0
github.com/charmbracelet/huh v0.8.0
github.com/charmbracelet/log v0.4.2
github.com/fatih/color v1.18.0
github.com/gabriel-vasile/mimetype v1.4.10
github.com/goccy/go-yaml v1.18.0
github.com/gotd/contrib v0.21.1
github.com/gotd/td v0.132.0
github.com/krau/ffmpeg-go v0.6.0
github.com/minio/minio-go/v7 v7.0.95
github.com/playwright-community/playwright-go v0.5200.1
github.com/rhysd/go-github-selfupdate v1.2.3
github.com/rs/xid v1.6.0
github.com/spf13/cobra v1.10.1
github.com/spf13/viper v1.21.0
golang.org/x/net v0.46.0
github.com/unvgo/ghselfupdate v1.0.0
github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c
golang.org/x/net v0.47.0
golang.org/x/term v0.37.0
golang.org/x/time v0.14.0
)
require (
github.com/AnimeKaizoku/cacher v1.0.3 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.15 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.15 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.15 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.15 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.3 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.6 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.11 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.3 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.3.2 // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.10.2 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20251023181713-f594ac034d6b // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/deckarep/golang-set/v2 v2.7.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/ghodss/yaml v1.0.0 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-faster/errors v0.7.1 // indirect
@@ -69,26 +84,20 @@ require (
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/ncruces/julianday v1.0.0 // indirect
github.com/ogen-go/ogen v1.16.0 // indirect
github.com/onsi/gomega v1.36.2 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/tcnksm/go-gitconfig v0.1.2 // indirect
github.com/tetratelabs/wazero v1.9.0 // indirect
github.com/tetratelabs/wazero v1.10.1 // indirect
github.com/tinylib/msgp v1.4.0 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
@@ -97,9 +106,8 @@ require (
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.43.0 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/oauth2 v0.32.0 // indirect
golang.org/x/tools v0.38.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
modernc.org/libc v1.66.10 // indirect
@@ -118,8 +126,8 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.18.1 // indirect
github.com/mitchellh/mapstructure v1.5.0
github.com/ncruces/go-sqlite3 v0.29.1
github.com/ncruces/go-sqlite3/gormlite v0.24.0
github.com/ncruces/go-sqlite3 v0.30.1
github.com/ncruces/go-sqlite3/gormlite v0.30.1
github.com/nicksnyder/go-i18n/v2 v2.6.0
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
@@ -128,10 +136,10 @@ require (
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.uber.org/multierr v1.11.0
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/sync v0.17.0
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0
gorm.io/gorm v1.31.0
golang.org/x/sync v0.18.0
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0
gorm.io/gorm v1.31.1
)

155
go.sum
View File

@@ -2,34 +2,58 @@ github.com/AnimeKaizoku/cacher v1.0.3 h1:foNAmLfY/DXfA4yEy4uP6WK2Ni7JC+s3QhZv72D
github.com/AnimeKaizoku/cacher v1.0.3/go.mod h1:jw0de/b0K6W7Y3T9rHCMGVKUf6oG7hENNcssxYcZTCc=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aws/aws-sdk-go-v2 v1.40.1 h1:difXb4maDZkRH0x//Qkwcfpdg1XQVXEAEs2DdXldFFc=
github.com/aws/aws-sdk-go-v2 v1.40.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
github.com/aws/aws-sdk-go-v2/config v1.32.3 h1:cpz7H2uMNTDa0h/5CYL5dLUEzPSLo2g0NkbxTRJtSSU=
github.com/aws/aws-sdk-go-v2/config v1.32.3/go.mod h1:srtPKaJJe3McW6T/+GMBZyIPc+SeqJsNPJsd4mOYZ6s=
github.com/aws/aws-sdk-go-v2/credentials v1.19.3 h1:01Ym72hK43hjwDeJUfi1l2oYLXBAOR8gNSZNmXmvuas=
github.com/aws/aws-sdk-go-v2/credentials v1.19.3/go.mod h1:55nWF/Sr9Zvls0bGnWkRxUdhzKqj9uRNlPvgV1vgxKc=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.15 h1:utxLraaifrSBkeyII9mIbVwXXWrZdlPO7FIKmyLCEcY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.15/go.mod h1:hW6zjYUDQwfz3icf4g2O41PHi77u10oAzJ84iSzR/lo=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.15 h1:Y5YXgygXwDI5P4RkteB5yF7v35neH7LfJKBG+hzIons=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.15/go.mod h1:K+/1EpG42dFSY7CBj+Fruzm8PsCGWTXJ3jdeJ659oGQ=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.15 h1:AvltKnW9ewxX2hFmQS0FyJH93aSvJVUEFvXfU+HWtSE=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.15/go.mod h1:3I4oCdZdmgrREhU74qS1dK9yZ62yumob+58AbFR4cQA=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.15 h1:NLYTEyZmVZo0Qh183sC8nC+ydJXOOeIL/qI/sS3PdLY=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.15/go.mod h1:Z803iB3B0bc8oJV8zH2PERLRfQUJ2n2BXISpsA4+O1M=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.6 h1:P1MU/SuhadGvg2jtviDXPEejU3jBNhoeeAlRadHzvHI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.6/go.mod h1:5KYaMG6wmVKMFBSfWoyG/zH8pWwzQFnKgpoSRlXHKdQ=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.15 h1:3/u/4yZOffg5jdNk1sDpOQ4Y+R6Xbh+GzpDrSZjuy3U=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.15/go.mod h1:4Zkjq0FKjE78NKjabuM4tRXKFzUJWXgP0ItEZK8l7JU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.15 h1:wsSQ4SVz5YE1crz0Ap7VBZrV4nNqZt4CIBBT8mnwoNc=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.15/go.mod h1:I7sditnFGtYMIqPRU1QoHZAUrXkGp4SczmlLwrNPlD0=
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.0 h1:IrbE3B8O9pm3lsg96AXIN5MXX4pECEuExh/A0Du3AuI=
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.0/go.mod h1:/sJLzHtiiZvs6C1RbxS/anSAFwZD6oC6M/kotQzOiLw=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.3 h1:d/6xOGIllc/XW1lzG9a4AUBMmpLA9PXcQnVPTuHHcik=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.3/go.mod h1:fQ7E7Qj9GiW8y0ClD7cUJk3Bz5Iw8wZkWDHsTe8vDKs=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.6 h1:8sTTiw+9yuNXcfWeqKF2x01GqCF49CpP4Z9nKrrk/ts=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.6/go.mod h1:8WYg+Y40Sn3X2hioaaWAAIngndR8n1XFdRPPX+7QBaM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.11 h1:E+KqWoVsSrj1tJ6I/fjDIu5xoS2Zacuu1zT+H7KtiIk=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.11/go.mod h1:qyWHz+4lvkXcr3+PoGlGHEI+3DLLiU6/GdrFfMaAhB0=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.3 h1:tzMkjh0yTChUqJDgGkcDdxvZDSrJ/WB6R6ymI5ehqJI=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.3/go.mod h1:T270C0R5sZNLbWUe8ueiAF42XSZxxPocTaGSgs5c/60=
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/celestix/gotgproto v1.0.0-beta22 h1:Iu78cFA08nV8+flmxKs9CJ3W73+HG30fx0nLOs5A6fI=
github.com/celestix/gotgproto v1.0.0-beta22/go.mod h1:JYC9Js/5KLUhFR5M2RslQi2DFAcF7EdrgJMXo0YrzGQ=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI=
github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI=
github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY=
github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
@@ -38,27 +62,13 @@ github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvA
github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8=
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/strings v0.0.0-20251023181713-f594ac034d6b h1:LUXEpSryQXJyP2lwAi/vBto9n+cJzlXSIefnCol3FVw=
github.com/charmbracelet/x/exp/strings v0.0.0-20251023181713-f594ac034d6b/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -76,13 +86,10 @@ github.com/duke-git/lancet/v2 v2.3.7 h1:nnNBA9KyoqwbPm4nFmEFVIbXeAmpqf6IDCH45+HH
github.com/duke-git/lancet/v2 v2.3.7/go.mod h1:zGa2R4xswg6EG9I6WnyubDbFO/+A/RROxIbXcwryTsc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
@@ -122,7 +129,6 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
@@ -145,7 +151,6 @@ github.com/gotd/neo v0.1.5 h1:oj0iQfMbGClP8xI59x7fE/uHoTJD7NZH9oV1WNuPukQ=
github.com/gotd/neo v0.1.5/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ=
github.com/gotd/td v0.132.0 h1:Iqm3S2b+8kDgA9237IDXRxj7sryUpvy+4Cr50/0tpx4=
github.com/gotd/td v0.132.0/go.mod h1:4CDGYS+rDtOqotRheGaF9MS5g6jaUewvSXqBNJnx8SQ=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7VjIE6z8dIvMsI4/s+1qr5EL+zoIGev1BQj1eoJ8=
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -159,21 +164,18 @@ github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdB
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/krau/ffmpeg-go v0.6.0 h1:F4HWvOrKXQsfLsFTOnUfP0HY6WISJqOrsAFGSIzkKto=
github.com/krau/ffmpeg-go v0.6.0/go.mod h1:sa7/bWHB6fO9j4lhmxnWQ1U07o+dE1leFjhctotxU7A=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
@@ -184,20 +186,14 @@ github.com/minio/minio-go/v7 v7.0.95 h1:ywOUPg+PebTMTzn9VDsoFJy32ZuARN9zhB+K3IYE
github.com/minio/minio-go/v7 v7.0.95/go.mod h1:wOOX3uxS334vImCNRVyIDdXX9OsXDm89ToynKgqUKlo=
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-sqlite3 v0.29.1 h1:NIi8AISWBToRHyoz01FXiTNvU147Tqdibgj2tFzJCqM=
github.com/ncruces/go-sqlite3 v0.29.1/go.mod h1:PpccBNNhvjwUOwDQEn2gXQPFPTWdlromj0+fSkd5KSg=
github.com/ncruces/go-sqlite3/gormlite v0.24.0 h1:81sHeq3CCdhjoqAB650n5wEdRlLO9VBvosArskcN3+c=
github.com/ncruces/go-sqlite3/gormlite v0.24.0/go.mod h1:vXfVWdBfg7qOgqQqHpzUWl9LLswD0h+8mK4oouaV2oc=
github.com/ncruces/go-sqlite3 v0.30.1 h1:pHC3YsyRdJv4pCMB4MO1Q2BXw/CAa+Hoj7GSaKtVk+g=
github.com/ncruces/go-sqlite3 v0.30.1/go.mod h1:UVsWrQaq1qkcal5/vT5lOJnZCVlR5rsThKdwidjFsKc=
github.com/ncruces/go-sqlite3/gormlite v0.30.1 h1:kApjSKrepgmhtx63KMeD8aUoz1l4aJT4fkoBmHSsRns=
github.com/ncruces/go-sqlite3/gormlite v0.30.1/go.mod h1:zgFibXnnKek3qMHd/2A1OtfDqbN7ae+H80aMX+487As=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
@@ -206,10 +202,6 @@ github.com/nicksnyder/go-i18n/v2 v2.6.0 h1:C/m2NNWNiTB6SK4Ao8df5EWm3JETSTIGNXBpM
github.com/nicksnyder/go-i18n/v2 v2.6.0/go.mod h1:88sRqr0C6OPyJn0/KRNaEz1uWorjxIKP7rUUcvycecE=
github.com/ogen-go/ogen v1.16.0 h1:fKHEYokW/QrMzVNXId74/6RObRIUs9T2oroGKtR25Iw=
github.com/ogen-go/ogen v1.16.0/go.mod h1:s3nWiMzybSf8fhxckyO+wtto92+QHpEL8FmkPnhL3jI=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8=
github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
@@ -222,8 +214,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rhysd/go-github-selfupdate v1.2.3 h1:iaa+J202f+Nc+A8zi75uccC8Wg3omaM7HDeimXA22Ag=
github.com/rhysd/go-github-selfupdate v1.2.3/go.mod h1:mp/N8zj6jFfBQy/XMYoWsmfzxazpPAODuqarmPDe2Rg=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -249,22 +239,24 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tcnksm/go-gitconfig v0.1.2 h1:iiDhRitByXAEyjgBqsKi9QU4o2TNtv9kPP3RgPgXBPw=
github.com/tcnksm/go-gitconfig v0.1.2/go.mod h1:/8EhP4H7oJZdIPyT+/UIsG87kTzrzM4UsLGSItWYCpE=
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
github.com/tetratelabs/wazero v1.10.1 h1:2DugeJf6VVk58KTPszlNfeeN8AhhpwcZqkJj2wwFuH8=
github.com/tetratelabs/wazero v1.10.1/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU=
github.com/tinylib/msgp v1.4.0 h1:SYOeDRiydzOw9kSiwdYp9UcBgPFtLU2WDHaJXyHruf8=
github.com/tinylib/msgp v1.4.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o=
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/unvgo/ghselfupdate v1.0.0 h1:XNdk2sr9ESgmVWlUj6y3XQnutczv2SLToCBENtUkIYE=
github.com/unvgo/ghselfupdate v1.0.0/go.mod h1:3snWV5vEHGXQqqhY7FwKjPOtH6e7cFdHYN7UMAihhxs=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c h1:xA2TJS9Hu/ivzaZIrDcwvpJ3Fnpsk5fDOJ4iSnL6J0w=
github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c/go.mod h1:WSZ59bidJOO40JSJmLqlkBJrjZCtjbKKkygEMfzY/kc=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
@@ -285,68 +277,57 @@ go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -358,21 +339,17 @@ golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=

View File

@@ -1,19 +1,17 @@
package parsers
package js
import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"sync"
"github.com/blang/semver"
"github.com/charmbracelet/log"
"github.com/dop251/goja"
"github.com/krau/SaveAny-Bot/common/utils/netutil"
"github.com/playwright-community/playwright-go"
"github.com/krau/SaveAny-Bot/parsers/parsers"
)
func jsRegisterParser(vm *goja.Runtime) func(call goja.FunctionCall) goja.Value {
@@ -57,7 +55,7 @@ func jsRegisterParser(vm *goja.Runtime) func(call goja.FunctionCall) goja.Value
if parseFn == nil || goja.IsUndefined(parseFn) {
return vm.NewGoError(errors.New("parser must provide a parse function"))
}
AddParser(newJSParser(vm, handleFn, parseFn, metadata))
parsers.Add(newJSParser(vm, handleFn, parseFn, metadata))
return goja.Undefined()
}
}
@@ -173,74 +171,3 @@ var jsGhttp = func(vm *goja.Runtime) *goja.Object {
})
return ghttp
}
var jsPlaywright = func(vm *goja.Runtime, logger *log.Logger) *goja.Object {
pwObj := vm.NewObject()
var installOnce sync.Once
slogger := slog.New(logger)
pwObj.Set("get", func(call goja.FunctionCall) goja.Value {
url := call.Argument(0).String()
var installErr error
installOnce.Do(func() {
installErr = playwright.Install(&playwright.RunOptions{
Browsers: []string{"chromium"},
DriverDirectory: "./playwright",
Logger: slogger,
})
})
if installErr != nil {
return vm.ToValue(map[string]any{
"error": fmt.Sprintf("failed to install playwright: %v", installErr),
})
}
pw, err := playwright.Run(&playwright.RunOptions{
DriverDirectory: "./playwright",
Logger: slogger,
})
if err != nil {
return vm.ToValue(map[string]any{
"error": fmt.Sprintf("failed to start playwright: %v", err),
})
}
defer pw.Stop()
browser, err := pw.Chromium.Launch()
if err != nil {
return vm.ToValue(map[string]any{
"error": fmt.Sprintf("failed to launch browser: %v", err),
})
}
defer browser.Close()
page, err := browser.NewPage()
if err != nil {
return vm.ToValue(map[string]any{
"error": fmt.Sprintf("failed to create page: %v", err),
})
}
resp, err := page.Goto(url, playwright.PageGotoOptions{
WaitUntil: playwright.WaitUntilStateNetworkidle,
Timeout: playwright.Float(60000),
})
if err != nil {
return vm.ToValue(map[string]any{
"error": fmt.Sprintf("failed to navigate: %v", err),
})
}
if resp != nil && resp.Status() >= 400 {
return vm.ToValue(map[string]any{
"error": fmt.Sprintf("bad status code: %d", resp.Status()),
})
}
content, err := page.Content()
if err != nil {
return vm.ToValue(map[string]any{
"error": fmt.Sprintf("failed to get page content: %v", err),
})
}
return vm.ToValue(content)
})
return pwObj
}

View File

@@ -0,0 +1,82 @@
package js
import (
"fmt"
"log/slog"
"sync"
"github.com/charmbracelet/log"
"github.com/dop251/goja"
"github.com/playwright-community/playwright-go"
)
var jsPlaywright = func(vm *goja.Runtime, logger *log.Logger) *goja.Object {
pwObj := vm.NewObject()
var installOnce sync.Once
slogger := slog.New(logger)
pwObj.Set("get", func(call goja.FunctionCall) goja.Value {
url := call.Argument(0).String()
var installErr error
installOnce.Do(func() {
installErr = playwright.Install(&playwright.RunOptions{
Browsers: []string{"chromium"},
DriverDirectory: "./playwright",
Logger: slogger,
})
})
if installErr != nil {
return vm.ToValue(map[string]any{
"error": fmt.Sprintf("failed to install playwright: %v", installErr),
})
}
pw, err := playwright.Run(&playwright.RunOptions{
DriverDirectory: "./playwright",
Logger: slogger,
})
if err != nil {
return vm.ToValue(map[string]any{
"error": fmt.Sprintf("failed to start playwright: %v", err),
})
}
defer pw.Stop()
browser, err := pw.Chromium.Launch()
if err != nil {
return vm.ToValue(map[string]any{
"error": fmt.Sprintf("failed to launch browser: %v", err),
})
}
defer browser.Close()
page, err := browser.NewPage()
if err != nil {
return vm.ToValue(map[string]any{
"error": fmt.Sprintf("failed to create page: %v", err),
})
}
resp, err := page.Goto(url, playwright.PageGotoOptions{
WaitUntil: playwright.WaitUntilStateNetworkidle,
Timeout: playwright.Float(60000),
})
if err != nil {
return vm.ToValue(map[string]any{
"error": fmt.Sprintf("failed to navigate: %v", err),
})
}
if resp != nil && resp.Status() >= 400 {
return vm.ToValue(map[string]any{
"error": fmt.Sprintf("bad status code: %d", resp.Status()),
})
}
content, err := page.Content()
if err != nil {
return vm.ToValue(map[string]any{
"error": fmt.Sprintf("failed to get page content: %v", err),
})
}
return vm.ToValue(content)
})
return pwObj
}

View File

@@ -0,0 +1,19 @@
//go:build no_playwright
package js
import (
"github.com/charmbracelet/log"
"github.com/dop251/goja"
)
var jsPlaywright = func(vm *goja.Runtime, _ *log.Logger) *goja.Object {
pwObj := vm.NewObject()
unsupported := vm.ToValue(map[string]any{
"error": "playwright is not supported in this build",
})
pwObj.Set("get", func(call goja.FunctionCall) goja.Value {
return unsupported
})
return pwObj
}

View File

@@ -1,4 +1,4 @@
package parsers
package js
import (
"context"

View File

@@ -1,4 +1,4 @@
package parsers
package js
import "github.com/blang/semver"

View File

@@ -3,41 +3,16 @@ package parsers
import (
"context"
"fmt"
"sync"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/parsers/kemono"
"github.com/krau/SaveAny-Bot/parsers/twitter"
"github.com/krau/SaveAny-Bot/parsers/js"
"github.com/krau/SaveAny-Bot/parsers/native/kemono"
"github.com/krau/SaveAny-Bot/parsers/native/twitter"
"github.com/krau/SaveAny-Bot/parsers/parsers"
"github.com/krau/SaveAny-Bot/pkg/parser"
)
var (
parsers []parser.Parser
parsersMu sync.Mutex
doConfig sync.Once
configParsers = func() {
if len(parsers) == 0 {
return
}
for _, pser := range parsers {
if configurable, ok := pser.(parser.ConfigurableParser); ok {
cfg := config.C().GetParserConfigByName(configurable.Name())
if err := configurable.Configure(cfg); err != nil {
fmt.Printf("Error configuring parser %s: %v\n", configurable.Name(), err)
}
}
}
}
)
func AddParser(p ...parser.Parser) {
parsersMu.Lock()
defer parsersMu.Unlock()
parsers = append(parsers, p...)
}
func init() {
AddParser(new(twitter.TwitterParser), new(kemono.KemonoParser))
parsers.Add(new(twitter.TwitterParser), new(kemono.KemonoParser))
}
var (
@@ -45,12 +20,11 @@ var (
)
func ParseWithContext(ctx context.Context, url string) (*parser.Item, error) {
doConfig.Do(configParsers)
ch := make(chan *parser.Item, 1)
errCh := make(chan error, 1)
go func() {
for _, pser := range parsers {
for _, pser := range parsers.Get() {
if !pser.CanHandle(url) {
continue
}
@@ -76,11 +50,18 @@ func ParseWithContext(ctx context.Context, url string) (*parser.Item, error) {
}
func CanHandle(url string) (bool, parser.Parser) {
doConfig.Do(configParsers)
for _, pser := range parsers {
for _, pser := range parsers.Get() {
if pser.CanHandle(url) {
return true, pser
}
}
return false, nil
}
func LoadPlugins(ctx context.Context, dir string) error {
return js.LoadPlugins(ctx, dir)
}
func AddPlugin(ctx context.Context, code string, name string) error {
return js.AddPlugin(ctx, code, name)
}

View File

@@ -0,0 +1,43 @@
package parsers
import (
"fmt"
"sync"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/parser"
)
var (
parsers []parser.Parser
mu sync.Mutex
configOnce sync.Once
configParsers = func() {
mu.Lock()
defer mu.Unlock()
if len(parsers) == 0 {
return
}
for _, pser := range parsers {
if configurable, ok := pser.(parser.ConfigurableParser); ok {
cfg := config.C().GetParserConfigByName(configurable.Name())
if err := configurable.Configure(cfg); err != nil {
fmt.Printf("Error configuring parser %s: %v\n", configurable.Name(), err)
}
}
}
}
)
func Add(p ...parser.Parser) {
mu.Lock()
defer mu.Unlock()
parsers = append(parsers, p...)
}
func Get() []parser.Parser {
configOnce.Do(configParsers)
mu.Lock()
defer mu.Unlock()
return parsers
}

View File

@@ -1,7 +1,7 @@
// Code generated by go-enum DO NOT EDIT.
// Version: 0.6.1
// Revision: a6f63bddde05aca4221df9c8e9e6d7d9674b1cb4
// Build Date: 2025-03-18T23:42:14Z
// Version: 0.9.1
// Revision: 42b1ed55945781de07471bb2db52b3f9edee19b0
// Build Date: 2025-08-02T17:25:40Z
// Built By: goreleaser
package ctxkey

View File

@@ -1,7 +1,7 @@
// Code generated by go-enum DO NOT EDIT.
// Version: 0.6.1
// Revision: a6f63bddde05aca4221df9c8e9e6d7d9674b1cb4
// Build Date: 2025-03-18T23:42:14Z
// Version: 0.9.1
// Revision: 42b1ed55945781de07471bb2db52b3f9edee19b0
// Build Date: 2025-08-02T17:25:40Z
// Built By: goreleaser
package fnamest

View File

@@ -4,6 +4,6 @@ package storage
// StorageType
/* ENUM(
local, webdav, alist, minio, telegram
local, webdav, alist, minio, telegram, s3
) */
type StorageType string

View File

@@ -1,7 +1,7 @@
// Code generated by go-enum DO NOT EDIT.
// Version: 0.6.1
// Revision: a6f63bddde05aca4221df9c8e9e6d7d9674b1cb4
// Build Date: 2025-03-18T23:42:14Z
// Version: 0.9.1
// Revision: 42b1ed55945781de07471bb2db52b3f9edee19b0
// Build Date: 2025-08-02T17:25:40Z
// Built By: goreleaser
package storage
@@ -22,6 +22,8 @@ const (
Minio StorageType = "minio"
// Telegram is a StorageType of type telegram.
Telegram StorageType = "telegram"
// S3 is a StorageType of type s3.
S3 StorageType = "s3"
)
var ErrInvalidStorageType = fmt.Errorf("not a valid StorageType, try [%s]", strings.Join(_StorageTypeNames, ", "))
@@ -32,6 +34,7 @@ var _StorageTypeNames = []string{
string(Alist),
string(Minio),
string(Telegram),
string(S3),
}
// StorageTypeNames returns a list of possible string values of StorageType.
@@ -49,6 +52,7 @@ func StorageTypeValues() []StorageType {
Alist,
Minio,
Telegram,
S3,
}
}
@@ -70,6 +74,7 @@ var _StorageTypeValue = map[string]StorageType{
"alist": Alist,
"minio": Minio,
"telegram": Telegram,
"s3": S3,
}
// ParseStorageType attempts to convert a string to a StorageType.

View File

@@ -1,7 +1,7 @@
// Code generated by go-enum DO NOT EDIT.
// Version: 0.6.1
// Revision: a6f63bddde05aca4221df9c8e9e6d7d9674b1cb4
// Build Date: 2025-03-18T23:42:14Z
// Version: 0.9.1
// Revision: 42b1ed55945781de07471bb2db52b3f9edee19b0
// Build Date: 2025-08-02T17:25:40Z
// Built By: goreleaser
package tasktype

View File

@@ -10,7 +10,7 @@ import (
const (
TypeAdd = "add"
TypeSetDefault = "setdefault"
TypeConfig = "config"
TypeConfig = "config"
TypeCancel = "cancel"
)
@@ -47,4 +47,5 @@ type Add struct {
type SetDefaultStorage struct {
StorageName string
DirID uint
}

View File

@@ -40,7 +40,7 @@ func GetStorageByUserIDAndName(ctx context.Context, chatID int64, name string) (
}
if !config.C().HasStorage(chatID, name) {
return nil, fmt.Errorf("没有找到用户 %d 的存储 %s", chatID, name)
return nil, fmt.Errorf("no storage %s for user %d", name, chatID)
}
return getStorageByName(ctx, name)

View File

@@ -6,6 +6,7 @@ import (
"io"
"path"
"strings"
"sync"
"github.com/charmbracelet/log"
config "github.com/krau/SaveAny-Bot/config/storage"
@@ -16,6 +17,10 @@ import (
"github.com/rs/xid"
)
var (
deprecatedOnce sync.Once
)
type Minio struct {
config config.MinioStorageConfig
client *minio.Client
@@ -23,6 +28,9 @@ type Minio struct {
}
func (m *Minio) Init(ctx context.Context, cfg config.StorageConfig) error {
deprecatedOnce.Do(func() {
log.FromContext(ctx).Warn("Minio storage is deprecated, please use S3 storage type instead.")
})
minioConfig, ok := cfg.(*config.MinioStorageConfig)
if !ok {
return fmt.Errorf("failed to cast minio config")
@@ -73,7 +81,7 @@ func (m *Minio) Save(ctx context.Context, r io.Reader, storagePath string) error
candidate := storagePath
for i := 1; m.Exists(ctx, candidate); i++ {
candidate = fmt.Sprintf("%s_%d%s", base, i, ext)
if i > 1000 {
if i > 100 {
m.logger.Errorf("Too many attempts to find a unique filename for %s", storagePath)
candidate = fmt.Sprintf("%s_%s%s", base, xid.New().String(), ext)
break

130
storage/s3/s3.go Normal file
View File

@@ -0,0 +1,130 @@
package s3
import (
"context"
"fmt"
"io"
"path"
"strings"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/charmbracelet/log"
storconfig "github.com/krau/SaveAny-Bot/config/storage"
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
"github.com/rs/xid"
)
type S3 struct {
config storconfig.S3StorageConfig
client *s3.Client
logger *log.Logger
}
func (m *S3) Init(ctx context.Context, cfg storconfig.StorageConfig) error {
s3Config, ok := cfg.(*storconfig.S3StorageConfig)
if !ok {
return fmt.Errorf("failed to cast s3 config")
}
if err := s3Config.Validate(); err != nil {
return err
}
m.config = *s3Config
m.logger = log.FromContext(ctx).WithPrefix(fmt.Sprintf("s3[%s]", m.config.Name))
awsCfg, err := config.LoadDefaultConfig(
ctx,
config.WithRegion(m.config.Region),
config.WithCredentialsProvider(
credentials.NewStaticCredentialsProvider(
m.config.AccessKeyID,
m.config.SecretAccessKey,
"",
),
),
)
if err != nil {
return fmt.Errorf("failed to load AWS config: %w", err)
}
m.client = s3.NewFromConfig(awsCfg)
// Check if bucket exists
_, err = m.client.HeadBucket(ctx, &s3.HeadBucketInput{
Bucket: aws.String(m.config.BucketName),
})
if err != nil {
return fmt.Errorf("bucket %s not accessible: %w", m.config.BucketName, err)
}
return nil
}
func (m *S3) Type() storenum.StorageType {
return storenum.S3
}
func (m *S3) Name() string {
return m.config.Name
}
func (m *S3) JoinStoragePath(p string) string {
return strings.TrimPrefix(path.Join(m.config.BasePath, p), "/")
}
func (m *S3) Save(ctx context.Context, r io.Reader, storagePath string) error {
m.logger.Infof("Saving file from reader to %s", storagePath)
ext := path.Ext(storagePath)
base := strings.TrimSuffix(storagePath, ext)
candidate := storagePath
// Unique filename
for i := 1; m.Exists(ctx, candidate); i++ {
candidate = fmt.Sprintf("%s_%d%s", base, i, ext)
if i > 100 {
m.logger.Errorf("Too many attempts for unique filename: %s", storagePath)
candidate = fmt.Sprintf("%s_%s%s", base, xid.New().String(), ext)
break
}
}
// Determine content length
size := int64(-1)
if length := ctx.Value(ctxkey.ContentLength); length != nil {
if l, ok := length.(int64); ok && l > 0 {
size = l
}
}
// S3 PutObject needs either size or StreamingBody
input := &s3.PutObjectInput{
Bucket: aws.String(m.config.BucketName),
Key: aws.String(candidate),
Body: r,
}
if size >= 0 {
input.ContentLength = &size
}
_, err := m.client.PutObject(ctx, input)
if err != nil {
return fmt.Errorf("failed to upload file to S3: %w", err)
}
return nil
}
func (m *S3) Exists(ctx context.Context, storagePath string) bool {
m.logger.Debugf("Checking if file exists at %s", storagePath)
_, err := m.client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(m.config.BucketName),
Key: aws.String(storagePath),
})
return err == nil
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/krau/SaveAny-Bot/storage/alist"
"github.com/krau/SaveAny-Bot/storage/local"
"github.com/krau/SaveAny-Bot/storage/minio"
"github.com/krau/SaveAny-Bot/storage/s3"
"github.com/krau/SaveAny-Bot/storage/telegram"
"github.com/krau/SaveAny-Bot/storage/webdav"
)
@@ -37,6 +38,7 @@ var storageConstructors = map[storenum.StorageType]StorageConstructor{
storenum.Local: func() Storage { return new(local.Local) },
storenum.Webdav: func() Storage { return new(webdav.Webdav) },
storenum.Minio: func() Storage { return new(minio.Minio) },
storenum.S3: func() Storage { return new(s3.S3) },
storenum.Telegram: func() Storage { return new(telegram.Telegram) },
}

1
storage/telegram/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
tests/

View File

@@ -68,8 +68,8 @@ func (t *Telegram) Save(ctx context.Context, r io.Reader, storagePath string) er
if err := t.limiter.Wait(ctx); err != nil {
return fmt.Errorf("rate limit failed: %w", err)
}
rs, ok := r.(io.ReadSeeker)
if !ok || rs == nil {
rs, seekable := r.(io.ReadSeeker)
if !seekable || rs == nil {
return fmt.Errorf("reader must implement io.ReadSeeker")
}
tctx := tgutil.ExtFromContext(ctx)
@@ -103,8 +103,8 @@ func (t *Telegram) Save(ctx context.Context, r io.Reader, storagePath string) er
if filename == "" {
filename = xid.New().String() + mtype.Extension()
}
peer := tctx.PeerStorage.GetInputPeerById(chatID)
if peer == nil {
peer := tryGetInputPeer(tctx, chatID)
if peer == nil || peer.Zero() {
return fmt.Errorf("failed to get input peer for chat ID %d", chatID)
}
@@ -137,18 +137,44 @@ func (t *Telegram) Save(ctx context.Context, r io.Reader, storagePath string) er
if strings.HasPrefix(mtype.String(), "image/") && size >= tglimit.MaxPhotoSize {
forceFile = true
}
docb := message.UploadedDocument(file, caption).
doc := message.UploadedDocument(file, caption).
Filename(filename).
ForceFile(forceFile).
MIME(mtype.String())
var media message.MediaOption = docb
var media message.MediaOption = doc
switch mtypeStr := mtype.String(); {
case strings.HasPrefix(mtypeStr, "video/"):
media = docb.Video().SupportsStreaming()
media = doc.Video().SupportsStreaming()
thumb, err := extractThumbFrame(rs)
if err == nil {
thumb, err := upler.FromBytes(ctx, "thumb.jpg", thumb)
if err == nil {
doc = doc.Thumb(thumb)
}
}
rs.Seek(0, io.SeekStart)
switch mtypeStr {
case "video/mp4":
info, err := getMP4Meta(rs)
if err == nil {
media = doc.Video().
Duration(time.Duration(info.Duration)*time.Second).
Resolution(info.Width, info.Height).
SupportsStreaming()
}
default:
info, err := getVideoMetadata(rs)
if err == nil {
media = doc.Video().
Duration(time.Duration(info.Duration)*time.Second).
Resolution(info.Width, info.Height).
SupportsStreaming()
}
}
case strings.HasPrefix(mtypeStr, "audio/"):
media = docb.Audio().Title(filename)
media = doc.Audio().Title(filename)
case strings.HasPrefix(mtypeStr, "image/") && !strings.HasSuffix(mtypeStr, "webp"):
media = message.UploadedPhoto(file, caption)
}

163
storage/telegram/util.go Normal file
View File

@@ -0,0 +1,163 @@
package telegram
import (
"bytes"
"encoding/json"
"fmt"
"io"
"time"
"github.com/celestix/gotgproto/ext"
"github.com/gotd/td/constant"
"github.com/gotd/td/tg"
"github.com/krau/ffmpeg-go"
"github.com/yapingcat/gomedia/go-mp4"
)
type VideoMetadata struct {
Duration int
Width int
Height int
}
// a go native way to get mp4 video metadata
func getMP4Meta(rs io.ReadSeeker) (*VideoMetadata, error) {
d := mp4.CreateMp4Demuxer(rs)
tracks, err := d.ReadHead()
if err != nil {
return nil, err
}
for _, track := range tracks {
if track.Cid == mp4.MP4_CODEC_H264 {
info := d.GetMp4Info()
return &VideoMetadata{
Duration: int(info.Duration / info.Timescale),
Width: int(track.Width),
Height: int(track.Height),
}, nil
}
}
return nil, fmt.Errorf("no h264 track found")
}
// getVideoMetadata uses ffprobe to get video metadata
func getVideoMetadata(rs io.ReadSeeker) (*VideoMetadata, error) {
pipeReader, pipeWriter := io.Pipe()
go func() {
defer pipeWriter.Close()
rs.Seek(0, io.SeekStart)
io.Copy(pipeWriter, rs)
}()
result, err := ffmpeg.ProbeReaderWithTimeout(
pipeReader,
time.Second*10,
ffmpeg.KwArgs{
"select_streams": "v:0",
"show_entries": "stream=width,height:format=duration",
"of": "json",
},
)
if err != nil {
return nil, err
}
var data struct {
Streams []struct {
Width int `json:"width"`
Height int `json:"height"`
} `json:"streams"`
Format struct {
Duration string `json:"duration"`
} `json:"format"`
}
if err := json.Unmarshal([]byte(result), &data); err != nil {
return nil, err
}
// 转换 duration
var durationFloat float64
if data.Format.Duration != "" {
fmt.Sscanf(data.Format.Duration, "%f", &durationFloat)
}
meta := &VideoMetadata{
Duration: int(durationFloat),
}
if len(data.Streams) > 0 {
meta.Width = data.Streams[0].Width
meta.Height = data.Streams[0].Height
}
return meta, nil
}
func extractThumbFrame(rs io.ReadSeeker) ([]byte, error) {
data, err := extractFrameAt(rs, 1.0)
if err == nil && len(data) > 0 {
return data, nil
}
return extractFrameAt(rs, 0.0)
}
func extractFrameAt(rs io.ReadSeeker, timestamp float64) ([]byte, error) {
pipeReader, pipeWriter := io.Pipe()
go func() {
defer pipeWriter.Close()
rs.Seek(0, io.SeekStart)
io.Copy(pipeWriter, rs)
}()
var out bytes.Buffer
err := ffmpeg.
Input("pipe:0", ffmpeg.KwArgs{
"ss": fmt.Sprintf("%.3f", timestamp),
}).
Output("pipe:1", ffmpeg.KwArgs{
"vframes": 1,
"f": "mjpeg",
}).
WithInput(pipeReader).
WithOutput(&out).
OverWriteOutput().
Run()
if err != nil {
return nil, err
}
return out.Bytes(), nil
}
func tryGetInputPeer(ctx *ext.Context, chatID int64) tg.InputPeerClass {
peer := ctx.PeerStorage.GetInputPeerById(chatID)
if peer != nil && !peer.Zero() {
return peer
}
id := constant.TDLibPeerID(chatID)
plain := id.ToPlain()
var channel constant.TDLibPeerID
channel.Channel(plain)
peer = ctx.PeerStorage.GetInputPeerById(int64(channel))
if peer != nil && !peer.Zero() {
return peer
}
var chat constant.TDLibPeerID
chat.Chat(plain)
peer = ctx.PeerStorage.GetInputPeerById(int64(chat))
if peer != nil && !peer.Zero() {
return peer
}
var user constant.TDLibPeerID
user.User(plain)
peer = ctx.PeerStorage.GetInputPeerById(int64(user))
return peer
}

View File

@@ -0,0 +1,34 @@
package telegram
import (
"os"
"testing"
)
func TestExtractThumbFrame(t *testing.T) {
file, err := os.Open("tests/testvideo")
if err != nil {
t.Fatalf("failed to open test video: %v", err)
}
defer file.Close()
thumb, err := extractThumbFrame(file)
if err != nil {
t.Fatalf("failed to extract thumb frame: %v", err)
}
os.WriteFile("tests/testthumb.jpg", thumb, 0644)
}
func TestGetVideoMetadata(t *testing.T) {
file, err := os.Open("tests/testvideo")
if err != nil {
t.Fatalf("failed to open test video: %v", err)
}
defer file.Close()
meta, err := getVideoMetadata(file)
if err != nil {
t.Fatalf("failed to get video metadata: %v", err)
}
if meta.Duration == 0 || meta.Width == 0 || meta.Height == 0 {
t.Fatalf("invalid video metadata: %+v", meta)
}
}