From 24db1724627977beea93f0ca765d9005727f16cf Mon Sep 17 00:00:00 2001 From: ari melody Date: Sun, 22 Jun 2025 17:44:23 +0100 Subject: [PATCH] =?UTF-8?q?first=20commit=20(and=20successful=20migration!?= =?UTF-8?q?)=20=F0=9F=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- atproto.go | 250 +++++++++++++++++++++++++++++++++++++++++++++++++++++ funcs.go | 161 ++++++++++++++++++++++++++++++++++ go.mod | 3 + main.go | 127 +++++++++++++++++++++++++++ 4 files changed, 541 insertions(+) create mode 100644 atproto.go create mode 100644 funcs.go create mode 100644 go.mod create mode 100644 main.go diff --git a/atproto.go b/atproto.go new file mode 100644 index 0000000..2bba9fe --- /dev/null +++ b/atproto.go @@ -0,0 +1,250 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" +) + +type ( + AtprotoCreateSession struct { + Identifier string `json:"identifier"` + Password string `json:"password"` + } + AtprotoSession struct { + AccessJwt string `json:"accessJwt"` + RefreshJwt string `json:"refreshJwt"` + Handle string `json:"handle"` + Did string `json:"did"` + } + + AtprotoIdentity struct { + Did string `json:"did"` + Handle string `json:"handle"` + DidDoc string `json:"didDoc"` + } + AtprotoGetRecordResponse struct { + Uri string `json:"uri"` + Cid string `json:"cid"` + Value json.RawMessage `json:"value"` + } + AtprotoCreateRecord struct { + Repo string `json:"repo"` + Collection string `json:"collection"` + Record json.RawMessage `json:"record"` + } + AtprotoPutRecord struct { + Repo string `json:"repo"` + Collection string `json:"collection"` + Rkey string `json:"rkey"` + Record any `json:"record"` + } + AtprotoUidCid struct { + Uri string `json:"uri"` + Cid string `json:"cid"` + } + AtprotoRecord struct { + Type string `json:"$type"` + } + AtprotoRef struct { + Link string `json:"$link"` + } + AtprotoBlob struct { + LexiconTypeID string `json:"$type,const=blob"` + Ref AtprotoRef `json:"ref"` + MimeType string `json:"mimeType"` + Size int `json:"size"` + } + + BskyActorProfileView struct { + Did string `json:"did"` + Handle string `json:"handle"` + DisplayName *string `json:"displayName"` + Description *string `json:"description"` + Avatar *string `json:"avatar"` + Associated *json.RawMessage `json:"associated"` + IndexedAt *string `json:"indexedAt"` + CreatedAt *string `json:"createdAt"` + Viewer *json.RawMessage `json:"viewer"` + Labels *json.RawMessage `json:"labels"` + Verification *json.RawMessage `json:"verification"` + Status *json.RawMessage `json:"status"` + } + BskyGetFollowsResponse struct { + Subject BskyActorProfileView `json:"subject"` + Cursor *string `json:"cursor"` + Follows []BskyActorProfileView `json:"follows"` + } + + BskyActorProfile struct { + LexiconTypeID string `json:"$type,const=app.bsky.actor.profile"` + DisplayName *string `json:"displayName,omitempty"` + Description *string `json:"description,omitempty"` + Avatar *AtprotoBlob `json:"avatar,omitempty"` + Banner *AtprotoBlob `json:"banner,omitempty"` + Labels []string `json:"labels,omitempty"` + PinnedPost *AtprotoUidCid `json:"pinnedPost,omitempty"` + } + BskyGraphFollow struct { + LexiconTypeID string `json:"$type,const=app.bsky.graph.follow"` + CreatedAt string `json:"createdAt"` + Subject string `json:"subject"` + } + + ErrorResponse struct { + Error string `json:"error"` + Message string `json:"message"` + } +) + +func CreateSession (pdsUrl string, identifier string, password string) (*AtprotoSession, error) { + reqBody, reqBodyBytes := AtprotoCreateSession{ + Identifier: identifier, + Password: password, + }, new(bytes.Buffer) + json.NewEncoder(reqBodyBytes).Encode(reqBody) + res, err := http.Post(pdsUrl + "/xrpc/com.atproto.server.createSession", "application/json", reqBodyBytes) + if err != nil { + return nil, errors.New(fmt.Sprintf("failed to log into @%s: %v\n", identifier, err)) + } + + session := AtprotoSession{} + err = json.NewDecoder(res.Body).Decode(&session) + if err != nil { + return nil, errors.New(fmt.Sprintf("failed to parse PDS response: %v\n", err)) + } + + return &session, nil +} + +func ResolveAtprotoHandle(session *AtprotoSession, pdsUrl string, handle string) (string, error) { + reqUrl, _ := url.Parse(pdsUrl + "/xrpc/com.atproto.identity.resolveHandle") + reqUrl.RawQuery = url.Values{ + "handle": { handle }, + }.Encode() + req, _ := http.NewRequest(http.MethodGet, reqUrl.String(), nil) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer " + session.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)) + } + + type DidResponse struct { + Did string `json:"did"` + } + identity := DidResponse{} + json.NewDecoder(res.Body).Decode(&identity) + + return identity.Did, nil +} + +func CreateAtprotoRecord(session *AtprotoSession, pdsUrl string, collection string, record json.RawMessage) (*AtprotoUidCid, error) { + reqBody, reqBodyBytes := AtprotoCreateRecord{ + Repo: session.Did, + Collection: collection, + Record: record, + }, 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.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer " + session.AccessJwt) + + res, err := http.DefaultClient.Do(req) + if err != nil { return nil, err } + if res.StatusCode != http.StatusOK { + errBody := ErrorResponse{} + json.NewDecoder(res.Body).Decode(&errBody) + return nil, errors.New(fmt.Sprintf("%s: %s", errBody.Error, errBody.Message)) + } + createdRecord := AtprotoUidCid{} + err = json.NewDecoder(res.Body).Decode(&createdRecord) + return &createdRecord, err +} + +func PutAtprotoRecord(session *AtprotoSession, pdsUrl string, collection string, record any) (*AtprotoUidCid, error) { + reqBody, reqBodyBytes := AtprotoPutRecord{ + Repo: session.Did, + Collection: collection, + Record: record, + Rkey: "self", + }, 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.putRecord", reqBodyBytes) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer " + session.AccessJwt) + + res, err := http.DefaultClient.Do(req) + if err != nil { return nil, err } + if res.StatusCode != http.StatusOK { + errBody := ErrorResponse{} + json.NewDecoder(res.Body).Decode(&errBody) + return nil, errors.New(fmt.Sprintf("%s: %s", errBody.Error, errBody.Message)) + } + updatedRecord := AtprotoUidCid{} + err = json.NewDecoder(res.Body).Decode(&updatedRecord) + return &updatedRecord, err +} + +func GetAtprotoBlob(pdsUrl string, did string, cid string) ([]byte, error) { + reqUrl, _ := url.Parse(pdsUrl + "/xrpc/com.atproto.sync.getBlob") + reqUrl.RawQuery = url.Values{ + "did": { did }, + "cid": { cid }, + }.Encode() + res, err := http.Get(reqUrl.String()) + if err != nil { + return nil, err + } + if res.StatusCode != http.StatusOK { + errBody := ErrorResponse{} + json.NewDecoder(res.Body).Decode(&errBody) + return nil, errors.New(fmt.Sprintf("%s: %s", errBody.Error, errBody.Message)) + } + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, errors.New(fmt.Sprintf("failed to read bytes: %v", err)) + } + + 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)) + req.Header.Set("Content-Type", mimeType) + req.Header.Set("Authorization", "Bearer " + session.AccessJwt) + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + if res.StatusCode != http.StatusOK { + errBody := ErrorResponse{} + json.NewDecoder(res.Body).Decode(&errBody) + return nil, errors.New(fmt.Sprintf("%s: %s", errBody.Error, errBody.Message)) + } + + type Response struct { + Blob AtprotoBlob `json:"blob"` + } + blob := Response{} + err = json.NewDecoder(res.Body).Decode(&blob) + if err != nil { + return nil, errors.New(fmt.Sprintf("failed to read response body: %v", err)) + } + blob.Blob.LexiconTypeID = "blob" + return &blob.Blob, nil +} diff --git a/funcs.go b/funcs.go new file mode 100644 index 0000000..3275376 --- /dev/null +++ b/funcs.go @@ -0,0 +1,161 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "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) + + var err error + if !strings.HasPrefix(fromAccount, "did:") { + fromAccount, 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.RawQuery = url.Values{ + "repo": { fromAccount }, + "collection": { "app.bsky.actor.profile" }, + "rkey": { "self" }, + }.Encode() + req, _ := http.NewRequest(http.MethodGet, reqUrl.String(), nil) + + res, err := http.DefaultClient.Do(req) + if err != nil { + return errors.New(fmt.Sprintf("failed to fetch profile: %v\n", err)) + } + + getProfileResponse := AtprotoGetRecordResponse{} + err = json.NewDecoder(res.Body).Decode(&getProfileResponse) + if err != nil { + return errors.New(fmt.Sprintf("failed to parse PDS response: %v\n", err)) + } + profile := BskyActorProfile{} + err = json.Unmarshal(getProfileResponse.Value, &profile) + + newProfile := BskyActorProfile{ + LexiconTypeID: "app.bsky.actor.profile", + DisplayName: profile.DisplayName, + Description: profile.Description, + Labels: profile.Labels, + } + + if verbose { + fmt.Printf( + "\nimporting profile details:\n" + + "- display name: %s\n" + + "- description:\n%s\n\n", + *newProfile.DisplayName, + *newProfile.Description, + ) + } + + if !dryrun { + // import avatar + avatarBytes, err := GetAtprotoBlob(pdsUrl, fromAccount, 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) + if err != nil { return errors.New(fmt.Sprintf("failed to upload avatar: %v\n", err)) } + newProfile.Avatar = avatarBlob + + // import banner + bannerBytes, err := GetAtprotoBlob(pdsUrl, fromAccount, 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) + 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) + 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) + + return nil +} + +func ImportFollows(session *AtprotoSession, pdsUrl string, fromAccount string, dryrun bool, verbose bool) error { + followDids := []string{} + cursor := "" + + fmt.Printf("fetching follow records from @%s...\n", fromAccount) + for { + reqUrl, _ := url.Parse(pdsUrl + "/xrpc/app.bsky.graph.getFollows") + reqUrlValues := url.Values{ + "actor": { fromAccount }, + "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) + + res, err := http.DefaultClient.Do(req) + if err != nil { + return errors.New(fmt.Sprintf("failed to fetch follows: %v\n", err)) + } + + getFollowsResponse := BskyGetFollowsResponse{} + err = json.NewDecoder(res.Body).Decode(&getFollowsResponse) + if err != nil { + return errors.New(fmt.Sprintf("failed to parse PDS response: %v\n", err)) + } + + for _, profile := range getFollowsResponse.Follows { + if verbose { + fmt.Printf("following: %s - %s\n", profile.Handle, *profile.DisplayName) + } + followDids = append(followDids, profile.Did) + } + if getFollowsResponse.Cursor == nil { + break + } + cursor = *getFollowsResponse.Cursor + } + fmt.Printf("fetched %d follow records.\n", len(followDids)) + + fmt.Printf("importing follow records to @%s...\n", session.Handle) + successful := 0 + for i, did := range followDids[len(followDids)-3:] { + if dryrun { + if verbose { + fmt.Printf("(%d/%d) followed %s (dry run)\n", i, len(followDids), did) + } + successful += 1 + continue + } + record := BskyGraphFollow{ + Subject: did, + CreatedAt: time.Now().Format(time.RFC3339), + } + recordJson, _ := json.Marshal(record) + newRecord, err := CreateAtprotoRecord(session, pdsUrl, "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 + } + if verbose { + fmt.Printf("(%d/%d) followed %s (%s)\n", i, len(followDids), did, newRecord.Uri) + } + successful += 1 + } + + fmt.Printf("%d/%d follow records imported successfully!\n", successful, len(followDids)) + + return nil +} + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6219d64 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module arimelody.me/bluesky-follow-migrator + +go 1.24.4 diff --git a/main.go b/main.go new file mode 100644 index 0000000..a6b87aa --- /dev/null +++ b/main.go @@ -0,0 +1,127 @@ +package main + +import ( + "fmt" + "os" + "slices" +) + +func main() { + if len(os.Args) == 1 || slices.Contains(os.Args, "-help") { + printHelp() + os.Exit(0) + } + + var srcUser string + var destUser string + var password string + var pdsUrl string + var dryrun bool = false + var verbose bool = false + + var importProfile bool = false + var importFollows bool = false + + 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) + } + + getValue := func(i int) (int, string) { + if i + 1 >= len(os.Args) { + fmt.Fprintf(os.Stderr, "-%s requires a value.\n", os.Args[i]) + os.Exit(1) + } + return i + 1, os.Args[i + 1] + } + + switch os.Args[i][1:] { + case "from": + i, srcUser = getValue(i) + case "to": + i, destUser = getValue(i) + case "pass": + i, password = getValue(i) + case "pds": + i, pdsUrl = getValue(i) + case "dryrun": + dryrun = true + case "v": + verbose = true + case "profile": + importProfile = true + case "follows": + importFollows = true + } + i += 1 + } + + if srcUser == "" { + fmt.Fprintf(os.Stderr, "missing required argument -from.\n") + os.Exit(1) + } + if destUser == "" { + fmt.Fprintf(os.Stderr, "missing required argument -to.\n") + os.Exit(1) + } + if password == "" { + fmt.Fprintf(os.Stderr, "missing required argument -pass.\n") + os.Exit(1) + } + if pdsUrl == "" { + fmt.Fprintf(os.Stderr, "missing required argument -pds.\n") + os.Exit(1) + } + + if !importProfile && !importFollows { + fmt.Fprintf(os.Stderr, "no action was specified.\n") + os.Exit(1) + } + + session, err := CreateSession(pdsUrl, destUser, password) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to create session: %v\n", err) + os.Exit(1) + } + + if importProfile { + err := ImportProfile(session, pdsUrl, srcUser, dryrun, verbose) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to import profile: %v\n", err) + os.Exit(1) + } + } + if importFollows { + err := ImportFollows(session, pdsUrl, srcUser, dryrun, verbose) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to import follows: %v\n", err) + os.Exit(1) + } + } +} + +func printHelp() { + fmt.Printf( +`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. + +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: + -dryrun: does not import follow records; good for sanity testing! + -v: verbose output + -help: shows this help message. +`, + os.Args[0], + ) +}