first commit

This commit is contained in:
2026-04-29 02:35:00 +00:00
commit 2cb17df4c5
90 changed files with 7321 additions and 0 deletions

87
internal/dm/encrypt.go Normal file
View File

@@ -0,0 +1,87 @@
package dm
import (
"context"
"errors"
"fmt"
gn "github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/keyer"
"github.com/nbd-wtf/go-nostr/nip04"
"github.com/nbd-wtf/go-nostr/nip59"
)
var ErrUnsupportedKind = errors.New("unsupported DM kind")
// BuildEvent builds the kind-4 or kind-1059 event(s) for a DM.
// For kind 1059 it returns the gift wrap addressed to the recipient.
// For kind 4 it returns a single signed event.
func BuildEvent(kind int, senderSk, recipientHex, content string) ([]gn.Event, error) {
switch kind {
case 4:
return buildNip04(senderSk, recipientHex, content)
case 1059:
return buildNip17(senderSk, recipientHex, content)
default:
return nil, fmt.Errorf("%w: %d", ErrUnsupportedKind, kind)
}
}
func buildNip04(sk, recipient, content string) ([]gn.Event, error) {
shared, err := nip04.ComputeSharedSecret(recipient, sk)
if err != nil {
return nil, fmt.Errorf("nip04 shared: %w", err)
}
enc, err := nip04.Encrypt(content, shared)
if err != nil {
return nil, fmt.Errorf("nip04 encrypt: %w", err)
}
pk, err := gn.GetPublicKey(sk)
if err != nil {
return nil, err
}
ev := gn.Event{
PubKey: pk,
CreatedAt: gn.Now(),
Kind: 4,
Tags: gn.Tags{{"p", recipient}},
Content: enc,
}
if err := ev.Sign(sk); err != nil {
return nil, fmt.Errorf("sign: %w", err)
}
return []gn.Event{ev}, nil
}
func buildNip17(sk, recipient, content string) ([]gn.Event, error) {
ks, err := keyer.NewPlainKeySigner(sk)
if err != nil {
return nil, fmt.Errorf("keyer: %w", err)
}
ctx := context.Background()
ourPubkey, err := ks.GetPublicKey(ctx)
if err != nil {
return nil, err
}
rumor := gn.Event{
Kind: gn.KindDirectMessage,
Content: content,
Tags: gn.Tags{{"p", recipient}},
CreatedAt: gn.Now(),
PubKey: ourPubkey,
}
rumor.ID = rumor.GetID()
wrap, err := nip59.GiftWrap(
rumor,
recipient,
func(s string) (string, error) { return ks.Encrypt(ctx, s, recipient) },
func(e *gn.Event) error { return ks.SignEvent(ctx, e) },
nil,
)
if err != nil {
return nil, fmt.Errorf("nip17 giftwrap: %w", err)
}
return []gn.Event{wrap}, nil
}

33
internal/dm/model.go Normal file
View File

@@ -0,0 +1,33 @@
package dm
import "time"
type EventType string
const (
EventWelcome EventType = "welcome"
EventExpiringSoon EventType = "expiring_soon"
EventExpired EventType = "expired"
EventExtended EventType = "extended"
)
type Status string
const (
StatusPending Status = "pending"
StatusDelivered Status = "delivered"
StatusDead Status = "dead"
)
type OutboxItem struct {
ID int64
EventType EventType
Pubkey string
Content string
Attempts int
LastAttemptAt *time.Time
NextAttemptAt time.Time
Status Status
LastError string
CreatedAt time.Time
}

86
internal/dm/repo.go Normal file
View File

@@ -0,0 +1,86 @@
package dm
import (
"context"
"database/sql"
"time"
"github.com/noderunners/nip05api/internal/db"
)
type Repo struct{ db *db.DB }
func NewRepo(d *db.DB) *Repo { return &Repo{db: d} }
func (r *Repo) Insert(ctx context.Context, eventType EventType, pubkey, content string) error {
_, err := r.db.ExecContext(ctx, `INSERT INTO dm_outbox
(event_type, pubkey, content, next_attempt_at) VALUES (?, ?, ?, ?)`,
string(eventType), pubkey, content, time.Now().UTC().Format(time.RFC3339))
return err
}
func (r *Repo) Claim(ctx context.Context, limit int) ([]*OutboxItem, error) {
rows, err := r.db.QueryContext(ctx, `SELECT id, event_type, pubkey, content, attempts,
last_attempt_at, next_attempt_at, status, COALESCE(last_error, ''), created_at
FROM dm_outbox
WHERE status = 'pending' AND next_attempt_at <= ?
ORDER BY next_attempt_at ASC LIMIT ?`,
time.Now().UTC().Format(time.RFC3339), limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []*OutboxItem{}
for rows.Next() {
var it OutboxItem
var et, status string
var lastAttempt, nextAttempt, created sql.NullString
if err := rows.Scan(&it.ID, &et, &it.Pubkey, &it.Content, &it.Attempts,
&lastAttempt, &nextAttempt, &status, &it.LastError, &created); err != nil {
return nil, err
}
it.EventType = EventType(et)
it.Status = Status(status)
if lastAttempt.Valid {
if t, err := time.Parse(time.RFC3339, lastAttempt.String); err == nil {
it.LastAttemptAt = &t
}
}
if nextAttempt.Valid {
if t, err := time.Parse(time.RFC3339, nextAttempt.String); err == nil {
it.NextAttemptAt = t
}
}
if created.Valid {
if t, err := time.Parse(time.RFC3339, created.String); err == nil {
it.CreatedAt = t
} else if t, err := time.Parse("2006-01-02 15:04:05", created.String); err == nil {
it.CreatedAt = t
}
}
out = append(out, &it)
}
return out, rows.Err()
}
func (r *Repo) MarkDelivered(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, `UPDATE dm_outbox SET status = 'delivered',
last_attempt_at = ?, last_error = '' WHERE id = ?`,
time.Now().UTC().Format(time.RFC3339), id)
return err
}
func (r *Repo) MarkRetry(ctx context.Context, id int64, attempts int, nextAt time.Time, errMsg string) error {
_, err := r.db.ExecContext(ctx, `UPDATE dm_outbox SET attempts = ?,
last_attempt_at = ?, next_attempt_at = ?, last_error = ? WHERE id = ?`,
attempts, time.Now().UTC().Format(time.RFC3339),
nextAt.UTC().Format(time.RFC3339), errMsg, id)
return err
}
func (r *Repo) MarkDead(ctx context.Context, id int64, errMsg string) error {
_, err := r.db.ExecContext(ctx, `UPDATE dm_outbox SET status = 'dead',
last_attempt_at = ?, last_error = ? WHERE id = ?`,
time.Now().UTC().Format(time.RFC3339), errMsg, id)
return err
}

