aboutsummaryrefslogtreecommitdiff
path: root/cmd
diff options
context:
space:
mode:
authorXe Iaso <me@xeiaso.net>2024-05-22 12:32:28 -0400
committerXe Iaso <me@xeiaso.net>2024-05-22 12:34:27 -0400
commit035a53c46055458ccf2e90172fb1166848eaa254 (patch)
tree2e4b48129926143af753be2742ce1f8998e98f37 /cmd
parent531fae570d0fdb89a519d3e4e1c80a7ff8b6e665 (diff)
downloadx-035a53c46055458ccf2e90172fb1166848eaa254.tar.xz
x-035a53c46055458ccf2e90172fb1166848eaa254.zip
cmd/mi: introduce announcer to replace the current one in mimi
Signed-off-by: Xe Iaso <me@xeiaso.net>
Diffstat (limited to 'cmd')
-rw-r--r--cmd/mi/main.go54
-rw-r--r--cmd/mi/manifest.yaml28
-rw-r--r--cmd/mi/models/blogpost.go46
-rw-r--r--cmd/mi/models/dao.go36
-rw-r--r--cmd/mi/posse.go96
-rw-r--r--cmd/mi/switchtracker.go5
6 files changed, 241 insertions, 24 deletions
diff --git a/cmd/mi/main.go b/cmd/mi/main.go
index 142dbab..af0b8bc 100644
--- a/cmd/mi/main.go
+++ b/cmd/mi/main.go
@@ -1,57 +1,73 @@
package main
import (
+ "context"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
- slogGorm "github.com/orandin/slog-gorm"
- "gorm.io/driver/sqlite"
- "gorm.io/gorm"
"within.website/x/cmd/mi/models"
"within.website/x/internal"
pb "within.website/x/proto/mi"
+ "within.website/x/proto/mimi/announce"
)
var (
bind = flag.String("bind", ":8080", "HTTP bind address")
dbLoc = flag.String("db-loc", "./var/data.db", "")
internalBind = flag.String("internal-bind", ":9195", "HTTP internal routes bind address")
+
+ // POSSE flags
+ blueskyAuthkey = flag.String("bsky-authkey", "", "Bluesky authkey")
+ blueskyHandle = flag.String("bsky-handle", "", "Bluesky handle")
+ blueskyPDS = flag.String("bsky-pds", "https://bsky.social", "Bluesky PDS")
+ mastodonToken = flag.String("mastodon-token", "", "Mastodon token")
+ mastodonURL = flag.String("mastodon-url", "", "Mastodon URL")
+ mastodonUsername = flag.String("mastodon-username", "", "Mastodon username")
)
func main() {
internal.HandleStartup()
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
- db, err := gorm.Open(sqlite.Open(*dbLoc), &gorm.Config{
- Logger: slogGorm.New(
- slogGorm.WithErrorField("err"),
- slogGorm.WithRecordNotFoundError(),
- ),
- })
+ slog.Info(
+ "starting up",
+ "bind", *bind,
+ "db-loc", *dbLoc,
+ "internal-bind", *internalBind,
+ "bsky-handle", *blueskyHandle,
+ "bsky-pds", *blueskyPDS,
+ "mastodon-url", *mastodonURL,
+ "mastodon-username", *mastodonUsername,
+ )
+
+ dao, err := models.New(*dbLoc)
if err != nil {
- slog.Error("failed to connect to database", "err", err)
+ slog.Error("failed to create dao", "err", err)
os.Exit(1)
}
- if err := db.AutoMigrate(&models.Member{}, &models.Switch{}); err != nil {
- slog.Error("failed to migrate schema", "err", err)
+ mux := http.NewServeMux()
+
+ ann, err := NewAnnouncer(ctx, dao)
+ if err != nil {
+ slog.Error("failed to create announcer", "err", err)
os.Exit(1)
}
- dao := models.New(db)
-
- mux := http.NewServeMux()
-
- mux.Handle(pb.SwitchTrackerPathPrefix, pb.NewSwitchTrackerServer(NewSwitchTracker(db)))
+ mux.Handle(announce.AnnouncePathPrefix, announce.NewAnnounceServer(ann))
+ mux.Handle(pb.SwitchTrackerPathPrefix, pb.NewSwitchTrackerServer(NewSwitchTracker(dao)))
mux.Handle("/front", &HomeFrontShim{dao: dao})
- i := &Importer{db: db}
+ i := &Importer{db: dao.DB()}
i.Mount(http.DefaultServeMux)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
- if err := db.Exec("select 1+1").Error; err != nil {
+ if err := dao.Ping(r.Context()); err != nil {
+ slog.Error("database not healthy", "err", err)
http.Error(w, "database not healthy", http.StatusInternalServerError)
return
}
diff --git a/cmd/mi/manifest.yaml b/cmd/mi/manifest.yaml
index f60aff4..1fb7746 100644
--- a/cmd/mi/manifest.yaml
+++ b/cmd/mi/manifest.yaml
@@ -20,6 +20,22 @@ spec:
requests:
storage: 2Gi
---
+apiVersion: onepassword.com/v1
+kind: OnePasswordItem
+metadata:
+ name: mi-bluesky
+ namespace: mi
+spec:
+ itemPath: "vaults/Kubernetes/items/Bluesky xeiaso.net"
+---
+apiVersion: onepassword.com/v1
+kind: OnePasswordItem
+metadata:
+ name: mi-mastodon
+ namespace: mi
+spec:
+ itemPath: "vaults/Kubernetes/items/@cadey@pony.social token"
+---
apiVersion: apps/v1
kind: Deployment
metadata:
@@ -44,6 +60,12 @@ spec:
- name: vol
persistentVolumeClaim:
claimName: mi
+ - name: bluesky
+ secret:
+ secretName: mi-bluesky
+ - name: mastodon
+ secret:
+ secretName: mi-mastodon
securityContext:
fsGroup: 1000
containers:
@@ -86,6 +108,12 @@ spec:
volumeMounts:
- name: vol
mountPath: "/data"
+ - name: bluesky
+ readOnly: true
+ mountPath: "/run/secrets/bluesky"
+ - name: mastodon
+ readOnly: true
+ mountPath: "/run/secrets/mastodon"
---
apiVersion: v1
kind: Service
diff --git a/cmd/mi/models/blogpost.go b/cmd/mi/models/blogpost.go
new file mode 100644
index 0000000..ad5bd81
--- /dev/null
+++ b/cmd/mi/models/blogpost.go
@@ -0,0 +1,46 @@
+package models
+
+import (
+ "context"
+ "time"
+
+ "gorm.io/gorm"
+ "within.website/x/proto/external/jsonfeed"
+)
+
+// Blogpost is a single blogpost from a JSON Feed.
+//
+// This is tracked in the database so that the Announcer service can avoid double-posting.
+type Blogpost struct {
+ gorm.Model // adds CreatedAt, UpdatedAt, DeletedAt
+ ID string `gorm:"uniqueIndex"` // unique identifier for the blogpost
+ URL string `gorm:"uniqueIndex"` // URL of the blogpost
+ Title string // title of the blogpost
+ BodyHTML string // HTML body of the blogpost
+ PublishedAt time.Time // when the blogpost was published
+}
+
+func (d *DAO) HasBlogpost(ctx context.Context, postURL string) (bool, error) {
+ var count int64
+ if err := d.db.WithContext(ctx).Model(&Blogpost{}).Where("url = ?", postURL).Count(&count).Error; err != nil {
+ return false, err
+ }
+
+ return count > 0, nil
+}
+
+func (d *DAO) InsertBlogpost(ctx context.Context, post *jsonfeed.Item) (*Blogpost, error) {
+ bp := &Blogpost{
+ ID: post.GetId(),
+ URL: post.GetUrl(),
+ Title: post.GetTitle(),
+ BodyHTML: post.GetContentHtml(),
+ PublishedAt: post.GetDatePublished().AsTime(),
+ }
+
+ if err := d.db.WithContext(ctx).Create(bp).Error; err != nil {
+ return nil, err
+ }
+
+ return bp, nil
+}
diff --git a/cmd/mi/models/dao.go b/cmd/mi/models/dao.go
index 9830bfe..9849c6d 100644
--- a/cmd/mi/models/dao.go
+++ b/cmd/mi/models/dao.go
@@ -4,9 +4,13 @@ import (
"context"
"crypto/rand"
"errors"
+ "log/slog"
+ "os"
"time"
"github.com/oklog/ulid/v2"
+ slogGorm "github.com/orandin/slog-gorm"
+ "gorm.io/driver/sqlite"
"gorm.io/gorm"
)
@@ -18,8 +22,36 @@ type DAO struct {
db *gorm.DB
}
-func New(db *gorm.DB) *DAO {
- return &DAO{db: db}
+func (d *DAO) DB() *gorm.DB {
+ return d.db
+}
+
+func (d *DAO) Ping(ctx context.Context) error {
+ if err := d.db.WithContext(ctx).Exec("select 1+1").Error; err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func New(dbLoc string) (*DAO, error) {
+ db, err := gorm.Open(sqlite.Open(dbLoc), &gorm.Config{
+ Logger: slogGorm.New(
+ slogGorm.WithErrorField("err"),
+ slogGorm.WithRecordNotFoundError(),
+ ),
+ })
+ if err != nil {
+ slog.Error("failed to connect to database", "err", err)
+ os.Exit(1)
+ }
+
+ if err := db.AutoMigrate(&Member{}, &Switch{}, &Blogpost{}); err != nil {
+ slog.Error("failed to migrate schema", "err", err)
+ os.Exit(1)
+ }
+
+ return &DAO{db: db}, nil
}
func (d *DAO) Members(ctx context.Context) ([]Member, error) {
diff --git a/cmd/mi/posse.go b/cmd/mi/posse.go
new file mode 100644
index 0000000..73b9731
--- /dev/null
+++ b/cmd/mi/posse.go
@@ -0,0 +1,96 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "net/url"
+ "strings"
+
+ bsky "github.com/danrusei/gobot-bsky"
+ "golang.org/x/sync/errgroup"
+ "google.golang.org/protobuf/types/known/emptypb"
+ "within.website/x/cmd/mi/models"
+ "within.website/x/proto/external/jsonfeed"
+ "within.website/x/web/mastodon"
+)
+
+type Announcer struct {
+ dao *models.DAO
+ mastodon *mastodon.Client
+ bluesky *bsky.BskyAgent
+}
+
+func NewAnnouncer(ctx context.Context, dao *models.DAO) (*Announcer, error) {
+ mas, err := mastodon.Authenticated("mi_irl", "https://xeiaso.net", *mastodonURL, *mastodonToken)
+ if err != nil {
+ return nil, fmt.Errorf("failed to authenticate to mastodon: %w", err)
+ }
+
+ blueAgent := bsky.NewAgent(ctx, *blueskyPDS, *blueskyHandle, *blueskyAuthkey)
+ if err := blueAgent.Connect(ctx); err != nil {
+ return nil, fmt.Errorf("failed to connect to bluesky: %w", err)
+ }
+
+ return &Announcer{
+ dao: dao,
+ mastodon: mas,
+ bluesky: &blueAgent,
+ }, nil
+}
+
+func (a *Announcer) Announce(ctx context.Context, it *jsonfeed.Item) (*emptypb.Empty, error) {
+ if has, err := a.dao.HasBlogpost(ctx, it.GetUrl()); err != nil {
+ return nil, err
+ } else if has {
+ return &emptypb.Empty{}, nil
+ }
+
+ var sb strings.Builder
+ fmt.Fprintf(&sb, "%s\n\n%s", it.GetTitle(), it.GetUrl())
+
+ // announce to bluesky and mastodon
+ g, gCtx := errgroup.WithContext(ctx)
+ g.Go(func() error {
+ post, err := a.mastodon.CreateStatus(gCtx, mastodon.CreateStatusParams{
+ Status: sb.String(),
+ })
+ if err != nil {
+ slog.Error("failed to announce to mastodon", "err", err)
+ return err
+ }
+ slog.Info("posted to mastodon", "blogpost_url", it.GetUrl(), "mastodon_url", post.URL)
+ return nil
+ })
+
+ g.Go(func() error {
+ u, err := url.Parse(it.GetUrl())
+ if err != nil {
+ return err
+ }
+ post, err := bsky.NewPostBuilder(sb.String()).
+ WithExternalLink(it.GetTitle(), *u, "The newest post on Xe Iaso's blog").
+ WithFacet(bsky.Facet_Link, it.GetUrl(), it.GetUrl()).
+ Build()
+ if err != nil {
+ slog.Error("failed to build bluesky post", "err", err)
+ return err
+ }
+
+ cid, uri, err := a.bluesky.PostToFeed(ctx, post)
+ if err != nil {
+ slog.Error("failed to post to bluesky", "err", err)
+ return err
+ }
+
+ slog.Info("posted to bluesky", "blogpost_url", it.GetUrl(), "bluesky_cid", cid, "bluesky_uri", uri)
+ return nil
+ })
+
+ if err := g.Wait(); err != nil {
+ return nil, err
+ }
+
+ _, err := a.dao.InsertBlogpost(ctx, it)
+ return &emptypb.Empty{}, err
+}
diff --git a/cmd/mi/switchtracker.go b/cmd/mi/switchtracker.go
index fd43385..b2d47f7 100644
--- a/cmd/mi/switchtracker.go
+++ b/cmd/mi/switchtracker.go
@@ -17,10 +17,9 @@ type SwitchTracker struct {
dao *models.DAO
}
-func NewSwitchTracker(db *gorm.DB) *SwitchTracker {
+func NewSwitchTracker(dao *models.DAO) *SwitchTracker {
return &SwitchTracker{
- db: db,
- dao: models.New(db),
+ dao: dao,
}
}