feat: support phone pairing auth

This commit is contained in:
Peter Steinberger 2026-05-04 07:25:36 +01:00
parent eabf8d6eec
commit e2bebf6eed
No known key found for this signature in database
5 changed files with 112 additions and 5 deletions

View File

@ -13,6 +13,7 @@
- Messages: add `messages search --has-media`, `--type text`, case-insensitive media types, and validation for contradictory filters. (#128 — thanks @ImLukeF and @Mansehej)
- 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)
- Send: add `send react` to add or clear reactions, with group sender validation. (#151 — thanks @draix)
### Security

View File

@ -12,6 +12,8 @@ import (
"github.com/spf13/cobra"
appPkg "github.com/steipete/wacli/internal/app"
"github.com/steipete/wacli/internal/out"
"github.com/steipete/wacli/internal/wa"
"go.mau.fi/whatsmeow/types"
)
func newAuthCmd(flags *rootFlags) *cobra.Command {
@ -19,6 +21,7 @@ func newAuthCmd(flags *rootFlags) *cobra.Command {
var idleExit time.Duration
var downloadMedia bool
var qrFormat string
var phone string
cmd := &cobra.Command{
Use: "auth",
@ -34,6 +37,10 @@ func newAuthCmd(flags *rootFlags) *cobra.Command {
if flags.asJSON && qrFormat == "text" {
return fmt.Errorf("--qr-format=text cannot be combined with --json because both write to stdout")
}
pairPhone, err := normalizePairPhone(phone)
if err != nil {
return err
}
ctx, stop := signalContext()
defer stop()
@ -57,6 +64,8 @@ func newAuthCmd(flags *rootFlags) *cobra.Command {
RefreshGroups: true,
IdleExit: idleExit,
OnQRCode: authQRWriter(qrFormat, os.Stdout, os.Stderr),
PairPhoneNumber: pairPhone,
OnPairCode: authPairCodeWriter(pairPhone, os.Stderr),
})
if err != nil {
return err
@ -78,6 +87,7 @@ func newAuthCmd(flags *rootFlags) *cobra.Command {
cmd.Flags().DurationVar(&idleExit, "idle-exit", 30*time.Second, "exit after being idle (bootstrap/once modes)")
cmd.Flags().BoolVar(&downloadMedia, "download-media", false, "download media in the background during sync")
cmd.Flags().StringVar(&qrFormat, "qr-format", "terminal", "QR output format: terminal or text")
cmd.Flags().StringVar(&phone, "phone", "", "pair by phone number instead of QR code")
cmd.AddCommand(newAuthStatusCmd(flags))
cmd.AddCommand(newAuthLogoutCmd(flags))
@ -85,6 +95,21 @@ func newAuthCmd(flags *rootFlags) *cobra.Command {
return cmd
}
func normalizePairPhone(phone string) (string, error) {
phone = strings.TrimSpace(phone)
if phone == "" {
return "", nil
}
jid, err := wa.ParseUserOrJID(phone)
if err != nil {
return "", fmt.Errorf("invalid --phone: %w", err)
}
if jid.Server != types.DefaultUserServer || jid.Device != 0 {
return "", fmt.Errorf("invalid --phone: must be an international phone number")
}
return jid.User, nil
}
func normalizeAuthQRFormat(format string) (string, error) {
format = strings.ToLower(strings.TrimSpace(format))
if format == "" {
@ -111,6 +136,17 @@ func authQRWriter(format string, stdout, stderr io.Writer) func(string) {
}
}
func authPairCodeWriter(phone string, stderr io.Writer) func(string) {
if phone == "" {
return nil
}
return func(code string) {
fmt.Fprintf(stderr, "\nPairing code for +%s: %s\n", phone, code)
fmt.Fprintln(stderr, "On your phone: WhatsApp > Linked Devices > Link a Device > Link with phone number.")
fmt.Fprintln(stderr, "Enter the code above and keep this command running until authentication completes.")
}
}
func newAuthStatusCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "status",

View File

@ -90,6 +90,51 @@ func TestAuthQRWriterText(t *testing.T) {
}
}
func TestNormalizePairPhone(t *testing.T) {
tests := []struct {
input string
want string
wantErr bool
}{
{input: "", want: ""},
{input: "+15551234567", want: "15551234567"},
{input: "15551234567", want: "15551234567"},
{input: "123@g.us", wantErr: true},
{input: "123abc", wantErr: true},
}
for _, tc := range tests {
got, err := normalizePairPhone(tc.input)
if tc.wantErr {
if err == nil {
t.Fatalf("normalizePairPhone(%q) expected error", tc.input)
}
continue
}
if err != nil {
t.Fatalf("normalizePairPhone(%q): %v", tc.input, err)
}
if got != tc.want {
t.Fatalf("normalizePairPhone(%q) = %q, want %q", tc.input, got, tc.want)
}
}
}
func TestAuthPairCodeWriter(t *testing.T) {
var stderr bytes.Buffer
writer := authPairCodeWriter("15551234567", &stderr)
if writer == nil {
t.Fatal("expected writer")
}
writer("ABCD-1234")
got := stderr.String()
if !strings.Contains(got, "Pairing code for +15551234567: ABCD-1234") {
t.Fatalf("stderr = %q", got)
}
if authPairCodeWriter("", &stderr) != nil {
t.Fatal("expected nil writer without phone")
}
}
func TestAuthCommandExposesQRFormat(t *testing.T) {
cmd := newAuthCmd(&rootFlags{})
flag := cmd.Flags().Lookup("qr-format")
@ -99,6 +144,9 @@ func TestAuthCommandExposesQRFormat(t *testing.T) {
if flag.DefValue != "terminal" {
t.Fatalf("qr-format default = %q", flag.DefValue)
}
if cmd.Flags().Lookup("phone") == nil {
t.Fatal("expected --phone flag")
}
}
func TestPhoneFromLinkedJID(t *testing.T) {

View File

@ -24,6 +24,8 @@ type SyncOptions struct {
Mode SyncMode
AllowQR bool
OnQRCode func(string)
PairPhoneNumber string
OnPairCode func(string)
AfterConnect func(context.Context) error
DownloadMedia bool
RefreshContacts bool
@ -75,7 +77,12 @@ func (a *App) Sync(ctx context.Context, opts SyncOptions) (SyncResult, error) {
defer stopMedia()
}
if err := a.Connect(ctx, opts.AllowQR, opts.OnQRCode); err != nil {
if err := a.wa.Connect(ctx, wa.ConnectOptions{
AllowQR: opts.AllowQR,
OnQRCode: opts.OnQRCode,
PairPhoneNumber: opts.PairPhoneNumber,
OnPairCode: opts.OnPairCode,
}); err != nil {
return SyncResult{}, err
}
lastEvent.Store(nowUTC().UnixNano())

View File

@ -71,8 +71,10 @@ func (c *Client) IsConnected() bool {
}
type ConnectOptions struct {
AllowQR bool
OnQRCode func(code string)
AllowQR bool
OnQRCode func(code string)
PairPhoneNumber string
OnPairCode func(code string)
}
func (c *Client) Connect(ctx context.Context, opts ConnectOptions) error {
@ -88,7 +90,7 @@ func (c *Client) Connect(ctx context.Context, opts ConnectOptions) error {
}
authed := cli.Store != nil && cli.Store.ID != nil
if !authed && !opts.AllowQR {
if !authed && !opts.AllowQR && opts.PairPhoneNumber == "" {
return fmt.Errorf("not authenticated; run `wacli auth`")
}
@ -110,6 +112,7 @@ func (c *Client) Connect(ctx context.Context, opts ConnectOptions) error {
}
// Wait for QR flow to succeed or fail.
pairCodeRequested := false
for {
select {
case <-ctx.Done():
@ -120,7 +123,19 @@ func (c *Client) Connect(ctx context.Context, opts ConnectOptions) error {
}
switch {
case evt.Event == whatsmeow.QRChannelEventCode:
if opts.OnQRCode != nil {
if opts.PairPhoneNumber != "" {
if pairCodeRequested {
continue
}
code, err := cli.PairPhone(ctx, opts.PairPhoneNumber, true, whatsmeow.PairClientChrome, "Chrome (Linux)")
if err != nil {
return fmt.Errorf("pair phone: %w", err)
}
pairCodeRequested = true
if opts.OnPairCode != nil {
opts.OnPairCode(code)
}
} else if opts.OnQRCode != nil {
opts.OnQRCode(evt.Code)
} else {
qrterminal.GenerateHalfBlock(evt.Code, qrterminal.M, os.Stdout)