feat: improve auth identity and recipient parsing

Co-authored-by: Paulo <pmatheus.nsx@gmail.com>
Co-authored-by: Fahmid Uddin <fahmid.me@gmail.com>
Co-authored-by: ImLukeF <92253590+ImLukeF@users.noreply.github.com>
This commit is contained in:
Peter Steinberger 2026-05-04 08:21:09 +01:00
parent 6fac72ee4d
commit 1e8342fbe7
No known key found for this signature in database
11 changed files with 128 additions and 25 deletions

View File

@ -14,8 +14,10 @@
- Messages: extract searchable/display text from WhatsApp Business templates, buttons, interactive messages, and list replies. (#79 — thanks @terry-li-hm)
- Auth: add `auth --qr-format text` to print the raw WhatsApp QR payload for external renderers. (#22 — thanks @teren-papercutlabs)
- Auth: add `auth --phone` for WhatsApp's phone-number pairing flow on headless systems. (#148, #184 — thanks @giovanninibarbosa and @KillerSnails)
- Auth: auto-detect a readable linked-device label and default linked-device platform to desktop. (#100 — thanks @pmatheus)
- 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: accept common phone-number formatting in recipient flags while still storing digits-only WhatsApp JIDs. (#130 — thanks @fahmidme and @ImLukeF)
### Security

View File

@ -88,6 +88,7 @@ pnpm wacli media download --chat 1234567890@s.whatsapp.net --id <message-id>
# Send a message
pnpm wacli send text --to 1234567890 --message "hello"
# Phone numbers can also be passed as +E164 or formatted input like "+1 (234) 567-8900"
# Send a quoted reply
pnpm wacli send text --to 1234567890 --message "replying" --reply-to <message-id>
@ -169,8 +170,8 @@ Global flags:
## Environment overrides
- `WACLI_DEVICE_LABEL`: set the linked device label (shown in WhatsApp).
- `WACLI_DEVICE_PLATFORM`: override the linked device platform (defaults to `CHROME` if unset or invalid).
- `WACLI_DEVICE_LABEL`: override the linked device label shown in WhatsApp (defaults to `wacli - <OS> (<hostname>)` when detectable).
- `WACLI_DEVICE_PLATFORM`: override the linked device platform (defaults to `DESKTOP`; invalid values fall back to `CHROME`).
- `WACLI_READONLY`: set to `1`, `true`, `yes`, or `on` to enable read-only mode.
- `WACLI_STORE_DIR`: override the default store directory.

View File

@ -82,6 +82,6 @@ func newGroupsParticipantsActionCmd(flags *rootFlags, action string) *cobra.Comm
},
}
cmd.Flags().StringVar(&group, "jid", "", "group JID (…@g.us)")
cmd.Flags().StringSliceVar(&users, "user", nil, "user phone number or JID (repeatable)")
cmd.Flags().StringSliceVar(&users, "user", nil, "user phone number (+E164 and formatting ok) or JID (repeatable)")
return cmd
}

View File

@ -2,6 +2,7 @@ package main
import (
"os"
"runtime"
"strings"
"go.mau.fi/whatsmeow/proto/waCompanionReg"
@ -19,13 +20,18 @@ func main() {
func applyDeviceLabel() {
label := strings.TrimSpace(os.Getenv("WACLI_DEVICE_LABEL"))
platformRaw := strings.TrimSpace(os.Getenv("WACLI_DEVICE_PLATFORM"))
if platformRaw != "" {
platform := parsePlatformType(platformRaw)
store.DeviceProps.PlatformType = platform.Enum()
if platformRaw == "" {
platformRaw = "DESKTOP"
}
if label == "" {
return
label = detectDeviceLabel(runtime.GOOS, os.Hostname, os.ReadFile)
}
platform := parsePlatformType(platformRaw)
store.DeviceProps.PlatformType = platform.Enum()
if label == "" {
label = "wacli"
}
store.SetOSInfo(label, [3]uint32{0, 1, 0})
store.BaseClientPayload.UserAgent.Device = proto.String(label)
store.BaseClientPayload.UserAgent.Manufacturer = proto.String(label)
@ -42,3 +48,50 @@ func parsePlatformType(raw string) waCompanionReg.DeviceProps_PlatformType {
}
return waCompanionReg.DeviceProps_CHROME
}
func detectDeviceLabel(goos string, hostname func() (string, error), readFile func(string) ([]byte, error)) string {
host, _ := hostname()
host = strings.TrimSpace(host)
osName := friendlyOSName(goos, readFile)
switch {
case host != "" && osName != "":
return "wacli - " + osName + " (" + host + ")"
case host != "":
return "wacli - " + host
case osName != "":
return "wacli - " + osName
default:
return "wacli"
}
}
func friendlyOSName(goos string, readFile func(string) ([]byte, error)) string {
switch goos {
case "darwin":
return "macOS"
case "linux":
return linuxDistroName(readFile)
case "windows":
return "Windows"
default:
return goos
}
}
func linuxDistroName(readFile func(string) ([]byte, error)) string {
data, err := readFile("/etc/os-release")
if err != nil {
return "Linux"
}
for _, line := range strings.Split(string(data), "\n") {
key, value, ok := strings.Cut(line, "=")
if !ok || key != "PRETTY_NAME" {
continue
}
value = strings.Trim(strings.TrimSpace(value), `"`)
if value != "" {
return value
}
}
return "Linux"
}

38
cmd/wacli/main_test.go Normal file
View File

@ -0,0 +1,38 @@
package main
import (
"errors"
"testing"
"go.mau.fi/whatsmeow/proto/waCompanionReg"
)
func TestParsePlatformType(t *testing.T) {
if got := parsePlatformType("desktop"); got != waCompanionReg.DeviceProps_DESKTOP {
t.Fatalf("desktop parsed as %v", got)
}
if got := parsePlatformType("bogus"); got != waCompanionReg.DeviceProps_CHROME {
t.Fatalf("bogus parsed as %v", got)
}
}
func TestDetectDeviceLabel(t *testing.T) {
host := func() (string, error) { return "workstation", nil }
readFile := func(string) ([]byte, error) { return []byte(`PRETTY_NAME="Ubuntu 24.04 LTS"`), nil }
if got := detectDeviceLabel("linux", host, readFile); got != "wacli - Ubuntu 24.04 LTS (workstation)" {
t.Fatalf("detectDeviceLabel = %q", got)
}
}
func TestDetectDeviceLabelFallbacks(t *testing.T) {
noHost := func() (string, error) { return "", errors.New("no hostname") }
noFile := func(string) ([]byte, error) { return nil, errors.New("missing") }
if got := detectDeviceLabel("darwin", noHost, noFile); got != "wacli - macOS" {
t.Fatalf("darwin label = %q", got)
}
if got := detectDeviceLabel("", noHost, noFile); got != "wacli" {
t.Fatalf("empty label = %q", got)
}
}

View File

@ -34,7 +34,7 @@ func newPresenceTypingCmd(flags *rootFlags) *cobra.Command {
},
}
cmd.Flags().StringVar(&to, "to", "", "recipient phone number or JID")
cmd.Flags().StringVar(&to, "to", "", "recipient phone number (+E164 and formatting ok) or JID")
cmd.Flags().StringVar(&media, "media", "", "media type: 'audio' for recording indicator (default: typing text)")
return cmd
}
@ -50,7 +50,7 @@ func newPresencePausedCmd(flags *rootFlags) *cobra.Command {
},
}
cmd.Flags().StringVar(&to, "to", "", "recipient phone number or JID")
cmd.Flags().StringVar(&to, "to", "", "recipient phone number (+E164 and formatting ok) or JID")
return cmd
}

