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:
Dinakar Sarbada 2026-05-05 01:54:31 -07:00 committed by GitHub
parent cd311e86c4
commit f1cb39fe8a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 548 additions and 9 deletions

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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
View 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
}

View 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
}

View 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)
}

View File

@ -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

View File

@ -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

View File

@ -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 "❤️"
```

View File

@ -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.

View File

@ -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.

View File

@ -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)
}