35
internal/dm/service.go Normal file
View File

@@ -0,0 +1,35 @@
package dm
import (
"context"
"github.com/noderunners/nip05api/internal/messages"
)
type Service struct {
repo *Repo
templates *messages.Templates
enabled bool
}
func NewService(repo *Repo, t *messages.Templates, enabled bool) *Service {
return &Service{repo: repo, templates: t, enabled: enabled}
}
func (s *Service) Enabled() bool { return s.enabled }
// Send renders the template for the given event and enqueues a DM. Empty rendered
// content short-circuits and returns nil.
func (s *Service) Send(ctx context.Context, event EventType, pubkey string, vars map[string]string) error {
if !s.enabled {
return nil
}
content, err := s.templates.Render(string(event), vars)
if err != nil {
return err
}
if content == "" {
return nil
}
return s.repo.Insert(ctx, event, pubkey, content)
}

109
internal/dm/worker.go Normal file
View File

@@ -0,0 +1,109 @@
package dm
import (
"context"
"log/slog"
"sync"
"time"
"github.com/noderunners/nip05api/internal/nostr"
)
var retrySchedule = []time.Duration{
1 * time.Minute,
5 * time.Minute,
30 * time.Minute,
2 * time.Hour,
12 * time.Hour,
}
type Worker struct {
repo *Repo
pool *nostr.Pool
senderSk string
kind int
maxRetries int
}
func NewWorker(repo *Repo, pool *nostr.Pool, nsec string, kind int) (*Worker, error) {
sk, err := nostr.NsecToHex(nsec)
if err != nil {
return nil, err
}
return &Worker{
repo: repo,
pool: pool,
senderSk: sk,
kind: kind,
maxRetries: len(retrySchedule),
}, nil
}
func (w *Worker) Run(ctx context.Context) {
t := time.NewTicker(2 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
w.tick(ctx)
}
}
}
func (w *Worker) tick(ctx context.Context) {
items, err := w.repo.Claim(ctx, 5)
if err != nil {
slog.Error("dm claim", "err", err)
return
}
if len(items) == 0 {
return
}
var wg sync.WaitGroup
for _, it := range items {
wg.Add(1)
go func(it *OutboxItem) {
defer wg.Done()
w.deliver(ctx, it)
}(it)
}
wg.Wait()
}
func (w *Worker) deliver(ctx context.Context, it *OutboxItem) {
pubCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
events, err := BuildEvent(w.kind, w.senderSk, it.Pubkey, it.Content)
if err != nil {
w.handleErr(ctx, it, err)
return
}
for _, ev := range events {
ev := ev
if err := nostr.Publish(pubCtx, w.pool, &ev); err != nil {
w.handleErr(ctx, it, err)
return
}
}
_ = w.repo.MarkDelivered(ctx, it.ID)
slog.Info("dm delivered", "event", it.EventType, "pubkey", it.Pubkey, "id", it.ID)
}
func (w *Worker) handleErr(ctx context.Context, it *OutboxItem, err error) {
attempts := it.Attempts + 1
if attempts >= w.maxRetries {
_ = w.repo.MarkDead(ctx, it.ID, err.Error())
slog.Error("dm dead", "id", it.ID, "pubkey", it.Pubkey, "err", err)
return
}
idx := attempts - 1
if idx >= len(retrySchedule) {
idx = len(retrySchedule) - 1
}
next := time.Now().UTC().Add(retrySchedule[idx])
_ = w.repo.MarkRetry(ctx, it.ID, attempts, next, err.Error())
slog.Warn("dm retry", "id", it.ID, "attempts", attempts, "err", err)
}