Compare commits

..

58 Commits

Author SHA1 Message Date
krau
942022969c style: format codes 2026-08-17 19:01:51 +08:00
krau
ab89310325 fix: drop unbounded saved-path cache in telegram storage
Telegram cannot reliably query remote file existence, so the
cache answered a question it could not answer and grew without
bound. Exists returns false again, as before.
2026-08-16 22:05:08 +08:00
krau
b27bb734be style: gofmt test files 2026-08-16 21:56:01 +08:00
krau
fc0c8975a0 style: trim verbose comments
Drop process-style explanations; keep one-line behavior notes.
2026-08-16 21:56:01 +08:00
krau
524a259ae4 test: exercise the alist 401 refresh path
Concurrent uploads now reject the init token, share one
refresh and retry with the new token; token-only stays inert.
2026-08-16 21:51:11 +08:00
krau
e8257c46d2 fix: refresh alist token after startup 401s
The init login no longer satisfies the dedup window, so an
early 401 triggers a real refresh; later 401s reuse it.
Clarify that streaming uploads cannot replay their body.
2026-08-16 21:51:11 +08:00
krau
6a11b8ac8d test: cover cancellation with IgnoreErrors 2026-08-16 21:44:40 +08:00
krau
47d941d851 test: cover notifyProgress tracker call 2026-08-16 21:44:40 +08:00
krau
7e502442c5 fix: propagate cancellation past IgnoreErrors
Cancelled tasks must not be reported as successful: only
ordinary element failures are ignored.
2026-08-16 21:44:35 +08:00
krau
a9425cb5ea fix: call tracker from notifyProgress instead of recursing
The helper called itself, overflowing the stack on any batch
task with a progress tracker.
2026-08-16 21:44:35 +08:00
krau
6ad2a2c884 test: cover alist refresh semantics
Concurrent refresh uses username/password; token-only storage
never attempts a login on 401.
2026-08-16 21:35:21 +08:00
krau
0d42c9f23d fix: skip login refresh for token-only alist storage
401 responses surface the auth error instead of sending a
credential-less login; the refresh window never exceeds TokenExp.
2026-08-16 21:35:21 +08:00
krau
4c8f35ae80 fix: report partial failure in batch done message
IgnoreErrors runs with failed elements show success and failed
counts instead of claiming every file completed.
2026-08-16 21:35:14 +08:00
krau
0a67b8bb57 test: cover nil-tracker task execution 2026-08-16 21:35:14 +08:00
krau
196bb9941f fix: keep album order when filtering failed batch items
Download results are stored by original index so surviving
elements keep their source order.
2026-08-16 21:35:14 +08:00
krau
973f3499a9 fix: guard nil progress trackers in api-created tasks
Batch, telegraph and transfer tasks run without a Telegram tracker
when created through the API; their callbacks must not panic.
2026-08-16 21:34:52 +08:00
krau
d9cb5f9cfc fix: deduplicate concurrent storage initialization
singleflight merges first-time inits so side effects are not duplicated.
2026-08-16 21:20:12 +08:00
krau
ccb702148e fix: localize storage lookup errors in /dir
Use the shared i18n key and escape the dynamic error.
2026-08-16 21:20:12 +08:00
krau
85b1f35dc5 fix: count parsed resources in progress text 2026-08-16 21:20:12 +08:00
krau
9f93a95258 test: cover concurrent alist 401 retry
Ten parallel uploads share a single re-login under -race.
2026-08-16 21:20:12 +08:00
krau
189bf9c736 fix: deduplicate alist token refreshes
Guard token access with a mutex and merge concurrent logins.
Reuse a recent refresh to avoid login storms.
2026-08-16 21:20:11 +08:00
krau
534ed7a7c2 fix: record telegram saved paths only after upload
Skip-large returns a sentinel so skipped files are not marked as saved.
2026-08-16 21:20:11 +08:00
krau
1d4997ba2d fix: upload only downloaded batch elements
Failed elements keep partial cache files and never reach the backend.
Successfully downloaded siblings still upload when one element fails.
2026-08-16 21:20:11 +08:00
krau
f7e532ca7e fix: make cache init idempotent 2026-08-16 20:30:06 +08:00
krau
0957d93da3 chore: ignore cache directory 2026-08-16 20:30:06 +08:00
krau
324d8c100f ci: fix BuildTime formatting and align checkout
Actions format does not format dates; pass the raw timestamp.
2026-08-16 20:28:45 +08:00
krau
4cb23f04b6 docs: document parser plugin config 2026-08-16 20:28:45 +08:00
krau
a92ca75c4f refactor: drop unused hook config 2026-08-16 20:28:45 +08:00
krau
4e99b4bdc9 fix: load remote config without local lookup
Skip the local file search after reading a config URL and add a timeout.
2026-08-16 20:28:45 +08:00
krau
389be59371 refactor: remove dead code from api and bot
Drop the empty ProgressTracker shim, unused token context key and a redundant SetBotCommands call.
2026-08-16 20:28:39 +08:00
krau
0d49ae94af fix: add timeouts and backoff to webhook delivery 2026-08-16 20:28:39 +08:00
krau
48e739f06d fix: fail fast when API listen fails
Bind synchronously and surface errors instead of logging them.
2026-08-16 20:28:39 +08:00
krau
b4fbbae068 fix: notify users on invalid update version 2026-08-16 20:28:33 +08:00
krau
29be3ee90c fix: avoid leaking raw errors in /dir reply 2026-08-16 20:28:33 +08:00
krau
56660a7705 fix: initialize userbot context once
Replace the racy lazy init with sync.OnceValue.
2026-08-16 20:28:33 +08:00
krau
0dfb6af153 fix: require permission for callback handlers 2026-08-16 20:28:33 +08:00
krau
63f53fac7f fix: isolate media groups per user
Key pending groups by chat, user and group id.
2026-08-16 20:28:33 +08:00
krau
607ec8aced fix: guard callback data parsing
Reject malformed callback payloads before indexing split parts.
2026-08-16 20:28:33 +08:00
krau
cd9886d710 fix: show all queued tasks in /task list
Render up to ten tasks and append the truncation note once.
2026-08-16 20:28:33 +08:00
krau
3f7f50133e test: cover invalid plugin version rejection 2026-08-16 20:28:27 +08:00
krau
c7911cc208 refactor: remove commented-out dead code
Drop unused kemono legacy types and commented response structs.
2026-08-16 20:28:27 +08:00
krau
b31d628c19 fix: guard kemono parser against nil fields
Skip sparse preview and attachment entries instead of panicking.
2026-08-16 20:28:27 +08:00
krau
60fd2e04d9 fix: harden JS parser plugin runtime
Validate semver instead of panicking and require canHandle.
Recover plugin worker panics and time out CanHandle calls.
Return a copy from the registry and sanitize install filenames.
2026-08-16 20:28:27 +08:00
krau
b72dd67be9 fix: guard storage registry maps
Protect Storages and UserStorages with mutexes and expose read accessors.
2026-08-16 20:28:21 +08:00
krau
aa25eb1510 fix: enforce telegram album limits and track saved paths
Use tglimit.MaxAlbumItems for album batching and video splitting.
Exists now reports previously saved paths instead of always false.
2026-08-16 20:28:21 +08:00
krau
ba0deababc fix: fail alist init instead of exiting
Replace log.Fatalf with wrapped errors so a bad alist cannot kill the bot.
Cancel token refresh with the init context and re-login on 401.
2026-08-16 20:28:21 +08:00
krau
d1b2dbfe5f fix: kill rclone subprocess on reader close
Prevent cat processes from hanging after the pipe is closed.
2026-08-16 20:28:15 +08:00
krau
bd7da7c31e fix: preserve webdav error causes
Wrap mkdir and write failures with %w and drop dead error values.
2026-08-16 20:28:15 +08:00
krau
1981d8662b fix: sanitize local storage paths
Reject absolute paths and dot-dot escapes in Save.
Check close errors and wrap creation failures.
2026-08-16 20:28:15 +08:00
krau
51fcd17922 refactor: share unique filename logic
Storage backends use fsutil.UniquePath instead of local loops.
2026-08-16 20:28:15 +08:00
krau
610f586d45 refactor: share progress throttling helpers
Move size-tiered and count-based throttling into progressutil.
Localize hardcoded Chinese progress strings.
Drop five duplicated implementations and dead local copies.
2026-08-16 20:28:07 +08:00
krau
6bc12d6feb fix: validate i18n key parity across locales
geni18n fails when a language file misses any key.
Align the syncpeers completion key between en and zh-Hans.
Translate three untranslated parse keys in zh-Hans.
2026-08-16 20:28:03 +08:00
krau
ad41aec22c fix: report streamed upload bytes in batch tasks
Stream downloads report their byte count as the upload total.
2026-08-16 20:27:53 +08:00
krau
4389bfe046 fix: honor IgnoreErrors in batch tasks
Element failures no longer cancel sibling elements or stop later groups.
2026-08-16 20:27:42 +08:00
krau
832eb27d4f fix: harden per-item processing tracking in tasks
Use the resource fingerprint as the dedup key on insert and delete.
Check and set processing entries atomically instead of TOCTOU.
Count only successful downloads.
2026-08-16 20:27:42 +08:00
krau
b6e981282d fix: make resource fingerprints deterministic
Sort map keys before hashing so Resource.ID is stable.
2026-08-16 20:27:42 +08:00
krau
9ed5b5f962 fix: init task queue lazily and close on shutdown
AddTask is safe before Run by initializing the queue once.
Close unblocks workers waiting in Get.
2026-08-16 20:27:26 +08:00
krau
4527022853 fix: prevent queue deadlock after cancelling tasks
Get no longer recurses while holding the mutex.
Cancelled queued tasks leave the map, making their IDs reusable.
Closed empty queues return ErrQueueClosed.
2026-08-16 20:27:26 +08:00
10 changed files with 9 additions and 271 deletions

