Every like gives me a bigger pumpkin head
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}