From c7b2d884845457e60835dffa0a0c48b0e953f654 Mon Sep 17 00:00:00 2001 From: Luiz Fernando Date: Tue, 27 Jan 2026 22:18:36 -0300 Subject: [PATCH 1/3] feat(forward): add --rename-file flag for media file renaming - Add new --rename-file flag to dynamically rename media files during forwarding - Use CEL expressions with access to From.ID, Message.ID, Message.Media.Name - Support per-message evaluation for albums (each video gets unique filename) - Automatically switch to clone mode when rename-file is used - Add documentation in English and Chinese - Add unit tests for resolveRenameFile function --- app/forward/elem.go | 15 ++++++ app/forward/forward.go | 46 ++++++++++++++---- app/forward/forward_test.go | 81 ++++++++++++++++++++++++++++++++ app/forward/iter.go | 43 +++++++++++++---- cmd/forward.go | 1 + core/forwarder/clone.go | 11 ++++- core/forwarder/forwarder.go | 32 ++++++++++++- core/forwarder/iter.go | 4 ++ docs/content/en/guide/forward.md | 32 +++++++++++++ docs/content/zh/guide/forward.md | 32 +++++++++++++ 10 files changed, 276 insertions(+), 21 deletions(-) create mode 100644 app/forward/forward_test.go diff --git a/app/forward/elem.go b/app/forward/elem.go index 78dde3cce8..7d328fc28d 100644 --- a/app/forward/elem.go +++ b/app/forward/elem.go @@ -7,12 +7,18 @@ import ( "github.com/iyear/tdl/core/forwarder" ) +// RenameFunc is a function that computes a renamed filename for a given message. +// It takes the source peer and message, returning the new filename. +// Returns empty string to keep the original filename. +type RenameFunc func(from peers.Peer, msg *tg.Message) string + type iterElem struct { from peers.Peer msg *tg.Message to peers.Peer thread int modeOverride forwarder.Mode + renameFunc RenameFunc // closure to compute filename per message opts iterOptions } @@ -36,3 +42,12 @@ func (i *iterElem) AsSilent() bool { return i.opts.silent } func (i *iterElem) AsDryRun() bool { return i.opts.dryRun } func (i *iterElem) AsGrouped() bool { return i.opts.grouped } + +// ComputeRenamedFilename computes the renamed filename for a given message. +// This allows each message in an album to have its own unique filename. +func (i *iterElem) ComputeRenamedFilename(msg *tg.Message) string { + if i.renameFunc == nil { + return "" + } + return i.renameFunc(i.from, msg) +} diff --git a/app/forward/forward.go b/app/forward/forward.go index e46d5e44f2..d6bacbb3e4 100644 --- a/app/forward/forward.go +++ b/app/forward/forward.go @@ -29,18 +29,19 @@ import ( ) type Options struct { - From []string - To string - Edit string - Mode forwarder.Mode - Silent bool - DryRun bool - Single bool - Desc bool + From []string + To string + Edit string + RenameFile string + Mode forwarder.Mode + Silent bool + DryRun bool + Single bool + Desc bool } func Run(ctx context.Context, c *telegram.Client, kvd storage.Storage, opts Options) (rerr error) { - if opts.To == "-" || opts.Edit == "-" { + if opts.To == "-" || opts.Edit == "-" || opts.RenameFile == "-" { fg := texpr.NewFieldsGetter(nil) fields, err := fg.Walk(exprEnv(nil, nil)) @@ -78,6 +79,11 @@ func Run(ctx context.Context, c *telegram.Client, kvd storage.Storage, opts Opti return errors.Wrap(err, "resolve edit") } + renameFile, err := resolveRenameFile(opts.RenameFile) + if err != nil { + return errors.Wrap(err, "resolve rename-file") + } + fwProgress := prog.New(pw.FormatNumber) fwProgress.SetNumTrackersExpected(totalMessages(dialogs)) prog.EnablePS(ctx, fwProgress) @@ -89,6 +95,7 @@ func Run(ctx context.Context, c *telegram.Client, kvd storage.Storage, opts Opti pool: pool, to: to, edit: edit, + renameFile: renameFile, dialogs: dialogs, mode: opts.Mode, silent: opts.Silent, @@ -190,6 +197,27 @@ func resolveEdit(input string) (*vm.Program, error) { return compile(input) } +// resolveRenameFile returns nil if input is empty, otherwise it returns a vm.Program. It can be a text or a file based on expression engine. +func resolveRenameFile(input string) (*vm.Program, error) { + compile := func(i string) (*vm.Program, error) { + // we pass empty peer and message to enable type checking + return expr.Compile(i, expr.Env(exprEnv(nil, nil)), expr.AsKind(reflect.String)) + } + + // no rename-file, nil program + if input == "" { + return nil, nil + } + + // file + if exp, err := os.ReadFile(input); err == nil { + return compile(string(exp)) + } + + // text + return compile(input) +} + func totalMessages(dialogs []*tmessage.Dialog) int { var total int for _, d := range dialogs { diff --git a/app/forward/forward_test.go b/app/forward/forward_test.go new file mode 100644 index 0000000000..6f5ee68c68 --- /dev/null +++ b/app/forward/forward_test.go @@ -0,0 +1,81 @@ +package forward + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolveRenameFile(t *testing.T) { + tests := []struct { + name string + input string + wantNil bool + wantErr bool + }{ + { + name: "empty input returns nil", + input: "", + wantNil: true, + wantErr: false, + }, + { + name: "valid expression compiles", + input: `"test_" + Message.Media.Name`, + wantNil: false, + wantErr: false, + }, + { + name: "simple string expression", + input: `"renamed_file.mp4"`, + wantNil: false, + wantErr: false, + }, + { + name: "complex expression with string conversion", + input: "`[` + string(From.ID) + `_` + string(Message.ID) + `]_` + Message.Media.Name", + wantNil: false, + wantErr: false, + }, + { + name: "invalid expression fails", + input: "this is not valid expr {{{}}}", + wantNil: true, // when error occurs, result is nil + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := resolveRenameFile(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("resolveRenameFile() error = %v, wantErr %v", err, tt.wantErr) + return + } + if (got == nil) != tt.wantNil { + t.Errorf("resolveRenameFile() got = %v, wantNil %v", got, tt.wantNil) + } + }) + } +} + +func TestResolveRenameFileFromFile(t *testing.T) { + // Create temp file with expression + tmpDir := t.TempDir() + exprFile := filepath.Join(tmpDir, "rename_expr.txt") + expr := "`[` + string(From.ID) + `]_` + Message.Media.Name" + + if err := os.WriteFile(exprFile, []byte(expr), 0644); err != nil { + t.Fatalf("failed to write temp file: %v", err) + } + + // Test reading from file + got, err := resolveRenameFile(exprFile) + if err != nil { + t.Errorf("resolveRenameFile() from file error = %v", err) + return + } + if got == nil { + t.Error("resolveRenameFile() from file returned nil, expected compiled program") + } +} diff --git a/app/forward/iter.go b/app/forward/iter.go index 405490fc32..f3f8a937d3 100644 --- a/app/forward/iter.go +++ b/app/forward/iter.go @@ -21,16 +21,17 @@ import ( ) type iterOptions struct { - manager *peers.Manager - pool dcpool.Pool - to *vm.Program - edit *vm.Program - dialogs []*tmessage.Dialog - mode forwarder.Mode - silent bool - dryRun bool - grouped bool - delay time.Duration + manager *peers.Manager + pool dcpool.Pool + to *vm.Program + edit *vm.Program + renameFile *vm.Program + dialogs []*tmessage.Dialog + mode forwarder.Mode + silent bool + dryRun bool + grouped bool + delay time.Duration } type iter struct { @@ -182,6 +183,27 @@ func (i *iter) Next(ctx context.Context) bool { modeOverride = forwarder.ModeClone } + // rename file - create a closure that can compute filename for any message + var renameFunc RenameFunc + if i.opts.renameFile != nil { + // Capture the compiled program in a closure + renameProgram := i.opts.renameFile + renameFunc = func(fromPeer peers.Peer, m *tg.Message) string { + result, err := texpr.Run(renameProgram, exprEnv(fromPeer, m)) + if err != nil { + // Log error but return empty (keep original filename) + return "" + } + r, ok := result.(string) + if !ok { + return "" + } + return r + } + // rename-file requires clone mode (re-upload with new filename) + modeOverride = forwarder.ModeClone + } + if err != nil { i.err = errors.Wrapf(err, "resolve dest: %v", result) return false @@ -193,6 +215,7 @@ func (i *iter) Next(ctx context.Context) bool { to: to, thread: thread, modeOverride: modeOverride, + renameFunc: renameFunc, opts: i.opts, } diff --git a/cmd/forward.go b/cmd/forward.go index ae2087df41..f31a60b961 100644 --- a/cmd/forward.go +++ b/cmd/forward.go @@ -31,6 +31,7 @@ func NewForward() *cobra.Command { cmd.Flags().StringArrayVar(&opts.From, "from", []string{}, "messages to be forwarded, can be links or exported JSON files") cmd.Flags().StringVar(&opts.To, "to", "", "destination peer, can be a CHAT or router based on expression engine") cmd.Flags().StringVar(&opts.Edit, "edit", "", "edit message or caption with expression engine. Empty means no edit") + cmd.Flags().StringVar(&opts.RenameFile, "rename-file", "", "rename media files with expression engine. Empty means keep original name") cmd.Flags().Var(&opts.Mode, "mode", fmt.Sprintf("forward mode: [%s]", strings.Join(forwarder.ModeNames(), ", "))) cmd.Flags().BoolVar(&opts.Silent, "silent", false, "send messages silently") cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "do not actually send messages, just show how they would be sent") diff --git a/core/forwarder/clone.go b/core/forwarder/clone.go index 6dd9f4ec09..241c585150 100644 --- a/core/forwarder/clone.go +++ b/core/forwarder/clone.go @@ -20,6 +20,7 @@ import ( type cloneOptions struct { elem Elem + msg *tg.Message // the specific message being cloned (for per-message rename) media *tmedia.Media progress progressAdd } @@ -66,7 +67,15 @@ func (f *Forwarder) cloneMedia(ctx context.Context, opts cloneOptions, dryRun bo return nil, errors.Wrap(err, "seek") } - upload := uploader.NewUpload(opts.media.Name, temp, opts.media.Size) + // Use renamed filename if provided, otherwise keep original + uploadName := opts.media.Name + if opts.msg != nil { + if renamed := opts.elem.ComputeRenamedFilename(opts.msg); renamed != "" { + uploadName = renamed + } + } + + upload := uploader.NewUpload(uploadName, temp, opts.media.Size) file, err = uploader.NewUploader(f.opts.Pool.Default(ctx)). WithPartSize(tuploader.MaxPartSize). WithThreads(threads). diff --git a/core/forwarder/forwarder.go b/core/forwarder/forwarder.go index 51a677adfa..e103698717 100644 --- a/core/forwarder/forwarder.go +++ b/core/forwarder/forwarder.go @@ -167,6 +167,7 @@ func (f *Forwarder) forwardMessage(ctx context.Context, elem Elem, grouped ...*t mediaFile, err := f.cloneMedia(ctx, cloneOptions{ elem: elem, + msg: msg, // pass the current message for per-message rename media: media, progress: &wrapProgress{ elem: elem, @@ -197,13 +198,41 @@ func (f *Forwarder) forwardMessage(ctx context.Context, elem Elem, grouped ...*t return nil, errors.Errorf("empty document %d", msg.ID) } + // Process attributes, replacing filename if renamed + // Use msg (the current message being processed) to compute the filename, + // so each message in an album gets its own unique filename + attributes := doc.Attributes + if renamed := elem.ComputeRenamedFilename(msg); renamed != "" { + // Create a new attributes slice with the renamed filename + newAttrs := make([]tg.DocumentAttributeClass, 0, len(doc.Attributes)) + filenameFound := false + for _, attr := range doc.Attributes { + if _, ok := attr.(*tg.DocumentAttributeFilename); ok { + // Replace with renamed filename + newAttrs = append(newAttrs, &tg.DocumentAttributeFilename{ + FileName: renamed, + }) + filenameFound = true + } else { + newAttrs = append(newAttrs, attr) + } + } + // If no filename attribute existed, add one + if !filenameFound { + newAttrs = append(newAttrs, &tg.DocumentAttributeFilename{ + FileName: renamed, + }) + } + attributes = newAttrs + } + document := &tg.InputMediaUploadedDocument{ NosoundVideo: false, // do not set ForceFile: false, // do not set Spoiler: m.Spoiler, File: mediaFile, MimeType: doc.MimeType, - Attributes: doc.Attributes, + Attributes: attributes, Stickers: nil, // do not set TTLSeconds: 0, // do not set } @@ -211,6 +240,7 @@ func (f *Forwarder) forwardMessage(ctx context.Context, elem Elem, grouped ...*t if thumb, ok := tmedia.GetDocumentThumb(doc); ok { thumbFile, err := f.cloneMedia(ctx, cloneOptions{ elem: elem, + msg: msg, // pass message for consistency (though thumbnail doesn't need rename) media: thumb, progress: nopProgress{}, }, elem.AsDryRun()) diff --git a/core/forwarder/iter.go b/core/forwarder/iter.go index a776bc2288..2443851fde 100644 --- a/core/forwarder/iter.go +++ b/core/forwarder/iter.go @@ -24,4 +24,8 @@ type Elem interface { AsSilent() bool AsDryRun() bool AsGrouped() bool // detect and forward grouped messages + // ComputeRenamedFilename computes the renamed filename for a given message. + // This allows each message in an album to have its own unique filename based on its ID. + // Returns empty string to keep the original filename. + ComputeRenamedFilename(msg *tg.Message) string } diff --git a/docs/content/en/guide/forward.md b/docs/content/en/guide/forward.md index 4bd5946a12..a90ae60b13 100644 --- a/docs/content/en/guide/forward.md +++ b/docs/content/en/guide/forward.md @@ -166,6 +166,38 @@ func main() { tdl forward --from tdl-export.json --edit edit.txt {{< /command >}} +## Rename File + +Rename media files before forwarding based on [expression](/reference/expr). This is useful for adding metadata like original message ID to filenames for tracking purposes. + +{{< hint info >}} +- This feature requires `clone` mode (automatically enabled when using `--rename-file`). +- Only applies to documents and videos with filenames. +{{< /hint >}} + +List all available fields: +{{< command >}} +tdl forward --from tdl-export.json --rename-file - +{{< /command >}} + +Add source channel ID and message ID prefix to filename: +{{< command >}} +tdl forward --from tdl-export.json --rename-file \ + '`[` + string(From.ID) + `_` + string(Message.ID) + `]_` + Message.Media.Name' +{{< /command >}} + +Pass a file name if the expression is complex: + +{{< details "rename.txt" >}} +```javascript +`[` + string(From.ID) + `_` + string(Message.ID) + `]_` + Message.Media.Name +``` +{{< /details >}} + +{{< command >}} +tdl forward --from tdl-export.json --rename-file rename.txt +{{< /command >}} + ## Dry Run Print the progress without actually sending messages, which is useful for message routing debugging. diff --git a/docs/content/zh/guide/forward.md b/docs/content/zh/guide/forward.md index dac674fee2..38b37b12b3 100644 --- a/docs/content/zh/guide/forward.md +++ b/docs/content/zh/guide/forward.md @@ -168,6 +168,38 @@ func main() { tdl forward --from tdl-export.json --edit edit.txt {{< /command >}} +## 重命名文件 + +使用[表达式引擎](/zh/reference/expr)在转发前重命名媒体文件。这对于在文件名中添加原始消息 ID 等元数据进行跟踪非常有用。 + +{{< hint info >}} +- 此功能需要 `clone` 模式(使用 `--rename-file` 时会自动启用)。 +- 仅适用于带有文件名的文档和视频。 +{{< /hint >}} + +列出所有可用字段: +{{< command >}} +tdl forward --from tdl-export.json --rename-file - +{{< /command >}} + +在文件名中添加来源频道 ID 和消息 ID 前缀: +{{< command >}} +tdl forward --from tdl-export.json --rename-file \ + '`[` + string(From.ID) + `_` + string(Message.ID) + `]_` + Message.Media.Name' +{{< /command >}} + +如果表达式较复杂,可以传递文件名: + +{{< details "rename.txt" >}} +```javascript +`[` + string(From.ID) + `_` + string(Message.ID) + `]_` + Message.Media.Name +``` +{{< /details >}} + +{{< command >}} +tdl forward --from tdl-export.json --rename-file rename.txt +{{< /command >}} + ## 试运行 只打印进度而不实际发送消息,可以用于调试消息路由的效果。 From 1be574b48c5110df7c67df55cf4452ac6c24e29f Mon Sep 17 00:00:00 2001 From: Luiz Fernando Date: Tue, 27 Jan 2026 22:31:19 -0300 Subject: [PATCH 2/3] style: fix gci import formatting --- app/forward/forward.go | 20 ++++----- app/forward/forward_test.go | 82 +++++++++++++++++++++++++++++++------ 2 files changed, 79 insertions(+), 23 deletions(-) diff --git a/app/forward/forward.go b/app/forward/forward.go index d6bacbb3e4..ffe29a9d6f 100644 --- a/app/forward/forward.go +++ b/app/forward/forward.go @@ -91,17 +91,17 @@ func Run(ctx context.Context, c *telegram.Client, kvd storage.Storage, opts Opti fw := forwarder.New(forwarder.Options{ Pool: pool, Iter: newIter(iterOptions{ - manager: manager, - pool: pool, - to: to, - edit: edit, + manager: manager, + pool: pool, + to: to, + edit: edit, renameFile: renameFile, - dialogs: dialogs, - mode: opts.Mode, - silent: opts.Silent, - dryRun: opts.DryRun, - grouped: !opts.Single, - delay: viper.GetDuration(consts.FlagDelay), + dialogs: dialogs, + mode: opts.Mode, + silent: opts.Silent, + dryRun: opts.DryRun, + grouped: !opts.Single, + delay: viper.GetDuration(consts.FlagDelay), }), Progress: newProgress(fwProgress), Threads: viper.GetInt(consts.FlagThreads), diff --git a/app/forward/forward_test.go b/app/forward/forward_test.go index 6f5ee68c68..3338e8f657 100644 --- a/app/forward/forward_test.go +++ b/app/forward/forward_test.go @@ -7,75 +7,131 @@ import ( ) func TestResolveRenameFile(t *testing.T) { + tests := []struct { - name string - input string + name string + + input string + wantNil bool + wantErr bool }{ + { - name: "empty input returns nil", - input: "", + + name: "empty input returns nil", + + input: "", + wantNil: true, + wantErr: false, }, + { - name: "valid expression compiles", - input: `"test_" + Message.Media.Name`, + + name: "valid expression compiles", + + input: `"test_" + Message.Media.Name`, + wantNil: false, + wantErr: false, }, + { - name: "simple string expression", - input: `"renamed_file.mp4"`, + + name: "simple string expression", + + input: `"renamed_file.mp4"`, + wantNil: false, + wantErr: false, }, + { - name: "complex expression with string conversion", - input: "`[` + string(From.ID) + `_` + string(Message.ID) + `]_` + Message.Media.Name", + + name: "complex expression with string conversion", + + input: "`[` + string(From.ID) + `_` + string(Message.ID) + `]_` + Message.Media.Name", + wantNil: false, + wantErr: false, }, + { - name: "invalid expression fails", - input: "this is not valid expr {{{}}}", + + name: "invalid expression fails", + + input: "this is not valid expr {{{}}}", + wantNil: true, // when error occurs, result is nil + wantErr: true, }, } for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := resolveRenameFile(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("resolveRenameFile() error = %v, wantErr %v", err, tt.wantErr) + return + } + if (got == nil) != tt.wantNil { + t.Errorf("resolveRenameFile() got = %v, wantNil %v", got, tt.wantNil) + } + }) + } + } func TestResolveRenameFileFromFile(t *testing.T) { + // Create temp file with expression + tmpDir := t.TempDir() + exprFile := filepath.Join(tmpDir, "rename_expr.txt") + expr := "`[` + string(From.ID) + `]_` + Message.Media.Name" - + if err := os.WriteFile(exprFile, []byte(expr), 0644); err != nil { + t.Fatalf("failed to write temp file: %v", err) + } // Test reading from file + got, err := resolveRenameFile(exprFile) + if err != nil { + t.Errorf("resolveRenameFile() from file error = %v", err) + return + } + if got == nil { + t.Error("resolveRenameFile() from file returned nil, expected compiled program") + } + } From 3700cdb13b31f8e0dd8304981d782248a87861a2 Mon Sep 17 00:00:00 2001 From: Luiz Fernando Date: Tue, 27 Jan 2026 22:36:59 -0300 Subject: [PATCH 3/3] style: fix gofumpt formatting in forward_test.go --- app/forward/forward_test.go | 82 ++++++------------------------------- 1 file changed, 13 insertions(+), 69 deletions(-) diff --git a/app/forward/forward_test.go b/app/forward/forward_test.go index 3338e8f657..2399b9c6ed 100644 --- a/app/forward/forward_test.go +++ b/app/forward/forward_test.go @@ -7,131 +7,75 @@ import ( ) func TestResolveRenameFile(t *testing.T) { - tests := []struct { - name string - - input string - + name string + input string wantNil bool - wantErr bool }{ - { - - name: "empty input returns nil", - - input: "", - + name: "empty input returns nil", + input: "", wantNil: true, - wantErr: false, }, - { - - name: "valid expression compiles", - - input: `"test_" + Message.Media.Name`, - + name: "valid expression compiles", + input: `"test_" + Message.Media.Name`, wantNil: false, - wantErr: false, }, - { - - name: "simple string expression", - - input: `"renamed_file.mp4"`, - + name: "simple string expression", + input: `"renamed_file.mp4"`, wantNil: false, - wantErr: false, }, - { - - name: "complex expression with string conversion", - - input: "`[` + string(From.ID) + `_` + string(Message.ID) + `]_` + Message.Media.Name", - + name: "complex expression with string conversion", + input: "`[` + string(From.ID) + `_` + string(Message.ID) + `]_` + Message.Media.Name", wantNil: false, - wantErr: false, }, - { - - name: "invalid expression fails", - - input: "this is not valid expr {{{}}}", - + name: "invalid expression fails", + input: "this is not valid expr {{{}}}", wantNil: true, // when error occurs, result is nil - wantErr: true, }, } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := resolveRenameFile(tt.input) - if (err != nil) != tt.wantErr { - t.Errorf("resolveRenameFile() error = %v, wantErr %v", err, tt.wantErr) - return - } - if (got == nil) != tt.wantNil { - t.Errorf("resolveRenameFile() got = %v, wantNil %v", got, tt.wantNil) - } - }) - } - } func TestResolveRenameFileFromFile(t *testing.T) { - // Create temp file with expression - tmpDir := t.TempDir() - exprFile := filepath.Join(tmpDir, "rename_expr.txt") - expr := "`[` + string(From.ID) + `]_` + Message.Media.Name" - if err := os.WriteFile(exprFile, []byte(expr), 0644); err != nil { - + if err := os.WriteFile(exprFile, []byte(expr), 0o644); err != nil { t.Fatalf("failed to write temp file: %v", err) - } // Test reading from file - got, err := resolveRenameFile(exprFile) - if err != nil { - t.Errorf("resolveRenameFile() from file error = %v", err) - return - } - if got == nil { - t.Error("resolveRenameFile() from file returned nil, expected compiled program") - } - }