From 09b2efbcaaffe24e2c7cc8c39168baa45d0de126 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 5 May 2026 05:11:27 +0100 Subject: [PATCH] feat: add send text mentions --- CHANGELOG.md | 1 + README.md | 6 ++-- cmd/wacli/send.go | 56 +++++++++++++++++++++++++++--- cmd/wacli/send_test.go | 79 ++++++++++++++++++++++++++++++++++++++++-- docs/send.md | 4 ++- 5 files changed, 136 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41aebc6..038b64c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - 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 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 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) diff --git a/README.md b/README.md index 75e1995..8d6516a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ WhatsApp CLI built on top of `whatsmeow`, focused on: - Best-effort local sync of message history + continuous capture - Fast offline search -- Sending text, quoted replies, and files +- Sending text, mentions, quoted replies, and files - Contact + group management - Scriptable JSON output @@ -39,7 +39,7 @@ Core implementation is in place. Start with [docs/overview.md](docs/overview.md) - **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, 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, 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. @@ -111,6 +111,8 @@ pnpm wacli media download --chat 1234567890@s.whatsapp.net --id pnpm wacli send text --to 1234567890 --message "hello" # Link previews are added automatically for the first http(s) URL; use --no-preview to skip. pnpm wacli send text --to 1234567890 --message "https://example.com" --no-preview +# Mention one or more users in a group text. +pnpm wacli send text --to "Family" --message "hey @15551234567" --mention +15551234567 # 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" diff --git a/cmd/wacli/send.go b/cmd/wacli/send.go index 6f2cd9a..8e6a936 100644 --- a/cmd/wacli/send.go +++ b/cmd/wacli/send.go @@ -36,6 +36,7 @@ func newSendTextCmd(flags *rootFlags) *cobra.Command { var to string var pick int var message string + var mentions []string var replyTo string var replyToSender string var noPreview bool @@ -69,6 +70,10 @@ func newSendTextCmd(flags *rootFlags) *cobra.Command { if err != nil { return err } + mentionedJIDs, err := parseMentionedJIDs(mentions) + if err != nil { + return err + } if err := a.Connect(ctx, false, nil); err != nil { return err } @@ -78,7 +83,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, preview, mentionedJIDs) }) if err != nil { return err @@ -117,6 +122,7 @@ func newSendTextCmd(flags *rootFlags) *cobra.Command { 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(&message, "message", "", "message text") + cmd.Flags().StringArrayVar(&mentions, "mention", nil, "phone number or user JID to mention (repeatable)") 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().BoolVar(&noPreview, "no-preview", false, "disable automatic link previews for the first URL in text") @@ -129,8 +135,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, preview *linkpreview.Preview, mentionedJIDs []string) (types.MessageID, error) { + msg, plainText, err := buildTextMessage(a.DB(), to, text, replyTo, replyToSender, preview, mentionedJIDs) if err != nil { return "", err } @@ -157,8 +163,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, preview *linkpreview.Preview, mentionedJIDs []string) (*waProto.Message, bool, error) { + info, err := buildTextContextInfo(db, to, replyTo, replyToSender, mentionedJIDs) if err != nil { return nil, false, err } @@ -195,6 +201,21 @@ 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, mentionedJIDs []string) (*waProto.ContextInfo, error) { + info, err := buildReplyContextInfo(db, chat, replyTo, replyToSender) + if err != nil { + return nil, err + } + if len(mentionedJIDs) == 0 { + return info, nil + } + if info == nil { + info = &waProto.ContextInfo{} + } + info.MentionedJID = append([]string(nil), mentionedJIDs...) + return info, nil +} + func buildReplyContextInfo(db *store.DB, chat types.JID, replyTo, replyToSender string) (*waProto.ContextInfo, error) { replyTo = strings.TrimSpace(replyTo) if replyTo == "" { @@ -241,3 +262,28 @@ func resolveReplySender(db *store.DB, chat types.JID, replyTo, override string) } return types.JID{}, nil } + +func parseMentionedJIDs(values []string) ([]string, error) { + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + jid, err := wa.ParseUserOrJID(value) + if err != nil { + return nil, fmt.Errorf("invalid --mention: %w", err) + } + if jid.Server == types.GroupServer { + return nil, fmt.Errorf("invalid --mention %q: mentions must target a user phone number or user JID", value) + } + normalized := jid.String() + if _, ok := seen[normalized]; ok { + continue + } + seen[normalized] = struct{}{} + out = append(out, normalized) + } + return out, nil +} diff --git a/cmd/wacli/send_test.go b/cmd/wacli/send_test.go index f7052f7..6208768 100644 --- a/cmd/wacli/send_test.go +++ b/cmd/wacli/send_test.go @@ -212,6 +212,29 @@ func TestBuildReplyContextInfo(t *testing.T) { } } +func TestParseMentionedJIDs(t *testing.T) { + got, err := parseMentionedJIDs([]string{ + " +1 (555) 123-4567 ", + "15551234567@s.whatsapp.net", + "15557654321@s.whatsapp.net", + "", + }) + if err != nil { + t.Fatalf("parseMentionedJIDs: %v", err) + } + want := []string{"15551234567@s.whatsapp.net", "15557654321@s.whatsapp.net"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("mentions = %v, want %v", got, want) + } +} + +func TestParseMentionedJIDsRejectsGroupJID(t *testing.T) { + _, err := parseMentionedJIDs([]string{"12345@g.us"}) + if err == nil || !strings.Contains(err.Error(), "mentions must target a user") { + t.Fatalf("expected group mention rejection, got %v", err) + } +} + func TestSendTextCommandExposesNoPreviewFlag(t *testing.T) { cmd := newSendTextCmd(&rootFlags{}) if cmd.Flags().Lookup("no-preview") == nil { @@ -219,11 +242,18 @@ func TestSendTextCommandExposesNoPreviewFlag(t *testing.T) { } } +func TestSendTextCommandExposesMentionFlag(t *testing.T) { + cmd := newSendTextCmd(&rootFlags{}) + if cmd.Flags().Lookup("mention") == nil { + t.Fatalf("missing --mention flag") + } +} + 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 +265,51 @@ func TestBuildTextMessageUsesPlainConversationWithoutReplyOrPreview(t *testing.T } } +func TestBuildTextMessageAttachesMentions(t *testing.T) { + db := openSendTestDB(t) + chat := types.JID{User: "12345", Server: types.GroupServer} + mentions := []string{"15551234567@s.whatsapp.net", "15557654321@s.whatsapp.net"} + + msg, plain, err := buildTextMessage(db, chat, "hey @15551234567", "", "", nil, mentions) + if err != nil { + t.Fatalf("buildTextMessage: %v", err) + } + if plain { + t.Fatalf("plain = true, want false") + } + ext := msg.GetExtendedTextMessage() + if ext.GetText() != "hey @15551234567" { + t.Fatalf("text = %q", ext.GetText()) + } + got := ext.GetContextInfo().GetMentionedJID() + if strings.Join(got, ",") != strings.Join(mentions, ",") { + t.Fatalf("mentioned JIDs = %v, want %v", got, mentions) + } +} + +func TestBuildTextMessageCombinesReplyAndMentions(t *testing.T) { + db := openSendTestDB(t) + chat := types.JID{User: "12345", Server: types.GroupServer} + + msg, plain, err := buildTextMessage(db, chat, "replying @15551234567", "quoted", "+15557654321", nil, []string{"15551234567@s.whatsapp.net"}) + 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()) + } + if got := info.GetMentionedJID(); strings.Join(got, ",") != "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 +320,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", "", "", preview, nil) if err != nil { t.Fatalf("buildTextMessage: %v", err) } diff --git a/docs/send.md b/docs/send.md index b5cbc5e..d481a88 100644 --- a/docs/send.md +++ b/docs/send.md @@ -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. +- Use repeatable `--mention USER` with a phone number or user JID to add WhatsApp mentions to `send text`. - `--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 "hey @15551234567" --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