From 6bde84b7e35b8891cef26da265e35bcda14d5b83 Mon Sep 17 00:00:00 2001 From: ari melody Date: Sun, 22 Jun 2025 19:16:17 +0100 Subject: [PATCH] add import preferences (-preferences) --- atproto.go | 15 +++++++----- funcs.go | 49 ++++++++++++++++++++++++++++++++++--- main.go | 71 +++++++++++++++++++++++++++++++++++++++--------------- 3 files changed, 107 insertions(+), 28 deletions(-) diff --git a/atproto.go b/atproto.go index 2bba9fe..fdc6a3e 100644 --- a/atproto.go +++ b/atproto.go @@ -43,7 +43,7 @@ type ( Rkey string `json:"rkey"` Record any `json:"record"` } - AtprotoUidCid struct { + AtprotoUriCid struct { Uri string `json:"uri"` Cid string `json:"cid"` } @@ -87,7 +87,10 @@ type ( Avatar *AtprotoBlob `json:"avatar,omitempty"` Banner *AtprotoBlob `json:"banner,omitempty"` Labels []string `json:"labels,omitempty"` - PinnedPost *AtprotoUidCid `json:"pinnedPost,omitempty"` + PinnedPost *AtprotoUriCid `json:"pinnedPost,omitempty"` + } + BskyActorPreferences struct { + Preferences []json.RawMessage `json:"preferences"` } BskyGraphFollow struct { LexiconTypeID string `json:"$type,const=app.bsky.graph.follow"` @@ -148,7 +151,7 @@ func ResolveAtprotoHandle(session *AtprotoSession, pdsUrl string, handle string) return identity.Did, nil } -func CreateAtprotoRecord(session *AtprotoSession, pdsUrl string, collection string, record json.RawMessage) (*AtprotoUidCid, error) { +func CreateAtprotoRecord(session *AtprotoSession, pdsUrl string, collection string, record json.RawMessage) (*AtprotoUriCid, error) { reqBody, reqBodyBytes := AtprotoCreateRecord{ Repo: session.Did, Collection: collection, @@ -167,12 +170,12 @@ func CreateAtprotoRecord(session *AtprotoSession, pdsUrl string, collection stri json.NewDecoder(res.Body).Decode(&errBody) return nil, errors.New(fmt.Sprintf("%s: %s", errBody.Error, errBody.Message)) } - createdRecord := AtprotoUidCid{} + createdRecord := AtprotoUriCid{} err = json.NewDecoder(res.Body).Decode(&createdRecord) return &createdRecord, err } -func PutAtprotoRecord(session *AtprotoSession, pdsUrl string, collection string, record any) (*AtprotoUidCid, error) { +func PutAtprotoRecord(session *AtprotoSession, pdsUrl string, collection string, record any) (*AtprotoUriCid, error) { reqBody, reqBodyBytes := AtprotoPutRecord{ Repo: session.Did, Collection: collection, @@ -193,7 +196,7 @@ func PutAtprotoRecord(session *AtprotoSession, pdsUrl string, collection string, json.NewDecoder(res.Body).Decode(&errBody) return nil, errors.New(fmt.Sprintf("%s: %s", errBody.Error, errBody.Message)) } - updatedRecord := AtprotoUidCid{} + updatedRecord := AtprotoUriCid{} err = json.NewDecoder(res.Body).Decode(&updatedRecord) return &updatedRecord, err } diff --git a/funcs.go b/funcs.go index 3275376..3e65493 100644 --- a/funcs.go +++ b/funcs.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "encoding/json" "errors" "fmt" @@ -15,8 +16,9 @@ func ImportProfile(session *AtprotoSession, pdsUrl string, fromAccount string, d fmt.Printf("fetching profile record from @%s...\n", fromAccount) var err error + fromDid := fromAccount if !strings.HasPrefix(fromAccount, "did:") { - fromAccount, err = ResolveAtprotoHandle(session, pdsUrl, fromAccount) + fromDid, err = ResolveAtprotoHandle(session, pdsUrl, fromAccount) if err != nil { return errors.New(fmt.Sprintf("failed to resolve identity: %v\n", err)) } @@ -62,7 +64,7 @@ func ImportProfile(session *AtprotoSession, pdsUrl string, fromAccount string, d if !dryrun { // import avatar - avatarBytes, err := GetAtprotoBlob(pdsUrl, fromAccount, profile.Avatar.Ref.Link) + avatarBytes, err := GetAtprotoBlob(pdsUrl, fromDid, profile.Avatar.Ref.Link) if err != nil { return errors.New(fmt.Sprintf("failed to download avatar: %v\n", err)) } avatarBlob, err := UploadAtprotoBlob(session, pdsUrl, avatarBytes, profile.Avatar.MimeType) @@ -70,7 +72,7 @@ func ImportProfile(session *AtprotoSession, pdsUrl string, fromAccount string, d newProfile.Avatar = avatarBlob // import banner - bannerBytes, err := GetAtprotoBlob(pdsUrl, fromAccount, profile.Banner.Ref.Link) + bannerBytes, err := GetAtprotoBlob(pdsUrl, fromDid, profile.Banner.Ref.Link) if err != nil { return errors.New(fmt.Sprintf("failed to download banner: %v\n", err)) } bannerBlob, err := UploadAtprotoBlob(session, pdsUrl, bannerBytes, profile.Banner.MimeType) @@ -159,3 +161,44 @@ func ImportFollows(session *AtprotoSession, pdsUrl string, fromAccount string, d return nil } +func ImportPreferences(fromSession *AtprotoSession, toSession *AtprotoSession, pdsUrl string, dryrun bool, verbose bool) error { + fmt.Printf("fetching preferences from @%s...\n", fromSession.Handle) + + req, _ := http.NewRequest(http.MethodGet, pdsUrl + "/xrpc/app.bsky.actor.getPreferences", nil) + req.Header.Set("Authorization", "Bearer " + fromSession.AccessJwt) + res, err := http.DefaultClient.Do(req) + if err != nil { + return errors.New(fmt.Sprintf("failed to fetch preferences: %v\n", err)) + } + + // // TODO: BskyGetPreferencesResponse + data := BskyActorPreferences{} + err = json.NewDecoder(res.Body).Decode(&data) + if err != nil { + return errors.New(fmt.Sprintf("failed to parse PDS response: %v\n", err)) + } + fmt.Printf("fetched %d preferences.\n", len(data.Preferences)) + + fmt.Printf("importing preferences to @%s...\n", toSession.Handle) + if !dryrun { + reqBodyBytes, _ := json.Marshal(data) + req, _ := http.NewRequest(http.MethodPost, pdsUrl + "/xrpc/app.bsky.actor.putPreferences", bytes.NewReader(reqBodyBytes)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer " + toSession.AccessJwt) + + res, err := http.DefaultClient.Do(req) + + if err != nil { + return err + } + if res.StatusCode != http.StatusOK { + errBody := ErrorResponse{} + json.NewDecoder(res.Body).Decode(&errBody) + return errors.New(fmt.Sprintf("%s: %s", errBody.Error, errBody.Message)) + } + } + + fmt.Printf("preferences imported successfully!\n") + + return nil +} diff --git a/main.go b/main.go index a6b87aa..3f24996 100644 --- a/main.go +++ b/main.go @@ -12,15 +12,18 @@ func main() { os.Exit(0) } - var srcUser string - var destUser string - var password string + var fromUser string + var fromPassword string + var toUser string + var toPassword string var pdsUrl string + var fromPdsUrl string var dryrun bool = false var verbose bool = false var importProfile bool = false var importFollows bool = false + var importPreferences bool = false i := 1 for { @@ -40,13 +43,17 @@ func main() { switch os.Args[i][1:] { case "from": - i, srcUser = getValue(i) + i, fromUser = getValue(i) + case "frompass": + i, fromPassword = getValue(i) case "to": - i, destUser = getValue(i) - case "pass": - i, password = getValue(i) + i, toUser = getValue(i) + case "topass": + i, toPassword = getValue(i) case "pds": i, pdsUrl = getValue(i) + case "frompds": + i, fromPdsUrl = getValue(i) case "dryrun": dryrun = true case "v": @@ -55,19 +62,21 @@ func main() { importProfile = true case "follows": importFollows = true + case "preferences": + importPreferences = true } i += 1 } - if srcUser == "" { + if fromUser == "" { fmt.Fprintf(os.Stderr, "missing required argument -from.\n") os.Exit(1) } - if destUser == "" { + if toUser == "" { fmt.Fprintf(os.Stderr, "missing required argument -to.\n") os.Exit(1) } - if password == "" { + if toPassword == "" { fmt.Fprintf(os.Stderr, "missing required argument -pass.\n") os.Exit(1) } @@ -75,27 +84,49 @@ func main() { fmt.Fprintf(os.Stderr, "missing required argument -pds.\n") os.Exit(1) } + if fromPdsUrl == "" { fromPdsUrl = pdsUrl } - if !importProfile && !importFollows { + if !(importProfile || importFollows || importPreferences) { fmt.Fprintf(os.Stderr, "no action was specified.\n") os.Exit(1) } - session, err := CreateSession(pdsUrl, destUser, password) + var fromSession *AtprotoSession + if !(fromPassword == "" || fromPdsUrl == "") { + var err error + fromSession, err = CreateSession(fromPdsUrl, fromUser, fromPassword) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to create session for source account: %v\n", err) + os.Exit(1) + } + } + + toSession, err := CreateSession(pdsUrl, toUser, toPassword) if err != nil { - fmt.Fprintf(os.Stderr, "failed to create session: %v\n", err) + fmt.Fprintf(os.Stderr, "failed to create session for destination account: %v\n", err) os.Exit(1) } if importProfile { - err := ImportProfile(session, pdsUrl, srcUser, dryrun, verbose) + err := ImportProfile(toSession, pdsUrl, fromUser, dryrun, verbose) if err != nil { fmt.Fprintf(os.Stderr, "failed to import profile: %v\n", err) os.Exit(1) } } + if importPreferences { + if fromSession == nil { + fmt.Fprintf(os.Stderr, "-frompass and -frompds must be provided for a preferences import.\n") + os.Exit(1) + } + err := ImportPreferences(fromSession, toSession, pdsUrl, dryrun, verbose) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to import preferences: %v\n", err) + os.Exit(1) + } + } if importFollows { - err := ImportFollows(session, pdsUrl, srcUser, dryrun, verbose) + err := ImportFollows(toSession, pdsUrl, fromUser, dryrun, verbose) if err != nil { fmt.Fprintf(os.Stderr, "failed to import follows: %v\n", err) os.Exit(1) @@ -108,16 +139,18 @@ func printHelp() { `usage: %s required arguments: - -from : the account to import follow records from. - -to : the account to import follow records to. - -pass : the password of the `+"`to`"+` account. - -pds : the full https:// url of the `+"`to`"+` account's PDS. + -from : the source account handle or DID. + -frompass : the source account password. + -to : the destination account handle or DID. + -topass : the destination account password. + -pds : the full https:// url of the destination account's PDS. imports (at least one required): -profile: imports the account profile, including avatar, banner, display name, and description. -follows: imports the following list of the account. optional arguments: + -frompds : the source account's PDS url (defaults to the value of -pds). -dryrun: does not import follow records; good for sanity testing! -v: verbose output -help: shows this help message.