Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions app/forward/elem.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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)
}
66 changes: 47 additions & 19 deletions app/forward/forward.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -78,23 +79,29 @@ 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)

fw := forwarder.New(forwarder.Options{
Pool: pool,
Iter: newIter(iterOptions{
manager: manager,
pool: pool,
to: to,
edit: edit,
dialogs: dialogs,
mode: opts.Mode,
silent: opts.Silent,
dryRun: opts.DryRun,
grouped: !opts.Single,
delay: viper.GetDuration(consts.FlagDelay),
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),
}),
Progress: newProgress(fwProgress),
Threads: viper.GetInt(consts.FlagThreads),
Expand Down Expand Up @@ -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 {
Expand Down
81 changes: 81 additions & 0 deletions app/forward/forward_test.go
Original file line number Diff line number Diff line change
@@ -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), 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")
}
}
43 changes: 33 additions & 10 deletions app/forward/iter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -193,6 +215,7 @@ func (i *iter) Next(ctx context.Context) bool {
to: to,
thread: thread,
modeOverride: modeOverride,
renameFunc: renameFunc,
opts: i.opts,
}

Expand Down
1 change: 1 addition & 0 deletions cmd/forward.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
11 changes: 10 additions & 1 deletion core/forwarder/clone.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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).
Expand Down
32 changes: 31 additions & 1 deletion core/forwarder/forwarder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -197,20 +198,49 @@ 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
}

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())
Expand Down
4 changes: 4 additions & 0 deletions core/forwarder/iter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading