tidy-up refactor, add readme

This commit is contained in:
ari melody 2025-06-22 20:49:19 +01:00
parent 6bde84b7e3
commit cd48757272
Signed by: ari
GPG key ID: CF99829C92678188
6 changed files with 104 additions and 77 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.obsidian

39
README.md Normal file
View file

@ -0,0 +1,39 @@
# migratesky 🦋
handy tools for migrating your bluesky account to a new DID!
## build
```sh
go build -o migratesky .
```
## usage
```sh
migratesky <-from <identifier>> <-frompass <password>> <-to <identifier>> <-topass <identifier>> <-pds <pds-url>> [collections...]`
```
### example
```sh
# migrate the profile, follows, and preferences from @old.example.org to @new.example.org:
migratesky -from old.example.org -frompass $SRC_PASSWORD \
-to new.example.org -topass $DEST_PASSWORD \
-pds https://my.awesome.pds.example.org \
profile follows preferences
```
### required arguments:
- `-from <identifier>`: the source account handle or DID.
- `-frompass <password>`: the source account password.
- `-to <identifier>`: the destination account handle or DID.
- `-topass <password>`: the destination account password.
- `-pds <pds-url>`: the full https:// url of the destination account's PDS.
### collections:
- `profile`: imports account profile, including avatar, banner, display name, and description.
- `follows`: imports all following accounts.
- `preferences`: imports account preferences; including feeds, labeller settings, and content filters.
### 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!
- `-v`: verbose output
- `-help`: shows this help message.

View file

@ -20,6 +20,7 @@ type (
RefreshJwt string `json:"refreshJwt"`
Handle string `json:"handle"`
Did string `json:"did"`
PdsUrl string `json:"-"`
}
AtprotoIdentity struct {
@ -120,12 +121,13 @@ func CreateSession (pdsUrl string, identifier string, password string) (*Atproto
if err != nil {
return nil, errors.New(fmt.Sprintf("failed to parse PDS response: %v\n", err))
}
session.PdsUrl = pdsUrl
return &session, nil
}
func ResolveAtprotoHandle(session *AtprotoSession, pdsUrl string, handle string) (string, error) {
reqUrl, _ := url.Parse(pdsUrl + "/xrpc/com.atproto.identity.resolveHandle")
func ResolveAtprotoHandle(session *AtprotoSession, handle string) (string, error) {
reqUrl, _ := url.Parse(session.PdsUrl + "/xrpc/com.atproto.identity.resolveHandle")
reqUrl.RawQuery = url.Values{
"handle": { handle },
}.Encode()
@ -151,7 +153,7 @@ func ResolveAtprotoHandle(session *AtprotoSession, pdsUrl string, handle string)
return identity.Did, nil
}
func CreateAtprotoRecord(session *AtprotoSession, pdsUrl string, collection string, record json.RawMessage) (*AtprotoUriCid, error) {
func CreateAtprotoRecord(session *AtprotoSession, collection string, record json.RawMessage) (*AtprotoUriCid, error) {
reqBody, reqBodyBytes := AtprotoCreateRecord{
Repo: session.Did,
Collection: collection,
@ -159,7 +161,7 @@ func CreateAtprotoRecord(session *AtprotoSession, pdsUrl string, collection stri
}, new(bytes.Buffer)
err := json.NewEncoder(reqBodyBytes).Encode(reqBody)
if err != nil { return nil, err }
req, _ := http.NewRequest(http.MethodPost, pdsUrl + "/xrpc/com.atproto.repo.createRecord", reqBodyBytes)
req, _ := http.NewRequest(http.MethodPost, session.PdsUrl + "/xrpc/com.atproto.repo.createRecord", reqBodyBytes)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer " + session.AccessJwt)
@ -175,7 +177,7 @@ func CreateAtprotoRecord(session *AtprotoSession, pdsUrl string, collection stri
return &createdRecord, err
}
func PutAtprotoRecord(session *AtprotoSession, pdsUrl string, collection string, record any) (*AtprotoUriCid, error) {
func PutAtprotoRecord(session *AtprotoSession, collection string, record any) (*AtprotoUriCid, error) {
reqBody, reqBodyBytes := AtprotoPutRecord{
Repo: session.Did,
Collection: collection,
@ -185,7 +187,7 @@ func PutAtprotoRecord(session *AtprotoSession, pdsUrl string, collection string,
err := json.NewEncoder(reqBodyBytes).Encode(reqBody)
if err != nil { return nil, err }
req, _ := http.NewRequest(http.MethodPost, pdsUrl + "/xrpc/com.atproto.repo.putRecord", reqBodyBytes)
req, _ := http.NewRequest(http.MethodPost, session.PdsUrl + "/xrpc/com.atproto.repo.putRecord", reqBodyBytes)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer " + session.AccessJwt)
@ -225,8 +227,8 @@ func GetAtprotoBlob(pdsUrl string, did string, cid string) ([]byte, error) {
return data, nil
}
func UploadAtprotoBlob(session *AtprotoSession, pdsUrl string, data []byte, mimeType string) (*AtprotoBlob, error) {
req, _ := http.NewRequest(http.MethodPost, pdsUrl + "/xrpc/com.atproto.repo.uploadBlob", bytes.NewReader(data))
func UploadAtprotoBlob(session *AtprotoSession, data []byte, mimeType string) (*AtprotoBlob, error) {
req, _ := http.NewRequest(http.MethodPost, session.PdsUrl + "/xrpc/com.atproto.repo.uploadBlob", bytes.NewReader(data))
req.Header.Set("Content-Type", mimeType)
req.Header.Set("Authorization", "Bearer " + session.AccessJwt)

View file

@ -8,25 +8,15 @@ import (
"net/http"
"net/url"
"os"
"strings"
"time"
)
func ImportProfile(session *AtprotoSession, pdsUrl string, fromAccount string, dryrun bool, verbose bool) error {
fmt.Printf("fetching profile record from @%s...\n", fromAccount)
func ImportProfile(fromSession *AtprotoSession, toSession *AtprotoSession, dryrun bool, verbose bool) error {
fmt.Printf("fetching profile record from @%s...\n", fromSession.Handle)
var err error
fromDid := fromAccount
if !strings.HasPrefix(fromAccount, "did:") {
fromDid, err = ResolveAtprotoHandle(session, pdsUrl, fromAccount)
if err != nil {
return errors.New(fmt.Sprintf("failed to resolve identity: %v\n", err))
}
}
reqUrl, _ := url.Parse(pdsUrl + "/xrpc/com.atproto.repo.getRecord")
reqUrl, _ := url.Parse(fromSession.PdsUrl + "/xrpc/com.atproto.repo.getRecord")
reqUrl.RawQuery = url.Values{
"repo": { fromAccount },
"repo": { fromSession.Did },
"collection": { "app.bsky.actor.profile" },
"rkey": { "self" },
}.Encode()
@ -64,47 +54,47 @@ func ImportProfile(session *AtprotoSession, pdsUrl string, fromAccount string, d
if !dryrun {
// import avatar
avatarBytes, err := GetAtprotoBlob(pdsUrl, fromDid, profile.Avatar.Ref.Link)
avatarBytes, err := GetAtprotoBlob(fromSession.PdsUrl, fromSession.Did, 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)
avatarBlob, err := UploadAtprotoBlob(toSession, avatarBytes, profile.Avatar.MimeType)
if err != nil { return errors.New(fmt.Sprintf("failed to upload avatar: %v\n", err)) }
newProfile.Avatar = avatarBlob
// import banner
bannerBytes, err := GetAtprotoBlob(pdsUrl, fromDid, profile.Banner.Ref.Link)
bannerBytes, err := GetAtprotoBlob(fromSession.PdsUrl, fromSession.Did, 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)
bannerBlob, err := UploadAtprotoBlob(toSession, bannerBytes, profile.Banner.MimeType)
if err != nil { return errors.New(fmt.Sprintf("failed to upload banner: %v\n", err)) }
newProfile.Banner = bannerBlob
// import all details
_, err = PutAtprotoRecord(session, pdsUrl, "app.bsky.actor.profile", newProfile)
_, err = PutAtprotoRecord(toSession, "app.bsky.actor.profile", newProfile)
if err != nil {
return errors.New(fmt.Sprintf("failed to update profile record: %v\n", err))
}
}
fmt.Printf("profile imported from @%s successfully.\n", fromAccount)
fmt.Printf("profile imported from @%s to @%s successfully.\n", fromSession.Handle, toSession.Handle)
return nil
}
func ImportFollows(session *AtprotoSession, pdsUrl string, fromAccount string, dryrun bool, verbose bool) error {
func ImportFollows(fromSession *AtprotoSession, toSession *AtprotoSession, dryrun bool, verbose bool) error {
followDids := []string{}
cursor := ""
fmt.Printf("fetching follow records from @%s...\n", fromAccount)
fmt.Printf("fetching follow records from @%s...\n", fromSession.Handle)
for {
reqUrl, _ := url.Parse(pdsUrl + "/xrpc/app.bsky.graph.getFollows")
reqUrl, _ := url.Parse(fromSession.PdsUrl + "/xrpc/app.bsky.graph.getFollows")
reqUrlValues := url.Values{
"actor": { fromAccount },
"actor": { fromSession.Did },
"limit": { "100" },
}
if cursor != "" { reqUrlValues.Set("cursor", cursor) }
reqUrl.RawQuery = reqUrlValues.Encode()
req, _ := http.NewRequest(http.MethodGet, reqUrl.String(), nil)
req.Header.Set("Authorization", "Bearer " + session.AccessJwt)
req.Header.Set("Authorization", "Bearer " + fromSession.AccessJwt)
res, err := http.DefaultClient.Do(req)
if err != nil {
@ -130,9 +120,9 @@ func ImportFollows(session *AtprotoSession, pdsUrl string, fromAccount string, d
}
fmt.Printf("fetched %d follow records.\n", len(followDids))
fmt.Printf("importing follow records to @%s...\n", session.Handle)
fmt.Printf("importing follow records to @%s...\n", toSession.Handle)
successful := 0
for i, did := range followDids[len(followDids)-3:] {
for i, did := range followDids {
if dryrun {
if verbose {
fmt.Printf("(%d/%d) followed %s (dry run)\n", i, len(followDids), did)
@ -145,7 +135,7 @@ func ImportFollows(session *AtprotoSession, pdsUrl string, fromAccount string, d
CreatedAt: time.Now().Format(time.RFC3339),
}
recordJson, _ := json.Marshal(record)
newRecord, err := CreateAtprotoRecord(session, pdsUrl, "app.bsky.graph.follow", recordJson)
newRecord, err := CreateAtprotoRecord(toSession, "app.bsky.graph.follow", recordJson)
if err != nil {
fmt.Fprintf(os.Stderr, "warn: failed to create follow record for %s: %v\n", did, err)
continue
@ -156,15 +146,21 @@ func ImportFollows(session *AtprotoSession, pdsUrl string, fromAccount string, d
successful += 1
}
fmt.Printf("%d/%d follow records imported successfully!\n", successful, len(followDids))
fmt.Printf(
"%d/%d follow records imported from @%s to @%s successfully!\n",
successful,
len(followDids),
fromSession.Handle,
toSession.Handle,
)
return nil
}
func ImportPreferences(fromSession *AtprotoSession, toSession *AtprotoSession, pdsUrl string, dryrun bool, verbose bool) error {
func ImportPreferences(fromSession *AtprotoSession, toSession *AtprotoSession, 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, _ := http.NewRequest(http.MethodGet, fromSession.PdsUrl + "/xrpc/app.bsky.actor.getPreferences", nil)
req.Header.Set("Authorization", "Bearer " + fromSession.AccessJwt)
res, err := http.DefaultClient.Do(req)
if err != nil {
@ -182,7 +178,7 @@ func ImportPreferences(fromSession *AtprotoSession, toSession *AtprotoSession, p
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, _ := http.NewRequest(http.MethodPost, toSession.PdsUrl + "/xrpc/app.bsky.actor.putPreferences", bytes.NewReader(reqBodyBytes))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer " + toSession.AccessJwt)

2
go.mod
View file

@ -1,3 +1,3 @@
module arimelody.me/bluesky-follow-migrator
module arimelody.me/migratesky
go 1.24.4

61
main.go
View file

@ -4,6 +4,7 @@ import (
"fmt"
"os"
"slices"
"strings"
)
func main() {
@ -21,16 +22,13 @@ func main() {
var dryrun bool = false
var verbose bool = false
var importProfile bool = false
var importFollows bool = false
var importPreferences bool = false
collections := []string{}
i := 1
for {
if i >= len(os.Args) { break }
if os.Args[i][0] != '-' {
fmt.Fprintf(os.Stderr, "unrecognised argument `%s`.\n", os.Args[i])
os.Exit(1)
collections = append(collections, os.Args[i])
}
getValue := func(i int) (int, string) {
@ -58,12 +56,6 @@ func main() {
dryrun = true
case "v":
verbose = true
case "profile":
importProfile = true
case "follows":
importFollows = true
case "preferences":
importPreferences = true
}
i += 1
}
@ -72,12 +64,16 @@ func main() {
fmt.Fprintf(os.Stderr, "missing required argument -from.\n")
os.Exit(1)
}
if fromPassword == "" {
fmt.Fprintf(os.Stderr, "missing required argument -frompass.\n")
os.Exit(1)
}
if toUser == "" {
fmt.Fprintf(os.Stderr, "missing required argument -to.\n")
os.Exit(1)
}
if toPassword == "" {
fmt.Fprintf(os.Stderr, "missing required argument -pass.\n")
fmt.Fprintf(os.Stderr, "missing required argument -topass.\n")
os.Exit(1)
}
if pdsUrl == "" {
@ -86,19 +82,15 @@ func main() {
}
if fromPdsUrl == "" { fromPdsUrl = pdsUrl }
if !(importProfile || importFollows || importPreferences) {
if len(collections) == 0 {
fmt.Fprintf(os.Stderr, "no action was specified.\n")
os.Exit(1)
}
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)
}
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)
@ -107,26 +99,22 @@ func main() {
os.Exit(1)
}
if importProfile {
err := ImportProfile(toSession, pdsUrl, fromUser, dryrun, verbose)
if slices.Contains(collections, "profile") {
err := ImportProfile(fromSession, toSession, 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 slices.Contains(collections, "preferences") {
err := ImportPreferences(fromSession, toSession, dryrun, verbose)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to import preferences: %v\n", err)
os.Exit(1)
}
}
if importFollows {
err := ImportFollows(toSession, pdsUrl, fromUser, dryrun, verbose)
if slices.Contains(collections, "follows") {
err := ImportFollows(fromSession, toSession, dryrun, verbose)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to import follows: %v\n", err)
os.Exit(1)
@ -136,7 +124,7 @@ func main() {
func printHelp() {
fmt.Printf(
`usage: %s <arguments...> <imports...>
`usage: %s <-from <identifier>> <-frompass <password>> <-to <identifier>> <-topass <identifier>> <-pds <pds-url>> [collections...]
required arguments:
-from <identifier>: the source account handle or DID.
@ -145,9 +133,10 @@ required arguments:
-topass <password>: the destination account password.
-pds <pds-url>: 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.
collections:
profile: imports account profile, including avatar, banner, display name, and description.
follows: imports all following accounts.
preferences: imports account preferences; including feeds, labeller settings, and content filters.
optional arguments:
-frompds <pds-url>: the source account's PDS url (defaults to the value of -pds).
@ -155,6 +144,6 @@ optional arguments:
-v: verbose output
-help: shows this help message.
`,
os.Args[0],
os.Args[0][strings.LastIndex(os.Args[0], "/") + 1:],
)
}