diff --git a/.forgejo/workflows/test-endpoints.yaml b/.forgejo/workflows/test-endpoints.yaml new file mode 100644 index 0000000..dfc4e79 --- /dev/null +++ b/.forgejo/workflows/test-endpoints.yaml @@ -0,0 +1,199 @@ +name: Test Every Endpoint on Base Sepolia + +on: + push: + branches: [ main, ai-main ] + pull_request: + branches: [ main ] + workflow_dispatch: + +env: + GO_VERSION: '1.25' + POSTGRES_DB: mcpserver + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + TEST_SERVER_URL: http://localhost:8080 + RPC_URL: https://sepolia.base.org + X402_FACILITATOR_URL: https://api.cdp.coinbase.com/platform/v2/x402 + +jobs: + test_endpoints: + runs-on: node20 + timeout-minutes: 30 + services: + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_DB: mcpserver + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d mcpserver" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + ollama: + image: ollama/ollama:latest + ports: + - 11434:11434 + # no healthcheck here - ollama takes >60s to pull image + start, + # act would mark service 'not healthy' and abort job. Let steps poll. + env: + PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} + X402_WALLET_ADDRESS: ${{ secrets.X402_WALLET_ADDRESS }} + BLUESKY_DID: ${{ secrets.BLUESKY_DID }} + BLUESKY_HOST: ${{ secrets.BLUESKY_HOST }} + BLUESKY_APP_PASSWORD: ${{ secrets.BLUESKY_APP_PASSWORD }} + AP_HOST: ${{ secrets.AP_HOST }} + AP_ACCESS_TOKEN: ${{ secrets.AP_ACCESS_TOKEN }} + TWITTER_BEARER_TOKEN: ${{ secrets.TWITTER_BEARER_TOKEN }} + CDP_API_KEY_ID: ${{ secrets.CDP_API_KEY_ID }} + CDP_API_SECRET: ${{ secrets.CDP_API_SECRET }} + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Load model config + run: | + source config/models.env + echo "EMBEDDING_MODEL=$EMBEDDING_MODEL" >> $FORGEJO_ENV + echo "STYLE_MODEL=$STYLE_MODEL" >> $FORGEJO_ENV + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + + - name: Install Dependencies + run: go mod download + + - name: Verify Code Formatting + run: | + if [ -n "$(gofmt -l .)" ]; then + echo "Code is not formatted correctly. Run 'gofmt -w .'" + exit 1 + fi + + - name: Wait for PostgreSQL + run: | + if ! command -v pg_isready >/dev/null 2>&1; then + apt-get update && apt-get install -y postgresql-client + fi + for i in {1..60}; do + if pg_isready -h localhost -U postgres -d mcpserver >/dev/null 2>&1 || \ + pg_isready -h postgres -U postgres -d mcpserver >/dev/null 2>&1; then + echo "PostgreSQL is ready" + exit 0 + fi + sleep 2 + done + echo "PostgreSQL failed to start" + exit 1 + + - name: Wait for Ollama + run: | + for i in {1..90}; do + if curl -sf http://localhost:11434/api/tags >/dev/null 2>&1 || \ + curl -sf http://ollama:11434/api/tags >/dev/null 2>&1; then + echo "Ollama is ready" + exit 0 + fi + echo "waiting for ollama $i/90..." + sleep 5 + done + echo "Ollama failed to start (not healthy, but continuing - pull may still work)" + curl -v http://localhost:11434/api/tags || curl -v http://ollama:11434/api/tags || true + + - name: Pull Ollama Models + run: | + for host in http://localhost:11434 http://ollama:11434; do + if curl -sf $host/api/tags >/dev/null 2>&1; then + OLLAMA_HOST=$host + break + fi + done + : "${OLLAMA_HOST:=http://localhost:11434}" + echo "Using OLLAMA_HOST=$OLLAMA_HOST" + if [ -n "${EMBEDDING_MODEL:-}" ]; then + echo "Pulling $EMBEDDING_MODEL..." + curl -X POST $OLLAMA_HOST/api/pull -d "{\"name\":\"${EMBEDDING_MODEL}\"}" || true + fi + if [ -n "${STYLE_MODEL:-}" ]; then + echo "Pulling $STYLE_MODEL..." + curl -X POST $OLLAMA_HOST/api/pull -d "{\"name\":\"${STYLE_MODEL}\"}" || true + fi + sleep 2 + curl -sf $OLLAMA_HOST/api/tags || true + + - name: Debug CDP Secret Format + env: + CDP_API_KEY_ID: ${{ secrets.CDP_API_KEY_ID }} + CDP_API_SECRET: ${{ secrets.CDP_API_SECRET }} + run: | + echo "=== CDP SECRET DEBUG ===" + echo "KEY_NAME: ${CDP_API_KEY_ID}" + echo "SECRET_LEN: ${#CDP_API_SECRET}" + echo "SECRET_FIRST_80: ${CDP_API_SECRET:0:80}" + echo "HAS_LITERAL_\\n: $(echo -n "$CDP_API_SECRET" | grep -c '\\\\n' || true)" + echo "HAS_ACTUAL_NEWLINE: $(echo -n "$CDP_API_SECRET" | grep -c $'\n' || true)" + echo "STARTS_WITH: $(echo -n "$CDP_API_SECRET" | head -c 40)" + echo "ENDS_WITH: $(echo -n "$CDP_API_SECRET" | tail -c 20)" + echo "=== END DEBUG ===" + + - name: Check Facilitator Supported Networks + env: + CDP_API_KEY_ID: ${{ secrets.CDP_API_KEY_ID }} + CDP_API_SECRET: ${{ secrets.CDP_API_SECRET }} + X402_FACILITATOR_URL: https://api.cdp.coinbase.com/platform/v2/x402 + run: | + echo "Building facilitator checker..." + go build -o /tmp/check-facilitator ./cmd/check-facilitator + /tmp/check-facilitator + + - name: Start Test Server + run: | + chmod +x scripts/start-test-server.sh + ./scripts/start-test-server.sh + + - name: Build MCP Test Client + run: | + go build -o test-mcp-endpoints ./testClient/mcp + + - name: Run MCP Endpoint Tests + env: + RPC_URL: ${{ env.RPC_URL }} + run: | + ./test-mcp-endpoints + + - name: Build REST Test Client + run: | + go build -o test-endpoints ./testClient/rest + + - name: Run REST Endpoint Tests + env: + RPC_URL: ${{ env.RPC_URL }} + run: | + ./test-endpoints + + - name: Dump Server Logs on Failure + if: failure() + run: | + echo "====== /tmp/mcpserver-test.log ======" + cat /tmp/mcpserver-test.log 2>&1 || echo "no log file at /tmp/mcpserver-test.log" + echo "====== end server log ======" + echo "====== journal (last 200 lines) ======" + journalctl --no-pager -n 200 2>&1 | tail -n 200 || true + + - name: Cleanup + if: always() + run: | + if [ -f /tmp/test-server.pid ]; then + kill -TERM $(cat /tmp/test-server.pid) 2>/dev/null || true + sleep 2 + fi + echo "====== server log tail (always) ======" + tail -n 500 /tmp/mcpserver-test.log 2>&1 || echo "no log file" diff --git a/.forgejo/workflows/test.yaml b/.forgejo/workflows/test.yaml index 92eccfb..e28e78c 100644 --- a/.forgejo/workflows/test.yaml +++ b/.forgejo/workflows/test.yaml @@ -1,10 +1,14 @@ -name: CI/CD Go Verification +name: CI Go Verification on: push: - branches: [ main, dev ] + branches: [ main, dev, ai-main ] pull_request: - branches: [ main ] + branches: [ main, dev ] + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: build_and_test: @@ -29,8 +33,11 @@ jobs: exit 1 fi + - name: Vet + run: go vet ./... + - name: Build Application run: go build -v ./... - - name: Run Tests (with Mocked Boundaries) - run: go test -v -cover ./... \ No newline at end of file + - name: Run Tests + run: go test -v -race -cover ./... \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3476089..c5974fe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ .env cdpScripts/cdp -bin/ \ No newline at end of file +bin/ +test-endpoints +test-endpoints.exe +test-mcp-endpoints +test-mcp-endpoints.exe \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..6d5891e --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,25 @@ +version: "2" +linters: + settings: + errcheck: + exclude-functions: + - (*net/http.Response).Body.Close + - io.Closer.Close + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..317bf8d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,97 @@ +# AGENTS.md — naco-api + +## Quick commands + +```bash +# Verify (no CI — match these locally) +gofmt -l . # fail if any output +go build -v ./... +go test -v -cover ./... + +# Single package / single test +go test -v -cover ./internal/server/... -run TestGetPost +go test -v -cover ./internal/bluesky/... + +# Integration tests (requires Postgres + pgvector; never use prod DB) +docker compose -f docker-compose.test.yml up -d # postgres:5432 + ollama:11434 +TEST_DATABASE_URL="postgres://postgres:postgres@localhost:5432/mcpserver?sslmode=disable" \ + go test -v -cover ./internal/store/... +``` + +## What this is + +Go MCP server (13 tools) for **Bluesky / Nostr / ActivityPub / Twitter**. Every tool is gated by **x402** (HTTP 402 crypto payments, EVM `base`); settlement → Postgres, optional **Matrix** webhook. + +Tools: `getPost`, `getLatestPosts`, `getPostsInTime`, `getFeed` (free), `getActor`, `searchActors`, `getStyleProfile`, `findSimilarUsers`, `getEngagementMetrics`, `getPostingPatterns`, `getTopicModeling`, `getSentimentTrends`, `getEngagementDrivers`. + +## Architecture + +``` +cmd/server/main.go entrypoint — wires config, DB, all clients, readyz sidecar +internal/server/ MCP handlers + x402 + analytics (~1800 lines in server.go — start there) + server.go tool dispatch, settlement, prewarm, cache + analytics.go analytics compute + coverage/cache logic + backends.go platform/backend interfaces (fakes for tests) + storefake_test.go in-memory Store fake (DB-free analytics tests) + types.go request/response types, platform enum + auth.go / webhook.go / cache*.go / *Middleware.go / networkInfo.go + *_test.go split suites: mcp_test.go, analytics_test.go, validation_test.go, etc. +internal/activitypub/ AP client; throttle.go per-host token bucket (1/s) for background GTS warming +internal/{bluesky,nostr,twitter,ollama,config,utils,actor,x402} +sql/schema/ 001..007 goose migrations +sql/queries/ sqlc query files → internal/store/ +config/models.env single source of truth for EMBEDDING_MODEL / STYLE_MODEL +testClient/rest/ manual live REST endpoint client (not part of go test) +testClient/mcp/ manual live MCP endpoint client (not part of go test) +``` + +## Code generation + +`sqlc` generates `internal/store/*.go` from `sql/queries/*.sql` + `sql/schema/`: +```bash +sqlc generate # after editing any file in sql/ +``` +Never hand-edit `// Code generated by sqlc. DO NOT EDIT.` files. + +## Migrations + +Goose-style (`-- +goose up` / `-- +goose down`) in `sql/schema/` — 7 files `001_transactions.sql` … `007_add_analytics_coverage.sql`. Applied at boot when `MIGRATE_ON_START=true` (default `true`). New files must be numbered sequentially (`008_*.sql`). + +## Testing patterns + +- Interface fakes in `internal/server/backends.go` + `storefake_test.go` — no live clients/DB needed. +- Call tools via `callTool(s, name, args)` through the MCP handler (see `mcp_test.go:302`). +- Integration `internal/store/integration_test.go` skipped unless `TEST_DATABASE_URL` set — use `docker-compose.test.yml` (pgvector:pg16). Point it at a throwaway DB. +- `internal/server` tests split by concern (`analytics_test.go`, `searchactors_test.go`, `styleprofile_test.go`, `validation_test.go`, `coverage_test.go`, `twitter_analytics_test.go`); `mcp_test.go` holds cross-cutting dispatch/cache. + +## Config + +Loaded in order: `config/models.env` → `.env` (gitignored, via `godotenv`) → env. `getEnv(k, fallback)` fatals if fallback `""` and var unset. + +| Variable | Default | Notes | +|---|---|---| +| `GOOSE_DBSTRING` | `postgres:///mcpserver` | Server DB (not tests) | +| `TEST_DATABASE_URL` | — | **Tests only**, separate DB | +| `SERVER_ADDRESS` / `SERVER_PORT` | `127.0.0.1` / `8080` | Unix socket via x402 `X402Server`; TCP sidecar on `READYZ_PORT` | +| `READYZ_PORT` | `9090` | Sidecar serves `/readyz` (DB+Ollama health) + `/metrics` | +| `MIGRATE_ON_START` | `true` | Apply `sql/schema` on boot | +| `OLLAMA_HOST` | `http://localhost:11434` | Models from `config/models.env` | +| `MATRIX_WEBHOOK_URL` | — | If set, payment notifications | +| `X402_FACILITATOR_URL` | `https://facilitator.x402.rs` | | +| `ACTIVE_NETWORKS` | `base` | Comma eip155 IDs | +| `ENRICH_CROSS_PLATFORM` | `true` | | + +`findProjectRoot()` walks up to `go.mod` to locate `config/models.env`; tests with `os.Chdir` can break it. + +## Gotchas + +- `bin/` gitignored — `go build -o bin/mcpserver ./cmd/server/`. +- Server DB `GOOSE_DBSTRING` vs test DB `TEST_DATABASE_URL` intentionally separate. +- `testClient/` hits live `api.naco.li` — not run by `go test`. +- `server.go` is large; new tool: register in `registerTools()` and add `func (s *Server) myTool(*mcp.CallToolRequest)`. +- Unix socket MCP + TCP readyz sidecar; `mcpServer.service` expects `bin/main` at repo root. +- Platform inferred from author handle in `determinePlatform`/`fetch*` — not caller-supplied. +- Bluesky auth refresh via `isAuthError`/`withRefresh` — 401/expired token retried once. +- Twitter client clamps `limit` (`>100` → 100), retries 429/5xx/transport errors with `x-rate-limit-*`/`Retry-After` backoff (bounded by `maxRateLimitWait`), and has a 60s per-request timeout. + - ActivityPub client: GTS Mastodon API only serves locally-federated statuses. Remote actors are resolved via `/accounts/search?resolve=true` (not `/accounts/lookup`, which skips WebFinger). When the local cache can't satisfy full `limit` (clamped to 200), `GetLatestNPosts`/`GetPostsInTimeframe` fall back to walking the remote outbox directly (webfinger → actor → `OrderedCollection`/`OrderedCollectionPage`), filtering `Create`/`Note`-like activities and dereferencing IRI-only objects; `GetPost` derefs status URLs over AP when GTS `/search` can't resolve them. The `read:search` scope is needed for status-URL resolution. Outbox-fetched posts are warmed into GTS in background via `GET /api/v2/search?resolve=true&type=statuses&limit=1` per post, throttled at 1 req/s per remote host via per-host token bucket (`internal/activitypub/throttle.go`) with cross-host concurrency, no follows. + - Coverage ranges for analytics are interval-merged; disjoint gaps must remain uncovered (see `coverage_test.go`). diff --git a/README.md b/README.md new file mode 100644 index 0000000..83e8a82 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# naco-api + +Go MCP server (13 tools) for Bluesky / Nostr / ActivityPub / Twitter, gated by x402 crypto payments. + +See `AGENTS.md` for architecture, commands, and development conventions. + +## Payment deadline note (TO REMEMBER) + +`MaxTimeoutSeconds` in `internal/server/settlement.go` (`buildRequirements`) is set to **600s** — not 60s. + +Why: for the `upto` scheme the client signs the Permit2 permit with `deadline = now + MaxTimeoutSeconds`, and the MCP wrapper **settles only after the tool handler has executed**. A slow handler (e.g. `getStyleProfile` which fetches posts and runs style analysis) plus round-trip latency therefore sits inside the permit's validity window. With 60s the permit expired before settlement and the facilitator rejected with `400 invalid_exact_evm_permit2_payload_deadline`. + +If a new payable tool is added with a handler slower than the current deadline window, bump this value along with the HTTP/test timeouts rather than shrinking it back down. \ No newline at end of file diff --git a/cdpScripts/listServices.py b/cdpScripts/listServices.py index 1a49e5f..2d501b5 100644 --- a/cdpScripts/listServices.py +++ b/cdpScripts/listServices.py @@ -19,9 +19,8 @@ async def main(): my_services = [ item for item in services.items - if "api.naco.li" in getattr(item, "resource", "") or getattr(item, "type", "") != "http" + if "api.naco.li" in getattr(item, "resource", "") ] - print(f'First service found: {getattr(services.items[0], "resource", "N/A")}') if my_services: print("Matching services:") diff --git a/cmd/check-facilitator/main.go b/cmd/check-facilitator/main.go new file mode 100644 index 0000000..717cf00 --- /dev/null +++ b/cmd/check-facilitator/main.go @@ -0,0 +1,138 @@ +package main + +import ( + "context" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/nacorid/naco-api/internal/server" +) + +func main() { + facilitatorURL := os.Getenv("X402_FACILITATOR_URL") + if facilitatorURL == "" { + facilitatorURL = "https://api.cdp.coinbase.com/platform/v2/x402" + } + + isMogami := strings.Contains(facilitatorURL, "mogami.tech") + isPayAI := strings.Contains(facilitatorURL, "payai.network") + isCDP := strings.Contains(facilitatorURL, "cdp.coinbase.com") + + var req *http.Request + var err error + + if isMogami || isPayAI { + // Mogami and PayAI facilitators don't require auth + req, err = http.NewRequestWithContext(context.Background(), "GET", facilitatorURL+"/supported", nil) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to create request: %v\n", err) + os.Exit(1) + } + if isPayAI { + fmt.Println("Using PayAI facilitator (no auth)") + } else { + fmt.Println("Using Mogami facilitator (no auth)") + } + } else if isCDP { + keyName := os.Getenv("CDP_API_KEY_ID") + privateKeyPEM := os.Getenv("CDP_API_SECRET") + + if keyName == "" || privateKeyPEM == "" { + fmt.Fprintf(os.Stderr, "CDP_API_KEY_ID and CDP_API_SECRET must be set for CDP facilitator\n") + os.Exit(1) + } + + // Debug: print key info + fmt.Fprintf(os.Stderr, "DEBUG: keyName=%s, secretLen=%d\n", keyName, len(privateKeyPEM)) + fmt.Fprintf(os.Stderr, "DEBUG: secretStart=%s\n", privateKeyPEM[:min(80, len(privateKeyPEM))]) + fmt.Fprintf(os.Stderr, "DEBUG: hasLiteralNewline=%v, hasActualNewline=%v\n", + strings.Contains(privateKeyPEM, "\\n"), + strings.Contains(privateKeyPEM, "\n")) + + // Test key parsing manually first + processedPEM := strings.ReplaceAll(privateKeyPEM, "\\n", "\n") + fmt.Fprintf(os.Stderr, "DEBUG: after replaceAll, startsWith=%s\n", processedPEM[:min(50, len(processedPEM))]) + + block, _ := pem.Decode([]byte(processedPEM)) + if block != nil { + fmt.Fprintf(os.Stderr, "DEBUG: pem block type=%s, bytes=%d\n", block.Type, len(block.Bytes)) + if strings.Contains(block.Type, "EC PRIVATE KEY") { + if _, err := x509.ParseECPrivateKey(block.Bytes); err == nil { + fmt.Fprintf(os.Stderr, "DEBUG: parsed as SEC1 EC key\n") + } else if parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil { + fmt.Fprintf(os.Stderr, "DEBUG: parsed as PKCS8 key, type=%T\n", parsed) + } + } + } + + auth, err := server.NewCDPAuth(keyName, privateKeyPEM) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to create CDP auth: %v\n", err) + os.Exit(1) + } + + fmt.Fprintf(os.Stderr, "DEBUG: auth created, signingMethod=%v\n", auth.GetSigningMethod()) + + headers, err := auth.GetAuthHeaders(context.Background()) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to get auth headers: %v\n", err) + os.Exit(1) + } + + // Debug: print generated tokens + for name, header := range map[string]string{ + "Verify": headers.Verify["Authorization"], + "Settle": headers.Settle["Authorization"], + "Supported": headers.Supported["Authorization"], + } { + if len(header) > 7 { + token := header[7:] // strip "Bearer " + if parts := strings.Split(token, "."); len(parts) == 3 { + if payload, err := base64.RawURLEncoding.DecodeString(parts[1]); err == nil { + fmt.Fprintf(os.Stderr, "DEBUG: %s token claims: %s\n", name, string(payload)) + } + } + } + } + + req, err = http.NewRequestWithContext(context.Background(), "GET", facilitatorURL+"/supported", nil) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to create request: %v\n", err) + os.Exit(1) + } + + for k, v := range headers.Supported { + req.Header.Set(k, v) + } + fmt.Println("Using CDP facilitator (with auth)") + } else { + fmt.Fprintf(os.Stderr, "Unknown facilitator URL: %s\n", facilitatorURL) + os.Exit(1) + } + + httpClient := &http.Client{ + Timeout: 30 * time.Second, + } + resp, err := httpClient.Do(req) + if err != nil { + fmt.Fprintf(os.Stderr, "Request failed: %v\n", err) + os.Exit(1) + } + defer resp.Body.Close() + + maxBytes := int64(5 * 1024 * 1024) + body, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes)) + if err != nil { + fmt.Printf("failed reading response body: %v", err) + } + + fmt.Printf("Status: %d\n", resp.StatusCode) + fmt.Printf("Response: %s\n", body) +} diff --git a/cmd/server/main.go b/cmd/server/main.go index fb6f050..b42c61f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -3,41 +3,75 @@ package main import ( "context" "database/sql" + "errors" + "fmt" + "io" "net/http" "net/url" "os" "os/signal" "syscall" + "time" _ "github.com/lib/pq" log "github.com/nacorid/logger" "github.com/nacorid/naco-api/internal/activitypub" "github.com/nacorid/naco-api/internal/bluesky" "github.com/nacorid/naco-api/internal/config" + "github.com/nacorid/naco-api/internal/nostr" "github.com/nacorid/naco-api/internal/ollama" "github.com/nacorid/naco-api/internal/server" "github.com/nacorid/naco-api/internal/store" + "github.com/nacorid/naco-api/internal/twitter" + "golang.org/x/sync/errgroup" ) func main() { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + // backgroundCtx is for long-lived clients whose lifetime is the process, + // not a single request. It must not inherit signal cancellation so that a + // SIGTERM during startup does not poison an in-flight session creation. + backgroundCtx := context.Background() cfg := config.Load() - log.Init(log.Config{ + if err := log.Init(log.Config{ LogFilePath: cfg.LogFile, ConsoleLevel: log.LevelDebug, FileLevel: log.LevelTrace, - }) + }); err != nil { + log.Fatalf("Failed to init logger: %v", err) + } + defer log.Close() + conn, err := sql.Open("postgres", cfg.DatabaseURL) if err != nil { - log.Fatalf("Failed to connect to database: %v", err) + log.Fatalf("Failed to initialize database connection: %v", err) } defer conn.Close() + + conn.SetMaxOpenConns(10) + conn.SetMaxIdleConns(10) + conn.SetConnMaxLifetime(5 * time.Minute) + + dbCtx, dbCancel := context.WithTimeout(ctx, 5*time.Second) + err = conn.PingContext(dbCtx) + dbCancel() + if err != nil { + log.Fatalf("Failed to ping database: %v", err) + } db := store.New(conn) - bskyClient, err := bluesky.NewBlueskyClient(ctx, cfg.BlueskyDID, cfg.BlueskyAppPassword, cfg.BlueskyHost) + if cfg.MigrateOnStart { + if err := store.Migrate(ctx, conn, cfg.MigrationsDir); err != nil { + log.Fatalf("Failed to apply migrations: %v", err) + } + log.Info("Database migrations applied", "dir", cfg.MigrationsDir) + } + + bskyClient, err := bluesky.NewBlueskyClient(backgroundCtx, cfg.BlueskyDID, cfg.BlueskyAppPassword, cfg.BlueskyHost) if err != nil { log.Fatal("Failed to create bluesky client", "error", err) } @@ -46,25 +80,107 @@ func main() { if err != nil { log.Fatal("Invalid OLLAMA_HOST URL", "error", err) } - ollamaClient := ollama.New(ctx, uri, http.DefaultClient) + ollamaHTTPClient := &http.Client{ + Timeout: 600 * time.Second, + } + ollamaClient := ollama.New(uri, ollamaHTTPClient, cfg.EmbeddingModel, cfg.StyleModel) - apClient, err := activitypub.NewAPClient(ctx, cfg.ActivityPubHost, cfg.ActivityPubToken) + apClient, err := activitypub.NewAPClient(cfg.ActivityPubHost, cfg.ActivityPubToken) if err != nil { log.Fatal("Failed to create activitypub client", "error", err) } - srv := server.New(ctx, db, apClient, bskyClient, ollamaClient, cfg) + nostrClient, err := nostr.NewNostrClient(cfg.NostrRelays) + if err != nil { + log.Fatal("Failed to create nostr client", "error", err) + } - go func() { + twitterClient := twitter.NewTwitterClient(cfg.TwitterBearerToken) + + srv := server.New(ctx, db, apClient, bskyClient, nostrClient, twitterClient, ollamaClient, cfg) + + srv.StartPrewarm(ctx) + + g, gCtx := errgroup.WithContext(ctx) + startReadyzServer(g, ctx, cfg, conn) + + g.Go(func() error { log.Info("Server starting", "address", cfg.ListenAddress) - if err := srv.Start(); err != http.ErrServerClosed { - log.Fatal("HTTP server error", "error", err) + if err := srv.Start(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("main server error: %w", err) } - }() + return nil + }) - stopChan := make(chan os.Signal, 1) - signal.Notify(stopChan, syscall.SIGINT, syscall.SIGTERM) - <-stopChan + g.Go(func() error { + <-gCtx.Done() + log.Info("Shutting down main server...") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + return srv.Shutdown(shutdownCtx) + }) - log.Info("Shutting down server...") + if err := g.Wait(); err != nil && !errors.Is(err, context.Canceled) { + log.Error("Application terminated with error", "error", err) + } + + log.Info("Application shutdown complete") +} + +func startReadyzServer(g *errgroup.Group, shutdownCtx context.Context, cfg *config.Config, conn *sql.DB) { + mux := http.NewServeMux() + + client := &http.Client{ + Timeout: 10 * time.Second, + } + + mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) { + if err := conn.PingContext(r.Context()); err != nil { + http.Error(w, "database unavailable", http.StatusServiceUnavailable) + return + } + + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, cfg.OllamaHost+"/api/tags", nil) + if err != nil { + http.Error(w, "invalid ollama configuration", http.StatusInternalServerError) + return + } + + resp, err := client.Do(req) + if err != nil || resp.StatusCode != http.StatusOK { + if resp != nil { + resp.Body.Close() + } + http.Error(w, "ollama unavailable", http.StatusServiceUnavailable) + return + } + defer func() { + _, _ = io.Copy(io.Discard, resp.Body) + resp.Body.Close() + }() + + _, _ = w.Write([]byte("ok")) + }) + + readyServer := &http.Server{ + Addr: cfg.ReadyzAddress + ":" + cfg.ReadyzPort, + Handler: mux, + ReadTimeout: 5 * time.Second, + WriteTimeout: 5 * time.Second, + } + + g.Go(func() error { + log.Info("Readiness server starting", "address", readyServer.Addr) + if err := readyServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("readiness server failed: %w", err) + } + return nil + }) + + g.Go(func() error { + <-shutdownCtx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + return readyServer.Shutdown(shutdownCtx) + }) } diff --git a/config/models.env b/config/models.env new file mode 100644 index 0000000..96a8c53 --- /dev/null +++ b/config/models.env @@ -0,0 +1,5 @@ +# Ollama model configuration +# Single source of truth for model names used by the application and CI + +EMBEDDING_MODEL=qwen3-embedding:0.6b +STYLE_MODEL=qwen3:4b-instruct-2507-q4_K_M \ No newline at end of file diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..83f6046 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,26 @@ +version: '3.8' + +services: + postgres: + image: pgvector/pgvector:pg16 + environment: + POSTGRES_DB: mcpserver + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d mcpserver"] + interval: 5s + timeout: 5s + retries: 10 + + ollama: + image: ollama/ollama:latest + ports: + - "11434:11434" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:11434/api/tags"] + interval: 10s + timeout: 5s + retries: 10 \ No newline at end of file diff --git a/go.mod b/go.mod index 227cd76..13124e1 100644 --- a/go.mod +++ b/go.mod @@ -3,13 +3,14 @@ module github.com/nacorid/naco-api go 1.25.4 require ( + fiatjaf.com/nostr v0.0.0-20260827104008-28e324b57f7e github.com/bluesky-social/indigo v0.0.0-20251009224519-09f107c1109e github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/jsonschema-go v0.4.3 github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.10.9 github.com/modelcontextprotocol/go-sdk v1.6.1 - github.com/nacorid/logger v0.0.0-20251221013547-5fc8fd94ed80 + github.com/nacorid/logger v0.0.0-20260907215822-b3c536ed7b37 github.com/ollama/ollama v0.12.10 github.com/x402-foundation/x402/go/v2 v2.19.0 golang.org/x/time v0.14.0 @@ -27,15 +28,21 @@ require ( github.com/klauspost/compress v1.18.1 // indirect github.com/supranational/blst v0.3.16 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect - golang.org/x/sync v0.19.0 // indirect + golang.org/x/sync v0.19.0 ) require ( + github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect github.com/StackExchange/wmi v1.2.1 // indirect + github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect + github.com/btcsuite/btcd/btcutil v1.1.5 // indirect + github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect github.com/carlmjohnson/versioninfo v0.22.5 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/coder/websocket v1.8.13 // indirect + github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -59,9 +66,14 @@ require ( github.com/ipfs/go-log/v2 v2.5.1 // indirect github.com/ipfs/go-metrics-interface v0.0.1 // indirect github.com/jbenet/goprocess v0.1.4 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/mailru/easyjson v0.9.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/minio/sha256-simd v1.0.1 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // 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 @@ -70,11 +82,17 @@ require ( github.com/multiformats/go-varint v0.0.7 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect + github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect github.com/segmentio/asm v1.2.0 // indirect github.com/segmentio/encoding v0.5.4 // indirect github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect github.com/sokkalf/slog-seq v0.5.1 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect + github.com/templexxx/cpu v0.0.1 // indirect + github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect diff --git a/go.sum b/go.sum index f88d49c..d43b247 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,10 @@ +fiatjaf.com/nostr v0.0.0-20260827104008-28e324b57f7e h1:BuWR1BP/CBzM+ZbLoUKuV6KlktlDZEyD9N/pdxqJJyE= +fiatjaf.com/nostr v0.0.0-20260827104008-28e324b57f7e/go.mod h1:b1EIUDnd133Ie8Pg8O/biaKdFyCMz28aD4n64g1GqvM= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= +github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 h1:ClzzXMDDuUbWfNNZqGeYq4PnYOlwlOVIvSyNaIy0ykg= +github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3/go.mod h1:we0YA5CsBbH5+/NUzC/AlMmxaDtWlXeNsqrwXjTzmzA= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU= @@ -9,6 +13,7 @@ github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDO github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= +github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= @@ -16,6 +21,30 @@ github.com/bits-and-blooms/bitset v1.24.2 h1:M7/NzVbsytmtfHbumG+K2bremQPMJuqv1JD github.com/bits-and-blooms/bitset v1.24.2/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bluesky-social/indigo v0.0.0-20251009224519-09f107c1109e h1:6oNYIMh+kbArCQiwZYPGgfxQq88tnMyiBIZtg5clq90= github.com/bluesky-social/indigo v0.0.0-20251009224519-09f107c1109e/go.mod h1:RuQVrCGm42QNsgumKaR6se+XkFKfCPNwdCiTvqKRUck= +github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= +github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= +github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A= +github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA= +github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE= +github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ= +github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= +github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A= +github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE= +github.com/btcsuite/btcd/btcutil v1.1.5 h1:+wER79R5670vs/ZusMTF1yTcRYE5GUsFbdjdisflzM8= +github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ= +github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= +github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= +github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= +github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= +github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I= +github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= +github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc= github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -32,6 +61,8 @@ github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwP github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo= github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ= +github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE= +github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/consensys/gnark-crypto v0.19.2 h1:qrEAIXq3T4egxqiliFFoNrepkIWVEeIYwt3UL0fvS80= github.com/consensys/gnark-crypto v0.19.2/go.mod h1:rT23F0XSZqE0mUA0+pRtnL56IbPxs6gp4CeRsBk4XS0= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= @@ -39,6 +70,7 @@ github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/crate-crypto/go-eth-kzg v1.5.0 h1:FYRiJMJG2iv+2Dy3fi14SVGjcPteZ5HAAUe4YWlJygc= github.com/crate-crypto/go-eth-kzg v1.5.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI= +github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -46,10 +78,14 @@ github.com/dchest/siphash v1.2.3 h1:QXwFc8cFOR2dSa/gE6o/HokBMWtLUaNDVd+22aKHeEA= github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc= github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ= github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= +github.com/dvyukov/go-fuzz v0.0.0-20200318091601-be3528f3a813/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw= github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A= github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn27fRjSls= @@ -62,6 +98,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY= github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps= github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -82,10 +120,22 @@ github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXe github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= @@ -95,6 +145,7 @@ 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/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb1T8ac= @@ -117,6 +168,7 @@ github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZ github.com/holiman/bloomfilter/v2 v2.0.3/go.mod h1:zpoh+gs7qcpqrHr3dB55AMiJwo0iURXE7ZOP9L9hSkA= github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA= github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= @@ -151,12 +203,20 @@ github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+ github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5Djy1+FlA= github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o= github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4= +github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= @@ -174,6 +234,8 @@ github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzW github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= @@ -190,6 +252,11 @@ github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjU github.com/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4= github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 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= @@ -202,10 +269,19 @@ github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7B 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/nacorid/logger v0.0.0-20251221013547-5fc8fd94ed80 h1:nZwGInvJNQzPSFYpstVR/TLumqBEZt0+yUXchdyBZiI= -github.com/nacorid/logger v0.0.0-20251221013547-5fc8fd94ed80/go.mod h1:hd9h4KEPU1f/Vv2qP0x/fZUwz2clFvOV/uIF0pkGo+s= +github.com/nacorid/logger v0.0.0-20260907215822-b3c536ed7b37 h1:o7CvgRLUikfsbyuZIJTTrfRac5rMXhca0b61VLBWSIA= +github.com/nacorid/logger v0.0.0-20260907215822-b3c536ed7b37/go.mod h1:hd9h4KEPU1f/Vv2qP0x/fZUwz2clFvOV/uIF0pkGo+s= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/ollama/ollama v0.12.10 h1:Dd0/SeCc+nv+FffxmWuQTGiRreib7Gt3nBhIIFuKwZA= github.com/ollama/ollama v0.12.10/go.mod h1:RUSmYywUWx/YZMaHrqtnT1ZChu+iSz/7jx2aO9+Mgfg= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= +github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8= @@ -233,11 +309,13 @@ github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lne github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg= +github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik= -github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -260,6 +338,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= @@ -267,6 +346,17 @@ github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jq github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/templexxx/cpu v0.0.1 h1:hY4WdLOgKdc8y13EYklu9OUTXik80BkxHoWvTO6MQQY= +github.com/templexxx/cpu v0.0.1/go.mod h1:w7Tb+7qgcAlIyX4NhLuDKt78AHA5SzPmq0Wj6HiEnnk= +github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b h1:XeDLE6c9mzHpdv3Wb1+pWBaWv/BlHK0ZYIu/KaL6eHg= +github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b/go.mod h1:7rwmCH0wC2fQvNEvPZ3sKXukhyCTyiaZ5VTZMQYpZKQ= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= @@ -324,6 +414,7 @@ go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -337,25 +428,37 @@ golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKG golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -371,6 +474,7 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= @@ -394,6 +498,12 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -401,10 +511,15 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/activitypub/client.go b/internal/activitypub/client.go index 38f238f..c3684dd 100644 --- a/internal/activitypub/client.go +++ b/internal/activitypub/client.go @@ -6,149 +6,453 @@ import ( "fmt" "io" "net/http" + "net/url" + "strings" + "sync" "time" + + "github.com/nacorid/naco-api/internal/actor" ) +const maxMastodonPageSize = 40 +const maxTimeframePages = 100 +const maxResponseBody = 1 << 20 +const maxLimit = 200 + type APClient struct { client *http.Client host string bearerToken string + outbox *outboxClient + limiter *hostLimiter } -func NewAPClient(ctx context.Context, host, apikey string) (*APClient, error) { +func NewAPClient(host, apikey string) (*APClient, error) { if host == "" || apikey == "" { return nil, fmt.Errorf("host and apikey must not be empty") } + return &APClient{ - client: http.DefaultClient, - host: host, - bearerToken: fmt.Sprintf("Bearer %s", apikey), + client: &http.Client{Timeout: 30 * time.Second}, + host: strings.TrimRight(host, "/"), + bearerToken: "Bearer " + apikey, + outbox: newOutboxClient(), + limiter: newHostLimiter(), }, nil } -func (ap *APClient) GetPost(ctx context.Context, postID string) (*Post, error) { - url := fmt.Sprintf("%s/api/v1/statuses/%s", ap.host, postID) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) +func (ap *APClient) get(ctx context.Context, endpoint string, target any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) + return fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Authorization", ap.bearerToken) + resp, err := ap.client.Do(req) if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("api error (status %d): %s", resp.StatusCode, string(errBody)) + } + + if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBody)).Decode(target); err != nil { + return fmt.Errorf("failed to decode response: %w", err) + } + + return nil +} + +func (ap *APClient) accountSearch(ctx context.Context, query string) (*Account, error) { + endpoint := fmt.Sprintf("%s/api/v1/accounts/search?q=%s&resolve=true&limit=1", ap.host, url.QueryEscape(query)) + var accounts []*Account + if err := ap.get(ctx, endpoint, &accounts); err != nil { + return nil, fmt.Errorf("failed to search accounts: %w", err) + } + for _, acc := range accounts { + if acc != nil { + return acc, nil + } + } + return nil, nil +} + +func (ap *APClient) resolveAccount(ctx context.Context, actorHandle string) (*Account, error) { + handle := strings.TrimPrefix(actorHandle, "@") + + acc, err := ap.accountSearch(ctx, handle) + if err != nil { + return nil, err + } + if acc != nil { + return acc, nil + } + + if !strings.Contains(handle, "@") { + var byID Account + endpoint := fmt.Sprintf("%s/api/v1/accounts/%s", ap.host, url.PathEscape(handle)) + if err := ap.get(ctx, endpoint, &byID); err != nil { + return nil, fmt.Errorf("account %q not found by search or id: %w", actorHandle, err) + } + if byID.ID == "" { + return nil, fmt.Errorf("account %q not found", actorHandle) + } + return &byID, nil + } + return nil, fmt.Errorf("account %q not found", actorHandle) +} + +func (ap *APClient) GetPost(ctx context.Context, postID string) (*Post, error) { + status, err := ap.resolveStatus(ctx, postID) + if err != nil { + return nil, err + } + return status.toPost(), nil +} + +func (ap *APClient) resolveStatus(ctx context.Context, postID string) (*internalPost, error) { + if strings.HasPrefix(postID, "http://") || strings.HasPrefix(postID, "https://") || strings.HasPrefix(postID, "//") { + if status, err := ap.searchStatus(ctx, postID); err == nil && status != nil { + return status, nil + } + note, derr := ap.outbox.resolveNote(ctx, postID) + if derr != nil { + return nil, fmt.Errorf("status %q not resolvable: %w", postID, derr) + } + author := extractHandleFromURL(postID) + return noteToInternalPost(note, author), nil + } + + endpoint := fmt.Sprintf("%s/api/v1/statuses/%s", ap.host, url.PathEscape(postID)) + var status internalPost + if err := ap.get(ctx, endpoint, &status); err != nil { return nil, fmt.Errorf("failed to get post: %w", err) } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to get post: %s", resp.Status) - } - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - var internal = &internalPost{} - if err = json.Unmarshal(body, internal); err != nil { - return nil, fmt.Errorf("failed to unmarshal json response: %w", err) - } - return internal.toPost(), nil + return &status, nil } -func (ap *APClient) GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*Post, error) { - url := fmt.Sprintf("%s/api/v1/accounts/%s/statuses?limit=%d", ap.host, actor, limit) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) +func (ap *APClient) searchStatus(ctx context.Context, postID string) (*internalPost, error) { + endpoint := fmt.Sprintf("%s/api/v2/search?q=%s&resolve=true&type=statuses&limit=1", ap.host, url.QueryEscape(postID)) + var res searchResult + if err := ap.get(ctx, endpoint, &res); err != nil { + return nil, fmt.Errorf("failed to resolve status %q: %w", postID, err) } - req.Header.Set("Authorization", ap.bearerToken) - resp, err := ap.client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get latest posts: %w", err) + for _, s := range res.Statuses { + if s != nil && (s.URL == postID || s.URI == postID) { + return s, nil + } } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to get latest posts: %s", resp.Status) - } - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - var internal []*internalPost - if err = json.Unmarshal(body, &internal); err != nil { - return nil, fmt.Errorf("failed to unmarshal json response: %w", err) - } - posts := make([]*Post, len(internal)) - for i, p := range internal { - posts[i] = p.toPost() - } - return posts, nil + return nil, nil } -func (ap *APClient) GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]Post, error) { - var posts []Post - var maxID string +func (ap *APClient) GetLatestNPosts(ctx context.Context, actorID string, limit int) ([]*Post, error) { + if limit <= 0 { + return nil, nil + } + if limit > maxLimit { + limit = maxLimit + } - for { + acc, err := ap.resolveAccount(ctx, actorID) + if err != nil { + return nil, err + } + + cached, cacheErr := ap.cacheLatestPosts(ctx, acc, limit) + if cacheErr != nil { + return nil, cacheErr + } + if len(cached) >= limit { + return cachePostsToPosts(cached), nil + } + + if !isRemoteAccount(acc) || acc.URL == "" { + return cachePostsToPosts(cached), nil + } + + outboxPosts, apErr := ap.outbox.getLatest(ctx, acc.URL, acc.Acct, limit-len(cached)) + if apErr != nil { + return nil, fmt.Errorf("outbox fallback failed: %w", apErr) + } + merged := mergePostsBySource(cached, outboxPosts) + if len(outboxPosts) > 0 { + urls := make([]string, 0, len(outboxPosts)) + for _, p := range outboxPosts { + if p != nil && p.ID != "" { + urls = append(urls, p.ID) + } + } + go ap.warmGTSAsync(context.WithoutCancel(ctx), urls) + } + return merged, nil +} + +func (ap *APClient) cacheLatestPosts(ctx context.Context, acc *Account, limit int) ([]*internalPost, error) { + var ( + internal []*internalPost + maxID string + ) + + for len(internal) < limit { if ctx.Err() != nil { return nil, ctx.Err() } - if len(posts) >= limit { - break + + pageSize := limit - len(internal) + if pageSize > maxMastodonPageSize { + pageSize = maxMastodonPageSize } - url := fmt.Sprintf("%s/api/v1/accounts/%s/statuses?limit=50", ap.host, actor) + endpoint := fmt.Sprintf("%s/api/v1/accounts/%s/statuses?limit=%d", ap.host, url.PathEscape(acc.ID), pageSize) if maxID != "" { - url += fmt.Sprintf("&max_id=%s", maxID) + endpoint += "&max_id=" + url.QueryEscape(maxID) } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) + var page []*internalPost + if err := ap.get(ctx, endpoint, &page); err != nil { + return nil, err } - req.Header.Set("Authorization", ap.bearerToken) - - resp, err := ap.client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to get posts: %w", err) - } - // !!IMPORTANT!! Manually close body instead of deferring, due to loop - - if resp.StatusCode != http.StatusOK { - resp.Body.Close() - return nil, fmt.Errorf("failed to get posts: %s", resp.Status) - } - - body, err := io.ReadAll(resp.Body) - resp.Body.Close() - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - var internal []*internalPost - if err := json.Unmarshal(body, &internal); err != nil { - return nil, fmt.Errorf("failed to unmarshal response body: %w", err) - } - - if len(internal) == 0 { + if len(page) == 0 { break } - for _, i := range internal { + newMaxID := page[len(page)-1].ID + if newMaxID == "" { + break + } + if maxID != "" && newMaxID == maxID { + break + } + + internal = append(internal, page...) + maxID = newMaxID + } + if len(internal) > limit { + internal = internal[:limit] + } + return internal, nil +} + +func (ap *APClient) GetPostsInTimeframe(ctx context.Context, actorID string, start, end time.Time, limit int) ([]*Post, error) { + if limit <= 0 { + return nil, nil + } + if limit > maxLimit { + limit = maxLimit + } + + acc, err := ap.resolveAccount(ctx, actorID) + if err != nil { + return nil, err + } + + cached, cacheErr := ap.cachePostsInTimeframe(ctx, acc, start, end, limit) + if cacheErr != nil { + return nil, cacheErr + } + if len(cached) >= limit { + return cachePostsToPosts(cached), nil + } + + if !isRemoteAccount(acc) || acc.URL == "" { + return cachePostsToPosts(cached), nil + } + + outboxPosts, apErr := ap.outbox.getInTimeframe(ctx, acc.URL, acc.Acct, start, end, limit-len(cached)) + if apErr != nil { + return nil, fmt.Errorf("outbox fallback failed: %w", apErr) + } + merged := mergePostsBySource(cached, outboxPosts) + if len(outboxPosts) > 0 { + urls := make([]string, 0, len(outboxPosts)) + for _, p := range outboxPosts { + if p != nil && p.ID != "" { + urls = append(urls, p.ID) + } + } + go ap.warmGTSAsync(context.WithoutCancel(ctx), urls) + } + return merged, nil +} + +func (ap *APClient) cachePostsInTimeframe(ctx context.Context, acc *Account, start, end time.Time, limit int) ([]*internalPost, error) { + var ( + internal []*internalPost + maxID string + ) + + for pages := 0; pages < maxTimeframePages; pages++ { + if ctx.Err() != nil { + return nil, ctx.Err() + } + if len(internal) >= limit { + break + } + + endpoint := fmt.Sprintf("%s/api/v1/accounts/%s/statuses?limit=%d", ap.host, url.PathEscape(acc.ID), maxMastodonPageSize) + if maxID != "" { + endpoint += "&max_id=" + url.QueryEscape(maxID) + } + + var page []*internalPost + if err := ap.get(ctx, endpoint, &page); err != nil { + return nil, err + } + if len(page) == 0 { + break + } + + newMaxID := page[len(page)-1].ID + if newMaxID == "" { + break + } + if maxID != "" && newMaxID == maxID { + break + } + + for _, i := range page { if i.CreatedAt.After(end) { continue } - if i.CreatedAt.Before(start) { - return posts, nil + return internal, nil } - - posts = append(posts, *i.toPost()) - - if len(posts) >= limit { - return posts, nil + internal = append(internal, i) + if len(internal) >= limit { + return internal, nil } } - - maxID = internal[len(internal)-1].ID + maxID = newMaxID } - return posts, nil + return internal, nil +} + +func isRemoteAccount(acc *Account) bool { + return acc != nil && strings.Contains(acc.Acct, "@") +} + +func cachePostsToPosts(cached []*internalPost) []*Post { + if len(cached) == 0 { + return nil + } + posts := make([]*Post, 0, len(cached)) + for _, c := range cached { + if c == nil { + continue + } + posts = append(posts, c.toPost()) + } + return posts +} + +func (ap *APClient) GetActor(ctx context.Context, actorHandle string) (*actor.Profile, error) { + acc, err := ap.resolveAccount(ctx, actorHandle) + if err != nil { + return nil, err + } + return ap.toProfile(acc), nil +} + +func (ap *APClient) SearchActors(ctx context.Context, query string, limit int) ([]*actor.Profile, error) { + endpoint := fmt.Sprintf("%s/api/v1/accounts/search?q=%s&resolve=true&limit=%d", ap.host, url.QueryEscape(query), limit) + var accounts []*Account + if err := ap.get(ctx, endpoint, &accounts); err != nil { + return nil, fmt.Errorf("failed to search actors: %w", err) + } + + results := make([]*actor.Profile, 0, len(accounts)) + for _, acc := range accounts { + if acc == nil { + continue + } + results = append(results, ap.toProfile(acc)) + } + return results, nil +} + +func (ap *APClient) toProfile(acc *Account) *actor.Profile { + return &actor.Profile{ + Platform: "ActivityPub", + Handle: acc.Acct, + DisplayName: acc.DisplayName, + AvatarURL: acc.Avatar, + Bio: acc.Note, + FollowersCount: acc.FollowersCount, + FollowingCount: acc.FollowingCount, + PostCount: acc.StatusesCount, + URL: acc.URL, + } +} + +func extractHandleFromURL(postURL string) string { + u, err := url.Parse(postURL) + if err != nil { + return "" + } + parts := strings.Split(strings.TrimPrefix(u.Path, "/"), "/") + for _, p := range parts { + if strings.HasPrefix(p, "@") { + return strings.TrimPrefix(p, "@") + } + } + return "" +} + +func (ap *APClient) warmGTSAsync(ctx context.Context, urls []string) { + if len(urls) == 0 { + return + } + if ap.limiter == nil { + ap.limiter = newHostLimiter() + } + ctx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + + seen := make(map[string]struct{}, len(urls)) + uniq := make([]string, 0, len(urls)) + for _, u := range urls { + if _, ok := seen[u]; ok { + continue + } + seen[u] = struct{}{} + uniq = append(uniq, u) + } + if len(uniq) > 50 { + uniq = uniq[:50] + } + + byHost := make(map[string][]string) + for _, u := range uniq { + parsed, err := url.Parse(u) + if err != nil || parsed.Host == "" { + continue + } + byHost[parsed.Host] = append(byHost[parsed.Host], u) + } + if len(byHost) == 0 { + return + } + + var wg sync.WaitGroup + for host, us := range byHost { + wg.Add(1) + go func(host string, us []string) { + defer wg.Done() + for _, u := range us { + if err := ap.limiter.Wait(ctx, host); err != nil { + return + } + if ctx.Err() != nil { + return + } + var res searchResult + _ = ap.get(ctx, fmt.Sprintf("%s/api/v2/search?q=%s&resolve=true&type=statuses&limit=1", ap.host, url.QueryEscape(u)), &res) + } + }(host, us) + } + wg.Wait() } diff --git a/internal/activitypub/client_test.go b/internal/activitypub/client_test.go new file mode 100644 index 0000000..04fbffd --- /dev/null +++ b/internal/activitypub/client_test.go @@ -0,0 +1,597 @@ +package activitypub + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func newTestClient(serverURL string) *APClient { + ap, err := NewAPClient("https://example.com", "tok") + if err != nil { + panic(err) + } + host := strings.TrimPrefix(serverURL, "http://") + ap.client = &http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + u := *r.URL + u.Scheme = "http" + u.Host = host + r2 := r.Clone(r.Context()) + r2.URL = &u + return http.DefaultTransport.RoundTrip(r2) + }), + } + return ap +} + +func TestNewAPClientEmpty(t *testing.T) { + if _, err := NewAPClient("", "tok"); err == nil { + t.Fatal("expected error for empty host") + } + if _, err := NewAPClient("host", ""); err == nil { + t.Fatal("expected error for empty apikey") + } +} + +func TestGetLatestNPosts(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprint(w, `[{"id":"1","acct":"alice"}]`) + }) + mux.HandleFunc("/api/v1/accounts/1/statuses", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer tok" { + w.WriteHeader(http.StatusUnauthorized) + return + } + if r.URL.Query().Get("max_id") != "" { + _, _ = fmt.Fprint(w, `[]`) + return + } + _, _ = fmt.Fprint(w, `[{"id":"1","content":"hello world","text":"hello world","created_at":"2024-01-01T00:00:00Z","account":{"acct":"alice"}}]`) + }) + server := httptest.NewServer(mux) + defer server.Close() + + ap := newTestClient(server.URL) + posts, err := ap.GetLatestNPosts(context.Background(), "alice", 10) + if err != nil { + t.Fatalf("GetLatestNPosts error: %v", err) + } + if len(posts) != 1 { + t.Fatalf("expected 1 post, got %d", len(posts)) + } + if posts[0].Content != "hello world" || posts[0].Author != "alice" { + t.Fatalf("unexpected post: %+v", posts[0]) + } +} + +func TestGetLatestNPostsPaginates(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprint(w, `[{"id":"1","acct":"alice"}]`) + }) + mux.HandleFunc("/api/v1/accounts/1/statuses", func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Query().Get("max_id") { + case "40": + ids := make([]map[string]any, 0, 10) + for id := 41; id <= 50; id++ { + ids = append(ids, map[string]any{ + "id": fmt.Sprintf("%d", id), "content": fmt.Sprintf("post %d", id), "created_at": "2024-01-01T00:00:00Z", + "account": map[string]any{"acct": "alice"}, + }) + } + _ = json.NewEncoder(w).Encode(ids) + case "": + ids := make([]map[string]any, 0, 40) + for id := 1; id <= 40; id++ { + ids = append(ids, map[string]any{ + "id": fmt.Sprintf("%d", id), "content": fmt.Sprintf("post %d", id), "created_at": "2024-01-01T00:00:00Z", + "account": map[string]any{"acct": "alice"}, + }) + } + _ = json.NewEncoder(w).Encode(ids) + default: + _ = json.NewEncoder(w).Encode([]map[string]any{}) + } + }) + server := httptest.NewServer(mux) + defer server.Close() + + ap := newTestClient(server.URL) + posts, err := ap.GetLatestNPosts(context.Background(), "alice", 50) + if err != nil { + t.Fatalf("GetLatestNPosts error: %v", err) + } + if len(posts) != 50 { + t.Fatalf("expected 50 posts across 2 pages, got %d", len(posts)) + } + if posts[49].Content != "post 50" { + t.Fatalf("expected last post 'post 50', got %q", posts[49].Content) + } +} + +func TestGetLatestNPostsZeroLimit(t *testing.T) { + ap := newTestClient("http://example.com") + posts, err := ap.GetLatestNPosts(context.Background(), "alice", 0) + if err != nil { + t.Fatalf("GetLatestNPosts with zero limit error: %v", err) + } + if len(posts) != 0 { + t.Fatalf("expected no posts for zero limit, got %d", len(posts)) + } +} + +func TestGetLatestNPostsLimitClamp(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprint(w, `[{"id":"1","acct":"alice"}]`) + }) + mux.HandleFunc("/api/v1/accounts/1/statuses", func(w http.ResponseWriter, r *http.Request) { + limit := r.URL.Query().Get("limit") + l, _ := strconv.Atoi(limit) + if l > maxLimit { + t.Fatalf("expected limit <= %d, got %s", maxLimit, limit) + } + _, _ = fmt.Fprint(w, `[{"id":"1","content":"hello","created_at":"2024-01-01T00:00:00Z","account":{"acct":"alice"}}]`) + }) + server := httptest.NewServer(mux) + defer server.Close() + + ap := newTestClient(server.URL) + posts, err := ap.GetLatestNPosts(context.Background(), "alice", 500) + if err != nil { + t.Fatalf("GetLatestNPosts error: %v", err) + } + if len(posts) == 0 { + t.Fatal("expected at least one post") + } +} + +func TestGetPost(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/statuses/42", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprint(w, `{"id":"42","content":"a post","text":"a post","created_at":"2024-01-01T00:00:00Z","account":{"acct":"bob"}}`) + }) + server := httptest.NewServer(mux) + defer server.Close() + + ap := newTestClient(server.URL) + post, err := ap.GetPost(context.Background(), "42") + if err != nil { + t.Fatalf("GetPost error: %v", err) + } + if post.Content != "a post" || post.Author != "bob" { + t.Fatalf("unexpected post: %+v", post) + } +} + +func TestGetPostURLResolves(t *testing.T) { + url := "https://inst.social/@alice/statuses/42" + mux := http.NewServeMux() + mux.HandleFunc("/api/v2/search", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("q") != url { + w.WriteHeader(http.StatusBadRequest) + return + } + if r.URL.Query().Get("type") != "statuses" || r.URL.Query().Get("resolve") != "true" { + w.WriteHeader(http.StatusBadRequest) + return + } + _, _ = fmt.Fprint(w, `{"accounts":[],"statuses":[{"id":"42","uri":"`+url+`","url":"`+url+`","content":"resolved post","created_at":"2024-01-01T00:00:00Z","account":{"acct":"bob@inst.social"}}]}`) + }) + server := httptest.NewServer(mux) + defer server.Close() + + ap := newTestClient(server.URL) + post, err := ap.GetPost(context.Background(), url) + if err != nil { + t.Fatalf("GetPost error: %v", err) + } + if post.Content != "resolved post" || post.Author != "bob@inst.social" { + t.Fatalf("unexpected post: %+v", post) + } +} + +func TestGetPostURLNotFound(t *testing.T) { + derefTarget := httptest.NewServer(http.NotFoundHandler()) + defer derefTarget.Close() + + mux := http.NewServeMux() + mux.HandleFunc("/api/v2/search", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprint(w, `{"accounts":[],"statuses":[]}`) + }) + gts := httptest.NewServer(mux) + defer gts.Close() + + ap, err := NewAPClient(gts.URL, "tok") + if err != nil { + t.Fatal(err) + } + if _, err := ap.GetPost(context.Background(), derefTarget.URL+"/missed"); err == nil { + t.Fatal("expected error when search and dereference both return nothing") + } +} + +func TestGetPostURLSearchReturnsWrongPost(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/v2/search", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprint(w, `{"accounts":[],"statuses":[{"id":"wrong","uri":"https://other.com/1","content":"wrong post"}]}`) + }) + gts := httptest.NewServer(mux) + defer gts.Close() + + ap, err := NewAPClient(gts.URL, "tok") + if err != nil { + t.Fatal(err) + } + if _, err := ap.GetPost(context.Background(), "https://inst.social/@alice/statuses/42"); err == nil { + t.Fatal("expected error when search returns no matching post") + } +} + +func TestGetPostURLDerefFallback(t *testing.T) { + remote, _ := newRemoteServer() + defer remote.Close() + + mux := http.NewServeMux() + mux.HandleFunc("/api/v2/search", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + gts := httptest.NewServer(mux) + defer gts.Close() + + ap, err := NewAPClient(gts.URL, "tok") + if err != nil { + t.Fatal(err) + } + post, err := ap.GetPost(context.Background(), remote.URL+"/notes/2") + if err != nil { + t.Fatalf("GetPost error: %v", err) + } + if post.Content != "deref note 2" { + t.Fatalf("expected dereferenced note, got %+v", post) + } +} + +func TestGetLatestNPostsRemoteFallsBackToOutbox(t *testing.T) { + remote, remoteBase := newRemoteServer() + defer remote.Close() + + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprintf(w, `[{"id":"1","acct":"alice@example.social","url":%q}]`, remoteBase) + }) + mux.HandleFunc("/api/v1/accounts/1/statuses", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("max_id") != "" { + _, _ = fmt.Fprint(w, `[]`) + return + } + _, _ = fmt.Fprint(w, `[{"id":"c1","content":"cached","uri":`+`"`+remoteBase+`/notes/9","created_at":"2024-03-10T00:00:00Z","account":{"acct":"alice@example.social"}}]`) + }) + gts := httptest.NewServer(mux) + defer gts.Close() + + ap, err := NewAPClient(gts.URL, "tok") + if err != nil { + t.Fatal(err) + } + posts, err := ap.GetLatestNPosts(context.Background(), "@alice@example.social", 5) + if err != nil { + t.Fatalf("GetLatestNPosts error: %v", err) + } + if len(posts) != 5 { + t.Fatalf("expected 5 merged posts, got %d: %+v", len(posts), posts) + } + if posts[0].Content != "cached" { + t.Fatalf("expected newest cached first, got %q", posts[0].Content) + } + if posts[4].Content != "note 4" { + t.Fatalf("expected oldest outbox post last, got %q", posts[4].Content) + } +} + +func TestGetLatestNPostsRemoteEmptyCacheFallsBack(t *testing.T) { + remote, remoteBase := newRemoteServer() + defer remote.Close() + + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprintf(w, `[{"id":"1","acct":"alice@example.social","url":%q}]`, remoteBase) + }) + mux.HandleFunc("/api/v1/accounts/1/statuses", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("max_id") != "" { + _, _ = fmt.Fprint(w, `[]`) + return + } + _, _ = fmt.Fprint(w, `[]`) + }) + gts := httptest.NewServer(mux) + defer gts.Close() + + ap, err := NewAPClient(gts.URL, "tok") + if err != nil { + t.Fatal(err) + } + posts, err := ap.GetLatestNPosts(context.Background(), "@alice@example.social", 4) + if err != nil { + t.Fatalf("GetLatestNPosts error: %v", err) + } + if len(posts) != 4 { + t.Fatalf("expected all 4 posts from outbox, got %d", len(posts)) + } + if posts[0].Content != "bare note 3" { + t.Fatalf("expected outbox post newest-first, got %q", posts[0].Content) + } +} + +func TestGetPostsInTimeframeRemoteFallsBackToOutbox(t *testing.T) { + remote, remoteBase := newRemoteServer() + defer remote.Close() + + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprintf(w, `[{"id":"1","acct":"alice@example.social","url":%q}]`, remoteBase) + }) + mux.HandleFunc("/api/v1/accounts/1/statuses", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("max_id") != "" { + _, _ = fmt.Fprint(w, `[]`) + return + } + _, _ = fmt.Fprint(w, `[{"id":"c1","content":"cached in window","uri":`+`"`+remoteBase+`/notes/5","created_at":"2024-02-20T00:00:00Z","account":{"acct":"alice@example.social"}}]`) + }) + gts := httptest.NewServer(mux) + defer gts.Close() + + ap, err := NewAPClient(gts.URL, "tok") + if err != nil { + t.Fatal(err) + } + start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + end := time.Date(2024, 3, 2, 0, 0, 0, 0, time.UTC) + posts, err := ap.GetPostsInTimeframe(context.Background(), "@alice@example.social", start, end, 3) + if err != nil { + t.Fatalf("GetPostsInTimeframe error: %v", err) + } + if len(posts) != 3 { + t.Fatalf("expected 3 in-window posts, got %d: %+v", len(posts), posts) + } + if posts[0].Content != "created note 1" || posts[1].Content != "cached in window" || posts[2].Content != "deref note 2" { + t.Fatalf("unexpected merged order: %+v", posts) + } +} + +func TestGetPostsInTimeframe(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprint(w, `[{"id":"1","acct":"alice"}]`) + }) + mux.HandleFunc("/api/v1/accounts/1/statuses", func(w http.ResponseWriter, r *http.Request) { + feed := []map[string]any{} + switch r.URL.Query().Get("max_id") { + case "": + feed = []map[string]any{ + {"id": "5", "content": "too-new", "text": "too-new", "created_at": "2025-03-01T00:00:00Z", "account": map[string]any{"acct": "alice"}}, + {"id": "4", "content": "mid", "text": "mid", "created_at": "2024-06-01T00:00:00Z", "account": map[string]any{"acct": "alice"}}, + } + case "4": + feed = []map[string]any{ + {"id": "3", "content": "early-mid", "text": "early-mid", "created_at": "2024-03-01T00:00:00Z", "account": map[string]any{"acct": "alice"}}, + {"id": "2", "content": "too-old", "text": "too-old", "created_at": "2023-01-01T00:00:00Z", "account": map[string]any{"acct": "alice"}}, + } + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(feed); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + }) + server := httptest.NewServer(mux) + defer server.Close() + + ap := newTestClient(server.URL) + start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + end := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC) + posts, err := ap.GetPostsInTimeframe(context.Background(), "alice", start, end, 5) + if err != nil { + t.Fatalf("GetPostsInTimeframe error: %v", err) + } + if len(posts) != 2 { + t.Fatalf("expected 2 in-window posts, got %d", len(posts)) + } + if posts[0].Content != "mid" || posts[1].Content != "early-mid" { + t.Fatalf("unexpected posts: %+v", posts) + } +} + +func TestGetLatestNPostsHTTPError(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprint(w, `[{"id":"1","acct":"alice"}]`) + }) + mux.HandleFunc("/api/v1/accounts/1/statuses", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + server := httptest.NewServer(mux) + defer server.Close() + + ap := newTestClient(server.URL) + if _, err := ap.GetLatestNPosts(context.Background(), "alice", 10); err == nil { + t.Fatal("expected error on non-200 status from timeline") + } +} + +func TestGetActor(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("q") != "alice" { + w.WriteHeader(http.StatusBadRequest) + return + } + _, _ = fmt.Fprint(w, `[{"id":"1","acct":"alice@inst.social","display_name":"Alice","note":"hello there","avatar":"https://img/avatar.png","followers_count":10,"following_count":5,"statuses_count":3,"url":"https://inst.social/@alice"}]`) + }) + server := httptest.NewServer(mux) + defer server.Close() + + ap := newTestClient(server.URL) + prof, err := ap.GetActor(context.Background(), "alice") + if err != nil { + t.Fatalf("GetActor error: %v", err) + } + if prof.Platform != "ActivityPub" || prof.Handle != "alice@inst.social" || prof.DisplayName != "Alice" { + t.Fatalf("unexpected profile: %+v", prof) + } + if prof.Bio != "hello there" || prof.AvatarURL != "https://img/avatar.png" { + t.Fatalf("unexpected profile fields: %+v", prof) + } + if prof.FollowersCount != 10 || prof.FollowingCount != 5 || prof.PostCount != 3 { + t.Fatalf("unexpected counts: %+v", prof) + } + if prof.URL != "https://inst.social/@alice" { + t.Fatalf("unexpected url: %q", prof.URL) + } +} + +func TestGetActorIDFallback(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprint(w, `[]`) + }) + mux.HandleFunc("/api/v1/accounts/alice", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprint(w, `{"id":"1","acct":"alice@inst.social","display_name":"Alice","note":"hi","avatar":"https://img/a.png","followers_count":1,"following_count":2,"statuses_count":3,"url":"https://x"}`) + }) + server := httptest.NewServer(mux) + defer server.Close() + + ap := newTestClient(server.URL) + prof, err := ap.GetActor(context.Background(), "alice") + if err != nil { + t.Fatalf("GetActor error: %v", err) + } + if prof.Handle != "alice@inst.social" { + t.Fatalf("expected fallback id lookup to succeed, got %+v", prof) + } +} + +func TestGetActorAtLocalHandle(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("q") + if q != "@alice" && q != "alice" { + w.WriteHeader(http.StatusBadRequest) + return + } + _, _ = fmt.Fprint(w, `[{"id":"1","acct":"alice","display_name":"Alice","note":"hi","avatar":"https://img/a.png","followers_count":1,"following_count":2,"statuses_count":3,"url":"https://x"}]`) + }) + server := httptest.NewServer(mux) + defer server.Close() + + ap := newTestClient(server.URL) + prof, err := ap.GetActor(context.Background(), "@alice") + if err != nil { + t.Fatalf("GetActor error: %v", err) + } + if prof.Handle != "alice" { + t.Fatalf("expected local handle, got %q", prof.Handle) + } +} + +func TestSearchActors(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("q") != "alice" { + w.WriteHeader(http.StatusBadRequest) + return + } + if r.URL.Query().Get("resolve") != "true" { + w.WriteHeader(http.StatusBadRequest) + return + } + _, _ = fmt.Fprint(w, `[{"id":"1","acct":"alice@inst.social","display_name":"Alice","note":"bio","avatar":"https://img/a.png","followers_count":1,"following_count":2,"statuses_count":3,"url":"https://x"}]`) + }) + server := httptest.NewServer(mux) + defer server.Close() + + ap := newTestClient(server.URL) + results, err := ap.SearchActors(context.Background(), "alice", 10) + if err != nil { + t.Fatalf("SearchActors error: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if results[0].Platform != "ActivityPub" || results[0].Handle != "alice@inst.social" { + t.Fatalf("unexpected result: %+v", results[0]) + } +} + +func TestExtractHandleFromURL(t *testing.T) { + tests := []struct { + url string + expected string + }{ + {"https://inst.social/@alice/statuses/42", "alice"}, + {"http://example.com/@bob/posts/1", "bob"}, + {"https://no-at-here.com/notes/1", ""}, + {"https://host/@user/note", "user"}, + } + for _, tt := range tests { + got := extractHandleFromURL(tt.url) + if got != tt.expected { + t.Fatalf("extractHandleFromURL(%q) = %q, want %q", tt.url, got, tt.expected) + } + } +} + +func TestGetPostBareLocalHandle(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("q") + if q != "@alice" && q != "alice" { + w.WriteHeader(http.StatusBadRequest) + return + } + _, _ = fmt.Fprint(w, `[{"id":"1","acct":"alice"}]`) + }) + mux.HandleFunc("/api/v1/statuses/alice", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprint(w, `{"id":"1","content":"local post","created_at":"2024-01-01T00:00:00Z","account":{"acct":"alice"}}`) + }) + server := httptest.NewServer(mux) + defer server.Close() + + ap := newTestClient(server.URL) + _, err := ap.GetPost(context.Background(), "alice") + if err != nil { + t.Fatalf("GetPost with local handle error: %v", err) + } +} + +func TestSearchActorsNilEntries(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprint(w, `[null,{"id":"1","acct":"alice@inst.social"}]`) + }) + server := httptest.NewServer(mux) + defer server.Close() + + ap := newTestClient(server.URL) + results, err := ap.SearchActors(context.Background(), "alice", 10) + if err != nil { + t.Fatalf("SearchActors error: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 result after filtering nil, got %d", len(results)) + } + if results[0].Handle != "alice@inst.social" { + t.Fatalf("unexpected result: %+v", results[0]) + } +} diff --git a/internal/activitypub/outbox.go b/internal/activitypub/outbox.go new file mode 100644 index 0000000..f7dc81f --- /dev/null +++ b/internal/activitypub/outbox.go @@ -0,0 +1,501 @@ +package activitypub + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + "time" +) + +const outboxMaxPages = 100 + +var webfingerScheme = "https" + +type outboxClient struct { + client *http.Client +} + +type apStringOrArray []string + +func (s *apStringOrArray) UnmarshalJSON(b []byte) error { + b = bytes.TrimSpace(b) + if len(b) == 0 || string(b) == "null" { + *s = nil + return nil + } + if b[0] == '"' { + var v string + if err := json.Unmarshal(b, &v); err != nil { + return err + } + *s = []string{v} + return nil + } + var arr []string + if err := json.Unmarshal(b, &arr); err != nil { + return err + } + *s = arr + return nil +} + +type apIRI string + +func (i *apIRI) UnmarshalJSON(b []byte) error { + b = bytes.TrimSpace(b) + if len(b) == 0 || string(b) == "null" { + *i = "" + return nil + } + if b[0] == '"' { + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + *i = apIRI(s) + return nil + } + var o struct { + ID string `json:"id"` + } + if err := json.Unmarshal(b, &o); err != nil { + return err + } + *i = apIRI(o.ID) + return nil +} + +type apRef struct { + IRI string + Inline json.RawMessage +} + +func (r *apRef) UnmarshalJSON(b []byte) error { + b = bytes.TrimSpace(b) + if len(b) == 0 || string(b) == "null" { + r.IRI = "" + r.Inline = nil + return nil + } + if b[0] == '"' { + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + r.IRI = s + return nil + } + r.Inline = append([]byte(nil), b...) + return nil +} + +type apActor struct { + ID string `json:"id"` + Type string `json:"type"` + PreferredUsername string `json:"preferredUsername"` + URL string `json:"url"` + Outbox apIRI `json:"outbox"` + Inbox apIRI `json:"inbox"` +} + +type apActivity struct { + ID string `json:"id"` + Type string `json:"type"` + Published string `json:"published"` + Content string `json:"content"` + URL apStringOrArray `json:"url"` + Object json.RawMessage `json:"object"` +} + +type apCollection struct { + Type string `json:"type"` + First apRef `json:"first"` +} + +type apPage struct { + Type string `json:"type"` + OrderedItems json.RawMessage `json:"orderedItems"` + Next apRef `json:"next"` +} + +type apNote struct { + ID string `json:"id"` + Type string `json:"type"` + Published string `json:"published"` + Content string `json:"content"` + URL apStringOrArray `json:"url"` +} + +type webfingerResponse struct { + Subject string `json:"subject"` + Links []struct { + Rel string `json:"rel"` + Type string `json:"type"` + Href string `json:"href"` + } `json:"links"` +} + +func newOutboxClient() *outboxClient { + return &outboxClient{client: &http.Client{Timeout: 30 * time.Second}} +} + +func (oc *outboxClient) getJSON(ctx context.Context, endpoint string, target any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Accept", `application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams", application/json`) + + resp, err := oc.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("api error (status %d): %s", resp.StatusCode, string(errBody)) + } + + if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBody)).Decode(target); err != nil { + return fmt.Errorf("failed to decode response: %w", err) + } + return nil +} + +func (oc *outboxClient) webfinger(ctx context.Context, handle string) (string, error) { + full := strings.TrimPrefix(handle, "@") + at := strings.LastIndex(full, "@") + if at <= 0 || at == len(full)-1 { + return "", fmt.Errorf("invalid handle %q", handle) + } + domain := full[at+1:] + + endpoint := fmt.Sprintf("%s://%s/.well-known/webfinger?resource=%s", webfingerScheme, domain, url.QueryEscape("acct:"+full)) + var wf webfingerResponse + if err := oc.getJSON(ctx, endpoint, &wf); err != nil { + return "", fmt.Errorf("webfinger lookup for %q failed: %w", full, err) + } + + var htmlSelf string + for _, l := range wf.Links { + if l.Rel != "self" || l.Href == "" { + continue + } + if strings.Contains(l.Type, "activity+json") || strings.Contains(l.Type, "ld+json") { + return l.Href, nil + } + if htmlSelf == "" && strings.Contains(l.Type, "html") { + htmlSelf = l.Href + } + } + if htmlSelf != "" { + return htmlSelf, nil + } + return "", fmt.Errorf("webfinger for %q returned no self link", full) +} + +func (oc *outboxClient) getActor(ctx context.Context, actorURL string) (*apActor, error) { + var actor apActor + if err := oc.getJSON(ctx, actorURL, &actor); err != nil { + return nil, fmt.Errorf("failed to fetch actor %q: %w", actorURL, err) + } + if actor.ID == "" && actor.Outbox == "" { + return nil, fmt.Errorf("response from %q is not an actor document", actorURL) + } + return &actor, nil +} + +func (oc *outboxClient) resolveActor(ctx context.Context, actorURL, handle string) (*apActor, error) { + if actorURL == "" { + var err error + actorURL, err = oc.webfinger(ctx, handle) + if err != nil { + return nil, err + } + } + return oc.getActor(ctx, actorURL) +} + +func (oc *outboxClient) getLatest(ctx context.Context, actorURL, author string, limit int) ([]*Post, error) { + if limit <= 0 { + return nil, nil + } + return oc.walk(ctx, actorURL, author, limit, func(*Post) bool { return true }) +} + +func (oc *outboxClient) getInTimeframe(ctx context.Context, actorURL, author string, start, end time.Time, limit int) ([]*Post, error) { + if limit <= 0 { + return nil, nil + } + return oc.walk(ctx, actorURL, author, limit, func(p *Post) bool { + return !p.CreatedAt.Before(start) && !p.CreatedAt.After(end) + }) +} + +func (oc *outboxClient) walk(ctx context.Context, actorURL, author string, limit int, include func(*Post) bool) ([]*Post, error) { + actor, err := oc.resolveActor(ctx, actorURL, author) + if err != nil { + return nil, err + } + if actor.Outbox == "" { + return nil, fmt.Errorf("actor %q exposes no outbox", actorURL) + } + + var posts []*Post + seen := make(map[string]struct{}) + + cur, err := oc.firstPage(ctx, string(actor.Outbox)) + if err != nil { + return nil, err + } + + for pages := 0; pages < outboxMaxPages; pages++ { + if ctx.Err() != nil { + return nil, ctx.Err() + } + if len(posts) >= limit { + break + } + + page, ok, err := oc.getPage(ctx, cur) + if err != nil { + return nil, err + } + if !ok { + break + } + + var items []apActivity + if len(page.OrderedItems) > 0 { + if err := json.Unmarshal(page.OrderedItems, &items); err != nil { + return nil, fmt.Errorf("failed to decode outbox page items: %w", err) + } + } + + for _, item := range items { + p, valid, err := oc.itemToPost(ctx, item, author) + if err != nil { + return nil, err + } + if !valid || p == nil { + continue + } + if _, dup := seen[p.ID]; dup { + continue + } + seen[p.ID] = struct{}{} + if include(p) { + posts = append(posts, p) + if len(posts) >= limit { + return posts, nil + } + } + } + + cur = page.Next + if cur.IRI == "" && len(cur.Inline) == 0 { + break + } + } + + return posts, nil +} + +func (oc *outboxClient) firstPage(ctx context.Context, outbox string) (apRef, error) { + var coll apCollection + if err := oc.getJSON(ctx, outbox, &coll); err != nil { + return apRef{}, fmt.Errorf("failed to fetch outbox %q: %w", outbox, err) + } + if coll.Type != "" && coll.Type != "OrderedCollection" { + return apRef{}, fmt.Errorf("outbox %q has unexpected type %q", outbox, coll.Type) + } + if coll.First.IRI == "" && len(coll.First.Inline) == 0 { + return apRef{}, fmt.Errorf("outbox %q exposes no pages", outbox) + } + return coll.First, nil +} + +func (oc *outboxClient) getPage(ctx context.Context, ref apRef) (apPage, bool, error) { + var page apPage + switch { + case ref.IRI != "": + if err := oc.getJSON(ctx, ref.IRI, &page); err != nil { + return apPage{}, false, fmt.Errorf("failed to fetch outbox page %q: %w", ref.IRI, err) + } + case len(ref.Inline) > 0: + if err := json.Unmarshal(ref.Inline, &page); err != nil { + return apPage{}, false, fmt.Errorf("failed to decode inline outbox page: %w", err) + } + default: + return apPage{}, false, nil + } + return page, true, nil +} + +func (oc *outboxClient) itemToPost(ctx context.Context, item apActivity, author string) (*Post, bool, error) { + var note *apNote + var err error + + switch item.Type { + case "Create", "Update": + note, err = oc.noteFromObject(ctx, item.Object) + case "Note", "Article", "Question", "Page", "": + note = &apNote{ + ID: item.ID, + Type: item.Type, + Published: item.Published, + Content: item.Content, + URL: item.URL, + } + default: + return nil, false, nil + } + if err != nil { + return nil, false, err + } + if note == nil || !isPostNote(note.Type) { + return nil, false, nil + } + + id := note.ID + if id == "" && len(note.URL) > 0 { + id = note.URL[0] + } + if id == "" { + return nil, false, nil + } + + created, ok := parseActivityTime(note.Published) + if !ok || created.IsZero() { + return nil, false, nil + } + + return &Post{ + ID: id, + Author: author, + Content: note.Content, + CreatedAt: created, + }, true, nil +} + +func (oc *outboxClient) noteFromObject(ctx context.Context, raw json.RawMessage) (*apNote, error) { + raw = bytes.TrimSpace(raw) + if len(raw) == 0 || string(raw) == "null" { + return nil, nil + } + if raw[0] == '"' { + var id string + if err := json.Unmarshal(raw, &id); err != nil { + return nil, err + } + return oc.fetchNote(ctx, id) + } + var n apNote + if err := json.Unmarshal(raw, &n); err != nil { + return nil, err + } + return &n, nil +} + +func (oc *outboxClient) fetchNote(ctx context.Context, iri string) (*apNote, error) { + var n apNote + if err := oc.getJSON(ctx, iri, &n); err != nil { + return nil, fmt.Errorf("failed to dereference object %q: %w", iri, err) + } + return &n, nil +} + +func (oc *outboxClient) resolveNote(ctx context.Context, url string) (*apNote, error) { + var act apActivity + if err := oc.getJSON(ctx, url, &act); err != nil { + return nil, fmt.Errorf("failed to dereference status %q: %w", url, err) + } + switch act.Type { + case "Create": + return oc.noteFromObject(ctx, act.Object) + case "Note", "Article", "Question", "Page", "": + return &apNote{ + ID: act.ID, + Type: act.Type, + Published: act.Published, + Content: act.Content, + URL: act.URL, + }, nil + default: + return nil, fmt.Errorf("document at %q is not a post (type %q)", url, act.Type) + } +} + +func isPostNote(t string) bool { + switch t { + case "", "Note", "Article", "Question", "Page": + return true + } + return false +} + +func parseActivityTime(s string) (time.Time, bool) { + if s == "" { + return time.Time{}, false + } + for _, layout := range []string{time.RFC3339, time.RFC3339Nano, "2006-01-02T15:04:05"} { + if t, err := time.Parse(layout, s); err == nil { + return t, true + } + } + return time.Time{}, false +} + +func noteToInternalPost(n *apNote, author string) *internalPost { + p := &internalPost{ + ID: n.ID, + URI: n.ID, + Content: n.Content, + } + if len(n.URL) > 0 { + p.URL = n.URL[0] + } + if t, ok := parseActivityTime(n.Published); ok { + p.CreatedAt = t + } + if author != "" { + p.Account = Account{Acct: author} + } + return p +} + +func mergePostsBySource(cached []*internalPost, outbox []*Post) []*Post { + seen := make(map[string]struct{}, len(cached)) + res := make([]*Post, 0, len(cached)+len(outbox)) + for _, c := range cached { + key := c.URI + if key == "" { + key = c.ID + } + if key != "" { + seen[key] = struct{}{} + } + if c != nil { + res = append(res, c.toPost()) + } + } + for _, p := range outbox { + if p == nil || p.ID == "" { + continue + } + if _, dup := seen[p.ID]; dup { + continue + } + seen[p.ID] = struct{}{} + res = append(res, p) + } + sort.SliceStable(res, func(i, j int) bool { return res[i].CreatedAt.After(res[j].CreatedAt) }) + return res +} diff --git a/internal/activitypub/outbox_test.go b/internal/activitypub/outbox_test.go new file mode 100644 index 0000000..fb8c733 --- /dev/null +++ b/internal/activitypub/outbox_test.go @@ -0,0 +1,224 @@ +package activitypub + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// newRemoteServer serves a synthetic ActivityPub instance: a webfinger +// endpoint, an actor with an outbox collection, two outbox pages (with mixed +// activity types and an IRI-referenced object) and a dereferenceable note. +// Returns the server and the actor's canonical URL. +func newRemoteServer() (*httptest.Server, string) { + base := "" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body string + switch { + case strings.HasSuffix(r.URL.Path, "/.well-known/webfinger"): + body = `{"subject":"acct:alice@example.social","links":[` + + `{"rel":"self","type":"application/activity+json","href":"` + base + `/users/alice"},` + + `{"rel":"http://webfinger.net/rel/profile-page","type":"text/html","href":"` + base + `/@alice"}]}` + case r.URL.Path == "/users/alice": + body = `{"id":"` + base + `/users/alice","type":"Person","preferredUsername":"alice","url":"` + base + `/@alice","outbox":"` + base + `/users/alice/outbox"}` + case r.URL.Path == "/users/alice/outbox" && r.URL.Query().Get("page") == "": + body = `{"type":"OrderedCollection","totalItems":5,"first":"` + base + `/users/alice/outbox?page=1"}` + case r.URL.Path == "/users/alice/outbox" && r.URL.Query().Get("page") == "1": + body = `{"type":"OrderedCollectionPage","partOf":"` + base + `/users/alice/outbox","orderedItems":[` + + `{"type":"Create","published":"2024-03-01T00:00:00Z","object":{"type":"Note","id":"` + base + `/notes/1","published":"2024-03-01T00:00:00Z","content":"created note 1","url":"` + base + `/@alice/statuses/1"}},` + + `{"type":"Announce","object":"` + base + `/notes/9"},` + + `{"type":"Like","object":"` + base + `/notes/8"},` + + `{"type":"Create","object":"` + base + `/notes/2"},` + + `{"type":"Note","id":"` + base + `/notes/3","published":"2024-03-03T00:00:00Z","content":"bare note 3","url":["` + base + `/@alice/statuses/3"]}` + + `],"next":"` + base + `/users/alice/outbox?page=2"}` + case r.URL.Path == "/users/alice/outbox" && r.URL.Query().Get("page") == "2": + body = `{"type":"OrderedCollectionPage","orderedItems":[` + + `{"type":"Create","object":{"type":"Note","id":"` + base + `/notes/4","published":"2024-01-15T00:00:00Z","content":"note 4"}}` + + `]}` + case r.URL.Path == "/notes/2": + body = `{"type":"Note","id":"` + base + `/notes/2","published":"2024-02-01T00:00:00Z","content":"deref note 2","url":"` + base + `/@alice/statuses/2"}` + default: + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/activity+json") + _, _ = fmt.Fprint(w, body) + })) + base = server.URL + return server, base + "/users/alice" +} + +func TestOutboxGetLatest(t *testing.T) { + server, actorURL := newRemoteServer() + defer server.Close() + + oc := newOutboxClient() + posts, err := oc.getLatest(context.Background(), actorURL, "alice@example.social", 10) + if err != nil { + t.Fatalf("getLatest error: %v", err) + } + if len(posts) != 4 { + t.Fatalf("expected 4 Create/Note posts (skipping Announce/Like), got %d: %+v", len(posts), posts) + } + want := []string{"created note 1", "deref note 2", "bare note 3", "note 4"} + for i, w := range want { + if posts[i].Content != w { + t.Fatalf("posts[%d].Content = %q, want %q", i, posts[i].Content, w) + } + } + if posts[0].Author != "alice@example.social" { + t.Fatalf("Author = %q, want handle", posts[0].Author) + } +} + +func TestOutboxGetLatestLimit(t *testing.T) { + server, actorURL := newRemoteServer() + defer server.Close() + + oc := newOutboxClient() + posts, err := oc.getLatest(context.Background(), actorURL, "h", 2) + if err != nil { + t.Fatalf("getLatest error: %v", err) + } + if len(posts) != 2 { + t.Fatalf("expected 2 posts, got %d", len(posts)) + } + want := []string{"created note 1", "deref note 2"} + for i, w := range want { + if posts[i].Content != w { + t.Fatalf("posts[%d].Content = %q, want %q", i, posts[i].Content, w) + } + } +} + +func TestOutboxGetInTimeframe(t *testing.T) { + server, actorURL := newRemoteServer() + defer server.Close() + + oc := newOutboxClient() + start := time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC) + end := time.Date(2024, 3, 2, 0, 0, 0, 0, time.UTC) + posts, err := oc.getInTimeframe(context.Background(), actorURL, "h", start, end, 10) + if err != nil { + t.Fatalf("getInTimeframe error: %v", err) + } + if len(posts) != 2 { + t.Fatalf("expected 2 in-window posts (note 3 and 4 are outside), got %d", len(posts)) + } + if posts[0].Content != "created note 1" || posts[1].Content != "deref note 2" { + t.Fatalf("unexpected in-window posts: %+v", posts) + } +} + +func TestOutboxResolveViaWebfinger(t *testing.T) { + server, _ := newRemoteServer() + defer server.Close() + + scheme := webfingerScheme + webfingerScheme = "http" + defer func() { webfingerScheme = scheme }() + + handle := "@alice@" + strings.TrimPrefix(server.URL, "http://") + oc := newOutboxClient() + posts, err := oc.getLatest(context.Background(), "", handle, 10) + if err != nil { + t.Fatalf("getLatest via webfinger error: %v", err) + } + if len(posts) != 4 { + t.Fatalf("expected 4 posts via webfinger, got %d", len(posts)) + } +} + +func TestOutboxResolveNote(t *testing.T) { + server, _ := newRemoteServer() + defer server.Close() + + oc := newOutboxClient() + note, err := oc.resolveNote(context.Background(), server.URL+"/notes/2") + if err != nil { + t.Fatalf("resolveNote error: %v", err) + } + if note.Content != "deref note 2" { + t.Fatalf("unexpected note: %+v", note) + } +} + +func TestNoteToInternalPostSetsAuthor(t *testing.T) { + note := &apNote{ + ID: "https://example.social/notes/1", + Content: "hello", + Published: "2024-01-01T00:00:00Z", + } + p := noteToInternalPost(note, "alice@example.social") + if p.Account.Acct != "alice@example.social" { + t.Fatalf("expected Account.Acct = 'alice@example.social', got %q", p.Account.Acct) + } + if p.toPost().Author != "alice@example.social" { + t.Fatalf("expected toPost().Author = 'alice@example.social', got %q", p.toPost().Author) + } +} + +func TestNoteToInternalPostNoAuthor(t *testing.T) { + note := &apNote{ + ID: "https://example.social/notes/1", + Content: "hello", + Published: "2024-01-01T00:00:00Z", + } + p := noteToInternalPost(note, "") + if p.Account.Acct != "" { + t.Fatalf("expected empty Account.Acct when no author, got %q", p.Account.Acct) + } + if p.toPost().Author != "" { + t.Fatalf("expected empty author when none provided, got %q", p.toPost().Author) + } +} + +func TestMergePostsBySourceDedup(t *testing.T) { + created := time.Date(2024, 3, 1, 0, 0, 0, 0, time.UTC) + cached := []*internalPost{ + {ID: "local-1", URI: "https://example.social/notes/1", Content: "cached", CreatedAt: created}, + } + outbox := []*Post{ + {ID: "https://example.social/notes/1", Content: "cached dup", CreatedAt: created}, + {ID: "https://example.social/notes/2", Content: "outbox only", CreatedAt: created.Add(-time.Hour)}, + } + merged := mergePostsBySource(cached, outbox) + if len(merged) != 2 { + t.Fatalf("expected dedup to drop the duplicate, got %d", len(merged)) + } + if merged[0].Content != "cached" { + t.Fatalf("newest first: expected cached first, got %q", merged[0].Content) + } + if merged[1].Content != "outbox only" { + t.Fatalf("expected outbox only second, got %q", merged[1].Content) + } +} + +func TestParseActivityTime(t *testing.T) { + for _, s := range []string{"2024-01-01T00:00:00Z", "2024-01-01T00:00:00.000Z", "2024-01-01T00:00:00+02:00"} { + if _, ok := parseActivityTime(s); !ok { + t.Fatalf("parseActivityTime(%q) failed", s) + } + } + if _, ok := parseActivityTime("not a date"); ok { + t.Fatal("expected invalid timestamp to fail") + } +} + +func TestAPStringOrArray(t *testing.T) { + var sa apStringOrArray + if err := json.Unmarshal([]byte(`"https://a"`), &sa); err != nil || len(sa) != 1 || sa[0] != "https://a" { + t.Fatalf("string form failed: %v %v", sa, err) + } + if err := json.Unmarshal([]byte(`["https://a","https://b"]`), &sa); err != nil || len(sa) != 2 { + t.Fatalf("array form failed: %v %v", sa, err) + } + if err := json.Unmarshal([]byte(`null`), &sa); err != nil || len(sa) != 0 { + t.Fatalf("null form failed: %v %v", sa, err) + } +} diff --git a/internal/activitypub/throttle.go b/internal/activitypub/throttle.go new file mode 100644 index 0000000..19287d5 --- /dev/null +++ b/internal/activitypub/throttle.go @@ -0,0 +1,58 @@ +package activitypub + +import ( + "context" + "sync" + "time" +) + +type hostLimiter struct { + mu sync.Mutex + buckets map[string]*bucket +} + +type bucket struct { + mu sync.Mutex + last time.Time +} + +func newHostLimiter() *hostLimiter { + return &hostLimiter{buckets: make(map[string]*bucket)} +} + +func (h *hostLimiter) Wait(ctx context.Context, host string) error { + h.mu.Lock() + b, ok := h.buckets[host] + if !ok { + b = &bucket{} + h.buckets[host] = b + if len(h.buckets) > 1024 { + for k, v := range h.buckets { + if time.Since(v.last) > 10*time.Minute { + delete(h.buckets, k) + } + } + } + } + h.mu.Unlock() + + b.mu.Lock() + defer b.mu.Unlock() + + if b.last.IsZero() { + b.last = time.Now() + return nil + } + wait := time.Until(b.last.Add(time.Second)) + if wait <= 0 { + b.last = time.Now() + return nil + } + select { + case <-time.After(wait): + b.last = time.Now() + return nil + case <-ctx.Done(): + return ctx.Err() + } +} diff --git a/internal/activitypub/types.go b/internal/activitypub/types.go index 94849b5..1d9fc27 100644 --- a/internal/activitypub/types.go +++ b/internal/activitypub/types.go @@ -3,6 +3,7 @@ package activitypub import "time" type Post struct { + ID string `json:"id"` Author string `json:"author"` Content string `json:"content"` CreatedAt time.Time `json:"created_at"` @@ -12,14 +13,18 @@ type Post struct { } func (i *internalPost) toPost() *Post { - return &Post{ - Author: i.Account.Acct, + p := &Post{ + ID: i.ID, Content: i.Content, CreatedAt: i.CreatedAt, FavouritesCount: i.FavouritesCount, ReblogsCount: i.ReblogsCount, RepliesCount: i.RepliesCount, } + if i.Account.Acct != "" { + p.Author = i.Account.Acct + } + return p } type internalPost struct { @@ -53,7 +58,6 @@ type internalPost struct { Sensitive bool `json:"sensitive"` SpoilerText string `json:"spoiler_text"` Tags []Tag `json:"tags"` - Text string `json:"text"` URI string `json:"uri"` URL string `json:"url"` Visibility string `json:"visibility"` @@ -266,3 +270,10 @@ type Tag struct { Name string `json:"name"` URL string `json:"url"` } + +// searchResult mirrors the /api/v2/search response: statuses are the api +// `status` objects served by the search endpoint. +type searchResult struct { + Accounts []*Account `json:"accounts"` + Statuses []*internalPost `json:"statuses"` +} diff --git a/internal/actor/profile.go b/internal/actor/profile.go new file mode 100644 index 0000000..a5b866d --- /dev/null +++ b/internal/actor/profile.go @@ -0,0 +1,16 @@ +package actor + +// Profile is a platform-agnostic representation of an actor's public metadata. It is shared +// by every platform client so the server can aggregate results without importing client +// packages (which would create an import cycle). +type Profile struct { + Platform string `json:"platform"` + Handle string `json:"handle"` + DisplayName string `json:"displayName,omitempty"` + AvatarURL string `json:"avatarUrl,omitempty"` + Bio string `json:"bio,omitempty"` + FollowersCount int `json:"followersCount"` + FollowingCount int `json:"followingCount"` + PostCount int `json:"postCount"` + URL string `json:"url,omitempty"` +} diff --git a/internal/bluesky/client.go b/internal/bluesky/client.go index 79523fd..edb6359 100644 --- a/internal/bluesky/client.go +++ b/internal/bluesky/client.go @@ -2,9 +2,11 @@ package bluesky import ( "context" + "errors" "fmt" "net/url" "strings" + "sync" "time" "github.com/bluesky-social/indigo/api/atproto" @@ -12,10 +14,39 @@ import ( "github.com/bluesky-social/indigo/atproto/syntax" "github.com/bluesky-social/indigo/xrpc" log "github.com/nacorid/logger" + "github.com/nacorid/naco-api/internal/actor" +) + +const ( + // maxBskyFeedLimit is the upper bound the Bluesky API accepts for ?limit=. + // Higher values are silently clamped server-side. + maxBskyFeedLimit = 100 + + // maxAuthorFeedPages caps the number of author-feed pages GetPostsInTimeframe + // scans. At 50 posts/page this allows up to ~5,000 statuses per window before + // giving up. + maxAuthorFeedPages = 100 + + // didCacheTTL bounds how long a handle->DID resolution is reused. Handles + // almost never re-map, but the TTL guarantees a long-running process still + // notices handle changes instead of pinning a stale DID forever. + didCacheTTL = 24 * time.Hour ) type BlueskyClient struct { - client *xrpc.Client + client *xrpc.Client + authMu sync.RWMutex + refreshMu sync.Mutex + cacheMu sync.Mutex + didCache map[string]didCacheEntry +} + +// didCacheEntry holds a positive (successfully resolved) handle->DID mapping +// and when it expires. Failures are never cached, so transient resolution +// errors are retried on the next call. +type didCacheEntry struct { + did string + expiresAt time.Time } func NewBlueskyClient(ctx context.Context, handle, password, host string) (*BlueskyClient, error) { @@ -37,61 +68,175 @@ func NewBlueskyClient(ctx context.Context, handle, password, host string) (*Blue Handle: session.Handle, Did: session.Did, } - return &BlueskyClient{client: client}, nil + return &BlueskyClient{ + client: client, + didCache: make(map[string]didCacheEntry), + }, nil +} + +// withRefresh runs fn. If it fails with an auth/expired-token XRPC error, the +// session is refreshed once and fn is retried. +// +// refreshMu serializes refresh calls: Bluesky refresh tokens are single-use and +// rotated, so two concurrent refreshes would race and one should fail for no +// reason. After acquiring the lock the error is re-checked, because a previous +// goroutine may already have refreshed the session while this one waited. +func (c *BlueskyClient) withRefresh(ctx context.Context, fn func() error) error { + if err := fn(); err == nil { + return nil + } else if !isAuthError(err) { + return err + } + + c.refreshMu.Lock() + defer c.refreshMu.Unlock() + + // Another goroutine may have refreshed while we waited for the lock. + err := fn() + if err == nil { + return nil + } + if !isAuthError(err) { + return err + } + + if rerr := c.refreshSession(ctx); rerr != nil { + log.WarnWithContext(ctx, "bluesky session refresh failed", "error", rerr) + return err + } + return fn() +} + +// clientSnapshot returns a copy of the xrpc client that requests must use. It +// pins the current, immutable AuthInfo under the read lock: refreshSession +// replaces the Auth pointer under the exclusive lock rather than mutating the +// old value in place, so every goroutine sharing a pinned AuthInfo reads a +// complete, consistent token. xrpc.Client.Do reads client.Auth without any +// locking of its own, so no request may touch c.client.Auth directly. +func (c *BlueskyClient) clientSnapshot() *xrpc.Client { + c.authMu.RLock() + defer c.authMu.RUnlock() + cp := *c.client + return &cp +} + +func (c *BlueskyClient) refreshSession(ctx context.Context) error { + refreshed, err := atproto.ServerRefreshSession(ctx, c.clientSnapshot()) + if err != nil { + return fmt.Errorf("failed to refresh bluesky session: %w", err) + } + + c.authMu.Lock() + defer c.authMu.Unlock() + c.client.Auth = &xrpc.AuthInfo{ + AccessJwt: refreshed.AccessJwt, + RefreshJwt: refreshed.RefreshJwt, + Handle: c.client.Auth.Handle, + Did: c.client.Auth.Did, + } + return nil +} + +// isAuthError reports whether err is an auth/expired-token XRPC error. The +// ErrStr switch covers the canonical cases; the message-substring fallback is a +// deliberate heuristic for third-party PDS instances that return nonstandard +// error strings. It can false-positive (an unrelated message that happens to +// contain "expired"/"unauthorized"/"token"), which only costs one gratuitous +// refresh + retry. +func isAuthError(err error) bool { + var xe *xrpc.XRPCError + if !errors.As(err, &xe) { + return false + } + switch xe.ErrStr { + case "ExpiredToken", "TokenRequired", "AuthMissing", "AuthorizationRequired": + return true + } + msg := strings.ToLower(xe.Message) + return strings.Contains(msg, "expired") || strings.Contains(msg, "unauthorized") || strings.Contains(msg, "token") } func (c *BlueskyClient) GetPost(ctx context.Context, actor, collection, recordKey string) (*bsky.FeedDefs_PostView, error) { - repo, err := c.resolveUser(ctx, actor) - if err != nil { - return nil, err - } + var result *bsky.FeedDefs_PostView + err := c.withRefresh(ctx, func() error { + repo, err := c.resolveUser(ctx, actor) + if err != nil { + return err + } - var uri string - if collection != "" { - uri = fmt.Sprintf("at://%s/%s/%s", repo.String(), collection, recordKey) - } else { - uri = fmt.Sprintf("at://%s/app.bsky.feed.post/%s", repo.String(), recordKey) - } - log.DebugWithContext(ctx, "Fetching post", "URI", uri) - resp, err := bsky.FeedGetPosts(ctx, c.client, []string{uri}) - if err != nil { - return nil, fmt.Errorf("failed to get post: %w", err) - } - if len(resp.Posts) == 0 { - return nil, fmt.Errorf("post not found") - } - return resp.Posts[0], nil + uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", repo.String(), recordKey) + if collection != "" { + uri = fmt.Sprintf("at://%s/%s/%s", repo.String(), collection, recordKey) + } + // collection and recordKey are caller-supplied; reject a malformed + // at-uri before spending a network round-trip. + if _, err := syntax.ParseATURI(uri); err != nil { + return fmt.Errorf("invalid at uri %q: %w", uri, err) + } + + log.DebugWithContext(ctx, "Fetching post", "URI", uri) + resp, err := bsky.FeedGetPosts(ctx, c.clientSnapshot(), []string{uri}) + if err != nil { + return fmt.Errorf("failed to get post: %w", err) + } + if len(resp.Posts) == 0 { + return fmt.Errorf("post not found") + } + result = resp.Posts[0] + return nil + }) + return result, err } func (c *BlueskyClient) GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*bsky.FeedDefs_FeedViewPost, error) { - repo, err := c.resolveUser(ctx, actor) - if err != nil { - return nil, err + if limit <= 0 { + return nil, nil } - resp, err := bsky.FeedGetAuthorFeed(ctx, c.client, repo.String(), "", "posts", false, int64(limit)) - if err != nil { - return nil, fmt.Errorf("failed to get latest posts: %w", err) + pageSize := limit + if pageSize > maxBskyFeedLimit { + pageSize = maxBskyFeedLimit } - return resp.Feed, nil + + var result []*bsky.FeedDefs_FeedViewPost + err := c.withRefresh(ctx, func() error { + repo, err := c.resolveUser(ctx, actor) + if err != nil { + return err + } + resp, err := bsky.FeedGetAuthorFeed(ctx, c.clientSnapshot(), repo.String(), "", "posts", false, int64(pageSize)) + if err != nil { + return fmt.Errorf("failed to get latest posts: %w", err) + } + result = resp.Feed + return nil + }) + return result, err } func (c *BlueskyClient) GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]*bsky.FeedDefs_FeedViewPost, error) { - repo, err := c.resolveUser(ctx, actor) - if err != nil { - return nil, err - } - - var posts []*bsky.FeedDefs_FeedViewPost - var cursor string - - for len(posts) < limit { - feed, err := bsky.FeedGetAuthorFeed(ctx, c.client, repo.String(), cursor, "posts", false, 50) + var result []*bsky.FeedDefs_FeedViewPost + err := c.withRefresh(ctx, func() error { + repo, err := c.resolveUser(ctx, actor) if err != nil { - return nil, err + return err } - for _, post := range feed.Feed { - if post.Post.Record != nil { + var posts []*bsky.FeedDefs_FeedViewPost + var cursor string + + // The author feed is reverse-chronological, so the first post older + // than start terminates the scan; maxAuthorFeedPages is a belt-and- + // braces guard against a misbehaving server. + for pages := 0; pages < maxAuthorFeedPages; pages++ { + feed, err := bsky.FeedGetAuthorFeed(ctx, c.clientSnapshot(), repo.String(), cursor, "posts", false, 50) + if err != nil { + return err + } + + for _, post := range feed.Feed { + if post.Post == nil || post.Post.Record == nil { + continue + } rec, ok := post.Post.Record.Val.(*bsky.FeedPost) if !ok { log.WarnWithContext(ctx, "Failed to parse post", "post", post.Post.Uri) @@ -104,21 +249,30 @@ func (c *BlueskyClient) GetPostsInTimeframe(ctx context.Context, actor string, s continue } - if createdAt.After(start) && createdAt.Before(end) { - posts = append(posts, post) - if len(posts) >= limit { - return posts, nil - } + if createdAt.After(end) { + continue + } + if createdAt.Before(start) { + // Reverse-chronological: nothing older is in-window. + result = posts + return nil + } + posts = append(posts, post) + if len(posts) >= limit { + result = posts + return nil } } - } - if feed.Cursor == nil || *feed.Cursor == "" { - break + if feed.Cursor == nil || *feed.Cursor == "" { + break + } + cursor = *feed.Cursor } - cursor = *feed.Cursor - } - return posts, nil + result = posts + return nil + }) + return result, err } func (c *BlueskyClient) GetFeed(ctx context.Context, feedURI string, limit int) (*bsky.FeedGetFeed_Output, error) { @@ -126,26 +280,139 @@ func (c *BlueskyClient) GetFeed(ctx context.Context, feedURI string, limit int) if err != nil { return nil, err } - return bsky.FeedGetFeed(ctx, c.client, "", uri, int64(50)) + pageSize := limit + if pageSize <= 0 { + pageSize = 50 + } + if pageSize > maxBskyFeedLimit { + pageSize = maxBskyFeedLimit + } + + var result *bsky.FeedGetFeed_Output + err = c.withRefresh(ctx, func() error { + resp, err := bsky.FeedGetFeed(ctx, c.clientSnapshot(), "", uri, int64(pageSize)) + if err != nil { + return fmt.Errorf("failed to get feed: %w", err) + } + result = resp + return nil + }) + return result, err } +// resolveUser converts a handle or DID into a syntax.DID. Handle resolutions +// are cached for didCacheTTL; DIDs pass through unmodified. Only successful +// resolutions are cached. func (c *BlueskyClient) resolveUser(ctx context.Context, user string) (syntax.DID, error) { - var did syntax.DID if d, err := syntax.ParseDID(user); err == nil { - did = d - } else { - resp, err := atproto.IdentityResolveHandle(ctx, c.client, user) - if err != nil { - return "", fmt.Errorf("failed to resolve handle to DID: %w", err) - } - did, err = syntax.ParseDID(resp.Did) - if err != nil { - return "", fmt.Errorf("failed to parse resolved DID: %w", err) - } + return d, nil } + + c.cacheMu.Lock() + if e, ok := c.didCache[user]; ok && time.Now().Before(e.expiresAt) { + c.cacheMu.Unlock() + return syntax.DID(e.did), nil + } + c.cacheMu.Unlock() + + resp, err := atproto.IdentityResolveHandle(ctx, c.clientSnapshot(), user) + if err != nil { + return "", fmt.Errorf("failed to resolve handle to DID: %w", err) + } + did, err := syntax.ParseDID(resp.Did) + if err != nil { + return "", fmt.Errorf("failed to parse resolved DID: %w", err) + } + + c.cacheMu.Lock() + c.didCache[user] = didCacheEntry{did: did.String(), expiresAt: time.Now().Add(didCacheTTL)} + c.cacheMu.Unlock() return did, nil } +func (c *BlueskyClient) ResolveUser(ctx context.Context, user string) (syntax.DID, error) { + return c.resolveUser(ctx, user) +} + +func (c *BlueskyClient) GetActor(ctx context.Context, author string) (*actor.Profile, error) { + var result *actor.Profile + err := c.withRefresh(ctx, func() error { + params := map[string]any{"actor": author} + var prof bskyActorProfile + if err := c.clientSnapshot().Do(ctx, xrpc.Query, "", "app.bsky.actor.getProfile", params, nil, &prof); err != nil { + return fmt.Errorf("failed to get actor profile: %w", err) + } + result = &actor.Profile{ + Platform: "Bluesky", + Handle: prof.Handle, + DisplayName: prof.DisplayName, + AvatarURL: prof.Avatar, + Bio: prof.Description, + FollowersCount: prof.FollowersCount, + FollowingCount: prof.FollowsCount, + PostCount: prof.PostsCount, + URL: blueskyProfileURL(prof.Handle), + } + return nil + }) + return result, err +} + +func (c *BlueskyClient) SearchActors(ctx context.Context, query string, limit int) ([]*actor.Profile, error) { + var result []*actor.Profile + err := c.withRefresh(ctx, func() error { + params := map[string]any{"q": query, "limit": limit} + var out bskyActorSearch + if err := c.clientSnapshot().Do(ctx, xrpc.Query, "", "app.bsky.actor.searchActors", params, nil, &out); err != nil { + return fmt.Errorf("failed to search actors: %w", err) + } + results := make([]*actor.Profile, 0, len(out.Actors)) + for _, a := range out.Actors { + results = append(results, &actor.Profile{ + Platform: "Bluesky", + Handle: a.Handle, + DisplayName: a.DisplayName, + AvatarURL: a.Avatar, + Bio: a.Description, + URL: blueskyProfileURL(a.Handle), + }) + } + result = results + return nil + }) + return result, err +} + +// blueskyProfileURL derives the canonical public profile URL from the handle. +// app.bsky.actor.getProfile/searchActors do not return a url field. +func blueskyProfileURL(handle string) string { + if handle == "" { + return "" + } + return "https://bsky.app/profile/" + strings.TrimPrefix(handle, "@") +} + +type bskyActorProfile struct { + Did string `json:"did"` + Handle string `json:"handle"` + DisplayName string `json:"displayName"` + Avatar string `json:"avatar"` + Description string `json:"description"` + FollowersCount int `json:"followersCount"` + FollowsCount int `json:"followsCount"` + PostsCount int `json:"postsCount"` +} + +type bskyActorSearch struct { + Actors []struct { + Did string `json:"did"` + Handle string `json:"handle"` + DisplayName string `json:"displayName"` + Avatar string `json:"avatar"` + Description string `json:"description"` + } `json:"actors"` +} + func (c *BlueskyClient) sanitizeFeedURI(ctx context.Context, input string) (string, error) { input = strings.TrimSpace(input) @@ -158,6 +425,10 @@ func (c *BlueskyClient) sanitizeFeedURI(ctx context.Context, input string) (stri return "", fmt.Errorf("invalid URI: %w", err) } + if u.Scheme != "http" && u.Scheme != "https" { + return "", fmt.Errorf("unrecognized URI form (scheme must be http/https): %s", input) + } + parts := strings.Split(strings.Trim(u.Path, "/"), "/") if len(parts) >= 4 && parts[0] == "profile" && parts[2] == "feed" { didRaw := parts[1] diff --git a/internal/bluesky/client_test.go b/internal/bluesky/client_test.go index 504b912..599edbb 100644 --- a/internal/bluesky/client_test.go +++ b/internal/bluesky/client_test.go @@ -7,7 +7,10 @@ import ( "net/http" "net/http/httptest" "strconv" + "sync" + "sync/atomic" "testing" + "time" "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/api/bsky" @@ -30,7 +33,7 @@ func setupMockServer() *httptest.Server { if input.Identifier == "fail.test" { w.WriteHeader(http.StatusUnauthorized) - fmt.Fprintln(w, `{"error": "InvalidCredentials", "message": "Invalid handle or password"}`) + _, _ = fmt.Fprintln(w, `{"error": "InvalidCredentials", "message": "Invalid handle or password"}`) return } @@ -42,7 +45,7 @@ func setupMockServer() *httptest.Server { Did: "did:plc:test", } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + _ = json.NewEncoder(w).Encode(resp) }) // Mock for bsky.FeedGetAuthorFeed @@ -80,7 +83,7 @@ func setupMockServer() *httptest.Server { Feed: feed, } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + _ = json.NewEncoder(w).Encode(resp) }) // Mock for bsky.FeedGetFeed @@ -106,7 +109,125 @@ func setupMockServer() *httptest.Server { }, } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + _ = json.NewEncoder(w).Encode(resp) + }) + + // Mock for bsky.FeedGetPosts (used by GetPost) + mux.HandleFunc("/xrpc/app.bsky.feed.getPosts", func(w http.ResponseWriter, r *http.Request) { + uris := r.URL.Query()["uris"] + if len(uris) == 0 { + http.Error(w, `{"error": "MissingParameter", "message": "uris parameter is required"}`, http.StatusBadRequest) + return + } + + posts := make([]*bsky.FeedDefs_PostView, 0, len(uris)) + for _, uri := range uris { + posts = append(posts, &bsky.FeedDefs_PostView{ + Uri: uri, + Cid: "bafy...", + Author: &bsky.ActorDefs_ProfileViewBasic{ + Did: "did:plc:test", + Handle: "test.handle", + }, + Record: &util.LexiconTypeDecoder{ + Val: &bsky.FeedPost{ + Text: "Test post content", + CreatedAt: "2023-10-27T12:00:00Z", + }, + }, + }) + } + + resp := bsky.FeedGetPosts_Output{ + Posts: posts, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + }) + + // Mock for app.bsky.actor.getProfile (used by GetActor) + mux.HandleFunc("/xrpc/app.bsky.actor.getProfile", func(w http.ResponseWriter, r *http.Request) { + actor := r.URL.Query().Get("actor") + if actor == "" { + http.Error(w, `{"error": "MissingParameter", "message": "actor parameter is required"}`, http.StatusBadRequest) + return + } + + resp := map[string]any{ + "did": "did:plc:test", + "handle": actor, + "displayName": "Test User", + "avatar": "https://example.com/avatar.jpg", + "description": "Test bio", + "followersCount": 100, + "followsCount": 50, + "postsCount": 200, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + }) + + // Mock for app.bsky.actor.searchActors (used by SearchActors) + mux.HandleFunc("/xrpc/app.bsky.actor.searchActors", func(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query().Get("q") + limitStr := r.URL.Query().Get("limit") + limit := 10 + if limitStr != "" { + limit, _ = strconv.Atoi(limitStr) + } + + if query == "" { + http.Error(w, `{"error": "MissingParameter", "message": "q parameter is required"}`, http.StatusBadRequest) + return + } + + actors := make([]map[string]any, 0, limit) + for i := 0; i < limit; i++ { + actors = append(actors, map[string]any{ + "did": fmt.Sprintf("did:plc:test%d", i), + "handle": fmt.Sprintf("%s%d.test", query, i), + "displayName": fmt.Sprintf("Test User %d", i), + "avatar": "https://example.com/avatar.jpg", + "description": fmt.Sprintf("Bio for user %d", i), + }) + } + + resp := map[string]any{"actors": actors} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + }) + + // Mock for com.atproto.identity.resolveHandle (used by ResolveUser) + mux.HandleFunc("/xrpc/com.atproto.identity.resolveHandle", func(w http.ResponseWriter, r *http.Request) { + handle := r.URL.Query().Get("handle") + if handle == "" { + http.Error(w, `{"error": "MissingParameter", "message": "handle parameter is required"}`, http.StatusBadRequest) + return + } + + resp := atproto.IdentityResolveHandle_Output{ + Did: "did:plc:resolved", + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + }) + + // Mock for com.atproto.server.refreshSession (used by withRefresh) + mux.HandleFunc("/xrpc/com.atproto.server.refreshSession", func(w http.ResponseWriter, r *http.Request) { + // Check for Authorization header + auth := r.Header.Get("Authorization") + if auth == "" { + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "TokenRequired"}) + return + } + + resp := atproto.ServerRefreshSession_Output{ + AccessJwt: "refreshed_access_jwt", + RefreshJwt: "refreshed_refresh_jwt", + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) }) return httptest.NewServer(mux) @@ -171,6 +292,7 @@ func newTestClient(t *testing.T, serverURL string) *BlueskyClient { AccessJwt: "test_access_jwt", }, }, + didCache: make(map[string]didCacheEntry), } } @@ -181,7 +303,7 @@ func TestGetLatestNPosts(t *testing.T) { client := newTestClient(t, server.URL) ctx := context.Background() actor := "did:plc:testactor" - var limit int = 5 + limit := 5 output, err := client.GetLatestNPosts(ctx, actor, limit) if err != nil { @@ -208,7 +330,7 @@ func TestGetFeed(t *testing.T) { client := newTestClient(t, server.URL) ctx := context.Background() feedURI := "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/with-friends" - var limit int = 10 + limit := 10 output, err := client.GetFeed(ctx, feedURI, limit) if err != nil { @@ -227,3 +349,597 @@ func TestGetFeed(t *testing.T) { t.Errorf("Unexpected post URI: %s", output.Feed[0].Post.Uri) } } + +func TestGetPost(t *testing.T) { + server := setupMockServer() + defer server.Close() + + client := newTestClient(t, server.URL) + ctx := context.Background() + + postURI := "at://did:plc:testactor/app.bsky.feed.post/123" + post, err := client.GetPost(ctx, "did:plc:testactor", "", "123") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if post == nil { + t.Fatal("Expected post not to be nil") + } + + if post.Uri != postURI { + t.Errorf("Expected post URI %q, got %q", postURI, post.Uri) + } + + if post.Author.Handle != "test.handle" { + t.Errorf("Expected author handle 'test.handle', got %q", post.Author.Handle) + } + + // Test with custom collection + post2, err := client.GetPost(ctx, "did:plc:testactor", "app.bsky.feed.post", "456") + if err != nil { + t.Fatalf("Expected no error with custom collection, got %v", err) + } + if post2 == nil { + t.Fatal("Expected post not to be nil") + } +} + +func TestGetPostsInTimeframe(t *testing.T) { + // The mock author-feed is reverse-chronological: page 1 has posts newer than + // the window end (skipped, cursor advances), page 2 has the in-range posts + // followed by one older than start (which terminates the scan). + requests := 0 + mux := http.NewServeMux() + mux.HandleFunc("/xrpc/com.atproto.server.createSession", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(atproto.ServerCreateSession_Output{Handle: "test.handle", Did: "did:plc:test"}) + }) + mux.HandleFunc("/xrpc/app.bsky.feed.getAuthorFeed", func(w http.ResponseWriter, r *http.Request) { + requests++ + mk := func(uri, createdAt string) *bsky.FeedDefs_FeedViewPost { + return &bsky.FeedDefs_FeedViewPost{ + Post: &bsky.FeedDefs_PostView{ + Uri: uri, + Author: &bsky.ActorDefs_ProfileViewBasic{ + Did: "did:plc:testactor", + Handle: "testactor.test", + }, + Record: &util.LexiconTypeDecoder{ + Val: &bsky.FeedPost{Text: uri, CreatedAt: createdAt}, + }, + }, + } + } + + var feed []*bsky.FeedDefs_FeedViewPost + var cursor string + if requests == 1 { + // Page 1: one post after the window end (skipped), with a cursor. + feed = []*bsky.FeedDefs_FeedViewPost{ + mk("at://did:plc:testactor/app.bsky.feed.post/4", "2024-01-01T00:00:00Z"), + } + cursor = "page-2-cursor" + } else { + // Page 2: two in-range posts, then one older than start (terminates). + feed = []*bsky.FeedDefs_FeedViewPost{ + mk("at://did:plc:testactor/app.bsky.feed.post/2", "2023-08-01T00:00:00Z"), + mk("at://did:plc:testactor/app.bsky.feed.post/1", "2023-06-01T00:00:00Z"), + mk("at://did:plc:testactor/app.bsky.feed.post/3", "2022-01-01T00:00:00Z"), + } + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(bsky.FeedGetAuthorFeed_Output{Feed: feed, Cursor: &cursor}) + }) + + server := httptest.NewServer(mux) + defer server.Close() + + client := newTestClient(t, server.URL) + ctx := context.Background() + + actor := "did:plc:testactor" + start := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC) + end := time.Date(2023, 12, 31, 23, 59, 59, 0, time.UTC) + limit := 10 + + posts, err := client.GetPostsInTimeframe(ctx, actor, start, end, limit) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + // Only the two in-range posts must survive filtering; cursor pagination must + // advance into page 2 to reach them. + if len(posts) != 2 { + t.Fatalf("expected 2 in-range posts, got %d", len(posts)) + } + for _, post := range posts { + rec, ok := post.Post.Record.Val.(*bsky.FeedPost) + if !ok { + t.Fatalf("unexpected record type") + } + createdAt, _ := time.Parse(time.RFC3339, rec.CreatedAt) + if createdAt.Before(start) || createdAt.After(end) { + t.Errorf("Post %s returned outside time range", post.Post.Uri) + } + } + if requests != 2 { + t.Errorf("expected pagination to make 2 feed requests, got %d", requests) + } +} + +func TestGetActor(t *testing.T) { + server := setupMockServer() + defer server.Close() + + client := newTestClient(t, server.URL) + ctx := context.Background() + + actor := "test.handle" + profile, err := client.GetActor(ctx, actor) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if profile == nil { + t.Fatal("Expected profile not to be nil") + } + + if profile.Handle != actor { + t.Errorf("Expected handle %q, got %q", actor, profile.Handle) + } + if profile.DisplayName != "Test User" { + t.Errorf("Expected display name 'Test User', got %q", profile.DisplayName) + } + if profile.Platform != "Bluesky" { + t.Errorf("Expected platform 'Bluesky', got %q", profile.Platform) + } + if profile.URL != "https://bsky.app/profile/"+actor { + t.Errorf("Expected URL %q, got %q", "https://bsky.app/profile/"+actor, profile.URL) + } + if profile.FollowersCount != 100 || profile.FollowingCount != 50 || profile.PostCount != 200 { + t.Errorf("Unexpected counts: followers=%d, following=%d, posts=%d", profile.FollowersCount, profile.FollowingCount, profile.PostCount) + } +} + +func TestSearchActors(t *testing.T) { + server := setupMockServer() + defer server.Close() + + client := newTestClient(t, server.URL) + ctx := context.Background() + + query := "alice" + limit := 5 + + results, err := client.SearchActors(ctx, query, limit) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if results == nil { + t.Fatal("Expected results not to be nil") + } + + if len(results) != limit { + t.Errorf("Expected %d results, got %d", limit, len(results)) + } + + for i, result := range results { + expectedHandle := fmt.Sprintf("%s%d.test", query, i) + if result.Handle != expectedHandle { + t.Errorf("Result %d: expected handle %q, got %q", i, expectedHandle, result.Handle) + } + if result.Platform != "Bluesky" { + t.Errorf("Result %d: expected platform 'Bluesky', got %q", i, result.Platform) + } + expectedURL := "https://bsky.app/profile/" + expectedHandle + if result.URL != expectedURL { + t.Errorf("Result %d: expected URL %q, got %q", i, expectedURL, result.URL) + } + } +} + +func TestResolveUser(t *testing.T) { + server := setupMockServer() + defer server.Close() + + client := newTestClient(t, server.URL) + ctx := context.Background() + + // Test with handle + did, err := client.ResolveUser(ctx, "test.handle") + if err != nil { + t.Fatalf("Expected no error resolving handle, got %v", err) + } + if did.String() != "did:plc:resolved" { + t.Errorf("Expected resolved DID 'did:plc:resolved', got %q", did.String()) + } + + // Test with DID directly + did2, err := client.ResolveUser(ctx, "did:plc:direct") + if err != nil { + t.Fatalf("Expected no error with direct DID, got %v", err) + } + if did2.String() != "did:plc:direct" { + t.Errorf("Expected same DID 'did:plc:direct', got %q", did2.String()) + } +} + +func TestSanitizeFeedURI(t *testing.T) { + server := setupMockServer() + defer server.Close() + + client := newTestClient(t, server.URL) + ctx := context.Background() + + tests := []struct { + name string + input string + expected string + wantErr bool + }{ + { + name: "AT URI", + input: "at://did:plc:abc/app.bsky.feed.generator/xyz", + expected: "at://did:plc:abc/app.bsky.feed.generator/xyz", + wantErr: false, + }, + { + name: "Web URI with profile/feed", + input: "https://bsky.app/profile/did:plc:abc/feed/xyz", + expected: "at://did:plc:abc/app.bsky.feed.generator/xyz", + wantErr: false, + }, + { + name: "Non-HTTP scheme", + input: "gopher://bsky.app/profile/did:plc:abc/feed/xyz", + expected: "", + wantErr: true, + }, + { + name: "Invalid URI", + input: "not-a-uri", + expected: "", + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result, err := client.sanitizeFeedURI(ctx, tc.input) + if tc.wantErr { + if err == nil { + t.Fatalf("Expected error for input %q, got nil", tc.input) + } + return + } + if err != nil { + t.Fatalf("Unexpected error for input %q: %v", tc.input, err) + } + if result != tc.expected { + t.Errorf("Input %q: expected %q, got %q", tc.input, tc.expected, result) + } + }) + } +} + +func TestGetLatestNPostsLimitClamp(t *testing.T) { + server := setupMockServer() + defer server.Close() + + client := newTestClient(t, server.URL) + ctx := context.Background() + actor := "did:plc:testactor" + + t.Run("OverMaxLimitClampedTo100", func(t *testing.T) { + output, err := client.GetLatestNPosts(ctx, actor, 250) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if len(output) != maxBskyFeedLimit { + t.Errorf("Expected %d posts (clamped), got %d", maxBskyFeedLimit, len(output)) + } + }) + + t.Run("ZeroLimitReturnsNil", func(t *testing.T) { + output, err := client.GetLatestNPosts(ctx, actor, 0) + if err != nil { + t.Fatalf("Expected no error with zero limit, got %v", err) + } + if output != nil { + t.Errorf("Expected nil output for zero limit, got %d posts", len(output)) + } + }) + + t.Run("NegativeLimitReturnsNil", func(t *testing.T) { + output, err := client.GetLatestNPosts(ctx, actor, -5) + if err != nil { + t.Fatalf("Expected no error with negative limit, got %v", err) + } + if output != nil { + t.Errorf("Expected nil output for negative limit, got %d posts", len(output)) + } + }) +} + +func TestGetFeedHonorsLimit(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/xrpc/app.bsky.feed.getFeed", func(w http.ResponseWriter, r *http.Request) { + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + feed := make([]*bsky.FeedDefs_FeedViewPost, 0, limit) + for i := 0; i < limit; i++ { + feed = append(feed, &bsky.FeedDefs_FeedViewPost{ + Post: &bsky.FeedDefs_PostView{ + Uri: fmt.Sprintf("at://did:plc:test/app.bsky.feed.post/%d", i), + Author: &bsky.ActorDefs_ProfileViewBasic{Did: "did:plc:test", Handle: "test.handle"}, + }, + }) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(bsky.FeedGetFeed_Output{Feed: feed}) + }) + + server := httptest.NewServer(mux) + defer server.Close() + + client := newTestClient(t, server.URL) + ctx := context.Background() + feedURI := "at://did:plc:abc/app.bsky.feed.generator/xyz" + + t.Run("ExplicitLimitPassedThrough", func(t *testing.T) { + output, err := client.GetFeed(ctx, feedURI, 75) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if len(output.Feed) != 75 { + t.Errorf("Expected 75 posts, got %d", len(output.Feed)) + } + }) + + t.Run("ZeroLimitDefaultsTo50", func(t *testing.T) { + output, err := client.GetFeed(ctx, feedURI, 0) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if len(output.Feed) != 50 { + t.Errorf("Expected 50 posts (default), got %d", len(output.Feed)) + } + }) + + t.Run("OverMaxLimitClampedTo100", func(t *testing.T) { + output, err := client.GetFeed(ctx, feedURI, 1000) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if len(output.Feed) != maxBskyFeedLimit { + t.Errorf("Expected %d posts (clamped), got %d", maxBskyFeedLimit, len(output.Feed)) + } + }) +} + +func TestGetPostInvalidRecordKey(t *testing.T) { + requests := 0 + mux := http.NewServeMux() + mux.HandleFunc("/xrpc/app.bsky.feed.getPosts", func(w http.ResponseWriter, r *http.Request) { + requests++ + http.Error(w, `{"error":"InternalServerError"}`, http.StatusInternalServerError) + }) + + server := httptest.NewServer(mux) + defer server.Close() + + client := newTestClient(t, server.URL) + ctx := context.Background() + + post, err := client.GetPost(ctx, "did:plc:testactor", "", "bad/key") + if post != nil { + t.Fatalf("Expected nil post, got %v", post) + } + if err == nil { + t.Fatal("Expected error for invalid record key, got nil") + } + if requests != 0 { + t.Errorf("Expected no network request for invalid record key, got %d", requests) + } +} + +func TestWithRefreshConcurrent(t *testing.T) { + var refreshCount atomic.Int32 + + mux := http.NewServeMux() + mux.HandleFunc("/xrpc/app.bsky.actor.getProfile", func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if auth != "Bearer refreshed_access_jwt" { + // The initial access token is expired; the session must be refreshed + // before the retry succeeds. + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "ExpiredToken", "message": "Token expired, refresh required"}) + return + } + resp := map[string]any{ + "did": "did:plc:test", + "handle": "test.handle", + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + }) + mux.HandleFunc("/xrpc/com.atproto.server.refreshSession", func(w http.ResponseWriter, r *http.Request) { + refreshCount.Add(1) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(atproto.ServerRefreshSession_Output{ + AccessJwt: "refreshed_access_jwt", + RefreshJwt: "refreshed_refresh_jwt", + }) + }) + + server := httptest.NewServer(mux) + defer server.Close() + + client := newTestClient(t, server.URL) + ctx := context.Background() + + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if _, err := client.GetActor(ctx, "test.handle"); err != nil { + t.Errorf("expected no error, got %v", err) + } + }() + } + wg.Wait() + + if refreshCount.Load() != 1 { + t.Errorf("expected exactly 1 session refresh under concurrency, got %d", refreshCount.Load()) + } +} + +func TestGetPostsInTimeframeStopsAtStart(t *testing.T) { + requests := 0 + mux := http.NewServeMux() + mux.HandleFunc("/xrpc/app.bsky.feed.getAuthorFeed", func(w http.ResponseWriter, r *http.Request) { + requests++ + mk := func(uri, createdAt string) *bsky.FeedDefs_FeedViewPost { + return &bsky.FeedDefs_FeedViewPost{ + Post: &bsky.FeedDefs_PostView{ + Uri: uri, + Author: &bsky.ActorDefs_ProfileViewBasic{Did: "did:plc:testactor", Handle: "testactor.test"}, + Record: &util.LexiconTypeDecoder{ + Val: &bsky.FeedPost{Text: uri, CreatedAt: createdAt}, + }, + }, + } + } + feed := []*bsky.FeedDefs_FeedViewPost{ + mk("at://did:plc:testactor/app.bsky.feed.post/1", "2023-06-01T00:00:00Z"), + mk("at://did:plc:testactor/app.bsky.feed.post/0", "2022-01-01T00:00:00Z"), + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(bsky.FeedGetAuthorFeed_Output{Feed: feed}) + }) + + server := httptest.NewServer(mux) + defer server.Close() + + client := newTestClient(t, server.URL) + ctx := context.Background() + + start := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC) + end := time.Date(2023, 12, 31, 23, 59, 59, 0, time.UTC) + + posts, err := client.GetPostsInTimeframe(ctx, "did:plc:testactor", start, end, 10) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if len(posts) != 1 { + t.Fatalf("expected 1 in-range post before early exit, got %d", len(posts)) + } + if requests != 1 { + t.Errorf("expected scan to stop after the first page, made %d requests", requests) + } +} + +func TestGetPostsInTimeframeIncludesBoundaries(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/xrpc/app.bsky.feed.getAuthorFeed", func(w http.ResponseWriter, r *http.Request) { + mk := func(uri, createdAt string) *bsky.FeedDefs_FeedViewPost { + return &bsky.FeedDefs_FeedViewPost{ + Post: &bsky.FeedDefs_PostView{ + Uri: uri, + Author: &bsky.ActorDefs_ProfileViewBasic{Did: "did:plc:testactor", Handle: "testactor.test"}, + Record: &util.LexiconTypeDecoder{ + Val: &bsky.FeedPost{Text: uri, CreatedAt: createdAt}, + }, + }, + } + } + feed := []*bsky.FeedDefs_FeedViewPost{ + mk("at://did:plc:testactor/app.bsky.feed.post/end", "2023-12-31T23:59:59Z"), + mk("at://did:plc:testactor/app.bsky.feed.post/start", "2023-01-01T00:00:00Z"), + mk("at://did:plc:testactor/app.bsky.feed.post/older", "2022-12-31T23:59:59Z"), + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(bsky.FeedGetAuthorFeed_Output{Feed: feed}) + }) + + server := httptest.NewServer(mux) + defer server.Close() + + client := newTestClient(t, server.URL) + ctx := context.Background() + + start := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC) + end := time.Date(2023, 12, 31, 23, 59, 59, 0, time.UTC) + + posts, err := client.GetPostsInTimeframe(ctx, "did:plc:testactor", start, end, 10) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + // Both boundary posts are inclusive; the post one second before start is not. + if len(posts) != 2 { + t.Fatalf("expected boundary posts to be included (got %d)", len(posts)) + } + got := map[string]bool{} + for _, p := range posts { + got[p.Post.Uri] = true + } + if !got["at://did:plc:testactor/app.bsky.feed.post/start"] { + t.Error("expected post exactly at start to be included") + } + if !got["at://did:plc:testactor/app.bsky.feed.post/end"] { + t.Error("expected post exactly at end to be included") + } +} + +func TestResolveUserCache(t *testing.T) { + var resolveRequests atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("/xrpc/com.atproto.identity.resolveHandle", func(w http.ResponseWriter, r *http.Request) { + resolveRequests.Add(1) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(atproto.IdentityResolveHandle_Output{Did: "did:plc:resolved"}) + }) + + server := httptest.NewServer(mux) + defer server.Close() + + client := newTestClient(t, server.URL) + ctx := context.Background() + + // First resolution hits the network and populates the cache. + did, err := client.ResolveUser(ctx, "test.handle") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if did.String() != "did:plc:resolved" { + t.Errorf("Unexpected DID %q", did.String()) + } + if resolveRequests.Load() != 1 { + t.Errorf("expected 1 resolve request, got %d", resolveRequests.Load()) + } + + // Second resolution is served from cache. + if _, err := client.ResolveUser(ctx, "test.handle"); err != nil { + t.Fatalf("Expected no error on cached resolution, got %v", err) + } + if resolveRequests.Load() != 1 { + t.Errorf("expected cached resolution to skip the network, got %d requests", resolveRequests.Load()) + } + + // An expired cache entry is refetched. + client.cacheMu.Lock() + entry := client.didCache["test.handle"] + entry.expiresAt = time.Now().Add(-time.Minute) + client.didCache["test.handle"] = entry + client.cacheMu.Unlock() + + if _, err := client.ResolveUser(ctx, "test.handle"); err != nil { + t.Fatalf("Expected no error after expiry, got %v", err) + } + if resolveRequests.Load() != 2 { + t.Errorf("expected a fresh resolve after TTL expiry, got %d requests", resolveRequests.Load()) + } +} diff --git a/internal/clustering/helpers.go b/internal/clustering/helpers.go new file mode 100644 index 0000000..02241a6 --- /dev/null +++ b/internal/clustering/helpers.go @@ -0,0 +1,37 @@ +package clustering + +import "math" + +func normalize(v []float64) []float64 { + out := make([]float64, len(v)) + + var norm float64 + for _, x := range v { + norm += x * x + } + + norm = math.Sqrt(norm) + + if norm == 0 { + return out + } + + for i, x := range v { + out[i] = x / norm + } + + return out +} + +func cosineSimilarity(a, b []float64) float64 { + if len(a) != len(b) { + return -1 + } + + var dot float64 + for i := range a { + dot += a[i] * b[i] + } + + return dot +} diff --git a/internal/clustering/k-means.go b/internal/clustering/k-means.go new file mode 100644 index 0000000..d1a126b --- /dev/null +++ b/internal/clustering/k-means.go @@ -0,0 +1,228 @@ +package clustering + +import ( + "errors" + "math" + "math/rand" +) + +func initializeKMeansPlusPlus( + vectors [][]float64, + k int, + rng *rand.Rand, +) [][]float64 { + centroids := make([][]float64, 0, k) + + first := rng.Intn(len(vectors)) + centroids = append( + centroids, + append([]float64(nil), vectors[first]...), + ) + + distances := make([]float64, len(vectors)) + + for len(centroids) < k { + var total float64 + + for i, vector := range vectors { + bestSimilarity := -math.MaxFloat64 + + for _, centroid := range centroids { + similarity := cosineSimilarity(vector, centroid) + if similarity > bestSimilarity { + bestSimilarity = similarity + } + } + + // cosine distance + distance := 1 - bestSimilarity + + if distance < 0 { + distance = 0 + } + + // k-means++ uses squared distance. + distances[i] = distance * distance + total += distances[i] + } + + if total == 0 { + break + } + + target := rng.Float64() * total + + var cumulative float64 + selected := len(vectors) - 1 + + for i, distance := range distances { + cumulative += distance + + if cumulative >= target { + selected = i + break + } + } + + centroids = append( + centroids, + append([]float64(nil), vectors[selected]...), + ) + } + + // This can theoretically happen if all vectors are identical. + for len(centroids) < k { + index := rng.Intn(len(vectors)) + + centroids = append( + centroids, + append([]float64(nil), vectors[index]...), + ) + } + + return centroids +} + +func KMeans(items []Item, k int, maxIterations int, seed int64) ([]Result, error) { + if len(items) == 0 { + return nil, errors.New("no items") + } + + if k <= 0 { + return nil, errors.New("k must be positive") + } + + if k > len(items) { + k = len(items) + } + + if maxIterations <= 0 { + maxIterations = 50 + } + + dimension := len(items[0].Embedding) + if dimension == 0 { + return nil, errors.New("empty embedding") + } + + // Normalize embeddings once. + normalized := make([][]float64, len(items)) + for i, item := range items { + if len(item.Embedding) != dimension { + return nil, errors.New("embedding dimensions do not match") + } + + normalized[i] = normalize(item.Embedding) + } + + rng := rand.New(rand.NewSource(seed)) + + centroids := initializeKMeansPlusPlus(normalized, k, rng) + + assignments := make([]int, len(items)) + + for iteration := 0; iteration < maxIterations; iteration++ { + changed := false + + // Assignment step. + for i, vector := range normalized { + bestCluster := 0 + bestSimilarity := -math.MaxFloat64 + + for cluster, centroid := range centroids { + similarity := cosineSimilarity(vector, centroid) + + if similarity > bestSimilarity { + bestSimilarity = similarity + bestCluster = cluster + } + } + + if iteration == 0 || assignments[i] != bestCluster { + assignments[i] = bestCluster + changed = true + } + } + + // Stop if nothing changed. + if !changed && iteration > 0 { + break + } + + // Update step. + newCentroids := make([][]float64, k) + counts := make([]int, k) + + for cluster := 0; cluster < k; cluster++ { + newCentroids[cluster] = make([]float64, dimension) + } + + for i, vector := range normalized { + cluster := assignments[i] + counts[cluster]++ + + for d, value := range vector { + newCentroids[cluster][d] += value + } + } + + for cluster := 0; cluster < k; cluster++ { + if counts[cluster] == 0 { + // Empty cluster: reinitialize with a random item. + index := rng.Intn(len(normalized)) + newCentroids[cluster] = append( + []float64(nil), + normalized[index]..., + ) + continue + } + + newCentroids[cluster] = normalize(newCentroids[cluster]) + } + + centroids = newCentroids + } + + results := make([]Result, k) + + for cluster := 0; cluster < k; cluster++ { + results[cluster] = Result{ + Cluster: cluster, + Centroid: centroids[cluster], + } + } + + for i, item := range items { + cluster := assignments[i] + + item.Embedding = normalized[i] + + results[cluster].Items = append( + results[cluster].Items, + item, + ) + } + + // Find the item closest to each centroid. + for cluster := range results { + if len(results[cluster].Items) == 0 { + continue + } + + bestSimilarity := -math.MaxFloat64 + + for _, item := range results[cluster].Items { + similarity := cosineSimilarity( + item.Embedding, + results[cluster].Centroid, + ) + + if similarity > bestSimilarity { + bestSimilarity = similarity + results[cluster].Representative = item + } + } + } + + return results, nil +} diff --git a/internal/clustering/kmeans_test.go b/internal/clustering/kmeans_test.go new file mode 100644 index 0000000..ca6bb91 --- /dev/null +++ b/internal/clustering/kmeans_test.go @@ -0,0 +1,213 @@ +package clustering + +import ( + "math" + "math/rand" + "testing" +) + +func TestKMeans_Basic(t *testing.T) { + items := []Item{ + {ID: "1", Text: "a", Embedding: []float64{1.0, 0.0}}, + {ID: "2", Text: "b", Embedding: []float64{0.9, 0.1}}, + {ID: "3", Text: "c", Embedding: []float64{-1.0, 0.0}}, + {ID: "4", Text: "d", Embedding: []float64{-0.9, -0.1}}, + } + + results, err := KMeans(items, 2, 50, 42) + if err != nil { + t.Fatalf("KMeans failed: %v", err) + } + + if len(results) != 2 { + t.Fatalf("expected 2 clusters, got %d", len(results)) + } + + // Check that items are assigned to clusters + totalItems := 0 + for _, r := range results { + totalItems += len(r.Items) + } + if totalItems != len(items) { + t.Fatalf("expected %d items across clusters, got %d", len(items), totalItems) + } + + // Verify centroids are normalized + for _, r := range results { + norm := 0.0 + for _, v := range r.Centroid { + norm += v * v + } + norm = math.Sqrt(norm) + if math.Abs(norm-1.0) > 1e-6 { + t.Errorf("centroid not normalized: norm=%f", norm) + } + } +} + +func TestKMeans_Empty(t *testing.T) { + _, err := KMeans([]Item{}, 2, 50, 42) + if err == nil { + t.Fatal("expected error for empty items") + } +} + +func TestKMeans_KGreaterThanItems(t *testing.T) { + items := []Item{ + {ID: "1", Text: "a", Embedding: []float64{1.0, 0.0}}, + {ID: "2", Text: "b", Embedding: []float64{0.0, 1.0}}, + } + + results, err := KMeans(items, 5, 50, 42) + if err != nil { + t.Fatalf("KMeans failed: %v", err) + } + + // k should be capped to len(items) + if len(results) != 2 { + t.Fatalf("expected 2 clusters (capped), got %d", len(results)) + } +} + +func TestKMeans_DeterministicWithSeed(t *testing.T) { + items := []Item{ + {ID: "1", Text: "a", Embedding: []float64{1.0, 0.0}}, + {ID: "2", Text: "b", Embedding: []float64{0.9, 0.1}}, + {ID: "3", Text: "c", Embedding: []float64{-1.0, 0.0}}, + {ID: "4", Text: "d", Embedding: []float64{-0.9, -0.1}}, + } + + results1, _ := KMeans(items, 2, 50, 42) + results2, _ := KMeans(items, 2, 50, 42) + + // Same seed should produce same assignments + for i := range results1 { + if len(results1[i].Items) != len(results2[i].Items) { + t.Fatalf("cluster %d size differs: %d vs %d", i, len(results1[i].Items), len(results2[i].Items)) + } + } +} + +func TestCosineSimilarity(t *testing.T) { + tests := []struct { + name string + a, b []float64 + expected float64 + }{ + {"identical", []float64{1, 0}, []float64{1, 0}, 1.0}, + {"orthogonal", []float64{1, 0}, []float64{0, 1}, 0.0}, + {"opposite", []float64{1, 0}, []float64{-1, 0}, -1.0}, + {"scaled", []float64{2, 0}, []float64{1, 0}, 2.0}, // not normalized + {"mismatched len", []float64{1, 0}, []float64{1, 0, 0}, -1.0}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := cosineSimilarity(tc.a, tc.b) + if math.Abs(got-tc.expected) > 1e-6 { + t.Errorf("cosineSimilarity(%v, %v) = %f, want %f", tc.a, tc.b, got, tc.expected) + } + }) + } +} + +func TestNormalize(t *testing.T) { + v := []float64{3.0, 4.0} + normalized := normalize(v) + + // Check length is 1 + norm := 0.0 + for _, x := range normalized { + norm += x * x + } + norm = math.Sqrt(norm) + if math.Abs(norm-1.0) > 1e-6 { + t.Errorf("normalized vector has norm %f, want 1.0", norm) + } + + // Check zero vector handling + zero := normalize([]float64{0.0, 0.0}) + if zero[0] != 0.0 || zero[1] != 0.0 { + t.Errorf("zero vector normalization failed: %v", zero) + } +} + +func TestInitializeKMeansPlusPlus(t *testing.T) { + vectors := [][]float64{ + {1.0, 0.0}, + {0.9, 0.1}, + {-1.0, 0.0}, + {-0.9, -0.1}, + } + + rng := rand.New(rand.NewSource(42)) + centroids := initializeKMeansPlusPlus(vectors, 2, rng) + + if len(centroids) != 2 { + t.Fatalf("expected 2 centroids, got %d", len(centroids)) + } + + // Centroids should be from the input vectors (or averages) + found1, found2 := false, false + for _, c := range centroids { + for _, v := range vectors { + if equalVectors(c, v) { + if !found1 { + found1 = true + } else { + found2 = true + } + } + } + } + if !found1 || !found2 { + t.Errorf("centroids not from input vectors: %v", centroids) + } +} + +func equalVectors(a, b []float64) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if math.Abs(a[i]-b[i]) > 1e-6 { + return false + } + } + return true +} + +func TestKMeans_HighDimensional(t *testing.T) { + // Test with 1024-dim vectors (like qwen3-embedding) + dim := 1024 + items := make([]Item, 10) + for i := range items { + emb := make([]float64, dim) + if i < 5 { + emb[0] = 1.0 // cluster 1 + } else { + emb[dim-1] = 1.0 // cluster 2 + } + items[i] = Item{ + ID: string(rune('a' + i)), + Text: "test", + Embedding: emb, + } + } + + results, err := KMeans(items, 2, 50, 42) + if err != nil { + t.Fatalf("KMeans failed: %v", err) + } + + if len(results) != 2 { + t.Fatalf("expected 2 clusters, got %d", len(results)) + } + + // Verify each cluster has ~5 items + for _, r := range results { + if len(r.Items) < 3 || len(r.Items) > 7 { + t.Errorf("cluster size %d unexpected", len(r.Items)) + } + } +} diff --git a/internal/clustering/types.go b/internal/clustering/types.go new file mode 100644 index 0000000..d1608d1 --- /dev/null +++ b/internal/clustering/types.go @@ -0,0 +1,21 @@ +package clustering + +import "time" + +type Item struct { + ID string + Text string + Embedding []float64 + Timestamp time.Time + Likes int64 + Reposts int64 + Replies int64 + Quotes int64 +} + +type Result struct { + Cluster int + Items []Item + Centroid []float64 + Representative Item +} diff --git a/internal/config/config.go b/internal/config/config.go index 22d283b..1c5ab7f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,33 +2,71 @@ package config import ( "os" + "path/filepath" "strconv" + "strings" + "time" "github.com/joho/godotenv" log "github.com/nacorid/logger" ) type Config struct { - ListenAddress string - ListenPort string - UnixSocketPath string - UnixSocketPermissions os.FileMode - DatabaseURL string - ActivityPubHost string - ActivityPubToken string - BlueskyDID string - BlueskyAppPassword string - BlueskyHost string - OllamaHost string - X402FacilitatorURL string - X402WalletAddress string - ApiKey string - ApiSecret string - LogFile string - ActiveNetworks string + ListenAddress string + ListenPort string + UnixSocketPath string + UnixSocketPermissions os.FileMode + DatabaseURL string + ActivityPubHost string + ActivityPubToken string + BlueskyDID string + BlueskyAppPassword string + BlueskyHost string + NostrRelays []string + TwitterBearerToken string + PrewarmAuthors []string + PrewarmFeedURI string + EnrichCrossPlatform bool + OllamaHost string + MigrateOnStart bool + MigrationsDir string + ReadyzAddress string + ReadyzPort string + MetricsPort string + X402FacilitatorURL string + X402WalletAddress string + ApiKey string + ApiSecret string + LogFile string + ActiveNetworks string + MatrixWebhookURL string + MatrixServerID string + EmbeddingModel string + StyleModel string + AnalyticsTTLEngagement time.Duration + AnalyticsTTLPatterns time.Duration + AnalyticsTTLTopics time.Duration + AnalyticsTTLSentiment time.Duration + AnalyticsTTLDrivers time.Duration } func Load() *Config { + // Load config/models.env for model defaults (single source of truth) + // Find project root by looking for go.mod + root := findProjectRoot() + modelEnvPaths := []string{ + filepath.Join(root, "config", "models.env"), + filepath.Join(root, "..", "config", "models.env"), // fallback + } + for _, p := range modelEnvPaths { + if err := godotenv.Load(p); err == nil { + log.Debug("Loaded model config", "path", p) + break + } else { + log.Debug("Model config not found", "path", p, "error", err) + } + } + // Also load .env for local development overrides if err := godotenv.Load(); err != nil { log.Debug("No .env file found, reading from environment") } @@ -38,24 +76,59 @@ func Load() *Config { if err != nil { log.Fatal("Invalid UNIX_SOCKET_PERMISSIONS", "error", err) } - return &Config{ - ListenAddress: getEnv("SERVER_ADDRESS", "127.0.0.1"), - ListenPort: getEnv("SERVER_PORT", "8080"), - UnixSocketPermissions: os.FileMode(perms), - DatabaseURL: getEnv("GOOSE_DBSTRING", "postgres:///mcpserver"), - ActivityPubHost: getEnv("AP_HOST", "https://mastodon.social"), - ActivityPubToken: getEnv("AP_ACCESS_TOKEN", ""), - BlueskyDID: getEnv("BLUESKY_DID", ""), - BlueskyAppPassword: getEnv("BLUESKY_APP_PASSWORD", ""), - BlueskyHost: getEnv("BLUESKY_HOST", "https://bsky.social"), - OllamaHost: getEnv("OLLAMA_HOST", "http://localhost:11434"), - X402FacilitatorURL: getEnv("X402_FACILITATOR_URL", "https://facilitator.x402.rs"), - X402WalletAddress: getEnv("X402_WALLET_ADDRESS", ""), - ApiKey: getEnv("CDP_API_KEY_ID", ""), - ApiSecret: getEnv("CDP_API_SECRET", ""), - LogFile: getEnv("LOG_FILE", "/var/log/mcpserver/output.log"), - ActiveNetworks: getEnv("ACTIVE_NETWORKS", "base"), + cfg := &Config{ + ListenAddress: getEnv("SERVER_ADDRESS", "127.0.0.1"), + ListenPort: getEnv("SERVER_PORT", "8080"), + UnixSocketPermissions: os.FileMode(perms), + DatabaseURL: getEnv("GOOSE_DBSTRING", "postgres:///mcpserver"), + ActivityPubHost: getEnv("AP_HOST", "https://mastodon.social"), + ActivityPubToken: getEnv("AP_ACCESS_TOKEN", ""), + BlueskyDID: getEnv("BLUESKY_DID", ""), + BlueskyAppPassword: getEnv("BLUESKY_APP_PASSWORD", ""), + BlueskyHost: getEnv("BLUESKY_HOST", "https://bsky.social"), + NostrRelays: splitComma(getEnv("NOSTR_RELAYS", "wss://relay.damus.io,wss://nos.lol")), + TwitterBearerToken: getEnvOptional("TWITTER_BEARER_TOKEN"), + PrewarmAuthors: splitComma(getEnvOptional("PREWARM_AUTHORS")), + PrewarmFeedURI: getEnvOptional("PREWARM_FEED_URI"), + EnrichCrossPlatform: getEnvBool("ENRICH_CROSS_PLATFORM", "true"), + OllamaHost: getEnv("OLLAMA_HOST", "http://localhost:11434"), + MigrateOnStart: getEnvBool("MIGRATE_ON_START", "true"), + MigrationsDir: getEnv("MIGRATIONS_DIR", "sql/schema"), + ReadyzAddress: getEnv("READYZ_ADDRESS", "127.0.0.1"), + ReadyzPort: getEnv("READYZ_PORT", "9090"), + MetricsPort: getEnv("METRICS_PORT", "9091"), + X402FacilitatorURL: getEnv("X402_FACILITATOR_URL", "https://api.cdp.coinbase.com/platform/v2/x402"), + X402WalletAddress: getEnv("X402_WALLET_ADDRESS", ""), + ApiKey: getEnv("CDP_API_KEY_ID", ""), + ApiSecret: getEnv("CDP_API_SECRET", ""), + LogFile: getEnv("LOG_FILE", "/var/log/mcpserver/output.log"), + ActiveNetworks: getEnv("ACTIVE_NETWORKS", "base"), + MatrixWebhookURL: os.Getenv("MATRIX_WEBHOOK_URL"), + MatrixServerID: os.Getenv("MATRIX_SERVER_ID"), + EmbeddingModel: getEnv("EMBEDDING_MODEL", ""), + StyleModel: getEnv("STYLE_MODEL", ""), + AnalyticsTTLEngagement: getEnvDuration("ANALYTICS_TTL_ENGAGEMENT", "1h"), + AnalyticsTTLPatterns: getEnvDuration("ANALYTICS_TTL_PATTERNS", "6h"), + AnalyticsTTLTopics: getEnvDuration("ANALYTICS_TTL_TOPICS", "24h"), + AnalyticsTTLSentiment: getEnvDuration("ANALYTICS_TTL_SENTIMENT", "6h"), + AnalyticsTTLDrivers: getEnvDuration("ANALYTICS_TTL_DRIVERS", "1h"), } + + for _, port := range []string{cfg.ReadyzPort, cfg.MetricsPort, cfg.ListenPort} { + if _, err := strconv.Atoi(port); err != nil { + log.Fatalf("Invalid port %q: must be a numeric port value", port) + } + } + + // Default MatrixServerID to system hostname if not explicitly set + if cfg.MatrixServerID == "" { + hostname, err := os.Hostname() + if err == nil && hostname != "" { + cfg.MatrixServerID = hostname + } + } + + return cfg } func getEnv(key, fallback string) string { @@ -67,3 +140,59 @@ func getEnv(key, fallback string) string { } return fallback } + +func getEnvOptional(key string) string { + if value, ok := os.LookupEnv(key); ok { + return value + } + log.Warn("Environment variable is not set (optional)", "key", key) + return "" +} + +func getEnvBool(key, fallback string) bool { + v := getEnv(key, fallback) + switch strings.ToLower(strings.TrimSpace(v)) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + +func getEnvDuration(key, fallback string) time.Duration { + v := getEnv(key, fallback) + d, err := time.ParseDuration(v) + if err != nil { + d, _ = time.ParseDuration(fallback) + } + return d +} + +func splitComma(s string) []string { + parts := make([]string, 0) + for p := range strings.SplitSeq(s, ",") { + if trimmed := strings.TrimSpace(p); trimmed != "" { + parts = append(parts, trimmed) + } + } + return parts +} + +func findProjectRoot() string { + // Start from current working directory and walk up looking for go.mod + dir, err := os.Getwd() + if err != nil { + return "." + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + break // reached filesystem root + } + dir = parent + } + return "." +} diff --git a/internal/nostr/client.go b/internal/nostr/client.go new file mode 100644 index 0000000..952045d --- /dev/null +++ b/internal/nostr/client.go @@ -0,0 +1,372 @@ +package nostr + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "strings" + "time" + + "fiatjaf.com/nostr" + "fiatjaf.com/nostr/nip19" + + "github.com/nacorid/naco-api/internal/actor" +) + +const ( + // relayQueryTimeout bounds connect + subscribe + collect against a single + // relay. The websocket dial, subscription handshake and event collection all + // share this budget. + relayQueryTimeout = 5 * time.Second + + // maxTotalRelayTime caps the fan-out across all relays when the caller's + // context carries no deadline of its own. Without it a long relay list could + // multiply the per-relay budget unboundedly. + maxTotalRelayTime = 20 * time.Second +) + +type Client struct { + relays []string +} + +func NewNostrClient(relays []string) (*Client, error) { + seen := make(map[string]struct{}, len(relays)) + cleaned := make([]string, 0, len(relays)) + for _, r := range relays { + r = strings.TrimSpace(r) + if r == "" { + continue + } + u, err := url.Parse(r) + if err != nil || (u.Scheme != "ws" && u.Scheme != "wss") { + return nil, fmt.Errorf("invalid relay URL %q: must be a ws:// or wss:// endpoint", r) + } + if _, dup := seen[r]; dup { + continue + } + seen[r] = struct{}{} + cleaned = append(cleaned, r) + } + if len(cleaned) == 0 { + return nil, fmt.Errorf("at least one relay is required") + } + return &Client{relays: cleaned}, nil +} + +type Post struct { + ID string + Author string + Content string + CreatedAt time.Time +} + +// queryBudget returns a context bounded to maxTotalRelayTime when ctx has no +// deadline, so the relay fan-out cannot run away across many relays. Callers +// must invoke the returned cancel. +func (c *Client) queryBudget(ctx context.Context) (context.Context, context.CancelFunc) { + if _, ok := ctx.Deadline(); ok { + return context.WithCancel(ctx) + } + return context.WithTimeout(ctx, maxTotalRelayTime) +} + +// collectFromRelay connects to url within ctx, subscribes with filter and +// appends matching events to posts (deduplicated by event id) until the limit is +// reached, EOSE arrives, or ctx is done. It reports whether the relay was +// actually queryable (connected + subscribed), letting callers distinguish a +// total relay outage from a legitimate empty result. +func (c *Client) collectFromRelay(ctx context.Context, url string, filter nostr.Filter, limit int, posts *[]*Post, seen map[string]struct{}) (used bool) { + relay, err := nostr.RelayConnect(ctx, url, nostr.RelayOptions{}) + if err != nil { + return false + } + defer relay.Close() + + sub, err := relay.Subscribe(ctx, filter, nostr.SubscriptionOptions{}) + if err != nil { + return false + } + defer sub.Unsub() + + for { + select { + case <-sub.EndOfStoredEvents: + return true + case ev, ok := <-sub.Events: + if !ok { + return true + } + id := ev.ID.Hex() + if _, dup := seen[id]; dup { + continue + } + seen[id] = struct{}{} + *posts = append(*posts, &Post{ + ID: id, + Author: ev.PubKey.Hex(), + Content: ev.Content, + CreatedAt: ev.CreatedAt.Time(), + }) + if len(*posts) >= limit { + return true + } + case <-ctx.Done(): + return true + } + } +} + +// collectPosts subscribes to filter across every relay, stopping once limit posts +// are collected or the query context is done. It returns the deduplicated posts, +// or an error if no relay could be queried at all or the operation was aborted. +func (c *Client) collectPosts(ctx context.Context, filter nostr.Filter, limit int) ([]*Post, error) { + posts := make([]*Post, 0, limit) + seen := make(map[string]struct{}) + + queryCtx, cancel := c.queryBudget(ctx) + defer cancel() + + var anyUsed bool + for _, url := range c.relays { + if queryCtx.Err() != nil { + break + } + relayCtx, relayCancel := context.WithTimeout(queryCtx, relayQueryTimeout) + used := c.collectFromRelay(relayCtx, url, filter, limit, &posts, seen) + relayCancel() + anyUsed = anyUsed || used + if len(posts) >= limit { + break + } + } + + if err := queryCtx.Err(); err != nil { + return nil, fmt.Errorf("nostr query aborted: %w", err) + } + if !anyUsed { + return nil, fmt.Errorf("could not connect to any relay") + } + return posts, nil +} + +func (c *Client) GetPost(ctx context.Context, id string) (*Post, error) { + eid, err := resolveEventID(id) + if err != nil { + return nil, err + } + + filter := nostr.Filter{ + IDs: []nostr.ID{eid}, + Kinds: []nostr.Kind{1}, + } + posts, err := c.collectPosts(ctx, filter, 1) + if err != nil { + return nil, err + } + if len(posts) == 0 { + return nil, fmt.Errorf("post %q not found across configured relays", id) + } + return posts[0], nil +} + +func (c *Client) GetLatestNPosts(ctx context.Context, author string, limit int) ([]*Post, error) { + if limit <= 0 { + return nil, nil + } + pubkey, err := resolvePubkey(author) + if err != nil { + return nil, err + } + + filter := nostr.Filter{ + Authors: []nostr.PubKey{pubkey}, + Kinds: []nostr.Kind{1}, + Limit: limit, + } + return c.collectPosts(ctx, filter, limit) +} + +func (c *Client) GetPostsInTimeframe(ctx context.Context, author string, start, end time.Time, limit int) ([]*Post, error) { + if limit <= 0 { + return nil, nil + } + pubkey, err := resolvePubkey(author) + if err != nil { + return nil, err + } + + filter := nostr.Filter{ + Authors: []nostr.PubKey{pubkey}, + Kinds: []nostr.Kind{1}, + Since: nostr.Timestamp(start.Unix()), + Until: nostr.Timestamp(end.Unix()), + Limit: limit, + } + return c.collectPosts(ctx, filter, limit) +} + +func (c *Client) GetActor(ctx context.Context, author string) (*actor.Profile, error) { + pubkey, err := resolvePubkey(author) + if err != nil { + return nil, err + } + npub := nip19.EncodeNpub(pubkey) + + filter := nostr.Filter{ + Authors: []nostr.PubKey{pubkey}, + Kinds: []nostr.Kind{0}, + Limit: 1, + } + + queryCtx, cancel := c.queryBudget(ctx) + defer cancel() + + var anyUsed bool + for _, url := range c.relays { + if queryCtx.Err() != nil { + break + } + relayCtx, relayCancel := context.WithTimeout(queryCtx, relayQueryTimeout) + profile, used := c.actorFromRelay(relayCtx, url, filter, npub) + relayCancel() + anyUsed = anyUsed || used + if profile != nil { + return profile, nil + } + } + + if err := queryCtx.Err(); err != nil { + return nil, fmt.Errorf("nostr query aborted: %w", err) + } + if !anyUsed { + return nil, fmt.Errorf("could not connect to any relay") + } + return nil, fmt.Errorf("no profile metadata found for %q", author) +} + +// actorFromRelay looks up one kind-0 metadata event on url. It returns a +// non-nil profile when a usable metadata event was found. used reports whether +// the relay was queried at all (so a fully unreachable relay list is +// distinguishable from an author without metadata). +func (c *Client) actorFromRelay(ctx context.Context, url string, filter nostr.Filter, npub string) (profile *actor.Profile, used bool) { + relay, err := nostr.RelayConnect(ctx, url, nostr.RelayOptions{}) + if err != nil { + return nil, false + } + defer relay.Close() + + sub, err := relay.Subscribe(ctx, filter, nostr.SubscriptionOptions{}) + if err != nil { + return nil, false + } + defer sub.Unsub() + + select { + case <-sub.EndOfStoredEvents: + return nil, true + case ev, ok := <-sub.Events: + if !ok { + return nil, true + } + return profileFromEvent(ev, npub), true + case <-ctx.Done(): + return nil, true + } +} + +// profileFromEvent builds an actor.Profile from a kind-0 metadata event, or nil +// when the content is not valid JSON or carries no usable fields. +func profileFromEvent(ev nostr.Event, npub string) *actor.Profile { + var meta struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + About string `json:"about"` + Picture string `json:"picture"` + Website string `json:"website"` + } + if err := json.Unmarshal([]byte(ev.Content), &meta); err != nil { + return nil + } + if meta.Name == "" && meta.DisplayName == "" && meta.About == "" && meta.Picture == "" { + return nil + } + displayName := meta.DisplayName + if displayName == "" { + displayName = meta.Name + } + return &actor.Profile{ + Platform: "Nostr", + Handle: npub, + DisplayName: displayName, + AvatarURL: meta.Picture, + Bio: meta.About, + URL: "https://njump.me/" + npub, + } +} + +func (c *Client) SearchActors(ctx context.Context, query string, limit int) ([]*actor.Profile, error) { + if _, err := resolvePubkey(query); err != nil { + // Not a pubkey we can resolve (e.g. a name query). Treat it as no match + // rather than an error so a cross-platform search isn't failed by a + // non-nostr query. + return nil, nil + } + prof, err := c.GetActor(ctx, query) + if err != nil { + return nil, err + } + return []*actor.Profile{prof}, nil +} + +func resolvePubkey(actor string) (nostr.PubKey, error) { + prefix, value, err := nip19.Decode(actor) + if err == nil { + switch prefix { + case "npub": + if pk, ok := value.(nostr.PubKey); ok { + return pk, nil + } + case "nprofile": + if pp, ok := value.(nostr.ProfilePointer); ok { + return pp.PublicKey, nil + } + } + return nostr.PubKey{}, fmt.Errorf("unsupported nostr entity %q", prefix) + } + + if len(actor) == 64 { + pk, e := nostr.PubKeyFromHex(actor) + if e != nil { + return nostr.PubKey{}, fmt.Errorf("invalid nostr author %q: %w", actor, e) + } + return pk, nil + } + return nostr.PubKey{}, fmt.Errorf("invalid nostr author %q: %w", actor, err) +} + +func resolveEventID(id string) (nostr.ID, error) { + prefix, value, err := nip19.Decode(id) + if err == nil { + switch prefix { + case "note": + if eid, ok := value.(nostr.ID); ok { + return eid, nil + } + case "nevent": + if ep, ok := value.(nostr.EventPointer); ok { + return ep.ID, nil + } + } + return nostr.ID{}, fmt.Errorf("unsupported nostr entity %q", prefix) + } + + if len(id) == 64 { + eid, e := nostr.IDFromHex(id) + if e != nil { + return nostr.ID{}, fmt.Errorf("invalid nostr event id %q: %w", id, e) + } + return eid, nil + } + return nostr.ID{}, fmt.Errorf("invalid nostr event id %q: %w", id, err) +} diff --git a/internal/nostr/client_test.go b/internal/nostr/client_test.go new file mode 100644 index 0000000..e65bbb1 --- /dev/null +++ b/internal/nostr/client_test.go @@ -0,0 +1,305 @@ +package nostr + +import ( + "context" + "strings" + "testing" + "time" + + "fiatjaf.com/nostr" + "fiatjaf.com/nostr/nip19" +) + +func TestNewNostrClientEmptyRelays(t *testing.T) { + _, err := NewNostrClient([]string{}) + if err == nil { + t.Fatal("expected error for empty relays") + } + if !strings.Contains(err.Error(), "at least one relay") { + t.Fatalf("expected 'at least one relay' error, got %v", err) + } +} + +func TestResolvePubkeyVariants(t *testing.T) { + // Create a valid pubkey for testing using Generate() + sk := nostr.Generate() + pk := sk.Public() + npub := nip19.EncodeNpub(pk) + hex := pk.Hex() + + tests := []struct { + name string + input string + wantErr bool + }{ + {"npub encoding", npub, false}, + {"64-char hex", hex, false}, + {"invalid npub prefix", "nsec" + npub[4:], true}, + {"too short hex", hex[:32], true}, + {"random string", "not-a-key", true}, + {"empty string", "", true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := resolvePubkey(tc.input) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error for %q, got nil", tc.input) + } + } else { + if err != nil { + t.Fatalf("unexpected error for %q: %v", tc.input, err) + } + } + }) + } +} + +func TestResolveEventIDVariants(t *testing.T) { + var eid nostr.ID + for i := range eid { + eid[i] = byte(i + 1) + } + hex := eid.Hex() + + tests := []struct { + name string + input string + wantErr bool + }{ + {"64-char hex", hex, false}, + {"too short hex", hex[:32], true}, + {"random string", "not-an-id", true}, + {"empty string", "", true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := resolveEventID(tc.input) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error for %q, got nil", tc.input) + } + } else { + if err != nil { + t.Fatalf("unexpected error for %q: %v", tc.input, err) + } + } + }) + } +} + +func TestGetPostInvalidID(t *testing.T) { + c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"}) + if err != nil { + t.Fatal(err) + } + + _, err = c.GetPost(context.Background(), "invalid-id") + if err == nil { + t.Fatal("expected error for invalid event ID") + } + if !strings.Contains(err.Error(), "invalid nostr event id") { + t.Fatalf("expected 'invalid nostr event id' error, got %v", err) + } +} + +func TestGetActorInvalidAuthor(t *testing.T) { + c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"}) + if err != nil { + t.Fatal(err) + } + + _, err = c.GetActor(context.Background(), "invalid-author") + if err == nil { + t.Fatal("expected error for invalid author") + } + if !strings.Contains(err.Error(), "invalid nostr author") { + t.Fatalf("expected 'invalid nostr author' error, got %v", err) + } +} + +func TestGetLatestNPostsInvalidAuthor(t *testing.T) { + c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"}) + if err != nil { + t.Fatal(err) + } + + _, err = c.GetLatestNPosts(context.Background(), "invalid-author", 10) + if err == nil { + t.Fatal("expected error for invalid author") + } + if !strings.Contains(err.Error(), "invalid nostr author") { + t.Fatalf("expected 'invalid nostr author' error, got %v", err) + } +} + +func TestGetPostsInTimeframeInvalidAuthor(t *testing.T) { + c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"}) + if err != nil { + t.Fatal(err) + } + + start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + end := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC) + _, err = c.GetPostsInTimeframe(context.Background(), "invalid-author", start, end, 10) + if err == nil { + t.Fatal("expected error for invalid author") + } + if !strings.Contains(err.Error(), "invalid nostr author") { + t.Fatalf("expected 'invalid nostr author' error, got %v", err) + } +} + +func TestRelayConnectionFailureReturnsError(t *testing.T) { + // When no relay can be reached, GetLatestNPosts/GetPostsInTimeframe must + // report the connection failure rather than silently returning an empty feed. + c, err := NewNostrClient([]string{ + "wss://invalid1.relay.invalid", + "wss://invalid2.relay.invalid", + "wss://invalid3.relay.invalid", + }) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + sk := nostr.Generate() + npub := nip19.EncodeNpub(sk.Public()) + + _, err = c.GetLatestNPosts(ctx, npub, 10) + if err == nil { + t.Fatal("expected error when no relay is reachable") + } + if !strings.Contains(err.Error(), "could not connect to any relay") { + t.Fatalf("expected 'could not connect to any relay' error, got %v", err) + } + + start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + end := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC) + _, err = c.GetPostsInTimeframe(ctx, npub, start, end, 10) + if err == nil { + t.Fatal("expected error when no relay is reachable") + } + if !strings.Contains(err.Error(), "could not connect to any relay") { + t.Fatalf("expected 'could not connect to any relay' error, got %v", err) + } +} + +func TestNewNostrClientInvalidScheme(t *testing.T) { + _, err := NewNostrClient([]string{"http://relay.example"}) + if err == nil { + t.Fatal("expected error for non-websocket relay URL") + } + if !strings.Contains(err.Error(), "invalid relay URL") { + t.Fatalf("expected 'invalid relay URL' error, got %v", err) + } + + // Blank and duplicate entries are dropped, the scheme is validated. + c, err := NewNostrClient([]string{ + "wss://relay.a", + " wss://relay.a ", + "ws://relay.b", + " ", + }) + if err != nil { + t.Fatal(err) + } + if len(c.relays) != 2 { + t.Fatalf("expected 2 unique relays after cleaning, got %d: %v", len(c.relays), c.relays) + } +} + +func TestGetLatestNPostsZeroOrNegativeLimit(t *testing.T) { + c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"}) + if err != nil { + t.Fatal(err) + } + + sk := nostr.Generate() + npub := nip19.EncodeNpub(sk.Public()) + + if posts, err := c.GetLatestNPosts(context.Background(), npub, 0); err != nil || posts != nil { + t.Fatalf("expected nil, nil for zero limit, got %v, %v", posts, err) + } + if posts, err := c.GetLatestNPosts(context.Background(), npub, -5); err != nil || posts != nil { + t.Fatalf("expected nil, nil for negative limit, got %v, %v", posts, err) + } + + start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + end := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC) + if posts, err := c.GetPostsInTimeframe(context.Background(), npub, start, end, 0); err != nil || posts != nil { + t.Fatalf("expected nil, nil for zero timeframe limit, got %v, %v", posts, err) + } +} + +func TestSearchActors(t *testing.T) { + c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"}) + if err != nil { + t.Fatal(err) + } + + ctx := context.Background() + + // A non-key query (e.g. a display name) is a valid no-match, not an error. + results, err := c.SearchActors(ctx, "alice", 1) + if err != nil { + t.Fatalf("expected no error for non-key query, got %v", err) + } + if results != nil { + t.Fatalf("expected nil results for non-key query, got %v", results) + } + + // A valid pubkey that fails on every relay must surface the outage. + sk := nostr.Generate() + npub := nip19.EncodeNpub(sk.Public()) + if _, err := c.SearchActors(ctx, npub, 1); err == nil { + t.Fatal("expected error when no relay is reachable") + } else if !strings.Contains(err.Error(), "could not connect to any relay") { + t.Fatalf("expected 'could not connect to any relay' error, got %v", err) + } +} + +func TestGetPostWithHexID(t *testing.T) { + c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"}) + if err != nil { + t.Fatal(err) + } + + var eid nostr.ID + for i := range eid { + eid[i] = byte(i + 1) + } + hex := eid.Hex() + + _, err = c.GetPost(context.Background(), hex) + if err == nil { + t.Fatal("expected error when relay unavailable") + } + if !strings.Contains(err.Error(), "could not connect to any relay") { + t.Fatalf("expected 'could not connect to any relay' error, got %v", err) + } +} + +func TestGetActorWithHexPubkey(t *testing.T) { + c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"}) + if err != nil { + t.Fatal(err) + } + + sk := nostr.Generate() + hex := sk.Public().Hex() + + _, err = c.GetActor(context.Background(), hex) + if err == nil { + t.Fatal("expected error when relay unavailable") + } + // GetActor reports a total relay outage as a connection failure rather than + // pretending the author has no metadata. + if !strings.Contains(err.Error(), "could not connect to any relay") { + t.Fatalf("expected 'could not connect to any relay' error, got %v", err) + } +} diff --git a/internal/ollama/client.go b/internal/ollama/client.go index 15db457..60515ae 100644 --- a/internal/ollama/client.go +++ b/internal/ollama/client.go @@ -2,51 +2,232 @@ package ollama import ( "context" + "errors" + "fmt" "net/http" "net/url" "strings" + "sync" + "time" + "github.com/nacorid/naco-api/internal/clustering" "github.com/ollama/ollama/api" ) +const ( + // embedConcurrency caps how many embedding requests run in parallel. Ollama + // serializes model requests server-side (OLLAMA_NUM_PARALLEL defaults to 1), + // so unbounded fan-out would only add latency and memory for no throughput. + embedConcurrency = 4 + + // embedRetries and embedRetryBackoff cover transient server-side embedding + // failures (e.g. cold model load, transient 5xx/429). + embedRetries = 2 + embedRetryBackoff = 150 * time.Millisecond + + // generateRetries and generateRetryBackoff cover transient failures that + // produce no output at all; a truncated stream is never retried because the + // partial response cannot be safely replayed. + generateRetries = 1 + generateRetryBackoff = 500 * time.Millisecond +) + type OllamaClient struct { - client api.Client + client api.Client + embeddingModel string + styleModel string } -func New(ctx context.Context, host *url.URL, http *http.Client) *OllamaClient { - client := api.NewClient(host, http) - return &OllamaClient{client: *client} +func New(host *url.URL, httpClient *http.Client, embeddingModel, styleModel string) *OllamaClient { + client := api.NewClient(host, httpClient) + return &OllamaClient{client: *client, embeddingModel: embeddingModel, styleModel: styleModel} } -func (o *OllamaClient) EmbedTexts(ctx context.Context, posts []string) ([][]float64, error) { - vectors := make([][]float64, 0, len(posts)) - for _, t := range posts { +// EmbedTexts computes one embedding per post. Embeddings are returned in the +// same order as posts; callers rely on index correspondence (e.g. +// items[i].Embedding = embeddings[i]) so posts are never skipped. Requests run +// in a bounded worker pool and transient failures are retried with backoff. +func (o *OllamaClient) EmbedTexts(ctx context.Context, posts []clustering.Item) ([][]float64, error) { + if len(posts) == 0 { + return nil, nil + } + + embeddings := make([][]float64, len(posts)) + + // poolCtx is a child of ctx: cancelling it on the first failure must not + // cancel the caller's request context. + poolCtx, cancel := context.WithCancel(ctx) + defer cancel() + + var ( + mu sync.Mutex + firstErr error + sema = make(chan struct{}, embedConcurrency) + wg sync.WaitGroup + ) + for i, post := range posts { + wg.Add(1) + go func(idx int, text string) { + defer wg.Done() + select { + case sema <- struct{}{}: + case <-poolCtx.Done(): + return + } + defer func() { <-sema }() + + emb, err := o.embedWithRetry(poolCtx, strings.TrimSpace(text)) + if err != nil { + mu.Lock() + if firstErr == nil { + firstErr = fmt.Errorf("embedding post %d (%q): %w", idx, text, err) + } + mu.Unlock() + cancel() + return + } + embeddings[idx] = emb + }(i, post.Text) + } + wg.Wait() + + if firstErr := atomicErr(&mu, &firstErr); firstErr != nil { + return embeddings, firstErr + } + // Every worker may have exited via the poolCtx.Done() branch without + // recording an error, e.g. when the caller's context is already canceled. + if err := ctx.Err(); err != nil { + return nil, err + } + return embeddings, nil +} + +func atomicErr(mu *sync.Mutex, err *error) error { + mu.Lock() + defer mu.Unlock() + return *err +} + +func (o *OllamaClient) embedWithRetry(ctx context.Context, text string) ([]float64, error) { + backoff := embedRetryBackoff + var lastErr error + for attempt := 0; attempt <= embedRetries; attempt++ { + if attempt > 0 { + if err := sleepCtx(ctx, backoff); err != nil { + return nil, err + } + backoff *= 2 + } resp, err := o.client.Embeddings(ctx, &api.EmbeddingRequest{ - Model: "nomic-embed-text", - Prompt: t, + Model: o.embeddingModel, + Prompt: text, }) - if err != nil { + if err == nil { + return resp.Embedding, nil + } + lastErr = err + if !isTransientErr(err) { return nil, err } - vectors = append(vectors, resp.Embedding) } - return vectors, nil + return nil, lastErr } -func (o *OllamaClient) SummarizeStyle(ctx context.Context, posts []string) (string, error) { - prompt := "Summarize writing style, recurring themes, tone, personality traits of this user:\n\n" + - strings.Join(posts, "\n") + "\n\nStyle summary:" +func (o *OllamaClient) SummarizeStyle(ctx context.Context, posts []clustering.Item) (string, error) { + if len(posts) == 0 { + return "", nil + } + + promptParts := make([]string, 0, len(posts)) + for _, post := range posts { + if text := strings.TrimSpace(post.Text); text != "" { + promptParts = append(promptParts, text) + } + } + if len(promptParts) == 0 { + return "", fmt.Errorf("no post text to summarize") + } + + prompt := "Summarize the user's writing style. Analyze:\n" + + "- tone and emotional register\n" + + "- recurring themes and interests\n" + + "- vocabulary and sentence structure\n" + + "- communication habits\n" + + "- personality traits expressed through writing\n\n" + + "Base your analysis only on the supplied posts. Do not infer sensitive personal attributes.\n\n" + + "Posts:\n\n" + + strings.Join(promptParts, "\n---\n") + + "\n\nStyle summary:" + var fullResponse strings.Builder - err := o.client.Generate(ctx, &api.GenerateRequest{ - Model: "mistral", - Prompt: prompt, - }, func(resp api.GenerateResponse) error { - fullResponse.WriteString(resp.Response) - return nil - }) - if err != nil { - return "", err - } + backoff := generateRetryBackoff + var lastErr error + for attempt := 0; attempt <= generateRetries; attempt++ { + if attempt > 0 { + if err := sleepCtx(ctx, backoff); err != nil { + return "", err + } + } - return fullResponse.String(), nil + // Reset between attempts so a failed run never leaks a partial summary. + fullResponse.Reset() + done := false + doneReason := "" + err := o.client.Generate(ctx, &api.GenerateRequest{ + Model: o.styleModel, + Prompt: prompt, + }, func(resp api.GenerateResponse) error { + fullResponse.WriteString(resp.Response) + if resp.Done { + done = true + doneReason = resp.DoneReason + } + return nil + }) + if err != nil { + lastErr = err + // Only retry transport/5xx failures that produced no output; a + // partial stream must not be replayed. + if !isTransientErr(err) || fullResponse.Len() > 0 { + return "", fmt.Errorf("ollama generate with model %q failed: %w", o.styleModel, err) + } + continue + } + + // The underlying stream reader can finish without an error when the + // request context is canceled or the connection drops mid-stream (the + // library never inspects bufio.Scanner.Err), so a bare nil from + // Generate does not mean the response is complete. + if ctxErr := ctx.Err(); ctxErr != nil { + return "", ctxErr + } + if !done { + return "", fmt.Errorf("ollama stream for model %q ended before completion", o.styleModel) + } + if doneReason == "length" { + return "", fmt.Errorf("ollama response for model %q was truncated (context limit reached)", o.styleModel) + } + return fullResponse.String(), nil + } + return "", fmt.Errorf("ollama generate with model %q failed after retries: %w", o.styleModel, lastErr) +} + +func sleepCtx(ctx context.Context, d time.Duration) error { + select { + case <-time.After(d): + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// isTransientErr reports whether err is a retryable server-side failure. Non-2xx +// HTTP errors that carry a StatusCode (429, 5xx) qualify; context and transport +// errors below that never do, so they surface immediately. +func isTransientErr(err error) bool { + var se api.StatusError + if errors.As(err, &se) { + return se.StatusCode == http.StatusTooManyRequests || se.StatusCode >= http.StatusInternalServerError + } + return false } diff --git a/internal/ollama/client_test.go b/internal/ollama/client_test.go new file mode 100644 index 0000000..9ade0e3 --- /dev/null +++ b/internal/ollama/client_test.go @@ -0,0 +1,516 @@ +package ollama + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/nacorid/naco-api/internal/clustering" +) + +// setupTestServer creates a test server and an initialized client. +func setupTestServer(t *testing.T, handler http.Handler) (*httptest.Server, *OllamaClient) { + t.Helper() + server := httptest.NewServer(handler) + + host, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("failed to parse test server URL: %v", err) + } + + client := New(host, http.DefaultClient, "test-embed", "test-style") + return server, client +} + +// ============================================================================ +// EmbedTexts Tests +// ============================================================================ + +func TestEmbedTexts(t *testing.T) { + type embedRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + } + + var mu sync.Mutex + var receivedRequests []embedRequest + + mux := http.NewServeMux() + mux.HandleFunc("/api/embeddings", func(w http.ResponseWriter, r *http.Request) { + // 1. Validate HTTP Method + if r.Method != http.MethodPost { + t.Errorf("expected method POST, got %s", r.Method) + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // 2. Validate Headers + if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { + t.Errorf("expected Content-Type application/json, got %q", r.Header.Get("Content-Type")) + http.Error(w, "invalid content-type", http.StatusBadRequest) + return + } + + // 3. Validate Request Payload + var req embedRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("failed to decode request body: %v", err) + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + if req.Model != "test-embed" { + t.Errorf("expected model 'test-embed', got %q", req.Model) + } + + mu.Lock() + receivedRequests = append(receivedRequests, req) + mu.Unlock() + + // 4. Return valid response + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "embedding": []float64{0.1, 0.2, 0.3}, + }) + }) + + server, client := setupTestServer(t, mux) + defer server.Close() + + posts := []clustering.Item{ + {ID: "1", Text: "hello", Embedding: nil}, + {ID: "2", Text: "world", Embedding: nil}, + } + + embeddings, err := client.EmbedTexts(context.Background(), posts) + if err != nil { + t.Fatalf("EmbedTexts failed: %v", err) + } + + // Verify outgoing requests: ensure both prompts were sent + mu.Lock() + if len(receivedRequests) != 2 { + t.Errorf("expected 2 HTTP requests, got %d", len(receivedRequests)) + } + promptsReceived := map[string]bool{} + for _, req := range receivedRequests { + promptsReceived[req.Prompt] = true + } + mu.Unlock() + + if !promptsReceived["hello"] || !promptsReceived["world"] { + t.Errorf("expected prompts 'hello' and 'world' to be sent, got: %+v", receivedRequests) + } + + // Verify parsed results + if len(embeddings) != 2 { + t.Fatalf("expected 2 embeddings, got %d", len(embeddings)) + } + for i, emb := range embeddings { + if len(emb) != 3 { + t.Errorf("embedding %d has wrong dimension: %d", i, len(emb)) + } + if emb[0] != 0.1 || emb[1] != 0.2 || emb[2] != 0.3 { + t.Errorf("embedding %d has wrong values: %v", i, emb) + } + } +} + +func TestEmbedTexts_EmptyInput(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/embeddings", func(w http.ResponseWriter, r *http.Request) { + t.Error("embeddings endpoint should not be called when input is empty") + http.Error(w, "should not be called", http.StatusInternalServerError) + }) + + server, client := setupTestServer(t, mux) + defer server.Close() + + embeddings, err := client.EmbedTexts(context.Background(), []clustering.Item{}) + if err != nil { + t.Fatalf("EmbedTexts with empty input failed: %v", err) + } + + if len(embeddings) != 0 { + t.Errorf("expected 0 embeddings, got %d", len(embeddings)) + } +} + +func TestEmbedTexts_Error(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/embeddings", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error": "model test-embed not found"}`)) + }) + + server, client := setupTestServer(t, mux) + defer server.Close() + + posts := []clustering.Item{{ID: "1", Text: "hello"}} + + _, err := client.EmbedTexts(context.Background(), posts) + if err == nil { + t.Fatal("expected error from failing server, got nil") + } +} + +func TestEmbedTexts_ContextCanceled(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/embeddings", func(w http.ResponseWriter, r *http.Request) { + // Hang until client cancels the request + <-r.Context().Done() + }) + + server, client := setupTestServer(t, mux) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + posts := []clustering.Item{{ID: "1", Text: "hello"}} + _, err := client.EmbedTexts(ctx, posts) + if err == nil { + t.Fatal("expected error due to canceled context, got nil") + } +} + +func TestEmbedTexts_RetryThenSuccess(t *testing.T) { + var requests atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("/api/embeddings", func(w http.ResponseWriter, r *http.Request) { + if requests.Add(1) == 1 { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"transient model load failure"}`)) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "embedding": []float64{9.9}, + }) + }) + + server, client := setupTestServer(t, mux) + defer server.Close() + + posts := []clustering.Item{{ID: "1", Text: "hello"}} + embeddings, err := client.EmbedTexts(context.Background(), posts) + if err != nil { + t.Fatalf("EmbedTexts failed after transient error: %v", err) + } + if len(embeddings) != 1 || len(embeddings[0]) != 1 || embeddings[0][0] != 9.9 { + t.Fatalf("unexpected embeddings after retry: %v", embeddings) + } + if got := requests.Load(); got != 2 { + t.Errorf("expected 2 requests (1 failure + 1 retry), got %d", got) + } +} + +func TestEmbedTexts_RetryExhausted(t *testing.T) { + var requests atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("/api/embeddings", func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"transient model load failure"}`)) + }) + + server, client := setupTestServer(t, mux) + defer server.Close() + + posts := []clustering.Item{{ID: "1", Text: "hello"}} + _, err := client.EmbedTexts(context.Background(), posts) + if err == nil { + t.Fatal("expected error after retries exhausted, got nil") + } + if got := requests.Load(); got != embedRetries+1 { + t.Errorf("expected %d attempts (initial + %d retries), got %d", embedRetries+1, embedRetries, got) + } +} + +func TestEmbedTexts_ParallelOrder(t *testing.T) { + // Shake ordering: handler sleeps and varies the embedding by prompt length, + // so a naive (unordered) worker pool would misalign results. + mux := http.NewServeMux() + mux.HandleFunc("/api/embeddings", func(w http.ResponseWriter, r *http.Request) { + var req struct { + Prompt string `json:"prompt"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("failed to decode request body: %v", err) + http.Error(w, "bad request", http.StatusBadRequest) + return + } + time.Sleep(30 * time.Millisecond) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "embedding": []float64{float64(len(req.Prompt))}, + }) + }) + + server, client := setupTestServer(t, mux) + defer server.Close() + + posts := make([]clustering.Item, 8) + want := make([]float64, 8) + for i := range posts { + posts[i] = clustering.Item{ID: fmt.Sprintf("%d", i), Text: strings.Repeat("x", i+1)} + want[i] = float64(i + 1) + } + + embeddings, err := client.EmbedTexts(context.Background(), posts) + if err != nil { + t.Fatalf("EmbedTexts failed: %v", err) + } + if len(embeddings) != len(posts) { + t.Fatalf("expected %d embeddings, got %d", len(posts), len(embeddings)) + } + for i, emb := range embeddings { + if len(emb) != 1 || emb[0] != want[i] { + t.Errorf("embedding %d out of order or wrong value: got %v, want %v", i, emb, want[i]) + } + } +} + +// ============================================================================ +// SummarizeStyle Tests +// ============================================================================ + +func TestSummarizeStyle(t *testing.T) { + type generateRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + } + + var reqBody generateRequest + callCount := 0 + + mux := http.NewServeMux() + mux.HandleFunc("/api/generate", func(w http.ResponseWriter, r *http.Request) { + callCount++ + + // 1. Validate HTTP Method + if r.Method != http.MethodPost { + t.Errorf("expected method POST, got %s", r.Method) + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // 2. Validate Headers + if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { + t.Errorf("expected Content-Type application/json, got %q", r.Header.Get("Content-Type")) + http.Error(w, "invalid content-type", http.StatusBadRequest) + return + } + + // 3. Validate Request Payload + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + t.Errorf("failed to decode request body: %v", err) + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + if reqBody.Model != "test-style" { + t.Errorf("expected model 'test-style', got %q", reqBody.Model) + } + + // Ensure the prompt includes input text + if !strings.Contains(reqBody.Prompt, "First post") || !strings.Contains(reqBody.Prompt, "Second post") { + t.Errorf("prompt missing post contents: %q", reqBody.Prompt) + } + + // 4. Stream NDJSON Response + flusher, ok := w.(http.Flusher) + if !ok { + t.Error("ResponseWriter is not a Flusher") + return + } + + w.Header().Set("Content-Type", "application/x-ndjson") + + chunks := []string{ + `{"response":"Style summary: "}` + "\n", + `{"response":"concise and "}` + "\n", + `{"response":"clear.","done":true}` + "\n", + } + + for _, chunk := range chunks { + _, _ = w.Write([]byte(chunk)) + flusher.Flush() + } + }) + + server, client := setupTestServer(t, mux) + defer server.Close() + + posts := []clustering.Item{ + {ID: "1", Text: "First post"}, + {ID: "2", Text: "Second post"}, + } + + summary, err := client.SummarizeStyle(context.Background(), posts) + if err != nil { + t.Fatalf("SummarizeStyle failed: %v", err) + } + + expected := "Style summary: concise and clear." + if summary != expected { + t.Errorf("got summary %q, want %q", summary, expected) + } + + if callCount != 1 { + t.Errorf("expected 1 call to /api/generate, got %d", callCount) + } +} + +func TestSummarizeStyle_EmptyInput(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/generate", func(w http.ResponseWriter, r *http.Request) { + t.Error("generate endpoint should not be called when input is empty") + http.Error(w, "should not be called", http.StatusInternalServerError) + }) + + server, client := setupTestServer(t, mux) + defer server.Close() + + summary, err := client.SummarizeStyle(context.Background(), []clustering.Item{}) + if err != nil { + t.Fatalf("SummarizeStyle with empty input failed: %v", err) + } + + if summary != "" { + t.Errorf("expected empty summary, got %q", summary) + } +} + +func TestSummarizeStyle_Error(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/generate", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"internal model failure"}`)) + }) + + server, client := setupTestServer(t, mux) + defer server.Close() + + posts := []clustering.Item{{ID: "1", Text: "hello"}} + + _, err := client.SummarizeStyle(context.Background(), posts) + if err == nil { + t.Fatal("expected error from failing server, got nil") + } +} + +func TestSummarizeStyle_MalformedJSONStream(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/generate", func(w http.ResponseWriter, r *http.Request) { + flusher, _ := w.(http.Flusher) + w.Header().Set("Content-Type", "application/x-ndjson") + + // First line is valid JSON, second is corrupt + _, _ = w.Write([]byte(`{"response":"valid part"}` + "\n")) + if flusher != nil { + flusher.Flush() + } + _, _ = w.Write([]byte(`{corrupt_json` + "\n")) + }) + + server, client := setupTestServer(t, mux) + defer server.Close() + + posts := []clustering.Item{{ID: "1", Text: "hello"}} + + _, err := client.SummarizeStyle(context.Background(), posts) + if err == nil { + t.Fatal("expected error on malformed JSON stream, got nil") + } +} + +func TestSummarizeStyle_ContextCanceled(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/generate", func(w http.ResponseWriter, r *http.Request) { + flusher, _ := w.(http.Flusher) + w.Header().Set("Content-Type", "application/x-ndjson") + + // Send initial chunk + _, _ = w.Write([]byte(`{"response":"waiting..."}` + "\n")) + if flusher != nil { + flusher.Flush() + } + + // Wait until client context cancels + <-r.Context().Done() + }) + + server, client := setupTestServer(t, mux) + defer server.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + posts := []clustering.Item{{ID: "1", Text: "hello"}} + + _, err := client.SummarizeStyle(ctx, posts) + if err == nil { + t.Fatal("expected error on canceled context, got nil") + } +} + +func TestSummarizeStyle_StreamNeverCompletes(t *testing.T) { + // Streaming ends without a done:true chunk (simulates a dropped connection + // or the http.Client timeout firing mid-generation). + mux := http.NewServeMux() + mux.HandleFunc("/api/generate", func(w http.ResponseWriter, r *http.Request) { + flusher, _ := w.(http.Flusher) + w.Header().Set("Content-Type", "application/x-ndjson") + _, _ = w.Write([]byte(`{"response":"partial answer"}` + "\n")) + if flusher != nil { + flusher.Flush() + } + }) // handler returns without ever writing done:true + + server, client := setupTestServer(t, mux) + defer server.Close() + + posts := []clustering.Item{{ID: "1", Text: "hello"}} + + _, err := client.SummarizeStyle(context.Background(), posts) + if err == nil { + t.Fatal("expected error when stream ends without completion, got nil") + } + if !strings.Contains(err.Error(), "ended before completion") { + t.Errorf("expected 'ended before completion' error, got %v", err) + } +} + +func TestSummarizeStyle_LengthTruncated(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/generate", func(w http.ResponseWriter, r *http.Request) { + flusher, _ := w.(http.Flusher) + w.Header().Set("Content-Type", "application/x-ndjson") + _, _ = w.Write([]byte(`{"response":"summary...","done":true,"done_reason":"length"}` + "\n")) + if flusher != nil { + flusher.Flush() + } + }) + + server, client := setupTestServer(t, mux) + defer server.Close() + + posts := []clustering.Item{{ID: "1", Text: "hello"}} + + _, err := client.SummarizeStyle(context.Background(), posts) + if err == nil { + t.Fatal("expected error when response is truncated at context limit, got nil") + } + if !strings.Contains(err.Error(), "truncated") { + t.Errorf("expected 'truncated' error, got %v", err) + } +} diff --git a/internal/server/LoggingMiddleware.go b/internal/server/LoggingMiddleware.go index bb4e7f0..76424b6 100644 --- a/internal/server/LoggingMiddleware.go +++ b/internal/server/LoggingMiddleware.go @@ -32,12 +32,20 @@ func (lm *LoggingMiddleware) OnCall(next mcp.MethodHandler) mcp.MethodHandler { toolName = callReq.Params.Name } - log.InfoWithContext(ctx, "MCP Tool Processed", - "transport", "mcp_native", - "tool", toolName, - "duration", duration, - "success", err == nil, - ) + if err != nil { + log.WarnWithContext(ctx, "MCP Tool Failed", + "transport", "mcp_native", + "tool", toolName, + "duration", duration, + "error", err.Error(), + ) + } else { + log.InfoWithContext(ctx, "MCP Tool Processed", + "transport", "mcp_native", + "tool", toolName, + "duration", duration, + ) + } return result, err } return next(ctx, method, request) diff --git a/internal/server/actors.go b/internal/server/actors.go new file mode 100644 index 0000000..205c8fa --- /dev/null +++ b/internal/server/actors.go @@ -0,0 +1,76 @@ +package server + +import ( + "context" + "fmt" + "strings" + "sync" +) + +func (s *Server) searchActorsAll(ctx context.Context, query string, limit int) ([]*ActorProfile, error) { + var ( + mu sync.Mutex + results []*ActorProfile + wg sync.WaitGroup + errMu sync.Mutex + errs []error + success int + ) + call := func(fn func(context.Context, string, int) ([]*ActorProfile, error)) { + defer wg.Done() + r, err := fn(ctx, query, limit) + if err != nil { + errMu.Lock() + errs = append(errs, err) + errMu.Unlock() + return + } + mu.Lock() + results = append(results, r...) + success++ + mu.Unlock() + } + wg.Add(3) + go call(s.bskyClient.SearchActors) + go call(s.apClient.SearchActors) + go call(s.nostrClient.SearchActors) + wg.Wait() + if success == 0 { + return nil, fmt.Errorf("all platforms failed: %v", errs) + } + return dedupeActors(results), nil +} + +// dedupeActors removes near-duplicate profiles across platforms. Two results are considered +// the same person when they share a normalized handle (the '@' and any instance/host stripped, +// lower-cased) or a normalized display name. The first occurrence wins. +func dedupeActors(in []*ActorProfile) []*ActorProfile { + seen := make(map[string]struct{}, len(in)) + out := make([]*ActorProfile, 0, len(in)) + for _, p := range in { + key := normalizeHandle(p.Handle) + if key == "" { + key = normalizeHandle(p.DisplayName) + } + if key == "" { + out = append(out, p) + continue + } + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + out = append(out, p) + } + return out +} + +func normalizeHandle(h string) string { + h = strings.ToLower(strings.TrimSpace(h)) + h = strings.TrimPrefix(h, "@") + // Drop an ActivityPub instance suffix (alice@mastodon.social -> alice). + if at := strings.Index(h, "@"); at >= 0 { + h = h[:at] + } + return h +} diff --git a/internal/server/analytics.go b/internal/server/analytics.go new file mode 100644 index 0000000..cb38bcf --- /dev/null +++ b/internal/server/analytics.go @@ -0,0 +1,966 @@ +package server + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "math" + "sort" + "strings" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + log "github.com/nacorid/logger" + "github.com/nacorid/naco-api/internal/clustering" + "github.com/nacorid/naco-api/internal/store" + "github.com/nacorid/naco-api/internal/utils" +) + +const ( + defaultAnalyticsLimit = 50 + maxAnalyticsLimit = 200 + defaultAnalyticsDays = 30 + maxTopicCount = 10 + minTopicCount = 3 + sentimentBucketDuration = 24 * time.Hour +) + +// ---- Analytics request/response helpers ---- + +func parseAnalyticsTimeRange(startStr, endStr string) (time.Time, time.Time, error) { + now := time.Now().UTC() + end := now + start := now.AddDate(0, 0, -defaultAnalyticsDays) + + if endStr != "" { + t, err := time.Parse(time.RFC3339, endStr) + if err != nil { + return time.Time{}, time.Time{}, fmt.Errorf("invalid endTime %q: %w", endStr, err) + } + end = t + } + if startStr != "" { + t, err := time.Parse(time.RFC3339, startStr) + if err != nil { + return time.Time{}, time.Time{}, fmt.Errorf("invalid startTime %q: %w", startStr, err) + } + start = t + } + if start.After(end) { + start, end = end, start + } + return start, end, nil +} + +func hashTimeRange(start, end time.Time, limit int) string { + data := fmt.Sprintf("%s|%s|%d", start.Format(time.RFC3339), end.Format(time.RFC3339), limit) + h := sha256.Sum256([]byte(data)) + return hex.EncodeToString(h[:]) +} + +func (s *Server) getAnalyticsCache(ctx context.Context, author, platform, toolName, timeRangeHash string) (json.RawMessage, bool) { + result, err := s.db.GetAnalyticsCache(ctx, store.GetAnalyticsCacheParams{ + UserID: author, + Platform: platform, + ToolName: toolName, + TimeRangeHash: timeRangeHash, + }) + if err != nil { + return nil, false + } + return result, true +} + +func (s *Server) setAnalyticsCache(ctx context.Context, author, platform, toolName, timeRangeHash string, result any, ttl time.Duration) error { + data, err := json.Marshal(result) + if err != nil { + return err + } + expiresAt := time.Now().Add(ttl) + return s.db.UpsertAnalyticsCache(ctx, store.UpsertAnalyticsCacheParams{ + UserID: author, + Platform: platform, + ToolName: toolName, + TimeRangeHash: timeRangeHash, + Result: data, + ExpiresAt: expiresAt, + }) +} + +// ---- Main analytics entry points ---- + +func (s *Server) getEngagementMetrics(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var args AnalyticsRequest + if err := json.Unmarshal(request.Params.Arguments, &args); err != nil { + return nil, fmt.Errorf("invalid arguments: %w", err) + } + return s.computeAnalytics(ctx, args, "engagementMetrics", s.computeEngagementMetrics) +} + +func (s *Server) getPostingPatterns(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var args AnalyticsRequest + if err := json.Unmarshal(request.Params.Arguments, &args); err != nil { + return nil, fmt.Errorf("invalid arguments: %w", err) + } + return s.computeAnalytics(ctx, args, "postingPatterns", s.computePostingPatterns) +} + +func (s *Server) getTopicModeling(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var args AnalyticsRequest + if err := json.Unmarshal(request.Params.Arguments, &args); err != nil { + return nil, fmt.Errorf("invalid arguments: %w", err) + } + return s.computeAnalytics(ctx, args, "topicModeling", s.computeTopicModeling) +} + +func (s *Server) getSentimentTrends(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var args AnalyticsRequest + if err := json.Unmarshal(request.Params.Arguments, &args); err != nil { + return nil, fmt.Errorf("invalid arguments: %w", err) + } + return s.computeAnalytics(ctx, args, "sentimentTrends", s.computeSentimentTrends) +} + +func (s *Server) getEngagementDrivers(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var args AnalyticsRequest + if err := json.Unmarshal(request.Params.Arguments, &args); err != nil { + return nil, fmt.Errorf("invalid arguments: %w", err) + } + return s.computeAnalytics(ctx, args, "engagementDrivers", s.computeEngagementDrivers) +} + +type analyticsComputer func(ctx context.Context, args AnalyticsRequest, posts []clustering.Item) (any, error) + +func (s *Server) computeAnalytics(ctx context.Context, args AnalyticsRequest, toolName string, compute analyticsComputer) (*mcp.CallToolResult, error) { + if args.Author == "" { + return nil, fmt.Errorf("author must be a valid did or handle") + } + + // Normalize args + limit := args.Limit + if limit <= 0 { + limit = defaultAnalyticsLimit + } + if limit > maxAnalyticsLimit { + limit = maxAnalyticsLimit + } + + start, end, err := parseAnalyticsTimeRange(args.StartTime, args.EndTime) + if err != nil { + return nil, fmt.Errorf("invalid time range: %w", err) + } + + var platforms []string + if args.Platform != "" { + platforms = []string{args.Platform} + } else { + // Twitter is included by default only when the API is enabled: the API + // costs it incurs are passed on to the caller through the upto pricing + // of these tools (see registerTools), so aggregating over it does not + // run the server at a loss. When no bearer token is configured there is + // no Twitter to aggregate over. + platforms = []string{"Bluesky", "ActivityPub", "Nostr"} + if s.twitterEnabled { + platforms = append(platforms, "Twitter") + } + } + + timeRangeHash := hashTimeRange(start, end, limit) + + // The analytics result cache is keyed by the scope of the computation: a + // single platform when one is requested, or the "all" sentinel when the + // result aggregates across all platforms. This keeps aggregate results from + // being mixed with single-platform results. + cachePlatform := "all" + if args.Platform != "" { + cachePlatform = args.Platform + } + + if cached, ok := s.getAnalyticsCache(ctx, args.Author, cachePlatform, toolName, timeRangeHash); ok { + var cachedResult json.RawMessage + _ = json.Unmarshal(cached, &cachedResult) + return s.toToolResult(cachedResult) + } + + // Fetch posts from all platforms + var allPosts []clustering.Item + twitterPosts := 0 + for _, p := range platforms { + plat, ok := platformFromString(p) + if !ok { + return nil, fmt.Errorf("unsupported platform %q", p) + } + posts, err := s.fetchAnalyticsPosts(ctx, plat, args.Author, start, end, limit) + if err != nil { + log.WarnWithContext(ctx, "failed to fetch posts for analytics", "platform", p, "error", err) + continue + } + allPosts = append(allPosts, posts...) + if plat == Twitter { + twitterPosts += len(posts) + } + } + + if len(allPosts) == 0 { + return nil, fmt.Errorf("no posts found for author %s in time range", args.Author) + } + + // Compute + result, err := compute(ctx, args, allPosts) + if err != nil { + return nil, err + } + result = setAnalyticsTwitterPostCount(result, twitterPosts) + + // Cache the result under the matching scope key. + ttl := s.getCacheTTL(toolName) + _ = s.setAnalyticsCache(ctx, args.Author, cachePlatform, toolName, timeRangeHash, result, ttl) + + return s.toToolResult(result) +} + +// setAnalyticsTwitterPostCount records how many posts in an analytics result +// were sourced from the Twitter API, so upto-scheme settlement can pass the +// realized Twitter costs on to the caller. Unknown result types are returned +// unchanged. +func setAnalyticsTwitterPostCount(result any, count int) any { + switch v := result.(type) { + case EngagementMetricsResponse: + v.TwitterPostCount = count + return v + case PostingPatternsResponse: + v.TwitterPostCount = count + return v + case TopicModelingResponse: + v.TwitterPostCount = count + return v + case SentimentTrendsResponse: + v.TwitterPostCount = count + return v + case EngagementDriversResponse: + v.TwitterPostCount = count + return v + } + return result +} + +func (s *Server) fetchAnalyticsPosts(ctx context.Context, platform platform, author string, start, end time.Time, limit int) ([]clustering.Item, error) { + posts, err := s.fetchPostsInTimeForAnalytics(ctx, platform, author, start, end, limit) + if err != nil { + return nil, err + } + return posts, nil +} + +func (s *Server) getCachedAnalyticsPosts(ctx context.Context, author, platform string, start, end time.Time, limit int) ([]clustering.Item, error) { + rows, err := s.db.GetAnalyticsPostsInRange(ctx, store.GetAnalyticsPostsInRangeParams{ + UserID: author, + Platform: platform, + PostTimestamp: start, + PostTimestamp_2: end, + Limit: int32(limit), + }) + if err != nil { + return nil, err + } + items := make([]clustering.Item, 0, len(rows)) + for _, r := range rows { + items = append(items, clustering.Item{ + ID: r.PostID, + Text: r.PostText, + Timestamp: r.PostTimestamp, + Likes: r.Likes, + Reposts: r.Reposts, + Replies: r.Replies, + Quotes: r.Quotes, + }) + } + return items, nil +} + +func (s *Server) persistAnalyticsPosts(ctx context.Context, author, platform string, posts []clustering.Item) error { + for _, p := range posts { + if p.ID == "" { + continue + } + if err := s.db.UpsertAnalyticsPosts(ctx, store.UpsertAnalyticsPostsParams{ + UserID: author, + Platform: platform, + PostID: p.ID, + PostText: p.Text, + PostTimestamp: p.Timestamp, + Likes: p.Likes, + Reposts: p.Reposts, + Replies: p.Replies, + Quotes: p.Quotes, + }); err != nil { + return err + } + } + return nil +} + +func (s *Server) fetchPostsInTimeForAnalytics(ctx context.Context, platform platform, author string, start, end time.Time, limit int) ([]clustering.Item, error) { + var items []clustering.Item + switch platform { + case Bluesky: + res, err := s.bskyClient.GetPostsInTimeframe(ctx, author, start, end, limit) + if err != nil { + return nil, err + } + items = blueskyFeedViewsToAnalyticsItems(res) + case ActivityPub: + res, err := s.apClient.GetPostsInTimeframe(ctx, author, start, end, limit) + if err != nil { + return nil, err + } + items = apPostsToAnalyticsItems(res) + case Nostr: + res, err := s.nostrClient.GetPostsInTimeframe(ctx, author, start, end, limit) + if err != nil { + return nil, err + } + items = nostrPostsToAnalyticsItems(res) + case Twitter: + // Twitter API calls are expensive (paid). Reuse persisted post data and + // only call the API for time ranges that are not yet covered by the + // cache, using a coverage watermark to guarantee broad coverage. + fetched, err := s.fetchTwitterAnalyticsPosts(ctx, author, start, end, limit) + if err != nil { + return nil, err + } + items = append(items, fetched...) + } + return items, nil +} + +// fetchTwitterAnalyticsPosts returns posts in [start, end] for the author, +// fetching from the Twitter API only the sub-ranges not already covered by the +// persistent post cache, and storing any newly fetched posts for future use. +// +// Coverage is tracked as a set of discrete intervals, so disjoint fetched +// ranges (e.g. [Dec 1, Dec 15] and [Dec 20, Dec 31]) are not merged into one +// continuous window. A later request that lands in the never-fetched gap +// (e.g. [Dec 15, Dec 20]) is therefore fetched from the API rather than being +// wrongly served from an incomplete cache. +func (s *Server) fetchTwitterAnalyticsPosts(ctx context.Context, author string, start, end time.Time, limit int) ([]clustering.Item, error) { + covered, err := s.getAnalyticsCoverageRanges(ctx, author, "Twitter") + if err != nil { + return nil, err + } + + // If the entire requested range falls within already-covered intervals, + // serve from the cache without incurring a paid Twitter request. + if len(uncoveredRanges(start, end, covered)) == 0 { + return s.getCachedAnalyticsPosts(ctx, author, "Twitter", start, end, limit) + } + + // Determine the sub-ranges of [start, end] not yet covered, and fetch each + // from the API. Fetching per uncovered range keeps costs bounded while + // expanding coverage on every call. + ranges := uncoveredRanges(start, end, covered) + + var fetched []clustering.Item + for _, r := range ranges { + posts, err := s.fetchTwitterFromAPI(ctx, author, r.start, r.end, limit) + if err != nil { + return nil, err + } + fetched = append(fetched, posts...) + } + + // Persist newly fetched posts permanently so repeat calls never re-pay. + if len(fetched) > 0 { + if err := s.persistAnalyticsPosts(ctx, author, "Twitter", fetched); err != nil { + log.WarnWithContext(ctx, "failed to persist analytics posts", "platform", "Twitter", "error", err) + } + } + + // Record the newly fetched intervals as covered so future calls can reuse + // them without hitting the paid API. + for _, r := range ranges { + if err := s.addAnalyticsCoverageRange(ctx, author, "Twitter", r.start, r.end); err != nil { + log.WarnWithContext(ctx, "failed to add analytics coverage range", "platform", "Twitter", "range", r, "error", err) + } + } + + // Return the full set: posts from cache plus anything newly fetched, merged + // and de-duplicated by post ID. + return s.mergeAnalyticsPosts(ctx, author, start, end, limit, fetched) +} + +type timeRange struct { + start, end time.Time +} + +// uncoveredRanges returns the sub-ranges of [start, end] that are not covered +// by any of the given intervals. Intervals are assumed sorted by their start, +// and no coverage (empty slice) yields the whole range as uncovered. +func uncoveredRanges(start, end time.Time, covered []timeRange) []timeRange { + var ranges []timeRange + cursor := start + for _, c := range covered { + if c.end.Before(cursor) || c.end.Equal(cursor) { + continue + } + if c.start.After(end) { + break + } + if c.start.After(cursor) { + ranges = append(ranges, timeRange{start: cursor, end: c.start}) + } + if c.end.After(cursor) { + cursor = c.end + } + } + if cursor.Before(end) { + ranges = append(ranges, timeRange{start: cursor, end: end}) + } + return ranges +} + +func (s *Server) getAnalyticsCoverageRanges(ctx context.Context, author, platform string) ([]timeRange, error) { + rows, err := s.db.GetAnalyticsCoverageRanges(ctx, store.GetAnalyticsCoverageRangesParams{ + UserID: author, + Platform: platform, + }) + if err != nil { + return nil, err + } + ranges := make([]timeRange, 0, len(rows)) + for _, r := range rows { + ranges = append(ranges, timeRange{start: r.CoveredStart, end: r.CoveredEnd}) + } + return ranges, nil +} + +func (s *Server) addAnalyticsCoverageRange(ctx context.Context, author, platform string, start, end time.Time) error { + return s.db.InsertAnalyticsCoverageRange(ctx, store.InsertAnalyticsCoverageRangeParams{ + UserID: author, + Platform: platform, + CoveredStart: start, + CoveredEnd: end, + }) +} + +func (s *Server) fetchTwitterFromAPI(ctx context.Context, author string, start, end time.Time, limit int) ([]clustering.Item, error) { + res, err := s.twitterClient.GetPostsInTimeframe(ctx, author, start, end, limit) + if err != nil { + return nil, err + } + return twitterPostsToAnalyticsItems(res), nil +} + +// mergeAnalyticsPosts combines cached posts in [start, end] with newly fetched +// posts, de-duplicating by post ID and capping the result at limit. The cached +// query and each fetched sub-range individually honor limit, so the merged set +// can otherwise exceed it when the range is split into multiple fetch calls. +func (s *Server) mergeAnalyticsPosts(ctx context.Context, author string, start, end time.Time, limit int, fetched []clustering.Item) ([]clustering.Item, error) { + merged := make([]clustering.Item, 0, len(fetched)) + seen := make(map[string]struct{}, len(fetched)) + for _, p := range fetched { + if len(merged) >= limit { + break + } + if _, ok := seen[p.ID]; ok { + continue + } + seen[p.ID] = struct{}{} + merged = append(merged, p) + } + + cached, err := s.getCachedAnalyticsPosts(ctx, author, "Twitter", start, end, limit) + if err != nil { + return merged, nil + } + for _, p := range cached { + if len(merged) >= limit { + break + } + if _, ok := seen[p.ID]; ok { + continue + } + seen[p.ID] = struct{}{} + merged = append(merged, p) + } + return merged, nil +} + +func (s *Server) toToolResult(result any) (*mcp.CallToolResult, error) { + data, err := json.Marshal(result) + if err != nil { + return nil, err + } + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: string(data)}, + }, + }, nil +} + +func (s *Server) getCacheTTL(toolName string) time.Duration { + switch toolName { + case "postingPatterns": + return s.analyticsTTLPatterns + case "topicModeling": + return s.analyticsTTLTopics + case "sentimentTrends": + return s.analyticsTTLSentiment + case "engagementDrivers": + return s.analyticsTTLDrivers + case "engagementMetrics": + fallthrough + default: + return s.analyticsTTLEngagement + } +} + +// ---- Computation implementations ---- + +func (s *Server) computeEngagementMetrics(ctx context.Context, args AnalyticsRequest, posts []clustering.Item) (any, error) { + var metrics []EngagementMetricPoint + for _, post := range posts { + total := post.Likes + post.Reposts + post.Replies + post.Quotes + // Rate is a simple relative measure (interactions per post). Without a + // follower/fan base to normalize against, report total interactions. + metrics = append(metrics, EngagementMetricPoint{ + PostID: post.ID, + Timestamp: post.Timestamp.Format(time.RFC3339), + Likes: post.Likes, + Reposts: post.Reposts, + Replies: post.Replies, + Quotes: post.Quotes, + EngagementRate: float64(total), + }) + } + + return EngagementMetricsResponse{ + Author: args.Author, + Metrics: metrics, + }, nil +} + +func (s *Server) computePostingPatterns(ctx context.Context, args AnalyticsRequest, posts []clustering.Item) (any, error) { + // Heatmap: 24 hours x 7 days + heatmap := make([][]int, 24) + for i := range heatmap { + heatmap[i] = make([]int, 7) + } + dayCounts := make(map[int]int) + hourCounts := make(map[int]int) + dateSet := make(map[string]bool) + + for _, post := range posts { + t := post.Timestamp + if t.IsZero() { + continue + } + hour := t.Hour() + weekday := int(t.Weekday()) + heatmap[hour][weekday]++ + hourCounts[hour]++ + dayCounts[weekday]++ + dateSet[t.Format("2006-01-02")] = true + } + + // Find peak hours (top 3) + type hourCount struct { + Hour int + Count int + } + var hours []hourCount + for h, c := range hourCounts { + hours = append(hours, hourCount{h, c}) + } + sort.Slice(hours, func(i, j int) bool { return hours[i].Count > hours[j].Count }) + peakHours := make([]int, 0, 3) + for i := 0; i < len(hours) && i < 3; i++ { + peakHours = append(peakHours, hours[i].Hour) + } + + // Find peak days (top 3) + type dayCount struct { + Day int + Count int + } + var days []dayCount + for d, c := range dayCounts { + days = append(days, dayCount{d, c}) + } + sort.Slice(days, func(i, j int) bool { return days[i].Count > days[j].Count }) + peakDays := make([]int, 0, 3) + for i := 0; i < len(days) && i < 3; i++ { + peakDays = append(peakDays, days[i].Day) + } + + totalPosts := len(posts) + postingDays := len(dateSet) + avgPostsPerDay := 0.0 + if postingDays > 0 { + avgPostsPerDay = float64(totalPosts) / float64(postingDays) + } + + return PostingPatternsResponse{ + Author: args.Author, + Heatmap: heatmap, + PeakHours: peakHours, + PeakDays: peakDays, + AvgPostsPerDay: avgPostsPerDay, + TotalPosts: totalPosts, + PostingDays: postingDays, + }, nil +} + +func (s *Server) computeTopicModeling(ctx context.Context, args AnalyticsRequest, posts []clustering.Item) (any, error) { + topics, _, err := s.clusterPosts(ctx, posts) + if err != nil { + return nil, err + } + return TopicModelingResponse{ + Author: args.Author, + Topics: topics, + }, nil +} + +// clusterPosts runs topic clustering over the posts and returns both the +// resulting topics and a map from post ID to the label of the topic the post +// belongs to. The membership map lets callers correlate topics with per-post +// attributes (e.g. engagement) without repeating the clustering. +func (s *Server) clusterPosts(ctx context.Context, posts []clustering.Item) ([]TopicCluster, map[string]string, error) { + if len(posts) < minTopicCount { + return []TopicCluster{}, map[string]string{}, nil + } + + items := posts + embeddings, err := s.ollama.EmbedTexts(ctx, items) + if err != nil { + return nil, nil, fmt.Errorf("failed to compute embeddings: %w", err) + } + + for i := range items { + items[i].Embedding = embeddings[i] + } + + topicCount := maxTopicCount + if len(items) < maxTopicCount { + topicCount = len(items) + } + if topicCount < minTopicCount { + topicCount = minTopicCount + } + + results, err := clustering.KMeans(items, topicCount, 50, 42) + if err != nil { + return nil, nil, fmt.Errorf("kmeans failed: %w", err) + } + + membership := make(map[string]string) + labelByCluster := make(map[int]string) + var topics []TopicCluster + for _, cluster := range results { + if len(cluster.Items) == 0 { + continue + } + + var samples []string + for _, item := range cluster.Items { + if len(samples) >= 3 { + break + } + samples = append(samples, item.Text) + } + + // Generate label using Ollama + label := s.generateTopicLabel(ctx, samples) + labelByCluster[cluster.Cluster] = label + keywords := s.extractKeywords(cluster.Items) + + topics = append(topics, TopicCluster{ + TopicID: cluster.Cluster, + Label: label, + Keywords: keywords, + PostCount: len(cluster.Items), + Percentage: float64(len(cluster.Items)) / float64(len(items)) * 100, + SamplePosts: samples, + }) + } + + // Map each post to the label of the cluster it belongs to. + for _, cluster := range results { + label := labelByCluster[cluster.Cluster] + for _, item := range cluster.Items { + if item.ID != "" { + membership[item.ID] = label + } + } + } + + // Sort by post count descending + sort.Slice(topics, func(i, j int) bool { return topics[i].PostCount > topics[j].PostCount }) + + return topics, membership, nil +} + +func (s *Server) computeSentimentTrends(ctx context.Context, args AnalyticsRequest, posts []clustering.Item) (any, error) { + // Bucket posts by day + bucketMap := make(map[string][]string) + for _, post := range posts { + if post.Timestamp.IsZero() { + continue + } + bucket := post.Timestamp.Truncate(sentimentBucketDuration).Format(time.RFC3339) + bucketMap[bucket] = append(bucketMap[bucket], post.Text) + } + + var timeSeries []SentimentPoint + overallPos, overallNeg, overallNeu := 0.0, 0.0, 0.0 + emotionCounts := make(map[string]int) + var shifts []SentimentShift + var prevSentiment string + var prevScores [3]float64 + + // Only posts with a timestamp are bucketed and contribute to sentiment, so + // the overall ratios are normalized by that same count (not the total + // number of fetched posts, some of which may lack a timestamp). + timestampedPosts := 0 + for _, post := range posts { + if !post.Timestamp.IsZero() { + timestampedPosts++ + } + } + + // Process buckets in chronological order so sentiment shifts are detected correctly. + buckets := make([]string, 0, len(bucketMap)) + for b := range bucketMap { + buckets = append(buckets, b) + } + sort.Strings(buckets) + + for _, bucket := range buckets { + texts := bucketMap[bucket] + pos, neg, neu, emotions := s.analyzeSentiment(ctx, texts) + timeSeries = append(timeSeries, SentimentPoint{ + Timestamp: bucket, + Positive: pos, + Negative: neg, + Neutral: neu, + PostCount: len(texts), + }) + + overallPos += pos * float64(len(texts)) + overallNeg += neg * float64(len(texts)) + overallNeu += neu * float64(len(texts)) + + for e, c := range emotions { + emotionCounts[e] += c + } + + // Detect sentiment shifts + dominant := maxSentiment(pos, neg, neu) + if prevSentiment != "" && dominant != prevSentiment { + mag := math.Abs(pos-prevScores[0]) + math.Abs(neg-prevScores[1]) + math.Abs(neu-prevScores[2]) + shifts = append(shifts, SentimentShift{ + Timestamp: bucket, + FromSentiment: prevSentiment, + ToSentiment: dominant, + Magnitude: mag, + }) + } + prevSentiment = dominant + prevScores = [3]float64{pos, neg, neu} + } + + totalPosts := float64(timestampedPosts) + if totalPosts > 0 { + overallPos /= totalPosts + overallNeg /= totalPosts + overallNeu /= totalPosts + } + + // Top emotions + var topEmotions []EmotionScore + type emoCount struct { + Emotion string + Count int + } + var emos []emoCount + for e, c := range emotionCounts { + emos = append(emos, emoCount{e, c}) + } + sort.Slice(emos, func(i, j int) bool { return emos[i].Count > emos[j].Count }) + for i := 0; i < len(emos) && i < 5; i++ { + topEmotions = append(topEmotions, EmotionScore{ + Emotion: emos[i].Emotion, + Score: float64(emos[i].Count) / totalPosts, + }) + } + + return SentimentTrendsResponse{ + Author: args.Author, + TimeSeries: timeSeries, + OverallSentiment: SentimentSummary{ + Positive: overallPos, + Negative: overallNeg, + Neutral: overallNeu, + }, + DominantEmotions: topEmotions, + SentimentShifts: shifts, + }, nil +} + +func (s *Server) computeEngagementDrivers(ctx context.Context, args AnalyticsRequest, posts []clustering.Item) (any, error) { + // Topic modeling first, getting post->topic membership for correlation. + topics, membership, err := s.clusterPosts(ctx, posts) + if err != nil { + return nil, err + } + + // Group engagement by topic label. Each post contributes its total + // engagement (likes + reposts + replies + quotes) to its topic. + topicEngagement := make(map[string][]float64) + for _, post := range posts { + label, ok := membership[post.ID] + if !ok { + continue + } + total := float64(post.Likes + post.Reposts + post.Replies + post.Quotes) + topicEngagement[label] = append(topicEngagement[label], total) + } + + // Baseline is the mean engagement across all posts, for comparison. + var allEngagement []float64 + for _, post := range posts { + allEngagement = append(allEngagement, float64(post.Likes+post.Reposts+post.Replies+post.Quotes)) + } + baseline := mean(allEngagement) + + var drivers []EngagementDriver + for _, topic := range topics { + values := topicEngagement[topic.Label] + topicMean := mean(values) + correlation := 0.0 + if denom := topicMean + baseline; denom > 0 { + correlation = (topicMean - baseline) / denom + if correlation > 1 { + correlation = 1 + } else if correlation < -1 { + correlation = -1 + } + } + drivers = append(drivers, EngagementDriver{ + Topic: topic.Label, + AvgEngagement: topicMean, + Correlation: correlation, + PostCount: topic.PostCount, + SamplePosts: topic.SamplePosts[:min(2, len(topic.SamplePosts))], + }) + } + + sort.Slice(drivers, func(i, j int) bool { + return math.Abs(drivers[i].Correlation) > math.Abs(drivers[j].Correlation) + }) + + return EngagementDriversResponse{ + Author: args.Author, + Drivers: drivers, + }, nil +} + +func mean(values []float64) float64 { + if len(values) == 0 { + return 0 + } + sum := 0.0 + for _, v := range values { + sum += v + } + return sum / float64(len(values)) +} + +// ---- Helper functions ---- + +func (s *Server) generateTopicLabel(ctx context.Context, samples []string) string { + if len(samples) == 0 { + return "General" + } + prompt := fmt.Sprintf(`Given these sample posts, provide a concise 1-3 word topic label: + +Posts: +%s + +Label only (no explanation):`, strings.Join(samples, "\n---\n")) + + resp, err := s.ollama.SummarizeStyle(ctx, []clustering.Item{{Text: prompt}}) + if err != nil { + return "General" + } + return strings.TrimSpace(resp) +} + +func (s *Server) extractKeywords(items []clustering.Item) []string { + var allText []string + for _, item := range items { + allText = append(allText, item.Text) + } + return utils.TopWords(allText, make(map[string]struct{}), 5) +} + +func (s *Server) analyzeSentiment(ctx context.Context, texts []string) (pos, neg, neu float64, emotions map[string]int) { + if len(texts) == 0 { + return 0, 0, 0, nil + } + + // Use Ollama for sentiment analysis + prompt := fmt.Sprintf(`Analyze sentiment and emotions in these texts. Return JSON only: +{ + "positive": 0.0-1.0, + "negative": 0.0-1.0, + "neutral": 0.0-1.0, + "emotions": {"emotion": count} +} + +Texts: +%s`, strings.Join(texts, "\n---\n")) + + resp, err := s.ollama.SummarizeStyle(ctx, []clustering.Item{{Text: prompt}}) + if err != nil { + return 0.33, 0.33, 0.34, nil + } + + var result struct { + Positive float64 `json:"positive"` + Negative float64 `json:"negative"` + Neutral float64 `json:"neutral"` + Emotions map[string]int `json:"emotions"` + } + _ = json.Unmarshal([]byte(resp), &result) + + // Normalize + sum := result.Positive + result.Negative + result.Neutral + if sum > 0 { + result.Positive /= sum + result.Negative /= sum + result.Neutral /= sum + } + + return result.Positive, result.Negative, result.Neutral, result.Emotions +} + +func maxSentiment(pos, neg, neu float64) string { + if pos >= neg && pos >= neu { + return "positive" + } + if neg >= pos && neg >= neu { + return "negative" + } + return "neutral" +} + +func int64Val(p *int64) int64 { + if p == nil { + return 0 + } + return *p +} diff --git a/internal/server/analytics_test.go b/internal/server/analytics_test.go new file mode 100644 index 0000000..e74eef6 --- /dev/null +++ b/internal/server/analytics_test.go @@ -0,0 +1,318 @@ +package server + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/nacorid/naco-api/internal/activitypub" + "github.com/nacorid/naco-api/internal/clustering" + "github.com/nacorid/naco-api/internal/twitter" +) + +var errSome = fmt.Errorf("some error") + +func analyticsAPPosts() *fakeAP { + base := time.Date(2024, 1, 10, 12, 0, 0, 0, time.UTC) + return &fakeAP{posts: []*activitypub.Post{ + {ID: "p1", Author: "@a@mastodon.social", Content: "first post", CreatedAt: base}, + {ID: "p2", Author: "@a@mastodon.social", Content: "second post", CreatedAt: base.Add(time.Hour)}, + }} +} + +// ---- validation ---- + +func TestAnalyticsEmptyAuthor(t *testing.T) { + s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) + if _, err := callTool(s, "getEngagementMetrics", AnalyticsRequest{Author: ""}); err == nil || !strings.Contains(err.Error(), "author") { + t.Fatalf("expected author error, got %v", err) + } +} + +func TestAnalyticsInvalidTimeRange(t *testing.T) { + s := newTestServer(&fakeBluesky{}, analyticsAPPosts(), &fakeNostr{}, &fakeTwitter{}) + _, err := callTool(s, "getEngagementMetrics", AnalyticsRequest{ + Author: "@a@mastodon.social", + StartTime: "not-a-time", + EndTime: "2024-12-31T00:00:00Z", + Platform: "ActivityPub", + }) + if err == nil || !strings.Contains(err.Error(), "time range") { + t.Fatalf("expected time range error, got %v", err) + } +} + +func TestAnalyticsUnknownPlatform(t *testing.T) { + s := newTestServer(&fakeBluesky{}, analyticsAPPosts(), &fakeNostr{}, &fakeTwitter{}) + _, err := callTool(s, "getEngagementMetrics", AnalyticsRequest{ + Author: "@a@mastodon.social", + Platform: "MySpace", + }) + if err == nil || !strings.Contains(err.Error(), `unsupported platform "MySpace"`) { + t.Fatalf("expected unsupported platform error, got %v", err) + } +} + +func TestAnalyticsNoPostsFound(t *testing.T) { + ap := &fakeAP{} // no posts + s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}) + _, err := callTool(s, "getEngagementMetrics", AnalyticsRequest{Author: "@a@mastodon.social", Platform: "ActivityPub"}) + if err == nil || !strings.Contains(err.Error(), "no posts found") { + t.Fatalf("expected no-posts error, got %v", err) + } +} + +// ---- success + caching ---- + +func TestAnalyticsEngagementMetricsAndCache(t *testing.T) { + ap := analyticsAPPosts() + st := newFakeStore() + s := newServerWith(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{}) + + args := AnalyticsRequest{ + Author: "@a@mastodon.social", + StartTime: "2024-01-01T00:00:00Z", + EndTime: "2024-12-31T00:00:00Z", + Platform: "ActivityPub", + } + res, err := callTool(s, "getEngagementMetrics", args) + if err != nil { + t.Fatalf("getEngagementMetrics error: %v", err) + } + var out EngagementMetricsResponse + if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if out.Author != "@a@mastodon.social" || len(out.Metrics) != 2 { + t.Fatalf("unexpected metrics: author=%q count=%d", out.Author, len(out.Metrics)) + } + + // Cached result is served without hitting the backend again. + if _, err := callTool(s, "getEngagementMetrics", args); err != nil { + t.Fatalf("second call error: %v", err) + } + if ap.calls != 1 { + t.Fatalf("expected backend called once due to cache, got %d", ap.calls) + } +} + +func TestAnalyticsSinglePlatformScopeDoesNotCrossPollinate(t *testing.T) { + // Engagement over ActivityPub should never be served from a cache written + // under the aggregate "all" scope. + ap := analyticsAPPosts() + st := newFakeStore() + s := newServerWith(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{}) + + // Warm cache under "all" scope (no platform) first. + args := AnalyticsRequest{ + Author: "@a@mastodon.social", + StartTime: "2024-01-05T00:00:00Z", + EndTime: "2024-01-15T00:00:00Z", + } + if _, err := callTool(s, "getEngagementMetrics", args); err != nil { + t.Fatalf("aggregate call error: %v", err) + } + + // Now request a single-platform scope; it must still fetch from the backend. + spArgs := args + spArgs.Platform = "ActivityPub" + if _, err := callTool(s, "getEngagementMetrics", spArgs); err != nil { + t.Fatalf("scoped call error: %v", err) + } + if ap.calls != 2 { + t.Fatalf("expected scoped request to re-fetch (distinct cache scope), got %d calls", ap.calls) + } +} + +func TestAnalyticsPartialPlatformFailureTolerated(t *testing.T) { + // One platform failing must not abort the whole aggregation. + bsky := &fakeBluesky{err: errSome} + ap := analyticsAPPosts() + st := newFakeStore() + s := newServerWith(bsky, ap, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{}) + _, err := callTool(s, "getEngagementMetrics", AnalyticsRequest{ + Author: "@a@mastodon.social", + StartTime: "2024-01-01T00:00:00Z", + EndTime: "2024-12-31T00:00:00Z", + }) + if err != nil { + t.Fatalf("expected partial failure to be tolerated, got %v", err) + } +} + +// TestAnalyticsTwitterPostCount verifies the response reports how many posts +// were sourced from the Twitter API, so upto-scheme settlement can charge the +// realized Twitter API costs. +func TestAnalyticsTwitterPostCount(t *testing.T) { + tw := &fakeTwitter{posts: []*twitter.Post{ + {ID: "t1", Content: "first tweet", CreatedAt: time.Date(2024, 1, 11, 12, 0, 0, 0, time.UTC)}, + {ID: "t2", Content: "second tweet", CreatedAt: time.Date(2024, 1, 12, 12, 0, 0, 0, time.UTC)}, + {ID: "t3", Content: "third tweet", CreatedAt: time.Date(2024, 1, 13, 12, 0, 0, 0, time.UTC)}, + }} + st := newFakeStore() + s := newServerWith(&fakeBluesky{}, analyticsAPPosts(), &fakeNostr{}, tw, st, &fakeOllama{}) + + res, err := callTool(s, "getEngagementMetrics", AnalyticsRequest{ + Author: "@alice", + StartTime: "2024-01-01T00:00:00Z", + EndTime: "2024-12-31T00:00:00Z", + Platform: "Twitter", + }) + if err != nil { + t.Fatalf("getEngagementMetrics error: %v", err) + } + var out EngagementMetricsResponse + if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if out.TwitterPostCount != 3 { + t.Fatalf("expected TwitterPostCount 3, got %d", out.TwitterPostCount) + } + + // A non-Twitter platform reports 0 and the default aggregate excludes + // Twitter when the API is not enabled (twitterEnabled=false in tests). + res, err = callTool(s, "getEngagementMetrics", AnalyticsRequest{ + Author: "@a@mastodon.social", + StartTime: "2024-01-01T00:00:00Z", + EndTime: "2024-12-31T00:00:00Z", + Platform: "ActivityPub", + }) + if err != nil { + t.Fatalf("getEngagementMetrics error: %v", err) + } + if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if out.TwitterPostCount != 0 { + t.Fatalf("expected TwitterPostCount 0 for ActivityPub, got %d", out.TwitterPostCount) + } +} + +// ---- pure compute functions ---- + +func TestComputePostingPatterns(t *testing.T) { + s := &Server{} + args := AnalyticsRequest{Author: "a"} + mon := time.Date(2024, 1, 1, 9, 0, 0, 0, time.UTC) // Monday 09:00 + posts := []clustering.Item{ + {ID: "1", Timestamp: mon}, + {ID: "2", Timestamp: mon.Add(30 * time.Minute)}, // same hour 9 + {ID: "3"}, // zero timestamp: skipped + } + res, err := s.computePostingPatterns(context.Background(), args, posts) + if err != nil { + t.Fatalf("computePostingPatterns error: %v", err) + } + out := res.(PostingPatternsResponse) + // Two timestamped posts across one distinct day. + if out.TotalPosts != 3 { + t.Fatalf("expected TotalPosts 3, got %d", out.TotalPosts) + } + if out.PostingDays != 1 { + t.Fatalf("expected PostingDays 1, got %d", out.PostingDays) + } + if out.AvgPostsPerDay != 3 { + t.Fatalf("expected AvgPostsPerDay 3, got %f", out.AvgPostsPerDay) + } + if len(out.PeakHours) == 0 || out.PeakHours[0] != 9 { + t.Fatalf("expected peak hour 9, got %+v", out.PeakHours) + } +} + +func TestComputePostingPatternsNoTimestamps(t *testing.T) { + s := &Server{} + posts := []clustering.Item{{ID: "1"}, {ID: "2"}} + res, err := s.computePostingPatterns(context.Background(), AnalyticsRequest{}, posts) + if err != nil { + t.Fatalf("computePostingPatterns error: %v", err) + } + out := res.(PostingPatternsResponse) + // Division by zero avoided: posting days of 0 leaves AvgPostsPerDay at 0. + if out.PostingDays != 0 || out.AvgPostsPerDay != 0 { + t.Fatalf("expected 0 posting days / 0 avg, got %d / %f", out.PostingDays, out.AvgPostsPerDay) + } + if out.TotalPosts != 2 { + t.Fatalf("expected TotalPosts 2, got %d", out.TotalPosts) + } +} + +func TestComputeEngagementMetrics(t *testing.T) { + s := &Server{} + res, err := s.computeEngagementMetrics(context.Background(), AnalyticsRequest{Author: "a"}, []clustering.Item{ + {ID: "p", Timestamp: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), Likes: 2, Reposts: 3, Replies: 1, Quotes: 0}, + }) + if err != nil { + t.Fatalf("computeEngagementMetrics error: %v", err) + } + out := res.(EngagementMetricsResponse) + if len(out.Metrics) != 1 { + t.Fatalf("expected 1 metric, got %d", len(out.Metrics)) + } + m := out.Metrics[0] + if m.EngagementRate != 6 { + t.Fatalf("expected engagement rate 6, got %f", m.EngagementRate) + } +} + +func TestComputeSentimentTrends(t *testing.T) { + oll := &fakeOllama{summaries: []string{`{"positive":0.8,"negative":0.1,"neutral":0.1,"emotions":{"joy":1}}`}} + s := &Server{ollama: oll} + day := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + res, err := s.computeSentimentTrends(context.Background(), AnalyticsRequest{Author: "a"}, []clustering.Item{ + {ID: "a", Text: "great day", Timestamp: day}, + {ID: "b", Text: "wonderful", Timestamp: day.Add(2 * time.Hour)}, + {ID: "c", Text: "no ts"}, // zero timestamp, excluded from overall normalization + }) + if err != nil { + t.Fatalf("computeSentimentTrends error: %v", err) + } + out := res.(SentimentTrendsResponse) + if len(out.TimeSeries) != 1 { + t.Fatalf("expected 1 daily bucket, got %d", len(out.TimeSeries)) + } + if out.TimeSeries[0].PostCount != 2 { + t.Fatalf("expected bucket post count 2, got %d", out.TimeSeries[0].PostCount) + } + // Overall normalized by timestamped posts (2), not total (3). + if out.OverallSentiment.Positive != 0.8 { + t.Fatalf("expected overall positive 0.8, got %f", out.OverallSentiment.Positive) + } + if oll.summCalls == 0 { + t.Fatal("expected sentiment ollama call") + } +} + +func TestComputeSentimentTrendsNoTimestampedPosts(t *testing.T) { + s := &Server{ollama: &fakeOllama{summaries: []string{`{"positive":1,"negative":0,"neutral":0}`}}} + res, err := s.computeSentimentTrends(context.Background(), AnalyticsRequest{}, []clustering.Item{ + {ID: "a", Text: "x"}, // no timestamp + }) + if err != nil { + t.Fatalf("computeSentimentTrends error: %v", err) + } + out := res.(SentimentTrendsResponse) + // Division by zero avoided: overall stays 0 when there are no timestamped posts. + if out.OverallSentiment.Positive != 0 || out.OverallSentiment.Negative != 0 || out.OverallSentiment.Neutral != 0 { + t.Fatalf("expected zero overall sentiment, got %+v", out.OverallSentiment) + } +} + +func TestAnalyzeSentimentFallsBackOnOllamaError(t *testing.T) { + s := &Server{ollama: &fakeOllama{summErr: errSome}} + pos, neg, neu, _ := s.analyzeSentiment(context.Background(), []string{"text"}) + if pos == 0 || neg == 0 || neu == 0 { + t.Fatalf("expected non-zero fallback sentiment, got %f/%f/%f", pos, neg, neu) + } +} + +func TestGenerateTopicLabelFallsBackToGeneral(t *testing.T) { + s := &Server{ollama: &fakeOllama{summErr: errSome}} + if got := s.generateTopicLabel(context.Background(), []string{"sample"}); got != "General" { + t.Fatalf("expected General on error, got %q", got) + } + if got := s.generateTopicLabel(context.Background(), []string{}); got != "General" { + t.Fatalf("expected General for empty samples, got %q", got) + } +} diff --git a/internal/server/auth.go b/internal/server/auth.go index 94c75bc..07c8c24 100644 --- a/internal/server/auth.go +++ b/internal/server/auth.go @@ -2,12 +2,16 @@ package server import ( "context" - "crypto/ecdsa" + "crypto" + "crypto/ed25519" "crypto/rand" "crypto/x509" + "encoding/base64" "encoding/hex" "encoding/pem" + "errors" "fmt" + "strings" "time" "github.com/golang-jwt/jwt/v5" @@ -16,52 +20,66 @@ import ( type CDPAuth struct { keyName string - privateKey *ecdsa.PrivateKey + privateKey crypto.Signer signingMethod jwt.SigningMethod } func NewCDPAuth(keyName, privateKeyPEM string) (*CDPAuth, error) { + if keyName == "" { + return nil, errors.New("key name is required") + } + if privateKeyPEM == "" { + return nil, errors.New("private key is required") + } + + // Forgejo/GitHub secrets store multiline PEM as single line with literal \n. + // Convert to real newlines before decoding; idempotent if already multiline. + privateKeyPEM = strings.ReplaceAll(privateKeyPEM, "\\n", "\n") + + // Check if it's a base64-encoded Ed25519 key (64 bytes). + decoded, err := base64.StdEncoding.DecodeString(privateKeyPEM) + if err == nil && len(decoded) == 64 { + return &CDPAuth{ + keyName: keyName, + privateKey: ed25519.PrivateKey(decoded), + signingMethod: jwt.SigningMethodEdDSA, + }, nil + } + + // Otherwise, try PEM-decoded EC key block, _ := pem.Decode([]byte(privateKeyPEM)) if block == nil { return nil, fmt.Errorf("failed to decode PEM block") } - var signingMethod jwt.SigningMethod + // Try to parse as EC private key first privKey, err := x509.ParseECPrivateKey(block.Bytes) if err != nil { - parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) - if err != nil { - return nil, fmt.Errorf("failed to parse private key: %w", err) - } - var ok bool - privKey, ok = parsed.(*ecdsa.PrivateKey) - if !ok { - return nil, fmt.Errorf("not an ECDSA private key") - } - signingMethod = jwt.SigningMethodEdDSA - } else { - signingMethod = jwt.SigningMethodES256 + return nil, fmt.Errorf("failed to parse EC private key: %w", err) } + return &CDPAuth{ keyName: keyName, privateKey: privKey, - signingMethod: signingMethod, + signingMethod: jwt.SigningMethodES256, }, nil } func (a *CDPAuth) generateBearerToken(method, path string) (string, error) { nonceBytes := make([]byte, 16) if _, err := rand.Read(nonceBytes); err != nil { - return "", err + return "", fmt.Errorf("failed to generate nonce: %w", err) } nonce := hex.EncodeToString(nonceBytes) uri := fmt.Sprintf("%s api.cdp.coinbase.com%s", method, path) - now := time.Now().Unix() + now := time.Now() + claims := jwt.MapClaims{ "sub": a.keyName, "iss": "cdp", - "nbf": now, - "exp": now + 120, + "nbf": now.Unix(), + "iat": now.Unix(), + "exp": now.Add(120 * time.Second).Unix(), "uris": []string{uri}, } @@ -69,16 +87,43 @@ func (a *CDPAuth) generateBearerToken(method, path string) (string, error) { token.Header["kid"] = a.keyName token.Header["nonce"] = nonce - return token.SignedString(a.privateKey) + var signedToken string + var err error + + switch a.signingMethod { + case jwt.SigningMethodES256, jwt.SigningMethodEdDSA: + signedToken, err = token.SignedString(a.privateKey) + default: + return "", fmt.Errorf("unsupported signing method: %v", a.signingMethod) + } + + if err != nil { + return "", fmt.Errorf("failed to sign token: %w", err) + } + + return signedToken, nil } func (a *CDPAuth) GetAuthHeaders(ctx context.Context) (x402http.AuthHeaders, error) { - verify, _ := a.generateBearerToken("POST", "/platform/v2/x402/verify") - settle, _ := a.generateBearerToken("POST", "/platform/v2/x402/settle") - supported, _ := a.generateBearerToken("GET", "/platform/v2/x402/supported") + verify, err := a.generateBearerToken("POST", "/platform/v2/x402/verify") + if err != nil { + return x402http.AuthHeaders{}, fmt.Errorf("failed to generate verify token: %w", err) + } + settle, err := a.generateBearerToken("POST", "/platform/v2/x402/settle") + if err != nil { + return x402http.AuthHeaders{}, fmt.Errorf("failed to generate settle token: %w", err) + } + supported, err := a.generateBearerToken("GET", "/platform/v2/x402/supported") + if err != nil { + return x402http.AuthHeaders{}, fmt.Errorf("failed to generate supported token: %w", err) + } return x402http.AuthHeaders{ Verify: map[string]string{"Authorization": "Bearer " + verify}, Settle: map[string]string{"Authorization": "Bearer " + settle}, Supported: map[string]string{"Authorization": "Bearer " + supported}, }, nil } + +func (a *CDPAuth) GetSigningMethod() jwt.SigningMethod { + return a.signingMethod +} diff --git a/internal/server/auth_test.go b/internal/server/auth_test.go new file mode 100644 index 0000000..017aba1 --- /dev/null +++ b/internal/server/auth_test.go @@ -0,0 +1,213 @@ +package server + +import ( + "context" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "testing" + + "github.com/golang-jwt/jwt/v5" +) + +func generateTestECKey() string { + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + panic(err) + } + der, err := x509.MarshalECPrivateKey(privateKey) + if err != nil { + panic(err) + } + block := &pem.Block{ + Type: "EC PRIVATE KEY", + Bytes: der, + } + return string(pem.EncodeToMemory(block)) +} + +func TestNewCDPAuth_ECKey(t *testing.T) { + ecKey := generateTestECKey() + + auth, err := NewCDPAuth("test-key-id", ecKey) + if err != nil { + t.Fatalf("NewCDPAuth failed: %v", err) + } + + if auth.signingMethod != jwt.SigningMethodES256 { + t.Errorf("expected ES256, got %v", auth.signingMethod) + } + + if auth.keyName != "test-key-id" { + t.Errorf("expected keyName 'test-key-id', got %v", auth.keyName) + } + + if auth.privateKey == nil { + t.Error("privateKey should not be nil") + } + + _, ok := auth.privateKey.(*ecdsa.PrivateKey) + if !ok { + t.Error("privateKey should be *ecdsa.PrivateKey") + } +} + +func TestNewCDPAuth_Ed25519Key(t *testing.T) { + // Generate a valid Ed25519 key + _, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("failed to generate Ed25519 key: %v", err) + } + edKey := base64.StdEncoding.EncodeToString(privateKey) + + auth, err := NewCDPAuth("test-key-id", edKey) + if err != nil { + t.Fatalf("NewCDPAuth failed: %v", err) + } + + if auth.signingMethod != jwt.SigningMethodEdDSA { + t.Errorf("expected EdDSA, got %v", auth.signingMethod) + } + + if auth.privateKey == nil { + t.Error("privateKey should not be nil") + } + + _, ok := auth.privateKey.(ed25519.PrivateKey) + if !ok { + t.Error("privateKey should be ed25519.PrivateKey") + } +} + +func TestNewCDPAuth_InvalidKey(t *testing.T) { + _, err := NewCDPAuth("test-key-id", "invalid-key") + if err == nil { + t.Error("expected error for invalid key") + } +} + +func TestNewCDPAuth_EmptyKeyName(t *testing.T) { + _, err := NewCDPAuth("", "some-key") + if err == nil { + t.Error("expected error for empty key name") + } +} + +func TestNewCDPAuth_EmptyKey(t *testing.T) { + _, err := NewCDPAuth("test-key-id", "") + if err == nil { + t.Error("expected error for empty key") + } +} + +func TestGenerateBearerToken_ECKey(t *testing.T) { + ecKey := generateTestECKey() + + auth, err := NewCDPAuth("test-key-id", ecKey) + if err != nil { + t.Fatalf("NewCDPAuth failed: %v", err) + } + + token, err := auth.generateBearerToken("POST", "/platform/v2/x402/verify") + if err != nil { + t.Fatalf("generateBearerToken failed: %v", err) + } + + if token == "" { + t.Error("token should not be empty") + } + + // Parse and verify claims + parsed, err := jwt.Parse(token, func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodECDSA); !ok { + return nil, jwt.ErrSignatureInvalid + } + return auth.privateKey.(*ecdsa.PrivateKey).Public(), nil + }) + if err != nil { + t.Fatalf("failed to parse token: %v", err) + } + + if !parsed.Valid { + t.Error("token should be valid") + } + + claims, ok := parsed.Claims.(jwt.MapClaims) + if !ok { + t.Fatal("claims should be MapClaims") + } + + if claims["sub"] != "test-key-id" { + t.Errorf("expected sub 'test-key-id', got %v", claims["sub"]) + } + if claims["iss"] != "cdp" { + t.Errorf("expected iss 'cdp', got %v", claims["iss"]) + } + if claims["iat"] == nil { + t.Error("iat claim should be present") + } + if claims["nbf"] == nil { + t.Error("nbf claim should be present") + } + if claims["exp"] == nil { + t.Error("exp claim should be present") + } + if claims["uris"] == nil { + t.Error("uris claim should be present") + } + + uris, ok := claims["uris"].([]any) + if !ok || len(uris) != 1 { + t.Errorf("expected uris array with 1 element, got %v", claims["uris"]) + } + if uris[0] != "POST api.cdp.coinbase.com/platform/v2/x402/verify" { + t.Errorf("expected uri 'POST api.cdp.coinbase.com/platform/v2/x402/verify', got %v", uris[0]) + } + + // Check header + if parsed.Header["kid"] != "test-key-id" { + t.Errorf("expected kid 'test-key-id', got %v", parsed.Header["kid"]) + } + if parsed.Header["nonce"] == nil { + t.Error("nonce header should be present") + } +} + +func TestGetAuthHeaders(t *testing.T) { + ecKey := generateTestECKey() + + auth, err := NewCDPAuth("test-key-id", ecKey) + if err != nil { + t.Fatalf("NewCDPAuth failed: %v", err) + } + + headers, err := auth.GetAuthHeaders(context.TODO()) + if err != nil { + t.Fatalf("GetAuthHeaders failed: %v", err) + } + + if headers.Verify == nil || headers.Verify["Authorization"] == "" { + t.Error("Verify header should be present") + } + if headers.Settle == nil || headers.Settle["Authorization"] == "" { + t.Error("Settle header should be present") + } + if headers.Supported == nil || headers.Supported["Authorization"] == "" { + t.Error("Supported header should be present") + } + + // All should start with "Bearer " + for name, header := range map[string]string{ + "Verify": headers.Verify["Authorization"], + "Settle": headers.Settle["Authorization"], + "Supported": headers.Supported["Authorization"], + } { + if len(header) < 7 || header[:7] != "Bearer " { + t.Errorf("%s header should start with 'Bearer ', got %s", name, header) + } + } +} diff --git a/internal/server/backends.go b/internal/server/backends.go new file mode 100644 index 0000000..3182a3b --- /dev/null +++ b/internal/server/backends.go @@ -0,0 +1,76 @@ +package server + +import ( + "context" + "encoding/json" + "time" + + "github.com/bluesky-social/indigo/api/bsky" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/nacorid/naco-api/internal/activitypub" + "github.com/nacorid/naco-api/internal/actor" + "github.com/nacorid/naco-api/internal/clustering" + "github.com/nacorid/naco-api/internal/nostr" + "github.com/nacorid/naco-api/internal/store" + "github.com/nacorid/naco-api/internal/twitter" +) + +// The following interfaces let the server depend on platform behaviour rather than on the +// concrete client types. They exist so tests can inject fake backends; the production code in +// main.go keeps passing the real clients (which satisfy these interfaces). + +type blueskyBackend interface { + GetPost(ctx context.Context, actor, collection, recordKey string) (*bsky.FeedDefs_PostView, error) + GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*bsky.FeedDefs_FeedViewPost, error) + GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]*bsky.FeedDefs_FeedViewPost, error) + GetFeed(ctx context.Context, feedURI string, limit int) (*bsky.FeedGetFeed_Output, error) + ResolveUser(ctx context.Context, user string) (syntax.DID, error) + GetActor(ctx context.Context, author string) (*actor.Profile, error) + SearchActors(ctx context.Context, query string, limit int) ([]*actor.Profile, error) +} + +type apBackend interface { + GetPost(ctx context.Context, postID string) (*activitypub.Post, error) + GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*activitypub.Post, error) + GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]*activitypub.Post, error) + GetActor(ctx context.Context, author string) (*actor.Profile, error) + SearchActors(ctx context.Context, query string, limit int) ([]*actor.Profile, error) +} + +type nostrBackend interface { + GetPost(ctx context.Context, id string) (*nostr.Post, error) + GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*nostr.Post, error) + GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]*nostr.Post, error) + GetActor(ctx context.Context, author string) (*actor.Profile, error) + SearchActors(ctx context.Context, query string, limit int) ([]*actor.Profile, error) +} + +type twitterBackend interface { + GetPost(ctx context.Context, id string) (*twitter.Post, error) + GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*twitter.Post, error) + GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]*twitter.Post, error) +} + +type ollamaBackend interface { + EmbedTexts(ctx context.Context, posts []clustering.Item) ([][]float64, error) + SummarizeStyle(ctx context.Context, posts []clustering.Item) (string, error) +} + +// Store abstracts the persistence layer the server depends on. *store.Queries satisfies it; +// tests inject an in-memory fake so db-backed paths (style profiles, analytics, similar users) +// can be exercised without a live Postgres. +type Store interface { + GetAnalyticsCache(ctx context.Context, arg store.GetAnalyticsCacheParams) (json.RawMessage, error) + UpsertAnalyticsCache(ctx context.Context, arg store.UpsertAnalyticsCacheParams) error + GetAnalyticsPostsInRange(ctx context.Context, arg store.GetAnalyticsPostsInRangeParams) ([]store.GetAnalyticsPostsInRangeRow, error) + UpsertAnalyticsPosts(ctx context.Context, arg store.UpsertAnalyticsPostsParams) error + GetAnalyticsCoverageRanges(ctx context.Context, arg store.GetAnalyticsCoverageRangesParams) ([]store.GetAnalyticsCoverageRangesRow, error) + InsertAnalyticsCoverageRange(ctx context.Context, arg store.InsertAnalyticsCoverageRangeParams) error + GetStyleProfile(ctx context.Context, arg store.GetStyleProfileParams) (store.UserStyleProfile, error) + UpsertStyleProfile(ctx context.Context, arg store.UpsertStyleProfileParams) error + DeleteStylePosts(ctx context.Context, arg store.DeleteStylePostsParams) error + GetStylePosts(ctx context.Context, arg store.GetStylePostsParams) ([]store.GetStylePostsRow, error) + UpsertStylePosts(ctx context.Context, arg store.UpsertStylePostsParams) error + FindSimilarUsers(ctx context.Context, arg store.FindSimilarUsersParams) ([]store.FindSimilarUsersRow, error) + PruneStylePosts(ctx context.Context) error +} diff --git a/internal/server/cache.go b/internal/server/cache.go new file mode 100644 index 0000000..09b7f2a --- /dev/null +++ b/internal/server/cache.go @@ -0,0 +1,88 @@ +package server + +import ( + "sync" + "time" +) + +const postCacheTTL = 5 * time.Minute +const postCacheMaxItems = 10000 + +type ttlCacheEntry struct { + value any + exp time.Time +} + +type ttlCache struct { + mu sync.Mutex + items map[string]ttlCacheEntry + ttl time.Duration + maxItems int +} + +func newTTLCache(ttl time.Duration) *ttlCache { + return &ttlCache{ + items: make(map[string]ttlCacheEntry), + ttl: ttl, + maxItems: postCacheMaxItems, + } +} + +func (c *ttlCache) get(key string) (any, bool) { + c.mu.Lock() + defer c.mu.Unlock() + e, ok := c.items[key] + if !ok { + return nil, false + } + if time.Now().After(e.exp) { + delete(c.items, key) + return nil, false + } + return e.value, true +} + +func (c *ttlCache) set(key string, value any) { + c.mu.Lock() + defer c.mu.Unlock() + if len(c.items) >= c.maxItems { + // Evict the oldest expired entry, or if none expired, the first one. + var oldestKey string + var oldestTime time.Time + first := true + for k, e := range c.items { + if first || e.exp.Before(oldestTime) { + oldestKey = k + oldestTime = e.exp + first = false + } + } + if oldestKey != "" { + delete(c.items, oldestKey) + } + } + c.items[key] = ttlCacheEntry{value: value, exp: time.Now().Add(c.ttl)} +} + +// cachedFetch returns the cached value for key or calls fetch to obtain it, +// storing the result in the cache before returning. The zero value is returned +// on fetch error and not cached. +func cachedFetch[T any](c *ttlCache, key string, fetch func() (T, error)) (T, error) { + if v, ok := c.get(key); ok { + if typed, ok := v.(T); ok { + return typed, nil + } + } + val, err := fetch() + if err != nil { + var zero T + return zero, err + } + c.set(key, val) + return val, nil +} + +type latestCacheValue struct { + posts []*GetPostResponsePost + aid string +} diff --git a/internal/server/cache_version.go b/internal/server/cache_version.go new file mode 100644 index 0000000..40ea07e --- /dev/null +++ b/internal/server/cache_version.go @@ -0,0 +1,16 @@ +package server + +const ( + CurrentModelVersion = 1 + CurrentSchemaVersion = 1 + CurrentAlgorithmVersion = 1 +) + +// IsProfileStale returns true if the profile's versions are older than the current versions. +// This is used to mark profiles stale for lazy recompute on the next request. +// Currently all three version fields are 1 since this is the first version of the combination. +func IsProfileStale(model, schema, algorithm int) bool { + return model != CurrentModelVersion || + schema != CurrentSchemaVersion || + algorithm != CurrentAlgorithmVersion +} diff --git a/internal/server/coverage_test.go b/internal/server/coverage_test.go new file mode 100644 index 0000000..3f37f62 --- /dev/null +++ b/internal/server/coverage_test.go @@ -0,0 +1,100 @@ +package server + +import ( + "testing" + "time" +) + +func rangeOf(t *testing.T, s, e string) timeRange { + t.Helper() + layout := "2006-01-02 15:04" + start, err := time.Parse(layout, s) + if err != nil { + t.Fatalf("bad start %q: %v", s, err) + } + end, err := time.Parse(layout, e) + if err != nil { + t.Fatalf("bad end %q: %v", e, err) + } + return timeRange{start: start, end: end} +} + +func assertRanges(t *testing.T, got []timeRange, want ...timeRange) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("got %d ranges, want %d: %+v", len(got), len(want), got) + } + for i := range want { + if !got[i].start.Equal(want[i].start) || !got[i].end.Equal(want[i].end) { + t.Fatalf("range %d: got [%v, %v], want [%v, %v]", + i, got[i].start, got[i].end, want[i].start, want[i].end) + } + } +} + +func TestUncoveredRangesNoCoverage(t *testing.T) { + r := rangeOf(t, "2026-01-01 00:00", "2026-01-31 00:00") + got := uncoveredRanges(r.start, r.end, nil) + assertRanges(t, got, r) +} + +func TestUncoveredRangesFullyCovered(t *testing.T) { + req := rangeOf(t, "2026-01-10 00:00", "2026-01-20 00:00") + cov := rangeOf(t, "2026-01-01 00:00", "2026-01-31 00:00") + got := uncoveredRanges(req.start, req.end, []timeRange{cov}) + assertRanges(t, got) +} + +func TestUncoveredRangesLeftOnly(t *testing.T) { + req := rangeOf(t, "2026-01-01 00:00", "2026-01-31 00:00") + cov := rangeOf(t, "2026-01-15 00:00", "2026-01-31 00:00") + got := uncoveredRanges(req.start, req.end, []timeRange{cov}) + want := rangeOf(t, "2026-01-01 00:00", "2026-01-15 00:00") + assertRanges(t, got, want) +} + +func TestUncoveredRangesRightOnly(t *testing.T) { + req := rangeOf(t, "2026-01-01 00:00", "2026-01-31 00:00") + cov := rangeOf(t, "2026-01-01 00:00", "2026-01-15 00:00") + got := uncoveredRanges(req.start, req.end, []timeRange{cov}) + want := rangeOf(t, "2026-01-15 00:00", "2026-01-31 00:00") + assertRanges(t, got, want) +} + +func TestUncoveredRangesBothSides(t *testing.T) { + req := rangeOf(t, "2026-01-01 00:00", "2026-01-31 00:00") + cov := rangeOf(t, "2026-01-10 00:00", "2026-01-20 00:00") + wantLeft := rangeOf(t, "2026-01-01 00:00", "2026-01-10 00:00") + wantRight := rangeOf(t, "2026-01-20 00:00", "2026-01-31 00:00") + got := uncoveredRanges(req.start, req.end, []timeRange{cov}) + assertRanges(t, got, wantLeft, wantRight) +} + +// Regression: disjoint covered intervals must not be merged into a single +// contiguous window. A request landing in the gap between two covered ranges +// must be reported as uncovered so it is fetched rather than served from an +// incomplete cache. +func TestUncoveredRangesDisjointGap(t *testing.T) { + req := rangeOf(t, "2026-01-15 00:00", "2026-01-20 00:00") + first := rangeOf(t, "2026-01-01 00:00", "2026-01-15 00:00") + second := rangeOf(t, "2026-01-20 00:00", "2026-01-31 00:00") + got := uncoveredRanges(req.start, req.end, []timeRange{first, second}) + assertRanges(t, got, req) +} + +func TestUncoveredRangesGapSurroundedByCoverage(t *testing.T) { + req := rangeOf(t, "2026-01-10 00:00", "2026-01-25 00:00") + first := rangeOf(t, "2026-01-01 00:00", "2026-01-12 00:00") + second := rangeOf(t, "2026-01-20 00:00", "2026-01-31 00:00") + want := rangeOf(t, "2026-01-12 00:00", "2026-01-20 00:00") + got := uncoveredRanges(req.start, req.end, []timeRange{first, second}) + assertRanges(t, got, want) +} + +func TestUncoveredRangesOverlappingCoverage(t *testing.T) { + req := rangeOf(t, "2026-01-10 00:00", "2026-01-20 00:00") + first := rangeOf(t, "2026-01-01 00:00", "2026-01-15 00:00") + second := rangeOf(t, "2026-01-12 00:00", "2026-01-31 00:00") + got := uncoveredRanges(req.start, req.end, []timeRange{first, second}) + assertRanges(t, got) +} diff --git a/internal/server/mcp_test.go b/internal/server/mcp_test.go new file mode 100644 index 0000000..1ff56b1 --- /dev/null +++ b/internal/server/mcp_test.go @@ -0,0 +1,855 @@ +package server + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + "github.com/bluesky-social/indigo/api/bsky" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/bluesky-social/indigo/lex/util" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/nacorid/naco-api/internal/activitypub" + "github.com/nacorid/naco-api/internal/actor" + "github.com/nacorid/naco-api/internal/clustering" + "github.com/nacorid/naco-api/internal/nostr" + "github.com/nacorid/naco-api/internal/store" + "github.com/nacorid/naco-api/internal/twitter" +) + +// ---- fakes ---- + +type fakeBluesky struct { + postView *bsky.FeedDefs_PostView + feedViews []*bsky.FeedDefs_FeedViewPost + profile *actor.Profile + actors []*actor.Profile + feedOut *bsky.FeedGetFeed_Output + err error + calls int + lastLimit int +} + +func (f *fakeBluesky) GetPost(ctx context.Context, actor, collection, recordKey string) (*bsky.FeedDefs_PostView, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + return f.postView, nil +} +func (f *fakeBluesky) GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*bsky.FeedDefs_FeedViewPost, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + return f.feedViews, nil +} +func (f *fakeBluesky) GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]*bsky.FeedDefs_FeedViewPost, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + return f.feedViews, nil +} +func (f *fakeBluesky) GetFeed(ctx context.Context, feedURI string, limit int) (*bsky.FeedGetFeed_Output, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + return f.feedOut, nil +} +func (f *fakeBluesky) ResolveUser(ctx context.Context, user string) (syntax.DID, error) { + f.calls++ + if f.err != nil { + return "", f.err + } + return syntax.ParseDID("did:plc:abc") +} +func (f *fakeBluesky) GetActor(ctx context.Context, author string) (*actor.Profile, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + return f.profile, nil +} +func (f *fakeBluesky) SearchActors(ctx context.Context, query string, limit int) ([]*actor.Profile, error) { + f.calls++ + f.lastLimit = limit + if f.err != nil { + return nil, f.err + } + return f.actors, nil +} + +type fakeAP struct { + post *activitypub.Post + posts []*activitypub.Post + profile *actor.Profile + actors []*actor.Profile + err error + calls int + lastLimit int +} + +func (f *fakeAP) GetPost(ctx context.Context, postID string) (*activitypub.Post, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + return f.post, nil +} +func (f *fakeAP) GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*activitypub.Post, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + return f.posts, nil +} +func (f *fakeAP) GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]*activitypub.Post, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + out := make([]*activitypub.Post, 0, len(f.posts)) + out = append(out, f.posts...) + return out, nil +} +func (f *fakeAP) GetActor(ctx context.Context, author string) (*actor.Profile, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + return f.profile, nil +} +func (f *fakeAP) SearchActors(ctx context.Context, query string, limit int) ([]*actor.Profile, error) { + f.calls++ + f.lastLimit = limit + if f.err != nil { + return nil, f.err + } + return f.actors, nil +} + +type fakeNostr struct { + post *nostr.Post + posts []*nostr.Post + profile *actor.Profile + actors []*actor.Profile + err error + calls int + lastLimit int +} + +func (f *fakeNostr) GetPost(ctx context.Context, id string) (*nostr.Post, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + return f.post, nil +} +func (f *fakeNostr) GetLatestNPosts(ctx context.Context, author string, limit int) ([]*nostr.Post, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + return f.posts, nil +} +func (f *fakeNostr) GetPostsInTimeframe(ctx context.Context, author string, start, end time.Time, limit int) ([]*nostr.Post, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + return f.posts, nil +} +func (f *fakeNostr) GetActor(ctx context.Context, author string) (*actor.Profile, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + return f.profile, nil +} +func (f *fakeNostr) SearchActors(ctx context.Context, query string, limit int) ([]*actor.Profile, error) { + f.calls++ + f.lastLimit = limit + if f.err != nil { + return nil, f.err + } + return f.actors, nil +} + +type fakeTwitter struct { + post *twitter.Post + posts []*twitter.Post + profile *actor.Profile + actors []*actor.Profile + err error + calls int +} + +func (f *fakeTwitter) GetPost(ctx context.Context, id string) (*twitter.Post, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + return f.post, nil +} +func (f *fakeTwitter) GetLatestNPosts(ctx context.Context, author string, limit int) ([]*twitter.Post, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + return f.posts, nil +} +func (f *fakeTwitter) GetPostsInTimeframe(ctx context.Context, author string, start, end time.Time, limit int) ([]*twitter.Post, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + return f.posts, nil +} +func (f *fakeTwitter) GetActor(ctx context.Context, author string) (*actor.Profile, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + return f.profile, nil +} +func (f *fakeTwitter) SearchActors(ctx context.Context, query string, limit int) ([]*actor.Profile, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + return f.actors, nil +} + +// fakeOllama implements ollamaBackend. Its behavior is driven by a function so +// tests can simulate embedding, sentiment, and topic-label responses. +type fakeOllama struct { + embeddings [][]float64 + embedErr error + summaries []string + summaryIdx int + summErr error + embedCalls int + summCalls int +} + +func (f *fakeOllama) EmbedTexts(ctx context.Context, posts []clustering.Item) ([][]float64, error) { + f.embedCalls++ + if f.embedErr != nil { + return nil, f.embedErr + } + if f.embeddings != nil { + return f.embeddings, nil + } + out := make([][]float64, len(posts)) + for i := range posts { + out[i] = []float64{float64(i + 1), 0.5} + } + return out, nil +} + +func (f *fakeOllama) SummarizeStyle(ctx context.Context, posts []clustering.Item) (string, error) { + f.summCalls++ + if f.summErr != nil { + return "", f.summErr + } + if f.summaries != nil { + if f.summaryIdx >= len(f.summaries) { + f.summaryIdx = len(f.summaries) - 1 + } + out := f.summaries[f.summaryIdx] + f.summaryIdx++ + return out, nil + } + return "summary", nil +} + +// ---- helpers ---- + +func newTestServer(bsky blueskyBackend, ap apBackend, nstr nostrBackend, tw twitterBackend) *Server { + return newServerWith(bsky, ap, nstr, tw, newFakeStore(), &fakeOllama{}) +} + +// newServerWith builds a Server with explicit store and ollama backends so tests +// can drive db-backed tool paths (style profiles, analytics, similar users). +func newServerWith(bsky blueskyBackend, ap apBackend, nstr nostrBackend, tw twitterBackend, st Store, oll ollamaBackend) *Server { + return &Server{ + bskyClient: bsky, + apClient: ap, + nostrClient: nstr, + twitterClient: tw, + db: st, + ollama: oll, + enrichEnabled: false, + enrichSem: make(chan struct{}, 4), + postCache: newTTLCache(postCacheTTL), + + analyticsTTLEngagement: time.Hour, + analyticsTTLPatterns: time.Hour, + analyticsTTLTopics: time.Hour, + analyticsTTLSentiment: time.Hour, + analyticsTTLDrivers: time.Hour, + } +} + +func callTool(s *Server, name string, args any) (*mcp.CallToolResult, error) { + raw, err := json.Marshal(args) + if err != nil { + return nil, err + } + req := &mcp.CallToolRequest{Params: &mcp.CallToolParamsRaw{Name: name, Arguments: raw}} + ctx := context.Background() + switch name { + case "getActor": + return s.getActor(ctx, req) + case "searchActors": + return s.searchActors(ctx, req) + case "getPost": + return s.getPost(ctx, req) + case "getLatestPosts": + return s.getLatestPosts(ctx, req) + case "getPostsInTime": + return s.getPostsInTime(ctx, req) + case "getFeed": + return s.getFeed(ctx, req) + case "getStyleProfile": + return s.getStyleProfile(ctx, req) + case "findSimilarUsers": + return s.getSimilarProfiles(ctx, req) + case "getEngagementMetrics": + return s.getEngagementMetrics(ctx, req) + case "getPostingPatterns": + return s.getPostingPatterns(ctx, req) + case "getTopicModeling": + return s.getTopicModeling(ctx, req) + case "getSentimentTrends": + return s.getSentimentTrends(ctx, req) + case "getEngagementDrivers": + return s.getEngagementDrivers(ctx, req) + } + return nil, fmt.Errorf("unknown tool %q", name) +} + +func textOf(t *testing.T, res *mcp.CallToolResult) string { + t.Helper() + if res == nil || len(res.Content) == 0 { + t.Fatal("empty result") + } + tc, ok := res.Content[0].(*mcp.TextContent) + if !ok { + t.Fatalf("expected TextContent, got %T", res.Content[0]) + } + return tc.Text +} + +func bskyPostView(text string) *bsky.FeedDefs_PostView { + return &bsky.FeedDefs_PostView{ + Author: &bsky.ActorDefs_ProfileViewBasic{Did: "did:plc:abc"}, + Record: &util.LexiconTypeDecoder{Val: &bsky.FeedPost{ + Text: text, + CreatedAt: "2024-01-01T00:00:00Z", + }}, + } +} + +// ---- tests ---- + +func TestGetActorDispatch(t *testing.T) { + cases := []struct { + author string + fake func() (blueskyBackend, apBackend, nostrBackend, twitterBackend) + profile *actor.Profile + }{ + {"did:plc:abc", func() (blueskyBackend, apBackend, nostrBackend, twitterBackend) { + return &fakeBluesky{profile: &actor.Profile{Platform: "Bluesky", Handle: "a"}}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{} + }, &actor.Profile{Platform: "Bluesky"}}, + {"@alice@mastodon.social", func() (blueskyBackend, apBackend, nostrBackend, twitterBackend) { + return &fakeBluesky{}, &fakeAP{profile: &actor.Profile{Platform: "ActivityPub"}}, &fakeNostr{}, &fakeTwitter{} + }, &actor.Profile{Platform: "ActivityPub"}}, + {"npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", func() (blueskyBackend, apBackend, nostrBackend, twitterBackend) { + return &fakeBluesky{}, &fakeAP{}, &fakeNostr{profile: &actor.Profile{Platform: "Nostr"}}, &fakeTwitter{} + }, &actor.Profile{Platform: "Nostr"}}, + } + for _, c := range cases { + s := newTestServer(c.fake()) + res, err := callTool(s, "getActor", GetActorRequest{Author: c.author}) + if err != nil { + t.Fatalf("%s: getActor error: %v", c.author, err) + } + var out GetActorResponse + if err := json.Unmarshal([]byte(textOf(t, res)), &out); err != nil { + t.Fatalf("%s: unmarshal: %v", c.author, err) + } + if out.Actor.Platform != c.profile.Platform { + t.Fatalf("%s: expected platform %s, got %s", c.author, c.profile.Platform, out.Actor.Platform) + } + } +} + +func TestSearchActorsAllSucceed(t *testing.T) { + bsky := &fakeBluesky{actors: []*actor.Profile{{Platform: "Bluesky", Handle: "a"}}} + ap := &fakeAP{actors: []*actor.Profile{{Platform: "ActivityPub", Handle: "b"}}} + nstr := &fakeNostr{actors: []*actor.Profile{{Platform: "Nostr", Handle: "c"}}} + tw := &fakeTwitter{actors: []*actor.Profile{{Platform: "Twitter", Handle: "d"}}} + s := newTestServer(bsky, ap, nstr, tw) + res, err := callTool(s, "searchActors", SearchActorsRequest{Query: "alice", Limit: 10}) + if err != nil { + t.Fatalf("searchActors error: %v", err) + } + var out SearchActorsResponse + if err := json.Unmarshal([]byte(textOf(t, res)), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(out.Results) != 3 { + t.Fatalf("expected 3 merged results, got %d", len(out.Results)) + } +} + +func TestSearchActorsAllFail(t *testing.T) { + bsky := &fakeBluesky{err: fmt.Errorf("boom")} + ap := &fakeAP{err: fmt.Errorf("boom")} + nstr := &fakeNostr{err: fmt.Errorf("boom")} + tw := &fakeTwitter{err: fmt.Errorf("boom")} + s := newTestServer(bsky, ap, nstr, tw) + if _, err := callTool(s, "searchActors", SearchActorsRequest{Query: "alice"}); err == nil { + t.Fatal("expected error when all platforms fail") + } +} + +func TestSearchActorsPartialSuccess(t *testing.T) { + bsky := &fakeBluesky{err: fmt.Errorf("boom")} + ap := &fakeAP{actors: []*actor.Profile{{Platform: "ActivityPub", Handle: "b"}}} + nstr := &fakeNostr{actors: []*actor.Profile{{Platform: "Nostr", Handle: "c"}}} + tw := &fakeTwitter{err: fmt.Errorf("boom")} + s := newTestServer(bsky, ap, nstr, tw) + res, err := callTool(s, "searchActors", SearchActorsRequest{Query: "alice"}) + if err != nil { + t.Fatalf("expected partial success, got error: %v", err) + } + var out SearchActorsResponse + if err := json.Unmarshal([]byte(textOf(t, res)), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(out.Results) != 2 { + t.Fatalf("expected 2 partial results, got %d", len(out.Results)) + } +} + +func TestGetPostTwitter(t *testing.T) { + tw := &fakeTwitter{post: &twitter.Post{ID: "123", Content: "hello twitter", Author: "@alice"}} + s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, tw) + _, err := callTool(s, "getPost", GetPostRequest{Author: "@alice", RecordKey: "123"}) + if err == nil { + t.Fatalf("expected twitter_unavailable error, got nil") + } + if !strings.Contains(err.Error(), "twitter_unavailable") { + t.Fatalf("expected twitter_unavailable error, got: %v", err) + } +} + +func TestGetLatestPostsAP(t *testing.T) { + ap := &fakeAP{posts: []*activitypub.Post{ + {Author: "alice@mastodon.social", Content: "one", CreatedAt: time.Now()}, + {Author: "alice@mastodon.social", Content: "two", CreatedAt: time.Now()}, + }} + s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}) + res, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "@alice@mastodon.social", Limit: 5}) + if err != nil { + t.Fatalf("getLatestPosts error: %v", err) + } + var out GetLatestPostsResponse + if err := json.Unmarshal([]byte(textOf(t, res)), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(out.Posts) != 2 { + t.Fatalf("expected 2 posts, got %d", len(out.Posts)) + } +} + +func TestGetPostsInTimeNostr(t *testing.T) { + nstr := &fakeNostr{posts: []*nostr.Post{{ID: "e1", Author: "npubX", Content: "nostr post", CreatedAt: time.Now()}}} + s := newTestServer(&fakeBluesky{}, &fakeAP{}, nstr, &fakeTwitter{}) + res, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{ + Author: "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", + StartTime: "2024-01-01T00:00:00Z", + EndTime: "2024-12-31T00:00:00Z", + Limit: 5, + }) + if err != nil { + t.Fatalf("getPostsInTime error: %v", err) + } + var out GetPostsInTimeResponse + if err := json.Unmarshal([]byte(textOf(t, res)), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(out.Posts) != 1 || !strings.Contains(out.Posts[0].Content, "nostr post") { + t.Fatalf("unexpected posts: %+v", out.Posts) + } +} + +func TestGetFeedBluesky(t *testing.T) { + bsky := &fakeBluesky{feedOut: &bsky.FeedGetFeed_Output{Feed: []*bsky.FeedDefs_FeedViewPost{{Post: bskyPostView("feed post")}}}} + s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) + res, err := callTool(s, "getFeed", GetFeedRequest{URI: "at://did:plc:abc/app.bsky.feed.generator/abc"}) + if err != nil { + t.Fatalf("getFeed error: %v", err) + } + var out GetFeedResponse + if err := json.Unmarshal([]byte(textOf(t, res)), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(out.Posts) != 1 || out.Posts[0].Content != "feed post" { + t.Fatalf("unexpected feed posts: %+v", out.Posts) + } +} + +// ---- getPost success paths ---- + +func TestGetPostBlueskyWithCollection(t *testing.T) { + bsky := &fakeBluesky{postView: bskyPostView("hello bluesky with collection")} + s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) + res, err := callTool(s, "getPost", GetPostRequest{Author: "did:plc:abc", RecordKey: "k", Collection: "app.bsky.feed.post"}) + if err != nil { + t.Fatalf("getPost bluesky with collection error: %v", err) + } + if !strings.Contains(textOf(t, res), "hello bluesky with collection") { + t.Fatalf("unexpected bluesky post text: %s", textOf(t, res)) + } + if bsky.calls != 1 { + t.Fatalf("expected 1 backend call, got %d", bsky.calls) + } +} + +func TestGetPostActivityPub(t *testing.T) { + ap := &fakeAP{post: &activitypub.Post{Author: "alice@mastodon.social", Content: "hello activitypub", CreatedAt: time.Now()}} + s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}) + res, err := callTool(s, "getPost", GetPostRequest{Author: "@alice@mastodon.social", RecordKey: "123"}) + if err != nil { + t.Fatalf("getPost activitypub error: %v", err) + } + if !strings.Contains(textOf(t, res), "hello activitypub") { + t.Fatalf("unexpected activitypub post text: %s", textOf(t, res)) + } + if ap.calls != 1 { + t.Fatalf("expected 1 backend call, got %d", ap.calls) + } +} + +func TestGetPostNostr(t *testing.T) { + nstr := &fakeNostr{post: &nostr.Post{ID: "n1", Author: "npub1x", Content: "nostr hello"}} + s := newTestServer(&fakeBluesky{}, &fakeAP{}, nstr, &fakeTwitter{}) + res, err := callTool(s, "getPost", GetPostRequest{Author: "npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", RecordKey: "n1"}) + if err != nil { + t.Fatalf("getPost nostr error: %v", err) + } + if !strings.Contains(textOf(t, res), "nostr hello") { + t.Fatalf("unexpected nostr post text: %s", textOf(t, res)) + } + if nstr.calls != 1 { + t.Fatalf("expected 1 backend call, got %d", nstr.calls) + } +} + +func TestGetPostBackendError(t *testing.T) { + bsky := &fakeBluesky{err: fmt.Errorf("backend failure")} + s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) + _, err := callTool(s, "getPost", GetPostRequest{Author: "did:plc:abc", RecordKey: "k"}) + if err == nil || !strings.Contains(err.Error(), "backend failure") { + t.Fatalf("expected backend error, got %v", err) + } +} + +// ---- getLatestPosts success paths ---- + +func TestGetLatestPostsBluesky(t *testing.T) { + bsky := &fakeBluesky{feedViews: []*bsky.FeedDefs_FeedViewPost{{Post: bskyPostView("bluesky post 1")}, {Post: bskyPostView("bluesky post 2")}}} + s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) + res, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "did:plc:abc", Limit: 5}) + if err != nil { + t.Fatalf("getLatestPosts bluesky error: %v", err) + } + var out GetLatestPostsResponse + if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(out.Posts) != 2 { + t.Fatalf("expected 2 posts, got %d", len(out.Posts)) + } + if !strings.Contains(out.Posts[0].Content, "bluesky post 1") { + t.Fatalf("unexpected post content: %s", out.Posts[0].Content) + } + if bsky.calls != 1 { + t.Fatalf("expected 1 backend call, got %d", bsky.calls) + } +} + +func TestGetLatestPostsNostr(t *testing.T) { + nstr := &fakeNostr{posts: []*nostr.Post{{ID: "n1", Author: "npub1x", Content: "nostr latest 1", CreatedAt: time.Now()}, {ID: "n2", Author: "npub1x", Content: "nostr latest 2", CreatedAt: time.Now()}}} + s := newTestServer(&fakeBluesky{}, &fakeAP{}, nstr, &fakeTwitter{}) + res, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", Limit: 5}) + if err != nil { + t.Fatalf("getLatestPosts nostr error: %v", err) + } + var out GetLatestPostsResponse + if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(out.Posts) != 2 { + t.Fatalf("expected 2 posts, got %d", len(out.Posts)) + } + if !strings.Contains(out.Posts[0].Content, "nostr latest") { + t.Fatalf("unexpected post content: %s", out.Posts[0].Content) + } + if nstr.calls != 1 { + t.Fatalf("expected 1 backend call, got %d", nstr.calls) + } +} + +// ---- getPostsInTime success paths ---- + +func TestGetPostsInTimeBluesky(t *testing.T) { + bsky := &fakeBluesky{feedViews: []*bsky.FeedDefs_FeedViewPost{{Post: bskyPostView("bluesky timed 1")}, {Post: bskyPostView("bluesky timed 2")}}} + s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) + res, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "did:plc:abc", StartTime: "2024-01-01T00:00:00Z", EndTime: "2024-12-31T00:00:00Z", Limit: 5}) + if err != nil { + t.Fatalf("getPostsInTime bluesky error: %v", err) + } + var out GetPostsInTimeResponse + if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(out.Posts) != 2 { + t.Fatalf("expected 2 posts, got %d", len(out.Posts)) + } + if bsky.calls != 1 { + t.Fatalf("expected 1 backend call, got %d", bsky.calls) + } +} + +func TestGetPostsInTimeActivityPub(t *testing.T) { + ap := &fakeAP{posts: []*activitypub.Post{{Author: "a@m.social", Content: "ap timed 1", CreatedAt: time.Date(2024, 6, 15, 0, 0, 0, 0, time.UTC)}, {Author: "a@m.social", Content: "ap timed 2", CreatedAt: time.Date(2024, 7, 15, 0, 0, 0, 0, time.UTC)}}} + s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}) + res, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "@a@mastodon.social", StartTime: "2024-01-01T00:00:00Z", EndTime: "2024-12-31T00:00:00Z", Limit: 5}) + if err != nil { + t.Fatalf("getPostsInTime activitypub error: %v", err) + } + var out GetPostsInTimeResponse + if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(out.Posts) != 2 { + t.Fatalf("expected 2 posts, got %d", len(out.Posts)) + } + if ap.calls != 1 { + t.Fatalf("expected 1 backend call, got %d", ap.calls) + } +} + +// ---- getFeed tests ---- + +func TestGetFeedBlueskyWithCursor(t *testing.T) { + // Test that feed pagination works + bsky := &fakeBluesky{feedOut: &bsky.FeedGetFeed_Output{Feed: []*bsky.FeedDefs_FeedViewPost{{Post: bskyPostView("feed post 1")}, {Post: bskyPostView("feed post 2")}}}} + s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) + res, err := callTool(s, "getFeed", GetFeedRequest{URI: "at://did:plc:abc/app.bsky.feed.generator/abc"}) + if err != nil { + t.Fatalf("getFeed error: %v", err) + } + var out GetFeedResponse + if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(out.Posts) != 2 { + t.Fatalf("expected 2 posts, got %d", len(out.Posts)) + } +} + +// ---- getActor tests ---- + +func TestGetActorBlueskyDID(t *testing.T) { + bsky := &fakeBluesky{profile: &actor.Profile{Platform: "Bluesky", Handle: "user.bsky.social", DisplayName: "Test User"}} + s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) + res, err := callTool(s, "getActor", GetActorRequest{Author: "did:plc:abc123"}) + if err != nil { + t.Fatalf("getActor bluesky DID error: %v", err) + } + var out GetActorResponse + if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if out.Actor.Platform != "Bluesky" { + t.Fatalf("expected Bluesky platform, got %s", out.Actor.Platform) + } + if out.Actor.Handle != "user.bsky.social" { + t.Fatalf("expected handle user.bsky.social, got %s", out.Actor.Handle) + } +} + +func TestGetActorActivityPub(t *testing.T) { + ap := &fakeAP{profile: &actor.Profile{Platform: "ActivityPub", Handle: "alice@mastodon.social", DisplayName: "Alice"}} + s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}) + res, err := callTool(s, "getActor", GetActorRequest{Author: "@alice@mastodon.social"}) + if err != nil { + t.Fatalf("getActor activitypub error: %v", err) + } + var out GetActorResponse + if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if out.Actor.Platform != "ActivityPub" { + t.Fatalf("expected ActivityPub platform, got %s", out.Actor.Platform) + } +} + +func TestGetActorNostr(t *testing.T) { + nstr := &fakeNostr{profile: &actor.Profile{Platform: "Nostr", Handle: "npub1xxx", DisplayName: "Nostr User"}} + s := newTestServer(&fakeBluesky{}, &fakeAP{}, nstr, &fakeTwitter{}) + res, err := callTool(s, "getActor", GetActorRequest{Author: "npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}) + if err != nil { + t.Fatalf("getActor nostr error: %v", err) + } + var out GetActorResponse + if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if out.Actor.Platform != "Nostr" { + t.Fatalf("expected Nostr platform, got %s", out.Actor.Platform) + } +} + +func TestGetActorTwitter(t *testing.T) { + // Twitter getActor is blocked by policy (same as other Twitter tools) + tw := &fakeTwitter{profile: &actor.Profile{Platform: "Twitter", Handle: "@alice", DisplayName: "Alice Twitter"}} + s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, tw) + _, err := callTool(s, "getActor", GetActorRequest{Author: "@alice"}) + if err == nil || !strings.Contains(err.Error(), "twitter_unavailable") { + t.Fatalf("expected twitter_unavailable error, got %v", err) + } + if tw.calls != 0 { + t.Fatalf("expected 0 backend calls, got %d", tw.calls) + } +} + +// ---- searchActors edge cases ---- + +func TestSearchActorsLimitClamp(t *testing.T) { + bsky := &fakeBluesky{actors: []*actor.Profile{{Platform: "Bluesky", Handle: "a"}}} + ap := &fakeAP{actors: []*actor.Profile{{Platform: "ActivityPub", Handle: "b"}}} + nstr := &fakeNostr{actors: []*actor.Profile{{Platform: "Nostr", Handle: "c"}}} + s := newTestServer(bsky, ap, nstr, &fakeTwitter{}) + + // Limit > 50 should be clamped to 50 in the backend call. + if _, err := callTool(s, "searchActors", SearchActorsRequest{Query: "test", Limit: 100}); err != nil { + t.Fatalf("searchActors error: %v", err) + } + if bsky.lastLimit != 50 || ap.lastLimit != 50 || nstr.lastLimit != 50 { + t.Fatalf("expected limit clamped to 50, got bsky=%d ap=%d nstr=%d", bsky.lastLimit, ap.lastLimit, nstr.lastLimit) + } + + // Limit 0 should default to 10. + bsky.lastLimit, ap.lastLimit, nstr.lastLimit = 0, 0, 0 + if _, err := callTool(s, "searchActors", SearchActorsRequest{Query: "test", Limit: 0}); err != nil { + t.Fatalf("searchActors error with limit 0: %v", err) + } + if bsky.lastLimit != 10 || ap.lastLimit != 10 || nstr.lastLimit != 10 { + t.Fatalf("expected default limit 10, got bsky=%d ap=%d nstr=%d", bsky.lastLimit, ap.lastLimit, nstr.lastLimit) + } +} + +func TestSearchActorsTwitterExcludedFromSearch(t *testing.T) { + // Twitter is not supported for search in the current implementation + bsky := &fakeBluesky{actors: []*actor.Profile{{Platform: "Bluesky", Handle: "a"}}} + ap := &fakeAP{actors: []*actor.Profile{{Platform: "ActivityPub", Handle: "b"}}} + nstr := &fakeNostr{actors: []*actor.Profile{{Platform: "Nostr", Handle: "c"}}} + tw := &fakeTwitter{actors: []*actor.Profile{{Platform: "Twitter", Handle: "d"}}} + s := newTestServer(bsky, ap, nstr, tw) + res, err := callTool(s, "searchActors", SearchActorsRequest{Query: "test", Limit: 10}) + if err != nil { + t.Fatalf("searchActors error: %v", err) + } + var out SearchActorsResponse + if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + // Twitter should be excluded (only 3 results) + if len(out.Results) != 3 { + t.Fatalf("expected 3 results (Twitter excluded), got %d", len(out.Results)) + } +} + +func TestSearchActorsNormalizationAndDedup(t *testing.T) { + // Test that handles normalize correctly and deduplicate + bsky := &fakeBluesky{actors: []*actor.Profile{{Platform: "Bluesky", Handle: "user.bsky.social", DisplayName: "User"}}} + ap := &fakeAP{actors: []*actor.Profile{{Platform: "ActivityPub", Handle: "user@mastodon.social", DisplayName: "User"}}} + s := newTestServer(bsky, ap, &fakeNostr{}, &fakeTwitter{}) + res, err := callTool(s, "searchActors", SearchActorsRequest{Query: "user", Limit: 10}) + if err != nil { + t.Fatalf("searchActors error: %v", err) + } + var out SearchActorsResponse + if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + // Should be deduped by normalized handle + if len(out.Results) != 2 { + t.Fatalf("expected 2 deduped results, got %d: %+v", len(out.Results), out.Results) + } +} + +// ---- cache tests ---- + +func TestPostCacheDifferentKeys(t *testing.T) { + ap := &fakeAP{posts: []*activitypub.Post{{Author: "a@m.social", Content: "post1", CreatedAt: time.Now()}}} + s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}) + // Different authors should not share cache + _, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "@a@mastodon.social", Limit: 5}) + if err != nil { + t.Fatalf("getLatestPosts error: %v", err) + } + _, err = callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "@b@mastodon.social", Limit: 5}) + if err != nil { + t.Fatalf("getLatestPosts error for different author: %v", err) + } + // Each author should have called backend once + if ap.calls != 2 { + t.Fatalf("expected 2 backend calls for different authors, got %d", ap.calls) + } +} + +func TestCacheVersionStaleness(t *testing.T) { + st := newFakeStore() + // Add a stale profile (older than 30 days) + st.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{ + UserID: "@a@mastodon.social", + Platform: "ActivityPub", + Embedding: "[0.1,0.2]", + TopKeywords: []string{"old"}, + StyleSummary: "stale summary", + PostCount: 5, + ModifiedAt: sql.NullTime{Time: time.Now().Add(-31 * 24 * time.Hour), Valid: true}, + } + ap := &fakeAP{posts: []*activitypub.Post{{Author: "@a@mastodon.social", Content: "fresh post", CreatedAt: time.Now()}}} + s := newServerWith(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{}) + res, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "@a@mastodon.social"}) + if err != nil { + t.Fatalf("getStyleProfile error: %v", err) + } + var out GetStyleProfileResponse + if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + // Should have rebuilt with fresh data + if out.StyleSummary == "stale summary" { + t.Fatal("expected rebuild to replace stale summary") + } + if out.PostCount != 1 { + t.Fatalf("expected rebuilt post count 1, got %d", out.PostCount) + } +} + +// ---- edge cases ---- diff --git a/internal/server/metricsMiddleware.go b/internal/server/metricsMiddleware.go index 138af47..4960b37 100644 --- a/internal/server/metricsMiddleware.go +++ b/internal/server/metricsMiddleware.go @@ -4,6 +4,9 @@ import ( "context" "fmt" "net/http" + "sort" + "strings" + "sync" "time" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -11,27 +14,93 @@ import ( "github.com/nacorid/naco-api/internal/utils" ) +// meanStat is a cumulative mean over a mutex; used for per-tool latency (ms). +type meanStat struct { + mu sync.Mutex + sum float64 + n int64 +} + +func (m *meanStat) Record(v float64) { + m.mu.Lock() + m.sum += v + m.n++ + m.mu.Unlock() +} + +func (m *meanStat) GetMean() float64 { + m.mu.Lock() + defer m.mu.Unlock() + if m.n == 0 { + return 0 + } + return m.sum / float64(m.n) +} + +type toolStat struct { + calls utils.Counter + errors utils.Counter + latency meanStat +} + type MetricsMiddleware struct { + mu sync.Mutex requestsBucket utils.RollingStats totalRequests utils.Counter + totalErrors utils.Counter + enrichEvents utils.Counter + toolStats map[string]*toolStat } func NewMetricsHook() *MetricsMiddleware { return &MetricsMiddleware{ requestsBucket: *utils.NewRollingStats(60, 1*time.Second), totalRequests: *utils.NewCounter(), + totalErrors: *utils.NewCounter(), + enrichEvents: *utils.NewCounter(), + toolStats: make(map[string]*toolStat), } } +func (mh *MetricsMiddleware) statsFor(tool string) *toolStat { + mh.mu.Lock() + defer mh.mu.Unlock() + s, ok := mh.toolStats[tool] + if !ok { + s = &toolStat{} + mh.toolStats[tool] = s + } + return s +} + +func (mh *MetricsMiddleware) RecordTool(tool string, err error, duration time.Duration) { + mh.totalRequests.Increment() + if err != nil { + mh.totalErrors.Increment() + } + s := mh.statsFor(tool) + s.calls.Increment() + if err != nil { + s.errors.Increment() + } + s.latency.Record(float64(duration) / float64(time.Millisecond)) +} + +func (mh *MetricsMiddleware) RecordEnrichment() { + mh.enrichEvents.Increment() +} + func (mh *MetricsMiddleware) OnCall(next mcp.MethodHandler) mcp.MethodHandler { return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) { if method == "tools/call" { + toolName := "unknown" + if callReq, ok := request.(*mcp.CallToolRequest); ok && callReq.Params != nil { + toolName = callReq.Params.Name + } start := time.Now() mh.requestsBucket.Increment() - mh.totalRequests.Increment() result, err := next(ctx, method, request) - duration := fmt.Sprintf("%.2fms", float64(time.Since(start))/float64(time.Millisecond)) - log.DebugWithContext(ctx, "MetricsMiddleware: MCP Handler", "duration", duration) + mh.RecordTool(toolName, err, time.Since(start)) return result, err } return next(ctx, method, request) @@ -42,7 +111,6 @@ func (mh *MetricsMiddleware) OnCallHTTP(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() mh.requestsBucket.Increment() - mh.totalRequests.Increment() next.ServeHTTP(w, r) duration := fmt.Sprintf("%.2fms", float64(time.Since(start))/float64(time.Millisecond)) log.DebugWithContext(r.Context(), "MetricsMiddleware: HTTP Handler", "duration", duration) @@ -56,3 +124,42 @@ func (mh *MetricsMiddleware) GetRPS() float64 { func (mh *MetricsMiddleware) GetTotalRequests() int64 { return mh.totalRequests.Get() } + +// MetricsText renders Prometheus-style exposition text for /metrics. +func (mh *MetricsMiddleware) MetricsText() string { + mh.mu.Lock() + tools := make([]string, 0, len(mh.toolStats)) + for t := range mh.toolStats { + tools = append(tools, t) + } + sort.Strings(tools) + mh.mu.Unlock() + + var b strings.Builder + fmt.Fprintf(&b, "# HELP naco_requests_total Total MCP tool calls.\n") + fmt.Fprintf(&b, "# TYPE naco_requests_total counter\n") + fmt.Fprintf(&b, "naco_requests_total %d\n", mh.totalRequests.Get()) + fmt.Fprintf(&b, "# HELP naco_errors_total Total MCP tool calls that returned an error.\n") + fmt.Fprintf(&b, "# TYPE naco_errors_total counter\n") + fmt.Fprintf(&b, "naco_errors_total %d\n", mh.totalErrors.Get()) + fmt.Fprintf(&b, "# HELP naco_enrichment_total Total background enrichment runs.\n") + fmt.Fprintf(&b, "# TYPE naco_enrichment_total counter\n") + fmt.Fprintf(&b, "naco_enrichment_total %d\n", mh.enrichEvents.Get()) + fmt.Fprintf(&b, "# HELP naco_requests_rps Rolling requests per second.\n") + fmt.Fprintf(&b, "# TYPE naco_requests_rps gauge\n") + fmt.Fprintf(&b, "naco_requests_rps %.3f\n", mh.GetRPS()) + + for _, t := range tools { + s := mh.statsFor(t) + fmt.Fprintf(&b, "# HELP naco_tool_calls_total Tool calls for %s.\n", t) + fmt.Fprintf(&b, "# TYPE naco_tool_calls_total counter\n") + fmt.Fprintf(&b, "naco_tool_calls_total{tool=%q} %d\n", t, s.calls.Get()) + fmt.Fprintf(&b, "# HELP naco_tool_errors_total Tool errors for %s.\n", t) + fmt.Fprintf(&b, "# TYPE naco_tool_errors_total counter\n") + fmt.Fprintf(&b, "naco_tool_errors_total{tool=%q} %d\n", t, s.errors.Get()) + fmt.Fprintf(&b, "# HELP naco_tool_latency_ms Rolling mean latency (ms) for %s.\n", t) + fmt.Fprintf(&b, "# TYPE naco_tool_latency_ms gauge\n") + fmt.Fprintf(&b, "naco_tool_latency_ms{tool=%q} %.3f\n", t, s.latency.GetMean()) + } + return b.String() +} diff --git a/internal/server/platform.go b/internal/server/platform.go new file mode 100644 index 0000000..a0330cd --- /dev/null +++ b/internal/server/platform.go @@ -0,0 +1,44 @@ +package server + +import ( + "fmt" + "strings" +) + +func determinePlatform(author string) (platform, error) { + if strings.HasPrefix(author, "did:") { + return Bluesky, nil + } + if strings.HasPrefix(author, "npub") { + return Nostr, nil + } + if strings.HasPrefix(author, "@") { + if strings.Count(author, "@") > 1 { + return ActivityPub, nil + } + + if strings.Contains(author, ".") { + return Bluesky, nil + } + + return Twitter, nil + } + if len(author) == 64 { + isHex := true + for _, c := range author { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') { + isHex = false + break + } + } + if isHex { + return Nostr, nil + } + } + + if strings.Contains(author, ".") { + return Bluesky, nil + } + + return -1, fmt.Errorf("unknown author format: %s", author) +} diff --git a/internal/server/platform_test.go b/internal/server/platform_test.go new file mode 100644 index 0000000..733d1e0 --- /dev/null +++ b/internal/server/platform_test.go @@ -0,0 +1,57 @@ +package server + +import "testing" + +func TestDeterminePlatform(t *testing.T) { + cases := []struct { + author string + want platform + err bool + }{ + {"did:plc:abc123", Bluesky, false}, + {"npub1qwerty", Nostr, false}, + {"@user@host.social", ActivityPub, false}, + {"@user.bsky.social", Bluesky, false}, + {"@user", Twitter, false}, + {"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", Nostr, false}, + {"user.bsky.social", Bluesky, false}, + {"example.com", Bluesky, false}, + {"justhandle", -1, true}, + {"", -1, true}, + } + for _, c := range cases { + got, err := determinePlatform(c.author) + if c.err { + if err == nil { + t.Fatalf("%q: expected error", c.author) + } + continue + } + if err != nil { + t.Fatalf("%q: unexpected error %v", c.author, err) + } + if got != c.want { + t.Fatalf("%q: got %v want %v", c.author, got, c.want) + } + } +} + +func TestPlatformFromString(t *testing.T) { + cases := []struct { + in string + want platform + ok bool + }{ + {"Bluesky", Bluesky, true}, + {"ActivityPub", ActivityPub, true}, + {"Nostr", Nostr, true}, + {"Twitter", Twitter, true}, + {"nope", -1, false}, + } + for _, c := range cases { + got, ok := platformFromString(c.in) + if ok != c.ok || got != c.want { + t.Fatalf("%q: got %v ok %v, want %v %v", c.in, got, ok, c.want, c.ok) + } + } +} diff --git a/internal/server/posts.go b/internal/server/posts.go new file mode 100644 index 0000000..cd2bd28 --- /dev/null +++ b/internal/server/posts.go @@ -0,0 +1,144 @@ +package server + +import ( + "context" + "fmt" + "time" +) + +func (s *Server) fetchSinglePost(ctx context.Context, platform platform, author, recordKey, collection string) (*GetPostResponsePost, error) { + key := fmt.Sprintf("post:%s|%s|%s|%s", platform, author, collection, recordKey) + return cachedFetch(s.postCache, key, func() (*GetPostResponsePost, error) { + switch platform { + case Bluesky: + res, err := s.bskyClient.GetPost(ctx, author, collection, recordKey) + if err != nil { + return nil, err + } + r := bskyPostViewToResponse(res) + return r, nil + case ActivityPub: + p, err := s.apClient.GetPost(ctx, recordKey) + if err != nil { + return nil, err + } + r := apPostToResponse(p) + return r, nil + case Nostr: + p, err := s.nostrClient.GetPost(ctx, recordKey) + if err != nil { + return nil, err + } + r := nostrPostToResponse(p) + return r, nil + case Twitter: + p, err := s.twitterClient.GetPost(ctx, recordKey) + if err != nil { + return nil, err + } + r := twitterPostToResponse(p) + return r, nil + } + return nil, fmt.Errorf("unsupported platform %q", platform) + }) +} + +func (s *Server) fetchLatestPosts(ctx context.Context, platform platform, author string, limit int) ([]*GetPostResponsePost, string, error) { + key := fmt.Sprintf("latest:%s|%s|%d", platform, author, limit) + cv, err := cachedFetch(s.postCache, key, func() (latestCacheValue, error) { + switch platform { + case Bluesky: + res, err := s.bskyClient.GetLatestNPosts(ctx, author, limit) + if err != nil { + return latestCacheValue{}, err + } + posts, aid := blueskyFeedViewsToResponses(res) + return latestCacheValue{posts: posts, aid: aid}, nil + case ActivityPub: + res, err := s.apClient.GetLatestNPosts(ctx, author, limit) + if err != nil { + return latestCacheValue{}, err + } + posts, aid := apPostsPtrToResponses(res) + return latestCacheValue{posts: posts, aid: aid}, nil + case Nostr: + res, err := s.nostrClient.GetLatestNPosts(ctx, author, limit) + if err != nil { + return latestCacheValue{}, err + } + posts, aid := nostrPostsToResponses(res) + return latestCacheValue{posts: posts, aid: aid}, nil + case Twitter: + res, err := s.twitterClient.GetLatestNPosts(ctx, author, limit) + if err != nil { + return latestCacheValue{}, err + } + posts, aid := twitterPostsToResponses(res) + return latestCacheValue{posts: posts, aid: aid}, nil + } + return latestCacheValue{}, fmt.Errorf("unsupported platform %q", platform) + }) + if err != nil { + return nil, "", err + } + return cv.posts, cv.aid, nil +} + +func (s *Server) fetchPostsInTime(ctx context.Context, platform platform, author string, start, end time.Time, limit int) ([]*GetPostResponsePost, string, error) { + key := fmt.Sprintf("time:%s|%s|%s|%s|%d", platform, author, start.Format(time.RFC3339), end.Format(time.RFC3339), limit) + cv, err := cachedFetch(s.postCache, key, func() (latestCacheValue, error) { + switch platform { + case Bluesky: + res, err := s.bskyClient.GetPostsInTimeframe(ctx, author, start, end, limit) + if err != nil { + return latestCacheValue{}, err + } + posts, aid := blueskyFeedViewsToResponses(res) + return latestCacheValue{posts: posts, aid: aid}, nil + case ActivityPub: + res, err := s.apClient.GetPostsInTimeframe(ctx, author, start, end, limit) + if err != nil { + return latestCacheValue{}, err + } + posts, aid := apPostsToResponses(res) + return latestCacheValue{posts: posts, aid: aid}, nil + case Nostr: + res, err := s.nostrClient.GetPostsInTimeframe(ctx, author, start, end, limit) + if err != nil { + return latestCacheValue{}, err + } + posts, aid := nostrPostsToResponses(res) + return latestCacheValue{posts: posts, aid: aid}, nil + case Twitter: + res, err := s.twitterClient.GetPostsInTimeframe(ctx, author, start, end, limit) + if err != nil { + return latestCacheValue{}, err + } + posts, aid := twitterPostsToResponses(res) + return latestCacheValue{posts: posts, aid: aid}, nil + } + return latestCacheValue{}, fmt.Errorf("unsupported platform %q", platform) + }) + if err != nil { + return nil, "", err + } + return cv.posts, cv.aid, nil +} + +func (s *Server) fetchFeed(ctx context.Context, platform platform, uri string) ([]*GetPostResponsePost, error) { + key := fmt.Sprintf("feed:%s|%s", platform, uri) + return cachedFetch(s.postCache, key, func() ([]*GetPostResponsePost, error) { + if platform == Bluesky { + res, err := s.bskyClient.GetFeed(ctx, uri, 50) + if err != nil { + return nil, err + } + posts, _ := blueskyFeedViewsToResponses(res.Feed) + return posts, nil + } + // Non-Bluesky platforms have no custom-feed concept; treat the URI as an author handle + // and return their latest posts as the feed. + posts, _, err := s.fetchLatestPosts(ctx, platform, uri, 50) + return posts, err + }) +} diff --git a/internal/server/posts_convert.go b/internal/server/posts_convert.go new file mode 100644 index 0000000..f35b394 --- /dev/null +++ b/internal/server/posts_convert.go @@ -0,0 +1,255 @@ +package server + +import ( + "time" + + "github.com/bluesky-social/indigo/api/bsky" + "github.com/nacorid/naco-api/internal/activitypub" + "github.com/nacorid/naco-api/internal/clustering" + "github.com/nacorid/naco-api/internal/nostr" + "github.com/nacorid/naco-api/internal/twitter" +) + +func bskyPostViewToResponse(p *bsky.FeedDefs_PostView) *GetPostResponsePost { + author := "" + if p.Author != nil { + author = p.Author.Did + } + content := "" + created := "" + if fp, ok := p.Record.Val.(*bsky.FeedPost); ok { + content = fp.Text + created = fp.CreatedAt + } + likes, reposts, quotes, replies := int64(0), int64(0), int64(0), int64(0) + if p.LikeCount != nil { + likes = *p.LikeCount + } + if p.RepostCount != nil { + reposts = *p.RepostCount + } + if p.QuoteCount != nil { + quotes = *p.QuoteCount + } + if p.ReplyCount != nil { + replies = *p.ReplyCount + } + return &GetPostResponsePost{ + Author: author, + Content: content, + Likes: likes, + Reposts: reposts, + Quotes: quotes, + Replies: replies, + CreatedAt: created, + } +} + +func apPostToResponse(p *activitypub.Post) *GetPostResponsePost { + created := "" + if !p.CreatedAt.IsZero() { + created = p.CreatedAt.Format(time.RFC3339) + } + return &GetPostResponsePost{ + Author: p.Author, + Content: p.Content, + Likes: int64(p.FavouritesCount), + Reposts: int64(p.ReblogsCount), + Replies: int64(p.RepliesCount), + CreatedAt: created, + } +} + +func nostrPostToResponse(p *nostr.Post) *GetPostResponsePost { + created := "" + if !p.CreatedAt.IsZero() { + created = p.CreatedAt.Format(time.RFC3339) + } + return &GetPostResponsePost{ + Author: p.Author, + Content: p.Content, + CreatedAt: created, + } +} + +func twitterPostToResponse(p *twitter.Post) *GetPostResponsePost { + created := "" + if !p.CreatedAt.IsZero() { + created = p.CreatedAt.Format(time.RFC3339) + } + return &GetPostResponsePost{ + Author: p.Author, + Content: p.Content, + Likes: int64(p.Likes), + Reposts: int64(p.Reposts), + Quotes: int64(p.Quotes), + Replies: int64(p.Replies), + CreatedAt: created, + } +} + +// ---- Batch converters for GetPostResponsePost ---- + +func blueskyFeedViewsToResponses(views []*bsky.FeedDefs_FeedViewPost) ([]*GetPostResponsePost, string) { + posts := make([]*GetPostResponsePost, 0, len(views)) + var aid string + for _, p := range views { + if p.Post == nil { + continue + } + posts = append(posts, bskyPostViewToResponse(p.Post)) + if p.Post.Author != nil { + aid = p.Post.Author.Did + } + } + return posts, aid +} + +func apPostsPtrToResponses(posts []*activitypub.Post) ([]*GetPostResponsePost, string) { + out := make([]*GetPostResponsePost, 0, len(posts)) + var aid string + for _, p := range posts { + out = append(out, apPostToResponse(p)) + aid = p.Author + } + return out, aid +} + +func apPostsToResponses(posts []*activitypub.Post) ([]*GetPostResponsePost, string) { + out := make([]*GetPostResponsePost, 0, len(posts)) + var aid string + for _, p := range posts { + out = append(out, apPostToResponse(p)) + aid = p.Author + } + return out, aid +} + +func nostrPostsToResponses(posts []*nostr.Post) ([]*GetPostResponsePost, string) { + out := make([]*GetPostResponsePost, 0, len(posts)) + var aid string + for _, p := range posts { + out = append(out, nostrPostToResponse(p)) + aid = p.Author + } + return out, aid +} + +func twitterPostsToResponses(posts []*twitter.Post) ([]*GetPostResponsePost, string) { + out := make([]*GetPostResponsePost, 0, len(posts)) + var aid string + for _, p := range posts { + out = append(out, twitterPostToResponse(p)) + aid = p.Author + } + return out, aid +} + +// ---- Batch converters for clustering.Item (style & analytics) ---- + +func blueskyFeedViewsToItems(views []*bsky.FeedDefs_FeedViewPost) []clustering.Item { + out := make([]clustering.Item, 0, len(views)) + for _, p := range views { + fp, ok := p.Post.Record.Val.(*bsky.FeedPost) + if !ok { + continue + } + if fp.Text == "" { + continue + } + out = append(out, clustering.Item{ID: p.Post.Uri, Text: fp.Text}) + } + return out +} + +func apPostsPtrToItems(posts []*activitypub.Post) []clustering.Item { + out := make([]clustering.Item, 0, len(posts)) + for _, p := range posts { + if p.Content != "" { + out = append(out, clustering.Item{ID: p.ID, Text: p.Content}) + } + } + return out +} + +func nostrPostsToItems(posts []*nostr.Post) []clustering.Item { + out := make([]clustering.Item, 0, len(posts)) + for _, p := range posts { + if p.Content != "" { + out = append(out, clustering.Item{ID: p.ID, Text: p.Content}) + } + } + return out +} + +func twitterPostsToItems(posts []*twitter.Post) []clustering.Item { + out := make([]clustering.Item, 0, len(posts)) + for _, p := range posts { + if p.Content != "" { + out = append(out, clustering.Item{ID: p.ID, Text: p.Content}) + } + } + return out +} + +// analytics-specific converters that preserve engagement + timestamps + +func blueskyFeedViewsToAnalyticsItems(views []*bsky.FeedDefs_FeedViewPost) []clustering.Item { + out := make([]clustering.Item, 0, len(views)) + for _, p := range views { + if p.Post == nil || p.Post.Record == nil { + continue + } + fp, ok := p.Post.Record.Val.(*bsky.FeedPost) + if !ok { + continue + } + item := clustering.Item{ID: p.Post.Uri, Text: fp.Text} + if ts, err := time.Parse(time.RFC3339, p.Post.IndexedAt); err == nil { + item.Timestamp = ts + } + item.Likes = int64Val(p.Post.LikeCount) + item.Reposts = int64Val(p.Post.RepostCount) + item.Replies = int64Val(p.Post.ReplyCount) + item.Quotes = int64Val(p.Post.QuoteCount) + out = append(out, item) + } + return out +} + +func apPostsToAnalyticsItems(posts []*activitypub.Post) []clustering.Item { + out := make([]clustering.Item, 0, len(posts)) + for _, p := range posts { + if p.Content != "" { + out = append(out, clustering.Item{ + ID: p.ID, Text: p.Content, Timestamp: p.CreatedAt, + Likes: int64(p.FavouritesCount), Reposts: int64(p.ReblogsCount), Replies: int64(p.RepliesCount), + }) + } + } + return out +} + +func nostrPostsToAnalyticsItems(posts []*nostr.Post) []clustering.Item { + out := make([]clustering.Item, 0, len(posts)) + for _, p := range posts { + if p.Content != "" { + out = append(out, clustering.Item{ID: p.ID, Text: p.Content, Timestamp: p.CreatedAt}) + } + } + return out +} + +func twitterPostsToAnalyticsItems(posts []*twitter.Post) []clustering.Item { + out := make([]clustering.Item, 0, len(posts)) + for _, p := range posts { + if p.Content == "" { + continue + } + out = append(out, clustering.Item{ + ID: p.ID, Text: p.Content, Timestamp: p.CreatedAt, + Likes: int64(p.Likes), Reposts: int64(p.Reposts), Replies: int64(p.Replies), Quotes: int64(p.Quotes), + }) + } + return out +} diff --git a/internal/server/prewarm.go b/internal/server/prewarm.go new file mode 100644 index 0000000..2fd9041 --- /dev/null +++ b/internal/server/prewarm.go @@ -0,0 +1,93 @@ +package server + +import ( + "context" + "time" + + log "github.com/nacorid/logger" +) + +// StartPrewarm launches a background goroutine that discovers interesting authors and +// pre-fetches their style profiles. It issues at most one pre-fetch "request" per minute +// so upstream platforms, Ollama and the database are never hit harder than that cadence +// allows. Profiles are still only (re)written when older than a week, exactly like a live +// getStyleProfile request. Failures (including rate limits) are logged and retried on the +// next tick. +func (s *Server) StartPrewarm(ctx context.Context) { + if s.prewarmFeedURI == "" && len(s.prewarmAuthors) == 0 { + log.DebugWithContext(ctx, "no prewarm feed or authors configured, skipping prewarm") + return + } + + go func() { + authors := s.discoverInterestingAuthors(ctx) + if len(authors) == 0 { + return + } + s.prewarmOne(ctx, authors[0]) + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + i := 1 + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if i >= len(authors) { + // re-discover interesting profiles and restart the cycle + authors = s.discoverInterestingAuthors(ctx) + i = 0 + if len(authors) == 0 { + continue + } + } + author := authors[i] + i++ + s.prewarmOne(ctx, author) + } + } + }() +} + +// discoverInterestingAuthors returns a list of authors worth pre-fetching. When a feed URI +// is configured it pulls recent posts from that feed and collects the author DIDs; +// otherwise it falls back to the statically configured seed list. +func (s *Server) discoverInterestingAuthors(ctx context.Context) []string { + if s.prewarmFeedURI != "" { + feed, err := s.bskyClient.GetFeed(ctx, s.prewarmFeedURI, 50) + if err != nil { + log.WarnWithContext(ctx, "prewarm: failed to discover authors from feed", "error", err) + } else { + seen := make(map[string]struct{}) + authors := make([]string, 0, len(feed.Feed)) + for _, p := range feed.Feed { + if p.Post == nil || p.Post.Author == nil { + continue + } + did := p.Post.Author.Did + if did == "" { + continue + } + if _, ok := seen[did]; ok { + continue + } + seen[did] = struct{}{} + authors = append(authors, did) + } + if len(authors) > 0 { + return authors + } + } + } + return s.prewarmAuthors +} + +func (s *Server) prewarmOne(ctx context.Context, author string) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + if _, err := s.requestStyleProfile(ctx, author); err != nil { + log.WarnWithContext(ctx, "prewarm: failed to build profile", "author", author, "error", err) + return + } + log.InfoWithContext(ctx, "prewarmed style profile", "author", author) +} diff --git a/internal/server/searchactors_test.go b/internal/server/searchactors_test.go new file mode 100644 index 0000000..61fa8c6 --- /dev/null +++ b/internal/server/searchactors_test.go @@ -0,0 +1,84 @@ +package server + +import ( + "testing" + + "github.com/nacorid/naco-api/internal/actor" +) + +func TestNormalizeHandle(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"", ""}, + {" ", ""}, + {"@alice", "alice"}, + {"@", ""}, + {"alice@mastodon.social", "alice"}, + {"Alice@Mastodon.Social", "alice"}, + {"@Alice@mastodon.social", "alice"}, + {"alice.bsky.social", "alice.bsky.social"}, + {" @bob.X ", "bob.x"}, + } + for _, c := range cases { + if got := normalizeHandle(c.in); got != c.want { + t.Errorf("normalizeHandle(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestDedupeActors(t *testing.T) { + in := []*ActorProfile{ + {Handle: "@alice", DisplayName: "Alice"}, + {Handle: "alice@mastodon.social", DisplayName: "Alice"}, + {Handle: "", DisplayName: ""}, + {Handle: "bob", DisplayName: "Bob"}, + } + // @alice and alice@mastodon.social dedupe by handle; the empty-handle AND + // empty-display-name is appended unconditionally; bob is another person. + out := dedupeActors(in) + if len(out) != 3 { + t.Fatalf("expected 3 deduped results, got %d: %+v", len(out), out) + } +} + +func TestDedupeActorsByDisplayNameOnly(t *testing.T) { + // When handles are empty but display names collide, dedupe by display name. + in := []*ActorProfile{ + {Handle: "", DisplayName: "Shared"}, + {Handle: "", DisplayName: "Shared"}, + {Handle: "", DisplayName: "Other"}, + } + out := dedupeActors(in) + if len(out) != 2 { + t.Fatalf("expected 2 results after display-name dedup, got %d: %+v", len(out), out) + } +} + +func TestSearchActorsTwitterExcluded(t *testing.T) { + // searchActorsAll only queries Bluesky, ActivityPub and Nostr. A Twitter + // result must never appear because the backend is not consulted. + tw := &fakeTwitter{actors: []*actor.Profile{{Platform: "Twitter", Handle: "t"}}, calls: 0} + bsky := &fakeBluesky{actors: []*actor.Profile{{Platform: "Bluesky", Handle: "a"}}} + ap := &fakeAP{actors: []*actor.Profile{{Platform: "ActivityPub", Handle: "b"}}} + nstr := &fakeNostr{actors: []*actor.Profile{{Platform: "Nostr", Handle: "c"}}} + s := newTestServer(bsky, ap, nstr, tw) + + res, err := callTool(s, "searchActors", SearchActorsRequest{Query: "q"}) + if err != nil { + t.Fatalf("searchActors error: %v", err) + } + var out SearchActorsResponse + if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + for _, r := range out.Results { + if r.Platform == "Twitter" { + t.Fatalf("unexpected Twitter result: %+v", r) + } + } + if tw.calls != 0 { + t.Fatalf("expected Twitter backend untouched, got %d calls", tw.calls) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index caa40eb..4cccf25 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -2,740 +2,119 @@ package server import ( "context" - "encoding/json" "fmt" - "maps" - "net/http" "os" "strings" "time" - "github.com/bluesky-social/indigo/api/bsky" - "github.com/modelcontextprotocol/go-sdk/mcp" log "github.com/nacorid/logger" "github.com/nacorid/naco-api/internal/activitypub" "github.com/nacorid/naco-api/internal/bluesky" "github.com/nacorid/naco-api/internal/config" + "github.com/nacorid/naco-api/internal/nostr" "github.com/nacorid/naco-api/internal/ollama" "github.com/nacorid/naco-api/internal/store" - "github.com/nacorid/naco-api/internal/utils" + "github.com/nacorid/naco-api/internal/twitter" "github.com/nacorid/naco-api/internal/x402" - x402v2 "github.com/x402-foundation/x402/go/v2" - x402http "github.com/x402-foundation/x402/go/v2/http/nethttp" ) type Server struct { - listenAddress string - permissions os.FileMode - mcpHandler *x402.X402Server - db *store.Queries - apClient *activitypub.APClient - bskyClient *bluesky.BlueskyClient - ollama *ollama.OllamaClient - x402Wallet string - activeNetworks []NetworkInfo + // Transport & middleware + listenAddress string + permissions os.FileMode + mcpHandler *x402.X402Server + metrics *MetricsMiddleware + + // Dependencies + db Store + apClient apBackend + bskyClient blueskyBackend + nostrClient nostrBackend + twitterClient twitterBackend + ollama ollamaBackend + + // Cache & background work + postCache *ttlCache + enrichSem chan struct{} + prewarmAuthors []string + prewarmFeedURI string + enrichEnabled bool + + // Payments & analytics tuning + x402Wallet string + activeNetworks []NetworkInfo + analyticsTTLEngagement time.Duration + analyticsTTLPatterns time.Duration + analyticsTTLTopics time.Duration + analyticsTTLSentiment time.Duration + analyticsTTLDrivers time.Duration + + // twitterEnabled reports whether the Twitter API is available (a bearer + // token is configured). It flips the Twitter-capable tools to upto + // variable pricing so the realized Twitter API costs are passed on to + // callers (see registerTools). + twitterEnabled bool } -func New(ctx context.Context, db *store.Queries, ap *activitypub.APClient, bsky *bluesky.BlueskyClient, o *ollama.OllamaClient, cfg *config.Config) *Server { - rateLimits := NewRateLimitMiddleware(float64(1.0/10.0), 2, []string{"getFeed"}) - metrics := NewMetricsHook() - logging := NewLoggingMiddleware() - toolChain := x402.X402Middlewares{ - MCPMiddleware: []mcp.Middleware{ - logging.OnCall, - metrics.OnCall, - rateLimits.OnCall, - }, - HTTPMiddleware: []func(http.Handler) http.Handler{ - logging.OnCallHTTP, - metrics.OnCallHTTP, - rateLimits.OnCallHTTP, - }, - } - auth, err := NewCDPAuth(cfg.ApiKey, cfg.ApiSecret) - if err != nil { - log.FatalWithContext(ctx, "failed to create CDP auth", "error", err) - } - - srv, err := x402.NewX402Server( - "x402 enabled Bluesky lookingglass for autonomous Agents", - "0.2.0", - cfg.X402FacilitatorURL, - auth, - toolChain, - GlobalLoggingMiddleware, - func(w http.ResponseWriter, r *http.Request, resp *x402v2.SettleResponse) { - requirements, ok := x402http.RequirementsFromContext(r.Context()) - if !ok { - log.WarnWithContext(r.Context(), "failed to extract payment requirements from context during settlement hook") - return - } - amount := requirements.Amount - asset := requirements.Asset - to := requirements.PayTo - _ = settleHook(r.Context(), db, resp.Transaction, amount, asset, string(resp.Network), to, resp.Payer) - }, - ) - if err != nil { - log.FatalWithContext(ctx, "failed to create x402 server", "error", err) - } - - srv.OnAfterSettle(func(settleCtx x402v2.SettleResultContext) error { - amount := settleCtx.Requirements.GetAmount() - asset := settleCtx.Requirements.GetAsset() - to := settleCtx.Requirements.GetPayTo() - from := settleCtx.Result.Payer - - return settleHook(settleCtx.Ctx, db, settleCtx.Result.Transaction, amount, asset, string(settleCtx.Result.Network), to, from) - }) - - enabledNetworkNames := parseActiveNetworks(cfg.ActiveNetworks) - - var activeNetworks []NetworkInfo - for _, name := range enabledNetworkNames { - if info, exists := NetworkRegistry[name]; exists { - activeNetworks = append(activeNetworks, info) - } else { - log.WarnWithContext(ctx, "configured network is not defined in the registry", "name", name) - } - } +func New(ctx context.Context, db *store.Queries, ap *activitypub.APClient, bsky *bluesky.BlueskyClient, n *nostr.Client, t *twitter.Client, o *ollama.OllamaClient, cfg *config.Config) *Server { + toolchain, metrics := buildMiddleware() + srv := newX402Server(ctx, db, cfg, toolchain, metrics) + activeNetworks := resolveActiveNetworks(ctx, cfg.ActiveNetworks) s := &Server{ - listenAddress: cfg.ListenAddress, - permissions: cfg.UnixSocketPermissions, - mcpHandler: srv, - db: db, - apClient: ap, - bskyClient: bsky, - ollama: o, - x402Wallet: cfg.X402WalletAddress, - activeNetworks: activeNetworks, + listenAddress: cfg.ListenAddress, + permissions: cfg.UnixSocketPermissions, + mcpHandler: srv, + db: db, + apClient: ap, + bskyClient: bsky, + nostrClient: n, + twitterClient: t, + ollama: o, + metrics: metrics, + postCache: newTTLCache(postCacheTTL), + prewarmAuthors: cfg.PrewarmAuthors, + prewarmFeedURI: cfg.PrewarmFeedURI, + enrichEnabled: cfg.EnrichCrossPlatform, + x402Wallet: cfg.X402WalletAddress, + activeNetworks: activeNetworks, + enrichSem: make(chan struct{}, 4), + analyticsTTLEngagement: cfg.AnalyticsTTLEngagement, + analyticsTTLPatterns: cfg.AnalyticsTTLPatterns, + analyticsTTLTopics: cfg.AnalyticsTTLTopics, + analyticsTTLSentiment: cfg.AnalyticsTTLSentiment, + analyticsTTLDrivers: cfg.AnalyticsTTLDrivers, + twitterEnabled: cfg.TwitterBearerToken != "", } + // Combine address and port for TCP (not unix socket) if port not already present + if !strings.HasPrefix(cfg.ListenAddress, "unix:") && !strings.Contains(cfg.ListenAddress, ":") { + s.listenAddress = fmt.Sprintf("%s:%s", cfg.ListenAddress, cfg.ListenPort) + } + + log.DebugWithContext(ctx, "server initialized", + "networks", len(activeNetworks), + "enrich", s.enrichEnabled, + "prewarm_authors", len(s.prewarmAuthors), + ) + s.registerTools() return s } -func (s *Server) registerTools() { - extra := map[string]any{ - "OutputSchema": Schema{ - Input: InputSchema{ - Type: "HTTP", - Method: "POST", - Discoverable: true, - BodyFields: map[string]any{ - "actor": Bodyfield{ - Type: "string", - Description: "Did or handle of an actor", - Required: true, - }, - "rkey": Bodyfield{ - Type: "string", - Description: "Record key of the post", - Required: true, - }, - "collection": Bodyfield{ - Type: "string", - Description: "Collection the post belongs to. Defaults to 'app.bsky.feed.post'", - Required: false, - }, - }, - }, - Output: map[string]any{ - "actor": Bodyfield{ - Type: "string", - Description: "Did of the actor", - }, - "post": Bodyfield{ - Type: "object", - Description: "The post object. Includes the post content 'content', like 'likes', quote 'quotes', replies 'replies' and repost 'reposts' count", - }, - }, - }, +func (s *Server) MetricsText() string { + if s.metrics == nil { + return "" } - - s.mcpHandler.AddPayableTool( - mcp.Tool{Name: "getPost", - Description: "Get a specific post from a specific actor", - InputSchema: GetPostRequest{}, - OutputSchema: GetPostResponsePost{}, - }, - s.getPost, - s.buildRequirements("0.01", extra)..., - ) - - extra = map[string]any{ - "OutputSchema": Schema{ - Input: InputSchema{ - Type: "HTTP", - Method: "POST", - Discoverable: true, - BodyFields: map[string]any{ - "actor": Bodyfield{ - Type: "string", - Description: "Did or handle of an actor", - Required: true, - }, - "n": Bodyfield{ - Type: "integer", - Description: "Number of posts to retrieve, maximum 50", - Required: true, - }, - }, - }, - Output: map[string]any{ - "actor": Bodyfield{ - Type: "string", - Description: "Did of an actor", - }, - "posts": Bodyfield{ - Type: "array", - Description: "Array of post objects. Each includes the post content 'content', like 'likes', quote 'quotes', replies 'replies' and repost 'reposts' count", - }, - }, - }, - } - - s.mcpHandler.AddPayableTool( - mcp.Tool{Name: "getLatestPosts", - Description: "Get the latest n posts from a specific actor", - InputSchema: GetLatestPostsRequest{}, - OutputSchema: GetLatestPostsResponse{}, - }, - s.getLatestPosts, - s.buildRequirements("0.10", extra)..., - ) - - extra = map[string]any{ - "OutputSchema": Schema{ - Input: InputSchema{ - Type: "HTTP", - Method: "POST", - Discoverable: true, - BodyFields: map[string]any{ - "actor": Bodyfield{ - Type: "string", - Description: "Did or handle of an actor", - Required: true, - }, - "start": Bodyfield{ - Type: "string", - Description: "Start time in RFC3339 format", - Required: true, - }, - "end": Bodyfield{ - Type: "string", - Description: "End time in RFC3339 format", - Required: true, - }, - "n": Bodyfield{ - Type: "integer", - Description: "Maximum number of posts to retrieve, maximum 50", - Required: true, - }, - }, - }, - Output: map[string]any{ - "actor": Bodyfield{ - Type: "string", - Description: "Did of an actor", - }, - "posts": Bodyfield{ - Type: "array", - Description: "Array of post objects. Each includes the post content 'content', like 'likes', quote 'quotes', replies 'replies' and repost 'reposts' count", - }, - }, - }, - } - - s.mcpHandler.AddPayableTool( - mcp.Tool{Name: "getPostsInTime", - Description: "Get posts from a specific actor in a time range", - InputSchema: GetPostsInTimeRequest{}, - OutputSchema: GetPostsInTimeResponse{}, - }, - s.getPostsInTime, - s.buildRequirements("0.10", extra)..., - ) - - extra = map[string]any{ - "OutputSchema": Schema{ - Input: InputSchema{ - Type: "HTTP", - Method: "POST", - Discoverable: true, - BodyFields: map[string]any{ - "actor": Bodyfield{ - Type: "string", - Description: "Did or handle of an actor", - Required: true, - }, - }, - }, - Output: map[string]any{ - "PersonaEmbedding": Bodyfield{ - Type: "array", - Description: "Embedding vector representing the actor's persona", - }, - "TopKeywords": Bodyfield{ - Type: "array", - Description: "Top words used by the actor", - }, - "StyleSummary": Bodyfield{ - Type: "string", - Description: "Summary of the actor's writing style", - }, - "PostCount": Bodyfield{ - Type: "integer", - Description: "Number of posts analyzed", - }, - }, - }, - } - - /*s.mcpHandler.AddPayableTool( - mcp.Tool{Name: "getStyleProfile", - Description: "Get an actor's style Profile", - InputSchema: GetStyleProfileRequest{}, - OutputSchema: GetStyleProfileResponse{}, - }, - s.getStyleProfile, - s.buildRequirements("100000", extra)..., - )*/ - - s.mcpHandler.AddTool( - mcp.Tool{Name: "getFeed", - Description: "Get a feed by its URI", - InputSchema: GetFeedRequest{}, - OutputSchema: GetFeedResponse{}, - }, - s.getFeed, - ) -} - -func (s *Server) getPost(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - var args GetPostRequest - if err := json.Unmarshal(request.Params.Arguments, &args); err != nil { - return nil, fmt.Errorf("invalid arguments: %w", err) - } - if args.Author == "" { - return nil, fmt.Errorf("author must be a valid did or handle") - } - if args.RecordKey == "" { - return nil, fmt.Errorf("rkey must be a valid record key") - } - res, err := s.bskyClient.GetPost(ctx, args.Author, args.Collection, args.RecordKey) - if err != nil { - return nil, err - } - post, ok := res.Record.Val.(*bsky.FeedPost) - if !ok { - return nil, fmt.Errorf("record is not a feed post") - } - resp := GetPostResponsePost{ - Author: res.Author.Did, - Content: post.Text, - Likes: *res.LikeCount, - Reposts: *res.RepostCount, - Quotes: *res.QuoteCount, - Replies: *res.ReplyCount, - CreatedAt: post.CreatedAt, - } - - respBytes, err := json.Marshal(resp) - if err != nil { - return nil, err - } - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: string(respBytes)}, - }, - }, nil -} - -func (s *Server) getLatestPosts(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - var args GetLatestPostsRequest - if err := json.Unmarshal(request.Params.Arguments, &args); err != nil { - return nil, fmt.Errorf("invalid arguments: %w", err) - } - if args.Author == "" { - return nil, fmt.Errorf("author must be a valid did or handle") - } - if args.Limit <= 0 { - return nil, fmt.Errorf("n must be a positive integer") - } - if args.Limit > 50 { - return nil, fmt.Errorf("n must be at most 50") - } - res, err := s.bskyClient.GetLatestNPosts(ctx, args.Author, args.Limit) - if err != nil { - return nil, err - } - posts := make([]GetPostResponsePost, 0, len(res)) - var author string - for _, p := range res { - post, ok := p.Post.Record.Val.(*bsky.FeedPost) - if !ok { - continue - } - posts = append(posts, GetPostResponsePost{ - Content: post.Text, - Likes: *p.Post.LikeCount, - Reposts: *p.Post.RepostCount, - Quotes: *p.Post.QuoteCount, - Replies: *p.Post.ReplyCount, - CreatedAt: post.CreatedAt, - }) - author = p.Post.Author.Did - } - if author == "" { - author = args.Author - } - resp := GetLatestPostsResponse{ - Author: author, - Posts: posts, - } - - respBytes, err := json.Marshal(resp) - if err != nil { - return nil, err - } - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: string(respBytes)}, - }, - }, nil -} - -func (s *Server) getPostsInTime(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - var args GetPostsInTimeRequest - if err := json.Unmarshal(request.Params.Arguments, &args); err != nil { - return nil, fmt.Errorf("invalid arguments: %w", err) - } - if args.Author == "" { - return nil, fmt.Errorf("author must be a valid did or handle") - } - if args.Limit <= 0 { - return nil, fmt.Errorf("n must be a positive integer") - } - limit := min(args.Limit, 50) - startTime, err := time.Parse(time.RFC3339, args.StartTime) - if err != nil { - return nil, fmt.Errorf("startTime must be in RFC3339 format") - } - endTime, err := time.Parse(time.RFC3339, args.EndTime) - if err != nil { - return nil, fmt.Errorf("startTime must be in RFC3339 format") - } - if startTime.After(endTime) { - tmp := startTime - startTime = endTime - endTime = tmp - } - res, err := s.bskyClient.GetPostsInTimeframe(ctx, args.Author, startTime, endTime, limit) - if err != nil { - return nil, err - } - posts := make([]GetPostResponsePost, 0, len(res)) - var author string - for _, p := range res { - post, ok := p.Post.Record.Val.(*bsky.FeedPost) - if !ok { - continue - } - posts = append(posts, GetPostResponsePost{ - Content: post.Text, - Likes: *p.Post.LikeCount, - Reposts: *p.Post.RepostCount, - Quotes: *p.Post.QuoteCount, - Replies: *p.Post.ReplyCount, - CreatedAt: post.CreatedAt, - }) - author = p.Post.Author.Did - } - if author == "" { - author = args.Author - } - resp := &GetPostsInTimeResponse{ - Author: author, - Posts: posts, - } - - respBytes, err := json.Marshal(resp) - if err != nil { - return nil, err - } - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: string(respBytes)}, - }, - }, nil -} - -func (s *Server) getFeed(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - var args GetFeedRequest - if err := json.Unmarshal(request.Params.Arguments, &args); err != nil { - return nil, fmt.Errorf("invalid arguments: %w", err) - } - if args.URI == "" { - return nil, fmt.Errorf("feedURI must be a valid feed URI") - } - res, err := s.bskyClient.GetFeed(ctx, args.URI, 50) - if err != nil { - return nil, err - } - - posts := make([]GetPostResponsePost, 0, len(res.Feed)) - for _, p := range res.Feed { - post, ok := p.Post.Record.Val.(*bsky.FeedPost) - if !ok { - continue - } - posts = append(posts, GetPostResponsePost{ - Author: p.Post.Author.Did, - Content: post.Text, - Likes: *p.Post.LikeCount, - Reposts: *p.Post.RepostCount, - Quotes: *p.Post.QuoteCount, - Replies: *p.Post.ReplyCount, - CreatedAt: post.CreatedAt, - }) - } - resp := GetFeedResponse{ - URI: args.URI, - Posts: posts, - } - - respBytes, err := json.Marshal(resp) - if err != nil { - return nil, err - } - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: string(respBytes)}, - }, - }, nil -} - -func (s *Server) getStyleProfile(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - var args GetStyleProfileRequest - if err := json.Unmarshal(request.Params.Arguments, &args); err != nil { - return nil, fmt.Errorf("invalid arguments: %w", err) - } - if args.Author == "" { - return nil, fmt.Errorf("author must be a valid did or handle") - } - - styleProfile, err := s.db.GetStyleProfile(ctx, store.GetStyleProfileParams{ - UserID: args.Author, - Platform: "bluesky", - }) - isRecent := styleProfile.ModifiedAt.Valid && time.Since(styleProfile.ModifiedAt.Time) < 24*7*time.Hour - if err == nil && isRecent { - embedding, err := utils.ParseEmbedding(styleProfile.Embedding) - if err != nil { - return nil, err - } - resp := GetStyleProfileResponse{ - Author: styleProfile.UserID, - PersonaEmbedding: embedding, - TopKeywords: styleProfile.TopKeywords, - PostCount: int(styleProfile.PostCount), - StyleSummary: styleProfile.StyleSummary, - } - - respBytes, err := json.Marshal(resp) - if err != nil { - return nil, err - } - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: string(respBytes)}, - }, - }, nil - } - - platform, err := determinePlatform(args.Author) - if err != nil { - return nil, err - } - switch platform { - case Bluesky: - - } - res, err := s.bskyClient.GetLatestNPosts(ctx, args.Author, 50) - if err != nil { - return nil, err - } - posts := make([]string, 0, len(res)) - var author string - for _, p := range res { - post, ok := p.Post.Record.Val.(*bsky.FeedPost) - if !ok { - continue - } - posts = append(posts, post.Text) - author = p.Post.Author.Did - } - - embeddings, err := s.ollama.EmbedTexts(ctx, posts) - if err != nil { - return nil, err - } - personaEmbedding := utils.AverageVector(embeddings) - topWords := utils.TopWords(posts, make(map[string]struct{}), 10) - styleSummary, err := s.ollama.SummarizeStyle(ctx, posts) - if err != nil { - return nil, err - } - - err = s.db.UpsertStyleProfile(ctx, store.UpsertStyleProfileParams{ - UserID: author, - Platform: "bluesky", - Embedding: personaEmbedding, - TopKeywords: topWords, - StyleSummary: styleSummary, - PostCount: int32(len(posts)), - }) - if err != nil { - return nil, err - } - - if author == "" { - author = args.Author - } - profile := &GetStyleProfileResponse{ - Author: author, - PersonaEmbedding: personaEmbedding, - TopKeywords: topWords, - StyleSummary: styleSummary, - PostCount: len(posts), - SourcePlatform: "bluesky", - } - - profileBytes, err := json.Marshal(profile) - if err != nil { - return nil, err - } - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: string(profileBytes)}, - }, - }, nil -} - -func determinePlatform(author string) (platform, error) { - if strings.HasPrefix(author, "did:") { - return Bluesky, nil - } - if strings.HasPrefix(author, "npub") { - return Nostr, nil - } - if strings.HasPrefix(author, "@") { - if strings.Count(author, "@") > 1 { - return ActivityPub, nil - } - - if strings.Contains(author, ".") { - return Bluesky, nil - } - - return Twitter, nil - } - if len(author) == 64 { - isHex := true - for _, c := range author { - if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { - isHex = false - break - } - } - if isHex { - return Nostr, nil - } - } - - if strings.Contains(author, ".") { - return Bluesky, nil - } - - return -1, fmt.Errorf("unknown author format: %s", author) -} - -func settleHook(ctx context.Context, db *store.Queries, transaction, amount, asset, network, to, from string) error { - ret, err := db.SaveTransaction(ctx, store.SaveTransactionParams{ - X402TransactionHash: transaction, - Amount: amount, - Asset: asset, - Network: network, - Recipient: to, - Sender: from, - }) - - var id int64 = 0 - if len(ret) > 0 { - id = ret[0].ID - } - - fields := log.Fields{ - "transaction_id": id, - "X402_transaction_hash": transaction, - "amount": amount, - "asset": asset, - "network": network, - "recipient": to, - "sender": from, - } - - if err != nil { - log.WithFields(fields).ErrorWithContext(ctx, "Failed to save transaction", "value", ret, "error", err) - return err - } - - log.WithFields(fields).DebugWithContext(ctx, "Saved transaction") - return nil -} - -func (s *Server) buildRequirements(amount string, extra map[string]any) []x402v2.PaymentRequirements { - requirements := make([]x402v2.PaymentRequirements, 0, len(s.activeNetworks)) - - for _, info := range s.activeNetworks { - mergedExtra := make(map[string]any) - maps.Copy(mergedExtra, extra) - maps.Copy(mergedExtra, info.Extra) - requirements = append(requirements, x402v2.PaymentRequirements{ - Scheme: "exact", - Network: info.Network, - Asset: info.Asset, - Amount: amount, - PayTo: s.x402Wallet, - MaxTimeoutSeconds: 60, - Extra: mergedExtra, - }) - } - - return requirements -} - -func parseActiveNetworks(raw string) []string { - parts := strings.Split(raw, ",") - var slugs []string - for _, part := range parts { - trimmed := strings.ToLower(strings.TrimSpace(part)) - if trimmed != "" { - slugs = append(slugs, trimmed) - } - } - return slugs + return s.metrics.MetricsText() } func (s *Server) Start() error { + //go s.startPostPruner() return s.mcpHandler.Start(s.listenAddress, s.permissions) } + +func (s *Server) Shutdown(ctx context.Context) error { + return s.mcpHandler.Shutdown(ctx) +} diff --git a/internal/server/server_init.go b/internal/server/server_init.go new file mode 100644 index 0000000..832d103 --- /dev/null +++ b/internal/server/server_init.go @@ -0,0 +1,146 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + log "github.com/nacorid/logger" + "github.com/nacorid/naco-api/internal/config" + "github.com/nacorid/naco-api/internal/store" + "github.com/nacorid/naco-api/internal/x402" + x402v2 "github.com/x402-foundation/x402/go/v2" + x402http "github.com/x402-foundation/x402/go/v2/http/nethttp" +) + +// buildMiddleware creates the shared middleware chain and its stateful components. +func buildMiddleware() (x402.X402Middlewares, *MetricsMiddleware) { + rateLimits := NewRateLimitMiddleware(float64(1.0/10.0), 2, []string{"getFeed"}) + metrics := NewMetricsHook() + logging := NewLoggingMiddleware() + toolchain := x402.X402Middlewares{ + MCPMiddleware: []mcp.Middleware{ + logging.OnCall, + metrics.OnCall, + rateLimits.OnCall, + }, + HTTPMiddleware: []func(http.Handler) http.Handler{ + logging.OnCallHTTP, + metrics.OnCallHTTP, + rateLimits.OnCallHTTP, + }, + } + return toolchain, metrics +} + +// resolveActiveNetworks parses ACTIVE_NETWORKS and resolves them against NetworkRegistry. +func resolveActiveNetworks(ctx context.Context, raw string) []NetworkInfo { + names := parseActiveNetworks(raw) + var out []NetworkInfo + for _, name := range names { + if info, ok := NetworkRegistry[name]; ok { + out = append(out, info) + } else { + log.WarnWithContext(ctx, "configured network is not defined in the registry", "name", name) + } + } + return out +} + +// newX402Server creates the x402-enabled MCP server with settlement and webhook hooks. +// db is the Store used to persist settlements; cfg provides wallet/webhook settings. +func newX402Server(ctx context.Context, db *store.Queries, cfg *config.Config, toolchain x402.X402Middlewares, metrics *MetricsMiddleware) *x402.X402Server { + auth, err := NewCDPAuth(cfg.ApiKey, cfg.ApiSecret) + if err != nil { + log.FatalWithContext(ctx, "failed to create CDP auth", "error", err) + } + + // Resolve active networks for x402 scheme registration + activeNetworks := resolveActiveNetworks(ctx, cfg.ActiveNetworks) + networkStrings := make([]string, len(activeNetworks)) + for i, net := range activeNetworks { + networkStrings[i] = net.Network + } + + srv, err := x402.NewX402Server( + "x402 enabled social media lookingglass for autonomous agents", + "0.2.0", + cfg.X402FacilitatorURL, + auth, + toolchain, + GlobalLoggingMiddleware, + func(w http.ResponseWriter, r *http.Request, resp *x402v2.SettleResponse) { + requirements, ok := x402http.RequirementsFromContext(r.Context()) + if !ok { + log.WarnWithContext(r.Context(), "failed to extract payment requirements from context during HTTP settlement hook") + return + } + log.InfoWithContext(r.Context(), "persisting HTTP settlement", + "transaction", resp.Transaction, + "network", string(resp.Network), + "payer", resp.Payer, + "payTo", requirements.PayTo, + "amount", requirements.Amount, + "asset", requirements.Asset, + ) + if err := persistSettlement(r.Context(), db, resp.Transaction, requirements.Amount, requirements.Asset, string(resp.Network), requirements.PayTo, resp.Payer); err != nil { + log.WarnWithContext(r.Context(), "failed to persist HTTP settlement", "error", err) + } + }, + networkStrings, + ) + if err != nil { + log.FatalWithContext(ctx, "failed to create x402 server", "error", err) + } + + srv.OnAfterSettle(func(settleCtx x402v2.SettleResultContext) error { + log.InfoWithContext(settleCtx.Ctx, "MCP settlement succeeded", + "transaction", settleCtx.Result.Transaction, + "network", string(settleCtx.Result.Network), + "payer", settleCtx.Result.Payer, + "payTo", settleCtx.Requirements.GetPayTo(), + "amount", settleCtx.Requirements.GetAmount(), + "asset", settleCtx.Requirements.GetAsset(), + "scheme", settleCtx.Requirements.GetScheme(), + ) + if err := persistSettlement(settleCtx.Ctx, db, settleCtx.Result.Transaction, + settleCtx.Requirements.GetAmount(), settleCtx.Requirements.GetAsset(), + string(settleCtx.Result.Network), settleCtx.Requirements.GetPayTo(), settleCtx.Result.Payer); err != nil { + log.WarnWithContext(settleCtx.Ctx, "failed to persist settlement", "error", err) + } + + // Webhook is best-effort and non-blocking. + go sendSettlementWebhook(settleCtx.Ctx, cfg, metrics, settleCtx.Result.Transaction) + return nil + }) + + return srv +} + +// persistSettlement is a thin wrapper around settleHook to keep call sites uniform. +func persistSettlement(ctx context.Context, db *store.Queries, transaction, amount, asset, network, to, from string) error { + return settleHook(ctx, db, transaction, amount, asset, network, to, from) +} + +// sendSettlementWebhook builds and sends the Matrix webhook payload for a settled transaction. +func sendSettlementWebhook(ctx context.Context, cfg *config.Config, metrics *MetricsMiddleware, txHash string) { + wh := newWebhookSender(cfg, metrics) + if wh == nil { + return + } + payload := map[string]any{ + "event": "transaction_success", + "transaction": map[string]any{ + "x402_transaction_hash": txHash, + }, + "metrics": map[string]any{ + "platform": "unknown", + }, + "timestamp": time.Now().UTC().Format(time.RFC3339), + "server_id": cfg.MatrixServerID, + } + jsonBytes, _ := json.Marshal(payload) + wh.Send(ctx, jsonBytes) +} diff --git a/internal/server/settlement.go b/internal/server/settlement.go new file mode 100644 index 0000000..4acb2a0 --- /dev/null +++ b/internal/server/settlement.go @@ -0,0 +1,78 @@ +package server + +import ( + "context" + "maps" + "strings" + + log "github.com/nacorid/logger" + "github.com/nacorid/naco-api/internal/store" + x402v2 "github.com/x402-foundation/x402/go/v2" +) + +func settleHook(ctx context.Context, db *store.Queries, transaction, amount, asset, network, to, from string) error { + ret, err := db.SaveTransaction(ctx, store.SaveTransactionParams{ + X402TransactionHash: transaction, + Amount: amount, + Asset: asset, + Network: network, + Recipient: to, + Sender: from, + }) + + var id int64 = 0 + if len(ret) > 0 { + id = ret[0].ID + } + + fields := log.Fields{ + "transaction_id": id, + "X402_transaction_hash": transaction, + "amount": amount, + "asset": asset, + "network": network, + "recipient": to, + "sender": from, + } + + if err != nil { + log.WithFields(fields).ErrorWithContext(ctx, "Failed to save transaction", "error", err) + return err + } + + log.WithFields(fields).DebugWithContext(ctx, "Saved transaction") + return nil +} + +func (s *Server) buildRequirements(amount string, extra map[string]any, scheme string, timeout int) []x402v2.PaymentRequirements { + requirements := make([]x402v2.PaymentRequirements, 0, len(s.activeNetworks)) + + for _, info := range s.activeNetworks { + mergedExtra := make(map[string]any) + maps.Copy(mergedExtra, extra) + maps.Copy(mergedExtra, info.Extra) + requirements = append(requirements, x402v2.PaymentRequirements{ + Scheme: scheme, + Network: info.Network, + Asset: info.Asset, + Amount: amount, + PayTo: s.x402Wallet, + MaxTimeoutSeconds: timeout, + Extra: mergedExtra, + }) + } + + return requirements +} + +func parseActiveNetworks(raw string) []string { + parts := strings.Split(raw, ",") + var slugs []string + for _, part := range parts { + trimmed := strings.ToLower(strings.TrimSpace(part)) + if trimmed != "" { + slugs = append(slugs, trimmed) + } + } + return slugs +} diff --git a/internal/server/storefake_test.go b/internal/server/storefake_test.go new file mode 100644 index 0000000..4ab3b24 --- /dev/null +++ b/internal/server/storefake_test.go @@ -0,0 +1,214 @@ +package server + +import ( + "context" + "database/sql" + "encoding/json" + "sort" + "time" + + "github.com/nacorid/naco-api/internal/store" +) + +// fakeStore is an in-memory implementation of the Store interface used by handler +// tests so db-backed tool paths (style profiles, analytics, similar users) can be +// exercised without a live Postgres+pgvector. +type fakeStore struct { + analyticsCache map[cacheKey]cacheEntry + analyticsPosts map[string]store.AnalyticsPost + coverageRanges []store.AnalyticsCoverageRange + styleProfiles map[string]store.UserStyleProfile + stylePosts map[string]store.UserStylePost + similarRows []store.FindSimilarUsersRow + findSimilarErr error + styleProfileErr error + stylePostsErr error + cacheErr bool + postsRangeErr bool + coverageErr bool +} + +type cacheKey struct { + user, platform, tool, hash string +} + +type cacheEntry struct { + value json.RawMessage + exp time.Time +} + +func newFakeStore() *fakeStore { + return &fakeStore{ + analyticsCache: map[cacheKey]cacheEntry{}, + analyticsPosts: map[string]store.AnalyticsPost{}, + styleProfiles: map[string]store.UserStyleProfile{}, + stylePosts: map[string]store.UserStylePost{}, + } +} + +func postKey(user, platform, id string) string { + return user + "|" + platform + "|" + id +} + +func profileKey(user, platform string) string { + return user + "|" + platform +} + +func (f *fakeStore) GetAnalyticsCache(ctx context.Context, arg store.GetAnalyticsCacheParams) (json.RawMessage, error) { + if f.cacheErr { + return nil, sql.ErrNoRows + } + key := cacheKey{arg.UserID, arg.Platform, arg.ToolName, arg.TimeRangeHash} + entry, ok := f.analyticsCache[key] + if !ok || time.Now().After(entry.exp) { + return nil, sql.ErrNoRows + } + return entry.value, nil +} + +func (f *fakeStore) UpsertAnalyticsCache(ctx context.Context, arg store.UpsertAnalyticsCacheParams) error { + key := cacheKey{arg.UserID, arg.Platform, arg.ToolName, arg.TimeRangeHash} + f.analyticsCache[key] = cacheEntry{value: arg.Result, exp: arg.ExpiresAt} + return nil +} + +func (f *fakeStore) GetAnalyticsPostsInRange(ctx context.Context, arg store.GetAnalyticsPostsInRangeParams) ([]store.GetAnalyticsPostsInRangeRow, error) { + if f.postsRangeErr { + return nil, sql.ErrConnDone + } + var rows []store.GetAnalyticsPostsInRangeRow + for _, p := range f.analyticsPosts { + if p.UserID != arg.UserID || p.Platform != arg.Platform { + continue + } + if p.PostTimestamp.Before(arg.PostTimestamp) || p.PostTimestamp.After(arg.PostTimestamp_2) { + continue + } + rows = append(rows, store.GetAnalyticsPostsInRangeRow{ + PostID: p.PostID, + PostText: p.PostText, + PostTimestamp: p.PostTimestamp, + Likes: p.Likes, + Reposts: p.Reposts, + Replies: p.Replies, + Quotes: p.Quotes, + }) + } + sort.Slice(rows, func(i, j int) bool { return rows[i].PostTimestamp.Before(rows[j].PostTimestamp) }) + if int32(len(rows)) > arg.Limit { + rows = rows[:arg.Limit] + } + return rows, nil +} + +func (f *fakeStore) UpsertAnalyticsPosts(ctx context.Context, arg store.UpsertAnalyticsPostsParams) error { + f.analyticsPosts[postKey(arg.UserID, arg.Platform, arg.PostID)] = store.AnalyticsPost{ + UserID: arg.UserID, + Platform: arg.Platform, + PostID: arg.PostID, + PostText: arg.PostText, + PostTimestamp: arg.PostTimestamp, + Likes: arg.Likes, + Reposts: arg.Reposts, + Replies: arg.Replies, + Quotes: arg.Quotes, + } + return nil +} + +func (f *fakeStore) GetAnalyticsCoverageRanges(ctx context.Context, arg store.GetAnalyticsCoverageRangesParams) ([]store.GetAnalyticsCoverageRangesRow, error) { + if f.coverageErr { + return nil, sql.ErrConnDone + } + var rows []store.GetAnalyticsCoverageRangesRow + for _, r := range f.coverageRanges { + if r.UserID != arg.UserID || r.Platform != arg.Platform { + continue + } + rows = append(rows, store.GetAnalyticsCoverageRangesRow{CoveredStart: r.CoveredStart, CoveredEnd: r.CoveredEnd}) + } + return rows, nil +} + +func (f *fakeStore) InsertAnalyticsCoverageRange(ctx context.Context, arg store.InsertAnalyticsCoverageRangeParams) error { + f.coverageRanges = append(f.coverageRanges, store.AnalyticsCoverageRange{ + UserID: arg.UserID, + Platform: arg.Platform, + CoveredStart: arg.CoveredStart, + CoveredEnd: arg.CoveredEnd, + }) + return nil +} + +func (f *fakeStore) GetStyleProfile(ctx context.Context, arg store.GetStyleProfileParams) (store.UserStyleProfile, error) { + if f.styleProfileErr != nil { + return store.UserStyleProfile{}, f.styleProfileErr + } + p, ok := f.styleProfiles[profileKey(arg.UserID, arg.Platform)] + if !ok { + return store.UserStyleProfile{}, sql.ErrNoRows + } + return p, nil +} + +func (f *fakeStore) UpsertStyleProfile(ctx context.Context, arg store.UpsertStyleProfileParams) error { + f.styleProfiles[profileKey(arg.UserID, arg.Platform)] = store.UserStyleProfile{ + UserID: arg.UserID, + Platform: arg.Platform, + Embedding: arg.Embedding, + TopKeywords: arg.TopKeywords, + StyleSummary: arg.StyleSummary, + PostCount: arg.PostCount, + ModifiedAt: sql.NullTime{Time: time.Now(), Valid: true}, + } + return nil +} + +func (f *fakeStore) DeleteStylePosts(ctx context.Context, arg store.DeleteStylePostsParams) error { + for _, k := range f.stylePosts { + if k.UserID == arg.UserID && k.Platform == arg.Platform { + delete(f.stylePosts, postKey(k.UserID, k.Platform, k.PostID)) + } + } + return nil +} + +func (f *fakeStore) GetStylePosts(ctx context.Context, arg store.GetStylePostsParams) ([]store.GetStylePostsRow, error) { + if f.stylePostsErr != nil { + return nil, f.stylePostsErr + } + var rows []store.GetStylePostsRow + for _, p := range f.stylePosts { + if p.UserID != arg.UserID || p.Platform != arg.Platform { + continue + } + rows = append(rows, store.GetStylePostsRow{PostID: p.PostID, PostText: p.PostText}) + } + sort.Slice(rows, func(i, j int) bool { return rows[i].PostID > rows[j].PostID }) + if int32(len(rows)) > arg.Limit { + rows = rows[:arg.Limit] + } + return rows, nil +} + +func (f *fakeStore) UpsertStylePosts(ctx context.Context, arg store.UpsertStylePostsParams) error { + f.stylePosts[postKey(arg.UserID, arg.Platform, arg.PostID)] = store.UserStylePost{ + UserID: arg.UserID, + Platform: arg.Platform, + PostID: arg.PostID, + PostText: arg.PostText, + CreatedAt: time.Now(), + } + return nil +} + +func (f *fakeStore) FindSimilarUsers(ctx context.Context, arg store.FindSimilarUsersParams) ([]store.FindSimilarUsersRow, error) { + if f.findSimilarErr != nil { + return nil, f.findSimilarErr + } + return f.similarRows, nil +} + +func (f *fakeStore) PruneStylePosts(ctx context.Context) error { + return nil +} diff --git a/internal/server/style.go b/internal/server/style.go new file mode 100644 index 0000000..8015d50 --- /dev/null +++ b/internal/server/style.go @@ -0,0 +1,360 @@ +package server + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "sync" + "time" + + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + log "github.com/nacorid/logger" + "github.com/nacorid/naco-api/internal/clustering" + "github.com/nacorid/naco-api/internal/store" + "github.com/nacorid/naco-api/internal/utils" +) + +func (s *Server) getStyleProfile(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var args GetStyleProfileRequest + if err := json.Unmarshal(request.Params.Arguments, &args); err != nil { + return nil, fmt.Errorf("invalid arguments: %w", err) + } + if args.Author == "" { + return nil, fmt.Errorf("author must be a valid did or handle") + } + + profile, err := s.requestStyleProfile(ctx, args.Author) + if err != nil { + return nil, err + } + + // Best-effort: also look for this author on every other platform in the background so + // future requests can be served from cache. Failures are logged and never affect the + // response. Uses a detached context so the work outlives the request. + if s.enrichEnabled { + if src, ok := platformFromString(profile.SourcePlatform); ok { + go s.enrichCrossPlatform(context.WithoutCancel(ctx), src, args.Author) + } + } + + profileBytes, err := json.Marshal(profile) + if err != nil { + return nil, err + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: string(profileBytes)}, + }, + }, nil +} + +// requestStyleProfile returns a style profile for author. It computes and persists the +// profile only when a request is made AND the cached profile is older than a month (or +// missing); otherwise it serves the cached copy. The prewarmer calls this same path, so a +// pre-fetch is treated as a system-initiated request and obeys the identical update policy. +func (s *Server) requestStyleProfile(ctx context.Context, author string) (*GetStyleProfileResponse, error) { + platform, err := determinePlatform(author) + if err != nil { + return nil, err + } + return s.requestStyleProfileForced(ctx, platform, author) +} + +// requestStyleProfileForced is the core of requestStyleProfile for an explicitly chosen +// platform. It resolves the author id, serves a fresh cached profile, or builds (and +// persists) a new one when the cache is stale/missing. +func (s *Server) requestStyleProfileForced(ctx context.Context, platform platform, author string) (*GetStyleProfileResponse, error) { + var authorID string + switch platform { + case Bluesky: + did, rerr := s.bskyClient.ResolveUser(ctx, author) + if rerr != nil { + return nil, rerr + } + authorID = did.String() + case ActivityPub, Nostr, Twitter: + authorID = author + default: + return nil, fmt.Errorf("platform %q is not supported yet", platform) + } + + styleProfile, err := s.db.GetStyleProfile(ctx, store.GetStyleProfileParams{ + UserID: authorID, + Platform: platform.String(), + }) + if err == nil { + isRecent := styleProfile.ModifiedAt.Valid && time.Since(styleProfile.ModifiedAt.Time) < 24*30*time.Hour + if isRecent { + embedding, perr := utils.ParseEmbedding(styleProfile.Embedding) + if perr != nil { + return nil, perr + } + return &GetStyleProfileResponse{ + Author: styleProfile.UserID, + PersonaEmbedding: embedding, + TopKeywords: styleProfile.TopKeywords, + PostCount: int(styleProfile.PostCount), + StyleSummary: styleProfile.StyleSummary, + SourcePlatform: platform.String(), + }, nil + } + } else if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("failed to load cached style profile: %w", err) + } + + return s.buildStyleProfile(ctx, platform, authorID, author) +} + +// enrichCrossPlatform looks for author on every platform other than sourcePlatform, in the +// background. It uses the same identifier on each platform (matching only where that handle +// exists; mismatches fail gracefully and are skipped) and obeys the same 1-month update gate +// as a live request. Concurrency is bounded to the other three platforms. +func (s *Server) enrichCrossPlatform(ctx context.Context, sourcePlatform platform, author string) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + + if s.metrics != nil { + s.metrics.RecordEnrichment() + } + + // Bound concurrent background enrichment so a burst of live requests can't overwhelm + // Ollama. Block until a slot is free (or the context is cancelled). + select { + case s.enrichSem <- struct{}{}: + defer func() { <-s.enrichSem }() + case <-ctx.Done(): + return + } + + var wg sync.WaitGroup + for _, p := range []platform{Bluesky, ActivityPub, Nostr, Twitter} { + if p == sourcePlatform { + continue + } + wg.Add(1) + go func(p platform) { + defer wg.Done() + if _, err := s.requestStyleProfileForced(ctx, p, author); err != nil { + log.DebugWithContext(ctx, "cross-platform enrichment skipped", "platform", p.String(), "author", author, "error", err) + return + } + log.InfoWithContext(ctx, "cross-platform enrichment done", "platform", p.String(), "author", author) + }(p) + } + wg.Wait() +} + +func (s *Server) getSimilarProfiles(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var args FindSimilarUsersRequest + if err := json.Unmarshal(request.Params.Arguments, &args); err != nil { + return nil, fmt.Errorf("invalid arguments: %w", err) + } + if args.Author == "" { + return nil, fmt.Errorf("author must be a valid did or handle") + } + if args.Limit <= 0 { + args.Limit = 10 + } + if args.Limit > 50 { + args.Limit = 50 + } + + profile, err := s.requestStyleProfile(ctx, args.Author) + if err != nil { + return nil, err + } + + vec := utils.FormatVector(profile.PersonaEmbedding) + rows, err := s.db.FindSimilarUsers(ctx, store.FindSimilarUsersParams{ + QueryEmbedding: vec, + LimitVal: args.Limit, + }) + if err != nil { + return nil, fmt.Errorf("failed to search similar users: %w", err) + } + + matches := make([]SimilarUser, 0, len(rows)) + for _, r := range rows { + if r.UserID == profile.Author { + continue + } + matches = append(matches, SimilarUser{ + Author: r.UserID, + Platform: r.Platform, + Similarity: float64(r.Similarity), + TopKeywords: r.TopKeywords, + StyleSummary: r.StyleSummary, + PostCount: int(r.PostCount), + }) + } + + twitterPostCount := 0 + if profile.SourcePlatform == "Twitter" { + twitterPostCount = profile.PostCount + } + respBytes, err := json.Marshal(FindSimilarUsersResponse{ + Matches: matches, + TwitterPostCount: twitterPostCount, + }) + if err != nil { + return nil, err + } + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: string(respBytes)}, + }, + }, nil +} + +func (s *Server) buildStyleProfile(ctx context.Context, platform platform, authorID, author string) (*GetStyleProfileResponse, error) { + posts, err := s.fetchProfilePosts(ctx, platform, authorID, 50) + if err != nil { + return nil, err + } + if len(posts) == 0 { + return nil, fmt.Errorf("no posts found for author %s", author) + } + + // Drop posts without any text (e.g. Bluesky image/reply-only posts). Embedding them + // yields empty vectors, which would poison the persona-average and the persisted + // profile. + filtered := posts[:0] + for _, p := range posts { + if strings.TrimSpace(p.Text) == "" { + continue + } + filtered = append(filtered, p) + } + if len(filtered) == 0 { + return nil, fmt.Errorf("no posts found for author %s", author) + } + posts = filtered + + // Delete old posts for non-Twitter platforms before rebuild + if platform != Twitter { + if err := s.db.DeleteStylePosts(ctx, store.DeleteStylePostsParams{ + UserID: authorID, + Platform: platform.String(), + }); err != nil { + log.WarnWithContext(ctx, "failed to delete old style posts", "author", authorID, "platform", platform, "error", err) + } + } + + // Extract texts for embedding and keyword analysis + postTexts := make([]string, len(posts)) + for i, p := range posts { + postTexts[i] = p.Text + } + + // Fail fast on any server-side error. These are payable tools: returning a degraded + // profile here would bill the caller for a deliverable they didn't ask for. The x402 + // layer only settles payment when the handler succeeds, so returning the error means + // the caller is not charged. + embeddings, err := s.ollama.EmbedTexts(ctx, posts) + if err != nil { + return nil, err + } + personaEmbedding := utils.AverageVector(embeddings) + topWords := utils.TopWords(postTexts, make(map[string]struct{}), 10) + styleSummary, err := s.ollama.SummarizeStyle(ctx, posts) + if err != nil { + return nil, err + } + + err = s.db.UpsertStyleProfile(ctx, store.UpsertStyleProfileParams{ + UserID: authorID, + Platform: platform.String(), + Embedding: utils.FormatVector(personaEmbedding), + TopKeywords: topWords, + StyleSummary: styleSummary, + PostCount: int32(len(posts)), + }) + if err != nil { + return nil, err + } + + return &GetStyleProfileResponse{ + Author: authorID, + PersonaEmbedding: personaEmbedding, + TopKeywords: topWords, + StyleSummary: styleSummary, + PostCount: len(posts), + SourcePlatform: platform.String(), + }, nil +} + +func (s *Server) fetchProfilePosts(ctx context.Context, platform platform, author string, limit int) ([]clustering.Item, error) { + platformStr := platform.String() + + // 1. Try cache first + cached, err := s.db.GetStylePosts(ctx, store.GetStylePostsParams{ + UserID: author, + Platform: platformStr, + Limit: int32(limit), + }) + if err == nil && len(cached) >= limit { + posts := make([]clustering.Item, len(cached)) + for i, c := range cached { + posts[i] = clustering.Item{ID: c.PostID, Text: c.PostText} + } + return posts, nil + } + + // 2. Fetch from platform + var freshPosts []clustering.Item + switch platform { + case Bluesky: + res, err := s.bskyClient.GetLatestNPosts(ctx, author, limit) + if err != nil { + return toItems(cached), err + } + freshPosts = blueskyFeedViewsToItems(res) + case ActivityPub: + res, err := s.apClient.GetLatestNPosts(ctx, author, limit) + if err != nil { + return toItems(cached), err + } + freshPosts = apPostsPtrToItems(res) + case Nostr: + res, err := s.nostrClient.GetLatestNPosts(ctx, author, limit) + if err != nil { + return toItems(cached), err + } + freshPosts = nostrPostsToItems(res) + case Twitter: + res, err := s.twitterClient.GetLatestNPosts(ctx, author, limit) + if err != nil { + return toItems(cached), err + } + freshPosts = twitterPostsToItems(res) + default: + return nil, fmt.Errorf("platform %q is not supported yet", platform) + } + + // 3. Upsert to cache (batch) + for _, post := range freshPosts { + if err := s.db.UpsertStylePosts(ctx, store.UpsertStylePostsParams{ + UserID: author, + Platform: platformStr, + PostID: post.ID, + PostText: post.Text, + }); err != nil { + log.WarnWithContext(ctx, "failed to cache style post", "author", author, "platform", platform, "postID", post.ID, "error", err) + } + } + + return freshPosts, nil +} + +func toItems(rows []store.GetStylePostsRow) []clustering.Item { + items := make([]clustering.Item, len(rows)) + for i, r := range rows { + items[i] = clustering.Item{ID: r.PostID, Text: r.PostText} + } + return items +} diff --git a/internal/server/styleprofile_test.go b/internal/server/styleprofile_test.go new file mode 100644 index 0000000..9051f18 --- /dev/null +++ b/internal/server/styleprofile_test.go @@ -0,0 +1,375 @@ +package server + +import ( + "database/sql" + "fmt" + "reflect" + "strings" + "testing" + "time" + + "github.com/bluesky-social/indigo/api/bsky" + "github.com/nacorid/naco-api/internal/activitypub" + "github.com/nacorid/naco-api/internal/store" +) + +// ---- getStyleProfile ---- + +func TestGetStyleProfileBuildsFresh(t *testing.T) { + ap := &fakeAP{posts: []*activitypub.Post{ + {Author: "@a@mastodon.social", Content: "alpha post one", CreatedAt: time.Now()}, + {Author: "@a@mastodon.social", Content: "alpha post two", CreatedAt: time.Now()}, + }} + st := newFakeStore() + oll := &fakeOllama{embeddings: [][]float64{{0.1, 0.2}, {0.3, 0.4}}} + s := newServerWith(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}, st, oll) + + res, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "@a@mastodon.social"}) + if err != nil { + t.Fatalf("getStyleProfile error: %v", err) + } + var out GetStyleProfileResponse + if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if out.Author != "@a@mastodon.social" { + t.Fatalf("expected author, got %q", out.Author) + } + if out.PostCount != 2 { + t.Fatalf("expected post count 2, got %d", out.PostCount) + } + if out.SourcePlatform != "ActivityPub" { + t.Fatalf("expected ActivityPub source, got %q", out.SourcePlatform) + } + if oll.embedCalls != 1 || oll.summCalls != 1 { + t.Fatalf("expected 1 embed and 1 summarize call, got %d/%d", oll.embedCalls, oll.summCalls) + } +} + +func TestGetStyleProfileServesFreshCache(t *testing.T) { + // A profile modified less than the 30-day stale threshold is served without hitting the + // backend or ollama. + st := newFakeStore() + st.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{ + UserID: "@a@mastodon.social", + Platform: "ActivityPub", + Embedding: "[0.1,0.2]", + TopKeywords: []string{"alpha"}, + StyleSummary: "cached summary", + PostCount: 5, + ModifiedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true}, + } + ap := &fakeAP{} + oll := &fakeOllama{} + s := newServerWith(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}, st, oll) + + res, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "@a@mastodon.social"}) + if err != nil { + t.Fatalf("getStyleProfile error: %v", err) + } + var out GetStyleProfileResponse + if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if out.StyleSummary != "cached summary" { + t.Fatalf("expected cached summary, got %q", out.StyleSummary) + } + if ap.calls != 0 { + t.Fatalf("expected no backend call on cache hit, got %d", ap.calls) + } + if oll.summCalls != 0 { + t.Fatalf("expected no ollama call on cache hit, got %d", oll.summCalls) + } +} + +func TestGetStyleProfileRebuildsStale(t *testing.T) { + // A stale (older than a month) cached profile triggers a rebuild. + st := newFakeStore() + st.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{ + UserID: "@a@mastodon.social", + Platform: "ActivityPub", + Embedding: "[0.1,0.2]", + TopKeywords: []string{"old"}, + StyleSummary: "stale summary", + PostCount: 5, + ModifiedAt: sql.NullTime{Time: time.Now().Add(-31 * 24 * time.Hour), Valid: true}, + } + ap := &fakeAP{posts: []*activitypub.Post{ + {Author: "@a@mastodon.social", Content: "fresh post", CreatedAt: time.Now()}, + }} + s := newServerWith(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{}) + + res, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "@a@mastodon.social"}) + if err != nil { + t.Fatalf("getStyleProfile error: %v", err) + } + var out GetStyleProfileResponse + if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if out.StyleSummary == "stale summary" { + t.Fatal("expected rebuild to replace stale summary") + } + if out.PostCount != 1 { + t.Fatalf("expected rebuilt post count 1, got %d", out.PostCount) + } +} + +func TestGetStyleProfileDBErrorIsNotNoRows(t *testing.T) { + st := newFakeStore() + st.styleProfileErr = fmt.Errorf("db down") + s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{}) + if _, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "@a@mastodon.social"}); err == nil || !strings.Contains(err.Error(), "db down") { + t.Fatalf("expected db error propagation, got %v", err) + } +} + +func TestGetStyleProfileNoPostsFound(t *testing.T) { + // Empty backend result while building leads to a "no posts found" error. + ap := &fakeAP{} // no posts + s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}) + _, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "@a@mastodon.social"}) + if err == nil || !strings.Contains(err.Error(), "no posts found") { + t.Fatalf("expected no-posts error, got %v", err) + } +} + +func TestGetStyleProfileOllamaEmbedFailure(t *testing.T) { + ap := &fakeAP{posts: []*activitypub.Post{{ID: "p1", Author: "@a@mastodon.social", Content: "post", CreatedAt: time.Now()}}} + oll := &fakeOllama{embedErr: fmt.Errorf("embedding failed")} + st := newFakeStore() + s := newServerWith(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}, st, oll) + if _, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "@a@mastodon.social"}); err == nil || !strings.Contains(err.Error(), "embedding") { + t.Fatalf("expected embed failure, got %v", err) + } +} + +func TestGetStyleProfileBlueskyResolution(t *testing.T) { + // Bluesky resolves the did before building/persisting. + bsky := &fakeBluesky{feedViews: []*bsky.FeedDefs_FeedViewPost{{Post: bskyPostView("bsky post")}}} + oll := &fakeOllama{embeddings: [][]float64{{0.1, 0.2}}} + st := newFakeStore() + s := newServerWith(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, oll) + res, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "did:plc:abc"}) + if err != nil { + t.Fatalf("getStyleProfile error: %v", err) + } + var out GetStyleProfileResponse + if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if out.Author != "did:plc:abc" { + t.Fatalf("expected resolved did, got %q", out.Author) + } + if out.SourcePlatform != "Bluesky" { + t.Fatalf("expected Bluesky source, got %q", out.SourcePlatform) + } +} + +func TestGetStyleProfileFirstEmbeddingEmpty(t *testing.T) { + // Regression: an empty embedding returned by Ollama for a non-first post used to + // panic AverageVector when the first vector was also empty (accumulator sized to + // length 0, later non-empty vector indexed it). Empty vectors are skipped. + bsky := &fakeBluesky{feedViews: []*bsky.FeedDefs_FeedViewPost{ + {Post: bskyPostView("first post")}, + {Post: bskyPostView("second post")}, + }} + oll := &fakeOllama{embeddings: [][]float64{{}, {0.1, 0.2}}} + st := newFakeStore() + s := newServerWith(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, oll) + + res, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "did:plc:abc"}) + if err != nil { + t.Fatalf("getStyleProfile error: %v", err) + } + var out GetStyleProfileResponse + if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !reflect.DeepEqual(out.PersonaEmbedding, []float64{0.1, 0.2}) { + t.Fatalf("expected averaged embedding {0.1,0.2}, got %v", out.PersonaEmbedding) + } + if out.PostCount != 2 { + t.Fatalf("expected post count 2, got %d", out.PostCount) + } +} + +func TestGetStyleProfileSkipsEmptyTextPosts(t *testing.T) { + // A Bluesky post with no text (image/reply-only) must not reach Ollama: its empty + // embedding would poison the persona average. It is dropped before embedding. + bsky := &fakeBluesky{feedViews: []*bsky.FeedDefs_FeedViewPost{ + {Post: bskyPostView("")}, + {Post: bskyPostView("real post")}, + }} + oll := &fakeOllama{} + st := newFakeStore() + s := newServerWith(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, oll) + + res, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "did:plc:abc"}) + if err != nil { + t.Fatalf("getStyleProfile error: %v", err) + } + var out GetStyleProfileResponse + if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if out.PostCount != 1 { + t.Fatalf("expected filtered post count 1, got %d", out.PostCount) + } + if len(out.PersonaEmbedding) != 2 { + t.Fatalf("expected 2-dim embedding, got %v", out.PersonaEmbedding) + } +} + +// ---- findSimilarUsers ---- + +func TestFindSimilarUsers(t *testing.T) { + st := newFakeStore() + st.similarRows = []store.FindSimilarUsersRow{ + {UserID: "did:plc:other", Platform: "Bluesky", TopKeywords: []string{"x"}, StyleSummary: "s", PostCount: 3, Similarity: 90}, + } + // Serve a fresh cached profile so requestStyleProfile doesn't need posts. + st.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{ + UserID: "@a@mastodon.social", + Platform: "ActivityPub", + Embedding: "[0.1,0.2]", + ModifiedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true}, + } + s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{}) + res, err := callTool(s, "findSimilarUsers", FindSimilarUsersRequest{Author: "@a@mastodon.social", Limit: 10}) + if err != nil { + t.Fatalf("findSimilarUsers error: %v", err) + } + var out FindSimilarUsersResponse + if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(out.Matches) != 1 { + t.Fatalf("expected 1 match, got %d", len(out.Matches)) + } + if out.Matches[0].Author != "did:plc:other" { + t.Fatalf("unexpected match: %+v", out.Matches[0]) + } +} + +func TestFindSimilarUsersExcludesSelf(t *testing.T) { + st := newFakeStore() + // Both rows share the query author's user ID; the self row must be excluded. + st.similarRows = []store.FindSimilarUsersRow{ + {UserID: "@a@mastodon.social", Platform: "ActivityPub"}, // same as query author + {UserID: "did:plc:other", Platform: "Bluesky", Similarity: 80}, + } + st.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{ + UserID: "@a@mastodon.social", + Platform: "ActivityPub", + Embedding: "[0.1,0.2]", + ModifiedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true}, + } + s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{}) + res, err := callTool(s, "findSimilarUsers", FindSimilarUsersRequest{Author: "@a@mastodon.social", Limit: 10}) + if err != nil { + t.Fatalf("findSimilarUsers error: %v", err) + } + var out FindSimilarUsersResponse + if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(out.Matches) != 1 { + t.Fatalf("expected self to be excluded, got %d matches: %+v", len(out.Matches), out.Matches) + } + if out.Matches[0].Author != "did:plc:other" { + t.Fatalf("expected only the non-self match, got %+v", out.Matches) + } +} + +func TestFindSimilarUsersEmptyMatches(t *testing.T) { + st := newFakeStore() + st.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{ + UserID: "@a@mastodon.social", + Platform: "ActivityPub", + Embedding: "[0.1,0.2]", + ModifiedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true}, + } + s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{}) + res, err := callTool(s, "findSimilarUsers", FindSimilarUsersRequest{Author: "@a@mastodon.social"}) + if err != nil { + t.Fatalf("findSimilarUsers error: %v", err) + } + var out FindSimilarUsersResponse + if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(out.Matches) != 0 { + t.Fatalf("expected empty matches, got %d", len(out.Matches)) + } +} + +// TestFindSimilarUsersTwitterPostCount verifies the response reports how many +// posts went into the query author's style profile when that author is on +// Twitter, so upto-scheme settlement can charge the realized Twitter API costs. +func TestFindSimilarUsersTwitterPostCount(t *testing.T) { + st := newFakeStore() + st.similarRows = []store.FindSimilarUsersRow{ + {UserID: "did:plc:other", Platform: "Bluesky", Similarity: 80}, + } + // Serve a fresh cached Twitter profile with 42 analyzed posts. + st.styleProfiles["@alice|Twitter"] = store.UserStyleProfile{ + UserID: "@alice", + Platform: "Twitter", + Embedding: "[0.1,0.2]", + PostCount: 42, + ModifiedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true}, + } + s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{}) + res, err := callTool(s, "findSimilarUsers", FindSimilarUsersRequest{Author: "@alice"}) + if err != nil { + t.Fatalf("findSimilarUsers error: %v", err) + } + var out FindSimilarUsersResponse + if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if out.TwitterPostCount != 42 { + t.Fatalf("expected TwitterPostCount 42, got %d", out.TwitterPostCount) + } + + // A non-Twitter query author reports 0. + st2 := newFakeStore() + st2.similarRows = []store.FindSimilarUsersRow{ + {UserID: "did:plc:other", Platform: "Bluesky", Similarity: 80}, + } + st2.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{ + UserID: "@a@mastodon.social", + Platform: "ActivityPub", + Embedding: "[0.1,0.2]", + PostCount: 9, + ModifiedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true}, + } + s2 := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st2, &fakeOllama{}) + res2, err := callTool(s2, "findSimilarUsers", FindSimilarUsersRequest{Author: "@a@mastodon.social"}) + if err != nil { + t.Fatalf("findSimilarUsers error: %v", err) + } + var out2 FindSimilarUsersResponse + if err := jsonUnmarshal(t, textOf(t, res2), &out2); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if out2.TwitterPostCount != 0 { + t.Fatalf("expected TwitterPostCount 0 for ActivityPub, got %d", out2.TwitterPostCount) + } +} + +func TestFindSimilarUsersDBError(t *testing.T) { + st := newFakeStore() + st.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{ + UserID: "@a@mastodon.social", + Platform: "ActivityPub", + Embedding: "[0.1,0.2]", + ModifiedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true}, + } + st.findSimilarErr = fmt.Errorf("query failed") + s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{}) + if _, err := callTool(s, "findSimilarUsers", FindSimilarUsersRequest{Author: "@a@mastodon.social"}); err == nil || !strings.Contains(err.Error(), "similar") { + t.Fatalf("expected similar-users error, got %v", err) + } +} diff --git a/internal/server/tools.go b/internal/server/tools.go new file mode 100644 index 0000000..bb91c3d --- /dev/null +++ b/internal/server/tools.go @@ -0,0 +1,501 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/nacorid/naco-api/internal/x402" +) + +// registerTools registers all MCP tools with their x402 payment requirements. +// The payable tools are defined in a table to avoid repeating Schema construction. +func (s *Server) registerTools() { + // Helper to build the x402 extra map from input/output BodyFields. + schemaExtra := func(input, output map[string]any) map[string]any { + return map[string]any{ + "OutputSchema": Schema{ + Input: InputSchema{ + Type: "HTTP", + Method: "POST", + Discoverable: true, + BodyFields: input, + }, + Output: output, + }, + } + } + + analyticsInput := map[string]any{ + "author": Bodyfield{Type: "string", Description: "Did or handle of an author", Required: true}, + "startTime": Bodyfield{Type: "string", Description: "Start time in RFC3339 format (default: 30 days ago)", Required: false}, + "endTime": Bodyfield{Type: "string", Description: "End time in RFC3339 format (default: now)", Required: false}, + "limit": Bodyfield{Type: "integer", Description: "Maximum number of posts to analyze (default: 50, max: 200)", Required: false}, + "platform": Bodyfield{Type: "string", Description: "Platform to filter (default: all)", Required: false}, + } + + type payableDef struct { + Name string + Desc string + Input any + Output any + Handler func(context.Context, *mcp.CallToolRequest) (*mcp.CallToolResult, error) + Price string + MaxTimeoutSeconds int + // UsesTwitter marks tools that may read posts (or set up lookups) + // through the Twitter API and return only transformative/aggregated + // results, as permitted by the X API policy. With Twitter enabled these + // tools charge upto-scheme: base price plus the realized Twitter API + // costs. Tools that resell raw post/profile/feed data (getPost, + // getLatestPosts, ...) never set this. + UsesTwitter bool + // TwitterPostCap is the post count used to compute the advertised upto + // maximum (see x402.MaxTwitterCostMicro). It only bounds the largest + // settlement the tool can demand; the actual charge is settled from the + // response. + TwitterPostCap int64 + Extra map[string]any + } + + payable := []payableDef{ + { + Name: "getPost", Desc: "Get a specific post from a specific actor", + Input: GetPostRequest{}, Output: GetPostResponsePost{}, Handler: s.getPost, Price: "0.01", + Extra: schemaExtra( + map[string]any{ + "author": Bodyfield{Type: "string", Description: "Did or handle of an author", Required: true}, + "rkey": Bodyfield{Type: "string", Description: "Record key of the post", Required: true}, + "collection": Bodyfield{Type: "string", Description: "Collection the post belongs to (Bluesky only). Defaults to 'app.bsky.feed.post'", Required: false}, + }, + map[string]any{ + "author": Bodyfield{Type: "string", Description: "Did of the author"}, + "post": Bodyfield{Type: "object", Description: "The post object. Includes the post content 'content', like 'likes', quote 'quotes', replies 'replies' and repost 'reposts' count"}, + }, + ), + }, + { + Name: "getLatestPosts", Desc: "Get the latest n posts from a specific actor", + Input: GetLatestPostsRequest{}, Output: GetLatestPostsResponse{}, Handler: s.getLatestPosts, Price: "0.10", + Extra: schemaExtra( + map[string]any{ + "author": Bodyfield{Type: "string", Description: "Did or handle of an author", Required: true}, + "n": Bodyfield{Type: "integer", Description: "Number of posts to retrieve, maximum 50", Required: true}, + }, + map[string]any{ + "author": Bodyfield{Type: "string", Description: "Did of an actor"}, + "posts": Bodyfield{Type: "array", Description: "Array of post objects. Each includes the post content 'content', like 'likes', quote 'quotes', replies 'replies' and repost 'reposts' count"}, + }, + ), + }, + { + Name: "getPostsInTime", Desc: "Get posts from a specific actor in a time range", + Input: GetPostsInTimeRequest{}, Output: GetPostsInTimeResponse{}, Handler: s.getPostsInTime, Price: "0.10", + Extra: schemaExtra( + map[string]any{ + "author": Bodyfield{Type: "string", Description: "Did or handle of an author", Required: true}, + "start": Bodyfield{Type: "string", Description: "Start time in RFC3339 format", Required: true}, + "end": Bodyfield{Type: "string", Description: "End time in RFC3339 format", Required: true}, + "n": Bodyfield{Type: "integer", Description: "Maximum number of posts to retrieve, maximum 50", Required: true}, + }, + map[string]any{ + "author": Bodyfield{Type: "string", Description: "Did of an actor"}, + "posts": Bodyfield{Type: "array", Description: "Array of post objects. Each includes the post content 'content', like 'likes', quote 'quotes', replies 'replies' and repost 'reposts' count"}, + }, + ), + }, + { + // base price is $0.25 (x402.BaseProfileCostMicro). With Twitter + // enabled the tool is advertised as upto at the maximum possible + // cost - $0.25 base + $0.01 lookup + $0.005/post at the 100-post + // cap = $0.76 (x402.MaxTwitterCostMicro(BaseProfileCostMicro, + // MaxProfileCountedPosts)) - and the actual charge is settled from + // the posts read via x402.NewStyleProfileCostResolver. + Name: "getStyleProfile", Desc: "Get an actor's style Profile", + Input: GetStyleProfileRequest{}, Output: GetStyleProfileResponse{}, Handler: s.getStyleProfile, Price: "0.25", + MaxTimeoutSeconds: 600, + UsesTwitter: true, TwitterPostCap: x402.MaxProfileCountedPosts, + Extra: schemaExtra( + map[string]any{ + "author": Bodyfield{Type: "string", Description: "Did or handle of an author", Required: true}, + }, + map[string]any{ + "PersonaEmbedding": Bodyfield{Type: "array", Description: "Embedding vector representing the actor's persona"}, + "TopKeywords": Bodyfield{Type: "array", Description: "Top words used by the actor"}, + "StyleSummary": Bodyfield{Type: "string", Description: "Summary of the actor's writing style"}, + "PostCount": Bodyfield{Type: "integer", Description: "Number of posts analyzed"}, + }, + ), + }, + { + Name: "findSimilarUsers", Desc: "Find authors with a similar writing style to the given author, across all platforms", + Input: FindSimilarUsersRequest{}, Output: FindSimilarUsersResponse{}, Handler: s.getSimilarProfiles, Price: "0.10", + UsesTwitter: true, TwitterPostCap: x402.MaxProfileCountedPosts, + Extra: schemaExtra( + map[string]any{ + "author": Bodyfield{Type: "string", Description: "Did or handle of an author", Required: true}, + }, + map[string]any{ + "matches": Bodyfield{Type: "array", Description: "Ranked list of similar profiles (author, platform, similarity, top keywords, style summary, post count)"}, + }, + ), + }, + { + Name: "getActor", Desc: "Get an actor's public profile (bio, avatar, follower counts) on the platform inferred from the identifier", + Input: GetActorRequest{}, Output: GetActorResponse{}, Handler: s.getActor, Price: "0.01", + Extra: schemaExtra( + map[string]any{ + "author": Bodyfield{Type: "string", Description: "Did, handle, npub or @handle of an actor", Required: true}, + }, + map[string]any{ + "actor": Bodyfield{Type: "object", Description: "Profile of the actor (platform, handle, displayName, avatarUrl, bio, followersCount, followingCount, postCount, url)"}, + }, + ), + }, + { + Name: "searchActors", Desc: "Search for actors by handle/name/keyword across all platforms", + Input: SearchActorsRequest{}, Output: SearchActorsResponse{}, Handler: s.searchActors, Price: "0.01", + Extra: schemaExtra( + map[string]any{ + "query": Bodyfield{Type: "string", Description: "Search query (handle, name or keyword)", Required: true}, + "limit": Bodyfield{Type: "integer", Description: "Maximum number of results (default 10, max 50)", Required: false}, + }, + map[string]any{ + "results": Bodyfield{Type: "array", Description: "Ranked list of matching actor profiles across platforms"}, + }, + ), + }, + { + Name: "getEngagementMetrics", Desc: "Get time-series engagement metrics (likes, reposts, replies, quotes) per post", + Input: AnalyticsRequest{}, Output: EngagementMetricsResponse{}, Handler: s.getEngagementMetrics, Price: "0.15", + UsesTwitter: true, TwitterPostCap: x402.MaxAnalyticsCountedPosts, + Extra: schemaExtra(analyticsInput, map[string]any{ + "author": Bodyfield{Type: "string", Description: "Author identifier"}, + "metrics": Bodyfield{Type: "array", Description: "Engagement metrics per post"}, + }), + }, + { + Name: "getPostingPatterns", Desc: "Get posting heatmap and patterns (hour-of-day x day-of-week)", + Input: AnalyticsRequest{}, Output: PostingPatternsResponse{}, Handler: s.getPostingPatterns, Price: "0.10", + UsesTwitter: true, TwitterPostCap: x402.MaxAnalyticsCountedPosts, + Extra: schemaExtra(analyticsInput, map[string]any{ + "author": Bodyfield{Type: "string", Description: "Author identifier"}, + "heatmap": Bodyfield{Type: "array", Description: "24x7 matrix: hour (0-23) x day of week (0-6 = Sun-Sat)"}, + "peakHours": Bodyfield{Type: "array", Description: "Top 3 hours with most posts (0-23)"}, + "peakDays": Bodyfield{Type: "array", Description: "Top 3 days with most posts (0-6 = Sun-Sat)"}, + "avgPostsPerDay": Bodyfield{Type: "number", Description: "Average posts per day"}, + "totalPosts": Bodyfield{Type: "integer", Description: "Total posts in time range"}, + "postingDays": Bodyfield{Type: "integer", Description: "Number of days with at least one post"}, + }), + }, + { + Name: "getTopicModeling", Desc: "Get LLM-based topic extraction and clustering from posts", + Input: AnalyticsRequest{}, Output: TopicModelingResponse{}, Handler: s.getTopicModeling, Price: "0.20", + UsesTwitter: true, TwitterPostCap: x402.MaxAnalyticsCountedPosts, + Extra: schemaExtra(analyticsInput, map[string]any{ + "author": Bodyfield{Type: "string", Description: "Author identifier"}, + "topics": Bodyfield{Type: "array", Description: "Identified topic clusters"}, + }), + }, + { + Name: "getSentimentTrends", Desc: "Get sentiment analysis and emotional arcs over time", + Input: AnalyticsRequest{}, Output: SentimentTrendsResponse{}, Handler: s.getSentimentTrends, Price: "0.15", + UsesTwitter: true, TwitterPostCap: x402.MaxAnalyticsCountedPosts, + Extra: schemaExtra(analyticsInput, map[string]any{ + "author": Bodyfield{Type: "string", Description: "Author identifier"}, + "timeSeries": Bodyfield{Type: "array", Description: "Sentiment over time"}, + "overallSentiment": Bodyfield{Type: "object", Description: "Overall sentiment summary"}, + "dominantEmotions": Bodyfield{Type: "array", Description: "Most frequent emotions"}, + "sentimentShifts": Bodyfield{Type: "array", Description: "Significant sentiment changes"}, + }), + }, + { + Name: "getEngagementDrivers", Desc: "Get topics/keywords that correlate with high engagement", + Input: AnalyticsRequest{}, Output: EngagementDriversResponse{}, Handler: s.getEngagementDrivers, Price: "0.20", + UsesTwitter: true, TwitterPostCap: x402.MaxAnalyticsCountedPosts, + Extra: schemaExtra(analyticsInput, map[string]any{ + "author": Bodyfield{Type: "string", Description: "Author identifier"}, + "drivers": Bodyfield{Type: "array", Description: "Topics/keywords driving engagement"}, + }), + }, + } + + for _, d := range payable { + // Twitter-capable tools are advertised as upto (variable-priced) only + // when the Twitter API is actually enabled: the upto maximum adds the + // realized Twitter lookup and per-post costs, and the actual charge is + // settled from the response. Without Twitter, every tool is exact at + // its flat base price. + scheme := "exact" + price := d.Price + var resolver x402.SettleAmountResolver + if d.UsesTwitter && s.twitterEnabled { + scheme = "upto" + baseMicro := x402.MicroFromPriceString(d.Price) + price = x402.FormatPriceMicro(x402.MaxTwitterCostMicro(baseMicro, d.TwitterPostCap)) + if d.Name == "getStyleProfile" { + resolver = x402.NewStyleProfileCostResolver() + } else { + resolver = x402.NewTwitterCountCostResolver(baseMicro) + } + } + s.mcpHandler.AddPayableTool( + mcp.Tool{Name: d.Name, Description: d.Desc, InputSchema: d.Input, OutputSchema: d.Output}, + d.Handler, + resolver, + s.buildRequirements(price, d.Extra, scheme, d.MaxTimeoutSeconds)..., + ) + } + + // Free tool: getFeed + s.mcpHandler.AddTool( + mcp.Tool{Name: "getFeed", Description: "Get a feed by its URI", InputSchema: GetFeedRequest{}, OutputSchema: GetFeedResponse{}}, + s.getFeed, + ) +} + +func (s *Server) getPost(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var args GetPostRequest + if err := json.Unmarshal(request.Params.Arguments, &args); err != nil { + return nil, fmt.Errorf("invalid arguments: %w", err) + } + if args.Author == "" { + return nil, fmt.Errorf("author must be a valid did or handle") + } + if args.RecordKey == "" { + return nil, fmt.Errorf("rkey must be a valid record key") + } + platform, err := determinePlatform(args.Author) + if err != nil { + return nil, err + } + if platform == Twitter { + return nil, fmt.Errorf("twitter_unavailable: X API policy prohibits reselling post data") + } + post, err := s.fetchSinglePost(ctx, platform, args.Author, args.RecordKey, args.Collection) + if err != nil { + return nil, err + } + respBytes, err := json.Marshal(post) + if err != nil { + return nil, err + } + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: string(respBytes)}, + }, + }, nil +} + +func (s *Server) getLatestPosts(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var args GetLatestPostsRequest + if err := json.Unmarshal(request.Params.Arguments, &args); err != nil { + return nil, fmt.Errorf("invalid arguments: %w", err) + } + if args.Author == "" { + return nil, fmt.Errorf("author must be a valid did or handle") + } + if args.Limit <= 0 { + return nil, fmt.Errorf("n must be a positive integer") + } + if args.Limit > 50 { + return nil, fmt.Errorf("n must be at most 50") + } + platform, err := determinePlatform(args.Author) + if err != nil { + return nil, err + } + if platform == Twitter { + return nil, fmt.Errorf("twitter_unavailable: X API policy prohibits reselling post data") + } + posts, authorID, err := s.fetchLatestPosts(ctx, platform, args.Author, args.Limit) + if err != nil { + return nil, err + } + if authorID == "" { + authorID = args.Author + } + resp := GetLatestPostsResponse{ + Author: authorID, + Posts: posts, + } + + respBytes, err := json.Marshal(resp) + if err != nil { + return nil, err + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: string(respBytes)}, + }, + }, nil +} + +func (s *Server) getPostsInTime(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var args GetPostsInTimeRequest + if err := json.Unmarshal(request.Params.Arguments, &args); err != nil { + return nil, fmt.Errorf("invalid arguments: %w", err) + } + if args.Author == "" { + return nil, fmt.Errorf("author must be a valid did or handle") + } + if args.Limit <= 0 { + return nil, fmt.Errorf("n must be a positive integer") + } + if args.Limit > 50 { + return nil, fmt.Errorf("n must be at most 50") + } + limit := args.Limit + startTime, err := time.Parse(time.RFC3339, args.StartTime) + if err != nil { + return nil, fmt.Errorf("startTime must be in RFC3339 format") + } + endTime, err := time.Parse(time.RFC3339, args.EndTime) + if err != nil { + return nil, fmt.Errorf("endTime must be in RFC3339 format") + } + if startTime.After(endTime) { + tmp := startTime + startTime = endTime + endTime = tmp + } + platform, err := determinePlatform(args.Author) + if err != nil { + return nil, err + } + if platform == Twitter { + return nil, fmt.Errorf("twitter_unavailable: X API policy prohibits reselling post data") + } + posts, authorID, err := s.fetchPostsInTime(ctx, platform, args.Author, startTime, endTime, limit) + if err != nil { + return nil, err + } + if authorID == "" { + authorID = args.Author + } + resp := GetPostsInTimeResponse{ + Author: authorID, + Posts: posts, + } + + respBytes, err := json.Marshal(resp) + if err != nil { + return nil, err + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: string(respBytes)}, + }, + }, nil +} + +func (s *Server) getFeed(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var args GetFeedRequest + if err := json.Unmarshal(request.Params.Arguments, &args); err != nil { + return nil, fmt.Errorf("invalid arguments: %w", err) + } + if args.URI == "" { + return nil, fmt.Errorf("feedURI must be a valid feed URI") + } + platform, err := determinePlatform(args.URI) + if err != nil { + return nil, err + } + if platform == Twitter { + return nil, fmt.Errorf("twitter_unavailable: X API policy prohibits reselling feed data") + } + posts, err := s.fetchFeed(ctx, platform, args.URI) + if err != nil { + return nil, err + } + resp := GetFeedResponse{ + URI: args.URI, + Posts: posts, + } + + respBytes, err := json.Marshal(resp) + if err != nil { + return nil, err + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: string(respBytes)}, + }, + }, nil +} + +func (s *Server) getActor(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var args GetActorRequest + if err := json.Unmarshal(request.Params.Arguments, &args); err != nil { + return nil, fmt.Errorf("invalid arguments: %w", err) + } + if args.Author == "" { + return nil, fmt.Errorf("author must be a valid did, handle, npub or @handle") + } + platform, err := determinePlatform(args.Author) + if err != nil { + return nil, err + } + var ( + prof *ActorProfile + e error + ) + switch platform { + case Bluesky: + prof, e = s.bskyClient.GetActor(ctx, args.Author) + case ActivityPub: + prof, e = s.apClient.GetActor(ctx, args.Author) + case Nostr: + prof, e = s.nostrClient.GetActor(ctx, args.Author) + case Twitter: + return nil, fmt.Errorf("twitter_unavailable: X API policy prohibits reselling user/profile data") + default: + return nil, fmt.Errorf("unsupported platform %q", platform) + } + if e != nil { + return nil, e + } + respBytes, err := json.Marshal(GetActorResponse{Actor: *prof}) + if err != nil { + return nil, err + } + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: string(respBytes)}, + }, + }, nil +} + +func (s *Server) searchActors(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var args SearchActorsRequest + if err := json.Unmarshal(request.Params.Arguments, &args); err != nil { + return nil, fmt.Errorf("invalid arguments: %w", err) + } + if args.Query == "" { + return nil, fmt.Errorf("query must not be empty") + } + limit := args.Limit + if limit <= 0 { + limit = 10 + } + if limit > 50 { + limit = 50 + } + results, err := s.searchActorsAll(ctx, args.Query, limit) + if err != nil { + return nil, err + } + respBytes, err := json.Marshal(SearchActorsResponse{Results: results}) + if err != nil { + return nil, err + } + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: string(respBytes)}, + }, + }, nil +} diff --git a/internal/server/twitter_analytics_test.go b/internal/server/twitter_analytics_test.go new file mode 100644 index 0000000..2cc9cf8 --- /dev/null +++ b/internal/server/twitter_analytics_test.go @@ -0,0 +1,162 @@ +package server + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/nacorid/naco-api/internal/clustering" + "github.com/nacorid/naco-api/internal/store" + "github.com/nacorid/naco-api/internal/twitter" +) + +func twitterPosts() []*twitter.Post { + base := time.Date(2024, 5, 10, 12, 0, 0, 0, time.UTC) + return []*twitter.Post{ + {ID: "t1", Author: "@a", Content: "twitter one", CreatedAt: base}, + {ID: "t2", Author: "@a", Content: "twitter two", CreatedAt: base.Add(time.Hour)}, + } +} + +func TestFetchTwitterAnalyticsPostsFetchesAndPersists(t *testing.T) { + tw := &fakeTwitter{posts: twitterPosts()} + st := newFakeStore() + s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, tw, st, &fakeOllama{}) + + start := time.Date(2024, 5, 1, 0, 0, 0, 0, time.UTC) + end := time.Date(2024, 5, 31, 0, 0, 0, 0, time.UTC) + items, err := s.fetchTwitterAnalyticsPosts(context.Background(), "@a", start, end, 50) + if err != nil { + t.Fatalf("fetchTwitterAnalyticsPosts error: %v", err) + } + if len(items) != 2 { + t.Fatalf("expected 2 fetched posts, got %d", len(items)) + } + if tw.calls != 1 { + t.Fatalf("expected 1 API call, got %d", tw.calls) + } + + // Newly fetched posts are persisted for future reuse. + if len(st.analyticsPosts) != 2 { + t.Fatalf("expected 2 persisted analytics posts, got %d", len(st.analyticsPosts)) + } + // Coverage range for the whole range recorded. + if len(st.coverageRanges) != 1 { + t.Fatalf("expected 1 coverage range, got %d", len(st.coverageRanges)) + } +} + +func TestFetchTwitterAnalyticsPostsServedFromCoverage(t *testing.T) { + // Posts already persisted and coverage present => no paid API call. + st := newFakeStore() + base := time.Date(2024, 5, 10, 12, 0, 0, 0, time.UTC) + st.coverageRanges = []store.AnalyticsCoverageRange{ + {UserID: "@a", Platform: "Twitter", CoveredStart: base, CoveredEnd: base.Add(2 * time.Hour)}, + } + st.analyticsPosts[postKey("@a", "Twitter", "t1")] = store.AnalyticsPost{ + UserID: "@a", Platform: "Twitter", PostID: "t1", PostText: "cached post", PostTimestamp: base, + } + tw := &fakeTwitter{} + s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, tw, st, &fakeOllama{}) + + start := base + end := base.Add(2 * time.Hour) + items, err := s.fetchTwitterAnalyticsPosts(context.Background(), "@a", start, end, 50) + if err != nil { + t.Fatalf("fetchTwitterAnalyticsPosts error: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 cached post, got %d", len(items)) + } + if tw.calls != 0 { + t.Fatalf("expected no API call when covered, got %d", tw.calls) + } + if items[0].Text != "cached post" { + t.Fatalf("expected cached text, got %q", items[0].Text) + } +} + +func TestMergeAnalyticsPostsDedupsAndCaps(t *testing.T) { + base := time.Date(2024, 5, 10, 12, 0, 0, 0, time.UTC) + st := newFakeStore() + // Cached posts overlap with fetched ones by ID, and both exceed the limit. + st.analyticsPosts[postKey("@a", "Twitter", "dup")] = store.AnalyticsPost{ + UserID: "@a", Platform: "Twitter", PostID: "dup", PostText: "dup cached", PostTimestamp: base, + } + s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{}) + + fetched := []clustering.Item{ + {ID: "dup", Text: "dup fetched", Timestamp: base}, + {ID: "b", Text: "b", Timestamp: base}, + {ID: "c", Text: "c", Timestamp: base}, + } + merged, err := s.mergeAnalyticsPosts(context.Background(), "@a", base, base.Add(time.Hour), 2, fetched) + if err != nil { + t.Fatalf("mergeAnalyticsPosts error: %v", err) + } + if len(merged) != 2 { + t.Fatalf("expected merged capped at 2, got %d", len(merged)) + } + // Fetched entries come first and dedupe against the cache. + if merged[0].ID != "dup" || merged[0].Text != "dup fetched" { + t.Fatalf("expected fetched dup first, got %+v", merged[0]) + } +} + +func TestPersistAnalyticsPostsSkipsEmptyIDs(t *testing.T) { + st := newFakeStore() + s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{}) + err := s.persistAnalyticsPosts(context.Background(), "@a", "Twitter", []clustering.Item{ + {ID: "", Text: "no id"}, + {ID: "real", Text: "real", Timestamp: time.Now()}, + }) + if err != nil { + t.Fatalf("persistAnalyticsPosts error: %v", err) + } + if len(st.analyticsPosts) != 1 { + t.Fatalf("expected only the post with an ID persisted, got %d", len(st.analyticsPosts)) + } + if _, ok := st.analyticsPosts[postKey("@a", "Twitter", "real")]; !ok { + t.Fatal("expected the 'real' post to be persisted") + } +} + +func TestAnalyticsTwitterEndToEnd(t *testing.T) { + // getEngagementMetrics over the Twitter platform drives the coverage logic. + tw := &fakeTwitter{posts: twitterPosts()} + st := newFakeStore() + s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, tw, st, &fakeOllama{}) + + res, err := callTool(s, "getEngagementMetrics", AnalyticsRequest{ + Author: "@a", + StartTime: "2024-05-01T00:00:00Z", + EndTime: "2024-05-31T00:00:00Z", + Platform: "Twitter", + }) + if err != nil { + t.Fatalf("getEngagementMetrics twitter error: %v", err) + } + if !strings.Contains(textOf(t, res), `"postId":"t1"`) { + t.Fatalf("expected twitter post t1 in result, got %s", textOf(t, res)) + } + if !strings.Contains(textOf(t, res), `"postId":"t2"`) { + t.Fatalf("expected twitter post t2 in result, got %s", textOf(t, res)) + } + if tw.calls != 1 { + t.Fatalf("expected 1 API call for the uncovered range, got %d", tw.calls) + } + + // Second identical request is served from the analytics cache (no API call). + if _, err := callTool(s, "getEngagementMetrics", AnalyticsRequest{ + Author: "@a", + StartTime: "2024-05-01T00:00:00Z", + EndTime: "2024-05-31T00:00:00Z", + Platform: "Twitter", + }); err != nil { + t.Fatalf("second call error: %v", err) + } + if tw.calls != 1 { + t.Fatalf("expected cache to avoid a second API call, got %d", tw.calls) + } +} diff --git a/internal/server/types.go b/internal/server/types.go index ec0e8d4..7d833c6 100644 --- a/internal/server/types.go +++ b/internal/server/types.go @@ -1,5 +1,7 @@ package server +import "github.com/nacorid/naco-api/internal/actor" + type Bodyfield struct { Type string `json:"type"` Description string `json:"description"` @@ -24,8 +26,8 @@ type GetLatestPostsRequest struct { } type GetLatestPostsResponse struct { - Author string `json:"actor" jsonschema_description:"Did of the actor"` - Posts []GetPostResponsePost `json:"posts" jsonschema_description:"List of latest posts"` + Author string `json:"actor" jsonschema_description:"Did of the actor"` + Posts []*GetPostResponsePost `json:"posts" jsonschema_description:"List of latest posts"` } type GetPostsInTimeRequest struct { @@ -36,14 +38,14 @@ type GetPostsInTimeRequest struct { } type GetPostsInTimeResponse struct { - Author string `json:"author" jsonschema_description:"Did of the author"` - Posts []GetPostResponsePost `json:"posts" jsonschema_description:"List of posts in the timeframe"` + Author string `json:"author" jsonschema_description:"Did of the author"` + Posts []*GetPostResponsePost `json:"posts" jsonschema_description:"List of posts in the timeframe"` } type GetPostRequest struct { Author string `json:"author" jsonschema_description:"Did or handle of an author"` RecordKey string `json:"rkey" jsonschema_description:"Record key of the post"` - Collection string `json:"collection" jsonschema_description:"Optional collection the post belongs to"` + Collection string `json:"collection" jsonschema_description:"Optional collection the post belongs to (Bluesky only). Defaults to 'app.bsky.feed.post'"` } type GetPostResponsePost struct { @@ -57,12 +59,12 @@ type GetPostResponsePost struct { } type GetFeedRequest struct { - URI string `json:"uri" jsonschema_description:"The URI of the feed to retrieve"` + URI string `json:"uri" jsonschema_description:"URI of the feed to retrieve. For Bluesky this is a feed URI; for other platforms it is treated as an author handle"` } type GetFeedResponse struct { - URI string `json:"uri" jsonschema_description:"The URI of the feed"` - Posts []GetPostResponsePost `json:"posts" jsonschema_description:"List of posts in the feed"` + URI string `json:"uri" jsonschema_description:"The URI of the feed"` + Posts []*GetPostResponsePost `json:"posts" jsonschema_description:"List of posts in the feed"` } type GetStyleProfileRequest struct { @@ -78,6 +80,47 @@ type GetStyleProfileResponse struct { SourcePlatform string `json:"sourcePlatform,omitempty" jsonschema_description:"The platform from which the posts were sourced"` } +type FindSimilarUsersRequest struct { + Author string `json:"author" jsonschema_description:"Did or handle of an author to find similar profiles for"` + Limit int32 `json:"limit,omitempty" jsonschema_description:"Maximum number of similar profiles to return (default 10, max 50)"` +} + +type FindSimilarUsersResponse struct { + Matches []SimilarUser `json:"matches" jsonschema_description:"Ranked list of similar profiles by persona embedding similarity"` + // TwitterPostCount reports how many posts were sourced from the Twitter API + // while building the query author's style profile. It is 0 when the query + // author is not on Twitter. + TwitterPostCount int `json:"twitterPostCount" jsonschema_description:"Number of Twitter-sourced posts used for the query author's profile"` +} + +type ActorProfile = actor.Profile + +type GetActorRequest struct { + Author string `json:"author" jsonschema_description:"Did, handle, npub or @handle of an actor"` +} + +type GetActorResponse struct { + Actor ActorProfile `json:"actor" jsonschema_description:"Profile of the actor"` +} + +type SearchActorsRequest struct { + Query string `json:"query" jsonschema_description:"Search query (handle, name or keyword)"` + Limit int `json:"limit,omitempty" jsonschema_description:"Maximum number of results (default 10, max 50)"` +} + +type SearchActorsResponse struct { + Results []*ActorProfile `json:"results" jsonschema_description:"Ranked list of matching actor profiles across platforms"` +} + +type SimilarUser struct { + Author string `json:"author" jsonschema_description:"Did or handle of the similar author"` + Platform string `json:"platform" jsonschema_description:"Platform the similar author was found on"` + Similarity float64 `json:"similarity" jsonschema_description:"Cosine similarity of the persona embeddings, range 0..1"` + TopKeywords []string `json:"topKeywords,omitempty" jsonschema_description:"Top keywords used by the similar author"` + StyleSummary string `json:"styleSummary,omitempty" jsonschema_description:"Summary of the similar author's writing style"` + PostCount int `json:"postCount,omitempty" jsonschema_description:"Number of posts analyzed for the similar author"` +} + type DCAPMessage struct { V int `json:"v" jsonschema_description:"DCAP Version"` T string `json:"t" jsonschema_description:"DCAP Message Type"` @@ -97,3 +140,134 @@ const ( func (p platform) String() string { return [...]string{"ActivityPub", "Bluesky", "Nostr", "Twitter"}[p] } + +func platformFromString(name string) (platform, bool) { + switch name { + case "ActivityPub": + return ActivityPub, true + case "Bluesky": + return Bluesky, true + case "Nostr": + return Nostr, true + case "Twitter": + return Twitter, true + default: + return -1, false + } +} + +// --- Analytics types --- + +type AnalyticsRequest struct { + Author string `json:"author" jsonschema_description:"Did or handle of an author"` + StartTime string `json:"startTime,omitempty" jsonschema_description:"Start time in RFC3339 format (default: 30 days ago)"` + EndTime string `json:"endTime,omitempty" jsonschema_description:"End time in RFC3339 format (default: now)"` + Limit int `json:"limit,omitempty" jsonschema_description:"Maximum number of posts to analyze (default: 50, max: 200)"` + Platform string `json:"platform,omitempty" jsonschema_description:"Platform to filter (default: all)"` +} + +type EngagementMetricsResponse struct { + Author string `json:"author" jsonschema_description:"Author identifier"` + Metrics []EngagementMetricPoint `json:"metrics" jsonschema_description:"Engagement metrics per post"` + // TwitterPostCount reports how many posts in the result were sourced from + // the Twitter API, so upto-scheme settlement can pass the realized Twitter + // costs on to the caller. + TwitterPostCount int `json:"twitterPostCount" jsonschema_description:"Number of Twitter-sourced posts analyzed"` +} + +type EngagementMetricPoint struct { + PostID string `json:"postId" jsonschema_description:"Post identifier"` + Timestamp string `json:"timestamp" jsonschema_description:"RFC3339 timestamp"` + Likes int64 `json:"likes" jsonschema_description:"Number of likes"` + Reposts int64 `json:"reposts" jsonschema_description:"Number of reposts"` + Replies int64 `json:"replies" jsonschema_description:"Number of replies"` + Quotes int64 `json:"quotes" jsonschema_description:"Number of quotes"` + EngagementRate float64 `json:"engagementRate" jsonschema_description:"Engagement rate (interactions / impressions estimate)"` +} + +type PostingPatternsResponse struct { + Author string `json:"author" jsonschema_description:"Author identifier"` + Heatmap [][]int `json:"heatmap" jsonschema_description:"24x7 matrix: hour (0-23) x day of week (0-6 = Sun-Sat)"` + PeakHours []int `json:"peakHours" jsonschema_description:"Top 3 hours with most posts (0-23)"` + PeakDays []int `json:"peakDays" jsonschema_description:"Top 3 days with most posts (0-6 = Sun-Sat)"` + AvgPostsPerDay float64 `json:"avgPostsPerDay" jsonschema_description:"Average posts per day"` + TotalPosts int `json:"totalPosts" jsonschema_description:"Total posts in time range"` + PostingDays int `json:"postingDays" jsonschema_description:"Number of days with at least one post"` + // TwitterPostCount reports how many posts in the result were sourced from + // the Twitter API, so upto-scheme settlement can pass the realized Twitter + // costs on to the caller. + TwitterPostCount int `json:"twitterPostCount" jsonschema_description:"Number of Twitter-sourced posts analyzed"` +} + +type TopicModelingResponse struct { + Author string `json:"author" jsonschema_description:"Author identifier"` + Topics []TopicCluster `json:"topics" jsonschema_description:"Identified topic clusters"` + // TwitterPostCount reports how many posts in the result were sourced from + // the Twitter API, so upto-scheme settlement can pass the realized Twitter + // costs on to the caller. + TwitterPostCount int `json:"twitterPostCount" jsonschema_description:"Number of Twitter-sourced posts analyzed"` +} + +type TopicCluster struct { + TopicID int `json:"topicId" jsonschema_description:"Cluster identifier"` + Label string `json:"label" jsonschema_description:"Human-readable topic label"` + Keywords []string `json:"keywords" jsonschema_description:"Top keywords for this topic"` + PostCount int `json:"postCount" jsonschema_description:"Number of posts in this topic"` + Percentage float64 `json:"percentage" jsonschema_description:"Percentage of total posts"` + SamplePosts []string `json:"samplePosts" jsonschema_description:"Sample post texts (max 3)"` +} + +type SentimentTrendsResponse struct { + Author string `json:"author" jsonschema_description:"Author identifier"` + TimeSeries []SentimentPoint `json:"timeSeries" jsonschema_description:"Sentiment over time"` + OverallSentiment SentimentSummary `json:"overallSentiment" jsonschema_description:"Overall sentiment summary"` + DominantEmotions []EmotionScore `json:"dominantEmotions" jsonschema_description:"Most frequent emotions"` + SentimentShifts []SentimentShift `json:"sentimentShifts" jsonschema_description:"Significant sentiment changes"` + // TwitterPostCount reports how many posts in the result were sourced from + // the Twitter API, so upto-scheme settlement can pass the realized Twitter + // costs on to the caller. + TwitterPostCount int `json:"twitterPostCount" jsonschema_description:"Number of Twitter-sourced posts analyzed"` +} + +type SentimentPoint struct { + Timestamp string `json:"timestamp" jsonschema_description:"RFC3339 timestamp (bucketed by day)"` + Positive float64 `json:"positive" jsonschema_description:"Positive sentiment ratio (0-1)"` + Negative float64 `json:"negative" jsonschema_description:"Negative sentiment ratio (0-1)"` + Neutral float64 `json:"neutral" jsonschema_description:"Neutral sentiment ratio (0-1)"` + PostCount int `json:"postCount" jsonschema_description:"Posts in this time bucket"` +} + +type SentimentSummary struct { + Positive float64 `json:"positive" jsonschema_description:"Overall positive ratio"` + Negative float64 `json:"negative" jsonschema_description:"Overall negative ratio"` + Neutral float64 `json:"neutral" jsonschema_description:"Overall neutral ratio"` +} + +type EmotionScore struct { + Emotion string `json:"emotion" jsonschema_description:"Emotion name"` + Score float64 `json:"score" jsonschema_description:"Frequency score (0-1)"` +} + +type SentimentShift struct { + Timestamp string `json:"timestamp" jsonschema_description:"When the shift occurred"` + FromSentiment string `json:"fromSentiment" jsonschema_description:"Previous dominant sentiment"` + ToSentiment string `json:"toSentiment" jsonschema_description:"New dominant sentiment"` + Magnitude float64 `json:"magnitude" jsonschema_description:"Shift magnitude (0-1)"` +} + +type EngagementDriversResponse struct { + Author string `json:"author" jsonschema_description:"Author identifier"` + Drivers []EngagementDriver `json:"drivers" jsonschema_description:"Topics/keywords driving engagement"` + // TwitterPostCount reports how many posts in the result were sourced from + // the Twitter API, so upto-scheme settlement can pass the realized Twitter + // costs on to the caller. + TwitterPostCount int `json:"twitterPostCount" jsonschema_description:"Number of Twitter-sourced posts analyzed"` +} + +type EngagementDriver struct { + Topic string `json:"topic" jsonschema_description:"Topic or keyword"` + AvgEngagement float64 `json:"avgEngagement" jsonschema_description:"Average engagement rate for posts with this topic"` + Correlation float64 `json:"correlation" jsonschema_description:"Correlation coefficient with engagement (-1 to 1)"` + PostCount int `json:"postCount" jsonschema_description:"Number of posts containing this topic"` + SamplePosts []string `json:"samplePosts" jsonschema_description:"Sample posts (max 2)"` +} diff --git a/internal/server/validation_test.go b/internal/server/validation_test.go new file mode 100644 index 0000000..f33aef0 --- /dev/null +++ b/internal/server/validation_test.go @@ -0,0 +1,261 @@ +package server + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + "github.com/bluesky-social/indigo/api/bsky" + "github.com/nacorid/naco-api/internal/activitypub" + "github.com/nacorid/naco-api/internal/nostr" +) + +// ---- validation matrix ---- + +func TestGetPostValidation(t *testing.T) { + s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) + + if _, err := callTool(s, "getPost", GetPostRequest{Author: "", RecordKey: "k"}); err == nil || !strings.Contains(err.Error(), "author") { + t.Fatalf("expected author error, got %v", err) + } + if _, err := callTool(s, "getPost", GetPostRequest{Author: "did:plc:abc", RecordKey: ""}); err == nil || !strings.Contains(err.Error(), "rkey") { + t.Fatalf("expected rkey error, got %v", err) + } + if _, err := callTool(s, "getPost", "not-an-object"); err == nil { + t.Fatal("expected invalid arguments error") + } + // Unparseable handle -> determinePlatform error + if _, err := callTool(s, "getPost", GetPostRequest{Author: "justhandle", RecordKey: "k"}); err == nil { + t.Fatal("expected determinePlatform error") + } +} + +func TestGetLatestPostsValidation(t *testing.T) { + s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) + + if _, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "", Limit: 5}); err == nil || !strings.Contains(err.Error(), "author") { + t.Fatalf("expected author error, got %v", err) + } + if _, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "did:plc:abc", Limit: 0}); err == nil || !strings.Contains(err.Error(), "positive") { + t.Fatalf("expected limit error, got %v", err) + } + if _, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "did:plc:abc", Limit: -3}); err == nil { + t.Fatal("expected negative limit error") + } + if _, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "did:plc:abc", Limit: 51}); err == nil || !strings.Contains(err.Error(), "at most 50") { + t.Fatalf("expected max-limit error, got %v", err) + } +} + +func TestGetPostsInTimeValidation(t *testing.T) { + s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) + + if _, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "", StartTime: "2024-01-01T00:00:00Z", EndTime: "2024-12-31T00:00:00Z", Limit: 5}); err == nil || !strings.Contains(err.Error(), "author") { + t.Fatalf("expected author error, got %v", err) + } + if _, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "did:plc:abc", StartTime: "2024-01-01T00:00:00Z", EndTime: "2024-12-31T00:00:00Z", Limit: 0}); err == nil || !strings.Contains(err.Error(), "positive") { + t.Fatalf("expected limit error, got %v", err) + } + // getPostsInTime now rejects over-max limit for consistency with getLatestPosts. + if _, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "did:plc:abc", StartTime: "2024-01-01T00:00:00Z", EndTime: "2024-12-31T00:00:00Z", Limit: 51}); err == nil || !strings.Contains(err.Error(), "at most 50") { + t.Fatalf("expected max-limit error, got %v", err) + } + // Bad start time. + if _, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "did:plc:abc", StartTime: "not-a-time", EndTime: "2024-12-31T00:00:00Z", Limit: 5}); err == nil || !strings.Contains(err.Error(), "startTime") { + t.Fatalf("expected startTime error, got %v", err) + } + // Bad end time reports the correct field name. + if _, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "did:plc:abc", StartTime: "2024-01-01T00:00:00Z", EndTime: "not-a-time", Limit: 5}); err == nil || !strings.Contains(err.Error(), "endTime") { + t.Fatalf("expected endTime error, got %v", err) + } +} + +func TestGetPostsInTimeSwapsReversedRange(t *testing.T) { + // Nostr fake returns posts regardless of args; the point is that a reversed + // range is accepted (swapped) rather than rejected. + nstr := &fakeNostr{posts: []*nostr.Post{{ID: "e1", Author: "npub1x", Content: "hi", CreatedAt: time.Now()}}} + s := newTestServer(&fakeBluesky{}, &fakeAP{}, nstr, &fakeTwitter{}) + res, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{ + Author: "npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + StartTime: "2024-12-31T00:00:00Z", + EndTime: "2024-01-01T00:00:00Z", + Limit: 5, + }) + if err != nil { + t.Fatalf("expected reversed range to be swapped and succeed, got %v", err) + } + if res == nil || len(res.Content) == 0 { + t.Fatal("expected a result") + } +} + +func TestGetFeedValidation(t *testing.T) { + s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) + if _, err := callTool(s, "getFeed", GetFeedRequest{URI: ""}); err == nil || !strings.Contains(err.Error(), "URI") { + t.Fatalf("expected URI error, got %v", err) + } +} + +func TestGetActorValidation(t *testing.T) { + s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) + if _, err := callTool(s, "getActor", GetActorRequest{Author: ""}); err == nil || !strings.Contains(err.Error(), "author") { + t.Fatalf("expected author error, got %v", err) + } + // Bare handle is unparseable -> determinePlatform error. + if _, err := callTool(s, "getActor", GetActorRequest{Author: "justhandle"}); err == nil { + t.Fatal("expected determinePlatform error") + } +} + +func TestSearchActorsValidation(t *testing.T) { + s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) + if _, err := callTool(s, "searchActors", SearchActorsRequest{Query: ""}); err == nil || !strings.Contains(err.Error(), "query") { + t.Fatalf("expected query error, got %v", err) + } +} + +// ---- twitter policy blocks ---- + +func TestTwitterPolicyBlocksAllTools(t *testing.T) { + cases := []struct { + tool string + args any + }{ + {"getPost", GetPostRequest{Author: "@alice", RecordKey: "k"}}, + {"getLatestPosts", GetLatestPostsRequest{Author: "@alice", Limit: 5}}, + {"getPostsInTime", GetPostsInTimeRequest{Author: "@alice", StartTime: "2024-01-01T00:00:00Z", EndTime: "2024-12-31T00:00:00Z", Limit: 5}}, + {"getFeed", GetFeedRequest{URI: "@alice"}}, + {"getActor", GetActorRequest{Author: "@alice"}}, + } + for _, c := range cases { + tw := &fakeTwitter{} + s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, tw) + _, err := callTool(s, c.tool, c.args) + if err == nil || !strings.Contains(err.Error(), "twitter_unavailable") { + t.Fatalf("%s: expected twitter_unavailable, got %v", c.tool, err) + } + if tw.calls != 0 { + t.Fatalf("%s: backend should not be called, got %d calls", c.tool, tw.calls) + } + } +} + +// ---- backend error propagation ---- + +func TestBackendErrorPropagation(t *testing.T) { + bsky := &fakeBluesky{err: fmt.Errorf("boom")} + s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) + if _, err := callTool(s, "getPost", GetPostRequest{Author: "did:plc:abc", RecordKey: "k"}); err == nil || !strings.Contains(err.Error(), "boom") { + t.Fatalf("expected backend error, got %v", err) + } +} + +func TestGetActorBackendError(t *testing.T) { + ap := &fakeAP{err: fmt.Errorf("boom")} + s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}) + if _, err := callTool(s, "getActor", GetActorRequest{Author: "@a@mastodon.social"}); err == nil || !strings.Contains(err.Error(), "boom") { + t.Fatalf("expected backend error, got %v", err) + } +} + +// ---- getPost success paths ---- + +func TestGetPostBlueskyAndNostr(t *testing.T) { + bsky := &fakeBluesky{postView: bskyPostView("hello bluesky")} + s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) + res, err := callTool(s, "getPost", GetPostRequest{Author: "did:plc:abc", RecordKey: "k"}) + if err != nil { + t.Fatalf("getPost bluesky error: %v", err) + } + if !strings.Contains(textOf(t, res), "hello bluesky") { + t.Fatalf("unexpected bluesky post text: %s", textOf(t, res)) + } + if bsky.calls != 1 { + t.Fatalf("expected 1 backend call, got %d", bsky.calls) + } + + nstr := &fakeNostr{post: &nostr.Post{ID: "n1", Author: "npub1x", Content: "nostr hello"}} + s2 := newTestServer(&fakeBluesky{}, &fakeAP{}, nstr, &fakeTwitter{}) + res2, err := callTool(s2, "getPost", GetPostRequest{Author: "npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", RecordKey: "n1"}) + if err != nil { + t.Fatalf("getPost nostr error: %v", err) + } + if !strings.Contains(textOf(t, res2), "nostr hello") { + t.Fatalf("unexpected nostr post text: %s", textOf(t, res2)) + } +} + +func TestGetLatestPostsEmptyResult(t *testing.T) { + ap := &fakeAP{} + s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}) + res, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "@a@mastodon.social", Limit: 5}) + if err != nil { + t.Fatalf("getLatestPosts error: %v", err) + } + var out GetLatestPostsResponse + if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(out.Posts) != 0 { + t.Fatalf("expected 0 posts, got %d", len(out.Posts)) + } + if out.Author != "@a@mastodon.social" { + t.Fatalf("expected author fallback to request author, got %q", out.Author) + } +} + +func TestGetLatestPostsBlueskyNilPostSkipped(t *testing.T) { + bsky := &fakeBluesky{feedViews: []*bsky.FeedDefs_FeedViewPost{ + {Post: bskyPostView("kept")}, + {Post: nil}, + }} + s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) + res, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "did:plc:abc", Limit: 5}) + if err != nil { + t.Fatalf("getLatestPosts error: %v", err) + } + var out GetLatestPostsResponse + if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(out.Posts) != 1 { + t.Fatalf("expected 1 post after skipping nil, got %d", len(out.Posts)) + } +} + +func TestGetFeedNonBlueskyFallback(t *testing.T) { + // A non-Bluesky feed URI is treated as an author handle and falls back to + // fetchLatestPosts (evaluated via the ActivityPub backend here). + ap := &fakeAP{posts: []*activitypub.Post{{Author: "@a@mastodon.social", Content: "fallback post", CreatedAt: time.Now()}}} + s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}) + res, err := callTool(s, "getFeed", GetFeedRequest{URI: "@a@mastodon.social"}) + if err != nil { + t.Fatalf("getFeed fallback error: %v", err) + } + if !strings.Contains(textOf(t, res), "fallback post") { + t.Fatalf("expected fallback post, got %s", textOf(t, res)) + } +} + +func TestGetFeedEmptyResult(t *testing.T) { + bsky := &fakeBluesky{feedOut: &bsky.FeedGetFeed_Output{Feed: nil}} + s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) + res, err := callTool(s, "getFeed", GetFeedRequest{URI: "at://did:plc:abc/app.bsky.feed.generator/abc"}) + if err != nil { + t.Fatalf("getFeed error: %v", err) + } + var out GetFeedResponse + if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(out.Posts) != 0 { + t.Fatalf("expected 0 feed posts, got %d", len(out.Posts)) + } +} + +func jsonUnmarshal(t *testing.T, in string, out any) error { + t.Helper() + return json.Unmarshal([]byte(in), out) +} diff --git a/internal/server/webhook.go b/internal/server/webhook.go new file mode 100644 index 0000000..adda05e --- /dev/null +++ b/internal/server/webhook.go @@ -0,0 +1,125 @@ +package server + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + log "github.com/nacorid/logger" + "github.com/nacorid/naco-api/internal/config" +) + +type WebhookSender struct { + cfg *config.Config + metrics *MetricsMiddleware + httpCli *http.Client + server string + url string +} + +func newWebhookSender(cfg *config.Config, m *MetricsMiddleware) *WebhookSender { + if cfg.MatrixWebhookURL == "" { + return nil + } + + return &WebhookSender{ + cfg: cfg, + metrics: m, + httpCli: &http.Client{Timeout: 10 * time.Second}, + server: cfg.MatrixServerID, + url: cfg.MatrixWebhookURL, + } +} + +func (wh *WebhookSender) Send(ctx context.Context, jsonBytes []byte) { + if wh == nil { + return + } + + if len(jsonBytes) == 0 { + log.ErrorWithContext(ctx, "webhook: empty payload ignored") + return + } + + var lastErr error + for attempt := 1; attempt <= 5; attempt++ { + delay := exponentialBackoff(attempt) + if attempt > 1 { + select { + case <-time.After(delay): + case <-ctx.Done(): + log.WarnWithContext(ctx, "webhook cancelled during backoff", "attempt", attempt, "error", ctx.Err()) + return + } + } + + err := wh.sendAttempt(ctx, jsonBytes, attempt) + if err == nil { + log.InfoWithContext(ctx, + "webhook succeeded", + "attempt", attempt, + "server", wh.server, + "url", maskURL(wh.url), + ) + return + } + + lastErr = err + log.WarnWithContext(ctx, + "webhook attempt failed", + "attempt", attempt, + "total_attempts", 5, + "server", wh.server, + "url", maskURL(wh.url), + "error", err, + ) + } + + log.ErrorWithContext(ctx, + "webhook failed after all attempts", + "total_attempts", 5, + "server", wh.server, + "url", maskURL(wh.url), + "error", lastErr, + ) +} + +func (wh *WebhookSender) sendAttempt(ctx context.Context, jsonBytes []byte, attempt int) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, wh.url, strings.NewReader(string(jsonBytes))) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "naco-api/"+wh.server) + + resp, err := wh.httpCli.Do(req) + if err != nil { + return fmt.Errorf("do request: %w", err) + } + _ = resp.Body.Close() + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + + return fmt.Errorf("non-2xx status: %d", resp.StatusCode) +} + +func exponentialBackoff(attempt int) time.Duration { + base := 500 * time.Millisecond + delay := base << (attempt - 1) + if delay > 30*time.Second { + delay = 30 * time.Second + } + return delay +} + +func maskURL(url string) string { + if len(url) > 30 { + return url[:15] + "..." + url[len(url)-15:] + } + return url +} diff --git a/internal/store/AnalyticsCache.sql.go b/internal/store/AnalyticsCache.sql.go new file mode 100644 index 0000000..c0e4493 --- /dev/null +++ b/internal/store/AnalyticsCache.sql.go @@ -0,0 +1,96 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: AnalyticsCache.sql + +package store + +import ( + "context" + "encoding/json" + "time" +) + +const deleteAnalyticsCache = `-- name: DeleteAnalyticsCache :exec +DELETE FROM analytics_cache +WHERE user_id = $1 AND platform = $2 AND tool_name = $3 AND time_range_hash = $4 +` + +type DeleteAnalyticsCacheParams struct { + UserID string + Platform string + ToolName string + TimeRangeHash string +} + +func (q *Queries) DeleteAnalyticsCache(ctx context.Context, arg DeleteAnalyticsCacheParams) error { + _, err := q.db.ExecContext(ctx, deleteAnalyticsCache, + arg.UserID, + arg.Platform, + arg.ToolName, + arg.TimeRangeHash, + ) + return err +} + +const getAnalyticsCache = `-- name: GetAnalyticsCache :one +SELECT result FROM analytics_cache +WHERE user_id = $1 AND platform = $2 AND tool_name = $3 AND time_range_hash = $4 + AND expires_at > now() +` + +type GetAnalyticsCacheParams struct { + UserID string + Platform string + ToolName string + TimeRangeHash string +} + +func (q *Queries) GetAnalyticsCache(ctx context.Context, arg GetAnalyticsCacheParams) (json.RawMessage, error) { + row := q.db.QueryRowContext(ctx, getAnalyticsCache, + arg.UserID, + arg.Platform, + arg.ToolName, + arg.TimeRangeHash, + ) + var result json.RawMessage + err := row.Scan(&result) + return result, err +} + +const pruneAnalyticsCache = `-- name: PruneAnalyticsCache :exec +DELETE FROM analytics_cache WHERE expires_at <= now() +` + +func (q *Queries) PruneAnalyticsCache(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, pruneAnalyticsCache) + return err +} + +const upsertAnalyticsCache = `-- name: UpsertAnalyticsCache :exec +INSERT INTO analytics_cache (user_id, platform, tool_name, time_range_hash, result, expires_at) +VALUES ($1, $2, $3, $4, $5, $6) +ON CONFLICT (user_id, platform, tool_name, time_range_hash) +DO UPDATE SET result = EXCLUDED.result, expires_at = EXCLUDED.expires_at, computed_at = now() +` + +type UpsertAnalyticsCacheParams struct { + UserID string + Platform string + ToolName string + TimeRangeHash string + Result json.RawMessage + ExpiresAt time.Time +} + +func (q *Queries) UpsertAnalyticsCache(ctx context.Context, arg UpsertAnalyticsCacheParams) error { + _, err := q.db.ExecContext(ctx, upsertAnalyticsCache, + arg.UserID, + arg.Platform, + arg.ToolName, + arg.TimeRangeHash, + arg.Result, + arg.ExpiresAt, + ) + return err +} diff --git a/internal/store/AnalyticsCoverage.sql.go b/internal/store/AnalyticsCoverage.sql.go new file mode 100644 index 0000000..9010166 --- /dev/null +++ b/internal/store/AnalyticsCoverage.sql.go @@ -0,0 +1,96 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: AnalyticsCoverage.sql + +package store + +import ( + "context" + "time" +) + +const deleteAnalyticsCoverageRange = `-- name: DeleteAnalyticsCoverageRange :exec +DELETE FROM analytics_coverage_ranges +WHERE user_id = $1 AND platform = $2 AND covered_start = $3 AND covered_end = $4 +` + +type DeleteAnalyticsCoverageRangeParams struct { + UserID string + Platform string + CoveredStart time.Time + CoveredEnd time.Time +} + +func (q *Queries) DeleteAnalyticsCoverageRange(ctx context.Context, arg DeleteAnalyticsCoverageRangeParams) error { + _, err := q.db.ExecContext(ctx, deleteAnalyticsCoverageRange, + arg.UserID, + arg.Platform, + arg.CoveredStart, + arg.CoveredEnd, + ) + return err +} + +const getAnalyticsCoverageRanges = `-- name: GetAnalyticsCoverageRanges :many +SELECT covered_start, covered_end FROM analytics_coverage_ranges +WHERE user_id = $1 AND platform = $2 +ORDER BY covered_start +` + +type GetAnalyticsCoverageRangesParams struct { + UserID string + Platform string +} + +type GetAnalyticsCoverageRangesRow struct { + CoveredStart time.Time + CoveredEnd time.Time +} + +func (q *Queries) GetAnalyticsCoverageRanges(ctx context.Context, arg GetAnalyticsCoverageRangesParams) ([]GetAnalyticsCoverageRangesRow, error) { + rows, err := q.db.QueryContext(ctx, getAnalyticsCoverageRanges, arg.UserID, arg.Platform) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetAnalyticsCoverageRangesRow + for rows.Next() { + var i GetAnalyticsCoverageRangesRow + if err := rows.Scan(&i.CoveredStart, &i.CoveredEnd); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const insertAnalyticsCoverageRange = `-- name: InsertAnalyticsCoverageRange :exec +INSERT INTO analytics_coverage_ranges (user_id, platform, covered_start, covered_end, updated_at) +VALUES ($1, $2, $3, $4, now()) +ON CONFLICT (user_id, platform, covered_start, covered_end) +DO UPDATE SET updated_at = now() +` + +type InsertAnalyticsCoverageRangeParams struct { + UserID string + Platform string + CoveredStart time.Time + CoveredEnd time.Time +} + +func (q *Queries) InsertAnalyticsCoverageRange(ctx context.Context, arg InsertAnalyticsCoverageRangeParams) error { + _, err := q.db.ExecContext(ctx, insertAnalyticsCoverageRange, + arg.UserID, + arg.Platform, + arg.CoveredStart, + arg.CoveredEnd, + ) + return err +} diff --git a/internal/store/AnalyticsPosts.sql.go b/internal/store/AnalyticsPosts.sql.go new file mode 100644 index 0000000..f68dd24 --- /dev/null +++ b/internal/store/AnalyticsPosts.sql.go @@ -0,0 +1,113 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: AnalyticsPosts.sql + +package store + +import ( + "context" + "time" +) + +const getAnalyticsPostsInRange = `-- name: GetAnalyticsPostsInRange :many +SELECT post_id, post_text, post_timestamp, likes, reposts, replies, quotes +FROM analytics_posts +WHERE user_id = $1 AND platform = $2 AND post_timestamp >= $3 AND post_timestamp <= $4 +ORDER BY post_timestamp DESC +LIMIT $5 +` + +type GetAnalyticsPostsInRangeParams struct { + UserID string + Platform string + PostTimestamp time.Time + PostTimestamp_2 time.Time + Limit int32 +} + +type GetAnalyticsPostsInRangeRow struct { + PostID string + PostText string + PostTimestamp time.Time + Likes int64 + Reposts int64 + Replies int64 + Quotes int64 +} + +func (q *Queries) GetAnalyticsPostsInRange(ctx context.Context, arg GetAnalyticsPostsInRangeParams) ([]GetAnalyticsPostsInRangeRow, error) { + rows, err := q.db.QueryContext(ctx, getAnalyticsPostsInRange, + arg.UserID, + arg.Platform, + arg.PostTimestamp, + arg.PostTimestamp_2, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetAnalyticsPostsInRangeRow + for rows.Next() { + var i GetAnalyticsPostsInRangeRow + if err := rows.Scan( + &i.PostID, + &i.PostText, + &i.PostTimestamp, + &i.Likes, + &i.Reposts, + &i.Replies, + &i.Quotes, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertAnalyticsPosts = `-- name: UpsertAnalyticsPosts :exec +INSERT INTO analytics_posts (user_id, platform, post_id, post_text, post_timestamp, likes, reposts, replies, quotes) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +ON CONFLICT (user_id, platform, post_id) +DO UPDATE SET post_text = EXCLUDED.post_text, + post_timestamp = EXCLUDED.post_timestamp, + likes = EXCLUDED.likes, + reposts = EXCLUDED.reposts, + replies = EXCLUDED.replies, + quotes = EXCLUDED.quotes +` + +type UpsertAnalyticsPostsParams struct { + UserID string + Platform string + PostID string + PostText string + PostTimestamp time.Time + Likes int64 + Reposts int64 + Replies int64 + Quotes int64 +} + +func (q *Queries) UpsertAnalyticsPosts(ctx context.Context, arg UpsertAnalyticsPostsParams) error { + _, err := q.db.ExecContext(ctx, upsertAnalyticsPosts, + arg.UserID, + arg.Platform, + arg.PostID, + arg.PostText, + arg.PostTimestamp, + arg.Likes, + arg.Reposts, + arg.Replies, + arg.Quotes, + ) + return err +} diff --git a/internal/store/DeleteStylePosts.sql.go b/internal/store/DeleteStylePosts.sql.go new file mode 100644 index 0000000..e9680d7 --- /dev/null +++ b/internal/store/DeleteStylePosts.sql.go @@ -0,0 +1,25 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: DeleteStylePosts.sql + +package store + +import ( + "context" +) + +const deleteStylePosts = `-- name: DeleteStylePosts :exec +DELETE FROM user_style_posts +WHERE user_id = $1 AND platform = $2 AND platform != 'Twitter' +` + +type DeleteStylePostsParams struct { + UserID string + Platform string +} + +func (q *Queries) DeleteStylePosts(ctx context.Context, arg DeleteStylePostsParams) error { + _, err := q.db.ExecContext(ctx, deleteStylePosts, arg.UserID, arg.Platform) + return err +} diff --git a/internal/store/FindSimilarUsers.sql.go b/internal/store/FindSimilarUsers.sql.go index 6211da2..b2d4767 100644 --- a/internal/store/FindSimilarUsers.sql.go +++ b/internal/store/FindSimilarUsers.sql.go @@ -20,8 +20,8 @@ LIMIT $2 ` type FindSimilarUsersParams struct { - Column1 interface{} - Limit int32 + QueryEmbedding interface{} + LimitVal int32 } type FindSimilarUsersRow struct { @@ -35,7 +35,7 @@ type FindSimilarUsersRow struct { } func (q *Queries) FindSimilarUsers(ctx context.Context, arg FindSimilarUsersParams) ([]FindSimilarUsersRow, error) { - rows, err := q.db.QueryContext(ctx, findSimilarUsers, arg.Column1, arg.Limit) + rows, err := q.db.QueryContext(ctx, findSimilarUsers, arg.QueryEmbedding, arg.LimitVal) if err != nil { return nil, err } diff --git a/internal/store/GetStylePostByID.sql.go b/internal/store/GetStylePostByID.sql.go new file mode 100644 index 0000000..b3ca156 --- /dev/null +++ b/internal/store/GetStylePostByID.sql.go @@ -0,0 +1,33 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: GetStylePostByID.sql + +package store + +import ( + "context" +) + +const getStylePostByID = `-- name: GetStylePostByID :one +SELECT post_id, post_text FROM user_style_posts +WHERE user_id = $1 AND platform = $2 AND post_id = $3 +` + +type GetStylePostByIDParams struct { + UserID string + Platform string + PostID string +} + +type GetStylePostByIDRow struct { + PostID string + PostText string +} + +func (q *Queries) GetStylePostByID(ctx context.Context, arg GetStylePostByIDParams) (GetStylePostByIDRow, error) { + row := q.db.QueryRowContext(ctx, getStylePostByID, arg.UserID, arg.Platform, arg.PostID) + var i GetStylePostByIDRow + err := row.Scan(&i.PostID, &i.PostText) + return i, err +} diff --git a/internal/store/GetStylePosts.sql.go b/internal/store/GetStylePosts.sql.go new file mode 100644 index 0000000..34c443d --- /dev/null +++ b/internal/store/GetStylePosts.sql.go @@ -0,0 +1,51 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: GetStylePosts.sql + +package store + +import ( + "context" +) + +const getStylePosts = `-- name: GetStylePosts :many +SELECT post_id, post_text FROM user_style_posts +WHERE user_id = $1 AND platform = $2 +ORDER BY created_at DESC +LIMIT $3 +` + +type GetStylePostsParams struct { + UserID string + Platform string + Limit int32 +} + +type GetStylePostsRow struct { + PostID string + PostText string +} + +func (q *Queries) GetStylePosts(ctx context.Context, arg GetStylePostsParams) ([]GetStylePostsRow, error) { + rows, err := q.db.QueryContext(ctx, getStylePosts, arg.UserID, arg.Platform, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetStylePostsRow + for rows.Next() { + var i GetStylePostsRow + if err := rows.Scan(&i.PostID, &i.PostText); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/store/GetStylePostsByIDs.sql.go b/internal/store/GetStylePostsByIDs.sql.go new file mode 100644 index 0000000..fb5c417 --- /dev/null +++ b/internal/store/GetStylePostsByIDs.sql.go @@ -0,0 +1,51 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: GetStylePostsByIDs.sql + +package store + +import ( + "context" + + "github.com/lib/pq" +) + +const getStylePostsByIDs = `-- name: GetStylePostsByIDs :many +SELECT post_id, post_text FROM user_style_posts +WHERE user_id = $1 AND platform = $2 AND post_id = ANY($3::text[]) +` + +type GetStylePostsByIDsParams struct { + UserID string + Platform string + Column3 []string +} + +type GetStylePostsByIDsRow struct { + PostID string + PostText string +} + +func (q *Queries) GetStylePostsByIDs(ctx context.Context, arg GetStylePostsByIDsParams) ([]GetStylePostsByIDsRow, error) { + rows, err := q.db.QueryContext(ctx, getStylePostsByIDs, arg.UserID, arg.Platform, pq.Array(arg.Column3)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetStylePostsByIDsRow + for rows.Next() { + var i GetStylePostsByIDsRow + if err := rows.Scan(&i.PostID, &i.PostText); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/store/GetStyleProfile.sql.go b/internal/store/GetStyleProfile.sql.go index e86003c..153823e 100644 --- a/internal/store/GetStyleProfile.sql.go +++ b/internal/store/GetStyleProfile.sql.go @@ -12,7 +12,7 @@ import ( ) const getStyleProfile = `-- name: GetStyleProfile :one -SELECT id, user_id, platform, embedding, top_keywords, style_summary, post_count, modified_at, created_at FROM user_style_profiles +SELECT id, user_id, platform, embedding, top_keywords, style_summary, post_count, modified_at, created_at, model_version, schema_version, algorithm_version FROM user_style_profiles WHERE user_id = $1 AND platform = $2 ` @@ -34,6 +34,9 @@ func (q *Queries) GetStyleProfile(ctx context.Context, arg GetStyleProfileParams &i.PostCount, &i.ModifiedAt, &i.CreatedAt, + &i.ModelVersion, + &i.SchemaVersion, + &i.AlgorithmVersion, ) return i, err } diff --git a/internal/store/PruneStylePosts.sql.go b/internal/store/PruneStylePosts.sql.go new file mode 100644 index 0000000..d2d6ae3 --- /dev/null +++ b/internal/store/PruneStylePosts.sql.go @@ -0,0 +1,20 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: PruneStylePosts.sql + +package store + +import ( + "context" +) + +const pruneStylePosts = `-- name: PruneStylePosts :exec +DELETE FROM user_style_posts +WHERE created_at < now() - interval '7 days' AND platform != 'Twitter' +` + +func (q *Queries) PruneStylePosts(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, pruneStylePosts) + return err +} diff --git a/internal/store/UpsertStylePosts.sql.go b/internal/store/UpsertStylePosts.sql.go new file mode 100644 index 0000000..49eff57 --- /dev/null +++ b/internal/store/UpsertStylePosts.sql.go @@ -0,0 +1,34 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: UpsertStylePosts.sql + +package store + +import ( + "context" +) + +const upsertStylePosts = `-- name: UpsertStylePosts :exec +INSERT INTO user_style_posts (user_id, platform, post_id, post_text) +VALUES ($1, $2, $3, $4) +ON CONFLICT (user_id, platform, post_id) +DO UPDATE SET post_text = EXCLUDED.post_text, created_at = now() +` + +type UpsertStylePostsParams struct { + UserID string + Platform string + PostID string + PostText string +} + +func (q *Queries) UpsertStylePosts(ctx context.Context, arg UpsertStylePostsParams) error { + _, err := q.db.ExecContext(ctx, upsertStylePosts, + arg.UserID, + arg.Platform, + arg.PostID, + arg.PostText, + ) + return err +} diff --git a/internal/store/UpsertStyleProfiles.sql.go b/internal/store/UpsertStyleProfiles.sql.go index 9e80301..e6d431a 100644 --- a/internal/store/UpsertStyleProfiles.sql.go +++ b/internal/store/UpsertStyleProfiles.sql.go @@ -13,14 +13,14 @@ import ( const upsertStyleProfile = `-- name: UpsertStyleProfile :exec INSERT INTO user_style_profiles (user_id, platform, embedding, top_keywords, style_summary, post_count) -VALUES ($1, $2, $3, $4, $5, $6) +VALUES ($1, $2, $3::vector, $4, $5, $6) ON CONFLICT (user_id, platform) DO UPDATE SET embedding = EXCLUDED.embedding, top_keywords = EXCLUDED.top_keywords, style_summary = EXCLUDED.style_summary, post_count = EXCLUDED.post_count, - updated_at = NOW() + modified_at = NOW() ` type UpsertStyleProfileParams struct { diff --git a/internal/store/integration_test.go b/internal/store/integration_test.go new file mode 100644 index 0000000..5c9aeaf --- /dev/null +++ b/internal/store/integration_test.go @@ -0,0 +1,194 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "os" + "reflect" + "strings" + "testing" + "time" + + _ "github.com/lib/pq" +) + +// setupTestDB handles connection pooling, migrations, and cleanup per test run. +func setupTestDB(t *testing.T) (*sql.DB, *Queries) { + t.Helper() + + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + t.Skip("TEST_DATABASE_URL not set; skipping store integration test") + } + + ctx := context.Background() + conn, err := sql.Open("postgres", dsn) + if err != nil { + t.Fatalf("failed to open db: %v", err) + } + + if err := conn.PingContext(ctx); err != nil { + _ = conn.Close() + t.Fatalf("cannot reach test database: %v", err) + } + + t.Cleanup(func() { + _ = conn.Close() + }) + + if err := Migrate(ctx, conn, "sql/schema"); err != nil { + t.Fatalf("migrate: %v", err) + } + + return conn, New(conn) +} + +func makeEmbedding(dim int, val string) string { + return "[" + strings.TrimSuffix(strings.Repeat(val+",", dim), ",") + "]" +} + +func TestStoreRoundTrip(t *testing.T) { + conn, q := setupTestDB(t) + ctx := context.Background() + + uid := fmt.Sprintf("test_%d", time.Now().UnixNano()) + platform := "Bluesky" + emb1 := makeEmbedding(1024, "0.1") + + // Clean up after the test so leftover records do not pollute subsequent runs + t.Cleanup(func() { + _, _ = conn.ExecContext(context.Background(), "DELETE FROM style_profiles WHERE user_id = $1", uid) + }) + + // 1. Test INSERT path of Upsert + err := q.UpsertStyleProfile(ctx, UpsertStyleProfileParams{ + UserID: uid, + Platform: platform, + Embedding: emb1, + TopKeywords: []string{"alpha", "beta"}, + StyleSummary: "original summary", + PostCount: 5, + }) + if err != nil { + t.Fatalf("initial upsert failed: %v", err) + } + + // 2. Test GET and verify fields + p, err := q.GetStyleProfile(ctx, GetStyleProfileParams{UserID: uid, Platform: platform}) + if err != nil { + t.Fatalf("get failed: %v", err) + } + + if p.UserID != uid || p.Platform != platform || p.PostCount != 5 { + t.Errorf("mismatched basic fields: %+v", p) + } + if p.StyleSummary != "original summary" { + t.Errorf("got summary %q, want %q", p.StyleSummary, "original summary") + } + if !reflect.DeepEqual(p.TopKeywords, []string{"alpha", "beta"}) { + t.Errorf("got keywords %v, want %v", p.TopKeywords, []string{"alpha", "beta"}) + } + + // 3. Test UPDATE path of Upsert (Verify conflict resolution works) + emb2 := makeEmbedding(1024, "0.2") + err = q.UpsertStyleProfile(ctx, UpsertStyleProfileParams{ + UserID: uid, + Platform: platform, + Embedding: emb2, + TopKeywords: []string{"gamma"}, + StyleSummary: "updated summary", + PostCount: 10, + }) + if err != nil { + t.Fatalf("secondary upsert (update) failed: %v", err) + } + + updated, err := q.GetStyleProfile(ctx, GetStyleProfileParams{UserID: uid, Platform: platform}) + if err != nil { + t.Fatalf("get updated profile failed: %v", err) + } + if updated.PostCount != 10 || updated.StyleSummary != "updated summary" { + t.Errorf("upsert failed to update existing record: %+v", updated) + } + + // 4. Test pgvector similarity search + rows, err := q.FindSimilarUsers(ctx, FindSimilarUsersParams{ + QueryEmbedding: emb2, + LimitVal: 10, + }) + if err != nil { + t.Fatalf("find similar failed: %v", err) + } + + found := false + for _, r := range rows { + if r.UserID == uid { + found = true + break + } + } + if !found { + t.Errorf("expected user %s in similarity results, but was not found", uid) + } +} + +func TestStoreSaveTransaction(t *testing.T) { + conn, q := setupTestDB(t) + ctx := context.Background() + + txHash := fmt.Sprintf("0x_%d", time.Now().UnixNano()) + amount := "10.0" + asset := "ETH" + network := "eip155:1" + recipient := "0x1234567890abcdef" + sender := "0xabcdef1234567890" + + t.Cleanup(func() { + _, _ = conn.ExecContext(context.Background(), "DELETE FROM transactions WHERE x402_transaction_hash = $1", txHash) + }) + + ret, err := q.SaveTransaction(ctx, SaveTransactionParams{ + X402TransactionHash: txHash, + Amount: amount, + Asset: asset, + Network: network, + Recipient: recipient, + Sender: sender, + }) + if err != nil { + t.Fatalf("SaveTransaction error: %v", err) + } + + if len(ret) != 1 { + t.Fatalf("expected 1 result row returned, got %d", len(ret)) + } + + saved := ret[0] + if saved.ID <= 0 { + t.Errorf("expected positive ID, got %d", saved.ID) + } + if !saved.CreatedAt.Valid { + t.Error("expected CreatedAt to be set") + } + + // Since the sqlc query only returns `id` and `created_at`, + // verify directly that PostgreSQL stored the remaining columns accurately: + var ( + gotHash, gotAmount, gotAsset, gotNetwork, gotRecipient, gotSender string + ) + err = conn.QueryRowContext(ctx, ` + SELECT x402_transaction_hash, amount, asset, network, recipient, sender + FROM transactions + WHERE id = $1 + `, saved.ID).Scan(&gotHash, &gotAmount, &gotAsset, &gotNetwork, &gotRecipient, &gotSender) + if err != nil { + t.Fatalf("failed to query inserted transaction from db: %v", err) + } + + if gotHash != txHash || gotAmount != amount || gotAsset != asset || + gotNetwork != network || gotRecipient != recipient || gotSender != sender { + t.Errorf("stored values do not match input params: got (%s, %s, %s, %s, %s, %s)", + gotHash, gotAmount, gotAsset, gotNetwork, gotRecipient, gotSender) + } +} diff --git a/internal/store/migrate.go b/internal/store/migrate.go new file mode 100644 index 0000000..b6d7932 --- /dev/null +++ b/internal/store/migrate.go @@ -0,0 +1,106 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// Migrate applies every *.sql file in dir (in filename order). Each file is expected to +// contain a "-- +goose up" section (between that marker and "-- +goose down", or EOF) which +// is executed inside a single transaction. Applied versions are recorded in +// schema_migrations so re-running is a no-op. +func Migrate(ctx context.Context, db *sql.DB, dir string) error { + if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`); err != nil { + return fmt.Errorf("create schema_migrations table: %w", err) + } + + entries, err := os.ReadDir(dir) + if err != nil { + return fmt.Errorf("read migrations dir %q: %w", dir, err) + } + + type migration struct{ name, up string } + migrations := make([]migration, 0, len(entries)) + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") { + continue + } + raw, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + return fmt.Errorf("read migration %q: %w", e.Name(), err) + } + migrations = append(migrations, migration{name: e.Name(), up: extractUpSQL(string(raw))}) + } + sort.Slice(migrations, func(i, j int) bool { return migrations[i].name < migrations[j].name }) + + for _, m := range migrations { + if strings.TrimSpace(m.up) == "" { + continue + } + applied, err := versionApplied(ctx, db, m.name) + if err != nil { + return err + } + if applied { + continue + } + if err := applyMigration(ctx, db, m.name, m.up); err != nil { + return fmt.Errorf("apply migration %q: %w", m.name, err) + } + } + return nil +} + +// extractUpSQL returns the SQL between the "-- +goose up" marker and the next +// "-- +goose down" marker (or end of file). +func extractUpSQL(content string) string { + var b strings.Builder + inUp := false + for _, line := range strings.Split(content, "\n") { + switch strings.TrimSpace(line) { + case "-- +goose up": + inUp = true + continue + case "-- +goose down": + inUp = false + continue + } + if inUp { + b.WriteString(line) + b.WriteString("\n") + } + } + return b.String() +} + +func versionApplied(ctx context.Context, db *sql.DB, version string) (bool, error) { + var count int + if err := db.QueryRowContext(ctx, `SELECT count(*) FROM schema_migrations WHERE version = $1`, version).Scan(&count); err != nil { + return false, fmt.Errorf("check migration %q: %w", version, err) + } + return count > 0, nil +} + +func applyMigration(ctx context.Context, db *sql.DB, version, up string) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + if _, err := tx.ExecContext(ctx, up); err != nil { + _ = tx.Rollback() + return err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations (version) VALUES ($1)`, version); err != nil { + _ = tx.Rollback() + return err + } + return tx.Commit() +} diff --git a/internal/store/models.go b/internal/store/models.go index eac3136..2bba824 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -6,8 +6,43 @@ package store import ( "database/sql" + "encoding/json" + "time" ) +type AnalyticsCache struct { + ID int64 + UserID string + Platform string + ToolName string + TimeRangeHash string + Result json.RawMessage + ComputedAt time.Time + ExpiresAt time.Time +} + +type AnalyticsCoverageRange struct { + UserID string + Platform string + CoveredStart time.Time + CoveredEnd time.Time + UpdatedAt time.Time +} + +type AnalyticsPost struct { + ID int64 + UserID string + Platform string + PostID string + PostText string + PostTimestamp time.Time + Likes int64 + Reposts int64 + Replies int64 + Quotes int64 + CreatedAt time.Time +} + type Transaction struct { ID int64 X402TransactionHash string @@ -19,14 +54,26 @@ type Transaction struct { CreatedAt sql.NullTime } -type UserStyleProfile struct { - ID int64 - UserID string - Platform string - Embedding interface{} - TopKeywords []string - StyleSummary string - PostCount int32 - ModifiedAt sql.NullTime - CreatedAt sql.NullTime +type UserStylePost struct { + ID int64 + UserID string + Platform string + PostID string + PostText string + CreatedAt time.Time +} + +type UserStyleProfile struct { + ID int64 + UserID string + Platform string + Embedding interface{} + TopKeywords []string + StyleSummary string + PostCount int32 + ModifiedAt sql.NullTime + CreatedAt sql.NullTime + ModelVersion int32 + SchemaVersion int32 + AlgorithmVersion int32 } diff --git a/internal/twitter/client.go b/internal/twitter/client.go new file mode 100644 index 0000000..f849796 --- /dev/null +++ b/internal/twitter/client.go @@ -0,0 +1,369 @@ +package twitter + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + log "github.com/nacorid/logger" +) + +func parseTwitterTime(s string) time.Time { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + log.Warn("Failed to parse Twitter timestamp", "timestamp", s, "error", err) + return time.Time{} + } + return t +} + +const apiBase = "https://api.x.com/2" + +// httpTimeout bounds a single HTTP request so a stalled X backend cannot block +// a request indefinitely. +const httpTimeout = 60 * time.Second + +const userAgent = "naco-api/1.0" + +type Client struct { + httpClient *http.Client + bearerToken string +} + +func NewTwitterClient(bearerToken string) *Client { + return &Client{ + httpClient: &http.Client{Timeout: httpTimeout}, + bearerToken: bearerToken, + } +} + +type Post struct { + ID string + Author string + Content string + CreatedAt time.Time + Likes int + Reposts int + Quotes int + Replies int +} + +type userResponse struct { + Data struct { + ID string `json:"id"` + Name string `json:"name"` + Username string `json:"username"` + } `json:"data"` + Errors []struct { + Title string `json:"title"` + Detail string `json:"detail"` + } `json:"errors"` +} + +type tweetMetrics struct { + RetweetCount int `json:"retweet_count"` + ReplyCount int `json:"reply_count"` + LikeCount int `json:"like_count"` + QuoteCount int `json:"quote_count"` +} + +type tweetData struct { + ID string `json:"id"` + Text string `json:"text"` + CreatedAt string `json:"created_at"` + PublicMetrics tweetMetrics `json:"public_metrics"` +} + +type tweetsResponse struct { + Data []tweetData `json:"data"` + Includes struct { + Users []struct { + Username string `json:"username"` + } `json:"users"` + } `json:"includes"` + Errors []struct { + Title string `json:"title"` + Detail string `json:"detail"` + } `json:"errors"` +} + +type singleTweetResponse struct { + Data struct { + ID string `json:"id"` + Text string `json:"text"` + CreatedAt string `json:"created_at"` + PublicMetrics tweetMetrics `json:"public_metrics"` + } `json:"data"` + Includes struct { + Users []struct { + Username string `json:"username"` + } `json:"users"` + } `json:"includes"` + Errors []struct { + Title string `json:"title"` + Detail string `json:"detail"` + } `json:"errors"` +} + +const ( + maxRetries = 4 + maxRateLimitWait = 30 * time.Second // Never stall a request longer than 30s +) + +var defaultBackoff = 500 * time.Millisecond + +func (c *Client) doGet(ctx context.Context, endpoint string) ([]byte, error) { + backoff := defaultBackoff + + for attempt := 0; ; attempt++ { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("failed to build request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.bearerToken) + req.Header.Set("User-Agent", userAgent) + + resp, err := c.httpClient.Do(req) + if err != nil { + // Transport-level failure (connection refused/reset, timeout). + if attempt >= maxRetries { + return nil, fmt.Errorf("twitter request failed after %d attempts: %w", maxRetries+1, err) + } + log.WarnWithContext(ctx, "twitter transport error, waiting to retry", "attempt", attempt+1, "error", err) + if werr := waitForRetry(ctx, backoff); werr != nil { + return nil, werr + } + backoff *= 2 + continue + } + + // Handle 429 Too Many Requests + if resp.StatusCode == http.StatusTooManyRequests { + // Drain & close body before retrying. + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + + if attempt >= maxRetries { + return nil, fmt.Errorf("twitter rate limit exceeded, max retries reached") + } + + wait := parseRateLimitWait(resp.Header, backoff) + + // Protect against 15-minute thread freezes + if wait > maxRateLimitWait { + return nil, fmt.Errorf("twitter rate limited: reset wait (%s) exceeds maximum allowed (%s)", wait, maxRateLimitWait) + } + + log.WarnWithContext(ctx, "twitter rate limited, waiting to retry", "attempt", attempt+1, "wait", wait.String()) + if werr := waitForRetry(ctx, wait); werr != nil { + return nil, werr + } + backoff *= 2 + continue + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) + _ = resp.Body.Close() + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + // Retry transient server-side failures (5xx) with backoff. 4xx/3xx fail + // fast below. + if resp.StatusCode >= http.StatusInternalServerError && attempt < maxRetries { + log.WarnWithContext(ctx, "twitter server error, waiting to retry", "attempt", attempt+1, "status", resp.Status) + if werr := waitForRetry(ctx, backoff); werr != nil { + return nil, werr + } + backoff *= 2 + continue + } + + if resp.StatusCode != http.StatusOK { + errMsg := strings.TrimSpace(string(body)) + if len(errMsg) > 512 { + errMsg = errMsg[:512] + "... (truncated)" + } + return nil, fmt.Errorf("twitter error (%s): %s", resp.Status, errMsg) + } + + return body, nil + } +} + +// waitForRetry sleeps for wait, aborting early if the context is canceled. +// A timer is used (not time.After) so the timer can be stopped to free its +// resource when the caller abandons the wait. +func waitForRetry(ctx context.Context, wait time.Duration) error { + timer := time.NewTimer(wait) + defer timer.Stop() + select { + case <-timer.C: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// parseRateLimitWait extracts x-rate-limit-* or Retry-After, falling back to backoff. +func parseRateLimitWait(header http.Header, fallback time.Duration) time.Duration { + // 1. Check x-rate-limit-reset (Unix timestamp) + if remStr := header.Get("x-rate-limit-remaining"); remStr != "" { + if rem, err := strconv.Atoi(remStr); err == nil && rem <= 0 { + if rstStr := header.Get("x-rate-limit-reset"); rstStr != "" { + if rst, err := strconv.ParseInt(rstStr, 10, 64); err == nil { + // Add 1s clock skew buffer + wait := time.Until(time.Unix(rst, 0)) + (1 * time.Second) + if wait > 0 { + return wait + } + } + } + } + } + + // 2. Check standard Retry-After (seconds) + if ra := header.Get("Retry-After"); ra != "" { + if secs, err := strconv.Atoi(ra); err == nil && secs > 0 { + return time.Duration(secs)*time.Second + (1 * time.Second) + } + } + + return fallback +} + +func (c *Client) resolveUser(ctx context.Context, username string) (id, handle string, err error) { + endpoint := fmt.Sprintf("%s/users/by/username/%s", apiBase, url.PathEscape(username)) + body, err := c.doGet(ctx, endpoint) + if err != nil { + return "", "", err + } + var ur userResponse + if err := json.Unmarshal(body, &ur); err != nil { + return "", "", fmt.Errorf("failed to unmarshal user response: %w", err) + } + if len(ur.Errors) > 0 { + return "", "", fmt.Errorf("twitter error: %s: %s", ur.Errors[0].Title, ur.Errors[0].Detail) + } + if ur.Data.ID == "" { + return "", "", fmt.Errorf("twitter user %q not found", username) + } + return ur.Data.ID, ur.Data.Username, nil +} + +func (c *Client) GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*Post, error) { + if c.bearerToken == "" { + return nil, fmt.Errorf("twitter bearer token not configured") + } + username := strings.TrimPrefix(actor, "@") + + userID, handle, err := c.resolveUser(ctx, username) + if err != nil { + return nil, err + } + + if limit > 100 { + limit = 100 + } + + endpoint := fmt.Sprintf("%s/users/%s/tweets?max_results=%d&tweet.fields=created_at,public_metrics&exclude=retweets,replies", + apiBase, url.PathEscape(userID), limit) + return c.fetchTweets(ctx, handle, endpoint) +} + +func (c *Client) GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]*Post, error) { + if c.bearerToken == "" { + return nil, fmt.Errorf("twitter bearer token not configured") + } + username := strings.TrimPrefix(actor, "@") + + userID, handle, err := c.resolveUser(ctx, username) + if err != nil { + return nil, err + } + if limit > 100 { + limit = 100 + } + + endpoint := fmt.Sprintf("%s/users/%s/tweets?start_time=%s&end_time=%s&max_results=%d&tweet.fields=created_at,public_metrics&exclude=retweets,replies", + apiBase, url.PathEscape(userID), start.Format(time.RFC3339), end.Format(time.RFC3339), limit) + return c.fetchTweets(ctx, handle, endpoint) +} + +// fetchTweets performs the request and converts the tweet payload into Posts, +// skipping entries without an ID so downstream dedupe keys are never empty. +func (c *Client) fetchTweets(ctx context.Context, username, endpoint string) ([]*Post, error) { + body, err := c.doGet(ctx, endpoint) + if err != nil { + return nil, err + } + + var tr tweetsResponse + if err := json.Unmarshal(body, &tr); err != nil { + return nil, fmt.Errorf("failed to unmarshal tweets response: %w", err) + } + if len(tr.Errors) > 0 { + return nil, fmt.Errorf("twitter error: %s: %s", tr.Errors[0].Title, tr.Errors[0].Detail) + } + + posts := make([]*Post, 0, len(tr.Data)) + for _, t := range tr.Data { + if t.ID == "" { + continue + } + posts = append(posts, &Post{ + ID: t.ID, + Author: username, + Content: t.Text, + CreatedAt: parseTwitterTime(t.CreatedAt), + Likes: t.PublicMetrics.LikeCount, + Reposts: t.PublicMetrics.RetweetCount, + Quotes: t.PublicMetrics.QuoteCount, + Replies: t.PublicMetrics.ReplyCount, + }) + } + return posts, nil +} + +func (c *Client) GetPost(ctx context.Context, id string) (*Post, error) { + if c.bearerToken == "" { + return nil, fmt.Errorf("twitter bearer token not configured") + } + endpoint := fmt.Sprintf("%s/tweets/%s?tweet.fields=created_at,public_metrics&expansions=author_id&user.fields=username", + apiBase, url.PathEscape(id)) + body, err := c.doGet(ctx, endpoint) + if err != nil { + return nil, err + } + + var tr singleTweetResponse + if err := json.Unmarshal(body, &tr); err != nil { + return nil, fmt.Errorf("failed to unmarshal tweet response: %w", err) + } + if len(tr.Errors) > 0 { + return nil, fmt.Errorf("twitter error: %s: %s", tr.Errors[0].Title, tr.Errors[0].Detail) + } + if tr.Data.ID == "" { + return nil, fmt.Errorf("tweet %q not found", id) + } + author := "unknown" + if len(tr.Includes.Users) > 0 { + author = tr.Includes.Users[0].Username + } + return &Post{ + ID: tr.Data.ID, + Author: author, + Content: tr.Data.Text, + CreatedAt: parseTwitterTime(tr.Data.CreatedAt), + Likes: tr.Data.PublicMetrics.LikeCount, + Reposts: tr.Data.PublicMetrics.RetweetCount, + Quotes: tr.Data.PublicMetrics.QuoteCount, + Replies: tr.Data.PublicMetrics.ReplyCount, + }, nil +} diff --git a/internal/twitter/client_test.go b/internal/twitter/client_test.go new file mode 100644 index 0000000..87a6024 --- /dev/null +++ b/internal/twitter/client_test.go @@ -0,0 +1,445 @@ +package twitter + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +// Speed up test suite: override defaultBackoff to 1ms so retries are instantaneous. +func init() { + defaultBackoff = 1 * time.Millisecond +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +// newTestClient returns a Client whose requests are rewritten to the given httptest server. +func newTestClient(serverURL string) *Client { + c := NewTwitterClient("test-token") + host := strings.TrimPrefix(serverURL, "http://") + c.httpClient = &http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + u := *r.URL + u.Scheme = "http" + u.Host = host + r2 := r.Clone(r.Context()) + r2.URL = &u + return http.DefaultTransport.RoundTrip(r2) + }), + } + return c +} + +func TestGetLatestNPostsNoToken(t *testing.T) { + c := NewTwitterClient("") + if _, err := c.GetLatestNPosts(context.Background(), "alice", 10); err == nil { + t.Fatal("expected error when bearer token is empty") + } +} + +func TestGetLatestNPosts(t *testing.T) { + var tweetsURL string + mux := http.NewServeMux() + mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer test-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"data":{"id":"12345","name":"Test","username":"testuser"}}`) + }) + mux.HandleFunc("/2/users/12345/tweets", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer test-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + tweetsURL = r.URL.String() + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"data":[{"id":"1","text":"hello world","created_at":"2024-01-01T00:00:00Z"}]}`) + }) + server := httptest.NewServer(mux) + defer server.Close() + + c := newTestClient(server.URL) + posts, err := c.GetLatestNPosts(context.Background(), "@testuser", 10) + if err != nil { + t.Fatalf("GetLatestNPosts error: %v", err) + } + if len(posts) != 1 { + t.Fatalf("expected 1 post, got %d", len(posts)) + } + if posts[0].Content != "hello world" || posts[0].Author != "testuser" { + t.Fatalf("unexpected post: %+v", posts[0]) + } + if !strings.Contains(tweetsURL, "max_results=10") { + t.Fatalf("expected max_results=10, got %q", tweetsURL) + } +} + +func TestGetLatestNPostsLimitNoLowerFloor(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"data":{"id":"1","username":"u"}}`) + }) + mux.HandleFunc("/2/users/1/tweets", func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("max_results"); got != "3" { + t.Fatalf("expected max_results=3 for limit=3, got %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"data":[]}`) + }) + server := httptest.NewServer(mux) + defer server.Close() + + c := newTestClient(server.URL) + if _, err := c.GetLatestNPosts(context.Background(), "u", 3); err != nil { + t.Fatalf("error: %v", err) + } +} + +func TestGetLatestNPostsHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + c := newTestClient(server.URL) + if _, err := c.GetLatestNPosts(context.Background(), "u", 10); err == nil { + t.Fatal("expected error on non-200 status") + } +} + +func TestGetPost(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"data":{"id":"12345","username":"testuser"}}`) + }) + mux.HandleFunc("/2/tweets/999", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer test-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"data":{"id":"999","text":"a single tweet","created_at":"2024-02-02T00:00:00Z","public_metrics":{"retweet_count":2,"reply_count":1,"like_count":5,"quote_count":0}},"includes":{"users":[{"username":"testuser"}]}}`) + }) + server := httptest.NewServer(mux) + defer server.Close() + + c := newTestClient(server.URL) + p, err := c.GetPost(context.Background(), "999") + if err != nil { + t.Fatalf("GetPost error: %v", err) + } + if p.ID != "999" || p.Content != "a single tweet" || p.Author != "testuser" { + t.Fatalf("unexpected post: %+v", p) + } + if p.Likes != 5 || p.Reposts != 2 || p.Replies != 1 || p.Quotes != 0 { + t.Fatalf("unexpected metrics: %+v", p) + } +} + +func TestGetPostsInTimeframe(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"data":{"id":"12345","username":"testuser"}}`) + }) + mux.HandleFunc("/2/users/12345/tweets", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer test-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + if r.URL.Query().Get("start_time") == "" || r.URL.Query().Get("end_time") == "" { + t.Fatalf("expected start_time and end_time query params") + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"data":[{"id":"1","text":"timed post","created_at":"2024-03-03T00:00:00Z","public_metrics":{"retweet_count":0,"reply_count":0,"like_count":3,"quote_count":0}}]}`) + }) + server := httptest.NewServer(mux) + defer server.Close() + + c := newTestClient(server.URL) + start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + end := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC) + posts, err := c.GetPostsInTimeframe(context.Background(), "testuser", start, end, 10) + if err != nil { + t.Fatalf("GetPostsInTimeframe error: %v", err) + } + if len(posts) != 1 { + t.Fatalf("expected 1 post, got %d", len(posts)) + } + if posts[0].Content != "timed post" || posts[0].Likes != 3 { + t.Fatalf("unexpected post: %+v", posts[0]) + } +} + +func TestRateLimitRetryWithResetHeader(t *testing.T) { + callCount := 0 + mux := http.NewServeMux() + mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) { + callCount++ + if callCount == 1 { + // First call: rate limited with reset header slightly in the past/present + w.Header().Set("x-rate-limit-remaining", "0") + w.Header().Set("x-rate-limit-reset", fmt.Sprintf("%d", time.Now().Unix())) + w.WriteHeader(http.StatusTooManyRequests) + return + } + // Second call: success + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"data":{"id":"12345","name":"Test","username":"testuser"}}`) + }) + mux.HandleFunc("/2/users/12345/tweets", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"data":[{"id":"1","text":"hello","created_at":"2024-01-01T00:00:00Z"}]}`) + }) + server := httptest.NewServer(mux) + defer server.Close() + + c := newTestClient(server.URL) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + posts, err := c.GetLatestNPosts(ctx, "@testuser", 10) + if err != nil { + t.Fatalf("GetLatestNPosts error after retry: %v", err) + } + if callCount != 2 { + t.Fatalf("expected 2 calls (1 retry), got %d", callCount) + } + if len(posts) != 1 { + t.Fatalf("expected 1 post after retry, got %d", len(posts)) + } +} + +func TestRateLimitExhaustedAfterMaxRetries(t *testing.T) { + callCount := 0 + mux := http.NewServeMux() + mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) { + callCount++ + w.Header().Set("x-rate-limit-remaining", "0") + w.WriteHeader(http.StatusTooManyRequests) + }) + server := httptest.NewServer(mux) + defer server.Close() + + c := newTestClient(server.URL) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + _, err := c.GetLatestNPosts(ctx, "@testuser", 10) + if err == nil { + t.Fatal("expected error after max retries exhausted") + } + if callCount < 5 { + t.Fatalf("expected at least 5 attempts (1 initial + 4 retries), got %d", callCount) + } + if !strings.Contains(err.Error(), "rate limit") { + t.Fatalf("expected rate limit error, got %v", err) + } +} + +func TestUserLookupNotFound(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"errors":[{"title":"Not Found","detail":"User not found"}]}`) + }) + server := httptest.NewServer(mux) + defer server.Close() + + c := newTestClient(server.URL) + _, err := c.GetLatestNPosts(context.Background(), "@nonexistent", 10) + if err == nil { + t.Fatal("expected error for user not found") + } + if !strings.Contains(err.Error(), "twitter error") || !strings.Contains(err.Error(), "Not Found") { + t.Fatalf("expected 'Not Found' error, got %v", err) + } +} + +func TestUserLookupUnauthorized(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"errors":[{"title":"Unauthorized","detail":"Invalid token"}]}`) + }) + server := httptest.NewServer(mux) + defer server.Close() + + c := newTestClient(server.URL) + _, err := c.GetLatestNPosts(context.Background(), "@testuser", 10) + if err == nil { + t.Fatal("expected error for unauthorized") + } + if !strings.Contains(err.Error(), "twitter error") || !strings.Contains(err.Error(), "Unauthorized") { + t.Fatalf("expected 'Unauthorized' error, got %v", err) + } +} + +func TestUserLookupRateLimited(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + }) + server := httptest.NewServer(mux) + defer server.Close() + + c := newTestClient(server.URL) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + _, err := c.GetLatestNPosts(ctx, "@testuser", 10) + if err == nil { + t.Fatal("expected error for rate limited user lookup") + } + if !strings.Contains(err.Error(), "rate limit") { + t.Fatalf("expected rate limit error, got %v", err) + } +} + +func TestContextCancellationDuringRetry(t *testing.T) { + reqReceived := make(chan struct{}) + var once sync.Once + + mux := http.NewServeMux() + mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("x-rate-limit-remaining", "0") + w.Header().Set("x-rate-limit-reset", fmt.Sprintf("%d", time.Now().Add(10*time.Second).Unix())) + w.WriteHeader(http.StatusTooManyRequests) + once.Do(func() { + close(reqReceived) // Signal test that the 429 response was returned + }) + }) + server := httptest.NewServer(mux) + defer server.Close() + + c := newTestClient(server.URL) + ctx, cancel := context.WithCancel(context.Background()) + + done := make(chan error, 1) + go func() { + _, err := c.GetLatestNPosts(ctx, "@testuser", 10) + done <- err + }() + + // Deterministic wait: block until the mock server receives the request, then cancel immediately + select { + case <-reqReceived: + cancel() + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for mock request to be hit") + } + + select { + case err := <-done: + if err == nil { + t.Fatal("expected error from context cancellation") + } + if !errors.Is(err, context.Canceled) && !strings.Contains(err.Error(), "context canceled") { + t.Fatalf("expected context.Canceled, got %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("test timed out waiting for goroutine to abort") + } +} + +func TestResolveUserEmptyData(t *testing.T) { + // 200 with an empty data object (banned/suspended or privacy-restricted + // account) must error rather than build a bogus /users//tweets request. + var tweetsCalled bool + mux := http.NewServeMux() + mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"data":{}}`) + }) + mux.HandleFunc("/2/users//tweets", func(w http.ResponseWriter, r *http.Request) { + tweetsCalled = true + }) + server := httptest.NewServer(mux) + defer server.Close() + + c := newTestClient(server.URL) + _, err := c.GetLatestNPosts(context.Background(), "@ghost", 10) + if err == nil { + t.Fatal("expected error for empty user data, got nil") + } + if !strings.Contains(err.Error(), "not found") { + t.Fatalf("expected 'not found' error, got %v", err) + } + if tweetsCalled { + t.Fatal("tweets endpoint should not be called when user lookup returns empty data") + } +} + +func TestGetLatestNPostsSkipsEmptyID(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"data":{"id":"1","username":"u"}}`) + }) + mux.HandleFunc("/2/users/1/tweets", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"data":[{"id":"","text":"bad"},{"id":"2","text":"good","created_at":"2024-01-01T00:00:00Z"}]}`) + }) + server := httptest.NewServer(mux) + defer server.Close() + + c := newTestClient(server.URL) + posts, err := c.GetLatestNPosts(context.Background(), "u", 10) + if err != nil { + t.Fatalf("error: %v", err) + } + if len(posts) != 1 { + t.Fatalf("expected 1 post (empty-ID tweet skipped), got %d", len(posts)) + } + if posts[0].ID != "2" || posts[0].Content != "good" { + t.Fatalf("unexpected post: %+v", posts[0]) + } +} + +func TestServerErrorRetried(t *testing.T) { + callCount := 0 + mux := http.NewServeMux() + mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) { + callCount++ + if callCount == 1 { + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, `{"title":"Internal Server Error","detail":"transient"}`) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"data":{"id":"1","username":"u"}}`) + }) + mux.HandleFunc("/2/users/1/tweets", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"data":[{"id":"1","text":"ok","created_at":"2024-01-01T00:00:00Z"}]}`) + }) + server := httptest.NewServer(mux) + defer server.Close() + + c := newTestClient(server.URL) + posts, err := c.GetLatestNPosts(context.Background(), "u", 10) + if err != nil { + t.Fatalf("GetLatestNPosts error after 5xx retry: %v", err) + } + if callCount != 2 { + t.Fatalf("expected 2 calls (1 failure + 1 retry), got %d", callCount) + } + if len(posts) != 1 || posts[0].Content != "ok" { + t.Fatalf("unexpected posts after retry: %+v", posts) + } +} diff --git a/internal/utils/styleProfiles.go b/internal/utils/styleProfiles.go index 051d420..cb475a4 100644 --- a/internal/utils/styleProfiles.go +++ b/internal/utils/styleProfiles.go @@ -7,12 +7,26 @@ import ( "strings" ) -func ParseEmbedding(value interface{}) ([]float64, error) { +func parseVectorString(v string) ([]float64, error) { + s := strings.Trim(v, "[]") + parts := strings.Split(s, ",") + vec := make([]float64, len(parts)) + for i, p := range parts { + f, err := strconv.ParseFloat(strings.TrimSpace(p), 64) + if err != nil { + return nil, err + } + vec[i] = f + } + return vec, nil +} + +func ParseEmbedding(value any) ([]float64, error) { switch v := value.(type) { case []float64: return v, nil - case []interface{}: + case []any: vec := make([]float64, len(v)) for i, x := range v { f, ok := x.(float64) @@ -24,35 +38,52 @@ func ParseEmbedding(value interface{}) ([]float64, error) { return vec, nil case string: - // pgvector returns a string like "[0.12, 0.53, ...]" sometimes - s := strings.Trim(v, "[]") - parts := strings.Split(s, ",") - vec := make([]float64, len(parts)) - for i, p := range parts { - f, err := strconv.ParseFloat(strings.TrimSpace(p), 64) - if err != nil { - return nil, err - } - vec[i] = f - } - return vec, nil + return parseVectorString(v) + + case []byte: + return parseVectorString(string(v)) default: return nil, fmt.Errorf("unexpected embedding type: %T", v) } } -func AverageVector(vs [][]float64) []float64 { - dim := len(vs[0]) - avg := make([]float64, dim) +func FormatVector(v []float64) string { + parts := make([]string, len(v)) + for i, x := range v { + parts[i] = strconv.FormatFloat(x, 'f', -1, 64) + } + return "[" + strings.Join(parts, ",") + "]" +} +// AverageVector returns the element-wise mean of vs. Zero-length vectors (e.g. an +// embedding model returning an empty vector for a post without text) are skipped, and +// the accumulator is sized from the first non-empty vector so a leading empty vector +// cannot panic later in the loop. Returns nil when no usable vector is present. +func AverageVector(vs [][]float64) []float64 { + dim := 0 + var valid [][]float64 for _, v := range vs { - for i, val := range v { - avg[i] += val + if len(v) == 0 { + continue + } + if dim == 0 { + dim = len(v) + } + valid = append(valid, v) + } + if len(valid) == 0 { + return nil + } + + avg := make([]float64, dim) + for _, v := range valid { + for i := 0; i < dim && i < len(v); i++ { + avg[i] += v[i] } } for i := range avg { - avg[i] /= float64(len(vs)) + avg[i] /= float64(len(valid)) } return avg } @@ -60,7 +91,7 @@ func AverageVector(vs [][]float64) []float64 { func TopWords(posts []string, stopWords map[string]struct{}, limit int) []string { counts := map[string]int{} for _, p := range posts { - for _, w := range strings.Fields(strings.ToLower(p)) { + for w := range strings.FieldsSeq(strings.ToLower(p)) { if len(w) < 4 { continue } diff --git a/internal/utils/styleProfiles_test.go b/internal/utils/styleProfiles_test.go new file mode 100644 index 0000000..4692b95 --- /dev/null +++ b/internal/utils/styleProfiles_test.go @@ -0,0 +1,47 @@ +package utils + +import ( + "math" + "testing" +) + +func assertVectorsEqual(t *testing.T, got, want []float64) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("length %d, want %d", len(got), len(want)) + } + for i := range want { + if math.Abs(got[i]-want[i]) > 1e-9 { + t.Fatalf("index %d = %v, want %v", i, got, want) + } + } +} + +func TestAverageVector(t *testing.T) { + tests := []struct { + name string + in [][]float64 + want []float64 + }{ + {name: "empty input", in: nil, want: nil}, + {name: "empty slice", in: [][]float64{}, want: nil}, + {name: "all empty vectors", in: [][]float64{{}, {}, {}}, want: nil}, + {name: "single vector", in: [][]float64{{0.4, -0.2}}, want: []float64{0.4, -0.2}}, + {name: "multiple vectors", in: [][]float64{{0.1, 0.2}, {0.3, 0.4}}, want: []float64{0.2, 0.3}}, + {name: "first vector empty", in: [][]float64{{}, {0.1, 0.2}, {0.3, 0.4}}, want: []float64{0.2, 0.3}}, + {name: "trailing empty vectors", in: [][]float64{{0.1, 0.2}, {}, {0.3, 0.4}}, want: []float64{0.2, 0.3}}, + {name: "only first valid", in: [][]float64{{}, {}, {0.1, 0.2}}, want: []float64{0.1, 0.2}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := AverageVector(tt.in) + if tt.want == nil { + if got != nil { + t.Fatalf("AverageVector(%v) = %v, want nil", tt.in, got) + } + return + } + assertVectorsEqual(t, got, tt.want) + }) + } +} diff --git a/internal/x402/mcpwrapper.go b/internal/x402/mcpwrapper.go new file mode 100644 index 0000000..8d69b01 --- /dev/null +++ b/internal/x402/mcpwrapper.go @@ -0,0 +1,156 @@ +package x402 + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" + log "github.com/nacorid/logger" + x402 "github.com/x402-foundation/x402/go/v2" + "github.com/x402-foundation/x402/go/v2/types" +) + +// Payment meta keys exchanged over MCP, mirroring the x402 SDK contract. +const ( + PaymentMetaKey = "x402/payment" + PaymentResponseMetaKey = "x402/payment-response" +) + +// SettleAmountResolver computes the actual settlement amount for a completed +// tool call. It returns ("", false) to settle the amount advertised in the +// payment requirements as-is. +type SettleAmountResolver func(result *mcp.CallToolResult) (string, bool) + +// PaymentWrapperConfig configures a PaymentWrapper. Unlike the SDK's wrapper, +// it accepts a SettleAmountResolver so upto-scheme tools can charge the actual +// computed cost after the handler runs (the same behavior the REST path gets +// via nethttp.SetSettlementOverrides). +type PaymentWrapperConfig struct { + Accepts []x402.PaymentRequirements + Resource *types.ResourceInfo + Extensions map[string]interface{} + // SettleAmountResolver is invoked after a successful handler call. When it + // returns a non-empty amount, that amount is charged instead of the + // advertised maximum (which must therefore be <= the accept amount). + SettleAmountResolver SettleAmountResolver +} + +// PaymentWrapper gates an MCP tool behind x402 payments and settles them. +type PaymentWrapper struct { + server *x402.X402ResourceServer + config PaymentWrapperConfig +} + +// NewPaymentWrapper creates a PaymentWrapper for the given resource server. +func NewPaymentWrapper(server *x402.X402ResourceServer, config PaymentWrapperConfig) *PaymentWrapper { + if len(config.Accepts) == 0 { + panic("PaymentWrapperConfig.Accepts must have at least one payment requirement") + } + return &PaymentWrapper{server: server, config: config} +} + +// Wrap returns a ToolHandler that requires a valid x402 payment before invoking +// the wrapped handler and settles it afterwards. +func (w *PaymentWrapper) Wrap(handler mcp.ToolHandler) mcp.ToolHandler { + return func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + paymentData := extractPaymentFromRequest(request) + if paymentData == nil { + return w.paymentRequiredResult("Payment Required"), nil + } + + payloadBytes, err := json.Marshal(paymentData) + if err != nil { + return w.paymentRequiredResult(fmt.Sprintf("Invalid payment payload: %v", err)), nil + } + var payload types.PaymentPayload + if err := json.Unmarshal(payloadBytes, &payload); err != nil { + return w.paymentRequiredResult(fmt.Sprintf("Invalid payment payload: %v", err)), nil + } + + matched := w.server.FindMatchingRequirements(w.config.Accepts, payload) + if matched == nil { + return w.paymentRequiredResult("No matching payment requirements found"), nil + } + requirements := *matched + + verifyResp, err := w.server.VerifyPayment(ctx, payload, requirements) + if err != nil { + return w.paymentRequiredResult(fmt.Sprintf("Payment verification error: %v", err)), nil + } + if !verifyResp.IsValid { + return w.paymentRequiredResult(fmt.Sprintf("Payment verification failed: %s", verifyResp.InvalidReason)), nil + } + + result, err := handler(ctx, request) + if err != nil { + return nil, err + } + if result.IsError { + return result, nil + } + + var overrides *x402.SettlementOverrides + if w.config.SettleAmountResolver != nil { + if amount, ok := w.config.SettleAmountResolver(result); ok && amount != "" { + overrides = &x402.SettlementOverrides{Amount: amount} + log.Debug("MCP settlement override", "amount", amount) + } + } + + settleResp, err := w.server.SettlePayment(ctx, payload, requirements, overrides) + if err != nil { + return w.settlementFailedResult(fmt.Sprintf("Settlement error: %v", err)), nil + } + if !settleResp.Success { + return w.settlementFailedResult(fmt.Sprintf("Settlement failed: %s", settleResp.ErrorReason)), nil + } + + if result.Meta == nil { + result.Meta = mcp.Meta{} + } + result.Meta[PaymentResponseMetaKey] = settleResp + return result, nil + } +} + +func extractPaymentFromRequest(request *mcp.CallToolRequest) interface{} { + if request == nil || request.Params.Meta == nil { + return nil + } + return request.Params.Meta[PaymentMetaKey] +} + +func (w *PaymentWrapper) paymentRequiredResult(errorMessage string) *mcp.CallToolResult { + var resource *types.ResourceInfo + if w.config.Resource != nil { + resource = &types.ResourceInfo{ + URL: w.config.Resource.URL, + Description: w.config.Resource.Description, + } + } + payment := types.PaymentRequired{ + X402Version: 2, + Error: errorMessage, + Resource: resource, + Accepts: w.config.Accepts, + Extensions: w.config.Extensions, + } + b, err := json.Marshal(payment) + if err != nil { + panic(err) + } + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{&mcp.TextContent{Text: string(b)}}, + } +} + +func (w *PaymentWrapper) settlementFailedResult(errorMessage string) *mcp.CallToolResult { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("{\"x402Version\":2,\"error\":%q}", errorMessage)}, + }, + } +} diff --git a/internal/x402/x402.go b/internal/x402/x402.go index 08e7b8b..28eeb5c 100644 --- a/internal/x402/x402.go +++ b/internal/x402/x402.go @@ -1,15 +1,23 @@ package x402 import ( + "context" + "encoding/base64" "encoding/json" "fmt" "io" "log/slog" + "math" "net" "net/http" "os" + "os/user" "reflect" + "slices" + "strconv" "strings" + "sync" + "time" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -17,8 +25,9 @@ import ( x402 "github.com/x402-foundation/x402/go/v2" x402http "github.com/x402-foundation/x402/go/v2/http" nethttp "github.com/x402-foundation/x402/go/v2/http/nethttp" - x402mcp "github.com/x402-foundation/x402/go/v2/mcp" evm "github.com/x402-foundation/x402/go/v2/mechanisms/evm/exact/server" + uptoevm "github.com/x402-foundation/x402/go/v2/mechanisms/evm/upto/server" + "github.com/x402-foundation/x402/go/v2/types" ) type X402Middlewares struct { @@ -45,11 +54,23 @@ type X402Server struct { middlewareChain func(http.Handler) http.Handler tools []ToolInfo globalReqLog func(http.Handler) http.Handler + networks []string + + resourceInitMu sync.Mutex + resourceInitDone bool + + // settleResolvers maps payable tool name -> SettleAmountResolver used by + // both the MCP wrapper and the REST override path to charge the actual + // computed cost (instead of the advertised upto maximum). + settleResolvers map[string]SettleAmountResolver + + httpServer *http.Server + mu sync.Mutex } const VERSION1 = "v1/" -func NewX402Server(name, version string, facilitatorURL string, auth x402http.AuthProvider, mws X402Middlewares, globalLoggingMw func(http.Handler) http.Handler, settleHook func(http.ResponseWriter, *http.Request, *x402.SettleResponse)) (*X402Server, error) { +func NewX402Server(name, version string, facilitatorURL string, auth x402http.AuthProvider, mws X402Middlewares, globalLoggingMw func(http.Handler) http.Handler, settleHook func(http.ResponseWriter, *http.Request, *x402.SettleResponse), networks []string) (*X402Server, error) { mcpServer := mcp.NewServer( &mcp.Implementation{ Name: name, @@ -62,18 +83,29 @@ func NewX402Server(name, version string, facilitatorURL string, auth x402http.Au } if facilitatorURL == "" { - facilitatorURL = "https://facilitator.mogami.tech" + facilitatorURL = "https://api.cdp.coinbase.com/platform/v2/x402" } facilitator := x402http.NewHTTPFacilitatorClient(&x402http.FacilitatorConfig{ URL: facilitatorURL, AuthProvider: auth, + Timeout: 600 * time.Second, }) resourceServer := x402.Newx402ResourceServer( x402.WithFacilitatorClient(facilitator), ) - resourceServer.Register("eip155:8453", evm.NewExactEvmScheme()) + // Register exact and upto EVM schemes for each network + registered := make(map[string]bool) + for _, network := range networks { + if registered[network] { + continue + } + net := x402.Network(network) + resourceServer.Register(net, evm.NewExactEvmScheme()) + resourceServer.Register(net, uptoevm.NewUptoEvmScheme()) + registered[network] = true + } s := &X402Server{ mcpServer: mcpServer, @@ -86,11 +118,29 @@ func NewX402Server(name, version string, facilitatorURL string, auth x402http.Au middlewareChain: chainHTTPMiddlewares(mws.HTTPMiddleware...), tools: make([]ToolInfo, 0), globalReqLog: globalLoggingMw, + networks: networks, + settleResolvers: make(map[string]SettleAmountResolver), } + // Warm the facilitator cache now so AddPayableTool can build Accepts with + // facilitator extras (e.g. upto facilitatorAddress). Failure is non-fatal; + // ensureFacilitatorReady retries on every request until it succeeds once. + s.ensureFacilitatorReady(context.Background()) + + // Self-healing: retry facilitator initialization on every inbound request + // until it succeeds, so a facilitator that was down at boot requires no + // process restart. Runs before tool dispatch, i.e. before the PaymentWrapper + // performs VerifyPayment/SettlePayment. + mcpServer.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + s.ensureFacilitatorReady(ctx) + return next(ctx, method, req) + } + }) + s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) - w.Write([]byte("ok")) + _, _ = w.Write([]byte("ok")) }) s.mux.HandleFunc("GET /openapi.json", s.serveOpenAPI) @@ -103,6 +153,32 @@ func NewX402Server(name, version string, facilitatorURL string, auth x402http.Au return s, nil } +// facilitatorRetryTimeout bounds each lazy facilitator init attempt so a down +// facilitator can't stall requests indefinitely. +const facilitatorRetryTimeout = 10 * time.Second + +// ensureFacilitatorReady lazily initializes the resource server against the +// facilitator, retrying on every call until one succeeds. After the first +// success the facilitatorClients map persists for the process lifetime, so a +// facilitator outage at boot (or afterwards) no longer requires a restart. +func (s *X402Server) ensureFacilitatorReady(ctx context.Context) { + s.resourceInitMu.Lock() + defer s.resourceInitMu.Unlock() + + if s.resourceInitDone { + return + } + + initCtx, cancel := context.WithTimeout(ctx, facilitatorRetryTimeout) + defer cancel() + if err := s.x402ResourceServer.Initialize(initCtx); err != nil { + log.Warn("x402 facilitator unavailable; will retry on next request", "error", err) + return + } + + s.resourceInitDone = true +} + func (s *X402Server) AddTool(tool mcp.Tool, handler mcp.ToolHandler) { tool.InputSchema = toJSONSchema(tool.InputSchema) tool.OutputSchema = toJSONSchema(tool.OutputSchema) @@ -124,19 +200,45 @@ func (s *X402Server) AddTool(tool mcp.Tool, handler mcp.ToolHandler) { func (s *X402Server) AddPayableTool( tool mcp.Tool, handler mcp.ToolHandler, + resolver SettleAmountResolver, requirements ...x402.PaymentRequirements, ) { if len(requirements) == 0 { - log.Fatalf("tool %s requires at least one payment requirement", tool.Name) + panic(fmt.Sprintf("tool %s requires at least one payment requirement", tool.Name)) + } + + // Convert each raw dollar price into the asset's smallest-unit integer amount using the + // same server-side conversion the REST middleware applies at request time (ParsePrice + + // EnhancePaymentRequirements). The MCP wrapper advertises these requirements directly, + // so without conversion clients fail with invalid_exact_evm_client_amount. + var mcpAccepts []x402.PaymentRequirements + for _, req := range requirements { + built, err := s.x402ResourceServer.BuildPaymentRequirementsFromConfig(context.Background(), x402.ResourceConfig{ + Scheme: req.Scheme, + PayTo: req.PayTo, + Price: req.Amount, + Network: x402.Network(req.Network), + MaxTimeoutSeconds: req.MaxTimeoutSeconds, + Extra: req.Extra, + }) + if err != nil { + panic(fmt.Sprintf("tool %s failed to build MCP payment requirements for scheme %s: %v", tool.Name, req.Scheme, err)) + } + mcpAccepts = append(mcpAccepts, built...) } tool.InputSchema = toJSONSchema(tool.InputSchema) tool.OutputSchema = toJSONSchema(tool.OutputSchema) - wrapper := x402mcp.NewPaymentWrapper(s.x402ResourceServer, x402mcp.PaymentWrapperConfig{ - Accepts: requirements, - Resource: &x402mcp.ResourceInfo{ + if resolver != nil { + s.settleResolvers[tool.Name] = resolver + } + + wrapper := NewPaymentWrapper(s.x402ResourceServer, PaymentWrapperConfig{ + Accepts: mcpAccepts, + SettleAmountResolver: resolver, + Resource: &types.ResourceInfo{ URL: "mcp://tool/" + tool.Name, Description: tool.Description, }, @@ -154,35 +256,136 @@ func (s *X402Server) AddPayableTool( var options x402http.PaymentOptions for _, req := range requirements { options = append(options, x402http.PaymentOption{ - Scheme: req.Scheme, - PayTo: req.PayTo, - Price: req.Amount, - Network: x402.Network(req.Network), + Scheme: req.Scheme, + PayTo: req.PayTo, + Price: req.Amount, + Network: x402.Network(req.Network), + MaxTimeoutSeconds: req.MaxTimeoutSeconds, + Extra: req.Extra, }) } - paymentMiddleware := nethttp.X402Payment(nethttp.Config{ + schemes := buildSchemeConfigs(s.networks, requirements...) + + settlementHandler := func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) { + requirements, _ := nethttp.RequirementsFromContext(r.Context()) + log.InfoWithContext(r.Context(), "HTTP settlement succeeded", + "tool", tool.Name, + "network", string(resp.Network), + "transaction", resp.Transaction, + "payer", resp.Payer, + "payTo", requirements.PayTo, + "amount", requirements.Amount, + "asset", requirements.Asset, + "scheme", requirements.Scheme, + "override_amount", r.Header.Get("Settlement-Overrides"), + ) + if s.httpSettlementHook != nil { + s.httpSettlementHook(w, r, resp) + } + } + + basePaymentMiddleware := nethttp.X402Payment(nethttp.Config{ Routes: x402http.RoutesConfig{ "POST /" + VERSION1 + tool.Name: { Accepts: options, Description: tool.Description, }, }, - Facilitator: s.facilitatorClient, - Schemes: []nethttp.SchemeConfig{ - {Network: "eip155:8453", Server: evm.NewExactEvmScheme()}, + Facilitator: s.facilitatorClient, + Schemes: schemes, + SettlementHandler: settlementHandler, + Timeout: 600 * time.Second, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + log.ErrorWithContext(r.Context(), "HTTP settlement failed", + "tool", tool.Name, + "method", r.Method, + "path", r.URL.Path, + "paymentSignature", r.Header.Get("PAYMENT-SIGNATURE"), + "paymentRequired", w.Header().Get("PAYMENT-REQUIRED"), + "settlement_overrides", r.Header.Get("Settlement-Overrides"), + "error", err, + ) + http.Error(w, fmt.Sprintf("settlement failed: %v", err), http.StatusPaymentRequired) }, - SettlementHandler: s.httpSettlementHook, }) + paymentMiddleware := func(next http.Handler) http.Handler { + return basePaymentMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capture := &verifyErrorCapture{ + ResponseWriter: w, + req: r, + toolName: tool.Name, + } + next.ServeHTTP(capture, r) + + if capture.statusCode == http.StatusPaymentRequired { + prHeader := capture.Header().Get("PAYMENT-REQUIRED") + if prHeader != "" { + if decoded, err := decodePaymentRequiredHeader(prHeader); err == nil { + for i, acc := range decoded.Accepts { + log.ErrorWithContext(r.Context(), "HTTP payment verify failed - new challenge issued", + "tool", tool.Name, + "acceptIndex", i, + "scheme", acc.Scheme, + "network", acc.Network, + "asset", acc.Asset, + "amount", acc.Amount, + "payTo", acc.PayTo, + "resource", decoded.Resource, + "acceptsCount", len(decoded.Accepts), + ) + } + } else { + log.ErrorWithContext(r.Context(), "HTTP payment verify failed - could not decode PAYMENT-REQUIRED", + "tool", tool.Name, + "rawHeader", prHeader, + "decodeError", err, + ) + } + } + // Debug: log the payment signature sent by client + log.DebugWithContext(r.Context(), "Payment retry debug", + "tool", tool.Name, + "paymentSignature", r.Header.Get("PAYMENT-SIGNATURE"), + "settlementOverrides", w.Header().Get("Settlement-Overrides"), + ) + } + })) + } + restHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s.handleRESTCall(w, r, tool.Name, handler) }) s.mux.Handle("POST /"+VERSION1+tool.Name, s.middlewareChain(paymentMiddleware(restHandler))) } +// buildSchemeConfigs maps the schemes actually declared in a tool's payment +// requirements to the EVM scheme servers a route needs to verify and settle +// them, for every configured network. Only the schemes present in the +// requirements are registered: an exact-only tool does not expose an upto +// scheme, and vice versa. This keeps upto (variable-pricing) authorization from +// being offered for tools that cannot vary their price. +func buildSchemeConfigs(networks []string, requirements ...x402.PaymentRequirements) []nethttp.SchemeConfig { + present := make(map[string]struct{}) + for _, req := range requirements { + present[req.Scheme] = struct{}{} + } + schemes := make([]nethttp.SchemeConfig, 0, len(networks)*len(present)) + for _, net := range networks { + if _, ok := present["exact"]; ok { + schemes = append(schemes, nethttp.SchemeConfig{Network: x402.Network(net), Server: evm.NewExactEvmScheme()}) + } + if _, ok := present["upto"]; ok { + schemes = append(schemes, nethttp.SchemeConfig{Network: x402.Network(net), Server: uptoevm.NewUptoEvmScheme()}) + } + } + return schemes +} + func (s *X402Server) handleRESTCall(w http.ResponseWriter, r *http.Request, toolName string, handler mcp.ToolHandler) { - bodyBytes, err := io.ReadAll(r.Body) + defer r.Body.Close() + bodyBytes, err := io.ReadAll(io.LimitReader(r.Body, 5*1024*1024)) if err != nil { http.Error(w, "Failed to read body", http.StatusBadRequest) return @@ -204,19 +407,31 @@ func (s *X402Server) handleRESTCall(w http.ResponseWriter, r *http.Request, tool } w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) if len(resp.Content) == 1 { if textContent, ok := resp.Content[0].(*mcp.TextContent); ok { var js json.RawMessage if json.Unmarshal([]byte(textContent.Text), &js) == nil { - w.Write([]byte(textContent.Text)) + if resolver := s.settleResolvers[toolName]; resolver != nil { + if actualCost, ok := resolver(&mcp.CallToolResult{Content: []mcp.Content{textContent}}); ok && actualCost != "" { + log.DebugWithContext(r.Context(), "Setting settlement override", + "tool", toolName, + "actualCost", actualCost, + ) + nethttp.SetSettlementOverrides(w, &x402.SettlementOverrides{ + Amount: actualCost, + }) + } + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(textContent.Text)) return } } } - json.NewEncoder(w).Encode(resp) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(resp) } func (s *X402Server) OnAfterSettle(hook func(ctx x402.SettleResultContext) error) { @@ -307,6 +522,23 @@ func (s *X402Server) Start(addr string, permissions os.FileMode) error { return fmt.Errorf("failed to set socket permissions: %w", err) } + grp, err := user.LookupGroup("mcpserver") + if err != nil { + return fmt.Errorf("failed to lookup group mcpserver: %w", err) + } + gid, err := strconv.Atoi(grp.Gid) + if err != nil { + return fmt.Errorf("invalid gid %q: %w", grp.Gid, err) + } + + if err := os.Chown(socketPath, -1, gid); err != nil { + return fmt.Errorf("failed to set socket group ownership: %w", err) + } + + if err := os.Chmod(socketPath, permissions); err != nil { + return fmt.Errorf("failed to set socket permissions: %w", err) + } + log.Info("Starting X402 MCP Server on unix socket", "socketPath", socketPath) } else { log.Info("Starting X402 MCP Server on TCP", "addr", addr) @@ -316,15 +548,33 @@ func (s *X402Server) Start(addr string, permissions os.FileMode) error { return fmt.Errorf("failed to listen on tcp: %w", err) } } - defer listener.Close() srv := &http.Server{ - Handler: s.globalReqLog(s.mux), - ErrorLog: slog.NewLogLogger(slog.Default().Handler(), slog.LevelDebug), + Handler: s.globalReqLog(s.mux), + ErrorLog: slog.NewLogLogger(slog.Default().Handler(), slog.LevelDebug), + ReadTimeout: 15 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 120 * time.Second, } + + s.mu.Lock() + s.httpServer = srv + s.mu.Unlock() + return srv.Serve(listener) } +func (s *X402Server) Shutdown(ctx context.Context) error { + s.mu.Lock() + srv := s.httpServer + s.mu.Unlock() + + if srv == nil { + return nil + } + return srv.Shutdown(ctx) +} + func toJSONSchema(val any) json.RawMessage { if val == nil { return json.RawMessage(`{"type": "object"}`) @@ -385,9 +635,203 @@ func parseSchema(schemaAny any) any { func chainHTTPMiddlewares(middlewares ...func(http.Handler) http.Handler) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { - for i := len(middlewares) - 1; i >= 0; i-- { - next = middlewares[i](next) + for _, middleware := range slices.Backward(middlewares) { + next = middleware(next) } return next } } + +// Twitter API costs are itemized below. Every getStyleProfile request, for any +// platform, also carries the flat base cost of the endpoint (the same price a +// non-Twitter platform pays). Adding that base cost on top of the itemized +// Twitter API costs guarantees the call always clears the underlying API cost +// and generates a profit - it can never break even or lose money. +const ( + // BaseProfileCostMicro is the base cost of the style-profile endpoint for + // the non-Twitter path. It doubles as the profit margin added to Twitter + // requests, expressed in micro-dollars (USDC has 6 decimals). + BaseProfileCostMicro int64 = 250_000 // $0.25 + // TwitterUserLookupMicro is the per-request Twitter user lookup API cost. + TwitterUserLookupMicro int64 = 10_000 // $0.01 + // TwitterPostReadMicro is the per-post Twitter read API cost. + TwitterPostReadMicro int64 = 5_000 // $0.005 per post + // MaxProfileCountedPosts caps the post count a style profile can ever + // report. It is NOT applied inside computeTwitterStyleProfileCost (charges + // follow the posts actually read, and the server fetches at most this + // many); it only bounds the largest settlement the endpoint can demand. + MaxProfileCountedPosts int64 = 100 + // MaxAnalyticsCountedPosts caps the number of posts an analytics tool can + // include from any single platform (mirrors maxAnalyticsLimit in the server + // package). It bounds the largest settlement an upto analytics request can + // demand. + MaxAnalyticsCountedPosts int64 = 200 +) + +// maxProfileCostMicro is the largest price getStyleProfile can ever command: +// the flat base cost plus the Twitter lookup and per-post costs at the +// post-count cap. The price advertised for getStyleProfile in tools.go must +// equal formatDollarAmount(maxProfileCostMicro) ("$0.76"). +const maxProfileCostMicro = BaseProfileCostMicro + TwitterUserLookupMicro + MaxProfileCountedPosts*TwitterPostReadMicro + +// computeTwitterStyleProfileCost calculates the actual charge for a Twitter +// getStyleProfile request based on the number of posts fetched and the user +// lookup, and returns it as a dollar price string (e.g. "$0.31") suitable for a +// x402 settlement override. +// +// Pricing: guaranteed base cost of $0.25 (the endpoint cost for other +// platforms), plus user lookup $0.01, plus $0.005 per post read. For example, +// 10 posts = $0.31. Non-Twitter platforms are charged the flat $0.25 base. +// +// The price always reflects the posts actually read - it is never clamped to a +// cap (the server only ever fetches at most maxProfileCountedPosts, so the +// derived max always stays at or below formatDollarAmount(maxProfileCostMicro)). +// A PostCount of 0 means no posts were read (server-side defaults are applied +// before the profile is built, so responses carry the real count). +func computeTwitterStyleProfileCost(_ *http.Request, responseText string) string { + // Parse the response to get PostCount and SourcePlatform + var resp GetStyleProfileResponse + if err := json.Unmarshal([]byte(responseText), &resp); err != nil { + return formatDollarAmount(BaseProfileCostMicro) // fallback: base cost + } + + costMicro := BaseProfileCostMicro + + // Twitter additionally passes on the user lookup and per-post API costs. + if resp.SourcePlatform == "Twitter" { + postCount := max(resp.PostCount, 0) + costMicro += TwitterUserLookupMicro + costMicro += int64(postCount) * TwitterPostReadMicro + } + + return formatDollarAmount(costMicro) +} + +// formatDollarAmount renders an amount in micro-dollars as a "$"-prefixed +// price string with at least two decimal places and trailing zeroes trimmed +// beyond that (e.g. "$0.16", "$1.50", "$0.125"), matching the format the x402 +// settlement override resolver expects. +func formatDollarAmount(micro int64) string { + dollars := float64(micro) / 1_000_000 + s := strconv.FormatFloat(dollars, 'f', 6, 64) + s = strings.TrimRight(s, "0") + if dot := strings.IndexByte(s, '.'); dot >= 0 { + if decimals := len(s) - dot - 1; decimals < 2 { + s += strings.Repeat("0", 2-decimals) + } + } else { + s += ".00" + } + return "$" + s +} + +// FormatPriceMicro renders an amount in micro-dollars as a plain, un-prefixed +// numeric price string ("0.76") as expected by x402 PaymentRequirements.Amount. +func FormatPriceMicro(micro int64) string { + return strings.TrimPrefix(formatDollarAmount(micro), "$") +} + +// MicroFromPriceString parses a plain dollar price string ("0.76") into +// micro-dollars so pricing arithmetic can be done without float rounding. +func MicroFromPriceString(price string) int64 { + micro, err := strconv.ParseFloat(price, 64) + if err != nil { + return 0 + } + return int64(math.Round(micro * 1_000_000)) +} + +// MaxTwitterCostMicro is the largest cost a Twitter-capable tool can command +// when it reaches its post-count cap: the flat base cost plus the Twitter +// lookup and per-post costs at the cap. It is used to advertise the upto +// maximum for tools whose actual cost is settled from the response. +func MaxTwitterCostMicro(baseMicro, postCap int64) int64 { + return baseMicro + TwitterUserLookupMicro + postCap*TwitterPostReadMicro +} + +// NewStyleProfileCostResolver returns a SettleAmountResolver that bills a +// getStyleProfile request by the posts actually read (see +// computeTwitterStyleProfileCost), rather than the advertised upto maximum. +func NewStyleProfileCostResolver() SettleAmountResolver { + return func(result *mcp.CallToolResult) (string, bool) { + if len(result.Content) != 1 { + return "", false + } + textContent, ok := result.Content[0].(*mcp.TextContent) + if !ok { + return "", false + } + return computeTwitterStyleProfileCost(nil, textContent.Text), true + } +} + +// NewTwitterCountCostResolver returns a SettleAmountResolver for tools whose +// response carries a `twitterPostCount` JSON field. It settles the flat +// baseCostMicro, plus the Twitter lookup and per-post costs when Twitter posts +// were actually used (twitterPostCount > 0). When no Twitter posts went into +// the response the caller is charged only the base cost. +func NewTwitterCountCostResolver(baseCostMicro int64) SettleAmountResolver { + return func(result *mcp.CallToolResult) (string, bool) { + if len(result.Content) != 1 { + return "", false + } + textContent, ok := result.Content[0].(*mcp.TextContent) + if !ok { + return "", false + } + var payload struct { + TwitterPostCount int `json:"twitterPostCount"` + } + if json.Unmarshal([]byte(textContent.Text), &payload) != nil { + return "", false + } + costMicro := baseCostMicro + if payload.TwitterPostCount > 0 { + costMicro += TwitterUserLookupMicro + int64(payload.TwitterPostCount)*TwitterPostReadMicro + } + return formatDollarAmount(costMicro), true + } +} + +// GetStyleProfileResponse is a local copy of the response schema for cost calculation +type GetStyleProfileResponse struct { + Author string `json:"author"` + PersonaEmbedding []float64 `json:"personaEmbedding"` + TopKeywords []string `json:"topKeywords"` + StyleSummary string `json:"styleSummary"` + PostCount int `json:"postCount"` + SourcePlatform string `json:"sourcePlatform"` +} + +type verifyErrorCapture struct { + http.ResponseWriter + req *http.Request + toolName string + statusCode int +} + +func (c *verifyErrorCapture) WriteHeader(code int) { + c.statusCode = code + c.ResponseWriter.WriteHeader(code) +} + +func (c *verifyErrorCapture) Write(b []byte) (int, error) { + if c.statusCode == 0 { + c.statusCode = http.StatusOK + } + return c.ResponseWriter.Write(b) +} + +func decodePaymentRequiredHeader(header string) (x402.PaymentRequired, error) { + data, err := base64.StdEncoding.DecodeString(header) + if err != nil { + return x402.PaymentRequired{}, fmt.Errorf("invalid base64 encoding: %w", err) + } + + var required x402.PaymentRequired + if err := json.Unmarshal(data, &required); err != nil { + return x402.PaymentRequired{}, fmt.Errorf("invalid payment required JSON: %w", err) + } + + return required, nil +} diff --git a/internal/x402/x402_test.go b/internal/x402/x402_test.go index 19f8036..f26e9ca 100644 --- a/internal/x402/x402_test.go +++ b/internal/x402/x402_test.go @@ -3,14 +3,19 @@ package x402 import ( "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" + "strconv" "strings" + "sync" + "sync/atomic" "testing" "github.com/modelcontextprotocol/go-sdk/mcp" x402 "github.com/x402-foundation/x402/go/v2" x402http "github.com/x402-foundation/x402/go/v2/http" + "github.com/x402-foundation/x402/go/v2/types" ) type DummyAuthProvider struct{} @@ -31,6 +36,7 @@ func TestX402Server_HTTPBoundaries(t *testing.T) { X402Middlewares{}, func(next http.Handler) http.Handler { return next }, func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {}, + []string{"eip155:8453"}, ) if err != nil { t.Fatalf("failed to create server: %v", err) @@ -46,6 +52,7 @@ func TestX402Server_HTTPBoundaries(t *testing.T) { Content: []mcp.Content{&mcp.TextContent{Text: `{"status": "success"}`}}, }, nil }, + nil, x402.PaymentRequirements{ Scheme: "exact", Network: "eip155:8453", @@ -102,6 +109,7 @@ func TestX402Server_MCPBoundaries(t *testing.T) { X402Middlewares{}, func(next http.Handler) http.Handler { return next }, func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {}, + []string{"eip155:8453"}, ) if err != nil { t.Fatalf("failed to create server: %v", err) @@ -117,6 +125,7 @@ func TestX402Server_MCPBoundaries(t *testing.T) { Content: []mcp.Content{&mcp.TextContent{Text: `{"status": "success"}`}}, }, nil }, + nil, x402.PaymentRequirements{ Scheme: "exact", Network: "eip155:8453", @@ -130,14 +139,14 @@ func TestX402Server_MCPBoundaries(t *testing.T) { if err != nil { t.Fatalf("failed to connect server session: %v", err) } - defer serverSession.Close() + defer func() { _ = serverSession.Close() }() client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "1.0.0"}, nil) clientSession, err := client.Connect(ctx, clientTransport, nil) if err != nil { t.Fatalf("failed to connect client session: %v", err) } - defer clientSession.Close() + defer func() { _ = clientSession.Close() }() toolsResponse, err := clientSession.ListTools(ctx, nil) if err != nil { @@ -173,10 +182,126 @@ func TestX402Server_MCPBoundaries(t *testing.T) { } } -func setupMockFacilitator() *httptest.Server { - s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") +func TestX402Server_MCPChallengesAdvertiseConvertedAmounts(t *testing.T) { + ctx := context.Background() + serverTransport, clientTransport := mcp.NewInMemoryTransports() + + mockFacilitator := setupMockFacilitator() + defer mockFacilitator.Close() + + srv, err := NewX402Server( + "Test Agent API", + "1.0.0", + mockFacilitator.URL, + &DummyAuthProvider{}, + X402Middlewares{}, + func(next http.Handler) http.Handler { return next }, + func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {}, + []string{"eip155:8453", "eip155:84532"}, + ) + if err != nil { + t.Fatalf("failed to create server: %v", err) + } + + srv.AddPayableTool( + mcp.Tool{Name: "getExact", Description: "exact gated tool"}, + func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: `{}`}}}, nil + }, + nil, + x402.PaymentRequirements{ + Scheme: "exact", + Network: "eip155:8453", + Amount: "0.01", + PayTo: "0x0000000000000000000000000000000000000000", + }, + ) + srv.AddPayableTool( + mcp.Tool{Name: "getUpto", Description: "upto gated tool"}, + func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: `{}`}}}, nil + }, + nil, + x402.PaymentRequirements{ + Scheme: "upto", + Network: "eip155:84532", + Amount: "0.51", + PayTo: "0x0000000000000000000000000000000000000000", + }, + ) + + serverSession, err := srv.mcpServer.Connect(ctx, serverTransport, nil) + if err != nil { + t.Fatalf("failed to connect server session: %v", err) + } + defer func() { _ = serverSession.Close() }() + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "1.0.0"}, nil) + clientSession, err := client.Connect(ctx, clientTransport, nil) + if err != nil { + t.Fatalf("failed to connect client session: %v", err) + } + defer func() { _ = clientSession.Close() }() + + cases := []struct { + tool string + scheme string + want string + }{ + {tool: "getExact", scheme: "exact", want: "10000"}, + {tool: "getUpto", scheme: "upto", want: "510000"}, + } + + for _, tc := range cases { + t.Run(tc.tool, func(t *testing.T) { + result, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: tc.tool, Arguments: json.RawMessage(`{}`)}) + if err != nil { + t.Fatalf("protocol-level error: %v", err) + } + if !result.IsError || len(result.Content) == 0 { + t.Fatalf("expected a payment challenge for %s, got %+v", tc.tool, result) + } + + text, ok := result.Content[0].(*mcp.TextContent) + if !ok { + t.Fatalf("unexpected content type %T", result.Content[0]) + } + + var pr types.PaymentRequired + if err := json.Unmarshal([]byte(text.Text), &pr); err != nil { + t.Fatalf("failed to decode payment challenge: %v", err) + } + if len(pr.Accepts) == 0 { + t.Fatalf("expected at least one accept in challenge, got %d", len(pr.Accepts)) + } + + if got := pr.Accepts[0].Amount; got != tc.want { + t.Errorf("accept amount = %q, want %q (converted to smallest units)", got, tc.want) + } + if got := pr.Accepts[0].Scheme; got != tc.scheme { + t.Errorf("accept scheme = %q, want %q", got, tc.scheme) + } + }) + } +} + +// toggleFacilitatorServer is a mock facilitator whose availability can be +// flipped at runtime to simulate an outage and recovery without a restart. +type toggleFacilitatorServer struct { + available atomic.Bool + srv *httptest.Server +} + +func newToggleFacilitatorServer(t *testing.T) *toggleFacilitatorServer { + f := &toggleFacilitatorServer{} + f.available.Store(true) + f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !f.available.Load() { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") switch { case r.Method == "GET" && r.URL.Path == "/supported": _, _ = w.Write([]byte(`{ @@ -204,5 +329,700 @@ func setupMockFacilitator() *httptest.Server { w.WriteHeader(http.StatusNotFound) } })) + t.Cleanup(f.srv.Close) + return f +} + +func TestX402Server_MCPVerifyResolvesFacilitator(t *testing.T) { + ctx := context.Background() + + f := newToggleFacilitatorServer(t) + + srv, err := NewX402Server( + "Test Agent API", + "1.0.0", + f.srv.URL, + &DummyAuthProvider{}, + X402Middlewares{}, + func(next http.Handler) http.Handler { return next }, + func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {}, + []string{"eip155:8453"}, + ) + if err != nil { + t.Fatalf("failed to create server: %v", err) + } + + if !srv.x402ResourceServer.HasFacilitatorSupport("eip155:8453", "exact") { + t.Fatal("expected a facilitator client resolved for eip155:8453/exact after construction") + } + + req := x402.PaymentRequirements{ + Scheme: "exact", + Network: "eip155:8453", + Amount: "10000", + Asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + PayTo: "0x0000000000000000000000000000000000000000", + } + payload := types.PaymentPayload{ + X402Version: 2, + Payload: map[string]interface{}{"chainId": 1}, + Accepted: req, + } + + resp, err := srv.x402ResourceServer.VerifyPayment(ctx, payload, req) + if err != nil { + t.Fatalf("VerifyPayment should succeed once a facilitator is registered, got: %v", err) + } + if resp == nil || !resp.IsValid { + t.Errorf("expected VerifyPayment to return isValid=true, got %+v", resp) + } +} + +func TestX402Server_MCPVerifySelfHeals(t *testing.T) { + ctx := context.Background() + + f := newToggleFacilitatorServer(t) + f.available.Store(false) // facilitator down at boot + + srv, err := NewX402Server( + "Test Agent API", + "1.0.0", + f.srv.URL, + &DummyAuthProvider{}, + X402Middlewares{}, + func(next http.Handler) http.Handler { return next }, + func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {}, + []string{"eip155:8453"}, + ) + if err != nil { + t.Fatalf("failed to create server: %v", err) + } + + if srv.x402ResourceServer.HasFacilitatorSupport("eip155:8453", "exact") { + t.Fatal("expected no facilitator client while the facilitator is down") + } + + req := x402.PaymentRequirements{ + Scheme: "exact", + Network: "eip155:8453", + Amount: "10000", + Asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + PayTo: "0x0000000000000000000000000000000000000000", + } + payload := types.PaymentPayload{ + X402Version: 2, + Payload: map[string]interface{}{"chainId": 1}, + Accepted: req, + } + + _, verifyErr := srv.x402ResourceServer.VerifyPayment(ctx, payload, req) + if verifyErr == nil || !strings.Contains(verifyErr.Error(), x402.ErrNoFacilitatorForNetwork) { + t.Fatalf("expected ErrNoFacilitatorForNetwork while facilitator is down, got: %v", verifyErr) + } + + // Facilitator comes back; no restart required. + f.available.Store(true) + srv.ensureFacilitatorReady(ctx) + + if !srv.x402ResourceServer.HasFacilitatorSupport("eip155:8453", "exact") { + t.Fatal("expected facilitator client to be resolved after recovery") + } + + resp, err := srv.x402ResourceServer.VerifyPayment(ctx, payload, req) + if err != nil { + t.Fatalf("VerifyPayment should succeed after recovery, got: %v", err) + } + if resp == nil || !resp.IsValid { + t.Errorf("expected VerifyPayment to return isValid=true after recovery, got %+v", resp) + } +} + +func TestComputeTwitterStyleProfileCost(t *testing.T) { + cases := []struct { + name string + platform string + postCount int + want string + }{ + {name: "non-twitter flat base", platform: "Bluesky", postCount: 10, want: "$0.25"}, + {name: "twitter example from spec", platform: "Twitter", postCount: 10, want: "$0.31"}, + {name: "twitter zero posts", platform: "Twitter", postCount: 0, want: "$0.26"}, + {name: "twitter max posts", platform: "Twitter", postCount: 50, want: "$0.51"}, + {name: "twitter odd post count half cents", platform: "Twitter", postCount: 3, want: "$0.275"}, + {name: "posts at the max counted cap", platform: "Twitter", postCount: 100, want: "$0.76"}, + {name: "beyond cap is not clamped", platform: "Twitter", postCount: 200, want: "$1.26"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resp := GetStyleProfileResponse{SourcePlatform: tc.platform, PostCount: tc.postCount} + payload, _ := json.Marshal(resp) + r, _ := http.NewRequest("POST", "/v1/getStyleProfile", nil) + + got := computeTwitterStyleProfileCost(r, string(payload)) + if got != tc.want { + t.Fatalf("computeTwitterStyleProfileCost(%q, %d) = %q, want %q", tc.platform, tc.postCount, got, tc.want) + } + }) + } +} + +// Verify that every Twitter charge strictly exceeds the flat base cost, so the +// endpoint always clears the underlying API cost (never break-even or loss). +func TestComputeTwitterStyleProfileCostAlwaysProfitable(t *testing.T) { + for postCount := 0; postCount <= 50; postCount++ { + resp := GetStyleProfileResponse{SourcePlatform: "Twitter", PostCount: postCount} + payload, _ := json.Marshal(resp) + r, _ := http.NewRequest("POST", "/v1/getStyleProfile", nil) + + got := computeTwitterStyleProfileCost(r, string(payload)) + // Cut the "$" prefix and compare numerically to BaseProfileCostMicro. + dollars := strings.TrimPrefix(got, "$") + parsed, err := strconv.ParseFloat(dollars, 64) + if err != nil { + t.Fatalf("failed to parse %q: %v", got, err) + } + micro := int64(parsed * 1_000_000) + if micro <= BaseProfileCostMicro { + t.Fatalf("postCount=%d: charge %q (%d micro) must be strictly greater than base %d", postCount, got, micro, BaseProfileCostMicro) + } + } +} + +func setupMockFacilitator() *httptest.Server { + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch { + case r.Method == "GET" && r.URL.Path == "/supported": + _, _ = w.Write([]byte(`{ + "kinds": [ + { + "x402Version": 2, + "scheme": "exact", + "network": "eip155:8453" + }, + { + "x402Version": 2, + "scheme": "upto", + "network": "eip155:84532" + } + ] + }`)) + case r.Method == "POST" && r.URL.Path == "/verify": + _, _ = w.Write([]byte(`{ + "isValid": true, + "invalidReason": "" + }`)) + case r.Method == "POST" && r.URL.Path == "/settle": + _, _ = w.Write([]byte(`{ + "success": true, + "transaction": "0x1234abcd", + "network": "eip155:8453", + "payer": "0xabcd1234" + }`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) return s } + +func TestAddTool_NonPayable(t *testing.T) { + mockFacilitator := setupMockFacilitator() + defer mockFacilitator.Close() + + srv, err := NewX402Server( + "Test Agent API", + "1.0.0", + mockFacilitator.URL, + &DummyAuthProvider{}, + X402Middlewares{}, + func(next http.Handler) http.Handler { return next }, + func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {}, + []string{"eip155:8453"}, + ) + if err != nil { + t.Fatalf("failed to create server: %v", err) + } + + srv.AddTool( + mcp.Tool{ + Name: "freeTool", + Description: "Free test endpoint", + }, + func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: `{"status": "free"}`}}, + }, nil + }, + ) + + // Test HTTP endpoint works without payment + reqRec := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/v1/freeTool", strings.NewReader(`{}`)) + srv.mux.ServeHTTP(reqRec, req) + if reqRec.Code != http.StatusOK { + t.Errorf("expected status 200 for free tool, got %d", reqRec.Code) + } + + // Test OpenAPI includes non-payable tool + reqRecOpenAPI := httptest.NewRecorder() + reqOpenAPI, _ := http.NewRequest("GET", "/openapi.json", nil) + srv.mux.ServeHTTP(reqRecOpenAPI, reqOpenAPI) + if !strings.Contains(reqRecOpenAPI.Body.String(), "freeTool") { + t.Error("OpenAPI spec did not render non-payable tool") + } + // Should not have 402 response for free tool + if strings.Contains(reqRecOpenAPI.Body.String(), `"402"`) { + t.Error("OpenAPI spec should not have 402 for free tool") + } +} + +func TestAddPayableTool_EmptyRequirements(t *testing.T) { + mockFacilitator := setupMockFacilitator() + defer mockFacilitator.Close() + + srv, err := NewX402Server( + "Test Agent API", + "1.0.0", + mockFacilitator.URL, + &DummyAuthProvider{}, + X402Middlewares{}, + func(next http.Handler) http.Handler { return next }, + func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {}, + []string{"eip155:8453"}, + ) + if err != nil { + t.Fatalf("failed to create server: %v", err) + } + + // The library validates requirements at AddPayableTool call time + // and returns an error (or panics via log.Fatal) for empty requirements + defer func() { + if r := recover(); r != nil { + errMsg := fmt.Sprintf("%v", r) + // The library may panic with log.Fatal or return an error + if !strings.Contains(errMsg, "requires at least one payment requirement") && + !strings.Contains(errMsg, "Accepts must have at least one") { + t.Errorf("expected panic about empty requirements, got: %v", r) + } + } else { + t.Error("expected panic for empty payment requirements") + } + }() + + srv.AddPayableTool( + mcp.Tool{ + Name: "badTool", + Description: "Bad endpoint", + }, + func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: `{"status": "success"}`}}, + }, nil + }, + nil, + // No requirements provided + ) +} + +func TestX402Server_MultipleTools(t *testing.T) { + mockFacilitator := setupMockFacilitator() + defer mockFacilitator.Close() + + srv, err := NewX402Server( + "Test Agent API", + "1.0.0", + mockFacilitator.URL, + &DummyAuthProvider{}, + X402Middlewares{}, + func(next http.Handler) http.Handler { return next }, + func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {}, + []string{"eip155:8453"}, + ) + if err != nil { + t.Fatalf("failed to create server: %v", err) + } + + // Add multiple tools + toolNames := []string{"tool1", "tool2", "tool3"} + for _, name := range toolNames { + name := name + srv.AddPayableTool( + mcp.Tool{ + Name: name, + Description: "Tool " + name, + }, + func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: `{"tool": "` + name + `"}`}}, + }, nil + }, + nil, + x402.PaymentRequirements{ + Scheme: "exact", + Network: "eip155:8453", + Asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + Amount: "1000", + PayTo: "0x0000000000000000000000000000000000000000", + }, + ) + } + + // Verify all tools are registered in OpenAPI + reqRec := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/openapi.json", nil) + srv.mux.ServeHTTP(reqRec, req) + + body := reqRec.Body.String() + for _, name := range toolNames { + if !strings.Contains(body, name) { + t.Errorf("OpenAPI missing tool: %s", name) + } + } + + // Verify each tool requires payment + for _, name := range toolNames { + reqRec := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/v1/"+name, strings.NewReader(`{}`)) + srv.mux.ServeHTTP(reqRec, req) + if reqRec.Code != http.StatusPaymentRequired { + t.Errorf("tool %s: expected status 402, got %d", name, reqRec.Code) + } + } +} + +func TestComputeTwitterStyleProfileCost_EdgeCases(t *testing.T) { + cases := []struct { + name string + platform string + postCount int + want string + }{ + {name: "case insensitive twitter", platform: "TWITTER", postCount: 10, want: "$0.25"}, // not "Twitter" so flat base + {name: "empty platform", platform: "", postCount: 10, want: "$0.25"}, + {name: "negative post count", platform: "Twitter", postCount: -5, want: "$0.26"}, // should clamp to 0 + {name: "few posts", platform: "Twitter", postCount: 1, want: "$0.265"}, + {name: "very large post count", platform: "Twitter", postCount: 1000, want: "$5.26"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resp := GetStyleProfileResponse{SourcePlatform: tc.platform, PostCount: tc.postCount} + payload, _ := json.Marshal(resp) + r, _ := http.NewRequest("POST", "/v1/getStyleProfile", nil) + + got := computeTwitterStyleProfileCost(r, string(payload)) + if got != tc.want { + t.Fatalf("computeTwitterStyleProfileCost(%q, %d) = %q, want %q", tc.platform, tc.postCount, got, tc.want) + } + }) + } +} + +// TestStyleProfileMaxPriceStaysAdvertised guards the advertised getStyleProfile +// price in internal/server/tools.go against silent drift from the x402 pricing +// model. internal/server shares the x402 constants (BaseProfileCostMicro / +// MaxProfileCountedPosts) to compute the upto maximum, so this only needs to +// catch a hand-edited Price string going out of sync. +func TestStyleProfileMaxPriceStaysAdvertised(t *testing.T) { + if got := formatDollarAmount(maxProfileCostMicro); got != "$0.76" { + t.Fatalf("maxProfileCostMicro = %d (%s), want $0.76; update getStyleProfile base Price in internal/server/tools.go if the pricing model changed", maxProfileCostMicro, got) + } +} + +// TestX402Server_MCPStyleProfileSettlementOverride drives the full MCP flow for +// the getStyleProfile upto tool and asserts the facilitator is told to settle +// the computed per-request cost (not the advertised $0.76 maximum). +func TestX402Server_MCPStyleProfileSettlementOverride(t *testing.T) { + ctx := context.Background() + + var mu sync.Mutex + var settleAmounts []string + mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == "GET" && r.URL.Path == "/supported": + _, _ = w.Write([]byte(`{ + "kinds": [ + {"x402Version": 2, "scheme": "upto", "network": "eip155:84532"} + ] + }`)) + case r.Method == "POST" && r.URL.Path == "/verify": + _, _ = w.Write([]byte(`{"isValid": true, "invalidReason": ""}`)) + case r.Method == "POST" && r.URL.Path == "/settle": + var body struct { + PaymentRequirements map[string]json.RawMessage `json:"paymentRequirements"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + if raw := body.PaymentRequirements["amount"]; len(raw) > 0 { + var s string + var f float64 + mu.Lock() + if json.Unmarshal(raw, &s) == nil { + settleAmounts = append(settleAmounts, s) + } else if json.Unmarshal(raw, &f) == nil { + settleAmounts = append(settleAmounts, strconv.FormatFloat(f, 'f', -1, 64)) + } + mu.Unlock() + } + _, _ = w.Write([]byte(`{ + "success": true, + "transaction": "0x1234abcd", + "network": "eip155:84532", + "payer": "0xabcd1234" + }`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer mock.Close() + + serverTransport, clientTransport := mcp.NewInMemoryTransports() + + srv, err := NewX402Server( + "Test Agent API", + "1.0.0", + mock.URL, + &DummyAuthProvider{}, + X402Middlewares{}, + func(next http.Handler) http.Handler { return next }, + func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {}, + []string{"eip155:84532"}, + ) + if err != nil { + t.Fatalf("failed to create server: %v", err) + } + + responses := []GetStyleProfileResponse{ + {Author: "@a", TopKeywords: []string{}, StyleSummary: "s", PostCount: 10, SourcePlatform: "Bluesky"}, + {Author: "@b", TopKeywords: []string{}, StyleSummary: "s", PostCount: 50, SourcePlatform: "Twitter"}, + } + var idx int + srv.AddPayableTool( + mcp.Tool{Name: "getStyleProfile", Description: "get a style profile"}, + func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + resp := responses[idx%len(responses)] + idx++ + b, _ := json.Marshal(resp) + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: string(b)}}}, nil + }, + NewStyleProfileCostResolver(), + x402.PaymentRequirements{ + Scheme: "upto", + Network: "eip155:84532", + Amount: "0.76", + PayTo: "0x0000000000000000000000000000000000000000", + }, + ) + + serverSession, err := srv.mcpServer.Connect(ctx, serverTransport, nil) + if err != nil { + t.Fatalf("failed to connect server session: %v", err) + } + defer func() { _ = serverSession.Close() }() + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "1.0.0"}, nil) + clientSession, err := client.Connect(ctx, clientTransport, nil) + if err != nil { + t.Fatalf("failed to connect client session: %v", err) + } + defer func() { _ = clientSession.Close() }() + + paidCall := func() (*mcp.CallToolResult, error) { + challenge, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: "getStyleProfile", Arguments: json.RawMessage(`{}`)}) + if err != nil { + return nil, fmt.Errorf("challenge call failed: %w", err) + } + if !challenge.IsError { + return nil, fmt.Errorf("expected a payment challenge, got %+v", challenge) + } + text, ok := challenge.Content[0].(*mcp.TextContent) + if !ok { + return nil, fmt.Errorf("unexpected challenge content type %T", challenge.Content[0]) + } + var pr types.PaymentRequired + if err := json.Unmarshal([]byte(text.Text), &pr); err != nil { + return nil, fmt.Errorf("failed to decode payment challenge: %w", err) + } + if len(pr.Accepts) == 0 { + return nil, fmt.Errorf("expected at least one accept, got %d", len(pr.Accepts)) + } + payload := types.PaymentPayload{ + X402Version: 2, + Payload: map[string]interface{}{"chainId": 84532}, + Accepted: pr.Accepts[0], + } + return clientSession.CallTool(ctx, &mcp.CallToolParams{ + Name: "getStyleProfile", + Arguments: json.RawMessage(`{}`), + Meta: mcp.Meta{PaymentMetaKey: payload}, + }) + } + + for i := 0; i < 2; i++ { + result, err := paidCall() + if err != nil { + t.Fatalf("paid call: %v", err) + } + if result.IsError { + text, _ := result.Content[0].(*mcp.TextContent) + t.Fatalf("expected successful paid call, got error result: %+v", text) + } + if resp := result.Meta[PaymentResponseMetaKey]; resp == nil { + t.Fatalf("expected %q in result meta", PaymentResponseMetaKey) + } + } + + mu.Lock() + got := append([]string(nil), settleAmounts...) + mu.Unlock() + // The "$0.25" / "$0.51" overrides resolve to 250000 / 510000 atomic units, + // with up to 1 micro tolerance for float rounding in the SDK's dollar parser. + wants := []int64{250000, 510000} + if len(got) != len(wants) { + t.Fatalf("settle amount count = %d (%v), want %d: %v", len(got), got, len(wants), wants) + } + for i, want := range wants { + v, err := strconv.ParseInt(got[i], 10, 64) + if err != nil { + t.Fatalf("settle amount[%d] = %q is not an integer: %v", i, got[i], err) + } + diff := v - want + if diff < 0 { + diff = -diff + } + if diff > 1 { + t.Errorf("settle amount[%d] = %d, want ~%d", i, v, want) + } + } +} + +func TestNewStyleProfileCostResolver(t *testing.T) { + resolver := NewStyleProfileCostResolver() + + cases := []struct { + name string + resp GetStyleProfileResponse + want string + }{ + {name: "bluesky base", resp: GetStyleProfileResponse{SourcePlatform: "Bluesky", PostCount: 10}, want: "$0.25"}, + {name: "twitter with posts", resp: GetStyleProfileResponse{SourcePlatform: "Twitter", PostCount: 50}, want: "$0.51"}, + {name: "twitter no posts", resp: GetStyleProfileResponse{SourcePlatform: "Twitter", PostCount: 0}, want: "$0.26"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + payload, _ := json.Marshal(tc.resp) + result := &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: string(payload)}}} + got, ok := resolver(result) + if !ok { + t.Fatalf("resolver returned ok=false") + } + if got != tc.want { + t.Fatalf("resolver = %q, want %q", got, tc.want) + } + }) + } + + // Non-text content resolves to "no override"; malformed JSON falls back to + // the flat base cost via computeTwitterStyleProfileCost. + if _, ok := resolver(&mcp.CallToolResult{}); ok { + t.Fatalf("expected no override for empty content") + } + got, ok := resolver(&mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "not json"}}}) + if !ok { + t.Fatalf("expected a base-cost fallback for malformed JSON") + } + if got != "$0.25" { + t.Fatalf("malformed JSON resolver = %q, want base fallback $0.25", got) + } +} + +// TestNewTwitterCountCostResolver verifies the actual-cost settlement for +// tools whose responses carry a `twitterPostCount` field (analytics tools, +// findSimilarUsers): flat base price when no Twitter posts went into the +// response, and base + lookup + per-post when they did. +func TestNewTwitterCountCostResolver(t *testing.T) { + const baseMicro = 150_000 // getEngagementMetrics base + resolver := NewTwitterCountCostResolver(baseMicro) + + cases := []struct { + name string + twitterCount int + want string + }{ + {name: "no twitter posts", twitterCount: 0, want: "$0.15"}, + {name: "one twitter post", twitterCount: 1, want: "$0.165"}, + {name: "ten twitter posts", twitterCount: 10, want: "$0.21"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + payload, _ := json.Marshal(map[string]any{ + "metrics": []any{}, + "twitterPostCount": tc.twitterCount, + }) + result := &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: string(payload)}}} + got, ok := resolver(result) + if !ok { + t.Fatalf("resolver returned ok=false") + } + if got != tc.want { + t.Fatalf("resolver = %q, want %q", got, tc.want) + } + }) + } + + if _, ok := resolver(&mcp.CallToolResult{}); ok { + t.Fatalf("expected no override for empty content") + } + if _, ok := resolver(&mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "not json"}}}); ok { + t.Fatalf("expected no override for malformed JSON") + } +} + +// TestBuildSchemeConfigs verifies scheme servers are derived from the payment +// requirements rather than the tool name, so upto is only registered for tools +// that actually declare it. +func TestBuildSchemeConfigs(t *testing.T) { + req := func(scheme string) x402.PaymentRequirements { + return x402.PaymentRequirements{Scheme: scheme, Network: "eip155:8453", Asset: "usdc", Amount: "1", PayTo: "to"} + } + + t.Run("exact only", func(t *testing.T) { + got := buildSchemeConfigs([]string{"eip155:8453"}, req("exact")) + if len(got) != 1 || got[0].Network != x402.Network("eip155:8453") { + t.Fatalf("expected 1 exact scheme for one network, got %+v", got) + } + }) + + t.Run("upto only", func(t *testing.T) { + got := buildSchemeConfigs([]string{"eip155:8453"}, req("upto")) + if len(got) != 1 { + t.Fatalf("expected 1 upto scheme, got %d", len(got)) + } + }) + + t.Run("both schemes per network", func(t *testing.T) { + got := buildSchemeConfigs([]string{"eip155:8453", "eip155:84532"}, req("exact"), req("upto")) + if len(got) != 4 { + t.Fatalf("expected 2 schemes x 2 networks = 4, got %d", len(got)) + } + }) +} + +func TestPricingHelpers(t *testing.T) { + if got := FormatPriceMicro(MaxTwitterCostMicro(BaseProfileCostMicro, MaxProfileCountedPosts)); got != "0.76" { + t.Fatalf("style profile upto max = %q, want 0.76", got) + } + if got := FormatPriceMicro(MaxTwitterCostMicro(150_000, MaxAnalyticsCountedPosts)); got != "1.16" { + t.Fatalf("engagement metrics upto max = %q, want 1.16", got) + } + if got := MicroFromPriceString("0.25"); got != BaseProfileCostMicro { + t.Fatalf("MicroFromPriceString(0.25) = %d, want %d", got, BaseProfileCostMicro) + } + if got := MicroFromPriceString("garbage"); got != 0 { + t.Fatalf("MicroFromPriceString(garbage) = %d, want 0", got) + } +} diff --git a/mcpServer.service b/mcpServer.service index d9e183e..d852b7b 100644 --- a/mcpServer.service +++ b/mcpServer.service @@ -7,7 +7,7 @@ User=mcpserver Group=mcpserver WorkingDirectory=/home/ubuntu/gitSources/mcpServer -ExecStart=/home/ubuntu/gitSources/mcpServer/bin/main +ExecStart=/home/ubuntu/gitSources/mcpServer/bin/mcpserver RuntimeDirectory=mcpserver RuntimeDirectoryMode=0750 diff --git a/scripts/fund-wallet.sh b/scripts/fund-wallet.sh new file mode 100755 index 0000000..7ad808d --- /dev/null +++ b/scripts/fund-wallet.sh @@ -0,0 +1,62 @@ +#!/bin/bash +set -euo pipefail + +echo "=== Funding Wallet with Base Sepolia USDC ===" + +# Required environment variables +: "${PRIVATE_KEY:?PRIVATE_KEY must be set}" +: "${FUNDING_FAUCET_URL:?FUNDING_FAUCET_URL must be set}" + +WALLET_ADDRESS=$(cast wallet address --private-key "$PRIVATE_KEY") +echo "Wallet address: $WALLET_ADDRESS" + +# Check current USDC balance +USDC_CONTRACT="0x036CbD53842c5426634e7929541eC2318f3dCF7e" +RPC_URL="https://sepolia.base.org" + +BALANCE=$(cast call "$USDC_CONTRACT" "balanceOf(address)(uint256)" "$WALLET_ADDRESS" --rpc-url "$RPC_URL" 2>/dev/null || echo "0") +BALANCE_DECIMAL=$(cast to-dec "$BALANCE" 2>/dev/null || echo "0") +BALANCE_USDC=$(echo "scale=6; $BALANCE_DECIMAL / 1000000" | bc 2>/dev/null || echo "0") + +echo "Current USDC balance: $BALANCE_USDC USDC" + +# If balance is less than 10 USDC, request from faucet +if (( $(echo "$BALANCE_USDC < 10" | bc -l) )); then + echo "Balance below 10 USDC, requesting from faucet..." + + # Request from faucet (Coinbase CDP faucet API format) + curl -X POST "$FUNDING_FAUCET_URL" \ + -H "Content-Type: application/json" \ + -d "{\"address\": \"$WALLET_ADDRESS\", \"token\": \"USDC\"}" \ + || echo "Faucet request failed, may need manual funding" + + # Wait for funding + echo "Waiting for funding..." + sleep 30 + + # Check balance again + BALANCE=$(cast call "$USDC_CONTRACT" "balanceOf(address)(uint256)" "$WALLET_ADDRESS" --rpc-url "$RPC_URL" 2>/dev/null || echo "0") + BALANCE_DECIMAL=$(cast to-dec "$BALANCE" 2>/dev/null || echo "0") + BALANCE_USDC=$(echo "scale=6; $BALANCE_DECIMAL / 1000000" | bc 2>/dev/null || echo "0") + echo "New USDC balance: $BALANCE_USDC USDC" +else + echo "Sufficient USDC balance" +fi + +# Also fund with ETH for gas +ETH_BALANCE=$(cast balance "$WALLET_ADDRESS" --rpc-url "$RPC_URL" 2>/dev/null || echo "0") +ETH_BALANCE_DECIMAL=$(cast to-dec "$ETH_BALANCE" 2>/dev/null || echo "0") +ETH_BALANCE_ETH=$(echo "scale=6; $ETH_BALANCE_DECIMAL / 1000000000000000000" | bc 2>/dev/null || echo "0") + +echo "Current ETH balance: $ETH_BALANCE_ETH ETH" + +if (( $(echo "$ETH_BALANCE_ETH < 0.01" | bc -l) )); then + echo "ETH balance low, requesting from faucet..." + curl -X POST "$FUNDING_FAUCET_URL" \ + -H "Content-Type: application/json" \ + -d "{\"address\": \"$WALLET_ADDRESS\", \"token\": \"ETH\"}" \ + || echo "ETH faucet request failed" + sleep 10 +fi + +echo "=== Wallet Funding Complete ===" \ No newline at end of file diff --git a/scripts/start-test-server.sh b/scripts/start-test-server.sh new file mode 100755 index 0000000..1ad0b07 --- /dev/null +++ b/scripts/start-test-server.sh @@ -0,0 +1,101 @@ +#!/bin/bash +set -euo pipefail + +echo "=== Starting Test Server ===" + +# Required environment variables +: "${PRIVATE_KEY:?PRIVATE_KEY must be set}" +: "${X402_WALLET_ADDRESS:?X402_WALLET_ADDRESS must be set}" + +# Set up environment for local testing +export SERVER_ADDRESS="127.0.0.1" +export SERVER_PORT="8080" +# Allow workflow services to override hosts (postgres/ollama service names) +export GOOSE_DBSTRING="${GOOSE_DBSTRING:-postgres://postgres:postgres@localhost:5432/mcpserver?sslmode=disable}" +# If postgres host not reachable, try service name +if ! pg_isready -h localhost -U postgres -d mcpserver >/dev/null 2>&1 2>/dev/null; then + if pg_isready -h postgres -U postgres -d mcpserver >/dev/null 2>&1 2>/dev/null; then + export GOOSE_DBSTRING="postgres://postgres:postgres@postgres:5432/mcpserver?sslmode=disable" + fi +fi +export ACTIVE_NETWORKS="sepolia" +export X402_FACILITATOR_URL="https://api.cdp.coinbase.com/platform/v2/x402" +export X402_WALLET_ADDRESS="$X402_WALLET_ADDRESS" +# Respect OLLAMA_HOST from env (workflow services use http://ollama:11434) +if curl -sf http://ollama:11434/api/tags >/dev/null 2>&1; then + export OLLAMA_HOST="${OLLAMA_HOST:-http://ollama:11434}" +else + export OLLAMA_HOST="${OLLAMA_HOST:-http://localhost:11434}" +fi +export MIGRATE_ON_START="true" +export MIGRATIONS_DIR="sql/schema" +export READYZ_PORT="9090" +export METRICS_PORT="9091" +export LOG_FILE="/tmp/mcpserver-test.log" +export LOG_LEVEL="debug" + +# Use test credentials or empty for local testing +export BLUESKY_DID="${BLUESKY_DID:-}" +export BLUESKY_APP_PASSWORD="${BLUESKY_APP_PASSWORD:-}" +export BLUESKY_HOST="${BLUESKY_HOST:-https://bsky.social}" +export AP_HOST="${AP_HOST:-https://mastodon.social}" +export AP_ACCESS_TOKEN="${AP_ACCESS_TOKEN:-}" +export NOSTR_RELAYS="${NOSTR_RELAYS:-wss://relay.damus.io,wss://nos.lol}" +export TWITTER_BEARER_TOKEN="${TWITTER_BEARER_TOKEN:-}" +export CDP_API_KEY_ID="${CDP_API_KEY_ID:-}" +export CDP_API_SECRET="${CDP_API_SECRET:-}" +export ENRICH_CROSS_PLATFORM="false" +export PREWARM_AUTHORS="" +export PREWARM_FEED_URI="" + +# Build server binary first +echo "Building server binary..." +go build -o /tmp/mcpserver ./cmd/server/main.go + +# Start the server in background +echo "Starting server on port 8080..." +/tmp/mcpserver >>/tmp/mcpserver-test.log 2>&1 & +SERVER_PID=$! + +# Save PID for cleanup +echo $SERVER_PID > /tmp/test-server.pid + +# Wait for server to be ready +echo "Waiting for server to be ready..." +for i in {1..30}; do + if curl -sf http://localhost:9090/readyz >/dev/null 2>&1; then + echo "Server is ready" + break + fi + sleep 1 +done + +# Verify server is responding +curl -sf http://localhost:8080/healthz >/dev/null 2>&1 || { + echo "Server health check failed" + kill -TERM $SERVER_PID 2>/dev/null || true + sleep 2 + cat /tmp/mcpserver-test.log 2>&1 | tail -n 200 + exit 1 +} + +# Verify payment endpoints are working (should return 402 without payment) +echo "Verifying payment endpoints..." +if curl -sf -X POST http://localhost:8080/v1/getActor \ + -H "Content-Type: application/json" \ + -d '{"author": "did:plc:z72i7hdynmk6r22z27h6tvur"}' >/dev/null 2>&1; then + echo "WARNING: Payable endpoint getActor returned 200 without payment!" +else + echo "Payment endpoint getActor correctly requires payment (402)" +fi + +# Verify free endpoint works +if curl -sf -X POST http://localhost:8080/v1/getFeed \ + -H "Content-Type: application/json" \ + -d '{"uri": "https://bsky.app/profile/did:plc:z72i7hdynmk6r22z27h6tvur/feed/whats-hot"}' >/dev/null 2>&1; then + echo "Free endpoint getFeed works correctly" +else + echo "WARNING: Free endpoint getFeed failed!" +fi + +echo "=== Test Server Started (PID: $SERVER_PID) ===" \ No newline at end of file diff --git a/sql/queries/AnalyticsCache.sql b/sql/queries/AnalyticsCache.sql new file mode 100644 index 0000000..670da7e --- /dev/null +++ b/sql/queries/AnalyticsCache.sql @@ -0,0 +1,17 @@ +-- name: GetAnalyticsCache :one +SELECT result FROM analytics_cache +WHERE user_id = $1 AND platform = $2 AND tool_name = $3 AND time_range_hash = $4 + AND expires_at > now(); + +-- name: UpsertAnalyticsCache :exec +INSERT INTO analytics_cache (user_id, platform, tool_name, time_range_hash, result, expires_at) +VALUES ($1, $2, $3, $4, $5, $6) +ON CONFLICT (user_id, platform, tool_name, time_range_hash) +DO UPDATE SET result = EXCLUDED.result, expires_at = EXCLUDED.expires_at, computed_at = now(); + +-- name: DeleteAnalyticsCache :exec +DELETE FROM analytics_cache +WHERE user_id = $1 AND platform = $2 AND tool_name = $3 AND time_range_hash = $4; + +-- name: PruneAnalyticsCache :exec +DELETE FROM analytics_cache WHERE expires_at <= now(); \ No newline at end of file diff --git a/sql/queries/AnalyticsCoverage.sql b/sql/queries/AnalyticsCoverage.sql new file mode 100644 index 0000000..0aa7ad0 --- /dev/null +++ b/sql/queries/AnalyticsCoverage.sql @@ -0,0 +1,14 @@ +-- name: GetAnalyticsCoverageRanges :many +SELECT covered_start, covered_end FROM analytics_coverage_ranges +WHERE user_id = $1 AND platform = $2 +ORDER BY covered_start; + +-- name: InsertAnalyticsCoverageRange :exec +INSERT INTO analytics_coverage_ranges (user_id, platform, covered_start, covered_end, updated_at) +VALUES ($1, $2, $3, $4, now()) +ON CONFLICT (user_id, platform, covered_start, covered_end) +DO UPDATE SET updated_at = now(); + +-- name: DeleteAnalyticsCoverageRange :exec +DELETE FROM analytics_coverage_ranges +WHERE user_id = $1 AND platform = $2 AND covered_start = $3 AND covered_end = $4; diff --git a/sql/queries/AnalyticsPosts.sql b/sql/queries/AnalyticsPosts.sql new file mode 100644 index 0000000..6f7c31e --- /dev/null +++ b/sql/queries/AnalyticsPosts.sql @@ -0,0 +1,17 @@ +-- name: GetAnalyticsPostsInRange :many +SELECT post_id, post_text, post_timestamp, likes, reposts, replies, quotes +FROM analytics_posts +WHERE user_id = $1 AND platform = $2 AND post_timestamp >= $3 AND post_timestamp <= $4 +ORDER BY post_timestamp DESC +LIMIT $5; + +-- name: UpsertAnalyticsPosts :exec +INSERT INTO analytics_posts (user_id, platform, post_id, post_text, post_timestamp, likes, reposts, replies, quotes) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +ON CONFLICT (user_id, platform, post_id) +DO UPDATE SET post_text = EXCLUDED.post_text, + post_timestamp = EXCLUDED.post_timestamp, + likes = EXCLUDED.likes, + reposts = EXCLUDED.reposts, + replies = EXCLUDED.replies, + quotes = EXCLUDED.quotes; diff --git a/sql/queries/DeleteStylePosts.sql b/sql/queries/DeleteStylePosts.sql new file mode 100644 index 0000000..163eaae --- /dev/null +++ b/sql/queries/DeleteStylePosts.sql @@ -0,0 +1,3 @@ +-- name: DeleteStylePosts :exec +DELETE FROM user_style_posts +WHERE user_id = $1 AND platform = $2 AND platform != 'Twitter'; \ No newline at end of file diff --git a/sql/queries/FindSimilarUsers.sql b/sql/queries/FindSimilarUsers.sql index 25490ef..90e74c1 100644 --- a/sql/queries/FindSimilarUsers.sql +++ b/sql/queries/FindSimilarUsers.sql @@ -1,6 +1,6 @@ -- name: FindSimilarUsers :many SELECT user_id, platform, embedding, top_keywords, style_summary, post_count, - 1 - (embedding <=> $1::vector) AS similarity + 1 - (embedding <=> @query_embedding::vector) AS similarity FROM user_style_profiles -ORDER BY embedding <=> $1::vector -LIMIT $2; \ No newline at end of file +ORDER BY embedding <=> @query_embedding::vector +LIMIT @limit_val; \ No newline at end of file diff --git a/sql/queries/GetStylePostByID.sql b/sql/queries/GetStylePostByID.sql new file mode 100644 index 0000000..255531c --- /dev/null +++ b/sql/queries/GetStylePostByID.sql @@ -0,0 +1,3 @@ +-- name: GetStylePostByID :one +SELECT post_id, post_text FROM user_style_posts +WHERE user_id = $1 AND platform = $2 AND post_id = $3; \ No newline at end of file diff --git a/sql/queries/GetStylePosts.sql b/sql/queries/GetStylePosts.sql new file mode 100644 index 0000000..3c1ca49 --- /dev/null +++ b/sql/queries/GetStylePosts.sql @@ -0,0 +1,5 @@ +-- name: GetStylePosts :many +SELECT post_id, post_text FROM user_style_posts +WHERE user_id = $1 AND platform = $2 +ORDER BY created_at DESC +LIMIT $3; \ No newline at end of file diff --git a/sql/queries/GetStylePostsByIDs.sql b/sql/queries/GetStylePostsByIDs.sql new file mode 100644 index 0000000..932b794 --- /dev/null +++ b/sql/queries/GetStylePostsByIDs.sql @@ -0,0 +1,3 @@ +-- name: GetStylePostsByIDs :many +SELECT post_id, post_text FROM user_style_posts +WHERE user_id = $1 AND platform = $2 AND post_id = ANY($3::text[]); \ No newline at end of file diff --git a/sql/queries/GetStyleProfile.sql b/sql/queries/GetStyleProfile.sql index d148ff6..a085e12 100644 --- a/sql/queries/GetStyleProfile.sql +++ b/sql/queries/GetStyleProfile.sql @@ -1,3 +1,3 @@ -- name: GetStyleProfile :one -SELECT * FROM user_style_profiles -WHERE user_id = $1 AND platform = $2; \ No newline at end of file +SELECT id, user_id, platform, embedding, top_keywords, style_summary, post_count, modified_at, created_at, model_version, schema_version, algorithm_version FROM user_style_profiles +WHERE user_id = @user_id AND platform = @platform; \ No newline at end of file diff --git a/sql/queries/PruneStylePosts.sql b/sql/queries/PruneStylePosts.sql new file mode 100644 index 0000000..064140d --- /dev/null +++ b/sql/queries/PruneStylePosts.sql @@ -0,0 +1,3 @@ +-- name: PruneStylePosts :exec +DELETE FROM user_style_posts +WHERE created_at < now() - interval '7 days' AND platform != 'Twitter'; \ No newline at end of file diff --git a/sql/queries/UpsertStylePosts.sql b/sql/queries/UpsertStylePosts.sql new file mode 100644 index 0000000..2c669bf --- /dev/null +++ b/sql/queries/UpsertStylePosts.sql @@ -0,0 +1,5 @@ +-- name: UpsertStylePosts :exec +INSERT INTO user_style_posts (user_id, platform, post_id, post_text) +VALUES ($1, $2, $3, $4) +ON CONFLICT (user_id, platform, post_id) +DO UPDATE SET post_text = EXCLUDED.post_text, created_at = now(); \ No newline at end of file diff --git a/sql/queries/UpsertStyleProfiles.sql b/sql/queries/UpsertStyleProfiles.sql index b65b61b..d797f51 100644 --- a/sql/queries/UpsertStyleProfiles.sql +++ b/sql/queries/UpsertStyleProfiles.sql @@ -1,10 +1,10 @@ -- name: UpsertStyleProfile :exec INSERT INTO user_style_profiles (user_id, platform, embedding, top_keywords, style_summary, post_count) -VALUES ($1, $2, $3, $4, $5, $6) +VALUES (@user_id, @platform, @embedding::vector, @top_keywords, @style_summary, @post_count) ON CONFLICT (user_id, platform) DO UPDATE SET embedding = EXCLUDED.embedding, top_keywords = EXCLUDED.top_keywords, style_summary = EXCLUDED.style_summary, post_count = EXCLUDED.post_count, - updated_at = NOW(); \ No newline at end of file + modified_at = NOW(); \ No newline at end of file diff --git a/sql/schema/001_transactions.sql b/sql/schema/001_transactions.sql index 700b6b8..612215f 100644 --- a/sql/schema/001_transactions.sql +++ b/sql/schema/001_transactions.sql @@ -1,5 +1,5 @@ -- +goose up -CREATE TABLE transactions ( +CREATE TABLE IF NOT EXISTS transactions ( id BIGSERIAL PRIMARY KEY, x402_transaction_hash VARCHAR(255) NOT NULL UNIQUE, amount NUMERIC NOT NULL, diff --git a/sql/schema/002_user_style_profiles.sql b/sql/schema/002_user_style_profiles.sql index f3efcb4..6241a7e 100644 --- a/sql/schema/002_user_style_profiles.sql +++ b/sql/schema/002_user_style_profiles.sql @@ -1,9 +1,11 @@ -- +goose up -CREATE TABLE user_style_profiles ( +CREATE EXTENSION IF NOT EXISTS vector; + +CREATE TABLE IF NOT EXISTS user_style_profiles ( id BIGSERIAL PRIMARY KEY, user_id TEXT NOT NULL, platform TEXT NOT NULL, - embedding VECTOR(768) NOT NULL, + embedding VECTOR(1024) NOT NULL, top_keywords TEXT[] NOT NULL, style_summary TEXT NOT NULL, post_count INTEGER NOT NULL, @@ -13,4 +15,6 @@ CREATE TABLE user_style_profiles ( ); -- +goose down -DROP TABLE IF EXISTS user_style_profiles; \ No newline at end of file +DROP TABLE IF EXISTS user_style_profiles; + +DROP EXTENSION IF EXISTS vector; \ No newline at end of file diff --git a/sql/schema/003_add_cache_version.sql b/sql/schema/003_add_cache_version.sql new file mode 100644 index 0000000..4fdbf1e --- /dev/null +++ b/sql/schema/003_add_cache_version.sql @@ -0,0 +1,15 @@ +-- +goose up +ALTER TABLE user_style_profiles + ADD COLUMN IF NOT EXISTS model_version INTEGER NOT NULL DEFAULT 1, + ADD COLUMN IF NOT EXISTS schema_version INTEGER NOT NULL DEFAULT 1, + ADD COLUMN IF NOT EXISTS algorithm_version INTEGER NOT NULL DEFAULT 1; + +CREATE INDEX IF NOT EXISTS idx_user_style_profiles_versions + ON user_style_profiles (model_version, schema_version, algorithm_version); + +-- +goose down +DROP INDEX idx_user_style_profiles_versions; +ALTER TABLE user_style_profiles + DROP COLUMN algorithm_version, + DROP COLUMN model_version, + DROP COLUMN schema_version; diff --git a/sql/schema/004_add_style_posts.sql b/sql/schema/004_add_style_posts.sql new file mode 100644 index 0000000..ae5bf08 --- /dev/null +++ b/sql/schema/004_add_style_posts.sql @@ -0,0 +1,17 @@ +-- +goose up +CREATE TABLE IF NOT EXISTS user_style_posts ( + id BIGSERIAL PRIMARY KEY, + user_id TEXT NOT NULL, + platform TEXT NOT NULL, + post_id TEXT NOT NULL, + post_text TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (user_id, platform, post_id) +); + +CREATE INDEX IF NOT EXISTS idx_user_style_posts_user_platform + ON user_style_posts (user_id, platform); + +-- +goose down +DROP INDEX IF EXISTS idx_user_style_posts_user_platform; +DROP TABLE IF EXISTS user_style_posts; \ No newline at end of file diff --git a/sql/schema/005_add_analytics_cache.sql b/sql/schema/005_add_analytics_cache.sql new file mode 100644 index 0000000..3dd106c --- /dev/null +++ b/sql/schema/005_add_analytics_cache.sql @@ -0,0 +1,23 @@ +-- +goose up +CREATE TABLE IF NOT EXISTS analytics_cache ( + id BIGSERIAL PRIMARY KEY, + user_id TEXT NOT NULL, + platform TEXT NOT NULL, + tool_name TEXT NOT NULL, + time_range_hash TEXT NOT NULL, + result JSONB NOT NULL, + computed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + UNIQUE (user_id, platform, tool_name, time_range_hash) +); + +CREATE INDEX IF NOT EXISTS idx_analytics_cache_lookup + ON analytics_cache (user_id, platform, tool_name, time_range_hash); + +CREATE INDEX IF NOT EXISTS idx_analytics_cache_expires + ON analytics_cache (expires_at); + +-- +goose down +DROP INDEX IF EXISTS idx_analytics_cache_expires; +DROP INDEX IF EXISTS idx_analytics_cache_lookup; +DROP TABLE IF EXISTS analytics_cache; \ No newline at end of file diff --git a/sql/schema/006_add_analytics_posts.sql b/sql/schema/006_add_analytics_posts.sql new file mode 100644 index 0000000..36fc965 --- /dev/null +++ b/sql/schema/006_add_analytics_posts.sql @@ -0,0 +1,22 @@ +-- +goose up +CREATE TABLE IF NOT EXISTS analytics_posts ( + id BIGSERIAL PRIMARY KEY, + user_id TEXT NOT NULL, + platform TEXT NOT NULL, + post_id TEXT NOT NULL, + post_text TEXT NOT NULL, + post_timestamp TIMESTAMPTZ NOT NULL, + likes BIGINT NOT NULL DEFAULT 0, + reposts BIGINT NOT NULL DEFAULT 0, + replies BIGINT NOT NULL DEFAULT 0, + quotes BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (user_id, platform, post_id) +); + +CREATE INDEX IF NOT EXISTS idx_analytics_posts_user_platform_time + ON analytics_posts (user_id, platform, post_timestamp); + +-- +goose down +DROP INDEX IF EXISTS idx_analytics_posts_user_platform_time; +DROP TABLE IF EXISTS analytics_posts; diff --git a/sql/schema/007_add_analytics_coverage.sql b/sql/schema/007_add_analytics_coverage.sql new file mode 100644 index 0000000..148e72d --- /dev/null +++ b/sql/schema/007_add_analytics_coverage.sql @@ -0,0 +1,21 @@ +-- +goose up +-- Tracks which time ranges have already been fetched for an author on a +-- platform so paid API calls (e.g. Twitter) are not repeated. Coverage is +-- stored as discrete non-overlapping intervals rather than a single +-- contiguous window, so gaps between separately-fetched ranges are not +-- mistaken for covered data. +CREATE TABLE IF NOT EXISTS analytics_coverage_ranges ( + user_id TEXT NOT NULL, + platform TEXT NOT NULL, + covered_start TIMESTAMPTZ NOT NULL, + covered_end TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, platform, covered_start, covered_end) +); + +CREATE INDEX IF NOT EXISTS idx_analytics_coverage_ranges_lookup + ON analytics_coverage_ranges (user_id, platform, covered_start); + +-- +goose down +DROP INDEX IF EXISTS idx_analytics_coverage_ranges_lookup; +DROP TABLE IF EXISTS analytics_coverage_ranges; diff --git a/testClient/main.go b/testClient/main.go deleted file mode 100644 index 394d2c1..0000000 --- a/testClient/main.go +++ /dev/null @@ -1,121 +0,0 @@ -package main - -import ( - "bytes" - "context" - "fmt" - "io" - "log" - "os" - - "net/http" - "net/http/httputil" - - "github.com/joho/godotenv" - - x402 "github.com/x402-foundation/x402/go/v2" - x402http "github.com/x402-foundation/x402/go/v2/http" - evm "github.com/x402-foundation/x402/go/v2/mechanisms/evm/exact/client" - evmsigners "github.com/x402-foundation/x402/go/v2/signers/evm" -) - -type LoggingRoundTripper struct { - Proxied http.RoundTripper -} - -func (lrt *LoggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - var err error - - if req.GetBody != nil { - rc, _ := req.GetBody() - req.Body = rc - } - - // Dump request - reqDump, err := httputil.DumpRequestOut(req, true) - if err != nil { - log.Printf("Error dumping request: %v", err) - } else { - fmt.Printf("\n--- REQUEST ---\n%s\n----------------\n", reqDump) - } - - // Perform request - resp, err := lrt.Proxied.RoundTrip(req) - if err != nil { - return nil, err - } - - // Dump response - respDump, err := httputil.DumpResponse(resp, true) - if err != nil { - log.Printf("Error dumping response: %v", err) - } else { - fmt.Printf("\n--- RESPONSE ---\n%s\n----------------\n", respDump) - } - - return resp, nil -} - -func main() { - godotenv.Load() - var ( - privateKey = os.Getenv("PRIVATE_KEY") - serverURL = "https://api.naco.li/v1" - ctx = context.Background() - ) - - signer, err := evmsigners.NewClientSignerFromPrivateKey(privateKey) - if err != nil { - log.Fatal("Failed to create EVM signer:", err) - } - - x402Client := x402.Newx402Client(). - Register("eip155:*", evm.NewExactEvmScheme(signer, nil)) - - debugClient := &http.Client{ - Transport: &LoggingRoundTripper{ - Proxied: http.DefaultTransport, - }, - } - - httpClient := x402http.WrapHTTPClientWithPayment( - debugClient, - x402http.Newx402HTTPClient(x402Client), - ) - - log.Println("Starting REST API tests...") - - log.Println("\nCalling getFeed (free tool) via HTTP client...") - bodyGetFeed := []byte(`{"uri": "https://bsky.app/profile/did:plc:z72i7hdynmk6r22z27h6tvur/feed/whats-hot"}`) - resp, err := httpClient.Post(fmt.Sprintf("%s/getFeed", serverURL), "application/json", bytes.NewReader(bodyGetFeed)) - if err != nil { - log.Println("getFeed HTTP request failed:", err) - } else { - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - log.Printf("getFeed Response Status: %s\n", resp.Status) - log.Printf("getFeed Response Body: %s\n", string(body)) - } - - log.Println("\nCalling getPost (gated paid tool) via HTTP client...") - bodyGetPost := []byte(`{"actor":"sharkgamiart.bsky.social","rkey":"3m47y27c67c2b"}`) - - req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("%s/getPost", serverURL), bytes.NewReader(bodyGetPost)) - if err != nil { - log.Fatal("Failed to create getPost request:", err) - } - req.GetBody = func() (io.ReadCloser, error) { - return io.NopCloser(bytes.NewReader(bodyGetPost)), nil - } - req.Header.Set("Content-Type", "application/json") - - respPost, err := httpClient.Do(req) - if err != nil { - log.Println("getPost HTTP request failed:", err) - } else { - defer respPost.Body.Close() - body, _ := io.ReadAll(respPost.Body) - log.Printf("getPost Response Status: %s\n", respPost.Status) - log.Printf("getPost Response Body: %s\n", string(body)) - } -} diff --git a/testClient/mcp/test-mcp-endpoints.go b/testClient/mcp/test-mcp-endpoints.go new file mode 100644 index 0000000..79b35cb --- /dev/null +++ b/testClient/mcp/test-mcp-endpoints.go @@ -0,0 +1,188 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "net/http/httputil" + "os" + "strings" + "time" + + "github.com/joho/godotenv" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + x402 "github.com/x402-foundation/x402/go/v2" + x402mcp "github.com/x402-foundation/x402/go/v2/mcp" + evm "github.com/x402-foundation/x402/go/v2/mechanisms/evm/exact/client" + uptoclient "github.com/x402-foundation/x402/go/v2/mechanisms/evm/upto/client" + evmsigners "github.com/x402-foundation/x402/go/v2/signers/evm" +) + +const USDCContractAddress = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" + +type LoggingRoundTripper struct { + Proxied http.RoundTripper +} + +func (lrt *LoggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + var err error + + if req.GetBody != nil { + rc, _ := req.GetBody() + req.Body = rc + } + + reqDump, err := httputil.DumpRequestOut(req, true) + if err != nil { + log.Printf("Error dumping request: %v", err) + } else { + fmt.Printf("\n--- REQUEST ---\n%s\n----------------\n", reqDump) + } + + resp, err := lrt.Proxied.RoundTrip(req) + if err != nil { + return nil, err + } + + respDump, err := httputil.DumpResponse(resp, true) + if err != nil { + log.Printf("Error dumping response: %v", err) + } else { + fmt.Printf("\n--- RESPONSE ---\n%s\n----------------\n", respDump) + } + + return resp, nil +} + +func callTool(x402Mcp *x402mcp.X402MCPClient, ctx context.Context, name string, args map[string]any) error { + result, err := x402Mcp.CallTool(ctx, name, args) + if err != nil { + return fmt.Errorf("%s MCP call failed: %w", name, err) + } + + fmt.Printf("%s Response:\n", name) + for _, item := range result.Content { + fmt.Printf(" %s\n", item.Text) + } + if result.PaymentMade { + fmt.Printf("%s Payment made\n", name) + } else { + fmt.Printf("%s No payment required\n", name) + } + + if result.IsError { + return fmt.Errorf("%s: tool returned an error result", name) + } + + return nil +} + +func getEnv(key, fallback string) string { + if value, ok := os.LookupEnv(key); ok { + return value + } + return fallback +} + +func main() { + _ = godotenv.Load() + privateKey := os.Getenv("PRIVATE_KEY") + serverURL := getEnv("TEST_SERVER_URL", "http://localhost:8080") + + if privateKey == "" { + log.Fatal("PRIVATE_KEY environment variable is required") + } + + signer, err := evmsigners.NewClientSignerFromPrivateKey(privateKey) + if err != nil { + log.Fatal("Failed to create EVM signer:", err) + } + + debugClient := &http.Client{ + Transport: &LoggingRoundTripper{ + Proxied: http.DefaultTransport, + }, + Timeout: 600 * time.Second, + } + + ctx := context.Background() + + endpoint := strings.TrimRight(serverURL, "/") + "/mcp" + transport := &mcpsdk.StreamableClientTransport{ + Endpoint: endpoint, + HTTPClient: debugClient, + DisableStandaloneSSE: true, + } + + mcpClient := mcpsdk.NewClient(&mcpsdk.Implementation{ + Name: "test-mcp-endpoints", + Version: "1.0.0", + }, nil) + + session, err := mcpClient.Connect(ctx, transport, nil) + if err != nil { + log.Fatal("Failed to connect MCP client:", err) + } + defer func() { _ = session.Close() }() + + x402Mcp := x402mcp.NewX402MCPClientFromConfig(session, []x402mcp.SchemeRegistration{ + {Network: x402.Network("eip155:84532"), Client: evm.NewExactEvmScheme(signer, nil)}, + {Network: x402.Network("eip155:84532"), Client: uptoclient.NewUptoEvmScheme(signer, nil)}, + }, x402mcp.Options{}) + + log.Printf("Connected to MCP endpoint %s", endpoint) + + log.Printf("Starting MCP endpoint tests against %s...", serverURL) + + testCases := []struct { + name string + args map[string]any + }{ + {"getFeed", map[string]any{"uri": "https://bsky.app/profile/did:plc:z72i7hdynmk6r22z27h6tvur/feed/whats-hot"}}, + {"getActor", map[string]any{"author": "did:plc:z72i7hdynmk6r22z27h6tvur"}}, + {"searchActors", map[string]any{"query": "test", "limit": 5}}, + {"getPost", map[string]any{"author": "sharkgamiart.bsky.social", "rkey": "3m47y27c67c2b"}}, + {"getLatestPosts", map[string]any{"author": "sharkgamiart.bsky.social", "limit": 5}}, + {"getPostsInTime", map[string]any{"author": "sharkgamiart.bsky.social", "startTime": "2024-01-01T00:00:00Z", "endTime": "2024-12-31T23:59:59Z", "limit": 5}}, + {"getStyleProfile", map[string]any{"author": "did:plc:z72i7hdynmk6r22z27h6tvur"}}, + {"findSimilarUsers", map[string]any{"author": "did:plc:z72i7hdynmk6r22z27h6tvur", "limit": 5}}, + } + + passed := 0 + failed := 0 + + fmt.Printf("\n=== Testing listTools ===\n") + toolsResponse, err := session.ListTools(ctx, nil) + if err != nil { + log.Printf("FAIL listTools: %v\n", err) + failed++ + } else { + for _, tool := range toolsResponse.Tools { + fmt.Printf(" tool: %s\n", tool.Name) + } + fmt.Printf("PASS listTools (%d tools listed)\n", len(toolsResponse.Tools)) + passed++ + } + + for _, tc := range testCases { + fmt.Printf("\n=== Testing %s ===\n", tc.name) + err := callTool(x402Mcp, ctx, tc.name, tc.args) + if err != nil { + log.Printf("FAIL %s: %v\n", tc.name, err) + failed++ + } else { + fmt.Printf("PASS %s\n", tc.name) + passed++ + } + } + + log.Printf("\n=== Test Summary ===") + log.Printf("Passed: %d", passed) + log.Printf("Failed: %d", failed) + + if failed > 0 { + os.Exit(1) + } +} diff --git a/testClient/rest/test-rest-endpoints.go b/testClient/rest/test-rest-endpoints.go new file mode 100644 index 0000000..c9e9f7b --- /dev/null +++ b/testClient/rest/test-rest-endpoints.go @@ -0,0 +1,196 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/http/httputil" + "os" + "strings" + "time" + + "github.com/joho/godotenv" + + x402 "github.com/x402-foundation/x402/go/v2" + x402http "github.com/x402-foundation/x402/go/v2/http" + evm "github.com/x402-foundation/x402/go/v2/mechanisms/evm/exact/client" + uptoclient "github.com/x402-foundation/x402/go/v2/mechanisms/evm/upto/client" + evmsigners "github.com/x402-foundation/x402/go/v2/signers/evm" +) + +const USDCContractAddress = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" + +type LoggingRoundTripper struct { + Proxied http.RoundTripper +} + +func (lrt *LoggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + var err error + + if req.GetBody != nil { + rc, _ := req.GetBody() + req.Body = rc + } + + reqDump, err := httputil.DumpRequestOut(req, true) + if err != nil { + log.Printf("Error dumping request: %v", err) + } else { + fmt.Printf("\n--- REQUEST ---\n%s\n----------------\n", reqDump) + } + + resp, err := lrt.Proxied.RoundTrip(req) + if err != nil { + return nil, err + } + + respDump, err := httputil.DumpResponse(resp, true) + if err != nil { + log.Printf("Error dumping response: %v", err) + } else { + fmt.Printf("\n--- RESPONSE ---\n%s\n----------------\n", respDump) + } + + return resp, nil +} + +func callEndpoint(client *http.Client, method, endpoint, body string) error { + baseURL := getEnv("TEST_SERVER_URL", "http://localhost:8080") + url := fmt.Sprintf("%s/v1/%s", baseURL, endpoint) + req, err := http.NewRequest(method, url, strings.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create %s request: %w", endpoint, err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("%s HTTP request failed: %w", endpoint, err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, _ := io.ReadAll(resp.Body) + fmt.Printf("%s Response Status: %s\n", endpoint, resp.Status) + if len(respBody) > 0 { + fmt.Printf("%s Response Body: %s\n", endpoint, string(respBody)) + } + + // Fail on any non-2xx except 402 (which is expected for payable endpoints) + if resp.StatusCode >= 400 && resp.StatusCode != 402 { + return fmt.Errorf("%s: unexpected status %d: %s", endpoint, resp.StatusCode, string(respBody)) + } + + if resp.StatusCode == 402 { + fmt.Printf("%s SECOND 402 (paid retry rejected):\n", endpoint) + fmt.Printf("%s Status: %s\n", endpoint, resp.Status) + fmt.Printf("%s PAYMENT-REQUIRED header: %s\n", endpoint, resp.Header.Get("PAYMENT-REQUIRED")) + if prHeader := resp.Header.Get("PAYMENT-REQUIRED"); prHeader != "" { + if decoded, err := decodePaymentRequired(prHeader); err == nil { + pretty, _ := json.MarshalIndent(decoded, "", " ") + fmt.Printf("%s Decoded PAYMENT-REQUIRED:\n%s\n", endpoint, pretty) + } else { + fmt.Printf("%s Failed to decode PAYMENT-REQUIRED header: %v\n", endpoint, err) + } + } + if len(respBody) > 0 { + fmt.Printf("%s Response Body: %s\n", endpoint, string(respBody)) + } + return fmt.Errorf("%s: payment required (402)", endpoint) + } + + return nil +} + +func decodePaymentRequired(header string) (map[string]any, error) { + data, err := base64.StdEncoding.DecodeString(header) + if err != nil { + return nil, err + } + var v map[string]any + if err := json.Unmarshal(data, &v); err != nil { + return nil, err + } + return v, nil +} + +func getEnv(key, fallback string) string { + if value, ok := os.LookupEnv(key); ok { + return value + } + return fallback +} + +func main() { + _ = godotenv.Load() + privateKey := os.Getenv("PRIVATE_KEY") + serverURL := getEnv("TEST_SERVER_URL", "http://localhost:8080") + + if privateKey == "" { + log.Fatal("PRIVATE_KEY environment variable is required") + } + + signer, err := evmsigners.NewClientSignerFromPrivateKey(privateKey) + if err != nil { + log.Fatal("Failed to create EVM signer:", err) + } + + x402Client := x402.Newx402Client(). + Register("eip155:84532", evm.NewExactEvmScheme(signer, nil)). + Register("eip155:84532", uptoclient.NewUptoEvmScheme(signer, nil)) + + debugClient := &http.Client{ + Transport: &LoggingRoundTripper{ + Proxied: http.DefaultTransport, + }, + Timeout: 600 * time.Second, + } + + httpClient := x402http.WrapHTTPClientWithPayment( + debugClient, + x402http.Newx402HTTPClient(x402Client), + ) + + log.Printf("Starting REST API endpoint tests against %s...", serverURL) + + testCases := []struct { + name string + method string + endpoint string + body string + }{ + {"getFeed", "POST", "getFeed", `{"uri": "https://bsky.app/profile/did:plc:z72i7hdynmk6r22z27h6tvur/feed/whats-hot"}`}, + {"getActor", "POST", "getActor", `{"author": "did:plc:z72i7hdynmk6r22z27h6tvur"}`}, + {"searchActors", "POST", "searchActors", `{"query": "test", "limit": 5}`}, + {"getPost", "POST", "getPost", `{"author":"sharkgamiart.bsky.social","rkey":"3m47y27c67c2b"}`}, + {"getLatestPosts", "POST", "getLatestPosts", `{"author":"sharkgamiart.bsky.social","limit": 5}`}, + {"getPostsInTime", "POST", "getPostsInTime", `{"author":"sharkgamiart.bsky.social","startTime": "2024-01-01T00:00:00Z","endTime": "2024-12-31T23:59:59Z","limit": 5}`}, + {"getStyleProfile", "POST", "getStyleProfile", `{"author":"did:plc:z72i7hdynmk6r22z27h6tvur"}`}, + {"findSimilarUsers", "POST", "findSimilarUsers", `{"author":"did:plc:z72i7hdynmk6r22z27h6tvur","limit": 5}`}, + } + + passed := 0 + failed := 0 + + for _, tc := range testCases { + fmt.Printf("\n=== Testing %s ===\n", tc.name) + err := callEndpoint(httpClient, tc.method, tc.endpoint, tc.body) + if err != nil { + log.Printf("FAIL %s: %v\n", tc.name, err) + failed++ + } else { + fmt.Printf("PASS %s\n", tc.name) + passed++ + } + } + + log.Printf("\n=== Test Summary ===") + log.Printf("Passed: %d", passed) + log.Printf("Failed: %d", failed) + + if failed > 0 { + os.Exit(1) + } +}