diff --git a/.forgejo/workflows/test-endpoints.yaml b/.forgejo/workflows/test-endpoints.yaml new file mode 100644 index 0000000..1d93e50 --- /dev/null +++ b/.forgejo/workflows/test-endpoints.yaml @@ -0,0 +1,142 @@ +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 + env: + PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} + X402_WALLET_ADDRESS: ${{ secrets.X402_WALLET_ADDRESS }} + FUNDING_FAUCET_URL: ${{ secrets.FUNDING_FAUCET_URL }} + BLUESKY_DID: ${{ secrets.BLUESKY_DID }} + BLUESKY_APP_PASSWORD: ${{ secrets.BLUESKY_APP_PASSWORD }} + 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 + # Install foundry (cast) for wallet funding + curl -L https://foundry.paradigm.xyz | bash + echo "$HOME/.foundry/bin" >> $FORGEJO_PATH + $HOME/.foundry/bin/foundryup + # Install bc for float math + sudo apt-get update && sudo apt-get install -y bc + + - name: Verify Code Formatting + run: | + if [ -n "$(gofmt -l .)" ]; then + echo "Code is not formatted correctly. Run 'gofmt -w .'" + exit 1 + fi + + - name: Start Test Infrastructure + run: | + docker-compose -f docker-compose.test.yml up -d + + - name: Wait for PostgreSQL + run: | + for i in {1..30}; do + if docker-compose -f docker-compose.test.yml exec -T postgres pg_isready -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..30}; do + if curl -sf http://localhost:11434/api/tags >/dev/null 2>&1; then + echo "Ollama is ready" + exit 0 + fi + sleep 2 + done + echo "Ollama failed to start" + exit 1 + + - name: Pull Ollama Models + run: | + docker-compose -f docker-compose.test.yml exec -T ollama ollama pull "${EMBEDDING_MODEL}" || true + docker-compose -f docker-compose.test.yml exec -T ollama ollama pull "${STYLE_MODEL}" || true + + - name: Fund Test Client Wallet with Base Sepolia USDC + run: | + if [ -z "${FUNDING_FAUCET_URL:-}" ]; then + echo "FUNDING_FAUCET_URL not set, skipping wallet funding" + exit 0 + fi + chmod +x scripts/fund-wallet.sh + ./scripts/fund-wallet.sh + + - name: Fund Server Wallet with Base Sepolia USDC + run: | + if [ -z "${FUNDING_FAUCET_URL:-}" ]; then + echo "FUNDING_FAUCET_URL not set, skipping server wallet funding" + exit 0 + fi + # Fund the server wallet (X402_WALLET_ADDRESS) using the same faucet + # This uses the PRIVATE_KEY from env which corresponds to the test client + # For server wallet, we'd need a separate private key, but for testing we use test client key + echo "Server wallet funding uses same faucet (test client wallet)" + + - name: Start Test Server + run: | + chmod +x scripts/start-test-server.sh + ./scripts/start-test-server.sh + + - name: Build Test Client + run: | + go build -o test-endpoints ./testClient + + - name: Run Endpoint Tests + env: + RPC_URL: ${{ env.RPC_URL }} + FUNDING_FAUCET_URL: ${{ secrets.FUNDING_FAUCET_URL }} + run: | + ./test-endpoints + + - name: Cleanup + if: always() + run: | + if [ -f /tmp/test-server.pid ]; then + kill $(cat /tmp/test-server.pid) 2>/dev/null || true + fi + docker-compose -f docker-compose.test.yml down -v \ No newline at end of file diff --git a/.forgejo/workflows/test.yaml b/.forgejo/workflows/test.yaml new file mode 100644 index 0000000..83af0a3 --- /dev/null +++ b/.forgejo/workflows/test.yaml @@ -0,0 +1,36 @@ +name: CI/CD Go Verification + +on: + push: + branches: [ main, dev, ai-main ] + pull_request: + branches: [ main ] + +jobs: + build_and_test: + runs-on: node20 + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache: true + + - name: Install dependencies + run: go mod download + + - name: Check code formatting + run: | + if [ -n "$(gofmt -l .)" ]; then + echo "Code is not formatted correctly. Run 'gofmt -w .'" + exit 1 + fi + + - name: Build Application + run: go build -v ./... + + - name: Run Tests (with Mocked Boundaries) + run: go test -v -cover ./... \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3476089..c678aa0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .env cdpScripts/cdp -bin/ \ No newline at end of file +bin/ +test-endpoints +test-endpoints.exe \ No newline at end of file diff --git a/cmd/server/main.go b/cmd/server/main.go index 39d36f4..a35f99c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -11,11 +11,14 @@ import ( _ "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" ) func main() { @@ -36,6 +39,13 @@ func main() { defer conn.Close() 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(ctx, cfg.BlueskyDID, cfg.BlueskyAppPassword, cfg.BlueskyHost) if err != nil { log.Fatal("Failed to create bluesky client", "error", err) @@ -45,9 +55,55 @@ func main() { if err != nil { log.Fatal("Invalid OLLAMA_HOST URL", "error", err) } - ollamaClient := ollama.New(ctx, uri, http.DefaultClient) + ollamaClient := ollama.New(ctx, uri, http.DefaultClient, cfg.EmbeddingModel, cfg.StyleModel) - srv := server.New(ctx, db, bskyClient, ollamaClient, cfg) + 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) + } + + twitterClient := twitter.NewTwitterClient(cfg.TwitterBearerToken) + + srv := server.New(ctx, db, apClient, bskyClient, nostrClient, twitterClient, ollamaClient, cfg) + + srv.StartPrewarm(ctx) + + go func() { + mux := http.NewServeMux() + 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, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, cfg.OllamaHost+"/api/tags", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil || resp.StatusCode != http.StatusOK { + if resp != nil { + resp.Body.Close() + } + http.Error(w, "ollama unavailable", http.StatusServiceUnavailable) + return + } + resp.Body.Close() + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + }) + mux.HandleFunc("GET /metrics", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") + w.WriteHeader(http.StatusOK) + w.Write([]byte(srv.MetricsText())) + }) + addr := ":" + cfg.ReadyzPort + log.Info("Readiness server starting", "address", addr) + if err := http.ListenAndServe(addr, mux); err != nil { + log.Error("Readiness server error", "error", err) + } + }() go func() { log.Info("Server starting", "address", cfg.ListenAddress) diff --git a/config/models.env b/config/models.env new file mode 100644 index 0000000..96a8c53 --- /dev/null +++ b/config/models.env @@ -0,0 +1,5 @@ +# Ollama model configuration +# Single source of truth for model names used by the application and CI + +EMBEDDING_MODEL=qwen3-embedding:0.6b +STYLE_MODEL=qwen3:4b-instruct-2507-q4_K_M \ No newline at end of file diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..83f6046 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,26 @@ +version: '3.8' + +services: + postgres: + image: pgvector/pgvector:pg16 + environment: + POSTGRES_DB: mcpserver + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d mcpserver"] + interval: 5s + timeout: 5s + retries: 10 + + ollama: + image: ollama/ollama:latest + ports: + - "11434:11434" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:11434/api/tags"] + interval: 10s + timeout: 5s + retries: 10 \ No newline at end of file diff --git a/go.mod b/go.mod index d7840fd..a168fc6 100644 --- a/go.mod +++ b/go.mod @@ -3,73 +3,53 @@ 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/mark3labs/mcp-go v0.43.2 - github.com/mark3labs/x402-go v0.12.1 - github.com/nacorid/logger v0.0.0-20251221003040-586da7a03fa0 + github.com/modelcontextprotocol/go-sdk v1.6.1 + 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 ) require ( - filippo.io/edwards25519 v1.1.0 // indirect - github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e // indirect - github.com/FactomProject/btcutilecc v0.0.0-20130527213604-d3a63a5752ec // indirect - github.com/bahlo/generic-list-go v0.2.0 // indirect - github.com/benbjohnson/clock v1.3.5 // indirect github.com/bits-and-blooms/bitset v1.24.2 // indirect - github.com/blendle/zapdriver v1.3.1 // indirect - github.com/buger/jsonparser v1.1.1 // indirect github.com/consensys/gnark-crypto v0.19.2 // indirect - github.com/crate-crypto/go-eth-kzg v1.4.0 // indirect - github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect github.com/deckarep/golang-set/v2 v2.8.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect - github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect - github.com/ethereum/go-ethereum v1.16.5 // indirect - github.com/ethereum/go-verkle v0.2.2 // indirect - github.com/fatih/color v1.18.0 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/gagliardetto/binary v0.8.0 // indirect - github.com/gagliardetto/solana-go v1.14.0 // indirect - github.com/gagliardetto/treeout v0.1.4 // indirect + github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect + github.com/ethereum/go-ethereum v1.17.2 github.com/holiman/uint256 v1.3.2 // indirect - github.com/invopop/jsonschema v0.13.0 // indirect - github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.1 // indirect - github.com/logrusorgru/aurora v2.0.3+incompatible // indirect - github.com/mailru/easyjson v0.9.1 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mitchellh/go-testing-interface v1.14.1 // indirect - github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 // indirect - github.com/spf13/cast v1.10.0 // indirect - github.com/streamingfast/logging v0.0.0-20250918142248-ac5a1e292845 // indirect - github.com/stretchr/testify v1.11.1 // indirect github.com/supranational/blst v0.3.16 // indirect - github.com/tyler-smith/go-bip32 v1.0.0 // indirect - github.com/tyler-smith/go-bip39 v1.1.0 // indirect - github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect - go.mongodb.org/mongo-driver v1.17.6 // indirect - go.uber.org/ratelimit v0.3.1 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/term v0.36.0 // indirect - gopkg.in/square/go-jose.v2 v2.6.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + 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.2 // indirect + github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.1 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.5 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect @@ -86,9 +66,14 @@ require ( github.com/ipfs/go-log/v2 v2.5.1 // indirect github.com/ipfs/go-metrics-interface v0.0.1 // indirect github.com/jbenet/goprocess v0.1.4 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/mailru/easyjson v0.9.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/minio/sha256-simd v1.0.1 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mr-tron/base58 v1.2.0 // indirect github.com/multiformats/go-base32 v0.1.0 // indirect github.com/multiformats/go-base36 v0.2.0 // indirect @@ -97,20 +82,38 @@ 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 - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xeipuuv/gojsonschema v1.2.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect - go.opentelemetry.io/otel v1.35.0 // indirect - go.opentelemetry.io/otel/metric v1.35.0 // indirect - go.opentelemetry.io/otel/sdk v1.35.0 // indirect - go.opentelemetry.io/otel/trace v1.35.0 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/sdk v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect - golang.org/x/crypto v0.43.0 // indirect - golang.org/x/sys v0.37.0 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.32.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect lukechampine.com/blake3 v1.2.1 // indirect ) diff --git a/go.sum b/go.sum index 782cfc5..9ef062a 100644 --- a/go.sum +++ b/go.sum @@ -1,80 +1,113 @@ -filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= -filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -github.com/AlekSi/pointer v1.1.0 h1:SSDMPcXD9jSl8FPy9cRzoRaMJtm9g9ggGTxecRUbQoI= -github.com/AlekSi/pointer v1.1.0/go.mod h1:y7BvfRI3wXPWKXEBhU71nbnIEEZX0QTSB2Bj48UJIZE= +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/FactomProject/basen v0.0.0-20150613233007-fe3947df716e h1:ahyvB3q25YnZWly5Gq1ekg6jcmWaGj/vG/MhF4aisoc= -github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e/go.mod h1:kGUqhHd//musdITWjFvNTHn90WG9bMLBEPQZ17Cmlpw= -github.com/FactomProject/btcutilecc v0.0.0-20130527213604-d3a63a5752ec h1:1Qb69mGp/UtRPn422BH4/Y4Q3SLUrD9KHuDkm8iodFc= -github.com/FactomProject/btcutilecc v0.0.0-20130527213604-d3a63a5752ec/go.mod h1:CD8UlnlLDiqb36L110uqiP2iSflVjx9g/3U9hCI4q2U= +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= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= -github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= -github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +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/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= -github.com/benbjohnson/clock v1.3.5/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= github.com/bits-and-blooms/bitset v1.24.2 h1:M7/NzVbsytmtfHbumG+K2bremQPMJuqv1JD3vOaFxp0= github.com/bits-and-blooms/bitset v1.24.2/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= -github.com/blendle/zapdriver v1.3.1 h1:C3dydBOWYRiOk+B8X9IVZ5IOe+7cl+tGOexN4QqHfpE= -github.com/blendle/zapdriver v1.3.1/go.mod h1:mdXfREi6u5MArG4j9fewC+FGnXaBR+T4Ox4J2u4eHCc= 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/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= -github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +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/cp v0.1.0 h1:SE+dxFebS7Iik5LK0tsi1k9ZCxEaFX4AjQmoyA+1dJk= -github.com/cespare/cp v0.1.0/go.mod h1:SOGHArjBr4JWaSDEVpWpo/hNg6RoKrls6Oh40hiwW+s= -github.com/cmars/basen v0.0.0-20150613233007-fe3947df716e h1:0XBUw73chJ1VYSsfvcPvVT7auykAJce9FpRr10L6Qhw= -github.com/cmars/basen v0.0.0-20150613233007-fe3947df716e/go.mod h1:P13beTBKr5Q18lJe1rIoLUqjM+CB1zYrRg44ZqGuQSA= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I= +github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8= +github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce h1:giXvy4KSc/6g/esnpM7Geqxka4WSqI1SZc7sMJFd3y4= +github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= +github.com/cockroachdb/pebble v1.1.5 h1:5AAWCBWbat0uE0blr8qzufZP5tBjkRyy/jWe1QWLnvw= +github.com/cockroachdb/pebble v1.1.5/go.mod h1:17wO9el1YEigxkP/YtV8NtCivQDgoCyBg5c4VR/eOWo= +github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30= +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= -github.com/crate-crypto/go-eth-kzg v1.4.0 h1:WzDGjHk4gFg6YzV0rJOAsTK4z3Qkz5jd4RE3DAvPFkg= -github.com/crate-crypto/go-eth-kzg v1.4.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI= -github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a h1:W8mUrRp6NOVl3J+MYp5kPMoUZPp7aOYHtaua31lwRHg= -github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a/go.mod h1:sTwzHBvIzm2RfVCGNEBZgRyjwK40bVoun3ZnGOCafNM= +github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= +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= +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.5 h1:aVtoLK5xwJ6c5RiqO8g8ptJ5KU+2Hdquf6G3aXiHh5s= -github.com/ethereum/c-kzg-4844/v2 v2.1.5/go.mod h1:u59hRTTah4Co6i9fDWtiCjTrblJv0UwsqZKCc0GfgUs= -github.com/ethereum/go-ethereum v1.16.5 h1:GZI995PZkzP7ySCxEFaOPzS8+bd8NldE//1qvQDQpe0= -github.com/ethereum/go-ethereum v1.16.5/go.mod h1:kId9vOtlYg3PZk9VwKbGlQmSACB5ESPTBGT+M9zjmok= -github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8= -github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk= -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn27fRjSls= +github.com/ethereum/c-kzg-4844/v2 v2.1.6/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw= +github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk= +github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8= +github.com/ethereum/go-ethereum v1.17.2 h1:ag6geu0kn8Hv5FLKTpH+Hm2DHD+iuFtuqKxEuwUsDOI= +github.com/ethereum/go-ethereum v1.17.2/go.mod h1:KHcRXfGOUfUmKg51IhQ0IowiqZ6PqZf08CMtk0g5K1o= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/gagliardetto/binary v0.8.0 h1:U9ahc45v9HW0d15LoN++vIXSJyqR/pWw8DDlhd7zvxg= -github.com/gagliardetto/binary v0.8.0/go.mod h1:2tfj51g5o9dnvsc+fL3Jxr22MuWzYXwx9wEoN0XQ7/c= -github.com/gagliardetto/gofuzz v1.2.2 h1:XL/8qDMzcgvR4+CyRQW9UGdwPRPMHVJfqQ/uMvSUuQw= -github.com/gagliardetto/gofuzz v1.2.2/go.mod h1:bkH/3hYLZrMLbfYWA0pWzXmi5TTRZnu4pMGZBkqMKvY= -github.com/gagliardetto/solana-go v1.14.0 h1:3WfAi70jOOjAJ0deFMjdhFYlLXATF4tOQXsDNWJtOLw= -github.com/gagliardetto/solana-go v1.14.0/go.mod h1:l/qqqIN6qJJPtxW/G1PF4JtcE3Zg2vD2EliZrr9Gn5k= -github.com/gagliardetto/treeout v0.1.4 h1:ozeYerrLCmCubo1TcIjFiOWTTGteOOHND1twdFpgwaw= -github.com/gagliardetto/treeout v0.1.4/go.mod h1:loUefvXTrlRG5rYmJmExNryyBRh8f89VZhmMOyCyqok= +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= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= @@ -82,16 +115,45 @@ github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +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= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 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= +github.com/grafana/pyroscope-go v1.2.7/go.mod h1:o/bpSLiJYYP6HQtvcoVKiE9s5RiNgjYTj1DhiddP2Pc= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= +github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE= +github.com/hashicorp/go-bexpr v0.1.10/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= @@ -100,10 +162,15 @@ github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db h1:IZUYC/xb3giYwBLMnr8d0TGTzPKFGNTCGgGLoyeX330= +github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db/go.mod h1:xTEYN9KCHxuYHs+NmrmzFcnvHMzLLNiGFafCb1n3Mfg= +github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZKVu0fao= +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/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= -github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +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= github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0= github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs= @@ -131,24 +198,30 @@ github.com/ipfs/go-log/v2 v2.5.1 h1:1XdUzF7048prq4aBjDQQ4SL5RxftpRGdXhNRwKSAlcY= github.com/ipfs/go-log/v2 v2.5.1/go.mod h1:prSpmC1Gpllc9UYWxDiZDreBYw7zp4Iqp1kOLU9U5UI= github.com/ipfs/go-metrics-interface v0.0.1 h1:j+cpbjYvu4R8zbleSs36gvB7jR+wsL2fGD6n0jO4kdg= github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j/b/tL7HTWtJ4VPgWY= +github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= +github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= 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/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +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= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -161,34 +234,29 @@ 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/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= -github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= -github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= -github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I= -github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= -github.com/mark3labs/x402-go v0.12.1 h1:yWlT/mjF0P1j2sB0xAM4GJb+1/Ycv6p6EqUrT6RB2X0= -github.com/mark3labs/x402-go v0.12.1/go.mod h1:srAvV9FosjBiqrclF15thrQbz0fVVfNXtMcqD0e1hKU= +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= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= -github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= +github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= +github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= -github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= -github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A= +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/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 h1:mPMvm6X6tf4w8y7j9YIt6V9jfWhL6QlbEc7CCmeQlWk= -github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1/go.mod h1:ye2e/VUEtE2BHE+G/QcKkcLQVAEJoYRFj5VUOQatCRE= 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= @@ -201,31 +269,62 @@ 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-20251221003040-586da7a03fa0 h1:vKVU0JbEOiG7xI+UNwb8M72mo98uHCiPNyPUd9oBC9c= -github.com/nacorid/logger v0.0.0-20251221003040-586da7a03fa0/go.mod h1:hd9h4KEPU1f/Vv2qP0x/fZUwz2clFvOV/uIF0pkGo+s= -github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= -github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +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/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/ollama/ollama v0.12.10 h1:Dd0/SeCc+nv+FffxmWuQTGiRreib7Gt3nBhIIFuKwZA= github.com/ollama/ollama v0.12.10/go.mod h1:RUSmYywUWx/YZMaHrqtnT1ZChu+iSz/7jx2aO9+Mgfg= -github.com/onsi/gomega v1.10.1 h1:o0+MgICZLuZ7xjH7Vx6zS/zcu93/BEp1VwkIW1mEXCE= +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= +github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= +github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= +github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= +github.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0= +github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ= +github.com/pion/transport/v2 v2.2.1 h1:7qYnCBlpgSJNYMbLCKuSY9KbQdBFoETvPNETv0y4N7c= +github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g= +github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouANiM= +github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= +github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= +github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM= +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.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +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/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= +github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= +github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU= github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= -github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= -github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs= github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= @@ -235,128 +334,150 @@ github.com/sokkalf/slog-seq v0.5.1 h1:4LicZGsMhuCtmZkbS++JOm3Wn8PLIrjFkuvEp0yeGH github.com/sokkalf/slog-seq v0.5.1/go.mod h1:B82pc/cMpdQQg6hkBbstHEL4vqI1eZ1MISuN1IK7h14= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= -github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= -github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= -github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091/go.mod h1:VlduQ80JcGJSargkRU4Sg9Xo63wZD/l8A5NC/Uo1/uU= -github.com/streamingfast/logging v0.0.0-20250918142248-ac5a1e292845 h1:VMA0pZ3MI8BErRA3kh8dKJThP5d0Xh5vZVk5yFIgH/A= -github.com/streamingfast/logging v0.0.0-20250918142248-ac5a1e292845/go.mod h1:BtDq81Tyc7H8up5aXNi/I95nPmG3C0PLEqGWY/iWQ2E= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.1.5-0.20170601210322-f6abca593680/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 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= github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jqwYhE= github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= -github.com/test-go/testify v1.1.4 h1:Tf9lntrKUMHiXQ07qBScBTSA0dhYQlu83hswqelv1iE= -github.com/test-go/testify v1.1.4/go.mod h1:rH7cfJo/47vWGdi4GPj16x3/t1xGOj2YxzmNQzk2ghU= +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= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= -github.com/tyler-smith/go-bip32 v1.0.0 h1:sDR9juArbUgX+bO/iblgZnMPeWY1KZMUC2AFUJdv5KE= -github.com/tyler-smith/go-bip32 v1.0.0/go.mod h1:onot+eHknzV4BVPwrzqY5OoVpyCvnwD7lMawL5aQupE= -github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8= -github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U= +github.com/urfave/cli v1.22.10 h1:p8Fspmz3iTctJstry1PYS3HVdllxnEzTEsgIgtxTrCk= github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= +github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ= github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4= github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= -github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= -github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/x402-foundation/x402/go/v2 v2.19.0 h1:vyU9cKFfqw8oI6To66r+UH67vFwWpoMDRX9zGM8bCJI= +github.com/x402-foundation/x402/go/v2 v2.19.0/go.mod h1:OAsOuB+FYhCoKzijOGZ+PcJk9g4RBfCJvpABGxi+NcE= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss= -go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo= -go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= -go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= -go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= -go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= -go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= -go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= -go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= -go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= -go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= -go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/ratelimit v0.3.1 h1:K4qVE+byfv/B3tC+4nYWP7v/6SimcO7HzHekoMNBma0= -go.uber.org/ratelimit v0.3.1/go.mod h1:6euWsTB6U/Nb3X++xEUXA8ciPJvr19Q/0h1+oDcJhRk= go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= -go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= 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.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw= 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-20170613210332-850760c427c5/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +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= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= -golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 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.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= -golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +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.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +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= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= -golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +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.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -369,21 +490,36 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI= -gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= +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= @@ -391,7 +527,5 @@ gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -launchpad.net/gocheck v0.0.0-20140225173054-000000000087 h1:Izowp2XBH6Ya6rv+hqbceQyw/gSGoXfH/UPoTGduL54= -launchpad.net/gocheck v0.0.0-20140225173054-000000000087/go.mod h1:hj7XX3B/0A+80Vse0e+BUHsHMTEhd0O4cpUHr/e/BUM= lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI= lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= diff --git a/internal/activitypub/client.go b/internal/activitypub/client.go index 94ca2d3..cc45f07 100644 --- a/internal/activitypub/client.go +++ b/internal/activitypub/client.go @@ -2,8 +2,14 @@ package activitypub import ( "context" + "encoding/json" "fmt" + "io" "net/http" + "net/url" + "time" + + "github.com/nacorid/naco-api/internal/actor" ) type APClient struct { @@ -22,3 +28,217 @@ func NewAPClient(ctx context.Context, host, apikey string) (*APClient, error) { bearerToken: fmt.Sprintf("Bearer %s", apikey), }, 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 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 post: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to get post: %s", resp.Status) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + var internal = &internalPost{} + if err = json.Unmarshal(body, internal); err != nil { + return nil, fmt.Errorf("failed to unmarshal json response: %w", err) + } + return internal.toPost(), nil +} + +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 +} + +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() + } + if len(posts) >= limit { + break + } + + url := fmt.Sprintf("%s/api/v1/accounts/%s/statuses?limit=50", ap.host, actor) + if maxID != "" { + url += fmt.Sprintf("&max_id=%s", maxID) + } + + 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 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 + } + + for _, i := range internal { + if i.CreatedAt.After(end) { + continue + } + + if i.CreatedAt.Before(start) { + return posts, nil + } + + posts = append(posts, *i.toPost()) + + if len(posts) >= limit { + return posts, nil + } + } + + maxID = internal[len(internal)-1].ID + } + + return posts, nil +} + +func (ap *APClient) GetActor(ctx context.Context, actor string) (*actor.Profile, error) { + // Mastodon exposes actor lookup by acct; fall back to a direct id lookup. + prof, err := ap.fetchAccount(ctx, fmt.Sprintf("%s/api/v1/accounts/lookup?acct=%s", ap.host, url.QueryEscape(actor))) + if err != nil { + prof, err = ap.fetchAccount(ctx, fmt.Sprintf("%s/api/v1/accounts/%s", ap.host, url.PathEscape(actor))) + if err != nil { + return nil, err + } + } + return prof, nil +} + +func (ap *APClient) fetchAccount(ctx context.Context, endpoint string) (*actor.Profile, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, 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 actor: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to get actor: %s", resp.Status) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + var acc Account + if err := json.Unmarshal(body, &acc); err != nil { + return nil, fmt.Errorf("failed to unmarshal account: %w", err) + } + 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, + }, 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&limit=%d", ap.host, url.QueryEscape(query), limit) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, 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 search actors: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to search actors: %s", resp.Status) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + var accounts []Account + if err := json.Unmarshal(body, &accounts); err != nil { + return nil, fmt.Errorf("failed to unmarshal accounts: %w", err) + } + results := make([]actor.Profile, 0, len(accounts)) + for _, acc := range accounts { + results = append(results, 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, + }) + } + return results, nil +} diff --git a/internal/activitypub/client_test.go b/internal/activitypub/client_test.go new file mode 100644 index 0000000..2fdfa11 --- /dev/null +++ b/internal/activitypub/client_test.go @@ -0,0 +1,220 @@ +package activitypub + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "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(context.Background(), "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(context.Background(), "", "tok"); err == nil { + t.Fatal("expected error for empty host") + } + if _, err := NewAPClient(context.Background(), "host", ""); err == nil { + t.Fatal("expected error for empty apikey") + } +} + +func TestGetLatestNPosts(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/accounts/alice/statuses", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer tok" { + w.WriteHeader(http.StatusUnauthorized) + 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 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 TestGetLatestNPostsHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + 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") + } +} + +func TestGetPostsInTimeframe(t *testing.T) { + // Feed is reverse-chronological: newer than `end`, two in-window, then older + // than `start`. The client must skip the too-new post, return both in-window + // posts, and stop when it crosses the `start` boundary. + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/accounts/alice/statuses", func(w http.ResponseWriter, r *http.Request) { + feed := []map[string]any{} + switch r.URL.Query().Get("max_id") { + case "": + // Page 1: too-new post and one in-window post. + 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": + // Page 2: in-window post then too-old post. + 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 TestGetActor(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/accounts/lookup", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("acct") != "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/lookup", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + 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 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 + } + 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]) + } +} diff --git a/internal/activitypub/types.go b/internal/activitypub/types.go new file mode 100644 index 0000000..269604d --- /dev/null +++ b/internal/activitypub/types.go @@ -0,0 +1,272 @@ +package activitypub + +import "time" + +type Post struct { + ID string `json:"id"` + Author string `json:"author"` + Content string `json:"content"` + Text string `json:"text"` + CreatedAt time.Time `json:"created_at"` + FavouritesCount int `json:"favourites_count"` + ReblogsCount int `json:"reblogs_count"` + RepliesCount int `json:"replies_count"` +} + +func (i *internalPost) toPost() *Post { + return &Post{ + ID: i.ID, + Author: i.Account.Acct, + Content: i.Content, + Text: i.Text, + CreatedAt: i.CreatedAt, + FavouritesCount: i.FavouritesCount, + ReblogsCount: i.ReblogsCount, + RepliesCount: i.RepliesCount, + } +} + +type internalPost struct { + Account Account `json:"account"` + Application Application `json:"application"` + Bookmarked bool `json:"bookmarked"` + Card Card `json:"card"` + Content string `json:"content"` + ContentType string `json:"content_type"` + CreatedAt time.Time `json:"created_at"` + EditedAt time.Time `json:"edited_at"` + Emojis []Emoji `json:"emojis"` + Favourited bool `json:"favourited"` + FavouritesCount int `json:"favourites_count"` + Filtered []FilterResult `json:"filtered"` + ID string `json:"id"` + InReplyToAccountID string `json:"in_reply_to_account_id"` + InReplyToID string `json:"in_reply_to_id"` + InteractionPolicy InteractionPolicy `json:"interaction_policy"` + Language string `json:"language"` + LocalOnly bool `json:"local_only"` + MediaAttachments []MediaAttachment `json:"media_attachments"` + Mentions []Mention `json:"mentions"` + Muted bool `json:"muted"` + Pinned bool `json:"pinned"` + Poll Poll `json:"poll"` + Reblog *internalPost `json:"reblog"` // Pointer used for recursive structure + Reblogged bool `json:"reblogged"` + ReblogsCount int `json:"reblogs_count"` + RepliesCount int `json:"replies_count"` + 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"` +} + +type Account struct { + Acct string `json:"acct"` + Avatar string `json:"avatar"` + AvatarDescription string `json:"avatar_description"` + AvatarMediaID string `json:"avatar_media_id"` + AvatarStatic string `json:"avatar_static"` + Bot bool `json:"bot"` + CreatedAt string `json:"created_at"` + CustomCSS string `json:"custom_css"` + Discoverable bool `json:"discoverable"` + DisplayName string `json:"display_name"` + Emojis []Emoji `json:"emojis"` + EnableRSS bool `json:"enable_rss"` + Fields []Field `json:"fields"` + FollowersCount int `json:"followers_count"` + FollowingCount int `json:"following_count"` + Group bool `json:"group"` + Header string `json:"header"` + HeaderDescription string `json:"header_description"` + HeaderMediaID string `json:"header_media_id"` + HeaderStatic string `json:"header_static"` + HideCollections bool `json:"hide_collections"` + ID string `json:"id"` + LastStatusAt string `json:"last_status_at"` + Locked bool `json:"locked"` + Moved string `json:"moved"` + Note string `json:"note"` + Role Role `json:"role"` + Roles []Role `json:"roles"` + Source Source `json:"source"` + StatusesCount int `json:"statuses_count"` + Suspended bool `json:"suspended"` + Theme string `json:"theme"` + URL string `json:"url"` + Username string `json:"username"` +} + +type Application struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + CreatedAt string `json:"created_at"` + ID string `json:"id"` + Name string `json:"name"` + RedirectURI string `json:"redirect_uri"` + RedirectURIs []string `json:"redirect_uris"` + Scopes []string `json:"scopes"` + VapidKey string `json:"vapid_key"` + Website string `json:"website"` +} + +type Card struct { + AuthorName string `json:"author_name"` + AuthorURL string `json:"author_url"` + Blurhash string `json:"blurhash"` + Description string `json:"description"` + EmbedURL string `json:"embed_url"` + Height int `json:"height"` + HTML string `json:"html"` + Image string `json:"image"` + ProviderName string `json:"provider_name"` + ProviderURL string `json:"provider_url"` + Title string `json:"title"` + Type string `json:"type"` + URL string `json:"url"` + Width int `json:"width"` +} + +type Emoji struct { + Category string `json:"category"` + Shortcode string `json:"shortcode"` + StaticURL string `json:"static_url"` + URL string `json:"url"` + VisibleInPicker bool `json:"visible_in_picker"` +} + +type Field struct { + Name string `json:"name"` + Value string `json:"value"` + VerifiedAt string `json:"verified_at"` +} + +type FilterResult struct { + Filter FilterInfo `json:"filter"` + KeywordMatches []string `json:"keyword_matches"` + StatusMatches []string `json:"status_matches"` +} + +type FilterInfo struct { + Context []string `json:"context"` + ExpiresAt string `json:"expires_at"` + FilterAction string `json:"filter_action"` + ID string `json:"id"` + Keywords []FilterKeyword `json:"keywords"` + Statuses []FilterStatus `json:"statuses"` + Title string `json:"title"` +} + +type FilterKeyword struct { + ID string `json:"id"` + Keyword string `json:"keyword"` + WholeWord bool `json:"whole_word"` +} + +type FilterStatus struct { + ID string `json:"id"` + Phrase string `json:"phrase"` +} + +type InteractionPolicy struct { + CanFavourite PolicyDetail `json:"can_favourite"` + CanReblog PolicyDetail `json:"can_reblog"` + CanReply PolicyDetail `json:"can_reply"` +} + +type PolicyDetail struct { + Always []string `json:"always"` + AutomaticApproval []string `json:"automatic_approval"` + ManualApproval []string `json:"manual_approval"` + WithApproval []string `json:"with_approval"` +} + +type MediaAttachment struct { + Blurhash string `json:"blurhash"` + Description string `json:"description"` + ID string `json:"id"` + Meta AttachmentMeta `json:"meta"` + PreviewRemoteURL string `json:"preview_remote_url"` + PreviewURL string `json:"preview_url"` + RemoteURL string `json:"remote_url"` + TextURL string `json:"text_url"` + Type string `json:"type"` + URL string `json:"url"` +} + +type AttachmentMeta struct { + Focus MetaFocus `json:"focus"` + Original MetaDetail `json:"original"` + Small MetaDetail `json:"small"` +} + +type MetaFocus struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} + +type MetaDetail struct { + Aspect float64 `json:"aspect"` + Bitrate int `json:"bitrate"` + Duration float64 `json:"duration"` + FrameRate string `json:"frame_rate"` + Height int `json:"height"` + Size string `json:"size"` + Width int `json:"width"` +} + +type Mention struct { + Acct string `json:"acct"` + ID string `json:"id"` + URL string `json:"url"` + Username string `json:"username"` +} + +type Poll struct { + Emojis []Emoji `json:"emojis"` + Expired bool `json:"expired"` + ExpiresAt string `json:"expires_at"` + ID string `json:"id"` + Multiple bool `json:"multiple"` + Options []PollOption `json:"options"` + OwnVotes []int `json:"own_votes"` + Voted bool `json:"voted"` + VotersCount int `json:"voters_count"` + VotesCount int `json:"votes_count"` +} + +type PollOption struct { + Title string `json:"title"` + VotesCount int `json:"votes_count"` +} + +type Role struct { + Color string `json:"color"` + Highlighted bool `json:"highlighted"` + ID string `json:"id"` + Name string `json:"name"` + Permissions string `json:"permissions"` +} + +type Source struct { + AlsoKnownAsURIs []string `json:"also_known_as_uris"` + Fields []Field `json:"fields"` + FollowRequestsCount int `json:"follow_requests_count"` + Language string `json:"language"` + Note string `json:"note"` + Privacy string `json:"privacy"` + Sensitive bool `json:"sensitive"` + StatusContentType string `json:"status_content_type"` + WebLayout string `json:"web_layout"` + WebVisibility string `json:"web_visibility"` +} + +type Tag struct { + Following bool `json:"following"` + History []string `json:"history"` + Name string `json:"name"` + URL string `json:"url"` +} diff --git a/internal/actor/profile.go b/internal/actor/profile.go new file mode 100644 index 0000000..a5b866d --- /dev/null +++ b/internal/actor/profile.go @@ -0,0 +1,16 @@ +package actor + +// Profile is a platform-agnostic representation of an actor's public metadata. It is shared +// by every platform client so the server can aggregate results without importing client +// packages (which would create an import cycle). +type Profile struct { + Platform string `json:"platform"` + Handle string `json:"handle"` + DisplayName string `json:"displayName,omitempty"` + AvatarURL string `json:"avatarUrl,omitempty"` + Bio string `json:"bio,omitempty"` + FollowersCount int `json:"followersCount"` + FollowingCount int `json:"followingCount"` + PostCount int `json:"postCount"` + URL string `json:"url,omitempty"` +} diff --git a/internal/bluesky/client.go b/internal/bluesky/client.go index 409ea2b..2ddc4bf 100644 --- a/internal/bluesky/client.go +++ b/internal/bluesky/client.go @@ -2,6 +2,7 @@ package bluesky import ( "context" + "errors" "fmt" "net/url" "strings" @@ -12,6 +13,7 @@ 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" ) type BlueskyClient struct { @@ -40,58 +42,113 @@ func NewBlueskyClient(ctx context.Context, handle, password, host string) (*Blue return &BlueskyClient{client: client}, nil } -func (c *BlueskyClient) GetPost(ctx context.Context, actor, collection, recordKey string) (*bsky.FeedDefs_PostView, error) { - repo, err := c.resolveUser(ctx, actor) - if err != nil { - return nil, err +// withRefresh runs fn. If it fails with an auth/expired-token XRPC error, the session is +// refreshed once and fn is retried. This makes long-running and background enrichment calls +// resilient to Bluesky access-token expiry without callers having to handle it. +func (c *BlueskyClient) withRefresh(ctx context.Context, fn func() error) error { + firstErr := fn() + if firstErr == nil { + return nil } + if !isAuthError(firstErr) { + return firstErr + } + if rerr := c.refreshSession(ctx); rerr != nil { + log.WarnWithContext(ctx, "bluesky session refresh failed", "error", rerr) + return firstErr + } + return fn() +} - 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}) +func (c *BlueskyClient) refreshSession(ctx context.Context) error { + refreshed, err := atproto.ServerRefreshSession(ctx, c.client) if err != nil { - return nil, fmt.Errorf("failed to get post: %w", err) + return fmt.Errorf("failed to refresh bluesky session: %w", err) } - if len(resp.Posts) == 0 { - return nil, fmt.Errorf("post not found") + c.client.Auth.AccessJwt = refreshed.AccessJwt + c.client.Auth.RefreshJwt = refreshed.RefreshJwt + return nil +} + +func isAuthError(err error) bool { + var xe *xrpc.XRPCError + if !errors.As(err, &xe) { + return false } - return resp.Posts[0], nil + switch xe.ErrStr { + case "ExpiredToken", "TokenRequired", "AuthMissing", "AuthorizationRequired": + return true + } + msg := strings.ToLower(xe.Message) + return strings.Contains(msg, "expired") || strings.Contains(msg, "unauthorized") || strings.Contains(msg, "token") +} + +func (c *BlueskyClient) GetPost(ctx context.Context, actor, collection, recordKey string) (*bsky.FeedDefs_PostView, error) { + var result *bsky.FeedDefs_PostView + err := c.withRefresh(ctx, func() error { + repo, err := c.resolveUser(ctx, actor) + if err != nil { + return err + } + + var uri string + if collection != "" { + uri = fmt.Sprintf("at://%s/%s/%s", repo.String(), collection, recordKey) + } else { + uri = fmt.Sprintf("at://%s/app.bsky.feed.post/%s", repo.String(), recordKey) + } + log.DebugWithContext(ctx, "Fetching post", "URI", uri) + resp, err := bsky.FeedGetPosts(ctx, c.client, []string{uri}) + if err != nil { + return fmt.Errorf("failed to get post: %w", err) + } + if len(resp.Posts) == 0 { + return fmt.Errorf("post not found") + } + result = resp.Posts[0] + return nil + }) + return result, err } func (c *BlueskyClient) GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*bsky.FeedDefs_FeedViewPost, error) { - repo, err := c.resolveUser(ctx, actor) - if err != nil { - return nil, err - } - 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) - } - return resp.Feed, nil + var result []*bsky.FeedDefs_FeedViewPost + err := c.withRefresh(ctx, func() error { + repo, err := c.resolveUser(ctx, actor) + if err != nil { + return err + } + resp, err := bsky.FeedGetAuthorFeed(ctx, c.client, repo.String(), "", "posts", false, int64(limit)) + if err != nil { + return fmt.Errorf("failed to get latest posts: %w", err) + } + result = resp.Feed + return nil + }) + return result, err } -func (c *BlueskyClient) GetPostsInTimefame(ctx context.Context, actor string, start, end time.Time, limit int) ([]*bsky.FeedDefs_FeedViewPost, error) { - repo, err := c.resolveUser(ctx, actor) - if err != nil { - return nil, err - } - - var posts []*bsky.FeedDefs_FeedViewPost - var cursor string - - for len(posts) < limit { - feed, err := bsky.FeedGetAuthorFeed(ctx, c.client, repo.String(), cursor, "posts", false, 50) +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) if err != nil { - return nil, err + return err } - for _, post := range feed.Feed { - if post.Post.Record != nil { + var posts []*bsky.FeedDefs_FeedViewPost + var cursor string + + for len(posts) < limit { + feed, err := bsky.FeedGetAuthorFeed(ctx, c.client, repo.String(), cursor, "posts", false, 50) + if err != nil { + return err + } + + for _, post := range feed.Feed { + if post.Post == nil || post.Post.Record == nil { + continue + } rec, ok := post.Post.Record.Val.(*bsky.FeedPost) if !ok { log.WarnWithContext(ctx, "Failed to parse post", "post", post.Post.Uri) @@ -107,18 +164,21 @@ func (c *BlueskyClient) GetPostsInTimefame(ctx context.Context, actor string, st if createdAt.After(start) && createdAt.Before(end) { posts = append(posts, post) if len(posts) >= limit { - return posts, nil + result = posts + return nil } } } - } - if feed.Cursor == nil || *feed.Cursor == "" { - break + if feed.Cursor == nil || *feed.Cursor == "" { + break + } + cursor = *feed.Cursor } - cursor = *feed.Cursor - } - return posts, nil + result = posts + return nil + }) + return result, err } func (c *BlueskyClient) GetFeed(ctx context.Context, feedURI string, limit int) (*bsky.FeedGetFeed_Output, error) { @@ -126,7 +186,16 @@ func (c *BlueskyClient) GetFeed(ctx context.Context, feedURI string, limit int) if err != nil { return nil, err } - return bsky.FeedGetFeed(ctx, c.client, "", uri, int64(50)) + var result *bsky.FeedGetFeed_Output + err = c.withRefresh(ctx, func() error { + resp, err := bsky.FeedGetFeed(ctx, c.client, "", uri, int64(50)) + if err != nil { + return fmt.Errorf("failed to get feed: %w", err) + } + result = resp + return nil + }) + return result, err } func (c *BlueskyClient) resolveUser(ctx context.Context, user string) (syntax.DID, error) { @@ -146,6 +215,82 @@ func (c *BlueskyClient) resolveUser(ctx context.Context, user string) (syntax.DI 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.client.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: prof.URL, + } + 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.client.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: a.URL, + }) + } + result = results + return nil + }) + return result, err +} + +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"` + URL string `json:"url"` +} + +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"` + URL string `json:"url"` + } `json:"actors"` +} + func (c *BlueskyClient) sanitizeFeedURI(ctx context.Context, input string) (string, error) { input = strings.TrimSpace(input) diff --git a/internal/bluesky/client_test.go b/internal/bluesky/client_test.go index 504b912..73e9977 100644 --- a/internal/bluesky/client_test.go +++ b/internal/bluesky/client_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "strconv" "testing" + "time" "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/api/bsky" @@ -109,6 +110,126 @@ func setupMockServer() *httptest.Server { 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, + "url": "https://bsky.app/profile/" + actor, + } + 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), + "url": fmt.Sprintf("https://bsky.app/profile/%s%d.test", query, i), + }) + } + + resp := map[string]any{"actors": actors} + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + }) + + // Mock for com.atproto.identity.resolveHandle (used by ResolveUser) + mux.HandleFunc("/xrpc/com.atproto.identity.resolveHandle", func(w http.ResponseWriter, r *http.Request) { + handle := r.URL.Query().Get("handle") + if handle == "" { + http.Error(w, `{"error": "MissingParameter", "message": "handle parameter is required"}`, http.StatusBadRequest) + return + } + + resp := atproto.IdentityResolveHandle_Output{ + Did: "did:plc:resolved", + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + }) + + // Mock for com.atproto.server.refreshSession (used by withRefresh) + mux.HandleFunc("/xrpc/com.atproto.server.refreshSession", func(w http.ResponseWriter, r *http.Request) { + // Check for Authorization header + auth := r.Header.Get("Authorization") + if auth == "" { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": "TokenRequired"}) + return + } + + resp := atproto.ServerRefreshSession_Output{ + AccessJwt: "refreshed_access_jwt", + RefreshJwt: "refreshed_refresh_jwt", + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + }) + return httptest.NewServer(mux) } @@ -227,3 +348,264 @@ 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 returns two pages: the first contains out-of-range posts, + // then in-range posts, verifying both date filtering and cursor pagination. + 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 out-of-range (before start), one out-of-range (after end), and a cursor. + feed = []*bsky.FeedDefs_FeedViewPost{ + mk("at://did:plc:testactor/app.bsky.feed.post/3", "2022-01-01T00:00:00Z"), + 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 and no cursor (end of feed). + 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/2", "2023-08-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.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) + } + } +} + +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: "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) + } + }) + } +} diff --git a/internal/clustering/helpers.go b/internal/clustering/helpers.go new file mode 100644 index 0000000..02241a6 --- /dev/null +++ b/internal/clustering/helpers.go @@ -0,0 +1,37 @@ +package clustering + +import "math" + +func normalize(v []float64) []float64 { + out := make([]float64, len(v)) + + var norm float64 + for _, x := range v { + norm += x * x + } + + norm = math.Sqrt(norm) + + if norm == 0 { + return out + } + + for i, x := range v { + out[i] = x / norm + } + + return out +} + +func cosineSimilarity(a, b []float64) float64 { + if len(a) != len(b) { + return -1 + } + + var dot float64 + for i := range a { + dot += a[i] * b[i] + } + + return dot +} diff --git a/internal/clustering/k-means.go b/internal/clustering/k-means.go new file mode 100644 index 0000000..d1a126b --- /dev/null +++ b/internal/clustering/k-means.go @@ -0,0 +1,228 @@ +package clustering + +import ( + "errors" + "math" + "math/rand" +) + +func initializeKMeansPlusPlus( + vectors [][]float64, + k int, + rng *rand.Rand, +) [][]float64 { + centroids := make([][]float64, 0, k) + + first := rng.Intn(len(vectors)) + centroids = append( + centroids, + append([]float64(nil), vectors[first]...), + ) + + distances := make([]float64, len(vectors)) + + for len(centroids) < k { + var total float64 + + for i, vector := range vectors { + bestSimilarity := -math.MaxFloat64 + + for _, centroid := range centroids { + similarity := cosineSimilarity(vector, centroid) + if similarity > bestSimilarity { + bestSimilarity = similarity + } + } + + // cosine distance + distance := 1 - bestSimilarity + + if distance < 0 { + distance = 0 + } + + // k-means++ uses squared distance. + distances[i] = distance * distance + total += distances[i] + } + + if total == 0 { + break + } + + target := rng.Float64() * total + + var cumulative float64 + selected := len(vectors) - 1 + + for i, distance := range distances { + cumulative += distance + + if cumulative >= target { + selected = i + break + } + } + + centroids = append( + centroids, + append([]float64(nil), vectors[selected]...), + ) + } + + // This can theoretically happen if all vectors are identical. + for len(centroids) < k { + index := rng.Intn(len(vectors)) + + centroids = append( + centroids, + append([]float64(nil), vectors[index]...), + ) + } + + return centroids +} + +func KMeans(items []Item, k int, maxIterations int, seed int64) ([]Result, error) { + if len(items) == 0 { + return nil, errors.New("no items") + } + + if k <= 0 { + return nil, errors.New("k must be positive") + } + + if k > len(items) { + k = len(items) + } + + if maxIterations <= 0 { + maxIterations = 50 + } + + dimension := len(items[0].Embedding) + if dimension == 0 { + return nil, errors.New("empty embedding") + } + + // Normalize embeddings once. + normalized := make([][]float64, len(items)) + for i, item := range items { + if len(item.Embedding) != dimension { + return nil, errors.New("embedding dimensions do not match") + } + + normalized[i] = normalize(item.Embedding) + } + + rng := rand.New(rand.NewSource(seed)) + + centroids := initializeKMeansPlusPlus(normalized, k, rng) + + assignments := make([]int, len(items)) + + for iteration := 0; iteration < maxIterations; iteration++ { + changed := false + + // Assignment step. + for i, vector := range normalized { + bestCluster := 0 + bestSimilarity := -math.MaxFloat64 + + for cluster, centroid := range centroids { + similarity := cosineSimilarity(vector, centroid) + + if similarity > bestSimilarity { + bestSimilarity = similarity + bestCluster = cluster + } + } + + if iteration == 0 || assignments[i] != bestCluster { + assignments[i] = bestCluster + changed = true + } + } + + // Stop if nothing changed. + if !changed && iteration > 0 { + break + } + + // Update step. + newCentroids := make([][]float64, k) + counts := make([]int, k) + + for cluster := 0; cluster < k; cluster++ { + newCentroids[cluster] = make([]float64, dimension) + } + + for i, vector := range normalized { + cluster := assignments[i] + counts[cluster]++ + + for d, value := range vector { + newCentroids[cluster][d] += value + } + } + + for cluster := 0; cluster < k; cluster++ { + if counts[cluster] == 0 { + // Empty cluster: reinitialize with a random item. + index := rng.Intn(len(normalized)) + newCentroids[cluster] = append( + []float64(nil), + normalized[index]..., + ) + continue + } + + newCentroids[cluster] = normalize(newCentroids[cluster]) + } + + centroids = newCentroids + } + + results := make([]Result, k) + + for cluster := 0; cluster < k; cluster++ { + results[cluster] = Result{ + Cluster: cluster, + Centroid: centroids[cluster], + } + } + + for i, item := range items { + cluster := assignments[i] + + item.Embedding = normalized[i] + + results[cluster].Items = append( + results[cluster].Items, + item, + ) + } + + // Find the item closest to each centroid. + for cluster := range results { + if len(results[cluster].Items) == 0 { + continue + } + + bestSimilarity := -math.MaxFloat64 + + for _, item := range results[cluster].Items { + similarity := cosineSimilarity( + item.Embedding, + results[cluster].Centroid, + ) + + if similarity > bestSimilarity { + bestSimilarity = similarity + results[cluster].Representative = item + } + } + } + + return results, nil +} diff --git a/internal/clustering/kmeans_test.go b/internal/clustering/kmeans_test.go new file mode 100644 index 0000000..ca6bb91 --- /dev/null +++ b/internal/clustering/kmeans_test.go @@ -0,0 +1,213 @@ +package clustering + +import ( + "math" + "math/rand" + "testing" +) + +func TestKMeans_Basic(t *testing.T) { + items := []Item{ + {ID: "1", Text: "a", Embedding: []float64{1.0, 0.0}}, + {ID: "2", Text: "b", Embedding: []float64{0.9, 0.1}}, + {ID: "3", Text: "c", Embedding: []float64{-1.0, 0.0}}, + {ID: "4", Text: "d", Embedding: []float64{-0.9, -0.1}}, + } + + results, err := KMeans(items, 2, 50, 42) + if err != nil { + t.Fatalf("KMeans failed: %v", err) + } + + if len(results) != 2 { + t.Fatalf("expected 2 clusters, got %d", len(results)) + } + + // Check that items are assigned to clusters + totalItems := 0 + for _, r := range results { + totalItems += len(r.Items) + } + if totalItems != len(items) { + t.Fatalf("expected %d items across clusters, got %d", len(items), totalItems) + } + + // Verify centroids are normalized + for _, r := range results { + norm := 0.0 + for _, v := range r.Centroid { + norm += v * v + } + norm = math.Sqrt(norm) + if math.Abs(norm-1.0) > 1e-6 { + t.Errorf("centroid not normalized: norm=%f", norm) + } + } +} + +func TestKMeans_Empty(t *testing.T) { + _, err := KMeans([]Item{}, 2, 50, 42) + if err == nil { + t.Fatal("expected error for empty items") + } +} + +func TestKMeans_KGreaterThanItems(t *testing.T) { + items := []Item{ + {ID: "1", Text: "a", Embedding: []float64{1.0, 0.0}}, + {ID: "2", Text: "b", Embedding: []float64{0.0, 1.0}}, + } + + results, err := KMeans(items, 5, 50, 42) + if err != nil { + t.Fatalf("KMeans failed: %v", err) + } + + // k should be capped to len(items) + if len(results) != 2 { + t.Fatalf("expected 2 clusters (capped), got %d", len(results)) + } +} + +func TestKMeans_DeterministicWithSeed(t *testing.T) { + items := []Item{ + {ID: "1", Text: "a", Embedding: []float64{1.0, 0.0}}, + {ID: "2", Text: "b", Embedding: []float64{0.9, 0.1}}, + {ID: "3", Text: "c", Embedding: []float64{-1.0, 0.0}}, + {ID: "4", Text: "d", Embedding: []float64{-0.9, -0.1}}, + } + + results1, _ := KMeans(items, 2, 50, 42) + results2, _ := KMeans(items, 2, 50, 42) + + // Same seed should produce same assignments + for i := range results1 { + if len(results1[i].Items) != len(results2[i].Items) { + t.Fatalf("cluster %d size differs: %d vs %d", i, len(results1[i].Items), len(results2[i].Items)) + } + } +} + +func TestCosineSimilarity(t *testing.T) { + tests := []struct { + name string + a, b []float64 + expected float64 + }{ + {"identical", []float64{1, 0}, []float64{1, 0}, 1.0}, + {"orthogonal", []float64{1, 0}, []float64{0, 1}, 0.0}, + {"opposite", []float64{1, 0}, []float64{-1, 0}, -1.0}, + {"scaled", []float64{2, 0}, []float64{1, 0}, 2.0}, // not normalized + {"mismatched len", []float64{1, 0}, []float64{1, 0, 0}, -1.0}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := cosineSimilarity(tc.a, tc.b) + if math.Abs(got-tc.expected) > 1e-6 { + t.Errorf("cosineSimilarity(%v, %v) = %f, want %f", tc.a, tc.b, got, tc.expected) + } + }) + } +} + +func TestNormalize(t *testing.T) { + v := []float64{3.0, 4.0} + normalized := normalize(v) + + // Check length is 1 + norm := 0.0 + for _, x := range normalized { + norm += x * x + } + norm = math.Sqrt(norm) + if math.Abs(norm-1.0) > 1e-6 { + t.Errorf("normalized vector has norm %f, want 1.0", norm) + } + + // Check zero vector handling + zero := normalize([]float64{0.0, 0.0}) + if zero[0] != 0.0 || zero[1] != 0.0 { + t.Errorf("zero vector normalization failed: %v", zero) + } +} + +func TestInitializeKMeansPlusPlus(t *testing.T) { + vectors := [][]float64{ + {1.0, 0.0}, + {0.9, 0.1}, + {-1.0, 0.0}, + {-0.9, -0.1}, + } + + rng := rand.New(rand.NewSource(42)) + centroids := initializeKMeansPlusPlus(vectors, 2, rng) + + if len(centroids) != 2 { + t.Fatalf("expected 2 centroids, got %d", len(centroids)) + } + + // Centroids should be from the input vectors (or averages) + found1, found2 := false, false + for _, c := range centroids { + for _, v := range vectors { + if equalVectors(c, v) { + if !found1 { + found1 = true + } else { + found2 = true + } + } + } + } + if !found1 || !found2 { + t.Errorf("centroids not from input vectors: %v", centroids) + } +} + +func equalVectors(a, b []float64) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if math.Abs(a[i]-b[i]) > 1e-6 { + return false + } + } + return true +} + +func TestKMeans_HighDimensional(t *testing.T) { + // Test with 1024-dim vectors (like qwen3-embedding) + dim := 1024 + items := make([]Item, 10) + for i := range items { + emb := make([]float64, dim) + if i < 5 { + emb[0] = 1.0 // cluster 1 + } else { + emb[dim-1] = 1.0 // cluster 2 + } + items[i] = Item{ + ID: string(rune('a' + i)), + Text: "test", + Embedding: emb, + } + } + + results, err := KMeans(items, 2, 50, 42) + if err != nil { + t.Fatalf("KMeans failed: %v", err) + } + + if len(results) != 2 { + t.Fatalf("expected 2 clusters, got %d", len(results)) + } + + // Verify each cluster has ~5 items + for _, r := range results { + if len(r.Items) < 3 || len(r.Items) > 7 { + t.Errorf("cluster size %d unexpected", len(r.Items)) + } + } +} diff --git a/internal/clustering/types.go b/internal/clustering/types.go new file mode 100644 index 0000000..d1608d1 --- /dev/null +++ b/internal/clustering/types.go @@ -0,0 +1,21 @@ +package clustering + +import "time" + +type Item struct { + ID string + Text string + Embedding []float64 + Timestamp time.Time + Likes int64 + Reposts int64 + Replies int64 + Quotes int64 +} + +type Result struct { + Cluster int + Items []Item + Centroid []float64 + Representative Item +} diff --git a/internal/config/config.go b/internal/config/config.go index f1a442b..12a07fa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,30 +2,70 @@ 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 - BlueskyDID string - BlueskyAppPassword string - BlueskyHost string - OllamaHost string - X402FacilitatorURL string - X402WalletAddress string - ApiKey string - ApiSecret string - LogFile string + ListenAddress string + ListenPort string + UnixSocketPath string + UnixSocketPermissions os.FileMode + DatabaseURL string + ActivityPubHost string + ActivityPubToken string + BlueskyDID string + BlueskyAppPassword string + BlueskyHost string + NostrRelays []string + TwitterBearerToken string + PrewarmAuthors []string + PrewarmFeedURI string + EnrichCrossPlatform bool + OllamaHost string + MigrateOnStart bool + MigrationsDir string + ReadyzPort string + MetricsPort string + X402FacilitatorURL string + X402WalletAddress string + ApiKey string + ApiSecret string + LogFile string + ActiveNetworks string + MatrixWebhookURL string + MatrixServerID string + EmbeddingModel string + StyleModel string + AnalyticsTTLEngagement time.Duration + AnalyticsTTLPatterns time.Duration + AnalyticsTTLTopics time.Duration + AnalyticsTTLSentiment time.Duration + AnalyticsTTLDrivers time.Duration } func Load() *Config { + // Load config/models.env for model defaults (single source of truth) + // Find project root by looking for go.mod + root := findProjectRoot() + modelEnvPaths := []string{ + filepath.Join(root, "config", "models.env"), + filepath.Join(root, "..", "config", "models.env"), // fallback + } + for _, p := range modelEnvPaths { + if err := godotenv.Load(p); err == nil { + log.Debug("Loaded model config", "path", p) + break + } else { + log.Debug("Model config not found", "path", p, "error", err) + } + } + // Also load .env for local development overrides if err := godotenv.Load(); err != nil { log.Debug("No .env file found, reading from environment") } @@ -35,21 +75,52 @@ func Load() *Config { if err != nil { log.Fatal("Invalid UNIX_SOCKET_PERMISSIONS", "error", err) } - return &Config{ - ListenAddress: getEnv("SERVER_ADDRESS", "127.0.0.1"), - ListenPort: getEnv("SERVER_PORT", "8080"), - UnixSocketPermissions: os.FileMode(perms), - DatabaseURL: getEnv("GOOSE_DBSTRING", "postgres:///mcpserver"), - BlueskyDID: getEnv("BLUESKY_DID", ""), - BlueskyAppPassword: getEnv("BLUESKY_APP_PASSWORD", ""), - BlueskyHost: getEnv("BLUESKY_HOST", "https://bsky.social"), - OllamaHost: getEnv("OLLAMA_HOST", "http://localhost:11434"), - X402FacilitatorURL: getEnv("X402_FACILITATOR_URL", "https://facilitator.x402.rs"), - X402WalletAddress: getEnv("X402_WALLET_ADDRESS", ""), - ApiKey: getEnv("CDP_API_KEY_ID", ""), - ApiSecret: getEnv("CDP_API_SECRET", ""), - LogFile: getEnv("LOG_FILE", "/var/log/mcpserver/output.log"), + 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: getEnv("TWITTER_BEARER_TOKEN", ""), + PrewarmAuthors: splitComma(getEnv("PREWARM_AUTHORS", "")), + PrewarmFeedURI: getEnv("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"), + ReadyzPort: getEnv("READYZ_PORT", "9090"), + MetricsPort: getEnv("METRICS_PORT", "9091"), + X402FacilitatorURL: getEnv("X402_FACILITATOR_URL", "https://facilitator.x402.rs"), + X402WalletAddress: getEnv("X402_WALLET_ADDRESS", ""), + ApiKey: getEnv("CDP_API_KEY_ID", ""), + ApiSecret: getEnv("CDP_API_SECRET", ""), + LogFile: getEnv("LOG_FILE", "/var/log/mcpserver/output.log"), + ActiveNetworks: getEnv("ACTIVE_NETWORKS", "base"), + MatrixWebhookURL: getEnv("MATRIX_WEBHOOK_URL", ""), + MatrixServerID: 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"), } + + // 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 { @@ -61,3 +132,51 @@ func getEnv(key, fallback string) string { } return fallback } + +func getEnvBool(key, fallback string) bool { + v := getEnv(key, fallback) + switch strings.ToLower(strings.TrimSpace(v)) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + +func getEnvDuration(key, fallback string) time.Duration { + v := getEnv(key, fallback) + d, err := time.ParseDuration(v) + if err != nil { + d, _ = time.ParseDuration(fallback) + } + return d +} + +func splitComma(s string) []string { + parts := make([]string, 0) + for p := range strings.SplitSeq(s, ",") { + if trimmed := strings.TrimSpace(p); trimmed != "" { + parts = append(parts, trimmed) + } + } + return parts +} + +func findProjectRoot() string { + // Start from current working directory and walk up looking for go.mod + dir, err := os.Getwd() + if err != nil { + return "." + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + break // reached filesystem root + } + dir = parent + } + return "." +} diff --git a/internal/nostr/client.go b/internal/nostr/client.go new file mode 100644 index 0000000..5c22907 --- /dev/null +++ b/internal/nostr/client.go @@ -0,0 +1,333 @@ +package nostr + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "fiatjaf.com/nostr" + "fiatjaf.com/nostr/nip19" + + "github.com/nacorid/naco-api/internal/actor" +) + +type Client struct { + relays []string +} + +func NewNostrClient(relays []string) (*Client, error) { + if len(relays) == 0 { + return nil, fmt.Errorf("at least one relay is required") + } + return &Client{relays: relays}, nil +} + +type Post struct { + ID string + Author string + Content string + CreatedAt time.Time +} + +func (c *Client) GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*Post, error) { + pubkey, err := resolvePubkey(actor) + if err != nil { + return nil, err + } + + posts := make([]*Post, 0, limit) + seen := make(map[string]struct{}) + + connected := false + + for _, url := range c.relays { + relay, err := nostr.RelayConnect(ctx, url, nostr.RelayOptions{}) + if err != nil { + continue + } + connected = true + sub, err := relay.Subscribe(ctx, nostr.Filter{ + Authors: []nostr.PubKey{pubkey}, + Kinds: []nostr.Kind{1}, + Limit: limit, + }, nostr.SubscriptionOptions{}) + if err != nil { + relay.Close() + continue + } + + func() { + defer sub.Unsub() + defer relay.Close() + + collect: + for { + select { + case <-sub.EndOfStoredEvents: + break collect + case ev, ok := <-sub.Events: + if !ok { + break collect + } + 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(), + }) + case <-ctx.Done(): + break collect + } + } + }() + + if len(posts) >= limit { + break + } + } + + if !connected { + return nil, fmt.Errorf("could not connect to any relay") + } + return posts, nil +} + +func (c *Client) GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]*Post, error) { + pubkey, err := resolvePubkey(actor) + if err != nil { + return nil, err + } + + since := nostr.Timestamp(start.Unix()) + until := nostr.Timestamp(end.Unix()) + + posts := make([]*Post, 0, limit) + seen := make(map[string]struct{}) + + connected := false + + for _, url := range c.relays { + relay, err := nostr.RelayConnect(ctx, url, nostr.RelayOptions{}) + if err != nil { + continue + } + connected = true + sub, err := relay.Subscribe(ctx, nostr.Filter{ + Authors: []nostr.PubKey{pubkey}, + Kinds: []nostr.Kind{1}, + Since: since, + Until: until, + Limit: limit, + }, nostr.SubscriptionOptions{}) + if err != nil { + relay.Close() + continue + } + + func() { + defer sub.Unsub() + defer relay.Close() + + collect: + for { + select { + case <-sub.EndOfStoredEvents: + break collect + case ev, ok := <-sub.Events: + if !ok { + break collect + } + 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(), + }) + case <-ctx.Done(): + break collect + } + } + }() + + if len(posts) >= limit { + break + } + } + + if !connected { + 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 + } + + for _, url := range c.relays { + relay, err := nostr.RelayConnect(ctx, url, nostr.RelayOptions{}) + if err != nil { + continue + } + sub, err := relay.Subscribe(ctx, nostr.Filter{ + IDs: []nostr.ID{eid}, + Kinds: []nostr.Kind{1}, + }, nostr.SubscriptionOptions{}) + if err != nil { + relay.Close() + continue + } + defer sub.Unsub() + defer relay.Close() + + for { + select { + case <-sub.EndOfStoredEvents: + return nil, fmt.Errorf("post %q not found", id) + case ev, ok := <-sub.Events: + if !ok { + return nil, fmt.Errorf("post %q not found", id) + } + return &Post{ + ID: ev.ID.Hex(), + Author: ev.PubKey.Hex(), + Content: ev.Content, + CreatedAt: ev.CreatedAt.Time(), + }, nil + case <-ctx.Done(): + return nil, ctx.Err() + } + } + } + return nil, fmt.Errorf("could not connect to any relay") +} + +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) + + for _, u := range c.relays { + relay, err := nostr.RelayConnect(ctx, u, nostr.RelayOptions{}) + if err != nil { + continue + } + sub, err := relay.Subscribe(ctx, nostr.Filter{ + Authors: []nostr.PubKey{pubkey}, + Kinds: []nostr.Kind{0}, + Limit: 1, + }, nostr.SubscriptionOptions{}) + if err != nil { + relay.Close() + continue + } + defer sub.Unsub() + defer relay.Close() + + collect: + for { + select { + case <-sub.EndOfStoredEvents: + break collect + case ev, ok := <-sub.Events: + if !ok { + break collect + } + 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 &actor.Profile{ + Platform: "Nostr", + Handle: npub, + DisplayName: meta.DisplayName, + AvatarURL: meta.Picture, + Bio: meta.About, + URL: "https://njump.me/" + npub, + }, nil + } + break collect + case <-ctx.Done(): + return nil, ctx.Err() + } + } + } + return nil, fmt.Errorf("no profile metadata found for %q", author) +} + +func (c *Client) SearchActors(ctx context.Context, query string, limit int) ([]actor.Profile, error) { + // Nostr has no global actor search; best-effort: if the query resolves to a + // pubkey/npub, return that single profile. + if _, err := resolvePubkey(query); err != nil { + return []actor.Profile{}, nil + } + prof, err := c.GetActor(ctx, query) + if err != nil { + return []actor.Profile{}, nil + } + return []actor.Profile{*prof}, nil +} + +func resolvePubkey(actor string) (nostr.PubKey, error) { + prefix, value, err := nip19.Decode(actor) + if err == nil { + if prefix != "npub" { + return nostr.PubKey{}, fmt.Errorf("unsupported nostr entity %q", prefix) + } + pk, ok := value.(nostr.PubKey) + if !ok { + return nostr.PubKey{}, fmt.Errorf("unexpected decoded value type %T", value) + } + return pk, nil + } + 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 { + if prefix != "note" { + return nostr.ID{}, fmt.Errorf("unsupported nostr entity %q", prefix) + } + eid, ok := value.(nostr.ID) + if !ok { + return nostr.ID{}, fmt.Errorf("unexpected decoded value type %T", value) + } + return eid, nil + } + if len(id) == 64 { + eid, e := nostr.IDFromHex(id) + if e != nil { + return nostr.ID{}, fmt.Errorf("invalid nostr event id %q: %w", id, e) + } + return eid, nil + } + return nostr.ID{}, fmt.Errorf("invalid nostr event id %q: %w", id, err) +} diff --git a/internal/nostr/client_test.go b/internal/nostr/client_test.go new file mode 100644 index 0000000..0980361 --- /dev/null +++ b/internal/nostr/client_test.go @@ -0,0 +1,231 @@ +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 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 surfaces the failure as an absence of a profile after the relay + // loop runs without connecting. + if !strings.Contains(err.Error(), "no profile metadata found") { + t.Fatalf("expected 'no profile metadata found' error, got %v", err) + } +} diff --git a/internal/ollama/client.go b/internal/ollama/client.go index 15db457..69df20c 100644 --- a/internal/ollama/client.go +++ b/internal/ollama/client.go @@ -6,39 +6,64 @@ import ( "net/url" "strings" + "github.com/nacorid/naco-api/internal/clustering" "github.com/ollama/ollama/api" ) type OllamaClient struct { - client api.Client + client api.Client + embeddingModel string + styleModel string } -func New(ctx context.Context, host *url.URL, http *http.Client) *OllamaClient { +func New(ctx context.Context, host *url.URL, http *http.Client, em, sm string) *OllamaClient { client := api.NewClient(host, http) - return &OllamaClient{client: *client} + return &OllamaClient{client: *client, embeddingModel: em, styleModel: sm} } -func (o *OllamaClient) EmbedTexts(ctx context.Context, posts []string) ([][]float64, error) { - vectors := make([][]float64, 0, len(posts)) - for _, t := range posts { +func (o *OllamaClient) EmbedTexts(ctx context.Context, posts []clustering.Item) ([][]float64, error) { + if len(posts) == 0 { + return nil, nil + } + embeddings := make([][]float64, 0, len(posts)) + + for _, post := range posts { resp, err := o.client.Embeddings(ctx, &api.EmbeddingRequest{ - Model: "nomic-embed-text", - Prompt: t, + Model: o.embeddingModel, + Prompt: post.Text, }) if err != nil { return nil, err } - vectors = append(vectors, resp.Embedding) + embeddings = append(embeddings, resp.Embedding) } - return vectors, nil + + return embeddings, nil } -func (o *OllamaClient) SummarizeStyle(ctx context.Context, posts []string) (string, error) { - prompt := "Summarize writing style, recurring themes, tone, personality traits of this user:\n\n" + - strings.Join(posts, "\n") + "\n\nStyle summary:" +func (o *OllamaClient) SummarizeStyle(ctx context.Context, posts []clustering.Item) (string, error) { + if len(posts) == 0 { + return "", nil + } + var promptParts []string + for _, post := range posts { + promptParts = append(promptParts, post.Text) + } + + prompt := "Summarize the user's writing style. Analyze:\n" + + "- tone and emotional register\n" + + "- recurring themes and interests\n" + + "- vocabulary and sentence structure\n" + + "- communication habits\n" + + "- personality traits expressed through writing\n\n" + + "Base your analysis only on the supplied posts. Do not infer sensitive personal attributes.\n\n" + + "Posts:\n\n" + + strings.Join(promptParts, "\n---\n") + + "\n\nStyle summary:" + var fullResponse strings.Builder err := o.client.Generate(ctx, &api.GenerateRequest{ - Model: "mistral", + Model: o.styleModel, Prompt: prompt, }, func(resp api.GenerateResponse) error { fullResponse.WriteString(resp.Response) diff --git a/internal/ollama/client_test.go b/internal/ollama/client_test.go new file mode 100644 index 0000000..16b18f3 --- /dev/null +++ b/internal/ollama/client_test.go @@ -0,0 +1,215 @@ +package ollama + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/nacorid/naco-api/internal/clustering" +) + +func TestEmbedTexts(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/embeddings", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"embedding":[0.1,0.2,0.3]}`)) + }) + + server := httptest.NewServer(mux) + defer server.Close() + + host, _ := url.Parse(server.URL) + client := New(context.Background(), host, http.DefaultClient, "test-embed", "test-style") + + 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) + } + + 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 dim: %d", i, len(emb)) + } + if emb[0] != 0.1 || emb[1] != 0.2 || emb[2] != 0.3 { + t.Errorf("embedding %d wrong values: %v", i, emb) + } + } +} + +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("server error")) + }) + + server := httptest.NewServer(mux) + defer server.Close() + + host, _ := url.Parse(server.URL) + client := New(context.Background(), host, http.DefaultClient, "test-embed", "test-style") + + posts := []clustering.Item{{ID: "1", Text: "hello", Embedding: nil}} + + _, err := client.EmbedTexts(context.Background(), posts) + if err == nil { + t.Fatal("expected error from failing server") + } +} + +func TestSummarizeStyle(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/generate", func(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + t.Fatal("response writer doesn't support flushing") + } + w.Header().Set("Content-Type", "application/x-ndjson") + // Stream response as newline-delimited JSON + w.Write([]byte(`{"response":"Style summary: "}` + "\n")) + if flusher != nil { + flusher.Flush() + } + w.Write([]byte(`{"response":"concise and clear."}` + "\n")) + }) + + server := httptest.NewServer(mux) + defer server.Close() + + host, _ := url.Parse(server.URL) + client := New(context.Background(), host, http.DefaultClient, "test-embed", "test-style") + + posts := []clustering.Item{ + {ID: "1", Text: "First post", Embedding: nil}, + {ID: "2", Text: "Second post", Embedding: nil}, + } + + summary, err := client.SummarizeStyle(context.Background(), posts) + if err != nil { + t.Fatalf("SummarizeStyle failed: %v", err) + } + + if summary != "Style summary: concise and clear." { + t.Errorf("unexpected summary: %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("server error")) + }) + + server := httptest.NewServer(mux) + defer server.Close() + + host, _ := url.Parse(server.URL) + client := New(context.Background(), host, http.DefaultClient, "test-embed", "test-style") + + posts := []clustering.Item{{ID: "1", Text: "hello", Embedding: nil}} + + _, err := client.SummarizeStyle(context.Background(), posts) + if err == nil { + t.Fatal("expected error from failing server") + } +} + +func TestSummarizeStyle_Streaming(t *testing.T) { + callCount := 0 + mux := http.NewServeMux() + mux.HandleFunc("/api/generate", func(w http.ResponseWriter, r *http.Request) { + callCount++ + flusher, ok := w.(http.Flusher) + if !ok { + t.Fatal("response writer doesn't support flushing") + } + w.Header().Set("Content-Type", "application/x-ndjson") + // Simulate streaming response + w.Write([]byte(`{"response":"part1"}` + "\n")) + if flusher != nil { + flusher.Flush() + } + w.Write([]byte(`{"response":"part2"}` + "\n")) + if flusher != nil { + flusher.Flush() + } + w.Write([]byte(`{"response":"part3","done":true}` + "\n")) + }) + + server := httptest.NewServer(mux) + defer server.Close() + + host, _ := url.Parse(server.URL) + client := New(context.Background(), host, http.DefaultClient, "test-embed", "test-style") + + posts := []clustering.Item{{ID: "1", Text: "test", Embedding: nil}} + + summary, err := client.SummarizeStyle(context.Background(), posts) + if err != nil { + t.Fatalf("SummarizeStyle failed: %v", err) + } + + if summary != "part1part2part3" { + t.Errorf("streaming summary = %q, want %q", summary, "part1part2part3") + } + + if callCount != 1 { + t.Errorf("generate endpoint called %d times, want 1", callCount) + } +} + +func TestEmbedTexts_EmptyInput(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/embeddings", func(w http.ResponseWriter, r *http.Request) { + t.Fatal("embeddings endpoint should not be called for empty input") + }) + + server := httptest.NewServer(mux) + defer server.Close() + + host, _ := url.Parse(server.URL) + client := New(context.Background(), host, http.DefaultClient, "test-embed", "test-style") + + 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 empty embeddings, got %d", len(embeddings)) + } +} + +func TestSummarizeStyle_EmptyInput(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/generate", func(w http.ResponseWriter, r *http.Request) { + t.Fatal("generate endpoint should not be called for empty input") + }) + + server := httptest.NewServer(mux) + defer server.Close() + + host, _ := url.Parse(server.URL) + client := New(context.Background(), host, http.DefaultClient, "test-embed", "test-style") + + 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) + } +} diff --git a/internal/server/GlobalLoggingMiddleware.go b/internal/server/GlobalLoggingMiddleware.go new file mode 100644 index 0000000..073676e --- /dev/null +++ b/internal/server/GlobalLoggingMiddleware.go @@ -0,0 +1,63 @@ +package server + +import ( + "fmt" + "net/http" + "strings" + "time" + + log "github.com/nacorid/logger" +) + +type statusTrackingWriter struct { + http.ResponseWriter + statusCode int +} + +func (stw *statusTrackingWriter) WriteHeader(code int) { + stw.statusCode = code + stw.ResponseWriter.WriteHeader(code) +} + +func (stw *statusTrackingWriter) Write(b []byte) (int, error) { + if stw.statusCode == 0 { + stw.statusCode = http.StatusOK + } + return stw.ResponseWriter.Write(b) +} + +func GlobalLoggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/healthz" { + next.ServeHTTP(w, r) + return + } + start := time.Now() + + ipAddr := r.RemoteAddr + if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { + ipAddr = strings.TrimSpace(strings.Split(forwarded, ",")[0]) + } else if realIP := r.Header.Get("X-Real-Ip"); realIP != "" { + ipAddr = realIP + } + + stw := &statusTrackingWriter{ResponseWriter: w} + + next.ServeHTTP(stw, r) + + duration := fmt.Sprintf("%.2fms", float64(time.Since(start))/float64(time.Millisecond)) + status := stw.statusCode + if status == 0 { + status = http.StatusOK + } + + log.InfoWithContext(r.Context(), "HTTP Request Processed", + "method", r.Method, + "path", r.URL.Path, + "status", status, + "duration", duration, + "remote_ip", ipAddr, + "user_agent", r.UserAgent(), + ) + }) +} diff --git a/internal/server/LoggingMiddleware.go b/internal/server/LoggingMiddleware.go new file mode 100644 index 0000000..76424b6 --- /dev/null +++ b/internal/server/LoggingMiddleware.go @@ -0,0 +1,74 @@ +package server + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + log "github.com/nacorid/logger" +) + +type LoggingMiddleware struct{} + +func NewLoggingMiddleware() *LoggingMiddleware { + return &LoggingMiddleware{} +} + +// OnCall logs native MCP tool requests streamed over SSE/WebSockets +func (lm *LoggingMiddleware) OnCall(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) { + if method == "tools/call" { + start := time.Now() + + result, err := next(ctx, method, request) + + duration := fmt.Sprintf("%.2fms", float64(time.Since(start))/float64(time.Millisecond)) + + toolName := "unknown" + if callReq, ok := request.(*mcp.CallToolRequest); ok && callReq.Params != nil { + 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, + ) + } + return result, err + } + return next(ctx, method, request) + } +} + +func (lm *LoggingMiddleware) OnCallHTTP(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + ipAddr := r.RemoteAddr + if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { + ipAddr = strings.TrimSpace(strings.Split(forwarded, ",")[0]) + } + + next.ServeHTTP(w, r) + + duration := fmt.Sprintf("%.2fms", float64(time.Since(start))/float64(time.Millisecond)) + log.InfoWithContext(r.Context(), "REST Tool Processed", + "transport", "rest", + "path", r.URL.Path, + "duration", duration, + "remote_ip", ipAddr, + ) + }) +} diff --git a/internal/server/analytics.go b/internal/server/analytics.go new file mode 100644 index 0000000..e34bc98 --- /dev/null +++ b/internal/server/analytics.go @@ -0,0 +1,995 @@ +package server + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "math" + "sort" + "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/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) + } + + platforms := []string{} + if args.Platform != "" { + platforms = []string{args.Platform} + } else { + // Twitter is included by default - the API costs it incurs are passed + // on to the caller through the paid tool pricing, so aggregating over + // it does not run the server at a loss. + platforms = []string{"Twitter", "Bluesky", "ActivityPub", "Nostr"} + } + + 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 + 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 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 + } + + // 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) +} + +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 + } + for _, p := range res { + if p.Post == nil || p.Post.Record == nil { + continue + } + if fp, ok := p.Post.Record.Val.(*bsky.FeedPost); ok { + 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) + items = append(items, item) + } + } + case ActivityPub: + res, err := s.apClient.GetPostsInTimeframe(ctx, author, start, end, limit) + if err != nil { + return nil, err + } + for _, p := range res { + if p.Text != "" { + items = append(items, clustering.Item{ + ID: p.ID, + Text: p.Text, + Timestamp: p.CreatedAt, + Likes: int64(p.FavouritesCount), + Reposts: int64(p.ReblogsCount), + Replies: int64(p.RepliesCount), + }) + } + } + case Nostr: + res, err := s.nostrClient.GetPostsInTimeframe(ctx, author, start, end, limit) + if err != nil { + return nil, err + } + for _, p := range res { + if p.Content != "" { + items = append(items, clustering.Item{ + ID: p.ID, + Text: p.Content, + Timestamp: p.CreatedAt, + }) + } + } + 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 + } + var items []clustering.Item + for _, p := range res { + if p.Content == "" { + continue + } + items = append(items, 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 items, 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 + } + + // Use embeddings from ollama for clustering + items := posts + // We need embeddings for clustering - compute them + embeddings, err := s.ollama.EmbedTexts(ctx, items) + if err != nil { + return nil, nil, fmt.Errorf("failed to compute embeddings: %w", err) + } + + // Update items with embeddings + for i := range items { + items[i].Embedding = embeddings[i] + } + + // Determine optimal number of topics + topicCount := maxTopicCount + if len(items) < maxTopicCount { + topicCount = len(items) + } + if topicCount < minTopicCount { + topicCount = minTopicCount + } + + // Run KMeans + 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 + } + + // Collect sample texts for labeling + 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 so we can correlate + // each topic with the engagement of its member posts. + 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, used as the comparison + // point for relevance ranking. + 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 is the normalized difference between the topic's mean + // engagement and the overall baseline: positive when the topic draws + // above-average engagement, negative when below. Bounded to [-1, 1]. + correlation := 0.0 + if denom := topicMean + baseline; denom > 0 { + correlation = (topicMean - baseline) / denom + if correlation > 1 { + correlation = 1 + } else if correlation < -1 { + correlation = -1 + } + } + drivers = append(drivers, EngagementDriver{ + Topic: topic.Label, + AvgEngagement: topicMean, + Correlation: correlation, + PostCount: topic.PostCount, + SamplePosts: topic.SamplePosts[:min(2, len(topic.SamplePosts))], + }) + } + + sort.Slice(drivers, func(i, j int) bool { + return math.Abs(drivers[i].Correlation) > math.Abs(drivers[j].Correlation) + }) + + return EngagementDriversResponse{ + Author: args.Author, + Drivers: drivers, + }, nil +} + +func mean(values []float64) float64 { + if len(values) == 0 { + return 0 + } + sum := 0.0 + for _, v := range values { + sum += v + } + return sum / float64(len(values)) +} + +// ---- Helper functions ---- + +func (s *Server) generateTopicLabel(ctx context.Context, samples []string) string { + if len(samples) == 0 { + return "General" + } + prompt := fmt.Sprintf(`Given these sample posts, provide a concise 1-3 word topic label: + +Posts: +%s + +Label only (no explanation):`, strings.Join(samples, "\n---\n")) + + resp, err := s.ollama.SummarizeStyle(ctx, []clustering.Item{{Text: prompt}}) + if err != nil { + return "General" + } + return strings.TrimSpace(resp) +} + +func (s *Server) extractKeywords(items []clustering.Item) []string { + var allText []string + for _, item := range items { + allText = append(allText, item.Text) + } + return utils.TopWords(allText, make(map[string]struct{}), 5) +} + +func (s *Server) analyzeSentiment(ctx context.Context, texts []string) (pos, neg, neu float64, emotions map[string]int) { + if len(texts) == 0 { + return 0, 0, 0, nil + } + + // Use Ollama for sentiment analysis + prompt := fmt.Sprintf(`Analyze sentiment and emotions in these texts. Return JSON only: +{ + "positive": 0.0-1.0, + "negative": 0.0-1.0, + "neutral": 0.0-1.0, + "emotions": {"emotion": count} +} + +Texts: +%s`, strings.Join(texts, "\n---\n")) + + resp, err := s.ollama.SummarizeStyle(ctx, []clustering.Item{{Text: prompt}}) + if err != nil { + return 0.33, 0.33, 0.34, nil + } + + var result struct { + Positive float64 `json:"positive"` + Negative float64 `json:"negative"` + Neutral float64 `json:"neutral"` + Emotions map[string]int `json:"emotions"` + } + json.Unmarshal([]byte(resp), &result) + + // Normalize + sum := result.Positive + result.Negative + result.Neutral + if sum > 0 { + result.Positive /= sum + result.Negative /= sum + result.Neutral /= sum + } + + return result.Positive, result.Negative, result.Neutral, result.Emotions +} + +func maxSentiment(pos, neg, neu float64) string { + if pos >= neg && pos >= neu { + return "positive" + } + if neg >= pos && neg >= neu { + return "negative" + } + return "neutral" +} + +func int64Val(p *int64) int64 { + if p == nil { + return 0 + } + return *p +} diff --git a/internal/server/analytics_test.go b/internal/server/analytics_test.go new file mode 100644 index 0000000..d96a439 --- /dev/null +++ b/internal/server/analytics_test.go @@ -0,0 +1,269 @@ +package server + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/nacorid/naco-api/internal/activitypub" + "github.com/nacorid/naco-api/internal/clustering" +) + +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", Text: "first post", CreatedAt: base}, + {ID: "p2", Author: "@a@mastodon.social", Content: "second post", Text: "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) + } +} + +// ---- 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 new file mode 100644 index 0000000..94c75bc --- /dev/null +++ b/internal/server/auth.go @@ -0,0 +1,84 @@ +package server + +import ( + "context" + "crypto/ecdsa" + "crypto/rand" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" + x402http "github.com/x402-foundation/x402/go/v2/http" +) + +type CDPAuth struct { + keyName string + privateKey *ecdsa.PrivateKey + signingMethod jwt.SigningMethod +} + +func NewCDPAuth(keyName, privateKeyPEM string) (*CDPAuth, error) { + block, _ := pem.Decode([]byte(privateKeyPEM)) + if block == nil { + return nil, fmt.Errorf("failed to decode PEM block") + } + + var signingMethod jwt.SigningMethod + privKey, err := x509.ParseECPrivateKey(block.Bytes) + if err != nil { + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse private key: %w", err) + } + var ok bool + privKey, ok = parsed.(*ecdsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("not an ECDSA private key") + } + signingMethod = jwt.SigningMethodEdDSA + } else { + signingMethod = jwt.SigningMethodES256 + } + return &CDPAuth{ + keyName: keyName, + privateKey: privKey, + signingMethod: signingMethod, + }, nil +} + +func (a *CDPAuth) generateBearerToken(method, path string) (string, error) { + nonceBytes := make([]byte, 16) + if _, err := rand.Read(nonceBytes); err != nil { + return "", err + } + nonce := hex.EncodeToString(nonceBytes) + uri := fmt.Sprintf("%s api.cdp.coinbase.com%s", method, path) + now := time.Now().Unix() + claims := jwt.MapClaims{ + "sub": a.keyName, + "iss": "cdp", + "nbf": now, + "exp": now + 120, + "uris": []string{uri}, + } + + token := jwt.NewWithClaims(a.signingMethod, claims) + token.Header["kid"] = a.keyName + token.Header["nonce"] = nonce + + return token.SignedString(a.privateKey) +} + +func (a *CDPAuth) GetAuthHeaders(ctx context.Context) (x402http.AuthHeaders, error) { + verify, _ := a.generateBearerToken("POST", "/platform/v2/x402/verify") + settle, _ := a.generateBearerToken("POST", "/platform/v2/x402/settle") + supported, _ := a.generateBearerToken("GET", "/platform/v2/x402/supported") + 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 +} diff --git a/internal/server/backends.go b/internal/server/backends.go new file mode 100644 index 0000000..8bd39a6 --- /dev/null +++ b/internal/server/backends.go @@ -0,0 +1,76 @@ +package server + +import ( + "context" + "encoding/json" + "time" + + "github.com/bluesky-social/indigo/api/bsky" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/nacorid/naco-api/internal/activitypub" + "github.com/nacorid/naco-api/internal/actor" + "github.com/nacorid/naco-api/internal/clustering" + "github.com/nacorid/naco-api/internal/nostr" + "github.com/nacorid/naco-api/internal/store" + "github.com/nacorid/naco-api/internal/twitter" +) + +// The following interfaces let the server depend on platform behaviour rather than on the +// concrete client types. They exist so tests can inject fake backends; the production code in +// main.go keeps passing the real clients (which satisfy these interfaces). + +type blueskyBackend interface { + GetPost(ctx context.Context, actor, collection, recordKey string) (*bsky.FeedDefs_PostView, error) + GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*bsky.FeedDefs_FeedViewPost, error) + GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]*bsky.FeedDefs_FeedViewPost, error) + GetFeed(ctx context.Context, feedURI string, limit int) (*bsky.FeedGetFeed_Output, error) + ResolveUser(ctx context.Context, user string) (syntax.DID, error) + GetActor(ctx context.Context, author string) (*actor.Profile, error) + SearchActors(ctx context.Context, query string, limit int) ([]actor.Profile, error) +} + +type apBackend interface { + GetPost(ctx context.Context, postID string) (*activitypub.Post, error) + GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*activitypub.Post, error) + GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]activitypub.Post, error) + GetActor(ctx context.Context, author string) (*actor.Profile, error) + SearchActors(ctx context.Context, query string, limit int) ([]actor.Profile, error) +} + +type nostrBackend interface { + GetPost(ctx context.Context, id string) (*nostr.Post, error) + GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*nostr.Post, error) + GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]*nostr.Post, error) + GetActor(ctx context.Context, author string) (*actor.Profile, error) + SearchActors(ctx context.Context, query string, limit int) ([]actor.Profile, error) +} + +type twitterBackend interface { + GetPost(ctx context.Context, id string) (*twitter.Post, error) + GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*twitter.Post, error) + GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]*twitter.Post, error) +} + +type ollamaBackend interface { + EmbedTexts(ctx context.Context, posts []clustering.Item) ([][]float64, error) + SummarizeStyle(ctx context.Context, posts []clustering.Item) (string, error) +} + +// Store abstracts the persistence layer the server depends on. *store.Queries satisfies it; +// tests inject an in-memory fake so db-backed paths (style profiles, analytics, similar users) +// can be exercised without a live Postgres. +type Store interface { + GetAnalyticsCache(ctx context.Context, arg store.GetAnalyticsCacheParams) (json.RawMessage, error) + UpsertAnalyticsCache(ctx context.Context, arg store.UpsertAnalyticsCacheParams) error + GetAnalyticsPostsInRange(ctx context.Context, arg store.GetAnalyticsPostsInRangeParams) ([]store.GetAnalyticsPostsInRangeRow, error) + UpsertAnalyticsPosts(ctx context.Context, arg store.UpsertAnalyticsPostsParams) error + GetAnalyticsCoverageRanges(ctx context.Context, arg store.GetAnalyticsCoverageRangesParams) ([]store.GetAnalyticsCoverageRangesRow, error) + InsertAnalyticsCoverageRange(ctx context.Context, arg store.InsertAnalyticsCoverageRangeParams) error + GetStyleProfile(ctx context.Context, arg store.GetStyleProfileParams) (store.UserStyleProfile, error) + UpsertStyleProfile(ctx context.Context, arg store.UpsertStyleProfileParams) error + DeleteStylePosts(ctx context.Context, arg store.DeleteStylePostsParams) error + GetStylePosts(ctx context.Context, arg store.GetStylePostsParams) ([]store.GetStylePostsRow, error) + UpsertStylePosts(ctx context.Context, arg store.UpsertStylePostsParams) error + FindSimilarUsers(ctx context.Context, arg store.FindSimilarUsersParams) ([]store.FindSimilarUsersRow, error) + PruneStylePosts(ctx context.Context) error +} diff --git a/internal/server/cache.go b/internal/server/cache.go new file mode 100644 index 0000000..10e656f --- /dev/null +++ b/internal/server/cache.go @@ -0,0 +1,51 @@ +package server + +import ( + "sync" + "time" +) + +const postCacheTTL = 5 * time.Minute + +type ttlCacheEntry struct { + value any + exp time.Time +} + +type ttlCache struct { + mu sync.Mutex + items map[string]ttlCacheEntry + ttl time.Duration +} + +func newTTLCache(ttl time.Duration) *ttlCache { + return &ttlCache{ + items: make(map[string]ttlCacheEntry), + ttl: ttl, + } +} + +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() + c.items[key] = ttlCacheEntry{value: value, exp: time.Now().Add(c.ttl)} +} + +type latestCacheValue struct { + posts []GetPostResponsePost + aid string +} diff --git a/internal/server/cache_version.go b/internal/server/cache_version.go new file mode 100644 index 0000000..25fc192 --- /dev/null +++ b/internal/server/cache_version.go @@ -0,0 +1,15 @@ +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. +func IsProfileStale(model, schema, algorithm int) bool { + return model != CurrentModelVersion || + schema != CurrentSchemaVersion || + algorithm != CurrentAlgorithmVersion +} diff --git a/internal/server/coverage_test.go b/internal/server/coverage_test.go new file mode 100644 index 0000000..3f37f62 --- /dev/null +++ b/internal/server/coverage_test.go @@ -0,0 +1,100 @@ +package server + +import ( + "testing" + "time" +) + +func rangeOf(t *testing.T, s, e string) timeRange { + t.Helper() + layout := "2006-01-02 15:04" + start, err := time.Parse(layout, s) + if err != nil { + t.Fatalf("bad start %q: %v", s, err) + } + end, err := time.Parse(layout, e) + if err != nil { + t.Fatalf("bad end %q: %v", e, err) + } + return timeRange{start: start, end: end} +} + +func assertRanges(t *testing.T, got []timeRange, want ...timeRange) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("got %d ranges, want %d: %+v", len(got), len(want), got) + } + for i := range want { + if !got[i].start.Equal(want[i].start) || !got[i].end.Equal(want[i].end) { + t.Fatalf("range %d: got [%v, %v], want [%v, %v]", + i, got[i].start, got[i].end, want[i].start, want[i].end) + } + } +} + +func TestUncoveredRangesNoCoverage(t *testing.T) { + r := rangeOf(t, "2026-01-01 00:00", "2026-01-31 00:00") + got := uncoveredRanges(r.start, r.end, nil) + assertRanges(t, got, r) +} + +func TestUncoveredRangesFullyCovered(t *testing.T) { + req := rangeOf(t, "2026-01-10 00:00", "2026-01-20 00:00") + cov := rangeOf(t, "2026-01-01 00:00", "2026-01-31 00:00") + got := uncoveredRanges(req.start, req.end, []timeRange{cov}) + assertRanges(t, got) +} + +func TestUncoveredRangesLeftOnly(t *testing.T) { + req := rangeOf(t, "2026-01-01 00:00", "2026-01-31 00:00") + cov := rangeOf(t, "2026-01-15 00:00", "2026-01-31 00:00") + got := uncoveredRanges(req.start, req.end, []timeRange{cov}) + want := rangeOf(t, "2026-01-01 00:00", "2026-01-15 00:00") + assertRanges(t, got, want) +} + +func TestUncoveredRangesRightOnly(t *testing.T) { + req := rangeOf(t, "2026-01-01 00:00", "2026-01-31 00:00") + cov := rangeOf(t, "2026-01-01 00:00", "2026-01-15 00:00") + got := uncoveredRanges(req.start, req.end, []timeRange{cov}) + want := rangeOf(t, "2026-01-15 00:00", "2026-01-31 00:00") + assertRanges(t, got, want) +} + +func TestUncoveredRangesBothSides(t *testing.T) { + req := rangeOf(t, "2026-01-01 00:00", "2026-01-31 00:00") + cov := rangeOf(t, "2026-01-10 00:00", "2026-01-20 00:00") + wantLeft := rangeOf(t, "2026-01-01 00:00", "2026-01-10 00:00") + wantRight := rangeOf(t, "2026-01-20 00:00", "2026-01-31 00:00") + got := uncoveredRanges(req.start, req.end, []timeRange{cov}) + assertRanges(t, got, wantLeft, wantRight) +} + +// Regression: disjoint covered intervals must not be merged into a single +// contiguous window. A request landing in the gap between two covered ranges +// must be reported as uncovered so it is fetched rather than served from an +// incomplete cache. +func TestUncoveredRangesDisjointGap(t *testing.T) { + req := rangeOf(t, "2026-01-15 00:00", "2026-01-20 00:00") + first := rangeOf(t, "2026-01-01 00:00", "2026-01-15 00:00") + second := rangeOf(t, "2026-01-20 00:00", "2026-01-31 00:00") + got := uncoveredRanges(req.start, req.end, []timeRange{first, second}) + assertRanges(t, got, req) +} + +func TestUncoveredRangesGapSurroundedByCoverage(t *testing.T) { + req := rangeOf(t, "2026-01-10 00:00", "2026-01-25 00:00") + first := rangeOf(t, "2026-01-01 00:00", "2026-01-12 00:00") + second := rangeOf(t, "2026-01-20 00:00", "2026-01-31 00:00") + want := rangeOf(t, "2026-01-12 00:00", "2026-01-20 00:00") + got := uncoveredRanges(req.start, req.end, []timeRange{first, second}) + assertRanges(t, got, want) +} + +func TestUncoveredRangesOverlappingCoverage(t *testing.T) { + req := rangeOf(t, "2026-01-10 00:00", "2026-01-20 00:00") + first := rangeOf(t, "2026-01-01 00:00", "2026-01-15 00:00") + second := rangeOf(t, "2026-01-12 00:00", "2026-01-31 00:00") + got := uncoveredRanges(req.start, req.end, []timeRange{first, second}) + assertRanges(t, got) +} diff --git a/internal/server/mcp_test.go b/internal/server/mcp_test.go new file mode 100644 index 0000000..9e5a1df --- /dev/null +++ b/internal/server/mcp_test.go @@ -0,0 +1,857 @@ +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)) + for _, p := range f.posts { + out = append(out, *p) + } + 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 7 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(-10 * 24 * time.Hour), Valid: true}, + } + ap := &fakeAP{posts: []*activitypub.Post{{Author: "@a@mastodon.social", Content: "fresh post", Text: "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 1f30ed9..4960b37 100644 --- a/internal/server/metricsMiddleware.go +++ b/internal/server/metricsMiddleware.go @@ -2,36 +2,108 @@ package server import ( "context" + "fmt" "net/http" + "sort" + "strings" + "sync" "time" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" + "github.com/modelcontextprotocol/go-sdk/mcp" log "github.com/nacorid/logger" "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) OnCall(next server.ToolHandlerFunc) server.ToolHandlerFunc { - return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - start := time.Now() - mh.requestsBucket.Increment() - mh.totalRequests.Increment() - result, err := next(ctx, request) - duration := time.Since(start) - log.DebugWithContext(ctx, "MetricsMiddleware: MCP Handler", "duration", duration) - return result, err +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() + result, err := next(ctx, method, request) + mh.RecordTool(toolName, err, time.Since(start)) + return result, err + } + return next(ctx, method, request) } } @@ -39,9 +111,8 @@ 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 := time.Since(start) + duration := fmt.Sprintf("%.2fms", float64(time.Since(start))/float64(time.Millisecond)) log.DebugWithContext(r.Context(), "MetricsMiddleware: HTTP Handler", "duration", duration) }) } @@ -53,3 +124,42 @@ func (mh *MetricsMiddleware) GetRPS() float64 { func (mh *MetricsMiddleware) GetTotalRequests() int64 { return mh.totalRequests.Get() } + +// MetricsText renders Prometheus-style exposition text for /metrics. +func (mh *MetricsMiddleware) MetricsText() string { + mh.mu.Lock() + tools := make([]string, 0, len(mh.toolStats)) + for t := range mh.toolStats { + tools = append(tools, t) + } + sort.Strings(tools) + mh.mu.Unlock() + + var b strings.Builder + fmt.Fprintf(&b, "# HELP naco_requests_total Total MCP tool calls.\n") + fmt.Fprintf(&b, "# TYPE naco_requests_total counter\n") + fmt.Fprintf(&b, "naco_requests_total %d\n", mh.totalRequests.Get()) + fmt.Fprintf(&b, "# HELP naco_errors_total Total MCP tool calls that returned an error.\n") + fmt.Fprintf(&b, "# TYPE naco_errors_total counter\n") + fmt.Fprintf(&b, "naco_errors_total %d\n", mh.totalErrors.Get()) + fmt.Fprintf(&b, "# HELP naco_enrichment_total Total background enrichment runs.\n") + fmt.Fprintf(&b, "# TYPE naco_enrichment_total counter\n") + fmt.Fprintf(&b, "naco_enrichment_total %d\n", mh.enrichEvents.Get()) + fmt.Fprintf(&b, "# HELP naco_requests_rps Rolling requests per second.\n") + fmt.Fprintf(&b, "# TYPE naco_requests_rps gauge\n") + fmt.Fprintf(&b, "naco_requests_rps %.3f\n", mh.GetRPS()) + + for _, t := range tools { + s := mh.statsFor(t) + fmt.Fprintf(&b, "# HELP naco_tool_calls_total Tool calls for %s.\n", t) + fmt.Fprintf(&b, "# TYPE naco_tool_calls_total counter\n") + fmt.Fprintf(&b, "naco_tool_calls_total{tool=%q} %d\n", t, s.calls.Get()) + fmt.Fprintf(&b, "# HELP naco_tool_errors_total Tool errors for %s.\n", t) + fmt.Fprintf(&b, "# TYPE naco_tool_errors_total counter\n") + fmt.Fprintf(&b, "naco_tool_errors_total{tool=%q} %d\n", t, s.errors.Get()) + fmt.Fprintf(&b, "# HELP naco_tool_latency_ms Rolling mean latency (ms) for %s.\n", t) + fmt.Fprintf(&b, "# TYPE naco_tool_latency_ms gauge\n") + fmt.Fprintf(&b, "naco_tool_latency_ms{tool=%q} %.3f\n", t, s.latency.GetMean()) + } + return b.String() +} diff --git a/internal/server/networkInfo.go b/internal/server/networkInfo.go new file mode 100644 index 0000000..be96613 --- /dev/null +++ b/internal/server/networkInfo.go @@ -0,0 +1,34 @@ +package server + +type NetworkInfo struct { + Network string + Asset string + Extra map[string]any +} + +// NetworkRegistry maps human-readable names (slugs) to their full configuration parameters. +var NetworkRegistry = map[string]NetworkInfo{ + "base": { + Network: "eip155:8453", + Asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + }, + "sepolia": { + Network: "eip155:84532", + Asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + }, + "polygon": { + Network: "eip155:137", + Asset: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", + }, + "arbitrum": { + Network: "eip155:42161", + Asset: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, + "solana": { + Network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + Asset: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + Extra: map[string]any{ + "feePayer": "BENrLoUbndxoNMUS5JXApGMtNykLjFXXixMtpDwDR9SP", + }, + }, +} diff --git a/internal/server/platform_test.go b/internal/server/platform_test.go new file mode 100644 index 0000000..733d1e0 --- /dev/null +++ b/internal/server/platform_test.go @@ -0,0 +1,57 @@ +package server + +import "testing" + +func TestDeterminePlatform(t *testing.T) { + cases := []struct { + author string + want platform + err bool + }{ + {"did:plc:abc123", Bluesky, false}, + {"npub1qwerty", Nostr, false}, + {"@user@host.social", ActivityPub, false}, + {"@user.bsky.social", Bluesky, false}, + {"@user", Twitter, false}, + {"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", Nostr, false}, + {"user.bsky.social", Bluesky, false}, + {"example.com", Bluesky, false}, + {"justhandle", -1, true}, + {"", -1, true}, + } + for _, c := range cases { + got, err := determinePlatform(c.author) + if c.err { + if err == nil { + t.Fatalf("%q: expected error", c.author) + } + continue + } + if err != nil { + t.Fatalf("%q: unexpected error %v", c.author, err) + } + if got != c.want { + t.Fatalf("%q: got %v want %v", c.author, got, c.want) + } + } +} + +func TestPlatformFromString(t *testing.T) { + cases := []struct { + in string + want platform + ok bool + }{ + {"Bluesky", Bluesky, true}, + {"ActivityPub", ActivityPub, true}, + {"Nostr", Nostr, true}, + {"Twitter", Twitter, true}, + {"nope", -1, false}, + } + for _, c := range cases { + got, ok := platformFromString(c.in) + if ok != c.ok || got != c.want { + t.Fatalf("%q: got %v ok %v, want %v %v", c.in, got, ok, c.want, c.ok) + } + } +} diff --git a/internal/server/rateLimitMiddleware.go b/internal/server/rateLimitMiddleware.go index e2cc58e..f8a79f3 100644 --- a/internal/server/rateLimitMiddleware.go +++ b/internal/server/rateLimitMiddleware.go @@ -8,8 +8,8 @@ import ( "strings" "sync" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" + "github.com/modelcontextprotocol/go-sdk/mcp" + log "github.com/nacorid/logger" "golang.org/x/time/rate" ) @@ -45,23 +45,32 @@ func (m *RateLimitMiddleware) getLimiter(sessionID string) *rate.Limiter { return limiter } -func (m *RateLimitMiddleware) OnCall(next server.ToolHandlerFunc) server.ToolHandlerFunc { - return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - if slices.Contains(m.request, req.Params.Name) { - var sessionID string - session := server.ClientSessionFromContext(ctx) - if session == nil { - sessionID = getClientIP(req.Header) - } else { - sessionID = session.SessionID() - } - limiter := m.getLimiter(sessionID) +func (m *RateLimitMiddleware) OnCall(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + if method == "tools/call" { + callReq, ok := req.(*mcp.CallToolRequest) + if ok && slices.Contains(m.request, callReq.Params.Name) { + var sessionID string + session := req.GetSession() + if session == nil || session.ID() == "" { + if extra := req.GetExtra(); extra != nil { + sessionID = getClientIP(req.GetExtra().Header) + } else { + log.WarnWithContext(ctx, "No session or extra information found in request; using 'unknown' as session ID") + sessionID = "unknown" + } + } else { + sessionID = session.ID() + } + limiter := m.getLimiter(sessionID) - if !limiter.Allow() { - return nil, fmt.Errorf("rate limit exceeded for session %s\nRatelimit: %v requests per second", sessionID, limiter.Limit()) + if !limiter.Allow() { + return nil, fmt.Errorf("rate limit exceeded for session %s\nRatelimit: %v requests per second", sessionID, limiter.Limit()) + } } + return next(ctx, method, req) } - return next(ctx, req) + return next(ctx, method, req) } } diff --git a/internal/server/searchactors_test.go b/internal/server/searchactors_test.go new file mode 100644 index 0000000..dcab2f3 --- /dev/null +++ b/internal/server/searchactors_test.go @@ -0,0 +1,84 @@ +package server + +import ( + "testing" + + "github.com/nacorid/naco-api/internal/actor" +) + +func TestNormalizeHandle(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"", ""}, + {" ", ""}, + {"@alice", "alice"}, + {"@", ""}, + {"alice@mastodon.social", "alice"}, + {"Alice@Mastodon.Social", "alice"}, + {"@Alice@mastodon.social", "alice"}, + {"alice.bsky.social", "alice.bsky.social"}, + {" @bob.X ", "bob.x"}, + } + for _, c := range cases { + if got := normalizeHandle(c.in); got != c.want { + t.Errorf("normalizeHandle(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestDedupeActors(t *testing.T) { + in := []ActorProfile{ + {Handle: "@alice", DisplayName: "Alice"}, + {Handle: "alice@mastodon.social", DisplayName: "Alice"}, + {Handle: "", DisplayName: ""}, + {Handle: "bob", DisplayName: "Bob"}, + } + // @alice and alice@mastodon.social dedupe by handle; the empty-handle AND + // empty-display-name is appended unconditionally; bob is another person. + out := dedupeActors(in) + if len(out) != 3 { + t.Fatalf("expected 3 deduped results, got %d: %+v", len(out), out) + } +} + +func TestDedupeActorsByDisplayNameOnly(t *testing.T) { + // When handles are empty but display names collide, dedupe by display name. + in := []ActorProfile{ + {Handle: "", DisplayName: "Shared"}, + {Handle: "", DisplayName: "Shared"}, + {Handle: "", DisplayName: "Other"}, + } + out := dedupeActors(in) + if len(out) != 2 { + t.Fatalf("expected 2 results after display-name dedup, got %d: %+v", len(out), out) + } +} + +func TestSearchActorsTwitterExcluded(t *testing.T) { + // searchActorsAll only queries Bluesky, ActivityPub and Nostr. A Twitter + // result must never appear because the backend is not consulted. + tw := &fakeTwitter{actors: []actor.Profile{{Platform: "Twitter", Handle: "t"}}, calls: 0} + bsky := &fakeBluesky{actors: []actor.Profile{{Platform: "Bluesky", Handle: "a"}}} + ap := &fakeAP{actors: []actor.Profile{{Platform: "ActivityPub", Handle: "b"}}} + nstr := &fakeNostr{actors: []actor.Profile{{Platform: "Nostr", Handle: "c"}}} + s := newTestServer(bsky, ap, nstr, tw) + + res, err := callTool(s, "searchActors", SearchActorsRequest{Query: "q"}) + if err != nil { + t.Fatalf("searchActors error: %v", err) + } + var out SearchActorsResponse + if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + for _, r := range out.Results { + if r.Platform == "Twitter" { + t.Fatalf("unexpected Twitter result: %+v", r) + } + } + if tw.calls != 0 { + t.Fatalf("expected Twitter backend untouched, got %d calls", tw.calls) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 56e3f0c..f2d1011 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -2,137 +2,173 @@ package server import ( "context" + "database/sql" + "encoding/json" + "errors" "fmt" + "maps" "net/http" "os" + "strings" + "sync" "time" "github.com/bluesky-social/indigo/api/bsky" - "github.com/mark3labs/mcp-go/mcp" - mcpserver "github.com/mark3labs/mcp-go/server" - x402go "github.com/mark3labs/x402-go" - x402http "github.com/mark3labs/x402-go/http" - "github.com/mark3labs/x402-go/signers/coinbase" + "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/clustering" "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 { - listenAddress string - permissions os.FileMode - mcpHandler *x402.X402Server - db *store.Queries - bskyClient *bluesky.BlueskyClient - ollama *ollama.OllamaClient - x402Wallet string + listenAddress string + permissions os.FileMode + mcpHandler *x402.X402Server + db Store + apClient apBackend + bskyClient blueskyBackend + nostrClient nostrBackend + twitterClient twitterBackend + ollama ollamaBackend + metrics *MetricsMiddleware + postCache *ttlCache + prewarmAuthors []string + prewarmFeedURI string + enrichEnabled bool + x402Wallet string + activeNetworks []NetworkInfo + enrichSem chan struct{} + analyticsTTLEngagement time.Duration + analyticsTTLPatterns time.Duration + analyticsTTLTopics time.Duration + analyticsTTLSentiment time.Duration + analyticsTTLDrivers time.Duration } -func New(ctx context.Context, db *store.Queries, bsky *bluesky.BlueskyClient, o *ollama.OllamaClient, cfg *config.Config) *Server { +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 { rateLimits := NewRateLimitMiddleware(float64(1.0/10.0), 2, []string{"getFeed"}) metrics := NewMetricsHook() + logging := NewLoggingMiddleware() toolChain := x402.X402Middlewares{ - MCPMiddleware: []mcpserver.ToolHandlerMiddleware{ + MCPMiddleware: []mcp.Middleware{ + logging.OnCall, metrics.OnCall, rateLimits.OnCall, }, HTTPMiddleware: []func(http.Handler) http.Handler{ + logging.OnCallHTTP, metrics.OnCallHTTP, rateLimits.OnCallHTTP, }, } - auth, err := coinbase.NewCDPAuth(cfg.ApiKey, cfg.ApiSecret, "") + 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.1.0", - &x402http.Config{ - FacilitatorURL: cfg.X402FacilitatorURL, - FacilitatorAuthorizationProvider: func(r *http.Request) string { - token, err := auth.GenerateBearerToken(r.Method, r.URL.Path) - if err != nil { - log.FatalWithContext(ctx, "failed to generate auth token", "error", err) - return "" - } - return "Bearer " + token - }, - FacilitatorOnAfterSettle: func(ctx context.Context, pp x402go.PaymentPayload, pr x402go.PaymentRequirement, sr *x402go.SettlementResponse, errVal error) { - var amount string - if errVal != nil { - log.ErrorWithContext(ctx, "Payment settlement failed", "error", errVal) - amount = "0" - } else { - if payload, ok := pp.Payload.(map[string]interface{}); ok { - if auth, ok := payload["Authorization"].(map[string]interface{}); ok { - if value, ok := auth["Value"].(string); ok { - amount = value - } - } - } - } - var to string - if payload, ok := pp.Payload.(map[string]interface{}); ok { - if auth, ok := payload["Authorization"].(map[string]interface{}); ok { - if value, ok := auth["To"].(string); ok { - to = value - } - } - } - var from string - if payload, ok := pp.Payload.(map[string]interface{}); ok { - if auth, ok := payload["Authorization"].(map[string]interface{}); ok { - if value, ok := auth["From"].(string); ok { - from = value - } - } - } - ret, err := db.SaveTransaction(ctx, store.SaveTransactionParams{ - X402TransactionHash: sr.Transaction, - Amount: amount, - Asset: pr.Asset, - Network: sr.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": sr.Transaction, - "amount": amount, - "asset": pr.Asset, - "network": sr.Network, - "recipient": to, - "sender": from, - } - if err != nil { - log.WithFields(fields).ErrorWithContext(ctx, "Failed to save transaction", "value", ret, "error", err) - } - log.WithFields(fields).DebugWithContext(ctx, "Saved transaction") - }, - }, + "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 + + _ = settleHook(settleCtx.Ctx, db, settleCtx.Result.Transaction, amount, asset, string(settleCtx.Result.Network), to, from) + + // Send webhook after transaction is saved (non-blocking, goroutine) + // settleCtx.Result.Transaction is the x402 transaction hash (string) + go func() { + wh := newWebhookSender(cfg, metrics) + if wh != nil { + // Build payload with available transaction hash and default platform + txHash := settleCtx.Result.Transaction + platform := "unknown" + payload := map[string]any{ + "event": "transaction_success", + "transaction": map[string]any{ + "x402_transaction_hash": txHash, + }, + "metrics": map[string]any{ + "function_runtime_ms": 0, + "platform": platform, + }, + "timestamp": time.Now().UTC().Format(time.RFC3339), + "server_id": cfg.MatrixServerID, + } + jsonBytes, _ := json.Marshal(payload) + wh.Send(settleCtx.Ctx, jsonBytes) + } + }() + + return nil + }) + + 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, - bskyClient: bsky, - ollama: o, - x402Wallet: cfg.X402WalletAddress, + 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, } s.registerTools() @@ -140,444 +176,1317 @@ func New(ctx context.Context, db *store.Queries, bsky *bluesky.BlueskyClient, o return s } -func (s *Server) registerTools() { - chainConfig := x402go.BaseMainnet +func (s *Server) MetricsText() string { + if s.metrics == nil { + return "" + } + return s.metrics.MetricsText() +} - requirement, err := newUSDCPaymentRequirement(x402go.USDCRequirementConfig{ - Chain: chainConfig, - Amount: "0.01", - RecipientAddress: s.x402Wallet, - Description: "Get a specific post from a specific actor", - MaxTimeoutSeconds: 60, - }, - &x402go.OutputSchema{ - Input: x402go.InputSchema{ - Type: x402go.InputSchemaTypeHTTP, - Method: x402go.InputSchemaMethodPOST, - BodyType: x402go.InputSchemaBodyTypeJSON, - BodyFields: map[string]x402go.FieldDef{ - "actor": { +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": { + "rkey": Bodyfield{ Type: "string", Description: "Record key of the post", Required: true, }, - "collection": { + "collection": Bodyfield{ Type: "string", - Description: "Collection the post belongs to. Defaults to 'app.bsky.feed.post'", + Description: "Collection the post belongs to (Bluesky only). Defaults to 'app.bsky.feed.post'", Required: false, }, }, }, - Output: map[string]x402go.FieldDef{ - "actor": { + Output: map[string]any{ + "actor": Bodyfield{ Type: "string", Description: "Did of the actor", }, - "post": { + "post": Bodyfield{ Type: "object", Description: "The post object. Includes the post content 'content', like 'likes', quote 'quotes', replies 'replies' and repost 'reposts' count", }, }, }, - ) - if err != nil { - log.Fatal("failed to create payment requirement", "error", err) } - requirements := []x402go.PaymentRequirement{requirement} + s.mcpHandler.AddPayableTool( - mcp.NewTool("getPost", - mcp.WithDescription("Get a specific post from a specific actor"), - mcp.WithInputSchema[GetPostRequest](), - mcp.WithOutputSchema[GetPostResponsePost](), - ), - mcp.NewStructuredToolHandler(s.getPost), - requirements..., + mcp.Tool{Name: "getPost", + Description: "Get a specific post from a specific actor", + InputSchema: GetPostRequest{}, + OutputSchema: GetPostResponsePost{}, + }, + s.getPost, + s.buildRequirements("0.01", extra)..., ) - requirement, err = newUSDCPaymentRequirement(x402go.USDCRequirementConfig{ - Chain: chainConfig, - Amount: "0.10", - RecipientAddress: s.x402Wallet, - Description: "Get the latest n posts from a specific actor", - MaxTimeoutSeconds: 60, - }, - &x402go.OutputSchema{ - Input: x402go.InputSchema{ - Type: x402go.InputSchemaTypeHTTP, - Method: x402go.InputSchemaMethodPOST, - BodyType: x402go.InputSchemaBodyTypeJSON, - BodyFields: map[string]x402go.FieldDef{ - "actor": { + 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": { + "n": Bodyfield{ Type: "integer", Description: "Number of posts to retrieve, maximum 50", Required: true, }, }, }, - Output: map[string]x402go.FieldDef{ - "actor": { + Output: map[string]any{ + "actor": Bodyfield{ Type: "string", Description: "Did of an actor", }, - "posts": { + "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", }, }, }, - ) - if err != nil { - log.Fatal("failed to create payment requirement", "error", err) } - requirements = []x402go.PaymentRequirement{requirement} s.mcpHandler.AddPayableTool( - mcp.NewTool("getLatestPosts", - mcp.WithDescription("Get the latest n posts from a specific actor"), - mcp.WithInputSchema[GetLatestPostsRequest](), - mcp.WithOutputSchema[GetLatestPostsResponse](), - ), - mcp.NewStructuredToolHandler(s.getLatestPosts), - requirements..., + 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)..., ) - requirement, err = newUSDCPaymentRequirement(x402go.USDCRequirementConfig{ - Chain: chainConfig, - Amount: "0.10", - RecipientAddress: s.x402Wallet, - Description: "Get posts from a specific actor in a time range", - MaxTimeoutSeconds: 60, - }, - &x402go.OutputSchema{ - Input: x402go.InputSchema{ - Type: x402go.InputSchemaTypeHTTP, - Method: x402go.InputSchemaMethodPOST, - BodyType: x402go.InputSchemaBodyTypeJSON, - BodyFields: map[string]x402go.FieldDef{ - "actor": { + 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": { + "start": Bodyfield{ Type: "string", Description: "Start time in RFC3339 format", Required: true, }, - "end": { + "end": Bodyfield{ Type: "string", Description: "End time in RFC3339 format", Required: true, }, - "n": { + "n": Bodyfield{ Type: "integer", Description: "Maximum number of posts to retrieve, maximum 50", Required: true, }, }, }, - Output: map[string]x402go.FieldDef{ - "actor": { + Output: map[string]any{ + "actor": Bodyfield{ Type: "string", Description: "Did of an actor", }, - "posts": { + "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", }, }, }, - ) - if err != nil { - log.Fatal("failed to create payment requirement", "error", err) } - requirements = []x402go.PaymentRequirement{requirement} s.mcpHandler.AddPayableTool( - mcp.NewTool("getPostsInTime", - mcp.WithDescription("Get posts from a specific actor in a time range"), - mcp.WithInputSchema[GetPostsInTimeRequest](), - mcp.WithOutputSchema[GetPostsInTimeResponse](), - ), - mcp.NewStructuredToolHandler(s.getPostsInTime), - requirements..., + 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)..., ) - requirement, err = newUSDCPaymentRequirement(x402go.USDCRequirementConfig{ - Chain: chainConfig, - Amount: "0.50", - RecipientAddress: s.x402Wallet, - Description: "Get an actor's style Profile", - MaxTimeoutSeconds: 60, - }, - &x402go.OutputSchema{ - Input: x402go.InputSchema{ - Type: x402go.InputSchemaTypeHTTP, - Method: x402go.InputSchemaMethodPOST, - BodyType: x402go.InputSchemaBodyTypeJSON, - BodyFields: map[string]x402go.FieldDef{ - "actor": { + 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]x402go.FieldDef{ - "PersonaEmbedding": { + Output: map[string]any{ + "PersonaEmbedding": Bodyfield{ Type: "array", Description: "Embedding vector representing the actor's persona", }, - "TopKeywords": { + "TopKeywords": Bodyfield{ Type: "array", Description: "Top words used by the actor", }, - "StyleSummary": { + "StyleSummary": Bodyfield{ Type: "string", Description: "Summary of the actor's writing style", }, - "PostCount": { + "PostCount": Bodyfield{ Type: "integer", Description: "Number of posts analyzed", }, }, }, - ) - if err != nil { - log.Fatal("failed to create payment requirement", "error", err) } - requirements = []x402go.PaymentRequirement{requirement} s.mcpHandler.AddPayableTool( - mcp.NewTool("getStyleProfile", - mcp.WithDescription("Get an actor's style Profile"), - mcp.WithInputSchema[GetStyleProfileRequest](), - mcp.WithOutputSchema[GetStyleProfileResponse](), - ), - mcp.NewStructuredToolHandler(s.getStyleProfile), - requirements..., + mcp.Tool{Name: "getStyleProfile", + Description: "Get an actor's style Profile", + InputSchema: GetStyleProfileRequest{}, + OutputSchema: GetStyleProfileResponse{}, + }, + s.getStyleProfile, + // Base registration is the maximum possible charge ($0.36): $0.10 base + // cost + $0.01 user lookup + $0.005 x 50 posts for a Twitter profile. + // The settlement override in the x402 handler charges the actual + // per-usage amount (Twitter passes API costs on to the caller). + s.buildRequirements("0.36", 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 author", + Required: true, + }, + }, + }, + Output: map[string]any{ + "matches": Bodyfield{ + Type: "array", + Description: "Ranked list of similar profiles (author, platform, similarity, top keywords, style summary, post count)", + }, + }, + }, + } + + s.mcpHandler.AddPayableTool( + mcp.Tool{Name: "findSimilarUsers", + Description: "Find authors with a similar writing style to the given author, across all platforms", + InputSchema: FindSimilarUsersRequest{}, + OutputSchema: FindSimilarUsersResponse{}, + }, + s.getSimilarProfiles, + s.buildRequirements("0.05", extra)..., + ) + + extra = map[string]any{ + "OutputSchema": Schema{ + Input: InputSchema{ + Type: "HTTP", + Method: "POST", + Discoverable: true, + BodyFields: map[string]any{ + "author": Bodyfield{ + Type: "string", + Description: "Did, handle, npub or @handle of an actor", + Required: true, + }, + }, + }, + Output: map[string]any{ + "actor": Bodyfield{ + Type: "object", + Description: "Profile of the actor (platform, handle, displayName, avatarUrl, bio, followersCount, followingCount, postCount, url)", + }, + }, + }, + } + + s.mcpHandler.AddPayableTool( + mcp.Tool{Name: "getActor", + Description: "Get an actor's public profile (bio, avatar, follower counts) on the platform inferred from the identifier", + InputSchema: GetActorRequest{}, + OutputSchema: GetActorResponse{}, + }, + s.getActor, + s.buildRequirements("0.01", extra)..., + ) + + extra = map[string]any{ + "OutputSchema": Schema{ + Input: InputSchema{ + Type: "HTTP", + Method: "POST", + Discoverable: true, + BodyFields: 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, + }, + }, + }, + Output: map[string]any{ + "results": Bodyfield{ + Type: "array", + Description: "Ranked list of matching actor profiles across platforms", + }, + }, + }, + } + + s.mcpHandler.AddPayableTool( + mcp.Tool{Name: "searchActors", + Description: "Search for actors by handle/name/keyword across all platforms", + InputSchema: SearchActorsRequest{}, + OutputSchema: SearchActorsResponse{}, + }, + s.searchActors, + s.buildRequirements("0.01", extra)..., + ) + + // --- Analytics tools --- + + extra = map[string]any{ + "OutputSchema": Schema{ + Input: InputSchema{ + Type: "HTTP", + Method: "POST", + Discoverable: true, + BodyFields: 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}, + }, + }, + Output: map[string]any{ + "author": Bodyfield{Type: "string", Description: "Author identifier"}, + "metrics": Bodyfield{Type: "array", Description: "Engagement metrics per post"}, + }, + }, + } + + s.mcpHandler.AddPayableTool( + mcp.Tool{Name: "getEngagementMetrics", + Description: "Get time-series engagement metrics (likes, reposts, replies, quotes) per post", + InputSchema: AnalyticsRequest{}, + OutputSchema: EngagementMetricsResponse{}, + }, + s.getEngagementMetrics, + s.buildRequirements("0.15", extra)..., + ) + + extra = map[string]any{ + "OutputSchema": Schema{ + Input: InputSchema{ + Type: "HTTP", + Method: "POST", + Discoverable: true, + BodyFields: 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}, + }, + }, + Output: 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"}, + }, + }, + } + + s.mcpHandler.AddPayableTool( + mcp.Tool{Name: "getPostingPatterns", + Description: "Get posting heatmap and patterns (hour-of-day x day-of-week)", + InputSchema: AnalyticsRequest{}, + OutputSchema: PostingPatternsResponse{}, + }, + s.getPostingPatterns, + s.buildRequirements("0.10", extra)..., + ) + + extra = map[string]any{ + "OutputSchema": Schema{ + Input: InputSchema{ + Type: "HTTP", + Method: "POST", + Discoverable: true, + BodyFields: 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}, + }, + }, + Output: map[string]any{ + "author": Bodyfield{Type: "string", Description: "Author identifier"}, + "topics": Bodyfield{Type: "array", Description: "Identified topic clusters"}, + }, + }, + } + + s.mcpHandler.AddPayableTool( + mcp.Tool{Name: "getTopicModeling", + Description: "Get LLM-based topic extraction and clustering from posts", + InputSchema: AnalyticsRequest{}, + OutputSchema: TopicModelingResponse{}, + }, + s.getTopicModeling, + s.buildRequirements("0.20", extra)..., + ) + + extra = map[string]any{ + "OutputSchema": Schema{ + Input: InputSchema{ + Type: "HTTP", + Method: "POST", + Discoverable: true, + BodyFields: 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}, + }, + }, + Output: 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"}, + }, + }, + } + + s.mcpHandler.AddPayableTool( + mcp.Tool{Name: "getSentimentTrends", + Description: "Get sentiment analysis and emotional arcs over time", + InputSchema: AnalyticsRequest{}, + OutputSchema: SentimentTrendsResponse{}, + }, + s.getSentimentTrends, + s.buildRequirements("0.15", extra)..., + ) + + extra = map[string]any{ + "OutputSchema": Schema{ + Input: InputSchema{ + Type: "HTTP", + Method: "POST", + Discoverable: true, + BodyFields: 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}, + }, + }, + Output: map[string]any{ + "author": Bodyfield{Type: "string", Description: "Author identifier"}, + "drivers": Bodyfield{Type: "array", Description: "Topics/keywords driving engagement"}, + }, + }, + } + + s.mcpHandler.AddPayableTool( + mcp.Tool{Name: "getEngagementDrivers", + Description: "Get topics/keywords that correlate with high engagement", + InputSchema: AnalyticsRequest{}, + OutputSchema: EngagementDriversResponse{}, + }, + s.getEngagementDrivers, + s.buildRequirements("0.20", extra)..., ) s.mcpHandler.AddTool( - mcp.NewTool("getFeed", - mcp.WithDescription("Get a feed by its URI"), - mcp.WithInputSchema[GetFeedRequest](), - mcp.WithOutputSchema[GetFeedResponse](), - ), - mcp.NewStructuredToolHandler(s.getFeed), + 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, args GetPostRequest) (*GetPostResponsePost, error) { - if args.Actor == "" { - return nil, fmt.Errorf("actor must be a valid did or handle") +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.Actor, args.Collection, args.RecordKey) + platform, err := determinePlatform(args.Author) 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") + if platform == Twitter { + return nil, fmt.Errorf("twitter_unavailable: X API policy prohibits reselling post data") } - return &GetPostResponsePost{ - Actor: res.Author.Did, - Content: post.Text, - Likes: *res.LikeCount, - Reposts: *res.RepostCount, - Quotes: *res.QuoteCount, - Replies: *res.ReplyCount, - CreatedAt: post.CreatedAt, + 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, args GetLatestPostsRequest) (*GetLatestPostsResponse, error) { - if args.Actor == "" { - return nil, fmt.Errorf("actor must be a valid did or handle") +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.N <= 0 { + 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.N > 50 { + if args.Limit > 50 { return nil, fmt.Errorf("n must be at most 50") } - res, err := s.bskyClient.GetLatestNPosts(ctx, args.Actor, args.N) + platform, err := determinePlatform(args.Author) if err != nil { return nil, err } - posts := make([]GetPostResponsePost, 0, len(res)) - var actor 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, - }) - actor = p.Post.Author.Did + if platform == Twitter { + return nil, fmt.Errorf("twitter_unavailable: X API policy prohibits reselling post data") } - if actor == "" { - actor = args.Actor + posts, authorID, err := s.fetchLatestPosts(ctx, platform, args.Author, args.Limit) + if err != nil { + return nil, err } - return &GetLatestPostsResponse{ - Actor: actor, - Posts: posts, + 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, args GetPostsInTimeRequest) (*GetPostsInTimeResponse, error) { - if args.Actor == "" { - return nil, fmt.Errorf("actor must be a valid did or handle") +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.N <= 0 { + 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 := 50 - if args.N < 50 { - limit = args.N + 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("startTime must be in RFC3339 format") + return nil, fmt.Errorf("endTime must be in RFC3339 format") } - res, err := s.bskyClient.GetPostsInTimefame(ctx, args.Actor, startTime, endTime, limit) + if startTime.After(endTime) { + tmp := startTime + startTime = endTime + endTime = tmp + } + platform, err := determinePlatform(args.Author) if err != nil { return nil, err } - posts := make([]GetPostResponsePost, 0, len(res)) - var actor 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, - }) - actor = p.Post.Author.Did + if platform == Twitter { + return nil, fmt.Errorf("twitter_unavailable: X API policy prohibits reselling post data") } - if actor == "" { - actor = args.Actor + posts, authorID, err := s.fetchPostsInTime(ctx, platform, args.Author, startTime, endTime, limit) + if err != nil { + return nil, err } - return &GetPostsInTimeResponse{ - Actor: actor, - Posts: posts, + 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, args GetFeedRequest) (*GetFeedResponse, error) { +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) + 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 } - 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{ - Actor: 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, - }) - } - return &GetFeedResponse{ - URI: args.URI, - Posts: posts, + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: string(respBytes)}, + }, }, nil } -func (s *Server) getStyleProfile(ctx context.Context, request mcp.CallToolRequest, args GetStyleProfileRequest) (*GetStyleProfileResponse, error) { - if args.Actor == "" { - return nil, fmt.Errorf("actor must be a valid did or handle") +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) } - - styleProfile, err := s.db.GetStyleProfile(ctx, store.GetStyleProfileParams{ - UserID: args.Actor, - 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 - } - return &GetStyleProfileResponse{ - Actor: styleProfile.UserID, - PersonaEmbedding: embedding, - TopKeywords: styleProfile.TopKeywords, - PostCount: int(styleProfile.PostCount), - StyleSummary: styleProfile.StyleSummary, - }, err + if args.Author == "" { + return nil, fmt.Errorf("author must be a valid did, handle, npub or @handle") } - - res, err := s.bskyClient.GetLatestNPosts(ctx, args.Actor, 50) + platform, err := determinePlatform(args.Author) if err != nil { return nil, err } - posts := make([]string, 0, len(res)) - var actor string - for _, p := range res { - post, ok := p.Post.Record.Val.(*bsky.FeedPost) - if !ok { + 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 +} + +// searchActorsAll fans the query out to every platform client and merges the results. This is +// a payable tool, so we never bill for a partial result: if at least one platform succeeds we +// return whatever it produced (possibly empty). We only error when every platform failed, +// so the caller isn't charged for a search that produced nothing. +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 } - posts = append(posts, post.Text) - actor = p.Post.Author.Did + 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 +} + +// --- cross-platform post helpers --- + +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, + } +} + +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) + if v, ok := s.postCache.get(key); ok { + return v.(*GetPostResponsePost), nil + } + switch platform { + case Bluesky: + res, err := s.bskyClient.GetPost(ctx, author, collection, recordKey) + if err != nil { + return nil, err + } + r := bskyPostViewToResponse(res) + s.postCache.set(key, &r) + return &r, nil + case ActivityPub: + p, err := s.apClient.GetPost(ctx, recordKey) + if err != nil { + return nil, err + } + r := apPostToResponse(*p) + s.postCache.set(key, &r) + return &r, nil + case Nostr: + p, err := s.nostrClient.GetPost(ctx, recordKey) + if err != nil { + return nil, err + } + r := nostrPostToResponse(p) + s.postCache.set(key, &r) + return &r, nil + case Twitter: + p, err := s.twitterClient.GetPost(ctx, recordKey) + if err != nil { + return nil, err + } + r := twitterPostToResponse(p) + s.postCache.set(key, &r) + 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) + if v, ok := s.postCache.get(key); ok { + cv := v.(latestCacheValue) + return cv.posts, cv.aid, nil + } + switch platform { + case Bluesky: + res, err := s.bskyClient.GetLatestNPosts(ctx, author, limit) + if err != nil { + return nil, "", err + } + posts := make([]GetPostResponsePost, 0, len(res)) + var aid string + for _, p := range res { + if p.Post == nil { + continue + } + posts = append(posts, bskyPostViewToResponse(p.Post)) + if p.Post.Author != nil { + aid = p.Post.Author.Did + } + } + s.postCache.set(key, latestCacheValue{posts: posts, aid: aid}) + return posts, aid, nil + case ActivityPub: + res, err := s.apClient.GetLatestNPosts(ctx, author, limit) + if err != nil { + return nil, "", err + } + posts := make([]GetPostResponsePost, 0, len(res)) + var aid string + for _, p := range res { + posts = append(posts, apPostToResponse(*p)) + aid = p.Author + } + s.postCache.set(key, latestCacheValue{posts: posts, aid: aid}) + return posts, aid, nil + case Nostr: + res, err := s.nostrClient.GetLatestNPosts(ctx, author, limit) + if err != nil { + return nil, "", err + } + posts := make([]GetPostResponsePost, 0, len(res)) + var aid string + for _, p := range res { + posts = append(posts, nostrPostToResponse(p)) + aid = p.Author + } + s.postCache.set(key, latestCacheValue{posts: posts, aid: aid}) + return posts, aid, nil + case Twitter: + res, err := s.twitterClient.GetLatestNPosts(ctx, author, limit) + if err != nil { + return nil, "", err + } + posts := make([]GetPostResponsePost, 0, len(res)) + var aid string + for _, p := range res { + posts = append(posts, twitterPostToResponse(p)) + aid = p.Author + } + s.postCache.set(key, latestCacheValue{posts: posts, aid: aid}) + return posts, aid, nil + } + return nil, "", fmt.Errorf("unsupported platform %q", platform) +} + +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) + if v, ok := s.postCache.get(key); ok { + cv := v.(latestCacheValue) + return cv.posts, cv.aid, nil + } + switch platform { + case Bluesky: + res, err := s.bskyClient.GetPostsInTimeframe(ctx, author, start, end, limit) + if err != nil { + return nil, "", err + } + posts := make([]GetPostResponsePost, 0, len(res)) + var aid string + for _, p := range res { + if p.Post == nil { + continue + } + posts = append(posts, bskyPostViewToResponse(p.Post)) + if p.Post.Author != nil { + aid = p.Post.Author.Did + } + } + s.postCache.set(key, latestCacheValue{posts: posts, aid: aid}) + return posts, aid, nil + case ActivityPub: + res, err := s.apClient.GetPostsInTimeframe(ctx, author, start, end, limit) + if err != nil { + return nil, "", err + } + posts := make([]GetPostResponsePost, 0, len(res)) + var aid string + for _, p := range res { + posts = append(posts, apPostToResponse(p)) + aid = p.Author + } + s.postCache.set(key, latestCacheValue{posts: posts, aid: aid}) + return posts, aid, nil + case Nostr: + res, err := s.nostrClient.GetPostsInTimeframe(ctx, author, start, end, limit) + if err != nil { + return nil, "", err + } + posts := make([]GetPostResponsePost, 0, len(res)) + var aid string + for _, p := range res { + posts = append(posts, nostrPostToResponse(p)) + aid = p.Author + } + s.postCache.set(key, latestCacheValue{posts: posts, aid: aid}) + return posts, aid, nil + case Twitter: + res, err := s.twitterClient.GetPostsInTimeframe(ctx, author, start, end, limit) + if err != nil { + return nil, "", err + } + posts := make([]GetPostResponsePost, 0, len(res)) + var aid string + for _, p := range res { + posts = append(posts, twitterPostToResponse(p)) + aid = p.Author + } + s.postCache.set(key, latestCacheValue{posts: posts, aid: aid}) + return posts, aid, nil + } + return nil, "", fmt.Errorf("unsupported platform %q", platform) +} + +func (s *Server) fetchFeed(ctx context.Context, platform platform, uri string) ([]GetPostResponsePost, error) { + key := fmt.Sprintf("feed:%s|%s", platform, uri) + if v, ok := s.postCache.get(key); ok { + return v.([]GetPostResponsePost), nil + } + if platform == Bluesky { + res, err := s.bskyClient.GetFeed(ctx, uri, 50) + if err != nil { + return nil, err + } + posts := make([]GetPostResponsePost, 0, len(res.Feed)) + for _, p := range res.Feed { + if p.Post == nil { + continue + } + posts = append(posts, bskyPostViewToResponse(p.Post)) + } + s.postCache.set(key, posts) + 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) + if err == nil { + s.postCache.set(key, posts) + } + return posts, err +} + +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 week (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*7*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-week 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), + }) + } + + respBytes, err := json.Marshal(FindSimilarUsersResponse{Matches: matches}) + 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) + } + + // 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(posts, make(map[string]struct{}), 10) + 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: actor, - Platform: "bluesky", - Embedding: personaEmbedding, + UserID: authorID, + Platform: platform.String(), + Embedding: utils.FormatVector(personaEmbedding), TopKeywords: topWords, StyleSummary: styleSummary, PostCount: int32(len(posts)), @@ -586,30 +1495,320 @@ func (s *Server) getStyleProfile(ctx context.Context, request mcp.CallToolReques return nil, err } - if actor == "" { - actor = args.Actor - } - profile := &GetStyleProfileResponse{ - Actor: actor, + return &GetStyleProfileResponse{ + Author: authorID, PersonaEmbedding: personaEmbedding, TopKeywords: topWords, StyleSummary: styleSummary, PostCount: len(posts), - SourcePlatform: "bluesky", - } - - return profile, nil + SourcePlatform: platform.String(), + }, nil } -func newUSDCPaymentRequirement(config x402go.USDCRequirementConfig, schema *x402go.OutputSchema) (x402go.PaymentRequirement, error) { - req, err := x402go.NewUSDCPaymentRequirement(config) - if err != nil { - return x402go.PaymentRequirement{}, err +// 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 } - req.OutputSchema = schema - return req, nil + + 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) { + 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) +} + +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 = make([]clustering.Item, 0, len(res)) + for _, p := range res { + fp, ok := p.Post.Record.Val.(*bsky.FeedPost) + if !ok { + continue + } + freshPosts = append(freshPosts, clustering.Item{ + ID: p.Post.Uri, + Text: fp.Text, + }) + } + case ActivityPub: + res, err := s.apClient.GetLatestNPosts(ctx, author, limit) + if err != nil { + return toItems(cached), err + } + freshPosts = make([]clustering.Item, 0, len(res)) + for _, p := range res { + if p.Text != "" { + freshPosts = append(freshPosts, clustering.Item{ + ID: p.ID, + Text: p.Text, + }) + } + } + case Nostr: + res, err := s.nostrClient.GetLatestNPosts(ctx, author, limit) + if err != nil { + return toItems(cached), err + } + freshPosts = make([]clustering.Item, 0, len(res)) + for _, p := range res { + if p.Content != "" { + freshPosts = append(freshPosts, clustering.Item{ + ID: p.ID, + Text: p.Content, + }) + } + } + case Twitter: + res, err := s.twitterClient.GetLatestNPosts(ctx, author, limit) + if err != nil { + return toItems(cached), err + } + freshPosts = make([]clustering.Item, 0, len(res)) + for _, p := range res { + if p.Content != "" { + freshPosts = append(freshPosts, clustering.Item{ + ID: p.ID, + Text: p.Content, + }) + } + } + 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 +} + +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) startPostPruner() { + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + for range ticker.C { + if err := s.db.PruneStylePosts(context.Background()); err != nil { + log.Error("failed to prune style posts", "error", err) + } + } +} diff --git a/internal/server/storefake_test.go b/internal/server/storefake_test.go new file mode 100644 index 0000000..066ca5e --- /dev/null +++ b/internal/server/storefake_test.go @@ -0,0 +1,214 @@ +package server + +import ( + "context" + "database/sql" + "encoding/json" + "sort" + "time" + + "github.com/nacorid/naco-api/internal/store" +) + +// fakeStore is an in-memory implementation of the Store interface used by handler +// tests so db-backed tool paths (style profiles, analytics, similar users) can be +// exercised without a live Postgres+pgvector. +type fakeStore struct { + analyticsCache map[cacheKey]cacheEntry + analyticsPosts map[string]store.AnalyticsPost + coverageRanges []store.AnalyticsCoverageRange + styleProfiles map[string]store.UserStyleProfile + stylePosts map[string]store.UserStylePost + similarRows []store.FindSimilarUsersRow + findSimilarErr error + styleProfileErr error + stylePostsErr error + cacheErr bool + postsRangeErr bool + coverageErr bool +} + +type cacheKey struct { + user, platform, tool, hash string +} + +type cacheEntry struct { + value json.RawMessage + exp time.Time +} + +func newFakeStore() *fakeStore { + return &fakeStore{ + analyticsCache: map[cacheKey]cacheEntry{}, + analyticsPosts: map[string]store.AnalyticsPost{}, + styleProfiles: map[string]store.UserStyleProfile{}, + stylePosts: map[string]store.UserStylePost{}, + } +} + +func postKey(user, platform, id string) string { + return user + "|" + platform + "|" + id +} + +func profileKey(user, platform string) string { + return user + "|" + platform +} + +func (f *fakeStore) GetAnalyticsCache(ctx context.Context, arg store.GetAnalyticsCacheParams) (json.RawMessage, error) { + if f.cacheErr { + return nil, sql.ErrNoRows + } + key := cacheKey{arg.UserID, arg.Platform, arg.ToolName, arg.TimeRangeHash} + entry, ok := f.analyticsCache[key] + if !ok || time.Now().After(entry.exp) { + return nil, sql.ErrNoRows + } + return entry.value, nil +} + +func (f *fakeStore) UpsertAnalyticsCache(ctx context.Context, arg store.UpsertAnalyticsCacheParams) error { + key := cacheKey{arg.UserID, arg.Platform, arg.ToolName, arg.TimeRangeHash} + f.analyticsCache[key] = cacheEntry{value: arg.Result, exp: arg.ExpiresAt} + return nil +} + +func (f *fakeStore) GetAnalyticsPostsInRange(ctx context.Context, arg store.GetAnalyticsPostsInRangeParams) ([]store.GetAnalyticsPostsInRangeRow, error) { + if f.postsRangeErr { + return nil, sql.ErrConnDone + } + var rows []store.GetAnalyticsPostsInRangeRow + for _, p := range f.analyticsPosts { + if p.UserID != arg.UserID || p.Platform != arg.Platform { + continue + } + if p.PostTimestamp.Before(arg.PostTimestamp) || p.PostTimestamp.After(arg.PostTimestamp_2) { + continue + } + rows = append(rows, store.GetAnalyticsPostsInRangeRow{ + PostID: p.PostID, + PostText: p.PostText, + PostTimestamp: p.PostTimestamp, + Likes: p.Likes, + Reposts: p.Reposts, + Replies: p.Replies, + Quotes: p.Quotes, + }) + } + sort.Slice(rows, func(i, j int) bool { return rows[i].PostTimestamp.After(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/styleprofile_test.go b/internal/server/styleprofile_test.go new file mode 100644 index 0000000..ffc8079 --- /dev/null +++ b/internal/server/styleprofile_test.go @@ -0,0 +1,264 @@ +package server + +import ( + "database/sql" + "fmt" + "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", Text: "alpha post one", CreatedAt: time.Now()}, + {Author: "@a@mastodon.social", Content: "alpha post two", Text: "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 a week ago 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 week) 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(-10 * 24 * time.Hour), Valid: true}, + } + ap := &fakeAP{posts: []*activitypub.Post{ + {Author: "@a@mastodon.social", Content: "fresh post", Text: "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", Text: "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) + } +} + +// ---- 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)) + } +} + +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/twitter_analytics_test.go b/internal/server/twitter_analytics_test.go new file mode 100644 index 0000000..2cc9cf8 --- /dev/null +++ b/internal/server/twitter_analytics_test.go @@ -0,0 +1,162 @@ +package server + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/nacorid/naco-api/internal/clustering" + "github.com/nacorid/naco-api/internal/store" + "github.com/nacorid/naco-api/internal/twitter" +) + +func twitterPosts() []*twitter.Post { + base := time.Date(2024, 5, 10, 12, 0, 0, 0, time.UTC) + return []*twitter.Post{ + {ID: "t1", Author: "@a", Content: "twitter one", CreatedAt: base}, + {ID: "t2", Author: "@a", Content: "twitter two", CreatedAt: base.Add(time.Hour)}, + } +} + +func TestFetchTwitterAnalyticsPostsFetchesAndPersists(t *testing.T) { + tw := &fakeTwitter{posts: twitterPosts()} + st := newFakeStore() + s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, tw, st, &fakeOllama{}) + + start := time.Date(2024, 5, 1, 0, 0, 0, 0, time.UTC) + end := time.Date(2024, 5, 31, 0, 0, 0, 0, time.UTC) + items, err := s.fetchTwitterAnalyticsPosts(context.Background(), "@a", start, end, 50) + if err != nil { + t.Fatalf("fetchTwitterAnalyticsPosts error: %v", err) + } + if len(items) != 2 { + t.Fatalf("expected 2 fetched posts, got %d", len(items)) + } + if tw.calls != 1 { + t.Fatalf("expected 1 API call, got %d", tw.calls) + } + + // Newly fetched posts are persisted for future reuse. + if len(st.analyticsPosts) != 2 { + t.Fatalf("expected 2 persisted analytics posts, got %d", len(st.analyticsPosts)) + } + // Coverage range for the whole range recorded. + if len(st.coverageRanges) != 1 { + t.Fatalf("expected 1 coverage range, got %d", len(st.coverageRanges)) + } +} + +func TestFetchTwitterAnalyticsPostsServedFromCoverage(t *testing.T) { + // Posts already persisted and coverage present => no paid API call. + st := newFakeStore() + base := time.Date(2024, 5, 10, 12, 0, 0, 0, time.UTC) + st.coverageRanges = []store.AnalyticsCoverageRange{ + {UserID: "@a", Platform: "Twitter", CoveredStart: base, CoveredEnd: base.Add(2 * time.Hour)}, + } + st.analyticsPosts[postKey("@a", "Twitter", "t1")] = store.AnalyticsPost{ + UserID: "@a", Platform: "Twitter", PostID: "t1", PostText: "cached post", PostTimestamp: base, + } + tw := &fakeTwitter{} + s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, tw, st, &fakeOllama{}) + + start := base + end := base.Add(2 * time.Hour) + items, err := s.fetchTwitterAnalyticsPosts(context.Background(), "@a", start, end, 50) + if err != nil { + t.Fatalf("fetchTwitterAnalyticsPosts error: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 cached post, got %d", len(items)) + } + if tw.calls != 0 { + t.Fatalf("expected no API call when covered, got %d", tw.calls) + } + if items[0].Text != "cached post" { + t.Fatalf("expected cached text, got %q", items[0].Text) + } +} + +func TestMergeAnalyticsPostsDedupsAndCaps(t *testing.T) { + base := time.Date(2024, 5, 10, 12, 0, 0, 0, time.UTC) + st := newFakeStore() + // Cached posts overlap with fetched ones by ID, and both exceed the limit. + st.analyticsPosts[postKey("@a", "Twitter", "dup")] = store.AnalyticsPost{ + UserID: "@a", Platform: "Twitter", PostID: "dup", PostText: "dup cached", PostTimestamp: base, + } + s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{}) + + fetched := []clustering.Item{ + {ID: "dup", Text: "dup fetched", Timestamp: base}, + {ID: "b", Text: "b", Timestamp: base}, + {ID: "c", Text: "c", Timestamp: base}, + } + merged, err := s.mergeAnalyticsPosts(context.Background(), "@a", base, base.Add(time.Hour), 2, fetched) + if err != nil { + t.Fatalf("mergeAnalyticsPosts error: %v", err) + } + if len(merged) != 2 { + t.Fatalf("expected merged capped at 2, got %d", len(merged)) + } + // Fetched entries come first and dedupe against the cache. + if merged[0].ID != "dup" || merged[0].Text != "dup fetched" { + t.Fatalf("expected fetched dup first, got %+v", merged[0]) + } +} + +func TestPersistAnalyticsPostsSkipsEmptyIDs(t *testing.T) { + st := newFakeStore() + s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{}) + err := s.persistAnalyticsPosts(context.Background(), "@a", "Twitter", []clustering.Item{ + {ID: "", Text: "no id"}, + {ID: "real", Text: "real", Timestamp: time.Now()}, + }) + if err != nil { + t.Fatalf("persistAnalyticsPosts error: %v", err) + } + if len(st.analyticsPosts) != 1 { + t.Fatalf("expected only the post with an ID persisted, got %d", len(st.analyticsPosts)) + } + if _, ok := st.analyticsPosts[postKey("@a", "Twitter", "real")]; !ok { + t.Fatal("expected the 'real' post to be persisted") + } +} + +func TestAnalyticsTwitterEndToEnd(t *testing.T) { + // getEngagementMetrics over the Twitter platform drives the coverage logic. + tw := &fakeTwitter{posts: twitterPosts()} + st := newFakeStore() + s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, tw, st, &fakeOllama{}) + + res, err := callTool(s, "getEngagementMetrics", AnalyticsRequest{ + Author: "@a", + StartTime: "2024-05-01T00:00:00Z", + EndTime: "2024-05-31T00:00:00Z", + Platform: "Twitter", + }) + if err != nil { + t.Fatalf("getEngagementMetrics twitter error: %v", err) + } + if !strings.Contains(textOf(t, res), `"postId":"t1"`) { + t.Fatalf("expected twitter post t1 in result, got %s", textOf(t, res)) + } + if !strings.Contains(textOf(t, res), `"postId":"t2"`) { + t.Fatalf("expected twitter post t2 in result, got %s", textOf(t, res)) + } + if tw.calls != 1 { + t.Fatalf("expected 1 API call for the uncovered range, got %d", tw.calls) + } + + // Second identical request is served from the analytics cache (no API call). + if _, err := callTool(s, "getEngagementMetrics", AnalyticsRequest{ + Author: "@a", + StartTime: "2024-05-01T00:00:00Z", + EndTime: "2024-05-31T00:00:00Z", + Platform: "Twitter", + }); err != nil { + t.Fatalf("second call error: %v", err) + } + if tw.calls != 1 { + t.Fatalf("expected cache to avoid a second API call, got %d", tw.calls) + } +} diff --git a/internal/server/types.go b/internal/server/types.go index 1f9ae78..b1915b7 100644 --- a/internal/server/types.go +++ b/internal/server/types.go @@ -1,41 +1,55 @@ package server +import "github.com/nacorid/naco-api/internal/actor" + type Bodyfield struct { Type string `json:"type"` Description string `json:"description"` Required bool `json:"required,omitempty"` } +type InputSchema struct { + BodyFields map[string]any `json:"bodyFields,omitempty"` + Discoverable bool `json:"discoverable,omitempty"` + Method string `json:"method,omitempty"` + Type string `json:"type,omitempty"` +} + +type Schema struct { + Input InputSchema `json:"input"` + Output map[string]any `json:"output"` +} + type GetLatestPostsRequest struct { - Actor string `json:"actor" jsonschema_description:"Did or handle of an actor"` - N int `json:"n" jsonschema_description:"Number of posts to retrieve"` + Author string `json:"author" jsonschema_description:"Did or handle of an author"` + Limit int `json:"limit" jsonschema_description:"Number of posts to retrieve"` } type GetLatestPostsResponse struct { - Actor 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 { - Actor string `json:"actor" jsonschema_description:"Did or handle of an actor"` + Author string `json:"author" jsonschema_description:"Did or handle of an author"` StartTime string `json:"startTime" jsonschema_description:"Start time in RFC3339 format"` EndTime string `json:"endTime" jsonschema_description:"End time in RFC3339 format"` - N int `json:"n" jsonschema_description:"Number of posts to retrieve at most"` + Limit int `json:"limit" jsonschema_description:"Number of posts to retrieve at most"` } type GetPostsInTimeResponse struct { - Actor string `json:"actor" jsonschema_description:"Did of the actor"` - 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 { - Actor string `json:"actor" jsonschema_description:"Did or handle of an actor"` + Author string `json:"author" jsonschema_description:"Did or handle of an author"` RecordKey string `json:"rkey" jsonschema_description:"Record key of the post"` - Collection string `json:"collection" jsonschema_description:"Optional collection the post belongs to"` + Collection string `json:"collection" jsonschema_description:"Optional collection the post belongs to (Bluesky only). Defaults to 'app.bsky.feed.post'"` } type GetPostResponsePost struct { - Actor string `json:"actor,omitempty" jsonschema_description:"Did of the actor"` + Author string `json:"author,omitempty" jsonschema_description:"Did of the author"` Content string `json:"content" jsonschema_description:"The post's content"` Likes int64 `json:"likes" jsonschema_description:"Number of likes"` Reposts int64 `json:"reposts" jsonschema_description:"Number of reposts"` @@ -45,7 +59,7 @@ type GetPostResponsePost struct { } type GetFeedRequest struct { - URI string `json:"uri" jsonschema_description:"The URI of the feed to retrieve"` + URI string `json:"uri" jsonschema_description:"URI of the feed to retrieve. For Bluesky this is a feed URI; for other platforms it is treated as an author handle"` } type GetFeedResponse struct { @@ -54,21 +68,182 @@ type GetFeedResponse struct { } type GetStyleProfileRequest struct { - Actor string `json:"actor" jsonschema_description:"Did or handle of an actor"` + Author string `json:"author" jsonschema_description:"Did or handle of an author"` } type GetStyleProfileResponse struct { - Actor string `json:"actor" jsonschema_description:"Did of the actor"` + Author string `json:"author" jsonschema_description:"Did of the author"` PersonaEmbedding []float64 `json:"personaEmbedding" jsonschema_description:"Vector embedding representing the actor's persona"` TopKeywords []string `json:"topKeywords" jsonschema_description:"List of top keywords used by the actor"` - StyleSummary string `json:"styleSummary" jsonschema_description:"Summary of the actor's writing style"` + StyleSummary string `json:"styleSummary" jsonschema_description:"Summary of the author's writing style"` PostCount int `json:"postCount" jsonschema_description:"Number of posts analyzed for the style profile"` 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"` +} + +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"` TS int64 `json:"ts" jsonschema_description:"Unix timestamp of the message"` SID string `json:"sid" jsonschema_description:"Server ID, 8-12 characters"` } + +type platform int + +const ( + ActivityPub platform = iota + Bluesky + Nostr + Twitter +) + +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"` +} + +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"` +} + +type TopicModelingResponse struct { + Author string `json:"author" jsonschema_description:"Author identifier"` + Topics []TopicCluster `json:"topics" jsonschema_description:"Identified topic clusters"` +} + +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"` +} + +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"` +} + +type EngagementDriver struct { + Topic string `json:"topic" jsonschema_description:"Topic or keyword"` + AvgEngagement float64 `json:"avgEngagement" jsonschema_description:"Average engagement rate for posts with this topic"` + Correlation float64 `json:"correlation" jsonschema_description:"Correlation coefficient with engagement (-1 to 1)"` + PostCount int `json:"postCount" jsonschema_description:"Number of posts containing this topic"` + SamplePosts []string `json:"samplePosts" jsonschema_description:"Sample posts (max 2)"` +} diff --git a/internal/server/validation_test.go b/internal/server/validation_test.go new file mode 100644 index 0000000..f33aef0 --- /dev/null +++ b/internal/server/validation_test.go @@ -0,0 +1,261 @@ +package server + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + "github.com/bluesky-social/indigo/api/bsky" + "github.com/nacorid/naco-api/internal/activitypub" + "github.com/nacorid/naco-api/internal/nostr" +) + +// ---- validation matrix ---- + +func TestGetPostValidation(t *testing.T) { + s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) + + if _, err := callTool(s, "getPost", GetPostRequest{Author: "", RecordKey: "k"}); err == nil || !strings.Contains(err.Error(), "author") { + t.Fatalf("expected author error, got %v", err) + } + if _, err := callTool(s, "getPost", GetPostRequest{Author: "did:plc:abc", RecordKey: ""}); err == nil || !strings.Contains(err.Error(), "rkey") { + t.Fatalf("expected rkey error, got %v", err) + } + if _, err := callTool(s, "getPost", "not-an-object"); err == nil { + t.Fatal("expected invalid arguments error") + } + // Unparseable handle -> determinePlatform error + if _, err := callTool(s, "getPost", GetPostRequest{Author: "justhandle", RecordKey: "k"}); err == nil { + t.Fatal("expected determinePlatform error") + } +} + +func TestGetLatestPostsValidation(t *testing.T) { + s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) + + if _, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "", Limit: 5}); err == nil || !strings.Contains(err.Error(), "author") { + t.Fatalf("expected author error, got %v", err) + } + if _, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "did:plc:abc", Limit: 0}); err == nil || !strings.Contains(err.Error(), "positive") { + t.Fatalf("expected limit error, got %v", err) + } + if _, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "did:plc:abc", Limit: -3}); err == nil { + t.Fatal("expected negative limit error") + } + if _, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "did:plc:abc", Limit: 51}); err == nil || !strings.Contains(err.Error(), "at most 50") { + t.Fatalf("expected max-limit error, got %v", err) + } +} + +func TestGetPostsInTimeValidation(t *testing.T) { + s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) + + if _, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "", StartTime: "2024-01-01T00:00:00Z", EndTime: "2024-12-31T00:00:00Z", Limit: 5}); err == nil || !strings.Contains(err.Error(), "author") { + t.Fatalf("expected author error, got %v", err) + } + if _, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "did:plc:abc", StartTime: "2024-01-01T00:00:00Z", EndTime: "2024-12-31T00:00:00Z", Limit: 0}); err == nil || !strings.Contains(err.Error(), "positive") { + t.Fatalf("expected limit error, got %v", err) + } + // getPostsInTime now rejects over-max limit for consistency with getLatestPosts. + if _, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "did:plc:abc", StartTime: "2024-01-01T00:00:00Z", EndTime: "2024-12-31T00:00:00Z", Limit: 51}); err == nil || !strings.Contains(err.Error(), "at most 50") { + t.Fatalf("expected max-limit error, got %v", err) + } + // Bad start time. + if _, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "did:plc:abc", StartTime: "not-a-time", EndTime: "2024-12-31T00:00:00Z", Limit: 5}); err == nil || !strings.Contains(err.Error(), "startTime") { + t.Fatalf("expected startTime error, got %v", err) + } + // Bad end time reports the correct field name. + if _, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "did:plc:abc", StartTime: "2024-01-01T00:00:00Z", EndTime: "not-a-time", Limit: 5}); err == nil || !strings.Contains(err.Error(), "endTime") { + t.Fatalf("expected endTime error, got %v", err) + } +} + +func TestGetPostsInTimeSwapsReversedRange(t *testing.T) { + // Nostr fake returns posts regardless of args; the point is that a reversed + // range is accepted (swapped) rather than rejected. + nstr := &fakeNostr{posts: []*nostr.Post{{ID: "e1", Author: "npub1x", Content: "hi", CreatedAt: time.Now()}}} + s := newTestServer(&fakeBluesky{}, &fakeAP{}, nstr, &fakeTwitter{}) + res, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{ + Author: "npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + StartTime: "2024-12-31T00:00:00Z", + EndTime: "2024-01-01T00:00:00Z", + Limit: 5, + }) + if err != nil { + t.Fatalf("expected reversed range to be swapped and succeed, got %v", err) + } + if res == nil || len(res.Content) == 0 { + t.Fatal("expected a result") + } +} + +func TestGetFeedValidation(t *testing.T) { + s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) + if _, err := callTool(s, "getFeed", GetFeedRequest{URI: ""}); err == nil || !strings.Contains(err.Error(), "URI") { + t.Fatalf("expected URI error, got %v", err) + } +} + +func TestGetActorValidation(t *testing.T) { + s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) + if _, err := callTool(s, "getActor", GetActorRequest{Author: ""}); err == nil || !strings.Contains(err.Error(), "author") { + t.Fatalf("expected author error, got %v", err) + } + // Bare handle is unparseable -> determinePlatform error. + if _, err := callTool(s, "getActor", GetActorRequest{Author: "justhandle"}); err == nil { + t.Fatal("expected determinePlatform error") + } +} + +func TestSearchActorsValidation(t *testing.T) { + s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) + if _, err := callTool(s, "searchActors", SearchActorsRequest{Query: ""}); err == nil || !strings.Contains(err.Error(), "query") { + t.Fatalf("expected query error, got %v", err) + } +} + +// ---- twitter policy blocks ---- + +func TestTwitterPolicyBlocksAllTools(t *testing.T) { + cases := []struct { + tool string + args any + }{ + {"getPost", GetPostRequest{Author: "@alice", RecordKey: "k"}}, + {"getLatestPosts", GetLatestPostsRequest{Author: "@alice", Limit: 5}}, + {"getPostsInTime", GetPostsInTimeRequest{Author: "@alice", StartTime: "2024-01-01T00:00:00Z", EndTime: "2024-12-31T00:00:00Z", Limit: 5}}, + {"getFeed", GetFeedRequest{URI: "@alice"}}, + {"getActor", GetActorRequest{Author: "@alice"}}, + } + for _, c := range cases { + tw := &fakeTwitter{} + s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, tw) + _, err := callTool(s, c.tool, c.args) + if err == nil || !strings.Contains(err.Error(), "twitter_unavailable") { + t.Fatalf("%s: expected twitter_unavailable, got %v", c.tool, err) + } + if tw.calls != 0 { + t.Fatalf("%s: backend should not be called, got %d calls", c.tool, tw.calls) + } + } +} + +// ---- backend error propagation ---- + +func TestBackendErrorPropagation(t *testing.T) { + bsky := &fakeBluesky{err: fmt.Errorf("boom")} + s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) + if _, err := callTool(s, "getPost", GetPostRequest{Author: "did:plc:abc", RecordKey: "k"}); err == nil || !strings.Contains(err.Error(), "boom") { + t.Fatalf("expected backend error, got %v", err) + } +} + +func TestGetActorBackendError(t *testing.T) { + ap := &fakeAP{err: fmt.Errorf("boom")} + s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}) + if _, err := callTool(s, "getActor", GetActorRequest{Author: "@a@mastodon.social"}); err == nil || !strings.Contains(err.Error(), "boom") { + t.Fatalf("expected backend error, got %v", err) + } +} + +// ---- getPost success paths ---- + +func TestGetPostBlueskyAndNostr(t *testing.T) { + bsky := &fakeBluesky{postView: bskyPostView("hello bluesky")} + s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) + res, err := callTool(s, "getPost", GetPostRequest{Author: "did:plc:abc", RecordKey: "k"}) + if err != nil { + t.Fatalf("getPost bluesky error: %v", err) + } + if !strings.Contains(textOf(t, res), "hello bluesky") { + t.Fatalf("unexpected bluesky post text: %s", textOf(t, res)) + } + if bsky.calls != 1 { + t.Fatalf("expected 1 backend call, got %d", bsky.calls) + } + + nstr := &fakeNostr{post: &nostr.Post{ID: "n1", Author: "npub1x", Content: "nostr hello"}} + s2 := newTestServer(&fakeBluesky{}, &fakeAP{}, nstr, &fakeTwitter{}) + res2, err := callTool(s2, "getPost", GetPostRequest{Author: "npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", RecordKey: "n1"}) + if err != nil { + t.Fatalf("getPost nostr error: %v", err) + } + if !strings.Contains(textOf(t, res2), "nostr hello") { + t.Fatalf("unexpected nostr post text: %s", textOf(t, res2)) + } +} + +func TestGetLatestPostsEmptyResult(t *testing.T) { + ap := &fakeAP{} + s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}) + res, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "@a@mastodon.social", Limit: 5}) + if err != nil { + t.Fatalf("getLatestPosts error: %v", err) + } + var out GetLatestPostsResponse + if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(out.Posts) != 0 { + t.Fatalf("expected 0 posts, got %d", len(out.Posts)) + } + if out.Author != "@a@mastodon.social" { + t.Fatalf("expected author fallback to request author, got %q", out.Author) + } +} + +func TestGetLatestPostsBlueskyNilPostSkipped(t *testing.T) { + bsky := &fakeBluesky{feedViews: []*bsky.FeedDefs_FeedViewPost{ + {Post: bskyPostView("kept")}, + {Post: nil}, + }} + s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) + res, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "did:plc:abc", Limit: 5}) + if err != nil { + t.Fatalf("getLatestPosts error: %v", err) + } + var out GetLatestPostsResponse + if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(out.Posts) != 1 { + t.Fatalf("expected 1 post after skipping nil, got %d", len(out.Posts)) + } +} + +func TestGetFeedNonBlueskyFallback(t *testing.T) { + // A non-Bluesky feed URI is treated as an author handle and falls back to + // fetchLatestPosts (evaluated via the ActivityPub backend here). + ap := &fakeAP{posts: []*activitypub.Post{{Author: "@a@mastodon.social", Content: "fallback post", CreatedAt: time.Now()}}} + s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}) + res, err := callTool(s, "getFeed", GetFeedRequest{URI: "@a@mastodon.social"}) + if err != nil { + t.Fatalf("getFeed fallback error: %v", err) + } + if !strings.Contains(textOf(t, res), "fallback post") { + t.Fatalf("expected fallback post, got %s", textOf(t, res)) + } +} + +func TestGetFeedEmptyResult(t *testing.T) { + bsky := &fakeBluesky{feedOut: &bsky.FeedGetFeed_Output{Feed: nil}} + s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}) + res, err := callTool(s, "getFeed", GetFeedRequest{URI: "at://did:plc:abc/app.bsky.feed.generator/abc"}) + if err != nil { + t.Fatalf("getFeed error: %v", err) + } + var out GetFeedResponse + if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(out.Posts) != 0 { + t.Fatalf("expected 0 feed posts, got %d", len(out.Posts)) + } +} + +func jsonUnmarshal(t *testing.T, in string, out any) error { + t.Helper() + return json.Unmarshal([]byte(in), out) +} diff --git a/internal/server/webhook.go b/internal/server/webhook.go new file mode 100644 index 0000000..75708c1 --- /dev/null +++ b/internal/server/webhook.go @@ -0,0 +1,120 @@ +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 { + time.Sleep(delay) + } + + err := wh.sendAttempt(ctx, jsonBytes, attempt) + if err == nil { + log.InfoWithContext(ctx, + "webhook succeeded", + "attempt", attempt, + "server", wh.server, + "url", maskURL(wh.url), + ) + return + } + + lastErr = err + log.WarnWithContext(ctx, + "webhook attempt failed", + "attempt", attempt, + "total_attempts", 5, + "server", wh.server, + "url", maskURL(wh.url), + "error", err, + ) + } + + log.ErrorWithContext(ctx, + "webhook failed after all attempts", + "total_attempts", 5, + "server", wh.server, + "url", maskURL(wh.url), + "error", lastErr, + ) +} + +func (wh *WebhookSender) sendAttempt(ctx context.Context, jsonBytes []byte, attempt int) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, wh.url, strings.NewReader(string(jsonBytes))) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "naco-api/"+wh.server) + + resp, err := wh.httpCli.Do(req) + if err != nil { + return fmt.Errorf("do request: %w", err) + } + _ = resp.Body.Close() + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + + return fmt.Errorf("non-2xx status: %d", resp.StatusCode) +} + +func exponentialBackoff(attempt int) time.Duration { + base := 500 * time.Millisecond + delay := base << (attempt - 1) + if delay > 30*time.Second { + delay = 30 * time.Second + } + return delay +} + +func maskURL(url string) string { + if len(url) > 30 { + return url[:15] + "..." + url[len(url)-15:] + } + return url +} diff --git a/internal/store/AnalyticsCache.sql.go b/internal/store/AnalyticsCache.sql.go new file mode 100644 index 0000000..c0e4493 --- /dev/null +++ b/internal/store/AnalyticsCache.sql.go @@ -0,0 +1,96 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: AnalyticsCache.sql + +package store + +import ( + "context" + "encoding/json" + "time" +) + +const deleteAnalyticsCache = `-- name: DeleteAnalyticsCache :exec +DELETE FROM analytics_cache +WHERE user_id = $1 AND platform = $2 AND tool_name = $3 AND time_range_hash = $4 +` + +type DeleteAnalyticsCacheParams struct { + UserID string + Platform string + ToolName string + TimeRangeHash string +} + +func (q *Queries) DeleteAnalyticsCache(ctx context.Context, arg DeleteAnalyticsCacheParams) error { + _, err := q.db.ExecContext(ctx, deleteAnalyticsCache, + arg.UserID, + arg.Platform, + arg.ToolName, + arg.TimeRangeHash, + ) + return err +} + +const getAnalyticsCache = `-- name: GetAnalyticsCache :one +SELECT result FROM analytics_cache +WHERE user_id = $1 AND platform = $2 AND tool_name = $3 AND time_range_hash = $4 + AND expires_at > now() +` + +type GetAnalyticsCacheParams struct { + UserID string + Platform string + ToolName string + TimeRangeHash string +} + +func (q *Queries) GetAnalyticsCache(ctx context.Context, arg GetAnalyticsCacheParams) (json.RawMessage, error) { + row := q.db.QueryRowContext(ctx, getAnalyticsCache, + arg.UserID, + arg.Platform, + arg.ToolName, + arg.TimeRangeHash, + ) + var result json.RawMessage + err := row.Scan(&result) + return result, err +} + +const pruneAnalyticsCache = `-- name: PruneAnalyticsCache :exec +DELETE FROM analytics_cache WHERE expires_at <= now() +` + +func (q *Queries) PruneAnalyticsCache(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, pruneAnalyticsCache) + return err +} + +const upsertAnalyticsCache = `-- name: UpsertAnalyticsCache :exec +INSERT INTO analytics_cache (user_id, platform, tool_name, time_range_hash, result, expires_at) +VALUES ($1, $2, $3, $4, $5, $6) +ON CONFLICT (user_id, platform, tool_name, time_range_hash) +DO UPDATE SET result = EXCLUDED.result, expires_at = EXCLUDED.expires_at, computed_at = now() +` + +type UpsertAnalyticsCacheParams struct { + UserID string + Platform string + ToolName string + TimeRangeHash string + Result json.RawMessage + ExpiresAt time.Time +} + +func (q *Queries) UpsertAnalyticsCache(ctx context.Context, arg UpsertAnalyticsCacheParams) error { + _, err := q.db.ExecContext(ctx, upsertAnalyticsCache, + arg.UserID, + arg.Platform, + arg.ToolName, + arg.TimeRangeHash, + arg.Result, + arg.ExpiresAt, + ) + return err +} diff --git a/internal/store/AnalyticsCoverage.sql.go b/internal/store/AnalyticsCoverage.sql.go new file mode 100644 index 0000000..9010166 --- /dev/null +++ b/internal/store/AnalyticsCoverage.sql.go @@ -0,0 +1,96 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: AnalyticsCoverage.sql + +package store + +import ( + "context" + "time" +) + +const deleteAnalyticsCoverageRange = `-- name: DeleteAnalyticsCoverageRange :exec +DELETE FROM analytics_coverage_ranges +WHERE user_id = $1 AND platform = $2 AND covered_start = $3 AND covered_end = $4 +` + +type DeleteAnalyticsCoverageRangeParams struct { + UserID string + Platform string + CoveredStart time.Time + CoveredEnd time.Time +} + +func (q *Queries) DeleteAnalyticsCoverageRange(ctx context.Context, arg DeleteAnalyticsCoverageRangeParams) error { + _, err := q.db.ExecContext(ctx, deleteAnalyticsCoverageRange, + arg.UserID, + arg.Platform, + arg.CoveredStart, + arg.CoveredEnd, + ) + return err +} + +const getAnalyticsCoverageRanges = `-- name: GetAnalyticsCoverageRanges :many +SELECT covered_start, covered_end FROM analytics_coverage_ranges +WHERE user_id = $1 AND platform = $2 +ORDER BY covered_start +` + +type GetAnalyticsCoverageRangesParams struct { + UserID string + Platform string +} + +type GetAnalyticsCoverageRangesRow struct { + CoveredStart time.Time + CoveredEnd time.Time +} + +func (q *Queries) GetAnalyticsCoverageRanges(ctx context.Context, arg GetAnalyticsCoverageRangesParams) ([]GetAnalyticsCoverageRangesRow, error) { + rows, err := q.db.QueryContext(ctx, getAnalyticsCoverageRanges, arg.UserID, arg.Platform) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetAnalyticsCoverageRangesRow + for rows.Next() { + var i GetAnalyticsCoverageRangesRow + if err := rows.Scan(&i.CoveredStart, &i.CoveredEnd); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const insertAnalyticsCoverageRange = `-- name: InsertAnalyticsCoverageRange :exec +INSERT INTO analytics_coverage_ranges (user_id, platform, covered_start, covered_end, updated_at) +VALUES ($1, $2, $3, $4, now()) +ON CONFLICT (user_id, platform, covered_start, covered_end) +DO UPDATE SET updated_at = now() +` + +type InsertAnalyticsCoverageRangeParams struct { + UserID string + Platform string + CoveredStart time.Time + CoveredEnd time.Time +} + +func (q *Queries) InsertAnalyticsCoverageRange(ctx context.Context, arg InsertAnalyticsCoverageRangeParams) error { + _, err := q.db.ExecContext(ctx, insertAnalyticsCoverageRange, + arg.UserID, + arg.Platform, + arg.CoveredStart, + arg.CoveredEnd, + ) + return err +} diff --git a/internal/store/AnalyticsPosts.sql.go b/internal/store/AnalyticsPosts.sql.go new file mode 100644 index 0000000..f68dd24 --- /dev/null +++ b/internal/store/AnalyticsPosts.sql.go @@ -0,0 +1,113 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: AnalyticsPosts.sql + +package store + +import ( + "context" + "time" +) + +const getAnalyticsPostsInRange = `-- name: GetAnalyticsPostsInRange :many +SELECT post_id, post_text, post_timestamp, likes, reposts, replies, quotes +FROM analytics_posts +WHERE user_id = $1 AND platform = $2 AND post_timestamp >= $3 AND post_timestamp <= $4 +ORDER BY post_timestamp DESC +LIMIT $5 +` + +type GetAnalyticsPostsInRangeParams struct { + UserID string + Platform string + PostTimestamp time.Time + PostTimestamp_2 time.Time + Limit int32 +} + +type GetAnalyticsPostsInRangeRow struct { + PostID string + PostText string + PostTimestamp time.Time + Likes int64 + Reposts int64 + Replies int64 + Quotes int64 +} + +func (q *Queries) GetAnalyticsPostsInRange(ctx context.Context, arg GetAnalyticsPostsInRangeParams) ([]GetAnalyticsPostsInRangeRow, error) { + rows, err := q.db.QueryContext(ctx, getAnalyticsPostsInRange, + arg.UserID, + arg.Platform, + arg.PostTimestamp, + arg.PostTimestamp_2, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetAnalyticsPostsInRangeRow + for rows.Next() { + var i GetAnalyticsPostsInRangeRow + if err := rows.Scan( + &i.PostID, + &i.PostText, + &i.PostTimestamp, + &i.Likes, + &i.Reposts, + &i.Replies, + &i.Quotes, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertAnalyticsPosts = `-- name: UpsertAnalyticsPosts :exec +INSERT INTO analytics_posts (user_id, platform, post_id, post_text, post_timestamp, likes, reposts, replies, quotes) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +ON CONFLICT (user_id, platform, post_id) +DO UPDATE SET post_text = EXCLUDED.post_text, + post_timestamp = EXCLUDED.post_timestamp, + likes = EXCLUDED.likes, + reposts = EXCLUDED.reposts, + replies = EXCLUDED.replies, + quotes = EXCLUDED.quotes +` + +type UpsertAnalyticsPostsParams struct { + UserID string + Platform string + PostID string + PostText string + PostTimestamp time.Time + Likes int64 + Reposts int64 + Replies int64 + Quotes int64 +} + +func (q *Queries) UpsertAnalyticsPosts(ctx context.Context, arg UpsertAnalyticsPostsParams) error { + _, err := q.db.ExecContext(ctx, upsertAnalyticsPosts, + arg.UserID, + arg.Platform, + arg.PostID, + arg.PostText, + arg.PostTimestamp, + arg.Likes, + arg.Reposts, + arg.Replies, + arg.Quotes, + ) + return err +} diff --git a/internal/store/DeleteStylePosts.sql.go b/internal/store/DeleteStylePosts.sql.go new file mode 100644 index 0000000..e9680d7 --- /dev/null +++ b/internal/store/DeleteStylePosts.sql.go @@ -0,0 +1,25 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: DeleteStylePosts.sql + +package store + +import ( + "context" +) + +const deleteStylePosts = `-- name: DeleteStylePosts :exec +DELETE FROM user_style_posts +WHERE user_id = $1 AND platform = $2 AND platform != 'Twitter' +` + +type DeleteStylePostsParams struct { + UserID string + Platform string +} + +func (q *Queries) DeleteStylePosts(ctx context.Context, arg DeleteStylePostsParams) error { + _, err := q.db.ExecContext(ctx, deleteStylePosts, arg.UserID, arg.Platform) + return err +} diff --git a/internal/store/FindSimilarUsers.sql.go b/internal/store/FindSimilarUsers.sql.go index 6211da2..b2d4767 100644 --- a/internal/store/FindSimilarUsers.sql.go +++ b/internal/store/FindSimilarUsers.sql.go @@ -20,8 +20,8 @@ LIMIT $2 ` type FindSimilarUsersParams struct { - Column1 interface{} - Limit int32 + QueryEmbedding interface{} + LimitVal int32 } type FindSimilarUsersRow struct { @@ -35,7 +35,7 @@ type FindSimilarUsersRow struct { } func (q *Queries) FindSimilarUsers(ctx context.Context, arg FindSimilarUsersParams) ([]FindSimilarUsersRow, error) { - rows, err := q.db.QueryContext(ctx, findSimilarUsers, arg.Column1, arg.Limit) + rows, err := q.db.QueryContext(ctx, findSimilarUsers, arg.QueryEmbedding, arg.LimitVal) if err != nil { return nil, err } diff --git a/internal/store/GetStylePostByID.sql.go b/internal/store/GetStylePostByID.sql.go new file mode 100644 index 0000000..b3ca156 --- /dev/null +++ b/internal/store/GetStylePostByID.sql.go @@ -0,0 +1,33 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: GetStylePostByID.sql + +package store + +import ( + "context" +) + +const getStylePostByID = `-- name: GetStylePostByID :one +SELECT post_id, post_text FROM user_style_posts +WHERE user_id = $1 AND platform = $2 AND post_id = $3 +` + +type GetStylePostByIDParams struct { + UserID string + Platform string + PostID string +} + +type GetStylePostByIDRow struct { + PostID string + PostText string +} + +func (q *Queries) GetStylePostByID(ctx context.Context, arg GetStylePostByIDParams) (GetStylePostByIDRow, error) { + row := q.db.QueryRowContext(ctx, getStylePostByID, arg.UserID, arg.Platform, arg.PostID) + var i GetStylePostByIDRow + err := row.Scan(&i.PostID, &i.PostText) + return i, err +} diff --git a/internal/store/GetStylePosts.sql.go b/internal/store/GetStylePosts.sql.go new file mode 100644 index 0000000..34c443d --- /dev/null +++ b/internal/store/GetStylePosts.sql.go @@ -0,0 +1,51 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: GetStylePosts.sql + +package store + +import ( + "context" +) + +const getStylePosts = `-- name: GetStylePosts :many +SELECT post_id, post_text FROM user_style_posts +WHERE user_id = $1 AND platform = $2 +ORDER BY created_at DESC +LIMIT $3 +` + +type GetStylePostsParams struct { + UserID string + Platform string + Limit int32 +} + +type GetStylePostsRow struct { + PostID string + PostText string +} + +func (q *Queries) GetStylePosts(ctx context.Context, arg GetStylePostsParams) ([]GetStylePostsRow, error) { + rows, err := q.db.QueryContext(ctx, getStylePosts, arg.UserID, arg.Platform, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetStylePostsRow + for rows.Next() { + var i GetStylePostsRow + if err := rows.Scan(&i.PostID, &i.PostText); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/store/GetStylePostsByIDs.sql.go b/internal/store/GetStylePostsByIDs.sql.go new file mode 100644 index 0000000..fb5c417 --- /dev/null +++ b/internal/store/GetStylePostsByIDs.sql.go @@ -0,0 +1,51 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: GetStylePostsByIDs.sql + +package store + +import ( + "context" + + "github.com/lib/pq" +) + +const getStylePostsByIDs = `-- name: GetStylePostsByIDs :many +SELECT post_id, post_text FROM user_style_posts +WHERE user_id = $1 AND platform = $2 AND post_id = ANY($3::text[]) +` + +type GetStylePostsByIDsParams struct { + UserID string + Platform string + Column3 []string +} + +type GetStylePostsByIDsRow struct { + PostID string + PostText string +} + +func (q *Queries) GetStylePostsByIDs(ctx context.Context, arg GetStylePostsByIDsParams) ([]GetStylePostsByIDsRow, error) { + rows, err := q.db.QueryContext(ctx, getStylePostsByIDs, arg.UserID, arg.Platform, pq.Array(arg.Column3)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetStylePostsByIDsRow + for rows.Next() { + var i GetStylePostsByIDsRow + if err := rows.Scan(&i.PostID, &i.PostText); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/store/GetStyleProfile.sql.go b/internal/store/GetStyleProfile.sql.go index e86003c..153823e 100644 --- a/internal/store/GetStyleProfile.sql.go +++ b/internal/store/GetStyleProfile.sql.go @@ -12,7 +12,7 @@ import ( ) const getStyleProfile = `-- name: GetStyleProfile :one -SELECT id, user_id, platform, embedding, top_keywords, style_summary, post_count, modified_at, created_at FROM user_style_profiles +SELECT id, user_id, platform, embedding, top_keywords, style_summary, post_count, modified_at, created_at, model_version, schema_version, algorithm_version FROM user_style_profiles WHERE user_id = $1 AND platform = $2 ` @@ -34,6 +34,9 @@ func (q *Queries) GetStyleProfile(ctx context.Context, arg GetStyleProfileParams &i.PostCount, &i.ModifiedAt, &i.CreatedAt, + &i.ModelVersion, + &i.SchemaVersion, + &i.AlgorithmVersion, ) return i, err } diff --git a/internal/store/PruneStylePosts.sql.go b/internal/store/PruneStylePosts.sql.go new file mode 100644 index 0000000..d2d6ae3 --- /dev/null +++ b/internal/store/PruneStylePosts.sql.go @@ -0,0 +1,20 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: PruneStylePosts.sql + +package store + +import ( + "context" +) + +const pruneStylePosts = `-- name: PruneStylePosts :exec +DELETE FROM user_style_posts +WHERE created_at < now() - interval '7 days' AND platform != 'Twitter' +` + +func (q *Queries) PruneStylePosts(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, pruneStylePosts) + return err +} diff --git a/internal/store/UpsertStylePosts.sql.go b/internal/store/UpsertStylePosts.sql.go new file mode 100644 index 0000000..49eff57 --- /dev/null +++ b/internal/store/UpsertStylePosts.sql.go @@ -0,0 +1,34 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: UpsertStylePosts.sql + +package store + +import ( + "context" +) + +const upsertStylePosts = `-- name: UpsertStylePosts :exec +INSERT INTO user_style_posts (user_id, platform, post_id, post_text) +VALUES ($1, $2, $3, $4) +ON CONFLICT (user_id, platform, post_id) +DO UPDATE SET post_text = EXCLUDED.post_text, created_at = now() +` + +type UpsertStylePostsParams struct { + UserID string + Platform string + PostID string + PostText string +} + +func (q *Queries) UpsertStylePosts(ctx context.Context, arg UpsertStylePostsParams) error { + _, err := q.db.ExecContext(ctx, upsertStylePosts, + arg.UserID, + arg.Platform, + arg.PostID, + arg.PostText, + ) + return err +} diff --git a/internal/store/UpsertStyleProfiles.sql.go b/internal/store/UpsertStyleProfiles.sql.go index 9e80301..e6d431a 100644 --- a/internal/store/UpsertStyleProfiles.sql.go +++ b/internal/store/UpsertStyleProfiles.sql.go @@ -13,14 +13,14 @@ import ( const upsertStyleProfile = `-- name: UpsertStyleProfile :exec INSERT INTO user_style_profiles (user_id, platform, embedding, top_keywords, style_summary, post_count) -VALUES ($1, $2, $3, $4, $5, $6) +VALUES ($1, $2, $3::vector, $4, $5, $6) ON CONFLICT (user_id, platform) DO UPDATE SET embedding = EXCLUDED.embedding, top_keywords = EXCLUDED.top_keywords, style_summary = EXCLUDED.style_summary, post_count = EXCLUDED.post_count, - updated_at = NOW() + modified_at = NOW() ` type UpsertStyleProfileParams struct { diff --git a/internal/store/integration_test.go b/internal/store/integration_test.go new file mode 100644 index 0000000..2adc0b4 --- /dev/null +++ b/internal/store/integration_test.go @@ -0,0 +1,134 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "os" + "strings" + "testing" + "time" + + _ "github.com/lib/pq" +) + +// TestStoreRoundTrip exercises the sqlc queries against a real Postgres+pgvector database. +// It is skipped unless TEST_DATABASE_URL is set (e.g. a local instance with the vector +// extension available), so it never fails in environments without a database. +func TestStoreRoundTrip(t *testing.T) { + 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.Fatal(err) + } + defer conn.Close() + if err := conn.PingContext(ctx); err != nil { + t.Fatalf("cannot reach test database: %v", err) + } + if err := Migrate(ctx, conn, "sql/schema"); err != nil { + t.Fatalf("migrate: %v", err) + } + q := New(conn) + + uid := fmt.Sprintf("test_%d", time.Now().UnixNano()) + emb := "[" + strings.TrimSuffix(strings.Repeat("0.0,", 1024), ",") + "]" + + if err := q.UpsertStyleProfile(ctx, UpsertStyleProfileParams{ + UserID: uid, + Platform: "Bluesky", + Embedding: emb, + TopKeywords: []string{"alpha", "beta"}, + StyleSummary: "summary", + PostCount: 5, + }); err != nil { + t.Fatalf("upsert: %v", err) + } + + p, err := q.GetStyleProfile(ctx, GetStyleProfileParams{UserID: uid, Platform: "Bluesky"}) + if err != nil { + t.Fatalf("get: %v", err) + } + if p.UserID != uid || p.Platform != "Bluesky" || p.PostCount != 5 { + t.Fatalf("unexpected profile: %+v", p) + } + + rows, err := q.FindSimilarUsers(ctx, FindSimilarUsersParams{QueryEmbedding: emb, LimitVal: 10}) + if err != nil { + t.Fatalf("find similar: %v", err) + } + found := false + for _, r := range rows { + if r.UserID == uid { + found = true + } + } + if !found { + t.Fatalf("expected newly inserted profile to be returned by similarity search") + } +} + +// TestStoreSaveTransaction exercises SaveTransaction against a real Postgres database. +// It is skipped unless TEST_DATABASE_URL is set. +func TestStoreSaveTransaction(t *testing.T) { + 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.Fatal(err) + } + defer conn.Close() + if err := conn.PingContext(ctx); err != nil { + t.Fatalf("cannot reach test database: %v", err) + } + if err := Migrate(ctx, conn, "sql/schema"); err != nil { + t.Fatalf("migrate: %v", err) + } + q := New(conn) + + txHash := fmt.Sprintf("0x_%d", time.Now().Unix()) + amount := "10.0" + asset := "ETH" + network := "eip155:1" + now := time.Now().Unix() + recipient := "0x" + strings.TrimLeft(fmt.Sprintf("%x", now%100000000), "0") + sender := "0x" + strings.TrimLeft(fmt.Sprintf("%x", now%100000000+0xab), "0") + + 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, got %d", len(ret)) + } + + id := ret[0].ID + if id <= 0 { + t.Fatalf("expected positive ID, got %d", id) + } + + // Verify the returned row has the expected fields + if !ret[0].CreatedAt.Valid { + t.Fatal("expected CreatedAt to be set") + } + + // Verify we can retrieve the transaction back + // Note: we'd need a SELECT query for full verification, but the insert + // returning id and created_at is the critical path tested here + + t.Logf("SaveTransaction successful: id=%d, hash=%s", id, txHash) +} diff --git a/internal/store/migrate.go b/internal/store/migrate.go new file mode 100644 index 0000000..b6d7932 --- /dev/null +++ b/internal/store/migrate.go @@ -0,0 +1,106 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// Migrate applies every *.sql file in dir (in filename order). Each file is expected to +// contain a "-- +goose up" section (between that marker and "-- +goose down", or EOF) which +// is executed inside a single transaction. Applied versions are recorded in +// schema_migrations so re-running is a no-op. +func Migrate(ctx context.Context, db *sql.DB, dir string) error { + if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`); err != nil { + return fmt.Errorf("create schema_migrations table: %w", err) + } + + entries, err := os.ReadDir(dir) + if err != nil { + return fmt.Errorf("read migrations dir %q: %w", dir, err) + } + + type migration struct{ name, up string } + migrations := make([]migration, 0, len(entries)) + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") { + continue + } + raw, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + return fmt.Errorf("read migration %q: %w", e.Name(), err) + } + migrations = append(migrations, migration{name: e.Name(), up: extractUpSQL(string(raw))}) + } + sort.Slice(migrations, func(i, j int) bool { return migrations[i].name < migrations[j].name }) + + for _, m := range migrations { + if strings.TrimSpace(m.up) == "" { + continue + } + applied, err := versionApplied(ctx, db, m.name) + if err != nil { + return err + } + if applied { + continue + } + if err := applyMigration(ctx, db, m.name, m.up); err != nil { + return fmt.Errorf("apply migration %q: %w", m.name, err) + } + } + return nil +} + +// extractUpSQL returns the SQL between the "-- +goose up" marker and the next +// "-- +goose down" marker (or end of file). +func extractUpSQL(content string) string { + var b strings.Builder + inUp := false + for _, line := range strings.Split(content, "\n") { + switch strings.TrimSpace(line) { + case "-- +goose up": + inUp = true + continue + case "-- +goose down": + inUp = false + continue + } + if inUp { + b.WriteString(line) + b.WriteString("\n") + } + } + return b.String() +} + +func versionApplied(ctx context.Context, db *sql.DB, version string) (bool, error) { + var count int + if err := db.QueryRowContext(ctx, `SELECT count(*) FROM schema_migrations WHERE version = $1`, version).Scan(&count); err != nil { + return false, fmt.Errorf("check migration %q: %w", version, err) + } + return count > 0, nil +} + +func applyMigration(ctx context.Context, db *sql.DB, version, up string) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + if _, err := tx.ExecContext(ctx, up); err != nil { + _ = tx.Rollback() + return err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations (version) VALUES ($1)`, version); err != nil { + _ = tx.Rollback() + return err + } + return tx.Commit() +} diff --git a/internal/store/models.go b/internal/store/models.go index eac3136..2bba824 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -6,8 +6,43 @@ package store import ( "database/sql" + "encoding/json" + "time" ) +type AnalyticsCache struct { + ID int64 + UserID string + Platform string + ToolName string + TimeRangeHash string + Result json.RawMessage + ComputedAt time.Time + ExpiresAt time.Time +} + +type AnalyticsCoverageRange struct { + UserID string + Platform string + CoveredStart time.Time + CoveredEnd time.Time + UpdatedAt time.Time +} + +type AnalyticsPost struct { + ID int64 + UserID string + Platform string + PostID string + PostText string + PostTimestamp time.Time + Likes int64 + Reposts int64 + Replies int64 + Quotes int64 + CreatedAt time.Time +} + type Transaction struct { ID int64 X402TransactionHash string @@ -19,14 +54,26 @@ type Transaction struct { CreatedAt sql.NullTime } -type UserStyleProfile struct { - ID int64 - UserID string - Platform string - Embedding interface{} - TopKeywords []string - StyleSummary string - PostCount int32 - ModifiedAt sql.NullTime - CreatedAt sql.NullTime +type UserStylePost struct { + ID int64 + UserID string + Platform string + PostID string + PostText string + CreatedAt time.Time +} + +type UserStyleProfile struct { + ID int64 + UserID string + Platform string + Embedding interface{} + TopKeywords []string + StyleSummary string + PostCount int32 + ModifiedAt sql.NullTime + CreatedAt sql.NullTime + ModelVersion int32 + SchemaVersion int32 + AlgorithmVersion int32 } diff --git a/internal/twitter/client.go b/internal/twitter/client.go new file mode 100644 index 0000000..08e97e2 --- /dev/null +++ b/internal/twitter/client.go @@ -0,0 +1,347 @@ +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" + +type Client struct { + httpClient *http.Client + bearerToken string +} + +func NewTwitterClient(bearerToken string) *Client { + return &Client{ + httpClient: http.DefaultClient, + 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"` +} + +// doGet issues a GET and retries with exponential backoff when Twitter returns HTTP 429 +// (Too Many Requests). It honors the x-rate-limit-* headers and caps retries so a +// sustained rate limit still surfaces as a real error to the caller (and thus isn't billed). +func (c *Client) doGet(ctx context.Context, endpoint string) ([]byte, error) { + const maxRetries = 4 + backoff := 500 * time.Millisecond + 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) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to do request: %w", err) + } + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode == http.StatusTooManyRequests { + if attempt >= maxRetries { + return nil, fmt.Errorf("twitter rate limited after retries: %s", resp.Status) + } + // Honor x-rate-limit-* headers first + remaining := resp.Header.Get("x-rate-limit-remaining") + reset := resp.Header.Get("x-rate-limit-reset") + if ra := remaining; ra != "" { + if rem, perr := strconv.Atoi(ra); perr == nil { + if rem <= 0 && reset != "" { + if rst, perr2 := strconv.Atoi(reset); perr2 == nil { + // Wait until the rate limit resets (Unix timestamp) + now := time.Now().Unix() + rst64 := int64(rst) + wait := time.Duration(rst64-now) * time.Second + if wait > 0 { + log.WarnWithContext(ctx, "twitter rate limited, waiting until reset", "reset", reset, "wait", wait.String()) + select { + case <-time.After(wait): + case <-ctx.Done(): + return nil, ctx.Err() + } + backoff = 500 * time.Millisecond + continue + } + } + } + } + } + // Fall back to Retry-After if headers not useful + if ra := resp.Header.Get("Retry-After"); ra != "" { + if secs, perr := strconv.Atoi(ra); perr == nil && secs > 0 { + backoff = time.Duration(secs) * time.Second + } + } + log.WarnWithContext(ctx, "twitter rate limited, backing off", "attempt", attempt+1, "wait", backoff.String()) + select { + case <-time.After(backoff): + case <-ctx.Done(): + return nil, ctx.Err() + } + backoff *= 2 + continue + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("twitter error: %s: %s", resp.Status, string(body)) + } + return body, nil + } +} + +func (c *Client) resolveUser(ctx context.Context, username string) (string, 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) + } + return ur.Data.ID, 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, err := c.resolveUser(ctx, username) + if err != nil { + return nil, err + } + + if limit > 100 { + limit = 100 + } + if limit < 5 { + limit = 5 + } + + endpoint := fmt.Sprintf("%s/users/%s/tweets?max_results=%d&tweet.fields=created_at,public_metrics&exclude=retweets,replies", + apiBase, url.PathEscape(userID), limit) + 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 { + created := parseTwitterTime(t.CreatedAt) + posts = append(posts, &Post{ + ID: t.ID, + Author: userID, + Content: t.Text, + CreatedAt: created, + Likes: t.PublicMetrics.LikeCount, + Reposts: t.PublicMetrics.RetweetCount, + Quotes: t.PublicMetrics.QuoteCount, + Replies: t.PublicMetrics.ReplyCount, + }) + } + return posts, nil +} + +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, err := c.resolveUser(ctx, username) + if err != nil { + return nil, err + } + if limit > 100 { + limit = 100 + } + if limit < 5 { + limit = 5 + } + + 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) + 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 { + created := parseTwitterTime(t.CreatedAt) + posts = append(posts, &Post{ + ID: t.ID, + Author: userID, + Content: t.Text, + CreatedAt: created, + 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 := id + if len(tr.Includes.Users) > 0 { + author = tr.Includes.Users[0].Username + } + created, _ := time.Parse(time.RFC3339, tr.Data.CreatedAt) + return &Post{ + ID: tr.Data.ID, + Author: author, + Content: tr.Data.Text, + CreatedAt: created, + Likes: tr.Data.PublicMetrics.LikeCount, + Reposts: tr.Data.PublicMetrics.RetweetCount, + Quotes: tr.Data.PublicMetrics.QuoteCount, + Replies: tr.Data.PublicMetrics.ReplyCount, + }, nil +} + +type userFields struct { + ID string `json:"id"` + Name string `json:"name"` + Username string `json:"username"` + Description string `json:"description"` + ProfileImageURL string `json:"profile_image_url"` + PublicMetrics struct { + FollowersCount int `json:"followers_count"` + FollowingCount int `json:"following_count"` + TweetCount int `json:"tweet_count"` + } `json:"public_metrics"` +} diff --git a/internal/twitter/client_test.go b/internal/twitter/client_test.go new file mode 100644 index 0000000..9dca301 --- /dev/null +++ b/internal/twitter/client_test.go @@ -0,0 +1,378 @@ +package twitter + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +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 + } + fmt.Fprint(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() + fmt.Fprint(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 != "12345" { + t.Fatalf("unexpected post: %+v", posts[0]) + } + if !strings.Contains(tweetsURL, "max_results=10") { + t.Fatalf("expected max_results=10, got %q", tweetsURL) + } +} + +func TestGetLatestNPostsLimitClamp(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(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 != "5" { + t.Fatalf("expected clamped max_results=5 for limit=3, got %q", got) + } + fmt.Fprint(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) { + fmt.Fprint(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 + } + fmt.Fprint(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) { + fmt.Fprint(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") + } + fmt.Fprint(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 + w.Header().Set("x-rate-limit-remaining", "0") + w.Header().Set("x-rate-limit-reset", fmt.Sprintf("%d", time.Now().Add(100*time.Millisecond).Unix())) + w.WriteHeader(http.StatusTooManyRequests) + return + } + // Second call: success + fmt.Fprint(w, `{"data":{"id":"12345","name":"Test","username":"testuser"}}`) + }) + mux.HandleFunc("/2/users/12345/tweets", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"data":[{"id":"1","text":"hello","created_at":"2024-01-01T00:00:00Z"}]}`) + }) + server := httptest.NewServer(mux) + defer server.Close() + + c := newTestClient(server.URL) + // Use a short context timeout to avoid waiting too long + 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 TestRateLimitRetryWithRetryAfterHeader(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 Retry-After header + w.Header().Set("Retry-After", "1") // 1 second + w.WriteHeader(http.StatusTooManyRequests) + return + } + // Second call: success + fmt.Fprint(w, `{"data":{"id":"12345","name":"Test","username":"testuser"}}`) + }) + mux.HandleFunc("/2/users/12345/tweets", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(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++ + // Always rate limited - no reset header, no Retry-After, so exponential backoff is used + w.Header().Set("x-rate-limit-remaining", "0") + // Don't set x-rate-limit-reset or Retry-After to force exponential backoff + w.WriteHeader(http.StatusTooManyRequests) + }) + server := httptest.NewServer(mux) + defer server.Close() + + c := newTestClient(server.URL) + // Use a timeout long enough for all retries (4 retries with exponential backoff: 500ms, 1s, 2s, 4s = ~7.5s) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + _, err := c.GetLatestNPosts(ctx, "@testuser", 10) + if err == nil { + t.Fatal("expected error after max retries exhausted") + } + // Should have tried maxRetries (4) + 1 initial = 5 times + if callCount < 5 { + t.Fatalf("expected at least 5 attempts (1 initial + 4 retries), got %d", callCount) + } + if !strings.Contains(err.Error(), "rate limited after retries") { + t.Fatalf("expected 'rate limited after retries' 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) + fmt.Fprint(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) + fmt.Fprint(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.Header().Set("Retry-After", "1") + w.WriteHeader(http.StatusTooManyRequests) + }) + server := httptest.NewServer(mux) + defer server.Close() + + c := newTestClient(server.URL) + // Need enough time for max retries (4 retries * 1 second = 4 seconds) + initial + ctx, cancel := context.WithTimeout(context.Background(), 10*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(), "twitter error") && !strings.Contains(err.Error(), "rate limited after retries") { + t.Fatalf("expected twitter error or rate limited after retries, got %v", err) + } +} + +func TestContextCancellationDuringRetry(t *testing.T) { + callCount := 0 + mux := http.NewServeMux() + mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) { + callCount++ + // First call: rate limited with long reset time + if callCount == 1 { + 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) + return + } + }) + server := httptest.NewServer(mux) + defer server.Close() + + c := newTestClient(server.URL) + // Cancel context immediately after first call + ctx, cancel := context.WithCancel(context.Background()) + // Start the request in a goroutine + done := make(chan error, 1) + go func() { + _, err := c.GetLatestNPosts(ctx, "@testuser", 10) + done <- err + }() + + // Give it a moment to make the first call, then cancel + time.Sleep(100 * time.Millisecond) + cancel() + + select { + case err := <-done: + if err == nil { + t.Fatal("expected error from context cancellation") + } + if !strings.Contains(err.Error(), "context canceled") && !strings.Contains(err.Error(), "context cancelled") { + t.Fatalf("expected context cancellation error, got %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("test timed out waiting for cancellation") + } +} diff --git a/internal/utils/counter.go b/internal/utils/counter.go index 22b70d5..39e8399 100644 --- a/internal/utils/counter.go +++ b/internal/utils/counter.go @@ -3,7 +3,7 @@ package utils import "sync/atomic" type Counter struct { - valut atomic.Int64 + value atomic.Int64 } func NewCounter() *Counter { @@ -11,13 +11,13 @@ func NewCounter() *Counter { } func (c *Counter) Increment() { - c.valut.Add(1) + c.value.Add(1) } func (c *Counter) Get() int64 { - return c.valut.Load() + return c.value.Load() } func (c *Counter) Reset() { - c.valut.Store(0) + c.value.Store(0) } diff --git a/internal/utils/styleProfiles.go b/internal/utils/styleProfiles.go index 051d420..7a6a587 100644 --- a/internal/utils/styleProfiles.go +++ b/internal/utils/styleProfiles.go @@ -7,12 +7,26 @@ import ( "strings" ) -func ParseEmbedding(value interface{}) ([]float64, error) { +func parseVectorString(v string) ([]float64, error) { + s := strings.Trim(v, "[]") + parts := strings.Split(s, ",") + vec := make([]float64, len(parts)) + for i, p := range parts { + f, err := strconv.ParseFloat(strings.TrimSpace(p), 64) + if err != nil { + return nil, err + } + vec[i] = f + } + return vec, nil +} + +func ParseEmbedding(value any) ([]float64, error) { switch v := value.(type) { case []float64: return v, nil - case []interface{}: + case []any: vec := make([]float64, len(v)) for i, x := range v { f, ok := x.(float64) @@ -24,24 +38,24 @@ func ParseEmbedding(value interface{}) ([]float64, error) { return vec, nil case string: - // pgvector returns a string like "[0.12, 0.53, ...]" sometimes - s := strings.Trim(v, "[]") - parts := strings.Split(s, ",") - vec := make([]float64, len(parts)) - for i, p := range parts { - f, err := strconv.ParseFloat(strings.TrimSpace(p), 64) - if err != nil { - return nil, err - } - vec[i] = f - } - return vec, nil + return parseVectorString(v) + + case []byte: + return parseVectorString(string(v)) default: return nil, fmt.Errorf("unexpected embedding type: %T", v) } } +func 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, ",") + "]" +} + func AverageVector(vs [][]float64) []float64 { dim := len(vs[0]) avg := make([]float64, dim) @@ -60,7 +74,7 @@ func AverageVector(vs [][]float64) []float64 { func TopWords(posts []string, stopWords map[string]struct{}, limit int) []string { counts := map[string]int{} for _, p := range posts { - for _, w := range strings.Fields(strings.ToLower(p)) { + for w := range strings.FieldsSeq(strings.ToLower(p)) { if len(w) < 4 { continue } diff --git a/internal/x402/types.go b/internal/x402/types.go deleted file mode 100644 index 2ebb079..0000000 --- a/internal/x402/types.go +++ /dev/null @@ -1,110 +0,0 @@ -package x402 - -type InputSchema struct { - BodyFields map[string]any `json:"bodyFields,omitempty"` - Discoverable bool `json:"discoverable,omitempty"` - Method string `json:"method,omitempty"` - Type string `json:"type,omitempty"` -} - -type Schema struct { - Input InputSchema `json:"input"` - Output map[string]any `json:"output"` -} - -// PaymentRequirement defines payment requirements for a resource/tool -// as defined in the x402 specification section 5.1 -type PaymentRequirement struct { - Scheme string `json:"scheme"` - Network string `json:"network"` - MaxAmountRequired string `json:"maxAmountRequired"` - Asset string `json:"asset"` - PayTo string `json:"payTo"` - Resource string `json:"resource"` - Description string `json:"description"` - MimeType string `json:"mimeType"` - OutputSchema Schema `json:"outputSchema"` - MaxTimeoutSeconds int `json:"maxTimeoutSeconds"` - Extra map[string]string `json:"extra,omitempty"` -} - -// PaymentRequirements402Response is the HTTP 402 response body -type PaymentRequirements402Response struct { - X402Version int `json:"x402Version"` - Error string `json:"error"` - Accepts []PaymentRequirement `json:"accepts"` -} - -// PaymentPayload represents the X-PAYMENT header content -// as defined in the x402 specification section 5.2 -type PaymentPayload struct { - X402Version int `json:"x402Version"` - Scheme string `json:"scheme"` - Network string `json:"network"` - Payload struct { - Signature string `json:"signature"` - Authorization struct { - From string `json:"from"` - To string `json:"to"` - Value string `json:"value"` - ValidAfter string `json:"validAfter"` - ValidBefore string `json:"validBefore"` - Nonce string `json:"nonce"` - } `json:"authorization"` - } `json:"payload"` -} - -// SettlementResponse is included in X-PAYMENT-RESPONSE header -// as defined in the x402 specification section 5.3 -type SettleResponse struct { - Success bool `json:"success"` - Transaction string `json:"transaction"` - Network string `json:"network"` - Payer string `json:"payer"` - ErrorReason string `json:"errorReason,omitempty"` -} - -// VerifyRequest sent to facilitator /verify endpoint -// as defined in the x402 specification section 7.1 -// Note: x402Version added at root level for facilitator compatibility -type VerifyRequest struct { - X402Version int `json:"x402Version"` - PaymentPayload *PaymentPayload `json:"paymentPayload"` - PaymentRequirements *PaymentRequirement `json:"paymentRequirements"` -} - -// VerifyResponse from facilitator -// as defined in the x402 specification section 7.1 -type VerifyResponse struct { - IsValid bool `json:"isValid"` - Payer string `json:"payer"` - InvalidReason string `json:"invalidReason,omitempty"` -} - -// SettleRequest sent to facilitator /settle endpoint -// as defined in the x402 specification section 7.2 -// Note: x402Version added at root level for facilitator compatibility -type SettleRequest struct { - X402Version int `json:"x402Version"` - PaymentPayload *PaymentPayload `json:"paymentPayload"` - PaymentRequirements *PaymentRequirement `json:"paymentRequirements"` -} - -// Config for X402Server -type Config struct { - // FacilitatorURL is the base URL of the x402 facilitator service - FacilitatorURL string - - CbApiKey *string - CbApiSecret *string - // PaymentTools maps tool names to their payment requirements - // Each tool can have multiple payment options - PaymentTools map[string][]PaymentRequirement - AllTools []string - - // VerifyOnly if true, only verifies but doesn't settle payments - VerifyOnly bool - - // Verbose if true, logs detailed request and payment information - Verbose bool -} diff --git a/internal/x402/x402.go b/internal/x402/x402.go index 0e7ce28..31cad9a 100644 --- a/internal/x402/x402.go +++ b/internal/x402/x402.go @@ -1,8 +1,6 @@ package x402 import ( - "bytes" - "context" "encoding/json" "fmt" "io" @@ -10,211 +8,298 @@ import ( "net" "net/http" "os" + "reflect" + "strconv" "strings" - "github.com/mark3labs/mcp-go/mcp" - mcpserver "github.com/mark3labs/mcp-go/server" - "github.com/mark3labs/x402-go" - x402http "github.com/mark3labs/x402-go/http" - "github.com/mark3labs/x402-go/mcp/server" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" log "github.com/nacorid/logger" + 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" ) type X402Middlewares struct { - MCPMiddleware []mcpserver.ToolHandlerMiddleware + MCPMiddleware []mcp.Middleware HTTPMiddleware []func(http.Handler) http.Handler } -type HTTPMiddleware func(http.Handler) http.Handler - -type X402Server struct { - mcpHandler *mcpserver.MCPServer - mcpServer *server.X402Handler - x402Middlewares map[string]func(http.Handler) http.Handler - httpConfig *x402http.Config - mcpConfig *server.Config - - toolMiddlewareChain mcpserver.ToolHandlerMiddleware - middlewareChain func(http.Handler) http.Handler - AllEndpoints []string - Handlers map[string]mcpserver.ToolHandlerFunc +type ToolInfo struct { + Tool mcp.Tool + IsPayable bool + Requirements []x402.PaymentRequirements + Handler mcp.ToolHandler } -func (s *X402Server) AddTool(tool mcp.Tool, handler mcpserver.ToolHandlerFunc) { - s.mcpHandler.AddTool(tool, handler) - s.AllEndpoints = append(s.AllEndpoints, tool.Name) - s.Handlers[tool.Name] = handler - s.x402Middlewares[tool.Name] = noOpMiddleware +type X402Server struct { + mcpServer *mcp.Server + x402ResourceServer *x402.X402ResourceServer + facilitatorClient *x402http.HTTPFacilitatorClient + httpSettlementHook func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) + mux *http.ServeMux + + name string + version string + middlewareChain func(http.Handler) http.Handler + tools []ToolInfo + globalReqLog func(http.Handler) http.Handler +} + +const VERSION1 = "v1/" + +func NewX402Server(name, version string, facilitatorURL string, auth x402http.AuthProvider, mws X402Middlewares, globalLoggingMw func(http.Handler) http.Handler, settleHook func(http.ResponseWriter, *http.Request, *x402.SettleResponse)) (*X402Server, error) { + mcpServer := mcp.NewServer( + &mcp.Implementation{ + Name: name, + Version: version, + }, + nil, + ) + for _, mw := range mws.MCPMiddleware { + mcpServer.AddReceivingMiddleware(mw) + } + + if facilitatorURL == "" { + facilitatorURL = "https://facilitator.mogami.tech" + } + facilitator := x402http.NewHTTPFacilitatorClient(&x402http.FacilitatorConfig{ + URL: facilitatorURL, + AuthProvider: auth, + }) + + resourceServer := x402.Newx402ResourceServer( + x402.WithFacilitatorClient(facilitator), + ) + + resourceServer.Register("eip155:8453", evm.NewExactEvmScheme()) + resourceServer.Register("eip155:8453", uptoevm.NewUptoEvmScheme()) + + s := &X402Server{ + mcpServer: mcpServer, + x402ResourceServer: resourceServer, + facilitatorClient: facilitator, + httpSettlementHook: settleHook, + mux: http.NewServeMux(), + name: name, + version: version, + middlewareChain: chainHTTPMiddlewares(mws.HTTPMiddleware...), + tools: make([]ToolInfo, 0), + globalReqLog: globalLoggingMw, + } + + s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + }) + + s.mux.HandleFunc("GET /openapi.json", s.serveOpenAPI) + + mcpHandler := mcp.NewStreamableHTTPHandler(func(req *http.Request) *mcp.Server { + return s.mcpServer + }, nil) + s.mux.Handle("/mcp", mcpHandler) + + return s, nil +} + +func (s *X402Server) AddTool(tool mcp.Tool, handler mcp.ToolHandler) { + tool.InputSchema = toJSONSchema(tool.InputSchema) + tool.OutputSchema = toJSONSchema(tool.OutputSchema) + + s.mcpServer.AddTool(&tool, handler) + + s.tools = append(s.tools, ToolInfo{ + Tool: tool, + IsPayable: false, + Handler: handler, + }) + + 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(restHandler)) } func (s *X402Server) AddPayableTool( tool mcp.Tool, - handler mcpserver.ToolHandlerFunc, - requirements ...x402.PaymentRequirement, + handler mcp.ToolHandler, + requirements ...x402.PaymentRequirements, ) { - s.mcpHandler.AddTool(tool, handler) if len(requirements) == 0 { log.Fatalf("tool %s requires at least one payment requirement", tool.Name) } - s.mcpConfig.PaymentTools[tool.Name] = requirements - s.AllEndpoints = append(s.AllEndpoints, tool.Name) - s.Handlers[tool.Name] = handler - cfg := newMiddlewareConfig(s.httpConfig, func(c *x402http.Config) { - c.PaymentRequirements = requirements - }) - s.x402Middlewares[tool.Name] = x402http.NewX402Middleware(&cfg) -} + tool.InputSchema = toJSONSchema(tool.InputSchema) + tool.OutputSchema = toJSONSchema(tool.OutputSchema) -func noOpMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - next.ServeHTTP(w, r) - }) -} - -func NewX402Server(name, version string, httpConfig *x402http.Config, mws X402Middlewares) (*X402Server, error) { - toolChain := chainToolMiddlewares(mws.MCPMiddleware...) - httpChain := chainHTTPMiddlewares(mws.HTTPMiddleware...) - mcp := mcpserver.NewMCPServer(name, version, - mcpserver.WithToolHandlerMiddleware(toolChain), - mcpserver.WithRecovery(), - ) - - return &X402Server{ - mcpHandler: mcp, - x402Middlewares: make(map[string]func(http.Handler) http.Handler), - httpConfig: httpConfig, - mcpConfig: &server.Config{ - FacilitatorURL: "https://facilitator.mogami.tech", - Verbose: true, - PaymentTools: make(map[string][]x402.PaymentRequirement), + wrapper := x402mcp.NewPaymentWrapper(s.x402ResourceServer, x402mcp.PaymentWrapperConfig{ + Accepts: requirements, + Resource: &x402mcp.ResourceInfo{ + URL: "mcp://tool/" + tool.Name, + Description: tool.Description, }, - toolMiddlewareChain: toolChain, - middlewareChain: httpChain, - Handlers: make(map[string]mcpserver.ToolHandlerFunc), - }, nil + }) + + s.mcpServer.AddTool(&tool, wrapper.Wrap(handler)) + + s.tools = append(s.tools, ToolInfo{ + Tool: tool, + IsPayable: true, + Requirements: requirements, + Handler: handler, + }) + + 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), + }) + } + + paymentMiddleware := nethttp.X402Payment(nethttp.Config{ + Routes: x402http.RoutesConfig{ + "POST /" + VERSION1 + tool.Name: { + Accepts: options, + Description: tool.Description, + }, + }, + Facilitator: s.facilitatorClient, + Schemes: []nethttp.SchemeConfig{ + {Network: "eip155:8453", Server: evm.NewExactEvmScheme()}, + }, + SettlementHandler: s.httpSettlementHook, + }) + + 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))) } -func chainToolMiddlewares(middlewares ...mcpserver.ToolHandlerMiddleware) mcpserver.ToolHandlerMiddleware { - return func(next mcpserver.ToolHandlerFunc) mcpserver.ToolHandlerFunc { - for i := len(middlewares) - 1; i >= 0; i-- { - next = middlewares[i](next) - } - return next - } -} - -func chainHTTPMiddlewares(middlewares ...func(http.Handler) http.Handler) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - for i := len(middlewares) - 1; i >= 0; i-- { - next = middlewares[i](next) - } - return next - } -} - -func newMiddlewareConfig(httpConfig *x402http.Config, overrides ...func(*x402http.Config)) x402http.Config { - cfg := *httpConfig - for _, override := range overrides { - override(&cfg) - } - return cfg -} - -func (s *X402Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { - toolName := strings.TrimPrefix(r.URL.Path, "/") - addr := "@" - forwarded := r.Header.Get("X-Forwarded-For") - if forwarded != "" { - ip := strings.Split(forwarded, ",")[0] - addr = strings.TrimSpace(ip) - } else { - realIP := r.Header.Get("X-Real-Ip") - if realIP != "" { - addr = realIP - } - } - log.InfoWithContext(r.Context(), "Request received", "host", r.Host, "method", r.Method, "path", r.URL.Path, "remoteAddr", addr) - - if r.URL.Path == "/healthz" { - w.WriteHeader(http.StatusOK) - w.Write([]byte("ok")) +func (s *X402Server) handleRESTCall(w http.ResponseWriter, r *http.Request, toolName string, handler mcp.ToolHandler) { + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read body", http.StatusBadRequest) return } - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + log.DebugWithContext(r.Context(), "RESTful API request.", "RemoteAddr", r.RemoteAddr, "X-Forwarded-For", r.Header.Get("X-Forwarded-For")) + + callReq := &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{ + Name: toolName, + Arguments: json.RawMessage(bodyBytes), + }, + } + + resp, err := handler(r.Context(), callReq) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) return } - if handler, ok := s.Handlers[toolName]; ok { - bodyBytes, err := io.ReadAll(r.Body) - if err != nil { - log.WarnWithContext(r.Context(), "Failed to read request body", "error", err) - http.Error(w, "Failed to read request body", http.StatusBadRequest) - return - } - var args map[string]any - if len(bodyBytes) > 0 { - if err := json.Unmarshal(bodyBytes, &args); err != nil { - log.WarnWithContext(r.Context(), "Failed to parse request body as JSON", "tool", toolName, "error", err) - http.Error(w, "Invalid JSON body", http.StatusBadRequest) + 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 { + // For upto scheme tools, compute actual cost and apply settlement override + if toolName == "getStyleProfile" { + actualCost := computeTwitterStyleProfileCost(r, textContent.Text) + nethttp.SetSettlementOverrides(w, &x402.SettlementOverrides{ + Amount: actualCost, + }) + } + w.Write([]byte(textContent.Text)) return } - } else { - log.DebugWithContext(r.Context(), "Empty request body for tool", "tool", toolName) - args = make(map[string]any) + } + } + + json.NewEncoder(w).Encode(resp) +} + +func (s *X402Server) OnAfterSettle(hook func(ctx x402.SettleResultContext) error) { + s.x402ResourceServer.OnAfterSettle(hook) +} + +func (s *X402Server) serveOpenAPI(w http.ResponseWriter, r *http.Request) { + paths := make(map[string]any) + + log.DebugWithContext(r.Context(), "OpenAPI Spec requested.", "RemoteAddr", r.RemoteAddr, "X-Forwarded-For", r.Header.Get("X-Forwarded-For")) + + for _, info := range s.tools { + inputSchema := parseSchema(info.Tool.InputSchema) + outputSchema := parseSchema(info.Tool.OutputSchema) + + postOp := map[string]any{ + "summary": info.Tool.Description, + "description": fmt.Sprintf("Exposes the MCP tool '%s' as a RESTful endpoint.", info.Tool.Name), + "requestBody": map[string]any{ + "required": true, + "content": map[string]any{ + "application/json": map[string]any{ + "schema": inputSchema, + }, + }, + }, + "responses": map[string]any{ + "200": map[string]any{ + "description": "Successful operation", + "content": map[string]any{ + "application/json": map[string]any{ + "schema": outputSchema, + }, + }, + }, + }, } - // Store parsed args in context - type contextKey string - const argsKey contextKey = "parsed_args" - ctx := context.WithValue(r.Context(), argsKey, args) - r = r.WithContext(ctx) - - r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // Restore body for further reading - functionHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - args := r.Context().Value(argsKey).(map[string]any) - - callReq := mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Name: toolName, - Arguments: args, + if info.IsPayable { + postOp["description"] = fmt.Sprintf("%s\n\n**Paid Route**: Requires payments via x402.", postOp["description"]) + postOp["responses"].(map[string]any)["402"] = map[string]any{ + "description": "Payment Required. Responds with the PAYMENT-REQUIRED header challenge.", + "headers": map[string]any{ + "PAYMENT-REQUIRED": map[string]any{ + "schema": map[string]any{"type": "string"}, + "description": "Base64 encoded PaymentRequired challenge matching the x402 specification.", + }, }, } - resp, err := s.toolMiddlewareChain(handler)(r.Context(), callReq) - if err != nil { - log.WarnWithContext(r.Context(), "Tool handler error in direct handler", "tool", toolName, "error", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - respBytes, err := json.Marshal(resp.Result) - if err != nil { - log.WarnWithContext(r.Context(), "Failed to marshal response", "error", err) - http.Error(w, "Failed to marshal response", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - w.Write(respBytes) - }) - - middleware, ok := s.x402Middlewares[toolName] - if !ok { - log.ErrorWithContext(r.Context(), "No X402 middleware found for tool", "tool", toolName) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return } - s.middlewareChain(middleware(functionHandler)).ServeHTTP(w, r) - } else { - http.NotFound(w, r) + + paths["/"+VERSION1+info.Tool.Name] = map[string]any{ + "post": postOp, + } } + + openapiDoc := map[string]any{ + "openapi": "3.0.3", + "info": map[string]any{ + "title": s.name, + "version": s.version, + "description": "Auto-generated REST API matching active Model Context Protocol (MCP) tools.", + "contact": map[string]any{ + "email": "x402@naco.li", + }, + }, + "paths": paths, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(openapiDoc) } -// Start starts the x402 server on the specified address func (s *X402Server) Start(addr string, permissions os.FileMode) error { var ( listener net.Listener @@ -233,10 +318,8 @@ func (s *X402Server) Start(addr string, permissions os.FileMode) error { } log.Info("Starting X402 MCP Server on unix socket", "socketPath", socketPath) - log.Info("MCP endpoint (unix socket)", "socketPath", socketPath) } else { log.Info("Starting X402 MCP Server on TCP", "addr", addr) - log.Infof("MCP endpoint: http://localhost%s", addr) listener, err = net.Listen("tcp", addr) if err != nil { @@ -245,12 +328,150 @@ func (s *X402Server) Start(addr string, permissions os.FileMode) error { } defer listener.Close() - httpServer := mcpserver.NewStreamableHTTPServer(s.mcpHandler) - s.mcpServer = server.NewX402Handler(httpServer, s.mcpConfig) - srv := &http.Server{ - Handler: s, + Handler: s.globalReqLog(s.mux), ErrorLog: slog.NewLogLogger(slog.Default().Handler(), slog.LevelDebug), } return srv.Serve(listener) } + +func toJSONSchema(val any) json.RawMessage { + if val == nil { + return json.RawMessage(`{"type": "object"}`) + } + switch v := val.(type) { + case json.RawMessage: + if len(v) == 0 { + return json.RawMessage(`{"type": "object"}`) + } + return v + case []byte: + if len(v) == 0 { + return json.RawMessage(`{"type": "object"}`) + } + return json.RawMessage(v) + case string: + if v == "" { + return json.RawMessage(`{"type": "object"}`) + } + return json.RawMessage(v) + } + + t := reflect.TypeOf(val) + schema, err := jsonschema.ForType(t, &jsonschema.ForOptions{}) + if err != nil { + return json.RawMessage(`{"type": "object"}`) + } + bytes, err := json.Marshal(schema) + if err != nil { + return json.RawMessage(`{"type": "object"}`) + } + return bytes +} + +func parseSchema(schemaAny any) any { + if schemaAny == nil { + return map[string]any{"type": "object"} + } + switch v := schemaAny.(type) { + case json.RawMessage: + var m any + if err := json.Unmarshal(v, &m); err == nil { + return m + } + case []byte: + var m any + if err := json.Unmarshal(v, &m); err == nil { + return m + } + case string: + var m any + if err := json.Unmarshal([]byte(v), &m); err == nil { + return m + } + } + return schemaAny +} + +func chainHTTPMiddlewares(middlewares ...func(http.Handler) http.Handler) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + for i := len(middlewares) - 1; i >= 0; i-- { + next = middlewares[i](next) + } + 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 = 100_000 // $0.10 + // 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 +) + +// 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.16") suitable for a +// x402 settlement override. +// +// Pricing: guaranteed base cost of $0.10 (the endpoint cost for other +// platforms), plus user lookup $0.01, plus $0.005 per post read. For example, +// 10 posts = $0.16. Non-Twitter platforms are charged the flat $0.10 base. +func computeTwitterStyleProfileCost(r *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 := resp.PostCount + if postCount < 0 { + 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 +} + +// 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"` +} diff --git a/internal/x402/x402_test.go b/internal/x402/x402_test.go new file mode 100644 index 0000000..cf4a757 --- /dev/null +++ b/internal/x402/x402_test.go @@ -0,0 +1,447 @@ +package x402 + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + x402 "github.com/x402-foundation/x402/go/v2" + x402http "github.com/x402-foundation/x402/go/v2/http" +) + +type DummyAuthProvider struct{} + +func (d *DummyAuthProvider) GetAuthHeaders(ctx context.Context) (x402http.AuthHeaders, error) { + return x402http.AuthHeaders{}, nil +} + +func TestX402Server_HTTPBoundaries(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) {}, + ) + if err != nil { + t.Fatalf("failed to create server: %v", err) + } + + srv.AddPayableTool( + mcp.Tool{ + Name: "getTestPost", + Description: "Gated test endpoint", + }, + func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: `{"status": "success"}`}}, + }, nil + }, + x402.PaymentRequirements{ + Scheme: "exact", + Network: "eip155:8453", + Asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + Amount: "1000", + PayTo: "0x0000000000000000000000000000000000000000", + }, + ) + + reqRec := httptest.NewRecorder() + reqHealth, _ := http.NewRequest("GET", "/healthz", nil) + srv.mux.ServeHTTP(reqRec, reqHealth) + if reqRec.Code != http.StatusOK { + t.Errorf("expected healthz status 200, got %d", reqRec.Code) + } + + reqRecOpenAPI := httptest.NewRecorder() + reqOpenAPI, _ := http.NewRequest("GET", "/openapi.json", nil) + srv.mux.ServeHTTP(reqRecOpenAPI, reqOpenAPI) + if reqRecOpenAPI.Code != http.StatusOK { + t.Errorf("expected OpenAPI status 200, got %d", reqRecOpenAPI.Code) + } + if !strings.Contains(reqRecOpenAPI.Body.String(), "getTestPost") { + t.Error("OpenAPI spec did not render registered tool definitions") + } + + reqRecPayable := httptest.NewRecorder() + reqPayable, _ := http.NewRequest("POST", "/v1/getTestPost", strings.NewReader(`{}`)) + srv.mux.ServeHTTP(reqRecPayable, reqPayable) + + if reqRecPayable.Code != http.StatusPaymentRequired { + t.Errorf("expected status 402 (Payment Required), got %d", reqRecPayable.Code) + } + + payHeader := reqRecPayable.Header().Get("PAYMENT-REQUIRED") + if payHeader == "" { + t.Error("missing PAYMENT-REQUIRED response header on 402 challenge") + } +} + +func TestX402Server_MCPBoundaries(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) {}, + ) + if err != nil { + t.Fatalf("failed to create server: %v", err) + } + + srv.AddPayableTool( + mcp.Tool{ + Name: "getTestPost", + Description: "Gated test endpoint", + }, + func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: `{"status": "success"}`}}, + }, nil + }, + x402.PaymentRequirements{ + Scheme: "exact", + Network: "eip155:8453", + Asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + Amount: "1000", + PayTo: "0x0000000000000000000000000000000000000000", + }, + ) + + serverSession, err := srv.mcpServer.Connect(ctx, serverTransport, nil) + if err != nil { + t.Fatalf("failed to connect server session: %v", err) + } + 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 clientSession.Close() + + toolsResponse, err := clientSession.ListTools(ctx, nil) + if err != nil { + t.Fatalf("failed to list tools over MCP: %v", err) + } + + found := false + for _, tool := range toolsResponse.Tools { + if tool.Name == "getTestPost" { + found = true + break + } + } + if !found { + t.Error("expected tool 'getTestPost' to be discovered by the MCP client, but it was missing") + } + + result, err := clientSession.CallTool(ctx, &mcp.CallToolParams{ + Name: "getTestPost", + Arguments: json.RawMessage(`{}`), + }) + + if err != nil { + t.Errorf("Unexpected protocol-level error: %v", err) + } else { + if !result.IsError { + t.Error("expected CallTool to return a payment challenge, but it returned a successful, free result") + } else { + t.Logf("Intercepted expected application-level payment challenge.") + t.Logf("Content: %v", result.Content) + t.Logf("Meta fields: %v", result.Meta) + } + } +} + +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.10"}, + {name: "twitter example from spec", platform: "Twitter", postCount: 10, want: "$0.16"}, + {name: "twitter zero posts", platform: "Twitter", postCount: 0, want: "$0.11"}, + {name: "twitter max posts", platform: "Twitter", postCount: 50, want: "$0.36"}, + {name: "twitter odd post count half cents", platform: "Twitter", postCount: 3, want: "$0.125"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resp := GetStyleProfileResponse{SourcePlatform: tc.platform, PostCount: tc.postCount} + payload, _ := json.Marshal(resp) + r, _ := http.NewRequest("POST", "/v1/getStyleProfile", nil) + + got := computeTwitterStyleProfileCost(r, string(payload)) + if got != tc.want { + t.Fatalf("computeTwitterStyleProfileCost(%q, %d) = %q, want %q", tc.platform, tc.postCount, got, tc.want) + } + }) + } +} + +// Verify that every Twitter charge strictly exceeds the flat base cost, so the +// endpoint always clears the underlying API cost (never break-even or loss). +func TestComputeTwitterStyleProfileCostAlwaysProfitable(t *testing.T) { + for postCount := 0; postCount <= 50; postCount++ { + resp := GetStyleProfileResponse{SourcePlatform: "Twitter", PostCount: postCount} + payload, _ := json.Marshal(resp) + r, _ := http.NewRequest("POST", "/v1/getStyleProfile", nil) + + got := computeTwitterStyleProfileCost(r, string(payload)) + // Cut the "$" prefix and compare numerically to baseProfileCostMicro. + dollars := strings.TrimPrefix(got, "$") + parsed, err := strconv.ParseFloat(dollars, 64) + if err != nil { + t.Fatalf("failed to parse %q: %v", got, err) + } + micro := int64(parsed * 1_000_000) + if micro <= baseProfileCostMicro { + t.Fatalf("postCount=%d: charge %q (%d micro) must be strictly greater than base %d", postCount, got, micro, baseProfileCostMicro) + } + } +} + +func setupMockFacilitator() *httptest.Server { + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch { + case r.Method == "GET" && r.URL.Path == "/supported": + _, _ = w.Write([]byte(`{ + "kinds": [ + { + "x402Version": 2, + "scheme": "exact", + "network": "eip155:8453" + } + ] + }`)) + case r.Method == "POST" && r.URL.Path == "/verify": + _, _ = w.Write([]byte(`{ + "isValid": true, + "invalidReason": "" + }`)) + case r.Method == "POST" && r.URL.Path == "/settle": + _, _ = w.Write([]byte(`{ + "success": true, + "transaction": "0x1234abcd", + "network": "eip155:8453", + "payer": "0xabcd1234" + }`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + return s +} + +func TestAddTool_NonPayable(t *testing.T) { + mockFacilitator := setupMockFacilitator() + defer mockFacilitator.Close() + + srv, err := NewX402Server( + "Test Agent API", + "1.0.0", + mockFacilitator.URL, + &DummyAuthProvider{}, + X402Middlewares{}, + func(next http.Handler) http.Handler { return next }, + func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {}, + ) + 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) {}, + ) + 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 + }, + // 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) {}, + ) + 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 + }, + 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.10"}, // not "Twitter" so flat base + {name: "empty platform", platform: "", postCount: 10, want: "$0.10"}, + {name: "negative post count", platform: "Twitter", postCount: -5, want: "$0.11"}, // should clamp to 0 + {name: "very large post count", platform: "Twitter", postCount: 1000, want: "$5.11"}, + } + + 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) + } + }) + } +} diff --git a/mcpServer.service b/mcpServer.service index bb9a498..d9e183e 100644 --- a/mcpServer.service +++ b/mcpServer.service @@ -4,12 +4,13 @@ After=network.target [Service] User=mcpserver -Group=www-data +Group=mcpserver WorkingDirectory=/home/ubuntu/gitSources/mcpServer ExecStart=/home/ubuntu/gitSources/mcpServer/bin/main RuntimeDirectory=mcpserver +RuntimeDirectoryMode=0750 Restart=on-failure RestartSec=3s diff --git a/scripts/fund-wallet.sh b/scripts/fund-wallet.sh new file mode 100755 index 0000000..7ad808d --- /dev/null +++ b/scripts/fund-wallet.sh @@ -0,0 +1,62 @@ +#!/bin/bash +set -euo pipefail + +echo "=== Funding Wallet with Base Sepolia USDC ===" + +# Required environment variables +: "${PRIVATE_KEY:?PRIVATE_KEY must be set}" +: "${FUNDING_FAUCET_URL:?FUNDING_FAUCET_URL must be set}" + +WALLET_ADDRESS=$(cast wallet address --private-key "$PRIVATE_KEY") +echo "Wallet address: $WALLET_ADDRESS" + +# Check current USDC balance +USDC_CONTRACT="0x036CbD53842c5426634e7929541eC2318f3dCF7e" +RPC_URL="https://sepolia.base.org" + +BALANCE=$(cast call "$USDC_CONTRACT" "balanceOf(address)(uint256)" "$WALLET_ADDRESS" --rpc-url "$RPC_URL" 2>/dev/null || echo "0") +BALANCE_DECIMAL=$(cast to-dec "$BALANCE" 2>/dev/null || echo "0") +BALANCE_USDC=$(echo "scale=6; $BALANCE_DECIMAL / 1000000" | bc 2>/dev/null || echo "0") + +echo "Current USDC balance: $BALANCE_USDC USDC" + +# If balance is less than 10 USDC, request from faucet +if (( $(echo "$BALANCE_USDC < 10" | bc -l) )); then + echo "Balance below 10 USDC, requesting from faucet..." + + # Request from faucet (Coinbase CDP faucet API format) + curl -X POST "$FUNDING_FAUCET_URL" \ + -H "Content-Type: application/json" \ + -d "{\"address\": \"$WALLET_ADDRESS\", \"token\": \"USDC\"}" \ + || echo "Faucet request failed, may need manual funding" + + # Wait for funding + echo "Waiting for funding..." + sleep 30 + + # Check balance again + BALANCE=$(cast call "$USDC_CONTRACT" "balanceOf(address)(uint256)" "$WALLET_ADDRESS" --rpc-url "$RPC_URL" 2>/dev/null || echo "0") + BALANCE_DECIMAL=$(cast to-dec "$BALANCE" 2>/dev/null || echo "0") + BALANCE_USDC=$(echo "scale=6; $BALANCE_DECIMAL / 1000000" | bc 2>/dev/null || echo "0") + echo "New USDC balance: $BALANCE_USDC USDC" +else + echo "Sufficient USDC balance" +fi + +# Also fund with ETH for gas +ETH_BALANCE=$(cast balance "$WALLET_ADDRESS" --rpc-url "$RPC_URL" 2>/dev/null || echo "0") +ETH_BALANCE_DECIMAL=$(cast to-dec "$ETH_BALANCE" 2>/dev/null || echo "0") +ETH_BALANCE_ETH=$(echo "scale=6; $ETH_BALANCE_DECIMAL / 1000000000000000000" | bc 2>/dev/null || echo "0") + +echo "Current ETH balance: $ETH_BALANCE_ETH ETH" + +if (( $(echo "$ETH_BALANCE_ETH < 0.01" | bc -l) )); then + echo "ETH balance low, requesting from faucet..." + curl -X POST "$FUNDING_FAUCET_URL" \ + -H "Content-Type: application/json" \ + -d "{\"address\": \"$WALLET_ADDRESS\", \"token\": \"ETH\"}" \ + || echo "ETH faucet request failed" + sleep 10 +fi + +echo "=== Wallet Funding Complete ===" \ No newline at end of file diff --git a/scripts/start-test-server.sh b/scripts/start-test-server.sh new file mode 100755 index 0000000..cdee795 --- /dev/null +++ b/scripts/start-test-server.sh @@ -0,0 +1,83 @@ +#!/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" +export GOOSE_DBSTRING="postgres://postgres:postgres@localhost:5432/mcpserver?sslmode=disable" +export ACTIVE_NETWORKS="sepolia" +export X402_FACILITATOR_URL="https://api.cdp.coinbase.com/platform/v2/x402" +export X402_WALLET_ADDRESS="$X402_WALLET_ADDRESS" +export OLLAMA_HOST="http://localhost:11434" +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="" + +# Start the server in background +echo "Starting server on port 8080..." +go run ./cmd/server/main.go & +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 $SERVER_PID 2>/dev/null || true + exit 1 +} + +# Verify payment endpoints are working (should return 402 without payment) +echo "Verifying payment endpoints..." +if curl -sf -X POST http://localhost:8080/v1/getActor \ + -H "Content-Type: application/json" \ + -d '{"author": "did:plc:z72i7hdynmk6r22z27h6tvur"}' >/dev/null 2>&1; then + echo "WARNING: Payable endpoint getActor returned 200 without payment!" +else + echo "Payment endpoint getActor correctly requires payment (402)" +fi + +# Verify free endpoint works +if curl -sf -X POST http://localhost:8080/v1/getFeed \ + -H "Content-Type: application/json" \ + -d '{"uri": "https://bsky.app/profile/did:plc:z72i7hdynmk6r22z27h6tvur/feed/whats-hot"}' >/dev/null 2>&1; then + echo "Free endpoint getFeed works correctly" +else + echo "WARNING: Free endpoint getFeed failed!" +fi + +echo "=== Test Server Started (PID: $SERVER_PID) ===" \ No newline at end of file diff --git a/sql/queries/AnalyticsCache.sql b/sql/queries/AnalyticsCache.sql new file mode 100644 index 0000000..670da7e --- /dev/null +++ b/sql/queries/AnalyticsCache.sql @@ -0,0 +1,17 @@ +-- name: GetAnalyticsCache :one +SELECT result FROM analytics_cache +WHERE user_id = $1 AND platform = $2 AND tool_name = $3 AND time_range_hash = $4 + AND expires_at > now(); + +-- name: UpsertAnalyticsCache :exec +INSERT INTO analytics_cache (user_id, platform, tool_name, time_range_hash, result, expires_at) +VALUES ($1, $2, $3, $4, $5, $6) +ON CONFLICT (user_id, platform, tool_name, time_range_hash) +DO UPDATE SET result = EXCLUDED.result, expires_at = EXCLUDED.expires_at, computed_at = now(); + +-- name: DeleteAnalyticsCache :exec +DELETE FROM analytics_cache +WHERE user_id = $1 AND platform = $2 AND tool_name = $3 AND time_range_hash = $4; + +-- name: PruneAnalyticsCache :exec +DELETE FROM analytics_cache WHERE expires_at <= now(); \ No newline at end of file diff --git a/sql/queries/AnalyticsCoverage.sql b/sql/queries/AnalyticsCoverage.sql new file mode 100644 index 0000000..0aa7ad0 --- /dev/null +++ b/sql/queries/AnalyticsCoverage.sql @@ -0,0 +1,14 @@ +-- name: GetAnalyticsCoverageRanges :many +SELECT covered_start, covered_end FROM analytics_coverage_ranges +WHERE user_id = $1 AND platform = $2 +ORDER BY covered_start; + +-- name: InsertAnalyticsCoverageRange :exec +INSERT INTO analytics_coverage_ranges (user_id, platform, covered_start, covered_end, updated_at) +VALUES ($1, $2, $3, $4, now()) +ON CONFLICT (user_id, platform, covered_start, covered_end) +DO UPDATE SET updated_at = now(); + +-- name: DeleteAnalyticsCoverageRange :exec +DELETE FROM analytics_coverage_ranges +WHERE user_id = $1 AND platform = $2 AND covered_start = $3 AND covered_end = $4; diff --git a/sql/queries/AnalyticsPosts.sql b/sql/queries/AnalyticsPosts.sql new file mode 100644 index 0000000..6f7c31e --- /dev/null +++ b/sql/queries/AnalyticsPosts.sql @@ -0,0 +1,17 @@ +-- name: GetAnalyticsPostsInRange :many +SELECT post_id, post_text, post_timestamp, likes, reposts, replies, quotes +FROM analytics_posts +WHERE user_id = $1 AND platform = $2 AND post_timestamp >= $3 AND post_timestamp <= $4 +ORDER BY post_timestamp DESC +LIMIT $5; + +-- name: UpsertAnalyticsPosts :exec +INSERT INTO analytics_posts (user_id, platform, post_id, post_text, post_timestamp, likes, reposts, replies, quotes) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +ON CONFLICT (user_id, platform, post_id) +DO UPDATE SET post_text = EXCLUDED.post_text, + post_timestamp = EXCLUDED.post_timestamp, + likes = EXCLUDED.likes, + reposts = EXCLUDED.reposts, + replies = EXCLUDED.replies, + quotes = EXCLUDED.quotes; diff --git a/sql/queries/DeleteStylePosts.sql b/sql/queries/DeleteStylePosts.sql new file mode 100644 index 0000000..163eaae --- /dev/null +++ b/sql/queries/DeleteStylePosts.sql @@ -0,0 +1,3 @@ +-- name: DeleteStylePosts :exec +DELETE FROM user_style_posts +WHERE user_id = $1 AND platform = $2 AND platform != 'Twitter'; \ No newline at end of file diff --git a/sql/queries/FindSimilarUsers.sql b/sql/queries/FindSimilarUsers.sql index 25490ef..90e74c1 100644 --- a/sql/queries/FindSimilarUsers.sql +++ b/sql/queries/FindSimilarUsers.sql @@ -1,6 +1,6 @@ -- name: FindSimilarUsers :many SELECT user_id, platform, embedding, top_keywords, style_summary, post_count, - 1 - (embedding <=> $1::vector) AS similarity + 1 - (embedding <=> @query_embedding::vector) AS similarity FROM user_style_profiles -ORDER BY embedding <=> $1::vector -LIMIT $2; \ No newline at end of file +ORDER BY embedding <=> @query_embedding::vector +LIMIT @limit_val; \ No newline at end of file diff --git a/sql/queries/GetStylePostByID.sql b/sql/queries/GetStylePostByID.sql new file mode 100644 index 0000000..255531c --- /dev/null +++ b/sql/queries/GetStylePostByID.sql @@ -0,0 +1,3 @@ +-- name: GetStylePostByID :one +SELECT post_id, post_text FROM user_style_posts +WHERE user_id = $1 AND platform = $2 AND post_id = $3; \ No newline at end of file diff --git a/sql/queries/GetStylePosts.sql b/sql/queries/GetStylePosts.sql new file mode 100644 index 0000000..3c1ca49 --- /dev/null +++ b/sql/queries/GetStylePosts.sql @@ -0,0 +1,5 @@ +-- name: GetStylePosts :many +SELECT post_id, post_text FROM user_style_posts +WHERE user_id = $1 AND platform = $2 +ORDER BY created_at DESC +LIMIT $3; \ No newline at end of file diff --git a/sql/queries/GetStylePostsByIDs.sql b/sql/queries/GetStylePostsByIDs.sql new file mode 100644 index 0000000..932b794 --- /dev/null +++ b/sql/queries/GetStylePostsByIDs.sql @@ -0,0 +1,3 @@ +-- name: GetStylePostsByIDs :many +SELECT post_id, post_text FROM user_style_posts +WHERE user_id = $1 AND platform = $2 AND post_id = ANY($3::text[]); \ No newline at end of file diff --git a/sql/queries/GetStyleProfile.sql b/sql/queries/GetStyleProfile.sql index d148ff6..a085e12 100644 --- a/sql/queries/GetStyleProfile.sql +++ b/sql/queries/GetStyleProfile.sql @@ -1,3 +1,3 @@ -- name: GetStyleProfile :one -SELECT * FROM user_style_profiles -WHERE user_id = $1 AND platform = $2; \ No newline at end of file +SELECT id, user_id, platform, embedding, top_keywords, style_summary, post_count, modified_at, created_at, model_version, schema_version, algorithm_version FROM user_style_profiles +WHERE user_id = @user_id AND platform = @platform; \ No newline at end of file diff --git a/sql/queries/PruneStylePosts.sql b/sql/queries/PruneStylePosts.sql new file mode 100644 index 0000000..064140d --- /dev/null +++ b/sql/queries/PruneStylePosts.sql @@ -0,0 +1,3 @@ +-- name: PruneStylePosts :exec +DELETE FROM user_style_posts +WHERE created_at < now() - interval '7 days' AND platform != 'Twitter'; \ No newline at end of file diff --git a/sql/queries/UpsertStylePosts.sql b/sql/queries/UpsertStylePosts.sql new file mode 100644 index 0000000..2c669bf --- /dev/null +++ b/sql/queries/UpsertStylePosts.sql @@ -0,0 +1,5 @@ +-- name: UpsertStylePosts :exec +INSERT INTO user_style_posts (user_id, platform, post_id, post_text) +VALUES ($1, $2, $3, $4) +ON CONFLICT (user_id, platform, post_id) +DO UPDATE SET post_text = EXCLUDED.post_text, created_at = now(); \ No newline at end of file diff --git a/sql/queries/UpsertStyleProfiles.sql b/sql/queries/UpsertStyleProfiles.sql index b65b61b..d797f51 100644 --- a/sql/queries/UpsertStyleProfiles.sql +++ b/sql/queries/UpsertStyleProfiles.sql @@ -1,10 +1,10 @@ -- name: UpsertStyleProfile :exec INSERT INTO user_style_profiles (user_id, platform, embedding, top_keywords, style_summary, post_count) -VALUES ($1, $2, $3, $4, $5, $6) +VALUES (@user_id, @platform, @embedding::vector, @top_keywords, @style_summary, @post_count) ON CONFLICT (user_id, platform) DO UPDATE SET embedding = EXCLUDED.embedding, top_keywords = EXCLUDED.top_keywords, style_summary = EXCLUDED.style_summary, post_count = EXCLUDED.post_count, - updated_at = NOW(); \ No newline at end of file + modified_at = NOW(); \ No newline at end of file diff --git a/sql/schema/001_transactions.sql b/sql/schema/001_transactions.sql index 700b6b8..612215f 100644 --- a/sql/schema/001_transactions.sql +++ b/sql/schema/001_transactions.sql @@ -1,5 +1,5 @@ -- +goose up -CREATE TABLE transactions ( +CREATE TABLE IF NOT EXISTS transactions ( id BIGSERIAL PRIMARY KEY, x402_transaction_hash VARCHAR(255) NOT NULL UNIQUE, amount NUMERIC NOT NULL, diff --git a/sql/schema/002_user_style_profiles.sql b/sql/schema/002_user_style_profiles.sql index f3efcb4..6241a7e 100644 --- a/sql/schema/002_user_style_profiles.sql +++ b/sql/schema/002_user_style_profiles.sql @@ -1,9 +1,11 @@ -- +goose up -CREATE TABLE user_style_profiles ( +CREATE EXTENSION IF NOT EXISTS vector; + +CREATE TABLE IF NOT EXISTS user_style_profiles ( id BIGSERIAL PRIMARY KEY, user_id TEXT NOT NULL, platform TEXT NOT NULL, - embedding VECTOR(768) NOT NULL, + embedding VECTOR(1024) NOT NULL, top_keywords TEXT[] NOT NULL, style_summary TEXT NOT NULL, post_count INTEGER NOT NULL, @@ -13,4 +15,6 @@ CREATE TABLE user_style_profiles ( ); -- +goose down -DROP TABLE IF EXISTS user_style_profiles; \ No newline at end of file +DROP TABLE IF EXISTS user_style_profiles; + +DROP EXTENSION IF EXISTS vector; \ No newline at end of file diff --git a/sql/schema/003_add_cache_version.sql b/sql/schema/003_add_cache_version.sql new file mode 100644 index 0000000..4c86c15 --- /dev/null +++ b/sql/schema/003_add_cache_version.sql @@ -0,0 +1,15 @@ +-- +goose Up +ALTER TABLE user_style_profiles + ADD COLUMN model_version INTEGER NOT NULL DEFAULT 1, + ADD COLUMN schema_version INTEGER NOT NULL DEFAULT 1, + ADD COLUMN algorithm_version INTEGER NOT NULL DEFAULT 1; + +CREATE INDEX IF NOT EXISTS idx_user_style_profiles_versions + ON user_style_profiles (model_version, schema_version, algorithm_version); + +-- +goose Down +DROP INDEX idx_user_style_profiles_versions; +ALTER TABLE user_style_profiles + DROP COLUMN algorithm_version, + DROP COLUMN model_version, + DROP COLUMN schema_version; diff --git a/sql/schema/004_add_style_posts.sql b/sql/schema/004_add_style_posts.sql new file mode 100644 index 0000000..cc020c1 --- /dev/null +++ b/sql/schema/004_add_style_posts.sql @@ -0,0 +1,17 @@ +-- +goose up +CREATE TABLE IF NOT EXISTS user_style_posts ( + id BIGSERIAL PRIMARY KEY, + user_id TEXT NOT NULL, + platform TEXT NOT NULL, + post_id TEXT NOT NULL, + post_text TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (user_id, platform, post_id) +); + +CREATE INDEX idx_user_style_posts_user_platform + ON user_style_posts (user_id, platform); + +-- +goose down +DROP INDEX IF EXISTS idx_user_style_posts_user_platform; +DROP TABLE IF EXISTS user_style_posts; \ No newline at end of file diff --git a/sql/schema/005_add_analytics_cache.sql b/sql/schema/005_add_analytics_cache.sql new file mode 100644 index 0000000..3dd106c --- /dev/null +++ b/sql/schema/005_add_analytics_cache.sql @@ -0,0 +1,23 @@ +-- +goose up +CREATE TABLE IF NOT EXISTS analytics_cache ( + id BIGSERIAL PRIMARY KEY, + user_id TEXT NOT NULL, + platform TEXT NOT NULL, + tool_name TEXT NOT NULL, + time_range_hash TEXT NOT NULL, + result JSONB NOT NULL, + computed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + UNIQUE (user_id, platform, tool_name, time_range_hash) +); + +CREATE INDEX IF NOT EXISTS idx_analytics_cache_lookup + ON analytics_cache (user_id, platform, tool_name, time_range_hash); + +CREATE INDEX IF NOT EXISTS idx_analytics_cache_expires + ON analytics_cache (expires_at); + +-- +goose down +DROP INDEX IF EXISTS idx_analytics_cache_expires; +DROP INDEX IF EXISTS idx_analytics_cache_lookup; +DROP TABLE IF EXISTS analytics_cache; \ No newline at end of file diff --git a/sql/schema/006_add_analytics_posts.sql b/sql/schema/006_add_analytics_posts.sql new file mode 100644 index 0000000..36fc965 --- /dev/null +++ b/sql/schema/006_add_analytics_posts.sql @@ -0,0 +1,22 @@ +-- +goose up +CREATE TABLE IF NOT EXISTS analytics_posts ( + id BIGSERIAL PRIMARY KEY, + user_id TEXT NOT NULL, + platform TEXT NOT NULL, + post_id TEXT NOT NULL, + post_text TEXT NOT NULL, + post_timestamp TIMESTAMPTZ NOT NULL, + likes BIGINT NOT NULL DEFAULT 0, + reposts BIGINT NOT NULL DEFAULT 0, + replies BIGINT NOT NULL DEFAULT 0, + quotes BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (user_id, platform, post_id) +); + +CREATE INDEX IF NOT EXISTS idx_analytics_posts_user_platform_time + ON analytics_posts (user_id, platform, post_timestamp); + +-- +goose down +DROP INDEX IF EXISTS idx_analytics_posts_user_platform_time; +DROP TABLE IF EXISTS analytics_posts; diff --git a/sql/schema/007_add_analytics_coverage.sql b/sql/schema/007_add_analytics_coverage.sql new file mode 100644 index 0000000..148e72d --- /dev/null +++ b/sql/schema/007_add_analytics_coverage.sql @@ -0,0 +1,21 @@ +-- +goose up +-- Tracks which time ranges have already been fetched for an author on a +-- platform so paid API calls (e.g. Twitter) are not repeated. Coverage is +-- stored as discrete non-overlapping intervals rather than a single +-- contiguous window, so gaps between separately-fetched ranges are not +-- mistaken for covered data. +CREATE TABLE IF NOT EXISTS analytics_coverage_ranges ( + user_id TEXT NOT NULL, + platform TEXT NOT NULL, + covered_start TIMESTAMPTZ NOT NULL, + covered_end TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, platform, covered_start, covered_end) +); + +CREATE INDEX IF NOT EXISTS idx_analytics_coverage_ranges_lookup + ON analytics_coverage_ranges (user_id, platform, covered_start); + +-- +goose down +DROP INDEX IF EXISTS idx_analytics_coverage_ranges_lookup; +DROP TABLE IF EXISTS analytics_coverage_ranges; diff --git a/testClient/main.go b/testClient/main.go index 75279a5..c73d1a0 100644 --- a/testClient/main.go +++ b/testClient/main.go @@ -1,27 +1,39 @@ package main import ( - "bytes" "context" "encoding/json" "fmt" "io" "log" - "os" - + "math/big" "net/http" "net/http/httputil" + "os" + "strings" + "time" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" "github.com/joho/godotenv" - //"github.com/mark3labs/mcp-go/client" - "github.com/mark3labs/x402-go" - x402http "github.com/mark3labs/x402-go/http" - - //x402client "github.com/mark3labs/x402-go/mcp/client" - "github.com/mark3labs/x402-go/signers/evm" + 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" ) +const ( + USDCContractAddress = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" + BaseSepoliaRPC = "https://sepolia.base.org" + MinUSDCRequired = 10.0 + MinETHRequired = 0.01 +) + +var usdcABI = `[{"constant":true,"inputs":[{"name":"_owner","type":"address"}],"name":"balanceOf","outputs":[{"name":"balance","type":"uint256"}],"type":"function"}]` + type LoggingRoundTripper struct { Proxied http.RoundTripper } @@ -34,7 +46,6 @@ func (lrt *LoggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, er req.Body = rc } - // Dump request reqDump, err := httputil.DumpRequestOut(req, true) if err != nil { log.Printf("Error dumping request: %v", err) @@ -42,13 +53,11 @@ func (lrt *LoggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, er 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) @@ -59,338 +68,265 @@ func (lrt *LoggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, er 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 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)) + } + + if resp.StatusCode == 402 { + return fmt.Errorf("%s: payment required (402)", endpoint) + } + + return nil +} + +func getEnv(key, fallback string) string { + if value, ok := os.LookupEnv(key); ok { + return value + } + return fallback +} + +func getUSDCBalance(client *ethclient.Client, address common.Address) (*big.Int, error) { + contractAddr := common.HexToAddress(USDCContractAddress) + parsedABI, err := abi.JSON(strings.NewReader(usdcABI)) + if err != nil { + return nil, fmt.Errorf("failed to parse USDC ABI: %w", err) + } + data, err := parsedABI.Pack("balanceOf", address) + if err != nil { + return nil, fmt.Errorf("failed to pack balanceOf call: %w", err) + } + msg := ethereum.CallMsg{ + To: &contractAddr, + Data: data, + } + result, err := client.CallContract(context.Background(), msg, nil) + if err != nil { + return nil, fmt.Errorf("failed to call balanceOf: %w", err) + } + unpacked, err := parsedABI.Unpack("balanceOf", result) + if err != nil { + return nil, fmt.Errorf("failed to unpack balance: %w", err) + } + if len(unpacked) == 0 { + return nil, fmt.Errorf("no balance returned") + } + balance, ok := unpacked[0].(*big.Int) + if !ok { + return nil, fmt.Errorf("unexpected balance type: %T", unpacked[0]) + } + return balance, nil +} + +func getETHBalance(client *ethclient.Client, address common.Address) (*big.Int, error) { + balance, err := client.BalanceAt(context.Background(), address, nil) + if err != nil { + return nil, fmt.Errorf("failed to get ETH balance: %w", err) + } + return balance, nil +} + +func requestFaucetFunds(faucetURL, address, token string) error { + payload := map[string]string{ + "address": address, + "token": token, + } + jsonData, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal faucet request: %w", err) + } + resp, err := http.Post(faucetURL, "application/json", strings.NewReader(string(jsonData))) + if err != nil { + return fmt.Errorf("faucet request failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("faucet returned error %d: %s", resp.StatusCode, string(body)) + } + return nil +} + +func ensureFundedWallet(privateKey, faucetURL, rpcURL string) error { + client, err := ethclient.Dial(rpcURL) + if err != nil { + return fmt.Errorf("failed to connect to RPC: %w", err) + } + defer client.Close() + + signer, err := evmsigners.NewClientSignerFromPrivateKey(privateKey) + if err != nil { + return fmt.Errorf("failed to create signer: %w", err) + } + addrStr := signer.Address() + address := common.HexToAddress(addrStr) + + log.Printf("Checking wallet balance for %s...", addrStr) + + usdcBalance, err := getUSDCBalance(client, address) + if err != nil { + return fmt.Errorf("failed to get USDC balance: %w", err) + } + usdcBalanceFloat := new(big.Float).Quo(new(big.Float).SetInt(usdcBalance), big.NewFloat(1e6)) + usdcFloat, _ := usdcBalanceFloat.Float64() + log.Printf("Current USDC balance: %.6f USDC", usdcFloat) + + ethBalance, err := getETHBalance(client, address) + if err != nil { + return fmt.Errorf("failed to get ETH balance: %w", err) + } + ethBalanceFloat := new(big.Float).Quo(new(big.Float).SetInt(ethBalance), big.NewFloat(1e18)) + ethFloat, _ := ethBalanceFloat.Float64() + log.Printf("Current ETH balance: %.6f ETH", ethFloat) + + needsFunding := false + if usdcFloat < MinUSDCRequired { + log.Printf("USDC balance %.6f below minimum %.2f, requesting funding...", usdcFloat, MinUSDCRequired) + needsFunding = true + } + if ethFloat < MinETHRequired { + log.Printf("ETH balance %.6f below minimum %.4f, requesting funding...", ethFloat, MinETHRequired) + needsFunding = true + } + + if needsFunding { + if faucetURL == "" { + return fmt.Errorf("FUNDING_FAUCET_URL not set but wallet needs funding") + } + if usdcFloat < MinUSDCRequired { + log.Printf("Requesting USDC from faucet...") + if err := requestFaucetFunds(faucetURL, addrStr, "USDC"); err != nil { + return fmt.Errorf("failed to request USDC from faucet: %w", err) + } + } + if ethFloat < MinETHRequired { + log.Printf("Requesting ETH from faucet...") + if err := requestFaucetFunds(faucetURL, addrStr, "ETH"); err != nil { + return fmt.Errorf("failed to request ETH from faucet: %w", err) + } + } + log.Printf("Waiting for funding to arrive...") + time.Sleep(30 * time.Second) + + usdcBalance, err = getUSDCBalance(client, address) + if err != nil { + return fmt.Errorf("failed to get USDC balance after funding: %w", err) + } + usdcBalanceFloat = new(big.Float).Quo(new(big.Float).SetInt(usdcBalance), big.NewFloat(1e6)) + usdcFloat, _ = usdcBalanceFloat.Float64() + log.Printf("New USDC balance: %.6f USDC", usdcFloat) + + ethBalance, err = getETHBalance(client, address) + if err != nil { + return fmt.Errorf("failed to get ETH balance after funding: %w", err) + } + ethBalanceFloat = new(big.Float).Quo(new(big.Float).SetInt(ethBalance), big.NewFloat(1e18)) + ethFloat, _ = ethBalanceFloat.Float64() + log.Printf("New ETH balance: %.6f ETH", ethFloat) + + if usdcFloat < MinUSDCRequired { + return fmt.Errorf("USDC balance still insufficient after funding: %.6f < %.2f", usdcFloat, MinUSDCRequired) + } + if ethFloat < MinETHRequired { + return fmt.Errorf("ETH balance still insufficient after funding: %.6f < %.4f", ethFloat, MinETHRequired) + } + } else { + log.Printf("Wallet has sufficient funds") + } + return nil +} + func main() { godotenv.Load() - var ( - privateKey = os.Getenv("PRIVATE_KEY") - serverURL = "https://api.naco.li" - err error - ) + privateKey := os.Getenv("PRIVATE_KEY") + serverURL := getEnv("TEST_SERVER_URL", "http://localhost:8080") + faucetURL := getEnv("FUNDING_FAUCET_URL", "") + rpcURL := getEnv("RPC_URL", BaseSepoliaRPC) - chainConfig := x402.BaseMainnet - - signerOpts := []evm.SignerOption{ - evm.WithPrivateKey(privateKey), - evm.WithNetwork(chainConfig.NetworkID), - evm.WithToken(chainConfig.USDCAddress, chainConfig.EIP3009Name, int(chainConfig.Decimals)), + if privateKey == "" { + log.Fatal("PRIVATE_KEY environment variable is required") } - signer, err := evm.NewSigner(signerOpts...) - if err != nil { - log.Fatal("Failed to create signer:", err) + log.Printf("Ensuring wallet is funded...") + if err := ensureFundedWallet(privateKey, faucetURL, rpcURL); err != nil { + log.Fatal("Failed to ensure wallet is funded: ", err) } - /*x402mcpTransport, err := x402client.NewTransport(serverURL+"/mcp", x402client.WithSigner(signer)) + signer, err := evmsigners.NewClientSignerFromPrivateKey(privateKey) if err != nil { - log.Fatal("Failed to create transport:", err) - }*/ + log.Fatal("Failed to create EVM signer:", err) + } + + x402Client := x402.Newx402Client(). + Register("eip155:84532", evm.NewExactEvmScheme(signer, nil)) debugClient := &http.Client{ Transport: &LoggingRoundTripper{ Proxied: http.DefaultTransport, }, + Timeout: 60 * time.Second, } - callback := x402.PaymentCallback(func(pe x402.PaymentEvent) { - log.Printf("Type: %s, Tool: %s, Error: %s", pe.Type, pe.Tool, pe.Error) - }) - // Create MCP client with x402 transport - //mcpClient := client.NewClient(x402mcpTransport) - httpClient, err := x402http.NewClient(x402http.WithPaymentCallbacks( - callback, - callback, - callback, - ), - x402http.WithHTTPClient(debugClient), - x402http.WithSelector(x402.NewDefaultPaymentSelector()), - x402http.WithSigner(signer), + httpClient := x402http.WrapHTTPClientWithPayment( + debugClient, + x402http.Newx402HTTPClient(x402Client), ) - if err != nil { - log.Fatal("Failed to create HTTP client:", err) + + 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", `{"actor":"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}`}, } - ctx := context.Background() - /*if err := mcpClient.Start(ctx); err != nil { - log.Fatal("Failed to start client:", err) - } - defer mcpClient.Close() - */ - //startTime, _ := time.Parse(time.DateOnly, "2025-10-25") - //endTime, _ := time.Parse(time.DateOnly, "2025-10-31") + passed := 0 + failed := 0 - /*// Initialize MCP session - initResp, err := mcpClient.Initialize(ctx, mcp.InitializeRequest{ - Params: mcp.InitializeParams{ - ProtocolVersion: "2024-11-05", - ClientInfo: mcp.Implementation{ - Name: "x402-test", - Version: "1.0.0", - }, - }, - }) - if err != nil { - log.Fatal("Failed to initialize:", err) - } - - log.Printf("Connected to server: %s v%s", - initResp.ServerInfo.Name, initResp.ServerInfo.Version) - - // List all available tools - log.Println("\nListing available tools...") - toolsResp, err := mcpClient.ListTools(ctx, mcp.ListToolsRequest{}) - if err != nil { - log.Printf("Failed to list tools: %v", err) - } else { - log.Println("Available tools:") - for _, tool := range toolsResp.Tools { - log.Printf(" - %s: %s", tool.Name, tool.Description) - } - } - - log.Println("\nCalling feed tool (free)...") - _, err = mcpClient.CallTool(ctx, mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Name: "getFeed", - Arguments: map[string]any{ - "uri": "https://bsky.app/profile/did:plc:z72i7hdynmk6r22z27h6tvur/feed/whats-hot", - }, - }, - }) - - if err != nil { - log.Printf("Feed failed: %v", err) - } else { - log.Println("Feed response:") - if len(echoResp.Content) > 0 { - if textContent, ok := mcp.AsTextContent(echoResp.Content[0]); ok { - log.Println(textContent.Text) - } - } - log.Println("Feed response received successfully.") - } - - log.Println("\nCalling feed tool again with proper feed uri...") - _, err = mcpClient.CallTool(ctx, mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Name: "getFeed", - Arguments: map[string]any{ - "uri": "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot", - }, - }, - }) - - if err != nil { - log.Printf("Feed failed: %v", err) - } else { - log.Println("Feed response:") - if len(echoResp.Content) > 0 { - if textContent, ok := mcp.AsTextContent(echoResp.Content[0]); ok { - log.Println(textContent.Text) - } - } - log.Println("Feed response received successfully.") - } - - // Call the search tool (paid) - log.Println("\nGet Post (paid tool)...") - searchResp, err := mcpClient.CallTool(ctx, mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Name: "getPost", - Arguments: map[string]any{ - "actor": "sharkgamiart.bsky.social", - "rkey": "3m47y27c67c2b", - }, - }, - }) - - if err != nil { - log.Fatalf("Get Post failed: %v", err) - } - - log.Println("Post results:") - if len(searchResp.Content) > 0 { - if textContent, ok := mcp.AsTextContent(searchResp.Content[0]); ok { - log.Println(textContent.Text) - } - } - - log.Println("\nGet latest posts (paid tool)...") - latestResp, err := mcpClient.CallTool(ctx, mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Name: "getLatestPosts", - Arguments: map[string]any{ - "actor": "sharkgamiart.bsky.social", - "n": 10, - }, - }, - }) - - if err != nil { - log.Fatalf("Get Latest Posts failed: %v", err) - } - - log.Printf("Latest posts (%d):\n", len(latestResp.Content)) - if len(latestResp.Content) > 0 { - if textContent, ok := mcp.AsTextContent(latestResp.Content[0]); ok { - log.Println(" - ", textContent.Text) - } - } - - log.Println("\nGet posts in timeframe (paid tool)...") - timeframeResp, err := mcpClient.CallTool(ctx, mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Name: "getPostsInTime", - Arguments: map[string]any{ - "actor": "sharkgamiart.bsky.social", - "startTime": startTime.Format(time.RFC3339), - "endTime": endTime.Format(time.RFC3339), - "n": 5, - }, - }, - }) - - if err != nil { - log.Fatalf("Get Posts in Timeframe failed: %v", err) - } - - log.Println("Posts in timeframe results:") - if len(timeframeResp.Content) > 0 { - if textContent, ok := mcp.AsTextContent(timeframeResp.Content[0]); ok { - log.Println(textContent.Text) - } - } - - mcpClient.Close()*/ - - var dataGetFeed any - log.Println("Calling getFeed via HTTP client") - resp, err := httpClient.Post(fmt.Sprintf("%s/getFeed", serverURL), "application/json", bytes.NewReader([]byte(`{"uri": "https://bsky.app/profile/did:plc:z72i7hdynmk6r22z27h6tvur/feed/whats-hot"}`))) - if err != nil { - log.Println("getFeed failed:", err) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - log.Println("Failed to read getFeed response body:", err) - } - if resp.StatusCode != http.StatusOK { - log.Printf("Raw Response Body: %s\n", string(body)) - } - err = json.Unmarshal(body, &dataGetFeed) - if err != nil { - log.Println("Failed to unmarshal getFeed response body:", err) - log.Printf("Received Raw Body: %s\n", string(body)) - } - //log.Println("getFeed response:", dataGetFeed) - resp.Body.Close() - - /* var dataGetFeed2 any - log.Println("Calling getFeed via HTTP client with proper feed uri") - resp, err = httpClient.Post(fmt.Sprintf("%s/getFeed", serverURL), "application/json", bytes.NewReader([]byte(`{"uri":"at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot"}`))) + 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.Println("getFeed failed:", err) + log.Printf("FAIL %s: %v\n", tc.name, err) + failed++ + } else { + fmt.Printf("PASS %s\n", tc.name) + passed++ } - - body, err = io.ReadAll(resp.Body) - if err != nil { - log.Println("Failed to read getFeed response body:", err) - } - if resp.StatusCode != http.StatusOK { - log.Printf("Raw Response Body: %s\n", string(body)) - } - err = json.Unmarshal(body, &dataGetFeed2) - if err != nil { - log.Println("Failed to unmarshal getFeed response body:", err) - log.Printf("Received Raw Body: %s\n", string(body)) - } - //log.Println("getFeed response:", dataGetFeed2) - resp.Body.Close() - */ - var dataGetPost any - log.Println("Calling getPost via HTTP client") - bodyBytes := []byte(`{"actor":"sharkgamiart.bsky.social","rkey":"3m47y27c67c2b"}`) - - req, _ := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("%s/getPost", serverURL), bytes.NewReader(bodyBytes)) - req.GetBody = func() (io.ReadCloser, error) { - return io.NopCloser(bytes.NewReader(bodyBytes)), nil - } - req.Header.Set("Content-Type", "application/json") - - resp, err = httpClient.Do(req) - if err != nil { - log.Println("getPost failed:", err) } - body, err = io.ReadAll(resp.Body) - if err != nil { - log.Println("Failed to read getPost response body:", err) + log.Printf("\n=== Test Summary ===") + log.Printf("Passed: %d", passed) + log.Printf("Failed: %d", failed) + + if failed > 0 { + os.Exit(1) } - if resp.StatusCode != http.StatusOK { - log.Printf("Raw Response Body: %s\n", string(body)) - } - err = json.Unmarshal(body, &dataGetPost) - if err != nil { - log.Println("Failed to unmarshal getPost response body:", err) - log.Printf("Received Raw Body: %s\n", string(body)) - } - log.Println("getPost response:", dataGetPost) - resp.Body.Close() - /* - var dataGetLatestPosts any - log.Println("Calling getLatestPosts via HTTP client") - resp, err = httpClient.Post(fmt.Sprintf("%s/getLatestPosts", serverURL), "application/json", bytes.NewReader([]byte(`{"actor":"sharkgamiart.bsky.social","n":10}`))) - - if err != nil { - log.Println("getLatestPosts failed:", err) - } - - log.Println(resp.Proto) - body, err = io.ReadAll(resp.Body) - - if err != nil { - log.Println("Failed to read getLatestPosts response body:", err) - } - - if resp.StatusCode != http.StatusOK { - log.Printf("Raw Response Body: %s\n", string(body)) - } - - err = json.Unmarshal(body, &dataGetLatestPosts) - - if err != nil { - log.Println("Failed to unmarshal getLatestPosts response body:", err) - log.Printf("Received Raw Body: %s\n", string(body)) - } - - log.Println("getLatestPosts response:", dataGetLatestPosts) - resp.Body.Close() - - var dataGetPostsInTime any - log.Println("Calling getPostsInTime via HTTP client") - resp, err = httpClient.Post(fmt.Sprintf("%s/getPostsInTime", serverURL), "application/json", bytes.NewReader([]byte(fmt.Sprintf(`{"actor":"sharkgamiart.bsky.social","startTime":"%s","endTime":"%s","n":5}`, startTime.Format(time.RFC3339), endTime.Format(time.RFC3339))))) - - if err != nil { - log.Println("getPostsInTime failed:", err) - } - - body, err = io.ReadAll(resp.Body) - - if err != nil { - log.Println("Failed to read getPostsInTime response body:", err) - } - - if resp.StatusCode != http.StatusOK { - log.Printf("Raw Response Body: %s\n", string(body)) - } - - err = json.Unmarshal(body, &dataGetPostsInTime) - - if err != nil { - log.Println("Failed to unmarshal getPostsInTime response body:", err) - log.Printf("Received Raw Body: %s\n", string(body)) - } - - log.Println("getPostsInTime response:", dataGetPostsInTime) - resp.Body.Close() - */ }