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}