Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a2274baa0 | ||
|
|
69a3ed6f4e | ||
|
|
36f3dd83fc | ||
|
|
501b9d844a | ||
|
|
03cec7ec01 | ||
|
|
dc0debcd1c | ||
|
|
4b136bd41e | ||
|
|
d703f11ea0 | ||
|
|
3ce9926967 | ||
|
|
80146176f0 | ||
|
|
14ba2afdf8 | ||
|
|
f4d427a1cb | ||
|
|
f84c83a7e2 | ||
|
|
cb6540c017 | ||
|
|
e7bab27543 | ||
|
|
f693bd6103 | ||
|
|
75f52569a0 | ||
|
|
c795f957a9 | ||
|
|
3b85911e3d |
@@ -6,6 +6,6 @@
|
||||
downloads/
|
||||
data/
|
||||
cache/
|
||||
docs
|
||||
docs/
|
||||
config.example.toml
|
||||
docker-compose.*
|
||||
|
||||
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
@@ -1,5 +1,5 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
custom: [
|
||||
"https://afdian.com/a/acherkrau"
|
||||
"https://afdian.com/a/unvapp"
|
||||
]
|
||||
27
.github/workflows/build-docker.yml
vendored
27
.github/workflows/build-docker.yml
vendored
@@ -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)) }}
|
||||
|
||||
@@ -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 .
|
||||
|
||||
|
||||
@@ -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 初始化完成")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,")
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
15
common/utils/netutil/proxy.go
Normal file
15
common/utils/netutil/proxy.go
Normal 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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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' 的存储
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
### 存储端列表
|
||||
|
||||
@@ -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
28
go.mod
@@ -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
29
go.sum
@@ -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=
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package consts
|
||||
|
||||
const (
|
||||
RuleStorNameChosen = "CHOSEN"
|
||||
RuleStorNameChosen = "CHOSEN"
|
||||
RuleDirPathNewForAlbum = "NEW-FOR-ALBUM" // create a new directory for album files
|
||||
)
|
||||
|
||||
@@ -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
38
pkg/rule/is_album.go
Normal 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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -35,4 +35,4 @@ func WithSizeIfZero(size int64) TGFileOptions {
|
||||
f.size = size
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user