Skip to content

Commit 9f12f22

Browse files
committed
feat: import crawler contact exports
1 parent 765d357 commit 9f12f22

6 files changed

Lines changed: 510 additions & 1 deletion

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module github.com/openclaw/clawdex
22

3-
go 1.26.3
3+
go 1.26.4
44

55
require (
66
github.com/alecthomas/kong v1.15.0

internal/cli/cli.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
package cli
22

33
import (
4+
"bytes"
45
"context"
56
"encoding/json"
67
"errors"
78
"fmt"
89
"io"
910
"os"
1011
"os/exec"
12+
"path/filepath"
1113
"sort"
1214
"strings"
1315
"time"
@@ -16,13 +18,15 @@ import (
1618
"github.com/openclaw/clawdex/internal/apple"
1719
"github.com/openclaw/clawdex/internal/avatar"
1820
"github.com/openclaw/clawdex/internal/birdclaw"
21+
"github.com/openclaw/clawdex/internal/contactexport"
1922
"github.com/openclaw/clawdex/internal/discrawl"
2023
"github.com/openclaw/clawdex/internal/google"
2124
"github.com/openclaw/clawdex/internal/index"
2225
"github.com/openclaw/clawdex/internal/markdown"
2326
"github.com/openclaw/clawdex/internal/model"
2427
"github.com/openclaw/clawdex/internal/repo"
2528
"github.com/openclaw/clawdex/internal/vcard"
29+
"github.com/openclaw/crawlkit/control"
2630
)
2731

2832
var Version = "dev"
@@ -414,10 +418,116 @@ func (c *SearchCmd) Run(r *Runtime) error {
414418
type ImportCmd struct {
415419
Apple ImportAppleCmd `cmd:"" help:"Import Apple Contacts into local markdown"`
416420
Birdclaw ImportBirdclawCmd `cmd:"" help:"Import X/Twitter DM contacts from local birdclaw archive"`
421+
Contacts ImportContactsCmd `cmd:"" help:"Import contacts from a source crawler"`
417422
Google ImportGoogleCmd `cmd:"" help:"Import Google Contacts into local markdown"`
418423
Discrawl ImportDiscrawlCmd `cmd:"" help:"Import Discord DM contacts from local discrawl archive"`
419424
}
420425

426+
type ImportContactsCmd struct {
427+
From string `name:"from" help:"Crawler binary to import contacts from" required:""`
428+
}
429+
430+
func (c *ImportContactsCmd) Run(r *Runtime) error {
431+
source, contacts, err := readCrawlerContacts(r.ctx, c.From)
432+
if err != nil {
433+
return err
434+
}
435+
changes, err := r.store.ImportContacts(source, contacts, r.root.DryRun, time.Now())
436+
if err != nil {
437+
return err
438+
}
439+
return r.print(changes)
440+
}
441+
442+
func readCrawlerContacts(ctx context.Context, binary string) (string, []model.SourceContact, error) {
443+
manifest, err := readCrawlerManifest(ctx, binary)
444+
if err != nil {
445+
return "", nil, err
446+
}
447+
command, ok := manifest.Commands["contact-export"]
448+
if !ok {
449+
return "", nil, fmt.Errorf("%s metadata does not advertise contact-export", binary)
450+
}
451+
if !command.JSON {
452+
return "", nil, fmt.Errorf("%s contact-export must advertise json output", binary)
453+
}
454+
if command.Mutates {
455+
return "", nil, fmt.Errorf("%s contact-export must be read-only", binary)
456+
}
457+
if len(command.Argv) == 0 {
458+
return "", nil, fmt.Errorf("%s contact-export argv is empty", binary)
459+
}
460+
argv, err := contactExportArgv(binary, command.Argv)
461+
if err != nil {
462+
return "", nil, err
463+
}
464+
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) // #nosec G204 -- argv comes from the local crawler manifest and is executed without a shell.
465+
var stderr bytes.Buffer
466+
cmd.Stderr = &stderr
467+
data, err := cmd.Output()
468+
if err != nil {
469+
msg := strings.TrimSpace(stderr.String())
470+
if msg != "" {
471+
return "", nil, fmt.Errorf("%s contact-export failed: %w: %s", binary, err, msg)
472+
}
473+
return "", nil, fmt.Errorf("%s contact-export failed: %w", binary, err)
474+
}
475+
export, err := contactexport.Decode(bytes.NewReader(data))
476+
if err != nil {
477+
return "", nil, fmt.Errorf("%s contact-export decode failed: %w", binary, err)
478+
}
479+
source := strings.TrimSpace(manifest.ID)
480+
if source == "" {
481+
source = filepath.Base(binary)
482+
}
483+
return source, sourceContactsFromExport(source, export), nil
484+
}
485+
486+
func contactExportArgv(binary string, advertised []string) ([]string, error) {
487+
if len(advertised) == 0 {
488+
return nil, fmt.Errorf("%s contact-export argv is empty", binary)
489+
}
490+
requestedName := filepath.Base(binary)
491+
advertisedName := filepath.Base(advertised[0])
492+
if requestedName != "" && advertisedName != "" && requestedName != advertisedName {
493+
return nil, fmt.Errorf("%s contact-export argv starts with %q, want %q", binary, advertised[0], requestedName)
494+
}
495+
argv := append([]string(nil), advertised...)
496+
argv[0] = binary
497+
return argv, nil
498+
}
499+
500+
func readCrawlerManifest(ctx context.Context, binary string) (control.Manifest, error) {
501+
cmd := exec.CommandContext(ctx, binary, "--json", "metadata") // #nosec G204 -- binary is an explicit local crawler command.
502+
var stderr bytes.Buffer
503+
cmd.Stderr = &stderr
504+
data, err := cmd.Output()
505+
if err != nil {
506+
msg := strings.TrimSpace(stderr.String())
507+
if msg != "" {
508+
return control.Manifest{}, fmt.Errorf("%s metadata failed: %w: %s", binary, err, msg)
509+
}
510+
return control.Manifest{}, fmt.Errorf("%s metadata failed: %w", binary, err)
511+
}
512+
var manifest control.Manifest
513+
if err := json.Unmarshal(data, &manifest); err != nil {
514+
return control.Manifest{}, fmt.Errorf("%s metadata decode failed: %w", binary, err)
515+
}
516+
return manifest, nil
517+
}
518+
519+
func sourceContactsFromExport(source string, export contactexport.ContactExport) []model.SourceContact {
520+
contacts := make([]model.SourceContact, 0, len(export.Contacts))
521+
for _, c := range export.Contacts {
522+
contact := model.SourceContact{Source: source, Name: c.DisplayName}
523+
for i, phone := range c.PhoneNumbers {
524+
contact.Phones = append(contact.Phones, model.ContactValue{Value: phone, Source: source, Primary: i == 0})
525+
}
526+
contacts = append(contacts, contact)
527+
}
528+
return contacts
529+
}
530+
421531
type ImportAppleCmd struct {
422532
Input string `name:"input" help:"JSON/NDJSON contact file instead of macOS Contacts"`
423533
Avatars bool `name:"avatars" help:"Import local avatar thumbnails"`

0 commit comments

Comments
 (0)