this repo has no description

initial implementation that consumes tangled issue/comments and stores them in a db

willdot.net 623a43a3

+2
.dockerignore
···
+
database.db
+
tangled-alert-bot
+4
.gitignore
···
+
.env
+
database.db
+
tangled-alert-bot
+
makefile
+17
Dockerfile
···
+
FROM golang:latest AS builder
+
+
WORKDIR /app
+
+
COPY . .
+
RUN go mod download
+
+
RUN CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=5 go build -a -installsuffix cgo -o tangled-alert-bot .
+
+
FROM alpine:latest
+
+
RUN apk --no-cache add ca-certificates
+
+
WORKDIR /root/
+
COPY --from=builder /app/tangled-alert-bot .
+
+
CMD ["./tangled-alert-bot"]
+87
cmd/main.go
···
+
package main
+
+
import (
+
"context"
+
"errors"
+
"fmt"
+
"log"
+
"log/slog"
+
"os"
+
"os/signal"
+
"path"
+
"syscall"
+
+
tangledalertbot "tangled.sh/willdot.net/tangled-alert-bot"
+
+
"github.com/avast/retry-go/v4"
+
"github.com/joho/godotenv"
+
)
+
+
const (
+
defaultJetstreamAddr = "wss://jetstream.atproto.tools/subscribe"
+
)
+
+
func main() {
+
err := run()
+
if err != nil {
+
log.Fatal(err)
+
}
+
}
+
+
func run() error {
+
err := godotenv.Load()
+
if err != nil && !os.IsNotExist(err) {
+
return fmt.Errorf("error loading .env file")
+
}
+
+
signals := make(chan os.Signal, 1)
+
signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT)
+
+
dbPath := os.Getenv("DATABASE_PATH")
+
if dbPath == "" {
+
dbPath = "./"
+
}
+
+
dbFilename := path.Join(dbPath, "database.db")
+
database, err := tangledalertbot.NewDatabase(dbFilename)
+
if err != nil {
+
return fmt.Errorf("create new store: %w", err)
+
}
+
defer database.Close()
+
+
ctx, cancel := context.WithCancel(context.Background())
+
defer cancel()
+
+
go consumeLoop(ctx, database)
+
+
<-signals
+
cancel()
+
+
return nil
+
}
+
+
func consumeLoop(ctx context.Context, database *tangledalertbot.Database) {
+
handler := tangledalertbot.NewFeedHandler(database)
+
+
jsServerAddr := os.Getenv("JS_SERVER_ADDR")
+
if jsServerAddr == "" {
+
jsServerAddr = defaultJetstreamAddr
+
}
+
+
consumer := tangledalertbot.NewJetstreamConsumer(jsServerAddr, slog.Default(), handler)
+
+
_ = retry.Do(func() error {
+
err := consumer.Consume(ctx)
+
if err != nil {
+
// if the context has been cancelled then it's time to exit
+
if errors.Is(err, context.Canceled) {
+
return nil
+
}
+
slog.Error("consume loop", "error", err)
+
return err
+
}
+
return nil
+
}, retry.Attempts(0)) // retry indefinitly until context canceled
+
+
slog.Warn("exiting consume loop")
+
}
+194
consumer.go
···
+
package tangledalertbot
+
+
import (
+
"context"
+
"encoding/json"
+
+
"fmt"
+
"log/slog"
+
"time"
+
+
"github.com/bluesky-social/jetstream/pkg/client"
+
"github.com/bluesky-social/jetstream/pkg/client/schedulers/sequential"
+
"github.com/bluesky-social/jetstream/pkg/models"
+
"tangled.sh/tangled.sh/core/api/tangled"
+
)
+
+
type Issue struct {
+
AuthorDID string `json:"authorDID"`
+
RKey string `json:"rkey"`
+
Title string `json:"title"`
+
Body string `json:"body"`
+
Repo string `json:"repo"`
+
CreatedAt int64 `json:"createdAt"`
+
}
+
+
type Comment struct {
+
AuthorDID string `json:"authorDID"`
+
RKey string `json:"rkey"`
+
Body string `json:"body"`
+
Issue string `json:"issue" `
+
ReplyTo string `json:"replyTo"`
+
CreatedAt int64 `json:"createdAt"`
+
}
+
+
type Store interface {
+
CreateIssue(issue Issue) error
+
CreateComment(comment Comment) error
+
}
+
+
// JetstreamConsumer is responsible for consuming from a jetstream instance
+
type JetstreamConsumer struct {
+
cfg *client.ClientConfig
+
handler *Handler
+
logger *slog.Logger
+
}
+
+
// NewJetstreamConsumer configures a new jetstream consumer. To run or start you should call the Consume function
+
func NewJetstreamConsumer(jsAddr string, logger *slog.Logger, handler *Handler) *JetstreamConsumer {
+
cfg := client.DefaultClientConfig()
+
if jsAddr != "" {
+
cfg.WebsocketURL = jsAddr
+
}
+
cfg.WantedCollections = []string{
+
tangled.RepoIssueNSID,
+
tangled.RepoIssueCommentNSID,
+
}
+
cfg.WantedDids = []string{}
+
+
return &JetstreamConsumer{
+
cfg: cfg,
+
logger: logger,
+
handler: handler,
+
}
+
}
+
+
// Consume will connect to a Jetstream client and start to consume and handle messages from it
+
func (c *JetstreamConsumer) Consume(ctx context.Context) error {
+
scheduler := sequential.NewScheduler("jetstream", c.logger, c.handler.HandleEvent)
+
defer scheduler.Shutdown()
+
+
client, err := client.NewClient(c.cfg, c.logger, scheduler)
+
if err != nil {
+
return fmt.Errorf("failed to create client: %w", err)
+
}
+
+
cursor := time.Now().Add(1 * -time.Minute).UnixMicro()
+
+
if err := client.ConnectAndRead(ctx, &cursor); err != nil {
+
return fmt.Errorf("connect and read: %w", err)
+
}
+
+
slog.Info("stopping consume")
+
return nil
+
}
+
+
// Handler is responsible for handling a message consumed from Jetstream
+
type Handler struct {
+
store Store
+
}
+
+
// NewFeedHandler returns a new handler
+
func NewFeedHandler(store Store) *Handler {
+
return &Handler{store: store}
+
}
+
+
// HandleEvent will handle an event based on the event's commit operation
+
func (h *Handler) HandleEvent(ctx context.Context, event *models.Event) error {
+
if event.Commit == nil {
+
return nil
+
}
+
+
switch event.Commit.Operation {
+
case models.CommitOperationCreate:
+
return h.handleCreateEvent(ctx, event)
+
// TODO: handle deletes too
+
default:
+
return nil
+
}
+
}
+
+
func (h *Handler) handleCreateEvent(ctx context.Context, event *models.Event) error {
+
switch event.Commit.Collection {
+
case tangled.RepoIssueNSID:
+
h.handleIssueEvent(ctx, event)
+
case tangled.RepoIssueCommentNSID:
+
h.handleIssueCommentEvent(ctx, event)
+
default:
+
slog.Info("create event was not for expected collection", "RKey", "did", event.Did, event.Commit.RKey, "collection", event.Commit.Collection)
+
return nil
+
}
+
+
return nil
+
}
+
+
func (h *Handler) handleIssueEvent(ctx context.Context, event *models.Event) {
+
var issue tangled.RepoIssue
+
+
err := json.Unmarshal(event.Commit.Record, &issue)
+
if err != nil {
+
slog.Error("error unmarshalling event record to issue", "error", err)
+
return
+
}
+
+
did := event.Did
+
rkey := event.Commit.RKey
+
+
createdAt, err := time.Parse(time.RFC3339, issue.CreatedAt)
+
if err != nil {
+
slog.Error("parsing createdAt time from issue", "error", err, "timestamp", issue.CreatedAt)
+
createdAt = time.Now().UTC()
+
}
+
body := ""
+
if issue.Body != nil {
+
body = *&body
+
}
+
err = h.store.CreateIssue(Issue{
+
AuthorDID: did,
+
RKey: rkey,
+
Title: issue.Title,
+
Body: body,
+
CreatedAt: createdAt.UnixMilli(),
+
Repo: issue.Repo,
+
})
+
if err != nil {
+
slog.Error("create issue", "error", err, "did", did, "rkey", rkey)
+
return
+
}
+
slog.Info("created issue ", "value", issue, "did", did, "rkey", rkey)
+
}
+
+
func (h *Handler) handleIssueCommentEvent(ctx context.Context, event *models.Event) {
+
var comment tangled.RepoIssueComment
+
+
err := json.Unmarshal(event.Commit.Record, &comment)
+
if err != nil {
+
slog.Error("error unmarshalling event record to comment", "error", err)
+
return
+
}
+
+
did := event.Did
+
rkey := event.Commit.RKey
+
+
createdAt, err := time.Parse(time.RFC3339, comment.CreatedAt)
+
if err != nil {
+
slog.Error("parsing createdAt time from comment", "error", err, "timestamp", comment.CreatedAt)
+
createdAt = time.Now().UTC()
+
}
+
err = h.store.CreateComment(Comment{
+
AuthorDID: did,
+
RKey: rkey,
+
Body: comment.Body,
+
Issue: comment.Issue,
+
CreatedAt: createdAt.UnixMilli(),
+
//ReplyTo: comment, // TODO: there should be a ReplyTo field that can be used as well once the right type is imported
+
})
+
if err != nil {
+
slog.Error("create comment", "error", err, "did", did, "rkey", rkey)
+
return
+
}
+
+
// TODO: now send a notification to either the issue creator or whoever the comment was a reply to
+
+
slog.Info("created comment ", "value", comment, "did", did, "rkey", rkey)
+
}
+144
database.go
···
+
package tangledalertbot
+
+
import (
+
"database/sql"
+
"errors"
+
"fmt"
+
"log/slog"
+
"os"
+
+
_ "github.com/glebarez/go-sqlite"
+
)
+
+
// Database is a sqlite database
+
type Database struct {
+
db *sql.DB
+
}
+
+
// NewDatabase will open a new database. It will ping the database to ensure it is available and error if not
+
func NewDatabase(dbPath string) (*Database, error) {
+
if dbPath != ":memory:" {
+
err := createDbFile(dbPath)
+
if err != nil {
+
return nil, fmt.Errorf("create db file: %w", err)
+
}
+
}
+
+
db, err := sql.Open("sqlite", dbPath)
+
if err != nil {
+
return nil, fmt.Errorf("open database: %w", err)
+
}
+
+
err = db.Ping()
+
if err != nil {
+
return nil, fmt.Errorf("ping db: %w", err)
+
}
+
+
err = createIssuesTable(db)
+
if err != nil {
+
return nil, fmt.Errorf("creating issues table: %w", err)
+
}
+
+
err = createCommentsTable(db)
+
if err != nil {
+
return nil, fmt.Errorf("creating comments table: %w", err)
+
}
+
+
return &Database{db: db}, nil
+
}
+
+
// Close will cleanly stop the database connection
+
func (d *Database) Close() {
+
err := d.db.Close()
+
if err != nil {
+
slog.Error("failed to close db", "error", err)
+
}
+
}
+
+
func createDbFile(dbFilename string) error {
+
if _, err := os.Stat(dbFilename); !errors.Is(err, os.ErrNotExist) {
+
return nil
+
}
+
+
f, err := os.Create(dbFilename)
+
if err != nil {
+
return fmt.Errorf("create db file : %w", err)
+
}
+
err = f.Close()
+
if err != nil {
+
return fmt.Errorf("failed to close DB file: %w", err)
+
}
+
return nil
+
}
+
+
func createIssuesTable(db *sql.DB) error {
+
createTableSQL := `CREATE TABLE IF NOT EXISTS issues (
+
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
+
"authorDid" TEXT,
+
"rkey" TEXT,
+
"title" TEXT,
+
"body" TEXT,
+
"repo" TEXT,
+
"createdAt" integer NOT NULL,
+
UNIQUE(authorDid,rkey)
+
);`
+
+
slog.Info("Create issues table...")
+
statement, err := db.Prepare(createTableSQL)
+
if err != nil {
+
return fmt.Errorf("prepare DB statement to create issues table: %w", err)
+
}
+
_, err = statement.Exec()
+
if err != nil {
+
return fmt.Errorf("exec sql statement to create issues table: %w", err)
+
}
+
slog.Info("issues table created")
+
+
return nil
+
}
+
+
func createCommentsTable(db *sql.DB) error {
+
createTableSQL := `CREATE TABLE IF NOT EXISTS comments (
+
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
+
"authorDid" TEXT,
+
"rkey" TEXT,
+
"body" TEXT,
+
"issue" TEXT,
+
"replyTo" TEXT,
+
"createdAt" integer NOT NULL,
+
UNIQUE(authorDid,rkey)
+
);`
+
+
slog.Info("Create comments table...")
+
statement, err := db.Prepare(createTableSQL)
+
if err != nil {
+
return fmt.Errorf("prepare DB statement to create comments table: %w", err)
+
}
+
_, err = statement.Exec()
+
if err != nil {
+
return fmt.Errorf("exec sql statement to create comments table: %w", err)
+
}
+
slog.Info("comments table created")
+
+
return nil
+
}
+
+
// CreateIssue will insert a issue into a database
+
func (d *Database) CreateIssue(issue Issue) error {
+
sql := `INSERT INTO issues (authorDid, rkey, title, body, repo, createdAt) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(authorDid, rkey) DO NOTHING;`
+
_, err := d.db.Exec(sql, issue.AuthorDID, issue.RKey, issue.Title, issue.Body, issue.Repo, issue.CreatedAt)
+
if err != nil {
+
return fmt.Errorf("exec insert issue: %w", err)
+
}
+
return nil
+
}
+
+
// CreateComment will insert a comment into a database
+
func (d *Database) CreateComment(comment Comment) error {
+
sql := `INSERT INTO comments (authorDid, rkey, body, issue, replyTo, createdAt) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(authorDid, rkey) DO NOTHING;`
+
_, err := d.db.Exec(sql, comment.AuthorDID, comment.RKey, comment.Body, comment.Issue, comment.ReplyTo, comment.CreatedAt)
+
if err != nil {
+
return fmt.Errorf("exec insert comment: %w", err)
+
}
+
return nil
+
}
+51
go.mod
···
+
module tangled.sh/willdot.net/tangled-alert-bot
+
+
go 1.25.0
+
+
require (
+
github.com/avast/retry-go/v4 v4.6.1
+
github.com/bluesky-social/jetstream v0.0.0-20250815235753-306e46369336
+
github.com/glebarez/go-sqlite v1.22.0
+
github.com/joho/godotenv v1.5.1
+
tangled.sh/tangled.sh/core v1.8.1-alpha
+
)
+
+
require (
+
github.com/beorn7/perks v1.0.1 // indirect
+
github.com/bluesky-social/indigo v0.0.0-20250808182429-6f0837c2d12b // indirect
+
github.com/cespare/xxhash/v2 v2.3.0 // indirect
+
github.com/dustin/go-humanize v1.0.1 // indirect
+
github.com/goccy/go-json v0.10.5 // indirect
+
github.com/google/uuid v1.6.0 // indirect
+
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
+
github.com/ipfs/go-cid v0.5.0 // indirect
+
github.com/klauspost/compress v1.18.0 // indirect
+
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
+
github.com/mattn/go-isatty v0.0.20 // indirect
+
github.com/minio/sha256-simd v1.0.1 // indirect
+
github.com/mr-tron/base58 v1.2.0 // indirect
+
github.com/multiformats/go-base32 v0.1.0 // indirect
+
github.com/multiformats/go-base36 v0.2.0 // indirect
+
github.com/multiformats/go-multibase v0.2.0 // indirect
+
github.com/multiformats/go-multihash v0.2.3 // indirect
+
github.com/multiformats/go-varint v0.0.7 // indirect
+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+
github.com/prometheus/client_golang v1.22.0 // indirect
+
github.com/prometheus/client_model v0.6.2 // indirect
+
github.com/prometheus/common v0.64.0 // indirect
+
github.com/prometheus/procfs v0.16.1 // indirect
+
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+
github.com/spaolacci/murmur3 v1.1.0 // indirect
+
github.com/whyrusleeping/cbor-gen v0.3.1 // indirect
+
go.uber.org/atomic v1.11.0 // indirect
+
golang.org/x/crypto v0.40.0 // indirect
+
golang.org/x/net v0.42.0 // indirect
+
golang.org/x/sys v0.34.0 // indirect
+
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
+
google.golang.org/protobuf v1.36.6 // indirect
+
lukechampine.com/blake3 v1.4.1 // indirect
+
modernc.org/libc v1.37.6 // indirect
+
modernc.org/mathutil v1.6.0 // indirect
+
modernc.org/memory v1.7.2 // indirect
+
modernc.org/sqlite v1.28.0 // indirect
+
)
+97
go.sum
···
+
github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk=
+
github.com/avast/retry-go/v4 v4.6.1/go.mod h1:V6oF8njAwxJ5gRo1Q7Cxab24xs5NCWZBeaHHBklR8mA=
+
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+
github.com/bluesky-social/indigo v0.0.0-20250808182429-6f0837c2d12b h1:bJTlFwMhB9sluuqZxVXtv2yFcaWOC/PZokz9mcwb4u4=
+
github.com/bluesky-social/indigo v0.0.0-20250808182429-6f0837c2d12b/go.mod h1:0XUyOCRtL4/OiyeqMTmr6RlVHQMDgw3LS7CfibuZR5Q=
+
github.com/bluesky-social/jetstream v0.0.0-20250815235753-306e46369336 h1:NM3wfeFUrdjCE/xHLXQorwQvEKlI9uqnWl7L0Y9KA8U=
+
github.com/bluesky-social/jetstream v0.0.0-20250815235753-306e46369336/go.mod h1:3ihWQCbXeayg41G8lQ5DfB/3NnEhl0XX24eZ3mLpf7Q=
+
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
+
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
+
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
+
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
+
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
+
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
+
github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg=
+
github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk=
+
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
+
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
+
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
+
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
+
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
+
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
+
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
+
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
+
github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE=
+
github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI=
+
github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0=
+
github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4=
+
github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g=
+
github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk=
+
github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=
+
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
+
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
+
github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU=
+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
+
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
+
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
+
github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4=
+
github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
+
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
+
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
+
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
+
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
+
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+
github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0=
+
github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
+
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
+
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
+
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
+
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
+
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
+
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
+
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
+
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
+
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
+
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
+
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
+
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=
+
modernc.org/libc v1.37.6 h1:orZH3c5wmhIQFTXF+Nt+eeauyd+ZIt2BX6ARe+kD+aw=
+
modernc.org/libc v1.37.6/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
+
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
+
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
+
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
+
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
+
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
+
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
+
tangled.sh/tangled.sh/core v1.8.1-alpha h1:mCBXOUfzNCT1AnbMnaBrc/AgvfnxOIf5rSIescecpko=
+
tangled.sh/tangled.sh/core v1.8.1-alpha/go.mod h1:9kSVXCu9DMszZoQ5P4Rgdpz+RHLMjbHy++53qE7EBoU=