feat: import system contacts

Co-authored-by: Paul Bohm <29411+enki@users.noreply.github.com>
Co-authored-by: Octavio Froid <froid@bohm.com>
This commit is contained in:
Peter Steinberger 2026-05-06 06:12:59 +01:00
parent 4b84b90a66
commit 403fda0fe7
No known key found for this signature in database
18 changed files with 820 additions and 18 deletions

View File

@ -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)

View File

@ -25,7 +25,7 @@ Full docs site: <https://wacli.sh>.
- [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: <https://wacli.sh>.
- **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 <query>`
- `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]`

View File

@ -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, ", "))
}

View File

@ -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()
}

View File

@ -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
}

View File

@ -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 <query> [--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
```

View File

@ -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.

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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 {

View File

@ -21,6 +21,7 @@ const coreSchemaSQL = `
full_name TEXT,
first_name TEXT,
business_name TEXT,
system_name TEXT,
updated_at INTEGER NOT NULL
);

View File

@ -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 {

View File

@ -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 {

View File

@ -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)")
}

View File

@ -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)
}

View File

@ -0,0 +1,9 @@
//go:build !darwin
package syscontacts
import "context"
func ReadSystem(ctx context.Context) ([]Contact, error) {
return nil, UnsupportedError()
}

View File

@ -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")
}

View File

@ -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)
}
}