add import preferences (-preferences)

This commit is contained in:
ari melody 2025-06-22 19:16:17 +01:00
parent 24db172462
commit 6bde84b7e3
Signed by: ari
GPG key ID: CF99829C92678188
3 changed files with 107 additions and 28 deletions

View file

@ -43,7 +43,7 @@ type (
Rkey string `json:"rkey"` Rkey string `json:"rkey"`
Record any `json:"record"` Record any `json:"record"`
} }
AtprotoUidCid struct { AtprotoUriCid struct {
Uri string `json:"uri"` Uri string `json:"uri"`
Cid string `json:"cid"` Cid string `json:"cid"`
} }
@ -87,7 +87,10 @@ type (
Avatar *AtprotoBlob `json:"avatar,omitempty"` Avatar *AtprotoBlob `json:"avatar,omitempty"`
Banner *AtprotoBlob `json:"banner,omitempty"` Banner *AtprotoBlob `json:"banner,omitempty"`
Labels []string `json:"labels,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 { BskyGraphFollow struct {
LexiconTypeID string `json:"$type,const=app.bsky.graph.follow"` 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 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{ reqBody, reqBodyBytes := AtprotoCreateRecord{
Repo: session.Did, Repo: session.Did,
Collection: collection, Collection: collection,
@ -167,12 +170,12 @@ func CreateAtprotoRecord(session *AtprotoSession, pdsUrl string, collection stri
json.NewDecoder(res.Body).Decode(&errBody) json.NewDecoder(res.Body).Decode(&errBody)
return nil, errors.New(fmt.Sprintf("%s: %s", errBody.Error, errBody.Message)) return nil, errors.New(fmt.Sprintf("%s: %s", errBody.Error, errBody.Message))
} }
createdRecord := AtprotoUidCid{} createdRecord := AtprotoUriCid{}
err = json.NewDecoder(res.Body).Decode(&createdRecord) err = json.NewDecoder(res.Body).Decode(&createdRecord)
return &createdRecord, err 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{ reqBody, reqBodyBytes := AtprotoPutRecord{
Repo: session.Did, Repo: session.Did,
Collection: collection, Collection: collection,
@ -193,7 +196,7 @@ func PutAtprotoRecord(session *AtprotoSession, pdsUrl string, collection string,
json.NewDecoder(res.Body).Decode(&errBody) json.NewDecoder(res.Body).Decode(&errBody)
return nil, errors.New(fmt.Sprintf("%s: %s", errBody.Error, errBody.Message)) return nil, errors.New(fmt.Sprintf("%s: %s", errBody.Error, errBody.Message))
} }
updatedRecord := AtprotoUidCid{} updatedRecord := AtprotoUriCid{}
err = json.NewDecoder(res.Body).Decode(&updatedRecord) err = json.NewDecoder(res.Body).Decode(&updatedRecord)
return &updatedRecord, err return &updatedRecord, err
} }

View file

@ -1,6 +1,7 @@
package main package main
import ( import (
"bytes"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@ -15,8 +16,9 @@ func ImportProfile(session *AtprotoSession, pdsUrl string, fromAccount string, d
fmt.Printf("fetching profile record from @%s...\n", fromAccount) fmt.Printf("fetching profile record from @%s...\n", fromAccount)
var err error var err error
fromDid := fromAccount
if !strings.HasPrefix(fromAccount, "did:") { if !strings.HasPrefix(fromAccount, "did:") {
fromAccount, err = ResolveAtprotoHandle(session, pdsUrl, fromAccount) fromDid, err = ResolveAtprotoHandle(session, pdsUrl, fromAccount)
if err != nil { if err != nil {
return errors.New(fmt.Sprintf("failed to resolve identity: %v\n", err)) 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 { if !dryrun {
// import avatar // 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)) } if err != nil { return errors.New(fmt.Sprintf("failed to download avatar: %v\n", err)) }
avatarBlob, err := UploadAtprotoBlob(session, pdsUrl, avatarBytes, profile.Avatar.MimeType) 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 newProfile.Avatar = avatarBlob
// import banner // 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)) } if err != nil { return errors.New(fmt.Sprintf("failed to download banner: %v\n", err)) }
bannerBlob, err := UploadAtprotoBlob(session, pdsUrl, bannerBytes, profile.Banner.MimeType) bannerBlob, err := UploadAtprotoBlob(session, pdsUrl, bannerBytes, profile.Banner.MimeType)
@ -159,3 +161,44 @@ func ImportFollows(session *AtprotoSession, pdsUrl string, fromAccount string, d
return nil 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
}

71
main.go
View file

@ -12,15 +12,18 @@ func main() {
os.Exit(0) os.Exit(0)
} }
var srcUser string var fromUser string
var destUser string var fromPassword string
var password string var toUser string
var toPassword string
var pdsUrl string var pdsUrl string
var fromPdsUrl string
var dryrun bool = false var dryrun bool = false
var verbose bool = false var verbose bool = false
var importProfile bool = false var importProfile bool = false
var importFollows bool = false var importFollows bool = false
var importPreferences bool = false
i := 1 i := 1
for { for {
@ -40,13 +43,17 @@ func main() {
switch os.Args[i][1:] { switch os.Args[i][1:] {
case "from": case "from":
i, srcUser = getValue(i) i, fromUser = getValue(i)
case "frompass":
i, fromPassword = getValue(i)
case "to": case "to":
i, destUser = getValue(i) i, toUser = getValue(i)
case "pass": case "topass":
i, password = getValue(i) i, toPassword = getValue(i)
case "pds": case "pds":
i, pdsUrl = getValue(i) i, pdsUrl = getValue(i)
case "frompds":
i, fromPdsUrl = getValue(i)
case "dryrun": case "dryrun":
dryrun = true dryrun = true
case "v": case "v":
@ -55,19 +62,21 @@ func main() {
importProfile = true importProfile = true
case "follows": case "follows":
importFollows = true importFollows = true
case "preferences":
importPreferences = true
} }
i += 1 i += 1
} }
if srcUser == "" { if fromUser == "" {
fmt.Fprintf(os.Stderr, "missing required argument -from.\n") fmt.Fprintf(os.Stderr, "missing required argument -from.\n")
os.Exit(1) os.Exit(1)
} }
if destUser == "" { if toUser == "" {
fmt.Fprintf(os.Stderr, "missing required argument -to.\n") fmt.Fprintf(os.Stderr, "missing required argument -to.\n")
os.Exit(1) os.Exit(1)
} }
if password == "" { if toPassword == "" {
fmt.Fprintf(os.Stderr, "missing required argument -pass.\n") fmt.Fprintf(os.Stderr, "missing required argument -pass.\n")
os.Exit(1) os.Exit(1)
} }
@ -75,27 +84,49 @@ func main() {
fmt.Fprintf(os.Stderr, "missing required argument -pds.\n") fmt.Fprintf(os.Stderr, "missing required argument -pds.\n")
os.Exit(1) os.Exit(1)
} }
if fromPdsUrl == "" { fromPdsUrl = pdsUrl }
if !importProfile && !importFollows { if !(importProfile || importFollows || importPreferences) {
fmt.Fprintf(os.Stderr, "no action was specified.\n") fmt.Fprintf(os.Stderr, "no action was specified.\n")
os.Exit(1) 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 { if err != nil {
fmt.Fprintf(os.Stderr, "failed to create session: %v\n", err) 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 for destination account: %v\n", err)
os.Exit(1) os.Exit(1)
} }
if importProfile { if importProfile {
err := ImportProfile(session, pdsUrl, srcUser, dryrun, verbose) err := ImportProfile(toSession, pdsUrl, fromUser, dryrun, verbose)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "failed to import profile: %v\n", err) fmt.Fprintf(os.Stderr, "failed to import profile: %v\n", err)
os.Exit(1) 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 { if importFollows {
err := ImportFollows(session, pdsUrl, srcUser, dryrun, verbose) err := ImportFollows(toSession, pdsUrl, fromUser, dryrun, verbose)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "failed to import follows: %v\n", err) fmt.Fprintf(os.Stderr, "failed to import follows: %v\n", err)
os.Exit(1) os.Exit(1)
@ -108,16 +139,18 @@ func printHelp() {
`usage: %s <arguments...> <imports...> `usage: %s <arguments...> <imports...>
required arguments: required arguments:
-from <handle>: the account to import follow records from. -from <identifier>: the source account handle or DID.
-to <handle>: the account to import follow records to. -frompass <password>: the source account password.
-pass <password>: the password of the `+"`to`"+` account. -to <identifier>: the destination account handle or DID.
-pds <pds-url>: the full https:// url of the `+"`to`"+` account's PDS. -topass <password>: the destination account password.
-pds <pds-url>: the full https:// url of the destination account's PDS.
imports (at least one required): imports (at least one required):
-profile: imports the account profile, including avatar, banner, display name, and description. -profile: imports the account profile, including avatar, banner, display name, and description.
-follows: imports the following list of the account. -follows: imports the following list of the account.
optional arguments: optional arguments:
-frompds <pds-url>: the source account's PDS url (defaults to the value of -pds).
-dryrun: does not import follow records; good for sanity testing! -dryrun: does not import follow records; good for sanity testing!
-v: verbose output -v: verbose output
-help: shows this help message. -help: shows this help message.