Compare commits

...

19 Commits

Author SHA1 Message Date
krau
7a2274baa0 refactor: update configuration file structure and improve comments for clarity 2025-07-07 13:57:58 +08:00
krau
69a3ed6f4e fix: parse chat ID correctly in Save method for Telegram storage 2025-07-07 13:48:16 +08:00
krau
36f3dd83fc refactor: remove error wrapping in retry middleware skip logic 2025-07-07 13:44:32 +08:00
krau
501b9d844a docs: update userbot content 2025-07-04 16:26:17 +08:00
krau
03cec7ec01 docs: add IS-ALBUM rule type 2025-07-04 16:18:01 +08:00
krau
dc0debcd1c fix: add unique filename handling with error logging in Save method for Minio and Webdav 2025-07-04 16:10:42 +08:00
krau
4b136bd41e feat: add test case for nested directory file writing 2025-07-04 16:08:01 +08:00
krau
d703f11ea0 feat: implement album handling rules and refactor related logic 2025-07-04 16:01:29 +08:00
krau
3ce9926967 feat: handle media group message in updates 2025-07-04 14:36:03 +08:00
krau
80146176f0 feat: make retry middleware configurable 2025-07-04 13:38:55 +08:00
krau
14ba2afdf8 chore: update funding url 2025-07-04 13:38:28 +08:00
krau
f4d427a1cb fix(dockerignore): ensure docs directory is ignored correctly 2025-06-30 22:49:01 +08:00
krau
f84c83a7e2 fix(ci): try speed up docker build 2025-06-30 22:47:47 +08:00
krau
cb6540c017 fix(minio): trim leading slash in JoinStoragePath method 2025-06-30 22:19:33 +08:00
krau
e7bab27543 feat: add user client inject in file link handler 2025-06-29 23:08:48 +08:00
krau
f693bd6103 refactor: update file handling to use new downloader interface; remove unused tdler package 2025-06-29 23:00:40 +08:00
krau
75f52569a0 deps: upgrade 2025-06-29 22:26:52 +08:00
krau
c795f957a9 refactor: user client and proxy handling; remove unused auth code 2025-06-29 22:25:05 +08:00
krau
3b85911e3d fix: mimetype nil pointer 2025-06-20 22:42:57 +08:00
38 changed files with 537 additions and 226 deletions

View File

@@ -6,6 +6,6 @@
downloads/
data/
cache/
docs
docs/
config.example.toml
docker-compose.*

2
.github/FUNDING.yml vendored
View File

@@ -1,5 +1,5 @@
# These are supported funding model platforms
custom: [
"https://afdian.com/a/acherkrau"
"https://afdian.com/a/unvapp"
]

View File

