feat: add send text mentions
This commit is contained in:
parent
d0752dbc2c
commit
09b2efbcaa
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user