bluesky appview implementation using microcosm and other services server.reddwarf.app
appview bluesky reddwarf microcosm
1package main 2 3import ( 4 "bytes" 5 "context" 6 "encoding/json" 7 "flag" 8 "fmt" 9 "io" 10 "log" 11 "net/http" 12 "os" 13 "sync" 14 "time" 15 16 did "github.com/whyrusleeping/go-did" 17 "tangled.org/whey.party/red-dwarf-server/auth" 18 "tangled.org/whey.party/red-dwarf-server/microcosm/constellation" 19 "tangled.org/whey.party/red-dwarf-server/microcosm/slingshot" 20 appbskyactordefs "tangled.org/whey.party/red-dwarf-server/shims/lex/app/bsky/actor/defs" 21 appbskyfeeddefs "tangled.org/whey.party/red-dwarf-server/shims/lex/app/bsky/feed/defs" 22 "tangled.org/whey.party/red-dwarf-server/shims/utils" 23 "tangled.org/whey.party/red-dwarf-server/sticket" 24 "tangled.org/whey.party/red-dwarf-server/store" 25 26 // "github.com/bluesky-social/indigo/atproto/atclient" 27 // comatproto "github.com/bluesky-social/indigo/api/atproto" 28 appbsky "github.com/bluesky-social/indigo/api/bsky" 29 "github.com/bluesky-social/indigo/atproto/syntax" 30 31 // "github.com/bluesky-social/indigo/atproto/atclient" 32 // "github.com/bluesky-social/indigo/atproto/identity" 33 // "github.com/bluesky-social/indigo/atproto/syntax" 34 "github.com/bluesky-social/indigo/api/agnostic" 35 "github.com/gin-contrib/cors" 36 "github.com/gin-gonic/gin" 37 // "github.com/bluesky-social/jetstream/pkg/models" 38) 39 40var ( 41 JETSTREAM_URL string 42 SPACEDUST_URL string 43 SLINGSHOT_URL string 44 CONSTELLATION_URL string 45) 46 47func initURLs(prod bool) { 48 if !prod { 49 JETSTREAM_URL = "wss://jetstream.whey.party/subscribe" 50 SPACEDUST_URL = "wss://spacedust.whey.party/subscribe" 51 SLINGSHOT_URL = "https://slingshot.whey.party" 52 CONSTELLATION_URL = "https://constellation.whey.party" 53 } else { 54 JETSTREAM_URL = "ws://localhost:6008/subscribe" 55 SPACEDUST_URL = "ws://localhost:9998/subscribe" 56 SLINGSHOT_URL = "http://localhost:7729" 57 CONSTELLATION_URL = "http://localhost:7728" 58 } 59} 60 61const ( 62 BSKYIMAGECDN_URL = "https://cdn.bsky.app" 63 BSKYVIDEOCDN_URL = "https://video.bsky.app" 64 serviceWebDID = "did:web:server.reddwarf.app" 65 serviceWebHost = "https://server.reddwarf.app" 66) 67 68func main() { 69 log.Println("red-dwarf-server started") 70 prod := flag.Bool("prod", false, "use production URLs instead of localhost") 71 flag.Parse() 72 73 initURLs(*prod) 74 75 ctx := context.Background() 76 mailbox := sticket.New() 77 sl := slingshot.NewSlingshot(SLINGSHOT_URL) 78 cs := constellation.NewConstellation(CONSTELLATION_URL) 79 // spacedust is type definitions only 80 // jetstream types is probably available from jetstream/pkg/models 81 82 router := gin.New() 83 router.Use(gin.Logger()) 84 router.Use(gin.Recovery()) 85 router.Use(cors.Default()) 86 87 router.GET("/.well-known/did.json", GetWellKnownDID) 88 89 auther, err := auth.NewAuth( 90 100_000, 91 time.Hour*12, 92 5, 93 serviceWebDID, //+"#bsky_appview", 94 ) 95 if err != nil { 96 log.Fatalf("Failed to create Auth: %v", err) 97 } 98 99 router.Use(auther.AuthenticateGinRequestViaJWT) 100 101 responsewow, err := agnostic.RepoGetRecord(ctx, sl, "", "app.bsky.actor.profile", "did:web:did12.whey.party", "self") 102 if err != nil { 103 log.Println(err) 104 } 105 106 log.Println(responsewow.Uri) 107 108 var didtest *utils.DID 109 didval, errdid := utils.NewDID("did:web:did12.whey.party") 110 if errdid != nil { 111 didtest = nil 112 } else { 113 didtest = &didval 114 } 115 profiletest, _, _ := appbskyactordefs.ProfileViewBasic(ctx, *didtest, sl, BSKYIMAGECDN_URL) 116 117 log.Println(*profiletest.DisplayName) 118 log.Println(*profiletest.Avatar) 119 120 router.GET("/ws", func(c *gin.Context) { 121 mailbox.HandleWS(c.Writer, c.Request) 122 }) 123 124 kv := store.NewKV() 125 126 // sad attempt to get putpref working. tldr it wont work without a client fork 127 // https://bsky.app/profile/did:web:did12.whey.party/post/3m75xtomd722n 128 router.GET("/xrpc/app.bsky.actor.putPreferences", func(c *gin.Context) { 129 c.Status(200) 130 }) 131 router.PUT("/xrpc/app.bsky.actor.putPreferences", func(c *gin.Context) { 132 c.Status(200) 133 }) 134 router.POST("/xrpc/app.bsky.actor.putPreferences", func(c *gin.Context) { 135 c.Status(200) 136 137 userDID := c.GetString("user_did") 138 body, err := io.ReadAll(c.Request.Body) 139 if err != nil { 140 c.JSON(400, gin.H{"error": "invalid body"}) 141 return 142 } 143 144 kv.Set(userDID, body) 145 146 }) 147 148 router.GET("/xrpc/app.bsky.actor.getPreferences", func(c *gin.Context) { 149 userDID := c.GetString("user_did") 150 val, ok := kv.Get(userDID) 151 if !ok { 152 c.JSON(200, gin.H{"preferences": []any{}}) 153 return 154 } 155 156 c.Data(200, "application/json", val) 157 158 }) 159 160 bskyappdid, _ := utils.NewDID("did:plc:z72i7hdynmk6r22z27h6tvur") 161 162 profiletest2, _, _ := appbskyactordefs.ProfileViewDetailed(ctx, bskyappdid, sl, cs, BSKYIMAGECDN_URL) 163 164 data, err := json.MarshalIndent(profiletest2, "", " ") 165 if err != nil { 166 panic(err) 167 } 168 fmt.Println(string(data)) 169 170 router.GET("/xrpc/app.bsky.actor.getProfiles", 171 func(c *gin.Context) { 172 actors := c.QueryArray("actors") 173 174 profiles := make([]*appbsky.ActorDefs_ProfileViewDetailed, 0, len(actors)) 175 176 for _, v := range actors { 177 did, err := utils.NewDID(v) 178 if err != nil { 179 continue 180 } 181 profile, _, _ := appbskyactordefs.ProfileViewDetailed(ctx, did, sl, cs, BSKYIMAGECDN_URL) 182 profiles = append(profiles, profile) 183 } 184 185 c.JSON(http.StatusOK, &appbsky.ActorGetProfiles_Output{ 186 Profiles: profiles, 187 }) 188 }) 189 190 router.GET("/xrpc/app.bsky.actor.getProfile", 191 func(c *gin.Context) { 192 actor := c.Query("actor") 193 did, err := utils.NewDID(actor) 194 if err != nil { 195 c.JSON(http.StatusBadRequest, nil) 196 return 197 } 198 profile, _, _ := appbskyactordefs.ProfileViewDetailed(ctx, did, sl, cs, BSKYIMAGECDN_URL) 199 c.JSON(http.StatusOK, profile) 200 }) 201 202 // really bad actually 203 router.GET("/xrpc/app.bsky.notification.listNotifications", 204 func(c *gin.Context) { 205 c.JSON(http.StatusOK, nil) 206 }) 207 208 router.GET("/xrpc/app.bsky.labeler.getServices", 209 func(c *gin.Context) { 210 dids := c.QueryArray("dids") 211 212 labelers := make([]*appbsky.LabelerGetServices_Output_Views_Elem, 0, len(dids)) 213 //profiles := make([]*appbsky.ActorDefs_ProfileViewDetailed, 0, len(dids)) 214 215 for _, v := range dids { 216 did, err := utils.NewDID(v) 217 if err != nil { 218 continue 219 } 220 labelerprofile, _, _ := appbskyactordefs.ProfileView(ctx, did, sl, BSKYIMAGECDN_URL) 221 labelerserviceresponse, _ := agnostic.RepoGetRecord(ctx, sl, "", "app.bsky.labeler.service", string(did), "self") 222 var labelerservice appbsky.LabelerService 223 if err := json.Unmarshal(*labelerserviceresponse.Value, &labelerservice); err != nil { 224 continue 225 } 226 227 a := "account" 228 b := "record" 229 c := "chat" 230 231 placeholderTypes := []*string{&a, &b, &c} 232 233 labeler := &appbsky.LabelerGetServices_Output_Views_Elem{ 234 LabelerDefs_LabelerView: &appbsky.LabelerDefs_LabelerView{ 235 // LexiconTypeID string `json:"$type" cborgen:"$type,const=app.bsky.labeler.defs#labelerView"` 236 LexiconTypeID: "app.bsky.labeler.defs#labelerView", 237 // Cid string `json:"cid" cborgen:"cid"` 238 Cid: *labelerserviceresponse.Cid, 239 // Creator *ActorDefs_ProfileView `json:"creator" cborgen:"creator"` 240 Creator: labelerprofile, 241 // IndexedAt string `json:"indexedAt" cborgen:"indexedAt"` 242 IndexedAt: labelerservice.CreatedAt, 243 // Labels []*comatproto.LabelDefs_Label `json:"labels,omitempty" cborgen:"labels,omitempty"` 244 Labels: nil, // seems to always be empty? 245 // LikeCount *int64 `json:"likeCount,omitempty" cborgen:"likeCount,omitempty"` 246 LikeCount: nil, // placeholder sorry 247 // Uri string `json:"uri" cborgen:"uri"` 248 Uri: labelerserviceresponse.Uri, 249 // Viewer *LabelerDefs_LabelerViewerState `json:"viewer,omitempty" cborgen:"viewer,omitempty"` 250 Viewer: nil, 251 }, 252 LabelerDefs_LabelerViewDetailed: &appbsky.LabelerDefs_LabelerViewDetailed{ 253 // LexiconTypeID string `json:"$type" cborgen:"$type,const=app.bsky.labeler.defs#labelerViewDetailed"` 254 LexiconTypeID: "app.bsky.labeler.defs#labelerViewDetailed", 255 // Cid string `json:"cid" cborgen:"cid"` 256 Cid: *labelerserviceresponse.Cid, 257 // Creator *ActorDefs_ProfileView `json:"creator" cborgen:"creator"` 258 Creator: labelerprofile, 259 // IndexedAt string `json:"indexedAt" cborgen:"indexedAt"` 260 IndexedAt: labelerservice.CreatedAt, 261 // Labels []*comatproto.LabelDefs_Label `json:"labels,omitempty" cborgen:"labels,omitempty"` 262 Labels: nil, // seems to always be empty? 263 // LikeCount *int64 `json:"likeCount,omitempty" cborgen:"likeCount,omitempty"` 264 LikeCount: nil, // placeholder sorry 265 // Policies *LabelerDefs_LabelerPolicies `json:"policies" cborgen:"policies"` 266 Policies: labelerservice.Policies, 267 // // reasonTypes: The set of report reason 'codes' which are in-scope for this service to review and action. These usually align to policy categories. If not defined (distinct from empty array), all reason types are allowed. 268 // ReasonTypes []*string `json:"reasonTypes,omitempty" cborgen:"reasonTypes,omitempty"` 269 ReasonTypes: nil, //usually not even present 270 // // subjectCollections: Set of record types (collection NSIDs) which can be reported to this service. If not defined (distinct from empty array), default is any record type. 271 // SubjectCollections []string `json:"subjectCollections,omitempty" cborgen:"subjectCollections,omitempty"` 272 SubjectCollections: nil, //usually not even present 273 // // subjectTypes: The set of subject types (account, record, etc) this service accepts reports on. 274 // SubjectTypes []*string `json:"subjectTypes,omitempty" cborgen:"subjectTypes,omitempty"` 275 SubjectTypes: placeholderTypes, 276 // Uri string `json:"uri" cborgen:"uri"` 277 Uri: labelerserviceresponse.Uri, 278 // Viewer *LabelerDefs_LabelerViewerState `json:"viewer,omitempty" cborgen:"viewer,omitempty"` 279 Viewer: nil, 280 }, 281 } 282 labelers = append(labelers, labeler) 283 } 284 285 c.JSON(http.StatusOK, &appbsky.LabelerGetServices_Output{ 286 Views: labelers, 287 }) 288 }) 289 290 router.GET("/xrpc/app.bsky.feed.getFeedGenerators", 291 func(c *gin.Context) { 292 feeds := c.QueryArray("feeds") 293 ctx := c.Request.Context() 294 295 type result struct { 296 view *appbsky.FeedDefs_GeneratorView 297 } 298 299 results := make([]result, len(feeds)) 300 301 var wg sync.WaitGroup 302 wg.Add(len(feeds)) 303 304 for i, raw := range feeds { 305 go func(i int, raw string) { 306 defer wg.Done() 307 308 aturi, err := syntax.ParseATURI(raw) 309 if err != nil { 310 return 311 } 312 313 did := aturi.Authority().String() 314 collection := aturi.Collection().String() 315 rkey := aturi.RecordKey().String() 316 317 repoDID, err := utils.NewDID(did) 318 if err != nil { 319 return 320 } 321 322 // fetch profile and record in parallel too (optional) 323 // but to keep it simple, do serial inside this goroutine 324 profile, _, _ := appbskyactordefs.ProfileView(ctx, repoDID, sl, BSKYIMAGECDN_URL) 325 326 rec, err := agnostic.RepoGetRecord(ctx, sl, "", collection, did, rkey) 327 if err != nil || rec.Value == nil { 328 return 329 } 330 331 var genRec appbsky.FeedGenerator 332 if err := json.Unmarshal(*rec.Value, &genRec); err != nil { 333 return 334 } 335 336 var avatar *string 337 if genRec.Avatar != nil { 338 u := utils.MakeImageCDN(repoDID, BSKYIMAGECDN_URL, "avatar", genRec.Avatar.Ref.String()) 339 avatar = &u 340 } 341 342 results[i].view = &appbsky.FeedDefs_GeneratorView{ 343 LexiconTypeID: "app.bsky.feed.defs#generatorView", 344 AcceptsInteractions: genRec.AcceptsInteractions, 345 Avatar: avatar, 346 Cid: *rec.Cid, 347 ContentMode: genRec.ContentMode, 348 Creator: profile, 349 Description: genRec.Description, 350 DescriptionFacets: genRec.DescriptionFacets, 351 Did: did, 352 DisplayName: genRec.DisplayName, 353 IndexedAt: genRec.CreatedAt, 354 Uri: rec.Uri, 355 } 356 }(i, raw) 357 } 358 359 wg.Wait() 360 361 // build final slice 362 out := make([]*appbsky.FeedDefs_GeneratorView, 0, len(results)) 363 for _, r := range results { 364 if r.view != nil { 365 out = append(out, r.view) 366 } 367 } 368 369 c.JSON(http.StatusOK, &appbsky.FeedGetFeedGenerators_Output{ 370 Feeds: out, 371 }) 372 }) 373 374 router.GET("/xrpc/app.bsky.feed.getPosts", 375 func(c *gin.Context) { 376 postsreq := c.QueryArray("uris") 377 ctx := c.Request.Context() 378 379 type result struct { 380 view *appbsky.FeedDefs_PostView 381 } 382 383 results := make([]result, len(postsreq)) 384 385 var wg sync.WaitGroup 386 wg.Add(len(postsreq)) 387 388 for i, raw := range postsreq { 389 go func(i int, raw string) { 390 defer wg.Done() 391 392 post, _, _ := appbskyfeeddefs.PostView(ctx, raw, sl, BSKYIMAGECDN_URL) 393 394 results[i].view = post 395 }(i, raw) 396 } 397 398 wg.Wait() 399 400 // build final slice 401 out := make([]*appbsky.FeedDefs_PostView, 0, len(results)) 402 for _, r := range results { 403 if r.view != nil { 404 out = append(out, r.view) 405 } 406 } 407 408 c.JSON(http.StatusOK, &appbsky.FeedGetPosts_Output{ 409 Posts: out, 410 }) 411 }) 412 413 yourJSONBytes, _ := os.ReadFile("./public/getConfig.json") 414 router.GET("/xrpc/app.bsky.unspecced.getConfig", func(c *gin.Context) { 415 c.DataFromReader(200, -1, "application/json", 416 bytes.NewReader(yourJSONBytes), nil) 417 }) 418 419 router.GET("/", func(c *gin.Context) { 420 log.Println("hello worldio !") 421 clientUUID := sticket.GetUUIDFromRequest(c.Request) 422 hasSticket := clientUUID != "" 423 if hasSticket { 424 go func(targetUUID string) { 425 // simulated heavy processing 426 time.Sleep(2 * time.Second) 427 428 lateData := map[string]any{ 429 "postId": 101, 430 "newComments": []string{ 431 "Wow great tutorial!", 432 "I am stuck on step 1.", 433 }, 434 } 435 436 success := mailbox.SendToClient(targetUUID, "post_thread_update", lateData) 437 if success { 438 log.Println("Successfully sent late data via Sticket") 439 } else { 440 log.Println("Failed to send late data (client disconnected?)") 441 } 442 }(clientUUID) 443 } 444 }) 445 router.Run(":7152") 446} 447 448func getPostThreadV2(w http.ResponseWriter, r *http.Request) { 449 log.Println("hello worldio !") 450} 451 452type DidResponse struct { 453 Context []string `json:"@context"` 454 ID string `json:"id"` 455 Service []did.Service `json:"service"` 456} 457 458/* 459 { 460 id: "#bsky_appview", 461 type: "BskyAppView", 462 serviceEndpoint: endpoint, 463 }, 464*/ 465func GetWellKnownDID(c *gin.Context) { 466 // Use a custom struct to fix missing omitempty on did.Document 467 serviceEndpoint := serviceWebHost 468 serviceDID, err := did.ParseDID(serviceWebDID) 469 if err != nil { 470 log.Println(fmt.Errorf("error parsing serviceDID: %w", err)) 471 return 472 } 473 serviceID, err := did.ParseDID("#bsky_appview") 474 if err != nil { 475 panic(err) 476 } 477 didDoc := did.Document{ 478 Context: []string{did.CtxDIDv1}, 479 ID: serviceDID, 480 Service: []did.Service{ 481 { 482 ID: serviceID, 483 Type: "BskyAppView", 484 ServiceEndpoint: serviceEndpoint, 485 }, 486 }, 487 } 488 didResponse := DidResponse{ 489 Context: didDoc.Context, 490 ID: didDoc.ID.String(), 491 Service: didDoc.Service, 492 } 493 c.JSON(http.StatusOK, didResponse) 494}