Compare commits

...

1 Commits

Author SHA1 Message Date
Dinakar Sarbada
602ea1cfe4 feat: add send text mentions 2026-05-04 20:07:39 -07:00
6 changed files with 139 additions and 13 deletions

View File

@ -17,6 +17,7 @@
- Auth: auto-detect a readable linked-device label and default linked-device platform to desktop. (#100 — thanks @pmatheus)
- Profile: add `profile set-picture` to update the authenticated account profile picture from JPEG or PNG input. (#198 — thanks @gado-ships-it)
- Send: add `send react` to add or clear reactions, with group sender validation. (#151 — thanks @draix)
- Send: add `send text --mention` for WhatsApp user mentions in group messages. (#16)
- Send: add `send file --reply-to` for quoted media/document replies. (#68 — thanks @vlassance)
- Send: add automatic link previews for text messages with `--no-preview` opt-out. (#94, #95 — thanks @elgatoflaco)
- Send: add `send voice` and `send file --ptt` for OGG/Opus WhatsApp voice notes. (#40, #41 — thanks @ricardopolo and @emre6943)

View File

@ -114,6 +114,8 @@ pnpm wacli send text --to 1234567890 --message "https://example.com" --no-previe
# Phone numbers can also be passed as +E164 or formatted input like "+1 (234) 567-8900"
pnpm wacli send text --to mom --message "hello"
pnpm wacli send text --to "Family" --pick 2 --message "hello"
# Mention one or more users in a group text
pnpm wacli send text --to "Family" --message "@alice can you check this?" --mention 15551234567
# Send a quoted reply
pnpm wacli send text --to 1234567890 --message "replying" --reply-to <message-id>
@ -160,7 +162,7 @@ Full command docs live under [docs/overview.md](docs/overview.md). Quick referen
- `wacli messages search <query> [--chat JID] [--from JID] [--has-media] [--type text|image|video|audio|document] [--forwarded]`
- `wacli messages show --chat JID --id MSG_ID`
- `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 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 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]`

View File

@ -38,6 +38,7 @@ func newSendTextCmd(flags *rootFlags) *cobra.Command {
var message string
var replyTo string
var replyToSender string
var mentions []string
var noPreview bool
postSendWait := postSendRetryReceiptWait
@ -78,7 +79,7 @@ func newSendTextCmd(flags *rootFlags) *cobra.Command {
preview := fetchLinkPreview(ctx, message, noPreview)
msgID, err := runSendOperation(ctx, reconnectForSend(a), func(ctx context.Context) (types.MessageID, error) {
return sendTextMessage(ctx, a, toJID, message, replyTo, replyToSender, preview)
return sendTextMessage(ctx, a, toJID, message, replyTo, replyToSender, mentions, preview)
})
if err != nil {
return err
@ -119,6 +120,7 @@ func newSendTextCmd(flags *rootFlags) *cobra.Command {
cmd.Flags().StringVar(&message, "message", "", "message text")
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().StringArrayVar(&mentions, "mention", nil, "user JID or phone number to mention (repeatable)")
cmd.Flags().BoolVar(&noPreview, "no-preview", false, "disable automatic link previews for the first URL in text")
cmd.Flags().DurationVar(&postSendWait, "post-send-wait", postSendRetryReceiptWait, "keep the connection alive after send so retry receipts can be handled (0 disables)")
return cmd
@ -129,8 +131,8 @@ type sendTextApp interface {
DB() *store.DB
}
func sendTextMessage(ctx context.Context, a sendTextApp, to types.JID, text, replyTo, replyToSender string, preview *linkpreview.Preview) (types.MessageID, error) {
msg, plainText, err := buildTextMessage(a.DB(), to, text, replyTo, replyToSender, preview)
func sendTextMessage(ctx context.Context, a sendTextApp, to types.JID, text, replyTo, replyToSender string, mentions []string, preview *linkpreview.Preview) (types.MessageID, error) {
msg, plainText, err := buildTextMessage(a.DB(), to, text, replyTo, replyToSender, mentions, preview)
if err != nil {
return "", err
}
@ -157,8 +159,8 @@ func fetchLinkPreview(ctx context.Context, text string, disabled bool) *linkprev
return preview
}
func buildTextMessage(db *store.DB, to types.JID, text, replyTo, replyToSender string, preview *linkpreview.Preview) (*waProto.Message, bool, error) {
info, err := buildReplyContextInfo(db, to, replyTo, replyToSender)
func buildTextMessage(db *store.DB, to types.JID, text, replyTo, replyToSender string, mentions []string, preview *linkpreview.Preview) (*waProto.Message, bool, error) {
info, err := buildTextContextInfo(db, to, replyTo, replyToSender, mentions)
if err != nil {
return nil, false, err
}
@ -195,6 +197,51 @@ func attachLinkPreview(msg *waProto.ExtendedTextMessage, preview *linkpreview.Pr
msg.PreviewType = waProto.ExtendedTextMessage_NONE.Enum()
}
func buildTextContextInfo(db *store.DB, chat types.JID, replyTo, replyToSender string, mentions []string) (*waProto.ContextInfo, error) {
info, err := buildReplyContextInfo(db, chat, replyTo, replyToSender)
if err != nil {
return nil, err
}
mentioned, err := normalizeMentionJIDs(mentions)
if err != nil {
return nil, err
}
if len(mentioned) == 0 {
return info, nil
}
if chat.Server != types.GroupServer {
return nil, fmt.Errorf("--mention is only supported for group text messages")
}
if info == nil {
info = &waProto.ContextInfo{}
}
info.MentionedJID = mentioned
return info, nil
}
func normalizeMentionJIDs(raw []string) ([]string, error) {
seen := map[string]struct{}{}
out := make([]string, 0, len(raw))
for _, input := range raw {
jid, err := wa.ParseUserOrJID(input)
if err != nil {
return nil, fmt.Errorf("invalid --mention %q: %w", input, err)
}
jid = jid.ToNonAD()
if jid.Server != types.DefaultUserServer && jid.Server != types.HiddenUserServer {
return nil, fmt.Errorf("invalid --mention %q: must be a user JID or phone number", input)
}
normalized := jid.String()
if _, ok := seen[normalized]; ok {
continue
}
seen[normalized] = struct{}{}
out = append(out, normalized)
}
return out, nil
}
func buildReplyContextInfo(db *store.DB, chat types.JID, replyTo, replyToSender string) (*waProto.ContextInfo, error) {
replyTo = strings.TrimSpace(replyTo)
if replyTo == "" {

View File

@ -212,10 +212,12 @@ func TestBuildReplyContextInfo(t *testing.T) {
}
}
func TestSendTextCommandExposesNoPreviewFlag(t *testing.T) {
func TestSendTextCommandExposesTextFlags(t *testing.T) {
cmd := newSendTextCmd(&rootFlags{})
if cmd.Flags().Lookup("no-preview") == nil {
t.Fatalf("missing --no-preview flag")
for _, name := range []string{"no-preview", "mention"} {
if cmd.Flags().Lookup(name) == nil {
t.Fatalf("missing --%s flag", name)
}
}
}
@ -223,7 +225,7 @@ func TestBuildTextMessageUsesPlainConversationWithoutReplyOrPreview(t *testing.T
db := openSendTestDB(t)
chat := types.JID{User: "15551234567", Server: types.DefaultUserServer}
msg, plain, err := buildTextMessage(db, chat, "hello", "", "", nil)
msg, plain, err := buildTextMessage(db, chat, "hello", "", "", nil, nil)
if err != nil {
t.Fatalf("buildTextMessage: %v", err)
}
@ -235,6 +237,77 @@ func TestBuildTextMessageUsesPlainConversationWithoutReplyOrPreview(t *testing.T
}
}
func TestBuildTextMessageAttachesMentions(t *testing.T) {
db := openSendTestDB(t)
chat := types.JID{User: "12345", Server: types.GroupServer}
msg, plain, err := buildTextMessage(db, chat, "@alice @bob", "", "", []string{
"+1 (555) 123-4567",
"999123456@lid",
"15551234567@s.whatsapp.net",
}, nil)
if err != nil {
t.Fatalf("buildTextMessage: %v", err)
}
if plain {
t.Fatalf("plain = true, want false")
}
got := msg.GetExtendedTextMessage().GetContextInfo().GetMentionedJID()
want := []string{"15551234567@s.whatsapp.net", "999123456@lid"}
if len(got) != len(want) {
t.Fatalf("mentioned JIDs = %#v, want %#v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("mentioned JIDs = %#v, want %#v", got, want)
}
}
}
func TestBuildTextMessageRejectsGroupMention(t *testing.T) {
db := openSendTestDB(t)
chat := types.JID{User: "12345", Server: types.GroupServer}
_, _, err := buildTextMessage(db, chat, "hello", "", "", []string{"12345@g.us"}, nil)
if err == nil || !strings.Contains(err.Error(), "must be a user JID or phone number") {
t.Fatalf("expected group mention rejection, got %v", err)
}
}
func TestBuildTextMessageRejectsMentionsOutsideGroups(t *testing.T) {
db := openSendTestDB(t)
chat := types.JID{User: "15551234567", Server: types.DefaultUserServer}
_, _, err := buildTextMessage(db, chat, "hello", "", "", []string{"+15557654321"}, nil)
if err == nil || !strings.Contains(err.Error(), "only supported for group text messages") {
t.Fatalf("expected direct-chat mention rejection, got %v", err)
}
}
func TestBuildTextMessageCombinesReplyAndMentions(t *testing.T) {
db := openSendTestDB(t)
chat := types.JID{User: "12345", Server: types.GroupServer}
msg, plain, err := buildTextMessage(db, chat, "@alice replying", "quoted", "+15557654321", []string{"+15551234567"}, nil)
if err != nil {
t.Fatalf("buildTextMessage: %v", err)
}
if plain {
t.Fatalf("plain = true, want false")
}
info := msg.GetExtendedTextMessage().GetContextInfo()
if info.GetStanzaID() != "quoted" {
t.Fatalf("stanza ID = %q, want quoted", info.GetStanzaID())
}
if info.GetParticipant() != "15557654321@s.whatsapp.net" {
t.Fatalf("participant = %q", info.GetParticipant())
}
got := info.GetMentionedJID()
if len(got) != 1 || got[0] != "15551234567@s.whatsapp.net" {
t.Fatalf("mentioned JIDs = %#v", got)
}
}
func TestBuildTextMessageAttachesLinkPreview(t *testing.T) {
db := openSendTestDB(t)
chat := types.JID{User: "15551234567", Server: types.DefaultUserServer}
@ -245,7 +318,7 @@ func TestBuildTextMessageAttachesLinkPreview(t *testing.T) {
Thumbnail: []byte("jpeg"),
}
msg, plain, err := buildTextMessage(db, chat, "see https://example.com/post", "", "", preview)
msg, plain, err := buildTextMessage(db, chat, "see https://example.com/post", "", "", nil, preview)
if err != nil {
t.Fatalf("buildTextMessage: %v", err)
}

View File

@ -7,7 +7,7 @@ Read when: sending text, files, quoted replies, or reactions.
## Commands
```bash
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 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 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]
@ -25,6 +25,7 @@ wacli send react --to PHONE_OR_JID --id MSG_ID [--reaction TEXT] [--sender JID]
- `send text` fetches Open Graph metadata for the first `http://` or `https://` URL and sends it as a WhatsApp link preview.
- Preview metadata fetches time out after 10 seconds and fall back to plain text.
- Pass `--no-preview` to disable link-preview fetching.
- Pass repeatable `--mention` values to mention user JIDs or phone numbers in group text messages.
- `--reply-to` quotes a stored message ID.
- For unsynced group replies, pass `--reply-to-sender`.
- `send react` defaults to thumbs-up.
@ -47,6 +48,7 @@ wacli send react --to PHONE_OR_JID --id MSG_ID [--reaction TEXT] [--sender JID]
```bash
wacli send text --to mom --message "landed"
wacli send text --to "Family" --pick 2 --message "on my way"
wacli send text --to "Family" --message "@alice can you check this?" --mention 15551234567
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

View File

@ -178,13 +178,14 @@ WhatsApp Web history is best-effort. If you want to try fetching *older* message
### Send
- `wacli send text --to RECIPIENT --message TEXT [--pick N] [--no-preview] [--reply-to MSG_ID] [--reply-to-sender JID]`
- `wacli send text --to RECIPIENT --message TEXT [--pick N] [--mention USER]... [--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 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.
Text sends can mention users with repeatable `--mention` values. Each value may be a user JID or phone number.
Voice notes require OGG/Opus audio and use optional `ffprobe`/`ffmpeg` metadata when available.
Send-file uploads and media downloads are capped at 100 MiB to avoid reading