diff --git a/CHANGELOG.md b/CHANGELOG.md index 90dc301..66176bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 2b1e4c1..e9d73a5 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ pnpm wacli media download --chat 1234567890@s.whatsapp.net --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 @@ -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 - ()` 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. diff --git a/cmd/wacli/groups_participants.go b/cmd/wacli/groups_participants.go index ae763ae..d001706 100644 --- a/cmd/wacli/groups_participants.go +++ b/cmd/wacli/groups_participants.go @@ -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 } diff --git a/cmd/wacli/main.go b/cmd/wacli/main.go index e4267b2..e0f71b1 100644 --- a/cmd/wacli/main.go +++ b/cmd/wacli/main.go @@ -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" +} diff --git a/cmd/wacli/main_test.go b/cmd/wacli/main_test.go new file mode 100644 index 0000000..22eaefe --- /dev/null +++ b/cmd/wacli/main_test.go @@ -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) + } +} diff --git a/cmd/wacli/presence.go b/cmd/wacli/presence.go index 08563e6..56a83a1 100644 --- a/cmd/wacli/presence.go +++ b/cmd/wacli/presence.go @@ -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 } diff --git a/cmd/wacli/send.go b/cmd/wacli/send.go index 4682316..67d54d7 100644 --- a/cmd/wacli/send.go +++ b/cmd/wacli/send.go @@ -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)") diff --git a/cmd/wacli/send_file_cmd.go b/cmd/wacli/send_file_cmd.go index e8ad4b8..f8ba3fb 100644 --- a/cmd/wacli/send_file_cmd.go +++ b/cmd/wacli/send_file_cmd.go @@ -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)") diff --git a/cmd/wacli/send_react_cmd.go b/cmd/wacli/send_react_cmd.go index 4a094bf..91e9f64 100644 --- a/cmd/wacli/send_react_cmd.go +++ b/cmd/wacli/send_react_cmd.go @@ -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)") diff --git a/internal/wa/client_test.go b/internal/wa/client_test.go index fc98f9c..04939c8 100644 --- a/internal/wa/client_test.go +++ b/internal/wa/client_test.go @@ -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 { diff --git a/internal/wa/jid.go b/internal/wa/jid.go index 5b66acd..e8d1062 100644 --- a/internal/wa/jid.go +++ b/internal/wa/jid.go @@ -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 }