View File

@ -103,7 +103,7 @@ func newSendTextCmd(flags *rootFlags) *cobra.Command {
},
}
cmd.Flags().StringVar(&to, "to", "", "recipient phone number or JID")
cmd.Flags().StringVar(&to, "to", "", "recipient phone number (+E164 and formatting ok) or JID")
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)")

View File

@ -80,7 +80,7 @@ func newSendFileCmd(flags *rootFlags) *cobra.Command {
},
}
cmd.Flags().StringVar(&to, "to", "", "recipient phone number or JID")
cmd.Flags().StringVar(&to, "to", "", "recipient phone number (+E164 and formatting ok) or JID")
cmd.Flags().StringVar(&filePath, "file", "", "path to file")
cmd.Flags().StringVar(&filename, "filename", "", "display name for the file (defaults to basename of --file)")
cmd.Flags().StringVar(&caption, "caption", "", "caption (images/videos/documents)")

View File

@ -74,7 +74,7 @@ func newSendReactCmd(flags *rootFlags) *cobra.Command {
},
}
cmd.Flags().StringVar(&to, "to", "", "recipient phone number or JID")
cmd.Flags().StringVar(&to, "to", "", "recipient phone number (+E164 and formatting ok) or JID")
cmd.Flags().StringVar(&msgID, "id", "", "target message ID")
cmd.Flags().StringVar(&emoji, "reaction", "\U0001f44d", "reaction emoji (pass an empty string to remove)")
cmd.Flags().StringVar(&sender, "sender", "", "message sender JID (required for group messages)")

