From 1ef8fa3908d9ddb7e779c9519606f637fee08f9a Mon Sep 17 00:00:00 2001 From: Fdvkey Date: Mon, 20 Jul 2026 21:15:49 +0700 Subject: [PATCH] feat: add quote generation command --- cmds/media/quote.go | 286 ++++++++++++++++++++++++++++++++++++++++++++ context/message.go | 64 ++++++++++ 2 files changed, 350 insertions(+) create mode 100644 cmds/media/quote.go diff --git a/cmds/media/quote.go b/cmds/media/quote.go new file mode 100644 index 0000000..115ffd6 --- /dev/null +++ b/cmds/media/quote.go @@ -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 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) + }, +} diff --git a/context/message.go b/context/message.go index 789ae53..d0d3391 100644 --- a/context/message.go +++ b/context/message.go @@ -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 == "" {