diff --git a/.forgejo/workflows/test-endpoints.yaml b/.forgejo/workflows/test-endpoints.yaml deleted file mode 100644 index dfc4e79..0000000 --- a/.forgejo/workflows/test-endpoints.yaml +++ /dev/null @@ -1,199 +0,0 @@ -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 e28e78c..92eccfb 100644 --- a/.forgejo/workflows/test.yaml +++ b/.forgejo/workflows/test.yaml @@ -1,14 +1,10 @@ -name: CI Go Verification +name: CI/CD Go Verification on: push: - branches: [ main, dev, ai-main ] - pull_request: branches: [ main, dev ] - -concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + pull_request: + branches: [ main ] jobs: build_and_test: @@ -33,11 +29,8 @@ jobs: exit 1 fi - - name: Vet - run: go vet ./... - - name: Build Application run: go build -v ./... - - name: Run Tests - run: go test -v -race -cover ./... \ No newline at end of file + - name: Run Tests (with Mocked Boundaries) + run: go test -v -cover ./... \ No newline at end of file diff --git a/.gitignore b/.gitignore index c5974fe..3476089 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,3 @@ .env cdpScripts/cdp -bin/ -test-endpoints -test-endpoints.exe -test-mcp-endpoints -test-mcp-endpoints.exe \ No newline at end of file +bin/ \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml deleted file mode 100644 index 6d5891e..0000000 --- a/.golangci.yml +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index 317bf8d..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,97 +0,0 @@ -# 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 deleted file mode 100644 index 83e8a82..0000000 --- a/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# 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 2d501b5..1a49e5f 100644 --- a/cdpScripts/listServices.py +++ b/cdpScripts/listServices.py @@ -19,8 +19,9 @@ async def main(): my_services = [ item for item in services.items - if "api.naco.li" in getattr(item, "resource", "") + if "api.naco.li" in getattr(item, "resource", "") or getattr(item, "type", "") != "http" ] + 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 deleted file mode 100644 index 717cf00..0000000 --- a/cmd/check-facilitator/main.go +++ /dev/null @@ -1,138 +0,0 @@ -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 b42c61f..fb6f050 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -3,75 +3,41 @@ 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, 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() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() cfg := config.Load() - if err := log.Init(log.Config{ + 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 initialize database connection: %v", err) + log.Fatalf("Failed to connect to database: %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) - 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) + bskyClient, err := bluesky.NewBlueskyClient(ctx, cfg.BlueskyDID, cfg.BlueskyAppPassword, cfg.BlueskyHost) if err != nil { log.Fatal("Failed to create bluesky client", "error", err) } @@ -80,107 +46,25 @@ func main() { if err != nil { log.Fatal("Invalid OLLAMA_HOST URL", "error", err) } - ollamaHTTPClient := &http.Client{ - Timeout: 600 * time.Second, - } - ollamaClient := ollama.New(uri, ollamaHTTPClient, cfg.EmbeddingModel, cfg.StyleModel) + ollamaClient := ollama.New(ctx, uri, http.DefaultClient) - apClient, err := activitypub.NewAPClient(cfg.ActivityPubHost, cfg.ActivityPubToken) + apClient, err := activitypub.NewAPClient(ctx, cfg.ActivityPubHost, cfg.ActivityPubToken) if err != nil { log.Fatal("Failed to create activitypub client", "error", err) } - nostrClient, err := nostr.NewNostrClient(cfg.NostrRelays) - if err != nil { - log.Fatal("Failed to create nostr client", "error", err) - } + srv := server.New(ctx, db, apClient, bskyClient, ollamaClient, cfg) - 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 { + go func() { log.Info("Server starting", "address", cfg.ListenAddress) - if err := srv.Start(); err != nil && !errors.Is(err, http.ErrServerClosed) { - return fmt.Errorf("main server error: %w", err) + if err := srv.Start(); err != http.ErrServerClosed { + log.Fatal("HTTP server error", "error", err) } - return nil - }) + }() - 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) - }) + stopChan := make(chan os.Signal, 1) + signal.Notify(stopChan, syscall.SIGINT, syscall.SIGTERM) + <-stopChan - 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) - }) + log.Info("Shutting down server...") } diff --git a/config/models.env b/config/models.env deleted file mode 100644 index 96a8c53..0000000 --- a/config/models.env +++ /dev/null @@ -1,5 +0,0 @@ -# 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 deleted file mode 100644 index 83f6046..0000000 --- a/docker-compose.test.yml +++ /dev/null @@ -1,26 +0,0 @@ -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 13124e1..227cd76 100644 --- a/go.mod +++ b/go.mod @@ -3,14 +3,13 @@ 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-20260907215822-b3c536ed7b37 + github.com/nacorid/logger v0.0.0-20251221013547-5fc8fd94ed80 github.com/ollama/ollama v0.12.10 github.com/x402-foundation/x402/go/v2 v2.19.0 golang.org/x/time v0.14.0 @@ -28,21 +27,15 @@ 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 + golang.org/x/sync v0.19.0 // indirect ) 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 @@ -66,14 +59,9 @@ 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 @@ -82,17 +70,11 @@ 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 d43b247..f88d49c 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,6 @@ -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= @@ -13,7 +9,6 @@ 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= @@ -21,30 +16,6 @@ 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= @@ -61,8 +32,6 @@ 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= @@ -70,7 +39,6 @@ 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= @@ -78,14 +46,10 @@ 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= @@ -98,8 +62,6 @@ 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= @@ -120,22 +82,10 @@ 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= @@ -145,7 +95,6 @@ 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= @@ -168,7 +117,6 @@ 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= @@ -203,20 +151,12 @@ 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= @@ -234,8 +174,6 @@ 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= @@ -252,11 +190,6 @@ 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= @@ -269,19 +202,10 @@ 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-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/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/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= @@ -309,13 +233,11 @@ 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.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= -github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +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/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= @@ -338,7 +260,6 @@ 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= @@ -346,17 +267,6 @@ 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= @@ -414,7 +324,6 @@ 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= @@ -428,37 +337,25 @@ 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= @@ -474,7 +371,6 @@ 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= @@ -498,12 +394,6 @@ 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= @@ -511,15 +401,10 @@ 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 c3684dd..38f238f 100644 --- a/internal/activitypub/client.go +++ b/internal/activitypub/client.go @@ -6,453 +6,149 @@ 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(host, apikey string) (*APClient, error) { +func NewAPClient(ctx context.Context, host, apikey string) (*APClient, error) { if host == "" || apikey == "" { return nil, fmt.Errorf("host and apikey must not be empty") } - return &APClient{ - client: &http.Client{Timeout: 30 * time.Second}, - host: strings.TrimRight(host, "/"), - bearerToken: "Bearer " + apikey, - outbox: newOutboxClient(), - limiter: newHostLimiter(), + client: http.DefaultClient, + host: host, + bearerToken: fmt.Sprintf("Bearer %s", apikey), }, nil } -func (ap *APClient) get(ctx context.Context, endpoint string, target any) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, 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) if err != nil { - return fmt.Errorf("failed to create request: %w", err) + return nil, 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) } - return &status, nil -} - -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) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to get post: %s", resp.Status) } - for _, s := range res.Statuses { - if s != nil && (s.URL == postID || s.URI == postID) { - return s, nil - } - } - return nil, nil -} - -func (ap *APClient) GetLatestNPosts(ctx context.Context, actorID string, limit int) ([]*Post, error) { - if limit <= 0 { - return nil, nil - } - if limit > maxLimit { - limit = maxLimit - } - - acc, err := ap.resolveAccount(ctx, actorID) + body, err := io.ReadAll(resp.Body) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to read response body: %w", err) } - - cached, cacheErr := ap.cacheLatestPosts(ctx, acc, limit) - if cacheErr != nil { - return nil, cacheErr + var internal = &internalPost{} + if err = json.Unmarshal(body, internal); err != nil { + return nil, fmt.Errorf("failed to unmarshal json response: %w", err) } - 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 + return internal.toPost(), nil } -func (ap *APClient) cacheLatestPosts(ctx context.Context, acc *Account, limit int) ([]*internalPost, error) { - var ( - internal []*internalPost - maxID string - ) +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) + } + 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) + } + 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 +} - for len(internal) < limit { +func (ap *APClient) GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]Post, error) { + var posts []Post + var maxID string + + for { if ctx.Err() != nil { return nil, ctx.Err() } - - pageSize := limit - len(internal) - if pageSize > maxMastodonPageSize { - pageSize = maxMastodonPageSize + if len(posts) >= limit { + break } - endpoint := fmt.Sprintf("%s/api/v1/accounts/%s/statuses?limit=%d", ap.host, url.PathEscape(acc.ID), pageSize) + url := fmt.Sprintf("%s/api/v1/accounts/%s/statuses?limit=50", ap.host, actor) if maxID != "" { - endpoint += "&max_id=" + url.QueryEscape(maxID) + url += fmt.Sprintf("&max_id=%s", maxID) } - var page []*internalPost - if err := ap.get(ctx, endpoint, &page); err != nil { - return nil, err + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) } - if len(page) == 0 { + 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 { break } - 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 { + for _, i := range internal { if i.CreatedAt.After(end) { continue } + if i.CreatedAt.Before(start) { - return internal, nil + return posts, nil } - internal = append(internal, i) - if len(internal) >= limit { - return internal, nil + + posts = append(posts, *i.toPost()) + + if len(posts) >= limit { + return posts, nil } } - maxID = newMaxID + + maxID = internal[len(internal)-1].ID } - 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() + return posts, nil } diff --git a/internal/activitypub/client_test.go b/internal/activitypub/client_test.go deleted file mode 100644 index 04fbffd..0000000 --- a/internal/activitypub/client_test.go +++ /dev/null @@ -1,597 +0,0 @@ -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 deleted file mode 100644 index f7dc81f..0000000 --- a/internal/activitypub/outbox.go +++ /dev/null @@ -1,501 +0,0 @@ -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 deleted file mode 100644 index fb8c733..0000000 --- a/internal/activitypub/outbox_test.go +++ /dev/null @@ -1,224 +0,0 @@ -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 deleted file mode 100644 index 19287d5..0000000 --- a/internal/activitypub/throttle.go +++ /dev/null @@ -1,58 +0,0 @@ -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 1d9fc27..94849b5 100644 --- a/internal/activitypub/types.go +++ b/internal/activitypub/types.go @@ -3,7 +3,6 @@ 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"` @@ -13,18 +12,14 @@ type Post struct { } func (i *internalPost) toPost() *Post { - p := &Post{ - ID: i.ID, + return &Post{ + Author: i.Account.Acct, 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 { @@ -58,6 +53,7 @@ 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"` @@ -270,10 +266,3 @@ 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 deleted file mode 100644 index a5b866d..0000000 --- a/internal/actor/profile.go +++ /dev/null @@ -1,16 +0,0 @@ -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 edb6359..79523fd 100644 --- a/internal/bluesky/client.go +++ b/internal/bluesky/client.go @@ -2,11 +2,9 @@ package bluesky import ( "context" - "errors" "fmt" "net/url" "strings" - "sync" "time" "github.com/bluesky-social/indigo/api/atproto" @@ -14,39 +12,10 @@ 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 - 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 + client *xrpc.Client } func NewBlueskyClient(ctx context.Context, handle, password, host string) (*BlueskyClient, error) { @@ -68,175 +37,61 @@ func NewBlueskyClient(ctx context.Context, handle, password, host string) (*Blue Handle: session.Handle, Did: session.Did, } - 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") + return &BlueskyClient{client: client}, nil } func (c *BlueskyClient) GetPost(ctx context.Context, actor, collection, recordKey string) (*bsky.FeedDefs_PostView, error) { - var result *bsky.FeedDefs_PostView - err := c.withRefresh(ctx, func() error { - repo, err := c.resolveUser(ctx, actor) - if err != nil { - return err - } + repo, err := c.resolveUser(ctx, actor) + if err != nil { + return nil, err + } - 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 + 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 } func (c *BlueskyClient) GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*bsky.FeedDefs_FeedViewPost, error) { - if limit <= 0 { - return nil, nil + repo, err := c.resolveUser(ctx, actor) + if err != nil { + return nil, err } - pageSize := limit - if pageSize > maxBskyFeedLimit { - pageSize = maxBskyFeedLimit + 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) } - - 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 + return resp.Feed, nil } func (c *BlueskyClient) GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]*bsky.FeedDefs_FeedViewPost, error) { - var result []*bsky.FeedDefs_FeedViewPost - err := c.withRefresh(ctx, func() error { - repo, err := c.resolveUser(ctx, actor) + 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) if err != nil { - return err + return nil, err } - 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 - } + for _, post := range feed.Feed { + if post.Post.Record != nil { rec, ok := post.Post.Record.Val.(*bsky.FeedPost) if !ok { log.WarnWithContext(ctx, "Failed to parse post", "post", post.Post.Uri) @@ -249,30 +104,21 @@ func (c *BlueskyClient) GetPostsInTimeframe(ctx context.Context, actor string, s continue } - 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 createdAt.After(start) && createdAt.Before(end) { + posts = append(posts, post) + if len(posts) >= limit { + return posts, nil + } } } - - if feed.Cursor == nil || *feed.Cursor == "" { - break - } - cursor = *feed.Cursor } - result = posts - return nil - }) - return result, err + + if feed.Cursor == nil || *feed.Cursor == "" { + break + } + cursor = *feed.Cursor + } + return posts, nil } func (c *BlueskyClient) GetFeed(ctx context.Context, feedURI string, limit int) (*bsky.FeedGetFeed_Output, error) { @@ -280,139 +126,26 @@ func (c *BlueskyClient) GetFeed(ctx context.Context, feedURI string, limit int) if err != nil { return nil, err } - 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 + return bsky.FeedGetFeed(ctx, c.client, "", uri, int64(50)) } -// 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 { - return d, 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) + } } - - 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) @@ -425,10 +158,6 @@ 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 599edbb..504b912 100644 --- a/internal/bluesky/client_test.go +++ b/internal/bluesky/client_test.go @@ -7,10 +7,7 @@ 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" @@ -33,7 +30,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 } @@ -45,7 +42,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 @@ -83,7 +80,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 @@ -109,125 +106,7 @@ func setupMockServer() *httptest.Server { }, } w.Header().Set("Content-Type", "application/json") - _ = 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) + json.NewEncoder(w).Encode(resp) }) return httptest.NewServer(mux) @@ -292,7 +171,6 @@ func newTestClient(t *testing.T, serverURL string) *BlueskyClient { AccessJwt: "test_access_jwt", }, }, - didCache: make(map[string]didCacheEntry), } } @@ -303,7 +181,7 @@ func TestGetLatestNPosts(t *testing.T) { client := newTestClient(t, server.URL) ctx := context.Background() actor := "did:plc:testactor" - limit := 5 + var limit int = 5 output, err := client.GetLatestNPosts(ctx, actor, limit) if err != nil { @@ -330,7 +208,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" - limit := 10 + var limit int = 10 output, err := client.GetFeed(ctx, feedURI, limit) if err != nil { @@ -349,597 +227,3 @@ 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 deleted file mode 100644 index 02241a6..0000000 --- a/internal/clustering/helpers.go +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index d1a126b..0000000 --- a/internal/clustering/k-means.go +++ /dev/null @@ -1,228 +0,0 @@ -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 deleted file mode 100644 index ca6bb91..0000000 --- a/internal/clustering/kmeans_test.go +++ /dev/null @@ -1,213 +0,0 @@ -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 deleted file mode 100644 index d1608d1..0000000 --- a/internal/clustering/types.go +++ /dev/null @@ -1,21 +0,0 @@ -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 1c5ab7f..0905193 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,71 +2,33 @@ 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 - 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 + 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 } 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") } @@ -76,59 +38,24 @@ func Load() *Config { if err != nil { log.Fatal("Invalid UNIX_SOCKET_PERMISSIONS", "error", err) } - 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"), + 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://v2.facilitator.mogami.tech"), + 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"), } - - 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 { @@ -140,59 +67,3 @@ 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 deleted file mode 100644 index 952045d..0000000 --- a/internal/nostr/client.go +++ /dev/null @@ -1,372 +0,0 @@ -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 deleted file mode 100644 index e65bbb1..0000000 --- a/internal/nostr/client_test.go +++ /dev/null @@ -1,305 +0,0 @@ -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 60515ae..15db457 100644 --- a/internal/ollama/client.go +++ b/internal/ollama/client.go @@ -2,232 +2,51 @@ 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 - embeddingModel string - styleModel string + client api.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 New(ctx context.Context, host *url.URL, http *http.Client) *OllamaClient { + client := api.NewClient(host, http) + return &OllamaClient{client: *client} } -// 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 - } +func (o *OllamaClient) EmbedTexts(ctx context.Context, posts []string) ([][]float64, error) { + vectors := make([][]float64, 0, len(posts)) + for _, t := range posts { resp, err := o.client.Embeddings(ctx, &api.EmbeddingRequest{ - Model: o.embeddingModel, - Prompt: text, - }) - if err == nil { - return resp.Embedding, nil - } - lastErr = err - if !isTransientErr(err) { - return nil, err - } - } - return nil, lastErr -} - -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 - backoff := generateRetryBackoff - var lastErr error - for attempt := 0; attempt <= generateRetries; attempt++ { - if attempt > 0 { - if err := sleepCtx(ctx, backoff); err != nil { - return "", err - } - } - - // 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 + Model: "nomic-embed-text", + Prompt: t, }) 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 + return nil, err } - - // 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 + vectors = append(vectors, resp.Embedding) } - return "", fmt.Errorf("ollama generate with model %q failed after retries: %w", o.styleModel, lastErr) + return vectors, nil } -func sleepCtx(ctx context.Context, d time.Duration) error { - select { - case <-time.After(d): +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:" + 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 - case <-ctx.Done(): - return ctx.Err() + }) + if err != nil { + return "", 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 + return fullResponse.String(), nil } diff --git a/internal/ollama/client_test.go b/internal/ollama/client_test.go deleted file mode 100644 index 9ade0e3..0000000 --- a/internal/ollama/client_test.go +++ /dev/null @@ -1,516 +0,0 @@ -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 76424b6..bb4e7f0 100644 --- a/internal/server/LoggingMiddleware.go +++ b/internal/server/LoggingMiddleware.go @@ -32,20 +32,12 @@ func (lm *LoggingMiddleware) OnCall(next mcp.MethodHandler) mcp.MethodHandler { toolName = callReq.Params.Name } - 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, - ) - } + log.InfoWithContext(ctx, "MCP Tool Processed", + "transport", "mcp_native", + "tool", toolName, + "duration", duration, + "success", err == nil, + ) return result, err } return next(ctx, method, request) diff --git a/internal/server/actors.go b/internal/server/actors.go deleted file mode 100644 index 205c8fa..0000000 --- a/internal/server/actors.go +++ /dev/null @@ -1,76 +0,0 @@ -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 deleted file mode 100644 index cb38bcf..0000000 --- a/internal/server/analytics.go +++ /dev/null @@ -1,966 +0,0 @@ -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 deleted file mode 100644 index e74eef6..0000000 --- a/internal/server/analytics_test.go +++ /dev/null @@ -1,318 +0,0 @@ -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 07c8c24..94c75bc 100644 --- a/internal/server/auth.go +++ b/internal/server/auth.go @@ -2,16 +2,12 @@ package server import ( "context" - "crypto" - "crypto/ed25519" + "crypto/ecdsa" "crypto/rand" "crypto/x509" - "encoding/base64" "encoding/hex" "encoding/pem" - "errors" "fmt" - "strings" "time" "github.com/golang-jwt/jwt/v5" @@ -20,66 +16,52 @@ import ( type CDPAuth struct { keyName string - privateKey crypto.Signer + privateKey *ecdsa.PrivateKey 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") } - // Try to parse as EC private key first + var signingMethod jwt.SigningMethod privKey, err := x509.ParseECPrivateKey(block.Bytes) if err != nil { - return nil, fmt.Errorf("failed to parse EC private key: %w", err) + 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 &CDPAuth{ keyName: keyName, privateKey: privKey, - signingMethod: jwt.SigningMethodES256, + signingMethod: signingMethod, }, nil } func (a *CDPAuth) generateBearerToken(method, path string) (string, error) { nonceBytes := make([]byte, 16) if _, err := rand.Read(nonceBytes); err != nil { - return "", fmt.Errorf("failed to generate nonce: %w", err) + return "", err } nonce := hex.EncodeToString(nonceBytes) uri := fmt.Sprintf("%s api.cdp.coinbase.com%s", method, path) - now := time.Now() - + now := time.Now().Unix() claims := jwt.MapClaims{ "sub": a.keyName, "iss": "cdp", - "nbf": now.Unix(), - "iat": now.Unix(), - "exp": now.Add(120 * time.Second).Unix(), + "nbf": now, + "exp": now + 120, "uris": []string{uri}, } @@ -87,43 +69,16 @@ func (a *CDPAuth) generateBearerToken(method, path string) (string, error) { token.Header["kid"] = a.keyName token.Header["nonce"] = nonce - 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 + return token.SignedString(a.privateKey) } func (a *CDPAuth) GetAuthHeaders(ctx context.Context) (x402http.AuthHeaders, error) { - 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) - } + verify, _ := a.generateBearerToken("POST", "/platform/v2/x402/verify") + settle, _ := a.generateBearerToken("POST", "/platform/v2/x402/settle") + supported, _ := a.generateBearerToken("GET", "/platform/v2/x402/supported") 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 deleted file mode 100644 index 017aba1..0000000 --- a/internal/server/auth_test.go +++ /dev/null @@ -1,213 +0,0 @@ -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 deleted file mode 100644 index 3182a3b..0000000 --- a/internal/server/backends.go +++ /dev/null @@ -1,76 +0,0 @@ -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 deleted file mode 100644 index 09b7f2a..0000000 --- a/internal/server/cache.go +++ /dev/null @@ -1,88 +0,0 @@ -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 deleted file mode 100644 index 40ea07e..0000000 --- a/internal/server/cache_version.go +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 3f37f62..0000000 --- a/internal/server/coverage_test.go +++ /dev/null @@ -1,100 +0,0 @@ -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 deleted file mode 100644 index 1ff56b1..0000000 --- a/internal/server/mcp_test.go +++ /dev/null @@ -1,855 +0,0 @@ -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 4960b37..138af47 100644 --- a/internal/server/metricsMiddleware.go +++ b/internal/server/metricsMiddleware.go @@ -4,9 +4,6 @@ import ( "context" "fmt" "net/http" - "sort" - "strings" - "sync" "time" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -14,93 +11,27 @@ 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) - mh.RecordTool(toolName, err, time.Since(start)) + duration := fmt.Sprintf("%.2fms", float64(time.Since(start))/float64(time.Millisecond)) + log.DebugWithContext(ctx, "MetricsMiddleware: MCP Handler", "duration", duration) return result, err } return next(ctx, method, request) @@ -111,6 +42,7 @@ 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) @@ -124,42 +56,3 @@ 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 deleted file mode 100644 index a0330cd..0000000 --- a/internal/server/platform.go +++ /dev/null @@ -1,44 +0,0 @@ -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 deleted file mode 100644 index 733d1e0..0000000 --- a/internal/server/platform_test.go +++ /dev/null @@ -1,57 +0,0 @@ -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 deleted file mode 100644 index cd2bd28..0000000 --- a/internal/server/posts.go +++ /dev/null @@ -1,144 +0,0 @@ -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 deleted file mode 100644 index f35b394..0000000 --- a/internal/server/posts_convert.go +++ /dev/null @@ -1,255 +0,0 @@ -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 deleted file mode 100644 index 2fd9041..0000000 --- a/internal/server/prewarm.go +++ /dev/null @@ -1,93 +0,0 @@ -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 deleted file mode 100644 index 61fa8c6..0000000 --- a/internal/server/searchactors_test.go +++ /dev/null @@ -1,84 +0,0 @@ -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 4cccf25..caa40eb 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -2,119 +2,740 @@ 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/twitter" + "github.com/nacorid/naco-api/internal/utils" "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 { - // 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 + listenAddress string + permissions os.FileMode + mcpHandler *x402.X402Server + db *store.Queries + apClient *activitypub.APClient + bskyClient *bluesky.BlueskyClient + ollama *ollama.OllamaClient + x402Wallet string + activeNetworks []NetworkInfo } -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) +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) + } + } s := &Server{ - 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 != "", + listenAddress: cfg.ListenAddress, + permissions: cfg.UnixSocketPermissions, + mcpHandler: srv, + db: db, + apClient: ap, + bskyClient: bsky, + ollama: o, + x402Wallet: cfg.X402WalletAddress, + activeNetworks: activeNetworks, } - // 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) MetricsText() string { - if s.metrics == nil { - return "" +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", + }, + }, + }, } - return s.metrics.MetricsText() + + 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 } 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 deleted file mode 100644 index 832d103..0000000 --- a/internal/server/server_init.go +++ /dev/null @@ -1,146 +0,0 @@ -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 deleted file mode 100644 index 4acb2a0..0000000 --- a/internal/server/settlement.go +++ /dev/null @@ -1,78 +0,0 @@ -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 deleted file mode 100644 index 4ab3b24..0000000 --- a/internal/server/storefake_test.go +++ /dev/null @@ -1,214 +0,0 @@ -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 deleted file mode 100644 index 8015d50..0000000 --- a/internal/server/style.go +++ /dev/null @@ -1,360 +0,0 @@ -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 deleted file mode 100644 index 9051f18..0000000 --- a/internal/server/styleprofile_test.go +++ /dev/null @@ -1,375 +0,0 @@ -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 deleted file mode 100644 index bb91c3d..0000000 --- a/internal/server/tools.go +++ /dev/null @@ -1,501 +0,0 @@ -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 deleted file mode 100644 index 2cc9cf8..0000000 --- a/internal/server/twitter_analytics_test.go +++ /dev/null @@ -1,162 +0,0 @@ -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 7d833c6..ec0e8d4 100644 --- a/internal/server/types.go +++ b/internal/server/types.go @@ -1,7 +1,5 @@ package server -import "github.com/nacorid/naco-api/internal/actor" - type Bodyfield struct { Type string `json:"type"` Description string `json:"description"` @@ -26,8 +24,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 { @@ -38,14 +36,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 (Bluesky only). Defaults to 'app.bsky.feed.post'"` + Collection string `json:"collection" jsonschema_description:"Optional collection the post belongs to"` } type GetPostResponsePost struct { @@ -59,12 +57,12 @@ type GetPostResponsePost struct { } type GetFeedRequest struct { - 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"` + URI string `json:"uri" jsonschema_description:"The URI of the feed to retrieve"` } 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 { @@ -80,47 +78,6 @@ 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"` @@ -140,134 +97,3 @@ 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 deleted file mode 100644 index f33aef0..0000000 --- a/internal/server/validation_test.go +++ /dev/null @@ -1,261 +0,0 @@ -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 deleted file mode 100644 index adda05e..0000000 --- a/internal/server/webhook.go +++ /dev/null @@ -1,125 +0,0 @@ -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 deleted file mode 100644 index c0e4493..0000000 --- a/internal/store/AnalyticsCache.sql.go +++ /dev/null @@ -1,96 +0,0 @@ -// 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 deleted file mode 100644 index 9010166..0000000 --- a/internal/store/AnalyticsCoverage.sql.go +++ /dev/null @@ -1,96 +0,0 @@ -// 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 deleted file mode 100644 index f68dd24..0000000 --- a/internal/store/AnalyticsPosts.sql.go +++ /dev/null @@ -1,113 +0,0 @@ -// 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 deleted file mode 100644 index e9680d7..0000000 --- a/internal/store/DeleteStylePosts.sql.go +++ /dev/null @@ -1,25 +0,0 @@ -// 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 b2d4767..6211da2 100644 --- a/internal/store/FindSimilarUsers.sql.go +++ b/internal/store/FindSimilarUsers.sql.go @@ -20,8 +20,8 @@ LIMIT $2 ` type FindSimilarUsersParams struct { - QueryEmbedding interface{} - LimitVal int32 + Column1 interface{} + Limit 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.QueryEmbedding, arg.LimitVal) + rows, err := q.db.QueryContext(ctx, findSimilarUsers, arg.Column1, arg.Limit) if err != nil { return nil, err } diff --git a/internal/store/GetStylePostByID.sql.go b/internal/store/GetStylePostByID.sql.go deleted file mode 100644 index b3ca156..0000000 --- a/internal/store/GetStylePostByID.sql.go +++ /dev/null @@ -1,33 +0,0 @@ -// 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 deleted file mode 100644 index 34c443d..0000000 --- a/internal/store/GetStylePosts.sql.go +++ /dev/null @@ -1,51 +0,0 @@ -// 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 deleted file mode 100644 index fb5c417..0000000 --- a/internal/store/GetStylePostsByIDs.sql.go +++ /dev/null @@ -1,51 +0,0 @@ -// 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 153823e..e86003c 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, model_version, schema_version, algorithm_version FROM user_style_profiles +SELECT id, user_id, platform, embedding, top_keywords, style_summary, post_count, modified_at, created_at FROM user_style_profiles WHERE user_id = $1 AND platform = $2 ` @@ -34,9 +34,6 @@ 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 deleted file mode 100644 index d2d6ae3..0000000 --- a/internal/store/PruneStylePosts.sql.go +++ /dev/null @@ -1,20 +0,0 @@ -// 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 deleted file mode 100644 index 49eff57..0000000 --- a/internal/store/UpsertStylePosts.sql.go +++ /dev/null @@ -1,34 +0,0 @@ -// 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 e6d431a..9e80301 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::vector, $4, $5, $6) +VALUES ($1, $2, $3, $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, - modified_at = NOW() + updated_at = NOW() ` type UpsertStyleProfileParams struct { diff --git a/internal/store/integration_test.go b/internal/store/integration_test.go deleted file mode 100644 index 5c9aeaf..0000000 --- a/internal/store/integration_test.go +++ /dev/null @@ -1,194 +0,0 @@ -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 deleted file mode 100644 index b6d7932..0000000 --- a/internal/store/migrate.go +++ /dev/null @@ -1,106 +0,0 @@ -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 2bba824..eac3136 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -6,43 +6,8 @@ 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 @@ -54,26 +19,14 @@ type Transaction struct { 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 + ID int64 + UserID string + Platform string + Embedding interface{} + TopKeywords []string + StyleSummary string + PostCount int32 + ModifiedAt sql.NullTime + CreatedAt sql.NullTime } diff --git a/internal/twitter/client.go b/internal/twitter/client.go deleted file mode 100644 index f849796..0000000 --- a/internal/twitter/client.go +++ /dev/null @@ -1,369 +0,0 @@ -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 deleted file mode 100644 index 87a6024..0000000 --- a/internal/twitter/client_test.go +++ /dev/null @@ -1,445 +0,0 @@ -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 cb475a4..051d420 100644 --- a/internal/utils/styleProfiles.go +++ b/internal/utils/styleProfiles.go @@ -7,26 +7,12 @@ import ( "strings" ) -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) { +func ParseEmbedding(value interface{}) ([]float64, error) { switch v := value.(type) { case []float64: return v, nil - case []any: + case []interface{}: vec := make([]float64, len(v)) for i, x := range v { f, ok := x.(float64) @@ -38,52 +24,35 @@ func ParseEmbedding(value any) ([]float64, error) { return vec, nil case string: - return parseVectorString(v) - - case []byte: - return parseVectorString(string(v)) + // 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 default: return nil, fmt.Errorf("unexpected embedding type: %T", v) } } -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 { - if len(v) == 0 { - continue - } - if dim == 0 { - dim = len(v) - } - valid = append(valid, v) - } - if len(valid) == 0 { - return nil - } - + dim := len(vs[0]) avg := make([]float64, dim) - for _, v := range valid { - for i := 0; i < dim && i < len(v); i++ { - avg[i] += v[i] + + for _, v := range vs { + for i, val := range v { + avg[i] += val } } for i := range avg { - avg[i] /= float64(len(valid)) + avg[i] /= float64(len(vs)) } return avg } @@ -91,7 +60,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.FieldsSeq(strings.ToLower(p)) { + for _, w := range strings.Fields(strings.ToLower(p)) { if len(w) < 4 { continue } diff --git a/internal/utils/styleProfiles_test.go b/internal/utils/styleProfiles_test.go deleted file mode 100644 index 4692b95..0000000 --- a/internal/utils/styleProfiles_test.go +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index 8d69b01..0000000 --- a/internal/x402/mcpwrapper.go +++ /dev/null @@ -1,156 +0,0 @@ -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 28eeb5c..2d73329 100644 --- a/internal/x402/x402.go +++ b/internal/x402/x402.go @@ -1,23 +1,15 @@ 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" @@ -25,9 +17,8 @@ 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 { @@ -54,23 +45,11 @@ 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), networks []string) (*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)) (*X402Server, error) { mcpServer := mcp.NewServer( &mcp.Implementation{ Name: name, @@ -83,29 +62,18 @@ func NewX402Server(name, version string, facilitatorURL string, auth x402http.Au } if facilitatorURL == "" { - facilitatorURL = "https://api.cdp.coinbase.com/platform/v2/x402" + facilitatorURL = "https://v2.facilitator.mogami.tech" } facilitator := x402http.NewHTTPFacilitatorClient(&x402http.FacilitatorConfig{ URL: facilitatorURL, AuthProvider: auth, - Timeout: 600 * time.Second, }) resourceServer := x402.Newx402ResourceServer( x402.WithFacilitatorClient(facilitator), ) - // 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 - } + resourceServer.Register("eip155:8453", evm.NewExactEvmScheme()) s := &X402Server{ mcpServer: mcpServer, @@ -118,29 +86,11 @@ 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) @@ -153,32 +103,6 @@ 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) @@ -200,45 +124,19 @@ 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 { - 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...) + log.Fatalf("tool %s requires at least one payment requirement", tool.Name) } tool.InputSchema = toJSONSchema(tool.InputSchema) tool.OutputSchema = toJSONSchema(tool.OutputSchema) - if resolver != nil { - s.settleResolvers[tool.Name] = resolver - } - - wrapper := NewPaymentWrapper(s.x402ResourceServer, PaymentWrapperConfig{ - Accepts: mcpAccepts, - SettleAmountResolver: resolver, - Resource: &types.ResourceInfo{ + wrapper := x402mcp.NewPaymentWrapper(s.x402ResourceServer, x402mcp.PaymentWrapperConfig{ + Accepts: requirements, + Resource: &x402mcp.ResourceInfo{ URL: "mcp://tool/" + tool.Name, Description: tool.Description, }, @@ -256,136 +154,35 @@ 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), - MaxTimeoutSeconds: req.MaxTimeoutSeconds, - Extra: req.Extra, + Scheme: req.Scheme, + PayTo: req.PayTo, + Price: req.Amount, + Network: x402.Network(req.Network), }) } - 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{ + paymentMiddleware := nethttp.X402Payment(nethttp.Config{ Routes: x402http.RoutesConfig{ "POST /" + VERSION1 + tool.Name: { Accepts: options, Description: tool.Description, }, }, - 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) + Facilitator: s.facilitatorClient, + Schemes: []nethttp.SchemeConfig{ + {Network: "eip155:8453", Server: evm.NewExactEvmScheme()}, }, + 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) { - defer r.Body.Close() - bodyBytes, err := io.ReadAll(io.LimitReader(r.Body, 5*1024*1024)) + bodyBytes, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "Failed to read body", http.StatusBadRequest) return @@ -407,31 +204,19 @@ 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 { - 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)) + w.Write([]byte(textContent.Text)) return } } } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(resp) + json.NewEncoder(w).Encode(resp) } func (s *X402Server) OnAfterSettle(hook func(ctx x402.SettleResultContext) error) { @@ -522,23 +307,6 @@ 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) @@ -548,33 +316,15 @@ 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), - ReadTimeout: 15 * time.Second, - WriteTimeout: 60 * time.Second, - IdleTimeout: 120 * time.Second, + Handler: s.globalReqLog(s.mux), + ErrorLog: slog.NewLogLogger(slog.Default().Handler(), slog.LevelDebug), } - - 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"}`) @@ -635,203 +385,9 @@ 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 _, middleware := range slices.Backward(middlewares) { - next = middleware(next) + for i := len(middlewares) - 1; i >= 0; i-- { + next = middlewares[i](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 f26e9ca..19f8036 100644 --- a/internal/x402/x402_test.go +++ b/internal/x402/x402_test.go @@ -3,19 +3,14 @@ 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{} @@ -36,7 +31,6 @@ 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) @@ -52,7 +46,6 @@ func TestX402Server_HTTPBoundaries(t *testing.T) { Content: []mcp.Content{&mcp.TextContent{Text: `{"status": "success"}`}}, }, nil }, - nil, x402.PaymentRequirements{ Scheme: "exact", Network: "eip155:8453", @@ -109,7 +102,6 @@ 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) @@ -125,7 +117,6 @@ func TestX402Server_MCPBoundaries(t *testing.T) { Content: []mcp.Content{&mcp.TextContent{Text: `{"status": "success"}`}}, }, nil }, - nil, x402.PaymentRequirements{ Scheme: "exact", Network: "eip155:8453", @@ -139,14 +130,14 @@ func TestX402Server_MCPBoundaries(t *testing.T) { if err != nil { t.Fatalf("failed to connect server session: %v", err) } - defer func() { _ = serverSession.Close() }() + defer 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() }() + defer clientSession.Close() toolsResponse, err := clientSession.ListTools(ctx, nil) if err != nil { @@ -182,313 +173,6 @@ func TestX402Server_MCPBoundaries(t *testing.T) { } } -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(`{ - "kinds": [ - { - "x402Version": 2, - "scheme": "exact", - "network": "eip155:8453" - } - ] - }`)) - 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) - } - })) - 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") @@ -501,11 +185,6 @@ func setupMockFacilitator() *httptest.Server { "x402Version": 2, "scheme": "exact", "network": "eip155:8453" - }, - { - "x402Version": 2, - "scheme": "upto", - "network": "eip155:84532" } ] }`)) @@ -527,502 +206,3 @@ func setupMockFacilitator() *httptest.Server { })) 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 d852b7b..d9e183e 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/mcpserver +ExecStart=/home/ubuntu/gitSources/mcpServer/bin/main RuntimeDirectory=mcpserver RuntimeDirectoryMode=0750 diff --git a/scripts/fund-wallet.sh b/scripts/fund-wallet.sh deleted file mode 100755 index 7ad808d..0000000 --- a/scripts/fund-wallet.sh +++ /dev/null @@ -1,62 +0,0 @@ -#!/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 deleted file mode 100755 index 1ad0b07..0000000 --- a/scripts/start-test-server.sh +++ /dev/null @@ -1,101 +0,0 @@ -#!/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 deleted file mode 100644 index 670da7e..0000000 --- a/sql/queries/AnalyticsCache.sql +++ /dev/null @@ -1,17 +0,0 @@ --- 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 deleted file mode 100644 index 0aa7ad0..0000000 --- a/sql/queries/AnalyticsCoverage.sql +++ /dev/null @@ -1,14 +0,0 @@ --- 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 deleted file mode 100644 index 6f7c31e..0000000 --- a/sql/queries/AnalyticsPosts.sql +++ /dev/null @@ -1,17 +0,0 @@ --- 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 deleted file mode 100644 index 163eaae..0000000 --- a/sql/queries/DeleteStylePosts.sql +++ /dev/null @@ -1,3 +0,0 @@ --- 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 90e74c1..25490ef 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 <=> @query_embedding::vector) AS similarity + 1 - (embedding <=> $1::vector) AS similarity FROM user_style_profiles -ORDER BY embedding <=> @query_embedding::vector -LIMIT @limit_val; \ No newline at end of file +ORDER BY embedding <=> $1::vector +LIMIT $2; \ No newline at end of file diff --git a/sql/queries/GetStylePostByID.sql b/sql/queries/GetStylePostByID.sql deleted file mode 100644 index 255531c..0000000 --- a/sql/queries/GetStylePostByID.sql +++ /dev/null @@ -1,3 +0,0 @@ --- 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 deleted file mode 100644 index 3c1ca49..0000000 --- a/sql/queries/GetStylePosts.sql +++ /dev/null @@ -1,5 +0,0 @@ --- 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 deleted file mode 100644 index 932b794..0000000 --- a/sql/queries/GetStylePostsByIDs.sql +++ /dev/null @@ -1,3 +0,0 @@ --- 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 a085e12..d148ff6 100644 --- a/sql/queries/GetStyleProfile.sql +++ b/sql/queries/GetStyleProfile.sql @@ -1,3 +1,3 @@ -- name: GetStyleProfile :one -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 +SELECT * FROM user_style_profiles +WHERE user_id = $1 AND platform = $2; \ No newline at end of file diff --git a/sql/queries/PruneStylePosts.sql b/sql/queries/PruneStylePosts.sql deleted file mode 100644 index 064140d..0000000 --- a/sql/queries/PruneStylePosts.sql +++ /dev/null @@ -1,3 +0,0 @@ --- 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 deleted file mode 100644 index 2c669bf..0000000 --- a/sql/queries/UpsertStylePosts.sql +++ /dev/null @@ -1,5 +0,0 @@ --- 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 d797f51..b65b61b 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 (@user_id, @platform, @embedding::vector, @top_keywords, @style_summary, @post_count) +VALUES ($1, $2, $3, $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, - modified_at = NOW(); \ No newline at end of file + updated_at = NOW(); \ No newline at end of file diff --git a/sql/schema/001_transactions.sql b/sql/schema/001_transactions.sql index 612215f..700b6b8 100644 --- a/sql/schema/001_transactions.sql +++ b/sql/schema/001_transactions.sql @@ -1,5 +1,5 @@ -- +goose up -CREATE TABLE IF NOT EXISTS transactions ( +CREATE TABLE 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 6241a7e..f3efcb4 100644 --- a/sql/schema/002_user_style_profiles.sql +++ b/sql/schema/002_user_style_profiles.sql @@ -1,11 +1,9 @@ -- +goose up -CREATE EXTENSION IF NOT EXISTS vector; - -CREATE TABLE IF NOT EXISTS user_style_profiles ( +CREATE TABLE user_style_profiles ( id BIGSERIAL PRIMARY KEY, user_id TEXT NOT NULL, platform TEXT NOT NULL, - embedding VECTOR(1024) NOT NULL, + embedding VECTOR(768) NOT NULL, top_keywords TEXT[] NOT NULL, style_summary TEXT NOT NULL, post_count INTEGER NOT NULL, @@ -15,6 +13,4 @@ CREATE TABLE IF NOT EXISTS user_style_profiles ( ); -- +goose down -DROP TABLE IF EXISTS user_style_profiles; - -DROP EXTENSION IF EXISTS vector; \ No newline at end of file +DROP TABLE IF EXISTS user_style_profiles; \ No newline at end of file diff --git a/sql/schema/003_add_cache_version.sql b/sql/schema/003_add_cache_version.sql deleted file mode 100644 index 4fdbf1e..0000000 --- a/sql/schema/003_add_cache_version.sql +++ /dev/null @@ -1,15 +0,0 @@ --- +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 deleted file mode 100644 index ae5bf08..0000000 --- a/sql/schema/004_add_style_posts.sql +++ /dev/null @@ -1,17 +0,0 @@ --- +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 deleted file mode 100644 index 3dd106c..0000000 --- a/sql/schema/005_add_analytics_cache.sql +++ /dev/null @@ -1,23 +0,0 @@ --- +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 deleted file mode 100644 index 36fc965..0000000 --- a/sql/schema/006_add_analytics_posts.sql +++ /dev/null @@ -1,22 +0,0 @@ --- +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 deleted file mode 100644 index 148e72d..0000000 --- a/sql/schema/007_add_analytics_coverage.sql +++ /dev/null @@ -1,21 +0,0 @@ --- +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 new file mode 100644 index 0000000..394d2c1 --- /dev/null +++ b/testClient/main.go @@ -0,0 +1,121 @@ +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 deleted file mode 100644 index 79b35cb..0000000 --- a/testClient/mcp/test-mcp-endpoints.go +++ /dev/null @@ -1,188 +0,0 @@ -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 deleted file mode 100644 index c9e9f7b..0000000 --- a/testClient/rest/test-rest-endpoints.go +++ /dev/null @@ -1,196 +0,0 @@ -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) - } -}