Compare commits

...
19 Commits
Author SHA1 Message Date
Fd 1ef8fa3908 feat: add quote generation command 2026-07-20 21:15:49 +07:00
Fd f6e6d4eb59 feat: implement group filter management with caching and database integration 2026-07-20 15:33:15 +07:00
Fd a4914b3dfa feat: integrate govips for image processing and enhance image conversion to WebP 2026-07-18 12:08:19 +07:00
Fd 38773e31fc fix: instagram && tiktok downloader 2026-07-17 22:50:36 +07:00
Fd 24338beaaf feat: enhance YouTube download functionality with chunked requests and audio conversion 2026-07-17 17:24:44 +07:00
Fd 43a559bd55 refactor: streamline video conversion by removing intermediate file generation 2026-07-16 11:12:20 +07:00
Fd 6a4217960f refactor: simplify sticker media stream processing with switch statement 2026-07-16 10:53:26 +07:00
Fd d8b9f815c0 fix: replace hardcoded prefix cache with dynamic parameter mapping 2026-07-16 10:47:37 +07:00
Fd ef888b7256 perf: cache static command menu layout to avoid redundant loop processing 2026-07-16 07:15:38 +07:00
Fd 5f16856959 chore: comment out permissions in ping command 2026-07-15 21:13:25 +07:00
Fd 10209a5258 chore: add .dockerignore to exclude unnecessary files from Docker context 2026-07-15 21:12:40 +07:00
Fd cde38e4c2d feat: add help menu command 2026-07-15 21:02:49 +07:00
Fd 199846857f docs: clarify reason for retaining libwebp-tools in dockerfile 2026-07-15 19:48:30 +07:00
Fd 2bb2463aa5 chore: remove documentation folder and temporary trace artifacts 2026-07-15 19:40:34 +07:00
Fd 5439186bc6 fix: replace broken ffmpeg webp encoder with native gif2webp bypass 2026-07-15 19:24:41 +07:00
Fd 8c255a7066 fix: restore working crop scale filters for video to avoid exit status 8 2026-07-15 19:19:10 +07:00
Fd d93a40e531 feat: use pure-go exif injector in sticker command 2026-07-15 18:08:43 +07:00
Fd 68f964f810 feat: implement pure-go RIFF EXIF injector 2026-07-15 18:02:47 +07:00
Fd 6228cb2733 refactor: remove InjectExif shell wrapper from media_convert 2026-07-15 17:59:24 +07:00
24 changed files with 1114 additions and 642 deletions
+6
View File
@@ -0,0 +1,6 @@
*.db
.env
doc.json
neo
testing/
temp/
+4 -5
View File
@@ -1,8 +1,8 @@
# Stage: build # Stage: build
FROM golang:1.25.5-alpine AS build FROM golang:1.25.5-alpine AS build
# Install build dependencies (CGO often needed for go-sqlite3) # Install build dependencies (CGO for go-sqlite3 + govips)
RUN apk add --no-cache gcc musl-dev RUN apk add --no-cache gcc musl-dev vips-dev
WORKDIR /app WORKDIR /app
@@ -17,11 +17,10 @@ RUN CGO_ENABLED=1 GOOS=linux go build -trimpath -ldflags="-w -s" -o /app/neo .
# Stage: prod # Stage: prod
FROM alpine:latest AS prod FROM alpine:latest AS prod
# Install required tools (webpmux via libwebp-tools, ffmpeg) # Install runtime dependencies (vips for image processing, ffmpeg for video)
RUN apk add --no-cache \ RUN apk add --no-cache \
ffmpeg \ ffmpeg \
libwebp-tools \ vips \
imagemagick \
ca-certificates \ ca-certificates \
tzdata tzdata
+76
View File
@@ -1,6 +1,7 @@
package api package api
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "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. // Download fetches a URL and returns the raw bytes and MIME type.
func Download(rawURL string) ([]byte, string, error) { 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) req, err := http.NewRequest("GET", rawURL, nil)
if err != nil { if err != nil {
return nil, "", fmt.Errorf("download request: %w", err) 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) resp, err := httpClient.Do(req)
if err != nil { if err != nil {
return nil, "", fmt.Errorf("download: %w", err) return nil, "", fmt.Errorf("download: %w", err)
@@ -115,5 +127,69 @@ func Download(rawURL string) ([]byte, string, error) {
mime = mimetype.Detect(data).String() mime = mimetype.Detect(data).String()
} }
// fmt.Printf("Downloaded %d bytes from %s, MIME: %s\n", len(data), rawURL, mime)
return data, mime, nil 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
View File
@@ -7,7 +7,6 @@ import (
"neo/api" "neo/api"
hc "neo/context" hc "neo/context"
"neo/core" "neo/core"
"neo/helper"
) )
type instagramVideo struct { type instagramVideo struct {
@@ -47,23 +46,30 @@ var Instagram = &core.Command{
return return
} }
mediaURL := "" for _, video := range res.Videos {
if len(res.Videos) > 0 { data, _, err := api.Download(video.Src)
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 { if err != nil {
ctx.Reply(fmt.Sprintf("Download failed: %v", err)) ctx.Reply(fmt.Sprintf("Download failed: %v", err))
return 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
View File
@@ -7,7 +7,6 @@ import (
"neo/api" "neo/api"
hc "neo/context" hc "neo/context"
"neo/core" "neo/core"
"neo/helper"
) )
type tiktokResult struct { type tiktokResult struct {
@@ -31,7 +30,7 @@ var TikTok = &core.Command{
return 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) { if !ttRegex.MatchString(url) {
ctx.Reply("Invalid TikTok URL") ctx.Reply("Invalid TikTok URL")
return return
@@ -44,24 +43,28 @@ var TikTok = &core.Command{
return return
} }
mediaURL := "" caption := fmt.Sprintf("@%s\n%s", res.Username, res.Description)
if len(res.VideoURL) > 0 { if len(res.VideoURL) > 0 {
mediaURL = res.VideoURL[0] data, _, err := api.Download(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 { if err != nil {
ctx.Reply(fmt.Sprintf("Download failed: %v", err)) ctx.Reply(fmt.Sprintf("Download failed: %v", err))
return return
} }
ctx.SendVideo(data, caption)
caption := fmt.Sprintf("@%s\n%s", res.Username, res.Description) } else if len(res.ImageURL) > 0 {
helper.SendMedia(ctx, data, mime, caption) 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
View File
@@ -128,29 +128,50 @@ func formatSize(bytes int) string {
} }
func downloadByFormatID(ctx *hc.Ctx, res *api.YtDownloadResult, fid string) { func downloadByFormatID(ctx *hc.Ctx, res *api.YtDownloadResult, fid string) {
var targetURL string
var isAudioOnly bool
// Look for video // Look for video
for _, f := range res.VideoFormats { for _, f := range res.VideoFormats {
if f.FormatID == fid && f.URL != "" { if f.FormatID == fid && f.URL != "" {
data, mime, err := api.Download(f.URL) targetURL = f.URL
if err != nil { break
ctx.Reply(fmt.Sprintf("Download failed: %v", err))
return
}
helper.SendMedia(ctx, data, mime, res.Title) // using existing sendMedia handling
return
} }
} }
// Look for audio
// Look for audio if not found in video
if targetURL == "" {
for _, f := range res.AudioFormats { for _, f := range res.AudioFormats {
if f.FormatID == fid && f.URL != "" { 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 { if err != nil {
ctx.Reply(fmt.Sprintf("Download failed: %v", err)) ctx.Reply(fmt.Sprintf("Download failed: %v", err))
return 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) helper.SendMedia(ctx, data, mime, res.Title)
return
}
}
ctx.Reply("Format " + fid + " not found")
} }
+95
View File
@@ -0,0 +1,95 @@
package general
import (
"fmt"
"strings"
"sync"
"time"
hc "neo/context"
"neo/core"
)
func init() {
core.Default.Register(Help)
}
var (
cachedMenu string
menuOnce sync.Once
)
// buildMenuCache generates the static portion of the help menu with a {{PREFIX}} placeholder
func buildMenuCache() {
var sb strings.Builder
cmdMap := make(map[string][]string)
for _, cmd := range core.Default.GetCommands() {
cat := cmd.Category
if cat == "" {
cat = "general"
}
cmdMap[cat] = append(cmdMap[cat], cmd.Name)
}
for cat, cmds := range cmdMap {
sb.WriteString(fmt.Sprintf("\n*📃%s*\n", strings.ToUpper(cat)))
for i, cmdName := range cmds {
branch := "├"
if i == len(cmds)-1 {
branch = "└"
}
sb.WriteString(fmt.Sprintf("%s {{PREFIX}}%s\n", branch, cmdName))
}
}
cachedMenu = sb.String()
}
var Help = &core.Command{
Name: "help",
Aliases: []string{"?", "h", "menu"},
Category: "general",
Description: "Shows the bot's features and commands",
Run: func(ctx *hc.Ctx) {
loc, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
loc = time.FixedZone("UTC+7", 7*3600)
}
now := time.Now().In(loc)
hour := now.Hour()
var greeting string
switch {
case hour >= 4 && hour < 12:
greeting = "Ohayō"
case hour >= 12 && hour < 18:
greeting = "Konnichiwa"
default:
greeting = "Konbanwa"
}
pushName := ctx.Event().Info.PushName
if pushName == "" {
pushName = "User"
}
prefix := ctx.Prefix()
if prefix == "" {
prefix = "."
}
// Calculate the static command layout part only once
menuOnce.Do(func() {
buildMenuCache()
})
// Replace placeholder with the dynamically used prefix
finalMenu := strings.ReplaceAll(cachedMenu, "{{PREFIX}}", prefix)
header := fmt.Sprintf("%s *%s*👋\n📬 Need help? Here are all of my commands\n", greeting, pushName)
ctx.Reply(header + finalMenu)
},
}
+171
View File
@@ -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
}
+286
View File
@@ -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 = &quoteReplyMessage{
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)
},
}
+47 -23
View File
@@ -4,10 +4,13 @@ import (
"context" "context"
"fmt" "fmt"
"os" "os"
"strings"
hc "neo/context" hc "neo/context"
"neo/core" "neo/core"
"neo/helper" "neo/helper"
"go.mau.fi/whatsmeow"
) )
func init() { core.Default.Register(Sticker) } func init() { core.Default.Register(Sticker) }
@@ -20,12 +23,28 @@ var Sticker = &core.Command{
Run: func(ctx *hc.Ctx) { Run: func(ctx *hc.Ctx) {
// go ctx.React("👌") // go ctx.React("👌")
exifPath, cleanupExif := helper.ResolveExif(ctx.Arguments()) var packName, publisher string
defer func() { args := ctx.Arguments()
if cleanupExif {
os.Remove(exifPath) packName = os.Getenv("STICKER_NAME")
if packName == "" {
packName = "Bot"
}
publisher = os.Getenv("STICKER_PUBLISHER")
if publisher == "" {
publisher = "Bot"
}
if len(args) > 0 {
argsStr := strings.Join(args, " ")
parts := strings.Split(argsStr, "|")
packName = strings.TrimSpace(parts[0])
if len(parts) > 1 {
publisher = strings.TrimSpace(parts[1])
} else {
publisher = ""
}
} }
}()
msg := ctx.Message() msg := ctx.Message()
quoted := hc.GetQuotedMessage(msg) quoted := hc.GetQuotedMessage(msg)
@@ -33,29 +52,34 @@ var Sticker = &core.Command{
var err error var err error
var rawWebp []byte var rawWebp []byte
var mediaData []byte var mediaData []byte
var downloadable whatsmeow.DownloadableMessage
var mediaType string
if img := msg.GetImageMessage(); img != nil { switch {
mediaData, err = ctx.Client().Download(context.Background(), img) case msg.GetImageMessage() != nil:
downloadable, mediaType = msg.GetImageMessage(), "image"
case quoted != nil && quoted.GetImageMessage() != nil:
downloadable, mediaType = quoted.GetImageMessage(), "image"
case msg.GetVideoMessage() != nil:
downloadable, mediaType = msg.GetVideoMessage(), "video"
case quoted != nil && quoted.GetVideoMessage() != nil:
downloadable, mediaType = quoted.GetVideoMessage(), "video"
case quoted != nil && quoted.GetStickerMessage() != nil:
downloadable, mediaType = quoted.GetStickerMessage(), "sticker"
}
if downloadable != nil {
mediaData, err = ctx.Client().Download(context.Background(), downloadable)
if err == nil { if err == nil {
switch mediaType {
case "image":
rawWebp, err = helper.ConvertImageToWebp(mediaData) rawWebp, err = helper.ConvertImageToWebp(mediaData)
} case "video":
} else if img := quoted.GetImageMessage(); img != nil && quoted != nil {
mediaData, err = ctx.Client().Download(context.Background(), img)
if err == nil {
rawWebp, err = helper.ConvertImageToWebp(mediaData)
}
} else if vid := msg.GetVideoMessage(); vid != nil {
mediaData, err = ctx.Client().Download(context.Background(), vid)
if err == nil {
rawWebp, err = helper.ConvertVideoToWebp(mediaData) rawWebp, err = helper.ConvertVideoToWebp(mediaData)
case "sticker":
rawWebp = mediaData
} }
} else if vid := quoted.GetVideoMessage(); vid != nil && quoted != nil {
mediaData, err = ctx.Client().Download(context.Background(), vid)
if err == nil {
rawWebp, err = helper.ConvertVideoToWebp(mediaData)
} }
} else if smsg := quoted.GetStickerMessage(); smsg != nil && quoted != nil {
rawWebp, err = ctx.Client().Download(context.Background(), smsg)
} else { } else {
ctx.Reply("error: reply to image/video/sticker or send with caption") ctx.Reply("error: reply to image/video/sticker or send with caption")
return return
@@ -66,7 +90,7 @@ var Sticker = &core.Command{
return return
} }
finalSticker, err := helper.InjectExif(rawWebp, exifPath) finalSticker, err := helper.InjectExif(rawWebp, packName, publisher)
if err != nil { if err != nil {
ctx.Reply(fmt.Sprintf("error injecting EXIF: %v", err)) ctx.Reply(fmt.Sprintf("error injecting EXIF: %v", err))
return return
+1 -1
View File
@@ -15,7 +15,7 @@ var Ping = &core.Command{
Aliases: []string{"test", "tes", "p"}, Aliases: []string{"test", "tes", "p"},
Category: "general", Category: "general",
Description: "Check bot response time", Description: "Check bot response time",
Permissions: []core.Permission{core.PermissionGroupOnly}, // Permissions: []core.Permission{core.PermissionGroupOnly},
Run: func(ctx *hc.Ctx) { Run: func(ctx *hc.Ctx) {
t := ctx.Event().Info.Timestamp t := ctx.Event().Info.Timestamp
speed := time.Since(t).Milliseconds() speed := time.Since(t).Milliseconds()
+2
View File
@@ -2,6 +2,8 @@ package cmds
import ( import (
_ "neo/cmds/download" _ "neo/cmds/download"
_ "neo/cmds/general"
_ "neo/cmds/group"
_ "neo/cmds/media" _ "neo/cmds/media"
_ "neo/cmds/misc" _ "neo/cmds/misc"
) )
+64
View File
@@ -2,11 +2,13 @@ package context
import ( import (
"strings" "strings"
stdctx "context"
waE2E "go.mau.fi/whatsmeow/proto/waE2E" waE2E "go.mau.fi/whatsmeow/proto/waE2E"
waTypes "go.mau.fi/whatsmeow/types" waTypes "go.mau.fi/whatsmeow/types"
"go.mau.fi/whatsmeow/types/events" "go.mau.fi/whatsmeow/types/events"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
"go.mau.fi/whatsmeow"
) )
// ParseMessageText extracts text content from a message event. // 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. // ParseJID parses a JID string, adding @s.whatsapp.net if no server is specified.
func ParseJID(arg string) (waTypes.JID, bool) { func ParseJID(arg string) (waTypes.JID, bool) {
if arg == "" { if arg == "" {
+6 -2
View File
@@ -47,13 +47,17 @@ func (c *Ctx) UploadVideo(bytes []byte, caption string) (*waE2E.VideoMessage, er
} }
func (c *Ctx) UploadAudio(bytes []byte) (*waE2E.AudioMessage, error) { 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) resp, err := c.client.Upload(stdctx.Background(), bytes, whatsmeow.MediaAudio)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &waE2E.AudioMessage{ return &waE2E.AudioMessage{
Mimetype: proto.String(mime.String()), Mimetype: proto.String(mime),
URL: &resp.URL, URL: &resp.URL,
DirectPath: &resp.DirectPath, DirectPath: &resp.DirectPath,
MediaKey: resp.MediaKey, MediaKey: resp.MediaKey,
+29
View File
@@ -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
}
+14
View File
@@ -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{})
}
@@ -1,412 +0,0 @@
# Pure-Go WebP EXIF RIFF Injector Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Modify `helper/exif.go` and `helper/media_convert.go` to inject EXIF payloads directly into WebP RIFF byte slices entirely in Go memory, replacing `webpmux`.
**Architecture:** We will implement an `InjectExif(webpBytes []byte, packName, publisher string) ([]byte, error)` function inside `helper/exif.go` to handle raw RIFF parsing in place, and strip out the old Temp File logic. Webpmux invocation in `helper/media_convert.go` will be removed.
**Tech Stack:** Go (encoding/binary)
## Global Constraints
- Target files: `helper/exif.go` and `helper/media_convert.go`
- Do not use CGO or any unstated 3rd party package for RIFF handling. Implement manually using `encoding/binary`.
- Format must be `VP8X` with the EXIF flag bit activated.
---
### Task 1: Clean Up media_convert.go
**Files:**
- Modify:`/Users/fd/Documents/perf/helper/media_convert.go`
**Interfaces:**
- Consumes: Nothing
- Produces: `ConvertImageToWebp`, `ConvertVideoToWebp`
- [ ] **Step 1: Simplify Convert methods**
Remove `InjectExif` from `media_convert.go` since it will be rewritten from scratch in `exif.go`.
```go
package helper
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"github.com/google/uuid"
)
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
convertCmd.Stdout = &convertBuf
if err := convertCmd.Run(); err != nil {
return nil, fmt.Errorf("convert failed: %w", err)
}
cwebpCmd := exec.Command("cwebp", "-quiet", "-mt", "-exact", "-q", "100", "-m", "6", "-alpha_q", "100", "-o", "-", "--", "-")
cwebpCmd.Stdin = &convertBuf
var cwebpBuf bytes.Buffer
cwebpCmd.Stdout = &cwebpBuf
if err := cwebpCmd.Run(); err != nil {
return nil, fmt.Errorf("cwebp failed: %w", err)
}
return cwebpBuf.Bytes(), nil
}
func ConvertVideoToWebp(vidData []byte) ([]byte, error) {
webpPath := filepath.Join("temp", fmt.Sprintf("%s.webp", uuid.New().String()))
defer os.Remove(webpPath)
ffmpegCmd := exec.Command("ffmpeg",
"-y", "-hide_banner", "-loglevel", "error",
"-i", "pipe:0", "-f", "mp4",
"-ss", "00:00:00", "-t", "00:00:15",
"-vf", "fps=10,scale=720:-1:flags=lanczos:force_original_aspect_ratio=increase,crop=512:512,pad=512:512:(ow-iw)/2:(oh-ih)/2:color=#00000000,setsar=1",
"-compression_level", "6",
"-q:v", "60", "-loop", "0",
"-preset", "picture", "-an", "-fps_mode", "auto",
"-f", "webp", webpPath,
)
ffmpegCmd.Stdin = bytes.NewReader(vidData)
if err := ffmpegCmd.Run(); err != nil {
return nil, fmt.Errorf("ffmpeg failed: %w", err)
}
return os.ReadFile(webpPath)
}
```
- [ ] **Step 2: Check standard test suite**
Run: `go build ./...`
- [ ] **Step 3: Commit**
```bash
git add helper/media_convert.go
git commit -m "refactor: remove InjectExif shell wrapper from media_convert"
```
### Task 2: Implement Pure-Go RIFF Injector
**Files:**
- Modify:`/Users/fd/Documents/perf/helper/exif.go`
**Interfaces:**
- Produces: `InjectExif(webpData []byte, packName, publisher string) ([]byte, error)`
- Consumes: Nothing
- [ ] **Step 1: Write `helper/exif.go`**
Overwrite `/Users/fd/Documents/perf/helper/exif.go` with the completely new implementation. The previous dynamic `temp/default.exif` file logic is gone.
```go
package helper
import (
"bytes"
"encoding/binary"
"encoding/json"
"fmt"
"math/big"
)
type StickerMetadata struct {
PackId string `json:"sticker-pack-id"`
Name string `json:"sticker-pack-name"`
Publisher string `json:"sticker-pack-publisher"`
Emojis []string `json:"emojis,omitempty"`
}
func writeUIntLE(buffer []byte, value, offset, byteLength int64) {
slice := make([]byte, byteLength)
val := new(big.Int)
val.SetUint64(uint64(value))
valBytes := val.Bytes()
tmp := make([]byte, len(valBytes))
for i := range valBytes {
tmp[i] = valBytes[len(valBytes)-1-i]
}
copy(slice, tmp)
copy(buffer[offset:], slice)
}
func createMetadataBytes(packName, publisher string) []byte {
exifObj := StickerMetadata{
PackId: "com.wa.bot.sticker",
Name: packName,
Publisher: publisher,
Emojis: []string{"😀"},
}
exifJson, err := json.Marshal(exifObj)
if err != nil {
return nil
}
bit := []byte{0x49, 0x49, 0x2A, 0x00, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x41, 0x57, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x16, 0x00, 0x00, 0x00}
bit = append(bit, exifJson...)
writeUIntLE(bit, int64(len(exifJson)), 14, 4)
return bit
}
// InjectExif takes raw WebP bytes and constructs a new WebP RIFF byte slice containing the EXIF metadata
func InjectExif(webpData []byte, packName, publisher string) ([]byte, error) {
if len(webpData) < 12 || string(webpData[0:4]) != "RIFF" || string(webpData[8:12]) != "WEBP" {
return nil, fmt.Errorf("not a valid WEBP file")
}
var width, height uint32
hasVP8X := false
var vp8xFlags byte
var vp8xBytes []byte
var chunks [][]byte
// Default defaults for VP8X canvas sizes
width = 512
height = 512
offset := 12
for offset < len(webpData) {
if offset+8 > len(webpData) {
break
}
chunkID := string(webpData[offset : offset+4])
chunkSize := binary.LittleEndian.Uint32(webpData[offset+4 : offset+8])
paddedSize := chunkSize
if paddedSize%2 != 0 {
paddedSize++
}
if offset+8+int(paddedSize) > len(webpData) {
break
}
chunkData := webpData[offset+8 : offset+8+int(paddedSize)]
switch chunkID {
case "VP8X":
hasVP8X = true
vp8xFlags = chunkData[0]
vp8xBytes = chunkData
// extract dimensions
wBytes := []byte{chunkData[4], chunkData[5], chunkData[6], 0}
hBytes := []byte{chunkData[7], chunkData[8], chunkData[9], 0}
width = binary.LittleEndian.Uint32(wBytes) + 1
height = binary.LittleEndian.Uint32(hBytes) + 1
case "VP8 ":
if !hasVP8X {
// extract 14 bit scale block
w := uint32(chunkData[6]) | (uint32(chunkData[7]) << 8)
width = w & 0x3FFF
h := uint32(chunkData[8]) | (uint32(chunkData[9]) << 8)
height = h & 0x3FFF
}
chunks = append(chunks, webpData[offset:offset+8+int(paddedSize)])
case "VP8L":
if !hasVP8X {
// Parse bits 1-28 for w/h
b0 := uint32(chunkData[1])
b1 := uint32(chunkData[2])
b2 := uint32(chunkData[3])
b3 := uint32(chunkData[4])
width = 1 + (((b1 & 0x3F) << 8) | b0)
height = 1 + (((b3 & 0xF) << 10) | (b2 << 2) | ((b1 & 0xC0) >> 6))
}
chunks = append(chunks, webpData[offset:offset+8+int(paddedSize)])
default:
if chunkID != "EXIF" {
chunks = append(chunks, webpData[offset:offset+8+int(paddedSize)])
}
}
offset += 8 + int(paddedSize)
}
exifMeta := createMetadataBytes(packName, publisher)
if exifMeta == nil {
return nil, fmt.Errorf("failed to create exif byte payload")
}
// Turn on EXIF flag (bit 3) in VP8X
if !hasVP8X {
vp8xBytes = make([]byte, 10)
vp8xFlags = 0x08 // EXIF flag is bit 3
vp8xBytes[0] = vp8xFlags
w := width - 1
h := height - 1
vp8xBytes[4] = byte(w)
vp8xBytes[5] = byte(w >> 8)
vp8xBytes[6] = byte(w >> 16)
vp8xBytes[7] = byte(h)
vp8xBytes[8] = byte(h >> 8)
vp8xBytes[9] = byte(h >> 16)
} else {
vp8xBytes[0] = vp8xFlags | 0x08
}
var buf bytes.Buffer
buf.WriteString("RIFF")
buf.Write([]byte{0, 0, 0, 0}) // Total size placeholder
buf.WriteString("WEBP")
// Write VP8X
buf.WriteString("VP8X")
vp8xSize := make([]byte, 4)
binary.LittleEndian.PutUint32(vp8xSize, 10)
buf.Write(vp8xSize)
buf.Write(vp8xBytes)
// Write existing chunks
for _, chunk := range chunks {
buf.Write(chunk)
}
// Write EXIF
buf.WriteString("EXIF")
exifSize := uint32(len(exifMeta))
exifSizeSlice := make([]byte, 4)
binary.LittleEndian.PutUint32(exifSizeSlice, exifSize)
buf.Write(exifSizeSlice)
buf.Write(exifMeta)
if exifSize%2 != 0 {
buf.WriteByte(0)
}
result := buf.Bytes()
totalSize := uint32(len(result) - 8)
binary.LittleEndian.PutUint32(result[4:8], totalSize)
return result, nil
}
```
- [ ] **Step 2: Check test suite**
Run: `go build ./...`
- [ ] **Step 3: Commit**
```bash
git add helper/exif.go
git commit -m "feat: implement pure-go RIFF EXIF injector"
```
### Task 3: Update cmds/media/sticker.go Signature
**Files:**
- Modify:`/Users/fd/Documents/perf/cmds/media/sticker.go`
**Interfaces:**
- Consumes: `helper.InjectExif(rawWebp, packName, publisher)`
- [ ] **Step 1: Replace ResolveExif usages in `cmds/media/sticker.go`**
Read `ctx.Arguments()` directly to compute `packName` and `publisher` locally instead of calling `helper.ResolveExif`. Use the same ENV logic as fallback defaults if args are empty. Remove `helper.ResolveExif` code context cleanup.
```go
package media
import (
"context"
"fmt"
"os"
"strings"
hc "neo/context"
"neo/core"
"neo/helper"
)
func init() { core.Default.Register(Sticker) }
var Sticker = &core.Command{
Name: "sticker",
Aliases: []string{"s", "stiker"},
Category: "media",
Description: "Create sticker from image/video or rewrite existing sticker EXIF",
Run: func(ctx *hc.Ctx) {
ctx.React("👌")
args := ctx.Arguments()
packName := os.Getenv("STICKER_NAME")
if packName == "" {
packName = "Bot"
}
publisher := os.Getenv("STICKER_PUBLISHER")
if publisher == "" {
publisher = "Bot"
}
if len(args) > 0 {
argStr := strings.Join(args, " ")
parts := strings.Split(argStr, "|")
packName = strings.TrimSpace(parts[0])
publisher = "Bot"
if len(parts) > 1 {
publisher = strings.TrimSpace(parts[1])
}
}
msg := ctx.Message()
quoted := hc.GetQuotedMessage(msg)
var err error
var rawWebp []byte
var mediaData []byte
if img := msg.GetImageMessage(); img != nil {
mediaData, err = ctx.Client().Download(context.Background(), img)
if err == nil {
rawWebp, err = helper.ConvertImageToWebp(mediaData)
}
} else if img := quoted.GetImageMessage(); img != nil && quoted != nil {
mediaData, err = ctx.Client().Download(context.Background(), img)
if err == nil {
rawWebp, err = helper.ConvertImageToWebp(mediaData)
}
} else if vid := msg.GetVideoMessage(); vid != nil {
mediaData, err = ctx.Client().Download(context.Background(), vid)
if err == nil {
rawWebp, err = helper.ConvertVideoToWebp(mediaData)
}
} else if vid := quoted.GetVideoMessage(); vid != nil && quoted != nil {
mediaData, err = ctx.Client().Download(context.Background(), vid)
if err == nil {
rawWebp, err = helper.ConvertVideoToWebp(mediaData)
}
} else if smsg := quoted.GetStickerMessage(); smsg != nil && quoted != nil {
rawWebp, err = ctx.Client().Download(context.Background(), smsg)
} else {
ctx.Reply("error: reply to image/video/sticker or send with caption")
return
}
if err != nil {
ctx.Reply(fmt.Sprintf("error processing media: %v", err))
return
}
finalSticker, err := helper.InjectExif(rawWebp, packName, publisher)
if err != nil {
ctx.Reply(fmt.Sprintf("error injecting EXIF: %v", err))
return
}
ctx.SendSticker(finalSticker)
},
}
```
- [ ] **Step 2: Verify Compilation**
Run: `go build ./...`
- [ ] **Step 3: Commit**
```bash
git add cmds/media/sticker.go
git commit -m "feat: use pure-go exif injector in sticker command"
```
@@ -1,17 +0,0 @@
# Pure-Go WebP EXIF RIFF Injector
## 1. Goal
Completely remove file-based IO and the `webpmux` dependency by weaving the EXIF chunk into the WebP RIFF byte struct physically in memory.
## 2. Architecture
Instead of using `webpmux` passing paths around, `helper/exif.go` will implement `InjectExif(webpBytes []byte, packName, publisher string) ([]byte, error)`.
## 3. RIFF Parsing Logic
1. Validate `RIFF` and `WEBP` signatures.
2. Read all chunks to extract Canvas Width/Height (from `VP8X`, `VP8L`, or `VP8 `).
3. Identify if `VP8X` is missing, and if so, construct it. We must ensure the `EXIF` bit flag is set.
4. Construct the custom `EXIF` chunk (which holds the exact binary array and JSON metadata we validated via F-BOT_GO earlier).
5. Append `EXIF` to the end of the WebP chunks, update the main `RIFF` file size pointer, and return the aggregated `[]byte` slice.
## 4. Main Command Refactor
`cmds/media/sticker.go` will stop requesting files via `ResolveExif()`. It will simply pass the raw Webp bytes received from `ConvertImageToWebp` or `ConvertVideoToWebp` into `InjectExif` and instantly upload the resulting byte array.
+13 -7
View File
@@ -3,12 +3,15 @@ module neo
go 1.25.5 go 1.25.5
require ( require (
github.com/davidbyttow/govips/v2 v2.18.0
github.com/gabriel-vasile/mimetype v1.4.13 github.com/gabriel-vasile/mimetype v1.4.13
github.com/joho/godotenv v1.5.1 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 github.com/mdp/qrterminal/v3 v3.2.1
go.mau.fi/whatsmeow v0.0.0-20260630180629-b572e5bcb92b go.mau.fi/whatsmeow v0.0.0-20260630180629-b572e5bcb92b
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.11
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.2
) )
require ( require (
@@ -17,6 +20,8 @@ require (
github.com/coder/websocket v1.8.15 // indirect github.com/coder/websocket v1.8.15 // indirect
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
github.com/google/uuid v1.6.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-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // 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 github.com/vektah/gqlparser/v2 v2.5.27 // indirect
go.mau.fi/libsignal v0.2.2 // indirect go.mau.fi/libsignal v0.2.2 // indirect
go.mau.fi/util v0.9.10 // 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/exp v0.0.0-20260611194520-c48552f49976 // indirect
golang.org/x/net v0.56.0 // indirect golang.org/x/image v0.44.0 // indirect
golang.org/x/sync v0.21.0 // indirect golang.org/x/net v0.57.0 // indirect
golang.org/x/sys v0.46.0 // indirect golang.org/x/sync v0.22.0 // indirect
golang.org/x/term v0.44.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.38.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 rsc.io/qr v0.2.0 // indirect
) )
+26 -14
View File
@@ -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/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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo= github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= 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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 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 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs=
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= 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 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= 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= 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/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 h1:ZUk1ErarDNpnbosXR/MeOz2gkqA4S1bh8zjaSRj7N+Y=
go.mau.fi/whatsmeow v0.0.0-20260630180629-b572e5bcb92b/go.mod h1:9dmNTYZ/1pHjPw/bz+azBsGjAkcrZbqzMrKcvG5bJ8U= 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.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= 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 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY= 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/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= 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 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
+115 -60
View File
@@ -1,15 +1,11 @@
package helper package helper
import ( import (
"bytes"
"encoding/binary"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"math/big" "math/big"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
) )
type StickerMetadata struct { type StickerMetadata struct {
@@ -19,7 +15,6 @@ type StickerMetadata struct {
Emojis []string `json:"emojis,omitempty"` Emojis []string `json:"emojis,omitempty"`
} }
// Added matching writeUIntLE from F-BOT_GO reference
func writeUIntLE(buffer []byte, value, offset, byteLength int64) { func writeUIntLE(buffer []byte, value, offset, byteLength int64) {
slice := make([]byte, byteLength) slice := make([]byte, byteLength)
val := new(big.Int) val := new(big.Int)
@@ -36,7 +31,7 @@ func writeUIntLE(buffer []byte, value, offset, byteLength int64) {
func createMetadataBytes(packName, publisher string) []byte { func createMetadataBytes(packName, publisher string) []byte {
exifObj := StickerMetadata{ exifObj := StickerMetadata{
PackId: "com.wa.bot.sticker", // or generic ID PackId: "com.wa.bot.sticker",
Name: packName, Name: packName,
Publisher: publisher, Publisher: publisher,
Emojis: []string{"😀"}, Emojis: []string{"😀"},
@@ -44,83 +39,143 @@ func createMetadataBytes(packName, publisher string) []byte {
exifJson, err := json.Marshal(exifObj) exifJson, err := json.Marshal(exifObj)
if err != nil { if err != nil {
log.Printf("Failed to marshal EXIF: %v", err)
return nil return nil
} }
// WebP EXIF JSON string representation inside the file
bit := []byte{0x49, 0x49, 0x2A, 0x00, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x41, 0x57, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x16, 0x00, 0x00, 0x00} bit := []byte{0x49, 0x49, 0x2A, 0x00, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x41, 0x57, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x16, 0x00, 0x00, 0x00}
bit = append(bit, exifJson...) bit = append(bit, exifJson...)
// Crucial fix: Inject JSON string length at offset 14 (4 bytes)
writeUIntLE(bit, int64(len(exifJson)), 14, 4) writeUIntLE(bit, int64(len(exifJson)), 14, 4)
return bit return bit
} }
func getOrCreateDefaultExif() string { // InjectExif takes raw WebP bytes and constructs a new WebP RIFF byte slice containing the EXIF metadata
defaultExifPath := "temp/default.exif" func InjectExif(webpData []byte, packName, publisher string) ([]byte, error) {
if len(webpData) < 12 || string(webpData[0:4]) != "RIFF" || string(webpData[8:12]) != "WEBP" {
if err := os.MkdirAll("temp", 0755); err != nil { return nil, fmt.Errorf("not a valid WEBP file")
log.Printf("Failed to create temp directory: %v", err)
return ""
} }
// ALWAYS recreate temp/default.exif just in case previous boots were corrupted var width, height uint32
// This corrects broken instances locally instantly hasVP8X := false
packName := os.Getenv("STICKER_NAME") var vp8xFlags byte
if packName == "" { var vp8xBytes []byte
packName = "Bot"
var chunks [][]byte
// Default defaults for VP8X canvas sizes
width = 512
height = 512
offset := 12
for offset < len(webpData) {
if offset+8 > len(webpData) {
break
}
chunkID := string(webpData[offset : offset+4])
chunkSize := binary.LittleEndian.Uint32(webpData[offset+4 : offset+8])
paddedSize := chunkSize
if paddedSize%2 != 0 {
paddedSize++
} }
publisher := os.Getenv("STICKER_PUBLISHER") if offset+8+int(paddedSize) > len(webpData) {
if publisher == "" { break
publisher = "Bot"
} }
exifContent := createMetadataBytes(packName, publisher) chunkData := webpData[offset+8 : offset+8+int(paddedSize)]
if exifContent == nil {
return "" switch chunkID {
case "VP8X":
hasVP8X = true
vp8xFlags = chunkData[0]
vp8xBytes = chunkData
// extract dimensions
wBytes := []byte{chunkData[4], chunkData[5], chunkData[6], 0}
hBytes := []byte{chunkData[7], chunkData[8], chunkData[9], 0}
width = binary.LittleEndian.Uint32(wBytes) + 1
height = binary.LittleEndian.Uint32(hBytes) + 1
case "VP8 ":
if !hasVP8X {
// extract 14 bit scale block
w := uint32(chunkData[6]) | (uint32(chunkData[7]) << 8)
width = w & 0x3FFF
h := uint32(chunkData[8]) | (uint32(chunkData[9]) << 8)
height = h & 0x3FFF
}
chunks = append(chunks, webpData[offset:offset+8+int(paddedSize)])
case "VP8L":
if !hasVP8X {
// Parse bits 1-28 for w/h
b0 := uint32(chunkData[1])
b1 := uint32(chunkData[2])
b2 := uint32(chunkData[3])
b3 := uint32(chunkData[4])
width = 1 + (((b1 & 0x3F) << 8) | b0)
height = 1 + (((b3 & 0xF) << 10) | (b2 << 2) | ((b1 & 0xC0) >> 6))
}
chunks = append(chunks, webpData[offset:offset+8+int(paddedSize)])
default:
if chunkID != "EXIF" {
chunks = append(chunks, webpData[offset:offset+8+int(paddedSize)])
}
}
offset += 8 + int(paddedSize)
} }
if err := os.WriteFile(defaultExifPath, exifContent, 0644); err != nil { exifMeta := createMetadataBytes(packName, publisher)
log.Printf("Failed to write default EXIF: %v", err) if exifMeta == nil {
return "" return nil, fmt.Errorf("failed to create exif byte payload")
} }
return defaultExifPath // Turn on EXIF flag (bit 3) in VP8X
} if !hasVP8X {
vp8xBytes = make([]byte, 10)
func ResolveExif(args []string) (string, bool) { vp8xFlags = 0x08 // EXIF flag is bit 3
if len(args) == 0 { vp8xBytes[0] = vp8xFlags
return getOrCreateDefaultExif(), false w := width - 1
} h := height - 1
vp8xBytes[4] = byte(w)
argStr := strings.Join(args, " ") vp8xBytes[5] = byte(w >> 8)
parts := strings.Split(argStr, "|") vp8xBytes[6] = byte(w >> 16)
vp8xBytes[7] = byte(h)
packName := strings.TrimSpace(parts[0]) vp8xBytes[8] = byte(h >> 8)
publisher := "Bot" vp8xBytes[9] = byte(h >> 16)
if len(parts) > 1 { } else {
publisher = strings.TrimSpace(parts[1]) vp8xBytes[0] = vp8xFlags | 0x08
} }
if packName == "" && publisher == "Bot" { var buf bytes.Buffer
return getOrCreateDefaultExif(), false buf.WriteString("RIFF")
buf.Write([]byte{0, 0, 0, 0}) // Total size placeholder
buf.WriteString("WEBP")
// Write VP8X
buf.WriteString("VP8X")
vp8xSize := make([]byte, 4)
binary.LittleEndian.PutUint32(vp8xSize, 10)
buf.Write(vp8xSize)
buf.Write(vp8xBytes)
// Write existing chunks
for _, chunk := range chunks {
buf.Write(chunk)
} }
exifContent := createMetadataBytes(packName, publisher) // Write EXIF
if exifContent == nil { buf.WriteString("EXIF")
return getOrCreateDefaultExif(), false exifSize := uint32(len(exifMeta))
exifSizeSlice := make([]byte, 4)
binary.LittleEndian.PutUint32(exifSizeSlice, exifSize)
buf.Write(exifSizeSlice)
buf.Write(exifMeta)
if exifSize%2 != 0 {
buf.WriteByte(0)
} }
id := uuid.New().String()
tempPath := filepath.Join("temp", fmt.Sprintf("%s.exif", id))
if err := os.WriteFile(tempPath, exifContent, 0644); err != nil { result := buf.Bytes()
log.Printf("Failed to write dynamic EXIF: %v", err) totalSize := uint32(len(result) - 8)
return getOrCreateDefaultExif(), false binary.LittleEndian.PutUint32(result[4:8], totalSize)
}
return tempPath, true return result, nil
} }
+13
View File
@@ -1,11 +1,24 @@
package helper package helper
import ( import (
"bytes"
"os/exec"
"strings" "strings"
hc "neo/context" 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) { func SendMedia(ctx *hc.Ctx, data []byte, mime, caption string) {
switch { switch {
case strings.HasPrefix(mime, "video/mp4"): case strings.HasPrefix(mime, "video/mp4"):
+47 -44
View File
@@ -3,69 +3,72 @@ package helper
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"os"
"os/exec" "os/exec"
"path/filepath"
"github.com/google/uuid" "github.com/davidbyttow/govips/v2/vips"
) )
func ConvertImageToWebp(imgData []byte) ([]byte, error) { func ConvertImageToWebp(imgData []byte) ([]byte, error) {
convertCmd := exec.Command("convert", "-", "-resize", "512x512", "-background", "none", "-compose", "Copy", "-gravity", "center", "-extent", "512x512", "-quality", "100", "png:-") img, err := vips.NewImageFromBuffer(imgData)
convertCmd.Stdin = bytes.NewReader(imgData) if err != nil {
var convertBuf bytes.Buffer return nil, fmt.Errorf("failed to load image: %w", err)
convertCmd.Stdout = &convertBuf }
if err := convertCmd.Run(); err != nil { defer img.Close()
return nil, fmt.Errorf("convert failed: %w", err)
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", "-", "--", "-") if err := img.ThumbnailWithSize(512, 512, vips.InterestingNone, vips.SizeBoth); err != nil {
cwebpCmd.Stdin = &convertBuf return nil, fmt.Errorf("failed to resize: %w", err)
var cwebpBuf bytes.Buffer
cwebpCmd.Stdout = &cwebpBuf
if err := cwebpCmd.Run(); err != nil {
return nil, fmt.Errorf("cwebp failed: %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) { func ConvertVideoToWebp(vidData []byte) ([]byte, error) {
webpPath := filepath.Join("temp", fmt.Sprintf("%s.webp", uuid.New().String()))
defer os.Remove(webpPath)
ffmpegCmd := exec.Command("ffmpeg", ffmpegCmd := exec.Command("ffmpeg",
"-y", "-hide_banner", "-loglevel", "error", "-y", "-hide_banner", "-loglevel", "error",
"-i", "pipe:0", "-f", "mp4", "-i", "pipe:0",
"-ss", "00:00:00", "-t", "00:00:15", "-ss", "00:00:00", "-t", "00:00:15",
"-vcodec", "libwebp",
"-vf", "fps=10,scale=720:-1:flags=lanczos:force_original_aspect_ratio=increase,crop=512:512,pad=512:512:(ow-iw)/2:(oh-ih)/2:color=#00000000,setsar=1", "-vf", "fps=10,scale=720:-1:flags=lanczos:force_original_aspect_ratio=increase,crop=512:512,pad=512:512:(ow-iw)/2:(oh-ih)/2:color=#00000000,setsar=1",
"-compression_level", "6", "-loop", "0",
"-q:v", "60", "-loop", "0", "-preset", "default",
"-preset", "picture", "-an", "-fps_mode", "auto", "-an",
"-f", "webp", webpPath, "-f", "webp", "pipe:1",
) )
ffmpegCmd.Stdin = bytes.NewReader(vidData) ffmpegCmd.Stdin = bytes.NewReader(vidData)
var outBuf bytes.Buffer
var stderr bytes.Buffer
ffmpegCmd.Stdout = &outBuf
ffmpegCmd.Stderr = &stderr
if err := ffmpegCmd.Run(); err != nil { if err := ffmpegCmd.Run(); err != nil {
return nil, fmt.Errorf("ffmpeg failed: %w", err) return nil, fmt.Errorf("ffmpeg direct convert failed: %w, stderr: %s", err, stderr.String())
} }
return os.ReadFile(webpPath) return outBuf.Bytes(), nil
}
func InjectExif(webpData []byte, exifPath string) ([]byte, error) {
webpPath := filepath.Join("temp", fmt.Sprintf("%s.webp", uuid.New().String()))
defer os.Remove(webpPath)
tmpWebp := webpPath + ".tmp.webp"
if err := os.WriteFile(tmpWebp, webpData, 0644); err != nil {
return nil, err
}
defer os.Remove(tmpWebp)
webpmuxCmd := exec.Command("webpmux", "-set", "exif", exifPath, tmpWebp, "-o", webpPath)
if err := webpmuxCmd.Run(); err != nil {
return nil, fmt.Errorf("webpmux failed: %w", err)
}
return os.ReadFile(webpPath)
} }
+12
View File
@@ -8,8 +8,11 @@ import (
"syscall" "syscall"
_ "neo/cmds" _ "neo/cmds"
"neo/cmds/group"
"neo/core" "neo/core"
"neo/database"
"github.com/davidbyttow/govips/v2/vips"
"github.com/joho/godotenv" "github.com/joho/godotenv"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
"github.com/mdp/qrterminal/v3" "github.com/mdp/qrterminal/v3"
@@ -33,6 +36,15 @@ func init() {
} }
func main() { 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) dbLog := waLog.Stdout("Database", "DEBUG", true)
ctx := context.Background() ctx := context.Background()
container, err := sqlstore.New(ctx, "sqlite3", fmt.Sprintf("file:%s?_foreign_keys=on", os.Getenv("SESSION_PATH")), dbLog) container, err := sqlstore.New(ctx, "sqlite3", fmt.Sprintf("file:%s?_foreign_keys=on", os.Getenv("SESSION_PATH")), dbLog)