Interactively go through your bluesky follow graph and decide to keep or remove follow records
1package main
2
3import (
4 "bufio"
5 "context"
6 _ "embed"
7 "flag"
8 "fmt"
9 _ "image/jpeg"
10 _ "image/png"
11 "log"
12 "os"
13
14 "github.com/bluesky-social/indigo/api/atproto"
15 "github.com/bluesky-social/indigo/api/bsky"
16 "github.com/bluesky-social/indigo/atproto/atclient"
17 "github.com/bluesky-social/indigo/atproto/identity"
18 "github.com/bluesky-social/indigo/atproto/syntax"
19 "github.com/bluesky-social/indigo/util"
20 "golang.org/x/term"
21)
22
23const BSKY_BASE_URL = "https://bsky.app/profile"
24
25func getProfileInformation(
26 ctx context.Context,
27 apiClient *atclient.APIClient,
28 record *bsky.GraphFollow,
29) (string, string, string, []byte) {
30 identityDirectory := identity.DefaultDirectory()
31
32 did, _ := syntax.ParseDID(record.Subject)
33 doc, err := identityDirectory.LookupDID(ctx, did)
34 if err != nil {
35 log.Printf("Failed to get identity document for %s: %s", record.Subject, err)
36 return "", "", "", nil
37 }
38
39 profileRecord, err := atproto.RepoGetRecord(ctx, apiClient,
40 "",
41 "app.bsky.actor.profile",
42 record.Subject,
43 "self",
44 )
45
46 if err != nil {
47 log.Printf("Failed to get profile record for %s: %s", record.Subject, err)
48 return string(doc.Handle), "", "", nil
49 }
50
51 profile := profileRecord.Value.Val.(*bsky.ActorProfile)
52
53 var displayName string
54 if profile.DisplayName != nil {
55 displayName = *profile.DisplayName
56 }
57
58 var description string
59 if profile.Description != nil {
60 description = *profile.Description
61 }
62
63 blobClient := atclient.NewAPIClient(doc.DIDDocument().Service[0].ServiceEndpoint)
64 avatar, err := atproto.SyncGetBlob(ctx, blobClient,
65 profile.Avatar.Ref.String(),
66 record.Subject,
67 )
68
69 if err != nil {
70 log.Println("Failed to download avatar", err)
71 return string(doc.Handle), *profile.DisplayName, *profile.Description, nil
72 }
73
74 return string(doc.Handle), displayName, description, avatar
75}
76
77func main() {
78 if term.IsTerminal(int(os.Stdin.Fd())) == false {
79 log.Fatal("Expected to be running inside of a terminal emulator")
80 }
81
82 handle := flag.String("h", "", "Account handle to use")
83 resumeSubject := flag.String("r", "", "Resume from handle or DID")
84 flag.Parse()
85
86 if *handle == "" {
87 log.Fatal("Missing an atproto (bluesky) handle")
88 }
89
90 ctx := context.Background()
91
92 resume := false
93 resumeDID := ""
94 if *resumeSubject != "" {
95 identityDirectory := identity.DefaultDirectory()
96 doc, err := identityDirectory.Lookup(ctx, syntax.AtIdentifier(*resumeSubject))
97
98 if err != nil {
99 log.Fatal("Couldn't verify handle or DID for resuming:", err)
100 }
101
102 resumeDID = string(doc.DID)
103 resume = true
104 }
105
106 oauthClient, sessionData, err := authenticate(ctx, handle)
107 if err != nil {
108 log.Fatal("Failed to authenticate:", err)
109 }
110
111 fmt.Printf("Logged in as: %s (%s)\n", *handle, sessionData.AccountDID)
112
113 session, err := oauthClient.ResumeSession(
114 ctx,
115 sessionData.AccountDID,
116 sessionData.SessionID,
117 )
118
119 if err != nil {
120 log.Fatal("Failed to get oauth session:", err)
121 }
122
123 apiClient := session.APIClient()
124
125 // Save and Restore cursor position
126 fmt.Printf("\x1b7")
127 defer fmt.Printf("\x1b8")
128
129 // Enter alternate buffer
130 fmt.Printf("\x1b[?1049h")
131 defer fmt.Printf("\x1b[?1049l")
132
133 cursor := ""
134 for {
135 records, err := atproto.RepoListRecords(ctx, apiClient,
136 "app.bsky.graph.follow",
137 cursor,
138 50,
139 sessionData.AccountDID.String(),
140 false,
141 )
142
143 if err != nil {
144 log.Fatal("Failed to get records:", err)
145 }
146
147 if len(records.Records) == 0 {
148 break
149 }
150
151 cursor = *records.Cursor
152
153 for _, record := range records.Records {
154 // Clear the screen and move cursor home (first cell of the terminal)
155 fmt.Printf("\x1b[2J")
156 fmt.Printf("\x1b[H")
157
158 followRecord := record.Value.Val.(*bsky.GraphFollow)
159
160 if resume && followRecord.Subject != resumeDID {
161 continue
162 }
163 resume = false
164
165 handle, displayName, description, avatar := getProfileInformation(
166 ctx, apiClient,
167 followRecord,
168 )
169
170 if avatar != nil {
171 err = displayImage(avatar)
172 if err != nil {
173 log.Println("Failed to display avatar:", err)
174 }
175 }
176
177 if handle != "" {
178 fmt.Printf("\nHandle: %s\n", handle)
179 }
180
181 if displayName != "" {
182 fmt.Printf("Display Name: %s\n", displayName)
183 }
184
185 fmt.Printf("DID: %s\n", followRecord.Subject)
186 fmt.Println("Profile URL: " + BSKY_BASE_URL + "/" + followRecord.Subject)
187
188 if description != "" {
189 fmt.Printf("Description:\n%s\n\n", description)
190 }
191
192 user_input:
193 fmt.Print("Keep this record ([Y]es / [n]o / [q]uit)? ")
194
195 scanner := bufio.NewScanner(os.Stdin)
196 scanner.Scan()
197 err = scanner.Err()
198 if err != nil {
199 log.Fatal("Failed to get user input:", scanner.Err())
200 }
201
202 switch scanner.Text() {
203 case "", "Y", "y":
204 case "N", "n":
205 aturi, err := util.ParseAtUri(record.Uri)
206 if err != nil {
207 log.Println("Failed to parse aturi:", err)
208 fmt.Println("Manual deletion required (Use the profile url above).")
209 fmt.Print("Press Enter to continue.")
210 scanner.Scan()
211 continue
212 }
213
214 _, err = atproto.RepoDeleteRecord(ctx, apiClient,
215 &atproto.RepoDeleteRecord_Input{
216 Collection: "app.bsky.graph.follow",
217 Repo: string(sessionData.AccountDID),
218 Rkey: aturi.Rkey,
219 SwapCommit: nil,
220 SwapRecord: nil,
221 },
222 )
223
224 if err != nil {
225 log.Println("Failed to delete record:", err)
226 fmt.Println("Manual deletion required (Use the profile url above).")
227 fmt.Print("Press Enter to continue.")
228 scanner.Scan()
229 }
230 case "Q", "q":
231 return
232 default:
233 fmt.Println("Expected either 'y', 'n', or 'q'")
234 goto user_input
235 }
236 }
237 }
238}