feat: add quote generation command

This commit is contained in:
Fd
2026-07-20 21:15:49 +07:00
parent f6e6d4eb59
commit 1ef8fa3908
2 changed files with 350 additions and 0 deletions
+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)
},
}
+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 == "" {