bluesky appview implementation using microcosm and other services
server.reddwarf.app
appview
bluesky
reddwarf
microcosm
1package main
2
3import (
4 "context"
5 "encoding/json"
6 "flag"
7 "fmt"
8 "io"
9 "log"
10 "net/http"
11 "time"
12
13 did "github.com/whyrusleeping/go-did"
14 "tangled.org/whey.party/red-dwarf-server/auth"
15 "tangled.org/whey.party/red-dwarf-server/microcosm/constellation"
16 "tangled.org/whey.party/red-dwarf-server/microcosm/slingshot"
17 appbskyactordefs "tangled.org/whey.party/red-dwarf-server/shims/lex/app/bsky/actor/defs"
18 "tangled.org/whey.party/red-dwarf-server/shims/utils"
19 "tangled.org/whey.party/red-dwarf-server/sticket"
20 "tangled.org/whey.party/red-dwarf-server/store"
21
22 // "github.com/bluesky-social/indigo/atproto/atclient"
23 // comatproto "github.com/bluesky-social/indigo/api/atproto"
24 appbsky "github.com/bluesky-social/indigo/api/bsky"
25 // "github.com/bluesky-social/indigo/atproto/atclient"
26 // "github.com/bluesky-social/indigo/atproto/identity"
27 // "github.com/bluesky-social/indigo/atproto/syntax"
28 "github.com/bluesky-social/indigo/api/agnostic"
29 "github.com/gin-contrib/cors"
30 "github.com/gin-gonic/gin"
31 // "github.com/bluesky-social/jetstream/pkg/models"
32)
33
34var (
35 JETSTREAM_URL string
36 SPACEDUST_URL string
37 SLINGSHOT_URL string
38 CONSTELLATION_URL string
39)
40
41func initURLs(prod bool) {
42 if !prod {
43 JETSTREAM_URL = "wss://jetstream.whey.party/subscribe"
44 SPACEDUST_URL = "wss://spacedust.whey.party/subscribe"
45 SLINGSHOT_URL = "https://slingshot.whey.party"
46 CONSTELLATION_URL = "https://constellation.whey.party"
47 } else {
48 JETSTREAM_URL = "ws://localhost:6008/subscribe"
49 SPACEDUST_URL = "ws://localhost:9998/subscribe"
50 SLINGSHOT_URL = "http://localhost:7729"
51 CONSTELLATION_URL = "http://localhost:7728"
52 }
53}
54
55const (
56 BSKYIMAGECDN_URL = "https://cdn.bsky.app"
57 BSKYVIDEOCDN_URL = "https://video.bsky.app"
58 serviceWebDID = "did:web:server.reddwarf.app"
59 serviceWebHost = "https://server.reddwarf.app"
60)
61
62func main() {
63 log.Println("red-dwarf-server started")
64 prod := flag.Bool("prod", false, "use production URLs instead of localhost")
65 flag.Parse()
66
67 initURLs(*prod)
68
69 ctx := context.Background()
70 mailbox := sticket.New()
71 sl := slingshot.NewSlingshot(SLINGSHOT_URL)
72 cs := constellation.NewConstellation(CONSTELLATION_URL)
73 // spacedust is type definitions only
74 // jetstream types is probably available from jetstream/pkg/models
75
76 router := gin.New()
77 router.Use(gin.Logger())
78 router.Use(gin.Recovery())
79 router.Use(cors.Default())
80
81 router.GET("/.well-known/did.json", GetWellKnownDID)
82
83 auther, err := auth.NewAuth(
84 100_000,
85 time.Hour*12,
86 5,
87 serviceWebDID, //+"#bsky_appview",
88 )
89 if err != nil {
90 log.Fatalf("Failed to create Auth: %v", err)
91 }
92
93 router.Use(auther.AuthenticateGinRequestViaJWT)
94
95 responsewow, err := agnostic.RepoGetRecord(ctx, sl, "", "app.bsky.actor.profile", "did:web:did12.whey.party", "self")
96 if err != nil {
97 log.Println(err)
98 }
99
100 log.Println(responsewow.Uri)
101
102 var didtest *utils.DID
103 didval, errdid := utils.NewDID("did:web:did12.whey.party")
104 if errdid != nil {
105 didtest = nil
106 } else {
107 didtest = &didval
108 }
109 profiletest, _, _ := appbskyactordefs.ProfileViewBasic(ctx, *didtest, sl, BSKYIMAGECDN_URL)
110
111 log.Println(*profiletest.DisplayName)
112 log.Println(*profiletest.Avatar)
113
114 router.GET("/ws", func(c *gin.Context) {
115 mailbox.HandleWS(c.Writer, c.Request)
116 })
117
118 kv := store.NewKV()
119
120 // sad attempt to get putpref working. tldr it wont work without a client fork
121 // https://bsky.app/profile/did:web:did12.whey.party/post/3m75xtomd722n
122 router.GET("/xrpc/app.bsky.actor.putPreferences", func(c *gin.Context) {
123 c.Status(200)
124 })
125 router.PUT("/xrpc/app.bsky.actor.putPreferences", func(c *gin.Context) {
126 c.Status(200)
127 })
128 router.POST("/xrpc/app.bsky.actor.putPreferences", func(c *gin.Context) {
129 c.Status(200)
130
131 userDID := c.GetString("user_did")
132 body, err := io.ReadAll(c.Request.Body)
133 if err != nil {
134 c.JSON(400, gin.H{"error": "invalid body"})
135 return
136 }
137
138 kv.Set(userDID, body)
139
140 })
141
142 router.GET("/xrpc/app.bsky.actor.getPreferences", func(c *gin.Context) {
143 userDID := c.GetString("user_did")
144 val, ok := kv.Get(userDID)
145 if !ok {
146 c.JSON(200, gin.H{"preferences": []any{}})
147 return
148 }
149
150 c.Data(200, "application/json", val)
151
152 })
153
154 bskyappdid, _ := utils.NewDID("did:plc:z72i7hdynmk6r22z27h6tvur")
155
156 profiletest2, _, _ := appbskyactordefs.ProfileViewDetailed(ctx, bskyappdid, sl, cs, BSKYIMAGECDN_URL)
157
158 data, err := json.MarshalIndent(profiletest2, "", " ")
159 if err != nil {
160 panic(err)
161 }
162 fmt.Println(string(data))
163
164 router.GET("/xrpc/app.bsky.actor.getProfiles",
165 func(c *gin.Context) {
166 actors := c.QueryArray("actors")
167
168 profiles := make([]*appbsky.ActorDefs_ProfileViewDetailed, 0, len(actors))
169
170 for _, v := range actors {
171 did, err := utils.NewDID(v)
172 if err != nil {
173 continue
174 }
175 profile, _, _ := appbskyactordefs.ProfileViewDetailed(ctx, did, sl, cs, BSKYIMAGECDN_URL)
176 profiles = append(profiles, profile)
177 }
178
179 c.JSON(http.StatusOK, &appbsky.ActorGetProfiles_Output{
180 Profiles: profiles,
181 })
182 })
183
184 router.GET("/xrpc/app.bsky.actor.getProfile",
185 func(c *gin.Context) {
186 actor := c.Query("actor")
187 did, err := utils.NewDID(actor)
188 if err != nil {
189 c.JSON(http.StatusBadRequest, nil)
190 return
191 }
192 profile, _, _ := appbskyactordefs.ProfileViewDetailed(ctx, did, sl, cs, BSKYIMAGECDN_URL)
193 c.JSON(http.StatusOK, profile)
194 })
195
196 // really bad actually
197 router.GET("/xrpc/app.bsky.notification.listNotifications",
198 func(c *gin.Context) {
199 c.JSON(http.StatusOK, nil)
200 })
201
202 router.GET("/xrpc/app.bsky.labeler.getServices",
203 func(c *gin.Context) {
204 dids := c.QueryArray("dids")
205
206 labelers := make([]*appbsky.LabelerGetServices_Output_Views_Elem, 0, len(dids))
207 //profiles := make([]*appbsky.ActorDefs_ProfileViewDetailed, 0, len(dids))
208
209 for _, v := range dids {
210 did, err := utils.NewDID(v)
211 if err != nil {
212 continue
213 }
214 labelerprofile, _, _ := appbskyactordefs.ProfileView(ctx, did, sl, BSKYIMAGECDN_URL)
215 labelerserviceresponse, _ := agnostic.RepoGetRecord(ctx, sl, "", "app.bsky.labeler.service", string(did), "self")
216 var labelerservice appbsky.LabelerService
217 if err := json.Unmarshal(*labelerserviceresponse.Value, &labelerservice); err != nil {
218 continue
219 }
220
221 a := "account"
222 b := "record"
223 c := "chat"
224
225 placeholderTypes := []*string{&a, &b, &c}
226
227 labeler := &appbsky.LabelerGetServices_Output_Views_Elem{
228 LabelerDefs_LabelerView: &appbsky.LabelerDefs_LabelerView{
229 // LexiconTypeID string `json:"$type" cborgen:"$type,const=app.bsky.labeler.defs#labelerView"`
230 LexiconTypeID: "app.bsky.labeler.defs#labelerView",
231 // Cid string `json:"cid" cborgen:"cid"`
232 Cid: *labelerserviceresponse.Cid,
233 // Creator *ActorDefs_ProfileView `json:"creator" cborgen:"creator"`
234 Creator: labelerprofile,
235 // IndexedAt string `json:"indexedAt" cborgen:"indexedAt"`
236 IndexedAt: labelerservice.CreatedAt,
237 // Labels []*comatproto.LabelDefs_Label `json:"labels,omitempty" cborgen:"labels,omitempty"`
238 Labels: nil, // seems to always be empty?
239 // LikeCount *int64 `json:"likeCount,omitempty" cborgen:"likeCount,omitempty"`
240 LikeCount: nil, // placeholder sorry
241 // Uri string `json:"uri" cborgen:"uri"`
242 Uri: labelerserviceresponse.Uri,
243 // Viewer *LabelerDefs_LabelerViewerState `json:"viewer,omitempty" cborgen:"viewer,omitempty"`
244 Viewer: nil,
245 },
246 LabelerDefs_LabelerViewDetailed: &appbsky.LabelerDefs_LabelerViewDetailed{
247 // LexiconTypeID string `json:"$type" cborgen:"$type,const=app.bsky.labeler.defs#labelerViewDetailed"`
248 LexiconTypeID: "app.bsky.labeler.defs#labelerViewDetailed",
249 // Cid string `json:"cid" cborgen:"cid"`
250 Cid: *labelerserviceresponse.Cid,
251 // Creator *ActorDefs_ProfileView `json:"creator" cborgen:"creator"`
252 Creator: labelerprofile,
253 // IndexedAt string `json:"indexedAt" cborgen:"indexedAt"`
254 IndexedAt: labelerservice.CreatedAt,
255 // Labels []*comatproto.LabelDefs_Label `json:"labels,omitempty" cborgen:"labels,omitempty"`
256 Labels: nil, // seems to always be empty?
257 // LikeCount *int64 `json:"likeCount,omitempty" cborgen:"likeCount,omitempty"`
258 LikeCount: nil, // placeholder sorry
259 // Policies *LabelerDefs_LabelerPolicies `json:"policies" cborgen:"policies"`
260 Policies: labelerservice.Policies,
261 // // 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.
262 // ReasonTypes []*string `json:"reasonTypes,omitempty" cborgen:"reasonTypes,omitempty"`
263 ReasonTypes: nil, //usually not even present
264 // // 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.
265 // SubjectCollections []string `json:"subjectCollections,omitempty" cborgen:"subjectCollections,omitempty"`
266 SubjectCollections: nil, //usually not even present
267 // // subjectTypes: The set of subject types (account, record, etc) this service accepts reports on.
268 // SubjectTypes []*string `json:"subjectTypes,omitempty" cborgen:"subjectTypes,omitempty"`
269 SubjectTypes: placeholderTypes,
270 // Uri string `json:"uri" cborgen:"uri"`
271 Uri: labelerserviceresponse.Uri,
272 // Viewer *LabelerDefs_LabelerViewerState `json:"viewer,omitempty" cborgen:"viewer,omitempty"`
273 Viewer: nil,
274 },
275 }
276 labelers = append(labelers, labeler)
277 }
278
279 c.JSON(http.StatusOK, &appbsky.LabelerGetServices_Output{
280 Views: labelers,
281 })
282 })
283
284 router.GET("/", func(c *gin.Context) {
285 log.Println("hello worldio !")
286 clientUUID := sticket.GetUUIDFromRequest(c.Request)
287 hasSticket := clientUUID != ""
288 if hasSticket {
289 go func(targetUUID string) {
290 // simulated heavy processing
291 time.Sleep(2 * time.Second)
292
293 lateData := map[string]any{
294 "postId": 101,
295 "newComments": []string{
296 "Wow great tutorial!",
297 "I am stuck on step 1.",
298 },
299 }
300
301 success := mailbox.SendToClient(targetUUID, "post_thread_update", lateData)
302 if success {
303 log.Println("Successfully sent late data via Sticket")
304 } else {
305 log.Println("Failed to send late data (client disconnected?)")
306 }
307 }(clientUUID)
308 }
309 })
310 router.Run(":7152")
311}
312
313func getPostThreadV2(w http.ResponseWriter, r *http.Request) {
314 log.Println("hello worldio !")
315}
316
317type DidResponse struct {
318 Context []string `json:"@context"`
319 ID string `json:"id"`
320 Service []did.Service `json:"service"`
321}
322
323/*
324 {
325 id: "#bsky_appview",
326 type: "BskyAppView",
327 serviceEndpoint: endpoint,
328 },
329*/
330func GetWellKnownDID(c *gin.Context) {
331 // Use a custom struct to fix missing omitempty on did.Document
332 serviceEndpoint := serviceWebHost
333 serviceDID, err := did.ParseDID(serviceWebDID)
334 if err != nil {
335 log.Println(fmt.Errorf("error parsing serviceDID: %w", err))
336 return
337 }
338 serviceID, err := did.ParseDID("#bsky_appview")
339 if err != nil {
340 panic(err)
341 }
342 didDoc := did.Document{
343 Context: []string{did.CtxDIDv1},
344 ID: serviceDID,
345 Service: []did.Service{
346 {
347 ID: serviceID,
348 Type: "BskyAppView",
349 ServiceEndpoint: serviceEndpoint,
350 },
351 },
352 }
353 didResponse := DidResponse{
354 Context: didDoc.Context,
355 ID: didDoc.ID.String(),
356 Service: didDoc.Service,
357 }
358 c.JSON(http.StatusOK, didResponse)
359}