Compare commits
1 Commits
main
...
codex/send
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
602ea1cfe4 |
@ -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)
|
||||
|
||||
@ -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]`
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user