diff options
| author | Xe Iaso <me@xeiaso.net> | 2024-10-25 16:33:52 -0400 |
|---|---|---|
| committer | Xe Iaso <me@xeiaso.net> | 2024-10-25 16:33:52 -0400 |
| commit | 6630e93845be2bf22a96da2825e40e0302bdcefa (patch) | |
| tree | a889a14235b4b2f219d9d5418ab12863295bc492 /web | |
| parent | 5ce079929947aca4c8691a1cf8aaea1c2654c538 (diff) | |
| download | x-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.json | 35 | ||||
| -rw-r--r-- | web/bsky/com/atproto/identity/resolveHandle/package.go | 69 | ||||
| -rw-r--r-- | web/bsky/package.go | 22 | ||||
| -rw-r--r-- | web/bskybot/README.md | 107 | ||||
| -rw-r--r-- | web/bskybot/gobot.go | 180 | ||||
| -rw-r--r-- | web/bskybot/post.go | 226 |
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 + + 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 +} |
