feat: add send text mentions

This commit is contained in:
Peter Steinberger 2026-05-05 05:11:27 +01:00
parent d0752dbc2c
commit 09b2efbcaa
No known key found for this signature in database
5 changed files with 136 additions and 10 deletions

View File

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

View File

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

View File

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

View File

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

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