Every like gives me a bigger pumpkin head
at main 371 lines 9.7 kB view raw
1package main 2 3import ( 4 "bytes" 5 "context" 6 "fmt" 7 "image" 8 "image/draw" 9 "image/jpeg" 10 "image/png" 11 "io" 12 "log" 13 "os" 14 "os/signal" 15 "path/filepath" 16 "syscall" 17 18 comatproto "github.com/bluesky-social/indigo/api/atproto" 19 appbsky "github.com/bluesky-social/indigo/api/bsky" 20 "github.com/bluesky-social/indigo/atproto/atclient" 21 "github.com/bluesky-social/indigo/atproto/syntax" 22 lexutil "github.com/bluesky-social/indigo/lex/util" 23 "github.com/coder/websocket" 24 "github.com/joho/godotenv" 25 xdraw "golang.org/x/image/draw" 26) 27 28// PumpkinHead context for increasing the pumpkin head/ 29type PumpkinHead struct { 30 bodyImg image.Image 31 headImg image.Image 32 callCount int 33 scaleStep float64 34} 35 36func (p *PumpkinHead) Reset() { 37 p.callCount = 0 38} 39 40func (p *PumpkinHead) GetCallCount() int { 41 return p.callCount 42} 43 44// NewPumpkinHead commence pumpkinhead 45func NewPumpkinHead(bodyPath, headPath string) (*PumpkinHead, error) { 46 // Load body image 47 bodyFile, err := os.Open(bodyPath) 48 if err != nil { 49 return nil, fmt.Errorf("failed to open body image: %w", err) 50 } 51 defer bodyFile.Close() 52 53 bodyImg, err := jpeg.Decode(bodyFile) 54 if err != nil { 55 return nil, fmt.Errorf("failed to decode body image: %w", err) 56 } 57 58 // Load head image 59 headFile, err := os.Open(headPath) 60 if err != nil { 61 return nil, fmt.Errorf("failed to open head image: %w", err) 62 } 63 defer headFile.Close() 64 65 headImg, err := png.Decode(headFile) 66 if err != nil { 67 return nil, fmt.Errorf("failed to decode head image: %w", err) 68 } 69 70 return &PumpkinHead{ 71 bodyImg: bodyImg, 72 headImg: headImg, 73 callCount: 0, 74 scaleStep: 0.01, // How much my big head grows each call 75 }, nil 76} 77 78// GrowHead Increases the pumpkin head by values in PumpkinHead.scaleStep 79func (p *PumpkinHead) GrowHead() (image.Image, error) { 80 p.callCount++ 81 82 // Calculate new scale (starts at 1.0, grows by scaleStep each time) 83 scale := 1.0 + (float64(p.callCount) * p.scaleStep) 84 85 fmt.Printf("Call #%d: Growing head to %.1fx original size\n", p.callCount, scale) 86 87 bodyBounds := p.bodyImg.Bounds() 88 headBounds := p.headImg.Bounds() 89 90 newHeadWidth := int(float64(headBounds.Dx()) * scale) 91 newHeadHeight := int(float64(headBounds.Dy()) * scale) 92 93 scaledHead := image.NewRGBA(image.Rect(0, 0, newHeadWidth, newHeadHeight)) 94 95 // Scale the head using bilinear interpolation 96 xdraw.BiLinear.Scale(scaledHead, scaledHead.Bounds(), p.headImg, headBounds, draw.Over, nil) 97 98 output := image.NewRGBA(bodyBounds) 99 100 // Drawing the base layer image 101 draw.Draw(output, bodyBounds, p.bodyImg, image.Point{0, 0}, draw.Src) 102 103 // Calculate position to center the head on top of the body 104 // Place head in upper portion of the image 105 headX := (bodyBounds.Dx() - newHeadWidth) / 2 106 headY := bodyBounds.Dy()/8 - newHeadHeight/2 + 350 // the 350 is to line up with my head 107 108 headPos := image.Rect(headX, headY, headX+newHeadWidth, headY+newHeadHeight) 109 110 // Draw scaled head on top 111 draw.Draw(output, headPos, scaledHead, image.Point{0, 0}, draw.Over) 112 113 return output, nil 114} 115 116// CompressImage compresses the image to JPEG format with the given quality (1-100) 117func CompressImage(img image.Image, quality int) (*bytes.Buffer, error) { 118 buf := new(bytes.Buffer) 119 err := jpeg.Encode(buf, img, &jpeg.Options{Quality: quality}) 120 if err != nil { 121 return nil, fmt.Errorf("failed to compress image: %w", err) 122 } 123 return buf, nil 124} 125 126// saveToFiles saves the image to a file. Mostly used for testing 127func saveToFiles(pumpkin *PumpkinHead, outputDir string, newPic image.Image) { 128 129 // Create output directory if it doesn't exist 130 if err := os.MkdirAll(outputDir, 0755); err != nil { 131 writeErrorAndExit(err) 132 } 133 134 filename := filepath.Join(outputDir, fmt.Sprintf("bighead_%03d.jpg", pumpkin.callCount)) 135 outFile, err := os.Create(filename) 136 if err != nil { 137 writeErrorAndExit(err) 138 } 139 defer outFile.Close() 140 141 compressed, err := CompressImage(newPic, 80) 142 if err != nil { 143 writeErrorAndExit(err) 144 } 145 146 if _, err := outFile.Write(compressed.Bytes()); err != nil { 147 writeErrorAndExit(err) 148 } 149} 150 151// AuthSession stores the auth session data. ty goat 152type AuthSession struct { 153 DID syntax.DID `json:"did"` 154 Password string `json:"password"` 155 AccessToken string `json:"access_token"` 156 RefreshToken string `json:"session_token"` 157 PDS string `json:"pds"` 158} 159 160// AtProtoStuff stores the atproto stuff. or more correctly the context of auth and a authenticated client 161type AtProtoStuff struct { 162 authSession AuthSession 163 Client *atclient.APIClient 164} 165 166// Login logs into their PDS and sets AtProtoStuff 167func Login(ctx context.Context, host, did, password string) (*AtProtoStuff, error) { 168 169 client, err := atclient.LoginWithPasswordHost(ctx, host, did, password, "", nil) 170 171 if err != nil { 172 return nil, err 173 } 174 175 passAuth, ok := client.Auth.(*atclient.PasswordAuth) 176 if !ok { 177 return nil, fmt.Errorf("expected password auth") 178 } 179 180 sess := AuthSession{ 181 DID: passAuth.Session.AccountDID, 182 PDS: passAuth.Session.Host, 183 Password: password, 184 AccessToken: passAuth.Session.AccessToken, 185 RefreshToken: passAuth.Session.RefreshToken, 186 } 187 188 return &AtProtoStuff{ 189 authSession: sess, 190 Client: client, 191 }, nil 192} 193 194// loadAuthClient ty again goat 195func (a *AtProtoStuff) loadAuthClient(ctx context.Context) (*atclient.APIClient, error) { 196 197 // first try to resume session 198 client := atclient.ResumePasswordSession(atclient.PasswordSessionData{ 199 AccessToken: a.authSession.AccessToken, 200 RefreshToken: a.authSession.RefreshToken, 201 AccountDID: a.authSession.DID, 202 Host: a.authSession.PDS, 203 }, a.authRefreshCallback) 204 205 // check that auth is working 206 _, err := comatproto.ServerGetSession(ctx, client) 207 if nil == err { 208 return client, nil 209 } 210 211 return atclient.LoginWithPasswordHost(ctx, a.authSession.PDS, a.authSession.DID.String(), a.authSession.Password, "", nil) 212} 213 214// authRefreshCallback is called when the auth session expires and refreshes the auth session 215func (a *AtProtoStuff) authRefreshCallback(ctx context.Context, data atclient.PasswordSessionData) { 216 fmt.Println("auth refresh callback") 217 218 a.authSession.DID = data.AccountDID 219 a.authSession.AccessToken = data.AccessToken 220 a.authSession.RefreshToken = data.RefreshToken 221 a.authSession.PDS = data.Host 222} 223 224func (a *AtProtoStuff) updatePumpkinHead(ctx context.Context, image io.Reader) (err error) { 225 226 client, err := a.loadAuthClient(ctx) 227 if err != nil { 228 return err 229 } 230 231 profileNsid := "app.bsky.actor.profile" 232 selfKey := "self" 233 234 currentProfileResp, err := comatproto.RepoGetRecord( 235 ctx, 236 client, 237 "", 238 profileNsid, 239 client.AccountDID.String(), 240 selfKey) 241 242 if err != nil { 243 return err 244 } 245 246 profile := currentProfileResp.Value.Val.(*appbsky.ActorProfile) 247 248 blobUploadResp, err := comatproto.RepoUploadBlob(ctx, a.Client, image) 249 if err != nil { 250 return err 251 } 252 profile.Avatar = blobUploadResp.Blob 253 254 _, err = comatproto.RepoPutRecord(ctx, a.Client, &comatproto.RepoPutRecord_Input{ 255 Collection: profileNsid, 256 Repo: a.Client.AccountDID.String(), 257 Rkey: selfKey, 258 SwapRecord: currentProfileResp.Cid, 259 Record: &lexutil.LexiconTypeDecoder{ 260 Val: profile, 261 }, 262 }) 263 264 if err != nil { 265 return err 266 } 267 268 return nil 269} 270 271func writeErrorAndExit(err error) { 272 _, err = fmt.Fprintf(os.Stderr, "Error: %v\n", err) 273 if err != nil { 274 panic(err) 275 } 276 os.Exit(1) 277} 278 279func egoWatcher(ctx context.Context, conn *websocket.Conn, atProtoStuff *AtProtoStuff, pumpkin *PumpkinHead) { 280 for { 281 _, _, err := conn.Read(ctx) 282 if err != nil { 283 if websocket.CloseStatus(err) == websocket.StatusNormalClosure { 284 log.Println("Connection closed normally") 285 return 286 } 287 log.Println("Read error:", err) 288 return 289 } 290 291 fmt.Println("Someone liked one of your posts. Inflating ego") 292 293 newHeadWhoDis, err := pumpkin.GrowHead() 294 if err != nil { 295 writeErrorAndExit(err) 296 } 297 298 compressed, err := CompressImage(newHeadWhoDis, 80) 299 if err != nil { 300 log.Printf("Failed to compress image: %v", err) 301 continue 302 } 303 304 if err := atProtoStuff.updatePumpkinHead(ctx, compressed); err != nil { 305 log.Printf("Failed to update Bluesky profile: %v", err) 306 } 307 } 308} 309 310func main() { 311 312 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 313 defer stop() 314 315 err := godotenv.Load() 316 if err != nil { 317 writeErrorAndExit(err) 318 } 319 320 usersDid := os.Getenv("DID") 321 pdsHost := os.Getenv("PDS_HOST") 322 appPassword := os.Getenv("APP_PASSWORD") 323 324 fmt.Fprintf(os.Stderr, "Logging with DID %s to %s\n", usersDid, pdsHost) 325 326 var atProtoStuff *AtProtoStuff 327 atProtoStuff, err = Login(ctx, pdsHost, usersDid, appPassword) 328 if err != nil { 329 writeErrorAndExit(err) 330 } 331 332 //Resets back to a baseline pumpkinhead 333 ogPumpkinHead, err := os.Open("./base_images/base_big_head.jpg") 334 if err != nil { 335 writeErrorAndExit(err) 336 } 337 338 err = atProtoStuff.updatePumpkinHead(ctx, ogPumpkinHead) 339 if err != nil { 340 writeErrorAndExit(err) 341 } 342 343 //fmt.Println(app_password) 344 345 spaceDustUrl := fmt.Sprintf("wss://spacedust.microcosm.blue/subscribe?wantedSubjectDids=%s&wantedSources=app.bsky.feed.like:subject.uri", usersDid) 346 347 pumpkin, err := NewPumpkinHead("base_images/pumpkin_body_cropped.jpg", "base_images/better_pumpkin_head.png") 348 349 if err != nil { 350 writeErrorAndExit(err) 351 } 352 353 fmt.Println("Pumpkin Head Grower!") 354 fmt.Println("==================") 355 356 conn, _, err := websocket.Dial(ctx, spaceDustUrl, nil) 357 if err != nil { 358 log.Fatal(err) 359 } 360 defer conn.Close(websocket.StatusNormalClosure, "done") 361 362 fmt.Println("Connected to spacedust!") 363 364 // Watching for likes, like a hawk 365 go egoWatcher(ctx, conn, atProtoStuff, pumpkin) 366 367 // Wait for stop signal 368 <-ctx.Done() 369 370 fmt.Println("\nStopping... Check the 'bighead' directory for results.") 371}