Compare commits
5
Commits
43a559bd55
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ef8fa3908 | ||
|
|
f6e6d4eb59 | ||
|
|
a4914b3dfa | ||
|
|
38773e31fc | ||
|
|
24338beaaf |
+4
-5
@@ -1,8 +1,8 @@
|
||||
# Stage: build
|
||||
FROM golang:1.25.5-alpine AS build
|
||||
|
||||
# Install build dependencies (CGO often needed for go-sqlite3)
|
||||
RUN apk add --no-cache gcc musl-dev
|
||||
# Install build dependencies (CGO for go-sqlite3 + govips)
|
||||
RUN apk add --no-cache gcc musl-dev vips-dev
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -17,11 +17,10 @@ RUN CGO_ENABLED=1 GOOS=linux go build -trimpath -ldflags="-w -s" -o /app/neo .
|
||||
# Stage: prod
|
||||
FROM alpine:latest AS prod
|
||||
|
||||
# Install required tools (cwebp/gif2webp via libwebp-tools, ffmpeg, imagemagick)
|
||||
# Install runtime dependencies (vips for image processing, ffmpeg for video)
|
||||
RUN apk add --no-cache \
|
||||
ffmpeg \
|
||||
libwebp-tools \
|
||||
imagemagick \
|
||||
vips \
|
||||
ca-certificates \
|
||||
tzdata
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -82,11 +83,22 @@ func (c *Client) Get(path string, params map[string]string, target any) error {
|
||||
|
||||
// Download fetches a URL and returns the raw bytes and MIME type.
|
||||
func Download(rawURL string) ([]byte, string, error) {
|
||||
// fast-path for googlevideo.com to bypass throttling via chunked requests
|
||||
if strings.Contains(rawURL, "googlevideo.com") {
|
||||
return downloadChunked(rawURL)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("download request: %w", err)
|
||||
}
|
||||
|
||||
// Add standard browser headers to avoid getting throttled/blocked
|
||||
// by CDNs like googlevideo.
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
req.Header.Set("DNT", "1")
|
||||
req.Header.Set("Upgrade-Insecure-Requests", "1")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("download: %w", err)
|
||||
@@ -115,5 +127,69 @@ func Download(rawURL string) ([]byte, string, error) {
|
||||
mime = mimetype.Detect(data).String()
|
||||
}
|
||||
|
||||
// fmt.Printf("Downloaded %d bytes from %s, MIME: %s\n", len(data), rawURL, mime)
|
||||
return data, mime, nil
|
||||
}
|
||||
|
||||
func downloadChunked(rawURL string) ([]byte, string, error) {
|
||||
chunkSize := 1024 * 1024 // 1MB
|
||||
offset := 0
|
||||
|
||||
var buf bytes.Buffer
|
||||
var mime string
|
||||
|
||||
for {
|
||||
req, err := http.NewRequest("GET", rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
req.Header.Set("DNT", "1")
|
||||
req.Header.Set("Upgrade-Insecure-Requests", "1")
|
||||
req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", offset, offset+chunkSize-1))
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 && resp.StatusCode != 206 {
|
||||
if resp.StatusCode == 416 && offset > 0 {
|
||||
resp.Body.Close()
|
||||
break
|
||||
}
|
||||
resp.Body.Close()
|
||||
return nil, "", fmt.Errorf("bad status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if offset == 0 {
|
||||
mime = resp.Header.Get("Content-Type")
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, int64(chunkSize+1)))
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
if int64(buf.Len()+len(data)) > MaxDownloadBytes {
|
||||
return nil, "", fmt.Errorf("download too large: over %d bytes", MaxDownloadBytes)
|
||||
}
|
||||
|
||||
buf.Write(data)
|
||||
|
||||
if len(data) < chunkSize || resp.StatusCode == 200 {
|
||||
break
|
||||
}
|
||||
offset += len(data)
|
||||
}
|
||||
|
||||
detectedMime := mime
|
||||
if detectedMime == "" || detectedMime == "application/octet-stream" {
|
||||
detectedMime = mimetype.Detect(buf.Bytes()).String()
|
||||
}
|
||||
|
||||
// fmt.Printf("Downloaded %d chunks (%d bytes) from %s, MIME: %s\n", (offset/chunkSize)+1, buf.Len(), rawURL, detectedMime)
|
||||
return buf.Bytes(), detectedMime, nil
|
||||
}
|
||||
|
||||
+20
-14
@@ -7,7 +7,6 @@ import (
|
||||
"neo/api"
|
||||
hc "neo/context"
|
||||
"neo/core"
|
||||
"neo/helper"
|
||||
)
|
||||
|
||||
type instagramVideo struct {
|
||||
@@ -47,23 +46,30 @@ var Instagram = &core.Command{
|
||||
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)
|
||||
for _, video := range res.Videos {
|
||||
data, _, err := api.Download(video.Src)
|
||||
if err != nil {
|
||||
ctx.Reply(fmt.Sprintf("Download failed: %v", err))
|
||||
return
|
||||
}
|
||||
// if i == 0 {
|
||||
// ctx.SendVideo(data, fmt.Sprintf("Instagram Video\n%s", url))
|
||||
// } else {
|
||||
ctx.SendVideo(data, "")
|
||||
// }
|
||||
}
|
||||
|
||||
helper.SendMedia(ctx, data, mime, "")
|
||||
for _, imgURL := range res.Images {
|
||||
data, _, err := api.Download(imgURL)
|
||||
if err != nil {
|
||||
ctx.Reply(fmt.Sprintf("Download failed: %v", err))
|
||||
return
|
||||
}
|
||||
// if i == 0 {
|
||||
// ctx.SendImage(data, fmt.Sprintf("Instagram Image\n%s", url))
|
||||
// } else {
|
||||
ctx.SendImage(data, "")
|
||||
// }
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
+19
-16
@@ -7,7 +7,6 @@ import (
|
||||
"neo/api"
|
||||
hc "neo/context"
|
||||
"neo/core"
|
||||
"neo/helper"
|
||||
)
|
||||
|
||||
type tiktokResult struct {
|
||||
@@ -31,7 +30,7 @@ var TikTok = &core.Command{
|
||||
return
|
||||
}
|
||||
|
||||
ttRegex := regexp.MustCompile(`(?i)(?:tiktok\.com)\/(?:@[\w.-]+\/video\/|v\/|t\/)([0-9]+)`)
|
||||
ttRegex := regexp.MustCompile(`(?i)^https?://(?:[\w-]+\.)?tiktok\.com(?:/.*)?$`)
|
||||
if !ttRegex.MatchString(url) {
|
||||
ctx.Reply("Invalid TikTok URL")
|
||||
return
|
||||
@@ -44,24 +43,28 @@ var TikTok = &core.Command{
|
||||
return
|
||||
}
|
||||
|
||||
mediaURL := ""
|
||||
caption := fmt.Sprintf("@%s\n%s", res.Username, res.Description)
|
||||
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)
|
||||
data, _, err := api.Download(res.VideoURL[0])
|
||||
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)
|
||||
ctx.SendVideo(data, caption)
|
||||
} else if len(res.ImageURL) > 0 {
|
||||
go func() {
|
||||
for _, imgURL := range res.ImageURL {
|
||||
data, _, err := api.Download(imgURL)
|
||||
if err != nil {
|
||||
ctx.Reply(fmt.Sprintf("Download failed: %v", err))
|
||||
return
|
||||
}
|
||||
ctx.SendImage(data, caption)
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
ctx.Reply("No media found")
|
||||
return
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
+34
-13
@@ -128,29 +128,50 @@ func formatSize(bytes int) string {
|
||||
}
|
||||
|
||||
func downloadByFormatID(ctx *hc.Ctx, res *api.YtDownloadResult, fid string) {
|
||||
var targetURL string
|
||||
var isAudioOnly bool
|
||||
|
||||
// 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
|
||||
targetURL = f.URL
|
||||
break
|
||||
}
|
||||
}
|
||||
// Look for audio
|
||||
|
||||
// Look for audio if not found in video
|
||||
if targetURL == "" {
|
||||
for _, f := range res.AudioFormats {
|
||||
if f.FormatID == fid && f.URL != "" {
|
||||
data, mime, err := api.Download(f.URL)
|
||||
targetURL = f.URL
|
||||
isAudioOnly = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if targetURL == "" {
|
||||
ctx.Reply("Format " + fid + " not found")
|
||||
return
|
||||
}
|
||||
|
||||
data, mime, err := api.Download(targetURL)
|
||||
if err != nil {
|
||||
ctx.Reply(fmt.Sprintf("Download failed: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Detect if it is audio from googlevideo (which is often fragmented MP4/DASH),
|
||||
// pipe it to ffmpeg to get standard MP3 so WhatsApp can play it
|
||||
if isAudioOnly && strings.Contains(targetURL, "googlevideo.com") {
|
||||
convertedData, err := helper.ConvertToMP3(data)
|
||||
if err == nil {
|
||||
data = convertedData
|
||||
mime = "audio/mpeg"
|
||||
} else {
|
||||
fmt.Printf("Failed to convert audio using ffmpeg: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
helper.SendMedia(ctx, data, mime, res.Title)
|
||||
return
|
||||
}
|
||||
}
|
||||
ctx.Reply("Format " + fid + " not found")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package group
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"neo/core"
|
||||
hc "neo/context"
|
||||
"neo/database"
|
||||
)
|
||||
|
||||
var (
|
||||
// filterCache stores group filters. Map of ChatJID string -> map of Key string -> Content string
|
||||
filterCache = make(map[string]map[string]string)
|
||||
cacheMu sync.RWMutex
|
||||
)
|
||||
|
||||
func init() {
|
||||
core.Default.Register(&core.Command{
|
||||
Name: "filter",
|
||||
Aliases: []string{"f"},
|
||||
Description: "Manage group filters",
|
||||
Category: "Group",
|
||||
Permissions: []core.Permission{core.PermissionGroupOnly},
|
||||
Run: HandleFilterCommand,
|
||||
})
|
||||
|
||||
// Register the middleware to catch incoming messages
|
||||
core.Default.Use(FilterMiddleware)
|
||||
}
|
||||
|
||||
// LoadCache loads all filters from DB into memory
|
||||
func LoadCache() {
|
||||
cacheMu.Lock()
|
||||
defer cacheMu.Unlock()
|
||||
|
||||
var filters []database.Filter
|
||||
database.DB.Find(&filters)
|
||||
|
||||
for _, f := range filters {
|
||||
if filterCache[f.GroupID] == nil {
|
||||
filterCache[f.GroupID] = make(map[string]string)
|
||||
}
|
||||
filterCache[f.GroupID][f.Key] = f.Content
|
||||
}
|
||||
}
|
||||
|
||||
func HandleFilterCommand(ctx *hc.Ctx) {
|
||||
args := ctx.Arguments()
|
||||
if len(args) < 1 {
|
||||
ctx.Reply("Usage:\n.filter add key|content\n.filter del key")
|
||||
return
|
||||
}
|
||||
|
||||
action := args[0]
|
||||
chatID := ctx.ChatJID().String()
|
||||
|
||||
switch action {
|
||||
case "add":
|
||||
fullArgs := ctx.FullArgs()
|
||||
// Remove the 'add ' prefix from full arguments string
|
||||
contentStart := strings.Index(fullArgs, "add") + 3
|
||||
if contentStart >= len(fullArgs) {
|
||||
ctx.Reply("Invalid format. Use: .filter add key|content")
|
||||
return
|
||||
}
|
||||
|
||||
payload := strings.TrimSpace(fullArgs[contentStart:])
|
||||
|
||||
parts := strings.SplitN(payload, "|", 2)
|
||||
if len(parts) < 2 {
|
||||
ctx.Reply("Invalid format. Use: .filter add key|content")
|
||||
return
|
||||
}
|
||||
|
||||
key := strings.ToLower(strings.TrimSpace(parts[0]))
|
||||
content := strings.TrimSpace(parts[1])
|
||||
|
||||
if key == "" || content == "" {
|
||||
ctx.Reply("Key or content cannot be empty.")
|
||||
return
|
||||
}
|
||||
|
||||
// Update DB
|
||||
var filter database.Filter
|
||||
result := database.DB.Where(database.Filter{Key: key, GroupID: chatID}).FirstOrCreate(&filter, database.Filter{
|
||||
Key: key,
|
||||
GroupID: chatID,
|
||||
Content: content,
|
||||
})
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
// Update if exists
|
||||
filter.Content = content
|
||||
database.DB.Save(&filter)
|
||||
}
|
||||
|
||||
// Update Cache
|
||||
cacheMu.Lock()
|
||||
if filterCache[chatID] == nil {
|
||||
filterCache[chatID] = make(map[string]string)
|
||||
}
|
||||
filterCache[chatID][key] = content
|
||||
cacheMu.Unlock()
|
||||
|
||||
ctx.Reply("Filter '" + key + "' added successfully.")
|
||||
|
||||
case "del":
|
||||
if len(args) < 2 {
|
||||
ctx.Reply("Usage: .filter del key")
|
||||
return
|
||||
}
|
||||
key := strings.ToLower(strings.Join(args[1:], " "))
|
||||
|
||||
// Delete from DB
|
||||
database.DB.Where(database.Filter{Key: key, GroupID: chatID}).Delete(&database.Filter{})
|
||||
|
||||
// Delete from cache
|
||||
cacheMu.Lock()
|
||||
if groupFilters, ok := filterCache[chatID]; ok {
|
||||
delete(groupFilters, key)
|
||||
}
|
||||
cacheMu.Unlock()
|
||||
|
||||
ctx.Reply("Filter '" + key + "' deleted successfully.")
|
||||
|
||||
default:
|
||||
ctx.Reply("Unknown action. Use add or del.")
|
||||
}
|
||||
}
|
||||
|
||||
func FilterMiddleware(ctx *hc.Ctx) bool {
|
||||
if !ctx.IsGroup() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Get message text safely handling different message types similarly to core.go
|
||||
text := hc.ParseMessageText(ctx.Event())
|
||||
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Don't intercept normal commands
|
||||
prefixes := []string{"!", "/", "."}
|
||||
for _, p := range prefixes {
|
||||
if strings.HasPrefix(text, p) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
lowerText := strings.ToLower(strings.TrimSpace(text))
|
||||
chatID := ctx.ChatJID().String()
|
||||
|
||||
cacheMu.RLock()
|
||||
groupFilters, exists := filterCache[chatID]
|
||||
if !exists {
|
||||
cacheMu.RUnlock()
|
||||
return false
|
||||
}
|
||||
|
||||
content, hasFilter := groupFilters[lowerText]
|
||||
cacheMu.RUnlock()
|
||||
|
||||
if hasFilter {
|
||||
ctx.Reply(content)
|
||||
return true // Stop processing, we handled it
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
stdctx "context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
hc "neo/context"
|
||||
"neo/core"
|
||||
"neo/helper"
|
||||
|
||||
"go.mau.fi/whatsmeow"
|
||||
waTypes "go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
func init() { core.Default.Register(Quote) }
|
||||
|
||||
// quoteAPIRequest represents the POST body for the quote-api /generate endpoint.
|
||||
type quoteAPIRequest struct {
|
||||
Type string `json:"type"`
|
||||
Format string `json:"format"`
|
||||
BackgroundColor string `json:"backgroundColor"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Scale int `json:"scale"`
|
||||
Messages []quoteMessage `json:"messages"`
|
||||
}
|
||||
|
||||
type quoteMessage struct {
|
||||
Entities []any `json:"entities"`
|
||||
Avatar bool `json:"avatar"`
|
||||
From quoteFrom `json:"from"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Media *quoteMedia `json:"media,omitempty"`
|
||||
ReplyMessage *quoteReplyMessage `json:"replyMessage,omitempty"`
|
||||
}
|
||||
|
||||
type quoteFrom struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Photo quotePhoto `json:"photo"`
|
||||
}
|
||||
|
||||
type quotePhoto struct {
|
||||
URL string `json:"url,omitempty"`
|
||||
CreateImageLatters bool `json:"createImageLatters,omitempty"`
|
||||
}
|
||||
|
||||
type quoteMedia struct {
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
type quoteReplyMessage struct {
|
||||
ChatID string `json:"chatId,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
type quoteAPIResponse struct {
|
||||
Result struct {
|
||||
Image string `json:"image"`
|
||||
} `json:"result"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
|
||||
var Quote = &core.Command{
|
||||
Name: "quote",
|
||||
Aliases: []string{"q", "qc"},
|
||||
Category: "media",
|
||||
Description: "Generate a quote sticker from text or replied message",
|
||||
Run: func(ctx *hc.Ctx) {
|
||||
quoteURL := os.Getenv("QUOTE_URL")
|
||||
if quoteURL == "" {
|
||||
ctx.Reply("QUOTE_URL is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
args := ctx.Arguments()
|
||||
msg := ctx.Message()
|
||||
quoted := hc.GetQuotedMessage(msg)
|
||||
quotedCtxInfo := hc.GetContextInfo(msg)
|
||||
|
||||
// Parse --img flag from args (accounting for smart dashes from iOS/macOS)
|
||||
sendAsImage := false
|
||||
filteredArgs := make([]string, 0, len(args))
|
||||
for _, a := range args {
|
||||
// Check for normal dashes, en dash, or em dash
|
||||
if a == "--img" || a == "-img" || a == "—img" || a == "–img" {
|
||||
sendAsImage = true
|
||||
} else {
|
||||
filteredArgs = append(filteredArgs, a)
|
||||
}
|
||||
}
|
||||
|
||||
// Text args = everything except --img
|
||||
textArgs := strings.Join(filteredArgs, " ")
|
||||
|
||||
// Determine text: from args or from quoted message
|
||||
text := textArgs
|
||||
if text == "" && quoted != nil {
|
||||
text = hc.GetQuotedText(quoted)
|
||||
}
|
||||
|
||||
if text == "" {
|
||||
ctx.Reply(fmt.Sprintf("Send command %s%s <text> or reply to a message", ctx.Prefix(), ctx.Cmd()))
|
||||
return
|
||||
}
|
||||
|
||||
// hasTextArgs = user actually typed content text (not just --img)
|
||||
hasTextArgs := textArgs != ""
|
||||
|
||||
// Determine the "from" user for the quote bubble
|
||||
senderJID := ctx.SenderJID()
|
||||
senderName := ctx.Event().Info.PushName
|
||||
|
||||
// Launch concurrent data fetching
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
|
||||
var quotedSenderJID waTypes.JID
|
||||
var quotedSenderName string
|
||||
hasQuotedSender := false
|
||||
|
||||
// 1. Fetch quoted sender name concurrently
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if quoted != nil && quotedCtxInfo != nil && quotedCtxInfo.GetParticipant() != "" {
|
||||
participant := quotedCtxInfo.GetParticipant()
|
||||
jid, ok := hc.ParseJID(participant)
|
||||
if ok {
|
||||
qJID := jid.ToNonAD()
|
||||
qName := hc.GetContactName(ctx.Client(), qJID)
|
||||
|
||||
mu.Lock()
|
||||
quotedSenderJID = qJID
|
||||
quotedSenderName = qName
|
||||
hasQuotedSender = true
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 2. Resolve main sender name based on quoted status early
|
||||
if senderName == "" {
|
||||
senderName = "+" + senderJID.User
|
||||
}
|
||||
|
||||
wg.Wait() // we need hasQuotedSender for the next step
|
||||
|
||||
if !hasTextArgs && hasQuotedSender {
|
||||
senderJID = quotedSenderJID
|
||||
senderName = quotedSenderName
|
||||
}
|
||||
|
||||
// 3. Fetch profile picture concurrently
|
||||
photoObj := quotePhoto{CreateImageLatters: true}
|
||||
wg.Add(1)
|
||||
go func(targetJID waTypes.JID) {
|
||||
defer wg.Done()
|
||||
picInfo, err := ctx.Client().GetProfilePictureInfo(
|
||||
stdctx.Background(),
|
||||
targetJID,
|
||||
&whatsmeow.GetProfilePictureParams{},
|
||||
)
|
||||
if err == nil && picInfo != nil && picInfo.URL != "" {
|
||||
mu.Lock()
|
||||
photoObj.URL = picInfo.URL
|
||||
photoObj.CreateImageLatters = false
|
||||
mu.Unlock()
|
||||
}
|
||||
}(senderJID)
|
||||
|
||||
// Wait for profile picture
|
||||
wg.Wait()
|
||||
|
||||
// Build replyMessage if user typed text AND is replying to a message
|
||||
var replyMsg *quoteReplyMessage
|
||||
if hasTextArgs && quoted != nil && hasQuotedSender {
|
||||
quotedText := hc.GetQuotedText(quoted)
|
||||
if quotedText != "" {
|
||||
replyMsg = "eReplyMessage{
|
||||
ChatID: quotedSenderJID.User,
|
||||
Name: quotedSenderName,
|
||||
Text: quotedText,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine image dimensions based on text length
|
||||
size := 520
|
||||
if len(text) >= 170 {
|
||||
size = 1024
|
||||
}
|
||||
|
||||
// Build request
|
||||
payload := quoteAPIRequest{
|
||||
Type: "quote",
|
||||
Format: "webp",
|
||||
BackgroundColor: "#1b1429",
|
||||
Width: size,
|
||||
Height: size,
|
||||
Scale: 2,
|
||||
Messages: []quoteMessage{
|
||||
{
|
||||
Entities: []any{},
|
||||
Avatar: true,
|
||||
From: quoteFrom{
|
||||
ID: senderJID.User,
|
||||
Name: senderName,
|
||||
Photo: photoObj,
|
||||
},
|
||||
Text: text,
|
||||
ReplyMessage: replyMsg,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
ctx.Reply(fmt.Sprintf("Failed to build request: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := http.Post(quoteURL, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
ctx.Reply(fmt.Sprintf("Failed to call quote API: %v", err))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
ctx.Reply(fmt.Sprintf("Failed to read response: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
ctx.Reply(fmt.Sprintf("Quote API error (%d): %s", resp.StatusCode, string(respBody)))
|
||||
return
|
||||
}
|
||||
|
||||
var apiResp quoteAPIResponse
|
||||
if err := json.Unmarshal(respBody, &apiResp); err != nil {
|
||||
ctx.Reply(fmt.Sprintf("Failed to parse response: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// The API may return image in result.image or directly in image
|
||||
imageB64 := apiResp.Result.Image
|
||||
if imageB64 == "" {
|
||||
imageB64 = apiResp.Image
|
||||
}
|
||||
if imageB64 == "" {
|
||||
ctx.Reply("Quote API returned empty image")
|
||||
return
|
||||
}
|
||||
|
||||
imageData, err := base64.StdEncoding.DecodeString(imageB64)
|
||||
if err != nil {
|
||||
ctx.Reply(fmt.Sprintf("Failed to decode image: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if sendAsImage {
|
||||
ctx.SendImage(imageData, "")
|
||||
return
|
||||
}
|
||||
|
||||
// Default: send as sticker with EXIF
|
||||
stickerData, err := helper.InjectExif(imageData, "Quote", "Bot")
|
||||
if err != nil {
|
||||
// Fallback: send raw webp as sticker
|
||||
ctx.SendSticker(imageData)
|
||||
return
|
||||
}
|
||||
ctx.SendSticker(stickerData)
|
||||
},
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package cmds
|
||||
import (
|
||||
_ "neo/cmds/download"
|
||||
_ "neo/cmds/general"
|
||||
_ "neo/cmds/group"
|
||||
_ "neo/cmds/media"
|
||||
_ "neo/cmds/misc"
|
||||
)
|
||||
|
||||
@@ -2,11 +2,13 @@ package context
|
||||
|
||||
import (
|
||||
"strings"
|
||||
stdctx "context"
|
||||
|
||||
waE2E "go.mau.fi/whatsmeow/proto/waE2E"
|
||||
waTypes "go.mau.fi/whatsmeow/types"
|
||||
"go.mau.fi/whatsmeow/types/events"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"go.mau.fi/whatsmeow"
|
||||
)
|
||||
|
||||
// ParseMessageText extracts text content from a message event.
|
||||
@@ -89,6 +91,68 @@ func GetQuotedStanzaID(m *waE2E.Message) string {
|
||||
}
|
||||
}
|
||||
|
||||
// GetContextInfo extracts the ContextInfo from any message type.
|
||||
func GetContextInfo(m *waE2E.Message) *waE2E.ContextInfo {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case m.GetExtendedTextMessage().GetContextInfo() != nil:
|
||||
return m.GetExtendedTextMessage().GetContextInfo()
|
||||
case m.GetImageMessage().GetContextInfo() != nil:
|
||||
return m.GetImageMessage().GetContextInfo()
|
||||
case m.GetVideoMessage().GetContextInfo() != nil:
|
||||
return m.GetVideoMessage().GetContextInfo()
|
||||
case m.GetDocumentMessage().GetContextInfo() != nil:
|
||||
return m.GetDocumentMessage().GetContextInfo()
|
||||
case m.GetAudioMessage().GetContextInfo() != nil:
|
||||
return m.GetAudioMessage().GetContextInfo()
|
||||
case m.GetStickerMessage().GetContextInfo() != nil:
|
||||
return m.GetStickerMessage().GetContextInfo()
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// GetQuotedText extracts text from a quoted message.
|
||||
func GetQuotedText(m *waE2E.Message) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
switch {
|
||||
case m.GetConversation() != "":
|
||||
return m.GetConversation()
|
||||
case m.GetExtendedTextMessage().GetText() != "":
|
||||
return m.GetExtendedTextMessage().GetText()
|
||||
case m.GetImageMessage().GetCaption() != "":
|
||||
return m.GetImageMessage().GetCaption()
|
||||
case m.GetVideoMessage().GetCaption() != "":
|
||||
return m.GetVideoMessage().GetCaption()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
// GetContactName returns a display name for the given JID from the store.
|
||||
func GetContactName(client *whatsmeow.Client, jid waTypes.JID) string {
|
||||
if client == nil || client.Store == nil || client.Store.Contacts == nil {
|
||||
return "+" + jid.User
|
||||
}
|
||||
contact, err := client.Store.Contacts.GetContact(stdctx.Background(), jid)
|
||||
if err == nil && contact.Found {
|
||||
if contact.PushName != "" {
|
||||
return contact.PushName
|
||||
}
|
||||
if contact.FullName != "" {
|
||||
return contact.FullName
|
||||
}
|
||||
if contact.BusinessName != "" {
|
||||
return contact.BusinessName
|
||||
}
|
||||
}
|
||||
return "+" + jid.User
|
||||
}
|
||||
|
||||
|
||||
// ParseJID parses a JID string, adding @s.whatsapp.net if no server is specified.
|
||||
func ParseJID(arg string) (waTypes.JID, bool) {
|
||||
if arg == "" {
|
||||
|
||||
+6
-2
@@ -47,13 +47,17 @@ func (c *Ctx) UploadVideo(bytes []byte, caption string) (*waE2E.VideoMessage, er
|
||||
}
|
||||
|
||||
func (c *Ctx) UploadAudio(bytes []byte) (*waE2E.AudioMessage, error) {
|
||||
mime := mimetype.Detect(bytes)
|
||||
mime := mimetype.Detect(bytes).String()
|
||||
if mime == "video/mp4" {
|
||||
mime = "audio/mp4"
|
||||
}
|
||||
|
||||
resp, err := c.client.Upload(stdctx.Background(), bytes, whatsmeow.MediaAudio)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &waE2E.AudioMessage{
|
||||
Mimetype: proto.String(mime.String()),
|
||||
Mimetype: proto.String(mime),
|
||||
URL: &resp.URL,
|
||||
DirectPath: &resp.DirectPath,
|
||||
MediaKey: resp.MediaKey,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
|
||||
func InitDB(dbPath string) {
|
||||
err := os.MkdirAll(filepath.Dir(dbPath), 0755)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create database directory: %v", err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to database: %v", err)
|
||||
}
|
||||
|
||||
DB = db
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package database
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
type Filter struct {
|
||||
gorm.Model
|
||||
Key string `gorm:"type:varchar(255);uniqueIndex:idx_group_key"`
|
||||
GroupID string `gorm:"type:varchar(255);uniqueIndex:idx_group_key"`
|
||||
Content string `gorm:"type:text"`
|
||||
}
|
||||
|
||||
func AutoMigrate() {
|
||||
DB.AutoMigrate(&Filter{})
|
||||
}
|
||||
@@ -3,12 +3,15 @@ module neo
|
||||
go 1.25.5
|
||||
|
||||
require (
|
||||
github.com/davidbyttow/govips/v2 v2.18.0
|
||||
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/mattn/go-sqlite3 v1.14.48
|
||||
github.com/mdp/qrterminal/v3 v3.2.1
|
||||
go.mau.fi/whatsmeow v0.0.0-20260630180629-b572e5bcb92b
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.2
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -17,6 +20,8 @@ require (
|
||||
github.com/coder/websocket v1.8.15 // indirect
|
||||
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // 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
|
||||
@@ -24,12 +29,13 @@ require (
|
||||
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
|
||||
go.mau.fi/libsignal v0.2.2 // indirect
|
||||
go.mau.fi/util v0.9.10 // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/crypto v0.54.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/term v0.44.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
golang.org/x/image v0.44.0 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/term v0.45.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
rsc.io/qr v0.2.0 // indirect
|
||||
)
|
||||
|
||||
@@ -12,6 +12,8 @@ github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNU
|
||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davidbyttow/govips/v2 v2.18.0 h1:pZRshWVYvewP/TZx3yZ7YeC42WyLXg53tHy5Qt8nT9E=
|
||||
github.com/davidbyttow/govips/v2 v2.18.0/go.mod h1:8+nst5zfMoats12PgmmAPh6p5OfjDaXK0BXMFl/vOcM=
|
||||
github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
|
||||
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
@@ -20,14 +22,18 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
|
||||
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs=
|
||||
github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
|
||||
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
|
||||
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM=
|
||||
@@ -48,24 +54,30 @@ go.mau.fi/util v0.9.10 h1:wzvz5iDHyqDXB8vgisD4d3SzucLXNM3iNY+1O1RoHtg=
|
||||
go.mau.fi/util v0.9.10/go.mod h1:YQOxySn+ZE3qSYqNxvyX7Yi3suA8YK17PS6QqBREW7A=
|
||||
go.mau.fi/whatsmeow v0.0.0-20260630180629-b572e5bcb92b h1:ZUk1ErarDNpnbosXR/MeOz2gkqA4S1bh8zjaSRj7N+Y=
|
||||
go.mau.fi/whatsmeow v0.0.0-20260630180629-b572e5bcb92b/go.mod h1:9dmNTYZ/1pHjPw/bz+azBsGjAkcrZbqzMrKcvG5bJ8U=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
package helper
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
hc "neo/context"
|
||||
)
|
||||
|
||||
func ConvertToMP3(data []byte) ([]byte, error) {
|
||||
cmd := exec.Command("ffmpeg", "-i", "pipe:0", "-f", "mp3", "-ab", "128k", "pipe:1")
|
||||
cmd.Stdin = bytes.NewReader(data)
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
func SendMedia(ctx *hc.Ctx, data []byte, mime, caption string) {
|
||||
switch {
|
||||
case strings.HasPrefix(mime, "video/mp4"):
|
||||
|
||||
+34
-17
@@ -4,30 +4,47 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/davidbyttow/govips/v2/vips"
|
||||
)
|
||||
|
||||
func ConvertImageToWebp(imgData []byte) ([]byte, error) {
|
||||
convertCmd := exec.Command("convert", "-", "-resize", "512x512", "-background", "none", "-compose", "Copy", "-gravity", "center", "-extent", "512x512", "-quality", "100", "png:-")
|
||||
convertCmd.Stdin = bytes.NewReader(imgData)
|
||||
var convertBuf bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
convertCmd.Stdout = &convertBuf
|
||||
convertCmd.Stderr = &stderr
|
||||
if err := convertCmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("convert failed: %w, stderr: %s", err, stderr.String())
|
||||
img, err := vips.NewImageFromBuffer(imgData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load image: %w", err)
|
||||
}
|
||||
defer img.Close()
|
||||
|
||||
if !img.HasAlpha() {
|
||||
if err := img.AddAlpha(); err != nil {
|
||||
return nil, fmt.Errorf("failed to add alpha: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
cwebpCmd := exec.Command("cwebp", "-quiet", "-mt", "-exact", "-q", "100", "-m", "6", "-alpha_q", "100", "-o", "-", "--", "-")
|
||||
cwebpCmd.Stdin = &convertBuf
|
||||
var cwebpBuf bytes.Buffer
|
||||
var cwebStderr bytes.Buffer
|
||||
cwebpCmd.Stdout = &cwebpBuf
|
||||
cwebpCmd.Stderr = &cwebStderr
|
||||
if err := cwebpCmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("cwebp failed: %w, stderr: %s", err, cwebStderr.String())
|
||||
if err := img.ThumbnailWithSize(512, 512, vips.InterestingNone, vips.SizeBoth); err != nil {
|
||||
return nil, fmt.Errorf("failed to resize: %w", err)
|
||||
}
|
||||
|
||||
return cwebpBuf.Bytes(), nil
|
||||
// Center in 512x512 canvas with transparent background (fit: contain)
|
||||
w, h := img.Width(), img.Height()
|
||||
if w != 512 || h != 512 {
|
||||
left := (512 - w) / 2
|
||||
top := (512 - h) / 2
|
||||
if err := img.EmbedBackgroundRGBA(left, top, 512, 512, &vips.ColorRGBA{R: 0, G: 0, B: 0, A: 0}); err != nil {
|
||||
return nil, fmt.Errorf("failed to embed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
webpBuf, _, err := img.ExportWebp(&vips.WebpExportParams{
|
||||
Quality: 100,
|
||||
Lossless: false,
|
||||
ReductionEffort: 6,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to export webp: %w", err)
|
||||
}
|
||||
|
||||
return webpBuf, nil
|
||||
}
|
||||
|
||||
func ConvertVideoToWebp(vidData []byte) ([]byte, error) {
|
||||
|
||||
@@ -8,8 +8,11 @@ import (
|
||||
"syscall"
|
||||
|
||||
_ "neo/cmds"
|
||||
"neo/cmds/group"
|
||||
"neo/core"
|
||||
"neo/database"
|
||||
|
||||
"github.com/davidbyttow/govips/v2/vips"
|
||||
"github.com/joho/godotenv"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/mdp/qrterminal/v3"
|
||||
@@ -33,6 +36,15 @@ func init() {
|
||||
}
|
||||
|
||||
func main() {
|
||||
vips.Startup(nil)
|
||||
defer vips.Shutdown()
|
||||
|
||||
go func() {
|
||||
database.InitDB(os.Getenv("SQLITE_PATH"))
|
||||
database.AutoMigrate()
|
||||
group.LoadCache()
|
||||
}()
|
||||
|
||||
dbLog := waLog.Stdout("Database", "DEBUG", true)
|
||||
ctx := context.Background()
|
||||
container, err := sqlstore.New(ctx, "sqlite3", fmt.Sprintf("file:%s?_foreign_keys=on", os.Getenv("SESSION_PATH")), dbLog)
|
||||
|
||||
Reference in New Issue
Block a user