feat: add quote generation command
This commit is contained in:
@@ -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)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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 == "" {
|
||||||
|
|||||||
Reference in New Issue
Block a user