diff --git a/api/client.go b/api/client.go new file mode 100644 index 0000000..5909e74 --- /dev/null +++ b/api/client.go @@ -0,0 +1,119 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "sync" + "time" + + "github.com/gabriel-vasile/mimetype" +) + +// Client is the shared HTTP client for the Mono REST API. +type Client struct { + BaseURL string + APIKey string +} + +const MaxDownloadBytes int64 = 256 * 1024 * 1024 + +var ( + defaultClient *Client + defaultOnce sync.Once + httpClient = &http.Client{Timeout: 2 * time.Minute} +) + +func initDefault() { + baseURL := os.Getenv("MONO_BASEURL") + if baseURL == "" { + baseURL = "https://mono.fdvky.me/api/v1" + } + defaultClient = &Client{ + BaseURL: strings.TrimRight(baseURL, "/"), + APIKey: os.Getenv("MONO_API_KEY"), + } +} + +// Default returns the shared API client, initialized lazily so env vars +// loaded via godotenv in main.init() are already available. +func Default() *Client { + defaultOnce.Do(initDefault) + return defaultClient +} + +// Get calls the API and decodes the JSON response into target. +func (c *Client) Get(path string, params map[string]string, target any) error { + u, err := url.Parse(c.BaseURL + path) + if err != nil { + return fmt.Errorf("api url: %w", err) + } + q := u.Query() + for k, v := range params { + q.Set(k, v) + } + u.RawQuery = q.Encode() + + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return fmt.Errorf("api request: %w", err) + } + if c.APIKey != "" { + req.Header.Set("X-API-Key", c.APIKey) + } + + resp, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("api: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("api %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + return json.NewDecoder(resp.Body).Decode(target) +} + +// Download fetches a URL and returns the raw bytes and MIME type. +func Download(rawURL string) ([]byte, string, error) { + req, err := http.NewRequest("GET", rawURL, nil) + if err != nil { + return nil, "", fmt.Errorf("download request: %w", err) + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, "", fmt.Errorf("download: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return nil, "", fmt.Errorf("download %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + if resp.ContentLength > MaxDownloadBytes { + return nil, "", fmt.Errorf("download too large: %d bytes", resp.ContentLength) + } + + mime := resp.Header.Get("Content-Type") + + data, err := io.ReadAll(io.LimitReader(resp.Body, MaxDownloadBytes+1)) + if err != nil { + return nil, "", fmt.Errorf("read: %w", err) + } + if int64(len(data)) > MaxDownloadBytes { + return nil, "", fmt.Errorf("download too large: over %d bytes", MaxDownloadBytes) + } + + if mime == "" || mime == "application/octet-stream" { + mime = mimetype.Detect(data).String() + } + + return data, mime, nil +} diff --git a/api/youtube.go b/api/youtube.go new file mode 100644 index 0000000..b753ba8 --- /dev/null +++ b/api/youtube.go @@ -0,0 +1,128 @@ +package api + +import ( + "sync" + "time" +) + +type YtFormat struct { + Ext string `json:"ext"` + Filesize int `json:"filesize"` + FormatID string `json:"formatId"` + HasAudio bool `json:"hasAudio"` + Resolution string `json:"resolution"` + URL string `json:"url"` +} + +type YtDownloadResult struct { + AudioFormats []YtFormat `json:"audioFormats"` + Thumbnail string `json:"thumbnail"` + Title string `json:"title"` + VideoFormats []YtFormat `json:"videoFormats"` + VideoID string `json:"videoId"` +} + +const ytCacheTTL = 10 * time.Minute + +type ytCacheEntry struct { + result *YtDownloadResult + expiresAt time.Time +} + +var ( + ytMu sync.Mutex + ytCache = map[string]ytCacheEntry{} +) + +func GetYtCache(messageID string) (*YtDownloadResult, bool) { + ytMu.Lock() + defer ytMu.Unlock() + + entry, ok := ytCache[messageID] + if !ok { + return nil, false + } + if time.Now().After(entry.expiresAt) { + delete(ytCache, messageID) + return nil, false + } + return entry.result, true +} + +func SetYtCache(messageID string, result *YtDownloadResult) { + ytMu.Lock() + defer ytMu.Unlock() + + now := time.Now() + for id, entry := range ytCache { + if now.After(entry.expiresAt) { + delete(ytCache, id) + } + } + ytCache[messageID] = ytCacheEntry{ + result: result, + expiresAt: now.Add(ytCacheTTL), + } +} + +func HasFormatID(res *YtDownloadResult, formatID string) bool { + for _, f := range res.VideoFormats { + if f.FormatID == formatID && f.URL != "" { + return true + } + } + for _, f := range res.AudioFormats { + if f.FormatID == formatID && f.URL != "" { + return true + } + } + return false +} + +// func DownloadRemuxedMP4(rawURL string) ([]byte, error) { +// if _, err := exec.LookPath("ffmpeg"); err != nil { +// return nil, fmt.Errorf("ffmpeg not found: %w", err) +// } + +// data, mime, err := Download(rawURL) +// if err != nil { +// return nil, err +// } + +// if !strings.HasPrefix(mime, "video/mp4") { +// return data, nil +// } + +// // TEST: set YT_NO_REMUX=1 to skip ffmpeg and use raw API output +// if os.Getenv("YT_NO_REMUX") != "" { +// return data, nil +// } + +// out, err := os.CreateTemp("", "yt-out-*.mp4") +// if err != nil { +// return nil, fmt.Errorf("temp file: %w", err) +// } +// outPath := out.Name() +// out.Close() +// defer os.Remove(outPath) + +// cmdCtx, cancelCmd := context.WithTimeout(context.Background(), 5*time.Minute) +// defer cancelCmd() + +// cmd := exec.CommandContext(cmdCtx, "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", +// "-i", "pipe:0", "-c", "copy", "-movflags", "+faststart", "-f", "mp4", outPath) +// cmd.Stdin = bytes.NewReader(data) +// if output, err := cmd.CombinedOutput(); err != nil { +// return nil, fmt.Errorf("ffmpeg remux: %w: %s", err, strings.TrimSpace(string(output))) +// } + +// info, err := os.Stat(outPath) +// if err != nil { +// return nil, fmt.Errorf("stat remuxed file: %w", err) +// } +// if info.Size() > MaxDownloadBytes { +// return nil, fmt.Errorf("remuxed file too large: %d bytes", info.Size()) +// } + +// return os.ReadFile(outPath) +// } diff --git a/cmds/download/facebook.go b/cmds/download/facebook.go new file mode 100644 index 0000000..f141ccd --- /dev/null +++ b/cmds/download/facebook.go @@ -0,0 +1,63 @@ +package download + +import ( + "fmt" + "regexp" + + "neo/api" + hc "neo/context" + "neo/core" + "neo/helper" +) + +type facebookResult struct { + HD string `json:"hd"` + SD string `json:"sd"` + Thumbnail string `json:"thumbnail"` +} + +func init() { core.Default.Register(Facebook) } + +var Facebook = &core.Command{ + Name: "facebook", + Aliases: []string{"fb"}, + Category: "download", + Description: "Download Facebook video", + Run: func(ctx *hc.Ctx) { + url := ctx.Arg(0) + if url == "" { + ctx.Reply("Usage: !fb ") + return + } + + fbRegex := regexp.MustCompile(`(?i)(?:facebook\.com|fb\.com|fb\.watch)\/(?:share\/[pr]\/|reel\/|watch\/?\?v=|.*\/videos\/)?([a-zA-Z0-9_\-]+)`) + if !fbRegex.MatchString(url) { + ctx.Reply("Invalid Facebook URL") + return + } + + ctx.Send("Downloading...") + var res facebookResult + if err := api.Default().Get("/fb", map[string]string{"url": url}, &res); err != nil { + ctx.Reply(fmt.Sprintf("Failed to fetch: %v", err)) + return + } + + downloadURL := res.HD + if downloadURL == "" { + downloadURL = res.SD + } + if downloadURL == "" { + ctx.Reply("No video found") + return + } + + data, mime, err := api.Download(downloadURL) + if err != nil { + ctx.Reply(fmt.Sprintf("Download failed: %v", err)) + return + } + + helper.SendMedia(ctx, data, mime, "") + }, +} diff --git a/cmds/download/instagram.go b/cmds/download/instagram.go new file mode 100644 index 0000000..ac48bab --- /dev/null +++ b/cmds/download/instagram.go @@ -0,0 +1,69 @@ +package download + +import ( + "fmt" + "regexp" + + "neo/api" + hc "neo/context" + "neo/core" + "neo/helper" +) + +type instagramVideo struct { + Src string `json:"src"` + Thumbnail string `json:"thumbnail"` +} + +type instagramResult struct { + Images []string `json:"images"` + Videos []instagramVideo `json:"videos"` +} + +func init() { core.Default.Register(Instagram) } + +var Instagram = &core.Command{ + Name: "instagram", + Aliases: []string{"ig"}, + Category: "download", + Description: "Download Instagram media", + Run: func(ctx *hc.Ctx) { + url := ctx.Arg(0) + if url == "" { + ctx.Reply("Usage: !ig ") + return + } + + igRegex := regexp.MustCompile(`(?i)(?:instagram\.com|instagr\.am)\/(?:p|reel|tv)\/([a-zA-Z0-9_\-]+)`) + if !igRegex.MatchString(url) { + ctx.Reply("Invalid Instagram URL") + return + } + + ctx.Send("Downloading...") + var res instagramResult + if err := api.Default().Get("/ig", map[string]string{"url": url}, &res); err != nil { + ctx.Reply(fmt.Sprintf("Failed to fetch: %v", err)) + return + } + + mediaURL := "" + if len(res.Videos) > 0 { + mediaURL = res.Videos[0].Src + } else if len(res.Images) > 0 { + mediaURL = res.Images[0] + } + if mediaURL == "" { + ctx.Reply("No media found") + return + } + + data, mime, err := api.Download(mediaURL) + if err != nil { + ctx.Reply(fmt.Sprintf("Download failed: %v", err)) + return + } + + helper.SendMedia(ctx, data, mime, "") + }, +} diff --git a/cmds/download/tiktok.go b/cmds/download/tiktok.go new file mode 100644 index 0000000..77a21fb --- /dev/null +++ b/cmds/download/tiktok.go @@ -0,0 +1,67 @@ +package download + +import ( + "fmt" + "regexp" + + "neo/api" + hc "neo/context" + "neo/core" + "neo/helper" +) + +type tiktokResult struct { + Description string `json:"description"` + ImageURL []string `json:"image_url"` + Username string `json:"username"` + VideoURL []string `json:"video_url"` +} + +func init() { core.Default.Register(TikTok) } + +var TikTok = &core.Command{ + Name: "tiktok", + Aliases: []string{"tt", "tk"}, + Category: "download", + Description: "Download TikTok video", + Run: func(ctx *hc.Ctx) { + url := ctx.Arg(0) + if url == "" { + ctx.Reply("Usage: !tiktok ") + return + } + + ttRegex := regexp.MustCompile(`(?i)(?:tiktok\.com)\/(?:@[\w.-]+\/video\/|v\/|t\/)([0-9]+)`) + if !ttRegex.MatchString(url) { + ctx.Reply("Invalid TikTok URL") + return + } + + ctx.Send("Downloading...") + var res tiktokResult + if err := api.Default().Get("/tiktok", map[string]string{"url": url}, &res); err != nil { + ctx.Reply(fmt.Sprintf("Failed to fetch: %v", err)) + return + } + + mediaURL := "" + if len(res.VideoURL) > 0 { + mediaURL = res.VideoURL[0] + } else if len(res.ImageURL) > 0 { + mediaURL = res.ImageURL[0] + } + if mediaURL == "" { + ctx.Reply("No media found") + return + } + + data, mime, err := api.Download(mediaURL) + if err != nil { + ctx.Reply(fmt.Sprintf("Download failed: %v", err)) + return + } + + caption := fmt.Sprintf("@%s\n%s", res.Username, res.Description) + helper.SendMedia(ctx, data, mime, caption) + }, +} diff --git a/cmds/download/youtube.go b/cmds/download/youtube.go new file mode 100644 index 0000000..dd81152 --- /dev/null +++ b/cmds/download/youtube.go @@ -0,0 +1,156 @@ +package download + +import ( + "context" + "fmt" + "regexp" + "strings" + + "neo/api" + hc "neo/context" + "neo/core" + "neo/helper" + + waE2E "go.mau.fi/whatsmeow/proto/waE2E" +) + +func init() { + core.Default.Register(YouTube) + core.Default.Use(handleYTReply) +} + +func handleYTReply(ctx *hc.Ctx) bool { + if ctx.Cmd() != "" { + return false + } + + quotedID := hc.GetQuotedStanzaID(ctx.Message()) + if quotedID == "" { + return false + } + + result, ok := api.GetYtCache(quotedID) + if !ok { + return false + } + + formatID := strings.TrimSpace(hc.ParseMessageText(ctx.Event())) + if formatID == "" || !api.HasFormatID(result, formatID) { + return false + } + + ctx.Send("Downloading...") + downloadByFormatID(ctx, result, formatID) + return true +} + +var YouTube = &core.Command{ + Name: "youtube", + Aliases: []string{"yt", "ytdl"}, + Category: "download", + Description: "Download YouTube video", + Run: func(ctx *hc.Ctx) { + url := ctx.Arg(0) + if url == "" { + ctx.Reply("Usage: !yt ") + return + } + + ytRegex := regexp.MustCompile(`(?i)(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/|youtube\.com\/shorts\/)([^"&?\/\s]{11})`) + if !ytRegex.MatchString(url) { + ctx.Reply("Invalid YouTube URL") + return + } + + ctx.Send("Processing...") + var res api.YtDownloadResult + if err := api.Default().Get("/yt/query", map[string]string{"url": url}, &res); err != nil { + ctx.Reply(fmt.Sprintf("Error: %v", err)) + return + } + + showFormatPicker(ctx, &res) + }, +} + +func showFormatPicker(ctx *hc.Ctx, res *api.YtDownloadResult) { + thumb, _, err := api.Download(res.Thumbnail) + if err != nil { + ctx.Send(formatCaption(res)) + return + } + + img, err := ctx.UploadImage(thumb, formatCaption(res)) + if err != nil { + ctx.Reply("Failed to upload thumbnail: " + err.Error()) + return + } + + resp, err := ctx.Client().SendMessage(context.Background(), ctx.ChatJID(), &waE2E.Message{ImageMessage: img}) + if err != nil { + ctx.Reply("Failed to send: " + err.Error()) + return + } + + api.SetYtCache(resp.ID, res) +} + +func formatCaption(res *api.YtDownloadResult) string { + var b strings.Builder + b.WriteString("Title: ") + b.WriteString(res.Title) + b.WriteString("\n\n── Video ──\n") + for _, f := range res.VideoFormats { + if f.URL == "" { + continue + } + fmt.Fprintf(&b, "%s · %s · %s · %s\n", f.FormatID, f.Resolution, f.Ext, formatSize(f.Filesize)) + } + b.WriteString("\n── Audio ──\n") + for _, f := range res.AudioFormats { + if f.URL == "" { + continue + } + fmt.Fprintf(&b, "%s · %s · %s\n", f.FormatID, f.Ext, formatSize(f.Filesize)) + } + b.WriteString("\nReply with formatId") + return b.String() +} + +func formatSize(bytes int) string { + if bytes <= 0 { + return "?" + } + if bytes < 1024*1024 { + return fmt.Sprintf("%dKB", bytes/1024) + } + return fmt.Sprintf("%.1fMB", float64(bytes)/(1024*1024)) +} + +func downloadByFormatID(ctx *hc.Ctx, res *api.YtDownloadResult, fid string) { + // Look for video + for _, f := range res.VideoFormats { + if f.FormatID == fid && f.URL != "" { + data, mime, err := api.Download(f.URL) + if err != nil { + ctx.Reply(fmt.Sprintf("Download failed: %v", err)) + return + } + helper.SendMedia(ctx, data, mime, res.Title) // using existing sendMedia handling + return + } + } + // Look for audio + for _, f := range res.AudioFormats { + if f.FormatID == fid && f.URL != "" { + data, mime, err := api.Download(f.URL) + if err != nil { + ctx.Reply(fmt.Sprintf("Download failed: %v", err)) + return + } + helper.SendMedia(ctx, data, mime, res.Title) + return + } + } + ctx.Reply("Format " + fid + " not found") +} diff --git a/cmds/register.go b/cmds/register.go index 17c1484..2d3716e 100644 --- a/cmds/register.go +++ b/cmds/register.go @@ -1,5 +1,6 @@ package cmds import ( + _ "neo/cmds/download" _ "neo/cmds/misc" ) diff --git a/context/message.go b/context/message.go index a051f26..789ae53 100644 --- a/context/message.go +++ b/context/message.go @@ -66,6 +66,29 @@ func WithReply(evt *events.Message) *waE2E.ContextInfo { } } +// GetQuotedStanzaID returns the stanza ID of the message being replied to, if any. +func GetQuotedStanzaID(m *waE2E.Message) string { + if m == nil { + return "" + } + switch { + case m.GetExtendedTextMessage().GetContextInfo() != nil: + return m.GetExtendedTextMessage().GetContextInfo().GetStanzaID() + case m.GetImageMessage().GetContextInfo() != nil: + return m.GetImageMessage().GetContextInfo().GetStanzaID() + case m.GetVideoMessage().GetContextInfo() != nil: + return m.GetVideoMessage().GetContextInfo().GetStanzaID() + case m.GetDocumentMessage().GetContextInfo() != nil: + return m.GetDocumentMessage().GetContextInfo().GetStanzaID() + case m.GetAudioMessage().GetContextInfo() != nil: + return m.GetAudioMessage().GetContextInfo().GetStanzaID() + case m.GetStickerMessage().GetContextInfo() != nil: + return m.GetStickerMessage().GetContextInfo().GetStanzaID() + default: + return "" + } +} + // ParseJID parses a JID string, adding @s.whatsapp.net if no server is specified. func ParseJID(arg string) (waTypes.JID, bool) { if arg == "" { diff --git a/core/core.go b/core/core.go index 709910a..818f75e 100644 --- a/core/core.go +++ b/core/core.go @@ -26,10 +26,19 @@ type Command struct { // via init() — see neo/cmds for the registration pattern. var Default = New() +type Middleware func(ctx *hc.Ctx) bool + type Handler struct { - mu sync.RWMutex - commands map[string]*Command - prefixes []string + mu sync.RWMutex + commands map[string]*Command + prefixes []string + middlewares []Middleware +} + +func (h *Handler) Use(fn Middleware) { + h.mu.Lock() + defer h.mu.Unlock() + h.middlewares = append(h.middlewares, fn) } func New(prefixes ...string) *Handler { @@ -92,6 +101,17 @@ func (h *Handler) processMessage(client *whatsmeow.Client, evt *events.Message) text := hc.ParseMessageText(evt) prefix, cmd, args, isCmd := parseCommand(text, h.prefixes) + ctx := hc.NewCtx(client, evt, prefix, cmd, args) + + h.mu.RLock() + middlewares := append([]Middleware(nil), h.middlewares...) + h.mu.RUnlock() + for _, middleware := range middlewares { + if middleware(ctx) { + return + } + } + if !isCmd { return } @@ -108,7 +128,6 @@ func (h *Handler) processMessage(client *whatsmeow.Client, evt *events.Message) return } - ctx := hc.NewCtx(client, evt, prefix, cmd, args) fmt.Printf("[CMD] %s > %s%s\n", formatSenderInfo(evt), prefix, cmd) command.Run(ctx) } diff --git a/go.mod b/go.mod index 9680887..08eda17 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,12 @@ module neo go 1.25.5 require ( + github.com/gabriel-vasile/mimetype v1.4.13 + github.com/joho/godotenv v1.5.1 github.com/mattn/go-sqlite3 v1.14.47 github.com/mdp/qrterminal/v3 v3.2.1 go.mau.fi/whatsmeow v0.0.0-20260630180629-b572e5bcb92b + google.golang.org/protobuf v1.36.11 ) require ( @@ -13,9 +16,7 @@ require ( github.com/beeper/argo-go v1.1.2 // indirect github.com/coder/websocket v1.8.15 // indirect github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/joho/godotenv v1.5.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect @@ -30,6 +31,5 @@ require ( golang.org/x/sys v0.46.0 // indirect golang.org/x/term v0.44.0 // indirect golang.org/x/text v0.38.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect rsc.io/qr v0.2.0 // indirect ) diff --git a/helper/media.go b/helper/media.go new file mode 100644 index 0000000..3429173 --- /dev/null +++ b/helper/media.go @@ -0,0 +1,49 @@ +package helper + +import ( + "strings" + + hc "neo/context" +) + +func SendMedia(ctx *hc.Ctx, data []byte, mime, caption string) { + switch { + case strings.HasPrefix(mime, "video/mp4"): + ctx.SendVideo(data, caption) + case strings.HasPrefix(mime, "image/"): + ctx.SendImage(data, caption) + case strings.HasPrefix(mime, "audio/"): + ctx.SendAudio(data) + default: + ctx.SendDocument(data, "media", "media"+extByMIME(mime)) + } +} + +func extByMIME(mime string) string { + switch { + case strings.HasPrefix(mime, "video/mp4"): + return ".mp4" + case strings.HasPrefix(mime, "video/webm"): + return ".webm" + case strings.HasPrefix(mime, "video/quicktime"): + return ".mov" + case strings.HasPrefix(mime, "video/x-matroska"): + return ".mkv" + case strings.HasPrefix(mime, "video/"): + return ".video" + case strings.HasPrefix(mime, "image/jpeg"): + return ".jpg" + case strings.HasPrefix(mime, "image/png"): + return ".png" + case strings.HasPrefix(mime, "image/gif"): + return ".gif" + case strings.HasPrefix(mime, "image/webp"): + return ".webp" + case strings.HasPrefix(mime, "audio/mpeg"), strings.HasPrefix(mime, "audio/mp3"): + return ".mp3" + case strings.HasPrefix(mime, "audio/"): + return ".m4a" + default: + return ".bin" + } +}