View File

@@ -1,7 +1,6 @@
package api
import (
"context"
"sync"
"time"
@@ -196,6 +195,6 @@ func (t *TaskProgressInfo) Emit(e taskevent.Event) {
if notify {
payload := CreateWebhookPayload(t.TaskID, t.Type, t.Status, t.Storage, t.Path, e.Err)
SendWebhook(context.Background(), payload)
SendWebhook(nil, payload)
}
}

View File

@@ -1,13 +1,10 @@
package handlers
import (
"errors"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/duke-git/lancet/v2/slice"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/dirutil"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/config"
@@ -15,23 +12,10 @@ import (
"github.com/krau/SaveAny-Bot/storage"
)
// responsibleUserID returns the sender's ID. Callback queries carry it
// natively; message updates resolve it through the entity map.
func responsibleUserID(u *ext.Update) int64 {
if u.CallbackQuery != nil {
return u.CallbackQuery.GetUserID()
}
return u.GetUserChat().GetID()
}
func checkPermission(ctx *ext.Context, update *ext.Update) error {
userID := responsibleUserID(update)
userID := update.GetUserChat().GetID()
if !slice.Contain(config.C().GetUsersID(), userID) {
if cbq := update.CallbackQuery; cbq != nil {
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(cbq.GetQueryID(), i18n.T(i18nk.BotMsgCommonErrorNoPermission, nil)))
} else {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorNoPermission, nil)), nil)
}
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorNoPermission, nil)), nil)
return dispatcher.EndGroups
}
@@ -39,12 +23,10 @@ func checkPermission(ctx *ext.Context, update *ext.Update) error {
}
// withPermission wraps a callback handler with the same whitelist check used
// for message handlers (checkPermission). ContinueGroups is the dispatcher's
// success sentinel, not an error: only real failures and EndGroups stop the
// chain before the wrapped handler runs.
// for message handlers (checkPermission).
func withPermission(handler func(*ext.Context, *ext.Update) error) func(*ext.Context, *ext.Update) error {
return func(ctx *ext.Context, update *ext.Update) error {
if err := checkPermission(ctx, update); err != nil && !errors.Is(err, dispatcher.ContinueGroups) {
if err := checkPermission(ctx, update); err != nil {
return err
}
return handler(ctx, update)

View File

@@ -1,85 +0,0 @@
package handlers
import (
"os"
"path/filepath"
"testing"
"github.com/celestix/gotgproto/ext"
"github.com/celestix/gotgproto/types"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/config"
)
// Regression: callback queries usually arrive as updateShort without entity
// maps, so resolving the sender through the entity map yields ID 0 and every
// click was denied by the whitelist check. Callback updates must use the
// native UserID field.
func TestResponsibleUserID(t *testing.T) {
tests := []struct {
name string
update *ext.Update
want int64
}{
{
name: "callback query uses native user id",
update: &ext.Update{CallbackQuery: &tg.UpdateBotCallbackQuery{UserID: 42}},
want: 42,
},
{
name: "message resolves through entity map",
update: &ext.Update{
EffectiveMessage: &types.Message{Message: &tg.Message{PeerID: &tg.PeerUser{UserID: 7}}},
Entities: &tg.Entities{Users: map[int64]*tg.User{7: {ID: 7}}},
},
want: 7,
},
{
name: "callback query ignores entity map",
update: &ext.Update{
CallbackQuery: &tg.UpdateBotCallbackQuery{UserID: 9},
Entities: &tg.Entities{Users: map[int64]*tg.User{8: {ID: 8}}},
},
want: 9,
},
{
name: "unresolvable update yields zero",
update: &ext.Update{},
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := responsibleUserID(tt.update); got != tt.want {
t.Fatalf("responsibleUserID() = %d, want %d", got, tt.want)
}
})
}
}
// Regression: withPermission must treat ContinueGroups (the dispatcher's
// success sentinel) as a pass and invoke the wrapped handler. v0.60.1 treated
// it as an error, so every permitted callback was swallowed before the real
// handler ran.
func TestWithPermissionInvokesHandler(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte("workers = 2\n\n[[users]]\nid = 42\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := config.Init(t.Context(), path); err != nil {
t.Fatal(err)
}
update := &ext.Update{CallbackQuery: &tg.UpdateBotCallbackQuery{UserID: 42}}
called := false
handler := withPermission(func(ctx *ext.Context, u *ext.Update) error {
called = true
return nil
})
if err := handler(&ext.Context{}, update); err != nil {
t.Fatalf("withPermission returned error: %v", err)
}
if !called {
t.Fatal("withPermission did not invoke the wrapped handler")
}
}

View File

@@ -64,7 +64,7 @@ func handleWatchCmd(ctx *ext.Context, update *ext.Update) error {
filter := ""
if len(args) > 2 {
filterArg := strings.Join(args[2:], " ")
filterType, _, _ := strings.Cut(filterArg, ":")
filterType := strings.Split(filterArg, ":")[0]
filterData := strings.Split(filterArg, ":")[1]
if filterType == "" || filterData == "" {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchErrorFilterFormatInvalid)), nil)

View File

@@ -351,7 +351,7 @@ bot:
info_filename_prefix: "Filename: "
info_prompt_select_storage: "\nPlease select storage"
progress:
batch_status_header: "<b>📦 Processing</b>\n\nFiles: <code>{{.Total}}</code> | Total size: <code>{{.TotalSize}}</code>\nStatus: ✅ <code>{{.Completed}}</code> | 📥 <code>{{.Downloaded}}</code> | ⏳ <code>{{.Waiting}}</code>\nTotal speed: ⬇️ <code>{{.DownloadSpeed}}</code> | ⬆️ <code>{{.UploadSpeed}}</code>"
batch_status_header: "<b>📦 Processing</b>\n\nFiles: <code>{{.Total}}</code>\nStatus: ✅ <code>{{.Completed}}</code> | 📥 <code>{{.Downloaded}}</code> | ⏳ <code>{{.Waiting}}</code>\nTotal speed: ⬇️ <code>{{.DownloadSpeed}}</code> | ⬆️ <code>{{.UploadSpeed}}</code>"
batch_item_downloading: "<blockquote><b>⬇️ {{.Index}}/{{.Total}} Downloading</b>\n<code>{{.Name}}</code>\n{{.Bar}} <code>{{.Progress}}%</code>\nSpeed: <code>{{.Speed}}</code>\nSize: <code>{{.Current}}</code> / <code>{{.Size}}</code></blockquote>"
batch_item_downloading_unknown: "<blockquote><b>⬇️ {{.Index}}/{{.Total}} Downloading</b>\n<code>{{.Name}}</code>\nSpeed: <code>{{.Speed}}</code>\nSize: <code>{{.Current}}</code> / unknown</blockquote>"
batch_item_transferring: "<blockquote><b>↕️ {{.Index}}/{{.Total}} Transferring</b>\n<code>{{.Name}}</code>\n{{.Bar}} <code>{{.Progress}}%</code>\nSpeed: <code>{{.Speed}}</code>\nSize: <code>{{.Current}}</code> / <code>{{.Size}}</code></blockquote>"

View File

@@ -352,7 +352,7 @@ bot:
info_filename_prefix: "文件名: "
info_prompt_select_storage: "\n请选择存储位置"
progress:
batch_status_header: "<b>📦 正在处理</b>\n\n文件<code>{{.Total}}</code> 总大小:<code>{{.TotalSize}}</code>\n状态✅ <code>{{.Completed}}</code> 📥 <code>{{.Downloaded}}</code> ⏳ <code>{{.Waiting}}</code>\n总速度 <code>{{.DownloadSpeed}}</code> ⬆️ <code>{{.UploadSpeed}}</code>"
batch_status_header: "<b>📦 正在处理</b>\n\n文件<code>{{.Total}}</code>\n状态✅ <code>{{.Completed}}</code> 📥 <code>{{.Downloaded}}</code> ⏳ <code>{{.Waiting}}</code>\n总速度 <code>{{.DownloadSpeed}}</code> ⬆️ <code>{{.UploadSpeed}}</code>"
batch_item_downloading: "<blockquote><b>⬇️ {{.Index}}/{{.Total}} 下载中</b>\n<code>{{.Name}}</code>\n{{.Bar}} <code>{{.Progress}}%</code>\n速度<code>{{.Speed}}</code>\n大小<code>{{.Current}}</code> / <code>{{.Size}}</code></blockquote>"
batch_item_downloading_unknown: "<blockquote><b>⬇️ {{.Index}}/{{.Total}} 下载中</b>\n<code>{{.Name}}</code>\n速度<code>{{.Speed}}</code>\n大小<code>{{.Current}}</code> / 未知</blockquote>"
batch_item_transferring: "<blockquote><b>↕️ {{.Index}}/{{.Total}} 传输中</b>\n<code>{{.Name}}</code>\n{{.Bar}} <code>{{.Progress}}%</code>\n速度<code>{{.Speed}}</code>\n大小<code>{{.Current}}</code> / <code>{{.Size}}</code></blockquote>"

View File

@@ -1,10 +1,7 @@
package tdler
import (
"context"
"github.com/gotd/td/telegram/downloader"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common/utils/dlutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/consts/tglimit"
@@ -13,23 +10,5 @@ import (
func NewDownloader(file tfile.TGFile) *downloader.Builder {
return downloader.NewDownloader().WithPartSize(tglimit.MaxPartSize).
Download(eofAwareClient{Client: file.Dler(), size: file.Size()}, file.Location()).
WithThreads(dlutil.BestThreads(file.Size(), config.C().Threads))
}
// eofAwareClient answers upload.getFile requests at or past the end of the
// file with an empty chunk. gotd's downloader is size-unaware: for files
// whose size is an exact multiple of the part size it issues one final
// request at offset == size and expects an empty chunk, but Telegram rejects
// it with 400 OFFSET_INVALID and the whole download fails.
type eofAwareClient struct {
downloader.Client
size int64
}
func (c eofAwareClient) UploadGetFile(ctx context.Context, req *tg.UploadGetFileRequest) (tg.UploadFileClass, error) {
if req.Offset >= c.size {
return &tg.UploadFile{}, nil
}
return c.Client.UploadGetFile(ctx, req)
Download(file.Dler(), file.Location()).WithThreads(dlutil.BestThreads(file.Size(), config.C().Threads))
}

View File

@@ -1,112 +0,0 @@
package tdler
import (
"bytes"
"context"
"sync"
"testing"
"github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
"github.com/krau/SaveAny-Bot/pkg/tfile"
)
// serverLikeClient mimics real Telegram upload.getFile behavior: it returns
// up to limit bytes per chunk, and answers any offset at or past the end of
// the file with 400 OFFSET_INVALID.
type serverLikeClient struct {
data []byte
mu sync.Mutex
maxOffset int64
}
func (c *serverLikeClient) UploadGetFile(_ context.Context, req *tg.UploadGetFileRequest) (tg.UploadFileClass, error) {
c.mu.Lock()
if req.Offset > c.maxOffset {
c.maxOffset = req.Offset
}
c.mu.Unlock()
if req.Offset >= int64(len(c.data)) {
return nil, tgerr.New(400, "OFFSET_INVALID")
}
end := min(len(c.data), int(req.Offset)+req.Limit)
return &tg.UploadFile{Bytes: c.data[req.Offset:end]}, nil
}
func (c *serverLikeClient) UploadGetFileHashes(context.Context, *tg.UploadGetFileHashesRequest) ([]tg.FileHash, error) {
return nil, nil
}
func (c *serverLikeClient) UploadReuploadCDNFile(context.Context, *tg.UploadReuploadCDNFileRequest) ([]tg.FileHash, error) {
return nil, nil
}
func (c *serverLikeClient) UploadGetCDNFileHashes(context.Context, *tg.UploadGetCDNFileHashesRequest) ([]tg.FileHash, error) {
return nil, nil
}
func (c *serverLikeClient) UploadGetWebFile(context.Context, *tg.UploadGetWebFileRequest) (*tg.UploadWebFile, error) {
return nil, nil
}
type memWriterAt struct {
b []byte
}
func (w *memWriterAt) WriteAt(p []byte, off int64) (int, error) {
copy(w.b[off:], p)
return len(p), nil
}
func TestDownloadServerLikeEOF(t *testing.T) {
const partSize = 1024 * 1024
tests := []struct {
name string
size int
parallel bool
}{
{"stream exact multiple of part size", 2 * partSize, false},
{"stream non-multiple", 2*partSize + 12345, false},
{"stream smaller than part size", 1234, false},
{"parallel exact multiple of part size", 2 * partSize, true},
{"parallel non-multiple", 2*partSize + 12345, true},
{"parallel smaller than part size", 1234, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data := make([]byte, tt.size)
for i := range data {
data[i] = byte(i % 251)
}
client := &serverLikeClient{data: data}
file := tfile.NewTGFile(
&tg.InputDocumentFileLocation{ID: 1, AccessHash: 2},
client, int64(tt.size), "test.bin",
)
dl := NewDownloader(file)
var got []byte
var err error
if tt.parallel {
buf := make([]byte, tt.size)
_, err = dl.WithThreads(4).Parallel(context.Background(), &memWriterAt{b: buf})
got = buf
} else {
var buf bytes.Buffer
_, err = dl.Stream(context.Background(), &buf)
got = buf.Bytes()
}
if err != nil {
t.Fatalf("download failed: %v", err)
}
if !bytes.Equal(got, data) {
t.Fatalf("downloaded %d bytes, want %d matching bytes", len(got), len(data))
}
if client.maxOffset >= int64(tt.size) {
t.Fatalf("requested offset %d at or past EOF (size %d)", client.maxOffset, tt.size)
}
})
}
}

View File

@@ -152,7 +152,6 @@ func buildBatchProgressMessage(info TaskInfo, skipped []string, activeLimit int)
uploadSpeedText := formatSpeed(uploadSpeed)
header := localizedProgressMarkup(i18nk.BotMsgProgressBatchStatusHeader, map[string]any{
"Total": total,
"TotalSize": dlutil.FormatSize(info.ActualTotalSize()),
"Completed": completed,
"Downloaded": downloaded,
"Waiting": waiting,

View File

@@ -95,30 +95,6 @@ func TestBatchProgressShowsTransferSpeedAndSize(t *testing.T) {
}
}
func TestBatchProgressHeaderShowsTotalSize(t *testing.T) {
useProgressRegressionLocale(t)
task := newProgressRegressionTask(nil,
progressRegressionFile{"first", 1024},
progressRegressionFile{"second", 1024},
)
message := buildBatchProgressMessage(task, nil, 2)
if message.Err != nil {
t.Fatalf("buildBatchProgressMessage() failed: %v", message.Err)
}
assertProgressRegressionContains(t, message.Text,
"文件2 总大小2.00 KB",
)
i18n.Init("en")
english := buildBatchProgressMessage(task, nil, 2)
if english.Err != nil {
t.Fatalf("English batch template failed: %v", english.Err)
}
assertProgressRegressionContains(t, english.Text,
"Files: 2 | Total size: 2.00 KB",
)
}
func TestBatchProgressLimitsRowsWithoutHidingActiveUpload(t *testing.T) {
useProgressRegressionLocale(t)
task := newProgressRegressionTask(nil,