View File

@ -39,6 +39,8 @@ func TestParseUserOrJID(t *testing.T) {
{name: "phone", input: "1234567890", wantUser: "1234567890", wantServer: types.DefaultUserServer},
{name: "phone with plus", input: "+1234567890", wantUser: "1234567890", wantServer: types.DefaultUserServer},
{name: "phone with spaces and plus", input: " +1234567890 ", wantUser: "1234567890", wantServer: types.DefaultUserServer},
{name: "formatted phone", input: "+1 (234) 567-8900", wantUser: "12345678900", wantServer: types.DefaultUserServer},
{name: "dotted phone", input: "1.234.567.8900", wantUser: "12345678900", wantServer: types.DefaultUserServer},
{name: "minimum length phone", input: "1234567", wantUser: "1234567", wantServer: types.DefaultUserServer},
{name: "maximum length phone", input: "123456789012345", wantUser: "123456789012345", wantServer: types.DefaultUserServer},
{name: "group jid", input: "123@g.us", wantUser: "123", wantServer: types.GroupServer},
@ -46,10 +48,9 @@ func TestParseUserOrJID(t *testing.T) {
{name: "too short phone", input: "123456", wantErr: true},
{name: "too long phone", input: "1234567890123456", wantErr: true},
{name: "letters in phone", input: "123abc456", wantErr: true},
{name: "punctuation in phone", input: "123-456-7890", wantErr: true},
{name: "spaces inside phone", input: "123 456 7890", wantErr: true},
{name: "plus inside phone", input: "12+34567", wantErr: true},
{name: "double leading plus", input: "++1234567", wantErr: true},
{name: "unicode digits rejected", input: "١٢٣٤٥٦٧", wantErr: true},
}
for _, tt := range tests {

View File

@ -27,17 +27,25 @@ func IsGroupJID(jid types.JID) bool {
}
func normalizePhoneRecipient(s string) (string, error) {
phone := strings.TrimPrefix(s, "+")
if phone == "" {
return "", fmt.Errorf("recipient is required")
}
if len(phone) < 7 || len(phone) > 15 {
return "", fmt.Errorf("invalid phone number %q: must be 7-15 digits", s)
}
for _, ch := range phone {
if ch < '0' || ch > '9' {
return "", fmt.Errorf("invalid phone number %q: must contain digits only", s)
var phone strings.Builder
for i, ch := range s {
switch {
case ch == '+' && i == 0:
continue
case ch >= '0' && ch <= '9':
phone.WriteRune(ch)
case ch == ' ' || ch == '-' || ch == '(' || ch == ')' || ch == '.':
continue
default:
return "", fmt.Errorf("invalid phone number %q: must contain digits, common formatting, or one leading +", s)
}
}
return phone, nil
normalized := phone.String()
if normalized == "" {
return "", fmt.Errorf("recipient is required")
}
if len(normalized) < 7 || len(normalized) > 15 {
return "", fmt.Errorf("invalid phone number %q: must be 7-15 digits", s)
}
return normalized, nil
}