@@ -29,13 +29,7 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
type=raw,value=latest
type=ref,event=branch
type=ref,event=tag
labels: |
org.opencontainers.image.title=${{ env.IMAGE_NAME }}
org.opencontainers.image.source=https://github.com/krau/SaveAny-Bot
org.opencontainers.image.url=https://github.com/krau/SaveAny-Bot
type=raw,value=latest,enable={{is_default_branch}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -50,23 +44,20 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract version from Git Ref
id: extract_version
run: |
VERSION=$(echo "${{ github.ref }}" | sed 's/refs\/tags\/v//')
echo "VERSION=${VERSION}" >> $GITHUB_ENV
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
cache-from: type=gha
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: |
type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
type=gha
cache-to: type=gha,mode=max
build-args: |
VERSION=${{ steps.meta.outputs.version }}
GitCommit=${{ github.sha }}
BuildTime=${{ format(github.event.repository.updated_at, 'yyyy-MM-dd HH:mm:ss') }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
BuildTime=${{ fromJson(toJSON(github.event.repository.pushed_at)) }}

View File

@@ -6,15 +6,18 @@ ARG BuildTime="Unknown"
WORKDIR /app
COPY . .
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg \
CGO_ENABLED=0 \
go build -trimpath \
-ldflags "-s -w \
-X github.com/krau/SaveAny-Bot/common.Version=${VERSION} \
-X github.com/krau/SaveAny-Bot/common.GitCommit=${GiTCommit} \
-X github.com/krau/SaveAny-Bot/common.GitCommit=${GitCommit} \
-X github.com/krau/SaveAny-Bot/common.BuildTime=${BuildTime}" \
-o saveany-bot .

View File

@@ -2,7 +2,6 @@ package bot
import (
"context"
"net/url"
"time"
"github.com/celestix/gotgproto"
@@ -14,21 +13,12 @@ import (
"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/config"
"github.com/ncruces/go-sqlite3/gormlite"
"golang.org/x/net/proxy"
)
var Client *gotgproto.Client
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 Init(ctx context.Context) {
log.FromContext(ctx).Info("初始化 Bot...")
resultChan := make(chan struct {
@@ -38,7 +28,7 @@ func Init(ctx context.Context) {
go func() {
var resolver dcs.Resolver
if config.Cfg.Telegram.Proxy.Enable && config.Cfg.Telegram.Proxy.URL != "" {
dialer, err := newProxyDialer(config.Cfg.Telegram.Proxy.URL)
dialer, err := netutil.NewProxyDialer(config.Cfg.Telegram.Proxy.URL)
if err != nil {
resultChan <- struct {
client *gotgproto.Client
@@ -52,7 +42,8 @@ func Init(ctx context.Context) {
} else {
resolver = dcs.DefaultResolver()
}
client, err := gotgproto.NewClient(config.Cfg.Telegram.AppID,
client, err := gotgproto.NewClient(
config.Cfg.Telegram.AppID,
config.Cfg.Telegram.AppHash,
gotgproto.ClientTypeBot(config.Cfg.Telegram.Token),
&gotgproto.ClientOpts{
@@ -104,8 +95,7 @@ func Init(ctx context.Context) {
if result.err != nil {
log.FromContext(ctx).Fatalf("初始化 Bot 失败: %s", result.err)
}
Client = result.client
handlers.Register(Client.Dispatcher)
handlers.Register(result.client.Dispatcher)
log.FromContext(ctx).Info("Bot 初始化完成")
}
}

View File

@@ -38,8 +38,7 @@ func handleMessageLink(ctx *ext.Context, update *ext.Update) error {
editReplied("构建存储选择键盘失败: "+err.Error(), nil)
return dispatcher.EndGroups
}
editReplied(fmt.Sprintf("找到 %d 个文件, 请选择存储位置", len(files)),
markup)
editReplied(fmt.Sprintf("找到 %d 个文件, 请选择存储位置", len(files)), markup)
return dispatcher.EndGroups
}

View File

@@ -1,18 +1,32 @@
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/pkg/tcbdata"
"github.com/krau/SaveAny-Bot/pkg/tfile"
"github.com/krau/SaveAny-Bot/storage"
)
func handleMediaMessage(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
message := update.EffectiveMessage.Message
groupID, isGroup := message.GetGroupedID()
if isGroup && groupID != 0 {
return handleGroupMediaMessage(ctx, update, message, groupID)
}
logger.Debugf("Got media: %s", message.Media.TypeName())
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, message)
if err != nil {
return err
@@ -38,6 +52,10 @@ func handleSilentSaveMedia(ctx *ext.Context, update *ext.Update) error {
return dispatcher.EndGroups
}
message := update.EffectiveMessage.Message
groupID, isGroup := message.GetGroupedID()
if isGroup && groupID != 0 {
return handleGroupMediaMessage(ctx, update, message, groupID)
}
logger.Debugf("Got media: %s", message.Media.TypeName())
userID := update.GetUserChat().GetID()
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, message)
@@ -46,3 +64,96 @@ func handleSilentSaveMedia(ctx *ext.Context, update *ext.Update) error {
}
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,
})
}

View File

@@ -149,7 +149,7 @@ func handleBatchSave(ctx *ext.Context, update *ext.Update, args []string) error
if !supported {
continue
}
file, err := tfile.FromMediaMessage(media, msg, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*msg)))
file, err := tfile.FromMediaMessage(media, ctx.Raw, msg, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*msg)))
if err != nil {
log.FromContext(ctx).Errorf("获取文件失败: %s", err)
continue

View File

@@ -3,6 +3,8 @@ package ruleutil
import (
"context"
"github.com/duke-git/lancet/v2/convertor"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/consts"
@@ -33,11 +35,22 @@ func (m matchedStorName) String() string {
return string(m)
}
func (m matchedStorName) IsValid() bool {
// can we use this storage name directly?
func (m matchedStorName) IsUsable() bool {
return m != "" && m != consts.RuleStorNameChosen
}
func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (matchedStorageName matchedStorName, dirPath string) {
type MatchedDirPath string
func (m MatchedDirPath) String() string {
return string(m)
}
func (m MatchedDirPath) NeedNewForAlbum() bool {
return m != "" && m == consts.RuleDirPathNewForAlbum
}
func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (matchedStorageName matchedStorName, dirPath MatchedDirPath) {
if inputs == nil || len(rules) == 0 {
return "", ""
}
@@ -56,7 +69,7 @@ func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (m
continue
}
if ok {
dirPath = ru.StoragePath()
dirPath = MatchedDirPath(ru.StoragePath())
matchedStorageName = matchedStorName(ru.StorageName())
}
case ruleenum.MessageRegex.String():
@@ -71,7 +84,26 @@ func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (m
continue
}
if ok {
dirPath = ru.StoragePath()
dirPath = MatchedDirPath(ru.StoragePath())
matchedStorageName = matchedStorName(ru.StorageName())
}
case ruleenum.IsAlbum.String():
matchAlbum, err := convertor.ToBool(ur.Data)
if err != nil {
matchAlbum = false
}
ru, err := rule.NewRuleMediaType(ur.StorageName, ur.DirPath, matchAlbum)
if err != nil {
logger.Errorf("Failed to create rule: %s", err)
continue
}
ok, err := ru.Match(inputs.File.Message().GroupedID != 0)
if err != nil {
logger.Errorf("Failed to match rule: %s", err)
continue
}
if ok {
dirPath = MatchedDirPath(ru.StoragePath())
matchedStorageName = matchedStorName(ru.StorageName())
}
}

View File

@@ -14,9 +14,11 @@ import (
"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/re"
uc "github.com/krau/SaveAny-Bot/client/user"
"github.com/krau/SaveAny-Bot/common/cache"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/common/utils/tphutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/telegraph"
"github.com/krau/SaveAny-Bot/pkg/tfile"
)
@@ -46,7 +48,7 @@ func GetFileFromMessageWithReply(ctx *ext.Context, update *ext.Update, message *
} else {
options = append(options, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*message)))
}
file, err = tfile.FromMediaMessage(media, message, options...)
file, err = tfile.FromMediaMessage(media, ctx.Raw, message, options...)
if err != nil {
logger.Errorf("Failed to get file from media: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取文件失败: "+err.Error()), nil)
@@ -81,8 +83,8 @@ func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Up
}
files = make([]tfile.TGFileMessage, 0, len(msgLinks))
addFile := func(msg *tg.Message) {
if msg == nil {
addFile := func(client tfile.DlerClient, msg *tg.Message) {
if msg == nil || msg.Media == nil {
logger.Warn("message is nil, skipping")
return
}
@@ -91,25 +93,31 @@ func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Up
logger.Debugf("message %d has no media", msg.GetID())
return
}
file, err := tfile.FromMediaMessage(media, msg, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*msg)))
file, err := tfile.FromMediaMessage(media, client, msg, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*msg)))
if err != nil {
logger.Errorf("failed to create file from media: %s", err)
return
}
files = append(files, file)
}
tctx := ctx
if config.Cfg.Telegram.Userbot.Enable {
tctx = uc.GetCtx()
}
for _, link := range msgLinks {
linkUrl, err := url.Parse(link)
if err != nil {
logger.Errorf("failed to parse message link %s: %s", link, err)
continue
}
chatId, msgId, err := tgutil.ParseMessageLink(ctx, link)
chatId, msgId, err := tgutil.ParseMessageLink(tctx, link)
if err != nil {
logger.Errorf("failed to parse message link %s: %s", link, err)
continue
}
msg, err := tgutil.GetMessageByID(ctx, chatId, msgId)
msg, err := tgutil.GetMessageByID(tctx, chatId, msgId)
if err != nil {
logger.Errorf("failed to get message by ID: %s", err)
continue
@@ -121,11 +129,11 @@ func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Up
logger.Errorf("failed to get grouped messages: %s", err)
} else {
for _, gmsg := range gmsgs {
addFile(gmsg)
addFile(tctx.Raw, gmsg)
}
}
} else {
addFile(msg)
addFile(tctx.Raw, msg)
}
}
if len(files) == 0 {

View File

@@ -3,6 +3,7 @@ package shortcut
import (
"fmt"
"path"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
@@ -34,8 +35,8 @@ func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage
}
if user.ApplyRule && user.Rules != nil {
matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
dirPath = matchedDirPath
if matchedStorageName.IsValid() {
dirPath = matchedDirPath.String()
if matchedStorageName.IsUsable() {
stor, err = storage.GetStorageByUserIDAndName(ctx, user.ChatID, matchedStorageName.String())
if err != nil {
logger.Errorf("Failed to get storage by user ID and name: %s", err)
@@ -51,7 +52,7 @@ func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage
storagePath := stor.JoinStoragePath(path.Join(dirPath, file.Name()))
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
taskid := xid.New().String()
task, err := tftask.NewTGFileTask(taskid, injectCtx, file, ctx.Raw, stor, storagePath,
task, err := tftask.NewTGFileTask(taskid, injectCtx, file, stor, storagePath,
tftask.NewProgressTrack(
trackMsgID,
userID))
@@ -93,19 +94,28 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
})
return dispatcher.EndGroups
}
useRule := user.ApplyRule && user.Rules != nil
applyRule := func(file tfile.TGFileMessage) (string, string) {
applyRule := func(file tfile.TGFileMessage) (string, ruleutil.MatchedDirPath) {
if !useRule {
return stor.Name(), dirPath
return stor.Name(), ruleutil.MatchedDirPath(dirPath)
}
storName, dirP := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
if !storName.IsValid() {
return stor.Name(), dirP
storname := storName.String()
if !storName.IsUsable() {
storname = stor.Name()
}
return storName.String(), dirP
return storname, dirP
}
elems := make([]batchtftask.TaskElement, 0, len(files))
type albumFile struct {
file tfile.TGFileMessage
storage storage.Storage
}
albumFiles := make(map[int64][]albumFile, 0)
for _, file := range files {
storName, dirPath := applyRule(file)
fileStor := stor
@@ -120,21 +130,59 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
return dispatcher.EndGroups
}
}
storPath := fileStor.JoinStoragePath(path.Join(dirPath, file.Name()))
elem, err := batchtftask.NewTaskElement(fileStor, storPath, file)
if err != nil {
logger.Errorf("Failed to create task element: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: "任务创建失败: " + err.Error(),
if !dirPath.NeedNewForAlbum() {
storPath := fileStor.JoinStoragePath(path.Join(dirPath.String(), file.Name()))
elem, err := batchtftask.NewTaskElement(fileStor, storPath, file)
if err != nil {
logger.Errorf("Failed to create task element: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: "任务创建失败: " + err.Error(),
})
return dispatcher.EndGroups
}
elems = append(elems, *elem)
} else {
groupId, isGroup := file.Message().GetGroupedID()
if !isGroup || groupId == 0 {
logger.Warnf("File %s is not in a group, skipping album handling", file.Name())
continue
}
if _, ok := albumFiles[groupId]; !ok {
albumFiles[groupId] = make([]albumFile, 0)
}
albumFiles[groupId] = append(albumFiles[groupId], albumFile{
file: file,
storage: fileStor,
})
return dispatcher.EndGroups
}
elems = append(elems, *elem)
}
for _, afiles := range albumFiles {
if len(afiles) <= 1 {
continue
}
// 对于需要新建目录的文件, 将第一个文件的文件名(去除扩展名)作为目录名
// 存储以第一个文件的存储为准
albumDir := strings.TrimSuffix(path.Base(afiles[0].file.Name()), path.Ext(afiles[0].file.Name()))
albumStor := afiles[0].storage
for _, af := range afiles {
afstorPath := af.storage.JoinStoragePath(path.Join(dirPath, albumDir, af.file.Name()))
elem, err := batchtftask.NewTaskElement(albumStor, afstorPath, af.file)
if err != nil {
logger.Errorf("Failed to create task element for album file: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: "任务创建失败: " + err.Error(),
})
return dispatcher.EndGroups
}
elems = append(elems, *elem)
}
}
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
taskid := xid.New().String()
task := batchtftask.NewBatchTGFileTask(taskid, injectCtx, elems, ctx.Raw, batchtftask.NewProgressTracker(trackMsgID, userID), true)
task := batchtftask.NewBatchTGFileTask(taskid, injectCtx, elems, batchtftask.NewProgressTracker(trackMsgID, userID), true)
if err := core.AddTask(injectCtx, task); err != nil {
logger.Errorf("Failed to add batch task: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{

View File

@@ -9,13 +9,14 @@ import (
"github.com/gotd/td/telegram"
"github.com/krau/SaveAny-Bot/client/middleware/recovery"
"github.com/krau/SaveAny-Bot/client/middleware/retry"
"github.com/krau/SaveAny-Bot/config"
)
// https://github.com/iyear/tdl/blob/master/core/tclient/tclient.go
func NewDefaultMiddlewares(ctx context.Context, timeout time.Duration) []telegram.Middleware {
return []telegram.Middleware{
recovery.New(ctx, newBackoff(timeout)),
retry.New(5),
retry.New(config.Cfg.Telegram.RpcRetry),
floodwait.NewSimpleWaiter(),
}
}

View File

@@ -5,7 +5,6 @@ import (
"fmt"
"github.com/charmbracelet/log"
"github.com/go-faster/errors"
"github.com/gotd/td/bin"
"github.com/gotd/td/telegram"
"github.com/gotd/td/tg"
@@ -37,7 +36,8 @@ func (r retry) Handle(next tg.Invoker) telegram.InvokeFunc {
retries++
continue
}
return errors.Wrap(err, "retry middleware skip")
// retry middleware skip
return err
}
return nil

View File

@@ -9,9 +9,9 @@ import (
"github.com/fatih/color"
)
type termialAuthConversator struct{}
type terminalAuthConversator struct{}
func (t *termialAuthConversator) AskPhoneNumber() (string, error) {
func (t *terminalAuthConversator) AskPhoneNumber() (string, error) {
phone := ""
err := huh.NewInput().Title("Your Phone Number").
Placeholder("+44 123456").
@@ -29,7 +29,7 @@ func (t *termialAuthConversator) AskPhoneNumber() (string, error) {
return strings.TrimSpace(phone), nil
}
func (t *termialAuthConversator) AskCode() (string, error) {
func (t *terminalAuthConversator) AskCode() (string, error) {
code := ""
err := huh.NewInput().Title("Your Code").
Placeholder("123456").
@@ -45,7 +45,7 @@ func (t *termialAuthConversator) AskCode() (string, error) {
return strings.TrimSpace(code), nil
}
func (t *termialAuthConversator) AskPassword() (string, error) {
func (t *terminalAuthConversator) AskPassword() (string, error) {
pwd := ""
err := huh.NewInput().Title("Your 2FA Password").
@@ -61,7 +61,7 @@ func (t *termialAuthConversator) AskPassword() (string, error) {
return strings.TrimSpace(pwd), nil
}
func (t *termialAuthConversator) AuthStatus(authStatus gotgproto.AuthStatus) {
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,")

View File

@@ -5,46 +5,82 @@ import (
"time"
"github.com/celestix/gotgproto"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/celestix/gotgproto/sessionMaker"
"github.com/charmbracelet/log"
"github.com/gotd/td/telegram/dcs"
"github.com/krau/SaveAny-Bot/client/middleware"
"github.com/krau/SaveAny-Bot/common/utils/netutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/ncruces/go-sqlite3/gormlite"
"golang.org/x/net/proxy"
)
var UC *gotgproto.Client
var uc *gotgproto.Client
var ectx *ext.Context
func GetCtx() *ext.Context {
if uc == nil {
panic("User client is not initialized, please call Login first")
}
if ectx != nil {
// UC.RefreshContext(ectx)
return ectx
}
ectx = UC.CreateContext()
ectx = uc.CreateContext()
return ectx
}
func GetClient() *gotgproto.Client {
if uc == nil {
panic("User client is not initialized, please call Login first")
}
return uc
}
func Login(ctx context.Context) (*gotgproto.Client, error) {
log.FromContext(ctx).Debug("Logging in as user client")
if UC != nil {
return UC, nil
log.FromContext(ctx).Debug("Logging in user client")
if uc != nil {
return uc, nil
}
res := make(chan struct {
client *gotgproto.Client
err error
})
go func() {
var resolver dcs.Resolver
if config.Cfg.Telegram.Proxy.Enable && config.Cfg.Telegram.Proxy.URL != "" {
dialer, err := netutil.NewProxyDialer(config.Cfg.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()
}
tclient, err := gotgproto.NewClient(
config.Cfg.Telegram.AppID,
config.Cfg.Telegram.AppHash,
gotgproto.ClientTypePhone(""),
&gotgproto.ClientOpts{
Session: sessionMaker.SqlSession(gormlite.Open(config.Cfg.Telegram.Userbot.Session)),
AuthConversator: &termialAuthConversator{},
AuthConversator: &terminalAuthConversator{},
Context: ctx,
DisableCopyright: true,
Resolver: resolver,
MaxRetries: config.Cfg.Telegram.RpcRetry,
AutoFetchReply: true,
Middlewares: middleware.NewDefaultMiddlewares(ctx, 5*time.Minute),
ErrorHandler: func(ctx *ext.Context, u *ext.Update, s string) error {
log.FromContext(ctx).Errorf("Unhandled error: %s", s)
return dispatcher.EndGroups
},
},
)
if err != nil {
@@ -69,7 +105,8 @@ func Login(ctx context.Context) (*gotgproto.Client, error) {
if r.err != nil {
return nil, r.err
}
UC = r.client
return UC, nil
uc = r.client
log.FromContext(ctx).Infof("User client logged in successfully: %s", uc.Self.FirstName+" "+uc.Self.LastName)
return uc, nil
}
}

View File

@@ -51,16 +51,14 @@ func initAll(ctx context.Context) {
logger := log.FromContext(ctx)
i18n.Init(config.Cfg.Lang)
logger.Info(i18n.T(i18nk.Initing))
database.Init(ctx)
storage.LoadStorages(ctx)
if config.Cfg.Telegram.Userbot.Enable {
uc, err := userclient.Login(ctx)
_, err := userclient.Login(ctx)
if err != nil {
logger.Fatalf("User client login failed: %s", err)
}
logger.Infof("User client logged in as %s", uc.Self.FirstName)
}
database.Init(ctx)
storage.LoadStorages(ctx)
bot.Init(ctx)
}

View File

@@ -0,0 +1,15 @@
package netutil
import (
"net/url"
"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)
}

View File

@@ -27,11 +27,11 @@ func GenFileNameFromMessage(message tg.Message) string {
if !ok {
return ""
}
ext := mimetype.Lookup(doc.MimeType).Extension()
if ext == "" {
mmt := mimetype.Lookup(doc.MimeType)
if mmt == nil || mmt.Extension() == "" {
return ""
}
return ext
return mmt.Extension()
case *tg.MessageMediaPhoto:
return ".jpg"
}

View File

@@ -1,69 +1,34 @@
#创建文件时,若需要保留中文注释,请务必确保本文件编码为 UTF-8 ,否则会无法读取。
workers = 4 # 同时下载文件数
retry = 3 # 下载失败重试次
threads = 4 # 单个任务下载最大线程
stream = false # 使用stream模式, 详情请查看文档
# 创建文件时,若需要保留中文注释,请务必确保本文件编码为 UTF-8 ,否则会无法读取。
# 更详细的配置请在 https://sabot.unv.app/deployment/configuration 查看
workers = 4 # 同时下载文件
retry = 3 # 下载失败重试次
threads = 4 # 单个任务下载使用的最大线程数
stream = false # 使用流式传输模式, 建议仅在硬盘空间十分有限时使用.
[telegram]
# Bot Token
# 更换 Bot Token 后请删除数据库文件 session.db
# 更换 Bot Token 后请删除会话数据库文件 (默认路径为 data/session.db )
token = ""
# Telegram API 配置, 若不配置也可运行, 将使用默认的 API ID 和 API HASH
# 推荐使用自己的 API ID 和 API HASH (https://my.telegram.org)
# app_id = 1025907
# app_hash = "452b0359b988148995f22ff0f4229750"
# 初始化超时时间, 单位: 秒
timeout = 60
# flood_retry = 5
# rpc_retry = 5
[telegram.proxy]
# 启用代理连接 telegram, 只支持 socks5
enable = false
url = "socks5://127.0.0.1:7890"
# 用户列表
[[users]]
# telegram user id
id = 114514
# 使用黑名单模式,开启后下方留空以使用所有存储,反之则为白名单,白名单请在下方输入允许的存储名
blacklist = true
# 将列表留空并开启黑名单模式以允许使用所有存储,此处示例为黑名单模式,用户 114514 可使用所有存储
storages = []
[[users]]
id = 123456
blacklist = false # 使用白名单模式此时用户123456 仅可使用下方列表中的存储
# 此时该用户只能使用名为 本机1 的存储
storages = ["本机1"]
# 存储列表
[[storages]]
# 标识名, 需要唯一
name = "本机1"
# 存储类型, 目前可用: local, alist, webdav, minio
# 存储类型, 目前可用: local, alist, webdav, minio, telegram
type = "local"
# 启用存储
enable = true
# 文件保存根路径
base_path = "./downloads"
[[storages]]
name = "MyAlist"
type = "alist"
enable = false #记得启用
base_path = '/'
url = 'https://alist.com'
username = 'admin'
password = 'password'
# alist token 刷新时间
# 86400--1天 604800--7天 1296000--15天 2592000--30天 15552000--180天
token_exp = 86400
# alist 可直接使用 token 登录, 此时 username, password, token_exp 将被忽略
# 请自行在 alist 侧配置合理的 token 过期时间
# token = ""
[[storages]]
name = "MyWebdav"
type = "webdav"
@@ -73,28 +38,17 @@ url = 'https://example.com/dav'
username = 'username'
password = 'password'
[[storages]]
name = "MyMinio"
type = "minio"
enable = true
endpoint = 'play.min.io'
use_ssl = true
access_key_id = 'Q3AM3UQ867SPQQA43P2F'
secret_access_key = 'zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG'
bucket_name = 'saveanybot'
base_path = '/path/telegram'
[[storages]]
name = "mychannel"
type = "telegram"
enable = true
chat_id = 1820371480
# [temp]
# # 下载文件临时目录, 请不要在此目录下存放任何其他文件
# base_path = "cache/"
# [db]
# path = "data/data.db" # 数据库文件路径
# session = "data/session.db"
# 用户列表
[[users]]
# telegram user id
id = 114514
# 存储过滤列表, 元素为存储标识名.
# 将该列表留空并开启黑名单过滤模式以允许使用所有存储,此处示例为黑名单模式,用户 114514 可使用所有存储
storages = []
# 使用列表过滤黑名单模式,反之则为白名单,白名单请在列表中指定可用的存储.
blacklist = true
[[users]]
id = 123456
storages = ["本机1"]
blacklist = false # 使用白名单模式,此时,用户 123456 仅可使用标识名为 '本地1' 的存储

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"
)
@@ -62,7 +62,7 @@ func (t *Task) processElement(ctx context.Context, elem TaskElement) error {
})
errg.Go(func() error {
logger.Info("Starting file download in stream mode")
_, err := tdler.NewDownloader(t.client, elem.File).Stream(uploadCtx, wr)
_, err := tfile.NewDownloader(elem.File).Stream(uploadCtx, wr)
if closeErr := pw.CloseWithError(err); closeErr != nil {
logger.Errorf("Failed to close pipe writer: %v", closeErr)
}
@@ -88,7 +88,7 @@ func (t *Task) processElement(ctx context.Context, elem TaskElement) error {
t.downloaded.Add(int64(n))
t.Progress.OnProgress(ctx, t)
})
_, err = tdler.NewDownloader(t.client, elem.File).Parallel(ctx, wrAt)
_, err = tfile.NewDownloader(elem.File).Parallel(ctx, wrAt)
if err != nil {
return fmt.Errorf("failed to download file: %w", err)
}

View File

@@ -6,7 +6,6 @@ import (
"path/filepath"
"sync/atomic"
"github.com/krau/SaveAny-Bot/common/tdler"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
"github.com/krau/SaveAny-Bot/pkg/tfile"
@@ -30,7 +29,6 @@ type Task struct {
Progress ProgressTracker
IgnoreErrors bool // if true, errors during processing will be ignored
downloaded atomic.Int64
client tdler.Client
totalSize int64
processing map[string]TaskElementInfo
failed map[string]error // errors for each element
@@ -73,14 +71,12 @@ func NewBatchTGFileTask(
id string,
ctx context.Context,
files []TaskElement,
client tdler.Client,
progress ProgressTracker,
ignoreErrors bool,
) *Task {
task := &Task{
ID: id,
Ctx: ctx,
client: client,
Elems: files,
Progress: progress,
downloaded: atomic.Int64{},

View File

@@ -8,10 +8,10 @@ import (
"time"
"github.com/charmbracelet/log"
"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 {
@@ -36,7 +36,7 @@ func (t *Task) Execute(ctx context.Context) error {
defer func() {
t.Progress.OnDone(ctx, t, err)
}()
_, err = tdler.NewDownloader(t.client, t.File).Parallel(ctx, wrAt)
_, err = tfile.NewDownloader(t.File).Parallel(ctx, wrAt)
if err != nil {
return fmt.Errorf("failed to download file: %w", err)
}

View File

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

View File

@@ -5,7 +5,6 @@ import (
"fmt"
"path/filepath"
"github.com/krau/SaveAny-Bot/common/tdler"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
"github.com/krau/SaveAny-Bot/pkg/tfile"
@@ -19,7 +18,6 @@ type Task struct {
Storage storage.Storage
Path string
Progress ProgressTracker
client tdler.Client
stream bool // true if the file should be downloaded in stream mode
localPath string
}
@@ -32,7 +30,6 @@ func NewTGFileTask(
id string,
ctx context.Context,
file tfile.TGFile,
client tdler.Client,
stor storage.Storage,
path string,
progress ProgressTracker,
@@ -46,7 +43,6 @@ func NewTGFileTask(
tftask := &Task{
ID: id,
Ctx: ctx,
client: client,
File: file,
Storage: stor,
Path: path,
@@ -58,7 +54,6 @@ func NewTGFileTask(
tfileTask := &Task{
ID: id,
Ctx: ctx,
client: client,
File: file,
Storage: stor,
Path: path,

View File

@@ -51,9 +51,20 @@ Stream 模式对于磁盘空间有限的部署环境十分有用, 但也有一
- `app_id`, `app_hash`: Telegram API ID & Hash, 在 [Telegram API](https://my.telegram.org/apps) 创建应用获取, 若不提供则使用默认值.
- `flood_retry`: Flood 控制重试次数, 默认为 5.
- `rpc_retry`: RPC 请求重试次数, 默认为 5.
- `proxy`: 代理配置, 可选.
- `proxy`: 代理配置, 可选.
- `enable`: 是否启用代理.
- `url`: 代理地址, 只支持 `socks5://`
- `userbot`: userbot 配置, 可选.
- `enable`: 启用 userbot 集成, 需要登录用户账号, 此时请务必使用自己的 api id & hash.
- `session`: userbot 会话文件路径, 默认为 `data/usersession.db`.
{{< hint warning >}}
启用 userbot 集成后, bot 可以下载私密频道和群组的文件, 但具有无法避免的账号被封禁的风险.
<br />
并且, 由于上游依赖问题, 该功能不稳定, 会出现获取文件失败的情况.
<br />
开启 userbot 集成后第一次启动 bot 时需要通过终端交互输入手机号, 2FA 和验证码, 如果你使用 docker 部署, 请进入容器内执行相关操作.
{{< /hint >}}
```toml
[telegram]
@@ -65,6 +76,9 @@ rpc_retry = 5
[telegram.proxy]
enable = false
url = "socks5://127.0.0.1:7890"
[telegram.userbot]
enable = false
session = "data/usersession.db"
```
### 存储端列表

View File

@@ -26,7 +26,6 @@ Bot 接受两种消息: 文件和链接.
在开启静默模式之前, 需要使用 `/storage` 命令设置默认保存位置.
## 存储规则
允许你为 Bot 在上传文件到存储时设置一些重定向规则, 用于自动整理所保存的文件.
@@ -37,6 +36,7 @@ Bot 接受两种消息: 文件和链接.
1. FILENAME-REGEX
2. MESSAGE-REGEX
3. IS-ALBUM
添加规则的基本语法:
@@ -64,4 +64,18 @@ FILENAME-REGEX (?i)\.(mp4|mkv|ts|avi|flv)$ MyAlist /视频
### MESSAGE-REGEX
同上, 但是是根据消息本身的文本内容正则匹配
同上, 但是是根据消息本身的文本内容正则匹配
### IS-ALBUM
匹配相册消息 (media group), 规则内容只能为 `true``false`.
规则中的路径若使用 "NEW-FOR-ALBUM" , 则表示为该组消息新建一个文件夹来存储它们. 见: https://github.com/krau/SaveAny-Bot/issues/87
例如:
```
IS-ALBUM true MyWebdav NEW-FOR-ALBUM
```
这将会把以 media group 形式发送的消息保存到名为 MyWebdav 的存储下, 并为每个相册新建一个文件夹(由第一个文件生成)来存储它们.

28
go.mod
View File

@@ -13,7 +13,7 @@ require (
github.com/go-faster/errors v0.7.1
github.com/gotd/contrib v0.21.0
github.com/gotd/td v0.125.0
github.com/minio/minio-go/v7 v7.0.92
github.com/minio/minio-go/v7 v7.0.94
github.com/rhysd/go-github-selfupdate v1.2.3
github.com/rs/xid v1.6.0
github.com/spf13/cobra v1.9.1
@@ -29,12 +29,12 @@ require (
github.com/catppuccin/go v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/bubbles v0.21.0 // indirect
github.com/charmbracelet/bubbletea v1.3.4 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/bubbletea v1.3.5 // indirect
github.com/charmbracelet/colorprofile v0.3.1 // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/ansi v0.9.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20250629123816-066ae234febc // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/coder/websocket v1.8.13 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
@@ -47,7 +47,7 @@ require (
github.com/go-faster/yaml v0.4.6 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/go-viper/mapstructure/v2 v2.3.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/go-github/v30 v30.1.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
@@ -57,7 +57,7 @@ require (
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -73,7 +73,7 @@ require (
github.com/ncruces/julianday v1.0.0 // indirect
github.com/ogen-go/ogen v1.14.0 // indirect
github.com/onsi/gomega v1.36.2 // indirect
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // 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
@@ -82,9 +82,9 @@ require (
github.com/tetratelabs/wazero v1.9.0 // indirect
github.com/tinylib/msgp v1.3.0 // indirect
github.com/ulikunitz/xz v0.5.12 // indirect
go.opentelemetry.io/otel v1.36.0 // indirect
go.opentelemetry.io/otel/metric v1.36.0 // indirect
go.opentelemetry.io/otel/trace v1.36.0 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
golang.org/x/crypto v0.39.0 // indirect
@@ -93,7 +93,7 @@ require (
golang.org/x/tools v0.34.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
modernc.org/libc v1.65.10 // indirect
modernc.org/libc v1.66.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.38.0 // indirect
@@ -108,7 +108,7 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/mitchellh/mapstructure v1.5.0
github.com/ncruces/go-sqlite3 v0.26.1
github.com/ncruces/go-sqlite3 v0.26.2
github.com/ncruces/go-sqlite3/gormlite v0.24.0
github.com/nicksnyder/go-i18n/v2 v2.6.0
github.com/pelletier/go-toml/v2 v2.2.4
@@ -120,7 +120,7 @@ require (
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
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sync v0.15.0
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.26.0

29
go.sum
View File

@@ -24,8 +24,12 @@ github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc=
github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
github.com/charmbracelet/huh v0.7.0 h1:W8S1uyGETgj9Tuda3/JdVkc3x7DBLZYPZc4c+/rnRdc=
github.com/charmbracelet/huh v0.7.0/go.mod h1:UGC3DZHlgOKHvHC07a5vHag41zzhpPFj34U92sOmyuk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
@@ -34,6 +38,8 @@ github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsy
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
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=
@@ -44,6 +50,8 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payR
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-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/exp/strings v0.0.0-20250629123816-066ae234febc h1:XFsX2G2Z1k1p9/52+7TYs2iYW//XCJXSD7xWlEeGvBM=
github.com/charmbracelet/x/exp/strings v0.0.0-20250629123816-066ae234febc/go.mod h1:Rgw3/F+xlcUc5XygUtimVSxAqCOsqyvJjqF5UHRvc5k=
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=
@@ -99,10 +107,13 @@ github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk=
github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
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/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -141,6 +152,8 @@ github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYW
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -165,6 +178,8 @@ github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.92 h1:jpBFWyRS3p8P/9tsRc+NuvqoFi7qAmTCFPoRFmobbVw=
github.com/minio/minio-go/v7 v7.0.92/go.mod h1:vTIc8DNcnAZIhyFsk8EB90AbPjj3j68aWIEQCiPj7d0=
github.com/minio/minio-go/v7 v7.0.94 h1:1ZoksIKPyaSt64AVOyaQvhDOgVC3MfZsWM6mZXRUGtM=
github.com/minio/minio-go/v7 v7.0.94/go.mod h1:71t2CqDt3ThzESgZUlU1rBN54mksGGlkLcFgguDnnAc=
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=
@@ -177,6 +192,8 @@ 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.26.1 h1:lBXmbmucH1Bsj57NUQR6T84UoMN7jnNImhF+ibEITJU=
github.com/ncruces/go-sqlite3 v0.26.1/go.mod h1:XFTPtFIo1DmGCh+XVP8KGn9b/o2f+z0WZuT09x2N6eo=
github.com/ncruces/go-sqlite3 v0.26.2 h1:5UkIBwdfMN2irpVI1dgi9TjTUlxNI06Rti1C8O7ZKVg=
github.com/ncruces/go-sqlite3 v0.26.2/go.mod h1:XFTPtFIo1DmGCh+XVP8KGn9b/o2f+z0WZuT09x2N6eo=
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-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
@@ -195,6 +212,8 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -246,10 +265,16 @@ go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJyS
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -264,6 +289,8 @@ golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 h1:bsqhLWFR6G6xiQcb+JoGqdKdRU6WzPWmK8E0jxTjzo4=
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -323,6 +350,8 @@ modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.65.10 h1:ZwEk8+jhW7qBjHIT+wd0d9VjitRyQef9BnzlzGwMODc=
modernc.org/libc v1.65.10/go.mod h1:StFvYpx7i/mXtBAfVOjaU0PWZOvIRoZSgXhrwXzr8Po=
modernc.org/libc v1.66.1 h1:4uQsntXbVyAgrV+j6NhKvDiUypoJL48BWQx6sy9y8ok=
modernc.org/libc v1.66.1/go.mod h1:AiZxInURfEJx516LqEaFcrC+X38rt9G7+8ojIXQKHbo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=

View File

@@ -1,5 +1,6 @@
package consts
const (
RuleStorNameChosen = "CHOSEN"
RuleStorNameChosen = "CHOSEN"
RuleDirPathNewForAlbum = "NEW-FOR-ALBUM" // create a new directory for album files
)

View File

@@ -5,6 +5,7 @@ type RuleType string
const (
FileNameRegex RuleType = "FILENAME-REGEX"
MessageRegex RuleType = "MESSAGE-REGEX"
IsAlbum RuleType = "IS-ALBUM"
)
func (r RuleType) String() string {
@@ -12,5 +13,5 @@ func (r RuleType) String() string {
}
func Values() []RuleType {
return []RuleType{FileNameRegex, MessageRegex}
return []RuleType{FileNameRegex, MessageRegex, IsAlbum}
}

38
pkg/rule/is_album.go Normal file
View File

@@ -0,0 +1,38 @@
package rule
import (
ruleenum "github.com/krau/SaveAny-Bot/pkg/enums/rule"
)
var _ RuleClass[bool] = (*RuleMediaType)(nil)
type RuleMediaType struct {
storInfo
matchAlbum bool
}
func (r RuleMediaType) Type() ruleenum.RuleType {
return ruleenum.IsAlbum
}
func (r RuleMediaType) Match(input bool) (bool, error) {
return r.matchAlbum == input, nil
}
func (r RuleMediaType) StorageName() string {
return r.storName
}
func (r RuleMediaType) StoragePath() string {
return r.storPath
}
func NewRuleMediaType(storName, storPath string, matchAlbum bool) (*RuleMediaType, error) {
return &RuleMediaType{
storInfo: storInfo{
storName: storName,
storPath: storPath,
},
matchAlbum: matchAlbum,
}, nil
}

View File

@@ -1,18 +1,17 @@
package tdler
package tfile
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"
)
type Client interface {
type DlerClient interface {
downloader.Client
}
func NewDownloader(client Client, file tfile.TGFile) *downloader.Builder {
func NewDownloader(file TGFile) *downloader.Builder {
return downloader.NewDownloader().WithPartSize(tglimit.MaxPartSize).
Download(client, file.Location()).WithThreads(dlutil.BestThreads(file.Size(), config.Cfg.Threads))
Download(file.Dler(), file.Location()).WithThreads(dlutil.BestThreads(file.Size(), config.Cfg.Threads))
}

View File

@@ -35,4 +35,4 @@ func WithSizeIfZero(size int64) TGFileOptions {
f.size = size
}
}
}
}

View File

@@ -10,6 +10,7 @@ import (
type TGFile interface {
Location() tg.InputFileLocationClass
Dler() DlerClient // witch client to use for downloading
Size() int64
Name() string
}
@@ -24,6 +25,7 @@ type tgFile struct {
size int64
name string
message *tg.Message
dler DlerClient
}
func (f *tgFile) Location() tg.InputFileLocationClass {
@@ -42,11 +44,20 @@ func (f *tgFile) Message() *tg.Message {
return f.message
}
func NewTGFile(location tg.InputFileLocationClass, size int64, name string,
func (f *tgFile) Dler() DlerClient {
return f.dler
}
func NewTGFile(
location tg.InputFileLocationClass,
dler DlerClient,
size int64,
name string,
opts ...TGFileOptions,
) TGFile {
f := &tgFile{
location: location,
dler: dler,
size: size,
name: name,
}
@@ -56,7 +67,7 @@ func NewTGFile(location tg.InputFileLocationClass, size int64, name string,
return f
}
func FromMedia(media tg.MessageMediaClass, opts ...TGFileOptions) (TGFile, error) {
func FromMedia(media tg.MessageMediaClass, client DlerClient, opts ...TGFileOptions) (TGFile, error) {
switch m := media.(type) {
case *tg.MessageMediaDocument:
document, ok := m.Document.AsNotEmpty()
@@ -70,14 +81,13 @@ func FromMedia(media tg.MessageMediaClass, opts ...TGFileOptions) (TGFile, error
break
}
}
file := &tgFile{
location: document.AsInputDocumentFileLocation(),
size: document.Size,
name: fileName,
}
for _, opt := range opts {
opt(file)
}
file := NewTGFile(
document.AsInputDocumentFileLocation(),
client,
document.Size,
fileName,
opts...,
)
return file, nil
case *tg.MessageMediaPhoto:
photo, ok := m.Photo.AsNotEmpty()
@@ -99,26 +109,26 @@ func FromMedia(media tg.MessageMediaClass, opts ...TGFileOptions) (TGFile, error
location.FileReference = photo.GetFileReference()
location.ThumbSize = size.GetType()
fileName := fmt.Sprintf("photo_%s_%d.jpg", time.Now().Format("2006-01-02_15-04-05"), photo.GetID())
file := &tgFile{
location: location,
size: 0,
name: fileName,
}
for _, opt := range opts {
opt(file)
}
file := NewTGFile(
location,
client,
0, // Photo size is not available in InputPhotoFileLocation
fileName,
opts...,
)
return file, nil
}
return nil, fmt.Errorf("unsupported media type: %T", media)
}
func FromMediaMessage(media tg.MessageMediaClass, msg *tg.Message, opts ...TGFileOptions) (TGFileMessage, error) {
file, err := FromMedia(media, opts...)
func FromMediaMessage(media tg.MessageMediaClass, client DlerClient, msg *tg.Message, opts ...TGFileOptions) (TGFileMessage, error) {
file, err := FromMedia(media, client, opts...)
if err != nil {
return nil, err
}
return &tgFile{
location: file.Location(),
dler: file.Dler(),
size: file.Size(),
name: file.Name(),
message: msg,

View File

@@ -13,6 +13,7 @@ import (
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"github.com/rs/xid"
)
type Minio struct {
@@ -61,7 +62,7 @@ func (m *Minio) Name() string {
}
func (m *Minio) JoinStoragePath(p string) string {
return path.Join(m.config.BasePath, p)
return strings.TrimPrefix(path.Join(m.config.BasePath, p), "/")
}
func (m *Minio) Save(ctx context.Context, r io.Reader, storagePath string) error {
@@ -72,6 +73,11 @@ 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 {
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
}
}
size := int64(-1)
if length := ctx.Value(ctxkey.ContentLength); length != nil {

View File

@@ -5,8 +5,11 @@ import (
"fmt"
"io"
"path"
"strconv"
"strings"
"time"
"github.com/duke-git/lancet/v2/convertor"
"github.com/gabriel-vasile/mimetype"
"github.com/gotd/td/telegram/message"
"github.com/gotd/td/telegram/message/styling"
@@ -70,9 +73,17 @@ func (t *Telegram) Save(ctx context.Context, r io.Reader, storagePath string) er
if tctx == nil {
return fmt.Errorf("failed to get telegram context")
}
peer := tctx.PeerStorage.GetInputPeerById(t.config.ChatID)
chatID := t.config.ChatID
if after, ok0 := strings.CutPrefix(convertor.ToString(chatID), "-100"); ok0 {
cid, err := strconv.ParseInt(after, 10, 64)
if err != nil {
return fmt.Errorf("failed to parse chat ID: %w", err)
}
chatID = cid
}
peer := tctx.PeerStorage.GetInputPeerById(chatID)
if peer == nil {
return fmt.Errorf("failed to get input peer for chat ID %d", t.config.ChatID)
return fmt.Errorf("failed to get input peer for chat ID %d", chatID)
}
mtype, err := mimetype.DetectReader(rs)
if err != nil {

View File

@@ -78,6 +78,10 @@ func TestWriteFile(t *testing.T) {
remotePath: "hello.txt",
content: "Hello webdav",
},
{
remotePath: "//nested/dir/test.txt",
content: "Nested file",
},
{
remotePath: "nested/dir/test.txt",
content: "Nested file",

View File

@@ -12,6 +12,7 @@ import (
"github.com/charmbracelet/log"
config "github.com/krau/SaveAny-Bot/config/storage"
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
"github.com/rs/xid"
)
type Webdav struct {
@@ -56,6 +57,11 @@ func (w *Webdav) Save(ctx context.Context, r io.Reader, storagePath string) erro
candidate := storagePath
for i := 1; w.Exists(ctx, candidate); i++ {
candidate = fmt.Sprintf("%s_%d%s", base, i, ext)
if i > 1000 {
w.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
}
}
if err := w.client.MkDir(ctx, path.Dir(candidate)); err != nil {