butterrobot/internal/plugin/gallerydl/gallerydl.go
Felipe M. 29538b7d63
All checks were successful
CI / format (push) Successful in 1m50s
CI / lint (push) Successful in 3m0s
CI / goreleaser-lint (push) Successful in 10s
CI / test (push) Successful in 1m35s
CI / build (push) Successful in 1m35s
chore: remove Slack platform support
Slack was always marked as untested and is being dropped, leaving
Telegram as the sole supported platform.

- Delete the internal/platform/slack connector package
- Remove Slack registration in the platform factory
- Remove SlackConfig and its SLACK_TOKEN env vars from config
- Scrub Slack from README, docs, and .env examples
- Drop incidental Slack references from the gallerydl plugin copy

Also fixes a pre-existing staticcheck SA5011 warning in db_test.go
so the tree lints cleanly.
2026-07-06 07:52:31 +02:00

225 lines
6.2 KiB
Go

// 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
}