feat: support phone pairing auth
This commit is contained in:
parent
eabf8d6eec
commit
e2bebf6eed
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user