aboutsummaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
authorXe Iaso <me@xeiaso.net>2024-10-25 16:33:52 -0400
committerXe Iaso <me@xeiaso.net>2024-10-25 16:33:52 -0400
commit6630e93845be2bf22a96da2825e40e0302bdcefa (patch)
treea889a14235b4b2f219d9d5418ab12863295bc492 /web
parent5ce079929947aca4c8691a1cf8aaea1c2654c538 (diff)
downloadx-6630e93845be2bf22a96da2825e40e0302bdcefa.tar.xz
x-6630e93845be2bf22a96da2825e40e0302bdcefa.zip
cmd/stealthmountain: better reauth loop support
Signed-off-by: Xe Iaso <me@xeiaso.net>
Diffstat (limited to 'web')
-rw-r--r--web/bsky/com/atproto/identity/resolveHandle/lexicon.json35
-rw-r--r--web/bsky/com/atproto/identity/resolveHandle/package.go69
-rw-r--r--web/bsky/package.go22
-rw-r--r--web/bskybot/README.md107
-rw-r--r--web/bskybot/gobot.go180
-rw-r--r--web/bskybot/post.go226
6 files changed, 513 insertions, 126 deletions
diff --git a/web/bsky/com/atproto/identity/resolveHandle/lexicon.json b/web/bsky/com/atproto/identity/resolveHandle/lexicon.json
deleted file mode 100644
index b3aed33..0000000
--- a/web/bsky/com/atproto/identity/resolveHandle/lexicon.json
+++ /dev/null
@@ -1,35 +0,0 @@
-{
- "lexicon": 1,
- "id": "com.atproto.identity.resolveHandle",
- "defs": {
- "main": {
- "type": "query",
- "description": "Provides the DID of a repo.",
- "parameters": {
- "type": "params",
- "properties": {
- "handle": {
- "type": "string",
- "format": "handle",
- "description": "The handle to resolve. If not supplied, will resolve the host's own handle."
- }
- }
- },
- "output": {
- "encoding": "application/json",
- "schema": {
- "type": "object",
- "required": [
- "did"
- ],
- "properties": {
- "did": {
- "type": "string",
- "format": "did"
- }
- }
- }
- }
- }
- }
-}
diff --git a/web/bsky/com/atproto/identity/resolveHandle/package.go b/web/bsky/com/atproto/identity/resolveHandle/package.go
deleted file mode 100644
index 7a8dec5..0000000
--- a/web/bsky/com/atproto/identity/resolveHandle/package.go
+++ /dev/null
@@ -1,69 +0,0 @@
-package resolveHandle
-
-import (
- "context"
- "encoding/json"
- "log/slog"
- "net/http"
-
- "github.com/pasztorpisti/qs"
- "within.website/x/web/bsky"
-)
-
-type Query struct {
- Handle *string `json:"handle"`
-}
-
-func (q Query) XRPCType() string {
- return "params"
-}
-
-type Output struct {
- DID string `json:"did"`
-}
-
-func (o Output) XRPCType() string {
- return "object"
-}
-
-type Handler interface {
- IdentityResolveHandle(context.Context, *Query) (*Output, error)
-}
-
-func ServeHTTP(h Handler) http.HandlerFunc {
- return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
- q := &Query{}
-
- if err := qs.Unmarshal(q, req.URL.RawQuery); err != nil {
- slog.Error("error parsing request query parameters", "err", err)
- rw.Header().Set("Content-Type", "application/json")
- rw.WriteHeader(http.StatusBadRequest)
- json.NewEncoder(rw).Encode(bsky.Error{
- ErrorKind: "InvalidRequestError",
- Message: "Your request cannot be parsed. Try again.",
- })
- return
- }
-
- output, err := h.IdentityResolveHandle(req.Context(), q)
- if err != nil {
- slog.Error("error doing handler logic", "err", err)
- switch err.(type) {
- case *bsky.Error:
- rw.Header().Set("Content-Type", "application/json")
- rw.WriteHeader(http.StatusBadRequest)
- json.NewEncoder(rw).Encode(err)
- default:
- rw.Header().Set("Content-Type", "application/json")
- rw.WriteHeader(http.StatusInternalServerError)
- json.NewEncoder(rw).Encode(bsky.Error{
- ErrorKind: "InternalServerError",
- Message: "There was an internal server error. No further information is available.",
- })
- }
- return
- }
-
- json.NewEncoder(rw).Encode(output)
- })
-}
diff --git a/web/bsky/package.go b/web/bsky/package.go
deleted file mode 100644
index ed43b46..0000000
--- a/web/bsky/package.go
+++ /dev/null
@@ -1,22 +0,0 @@
-package bsky
-
-import (
- "fmt"
- "log/slog"
-)
-
-type Error struct {
- ErrorKind string `json:"error"`
- Message string `json:"message"`
-}
-
-func (e Error) Error() string {
- return fmt.Sprintf("bsky: %s: %s", e.ErrorKind, e.Message)
-}
-
-func (e Error) LogValue() slog.Value {
- return slog.GroupValue(
- slog.String("error", e.ErrorKind),
- slog.String("msg", e.Message),
- )
-}
diff --git a/web/bskybot/README.md b/web/bskybot/README.md
new file mode 100644
index 0000000..e97911b
--- /dev/null
+++ b/web/bskybot/README.md
@@ -0,0 +1,107 @@
+# gobot-bsky
+
+Gobot-bsky - a simple GO lib to write Bluesky bots
+
+## Usage example
+
+Has to provide:
+
+* a handle - example bluesky handle: "example.bsky.social"
+* an apikey - is used for authetication and the retrieval of the access token and refresh token. To create a new one: Settings --> App Passwords
+* the server (PDS) - the Bluesky's "PDS Service" is bsky.social.
+
+```go
+import gobot "github.com/danrusei/gobot-bsky"
+
+func main() {
+
+ godotenv.Load()
+ handle := os.Getenv("HANDLE")
+ apikey := os.Getenv("APIKEY")
+ server := "https://bsky.social"
+
+ ctx := context.Background()
+
+ agent := gobot.NewAgent(ctx, server, handle, apikey)
+ agent.Connect(ctx)
+
+ // Facets Section
+ // =======================================
+ // Facet_type coulf be Facet_Link, Facet_Mention or Facet_Tag
+ // based on the selected type it expect the second argument to be URI, DID, or TAG
+ // the last function argument is the text, part of the original text that is modifiend in Richtext
+
+ post1, err := gobot.NewPostBuilder("Hello to Bluesky, the coolest open social network").
+ WithFacet(gobot.Facet_Link, "https://docs.bsky.app/", "Bluesky").
+ WithFacet(gobot.Facet_Tag, "bsky", "open social").
+ Build()
+ if err != nil {
+ fmt.Printf("Got error: %v", err)
+ }
+
+ cid1, uri1, err := agent.PostToFeed(ctx, post1)
+ if err != nil {
+ fmt.Printf("Got error: %v", err)
+ } else {
+ fmt.Printf("Succes: Cid = %v , Uri = %v", cid1, uri1)
+ }
+
+ // Embed Links section
+ // =======================================
+
+ u, err := url.Parse("https://go.dev/")
+ if err != nil {
+ log.Fatalf("Parse error, %v", err)
+ }
+ post2, err := gobot.NewPostBuilder("Hello to Go on Bluesky").
+ WithExternalLink("Go Programming Language", *u, "Build simple, secure, scalable systems with Go").
+ Build()
+ if err != nil {
+ fmt.Printf("Got error: %v", err)
+ }
+
+ cid2, uri2, err := agent.PostToFeed(ctx, post2)
+ if err != nil {
+ fmt.Printf("Got error: %v", err)
+ } else {
+ fmt.Printf("Succes: Cid = %v , Uri = %v", cid2, uri2)
+ }
+
+ // Embed Images section
+ // =======================================
+ images := []gobot.Image{}
+
+ url1, err := url.Parse("https://www.freecodecamp.org/news/content/images/2021/10/golang.png")
+ if err != nil {
+ log.Fatalf("Parse error, %v", err)
+ }
+ images = append(images, gobot.Image{
+ Title: "Golang",
+ Uri: *url1,
+ })
+
+ blobs, err := agent.UploadImages(ctx, images...)
+ if err != nil {
+ log.Fatalf("Parse error, %v", err)
+ }
+
+ post3, err := gobot.NewPostBuilder("Gobot-bsky - a simple golang lib to write Bluesky bots").
+ WithImages(blobs, images).
+ Build()
+ if err != nil {
+ fmt.Printf("Got error: %v", err)
+ }
+
+ cid3, uri3, err := agent.PostToFeed(ctx, post3)
+ if err != nil {
+ fmt.Printf("Got error: %v", err)
+ } else {
+ fmt.Printf("Succes: Cid = %v , Uri = %v", cid3, uri3)
+ }
+
+}
+```
+
+## The results of running the above code
+
+![Content generated with gobot-bsky](bsky_bot_in_go.png "Content generated with gobot-bsky")
diff --git a/web/bskybot/gobot.go b/web/bskybot/gobot.go
new file mode 100644
index 0000000..9ecd211
--- /dev/null
+++ b/web/bskybot/gobot.go
@@ -0,0 +1,180 @@
+package bskybot
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io/ioutil"
+ "log"
+ "log/slog"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/bluesky-social/indigo/api/atproto"
+ appbsky "github.com/bluesky-social/indigo/api/bsky"
+ lexutil "github.com/bluesky-social/indigo/lex/util"
+ "github.com/bluesky-social/indigo/xrpc"
+)
+
+const defaultPDS = "https://bsky.social"
+
+var blob []lexutil.LexBlob
+
+// Wrapper over the atproto xrpc transport
+type BskyAgent struct {
+ // xrpc transport, a wrapper around http server
+ client *xrpc.Client
+ handle string
+ apikey string
+ t *time.Ticker
+ lock sync.Mutex
+}
+
+// Creates new BlueSky Agent
+func NewAgent(ctx context.Context, server string, handle string, apikey string) BskyAgent {
+ if server == "" {
+ server = defaultPDS
+ }
+
+ return BskyAgent{
+ client: &xrpc.Client{
+ Client: new(http.Client),
+ Host: server,
+ },
+ handle: handle,
+ apikey: apikey,
+ }
+}
+
+// Connect and Authenticate to the provided Personal Data Server, default is Bluesky PDS
+// No need to refresh the access token if the bot script will be executed based on the cron job
+func (c *BskyAgent) Connect(ctx context.Context) error {
+ input_for_session := &atproto.ServerCreateSession_Input{
+ Identifier: c.handle,
+ Password: c.apikey,
+ }
+
+ session, err := atproto.ServerCreateSession(ctx, c.client, input_for_session)
+ if err != nil {
+ return fmt.Errorf("UNABLE TO CONNECT: %v", err)
+ }
+
+ // Access Token is used to make authenticated requests
+ // Refresh Token allows to generate a new Access Token
+ c.client.Auth = &xrpc.AuthInfo{
+ AccessJwt: session.AccessJwt,
+ RefreshJwt: session.RefreshJwt,
+ Handle: session.Handle,
+ Did: session.Did,
+ }
+
+ slog.Debug("authed", "did", session.Did, "handle", session.Handle)
+
+ c.lock.Lock()
+ defer c.lock.Unlock()
+
+ if c.t == nil {
+ return nil
+ }
+
+ t := time.NewTicker(10 * time.Minute)
+ c.t = t
+
+ go c.reauthLoop()
+
+ return nil
+}
+
+func (c *BskyAgent) reauthLoop() {
+ slog.Debug("started auth background process")
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ for range c.t.C {
+ if err := c.refreshAuth(ctx); err != nil {
+ slog.Error("can't refresh auth, auth may be bad?", "err", err)
+ continue
+ }
+ }
+}
+
+func (c *BskyAgent) refreshAuth(ctx context.Context) error {
+ c.lock.Lock()
+ defer c.lock.Unlock()
+
+ resp, err := atproto.ServerRefreshSession(ctx, c.client)
+ if err != nil {
+ return err
+ }
+
+ c.client.Auth = &xrpc.AuthInfo{
+ AccessJwt: resp.AccessJwt,
+ RefreshJwt: resp.RefreshJwt,
+ Handle: resp.Handle,
+ Did: resp.Did,
+ }
+
+ return nil
+}
+
+func (c *BskyAgent) UploadImages(ctx context.Context, images ...Image) ([]lexutil.LexBlob, error) {
+ for _, img := range images {
+ getImage, err := getImageAsBuffer(img.Uri.String())
+ if err != nil {
+ log.Printf("Couldn't retrive the image: %v , %v", img, err)
+ }
+
+ resp, err := atproto.RepoUploadBlob(ctx, c.client, bytes.NewReader(getImage))
+ if err != nil {
+ return nil, err
+ }
+
+ blob = append(blob, lexutil.LexBlob{
+ Ref: resp.Blob.Ref,
+ MimeType: resp.Blob.MimeType,
+ Size: resp.Blob.Size,
+ })
+ }
+ return blob, nil
+}
+
+// Post to social app
+func (c *BskyAgent) PostToFeed(ctx context.Context, post appbsky.FeedPost) (string, string, error) {
+ post_input := &atproto.RepoCreateRecord_Input{
+ // collection: The NSID of the record collection.
+ Collection: "app.bsky.feed.post",
+ // repo: The handle or DID of the repo (aka, current account).
+ Repo: c.client.Auth.Did,
+ // record: The record itself. Must contain a $type field.
+ Record: &lexutil.LexiconTypeDecoder{Val: &post},
+ }
+
+ response, err := atproto.RepoCreateRecord(ctx, c.client, post_input)
+ if err != nil {
+ return "", "", fmt.Errorf("unable to post, %v", err)
+ }
+
+ return response.Cid, response.Uri, nil
+}
+
+func getImageAsBuffer(imageURL string) ([]byte, error) {
+ // Fetch image
+ response, err := http.Get(imageURL)
+ if err != nil {
+ return nil, err
+ }
+ defer response.Body.Close()
+
+ // Check response status
+ if response.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("failed to fetch image: %s", response.Status)
+ }
+
+ // Read response body
+ imageData, err := ioutil.ReadAll(response.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ return imageData, nil
+}
diff --git a/web/bskybot/post.go b/web/bskybot/post.go
new file mode 100644
index 0000000..db87ec0
--- /dev/null
+++ b/web/bskybot/post.go
@@ -0,0 +1,226 @@
+package bskybot
+
+import (
+ "errors"
+ "fmt"
+ "net/url"
+ "strings"
+ "time"
+
+ appbsky "github.com/bluesky-social/indigo/api/bsky"
+ lexutil "github.com/bluesky-social/indigo/lex/util"
+ "github.com/bluesky-social/indigo/util"
+)
+
+var FeedPost_Embed appbsky.FeedPost_Embed
+
+type Facet_Type int
+
+const (
+ Facet_Link Facet_Type = iota + 1
+ Facet_Mention
+ Facet_Tag
+)
+
+// construct the post
+type PostBuilder struct {
+ Text string
+ Facet []Facet
+ Embed Embed
+}
+
+type Facet struct {
+ Ftype Facet_Type
+ Value string
+ T_facet string
+}
+
+type Embed struct {
+ Link Link
+ Images []Image
+ UploadedImages []lexutil.LexBlob
+}
+
+type Link struct {
+ Title string
+ Uri url.URL
+ Description string
+}
+
+type Image struct {
+ Title string
+ Uri url.URL
+}
+
+// Create a simple post with text
+func NewPostBuilder(text string) PostBuilder {
+ return PostBuilder{
+ Text: text,
+ Facet: []Facet{},
+ }
+}
+
+// Create a Richtext Post with facests
+func (pb PostBuilder) WithFacet(ftype Facet_Type, value string, text string) PostBuilder {
+
+ pb.Facet = append(pb.Facet, Facet{
+ Ftype: ftype,
+ Value: value,
+ T_facet: text,
+ })
+
+ return pb
+}
+
+// Create a Post with external links
+func (pb PostBuilder) WithExternalLink(title string, link url.URL, description string) PostBuilder {
+
+ pb.Embed.Link.Title = title
+ pb.Embed.Link.Uri = link
+ pb.Embed.Link.Description = description
+
+ return pb
+}
+
+// Create a Post with images
+func (pb PostBuilder) WithImages(blobs []lexutil.LexBlob, images []Image) PostBuilder {
+
+ pb.Embed.Images = images
+ pb.Embed.UploadedImages = blobs
+
+ return pb
+}
+
+// Build the request
+func (pb PostBuilder) Build() (appbsky.FeedPost, error) {
+
+ post := appbsky.FeedPost{}
+
+ post.Text = pb.Text
+ post.LexiconTypeID = "app.bsky.feed.post"
+ post.CreatedAt = time.Now().Format(util.ISO8601)
+
+ // RichtextFacet Section
+ // https://docs.bsky.app/docs/advanced-guides/post-richtext
+
+ Facets := []*appbsky.RichtextFacet{}
+
+ for _, f := range pb.Facet {
+ facet := &appbsky.RichtextFacet{}
+ features := []*appbsky.RichtextFacet_Features_Elem{}
+ feature := &appbsky.RichtextFacet_Features_Elem{}
+
+ switch f.Ftype {
+
+ case Facet_Link:
+ {
+ feature = &appbsky.RichtextFacet_Features_Elem{
+ RichtextFacet_Link: &appbsky.RichtextFacet_Link{
+ LexiconTypeID: f.Ftype.String(),
+ Uri: f.Value,
+ },
+ }
+ }
+
+ case Facet_Mention:
+ {
+ feature = &appbsky.RichtextFacet_Features_Elem{
+ RichtextFacet_Mention: &appbsky.RichtextFacet_Mention{
+ LexiconTypeID: f.Ftype.String(),
+ Did: f.Value,
+ },
+ }
+ }
+
+ case Facet_Tag:
+ {
+ feature = &appbsky.RichtextFacet_Features_Elem{
+ RichtextFacet_Tag: &appbsky.RichtextFacet_Tag{
+ LexiconTypeID: f.Ftype.String(),
+ Tag: f.Value,
+ },
+ }
+ }
+
+ }
+
+ features = append(features, feature)
+ facet.Features = features
+
+ ByteStart, ByteEnd, err := findSubstring(post.Text, f.T_facet)
+ if err != nil {
+ return post, fmt.Errorf("unable to find the substring: %v , %v", f.T_facet, err)
+ }
+
+ index := &appbsky.RichtextFacet_ByteSlice{
+ ByteStart: int64(ByteStart),
+ ByteEnd: int64(ByteEnd),
+ }
+ facet.Index = index
+
+ Facets = append(Facets, facet)
+ }
+
+ post.Facets = Facets
+
+ // Embed Section (either external links or images)
+ // As of now it allows only one Embed type per post:
+ // https://github.com/bluesky-social/indigo/blob/main/api/bsky/feedpost.go
+ if pb.Embed.Link != (Link{}) {
+
+ FeedPost_Embed.EmbedExternal = &appbsky.EmbedExternal{
+ LexiconTypeID: "app.bsky.embed.external",
+ External: &appbsky.EmbedExternal_External{
+ Title: pb.Embed.Link.Title,
+ Uri: pb.Embed.Link.Uri.String(),
+ Description: pb.Embed.Link.Description,
+ },
+ }
+
+ } else {
+ if len(pb.Embed.Images) != 0 && len(pb.Embed.Images) == len(pb.Embed.UploadedImages) {
+
+ EmbedImages := appbsky.EmbedImages{
+ LexiconTypeID: "app.bsky.embed.images",
+ Images: make([]*appbsky.EmbedImages_Image, len(pb.Embed.Images)),
+ }
+
+ for i, img := range pb.Embed.Images {
+ EmbedImages.Images[i] = &appbsky.EmbedImages_Image{
+ Alt: img.Title,
+ Image: &pb.Embed.UploadedImages[i],
+ }
+ }
+
+ FeedPost_Embed.EmbedImages = &EmbedImages
+
+ }
+ }
+
+ // avoid error when trying to marshal empty field (*bsky.FeedPost_Embed)
+ if len(pb.Embed.Images) != 0 || pb.Embed.Link.Title != "" {
+ post.Embed = &FeedPost_Embed
+ }
+
+ return post, nil
+}
+
+func (f Facet_Type) String() string {
+ switch f {
+ case Facet_Link:
+ return "app.bsky.richtext.facet#link"
+ case Facet_Mention:
+ return "app.bsky.richtext.facet#mention"
+ case Facet_Tag:
+ return "app.bsky.richtext.facet#tag"
+ default:
+ return "Unknown"
+ }
+}
+func findSubstring(s, substr string) (int, int, error) {
+ index := strings.Index(s, substr)
+ if index == -1 {
+ return 0, 0, errors.New("substring not found")
+ }
+ return index, index + len(substr), nil
+}