Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8bb4cb539c | |||
| 07ab6a13af | |||
| ee0589c774 | |||
| 193184675e | |||
| 90af0ec4fc | |||
| c8d1829172 | |||
| 6477d5845f | |||
| 0dcb74675e | |||
| e4176f99ed | |||
| 979cd92445 | |||
| c6b73cba1f | |||
| fbd74a37b5 | |||
| 4e027ca857 | |||
| c851d0e50f | |||
| ab1eb634f9 | |||
| 686c7ca2b6 | |||
| 695e137915 | |||
| 4f56def00e | |||
| 3c0a7b9506 | |||
| 248f738e07 | |||
| 877f839926 | |||
| ad67171f37 | |||
| 5574a8596f | |||
| 56f8625f05 | |||
| e2cd655bc1 | |||
| 63709524de | |||
| 63f8756cc7 | |||
| d3845671b4 | |||
| 027a4ee5e9 | |||
| e54d17736b | |||
| 114574b331 | |||
| fbd1f5af6c | |||
| 9918134fd0 | |||
| d7bece250f | |||
| 71797cfc4f | |||
| d085d9e3ae | |||
| bd707cf2ca | |||
| 3c024d668e | |||
| cab911ea6f | |||
| ec27416cb1 | |||
| 4ac15ff6c2 | |||
| 1d97ca1164 | |||
| 8cfb1291aa | |||
| 533ecede00 | |||
| 4d11cea6cd | |||
| e31a4313f4 | |||
| e8a37831cb | |||
| bc168e1bca | |||
| e5168e33f3 | |||
| 12bf3e8311 | |||
| b8b39af276 | |||
| dbf0e2591b | |||
| 4af4d79940 | |||
| 33f07ea75a | |||
| bd62f05b5d | |||
| 641bf66dbb | |||
| 9f59a7d9d6 | |||
| 5c89b8077a | |||
| 8b27ecacfa | |||
| b655faca4a | |||
| f7c75e05f0 | |||
| f144cf8868 | |||
| fed1a8d294 |
90 changed files with 11977 additions and 1263 deletions
142
.forgejo/workflows/test-endpoints.yaml
Normal file
142
.forgejo/workflows/test-endpoints.yaml
Normal file
|
|
@ -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
|
||||
36
.forgejo/workflows/test.yaml
Normal file
36
.forgejo/workflows/test.yaml
Normal file
|
|
@ -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 ./...
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1,3 +1,5 @@
|
|||
.env
|
||||
cdpScripts/cdp
|
||||
bin/
|
||||
bin/
|
||||
test-endpoints
|
||||
test-endpoints.exe
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
5
config/models.env
Normal file
5
config/models.env
Normal file
|
|
@ -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
|
||||
26
docker-compose.test.yml
Normal file
26
docker-compose.test.yml
Normal file
|
|
@ -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
|
||||
103
go.mod
103
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
|
||||
)
|
||||
|
|
|
|||
394
go.sum
394
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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
220
internal/activitypub/client_test.go
Normal file
220
internal/activitypub/client_test.go
Normal file
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
272
internal/activitypub/types.go
Normal file
272
internal/activitypub/types.go
Normal file
|
|
@ -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"`
|
||||
}
|
||||
16
internal/actor/profile.go
Normal file
16
internal/actor/profile.go
Normal file
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
37
internal/clustering/helpers.go
Normal file
37
internal/clustering/helpers.go
Normal file
|
|
@ -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
|
||||
}
|
||||
228
internal/clustering/k-means.go
Normal file
228
internal/clustering/k-means.go
Normal file
|
|
@ -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
|
||||
}
|
||||
213
internal/clustering/kmeans_test.go
Normal file
213
internal/clustering/kmeans_test.go
Normal file
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
21
internal/clustering/types.go
Normal file
21
internal/clustering/types.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 "."
|
||||
}
|
||||
|
|
|
|||
333
internal/nostr/client.go
Normal file
333
internal/nostr/client.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
231
internal/nostr/client_test.go
Normal file
231
internal/nostr/client_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
215
internal/ollama/client_test.go
Normal file
215
internal/ollama/client_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
63
internal/server/GlobalLoggingMiddleware.go
Normal file
63
internal/server/GlobalLoggingMiddleware.go
Normal file
|
|
@ -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(),
|
||||
)
|
||||
})
|
||||
}
|
||||
74
internal/server/LoggingMiddleware.go
Normal file
74
internal/server/LoggingMiddleware.go
Normal file
|
|
@ -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,
|
||||
)
|
||||
})
|
||||
}
|
||||
995
internal/server/analytics.go
Normal file
995
internal/server/analytics.go
Normal file
|
|
@ -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
|
||||
}
|
||||
269
internal/server/analytics_test.go
Normal file
269
internal/server/analytics_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
84
internal/server/auth.go
Normal file
84
internal/server/auth.go
Normal file
|
|
@ -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
|
||||
}
|
||||
76
internal/server/backends.go
Normal file
76
internal/server/backends.go
Normal file
|
|
@ -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
|
||||
}
|
||||
51
internal/server/cache.go
Normal file
51
internal/server/cache.go
Normal file
|
|
@ -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
|
||||
}
|
||||
15
internal/server/cache_version.go
Normal file
15
internal/server/cache_version.go
Normal file
|
|
@ -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
|
||||
}
|
||||
100
internal/server/coverage_test.go
Normal file
100
internal/server/coverage_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
857
internal/server/mcp_test.go
Normal file
857
internal/server/mcp_test.go
Normal file
|
|
@ -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 ----
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
34
internal/server/networkInfo.go
Normal file
34
internal/server/networkInfo.go
Normal file
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
}
|
||||
57
internal/server/platform_test.go
Normal file
57
internal/server/platform_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
84
internal/server/searchactors_test.go
Normal file
84
internal/server/searchactors_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
214
internal/server/storefake_test.go
Normal file
214
internal/server/storefake_test.go
Normal file
|
|
@ -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
|
||||
}
|
||||
264
internal/server/styleprofile_test.go
Normal file
264
internal/server/styleprofile_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
162
internal/server/twitter_analytics_test.go
Normal file
162
internal/server/twitter_analytics_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)"`
|
||||
}
|
||||
|
|
|
|||
261
internal/server/validation_test.go
Normal file
261
internal/server/validation_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
120
internal/server/webhook.go
Normal file
120
internal/server/webhook.go
Normal file
|
|
@ -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
|
||||
}
|
||||
96
internal/store/AnalyticsCache.sql.go
Normal file
96
internal/store/AnalyticsCache.sql.go
Normal file
|
|
@ -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
|
||||
}
|
||||
96
internal/store/AnalyticsCoverage.sql.go
Normal file
96
internal/store/AnalyticsCoverage.sql.go
Normal file
|
|
@ -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
|
||||
}
|
||||
113
internal/store/AnalyticsPosts.sql.go
Normal file
113
internal/store/AnalyticsPosts.sql.go
Normal file
|
|
@ -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
|
||||
}
|
||||
25
internal/store/DeleteStylePosts.sql.go
Normal file
25
internal/store/DeleteStylePosts.sql.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
33
internal/store/GetStylePostByID.sql.go
Normal file
33
internal/store/GetStylePostByID.sql.go
Normal file
|
|
@ -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
|
||||
}
|
||||
51
internal/store/GetStylePosts.sql.go
Normal file
51
internal/store/GetStylePosts.sql.go
Normal file
|
|
@ -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
|
||||
}
|
||||
51
internal/store/GetStylePostsByIDs.sql.go
Normal file
51
internal/store/GetStylePostsByIDs.sql.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
20
internal/store/PruneStylePosts.sql.go
Normal file
20
internal/store/PruneStylePosts.sql.go
Normal file
|
|
@ -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
|
||||
}
|
||||
34
internal/store/UpsertStylePosts.sql.go
Normal file
34
internal/store/UpsertStylePosts.sql.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
134
internal/store/integration_test.go
Normal file
134
internal/store/integration_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
106
internal/store/migrate.go
Normal file
106
internal/store/migrate.go
Normal file
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
347
internal/twitter/client.go
Normal file
347
internal/twitter/client.go
Normal file
|
|
@ -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"`
|
||||
}
|
||||
378
internal/twitter/client_test.go
Normal file
378
internal/twitter/client_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
447
internal/x402/x402_test.go
Normal file
447
internal/x402/x402_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
62
scripts/fund-wallet.sh
Executable file
62
scripts/fund-wallet.sh
Executable file
|
|
@ -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 ==="
|
||||
83
scripts/start-test-server.sh
Executable file
83
scripts/start-test-server.sh
Executable file
|
|
@ -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) ==="
|
||||
17
sql/queries/AnalyticsCache.sql
Normal file
17
sql/queries/AnalyticsCache.sql
Normal file
|
|
@ -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();
|
||||
14
sql/queries/AnalyticsCoverage.sql
Normal file
14
sql/queries/AnalyticsCoverage.sql
Normal file
|
|
@ -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;
|
||||
17
sql/queries/AnalyticsPosts.sql
Normal file
17
sql/queries/AnalyticsPosts.sql
Normal file
|
|
@ -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;
|
||||
3
sql/queries/DeleteStylePosts.sql
Normal file
3
sql/queries/DeleteStylePosts.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
-- name: DeleteStylePosts :exec
|
||||
DELETE FROM user_style_posts
|
||||
WHERE user_id = $1 AND platform = $2 AND platform != 'Twitter';
|
||||
|
|
@ -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;
|
||||
ORDER BY embedding <=> @query_embedding::vector
|
||||
LIMIT @limit_val;
|
||||
3
sql/queries/GetStylePostByID.sql
Normal file
3
sql/queries/GetStylePostByID.sql
Normal file
|
|
@ -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;
|
||||
5
sql/queries/GetStylePosts.sql
Normal file
5
sql/queries/GetStylePosts.sql
Normal file
|
|
@ -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;
|
||||
3
sql/queries/GetStylePostsByIDs.sql
Normal file
3
sql/queries/GetStylePostsByIDs.sql
Normal file
|
|
@ -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[]);
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
-- name: GetStyleProfile :one
|
||||
SELECT * FROM user_style_profiles
|
||||
WHERE user_id = $1 AND platform = $2;
|
||||
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;
|
||||
3
sql/queries/PruneStylePosts.sql
Normal file
3
sql/queries/PruneStylePosts.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
-- name: PruneStylePosts :exec
|
||||
DELETE FROM user_style_posts
|
||||
WHERE created_at < now() - interval '7 days' AND platform != 'Twitter';
|
||||
5
sql/queries/UpsertStylePosts.sql
Normal file
5
sql/queries/UpsertStylePosts.sql
Normal file
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
modified_at = NOW();
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
DROP TABLE IF EXISTS user_style_profiles;
|
||||
|
||||
DROP EXTENSION IF EXISTS vector;
|
||||
15
sql/schema/003_add_cache_version.sql
Normal file
15
sql/schema/003_add_cache_version.sql
Normal file
|
|
@ -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;
|
||||
17
sql/schema/004_add_style_posts.sql
Normal file
17
sql/schema/004_add_style_posts.sql
Normal file
|
|
@ -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;
|
||||
23
sql/schema/005_add_analytics_cache.sql
Normal file
23
sql/schema/005_add_analytics_cache.sql
Normal file
|
|
@ -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;
|
||||
22
sql/schema/006_add_analytics_posts.sql
Normal file
22
sql/schema/006_add_analytics_posts.sql
Normal file
|
|
@ -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;
|
||||
21
sql/schema/007_add_analytics_coverage.sql
Normal file
21
sql/schema/007_add_analytics_coverage.sql
Normal file
|
|
@ -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;
|
||||
|
|
@ -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()
|
||||
*/
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue