Monorepo for Tangled

appview/bsky: init module to fetch and store bsky posts on loadup

fetches recent tangled posts for use on landing page

Signed-off-by: oppiliappan <me@oppi.li>

authored by

oppiliappan and committed by tangled.org 0a8187a5 0646ca4f

+266 -9
+43
appview/bsky/client.go
···
··· 1 + package bsky 2 + 3 + import ( 4 + "context" 5 + "log" 6 + 7 + bsky "github.com/bluesky-social/indigo/api/bsky" 8 + "github.com/bluesky-social/indigo/xrpc" 9 + "tangled.org/core/appview/models" 10 + "tangled.org/core/consts" 11 + ) 12 + 13 + func FetchPosts(ctx context.Context, c *xrpc.Client, limit int, cursor string) ([]models.BskyPost, string, error) { 14 + resp, err := bsky.FeedGetAuthorFeed(ctx, c, consts.TangledDid, cursor, "posts_no_replies", false, int64(limit)) 15 + if err != nil { 16 + return nil, "", err 17 + } 18 + 19 + var posts []models.BskyPost 20 + for _, feedViewPost := range resp.Feed { 21 + // skip quote posts 22 + if feedViewPost.Post.Embed != nil && feedViewPost.Post.Embed.EmbedRecord_View != nil { 23 + continue 24 + } 25 + 26 + post, err := models.NewBskyPostFromView(feedViewPost.Post) 27 + if err != nil { 28 + log.Println(err) 29 + continue 30 + } 31 + 32 + posts = append(posts, *post) 33 + } 34 + 35 + return posts, stringPtr(resp.Cursor), nil 36 + } 37 + 38 + func stringPtr(s *string) string { 39 + if s == nil { 40 + return "" 41 + } 42 + return *s 43 + }
+5
appview/config/config.go
··· 100 GoodFirstIssue string `env:"GFI, default=at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue"` 101 } 102 103 func (cfg RedisConfig) ToURL() string { 104 u := &url.URL{ 105 Scheme: "redis", ··· 129 Pds PdsConfig `env:",prefix=TANGLED_PDS_"` 130 Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"` 131 Label LabelConfig `env:",prefix=TANGLED_LABEL_"` 132 } 133 134 func LoadConfig(ctx context.Context) (*Config, error) {
··· 100 GoodFirstIssue string `env:"GFI, default=at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue"` 101 } 102 103 + type BlueskyConfig struct { 104 + UpdateInterval time.Duration `env:"UPDATE_INTERVAL, default=1h"` 105 + } 106 + 107 func (cfg RedisConfig) ToURL() string { 108 u := &url.URL{ 109 Scheme: "redis", ··· 133 Pds PdsConfig `env:",prefix=TANGLED_PDS_"` 134 Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"` 135 Label LabelConfig `env:",prefix=TANGLED_LABEL_"` 136 + Bluesky BlueskyConfig `env:",prefix=TANGLED_BLUESKY_"` 137 } 138 139 func LoadConfig(ctx context.Context) (*Config, error) {
+116
appview/db/bsky.go
···
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "encoding/json" 6 + "time" 7 + 8 + "tangled.org/core/appview/models" 9 + ) 10 + 11 + func InsertBlueskyPosts(e Execer, posts []models.BskyPost) error { 12 + if len(posts) == 0 { 13 + return nil 14 + } 15 + 16 + stmt, err := e.Prepare(` 17 + insert or replace into bluesky_posts (rkey, text, created_at, langs, facets, embed, like_count, reply_count, repost_count, quote_count) 18 + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 19 + `) 20 + if err != nil { 21 + return err 22 + } 23 + defer stmt.Close() 24 + 25 + for _, post := range posts { 26 + var langsJSON, facetsJSON, embedJSON []byte 27 + 28 + if len(post.Langs) > 0 { 29 + langsJSON, _ = json.Marshal(post.Langs) 30 + } 31 + if len(post.Facets) > 0 { 32 + facetsJSON, _ = json.Marshal(post.Facets) 33 + } 34 + if post.Embed != nil { 35 + embedJSON, _ = json.Marshal(post.Embed) 36 + } 37 + 38 + _, err := stmt.Exec( 39 + post.Rkey, 40 + post.Text, 41 + post.CreatedAt.Format(time.RFC3339), 42 + nullString(langsJSON), 43 + nullString(facetsJSON), 44 + nullString(embedJSON), 45 + post.LikeCount, 46 + post.ReplyCount, 47 + post.RepostCount, 48 + post.QuoteCount, 49 + ) 50 + if err != nil { 51 + return err 52 + } 53 + } 54 + 55 + return nil 56 + } 57 + 58 + func nullString(b []byte) any { 59 + if len(b) == 0 { 60 + return nil 61 + } 62 + return string(b) 63 + } 64 + 65 + func GetBlueskyPosts(e Execer, limit int) ([]models.BskyPost, error) { 66 + query := ` 67 + select rkey, text, created_at, langs, facets, embed, like_count, reply_count, repost_count, quote_count 68 + from bluesky_posts 69 + order by created_at desc 70 + limit ? 71 + ` 72 + 73 + rows, err := e.Query(query, limit) 74 + if err != nil { 75 + return nil, err 76 + } 77 + defer rows.Close() 78 + 79 + var posts []models.BskyPost 80 + for rows.Next() { 81 + var rkey, text, createdAt string 82 + var langs, facets, embed sql.Null[string] 83 + var likeCount, replyCount, repostCount, quoteCount int64 84 + 85 + err := rows.Scan(&rkey, &text, &createdAt, &langs, &facets, &embed, &likeCount, &replyCount, &repostCount, &quoteCount) 86 + if err != nil { 87 + return nil, err 88 + } 89 + 90 + post := models.BskyPost{ 91 + Rkey: rkey, 92 + Text: text, 93 + LikeCount: likeCount, 94 + ReplyCount: replyCount, 95 + RepostCount: repostCount, 96 + QuoteCount: quoteCount, 97 + } 98 + 99 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 100 + post.CreatedAt = t 101 + } 102 + if langs.Valid && langs.V != "" { 103 + json.Unmarshal([]byte(langs.V), &post.Langs) 104 + } 105 + if facets.Valid && facets.V != "" { 106 + json.Unmarshal([]byte(facets.V), &post.Facets) 107 + } 108 + if embed.Valid && embed.V != "" { 109 + json.Unmarshal([]byte(embed.V), &post.Embed) 110 + } 111 + 112 + posts = append(posts, post) 113 + } 114 + 115 + return posts, rows.Err() 116 + }
+13
appview/db/db.go
··· 596 foreign key (webhook_id) references webhooks(id) on delete cascade 597 ); 598 599 create table if not exists migrations ( 600 id integer primary key autoincrement, 601 name text unique
··· 596 foreign key (webhook_id) references webhooks(id) on delete cascade 597 ); 598 599 + create table if not exists bluesky_posts ( 600 + rkey text primary key, 601 + text text not null, 602 + created_at text not null, 603 + langs text, 604 + facets text, 605 + embed text, 606 + like_count integer not null default 0, 607 + reply_count integer not null default 0, 608 + repost_count integer not null default 0, 609 + quote_count integer not null default 0 610 + ); 611 + 612 create table if not exists migrations ( 613 id integer primary key autoincrement, 614 name text unique
+72
appview/models/bsky.go
···
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + 6 + apibsky "github.com/bluesky-social/indigo/api/bsky" 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + ) 9 + 10 + type BskyPost struct { 11 + Rkey string 12 + Text string 13 + CreatedAt time.Time 14 + Langs []string 15 + Tags []string 16 + Embed *apibsky.FeedDefs_PostView_Embed 17 + Facets []*apibsky.RichtextFacet 18 + Labels *apibsky.FeedPost_Labels 19 + Reply *apibsky.FeedPost_ReplyRef 20 + LikeCount int64 21 + ReplyCount int64 22 + RepostCount int64 23 + QuoteCount int64 24 + } 25 + 26 + func NewBskyPostFromView(postView *apibsky.FeedDefs_PostView) (*BskyPost, error) { 27 + atUri, err := syntax.ParseATURI(postView.Uri) 28 + if err != nil { 29 + return nil, err 30 + } 31 + 32 + // decode the record to get FeedPost 33 + feedPost, ok := postView.Record.Val.(*apibsky.FeedPost) 34 + if !ok { 35 + return nil, err 36 + } 37 + 38 + createdAt, err := time.Parse(time.RFC3339, feedPost.CreatedAt) 39 + if err != nil { 40 + return nil, err 41 + } 42 + 43 + var likeCount, replyCount, repostCount, quoteCount int64 44 + if postView.LikeCount != nil { 45 + likeCount = *postView.LikeCount 46 + } 47 + if postView.ReplyCount != nil { 48 + replyCount = *postView.ReplyCount 49 + } 50 + if postView.RepostCount != nil { 51 + repostCount = *postView.RepostCount 52 + } 53 + if postView.QuoteCount != nil { 54 + quoteCount = *postView.QuoteCount 55 + } 56 + 57 + return &BskyPost{ 58 + Rkey: atUri.RecordKey().String(), 59 + Text: feedPost.Text, 60 + CreatedAt: createdAt, 61 + Langs: feedPost.Langs, 62 + Tags: feedPost.Tags, 63 + Embed: postView.Embed, 64 + Facets: feedPost.Facets, 65 + Labels: feedPost.Labels, 66 + Reply: feedPost.Reply, 67 + LikeCount: likeCount, 68 + ReplyCount: replyCount, 69 + RepostCount: repostCount, 70 + QuoteCount: quoteCount, 71 + }, nil 72 + }
+10 -9
appview/oauth/handler.go
··· 19 "tangled.org/core/appview/db" 20 "tangled.org/core/appview/models" 21 "tangled.org/core/consts" 22 "tangled.org/core/orm" 23 "tangled.org/core/tid" 24 ) ··· 129 } 130 131 l.Debug("adding to default spindle") 132 - session, err := o.createAppPasswordSession(o.Config.Core.AppPassword, consts.TangledDid) 133 if err != nil { 134 l.Error("failed to create session", "err", err) 135 return ··· 168 } 169 170 l.Debug("adding to default knot") 171 - session, err := o.createAppPasswordSession(o.Config.Core.TmpAltAppPassword, consts.IcyDid) 172 if err != nil { 173 l.Error("failed to create session", "err", err) 174 return ··· 241 l.Debug("successfully created empty Tangled profile on PDS and DB") 242 } 243 244 - // create a session using apppasswords 245 - type session struct { 246 AccessJwt string `json:"accessJwt"` 247 PdsEndpoint string 248 Did string 249 } 250 251 - func (o *OAuth) createAppPasswordSession(appPassword, did string) (*session, error) { 252 if appPassword == "" { 253 - return nil, fmt.Errorf("no app password configured, skipping member addition") 254 } 255 256 - resolved, err := o.IdResolver.ResolveIdent(context.Background(), did) 257 if err != nil { 258 return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err) 259 } ··· 290 return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) 291 } 292 293 - var session session 294 if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 295 return nil, fmt.Errorf("failed to decode session response: %v", err) 296 } ··· 301 return &session, nil 302 } 303 304 - func (s *session) putRecord(record any, collection string) error { 305 recordBytes, err := json.Marshal(record) 306 if err != nil { 307 return fmt.Errorf("failed to marshal knot member record: %w", err)
··· 19 "tangled.org/core/appview/db" 20 "tangled.org/core/appview/models" 21 "tangled.org/core/consts" 22 + "tangled.org/core/idresolver" 23 "tangled.org/core/orm" 24 "tangled.org/core/tid" 25 ) ··· 130 } 131 132 l.Debug("adding to default spindle") 133 + session, err := CreateAppPasswordSession(o.IdResolver, o.Config.Core.AppPassword, consts.TangledDid) 134 if err != nil { 135 l.Error("failed to create session", "err", err) 136 return ··· 169 } 170 171 l.Debug("adding to default knot") 172 + session, err := CreateAppPasswordSession(o.IdResolver, o.Config.Core.TmpAltAppPassword, consts.IcyDid) 173 if err != nil { 174 l.Error("failed to create session", "err", err) 175 return ··· 242 l.Debug("successfully created empty Tangled profile on PDS and DB") 243 } 244 245 + // create a AppPasswordSession using apppasswords 246 + type AppPasswordSession struct { 247 AccessJwt string `json:"accessJwt"` 248 PdsEndpoint string 249 Did string 250 } 251 252 + func CreateAppPasswordSession(res *idresolver.Resolver, appPassword, did string) (*AppPasswordSession, error) { 253 if appPassword == "" { 254 + return nil, fmt.Errorf("no app password configured") 255 } 256 257 + resolved, err := res.ResolveIdent(context.Background(), did) 258 if err != nil { 259 return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err) 260 } ··· 291 return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) 292 } 293 294 + var session AppPasswordSession 295 if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 296 return nil, fmt.Errorf("failed to decode session response: %v", err) 297 } ··· 302 return &session, nil 303 } 304 305 + func (s *AppPasswordSession) putRecord(record any, collection string) error { 306 recordBytes, err := json.Marshal(record) 307 if err != nil { 308 return fmt.Errorf("failed to marshal knot member record: %w", err)
+1
appview/pages/pages.go
··· 339 Timeline []models.TimelineEvent 340 Repos []models.Repo 341 GfiLabel *models.LabelDefinition 342 } 343 344 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error {
··· 339 Timeline []models.TimelineEvent 340 Repos []models.Repo 341 GfiLabel *models.LabelDefinition 342 + BlueskyPosts []models.BskyPost 343 } 344 345 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error {
+6
appview/state/state.go
··· 12 13 "tangled.org/core/api/tangled" 14 "tangled.org/core/appview" 15 "tangled.org/core/appview/config" 16 "tangled.org/core/appview/db" 17 "tangled.org/core/appview/indexer" ··· 25 "tangled.org/core/appview/reporesolver" 26 "tangled.org/core/appview/validator" 27 xrpcclient "tangled.org/core/appview/xrpcclient" 28 "tangled.org/core/eventconsumer" 29 "tangled.org/core/idresolver" 30 "tangled.org/core/jetstream" ··· 38 atpclient "github.com/bluesky-social/indigo/atproto/client" 39 "github.com/bluesky-social/indigo/atproto/syntax" 40 lexutil "github.com/bluesky-social/indigo/lex/util" 41 securejoin "github.com/cyphar/filepath-securejoin" 42 "github.com/go-chi/chi/v5" 43 "github.com/posthog/posthog-go" ··· 198 logger, 199 validator, 200 } 201 202 return state, nil 203 }
··· 12 13 "tangled.org/core/api/tangled" 14 "tangled.org/core/appview" 15 + "tangled.org/core/appview/bsky" 16 "tangled.org/core/appview/config" 17 "tangled.org/core/appview/db" 18 "tangled.org/core/appview/indexer" ··· 26 "tangled.org/core/appview/reporesolver" 27 "tangled.org/core/appview/validator" 28 xrpcclient "tangled.org/core/appview/xrpcclient" 29 + "tangled.org/core/consts" 30 "tangled.org/core/eventconsumer" 31 "tangled.org/core/idresolver" 32 "tangled.org/core/jetstream" ··· 40 atpclient "github.com/bluesky-social/indigo/atproto/client" 41 "github.com/bluesky-social/indigo/atproto/syntax" 42 lexutil "github.com/bluesky-social/indigo/lex/util" 43 + "github.com/bluesky-social/indigo/xrpc" 44 securejoin "github.com/cyphar/filepath-securejoin" 45 "github.com/go-chi/chi/v5" 46 "github.com/posthog/posthog-go" ··· 201 logger, 202 validator, 203 } 204 + 205 + // fetch initial bluesky posts if configured 206 + go fetchBskyPosts(ctx, res, config, d, logger) 207 208 return state, nil 209 }