Compare commits

..
Author SHA1 Message Date
3b3336ac7e update: default facilitator
All checks were successful
CI/CD Go Verification / build_and_test (push) Successful in 1m25s
2026-08-04 21:41:05 +00:00
107 changed files with 1175 additions and 15736 deletions

View file

@ -1,199 +0,0 @@
name: Test Every Endpoint on Base Sepolia
on:
push:
branches: [ main, ai-main ]
pull_request:
branches: [ main ]
workflow_dispatch:
env:
GO_VERSION: '1.25'
POSTGRES_DB: mcpserver
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
TEST_SERVER_URL: http://localhost:8080
RPC_URL: https://sepolia.base.org
X402_FACILITATOR_URL: https://api.cdp.coinbase.com/platform/v2/x402
jobs:
test_endpoints:
runs-on: node20
timeout-minutes: 30
services:
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_DB: mcpserver
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres -d mcpserver"
--health-interval 5s
--health-timeout 5s
--health-retries 10
ollama:
image: ollama/ollama:latest
ports:
- 11434:11434
# no healthcheck here - ollama takes >60s to pull image + start,
# act would mark service 'not healthy' and abort job. Let steps poll.
env:
PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }}
X402_WALLET_ADDRESS: ${{ secrets.X402_WALLET_ADDRESS }}
BLUESKY_DID: ${{ secrets.BLUESKY_DID }}
BLUESKY_HOST: ${{ secrets.BLUESKY_HOST }}
BLUESKY_APP_PASSWORD: ${{ secrets.BLUESKY_APP_PASSWORD }}
AP_HOST: ${{ secrets.AP_HOST }}
AP_ACCESS_TOKEN: ${{ secrets.AP_ACCESS_TOKEN }}
TWITTER_BEARER_TOKEN: ${{ secrets.TWITTER_BEARER_TOKEN }}
CDP_API_KEY_ID: ${{ secrets.CDP_API_KEY_ID }}
CDP_API_SECRET: ${{ secrets.CDP_API_SECRET }}
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Load model config
run: |
source config/models.env
echo "EMBEDDING_MODEL=$EMBEDDING_MODEL" >> $FORGEJO_ENV
echo "STYLE_MODEL=$STYLE_MODEL" >> $FORGEJO_ENV
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: Install Dependencies
run: go mod download
- name: Verify Code Formatting
run: |
if [ -n "$(gofmt -l .)" ]; then
echo "Code is not formatted correctly. Run 'gofmt -w .'"
exit 1
fi
- name: Wait for PostgreSQL
run: |
if ! command -v pg_isready >/dev/null 2>&1; then
apt-get update && apt-get install -y postgresql-client
fi
for i in {1..60}; do
if pg_isready -h localhost -U postgres -d mcpserver >/dev/null 2>&1 || \
pg_isready -h postgres -U postgres -d mcpserver >/dev/null 2>&1; then
echo "PostgreSQL is ready"
exit 0
fi
sleep 2
done
echo "PostgreSQL failed to start"
exit 1
- name: Wait for Ollama
run: |
for i in {1..90}; do
if curl -sf http://localhost:11434/api/tags >/dev/null 2>&1 || \
curl -sf http://ollama:11434/api/tags >/dev/null 2>&1; then
echo "Ollama is ready"
exit 0
fi
echo "waiting for ollama $i/90..."
sleep 5
done
echo "Ollama failed to start (not healthy, but continuing - pull may still work)"
curl -v http://localhost:11434/api/tags || curl -v http://ollama:11434/api/tags || true
- name: Pull Ollama Models
run: |
for host in http://localhost:11434 http://ollama:11434; do
if curl -sf $host/api/tags >/dev/null 2>&1; then
OLLAMA_HOST=$host
break
fi
done
: "${OLLAMA_HOST:=http://localhost:11434}"
echo "Using OLLAMA_HOST=$OLLAMA_HOST"
if [ -n "${EMBEDDING_MODEL:-}" ]; then
echo "Pulling $EMBEDDING_MODEL..."
curl -X POST $OLLAMA_HOST/api/pull -d "{\"name\":\"${EMBEDDING_MODEL}\"}" || true
fi
if [ -n "${STYLE_MODEL:-}" ]; then
echo "Pulling $STYLE_MODEL..."
curl -X POST $OLLAMA_HOST/api/pull -d "{\"name\":\"${STYLE_MODEL}\"}" || true
fi
sleep 2
curl -sf $OLLAMA_HOST/api/tags || true
- name: Debug CDP Secret Format
env:
CDP_API_KEY_ID: ${{ secrets.CDP_API_KEY_ID }}
CDP_API_SECRET: ${{ secrets.CDP_API_SECRET }}
run: |
echo "=== CDP SECRET DEBUG ==="
echo "KEY_NAME: ${CDP_API_KEY_ID}"
echo "SECRET_LEN: ${#CDP_API_SECRET}"
echo "SECRET_FIRST_80: ${CDP_API_SECRET:0:80}"
echo "HAS_LITERAL_\\n: $(echo -n "$CDP_API_SECRET" | grep -c '\\\\n' || true)"
echo "HAS_ACTUAL_NEWLINE: $(echo -n "$CDP_API_SECRET" | grep -c $'\n' || true)"
echo "STARTS_WITH: $(echo -n "$CDP_API_SECRET" | head -c 40)"
echo "ENDS_WITH: $(echo -n "$CDP_API_SECRET" | tail -c 20)"
echo "=== END DEBUG ==="
- name: Check Facilitator Supported Networks
env:
CDP_API_KEY_ID: ${{ secrets.CDP_API_KEY_ID }}
CDP_API_SECRET: ${{ secrets.CDP_API_SECRET }}
X402_FACILITATOR_URL: https://api.cdp.coinbase.com/platform/v2/x402
run: |
echo "Building facilitator checker..."
go build -o /tmp/check-facilitator ./cmd/check-facilitator
/tmp/check-facilitator
- name: Start Test Server
run: |
chmod +x scripts/start-test-server.sh
./scripts/start-test-server.sh
- name: Build MCP Test Client
run: |
go build -o test-mcp-endpoints ./testClient/mcp
- name: Run MCP Endpoint Tests
env:
RPC_URL: ${{ env.RPC_URL }}
run: |
./test-mcp-endpoints
- name: Build REST Test Client
run: |
go build -o test-endpoints ./testClient/rest
- name: Run REST Endpoint Tests
env:
RPC_URL: ${{ env.RPC_URL }}
run: |
./test-endpoints
- name: Dump Server Logs on Failure
if: failure()
run: |
echo "====== /tmp/mcpserver-test.log ======"
cat /tmp/mcpserver-test.log 2>&1 || echo "no log file at /tmp/mcpserver-test.log"
echo "====== end server log ======"
echo "====== journal (last 200 lines) ======"
journalctl --no-pager -n 200 2>&1 | tail -n 200 || true
- name: Cleanup
if: always()
run: |
if [ -f /tmp/test-server.pid ]; then
kill -TERM $(cat /tmp/test-server.pid) 2>/dev/null || true
sleep 2
fi
echo "====== server log tail (always) ======"
tail -n 500 /tmp/mcpserver-test.log 2>&1 || echo "no log file"

View file

@ -1,14 +1,10 @@
name: CI Go Verification
name: CI/CD Go Verification
on:
push:
branches: [ main, dev, ai-main ]
pull_request:
branches: [ main, dev ]
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
pull_request:
branches: [ main ]
jobs:
build_and_test:
@ -33,11 +29,8 @@ jobs:
exit 1
fi
- name: Vet
run: go vet ./...
- name: Build Application
run: go build -v ./...
- name: Run Tests
run: go test -v -race -cover ./...
- name: Run Tests (with Mocked Boundaries)
run: go test -v -cover ./...

4
.gitignore vendored
View file

@ -1,7 +1,3 @@
.env
cdpScripts/cdp
bin/
test-endpoints
test-endpoints.exe
test-mcp-endpoints
test-mcp-endpoints.exe

View file

@ -1,25 +0,0 @@
version: "2"
linters:
settings:
errcheck:
exclude-functions:
- (*net/http.Response).Body.Close
- io.Closer.Close
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
- third_party$
- builtin$
- examples$
formatters:
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$

View file

@ -1,97 +0,0 @@
# AGENTS.md — naco-api
## Quick commands
```bash
# Verify (no CI — match these locally)
gofmt -l . # fail if any output
go build -v ./...
go test -v -cover ./...
# Single package / single test
go test -v -cover ./internal/server/... -run TestGetPost
go test -v -cover ./internal/bluesky/...
# Integration tests (requires Postgres + pgvector; never use prod DB)
docker compose -f docker-compose.test.yml up -d # postgres:5432 + ollama:11434
TEST_DATABASE_URL="postgres://postgres:postgres@localhost:5432/mcpserver?sslmode=disable" \
go test -v -cover ./internal/store/...
```
## What this is
Go MCP server (13 tools) for **Bluesky / Nostr / ActivityPub / Twitter**. Every tool is gated by **x402** (HTTP 402 crypto payments, EVM `base`); settlement → Postgres, optional **Matrix** webhook.
Tools: `getPost`, `getLatestPosts`, `getPostsInTime`, `getFeed` (free), `getActor`, `searchActors`, `getStyleProfile`, `findSimilarUsers`, `getEngagementMetrics`, `getPostingPatterns`, `getTopicModeling`, `getSentimentTrends`, `getEngagementDrivers`.
## Architecture
```
cmd/server/main.go entrypoint — wires config, DB, all clients, readyz sidecar
internal/server/ MCP handlers + x402 + analytics (~1800 lines in server.go — start there)
server.go tool dispatch, settlement, prewarm, cache
analytics.go analytics compute + coverage/cache logic
backends.go platform/backend interfaces (fakes for tests)
storefake_test.go in-memory Store fake (DB-free analytics tests)
types.go request/response types, platform enum
auth.go / webhook.go / cache*.go / *Middleware.go / networkInfo.go
*_test.go split suites: mcp_test.go, analytics_test.go, validation_test.go, etc.
internal/activitypub/ AP client; throttle.go per-host token bucket (1/s) for background GTS warming
internal/{bluesky,nostr,twitter,ollama,config,utils,actor,x402}
sql/schema/ 001..007 goose migrations
sql/queries/ sqlc query files → internal/store/
config/models.env single source of truth for EMBEDDING_MODEL / STYLE_MODEL
testClient/rest/ manual live REST endpoint client (not part of go test)
testClient/mcp/ manual live MCP endpoint client (not part of go test)
```
## Code generation
`sqlc` generates `internal/store/*.go` from `sql/queries/*.sql` + `sql/schema/`:
```bash
sqlc generate # after editing any file in sql/
```
Never hand-edit `// Code generated by sqlc. DO NOT EDIT.` files.
## Migrations
Goose-style (`-- +goose up` / `-- +goose down`) in `sql/schema/` — 7 files `001_transactions.sql` … `007_add_analytics_coverage.sql`. Applied at boot when `MIGRATE_ON_START=true` (default `true`). New files must be numbered sequentially (`008_*.sql`).
## Testing patterns
- Interface fakes in `internal/server/backends.go` + `storefake_test.go` — no live clients/DB needed.
- Call tools via `callTool(s, name, args)` through the MCP handler (see `mcp_test.go:302`).
- Integration `internal/store/integration_test.go` skipped unless `TEST_DATABASE_URL` set — use `docker-compose.test.yml` (pgvector:pg16). Point it at a throwaway DB.
- `internal/server` tests split by concern (`analytics_test.go`, `searchactors_test.go`, `styleprofile_test.go`, `validation_test.go`, `coverage_test.go`, `twitter_analytics_test.go`); `mcp_test.go` holds cross-cutting dispatch/cache.
## Config
Loaded in order: `config/models.env` → `.env` (gitignored, via `godotenv`) → env. `getEnv(k, fallback)` fatals if fallback `""` and var unset.
| Variable | Default | Notes |
|---|---|---|
| `GOOSE_DBSTRING` | `postgres:///mcpserver` | Server DB (not tests) |
| `TEST_DATABASE_URL` | — | **Tests only**, separate DB |
| `SERVER_ADDRESS` / `SERVER_PORT` | `127.0.0.1` / `8080` | Unix socket via x402 `X402Server`; TCP sidecar on `READYZ_PORT` |
| `READYZ_PORT` | `9090` | Sidecar serves `/readyz` (DB+Ollama health) + `/metrics` |
| `MIGRATE_ON_START` | `true` | Apply `sql/schema` on boot |
| `OLLAMA_HOST` | `http://localhost:11434` | Models from `config/models.env` |
| `MATRIX_WEBHOOK_URL` | — | If set, payment notifications |
| `X402_FACILITATOR_URL` | `https://facilitator.x402.rs` | |
| `ACTIVE_NETWORKS` | `base` | Comma eip155 IDs |
| `ENRICH_CROSS_PLATFORM` | `true` | |
`findProjectRoot()` walks up to `go.mod` to locate `config/models.env`; tests with `os.Chdir` can break it.
## Gotchas
- `bin/` gitignored — `go build -o bin/mcpserver ./cmd/server/`.
- Server DB `GOOSE_DBSTRING` vs test DB `TEST_DATABASE_URL` intentionally separate.
- `testClient/` hits live `api.naco.li` — not run by `go test`.
- `server.go` is large; new tool: register in `registerTools()` and add `func (s *Server) myTool(*mcp.CallToolRequest)`.
- Unix socket MCP + TCP readyz sidecar; `mcpServer.service` expects `bin/main` at repo root.
- Platform inferred from author handle in `determinePlatform`/`fetch*` — not caller-supplied.
- Bluesky auth refresh via `isAuthError`/`withRefresh` — 401/expired token retried once.
- Twitter client clamps `limit` (`>100` → 100), retries 429/5xx/transport errors with `x-rate-limit-*`/`Retry-After` backoff (bounded by `maxRateLimitWait`), and has a 60s per-request timeout.
- ActivityPub client: GTS Mastodon API only serves locally-federated statuses. Remote actors are resolved via `/accounts/search?resolve=true` (not `/accounts/lookup`, which skips WebFinger). When the local cache can't satisfy full `limit` (clamped to 200), `GetLatestNPosts`/`GetPostsInTimeframe` fall back to walking the remote outbox directly (webfinger → actor → `OrderedCollection`/`OrderedCollectionPage`), filtering `Create`/`Note`-like activities and dereferencing IRI-only objects; `GetPost` derefs status URLs over AP when GTS `/search` can't resolve them. The `read:search` scope is needed for status-URL resolution. Outbox-fetched posts are warmed into GTS in background via `GET /api/v2/search?resolve=true&type=statuses&limit=1` per post, throttled at 1 req/s per remote host via per-host token bucket (`internal/activitypub/throttle.go`) with cross-host concurrency, no follows.
- Coverage ranges for analytics are interval-merged; disjoint gaps must remain uncovered (see `coverage_test.go`).

View file

@ -1,13 +0,0 @@
# naco-api
Go MCP server (13 tools) for Bluesky / Nostr / ActivityPub / Twitter, gated by x402 crypto payments.
See `AGENTS.md` for architecture, commands, and development conventions.
## Payment deadline note (TO REMEMBER)
`MaxTimeoutSeconds` in `internal/server/settlement.go` (`buildRequirements`) is set to **600s** — not 60s.
Why: for the `upto` scheme the client signs the Permit2 permit with `deadline = now + MaxTimeoutSeconds`, and the MCP wrapper **settles only after the tool handler has executed**. A slow handler (e.g. `getStyleProfile` which fetches posts and runs style analysis) plus round-trip latency therefore sits inside the permit's validity window. With 60s the permit expired before settlement and the facilitator rejected with `400 invalid_exact_evm_permit2_payload_deadline`.
If a new payable tool is added with a handler slower than the current deadline window, bump this value along with the HTTP/test timeouts rather than shrinking it back down.

View file

@ -19,8 +19,9 @@ async def main():
my_services = [
item
for item in services.items
if "api.naco.li" in getattr(item, "resource", "")
if "api.naco.li" in getattr(item, "resource", "") or getattr(item, "type", "") != "http"
]
print(f'First service found: {getattr(services.items[0], "resource", "N/A")}')
if my_services:
print("Matching services:")

View file

@ -1,138 +0,0 @@
package main
import (
"context"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/nacorid/naco-api/internal/server"
)
func main() {
facilitatorURL := os.Getenv("X402_FACILITATOR_URL")
if facilitatorURL == "" {
facilitatorURL = "https://api.cdp.coinbase.com/platform/v2/x402"
}
isMogami := strings.Contains(facilitatorURL, "mogami.tech")
isPayAI := strings.Contains(facilitatorURL, "payai.network")
isCDP := strings.Contains(facilitatorURL, "cdp.coinbase.com")
var req *http.Request
var err error
if isMogami || isPayAI {
// Mogami and PayAI facilitators don't require auth
req, err = http.NewRequestWithContext(context.Background(), "GET", facilitatorURL+"/supported", nil)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to create request: %v\n", err)
os.Exit(1)
}
if isPayAI {
fmt.Println("Using PayAI facilitator (no auth)")
} else {
fmt.Println("Using Mogami facilitator (no auth)")
}
} else if isCDP {
keyName := os.Getenv("CDP_API_KEY_ID")
privateKeyPEM := os.Getenv("CDP_API_SECRET")
if keyName == "" || privateKeyPEM == "" {
fmt.Fprintf(os.Stderr, "CDP_API_KEY_ID and CDP_API_SECRET must be set for CDP facilitator\n")
os.Exit(1)
}
// Debug: print key info
fmt.Fprintf(os.Stderr, "DEBUG: keyName=%s, secretLen=%d\n", keyName, len(privateKeyPEM))
fmt.Fprintf(os.Stderr, "DEBUG: secretStart=%s\n", privateKeyPEM[:min(80, len(privateKeyPEM))])
fmt.Fprintf(os.Stderr, "DEBUG: hasLiteralNewline=%v, hasActualNewline=%v\n",
strings.Contains(privateKeyPEM, "\\n"),
strings.Contains(privateKeyPEM, "\n"))
// Test key parsing manually first
processedPEM := strings.ReplaceAll(privateKeyPEM, "\\n", "\n")
fmt.Fprintf(os.Stderr, "DEBUG: after replaceAll, startsWith=%s\n", processedPEM[:min(50, len(processedPEM))])
block, _ := pem.Decode([]byte(processedPEM))
if block != nil {
fmt.Fprintf(os.Stderr, "DEBUG: pem block type=%s, bytes=%d\n", block.Type, len(block.Bytes))
if strings.Contains(block.Type, "EC PRIVATE KEY") {
if _, err := x509.ParseECPrivateKey(block.Bytes); err == nil {
fmt.Fprintf(os.Stderr, "DEBUG: parsed as SEC1 EC key\n")
} else if parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
fmt.Fprintf(os.Stderr, "DEBUG: parsed as PKCS8 key, type=%T\n", parsed)
}
}
}
auth, err := server.NewCDPAuth(keyName, privateKeyPEM)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to create CDP auth: %v\n", err)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "DEBUG: auth created, signingMethod=%v\n", auth.GetSigningMethod())
headers, err := auth.GetAuthHeaders(context.Background())
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to get auth headers: %v\n", err)
os.Exit(1)
}
// Debug: print generated tokens
for name, header := range map[string]string{
"Verify": headers.Verify["Authorization"],
"Settle": headers.Settle["Authorization"],
"Supported": headers.Supported["Authorization"],
} {
if len(header) > 7 {
token := header[7:] // strip "Bearer "
if parts := strings.Split(token, "."); len(parts) == 3 {
if payload, err := base64.RawURLEncoding.DecodeString(parts[1]); err == nil {
fmt.Fprintf(os.Stderr, "DEBUG: %s token claims: %s\n", name, string(payload))
}
}
}
}
req, err = http.NewRequestWithContext(context.Background(), "GET", facilitatorURL+"/supported", nil)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to create request: %v\n", err)
os.Exit(1)
}
for k, v := range headers.Supported {
req.Header.Set(k, v)
}
fmt.Println("Using CDP facilitator (with auth)")
} else {
fmt.Fprintf(os.Stderr, "Unknown facilitator URL: %s\n", facilitatorURL)
os.Exit(1)
}
httpClient := &http.Client{
Timeout: 30 * time.Second,
}
resp, err := httpClient.Do(req)
if err != nil {
fmt.Fprintf(os.Stderr, "Request failed: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
maxBytes := int64(5 * 1024 * 1024)
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes))
if err != nil {
fmt.Printf("failed reading response body: %v", err)
}
fmt.Printf("Status: %d\n", resp.StatusCode)
fmt.Printf("Response: %s\n", body)
}

View file

@ -3,75 +3,41 @@ package main
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/signal"
"syscall"
"time"
_ "github.com/lib/pq"
log "github.com/nacorid/logger"
"github.com/nacorid/naco-api/internal/activitypub"
"github.com/nacorid/naco-api/internal/bluesky"
"github.com/nacorid/naco-api/internal/config"
"github.com/nacorid/naco-api/internal/nostr"
"github.com/nacorid/naco-api/internal/ollama"
"github.com/nacorid/naco-api/internal/server"
"github.com/nacorid/naco-api/internal/store"
"github.com/nacorid/naco-api/internal/twitter"
"golang.org/x/sync/errgroup"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// backgroundCtx is for long-lived clients whose lifetime is the process,
// not a single request. It must not inherit signal cancellation so that a
// SIGTERM during startup does not poison an in-flight session creation.
backgroundCtx := context.Background()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cfg := config.Load()
if err := log.Init(log.Config{
log.Init(log.Config{
LogFilePath: cfg.LogFile,
ConsoleLevel: log.LevelDebug,
FileLevel: log.LevelTrace,
}); err != nil {
log.Fatalf("Failed to init logger: %v", err)
}
defer log.Close()
})
conn, err := sql.Open("postgres", cfg.DatabaseURL)
if err != nil {
log.Fatalf("Failed to initialize database connection: %v", err)
log.Fatalf("Failed to connect to database: %v", err)
}
defer conn.Close()
conn.SetMaxOpenConns(10)
conn.SetMaxIdleConns(10)
conn.SetConnMaxLifetime(5 * time.Minute)
dbCtx, dbCancel := context.WithTimeout(ctx, 5*time.Second)
err = conn.PingContext(dbCtx)
dbCancel()
if err != nil {
log.Fatalf("Failed to ping database: %v", err)
}
db := store.New(conn)
if cfg.MigrateOnStart {
if err := store.Migrate(ctx, conn, cfg.MigrationsDir); err != nil {
log.Fatalf("Failed to apply migrations: %v", err)
}
log.Info("Database migrations applied", "dir", cfg.MigrationsDir)
}
bskyClient, err := bluesky.NewBlueskyClient(backgroundCtx, cfg.BlueskyDID, cfg.BlueskyAppPassword, cfg.BlueskyHost)
bskyClient, err := bluesky.NewBlueskyClient(ctx, cfg.BlueskyDID, cfg.BlueskyAppPassword, cfg.BlueskyHost)
if err != nil {
log.Fatal("Failed to create bluesky client", "error", err)
}
@ -80,107 +46,25 @@ func main() {
if err != nil {
log.Fatal("Invalid OLLAMA_HOST URL", "error", err)
}
ollamaHTTPClient := &http.Client{
Timeout: 600 * time.Second,
}
ollamaClient := ollama.New(uri, ollamaHTTPClient, cfg.EmbeddingModel, cfg.StyleModel)
ollamaClient := ollama.New(ctx, uri, http.DefaultClient)
apClient, err := activitypub.NewAPClient(cfg.ActivityPubHost, cfg.ActivityPubToken)
apClient, err := activitypub.NewAPClient(ctx, cfg.ActivityPubHost, cfg.ActivityPubToken)
if err != nil {
log.Fatal("Failed to create activitypub client", "error", err)
}
nostrClient, err := nostr.NewNostrClient(cfg.NostrRelays)
if err != nil {
log.Fatal("Failed to create nostr client", "error", err)
}
srv := server.New(ctx, db, apClient, bskyClient, ollamaClient, cfg)
twitterClient := twitter.NewTwitterClient(cfg.TwitterBearerToken)
srv := server.New(ctx, db, apClient, bskyClient, nostrClient, twitterClient, ollamaClient, cfg)
srv.StartPrewarm(ctx)
g, gCtx := errgroup.WithContext(ctx)
startReadyzServer(g, ctx, cfg, conn)
g.Go(func() error {
go func() {
log.Info("Server starting", "address", cfg.ListenAddress)
if err := srv.Start(); err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("main server error: %w", err)
if err := srv.Start(); err != http.ErrServerClosed {
log.Fatal("HTTP server error", "error", err)
}
return nil
})
g.Go(func() error {
<-gCtx.Done()
log.Info("Shutting down main server...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
})
if err := g.Wait(); err != nil && !errors.Is(err, context.Canceled) {
log.Error("Application terminated with error", "error", err)
}
log.Info("Application shutdown complete")
}
func startReadyzServer(g *errgroup.Group, shutdownCtx context.Context, cfg *config.Config, conn *sql.DB) {
mux := http.NewServeMux()
client := &http.Client{
Timeout: 10 * time.Second,
}
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
if err := conn.PingContext(r.Context()); err != nil {
http.Error(w, "database unavailable", http.StatusServiceUnavailable)
return
}
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, cfg.OllamaHost+"/api/tags", nil)
if err != nil {
http.Error(w, "invalid ollama configuration", http.StatusInternalServerError)
return
}
resp, err := client.Do(req)
if err != nil || resp.StatusCode != http.StatusOK {
if resp != nil {
resp.Body.Close()
}
http.Error(w, "ollama unavailable", http.StatusServiceUnavailable)
return
}
defer func() {
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}()
_, _ = w.Write([]byte("ok"))
})
stopChan := make(chan os.Signal, 1)
signal.Notify(stopChan, syscall.SIGINT, syscall.SIGTERM)
<-stopChan
readyServer := &http.Server{
Addr: cfg.ReadyzAddress + ":" + cfg.ReadyzPort,
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
}
g.Go(func() error {
log.Info("Readiness server starting", "address", readyServer.Addr)
if err := readyServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("readiness server failed: %w", err)
}
return nil
})
g.Go(func() error {
<-shutdownCtx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
return readyServer.Shutdown(shutdownCtx)
})
log.Info("Shutting down server...")
}

View file

@ -1,5 +0,0 @@
# Ollama model configuration
# Single source of truth for model names used by the application and CI
EMBEDDING_MODEL=qwen3-embedding:0.6b
STYLE_MODEL=qwen3:4b-instruct-2507-q4_K_M

View file

@ -1,26 +0,0 @@
version: '3.8'
services:
postgres:
image: pgvector/pgvector:pg16
environment:
POSTGRES_DB: mcpserver
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d mcpserver"]
interval: 5s
timeout: 5s
retries: 10
ollama:
image: ollama/ollama:latest
ports:
- "11434:11434"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:11434/api/tags"]
interval: 10s
timeout: 5s
retries: 10

22
go.mod
View file

@ -3,14 +3,13 @@ module github.com/nacorid/naco-api
go 1.25.4
require (
fiatjaf.com/nostr v0.0.0-20260827104008-28e324b57f7e
github.com/bluesky-social/indigo v0.0.0-20251009224519-09f107c1109e
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/jsonschema-go v0.4.3
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
github.com/modelcontextprotocol/go-sdk v1.6.1
github.com/nacorid/logger v0.0.0-20260907215822-b3c536ed7b37
github.com/nacorid/logger v0.0.0-20251221013547-5fc8fd94ed80
github.com/ollama/ollama v0.12.10
github.com/x402-foundation/x402/go/v2 v2.19.0
golang.org/x/time v0.14.0
@ -28,21 +27,15 @@ require (
github.com/klauspost/compress v1.18.1 // indirect
github.com/supranational/blst v0.3.16 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/sync v0.19.0
golang.org/x/sync v0.19.0 // indirect
)
require (
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect
github.com/StackExchange/wmi v1.2.1 // indirect
github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect
github.com/btcsuite/btcd/btcutil v1.1.5 // indirect
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
github.com/carlmjohnson/versioninfo v0.22.5 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/coder/websocket v1.8.13 // indirect
github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
@ -66,14 +59,9 @@ require (
github.com/ipfs/go-log/v2 v2.5.1 // indirect
github.com/ipfs/go-metrics-interface v0.0.1 // indirect
github.com/jbenet/goprocess v0.1.4 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/minio/sha256-simd v1.0.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect
github.com/multiformats/go-base32 v0.1.0 // indirect
github.com/multiformats/go-base36 v0.2.0 // indirect
@ -82,17 +70,11 @@ require (
github.com/multiformats/go-varint v0.0.7 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect
github.com/sokkalf/slog-seq v0.5.1 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/templexxx/cpu v0.0.1 // indirect
github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect

123
go.sum
View file

@ -1,10 +1,6 @@
fiatjaf.com/nostr v0.0.0-20260827104008-28e324b57f7e h1:BuWR1BP/CBzM+ZbLoUKuV6KlktlDZEyD9N/pdxqJJyE=
fiatjaf.com/nostr v0.0.0-20260827104008-28e324b57f7e/go.mod h1:b1EIUDnd133Ie8Pg8O/biaKdFyCMz28aD4n64g1GqvM=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 h1:ClzzXMDDuUbWfNNZqGeYq4PnYOlwlOVIvSyNaIy0ykg=
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3/go.mod h1:we0YA5CsBbH5+/NUzC/AlMmxaDtWlXeNsqrwXjTzmzA=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU=
@ -13,7 +9,6 @@ github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDO
github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8=
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
@ -21,30 +16,6 @@ github.com/bits-and-blooms/bitset v1.24.2 h1:M7/NzVbsytmtfHbumG+K2bremQPMJuqv1JD
github.com/bits-and-blooms/bitset v1.24.2/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bluesky-social/indigo v0.0.0-20251009224519-09f107c1109e h1:6oNYIMh+kbArCQiwZYPGgfxQq88tnMyiBIZtg5clq90=
github.com/bluesky-social/indigo v0.0.0-20251009224519-09f107c1109e/go.mod h1:RuQVrCGm42QNsgumKaR6se+XkFKfCPNwdCiTvqKRUck=
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A=
github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA=
github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE=
github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ=
github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04=
github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A=
github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE=
github.com/btcsuite/btcd/btcutil v1.1.5 h1:+wER79R5670vs/ZusMTF1yTcRYE5GUsFbdjdisflzM8=
github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ=
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY=
github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I=
github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc=
github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@ -61,8 +32,6 @@ github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwP
github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo=
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ=
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/consensys/gnark-crypto v0.19.2 h1:qrEAIXq3T4egxqiliFFoNrepkIWVEeIYwt3UL0fvS80=
github.com/consensys/gnark-crypto v0.19.2/go.mod h1:rT23F0XSZqE0mUA0+pRtnL56IbPxs6gp4CeRsBk4XS0=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
@ -70,7 +39,6 @@ github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/crate-crypto/go-eth-kzg v1.5.0 h1:FYRiJMJG2iv+2Dy3fi14SVGjcPteZ5HAAUe4YWlJygc=
github.com/crate-crypto/go-eth-kzg v1.5.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI=
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -78,14 +46,10 @@ github.com/dchest/siphash v1.2.3 h1:QXwFc8cFOR2dSa/gE6o/HokBMWtLUaNDVd+22aKHeEA=
github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc=
github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ=
github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
github.com/dvyukov/go-fuzz v0.0.0-20200318091601-be3528f3a813/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw=
github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A=
github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s=
github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn27fRjSls=
@ -98,8 +62,6 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY=
github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps=
github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@ -120,22 +82,10 @@ github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXe
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
@ -145,7 +95,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb1T8ac=
@ -168,7 +117,6 @@ github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZ
github.com/holiman/bloomfilter/v2 v2.0.3/go.mod h1:zpoh+gs7qcpqrHr3dB55AMiJwo0iURXE7ZOP9L9hSkA=
github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA=
github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
@ -203,20 +151,12 @@ github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+
github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5Djy1+FlA=
github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o=
github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4=
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
@ -234,8 +174,6 @@ github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzW
github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
@ -252,11 +190,6 @@ github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjU
github.com/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4=
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE=
@ -269,19 +202,10 @@ github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7B
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU=
github.com/nacorid/logger v0.0.0-20260907215822-b3c536ed7b37 h1:o7CvgRLUikfsbyuZIJTTrfRac5rMXhca0b61VLBWSIA=
github.com/nacorid/logger v0.0.0-20260907215822-b3c536ed7b37/go.mod h1:hd9h4KEPU1f/Vv2qP0x/fZUwz2clFvOV/uIF0pkGo+s=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nacorid/logger v0.0.0-20251221013547-5fc8fd94ed80 h1:nZwGInvJNQzPSFYpstVR/TLumqBEZt0+yUXchdyBZiI=
github.com/nacorid/logger v0.0.0-20251221013547-5fc8fd94ed80/go.mod h1:hd9h4KEPU1f/Vv2qP0x/fZUwz2clFvOV/uIF0pkGo+s=
github.com/ollama/ollama v0.12.10 h1:Dd0/SeCc+nv+FffxmWuQTGiRreib7Gt3nBhIIFuKwZA=
github.com/ollama/ollama v0.12.10/go.mod h1:RUSmYywUWx/YZMaHrqtnT1ZChu+iSz/7jx2aO9+Mgfg=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8=
@ -309,13 +233,11 @@ github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lne
github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY=
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik=
github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@ -338,7 +260,6 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
@ -346,17 +267,6 @@ github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jq
github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw=
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY=
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
github.com/templexxx/cpu v0.0.1 h1:hY4WdLOgKdc8y13EYklu9OUTXik80BkxHoWvTO6MQQY=
github.com/templexxx/cpu v0.0.1/go.mod h1:w7Tb+7qgcAlIyX4NhLuDKt78AHA5SzPmq0Wj6HiEnnk=
github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b h1:XeDLE6c9mzHpdv3Wb1+pWBaWv/BlHK0ZYIu/KaL6eHg=
github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b/go.mod h1:7rwmCH0wC2fQvNEvPZ3sKXukhyCTyiaZ5VTZMQYpZKQ=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
@ -414,7 +324,6 @@ go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ=
go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
@ -428,37 +337,25 @@ golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKG
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -474,7 +371,6 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
@ -498,12 +394,6 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@ -511,15 +401,10 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -6,453 +6,149 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/nacorid/naco-api/internal/actor"
)
const maxMastodonPageSize = 40
const maxTimeframePages = 100
const maxResponseBody = 1 << 20
const maxLimit = 200
type APClient struct {
client *http.Client
host string
bearerToken string
outbox *outboxClient
limiter *hostLimiter
}
func NewAPClient(host, apikey string) (*APClient, error) {
func NewAPClient(ctx context.Context, host, apikey string) (*APClient, error) {
if host == "" || apikey == "" {
return nil, fmt.Errorf("host and apikey must not be empty")
}
return &APClient{
client: &http.Client{Timeout: 30 * time.Second},
host: strings.TrimRight(host, "/"),
bearerToken: "Bearer " + apikey,
outbox: newOutboxClient(),
limiter: newHostLimiter(),
client: http.DefaultClient,
host: host,
bearerToken: fmt.Sprintf("Bearer %s", apikey),
}, nil
}
func (ap *APClient) get(ctx context.Context, endpoint string, target any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
func (ap *APClient) GetPost(ctx context.Context, postID string) (*Post, error) {
url := fmt.Sprintf("%s/api/v1/statuses/%s", ap.host, postID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", ap.bearerToken)
resp, err := ap.client.Do(req)
if err != nil {
return 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 err
return nil, fmt.Errorf("failed to get posts: %w", err)
}
defer resp.Body.Close()
// !!IMPORTANT!! Manually close body instead of deferring, due to loop
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("api error (status %d): %s", resp.StatusCode, string(errBody))
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("failed to get posts: %s", resp.Status)
}
if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBody)).Decode(target); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
return nil
}
func (ap *APClient) accountSearch(ctx context.Context, query string) (*Account, error) {
endpoint := fmt.Sprintf("%s/api/v1/accounts/search?q=%s&resolve=true&limit=1", ap.host, url.QueryEscape(query))
var accounts []*Account
if err := ap.get(ctx, endpoint, &accounts); err != nil {
return nil, fmt.Errorf("failed to search accounts: %w", err)
}
for _, acc := range accounts {
if acc != nil {
return acc, nil
}
}
return nil, nil
}
func (ap *APClient) resolveAccount(ctx context.Context, actorHandle string) (*Account, error) {
handle := strings.TrimPrefix(actorHandle, "@")
acc, err := ap.accountSearch(ctx, handle)
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return nil, err
}
if acc != nil {
return acc, nil
return nil, fmt.Errorf("failed to read response body: %w", err)
}
if !strings.Contains(handle, "@") {
var byID Account
endpoint := fmt.Sprintf("%s/api/v1/accounts/%s", ap.host, url.PathEscape(handle))
if err := ap.get(ctx, endpoint, &byID); err != nil {
return nil, fmt.Errorf("account %q not found by search or id: %w", actorHandle, err)
}
if byID.ID == "" {
return nil, fmt.Errorf("account %q not found", actorHandle)
}
return &byID, nil
}
return nil, fmt.Errorf("account %q not found", actorHandle)
}
func (ap *APClient) GetPost(ctx context.Context, postID string) (*Post, error) {
status, err := ap.resolveStatus(ctx, postID)
if err != nil {
return nil, err
}
return status.toPost(), nil
}
func (ap *APClient) resolveStatus(ctx context.Context, postID string) (*internalPost, error) {
if strings.HasPrefix(postID, "http://") || strings.HasPrefix(postID, "https://") || strings.HasPrefix(postID, "//") {
if status, err := ap.searchStatus(ctx, postID); err == nil && status != nil {
return status, nil
}
note, derr := ap.outbox.resolveNote(ctx, postID)
if derr != nil {
return nil, fmt.Errorf("status %q not resolvable: %w", postID, derr)
}
author := extractHandleFromURL(postID)
return noteToInternalPost(note, author), nil
var internal []*internalPost
if err := json.Unmarshal(body, &internal); err != nil {
return nil, fmt.Errorf("failed to unmarshal response body: %w", err)
}
endpoint := fmt.Sprintf("%s/api/v1/statuses/%s", ap.host, url.PathEscape(postID))
var status internalPost
if err := ap.get(ctx, endpoint, &status); err != nil {
return nil, fmt.Errorf("failed to get post: %w", err)
}
return &status, nil
}
func (ap *APClient) searchStatus(ctx context.Context, postID string) (*internalPost, error) {
endpoint := fmt.Sprintf("%s/api/v2/search?q=%s&resolve=true&type=statuses&limit=1", ap.host, url.QueryEscape(postID))
var res searchResult
if err := ap.get(ctx, endpoint, &res); err != nil {
return nil, fmt.Errorf("failed to resolve status %q: %w", postID, err)
}
for _, s := range res.Statuses {
if s != nil && (s.URL == postID || s.URI == postID) {
return s, nil
}
}
return nil, nil
}
func (ap *APClient) GetLatestNPosts(ctx context.Context, actorID string, limit int) ([]*Post, error) {
if limit <= 0 {
return nil, nil
}
if limit > maxLimit {
limit = maxLimit
}
acc, err := ap.resolveAccount(ctx, actorID)
if err != nil {
return nil, err
}
cached, cacheErr := ap.cacheLatestPosts(ctx, acc, limit)
if cacheErr != nil {
return nil, cacheErr
}
if len(cached) >= limit {
return cachePostsToPosts(cached), nil
}
if !isRemoteAccount(acc) || acc.URL == "" {
return cachePostsToPosts(cached), nil
}
outboxPosts, apErr := ap.outbox.getLatest(ctx, acc.URL, acc.Acct, limit-len(cached))
if apErr != nil {
return nil, fmt.Errorf("outbox fallback failed: %w", apErr)
}
merged := mergePostsBySource(cached, outboxPosts)
if len(outboxPosts) > 0 {
urls := make([]string, 0, len(outboxPosts))
for _, p := range outboxPosts {
if p != nil && p.ID != "" {
urls = append(urls, p.ID)
}
}
go ap.warmGTSAsync(context.WithoutCancel(ctx), urls)
}
return merged, nil
}
func (ap *APClient) cacheLatestPosts(ctx context.Context, acc *Account, limit int) ([]*internalPost, error) {
var (
internal []*internalPost
maxID string
)
for len(internal) < limit {
if ctx.Err() != nil {
return nil, ctx.Err()
}
pageSize := limit - len(internal)
if pageSize > maxMastodonPageSize {
pageSize = maxMastodonPageSize
}
endpoint := fmt.Sprintf("%s/api/v1/accounts/%s/statuses?limit=%d", ap.host, url.PathEscape(acc.ID), pageSize)
if maxID != "" {
endpoint += "&max_id=" + url.QueryEscape(maxID)
}
var page []*internalPost
if err := ap.get(ctx, endpoint, &page); err != nil {
return nil, err
}
if len(page) == 0 {
if len(internal) == 0 {
break
}
newMaxID := page[len(page)-1].ID
if newMaxID == "" {
break
}
if maxID != "" && newMaxID == maxID {
break
}
internal = append(internal, page...)
maxID = newMaxID
}
if len(internal) > limit {
internal = internal[:limit]
}
return internal, nil
}
func (ap *APClient) GetPostsInTimeframe(ctx context.Context, actorID string, start, end time.Time, limit int) ([]*Post, error) {
if limit <= 0 {
return nil, nil
}
if limit > maxLimit {
limit = maxLimit
}
acc, err := ap.resolveAccount(ctx, actorID)
if err != nil {
return nil, err
}
cached, cacheErr := ap.cachePostsInTimeframe(ctx, acc, start, end, limit)
if cacheErr != nil {
return nil, cacheErr
}
if len(cached) >= limit {
return cachePostsToPosts(cached), nil
}
if !isRemoteAccount(acc) || acc.URL == "" {
return cachePostsToPosts(cached), nil
}
outboxPosts, apErr := ap.outbox.getInTimeframe(ctx, acc.URL, acc.Acct, start, end, limit-len(cached))
if apErr != nil {
return nil, fmt.Errorf("outbox fallback failed: %w", apErr)
}
merged := mergePostsBySource(cached, outboxPosts)
if len(outboxPosts) > 0 {
urls := make([]string, 0, len(outboxPosts))
for _, p := range outboxPosts {
if p != nil && p.ID != "" {
urls = append(urls, p.ID)
}
}
go ap.warmGTSAsync(context.WithoutCancel(ctx), urls)
}
return merged, nil
}
func (ap *APClient) cachePostsInTimeframe(ctx context.Context, acc *Account, start, end time.Time, limit int) ([]*internalPost, error) {
var (
internal []*internalPost
maxID string
)
for pages := 0; pages < maxTimeframePages; pages++ {
if ctx.Err() != nil {
return nil, ctx.Err()
}
if len(internal) >= limit {
break
}
endpoint := fmt.Sprintf("%s/api/v1/accounts/%s/statuses?limit=%d", ap.host, url.PathEscape(acc.ID), maxMastodonPageSize)
if maxID != "" {
endpoint += "&max_id=" + url.QueryEscape(maxID)
}
var page []*internalPost
if err := ap.get(ctx, endpoint, &page); err != nil {
return nil, err
}
if len(page) == 0 {
break
}
newMaxID := page[len(page)-1].ID
if newMaxID == "" {
break
}
if maxID != "" && newMaxID == maxID {
break
}
for _, i := range page {
for _, i := range internal {
if i.CreatedAt.After(end) {
continue
}
if i.CreatedAt.Before(start) {
return internal, nil
}
internal = append(internal, i)
if len(internal) >= limit {
return internal, nil
}
}
maxID = newMaxID
return posts, nil
}
return internal, nil
}
func isRemoteAccount(acc *Account) bool {
return acc != nil && strings.Contains(acc.Acct, "@")
}
func cachePostsToPosts(cached []*internalPost) []*Post {
if len(cached) == 0 {
return nil
}
posts := make([]*Post, 0, len(cached))
for _, c := range cached {
if c == nil {
continue
}
posts = append(posts, c.toPost())
}
return posts
}
func (ap *APClient) GetActor(ctx context.Context, actorHandle string) (*actor.Profile, error) {
acc, err := ap.resolveAccount(ctx, actorHandle)
if err != nil {
return nil, err
}
return ap.toProfile(acc), nil
}
func (ap *APClient) SearchActors(ctx context.Context, query string, limit int) ([]*actor.Profile, error) {
endpoint := fmt.Sprintf("%s/api/v1/accounts/search?q=%s&resolve=true&limit=%d", ap.host, url.QueryEscape(query), limit)
var accounts []*Account
if err := ap.get(ctx, endpoint, &accounts); err != nil {
return nil, fmt.Errorf("failed to search actors: %w", err)
}
results := make([]*actor.Profile, 0, len(accounts))
for _, acc := range accounts {
if acc == nil {
continue
}
results = append(results, ap.toProfile(acc))
}
return results, nil
}
func (ap *APClient) toProfile(acc *Account) *actor.Profile {
return &actor.Profile{
Platform: "ActivityPub",
Handle: acc.Acct,
DisplayName: acc.DisplayName,
AvatarURL: acc.Avatar,
Bio: acc.Note,
FollowersCount: acc.FollowersCount,
FollowingCount: acc.FollowingCount,
PostCount: acc.StatusesCount,
URL: acc.URL,
}
}
func extractHandleFromURL(postURL string) string {
u, err := url.Parse(postURL)
if err != nil {
return ""
}
parts := strings.Split(strings.TrimPrefix(u.Path, "/"), "/")
for _, p := range parts {
if strings.HasPrefix(p, "@") {
return strings.TrimPrefix(p, "@")
}
}
return ""
}
func (ap *APClient) warmGTSAsync(ctx context.Context, urls []string) {
if len(urls) == 0 {
return
}
if ap.limiter == nil {
ap.limiter = newHostLimiter()
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
seen := make(map[string]struct{}, len(urls))
uniq := make([]string, 0, len(urls))
for _, u := range urls {
if _, ok := seen[u]; ok {
continue
}
seen[u] = struct{}{}
uniq = append(uniq, u)
}
if len(uniq) > 50 {
uniq = uniq[:50]
}
byHost := make(map[string][]string)
for _, u := range uniq {
parsed, err := url.Parse(u)
if err != nil || parsed.Host == "" {
continue
}
byHost[parsed.Host] = append(byHost[parsed.Host], u)
}
if len(byHost) == 0 {
return
}
var wg sync.WaitGroup
for host, us := range byHost {
wg.Add(1)
go func(host string, us []string) {
defer wg.Done()
for _, u := range us {
if err := ap.limiter.Wait(ctx, host); err != nil {
return
}
if ctx.Err() != nil {
return
}
var res searchResult
_ = ap.get(ctx, fmt.Sprintf("%s/api/v2/search?q=%s&resolve=true&type=statuses&limit=1", ap.host, url.QueryEscape(u)), &res)
}
}(host, us)
}
wg.Wait()
posts = append(posts, *i.toPost())
if len(posts) >= limit {
return posts, nil
}
}
maxID = internal[len(internal)-1].ID
}
return posts, nil
}

View file

@ -1,597 +0,0 @@
package activitypub
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func newTestClient(serverURL string) *APClient {
ap, err := NewAPClient("https://example.com", "tok")
if err != nil {
panic(err)
}
host := strings.TrimPrefix(serverURL, "http://")
ap.client = &http.Client{
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
u := *r.URL
u.Scheme = "http"
u.Host = host
r2 := r.Clone(r.Context())
r2.URL = &u
return http.DefaultTransport.RoundTrip(r2)
}),
}
return ap
}
func TestNewAPClientEmpty(t *testing.T) {
if _, err := NewAPClient("", "tok"); err == nil {
t.Fatal("expected error for empty host")
}
if _, err := NewAPClient("host", ""); err == nil {
t.Fatal("expected error for empty apikey")
}
}
func TestGetLatestNPosts(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, `[{"id":"1","acct":"alice"}]`)
})
mux.HandleFunc("/api/v1/accounts/1/statuses", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer tok" {
w.WriteHeader(http.StatusUnauthorized)
return
}
if r.URL.Query().Get("max_id") != "" {
_, _ = fmt.Fprint(w, `[]`)
return
}
_, _ = fmt.Fprint(w, `[{"id":"1","content":"hello world","text":"hello world","created_at":"2024-01-01T00:00:00Z","account":{"acct":"alice"}}]`)
})
server := httptest.NewServer(mux)
defer server.Close()
ap := newTestClient(server.URL)
posts, err := ap.GetLatestNPosts(context.Background(), "alice", 10)
if err != nil {
t.Fatalf("GetLatestNPosts error: %v", err)
}
if len(posts) != 1 {
t.Fatalf("expected 1 post, got %d", len(posts))
}
if posts[0].Content != "hello world" || posts[0].Author != "alice" {
t.Fatalf("unexpected post: %+v", posts[0])
}
}
func TestGetLatestNPostsPaginates(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, `[{"id":"1","acct":"alice"}]`)
})
mux.HandleFunc("/api/v1/accounts/1/statuses", func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Query().Get("max_id") {
case "40":
ids := make([]map[string]any, 0, 10)
for id := 41; id <= 50; id++ {
ids = append(ids, map[string]any{
"id": fmt.Sprintf("%d", id), "content": fmt.Sprintf("post %d", id), "created_at": "2024-01-01T00:00:00Z",
"account": map[string]any{"acct": "alice"},
})
}
_ = json.NewEncoder(w).Encode(ids)
case "":
ids := make([]map[string]any, 0, 40)
for id := 1; id <= 40; id++ {
ids = append(ids, map[string]any{
"id": fmt.Sprintf("%d", id), "content": fmt.Sprintf("post %d", id), "created_at": "2024-01-01T00:00:00Z",
"account": map[string]any{"acct": "alice"},
})
}
_ = json.NewEncoder(w).Encode(ids)
default:
_ = json.NewEncoder(w).Encode([]map[string]any{})
}
})
server := httptest.NewServer(mux)
defer server.Close()
ap := newTestClient(server.URL)
posts, err := ap.GetLatestNPosts(context.Background(), "alice", 50)
if err != nil {
t.Fatalf("GetLatestNPosts error: %v", err)
}
if len(posts) != 50 {
t.Fatalf("expected 50 posts across 2 pages, got %d", len(posts))
}
if posts[49].Content != "post 50" {
t.Fatalf("expected last post 'post 50', got %q", posts[49].Content)
}
}
func TestGetLatestNPostsZeroLimit(t *testing.T) {
ap := newTestClient("http://example.com")
posts, err := ap.GetLatestNPosts(context.Background(), "alice", 0)
if err != nil {
t.Fatalf("GetLatestNPosts with zero limit error: %v", err)
}
if len(posts) != 0 {
t.Fatalf("expected no posts for zero limit, got %d", len(posts))
}
}
func TestGetLatestNPostsLimitClamp(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, `[{"id":"1","acct":"alice"}]`)
})
mux.HandleFunc("/api/v1/accounts/1/statuses", func(w http.ResponseWriter, r *http.Request) {
limit := r.URL.Query().Get("limit")
l, _ := strconv.Atoi(limit)
if l > maxLimit {
t.Fatalf("expected limit <= %d, got %s", maxLimit, limit)
}
_, _ = fmt.Fprint(w, `[{"id":"1","content":"hello","created_at":"2024-01-01T00:00:00Z","account":{"acct":"alice"}}]`)
})
server := httptest.NewServer(mux)
defer server.Close()
ap := newTestClient(server.URL)
posts, err := ap.GetLatestNPosts(context.Background(), "alice", 500)
if err != nil {
t.Fatalf("GetLatestNPosts error: %v", err)
}
if len(posts) == 0 {
t.Fatal("expected at least one post")
}
}
func TestGetPost(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/statuses/42", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, `{"id":"42","content":"a post","text":"a post","created_at":"2024-01-01T00:00:00Z","account":{"acct":"bob"}}`)
})
server := httptest.NewServer(mux)
defer server.Close()
ap := newTestClient(server.URL)
post, err := ap.GetPost(context.Background(), "42")
if err != nil {
t.Fatalf("GetPost error: %v", err)
}
if post.Content != "a post" || post.Author != "bob" {
t.Fatalf("unexpected post: %+v", post)
}
}
func TestGetPostURLResolves(t *testing.T) {
url := "https://inst.social/@alice/statuses/42"
mux := http.NewServeMux()
mux.HandleFunc("/api/v2/search", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("q") != url {
w.WriteHeader(http.StatusBadRequest)
return
}
if r.URL.Query().Get("type") != "statuses" || r.URL.Query().Get("resolve") != "true" {
w.WriteHeader(http.StatusBadRequest)
return
}
_, _ = fmt.Fprint(w, `{"accounts":[],"statuses":[{"id":"42","uri":"`+url+`","url":"`+url+`","content":"resolved post","created_at":"2024-01-01T00:00:00Z","account":{"acct":"bob@inst.social"}}]}`)
})
server := httptest.NewServer(mux)
defer server.Close()
ap := newTestClient(server.URL)
post, err := ap.GetPost(context.Background(), url)
if err != nil {
t.Fatalf("GetPost error: %v", err)
}
if post.Content != "resolved post" || post.Author != "bob@inst.social" {
t.Fatalf("unexpected post: %+v", post)
}
}
func TestGetPostURLNotFound(t *testing.T) {
derefTarget := httptest.NewServer(http.NotFoundHandler())
defer derefTarget.Close()
mux := http.NewServeMux()
mux.HandleFunc("/api/v2/search", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, `{"accounts":[],"statuses":[]}`)
})
gts := httptest.NewServer(mux)
defer gts.Close()
ap, err := NewAPClient(gts.URL, "tok")
if err != nil {
t.Fatal(err)
}
if _, err := ap.GetPost(context.Background(), derefTarget.URL+"/missed"); err == nil {
t.Fatal("expected error when search and dereference both return nothing")
}
}
func TestGetPostURLSearchReturnsWrongPost(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v2/search", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, `{"accounts":[],"statuses":[{"id":"wrong","uri":"https://other.com/1","content":"wrong post"}]}`)
})
gts := httptest.NewServer(mux)
defer gts.Close()
ap, err := NewAPClient(gts.URL, "tok")
if err != nil {
t.Fatal(err)
}
if _, err := ap.GetPost(context.Background(), "https://inst.social/@alice/statuses/42"); err == nil {
t.Fatal("expected error when search returns no matching post")
}
}
func TestGetPostURLDerefFallback(t *testing.T) {
remote, _ := newRemoteServer()
defer remote.Close()
mux := http.NewServeMux()
mux.HandleFunc("/api/v2/search", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
gts := httptest.NewServer(mux)
defer gts.Close()
ap, err := NewAPClient(gts.URL, "tok")
if err != nil {
t.Fatal(err)
}
post, err := ap.GetPost(context.Background(), remote.URL+"/notes/2")
if err != nil {
t.Fatalf("GetPost error: %v", err)
}
if post.Content != "deref note 2" {
t.Fatalf("expected dereferenced note, got %+v", post)
}
}
func TestGetLatestNPostsRemoteFallsBackToOutbox(t *testing.T) {
remote, remoteBase := newRemoteServer()
defer remote.Close()
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, `[{"id":"1","acct":"alice@example.social","url":%q}]`, remoteBase)
})
mux.HandleFunc("/api/v1/accounts/1/statuses", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("max_id") != "" {
_, _ = fmt.Fprint(w, `[]`)
return
}
_, _ = fmt.Fprint(w, `[{"id":"c1","content":"cached","uri":`+`"`+remoteBase+`/notes/9","created_at":"2024-03-10T00:00:00Z","account":{"acct":"alice@example.social"}}]`)
})
gts := httptest.NewServer(mux)
defer gts.Close()
ap, err := NewAPClient(gts.URL, "tok")
if err != nil {
t.Fatal(err)
}
posts, err := ap.GetLatestNPosts(context.Background(), "@alice@example.social", 5)
if err != nil {
t.Fatalf("GetLatestNPosts error: %v", err)
}
if len(posts) != 5 {
t.Fatalf("expected 5 merged posts, got %d: %+v", len(posts), posts)
}
if posts[0].Content != "cached" {
t.Fatalf("expected newest cached first, got %q", posts[0].Content)
}
if posts[4].Content != "note 4" {
t.Fatalf("expected oldest outbox post last, got %q", posts[4].Content)
}
}
func TestGetLatestNPostsRemoteEmptyCacheFallsBack(t *testing.T) {
remote, remoteBase := newRemoteServer()
defer remote.Close()
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, `[{"id":"1","acct":"alice@example.social","url":%q}]`, remoteBase)
})
mux.HandleFunc("/api/v1/accounts/1/statuses", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("max_id") != "" {
_, _ = fmt.Fprint(w, `[]`)
return
}
_, _ = fmt.Fprint(w, `[]`)
})
gts := httptest.NewServer(mux)
defer gts.Close()
ap, err := NewAPClient(gts.URL, "tok")
if err != nil {
t.Fatal(err)
}
posts, err := ap.GetLatestNPosts(context.Background(), "@alice@example.social", 4)
if err != nil {
t.Fatalf("GetLatestNPosts error: %v", err)
}
if len(posts) != 4 {
t.Fatalf("expected all 4 posts from outbox, got %d", len(posts))
}
if posts[0].Content != "bare note 3" {
t.Fatalf("expected outbox post newest-first, got %q", posts[0].Content)
}
}
func TestGetPostsInTimeframeRemoteFallsBackToOutbox(t *testing.T) {
remote, remoteBase := newRemoteServer()
defer remote.Close()
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, `[{"id":"1","acct":"alice@example.social","url":%q}]`, remoteBase)
})
mux.HandleFunc("/api/v1/accounts/1/statuses", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("max_id") != "" {
_, _ = fmt.Fprint(w, `[]`)
return
}
_, _ = fmt.Fprint(w, `[{"id":"c1","content":"cached in window","uri":`+`"`+remoteBase+`/notes/5","created_at":"2024-02-20T00:00:00Z","account":{"acct":"alice@example.social"}}]`)
})
gts := httptest.NewServer(mux)
defer gts.Close()
ap, err := NewAPClient(gts.URL, "tok")
if err != nil {
t.Fatal(err)
}
start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2024, 3, 2, 0, 0, 0, 0, time.UTC)
posts, err := ap.GetPostsInTimeframe(context.Background(), "@alice@example.social", start, end, 3)
if err != nil {
t.Fatalf("GetPostsInTimeframe error: %v", err)
}
if len(posts) != 3 {
t.Fatalf("expected 3 in-window posts, got %d: %+v", len(posts), posts)
}
if posts[0].Content != "created note 1" || posts[1].Content != "cached in window" || posts[2].Content != "deref note 2" {
t.Fatalf("unexpected merged order: %+v", posts)
}
}
func TestGetPostsInTimeframe(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, `[{"id":"1","acct":"alice"}]`)
})
mux.HandleFunc("/api/v1/accounts/1/statuses", func(w http.ResponseWriter, r *http.Request) {
feed := []map[string]any{}
switch r.URL.Query().Get("max_id") {
case "":
feed = []map[string]any{
{"id": "5", "content": "too-new", "text": "too-new", "created_at": "2025-03-01T00:00:00Z", "account": map[string]any{"acct": "alice"}},
{"id": "4", "content": "mid", "text": "mid", "created_at": "2024-06-01T00:00:00Z", "account": map[string]any{"acct": "alice"}},
}
case "4":
feed = []map[string]any{
{"id": "3", "content": "early-mid", "text": "early-mid", "created_at": "2024-03-01T00:00:00Z", "account": map[string]any{"acct": "alice"}},
{"id": "2", "content": "too-old", "text": "too-old", "created_at": "2023-01-01T00:00:00Z", "account": map[string]any{"acct": "alice"}},
}
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(feed); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
})
server := httptest.NewServer(mux)
defer server.Close()
ap := newTestClient(server.URL)
start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC)
posts, err := ap.GetPostsInTimeframe(context.Background(), "alice", start, end, 5)
if err != nil {
t.Fatalf("GetPostsInTimeframe error: %v", err)
}
if len(posts) != 2 {
t.Fatalf("expected 2 in-window posts, got %d", len(posts))
}
if posts[0].Content != "mid" || posts[1].Content != "early-mid" {
t.Fatalf("unexpected posts: %+v", posts)
}
}
func TestGetLatestNPostsHTTPError(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, `[{"id":"1","acct":"alice"}]`)
})
mux.HandleFunc("/api/v1/accounts/1/statuses", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
server := httptest.NewServer(mux)
defer server.Close()
ap := newTestClient(server.URL)
if _, err := ap.GetLatestNPosts(context.Background(), "alice", 10); err == nil {
t.Fatal("expected error on non-200 status from timeline")
}
}
func TestGetActor(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("q") != "alice" {
w.WriteHeader(http.StatusBadRequest)
return
}
_, _ = fmt.Fprint(w, `[{"id":"1","acct":"alice@inst.social","display_name":"Alice","note":"hello there","avatar":"https://img/avatar.png","followers_count":10,"following_count":5,"statuses_count":3,"url":"https://inst.social/@alice"}]`)
})
server := httptest.NewServer(mux)
defer server.Close()
ap := newTestClient(server.URL)
prof, err := ap.GetActor(context.Background(), "alice")
if err != nil {
t.Fatalf("GetActor error: %v", err)
}
if prof.Platform != "ActivityPub" || prof.Handle != "alice@inst.social" || prof.DisplayName != "Alice" {
t.Fatalf("unexpected profile: %+v", prof)
}
if prof.Bio != "hello there" || prof.AvatarURL != "https://img/avatar.png" {
t.Fatalf("unexpected profile fields: %+v", prof)
}
if prof.FollowersCount != 10 || prof.FollowingCount != 5 || prof.PostCount != 3 {
t.Fatalf("unexpected counts: %+v", prof)
}
if prof.URL != "https://inst.social/@alice" {
t.Fatalf("unexpected url: %q", prof.URL)
}
}
func TestGetActorIDFallback(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, `[]`)
})
mux.HandleFunc("/api/v1/accounts/alice", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, `{"id":"1","acct":"alice@inst.social","display_name":"Alice","note":"hi","avatar":"https://img/a.png","followers_count":1,"following_count":2,"statuses_count":3,"url":"https://x"}`)
})
server := httptest.NewServer(mux)
defer server.Close()
ap := newTestClient(server.URL)
prof, err := ap.GetActor(context.Background(), "alice")
if err != nil {
t.Fatalf("GetActor error: %v", err)
}
if prof.Handle != "alice@inst.social" {
t.Fatalf("expected fallback id lookup to succeed, got %+v", prof)
}
}
func TestGetActorAtLocalHandle(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
if q != "@alice" && q != "alice" {
w.WriteHeader(http.StatusBadRequest)
return
}
_, _ = fmt.Fprint(w, `[{"id":"1","acct":"alice","display_name":"Alice","note":"hi","avatar":"https://img/a.png","followers_count":1,"following_count":2,"statuses_count":3,"url":"https://x"}]`)
})
server := httptest.NewServer(mux)
defer server.Close()
ap := newTestClient(server.URL)
prof, err := ap.GetActor(context.Background(), "@alice")
if err != nil {
t.Fatalf("GetActor error: %v", err)
}
if prof.Handle != "alice" {
t.Fatalf("expected local handle, got %q", prof.Handle)
}
}
func TestSearchActors(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("q") != "alice" {
w.WriteHeader(http.StatusBadRequest)
return
}
if r.URL.Query().Get("resolve") != "true" {
w.WriteHeader(http.StatusBadRequest)
return
}
_, _ = fmt.Fprint(w, `[{"id":"1","acct":"alice@inst.social","display_name":"Alice","note":"bio","avatar":"https://img/a.png","followers_count":1,"following_count":2,"statuses_count":3,"url":"https://x"}]`)
})
server := httptest.NewServer(mux)
defer server.Close()
ap := newTestClient(server.URL)
results, err := ap.SearchActors(context.Background(), "alice", 10)
if err != nil {
t.Fatalf("SearchActors error: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
if results[0].Platform != "ActivityPub" || results[0].Handle != "alice@inst.social" {
t.Fatalf("unexpected result: %+v", results[0])
}
}
func TestExtractHandleFromURL(t *testing.T) {
tests := []struct {
url string
expected string
}{
{"https://inst.social/@alice/statuses/42", "alice"},
{"http://example.com/@bob/posts/1", "bob"},
{"https://no-at-here.com/notes/1", ""},
{"https://host/@user/note", "user"},
}
for _, tt := range tests {
got := extractHandleFromURL(tt.url)
if got != tt.expected {
t.Fatalf("extractHandleFromURL(%q) = %q, want %q", tt.url, got, tt.expected)
}
}
}
func TestGetPostBareLocalHandle(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
if q != "@alice" && q != "alice" {
w.WriteHeader(http.StatusBadRequest)
return
}
_, _ = fmt.Fprint(w, `[{"id":"1","acct":"alice"}]`)
})
mux.HandleFunc("/api/v1/statuses/alice", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, `{"id":"1","content":"local post","created_at":"2024-01-01T00:00:00Z","account":{"acct":"alice"}}`)
})
server := httptest.NewServer(mux)
defer server.Close()
ap := newTestClient(server.URL)
_, err := ap.GetPost(context.Background(), "alice")
if err != nil {
t.Fatalf("GetPost with local handle error: %v", err)
}
}
func TestSearchActorsNilEntries(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, `[null,{"id":"1","acct":"alice@inst.social"}]`)
})
server := httptest.NewServer(mux)
defer server.Close()
ap := newTestClient(server.URL)
results, err := ap.SearchActors(context.Background(), "alice", 10)
if err != nil {
t.Fatalf("SearchActors error: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 result after filtering nil, got %d", len(results))
}
if results[0].Handle != "alice@inst.social" {
t.Fatalf("unexpected result: %+v", results[0])
}
}

View file

@ -1,501 +0,0 @@
package activitypub
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"time"
)
const outboxMaxPages = 100
var webfingerScheme = "https"
type outboxClient struct {
client *http.Client
}
type apStringOrArray []string
func (s *apStringOrArray) UnmarshalJSON(b []byte) error {
b = bytes.TrimSpace(b)
if len(b) == 0 || string(b) == "null" {
*s = nil
return nil
}
if b[0] == '"' {
var v string
if err := json.Unmarshal(b, &v); err != nil {
return err
}
*s = []string{v}
return nil
}
var arr []string
if err := json.Unmarshal(b, &arr); err != nil {
return err
}
*s = arr
return nil
}
type apIRI string
func (i *apIRI) UnmarshalJSON(b []byte) error {
b = bytes.TrimSpace(b)
if len(b) == 0 || string(b) == "null" {
*i = ""
return nil
}
if b[0] == '"' {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
*i = apIRI(s)
return nil
}
var o struct {
ID string `json:"id"`
}
if err := json.Unmarshal(b, &o); err != nil {
return err
}
*i = apIRI(o.ID)
return nil
}
type apRef struct {
IRI string
Inline json.RawMessage
}
func (r *apRef) UnmarshalJSON(b []byte) error {
b = bytes.TrimSpace(b)
if len(b) == 0 || string(b) == "null" {
r.IRI = ""
r.Inline = nil
return nil
}
if b[0] == '"' {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
r.IRI = s
return nil
}
r.Inline = append([]byte(nil), b...)
return nil
}
type apActor struct {
ID string `json:"id"`
Type string `json:"type"`
PreferredUsername string `json:"preferredUsername"`
URL string `json:"url"`
Outbox apIRI `json:"outbox"`
Inbox apIRI `json:"inbox"`
}
type apActivity struct {
ID string `json:"id"`
Type string `json:"type"`
Published string `json:"published"`
Content string `json:"content"`
URL apStringOrArray `json:"url"`
Object json.RawMessage `json:"object"`
}
type apCollection struct {
Type string `json:"type"`
First apRef `json:"first"`
}
type apPage struct {
Type string `json:"type"`
OrderedItems json.RawMessage `json:"orderedItems"`
Next apRef `json:"next"`
}
type apNote struct {
ID string `json:"id"`
Type string `json:"type"`
Published string `json:"published"`
Content string `json:"content"`
URL apStringOrArray `json:"url"`
}
type webfingerResponse struct {
Subject string `json:"subject"`
Links []struct {
Rel string `json:"rel"`
Type string `json:"type"`
Href string `json:"href"`
} `json:"links"`
}
func newOutboxClient() *outboxClient {
return &outboxClient{client: &http.Client{Timeout: 30 * time.Second}}
}
func (oc *outboxClient) getJSON(ctx context.Context, endpoint string, target any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", `application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams", application/json`)
resp, err := oc.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("api error (status %d): %s", resp.StatusCode, string(errBody))
}
if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBody)).Decode(target); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
return nil
}
func (oc *outboxClient) webfinger(ctx context.Context, handle string) (string, error) {
full := strings.TrimPrefix(handle, "@")
at := strings.LastIndex(full, "@")
if at <= 0 || at == len(full)-1 {
return "", fmt.Errorf("invalid handle %q", handle)
}
domain := full[at+1:]
endpoint := fmt.Sprintf("%s://%s/.well-known/webfinger?resource=%s", webfingerScheme, domain, url.QueryEscape("acct:"+full))
var wf webfingerResponse
if err := oc.getJSON(ctx, endpoint, &wf); err != nil {
return "", fmt.Errorf("webfinger lookup for %q failed: %w", full, err)
}
var htmlSelf string
for _, l := range wf.Links {
if l.Rel != "self" || l.Href == "" {
continue
}
if strings.Contains(l.Type, "activity+json") || strings.Contains(l.Type, "ld+json") {
return l.Href, nil
}
if htmlSelf == "" && strings.Contains(l.Type, "html") {
htmlSelf = l.Href
}
}
if htmlSelf != "" {
return htmlSelf, nil
}
return "", fmt.Errorf("webfinger for %q returned no self link", full)
}
func (oc *outboxClient) getActor(ctx context.Context, actorURL string) (*apActor, error) {
var actor apActor
if err := oc.getJSON(ctx, actorURL, &actor); err != nil {
return nil, fmt.Errorf("failed to fetch actor %q: %w", actorURL, err)
}
if actor.ID == "" && actor.Outbox == "" {
return nil, fmt.Errorf("response from %q is not an actor document", actorURL)
}
return &actor, nil
}
func (oc *outboxClient) resolveActor(ctx context.Context, actorURL, handle string) (*apActor, error) {
if actorURL == "" {
var err error
actorURL, err = oc.webfinger(ctx, handle)
if err != nil {
return nil, err
}
}
return oc.getActor(ctx, actorURL)
}
func (oc *outboxClient) getLatest(ctx context.Context, actorURL, author string, limit int) ([]*Post, error) {
if limit <= 0 {
return nil, nil
}
return oc.walk(ctx, actorURL, author, limit, func(*Post) bool { return true })
}
func (oc *outboxClient) getInTimeframe(ctx context.Context, actorURL, author string, start, end time.Time, limit int) ([]*Post, error) {
if limit <= 0 {
return nil, nil
}
return oc.walk(ctx, actorURL, author, limit, func(p *Post) bool {
return !p.CreatedAt.Before(start) && !p.CreatedAt.After(end)
})
}
func (oc *outboxClient) walk(ctx context.Context, actorURL, author string, limit int, include func(*Post) bool) ([]*Post, error) {
actor, err := oc.resolveActor(ctx, actorURL, author)
if err != nil {
return nil, err
}
if actor.Outbox == "" {
return nil, fmt.Errorf("actor %q exposes no outbox", actorURL)
}
var posts []*Post
seen := make(map[string]struct{})
cur, err := oc.firstPage(ctx, string(actor.Outbox))
if err != nil {
return nil, err
}
for pages := 0; pages < outboxMaxPages; pages++ {
if ctx.Err() != nil {
return nil, ctx.Err()
}
if len(posts) >= limit {
break
}
page, ok, err := oc.getPage(ctx, cur)
if err != nil {
return nil, err
}
if !ok {
break
}
var items []apActivity
if len(page.OrderedItems) > 0 {
if err := json.Unmarshal(page.OrderedItems, &items); err != nil {
return nil, fmt.Errorf("failed to decode outbox page items: %w", err)
}
}
for _, item := range items {
p, valid, err := oc.itemToPost(ctx, item, author)
if err != nil {
return nil, err
}
if !valid || p == nil {
continue
}
if _, dup := seen[p.ID]; dup {
continue
}
seen[p.ID] = struct{}{}
if include(p) {
posts = append(posts, p)
if len(posts) >= limit {
return posts, nil
}
}
}
cur = page.Next
if cur.IRI == "" && len(cur.Inline) == 0 {
break
}
}
return posts, nil
}
func (oc *outboxClient) firstPage(ctx context.Context, outbox string) (apRef, error) {
var coll apCollection
if err := oc.getJSON(ctx, outbox, &coll); err != nil {
return apRef{}, fmt.Errorf("failed to fetch outbox %q: %w", outbox, err)
}
if coll.Type != "" && coll.Type != "OrderedCollection" {
return apRef{}, fmt.Errorf("outbox %q has unexpected type %q", outbox, coll.Type)
}
if coll.First.IRI == "" && len(coll.First.Inline) == 0 {
return apRef{}, fmt.Errorf("outbox %q exposes no pages", outbox)
}
return coll.First, nil
}
func (oc *outboxClient) getPage(ctx context.Context, ref apRef) (apPage, bool, error) {
var page apPage
switch {
case ref.IRI != "":
if err := oc.getJSON(ctx, ref.IRI, &page); err != nil {
return apPage{}, false, fmt.Errorf("failed to fetch outbox page %q: %w", ref.IRI, err)
}
case len(ref.Inline) > 0:
if err := json.Unmarshal(ref.Inline, &page); err != nil {
return apPage{}, false, fmt.Errorf("failed to decode inline outbox page: %w", err)
}
default:
return apPage{}, false, nil
}
return page, true, nil
}
func (oc *outboxClient) itemToPost(ctx context.Context, item apActivity, author string) (*Post, bool, error) {
var note *apNote
var err error
switch item.Type {
case "Create", "Update":
note, err = oc.noteFromObject(ctx, item.Object)
case "Note", "Article", "Question", "Page", "":
note = &apNote{
ID: item.ID,
Type: item.Type,
Published: item.Published,
Content: item.Content,
URL: item.URL,
}
default:
return nil, false, nil
}
if err != nil {
return nil, false, err
}
if note == nil || !isPostNote(note.Type) {
return nil, false, nil
}
id := note.ID
if id == "" && len(note.URL) > 0 {
id = note.URL[0]
}
if id == "" {
return nil, false, nil
}
created, ok := parseActivityTime(note.Published)
if !ok || created.IsZero() {
return nil, false, nil
}
return &Post{
ID: id,
Author: author,
Content: note.Content,
CreatedAt: created,
}, true, nil
}
func (oc *outboxClient) noteFromObject(ctx context.Context, raw json.RawMessage) (*apNote, error) {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || string(raw) == "null" {
return nil, nil
}
if raw[0] == '"' {
var id string
if err := json.Unmarshal(raw, &id); err != nil {
return nil, err
}
return oc.fetchNote(ctx, id)
}
var n apNote
if err := json.Unmarshal(raw, &n); err != nil {
return nil, err
}
return &n, nil
}
func (oc *outboxClient) fetchNote(ctx context.Context, iri string) (*apNote, error) {
var n apNote
if err := oc.getJSON(ctx, iri, &n); err != nil {
return nil, fmt.Errorf("failed to dereference object %q: %w", iri, err)
}
return &n, nil
}
func (oc *outboxClient) resolveNote(ctx context.Context, url string) (*apNote, error) {
var act apActivity
if err := oc.getJSON(ctx, url, &act); err != nil {
return nil, fmt.Errorf("failed to dereference status %q: %w", url, err)
}
switch act.Type {
case "Create":
return oc.noteFromObject(ctx, act.Object)
case "Note", "Article", "Question", "Page", "":
return &apNote{
ID: act.ID,
Type: act.Type,
Published: act.Published,
Content: act.Content,
URL: act.URL,
}, nil
default:
return nil, fmt.Errorf("document at %q is not a post (type %q)", url, act.Type)
}
}
func isPostNote(t string) bool {
switch t {
case "", "Note", "Article", "Question", "Page":
return true
}
return false
}
func parseActivityTime(s string) (time.Time, bool) {
if s == "" {
return time.Time{}, false
}
for _, layout := range []string{time.RFC3339, time.RFC3339Nano, "2006-01-02T15:04:05"} {
if t, err := time.Parse(layout, s); err == nil {
return t, true
}
}
return time.Time{}, false
}
func noteToInternalPost(n *apNote, author string) *internalPost {
p := &internalPost{
ID: n.ID,
URI: n.ID,
Content: n.Content,
}
if len(n.URL) > 0 {
p.URL = n.URL[0]
}
if t, ok := parseActivityTime(n.Published); ok {
p.CreatedAt = t
}
if author != "" {
p.Account = Account{Acct: author}
}
return p
}
func mergePostsBySource(cached []*internalPost, outbox []*Post) []*Post {
seen := make(map[string]struct{}, len(cached))
res := make([]*Post, 0, len(cached)+len(outbox))
for _, c := range cached {
key := c.URI
if key == "" {
key = c.ID
}
if key != "" {
seen[key] = struct{}{}
}
if c != nil {
res = append(res, c.toPost())
}
}
for _, p := range outbox {
if p == nil || p.ID == "" {
continue
}
if _, dup := seen[p.ID]; dup {
continue
}
seen[p.ID] = struct{}{}
res = append(res, p)
}
sort.SliceStable(res, func(i, j int) bool { return res[i].CreatedAt.After(res[j].CreatedAt) })
return res
}

View file

@ -1,224 +0,0 @@
package activitypub
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// newRemoteServer serves a synthetic ActivityPub instance: a webfinger
// endpoint, an actor with an outbox collection, two outbox pages (with mixed
// activity types and an IRI-referenced object) and a dereferenceable note.
// Returns the server and the actor's canonical URL.
func newRemoteServer() (*httptest.Server, string) {
base := ""
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body string
switch {
case strings.HasSuffix(r.URL.Path, "/.well-known/webfinger"):
body = `{"subject":"acct:alice@example.social","links":[` +
`{"rel":"self","type":"application/activity+json","href":"` + base + `/users/alice"},` +
`{"rel":"http://webfinger.net/rel/profile-page","type":"text/html","href":"` + base + `/@alice"}]}`
case r.URL.Path == "/users/alice":
body = `{"id":"` + base + `/users/alice","type":"Person","preferredUsername":"alice","url":"` + base + `/@alice","outbox":"` + base + `/users/alice/outbox"}`
case r.URL.Path == "/users/alice/outbox" && r.URL.Query().Get("page") == "":
body = `{"type":"OrderedCollection","totalItems":5,"first":"` + base + `/users/alice/outbox?page=1"}`
case r.URL.Path == "/users/alice/outbox" && r.URL.Query().Get("page") == "1":
body = `{"type":"OrderedCollectionPage","partOf":"` + base + `/users/alice/outbox","orderedItems":[` +
`{"type":"Create","published":"2024-03-01T00:00:00Z","object":{"type":"Note","id":"` + base + `/notes/1","published":"2024-03-01T00:00:00Z","content":"created note 1","url":"` + base + `/@alice/statuses/1"}},` +
`{"type":"Announce","object":"` + base + `/notes/9"},` +
`{"type":"Like","object":"` + base + `/notes/8"},` +
`{"type":"Create","object":"` + base + `/notes/2"},` +
`{"type":"Note","id":"` + base + `/notes/3","published":"2024-03-03T00:00:00Z","content":"bare note 3","url":["` + base + `/@alice/statuses/3"]}` +
`],"next":"` + base + `/users/alice/outbox?page=2"}`
case r.URL.Path == "/users/alice/outbox" && r.URL.Query().Get("page") == "2":
body = `{"type":"OrderedCollectionPage","orderedItems":[` +
`{"type":"Create","object":{"type":"Note","id":"` + base + `/notes/4","published":"2024-01-15T00:00:00Z","content":"note 4"}}` +
`]}`
case r.URL.Path == "/notes/2":
body = `{"type":"Note","id":"` + base + `/notes/2","published":"2024-02-01T00:00:00Z","content":"deref note 2","url":"` + base + `/@alice/statuses/2"}`
default:
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/activity+json")
_, _ = fmt.Fprint(w, body)
}))
base = server.URL
return server, base + "/users/alice"
}
func TestOutboxGetLatest(t *testing.T) {
server, actorURL := newRemoteServer()
defer server.Close()
oc := newOutboxClient()
posts, err := oc.getLatest(context.Background(), actorURL, "alice@example.social", 10)
if err != nil {
t.Fatalf("getLatest error: %v", err)
}
if len(posts) != 4 {
t.Fatalf("expected 4 Create/Note posts (skipping Announce/Like), got %d: %+v", len(posts), posts)
}
want := []string{"created note 1", "deref note 2", "bare note 3", "note 4"}
for i, w := range want {
if posts[i].Content != w {
t.Fatalf("posts[%d].Content = %q, want %q", i, posts[i].Content, w)
}
}
if posts[0].Author != "alice@example.social" {
t.Fatalf("Author = %q, want handle", posts[0].Author)
}
}
func TestOutboxGetLatestLimit(t *testing.T) {
server, actorURL := newRemoteServer()
defer server.Close()
oc := newOutboxClient()
posts, err := oc.getLatest(context.Background(), actorURL, "h", 2)
if err != nil {
t.Fatalf("getLatest error: %v", err)
}
if len(posts) != 2 {
t.Fatalf("expected 2 posts, got %d", len(posts))
}
want := []string{"created note 1", "deref note 2"}
for i, w := range want {
if posts[i].Content != w {
t.Fatalf("posts[%d].Content = %q, want %q", i, posts[i].Content, w)
}
}
}
func TestOutboxGetInTimeframe(t *testing.T) {
server, actorURL := newRemoteServer()
defer server.Close()
oc := newOutboxClient()
start := time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2024, 3, 2, 0, 0, 0, 0, time.UTC)
posts, err := oc.getInTimeframe(context.Background(), actorURL, "h", start, end, 10)
if err != nil {
t.Fatalf("getInTimeframe error: %v", err)
}
if len(posts) != 2 {
t.Fatalf("expected 2 in-window posts (note 3 and 4 are outside), got %d", len(posts))
}
if posts[0].Content != "created note 1" || posts[1].Content != "deref note 2" {
t.Fatalf("unexpected in-window posts: %+v", posts)
}
}
func TestOutboxResolveViaWebfinger(t *testing.T) {
server, _ := newRemoteServer()
defer server.Close()
scheme := webfingerScheme
webfingerScheme = "http"
defer func() { webfingerScheme = scheme }()
handle := "@alice@" + strings.TrimPrefix(server.URL, "http://")
oc := newOutboxClient()
posts, err := oc.getLatest(context.Background(), "", handle, 10)
if err != nil {
t.Fatalf("getLatest via webfinger error: %v", err)
}
if len(posts) != 4 {
t.Fatalf("expected 4 posts via webfinger, got %d", len(posts))
}
}
func TestOutboxResolveNote(t *testing.T) {
server, _ := newRemoteServer()
defer server.Close()
oc := newOutboxClient()
note, err := oc.resolveNote(context.Background(), server.URL+"/notes/2")
if err != nil {
t.Fatalf("resolveNote error: %v", err)
}
if note.Content != "deref note 2" {
t.Fatalf("unexpected note: %+v", note)
}
}
func TestNoteToInternalPostSetsAuthor(t *testing.T) {
note := &apNote{
ID: "https://example.social/notes/1",
Content: "hello",
Published: "2024-01-01T00:00:00Z",
}
p := noteToInternalPost(note, "alice@example.social")
if p.Account.Acct != "alice@example.social" {
t.Fatalf("expected Account.Acct = 'alice@example.social', got %q", p.Account.Acct)
}
if p.toPost().Author != "alice@example.social" {
t.Fatalf("expected toPost().Author = 'alice@example.social', got %q", p.toPost().Author)
}
}
func TestNoteToInternalPostNoAuthor(t *testing.T) {
note := &apNote{
ID: "https://example.social/notes/1",
Content: "hello",
Published: "2024-01-01T00:00:00Z",
}
p := noteToInternalPost(note, "")
if p.Account.Acct != "" {
t.Fatalf("expected empty Account.Acct when no author, got %q", p.Account.Acct)
}
if p.toPost().Author != "" {
t.Fatalf("expected empty author when none provided, got %q", p.toPost().Author)
}
}
func TestMergePostsBySourceDedup(t *testing.T) {
created := time.Date(2024, 3, 1, 0, 0, 0, 0, time.UTC)
cached := []*internalPost{
{ID: "local-1", URI: "https://example.social/notes/1", Content: "cached", CreatedAt: created},
}
outbox := []*Post{
{ID: "https://example.social/notes/1", Content: "cached dup", CreatedAt: created},
{ID: "https://example.social/notes/2", Content: "outbox only", CreatedAt: created.Add(-time.Hour)},
}
merged := mergePostsBySource(cached, outbox)
if len(merged) != 2 {
t.Fatalf("expected dedup to drop the duplicate, got %d", len(merged))
}
if merged[0].Content != "cached" {
t.Fatalf("newest first: expected cached first, got %q", merged[0].Content)
}
if merged[1].Content != "outbox only" {
t.Fatalf("expected outbox only second, got %q", merged[1].Content)
}
}
func TestParseActivityTime(t *testing.T) {
for _, s := range []string{"2024-01-01T00:00:00Z", "2024-01-01T00:00:00.000Z", "2024-01-01T00:00:00+02:00"} {
if _, ok := parseActivityTime(s); !ok {
t.Fatalf("parseActivityTime(%q) failed", s)
}
}
if _, ok := parseActivityTime("not a date"); ok {
t.Fatal("expected invalid timestamp to fail")
}
}
func TestAPStringOrArray(t *testing.T) {
var sa apStringOrArray
if err := json.Unmarshal([]byte(`"https://a"`), &sa); err != nil || len(sa) != 1 || sa[0] != "https://a" {
t.Fatalf("string form failed: %v %v", sa, err)
}
if err := json.Unmarshal([]byte(`["https://a","https://b"]`), &sa); err != nil || len(sa) != 2 {
t.Fatalf("array form failed: %v %v", sa, err)
}
if err := json.Unmarshal([]byte(`null`), &sa); err != nil || len(sa) != 0 {
t.Fatalf("null form failed: %v %v", sa, err)
}
}

View file

@ -1,58 +0,0 @@
package activitypub
import (
"context"
"sync"
"time"
)
type hostLimiter struct {
mu sync.Mutex
buckets map[string]*bucket
}
type bucket struct {
mu sync.Mutex
last time.Time
}
func newHostLimiter() *hostLimiter {
return &hostLimiter{buckets: make(map[string]*bucket)}
}
func (h *hostLimiter) Wait(ctx context.Context, host string) error {
h.mu.Lock()
b, ok := h.buckets[host]
if !ok {
b = &bucket{}
h.buckets[host] = b
if len(h.buckets) > 1024 {
for k, v := range h.buckets {
if time.Since(v.last) > 10*time.Minute {
delete(h.buckets, k)
}
}
}
}
h.mu.Unlock()
b.mu.Lock()
defer b.mu.Unlock()
if b.last.IsZero() {
b.last = time.Now()
return nil
}
wait := time.Until(b.last.Add(time.Second))
if wait <= 0 {
b.last = time.Now()
return nil
}
select {
case <-time.After(wait):
b.last = time.Now()
return nil
case <-ctx.Done():
return ctx.Err()
}
}

View file

@ -3,7 +3,6 @@ package activitypub
import "time"
type Post struct {
ID string `json:"id"`
Author string `json:"author"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
@ -13,18 +12,14 @@ type Post struct {
}
func (i *internalPost) toPost() *Post {
p := &Post{
ID: i.ID,
return &Post{
Author: i.Account.Acct,
Content: i.Content,
CreatedAt: i.CreatedAt,
FavouritesCount: i.FavouritesCount,
ReblogsCount: i.ReblogsCount,
RepliesCount: i.RepliesCount,
}
if i.Account.Acct != "" {
p.Author = i.Account.Acct
}
return p
}
type internalPost struct {
@ -58,6 +53,7 @@ type internalPost struct {
Sensitive bool `json:"sensitive"`
SpoilerText string `json:"spoiler_text"`
Tags []Tag `json:"tags"`
Text string `json:"text"`
URI string `json:"uri"`
URL string `json:"url"`
Visibility string `json:"visibility"`
@ -270,10 +266,3 @@ type Tag struct {
Name string `json:"name"`
URL string `json:"url"`
}
// searchResult mirrors the /api/v2/search response: statuses are the api
// `status` objects served by the search endpoint.
type searchResult struct {
Accounts []*Account `json:"accounts"`
Statuses []*internalPost `json:"statuses"`
}

View file

@ -1,16 +0,0 @@
package actor
// Profile is a platform-agnostic representation of an actor's public metadata. It is shared
// by every platform client so the server can aggregate results without importing client
// packages (which would create an import cycle).
type Profile struct {
Platform string `json:"platform"`
Handle string `json:"handle"`
DisplayName string `json:"displayName,omitempty"`
AvatarURL string `json:"avatarUrl,omitempty"`
Bio string `json:"bio,omitempty"`
FollowersCount int `json:"followersCount"`
FollowingCount int `json:"followingCount"`
PostCount int `json:"postCount"`
URL string `json:"url,omitempty"`
}

View file

@ -2,11 +2,9 @@ package bluesky
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"sync"
"time"
"github.com/bluesky-social/indigo/api/atproto"
@ -14,39 +12,10 @@ import (
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/bluesky-social/indigo/xrpc"
log "github.com/nacorid/logger"
"github.com/nacorid/naco-api/internal/actor"
)
const (
// maxBskyFeedLimit is the upper bound the Bluesky API accepts for ?limit=.
// Higher values are silently clamped server-side.
maxBskyFeedLimit = 100
// maxAuthorFeedPages caps the number of author-feed pages GetPostsInTimeframe
// scans. At 50 posts/page this allows up to ~5,000 statuses per window before
// giving up.
maxAuthorFeedPages = 100
// didCacheTTL bounds how long a handle->DID resolution is reused. Handles
// almost never re-map, but the TTL guarantees a long-running process still
// notices handle changes instead of pinning a stale DID forever.
didCacheTTL = 24 * time.Hour
)
type BlueskyClient struct {
client *xrpc.Client
authMu sync.RWMutex
refreshMu sync.Mutex
cacheMu sync.Mutex
didCache map[string]didCacheEntry
}
// didCacheEntry holds a positive (successfully resolved) handle->DID mapping
// and when it expires. Failures are never cached, so transient resolution
// errors are retried on the next call.
type didCacheEntry struct {
did string
expiresAt time.Time
}
func NewBlueskyClient(ctx context.Context, handle, password, host string) (*BlueskyClient, error) {
@ -68,175 +37,61 @@ func NewBlueskyClient(ctx context.Context, handle, password, host string) (*Blue
Handle: session.Handle,
Did: session.Did,
}
return &BlueskyClient{
client: client,
didCache: make(map[string]didCacheEntry),
}, nil
}
// withRefresh runs fn. If it fails with an auth/expired-token XRPC error, the
// session is refreshed once and fn is retried.
//
// refreshMu serializes refresh calls: Bluesky refresh tokens are single-use and
// rotated, so two concurrent refreshes would race and one should fail for no
// reason. After acquiring the lock the error is re-checked, because a previous
// goroutine may already have refreshed the session while this one waited.
func (c *BlueskyClient) withRefresh(ctx context.Context, fn func() error) error {
if err := fn(); err == nil {
return nil
} else if !isAuthError(err) {
return err
}
c.refreshMu.Lock()
defer c.refreshMu.Unlock()
// Another goroutine may have refreshed while we waited for the lock.
err := fn()
if err == nil {
return nil
}
if !isAuthError(err) {
return err
}
if rerr := c.refreshSession(ctx); rerr != nil {
log.WarnWithContext(ctx, "bluesky session refresh failed", "error", rerr)
return err
}
return fn()
}
// clientSnapshot returns a copy of the xrpc client that requests must use. It
// pins the current, immutable AuthInfo under the read lock: refreshSession
// replaces the Auth pointer under the exclusive lock rather than mutating the
// old value in place, so every goroutine sharing a pinned AuthInfo reads a
// complete, consistent token. xrpc.Client.Do reads client.Auth without any
// locking of its own, so no request may touch c.client.Auth directly.
func (c *BlueskyClient) clientSnapshot() *xrpc.Client {
c.authMu.RLock()
defer c.authMu.RUnlock()
cp := *c.client
return &cp
}
func (c *BlueskyClient) refreshSession(ctx context.Context) error {
refreshed, err := atproto.ServerRefreshSession(ctx, c.clientSnapshot())
if err != nil {
return fmt.Errorf("failed to refresh bluesky session: %w", err)
}
c.authMu.Lock()
defer c.authMu.Unlock()
c.client.Auth = &xrpc.AuthInfo{
AccessJwt: refreshed.AccessJwt,
RefreshJwt: refreshed.RefreshJwt,
Handle: c.client.Auth.Handle,
Did: c.client.Auth.Did,
}
return nil
}
// isAuthError reports whether err is an auth/expired-token XRPC error. The
// ErrStr switch covers the canonical cases; the message-substring fallback is a
// deliberate heuristic for third-party PDS instances that return nonstandard
// error strings. It can false-positive (an unrelated message that happens to
// contain "expired"/"unauthorized"/"token"), which only costs one gratuitous
// refresh + retry.
func isAuthError(err error) bool {
var xe *xrpc.XRPCError
if !errors.As(err, &xe) {
return false
}
switch xe.ErrStr {
case "ExpiredToken", "TokenRequired", "AuthMissing", "AuthorizationRequired":
return true
}
msg := strings.ToLower(xe.Message)
return strings.Contains(msg, "expired") || strings.Contains(msg, "unauthorized") || strings.Contains(msg, "token")
return &BlueskyClient{client: client}, nil
}
func (c *BlueskyClient) GetPost(ctx context.Context, actor, collection, recordKey string) (*bsky.FeedDefs_PostView, error) {
var result *bsky.FeedDefs_PostView
err := c.withRefresh(ctx, func() error {
repo, err := c.resolveUser(ctx, actor)
if err != nil {
return err
return nil, err
}
uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", repo.String(), recordKey)
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)
}
// collection and recordKey are caller-supplied; reject a malformed
// at-uri before spending a network round-trip.
if _, err := syntax.ParseATURI(uri); err != nil {
return fmt.Errorf("invalid at uri %q: %w", uri, err)
}
log.DebugWithContext(ctx, "Fetching post", "URI", uri)
resp, err := bsky.FeedGetPosts(ctx, c.clientSnapshot(), []string{uri})
resp, err := bsky.FeedGetPosts(ctx, c.client, []string{uri})
if err != nil {
return fmt.Errorf("failed to get post: %w", err)
return nil, fmt.Errorf("failed to get post: %w", err)
}
if len(resp.Posts) == 0 {
return fmt.Errorf("post not found")
return nil, fmt.Errorf("post not found")
}
result = resp.Posts[0]
return nil
})
return result, err
return resp.Posts[0], nil
}
func (c *BlueskyClient) GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*bsky.FeedDefs_FeedViewPost, error) {
if limit <= 0 {
return nil, nil
}
pageSize := limit
if pageSize > maxBskyFeedLimit {
pageSize = maxBskyFeedLimit
}
var result []*bsky.FeedDefs_FeedViewPost
err := c.withRefresh(ctx, func() error {
repo, err := c.resolveUser(ctx, actor)
if err != nil {
return err
return nil, err
}
resp, err := bsky.FeedGetAuthorFeed(ctx, c.clientSnapshot(), repo.String(), "", "posts", false, int64(pageSize))
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)
return nil, fmt.Errorf("failed to get latest posts: %w", err)
}
result = resp.Feed
return nil
})
return result, err
return resp.Feed, nil
}
func (c *BlueskyClient) GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]*bsky.FeedDefs_FeedViewPost, error) {
var result []*bsky.FeedDefs_FeedViewPost
err := c.withRefresh(ctx, func() error {
repo, err := c.resolveUser(ctx, actor)
if err != nil {
return err
return nil, err
}
var posts []*bsky.FeedDefs_FeedViewPost
var cursor string
// The author feed is reverse-chronological, so the first post older
// than start terminates the scan; maxAuthorFeedPages is a belt-and-
// braces guard against a misbehaving server.
for pages := 0; pages < maxAuthorFeedPages; pages++ {
feed, err := bsky.FeedGetAuthorFeed(ctx, c.clientSnapshot(), repo.String(), cursor, "posts", false, 50)
for len(posts) < limit {
feed, err := bsky.FeedGetAuthorFeed(ctx, c.client, repo.String(), cursor, "posts", false, 50)
if err != nil {
return err
return nil, err
}
for _, post := range feed.Feed {
if post.Post == nil || post.Post.Record == nil {
continue
}
if post.Post.Record != nil {
rec, ok := post.Post.Record.Val.(*bsky.FeedPost)
if !ok {
log.WarnWithContext(ctx, "Failed to parse post", "post", post.Post.Uri)
@ -249,18 +104,12 @@ func (c *BlueskyClient) GetPostsInTimeframe(ctx context.Context, actor string, s
continue
}
if createdAt.After(end) {
continue
}
if createdAt.Before(start) {
// Reverse-chronological: nothing older is in-window.
result = posts
return nil
}
if createdAt.After(start) && createdAt.Before(end) {
posts = append(posts, post)
if len(posts) >= limit {
result = posts
return nil
return posts, nil
}
}
}
}
@ -269,10 +118,7 @@ func (c *BlueskyClient) GetPostsInTimeframe(ctx context.Context, actor string, s
}
cursor = *feed.Cursor
}
result = posts
return nil
})
return result, err
return posts, nil
}
func (c *BlueskyClient) GetFeed(ctx context.Context, feedURI string, limit int) (*bsky.FeedGetFeed_Output, error) {
@ -280,139 +126,26 @@ func (c *BlueskyClient) GetFeed(ctx context.Context, feedURI string, limit int)
if err != nil {
return nil, err
}
pageSize := limit
if pageSize <= 0 {
pageSize = 50
}
if pageSize > maxBskyFeedLimit {
pageSize = maxBskyFeedLimit
}
var result *bsky.FeedGetFeed_Output
err = c.withRefresh(ctx, func() error {
resp, err := bsky.FeedGetFeed(ctx, c.clientSnapshot(), "", uri, int64(pageSize))
if err != nil {
return fmt.Errorf("failed to get feed: %w", err)
}
result = resp
return nil
})
return result, err
return bsky.FeedGetFeed(ctx, c.client, "", uri, int64(50))
}
// resolveUser converts a handle or DID into a syntax.DID. Handle resolutions
// are cached for didCacheTTL; DIDs pass through unmodified. Only successful
// resolutions are cached.
func (c *BlueskyClient) resolveUser(ctx context.Context, user string) (syntax.DID, error) {
var did syntax.DID
if d, err := syntax.ParseDID(user); err == nil {
return d, nil
}
c.cacheMu.Lock()
if e, ok := c.didCache[user]; ok && time.Now().Before(e.expiresAt) {
c.cacheMu.Unlock()
return syntax.DID(e.did), nil
}
c.cacheMu.Unlock()
resp, err := atproto.IdentityResolveHandle(ctx, c.clientSnapshot(), user)
did = d
} else {
resp, err := atproto.IdentityResolveHandle(ctx, c.client, user)
if err != nil {
return "", fmt.Errorf("failed to resolve handle to DID: %w", err)
}
did, err := syntax.ParseDID(resp.Did)
did, err = syntax.ParseDID(resp.Did)
if err != nil {
return "", fmt.Errorf("failed to parse resolved DID: %w", err)
}
c.cacheMu.Lock()
c.didCache[user] = didCacheEntry{did: did.String(), expiresAt: time.Now().Add(didCacheTTL)}
c.cacheMu.Unlock()
}
return did, nil
}
func (c *BlueskyClient) ResolveUser(ctx context.Context, user string) (syntax.DID, error) {
return c.resolveUser(ctx, user)
}
func (c *BlueskyClient) GetActor(ctx context.Context, author string) (*actor.Profile, error) {
var result *actor.Profile
err := c.withRefresh(ctx, func() error {
params := map[string]any{"actor": author}
var prof bskyActorProfile
if err := c.clientSnapshot().Do(ctx, xrpc.Query, "", "app.bsky.actor.getProfile", params, nil, &prof); err != nil {
return fmt.Errorf("failed to get actor profile: %w", err)
}
result = &actor.Profile{
Platform: "Bluesky",
Handle: prof.Handle,
DisplayName: prof.DisplayName,
AvatarURL: prof.Avatar,
Bio: prof.Description,
FollowersCount: prof.FollowersCount,
FollowingCount: prof.FollowsCount,
PostCount: prof.PostsCount,
URL: blueskyProfileURL(prof.Handle),
}
return nil
})
return result, err
}
func (c *BlueskyClient) SearchActors(ctx context.Context, query string, limit int) ([]*actor.Profile, error) {
var result []*actor.Profile
err := c.withRefresh(ctx, func() error {
params := map[string]any{"q": query, "limit": limit}
var out bskyActorSearch
if err := c.clientSnapshot().Do(ctx, xrpc.Query, "", "app.bsky.actor.searchActors", params, nil, &out); err != nil {
return fmt.Errorf("failed to search actors: %w", err)
}
results := make([]*actor.Profile, 0, len(out.Actors))
for _, a := range out.Actors {
results = append(results, &actor.Profile{
Platform: "Bluesky",
Handle: a.Handle,
DisplayName: a.DisplayName,
AvatarURL: a.Avatar,
Bio: a.Description,
URL: blueskyProfileURL(a.Handle),
})
}
result = results
return nil
})
return result, err
}
// blueskyProfileURL derives the canonical public profile URL from the handle.
// app.bsky.actor.getProfile/searchActors do not return a url field.
func blueskyProfileURL(handle string) string {
if handle == "" {
return ""
}
return "https://bsky.app/profile/" + strings.TrimPrefix(handle, "@")
}
type bskyActorProfile struct {
Did string `json:"did"`
Handle string `json:"handle"`
DisplayName string `json:"displayName"`
Avatar string `json:"avatar"`
Description string `json:"description"`
FollowersCount int `json:"followersCount"`
FollowsCount int `json:"followsCount"`
PostsCount int `json:"postsCount"`
}
type bskyActorSearch struct {
Actors []struct {
Did string `json:"did"`
Handle string `json:"handle"`
DisplayName string `json:"displayName"`
Avatar string `json:"avatar"`
Description string `json:"description"`
} `json:"actors"`
}
func (c *BlueskyClient) sanitizeFeedURI(ctx context.Context, input string) (string, error) {
input = strings.TrimSpace(input)
@ -425,10 +158,6 @@ func (c *BlueskyClient) sanitizeFeedURI(ctx context.Context, input string) (stri
return "", fmt.Errorf("invalid URI: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return "", fmt.Errorf("unrecognized URI form (scheme must be http/https): %s", input)
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(parts) >= 4 && parts[0] == "profile" && parts[2] == "feed" {
didRaw := parts[1]

View file

@ -7,10 +7,7 @@ import (
"net/http"
"net/http/httptest"
"strconv"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/bluesky-social/indigo/api/atproto"
"github.com/bluesky-social/indigo/api/bsky"
@ -33,7 +30,7 @@ func setupMockServer() *httptest.Server {
if input.Identifier == "fail.test" {
w.WriteHeader(http.StatusUnauthorized)
_, _ = fmt.Fprintln(w, `{"error": "InvalidCredentials", "message": "Invalid handle or password"}`)
fmt.Fprintln(w, `{"error": "InvalidCredentials", "message": "Invalid handle or password"}`)
return
}
@ -45,7 +42,7 @@ func setupMockServer() *httptest.Server {
Did: "did:plc:test",
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
json.NewEncoder(w).Encode(resp)
})
// Mock for bsky.FeedGetAuthorFeed
@ -83,7 +80,7 @@ func setupMockServer() *httptest.Server {
Feed: feed,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
json.NewEncoder(w).Encode(resp)
})
// Mock for bsky.FeedGetFeed
@ -109,125 +106,7 @@ func setupMockServer() *httptest.Server {
},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
})
// Mock for bsky.FeedGetPosts (used by GetPost)
mux.HandleFunc("/xrpc/app.bsky.feed.getPosts", func(w http.ResponseWriter, r *http.Request) {
uris := r.URL.Query()["uris"]
if len(uris) == 0 {
http.Error(w, `{"error": "MissingParameter", "message": "uris parameter is required"}`, http.StatusBadRequest)
return
}
posts := make([]*bsky.FeedDefs_PostView, 0, len(uris))
for _, uri := range uris {
posts = append(posts, &bsky.FeedDefs_PostView{
Uri: uri,
Cid: "bafy...",
Author: &bsky.ActorDefs_ProfileViewBasic{
Did: "did:plc:test",
Handle: "test.handle",
},
Record: &util.LexiconTypeDecoder{
Val: &bsky.FeedPost{
Text: "Test post content",
CreatedAt: "2023-10-27T12:00:00Z",
},
},
})
}
resp := bsky.FeedGetPosts_Output{
Posts: posts,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
})
// Mock for app.bsky.actor.getProfile (used by GetActor)
mux.HandleFunc("/xrpc/app.bsky.actor.getProfile", func(w http.ResponseWriter, r *http.Request) {
actor := r.URL.Query().Get("actor")
if actor == "" {
http.Error(w, `{"error": "MissingParameter", "message": "actor parameter is required"}`, http.StatusBadRequest)
return
}
resp := map[string]any{
"did": "did:plc:test",
"handle": actor,
"displayName": "Test User",
"avatar": "https://example.com/avatar.jpg",
"description": "Test bio",
"followersCount": 100,
"followsCount": 50,
"postsCount": 200,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
})
// Mock for app.bsky.actor.searchActors (used by SearchActors)
mux.HandleFunc("/xrpc/app.bsky.actor.searchActors", func(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
limitStr := r.URL.Query().Get("limit")
limit := 10
if limitStr != "" {
limit, _ = strconv.Atoi(limitStr)
}
if query == "" {
http.Error(w, `{"error": "MissingParameter", "message": "q parameter is required"}`, http.StatusBadRequest)
return
}
actors := make([]map[string]any, 0, limit)
for i := 0; i < limit; i++ {
actors = append(actors, map[string]any{
"did": fmt.Sprintf("did:plc:test%d", i),
"handle": fmt.Sprintf("%s%d.test", query, i),
"displayName": fmt.Sprintf("Test User %d", i),
"avatar": "https://example.com/avatar.jpg",
"description": fmt.Sprintf("Bio for user %d", i),
})
}
resp := map[string]any{"actors": actors}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
})
// Mock for com.atproto.identity.resolveHandle (used by ResolveUser)
mux.HandleFunc("/xrpc/com.atproto.identity.resolveHandle", func(w http.ResponseWriter, r *http.Request) {
handle := r.URL.Query().Get("handle")
if handle == "" {
http.Error(w, `{"error": "MissingParameter", "message": "handle parameter is required"}`, http.StatusBadRequest)
return
}
resp := atproto.IdentityResolveHandle_Output{
Did: "did:plc:resolved",
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
})
// Mock for com.atproto.server.refreshSession (used by withRefresh)
mux.HandleFunc("/xrpc/com.atproto.server.refreshSession", func(w http.ResponseWriter, r *http.Request) {
// Check for Authorization header
auth := r.Header.Get("Authorization")
if auth == "" {
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "TokenRequired"})
return
}
resp := atproto.ServerRefreshSession_Output{
AccessJwt: "refreshed_access_jwt",
RefreshJwt: "refreshed_refresh_jwt",
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
json.NewEncoder(w).Encode(resp)
})
return httptest.NewServer(mux)
@ -292,7 +171,6 @@ func newTestClient(t *testing.T, serverURL string) *BlueskyClient {
AccessJwt: "test_access_jwt",
},
},
didCache: make(map[string]didCacheEntry),
}
}
@ -303,7 +181,7 @@ func TestGetLatestNPosts(t *testing.T) {
client := newTestClient(t, server.URL)
ctx := context.Background()
actor := "did:plc:testactor"
limit := 5
var limit int = 5
output, err := client.GetLatestNPosts(ctx, actor, limit)
if err != nil {
@ -330,7 +208,7 @@ func TestGetFeed(t *testing.T) {
client := newTestClient(t, server.URL)
ctx := context.Background()
feedURI := "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/with-friends"
limit := 10
var limit int = 10
output, err := client.GetFeed(ctx, feedURI, limit)
if err != nil {
@ -349,597 +227,3 @@ func TestGetFeed(t *testing.T) {
t.Errorf("Unexpected post URI: %s", output.Feed[0].Post.Uri)
}
}
func TestGetPost(t *testing.T) {
server := setupMockServer()
defer server.Close()
client := newTestClient(t, server.URL)
ctx := context.Background()
postURI := "at://did:plc:testactor/app.bsky.feed.post/123"
post, err := client.GetPost(ctx, "did:plc:testactor", "", "123")
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if post == nil {
t.Fatal("Expected post not to be nil")
}
if post.Uri != postURI {
t.Errorf("Expected post URI %q, got %q", postURI, post.Uri)
}
if post.Author.Handle != "test.handle" {
t.Errorf("Expected author handle 'test.handle', got %q", post.Author.Handle)
}
// Test with custom collection
post2, err := client.GetPost(ctx, "did:plc:testactor", "app.bsky.feed.post", "456")
if err != nil {
t.Fatalf("Expected no error with custom collection, got %v", err)
}
if post2 == nil {
t.Fatal("Expected post not to be nil")
}
}
func TestGetPostsInTimeframe(t *testing.T) {
// The mock author-feed is reverse-chronological: page 1 has posts newer than
// the window end (skipped, cursor advances), page 2 has the in-range posts
// followed by one older than start (which terminates the scan).
requests := 0
mux := http.NewServeMux()
mux.HandleFunc("/xrpc/com.atproto.server.createSession", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(atproto.ServerCreateSession_Output{Handle: "test.handle", Did: "did:plc:test"})
})
mux.HandleFunc("/xrpc/app.bsky.feed.getAuthorFeed", func(w http.ResponseWriter, r *http.Request) {
requests++
mk := func(uri, createdAt string) *bsky.FeedDefs_FeedViewPost {
return &bsky.FeedDefs_FeedViewPost{
Post: &bsky.FeedDefs_PostView{
Uri: uri,
Author: &bsky.ActorDefs_ProfileViewBasic{
Did: "did:plc:testactor",
Handle: "testactor.test",
},
Record: &util.LexiconTypeDecoder{
Val: &bsky.FeedPost{Text: uri, CreatedAt: createdAt},
},
},
}
}
var feed []*bsky.FeedDefs_FeedViewPost
var cursor string
if requests == 1 {
// Page 1: one post after the window end (skipped), with a cursor.
feed = []*bsky.FeedDefs_FeedViewPost{
mk("at://did:plc:testactor/app.bsky.feed.post/4", "2024-01-01T00:00:00Z"),
}
cursor = "page-2-cursor"
} else {
// Page 2: two in-range posts, then one older than start (terminates).
feed = []*bsky.FeedDefs_FeedViewPost{
mk("at://did:plc:testactor/app.bsky.feed.post/2", "2023-08-01T00:00:00Z"),
mk("at://did:plc:testactor/app.bsky.feed.post/1", "2023-06-01T00:00:00Z"),
mk("at://did:plc:testactor/app.bsky.feed.post/3", "2022-01-01T00:00:00Z"),
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(bsky.FeedGetAuthorFeed_Output{Feed: feed, Cursor: &cursor})
})
server := httptest.NewServer(mux)
defer server.Close()
client := newTestClient(t, server.URL)
ctx := context.Background()
actor := "did:plc:testactor"
start := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2023, 12, 31, 23, 59, 59, 0, time.UTC)
limit := 10
posts, err := client.GetPostsInTimeframe(ctx, actor, start, end, limit)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
// Only the two in-range posts must survive filtering; cursor pagination must
// advance into page 2 to reach them.
if len(posts) != 2 {
t.Fatalf("expected 2 in-range posts, got %d", len(posts))
}
for _, post := range posts {
rec, ok := post.Post.Record.Val.(*bsky.FeedPost)
if !ok {
t.Fatalf("unexpected record type")
}
createdAt, _ := time.Parse(time.RFC3339, rec.CreatedAt)
if createdAt.Before(start) || createdAt.After(end) {
t.Errorf("Post %s returned outside time range", post.Post.Uri)
}
}
if requests != 2 {
t.Errorf("expected pagination to make 2 feed requests, got %d", requests)
}
}
func TestGetActor(t *testing.T) {
server := setupMockServer()
defer server.Close()
client := newTestClient(t, server.URL)
ctx := context.Background()
actor := "test.handle"
profile, err := client.GetActor(ctx, actor)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if profile == nil {
t.Fatal("Expected profile not to be nil")
}
if profile.Handle != actor {
t.Errorf("Expected handle %q, got %q", actor, profile.Handle)
}
if profile.DisplayName != "Test User" {
t.Errorf("Expected display name 'Test User', got %q", profile.DisplayName)
}
if profile.Platform != "Bluesky" {
t.Errorf("Expected platform 'Bluesky', got %q", profile.Platform)
}
if profile.URL != "https://bsky.app/profile/"+actor {
t.Errorf("Expected URL %q, got %q", "https://bsky.app/profile/"+actor, profile.URL)
}
if profile.FollowersCount != 100 || profile.FollowingCount != 50 || profile.PostCount != 200 {
t.Errorf("Unexpected counts: followers=%d, following=%d, posts=%d", profile.FollowersCount, profile.FollowingCount, profile.PostCount)
}
}
func TestSearchActors(t *testing.T) {
server := setupMockServer()
defer server.Close()
client := newTestClient(t, server.URL)
ctx := context.Background()
query := "alice"
limit := 5
results, err := client.SearchActors(ctx, query, limit)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if results == nil {
t.Fatal("Expected results not to be nil")
}
if len(results) != limit {
t.Errorf("Expected %d results, got %d", limit, len(results))
}
for i, result := range results {
expectedHandle := fmt.Sprintf("%s%d.test", query, i)
if result.Handle != expectedHandle {
t.Errorf("Result %d: expected handle %q, got %q", i, expectedHandle, result.Handle)
}
if result.Platform != "Bluesky" {
t.Errorf("Result %d: expected platform 'Bluesky', got %q", i, result.Platform)
}
expectedURL := "https://bsky.app/profile/" + expectedHandle
if result.URL != expectedURL {
t.Errorf("Result %d: expected URL %q, got %q", i, expectedURL, result.URL)
}
}
}
func TestResolveUser(t *testing.T) {
server := setupMockServer()
defer server.Close()
client := newTestClient(t, server.URL)
ctx := context.Background()
// Test with handle
did, err := client.ResolveUser(ctx, "test.handle")
if err != nil {
t.Fatalf("Expected no error resolving handle, got %v", err)
}
if did.String() != "did:plc:resolved" {
t.Errorf("Expected resolved DID 'did:plc:resolved', got %q", did.String())
}
// Test with DID directly
did2, err := client.ResolveUser(ctx, "did:plc:direct")
if err != nil {
t.Fatalf("Expected no error with direct DID, got %v", err)
}
if did2.String() != "did:plc:direct" {
t.Errorf("Expected same DID 'did:plc:direct', got %q", did2.String())
}
}
func TestSanitizeFeedURI(t *testing.T) {
server := setupMockServer()
defer server.Close()
client := newTestClient(t, server.URL)
ctx := context.Background()
tests := []struct {
name string
input string
expected string
wantErr bool
}{
{
name: "AT URI",
input: "at://did:plc:abc/app.bsky.feed.generator/xyz",
expected: "at://did:plc:abc/app.bsky.feed.generator/xyz",
wantErr: false,
},
{
name: "Web URI with profile/feed",
input: "https://bsky.app/profile/did:plc:abc/feed/xyz",
expected: "at://did:plc:abc/app.bsky.feed.generator/xyz",
wantErr: false,
},
{
name: "Non-HTTP scheme",
input: "gopher://bsky.app/profile/did:plc:abc/feed/xyz",
expected: "",
wantErr: true,
},
{
name: "Invalid URI",
input: "not-a-uri",
expected: "",
wantErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result, err := client.sanitizeFeedURI(ctx, tc.input)
if tc.wantErr {
if err == nil {
t.Fatalf("Expected error for input %q, got nil", tc.input)
}
return
}
if err != nil {
t.Fatalf("Unexpected error for input %q: %v", tc.input, err)
}
if result != tc.expected {
t.Errorf("Input %q: expected %q, got %q", tc.input, tc.expected, result)
}
})
}
}
func TestGetLatestNPostsLimitClamp(t *testing.T) {
server := setupMockServer()
defer server.Close()
client := newTestClient(t, server.URL)
ctx := context.Background()
actor := "did:plc:testactor"
t.Run("OverMaxLimitClampedTo100", func(t *testing.T) {
output, err := client.GetLatestNPosts(ctx, actor, 250)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(output) != maxBskyFeedLimit {
t.Errorf("Expected %d posts (clamped), got %d", maxBskyFeedLimit, len(output))
}
})
t.Run("ZeroLimitReturnsNil", func(t *testing.T) {
output, err := client.GetLatestNPosts(ctx, actor, 0)
if err != nil {
t.Fatalf("Expected no error with zero limit, got %v", err)
}
if output != nil {
t.Errorf("Expected nil output for zero limit, got %d posts", len(output))
}
})
t.Run("NegativeLimitReturnsNil", func(t *testing.T) {
output, err := client.GetLatestNPosts(ctx, actor, -5)
if err != nil {
t.Fatalf("Expected no error with negative limit, got %v", err)
}
if output != nil {
t.Errorf("Expected nil output for negative limit, got %d posts", len(output))
}
})
}
func TestGetFeedHonorsLimit(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/xrpc/app.bsky.feed.getFeed", func(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
feed := make([]*bsky.FeedDefs_FeedViewPost, 0, limit)
for i := 0; i < limit; i++ {
feed = append(feed, &bsky.FeedDefs_FeedViewPost{
Post: &bsky.FeedDefs_PostView{
Uri: fmt.Sprintf("at://did:plc:test/app.bsky.feed.post/%d", i),
Author: &bsky.ActorDefs_ProfileViewBasic{Did: "did:plc:test", Handle: "test.handle"},
},
})
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(bsky.FeedGetFeed_Output{Feed: feed})
})
server := httptest.NewServer(mux)
defer server.Close()
client := newTestClient(t, server.URL)
ctx := context.Background()
feedURI := "at://did:plc:abc/app.bsky.feed.generator/xyz"
t.Run("ExplicitLimitPassedThrough", func(t *testing.T) {
output, err := client.GetFeed(ctx, feedURI, 75)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(output.Feed) != 75 {
t.Errorf("Expected 75 posts, got %d", len(output.Feed))
}
})
t.Run("ZeroLimitDefaultsTo50", func(t *testing.T) {
output, err := client.GetFeed(ctx, feedURI, 0)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(output.Feed) != 50 {
t.Errorf("Expected 50 posts (default), got %d", len(output.Feed))
}
})
t.Run("OverMaxLimitClampedTo100", func(t *testing.T) {
output, err := client.GetFeed(ctx, feedURI, 1000)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(output.Feed) != maxBskyFeedLimit {
t.Errorf("Expected %d posts (clamped), got %d", maxBskyFeedLimit, len(output.Feed))
}
})
}
func TestGetPostInvalidRecordKey(t *testing.T) {
requests := 0
mux := http.NewServeMux()
mux.HandleFunc("/xrpc/app.bsky.feed.getPosts", func(w http.ResponseWriter, r *http.Request) {
requests++
http.Error(w, `{"error":"InternalServerError"}`, http.StatusInternalServerError)
})
server := httptest.NewServer(mux)
defer server.Close()
client := newTestClient(t, server.URL)
ctx := context.Background()
post, err := client.GetPost(ctx, "did:plc:testactor", "", "bad/key")
if post != nil {
t.Fatalf("Expected nil post, got %v", post)
}
if err == nil {
t.Fatal("Expected error for invalid record key, got nil")
}
if requests != 0 {
t.Errorf("Expected no network request for invalid record key, got %d", requests)
}
}
func TestWithRefreshConcurrent(t *testing.T) {
var refreshCount atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/xrpc/app.bsky.actor.getProfile", func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if auth != "Bearer refreshed_access_jwt" {
// The initial access token is expired; the session must be refreshed
// before the retry succeeds.
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "ExpiredToken", "message": "Token expired, refresh required"})
return
}
resp := map[string]any{
"did": "did:plc:test",
"handle": "test.handle",
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
})
mux.HandleFunc("/xrpc/com.atproto.server.refreshSession", func(w http.ResponseWriter, r *http.Request) {
refreshCount.Add(1)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(atproto.ServerRefreshSession_Output{
AccessJwt: "refreshed_access_jwt",
RefreshJwt: "refreshed_refresh_jwt",
})
})
server := httptest.NewServer(mux)
defer server.Close()
client := newTestClient(t, server.URL)
ctx := context.Background()
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if _, err := client.GetActor(ctx, "test.handle"); err != nil {
t.Errorf("expected no error, got %v", err)
}
}()
}
wg.Wait()
if refreshCount.Load() != 1 {
t.Errorf("expected exactly 1 session refresh under concurrency, got %d", refreshCount.Load())
}
}
func TestGetPostsInTimeframeStopsAtStart(t *testing.T) {
requests := 0
mux := http.NewServeMux()
mux.HandleFunc("/xrpc/app.bsky.feed.getAuthorFeed", func(w http.ResponseWriter, r *http.Request) {
requests++
mk := func(uri, createdAt string) *bsky.FeedDefs_FeedViewPost {
return &bsky.FeedDefs_FeedViewPost{
Post: &bsky.FeedDefs_PostView{
Uri: uri,
Author: &bsky.ActorDefs_ProfileViewBasic{Did: "did:plc:testactor", Handle: "testactor.test"},
Record: &util.LexiconTypeDecoder{
Val: &bsky.FeedPost{Text: uri, CreatedAt: createdAt},
},
},
}
}
feed := []*bsky.FeedDefs_FeedViewPost{
mk("at://did:plc:testactor/app.bsky.feed.post/1", "2023-06-01T00:00:00Z"),
mk("at://did:plc:testactor/app.bsky.feed.post/0", "2022-01-01T00:00:00Z"),
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(bsky.FeedGetAuthorFeed_Output{Feed: feed})
})
server := httptest.NewServer(mux)
defer server.Close()
client := newTestClient(t, server.URL)
ctx := context.Background()
start := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2023, 12, 31, 23, 59, 59, 0, time.UTC)
posts, err := client.GetPostsInTimeframe(ctx, "did:plc:testactor", start, end, 10)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(posts) != 1 {
t.Fatalf("expected 1 in-range post before early exit, got %d", len(posts))
}
if requests != 1 {
t.Errorf("expected scan to stop after the first page, made %d requests", requests)
}
}
func TestGetPostsInTimeframeIncludesBoundaries(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/xrpc/app.bsky.feed.getAuthorFeed", func(w http.ResponseWriter, r *http.Request) {
mk := func(uri, createdAt string) *bsky.FeedDefs_FeedViewPost {
return &bsky.FeedDefs_FeedViewPost{
Post: &bsky.FeedDefs_PostView{
Uri: uri,
Author: &bsky.ActorDefs_ProfileViewBasic{Did: "did:plc:testactor", Handle: "testactor.test"},
Record: &util.LexiconTypeDecoder{
Val: &bsky.FeedPost{Text: uri, CreatedAt: createdAt},
},
},
}
}
feed := []*bsky.FeedDefs_FeedViewPost{
mk("at://did:plc:testactor/app.bsky.feed.post/end", "2023-12-31T23:59:59Z"),
mk("at://did:plc:testactor/app.bsky.feed.post/start", "2023-01-01T00:00:00Z"),
mk("at://did:plc:testactor/app.bsky.feed.post/older", "2022-12-31T23:59:59Z"),
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(bsky.FeedGetAuthorFeed_Output{Feed: feed})
})
server := httptest.NewServer(mux)
defer server.Close()
client := newTestClient(t, server.URL)
ctx := context.Background()
start := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2023, 12, 31, 23, 59, 59, 0, time.UTC)
posts, err := client.GetPostsInTimeframe(ctx, "did:plc:testactor", start, end, 10)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
// Both boundary posts are inclusive; the post one second before start is not.
if len(posts) != 2 {
t.Fatalf("expected boundary posts to be included (got %d)", len(posts))
}
got := map[string]bool{}
for _, p := range posts {
got[p.Post.Uri] = true
}
if !got["at://did:plc:testactor/app.bsky.feed.post/start"] {
t.Error("expected post exactly at start to be included")
}
if !got["at://did:plc:testactor/app.bsky.feed.post/end"] {
t.Error("expected post exactly at end to be included")
}
}
func TestResolveUserCache(t *testing.T) {
var resolveRequests atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/xrpc/com.atproto.identity.resolveHandle", func(w http.ResponseWriter, r *http.Request) {
resolveRequests.Add(1)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(atproto.IdentityResolveHandle_Output{Did: "did:plc:resolved"})
})
server := httptest.NewServer(mux)
defer server.Close()
client := newTestClient(t, server.URL)
ctx := context.Background()
// First resolution hits the network and populates the cache.
did, err := client.ResolveUser(ctx, "test.handle")
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if did.String() != "did:plc:resolved" {
t.Errorf("Unexpected DID %q", did.String())
}
if resolveRequests.Load() != 1 {
t.Errorf("expected 1 resolve request, got %d", resolveRequests.Load())
}
// Second resolution is served from cache.
if _, err := client.ResolveUser(ctx, "test.handle"); err != nil {
t.Fatalf("Expected no error on cached resolution, got %v", err)
}
if resolveRequests.Load() != 1 {
t.Errorf("expected cached resolution to skip the network, got %d requests", resolveRequests.Load())
}
// An expired cache entry is refetched.
client.cacheMu.Lock()
entry := client.didCache["test.handle"]
entry.expiresAt = time.Now().Add(-time.Minute)
client.didCache["test.handle"] = entry
client.cacheMu.Unlock()
if _, err := client.ResolveUser(ctx, "test.handle"); err != nil {
t.Fatalf("Expected no error after expiry, got %v", err)
}
if resolveRequests.Load() != 2 {
t.Errorf("expected a fresh resolve after TTL expiry, got %d requests", resolveRequests.Load())
}
}

View file

@ -1,37 +0,0 @@
package clustering
import "math"
func normalize(v []float64) []float64 {
out := make([]float64, len(v))
var norm float64
for _, x := range v {
norm += x * x
}
norm = math.Sqrt(norm)
if norm == 0 {
return out
}
for i, x := range v {
out[i] = x / norm
}
return out
}
func cosineSimilarity(a, b []float64) float64 {
if len(a) != len(b) {
return -1
}
var dot float64
for i := range a {
dot += a[i] * b[i]
}
return dot
}

View file

@ -1,228 +0,0 @@
package clustering
import (
"errors"
"math"
"math/rand"
)
func initializeKMeansPlusPlus(
vectors [][]float64,
k int,
rng *rand.Rand,
) [][]float64 {
centroids := make([][]float64, 0, k)
first := rng.Intn(len(vectors))
centroids = append(
centroids,
append([]float64(nil), vectors[first]...),
)
distances := make([]float64, len(vectors))
for len(centroids) < k {
var total float64
for i, vector := range vectors {
bestSimilarity := -math.MaxFloat64
for _, centroid := range centroids {
similarity := cosineSimilarity(vector, centroid)
if similarity > bestSimilarity {
bestSimilarity = similarity
}
}
// cosine distance
distance := 1 - bestSimilarity
if distance < 0 {
distance = 0
}
// k-means++ uses squared distance.
distances[i] = distance * distance
total += distances[i]
}
if total == 0 {
break
}
target := rng.Float64() * total
var cumulative float64
selected := len(vectors) - 1
for i, distance := range distances {
cumulative += distance
if cumulative >= target {
selected = i
break
}
}
centroids = append(
centroids,
append([]float64(nil), vectors[selected]...),
)
}
// This can theoretically happen if all vectors are identical.
for len(centroids) < k {
index := rng.Intn(len(vectors))
centroids = append(
centroids,
append([]float64(nil), vectors[index]...),
)
}
return centroids
}
func KMeans(items []Item, k int, maxIterations int, seed int64) ([]Result, error) {
if len(items) == 0 {
return nil, errors.New("no items")
}
if k <= 0 {
return nil, errors.New("k must be positive")
}
if k > len(items) {
k = len(items)
}
if maxIterations <= 0 {
maxIterations = 50
}
dimension := len(items[0].Embedding)
if dimension == 0 {
return nil, errors.New("empty embedding")
}
// Normalize embeddings once.
normalized := make([][]float64, len(items))
for i, item := range items {
if len(item.Embedding) != dimension {
return nil, errors.New("embedding dimensions do not match")
}
normalized[i] = normalize(item.Embedding)
}
rng := rand.New(rand.NewSource(seed))
centroids := initializeKMeansPlusPlus(normalized, k, rng)
assignments := make([]int, len(items))
for iteration := 0; iteration < maxIterations; iteration++ {
changed := false
// Assignment step.
for i, vector := range normalized {
bestCluster := 0
bestSimilarity := -math.MaxFloat64
for cluster, centroid := range centroids {
similarity := cosineSimilarity(vector, centroid)
if similarity > bestSimilarity {
bestSimilarity = similarity
bestCluster = cluster
}
}
if iteration == 0 || assignments[i] != bestCluster {
assignments[i] = bestCluster
changed = true
}
}
// Stop if nothing changed.
if !changed && iteration > 0 {
break
}
// Update step.
newCentroids := make([][]float64, k)
counts := make([]int, k)
for cluster := 0; cluster < k; cluster++ {
newCentroids[cluster] = make([]float64, dimension)
}
for i, vector := range normalized {
cluster := assignments[i]
counts[cluster]++
for d, value := range vector {
newCentroids[cluster][d] += value
}
}
for cluster := 0; cluster < k; cluster++ {
if counts[cluster] == 0 {
// Empty cluster: reinitialize with a random item.
index := rng.Intn(len(normalized))
newCentroids[cluster] = append(
[]float64(nil),
normalized[index]...,
)
continue
}
newCentroids[cluster] = normalize(newCentroids[cluster])
}
centroids = newCentroids
}
results := make([]Result, k)
for cluster := 0; cluster < k; cluster++ {
results[cluster] = Result{
Cluster: cluster,
Centroid: centroids[cluster],
}
}
for i, item := range items {
cluster := assignments[i]
item.Embedding = normalized[i]
results[cluster].Items = append(
results[cluster].Items,
item,
)
}
// Find the item closest to each centroid.
for cluster := range results {
if len(results[cluster].Items) == 0 {
continue
}
bestSimilarity := -math.MaxFloat64
for _, item := range results[cluster].Items {
similarity := cosineSimilarity(
item.Embedding,
results[cluster].Centroid,
)
if similarity > bestSimilarity {
bestSimilarity = similarity
results[cluster].Representative = item
}
}
}
return results, nil
}

View file

@ -1,213 +0,0 @@
package clustering
import (
"math"
"math/rand"
"testing"
)
func TestKMeans_Basic(t *testing.T) {
items := []Item{
{ID: "1", Text: "a", Embedding: []float64{1.0, 0.0}},
{ID: "2", Text: "b", Embedding: []float64{0.9, 0.1}},
{ID: "3", Text: "c", Embedding: []float64{-1.0, 0.0}},
{ID: "4", Text: "d", Embedding: []float64{-0.9, -0.1}},
}
results, err := KMeans(items, 2, 50, 42)
if err != nil {
t.Fatalf("KMeans failed: %v", err)
}
if len(results) != 2 {
t.Fatalf("expected 2 clusters, got %d", len(results))
}
// Check that items are assigned to clusters
totalItems := 0
for _, r := range results {
totalItems += len(r.Items)
}
if totalItems != len(items) {
t.Fatalf("expected %d items across clusters, got %d", len(items), totalItems)
}
// Verify centroids are normalized
for _, r := range results {
norm := 0.0
for _, v := range r.Centroid {
norm += v * v
}
norm = math.Sqrt(norm)
if math.Abs(norm-1.0) > 1e-6 {
t.Errorf("centroid not normalized: norm=%f", norm)
}
}
}
func TestKMeans_Empty(t *testing.T) {
_, err := KMeans([]Item{}, 2, 50, 42)
if err == nil {
t.Fatal("expected error for empty items")
}
}
func TestKMeans_KGreaterThanItems(t *testing.T) {
items := []Item{
{ID: "1", Text: "a", Embedding: []float64{1.0, 0.0}},
{ID: "2", Text: "b", Embedding: []float64{0.0, 1.0}},
}
results, err := KMeans(items, 5, 50, 42)
if err != nil {
t.Fatalf("KMeans failed: %v", err)
}
// k should be capped to len(items)
if len(results) != 2 {
t.Fatalf("expected 2 clusters (capped), got %d", len(results))
}
}
func TestKMeans_DeterministicWithSeed(t *testing.T) {
items := []Item{
{ID: "1", Text: "a", Embedding: []float64{1.0, 0.0}},
{ID: "2", Text: "b", Embedding: []float64{0.9, 0.1}},
{ID: "3", Text: "c", Embedding: []float64{-1.0, 0.0}},
{ID: "4", Text: "d", Embedding: []float64{-0.9, -0.1}},
}
results1, _ := KMeans(items, 2, 50, 42)
results2, _ := KMeans(items, 2, 50, 42)
// Same seed should produce same assignments
for i := range results1 {
if len(results1[i].Items) != len(results2[i].Items) {
t.Fatalf("cluster %d size differs: %d vs %d", i, len(results1[i].Items), len(results2[i].Items))
}
}
}
func TestCosineSimilarity(t *testing.T) {
tests := []struct {
name string
a, b []float64
expected float64
}{
{"identical", []float64{1, 0}, []float64{1, 0}, 1.0},
{"orthogonal", []float64{1, 0}, []float64{0, 1}, 0.0},
{"opposite", []float64{1, 0}, []float64{-1, 0}, -1.0},
{"scaled", []float64{2, 0}, []float64{1, 0}, 2.0}, // not normalized
{"mismatched len", []float64{1, 0}, []float64{1, 0, 0}, -1.0},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := cosineSimilarity(tc.a, tc.b)
if math.Abs(got-tc.expected) > 1e-6 {
t.Errorf("cosineSimilarity(%v, %v) = %f, want %f", tc.a, tc.b, got, tc.expected)
}
})
}
}
func TestNormalize(t *testing.T) {
v := []float64{3.0, 4.0}
normalized := normalize(v)
// Check length is 1
norm := 0.0
for _, x := range normalized {
norm += x * x
}
norm = math.Sqrt(norm)
if math.Abs(norm-1.0) > 1e-6 {
t.Errorf("normalized vector has norm %f, want 1.0", norm)
}
// Check zero vector handling
zero := normalize([]float64{0.0, 0.0})
if zero[0] != 0.0 || zero[1] != 0.0 {
t.Errorf("zero vector normalization failed: %v", zero)
}
}
func TestInitializeKMeansPlusPlus(t *testing.T) {
vectors := [][]float64{
{1.0, 0.0},
{0.9, 0.1},
{-1.0, 0.0},
{-0.9, -0.1},
}
rng := rand.New(rand.NewSource(42))
centroids := initializeKMeansPlusPlus(vectors, 2, rng)
if len(centroids) != 2 {
t.Fatalf("expected 2 centroids, got %d", len(centroids))
}
// Centroids should be from the input vectors (or averages)
found1, found2 := false, false
for _, c := range centroids {
for _, v := range vectors {
if equalVectors(c, v) {
if !found1 {
found1 = true
} else {
found2 = true
}
}
}
}
if !found1 || !found2 {
t.Errorf("centroids not from input vectors: %v", centroids)
}
}
func equalVectors(a, b []float64) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if math.Abs(a[i]-b[i]) > 1e-6 {
return false
}
}
return true
}
func TestKMeans_HighDimensional(t *testing.T) {
// Test with 1024-dim vectors (like qwen3-embedding)
dim := 1024
items := make([]Item, 10)
for i := range items {
emb := make([]float64, dim)
if i < 5 {
emb[0] = 1.0 // cluster 1
} else {
emb[dim-1] = 1.0 // cluster 2
}
items[i] = Item{
ID: string(rune('a' + i)),
Text: "test",
Embedding: emb,
}
}
results, err := KMeans(items, 2, 50, 42)
if err != nil {
t.Fatalf("KMeans failed: %v", err)
}
if len(results) != 2 {
t.Fatalf("expected 2 clusters, got %d", len(results))
}
// Verify each cluster has ~5 items
for _, r := range results {
if len(r.Items) < 3 || len(r.Items) > 7 {
t.Errorf("cluster size %d unexpected", len(r.Items))
}
}
}

View file

@ -1,21 +0,0 @@
package clustering
import "time"
type Item struct {
ID string
Text string
Embedding []float64
Timestamp time.Time
Likes int64
Reposts int64
Replies int64
Quotes int64
}
type Result struct {
Cluster int
Items []Item
Centroid []float64
Representative Item
}

View file

@ -2,10 +2,7 @@ package config
import (
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/joho/godotenv"
log "github.com/nacorid/logger"
@ -22,51 +19,16 @@ type Config struct {
BlueskyDID string
BlueskyAppPassword string
BlueskyHost string
NostrRelays []string
TwitterBearerToken string
PrewarmAuthors []string
PrewarmFeedURI string
EnrichCrossPlatform bool
OllamaHost string
MigrateOnStart bool
MigrationsDir string
ReadyzAddress string
ReadyzPort string
MetricsPort string
X402FacilitatorURL string
X402WalletAddress string
ApiKey string
ApiSecret string
LogFile string
ActiveNetworks string
MatrixWebhookURL string
MatrixServerID string
EmbeddingModel string
StyleModel string
AnalyticsTTLEngagement time.Duration
AnalyticsTTLPatterns time.Duration
AnalyticsTTLTopics time.Duration
AnalyticsTTLSentiment time.Duration
AnalyticsTTLDrivers time.Duration
}
func Load() *Config {
// Load config/models.env for model defaults (single source of truth)
// Find project root by looking for go.mod
root := findProjectRoot()
modelEnvPaths := []string{
filepath.Join(root, "config", "models.env"),
filepath.Join(root, "..", "config", "models.env"), // fallback
}
for _, p := range modelEnvPaths {
if err := godotenv.Load(p); err == nil {
log.Debug("Loaded model config", "path", p)
break
} else {
log.Debug("Model config not found", "path", p, "error", err)
}
}
// Also load .env for local development overrides
if err := godotenv.Load(); err != nil {
log.Debug("No .env file found, reading from environment")
}
@ -76,7 +38,7 @@ func Load() *Config {
if err != nil {
log.Fatal("Invalid UNIX_SOCKET_PERMISSIONS", "error", err)
}
cfg := &Config{
return &Config{
ListenAddress: getEnv("SERVER_ADDRESS", "127.0.0.1"),
ListenPort: getEnv("SERVER_PORT", "8080"),
UnixSocketPermissions: os.FileMode(perms),
@ -86,49 +48,14 @@ func Load() *Config {
BlueskyDID: getEnv("BLUESKY_DID", ""),
BlueskyAppPassword: getEnv("BLUESKY_APP_PASSWORD", ""),
BlueskyHost: getEnv("BLUESKY_HOST", "https://bsky.social"),
NostrRelays: splitComma(getEnv("NOSTR_RELAYS", "wss://relay.damus.io,wss://nos.lol")),
TwitterBearerToken: getEnvOptional("TWITTER_BEARER_TOKEN"),
PrewarmAuthors: splitComma(getEnvOptional("PREWARM_AUTHORS")),
PrewarmFeedURI: getEnvOptional("PREWARM_FEED_URI"),
EnrichCrossPlatform: getEnvBool("ENRICH_CROSS_PLATFORM", "true"),
OllamaHost: getEnv("OLLAMA_HOST", "http://localhost:11434"),
MigrateOnStart: getEnvBool("MIGRATE_ON_START", "true"),
MigrationsDir: getEnv("MIGRATIONS_DIR", "sql/schema"),
ReadyzAddress: getEnv("READYZ_ADDRESS", "127.0.0.1"),
ReadyzPort: getEnv("READYZ_PORT", "9090"),
MetricsPort: getEnv("METRICS_PORT", "9091"),
X402FacilitatorURL: getEnv("X402_FACILITATOR_URL", "https://api.cdp.coinbase.com/platform/v2/x402"),
X402FacilitatorURL: getEnv("X402_FACILITATOR_URL", "https://v2.facilitator.mogami.tech"),
X402WalletAddress: getEnv("X402_WALLET_ADDRESS", ""),
ApiKey: getEnv("CDP_API_KEY_ID", ""),
ApiSecret: getEnv("CDP_API_SECRET", ""),
LogFile: getEnv("LOG_FILE", "/var/log/mcpserver/output.log"),
ActiveNetworks: getEnv("ACTIVE_NETWORKS", "base"),
MatrixWebhookURL: os.Getenv("MATRIX_WEBHOOK_URL"),
MatrixServerID: os.Getenv("MATRIX_SERVER_ID"),
EmbeddingModel: getEnv("EMBEDDING_MODEL", ""),
StyleModel: getEnv("STYLE_MODEL", ""),
AnalyticsTTLEngagement: getEnvDuration("ANALYTICS_TTL_ENGAGEMENT", "1h"),
AnalyticsTTLPatterns: getEnvDuration("ANALYTICS_TTL_PATTERNS", "6h"),
AnalyticsTTLTopics: getEnvDuration("ANALYTICS_TTL_TOPICS", "24h"),
AnalyticsTTLSentiment: getEnvDuration("ANALYTICS_TTL_SENTIMENT", "6h"),
AnalyticsTTLDrivers: getEnvDuration("ANALYTICS_TTL_DRIVERS", "1h"),
}
for _, port := range []string{cfg.ReadyzPort, cfg.MetricsPort, cfg.ListenPort} {
if _, err := strconv.Atoi(port); err != nil {
log.Fatalf("Invalid port %q: must be a numeric port value", port)
}
}
// Default MatrixServerID to system hostname if not explicitly set
if cfg.MatrixServerID == "" {
hostname, err := os.Hostname()
if err == nil && hostname != "" {
cfg.MatrixServerID = hostname
}
}
return cfg
}
func getEnv(key, fallback string) string {
@ -140,59 +67,3 @@ func getEnv(key, fallback string) string {
}
return fallback
}
func getEnvOptional(key string) string {
if value, ok := os.LookupEnv(key); ok {
return value
}
log.Warn("Environment variable is not set (optional)", "key", key)
return ""
}
func getEnvBool(key, fallback string) bool {
v := getEnv(key, fallback)
switch strings.ToLower(strings.TrimSpace(v)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
func getEnvDuration(key, fallback string) time.Duration {
v := getEnv(key, fallback)
d, err := time.ParseDuration(v)
if err != nil {
d, _ = time.ParseDuration(fallback)
}
return d
}
func splitComma(s string) []string {
parts := make([]string, 0)
for p := range strings.SplitSeq(s, ",") {
if trimmed := strings.TrimSpace(p); trimmed != "" {
parts = append(parts, trimmed)
}
}
return parts
}
func findProjectRoot() string {
// Start from current working directory and walk up looking for go.mod
dir, err := os.Getwd()
if err != nil {
return "."
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
break // reached filesystem root
}
dir = parent
}
return "."
}

View file

@ -1,372 +0,0 @@
package nostr
import (
"context"
"encoding/json"
"fmt"
"net/url"
"strings"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
"github.com/nacorid/naco-api/internal/actor"
)
const (
// relayQueryTimeout bounds connect + subscribe + collect against a single
// relay. The websocket dial, subscription handshake and event collection all
// share this budget.
relayQueryTimeout = 5 * time.Second
// maxTotalRelayTime caps the fan-out across all relays when the caller's
// context carries no deadline of its own. Without it a long relay list could
// multiply the per-relay budget unboundedly.
maxTotalRelayTime = 20 * time.Second
)
type Client struct {
relays []string
}
func NewNostrClient(relays []string) (*Client, error) {
seen := make(map[string]struct{}, len(relays))
cleaned := make([]string, 0, len(relays))
for _, r := range relays {
r = strings.TrimSpace(r)
if r == "" {
continue
}
u, err := url.Parse(r)
if err != nil || (u.Scheme != "ws" && u.Scheme != "wss") {
return nil, fmt.Errorf("invalid relay URL %q: must be a ws:// or wss:// endpoint", r)
}
if _, dup := seen[r]; dup {
continue
}
seen[r] = struct{}{}
cleaned = append(cleaned, r)
}
if len(cleaned) == 0 {
return nil, fmt.Errorf("at least one relay is required")
}
return &Client{relays: cleaned}, nil
}
type Post struct {
ID string
Author string
Content string
CreatedAt time.Time
}
// queryBudget returns a context bounded to maxTotalRelayTime when ctx has no
// deadline, so the relay fan-out cannot run away across many relays. Callers
// must invoke the returned cancel.
func (c *Client) queryBudget(ctx context.Context) (context.Context, context.CancelFunc) {
if _, ok := ctx.Deadline(); ok {
return context.WithCancel(ctx)
}
return context.WithTimeout(ctx, maxTotalRelayTime)
}
// collectFromRelay connects to url within ctx, subscribes with filter and
// appends matching events to posts (deduplicated by event id) until the limit is
// reached, EOSE arrives, or ctx is done. It reports whether the relay was
// actually queryable (connected + subscribed), letting callers distinguish a
// total relay outage from a legitimate empty result.
func (c *Client) collectFromRelay(ctx context.Context, url string, filter nostr.Filter, limit int, posts *[]*Post, seen map[string]struct{}) (used bool) {
relay, err := nostr.RelayConnect(ctx, url, nostr.RelayOptions{})
if err != nil {
return false
}
defer relay.Close()
sub, err := relay.Subscribe(ctx, filter, nostr.SubscriptionOptions{})
if err != nil {
return false
}
defer sub.Unsub()
for {
select {
case <-sub.EndOfStoredEvents:
return true
case ev, ok := <-sub.Events:
if !ok {
return true
}
id := ev.ID.Hex()
if _, dup := seen[id]; dup {
continue
}
seen[id] = struct{}{}
*posts = append(*posts, &Post{
ID: id,
Author: ev.PubKey.Hex(),
Content: ev.Content,
CreatedAt: ev.CreatedAt.Time(),
})
if len(*posts) >= limit {
return true
}
case <-ctx.Done():
return true
}
}
}
// collectPosts subscribes to filter across every relay, stopping once limit posts
// are collected or the query context is done. It returns the deduplicated posts,
// or an error if no relay could be queried at all or the operation was aborted.
func (c *Client) collectPosts(ctx context.Context, filter nostr.Filter, limit int) ([]*Post, error) {
posts := make([]*Post, 0, limit)
seen := make(map[string]struct{})
queryCtx, cancel := c.queryBudget(ctx)
defer cancel()
var anyUsed bool
for _, url := range c.relays {
if queryCtx.Err() != nil {
break
}
relayCtx, relayCancel := context.WithTimeout(queryCtx, relayQueryTimeout)
used := c.collectFromRelay(relayCtx, url, filter, limit, &posts, seen)
relayCancel()
anyUsed = anyUsed || used
if len(posts) >= limit {
break
}
}
if err := queryCtx.Err(); err != nil {
return nil, fmt.Errorf("nostr query aborted: %w", err)
}
if !anyUsed {
return nil, fmt.Errorf("could not connect to any relay")
}
return posts, nil
}
func (c *Client) GetPost(ctx context.Context, id string) (*Post, error) {
eid, err := resolveEventID(id)
if err != nil {
return nil, err
}
filter := nostr.Filter{
IDs: []nostr.ID{eid},
Kinds: []nostr.Kind{1},
}
posts, err := c.collectPosts(ctx, filter, 1)
if err != nil {
return nil, err
}
if len(posts) == 0 {
return nil, fmt.Errorf("post %q not found across configured relays", id)
}
return posts[0], nil
}
func (c *Client) GetLatestNPosts(ctx context.Context, author string, limit int) ([]*Post, error) {
if limit <= 0 {
return nil, nil
}
pubkey, err := resolvePubkey(author)
if err != nil {
return nil, err
}
filter := nostr.Filter{
Authors: []nostr.PubKey{pubkey},
Kinds: []nostr.Kind{1},
Limit: limit,
}
return c.collectPosts(ctx, filter, limit)
}
func (c *Client) GetPostsInTimeframe(ctx context.Context, author string, start, end time.Time, limit int) ([]*Post, error) {
if limit <= 0 {
return nil, nil
}
pubkey, err := resolvePubkey(author)
if err != nil {
return nil, err
}
filter := nostr.Filter{
Authors: []nostr.PubKey{pubkey},
Kinds: []nostr.Kind{1},
Since: nostr.Timestamp(start.Unix()),
Until: nostr.Timestamp(end.Unix()),
Limit: limit,
}
return c.collectPosts(ctx, filter, limit)
}
func (c *Client) GetActor(ctx context.Context, author string) (*actor.Profile, error) {
pubkey, err := resolvePubkey(author)
if err != nil {
return nil, err
}
npub := nip19.EncodeNpub(pubkey)
filter := nostr.Filter{
Authors: []nostr.PubKey{pubkey},
Kinds: []nostr.Kind{0},
Limit: 1,
}
queryCtx, cancel := c.queryBudget(ctx)
defer cancel()
var anyUsed bool
for _, url := range c.relays {
if queryCtx.Err() != nil {
break
}
relayCtx, relayCancel := context.WithTimeout(queryCtx, relayQueryTimeout)
profile, used := c.actorFromRelay(relayCtx, url, filter, npub)
relayCancel()
anyUsed = anyUsed || used
if profile != nil {
return profile, nil
}
}
if err := queryCtx.Err(); err != nil {
return nil, fmt.Errorf("nostr query aborted: %w", err)
}
if !anyUsed {
return nil, fmt.Errorf("could not connect to any relay")
}
return nil, fmt.Errorf("no profile metadata found for %q", author)
}
// actorFromRelay looks up one kind-0 metadata event on url. It returns a
// non-nil profile when a usable metadata event was found. used reports whether
// the relay was queried at all (so a fully unreachable relay list is
// distinguishable from an author without metadata).
func (c *Client) actorFromRelay(ctx context.Context, url string, filter nostr.Filter, npub string) (profile *actor.Profile, used bool) {
relay, err := nostr.RelayConnect(ctx, url, nostr.RelayOptions{})
if err != nil {
return nil, false
}
defer relay.Close()
sub, err := relay.Subscribe(ctx, filter, nostr.SubscriptionOptions{})
if err != nil {
return nil, false
}
defer sub.Unsub()
select {
case <-sub.EndOfStoredEvents:
return nil, true
case ev, ok := <-sub.Events:
if !ok {
return nil, true
}
return profileFromEvent(ev, npub), true
case <-ctx.Done():
return nil, true
}
}
// profileFromEvent builds an actor.Profile from a kind-0 metadata event, or nil
// when the content is not valid JSON or carries no usable fields.
func profileFromEvent(ev nostr.Event, npub string) *actor.Profile {
var meta struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
About string `json:"about"`
Picture string `json:"picture"`
Website string `json:"website"`
}
if err := json.Unmarshal([]byte(ev.Content), &meta); err != nil {
return nil
}
if meta.Name == "" && meta.DisplayName == "" && meta.About == "" && meta.Picture == "" {
return nil
}
displayName := meta.DisplayName
if displayName == "" {
displayName = meta.Name
}
return &actor.Profile{
Platform: "Nostr",
Handle: npub,
DisplayName: displayName,
AvatarURL: meta.Picture,
Bio: meta.About,
URL: "https://njump.me/" + npub,
}
}
func (c *Client) SearchActors(ctx context.Context, query string, limit int) ([]*actor.Profile, error) {
if _, err := resolvePubkey(query); err != nil {
// Not a pubkey we can resolve (e.g. a name query). Treat it as no match
// rather than an error so a cross-platform search isn't failed by a
// non-nostr query.
return nil, nil
}
prof, err := c.GetActor(ctx, query)
if err != nil {
return nil, err
}
return []*actor.Profile{prof}, nil
}
func resolvePubkey(actor string) (nostr.PubKey, error) {
prefix, value, err := nip19.Decode(actor)
if err == nil {
switch prefix {
case "npub":
if pk, ok := value.(nostr.PubKey); ok {
return pk, nil
}
case "nprofile":
if pp, ok := value.(nostr.ProfilePointer); ok {
return pp.PublicKey, nil
}
}
return nostr.PubKey{}, fmt.Errorf("unsupported nostr entity %q", prefix)
}
if len(actor) == 64 {
pk, e := nostr.PubKeyFromHex(actor)
if e != nil {
return nostr.PubKey{}, fmt.Errorf("invalid nostr author %q: %w", actor, e)
}
return pk, nil
}
return nostr.PubKey{}, fmt.Errorf("invalid nostr author %q: %w", actor, err)
}
func resolveEventID(id string) (nostr.ID, error) {
prefix, value, err := nip19.Decode(id)
if err == nil {
switch prefix {
case "note":
if eid, ok := value.(nostr.ID); ok {
return eid, nil
}
case "nevent":
if ep, ok := value.(nostr.EventPointer); ok {
return ep.ID, nil
}
}
return nostr.ID{}, fmt.Errorf("unsupported nostr entity %q", prefix)
}
if len(id) == 64 {
eid, e := nostr.IDFromHex(id)
if e != nil {
return nostr.ID{}, fmt.Errorf("invalid nostr event id %q: %w", id, e)
}
return eid, nil
}
return nostr.ID{}, fmt.Errorf("invalid nostr event id %q: %w", id, err)
}

View file

@ -1,305 +0,0 @@
package nostr
import (
"context"
"strings"
"testing"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
)
func TestNewNostrClientEmptyRelays(t *testing.T) {
_, err := NewNostrClient([]string{})
if err == nil {
t.Fatal("expected error for empty relays")
}
if !strings.Contains(err.Error(), "at least one relay") {
t.Fatalf("expected 'at least one relay' error, got %v", err)
}
}
func TestResolvePubkeyVariants(t *testing.T) {
// Create a valid pubkey for testing using Generate()
sk := nostr.Generate()
pk := sk.Public()
npub := nip19.EncodeNpub(pk)
hex := pk.Hex()
tests := []struct {
name string
input string
wantErr bool
}{
{"npub encoding", npub, false},
{"64-char hex", hex, false},
{"invalid npub prefix", "nsec" + npub[4:], true},
{"too short hex", hex[:32], true},
{"random string", "not-a-key", true},
{"empty string", "", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := resolvePubkey(tc.input)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error for %q, got nil", tc.input)
}
} else {
if err != nil {
t.Fatalf("unexpected error for %q: %v", tc.input, err)
}
}
})
}
}
func TestResolveEventIDVariants(t *testing.T) {
var eid nostr.ID
for i := range eid {
eid[i] = byte(i + 1)
}
hex := eid.Hex()
tests := []struct {
name string
input string
wantErr bool
}{
{"64-char hex", hex, false},
{"too short hex", hex[:32], true},
{"random string", "not-an-id", true},
{"empty string", "", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := resolveEventID(tc.input)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error for %q, got nil", tc.input)
}
} else {
if err != nil {
t.Fatalf("unexpected error for %q: %v", tc.input, err)
}
}
})
}
}
func TestGetPostInvalidID(t *testing.T) {
c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"})
if err != nil {
t.Fatal(err)
}
_, err = c.GetPost(context.Background(), "invalid-id")
if err == nil {
t.Fatal("expected error for invalid event ID")
}
if !strings.Contains(err.Error(), "invalid nostr event id") {
t.Fatalf("expected 'invalid nostr event id' error, got %v", err)
}
}
func TestGetActorInvalidAuthor(t *testing.T) {
c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"})
if err != nil {
t.Fatal(err)
}
_, err = c.GetActor(context.Background(), "invalid-author")
if err == nil {
t.Fatal("expected error for invalid author")
}
if !strings.Contains(err.Error(), "invalid nostr author") {
t.Fatalf("expected 'invalid nostr author' error, got %v", err)
}
}
func TestGetLatestNPostsInvalidAuthor(t *testing.T) {
c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"})
if err != nil {
t.Fatal(err)
}
_, err = c.GetLatestNPosts(context.Background(), "invalid-author", 10)
if err == nil {
t.Fatal("expected error for invalid author")
}
if !strings.Contains(err.Error(), "invalid nostr author") {
t.Fatalf("expected 'invalid nostr author' error, got %v", err)
}
}
func TestGetPostsInTimeframeInvalidAuthor(t *testing.T) {
c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"})
if err != nil {
t.Fatal(err)
}
start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC)
_, err = c.GetPostsInTimeframe(context.Background(), "invalid-author", start, end, 10)
if err == nil {
t.Fatal("expected error for invalid author")
}
if !strings.Contains(err.Error(), "invalid nostr author") {
t.Fatalf("expected 'invalid nostr author' error, got %v", err)
}
}
func TestRelayConnectionFailureReturnsError(t *testing.T) {
// When no relay can be reached, GetLatestNPosts/GetPostsInTimeframe must
// report the connection failure rather than silently returning an empty feed.
c, err := NewNostrClient([]string{
"wss://invalid1.relay.invalid",
"wss://invalid2.relay.invalid",
"wss://invalid3.relay.invalid",
})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
sk := nostr.Generate()
npub := nip19.EncodeNpub(sk.Public())
_, err = c.GetLatestNPosts(ctx, npub, 10)
if err == nil {
t.Fatal("expected error when no relay is reachable")
}
if !strings.Contains(err.Error(), "could not connect to any relay") {
t.Fatalf("expected 'could not connect to any relay' error, got %v", err)
}
start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC)
_, err = c.GetPostsInTimeframe(ctx, npub, start, end, 10)
if err == nil {
t.Fatal("expected error when no relay is reachable")
}
if !strings.Contains(err.Error(), "could not connect to any relay") {
t.Fatalf("expected 'could not connect to any relay' error, got %v", err)
}
}
func TestNewNostrClientInvalidScheme(t *testing.T) {
_, err := NewNostrClient([]string{"http://relay.example"})
if err == nil {
t.Fatal("expected error for non-websocket relay URL")
}
if !strings.Contains(err.Error(), "invalid relay URL") {
t.Fatalf("expected 'invalid relay URL' error, got %v", err)
}
// Blank and duplicate entries are dropped, the scheme is validated.
c, err := NewNostrClient([]string{
"wss://relay.a",
" wss://relay.a ",
"ws://relay.b",
" ",
})
if err != nil {
t.Fatal(err)
}
if len(c.relays) != 2 {
t.Fatalf("expected 2 unique relays after cleaning, got %d: %v", len(c.relays), c.relays)
}
}
func TestGetLatestNPostsZeroOrNegativeLimit(t *testing.T) {
c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"})
if err != nil {
t.Fatal(err)
}
sk := nostr.Generate()
npub := nip19.EncodeNpub(sk.Public())
if posts, err := c.GetLatestNPosts(context.Background(), npub, 0); err != nil || posts != nil {
t.Fatalf("expected nil, nil for zero limit, got %v, %v", posts, err)
}
if posts, err := c.GetLatestNPosts(context.Background(), npub, -5); err != nil || posts != nil {
t.Fatalf("expected nil, nil for negative limit, got %v, %v", posts, err)
}
start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC)
if posts, err := c.GetPostsInTimeframe(context.Background(), npub, start, end, 0); err != nil || posts != nil {
t.Fatalf("expected nil, nil for zero timeframe limit, got %v, %v", posts, err)
}
}
func TestSearchActors(t *testing.T) {
c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"})
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
// A non-key query (e.g. a display name) is a valid no-match, not an error.
results, err := c.SearchActors(ctx, "alice", 1)
if err != nil {
t.Fatalf("expected no error for non-key query, got %v", err)
}
if results != nil {
t.Fatalf("expected nil results for non-key query, got %v", results)
}
// A valid pubkey that fails on every relay must surface the outage.
sk := nostr.Generate()
npub := nip19.EncodeNpub(sk.Public())
if _, err := c.SearchActors(ctx, npub, 1); err == nil {
t.Fatal("expected error when no relay is reachable")
} else if !strings.Contains(err.Error(), "could not connect to any relay") {
t.Fatalf("expected 'could not connect to any relay' error, got %v", err)
}
}
func TestGetPostWithHexID(t *testing.T) {
c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"})
if err != nil {
t.Fatal(err)
}
var eid nostr.ID
for i := range eid {
eid[i] = byte(i + 1)
}
hex := eid.Hex()
_, err = c.GetPost(context.Background(), hex)
if err == nil {
t.Fatal("expected error when relay unavailable")
}
if !strings.Contains(err.Error(), "could not connect to any relay") {
t.Fatalf("expected 'could not connect to any relay' error, got %v", err)
}
}
func TestGetActorWithHexPubkey(t *testing.T) {
c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"})
if err != nil {
t.Fatal(err)
}
sk := nostr.Generate()
hex := sk.Public().Hex()
_, err = c.GetActor(context.Background(), hex)
if err == nil {
t.Fatal("expected error when relay unavailable")
}
// GetActor reports a total relay outage as a connection failure rather than
// pretending the author has no metadata.
if !strings.Contains(err.Error(), "could not connect to any relay") {
t.Fatalf("expected 'could not connect to any relay' error, got %v", err)
}
}

View file

@ -2,232 +2,51 @@ package ollama
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/nacorid/naco-api/internal/clustering"
"github.com/ollama/ollama/api"
)
const (
// embedConcurrency caps how many embedding requests run in parallel. Ollama
// serializes model requests server-side (OLLAMA_NUM_PARALLEL defaults to 1),
// so unbounded fan-out would only add latency and memory for no throughput.
embedConcurrency = 4
// embedRetries and embedRetryBackoff cover transient server-side embedding
// failures (e.g. cold model load, transient 5xx/429).
embedRetries = 2
embedRetryBackoff = 150 * time.Millisecond
// generateRetries and generateRetryBackoff cover transient failures that
// produce no output at all; a truncated stream is never retried because the
// partial response cannot be safely replayed.
generateRetries = 1
generateRetryBackoff = 500 * time.Millisecond
)
type OllamaClient struct {
client api.Client
embeddingModel string
styleModel string
}
func New(host *url.URL, httpClient *http.Client, embeddingModel, styleModel string) *OllamaClient {
client := api.NewClient(host, httpClient)
return &OllamaClient{client: *client, embeddingModel: embeddingModel, styleModel: styleModel}
func New(ctx context.Context, host *url.URL, http *http.Client) *OllamaClient {
client := api.NewClient(host, http)
return &OllamaClient{client: *client}
}
// EmbedTexts computes one embedding per post. Embeddings are returned in the
// same order as posts; callers rely on index correspondence (e.g.
// items[i].Embedding = embeddings[i]) so posts are never skipped. Requests run
// in a bounded worker pool and transient failures are retried with backoff.
func (o *OllamaClient) EmbedTexts(ctx context.Context, posts []clustering.Item) ([][]float64, error) {
if len(posts) == 0 {
return nil, nil
}
embeddings := make([][]float64, len(posts))
// poolCtx is a child of ctx: cancelling it on the first failure must not
// cancel the caller's request context.
poolCtx, cancel := context.WithCancel(ctx)
defer cancel()
var (
mu sync.Mutex
firstErr error
sema = make(chan struct{}, embedConcurrency)
wg sync.WaitGroup
)
for i, post := range posts {
wg.Add(1)
go func(idx int, text string) {
defer wg.Done()
select {
case sema <- struct{}{}:
case <-poolCtx.Done():
return
}
defer func() { <-sema }()
emb, err := o.embedWithRetry(poolCtx, strings.TrimSpace(text))
if err != nil {
mu.Lock()
if firstErr == nil {
firstErr = fmt.Errorf("embedding post %d (%q): %w", idx, text, err)
}
mu.Unlock()
cancel()
return
}
embeddings[idx] = emb
}(i, post.Text)
}
wg.Wait()
if firstErr := atomicErr(&mu, &firstErr); firstErr != nil {
return embeddings, firstErr
}
// Every worker may have exited via the poolCtx.Done() branch without
// recording an error, e.g. when the caller's context is already canceled.
if err := ctx.Err(); err != nil {
return nil, err
}
return embeddings, nil
}
func atomicErr(mu *sync.Mutex, err *error) error {
mu.Lock()
defer mu.Unlock()
return *err
}
func (o *OllamaClient) embedWithRetry(ctx context.Context, text string) ([]float64, error) {
backoff := embedRetryBackoff
var lastErr error
for attempt := 0; attempt <= embedRetries; attempt++ {
if attempt > 0 {
if err := sleepCtx(ctx, backoff); err != nil {
return nil, err
}
backoff *= 2
}
func (o *OllamaClient) EmbedTexts(ctx context.Context, posts []string) ([][]float64, error) {
vectors := make([][]float64, 0, len(posts))
for _, t := range posts {
resp, err := o.client.Embeddings(ctx, &api.EmbeddingRequest{
Model: o.embeddingModel,
Prompt: text,
Model: "nomic-embed-text",
Prompt: t,
})
if err == nil {
return resp.Embedding, nil
}
lastErr = err
if !isTransientErr(err) {
if err != nil {
return nil, err
}
vectors = append(vectors, resp.Embedding)
}
return nil, lastErr
return vectors, nil
}
func (o *OllamaClient) SummarizeStyle(ctx context.Context, posts []clustering.Item) (string, error) {
if len(posts) == 0 {
return "", nil
}
promptParts := make([]string, 0, len(posts))
for _, post := range posts {
if text := strings.TrimSpace(post.Text); text != "" {
promptParts = append(promptParts, text)
}
}
if len(promptParts) == 0 {
return "", fmt.Errorf("no post text to summarize")
}
prompt := "Summarize the user's writing style. Analyze:\n" +
"- tone and emotional register\n" +
"- recurring themes and interests\n" +
"- vocabulary and sentence structure\n" +
"- communication habits\n" +
"- personality traits expressed through writing\n\n" +
"Base your analysis only on the supplied posts. Do not infer sensitive personal attributes.\n\n" +
"Posts:\n\n" +
strings.Join(promptParts, "\n---\n") +
"\n\nStyle summary:"
func (o *OllamaClient) SummarizeStyle(ctx context.Context, posts []string) (string, error) {
prompt := "Summarize writing style, recurring themes, tone, personality traits of this user:\n\n" +
strings.Join(posts, "\n") + "\n\nStyle summary:"
var fullResponse strings.Builder
backoff := generateRetryBackoff
var lastErr error
for attempt := 0; attempt <= generateRetries; attempt++ {
if attempt > 0 {
if err := sleepCtx(ctx, backoff); err != nil {
return "", err
}
}
// Reset between attempts so a failed run never leaks a partial summary.
fullResponse.Reset()
done := false
doneReason := ""
err := o.client.Generate(ctx, &api.GenerateRequest{
Model: o.styleModel,
Model: "mistral",
Prompt: prompt,
}, func(resp api.GenerateResponse) error {
fullResponse.WriteString(resp.Response)
if resp.Done {
done = true
doneReason = resp.DoneReason
}
return nil
})
if err != nil {
lastErr = err
// Only retry transport/5xx failures that produced no output; a
// partial stream must not be replayed.
if !isTransientErr(err) || fullResponse.Len() > 0 {
return "", fmt.Errorf("ollama generate with model %q failed: %w", o.styleModel, err)
}
continue
return "", err
}
// The underlying stream reader can finish without an error when the
// request context is canceled or the connection drops mid-stream (the
// library never inspects bufio.Scanner.Err), so a bare nil from
// Generate does not mean the response is complete.
if ctxErr := ctx.Err(); ctxErr != nil {
return "", ctxErr
}
if !done {
return "", fmt.Errorf("ollama stream for model %q ended before completion", o.styleModel)
}
if doneReason == "length" {
return "", fmt.Errorf("ollama response for model %q was truncated (context limit reached)", o.styleModel)
}
return fullResponse.String(), nil
}
return "", fmt.Errorf("ollama generate with model %q failed after retries: %w", o.styleModel, lastErr)
}
func sleepCtx(ctx context.Context, d time.Duration) error {
select {
case <-time.After(d):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// isTransientErr reports whether err is a retryable server-side failure. Non-2xx
// HTTP errors that carry a StatusCode (429, 5xx) qualify; context and transport
// errors below that never do, so they surface immediately.
func isTransientErr(err error) bool {
var se api.StatusError
if errors.As(err, &se) {
return se.StatusCode == http.StatusTooManyRequests || se.StatusCode >= http.StatusInternalServerError
}
return false
}

View file

@ -1,516 +0,0 @@
package ollama
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/nacorid/naco-api/internal/clustering"
)
// setupTestServer creates a test server and an initialized client.
func setupTestServer(t *testing.T, handler http.Handler) (*httptest.Server, *OllamaClient) {
t.Helper()
server := httptest.NewServer(handler)
host, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("failed to parse test server URL: %v", err)
}
client := New(host, http.DefaultClient, "test-embed", "test-style")
return server, client
}
// ============================================================================
// EmbedTexts Tests
// ============================================================================
func TestEmbedTexts(t *testing.T) {
type embedRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
}
var mu sync.Mutex
var receivedRequests []embedRequest
mux := http.NewServeMux()
mux.HandleFunc("/api/embeddings", func(w http.ResponseWriter, r *http.Request) {
// 1. Validate HTTP Method
if r.Method != http.MethodPost {
t.Errorf("expected method POST, got %s", r.Method)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// 2. Validate Headers
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
t.Errorf("expected Content-Type application/json, got %q", r.Header.Get("Content-Type"))
http.Error(w, "invalid content-type", http.StatusBadRequest)
return
}
// 3. Validate Request Payload
var req embedRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Errorf("failed to decode request body: %v", err)
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if req.Model != "test-embed" {
t.Errorf("expected model 'test-embed', got %q", req.Model)
}
mu.Lock()
receivedRequests = append(receivedRequests, req)
mu.Unlock()
// 4. Return valid response
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"embedding": []float64{0.1, 0.2, 0.3},
})
})
server, client := setupTestServer(t, mux)
defer server.Close()
posts := []clustering.Item{
{ID: "1", Text: "hello", Embedding: nil},
{ID: "2", Text: "world", Embedding: nil},
}
embeddings, err := client.EmbedTexts(context.Background(), posts)
if err != nil {
t.Fatalf("EmbedTexts failed: %v", err)
}
// Verify outgoing requests: ensure both prompts were sent
mu.Lock()
if len(receivedRequests) != 2 {
t.Errorf("expected 2 HTTP requests, got %d", len(receivedRequests))
}
promptsReceived := map[string]bool{}
for _, req := range receivedRequests {
promptsReceived[req.Prompt] = true
}
mu.Unlock()
if !promptsReceived["hello"] || !promptsReceived["world"] {
t.Errorf("expected prompts 'hello' and 'world' to be sent, got: %+v", receivedRequests)
}
// Verify parsed results
if len(embeddings) != 2 {
t.Fatalf("expected 2 embeddings, got %d", len(embeddings))
}
for i, emb := range embeddings {
if len(emb) != 3 {
t.Errorf("embedding %d has wrong dimension: %d", i, len(emb))
}
if emb[0] != 0.1 || emb[1] != 0.2 || emb[2] != 0.3 {
t.Errorf("embedding %d has wrong values: %v", i, emb)
}
}
}
func TestEmbedTexts_EmptyInput(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/embeddings", func(w http.ResponseWriter, r *http.Request) {
t.Error("embeddings endpoint should not be called when input is empty")
http.Error(w, "should not be called", http.StatusInternalServerError)
})
server, client := setupTestServer(t, mux)
defer server.Close()
embeddings, err := client.EmbedTexts(context.Background(), []clustering.Item{})
if err != nil {
t.Fatalf("EmbedTexts with empty input failed: %v", err)
}
if len(embeddings) != 0 {
t.Errorf("expected 0 embeddings, got %d", len(embeddings))
}
}
func TestEmbedTexts_Error(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/embeddings", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error": "model test-embed not found"}`))
})
server, client := setupTestServer(t, mux)
defer server.Close()
posts := []clustering.Item{{ID: "1", Text: "hello"}}
_, err := client.EmbedTexts(context.Background(), posts)
if err == nil {
t.Fatal("expected error from failing server, got nil")
}
}
func TestEmbedTexts_ContextCanceled(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/embeddings", func(w http.ResponseWriter, r *http.Request) {
// Hang until client cancels the request
<-r.Context().Done()
})
server, client := setupTestServer(t, mux)
defer server.Close()
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
posts := []clustering.Item{{ID: "1", Text: "hello"}}
_, err := client.EmbedTexts(ctx, posts)
if err == nil {
t.Fatal("expected error due to canceled context, got nil")
}
}
func TestEmbedTexts_RetryThenSuccess(t *testing.T) {
var requests atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/api/embeddings", func(w http.ResponseWriter, r *http.Request) {
if requests.Add(1) == 1 {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"transient model load failure"}`))
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"embedding": []float64{9.9},
})
})
server, client := setupTestServer(t, mux)
defer server.Close()
posts := []clustering.Item{{ID: "1", Text: "hello"}}
embeddings, err := client.EmbedTexts(context.Background(), posts)
if err != nil {
t.Fatalf("EmbedTexts failed after transient error: %v", err)
}
if len(embeddings) != 1 || len(embeddings[0]) != 1 || embeddings[0][0] != 9.9 {
t.Fatalf("unexpected embeddings after retry: %v", embeddings)
}
if got := requests.Load(); got != 2 {
t.Errorf("expected 2 requests (1 failure + 1 retry), got %d", got)
}
}
func TestEmbedTexts_RetryExhausted(t *testing.T) {
var requests atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/api/embeddings", func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"transient model load failure"}`))
})
server, client := setupTestServer(t, mux)
defer server.Close()
posts := []clustering.Item{{ID: "1", Text: "hello"}}
_, err := client.EmbedTexts(context.Background(), posts)
if err == nil {
t.Fatal("expected error after retries exhausted, got nil")
}
if got := requests.Load(); got != embedRetries+1 {
t.Errorf("expected %d attempts (initial + %d retries), got %d", embedRetries+1, embedRetries, got)
}
}
func TestEmbedTexts_ParallelOrder(t *testing.T) {
// Shake ordering: handler sleeps and varies the embedding by prompt length,
// so a naive (unordered) worker pool would misalign results.
mux := http.NewServeMux()
mux.HandleFunc("/api/embeddings", func(w http.ResponseWriter, r *http.Request) {
var req struct {
Prompt string `json:"prompt"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Errorf("failed to decode request body: %v", err)
http.Error(w, "bad request", http.StatusBadRequest)
return
}
time.Sleep(30 * time.Millisecond)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"embedding": []float64{float64(len(req.Prompt))},
})
})
server, client := setupTestServer(t, mux)
defer server.Close()
posts := make([]clustering.Item, 8)
want := make([]float64, 8)
for i := range posts {
posts[i] = clustering.Item{ID: fmt.Sprintf("%d", i), Text: strings.Repeat("x", i+1)}
want[i] = float64(i + 1)
}
embeddings, err := client.EmbedTexts(context.Background(), posts)
if err != nil {
t.Fatalf("EmbedTexts failed: %v", err)
}
if len(embeddings) != len(posts) {
t.Fatalf("expected %d embeddings, got %d", len(posts), len(embeddings))
}
for i, emb := range embeddings {
if len(emb) != 1 || emb[0] != want[i] {
t.Errorf("embedding %d out of order or wrong value: got %v, want %v", i, emb, want[i])
}
}
}
// ============================================================================
// SummarizeStyle Tests
// ============================================================================
func TestSummarizeStyle(t *testing.T) {
type generateRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
}
var reqBody generateRequest
callCount := 0
mux := http.NewServeMux()
mux.HandleFunc("/api/generate", func(w http.ResponseWriter, r *http.Request) {
callCount++
// 1. Validate HTTP Method
if r.Method != http.MethodPost {
t.Errorf("expected method POST, got %s", r.Method)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// 2. Validate Headers
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
t.Errorf("expected Content-Type application/json, got %q", r.Header.Get("Content-Type"))
http.Error(w, "invalid content-type", http.StatusBadRequest)
return
}
// 3. Validate Request Payload
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
t.Errorf("failed to decode request body: %v", err)
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if reqBody.Model != "test-style" {
t.Errorf("expected model 'test-style', got %q", reqBody.Model)
}
// Ensure the prompt includes input text
if !strings.Contains(reqBody.Prompt, "First post") || !strings.Contains(reqBody.Prompt, "Second post") {
t.Errorf("prompt missing post contents: %q", reqBody.Prompt)
}
// 4. Stream NDJSON Response
flusher, ok := w.(http.Flusher)
if !ok {
t.Error("ResponseWriter is not a Flusher")
return
}
w.Header().Set("Content-Type", "application/x-ndjson")
chunks := []string{
`{"response":"Style summary: "}` + "\n",
`{"response":"concise and "}` + "\n",
`{"response":"clear.","done":true}` + "\n",
}
for _, chunk := range chunks {
_, _ = w.Write([]byte(chunk))
flusher.Flush()
}
})
server, client := setupTestServer(t, mux)
defer server.Close()
posts := []clustering.Item{
{ID: "1", Text: "First post"},
{ID: "2", Text: "Second post"},
}
summary, err := client.SummarizeStyle(context.Background(), posts)
if err != nil {
t.Fatalf("SummarizeStyle failed: %v", err)
}
expected := "Style summary: concise and clear."
if summary != expected {
t.Errorf("got summary %q, want %q", summary, expected)
}
if callCount != 1 {
t.Errorf("expected 1 call to /api/generate, got %d", callCount)
}
}
func TestSummarizeStyle_EmptyInput(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/generate", func(w http.ResponseWriter, r *http.Request) {
t.Error("generate endpoint should not be called when input is empty")
http.Error(w, "should not be called", http.StatusInternalServerError)
})
server, client := setupTestServer(t, mux)
defer server.Close()
summary, err := client.SummarizeStyle(context.Background(), []clustering.Item{})
if err != nil {
t.Fatalf("SummarizeStyle with empty input failed: %v", err)
}
if summary != "" {
t.Errorf("expected empty summary, got %q", summary)
}
}
func TestSummarizeStyle_Error(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/generate", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"internal model failure"}`))
})
server, client := setupTestServer(t, mux)
defer server.Close()
posts := []clustering.Item{{ID: "1", Text: "hello"}}
_, err := client.SummarizeStyle(context.Background(), posts)
if err == nil {
t.Fatal("expected error from failing server, got nil")
}
}
func TestSummarizeStyle_MalformedJSONStream(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/generate", func(w http.ResponseWriter, r *http.Request) {
flusher, _ := w.(http.Flusher)
w.Header().Set("Content-Type", "application/x-ndjson")
// First line is valid JSON, second is corrupt
_, _ = w.Write([]byte(`{"response":"valid part"}` + "\n"))
if flusher != nil {
flusher.Flush()
}
_, _ = w.Write([]byte(`{corrupt_json` + "\n"))
})
server, client := setupTestServer(t, mux)
defer server.Close()
posts := []clustering.Item{{ID: "1", Text: "hello"}}
_, err := client.SummarizeStyle(context.Background(), posts)
if err == nil {
t.Fatal("expected error on malformed JSON stream, got nil")
}
}
func TestSummarizeStyle_ContextCanceled(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/generate", func(w http.ResponseWriter, r *http.Request) {
flusher, _ := w.(http.Flusher)
w.Header().Set("Content-Type", "application/x-ndjson")
// Send initial chunk
_, _ = w.Write([]byte(`{"response":"waiting..."}` + "\n"))
if flusher != nil {
flusher.Flush()
}
// Wait until client context cancels
<-r.Context().Done()
})
server, client := setupTestServer(t, mux)
defer server.Close()
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
posts := []clustering.Item{{ID: "1", Text: "hello"}}
_, err := client.SummarizeStyle(ctx, posts)
if err == nil {
t.Fatal("expected error on canceled context, got nil")
}
}
func TestSummarizeStyle_StreamNeverCompletes(t *testing.T) {
// Streaming ends without a done:true chunk (simulates a dropped connection
// or the http.Client timeout firing mid-generation).
mux := http.NewServeMux()
mux.HandleFunc("/api/generate", func(w http.ResponseWriter, r *http.Request) {
flusher, _ := w.(http.Flusher)
w.Header().Set("Content-Type", "application/x-ndjson")
_, _ = w.Write([]byte(`{"response":"partial answer"}` + "\n"))
if flusher != nil {
flusher.Flush()
}
}) // handler returns without ever writing done:true
server, client := setupTestServer(t, mux)
defer server.Close()
posts := []clustering.Item{{ID: "1", Text: "hello"}}
_, err := client.SummarizeStyle(context.Background(), posts)
if err == nil {
t.Fatal("expected error when stream ends without completion, got nil")
}
if !strings.Contains(err.Error(), "ended before completion") {
t.Errorf("expected 'ended before completion' error, got %v", err)
}
}
func TestSummarizeStyle_LengthTruncated(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/generate", func(w http.ResponseWriter, r *http.Request) {
flusher, _ := w.(http.Flusher)
w.Header().Set("Content-Type", "application/x-ndjson")
_, _ = w.Write([]byte(`{"response":"summary...","done":true,"done_reason":"length"}` + "\n"))
if flusher != nil {
flusher.Flush()
}
})
server, client := setupTestServer(t, mux)
defer server.Close()
posts := []clustering.Item{{ID: "1", Text: "hello"}}
_, err := client.SummarizeStyle(context.Background(), posts)
if err == nil {
t.Fatal("expected error when response is truncated at context limit, got nil")
}
if !strings.Contains(err.Error(), "truncated") {
t.Errorf("expected 'truncated' error, got %v", err)
}
}

View file

@ -32,20 +32,12 @@ func (lm *LoggingMiddleware) OnCall(next mcp.MethodHandler) mcp.MethodHandler {
toolName = callReq.Params.Name
}
if err != nil {
log.WarnWithContext(ctx, "MCP Tool Failed",
"transport", "mcp_native",
"tool", toolName,
"duration", duration,
"error", err.Error(),
)
} else {
log.InfoWithContext(ctx, "MCP Tool Processed",
"transport", "mcp_native",
"tool", toolName,
"duration", duration,
"success", err == nil,
)
}
return result, err
}
return next(ctx, method, request)

View file

@ -1,76 +0,0 @@
package server
import (
"context"
"fmt"
"strings"
"sync"
)
func (s *Server) searchActorsAll(ctx context.Context, query string, limit int) ([]*ActorProfile, error) {
var (
mu sync.Mutex
results []*ActorProfile
wg sync.WaitGroup
errMu sync.Mutex
errs []error
success int
)
call := func(fn func(context.Context, string, int) ([]*ActorProfile, error)) {
defer wg.Done()
r, err := fn(ctx, query, limit)
if err != nil {
errMu.Lock()
errs = append(errs, err)
errMu.Unlock()
return
}
mu.Lock()
results = append(results, r...)
success++
mu.Unlock()
}
wg.Add(3)
go call(s.bskyClient.SearchActors)
go call(s.apClient.SearchActors)
go call(s.nostrClient.SearchActors)
wg.Wait()
if success == 0 {
return nil, fmt.Errorf("all platforms failed: %v", errs)
}
return dedupeActors(results), nil
}
// dedupeActors removes near-duplicate profiles across platforms. Two results are considered
// the same person when they share a normalized handle (the '@' and any instance/host stripped,
// lower-cased) or a normalized display name. The first occurrence wins.
func dedupeActors(in []*ActorProfile) []*ActorProfile {
seen := make(map[string]struct{}, len(in))
out := make([]*ActorProfile, 0, len(in))
for _, p := range in {
key := normalizeHandle(p.Handle)
if key == "" {
key = normalizeHandle(p.DisplayName)
}
if key == "" {
out = append(out, p)
continue
}
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
out = append(out, p)
}
return out
}
func normalizeHandle(h string) string {
h = strings.ToLower(strings.TrimSpace(h))
h = strings.TrimPrefix(h, "@")
// Drop an ActivityPub instance suffix (alice@mastodon.social -> alice).
if at := strings.Index(h, "@"); at >= 0 {
h = h[:at]
}
return h
}

View file

@ -1,966 +0,0 @@
package server
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"math"
"sort"
"strings"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
log "github.com/nacorid/logger"
"github.com/nacorid/naco-api/internal/clustering"
"github.com/nacorid/naco-api/internal/store"
"github.com/nacorid/naco-api/internal/utils"
)
const (
defaultAnalyticsLimit = 50
maxAnalyticsLimit = 200
defaultAnalyticsDays = 30
maxTopicCount = 10
minTopicCount = 3
sentimentBucketDuration = 24 * time.Hour
)
// ---- Analytics request/response helpers ----
func parseAnalyticsTimeRange(startStr, endStr string) (time.Time, time.Time, error) {
now := time.Now().UTC()
end := now
start := now.AddDate(0, 0, -defaultAnalyticsDays)
if endStr != "" {
t, err := time.Parse(time.RFC3339, endStr)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("invalid endTime %q: %w", endStr, err)
}
end = t
}
if startStr != "" {
t, err := time.Parse(time.RFC3339, startStr)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("invalid startTime %q: %w", startStr, err)
}
start = t
}
if start.After(end) {
start, end = end, start
}
return start, end, nil
}
func hashTimeRange(start, end time.Time, limit int) string {
data := fmt.Sprintf("%s|%s|%d", start.Format(time.RFC3339), end.Format(time.RFC3339), limit)
h := sha256.Sum256([]byte(data))
return hex.EncodeToString(h[:])
}
func (s *Server) getAnalyticsCache(ctx context.Context, author, platform, toolName, timeRangeHash string) (json.RawMessage, bool) {
result, err := s.db.GetAnalyticsCache(ctx, store.GetAnalyticsCacheParams{
UserID: author,
Platform: platform,
ToolName: toolName,
TimeRangeHash: timeRangeHash,
})
if err != nil {
return nil, false
}
return result, true
}
func (s *Server) setAnalyticsCache(ctx context.Context, author, platform, toolName, timeRangeHash string, result any, ttl time.Duration) error {
data, err := json.Marshal(result)
if err != nil {
return err
}
expiresAt := time.Now().Add(ttl)
return s.db.UpsertAnalyticsCache(ctx, store.UpsertAnalyticsCacheParams{
UserID: author,
Platform: platform,
ToolName: toolName,
TimeRangeHash: timeRangeHash,
Result: data,
ExpiresAt: expiresAt,
})
}
// ---- Main analytics entry points ----
func (s *Server) getEngagementMetrics(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var args AnalyticsRequest
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
return s.computeAnalytics(ctx, args, "engagementMetrics", s.computeEngagementMetrics)
}
func (s *Server) getPostingPatterns(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var args AnalyticsRequest
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
return s.computeAnalytics(ctx, args, "postingPatterns", s.computePostingPatterns)
}
func (s *Server) getTopicModeling(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var args AnalyticsRequest
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
return s.computeAnalytics(ctx, args, "topicModeling", s.computeTopicModeling)
}
func (s *Server) getSentimentTrends(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var args AnalyticsRequest
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
return s.computeAnalytics(ctx, args, "sentimentTrends", s.computeSentimentTrends)
}
func (s *Server) getEngagementDrivers(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var args AnalyticsRequest
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
return s.computeAnalytics(ctx, args, "engagementDrivers", s.computeEngagementDrivers)
}
type analyticsComputer func(ctx context.Context, args AnalyticsRequest, posts []clustering.Item) (any, error)
func (s *Server) computeAnalytics(ctx context.Context, args AnalyticsRequest, toolName string, compute analyticsComputer) (*mcp.CallToolResult, error) {
if args.Author == "" {
return nil, fmt.Errorf("author must be a valid did or handle")
}
// Normalize args
limit := args.Limit
if limit <= 0 {
limit = defaultAnalyticsLimit
}
if limit > maxAnalyticsLimit {
limit = maxAnalyticsLimit
}
start, end, err := parseAnalyticsTimeRange(args.StartTime, args.EndTime)
if err != nil {
return nil, fmt.Errorf("invalid time range: %w", err)
}
var platforms []string
if args.Platform != "" {
platforms = []string{args.Platform}
} else {
// Twitter is included by default only when the API is enabled: the API
// costs it incurs are passed on to the caller through the upto pricing
// of these tools (see registerTools), so aggregating over it does not
// run the server at a loss. When no bearer token is configured there is
// no Twitter to aggregate over.
platforms = []string{"Bluesky", "ActivityPub", "Nostr"}
if s.twitterEnabled {
platforms = append(platforms, "Twitter")
}
}
timeRangeHash := hashTimeRange(start, end, limit)
// The analytics result cache is keyed by the scope of the computation: a
// single platform when one is requested, or the "all" sentinel when the
// result aggregates across all platforms. This keeps aggregate results from
// being mixed with single-platform results.
cachePlatform := "all"
if args.Platform != "" {
cachePlatform = args.Platform
}
if cached, ok := s.getAnalyticsCache(ctx, args.Author, cachePlatform, toolName, timeRangeHash); ok {
var cachedResult json.RawMessage
_ = json.Unmarshal(cached, &cachedResult)
return s.toToolResult(cachedResult)
}
// Fetch posts from all platforms
var allPosts []clustering.Item
twitterPosts := 0
for _, p := range platforms {
plat, ok := platformFromString(p)
if !ok {
return nil, fmt.Errorf("unsupported platform %q", p)
}
posts, err := s.fetchAnalyticsPosts(ctx, plat, args.Author, start, end, limit)
if err != nil {
log.WarnWithContext(ctx, "failed to fetch posts for analytics", "platform", p, "error", err)
continue
}
allPosts = append(allPosts, posts...)
if plat == Twitter {
twitterPosts += len(posts)
}
}
if len(allPosts) == 0 {
return nil, fmt.Errorf("no posts found for author %s in time range", args.Author)
}
// Compute
result, err := compute(ctx, args, allPosts)
if err != nil {
return nil, err
}
result = setAnalyticsTwitterPostCount(result, twitterPosts)
// Cache the result under the matching scope key.
ttl := s.getCacheTTL(toolName)
_ = s.setAnalyticsCache(ctx, args.Author, cachePlatform, toolName, timeRangeHash, result, ttl)
return s.toToolResult(result)
}
// setAnalyticsTwitterPostCount records how many posts in an analytics result
// were sourced from the Twitter API, so upto-scheme settlement can pass the
// realized Twitter costs on to the caller. Unknown result types are returned
// unchanged.
func setAnalyticsTwitterPostCount(result any, count int) any {
switch v := result.(type) {
case EngagementMetricsResponse:
v.TwitterPostCount = count
return v
case PostingPatternsResponse:
v.TwitterPostCount = count
return v
case TopicModelingResponse:
v.TwitterPostCount = count
return v
case SentimentTrendsResponse:
v.TwitterPostCount = count
return v
case EngagementDriversResponse:
v.TwitterPostCount = count
return v
}
return result
}
func (s *Server) fetchAnalyticsPosts(ctx context.Context, platform platform, author string, start, end time.Time, limit int) ([]clustering.Item, error) {
posts, err := s.fetchPostsInTimeForAnalytics(ctx, platform, author, start, end, limit)
if err != nil {
return nil, err
}
return posts, nil
}
func (s *Server) getCachedAnalyticsPosts(ctx context.Context, author, platform string, start, end time.Time, limit int) ([]clustering.Item, error) {
rows, err := s.db.GetAnalyticsPostsInRange(ctx, store.GetAnalyticsPostsInRangeParams{
UserID: author,
Platform: platform,
PostTimestamp: start,
PostTimestamp_2: end,
Limit: int32(limit),
})
if err != nil {
return nil, err
}
items := make([]clustering.Item, 0, len(rows))
for _, r := range rows {
items = append(items, clustering.Item{
ID: r.PostID,
Text: r.PostText,
Timestamp: r.PostTimestamp,
Likes: r.Likes,
Reposts: r.Reposts,
Replies: r.Replies,
Quotes: r.Quotes,
})
}
return items, nil
}
func (s *Server) persistAnalyticsPosts(ctx context.Context, author, platform string, posts []clustering.Item) error {
for _, p := range posts {
if p.ID == "" {
continue
}
if err := s.db.UpsertAnalyticsPosts(ctx, store.UpsertAnalyticsPostsParams{
UserID: author,
Platform: platform,
PostID: p.ID,
PostText: p.Text,
PostTimestamp: p.Timestamp,
Likes: p.Likes,
Reposts: p.Reposts,
Replies: p.Replies,
Quotes: p.Quotes,
}); err != nil {
return err
}
}
return nil
}
func (s *Server) fetchPostsInTimeForAnalytics(ctx context.Context, platform platform, author string, start, end time.Time, limit int) ([]clustering.Item, error) {
var items []clustering.Item
switch platform {
case Bluesky:
res, err := s.bskyClient.GetPostsInTimeframe(ctx, author, start, end, limit)
if err != nil {
return nil, err
}
items = blueskyFeedViewsToAnalyticsItems(res)
case ActivityPub:
res, err := s.apClient.GetPostsInTimeframe(ctx, author, start, end, limit)
if err != nil {
return nil, err
}
items = apPostsToAnalyticsItems(res)
case Nostr:
res, err := s.nostrClient.GetPostsInTimeframe(ctx, author, start, end, limit)
if err != nil {
return nil, err
}
items = nostrPostsToAnalyticsItems(res)
case Twitter:
// Twitter API calls are expensive (paid). Reuse persisted post data and
// only call the API for time ranges that are not yet covered by the
// cache, using a coverage watermark to guarantee broad coverage.
fetched, err := s.fetchTwitterAnalyticsPosts(ctx, author, start, end, limit)
if err != nil {
return nil, err
}
items = append(items, fetched...)
}
return items, nil
}
// fetchTwitterAnalyticsPosts returns posts in [start, end] for the author,
// fetching from the Twitter API only the sub-ranges not already covered by the
// persistent post cache, and storing any newly fetched posts for future use.
//
// Coverage is tracked as a set of discrete intervals, so disjoint fetched
// ranges (e.g. [Dec 1, Dec 15] and [Dec 20, Dec 31]) are not merged into one
// continuous window. A later request that lands in the never-fetched gap
// (e.g. [Dec 15, Dec 20]) is therefore fetched from the API rather than being
// wrongly served from an incomplete cache.
func (s *Server) fetchTwitterAnalyticsPosts(ctx context.Context, author string, start, end time.Time, limit int) ([]clustering.Item, error) {
covered, err := s.getAnalyticsCoverageRanges(ctx, author, "Twitter")
if err != nil {
return nil, err
}
// If the entire requested range falls within already-covered intervals,
// serve from the cache without incurring a paid Twitter request.
if len(uncoveredRanges(start, end, covered)) == 0 {
return s.getCachedAnalyticsPosts(ctx, author, "Twitter", start, end, limit)
}
// Determine the sub-ranges of [start, end] not yet covered, and fetch each
// from the API. Fetching per uncovered range keeps costs bounded while
// expanding coverage on every call.
ranges := uncoveredRanges(start, end, covered)
var fetched []clustering.Item
for _, r := range ranges {
posts, err := s.fetchTwitterFromAPI(ctx, author, r.start, r.end, limit)
if err != nil {
return nil, err
}
fetched = append(fetched, posts...)
}
// Persist newly fetched posts permanently so repeat calls never re-pay.
if len(fetched) > 0 {
if err := s.persistAnalyticsPosts(ctx, author, "Twitter", fetched); err != nil {
log.WarnWithContext(ctx, "failed to persist analytics posts", "platform", "Twitter", "error", err)
}
}
// Record the newly fetched intervals as covered so future calls can reuse
// them without hitting the paid API.
for _, r := range ranges {
if err := s.addAnalyticsCoverageRange(ctx, author, "Twitter", r.start, r.end); err != nil {
log.WarnWithContext(ctx, "failed to add analytics coverage range", "platform", "Twitter", "range", r, "error", err)
}
}
// Return the full set: posts from cache plus anything newly fetched, merged
// and de-duplicated by post ID.
return s.mergeAnalyticsPosts(ctx, author, start, end, limit, fetched)
}
type timeRange struct {
start, end time.Time
}
// uncoveredRanges returns the sub-ranges of [start, end] that are not covered
// by any of the given intervals. Intervals are assumed sorted by their start,
// and no coverage (empty slice) yields the whole range as uncovered.
func uncoveredRanges(start, end time.Time, covered []timeRange) []timeRange {
var ranges []timeRange
cursor := start
for _, c := range covered {
if c.end.Before(cursor) || c.end.Equal(cursor) {
continue
}
if c.start.After(end) {
break
}
if c.start.After(cursor) {
ranges = append(ranges, timeRange{start: cursor, end: c.start})
}
if c.end.After(cursor) {
cursor = c.end
}
}
if cursor.Before(end) {
ranges = append(ranges, timeRange{start: cursor, end: end})
}
return ranges
}
func (s *Server) getAnalyticsCoverageRanges(ctx context.Context, author, platform string) ([]timeRange, error) {
rows, err := s.db.GetAnalyticsCoverageRanges(ctx, store.GetAnalyticsCoverageRangesParams{
UserID: author,
Platform: platform,
})
if err != nil {
return nil, err
}
ranges := make([]timeRange, 0, len(rows))
for _, r := range rows {
ranges = append(ranges, timeRange{start: r.CoveredStart, end: r.CoveredEnd})
}
return ranges, nil
}
func (s *Server) addAnalyticsCoverageRange(ctx context.Context, author, platform string, start, end time.Time) error {
return s.db.InsertAnalyticsCoverageRange(ctx, store.InsertAnalyticsCoverageRangeParams{
UserID: author,
Platform: platform,
CoveredStart: start,
CoveredEnd: end,
})
}
func (s *Server) fetchTwitterFromAPI(ctx context.Context, author string, start, end time.Time, limit int) ([]clustering.Item, error) {
res, err := s.twitterClient.GetPostsInTimeframe(ctx, author, start, end, limit)
if err != nil {
return nil, err
}
return twitterPostsToAnalyticsItems(res), nil
}
// mergeAnalyticsPosts combines cached posts in [start, end] with newly fetched
// posts, de-duplicating by post ID and capping the result at limit. The cached
// query and each fetched sub-range individually honor limit, so the merged set
// can otherwise exceed it when the range is split into multiple fetch calls.
func (s *Server) mergeAnalyticsPosts(ctx context.Context, author string, start, end time.Time, limit int, fetched []clustering.Item) ([]clustering.Item, error) {
merged := make([]clustering.Item, 0, len(fetched))
seen := make(map[string]struct{}, len(fetched))
for _, p := range fetched {
if len(merged) >= limit {
break
}
if _, ok := seen[p.ID]; ok {
continue
}
seen[p.ID] = struct{}{}
merged = append(merged, p)
}
cached, err := s.getCachedAnalyticsPosts(ctx, author, "Twitter", start, end, limit)
if err != nil {
return merged, nil
}
for _, p := range cached {
if len(merged) >= limit {
break
}
if _, ok := seen[p.ID]; ok {
continue
}
seen[p.ID] = struct{}{}
merged = append(merged, p)
}
return merged, nil
}
func (s *Server) toToolResult(result any) (*mcp.CallToolResult, error) {
data, err := json.Marshal(result)
if err != nil {
return nil, err
}
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: string(data)},
},
}, nil
}
func (s *Server) getCacheTTL(toolName string) time.Duration {
switch toolName {
case "postingPatterns":
return s.analyticsTTLPatterns
case "topicModeling":
return s.analyticsTTLTopics
case "sentimentTrends":
return s.analyticsTTLSentiment
case "engagementDrivers":
return s.analyticsTTLDrivers
case "engagementMetrics":
fallthrough
default:
return s.analyticsTTLEngagement
}
}
// ---- Computation implementations ----
func (s *Server) computeEngagementMetrics(ctx context.Context, args AnalyticsRequest, posts []clustering.Item) (any, error) {
var metrics []EngagementMetricPoint
for _, post := range posts {
total := post.Likes + post.Reposts + post.Replies + post.Quotes
// Rate is a simple relative measure (interactions per post). Without a
// follower/fan base to normalize against, report total interactions.
metrics = append(metrics, EngagementMetricPoint{
PostID: post.ID,
Timestamp: post.Timestamp.Format(time.RFC3339),
Likes: post.Likes,
Reposts: post.Reposts,
Replies: post.Replies,
Quotes: post.Quotes,
EngagementRate: float64(total),
})
}
return EngagementMetricsResponse{
Author: args.Author,
Metrics: metrics,
}, nil
}
func (s *Server) computePostingPatterns(ctx context.Context, args AnalyticsRequest, posts []clustering.Item) (any, error) {
// Heatmap: 24 hours x 7 days
heatmap := make([][]int, 24)
for i := range heatmap {
heatmap[i] = make([]int, 7)
}
dayCounts := make(map[int]int)
hourCounts := make(map[int]int)
dateSet := make(map[string]bool)
for _, post := range posts {
t := post.Timestamp
if t.IsZero() {
continue
}
hour := t.Hour()
weekday := int(t.Weekday())
heatmap[hour][weekday]++
hourCounts[hour]++
dayCounts[weekday]++
dateSet[t.Format("2006-01-02")] = true
}
// Find peak hours (top 3)
type hourCount struct {
Hour int
Count int
}
var hours []hourCount
for h, c := range hourCounts {
hours = append(hours, hourCount{h, c})
}
sort.Slice(hours, func(i, j int) bool { return hours[i].Count > hours[j].Count })
peakHours := make([]int, 0, 3)
for i := 0; i < len(hours) && i < 3; i++ {
peakHours = append(peakHours, hours[i].Hour)
}
// Find peak days (top 3)
type dayCount struct {
Day int
Count int
}
var days []dayCount
for d, c := range dayCounts {
days = append(days, dayCount{d, c})
}
sort.Slice(days, func(i, j int) bool { return days[i].Count > days[j].Count })
peakDays := make([]int, 0, 3)
for i := 0; i < len(days) && i < 3; i++ {
peakDays = append(peakDays, days[i].Day)
}
totalPosts := len(posts)
postingDays := len(dateSet)
avgPostsPerDay := 0.0
if postingDays > 0 {
avgPostsPerDay = float64(totalPosts) / float64(postingDays)
}
return PostingPatternsResponse{
Author: args.Author,
Heatmap: heatmap,
PeakHours: peakHours,
PeakDays: peakDays,
AvgPostsPerDay: avgPostsPerDay,
TotalPosts: totalPosts,
PostingDays: postingDays,
}, nil
}
func (s *Server) computeTopicModeling(ctx context.Context, args AnalyticsRequest, posts []clustering.Item) (any, error) {
topics, _, err := s.clusterPosts(ctx, posts)
if err != nil {
return nil, err
}
return TopicModelingResponse{
Author: args.Author,
Topics: topics,
}, nil
}
// clusterPosts runs topic clustering over the posts and returns both the
// resulting topics and a map from post ID to the label of the topic the post
// belongs to. The membership map lets callers correlate topics with per-post
// attributes (e.g. engagement) without repeating the clustering.
func (s *Server) clusterPosts(ctx context.Context, posts []clustering.Item) ([]TopicCluster, map[string]string, error) {
if len(posts) < minTopicCount {
return []TopicCluster{}, map[string]string{}, nil
}
items := posts
embeddings, err := s.ollama.EmbedTexts(ctx, items)
if err != nil {
return nil, nil, fmt.Errorf("failed to compute embeddings: %w", err)
}
for i := range items {
items[i].Embedding = embeddings[i]
}
topicCount := maxTopicCount
if len(items) < maxTopicCount {
topicCount = len(items)
}
if topicCount < minTopicCount {
topicCount = minTopicCount
}
results, err := clustering.KMeans(items, topicCount, 50, 42)
if err != nil {
return nil, nil, fmt.Errorf("kmeans failed: %w", err)
}
membership := make(map[string]string)
labelByCluster := make(map[int]string)
var topics []TopicCluster
for _, cluster := range results {
if len(cluster.Items) == 0 {
continue
}
var samples []string
for _, item := range cluster.Items {
if len(samples) >= 3 {
break
}
samples = append(samples, item.Text)
}
// Generate label using Ollama
label := s.generateTopicLabel(ctx, samples)
labelByCluster[cluster.Cluster] = label
keywords := s.extractKeywords(cluster.Items)
topics = append(topics, TopicCluster{
TopicID: cluster.Cluster,
Label: label,
Keywords: keywords,
PostCount: len(cluster.Items),
Percentage: float64(len(cluster.Items)) / float64(len(items)) * 100,
SamplePosts: samples,
})
}
// Map each post to the label of the cluster it belongs to.
for _, cluster := range results {
label := labelByCluster[cluster.Cluster]
for _, item := range cluster.Items {
if item.ID != "" {
membership[item.ID] = label
}
}
}
// Sort by post count descending
sort.Slice(topics, func(i, j int) bool { return topics[i].PostCount > topics[j].PostCount })
return topics, membership, nil
}
func (s *Server) computeSentimentTrends(ctx context.Context, args AnalyticsRequest, posts []clustering.Item) (any, error) {
// Bucket posts by day
bucketMap := make(map[string][]string)
for _, post := range posts {
if post.Timestamp.IsZero() {
continue
}
bucket := post.Timestamp.Truncate(sentimentBucketDuration).Format(time.RFC3339)
bucketMap[bucket] = append(bucketMap[bucket], post.Text)
}
var timeSeries []SentimentPoint
overallPos, overallNeg, overallNeu := 0.0, 0.0, 0.0
emotionCounts := make(map[string]int)
var shifts []SentimentShift
var prevSentiment string
var prevScores [3]float64
// Only posts with a timestamp are bucketed and contribute to sentiment, so
// the overall ratios are normalized by that same count (not the total
// number of fetched posts, some of which may lack a timestamp).
timestampedPosts := 0
for _, post := range posts {
if !post.Timestamp.IsZero() {
timestampedPosts++
}
}
// Process buckets in chronological order so sentiment shifts are detected correctly.
buckets := make([]string, 0, len(bucketMap))
for b := range bucketMap {
buckets = append(buckets, b)
}
sort.Strings(buckets)
for _, bucket := range buckets {
texts := bucketMap[bucket]
pos, neg, neu, emotions := s.analyzeSentiment(ctx, texts)
timeSeries = append(timeSeries, SentimentPoint{
Timestamp: bucket,
Positive: pos,
Negative: neg,
Neutral: neu,
PostCount: len(texts),
})
overallPos += pos * float64(len(texts))
overallNeg += neg * float64(len(texts))
overallNeu += neu * float64(len(texts))
for e, c := range emotions {
emotionCounts[e] += c
}
// Detect sentiment shifts
dominant := maxSentiment(pos, neg, neu)
if prevSentiment != "" && dominant != prevSentiment {
mag := math.Abs(pos-prevScores[0]) + math.Abs(neg-prevScores[1]) + math.Abs(neu-prevScores[2])
shifts = append(shifts, SentimentShift{
Timestamp: bucket,
FromSentiment: prevSentiment,
ToSentiment: dominant,
Magnitude: mag,
})
}
prevSentiment = dominant
prevScores = [3]float64{pos, neg, neu}
}
totalPosts := float64(timestampedPosts)
if totalPosts > 0 {
overallPos /= totalPosts
overallNeg /= totalPosts
overallNeu /= totalPosts
}
// Top emotions
var topEmotions []EmotionScore
type emoCount struct {
Emotion string
Count int
}
var emos []emoCount
for e, c := range emotionCounts {
emos = append(emos, emoCount{e, c})
}
sort.Slice(emos, func(i, j int) bool { return emos[i].Count > emos[j].Count })
for i := 0; i < len(emos) && i < 5; i++ {
topEmotions = append(topEmotions, EmotionScore{
Emotion: emos[i].Emotion,
Score: float64(emos[i].Count) / totalPosts,
})
}
return SentimentTrendsResponse{
Author: args.Author,
TimeSeries: timeSeries,
OverallSentiment: SentimentSummary{
Positive: overallPos,
Negative: overallNeg,
Neutral: overallNeu,
},
DominantEmotions: topEmotions,
SentimentShifts: shifts,
}, nil
}
func (s *Server) computeEngagementDrivers(ctx context.Context, args AnalyticsRequest, posts []clustering.Item) (any, error) {
// Topic modeling first, getting post->topic membership for correlation.
topics, membership, err := s.clusterPosts(ctx, posts)
if err != nil {
return nil, err
}
// Group engagement by topic label. Each post contributes its total
// engagement (likes + reposts + replies + quotes) to its topic.
topicEngagement := make(map[string][]float64)
for _, post := range posts {
label, ok := membership[post.ID]
if !ok {
continue
}
total := float64(post.Likes + post.Reposts + post.Replies + post.Quotes)
topicEngagement[label] = append(topicEngagement[label], total)
}
// Baseline is the mean engagement across all posts, for comparison.
var allEngagement []float64
for _, post := range posts {
allEngagement = append(allEngagement, float64(post.Likes+post.Reposts+post.Replies+post.Quotes))
}
baseline := mean(allEngagement)
var drivers []EngagementDriver
for _, topic := range topics {
values := topicEngagement[topic.Label]
topicMean := mean(values)
correlation := 0.0
if denom := topicMean + baseline; denom > 0 {
correlation = (topicMean - baseline) / denom
if correlation > 1 {
correlation = 1
} else if correlation < -1 {
correlation = -1
}
}
drivers = append(drivers, EngagementDriver{
Topic: topic.Label,
AvgEngagement: topicMean,
Correlation: correlation,
PostCount: topic.PostCount,
SamplePosts: topic.SamplePosts[:min(2, len(topic.SamplePosts))],
})
}
sort.Slice(drivers, func(i, j int) bool {
return math.Abs(drivers[i].Correlation) > math.Abs(drivers[j].Correlation)
})
return EngagementDriversResponse{
Author: args.Author,
Drivers: drivers,
}, nil
}
func mean(values []float64) float64 {
if len(values) == 0 {
return 0
}
sum := 0.0
for _, v := range values {
sum += v
}
return sum / float64(len(values))
}
// ---- Helper functions ----
func (s *Server) generateTopicLabel(ctx context.Context, samples []string) string {
if len(samples) == 0 {
return "General"
}
prompt := fmt.Sprintf(`Given these sample posts, provide a concise 1-3 word topic label:
Posts:
%s
Label only (no explanation):`, strings.Join(samples, "\n---\n"))
resp, err := s.ollama.SummarizeStyle(ctx, []clustering.Item{{Text: prompt}})
if err != nil {
return "General"
}
return strings.TrimSpace(resp)
}
func (s *Server) extractKeywords(items []clustering.Item) []string {
var allText []string
for _, item := range items {
allText = append(allText, item.Text)
}
return utils.TopWords(allText, make(map[string]struct{}), 5)
}
func (s *Server) analyzeSentiment(ctx context.Context, texts []string) (pos, neg, neu float64, emotions map[string]int) {
if len(texts) == 0 {
return 0, 0, 0, nil
}
// Use Ollama for sentiment analysis
prompt := fmt.Sprintf(`Analyze sentiment and emotions in these texts. Return JSON only:
{
"positive": 0.0-1.0,
"negative": 0.0-1.0,
"neutral": 0.0-1.0,
"emotions": {"emotion": count}
}
Texts:
%s`, strings.Join(texts, "\n---\n"))
resp, err := s.ollama.SummarizeStyle(ctx, []clustering.Item{{Text: prompt}})
if err != nil {
return 0.33, 0.33, 0.34, nil
}
var result struct {
Positive float64 `json:"positive"`
Negative float64 `json:"negative"`
Neutral float64 `json:"neutral"`
Emotions map[string]int `json:"emotions"`
}
_ = json.Unmarshal([]byte(resp), &result)
// Normalize
sum := result.Positive + result.Negative + result.Neutral
if sum > 0 {
result.Positive /= sum
result.Negative /= sum
result.Neutral /= sum
}
return result.Positive, result.Negative, result.Neutral, result.Emotions
}
func maxSentiment(pos, neg, neu float64) string {
if pos >= neg && pos >= neu {
return "positive"
}
if neg >= pos && neg >= neu {
return "negative"
}
return "neutral"
}
func int64Val(p *int64) int64 {
if p == nil {
return 0
}
return *p
}

View file

@ -1,318 +0,0 @@
package server
import (
"context"
"fmt"
"strings"
"testing"
"time"
"github.com/nacorid/naco-api/internal/activitypub"
"github.com/nacorid/naco-api/internal/clustering"
"github.com/nacorid/naco-api/internal/twitter"
)
var errSome = fmt.Errorf("some error")
func analyticsAPPosts() *fakeAP {
base := time.Date(2024, 1, 10, 12, 0, 0, 0, time.UTC)
return &fakeAP{posts: []*activitypub.Post{
{ID: "p1", Author: "@a@mastodon.social", Content: "first post", CreatedAt: base},
{ID: "p2", Author: "@a@mastodon.social", Content: "second post", CreatedAt: base.Add(time.Hour)},
}}
}
// ---- validation ----
func TestAnalyticsEmptyAuthor(t *testing.T) {
s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
if _, err := callTool(s, "getEngagementMetrics", AnalyticsRequest{Author: ""}); err == nil || !strings.Contains(err.Error(), "author") {
t.Fatalf("expected author error, got %v", err)
}
}
func TestAnalyticsInvalidTimeRange(t *testing.T) {
s := newTestServer(&fakeBluesky{}, analyticsAPPosts(), &fakeNostr{}, &fakeTwitter{})
_, err := callTool(s, "getEngagementMetrics", AnalyticsRequest{
Author: "@a@mastodon.social",
StartTime: "not-a-time",
EndTime: "2024-12-31T00:00:00Z",
Platform: "ActivityPub",
})
if err == nil || !strings.Contains(err.Error(), "time range") {
t.Fatalf("expected time range error, got %v", err)
}
}
func TestAnalyticsUnknownPlatform(t *testing.T) {
s := newTestServer(&fakeBluesky{}, analyticsAPPosts(), &fakeNostr{}, &fakeTwitter{})
_, err := callTool(s, "getEngagementMetrics", AnalyticsRequest{
Author: "@a@mastodon.social",
Platform: "MySpace",
})
if err == nil || !strings.Contains(err.Error(), `unsupported platform "MySpace"`) {
t.Fatalf("expected unsupported platform error, got %v", err)
}
}
func TestAnalyticsNoPostsFound(t *testing.T) {
ap := &fakeAP{} // no posts
s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{})
_, err := callTool(s, "getEngagementMetrics", AnalyticsRequest{Author: "@a@mastodon.social", Platform: "ActivityPub"})
if err == nil || !strings.Contains(err.Error(), "no posts found") {
t.Fatalf("expected no-posts error, got %v", err)
}
}
// ---- success + caching ----
func TestAnalyticsEngagementMetricsAndCache(t *testing.T) {
ap := analyticsAPPosts()
st := newFakeStore()
s := newServerWith(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{})
args := AnalyticsRequest{
Author: "@a@mastodon.social",
StartTime: "2024-01-01T00:00:00Z",
EndTime: "2024-12-31T00:00:00Z",
Platform: "ActivityPub",
}
res, err := callTool(s, "getEngagementMetrics", args)
if err != nil {
t.Fatalf("getEngagementMetrics error: %v", err)
}
var out EngagementMetricsResponse
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if out.Author != "@a@mastodon.social" || len(out.Metrics) != 2 {
t.Fatalf("unexpected metrics: author=%q count=%d", out.Author, len(out.Metrics))
}
// Cached result is served without hitting the backend again.
if _, err := callTool(s, "getEngagementMetrics", args); err != nil {
t.Fatalf("second call error: %v", err)
}
if ap.calls != 1 {
t.Fatalf("expected backend called once due to cache, got %d", ap.calls)
}
}
func TestAnalyticsSinglePlatformScopeDoesNotCrossPollinate(t *testing.T) {
// Engagement over ActivityPub should never be served from a cache written
// under the aggregate "all" scope.
ap := analyticsAPPosts()
st := newFakeStore()
s := newServerWith(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{})
// Warm cache under "all" scope (no platform) first.
args := AnalyticsRequest{
Author: "@a@mastodon.social",
StartTime: "2024-01-05T00:00:00Z",
EndTime: "2024-01-15T00:00:00Z",
}
if _, err := callTool(s, "getEngagementMetrics", args); err != nil {
t.Fatalf("aggregate call error: %v", err)
}
// Now request a single-platform scope; it must still fetch from the backend.
spArgs := args
spArgs.Platform = "ActivityPub"
if _, err := callTool(s, "getEngagementMetrics", spArgs); err != nil {
t.Fatalf("scoped call error: %v", err)
}
if ap.calls != 2 {
t.Fatalf("expected scoped request to re-fetch (distinct cache scope), got %d calls", ap.calls)
}
}
func TestAnalyticsPartialPlatformFailureTolerated(t *testing.T) {
// One platform failing must not abort the whole aggregation.
bsky := &fakeBluesky{err: errSome}
ap := analyticsAPPosts()
st := newFakeStore()
s := newServerWith(bsky, ap, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{})
_, err := callTool(s, "getEngagementMetrics", AnalyticsRequest{
Author: "@a@mastodon.social",
StartTime: "2024-01-01T00:00:00Z",
EndTime: "2024-12-31T00:00:00Z",
})
if err != nil {
t.Fatalf("expected partial failure to be tolerated, got %v", err)
}
}
// TestAnalyticsTwitterPostCount verifies the response reports how many posts
// were sourced from the Twitter API, so upto-scheme settlement can charge the
// realized Twitter API costs.
func TestAnalyticsTwitterPostCount(t *testing.T) {
tw := &fakeTwitter{posts: []*twitter.Post{
{ID: "t1", Content: "first tweet", CreatedAt: time.Date(2024, 1, 11, 12, 0, 0, 0, time.UTC)},
{ID: "t2", Content: "second tweet", CreatedAt: time.Date(2024, 1, 12, 12, 0, 0, 0, time.UTC)},
{ID: "t3", Content: "third tweet", CreatedAt: time.Date(2024, 1, 13, 12, 0, 0, 0, time.UTC)},
}}
st := newFakeStore()
s := newServerWith(&fakeBluesky{}, analyticsAPPosts(), &fakeNostr{}, tw, st, &fakeOllama{})
res, err := callTool(s, "getEngagementMetrics", AnalyticsRequest{
Author: "@alice",
StartTime: "2024-01-01T00:00:00Z",
EndTime: "2024-12-31T00:00:00Z",
Platform: "Twitter",
})
if err != nil {
t.Fatalf("getEngagementMetrics error: %v", err)
}
var out EngagementMetricsResponse
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if out.TwitterPostCount != 3 {
t.Fatalf("expected TwitterPostCount 3, got %d", out.TwitterPostCount)
}
// A non-Twitter platform reports 0 and the default aggregate excludes
// Twitter when the API is not enabled (twitterEnabled=false in tests).
res, err = callTool(s, "getEngagementMetrics", AnalyticsRequest{
Author: "@a@mastodon.social",
StartTime: "2024-01-01T00:00:00Z",
EndTime: "2024-12-31T00:00:00Z",
Platform: "ActivityPub",
})
if err != nil {
t.Fatalf("getEngagementMetrics error: %v", err)
}
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if out.TwitterPostCount != 0 {
t.Fatalf("expected TwitterPostCount 0 for ActivityPub, got %d", out.TwitterPostCount)
}
}
// ---- pure compute functions ----
func TestComputePostingPatterns(t *testing.T) {
s := &Server{}
args := AnalyticsRequest{Author: "a"}
mon := time.Date(2024, 1, 1, 9, 0, 0, 0, time.UTC) // Monday 09:00
posts := []clustering.Item{
{ID: "1", Timestamp: mon},
{ID: "2", Timestamp: mon.Add(30 * time.Minute)}, // same hour 9
{ID: "3"}, // zero timestamp: skipped
}
res, err := s.computePostingPatterns(context.Background(), args, posts)
if err != nil {
t.Fatalf("computePostingPatterns error: %v", err)
}
out := res.(PostingPatternsResponse)
// Two timestamped posts across one distinct day.
if out.TotalPosts != 3 {
t.Fatalf("expected TotalPosts 3, got %d", out.TotalPosts)
}
if out.PostingDays != 1 {
t.Fatalf("expected PostingDays 1, got %d", out.PostingDays)
}
if out.AvgPostsPerDay != 3 {
t.Fatalf("expected AvgPostsPerDay 3, got %f", out.AvgPostsPerDay)
}
if len(out.PeakHours) == 0 || out.PeakHours[0] != 9 {
t.Fatalf("expected peak hour 9, got %+v", out.PeakHours)
}
}
func TestComputePostingPatternsNoTimestamps(t *testing.T) {
s := &Server{}
posts := []clustering.Item{{ID: "1"}, {ID: "2"}}
res, err := s.computePostingPatterns(context.Background(), AnalyticsRequest{}, posts)
if err != nil {
t.Fatalf("computePostingPatterns error: %v", err)
}
out := res.(PostingPatternsResponse)
// Division by zero avoided: posting days of 0 leaves AvgPostsPerDay at 0.
if out.PostingDays != 0 || out.AvgPostsPerDay != 0 {
t.Fatalf("expected 0 posting days / 0 avg, got %d / %f", out.PostingDays, out.AvgPostsPerDay)
}
if out.TotalPosts != 2 {
t.Fatalf("expected TotalPosts 2, got %d", out.TotalPosts)
}
}
func TestComputeEngagementMetrics(t *testing.T) {
s := &Server{}
res, err := s.computeEngagementMetrics(context.Background(), AnalyticsRequest{Author: "a"}, []clustering.Item{
{ID: "p", Timestamp: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), Likes: 2, Reposts: 3, Replies: 1, Quotes: 0},
})
if err != nil {
t.Fatalf("computeEngagementMetrics error: %v", err)
}
out := res.(EngagementMetricsResponse)
if len(out.Metrics) != 1 {
t.Fatalf("expected 1 metric, got %d", len(out.Metrics))
}
m := out.Metrics[0]
if m.EngagementRate != 6 {
t.Fatalf("expected engagement rate 6, got %f", m.EngagementRate)
}
}
func TestComputeSentimentTrends(t *testing.T) {
oll := &fakeOllama{summaries: []string{`{"positive":0.8,"negative":0.1,"neutral":0.1,"emotions":{"joy":1}}`}}
s := &Server{ollama: oll}
day := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
res, err := s.computeSentimentTrends(context.Background(), AnalyticsRequest{Author: "a"}, []clustering.Item{
{ID: "a", Text: "great day", Timestamp: day},
{ID: "b", Text: "wonderful", Timestamp: day.Add(2 * time.Hour)},
{ID: "c", Text: "no ts"}, // zero timestamp, excluded from overall normalization
})
if err != nil {
t.Fatalf("computeSentimentTrends error: %v", err)
}
out := res.(SentimentTrendsResponse)
if len(out.TimeSeries) != 1 {
t.Fatalf("expected 1 daily bucket, got %d", len(out.TimeSeries))
}
if out.TimeSeries[0].PostCount != 2 {
t.Fatalf("expected bucket post count 2, got %d", out.TimeSeries[0].PostCount)
}
// Overall normalized by timestamped posts (2), not total (3).
if out.OverallSentiment.Positive != 0.8 {
t.Fatalf("expected overall positive 0.8, got %f", out.OverallSentiment.Positive)
}
if oll.summCalls == 0 {
t.Fatal("expected sentiment ollama call")
}
}
func TestComputeSentimentTrendsNoTimestampedPosts(t *testing.T) {
s := &Server{ollama: &fakeOllama{summaries: []string{`{"positive":1,"negative":0,"neutral":0}`}}}
res, err := s.computeSentimentTrends(context.Background(), AnalyticsRequest{}, []clustering.Item{
{ID: "a", Text: "x"}, // no timestamp
})
if err != nil {
t.Fatalf("computeSentimentTrends error: %v", err)
}
out := res.(SentimentTrendsResponse)
// Division by zero avoided: overall stays 0 when there are no timestamped posts.
if out.OverallSentiment.Positive != 0 || out.OverallSentiment.Negative != 0 || out.OverallSentiment.Neutral != 0 {
t.Fatalf("expected zero overall sentiment, got %+v", out.OverallSentiment)
}
}
func TestAnalyzeSentimentFallsBackOnOllamaError(t *testing.T) {
s := &Server{ollama: &fakeOllama{summErr: errSome}}
pos, neg, neu, _ := s.analyzeSentiment(context.Background(), []string{"text"})
if pos == 0 || neg == 0 || neu == 0 {
t.Fatalf("expected non-zero fallback sentiment, got %f/%f/%f", pos, neg, neu)
}
}
func TestGenerateTopicLabelFallsBackToGeneral(t *testing.T) {
s := &Server{ollama: &fakeOllama{summErr: errSome}}
if got := s.generateTopicLabel(context.Background(), []string{"sample"}); got != "General" {
t.Fatalf("expected General on error, got %q", got)
}
if got := s.generateTopicLabel(context.Background(), []string{}); got != "General" {
t.Fatalf("expected General for empty samples, got %q", got)
}
}

View file

@ -2,16 +2,12 @@ package server
import (
"context"
"crypto"
"crypto/ed25519"
"crypto/ecdsa"
"crypto/rand"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/pem"
"errors"
"fmt"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
@ -20,66 +16,52 @@ import (
type CDPAuth struct {
keyName string
privateKey crypto.Signer
privateKey *ecdsa.PrivateKey
signingMethod jwt.SigningMethod
}
func NewCDPAuth(keyName, privateKeyPEM string) (*CDPAuth, error) {
if keyName == "" {
return nil, errors.New("key name is required")
}
if privateKeyPEM == "" {
return nil, errors.New("private key is required")
}
// Forgejo/GitHub secrets store multiline PEM as single line with literal \n.
// Convert to real newlines before decoding; idempotent if already multiline.
privateKeyPEM = strings.ReplaceAll(privateKeyPEM, "\\n", "\n")
// Check if it's a base64-encoded Ed25519 key (64 bytes).
decoded, err := base64.StdEncoding.DecodeString(privateKeyPEM)
if err == nil && len(decoded) == 64 {
return &CDPAuth{
keyName: keyName,
privateKey: ed25519.PrivateKey(decoded),
signingMethod: jwt.SigningMethodEdDSA,
}, nil
}
// Otherwise, try PEM-decoded EC key
block, _ := pem.Decode([]byte(privateKeyPEM))
if block == nil {
return nil, fmt.Errorf("failed to decode PEM block")
}
// Try to parse as EC private key first
var signingMethod jwt.SigningMethod
privKey, err := x509.ParseECPrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse EC private key: %w", err)
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse private key: %w", err)
}
var ok bool
privKey, ok = parsed.(*ecdsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("not an ECDSA private key")
}
signingMethod = jwt.SigningMethodEdDSA
} else {
signingMethod = jwt.SigningMethodES256
}
return &CDPAuth{
keyName: keyName,
privateKey: privKey,
signingMethod: jwt.SigningMethodES256,
signingMethod: signingMethod,
}, nil
}
func (a *CDPAuth) generateBearerToken(method, path string) (string, error) {
nonceBytes := make([]byte, 16)
if _, err := rand.Read(nonceBytes); err != nil {
return "", fmt.Errorf("failed to generate nonce: %w", err)
return "", err
}
nonce := hex.EncodeToString(nonceBytes)
uri := fmt.Sprintf("%s api.cdp.coinbase.com%s", method, path)
now := time.Now()
now := time.Now().Unix()
claims := jwt.MapClaims{
"sub": a.keyName,
"iss": "cdp",
"nbf": now.Unix(),
"iat": now.Unix(),
"exp": now.Add(120 * time.Second).Unix(),
"nbf": now,
"exp": now + 120,
"uris": []string{uri},
}
@ -87,43 +69,16 @@ func (a *CDPAuth) generateBearerToken(method, path string) (string, error) {
token.Header["kid"] = a.keyName
token.Header["nonce"] = nonce
var signedToken string
var err error
switch a.signingMethod {
case jwt.SigningMethodES256, jwt.SigningMethodEdDSA:
signedToken, err = token.SignedString(a.privateKey)
default:
return "", fmt.Errorf("unsupported signing method: %v", a.signingMethod)
}
if err != nil {
return "", fmt.Errorf("failed to sign token: %w", err)
}
return signedToken, nil
return token.SignedString(a.privateKey)
}
func (a *CDPAuth) GetAuthHeaders(ctx context.Context) (x402http.AuthHeaders, error) {
verify, err := a.generateBearerToken("POST", "/platform/v2/x402/verify")
if err != nil {
return x402http.AuthHeaders{}, fmt.Errorf("failed to generate verify token: %w", err)
}
settle, err := a.generateBearerToken("POST", "/platform/v2/x402/settle")
if err != nil {
return x402http.AuthHeaders{}, fmt.Errorf("failed to generate settle token: %w", err)
}
supported, err := a.generateBearerToken("GET", "/platform/v2/x402/supported")
if err != nil {
return x402http.AuthHeaders{}, fmt.Errorf("failed to generate supported token: %w", err)
}
verify, _ := a.generateBearerToken("POST", "/platform/v2/x402/verify")
settle, _ := a.generateBearerToken("POST", "/platform/v2/x402/settle")
supported, _ := a.generateBearerToken("GET", "/platform/v2/x402/supported")
return x402http.AuthHeaders{
Verify: map[string]string{"Authorization": "Bearer " + verify},
Settle: map[string]string{"Authorization": "Bearer " + settle},
Supported: map[string]string{"Authorization": "Bearer " + supported},
}, nil
}
func (a *CDPAuth) GetSigningMethod() jwt.SigningMethod {
return a.signingMethod
}

View file

@ -1,213 +0,0 @@
package server
import (
"context"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"testing"
"github.com/golang-jwt/jwt/v5"
)
func generateTestECKey() string {
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
panic(err)
}
der, err := x509.MarshalECPrivateKey(privateKey)
if err != nil {
panic(err)
}
block := &pem.Block{
Type: "EC PRIVATE KEY",
Bytes: der,
}
return string(pem.EncodeToMemory(block))
}
func TestNewCDPAuth_ECKey(t *testing.T) {
ecKey := generateTestECKey()
auth, err := NewCDPAuth("test-key-id", ecKey)
if err != nil {
t.Fatalf("NewCDPAuth failed: %v", err)
}
if auth.signingMethod != jwt.SigningMethodES256 {
t.Errorf("expected ES256, got %v", auth.signingMethod)
}
if auth.keyName != "test-key-id" {
t.Errorf("expected keyName 'test-key-id', got %v", auth.keyName)
}
if auth.privateKey == nil {
t.Error("privateKey should not be nil")
}
_, ok := auth.privateKey.(*ecdsa.PrivateKey)
if !ok {
t.Error("privateKey should be *ecdsa.PrivateKey")
}
}
func TestNewCDPAuth_Ed25519Key(t *testing.T) {
// Generate a valid Ed25519 key
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("failed to generate Ed25519 key: %v", err)
}
edKey := base64.StdEncoding.EncodeToString(privateKey)
auth, err := NewCDPAuth("test-key-id", edKey)
if err != nil {
t.Fatalf("NewCDPAuth failed: %v", err)
}
if auth.signingMethod != jwt.SigningMethodEdDSA {
t.Errorf("expected EdDSA, got %v", auth.signingMethod)
}
if auth.privateKey == nil {
t.Error("privateKey should not be nil")
}
_, ok := auth.privateKey.(ed25519.PrivateKey)
if !ok {
t.Error("privateKey should be ed25519.PrivateKey")
}
}
func TestNewCDPAuth_InvalidKey(t *testing.T) {
_, err := NewCDPAuth("test-key-id", "invalid-key")
if err == nil {
t.Error("expected error for invalid key")
}
}
func TestNewCDPAuth_EmptyKeyName(t *testing.T) {
_, err := NewCDPAuth("", "some-key")
if err == nil {
t.Error("expected error for empty key name")
}
}
func TestNewCDPAuth_EmptyKey(t *testing.T) {
_, err := NewCDPAuth("test-key-id", "")
if err == nil {
t.Error("expected error for empty key")
}
}
func TestGenerateBearerToken_ECKey(t *testing.T) {
ecKey := generateTestECKey()
auth, err := NewCDPAuth("test-key-id", ecKey)
if err != nil {
t.Fatalf("NewCDPAuth failed: %v", err)
}
token, err := auth.generateBearerToken("POST", "/platform/v2/x402/verify")
if err != nil {
t.Fatalf("generateBearerToken failed: %v", err)
}
if token == "" {
t.Error("token should not be empty")
}
// Parse and verify claims
parsed, err := jwt.Parse(token, func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodECDSA); !ok {
return nil, jwt.ErrSignatureInvalid
}
return auth.privateKey.(*ecdsa.PrivateKey).Public(), nil
})
if err != nil {
t.Fatalf("failed to parse token: %v", err)
}
if !parsed.Valid {
t.Error("token should be valid")
}
claims, ok := parsed.Claims.(jwt.MapClaims)
if !ok {
t.Fatal("claims should be MapClaims")
}
if claims["sub"] != "test-key-id" {
t.Errorf("expected sub 'test-key-id', got %v", claims["sub"])
}
if claims["iss"] != "cdp" {
t.Errorf("expected iss 'cdp', got %v", claims["iss"])
}
if claims["iat"] == nil {
t.Error("iat claim should be present")
}
if claims["nbf"] == nil {
t.Error("nbf claim should be present")
}
if claims["exp"] == nil {
t.Error("exp claim should be present")
}
if claims["uris"] == nil {
t.Error("uris claim should be present")
}
uris, ok := claims["uris"].([]any)
if !ok || len(uris) != 1 {
t.Errorf("expected uris array with 1 element, got %v", claims["uris"])
}
if uris[0] != "POST api.cdp.coinbase.com/platform/v2/x402/verify" {
t.Errorf("expected uri 'POST api.cdp.coinbase.com/platform/v2/x402/verify', got %v", uris[0])
}
// Check header
if parsed.Header["kid"] != "test-key-id" {
t.Errorf("expected kid 'test-key-id', got %v", parsed.Header["kid"])
}
if parsed.Header["nonce"] == nil {
t.Error("nonce header should be present")
}
}
func TestGetAuthHeaders(t *testing.T) {
ecKey := generateTestECKey()
auth, err := NewCDPAuth("test-key-id", ecKey)
if err != nil {
t.Fatalf("NewCDPAuth failed: %v", err)
}
headers, err := auth.GetAuthHeaders(context.TODO())
if err != nil {
t.Fatalf("GetAuthHeaders failed: %v", err)
}
if headers.Verify == nil || headers.Verify["Authorization"] == "" {
t.Error("Verify header should be present")
}
if headers.Settle == nil || headers.Settle["Authorization"] == "" {
t.Error("Settle header should be present")
}
if headers.Supported == nil || headers.Supported["Authorization"] == "" {
t.Error("Supported header should be present")
}
// All should start with "Bearer "
for name, header := range map[string]string{
"Verify": headers.Verify["Authorization"],
"Settle": headers.Settle["Authorization"],
"Supported": headers.Supported["Authorization"],
} {
if len(header) < 7 || header[:7] != "Bearer " {
t.Errorf("%s header should start with 'Bearer ', got %s", name, header)
}
}
}

View file

@ -1,76 +0,0 @@
package server
import (
"context"
"encoding/json"
"time"
"github.com/bluesky-social/indigo/api/bsky"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/nacorid/naco-api/internal/activitypub"
"github.com/nacorid/naco-api/internal/actor"
"github.com/nacorid/naco-api/internal/clustering"
"github.com/nacorid/naco-api/internal/nostr"
"github.com/nacorid/naco-api/internal/store"
"github.com/nacorid/naco-api/internal/twitter"
)
// The following interfaces let the server depend on platform behaviour rather than on the
// concrete client types. They exist so tests can inject fake backends; the production code in
// main.go keeps passing the real clients (which satisfy these interfaces).
type blueskyBackend interface {
GetPost(ctx context.Context, actor, collection, recordKey string) (*bsky.FeedDefs_PostView, error)
GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*bsky.FeedDefs_FeedViewPost, error)
GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]*bsky.FeedDefs_FeedViewPost, error)
GetFeed(ctx context.Context, feedURI string, limit int) (*bsky.FeedGetFeed_Output, error)
ResolveUser(ctx context.Context, user string) (syntax.DID, error)
GetActor(ctx context.Context, author string) (*actor.Profile, error)
SearchActors(ctx context.Context, query string, limit int) ([]*actor.Profile, error)
}
type apBackend interface {
GetPost(ctx context.Context, postID string) (*activitypub.Post, error)
GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*activitypub.Post, error)
GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]*activitypub.Post, error)
GetActor(ctx context.Context, author string) (*actor.Profile, error)
SearchActors(ctx context.Context, query string, limit int) ([]*actor.Profile, error)
}
type nostrBackend interface {
GetPost(ctx context.Context, id string) (*nostr.Post, error)
GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*nostr.Post, error)
GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]*nostr.Post, error)
GetActor(ctx context.Context, author string) (*actor.Profile, error)
SearchActors(ctx context.Context, query string, limit int) ([]*actor.Profile, error)
}
type twitterBackend interface {
GetPost(ctx context.Context, id string) (*twitter.Post, error)
GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*twitter.Post, error)
GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]*twitter.Post, error)
}
type ollamaBackend interface {
EmbedTexts(ctx context.Context, posts []clustering.Item) ([][]float64, error)
SummarizeStyle(ctx context.Context, posts []clustering.Item) (string, error)
}
// Store abstracts the persistence layer the server depends on. *store.Queries satisfies it;
// tests inject an in-memory fake so db-backed paths (style profiles, analytics, similar users)
// can be exercised without a live Postgres.
type Store interface {
GetAnalyticsCache(ctx context.Context, arg store.GetAnalyticsCacheParams) (json.RawMessage, error)
UpsertAnalyticsCache(ctx context.Context, arg store.UpsertAnalyticsCacheParams) error
GetAnalyticsPostsInRange(ctx context.Context, arg store.GetAnalyticsPostsInRangeParams) ([]store.GetAnalyticsPostsInRangeRow, error)
UpsertAnalyticsPosts(ctx context.Context, arg store.UpsertAnalyticsPostsParams) error
GetAnalyticsCoverageRanges(ctx context.Context, arg store.GetAnalyticsCoverageRangesParams) ([]store.GetAnalyticsCoverageRangesRow, error)
InsertAnalyticsCoverageRange(ctx context.Context, arg store.InsertAnalyticsCoverageRangeParams) error
GetStyleProfile(ctx context.Context, arg store.GetStyleProfileParams) (store.UserStyleProfile, error)
UpsertStyleProfile(ctx context.Context, arg store.UpsertStyleProfileParams) error
DeleteStylePosts(ctx context.Context, arg store.DeleteStylePostsParams) error
GetStylePosts(ctx context.Context, arg store.GetStylePostsParams) ([]store.GetStylePostsRow, error)
UpsertStylePosts(ctx context.Context, arg store.UpsertStylePostsParams) error
FindSimilarUsers(ctx context.Context, arg store.FindSimilarUsersParams) ([]store.FindSimilarUsersRow, error)
PruneStylePosts(ctx context.Context) error
}

View file

@ -1,88 +0,0 @@
package server
import (
"sync"
"time"
)
const postCacheTTL = 5 * time.Minute
const postCacheMaxItems = 10000
type ttlCacheEntry struct {
value any
exp time.Time
}
type ttlCache struct {
mu sync.Mutex
items map[string]ttlCacheEntry
ttl time.Duration
maxItems int
}
func newTTLCache(ttl time.Duration) *ttlCache {
return &ttlCache{
items: make(map[string]ttlCacheEntry),
ttl: ttl,
maxItems: postCacheMaxItems,
}
}
func (c *ttlCache) get(key string) (any, bool) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.items[key]
if !ok {
return nil, false
}
if time.Now().After(e.exp) {
delete(c.items, key)
return nil, false
}
return e.value, true
}
func (c *ttlCache) set(key string, value any) {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.items) >= c.maxItems {
// Evict the oldest expired entry, or if none expired, the first one.
var oldestKey string
var oldestTime time.Time
first := true
for k, e := range c.items {
if first || e.exp.Before(oldestTime) {
oldestKey = k
oldestTime = e.exp
first = false
}
}
if oldestKey != "" {
delete(c.items, oldestKey)
}
}
c.items[key] = ttlCacheEntry{value: value, exp: time.Now().Add(c.ttl)}
}
// cachedFetch returns the cached value for key or calls fetch to obtain it,
// storing the result in the cache before returning. The zero value is returned
// on fetch error and not cached.
func cachedFetch[T any](c *ttlCache, key string, fetch func() (T, error)) (T, error) {
if v, ok := c.get(key); ok {
if typed, ok := v.(T); ok {
return typed, nil
}
}
val, err := fetch()
if err != nil {
var zero T
return zero, err
}
c.set(key, val)
return val, nil
}
type latestCacheValue struct {
posts []*GetPostResponsePost
aid string
}

View file

@ -1,16 +0,0 @@
package server
const (
CurrentModelVersion = 1
CurrentSchemaVersion = 1
CurrentAlgorithmVersion = 1
)
// IsProfileStale returns true if the profile's versions are older than the current versions.
// This is used to mark profiles stale for lazy recompute on the next request.
// Currently all three version fields are 1 since this is the first version of the combination.
func IsProfileStale(model, schema, algorithm int) bool {
return model != CurrentModelVersion ||
schema != CurrentSchemaVersion ||
algorithm != CurrentAlgorithmVersion
}

View file

@ -1,100 +0,0 @@
package server
import (
"testing"
"time"
)
func rangeOf(t *testing.T, s, e string) timeRange {
t.Helper()
layout := "2006-01-02 15:04"
start, err := time.Parse(layout, s)
if err != nil {
t.Fatalf("bad start %q: %v", s, err)
}
end, err := time.Parse(layout, e)
if err != nil {
t.Fatalf("bad end %q: %v", e, err)
}
return timeRange{start: start, end: end}
}
func assertRanges(t *testing.T, got []timeRange, want ...timeRange) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("got %d ranges, want %d: %+v", len(got), len(want), got)
}
for i := range want {
if !got[i].start.Equal(want[i].start) || !got[i].end.Equal(want[i].end) {
t.Fatalf("range %d: got [%v, %v], want [%v, %v]",
i, got[i].start, got[i].end, want[i].start, want[i].end)
}
}
}
func TestUncoveredRangesNoCoverage(t *testing.T) {
r := rangeOf(t, "2026-01-01 00:00", "2026-01-31 00:00")
got := uncoveredRanges(r.start, r.end, nil)
assertRanges(t, got, r)
}
func TestUncoveredRangesFullyCovered(t *testing.T) {
req := rangeOf(t, "2026-01-10 00:00", "2026-01-20 00:00")
cov := rangeOf(t, "2026-01-01 00:00", "2026-01-31 00:00")
got := uncoveredRanges(req.start, req.end, []timeRange{cov})
assertRanges(t, got)
}
func TestUncoveredRangesLeftOnly(t *testing.T) {
req := rangeOf(t, "2026-01-01 00:00", "2026-01-31 00:00")
cov := rangeOf(t, "2026-01-15 00:00", "2026-01-31 00:00")
got := uncoveredRanges(req.start, req.end, []timeRange{cov})
want := rangeOf(t, "2026-01-01 00:00", "2026-01-15 00:00")
assertRanges(t, got, want)
}
func TestUncoveredRangesRightOnly(t *testing.T) {
req := rangeOf(t, "2026-01-01 00:00", "2026-01-31 00:00")
cov := rangeOf(t, "2026-01-01 00:00", "2026-01-15 00:00")
got := uncoveredRanges(req.start, req.end, []timeRange{cov})
want := rangeOf(t, "2026-01-15 00:00", "2026-01-31 00:00")
assertRanges(t, got, want)
}
func TestUncoveredRangesBothSides(t *testing.T) {
req := rangeOf(t, "2026-01-01 00:00", "2026-01-31 00:00")
cov := rangeOf(t, "2026-01-10 00:00", "2026-01-20 00:00")
wantLeft := rangeOf(t, "2026-01-01 00:00", "2026-01-10 00:00")
wantRight := rangeOf(t, "2026-01-20 00:00", "2026-01-31 00:00")
got := uncoveredRanges(req.start, req.end, []timeRange{cov})
assertRanges(t, got, wantLeft, wantRight)
}
// Regression: disjoint covered intervals must not be merged into a single
// contiguous window. A request landing in the gap between two covered ranges
// must be reported as uncovered so it is fetched rather than served from an
// incomplete cache.
func TestUncoveredRangesDisjointGap(t *testing.T) {
req := rangeOf(t, "2026-01-15 00:00", "2026-01-20 00:00")
first := rangeOf(t, "2026-01-01 00:00", "2026-01-15 00:00")
second := rangeOf(t, "2026-01-20 00:00", "2026-01-31 00:00")
got := uncoveredRanges(req.start, req.end, []timeRange{first, second})
assertRanges(t, got, req)
}
func TestUncoveredRangesGapSurroundedByCoverage(t *testing.T) {
req := rangeOf(t, "2026-01-10 00:00", "2026-01-25 00:00")
first := rangeOf(t, "2026-01-01 00:00", "2026-01-12 00:00")
second := rangeOf(t, "2026-01-20 00:00", "2026-01-31 00:00")
want := rangeOf(t, "2026-01-12 00:00", "2026-01-20 00:00")
got := uncoveredRanges(req.start, req.end, []timeRange{first, second})
assertRanges(t, got, want)
}
func TestUncoveredRangesOverlappingCoverage(t *testing.T) {
req := rangeOf(t, "2026-01-10 00:00", "2026-01-20 00:00")
first := rangeOf(t, "2026-01-01 00:00", "2026-01-15 00:00")
second := rangeOf(t, "2026-01-12 00:00", "2026-01-31 00:00")
got := uncoveredRanges(req.start, req.end, []timeRange{first, second})
assertRanges(t, got)
}

View file

@ -1,855 +0,0 @@
package server
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
"testing"
"time"
"github.com/bluesky-social/indigo/api/bsky"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/bluesky-social/indigo/lex/util"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/nacorid/naco-api/internal/activitypub"
"github.com/nacorid/naco-api/internal/actor"
"github.com/nacorid/naco-api/internal/clustering"
"github.com/nacorid/naco-api/internal/nostr"
"github.com/nacorid/naco-api/internal/store"
"github.com/nacorid/naco-api/internal/twitter"
)
// ---- fakes ----
type fakeBluesky struct {
postView *bsky.FeedDefs_PostView
feedViews []*bsky.FeedDefs_FeedViewPost
profile *actor.Profile
actors []*actor.Profile
feedOut *bsky.FeedGetFeed_Output
err error
calls int
lastLimit int
}
func (f *fakeBluesky) GetPost(ctx context.Context, actor, collection, recordKey string) (*bsky.FeedDefs_PostView, error) {
f.calls++
if f.err != nil {
return nil, f.err
}
return f.postView, nil
}
func (f *fakeBluesky) GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*bsky.FeedDefs_FeedViewPost, error) {
f.calls++
if f.err != nil {
return nil, f.err
}
return f.feedViews, nil
}
func (f *fakeBluesky) GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]*bsky.FeedDefs_FeedViewPost, error) {
f.calls++
if f.err != nil {
return nil, f.err
}
return f.feedViews, nil
}
func (f *fakeBluesky) GetFeed(ctx context.Context, feedURI string, limit int) (*bsky.FeedGetFeed_Output, error) {
f.calls++
if f.err != nil {
return nil, f.err
}
return f.feedOut, nil
}
func (f *fakeBluesky) ResolveUser(ctx context.Context, user string) (syntax.DID, error) {
f.calls++
if f.err != nil {
return "", f.err
}
return syntax.ParseDID("did:plc:abc")
}
func (f *fakeBluesky) GetActor(ctx context.Context, author string) (*actor.Profile, error) {
f.calls++
if f.err != nil {
return nil, f.err
}
return f.profile, nil
}
func (f *fakeBluesky) SearchActors(ctx context.Context, query string, limit int) ([]*actor.Profile, error) {
f.calls++
f.lastLimit = limit
if f.err != nil {
return nil, f.err
}
return f.actors, nil
}
type fakeAP struct {
post *activitypub.Post
posts []*activitypub.Post
profile *actor.Profile
actors []*actor.Profile
err error
calls int
lastLimit int
}
func (f *fakeAP) GetPost(ctx context.Context, postID string) (*activitypub.Post, error) {
f.calls++
if f.err != nil {
return nil, f.err
}
return f.post, nil
}
func (f *fakeAP) GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*activitypub.Post, error) {
f.calls++
if f.err != nil {
return nil, f.err
}
return f.posts, nil
}
func (f *fakeAP) GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]*activitypub.Post, error) {
f.calls++
if f.err != nil {
return nil, f.err
}
out := make([]*activitypub.Post, 0, len(f.posts))
out = append(out, f.posts...)
return out, nil
}
func (f *fakeAP) GetActor(ctx context.Context, author string) (*actor.Profile, error) {
f.calls++
if f.err != nil {
return nil, f.err
}
return f.profile, nil
}
func (f *fakeAP) SearchActors(ctx context.Context, query string, limit int) ([]*actor.Profile, error) {
f.calls++
f.lastLimit = limit
if f.err != nil {
return nil, f.err
}
return f.actors, nil
}
type fakeNostr struct {
post *nostr.Post
posts []*nostr.Post
profile *actor.Profile
actors []*actor.Profile
err error
calls int
lastLimit int
}
func (f *fakeNostr) GetPost(ctx context.Context, id string) (*nostr.Post, error) {
f.calls++
if f.err != nil {
return nil, f.err
}
return f.post, nil
}
func (f *fakeNostr) GetLatestNPosts(ctx context.Context, author string, limit int) ([]*nostr.Post, error) {
f.calls++
if f.err != nil {
return nil, f.err
}
return f.posts, nil
}
func (f *fakeNostr) GetPostsInTimeframe(ctx context.Context, author string, start, end time.Time, limit int) ([]*nostr.Post, error) {
f.calls++
if f.err != nil {
return nil, f.err
}
return f.posts, nil
}
func (f *fakeNostr) GetActor(ctx context.Context, author string) (*actor.Profile, error) {
f.calls++
if f.err != nil {
return nil, f.err
}
return f.profile, nil
}
func (f *fakeNostr) SearchActors(ctx context.Context, query string, limit int) ([]*actor.Profile, error) {
f.calls++
f.lastLimit = limit
if f.err != nil {
return nil, f.err
}
return f.actors, nil
}
type fakeTwitter struct {
post *twitter.Post
posts []*twitter.Post
profile *actor.Profile
actors []*actor.Profile
err error
calls int
}
func (f *fakeTwitter) GetPost(ctx context.Context, id string) (*twitter.Post, error) {
f.calls++
if f.err != nil {
return nil, f.err
}
return f.post, nil
}
func (f *fakeTwitter) GetLatestNPosts(ctx context.Context, author string, limit int) ([]*twitter.Post, error) {
f.calls++
if f.err != nil {
return nil, f.err
}
return f.posts, nil
}
func (f *fakeTwitter) GetPostsInTimeframe(ctx context.Context, author string, start, end time.Time, limit int) ([]*twitter.Post, error) {
f.calls++
if f.err != nil {
return nil, f.err
}
return f.posts, nil
}
func (f *fakeTwitter) GetActor(ctx context.Context, author string) (*actor.Profile, error) {
f.calls++
if f.err != nil {
return nil, f.err
}
return f.profile, nil
}
func (f *fakeTwitter) SearchActors(ctx context.Context, query string, limit int) ([]*actor.Profile, error) {
f.calls++
if f.err != nil {
return nil, f.err
}
return f.actors, nil
}
// fakeOllama implements ollamaBackend. Its behavior is driven by a function so
// tests can simulate embedding, sentiment, and topic-label responses.
type fakeOllama struct {
embeddings [][]float64
embedErr error
summaries []string
summaryIdx int
summErr error
embedCalls int
summCalls int
}
func (f *fakeOllama) EmbedTexts(ctx context.Context, posts []clustering.Item) ([][]float64, error) {
f.embedCalls++
if f.embedErr != nil {
return nil, f.embedErr
}
if f.embeddings != nil {
return f.embeddings, nil
}
out := make([][]float64, len(posts))
for i := range posts {
out[i] = []float64{float64(i + 1), 0.5}
}
return out, nil
}
func (f *fakeOllama) SummarizeStyle(ctx context.Context, posts []clustering.Item) (string, error) {
f.summCalls++
if f.summErr != nil {
return "", f.summErr
}
if f.summaries != nil {
if f.summaryIdx >= len(f.summaries) {
f.summaryIdx = len(f.summaries) - 1
}
out := f.summaries[f.summaryIdx]
f.summaryIdx++
return out, nil
}
return "summary", nil
}
// ---- helpers ----
func newTestServer(bsky blueskyBackend, ap apBackend, nstr nostrBackend, tw twitterBackend) *Server {
return newServerWith(bsky, ap, nstr, tw, newFakeStore(), &fakeOllama{})
}
// newServerWith builds a Server with explicit store and ollama backends so tests
// can drive db-backed tool paths (style profiles, analytics, similar users).
func newServerWith(bsky blueskyBackend, ap apBackend, nstr nostrBackend, tw twitterBackend, st Store, oll ollamaBackend) *Server {
return &Server{
bskyClient: bsky,
apClient: ap,
nostrClient: nstr,
twitterClient: tw,
db: st,
ollama: oll,
enrichEnabled: false,
enrichSem: make(chan struct{}, 4),
postCache: newTTLCache(postCacheTTL),
analyticsTTLEngagement: time.Hour,
analyticsTTLPatterns: time.Hour,
analyticsTTLTopics: time.Hour,
analyticsTTLSentiment: time.Hour,
analyticsTTLDrivers: time.Hour,
}
}
func callTool(s *Server, name string, args any) (*mcp.CallToolResult, error) {
raw, err := json.Marshal(args)
if err != nil {
return nil, err
}
req := &mcp.CallToolRequest{Params: &mcp.CallToolParamsRaw{Name: name, Arguments: raw}}
ctx := context.Background()
switch name {
case "getActor":
return s.getActor(ctx, req)
case "searchActors":
return s.searchActors(ctx, req)
case "getPost":
return s.getPost(ctx, req)
case "getLatestPosts":
return s.getLatestPosts(ctx, req)
case "getPostsInTime":
return s.getPostsInTime(ctx, req)
case "getFeed":
return s.getFeed(ctx, req)
case "getStyleProfile":
return s.getStyleProfile(ctx, req)
case "findSimilarUsers":
return s.getSimilarProfiles(ctx, req)
case "getEngagementMetrics":
return s.getEngagementMetrics(ctx, req)
case "getPostingPatterns":
return s.getPostingPatterns(ctx, req)
case "getTopicModeling":
return s.getTopicModeling(ctx, req)
case "getSentimentTrends":
return s.getSentimentTrends(ctx, req)
case "getEngagementDrivers":
return s.getEngagementDrivers(ctx, req)
}
return nil, fmt.Errorf("unknown tool %q", name)
}
func textOf(t *testing.T, res *mcp.CallToolResult) string {
t.Helper()
if res == nil || len(res.Content) == 0 {
t.Fatal("empty result")
}
tc, ok := res.Content[0].(*mcp.TextContent)
if !ok {
t.Fatalf("expected TextContent, got %T", res.Content[0])
}
return tc.Text
}
func bskyPostView(text string) *bsky.FeedDefs_PostView {
return &bsky.FeedDefs_PostView{
Author: &bsky.ActorDefs_ProfileViewBasic{Did: "did:plc:abc"},
Record: &util.LexiconTypeDecoder{Val: &bsky.FeedPost{
Text: text,
CreatedAt: "2024-01-01T00:00:00Z",
}},
}
}
// ---- tests ----
func TestGetActorDispatch(t *testing.T) {
cases := []struct {
author string
fake func() (blueskyBackend, apBackend, nostrBackend, twitterBackend)
profile *actor.Profile
}{
{"did:plc:abc", func() (blueskyBackend, apBackend, nostrBackend, twitterBackend) {
return &fakeBluesky{profile: &actor.Profile{Platform: "Bluesky", Handle: "a"}}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}
}, &actor.Profile{Platform: "Bluesky"}},
{"@alice@mastodon.social", func() (blueskyBackend, apBackend, nostrBackend, twitterBackend) {
return &fakeBluesky{}, &fakeAP{profile: &actor.Profile{Platform: "ActivityPub"}}, &fakeNostr{}, &fakeTwitter{}
}, &actor.Profile{Platform: "ActivityPub"}},
{"npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", func() (blueskyBackend, apBackend, nostrBackend, twitterBackend) {
return &fakeBluesky{}, &fakeAP{}, &fakeNostr{profile: &actor.Profile{Platform: "Nostr"}}, &fakeTwitter{}
}, &actor.Profile{Platform: "Nostr"}},
}
for _, c := range cases {
s := newTestServer(c.fake())
res, err := callTool(s, "getActor", GetActorRequest{Author: c.author})
if err != nil {
t.Fatalf("%s: getActor error: %v", c.author, err)
}
var out GetActorResponse
if err := json.Unmarshal([]byte(textOf(t, res)), &out); err != nil {
t.Fatalf("%s: unmarshal: %v", c.author, err)
}
if out.Actor.Platform != c.profile.Platform {
t.Fatalf("%s: expected platform %s, got %s", c.author, c.profile.Platform, out.Actor.Platform)
}
}
}
func TestSearchActorsAllSucceed(t *testing.T) {
bsky := &fakeBluesky{actors: []*actor.Profile{{Platform: "Bluesky", Handle: "a"}}}
ap := &fakeAP{actors: []*actor.Profile{{Platform: "ActivityPub", Handle: "b"}}}
nstr := &fakeNostr{actors: []*actor.Profile{{Platform: "Nostr", Handle: "c"}}}
tw := &fakeTwitter{actors: []*actor.Profile{{Platform: "Twitter", Handle: "d"}}}
s := newTestServer(bsky, ap, nstr, tw)
res, err := callTool(s, "searchActors", SearchActorsRequest{Query: "alice", Limit: 10})
if err != nil {
t.Fatalf("searchActors error: %v", err)
}
var out SearchActorsResponse
if err := json.Unmarshal([]byte(textOf(t, res)), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(out.Results) != 3 {
t.Fatalf("expected 3 merged results, got %d", len(out.Results))
}
}
func TestSearchActorsAllFail(t *testing.T) {
bsky := &fakeBluesky{err: fmt.Errorf("boom")}
ap := &fakeAP{err: fmt.Errorf("boom")}
nstr := &fakeNostr{err: fmt.Errorf("boom")}
tw := &fakeTwitter{err: fmt.Errorf("boom")}
s := newTestServer(bsky, ap, nstr, tw)
if _, err := callTool(s, "searchActors", SearchActorsRequest{Query: "alice"}); err == nil {
t.Fatal("expected error when all platforms fail")
}
}
func TestSearchActorsPartialSuccess(t *testing.T) {
bsky := &fakeBluesky{err: fmt.Errorf("boom")}
ap := &fakeAP{actors: []*actor.Profile{{Platform: "ActivityPub", Handle: "b"}}}
nstr := &fakeNostr{actors: []*actor.Profile{{Platform: "Nostr", Handle: "c"}}}
tw := &fakeTwitter{err: fmt.Errorf("boom")}
s := newTestServer(bsky, ap, nstr, tw)
res, err := callTool(s, "searchActors", SearchActorsRequest{Query: "alice"})
if err != nil {
t.Fatalf("expected partial success, got error: %v", err)
}
var out SearchActorsResponse
if err := json.Unmarshal([]byte(textOf(t, res)), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(out.Results) != 2 {
t.Fatalf("expected 2 partial results, got %d", len(out.Results))
}
}
func TestGetPostTwitter(t *testing.T) {
tw := &fakeTwitter{post: &twitter.Post{ID: "123", Content: "hello twitter", Author: "@alice"}}
s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, tw)
_, err := callTool(s, "getPost", GetPostRequest{Author: "@alice", RecordKey: "123"})
if err == nil {
t.Fatalf("expected twitter_unavailable error, got nil")
}
if !strings.Contains(err.Error(), "twitter_unavailable") {
t.Fatalf("expected twitter_unavailable error, got: %v", err)
}
}
func TestGetLatestPostsAP(t *testing.T) {
ap := &fakeAP{posts: []*activitypub.Post{
{Author: "alice@mastodon.social", Content: "one", CreatedAt: time.Now()},
{Author: "alice@mastodon.social", Content: "two", CreatedAt: time.Now()},
}}
s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{})
res, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "@alice@mastodon.social", Limit: 5})
if err != nil {
t.Fatalf("getLatestPosts error: %v", err)
}
var out GetLatestPostsResponse
if err := json.Unmarshal([]byte(textOf(t, res)), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(out.Posts) != 2 {
t.Fatalf("expected 2 posts, got %d", len(out.Posts))
}
}
func TestGetPostsInTimeNostr(t *testing.T) {
nstr := &fakeNostr{posts: []*nostr.Post{{ID: "e1", Author: "npubX", Content: "nostr post", CreatedAt: time.Now()}}}
s := newTestServer(&fakeBluesky{}, &fakeAP{}, nstr, &fakeTwitter{})
res, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{
Author: "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq",
StartTime: "2024-01-01T00:00:00Z",
EndTime: "2024-12-31T00:00:00Z",
Limit: 5,
})
if err != nil {
t.Fatalf("getPostsInTime error: %v", err)
}
var out GetPostsInTimeResponse
if err := json.Unmarshal([]byte(textOf(t, res)), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(out.Posts) != 1 || !strings.Contains(out.Posts[0].Content, "nostr post") {
t.Fatalf("unexpected posts: %+v", out.Posts)
}
}
func TestGetFeedBluesky(t *testing.T) {
bsky := &fakeBluesky{feedOut: &bsky.FeedGetFeed_Output{Feed: []*bsky.FeedDefs_FeedViewPost{{Post: bskyPostView("feed post")}}}}
s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
res, err := callTool(s, "getFeed", GetFeedRequest{URI: "at://did:plc:abc/app.bsky.feed.generator/abc"})
if err != nil {
t.Fatalf("getFeed error: %v", err)
}
var out GetFeedResponse
if err := json.Unmarshal([]byte(textOf(t, res)), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(out.Posts) != 1 || out.Posts[0].Content != "feed post" {
t.Fatalf("unexpected feed posts: %+v", out.Posts)
}
}
// ---- getPost success paths ----
func TestGetPostBlueskyWithCollection(t *testing.T) {
bsky := &fakeBluesky{postView: bskyPostView("hello bluesky with collection")}
s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
res, err := callTool(s, "getPost", GetPostRequest{Author: "did:plc:abc", RecordKey: "k", Collection: "app.bsky.feed.post"})
if err != nil {
t.Fatalf("getPost bluesky with collection error: %v", err)
}
if !strings.Contains(textOf(t, res), "hello bluesky with collection") {
t.Fatalf("unexpected bluesky post text: %s", textOf(t, res))
}
if bsky.calls != 1 {
t.Fatalf("expected 1 backend call, got %d", bsky.calls)
}
}
func TestGetPostActivityPub(t *testing.T) {
ap := &fakeAP{post: &activitypub.Post{Author: "alice@mastodon.social", Content: "hello activitypub", CreatedAt: time.Now()}}
s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{})
res, err := callTool(s, "getPost", GetPostRequest{Author: "@alice@mastodon.social", RecordKey: "123"})
if err != nil {
t.Fatalf("getPost activitypub error: %v", err)
}
if !strings.Contains(textOf(t, res), "hello activitypub") {
t.Fatalf("unexpected activitypub post text: %s", textOf(t, res))
}
if ap.calls != 1 {
t.Fatalf("expected 1 backend call, got %d", ap.calls)
}
}
func TestGetPostNostr(t *testing.T) {
nstr := &fakeNostr{post: &nostr.Post{ID: "n1", Author: "npub1x", Content: "nostr hello"}}
s := newTestServer(&fakeBluesky{}, &fakeAP{}, nstr, &fakeTwitter{})
res, err := callTool(s, "getPost", GetPostRequest{Author: "npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", RecordKey: "n1"})
if err != nil {
t.Fatalf("getPost nostr error: %v", err)
}
if !strings.Contains(textOf(t, res), "nostr hello") {
t.Fatalf("unexpected nostr post text: %s", textOf(t, res))
}
if nstr.calls != 1 {
t.Fatalf("expected 1 backend call, got %d", nstr.calls)
}
}
func TestGetPostBackendError(t *testing.T) {
bsky := &fakeBluesky{err: fmt.Errorf("backend failure")}
s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
_, err := callTool(s, "getPost", GetPostRequest{Author: "did:plc:abc", RecordKey: "k"})
if err == nil || !strings.Contains(err.Error(), "backend failure") {
t.Fatalf("expected backend error, got %v", err)
}
}
// ---- getLatestPosts success paths ----
func TestGetLatestPostsBluesky(t *testing.T) {
bsky := &fakeBluesky{feedViews: []*bsky.FeedDefs_FeedViewPost{{Post: bskyPostView("bluesky post 1")}, {Post: bskyPostView("bluesky post 2")}}}
s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
res, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "did:plc:abc", Limit: 5})
if err != nil {
t.Fatalf("getLatestPosts bluesky error: %v", err)
}
var out GetLatestPostsResponse
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(out.Posts) != 2 {
t.Fatalf("expected 2 posts, got %d", len(out.Posts))
}
if !strings.Contains(out.Posts[0].Content, "bluesky post 1") {
t.Fatalf("unexpected post content: %s", out.Posts[0].Content)
}
if bsky.calls != 1 {
t.Fatalf("expected 1 backend call, got %d", bsky.calls)
}
}
func TestGetLatestPostsNostr(t *testing.T) {
nstr := &fakeNostr{posts: []*nostr.Post{{ID: "n1", Author: "npub1x", Content: "nostr latest 1", CreatedAt: time.Now()}, {ID: "n2", Author: "npub1x", Content: "nostr latest 2", CreatedAt: time.Now()}}}
s := newTestServer(&fakeBluesky{}, &fakeAP{}, nstr, &fakeTwitter{})
res, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", Limit: 5})
if err != nil {
t.Fatalf("getLatestPosts nostr error: %v", err)
}
var out GetLatestPostsResponse
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(out.Posts) != 2 {
t.Fatalf("expected 2 posts, got %d", len(out.Posts))
}
if !strings.Contains(out.Posts[0].Content, "nostr latest") {
t.Fatalf("unexpected post content: %s", out.Posts[0].Content)
}
if nstr.calls != 1 {
t.Fatalf("expected 1 backend call, got %d", nstr.calls)
}
}
// ---- getPostsInTime success paths ----
func TestGetPostsInTimeBluesky(t *testing.T) {
bsky := &fakeBluesky{feedViews: []*bsky.FeedDefs_FeedViewPost{{Post: bskyPostView("bluesky timed 1")}, {Post: bskyPostView("bluesky timed 2")}}}
s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
res, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "did:plc:abc", StartTime: "2024-01-01T00:00:00Z", EndTime: "2024-12-31T00:00:00Z", Limit: 5})
if err != nil {
t.Fatalf("getPostsInTime bluesky error: %v", err)
}
var out GetPostsInTimeResponse
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(out.Posts) != 2 {
t.Fatalf("expected 2 posts, got %d", len(out.Posts))
}
if bsky.calls != 1 {
t.Fatalf("expected 1 backend call, got %d", bsky.calls)
}
}
func TestGetPostsInTimeActivityPub(t *testing.T) {
ap := &fakeAP{posts: []*activitypub.Post{{Author: "a@m.social", Content: "ap timed 1", CreatedAt: time.Date(2024, 6, 15, 0, 0, 0, 0, time.UTC)}, {Author: "a@m.social", Content: "ap timed 2", CreatedAt: time.Date(2024, 7, 15, 0, 0, 0, 0, time.UTC)}}}
s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{})
res, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "@a@mastodon.social", StartTime: "2024-01-01T00:00:00Z", EndTime: "2024-12-31T00:00:00Z", Limit: 5})
if err != nil {
t.Fatalf("getPostsInTime activitypub error: %v", err)
}
var out GetPostsInTimeResponse
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(out.Posts) != 2 {
t.Fatalf("expected 2 posts, got %d", len(out.Posts))
}
if ap.calls != 1 {
t.Fatalf("expected 1 backend call, got %d", ap.calls)
}
}
// ---- getFeed tests ----
func TestGetFeedBlueskyWithCursor(t *testing.T) {
// Test that feed pagination works
bsky := &fakeBluesky{feedOut: &bsky.FeedGetFeed_Output{Feed: []*bsky.FeedDefs_FeedViewPost{{Post: bskyPostView("feed post 1")}, {Post: bskyPostView("feed post 2")}}}}
s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
res, err := callTool(s, "getFeed", GetFeedRequest{URI: "at://did:plc:abc/app.bsky.feed.generator/abc"})
if err != nil {
t.Fatalf("getFeed error: %v", err)
}
var out GetFeedResponse
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(out.Posts) != 2 {
t.Fatalf("expected 2 posts, got %d", len(out.Posts))
}
}
// ---- getActor tests ----
func TestGetActorBlueskyDID(t *testing.T) {
bsky := &fakeBluesky{profile: &actor.Profile{Platform: "Bluesky", Handle: "user.bsky.social", DisplayName: "Test User"}}
s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
res, err := callTool(s, "getActor", GetActorRequest{Author: "did:plc:abc123"})
if err != nil {
t.Fatalf("getActor bluesky DID error: %v", err)
}
var out GetActorResponse
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if out.Actor.Platform != "Bluesky" {
t.Fatalf("expected Bluesky platform, got %s", out.Actor.Platform)
}
if out.Actor.Handle != "user.bsky.social" {
t.Fatalf("expected handle user.bsky.social, got %s", out.Actor.Handle)
}
}
func TestGetActorActivityPub(t *testing.T) {
ap := &fakeAP{profile: &actor.Profile{Platform: "ActivityPub", Handle: "alice@mastodon.social", DisplayName: "Alice"}}
s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{})
res, err := callTool(s, "getActor", GetActorRequest{Author: "@alice@mastodon.social"})
if err != nil {
t.Fatalf("getActor activitypub error: %v", err)
}
var out GetActorResponse
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if out.Actor.Platform != "ActivityPub" {
t.Fatalf("expected ActivityPub platform, got %s", out.Actor.Platform)
}
}
func TestGetActorNostr(t *testing.T) {
nstr := &fakeNostr{profile: &actor.Profile{Platform: "Nostr", Handle: "npub1xxx", DisplayName: "Nostr User"}}
s := newTestServer(&fakeBluesky{}, &fakeAP{}, nstr, &fakeTwitter{})
res, err := callTool(s, "getActor", GetActorRequest{Author: "npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"})
if err != nil {
t.Fatalf("getActor nostr error: %v", err)
}
var out GetActorResponse
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if out.Actor.Platform != "Nostr" {
t.Fatalf("expected Nostr platform, got %s", out.Actor.Platform)
}
}
func TestGetActorTwitter(t *testing.T) {
// Twitter getActor is blocked by policy (same as other Twitter tools)
tw := &fakeTwitter{profile: &actor.Profile{Platform: "Twitter", Handle: "@alice", DisplayName: "Alice Twitter"}}
s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, tw)
_, err := callTool(s, "getActor", GetActorRequest{Author: "@alice"})
if err == nil || !strings.Contains(err.Error(), "twitter_unavailable") {
t.Fatalf("expected twitter_unavailable error, got %v", err)
}
if tw.calls != 0 {
t.Fatalf("expected 0 backend calls, got %d", tw.calls)
}
}
// ---- searchActors edge cases ----
func TestSearchActorsLimitClamp(t *testing.T) {
bsky := &fakeBluesky{actors: []*actor.Profile{{Platform: "Bluesky", Handle: "a"}}}
ap := &fakeAP{actors: []*actor.Profile{{Platform: "ActivityPub", Handle: "b"}}}
nstr := &fakeNostr{actors: []*actor.Profile{{Platform: "Nostr", Handle: "c"}}}
s := newTestServer(bsky, ap, nstr, &fakeTwitter{})
// Limit > 50 should be clamped to 50 in the backend call.
if _, err := callTool(s, "searchActors", SearchActorsRequest{Query: "test", Limit: 100}); err != nil {
t.Fatalf("searchActors error: %v", err)
}
if bsky.lastLimit != 50 || ap.lastLimit != 50 || nstr.lastLimit != 50 {
t.Fatalf("expected limit clamped to 50, got bsky=%d ap=%d nstr=%d", bsky.lastLimit, ap.lastLimit, nstr.lastLimit)
}
// Limit 0 should default to 10.
bsky.lastLimit, ap.lastLimit, nstr.lastLimit = 0, 0, 0
if _, err := callTool(s, "searchActors", SearchActorsRequest{Query: "test", Limit: 0}); err != nil {
t.Fatalf("searchActors error with limit 0: %v", err)
}
if bsky.lastLimit != 10 || ap.lastLimit != 10 || nstr.lastLimit != 10 {
t.Fatalf("expected default limit 10, got bsky=%d ap=%d nstr=%d", bsky.lastLimit, ap.lastLimit, nstr.lastLimit)
}
}
func TestSearchActorsTwitterExcludedFromSearch(t *testing.T) {
// Twitter is not supported for search in the current implementation
bsky := &fakeBluesky{actors: []*actor.Profile{{Platform: "Bluesky", Handle: "a"}}}
ap := &fakeAP{actors: []*actor.Profile{{Platform: "ActivityPub", Handle: "b"}}}
nstr := &fakeNostr{actors: []*actor.Profile{{Platform: "Nostr", Handle: "c"}}}
tw := &fakeTwitter{actors: []*actor.Profile{{Platform: "Twitter", Handle: "d"}}}
s := newTestServer(bsky, ap, nstr, tw)
res, err := callTool(s, "searchActors", SearchActorsRequest{Query: "test", Limit: 10})
if err != nil {
t.Fatalf("searchActors error: %v", err)
}
var out SearchActorsResponse
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
// Twitter should be excluded (only 3 results)
if len(out.Results) != 3 {
t.Fatalf("expected 3 results (Twitter excluded), got %d", len(out.Results))
}
}
func TestSearchActorsNormalizationAndDedup(t *testing.T) {
// Test that handles normalize correctly and deduplicate
bsky := &fakeBluesky{actors: []*actor.Profile{{Platform: "Bluesky", Handle: "user.bsky.social", DisplayName: "User"}}}
ap := &fakeAP{actors: []*actor.Profile{{Platform: "ActivityPub", Handle: "user@mastodon.social", DisplayName: "User"}}}
s := newTestServer(bsky, ap, &fakeNostr{}, &fakeTwitter{})
res, err := callTool(s, "searchActors", SearchActorsRequest{Query: "user", Limit: 10})
if err != nil {
t.Fatalf("searchActors error: %v", err)
}
var out SearchActorsResponse
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
// Should be deduped by normalized handle
if len(out.Results) != 2 {
t.Fatalf("expected 2 deduped results, got %d: %+v", len(out.Results), out.Results)
}
}
// ---- cache tests ----
func TestPostCacheDifferentKeys(t *testing.T) {
ap := &fakeAP{posts: []*activitypub.Post{{Author: "a@m.social", Content: "post1", CreatedAt: time.Now()}}}
s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{})
// Different authors should not share cache
_, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "@a@mastodon.social", Limit: 5})
if err != nil {
t.Fatalf("getLatestPosts error: %v", err)
}
_, err = callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "@b@mastodon.social", Limit: 5})
if err != nil {
t.Fatalf("getLatestPosts error for different author: %v", err)
}
// Each author should have called backend once
if ap.calls != 2 {
t.Fatalf("expected 2 backend calls for different authors, got %d", ap.calls)
}
}
func TestCacheVersionStaleness(t *testing.T) {
st := newFakeStore()
// Add a stale profile (older than 30 days)
st.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{
UserID: "@a@mastodon.social",
Platform: "ActivityPub",
Embedding: "[0.1,0.2]",
TopKeywords: []string{"old"},
StyleSummary: "stale summary",
PostCount: 5,
ModifiedAt: sql.NullTime{Time: time.Now().Add(-31 * 24 * time.Hour), Valid: true},
}
ap := &fakeAP{posts: []*activitypub.Post{{Author: "@a@mastodon.social", Content: "fresh post", CreatedAt: time.Now()}}}
s := newServerWith(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{})
res, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "@a@mastodon.social"})
if err != nil {
t.Fatalf("getStyleProfile error: %v", err)
}
var out GetStyleProfileResponse
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
// Should have rebuilt with fresh data
if out.StyleSummary == "stale summary" {
t.Fatal("expected rebuild to replace stale summary")
}
if out.PostCount != 1 {
t.Fatalf("expected rebuilt post count 1, got %d", out.PostCount)
}
}
// ---- edge cases ----

View file

@ -4,9 +4,6 @@ import (
"context"
"fmt"
"net/http"
"sort"
"strings"
"sync"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
@ -14,93 +11,27 @@ import (
"github.com/nacorid/naco-api/internal/utils"
)
// meanStat is a cumulative mean over a mutex; used for per-tool latency (ms).
type meanStat struct {
mu sync.Mutex
sum float64
n int64
}
func (m *meanStat) Record(v float64) {
m.mu.Lock()
m.sum += v
m.n++
m.mu.Unlock()
}
func (m *meanStat) GetMean() float64 {
m.mu.Lock()
defer m.mu.Unlock()
if m.n == 0 {
return 0
}
return m.sum / float64(m.n)
}
type toolStat struct {
calls utils.Counter
errors utils.Counter
latency meanStat
}
type MetricsMiddleware struct {
mu sync.Mutex
requestsBucket utils.RollingStats
totalRequests utils.Counter
totalErrors utils.Counter
enrichEvents utils.Counter
toolStats map[string]*toolStat
}
func NewMetricsHook() *MetricsMiddleware {
return &MetricsMiddleware{
requestsBucket: *utils.NewRollingStats(60, 1*time.Second),
totalRequests: *utils.NewCounter(),
totalErrors: *utils.NewCounter(),
enrichEvents: *utils.NewCounter(),
toolStats: make(map[string]*toolStat),
}
}
func (mh *MetricsMiddleware) statsFor(tool string) *toolStat {
mh.mu.Lock()
defer mh.mu.Unlock()
s, ok := mh.toolStats[tool]
if !ok {
s = &toolStat{}
mh.toolStats[tool] = s
}
return s
}
func (mh *MetricsMiddleware) RecordTool(tool string, err error, duration time.Duration) {
mh.totalRequests.Increment()
if err != nil {
mh.totalErrors.Increment()
}
s := mh.statsFor(tool)
s.calls.Increment()
if err != nil {
s.errors.Increment()
}
s.latency.Record(float64(duration) / float64(time.Millisecond))
}
func (mh *MetricsMiddleware) RecordEnrichment() {
mh.enrichEvents.Increment()
}
func (mh *MetricsMiddleware) OnCall(next mcp.MethodHandler) mcp.MethodHandler {
return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) {
if method == "tools/call" {
toolName := "unknown"
if callReq, ok := request.(*mcp.CallToolRequest); ok && callReq.Params != nil {
toolName = callReq.Params.Name
}
start := time.Now()
mh.requestsBucket.Increment()
mh.totalRequests.Increment()
result, err := next(ctx, method, request)
mh.RecordTool(toolName, err, time.Since(start))
duration := fmt.Sprintf("%.2fms", float64(time.Since(start))/float64(time.Millisecond))
log.DebugWithContext(ctx, "MetricsMiddleware: MCP Handler", "duration", duration)
return result, err
}
return next(ctx, method, request)
@ -111,6 +42,7 @@ func (mh *MetricsMiddleware) OnCallHTTP(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
mh.requestsBucket.Increment()
mh.totalRequests.Increment()
next.ServeHTTP(w, r)
duration := fmt.Sprintf("%.2fms", float64(time.Since(start))/float64(time.Millisecond))
log.DebugWithContext(r.Context(), "MetricsMiddleware: HTTP Handler", "duration", duration)
@ -124,42 +56,3 @@ func (mh *MetricsMiddleware) GetRPS() float64 {
func (mh *MetricsMiddleware) GetTotalRequests() int64 {
return mh.totalRequests.Get()
}
// MetricsText renders Prometheus-style exposition text for /metrics.
func (mh *MetricsMiddleware) MetricsText() string {
mh.mu.Lock()
tools := make([]string, 0, len(mh.toolStats))
for t := range mh.toolStats {
tools = append(tools, t)
}
sort.Strings(tools)
mh.mu.Unlock()
var b strings.Builder
fmt.Fprintf(&b, "# HELP naco_requests_total Total MCP tool calls.\n")
fmt.Fprintf(&b, "# TYPE naco_requests_total counter\n")
fmt.Fprintf(&b, "naco_requests_total %d\n", mh.totalRequests.Get())
fmt.Fprintf(&b, "# HELP naco_errors_total Total MCP tool calls that returned an error.\n")
fmt.Fprintf(&b, "# TYPE naco_errors_total counter\n")
fmt.Fprintf(&b, "naco_errors_total %d\n", mh.totalErrors.Get())
fmt.Fprintf(&b, "# HELP naco_enrichment_total Total background enrichment runs.\n")
fmt.Fprintf(&b, "# TYPE naco_enrichment_total counter\n")
fmt.Fprintf(&b, "naco_enrichment_total %d\n", mh.enrichEvents.Get())
fmt.Fprintf(&b, "# HELP naco_requests_rps Rolling requests per second.\n")
fmt.Fprintf(&b, "# TYPE naco_requests_rps gauge\n")
fmt.Fprintf(&b, "naco_requests_rps %.3f\n", mh.GetRPS())
for _, t := range tools {
s := mh.statsFor(t)
fmt.Fprintf(&b, "# HELP naco_tool_calls_total Tool calls for %s.\n", t)
fmt.Fprintf(&b, "# TYPE naco_tool_calls_total counter\n")
fmt.Fprintf(&b, "naco_tool_calls_total{tool=%q} %d\n", t, s.calls.Get())
fmt.Fprintf(&b, "# HELP naco_tool_errors_total Tool errors for %s.\n", t)
fmt.Fprintf(&b, "# TYPE naco_tool_errors_total counter\n")
fmt.Fprintf(&b, "naco_tool_errors_total{tool=%q} %d\n", t, s.errors.Get())
fmt.Fprintf(&b, "# HELP naco_tool_latency_ms Rolling mean latency (ms) for %s.\n", t)
fmt.Fprintf(&b, "# TYPE naco_tool_latency_ms gauge\n")
fmt.Fprintf(&b, "naco_tool_latency_ms{tool=%q} %.3f\n", t, s.latency.GetMean())
}
return b.String()
}

View file

@ -1,44 +0,0 @@
package server
import (
"fmt"
"strings"
)
func determinePlatform(author string) (platform, error) {
if strings.HasPrefix(author, "did:") {
return Bluesky, nil
}
if strings.HasPrefix(author, "npub") {
return Nostr, nil
}
if strings.HasPrefix(author, "@") {
if strings.Count(author, "@") > 1 {
return ActivityPub, nil
}
if strings.Contains(author, ".") {
return Bluesky, nil
}
return Twitter, nil
}
if len(author) == 64 {
isHex := true
for _, c := range author {
if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') {
isHex = false
break
}
}
if isHex {
return Nostr, nil
}
}
if strings.Contains(author, ".") {
return Bluesky, nil
}
return -1, fmt.Errorf("unknown author format: %s", author)
}

View file

@ -1,57 +0,0 @@
package server
import "testing"
func TestDeterminePlatform(t *testing.T) {
cases := []struct {
author string
want platform
err bool
}{
{"did:plc:abc123", Bluesky, false},
{"npub1qwerty", Nostr, false},
{"@user@host.social", ActivityPub, false},
{"@user.bsky.social", Bluesky, false},
{"@user", Twitter, false},
{"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", Nostr, false},
{"user.bsky.social", Bluesky, false},
{"example.com", Bluesky, false},
{"justhandle", -1, true},
{"", -1, true},
}
for _, c := range cases {
got, err := determinePlatform(c.author)
if c.err {
if err == nil {
t.Fatalf("%q: expected error", c.author)
}
continue
}
if err != nil {
t.Fatalf("%q: unexpected error %v", c.author, err)
}
if got != c.want {
t.Fatalf("%q: got %v want %v", c.author, got, c.want)
}
}
}
func TestPlatformFromString(t *testing.T) {
cases := []struct {
in string
want platform
ok bool
}{
{"Bluesky", Bluesky, true},
{"ActivityPub", ActivityPub, true},
{"Nostr", Nostr, true},
{"Twitter", Twitter, true},
{"nope", -1, false},
}
for _, c := range cases {
got, ok := platformFromString(c.in)
if ok != c.ok || got != c.want {
t.Fatalf("%q: got %v ok %v, want %v %v", c.in, got, ok, c.want, c.ok)
}
}
}

View file

@ -1,144 +0,0 @@
package server
import (
"context"
"fmt"
"time"
)
func (s *Server) fetchSinglePost(ctx context.Context, platform platform, author, recordKey, collection string) (*GetPostResponsePost, error) {
key := fmt.Sprintf("post:%s|%s|%s|%s", platform, author, collection, recordKey)
return cachedFetch(s.postCache, key, func() (*GetPostResponsePost, error) {
switch platform {
case Bluesky:
res, err := s.bskyClient.GetPost(ctx, author, collection, recordKey)
if err != nil {
return nil, err
}
r := bskyPostViewToResponse(res)
return r, nil
case ActivityPub:
p, err := s.apClient.GetPost(ctx, recordKey)
if err != nil {
return nil, err
}
r := apPostToResponse(p)
return r, nil
case Nostr:
p, err := s.nostrClient.GetPost(ctx, recordKey)
if err != nil {
return nil, err
}
r := nostrPostToResponse(p)
return r, nil
case Twitter:
p, err := s.twitterClient.GetPost(ctx, recordKey)
if err != nil {
return nil, err
}
r := twitterPostToResponse(p)
return r, nil
}
return nil, fmt.Errorf("unsupported platform %q", platform)
})
}
func (s *Server) fetchLatestPosts(ctx context.Context, platform platform, author string, limit int) ([]*GetPostResponsePost, string, error) {
key := fmt.Sprintf("latest:%s|%s|%d", platform, author, limit)
cv, err := cachedFetch(s.postCache, key, func() (latestCacheValue, error) {
switch platform {
case Bluesky:
res, err := s.bskyClient.GetLatestNPosts(ctx, author, limit)
if err != nil {
return latestCacheValue{}, err
}
posts, aid := blueskyFeedViewsToResponses(res)
return latestCacheValue{posts: posts, aid: aid}, nil
case ActivityPub:
res, err := s.apClient.GetLatestNPosts(ctx, author, limit)
if err != nil {
return latestCacheValue{}, err
}
posts, aid := apPostsPtrToResponses(res)
return latestCacheValue{posts: posts, aid: aid}, nil
case Nostr:
res, err := s.nostrClient.GetLatestNPosts(ctx, author, limit)
if err != nil {
return latestCacheValue{}, err
}
posts, aid := nostrPostsToResponses(res)
return latestCacheValue{posts: posts, aid: aid}, nil
case Twitter:
res, err := s.twitterClient.GetLatestNPosts(ctx, author, limit)
if err != nil {
return latestCacheValue{}, err
}
posts, aid := twitterPostsToResponses(res)
return latestCacheValue{posts: posts, aid: aid}, nil
}
return latestCacheValue{}, fmt.Errorf("unsupported platform %q", platform)
})
if err != nil {
return nil, "", err
}
return cv.posts, cv.aid, nil
}
func (s *Server) fetchPostsInTime(ctx context.Context, platform platform, author string, start, end time.Time, limit int) ([]*GetPostResponsePost, string, error) {
key := fmt.Sprintf("time:%s|%s|%s|%s|%d", platform, author, start.Format(time.RFC3339), end.Format(time.RFC3339), limit)
cv, err := cachedFetch(s.postCache, key, func() (latestCacheValue, error) {
switch platform {
case Bluesky:
res, err := s.bskyClient.GetPostsInTimeframe(ctx, author, start, end, limit)
if err != nil {
return latestCacheValue{}, err
}
posts, aid := blueskyFeedViewsToResponses(res)
return latestCacheValue{posts: posts, aid: aid}, nil
case ActivityPub:
res, err := s.apClient.GetPostsInTimeframe(ctx, author, start, end, limit)
if err != nil {
return latestCacheValue{}, err
}
posts, aid := apPostsToResponses(res)
return latestCacheValue{posts: posts, aid: aid}, nil
case Nostr:
res, err := s.nostrClient.GetPostsInTimeframe(ctx, author, start, end, limit)
if err != nil {
return latestCacheValue{}, err
}
posts, aid := nostrPostsToResponses(res)
return latestCacheValue{posts: posts, aid: aid}, nil
case Twitter:
res, err := s.twitterClient.GetPostsInTimeframe(ctx, author, start, end, limit)
if err != nil {
return latestCacheValue{}, err
}
posts, aid := twitterPostsToResponses(res)
return latestCacheValue{posts: posts, aid: aid}, nil
}
return latestCacheValue{}, fmt.Errorf("unsupported platform %q", platform)
})
if err != nil {
return nil, "", err
}
return cv.posts, cv.aid, nil
}
func (s *Server) fetchFeed(ctx context.Context, platform platform, uri string) ([]*GetPostResponsePost, error) {
key := fmt.Sprintf("feed:%s|%s", platform, uri)
return cachedFetch(s.postCache, key, func() ([]*GetPostResponsePost, error) {
if platform == Bluesky {
res, err := s.bskyClient.GetFeed(ctx, uri, 50)
if err != nil {
return nil, err
}
posts, _ := blueskyFeedViewsToResponses(res.Feed)
return posts, nil
}
// Non-Bluesky platforms have no custom-feed concept; treat the URI as an author handle
// and return their latest posts as the feed.
posts, _, err := s.fetchLatestPosts(ctx, platform, uri, 50)
return posts, err
})
}

View file

@ -1,255 +0,0 @@
package server
import (
"time"
"github.com/bluesky-social/indigo/api/bsky"
"github.com/nacorid/naco-api/internal/activitypub"
"github.com/nacorid/naco-api/internal/clustering"
"github.com/nacorid/naco-api/internal/nostr"
"github.com/nacorid/naco-api/internal/twitter"
)
func bskyPostViewToResponse(p *bsky.FeedDefs_PostView) *GetPostResponsePost {
author := ""
if p.Author != nil {
author = p.Author.Did
}
content := ""
created := ""
if fp, ok := p.Record.Val.(*bsky.FeedPost); ok {
content = fp.Text
created = fp.CreatedAt
}
likes, reposts, quotes, replies := int64(0), int64(0), int64(0), int64(0)
if p.LikeCount != nil {
likes = *p.LikeCount
}
if p.RepostCount != nil {
reposts = *p.RepostCount
}
if p.QuoteCount != nil {
quotes = *p.QuoteCount
}
if p.ReplyCount != nil {
replies = *p.ReplyCount
}
return &GetPostResponsePost{
Author: author,
Content: content,
Likes: likes,
Reposts: reposts,
Quotes: quotes,
Replies: replies,
CreatedAt: created,
}
}
func apPostToResponse(p *activitypub.Post) *GetPostResponsePost {
created := ""
if !p.CreatedAt.IsZero() {
created = p.CreatedAt.Format(time.RFC3339)
}
return &GetPostResponsePost{
Author: p.Author,
Content: p.Content,
Likes: int64(p.FavouritesCount),
Reposts: int64(p.ReblogsCount),
Replies: int64(p.RepliesCount),
CreatedAt: created,
}
}
func nostrPostToResponse(p *nostr.Post) *GetPostResponsePost {
created := ""
if !p.CreatedAt.IsZero() {
created = p.CreatedAt.Format(time.RFC3339)
}
return &GetPostResponsePost{
Author: p.Author,
Content: p.Content,
CreatedAt: created,
}
}
func twitterPostToResponse(p *twitter.Post) *GetPostResponsePost {
created := ""
if !p.CreatedAt.IsZero() {
created = p.CreatedAt.Format(time.RFC3339)
}
return &GetPostResponsePost{
Author: p.Author,
Content: p.Content,
Likes: int64(p.Likes),
Reposts: int64(p.Reposts),
Quotes: int64(p.Quotes),
Replies: int64(p.Replies),
CreatedAt: created,
}
}
// ---- Batch converters for GetPostResponsePost ----
func blueskyFeedViewsToResponses(views []*bsky.FeedDefs_FeedViewPost) ([]*GetPostResponsePost, string) {
posts := make([]*GetPostResponsePost, 0, len(views))
var aid string
for _, p := range views {
if p.Post == nil {
continue
}
posts = append(posts, bskyPostViewToResponse(p.Post))
if p.Post.Author != nil {
aid = p.Post.Author.Did
}
}
return posts, aid
}
func apPostsPtrToResponses(posts []*activitypub.Post) ([]*GetPostResponsePost, string) {
out := make([]*GetPostResponsePost, 0, len(posts))
var aid string
for _, p := range posts {
out = append(out, apPostToResponse(p))
aid = p.Author
}
return out, aid
}
func apPostsToResponses(posts []*activitypub.Post) ([]*GetPostResponsePost, string) {
out := make([]*GetPostResponsePost, 0, len(posts))
var aid string
for _, p := range posts {
out = append(out, apPostToResponse(p))
aid = p.Author
}
return out, aid
}
func nostrPostsToResponses(posts []*nostr.Post) ([]*GetPostResponsePost, string) {
out := make([]*GetPostResponsePost, 0, len(posts))
var aid string
for _, p := range posts {
out = append(out, nostrPostToResponse(p))
aid = p.Author
}
return out, aid
}
func twitterPostsToResponses(posts []*twitter.Post) ([]*GetPostResponsePost, string) {
out := make([]*GetPostResponsePost, 0, len(posts))
var aid string
for _, p := range posts {
out = append(out, twitterPostToResponse(p))
aid = p.Author
}
return out, aid
}
// ---- Batch converters for clustering.Item (style & analytics) ----
func blueskyFeedViewsToItems(views []*bsky.FeedDefs_FeedViewPost) []clustering.Item {
out := make([]clustering.Item, 0, len(views))
for _, p := range views {
fp, ok := p.Post.Record.Val.(*bsky.FeedPost)
if !ok {
continue
}
if fp.Text == "" {
continue
}
out = append(out, clustering.Item{ID: p.Post.Uri, Text: fp.Text})
}
return out
}
func apPostsPtrToItems(posts []*activitypub.Post) []clustering.Item {
out := make([]clustering.Item, 0, len(posts))
for _, p := range posts {
if p.Content != "" {
out = append(out, clustering.Item{ID: p.ID, Text: p.Content})
}
}
return out
}
func nostrPostsToItems(posts []*nostr.Post) []clustering.Item {
out := make([]clustering.Item, 0, len(posts))
for _, p := range posts {
if p.Content != "" {
out = append(out, clustering.Item{ID: p.ID, Text: p.Content})
}
}
return out
}
func twitterPostsToItems(posts []*twitter.Post) []clustering.Item {
out := make([]clustering.Item, 0, len(posts))
for _, p := range posts {
if p.Content != "" {
out = append(out, clustering.Item{ID: p.ID, Text: p.Content})
}
}
return out
}
// analytics-specific converters that preserve engagement + timestamps
func blueskyFeedViewsToAnalyticsItems(views []*bsky.FeedDefs_FeedViewPost) []clustering.Item {
out := make([]clustering.Item, 0, len(views))
for _, p := range views {
if p.Post == nil || p.Post.Record == nil {
continue
}
fp, ok := p.Post.Record.Val.(*bsky.FeedPost)
if !ok {
continue
}
item := clustering.Item{ID: p.Post.Uri, Text: fp.Text}
if ts, err := time.Parse(time.RFC3339, p.Post.IndexedAt); err == nil {
item.Timestamp = ts
}
item.Likes = int64Val(p.Post.LikeCount)
item.Reposts = int64Val(p.Post.RepostCount)
item.Replies = int64Val(p.Post.ReplyCount)
item.Quotes = int64Val(p.Post.QuoteCount)
out = append(out, item)
}
return out
}
func apPostsToAnalyticsItems(posts []*activitypub.Post) []clustering.Item {
out := make([]clustering.Item, 0, len(posts))
for _, p := range posts {
if p.Content != "" {
out = append(out, clustering.Item{
ID: p.ID, Text: p.Content, Timestamp: p.CreatedAt,
Likes: int64(p.FavouritesCount), Reposts: int64(p.ReblogsCount), Replies: int64(p.RepliesCount),
})
}
}
return out
}
func nostrPostsToAnalyticsItems(posts []*nostr.Post) []clustering.Item {
out := make([]clustering.Item, 0, len(posts))
for _, p := range posts {
if p.Content != "" {
out = append(out, clustering.Item{ID: p.ID, Text: p.Content, Timestamp: p.CreatedAt})
}
}
return out
}
func twitterPostsToAnalyticsItems(posts []*twitter.Post) []clustering.Item {
out := make([]clustering.Item, 0, len(posts))
for _, p := range posts {
if p.Content == "" {
continue
}
out = append(out, clustering.Item{
ID: p.ID, Text: p.Content, Timestamp: p.CreatedAt,
Likes: int64(p.Likes), Reposts: int64(p.Reposts), Replies: int64(p.Replies), Quotes: int64(p.Quotes),
})
}
return out
}

View file

@ -1,93 +0,0 @@
package server
import (
"context"
"time"
log "github.com/nacorid/logger"
)
// StartPrewarm launches a background goroutine that discovers interesting authors and
// pre-fetches their style profiles. It issues at most one pre-fetch "request" per minute
// so upstream platforms, Ollama and the database are never hit harder than that cadence
// allows. Profiles are still only (re)written when older than a week, exactly like a live
// getStyleProfile request. Failures (including rate limits) are logged and retried on the
// next tick.
func (s *Server) StartPrewarm(ctx context.Context) {
if s.prewarmFeedURI == "" && len(s.prewarmAuthors) == 0 {
log.DebugWithContext(ctx, "no prewarm feed or authors configured, skipping prewarm")
return
}
go func() {
authors := s.discoverInterestingAuthors(ctx)
if len(authors) == 0 {
return
}
s.prewarmOne(ctx, authors[0])
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
i := 1
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if i >= len(authors) {
// re-discover interesting profiles and restart the cycle
authors = s.discoverInterestingAuthors(ctx)
i = 0
if len(authors) == 0 {
continue
}
}
author := authors[i]
i++
s.prewarmOne(ctx, author)
}
}
}()
}
// discoverInterestingAuthors returns a list of authors worth pre-fetching. When a feed URI
// is configured it pulls recent posts from that feed and collects the author DIDs;
// otherwise it falls back to the statically configured seed list.
func (s *Server) discoverInterestingAuthors(ctx context.Context) []string {
if s.prewarmFeedURI != "" {
feed, err := s.bskyClient.GetFeed(ctx, s.prewarmFeedURI, 50)
if err != nil {
log.WarnWithContext(ctx, "prewarm: failed to discover authors from feed", "error", err)
} else {
seen := make(map[string]struct{})
authors := make([]string, 0, len(feed.Feed))
for _, p := range feed.Feed {
if p.Post == nil || p.Post.Author == nil {
continue
}
did := p.Post.Author.Did
if did == "" {
continue
}
if _, ok := seen[did]; ok {
continue
}
seen[did] = struct{}{}
authors = append(authors, did)
}
if len(authors) > 0 {
return authors
}
}
}
return s.prewarmAuthors
}
func (s *Server) prewarmOne(ctx context.Context, author string) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
if _, err := s.requestStyleProfile(ctx, author); err != nil {
log.WarnWithContext(ctx, "prewarm: failed to build profile", "author", author, "error", err)
return
}
log.InfoWithContext(ctx, "prewarmed style profile", "author", author)
}

View file

@ -1,84 +0,0 @@
package server
import (
"testing"
"github.com/nacorid/naco-api/internal/actor"
)
func TestNormalizeHandle(t *testing.T) {
cases := []struct {
in string
want string
}{
{"", ""},
{" ", ""},
{"@alice", "alice"},
{"@", ""},
{"alice@mastodon.social", "alice"},
{"Alice@Mastodon.Social", "alice"},
{"@Alice@mastodon.social", "alice"},
{"alice.bsky.social", "alice.bsky.social"},
{" @bob.X ", "bob.x"},
}
for _, c := range cases {
if got := normalizeHandle(c.in); got != c.want {
t.Errorf("normalizeHandle(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestDedupeActors(t *testing.T) {
in := []*ActorProfile{
{Handle: "@alice", DisplayName: "Alice"},
{Handle: "alice@mastodon.social", DisplayName: "Alice"},
{Handle: "", DisplayName: ""},
{Handle: "bob", DisplayName: "Bob"},
}
// @alice and alice@mastodon.social dedupe by handle; the empty-handle AND
// empty-display-name is appended unconditionally; bob is another person.
out := dedupeActors(in)
if len(out) != 3 {
t.Fatalf("expected 3 deduped results, got %d: %+v", len(out), out)
}
}
func TestDedupeActorsByDisplayNameOnly(t *testing.T) {
// When handles are empty but display names collide, dedupe by display name.
in := []*ActorProfile{
{Handle: "", DisplayName: "Shared"},
{Handle: "", DisplayName: "Shared"},
{Handle: "", DisplayName: "Other"},
}
out := dedupeActors(in)
if len(out) != 2 {
t.Fatalf("expected 2 results after display-name dedup, got %d: %+v", len(out), out)
}
}
func TestSearchActorsTwitterExcluded(t *testing.T) {
// searchActorsAll only queries Bluesky, ActivityPub and Nostr. A Twitter
// result must never appear because the backend is not consulted.
tw := &fakeTwitter{actors: []*actor.Profile{{Platform: "Twitter", Handle: "t"}}, calls: 0}
bsky := &fakeBluesky{actors: []*actor.Profile{{Platform: "Bluesky", Handle: "a"}}}
ap := &fakeAP{actors: []*actor.Profile{{Platform: "ActivityPub", Handle: "b"}}}
nstr := &fakeNostr{actors: []*actor.Profile{{Platform: "Nostr", Handle: "c"}}}
s := newTestServer(bsky, ap, nstr, tw)
res, err := callTool(s, "searchActors", SearchActorsRequest{Query: "q"})
if err != nil {
t.Fatalf("searchActors error: %v", err)
}
var out SearchActorsResponse
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
for _, r := range out.Results {
if r.Platform == "Twitter" {
t.Fatalf("unexpected Twitter result: %+v", r)
}
}
if tw.calls != 0 {
t.Fatalf("expected Twitter backend untouched, got %d calls", tw.calls)
}
}

View file

@ -2,64 +2,103 @@ package server
import (
"context"
"encoding/json"
"fmt"
"maps"
"net/http"
"os"
"strings"
"time"
"github.com/bluesky-social/indigo/api/bsky"
"github.com/modelcontextprotocol/go-sdk/mcp"
log "github.com/nacorid/logger"
"github.com/nacorid/naco-api/internal/activitypub"
"github.com/nacorid/naco-api/internal/bluesky"
"github.com/nacorid/naco-api/internal/config"
"github.com/nacorid/naco-api/internal/nostr"
"github.com/nacorid/naco-api/internal/ollama"
"github.com/nacorid/naco-api/internal/store"
"github.com/nacorid/naco-api/internal/twitter"
"github.com/nacorid/naco-api/internal/utils"
"github.com/nacorid/naco-api/internal/x402"
x402v2 "github.com/x402-foundation/x402/go/v2"
x402http "github.com/x402-foundation/x402/go/v2/http/nethttp"
)
type Server struct {
// Transport & middleware
listenAddress string
permissions os.FileMode
mcpHandler *x402.X402Server
metrics *MetricsMiddleware
// Dependencies
db Store
apClient apBackend
bskyClient blueskyBackend
nostrClient nostrBackend
twitterClient twitterBackend
ollama ollamaBackend
// Cache & background work
postCache *ttlCache
enrichSem chan struct{}
prewarmAuthors []string
prewarmFeedURI string
enrichEnabled bool
// Payments & analytics tuning
db *store.Queries
apClient *activitypub.APClient
bskyClient *bluesky.BlueskyClient
ollama *ollama.OllamaClient
x402Wallet string
activeNetworks []NetworkInfo
analyticsTTLEngagement time.Duration
analyticsTTLPatterns time.Duration
analyticsTTLTopics time.Duration
analyticsTTLSentiment time.Duration
analyticsTTLDrivers time.Duration
// twitterEnabled reports whether the Twitter API is available (a bearer
// token is configured). It flips the Twitter-capable tools to upto
// variable pricing so the realized Twitter API costs are passed on to
// callers (see registerTools).
twitterEnabled bool
}
func New(ctx context.Context, db *store.Queries, ap *activitypub.APClient, bsky *bluesky.BlueskyClient, n *nostr.Client, t *twitter.Client, o *ollama.OllamaClient, cfg *config.Config) *Server {
toolchain, metrics := buildMiddleware()
srv := newX402Server(ctx, db, cfg, toolchain, metrics)
activeNetworks := resolveActiveNetworks(ctx, cfg.ActiveNetworks)
func New(ctx context.Context, db *store.Queries, ap *activitypub.APClient, bsky *bluesky.BlueskyClient, o *ollama.OllamaClient, cfg *config.Config) *Server {
rateLimits := NewRateLimitMiddleware(float64(1.0/10.0), 2, []string{"getFeed"})
metrics := NewMetricsHook()
logging := NewLoggingMiddleware()
toolChain := x402.X402Middlewares{
MCPMiddleware: []mcp.Middleware{
logging.OnCall,
metrics.OnCall,
rateLimits.OnCall,
},
HTTPMiddleware: []func(http.Handler) http.Handler{
logging.OnCallHTTP,
metrics.OnCallHTTP,
rateLimits.OnCallHTTP,
},
}
auth, err := NewCDPAuth(cfg.ApiKey, cfg.ApiSecret)
if err != nil {
log.FatalWithContext(ctx, "failed to create CDP auth", "error", err)
}
srv, err := x402.NewX402Server(
"x402 enabled Bluesky lookingglass for autonomous Agents",
"0.2.0",
cfg.X402FacilitatorURL,
auth,
toolChain,
GlobalLoggingMiddleware,
func(w http.ResponseWriter, r *http.Request, resp *x402v2.SettleResponse) {
requirements, ok := x402http.RequirementsFromContext(r.Context())
if !ok {
log.WarnWithContext(r.Context(), "failed to extract payment requirements from context during settlement hook")
return
}
amount := requirements.Amount
asset := requirements.Asset
to := requirements.PayTo
_ = settleHook(r.Context(), db, resp.Transaction, amount, asset, string(resp.Network), to, resp.Payer)
},
)
if err != nil {
log.FatalWithContext(ctx, "failed to create x402 server", "error", err)
}
srv.OnAfterSettle(func(settleCtx x402v2.SettleResultContext) error {
amount := settleCtx.Requirements.GetAmount()
asset := settleCtx.Requirements.GetAsset()
to := settleCtx.Requirements.GetPayTo()
from := settleCtx.Result.Payer
return settleHook(settleCtx.Ctx, db, settleCtx.Result.Transaction, amount, asset, string(settleCtx.Result.Network), to, from)
})
enabledNetworkNames := parseActiveNetworks(cfg.ActiveNetworks)
var activeNetworks []NetworkInfo
for _, name := range enabledNetworkNames {
if info, exists := NetworkRegistry[name]; exists {
activeNetworks = append(activeNetworks, info)
} else {
log.WarnWithContext(ctx, "configured network is not defined in the registry", "name", name)
}
}
s := &Server{
listenAddress: cfg.ListenAddress,
@ -68,53 +107,635 @@ func New(ctx context.Context, db *store.Queries, ap *activitypub.APClient, bsky
db: db,
apClient: ap,
bskyClient: bsky,
nostrClient: n,
twitterClient: t,
ollama: o,
metrics: metrics,
postCache: newTTLCache(postCacheTTL),
prewarmAuthors: cfg.PrewarmAuthors,
prewarmFeedURI: cfg.PrewarmFeedURI,
enrichEnabled: cfg.EnrichCrossPlatform,
x402Wallet: cfg.X402WalletAddress,
activeNetworks: activeNetworks,
enrichSem: make(chan struct{}, 4),
analyticsTTLEngagement: cfg.AnalyticsTTLEngagement,
analyticsTTLPatterns: cfg.AnalyticsTTLPatterns,
analyticsTTLTopics: cfg.AnalyticsTTLTopics,
analyticsTTLSentiment: cfg.AnalyticsTTLSentiment,
analyticsTTLDrivers: cfg.AnalyticsTTLDrivers,
twitterEnabled: cfg.TwitterBearerToken != "",
}
// Combine address and port for TCP (not unix socket) if port not already present
if !strings.HasPrefix(cfg.ListenAddress, "unix:") && !strings.Contains(cfg.ListenAddress, ":") {
s.listenAddress = fmt.Sprintf("%s:%s", cfg.ListenAddress, cfg.ListenPort)
}
log.DebugWithContext(ctx, "server initialized",
"networks", len(activeNetworks),
"enrich", s.enrichEnabled,
"prewarm_authors", len(s.prewarmAuthors),
)
s.registerTools()
return s
}
func (s *Server) MetricsText() string {
if s.metrics == nil {
return ""
func (s *Server) registerTools() {
extra := map[string]any{
"OutputSchema": Schema{
Input: InputSchema{
Type: "HTTP",
Method: "POST",
Discoverable: true,
BodyFields: map[string]any{
"actor": Bodyfield{
Type: "string",
Description: "Did or handle of an actor",
Required: true,
},
"rkey": Bodyfield{
Type: "string",
Description: "Record key of the post",
Required: true,
},
"collection": Bodyfield{
Type: "string",
Description: "Collection the post belongs to. Defaults to 'app.bsky.feed.post'",
Required: false,
},
},
},
Output: map[string]any{
"actor": Bodyfield{
Type: "string",
Description: "Did of the actor",
},
"post": Bodyfield{
Type: "object",
Description: "The post object. Includes the post content 'content', like 'likes', quote 'quotes', replies 'replies' and repost 'reposts' count",
},
},
},
}
return s.metrics.MetricsText()
s.mcpHandler.AddPayableTool(
mcp.Tool{Name: "getPost",
Description: "Get a specific post from a specific actor",
InputSchema: GetPostRequest{},
OutputSchema: GetPostResponsePost{},
},
s.getPost,
s.buildRequirements("0.01", extra)...,
)
extra = map[string]any{
"OutputSchema": Schema{
Input: InputSchema{
Type: "HTTP",
Method: "POST",
Discoverable: true,
BodyFields: map[string]any{
"actor": Bodyfield{
Type: "string",
Description: "Did or handle of an actor",
Required: true,
},
"n": Bodyfield{
Type: "integer",
Description: "Number of posts to retrieve, maximum 50",
Required: true,
},
},
},
Output: map[string]any{
"actor": Bodyfield{
Type: "string",
Description: "Did of an actor",
},
"posts": Bodyfield{
Type: "array",
Description: "Array of post objects. Each includes the post content 'content', like 'likes', quote 'quotes', replies 'replies' and repost 'reposts' count",
},
},
},
}
s.mcpHandler.AddPayableTool(
mcp.Tool{Name: "getLatestPosts",
Description: "Get the latest n posts from a specific actor",
InputSchema: GetLatestPostsRequest{},
OutputSchema: GetLatestPostsResponse{},
},
s.getLatestPosts,
s.buildRequirements("0.10", extra)...,
)
extra = map[string]any{
"OutputSchema": Schema{
Input: InputSchema{
Type: "HTTP",
Method: "POST",
Discoverable: true,
BodyFields: map[string]any{
"actor": Bodyfield{
Type: "string",
Description: "Did or handle of an actor",
Required: true,
},
"start": Bodyfield{
Type: "string",
Description: "Start time in RFC3339 format",
Required: true,
},
"end": Bodyfield{
Type: "string",
Description: "End time in RFC3339 format",
Required: true,
},
"n": Bodyfield{
Type: "integer",
Description: "Maximum number of posts to retrieve, maximum 50",
Required: true,
},
},
},
Output: map[string]any{
"actor": Bodyfield{
Type: "string",
Description: "Did of an actor",
},
"posts": Bodyfield{
Type: "array",
Description: "Array of post objects. Each includes the post content 'content', like 'likes', quote 'quotes', replies 'replies' and repost 'reposts' count",
},
},
},
}
s.mcpHandler.AddPayableTool(
mcp.Tool{Name: "getPostsInTime",
Description: "Get posts from a specific actor in a time range",
InputSchema: GetPostsInTimeRequest{},
OutputSchema: GetPostsInTimeResponse{},
},
s.getPostsInTime,
s.buildRequirements("0.10", extra)...,
)
extra = map[string]any{
"OutputSchema": Schema{
Input: InputSchema{
Type: "HTTP",
Method: "POST",
Discoverable: true,
BodyFields: map[string]any{
"actor": Bodyfield{
Type: "string",
Description: "Did or handle of an actor",
Required: true,
},
},
},
Output: map[string]any{
"PersonaEmbedding": Bodyfield{
Type: "array",
Description: "Embedding vector representing the actor's persona",
},
"TopKeywords": Bodyfield{
Type: "array",
Description: "Top words used by the actor",
},
"StyleSummary": Bodyfield{
Type: "string",
Description: "Summary of the actor's writing style",
},
"PostCount": Bodyfield{
Type: "integer",
Description: "Number of posts analyzed",
},
},
},
}
/*s.mcpHandler.AddPayableTool(
mcp.Tool{Name: "getStyleProfile",
Description: "Get an actor's style Profile",
InputSchema: GetStyleProfileRequest{},
OutputSchema: GetStyleProfileResponse{},
},
s.getStyleProfile,
s.buildRequirements("100000", extra)...,
)*/
s.mcpHandler.AddTool(
mcp.Tool{Name: "getFeed",
Description: "Get a feed by its URI",
InputSchema: GetFeedRequest{},
OutputSchema: GetFeedResponse{},
},
s.getFeed,
)
}
func (s *Server) getPost(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var args GetPostRequest
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
if args.Author == "" {
return nil, fmt.Errorf("author must be a valid did or handle")
}
if args.RecordKey == "" {
return nil, fmt.Errorf("rkey must be a valid record key")
}
res, err := s.bskyClient.GetPost(ctx, args.Author, args.Collection, args.RecordKey)
if err != nil {
return nil, err
}
post, ok := res.Record.Val.(*bsky.FeedPost)
if !ok {
return nil, fmt.Errorf("record is not a feed post")
}
resp := GetPostResponsePost{
Author: res.Author.Did,
Content: post.Text,
Likes: *res.LikeCount,
Reposts: *res.RepostCount,
Quotes: *res.QuoteCount,
Replies: *res.ReplyCount,
CreatedAt: post.CreatedAt,
}
respBytes, err := json.Marshal(resp)
if err != nil {
return nil, err
}
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: string(respBytes)},
},
}, nil
}
func (s *Server) getLatestPosts(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var args GetLatestPostsRequest
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
if args.Author == "" {
return nil, fmt.Errorf("author must be a valid did or handle")
}
if args.Limit <= 0 {
return nil, fmt.Errorf("n must be a positive integer")
}
if args.Limit > 50 {
return nil, fmt.Errorf("n must be at most 50")
}
res, err := s.bskyClient.GetLatestNPosts(ctx, args.Author, args.Limit)
if err != nil {
return nil, err
}
posts := make([]GetPostResponsePost, 0, len(res))
var author string
for _, p := range res {
post, ok := p.Post.Record.Val.(*bsky.FeedPost)
if !ok {
continue
}
posts = append(posts, GetPostResponsePost{
Content: post.Text,
Likes: *p.Post.LikeCount,
Reposts: *p.Post.RepostCount,
Quotes: *p.Post.QuoteCount,
Replies: *p.Post.ReplyCount,
CreatedAt: post.CreatedAt,
})
author = p.Post.Author.Did
}
if author == "" {
author = args.Author
}
resp := GetLatestPostsResponse{
Author: author,
Posts: posts,
}
respBytes, err := json.Marshal(resp)
if err != nil {
return nil, err
}
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: string(respBytes)},
},
}, nil
}
func (s *Server) getPostsInTime(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var args GetPostsInTimeRequest
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
if args.Author == "" {
return nil, fmt.Errorf("author must be a valid did or handle")
}
if args.Limit <= 0 {
return nil, fmt.Errorf("n must be a positive integer")
}
limit := min(args.Limit, 50)
startTime, err := time.Parse(time.RFC3339, args.StartTime)
if err != nil {
return nil, fmt.Errorf("startTime must be in RFC3339 format")
}
endTime, err := time.Parse(time.RFC3339, args.EndTime)
if err != nil {
return nil, fmt.Errorf("startTime must be in RFC3339 format")
}
if startTime.After(endTime) {
tmp := startTime
startTime = endTime
endTime = tmp
}
res, err := s.bskyClient.GetPostsInTimeframe(ctx, args.Author, startTime, endTime, limit)
if err != nil {
return nil, err
}
posts := make([]GetPostResponsePost, 0, len(res))
var author string
for _, p := range res {
post, ok := p.Post.Record.Val.(*bsky.FeedPost)
if !ok {
continue
}
posts = append(posts, GetPostResponsePost{
Content: post.Text,
Likes: *p.Post.LikeCount,
Reposts: *p.Post.RepostCount,
Quotes: *p.Post.QuoteCount,
Replies: *p.Post.ReplyCount,
CreatedAt: post.CreatedAt,
})
author = p.Post.Author.Did
}
if author == "" {
author = args.Author
}
resp := &GetPostsInTimeResponse{
Author: author,
Posts: posts,
}
respBytes, err := json.Marshal(resp)
if err != nil {
return nil, err
}
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: string(respBytes)},
},
}, nil
}
func (s *Server) getFeed(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var args GetFeedRequest
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
if args.URI == "" {
return nil, fmt.Errorf("feedURI must be a valid feed URI")
}
res, err := s.bskyClient.GetFeed(ctx, args.URI, 50)
if err != nil {
return nil, err
}
posts := make([]GetPostResponsePost, 0, len(res.Feed))
for _, p := range res.Feed {
post, ok := p.Post.Record.Val.(*bsky.FeedPost)
if !ok {
continue
}
posts = append(posts, GetPostResponsePost{
Author: p.Post.Author.Did,
Content: post.Text,
Likes: *p.Post.LikeCount,
Reposts: *p.Post.RepostCount,
Quotes: *p.Post.QuoteCount,
Replies: *p.Post.ReplyCount,
CreatedAt: post.CreatedAt,
})
}
resp := GetFeedResponse{
URI: args.URI,
Posts: posts,
}
respBytes, err := json.Marshal(resp)
if err != nil {
return nil, err
}
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: string(respBytes)},
},
}, nil
}
func (s *Server) getStyleProfile(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var args GetStyleProfileRequest
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
if args.Author == "" {
return nil, fmt.Errorf("author must be a valid did or handle")
}
styleProfile, err := s.db.GetStyleProfile(ctx, store.GetStyleProfileParams{
UserID: args.Author,
Platform: "bluesky",
})
isRecent := styleProfile.ModifiedAt.Valid && time.Since(styleProfile.ModifiedAt.Time) < 24*7*time.Hour
if err == nil && isRecent {
embedding, err := utils.ParseEmbedding(styleProfile.Embedding)
if err != nil {
return nil, err
}
resp := GetStyleProfileResponse{
Author: styleProfile.UserID,
PersonaEmbedding: embedding,
TopKeywords: styleProfile.TopKeywords,
PostCount: int(styleProfile.PostCount),
StyleSummary: styleProfile.StyleSummary,
}
respBytes, err := json.Marshal(resp)
if err != nil {
return nil, err
}
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: string(respBytes)},
},
}, nil
}
platform, err := determinePlatform(args.Author)
if err != nil {
return nil, err
}
switch platform {
case Bluesky:
}
res, err := s.bskyClient.GetLatestNPosts(ctx, args.Author, 50)
if err != nil {
return nil, err
}
posts := make([]string, 0, len(res))
var author string
for _, p := range res {
post, ok := p.Post.Record.Val.(*bsky.FeedPost)
if !ok {
continue
}
posts = append(posts, post.Text)
author = p.Post.Author.Did
}
embeddings, err := s.ollama.EmbedTexts(ctx, posts)
if err != nil {
return nil, err
}
personaEmbedding := utils.AverageVector(embeddings)
topWords := utils.TopWords(posts, make(map[string]struct{}), 10)
styleSummary, err := s.ollama.SummarizeStyle(ctx, posts)
if err != nil {
return nil, err
}
err = s.db.UpsertStyleProfile(ctx, store.UpsertStyleProfileParams{
UserID: author,
Platform: "bluesky",
Embedding: personaEmbedding,
TopKeywords: topWords,
StyleSummary: styleSummary,
PostCount: int32(len(posts)),
})
if err != nil {
return nil, err
}
if author == "" {
author = args.Author
}
profile := &GetStyleProfileResponse{
Author: author,
PersonaEmbedding: personaEmbedding,
TopKeywords: topWords,
StyleSummary: styleSummary,
PostCount: len(posts),
SourcePlatform: "bluesky",
}
profileBytes, err := json.Marshal(profile)
if err != nil {
return nil, err
}
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: string(profileBytes)},
},
}, nil
}
func determinePlatform(author string) (platform, error) {
if strings.HasPrefix(author, "did:") {
return Bluesky, nil
}
if strings.HasPrefix(author, "npub") {
return Nostr, nil
}
if strings.HasPrefix(author, "@") {
if strings.Count(author, "@") > 1 {
return ActivityPub, nil
}
if strings.Contains(author, ".") {
return Bluesky, nil
}
return Twitter, nil
}
if len(author) == 64 {
isHex := true
for _, c := range author {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
isHex = false
break
}
}
if isHex {
return Nostr, nil
}
}
if strings.Contains(author, ".") {
return Bluesky, nil
}
return -1, fmt.Errorf("unknown author format: %s", author)
}
func settleHook(ctx context.Context, db *store.Queries, transaction, amount, asset, network, to, from string) error {
ret, err := db.SaveTransaction(ctx, store.SaveTransactionParams{
X402TransactionHash: transaction,
Amount: amount,
Asset: asset,
Network: network,
Recipient: to,
Sender: from,
})
var id int64 = 0
if len(ret) > 0 {
id = ret[0].ID
}
fields := log.Fields{
"transaction_id": id,
"X402_transaction_hash": transaction,
"amount": amount,
"asset": asset,
"network": network,
"recipient": to,
"sender": from,
}
if err != nil {
log.WithFields(fields).ErrorWithContext(ctx, "Failed to save transaction", "value", ret, "error", err)
return err
}
log.WithFields(fields).DebugWithContext(ctx, "Saved transaction")
return nil
}
func (s *Server) buildRequirements(amount string, extra map[string]any) []x402v2.PaymentRequirements {
requirements := make([]x402v2.PaymentRequirements, 0, len(s.activeNetworks))
for _, info := range s.activeNetworks {
mergedExtra := make(map[string]any)
maps.Copy(mergedExtra, extra)
maps.Copy(mergedExtra, info.Extra)
requirements = append(requirements, x402v2.PaymentRequirements{
Scheme: "exact",
Network: info.Network,
Asset: info.Asset,
Amount: amount,
PayTo: s.x402Wallet,
MaxTimeoutSeconds: 60,
Extra: mergedExtra,
})
}
return requirements
}
func parseActiveNetworks(raw string) []string {
parts := strings.Split(raw, ",")
var slugs []string
for _, part := range parts {
trimmed := strings.ToLower(strings.TrimSpace(part))
if trimmed != "" {
slugs = append(slugs, trimmed)
}
}
return slugs
}
func (s *Server) Start() error {
//go s.startPostPruner()
return s.mcpHandler.Start(s.listenAddress, s.permissions)
}
func (s *Server) Shutdown(ctx context.Context) error {
return s.mcpHandler.Shutdown(ctx)
}

View file

@ -1,146 +0,0 @@
package server
import (
"context"
"encoding/json"
"net/http"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
log "github.com/nacorid/logger"
"github.com/nacorid/naco-api/internal/config"
"github.com/nacorid/naco-api/internal/store"
"github.com/nacorid/naco-api/internal/x402"
x402v2 "github.com/x402-foundation/x402/go/v2"
x402http "github.com/x402-foundation/x402/go/v2/http/nethttp"
)
// buildMiddleware creates the shared middleware chain and its stateful components.
func buildMiddleware() (x402.X402Middlewares, *MetricsMiddleware) {
rateLimits := NewRateLimitMiddleware(float64(1.0/10.0), 2, []string{"getFeed"})
metrics := NewMetricsHook()
logging := NewLoggingMiddleware()
toolchain := x402.X402Middlewares{
MCPMiddleware: []mcp.Middleware{
logging.OnCall,
metrics.OnCall,
rateLimits.OnCall,
},
HTTPMiddleware: []func(http.Handler) http.Handler{
logging.OnCallHTTP,
metrics.OnCallHTTP,
rateLimits.OnCallHTTP,
},
}
return toolchain, metrics
}
// resolveActiveNetworks parses ACTIVE_NETWORKS and resolves them against NetworkRegistry.
func resolveActiveNetworks(ctx context.Context, raw string) []NetworkInfo {
names := parseActiveNetworks(raw)
var out []NetworkInfo
for _, name := range names {
if info, ok := NetworkRegistry[name]; ok {
out = append(out, info)
} else {
log.WarnWithContext(ctx, "configured network is not defined in the registry", "name", name)
}
}
return out
}
// newX402Server creates the x402-enabled MCP server with settlement and webhook hooks.
// db is the Store used to persist settlements; cfg provides wallet/webhook settings.
func newX402Server(ctx context.Context, db *store.Queries, cfg *config.Config, toolchain x402.X402Middlewares, metrics *MetricsMiddleware) *x402.X402Server {
auth, err := NewCDPAuth(cfg.ApiKey, cfg.ApiSecret)
if err != nil {
log.FatalWithContext(ctx, "failed to create CDP auth", "error", err)
}
// Resolve active networks for x402 scheme registration
activeNetworks := resolveActiveNetworks(ctx, cfg.ActiveNetworks)
networkStrings := make([]string, len(activeNetworks))
for i, net := range activeNetworks {
networkStrings[i] = net.Network
}
srv, err := x402.NewX402Server(
"x402 enabled social media lookingglass for autonomous agents",
"0.2.0",
cfg.X402FacilitatorURL,
auth,
toolchain,
GlobalLoggingMiddleware,
func(w http.ResponseWriter, r *http.Request, resp *x402v2.SettleResponse) {
requirements, ok := x402http.RequirementsFromContext(r.Context())
if !ok {
log.WarnWithContext(r.Context(), "failed to extract payment requirements from context during HTTP settlement hook")
return
}
log.InfoWithContext(r.Context(), "persisting HTTP settlement",
"transaction", resp.Transaction,
"network", string(resp.Network),
"payer", resp.Payer,
"payTo", requirements.PayTo,
"amount", requirements.Amount,
"asset", requirements.Asset,
)
if err := persistSettlement(r.Context(), db, resp.Transaction, requirements.Amount, requirements.Asset, string(resp.Network), requirements.PayTo, resp.Payer); err != nil {
log.WarnWithContext(r.Context(), "failed to persist HTTP settlement", "error", err)
}
},
networkStrings,
)
if err != nil {
log.FatalWithContext(ctx, "failed to create x402 server", "error", err)
}
srv.OnAfterSettle(func(settleCtx x402v2.SettleResultContext) error {
log.InfoWithContext(settleCtx.Ctx, "MCP settlement succeeded",
"transaction", settleCtx.Result.Transaction,
"network", string(settleCtx.Result.Network),
"payer", settleCtx.Result.Payer,
"payTo", settleCtx.Requirements.GetPayTo(),
"amount", settleCtx.Requirements.GetAmount(),
"asset", settleCtx.Requirements.GetAsset(),
"scheme", settleCtx.Requirements.GetScheme(),
)
if err := persistSettlement(settleCtx.Ctx, db, settleCtx.Result.Transaction,
settleCtx.Requirements.GetAmount(), settleCtx.Requirements.GetAsset(),
string(settleCtx.Result.Network), settleCtx.Requirements.GetPayTo(), settleCtx.Result.Payer); err != nil {
log.WarnWithContext(settleCtx.Ctx, "failed to persist settlement", "error", err)
}
// Webhook is best-effort and non-blocking.
go sendSettlementWebhook(settleCtx.Ctx, cfg, metrics, settleCtx.Result.Transaction)
return nil
})
return srv
}
// persistSettlement is a thin wrapper around settleHook to keep call sites uniform.
func persistSettlement(ctx context.Context, db *store.Queries, transaction, amount, asset, network, to, from string) error {
return settleHook(ctx, db, transaction, amount, asset, network, to, from)
}
// sendSettlementWebhook builds and sends the Matrix webhook payload for a settled transaction.
func sendSettlementWebhook(ctx context.Context, cfg *config.Config, metrics *MetricsMiddleware, txHash string) {
wh := newWebhookSender(cfg, metrics)
if wh == nil {
return
}
payload := map[string]any{
"event": "transaction_success",
"transaction": map[string]any{
"x402_transaction_hash": txHash,
},
"metrics": map[string]any{
"platform": "unknown",
},
"timestamp": time.Now().UTC().Format(time.RFC3339),
"server_id": cfg.MatrixServerID,
}
jsonBytes, _ := json.Marshal(payload)
wh.Send(ctx, jsonBytes)
}

View file

@ -1,78 +0,0 @@
package server
import (
"context"
"maps"
"strings"
log "github.com/nacorid/logger"
"github.com/nacorid/naco-api/internal/store"
x402v2 "github.com/x402-foundation/x402/go/v2"
)
func settleHook(ctx context.Context, db *store.Queries, transaction, amount, asset, network, to, from string) error {
ret, err := db.SaveTransaction(ctx, store.SaveTransactionParams{
X402TransactionHash: transaction,
Amount: amount,
Asset: asset,
Network: network,
Recipient: to,
Sender: from,
})
var id int64 = 0
if len(ret) > 0 {
id = ret[0].ID
}
fields := log.Fields{
"transaction_id": id,
"X402_transaction_hash": transaction,
"amount": amount,
"asset": asset,
"network": network,
"recipient": to,
"sender": from,
}
if err != nil {
log.WithFields(fields).ErrorWithContext(ctx, "Failed to save transaction", "error", err)
return err
}
log.WithFields(fields).DebugWithContext(ctx, "Saved transaction")
return nil
}
func (s *Server) buildRequirements(amount string, extra map[string]any, scheme string, timeout int) []x402v2.PaymentRequirements {
requirements := make([]x402v2.PaymentRequirements, 0, len(s.activeNetworks))
for _, info := range s.activeNetworks {
mergedExtra := make(map[string]any)
maps.Copy(mergedExtra, extra)
maps.Copy(mergedExtra, info.Extra)
requirements = append(requirements, x402v2.PaymentRequirements{
Scheme: scheme,
Network: info.Network,
Asset: info.Asset,
Amount: amount,
PayTo: s.x402Wallet,
MaxTimeoutSeconds: timeout,
Extra: mergedExtra,
})
}
return requirements
}
func parseActiveNetworks(raw string) []string {
parts := strings.Split(raw, ",")
var slugs []string
for _, part := range parts {
trimmed := strings.ToLower(strings.TrimSpace(part))
if trimmed != "" {
slugs = append(slugs, trimmed)
}
}
return slugs
}

View file

@ -1,214 +0,0 @@
package server
import (
"context"
"database/sql"
"encoding/json"
"sort"
"time"
"github.com/nacorid/naco-api/internal/store"
)
// fakeStore is an in-memory implementation of the Store interface used by handler
// tests so db-backed tool paths (style profiles, analytics, similar users) can be
// exercised without a live Postgres+pgvector.
type fakeStore struct {
analyticsCache map[cacheKey]cacheEntry
analyticsPosts map[string]store.AnalyticsPost
coverageRanges []store.AnalyticsCoverageRange
styleProfiles map[string]store.UserStyleProfile
stylePosts map[string]store.UserStylePost
similarRows []store.FindSimilarUsersRow
findSimilarErr error
styleProfileErr error
stylePostsErr error
cacheErr bool
postsRangeErr bool
coverageErr bool
}
type cacheKey struct {
user, platform, tool, hash string
}
type cacheEntry struct {
value json.RawMessage
exp time.Time
}
func newFakeStore() *fakeStore {
return &fakeStore{
analyticsCache: map[cacheKey]cacheEntry{},
analyticsPosts: map[string]store.AnalyticsPost{},
styleProfiles: map[string]store.UserStyleProfile{},
stylePosts: map[string]store.UserStylePost{},
}
}
func postKey(user, platform, id string) string {
return user + "|" + platform + "|" + id
}
func profileKey(user, platform string) string {
return user + "|" + platform
}
func (f *fakeStore) GetAnalyticsCache(ctx context.Context, arg store.GetAnalyticsCacheParams) (json.RawMessage, error) {
if f.cacheErr {
return nil, sql.ErrNoRows
}
key := cacheKey{arg.UserID, arg.Platform, arg.ToolName, arg.TimeRangeHash}
entry, ok := f.analyticsCache[key]
if !ok || time.Now().After(entry.exp) {
return nil, sql.ErrNoRows
}
return entry.value, nil
}
func (f *fakeStore) UpsertAnalyticsCache(ctx context.Context, arg store.UpsertAnalyticsCacheParams) error {
key := cacheKey{arg.UserID, arg.Platform, arg.ToolName, arg.TimeRangeHash}
f.analyticsCache[key] = cacheEntry{value: arg.Result, exp: arg.ExpiresAt}
return nil
}
func (f *fakeStore) GetAnalyticsPostsInRange(ctx context.Context, arg store.GetAnalyticsPostsInRangeParams) ([]store.GetAnalyticsPostsInRangeRow, error) {
if f.postsRangeErr {
return nil, sql.ErrConnDone
}
var rows []store.GetAnalyticsPostsInRangeRow
for _, p := range f.analyticsPosts {
if p.UserID != arg.UserID || p.Platform != arg.Platform {
continue
}
if p.PostTimestamp.Before(arg.PostTimestamp) || p.PostTimestamp.After(arg.PostTimestamp_2) {
continue
}
rows = append(rows, store.GetAnalyticsPostsInRangeRow{
PostID: p.PostID,
PostText: p.PostText,
PostTimestamp: p.PostTimestamp,
Likes: p.Likes,
Reposts: p.Reposts,
Replies: p.Replies,
Quotes: p.Quotes,
})
}
sort.Slice(rows, func(i, j int) bool { return rows[i].PostTimestamp.Before(rows[j].PostTimestamp) })
if int32(len(rows)) > arg.Limit {
rows = rows[:arg.Limit]
}
return rows, nil
}
func (f *fakeStore) UpsertAnalyticsPosts(ctx context.Context, arg store.UpsertAnalyticsPostsParams) error {
f.analyticsPosts[postKey(arg.UserID, arg.Platform, arg.PostID)] = store.AnalyticsPost{
UserID: arg.UserID,
Platform: arg.Platform,
PostID: arg.PostID,
PostText: arg.PostText,
PostTimestamp: arg.PostTimestamp,
Likes: arg.Likes,
Reposts: arg.Reposts,
Replies: arg.Replies,
Quotes: arg.Quotes,
}
return nil
}
func (f *fakeStore) GetAnalyticsCoverageRanges(ctx context.Context, arg store.GetAnalyticsCoverageRangesParams) ([]store.GetAnalyticsCoverageRangesRow, error) {
if f.coverageErr {
return nil, sql.ErrConnDone
}
var rows []store.GetAnalyticsCoverageRangesRow
for _, r := range f.coverageRanges {
if r.UserID != arg.UserID || r.Platform != arg.Platform {
continue
}
rows = append(rows, store.GetAnalyticsCoverageRangesRow{CoveredStart: r.CoveredStart, CoveredEnd: r.CoveredEnd})
}
return rows, nil
}
func (f *fakeStore) InsertAnalyticsCoverageRange(ctx context.Context, arg store.InsertAnalyticsCoverageRangeParams) error {
f.coverageRanges = append(f.coverageRanges, store.AnalyticsCoverageRange{
UserID: arg.UserID,
Platform: arg.Platform,
CoveredStart: arg.CoveredStart,
CoveredEnd: arg.CoveredEnd,
})
return nil
}
func (f *fakeStore) GetStyleProfile(ctx context.Context, arg store.GetStyleProfileParams) (store.UserStyleProfile, error) {
if f.styleProfileErr != nil {
return store.UserStyleProfile{}, f.styleProfileErr
}
p, ok := f.styleProfiles[profileKey(arg.UserID, arg.Platform)]
if !ok {
return store.UserStyleProfile{}, sql.ErrNoRows
}
return p, nil
}
func (f *fakeStore) UpsertStyleProfile(ctx context.Context, arg store.UpsertStyleProfileParams) error {
f.styleProfiles[profileKey(arg.UserID, arg.Platform)] = store.UserStyleProfile{
UserID: arg.UserID,
Platform: arg.Platform,
Embedding: arg.Embedding,
TopKeywords: arg.TopKeywords,
StyleSummary: arg.StyleSummary,
PostCount: arg.PostCount,
ModifiedAt: sql.NullTime{Time: time.Now(), Valid: true},
}
return nil
}
func (f *fakeStore) DeleteStylePosts(ctx context.Context, arg store.DeleteStylePostsParams) error {
for _, k := range f.stylePosts {
if k.UserID == arg.UserID && k.Platform == arg.Platform {
delete(f.stylePosts, postKey(k.UserID, k.Platform, k.PostID))
}
}
return nil
}
func (f *fakeStore) GetStylePosts(ctx context.Context, arg store.GetStylePostsParams) ([]store.GetStylePostsRow, error) {
if f.stylePostsErr != nil {
return nil, f.stylePostsErr
}
var rows []store.GetStylePostsRow
for _, p := range f.stylePosts {
if p.UserID != arg.UserID || p.Platform != arg.Platform {
continue
}
rows = append(rows, store.GetStylePostsRow{PostID: p.PostID, PostText: p.PostText})
}
sort.Slice(rows, func(i, j int) bool { return rows[i].PostID > rows[j].PostID })
if int32(len(rows)) > arg.Limit {
rows = rows[:arg.Limit]
}
return rows, nil
}
func (f *fakeStore) UpsertStylePosts(ctx context.Context, arg store.UpsertStylePostsParams) error {
f.stylePosts[postKey(arg.UserID, arg.Platform, arg.PostID)] = store.UserStylePost{
UserID: arg.UserID,
Platform: arg.Platform,
PostID: arg.PostID,
PostText: arg.PostText,
CreatedAt: time.Now(),
}
return nil
}
func (f *fakeStore) FindSimilarUsers(ctx context.Context, arg store.FindSimilarUsersParams) ([]store.FindSimilarUsersRow, error) {
if f.findSimilarErr != nil {
return nil, f.findSimilarErr
}
return f.similarRows, nil
}
func (f *fakeStore) PruneStylePosts(ctx context.Context) error {
return nil
}

View file

@ -1,360 +0,0 @@
package server
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"sync"
"time"
"strings"
"github.com/modelcontextprotocol/go-sdk/mcp"
log "github.com/nacorid/logger"
"github.com/nacorid/naco-api/internal/clustering"
"github.com/nacorid/naco-api/internal/store"
"github.com/nacorid/naco-api/internal/utils"
)
func (s *Server) getStyleProfile(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var args GetStyleProfileRequest
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
if args.Author == "" {
return nil, fmt.Errorf("author must be a valid did or handle")
}
profile, err := s.requestStyleProfile(ctx, args.Author)
if err != nil {
return nil, err
}
// Best-effort: also look for this author on every other platform in the background so
// future requests can be served from cache. Failures are logged and never affect the
// response. Uses a detached context so the work outlives the request.
if s.enrichEnabled {
if src, ok := platformFromString(profile.SourcePlatform); ok {
go s.enrichCrossPlatform(context.WithoutCancel(ctx), src, args.Author)
}
}
profileBytes, err := json.Marshal(profile)
if err != nil {
return nil, err
}
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: string(profileBytes)},
},
}, nil
}
// requestStyleProfile returns a style profile for author. It computes and persists the
// profile only when a request is made AND the cached profile is older than a month (or
// missing); otherwise it serves the cached copy. The prewarmer calls this same path, so a
// pre-fetch is treated as a system-initiated request and obeys the identical update policy.
func (s *Server) requestStyleProfile(ctx context.Context, author string) (*GetStyleProfileResponse, error) {
platform, err := determinePlatform(author)
if err != nil {
return nil, err
}
return s.requestStyleProfileForced(ctx, platform, author)
}
// requestStyleProfileForced is the core of requestStyleProfile for an explicitly chosen
// platform. It resolves the author id, serves a fresh cached profile, or builds (and
// persists) a new one when the cache is stale/missing.
func (s *Server) requestStyleProfileForced(ctx context.Context, platform platform, author string) (*GetStyleProfileResponse, error) {
var authorID string
switch platform {
case Bluesky:
did, rerr := s.bskyClient.ResolveUser(ctx, author)
if rerr != nil {
return nil, rerr
}
authorID = did.String()
case ActivityPub, Nostr, Twitter:
authorID = author
default:
return nil, fmt.Errorf("platform %q is not supported yet", platform)
}
styleProfile, err := s.db.GetStyleProfile(ctx, store.GetStyleProfileParams{
UserID: authorID,
Platform: platform.String(),
})
if err == nil {
isRecent := styleProfile.ModifiedAt.Valid && time.Since(styleProfile.ModifiedAt.Time) < 24*30*time.Hour
if isRecent {
embedding, perr := utils.ParseEmbedding(styleProfile.Embedding)
if perr != nil {
return nil, perr
}
return &GetStyleProfileResponse{
Author: styleProfile.UserID,
PersonaEmbedding: embedding,
TopKeywords: styleProfile.TopKeywords,
PostCount: int(styleProfile.PostCount),
StyleSummary: styleProfile.StyleSummary,
SourcePlatform: platform.String(),
}, nil
}
} else if !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("failed to load cached style profile: %w", err)
}
return s.buildStyleProfile(ctx, platform, authorID, author)
}
// enrichCrossPlatform looks for author on every platform other than sourcePlatform, in the
// background. It uses the same identifier on each platform (matching only where that handle
// exists; mismatches fail gracefully and are skipped) and obeys the same 1-month update gate
// as a live request. Concurrency is bounded to the other three platforms.
func (s *Server) enrichCrossPlatform(ctx context.Context, sourcePlatform platform, author string) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
if s.metrics != nil {
s.metrics.RecordEnrichment()
}
// Bound concurrent background enrichment so a burst of live requests can't overwhelm
// Ollama. Block until a slot is free (or the context is cancelled).
select {
case s.enrichSem <- struct{}{}:
defer func() { <-s.enrichSem }()
case <-ctx.Done():
return
}
var wg sync.WaitGroup
for _, p := range []platform{Bluesky, ActivityPub, Nostr, Twitter} {
if p == sourcePlatform {
continue
}
wg.Add(1)
go func(p platform) {
defer wg.Done()
if _, err := s.requestStyleProfileForced(ctx, p, author); err != nil {
log.DebugWithContext(ctx, "cross-platform enrichment skipped", "platform", p.String(), "author", author, "error", err)
return
}
log.InfoWithContext(ctx, "cross-platform enrichment done", "platform", p.String(), "author", author)
}(p)
}
wg.Wait()
}
func (s *Server) getSimilarProfiles(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var args FindSimilarUsersRequest
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
if args.Author == "" {
return nil, fmt.Errorf("author must be a valid did or handle")
}
if args.Limit <= 0 {
args.Limit = 10
}
if args.Limit > 50 {
args.Limit = 50
}
profile, err := s.requestStyleProfile(ctx, args.Author)
if err != nil {
return nil, err
}
vec := utils.FormatVector(profile.PersonaEmbedding)
rows, err := s.db.FindSimilarUsers(ctx, store.FindSimilarUsersParams{
QueryEmbedding: vec,
LimitVal: args.Limit,
})
if err != nil {
return nil, fmt.Errorf("failed to search similar users: %w", err)
}
matches := make([]SimilarUser, 0, len(rows))
for _, r := range rows {
if r.UserID == profile.Author {
continue
}
matches = append(matches, SimilarUser{
Author: r.UserID,
Platform: r.Platform,
Similarity: float64(r.Similarity),
TopKeywords: r.TopKeywords,
StyleSummary: r.StyleSummary,
PostCount: int(r.PostCount),
})
}
twitterPostCount := 0
if profile.SourcePlatform == "Twitter" {
twitterPostCount = profile.PostCount
}
respBytes, err := json.Marshal(FindSimilarUsersResponse{
Matches: matches,
TwitterPostCount: twitterPostCount,
})
if err != nil {
return nil, err
}
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: string(respBytes)},
},
}, nil
}
func (s *Server) buildStyleProfile(ctx context.Context, platform platform, authorID, author string) (*GetStyleProfileResponse, error) {
posts, err := s.fetchProfilePosts(ctx, platform, authorID, 50)
if err != nil {
return nil, err
}
if len(posts) == 0 {
return nil, fmt.Errorf("no posts found for author %s", author)
}
// Drop posts without any text (e.g. Bluesky image/reply-only posts). Embedding them
// yields empty vectors, which would poison the persona-average and the persisted
// profile.
filtered := posts[:0]
for _, p := range posts {
if strings.TrimSpace(p.Text) == "" {
continue
}
filtered = append(filtered, p)
}
if len(filtered) == 0 {
return nil, fmt.Errorf("no posts found for author %s", author)
}
posts = filtered
// Delete old posts for non-Twitter platforms before rebuild
if platform != Twitter {
if err := s.db.DeleteStylePosts(ctx, store.DeleteStylePostsParams{
UserID: authorID,
Platform: platform.String(),
}); err != nil {
log.WarnWithContext(ctx, "failed to delete old style posts", "author", authorID, "platform", platform, "error", err)
}
}
// Extract texts for embedding and keyword analysis
postTexts := make([]string, len(posts))
for i, p := range posts {
postTexts[i] = p.Text
}
// Fail fast on any server-side error. These are payable tools: returning a degraded
// profile here would bill the caller for a deliverable they didn't ask for. The x402
// layer only settles payment when the handler succeeds, so returning the error means
// the caller is not charged.
embeddings, err := s.ollama.EmbedTexts(ctx, posts)
if err != nil {
return nil, err
}
personaEmbedding := utils.AverageVector(embeddings)
topWords := utils.TopWords(postTexts, make(map[string]struct{}), 10)
styleSummary, err := s.ollama.SummarizeStyle(ctx, posts)
if err != nil {
return nil, err
}
err = s.db.UpsertStyleProfile(ctx, store.UpsertStyleProfileParams{
UserID: authorID,
Platform: platform.String(),
Embedding: utils.FormatVector(personaEmbedding),
TopKeywords: topWords,
StyleSummary: styleSummary,
PostCount: int32(len(posts)),
})
if err != nil {
return nil, err
}
return &GetStyleProfileResponse{
Author: authorID,
PersonaEmbedding: personaEmbedding,
TopKeywords: topWords,
StyleSummary: styleSummary,
PostCount: len(posts),
SourcePlatform: platform.String(),
}, nil
}
func (s *Server) fetchProfilePosts(ctx context.Context, platform platform, author string, limit int) ([]clustering.Item, error) {
platformStr := platform.String()
// 1. Try cache first
cached, err := s.db.GetStylePosts(ctx, store.GetStylePostsParams{
UserID: author,
Platform: platformStr,
Limit: int32(limit),
})
if err == nil && len(cached) >= limit {
posts := make([]clustering.Item, len(cached))
for i, c := range cached {
posts[i] = clustering.Item{ID: c.PostID, Text: c.PostText}
}
return posts, nil
}
// 2. Fetch from platform
var freshPosts []clustering.Item
switch platform {
case Bluesky:
res, err := s.bskyClient.GetLatestNPosts(ctx, author, limit)
if err != nil {
return toItems(cached), err
}
freshPosts = blueskyFeedViewsToItems(res)
case ActivityPub:
res, err := s.apClient.GetLatestNPosts(ctx, author, limit)
if err != nil {
return toItems(cached), err
}
freshPosts = apPostsPtrToItems(res)
case Nostr:
res, err := s.nostrClient.GetLatestNPosts(ctx, author, limit)
if err != nil {
return toItems(cached), err
}
freshPosts = nostrPostsToItems(res)
case Twitter:
res, err := s.twitterClient.GetLatestNPosts(ctx, author, limit)
if err != nil {
return toItems(cached), err
}
freshPosts = twitterPostsToItems(res)
default:
return nil, fmt.Errorf("platform %q is not supported yet", platform)
}
// 3. Upsert to cache (batch)
for _, post := range freshPosts {
if err := s.db.UpsertStylePosts(ctx, store.UpsertStylePostsParams{
UserID: author,
Platform: platformStr,
PostID: post.ID,
PostText: post.Text,
}); err != nil {
log.WarnWithContext(ctx, "failed to cache style post", "author", author, "platform", platform, "postID", post.ID, "error", err)
}
}
return freshPosts, nil
}
func toItems(rows []store.GetStylePostsRow) []clustering.Item {
items := make([]clustering.Item, len(rows))
for i, r := range rows {
items[i] = clustering.Item{ID: r.PostID, Text: r.PostText}
}
return items
}

View file

@ -1,375 +0,0 @@
package server
import (
"database/sql"
"fmt"
"reflect"
"strings"
"testing"
"time"
"github.com/bluesky-social/indigo/api/bsky"
"github.com/nacorid/naco-api/internal/activitypub"
"github.com/nacorid/naco-api/internal/store"
)
// ---- getStyleProfile ----
func TestGetStyleProfileBuildsFresh(t *testing.T) {
ap := &fakeAP{posts: []*activitypub.Post{
{Author: "@a@mastodon.social", Content: "alpha post one", CreatedAt: time.Now()},
{Author: "@a@mastodon.social", Content: "alpha post two", CreatedAt: time.Now()},
}}
st := newFakeStore()
oll := &fakeOllama{embeddings: [][]float64{{0.1, 0.2}, {0.3, 0.4}}}
s := newServerWith(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}, st, oll)
res, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "@a@mastodon.social"})
if err != nil {
t.Fatalf("getStyleProfile error: %v", err)
}
var out GetStyleProfileResponse
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if out.Author != "@a@mastodon.social" {
t.Fatalf("expected author, got %q", out.Author)
}
if out.PostCount != 2 {
t.Fatalf("expected post count 2, got %d", out.PostCount)
}
if out.SourcePlatform != "ActivityPub" {
t.Fatalf("expected ActivityPub source, got %q", out.SourcePlatform)
}
if oll.embedCalls != 1 || oll.summCalls != 1 {
t.Fatalf("expected 1 embed and 1 summarize call, got %d/%d", oll.embedCalls, oll.summCalls)
}
}
func TestGetStyleProfileServesFreshCache(t *testing.T) {
// A profile modified less than the 30-day stale threshold is served without hitting the
// backend or ollama.
st := newFakeStore()
st.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{
UserID: "@a@mastodon.social",
Platform: "ActivityPub",
Embedding: "[0.1,0.2]",
TopKeywords: []string{"alpha"},
StyleSummary: "cached summary",
PostCount: 5,
ModifiedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true},
}
ap := &fakeAP{}
oll := &fakeOllama{}
s := newServerWith(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}, st, oll)
res, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "@a@mastodon.social"})
if err != nil {
t.Fatalf("getStyleProfile error: %v", err)
}
var out GetStyleProfileResponse
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if out.StyleSummary != "cached summary" {
t.Fatalf("expected cached summary, got %q", out.StyleSummary)
}
if ap.calls != 0 {
t.Fatalf("expected no backend call on cache hit, got %d", ap.calls)
}
if oll.summCalls != 0 {
t.Fatalf("expected no ollama call on cache hit, got %d", oll.summCalls)
}
}
func TestGetStyleProfileRebuildsStale(t *testing.T) {
// A stale (older than a month) cached profile triggers a rebuild.
st := newFakeStore()
st.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{
UserID: "@a@mastodon.social",
Platform: "ActivityPub",
Embedding: "[0.1,0.2]",
TopKeywords: []string{"old"},
StyleSummary: "stale summary",
PostCount: 5,
ModifiedAt: sql.NullTime{Time: time.Now().Add(-31 * 24 * time.Hour), Valid: true},
}
ap := &fakeAP{posts: []*activitypub.Post{
{Author: "@a@mastodon.social", Content: "fresh post", CreatedAt: time.Now()},
}}
s := newServerWith(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{})
res, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "@a@mastodon.social"})
if err != nil {
t.Fatalf("getStyleProfile error: %v", err)
}
var out GetStyleProfileResponse
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if out.StyleSummary == "stale summary" {
t.Fatal("expected rebuild to replace stale summary")
}
if out.PostCount != 1 {
t.Fatalf("expected rebuilt post count 1, got %d", out.PostCount)
}
}
func TestGetStyleProfileDBErrorIsNotNoRows(t *testing.T) {
st := newFakeStore()
st.styleProfileErr = fmt.Errorf("db down")
s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{})
if _, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "@a@mastodon.social"}); err == nil || !strings.Contains(err.Error(), "db down") {
t.Fatalf("expected db error propagation, got %v", err)
}
}
func TestGetStyleProfileNoPostsFound(t *testing.T) {
// Empty backend result while building leads to a "no posts found" error.
ap := &fakeAP{} // no posts
s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{})
_, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "@a@mastodon.social"})
if err == nil || !strings.Contains(err.Error(), "no posts found") {
t.Fatalf("expected no-posts error, got %v", err)
}
}
func TestGetStyleProfileOllamaEmbedFailure(t *testing.T) {
ap := &fakeAP{posts: []*activitypub.Post{{ID: "p1", Author: "@a@mastodon.social", Content: "post", CreatedAt: time.Now()}}}
oll := &fakeOllama{embedErr: fmt.Errorf("embedding failed")}
st := newFakeStore()
s := newServerWith(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}, st, oll)
if _, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "@a@mastodon.social"}); err == nil || !strings.Contains(err.Error(), "embedding") {
t.Fatalf("expected embed failure, got %v", err)
}
}
func TestGetStyleProfileBlueskyResolution(t *testing.T) {
// Bluesky resolves the did before building/persisting.
bsky := &fakeBluesky{feedViews: []*bsky.FeedDefs_FeedViewPost{{Post: bskyPostView("bsky post")}}}
oll := &fakeOllama{embeddings: [][]float64{{0.1, 0.2}}}
st := newFakeStore()
s := newServerWith(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, oll)
res, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "did:plc:abc"})
if err != nil {
t.Fatalf("getStyleProfile error: %v", err)
}
var out GetStyleProfileResponse
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if out.Author != "did:plc:abc" {
t.Fatalf("expected resolved did, got %q", out.Author)
}
if out.SourcePlatform != "Bluesky" {
t.Fatalf("expected Bluesky source, got %q", out.SourcePlatform)
}
}
func TestGetStyleProfileFirstEmbeddingEmpty(t *testing.T) {
// Regression: an empty embedding returned by Ollama for a non-first post used to
// panic AverageVector when the first vector was also empty (accumulator sized to
// length 0, later non-empty vector indexed it). Empty vectors are skipped.
bsky := &fakeBluesky{feedViews: []*bsky.FeedDefs_FeedViewPost{
{Post: bskyPostView("first post")},
{Post: bskyPostView("second post")},
}}
oll := &fakeOllama{embeddings: [][]float64{{}, {0.1, 0.2}}}
st := newFakeStore()
s := newServerWith(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, oll)
res, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "did:plc:abc"})
if err != nil {
t.Fatalf("getStyleProfile error: %v", err)
}
var out GetStyleProfileResponse
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if !reflect.DeepEqual(out.PersonaEmbedding, []float64{0.1, 0.2}) {
t.Fatalf("expected averaged embedding {0.1,0.2}, got %v", out.PersonaEmbedding)
}
if out.PostCount != 2 {
t.Fatalf("expected post count 2, got %d", out.PostCount)
}
}
func TestGetStyleProfileSkipsEmptyTextPosts(t *testing.T) {
// A Bluesky post with no text (image/reply-only) must not reach Ollama: its empty
// embedding would poison the persona average. It is dropped before embedding.
bsky := &fakeBluesky{feedViews: []*bsky.FeedDefs_FeedViewPost{
{Post: bskyPostView("")},
{Post: bskyPostView("real post")},
}}
oll := &fakeOllama{}
st := newFakeStore()
s := newServerWith(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, oll)
res, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "did:plc:abc"})
if err != nil {
t.Fatalf("getStyleProfile error: %v", err)
}
var out GetStyleProfileResponse
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if out.PostCount != 1 {
t.Fatalf("expected filtered post count 1, got %d", out.PostCount)
}
if len(out.PersonaEmbedding) != 2 {
t.Fatalf("expected 2-dim embedding, got %v", out.PersonaEmbedding)
}
}
// ---- findSimilarUsers ----
func TestFindSimilarUsers(t *testing.T) {
st := newFakeStore()
st.similarRows = []store.FindSimilarUsersRow{
{UserID: "did:plc:other", Platform: "Bluesky", TopKeywords: []string{"x"}, StyleSummary: "s", PostCount: 3, Similarity: 90},
}
// Serve a fresh cached profile so requestStyleProfile doesn't need posts.
st.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{
UserID: "@a@mastodon.social",
Platform: "ActivityPub",
Embedding: "[0.1,0.2]",
ModifiedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true},
}
s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{})
res, err := callTool(s, "findSimilarUsers", FindSimilarUsersRequest{Author: "@a@mastodon.social", Limit: 10})
if err != nil {
t.Fatalf("findSimilarUsers error: %v", err)
}
var out FindSimilarUsersResponse
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(out.Matches) != 1 {
t.Fatalf("expected 1 match, got %d", len(out.Matches))
}
if out.Matches[0].Author != "did:plc:other" {
t.Fatalf("unexpected match: %+v", out.Matches[0])
}
}
func TestFindSimilarUsersExcludesSelf(t *testing.T) {
st := newFakeStore()
// Both rows share the query author's user ID; the self row must be excluded.
st.similarRows = []store.FindSimilarUsersRow{
{UserID: "@a@mastodon.social", Platform: "ActivityPub"}, // same as query author
{UserID: "did:plc:other", Platform: "Bluesky", Similarity: 80},
}
st.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{
UserID: "@a@mastodon.social",
Platform: "ActivityPub",
Embedding: "[0.1,0.2]",
ModifiedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true},
}
s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{})
res, err := callTool(s, "findSimilarUsers", FindSimilarUsersRequest{Author: "@a@mastodon.social", Limit: 10})
if err != nil {
t.Fatalf("findSimilarUsers error: %v", err)
}
var out FindSimilarUsersResponse
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(out.Matches) != 1 {
t.Fatalf("expected self to be excluded, got %d matches: %+v", len(out.Matches), out.Matches)
}
if out.Matches[0].Author != "did:plc:other" {
t.Fatalf("expected only the non-self match, got %+v", out.Matches)
}
}
func TestFindSimilarUsersEmptyMatches(t *testing.T) {
st := newFakeStore()
st.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{
UserID: "@a@mastodon.social",
Platform: "ActivityPub",
Embedding: "[0.1,0.2]",
ModifiedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true},
}
s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{})
res, err := callTool(s, "findSimilarUsers", FindSimilarUsersRequest{Author: "@a@mastodon.social"})
if err != nil {
t.Fatalf("findSimilarUsers error: %v", err)
}
var out FindSimilarUsersResponse
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(out.Matches) != 0 {
t.Fatalf("expected empty matches, got %d", len(out.Matches))
}
}
// TestFindSimilarUsersTwitterPostCount verifies the response reports how many
// posts went into the query author's style profile when that author is on
// Twitter, so upto-scheme settlement can charge the realized Twitter API costs.
func TestFindSimilarUsersTwitterPostCount(t *testing.T) {
st := newFakeStore()
st.similarRows = []store.FindSimilarUsersRow{
{UserID: "did:plc:other", Platform: "Bluesky", Similarity: 80},
}
// Serve a fresh cached Twitter profile with 42 analyzed posts.
st.styleProfiles["@alice|Twitter"] = store.UserStyleProfile{
UserID: "@alice",
Platform: "Twitter",
Embedding: "[0.1,0.2]",
PostCount: 42,
ModifiedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true},
}
s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{})
res, err := callTool(s, "findSimilarUsers", FindSimilarUsersRequest{Author: "@alice"})
if err != nil {
t.Fatalf("findSimilarUsers error: %v", err)
}
var out FindSimilarUsersResponse
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if out.TwitterPostCount != 42 {
t.Fatalf("expected TwitterPostCount 42, got %d", out.TwitterPostCount)
}
// A non-Twitter query author reports 0.
st2 := newFakeStore()
st2.similarRows = []store.FindSimilarUsersRow{
{UserID: "did:plc:other", Platform: "Bluesky", Similarity: 80},
}
st2.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{
UserID: "@a@mastodon.social",
Platform: "ActivityPub",
Embedding: "[0.1,0.2]",
PostCount: 9,
ModifiedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true},
}
s2 := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st2, &fakeOllama{})
res2, err := callTool(s2, "findSimilarUsers", FindSimilarUsersRequest{Author: "@a@mastodon.social"})
if err != nil {
t.Fatalf("findSimilarUsers error: %v", err)
}
var out2 FindSimilarUsersResponse
if err := jsonUnmarshal(t, textOf(t, res2), &out2); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if out2.TwitterPostCount != 0 {
t.Fatalf("expected TwitterPostCount 0 for ActivityPub, got %d", out2.TwitterPostCount)
}
}
func TestFindSimilarUsersDBError(t *testing.T) {
st := newFakeStore()
st.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{
UserID: "@a@mastodon.social",
Platform: "ActivityPub",
Embedding: "[0.1,0.2]",
ModifiedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true},
}
st.findSimilarErr = fmt.Errorf("query failed")
s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{})
if _, err := callTool(s, "findSimilarUsers", FindSimilarUsersRequest{Author: "@a@mastodon.social"}); err == nil || !strings.Contains(err.Error(), "similar") {
t.Fatalf("expected similar-users error, got %v", err)
}
}

View file

@ -1,501 +0,0 @@
package server
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/nacorid/naco-api/internal/x402"
)
// registerTools registers all MCP tools with their x402 payment requirements.
// The payable tools are defined in a table to avoid repeating Schema construction.
func (s *Server) registerTools() {
// Helper to build the x402 extra map from input/output BodyFields.
schemaExtra := func(input, output map[string]any) map[string]any {
return map[string]any{
"OutputSchema": Schema{
Input: InputSchema{
Type: "HTTP",
Method: "POST",
Discoverable: true,
BodyFields: input,
},
Output: output,
},
}
}
analyticsInput := map[string]any{
"author": Bodyfield{Type: "string", Description: "Did or handle of an author", Required: true},
"startTime": Bodyfield{Type: "string", Description: "Start time in RFC3339 format (default: 30 days ago)", Required: false},
"endTime": Bodyfield{Type: "string", Description: "End time in RFC3339 format (default: now)", Required: false},
"limit": Bodyfield{Type: "integer", Description: "Maximum number of posts to analyze (default: 50, max: 200)", Required: false},
"platform": Bodyfield{Type: "string", Description: "Platform to filter (default: all)", Required: false},
}
type payableDef struct {
Name string
Desc string
Input any
Output any
Handler func(context.Context, *mcp.CallToolRequest) (*mcp.CallToolResult, error)
Price string
MaxTimeoutSeconds int
// UsesTwitter marks tools that may read posts (or set up lookups)
// through the Twitter API and return only transformative/aggregated
// results, as permitted by the X API policy. With Twitter enabled these
// tools charge upto-scheme: base price plus the realized Twitter API
// costs. Tools that resell raw post/profile/feed data (getPost,
// getLatestPosts, ...) never set this.
UsesTwitter bool
// TwitterPostCap is the post count used to compute the advertised upto
// maximum (see x402.MaxTwitterCostMicro). It only bounds the largest
// settlement the tool can demand; the actual charge is settled from the
// response.
TwitterPostCap int64
Extra map[string]any
}
payable := []payableDef{
{
Name: "getPost", Desc: "Get a specific post from a specific actor",
Input: GetPostRequest{}, Output: GetPostResponsePost{}, Handler: s.getPost, Price: "0.01",
Extra: schemaExtra(
map[string]any{
"author": Bodyfield{Type: "string", Description: "Did or handle of an author", Required: true},
"rkey": Bodyfield{Type: "string", Description: "Record key of the post", Required: true},
"collection": Bodyfield{Type: "string", Description: "Collection the post belongs to (Bluesky only). Defaults to 'app.bsky.feed.post'", Required: false},
},
map[string]any{
"author": Bodyfield{Type: "string", Description: "Did of the author"},
"post": Bodyfield{Type: "object", Description: "The post object. Includes the post content 'content', like 'likes', quote 'quotes', replies 'replies' and repost 'reposts' count"},
},
),
},
{
Name: "getLatestPosts", Desc: "Get the latest n posts from a specific actor",
Input: GetLatestPostsRequest{}, Output: GetLatestPostsResponse{}, Handler: s.getLatestPosts, Price: "0.10",
Extra: schemaExtra(
map[string]any{
"author": Bodyfield{Type: "string", Description: "Did or handle of an author", Required: true},
"n": Bodyfield{Type: "integer", Description: "Number of posts to retrieve, maximum 50", Required: true},
},
map[string]any{
"author": Bodyfield{Type: "string", Description: "Did of an actor"},
"posts": Bodyfield{Type: "array", Description: "Array of post objects. Each includes the post content 'content', like 'likes', quote 'quotes', replies 'replies' and repost 'reposts' count"},
},
),
},
{
Name: "getPostsInTime", Desc: "Get posts from a specific actor in a time range",
Input: GetPostsInTimeRequest{}, Output: GetPostsInTimeResponse{}, Handler: s.getPostsInTime, Price: "0.10",
Extra: schemaExtra(
map[string]any{
"author": Bodyfield{Type: "string", Description: "Did or handle of an author", Required: true},
"start": Bodyfield{Type: "string", Description: "Start time in RFC3339 format", Required: true},
"end": Bodyfield{Type: "string", Description: "End time in RFC3339 format", Required: true},
"n": Bodyfield{Type: "integer", Description: "Maximum number of posts to retrieve, maximum 50", Required: true},
},
map[string]any{
"author": Bodyfield{Type: "string", Description: "Did of an actor"},
"posts": Bodyfield{Type: "array", Description: "Array of post objects. Each includes the post content 'content', like 'likes', quote 'quotes', replies 'replies' and repost 'reposts' count"},
},
),
},
{
// base price is $0.25 (x402.BaseProfileCostMicro). With Twitter
// enabled the tool is advertised as upto at the maximum possible
// cost - $0.25 base + $0.01 lookup + $0.005/post at the 100-post
// cap = $0.76 (x402.MaxTwitterCostMicro(BaseProfileCostMicro,
// MaxProfileCountedPosts)) - and the actual charge is settled from
// the posts read via x402.NewStyleProfileCostResolver.
Name: "getStyleProfile", Desc: "Get an actor's style Profile",
Input: GetStyleProfileRequest{}, Output: GetStyleProfileResponse{}, Handler: s.getStyleProfile, Price: "0.25",
MaxTimeoutSeconds: 600,
UsesTwitter: true, TwitterPostCap: x402.MaxProfileCountedPosts,
Extra: schemaExtra(
map[string]any{
"author": Bodyfield{Type: "string", Description: "Did or handle of an author", Required: true},
},
map[string]any{
"PersonaEmbedding": Bodyfield{Type: "array", Description: "Embedding vector representing the actor's persona"},
"TopKeywords": Bodyfield{Type: "array", Description: "Top words used by the actor"},
"StyleSummary": Bodyfield{Type: "string", Description: "Summary of the actor's writing style"},
"PostCount": Bodyfield{Type: "integer", Description: "Number of posts analyzed"},
},
),
},
{
Name: "findSimilarUsers", Desc: "Find authors with a similar writing style to the given author, across all platforms",
Input: FindSimilarUsersRequest{}, Output: FindSimilarUsersResponse{}, Handler: s.getSimilarProfiles, Price: "0.10",
UsesTwitter: true, TwitterPostCap: x402.MaxProfileCountedPosts,
Extra: schemaExtra(
map[string]any{
"author": Bodyfield{Type: "string", Description: "Did or handle of an author", Required: true},
},
map[string]any{
"matches": Bodyfield{Type: "array", Description: "Ranked list of similar profiles (author, platform, similarity, top keywords, style summary, post count)"},
},
),
},
{
Name: "getActor", Desc: "Get an actor's public profile (bio, avatar, follower counts) on the platform inferred from the identifier",
Input: GetActorRequest{}, Output: GetActorResponse{}, Handler: s.getActor, Price: "0.01",
Extra: schemaExtra(
map[string]any{
"author": Bodyfield{Type: "string", Description: "Did, handle, npub or @handle of an actor", Required: true},
},
map[string]any{
"actor": Bodyfield{Type: "object", Description: "Profile of the actor (platform, handle, displayName, avatarUrl, bio, followersCount, followingCount, postCount, url)"},
},
),
},
{
Name: "searchActors", Desc: "Search for actors by handle/name/keyword across all platforms",
Input: SearchActorsRequest{}, Output: SearchActorsResponse{}, Handler: s.searchActors, Price: "0.01",
Extra: schemaExtra(
map[string]any{
"query": Bodyfield{Type: "string", Description: "Search query (handle, name or keyword)", Required: true},
"limit": Bodyfield{Type: "integer", Description: "Maximum number of results (default 10, max 50)", Required: false},
},
map[string]any{
"results": Bodyfield{Type: "array", Description: "Ranked list of matching actor profiles across platforms"},
},
),
},
{
Name: "getEngagementMetrics", Desc: "Get time-series engagement metrics (likes, reposts, replies, quotes) per post",
Input: AnalyticsRequest{}, Output: EngagementMetricsResponse{}, Handler: s.getEngagementMetrics, Price: "0.15",
UsesTwitter: true, TwitterPostCap: x402.MaxAnalyticsCountedPosts,
Extra: schemaExtra(analyticsInput, map[string]any{
"author": Bodyfield{Type: "string", Description: "Author identifier"},
"metrics": Bodyfield{Type: "array", Description: "Engagement metrics per post"},
}),
},
{
Name: "getPostingPatterns", Desc: "Get posting heatmap and patterns (hour-of-day x day-of-week)",
Input: AnalyticsRequest{}, Output: PostingPatternsResponse{}, Handler: s.getPostingPatterns, Price: "0.10",
UsesTwitter: true, TwitterPostCap: x402.MaxAnalyticsCountedPosts,
Extra: schemaExtra(analyticsInput, map[string]any{
"author": Bodyfield{Type: "string", Description: "Author identifier"},
"heatmap": Bodyfield{Type: "array", Description: "24x7 matrix: hour (0-23) x day of week (0-6 = Sun-Sat)"},
"peakHours": Bodyfield{Type: "array", Description: "Top 3 hours with most posts (0-23)"},
"peakDays": Bodyfield{Type: "array", Description: "Top 3 days with most posts (0-6 = Sun-Sat)"},
"avgPostsPerDay": Bodyfield{Type: "number", Description: "Average posts per day"},
"totalPosts": Bodyfield{Type: "integer", Description: "Total posts in time range"},
"postingDays": Bodyfield{Type: "integer", Description: "Number of days with at least one post"},
}),
},
{
Name: "getTopicModeling", Desc: "Get LLM-based topic extraction and clustering from posts",
Input: AnalyticsRequest{}, Output: TopicModelingResponse{}, Handler: s.getTopicModeling, Price: "0.20",
UsesTwitter: true, TwitterPostCap: x402.MaxAnalyticsCountedPosts,
Extra: schemaExtra(analyticsInput, map[string]any{
"author": Bodyfield{Type: "string", Description: "Author identifier"},
"topics": Bodyfield{Type: "array", Description: "Identified topic clusters"},
}),
},
{
Name: "getSentimentTrends", Desc: "Get sentiment analysis and emotional arcs over time",
Input: AnalyticsRequest{}, Output: SentimentTrendsResponse{}, Handler: s.getSentimentTrends, Price: "0.15",
UsesTwitter: true, TwitterPostCap: x402.MaxAnalyticsCountedPosts,
Extra: schemaExtra(analyticsInput, map[string]any{
"author": Bodyfield{Type: "string", Description: "Author identifier"},
"timeSeries": Bodyfield{Type: "array", Description: "Sentiment over time"},
"overallSentiment": Bodyfield{Type: "object", Description: "Overall sentiment summary"},
"dominantEmotions": Bodyfield{Type: "array", Description: "Most frequent emotions"},
"sentimentShifts": Bodyfield{Type: "array", Description: "Significant sentiment changes"},
}),
},
{
Name: "getEngagementDrivers", Desc: "Get topics/keywords that correlate with high engagement",
Input: AnalyticsRequest{}, Output: EngagementDriversResponse{}, Handler: s.getEngagementDrivers, Price: "0.20",
UsesTwitter: true, TwitterPostCap: x402.MaxAnalyticsCountedPosts,
Extra: schemaExtra(analyticsInput, map[string]any{
"author": Bodyfield{Type: "string", Description: "Author identifier"},
"drivers": Bodyfield{Type: "array", Description: "Topics/keywords driving engagement"},
}),
},
}
for _, d := range payable {
// Twitter-capable tools are advertised as upto (variable-priced) only
// when the Twitter API is actually enabled: the upto maximum adds the
// realized Twitter lookup and per-post costs, and the actual charge is
// settled from the response. Without Twitter, every tool is exact at
// its flat base price.
scheme := "exact"
price := d.Price
var resolver x402.SettleAmountResolver
if d.UsesTwitter && s.twitterEnabled {
scheme = "upto"
baseMicro := x402.MicroFromPriceString(d.Price)
price = x402.FormatPriceMicro(x402.MaxTwitterCostMicro(baseMicro, d.TwitterPostCap))
if d.Name == "getStyleProfile" {
resolver = x402.NewStyleProfileCostResolver()
} else {
resolver = x402.NewTwitterCountCostResolver(baseMicro)
}
}
s.mcpHandler.AddPayableTool(
mcp.Tool{Name: d.Name, Description: d.Desc, InputSchema: d.Input, OutputSchema: d.Output},
d.Handler,
resolver,
s.buildRequirements(price, d.Extra, scheme, d.MaxTimeoutSeconds)...,
)
}
// Free tool: getFeed
s.mcpHandler.AddTool(
mcp.Tool{Name: "getFeed", Description: "Get a feed by its URI", InputSchema: GetFeedRequest{}, OutputSchema: GetFeedResponse{}},
s.getFeed,
)
}
func (s *Server) getPost(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var args GetPostRequest
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
if args.Author == "" {
return nil, fmt.Errorf("author must be a valid did or handle")
}
if args.RecordKey == "" {
return nil, fmt.Errorf("rkey must be a valid record key")
}
platform, err := determinePlatform(args.Author)
if err != nil {
return nil, err
}
if platform == Twitter {
return nil, fmt.Errorf("twitter_unavailable: X API policy prohibits reselling post data")
}
post, err := s.fetchSinglePost(ctx, platform, args.Author, args.RecordKey, args.Collection)
if err != nil {
return nil, err
}
respBytes, err := json.Marshal(post)
if err != nil {
return nil, err
}
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: string(respBytes)},
},
}, nil
}
func (s *Server) getLatestPosts(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var args GetLatestPostsRequest
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
if args.Author == "" {
return nil, fmt.Errorf("author must be a valid did or handle")
}
if args.Limit <= 0 {
return nil, fmt.Errorf("n must be a positive integer")
}
if args.Limit > 50 {
return nil, fmt.Errorf("n must be at most 50")
}
platform, err := determinePlatform(args.Author)
if err != nil {
return nil, err
}
if platform == Twitter {
return nil, fmt.Errorf("twitter_unavailable: X API policy prohibits reselling post data")
}
posts, authorID, err := s.fetchLatestPosts(ctx, platform, args.Author, args.Limit)
if err != nil {
return nil, err
}
if authorID == "" {
authorID = args.Author
}
resp := GetLatestPostsResponse{
Author: authorID,
Posts: posts,
}
respBytes, err := json.Marshal(resp)
if err != nil {
return nil, err
}
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: string(respBytes)},
},
}, nil
}
func (s *Server) getPostsInTime(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var args GetPostsInTimeRequest
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
if args.Author == "" {
return nil, fmt.Errorf("author must be a valid did or handle")
}
if args.Limit <= 0 {
return nil, fmt.Errorf("n must be a positive integer")
}
if args.Limit > 50 {
return nil, fmt.Errorf("n must be at most 50")
}
limit := args.Limit
startTime, err := time.Parse(time.RFC3339, args.StartTime)
if err != nil {
return nil, fmt.Errorf("startTime must be in RFC3339 format")
}
endTime, err := time.Parse(time.RFC3339, args.EndTime)
if err != nil {
return nil, fmt.Errorf("endTime must be in RFC3339 format")
}
if startTime.After(endTime) {
tmp := startTime
startTime = endTime
endTime = tmp
}
platform, err := determinePlatform(args.Author)
if err != nil {
return nil, err
}
if platform == Twitter {
return nil, fmt.Errorf("twitter_unavailable: X API policy prohibits reselling post data")
}
posts, authorID, err := s.fetchPostsInTime(ctx, platform, args.Author, startTime, endTime, limit)
if err != nil {
return nil, err
}
if authorID == "" {
authorID = args.Author
}
resp := GetPostsInTimeResponse{
Author: authorID,
Posts: posts,
}
respBytes, err := json.Marshal(resp)
if err != nil {
return nil, err
}
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: string(respBytes)},
},
}, nil
}
func (s *Server) getFeed(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var args GetFeedRequest
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
if args.URI == "" {
return nil, fmt.Errorf("feedURI must be a valid feed URI")
}
platform, err := determinePlatform(args.URI)
if err != nil {
return nil, err
}
if platform == Twitter {
return nil, fmt.Errorf("twitter_unavailable: X API policy prohibits reselling feed data")
}
posts, err := s.fetchFeed(ctx, platform, args.URI)
if err != nil {
return nil, err
}
resp := GetFeedResponse{
URI: args.URI,
Posts: posts,
}
respBytes, err := json.Marshal(resp)
if err != nil {
return nil, err
}
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: string(respBytes)},
},
}, nil
}
func (s *Server) getActor(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var args GetActorRequest
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
if args.Author == "" {
return nil, fmt.Errorf("author must be a valid did, handle, npub or @handle")
}
platform, err := determinePlatform(args.Author)
if err != nil {
return nil, err
}
var (
prof *ActorProfile
e error
)
switch platform {
case Bluesky:
prof, e = s.bskyClient.GetActor(ctx, args.Author)
case ActivityPub:
prof, e = s.apClient.GetActor(ctx, args.Author)
case Nostr:
prof, e = s.nostrClient.GetActor(ctx, args.Author)
case Twitter:
return nil, fmt.Errorf("twitter_unavailable: X API policy prohibits reselling user/profile data")
default:
return nil, fmt.Errorf("unsupported platform %q", platform)
}
if e != nil {
return nil, e
}
respBytes, err := json.Marshal(GetActorResponse{Actor: *prof})
if err != nil {
return nil, err
}
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: string(respBytes)},
},
}, nil
}
func (s *Server) searchActors(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var args SearchActorsRequest
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
if args.Query == "" {
return nil, fmt.Errorf("query must not be empty")
}
limit := args.Limit
if limit <= 0 {
limit = 10
}
if limit > 50 {
limit = 50
}
results, err := s.searchActorsAll(ctx, args.Query, limit)
if err != nil {
return nil, err
}
respBytes, err := json.Marshal(SearchActorsResponse{Results: results})
if err != nil {
return nil, err
}
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: string(respBytes)},
},
}, nil
}

View file

@ -1,162 +0,0 @@
package server
import (
"context"
"strings"
"testing"
"time"
"github.com/nacorid/naco-api/internal/clustering"
"github.com/nacorid/naco-api/internal/store"
"github.com/nacorid/naco-api/internal/twitter"
)
func twitterPosts() []*twitter.Post {
base := time.Date(2024, 5, 10, 12, 0, 0, 0, time.UTC)
return []*twitter.Post{
{ID: "t1", Author: "@a", Content: "twitter one", CreatedAt: base},
{ID: "t2", Author: "@a", Content: "twitter two", CreatedAt: base.Add(time.Hour)},
}
}
func TestFetchTwitterAnalyticsPostsFetchesAndPersists(t *testing.T) {
tw := &fakeTwitter{posts: twitterPosts()}
st := newFakeStore()
s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, tw, st, &fakeOllama{})
start := time.Date(2024, 5, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2024, 5, 31, 0, 0, 0, 0, time.UTC)
items, err := s.fetchTwitterAnalyticsPosts(context.Background(), "@a", start, end, 50)
if err != nil {
t.Fatalf("fetchTwitterAnalyticsPosts error: %v", err)
}
if len(items) != 2 {
t.Fatalf("expected 2 fetched posts, got %d", len(items))
}
if tw.calls != 1 {
t.Fatalf("expected 1 API call, got %d", tw.calls)
}
// Newly fetched posts are persisted for future reuse.
if len(st.analyticsPosts) != 2 {
t.Fatalf("expected 2 persisted analytics posts, got %d", len(st.analyticsPosts))
}
// Coverage range for the whole range recorded.
if len(st.coverageRanges) != 1 {
t.Fatalf("expected 1 coverage range, got %d", len(st.coverageRanges))
}
}
func TestFetchTwitterAnalyticsPostsServedFromCoverage(t *testing.T) {
// Posts already persisted and coverage present => no paid API call.
st := newFakeStore()
base := time.Date(2024, 5, 10, 12, 0, 0, 0, time.UTC)
st.coverageRanges = []store.AnalyticsCoverageRange{
{UserID: "@a", Platform: "Twitter", CoveredStart: base, CoveredEnd: base.Add(2 * time.Hour)},
}
st.analyticsPosts[postKey("@a", "Twitter", "t1")] = store.AnalyticsPost{
UserID: "@a", Platform: "Twitter", PostID: "t1", PostText: "cached post", PostTimestamp: base,
}
tw := &fakeTwitter{}
s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, tw, st, &fakeOllama{})
start := base
end := base.Add(2 * time.Hour)
items, err := s.fetchTwitterAnalyticsPosts(context.Background(), "@a", start, end, 50)
if err != nil {
t.Fatalf("fetchTwitterAnalyticsPosts error: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 cached post, got %d", len(items))
}
if tw.calls != 0 {
t.Fatalf("expected no API call when covered, got %d", tw.calls)
}
if items[0].Text != "cached post" {
t.Fatalf("expected cached text, got %q", items[0].Text)
}
}
func TestMergeAnalyticsPostsDedupsAndCaps(t *testing.T) {
base := time.Date(2024, 5, 10, 12, 0, 0, 0, time.UTC)
st := newFakeStore()
// Cached posts overlap with fetched ones by ID, and both exceed the limit.
st.analyticsPosts[postKey("@a", "Twitter", "dup")] = store.AnalyticsPost{
UserID: "@a", Platform: "Twitter", PostID: "dup", PostText: "dup cached", PostTimestamp: base,
}
s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{})
fetched := []clustering.Item{
{ID: "dup", Text: "dup fetched", Timestamp: base},
{ID: "b", Text: "b", Timestamp: base},
{ID: "c", Text: "c", Timestamp: base},
}
merged, err := s.mergeAnalyticsPosts(context.Background(), "@a", base, base.Add(time.Hour), 2, fetched)
if err != nil {
t.Fatalf("mergeAnalyticsPosts error: %v", err)
}
if len(merged) != 2 {
t.Fatalf("expected merged capped at 2, got %d", len(merged))
}
// Fetched entries come first and dedupe against the cache.
if merged[0].ID != "dup" || merged[0].Text != "dup fetched" {
t.Fatalf("expected fetched dup first, got %+v", merged[0])
}
}
func TestPersistAnalyticsPostsSkipsEmptyIDs(t *testing.T) {
st := newFakeStore()
s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{})
err := s.persistAnalyticsPosts(context.Background(), "@a", "Twitter", []clustering.Item{
{ID: "", Text: "no id"},
{ID: "real", Text: "real", Timestamp: time.Now()},
})
if err != nil {
t.Fatalf("persistAnalyticsPosts error: %v", err)
}
if len(st.analyticsPosts) != 1 {
t.Fatalf("expected only the post with an ID persisted, got %d", len(st.analyticsPosts))
}
if _, ok := st.analyticsPosts[postKey("@a", "Twitter", "real")]; !ok {
t.Fatal("expected the 'real' post to be persisted")
}
}
func TestAnalyticsTwitterEndToEnd(t *testing.T) {
// getEngagementMetrics over the Twitter platform drives the coverage logic.
tw := &fakeTwitter{posts: twitterPosts()}
st := newFakeStore()
s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, tw, st, &fakeOllama{})
res, err := callTool(s, "getEngagementMetrics", AnalyticsRequest{
Author: "@a",
StartTime: "2024-05-01T00:00:00Z",
EndTime: "2024-05-31T00:00:00Z",
Platform: "Twitter",
})
if err != nil {
t.Fatalf("getEngagementMetrics twitter error: %v", err)
}
if !strings.Contains(textOf(t, res), `"postId":"t1"`) {
t.Fatalf("expected twitter post t1 in result, got %s", textOf(t, res))
}
if !strings.Contains(textOf(t, res), `"postId":"t2"`) {
t.Fatalf("expected twitter post t2 in result, got %s", textOf(t, res))
}
if tw.calls != 1 {
t.Fatalf("expected 1 API call for the uncovered range, got %d", tw.calls)
}
// Second identical request is served from the analytics cache (no API call).
if _, err := callTool(s, "getEngagementMetrics", AnalyticsRequest{
Author: "@a",
StartTime: "2024-05-01T00:00:00Z",
EndTime: "2024-05-31T00:00:00Z",
Platform: "Twitter",
}); err != nil {
t.Fatalf("second call error: %v", err)
}
if tw.calls != 1 {
t.Fatalf("expected cache to avoid a second API call, got %d", tw.calls)
}
}

View file

@ -1,7 +1,5 @@
package server
import "github.com/nacorid/naco-api/internal/actor"
type Bodyfield struct {
Type string `json:"type"`
Description string `json:"description"`
@ -27,7 +25,7 @@ type GetLatestPostsRequest struct {
type GetLatestPostsResponse struct {
Author string `json:"actor" jsonschema_description:"Did of the actor"`
Posts []*GetPostResponsePost `json:"posts" jsonschema_description:"List of latest posts"`
Posts []GetPostResponsePost `json:"posts" jsonschema_description:"List of latest posts"`
}
type GetPostsInTimeRequest struct {
@ -39,13 +37,13 @@ type GetPostsInTimeRequest struct {
type GetPostsInTimeResponse struct {
Author string `json:"author" jsonschema_description:"Did of the author"`
Posts []*GetPostResponsePost `json:"posts" jsonschema_description:"List of posts in the timeframe"`
Posts []GetPostResponsePost `json:"posts" jsonschema_description:"List of posts in the timeframe"`
}
type GetPostRequest struct {
Author string `json:"author" jsonschema_description:"Did or handle of an author"`
RecordKey string `json:"rkey" jsonschema_description:"Record key of the post"`
Collection string `json:"collection" jsonschema_description:"Optional collection the post belongs to (Bluesky only). Defaults to 'app.bsky.feed.post'"`
Collection string `json:"collection" jsonschema_description:"Optional collection the post belongs to"`
}
type GetPostResponsePost struct {
@ -59,12 +57,12 @@ type GetPostResponsePost struct {
}
type GetFeedRequest struct {
URI string `json:"uri" jsonschema_description:"URI of the feed to retrieve. For Bluesky this is a feed URI; for other platforms it is treated as an author handle"`
URI string `json:"uri" jsonschema_description:"The URI of the feed to retrieve"`
}
type GetFeedResponse struct {
URI string `json:"uri" jsonschema_description:"The URI of the feed"`
Posts []*GetPostResponsePost `json:"posts" jsonschema_description:"List of posts in the feed"`
Posts []GetPostResponsePost `json:"posts" jsonschema_description:"List of posts in the feed"`
}
type GetStyleProfileRequest struct {
@ -80,47 +78,6 @@ type GetStyleProfileResponse struct {
SourcePlatform string `json:"sourcePlatform,omitempty" jsonschema_description:"The platform from which the posts were sourced"`
}
type FindSimilarUsersRequest struct {
Author string `json:"author" jsonschema_description:"Did or handle of an author to find similar profiles for"`
Limit int32 `json:"limit,omitempty" jsonschema_description:"Maximum number of similar profiles to return (default 10, max 50)"`
}
type FindSimilarUsersResponse struct {
Matches []SimilarUser `json:"matches" jsonschema_description:"Ranked list of similar profiles by persona embedding similarity"`
// TwitterPostCount reports how many posts were sourced from the Twitter API
// while building the query author's style profile. It is 0 when the query
// author is not on Twitter.
TwitterPostCount int `json:"twitterPostCount" jsonschema_description:"Number of Twitter-sourced posts used for the query author's profile"`
}
type ActorProfile = actor.Profile
type GetActorRequest struct {
Author string `json:"author" jsonschema_description:"Did, handle, npub or @handle of an actor"`
}
type GetActorResponse struct {
Actor ActorProfile `json:"actor" jsonschema_description:"Profile of the actor"`
}
type SearchActorsRequest struct {
Query string `json:"query" jsonschema_description:"Search query (handle, name or keyword)"`
Limit int `json:"limit,omitempty" jsonschema_description:"Maximum number of results (default 10, max 50)"`
}
type SearchActorsResponse struct {
Results []*ActorProfile `json:"results" jsonschema_description:"Ranked list of matching actor profiles across platforms"`
}
type SimilarUser struct {
Author string `json:"author" jsonschema_description:"Did or handle of the similar author"`
Platform string `json:"platform" jsonschema_description:"Platform the similar author was found on"`
Similarity float64 `json:"similarity" jsonschema_description:"Cosine similarity of the persona embeddings, range 0..1"`
TopKeywords []string `json:"topKeywords,omitempty" jsonschema_description:"Top keywords used by the similar author"`
StyleSummary string `json:"styleSummary,omitempty" jsonschema_description:"Summary of the similar author's writing style"`
PostCount int `json:"postCount,omitempty" jsonschema_description:"Number of posts analyzed for the similar author"`
}
type DCAPMessage struct {
V int `json:"v" jsonschema_description:"DCAP Version"`
T string `json:"t" jsonschema_description:"DCAP Message Type"`
@ -140,134 +97,3 @@ const (
func (p platform) String() string {
return [...]string{"ActivityPub", "Bluesky", "Nostr", "Twitter"}[p]
}
func platformFromString(name string) (platform, bool) {
switch name {
case "ActivityPub":
return ActivityPub, true
case "Bluesky":
return Bluesky, true
case "Nostr":
return Nostr, true
case "Twitter":
return Twitter, true
default:
return -1, false
}
}
// --- Analytics types ---
type AnalyticsRequest struct {
Author string `json:"author" jsonschema_description:"Did or handle of an author"`
StartTime string `json:"startTime,omitempty" jsonschema_description:"Start time in RFC3339 format (default: 30 days ago)"`
EndTime string `json:"endTime,omitempty" jsonschema_description:"End time in RFC3339 format (default: now)"`
Limit int `json:"limit,omitempty" jsonschema_description:"Maximum number of posts to analyze (default: 50, max: 200)"`
Platform string `json:"platform,omitempty" jsonschema_description:"Platform to filter (default: all)"`
}
type EngagementMetricsResponse struct {
Author string `json:"author" jsonschema_description:"Author identifier"`
Metrics []EngagementMetricPoint `json:"metrics" jsonschema_description:"Engagement metrics per post"`
// TwitterPostCount reports how many posts in the result were sourced from
// the Twitter API, so upto-scheme settlement can pass the realized Twitter
// costs on to the caller.
TwitterPostCount int `json:"twitterPostCount" jsonschema_description:"Number of Twitter-sourced posts analyzed"`
}
type EngagementMetricPoint struct {
PostID string `json:"postId" jsonschema_description:"Post identifier"`
Timestamp string `json:"timestamp" jsonschema_description:"RFC3339 timestamp"`
Likes int64 `json:"likes" jsonschema_description:"Number of likes"`
Reposts int64 `json:"reposts" jsonschema_description:"Number of reposts"`
Replies int64 `json:"replies" jsonschema_description:"Number of replies"`
Quotes int64 `json:"quotes" jsonschema_description:"Number of quotes"`
EngagementRate float64 `json:"engagementRate" jsonschema_description:"Engagement rate (interactions / impressions estimate)"`
}
type PostingPatternsResponse struct {
Author string `json:"author" jsonschema_description:"Author identifier"`
Heatmap [][]int `json:"heatmap" jsonschema_description:"24x7 matrix: hour (0-23) x day of week (0-6 = Sun-Sat)"`
PeakHours []int `json:"peakHours" jsonschema_description:"Top 3 hours with most posts (0-23)"`
PeakDays []int `json:"peakDays" jsonschema_description:"Top 3 days with most posts (0-6 = Sun-Sat)"`
AvgPostsPerDay float64 `json:"avgPostsPerDay" jsonschema_description:"Average posts per day"`
TotalPosts int `json:"totalPosts" jsonschema_description:"Total posts in time range"`
PostingDays int `json:"postingDays" jsonschema_description:"Number of days with at least one post"`
// TwitterPostCount reports how many posts in the result were sourced from
// the Twitter API, so upto-scheme settlement can pass the realized Twitter
// costs on to the caller.
TwitterPostCount int `json:"twitterPostCount" jsonschema_description:"Number of Twitter-sourced posts analyzed"`
}
type TopicModelingResponse struct {
Author string `json:"author" jsonschema_description:"Author identifier"`
Topics []TopicCluster `json:"topics" jsonschema_description:"Identified topic clusters"`
// TwitterPostCount reports how many posts in the result were sourced from
// the Twitter API, so upto-scheme settlement can pass the realized Twitter
// costs on to the caller.
TwitterPostCount int `json:"twitterPostCount" jsonschema_description:"Number of Twitter-sourced posts analyzed"`
}
type TopicCluster struct {
TopicID int `json:"topicId" jsonschema_description:"Cluster identifier"`
Label string `json:"label" jsonschema_description:"Human-readable topic label"`
Keywords []string `json:"keywords" jsonschema_description:"Top keywords for this topic"`
PostCount int `json:"postCount" jsonschema_description:"Number of posts in this topic"`
Percentage float64 `json:"percentage" jsonschema_description:"Percentage of total posts"`
SamplePosts []string `json:"samplePosts" jsonschema_description:"Sample post texts (max 3)"`
}
type SentimentTrendsResponse struct {
Author string `json:"author" jsonschema_description:"Author identifier"`
TimeSeries []SentimentPoint `json:"timeSeries" jsonschema_description:"Sentiment over time"`
OverallSentiment SentimentSummary `json:"overallSentiment" jsonschema_description:"Overall sentiment summary"`
DominantEmotions []EmotionScore `json:"dominantEmotions" jsonschema_description:"Most frequent emotions"`
SentimentShifts []SentimentShift `json:"sentimentShifts" jsonschema_description:"Significant sentiment changes"`
// TwitterPostCount reports how many posts in the result were sourced from
// the Twitter API, so upto-scheme settlement can pass the realized Twitter
// costs on to the caller.
TwitterPostCount int `json:"twitterPostCount" jsonschema_description:"Number of Twitter-sourced posts analyzed"`
}
type SentimentPoint struct {
Timestamp string `json:"timestamp" jsonschema_description:"RFC3339 timestamp (bucketed by day)"`
Positive float64 `json:"positive" jsonschema_description:"Positive sentiment ratio (0-1)"`
Negative float64 `json:"negative" jsonschema_description:"Negative sentiment ratio (0-1)"`
Neutral float64 `json:"neutral" jsonschema_description:"Neutral sentiment ratio (0-1)"`
PostCount int `json:"postCount" jsonschema_description:"Posts in this time bucket"`
}
type SentimentSummary struct {
Positive float64 `json:"positive" jsonschema_description:"Overall positive ratio"`
Negative float64 `json:"negative" jsonschema_description:"Overall negative ratio"`
Neutral float64 `json:"neutral" jsonschema_description:"Overall neutral ratio"`
}
type EmotionScore struct {
Emotion string `json:"emotion" jsonschema_description:"Emotion name"`
Score float64 `json:"score" jsonschema_description:"Frequency score (0-1)"`
}
type SentimentShift struct {
Timestamp string `json:"timestamp" jsonschema_description:"When the shift occurred"`
FromSentiment string `json:"fromSentiment" jsonschema_description:"Previous dominant sentiment"`
ToSentiment string `json:"toSentiment" jsonschema_description:"New dominant sentiment"`
Magnitude float64 `json:"magnitude" jsonschema_description:"Shift magnitude (0-1)"`
}
type EngagementDriversResponse struct {
Author string `json:"author" jsonschema_description:"Author identifier"`
Drivers []EngagementDriver `json:"drivers" jsonschema_description:"Topics/keywords driving engagement"`
// TwitterPostCount reports how many posts in the result were sourced from
// the Twitter API, so upto-scheme settlement can pass the realized Twitter
// costs on to the caller.
TwitterPostCount int `json:"twitterPostCount" jsonschema_description:"Number of Twitter-sourced posts analyzed"`
}
type EngagementDriver struct {
Topic string `json:"topic" jsonschema_description:"Topic or keyword"`
AvgEngagement float64 `json:"avgEngagement" jsonschema_description:"Average engagement rate for posts with this topic"`
Correlation float64 `json:"correlation" jsonschema_description:"Correlation coefficient with engagement (-1 to 1)"`
PostCount int `json:"postCount" jsonschema_description:"Number of posts containing this topic"`
SamplePosts []string `json:"samplePosts" jsonschema_description:"Sample posts (max 2)"`
}

View file

@ -1,261 +0,0 @@
package server
import (
"encoding/json"
"fmt"
"strings"
"testing"
"time"
"github.com/bluesky-social/indigo/api/bsky"
"github.com/nacorid/naco-api/internal/activitypub"
"github.com/nacorid/naco-api/internal/nostr"
)
// ---- validation matrix ----
func TestGetPostValidation(t *testing.T) {
s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
if _, err := callTool(s, "getPost", GetPostRequest{Author: "", RecordKey: "k"}); err == nil || !strings.Contains(err.Error(), "author") {
t.Fatalf("expected author error, got %v", err)
}
if _, err := callTool(s, "getPost", GetPostRequest{Author: "did:plc:abc", RecordKey: ""}); err == nil || !strings.Contains(err.Error(), "rkey") {
t.Fatalf("expected rkey error, got %v", err)
}
if _, err := callTool(s, "getPost", "not-an-object"); err == nil {
t.Fatal("expected invalid arguments error")
}
// Unparseable handle -> determinePlatform error
if _, err := callTool(s, "getPost", GetPostRequest{Author: "justhandle", RecordKey: "k"}); err == nil {
t.Fatal("expected determinePlatform error")
}
}
func TestGetLatestPostsValidation(t *testing.T) {
s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
if _, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "", Limit: 5}); err == nil || !strings.Contains(err.Error(), "author") {
t.Fatalf("expected author error, got %v", err)
}
if _, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "did:plc:abc", Limit: 0}); err == nil || !strings.Contains(err.Error(), "positive") {
t.Fatalf("expected limit error, got %v", err)
}
if _, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "did:plc:abc", Limit: -3}); err == nil {
t.Fatal("expected negative limit error")
}
if _, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "did:plc:abc", Limit: 51}); err == nil || !strings.Contains(err.Error(), "at most 50") {
t.Fatalf("expected max-limit error, got %v", err)
}
}
func TestGetPostsInTimeValidation(t *testing.T) {
s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
if _, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "", StartTime: "2024-01-01T00:00:00Z", EndTime: "2024-12-31T00:00:00Z", Limit: 5}); err == nil || !strings.Contains(err.Error(), "author") {
t.Fatalf("expected author error, got %v", err)
}
if _, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "did:plc:abc", StartTime: "2024-01-01T00:00:00Z", EndTime: "2024-12-31T00:00:00Z", Limit: 0}); err == nil || !strings.Contains(err.Error(), "positive") {
t.Fatalf("expected limit error, got %v", err)
}
// getPostsInTime now rejects over-max limit for consistency with getLatestPosts.
if _, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "did:plc:abc", StartTime: "2024-01-01T00:00:00Z", EndTime: "2024-12-31T00:00:00Z", Limit: 51}); err == nil || !strings.Contains(err.Error(), "at most 50") {
t.Fatalf("expected max-limit error, got %v", err)
}
// Bad start time.
if _, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "did:plc:abc", StartTime: "not-a-time", EndTime: "2024-12-31T00:00:00Z", Limit: 5}); err == nil || !strings.Contains(err.Error(), "startTime") {
t.Fatalf("expected startTime error, got %v", err)
}
// Bad end time reports the correct field name.
if _, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "did:plc:abc", StartTime: "2024-01-01T00:00:00Z", EndTime: "not-a-time", Limit: 5}); err == nil || !strings.Contains(err.Error(), "endTime") {
t.Fatalf("expected endTime error, got %v", err)
}
}
func TestGetPostsInTimeSwapsReversedRange(t *testing.T) {
// Nostr fake returns posts regardless of args; the point is that a reversed
// range is accepted (swapped) rather than rejected.
nstr := &fakeNostr{posts: []*nostr.Post{{ID: "e1", Author: "npub1x", Content: "hi", CreatedAt: time.Now()}}}
s := newTestServer(&fakeBluesky{}, &fakeAP{}, nstr, &fakeTwitter{})
res, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{
Author: "npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
StartTime: "2024-12-31T00:00:00Z",
EndTime: "2024-01-01T00:00:00Z",
Limit: 5,
})
if err != nil {
t.Fatalf("expected reversed range to be swapped and succeed, got %v", err)
}
if res == nil || len(res.Content) == 0 {
t.Fatal("expected a result")
}
}
func TestGetFeedValidation(t *testing.T) {
s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
if _, err := callTool(s, "getFeed", GetFeedRequest{URI: ""}); err == nil || !strings.Contains(err.Error(), "URI") {
t.Fatalf("expected URI error, got %v", err)
}
}
func TestGetActorValidation(t *testing.T) {
s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
if _, err := callTool(s, "getActor", GetActorRequest{Author: ""}); err == nil || !strings.Contains(err.Error(), "author") {
t.Fatalf("expected author error, got %v", err)
}
// Bare handle is unparseable -> determinePlatform error.
if _, err := callTool(s, "getActor", GetActorRequest{Author: "justhandle"}); err == nil {
t.Fatal("expected determinePlatform error")
}
}
func TestSearchActorsValidation(t *testing.T) {
s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
if _, err := callTool(s, "searchActors", SearchActorsRequest{Query: ""}); err == nil || !strings.Contains(err.Error(), "query") {
t.Fatalf("expected query error, got %v", err)
}
}
// ---- twitter policy blocks ----
func TestTwitterPolicyBlocksAllTools(t *testing.T) {
cases := []struct {
tool string
args any
}{
{"getPost", GetPostRequest{Author: "@alice", RecordKey: "k"}},
{"getLatestPosts", GetLatestPostsRequest{Author: "@alice", Limit: 5}},
{"getPostsInTime", GetPostsInTimeRequest{Author: "@alice", StartTime: "2024-01-01T00:00:00Z", EndTime: "2024-12-31T00:00:00Z", Limit: 5}},
{"getFeed", GetFeedRequest{URI: "@alice"}},
{"getActor", GetActorRequest{Author: "@alice"}},
}
for _, c := range cases {
tw := &fakeTwitter{}
s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, tw)
_, err := callTool(s, c.tool, c.args)
if err == nil || !strings.Contains(err.Error(), "twitter_unavailable") {
t.Fatalf("%s: expected twitter_unavailable, got %v", c.tool, err)
}
if tw.calls != 0 {
t.Fatalf("%s: backend should not be called, got %d calls", c.tool, tw.calls)
}
}
}
// ---- backend error propagation ----
func TestBackendErrorPropagation(t *testing.T) {
bsky := &fakeBluesky{err: fmt.Errorf("boom")}
s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
if _, err := callTool(s, "getPost", GetPostRequest{Author: "did:plc:abc", RecordKey: "k"}); err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("expected backend error, got %v", err)
}
}
func TestGetActorBackendError(t *testing.T) {
ap := &fakeAP{err: fmt.Errorf("boom")}
s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{})
if _, err := callTool(s, "getActor", GetActorRequest{Author: "@a@mastodon.social"}); err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("expected backend error, got %v", err)
}
}
// ---- getPost success paths ----
func TestGetPostBlueskyAndNostr(t *testing.T) {
bsky := &fakeBluesky{postView: bskyPostView("hello bluesky")}
s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
res, err := callTool(s, "getPost", GetPostRequest{Author: "did:plc:abc", RecordKey: "k"})
if err != nil {
t.Fatalf("getPost bluesky error: %v", err)
}
if !strings.Contains(textOf(t, res), "hello bluesky") {
t.Fatalf("unexpected bluesky post text: %s", textOf(t, res))
}
if bsky.calls != 1 {
t.Fatalf("expected 1 backend call, got %d", bsky.calls)
}
nstr := &fakeNostr{post: &nostr.Post{ID: "n1", Author: "npub1x", Content: "nostr hello"}}
s2 := newTestServer(&fakeBluesky{}, &fakeAP{}, nstr, &fakeTwitter{})
res2, err := callTool(s2, "getPost", GetPostRequest{Author: "npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", RecordKey: "n1"})
if err != nil {
t.Fatalf("getPost nostr error: %v", err)
}
if !strings.Contains(textOf(t, res2), "nostr hello") {
t.Fatalf("unexpected nostr post text: %s", textOf(t, res2))
}
}
func TestGetLatestPostsEmptyResult(t *testing.T) {
ap := &fakeAP{}
s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{})
res, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "@a@mastodon.social", Limit: 5})
if err != nil {
t.Fatalf("getLatestPosts error: %v", err)
}
var out GetLatestPostsResponse
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(out.Posts) != 0 {
t.Fatalf("expected 0 posts, got %d", len(out.Posts))
}
if out.Author != "@a@mastodon.social" {
t.Fatalf("expected author fallback to request author, got %q", out.Author)
}
}
func TestGetLatestPostsBlueskyNilPostSkipped(t *testing.T) {
bsky := &fakeBluesky{feedViews: []*bsky.FeedDefs_FeedViewPost{
{Post: bskyPostView("kept")},
{Post: nil},
}}
s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
res, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "did:plc:abc", Limit: 5})
if err != nil {
t.Fatalf("getLatestPosts error: %v", err)
}
var out GetLatestPostsResponse
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(out.Posts) != 1 {
t.Fatalf("expected 1 post after skipping nil, got %d", len(out.Posts))
}
}
func TestGetFeedNonBlueskyFallback(t *testing.T) {
// A non-Bluesky feed URI is treated as an author handle and falls back to
// fetchLatestPosts (evaluated via the ActivityPub backend here).
ap := &fakeAP{posts: []*activitypub.Post{{Author: "@a@mastodon.social", Content: "fallback post", CreatedAt: time.Now()}}}
s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{})
res, err := callTool(s, "getFeed", GetFeedRequest{URI: "@a@mastodon.social"})
if err != nil {
t.Fatalf("getFeed fallback error: %v", err)
}
if !strings.Contains(textOf(t, res), "fallback post") {
t.Fatalf("expected fallback post, got %s", textOf(t, res))
}
}
func TestGetFeedEmptyResult(t *testing.T) {
bsky := &fakeBluesky{feedOut: &bsky.FeedGetFeed_Output{Feed: nil}}
s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
res, err := callTool(s, "getFeed", GetFeedRequest{URI: "at://did:plc:abc/app.bsky.feed.generator/abc"})
if err != nil {
t.Fatalf("getFeed error: %v", err)
}
var out GetFeedResponse
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(out.Posts) != 0 {
t.Fatalf("expected 0 feed posts, got %d", len(out.Posts))
}
}
func jsonUnmarshal(t *testing.T, in string, out any) error {
t.Helper()
return json.Unmarshal([]byte(in), out)
}

View file

@ -1,125 +0,0 @@
package server
import (
"context"
"fmt"
"net/http"
"strings"
"time"
log "github.com/nacorid/logger"
"github.com/nacorid/naco-api/internal/config"
)
type WebhookSender struct {
cfg *config.Config
metrics *MetricsMiddleware
httpCli *http.Client
server string
url string
}
func newWebhookSender(cfg *config.Config, m *MetricsMiddleware) *WebhookSender {
if cfg.MatrixWebhookURL == "" {
return nil
}
return &WebhookSender{
cfg: cfg,
metrics: m,
httpCli: &http.Client{Timeout: 10 * time.Second},
server: cfg.MatrixServerID,
url: cfg.MatrixWebhookURL,
}
}
func (wh *WebhookSender) Send(ctx context.Context, jsonBytes []byte) {
if wh == nil {
return
}
if len(jsonBytes) == 0 {
log.ErrorWithContext(ctx, "webhook: empty payload ignored")
return
}
var lastErr error
for attempt := 1; attempt <= 5; attempt++ {
delay := exponentialBackoff(attempt)
if attempt > 1 {
select {
case <-time.After(delay):
case <-ctx.Done():
log.WarnWithContext(ctx, "webhook cancelled during backoff", "attempt", attempt, "error", ctx.Err())
return
}
}
err := wh.sendAttempt(ctx, jsonBytes, attempt)
if err == nil {
log.InfoWithContext(ctx,
"webhook succeeded",
"attempt", attempt,
"server", wh.server,
"url", maskURL(wh.url),
)
return
}
lastErr = err
log.WarnWithContext(ctx,
"webhook attempt failed",
"attempt", attempt,
"total_attempts", 5,
"server", wh.server,
"url", maskURL(wh.url),
"error", err,
)
}
log.ErrorWithContext(ctx,
"webhook failed after all attempts",
"total_attempts", 5,
"server", wh.server,
"url", maskURL(wh.url),
"error", lastErr,
)
}
func (wh *WebhookSender) sendAttempt(ctx context.Context, jsonBytes []byte, attempt int) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, wh.url, strings.NewReader(string(jsonBytes)))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "naco-api/"+wh.server)
resp, err := wh.httpCli.Do(req)
if err != nil {
return fmt.Errorf("do request: %w", err)
}
_ = resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
return fmt.Errorf("non-2xx status: %d", resp.StatusCode)
}
func exponentialBackoff(attempt int) time.Duration {
base := 500 * time.Millisecond
delay := base << (attempt - 1)
if delay > 30*time.Second {
delay = 30 * time.Second
}
return delay
}
func maskURL(url string) string {
if len(url) > 30 {
return url[:15] + "..." + url[len(url)-15:]
}
return url
}

View file

@ -1,96 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: AnalyticsCache.sql
package store
import (
"context"
"encoding/json"
"time"
)
const deleteAnalyticsCache = `-- name: DeleteAnalyticsCache :exec
DELETE FROM analytics_cache
WHERE user_id = $1 AND platform = $2 AND tool_name = $3 AND time_range_hash = $4
`
type DeleteAnalyticsCacheParams struct {
UserID string
Platform string
ToolName string
TimeRangeHash string
}
func (q *Queries) DeleteAnalyticsCache(ctx context.Context, arg DeleteAnalyticsCacheParams) error {
_, err := q.db.ExecContext(ctx, deleteAnalyticsCache,
arg.UserID,
arg.Platform,
arg.ToolName,
arg.TimeRangeHash,
)
return err
}
const getAnalyticsCache = `-- name: GetAnalyticsCache :one
SELECT result FROM analytics_cache
WHERE user_id = $1 AND platform = $2 AND tool_name = $3 AND time_range_hash = $4
AND expires_at > now()
`
type GetAnalyticsCacheParams struct {
UserID string
Platform string
ToolName string
TimeRangeHash string
}
func (q *Queries) GetAnalyticsCache(ctx context.Context, arg GetAnalyticsCacheParams) (json.RawMessage, error) {
row := q.db.QueryRowContext(ctx, getAnalyticsCache,
arg.UserID,
arg.Platform,
arg.ToolName,
arg.TimeRangeHash,
)
var result json.RawMessage
err := row.Scan(&result)
return result, err
}
const pruneAnalyticsCache = `-- name: PruneAnalyticsCache :exec
DELETE FROM analytics_cache WHERE expires_at <= now()
`
func (q *Queries) PruneAnalyticsCache(ctx context.Context) error {
_, err := q.db.ExecContext(ctx, pruneAnalyticsCache)
return err
}
const upsertAnalyticsCache = `-- name: UpsertAnalyticsCache :exec
INSERT INTO analytics_cache (user_id, platform, tool_name, time_range_hash, result, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (user_id, platform, tool_name, time_range_hash)
DO UPDATE SET result = EXCLUDED.result, expires_at = EXCLUDED.expires_at, computed_at = now()
`
type UpsertAnalyticsCacheParams struct {
UserID string
Platform string
ToolName string
TimeRangeHash string
Result json.RawMessage
ExpiresAt time.Time
}
func (q *Queries) UpsertAnalyticsCache(ctx context.Context, arg UpsertAnalyticsCacheParams) error {
_, err := q.db.ExecContext(ctx, upsertAnalyticsCache,
arg.UserID,
arg.Platform,
arg.ToolName,
arg.TimeRangeHash,
arg.Result,
arg.ExpiresAt,
)
return err
}

View file

@ -1,96 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: AnalyticsCoverage.sql
package store
import (
"context"
"time"
)
const deleteAnalyticsCoverageRange = `-- name: DeleteAnalyticsCoverageRange :exec
DELETE FROM analytics_coverage_ranges
WHERE user_id = $1 AND platform = $2 AND covered_start = $3 AND covered_end = $4
`
type DeleteAnalyticsCoverageRangeParams struct {
UserID string
Platform string
CoveredStart time.Time
CoveredEnd time.Time
}
func (q *Queries) DeleteAnalyticsCoverageRange(ctx context.Context, arg DeleteAnalyticsCoverageRangeParams) error {
_, err := q.db.ExecContext(ctx, deleteAnalyticsCoverageRange,
arg.UserID,
arg.Platform,
arg.CoveredStart,
arg.CoveredEnd,
)
return err
}
const getAnalyticsCoverageRanges = `-- name: GetAnalyticsCoverageRanges :many
SELECT covered_start, covered_end FROM analytics_coverage_ranges
WHERE user_id = $1 AND platform = $2
ORDER BY covered_start
`
type GetAnalyticsCoverageRangesParams struct {
UserID string
Platform string
}
type GetAnalyticsCoverageRangesRow struct {
CoveredStart time.Time
CoveredEnd time.Time
}
func (q *Queries) GetAnalyticsCoverageRanges(ctx context.Context, arg GetAnalyticsCoverageRangesParams) ([]GetAnalyticsCoverageRangesRow, error) {
rows, err := q.db.QueryContext(ctx, getAnalyticsCoverageRanges, arg.UserID, arg.Platform)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetAnalyticsCoverageRangesRow
for rows.Next() {
var i GetAnalyticsCoverageRangesRow
if err := rows.Scan(&i.CoveredStart, &i.CoveredEnd); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const insertAnalyticsCoverageRange = `-- name: InsertAnalyticsCoverageRange :exec
INSERT INTO analytics_coverage_ranges (user_id, platform, covered_start, covered_end, updated_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (user_id, platform, covered_start, covered_end)
DO UPDATE SET updated_at = now()
`
type InsertAnalyticsCoverageRangeParams struct {
UserID string
Platform string
CoveredStart time.Time
CoveredEnd time.Time
}
func (q *Queries) InsertAnalyticsCoverageRange(ctx context.Context, arg InsertAnalyticsCoverageRangeParams) error {
_, err := q.db.ExecContext(ctx, insertAnalyticsCoverageRange,
arg.UserID,
arg.Platform,
arg.CoveredStart,
arg.CoveredEnd,
)
return err
}

View file

@ -1,113 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: AnalyticsPosts.sql
package store
import (
"context"
"time"
)
const getAnalyticsPostsInRange = `-- name: GetAnalyticsPostsInRange :many
SELECT post_id, post_text, post_timestamp, likes, reposts, replies, quotes
FROM analytics_posts
WHERE user_id = $1 AND platform = $2 AND post_timestamp >= $3 AND post_timestamp <= $4
ORDER BY post_timestamp DESC
LIMIT $5
`
type GetAnalyticsPostsInRangeParams struct {
UserID string
Platform string
PostTimestamp time.Time
PostTimestamp_2 time.Time
Limit int32
}
type GetAnalyticsPostsInRangeRow struct {
PostID string
PostText string
PostTimestamp time.Time
Likes int64
Reposts int64
Replies int64
Quotes int64
}
func (q *Queries) GetAnalyticsPostsInRange(ctx context.Context, arg GetAnalyticsPostsInRangeParams) ([]GetAnalyticsPostsInRangeRow, error) {
rows, err := q.db.QueryContext(ctx, getAnalyticsPostsInRange,
arg.UserID,
arg.Platform,
arg.PostTimestamp,
arg.PostTimestamp_2,
arg.Limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetAnalyticsPostsInRangeRow
for rows.Next() {
var i GetAnalyticsPostsInRangeRow
if err := rows.Scan(
&i.PostID,
&i.PostText,
&i.PostTimestamp,
&i.Likes,
&i.Reposts,
&i.Replies,
&i.Quotes,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const upsertAnalyticsPosts = `-- name: UpsertAnalyticsPosts :exec
INSERT INTO analytics_posts (user_id, platform, post_id, post_text, post_timestamp, likes, reposts, replies, quotes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (user_id, platform, post_id)
DO UPDATE SET post_text = EXCLUDED.post_text,
post_timestamp = EXCLUDED.post_timestamp,
likes = EXCLUDED.likes,
reposts = EXCLUDED.reposts,
replies = EXCLUDED.replies,
quotes = EXCLUDED.quotes
`
type UpsertAnalyticsPostsParams struct {
UserID string
Platform string
PostID string
PostText string
PostTimestamp time.Time
Likes int64
Reposts int64
Replies int64
Quotes int64
}
func (q *Queries) UpsertAnalyticsPosts(ctx context.Context, arg UpsertAnalyticsPostsParams) error {
_, err := q.db.ExecContext(ctx, upsertAnalyticsPosts,
arg.UserID,
arg.Platform,
arg.PostID,
arg.PostText,
arg.PostTimestamp,
arg.Likes,
arg.Reposts,
arg.Replies,
arg.Quotes,
)
return err
}

View file

@ -1,25 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: DeleteStylePosts.sql
package store
import (
"context"
)
const deleteStylePosts = `-- name: DeleteStylePosts :exec
DELETE FROM user_style_posts
WHERE user_id = $1 AND platform = $2 AND platform != 'Twitter'
`
type DeleteStylePostsParams struct {
UserID string
Platform string
}
func (q *Queries) DeleteStylePosts(ctx context.Context, arg DeleteStylePostsParams) error {
_, err := q.db.ExecContext(ctx, deleteStylePosts, arg.UserID, arg.Platform)
return err
}

View file

@ -20,8 +20,8 @@ LIMIT $2
`
type FindSimilarUsersParams struct {
QueryEmbedding interface{}
LimitVal int32
Column1 interface{}
Limit int32
}
type FindSimilarUsersRow struct {
@ -35,7 +35,7 @@ type FindSimilarUsersRow struct {
}
func (q *Queries) FindSimilarUsers(ctx context.Context, arg FindSimilarUsersParams) ([]FindSimilarUsersRow, error) {
rows, err := q.db.QueryContext(ctx, findSimilarUsers, arg.QueryEmbedding, arg.LimitVal)
rows, err := q.db.QueryContext(ctx, findSimilarUsers, arg.Column1, arg.Limit)
if err != nil {
return nil, err
}

View file

@ -1,33 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: GetStylePostByID.sql
package store
import (
"context"
)
const getStylePostByID = `-- name: GetStylePostByID :one
SELECT post_id, post_text FROM user_style_posts
WHERE user_id = $1 AND platform = $2 AND post_id = $3
`
type GetStylePostByIDParams struct {
UserID string
Platform string
PostID string
}
type GetStylePostByIDRow struct {
PostID string
PostText string
}
func (q *Queries) GetStylePostByID(ctx context.Context, arg GetStylePostByIDParams) (GetStylePostByIDRow, error) {
row := q.db.QueryRowContext(ctx, getStylePostByID, arg.UserID, arg.Platform, arg.PostID)
var i GetStylePostByIDRow
err := row.Scan(&i.PostID, &i.PostText)
return i, err
}

View file

@ -1,51 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: GetStylePosts.sql
package store
import (
"context"
)
const getStylePosts = `-- name: GetStylePosts :many
SELECT post_id, post_text FROM user_style_posts
WHERE user_id = $1 AND platform = $2
ORDER BY created_at DESC
LIMIT $3
`
type GetStylePostsParams struct {
UserID string
Platform string
Limit int32
}
type GetStylePostsRow struct {
PostID string
PostText string
}
func (q *Queries) GetStylePosts(ctx context.Context, arg GetStylePostsParams) ([]GetStylePostsRow, error) {
rows, err := q.db.QueryContext(ctx, getStylePosts, arg.UserID, arg.Platform, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetStylePostsRow
for rows.Next() {
var i GetStylePostsRow
if err := rows.Scan(&i.PostID, &i.PostText); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View file

@ -1,51 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: GetStylePostsByIDs.sql
package store
import (
"context"
"github.com/lib/pq"
)
const getStylePostsByIDs = `-- name: GetStylePostsByIDs :many
SELECT post_id, post_text FROM user_style_posts
WHERE user_id = $1 AND platform = $2 AND post_id = ANY($3::text[])
`
type GetStylePostsByIDsParams struct {
UserID string
Platform string
Column3 []string
}
type GetStylePostsByIDsRow struct {
PostID string
PostText string
}
func (q *Queries) GetStylePostsByIDs(ctx context.Context, arg GetStylePostsByIDsParams) ([]GetStylePostsByIDsRow, error) {
rows, err := q.db.QueryContext(ctx, getStylePostsByIDs, arg.UserID, arg.Platform, pq.Array(arg.Column3))
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetStylePostsByIDsRow
for rows.Next() {
var i GetStylePostsByIDsRow
if err := rows.Scan(&i.PostID, &i.PostText); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View file

@ -12,7 +12,7 @@ import (
)
const getStyleProfile = `-- name: GetStyleProfile :one
SELECT id, user_id, platform, embedding, top_keywords, style_summary, post_count, modified_at, created_at, model_version, schema_version, algorithm_version FROM user_style_profiles
SELECT id, user_id, platform, embedding, top_keywords, style_summary, post_count, modified_at, created_at FROM user_style_profiles
WHERE user_id = $1 AND platform = $2
`
@ -34,9 +34,6 @@ func (q *Queries) GetStyleProfile(ctx context.Context, arg GetStyleProfileParams
&i.PostCount,
&i.ModifiedAt,
&i.CreatedAt,
&i.ModelVersion,
&i.SchemaVersion,
&i.AlgorithmVersion,
)
return i, err
}

View file

@ -1,20 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: PruneStylePosts.sql
package store
import (
"context"
)
const pruneStylePosts = `-- name: PruneStylePosts :exec
DELETE FROM user_style_posts
WHERE created_at < now() - interval '7 days' AND platform != 'Twitter'
`
func (q *Queries) PruneStylePosts(ctx context.Context) error {
_, err := q.db.ExecContext(ctx, pruneStylePosts)
return err
}

View file

@ -1,34 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: UpsertStylePosts.sql
package store
import (
"context"
)
const upsertStylePosts = `-- name: UpsertStylePosts :exec
INSERT INTO user_style_posts (user_id, platform, post_id, post_text)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, platform, post_id)
DO UPDATE SET post_text = EXCLUDED.post_text, created_at = now()
`
type UpsertStylePostsParams struct {
UserID string
Platform string
PostID string
PostText string
}
func (q *Queries) UpsertStylePosts(ctx context.Context, arg UpsertStylePostsParams) error {
_, err := q.db.ExecContext(ctx, upsertStylePosts,
arg.UserID,
arg.Platform,
arg.PostID,
arg.PostText,
)
return err
}

View file

@ -13,14 +13,14 @@ import (
const upsertStyleProfile = `-- name: UpsertStyleProfile :exec
INSERT INTO user_style_profiles (user_id, platform, embedding, top_keywords, style_summary, post_count)
VALUES ($1, $2, $3::vector, $4, $5, $6)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (user_id, platform)
DO UPDATE SET
embedding = EXCLUDED.embedding,
top_keywords = EXCLUDED.top_keywords,
style_summary = EXCLUDED.style_summary,
post_count = EXCLUDED.post_count,
modified_at = NOW()
updated_at = NOW()
`
type UpsertStyleProfileParams struct {

View file

@ -1,194 +0,0 @@
package store
import (
"context"
"database/sql"
"fmt"
"os"
"reflect"
"strings"
"testing"
"time"
_ "github.com/lib/pq"
)
// setupTestDB handles connection pooling, migrations, and cleanup per test run.
func setupTestDB(t *testing.T) (*sql.DB, *Queries) {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set; skipping store integration test")
}
ctx := context.Background()
conn, err := sql.Open("postgres", dsn)
if err != nil {
t.Fatalf("failed to open db: %v", err)
}
if err := conn.PingContext(ctx); err != nil {
_ = conn.Close()
t.Fatalf("cannot reach test database: %v", err)
}
t.Cleanup(func() {
_ = conn.Close()
})
if err := Migrate(ctx, conn, "sql/schema"); err != nil {
t.Fatalf("migrate: %v", err)
}
return conn, New(conn)
}
func makeEmbedding(dim int, val string) string {
return "[" + strings.TrimSuffix(strings.Repeat(val+",", dim), ",") + "]"
}
func TestStoreRoundTrip(t *testing.T) {
conn, q := setupTestDB(t)
ctx := context.Background()
uid := fmt.Sprintf("test_%d", time.Now().UnixNano())
platform := "Bluesky"
emb1 := makeEmbedding(1024, "0.1")
// Clean up after the test so leftover records do not pollute subsequent runs
t.Cleanup(func() {
_, _ = conn.ExecContext(context.Background(), "DELETE FROM style_profiles WHERE user_id = $1", uid)
})
// 1. Test INSERT path of Upsert
err := q.UpsertStyleProfile(ctx, UpsertStyleProfileParams{
UserID: uid,
Platform: platform,
Embedding: emb1,
TopKeywords: []string{"alpha", "beta"},
StyleSummary: "original summary",
PostCount: 5,
})
if err != nil {
t.Fatalf("initial upsert failed: %v", err)
}
// 2. Test GET and verify fields
p, err := q.GetStyleProfile(ctx, GetStyleProfileParams{UserID: uid, Platform: platform})
if err != nil {
t.Fatalf("get failed: %v", err)
}
if p.UserID != uid || p.Platform != platform || p.PostCount != 5 {
t.Errorf("mismatched basic fields: %+v", p)
}
if p.StyleSummary != "original summary" {
t.Errorf("got summary %q, want %q", p.StyleSummary, "original summary")
}
if !reflect.DeepEqual(p.TopKeywords, []string{"alpha", "beta"}) {
t.Errorf("got keywords %v, want %v", p.TopKeywords, []string{"alpha", "beta"})
}
// 3. Test UPDATE path of Upsert (Verify conflict resolution works)
emb2 := makeEmbedding(1024, "0.2")
err = q.UpsertStyleProfile(ctx, UpsertStyleProfileParams{
UserID: uid,
Platform: platform,
Embedding: emb2,
TopKeywords: []string{"gamma"},
StyleSummary: "updated summary",
PostCount: 10,
})
if err != nil {
t.Fatalf("secondary upsert (update) failed: %v", err)
}
updated, err := q.GetStyleProfile(ctx, GetStyleProfileParams{UserID: uid, Platform: platform})
if err != nil {
t.Fatalf("get updated profile failed: %v", err)
}
if updated.PostCount != 10 || updated.StyleSummary != "updated summary" {
t.Errorf("upsert failed to update existing record: %+v", updated)
}
// 4. Test pgvector similarity search
rows, err := q.FindSimilarUsers(ctx, FindSimilarUsersParams{
QueryEmbedding: emb2,
LimitVal: 10,
})
if err != nil {
t.Fatalf("find similar failed: %v", err)
}
found := false
for _, r := range rows {
if r.UserID == uid {
found = true
break
}
}
if !found {
t.Errorf("expected user %s in similarity results, but was not found", uid)
}
}
func TestStoreSaveTransaction(t *testing.T) {
conn, q := setupTestDB(t)
ctx := context.Background()
txHash := fmt.Sprintf("0x_%d", time.Now().UnixNano())
amount := "10.0"
asset := "ETH"
network := "eip155:1"
recipient := "0x1234567890abcdef"
sender := "0xabcdef1234567890"
t.Cleanup(func() {
_, _ = conn.ExecContext(context.Background(), "DELETE FROM transactions WHERE x402_transaction_hash = $1", txHash)
})
ret, err := q.SaveTransaction(ctx, SaveTransactionParams{
X402TransactionHash: txHash,
Amount: amount,
Asset: asset,
Network: network,
Recipient: recipient,
Sender: sender,
})
if err != nil {
t.Fatalf("SaveTransaction error: %v", err)
}
if len(ret) != 1 {
t.Fatalf("expected 1 result row returned, got %d", len(ret))
}
saved := ret[0]
if saved.ID <= 0 {
t.Errorf("expected positive ID, got %d", saved.ID)
}
if !saved.CreatedAt.Valid {
t.Error("expected CreatedAt to be set")
}
// Since the sqlc query only returns `id` and `created_at`,
// verify directly that PostgreSQL stored the remaining columns accurately:
var (
gotHash, gotAmount, gotAsset, gotNetwork, gotRecipient, gotSender string
)
err = conn.QueryRowContext(ctx, `
SELECT x402_transaction_hash, amount, asset, network, recipient, sender
FROM transactions
WHERE id = $1
`, saved.ID).Scan(&gotHash, &gotAmount, &gotAsset, &gotNetwork, &gotRecipient, &gotSender)
if err != nil {
t.Fatalf("failed to query inserted transaction from db: %v", err)
}
if gotHash != txHash || gotAmount != amount || gotAsset != asset ||
gotNetwork != network || gotRecipient != recipient || gotSender != sender {
t.Errorf("stored values do not match input params: got (%s, %s, %s, %s, %s, %s)",
gotHash, gotAmount, gotAsset, gotNetwork, gotRecipient, gotSender)
}
}

View file

@ -1,106 +0,0 @@
package store
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// Migrate applies every *.sql file in dir (in filename order). Each file is expected to
// contain a "-- +goose up" section (between that marker and "-- +goose down", or EOF) which
// is executed inside a single transaction. Applied versions are recorded in
// schema_migrations so re-running is a no-op.
func Migrate(ctx context.Context, db *sql.DB, dir string) error {
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`); err != nil {
return fmt.Errorf("create schema_migrations table: %w", err)
}
entries, err := os.ReadDir(dir)
if err != nil {
return fmt.Errorf("read migrations dir %q: %w", dir, err)
}
type migration struct{ name, up string }
migrations := make([]migration, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
continue
}
raw, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
return fmt.Errorf("read migration %q: %w", e.Name(), err)
}
migrations = append(migrations, migration{name: e.Name(), up: extractUpSQL(string(raw))})
}
sort.Slice(migrations, func(i, j int) bool { return migrations[i].name < migrations[j].name })
for _, m := range migrations {
if strings.TrimSpace(m.up) == "" {
continue
}
applied, err := versionApplied(ctx, db, m.name)
if err != nil {
return err
}
if applied {
continue
}
if err := applyMigration(ctx, db, m.name, m.up); err != nil {
return fmt.Errorf("apply migration %q: %w", m.name, err)
}
}
return nil
}
// extractUpSQL returns the SQL between the "-- +goose up" marker and the next
// "-- +goose down" marker (or end of file).
func extractUpSQL(content string) string {
var b strings.Builder
inUp := false
for _, line := range strings.Split(content, "\n") {
switch strings.TrimSpace(line) {
case "-- +goose up":
inUp = true
continue
case "-- +goose down":
inUp = false
continue
}
if inUp {
b.WriteString(line)
b.WriteString("\n")
}
}
return b.String()
}
func versionApplied(ctx context.Context, db *sql.DB, version string) (bool, error) {
var count int
if err := db.QueryRowContext(ctx, `SELECT count(*) FROM schema_migrations WHERE version = $1`, version).Scan(&count); err != nil {
return false, fmt.Errorf("check migration %q: %w", version, err)
}
return count > 0, nil
}
func applyMigration(ctx context.Context, db *sql.DB, version, up string) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, up); err != nil {
_ = tx.Rollback()
return err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations (version) VALUES ($1)`, version); err != nil {
_ = tx.Rollback()
return err
}
return tx.Commit()
}

View file

@ -6,43 +6,8 @@ package store
import (
"database/sql"
"encoding/json"
"time"
)
type AnalyticsCache struct {
ID int64
UserID string
Platform string
ToolName string
TimeRangeHash string
Result json.RawMessage
ComputedAt time.Time
ExpiresAt time.Time
}
type AnalyticsCoverageRange struct {
UserID string
Platform string
CoveredStart time.Time
CoveredEnd time.Time
UpdatedAt time.Time
}
type AnalyticsPost struct {
ID int64
UserID string
Platform string
PostID string
PostText string
PostTimestamp time.Time
Likes int64
Reposts int64
Replies int64
Quotes int64
CreatedAt time.Time
}
type Transaction struct {
ID int64
X402TransactionHash string
@ -54,15 +19,6 @@ type Transaction struct {
CreatedAt sql.NullTime
}
type UserStylePost struct {
ID int64
UserID string
Platform string
PostID string
PostText string
CreatedAt time.Time
}
type UserStyleProfile struct {
ID int64
UserID string
@ -73,7 +29,4 @@ type UserStyleProfile struct {
PostCount int32
ModifiedAt sql.NullTime
CreatedAt sql.NullTime
ModelVersion int32
SchemaVersion int32
AlgorithmVersion int32
}

View file

@ -1,369 +0,0 @@
package twitter
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
log "github.com/nacorid/logger"
)
func parseTwitterTime(s string) time.Time {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
log.Warn("Failed to parse Twitter timestamp", "timestamp", s, "error", err)
return time.Time{}
}
return t
}
const apiBase = "https://api.x.com/2"
// httpTimeout bounds a single HTTP request so a stalled X backend cannot block
// a request indefinitely.
const httpTimeout = 60 * time.Second
const userAgent = "naco-api/1.0"
type Client struct {
httpClient *http.Client
bearerToken string
}
func NewTwitterClient(bearerToken string) *Client {
return &Client{
httpClient: &http.Client{Timeout: httpTimeout},
bearerToken: bearerToken,
}
}
type Post struct {
ID string
Author string
Content string
CreatedAt time.Time
Likes int
Reposts int
Quotes int
Replies int
}
type userResponse struct {
Data struct {
ID string `json:"id"`
Name string `json:"name"`
Username string `json:"username"`
} `json:"data"`
Errors []struct {
Title string `json:"title"`
Detail string `json:"detail"`
} `json:"errors"`
}
type tweetMetrics struct {
RetweetCount int `json:"retweet_count"`
ReplyCount int `json:"reply_count"`
LikeCount int `json:"like_count"`
QuoteCount int `json:"quote_count"`
}
type tweetData struct {
ID string `json:"id"`
Text string `json:"text"`
CreatedAt string `json:"created_at"`
PublicMetrics tweetMetrics `json:"public_metrics"`
}
type tweetsResponse struct {
Data []tweetData `json:"data"`
Includes struct {
Users []struct {
Username string `json:"username"`
} `json:"users"`
} `json:"includes"`
Errors []struct {
Title string `json:"title"`
Detail string `json:"detail"`
} `json:"errors"`
}
type singleTweetResponse struct {
Data struct {
ID string `json:"id"`
Text string `json:"text"`
CreatedAt string `json:"created_at"`
PublicMetrics tweetMetrics `json:"public_metrics"`
} `json:"data"`
Includes struct {
Users []struct {
Username string `json:"username"`
} `json:"users"`
} `json:"includes"`
Errors []struct {
Title string `json:"title"`
Detail string `json:"detail"`
} `json:"errors"`
}
const (
maxRetries = 4
maxRateLimitWait = 30 * time.Second // Never stall a request longer than 30s
)
var defaultBackoff = 500 * time.Millisecond
func (c *Client) doGet(ctx context.Context, endpoint string) ([]byte, error) {
backoff := defaultBackoff
for attempt := 0; ; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.bearerToken)
req.Header.Set("User-Agent", userAgent)
resp, err := c.httpClient.Do(req)
if err != nil {
// Transport-level failure (connection refused/reset, timeout).
if attempt >= maxRetries {
return nil, fmt.Errorf("twitter request failed after %d attempts: %w", maxRetries+1, err)
}
log.WarnWithContext(ctx, "twitter transport error, waiting to retry", "attempt", attempt+1, "error", err)
if werr := waitForRetry(ctx, backoff); werr != nil {
return nil, werr
}
backoff *= 2
continue
}
// Handle 429 Too Many Requests
if resp.StatusCode == http.StatusTooManyRequests {
// Drain & close body before retrying.
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
if attempt >= maxRetries {
return nil, fmt.Errorf("twitter rate limit exceeded, max retries reached")
}
wait := parseRateLimitWait(resp.Header, backoff)
// Protect against 15-minute thread freezes
if wait > maxRateLimitWait {
return nil, fmt.Errorf("twitter rate limited: reset wait (%s) exceeds maximum allowed (%s)", wait, maxRateLimitWait)
}
log.WarnWithContext(ctx, "twitter rate limited, waiting to retry", "attempt", attempt+1, "wait", wait.String())
if werr := waitForRetry(ctx, wait); werr != nil {
return nil, werr
}
backoff *= 2
continue
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
_ = resp.Body.Close()
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
// Retry transient server-side failures (5xx) with backoff. 4xx/3xx fail
// fast below.
if resp.StatusCode >= http.StatusInternalServerError && attempt < maxRetries {
log.WarnWithContext(ctx, "twitter server error, waiting to retry", "attempt", attempt+1, "status", resp.Status)
if werr := waitForRetry(ctx, backoff); werr != nil {
return nil, werr
}
backoff *= 2
continue
}
if resp.StatusCode != http.StatusOK {
errMsg := strings.TrimSpace(string(body))
if len(errMsg) > 512 {
errMsg = errMsg[:512] + "... (truncated)"
}
return nil, fmt.Errorf("twitter error (%s): %s", resp.Status, errMsg)
}
return body, nil
}
}
// waitForRetry sleeps for wait, aborting early if the context is canceled.
// A timer is used (not time.After) so the timer can be stopped to free its
// resource when the caller abandons the wait.
func waitForRetry(ctx context.Context, wait time.Duration) error {
timer := time.NewTimer(wait)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// parseRateLimitWait extracts x-rate-limit-* or Retry-After, falling back to backoff.
func parseRateLimitWait(header http.Header, fallback time.Duration) time.Duration {
// 1. Check x-rate-limit-reset (Unix timestamp)
if remStr := header.Get("x-rate-limit-remaining"); remStr != "" {
if rem, err := strconv.Atoi(remStr); err == nil && rem <= 0 {
if rstStr := header.Get("x-rate-limit-reset"); rstStr != "" {
if rst, err := strconv.ParseInt(rstStr, 10, 64); err == nil {
// Add 1s clock skew buffer
wait := time.Until(time.Unix(rst, 0)) + (1 * time.Second)
if wait > 0 {
return wait
}
}
}
}
}
// 2. Check standard Retry-After (seconds)
if ra := header.Get("Retry-After"); ra != "" {
if secs, err := strconv.Atoi(ra); err == nil && secs > 0 {
return time.Duration(secs)*time.Second + (1 * time.Second)
}
}
return fallback
}
func (c *Client) resolveUser(ctx context.Context, username string) (id, handle string, err error) {
endpoint := fmt.Sprintf("%s/users/by/username/%s", apiBase, url.PathEscape(username))
body, err := c.doGet(ctx, endpoint)
if err != nil {
return "", "", err
}
var ur userResponse
if err := json.Unmarshal(body, &ur); err != nil {
return "", "", fmt.Errorf("failed to unmarshal user response: %w", err)
}
if len(ur.Errors) > 0 {
return "", "", fmt.Errorf("twitter error: %s: %s", ur.Errors[0].Title, ur.Errors[0].Detail)
}
if ur.Data.ID == "" {
return "", "", fmt.Errorf("twitter user %q not found", username)
}
return ur.Data.ID, ur.Data.Username, nil
}
func (c *Client) GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*Post, error) {
if c.bearerToken == "" {
return nil, fmt.Errorf("twitter bearer token not configured")
}
username := strings.TrimPrefix(actor, "@")
userID, handle, err := c.resolveUser(ctx, username)
if err != nil {
return nil, err
}
if limit > 100 {
limit = 100
}
endpoint := fmt.Sprintf("%s/users/%s/tweets?max_results=%d&tweet.fields=created_at,public_metrics&exclude=retweets,replies",
apiBase, url.PathEscape(userID), limit)
return c.fetchTweets(ctx, handle, endpoint)
}
func (c *Client) GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]*Post, error) {
if c.bearerToken == "" {
return nil, fmt.Errorf("twitter bearer token not configured")
}
username := strings.TrimPrefix(actor, "@")
userID, handle, err := c.resolveUser(ctx, username)
if err != nil {
return nil, err
}
if limit > 100 {
limit = 100
}
endpoint := fmt.Sprintf("%s/users/%s/tweets?start_time=%s&end_time=%s&max_results=%d&tweet.fields=created_at,public_metrics&exclude=retweets,replies",
apiBase, url.PathEscape(userID), start.Format(time.RFC3339), end.Format(time.RFC3339), limit)
return c.fetchTweets(ctx, handle, endpoint)
}
// fetchTweets performs the request and converts the tweet payload into Posts,
// skipping entries without an ID so downstream dedupe keys are never empty.
func (c *Client) fetchTweets(ctx context.Context, username, endpoint string) ([]*Post, error) {
body, err := c.doGet(ctx, endpoint)
if err != nil {
return nil, err
}
var tr tweetsResponse
if err := json.Unmarshal(body, &tr); err != nil {
return nil, fmt.Errorf("failed to unmarshal tweets response: %w", err)
}
if len(tr.Errors) > 0 {
return nil, fmt.Errorf("twitter error: %s: %s", tr.Errors[0].Title, tr.Errors[0].Detail)
}
posts := make([]*Post, 0, len(tr.Data))
for _, t := range tr.Data {
if t.ID == "" {
continue
}
posts = append(posts, &Post{
ID: t.ID,
Author: username,
Content: t.Text,
CreatedAt: parseTwitterTime(t.CreatedAt),
Likes: t.PublicMetrics.LikeCount,
Reposts: t.PublicMetrics.RetweetCount,
Quotes: t.PublicMetrics.QuoteCount,
Replies: t.PublicMetrics.ReplyCount,
})
}
return posts, nil
}
func (c *Client) GetPost(ctx context.Context, id string) (*Post, error) {
if c.bearerToken == "" {
return nil, fmt.Errorf("twitter bearer token not configured")
}
endpoint := fmt.Sprintf("%s/tweets/%s?tweet.fields=created_at,public_metrics&expansions=author_id&user.fields=username",
apiBase, url.PathEscape(id))
body, err := c.doGet(ctx, endpoint)
if err != nil {
return nil, err
}
var tr singleTweetResponse
if err := json.Unmarshal(body, &tr); err != nil {
return nil, fmt.Errorf("failed to unmarshal tweet response: %w", err)
}
if len(tr.Errors) > 0 {
return nil, fmt.Errorf("twitter error: %s: %s", tr.Errors[0].Title, tr.Errors[0].Detail)
}
if tr.Data.ID == "" {
return nil, fmt.Errorf("tweet %q not found", id)
}
author := "unknown"
if len(tr.Includes.Users) > 0 {
author = tr.Includes.Users[0].Username
}
return &Post{
ID: tr.Data.ID,
Author: author,
Content: tr.Data.Text,
CreatedAt: parseTwitterTime(tr.Data.CreatedAt),
Likes: tr.Data.PublicMetrics.LikeCount,
Reposts: tr.Data.PublicMetrics.RetweetCount,
Quotes: tr.Data.PublicMetrics.QuoteCount,
Replies: tr.Data.PublicMetrics.ReplyCount,
}, nil
}

View file

@ -1,445 +0,0 @@
package twitter
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
// Speed up test suite: override defaultBackoff to 1ms so retries are instantaneous.
func init() {
defaultBackoff = 1 * time.Millisecond
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
// newTestClient returns a Client whose requests are rewritten to the given httptest server.
func newTestClient(serverURL string) *Client {
c := NewTwitterClient("test-token")
host := strings.TrimPrefix(serverURL, "http://")
c.httpClient = &http.Client{
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
u := *r.URL
u.Scheme = "http"
u.Host = host
r2 := r.Clone(r.Context())
r2.URL = &u
return http.DefaultTransport.RoundTrip(r2)
}),
}
return c
}
func TestGetLatestNPostsNoToken(t *testing.T) {
c := NewTwitterClient("")
if _, err := c.GetLatestNPosts(context.Background(), "alice", 10); err == nil {
t.Fatal("expected error when bearer token is empty")
}
}
func TestGetLatestNPosts(t *testing.T) {
var tweetsURL string
mux := http.NewServeMux()
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer test-token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"data":{"id":"12345","name":"Test","username":"testuser"}}`)
})
mux.HandleFunc("/2/users/12345/tweets", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer test-token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
tweetsURL = r.URL.String()
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"data":[{"id":"1","text":"hello world","created_at":"2024-01-01T00:00:00Z"}]}`)
})
server := httptest.NewServer(mux)
defer server.Close()
c := newTestClient(server.URL)
posts, err := c.GetLatestNPosts(context.Background(), "@testuser", 10)
if err != nil {
t.Fatalf("GetLatestNPosts error: %v", err)
}
if len(posts) != 1 {
t.Fatalf("expected 1 post, got %d", len(posts))
}
if posts[0].Content != "hello world" || posts[0].Author != "testuser" {
t.Fatalf("unexpected post: %+v", posts[0])
}
if !strings.Contains(tweetsURL, "max_results=10") {
t.Fatalf("expected max_results=10, got %q", tweetsURL)
}
}
func TestGetLatestNPostsLimitNoLowerFloor(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"data":{"id":"1","username":"u"}}`)
})
mux.HandleFunc("/2/users/1/tweets", func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("max_results"); got != "3" {
t.Fatalf("expected max_results=3 for limit=3, got %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"data":[]}`)
})
server := httptest.NewServer(mux)
defer server.Close()
c := newTestClient(server.URL)
if _, err := c.GetLatestNPosts(context.Background(), "u", 3); err != nil {
t.Fatalf("error: %v", err)
}
}
func TestGetLatestNPostsHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
c := newTestClient(server.URL)
if _, err := c.GetLatestNPosts(context.Background(), "u", 10); err == nil {
t.Fatal("expected error on non-200 status")
}
}
func TestGetPost(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"data":{"id":"12345","username":"testuser"}}`)
})
mux.HandleFunc("/2/tweets/999", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer test-token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"data":{"id":"999","text":"a single tweet","created_at":"2024-02-02T00:00:00Z","public_metrics":{"retweet_count":2,"reply_count":1,"like_count":5,"quote_count":0}},"includes":{"users":[{"username":"testuser"}]}}`)
})
server := httptest.NewServer(mux)
defer server.Close()
c := newTestClient(server.URL)
p, err := c.GetPost(context.Background(), "999")
if err != nil {
t.Fatalf("GetPost error: %v", err)
}
if p.ID != "999" || p.Content != "a single tweet" || p.Author != "testuser" {
t.Fatalf("unexpected post: %+v", p)
}
if p.Likes != 5 || p.Reposts != 2 || p.Replies != 1 || p.Quotes != 0 {
t.Fatalf("unexpected metrics: %+v", p)
}
}
func TestGetPostsInTimeframe(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"data":{"id":"12345","username":"testuser"}}`)
})
mux.HandleFunc("/2/users/12345/tweets", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer test-token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
if r.URL.Query().Get("start_time") == "" || r.URL.Query().Get("end_time") == "" {
t.Fatalf("expected start_time and end_time query params")
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"data":[{"id":"1","text":"timed post","created_at":"2024-03-03T00:00:00Z","public_metrics":{"retweet_count":0,"reply_count":0,"like_count":3,"quote_count":0}}]}`)
})
server := httptest.NewServer(mux)
defer server.Close()
c := newTestClient(server.URL)
start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC)
posts, err := c.GetPostsInTimeframe(context.Background(), "testuser", start, end, 10)
if err != nil {
t.Fatalf("GetPostsInTimeframe error: %v", err)
}
if len(posts) != 1 {
t.Fatalf("expected 1 post, got %d", len(posts))
}
if posts[0].Content != "timed post" || posts[0].Likes != 3 {
t.Fatalf("unexpected post: %+v", posts[0])
}
}
func TestRateLimitRetryWithResetHeader(t *testing.T) {
callCount := 0
mux := http.NewServeMux()
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
callCount++
if callCount == 1 {
// First call: rate limited with reset header slightly in the past/present
w.Header().Set("x-rate-limit-remaining", "0")
w.Header().Set("x-rate-limit-reset", fmt.Sprintf("%d", time.Now().Unix()))
w.WriteHeader(http.StatusTooManyRequests)
return
}
// Second call: success
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"data":{"id":"12345","name":"Test","username":"testuser"}}`)
})
mux.HandleFunc("/2/users/12345/tweets", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"data":[{"id":"1","text":"hello","created_at":"2024-01-01T00:00:00Z"}]}`)
})
server := httptest.NewServer(mux)
defer server.Close()
c := newTestClient(server.URL)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
posts, err := c.GetLatestNPosts(ctx, "@testuser", 10)
if err != nil {
t.Fatalf("GetLatestNPosts error after retry: %v", err)
}
if callCount != 2 {
t.Fatalf("expected 2 calls (1 retry), got %d", callCount)
}
if len(posts) != 1 {
t.Fatalf("expected 1 post after retry, got %d", len(posts))
}
}
func TestRateLimitExhaustedAfterMaxRetries(t *testing.T) {
callCount := 0
mux := http.NewServeMux()
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
callCount++
w.Header().Set("x-rate-limit-remaining", "0")
w.WriteHeader(http.StatusTooManyRequests)
})
server := httptest.NewServer(mux)
defer server.Close()
c := newTestClient(server.URL)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_, err := c.GetLatestNPosts(ctx, "@testuser", 10)
if err == nil {
t.Fatal("expected error after max retries exhausted")
}
if callCount < 5 {
t.Fatalf("expected at least 5 attempts (1 initial + 4 retries), got %d", callCount)
}
if !strings.Contains(err.Error(), "rate limit") {
t.Fatalf("expected rate limit error, got %v", err)
}
}
func TestUserLookupNotFound(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"errors":[{"title":"Not Found","detail":"User not found"}]}`)
})
server := httptest.NewServer(mux)
defer server.Close()
c := newTestClient(server.URL)
_, err := c.GetLatestNPosts(context.Background(), "@nonexistent", 10)
if err == nil {
t.Fatal("expected error for user not found")
}
if !strings.Contains(err.Error(), "twitter error") || !strings.Contains(err.Error(), "Not Found") {
t.Fatalf("expected 'Not Found' error, got %v", err)
}
}
func TestUserLookupUnauthorized(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"errors":[{"title":"Unauthorized","detail":"Invalid token"}]}`)
})
server := httptest.NewServer(mux)
defer server.Close()
c := newTestClient(server.URL)
_, err := c.GetLatestNPosts(context.Background(), "@testuser", 10)
if err == nil {
t.Fatal("expected error for unauthorized")
}
if !strings.Contains(err.Error(), "twitter error") || !strings.Contains(err.Error(), "Unauthorized") {
t.Fatalf("expected 'Unauthorized' error, got %v", err)
}
}
func TestUserLookupRateLimited(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
})
server := httptest.NewServer(mux)
defer server.Close()
c := newTestClient(server.URL)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_, err := c.GetLatestNPosts(ctx, "@testuser", 10)
if err == nil {
t.Fatal("expected error for rate limited user lookup")
}
if !strings.Contains(err.Error(), "rate limit") {
t.Fatalf("expected rate limit error, got %v", err)
}
}
func TestContextCancellationDuringRetry(t *testing.T) {
reqReceived := make(chan struct{})
var once sync.Once
mux := http.NewServeMux()
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("x-rate-limit-remaining", "0")
w.Header().Set("x-rate-limit-reset", fmt.Sprintf("%d", time.Now().Add(10*time.Second).Unix()))
w.WriteHeader(http.StatusTooManyRequests)
once.Do(func() {
close(reqReceived) // Signal test that the 429 response was returned
})
})
server := httptest.NewServer(mux)
defer server.Close()
c := newTestClient(server.URL)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
_, err := c.GetLatestNPosts(ctx, "@testuser", 10)
done <- err
}()
// Deterministic wait: block until the mock server receives the request, then cancel immediately
select {
case <-reqReceived:
cancel()
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for mock request to be hit")
}
select {
case err := <-done:
if err == nil {
t.Fatal("expected error from context cancellation")
}
if !errors.Is(err, context.Canceled) && !strings.Contains(err.Error(), "context canceled") {
t.Fatalf("expected context.Canceled, got %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("test timed out waiting for goroutine to abort")
}
}
func TestResolveUserEmptyData(t *testing.T) {
// 200 with an empty data object (banned/suspended or privacy-restricted
// account) must error rather than build a bogus /users//tweets request.
var tweetsCalled bool
mux := http.NewServeMux()
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"data":{}}`)
})
mux.HandleFunc("/2/users//tweets", func(w http.ResponseWriter, r *http.Request) {
tweetsCalled = true
})
server := httptest.NewServer(mux)
defer server.Close()
c := newTestClient(server.URL)
_, err := c.GetLatestNPosts(context.Background(), "@ghost", 10)
if err == nil {
t.Fatal("expected error for empty user data, got nil")
}
if !strings.Contains(err.Error(), "not found") {
t.Fatalf("expected 'not found' error, got %v", err)
}
if tweetsCalled {
t.Fatal("tweets endpoint should not be called when user lookup returns empty data")
}
}
func TestGetLatestNPostsSkipsEmptyID(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"data":{"id":"1","username":"u"}}`)
})
mux.HandleFunc("/2/users/1/tweets", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"data":[{"id":"","text":"bad"},{"id":"2","text":"good","created_at":"2024-01-01T00:00:00Z"}]}`)
})
server := httptest.NewServer(mux)
defer server.Close()
c := newTestClient(server.URL)
posts, err := c.GetLatestNPosts(context.Background(), "u", 10)
if err != nil {
t.Fatalf("error: %v", err)
}
if len(posts) != 1 {
t.Fatalf("expected 1 post (empty-ID tweet skipped), got %d", len(posts))
}
if posts[0].ID != "2" || posts[0].Content != "good" {
t.Fatalf("unexpected post: %+v", posts[0])
}
}
func TestServerErrorRetried(t *testing.T) {
callCount := 0
mux := http.NewServeMux()
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
callCount++
if callCount == 1 {
w.WriteHeader(http.StatusInternalServerError)
_, _ = io.WriteString(w, `{"title":"Internal Server Error","detail":"transient"}`)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"data":{"id":"1","username":"u"}}`)
})
mux.HandleFunc("/2/users/1/tweets", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"data":[{"id":"1","text":"ok","created_at":"2024-01-01T00:00:00Z"}]}`)
})
server := httptest.NewServer(mux)
defer server.Close()
c := newTestClient(server.URL)
posts, err := c.GetLatestNPosts(context.Background(), "u", 10)
if err != nil {
t.Fatalf("GetLatestNPosts error after 5xx retry: %v", err)
}
if callCount != 2 {
t.Fatalf("expected 2 calls (1 failure + 1 retry), got %d", callCount)
}
if len(posts) != 1 || posts[0].Content != "ok" {
t.Fatalf("unexpected posts after retry: %+v", posts)
}
}

View file

@ -7,26 +7,12 @@ import (
"strings"
)
func parseVectorString(v string) ([]float64, error) {
s := strings.Trim(v, "[]")
parts := strings.Split(s, ",")
vec := make([]float64, len(parts))
for i, p := range parts {
f, err := strconv.ParseFloat(strings.TrimSpace(p), 64)
if err != nil {
return nil, err
}
vec[i] = f
}
return vec, nil
}
func ParseEmbedding(value any) ([]float64, error) {
func ParseEmbedding(value interface{}) ([]float64, error) {
switch v := value.(type) {
case []float64:
return v, nil
case []any:
case []interface{}:
vec := make([]float64, len(v))
for i, x := range v {
f, ok := x.(float64)
@ -38,52 +24,35 @@ func ParseEmbedding(value any) ([]float64, error) {
return vec, nil
case string:
return parseVectorString(v)
case []byte:
return parseVectorString(string(v))
// pgvector returns a string like "[0.12, 0.53, ...]" sometimes
s := strings.Trim(v, "[]")
parts := strings.Split(s, ",")
vec := make([]float64, len(parts))
for i, p := range parts {
f, err := strconv.ParseFloat(strings.TrimSpace(p), 64)
if err != nil {
return nil, err
}
vec[i] = f
}
return vec, nil
default:
return nil, fmt.Errorf("unexpected embedding type: %T", v)
}
}
func FormatVector(v []float64) string {
parts := make([]string, len(v))
for i, x := range v {
parts[i] = strconv.FormatFloat(x, 'f', -1, 64)
}
return "[" + strings.Join(parts, ",") + "]"
}
// AverageVector returns the element-wise mean of vs. Zero-length vectors (e.g. an
// embedding model returning an empty vector for a post without text) are skipped, and
// the accumulator is sized from the first non-empty vector so a leading empty vector
// cannot panic later in the loop. Returns nil when no usable vector is present.
func AverageVector(vs [][]float64) []float64 {
dim := 0
var valid [][]float64
for _, v := range vs {
if len(v) == 0 {
continue
}
if dim == 0 {
dim = len(v)
}
valid = append(valid, v)
}
if len(valid) == 0 {
return nil
}
dim := len(vs[0])
avg := make([]float64, dim)
for _, v := range valid {
for i := 0; i < dim && i < len(v); i++ {
avg[i] += v[i]
for _, v := range vs {
for i, val := range v {
avg[i] += val
}
}
for i := range avg {
avg[i] /= float64(len(valid))
avg[i] /= float64(len(vs))
}
return avg
}
@ -91,7 +60,7 @@ func AverageVector(vs [][]float64) []float64 {
func TopWords(posts []string, stopWords map[string]struct{}, limit int) []string {
counts := map[string]int{}
for _, p := range posts {
for w := range strings.FieldsSeq(strings.ToLower(p)) {
for _, w := range strings.Fields(strings.ToLower(p)) {
if len(w) < 4 {
continue
}

View file

@ -1,47 +0,0 @@
package utils
import (
"math"
"testing"
)
func assertVectorsEqual(t *testing.T, got, want []float64) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("length %d, want %d", len(got), len(want))
}
for i := range want {
if math.Abs(got[i]-want[i]) > 1e-9 {
t.Fatalf("index %d = %v, want %v", i, got, want)
}
}
}
func TestAverageVector(t *testing.T) {
tests := []struct {
name string
in [][]float64
want []float64
}{
{name: "empty input", in: nil, want: nil},
{name: "empty slice", in: [][]float64{}, want: nil},
{name: "all empty vectors", in: [][]float64{{}, {}, {}}, want: nil},
{name: "single vector", in: [][]float64{{0.4, -0.2}}, want: []float64{0.4, -0.2}},
{name: "multiple vectors", in: [][]float64{{0.1, 0.2}, {0.3, 0.4}}, want: []float64{0.2, 0.3}},
{name: "first vector empty", in: [][]float64{{}, {0.1, 0.2}, {0.3, 0.4}}, want: []float64{0.2, 0.3}},
{name: "trailing empty vectors", in: [][]float64{{0.1, 0.2}, {}, {0.3, 0.4}}, want: []float64{0.2, 0.3}},
{name: "only first valid", in: [][]float64{{}, {}, {0.1, 0.2}}, want: []float64{0.1, 0.2}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := AverageVector(tt.in)
if tt.want == nil {
if got != nil {
t.Fatalf("AverageVector(%v) = %v, want nil", tt.in, got)
}
return
}
assertVectorsEqual(t, got, tt.want)
})
}
}

View file

@ -1,156 +0,0 @@
package x402
import (
"context"
"encoding/json"
"fmt"
"github.com/modelcontextprotocol/go-sdk/mcp"
log "github.com/nacorid/logger"
x402 "github.com/x402-foundation/x402/go/v2"
"github.com/x402-foundation/x402/go/v2/types"
)
// Payment meta keys exchanged over MCP, mirroring the x402 SDK contract.
const (
PaymentMetaKey = "x402/payment"
PaymentResponseMetaKey = "x402/payment-response"
)
// SettleAmountResolver computes the actual settlement amount for a completed
// tool call. It returns ("", false) to settle the amount advertised in the
// payment requirements as-is.
type SettleAmountResolver func(result *mcp.CallToolResult) (string, bool)
// PaymentWrapperConfig configures a PaymentWrapper. Unlike the SDK's wrapper,
// it accepts a SettleAmountResolver so upto-scheme tools can charge the actual
// computed cost after the handler runs (the same behavior the REST path gets
// via nethttp.SetSettlementOverrides).
type PaymentWrapperConfig struct {
Accepts []x402.PaymentRequirements
Resource *types.ResourceInfo
Extensions map[string]interface{}
// SettleAmountResolver is invoked after a successful handler call. When it
// returns a non-empty amount, that amount is charged instead of the
// advertised maximum (which must therefore be <= the accept amount).
SettleAmountResolver SettleAmountResolver
}
// PaymentWrapper gates an MCP tool behind x402 payments and settles them.
type PaymentWrapper struct {
server *x402.X402ResourceServer
config PaymentWrapperConfig
}
// NewPaymentWrapper creates a PaymentWrapper for the given resource server.
func NewPaymentWrapper(server *x402.X402ResourceServer, config PaymentWrapperConfig) *PaymentWrapper {
if len(config.Accepts) == 0 {
panic("PaymentWrapperConfig.Accepts must have at least one payment requirement")
}
return &PaymentWrapper{server: server, config: config}
}
// Wrap returns a ToolHandler that requires a valid x402 payment before invoking
// the wrapped handler and settles it afterwards.
func (w *PaymentWrapper) Wrap(handler mcp.ToolHandler) mcp.ToolHandler {
return func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
paymentData := extractPaymentFromRequest(request)
if paymentData == nil {
return w.paymentRequiredResult("Payment Required"), nil
}
payloadBytes, err := json.Marshal(paymentData)
if err != nil {
return w.paymentRequiredResult(fmt.Sprintf("Invalid payment payload: %v", err)), nil
}
var payload types.PaymentPayload
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
return w.paymentRequiredResult(fmt.Sprintf("Invalid payment payload: %v", err)), nil
}
matched := w.server.FindMatchingRequirements(w.config.Accepts, payload)
if matched == nil {
return w.paymentRequiredResult("No matching payment requirements found"), nil
}
requirements := *matched
verifyResp, err := w.server.VerifyPayment(ctx, payload, requirements)
if err != nil {
return w.paymentRequiredResult(fmt.Sprintf("Payment verification error: %v", err)), nil
}
if !verifyResp.IsValid {
return w.paymentRequiredResult(fmt.Sprintf("Payment verification failed: %s", verifyResp.InvalidReason)), nil
}
result, err := handler(ctx, request)
if err != nil {
return nil, err
}
if result.IsError {
return result, nil
}
var overrides *x402.SettlementOverrides
if w.config.SettleAmountResolver != nil {
if amount, ok := w.config.SettleAmountResolver(result); ok && amount != "" {
overrides = &x402.SettlementOverrides{Amount: amount}
log.Debug("MCP settlement override", "amount", amount)
}
}
settleResp, err := w.server.SettlePayment(ctx, payload, requirements, overrides)
if err != nil {
return w.settlementFailedResult(fmt.Sprintf("Settlement error: %v", err)), nil
}
if !settleResp.Success {
return w.settlementFailedResult(fmt.Sprintf("Settlement failed: %s", settleResp.ErrorReason)), nil
}
if result.Meta == nil {
result.Meta = mcp.Meta{}
}
result.Meta[PaymentResponseMetaKey] = settleResp
return result, nil
}
}
func extractPaymentFromRequest(request *mcp.CallToolRequest) interface{} {
if request == nil || request.Params.Meta == nil {
return nil
}
return request.Params.Meta[PaymentMetaKey]
}
func (w *PaymentWrapper) paymentRequiredResult(errorMessage string) *mcp.CallToolResult {
var resource *types.ResourceInfo
if w.config.Resource != nil {
resource = &types.ResourceInfo{
URL: w.config.Resource.URL,
Description: w.config.Resource.Description,
}
}
payment := types.PaymentRequired{
X402Version: 2,
Error: errorMessage,
Resource: resource,
Accepts: w.config.Accepts,
Extensions: w.config.Extensions,
}
b, err := json.Marshal(payment)
if err != nil {
panic(err)
}
return &mcp.CallToolResult{
IsError: true,
Content: []mcp.Content{&mcp.TextContent{Text: string(b)}},
}
}
func (w *PaymentWrapper) settlementFailedResult(errorMessage string) *mcp.CallToolResult {
return &mcp.CallToolResult{
IsError: true,
Content: []mcp.Content{
&mcp.TextContent{Text: fmt.Sprintf("{\"x402Version\":2,\"error\":%q}", errorMessage)},
},
}
}

View file

@ -1,23 +1,15 @@
package x402
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log/slog"
"math"
"net"
"net/http"
"os"
"os/user"
"reflect"
"slices"
"strconv"
"strings"
"sync"
"time"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
@ -25,9 +17,8 @@ import (
x402 "github.com/x402-foundation/x402/go/v2"
x402http "github.com/x402-foundation/x402/go/v2/http"
nethttp "github.com/x402-foundation/x402/go/v2/http/nethttp"
x402mcp "github.com/x402-foundation/x402/go/v2/mcp"
evm "github.com/x402-foundation/x402/go/v2/mechanisms/evm/exact/server"
uptoevm "github.com/x402-foundation/x402/go/v2/mechanisms/evm/upto/server"
"github.com/x402-foundation/x402/go/v2/types"
)
type X402Middlewares struct {
@ -54,23 +45,11 @@ type X402Server struct {
middlewareChain func(http.Handler) http.Handler
tools []ToolInfo
globalReqLog func(http.Handler) http.Handler
networks []string
resourceInitMu sync.Mutex
resourceInitDone bool
// settleResolvers maps payable tool name -> SettleAmountResolver used by
// both the MCP wrapper and the REST override path to charge the actual
// computed cost (instead of the advertised upto maximum).
settleResolvers map[string]SettleAmountResolver
httpServer *http.Server
mu sync.Mutex
}
const VERSION1 = "v1/"
func NewX402Server(name, version string, facilitatorURL string, auth x402http.AuthProvider, mws X402Middlewares, globalLoggingMw func(http.Handler) http.Handler, settleHook func(http.ResponseWriter, *http.Request, *x402.SettleResponse), networks []string) (*X402Server, error) {
func NewX402Server(name, version string, facilitatorURL string, auth x402http.AuthProvider, mws X402Middlewares, globalLoggingMw func(http.Handler) http.Handler, settleHook func(http.ResponseWriter, *http.Request, *x402.SettleResponse)) (*X402Server, error) {
mcpServer := mcp.NewServer(
&mcp.Implementation{
Name: name,
@ -83,29 +62,18 @@ func NewX402Server(name, version string, facilitatorURL string, auth x402http.Au
}
if facilitatorURL == "" {
facilitatorURL = "https://api.cdp.coinbase.com/platform/v2/x402"
facilitatorURL = "https://v2.facilitator.mogami.tech"
}
facilitator := x402http.NewHTTPFacilitatorClient(&x402http.FacilitatorConfig{
URL: facilitatorURL,
AuthProvider: auth,
Timeout: 600 * time.Second,
})
resourceServer := x402.Newx402ResourceServer(
x402.WithFacilitatorClient(facilitator),
)
// Register exact and upto EVM schemes for each network
registered := make(map[string]bool)
for _, network := range networks {
if registered[network] {
continue
}
net := x402.Network(network)
resourceServer.Register(net, evm.NewExactEvmScheme())
resourceServer.Register(net, uptoevm.NewUptoEvmScheme())
registered[network] = true
}
resourceServer.Register("eip155:8453", evm.NewExactEvmScheme())
s := &X402Server{
mcpServer: mcpServer,
@ -118,29 +86,11 @@ func NewX402Server(name, version string, facilitatorURL string, auth x402http.Au
middlewareChain: chainHTTPMiddlewares(mws.HTTPMiddleware...),
tools: make([]ToolInfo, 0),
globalReqLog: globalLoggingMw,
networks: networks,
settleResolvers: make(map[string]SettleAmountResolver),
}
// Warm the facilitator cache now so AddPayableTool can build Accepts with
// facilitator extras (e.g. upto facilitatorAddress). Failure is non-fatal;
// ensureFacilitatorReady retries on every request until it succeeds once.
s.ensureFacilitatorReady(context.Background())
// Self-healing: retry facilitator initialization on every inbound request
// until it succeeds, so a facilitator that was down at boot requires no
// process restart. Runs before tool dispatch, i.e. before the PaymentWrapper
// performs VerifyPayment/SettlePayment.
mcpServer.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler {
return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
s.ensureFacilitatorReady(ctx)
return next(ctx, method, req)
}
})
s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
w.Write([]byte("ok"))
})
s.mux.HandleFunc("GET /openapi.json", s.serveOpenAPI)
@ -153,32 +103,6 @@ func NewX402Server(name, version string, facilitatorURL string, auth x402http.Au
return s, nil
}
// facilitatorRetryTimeout bounds each lazy facilitator init attempt so a down
// facilitator can't stall requests indefinitely.
const facilitatorRetryTimeout = 10 * time.Second
// ensureFacilitatorReady lazily initializes the resource server against the
// facilitator, retrying on every call until one succeeds. After the first
// success the facilitatorClients map persists for the process lifetime, so a
// facilitator outage at boot (or afterwards) no longer requires a restart.
func (s *X402Server) ensureFacilitatorReady(ctx context.Context) {
s.resourceInitMu.Lock()
defer s.resourceInitMu.Unlock()
if s.resourceInitDone {
return
}
initCtx, cancel := context.WithTimeout(ctx, facilitatorRetryTimeout)
defer cancel()
if err := s.x402ResourceServer.Initialize(initCtx); err != nil {
log.Warn("x402 facilitator unavailable; will retry on next request", "error", err)
return
}
s.resourceInitDone = true
}
func (s *X402Server) AddTool(tool mcp.Tool, handler mcp.ToolHandler) {
tool.InputSchema = toJSONSchema(tool.InputSchema)
tool.OutputSchema = toJSONSchema(tool.OutputSchema)
@ -200,45 +124,19 @@ func (s *X402Server) AddTool(tool mcp.Tool, handler mcp.ToolHandler) {
func (s *X402Server) AddPayableTool(
tool mcp.Tool,
handler mcp.ToolHandler,
resolver SettleAmountResolver,
requirements ...x402.PaymentRequirements,
) {
if len(requirements) == 0 {
panic(fmt.Sprintf("tool %s requires at least one payment requirement", tool.Name))
}
// Convert each raw dollar price into the asset's smallest-unit integer amount using the
// same server-side conversion the REST middleware applies at request time (ParsePrice +
// EnhancePaymentRequirements). The MCP wrapper advertises these requirements directly,
// so without conversion clients fail with invalid_exact_evm_client_amount.
var mcpAccepts []x402.PaymentRequirements
for _, req := range requirements {
built, err := s.x402ResourceServer.BuildPaymentRequirementsFromConfig(context.Background(), x402.ResourceConfig{
Scheme: req.Scheme,
PayTo: req.PayTo,
Price: req.Amount,
Network: x402.Network(req.Network),
MaxTimeoutSeconds: req.MaxTimeoutSeconds,
Extra: req.Extra,
})
if err != nil {
panic(fmt.Sprintf("tool %s failed to build MCP payment requirements for scheme %s: %v", tool.Name, req.Scheme, err))
}
mcpAccepts = append(mcpAccepts, built...)
log.Fatalf("tool %s requires at least one payment requirement", tool.Name)
}
tool.InputSchema = toJSONSchema(tool.InputSchema)
tool.OutputSchema = toJSONSchema(tool.OutputSchema)
if resolver != nil {
s.settleResolvers[tool.Name] = resolver
}
wrapper := NewPaymentWrapper(s.x402ResourceServer, PaymentWrapperConfig{
Accepts: mcpAccepts,
SettleAmountResolver: resolver,
Resource: &types.ResourceInfo{
wrapper := x402mcp.NewPaymentWrapper(s.x402ResourceServer, x402mcp.PaymentWrapperConfig{
Accepts: requirements,
Resource: &x402mcp.ResourceInfo{
URL: "mcp://tool/" + tool.Name,
Description: tool.Description,
},
@ -260,32 +158,10 @@ func (s *X402Server) AddPayableTool(
PayTo: req.PayTo,
Price: req.Amount,
Network: x402.Network(req.Network),
MaxTimeoutSeconds: req.MaxTimeoutSeconds,
Extra: req.Extra,
})
}
schemes := buildSchemeConfigs(s.networks, requirements...)
settlementHandler := func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {
requirements, _ := nethttp.RequirementsFromContext(r.Context())
log.InfoWithContext(r.Context(), "HTTP settlement succeeded",
"tool", tool.Name,
"network", string(resp.Network),
"transaction", resp.Transaction,
"payer", resp.Payer,
"payTo", requirements.PayTo,
"amount", requirements.Amount,
"asset", requirements.Asset,
"scheme", requirements.Scheme,
"override_amount", r.Header.Get("Settlement-Overrides"),
)
if s.httpSettlementHook != nil {
s.httpSettlementHook(w, r, resp)
}
}
basePaymentMiddleware := nethttp.X402Payment(nethttp.Config{
paymentMiddleware := nethttp.X402Payment(nethttp.Config{
Routes: x402http.RoutesConfig{
"POST /" + VERSION1 + tool.Name: {
Accepts: options,
@ -293,99 +169,20 @@ func (s *X402Server) AddPayableTool(
},
},
Facilitator: s.facilitatorClient,
Schemes: schemes,
SettlementHandler: settlementHandler,
Timeout: 600 * time.Second,
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
log.ErrorWithContext(r.Context(), "HTTP settlement failed",
"tool", tool.Name,
"method", r.Method,
"path", r.URL.Path,
"paymentSignature", r.Header.Get("PAYMENT-SIGNATURE"),
"paymentRequired", w.Header().Get("PAYMENT-REQUIRED"),
"settlement_overrides", r.Header.Get("Settlement-Overrides"),
"error", err,
)
http.Error(w, fmt.Sprintf("settlement failed: %v", err), http.StatusPaymentRequired)
Schemes: []nethttp.SchemeConfig{
{Network: "eip155:8453", Server: evm.NewExactEvmScheme()},
},
SettlementHandler: s.httpSettlementHook,
})
paymentMiddleware := func(next http.Handler) http.Handler {
return basePaymentMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capture := &verifyErrorCapture{
ResponseWriter: w,
req: r,
toolName: tool.Name,
}
next.ServeHTTP(capture, r)
if capture.statusCode == http.StatusPaymentRequired {
prHeader := capture.Header().Get("PAYMENT-REQUIRED")
if prHeader != "" {
if decoded, err := decodePaymentRequiredHeader(prHeader); err == nil {
for i, acc := range decoded.Accepts {
log.ErrorWithContext(r.Context(), "HTTP payment verify failed - new challenge issued",
"tool", tool.Name,
"acceptIndex", i,
"scheme", acc.Scheme,
"network", acc.Network,
"asset", acc.Asset,
"amount", acc.Amount,
"payTo", acc.PayTo,
"resource", decoded.Resource,
"acceptsCount", len(decoded.Accepts),
)
}
} else {
log.ErrorWithContext(r.Context(), "HTTP payment verify failed - could not decode PAYMENT-REQUIRED",
"tool", tool.Name,
"rawHeader", prHeader,
"decodeError", err,
)
}
}
// Debug: log the payment signature sent by client
log.DebugWithContext(r.Context(), "Payment retry debug",
"tool", tool.Name,
"paymentSignature", r.Header.Get("PAYMENT-SIGNATURE"),
"settlementOverrides", w.Header().Get("Settlement-Overrides"),
)
}
}))
}
restHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.handleRESTCall(w, r, tool.Name, handler)
})
s.mux.Handle("POST /"+VERSION1+tool.Name, s.middlewareChain(paymentMiddleware(restHandler)))
}
// buildSchemeConfigs maps the schemes actually declared in a tool's payment
// requirements to the EVM scheme servers a route needs to verify and settle
// them, for every configured network. Only the schemes present in the
// requirements are registered: an exact-only tool does not expose an upto
// scheme, and vice versa. This keeps upto (variable-pricing) authorization from
// being offered for tools that cannot vary their price.
func buildSchemeConfigs(networks []string, requirements ...x402.PaymentRequirements) []nethttp.SchemeConfig {
present := make(map[string]struct{})
for _, req := range requirements {
present[req.Scheme] = struct{}{}
}
schemes := make([]nethttp.SchemeConfig, 0, len(networks)*len(present))
for _, net := range networks {
if _, ok := present["exact"]; ok {
schemes = append(schemes, nethttp.SchemeConfig{Network: x402.Network(net), Server: evm.NewExactEvmScheme()})
}
if _, ok := present["upto"]; ok {
schemes = append(schemes, nethttp.SchemeConfig{Network: x402.Network(net), Server: uptoevm.NewUptoEvmScheme()})
}
}
return schemes
}
func (s *X402Server) handleRESTCall(w http.ResponseWriter, r *http.Request, toolName string, handler mcp.ToolHandler) {
defer r.Body.Close()
bodyBytes, err := io.ReadAll(io.LimitReader(r.Body, 5*1024*1024))
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
@ -407,31 +204,19 @@ func (s *X402Server) handleRESTCall(w http.ResponseWriter, r *http.Request, tool
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if len(resp.Content) == 1 {
if textContent, ok := resp.Content[0].(*mcp.TextContent); ok {
var js json.RawMessage
if json.Unmarshal([]byte(textContent.Text), &js) == nil {
if resolver := s.settleResolvers[toolName]; resolver != nil {
if actualCost, ok := resolver(&mcp.CallToolResult{Content: []mcp.Content{textContent}}); ok && actualCost != "" {
log.DebugWithContext(r.Context(), "Setting settlement override",
"tool", toolName,
"actualCost", actualCost,
)
nethttp.SetSettlementOverrides(w, &x402.SettlementOverrides{
Amount: actualCost,
})
}
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(textContent.Text))
w.Write([]byte(textContent.Text))
return
}
}
}
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(resp)
json.NewEncoder(w).Encode(resp)
}
func (s *X402Server) OnAfterSettle(hook func(ctx x402.SettleResultContext) error) {
@ -522,23 +307,6 @@ func (s *X402Server) Start(addr string, permissions os.FileMode) error {
return fmt.Errorf("failed to set socket permissions: %w", err)
}
grp, err := user.LookupGroup("mcpserver")
if err != nil {
return fmt.Errorf("failed to lookup group mcpserver: %w", err)
}
gid, err := strconv.Atoi(grp.Gid)
if err != nil {
return fmt.Errorf("invalid gid %q: %w", grp.Gid, err)
}
if err := os.Chown(socketPath, -1, gid); err != nil {
return fmt.Errorf("failed to set socket group ownership: %w", err)
}
if err := os.Chmod(socketPath, permissions); err != nil {
return fmt.Errorf("failed to set socket permissions: %w", err)
}
log.Info("Starting X402 MCP Server on unix socket", "socketPath", socketPath)
} else {
log.Info("Starting X402 MCP Server on TCP", "addr", addr)
@ -548,33 +316,15 @@ func (s *X402Server) Start(addr string, permissions os.FileMode) error {
return fmt.Errorf("failed to listen on tcp: %w", err)
}
}
defer listener.Close()
srv := &http.Server{
Handler: s.globalReqLog(s.mux),
ErrorLog: slog.NewLogLogger(slog.Default().Handler(), slog.LevelDebug),
ReadTimeout: 15 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
s.mu.Lock()
s.httpServer = srv
s.mu.Unlock()
return srv.Serve(listener)
}
func (s *X402Server) Shutdown(ctx context.Context) error {
s.mu.Lock()
srv := s.httpServer
s.mu.Unlock()
if srv == nil {
return nil
}
return srv.Shutdown(ctx)
}
func toJSONSchema(val any) json.RawMessage {
if val == nil {
return json.RawMessage(`{"type": "object"}`)
@ -635,203 +385,9 @@ func parseSchema(schemaAny any) any {
func chainHTTPMiddlewares(middlewares ...func(http.Handler) http.Handler) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
for _, middleware := range slices.Backward(middlewares) {
next = middleware(next)
for i := len(middlewares) - 1; i >= 0; i-- {
next = middlewares[i](next)
}
return next
}
}
// Twitter API costs are itemized below. Every getStyleProfile request, for any
// platform, also carries the flat base cost of the endpoint (the same price a
// non-Twitter platform pays). Adding that base cost on top of the itemized
// Twitter API costs guarantees the call always clears the underlying API cost
// and generates a profit - it can never break even or lose money.
const (
// BaseProfileCostMicro is the base cost of the style-profile endpoint for
// the non-Twitter path. It doubles as the profit margin added to Twitter
// requests, expressed in micro-dollars (USDC has 6 decimals).
BaseProfileCostMicro int64 = 250_000 // $0.25
// TwitterUserLookupMicro is the per-request Twitter user lookup API cost.
TwitterUserLookupMicro int64 = 10_000 // $0.01
// TwitterPostReadMicro is the per-post Twitter read API cost.
TwitterPostReadMicro int64 = 5_000 // $0.005 per post
// MaxProfileCountedPosts caps the post count a style profile can ever
// report. It is NOT applied inside computeTwitterStyleProfileCost (charges
// follow the posts actually read, and the server fetches at most this
// many); it only bounds the largest settlement the endpoint can demand.
MaxProfileCountedPosts int64 = 100
// MaxAnalyticsCountedPosts caps the number of posts an analytics tool can
// include from any single platform (mirrors maxAnalyticsLimit in the server
// package). It bounds the largest settlement an upto analytics request can
// demand.
MaxAnalyticsCountedPosts int64 = 200
)
// maxProfileCostMicro is the largest price getStyleProfile can ever command:
// the flat base cost plus the Twitter lookup and per-post costs at the
// post-count cap. The price advertised for getStyleProfile in tools.go must
// equal formatDollarAmount(maxProfileCostMicro) ("$0.76").
const maxProfileCostMicro = BaseProfileCostMicro + TwitterUserLookupMicro + MaxProfileCountedPosts*TwitterPostReadMicro
// computeTwitterStyleProfileCost calculates the actual charge for a Twitter
// getStyleProfile request based on the number of posts fetched and the user
// lookup, and returns it as a dollar price string (e.g. "$0.31") suitable for a
// x402 settlement override.
//
// Pricing: guaranteed base cost of $0.25 (the endpoint cost for other
// platforms), plus user lookup $0.01, plus $0.005 per post read. For example,
// 10 posts = $0.31. Non-Twitter platforms are charged the flat $0.25 base.
//
// The price always reflects the posts actually read - it is never clamped to a
// cap (the server only ever fetches at most maxProfileCountedPosts, so the
// derived max always stays at or below formatDollarAmount(maxProfileCostMicro)).
// A PostCount of 0 means no posts were read (server-side defaults are applied
// before the profile is built, so responses carry the real count).
func computeTwitterStyleProfileCost(_ *http.Request, responseText string) string {
// Parse the response to get PostCount and SourcePlatform
var resp GetStyleProfileResponse
if err := json.Unmarshal([]byte(responseText), &resp); err != nil {
return formatDollarAmount(BaseProfileCostMicro) // fallback: base cost
}
costMicro := BaseProfileCostMicro
// Twitter additionally passes on the user lookup and per-post API costs.
if resp.SourcePlatform == "Twitter" {
postCount := max(resp.PostCount, 0)
costMicro += TwitterUserLookupMicro
costMicro += int64(postCount) * TwitterPostReadMicro
}
return formatDollarAmount(costMicro)
}
// formatDollarAmount renders an amount in micro-dollars as a "$"-prefixed
// price string with at least two decimal places and trailing zeroes trimmed
// beyond that (e.g. "$0.16", "$1.50", "$0.125"), matching the format the x402
// settlement override resolver expects.
func formatDollarAmount(micro int64) string {
dollars := float64(micro) / 1_000_000
s := strconv.FormatFloat(dollars, 'f', 6, 64)
s = strings.TrimRight(s, "0")
if dot := strings.IndexByte(s, '.'); dot >= 0 {
if decimals := len(s) - dot - 1; decimals < 2 {
s += strings.Repeat("0", 2-decimals)
}
} else {
s += ".00"
}
return "$" + s
}
// FormatPriceMicro renders an amount in micro-dollars as a plain, un-prefixed
// numeric price string ("0.76") as expected by x402 PaymentRequirements.Amount.
func FormatPriceMicro(micro int64) string {
return strings.TrimPrefix(formatDollarAmount(micro), "$")
}
// MicroFromPriceString parses a plain dollar price string ("0.76") into
// micro-dollars so pricing arithmetic can be done without float rounding.
func MicroFromPriceString(price string) int64 {
micro, err := strconv.ParseFloat(price, 64)
if err != nil {
return 0
}
return int64(math.Round(micro * 1_000_000))
}
// MaxTwitterCostMicro is the largest cost a Twitter-capable tool can command
// when it reaches its post-count cap: the flat base cost plus the Twitter
// lookup and per-post costs at the cap. It is used to advertise the upto
// maximum for tools whose actual cost is settled from the response.
func MaxTwitterCostMicro(baseMicro, postCap int64) int64 {
return baseMicro + TwitterUserLookupMicro + postCap*TwitterPostReadMicro
}
// NewStyleProfileCostResolver returns a SettleAmountResolver that bills a
// getStyleProfile request by the posts actually read (see
// computeTwitterStyleProfileCost), rather than the advertised upto maximum.
func NewStyleProfileCostResolver() SettleAmountResolver {
return func(result *mcp.CallToolResult) (string, bool) {
if len(result.Content) != 1 {
return "", false
}
textContent, ok := result.Content[0].(*mcp.TextContent)
if !ok {
return "", false
}
return computeTwitterStyleProfileCost(nil, textContent.Text), true
}
}
// NewTwitterCountCostResolver returns a SettleAmountResolver for tools whose
// response carries a `twitterPostCount` JSON field. It settles the flat
// baseCostMicro, plus the Twitter lookup and per-post costs when Twitter posts
// were actually used (twitterPostCount > 0). When no Twitter posts went into
// the response the caller is charged only the base cost.
func NewTwitterCountCostResolver(baseCostMicro int64) SettleAmountResolver {
return func(result *mcp.CallToolResult) (string, bool) {
if len(result.Content) != 1 {
return "", false
}
textContent, ok := result.Content[0].(*mcp.TextContent)
if !ok {
return "", false
}
var payload struct {
TwitterPostCount int `json:"twitterPostCount"`
}
if json.Unmarshal([]byte(textContent.Text), &payload) != nil {
return "", false
}
costMicro := baseCostMicro
if payload.TwitterPostCount > 0 {
costMicro += TwitterUserLookupMicro + int64(payload.TwitterPostCount)*TwitterPostReadMicro
}
return formatDollarAmount(costMicro), true
}
}
// GetStyleProfileResponse is a local copy of the response schema for cost calculation
type GetStyleProfileResponse struct {
Author string `json:"author"`
PersonaEmbedding []float64 `json:"personaEmbedding"`
TopKeywords []string `json:"topKeywords"`
StyleSummary string `json:"styleSummary"`
PostCount int `json:"postCount"`
SourcePlatform string `json:"sourcePlatform"`
}
type verifyErrorCapture struct {
http.ResponseWriter
req *http.Request
toolName string
statusCode int
}
func (c *verifyErrorCapture) WriteHeader(code int) {
c.statusCode = code
c.ResponseWriter.WriteHeader(code)
}
func (c *verifyErrorCapture) Write(b []byte) (int, error) {
if c.statusCode == 0 {
c.statusCode = http.StatusOK
}
return c.ResponseWriter.Write(b)
}
func decodePaymentRequiredHeader(header string) (x402.PaymentRequired, error) {
data, err := base64.StdEncoding.DecodeString(header)
if err != nil {
return x402.PaymentRequired{}, fmt.Errorf("invalid base64 encoding: %w", err)
}
var required x402.PaymentRequired
if err := json.Unmarshal(data, &required); err != nil {
return x402.PaymentRequired{}, fmt.Errorf("invalid payment required JSON: %w", err)
}
return required, nil
}

View file

@ -3,19 +3,14 @@ package x402
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
x402 "github.com/x402-foundation/x402/go/v2"
x402http "github.com/x402-foundation/x402/go/v2/http"
"github.com/x402-foundation/x402/go/v2/types"
)
type DummyAuthProvider struct{}
@ -36,7 +31,6 @@ func TestX402Server_HTTPBoundaries(t *testing.T) {
X402Middlewares{},
func(next http.Handler) http.Handler { return next },
func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {},
[]string{"eip155:8453"},
)
if err != nil {
t.Fatalf("failed to create server: %v", err)
@ -52,7 +46,6 @@ func TestX402Server_HTTPBoundaries(t *testing.T) {
Content: []mcp.Content{&mcp.TextContent{Text: `{"status": "success"}`}},
}, nil
},
nil,
x402.PaymentRequirements{
Scheme: "exact",
Network: "eip155:8453",
@ -109,7 +102,6 @@ func TestX402Server_MCPBoundaries(t *testing.T) {
X402Middlewares{},
func(next http.Handler) http.Handler { return next },
func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {},
[]string{"eip155:8453"},
)
if err != nil {
t.Fatalf("failed to create server: %v", err)
@ -125,7 +117,6 @@ func TestX402Server_MCPBoundaries(t *testing.T) {
Content: []mcp.Content{&mcp.TextContent{Text: `{"status": "success"}`}},
}, nil
},
nil,
x402.PaymentRequirements{
Scheme: "exact",
Network: "eip155:8453",
@ -139,14 +130,14 @@ func TestX402Server_MCPBoundaries(t *testing.T) {
if err != nil {
t.Fatalf("failed to connect server session: %v", err)
}
defer func() { _ = serverSession.Close() }()
defer serverSession.Close()
client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "1.0.0"}, nil)
clientSession, err := client.Connect(ctx, clientTransport, nil)
if err != nil {
t.Fatalf("failed to connect client session: %v", err)
}
defer func() { _ = clientSession.Close() }()
defer clientSession.Close()
toolsResponse, err := clientSession.ListTools(ctx, nil)
if err != nil {
@ -182,313 +173,6 @@ func TestX402Server_MCPBoundaries(t *testing.T) {
}
}
func TestX402Server_MCPChallengesAdvertiseConvertedAmounts(t *testing.T) {
ctx := context.Background()
serverTransport, clientTransport := mcp.NewInMemoryTransports()
mockFacilitator := setupMockFacilitator()
defer mockFacilitator.Close()
srv, err := NewX402Server(
"Test Agent API",
"1.0.0",
mockFacilitator.URL,
&DummyAuthProvider{},
X402Middlewares{},
func(next http.Handler) http.Handler { return next },
func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {},
[]string{"eip155:8453", "eip155:84532"},
)
if err != nil {
t.Fatalf("failed to create server: %v", err)
}
srv.AddPayableTool(
mcp.Tool{Name: "getExact", Description: "exact gated tool"},
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: `{}`}}}, nil
},
nil,
x402.PaymentRequirements{
Scheme: "exact",
Network: "eip155:8453",
Amount: "0.01",
PayTo: "0x0000000000000000000000000000000000000000",
},
)
srv.AddPayableTool(
mcp.Tool{Name: "getUpto", Description: "upto gated tool"},
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: `{}`}}}, nil
},
nil,
x402.PaymentRequirements{
Scheme: "upto",
Network: "eip155:84532",
Amount: "0.51",
PayTo: "0x0000000000000000000000000000000000000000",
},
)
serverSession, err := srv.mcpServer.Connect(ctx, serverTransport, nil)
if err != nil {
t.Fatalf("failed to connect server session: %v", err)
}
defer func() { _ = serverSession.Close() }()
client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "1.0.0"}, nil)
clientSession, err := client.Connect(ctx, clientTransport, nil)
if err != nil {
t.Fatalf("failed to connect client session: %v", err)
}
defer func() { _ = clientSession.Close() }()
cases := []struct {
tool string
scheme string
want string
}{
{tool: "getExact", scheme: "exact", want: "10000"},
{tool: "getUpto", scheme: "upto", want: "510000"},
}
for _, tc := range cases {
t.Run(tc.tool, func(t *testing.T) {
result, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: tc.tool, Arguments: json.RawMessage(`{}`)})
if err != nil {
t.Fatalf("protocol-level error: %v", err)
}
if !result.IsError || len(result.Content) == 0 {
t.Fatalf("expected a payment challenge for %s, got %+v", tc.tool, result)
}
text, ok := result.Content[0].(*mcp.TextContent)
if !ok {
t.Fatalf("unexpected content type %T", result.Content[0])
}
var pr types.PaymentRequired
if err := json.Unmarshal([]byte(text.Text), &pr); err != nil {
t.Fatalf("failed to decode payment challenge: %v", err)
}
if len(pr.Accepts) == 0 {
t.Fatalf("expected at least one accept in challenge, got %d", len(pr.Accepts))
}
if got := pr.Accepts[0].Amount; got != tc.want {
t.Errorf("accept amount = %q, want %q (converted to smallest units)", got, tc.want)
}
if got := pr.Accepts[0].Scheme; got != tc.scheme {
t.Errorf("accept scheme = %q, want %q", got, tc.scheme)
}
})
}
}
// toggleFacilitatorServer is a mock facilitator whose availability can be
// flipped at runtime to simulate an outage and recovery without a restart.
type toggleFacilitatorServer struct {
available atomic.Bool
srv *httptest.Server
}
func newToggleFacilitatorServer(t *testing.T) *toggleFacilitatorServer {
f := &toggleFacilitatorServer{}
f.available.Store(true)
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !f.available.Load() {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == "GET" && r.URL.Path == "/supported":
_, _ = w.Write([]byte(`{
"kinds": [
{
"x402Version": 2,
"scheme": "exact",
"network": "eip155:8453"
}
]
}`))
case r.Method == "POST" && r.URL.Path == "/verify":
_, _ = w.Write([]byte(`{
"isValid": true,
"invalidReason": ""
}`))
case r.Method == "POST" && r.URL.Path == "/settle":
_, _ = w.Write([]byte(`{
"success": true,
"transaction": "0x1234abcd",
"network": "eip155:8453",
"payer": "0xabcd1234"
}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(f.srv.Close)
return f
}
func TestX402Server_MCPVerifyResolvesFacilitator(t *testing.T) {
ctx := context.Background()
f := newToggleFacilitatorServer(t)
srv, err := NewX402Server(
"Test Agent API",
"1.0.0",
f.srv.URL,
&DummyAuthProvider{},
X402Middlewares{},
func(next http.Handler) http.Handler { return next },
func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {},
[]string{"eip155:8453"},
)
if err != nil {
t.Fatalf("failed to create server: %v", err)
}
if !srv.x402ResourceServer.HasFacilitatorSupport("eip155:8453", "exact") {
t.Fatal("expected a facilitator client resolved for eip155:8453/exact after construction")
}
req := x402.PaymentRequirements{
Scheme: "exact",
Network: "eip155:8453",
Amount: "10000",
Asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
PayTo: "0x0000000000000000000000000000000000000000",
}
payload := types.PaymentPayload{
X402Version: 2,
Payload: map[string]interface{}{"chainId": 1},
Accepted: req,
}
resp, err := srv.x402ResourceServer.VerifyPayment(ctx, payload, req)
if err != nil {
t.Fatalf("VerifyPayment should succeed once a facilitator is registered, got: %v", err)
}
if resp == nil || !resp.IsValid {
t.Errorf("expected VerifyPayment to return isValid=true, got %+v", resp)
}
}
func TestX402Server_MCPVerifySelfHeals(t *testing.T) {
ctx := context.Background()
f := newToggleFacilitatorServer(t)
f.available.Store(false) // facilitator down at boot
srv, err := NewX402Server(
"Test Agent API",
"1.0.0",
f.srv.URL,
&DummyAuthProvider{},
X402Middlewares{},
func(next http.Handler) http.Handler { return next },
func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {},
[]string{"eip155:8453"},
)
if err != nil {
t.Fatalf("failed to create server: %v", err)
}
if srv.x402ResourceServer.HasFacilitatorSupport("eip155:8453", "exact") {
t.Fatal("expected no facilitator client while the facilitator is down")
}
req := x402.PaymentRequirements{
Scheme: "exact",
Network: "eip155:8453",
Amount: "10000",
Asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
PayTo: "0x0000000000000000000000000000000000000000",
}
payload := types.PaymentPayload{
X402Version: 2,
Payload: map[string]interface{}{"chainId": 1},
Accepted: req,
}
_, verifyErr := srv.x402ResourceServer.VerifyPayment(ctx, payload, req)
if verifyErr == nil || !strings.Contains(verifyErr.Error(), x402.ErrNoFacilitatorForNetwork) {
t.Fatalf("expected ErrNoFacilitatorForNetwork while facilitator is down, got: %v", verifyErr)
}
// Facilitator comes back; no restart required.
f.available.Store(true)
srv.ensureFacilitatorReady(ctx)
if !srv.x402ResourceServer.HasFacilitatorSupport("eip155:8453", "exact") {
t.Fatal("expected facilitator client to be resolved after recovery")
}
resp, err := srv.x402ResourceServer.VerifyPayment(ctx, payload, req)
if err != nil {
t.Fatalf("VerifyPayment should succeed after recovery, got: %v", err)
}
if resp == nil || !resp.IsValid {
t.Errorf("expected VerifyPayment to return isValid=true after recovery, got %+v", resp)
}
}
func TestComputeTwitterStyleProfileCost(t *testing.T) {
cases := []struct {
name string
platform string
postCount int
want string
}{
{name: "non-twitter flat base", platform: "Bluesky", postCount: 10, want: "$0.25"},
{name: "twitter example from spec", platform: "Twitter", postCount: 10, want: "$0.31"},
{name: "twitter zero posts", platform: "Twitter", postCount: 0, want: "$0.26"},
{name: "twitter max posts", platform: "Twitter", postCount: 50, want: "$0.51"},
{name: "twitter odd post count half cents", platform: "Twitter", postCount: 3, want: "$0.275"},
{name: "posts at the max counted cap", platform: "Twitter", postCount: 100, want: "$0.76"},
{name: "beyond cap is not clamped", platform: "Twitter", postCount: 200, want: "$1.26"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
resp := GetStyleProfileResponse{SourcePlatform: tc.platform, PostCount: tc.postCount}
payload, _ := json.Marshal(resp)
r, _ := http.NewRequest("POST", "/v1/getStyleProfile", nil)
got := computeTwitterStyleProfileCost(r, string(payload))
if got != tc.want {
t.Fatalf("computeTwitterStyleProfileCost(%q, %d) = %q, want %q", tc.platform, tc.postCount, got, tc.want)
}
})
}
}
// Verify that every Twitter charge strictly exceeds the flat base cost, so the
// endpoint always clears the underlying API cost (never break-even or loss).
func TestComputeTwitterStyleProfileCostAlwaysProfitable(t *testing.T) {
for postCount := 0; postCount <= 50; postCount++ {
resp := GetStyleProfileResponse{SourcePlatform: "Twitter", PostCount: postCount}
payload, _ := json.Marshal(resp)
r, _ := http.NewRequest("POST", "/v1/getStyleProfile", nil)
got := computeTwitterStyleProfileCost(r, string(payload))
// Cut the "$" prefix and compare numerically to BaseProfileCostMicro.
dollars := strings.TrimPrefix(got, "$")
parsed, err := strconv.ParseFloat(dollars, 64)
if err != nil {
t.Fatalf("failed to parse %q: %v", got, err)
}
micro := int64(parsed * 1_000_000)
if micro <= BaseProfileCostMicro {
t.Fatalf("postCount=%d: charge %q (%d micro) must be strictly greater than base %d", postCount, got, micro, BaseProfileCostMicro)
}
}
}
func setupMockFacilitator() *httptest.Server {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
@ -501,11 +185,6 @@ func setupMockFacilitator() *httptest.Server {
"x402Version": 2,
"scheme": "exact",
"network": "eip155:8453"
},
{
"x402Version": 2,
"scheme": "upto",
"network": "eip155:84532"
}
]
}`))
@ -527,502 +206,3 @@ func setupMockFacilitator() *httptest.Server {
}))
return s
}
func TestAddTool_NonPayable(t *testing.T) {
mockFacilitator := setupMockFacilitator()
defer mockFacilitator.Close()
srv, err := NewX402Server(
"Test Agent API",
"1.0.0",
mockFacilitator.URL,
&DummyAuthProvider{},
X402Middlewares{},
func(next http.Handler) http.Handler { return next },
func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {},
[]string{"eip155:8453"},
)
if err != nil {
t.Fatalf("failed to create server: %v", err)
}
srv.AddTool(
mcp.Tool{
Name: "freeTool",
Description: "Free test endpoint",
},
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: `{"status": "free"}`}},
}, nil
},
)
// Test HTTP endpoint works without payment
reqRec := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/v1/freeTool", strings.NewReader(`{}`))
srv.mux.ServeHTTP(reqRec, req)
if reqRec.Code != http.StatusOK {
t.Errorf("expected status 200 for free tool, got %d", reqRec.Code)
}
// Test OpenAPI includes non-payable tool
reqRecOpenAPI := httptest.NewRecorder()
reqOpenAPI, _ := http.NewRequest("GET", "/openapi.json", nil)
srv.mux.ServeHTTP(reqRecOpenAPI, reqOpenAPI)
if !strings.Contains(reqRecOpenAPI.Body.String(), "freeTool") {
t.Error("OpenAPI spec did not render non-payable tool")
}
// Should not have 402 response for free tool
if strings.Contains(reqRecOpenAPI.Body.String(), `"402"`) {
t.Error("OpenAPI spec should not have 402 for free tool")
}
}
func TestAddPayableTool_EmptyRequirements(t *testing.T) {
mockFacilitator := setupMockFacilitator()
defer mockFacilitator.Close()
srv, err := NewX402Server(
"Test Agent API",
"1.0.0",
mockFacilitator.URL,
&DummyAuthProvider{},
X402Middlewares{},
func(next http.Handler) http.Handler { return next },
func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {},
[]string{"eip155:8453"},
)
if err != nil {
t.Fatalf("failed to create server: %v", err)
}
// The library validates requirements at AddPayableTool call time
// and returns an error (or panics via log.Fatal) for empty requirements
defer func() {
if r := recover(); r != nil {
errMsg := fmt.Sprintf("%v", r)
// The library may panic with log.Fatal or return an error
if !strings.Contains(errMsg, "requires at least one payment requirement") &&
!strings.Contains(errMsg, "Accepts must have at least one") {
t.Errorf("expected panic about empty requirements, got: %v", r)
}
} else {
t.Error("expected panic for empty payment requirements")
}
}()
srv.AddPayableTool(
mcp.Tool{
Name: "badTool",
Description: "Bad endpoint",
},
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: `{"status": "success"}`}},
}, nil
},
nil,
// No requirements provided
)
}
func TestX402Server_MultipleTools(t *testing.T) {
mockFacilitator := setupMockFacilitator()
defer mockFacilitator.Close()
srv, err := NewX402Server(
"Test Agent API",
"1.0.0",
mockFacilitator.URL,
&DummyAuthProvider{},
X402Middlewares{},
func(next http.Handler) http.Handler { return next },
func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {},
[]string{"eip155:8453"},
)
if err != nil {
t.Fatalf("failed to create server: %v", err)
}
// Add multiple tools
toolNames := []string{"tool1", "tool2", "tool3"}
for _, name := range toolNames {
name := name
srv.AddPayableTool(
mcp.Tool{
Name: name,
Description: "Tool " + name,
},
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: `{"tool": "` + name + `"}`}},
}, nil
},
nil,
x402.PaymentRequirements{
Scheme: "exact",
Network: "eip155:8453",
Asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
Amount: "1000",
PayTo: "0x0000000000000000000000000000000000000000",
},
)
}
// Verify all tools are registered in OpenAPI
reqRec := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/openapi.json", nil)
srv.mux.ServeHTTP(reqRec, req)
body := reqRec.Body.String()
for _, name := range toolNames {
if !strings.Contains(body, name) {
t.Errorf("OpenAPI missing tool: %s", name)
}
}
// Verify each tool requires payment
for _, name := range toolNames {
reqRec := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/v1/"+name, strings.NewReader(`{}`))
srv.mux.ServeHTTP(reqRec, req)
if reqRec.Code != http.StatusPaymentRequired {
t.Errorf("tool %s: expected status 402, got %d", name, reqRec.Code)
}
}
}
func TestComputeTwitterStyleProfileCost_EdgeCases(t *testing.T) {
cases := []struct {
name string
platform string
postCount int
want string
}{
{name: "case insensitive twitter", platform: "TWITTER", postCount: 10, want: "$0.25"}, // not "Twitter" so flat base
{name: "empty platform", platform: "", postCount: 10, want: "$0.25"},
{name: "negative post count", platform: "Twitter", postCount: -5, want: "$0.26"}, // should clamp to 0
{name: "few posts", platform: "Twitter", postCount: 1, want: "$0.265"},
{name: "very large post count", platform: "Twitter", postCount: 1000, want: "$5.26"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
resp := GetStyleProfileResponse{SourcePlatform: tc.platform, PostCount: tc.postCount}
payload, _ := json.Marshal(resp)
r, _ := http.NewRequest("POST", "/v1/getStyleProfile", nil)
got := computeTwitterStyleProfileCost(r, string(payload))
if got != tc.want {
t.Fatalf("computeTwitterStyleProfileCost(%q, %d) = %q, want %q", tc.platform, tc.postCount, got, tc.want)
}
})
}
}
// TestStyleProfileMaxPriceStaysAdvertised guards the advertised getStyleProfile
// price in internal/server/tools.go against silent drift from the x402 pricing
// model. internal/server shares the x402 constants (BaseProfileCostMicro /
// MaxProfileCountedPosts) to compute the upto maximum, so this only needs to
// catch a hand-edited Price string going out of sync.
func TestStyleProfileMaxPriceStaysAdvertised(t *testing.T) {
if got := formatDollarAmount(maxProfileCostMicro); got != "$0.76" {
t.Fatalf("maxProfileCostMicro = %d (%s), want $0.76; update getStyleProfile base Price in internal/server/tools.go if the pricing model changed", maxProfileCostMicro, got)
}
}
// TestX402Server_MCPStyleProfileSettlementOverride drives the full MCP flow for
// the getStyleProfile upto tool and asserts the facilitator is told to settle
// the computed per-request cost (not the advertised $0.76 maximum).
func TestX402Server_MCPStyleProfileSettlementOverride(t *testing.T) {
ctx := context.Background()
var mu sync.Mutex
var settleAmounts []string
mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == "GET" && r.URL.Path == "/supported":
_, _ = w.Write([]byte(`{
"kinds": [
{"x402Version": 2, "scheme": "upto", "network": "eip155:84532"}
]
}`))
case r.Method == "POST" && r.URL.Path == "/verify":
_, _ = w.Write([]byte(`{"isValid": true, "invalidReason": ""}`))
case r.Method == "POST" && r.URL.Path == "/settle":
var body struct {
PaymentRequirements map[string]json.RawMessage `json:"paymentRequirements"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
if raw := body.PaymentRequirements["amount"]; len(raw) > 0 {
var s string
var f float64
mu.Lock()
if json.Unmarshal(raw, &s) == nil {
settleAmounts = append(settleAmounts, s)
} else if json.Unmarshal(raw, &f) == nil {
settleAmounts = append(settleAmounts, strconv.FormatFloat(f, 'f', -1, 64))
}
mu.Unlock()
}
_, _ = w.Write([]byte(`{
"success": true,
"transaction": "0x1234abcd",
"network": "eip155:84532",
"payer": "0xabcd1234"
}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer mock.Close()
serverTransport, clientTransport := mcp.NewInMemoryTransports()
srv, err := NewX402Server(
"Test Agent API",
"1.0.0",
mock.URL,
&DummyAuthProvider{},
X402Middlewares{},
func(next http.Handler) http.Handler { return next },
func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {},
[]string{"eip155:84532"},
)
if err != nil {
t.Fatalf("failed to create server: %v", err)
}
responses := []GetStyleProfileResponse{
{Author: "@a", TopKeywords: []string{}, StyleSummary: "s", PostCount: 10, SourcePlatform: "Bluesky"},
{Author: "@b", TopKeywords: []string{}, StyleSummary: "s", PostCount: 50, SourcePlatform: "Twitter"},
}
var idx int
srv.AddPayableTool(
mcp.Tool{Name: "getStyleProfile", Description: "get a style profile"},
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
resp := responses[idx%len(responses)]
idx++
b, _ := json.Marshal(resp)
return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: string(b)}}}, nil
},
NewStyleProfileCostResolver(),
x402.PaymentRequirements{
Scheme: "upto",
Network: "eip155:84532",
Amount: "0.76",
PayTo: "0x0000000000000000000000000000000000000000",
},
)
serverSession, err := srv.mcpServer.Connect(ctx, serverTransport, nil)
if err != nil {
t.Fatalf("failed to connect server session: %v", err)
}
defer func() { _ = serverSession.Close() }()
client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "1.0.0"}, nil)
clientSession, err := client.Connect(ctx, clientTransport, nil)
if err != nil {
t.Fatalf("failed to connect client session: %v", err)
}
defer func() { _ = clientSession.Close() }()
paidCall := func() (*mcp.CallToolResult, error) {
challenge, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: "getStyleProfile", Arguments: json.RawMessage(`{}`)})
if err != nil {
return nil, fmt.Errorf("challenge call failed: %w", err)
}
if !challenge.IsError {
return nil, fmt.Errorf("expected a payment challenge, got %+v", challenge)
}
text, ok := challenge.Content[0].(*mcp.TextContent)
if !ok {
return nil, fmt.Errorf("unexpected challenge content type %T", challenge.Content[0])
}
var pr types.PaymentRequired
if err := json.Unmarshal([]byte(text.Text), &pr); err != nil {
return nil, fmt.Errorf("failed to decode payment challenge: %w", err)
}
if len(pr.Accepts) == 0 {
return nil, fmt.Errorf("expected at least one accept, got %d", len(pr.Accepts))
}
payload := types.PaymentPayload{
X402Version: 2,
Payload: map[string]interface{}{"chainId": 84532},
Accepted: pr.Accepts[0],
}
return clientSession.CallTool(ctx, &mcp.CallToolParams{
Name: "getStyleProfile",
Arguments: json.RawMessage(`{}`),
Meta: mcp.Meta{PaymentMetaKey: payload},
})
}
for i := 0; i < 2; i++ {
result, err := paidCall()
if err != nil {
t.Fatalf("paid call: %v", err)
}
if result.IsError {
text, _ := result.Content[0].(*mcp.TextContent)
t.Fatalf("expected successful paid call, got error result: %+v", text)
}
if resp := result.Meta[PaymentResponseMetaKey]; resp == nil {
t.Fatalf("expected %q in result meta", PaymentResponseMetaKey)
}
}
mu.Lock()
got := append([]string(nil), settleAmounts...)
mu.Unlock()
// The "$0.25" / "$0.51" overrides resolve to 250000 / 510000 atomic units,
// with up to 1 micro tolerance for float rounding in the SDK's dollar parser.
wants := []int64{250000, 510000}
if len(got) != len(wants) {
t.Fatalf("settle amount count = %d (%v), want %d: %v", len(got), got, len(wants), wants)
}
for i, want := range wants {
v, err := strconv.ParseInt(got[i], 10, 64)
if err != nil {
t.Fatalf("settle amount[%d] = %q is not an integer: %v", i, got[i], err)
}
diff := v - want
if diff < 0 {
diff = -diff
}
if diff > 1 {
t.Errorf("settle amount[%d] = %d, want ~%d", i, v, want)
}
}
}
func TestNewStyleProfileCostResolver(t *testing.T) {
resolver := NewStyleProfileCostResolver()
cases := []struct {
name string
resp GetStyleProfileResponse
want string
}{
{name: "bluesky base", resp: GetStyleProfileResponse{SourcePlatform: "Bluesky", PostCount: 10}, want: "$0.25"},
{name: "twitter with posts", resp: GetStyleProfileResponse{SourcePlatform: "Twitter", PostCount: 50}, want: "$0.51"},
{name: "twitter no posts", resp: GetStyleProfileResponse{SourcePlatform: "Twitter", PostCount: 0}, want: "$0.26"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
payload, _ := json.Marshal(tc.resp)
result := &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: string(payload)}}}
got, ok := resolver(result)
if !ok {
t.Fatalf("resolver returned ok=false")
}
if got != tc.want {
t.Fatalf("resolver = %q, want %q", got, tc.want)
}
})
}
// Non-text content resolves to "no override"; malformed JSON falls back to
// the flat base cost via computeTwitterStyleProfileCost.
if _, ok := resolver(&mcp.CallToolResult{}); ok {
t.Fatalf("expected no override for empty content")
}
got, ok := resolver(&mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "not json"}}})
if !ok {
t.Fatalf("expected a base-cost fallback for malformed JSON")
}
if got != "$0.25" {
t.Fatalf("malformed JSON resolver = %q, want base fallback $0.25", got)
}
}
// TestNewTwitterCountCostResolver verifies the actual-cost settlement for
// tools whose responses carry a `twitterPostCount` field (analytics tools,
// findSimilarUsers): flat base price when no Twitter posts went into the
// response, and base + lookup + per-post when they did.
func TestNewTwitterCountCostResolver(t *testing.T) {
const baseMicro = 150_000 // getEngagementMetrics base
resolver := NewTwitterCountCostResolver(baseMicro)
cases := []struct {
name string
twitterCount int
want string
}{
{name: "no twitter posts", twitterCount: 0, want: "$0.15"},
{name: "one twitter post", twitterCount: 1, want: "$0.165"},
{name: "ten twitter posts", twitterCount: 10, want: "$0.21"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
payload, _ := json.Marshal(map[string]any{
"metrics": []any{},
"twitterPostCount": tc.twitterCount,
})
result := &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: string(payload)}}}
got, ok := resolver(result)
if !ok {
t.Fatalf("resolver returned ok=false")
}
if got != tc.want {
t.Fatalf("resolver = %q, want %q", got, tc.want)
}
})
}
if _, ok := resolver(&mcp.CallToolResult{}); ok {
t.Fatalf("expected no override for empty content")
}
if _, ok := resolver(&mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "not json"}}}); ok {
t.Fatalf("expected no override for malformed JSON")
}
}
// TestBuildSchemeConfigs verifies scheme servers are derived from the payment
// requirements rather than the tool name, so upto is only registered for tools
// that actually declare it.
func TestBuildSchemeConfigs(t *testing.T) {
req := func(scheme string) x402.PaymentRequirements {
return x402.PaymentRequirements{Scheme: scheme, Network: "eip155:8453", Asset: "usdc", Amount: "1", PayTo: "to"}
}
t.Run("exact only", func(t *testing.T) {
got := buildSchemeConfigs([]string{"eip155:8453"}, req("exact"))
if len(got) != 1 || got[0].Network != x402.Network("eip155:8453") {
t.Fatalf("expected 1 exact scheme for one network, got %+v", got)
}
})
t.Run("upto only", func(t *testing.T) {
got := buildSchemeConfigs([]string{"eip155:8453"}, req("upto"))
if len(got) != 1 {
t.Fatalf("expected 1 upto scheme, got %d", len(got))
}
})
t.Run("both schemes per network", func(t *testing.T) {
got := buildSchemeConfigs([]string{"eip155:8453", "eip155:84532"}, req("exact"), req("upto"))
if len(got) != 4 {
t.Fatalf("expected 2 schemes x 2 networks = 4, got %d", len(got))
}
})
}
func TestPricingHelpers(t *testing.T) {
if got := FormatPriceMicro(MaxTwitterCostMicro(BaseProfileCostMicro, MaxProfileCountedPosts)); got != "0.76" {
t.Fatalf("style profile upto max = %q, want 0.76", got)
}
if got := FormatPriceMicro(MaxTwitterCostMicro(150_000, MaxAnalyticsCountedPosts)); got != "1.16" {
t.Fatalf("engagement metrics upto max = %q, want 1.16", got)
}
if got := MicroFromPriceString("0.25"); got != BaseProfileCostMicro {
t.Fatalf("MicroFromPriceString(0.25) = %d, want %d", got, BaseProfileCostMicro)
}
if got := MicroFromPriceString("garbage"); got != 0 {
t.Fatalf("MicroFromPriceString(garbage) = %d, want 0", got)
}
}

View file

@ -7,7 +7,7 @@ User=mcpserver
Group=mcpserver
WorkingDirectory=/home/ubuntu/gitSources/mcpServer
ExecStart=/home/ubuntu/gitSources/mcpServer/bin/mcpserver
ExecStart=/home/ubuntu/gitSources/mcpServer/bin/main
RuntimeDirectory=mcpserver
RuntimeDirectoryMode=0750

View file

@ -1,62 +0,0 @@
#!/bin/bash
set -euo pipefail
echo "=== Funding Wallet with Base Sepolia USDC ==="
# Required environment variables
: "${PRIVATE_KEY:?PRIVATE_KEY must be set}"
: "${FUNDING_FAUCET_URL:?FUNDING_FAUCET_URL must be set}"
WALLET_ADDRESS=$(cast wallet address --private-key "$PRIVATE_KEY")
echo "Wallet address: $WALLET_ADDRESS"
# Check current USDC balance
USDC_CONTRACT="0x036CbD53842c5426634e7929541eC2318f3dCF7e"
RPC_URL="https://sepolia.base.org"
BALANCE=$(cast call "$USDC_CONTRACT" "balanceOf(address)(uint256)" "$WALLET_ADDRESS" --rpc-url "$RPC_URL" 2>/dev/null || echo "0")
BALANCE_DECIMAL=$(cast to-dec "$BALANCE" 2>/dev/null || echo "0")
BALANCE_USDC=$(echo "scale=6; $BALANCE_DECIMAL / 1000000" | bc 2>/dev/null || echo "0")
echo "Current USDC balance: $BALANCE_USDC USDC"
# If balance is less than 10 USDC, request from faucet
if (( $(echo "$BALANCE_USDC < 10" | bc -l) )); then
echo "Balance below 10 USDC, requesting from faucet..."
# Request from faucet (Coinbase CDP faucet API format)
curl -X POST "$FUNDING_FAUCET_URL" \
-H "Content-Type: application/json" \
-d "{\"address\": \"$WALLET_ADDRESS\", \"token\": \"USDC\"}" \
|| echo "Faucet request failed, may need manual funding"
# Wait for funding
echo "Waiting for funding..."
sleep 30
# Check balance again
BALANCE=$(cast call "$USDC_CONTRACT" "balanceOf(address)(uint256)" "$WALLET_ADDRESS" --rpc-url "$RPC_URL" 2>/dev/null || echo "0")
BALANCE_DECIMAL=$(cast to-dec "$BALANCE" 2>/dev/null || echo "0")
BALANCE_USDC=$(echo "scale=6; $BALANCE_DECIMAL / 1000000" | bc 2>/dev/null || echo "0")
echo "New USDC balance: $BALANCE_USDC USDC"
else
echo "Sufficient USDC balance"
fi
# Also fund with ETH for gas
ETH_BALANCE=$(cast balance "$WALLET_ADDRESS" --rpc-url "$RPC_URL" 2>/dev/null || echo "0")
ETH_BALANCE_DECIMAL=$(cast to-dec "$ETH_BALANCE" 2>/dev/null || echo "0")
ETH_BALANCE_ETH=$(echo "scale=6; $ETH_BALANCE_DECIMAL / 1000000000000000000" | bc 2>/dev/null || echo "0")
echo "Current ETH balance: $ETH_BALANCE_ETH ETH"
if (( $(echo "$ETH_BALANCE_ETH < 0.01" | bc -l) )); then
echo "ETH balance low, requesting from faucet..."
curl -X POST "$FUNDING_FAUCET_URL" \
-H "Content-Type: application/json" \
-d "{\"address\": \"$WALLET_ADDRESS\", \"token\": \"ETH\"}" \
|| echo "ETH faucet request failed"
sleep 10
fi
echo "=== Wallet Funding Complete ==="

View file

@ -1,101 +0,0 @@
#!/bin/bash
set -euo pipefail
echo "=== Starting Test Server ==="
# Required environment variables
: "${PRIVATE_KEY:?PRIVATE_KEY must be set}"
: "${X402_WALLET_ADDRESS:?X402_WALLET_ADDRESS must be set}"
# Set up environment for local testing
export SERVER_ADDRESS="127.0.0.1"
export SERVER_PORT="8080"
# Allow workflow services to override hosts (postgres/ollama service names)
export GOOSE_DBSTRING="${GOOSE_DBSTRING:-postgres://postgres:postgres@localhost:5432/mcpserver?sslmode=disable}"
# If postgres host not reachable, try service name
if ! pg_isready -h localhost -U postgres -d mcpserver >/dev/null 2>&1 2>/dev/null; then
if pg_isready -h postgres -U postgres -d mcpserver >/dev/null 2>&1 2>/dev/null; then
export GOOSE_DBSTRING="postgres://postgres:postgres@postgres:5432/mcpserver?sslmode=disable"
fi
fi
export ACTIVE_NETWORKS="sepolia"
export X402_FACILITATOR_URL="https://api.cdp.coinbase.com/platform/v2/x402"
export X402_WALLET_ADDRESS="$X402_WALLET_ADDRESS"
# Respect OLLAMA_HOST from env (workflow services use http://ollama:11434)
if curl -sf http://ollama:11434/api/tags >/dev/null 2>&1; then
export OLLAMA_HOST="${OLLAMA_HOST:-http://ollama:11434}"
else
export OLLAMA_HOST="${OLLAMA_HOST:-http://localhost:11434}"
fi
export MIGRATE_ON_START="true"
export MIGRATIONS_DIR="sql/schema"
export READYZ_PORT="9090"
export METRICS_PORT="9091"
export LOG_FILE="/tmp/mcpserver-test.log"
export LOG_LEVEL="debug"
# Use test credentials or empty for local testing
export BLUESKY_DID="${BLUESKY_DID:-}"
export BLUESKY_APP_PASSWORD="${BLUESKY_APP_PASSWORD:-}"
export BLUESKY_HOST="${BLUESKY_HOST:-https://bsky.social}"
export AP_HOST="${AP_HOST:-https://mastodon.social}"
export AP_ACCESS_TOKEN="${AP_ACCESS_TOKEN:-}"
export NOSTR_RELAYS="${NOSTR_RELAYS:-wss://relay.damus.io,wss://nos.lol}"
export TWITTER_BEARER_TOKEN="${TWITTER_BEARER_TOKEN:-}"
export CDP_API_KEY_ID="${CDP_API_KEY_ID:-}"
export CDP_API_SECRET="${CDP_API_SECRET:-}"
export ENRICH_CROSS_PLATFORM="false"
export PREWARM_AUTHORS=""
export PREWARM_FEED_URI=""
# Build server binary first
echo "Building server binary..."
go build -o /tmp/mcpserver ./cmd/server/main.go
# Start the server in background
echo "Starting server on port 8080..."
/tmp/mcpserver >>/tmp/mcpserver-test.log 2>&1 &
SERVER_PID=$!
# Save PID for cleanup
echo $SERVER_PID > /tmp/test-server.pid
# Wait for server to be ready
echo "Waiting for server to be ready..."
for i in {1..30}; do
if curl -sf http://localhost:9090/readyz >/dev/null 2>&1; then
echo "Server is ready"
break
fi
sleep 1
done
# Verify server is responding
curl -sf http://localhost:8080/healthz >/dev/null 2>&1 || {
echo "Server health check failed"
kill -TERM $SERVER_PID 2>/dev/null || true
sleep 2
cat /tmp/mcpserver-test.log 2>&1 | tail -n 200
exit 1
}
# Verify payment endpoints are working (should return 402 without payment)
echo "Verifying payment endpoints..."
if curl -sf -X POST http://localhost:8080/v1/getActor \
-H "Content-Type: application/json" \
-d '{"author": "did:plc:z72i7hdynmk6r22z27h6tvur"}' >/dev/null 2>&1; then
echo "WARNING: Payable endpoint getActor returned 200 without payment!"
else
echo "Payment endpoint getActor correctly requires payment (402)"
fi
# Verify free endpoint works
if curl -sf -X POST http://localhost:8080/v1/getFeed \
-H "Content-Type: application/json" \
-d '{"uri": "https://bsky.app/profile/did:plc:z72i7hdynmk6r22z27h6tvur/feed/whats-hot"}' >/dev/null 2>&1; then
echo "Free endpoint getFeed works correctly"
else
echo "WARNING: Free endpoint getFeed failed!"
fi
echo "=== Test Server Started (PID: $SERVER_PID) ==="

View file

@ -1,17 +0,0 @@
-- name: GetAnalyticsCache :one
SELECT result FROM analytics_cache
WHERE user_id = $1 AND platform = $2 AND tool_name = $3 AND time_range_hash = $4
AND expires_at > now();
-- name: UpsertAnalyticsCache :exec
INSERT INTO analytics_cache (user_id, platform, tool_name, time_range_hash, result, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (user_id, platform, tool_name, time_range_hash)
DO UPDATE SET result = EXCLUDED.result, expires_at = EXCLUDED.expires_at, computed_at = now();
-- name: DeleteAnalyticsCache :exec
DELETE FROM analytics_cache
WHERE user_id = $1 AND platform = $2 AND tool_name = $3 AND time_range_hash = $4;
-- name: PruneAnalyticsCache :exec
DELETE FROM analytics_cache WHERE expires_at <= now();

View file

@ -1,14 +0,0 @@
-- name: GetAnalyticsCoverageRanges :many
SELECT covered_start, covered_end FROM analytics_coverage_ranges
WHERE user_id = $1 AND platform = $2
ORDER BY covered_start;
-- name: InsertAnalyticsCoverageRange :exec
INSERT INTO analytics_coverage_ranges (user_id, platform, covered_start, covered_end, updated_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (user_id, platform, covered_start, covered_end)
DO UPDATE SET updated_at = now();
-- name: DeleteAnalyticsCoverageRange :exec
DELETE FROM analytics_coverage_ranges
WHERE user_id = $1 AND platform = $2 AND covered_start = $3 AND covered_end = $4;

View file

@ -1,17 +0,0 @@
-- name: GetAnalyticsPostsInRange :many
SELECT post_id, post_text, post_timestamp, likes, reposts, replies, quotes
FROM analytics_posts
WHERE user_id = $1 AND platform = $2 AND post_timestamp >= $3 AND post_timestamp <= $4
ORDER BY post_timestamp DESC
LIMIT $5;
-- name: UpsertAnalyticsPosts :exec
INSERT INTO analytics_posts (user_id, platform, post_id, post_text, post_timestamp, likes, reposts, replies, quotes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (user_id, platform, post_id)
DO UPDATE SET post_text = EXCLUDED.post_text,
post_timestamp = EXCLUDED.post_timestamp,
likes = EXCLUDED.likes,
reposts = EXCLUDED.reposts,
replies = EXCLUDED.replies,
quotes = EXCLUDED.quotes;

View file

@ -1,3 +0,0 @@
-- name: DeleteStylePosts :exec
DELETE FROM user_style_posts
WHERE user_id = $1 AND platform = $2 AND platform != 'Twitter';

View file

@ -1,6 +1,6 @@
-- name: FindSimilarUsers :many
SELECT user_id, platform, embedding, top_keywords, style_summary, post_count,
1 - (embedding <=> @query_embedding::vector) AS similarity
1 - (embedding <=> $1::vector) AS similarity
FROM user_style_profiles
ORDER BY embedding <=> @query_embedding::vector
LIMIT @limit_val;
ORDER BY embedding <=> $1::vector
LIMIT $2;

View file

@ -1,3 +0,0 @@
-- name: GetStylePostByID :one
SELECT post_id, post_text FROM user_style_posts
WHERE user_id = $1 AND platform = $2 AND post_id = $3;

View file

@ -1,5 +0,0 @@
-- name: GetStylePosts :many
SELECT post_id, post_text FROM user_style_posts
WHERE user_id = $1 AND platform = $2
ORDER BY created_at DESC
LIMIT $3;

View file

@ -1,3 +0,0 @@
-- name: GetStylePostsByIDs :many
SELECT post_id, post_text FROM user_style_posts
WHERE user_id = $1 AND platform = $2 AND post_id = ANY($3::text[]);

View file

@ -1,3 +1,3 @@
-- name: GetStyleProfile :one
SELECT id, user_id, platform, embedding, top_keywords, style_summary, post_count, modified_at, created_at, model_version, schema_version, algorithm_version FROM user_style_profiles
WHERE user_id = @user_id AND platform = @platform;
SELECT * FROM user_style_profiles
WHERE user_id = $1 AND platform = $2;

View file

@ -1,3 +0,0 @@
-- name: PruneStylePosts :exec
DELETE FROM user_style_posts
WHERE created_at < now() - interval '7 days' AND platform != 'Twitter';

View file

@ -1,5 +0,0 @@
-- name: UpsertStylePosts :exec
INSERT INTO user_style_posts (user_id, platform, post_id, post_text)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, platform, post_id)
DO UPDATE SET post_text = EXCLUDED.post_text, created_at = now();

View file

@ -1,10 +1,10 @@
-- name: UpsertStyleProfile :exec
INSERT INTO user_style_profiles (user_id, platform, embedding, top_keywords, style_summary, post_count)
VALUES (@user_id, @platform, @embedding::vector, @top_keywords, @style_summary, @post_count)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (user_id, platform)
DO UPDATE SET
embedding = EXCLUDED.embedding,
top_keywords = EXCLUDED.top_keywords,
style_summary = EXCLUDED.style_summary,
post_count = EXCLUDED.post_count,
modified_at = NOW();
updated_at = NOW();

View file

@ -1,5 +1,5 @@
-- +goose up
CREATE TABLE IF NOT EXISTS transactions (
CREATE TABLE transactions (
id BIGSERIAL PRIMARY KEY,
x402_transaction_hash VARCHAR(255) NOT NULL UNIQUE,
amount NUMERIC NOT NULL,

View file

@ -1,11 +1,9 @@
-- +goose up
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE IF NOT EXISTS user_style_profiles (
CREATE TABLE user_style_profiles (
id BIGSERIAL PRIMARY KEY,
user_id TEXT NOT NULL,
platform TEXT NOT NULL,
embedding VECTOR(1024) NOT NULL,
embedding VECTOR(768) NOT NULL,
top_keywords TEXT[] NOT NULL,
style_summary TEXT NOT NULL,
post_count INTEGER NOT NULL,
@ -16,5 +14,3 @@ CREATE TABLE IF NOT EXISTS user_style_profiles (
-- +goose down
DROP TABLE IF EXISTS user_style_profiles;
DROP EXTENSION IF EXISTS vector;

View file

@ -1,15 +0,0 @@
-- +goose up
ALTER TABLE user_style_profiles
ADD COLUMN IF NOT EXISTS model_version INTEGER NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS schema_version INTEGER NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS algorithm_version INTEGER NOT NULL DEFAULT 1;
CREATE INDEX IF NOT EXISTS idx_user_style_profiles_versions
ON user_style_profiles (model_version, schema_version, algorithm_version);
-- +goose down
DROP INDEX idx_user_style_profiles_versions;
ALTER TABLE user_style_profiles
DROP COLUMN algorithm_version,
DROP COLUMN model_version,
DROP COLUMN schema_version;

Some files were not shown because too many files have changed in this diff Show more