Interactively go through your bluesky follow graph and decide to keep or remove follow records
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 238 lines 5.5 kB view raw
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}