feat(send): add sticker messages
Add wacli send sticker for 512x512 WebP sticker files, including recipient resolution, quoted replies, sync-process delegation, local media metadata, and stricter sticker payload validation. Verified with local full gate, GitHub CI, and a live sticker send. Co-authored-by: Dinakar Sarbada <dinakars777@users.noreply.github.com> Co-authored-by: Filipe <35941797+fm1randa@users.noreply.github.com>
This commit is contained in:
parent
cd311e86c4
commit
f1cb39fe8a
@ -23,6 +23,7 @@
|
||||
- Send: add `send file --reply-to` for quoted media/document replies. (#68 — thanks @vlassance)
|
||||
- Send: add repeatable `send text --mention` for WhatsApp user mentions in group messages. (#16 — thanks @nicozefrench and @sheepworrier)
|
||||
- Send: add automatic link previews for text messages with `--no-preview` opt-out. (#94, #95 — thanks @elgatoflaco)
|
||||
- Send: add `send sticker` for 512x512 WebP stickers, including animated-sticker metadata. (#205, #27 — thanks @dinakars777 and @fm1randa)
|
||||
- Send: add `send voice` and `send file --ptt` for OGG/Opus WhatsApp voice notes. (#40, #41 — thanks @ricardopolo and @emre6943)
|
||||
- Send: accept common phone-number formatting in recipient flags while still storing digits-only WhatsApp JIDs. (#130 — thanks @fahmidme and @ImLukeF)
|
||||
- Send: resolve `send text/file --to` against local contacts, groups, and chats, with `--pick` for non-interactive disambiguation. (#122 — thanks @AndroidPoet)
|
||||
|
||||
@ -22,7 +22,7 @@ Full docs site: <https://wacli.sh>.
|
||||
- [Auth](docs/auth.md): `auth`, `auth status`, `auth logout`.
|
||||
- [Sync](docs/sync.md): `sync --once`, `sync --follow`, refresh, media download.
|
||||
- [Messages](docs/messages.md): `messages list/search/starred/show/context`.
|
||||
- [Send](docs/send.md): `send text/file/react`, recipient resolution, replies.
|
||||
- [Send](docs/send.md): `send text/file/sticker/voice/react`, recipient resolution, replies.
|
||||
- [Media](docs/media.md): `media download`.
|
||||
- [Contacts](docs/contacts.md): `contacts search/show/refresh`, aliases, tags.
|
||||
- [Chats](docs/chats.md): `chats list/show`.
|
||||
@ -41,7 +41,7 @@ Full docs site: <https://wacli.sh>.
|
||||
- **Auth + sync**: `auth` shows QR login and bootstraps sync; `sync` is non-interactive, can run once or follow continuously, and can refresh contacts/groups.
|
||||
- **Offline message store**: local SQLite store with FTS5 search when available and LIKE fallback.
|
||||
- **Message tools**: list/search/show/context with chat, sender, direction, time, order, and media-type filters.
|
||||
- **Sending**: send text, mentions, quoted replies, and image/video/audio/document files with captions, MIME override, and custom display filenames. Sends keep a short retry-receipt grace window, and rapid repeated sends warn on stderr.
|
||||
- **Sending**: send text, mentions, quoted replies, stickers, and image/video/audio/document files with captions, MIME override, and custom display filenames. Sends keep a short retry-receipt grace window, and rapid repeated sends warn on stderr.
|
||||
- **Media**: download synced message media on demand, or download in the background during auth/sync; send-file uploads and downloads are capped at 100 MiB.
|
||||
- **Contacts/chats/groups**: search/show contacts, local aliases/tags, list/show chats, refresh/list/info/rename groups, manage participants, invite links, join, and leave; left groups are hidden after leave.
|
||||
- **Presence**: send typing/paused indicators.
|
||||
@ -131,6 +131,8 @@ pnpm wacli send file --to 1234567890 --file ./pic.jpg --caption "hi"
|
||||
pnpm wacli send file --to 1234567890 --file ./pic.jpg --caption "replying" --reply-to <message-id>
|
||||
# Or override display name
|
||||
pnpm wacli send file --to 1234567890 --file /tmp/abc123 --filename report.pdf
|
||||
# Send a 512x512 WebP sticker
|
||||
pnpm wacli send sticker --to 1234567890 --file ./sticker-512.webp
|
||||
# Send an OGG/Opus audio file as a native WhatsApp voice note
|
||||
pnpm wacli send voice --to 1234567890 --file ./voice.ogg
|
||||
|
||||
@ -171,6 +173,7 @@ Full command docs live under [docs/overview.md](docs/overview.md). Quick referen
|
||||
- `wacli messages context --chat JID --id MSG_ID [--before N] [--after N]`
|
||||
- `wacli send text --to RECIPIENT --message TEXT [--pick N] [--no-preview] [--reply-to MSG_ID] [--reply-to-sender JID] [--post-send-wait 2s]`
|
||||
- `wacli send file --to RECIPIENT --file PATH [--pick N] [--caption TEXT] [--filename NAME] [--mime TYPE] [--ptt] [--reply-to MSG_ID] [--reply-to-sender JID] [--post-send-wait 2s]`
|
||||
- `wacli send sticker --to RECIPIENT --file PATH [--pick N] [--reply-to MSG_ID] [--reply-to-sender JID] [--post-send-wait 2s]`
|
||||
- `wacli send voice --to RECIPIENT --file PATH [--pick N] [--mime TYPE] [--reply-to MSG_ID] [--reply-to-sender JID] [--post-send-wait 2s]`
|
||||
- `wacli send react --to PHONE_OR_JID --id MSG_ID [--reaction TEXT] [--sender JID] [--post-send-wait 2s]`
|
||||
- `wacli media download --chat JID --id MSG_ID [--output PATH]`
|
||||
@ -198,7 +201,7 @@ Full command docs live under [docs/overview.md](docs/overview.md). Quick referen
|
||||
- `wacli completion bash|zsh|fish|powershell [--no-descriptions]`
|
||||
- `wacli help [command]`
|
||||
|
||||
`RECIPIENT` for `send text/file` accepts a JID, phone number, or synced contact/group/chat name. If a name is ambiguous, interactive terminals prompt; scripts can pass `--pick N`.
|
||||
`RECIPIENT` for `send text/file/sticker/voice` accepts a JID, phone number, or synced contact/group/chat name. If a name is ambiguous, interactive terminals prompt; scripts can pass `--pick N`.
|
||||
|
||||
## Storage
|
||||
|
||||
|
||||
@ -27,6 +27,7 @@ func newSendCmd(flags *rootFlags) *cobra.Command {
|
||||
}
|
||||
cmd.AddCommand(newSendTextCmd(flags))
|
||||
cmd.AddCommand(newSendFileCmd(flags))
|
||||
cmd.AddCommand(newSendStickerCmd(flags))
|
||||
cmd.AddCommand(newSendVoiceCmd(flags))
|
||||
cmd.AddCommand(newSendReactCmd(flags))
|
||||
return cmd
|
||||
|
||||
@ -183,6 +183,8 @@ func executeDelegatedSend(parent context.Context, a *app.App, req sendDelegateRe
|
||||
return executeDelegatedText(ctx, a, req)
|
||||
case "file", "voice":
|
||||
return executeDelegatedFile(ctx, a, req)
|
||||
case "sticker":
|
||||
return executeDelegatedSticker(ctx, a, req)
|
||||
case "react":
|
||||
return executeDelegatedReact(ctx, a, req)
|
||||
default:
|
||||
@ -254,6 +256,31 @@ func executeDelegatedFile(ctx context.Context, a *app.App, req sendDelegateReque
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func executeDelegatedSticker(ctx context.Context, a *app.App, req sendDelegateRequest) (sendDelegateResponse, error) {
|
||||
toJID, err := resolveRecipient(a, req.To, recipientOptions{pick: req.Pick, asJSON: true})
|
||||
if err != nil {
|
||||
return sendDelegateResponse{}, err
|
||||
}
|
||||
if err := warnRapidSendIfNeeded(a.StoreDir(), time.Now().UTC(), os.Stderr); err != nil {
|
||||
return sendDelegateResponse{}, err
|
||||
}
|
||||
res, err := runSendOperation(ctx, reconnectForSend(a), func(ctx context.Context) (sendDelegateResponse, error) {
|
||||
msgID, meta, err := sendSticker(ctx, a, toJID, req.File, sendStickerOptions{
|
||||
replyTo: req.ReplyTo,
|
||||
replyToSender: req.ReplyToSender,
|
||||
})
|
||||
if err != nil {
|
||||
return sendDelegateResponse{}, err
|
||||
}
|
||||
return sendDelegateResponse{OK: true, Sent: true, To: toJID.String(), ID: msgID, File: meta}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return sendDelegateResponse{}, err
|
||||
}
|
||||
waitForPostSendRetryReceipts(ctx, millisDuration(req.PostSendWaitMS, 0))
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func executeDelegatedReact(ctx context.Context, a *app.App, req sendDelegateRequest) (sendDelegateResponse, error) {
|
||||
chat, senderJID, err := reactionTarget(req.To, req.Sender)
|
||||
if err != nil {
|
||||
@ -290,6 +317,8 @@ func writeDelegatedSendOutput(flags *rootFlags, kind string, resp sendDelegateRe
|
||||
switch kind {
|
||||
case "file":
|
||||
fmt.Fprintf(os.Stdout, "Sent %s to %s (id %s)\n", resp.File["name"], resp.To, resp.ID)
|
||||
case "sticker":
|
||||
fmt.Fprintf(os.Stdout, "Sent sticker to %s (id %s)\n", resp.To, resp.ID)
|
||||
case "voice":
|
||||
fmt.Fprintf(os.Stdout, "Sent voice note to %s (id %s)\n", resp.To, resp.ID)
|
||||
case "react":
|
||||
|
||||
217
cmd/wacli/send_sticker.go
Normal file
217
cmd/wacli/send_sticker.go
Normal file
@ -0,0 +1,217 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/steipete/wacli/internal/app"
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"github.com/steipete/wacli/internal/wa"
|
||||
"go.mau.fi/whatsmeow"
|
||||
waProto "go.mau.fi/whatsmeow/binary/proto"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
const sendStickerMIME = "image/webp"
|
||||
|
||||
const (
|
||||
stickerDimension = 512
|
||||
maxStaticStickerBytes = 100 * 1024
|
||||
maxAnimatedStickerByte = 500 * 1024
|
||||
)
|
||||
|
||||
type sendStickerOptions struct {
|
||||
replyTo string
|
||||
replyToSender string
|
||||
}
|
||||
|
||||
type webPStickerMetadata struct {
|
||||
width uint32
|
||||
height uint32
|
||||
animated bool
|
||||
}
|
||||
|
||||
func sendSticker(ctx context.Context, a interface {
|
||||
WA() app.WAClient
|
||||
DB() *store.DB
|
||||
}, to types.JID, filePath string, opts sendStickerOptions) (string, map[string]string, error) {
|
||||
data, err := readSendFileData(filePath)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
meta, err := validateWebPSticker(data)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
uploadType, err := wa.MediaTypeFromString("sticker")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
up, err := a.WA().Upload(ctx, data, uploadType)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
replyContext, err := buildReplyContextInfo(a.DB(), to, opts.replyTo, opts.replyToSender)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
msg := newStickerMessage(up, replyContext, meta)
|
||||
|
||||
id, err := a.WA().SendProtoMessage(ctx, to, msg)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
name := filepath.Base(filePath)
|
||||
chatName := a.WA().ResolveChatName(ctx, to, "")
|
||||
_ = a.DB().UpsertChat(to.String(), chatKindFromJID(to), chatName, now)
|
||||
_ = a.DB().UpsertMessage(store.UpsertMessageParams{
|
||||
ChatJID: to.String(),
|
||||
ChatName: chatName,
|
||||
MsgID: id,
|
||||
SenderJID: "",
|
||||
SenderName: "me",
|
||||
Timestamp: now,
|
||||
FromMe: true,
|
||||
MediaType: "sticker",
|
||||
Filename: name,
|
||||
MimeType: sendStickerMIME,
|
||||
DirectPath: up.DirectPath,
|
||||
MediaKey: up.MediaKey,
|
||||
FileSHA256: up.FileSHA256,
|
||||
FileEncSHA256: up.FileEncSHA256,
|
||||
FileLength: up.FileLength,
|
||||
})
|
||||
|
||||
return id, map[string]string{
|
||||
"name": name,
|
||||
"mime_type": sendStickerMIME,
|
||||
"media": "sticker",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newStickerMessage(up whatsmeow.UploadResponse, info *waProto.ContextInfo, meta webPStickerMetadata) *waProto.Message {
|
||||
return &waProto.Message{
|
||||
StickerMessage: &waProto.StickerMessage{
|
||||
URL: proto.String(up.URL),
|
||||
DirectPath: proto.String(up.DirectPath),
|
||||
MediaKey: up.MediaKey,
|
||||
FileEncSHA256: up.FileEncSHA256,
|
||||
FileSHA256: up.FileSHA256,
|
||||
FileLength: proto.Uint64(up.FileLength),
|
||||
Mimetype: proto.String(sendStickerMIME),
|
||||
Height: proto.Uint32(meta.height),
|
||||
Width: proto.Uint32(meta.width),
|
||||
IsAnimated: proto.Bool(meta.animated),
|
||||
ContextInfo: info,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func isWebPStickerData(data []byte) bool {
|
||||
_, err := parseWebPStickerMetadata(data)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func validateWebPSticker(data []byte) (webPStickerMetadata, error) {
|
||||
meta, err := parseWebPStickerMetadata(data)
|
||||
if err != nil {
|
||||
return webPStickerMetadata{}, fmt.Errorf("stickers must be valid WebP files")
|
||||
}
|
||||
if meta.width != stickerDimension || meta.height != stickerDimension {
|
||||
return webPStickerMetadata{}, fmt.Errorf("stickers must be %dx%d WebP files (got %dx%d)", stickerDimension, stickerDimension, meta.width, meta.height)
|
||||
}
|
||||
maxBytes := maxStaticStickerBytes
|
||||
kind := "static"
|
||||
if meta.animated {
|
||||
maxBytes = maxAnimatedStickerByte
|
||||
kind = "animated"
|
||||
}
|
||||
if len(data) > maxBytes {
|
||||
return webPStickerMetadata{}, fmt.Errorf("%s stickers must be at most %d KiB (got %d KiB)", kind, maxBytes/1024, (len(data)+1023)/1024)
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func parseWebPStickerMetadata(data []byte) (webPStickerMetadata, error) {
|
||||
if len(data) < 12 || !bytes.Equal(data[0:4], []byte("RIFF")) || !bytes.Equal(data[8:12], []byte("WEBP")) {
|
||||
return webPStickerMetadata{}, fmt.Errorf("missing WebP header")
|
||||
}
|
||||
for off := 12; off+8 <= len(data); {
|
||||
chunkType := string(data[off : off+4])
|
||||
chunkSize := int(binary.LittleEndian.Uint32(data[off+4 : off+8]))
|
||||
chunkStart := off + 8
|
||||
chunkEnd := chunkStart + chunkSize
|
||||
if chunkSize < 0 || chunkEnd > len(data) {
|
||||
return webPStickerMetadata{}, fmt.Errorf("invalid WebP chunk size")
|
||||
}
|
||||
chunk := data[chunkStart:chunkEnd]
|
||||
switch chunkType {
|
||||
case "VP8X":
|
||||
meta, err := parseWebPVP8X(chunk)
|
||||
if err != nil {
|
||||
return webPStickerMetadata{}, err
|
||||
}
|
||||
return meta, nil
|
||||
case "VP8L":
|
||||
meta, err := parseWebPVP8L(chunk)
|
||||
if err != nil {
|
||||
return webPStickerMetadata{}, err
|
||||
}
|
||||
return meta, nil
|
||||
case "VP8 ":
|
||||
meta, err := parseWebPVP8(chunk)
|
||||
if err != nil {
|
||||
return webPStickerMetadata{}, err
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
off = chunkEnd
|
||||
if chunkSize%2 == 1 {
|
||||
off++
|
||||
}
|
||||
}
|
||||
return webPStickerMetadata{}, fmt.Errorf("missing WebP image chunk")
|
||||
}
|
||||
|
||||
func parseWebPVP8X(chunk []byte) (webPStickerMetadata, error) {
|
||||
if len(chunk) < 10 {
|
||||
return webPStickerMetadata{}, fmt.Errorf("short VP8X chunk")
|
||||
}
|
||||
width := uint32(chunk[4]) | uint32(chunk[5])<<8 | uint32(chunk[6])<<16
|
||||
height := uint32(chunk[7]) | uint32(chunk[8])<<8 | uint32(chunk[9])<<16
|
||||
return webPStickerMetadata{
|
||||
width: width + 1,
|
||||
height: height + 1,
|
||||
animated: chunk[0]&0x02 != 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseWebPVP8L(chunk []byte) (webPStickerMetadata, error) {
|
||||
if len(chunk) < 5 || chunk[0] != 0x2f {
|
||||
return webPStickerMetadata{}, fmt.Errorf("invalid VP8L chunk")
|
||||
}
|
||||
bits := binary.LittleEndian.Uint32(chunk[1:5])
|
||||
return webPStickerMetadata{
|
||||
width: (bits & 0x3fff) + 1,
|
||||
height: ((bits >> 14) & 0x3fff) + 1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseWebPVP8(chunk []byte) (webPStickerMetadata, error) {
|
||||
if len(chunk) < 10 || !bytes.Equal(chunk[3:6], []byte{0x9d, 0x01, 0x2a}) {
|
||||
return webPStickerMetadata{}, fmt.Errorf("invalid VP8 chunk")
|
||||
}
|
||||
return webPStickerMetadata{
|
||||
width: uint32(binary.LittleEndian.Uint16(chunk[6:8]) & 0x3fff),
|
||||
height: uint32(binary.LittleEndian.Uint16(chunk[8:10]) & 0x3fff),
|
||||
}, nil
|
||||
}
|
||||
116
cmd/wacli/send_sticker_cmd.go
Normal file
116
cmd/wacli/send_sticker_cmd.go
Normal file
@ -0,0 +1,116 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
)
|
||||
|
||||
func newSendStickerCmd(flags *rootFlags) *cobra.Command {
|
||||
var to string
|
||||
var pick int
|
||||
var filePath string
|
||||
var replyTo string
|
||||
var replyToSender string
|
||||
postSendWait := postSendRetryReceiptWait
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "sticker",
|
||||
Short: "Send a sticker (WebP image)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if to == "" || filePath == "" {
|
||||
return fmt.Errorf("--to and --file are required")
|
||||
}
|
||||
if err := flags.requireWritable(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := withTimeout(context.Background(), flags)
|
||||
defer cancel()
|
||||
|
||||
a, lk, err := newApp(ctx, flags, true, false)
|
||||
if err != nil {
|
||||
delegateFile := filePath
|
||||
if abs, absErr := filepath.Abs(filePath); absErr == nil {
|
||||
delegateFile = abs
|
||||
}
|
||||
resp, delegated, delegateErr := tryDelegateSend(ctx, flags, err, sendDelegateRequest{
|
||||
Kind: "sticker",
|
||||
To: to,
|
||||
Pick: pick,
|
||||
File: delegateFile,
|
||||
ReplyTo: replyTo,
|
||||
ReplyToSender: replyToSender,
|
||||
PostSendWaitMS: durationMillis(postSendWait),
|
||||
})
|
||||
if delegated {
|
||||
if delegateErr != nil {
|
||||
return delegateErr
|
||||
}
|
||||
return writeDelegatedSendOutput(flags, "sticker", resp)
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer closeApp(a, lk)
|
||||
|
||||
if err := a.EnsureAuthed(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
toJID, err := resolveRecipient(a, to, recipientOptions{pick: pick, asJSON: flags.asJSON})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.Connect(ctx, false, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := warnRapidSendIfNeeded(a.StoreDir(), time.Now().UTC(), os.Stderr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
type sendStickerResult struct {
|
||||
id string
|
||||
meta map[string]string
|
||||
}
|
||||
res, err := runSendOperation(ctx, reconnectForSend(a), func(ctx context.Context) (sendStickerResult, error) {
|
||||
msgID, meta, err := sendSticker(ctx, a, toJID, filePath, sendStickerOptions{
|
||||
replyTo: replyTo,
|
||||
replyToSender: replyToSender,
|
||||
})
|
||||
if err != nil {
|
||||
return sendStickerResult{}, err
|
||||
}
|
||||
return sendStickerResult{id: msgID, meta: meta}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
waitForPostSendRetryReceipts(ctx, postSendWait)
|
||||
|
||||
if flags.asJSON {
|
||||
return out.WriteJSON(os.Stdout, map[string]any{
|
||||
"sent": true,
|
||||
"to": toJID.String(),
|
||||
"id": res.id,
|
||||
"file": res.meta,
|
||||
})
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "Sent sticker to %s (id %s)\n", toJID.String(), res.id)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&to, "to", "", "recipient JID, phone number, or contact/group/chat name")
|
||||
cmd.Flags().IntVar(&pick, "pick", 0, "when --to is ambiguous, pick the Nth match (1-indexed)")
|
||||
cmd.Flags().StringVar(&filePath, "file", "", "path to WebP sticker file")
|
||||
cmd.Flags().StringVar(&replyTo, "reply-to", "", "message ID to quote/reply to")
|
||||
cmd.Flags().StringVar(&replyToSender, "reply-to-sender", "", "sender JID of the quoted message (required for unsynced group replies)")
|
||||
cmd.Flags().DurationVar(&postSendWait, "post-send-wait", postSendRetryReceiptWait, "keep the connection alive after send so retry receipts can be handled (0 disables)")
|
||||
return cmd
|
||||
}
|
||||
164
cmd/wacli/send_sticker_test.go
Normal file
164
cmd/wacli/send_sticker_test.go
Normal file
@ -0,0 +1,164 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.mau.fi/whatsmeow"
|
||||
waProto "go.mau.fi/whatsmeow/binary/proto"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func TestSendCommandIncludesStickerSubcommand(t *testing.T) {
|
||||
cmd := newSendCmd(&rootFlags{})
|
||||
for _, sub := range cmd.Commands() {
|
||||
if sub.Name() == "sticker" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing send sticker subcommand")
|
||||
}
|
||||
|
||||
func TestSendStickerCommandExposesSharedSendFlags(t *testing.T) {
|
||||
cmd := newSendStickerCmd(&rootFlags{})
|
||||
for _, name := range []string{"to", "pick", "file", "reply-to", "reply-to-sender", "post-send-wait"} {
|
||||
if cmd.Flags().Lookup(name) == nil {
|
||||
t.Fatalf("missing --%s flag", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWebPStickerData(t *testing.T) {
|
||||
valid := testWebPVP8X(512, 512, false, nil)
|
||||
if !isWebPStickerData(valid) {
|
||||
t.Fatalf("valid WebP header was rejected")
|
||||
}
|
||||
for _, data := range [][]byte{
|
||||
nil,
|
||||
[]byte("RIFF\x10\x00\x00\x00PNG "),
|
||||
[]byte("not webp"),
|
||||
} {
|
||||
if isWebPStickerData(data) {
|
||||
t.Fatalf("invalid WebP header was accepted: %q", string(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWebPSticker(t *testing.T) {
|
||||
static := testWebPVP8X(512, 512, false, nil)
|
||||
meta, err := validateWebPSticker(static)
|
||||
if err != nil {
|
||||
t.Fatalf("validateWebPSticker: %v", err)
|
||||
}
|
||||
if meta.width != 512 || meta.height != 512 || meta.animated {
|
||||
t.Fatalf("metadata = %+v, want static 512x512", meta)
|
||||
}
|
||||
|
||||
animated := testWebPVP8X(512, 512, true, bytesOfSize(101*1024))
|
||||
meta, err = validateWebPSticker(animated)
|
||||
if err != nil {
|
||||
t.Fatalf("animated sticker should allow >100 KiB: %v", err)
|
||||
}
|
||||
if !meta.animated {
|
||||
t.Fatalf("animated WebP was not detected")
|
||||
}
|
||||
|
||||
for name, tc := range map[string]struct {
|
||||
data []byte
|
||||
want string
|
||||
}{
|
||||
"wrong dimensions": {testWebPVP8X(256, 512, false, nil), "512x512"},
|
||||
"static too large": {testWebPVP8X(512, 512, false, bytesOfSize(101*1024)), "static stickers"},
|
||||
"animated too large": {testWebPVP8X(512, 512, true, bytesOfSize(501*1024)), "animated stickers"},
|
||||
} {
|
||||
if _, err := validateWebPSticker(tc.data); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("%s: expected %q error, got %v", name, tc.want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendStickerRejectsNonWebPBeforeUpload(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "sticker.png")
|
||||
if err := os.WriteFile(path, []byte("not-webp"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
_, _, err := sendSticker(context.Background(), nil, types.JID{}, path, sendStickerOptions{})
|
||||
if err == nil || !strings.Contains(err.Error(), "stickers must be valid WebP") {
|
||||
t.Fatalf("expected WebP validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewStickerMessageAttachesUploadFieldsAndReply(t *testing.T) {
|
||||
up := whatsmeow.UploadResponse{
|
||||
URL: "https://upload",
|
||||
DirectPath: "/direct",
|
||||
MediaKey: []byte("key"),
|
||||
FileEncSHA256: []byte("enc"),
|
||||
FileSHA256: []byte("plain"),
|
||||
FileLength: 123,
|
||||
}
|
||||
meta := webPStickerMetadata{width: 512, height: 512, animated: true}
|
||||
info := &waProto.ContextInfo{
|
||||
StanzaID: proto.String("quoted"),
|
||||
Participant: proto.String("15551234567@s.whatsapp.net"),
|
||||
}
|
||||
|
||||
msg := newStickerMessage(up, info, meta)
|
||||
sticker := msg.GetStickerMessage()
|
||||
if sticker == nil {
|
||||
t.Fatalf("missing sticker message")
|
||||
}
|
||||
if sticker.GetMimetype() != sendStickerMIME {
|
||||
t.Fatalf("mime = %q, want %q", sticker.GetMimetype(), sendStickerMIME)
|
||||
}
|
||||
if sticker.GetURL() != up.URL || sticker.GetDirectPath() != up.DirectPath || sticker.GetFileLength() != up.FileLength {
|
||||
t.Fatalf("upload fields were not attached")
|
||||
}
|
||||
if string(sticker.GetMediaKey()) != string(up.MediaKey) ||
|
||||
string(sticker.GetFileSHA256()) != string(up.FileSHA256) ||
|
||||
string(sticker.GetFileEncSHA256()) != string(up.FileEncSHA256) {
|
||||
t.Fatalf("upload hashes were not attached")
|
||||
}
|
||||
if sticker.GetWidth() != meta.width || sticker.GetHeight() != meta.height || !sticker.GetIsAnimated() {
|
||||
t.Fatalf("sticker metadata was not attached")
|
||||
}
|
||||
if sticker.GetContextInfo() != info {
|
||||
t.Fatalf("reply context was not attached")
|
||||
}
|
||||
}
|
||||
|
||||
func testWebPVP8X(width, height uint32, animated bool, extra []byte) []byte {
|
||||
chunk := make([]byte, 10)
|
||||
if animated {
|
||||
chunk[0] = 0x02
|
||||
}
|
||||
putUint24(chunk[4:7], width-1)
|
||||
putUint24(chunk[7:10], height-1)
|
||||
data := make([]byte, 0, 12+8+len(chunk)+len(extra))
|
||||
data = append(data, []byte("RIFF")...)
|
||||
data = binary.LittleEndian.AppendUint32(data, uint32(4+8+len(chunk)+len(extra)))
|
||||
data = append(data, []byte("WEBPVP8X")...)
|
||||
data = binary.LittleEndian.AppendUint32(data, uint32(len(chunk)))
|
||||
data = append(data, chunk...)
|
||||
data = append(data, extra...)
|
||||
return data
|
||||
}
|
||||
|
||||
func putUint24(dst []byte, v uint32) {
|
||||
dst[0] = byte(v)
|
||||
dst[1] = byte(v >> 8)
|
||||
dst[2] = byte(v >> 16)
|
||||
}
|
||||
|
||||
func bytesOfSize(n int) []byte {
|
||||
if n <= 0 {
|
||||
return nil
|
||||
}
|
||||
return make([]byte, n)
|
||||
}
|
||||
@ -22,7 +22,7 @@ Read when: you need the user-facing command map, global flags, store model, or l
|
||||
- [auth](auth.md) - pair, inspect auth status, logout.
|
||||
- [sync](sync.md) - sync messages, contacts, groups, and optional media.
|
||||
- [messages](messages.md) - list, search, show, and contextualize stored messages.
|
||||
- [send](send.md) - send text, files, replies, and reactions.
|
||||
- [send](send.md) - send text, files, stickers, replies, and reactions.
|
||||
- [media](media.md) - download media attached to stored messages.
|
||||
- [contacts](contacts.md) - search contacts and manage local aliases/tags.
|
||||
- [chats](chats.md) - list and show known chats.
|
||||
@ -48,7 +48,7 @@ wacli send text --to mom --message "hello"
|
||||
|
||||
Commands that accept `PHONE_OR_JID` accept a WhatsApp JID like `1234567890@s.whatsapp.net`, a group JID like `123456789@g.us`, or a phone number with common formatting such as `+1 (234) 567-8900`.
|
||||
|
||||
`send text`, `send file`, and `send voice` also accept synced contact, group, or chat names through `RECIPIENT`. If a name is ambiguous, interactive terminals prompt; scripts can use `--pick N`.
|
||||
`send text`, `send file`, `send sticker`, and `send voice` also accept synced contact, group, or chat names through `RECIPIENT`. If a name is ambiguous, interactive terminals prompt; scripts can use `--pick N`.
|
||||
|
||||
## History limits
|
||||
|
||||
|
||||
@ -77,6 +77,9 @@ wacli send text --to 1234567890 --message "replying" --reply-to <message-id>
|
||||
# Send a file with a caption
|
||||
wacli send file --to 1234567890 --file ./pic.jpg --caption "hi"
|
||||
|
||||
# Send a 512x512 WebP sticker
|
||||
wacli send sticker --to 1234567890 --file ./sticker-512.webp
|
||||
|
||||
# Send a native voice note (OGG/Opus)
|
||||
wacli send voice --to 1234567890 --file ./voice.ogg
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# send
|
||||
|
||||
Read when: sending text, files, quoted replies, or reactions.
|
||||
Read when: sending text, files, stickers, quoted replies, or reactions.
|
||||
|
||||
`wacli send` requires authentication, a live connection, and writable mode. Send attempts are bounded and retry once after reconnect for known stale-session/usync timeout failures. After a successful send, wacli keeps the connection alive briefly so whatsmeow can handle retry receipts from devices that could not decrypt the first copy. Repeated send commands within 5 seconds print a stderr warning so tight loops make WhatsApp rate-limit/account-risk visible.
|
||||
|
||||
@ -11,13 +11,14 @@ When `sync --follow` is already running for the same store, send commands delega
|
||||
```bash
|
||||
wacli send text --to RECIPIENT --message TEXT [--pick N] [--mention USER] [--no-preview] [--reply-to MSG_ID] [--reply-to-sender JID] [--post-send-wait 2s]
|
||||
wacli send file --to RECIPIENT --file PATH [--pick N] [--caption TEXT] [--filename NAME] [--mime TYPE] [--ptt] [--reply-to MSG_ID] [--reply-to-sender JID] [--post-send-wait 2s]
|
||||
wacli send sticker --to RECIPIENT --file PATH [--pick N] [--reply-to MSG_ID] [--reply-to-sender JID] [--post-send-wait 2s]
|
||||
wacli send voice --to RECIPIENT --file PATH [--pick N] [--mime TYPE] [--reply-to MSG_ID] [--reply-to-sender JID] [--post-send-wait 2s]
|
||||
wacli send react --to PHONE_OR_JID --id MSG_ID [--reaction TEXT] [--sender JID] [--post-send-wait 2s]
|
||||
```
|
||||
|
||||
## Recipients
|
||||
|
||||
- `send text` and `send file` accept a JID, phone number, or synced contact/group/chat name.
|
||||
- `send text`, `send file`, `send sticker`, and `send voice` accept a JID, phone number, or synced contact/group/chat name.
|
||||
- If a name matches multiple recipients, interactive terminals prompt.
|
||||
- In scripts, use `--pick N` to choose a displayed match.
|
||||
- Phone numbers may use common formatting such as `+1 (234) 567-8900`.
|
||||
@ -42,6 +43,7 @@ wacli send react --to PHONE_OR_JID --id MSG_ID [--reaction TEXT] [--sender JID]
|
||||
- MIME type is detected automatically unless `--mime` is set.
|
||||
- `--filename` changes the displayed document name.
|
||||
- Captions apply to images, videos, and documents.
|
||||
- `send sticker` requires 512x512 WebP input. Static stickers are capped at 100 KiB; animated stickers are capped at 500 KiB and are sent with animation metadata.
|
||||
- `send voice` is a shortcut for `send file --ptt`.
|
||||
- Voice notes require OGG/Opus audio (`audio/ogg; codecs=opus`).
|
||||
- When available, `ffprobe` sets voice-note duration and `ffmpeg` generates the 64-sample waveform from decoded PCM audio.
|
||||
@ -55,6 +57,7 @@ wacli send text --to "Family" --message "hey @15551234567" --mention +1555123456
|
||||
wacli send text --to 1234567890 --message "replying" --reply-to ABC123
|
||||
wacli send file --to 1234567890 --file ./pic.jpg --caption "hi"
|
||||
wacli send file --to 1234567890 --file /tmp/report --filename report.pdf
|
||||
wacli send sticker --to 1234567890 --file ./sticker-512.webp
|
||||
wacli send voice --to 1234567890 --file ./voice.ogg
|
||||
wacli send react --to 1234567890 --id ABC123 --reaction "❤️"
|
||||
```
|
||||
|
||||
@ -182,12 +182,14 @@ WhatsApp Web history is best-effort. If you want to try fetching *older* message
|
||||
|
||||
- `wacli send text --to RECIPIENT --message TEXT [--pick N] [--no-preview] [--reply-to MSG_ID] [--reply-to-sender JID]`
|
||||
- `wacli send file --to RECIPIENT --file PATH [--caption TEXT] [--mime TYPE] [--pick N] [--ptt] [--reply-to MSG_ID] [--reply-to-sender JID]`
|
||||
- `wacli send sticker --to RECIPIENT --file PATH [--pick N] [--reply-to MSG_ID] [--reply-to-sender JID]`
|
||||
- `wacli send voice --to RECIPIENT --file PATH [--mime TYPE] [--pick N] [--reply-to MSG_ID] [--reply-to-sender JID]`
|
||||
- `wacli send react --to PHONE_OR_JID --id MSG_ID [--reaction TEXT] [--sender JID]`
|
||||
|
||||
`RECIPIENT` accepts a JID, phone number, or synced contact/group/chat name. If a name is ambiguous, interactive terminals prompt; scripts can pass `--pick N`.
|
||||
Text sends automatically include a link preview for the first `http://` or `https://` URL unless `--no-preview` is passed.
|
||||
Voice notes require OGG/Opus audio and use optional `ffprobe`/`ffmpeg` metadata when available.
|
||||
Stickers require 512x512 WebP input and are stored locally as `sticker` media after sending. Static stickers are capped at 100 KiB; animated stickers are capped at 500 KiB and carry animation metadata in the outgoing proto.
|
||||
|
||||
Send-file uploads and media downloads are capped at 100 MiB to avoid reading
|
||||
or writing unexpectedly large payloads in one command.
|
||||
|
||||
@ -23,7 +23,7 @@ wacli sync [--once] [--follow] [--idle-exit 30s] [--max-reconnect 5m] [--max-mes
|
||||
- `--refresh-groups` fetches joined groups live and updates the local DB.
|
||||
- If neither storage cap is configured, sync prints one warning because WhatsApp history can grow the local database substantially.
|
||||
- `WACLI_SYNC_MAX_MESSAGES` and `WACLI_SYNC_MAX_DB_SIZE` apply the same caps to `auth` bootstrap sync and `sync`.
|
||||
- While `sync --follow` is running, `send text`, `send file`, `send voice`, and `send react` commands for the same store are delegated to the running sync process so they do not fail on the store lock.
|
||||
- While `sync --follow` is running, `send text`, `send file`, `send sticker`, `send voice`, and `send react` commands for the same store are delegated to the running sync process so they do not fail on the store lock.
|
||||
- If whatsmeow reports an app-state LTHash mismatch, sync asks the primary device for the official recovery snapshot once for that app-state collection. If recovery also fails, the warning is printed and sync keeps handling normal message/history events.
|
||||
- `--events` emits one NDJSON lifecycle event per stderr line for machine consumers. Routine human progress/status lines, interrupt prompts, and command errors are emitted as events while events are enabled.
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ import (
|
||||
)
|
||||
|
||||
func TestMediaTypeFromString(t *testing.T) {
|
||||
for _, tc := range []string{"image", "video", "audio", "document"} {
|
||||
for _, tc := range []string{"image", "video", "audio", "document", "sticker"} {
|
||||
if _, err := MediaTypeFromString(tc); err != nil {
|
||||
t.Fatalf("expected %s to be supported: %v", tc, err)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user