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.

Refactor

- Split into separate files
- Remove unncessary fatal errors
- Add maximum size for images
- Simplify image area calculations

yemou 8cf87053 4c0812da

+291 -204
+80
auth.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + _ "embed" 6 + "errors" 7 + "fmt" 8 + "net" 9 + "net/http" 10 + "net/url" 11 + 12 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 13 + ) 14 + 15 + const BASE_URL = "https://bsky-sieve-cli.b77.boo" 16 + 17 + //go:embed static/callback.html 18 + var callbackHTML []byte 19 + 20 + func callbackListener(ctx context.Context, result chan url.Values) (int, error) { 21 + listener, err := net.Listen("tcp", ":0") 22 + if err != nil { 23 + return 0, err 24 + } 25 + 26 + mux := http.NewServeMux() 27 + server := &http.Server{Handler: mux} 28 + 29 + mux.HandleFunc("/callback", func(writer http.ResponseWriter, request *http.Request) { 30 + result <- request.URL.Query() 31 + writer.Header().Set("Content-Type", "text/html;charset=utf-8") 32 + writer.WriteHeader(200) 33 + writer.Write(callbackHTML) 34 + go server.Shutdown(ctx) 35 + }) 36 + 37 + go func() { 38 + err := server.Serve(listener) 39 + if !errors.Is(err, http.ErrServerClosed) { 40 + panic(err) 41 + } 42 + }() 43 + 44 + return listener.Addr().(*net.TCPAddr).Port, nil 45 + } 46 + 47 + func authenticate(ctx context.Context, handle *string) (*oauth.ClientApp, *oauth.ClientSessionData, error) { 48 + config := oauth.ClientConfig{ 49 + ClientID: BASE_URL + "/oauth-client-metadata.json", 50 + Scopes: []string{ 51 + "atproto", 52 + "atproto repo?collection=app.bsky.graph.follow&action=delete", 53 + }, 54 + UserAgent: "bsky-sieve", 55 + } 56 + 57 + oauthClient := oauth.NewClientApp(&config, oauth.NewMemStore()) 58 + 59 + callbackResult := make(chan url.Values, 1) 60 + listenPort, err := callbackListener(ctx, callbackResult) 61 + if err != nil { 62 + return nil, nil, err 63 + } 64 + 65 + config.CallbackURL = fmt.Sprintf("http://127.0.0.1:%d/callback", listenPort) 66 + 67 + authURL, err := oauthClient.StartAuthFlow(ctx, *handle) 68 + if err != nil { 69 + return nil, nil, err 70 + } 71 + 72 + fmt.Println("Login URL:", authURL) 73 + 74 + sessionData, err := oauthClient.ProcessCallback(ctx, <-callbackResult) 75 + if err != nil { 76 + return nil, nil, err 77 + } 78 + 79 + return oauthClient, sessionData, nil 80 + }
+3 -3
flake.lock
··· 2 2 "nodes": { 3 3 "nixpkgs": { 4 4 "locked": { 5 - "lastModified": 1773122722, 6 - "narHash": "sha256-FIqHByVqxCprNjor1NqF80F2QQoiiyqanNNefdlvOg4=", 5 + "lastModified": 1773646010, 6 + "narHash": "sha256-iYrs97hS7p5u4lQzuNWzuALGIOdkPXvjz7bviiBjUu8=", 7 7 "owner": "NixOS", 8 8 "repo": "nixpkgs", 9 - "rev": "62dc67aa6a52b4364dd75994ec00b51fbf474e50", 9 + "rev": "5b2c2d84341b2afb5647081c1386a80d7a8d8605", 10 10 "type": "github" 11 11 }, 12 12 "original": {
+1 -1
go.mod
··· 1 - module tangled.org/yemou.pink/atproto-sieve 1 + module tangled.org/yemou.pink/bsky-sieve 2 2 3 3 go 1.25.5 4 4
+130
image.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "errors" 6 + "fmt" 7 + "image" 8 + "os" 9 + "strconv" 10 + "strings" 11 + 12 + "github.com/mattn/go-sixel" 13 + "golang.org/x/image/draw" 14 + "golang.org/x/term" 15 + ) 16 + 17 + // The height should be a multiple of 6 otherwise when drawing the sixel, a black line will appear near the bottom 18 + const MAX_WIDTH = 126 19 + const MAX_HEIGHT = 126 20 + 21 + func getSixelArea() (int, int, error) { 22 + oldState, err := term.MakeRaw(int(os.Stdin.Fd())) 23 + if err != nil { 24 + return -1, -1, err 25 + } 26 + 27 + // The first escape sequence queries the sixel area while the second queries the cursor's relative 28 + // position. The second sequence's output isn't used, it is just there since all terminals support it so we 29 + // know we won't block forever while reading. 30 + fmt.Printf("\x1b[?2;1;0S\x1b[6n") 31 + 32 + result := "" 33 + hasArea := false 34 + char := make([]byte, 1) 35 + 36 + for { 37 + // This is the end of the cursor location query 38 + if char[0] == 'R' { 39 + break 40 + } 41 + 42 + _, err = os.Stdin.Read(char) 43 + if err != nil { 44 + return -1, -1, err 45 + } 46 + 47 + if !hasArea { 48 + result += string(char) 49 + } 50 + 51 + // This is the end of the sixel area query 52 + if len(result) != 0 && result[len(result)-1] == 'S' { 53 + hasArea = true 54 + } 55 + } 56 + 57 + err = term.Restore(int(os.Stdin.Fd()), oldState) 58 + if err != nil { 59 + return -1, -1, err 60 + } 61 + 62 + if !hasArea { 63 + return -1, -1, errors.New("Failed to get sixel area") 64 + } 65 + 66 + sixelArea := strings.Split(result, ";") 67 + if len(sixelArea) != 4 { 68 + return -1, -1, errors.New("Sixel area query returned an unexpected result") 69 + } 70 + 71 + width, err := strconv.Atoi(sixelArea[2]) 72 + if err != nil { 73 + return -1, -1, err 74 + } 75 + 76 + height, err := strconv.Atoi(sixelArea[3][0 : len(sixelArea[3])-1]) 77 + if err != nil { 78 + return -1, -1, err 79 + } 80 + 81 + return width, height, nil 82 + } 83 + 84 + // TODO: Ensure that when resizing the image, the height stays as a multiple of 6 85 + func displayImage(avatar []byte) error { 86 + avatarImg, _, err := image.Decode(bytes.NewReader(avatar)) 87 + if err != nil { 88 + return err 89 + } 90 + 91 + imageRatio := float64(avatarImg.Bounds().Max.X) / float64(avatarImg.Bounds().Max.Y) 92 + 93 + width, height, err := getSixelArea() 94 + if err != nil { 95 + return err 96 + } 97 + 98 + // Ensure the sixel area is within our allowed maximums 99 + if width > MAX_WIDTH { 100 + width = MAX_WIDTH 101 + } 102 + 103 + if height > MAX_HEIGHT { 104 + height = MAX_HEIGHT 105 + } 106 + 107 + // Adjust the sixelArea to the aspect ratio of the image 108 + if width > height { 109 + width = int(float64(height) * imageRatio) 110 + } else { 111 + height = int(float64(width) / imageRatio) 112 + } 113 + 114 + resizedAvatar := image.NewRGBA(image.Rect(0, 0, width, height)) 115 + draw.NearestNeighbor.Scale( 116 + resizedAvatar, 117 + resizedAvatar.Rect, 118 + avatarImg, 119 + avatarImg.Bounds(), 120 + draw.Over, 121 + nil, 122 + ) 123 + 124 + err = sixel.NewEncoder(os.Stdout).Encode(resizedAvatar) 125 + if err != nil { 126 + return err 127 + } 128 + 129 + return nil 130 + }
+73 -196
main.go
··· 1 - // TODO: Look into storing and reusing the oauth token instead of making a whole bunch 2 1 package main 3 2 4 3 import ( 5 - "bytes" 4 + "bufio" 6 5 "context" 7 6 _ "embed" 8 - "errors" 9 7 "flag" 10 8 "fmt" 11 - "image" 12 9 _ "image/jpeg" 13 10 _ "image/png" 14 11 "log" 15 - "net" 16 - "net/http" 17 - "net/url" 18 12 "os" 19 - "strconv" 20 - "strings" 21 13 22 14 "github.com/bluesky-social/indigo/api/atproto" 23 15 "github.com/bluesky-social/indigo/api/bsky" 24 16 "github.com/bluesky-social/indigo/atproto/atclient" 25 - "github.com/bluesky-social/indigo/atproto/auth/oauth" 26 17 "github.com/bluesky-social/indigo/atproto/identity" 27 18 "github.com/bluesky-social/indigo/atproto/syntax" 28 19 "github.com/bluesky-social/indigo/util" 29 - "github.com/mattn/go-sixel" 30 - "golang.org/x/image/draw" 31 20 "golang.org/x/term" 32 21 ) 33 22 34 - //go:embed static/callback.html 35 - var callbackHTML []byte 36 - 37 - func callbackListener(ctx context.Context, result chan url.Values) (int, error) { 38 - listener, err := net.Listen("tcp", ":0") 39 - if err != nil { 40 - return 0, err 41 - } 42 - 43 - mux := http.NewServeMux() 44 - server := &http.Server{Handler: mux} 45 - 46 - mux.HandleFunc("/callback", func(writer http.ResponseWriter, request *http.Request) { 47 - result <- request.URL.Query() 48 - writer.Header().Set("Content-Type", "text/html;charset=utf-8") 49 - writer.WriteHeader(200) 50 - writer.Write(callbackHTML) 51 - go server.Shutdown(ctx) 52 - }) 53 - 54 - go func() { 55 - err := server.Serve(listener) 56 - if !errors.Is(err, http.ErrServerClosed) { 57 - panic(err) 58 - } 59 - }() 60 - 61 - return listener.Addr().(*net.TCPAddr).Port, nil 62 - } 63 - 64 - func authenticate(ctx context.Context, handle *string) (*oauth.ClientApp, *oauth.ClientSessionData, error) { 65 - config := oauth.ClientConfig{ 66 - ClientID: "https://bsky-sieve-cli.b77.boo/oauth-client-metadata.json", 67 - Scopes: []string{ 68 - "atproto", 69 - "atproto repo?collection=app.bsky.graph.follow&action=delete", 70 - }, 71 - UserAgent: "atproto-sieve", 72 - } 23 + const BSKY_BASE_URL = "https://bsky.app/profile" 73 24 74 - // TODO: Store the oauth stuff somewhere so we don't have to authenticate everytime 75 - oauthClient := oauth.NewClientApp(&config, oauth.NewMemStore()) 25 + func getProfileInformation( 26 + ctx context.Context, 27 + apiClient *atclient.APIClient, 28 + record *bsky.GraphFollow, 29 + ) (string, string, string, []byte) { 30 + identityDirectory := identity.DefaultDirectory() 76 31 77 - callbackResult := make(chan url.Values, 1) 78 - listenPort, err := callbackListener(ctx, callbackResult) 32 + did, _ := syntax.ParseDID(record.Subject) 33 + doc, err := identityDirectory.LookupDID(ctx, did) 79 34 if err != nil { 80 - return nil, nil, err 35 + log.Printf("Failed to get identity document for %s: %s", record.Subject, err) 36 + return "", "", "", nil 81 37 } 82 38 83 - config.CallbackURL = fmt.Sprintf("http://127.0.0.1:%d/callback", listenPort) 39 + profileRecord, err := atproto.RepoGetRecord(ctx, apiClient, 40 + "", 41 + "app.bsky.actor.profile", 42 + record.Subject, 43 + "self", 44 + ) 84 45 85 - authURL, err := oauthClient.StartAuthFlow(ctx, *handle) 86 46 if err != nil { 87 - return nil, nil, err 47 + log.Printf("Failed to get profile record for %s: %s", record.Subject, err) 48 + return string(doc.Handle), "", "", nil 88 49 } 89 50 90 - fmt.Println("Login URL:", authURL) 51 + profile := profileRecord.Value.Val.(*bsky.ActorProfile) 52 + blobClient := atclient.NewAPIClient(doc.DIDDocument().Service[0].ServiceEndpoint) 53 + avatar, err := atproto.SyncGetBlob(ctx, blobClient, 54 + profile.Avatar.Ref.String(), 55 + record.Subject, 56 + ) 91 57 92 - sessionData, err := oauthClient.ProcessCallback(ctx, <-callbackResult) 93 58 if err != nil { 94 - return nil, nil, err 59 + log.Println("Failed to download avatar", err) 60 + return string(doc.Handle), *profile.DisplayName, *profile.Description, nil 95 61 } 96 62 97 - return oauthClient, sessionData, nil 63 + return string(doc.Handle), *profile.DisplayName, *profile.Description, avatar 98 64 } 99 65 100 66 func main() { ··· 102 68 log.Fatal("Expected to be running inside of a terminal emulator") 103 69 } 104 70 105 - fetchSize := flag.Int64("f", 50, "Number of records to fetch at a time (>= 1 and <= 100)") 71 + fetchLen := flag.Int64("f", 50, "Number of records to fetch at a time (>= 1 and <= 100)") 106 72 handle := flag.String("h", "", "Account handle to use") 73 + lastCursor := flag.String("c", "", "Cursor to resume from") 107 74 flag.Parse() 108 75 109 - if *fetchSize < 1 || *fetchSize > 100 { 76 + if *fetchLen < 1 || *fetchLen > 100 { 110 77 log.Fatal("The fetch size should be between 1 and 100 (inclusive)") 111 78 } 112 79 ··· 118 85 119 86 oauthClient, sessionData, err := authenticate(ctx, handle) 120 87 if err != nil { 121 - log.Fatal(err) 88 + log.Fatal("Failed to authenticate:", err) 122 89 } 123 90 124 91 fmt.Printf("Logged in as: %s (%s)\n", *handle, sessionData.AccountDID) ··· 130 97 ) 131 98 132 99 if err != nil { 133 - log.Fatal(err) 100 + log.Fatal("Failed to get oauth session:", err) 134 101 } 135 102 136 103 apiClient := session.APIClient() 137 - dir := identity.DefaultDirectory() 138 104 139 105 // Save and Restore cursor position 140 106 fmt.Printf("\x1b7") ··· 144 110 fmt.Printf("\x1b[?1049h") 145 111 defer fmt.Printf("\x1b[?1049l") 146 112 147 - // TODO: add a cli flag that allows us to resume from a specific cursor 148 - cursor := "" 113 + cursor := *lastCursor 149 114 for { 150 115 records, err := atproto.RepoListRecords(ctx, apiClient, 151 116 "app.bsky.graph.follow", 152 117 cursor, 153 - *fetchSize, 118 + *fetchLen, 154 119 sessionData.AccountDID.String(), 155 120 false, 156 121 ) 157 122 158 123 if err != nil { 159 - log.Fatal(err) 124 + log.Fatal("Failed to get records:", err) 125 + } 126 + 127 + if len(records.Records) == 0 { 128 + break 160 129 } 161 130 131 + prevCursor := cursor 162 132 cursor = *records.Cursor 163 133 164 134 for _, record := range records.Records { 165 - // Clear the screen and move cursor home 135 + // Clear the screen and move cursor home (first cell of the terminal) 166 136 fmt.Printf("\x1b[2J") 167 137 fmt.Printf("\x1b[H") 168 138 169 139 followRecord := record.Value.Val.(*bsky.GraphFollow) 170 - profileRecord, err := atproto.RepoGetRecord(ctx, apiClient, 171 - "", 172 - "app.bsky.actor.profile", 173 - followRecord.Subject, 174 - "self", 140 + handle, displayName, description, avatar := getProfileInformation( 141 + ctx, apiClient, 142 + followRecord, 175 143 ) 176 144 145 + err = displayImage(avatar) 177 146 if err != nil { 178 - log.Fatal(err) 147 + log.Println("Failed to display avatar:", err) 179 148 } 180 149 181 - profile := profileRecord.Value.Val.(*bsky.ActorProfile) 182 - did, err := syntax.ParseDID(followRecord.Subject) 183 - if err != nil { 184 - log.Fatal(err) 150 + if handle != "" { 151 + fmt.Printf("\n\nHandle: %s\n", handle) 185 152 } 186 153 187 - doc, err := dir.LookupDID(ctx, did) 188 - if err != nil { 189 - log.Fatal(err) 190 - } 191 - 192 - blobClient := atclient.NewAPIClient(doc.DIDDocument().Service[0].ServiceEndpoint) 193 - avatar, err := atproto.SyncGetBlob(ctx, blobClient, 194 - profile.Avatar.Ref.String(), 195 - followRecord.Subject, 196 - ) 197 - 198 - if err != nil { 199 - log.Fatal(err) 200 - } 201 - 202 - avatarImg, _, _ := image.Decode(bytes.NewReader(avatar)) 203 - imageWidth := avatarImg.Bounds().Max.X 204 - imageHeight := avatarImg.Bounds().Max.Y 205 - imageRatio := imageWidth / imageHeight 206 - 207 - oldState, err := term.MakeRaw(int(os.Stdin.Fd())) 208 - if err != nil { 209 - log.Fatal(err) 210 - } 211 - 212 - // The first escape sequence queries the sixel area while the second queries the cursor's relative 213 - // position. The second sequence's output isn't used, it is just there since all terminals support it so we 214 - // know we won't block forever while reading. 215 - fmt.Printf("\x1b[?2;1;0S\x1b[6n") 216 - 217 - hasSixelArea := false 218 - queryResult := "" 219 - b := make([]byte, 1) 220 - 221 - for { 222 - // This is the end of the cursor location query 223 - if b[0] == 'R' { 224 - break 225 - } 226 - 227 - os.Stdin.Read(b) 228 - 229 - if !hasSixelArea { 230 - queryResult += string(b) 231 - } 232 - 233 - if len(queryResult) != 0 && queryResult[len(queryResult)-1] == 'S' { 234 - hasSixelArea = true 235 - } 236 - } 237 - 238 - term.Restore(int(os.Stdin.Fd()), oldState) 239 - 240 - if !hasSixelArea { 241 - log.Fatal("Failed to get sixel area") 242 - } 243 - 244 - sixelArea := strings.Split(queryResult, ";") 245 - 246 - terminalSixelWidth, err := strconv.Atoi(sixelArea[2]) 247 - if err != nil { 248 - log.Fatal(err) 249 - } 250 - 251 - terminalSixelHeight, err := strconv.Atoi(sixelArea[3][0 : len(sixelArea[3])-1]) 252 - if err != nil { 253 - log.Fatal(err) 154 + if displayName != "" { 155 + fmt.Printf("Display Name: %s\n", displayName) 254 156 } 255 157 256 - if terminalSixelWidth > terminalSixelHeight { 257 - terminalSixelWidth = terminalSixelHeight * imageRatio 258 - } else { 259 - terminalSixelHeight = terminalSixelWidth / imageRatio 260 - } 158 + fmt.Printf("DID: %s\n", followRecord.Subject) 159 + fmt.Println("Profile URL: " + BSKY_BASE_URL + "/" + followRecord.Subject) 261 160 262 - // TODO: Instead of just doing half of the terminal size, we probably want a minimum size that we want to 263 - // display 264 - maxImageWidth := (terminalSixelWidth / 2) 265 - maxImageHeight := (terminalSixelHeight / 2) 266 - 267 - maxImageRatio := maxImageWidth / maxImageHeight 268 - 269 - var width, height int 270 - if maxImageRatio > imageRatio { 271 - width = maxImageWidth 272 - height = width / imageRatio 273 - } else { 274 - height = maxImageHeight 275 - width = height * imageRatio 161 + if description != "" { 162 + fmt.Printf("Description:\n%s\n\n", description) 276 163 } 277 164 278 - resizedAvatar := image.NewRGBA(image.Rect(0, 0, width, height)) 279 - draw.NearestNeighbor.Scale( 280 - resizedAvatar, 281 - resizedAvatar.Rect, 282 - avatarImg, 283 - avatarImg.Bounds(), 284 - draw.Over, 285 - nil, 286 - ) 287 - 288 - if sixel.NewEncoder(os.Stdout).Encode(resizedAvatar) != nil { 289 - log.Fatal(err) 165 + // TODO: Figure out a better way to display/save the previous cursor 166 + // I'd like to display the cursor after quiting 167 + if prevCursor != "" { 168 + fmt.Println("Last Cursor:", prevCursor) 290 169 } 291 170 292 - fmt.Printf("\n\nUser: %s (%s)\n", *profile.DisplayName, doc.Handle) 293 - fmt.Printf("DID: %s\n", followRecord.Subject) 294 - fmt.Println("Profile URL: " + "https://bsky.app/profile/" + followRecord.Subject) 295 - fmt.Printf("Description:\n%s\n\n", *profile.Description) 171 + user_input: 296 172 fmt.Print("Keep this record ([Y]es / [n]o / [q]uit)? ") 297 173 298 - user_input: 299 - // TODO: Switch to bufio, fmt.Scanln doesn't accept empty input making the deafult value not really work 300 - var answer string 301 - _, err = fmt.Scanln(&answer) 174 + scanner := bufio.NewScanner(os.Stdin) 175 + scanner.Scan() 176 + err = scanner.Err() 302 177 if err != nil { 303 - log.Fatal(err) 178 + log.Fatal("Failed to get user input:", scanner.Err()) 304 179 } 305 180 306 - switch answer { 181 + switch scanner.Text() { 307 182 case "", "Y", "y": 308 - fmt.Println("Yes") 309 183 case "N", "n": 310 - fmt.Println("Deleting record...") 184 + // NOTE: The log messages here actually won't ever be seen 311 185 aturi, err := util.ParseAtUri(record.Uri) 312 186 if err != nil { 313 - log.Fatal(err) 187 + log.Println("Failed to parse aturi... Skipping deletion:", err) 188 + continue 314 189 } 315 190 316 191 _, err = atproto.RepoDeleteRecord(ctx, apiClient, ··· 322 197 SwapRecord: nil, 323 198 }, 324 199 ) 200 + 201 + if err != nil { 202 + log.Println("Failed to delete record:", err) 203 + } 325 204 case "Q", "q": 326 205 return 327 206 default: 328 207 fmt.Println("Expected either 'y', 'n', or 'q'") 329 - fmt.Print("Keep this record ([Y]es / [n]o / [q]uit)? ") 330 208 goto user_input 331 209 } 332 - 333 210 } 334 211 } 335 212 }
+2 -2
static/callback.html
··· 3 3 <head> 4 4 <meta charset="UTF-8"> 5 5 <meta name="viewport" content="width=device-width"> 6 - <title>ATproto Sieve</title> 6 + <title>bsky-sieve cli</title> 7 7 </head> 8 8 <body> 9 - <h1>ATproto Sieve</h1> 9 + <h1>bsky-sieve cli</h1> 10 10 <p><strong>Authenticated!</strong></p> 11 11 <p>You can safely close this tab.</p> 12 12 </body>
+2 -2
static/client-metadata.json
··· 1 1 { 2 - "client_id": "https://bsky-sieve.b77.boo/oauth-client-metadata.json", 2 + "client_id": "https://bsky-sieve-cli.b77.boo/oauth-client-metadata.json", 3 3 "application_type": "native", 4 4 "grant_types": [ 5 5 "authorization_code" ··· 13 13 ], 14 14 "token_endpoint_auth_method": "none", 15 15 "dpop_bound_access_tokens": true, 16 - "client_name": "atproto-sieve" 16 + "client_name": "bsky-sieve" 17 17 }