// Package gallerydl provides the !gallerydl command, which downloads media // from a link using the gallery-dl CLI and posts the files back as a reply. package gallerydl import ( "context" "fmt" "io/fs" "os" "path/filepath" "strings" "time" "git.nakama.town/fmartingr/butterrobot/internal/model" "git.nakama.town/fmartingr/butterrobot/internal/plugin" "git.nakama.town/fmartingr/butterrobot/internal/shell" "git.nakama.town/fmartingr/butterrobot/internal/urlutil" ) const ( command = "!gallerydl" defaultBinary = "gallery-dl" defaultTimeout = 5 * time.Minute ) // Downloader downloads the media at url into destDir using the gallery-dl // binary at binPath. It is a struct field so tests can inject a fake. type Downloader func(ctx context.Context, binPath, destDir, url string) error // GalleryDLPlugin downloads media from a link with gallery-dl. type GalleryDLPlugin struct { plugin.BasePlugin download Downloader timeout time.Duration } // New creates a new GalleryDLPlugin instance. func New() *GalleryDLPlugin { return &GalleryDLPlugin{ BasePlugin: plugin.BasePlugin{ ID: "util.gallerydl", Name: "Gallery-DL Downloader", Help: "Reply to a message containing a link with `!gallerydl` to download its " + "media and have it posted back as a reply. Restricted to the users listed in " + "the `allowed_users` config (comma-separated); no one is allowed by default. " + "Match your Telegram @username. Media upload targets Telegram.", ConfigRequired: true, }, download: runGalleryDL, timeout: defaultTimeout, } } // OnMessage handles incoming messages. func (p *GalleryDLPlugin) OnMessage(msg *model.Message, config map[string]interface{}, cache model.CacheInterface) []*model.MessageAction { if msg.FromBot { return nil } // Only react to the command. if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(msg.Text)), command) { return nil } // Authorization: deny everyone unless explicitly allowed. if !isAllowed(msg.Author, config) { return p.reply(msg, "You are not allowed to use !gallerydl.") } // Resolve the link: prefer URLs in the command message itself, then fall // back to the message being replied to. link := firstURL(msg.Text) if link == "" && msg.ReplyTo != "" { link = firstURL(repliedToText(msg)) } if link == "" { return p.reply(msg, "No link found. Reply to a message with a link, or include one after the command.") } // Download into a temporary directory. tmpDir, err := os.MkdirTemp("", "gallerydl-*") if err != nil { return p.reply(msg, "Failed to prepare download directory.") } ctx, cancel := context.WithTimeout(context.Background(), p.timeout) defer cancel() binPath := configString(config, "gallery_dl_path", defaultBinary) if err := p.download(ctx, binPath, tmpDir, link); err != nil { _ = os.RemoveAll(tmpDir) return p.reply(msg, fmt.Sprintf("Download failed: %s", trimError(err))) } files, err := collectFiles(tmpDir) if err != nil || len(files) == 0 { _ = os.RemoveAll(tmpDir) return p.reply(msg, "Nothing was downloaded from that link.") } // Reply to the original (replied-to) message when available, otherwise to // the command message. replyTo := msg.ReplyTo if replyTo == "" { replyTo = msg.ID } media := &model.Message{ Chat: msg.Chat, Channel: msg.Channel, ReplyTo: replyTo, Files: files, Raw: map[string]interface{}{"cleanup_dir": tmpDir}, } return []*model.MessageAction{ { Type: model.ActionSendMedia, Message: media, Chat: msg.Chat, Channel: msg.Channel, }, } } // reply builds a single text-message action replying to the command message. func (p *GalleryDLPlugin) reply(msg *model.Message, text string) []*model.MessageAction { response := &model.Message{ Text: text, Chat: msg.Chat, Channel: msg.Channel, ReplyTo: msg.ID, } return []*model.MessageAction{ { Type: model.ActionSendMessage, Message: response, Chat: msg.Chat, Channel: msg.Channel, }, } } // runGalleryDL is the production Downloader; it shells out to gallery-dl. func runGalleryDL(ctx context.Context, binPath, destDir, url string) error { _, err := shell.Run(ctx, binPath, "-D", destDir, url) return err } // isAllowed reports whether author is in the comma-separated allowed_users // config. An empty or unset list denies everyone. func isAllowed(author string, config map[string]interface{}) bool { author = normalizeUser(author) if author == "" { return false } raw := configString(config, "allowed_users", "") for _, entry := range strings.Split(raw, ",") { if normalizeUser(entry) == author { return true } } return false } // normalizeUser lower-cases, trims whitespace, and drops a leading @ so the // allowlist tolerates entries written with or without the @ prefix. func normalizeUser(s string) string { return strings.TrimPrefix(strings.ToLower(strings.TrimSpace(s)), "@") } // firstURL returns the first http(s) URL found in text, or "". func firstURL(text string) string { if urls := urlutil.ExtractURLs(text); len(urls) > 0 { return urls[0] } return "" } // repliedToText extracts the text of the replied-to message from the raw // Telegram payload (see searchreplace plugin for the same pattern). func repliedToText(msg *model.Message) string { if msgData, ok := msg.Raw["message"].(map[string]interface{}); ok { if replyMsg, ok := msgData["reply_to_message"].(map[string]interface{}); ok { if text, ok := replyMsg["text"].(string); ok { return text } } } return "" } // collectFiles walks dir and returns the absolute paths of all regular files. func collectFiles(dir string) ([]string, error) { var files []string err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if !d.IsDir() { files = append(files, path) } return nil }) return files, err } // configString reads a string config value with a default fallback. func configString(config map[string]interface{}, key, fallback string) string { if v, ok := config[key].(string); ok && v != "" { return v } return fallback } // trimError shortens an error message for a chat reply. func trimError(err error) string { msg := strings.TrimSpace(err.Error()) const max = 300 if len(msg) > max { msg = msg[:max] + "…" } return msg }