diff options
| author | Xe Iaso <me@xeiaso.net> | 2024-05-22 12:32:28 -0400 |
|---|---|---|
| committer | Xe Iaso <me@xeiaso.net> | 2024-05-22 12:34:27 -0400 |
| commit | 035a53c46055458ccf2e90172fb1166848eaa254 (patch) | |
| tree | 2e4b48129926143af753be2742ce1f8998e98f37 /cmd | |
| parent | 531fae570d0fdb89a519d3e4e1c80a7ff8b6e665 (diff) | |
| download | x-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.go | 54 | ||||
| -rw-r--r-- | cmd/mi/manifest.yaml | 28 | ||||
| -rw-r--r-- | cmd/mi/models/blogpost.go | 46 | ||||
| -rw-r--r-- | cmd/mi/models/dao.go | 36 | ||||
| -rw-r--r-- | cmd/mi/posse.go | 96 | ||||
| -rw-r--r-- | cmd/mi/switchtracker.go | 5 |
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, } } |
