Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"sort"
"strings"
"time"
"unicode"

"github.com/openclaw/telecrawl/internal/backup"
"github.com/openclaw/telecrawl/internal/store"
Expand Down Expand Up @@ -466,6 +467,9 @@ func (r *runtime) runFolders(args []string) error {
}

func (r *runtime) runContacts(args []string) error {
if len(args) > 0 && args[0] == "export" {
return r.runContactsExport(args[1:])
}
fs := flag.NewFlagSet("telecrawl contacts", flag.ContinueOnError)
fs.SetOutput(io.Discard)
limit := fs.Int("limit", 100, "")
Expand All @@ -484,6 +488,94 @@ func (r *runtime) runContacts(args []string) error {
})
}

type contactExport struct {
Contacts []exportedContact `json:"contacts"`
}

type exportedContact struct {
DisplayName string `json:"display_name"`
PhoneNumbers []string `json:"phone_numbers"`
}

func (r *runtime) runContactsExport(args []string) error {
fs := flag.NewFlagSet("telecrawl contacts export", flag.ContinueOnError)
fs.SetOutput(io.Discard)
if err := fs.Parse(args); err != nil {
return usageErr(err)
}
if fs.NArg() != 0 {
return usageErr(errors.New("contacts export takes no arguments"))
}
return r.withStore(func(st *store.Store) error {
contacts, err := st.ExportContacts(r.ctx)
if err != nil {
return err
}
return r.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 {
if name := cleanContactName(contact.FullName, contact); name != "" {
return name
}
return cleanContactName(strings.TrimSpace(contact.FirstName+" "+contact.LastName), contact)
}

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 (r *runtime) runTopics(args []string) error {
fs := flag.NewFlagSet("telecrawl topics", flag.ContinueOnError)
fs.SetOutput(io.Discard)
Expand Down Expand Up @@ -810,6 +902,7 @@ usage:
telecrawl [--json] status
telecrawl [--json] folders
telecrawl [--json] contacts [--limit N]
telecrawl --json contacts export
Comment thread
joshp123 marked this conversation as resolved.
Outdated
telecrawl [--json] chats [--limit N] [--unread] [--folder ID]
telecrawl [--json] topics --chat ID [--limit N]
telecrawl [--json] messages [--chat ID] [--topic ID] [--limit N] [--after DATE]
Expand Down
110 changes: 110 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cli
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"slices"
Expand Down Expand Up @@ -77,6 +78,115 @@ func TestImportResultForChatFiltersContacts(t *testing.T) {
}
}

func TestContactsExportUsesContractShapeAndSkipsUnsafeNames(t *testing.T) {
ctx := context.Background()
db := filepath.Join(t.TempDir(), "telecrawl.db")
st, err := store.Open(ctx, db)
if err != nil {
t.Fatal(err)
}
defer func() { _ = st.Close() }()
contacts := make([]store.Contact, 0, 104)
for i := 0; i < 101; i++ {
contacts = append(contacts, store.Contact{
JID: "safe-" + string(rune('a'+(i%26))) + "-" + string(rune('a'+((i/26)%26))),
Phone: "+1555010" + strings.Repeat("0", 3-len(string(rune('0'+(i%10))))) + string(rune('0'+(i%10))),
FullName: "Safe Person",
})
}
contacts = append(contacts,
store.Contact{JID: "first-last", Phone: "+15559990001", FirstName: "First", LastName: "Last"},
store.Contact{JID: "username-only", Phone: "+15559990002", Username: "handle", FullName: "@handle"},
store.Contact{JID: "phone-only", Phone: "+15559990003", FullName: "+15559990003"},
store.Contact{JID: "jid-only", Phone: "+15559990004", FullName: "jid-only"},
store.Contact{JID: "blank-name", Phone: "+15559990005"},
store.Contact{JID: "no-phone", FullName: "No Phone"},
)
if err := st.ReplaceAll(ctx, store.ImportStats{}, contacts, nil, nil, nil, nil, nil); err != nil {
t.Fatal(err)
}
var out, errOut bytes.Buffer
err = Run(ctx, []string{"--json", "--db", db, "contacts", "export"}, &out, &errOut)
if err != nil {
t.Fatalf("contacts export: %v stderr=%s", err, errOut.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(out.Bytes(), &payload); err != nil {
t.Fatalf("json = %s err=%v", out.String(), err)
}
assertContactExportKeys(t, out.Bytes())
if len(payload.Contacts) != 102 {
t.Fatalf("contacts = %d, want 102", len(payload.Contacts))
}
var sawFirstLast bool
for _, contact := range payload.Contacts {
if contact.DisplayName == "First Last" {
sawFirstLast = true
}
if contact.DisplayName == "" || len(contact.PhoneNumbers) != 1 {
t.Fatalf("bad contact = %#v", contact)
}
if contact.JID != "" || contact.Username != "" {
t.Fatalf("leaked source fields = %#v", contact)
}
if strings.HasPrefix(contact.DisplayName, "@") || strings.HasPrefix(contact.DisplayName, "+") || contact.DisplayName == "jid-only" {
t.Fatalf("unsafe display name exported: %#v", contact)
}
}
if !sawFirstLast {
t.Fatalf("missing composed first/last name: %#v", payload.Contacts)
}
}

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{"telecrawl", "--json", "contacts", "export"}
if !slices.Equal(command.Argv, want) {
t.Fatalf("argv = %#v, want %#v", command.Argv, want)
}
}

func TestStoreImportResultPreservesArchivedMediaOnReimport(t *testing.T) {
ctx := context.Background()
st, err := store.Open(ctx, filepath.Join(t.TempDir(), "telecrawl.db"))
Expand Down
9 changes: 5 additions & 4 deletions internal/cli/control.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{"telegram-desktop", "telegram-macos-postbox", "sqlite", "encrypted-git-backup"}}
m.Commands = map[string]control.Command{
"doctor": {Title: "Doctor", Argv: []string{"telecrawl", "--json", "doctor"}, JSON: true},
"status": {Title: "Status", Argv: []string{"telecrawl", "--json", "status"}, JSON: true},
"sync": {Title: "Import", Argv: []string{"telecrawl", "--json", "import"}, JSON: true, Mutates: true},
"search": {Title: "Search", Argv: []string{"telecrawl", "--json", "search"}, JSON: true},
"doctor": {Title: "Doctor", Argv: []string{"telecrawl", "--json", "doctor"}, JSON: true},
"status": {Title: "Status", Argv: []string{"telecrawl", "--json", "status"}, JSON: true},
"sync": {Title: "Import", Argv: []string{"telecrawl", "--json", "import"}, JSON: true, Mutates: true},
"search": {Title: "Search", Argv: []string{"telecrawl", "--json", "search"}, JSON: true},
"contact-export": {Title: "Export contacts", Argv: []string{"telecrawl", "--json", "contacts", "export"}, JSON: true},
}
return m
}
4 changes: 4 additions & 0 deletions internal/store/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ func (s *Store) ListContacts(ctx context.Context, limit int) ([]Contact, error)
return s.contacts(ctx, limit)
}

func (s *Store) ExportContacts(ctx context.Context) ([]Contact, error) {
return s.allContacts(ctx)
}

func (s *Store) allContacts(ctx context.Context) ([]Contact, error) {
return s.contacts(ctx, 0)
}
Expand Down