tidy-up refactor, add readme
This commit is contained in:
parent
6bde84b7e3
commit
cd48757272
6 changed files with 104 additions and 77 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
.obsidian
|
39
README.md
Normal file
39
README.md
Normal 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.
|
18
atproto.go
18
atproto.go
|
@ -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)
|
||||
|
||||
|
|
60
funcs.go
60
funcs.go
|
@ -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
2
go.mod
|
@ -1,3 +1,3 @@
|
|||
module arimelody.me/bluesky-follow-migrator
|
||||
module arimelody.me/migratesky
|
||||
|
||||
go 1.24.4
|
||||
|
|
61
main.go
61
main.go
|
@ -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:],
|
||||
)
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue