From 03665bbe1140d472ddac6be4dce6692b96a38daf Mon Sep 17 00:00:00 2001 From: joshp123 Date: Fri, 5 Jun 2026 00:38:30 +0200 Subject: [PATCH 1/6] feat: expose contact export command --- internal/cli/cli.go | 115 +++++++++++++++++++++++++++++++++++++++ internal/cli/cli_test.go | 98 +++++++++++++++++++++++++++++++++ internal/cli/control.go | 9 +-- internal/store/export.go | 4 ++ 4 files changed, 222 insertions(+), 4 deletions(-) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 0699e43..f17e501 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -12,6 +12,7 @@ import ( "strings" "text/tabwriter" "time" + "unicode" "github.com/steipete/wacrawl/internal/backup" "github.com/steipete/wacrawl/internal/store" @@ -97,6 +98,8 @@ func Run(ctx context.Context, args []string, stdout, stderr io.Writer) error { return a.runStatus(ctx, rest[1:]) case "chats": return a.runChats(ctx, rest[1:]) + case "contacts": + return a.runContacts(ctx, rest[1:]) case "unread": return a.runUnread(ctx, rest[1:]) case "messages": @@ -200,6 +203,107 @@ func (a *app) runImport(ctx context.Context, command string, args []string) erro }) } +type contactExport struct { + Contacts []exportedContact `json:"contacts"` +} + +type exportedContact struct { + DisplayName string `json:"display_name"` + PhoneNumbers []string `json:"phone_numbers"` +} + +func (a *app) runContacts(ctx context.Context, args []string) error { + if len(args) == 0 || args[0] != "export" { + return usageErr(errors.New("contacts supports export only")) + } + fs := flag.NewFlagSet("contacts export", flag.ContinueOnError) + fs.SetOutput(io.Discard) + if err := fs.Parse(args[1:]); err != nil { + if errors.Is(err, flag.ErrHelp) { + printCommandUsage(a.stdout, "contacts", "export") + return nil + } + return usageErr(err) + } + if fs.NArg() != 0 { + return usageErr(errors.New("contacts export takes no arguments")) + } + return a.withStore(ctx, func(st *store.Store) error { + contacts, err := st.Contacts(ctx) + if err != nil { + return err + } + return a.print(contactExport{Contacts: exportContacts(contacts)}) + }) +} + +func exportContacts(contacts []store.Contact) []exportedContact { + out := make([]exportedContact, 0, len(contacts)) + for _, contact := range contacts { + name := contactDisplayName(contact) + phone := strings.TrimSpace(contact.Phone) + if name == "" || phone == "" { + continue + } + out = append(out, exportedContact{DisplayName: name, PhoneNumbers: []string{phone}}) + } + return out +} + +func contactDisplayName(contact store.Contact) string { + for _, name := range []string{ + contact.FullName, + contact.BusinessName, + strings.TrimSpace(contact.FirstName + " " + contact.LastName), + } { + if cleaned := cleanContactName(name, contact); cleaned != "" { + return cleaned + } + } + return "" +} + +func cleanContactName(name string, contact store.Contact) string { + name = strings.TrimSpace(name) + switch { + case name == "": + return "" + case name == strings.TrimSpace(contact.Phone): + return "" + case name == strings.TrimSpace(contact.JID): + return "" + case name == strings.TrimSpace(contact.Username): + return "" + case name == strings.TrimSpace(contact.LID): + return "" + case strings.HasPrefix(name, "@"): + return "" + case looksLikePhone(name): + return "" + default: + return name + } +} + +func looksLikePhone(value string) bool { + value = strings.TrimSpace(value) + if value == "" { + return false + } + digits := 0 + other := 0 + for _, r := range value { + switch { + case unicode.IsDigit(r): + digits++ + case strings.ContainsRune(" +()-.", r): + default: + other++ + } + } + return digits >= 5 && other == 0 +} + func (a *app) runChats(ctx context.Context, args []string) error { fs := flag.NewFlagSet("chats", flag.ContinueOnError) fs.SetOutput(io.Discard) @@ -477,6 +581,7 @@ Commands: sync Alias for import. status Show archive status. chats List chats. + contacts Export archived contacts for clawdex. unread List chats with unread messages. messages List archived messages. search Search archived messages. @@ -497,6 +602,7 @@ Examples: wacrawl doctor wacrawl sync wacrawl unread --limit 20 + wacrawl --json --sync never contacts export wacrawl --json search "invoice" --from-them --after 2026-01-01 wacrawl help messages `) @@ -567,6 +673,15 @@ Examples: wacrawl chats --limit 20 wacrawl chats --unread wacrawl --json chats --limit 100 +`) + case "contacts", "contacts export": + _, _ = fmt.Fprint(w, `Export archived contacts for clawdex. + +Usage: + wacrawl --json --sync never contacts export + +Examples: + wacrawl --json --sync never contacts export `) case "unread": _, _ = fmt.Fprint(w, `List chats with unread messages. diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 90b95fc..c58f26f 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -4,11 +4,13 @@ import ( "bytes" "context" "database/sql" + "encoding/json" "errors" "flag" "os" "os/exec" "path/filepath" + "reflect" "strings" "testing" "time" @@ -37,6 +39,7 @@ func TestRunEndToEnd(t *testing.T) { {"import copy media", []string{"--db", dbPath, "--source", source, "import", "--copy-media"}, "media_copied=1"}, {"status", []string{"--db", dbPath, "status"}, "unread_messages=2"}, {"chats", []string{"--db", dbPath, "chats", "--limit", "5"}, "UNREAD"}, + {"contacts export", []string{"--db", dbPath, "--json", "--sync", "never", "contacts", "export"}, `"display_name": "Alice Contact"`}, {"chats unread", []string{"--db", dbPath, "chats", "--unread", "--limit", "5"}, "Launch Group"}, {"unread", []string{"--db", dbPath, "unread", "--limit", "5"}, "Launch Group"}, {"messages", []string{"--db", dbPath, "messages", "--chat", "123@g.us", "--asc"}, "launch now"}, @@ -56,6 +59,101 @@ func TestRunEndToEnd(t *testing.T) { } } +func TestContactsExportUsesContractShapeAndSkipsUnsafeNames(t *testing.T) { + ctx := context.Background() + dbPath := filepath.Join(t.TempDir(), "archive.db") + st, err := store.Open(ctx, dbPath) + if err != nil { + t.Fatal(err) + } + defer func() { _ = st.Close() }() + contacts := []store.Contact{ + {JID: "safe@s.whatsapp.net", Phone: "+15550100", FullName: "Safe Person"}, + {JID: "business@s.whatsapp.net", Phone: "+15550101", BusinessName: "Business Name"}, + {JID: "first-last@s.whatsapp.net", Phone: "+15550102", FirstName: "First", LastName: "Last"}, + {JID: "username@s.whatsapp.net", Phone: "+15550103", Username: "handle", FullName: "@handle"}, + {JID: "phone@s.whatsapp.net", Phone: "+15550104", FullName: "+15550104"}, + {JID: "jid@s.whatsapp.net", Phone: "+15550105", FullName: "jid@s.whatsapp.net"}, + {JID: "blank@s.whatsapp.net", Phone: "+15550106"}, + {JID: "missing-phone@s.whatsapp.net", FullName: "Missing Phone"}, + } + if err := st.ReplaceAll(ctx, store.ImportStats{}, contacts, nil, nil, nil, nil); err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + if err := Run(ctx, []string{"--db", dbPath, "--json", "--sync", "always", "contacts", "export"}, &stdout, &stderr); err != nil { + t.Fatalf("contacts export: %v stderr=%s", err, stderr.String()) + } + var payload struct { + Contacts []struct { + DisplayName string `json:"display_name"` + PhoneNumbers []string `json:"phone_numbers"` + JID string `json:"jid"` + Username string `json:"username"` + } `json:"contacts"` + } + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("json = %s err=%v", stdout.String(), err) + } + assertContactExportKeys(t, stdout.Bytes()) + gotNames := make([]string, 0, len(payload.Contacts)) + for _, contact := range payload.Contacts { + gotNames = append(gotNames, contact.DisplayName) + if contact.JID != "" || contact.Username != "" { + t.Fatalf("leaked source fields = %#v", contact) + } + if len(contact.PhoneNumbers) != 1 { + t.Fatalf("bad phone numbers = %#v", contact) + } + } + wantNames := []string{"Business Name", "First Last", "Safe Person"} + if !reflect.DeepEqual(gotNames, wantNames) { + t.Fatalf("names = %#v, want %#v", gotNames, wantNames) + } +} + +func assertContactExportKeys(t *testing.T, data []byte) { + t.Helper() + var root map[string]json.RawMessage + if err := json.Unmarshal(data, &root); err != nil { + t.Fatal(err) + } + contactsJSON, ok := root["contacts"] + if !ok || len(root) != 1 { + t.Fatalf("root keys = %#v, want only contacts", root) + } + var contacts []map[string]json.RawMessage + if err := json.Unmarshal(contactsJSON, &contacts); err != nil { + t.Fatal(err) + } + for _, contact := range contacts { + if _, ok := contact["display_name"]; !ok { + t.Fatalf("contact keys = %#v, missing display_name", contact) + } + if _, ok := contact["phone_numbers"]; !ok { + t.Fatalf("contact keys = %#v, missing phone_numbers", contact) + } + if len(contact) != 2 { + t.Fatalf("contact keys = %#v, want only display_name and phone_numbers", contact) + } + } +} + +func TestMetadataAdvertisesContactExport(t *testing.T) { + manifest := controlManifest() + command, ok := manifest.Commands["contact-export"] + if !ok { + t.Fatalf("commands = %#v", manifest.Commands) + } + if command.Mutates || !command.JSON { + t.Fatalf("contact-export command = %#v", command) + } + want := []string{"wacrawl", "--json", "--sync", "never", "contacts", "export"} + if !reflect.DeepEqual(command.Argv, want) { + t.Fatalf("argv = %#v, want %#v", command.Argv, want) + } +} + func TestRunUsageErrors(t *testing.T) { var stdout, stderr bytes.Buffer if err := Run(context.Background(), nil, &stdout, &stderr); err != nil { diff --git a/internal/cli/control.go b/internal/cli/control.go index e615055..c454dab 100644 --- a/internal/cli/control.go +++ b/internal/cli/control.go @@ -19,10 +19,11 @@ func controlManifest() control.Manifest { m.Capabilities = []string{"metadata", "doctor", "status", "sync", "search", "backup"} m.Privacy = control.Privacy{ContainsPrivateMessages: true, ExportsSecrets: false, LocalOnlyScopes: []string{"whatsapp-desktop", "sqlite", "encrypted-git-backup"}} m.Commands = map[string]control.Command{ - "doctor": {Title: "Doctor", Argv: []string{"wacrawl", "--json", "doctor"}, JSON: true}, - "status": {Title: "Status", Argv: []string{"wacrawl", "--json", "--sync", "never", "status"}, JSON: true}, - "sync": {Title: "Sync", Argv: []string{"wacrawl", "--json", "sync"}, JSON: true, Mutates: true}, - "search": {Title: "Search", Argv: []string{"wacrawl", "--json", "--sync", "auto", "search"}, JSON: true}, + "doctor": {Title: "Doctor", Argv: []string{"wacrawl", "--json", "doctor"}, JSON: true}, + "status": {Title: "Status", Argv: []string{"wacrawl", "--json", "--sync", "never", "status"}, JSON: true}, + "sync": {Title: "Sync", Argv: []string{"wacrawl", "--json", "sync"}, JSON: true, Mutates: true}, + "search": {Title: "Search", Argv: []string{"wacrawl", "--json", "--sync", "auto", "search"}, JSON: true}, + "contact-export": {Title: "Export contacts", Argv: []string{"wacrawl", "--json", "--sync", "never", "contacts", "export"}, JSON: true}, } return m } diff --git a/internal/store/export.go b/internal/store/export.go index 6b8d284..0f8ee1b 100644 --- a/internal/store/export.go +++ b/internal/store/export.go @@ -64,6 +64,10 @@ func (s *Store) ExportAll(ctx context.Context) (SnapshotData, error) { return SnapshotData{Contacts: contacts, Chats: chats, Groups: groups, Participants: participants, Messages: messages}, nil } +func (s *Store) Contacts(ctx context.Context) ([]Contact, error) { + return s.exportContacts(ctx) +} + func (s *Store) ImportSnapshot(ctx context.Context, data SnapshotData, sourcePath string, finishedAt time.Time) error { return s.ReplaceAll(ctx, data.ImportStats(sourcePath, s.Path(), finishedAt), data.Contacts, data.Chats, data.Groups, data.Participants, data.Messages) } From 5e59ed82c37a88491d7dabb41b24309aba546f87 Mon Sep 17 00:00:00 2001 From: joshp123 Date: Fri, 5 Jun 2026 12:46:17 +0200 Subject: [PATCH 2/6] fix: honor sync policy for contact export --- internal/cli/cli.go | 6 +++--- internal/cli/cli_test.go | 30 +++++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index f17e501..bbb4c83 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -228,7 +228,7 @@ func (a *app) runContacts(ctx context.Context, args []string) error { if fs.NArg() != 0 { return usageErr(errors.New("contacts export takes no arguments")) } - return a.withStore(ctx, func(st *store.Store) error { + return a.withArchiveStore(ctx, func(st *store.Store) error { contacts, err := st.Contacts(ctx) if err != nil { return err @@ -581,7 +581,7 @@ Commands: sync Alias for import. status Show archive status. chats List chats. - contacts Export archived contacts for clawdex. + contacts Export archived contacts. unread List chats with unread messages. messages List archived messages. search Search archived messages. @@ -675,7 +675,7 @@ Examples: wacrawl --json chats --limit 100 `) case "contacts", "contacts export": - _, _ = fmt.Fprint(w, `Export archived contacts for clawdex. + _, _ = fmt.Fprint(w, `Export archived contacts. Usage: wacrawl --json --sync never contacts export diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index c58f26f..98abb90 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -81,7 +81,7 @@ func TestContactsExportUsesContractShapeAndSkipsUnsafeNames(t *testing.T) { t.Fatal(err) } var stdout, stderr bytes.Buffer - if err := Run(ctx, []string{"--db", dbPath, "--json", "--sync", "always", "contacts", "export"}, &stdout, &stderr); err != nil { + if err := Run(ctx, []string{"--db", dbPath, "--json", "--sync", "never", "contacts", "export"}, &stdout, &stderr); err != nil { t.Fatalf("contacts export: %v stderr=%s", err, stderr.String()) } var payload struct { @@ -110,6 +110,13 @@ func TestContactsExportUsesContractShapeAndSkipsUnsafeNames(t *testing.T) { if !reflect.DeepEqual(gotNames, wantNames) { t.Fatalf("names = %#v, want %#v", gotNames, wantNames) } + + stdout.Reset() + stderr.Reset() + err = Run(ctx, []string{"--db", dbPath, "--source", filepath.Join(t.TempDir(), "missing"), "--sync", "always", "contacts", "export"}, &stdout, &stderr) + if err == nil || !strings.Contains(err.Error(), "source unavailable") { + t.Fatalf("expected --sync always to fail without source, got %v", err) + } } func assertContactExportKeys(t *testing.T, data []byte) { @@ -305,6 +312,27 @@ func TestReadCommandsSyncArchive(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "source unavailable") { t.Fatalf("expected --sync always to fail without source, got %v", err) } + + stdout.Reset() + stderr.Reset() + if err := Run(ctx, []string{"--db", filepath.Join(t.TempDir(), "contacts.db"), "--source", source, "--sync", "always", "--json", "contacts", "export"}, &stdout, &stderr); err != nil { + t.Fatalf("contacts export --sync always error = %v stderr=%s", err, stderr.String()) + } + if !strings.Contains(stdout.String(), `"display_name": "Alice Contact"`) { + t.Fatalf("contacts export should sync before reading:\n%s", stdout.String()) + } + if !strings.Contains(stderr.String(), "sync: syncing WhatsApp Desktop snapshot") { + t.Fatalf("contacts export should report sync before reading, got %q", stderr.String()) + } + + stdout.Reset() + stderr.Reset() + if err := Run(ctx, []string{"--db", filepath.Join(t.TempDir(), "contacts.db"), "--source", source, "--sync", "never", "--json", "contacts", "export"}, &stdout, &stderr); err != nil { + t.Fatalf("contacts export --sync never error = %v stderr=%s", err, stderr.String()) + } + if strings.Contains(stdout.String(), `"display_name"`) { + t.Fatalf("contacts export should stay archive-only with --sync never:\n%s", stdout.String()) + } } func TestBackupCommands(t *testing.T) { From aaaaf11dc545c243255e1b7d999d19b589159136 Mon Sep 17 00:00:00 2001 From: joshp123 Date: Fri, 5 Jun 2026 12:56:21 +0200 Subject: [PATCH 3/6] docs: clarify contacts export usage --- internal/cli/cli.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index bbb4c83..3190452 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -678,7 +678,7 @@ Examples: _, _ = fmt.Fprint(w, `Export archived contacts. Usage: - wacrawl --json --sync never contacts export + wacrawl [--json] [--sync auto|always|never] contacts export Examples: wacrawl --json --sync never contacts export From 8c66ea279dbb731efa2fbce5776a2ea236f9f711 Mon Sep 17 00:00:00 2001 From: joshp123 Date: Fri, 5 Jun 2026 13:23:21 +0200 Subject: [PATCH 4/6] fix: sync contact-only source changes --- internal/cli/cli_test.go | 33 +++++++++++++++++++++++++++++++++ internal/cli/sync.go | 3 +++ 2 files changed, 36 insertions(+) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 98abb90..0d1425b 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -325,6 +325,26 @@ func TestReadCommandsSyncArchive(t *testing.T) { t.Fatalf("contacts export should report sync before reading, got %q", stderr.String()) } + addDesktopContact(t, source, "333@s.whatsapp.net", "+333", "Charlie Contact") + autoDB := filepath.Join(t.TempDir(), "auto-contacts.db") + stdout.Reset() + stderr.Reset() + if err := Run(ctx, []string{"--db", autoDB, "--source", source, "--sync", "always", "--json", "status"}, &stdout, &stderr); err != nil { + t.Fatalf("seed contact auto-sync archive: %v stderr=%s", err, stderr.String()) + } + addDesktopContact(t, source, "444@s.whatsapp.net", "+444", "Delta Contact") + stdout.Reset() + stderr.Reset() + if err := Run(ctx, []string{"--db", autoDB, "--source", source, "--sync", "auto", "--sync-max-age", "0s", "--json", "contacts", "export"}, &stdout, &stderr); err != nil { + t.Fatalf("contacts export --sync auto error = %v stderr=%s", err, stderr.String()) + } + if !strings.Contains(stdout.String(), `"display_name": "Delta Contact"`) { + t.Fatalf("contacts export should auto-sync contact count drift:\n%s", stdout.String()) + } + if !strings.Contains(stderr.String(), "sync: syncing WhatsApp Desktop snapshot") { + t.Fatalf("contacts export should report contact drift sync, got %q", stderr.String()) + } + stdout.Reset() stderr.Reset() if err := Run(ctx, []string{"--db", filepath.Join(t.TempDir(), "contacts.db"), "--source", source, "--sync", "never", "--json", "contacts", "export"}, &stdout, &stderr); err != nil { @@ -335,6 +355,19 @@ func TestReadCommandsSyncArchive(t *testing.T) { } } +func addDesktopContact(t *testing.T, dir, jid, phone, name string) { + t.Helper() + db, err := sql.Open("sqlite", filepath.Join(dir, "ContactsV2.sqlite")) + if err != nil { + t.Fatal(err) + } + defer func() { _ = db.Close() }() + _, err = db.Exec(`insert into ZWAADDRESSBOOKCONTACT values (?, ?, ?, '', '', '', '', '', '', 700000000)`, jid, phone, name) + if err != nil { + t.Fatal(err) + } +} + func TestBackupCommands(t *testing.T) { ctx := context.Background() source := t.TempDir() diff --git a/internal/cli/sync.go b/internal/cli/sync.go index 27de6dc..b8d6de8 100644 --- a/internal/cli/sync.go +++ b/internal/cli/sync.go @@ -87,6 +87,9 @@ func sourceAheadOfArchive(source whatsappdb.Source, status store.Status) bool { if source.MessageRows != 0 && source.MessageRows != status.Messages { return true } + if source.ContactRows != 0 && source.ContactRows != status.Contacts { + return true + } if strings.TrimSpace(source.NewestMessage) == "" { return false } From bf86d983342519e7fa2fc80516e94d632079310b Mon Sep 17 00:00:00 2001 From: joshp123 Date: Fri, 5 Jun 2026 13:33:34 +0200 Subject: [PATCH 5/6] fix: detect contact edit freshness --- internal/cli/cli_test.go | 35 +++++++++++++++++++++++++++++++++++ internal/cli/sync.go | 20 ++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 0d1425b..6fe4074 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -345,6 +345,20 @@ func TestReadCommandsSyncArchive(t *testing.T) { t.Fatalf("contacts export should report contact drift sync, got %q", stderr.String()) } + updateDesktopContact(t, source, "444@s.whatsapp.net", "+444", "Delta Renamed") + markDesktopContactsModified(t, source, time.Now().Add(time.Second)) + stdout.Reset() + stderr.Reset() + if err := Run(ctx, []string{"--db", autoDB, "--source", source, "--sync", "auto", "--sync-max-age", "0s", "--json", "contacts", "export"}, &stdout, &stderr); err != nil { + t.Fatalf("contacts export --sync auto same-count edit error = %v stderr=%s", err, stderr.String()) + } + if !strings.Contains(stdout.String(), `"display_name": "Delta Renamed"`) { + t.Fatalf("contacts export should auto-sync contact DB mtime drift:\n%s", stdout.String()) + } + if !strings.Contains(stderr.String(), "sync: syncing WhatsApp Desktop snapshot") { + t.Fatalf("contacts export should report contact mtime drift sync, got %q", stderr.String()) + } + stdout.Reset() stderr.Reset() if err := Run(ctx, []string{"--db", filepath.Join(t.TempDir(), "contacts.db"), "--source", source, "--sync", "never", "--json", "contacts", "export"}, &stdout, &stderr); err != nil { @@ -368,6 +382,27 @@ func addDesktopContact(t *testing.T, dir, jid, phone, name string) { } } +func updateDesktopContact(t *testing.T, dir, jid, phone, name string) { + t.Helper() + db, err := sql.Open("sqlite", filepath.Join(dir, "ContactsV2.sqlite")) + if err != nil { + t.Fatal(err) + } + defer func() { _ = db.Close() }() + _, err = db.Exec(`update ZWAADDRESSBOOKCONTACT set ZPHONENUMBER = ?, ZFULLNAME = ?, ZLASTUPDATED = 700000100 where ZWHATSAPPID = ?`, phone, name, jid) + if err != nil { + t.Fatal(err) + } +} + +func markDesktopContactsModified(t *testing.T, dir string, ts time.Time) { + t.Helper() + path := filepath.Join(dir, "ContactsV2.sqlite") + if err := os.Chtimes(path, ts, ts); err != nil { + t.Fatal(err) + } +} + func TestBackupCommands(t *testing.T) { ctx := context.Background() source := t.TempDir() diff --git a/internal/cli/sync.go b/internal/cli/sync.go index b8d6de8..96563ca 100644 --- a/internal/cli/sync.go +++ b/internal/cli/sync.go @@ -3,6 +3,7 @@ package cli import ( "context" "fmt" + "os" "strings" "time" @@ -90,6 +91,9 @@ func sourceAheadOfArchive(source whatsappdb.Source, status store.Status) bool { if source.ContactRows != 0 && source.ContactRows != status.Contacts { return true } + if sqliteTriadModifiedAfter(source.ContactsDB, status.LastImportAt) { + return true + } if strings.TrimSpace(source.NewestMessage) == "" { return false } @@ -100,6 +104,22 @@ func sourceAheadOfArchive(source whatsappdb.Source, status store.Status) bool { return sourceNewest.After(status.NewestMessage) } +func sqliteTriadModifiedAfter(path string, cutoff time.Time) bool { + if strings.TrimSpace(path) == "" || cutoff.IsZero() { + return false + } + for _, suffix := range []string{"", "-wal", "-shm"} { + info, err := os.Stat(path + suffix) + if err != nil { + continue + } + if info.ModTime().After(cutoff) { + return true + } + } + return false +} + func (a *app) warnSync(format string, args ...any) { if a.stderr == nil { return From 6fa7b0a35ce55d71a1723ece1090ca911a161cea Mon Sep 17 00:00:00 2001 From: joshp123 Date: Fri, 5 Jun 2026 21:33:53 +0200 Subject: [PATCH 6/6] fix: align contact export name cleaning What: - suppress exact duplicate contact-export name and phone rows - compare unsafe contact names case-insensitively - cover duplicate and case-insensitive identity-name rejection in tests Why: - keep wacrawl behavior aligned with the shared crawler contact-export contract - avoid producer drift before clawdex imports contacts from multiple crawlers Tests: - git diff --check (pass) - nix shell nixpkgs#go --command go test ./... (pass) - nix shell nixpkgs#go --command go vet ./... (pass) - nix shell nixpkgs#go --command go build -o bin/wacrawl ./cmd/wacrawl (pass) --- internal/cli/cli.go | 20 ++++++++++++++++---- internal/cli/cli_test.go | 2 ++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 3190452..a7181e9 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -239,12 +239,18 @@ func (a *app) runContacts(ctx context.Context, args []string) error { func exportContacts(contacts []store.Contact) []exportedContact { out := make([]exportedContact, 0, len(contacts)) + seen := map[string]struct{}{} for _, contact := range contacts { name := contactDisplayName(contact) phone := strings.TrimSpace(contact.Phone) if name == "" || phone == "" { continue } + key := name + "\x00" + phone + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} out = append(out, exportedContact{DisplayName: name, PhoneNumbers: []string{phone}}) } return out @@ -268,13 +274,13 @@ func cleanContactName(name string, contact store.Contact) string { switch { case name == "": return "" - case name == strings.TrimSpace(contact.Phone): + case sameContactText(name, contact.Phone): return "" - case name == strings.TrimSpace(contact.JID): + case sameContactText(name, contact.JID): return "" - case name == strings.TrimSpace(contact.Username): + case sameContactText(name, contact.Username): return "" - case name == strings.TrimSpace(contact.LID): + case sameContactText(name, contact.LID): return "" case strings.HasPrefix(name, "@"): return "" @@ -285,6 +291,12 @@ func cleanContactName(name string, contact store.Contact) string { } } +func sameContactText(a, b string) bool { + a = strings.TrimSpace(a) + b = strings.TrimSpace(b) + return a != "" && b != "" && strings.EqualFold(a, b) +} + func looksLikePhone(value string) bool { value = strings.TrimSpace(value) if value == "" { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 6fe4074..eda8236 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -69,11 +69,13 @@ func TestContactsExportUsesContractShapeAndSkipsUnsafeNames(t *testing.T) { defer func() { _ = st.Close() }() contacts := []store.Contact{ {JID: "safe@s.whatsapp.net", Phone: "+15550100", FullName: "Safe Person"}, + {JID: "safe-duplicate@s.whatsapp.net", Phone: "+15550100", FullName: "Safe Person"}, {JID: "business@s.whatsapp.net", Phone: "+15550101", BusinessName: "Business Name"}, {JID: "first-last@s.whatsapp.net", Phone: "+15550102", FirstName: "First", LastName: "Last"}, {JID: "username@s.whatsapp.net", Phone: "+15550103", Username: "handle", FullName: "@handle"}, {JID: "phone@s.whatsapp.net", Phone: "+15550104", FullName: "+15550104"}, {JID: "jid@s.whatsapp.net", Phone: "+15550105", FullName: "jid@s.whatsapp.net"}, + {JID: "case-jid@s.whatsapp.net", Phone: "+15550107", FullName: "CASE-JID@S.WHATSAPP.NET"}, {JID: "blank@s.whatsapp.net", Phone: "+15550106"}, {JID: "missing-phone@s.whatsapp.net", FullName: "Missing Phone"}, }