···11+// TODO: Look into storing and reusing the oauth token instead of making a whole bunch
12package main
2334import (
55+ "bytes"
46 "context"
57 _ "embed"
68 "errors"
79 "flag"
810 "fmt"
1111+ "image"
1212+ _ "image/jpeg"
1313+ _ "image/png"
914 "log"
1015 "net"
1116 "net/http"
1217 "net/url"
1818+ "os"
13192020+ "github.com/bluesky-social/indigo/api/atproto"
2121+ "github.com/bluesky-social/indigo/api/bsky"
2222+ "github.com/bluesky-social/indigo/atproto/atclient"
1423 "github.com/bluesky-social/indigo/atproto/auth/oauth"
2424+ "github.com/bluesky-social/indigo/atproto/identity"
2525+ "github.com/bluesky-social/indigo/atproto/syntax"
2626+ "github.com/mattn/go-sixel"
2727+ "golang.org/x/image/draw"
2828+ "golang.org/x/term"
1529)
16301731//go:embed static/callback.html
1832var callbackHTML []byte
3333+3434+const font_size = 16
19352036func callbackListener(ctx context.Context, result chan url.Values) (int, error) {
2137 listener, err := net.Listen("tcp", ":0")
···4460 return listener.Addr().(*net.TCPAddr).Port, nil
4561}
46624747-func main() {
4848- handle := flag.String("h", "", "The account handle to use")
4949- flag.Parse()
5050-5151- if *handle == "" {
5252- log.Fatal("Missing bluesky handle")
5353- }
5454-6363+func authenticate(ctx context.Context, handle *string) (*oauth.ClientApp, *oauth.ClientSessionData, error) {
5564 config := oauth.ClientConfig{
5656- ClientID: "https://bsky-sieve.yem.pink/oauth-client-metadata.json",
6565+ // TODO: Want to rename this back to bsky-sieve. It's unlikely I ever make this generic enough to work on other
6666+ // atproto services or even past bluesky follows and likes unless the demand is there
6767+ ClientID: "https://atproto-sieve.yem.pink/oauth-client-metadata.json",
5768 Scopes: []string{
5869 "atproto",
5970 "atproto repo?collection=app.bsky.feed.like&collection=app.bsky.graph.follow&action=delete",
6071 },
6161- UserAgent: "bsky-sieve",
7272+ UserAgent: "atproto-sieve",
6273 }
63747575+ // TODO: Store the oauth stuff somewhere so we don't have to authenticate everytime
6476 oauthClient := oauth.NewClientApp(&config, oauth.NewMemStore())
6565-6666- ctx := context.Background()
67776878 callbackResult := make(chan url.Values, 1)
6979 listenPort, err := callbackListener(ctx, callbackResult)
7080 if err != nil {
7171- log.Fatal(err)
8181+ return nil, nil, err
7282 }
73837484 config.CallbackURL = fmt.Sprintf("http://127.0.0.1:%d/callback", listenPort)
75857686 authURL, err := oauthClient.StartAuthFlow(ctx, *handle)
7787 if err != nil {
7878- log.Fatal(err)
8888+ return nil, nil, err
7989 }
80908191 fmt.Println("Login URL:", authURL)
82928383- session, err := oauthClient.ProcessCallback(ctx, <-callbackResult)
9393+ sessionData, err := oauthClient.ProcessCallback(ctx, <-callbackResult)
9494+ if err != nil {
9595+ return nil, nil, err
9696+ }
9797+9898+ return oauthClient, sessionData, nil
9999+}
100100+101101+func main() {
102102+ handle := flag.String("h", "", "Account handle to use")
103103+ // collection := flag.String("c", "likes", "Collection to sift ('likes' or 'follows')")
104104+ flag.Parse()
105105+106106+ if *handle == "" {
107107+ log.Fatal("Missing an atproto (bluesky) handle")
108108+ }
109109+110110+ ctx := context.Background()
111111+112112+ oauthClient, sessionData, err := authenticate(ctx, handle)
84113 if err != nil {
85114 log.Fatal(err)
86115 }
871168888- fmt.Printf("Logged in as: %s (%s)\n", *handle, session.AccountDID)
117117+ fmt.Printf("Logged in as: %s (%s)\n", *handle, sessionData.AccountDID)
118118+119119+ session, err := oauthClient.ResumeSession(
120120+ ctx,
121121+ sessionData.AccountDID,
122122+ sessionData.SessionID,
123123+ )
124124+125125+ if err != nil {
126126+ log.Fatal(err)
127127+ }
128128+129129+ apiClient := session.APIClient()
130130+ dir := identity.DefaultDirectory()
131131+132132+ // TODO: This should be in a while loop until we get no records back
133133+ records, err := atproto.RepoListRecords(ctx, apiClient,
134134+ "app.bsky.graph.follow",
135135+ "",
136136+ 5, // TODO: When done testing, increase this number
137137+ sessionData.AccountDID.String(),
138138+ false,
139139+ )
140140+141141+ if err != nil {
142142+ log.Fatal(err)
143143+ }
144144+145145+ for _, record := range records.Records {
146146+ followRecord := record.Value.Val.(*bsky.GraphFollow)
147147+ profileRecord, err := atproto.RepoGetRecord(ctx, apiClient,
148148+ "",
149149+ "app.bsky.actor.profile",
150150+ followRecord.Subject,
151151+ "self",
152152+ )
153153+154154+ if err != nil {
155155+ log.Fatal(err)
156156+ }
157157+158158+ profile := profileRecord.Value.Val.(*bsky.ActorProfile)
159159+ did, err := syntax.ParseDID(followRecord.Subject)
160160+ if err != nil {
161161+ log.Fatal(err)
162162+ }
163163+164164+ doc, err := dir.LookupDID(ctx, did)
165165+ if err != nil {
166166+ log.Fatal(err)
167167+ }
168168+169169+ blobClient := atclient.NewAPIClient(doc.DIDDocument().Service[0].ServiceEndpoint)
170170+ avatar, err := atproto.SyncGetBlob(ctx, blobClient,
171171+ profile.Avatar.Ref.String(),
172172+ followRecord.Subject,
173173+ )
174174+175175+ if err != nil {
176176+ log.Fatal(err)
177177+ }
178178+179179+ avatarImg, _, _ := image.Decode(bytes.NewReader(avatar))
180180+ imageWidth := avatarImg.Bounds().Max.X
181181+ imageHeight := avatarImg.Bounds().Max.Y
182182+ imageRatio := imageWidth / imageHeight
183183+184184+ terminalWidth, terminalHeight, err := term.GetSize(0)
185185+ if err != nil {
186186+ log.Fatal(err)
187187+ }
188188+189189+ if terminalWidth > terminalHeight {
190190+ terminalWidth = terminalHeight * imageRatio
191191+ } else {
192192+ terminalHeight = terminalWidth / imageRatio
193193+ }
194194+195195+ // TODO: Make the FONT_SIZE configureable via cli flag or figure
196196+ // out a way to get the cell height from the terminal
197197+ maxImageWidth := (terminalWidth / 2) * font_size
198198+ maxImageHeight := (terminalHeight / 2) * font_size
199199+200200+ maxImageRatio := maxImageWidth / maxImageHeight
201201+202202+ var width, height int
203203+ if maxImageRatio > imageRatio {
204204+ width = maxImageWidth
205205+ height = width / imageRatio
206206+ } else {
207207+ height = maxImageHeight
208208+ width = height * imageRatio
209209+ }
210210+211211+ resizedAvatar := image.NewRGBA(image.Rect(0, 0, width, height))
212212+ draw.NearestNeighbor.Scale(
213213+ resizedAvatar,
214214+ resizedAvatar.Rect,
215215+ avatarImg,
216216+ avatarImg.Bounds(),
217217+ draw.Over,
218218+ nil,
219219+ )
220220+221221+ if sixel.NewEncoder(os.Stdout).Encode(resizedAvatar) != nil {
222222+ log.Fatal(err)
223223+ }
224224+225225+ fmt.Printf("\nUser: %s (%s)\n", *profile.DisplayName, doc.Handle)
226226+ fmt.Printf("DID: %s\n", followRecord.Subject)
227227+ fmt.Println("Profile URL: " + "https://bsky.app/profile/" + followRecord.Subject)
228228+ fmt.Printf("Description: %s\n\n", *profile.Description)
229229+ fmt.Print("Keep this record ([Y]es / [n]o / [q]uit)? ")
230230+231231+ var answer string
232232+ _, err = fmt.Scanln(&answer)
233233+ if err != nil {
234234+ log.Fatal(err)
235235+ }
236236+237237+ switch answer {
238238+ case "":
239239+ fmt.Println("Default, Yes")
240240+ case "Y", "y":
241241+ fmt.Println("Yes")
242242+ case "N", "n":
243243+ fmt.Println("No")
244244+ case "Q", "q":
245245+ fmt.Println("Quit")
246246+ return
247247+ default:
248248+ // TODO: Replace this, just ask the user again
249249+ panic("Input should be either y, n, or q")
250250+ }
251251+ }
89252}
+7-3
readme.md
···11-# bsky-sieve
22-Interactively goes through either likes and follows and allows you to decide to
33-keep or remove the record from your account.
11+# atproto-sieve
22+Interactively go through a collection and decide to keep or remove records from
33+your account.
44+55+# TODO
66+- The oauth permissions need to be changed to allow deleting from any collection
77+ if we want this to work generally for all collections