diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c925ca..1b8f985 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - Messages: add `messages search --has-media`, `--type text`, case-insensitive media types, and validation for contradictory filters. (#128 — thanks @ImLukeF and @Mansehej) - Messages: add JSON export with `messages export --after` and `--before` filters. - Messages: extract searchable/display text from WhatsApp Business templates, buttons, interactive messages, and list replies. (#79 — thanks @terry-li-hm) +- Contacts: add `contacts import-system` to import macOS Contacts display names as local metadata with alias-first precedence. (#33 — thanks @enki and @octaviofroid) - 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) diff --git a/README.md b/README.md index 67e6d55..b8ec342 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Full docs site: . - [Messages](docs/messages.md): `messages list/search/starred/show/context/export/edit/delete`. - [Send](docs/send.md): `send text/file/sticker/voice/react`, recipient resolution, replies. - [Media](docs/media.md): `media download`. -- [Contacts](docs/contacts.md): `contacts search/show/refresh`, aliases, tags. +- [Contacts](docs/contacts.md): `contacts search/show/refresh/import-system`, aliases, tags. - [Chats](docs/chats.md): `chats list/show`, archive, pin, mute, mark read. - [Groups](docs/groups.md): group list, refresh, info, rename, leave, participants, invites, join. - [Store](docs/store.md): local store stats and cleanup commands. @@ -47,7 +47,7 @@ Full docs site: . - **Message tools**: list/search/show/context with chat, sender, direction, time, order, and media-type filters. - **Sending**: send text, mentions, quoted replies, stickers, and image/video/audio/document files with captions, MIME override, and custom display filenames. Sends keep a short retry-receipt grace window, and rapid repeated sends warn on stderr. - **Media**: download synced message media on demand, or download in the background during auth/sync; send-file uploads and downloads are capped at 100 MiB. -- **Contacts/chats/groups/store/channels**: search/show contacts, local aliases/tags, list/show/filter chats, archive/pin/mute/mark-read chats, refresh/list/info/rename/prune groups, inspect/prune the local store, manage participants, invite links, join, leave, and manage WhatsApp Channels. +- **Contacts/chats/groups/store/channels**: search/show contacts, import macOS Contacts names, local aliases/tags, list/show/filter chats, archive/pin/mute/mark-read chats, refresh/list/info/rename/prune groups, inspect/prune the local store, manage participants, invite links, join, leave, and manage WhatsApp Channels. - **Presence**: send typing/paused indicators. - **Profile**: set the authenticated account profile picture from JPEG or PNG input. - **Diagnostics + safety**: `doctor`, read-only mode, store locks with lock-owner reporting, lock waiting, owner-only database permissions, panic recovery, reconnect bounds, and bounded media queue backpressure. @@ -209,6 +209,7 @@ Full command docs live under [docs/overview.md](docs/overview.md). Quick referen - `wacli contacts search ` - `wacli contacts show --jid JID` - `wacli contacts refresh` +- `wacli contacts import-system [--input FILE] [--dry-run] [--clear]` - `wacli contacts alias set|rm --jid JID [--alias NAME]` - `wacli contacts tags add|rm --jid JID --tag TAG` - `wacli chats list [--query TEXT] [--limit N] [--archived|--no-archived] [--pinned|--no-pinned] [--muted|--no-muted] [--unread|--no-unread]` diff --git a/cmd/wacli/contacts.go b/cmd/wacli/contacts.go index f09a9fb..6b6b9dc 100644 --- a/cmd/wacli/contacts.go +++ b/cmd/wacli/contacts.go @@ -18,6 +18,7 @@ func newContactsCmd(flags *rootFlags) *cobra.Command { cmd.AddCommand(newContactsSearchCmd(flags)) cmd.AddCommand(newContactsShowCmd(flags)) cmd.AddCommand(newContactsRefreshCmd(flags)) + cmd.AddCommand(newContactsImportSystemCmd(flags)) cmd.AddCommand(newContactsAliasCmd(flags)) cmd.AddCommand(newContactsTagsCmd(flags)) return cmd @@ -104,6 +105,9 @@ func newContactsShowCmd(flags *rootFlags) *cobra.Command { if c.Alias != "" { fmt.Fprintf(os.Stdout, "Alias: %s\n", c.Alias) } + if c.SystemName != "" { + fmt.Fprintf(os.Stdout, "System Name: %s\n", c.SystemName) + } if len(c.Tags) > 0 { fmt.Fprintf(os.Stdout, "Tags: %s\n", strings.Join(c.Tags, ", ")) } diff --git a/cmd/wacli/contacts_import_system.go b/cmd/wacli/contacts_import_system.go new file mode 100644 index 0000000..546b17f --- /dev/null +++ b/cmd/wacli/contacts_import_system.go @@ -0,0 +1,182 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/steipete/wacli/internal/out" + "github.com/steipete/wacli/internal/store" + "github.com/steipete/wacli/internal/syscontacts" +) + +type systemContactMatch struct { + JID string `json:"jid"` + Phone string `json:"phone"` + CurrentName string `json:"current_name"` + SystemName string `json:"system_name"` + ExistingValue string `json:"existing_system_name,omitempty"` +} + +func newContactsImportSystemCmd(flags *rootFlags) *cobra.Command { + var dryRun bool + var clear bool + var input string + cmd := &cobra.Command{ + Use: "import-system", + Short: "Import display names from macOS Contacts", + Long: `Import display names from macOS Contacts and store them as local system names. + +System names are local wacli metadata. They do not edit WhatsApp contacts or +macOS Contacts. Display precedence is: alias, system name, WhatsApp names. + +On macOS, the default source is the Contacts framework. Use --input to import +from a JSON array or NDJSON file with fields first_name, last_name, full_name, +and phones.`, + RunE: func(cmd *cobra.Command, args []string) error { + if !dryRun { + if err := flags.requireWritable(); err != nil { + return err + } + } + + ctx, cancel := withTimeout(context.Background(), flags) + defer cancel() + + a, lk, err := newApp(ctx, flags, !dryRun, false) + if err != nil { + return err + } + defer closeApp(a, lk) + + if clear { + return runContactsSystemClear(a.DB(), dryRun, flags.asJSON) + } + + systemContacts, err := readSystemContacts(ctx, input) + if err != nil { + return err + } + phoneToName := syscontacts.PhoneToName(systemContacts) + localContacts, err := a.DB().ListContacts(0) + if err != nil { + return err + } + + matches, skippedNoPhone, skippedNoMatch, skippedSame := matchSystemContacts(localContacts, phoneToName) + result := map[string]any{ + "matched": len(matches), + "matches": matches, + "skipped_no_phone": skippedNoPhone, + "skipped_no_match": skippedNoMatch, + "skipped_same": skippedSame, + "dry_run": dryRun, + } + + if dryRun { + if flags.asJSON { + return out.WriteJSON(os.Stdout, result) + } + writeSystemImportPreview(matches, skippedNoPhone, skippedNoMatch, skippedSame) + return nil + } + + applied := 0 + for _, m := range matches { + if err := a.DB().SetSystemName(m.JID, m.SystemName); err != nil { + fmt.Fprintf(os.Stderr, "warning: failed to set system name for %s: %v\n", m.JID, err) + continue + } + applied++ + } + result["applied"] = applied + if flags.asJSON { + return out.WriteJSON(os.Stdout, result) + } + fmt.Fprintf(os.Stdout, "Applied %d system contact name(s).\n", applied) + return nil + }, + } + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show what would be imported without writing") + cmd.Flags().BoolVar(&clear, "clear", false, "clear all imported system names") + cmd.Flags().StringVar(&input, "input", "", "read system contacts from JSON/NDJSON instead of macOS Contacts") + return cmd +} + +func readSystemContacts(ctx context.Context, input string) ([]syscontacts.Contact, error) { + if input != "" { + return syscontacts.ReadFile(input) + } + return syscontacts.ReadSystem(ctx) +} + +func runContactsSystemClear(db *store.DB, dryRun, asJSON bool) error { + count, err := db.CountSystemNames() + if err != nil { + return err + } + if dryRun { + if asJSON { + return out.WriteJSON(os.Stdout, map[string]any{"would_clear": count, "dry_run": true}) + } + fmt.Fprintf(os.Stdout, "Would clear %d system contact name(s).\n", count) + return nil + } + cleared, err := db.ClearAllSystemNames() + if err != nil { + return err + } + if asJSON { + return out.WriteJSON(os.Stdout, map[string]any{"cleared": cleared}) + } + fmt.Fprintf(os.Stdout, "Cleared %d system contact name(s).\n", cleared) + return nil +} + +func matchSystemContacts(local []store.Contact, phoneToName map[string]string) ([]systemContactMatch, int, int, int) { + var matches []systemContactMatch + var skippedNoPhone, skippedNoMatch, skippedSame int + for _, c := range local { + phone := syscontacts.NormalizePhone(c.Phone) + if phone == "" { + skippedNoPhone++ + continue + } + systemName, ok := phoneToName[phone] + if !ok { + skippedNoMatch++ + continue + } + if c.SystemName == systemName { + skippedSame++ + continue + } + matches = append(matches, systemContactMatch{ + JID: c.JID, + Phone: c.Phone, + CurrentName: c.Name, + SystemName: systemName, + ExistingValue: c.SystemName, + }) + } + return matches, skippedNoPhone, skippedNoMatch, skippedSame +} + +func writeSystemImportPreview(matches []systemContactMatch, skippedNoPhone, skippedNoMatch, skippedSame int) { + fmt.Fprintf(os.Stdout, "Would import %d system contact name(s).\n", len(matches)) + fmt.Fprintf(os.Stdout, "Skipped: %d no phone, %d no match, %d already current.\n", skippedNoPhone, skippedNoMatch, skippedSame) + if len(matches) == 0 { + return + } + w := newTableWriter(os.Stdout) + fmt.Fprintln(w, "PHONE\tCURRENT\tSYSTEM") + for _, m := range matches { + fmt.Fprintf(w, "%s\t%s\t%s\n", + tableCell(m.Phone, 16, false), + tableCell(m.CurrentName, 24, false), + tableCell(m.SystemName, 24, false), + ) + } + _ = w.Flush() +} diff --git a/cmd/wacli/contacts_import_system_test.go b/cmd/wacli/contacts_import_system_test.go new file mode 100644 index 0000000..35ff73e --- /dev/null +++ b/cmd/wacli/contacts_import_system_test.go @@ -0,0 +1,99 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/steipete/wacli/internal/store" +) + +func TestContactsImportSystemFromInputDryRunDoesNotWrite(t *testing.T) { + storeDir, input := seedSystemImportStore(t) + + cmd := newContactsImportSystemCmd(&rootFlags{storeDir: storeDir, timeout: time.Minute}) + cmd.SetArgs([]string{"--input", input, "--dry-run"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("contacts import-system dry-run: %v", err) + } + + db := openSystemImportStore(t, storeDir) + defer db.Close() + c, err := db.GetContact("14157347847@s.whatsapp.net") + if err != nil { + t.Fatalf("GetContact: %v", err) + } + if c.SystemName != "" { + t.Fatalf("dry-run wrote system name: %#v", c) + } +} + +func TestContactsImportSystemFromInputWritesAndClears(t *testing.T) { + storeDir, input := seedSystemImportStore(t) + + cmd := newContactsImportSystemCmd(&rootFlags{storeDir: storeDir, timeout: time.Minute}) + cmd.SetArgs([]string{"--input", input}) + if err := cmd.Execute(); err != nil { + t.Fatalf("contacts import-system: %v", err) + } + + db := openSystemImportStore(t, storeDir) + c, err := db.GetContact("14157347847@s.whatsapp.net") + if err != nil { + t.Fatalf("GetContact: %v", err) + } + if c.SystemName != "Alice Appleseed" || c.Name != "Alice Appleseed" { + t.Fatalf("contact = %#v", c) + } + _ = db.Close() + + clearCmd := newContactsImportSystemCmd(&rootFlags{storeDir: storeDir, timeout: time.Minute}) + clearCmd.SetArgs([]string{"--clear"}) + if err := clearCmd.Execute(); err != nil { + t.Fatalf("contacts import-system --clear: %v", err) + } + db = openSystemImportStore(t, storeDir) + defer db.Close() + c, err = db.GetContact("14157347847@s.whatsapp.net") + if err != nil { + t.Fatalf("GetContact after clear: %v", err) + } + if c.SystemName != "" { + t.Fatalf("clear left system name: %#v", c) + } +} + +func seedSystemImportStore(t *testing.T) (string, string) { + t.Helper() + storeDir := t.TempDir() + db := openSystemImportStore(t, storeDir) + if err := db.UpsertContact("14157347847@s.whatsapp.net", "14157347847", "WhatsApp Alice", "", "", ""); err != nil { + t.Fatalf("UpsertContact: %v", err) + } + if err := db.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + input := filepath.Join(storeDir, "contacts.json") + raw, err := json.Marshal([]map[string]any{ + {"full_name": "Alice Appleseed", "phones": []string{"+1 (415) 734-7847"}}, + }) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if err := os.WriteFile(input, raw, 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + return storeDir, input +} + +func openSystemImportStore(t *testing.T, storeDir string) *store.DB { + t.Helper() + db, err := store.Open(filepath.Join(storeDir, "wacli.db")) + if err != nil { + t.Fatalf("Open: %v", err) + } + return db +} diff --git a/docs/contacts.md b/docs/contacts.md index a6f829c..72ed5e2 100644 --- a/docs/contacts.md +++ b/docs/contacts.md @@ -1,6 +1,6 @@ # contacts -Read when: finding synced contacts or managing local contact metadata. +Read when: finding synced contacts, importing macOS Contacts names, or managing local contact metadata. `wacli contacts` works with contact metadata stored locally. Aliases and tags are local to `wacli`; they do not edit WhatsApp contacts on the phone. @@ -10,6 +10,7 @@ Read when: finding synced contacts or managing local contact metadata. wacli contacts search [--limit N] wacli contacts show --jid JID wacli contacts refresh +wacli contacts import-system [--input FILE] [--dry-run] [--clear] wacli contacts alias set --jid JID --alias NAME wacli contacts alias rm --jid JID wacli contacts tags add --jid JID --tag TAG @@ -20,7 +21,11 @@ wacli contacts tags rm --jid JID --tag TAG - `search` matches alias, full name, push name, first name, business name, phone, and JID. - `refresh` imports contacts from the whatsmeow session store into `wacli.db`. -- Local aliases are preferred in contact search and display. +- `import-system` imports display names from macOS Contacts by matching phone numbers against already-synced wacli contacts. Run `contacts refresh` first. +- `import-system --input FILE` reads a JSON array or newline-delimited JSON contacts file with `full_name` and `phones` fields instead of opening macOS Contacts. +- Imported system names are local wacli metadata. They do not edit WhatsApp contacts or macOS Contacts. +- Display precedence is local alias, imported system name, then WhatsApp names. +- Use `import-system --dry-run` before writing. Use `import-system --clear` to remove imported system names. - Tags are local grouping metadata for scripts and future workflows. ## Examples @@ -29,6 +34,7 @@ wacli contacts tags rm --jid JID --tag TAG wacli contacts search Alice wacli contacts show --jid 1234567890@s.whatsapp.net wacli contacts refresh +wacli contacts import-system --dry-run wacli contacts alias set --jid 1234567890@s.whatsapp.net --alias mom wacli contacts tags add --jid 1234567890@s.whatsapp.net --tag family ``` diff --git a/docs/overview.md b/docs/overview.md index aa663b6..424a64e 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -26,7 +26,7 @@ Read when: you need the user-facing command map, global flags, store model, or l - [messages](messages.md) - list, search, show, and contextualize stored messages. - [send](send.md) - send text, files, stickers, replies, and reactions. - [media](media.md) - download media attached to stored messages. -- [contacts](contacts.md) - search contacts and manage local aliases/tags. +- [contacts](contacts.md) - search contacts, import macOS Contacts names, and manage local aliases/tags. - [chats](chats.md) - list, show, filter, and manage known chat state. - [groups](groups.md) - refresh, inspect, rename, leave, join, invite, and manage participants. - [store](store.md) - inspect local store stats and prune stale local rows. diff --git a/internal/store/contacts.go b/internal/store/contacts.go index e045e03..d835972 100644 --- a/internal/store/contacts.go +++ b/internal/store/contacts.go @@ -16,15 +16,23 @@ func (d *DB) SearchContacts(query string, limit int) ([]Contact, error) { SELECT c.jid, COALESCE(c.phone,''), COALESCE(NULLIF(a.alias,''), ''), - COALESCE(NULLIF(c.full_name,''), NULLIF(c.push_name,''), NULLIF(c.business_name,''), NULLIF(c.first_name,''), ''), + COALESCE(NULLIF(c.system_name,''), ''), + COALESCE(NULLIF(a.alias,''), NULLIF(c.system_name,''), NULLIF(c.full_name,''), NULLIF(c.push_name,''), NULLIF(c.business_name,''), NULLIF(c.first_name,''), ''), c.updated_at FROM contacts c LEFT JOIN contact_aliases a ON a.jid = c.jid - WHERE LOWER(COALESCE(a.alias,'')) LIKE LOWER(?) ESCAPE '\' OR LOWER(COALESCE(c.full_name,'')) LIKE LOWER(?) ESCAPE '\' OR LOWER(COALESCE(c.push_name,'')) LIKE LOWER(?) ESCAPE '\' OR LOWER(COALESCE(c.first_name,'')) LIKE LOWER(?) ESCAPE '\' OR LOWER(COALESCE(c.business_name,'')) LIKE LOWER(?) ESCAPE '\' OR LOWER(COALESCE(c.phone,'')) LIKE LOWER(?) ESCAPE '\' OR LOWER(c.jid) LIKE LOWER(?) ESCAPE '\' - ORDER BY COALESCE(NULLIF(a.alias,''), NULLIF(c.full_name,''), NULLIF(c.push_name,''), NULLIF(c.business_name,''), NULLIF(c.first_name,''), c.jid) + WHERE LOWER(COALESCE(a.alias,'')) LIKE LOWER(?) ESCAPE '\' + OR LOWER(COALESCE(c.system_name,'')) LIKE LOWER(?) ESCAPE '\' + OR LOWER(COALESCE(c.full_name,'')) LIKE LOWER(?) ESCAPE '\' + OR LOWER(COALESCE(c.push_name,'')) LIKE LOWER(?) ESCAPE '\' + OR LOWER(COALESCE(c.first_name,'')) LIKE LOWER(?) ESCAPE '\' + OR LOWER(COALESCE(c.business_name,'')) LIKE LOWER(?) ESCAPE '\' + OR LOWER(COALESCE(c.phone,'')) LIKE LOWER(?) ESCAPE '\' + OR LOWER(c.jid) LIKE LOWER(?) ESCAPE '\' + ORDER BY COALESCE(NULLIF(a.alias,''), NULLIF(c.system_name,''), NULLIF(c.full_name,''), NULLIF(c.push_name,''), NULLIF(c.business_name,''), NULLIF(c.first_name,''), c.jid) LIMIT ?` needle := likeContains(query) - rows, err := d.sql.Query(q, needle, needle, needle, needle, needle, needle, needle, limit) + rows, err := d.sql.Query(q, needle, needle, needle, needle, needle, needle, needle, needle, limit) if err != nil { return nil, err } @@ -34,7 +42,40 @@ func (d *DB) SearchContacts(query string, limit int) ([]Contact, error) { for rows.Next() { var c Contact var updated int64 - if err := rows.Scan(&c.JID, &c.Phone, &c.Alias, &c.Name, &updated); err != nil { + if err := rows.Scan(&c.JID, &c.Phone, &c.Alias, &c.SystemName, &c.Name, &updated); err != nil { + return nil, err + } + c.UpdatedAt = fromUnix(updated) + out = append(out, c) + } + return out, rows.Err() +} + +func (d *DB) ListContacts(limit int) ([]Contact, error) { + if limit <= 0 { + limit = 100000 + } + rows, err := d.sql.Query(` + SELECT c.jid, + COALESCE(c.phone,''), + COALESCE(NULLIF(a.alias,''), ''), + COALESCE(NULLIF(c.system_name,''), ''), + COALESCE(NULLIF(a.alias,''), NULLIF(c.system_name,''), NULLIF(c.full_name,''), NULLIF(c.push_name,''), NULLIF(c.business_name,''), NULLIF(c.first_name,''), ''), + c.updated_at + FROM contacts c + LEFT JOIN contact_aliases a ON a.jid = c.jid + ORDER BY COALESCE(NULLIF(a.alias,''), NULLIF(c.system_name,''), NULLIF(c.full_name,''), NULLIF(c.push_name,''), NULLIF(c.business_name,''), NULLIF(c.first_name,''), c.jid) + LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []Contact + for rows.Next() { + var c Contact + var updated int64 + if err := rows.Scan(&c.JID, &c.Phone, &c.Alias, &c.SystemName, &c.Name, &updated); err != nil { return nil, err } c.UpdatedAt = fromUnix(updated) @@ -48,7 +89,8 @@ func (d *DB) GetContact(jid string) (Contact, error) { SELECT c.jid, COALESCE(c.phone,''), COALESCE(NULLIF(a.alias,''), ''), - COALESCE(NULLIF(c.full_name,''), NULLIF(c.push_name,''), NULLIF(c.business_name,''), NULLIF(c.first_name,''), ''), + COALESCE(NULLIF(c.system_name,''), ''), + COALESCE(NULLIF(a.alias,''), NULLIF(c.system_name,''), NULLIF(c.full_name,''), NULLIF(c.push_name,''), NULLIF(c.business_name,''), NULLIF(c.first_name,''), ''), c.updated_at FROM contacts c LEFT JOIN contact_aliases a ON a.jid = c.jid @@ -56,7 +98,7 @@ func (d *DB) GetContact(jid string) (Contact, error) { `, jid) var c Contact var updated int64 - if err := row.Scan(&c.JID, &c.Phone, &c.Alias, &c.Name, &updated); err != nil { + if err := row.Scan(&c.JID, &c.Phone, &c.Alias, &c.SystemName, &c.Name, &updated); err != nil { return Contact{}, err } c.UpdatedAt = fromUnix(updated) @@ -65,6 +107,44 @@ func (d *DB) GetContact(jid string) (Contact, error) { return c, nil } +func (d *DB) SetSystemName(jid, systemName string) error { + jid = strings.TrimSpace(jid) + systemName = strings.TrimSpace(systemName) + if jid == "" { + return fmt.Errorf("jid is required") + } + if systemName == "" { + return fmt.Errorf("system name is required") + } + now := nowUTC().Unix() + res, err := d.sql.Exec(`UPDATE contacts SET system_name = ?, updated_at = ? WHERE jid = ?`, systemName, now, jid) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err == nil && n == 0 { + return fmt.Errorf("contact not found: %s", jid) + } + return nil +} + +func (d *DB) ClearAllSystemNames() (int64, error) { + now := nowUTC().Unix() + res, err := d.sql.Exec(`UPDATE contacts SET system_name = NULL, updated_at = ? WHERE system_name IS NOT NULL AND system_name != ''`, now) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func (d *DB) CountSystemNames() (int64, error) { + row := d.sql.QueryRow(`SELECT COUNT(1) FROM contacts WHERE system_name IS NOT NULL AND system_name != ''`) + var n int64 + if err := row.Scan(&n); err != nil { + return 0, err + } + return n, nil +} + func (d *DB) ListTags(jid string) ([]string, error) { rows, err := d.sql.Query(`SELECT tag FROM contact_tags WHERE jid = ? ORDER BY tag`, jid) if err != nil { diff --git a/internal/store/contacts_test.go b/internal/store/contacts_test.go index 3e0d23f..80f6b4a 100644 --- a/internal/store/contacts_test.go +++ b/internal/store/contacts_test.go @@ -67,3 +67,57 @@ func TestContactsAliasTagsAndSearch(t *testing.T) { t.Fatalf("expected remaining tag friends, got %v", c.Tags) } } + +func TestContactSystemNamePrecedenceAndSearch(t *testing.T) { + db := openTestDB(t) + + jid := "111@s.whatsapp.net" + if err := db.UpsertContact(jid, "111", "Push", "Full Name", "First", "Biz"); err != nil { + t.Fatalf("UpsertContact: %v", err) + } + if err := db.SetSystemName(jid, "System Alice"); err != nil { + t.Fatalf("SetSystemName: %v", err) + } + + c, err := db.GetContact(jid) + if err != nil { + t.Fatalf("GetContact: %v", err) + } + if c.Name != "System Alice" || c.SystemName != "System Alice" { + t.Fatalf("contact = %#v", c) + } + + found, err := db.SearchContacts("System", 10) + if err != nil { + t.Fatalf("SearchContacts: %v", err) + } + if len(found) != 1 || found[0].JID != jid { + t.Fatalf("expected system-name match, got %#v", found) + } + + if err := db.SetAlias(jid, "Alias Alice"); err != nil { + t.Fatalf("SetAlias: %v", err) + } + c, err = db.GetContact(jid) + if err != nil { + t.Fatalf("GetContact after alias: %v", err) + } + if c.Name != "Alias Alice" { + t.Fatalf("alias should win over system name, got %#v", c) + } + + count, err := db.CountSystemNames() + if err != nil { + t.Fatalf("CountSystemNames: %v", err) + } + if count != 1 { + t.Fatalf("system name count = %d, want 1", count) + } + cleared, err := db.ClearAllSystemNames() + if err != nil { + t.Fatalf("ClearAllSystemNames: %v", err) + } + if cleared != 1 { + t.Fatalf("cleared = %d, want 1", cleared) + } +} diff --git a/internal/store/migrations.go b/internal/store/migrations.go index 5aec9b1..f85a7ff 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -25,6 +25,7 @@ var schemaMigrations = []migration{ {version: 9, name: "messages deleted_for_me column", up: migrateMessagesDeletedForMeColumn}, {version: 10, name: "chat state columns", up: migrateChatStateColumns}, {version: 11, name: "group hierarchy columns", up: migrateGroupHierarchyColumns}, + {version: 12, name: "contacts system_name column", up: migrateContactsSystemNameColumn}, } func (d *DB) ensureSchema() error { @@ -237,6 +238,27 @@ func migrateGroupHierarchyColumns(d *DB) error { return nil } +func migrateContactsSystemNameColumn(d *DB) error { + hasContacts, err := d.tableExists("contacts") + if err != nil { + return err + } + if !hasContacts { + return nil + } + hasSystemName, err := d.tableHasColumn("contacts", "system_name") + if err != nil { + return err + } + if hasSystemName { + return nil + } + if _, err := d.sql.Exec(`ALTER TABLE contacts ADD COLUMN system_name TEXT`); err != nil { + return fmt.Errorf("add contacts.system_name column: %w", err) + } + return nil +} + func migrateMessagesFTS(d *DB) error { ftsExists, err := d.tableExists("messages_fts") if err != nil { diff --git a/internal/store/schema.go b/internal/store/schema.go index cb6cceb..3889232 100644 --- a/internal/store/schema.go +++ b/internal/store/schema.go @@ -21,6 +21,7 @@ const coreSchemaSQL = ` full_name TEXT, first_name TEXT, business_name TEXT, + system_name TEXT, updated_at INTEGER NOT NULL ); diff --git a/internal/store/schema_test.go b/internal/store/schema_test.go index 5877c19..c7984e5 100644 --- a/internal/store/schema_test.go +++ b/internal/store/schema_test.go @@ -63,6 +63,14 @@ func TestOpenCreatesExpectedSchema(t *testing.T) { if !indexExists(t, db.sql, "idx_groups_linked_parent_jid") { t.Fatalf("expected linked-parent group index to exist") } + + contactCols, err := tableColumns(db.sql, "contacts") + if err != nil { + t.Fatalf("contacts tableColumns: %v", err) + } + if !contactCols["system_name"] { + t.Fatalf("expected contacts system_name column to exist") + } } func TestOpenMigratesGroupHierarchyColumns(t *testing.T) { @@ -125,6 +133,62 @@ func TestOpenMigratesGroupHierarchyColumns(t *testing.T) { } } +func TestOpenMigratesContactsSystemNameColumn(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "wacli.db") + + raw, err := sql.Open("sqlite3", path) + if err != nil { + t.Fatalf("sql.Open: %v", err) + } + if _, err := raw.Exec(` + CREATE TABLE schema_migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at INTEGER NOT NULL + ); + CREATE TABLE contacts ( + jid TEXT PRIMARY KEY, + phone TEXT, + push_name TEXT, + full_name TEXT, + first_name TEXT, + business_name TEXT, + updated_at INTEGER NOT NULL + ); + INSERT INTO contacts(jid, phone, updated_at) VALUES('111@s.whatsapp.net', '111', 1); + `); err != nil { + _ = raw.Close() + t.Fatalf("create old contacts schema: %v", err) + } + for _, m := range schemaMigrations { + if m.version >= 12 { + continue + } + if _, err := raw.Exec(`INSERT INTO schema_migrations(version, name, applied_at) VALUES(?, ?, 1)`, m.version, m.name); err != nil { + _ = raw.Close() + t.Fatalf("mark migration %d: %v", m.version, err) + } + } + if err := raw.Close(); err != nil { + t.Fatalf("raw close: %v", err) + } + + db, err := Open(path) + if err != nil { + t.Fatalf("Open migrated DB: %v", err) + } + defer db.Close() + + contactCols, err := tableColumns(db.sql, "contacts") + if err != nil { + t.Fatalf("contacts tableColumns: %v", err) + } + if !contactCols["system_name"] { + t.Fatalf("expected migrated contacts system_name column") + } +} + func tableColumns(db *sql.DB, table string) (map[string]bool, error) { rows, err := db.Query("PRAGMA table_info(" + table + ")") if err != nil { diff --git a/internal/store/types.go b/internal/store/types.go index b0b6be9..2b42741 100644 --- a/internal/store/types.go +++ b/internal/store/types.go @@ -95,12 +95,13 @@ type MessageInfo struct { } type Contact struct { - JID string - Phone string - Name string - Alias string - Tags []string - UpdatedAt time.Time + JID string `json:"jid"` + Phone string `json:"phone"` + Name string `json:"name"` + Alias string `json:"alias"` + SystemName string `json:"system_name"` + Tags []string `json:"tags,omitempty"` + UpdatedAt time.Time `json:"updated_at"` } func unix(t time.Time) int64 { diff --git a/internal/syscontacts/contacts_export.swift b/internal/syscontacts/contacts_export.swift new file mode 100644 index 0000000..792a14c --- /dev/null +++ b/internal/syscontacts/contacts_export.swift @@ -0,0 +1,79 @@ +import Contacts +import Foundation + +struct WacliContact: Codable { + let first_name: String + let last_name: String + let full_name: String + let phones: [String] +} + +func fail(_ message: String) -> Never { + FileHandle.standardError.write(Data((message + "\n").utf8)) + exit(1) +} + +let store = CNContactStore() +let status = CNContactStore.authorizationStatus(for: .contacts) + +switch status { +case .authorized: + break +case .notDetermined: + let sem = DispatchSemaphore(value: 0) + var granted = false + var requestError: Error? + store.requestAccess(for: .contacts) { ok, err in + granted = ok + requestError = err + sem.signal() + } + _ = sem.wait(timeout: .now() + 60) + if !granted { + if let requestError { + fail("Contacts access denied: \(requestError.localizedDescription)") + } + fail("Contacts access denied. Grant access in System Settings > Privacy & Security > Contacts.") + } +case .denied, .restricted: + fail("Contacts access denied. Grant access in System Settings > Privacy & Security > Contacts.") +@unknown default: + fail("Contacts access is unavailable for this process.") +} + +let keys: [CNKeyDescriptor] = [ + CNContactFormatter.descriptorForRequiredKeys(for: .fullName), + CNContactOrganizationNameKey as CNKeyDescriptor, + CNContactPhoneNumbersKey as CNKeyDescriptor, +] + +let request = CNContactFetchRequest(keysToFetch: keys) +let encoder = JSONEncoder() + +do { + try store.enumerateContacts(with: request) { contact, _ in + let phones = contact.phoneNumbers + .map { $0.value.stringValue } + .filter { !$0.isEmpty } + guard !phones.isEmpty else { return } + + var fullName = CNContactFormatter.string(from: contact, style: .fullName) ?? "" + if fullName.isEmpty { + fullName = contact.organizationName + } + guard !fullName.isEmpty else { return } + + let row = WacliContact( + first_name: contact.givenName, + last_name: contact.familyName, + full_name: fullName, + phones: phones + ) + if let data = try? encoder.encode(row), + let line = String(data: data, encoding: .utf8) { + print(line) + } + } +} catch { + fail("Failed to enumerate Contacts: \(error.localizedDescription)") +} diff --git a/internal/syscontacts/darwin.go b/internal/syscontacts/darwin.go new file mode 100644 index 0000000..948006b --- /dev/null +++ b/internal/syscontacts/darwin.go @@ -0,0 +1,46 @@ +//go:build darwin + +package syscontacts + +import ( + "bytes" + "context" + _ "embed" + "fmt" + "os" + "os/exec" + "path/filepath" +) + +//go:embed contacts_export.swift +var contactsExportSwift string + +func ReadSystem(ctx context.Context) ([]Contact, error) { + dir, err := os.MkdirTemp("", "wacli-contacts-*") + if err != nil { + return nil, err + } + defer os.RemoveAll(dir) + + script := filepath.Join(dir, "contacts-export.swift") + if err := os.WriteFile(script, []byte(contactsExportSwift), 0o600); err != nil { + return nil, err + } + + cmd := swiftCommand(ctx, script) + out, err := cmd.Output() + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + return nil, fmt.Errorf("read macOS Contacts: %s", string(ee.Stderr)) + } + return nil, fmt.Errorf("run swift Contacts helper: %w", err) + } + return Decode(bytes.NewReader(out)) +} + +func swiftCommand(ctx context.Context, script string) *exec.Cmd { + if path, err := exec.LookPath("swift"); err == nil { + return exec.CommandContext(ctx, path, script) + } + return exec.CommandContext(ctx, "xcrun", "swift", script) +} diff --git a/internal/syscontacts/other.go b/internal/syscontacts/other.go new file mode 100644 index 0000000..7ae01eb --- /dev/null +++ b/internal/syscontacts/other.go @@ -0,0 +1,9 @@ +//go:build !darwin + +package syscontacts + +import "context" + +func ReadSystem(ctx context.Context) ([]Contact, error) { + return nil, UnsupportedError() +} diff --git a/internal/syscontacts/syscontacts.go b/internal/syscontacts/syscontacts.go new file mode 100644 index 0000000..c5197fb --- /dev/null +++ b/internal/syscontacts/syscontacts.go @@ -0,0 +1,105 @@ +package syscontacts + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "os" + "strings" + "unicode" +) + +type Contact struct { + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + FullName string `json:"full_name"` + Phones []string `json:"phones"` +} + +func (c Contact) Name() string { + if strings.TrimSpace(c.FullName) != "" { + return strings.TrimSpace(c.FullName) + } + return strings.TrimSpace(strings.Join([]string{c.FirstName, c.LastName}, " ")) +} + +func ReadFile(path string) ([]Contact, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + return Decode(f) +} + +func Decode(r io.Reader) ([]Contact, error) { + raw, err := io.ReadAll(r) + if err != nil { + return nil, err + } + trimmed := strings.TrimSpace(string(raw)) + if trimmed == "" { + return nil, nil + } + if strings.HasPrefix(trimmed, "[") { + var contacts []Contact + if err := json.Unmarshal([]byte(trimmed), &contacts); err != nil { + return nil, err + } + return contacts, nil + } + + var contacts []Contact + scanner := bufio.NewScanner(strings.NewReader(trimmed)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var c Contact + if err := json.Unmarshal([]byte(line), &c); err != nil { + return nil, err + } + contacts = append(contacts, c) + } + return contacts, scanner.Err() +} + +func PhoneToName(contacts []Contact) map[string]string { + out := map[string]string{} + for _, c := range contacts { + name := c.Name() + if name == "" { + continue + } + for _, phone := range c.Phones { + normalized := NormalizePhone(phone) + if len(normalized) < 7 { + continue + } + if _, exists := out[normalized]; !exists { + out[normalized] = name + } + } + } + return out +} + +func NormalizePhone(phone string) string { + var b strings.Builder + for _, r := range phone { + if unicode.IsDigit(r) { + b.WriteRune(r) + } + } + out := b.String() + if strings.HasPrefix(out, "00") { + out = strings.TrimPrefix(out, "00") + } + return out +} + +func UnsupportedError() error { + return fmt.Errorf("system contacts import is only supported on macOS; pass --input with JSON/NDJSON contacts to import from a file") +} diff --git a/internal/syscontacts/syscontacts_test.go b/internal/syscontacts/syscontacts_test.go new file mode 100644 index 0000000..94db3c3 --- /dev/null +++ b/internal/syscontacts/syscontacts_test.go @@ -0,0 +1,48 @@ +package syscontacts + +import ( + "strings" + "testing" +) + +func TestNormalizePhone(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"+1 (415) 734-7847", "14157347847"}, + {"0043 664 104 2436", "436641042436"}, + {"14157347847", "14157347847"}, + {"", ""}, + } + for _, tt := range tests { + if got := NormalizePhone(tt.in); got != tt.want { + t.Fatalf("NormalizePhone(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestDecodeSupportsJSONArrayAndNDJSON(t *testing.T) { + for _, input := range []string{ + `[{"full_name":"Alice","phones":["+1 (415) 734-7847"]}]`, + "{\"full_name\":\"Alice\",\"phones\":[\"+1 (415) 734-7847\"]}\n", + } { + contacts, err := Decode(strings.NewReader(input)) + if err != nil { + t.Fatalf("Decode(%q): %v", input, err) + } + if len(contacts) != 1 || contacts[0].Name() != "Alice" { + t.Fatalf("contacts = %#v", contacts) + } + } +} + +func TestPhoneToNameKeepsFirstNameForDuplicateNumber(t *testing.T) { + got := PhoneToName([]Contact{ + {FullName: "Alice", Phones: []string{"+1 (415) 734-7847"}}, + {FullName: "Other", Phones: []string{"14157347847"}}, + }) + if got["14157347847"] != "Alice" { + t.Fatalf("phone map = %#v", got) + } +}