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.
225 lines
6.2 KiB
Go
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
|
|
}
|