Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
3fcdb9b88c |
|||
|
7a21437120 |
|||
|
d1b7805162 |
|||
|
e66900dc75 |
|||
|
0db85ee650 |
|||
|
c965eb51d3 |
|||
|
19ae0d781a |
|||
|
9825c45350 |
|||
|
f4ae7d9c9f |
|||
|
c96447e3e0 |
|||
|
98a6bed466 |
|||
|
84fbad705d |
|||
|
67d35a0a2f |
|||
|
4f107da9ee |
|||
|
f7b3cd886b |
|||
|
4df3967cab |
|||
|
2fd4a4ec90 |
|||
|
b27108467e |
|||
|
1640fae6be |
|||
|
e68e02bf20 |
|||
|
9a45dbc2c6 |
|||
|
1e32bb752f |
|||
|
8c561a83a2 |
|||
|
debd49d6f6 |
|||
|
5cd1d128d6 |
|||
|
22b92cb6cd |
|||
|
7aee2fe88c |
|||
|
3b443eff36 |
|||
|
ee2829b818 |
|||
|
d06ade2172 |
|||
|
a5a4ccd972 |
|||
|
76d337f54c |
|||
|
9c26886957 |
|||
|
9059be5027 |
|||
|
4798fa6732 |
|||
|
2c8f09fdbf |
|||
|
78ec4af4ee |
|||
|
6e0a57063c |
|||
|
6c75d869b2 |
|||
|
9e12c6cac8 |
|||
|
79e2a2348c |
|||
|
9609f68b6c |
|||
|
f1a2a2c827 |
|||
|
57a9c43890 |
|||
|
fe22a9779f |
|||
|
f0b1e7a777 |
|||
|
60d18beda2 |
|||
|
3287e15ac0 |
|||
|
8e55922baf |
|||
|
bf7a91ca89 |
|||
|
357f0e1035 |
|||
|
3717a37748 |
|||
|
832bbba8ed |
|||
|
c8b313a9f6 |
|||
|
4c723445e0 |
|||
|
b6daa0efcc |
|||
|
28b65cd1bf |
|||
|
9cee30ff70 |
|||
|
6dc5b3c3f0 |
|||
|
443ecce9c2 |
|||
|
281c1b55eb |
|||
|
6f6b952e46 |
|||
|
6be20e0b83 |
|||
|
541af5f8cf |
|||
|
6e5670f69e |
|||
|
9d911a9227 |
|||
|
5cd3b8d316 |
|||
|
4fb665da51 |
|||
|
f80e23c722 |
|||
|
ec93468fec |
|||
|
a59cee1d2a |
|||
|
a218b8fbf5 |
|||
|
cd630a4fad |
|||
| 8bb4cb539c | |||
| 07ab6a13af | |||
| ee0589c774 | |||
| 193184675e | |||
| 90af0ec4fc | |||
| c8d1829172 | |||
| 6477d5845f | |||
| 0dcb74675e | |||
| e4176f99ed | |||
| 979cd92445 | |||
| c6b73cba1f | |||
| fbd74a37b5 | |||
| 4e027ca857 | |||
| c851d0e50f | |||
| ab1eb634f9 | |||
| 686c7ca2b6 | |||
| 695e137915 | |||
| 4f56def00e | |||
| 3c0a7b9506 | |||
| 248f738e07 | |||
| 877f839926 | |||
| ad67171f37 | |||
| 5574a8596f | |||
| 56f8625f05 | |||
| e2cd655bc1 | |||
| 63709524de | |||
| 63f8756cc7 | |||
| d3845671b4 | |||
| 027a4ee5e9 | |||
| e54d17736b | |||
| 114574b331 | |||
| fbd1f5af6c | |||
| 9918134fd0 | |||
| d7bece250f | |||
| 71797cfc4f | |||
| d085d9e3ae | |||
| bd707cf2ca | |||
| 3c024d668e | |||
| cab911ea6f | |||
| ec27416cb1 | |||
| 4ac15ff6c2 | |||
| 1d97ca1164 | |||
| 8cfb1291aa | |||
| 533ecede00 | |||
| 4d11cea6cd | |||
| e31a4313f4 | |||
| e8a37831cb | |||
| bc168e1bca | |||
| e5168e33f3 | |||
| 12bf3e8311 |
107 changed files with 15736 additions and 1175 deletions
199
.forgejo/workflows/test-endpoints.yaml
Normal file
199
.forgejo/workflows/test-endpoints.yaml
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
name: Test Every Endpoint on Base Sepolia
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, ai-main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GO_VERSION: '1.25'
|
||||
POSTGRES_DB: mcpserver
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
TEST_SERVER_URL: http://localhost:8080
|
||||
RPC_URL: https://sepolia.base.org
|
||||
X402_FACILITATOR_URL: https://api.cdp.coinbase.com/platform/v2/x402
|
||||
|
||||
jobs:
|
||||
test_endpoints:
|
||||
runs-on: node20
|
||||
timeout-minutes: 30
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
env:
|
||||
POSTGRES_DB: mcpserver
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres -d mcpserver"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
ports:
|
||||
- 11434:11434
|
||||
# no healthcheck here - ollama takes >60s to pull image + start,
|
||||
# act would mark service 'not healthy' and abort job. Let steps poll.
|
||||
env:
|
||||
PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }}
|
||||
X402_WALLET_ADDRESS: ${{ secrets.X402_WALLET_ADDRESS }}
|
||||
BLUESKY_DID: ${{ secrets.BLUESKY_DID }}
|
||||
BLUESKY_HOST: ${{ secrets.BLUESKY_HOST }}
|
||||
BLUESKY_APP_PASSWORD: ${{ secrets.BLUESKY_APP_PASSWORD }}
|
||||
AP_HOST: ${{ secrets.AP_HOST }}
|
||||
AP_ACCESS_TOKEN: ${{ secrets.AP_ACCESS_TOKEN }}
|
||||
TWITTER_BEARER_TOKEN: ${{ secrets.TWITTER_BEARER_TOKEN }}
|
||||
CDP_API_KEY_ID: ${{ secrets.CDP_API_KEY_ID }}
|
||||
CDP_API_SECRET: ${{ secrets.CDP_API_SECRET }}
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Load model config
|
||||
run: |
|
||||
source config/models.env
|
||||
echo "EMBEDDING_MODEL=$EMBEDDING_MODEL" >> $FORGEJO_ENV
|
||||
echo "STYLE_MODEL=$STYLE_MODEL" >> $FORGEJO_ENV
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: true
|
||||
|
||||
- name: Install Dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Verify Code Formatting
|
||||
run: |
|
||||
if [ -n "$(gofmt -l .)" ]; then
|
||||
echo "Code is not formatted correctly. Run 'gofmt -w .'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Wait for PostgreSQL
|
||||
run: |
|
||||
if ! command -v pg_isready >/dev/null 2>&1; then
|
||||
apt-get update && apt-get install -y postgresql-client
|
||||
fi
|
||||
for i in {1..60}; do
|
||||
if pg_isready -h localhost -U postgres -d mcpserver >/dev/null 2>&1 || \
|
||||
pg_isready -h postgres -U postgres -d mcpserver >/dev/null 2>&1; then
|
||||
echo "PostgreSQL is ready"
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "PostgreSQL failed to start"
|
||||
exit 1
|
||||
|
||||
- name: Wait for Ollama
|
||||
run: |
|
||||
for i in {1..90}; do
|
||||
if curl -sf http://localhost:11434/api/tags >/dev/null 2>&1 || \
|
||||
curl -sf http://ollama:11434/api/tags >/dev/null 2>&1; then
|
||||
echo "Ollama is ready"
|
||||
exit 0
|
||||
fi
|
||||
echo "waiting for ollama $i/90..."
|
||||
sleep 5
|
||||
done
|
||||
echo "Ollama failed to start (not healthy, but continuing - pull may still work)"
|
||||
curl -v http://localhost:11434/api/tags || curl -v http://ollama:11434/api/tags || true
|
||||
|
||||
- name: Pull Ollama Models
|
||||
run: |
|
||||
for host in http://localhost:11434 http://ollama:11434; do
|
||||
if curl -sf $host/api/tags >/dev/null 2>&1; then
|
||||
OLLAMA_HOST=$host
|
||||
break
|
||||
fi
|
||||
done
|
||||
: "${OLLAMA_HOST:=http://localhost:11434}"
|
||||
echo "Using OLLAMA_HOST=$OLLAMA_HOST"
|
||||
if [ -n "${EMBEDDING_MODEL:-}" ]; then
|
||||
echo "Pulling $EMBEDDING_MODEL..."
|
||||
curl -X POST $OLLAMA_HOST/api/pull -d "{\"name\":\"${EMBEDDING_MODEL}\"}" || true
|
||||
fi
|
||||
if [ -n "${STYLE_MODEL:-}" ]; then
|
||||
echo "Pulling $STYLE_MODEL..."
|
||||
curl -X POST $OLLAMA_HOST/api/pull -d "{\"name\":\"${STYLE_MODEL}\"}" || true
|
||||
fi
|
||||
sleep 2
|
||||
curl -sf $OLLAMA_HOST/api/tags || true
|
||||
|
||||
- name: Debug CDP Secret Format
|
||||
env:
|
||||
CDP_API_KEY_ID: ${{ secrets.CDP_API_KEY_ID }}
|
||||
CDP_API_SECRET: ${{ secrets.CDP_API_SECRET }}
|
||||
run: |
|
||||
echo "=== CDP SECRET DEBUG ==="
|
||||
echo "KEY_NAME: ${CDP_API_KEY_ID}"
|
||||
echo "SECRET_LEN: ${#CDP_API_SECRET}"
|
||||
echo "SECRET_FIRST_80: ${CDP_API_SECRET:0:80}"
|
||||
echo "HAS_LITERAL_\\n: $(echo -n "$CDP_API_SECRET" | grep -c '\\\\n' || true)"
|
||||
echo "HAS_ACTUAL_NEWLINE: $(echo -n "$CDP_API_SECRET" | grep -c $'\n' || true)"
|
||||
echo "STARTS_WITH: $(echo -n "$CDP_API_SECRET" | head -c 40)"
|
||||
echo "ENDS_WITH: $(echo -n "$CDP_API_SECRET" | tail -c 20)"
|
||||
echo "=== END DEBUG ==="
|
||||
|
||||
- name: Check Facilitator Supported Networks
|
||||
env:
|
||||
CDP_API_KEY_ID: ${{ secrets.CDP_API_KEY_ID }}
|
||||
CDP_API_SECRET: ${{ secrets.CDP_API_SECRET }}
|
||||
X402_FACILITATOR_URL: https://api.cdp.coinbase.com/platform/v2/x402
|
||||
run: |
|
||||
echo "Building facilitator checker..."
|
||||
go build -o /tmp/check-facilitator ./cmd/check-facilitator
|
||||
/tmp/check-facilitator
|
||||
|
||||
- name: Start Test Server
|
||||
run: |
|
||||
chmod +x scripts/start-test-server.sh
|
||||
./scripts/start-test-server.sh
|
||||
|
||||
- name: Build MCP Test Client
|
||||
run: |
|
||||
go build -o test-mcp-endpoints ./testClient/mcp
|
||||
|
||||
- name: Run MCP Endpoint Tests
|
||||
env:
|
||||
RPC_URL: ${{ env.RPC_URL }}
|
||||
run: |
|
||||
./test-mcp-endpoints
|
||||
|
||||
- name: Build REST Test Client
|
||||
run: |
|
||||
go build -o test-endpoints ./testClient/rest
|
||||
|
||||
- name: Run REST Endpoint Tests
|
||||
env:
|
||||
RPC_URL: ${{ env.RPC_URL }}
|
||||
run: |
|
||||
./test-endpoints
|
||||
|
||||
- name: Dump Server Logs on Failure
|
||||
if: failure()
|
||||
run: |
|
||||
echo "====== /tmp/mcpserver-test.log ======"
|
||||
cat /tmp/mcpserver-test.log 2>&1 || echo "no log file at /tmp/mcpserver-test.log"
|
||||
echo "====== end server log ======"
|
||||
echo "====== journal (last 200 lines) ======"
|
||||
journalctl --no-pager -n 200 2>&1 | tail -n 200 || true
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f /tmp/test-server.pid ]; then
|
||||
kill -TERM $(cat /tmp/test-server.pid) 2>/dev/null || true
|
||||
sleep 2
|
||||
fi
|
||||
echo "====== server log tail (always) ======"
|
||||
tail -n 500 /tmp/mcpserver-test.log 2>&1 || echo "no log file"
|
||||
|
|
@ -1,10 +1,14 @@
|
|||
name: CI/CD Go Verification
|
||||
name: CI Go Verification
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, dev ]
|
||||
branches: [ main, dev, ai-main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
branches: [ main, dev ]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build_and_test:
|
||||
|
|
@ -29,8 +33,11 @@ jobs:
|
|||
exit 1
|
||||
fi
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: Build Application
|
||||
run: go build -v ./...
|
||||
|
||||
- name: Run Tests (with Mocked Boundaries)
|
||||
run: go test -v -cover ./...
|
||||
- name: Run Tests
|
||||
run: go test -v -race -cover ./...
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -1,3 +1,7 @@
|
|||
.env
|
||||
cdpScripts/cdp
|
||||
bin/
|
||||
bin/
|
||||
test-endpoints
|
||||
test-endpoints.exe
|
||||
test-mcp-endpoints
|
||||
test-mcp-endpoints.exe
|
||||
25
.golangci.yml
Normal file
25
.golangci.yml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
version: "2"
|
||||
linters:
|
||||
settings:
|
||||
errcheck:
|
||||
exclude-functions:
|
||||
- (*net/http.Response).Body.Close
|
||||
- io.Closer.Close
|
||||
exclusions:
|
||||
generated: lax
|
||||
presets:
|
||||
- comments
|
||||
- common-false-positives
|
||||
- legacy
|
||||
- std-error-handling
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
formatters:
|
||||
exclusions:
|
||||
generated: lax
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
97
AGENTS.md
Normal file
97
AGENTS.md
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
# AGENTS.md — naco-api
|
||||
|
||||
## Quick commands
|
||||
|
||||
```bash
|
||||
# Verify (no CI — match these locally)
|
||||
gofmt -l . # fail if any output
|
||||
go build -v ./...
|
||||
go test -v -cover ./...
|
||||
|
||||
# Single package / single test
|
||||
go test -v -cover ./internal/server/... -run TestGetPost
|
||||
go test -v -cover ./internal/bluesky/...
|
||||
|
||||
# Integration tests (requires Postgres + pgvector; never use prod DB)
|
||||
docker compose -f docker-compose.test.yml up -d # postgres:5432 + ollama:11434
|
||||
TEST_DATABASE_URL="postgres://postgres:postgres@localhost:5432/mcpserver?sslmode=disable" \
|
||||
go test -v -cover ./internal/store/...
|
||||
```
|
||||
|
||||
## What this is
|
||||
|
||||
Go MCP server (13 tools) for **Bluesky / Nostr / ActivityPub / Twitter**. Every tool is gated by **x402** (HTTP 402 crypto payments, EVM `base`); settlement → Postgres, optional **Matrix** webhook.
|
||||
|
||||
Tools: `getPost`, `getLatestPosts`, `getPostsInTime`, `getFeed` (free), `getActor`, `searchActors`, `getStyleProfile`, `findSimilarUsers`, `getEngagementMetrics`, `getPostingPatterns`, `getTopicModeling`, `getSentimentTrends`, `getEngagementDrivers`.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
cmd/server/main.go entrypoint — wires config, DB, all clients, readyz sidecar
|
||||
internal/server/ MCP handlers + x402 + analytics (~1800 lines in server.go — start there)
|
||||
server.go tool dispatch, settlement, prewarm, cache
|
||||
analytics.go analytics compute + coverage/cache logic
|
||||
backends.go platform/backend interfaces (fakes for tests)
|
||||
storefake_test.go in-memory Store fake (DB-free analytics tests)
|
||||
types.go request/response types, platform enum
|
||||
auth.go / webhook.go / cache*.go / *Middleware.go / networkInfo.go
|
||||
*_test.go split suites: mcp_test.go, analytics_test.go, validation_test.go, etc.
|
||||
internal/activitypub/ AP client; throttle.go per-host token bucket (1/s) for background GTS warming
|
||||
internal/{bluesky,nostr,twitter,ollama,config,utils,actor,x402}
|
||||
sql/schema/ 001..007 goose migrations
|
||||
sql/queries/ sqlc query files → internal/store/
|
||||
config/models.env single source of truth for EMBEDDING_MODEL / STYLE_MODEL
|
||||
testClient/rest/ manual live REST endpoint client (not part of go test)
|
||||
testClient/mcp/ manual live MCP endpoint client (not part of go test)
|
||||
```
|
||||
|
||||
## Code generation
|
||||
|
||||
`sqlc` generates `internal/store/*.go` from `sql/queries/*.sql` + `sql/schema/`:
|
||||
```bash
|
||||
sqlc generate # after editing any file in sql/
|
||||
```
|
||||
Never hand-edit `// Code generated by sqlc. DO NOT EDIT.` files.
|
||||
|
||||
## Migrations
|
||||
|
||||
Goose-style (`-- +goose up` / `-- +goose down`) in `sql/schema/` — 7 files `001_transactions.sql` … `007_add_analytics_coverage.sql`. Applied at boot when `MIGRATE_ON_START=true` (default `true`). New files must be numbered sequentially (`008_*.sql`).
|
||||
|
||||
## Testing patterns
|
||||
|
||||
- Interface fakes in `internal/server/backends.go` + `storefake_test.go` — no live clients/DB needed.
|
||||
- Call tools via `callTool(s, name, args)` through the MCP handler (see `mcp_test.go:302`).
|
||||
- Integration `internal/store/integration_test.go` skipped unless `TEST_DATABASE_URL` set — use `docker-compose.test.yml` (pgvector:pg16). Point it at a throwaway DB.
|
||||
- `internal/server` tests split by concern (`analytics_test.go`, `searchactors_test.go`, `styleprofile_test.go`, `validation_test.go`, `coverage_test.go`, `twitter_analytics_test.go`); `mcp_test.go` holds cross-cutting dispatch/cache.
|
||||
|
||||
## Config
|
||||
|
||||
Loaded in order: `config/models.env` → `.env` (gitignored, via `godotenv`) → env. `getEnv(k, fallback)` fatals if fallback `""` and var unset.
|
||||
|
||||
| Variable | Default | Notes |
|
||||
|---|---|---|
|
||||
| `GOOSE_DBSTRING` | `postgres:///mcpserver` | Server DB (not tests) |
|
||||
| `TEST_DATABASE_URL` | — | **Tests only**, separate DB |
|
||||
| `SERVER_ADDRESS` / `SERVER_PORT` | `127.0.0.1` / `8080` | Unix socket via x402 `X402Server`; TCP sidecar on `READYZ_PORT` |
|
||||
| `READYZ_PORT` | `9090` | Sidecar serves `/readyz` (DB+Ollama health) + `/metrics` |
|
||||
| `MIGRATE_ON_START` | `true` | Apply `sql/schema` on boot |
|
||||
| `OLLAMA_HOST` | `http://localhost:11434` | Models from `config/models.env` |
|
||||
| `MATRIX_WEBHOOK_URL` | — | If set, payment notifications |
|
||||
| `X402_FACILITATOR_URL` | `https://facilitator.x402.rs` | |
|
||||
| `ACTIVE_NETWORKS` | `base` | Comma eip155 IDs |
|
||||
| `ENRICH_CROSS_PLATFORM` | `true` | |
|
||||
|
||||
`findProjectRoot()` walks up to `go.mod` to locate `config/models.env`; tests with `os.Chdir` can break it.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `bin/` gitignored — `go build -o bin/mcpserver ./cmd/server/`.
|
||||
- Server DB `GOOSE_DBSTRING` vs test DB `TEST_DATABASE_URL` intentionally separate.
|
||||
- `testClient/` hits live `api.naco.li` — not run by `go test`.
|
||||
- `server.go` is large; new tool: register in `registerTools()` and add `func (s *Server) myTool(*mcp.CallToolRequest)`.
|
||||
- Unix socket MCP + TCP readyz sidecar; `mcpServer.service` expects `bin/main` at repo root.
|
||||
- Platform inferred from author handle in `determinePlatform`/`fetch*` — not caller-supplied.
|
||||
- Bluesky auth refresh via `isAuthError`/`withRefresh` — 401/expired token retried once.
|
||||
- Twitter client clamps `limit` (`>100` → 100), retries 429/5xx/transport errors with `x-rate-limit-*`/`Retry-After` backoff (bounded by `maxRateLimitWait`), and has a 60s per-request timeout.
|
||||
- ActivityPub client: GTS Mastodon API only serves locally-federated statuses. Remote actors are resolved via `/accounts/search?resolve=true` (not `/accounts/lookup`, which skips WebFinger). When the local cache can't satisfy full `limit` (clamped to 200), `GetLatestNPosts`/`GetPostsInTimeframe` fall back to walking the remote outbox directly (webfinger → actor → `OrderedCollection`/`OrderedCollectionPage`), filtering `Create`/`Note`-like activities and dereferencing IRI-only objects; `GetPost` derefs status URLs over AP when GTS `/search` can't resolve them. The `read:search` scope is needed for status-URL resolution. Outbox-fetched posts are warmed into GTS in background via `GET /api/v2/search?resolve=true&type=statuses&limit=1` per post, throttled at 1 req/s per remote host via per-host token bucket (`internal/activitypub/throttle.go`) with cross-host concurrency, no follows.
|
||||
- Coverage ranges for analytics are interval-merged; disjoint gaps must remain uncovered (see `coverage_test.go`).
|
||||
13
README.md
Normal file
13
README.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# naco-api
|
||||
|
||||
Go MCP server (13 tools) for Bluesky / Nostr / ActivityPub / Twitter, gated by x402 crypto payments.
|
||||
|
||||
See `AGENTS.md` for architecture, commands, and development conventions.
|
||||
|
||||
## Payment deadline note (TO REMEMBER)
|
||||
|
||||
`MaxTimeoutSeconds` in `internal/server/settlement.go` (`buildRequirements`) is set to **600s** — not 60s.
|
||||
|
||||
Why: for the `upto` scheme the client signs the Permit2 permit with `deadline = now + MaxTimeoutSeconds`, and the MCP wrapper **settles only after the tool handler has executed**. A slow handler (e.g. `getStyleProfile` which fetches posts and runs style analysis) plus round-trip latency therefore sits inside the permit's validity window. With 60s the permit expired before settlement and the facilitator rejected with `400 invalid_exact_evm_permit2_payload_deadline`.
|
||||
|
||||
If a new payable tool is added with a handler slower than the current deadline window, bump this value along with the HTTP/test timeouts rather than shrinking it back down.
|
||||
|
|
@ -19,9 +19,8 @@ async def main():
|
|||
my_services = [
|
||||
item
|
||||
for item in services.items
|
||||
if "api.naco.li" in getattr(item, "resource", "") or getattr(item, "type", "") != "http"
|
||||
if "api.naco.li" in getattr(item, "resource", "")
|
||||
]
|
||||
print(f'First service found: {getattr(services.items[0], "resource", "N/A")}')
|
||||
|
||||
if my_services:
|
||||
print("Matching services:")
|
||||
|
|
|
|||
138
cmd/check-facilitator/main.go
Normal file
138
cmd/check-facilitator/main.go
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nacorid/naco-api/internal/server"
|
||||
)
|
||||
|
||||
func main() {
|
||||
facilitatorURL := os.Getenv("X402_FACILITATOR_URL")
|
||||
if facilitatorURL == "" {
|
||||
facilitatorURL = "https://api.cdp.coinbase.com/platform/v2/x402"
|
||||
}
|
||||
|
||||
isMogami := strings.Contains(facilitatorURL, "mogami.tech")
|
||||
isPayAI := strings.Contains(facilitatorURL, "payai.network")
|
||||
isCDP := strings.Contains(facilitatorURL, "cdp.coinbase.com")
|
||||
|
||||
var req *http.Request
|
||||
var err error
|
||||
|
||||
if isMogami || isPayAI {
|
||||
// Mogami and PayAI facilitators don't require auth
|
||||
req, err = http.NewRequestWithContext(context.Background(), "GET", facilitatorURL+"/supported", nil)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to create request: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if isPayAI {
|
||||
fmt.Println("Using PayAI facilitator (no auth)")
|
||||
} else {
|
||||
fmt.Println("Using Mogami facilitator (no auth)")
|
||||
}
|
||||
} else if isCDP {
|
||||
keyName := os.Getenv("CDP_API_KEY_ID")
|
||||
privateKeyPEM := os.Getenv("CDP_API_SECRET")
|
||||
|
||||
if keyName == "" || privateKeyPEM == "" {
|
||||
fmt.Fprintf(os.Stderr, "CDP_API_KEY_ID and CDP_API_SECRET must be set for CDP facilitator\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Debug: print key info
|
||||
fmt.Fprintf(os.Stderr, "DEBUG: keyName=%s, secretLen=%d\n", keyName, len(privateKeyPEM))
|
||||
fmt.Fprintf(os.Stderr, "DEBUG: secretStart=%s\n", privateKeyPEM[:min(80, len(privateKeyPEM))])
|
||||
fmt.Fprintf(os.Stderr, "DEBUG: hasLiteralNewline=%v, hasActualNewline=%v\n",
|
||||
strings.Contains(privateKeyPEM, "\\n"),
|
||||
strings.Contains(privateKeyPEM, "\n"))
|
||||
|
||||
// Test key parsing manually first
|
||||
processedPEM := strings.ReplaceAll(privateKeyPEM, "\\n", "\n")
|
||||
fmt.Fprintf(os.Stderr, "DEBUG: after replaceAll, startsWith=%s\n", processedPEM[:min(50, len(processedPEM))])
|
||||
|
||||
block, _ := pem.Decode([]byte(processedPEM))
|
||||
if block != nil {
|
||||
fmt.Fprintf(os.Stderr, "DEBUG: pem block type=%s, bytes=%d\n", block.Type, len(block.Bytes))
|
||||
if strings.Contains(block.Type, "EC PRIVATE KEY") {
|
||||
if _, err := x509.ParseECPrivateKey(block.Bytes); err == nil {
|
||||
fmt.Fprintf(os.Stderr, "DEBUG: parsed as SEC1 EC key\n")
|
||||
} else if parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
|
||||
fmt.Fprintf(os.Stderr, "DEBUG: parsed as PKCS8 key, type=%T\n", parsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auth, err := server.NewCDPAuth(keyName, privateKeyPEM)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to create CDP auth: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "DEBUG: auth created, signingMethod=%v\n", auth.GetSigningMethod())
|
||||
|
||||
headers, err := auth.GetAuthHeaders(context.Background())
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to get auth headers: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Debug: print generated tokens
|
||||
for name, header := range map[string]string{
|
||||
"Verify": headers.Verify["Authorization"],
|
||||
"Settle": headers.Settle["Authorization"],
|
||||
"Supported": headers.Supported["Authorization"],
|
||||
} {
|
||||
if len(header) > 7 {
|
||||
token := header[7:] // strip "Bearer "
|
||||
if parts := strings.Split(token, "."); len(parts) == 3 {
|
||||
if payload, err := base64.RawURLEncoding.DecodeString(parts[1]); err == nil {
|
||||
fmt.Fprintf(os.Stderr, "DEBUG: %s token claims: %s\n", name, string(payload))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
req, err = http.NewRequestWithContext(context.Background(), "GET", facilitatorURL+"/supported", nil)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to create request: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
for k, v := range headers.Supported {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
fmt.Println("Using CDP facilitator (with auth)")
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Unknown facilitator URL: %s\n", facilitatorURL)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
httpClient := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Request failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
maxBytes := int64(5 * 1024 * 1024)
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes))
|
||||
if err != nil {
|
||||
fmt.Printf("failed reading response body: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Status: %d\n", resp.StatusCode)
|
||||
fmt.Printf("Response: %s\n", body)
|
||||
}
|
||||
|
|
@ -3,41 +3,75 @@ package main
|
|||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
log "github.com/nacorid/logger"
|
||||
"github.com/nacorid/naco-api/internal/activitypub"
|
||||
"github.com/nacorid/naco-api/internal/bluesky"
|
||||
"github.com/nacorid/naco-api/internal/config"
|
||||
"github.com/nacorid/naco-api/internal/nostr"
|
||||
"github.com/nacorid/naco-api/internal/ollama"
|
||||
"github.com/nacorid/naco-api/internal/server"
|
||||
"github.com/nacorid/naco-api/internal/store"
|
||||
"github.com/nacorid/naco-api/internal/twitter"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// backgroundCtx is for long-lived clients whose lifetime is the process,
|
||||
// not a single request. It must not inherit signal cancellation so that a
|
||||
// SIGTERM during startup does not poison an in-flight session creation.
|
||||
backgroundCtx := context.Background()
|
||||
|
||||
cfg := config.Load()
|
||||
|
||||
log.Init(log.Config{
|
||||
if err := log.Init(log.Config{
|
||||
LogFilePath: cfg.LogFile,
|
||||
ConsoleLevel: log.LevelDebug,
|
||||
FileLevel: log.LevelTrace,
|
||||
})
|
||||
}); err != nil {
|
||||
log.Fatalf("Failed to init logger: %v", err)
|
||||
}
|
||||
defer log.Close()
|
||||
|
||||
conn, err := sql.Open("postgres", cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to database: %v", err)
|
||||
log.Fatalf("Failed to initialize database connection: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
conn.SetMaxOpenConns(10)
|
||||
conn.SetMaxIdleConns(10)
|
||||
conn.SetConnMaxLifetime(5 * time.Minute)
|
||||
|
||||
dbCtx, dbCancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
err = conn.PingContext(dbCtx)
|
||||
dbCancel()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to ping database: %v", err)
|
||||
}
|
||||
db := store.New(conn)
|
||||
|
||||
bskyClient, err := bluesky.NewBlueskyClient(ctx, cfg.BlueskyDID, cfg.BlueskyAppPassword, cfg.BlueskyHost)
|
||||
if cfg.MigrateOnStart {
|
||||
if err := store.Migrate(ctx, conn, cfg.MigrationsDir); err != nil {
|
||||
log.Fatalf("Failed to apply migrations: %v", err)
|
||||
}
|
||||
log.Info("Database migrations applied", "dir", cfg.MigrationsDir)
|
||||
}
|
||||
|
||||
bskyClient, err := bluesky.NewBlueskyClient(backgroundCtx, cfg.BlueskyDID, cfg.BlueskyAppPassword, cfg.BlueskyHost)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to create bluesky client", "error", err)
|
||||
}
|
||||
|
|
@ -46,25 +80,107 @@ func main() {
|
|||
if err != nil {
|
||||
log.Fatal("Invalid OLLAMA_HOST URL", "error", err)
|
||||
}
|
||||
ollamaClient := ollama.New(ctx, uri, http.DefaultClient)
|
||||
ollamaHTTPClient := &http.Client{
|
||||
Timeout: 600 * time.Second,
|
||||
}
|
||||
ollamaClient := ollama.New(uri, ollamaHTTPClient, cfg.EmbeddingModel, cfg.StyleModel)
|
||||
|
||||
apClient, err := activitypub.NewAPClient(ctx, cfg.ActivityPubHost, cfg.ActivityPubToken)
|
||||
apClient, err := activitypub.NewAPClient(cfg.ActivityPubHost, cfg.ActivityPubToken)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to create activitypub client", "error", err)
|
||||
}
|
||||
|
||||
srv := server.New(ctx, db, apClient, bskyClient, ollamaClient, cfg)
|
||||
nostrClient, err := nostr.NewNostrClient(cfg.NostrRelays)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to create nostr client", "error", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
twitterClient := twitter.NewTwitterClient(cfg.TwitterBearerToken)
|
||||
|
||||
srv := server.New(ctx, db, apClient, bskyClient, nostrClient, twitterClient, ollamaClient, cfg)
|
||||
|
||||
srv.StartPrewarm(ctx)
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
startReadyzServer(g, ctx, cfg, conn)
|
||||
|
||||
g.Go(func() error {
|
||||
log.Info("Server starting", "address", cfg.ListenAddress)
|
||||
if err := srv.Start(); err != http.ErrServerClosed {
|
||||
log.Fatal("HTTP server error", "error", err)
|
||||
if err := srv.Start(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return fmt.Errorf("main server error: %w", err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
})
|
||||
|
||||
stopChan := make(chan os.Signal, 1)
|
||||
signal.Notify(stopChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-stopChan
|
||||
g.Go(func() error {
|
||||
<-gCtx.Done()
|
||||
log.Info("Shutting down main server...")
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
return srv.Shutdown(shutdownCtx)
|
||||
})
|
||||
|
||||
log.Info("Shutting down server...")
|
||||
if err := g.Wait(); err != nil && !errors.Is(err, context.Canceled) {
|
||||
log.Error("Application terminated with error", "error", err)
|
||||
}
|
||||
|
||||
log.Info("Application shutdown complete")
|
||||
}
|
||||
|
||||
func startReadyzServer(g *errgroup.Group, shutdownCtx context.Context, cfg *config.Config, conn *sql.DB) {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := conn.PingContext(r.Context()); err != nil {
|
||||
http.Error(w, "database unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, cfg.OllamaHost+"/api/tags", nil)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid ollama configuration", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil || resp.StatusCode != http.StatusOK {
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
http.Error(w, "ollama unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
}()
|
||||
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
readyServer := &http.Server{
|
||||
Addr: cfg.ReadyzAddress + ":" + cfg.ReadyzPort,
|
||||
Handler: mux,
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
g.Go(func() error {
|
||||
log.Info("Readiness server starting", "address", readyServer.Addr)
|
||||
if err := readyServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return fmt.Errorf("readiness server failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
g.Go(func() error {
|
||||
<-shutdownCtx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
return readyServer.Shutdown(shutdownCtx)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
5
config/models.env
Normal file
5
config/models.env
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Ollama model configuration
|
||||
# Single source of truth for model names used by the application and CI
|
||||
|
||||
EMBEDDING_MODEL=qwen3-embedding:0.6b
|
||||
STYLE_MODEL=qwen3:4b-instruct-2507-q4_K_M
|
||||
26
docker-compose.test.yml
Normal file
26
docker-compose.test.yml
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
environment:
|
||||
POSTGRES_DB: mcpserver
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d mcpserver"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
ports:
|
||||
- "11434:11434"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:11434/api/tags"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
22
go.mod
22
go.mod
|
|
@ -3,13 +3,14 @@ module github.com/nacorid/naco-api
|
|||
go 1.25.4
|
||||
|
||||
require (
|
||||
fiatjaf.com/nostr v0.0.0-20260827104008-28e324b57f7e
|
||||
github.com/bluesky-social/indigo v0.0.0-20251009224519-09f107c1109e
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/jsonschema-go v0.4.3
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/modelcontextprotocol/go-sdk v1.6.1
|
||||
github.com/nacorid/logger v0.0.0-20251221013547-5fc8fd94ed80
|
||||
github.com/nacorid/logger v0.0.0-20260907215822-b3c536ed7b37
|
||||
github.com/ollama/ollama v0.12.10
|
||||
github.com/x402-foundation/x402/go/v2 v2.19.0
|
||||
golang.org/x/time v0.14.0
|
||||
|
|
@ -27,15 +28,21 @@ require (
|
|||
github.com/klauspost/compress v1.18.1 // indirect
|
||||
github.com/supranational/blst v0.3.16 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sync v0.19.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect
|
||||
github.com/StackExchange/wmi v1.2.1 // indirect
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect
|
||||
github.com/btcsuite/btcd/btcutil v1.1.5 // indirect
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
|
||||
github.com/carlmjohnson/versioninfo v0.22.5 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/coder/websocket v1.8.13 // indirect
|
||||
github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
|
|
@ -59,9 +66,14 @@ require (
|
|||
github.com/ipfs/go-log/v2 v2.5.1 // indirect
|
||||
github.com/ipfs/go-metrics-interface v0.0.1 // indirect
|
||||
github.com/jbenet/goprocess v0.1.4 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/mailru/easyjson v0.9.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/minio/sha256-simd v1.0.1 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mr-tron/base58 v1.2.0 // indirect
|
||||
github.com/multiformats/go-base32 v0.1.0 // indirect
|
||||
github.com/multiformats/go-base36 v0.2.0 // indirect
|
||||
|
|
@ -70,11 +82,17 @@ require (
|
|||
github.com/multiformats/go-varint v0.0.7 // indirect
|
||||
github.com/opentracing/opentracing-go v1.2.0 // indirect
|
||||
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
|
||||
github.com/segmentio/asm v1.2.0 // indirect
|
||||
github.com/segmentio/encoding v0.5.4 // indirect
|
||||
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect
|
||||
github.com/sokkalf/slog-seq v0.5.1 // indirect
|
||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||
github.com/templexxx/cpu v0.0.1 // indirect
|
||||
github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect
|
||||
|
|
|
|||
123
go.sum
123
go.sum
|
|
@ -1,6 +1,10 @@
|
|||
fiatjaf.com/nostr v0.0.0-20260827104008-28e324b57f7e h1:BuWR1BP/CBzM+ZbLoUKuV6KlktlDZEyD9N/pdxqJJyE=
|
||||
fiatjaf.com/nostr v0.0.0-20260827104008-28e324b57f7e/go.mod h1:b1EIUDnd133Ie8Pg8O/biaKdFyCMz28aD4n64g1GqvM=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
|
||||
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
||||
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 h1:ClzzXMDDuUbWfNNZqGeYq4PnYOlwlOVIvSyNaIy0ykg=
|
||||
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3/go.mod h1:we0YA5CsBbH5+/NUzC/AlMmxaDtWlXeNsqrwXjTzmzA=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU=
|
||||
|
|
@ -9,6 +13,7 @@ github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDO
|
|||
github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8=
|
||||
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
|
||||
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
|
||||
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
|
|
@ -16,6 +21,30 @@ github.com/bits-and-blooms/bitset v1.24.2 h1:M7/NzVbsytmtfHbumG+K2bremQPMJuqv1JD
|
|||
github.com/bits-and-blooms/bitset v1.24.2/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/bluesky-social/indigo v0.0.0-20251009224519-09f107c1109e h1:6oNYIMh+kbArCQiwZYPGgfxQq88tnMyiBIZtg5clq90=
|
||||
github.com/bluesky-social/indigo v0.0.0-20251009224519-09f107c1109e/go.mod h1:RuQVrCGm42QNsgumKaR6se+XkFKfCPNwdCiTvqKRUck=
|
||||
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
|
||||
github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
|
||||
github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04=
|
||||
github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A=
|
||||
github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE=
|
||||
github.com/btcsuite/btcd/btcutil v1.1.5 h1:+wER79R5670vs/ZusMTF1yTcRYE5GUsFbdjdisflzM8=
|
||||
github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00=
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ=
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
||||
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
|
||||
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
|
||||
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
|
||||
github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY=
|
||||
github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I=
|
||||
github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
|
||||
github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
|
||||
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
|
||||
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
|
||||
github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc=
|
||||
github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
|
|
@ -32,6 +61,8 @@ github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwP
|
|||
github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
|
||||
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo=
|
||||
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ=
|
||||
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
|
||||
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||
github.com/consensys/gnark-crypto v0.19.2 h1:qrEAIXq3T4egxqiliFFoNrepkIWVEeIYwt3UL0fvS80=
|
||||
github.com/consensys/gnark-crypto v0.19.2/go.mod h1:rT23F0XSZqE0mUA0+pRtnL56IbPxs6gp4CeRsBk4XS0=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
|
|
@ -39,6 +70,7 @@ github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB
|
|||
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/crate-crypto/go-eth-kzg v1.5.0 h1:FYRiJMJG2iv+2Dy3fi14SVGjcPteZ5HAAUe4YWlJygc=
|
||||
github.com/crate-crypto/go-eth-kzg v1.5.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI=
|
||||
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
|
|
@ -46,10 +78,14 @@ github.com/dchest/siphash v1.2.3 h1:QXwFc8cFOR2dSa/gE6o/HokBMWtLUaNDVd+22aKHeEA=
|
|||
github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc=
|
||||
github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ=
|
||||
github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
|
||||
github.com/dvyukov/go-fuzz v0.0.0-20200318091601-be3528f3a813/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw=
|
||||
github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A=
|
||||
github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s=
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn27fRjSls=
|
||||
|
|
@ -62,6 +98,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
|
|||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY=
|
||||
github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps=
|
||||
github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
|
|
@ -82,10 +120,22 @@ github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXe
|
|||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||
|
|
@ -95,6 +145,7 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
|
||||
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
||||
github.com/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb1T8ac=
|
||||
|
|
@ -117,6 +168,7 @@ github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZ
|
|||
github.com/holiman/bloomfilter/v2 v2.0.3/go.mod h1:zpoh+gs7qcpqrHr3dB55AMiJwo0iURXE7ZOP9L9hSkA=
|
||||
github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA=
|
||||
github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
|
||||
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
|
||||
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
|
||||
|
|
@ -151,12 +203,20 @@ github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+
|
|||
github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5Djy1+FlA=
|
||||
github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o=
|
||||
github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4=
|
||||
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
|
||||
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
|
|
@ -174,6 +234,8 @@ github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzW
|
|||
github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
|
||||
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
|
|
@ -190,6 +252,11 @@ github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjU
|
|||
github.com/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4=
|
||||
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
|
||||
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
|
||||
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
||||
github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE=
|
||||
|
|
@ -202,10 +269,19 @@ github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7B
|
|||
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
|
||||
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
|
||||
github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU=
|
||||
github.com/nacorid/logger v0.0.0-20251221013547-5fc8fd94ed80 h1:nZwGInvJNQzPSFYpstVR/TLumqBEZt0+yUXchdyBZiI=
|
||||
github.com/nacorid/logger v0.0.0-20251221013547-5fc8fd94ed80/go.mod h1:hd9h4KEPU1f/Vv2qP0x/fZUwz2clFvOV/uIF0pkGo+s=
|
||||
github.com/nacorid/logger v0.0.0-20260907215822-b3c536ed7b37 h1:o7CvgRLUikfsbyuZIJTTrfRac5rMXhca0b61VLBWSIA=
|
||||
github.com/nacorid/logger v0.0.0-20260907215822-b3c536ed7b37/go.mod h1:hd9h4KEPU1f/Vv2qP0x/fZUwz2clFvOV/uIF0pkGo+s=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/ollama/ollama v0.12.10 h1:Dd0/SeCc+nv+FffxmWuQTGiRreib7Gt3nBhIIFuKwZA=
|
||||
github.com/ollama/ollama v0.12.10/go.mod h1:RUSmYywUWx/YZMaHrqtnT1ZChu+iSz/7jx2aO9+Mgfg=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||
github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
|
||||
github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
|
||||
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
|
||||
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
|
||||
github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8=
|
||||
|
|
@ -233,11 +309,13 @@ github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lne
|
|||
github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY=
|
||||
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
|
||||
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik=
|
||||
github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
|
||||
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
|
||||
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
|
|
@ -260,6 +338,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
|
|||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
|
|
@ -267,6 +346,17 @@ github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jq
|
|||
github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw=
|
||||
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY=
|
||||
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
|
||||
github.com/templexxx/cpu v0.0.1 h1:hY4WdLOgKdc8y13EYklu9OUTXik80BkxHoWvTO6MQQY=
|
||||
github.com/templexxx/cpu v0.0.1/go.mod h1:w7Tb+7qgcAlIyX4NhLuDKt78AHA5SzPmq0Wj6HiEnnk=
|
||||
github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b h1:XeDLE6c9mzHpdv3Wb1+pWBaWv/BlHK0ZYIu/KaL6eHg=
|
||||
github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b/go.mod h1:7rwmCH0wC2fQvNEvPZ3sKXukhyCTyiaZ5VTZMQYpZKQ=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
|
|
@ -324,6 +414,7 @@ go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ=
|
|||
go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
|
|
@ -337,25 +428,37 @@ golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKG
|
|||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
|
|
@ -371,6 +474,7 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn
|
|||
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
|
|
@ -394,6 +498,12 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
|
|||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
|
||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
|
@ -401,10 +511,15 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
|
|||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
|
|||
|
|
@ -6,149 +6,453 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nacorid/naco-api/internal/actor"
|
||||
)
|
||||
|
||||
const maxMastodonPageSize = 40
|
||||
const maxTimeframePages = 100
|
||||
const maxResponseBody = 1 << 20
|
||||
const maxLimit = 200
|
||||
|
||||
type APClient struct {
|
||||
client *http.Client
|
||||
host string
|
||||
bearerToken string
|
||||
outbox *outboxClient
|
||||
limiter *hostLimiter
|
||||
}
|
||||
|
||||
func NewAPClient(ctx context.Context, host, apikey string) (*APClient, error) {
|
||||
func NewAPClient(host, apikey string) (*APClient, error) {
|
||||
if host == "" || apikey == "" {
|
||||
return nil, fmt.Errorf("host and apikey must not be empty")
|
||||
}
|
||||
|
||||
return &APClient{
|
||||
client: http.DefaultClient,
|
||||
host: host,
|
||||
bearerToken: fmt.Sprintf("Bearer %s", apikey),
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
host: strings.TrimRight(host, "/"),
|
||||
bearerToken: "Bearer " + apikey,
|
||||
outbox: newOutboxClient(),
|
||||
limiter: newHostLimiter(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (ap *APClient) GetPost(ctx context.Context, postID string) (*Post, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/statuses/%s", ap.host, postID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
func (ap *APClient) get(ctx context.Context, endpoint string, target any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", ap.bearerToken)
|
||||
|
||||
resp, err := ap.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return fmt.Errorf("api error (status %d): %s", resp.StatusCode, string(errBody))
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBody)).Decode(target); err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ap *APClient) accountSearch(ctx context.Context, query string) (*Account, error) {
|
||||
endpoint := fmt.Sprintf("%s/api/v1/accounts/search?q=%s&resolve=true&limit=1", ap.host, url.QueryEscape(query))
|
||||
var accounts []*Account
|
||||
if err := ap.get(ctx, endpoint, &accounts); err != nil {
|
||||
return nil, fmt.Errorf("failed to search accounts: %w", err)
|
||||
}
|
||||
for _, acc := range accounts {
|
||||
if acc != nil {
|
||||
return acc, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (ap *APClient) resolveAccount(ctx context.Context, actorHandle string) (*Account, error) {
|
||||
handle := strings.TrimPrefix(actorHandle, "@")
|
||||
|
||||
acc, err := ap.accountSearch(ctx, handle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if acc != nil {
|
||||
return acc, nil
|
||||
}
|
||||
|
||||
if !strings.Contains(handle, "@") {
|
||||
var byID Account
|
||||
endpoint := fmt.Sprintf("%s/api/v1/accounts/%s", ap.host, url.PathEscape(handle))
|
||||
if err := ap.get(ctx, endpoint, &byID); err != nil {
|
||||
return nil, fmt.Errorf("account %q not found by search or id: %w", actorHandle, err)
|
||||
}
|
||||
if byID.ID == "" {
|
||||
return nil, fmt.Errorf("account %q not found", actorHandle)
|
||||
}
|
||||
return &byID, nil
|
||||
}
|
||||
return nil, fmt.Errorf("account %q not found", actorHandle)
|
||||
}
|
||||
|
||||
func (ap *APClient) GetPost(ctx context.Context, postID string) (*Post, error) {
|
||||
status, err := ap.resolveStatus(ctx, postID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return status.toPost(), nil
|
||||
}
|
||||
|
||||
func (ap *APClient) resolveStatus(ctx context.Context, postID string) (*internalPost, error) {
|
||||
if strings.HasPrefix(postID, "http://") || strings.HasPrefix(postID, "https://") || strings.HasPrefix(postID, "//") {
|
||||
if status, err := ap.searchStatus(ctx, postID); err == nil && status != nil {
|
||||
return status, nil
|
||||
}
|
||||
note, derr := ap.outbox.resolveNote(ctx, postID)
|
||||
if derr != nil {
|
||||
return nil, fmt.Errorf("status %q not resolvable: %w", postID, derr)
|
||||
}
|
||||
author := extractHandleFromURL(postID)
|
||||
return noteToInternalPost(note, author), nil
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/api/v1/statuses/%s", ap.host, url.PathEscape(postID))
|
||||
var status internalPost
|
||||
if err := ap.get(ctx, endpoint, &status); err != nil {
|
||||
return nil, fmt.Errorf("failed to get post: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to get post: %s", resp.Status)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
var internal = &internalPost{}
|
||||
if err = json.Unmarshal(body, internal); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal json response: %w", err)
|
||||
}
|
||||
return internal.toPost(), nil
|
||||
return &status, nil
|
||||
}
|
||||
|
||||
func (ap *APClient) GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*Post, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/accounts/%s/statuses?limit=%d", ap.host, actor, limit)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
func (ap *APClient) searchStatus(ctx context.Context, postID string) (*internalPost, error) {
|
||||
endpoint := fmt.Sprintf("%s/api/v2/search?q=%s&resolve=true&type=statuses&limit=1", ap.host, url.QueryEscape(postID))
|
||||
var res searchResult
|
||||
if err := ap.get(ctx, endpoint, &res); err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve status %q: %w", postID, err)
|
||||
}
|
||||
req.Header.Set("Authorization", ap.bearerToken)
|
||||
resp, err := ap.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get latest posts: %w", err)
|
||||
for _, s := range res.Statuses {
|
||||
if s != nil && (s.URL == postID || s.URI == postID) {
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to get latest posts: %s", resp.Status)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
var internal []*internalPost
|
||||
if err = json.Unmarshal(body, &internal); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal json response: %w", err)
|
||||
}
|
||||
posts := make([]*Post, len(internal))
|
||||
for i, p := range internal {
|
||||
posts[i] = p.toPost()
|
||||
}
|
||||
return posts, nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (ap *APClient) GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]Post, error) {
|
||||
var posts []Post
|
||||
var maxID string
|
||||
func (ap *APClient) GetLatestNPosts(ctx context.Context, actorID string, limit int) ([]*Post, error) {
|
||||
if limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if limit > maxLimit {
|
||||
limit = maxLimit
|
||||
}
|
||||
|
||||
for {
|
||||
acc, err := ap.resolveAccount(ctx, actorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cached, cacheErr := ap.cacheLatestPosts(ctx, acc, limit)
|
||||
if cacheErr != nil {
|
||||
return nil, cacheErr
|
||||
}
|
||||
if len(cached) >= limit {
|
||||
return cachePostsToPosts(cached), nil
|
||||
}
|
||||
|
||||
if !isRemoteAccount(acc) || acc.URL == "" {
|
||||
return cachePostsToPosts(cached), nil
|
||||
}
|
||||
|
||||
outboxPosts, apErr := ap.outbox.getLatest(ctx, acc.URL, acc.Acct, limit-len(cached))
|
||||
if apErr != nil {
|
||||
return nil, fmt.Errorf("outbox fallback failed: %w", apErr)
|
||||
}
|
||||
merged := mergePostsBySource(cached, outboxPosts)
|
||||
if len(outboxPosts) > 0 {
|
||||
urls := make([]string, 0, len(outboxPosts))
|
||||
for _, p := range outboxPosts {
|
||||
if p != nil && p.ID != "" {
|
||||
urls = append(urls, p.ID)
|
||||
}
|
||||
}
|
||||
go ap.warmGTSAsync(context.WithoutCancel(ctx), urls)
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
func (ap *APClient) cacheLatestPosts(ctx context.Context, acc *Account, limit int) ([]*internalPost, error) {
|
||||
var (
|
||||
internal []*internalPost
|
||||
maxID string
|
||||
)
|
||||
|
||||
for len(internal) < limit {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if len(posts) >= limit {
|
||||
break
|
||||
|
||||
pageSize := limit - len(internal)
|
||||
if pageSize > maxMastodonPageSize {
|
||||
pageSize = maxMastodonPageSize
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/api/v1/accounts/%s/statuses?limit=50", ap.host, actor)
|
||||
endpoint := fmt.Sprintf("%s/api/v1/accounts/%s/statuses?limit=%d", ap.host, url.PathEscape(acc.ID), pageSize)
|
||||
if maxID != "" {
|
||||
url += fmt.Sprintf("&max_id=%s", maxID)
|
||||
endpoint += "&max_id=" + url.QueryEscape(maxID)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
var page []*internalPost
|
||||
if err := ap.get(ctx, endpoint, &page); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", ap.bearerToken)
|
||||
|
||||
resp, err := ap.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get posts: %w", err)
|
||||
}
|
||||
// !!IMPORTANT!! Manually close body instead of deferring, due to loop
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("failed to get posts: %s", resp.Status)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
var internal []*internalPost
|
||||
if err := json.Unmarshal(body, &internal); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal response body: %w", err)
|
||||
}
|
||||
|
||||
if len(internal) == 0 {
|
||||
if len(page) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
for _, i := range internal {
|
||||
newMaxID := page[len(page)-1].ID
|
||||
if newMaxID == "" {
|
||||
break
|
||||
}
|
||||
if maxID != "" && newMaxID == maxID {
|
||||
break
|
||||
}
|
||||
|
||||
internal = append(internal, page...)
|
||||
maxID = newMaxID
|
||||
}
|
||||
if len(internal) > limit {
|
||||
internal = internal[:limit]
|
||||
}
|
||||
return internal, nil
|
||||
}
|
||||
|
||||
func (ap *APClient) GetPostsInTimeframe(ctx context.Context, actorID string, start, end time.Time, limit int) ([]*Post, error) {
|
||||
if limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if limit > maxLimit {
|
||||
limit = maxLimit
|
||||
}
|
||||
|
||||
acc, err := ap.resolveAccount(ctx, actorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cached, cacheErr := ap.cachePostsInTimeframe(ctx, acc, start, end, limit)
|
||||
if cacheErr != nil {
|
||||
return nil, cacheErr
|
||||
}
|
||||
if len(cached) >= limit {
|
||||
return cachePostsToPosts(cached), nil
|
||||
}
|
||||
|
||||
if !isRemoteAccount(acc) || acc.URL == "" {
|
||||
return cachePostsToPosts(cached), nil
|
||||
}
|
||||
|
||||
outboxPosts, apErr := ap.outbox.getInTimeframe(ctx, acc.URL, acc.Acct, start, end, limit-len(cached))
|
||||
if apErr != nil {
|
||||
return nil, fmt.Errorf("outbox fallback failed: %w", apErr)
|
||||
}
|
||||
merged := mergePostsBySource(cached, outboxPosts)
|
||||
if len(outboxPosts) > 0 {
|
||||
urls := make([]string, 0, len(outboxPosts))
|
||||
for _, p := range outboxPosts {
|
||||
if p != nil && p.ID != "" {
|
||||
urls = append(urls, p.ID)
|
||||
}
|
||||
}
|
||||
go ap.warmGTSAsync(context.WithoutCancel(ctx), urls)
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
func (ap *APClient) cachePostsInTimeframe(ctx context.Context, acc *Account, start, end time.Time, limit int) ([]*internalPost, error) {
|
||||
var (
|
||||
internal []*internalPost
|
||||
maxID string
|
||||
)
|
||||
|
||||
for pages := 0; pages < maxTimeframePages; pages++ {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if len(internal) >= limit {
|
||||
break
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/api/v1/accounts/%s/statuses?limit=%d", ap.host, url.PathEscape(acc.ID), maxMastodonPageSize)
|
||||
if maxID != "" {
|
||||
endpoint += "&max_id=" + url.QueryEscape(maxID)
|
||||
}
|
||||
|
||||
var page []*internalPost
|
||||
if err := ap.get(ctx, endpoint, &page); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(page) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
newMaxID := page[len(page)-1].ID
|
||||
if newMaxID == "" {
|
||||
break
|
||||
}
|
||||
if maxID != "" && newMaxID == maxID {
|
||||
break
|
||||
}
|
||||
|
||||
for _, i := range page {
|
||||
if i.CreatedAt.After(end) {
|
||||
continue
|
||||
}
|
||||
|
||||
if i.CreatedAt.Before(start) {
|
||||
return posts, nil
|
||||
return internal, nil
|
||||
}
|
||||
|
||||
posts = append(posts, *i.toPost())
|
||||
|
||||
if len(posts) >= limit {
|
||||
return posts, nil
|
||||
internal = append(internal, i)
|
||||
if len(internal) >= limit {
|
||||
return internal, nil
|
||||
}
|
||||
}
|
||||
|
||||
maxID = internal[len(internal)-1].ID
|
||||
maxID = newMaxID
|
||||
}
|
||||
|
||||
return posts, nil
|
||||
return internal, nil
|
||||
}
|
||||
|
||||
func isRemoteAccount(acc *Account) bool {
|
||||
return acc != nil && strings.Contains(acc.Acct, "@")
|
||||
}
|
||||
|
||||
func cachePostsToPosts(cached []*internalPost) []*Post {
|
||||
if len(cached) == 0 {
|
||||
return nil
|
||||
}
|
||||
posts := make([]*Post, 0, len(cached))
|
||||
for _, c := range cached {
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
posts = append(posts, c.toPost())
|
||||
}
|
||||
return posts
|
||||
}
|
||||
|
||||
func (ap *APClient) GetActor(ctx context.Context, actorHandle string) (*actor.Profile, error) {
|
||||
acc, err := ap.resolveAccount(ctx, actorHandle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ap.toProfile(acc), nil
|
||||
}
|
||||
|
||||
func (ap *APClient) SearchActors(ctx context.Context, query string, limit int) ([]*actor.Profile, error) {
|
||||
endpoint := fmt.Sprintf("%s/api/v1/accounts/search?q=%s&resolve=true&limit=%d", ap.host, url.QueryEscape(query), limit)
|
||||
var accounts []*Account
|
||||
if err := ap.get(ctx, endpoint, &accounts); err != nil {
|
||||
return nil, fmt.Errorf("failed to search actors: %w", err)
|
||||
}
|
||||
|
||||
results := make([]*actor.Profile, 0, len(accounts))
|
||||
for _, acc := range accounts {
|
||||
if acc == nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, ap.toProfile(acc))
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (ap *APClient) toProfile(acc *Account) *actor.Profile {
|
||||
return &actor.Profile{
|
||||
Platform: "ActivityPub",
|
||||
Handle: acc.Acct,
|
||||
DisplayName: acc.DisplayName,
|
||||
AvatarURL: acc.Avatar,
|
||||
Bio: acc.Note,
|
||||
FollowersCount: acc.FollowersCount,
|
||||
FollowingCount: acc.FollowingCount,
|
||||
PostCount: acc.StatusesCount,
|
||||
URL: acc.URL,
|
||||
}
|
||||
}
|
||||
|
||||
func extractHandleFromURL(postURL string) string {
|
||||
u, err := url.Parse(postURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(strings.TrimPrefix(u.Path, "/"), "/")
|
||||
for _, p := range parts {
|
||||
if strings.HasPrefix(p, "@") {
|
||||
return strings.TrimPrefix(p, "@")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (ap *APClient) warmGTSAsync(ctx context.Context, urls []string) {
|
||||
if len(urls) == 0 {
|
||||
return
|
||||
}
|
||||
if ap.limiter == nil {
|
||||
ap.limiter = newHostLimiter()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
seen := make(map[string]struct{}, len(urls))
|
||||
uniq := make([]string, 0, len(urls))
|
||||
for _, u := range urls {
|
||||
if _, ok := seen[u]; ok {
|
||||
continue
|
||||
}
|
||||
seen[u] = struct{}{}
|
||||
uniq = append(uniq, u)
|
||||
}
|
||||
if len(uniq) > 50 {
|
||||
uniq = uniq[:50]
|
||||
}
|
||||
|
||||
byHost := make(map[string][]string)
|
||||
for _, u := range uniq {
|
||||
parsed, err := url.Parse(u)
|
||||
if err != nil || parsed.Host == "" {
|
||||
continue
|
||||
}
|
||||
byHost[parsed.Host] = append(byHost[parsed.Host], u)
|
||||
}
|
||||
if len(byHost) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for host, us := range byHost {
|
||||
wg.Add(1)
|
||||
go func(host string, us []string) {
|
||||
defer wg.Done()
|
||||
for _, u := range us {
|
||||
if err := ap.limiter.Wait(ctx, host); err != nil {
|
||||
return
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
var res searchResult
|
||||
_ = ap.get(ctx, fmt.Sprintf("%s/api/v2/search?q=%s&resolve=true&type=statuses&limit=1", ap.host, url.QueryEscape(u)), &res)
|
||||
}
|
||||
}(host, us)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
|
|
|||
597
internal/activitypub/client_test.go
Normal file
597
internal/activitypub/client_test.go
Normal file
|
|
@ -0,0 +1,597 @@
|
|||
package activitypub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||
|
||||
func newTestClient(serverURL string) *APClient {
|
||||
ap, err := NewAPClient("https://example.com", "tok")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
host := strings.TrimPrefix(serverURL, "http://")
|
||||
ap.client = &http.Client{
|
||||
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
u := *r.URL
|
||||
u.Scheme = "http"
|
||||
u.Host = host
|
||||
r2 := r.Clone(r.Context())
|
||||
r2.URL = &u
|
||||
return http.DefaultTransport.RoundTrip(r2)
|
||||
}),
|
||||
}
|
||||
return ap
|
||||
}
|
||||
|
||||
func TestNewAPClientEmpty(t *testing.T) {
|
||||
if _, err := NewAPClient("", "tok"); err == nil {
|
||||
t.Fatal("expected error for empty host")
|
||||
}
|
||||
if _, err := NewAPClient("host", ""); err == nil {
|
||||
t.Fatal("expected error for empty apikey")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestNPosts(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprint(w, `[{"id":"1","acct":"alice"}]`)
|
||||
})
|
||||
mux.HandleFunc("/api/v1/accounts/1/statuses", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer tok" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("max_id") != "" {
|
||||
_, _ = fmt.Fprint(w, `[]`)
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprint(w, `[{"id":"1","content":"hello world","text":"hello world","created_at":"2024-01-01T00:00:00Z","account":{"acct":"alice"}}]`)
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
ap := newTestClient(server.URL)
|
||||
posts, err := ap.GetLatestNPosts(context.Background(), "alice", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestNPosts error: %v", err)
|
||||
}
|
||||
if len(posts) != 1 {
|
||||
t.Fatalf("expected 1 post, got %d", len(posts))
|
||||
}
|
||||
if posts[0].Content != "hello world" || posts[0].Author != "alice" {
|
||||
t.Fatalf("unexpected post: %+v", posts[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestNPostsPaginates(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprint(w, `[{"id":"1","acct":"alice"}]`)
|
||||
})
|
||||
mux.HandleFunc("/api/v1/accounts/1/statuses", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Query().Get("max_id") {
|
||||
case "40":
|
||||
ids := make([]map[string]any, 0, 10)
|
||||
for id := 41; id <= 50; id++ {
|
||||
ids = append(ids, map[string]any{
|
||||
"id": fmt.Sprintf("%d", id), "content": fmt.Sprintf("post %d", id), "created_at": "2024-01-01T00:00:00Z",
|
||||
"account": map[string]any{"acct": "alice"},
|
||||
})
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(ids)
|
||||
case "":
|
||||
ids := make([]map[string]any, 0, 40)
|
||||
for id := 1; id <= 40; id++ {
|
||||
ids = append(ids, map[string]any{
|
||||
"id": fmt.Sprintf("%d", id), "content": fmt.Sprintf("post %d", id), "created_at": "2024-01-01T00:00:00Z",
|
||||
"account": map[string]any{"acct": "alice"},
|
||||
})
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(ids)
|
||||
default:
|
||||
_ = json.NewEncoder(w).Encode([]map[string]any{})
|
||||
}
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
ap := newTestClient(server.URL)
|
||||
posts, err := ap.GetLatestNPosts(context.Background(), "alice", 50)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestNPosts error: %v", err)
|
||||
}
|
||||
if len(posts) != 50 {
|
||||
t.Fatalf("expected 50 posts across 2 pages, got %d", len(posts))
|
||||
}
|
||||
if posts[49].Content != "post 50" {
|
||||
t.Fatalf("expected last post 'post 50', got %q", posts[49].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestNPostsZeroLimit(t *testing.T) {
|
||||
ap := newTestClient("http://example.com")
|
||||
posts, err := ap.GetLatestNPosts(context.Background(), "alice", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestNPosts with zero limit error: %v", err)
|
||||
}
|
||||
if len(posts) != 0 {
|
||||
t.Fatalf("expected no posts for zero limit, got %d", len(posts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestNPostsLimitClamp(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprint(w, `[{"id":"1","acct":"alice"}]`)
|
||||
})
|
||||
mux.HandleFunc("/api/v1/accounts/1/statuses", func(w http.ResponseWriter, r *http.Request) {
|
||||
limit := r.URL.Query().Get("limit")
|
||||
l, _ := strconv.Atoi(limit)
|
||||
if l > maxLimit {
|
||||
t.Fatalf("expected limit <= %d, got %s", maxLimit, limit)
|
||||
}
|
||||
_, _ = fmt.Fprint(w, `[{"id":"1","content":"hello","created_at":"2024-01-01T00:00:00Z","account":{"acct":"alice"}}]`)
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
ap := newTestClient(server.URL)
|
||||
posts, err := ap.GetLatestNPosts(context.Background(), "alice", 500)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestNPosts error: %v", err)
|
||||
}
|
||||
if len(posts) == 0 {
|
||||
t.Fatal("expected at least one post")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPost(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/statuses/42", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprint(w, `{"id":"42","content":"a post","text":"a post","created_at":"2024-01-01T00:00:00Z","account":{"acct":"bob"}}`)
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
ap := newTestClient(server.URL)
|
||||
post, err := ap.GetPost(context.Background(), "42")
|
||||
if err != nil {
|
||||
t.Fatalf("GetPost error: %v", err)
|
||||
}
|
||||
if post.Content != "a post" || post.Author != "bob" {
|
||||
t.Fatalf("unexpected post: %+v", post)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPostURLResolves(t *testing.T) {
|
||||
url := "https://inst.social/@alice/statuses/42"
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v2/search", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("q") != url {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("type") != "statuses" || r.URL.Query().Get("resolve") != "true" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprint(w, `{"accounts":[],"statuses":[{"id":"42","uri":"`+url+`","url":"`+url+`","content":"resolved post","created_at":"2024-01-01T00:00:00Z","account":{"acct":"bob@inst.social"}}]}`)
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
ap := newTestClient(server.URL)
|
||||
post, err := ap.GetPost(context.Background(), url)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPost error: %v", err)
|
||||
}
|
||||
if post.Content != "resolved post" || post.Author != "bob@inst.social" {
|
||||
t.Fatalf("unexpected post: %+v", post)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPostURLNotFound(t *testing.T) {
|
||||
derefTarget := httptest.NewServer(http.NotFoundHandler())
|
||||
defer derefTarget.Close()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v2/search", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprint(w, `{"accounts":[],"statuses":[]}`)
|
||||
})
|
||||
gts := httptest.NewServer(mux)
|
||||
defer gts.Close()
|
||||
|
||||
ap, err := NewAPClient(gts.URL, "tok")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ap.GetPost(context.Background(), derefTarget.URL+"/missed"); err == nil {
|
||||
t.Fatal("expected error when search and dereference both return nothing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPostURLSearchReturnsWrongPost(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v2/search", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprint(w, `{"accounts":[],"statuses":[{"id":"wrong","uri":"https://other.com/1","content":"wrong post"}]}`)
|
||||
})
|
||||
gts := httptest.NewServer(mux)
|
||||
defer gts.Close()
|
||||
|
||||
ap, err := NewAPClient(gts.URL, "tok")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ap.GetPost(context.Background(), "https://inst.social/@alice/statuses/42"); err == nil {
|
||||
t.Fatal("expected error when search returns no matching post")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPostURLDerefFallback(t *testing.T) {
|
||||
remote, _ := newRemoteServer()
|
||||
defer remote.Close()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v2/search", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
})
|
||||
gts := httptest.NewServer(mux)
|
||||
defer gts.Close()
|
||||
|
||||
ap, err := NewAPClient(gts.URL, "tok")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
post, err := ap.GetPost(context.Background(), remote.URL+"/notes/2")
|
||||
if err != nil {
|
||||
t.Fatalf("GetPost error: %v", err)
|
||||
}
|
||||
if post.Content != "deref note 2" {
|
||||
t.Fatalf("expected dereferenced note, got %+v", post)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestNPostsRemoteFallsBackToOutbox(t *testing.T) {
|
||||
remote, remoteBase := newRemoteServer()
|
||||
defer remote.Close()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprintf(w, `[{"id":"1","acct":"alice@example.social","url":%q}]`, remoteBase)
|
||||
})
|
||||
mux.HandleFunc("/api/v1/accounts/1/statuses", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("max_id") != "" {
|
||||
_, _ = fmt.Fprint(w, `[]`)
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprint(w, `[{"id":"c1","content":"cached","uri":`+`"`+remoteBase+`/notes/9","created_at":"2024-03-10T00:00:00Z","account":{"acct":"alice@example.social"}}]`)
|
||||
})
|
||||
gts := httptest.NewServer(mux)
|
||||
defer gts.Close()
|
||||
|
||||
ap, err := NewAPClient(gts.URL, "tok")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
posts, err := ap.GetLatestNPosts(context.Background(), "@alice@example.social", 5)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestNPosts error: %v", err)
|
||||
}
|
||||
if len(posts) != 5 {
|
||||
t.Fatalf("expected 5 merged posts, got %d: %+v", len(posts), posts)
|
||||
}
|
||||
if posts[0].Content != "cached" {
|
||||
t.Fatalf("expected newest cached first, got %q", posts[0].Content)
|
||||
}
|
||||
if posts[4].Content != "note 4" {
|
||||
t.Fatalf("expected oldest outbox post last, got %q", posts[4].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestNPostsRemoteEmptyCacheFallsBack(t *testing.T) {
|
||||
remote, remoteBase := newRemoteServer()
|
||||
defer remote.Close()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprintf(w, `[{"id":"1","acct":"alice@example.social","url":%q}]`, remoteBase)
|
||||
})
|
||||
mux.HandleFunc("/api/v1/accounts/1/statuses", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("max_id") != "" {
|
||||
_, _ = fmt.Fprint(w, `[]`)
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprint(w, `[]`)
|
||||
})
|
||||
gts := httptest.NewServer(mux)
|
||||
defer gts.Close()
|
||||
|
||||
ap, err := NewAPClient(gts.URL, "tok")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
posts, err := ap.GetLatestNPosts(context.Background(), "@alice@example.social", 4)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestNPosts error: %v", err)
|
||||
}
|
||||
if len(posts) != 4 {
|
||||
t.Fatalf("expected all 4 posts from outbox, got %d", len(posts))
|
||||
}
|
||||
if posts[0].Content != "bare note 3" {
|
||||
t.Fatalf("expected outbox post newest-first, got %q", posts[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPostsInTimeframeRemoteFallsBackToOutbox(t *testing.T) {
|
||||
remote, remoteBase := newRemoteServer()
|
||||
defer remote.Close()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprintf(w, `[{"id":"1","acct":"alice@example.social","url":%q}]`, remoteBase)
|
||||
})
|
||||
mux.HandleFunc("/api/v1/accounts/1/statuses", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("max_id") != "" {
|
||||
_, _ = fmt.Fprint(w, `[]`)
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprint(w, `[{"id":"c1","content":"cached in window","uri":`+`"`+remoteBase+`/notes/5","created_at":"2024-02-20T00:00:00Z","account":{"acct":"alice@example.social"}}]`)
|
||||
})
|
||||
gts := httptest.NewServer(mux)
|
||||
defer gts.Close()
|
||||
|
||||
ap, err := NewAPClient(gts.URL, "tok")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
end := time.Date(2024, 3, 2, 0, 0, 0, 0, time.UTC)
|
||||
posts, err := ap.GetPostsInTimeframe(context.Background(), "@alice@example.social", start, end, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPostsInTimeframe error: %v", err)
|
||||
}
|
||||
if len(posts) != 3 {
|
||||
t.Fatalf("expected 3 in-window posts, got %d: %+v", len(posts), posts)
|
||||
}
|
||||
if posts[0].Content != "created note 1" || posts[1].Content != "cached in window" || posts[2].Content != "deref note 2" {
|
||||
t.Fatalf("unexpected merged order: %+v", posts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPostsInTimeframe(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprint(w, `[{"id":"1","acct":"alice"}]`)
|
||||
})
|
||||
mux.HandleFunc("/api/v1/accounts/1/statuses", func(w http.ResponseWriter, r *http.Request) {
|
||||
feed := []map[string]any{}
|
||||
switch r.URL.Query().Get("max_id") {
|
||||
case "":
|
||||
feed = []map[string]any{
|
||||
{"id": "5", "content": "too-new", "text": "too-new", "created_at": "2025-03-01T00:00:00Z", "account": map[string]any{"acct": "alice"}},
|
||||
{"id": "4", "content": "mid", "text": "mid", "created_at": "2024-06-01T00:00:00Z", "account": map[string]any{"acct": "alice"}},
|
||||
}
|
||||
case "4":
|
||||
feed = []map[string]any{
|
||||
{"id": "3", "content": "early-mid", "text": "early-mid", "created_at": "2024-03-01T00:00:00Z", "account": map[string]any{"acct": "alice"}},
|
||||
{"id": "2", "content": "too-old", "text": "too-old", "created_at": "2023-01-01T00:00:00Z", "account": map[string]any{"acct": "alice"}},
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(feed); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
ap := newTestClient(server.URL)
|
||||
start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
end := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC)
|
||||
posts, err := ap.GetPostsInTimeframe(context.Background(), "alice", start, end, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPostsInTimeframe error: %v", err)
|
||||
}
|
||||
if len(posts) != 2 {
|
||||
t.Fatalf("expected 2 in-window posts, got %d", len(posts))
|
||||
}
|
||||
if posts[0].Content != "mid" || posts[1].Content != "early-mid" {
|
||||
t.Fatalf("unexpected posts: %+v", posts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestNPostsHTTPError(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprint(w, `[{"id":"1","acct":"alice"}]`)
|
||||
})
|
||||
mux.HandleFunc("/api/v1/accounts/1/statuses", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
ap := newTestClient(server.URL)
|
||||
if _, err := ap.GetLatestNPosts(context.Background(), "alice", 10); err == nil {
|
||||
t.Fatal("expected error on non-200 status from timeline")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetActor(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("q") != "alice" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprint(w, `[{"id":"1","acct":"alice@inst.social","display_name":"Alice","note":"hello there","avatar":"https://img/avatar.png","followers_count":10,"following_count":5,"statuses_count":3,"url":"https://inst.social/@alice"}]`)
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
ap := newTestClient(server.URL)
|
||||
prof, err := ap.GetActor(context.Background(), "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("GetActor error: %v", err)
|
||||
}
|
||||
if prof.Platform != "ActivityPub" || prof.Handle != "alice@inst.social" || prof.DisplayName != "Alice" {
|
||||
t.Fatalf("unexpected profile: %+v", prof)
|
||||
}
|
||||
if prof.Bio != "hello there" || prof.AvatarURL != "https://img/avatar.png" {
|
||||
t.Fatalf("unexpected profile fields: %+v", prof)
|
||||
}
|
||||
if prof.FollowersCount != 10 || prof.FollowingCount != 5 || prof.PostCount != 3 {
|
||||
t.Fatalf("unexpected counts: %+v", prof)
|
||||
}
|
||||
if prof.URL != "https://inst.social/@alice" {
|
||||
t.Fatalf("unexpected url: %q", prof.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetActorIDFallback(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprint(w, `[]`)
|
||||
})
|
||||
mux.HandleFunc("/api/v1/accounts/alice", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprint(w, `{"id":"1","acct":"alice@inst.social","display_name":"Alice","note":"hi","avatar":"https://img/a.png","followers_count":1,"following_count":2,"statuses_count":3,"url":"https://x"}`)
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
ap := newTestClient(server.URL)
|
||||
prof, err := ap.GetActor(context.Background(), "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("GetActor error: %v", err)
|
||||
}
|
||||
if prof.Handle != "alice@inst.social" {
|
||||
t.Fatalf("expected fallback id lookup to succeed, got %+v", prof)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetActorAtLocalHandle(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query().Get("q")
|
||||
if q != "@alice" && q != "alice" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprint(w, `[{"id":"1","acct":"alice","display_name":"Alice","note":"hi","avatar":"https://img/a.png","followers_count":1,"following_count":2,"statuses_count":3,"url":"https://x"}]`)
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
ap := newTestClient(server.URL)
|
||||
prof, err := ap.GetActor(context.Background(), "@alice")
|
||||
if err != nil {
|
||||
t.Fatalf("GetActor error: %v", err)
|
||||
}
|
||||
if prof.Handle != "alice" {
|
||||
t.Fatalf("expected local handle, got %q", prof.Handle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchActors(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("q") != "alice" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("resolve") != "true" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprint(w, `[{"id":"1","acct":"alice@inst.social","display_name":"Alice","note":"bio","avatar":"https://img/a.png","followers_count":1,"following_count":2,"statuses_count":3,"url":"https://x"}]`)
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
ap := newTestClient(server.URL)
|
||||
results, err := ap.SearchActors(context.Background(), "alice", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchActors error: %v", err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(results))
|
||||
}
|
||||
if results[0].Platform != "ActivityPub" || results[0].Handle != "alice@inst.social" {
|
||||
t.Fatalf("unexpected result: %+v", results[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractHandleFromURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
url string
|
||||
expected string
|
||||
}{
|
||||
{"https://inst.social/@alice/statuses/42", "alice"},
|
||||
{"http://example.com/@bob/posts/1", "bob"},
|
||||
{"https://no-at-here.com/notes/1", ""},
|
||||
{"https://host/@user/note", "user"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := extractHandleFromURL(tt.url)
|
||||
if got != tt.expected {
|
||||
t.Fatalf("extractHandleFromURL(%q) = %q, want %q", tt.url, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPostBareLocalHandle(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query().Get("q")
|
||||
if q != "@alice" && q != "alice" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprint(w, `[{"id":"1","acct":"alice"}]`)
|
||||
})
|
||||
mux.HandleFunc("/api/v1/statuses/alice", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprint(w, `{"id":"1","content":"local post","created_at":"2024-01-01T00:00:00Z","account":{"acct":"alice"}}`)
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
ap := newTestClient(server.URL)
|
||||
_, err := ap.GetPost(context.Background(), "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("GetPost with local handle error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchActorsNilEntries(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprint(w, `[null,{"id":"1","acct":"alice@inst.social"}]`)
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
ap := newTestClient(server.URL)
|
||||
results, err := ap.SearchActors(context.Background(), "alice", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchActors error: %v", err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 result after filtering nil, got %d", len(results))
|
||||
}
|
||||
if results[0].Handle != "alice@inst.social" {
|
||||
t.Fatalf("unexpected result: %+v", results[0])
|
||||
}
|
||||
}
|
||||
501
internal/activitypub/outbox.go
Normal file
501
internal/activitypub/outbox.go
Normal file
|
|
@ -0,0 +1,501 @@
|
|||
package activitypub
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const outboxMaxPages = 100
|
||||
|
||||
var webfingerScheme = "https"
|
||||
|
||||
type outboxClient struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type apStringOrArray []string
|
||||
|
||||
func (s *apStringOrArray) UnmarshalJSON(b []byte) error {
|
||||
b = bytes.TrimSpace(b)
|
||||
if len(b) == 0 || string(b) == "null" {
|
||||
*s = nil
|
||||
return nil
|
||||
}
|
||||
if b[0] == '"' {
|
||||
var v string
|
||||
if err := json.Unmarshal(b, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
*s = []string{v}
|
||||
return nil
|
||||
}
|
||||
var arr []string
|
||||
if err := json.Unmarshal(b, &arr); err != nil {
|
||||
return err
|
||||
}
|
||||
*s = arr
|
||||
return nil
|
||||
}
|
||||
|
||||
type apIRI string
|
||||
|
||||
func (i *apIRI) UnmarshalJSON(b []byte) error {
|
||||
b = bytes.TrimSpace(b)
|
||||
if len(b) == 0 || string(b) == "null" {
|
||||
*i = ""
|
||||
return nil
|
||||
}
|
||||
if b[0] == '"' {
|
||||
var s string
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
*i = apIRI(s)
|
||||
return nil
|
||||
}
|
||||
var o struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &o); err != nil {
|
||||
return err
|
||||
}
|
||||
*i = apIRI(o.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
type apRef struct {
|
||||
IRI string
|
||||
Inline json.RawMessage
|
||||
}
|
||||
|
||||
func (r *apRef) UnmarshalJSON(b []byte) error {
|
||||
b = bytes.TrimSpace(b)
|
||||
if len(b) == 0 || string(b) == "null" {
|
||||
r.IRI = ""
|
||||
r.Inline = nil
|
||||
return nil
|
||||
}
|
||||
if b[0] == '"' {
|
||||
var s string
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
r.IRI = s
|
||||
return nil
|
||||
}
|
||||
r.Inline = append([]byte(nil), b...)
|
||||
return nil
|
||||
}
|
||||
|
||||
type apActor struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
PreferredUsername string `json:"preferredUsername"`
|
||||
URL string `json:"url"`
|
||||
Outbox apIRI `json:"outbox"`
|
||||
Inbox apIRI `json:"inbox"`
|
||||
}
|
||||
|
||||
type apActivity struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Published string `json:"published"`
|
||||
Content string `json:"content"`
|
||||
URL apStringOrArray `json:"url"`
|
||||
Object json.RawMessage `json:"object"`
|
||||
}
|
||||
|
||||
type apCollection struct {
|
||||
Type string `json:"type"`
|
||||
First apRef `json:"first"`
|
||||
}
|
||||
|
||||
type apPage struct {
|
||||
Type string `json:"type"`
|
||||
OrderedItems json.RawMessage `json:"orderedItems"`
|
||||
Next apRef `json:"next"`
|
||||
}
|
||||
|
||||
type apNote struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Published string `json:"published"`
|
||||
Content string `json:"content"`
|
||||
URL apStringOrArray `json:"url"`
|
||||
}
|
||||
|
||||
type webfingerResponse struct {
|
||||
Subject string `json:"subject"`
|
||||
Links []struct {
|
||||
Rel string `json:"rel"`
|
||||
Type string `json:"type"`
|
||||
Href string `json:"href"`
|
||||
} `json:"links"`
|
||||
}
|
||||
|
||||
func newOutboxClient() *outboxClient {
|
||||
return &outboxClient{client: &http.Client{Timeout: 30 * time.Second}}
|
||||
}
|
||||
|
||||
func (oc *outboxClient) getJSON(ctx context.Context, endpoint string, target any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", `application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams", application/json`)
|
||||
|
||||
resp, err := oc.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return fmt.Errorf("api error (status %d): %s", resp.StatusCode, string(errBody))
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBody)).Decode(target); err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (oc *outboxClient) webfinger(ctx context.Context, handle string) (string, error) {
|
||||
full := strings.TrimPrefix(handle, "@")
|
||||
at := strings.LastIndex(full, "@")
|
||||
if at <= 0 || at == len(full)-1 {
|
||||
return "", fmt.Errorf("invalid handle %q", handle)
|
||||
}
|
||||
domain := full[at+1:]
|
||||
|
||||
endpoint := fmt.Sprintf("%s://%s/.well-known/webfinger?resource=%s", webfingerScheme, domain, url.QueryEscape("acct:"+full))
|
||||
var wf webfingerResponse
|
||||
if err := oc.getJSON(ctx, endpoint, &wf); err != nil {
|
||||
return "", fmt.Errorf("webfinger lookup for %q failed: %w", full, err)
|
||||
}
|
||||
|
||||
var htmlSelf string
|
||||
for _, l := range wf.Links {
|
||||
if l.Rel != "self" || l.Href == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(l.Type, "activity+json") || strings.Contains(l.Type, "ld+json") {
|
||||
return l.Href, nil
|
||||
}
|
||||
if htmlSelf == "" && strings.Contains(l.Type, "html") {
|
||||
htmlSelf = l.Href
|
||||
}
|
||||
}
|
||||
if htmlSelf != "" {
|
||||
return htmlSelf, nil
|
||||
}
|
||||
return "", fmt.Errorf("webfinger for %q returned no self link", full)
|
||||
}
|
||||
|
||||
func (oc *outboxClient) getActor(ctx context.Context, actorURL string) (*apActor, error) {
|
||||
var actor apActor
|
||||
if err := oc.getJSON(ctx, actorURL, &actor); err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch actor %q: %w", actorURL, err)
|
||||
}
|
||||
if actor.ID == "" && actor.Outbox == "" {
|
||||
return nil, fmt.Errorf("response from %q is not an actor document", actorURL)
|
||||
}
|
||||
return &actor, nil
|
||||
}
|
||||
|
||||
func (oc *outboxClient) resolveActor(ctx context.Context, actorURL, handle string) (*apActor, error) {
|
||||
if actorURL == "" {
|
||||
var err error
|
||||
actorURL, err = oc.webfinger(ctx, handle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return oc.getActor(ctx, actorURL)
|
||||
}
|
||||
|
||||
func (oc *outboxClient) getLatest(ctx context.Context, actorURL, author string, limit int) ([]*Post, error) {
|
||||
if limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return oc.walk(ctx, actorURL, author, limit, func(*Post) bool { return true })
|
||||
}
|
||||
|
||||
func (oc *outboxClient) getInTimeframe(ctx context.Context, actorURL, author string, start, end time.Time, limit int) ([]*Post, error) {
|
||||
if limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return oc.walk(ctx, actorURL, author, limit, func(p *Post) bool {
|
||||
return !p.CreatedAt.Before(start) && !p.CreatedAt.After(end)
|
||||
})
|
||||
}
|
||||
|
||||
func (oc *outboxClient) walk(ctx context.Context, actorURL, author string, limit int, include func(*Post) bool) ([]*Post, error) {
|
||||
actor, err := oc.resolveActor(ctx, actorURL, author)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if actor.Outbox == "" {
|
||||
return nil, fmt.Errorf("actor %q exposes no outbox", actorURL)
|
||||
}
|
||||
|
||||
var posts []*Post
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
cur, err := oc.firstPage(ctx, string(actor.Outbox))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for pages := 0; pages < outboxMaxPages; pages++ {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if len(posts) >= limit {
|
||||
break
|
||||
}
|
||||
|
||||
page, ok, err := oc.getPage(ctx, cur)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
|
||||
var items []apActivity
|
||||
if len(page.OrderedItems) > 0 {
|
||||
if err := json.Unmarshal(page.OrderedItems, &items); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode outbox page items: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
p, valid, err := oc.itemToPost(ctx, item, author)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !valid || p == nil {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[p.ID]; dup {
|
||||
continue
|
||||
}
|
||||
seen[p.ID] = struct{}{}
|
||||
if include(p) {
|
||||
posts = append(posts, p)
|
||||
if len(posts) >= limit {
|
||||
return posts, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cur = page.Next
|
||||
if cur.IRI == "" && len(cur.Inline) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
func (oc *outboxClient) firstPage(ctx context.Context, outbox string) (apRef, error) {
|
||||
var coll apCollection
|
||||
if err := oc.getJSON(ctx, outbox, &coll); err != nil {
|
||||
return apRef{}, fmt.Errorf("failed to fetch outbox %q: %w", outbox, err)
|
||||
}
|
||||
if coll.Type != "" && coll.Type != "OrderedCollection" {
|
||||
return apRef{}, fmt.Errorf("outbox %q has unexpected type %q", outbox, coll.Type)
|
||||
}
|
||||
if coll.First.IRI == "" && len(coll.First.Inline) == 0 {
|
||||
return apRef{}, fmt.Errorf("outbox %q exposes no pages", outbox)
|
||||
}
|
||||
return coll.First, nil
|
||||
}
|
||||
|
||||
func (oc *outboxClient) getPage(ctx context.Context, ref apRef) (apPage, bool, error) {
|
||||
var page apPage
|
||||
switch {
|
||||
case ref.IRI != "":
|
||||
if err := oc.getJSON(ctx, ref.IRI, &page); err != nil {
|
||||
return apPage{}, false, fmt.Errorf("failed to fetch outbox page %q: %w", ref.IRI, err)
|
||||
}
|
||||
case len(ref.Inline) > 0:
|
||||
if err := json.Unmarshal(ref.Inline, &page); err != nil {
|
||||
return apPage{}, false, fmt.Errorf("failed to decode inline outbox page: %w", err)
|
||||
}
|
||||
default:
|
||||
return apPage{}, false, nil
|
||||
}
|
||||
return page, true, nil
|
||||
}
|
||||
|
||||
func (oc *outboxClient) itemToPost(ctx context.Context, item apActivity, author string) (*Post, bool, error) {
|
||||
var note *apNote
|
||||
var err error
|
||||
|
||||
switch item.Type {
|
||||
case "Create", "Update":
|
||||
note, err = oc.noteFromObject(ctx, item.Object)
|
||||
case "Note", "Article", "Question", "Page", "":
|
||||
note = &apNote{
|
||||
ID: item.ID,
|
||||
Type: item.Type,
|
||||
Published: item.Published,
|
||||
Content: item.Content,
|
||||
URL: item.URL,
|
||||
}
|
||||
default:
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if note == nil || !isPostNote(note.Type) {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
id := note.ID
|
||||
if id == "" && len(note.URL) > 0 {
|
||||
id = note.URL[0]
|
||||
}
|
||||
if id == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
created, ok := parseActivityTime(note.Published)
|
||||
if !ok || created.IsZero() {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
return &Post{
|
||||
ID: id,
|
||||
Author: author,
|
||||
Content: note.Content,
|
||||
CreatedAt: created,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func (oc *outboxClient) noteFromObject(ctx context.Context, raw json.RawMessage) (*apNote, error) {
|
||||
raw = bytes.TrimSpace(raw)
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
if raw[0] == '"' {
|
||||
var id string
|
||||
if err := json.Unmarshal(raw, &id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return oc.fetchNote(ctx, id)
|
||||
}
|
||||
var n apNote
|
||||
if err := json.Unmarshal(raw, &n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &n, nil
|
||||
}
|
||||
|
||||
func (oc *outboxClient) fetchNote(ctx context.Context, iri string) (*apNote, error) {
|
||||
var n apNote
|
||||
if err := oc.getJSON(ctx, iri, &n); err != nil {
|
||||
return nil, fmt.Errorf("failed to dereference object %q: %w", iri, err)
|
||||
}
|
||||
return &n, nil
|
||||
}
|
||||
|
||||
func (oc *outboxClient) resolveNote(ctx context.Context, url string) (*apNote, error) {
|
||||
var act apActivity
|
||||
if err := oc.getJSON(ctx, url, &act); err != nil {
|
||||
return nil, fmt.Errorf("failed to dereference status %q: %w", url, err)
|
||||
}
|
||||
switch act.Type {
|
||||
case "Create":
|
||||
return oc.noteFromObject(ctx, act.Object)
|
||||
case "Note", "Article", "Question", "Page", "":
|
||||
return &apNote{
|
||||
ID: act.ID,
|
||||
Type: act.Type,
|
||||
Published: act.Published,
|
||||
Content: act.Content,
|
||||
URL: act.URL,
|
||||
}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("document at %q is not a post (type %q)", url, act.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func isPostNote(t string) bool {
|
||||
switch t {
|
||||
case "", "Note", "Article", "Question", "Page":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseActivityTime(s string) (time.Time, bool) {
|
||||
if s == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
for _, layout := range []string{time.RFC3339, time.RFC3339Nano, "2006-01-02T15:04:05"} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func noteToInternalPost(n *apNote, author string) *internalPost {
|
||||
p := &internalPost{
|
||||
ID: n.ID,
|
||||
URI: n.ID,
|
||||
Content: n.Content,
|
||||
}
|
||||
if len(n.URL) > 0 {
|
||||
p.URL = n.URL[0]
|
||||
}
|
||||
if t, ok := parseActivityTime(n.Published); ok {
|
||||
p.CreatedAt = t
|
||||
}
|
||||
if author != "" {
|
||||
p.Account = Account{Acct: author}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func mergePostsBySource(cached []*internalPost, outbox []*Post) []*Post {
|
||||
seen := make(map[string]struct{}, len(cached))
|
||||
res := make([]*Post, 0, len(cached)+len(outbox))
|
||||
for _, c := range cached {
|
||||
key := c.URI
|
||||
if key == "" {
|
||||
key = c.ID
|
||||
}
|
||||
if key != "" {
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
if c != nil {
|
||||
res = append(res, c.toPost())
|
||||
}
|
||||
}
|
||||
for _, p := range outbox {
|
||||
if p == nil || p.ID == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[p.ID]; dup {
|
||||
continue
|
||||
}
|
||||
seen[p.ID] = struct{}{}
|
||||
res = append(res, p)
|
||||
}
|
||||
sort.SliceStable(res, func(i, j int) bool { return res[i].CreatedAt.After(res[j].CreatedAt) })
|
||||
return res
|
||||
}
|
||||
224
internal/activitypub/outbox_test.go
Normal file
224
internal/activitypub/outbox_test.go
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
package activitypub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// newRemoteServer serves a synthetic ActivityPub instance: a webfinger
|
||||
// endpoint, an actor with an outbox collection, two outbox pages (with mixed
|
||||
// activity types and an IRI-referenced object) and a dereferenceable note.
|
||||
// Returns the server and the actor's canonical URL.
|
||||
func newRemoteServer() (*httptest.Server, string) {
|
||||
base := ""
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body string
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/.well-known/webfinger"):
|
||||
body = `{"subject":"acct:alice@example.social","links":[` +
|
||||
`{"rel":"self","type":"application/activity+json","href":"` + base + `/users/alice"},` +
|
||||
`{"rel":"http://webfinger.net/rel/profile-page","type":"text/html","href":"` + base + `/@alice"}]}`
|
||||
case r.URL.Path == "/users/alice":
|
||||
body = `{"id":"` + base + `/users/alice","type":"Person","preferredUsername":"alice","url":"` + base + `/@alice","outbox":"` + base + `/users/alice/outbox"}`
|
||||
case r.URL.Path == "/users/alice/outbox" && r.URL.Query().Get("page") == "":
|
||||
body = `{"type":"OrderedCollection","totalItems":5,"first":"` + base + `/users/alice/outbox?page=1"}`
|
||||
case r.URL.Path == "/users/alice/outbox" && r.URL.Query().Get("page") == "1":
|
||||
body = `{"type":"OrderedCollectionPage","partOf":"` + base + `/users/alice/outbox","orderedItems":[` +
|
||||
`{"type":"Create","published":"2024-03-01T00:00:00Z","object":{"type":"Note","id":"` + base + `/notes/1","published":"2024-03-01T00:00:00Z","content":"created note 1","url":"` + base + `/@alice/statuses/1"}},` +
|
||||
`{"type":"Announce","object":"` + base + `/notes/9"},` +
|
||||
`{"type":"Like","object":"` + base + `/notes/8"},` +
|
||||
`{"type":"Create","object":"` + base + `/notes/2"},` +
|
||||
`{"type":"Note","id":"` + base + `/notes/3","published":"2024-03-03T00:00:00Z","content":"bare note 3","url":["` + base + `/@alice/statuses/3"]}` +
|
||||
`],"next":"` + base + `/users/alice/outbox?page=2"}`
|
||||
case r.URL.Path == "/users/alice/outbox" && r.URL.Query().Get("page") == "2":
|
||||
body = `{"type":"OrderedCollectionPage","orderedItems":[` +
|
||||
`{"type":"Create","object":{"type":"Note","id":"` + base + `/notes/4","published":"2024-01-15T00:00:00Z","content":"note 4"}}` +
|
||||
`]}`
|
||||
case r.URL.Path == "/notes/2":
|
||||
body = `{"type":"Note","id":"` + base + `/notes/2","published":"2024-02-01T00:00:00Z","content":"deref note 2","url":"` + base + `/@alice/statuses/2"}`
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/activity+json")
|
||||
_, _ = fmt.Fprint(w, body)
|
||||
}))
|
||||
base = server.URL
|
||||
return server, base + "/users/alice"
|
||||
}
|
||||
|
||||
func TestOutboxGetLatest(t *testing.T) {
|
||||
server, actorURL := newRemoteServer()
|
||||
defer server.Close()
|
||||
|
||||
oc := newOutboxClient()
|
||||
posts, err := oc.getLatest(context.Background(), actorURL, "alice@example.social", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("getLatest error: %v", err)
|
||||
}
|
||||
if len(posts) != 4 {
|
||||
t.Fatalf("expected 4 Create/Note posts (skipping Announce/Like), got %d: %+v", len(posts), posts)
|
||||
}
|
||||
want := []string{"created note 1", "deref note 2", "bare note 3", "note 4"}
|
||||
for i, w := range want {
|
||||
if posts[i].Content != w {
|
||||
t.Fatalf("posts[%d].Content = %q, want %q", i, posts[i].Content, w)
|
||||
}
|
||||
}
|
||||
if posts[0].Author != "alice@example.social" {
|
||||
t.Fatalf("Author = %q, want handle", posts[0].Author)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboxGetLatestLimit(t *testing.T) {
|
||||
server, actorURL := newRemoteServer()
|
||||
defer server.Close()
|
||||
|
||||
oc := newOutboxClient()
|
||||
posts, err := oc.getLatest(context.Background(), actorURL, "h", 2)
|
||||
if err != nil {
|
||||
t.Fatalf("getLatest error: %v", err)
|
||||
}
|
||||
if len(posts) != 2 {
|
||||
t.Fatalf("expected 2 posts, got %d", len(posts))
|
||||
}
|
||||
want := []string{"created note 1", "deref note 2"}
|
||||
for i, w := range want {
|
||||
if posts[i].Content != w {
|
||||
t.Fatalf("posts[%d].Content = %q, want %q", i, posts[i].Content, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboxGetInTimeframe(t *testing.T) {
|
||||
server, actorURL := newRemoteServer()
|
||||
defer server.Close()
|
||||
|
||||
oc := newOutboxClient()
|
||||
start := time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC)
|
||||
end := time.Date(2024, 3, 2, 0, 0, 0, 0, time.UTC)
|
||||
posts, err := oc.getInTimeframe(context.Background(), actorURL, "h", start, end, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("getInTimeframe error: %v", err)
|
||||
}
|
||||
if len(posts) != 2 {
|
||||
t.Fatalf("expected 2 in-window posts (note 3 and 4 are outside), got %d", len(posts))
|
||||
}
|
||||
if posts[0].Content != "created note 1" || posts[1].Content != "deref note 2" {
|
||||
t.Fatalf("unexpected in-window posts: %+v", posts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboxResolveViaWebfinger(t *testing.T) {
|
||||
server, _ := newRemoteServer()
|
||||
defer server.Close()
|
||||
|
||||
scheme := webfingerScheme
|
||||
webfingerScheme = "http"
|
||||
defer func() { webfingerScheme = scheme }()
|
||||
|
||||
handle := "@alice@" + strings.TrimPrefix(server.URL, "http://")
|
||||
oc := newOutboxClient()
|
||||
posts, err := oc.getLatest(context.Background(), "", handle, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("getLatest via webfinger error: %v", err)
|
||||
}
|
||||
if len(posts) != 4 {
|
||||
t.Fatalf("expected 4 posts via webfinger, got %d", len(posts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboxResolveNote(t *testing.T) {
|
||||
server, _ := newRemoteServer()
|
||||
defer server.Close()
|
||||
|
||||
oc := newOutboxClient()
|
||||
note, err := oc.resolveNote(context.Background(), server.URL+"/notes/2")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveNote error: %v", err)
|
||||
}
|
||||
if note.Content != "deref note 2" {
|
||||
t.Fatalf("unexpected note: %+v", note)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteToInternalPostSetsAuthor(t *testing.T) {
|
||||
note := &apNote{
|
||||
ID: "https://example.social/notes/1",
|
||||
Content: "hello",
|
||||
Published: "2024-01-01T00:00:00Z",
|
||||
}
|
||||
p := noteToInternalPost(note, "alice@example.social")
|
||||
if p.Account.Acct != "alice@example.social" {
|
||||
t.Fatalf("expected Account.Acct = 'alice@example.social', got %q", p.Account.Acct)
|
||||
}
|
||||
if p.toPost().Author != "alice@example.social" {
|
||||
t.Fatalf("expected toPost().Author = 'alice@example.social', got %q", p.toPost().Author)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteToInternalPostNoAuthor(t *testing.T) {
|
||||
note := &apNote{
|
||||
ID: "https://example.social/notes/1",
|
||||
Content: "hello",
|
||||
Published: "2024-01-01T00:00:00Z",
|
||||
}
|
||||
p := noteToInternalPost(note, "")
|
||||
if p.Account.Acct != "" {
|
||||
t.Fatalf("expected empty Account.Acct when no author, got %q", p.Account.Acct)
|
||||
}
|
||||
if p.toPost().Author != "" {
|
||||
t.Fatalf("expected empty author when none provided, got %q", p.toPost().Author)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergePostsBySourceDedup(t *testing.T) {
|
||||
created := time.Date(2024, 3, 1, 0, 0, 0, 0, time.UTC)
|
||||
cached := []*internalPost{
|
||||
{ID: "local-1", URI: "https://example.social/notes/1", Content: "cached", CreatedAt: created},
|
||||
}
|
||||
outbox := []*Post{
|
||||
{ID: "https://example.social/notes/1", Content: "cached dup", CreatedAt: created},
|
||||
{ID: "https://example.social/notes/2", Content: "outbox only", CreatedAt: created.Add(-time.Hour)},
|
||||
}
|
||||
merged := mergePostsBySource(cached, outbox)
|
||||
if len(merged) != 2 {
|
||||
t.Fatalf("expected dedup to drop the duplicate, got %d", len(merged))
|
||||
}
|
||||
if merged[0].Content != "cached" {
|
||||
t.Fatalf("newest first: expected cached first, got %q", merged[0].Content)
|
||||
}
|
||||
if merged[1].Content != "outbox only" {
|
||||
t.Fatalf("expected outbox only second, got %q", merged[1].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseActivityTime(t *testing.T) {
|
||||
for _, s := range []string{"2024-01-01T00:00:00Z", "2024-01-01T00:00:00.000Z", "2024-01-01T00:00:00+02:00"} {
|
||||
if _, ok := parseActivityTime(s); !ok {
|
||||
t.Fatalf("parseActivityTime(%q) failed", s)
|
||||
}
|
||||
}
|
||||
if _, ok := parseActivityTime("not a date"); ok {
|
||||
t.Fatal("expected invalid timestamp to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPStringOrArray(t *testing.T) {
|
||||
var sa apStringOrArray
|
||||
if err := json.Unmarshal([]byte(`"https://a"`), &sa); err != nil || len(sa) != 1 || sa[0] != "https://a" {
|
||||
t.Fatalf("string form failed: %v %v", sa, err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(`["https://a","https://b"]`), &sa); err != nil || len(sa) != 2 {
|
||||
t.Fatalf("array form failed: %v %v", sa, err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(`null`), &sa); err != nil || len(sa) != 0 {
|
||||
t.Fatalf("null form failed: %v %v", sa, err)
|
||||
}
|
||||
}
|
||||
58
internal/activitypub/throttle.go
Normal file
58
internal/activitypub/throttle.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package activitypub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type hostLimiter struct {
|
||||
mu sync.Mutex
|
||||
buckets map[string]*bucket
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
mu sync.Mutex
|
||||
last time.Time
|
||||
}
|
||||
|
||||
func newHostLimiter() *hostLimiter {
|
||||
return &hostLimiter{buckets: make(map[string]*bucket)}
|
||||
}
|
||||
|
||||
func (h *hostLimiter) Wait(ctx context.Context, host string) error {
|
||||
h.mu.Lock()
|
||||
b, ok := h.buckets[host]
|
||||
if !ok {
|
||||
b = &bucket{}
|
||||
h.buckets[host] = b
|
||||
if len(h.buckets) > 1024 {
|
||||
for k, v := range h.buckets {
|
||||
if time.Since(v.last) > 10*time.Minute {
|
||||
delete(h.buckets, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
if b.last.IsZero() {
|
||||
b.last = time.Now()
|
||||
return nil
|
||||
}
|
||||
wait := time.Until(b.last.Add(time.Second))
|
||||
if wait <= 0 {
|
||||
b.last = time.Now()
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-time.After(wait):
|
||||
b.last = time.Now()
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package activitypub
|
|||
import "time"
|
||||
|
||||
type Post struct {
|
||||
ID string `json:"id"`
|
||||
Author string `json:"author"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
|
@ -12,14 +13,18 @@ type Post struct {
|
|||
}
|
||||
|
||||
func (i *internalPost) toPost() *Post {
|
||||
return &Post{
|
||||
Author: i.Account.Acct,
|
||||
p := &Post{
|
||||
ID: i.ID,
|
||||
Content: i.Content,
|
||||
CreatedAt: i.CreatedAt,
|
||||
FavouritesCount: i.FavouritesCount,
|
||||
ReblogsCount: i.ReblogsCount,
|
||||
RepliesCount: i.RepliesCount,
|
||||
}
|
||||
if i.Account.Acct != "" {
|
||||
p.Author = i.Account.Acct
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
type internalPost struct {
|
||||
|
|
@ -53,7 +58,6 @@ type internalPost struct {
|
|||
Sensitive bool `json:"sensitive"`
|
||||
SpoilerText string `json:"spoiler_text"`
|
||||
Tags []Tag `json:"tags"`
|
||||
Text string `json:"text"`
|
||||
URI string `json:"uri"`
|
||||
URL string `json:"url"`
|
||||
Visibility string `json:"visibility"`
|
||||
|
|
@ -266,3 +270,10 @@ type Tag struct {
|
|||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// searchResult mirrors the /api/v2/search response: statuses are the api
|
||||
// `status` objects served by the search endpoint.
|
||||
type searchResult struct {
|
||||
Accounts []*Account `json:"accounts"`
|
||||
Statuses []*internalPost `json:"statuses"`
|
||||
}
|
||||
|
|
|
|||
16
internal/actor/profile.go
Normal file
16
internal/actor/profile.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package actor
|
||||
|
||||
// Profile is a platform-agnostic representation of an actor's public metadata. It is shared
|
||||
// by every platform client so the server can aggregate results without importing client
|
||||
// packages (which would create an import cycle).
|
||||
type Profile struct {
|
||||
Platform string `json:"platform"`
|
||||
Handle string `json:"handle"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||
Bio string `json:"bio,omitempty"`
|
||||
FollowersCount int `json:"followersCount"`
|
||||
FollowingCount int `json:"followingCount"`
|
||||
PostCount int `json:"postCount"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
|
@ -2,9 +2,11 @@ package bluesky
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/bluesky-social/indigo/api/atproto"
|
||||
|
|
@ -12,10 +14,39 @@ import (
|
|||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
"github.com/bluesky-social/indigo/xrpc"
|
||||
log "github.com/nacorid/logger"
|
||||
"github.com/nacorid/naco-api/internal/actor"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxBskyFeedLimit is the upper bound the Bluesky API accepts for ?limit=.
|
||||
// Higher values are silently clamped server-side.
|
||||
maxBskyFeedLimit = 100
|
||||
|
||||
// maxAuthorFeedPages caps the number of author-feed pages GetPostsInTimeframe
|
||||
// scans. At 50 posts/page this allows up to ~5,000 statuses per window before
|
||||
// giving up.
|
||||
maxAuthorFeedPages = 100
|
||||
|
||||
// didCacheTTL bounds how long a handle->DID resolution is reused. Handles
|
||||
// almost never re-map, but the TTL guarantees a long-running process still
|
||||
// notices handle changes instead of pinning a stale DID forever.
|
||||
didCacheTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
type BlueskyClient struct {
|
||||
client *xrpc.Client
|
||||
client *xrpc.Client
|
||||
authMu sync.RWMutex
|
||||
refreshMu sync.Mutex
|
||||
cacheMu sync.Mutex
|
||||
didCache map[string]didCacheEntry
|
||||
}
|
||||
|
||||
// didCacheEntry holds a positive (successfully resolved) handle->DID mapping
|
||||
// and when it expires. Failures are never cached, so transient resolution
|
||||
// errors are retried on the next call.
|
||||
type didCacheEntry struct {
|
||||
did string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
func NewBlueskyClient(ctx context.Context, handle, password, host string) (*BlueskyClient, error) {
|
||||
|
|
@ -37,61 +68,175 @@ func NewBlueskyClient(ctx context.Context, handle, password, host string) (*Blue
|
|||
Handle: session.Handle,
|
||||
Did: session.Did,
|
||||
}
|
||||
return &BlueskyClient{client: client}, nil
|
||||
return &BlueskyClient{
|
||||
client: client,
|
||||
didCache: make(map[string]didCacheEntry),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// withRefresh runs fn. If it fails with an auth/expired-token XRPC error, the
|
||||
// session is refreshed once and fn is retried.
|
||||
//
|
||||
// refreshMu serializes refresh calls: Bluesky refresh tokens are single-use and
|
||||
// rotated, so two concurrent refreshes would race and one should fail for no
|
||||
// reason. After acquiring the lock the error is re-checked, because a previous
|
||||
// goroutine may already have refreshed the session while this one waited.
|
||||
func (c *BlueskyClient) withRefresh(ctx context.Context, fn func() error) error {
|
||||
if err := fn(); err == nil {
|
||||
return nil
|
||||
} else if !isAuthError(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
c.refreshMu.Lock()
|
||||
defer c.refreshMu.Unlock()
|
||||
|
||||
// Another goroutine may have refreshed while we waited for the lock.
|
||||
err := fn()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !isAuthError(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
if rerr := c.refreshSession(ctx); rerr != nil {
|
||||
log.WarnWithContext(ctx, "bluesky session refresh failed", "error", rerr)
|
||||
return err
|
||||
}
|
||||
return fn()
|
||||
}
|
||||
|
||||
// clientSnapshot returns a copy of the xrpc client that requests must use. It
|
||||
// pins the current, immutable AuthInfo under the read lock: refreshSession
|
||||
// replaces the Auth pointer under the exclusive lock rather than mutating the
|
||||
// old value in place, so every goroutine sharing a pinned AuthInfo reads a
|
||||
// complete, consistent token. xrpc.Client.Do reads client.Auth without any
|
||||
// locking of its own, so no request may touch c.client.Auth directly.
|
||||
func (c *BlueskyClient) clientSnapshot() *xrpc.Client {
|
||||
c.authMu.RLock()
|
||||
defer c.authMu.RUnlock()
|
||||
cp := *c.client
|
||||
return &cp
|
||||
}
|
||||
|
||||
func (c *BlueskyClient) refreshSession(ctx context.Context) error {
|
||||
refreshed, err := atproto.ServerRefreshSession(ctx, c.clientSnapshot())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to refresh bluesky session: %w", err)
|
||||
}
|
||||
|
||||
c.authMu.Lock()
|
||||
defer c.authMu.Unlock()
|
||||
c.client.Auth = &xrpc.AuthInfo{
|
||||
AccessJwt: refreshed.AccessJwt,
|
||||
RefreshJwt: refreshed.RefreshJwt,
|
||||
Handle: c.client.Auth.Handle,
|
||||
Did: c.client.Auth.Did,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isAuthError reports whether err is an auth/expired-token XRPC error. The
|
||||
// ErrStr switch covers the canonical cases; the message-substring fallback is a
|
||||
// deliberate heuristic for third-party PDS instances that return nonstandard
|
||||
// error strings. It can false-positive (an unrelated message that happens to
|
||||
// contain "expired"/"unauthorized"/"token"), which only costs one gratuitous
|
||||
// refresh + retry.
|
||||
func isAuthError(err error) bool {
|
||||
var xe *xrpc.XRPCError
|
||||
if !errors.As(err, &xe) {
|
||||
return false
|
||||
}
|
||||
switch xe.ErrStr {
|
||||
case "ExpiredToken", "TokenRequired", "AuthMissing", "AuthorizationRequired":
|
||||
return true
|
||||
}
|
||||
msg := strings.ToLower(xe.Message)
|
||||
return strings.Contains(msg, "expired") || strings.Contains(msg, "unauthorized") || strings.Contains(msg, "token")
|
||||
}
|
||||
|
||||
func (c *BlueskyClient) GetPost(ctx context.Context, actor, collection, recordKey string) (*bsky.FeedDefs_PostView, error) {
|
||||
repo, err := c.resolveUser(ctx, actor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result *bsky.FeedDefs_PostView
|
||||
err := c.withRefresh(ctx, func() error {
|
||||
repo, err := c.resolveUser(ctx, actor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var uri string
|
||||
if collection != "" {
|
||||
uri = fmt.Sprintf("at://%s/%s/%s", repo.String(), collection, recordKey)
|
||||
} else {
|
||||
uri = fmt.Sprintf("at://%s/app.bsky.feed.post/%s", repo.String(), recordKey)
|
||||
}
|
||||
log.DebugWithContext(ctx, "Fetching post", "URI", uri)
|
||||
resp, err := bsky.FeedGetPosts(ctx, c.client, []string{uri})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get post: %w", err)
|
||||
}
|
||||
if len(resp.Posts) == 0 {
|
||||
return nil, fmt.Errorf("post not found")
|
||||
}
|
||||
return resp.Posts[0], nil
|
||||
uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", repo.String(), recordKey)
|
||||
if collection != "" {
|
||||
uri = fmt.Sprintf("at://%s/%s/%s", repo.String(), collection, recordKey)
|
||||
}
|
||||
// collection and recordKey are caller-supplied; reject a malformed
|
||||
// at-uri before spending a network round-trip.
|
||||
if _, err := syntax.ParseATURI(uri); err != nil {
|
||||
return fmt.Errorf("invalid at uri %q: %w", uri, err)
|
||||
}
|
||||
|
||||
log.DebugWithContext(ctx, "Fetching post", "URI", uri)
|
||||
resp, err := bsky.FeedGetPosts(ctx, c.clientSnapshot(), []string{uri})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get post: %w", err)
|
||||
}
|
||||
if len(resp.Posts) == 0 {
|
||||
return fmt.Errorf("post not found")
|
||||
}
|
||||
result = resp.Posts[0]
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (c *BlueskyClient) GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*bsky.FeedDefs_FeedViewPost, error) {
|
||||
repo, err := c.resolveUser(ctx, actor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
resp, err := bsky.FeedGetAuthorFeed(ctx, c.client, repo.String(), "", "posts", false, int64(limit))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get latest posts: %w", err)
|
||||
pageSize := limit
|
||||
if pageSize > maxBskyFeedLimit {
|
||||
pageSize = maxBskyFeedLimit
|
||||
}
|
||||
return resp.Feed, nil
|
||||
|
||||
var result []*bsky.FeedDefs_FeedViewPost
|
||||
err := c.withRefresh(ctx, func() error {
|
||||
repo, err := c.resolveUser(ctx, actor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := bsky.FeedGetAuthorFeed(ctx, c.clientSnapshot(), repo.String(), "", "posts", false, int64(pageSize))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get latest posts: %w", err)
|
||||
}
|
||||
result = resp.Feed
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (c *BlueskyClient) GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]*bsky.FeedDefs_FeedViewPost, error) {
|
||||
repo, err := c.resolveUser(ctx, actor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var posts []*bsky.FeedDefs_FeedViewPost
|
||||
var cursor string
|
||||
|
||||
for len(posts) < limit {
|
||||
feed, err := bsky.FeedGetAuthorFeed(ctx, c.client, repo.String(), cursor, "posts", false, 50)
|
||||
var result []*bsky.FeedDefs_FeedViewPost
|
||||
err := c.withRefresh(ctx, func() error {
|
||||
repo, err := c.resolveUser(ctx, actor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
for _, post := range feed.Feed {
|
||||
if post.Post.Record != nil {
|
||||
var posts []*bsky.FeedDefs_FeedViewPost
|
||||
var cursor string
|
||||
|
||||
// The author feed is reverse-chronological, so the first post older
|
||||
// than start terminates the scan; maxAuthorFeedPages is a belt-and-
|
||||
// braces guard against a misbehaving server.
|
||||
for pages := 0; pages < maxAuthorFeedPages; pages++ {
|
||||
feed, err := bsky.FeedGetAuthorFeed(ctx, c.clientSnapshot(), repo.String(), cursor, "posts", false, 50)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, post := range feed.Feed {
|
||||
if post.Post == nil || post.Post.Record == nil {
|
||||
continue
|
||||
}
|
||||
rec, ok := post.Post.Record.Val.(*bsky.FeedPost)
|
||||
if !ok {
|
||||
log.WarnWithContext(ctx, "Failed to parse post", "post", post.Post.Uri)
|
||||
|
|
@ -104,21 +249,30 @@ func (c *BlueskyClient) GetPostsInTimeframe(ctx context.Context, actor string, s
|
|||
continue
|
||||
}
|
||||
|
||||
if createdAt.After(start) && createdAt.Before(end) {
|
||||
posts = append(posts, post)
|
||||
if len(posts) >= limit {
|
||||
return posts, nil
|
||||
}
|
||||
if createdAt.After(end) {
|
||||
continue
|
||||
}
|
||||
if createdAt.Before(start) {
|
||||
// Reverse-chronological: nothing older is in-window.
|
||||
result = posts
|
||||
return nil
|
||||
}
|
||||
posts = append(posts, post)
|
||||
if len(posts) >= limit {
|
||||
result = posts
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if feed.Cursor == nil || *feed.Cursor == "" {
|
||||
break
|
||||
if feed.Cursor == nil || *feed.Cursor == "" {
|
||||
break
|
||||
}
|
||||
cursor = *feed.Cursor
|
||||
}
|
||||
cursor = *feed.Cursor
|
||||
}
|
||||
return posts, nil
|
||||
result = posts
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (c *BlueskyClient) GetFeed(ctx context.Context, feedURI string, limit int) (*bsky.FeedGetFeed_Output, error) {
|
||||
|
|
@ -126,26 +280,139 @@ func (c *BlueskyClient) GetFeed(ctx context.Context, feedURI string, limit int)
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bsky.FeedGetFeed(ctx, c.client, "", uri, int64(50))
|
||||
pageSize := limit
|
||||
if pageSize <= 0 {
|
||||
pageSize = 50
|
||||
}
|
||||
if pageSize > maxBskyFeedLimit {
|
||||
pageSize = maxBskyFeedLimit
|
||||
}
|
||||
|
||||
var result *bsky.FeedGetFeed_Output
|
||||
err = c.withRefresh(ctx, func() error {
|
||||
resp, err := bsky.FeedGetFeed(ctx, c.clientSnapshot(), "", uri, int64(pageSize))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get feed: %w", err)
|
||||
}
|
||||
result = resp
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
// resolveUser converts a handle or DID into a syntax.DID. Handle resolutions
|
||||
// are cached for didCacheTTL; DIDs pass through unmodified. Only successful
|
||||
// resolutions are cached.
|
||||
func (c *BlueskyClient) resolveUser(ctx context.Context, user string) (syntax.DID, error) {
|
||||
var did syntax.DID
|
||||
if d, err := syntax.ParseDID(user); err == nil {
|
||||
did = d
|
||||
} else {
|
||||
resp, err := atproto.IdentityResolveHandle(ctx, c.client, user)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to resolve handle to DID: %w", err)
|
||||
}
|
||||
did, err = syntax.ParseDID(resp.Did)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse resolved DID: %w", err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
c.cacheMu.Lock()
|
||||
if e, ok := c.didCache[user]; ok && time.Now().Before(e.expiresAt) {
|
||||
c.cacheMu.Unlock()
|
||||
return syntax.DID(e.did), nil
|
||||
}
|
||||
c.cacheMu.Unlock()
|
||||
|
||||
resp, err := atproto.IdentityResolveHandle(ctx, c.clientSnapshot(), user)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to resolve handle to DID: %w", err)
|
||||
}
|
||||
did, err := syntax.ParseDID(resp.Did)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse resolved DID: %w", err)
|
||||
}
|
||||
|
||||
c.cacheMu.Lock()
|
||||
c.didCache[user] = didCacheEntry{did: did.String(), expiresAt: time.Now().Add(didCacheTTL)}
|
||||
c.cacheMu.Unlock()
|
||||
return did, nil
|
||||
}
|
||||
|
||||
func (c *BlueskyClient) ResolveUser(ctx context.Context, user string) (syntax.DID, error) {
|
||||
return c.resolveUser(ctx, user)
|
||||
}
|
||||
|
||||
func (c *BlueskyClient) GetActor(ctx context.Context, author string) (*actor.Profile, error) {
|
||||
var result *actor.Profile
|
||||
err := c.withRefresh(ctx, func() error {
|
||||
params := map[string]any{"actor": author}
|
||||
var prof bskyActorProfile
|
||||
if err := c.clientSnapshot().Do(ctx, xrpc.Query, "", "app.bsky.actor.getProfile", params, nil, &prof); err != nil {
|
||||
return fmt.Errorf("failed to get actor profile: %w", err)
|
||||
}
|
||||
result = &actor.Profile{
|
||||
Platform: "Bluesky",
|
||||
Handle: prof.Handle,
|
||||
DisplayName: prof.DisplayName,
|
||||
AvatarURL: prof.Avatar,
|
||||
Bio: prof.Description,
|
||||
FollowersCount: prof.FollowersCount,
|
||||
FollowingCount: prof.FollowsCount,
|
||||
PostCount: prof.PostsCount,
|
||||
URL: blueskyProfileURL(prof.Handle),
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (c *BlueskyClient) SearchActors(ctx context.Context, query string, limit int) ([]*actor.Profile, error) {
|
||||
var result []*actor.Profile
|
||||
err := c.withRefresh(ctx, func() error {
|
||||
params := map[string]any{"q": query, "limit": limit}
|
||||
var out bskyActorSearch
|
||||
if err := c.clientSnapshot().Do(ctx, xrpc.Query, "", "app.bsky.actor.searchActors", params, nil, &out); err != nil {
|
||||
return fmt.Errorf("failed to search actors: %w", err)
|
||||
}
|
||||
results := make([]*actor.Profile, 0, len(out.Actors))
|
||||
for _, a := range out.Actors {
|
||||
results = append(results, &actor.Profile{
|
||||
Platform: "Bluesky",
|
||||
Handle: a.Handle,
|
||||
DisplayName: a.DisplayName,
|
||||
AvatarURL: a.Avatar,
|
||||
Bio: a.Description,
|
||||
URL: blueskyProfileURL(a.Handle),
|
||||
})
|
||||
}
|
||||
result = results
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
// blueskyProfileURL derives the canonical public profile URL from the handle.
|
||||
// app.bsky.actor.getProfile/searchActors do not return a url field.
|
||||
func blueskyProfileURL(handle string) string {
|
||||
if handle == "" {
|
||||
return ""
|
||||
}
|
||||
return "https://bsky.app/profile/" + strings.TrimPrefix(handle, "@")
|
||||
}
|
||||
|
||||
type bskyActorProfile struct {
|
||||
Did string `json:"did"`
|
||||
Handle string `json:"handle"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Avatar string `json:"avatar"`
|
||||
Description string `json:"description"`
|
||||
FollowersCount int `json:"followersCount"`
|
||||
FollowsCount int `json:"followsCount"`
|
||||
PostsCount int `json:"postsCount"`
|
||||
}
|
||||
|
||||
type bskyActorSearch struct {
|
||||
Actors []struct {
|
||||
Did string `json:"did"`
|
||||
Handle string `json:"handle"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Avatar string `json:"avatar"`
|
||||
Description string `json:"description"`
|
||||
} `json:"actors"`
|
||||
}
|
||||
|
||||
func (c *BlueskyClient) sanitizeFeedURI(ctx context.Context, input string) (string, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
|
||||
|
|
@ -158,6 +425,10 @@ func (c *BlueskyClient) sanitizeFeedURI(ctx context.Context, input string) (stri
|
|||
return "", fmt.Errorf("invalid URI: %w", err)
|
||||
}
|
||||
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return "", fmt.Errorf("unrecognized URI form (scheme must be http/https): %s", input)
|
||||
}
|
||||
|
||||
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
||||
if len(parts) >= 4 && parts[0] == "profile" && parts[2] == "feed" {
|
||||
didRaw := parts[1]
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@ import (
|
|||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bluesky-social/indigo/api/atproto"
|
||||
"github.com/bluesky-social/indigo/api/bsky"
|
||||
|
|
@ -30,7 +33,7 @@ func setupMockServer() *httptest.Server {
|
|||
|
||||
if input.Identifier == "fail.test" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
fmt.Fprintln(w, `{"error": "InvalidCredentials", "message": "Invalid handle or password"}`)
|
||||
_, _ = fmt.Fprintln(w, `{"error": "InvalidCredentials", "message": "Invalid handle or password"}`)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -42,7 +45,7 @@ func setupMockServer() *httptest.Server {
|
|||
Did: "did:plc:test",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
})
|
||||
|
||||
// Mock for bsky.FeedGetAuthorFeed
|
||||
|
|
@ -80,7 +83,7 @@ func setupMockServer() *httptest.Server {
|
|||
Feed: feed,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
})
|
||||
|
||||
// Mock for bsky.FeedGetFeed
|
||||
|
|
@ -106,7 +109,125 @@ func setupMockServer() *httptest.Server {
|
|||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
})
|
||||
|
||||
// Mock for bsky.FeedGetPosts (used by GetPost)
|
||||
mux.HandleFunc("/xrpc/app.bsky.feed.getPosts", func(w http.ResponseWriter, r *http.Request) {
|
||||
uris := r.URL.Query()["uris"]
|
||||
if len(uris) == 0 {
|
||||
http.Error(w, `{"error": "MissingParameter", "message": "uris parameter is required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
posts := make([]*bsky.FeedDefs_PostView, 0, len(uris))
|
||||
for _, uri := range uris {
|
||||
posts = append(posts, &bsky.FeedDefs_PostView{
|
||||
Uri: uri,
|
||||
Cid: "bafy...",
|
||||
Author: &bsky.ActorDefs_ProfileViewBasic{
|
||||
Did: "did:plc:test",
|
||||
Handle: "test.handle",
|
||||
},
|
||||
Record: &util.LexiconTypeDecoder{
|
||||
Val: &bsky.FeedPost{
|
||||
Text: "Test post content",
|
||||
CreatedAt: "2023-10-27T12:00:00Z",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
resp := bsky.FeedGetPosts_Output{
|
||||
Posts: posts,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
})
|
||||
|
||||
// Mock for app.bsky.actor.getProfile (used by GetActor)
|
||||
mux.HandleFunc("/xrpc/app.bsky.actor.getProfile", func(w http.ResponseWriter, r *http.Request) {
|
||||
actor := r.URL.Query().Get("actor")
|
||||
if actor == "" {
|
||||
http.Error(w, `{"error": "MissingParameter", "message": "actor parameter is required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"did": "did:plc:test",
|
||||
"handle": actor,
|
||||
"displayName": "Test User",
|
||||
"avatar": "https://example.com/avatar.jpg",
|
||||
"description": "Test bio",
|
||||
"followersCount": 100,
|
||||
"followsCount": 50,
|
||||
"postsCount": 200,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
})
|
||||
|
||||
// Mock for app.bsky.actor.searchActors (used by SearchActors)
|
||||
mux.HandleFunc("/xrpc/app.bsky.actor.searchActors", func(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("q")
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
limit := 10
|
||||
if limitStr != "" {
|
||||
limit, _ = strconv.Atoi(limitStr)
|
||||
}
|
||||
|
||||
if query == "" {
|
||||
http.Error(w, `{"error": "MissingParameter", "message": "q parameter is required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
actors := make([]map[string]any, 0, limit)
|
||||
for i := 0; i < limit; i++ {
|
||||
actors = append(actors, map[string]any{
|
||||
"did": fmt.Sprintf("did:plc:test%d", i),
|
||||
"handle": fmt.Sprintf("%s%d.test", query, i),
|
||||
"displayName": fmt.Sprintf("Test User %d", i),
|
||||
"avatar": "https://example.com/avatar.jpg",
|
||||
"description": fmt.Sprintf("Bio for user %d", i),
|
||||
})
|
||||
}
|
||||
|
||||
resp := map[string]any{"actors": actors}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
})
|
||||
|
||||
// Mock for com.atproto.identity.resolveHandle (used by ResolveUser)
|
||||
mux.HandleFunc("/xrpc/com.atproto.identity.resolveHandle", func(w http.ResponseWriter, r *http.Request) {
|
||||
handle := r.URL.Query().Get("handle")
|
||||
if handle == "" {
|
||||
http.Error(w, `{"error": "MissingParameter", "message": "handle parameter is required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp := atproto.IdentityResolveHandle_Output{
|
||||
Did: "did:plc:resolved",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
})
|
||||
|
||||
// Mock for com.atproto.server.refreshSession (used by withRefresh)
|
||||
mux.HandleFunc("/xrpc/com.atproto.server.refreshSession", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Check for Authorization header
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth == "" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": "TokenRequired"})
|
||||
return
|
||||
}
|
||||
|
||||
resp := atproto.ServerRefreshSession_Output{
|
||||
AccessJwt: "refreshed_access_jwt",
|
||||
RefreshJwt: "refreshed_refresh_jwt",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
})
|
||||
|
||||
return httptest.NewServer(mux)
|
||||
|
|
@ -171,6 +292,7 @@ func newTestClient(t *testing.T, serverURL string) *BlueskyClient {
|
|||
AccessJwt: "test_access_jwt",
|
||||
},
|
||||
},
|
||||
didCache: make(map[string]didCacheEntry),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -181,7 +303,7 @@ func TestGetLatestNPosts(t *testing.T) {
|
|||
client := newTestClient(t, server.URL)
|
||||
ctx := context.Background()
|
||||
actor := "did:plc:testactor"
|
||||
var limit int = 5
|
||||
limit := 5
|
||||
|
||||
output, err := client.GetLatestNPosts(ctx, actor, limit)
|
||||
if err != nil {
|
||||
|
|
@ -208,7 +330,7 @@ func TestGetFeed(t *testing.T) {
|
|||
client := newTestClient(t, server.URL)
|
||||
ctx := context.Background()
|
||||
feedURI := "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/with-friends"
|
||||
var limit int = 10
|
||||
limit := 10
|
||||
|
||||
output, err := client.GetFeed(ctx, feedURI, limit)
|
||||
if err != nil {
|
||||
|
|
@ -227,3 +349,597 @@ func TestGetFeed(t *testing.T) {
|
|||
t.Errorf("Unexpected post URI: %s", output.Feed[0].Post.Uri)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPost(t *testing.T) {
|
||||
server := setupMockServer()
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(t, server.URL)
|
||||
ctx := context.Background()
|
||||
|
||||
postURI := "at://did:plc:testactor/app.bsky.feed.post/123"
|
||||
post, err := client.GetPost(ctx, "did:plc:testactor", "", "123")
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if post == nil {
|
||||
t.Fatal("Expected post not to be nil")
|
||||
}
|
||||
|
||||
if post.Uri != postURI {
|
||||
t.Errorf("Expected post URI %q, got %q", postURI, post.Uri)
|
||||
}
|
||||
|
||||
if post.Author.Handle != "test.handle" {
|
||||
t.Errorf("Expected author handle 'test.handle', got %q", post.Author.Handle)
|
||||
}
|
||||
|
||||
// Test with custom collection
|
||||
post2, err := client.GetPost(ctx, "did:plc:testactor", "app.bsky.feed.post", "456")
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error with custom collection, got %v", err)
|
||||
}
|
||||
if post2 == nil {
|
||||
t.Fatal("Expected post not to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPostsInTimeframe(t *testing.T) {
|
||||
// The mock author-feed is reverse-chronological: page 1 has posts newer than
|
||||
// the window end (skipped, cursor advances), page 2 has the in-range posts
|
||||
// followed by one older than start (which terminates the scan).
|
||||
requests := 0
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/xrpc/com.atproto.server.createSession", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(atproto.ServerCreateSession_Output{Handle: "test.handle", Did: "did:plc:test"})
|
||||
})
|
||||
mux.HandleFunc("/xrpc/app.bsky.feed.getAuthorFeed", func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
mk := func(uri, createdAt string) *bsky.FeedDefs_FeedViewPost {
|
||||
return &bsky.FeedDefs_FeedViewPost{
|
||||
Post: &bsky.FeedDefs_PostView{
|
||||
Uri: uri,
|
||||
Author: &bsky.ActorDefs_ProfileViewBasic{
|
||||
Did: "did:plc:testactor",
|
||||
Handle: "testactor.test",
|
||||
},
|
||||
Record: &util.LexiconTypeDecoder{
|
||||
Val: &bsky.FeedPost{Text: uri, CreatedAt: createdAt},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var feed []*bsky.FeedDefs_FeedViewPost
|
||||
var cursor string
|
||||
if requests == 1 {
|
||||
// Page 1: one post after the window end (skipped), with a cursor.
|
||||
feed = []*bsky.FeedDefs_FeedViewPost{
|
||||
mk("at://did:plc:testactor/app.bsky.feed.post/4", "2024-01-01T00:00:00Z"),
|
||||
}
|
||||
cursor = "page-2-cursor"
|
||||
} else {
|
||||
// Page 2: two in-range posts, then one older than start (terminates).
|
||||
feed = []*bsky.FeedDefs_FeedViewPost{
|
||||
mk("at://did:plc:testactor/app.bsky.feed.post/2", "2023-08-01T00:00:00Z"),
|
||||
mk("at://did:plc:testactor/app.bsky.feed.post/1", "2023-06-01T00:00:00Z"),
|
||||
mk("at://did:plc:testactor/app.bsky.feed.post/3", "2022-01-01T00:00:00Z"),
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(bsky.FeedGetAuthorFeed_Output{Feed: feed, Cursor: &cursor})
|
||||
})
|
||||
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(t, server.URL)
|
||||
ctx := context.Background()
|
||||
|
||||
actor := "did:plc:testactor"
|
||||
start := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
end := time.Date(2023, 12, 31, 23, 59, 59, 0, time.UTC)
|
||||
limit := 10
|
||||
|
||||
posts, err := client.GetPostsInTimeframe(ctx, actor, start, end, limit)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
// Only the two in-range posts must survive filtering; cursor pagination must
|
||||
// advance into page 2 to reach them.
|
||||
if len(posts) != 2 {
|
||||
t.Fatalf("expected 2 in-range posts, got %d", len(posts))
|
||||
}
|
||||
for _, post := range posts {
|
||||
rec, ok := post.Post.Record.Val.(*bsky.FeedPost)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected record type")
|
||||
}
|
||||
createdAt, _ := time.Parse(time.RFC3339, rec.CreatedAt)
|
||||
if createdAt.Before(start) || createdAt.After(end) {
|
||||
t.Errorf("Post %s returned outside time range", post.Post.Uri)
|
||||
}
|
||||
}
|
||||
if requests != 2 {
|
||||
t.Errorf("expected pagination to make 2 feed requests, got %d", requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetActor(t *testing.T) {
|
||||
server := setupMockServer()
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(t, server.URL)
|
||||
ctx := context.Background()
|
||||
|
||||
actor := "test.handle"
|
||||
profile, err := client.GetActor(ctx, actor)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if profile == nil {
|
||||
t.Fatal("Expected profile not to be nil")
|
||||
}
|
||||
|
||||
if profile.Handle != actor {
|
||||
t.Errorf("Expected handle %q, got %q", actor, profile.Handle)
|
||||
}
|
||||
if profile.DisplayName != "Test User" {
|
||||
t.Errorf("Expected display name 'Test User', got %q", profile.DisplayName)
|
||||
}
|
||||
if profile.Platform != "Bluesky" {
|
||||
t.Errorf("Expected platform 'Bluesky', got %q", profile.Platform)
|
||||
}
|
||||
if profile.URL != "https://bsky.app/profile/"+actor {
|
||||
t.Errorf("Expected URL %q, got %q", "https://bsky.app/profile/"+actor, profile.URL)
|
||||
}
|
||||
if profile.FollowersCount != 100 || profile.FollowingCount != 50 || profile.PostCount != 200 {
|
||||
t.Errorf("Unexpected counts: followers=%d, following=%d, posts=%d", profile.FollowersCount, profile.FollowingCount, profile.PostCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchActors(t *testing.T) {
|
||||
server := setupMockServer()
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(t, server.URL)
|
||||
ctx := context.Background()
|
||||
|
||||
query := "alice"
|
||||
limit := 5
|
||||
|
||||
results, err := client.SearchActors(ctx, query, limit)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if results == nil {
|
||||
t.Fatal("Expected results not to be nil")
|
||||
}
|
||||
|
||||
if len(results) != limit {
|
||||
t.Errorf("Expected %d results, got %d", limit, len(results))
|
||||
}
|
||||
|
||||
for i, result := range results {
|
||||
expectedHandle := fmt.Sprintf("%s%d.test", query, i)
|
||||
if result.Handle != expectedHandle {
|
||||
t.Errorf("Result %d: expected handle %q, got %q", i, expectedHandle, result.Handle)
|
||||
}
|
||||
if result.Platform != "Bluesky" {
|
||||
t.Errorf("Result %d: expected platform 'Bluesky', got %q", i, result.Platform)
|
||||
}
|
||||
expectedURL := "https://bsky.app/profile/" + expectedHandle
|
||||
if result.URL != expectedURL {
|
||||
t.Errorf("Result %d: expected URL %q, got %q", i, expectedURL, result.URL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUser(t *testing.T) {
|
||||
server := setupMockServer()
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(t, server.URL)
|
||||
ctx := context.Background()
|
||||
|
||||
// Test with handle
|
||||
did, err := client.ResolveUser(ctx, "test.handle")
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error resolving handle, got %v", err)
|
||||
}
|
||||
if did.String() != "did:plc:resolved" {
|
||||
t.Errorf("Expected resolved DID 'did:plc:resolved', got %q", did.String())
|
||||
}
|
||||
|
||||
// Test with DID directly
|
||||
did2, err := client.ResolveUser(ctx, "did:plc:direct")
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error with direct DID, got %v", err)
|
||||
}
|
||||
if did2.String() != "did:plc:direct" {
|
||||
t.Errorf("Expected same DID 'did:plc:direct', got %q", did2.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFeedURI(t *testing.T) {
|
||||
server := setupMockServer()
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(t, server.URL)
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "AT URI",
|
||||
input: "at://did:plc:abc/app.bsky.feed.generator/xyz",
|
||||
expected: "at://did:plc:abc/app.bsky.feed.generator/xyz",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Web URI with profile/feed",
|
||||
input: "https://bsky.app/profile/did:plc:abc/feed/xyz",
|
||||
expected: "at://did:plc:abc/app.bsky.feed.generator/xyz",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Non-HTTP scheme",
|
||||
input: "gopher://bsky.app/profile/did:plc:abc/feed/xyz",
|
||||
expected: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid URI",
|
||||
input: "not-a-uri",
|
||||
expected: "",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := client.sanitizeFeedURI(ctx, tc.input)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("Expected error for input %q, got nil", tc.input)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error for input %q: %v", tc.input, err)
|
||||
}
|
||||
if result != tc.expected {
|
||||
t.Errorf("Input %q: expected %q, got %q", tc.input, tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestNPostsLimitClamp(t *testing.T) {
|
||||
server := setupMockServer()
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(t, server.URL)
|
||||
ctx := context.Background()
|
||||
actor := "did:plc:testactor"
|
||||
|
||||
t.Run("OverMaxLimitClampedTo100", func(t *testing.T) {
|
||||
output, err := client.GetLatestNPosts(ctx, actor, 250)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if len(output) != maxBskyFeedLimit {
|
||||
t.Errorf("Expected %d posts (clamped), got %d", maxBskyFeedLimit, len(output))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ZeroLimitReturnsNil", func(t *testing.T) {
|
||||
output, err := client.GetLatestNPosts(ctx, actor, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error with zero limit, got %v", err)
|
||||
}
|
||||
if output != nil {
|
||||
t.Errorf("Expected nil output for zero limit, got %d posts", len(output))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NegativeLimitReturnsNil", func(t *testing.T) {
|
||||
output, err := client.GetLatestNPosts(ctx, actor, -5)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error with negative limit, got %v", err)
|
||||
}
|
||||
if output != nil {
|
||||
t.Errorf("Expected nil output for negative limit, got %d posts", len(output))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetFeedHonorsLimit(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/xrpc/app.bsky.feed.getFeed", func(w http.ResponseWriter, r *http.Request) {
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
feed := make([]*bsky.FeedDefs_FeedViewPost, 0, limit)
|
||||
for i := 0; i < limit; i++ {
|
||||
feed = append(feed, &bsky.FeedDefs_FeedViewPost{
|
||||
Post: &bsky.FeedDefs_PostView{
|
||||
Uri: fmt.Sprintf("at://did:plc:test/app.bsky.feed.post/%d", i),
|
||||
Author: &bsky.ActorDefs_ProfileViewBasic{Did: "did:plc:test", Handle: "test.handle"},
|
||||
},
|
||||
})
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(bsky.FeedGetFeed_Output{Feed: feed})
|
||||
})
|
||||
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(t, server.URL)
|
||||
ctx := context.Background()
|
||||
feedURI := "at://did:plc:abc/app.bsky.feed.generator/xyz"
|
||||
|
||||
t.Run("ExplicitLimitPassedThrough", func(t *testing.T) {
|
||||
output, err := client.GetFeed(ctx, feedURI, 75)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if len(output.Feed) != 75 {
|
||||
t.Errorf("Expected 75 posts, got %d", len(output.Feed))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ZeroLimitDefaultsTo50", func(t *testing.T) {
|
||||
output, err := client.GetFeed(ctx, feedURI, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if len(output.Feed) != 50 {
|
||||
t.Errorf("Expected 50 posts (default), got %d", len(output.Feed))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("OverMaxLimitClampedTo100", func(t *testing.T) {
|
||||
output, err := client.GetFeed(ctx, feedURI, 1000)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if len(output.Feed) != maxBskyFeedLimit {
|
||||
t.Errorf("Expected %d posts (clamped), got %d", maxBskyFeedLimit, len(output.Feed))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetPostInvalidRecordKey(t *testing.T) {
|
||||
requests := 0
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/xrpc/app.bsky.feed.getPosts", func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
http.Error(w, `{"error":"InternalServerError"}`, http.StatusInternalServerError)
|
||||
})
|
||||
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(t, server.URL)
|
||||
ctx := context.Background()
|
||||
|
||||
post, err := client.GetPost(ctx, "did:plc:testactor", "", "bad/key")
|
||||
if post != nil {
|
||||
t.Fatalf("Expected nil post, got %v", post)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for invalid record key, got nil")
|
||||
}
|
||||
if requests != 0 {
|
||||
t.Errorf("Expected no network request for invalid record key, got %d", requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithRefreshConcurrent(t *testing.T) {
|
||||
var refreshCount atomic.Int32
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/xrpc/app.bsky.actor.getProfile", func(w http.ResponseWriter, r *http.Request) {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth != "Bearer refreshed_access_jwt" {
|
||||
// The initial access token is expired; the session must be refreshed
|
||||
// before the retry succeeds.
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": "ExpiredToken", "message": "Token expired, refresh required"})
|
||||
return
|
||||
}
|
||||
resp := map[string]any{
|
||||
"did": "did:plc:test",
|
||||
"handle": "test.handle",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
})
|
||||
mux.HandleFunc("/xrpc/com.atproto.server.refreshSession", func(w http.ResponseWriter, r *http.Request) {
|
||||
refreshCount.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(atproto.ServerRefreshSession_Output{
|
||||
AccessJwt: "refreshed_access_jwt",
|
||||
RefreshJwt: "refreshed_refresh_jwt",
|
||||
})
|
||||
})
|
||||
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(t, server.URL)
|
||||
ctx := context.Background()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := client.GetActor(ctx, "test.handle"); err != nil {
|
||||
t.Errorf("expected no error, got %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if refreshCount.Load() != 1 {
|
||||
t.Errorf("expected exactly 1 session refresh under concurrency, got %d", refreshCount.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPostsInTimeframeStopsAtStart(t *testing.T) {
|
||||
requests := 0
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/xrpc/app.bsky.feed.getAuthorFeed", func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
mk := func(uri, createdAt string) *bsky.FeedDefs_FeedViewPost {
|
||||
return &bsky.FeedDefs_FeedViewPost{
|
||||
Post: &bsky.FeedDefs_PostView{
|
||||
Uri: uri,
|
||||
Author: &bsky.ActorDefs_ProfileViewBasic{Did: "did:plc:testactor", Handle: "testactor.test"},
|
||||
Record: &util.LexiconTypeDecoder{
|
||||
Val: &bsky.FeedPost{Text: uri, CreatedAt: createdAt},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
feed := []*bsky.FeedDefs_FeedViewPost{
|
||||
mk("at://did:plc:testactor/app.bsky.feed.post/1", "2023-06-01T00:00:00Z"),
|
||||
mk("at://did:plc:testactor/app.bsky.feed.post/0", "2022-01-01T00:00:00Z"),
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(bsky.FeedGetAuthorFeed_Output{Feed: feed})
|
||||
})
|
||||
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(t, server.URL)
|
||||
ctx := context.Background()
|
||||
|
||||
start := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
end := time.Date(2023, 12, 31, 23, 59, 59, 0, time.UTC)
|
||||
|
||||
posts, err := client.GetPostsInTimeframe(ctx, "did:plc:testactor", start, end, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if len(posts) != 1 {
|
||||
t.Fatalf("expected 1 in-range post before early exit, got %d", len(posts))
|
||||
}
|
||||
if requests != 1 {
|
||||
t.Errorf("expected scan to stop after the first page, made %d requests", requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPostsInTimeframeIncludesBoundaries(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/xrpc/app.bsky.feed.getAuthorFeed", func(w http.ResponseWriter, r *http.Request) {
|
||||
mk := func(uri, createdAt string) *bsky.FeedDefs_FeedViewPost {
|
||||
return &bsky.FeedDefs_FeedViewPost{
|
||||
Post: &bsky.FeedDefs_PostView{
|
||||
Uri: uri,
|
||||
Author: &bsky.ActorDefs_ProfileViewBasic{Did: "did:plc:testactor", Handle: "testactor.test"},
|
||||
Record: &util.LexiconTypeDecoder{
|
||||
Val: &bsky.FeedPost{Text: uri, CreatedAt: createdAt},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
feed := []*bsky.FeedDefs_FeedViewPost{
|
||||
mk("at://did:plc:testactor/app.bsky.feed.post/end", "2023-12-31T23:59:59Z"),
|
||||
mk("at://did:plc:testactor/app.bsky.feed.post/start", "2023-01-01T00:00:00Z"),
|
||||
mk("at://did:plc:testactor/app.bsky.feed.post/older", "2022-12-31T23:59:59Z"),
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(bsky.FeedGetAuthorFeed_Output{Feed: feed})
|
||||
})
|
||||
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(t, server.URL)
|
||||
ctx := context.Background()
|
||||
|
||||
start := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
end := time.Date(2023, 12, 31, 23, 59, 59, 0, time.UTC)
|
||||
|
||||
posts, err := client.GetPostsInTimeframe(ctx, "did:plc:testactor", start, end, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
// Both boundary posts are inclusive; the post one second before start is not.
|
||||
if len(posts) != 2 {
|
||||
t.Fatalf("expected boundary posts to be included (got %d)", len(posts))
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, p := range posts {
|
||||
got[p.Post.Uri] = true
|
||||
}
|
||||
if !got["at://did:plc:testactor/app.bsky.feed.post/start"] {
|
||||
t.Error("expected post exactly at start to be included")
|
||||
}
|
||||
if !got["at://did:plc:testactor/app.bsky.feed.post/end"] {
|
||||
t.Error("expected post exactly at end to be included")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUserCache(t *testing.T) {
|
||||
var resolveRequests atomic.Int32
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/xrpc/com.atproto.identity.resolveHandle", func(w http.ResponseWriter, r *http.Request) {
|
||||
resolveRequests.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(atproto.IdentityResolveHandle_Output{Did: "did:plc:resolved"})
|
||||
})
|
||||
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(t, server.URL)
|
||||
ctx := context.Background()
|
||||
|
||||
// First resolution hits the network and populates the cache.
|
||||
did, err := client.ResolveUser(ctx, "test.handle")
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if did.String() != "did:plc:resolved" {
|
||||
t.Errorf("Unexpected DID %q", did.String())
|
||||
}
|
||||
if resolveRequests.Load() != 1 {
|
||||
t.Errorf("expected 1 resolve request, got %d", resolveRequests.Load())
|
||||
}
|
||||
|
||||
// Second resolution is served from cache.
|
||||
if _, err := client.ResolveUser(ctx, "test.handle"); err != nil {
|
||||
t.Fatalf("Expected no error on cached resolution, got %v", err)
|
||||
}
|
||||
if resolveRequests.Load() != 1 {
|
||||
t.Errorf("expected cached resolution to skip the network, got %d requests", resolveRequests.Load())
|
||||
}
|
||||
|
||||
// An expired cache entry is refetched.
|
||||
client.cacheMu.Lock()
|
||||
entry := client.didCache["test.handle"]
|
||||
entry.expiresAt = time.Now().Add(-time.Minute)
|
||||
client.didCache["test.handle"] = entry
|
||||
client.cacheMu.Unlock()
|
||||
|
||||
if _, err := client.ResolveUser(ctx, "test.handle"); err != nil {
|
||||
t.Fatalf("Expected no error after expiry, got %v", err)
|
||||
}
|
||||
if resolveRequests.Load() != 2 {
|
||||
t.Errorf("expected a fresh resolve after TTL expiry, got %d requests", resolveRequests.Load())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
37
internal/clustering/helpers.go
Normal file
37
internal/clustering/helpers.go
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
package clustering
|
||||
|
||||
import "math"
|
||||
|
||||
func normalize(v []float64) []float64 {
|
||||
out := make([]float64, len(v))
|
||||
|
||||
var norm float64
|
||||
for _, x := range v {
|
||||
norm += x * x
|
||||
}
|
||||
|
||||
norm = math.Sqrt(norm)
|
||||
|
||||
if norm == 0 {
|
||||
return out
|
||||
}
|
||||
|
||||
for i, x := range v {
|
||||
out[i] = x / norm
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func cosineSimilarity(a, b []float64) float64 {
|
||||
if len(a) != len(b) {
|
||||
return -1
|
||||
}
|
||||
|
||||
var dot float64
|
||||
for i := range a {
|
||||
dot += a[i] * b[i]
|
||||
}
|
||||
|
||||
return dot
|
||||
}
|
||||
228
internal/clustering/k-means.go
Normal file
228
internal/clustering/k-means.go
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
package clustering
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"math/rand"
|
||||
)
|
||||
|
||||
func initializeKMeansPlusPlus(
|
||||
vectors [][]float64,
|
||||
k int,
|
||||
rng *rand.Rand,
|
||||
) [][]float64 {
|
||||
centroids := make([][]float64, 0, k)
|
||||
|
||||
first := rng.Intn(len(vectors))
|
||||
centroids = append(
|
||||
centroids,
|
||||
append([]float64(nil), vectors[first]...),
|
||||
)
|
||||
|
||||
distances := make([]float64, len(vectors))
|
||||
|
||||
for len(centroids) < k {
|
||||
var total float64
|
||||
|
||||
for i, vector := range vectors {
|
||||
bestSimilarity := -math.MaxFloat64
|
||||
|
||||
for _, centroid := range centroids {
|
||||
similarity := cosineSimilarity(vector, centroid)
|
||||
if similarity > bestSimilarity {
|
||||
bestSimilarity = similarity
|
||||
}
|
||||
}
|
||||
|
||||
// cosine distance
|
||||
distance := 1 - bestSimilarity
|
||||
|
||||
if distance < 0 {
|
||||
distance = 0
|
||||
}
|
||||
|
||||
// k-means++ uses squared distance.
|
||||
distances[i] = distance * distance
|
||||
total += distances[i]
|
||||
}
|
||||
|
||||
if total == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
target := rng.Float64() * total
|
||||
|
||||
var cumulative float64
|
||||
selected := len(vectors) - 1
|
||||
|
||||
for i, distance := range distances {
|
||||
cumulative += distance
|
||||
|
||||
if cumulative >= target {
|
||||
selected = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
centroids = append(
|
||||
centroids,
|
||||
append([]float64(nil), vectors[selected]...),
|
||||
)
|
||||
}
|
||||
|
||||
// This can theoretically happen if all vectors are identical.
|
||||
for len(centroids) < k {
|
||||
index := rng.Intn(len(vectors))
|
||||
|
||||
centroids = append(
|
||||
centroids,
|
||||
append([]float64(nil), vectors[index]...),
|
||||
)
|
||||
}
|
||||
|
||||
return centroids
|
||||
}
|
||||
|
||||
func KMeans(items []Item, k int, maxIterations int, seed int64) ([]Result, error) {
|
||||
if len(items) == 0 {
|
||||
return nil, errors.New("no items")
|
||||
}
|
||||
|
||||
if k <= 0 {
|
||||
return nil, errors.New("k must be positive")
|
||||
}
|
||||
|
||||
if k > len(items) {
|
||||
k = len(items)
|
||||
}
|
||||
|
||||
if maxIterations <= 0 {
|
||||
maxIterations = 50
|
||||
}
|
||||
|
||||
dimension := len(items[0].Embedding)
|
||||
if dimension == 0 {
|
||||
return nil, errors.New("empty embedding")
|
||||
}
|
||||
|
||||
// Normalize embeddings once.
|
||||
normalized := make([][]float64, len(items))
|
||||
for i, item := range items {
|
||||
if len(item.Embedding) != dimension {
|
||||
return nil, errors.New("embedding dimensions do not match")
|
||||
}
|
||||
|
||||
normalized[i] = normalize(item.Embedding)
|
||||
}
|
||||
|
||||
rng := rand.New(rand.NewSource(seed))
|
||||
|
||||
centroids := initializeKMeansPlusPlus(normalized, k, rng)
|
||||
|
||||
assignments := make([]int, len(items))
|
||||
|
||||
for iteration := 0; iteration < maxIterations; iteration++ {
|
||||
changed := false
|
||||
|
||||
// Assignment step.
|
||||
for i, vector := range normalized {
|
||||
bestCluster := 0
|
||||
bestSimilarity := -math.MaxFloat64
|
||||
|
||||
for cluster, centroid := range centroids {
|
||||
similarity := cosineSimilarity(vector, centroid)
|
||||
|
||||
if similarity > bestSimilarity {
|
||||
bestSimilarity = similarity
|
||||
bestCluster = cluster
|
||||
}
|
||||
}
|
||||
|
||||
if iteration == 0 || assignments[i] != bestCluster {
|
||||
assignments[i] = bestCluster
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
// Stop if nothing changed.
|
||||
if !changed && iteration > 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// Update step.
|
||||
newCentroids := make([][]float64, k)
|
||||
counts := make([]int, k)
|
||||
|
||||
for cluster := 0; cluster < k; cluster++ {
|
||||
newCentroids[cluster] = make([]float64, dimension)
|
||||
}
|
||||
|
||||
for i, vector := range normalized {
|
||||
cluster := assignments[i]
|
||||
counts[cluster]++
|
||||
|
||||
for d, value := range vector {
|
||||
newCentroids[cluster][d] += value
|
||||
}
|
||||
}
|
||||
|
||||
for cluster := 0; cluster < k; cluster++ {
|
||||
if counts[cluster] == 0 {
|
||||
// Empty cluster: reinitialize with a random item.
|
||||
index := rng.Intn(len(normalized))
|
||||
newCentroids[cluster] = append(
|
||||
[]float64(nil),
|
||||
normalized[index]...,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
newCentroids[cluster] = normalize(newCentroids[cluster])
|
||||
}
|
||||
|
||||
centroids = newCentroids
|
||||
}
|
||||
|
||||
results := make([]Result, k)
|
||||
|
||||
for cluster := 0; cluster < k; cluster++ {
|
||||
results[cluster] = Result{
|
||||
Cluster: cluster,
|
||||
Centroid: centroids[cluster],
|
||||
}
|
||||
}
|
||||
|
||||
for i, item := range items {
|
||||
cluster := assignments[i]
|
||||
|
||||
item.Embedding = normalized[i]
|
||||
|
||||
results[cluster].Items = append(
|
||||
results[cluster].Items,
|
||||
item,
|
||||
)
|
||||
}
|
||||
|
||||
// Find the item closest to each centroid.
|
||||
for cluster := range results {
|
||||
if len(results[cluster].Items) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
bestSimilarity := -math.MaxFloat64
|
||||
|
||||
for _, item := range results[cluster].Items {
|
||||
similarity := cosineSimilarity(
|
||||
item.Embedding,
|
||||
results[cluster].Centroid,
|
||||
)
|
||||
|
||||
if similarity > bestSimilarity {
|
||||
bestSimilarity = similarity
|
||||
results[cluster].Representative = item
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
213
internal/clustering/kmeans_test.go
Normal file
213
internal/clustering/kmeans_test.go
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
package clustering
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/rand"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestKMeans_Basic(t *testing.T) {
|
||||
items := []Item{
|
||||
{ID: "1", Text: "a", Embedding: []float64{1.0, 0.0}},
|
||||
{ID: "2", Text: "b", Embedding: []float64{0.9, 0.1}},
|
||||
{ID: "3", Text: "c", Embedding: []float64{-1.0, 0.0}},
|
||||
{ID: "4", Text: "d", Embedding: []float64{-0.9, -0.1}},
|
||||
}
|
||||
|
||||
results, err := KMeans(items, 2, 50, 42)
|
||||
if err != nil {
|
||||
t.Fatalf("KMeans failed: %v", err)
|
||||
}
|
||||
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("expected 2 clusters, got %d", len(results))
|
||||
}
|
||||
|
||||
// Check that items are assigned to clusters
|
||||
totalItems := 0
|
||||
for _, r := range results {
|
||||
totalItems += len(r.Items)
|
||||
}
|
||||
if totalItems != len(items) {
|
||||
t.Fatalf("expected %d items across clusters, got %d", len(items), totalItems)
|
||||
}
|
||||
|
||||
// Verify centroids are normalized
|
||||
for _, r := range results {
|
||||
norm := 0.0
|
||||
for _, v := range r.Centroid {
|
||||
norm += v * v
|
||||
}
|
||||
norm = math.Sqrt(norm)
|
||||
if math.Abs(norm-1.0) > 1e-6 {
|
||||
t.Errorf("centroid not normalized: norm=%f", norm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKMeans_Empty(t *testing.T) {
|
||||
_, err := KMeans([]Item{}, 2, 50, 42)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty items")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKMeans_KGreaterThanItems(t *testing.T) {
|
||||
items := []Item{
|
||||
{ID: "1", Text: "a", Embedding: []float64{1.0, 0.0}},
|
||||
{ID: "2", Text: "b", Embedding: []float64{0.0, 1.0}},
|
||||
}
|
||||
|
||||
results, err := KMeans(items, 5, 50, 42)
|
||||
if err != nil {
|
||||
t.Fatalf("KMeans failed: %v", err)
|
||||
}
|
||||
|
||||
// k should be capped to len(items)
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("expected 2 clusters (capped), got %d", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestKMeans_DeterministicWithSeed(t *testing.T) {
|
||||
items := []Item{
|
||||
{ID: "1", Text: "a", Embedding: []float64{1.0, 0.0}},
|
||||
{ID: "2", Text: "b", Embedding: []float64{0.9, 0.1}},
|
||||
{ID: "3", Text: "c", Embedding: []float64{-1.0, 0.0}},
|
||||
{ID: "4", Text: "d", Embedding: []float64{-0.9, -0.1}},
|
||||
}
|
||||
|
||||
results1, _ := KMeans(items, 2, 50, 42)
|
||||
results2, _ := KMeans(items, 2, 50, 42)
|
||||
|
||||
// Same seed should produce same assignments
|
||||
for i := range results1 {
|
||||
if len(results1[i].Items) != len(results2[i].Items) {
|
||||
t.Fatalf("cluster %d size differs: %d vs %d", i, len(results1[i].Items), len(results2[i].Items))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCosineSimilarity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
a, b []float64
|
||||
expected float64
|
||||
}{
|
||||
{"identical", []float64{1, 0}, []float64{1, 0}, 1.0},
|
||||
{"orthogonal", []float64{1, 0}, []float64{0, 1}, 0.0},
|
||||
{"opposite", []float64{1, 0}, []float64{-1, 0}, -1.0},
|
||||
{"scaled", []float64{2, 0}, []float64{1, 0}, 2.0}, // not normalized
|
||||
{"mismatched len", []float64{1, 0}, []float64{1, 0, 0}, -1.0},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := cosineSimilarity(tc.a, tc.b)
|
||||
if math.Abs(got-tc.expected) > 1e-6 {
|
||||
t.Errorf("cosineSimilarity(%v, %v) = %f, want %f", tc.a, tc.b, got, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
v := []float64{3.0, 4.0}
|
||||
normalized := normalize(v)
|
||||
|
||||
// Check length is 1
|
||||
norm := 0.0
|
||||
for _, x := range normalized {
|
||||
norm += x * x
|
||||
}
|
||||
norm = math.Sqrt(norm)
|
||||
if math.Abs(norm-1.0) > 1e-6 {
|
||||
t.Errorf("normalized vector has norm %f, want 1.0", norm)
|
||||
}
|
||||
|
||||
// Check zero vector handling
|
||||
zero := normalize([]float64{0.0, 0.0})
|
||||
if zero[0] != 0.0 || zero[1] != 0.0 {
|
||||
t.Errorf("zero vector normalization failed: %v", zero)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitializeKMeansPlusPlus(t *testing.T) {
|
||||
vectors := [][]float64{
|
||||
{1.0, 0.0},
|
||||
{0.9, 0.1},
|
||||
{-1.0, 0.0},
|
||||
{-0.9, -0.1},
|
||||
}
|
||||
|
||||
rng := rand.New(rand.NewSource(42))
|
||||
centroids := initializeKMeansPlusPlus(vectors, 2, rng)
|
||||
|
||||
if len(centroids) != 2 {
|
||||
t.Fatalf("expected 2 centroids, got %d", len(centroids))
|
||||
}
|
||||
|
||||
// Centroids should be from the input vectors (or averages)
|
||||
found1, found2 := false, false
|
||||
for _, c := range centroids {
|
||||
for _, v := range vectors {
|
||||
if equalVectors(c, v) {
|
||||
if !found1 {
|
||||
found1 = true
|
||||
} else {
|
||||
found2 = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found1 || !found2 {
|
||||
t.Errorf("centroids not from input vectors: %v", centroids)
|
||||
}
|
||||
}
|
||||
|
||||
func equalVectors(a, b []float64) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if math.Abs(a[i]-b[i]) > 1e-6 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestKMeans_HighDimensional(t *testing.T) {
|
||||
// Test with 1024-dim vectors (like qwen3-embedding)
|
||||
dim := 1024
|
||||
items := make([]Item, 10)
|
||||
for i := range items {
|
||||
emb := make([]float64, dim)
|
||||
if i < 5 {
|
||||
emb[0] = 1.0 // cluster 1
|
||||
} else {
|
||||
emb[dim-1] = 1.0 // cluster 2
|
||||
}
|
||||
items[i] = Item{
|
||||
ID: string(rune('a' + i)),
|
||||
Text: "test",
|
||||
Embedding: emb,
|
||||
}
|
||||
}
|
||||
|
||||
results, err := KMeans(items, 2, 50, 42)
|
||||
if err != nil {
|
||||
t.Fatalf("KMeans failed: %v", err)
|
||||
}
|
||||
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("expected 2 clusters, got %d", len(results))
|
||||
}
|
||||
|
||||
// Verify each cluster has ~5 items
|
||||
for _, r := range results {
|
||||
if len(r.Items) < 3 || len(r.Items) > 7 {
|
||||
t.Errorf("cluster size %d unexpected", len(r.Items))
|
||||
}
|
||||
}
|
||||
}
|
||||
21
internal/clustering/types.go
Normal file
21
internal/clustering/types.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package clustering
|
||||
|
||||
import "time"
|
||||
|
||||
type Item struct {
|
||||
ID string
|
||||
Text string
|
||||
Embedding []float64
|
||||
Timestamp time.Time
|
||||
Likes int64
|
||||
Reposts int64
|
||||
Replies int64
|
||||
Quotes int64
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Cluster int
|
||||
Items []Item
|
||||
Centroid []float64
|
||||
Representative Item
|
||||
}
|
||||
|
|
@ -2,33 +2,71 @@ package config
|
|||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
log "github.com/nacorid/logger"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ListenAddress string
|
||||
ListenPort string
|
||||
UnixSocketPath string
|
||||
UnixSocketPermissions os.FileMode
|
||||
DatabaseURL string
|
||||
ActivityPubHost string
|
||||
ActivityPubToken string
|
||||
BlueskyDID string
|
||||
BlueskyAppPassword string
|
||||
BlueskyHost string
|
||||
OllamaHost string
|
||||
X402FacilitatorURL string
|
||||
X402WalletAddress string
|
||||
ApiKey string
|
||||
ApiSecret string
|
||||
LogFile string
|
||||
ActiveNetworks string
|
||||
ListenAddress string
|
||||
ListenPort string
|
||||
UnixSocketPath string
|
||||
UnixSocketPermissions os.FileMode
|
||||
DatabaseURL string
|
||||
ActivityPubHost string
|
||||
ActivityPubToken string
|
||||
BlueskyDID string
|
||||
BlueskyAppPassword string
|
||||
BlueskyHost string
|
||||
NostrRelays []string
|
||||
TwitterBearerToken string
|
||||
PrewarmAuthors []string
|
||||
PrewarmFeedURI string
|
||||
EnrichCrossPlatform bool
|
||||
OllamaHost string
|
||||
MigrateOnStart bool
|
||||
MigrationsDir string
|
||||
ReadyzAddress string
|
||||
ReadyzPort string
|
||||
MetricsPort string
|
||||
X402FacilitatorURL string
|
||||
X402WalletAddress string
|
||||
ApiKey string
|
||||
ApiSecret string
|
||||
LogFile string
|
||||
ActiveNetworks string
|
||||
MatrixWebhookURL string
|
||||
MatrixServerID string
|
||||
EmbeddingModel string
|
||||
StyleModel string
|
||||
AnalyticsTTLEngagement time.Duration
|
||||
AnalyticsTTLPatterns time.Duration
|
||||
AnalyticsTTLTopics time.Duration
|
||||
AnalyticsTTLSentiment time.Duration
|
||||
AnalyticsTTLDrivers time.Duration
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
// Load config/models.env for model defaults (single source of truth)
|
||||
// Find project root by looking for go.mod
|
||||
root := findProjectRoot()
|
||||
modelEnvPaths := []string{
|
||||
filepath.Join(root, "config", "models.env"),
|
||||
filepath.Join(root, "..", "config", "models.env"), // fallback
|
||||
}
|
||||
for _, p := range modelEnvPaths {
|
||||
if err := godotenv.Load(p); err == nil {
|
||||
log.Debug("Loaded model config", "path", p)
|
||||
break
|
||||
} else {
|
||||
log.Debug("Model config not found", "path", p, "error", err)
|
||||
}
|
||||
}
|
||||
// Also load .env for local development overrides
|
||||
if err := godotenv.Load(); err != nil {
|
||||
log.Debug("No .env file found, reading from environment")
|
||||
}
|
||||
|
|
@ -38,24 +76,59 @@ func Load() *Config {
|
|||
if err != nil {
|
||||
log.Fatal("Invalid UNIX_SOCKET_PERMISSIONS", "error", err)
|
||||
}
|
||||
return &Config{
|
||||
ListenAddress: getEnv("SERVER_ADDRESS", "127.0.0.1"),
|
||||
ListenPort: getEnv("SERVER_PORT", "8080"),
|
||||
UnixSocketPermissions: os.FileMode(perms),
|
||||
DatabaseURL: getEnv("GOOSE_DBSTRING", "postgres:///mcpserver"),
|
||||
ActivityPubHost: getEnv("AP_HOST", "https://mastodon.social"),
|
||||
ActivityPubToken: getEnv("AP_ACCESS_TOKEN", ""),
|
||||
BlueskyDID: getEnv("BLUESKY_DID", ""),
|
||||
BlueskyAppPassword: getEnv("BLUESKY_APP_PASSWORD", ""),
|
||||
BlueskyHost: getEnv("BLUESKY_HOST", "https://bsky.social"),
|
||||
OllamaHost: getEnv("OLLAMA_HOST", "http://localhost:11434"),
|
||||
X402FacilitatorURL: getEnv("X402_FACILITATOR_URL", "https://facilitator.x402.rs"),
|
||||
X402WalletAddress: getEnv("X402_WALLET_ADDRESS", ""),
|
||||
ApiKey: getEnv("CDP_API_KEY_ID", ""),
|
||||
ApiSecret: getEnv("CDP_API_SECRET", ""),
|
||||
LogFile: getEnv("LOG_FILE", "/var/log/mcpserver/output.log"),
|
||||
ActiveNetworks: getEnv("ACTIVE_NETWORKS", "base"),
|
||||
cfg := &Config{
|
||||
ListenAddress: getEnv("SERVER_ADDRESS", "127.0.0.1"),
|
||||
ListenPort: getEnv("SERVER_PORT", "8080"),
|
||||
UnixSocketPermissions: os.FileMode(perms),
|
||||
DatabaseURL: getEnv("GOOSE_DBSTRING", "postgres:///mcpserver"),
|
||||
ActivityPubHost: getEnv("AP_HOST", "https://mastodon.social"),
|
||||
ActivityPubToken: getEnv("AP_ACCESS_TOKEN", ""),
|
||||
BlueskyDID: getEnv("BLUESKY_DID", ""),
|
||||
BlueskyAppPassword: getEnv("BLUESKY_APP_PASSWORD", ""),
|
||||
BlueskyHost: getEnv("BLUESKY_HOST", "https://bsky.social"),
|
||||
NostrRelays: splitComma(getEnv("NOSTR_RELAYS", "wss://relay.damus.io,wss://nos.lol")),
|
||||
TwitterBearerToken: getEnvOptional("TWITTER_BEARER_TOKEN"),
|
||||
PrewarmAuthors: splitComma(getEnvOptional("PREWARM_AUTHORS")),
|
||||
PrewarmFeedURI: getEnvOptional("PREWARM_FEED_URI"),
|
||||
EnrichCrossPlatform: getEnvBool("ENRICH_CROSS_PLATFORM", "true"),
|
||||
OllamaHost: getEnv("OLLAMA_HOST", "http://localhost:11434"),
|
||||
MigrateOnStart: getEnvBool("MIGRATE_ON_START", "true"),
|
||||
MigrationsDir: getEnv("MIGRATIONS_DIR", "sql/schema"),
|
||||
ReadyzAddress: getEnv("READYZ_ADDRESS", "127.0.0.1"),
|
||||
ReadyzPort: getEnv("READYZ_PORT", "9090"),
|
||||
MetricsPort: getEnv("METRICS_PORT", "9091"),
|
||||
X402FacilitatorURL: getEnv("X402_FACILITATOR_URL", "https://api.cdp.coinbase.com/platform/v2/x402"),
|
||||
X402WalletAddress: getEnv("X402_WALLET_ADDRESS", ""),
|
||||
ApiKey: getEnv("CDP_API_KEY_ID", ""),
|
||||
ApiSecret: getEnv("CDP_API_SECRET", ""),
|
||||
LogFile: getEnv("LOG_FILE", "/var/log/mcpserver/output.log"),
|
||||
ActiveNetworks: getEnv("ACTIVE_NETWORKS", "base"),
|
||||
MatrixWebhookURL: os.Getenv("MATRIX_WEBHOOK_URL"),
|
||||
MatrixServerID: os.Getenv("MATRIX_SERVER_ID"),
|
||||
EmbeddingModel: getEnv("EMBEDDING_MODEL", ""),
|
||||
StyleModel: getEnv("STYLE_MODEL", ""),
|
||||
AnalyticsTTLEngagement: getEnvDuration("ANALYTICS_TTL_ENGAGEMENT", "1h"),
|
||||
AnalyticsTTLPatterns: getEnvDuration("ANALYTICS_TTL_PATTERNS", "6h"),
|
||||
AnalyticsTTLTopics: getEnvDuration("ANALYTICS_TTL_TOPICS", "24h"),
|
||||
AnalyticsTTLSentiment: getEnvDuration("ANALYTICS_TTL_SENTIMENT", "6h"),
|
||||
AnalyticsTTLDrivers: getEnvDuration("ANALYTICS_TTL_DRIVERS", "1h"),
|
||||
}
|
||||
|
||||
for _, port := range []string{cfg.ReadyzPort, cfg.MetricsPort, cfg.ListenPort} {
|
||||
if _, err := strconv.Atoi(port); err != nil {
|
||||
log.Fatalf("Invalid port %q: must be a numeric port value", port)
|
||||
}
|
||||
}
|
||||
|
||||
// Default MatrixServerID to system hostname if not explicitly set
|
||||
if cfg.MatrixServerID == "" {
|
||||
hostname, err := os.Hostname()
|
||||
if err == nil && hostname != "" {
|
||||
cfg.MatrixServerID = hostname
|
||||
}
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
|
|
@ -67,3 +140,59 @@ func getEnv(key, fallback string) string {
|
|||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getEnvOptional(key string) string {
|
||||
if value, ok := os.LookupEnv(key); ok {
|
||||
return value
|
||||
}
|
||||
log.Warn("Environment variable is not set (optional)", "key", key)
|
||||
return ""
|
||||
}
|
||||
|
||||
func getEnvBool(key, fallback string) bool {
|
||||
v := getEnv(key, fallback)
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func getEnvDuration(key, fallback string) time.Duration {
|
||||
v := getEnv(key, fallback)
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
d, _ = time.ParseDuration(fallback)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func splitComma(s string) []string {
|
||||
parts := make([]string, 0)
|
||||
for p := range strings.SplitSeq(s, ",") {
|
||||
if trimmed := strings.TrimSpace(p); trimmed != "" {
|
||||
parts = append(parts, trimmed)
|
||||
}
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func findProjectRoot() string {
|
||||
// Start from current working directory and walk up looking for go.mod
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "."
|
||||
}
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
||||
return dir
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break // reached filesystem root
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
return "."
|
||||
}
|
||||
|
|
|
|||
372
internal/nostr/client.go
Normal file
372
internal/nostr/client.go
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
package nostr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
|
||||
"github.com/nacorid/naco-api/internal/actor"
|
||||
)
|
||||
|
||||
const (
|
||||
// relayQueryTimeout bounds connect + subscribe + collect against a single
|
||||
// relay. The websocket dial, subscription handshake and event collection all
|
||||
// share this budget.
|
||||
relayQueryTimeout = 5 * time.Second
|
||||
|
||||
// maxTotalRelayTime caps the fan-out across all relays when the caller's
|
||||
// context carries no deadline of its own. Without it a long relay list could
|
||||
// multiply the per-relay budget unboundedly.
|
||||
maxTotalRelayTime = 20 * time.Second
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
relays []string
|
||||
}
|
||||
|
||||
func NewNostrClient(relays []string) (*Client, error) {
|
||||
seen := make(map[string]struct{}, len(relays))
|
||||
cleaned := make([]string, 0, len(relays))
|
||||
for _, r := range relays {
|
||||
r = strings.TrimSpace(r)
|
||||
if r == "" {
|
||||
continue
|
||||
}
|
||||
u, err := url.Parse(r)
|
||||
if err != nil || (u.Scheme != "ws" && u.Scheme != "wss") {
|
||||
return nil, fmt.Errorf("invalid relay URL %q: must be a ws:// or wss:// endpoint", r)
|
||||
}
|
||||
if _, dup := seen[r]; dup {
|
||||
continue
|
||||
}
|
||||
seen[r] = struct{}{}
|
||||
cleaned = append(cleaned, r)
|
||||
}
|
||||
if len(cleaned) == 0 {
|
||||
return nil, fmt.Errorf("at least one relay is required")
|
||||
}
|
||||
return &Client{relays: cleaned}, nil
|
||||
}
|
||||
|
||||
type Post struct {
|
||||
ID string
|
||||
Author string
|
||||
Content string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// queryBudget returns a context bounded to maxTotalRelayTime when ctx has no
|
||||
// deadline, so the relay fan-out cannot run away across many relays. Callers
|
||||
// must invoke the returned cancel.
|
||||
func (c *Client) queryBudget(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
if _, ok := ctx.Deadline(); ok {
|
||||
return context.WithCancel(ctx)
|
||||
}
|
||||
return context.WithTimeout(ctx, maxTotalRelayTime)
|
||||
}
|
||||
|
||||
// collectFromRelay connects to url within ctx, subscribes with filter and
|
||||
// appends matching events to posts (deduplicated by event id) until the limit is
|
||||
// reached, EOSE arrives, or ctx is done. It reports whether the relay was
|
||||
// actually queryable (connected + subscribed), letting callers distinguish a
|
||||
// total relay outage from a legitimate empty result.
|
||||
func (c *Client) collectFromRelay(ctx context.Context, url string, filter nostr.Filter, limit int, posts *[]*Post, seen map[string]struct{}) (used bool) {
|
||||
relay, err := nostr.RelayConnect(ctx, url, nostr.RelayOptions{})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer relay.Close()
|
||||
|
||||
sub, err := relay.Subscribe(ctx, filter, nostr.SubscriptionOptions{})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer sub.Unsub()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-sub.EndOfStoredEvents:
|
||||
return true
|
||||
case ev, ok := <-sub.Events:
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
id := ev.ID.Hex()
|
||||
if _, dup := seen[id]; dup {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
*posts = append(*posts, &Post{
|
||||
ID: id,
|
||||
Author: ev.PubKey.Hex(),
|
||||
Content: ev.Content,
|
||||
CreatedAt: ev.CreatedAt.Time(),
|
||||
})
|
||||
if len(*posts) >= limit {
|
||||
return true
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// collectPosts subscribes to filter across every relay, stopping once limit posts
|
||||
// are collected or the query context is done. It returns the deduplicated posts,
|
||||
// or an error if no relay could be queried at all or the operation was aborted.
|
||||
func (c *Client) collectPosts(ctx context.Context, filter nostr.Filter, limit int) ([]*Post, error) {
|
||||
posts := make([]*Post, 0, limit)
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
queryCtx, cancel := c.queryBudget(ctx)
|
||||
defer cancel()
|
||||
|
||||
var anyUsed bool
|
||||
for _, url := range c.relays {
|
||||
if queryCtx.Err() != nil {
|
||||
break
|
||||
}
|
||||
relayCtx, relayCancel := context.WithTimeout(queryCtx, relayQueryTimeout)
|
||||
used := c.collectFromRelay(relayCtx, url, filter, limit, &posts, seen)
|
||||
relayCancel()
|
||||
anyUsed = anyUsed || used
|
||||
if len(posts) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if err := queryCtx.Err(); err != nil {
|
||||
return nil, fmt.Errorf("nostr query aborted: %w", err)
|
||||
}
|
||||
if !anyUsed {
|
||||
return nil, fmt.Errorf("could not connect to any relay")
|
||||
}
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetPost(ctx context.Context, id string) (*Post, error) {
|
||||
eid, err := resolveEventID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filter := nostr.Filter{
|
||||
IDs: []nostr.ID{eid},
|
||||
Kinds: []nostr.Kind{1},
|
||||
}
|
||||
posts, err := c.collectPosts(ctx, filter, 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(posts) == 0 {
|
||||
return nil, fmt.Errorf("post %q not found across configured relays", id)
|
||||
}
|
||||
return posts[0], nil
|
||||
}
|
||||
|
||||
func (c *Client) GetLatestNPosts(ctx context.Context, author string, limit int) ([]*Post, error) {
|
||||
if limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
pubkey, err := resolvePubkey(author)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filter := nostr.Filter{
|
||||
Authors: []nostr.PubKey{pubkey},
|
||||
Kinds: []nostr.Kind{1},
|
||||
Limit: limit,
|
||||
}
|
||||
return c.collectPosts(ctx, filter, limit)
|
||||
}
|
||||
|
||||
func (c *Client) GetPostsInTimeframe(ctx context.Context, author string, start, end time.Time, limit int) ([]*Post, error) {
|
||||
if limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
pubkey, err := resolvePubkey(author)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filter := nostr.Filter{
|
||||
Authors: []nostr.PubKey{pubkey},
|
||||
Kinds: []nostr.Kind{1},
|
||||
Since: nostr.Timestamp(start.Unix()),
|
||||
Until: nostr.Timestamp(end.Unix()),
|
||||
Limit: limit,
|
||||
}
|
||||
return c.collectPosts(ctx, filter, limit)
|
||||
}
|
||||
|
||||
func (c *Client) GetActor(ctx context.Context, author string) (*actor.Profile, error) {
|
||||
pubkey, err := resolvePubkey(author)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
npub := nip19.EncodeNpub(pubkey)
|
||||
|
||||
filter := nostr.Filter{
|
||||
Authors: []nostr.PubKey{pubkey},
|
||||
Kinds: []nostr.Kind{0},
|
||||
Limit: 1,
|
||||
}
|
||||
|
||||
queryCtx, cancel := c.queryBudget(ctx)
|
||||
defer cancel()
|
||||
|
||||
var anyUsed bool
|
||||
for _, url := range c.relays {
|
||||
if queryCtx.Err() != nil {
|
||||
break
|
||||
}
|
||||
relayCtx, relayCancel := context.WithTimeout(queryCtx, relayQueryTimeout)
|
||||
profile, used := c.actorFromRelay(relayCtx, url, filter, npub)
|
||||
relayCancel()
|
||||
anyUsed = anyUsed || used
|
||||
if profile != nil {
|
||||
return profile, nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := queryCtx.Err(); err != nil {
|
||||
return nil, fmt.Errorf("nostr query aborted: %w", err)
|
||||
}
|
||||
if !anyUsed {
|
||||
return nil, fmt.Errorf("could not connect to any relay")
|
||||
}
|
||||
return nil, fmt.Errorf("no profile metadata found for %q", author)
|
||||
}
|
||||
|
||||
// actorFromRelay looks up one kind-0 metadata event on url. It returns a
|
||||
// non-nil profile when a usable metadata event was found. used reports whether
|
||||
// the relay was queried at all (so a fully unreachable relay list is
|
||||
// distinguishable from an author without metadata).
|
||||
func (c *Client) actorFromRelay(ctx context.Context, url string, filter nostr.Filter, npub string) (profile *actor.Profile, used bool) {
|
||||
relay, err := nostr.RelayConnect(ctx, url, nostr.RelayOptions{})
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
defer relay.Close()
|
||||
|
||||
sub, err := relay.Subscribe(ctx, filter, nostr.SubscriptionOptions{})
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
defer sub.Unsub()
|
||||
|
||||
select {
|
||||
case <-sub.EndOfStoredEvents:
|
||||
return nil, true
|
||||
case ev, ok := <-sub.Events:
|
||||
if !ok {
|
||||
return nil, true
|
||||
}
|
||||
return profileFromEvent(ev, npub), true
|
||||
case <-ctx.Done():
|
||||
return nil, true
|
||||
}
|
||||
}
|
||||
|
||||
// profileFromEvent builds an actor.Profile from a kind-0 metadata event, or nil
|
||||
// when the content is not valid JSON or carries no usable fields.
|
||||
func profileFromEvent(ev nostr.Event, npub string) *actor.Profile {
|
||||
var meta struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
About string `json:"about"`
|
||||
Picture string `json:"picture"`
|
||||
Website string `json:"website"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(ev.Content), &meta); err != nil {
|
||||
return nil
|
||||
}
|
||||
if meta.Name == "" && meta.DisplayName == "" && meta.About == "" && meta.Picture == "" {
|
||||
return nil
|
||||
}
|
||||
displayName := meta.DisplayName
|
||||
if displayName == "" {
|
||||
displayName = meta.Name
|
||||
}
|
||||
return &actor.Profile{
|
||||
Platform: "Nostr",
|
||||
Handle: npub,
|
||||
DisplayName: displayName,
|
||||
AvatarURL: meta.Picture,
|
||||
Bio: meta.About,
|
||||
URL: "https://njump.me/" + npub,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) SearchActors(ctx context.Context, query string, limit int) ([]*actor.Profile, error) {
|
||||
if _, err := resolvePubkey(query); err != nil {
|
||||
// Not a pubkey we can resolve (e.g. a name query). Treat it as no match
|
||||
// rather than an error so a cross-platform search isn't failed by a
|
||||
// non-nostr query.
|
||||
return nil, nil
|
||||
}
|
||||
prof, err := c.GetActor(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []*actor.Profile{prof}, nil
|
||||
}
|
||||
|
||||
func resolvePubkey(actor string) (nostr.PubKey, error) {
|
||||
prefix, value, err := nip19.Decode(actor)
|
||||
if err == nil {
|
||||
switch prefix {
|
||||
case "npub":
|
||||
if pk, ok := value.(nostr.PubKey); ok {
|
||||
return pk, nil
|
||||
}
|
||||
case "nprofile":
|
||||
if pp, ok := value.(nostr.ProfilePointer); ok {
|
||||
return pp.PublicKey, nil
|
||||
}
|
||||
}
|
||||
return nostr.PubKey{}, fmt.Errorf("unsupported nostr entity %q", prefix)
|
||||
}
|
||||
|
||||
if len(actor) == 64 {
|
||||
pk, e := nostr.PubKeyFromHex(actor)
|
||||
if e != nil {
|
||||
return nostr.PubKey{}, fmt.Errorf("invalid nostr author %q: %w", actor, e)
|
||||
}
|
||||
return pk, nil
|
||||
}
|
||||
return nostr.PubKey{}, fmt.Errorf("invalid nostr author %q: %w", actor, err)
|
||||
}
|
||||
|
||||
func resolveEventID(id string) (nostr.ID, error) {
|
||||
prefix, value, err := nip19.Decode(id)
|
||||
if err == nil {
|
||||
switch prefix {
|
||||
case "note":
|
||||
if eid, ok := value.(nostr.ID); ok {
|
||||
return eid, nil
|
||||
}
|
||||
case "nevent":
|
||||
if ep, ok := value.(nostr.EventPointer); ok {
|
||||
return ep.ID, nil
|
||||
}
|
||||
}
|
||||
return nostr.ID{}, fmt.Errorf("unsupported nostr entity %q", prefix)
|
||||
}
|
||||
|
||||
if len(id) == 64 {
|
||||
eid, e := nostr.IDFromHex(id)
|
||||
if e != nil {
|
||||
return nostr.ID{}, fmt.Errorf("invalid nostr event id %q: %w", id, e)
|
||||
}
|
||||
return eid, nil
|
||||
}
|
||||
return nostr.ID{}, fmt.Errorf("invalid nostr event id %q: %w", id, err)
|
||||
}
|
||||
305
internal/nostr/client_test.go
Normal file
305
internal/nostr/client_test.go
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
package nostr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
)
|
||||
|
||||
func TestNewNostrClientEmptyRelays(t *testing.T) {
|
||||
_, err := NewNostrClient([]string{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty relays")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "at least one relay") {
|
||||
t.Fatalf("expected 'at least one relay' error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePubkeyVariants(t *testing.T) {
|
||||
// Create a valid pubkey for testing using Generate()
|
||||
sk := nostr.Generate()
|
||||
pk := sk.Public()
|
||||
npub := nip19.EncodeNpub(pk)
|
||||
hex := pk.Hex()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
{"npub encoding", npub, false},
|
||||
{"64-char hex", hex, false},
|
||||
{"invalid npub prefix", "nsec" + npub[4:], true},
|
||||
{"too short hex", hex[:32], true},
|
||||
{"random string", "not-a-key", true},
|
||||
{"empty string", "", true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := resolvePubkey(tc.input)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for %q, got nil", tc.input)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for %q: %v", tc.input, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEventIDVariants(t *testing.T) {
|
||||
var eid nostr.ID
|
||||
for i := range eid {
|
||||
eid[i] = byte(i + 1)
|
||||
}
|
||||
hex := eid.Hex()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
{"64-char hex", hex, false},
|
||||
{"too short hex", hex[:32], true},
|
||||
{"random string", "not-an-id", true},
|
||||
{"empty string", "", true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := resolveEventID(tc.input)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for %q, got nil", tc.input)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for %q: %v", tc.input, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPostInvalidID(t *testing.T) {
|
||||
c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = c.GetPost(context.Background(), "invalid-id")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid event ID")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid nostr event id") {
|
||||
t.Fatalf("expected 'invalid nostr event id' error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetActorInvalidAuthor(t *testing.T) {
|
||||
c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = c.GetActor(context.Background(), "invalid-author")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid author")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid nostr author") {
|
||||
t.Fatalf("expected 'invalid nostr author' error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestNPostsInvalidAuthor(t *testing.T) {
|
||||
c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = c.GetLatestNPosts(context.Background(), "invalid-author", 10)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid author")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid nostr author") {
|
||||
t.Fatalf("expected 'invalid nostr author' error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPostsInTimeframeInvalidAuthor(t *testing.T) {
|
||||
c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
end := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC)
|
||||
_, err = c.GetPostsInTimeframe(context.Background(), "invalid-author", start, end, 10)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid author")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid nostr author") {
|
||||
t.Fatalf("expected 'invalid nostr author' error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelayConnectionFailureReturnsError(t *testing.T) {
|
||||
// When no relay can be reached, GetLatestNPosts/GetPostsInTimeframe must
|
||||
// report the connection failure rather than silently returning an empty feed.
|
||||
c, err := NewNostrClient([]string{
|
||||
"wss://invalid1.relay.invalid",
|
||||
"wss://invalid2.relay.invalid",
|
||||
"wss://invalid3.relay.invalid",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
sk := nostr.Generate()
|
||||
npub := nip19.EncodeNpub(sk.Public())
|
||||
|
||||
_, err = c.GetLatestNPosts(ctx, npub, 10)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no relay is reachable")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "could not connect to any relay") {
|
||||
t.Fatalf("expected 'could not connect to any relay' error, got %v", err)
|
||||
}
|
||||
|
||||
start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
end := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC)
|
||||
_, err = c.GetPostsInTimeframe(ctx, npub, start, end, 10)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no relay is reachable")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "could not connect to any relay") {
|
||||
t.Fatalf("expected 'could not connect to any relay' error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewNostrClientInvalidScheme(t *testing.T) {
|
||||
_, err := NewNostrClient([]string{"http://relay.example"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-websocket relay URL")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid relay URL") {
|
||||
t.Fatalf("expected 'invalid relay URL' error, got %v", err)
|
||||
}
|
||||
|
||||
// Blank and duplicate entries are dropped, the scheme is validated.
|
||||
c, err := NewNostrClient([]string{
|
||||
"wss://relay.a",
|
||||
" wss://relay.a ",
|
||||
"ws://relay.b",
|
||||
" ",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(c.relays) != 2 {
|
||||
t.Fatalf("expected 2 unique relays after cleaning, got %d: %v", len(c.relays), c.relays)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestNPostsZeroOrNegativeLimit(t *testing.T) {
|
||||
c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sk := nostr.Generate()
|
||||
npub := nip19.EncodeNpub(sk.Public())
|
||||
|
||||
if posts, err := c.GetLatestNPosts(context.Background(), npub, 0); err != nil || posts != nil {
|
||||
t.Fatalf("expected nil, nil for zero limit, got %v, %v", posts, err)
|
||||
}
|
||||
if posts, err := c.GetLatestNPosts(context.Background(), npub, -5); err != nil || posts != nil {
|
||||
t.Fatalf("expected nil, nil for negative limit, got %v, %v", posts, err)
|
||||
}
|
||||
|
||||
start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
end := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC)
|
||||
if posts, err := c.GetPostsInTimeframe(context.Background(), npub, start, end, 0); err != nil || posts != nil {
|
||||
t.Fatalf("expected nil, nil for zero timeframe limit, got %v, %v", posts, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchActors(t *testing.T) {
|
||||
c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// A non-key query (e.g. a display name) is a valid no-match, not an error.
|
||||
results, err := c.SearchActors(ctx, "alice", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error for non-key query, got %v", err)
|
||||
}
|
||||
if results != nil {
|
||||
t.Fatalf("expected nil results for non-key query, got %v", results)
|
||||
}
|
||||
|
||||
// A valid pubkey that fails on every relay must surface the outage.
|
||||
sk := nostr.Generate()
|
||||
npub := nip19.EncodeNpub(sk.Public())
|
||||
if _, err := c.SearchActors(ctx, npub, 1); err == nil {
|
||||
t.Fatal("expected error when no relay is reachable")
|
||||
} else if !strings.Contains(err.Error(), "could not connect to any relay") {
|
||||
t.Fatalf("expected 'could not connect to any relay' error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPostWithHexID(t *testing.T) {
|
||||
c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var eid nostr.ID
|
||||
for i := range eid {
|
||||
eid[i] = byte(i + 1)
|
||||
}
|
||||
hex := eid.Hex()
|
||||
|
||||
_, err = c.GetPost(context.Background(), hex)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when relay unavailable")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "could not connect to any relay") {
|
||||
t.Fatalf("expected 'could not connect to any relay' error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetActorWithHexPubkey(t *testing.T) {
|
||||
c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sk := nostr.Generate()
|
||||
hex := sk.Public().Hex()
|
||||
|
||||
_, err = c.GetActor(context.Background(), hex)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when relay unavailable")
|
||||
}
|
||||
// GetActor reports a total relay outage as a connection failure rather than
|
||||
// pretending the author has no metadata.
|
||||
if !strings.Contains(err.Error(), "could not connect to any relay") {
|
||||
t.Fatalf("expected 'could not connect to any relay' error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,51 +2,232 @@ package ollama
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nacorid/naco-api/internal/clustering"
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
const (
|
||||
// embedConcurrency caps how many embedding requests run in parallel. Ollama
|
||||
// serializes model requests server-side (OLLAMA_NUM_PARALLEL defaults to 1),
|
||||
// so unbounded fan-out would only add latency and memory for no throughput.
|
||||
embedConcurrency = 4
|
||||
|
||||
// embedRetries and embedRetryBackoff cover transient server-side embedding
|
||||
// failures (e.g. cold model load, transient 5xx/429).
|
||||
embedRetries = 2
|
||||
embedRetryBackoff = 150 * time.Millisecond
|
||||
|
||||
// generateRetries and generateRetryBackoff cover transient failures that
|
||||
// produce no output at all; a truncated stream is never retried because the
|
||||
// partial response cannot be safely replayed.
|
||||
generateRetries = 1
|
||||
generateRetryBackoff = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
type OllamaClient struct {
|
||||
client api.Client
|
||||
client api.Client
|
||||
embeddingModel string
|
||||
styleModel string
|
||||
}
|
||||
|
||||
func New(ctx context.Context, host *url.URL, http *http.Client) *OllamaClient {
|
||||
client := api.NewClient(host, http)
|
||||
return &OllamaClient{client: *client}
|
||||
func New(host *url.URL, httpClient *http.Client, embeddingModel, styleModel string) *OllamaClient {
|
||||
client := api.NewClient(host, httpClient)
|
||||
return &OllamaClient{client: *client, embeddingModel: embeddingModel, styleModel: styleModel}
|
||||
}
|
||||
|
||||
func (o *OllamaClient) EmbedTexts(ctx context.Context, posts []string) ([][]float64, error) {
|
||||
vectors := make([][]float64, 0, len(posts))
|
||||
for _, t := range posts {
|
||||
// EmbedTexts computes one embedding per post. Embeddings are returned in the
|
||||
// same order as posts; callers rely on index correspondence (e.g.
|
||||
// items[i].Embedding = embeddings[i]) so posts are never skipped. Requests run
|
||||
// in a bounded worker pool and transient failures are retried with backoff.
|
||||
func (o *OllamaClient) EmbedTexts(ctx context.Context, posts []clustering.Item) ([][]float64, error) {
|
||||
if len(posts) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
embeddings := make([][]float64, len(posts))
|
||||
|
||||
// poolCtx is a child of ctx: cancelling it on the first failure must not
|
||||
// cancel the caller's request context.
|
||||
poolCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
firstErr error
|
||||
sema = make(chan struct{}, embedConcurrency)
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
for i, post := range posts {
|
||||
wg.Add(1)
|
||||
go func(idx int, text string) {
|
||||
defer wg.Done()
|
||||
select {
|
||||
case sema <- struct{}{}:
|
||||
case <-poolCtx.Done():
|
||||
return
|
||||
}
|
||||
defer func() { <-sema }()
|
||||
|
||||
emb, err := o.embedWithRetry(poolCtx, strings.TrimSpace(text))
|
||||
if err != nil {
|
||||
mu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("embedding post %d (%q): %w", idx, text, err)
|
||||
}
|
||||
mu.Unlock()
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
embeddings[idx] = emb
|
||||
}(i, post.Text)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if firstErr := atomicErr(&mu, &firstErr); firstErr != nil {
|
||||
return embeddings, firstErr
|
||||
}
|
||||
// Every worker may have exited via the poolCtx.Done() branch without
|
||||
// recording an error, e.g. when the caller's context is already canceled.
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return embeddings, nil
|
||||
}
|
||||
|
||||
func atomicErr(mu *sync.Mutex, err *error) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return *err
|
||||
}
|
||||
|
||||
func (o *OllamaClient) embedWithRetry(ctx context.Context, text string) ([]float64, error) {
|
||||
backoff := embedRetryBackoff
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= embedRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
if err := sleepCtx(ctx, backoff); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
backoff *= 2
|
||||
}
|
||||
resp, err := o.client.Embeddings(ctx, &api.EmbeddingRequest{
|
||||
Model: "nomic-embed-text",
|
||||
Prompt: t,
|
||||
Model: o.embeddingModel,
|
||||
Prompt: text,
|
||||
})
|
||||
if err != nil {
|
||||
if err == nil {
|
||||
return resp.Embedding, nil
|
||||
}
|
||||
lastErr = err
|
||||
if !isTransientErr(err) {
|
||||
return nil, err
|
||||
}
|
||||
vectors = append(vectors, resp.Embedding)
|
||||
}
|
||||
return vectors, nil
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (o *OllamaClient) SummarizeStyle(ctx context.Context, posts []string) (string, error) {
|
||||
prompt := "Summarize writing style, recurring themes, tone, personality traits of this user:\n\n" +
|
||||
strings.Join(posts, "\n") + "\n\nStyle summary:"
|
||||
func (o *OllamaClient) SummarizeStyle(ctx context.Context, posts []clustering.Item) (string, error) {
|
||||
if len(posts) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
promptParts := make([]string, 0, len(posts))
|
||||
for _, post := range posts {
|
||||
if text := strings.TrimSpace(post.Text); text != "" {
|
||||
promptParts = append(promptParts, text)
|
||||
}
|
||||
}
|
||||
if len(promptParts) == 0 {
|
||||
return "", fmt.Errorf("no post text to summarize")
|
||||
}
|
||||
|
||||
prompt := "Summarize the user's writing style. Analyze:\n" +
|
||||
"- tone and emotional register\n" +
|
||||
"- recurring themes and interests\n" +
|
||||
"- vocabulary and sentence structure\n" +
|
||||
"- communication habits\n" +
|
||||
"- personality traits expressed through writing\n\n" +
|
||||
"Base your analysis only on the supplied posts. Do not infer sensitive personal attributes.\n\n" +
|
||||
"Posts:\n\n" +
|
||||
strings.Join(promptParts, "\n---\n") +
|
||||
"\n\nStyle summary:"
|
||||
|
||||
var fullResponse strings.Builder
|
||||
err := o.client.Generate(ctx, &api.GenerateRequest{
|
||||
Model: "mistral",
|
||||
Prompt: prompt,
|
||||
}, func(resp api.GenerateResponse) error {
|
||||
fullResponse.WriteString(resp.Response)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
backoff := generateRetryBackoff
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= generateRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
if err := sleepCtx(ctx, backoff); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return fullResponse.String(), nil
|
||||
// Reset between attempts so a failed run never leaks a partial summary.
|
||||
fullResponse.Reset()
|
||||
done := false
|
||||
doneReason := ""
|
||||
err := o.client.Generate(ctx, &api.GenerateRequest{
|
||||
Model: o.styleModel,
|
||||
Prompt: prompt,
|
||||
}, func(resp api.GenerateResponse) error {
|
||||
fullResponse.WriteString(resp.Response)
|
||||
if resp.Done {
|
||||
done = true
|
||||
doneReason = resp.DoneReason
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
// Only retry transport/5xx failures that produced no output; a
|
||||
// partial stream must not be replayed.
|
||||
if !isTransientErr(err) || fullResponse.Len() > 0 {
|
||||
return "", fmt.Errorf("ollama generate with model %q failed: %w", o.styleModel, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// The underlying stream reader can finish without an error when the
|
||||
// request context is canceled or the connection drops mid-stream (the
|
||||
// library never inspects bufio.Scanner.Err), so a bare nil from
|
||||
// Generate does not mean the response is complete.
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return "", ctxErr
|
||||
}
|
||||
if !done {
|
||||
return "", fmt.Errorf("ollama stream for model %q ended before completion", o.styleModel)
|
||||
}
|
||||
if doneReason == "length" {
|
||||
return "", fmt.Errorf("ollama response for model %q was truncated (context limit reached)", o.styleModel)
|
||||
}
|
||||
return fullResponse.String(), nil
|
||||
}
|
||||
return "", fmt.Errorf("ollama generate with model %q failed after retries: %w", o.styleModel, lastErr)
|
||||
}
|
||||
|
||||
func sleepCtx(ctx context.Context, d time.Duration) error {
|
||||
select {
|
||||
case <-time.After(d):
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// isTransientErr reports whether err is a retryable server-side failure. Non-2xx
|
||||
// HTTP errors that carry a StatusCode (429, 5xx) qualify; context and transport
|
||||
// errors below that never do, so they surface immediately.
|
||||
func isTransientErr(err error) bool {
|
||||
var se api.StatusError
|
||||
if errors.As(err, &se) {
|
||||
return se.StatusCode == http.StatusTooManyRequests || se.StatusCode >= http.StatusInternalServerError
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
516
internal/ollama/client_test.go
Normal file
516
internal/ollama/client_test.go
Normal file
|
|
@ -0,0 +1,516 @@
|
|||
package ollama
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nacorid/naco-api/internal/clustering"
|
||||
)
|
||||
|
||||
// setupTestServer creates a test server and an initialized client.
|
||||
func setupTestServer(t *testing.T, handler http.Handler) (*httptest.Server, *OllamaClient) {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(handler)
|
||||
|
||||
host, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse test server URL: %v", err)
|
||||
}
|
||||
|
||||
client := New(host, http.DefaultClient, "test-embed", "test-style")
|
||||
return server, client
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EmbedTexts Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestEmbedTexts(t *testing.T) {
|
||||
type embedRequest struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
var receivedRequests []embedRequest
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/embeddings", func(w http.ResponseWriter, r *http.Request) {
|
||||
// 1. Validate HTTP Method
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("expected method POST, got %s", r.Method)
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Validate Headers
|
||||
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
|
||||
t.Errorf("expected Content-Type application/json, got %q", r.Header.Get("Content-Type"))
|
||||
http.Error(w, "invalid content-type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Validate Request Payload
|
||||
var req embedRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Errorf("failed to decode request body: %v", err)
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Model != "test-embed" {
|
||||
t.Errorf("expected model 'test-embed', got %q", req.Model)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
receivedRequests = append(receivedRequests, req)
|
||||
mu.Unlock()
|
||||
|
||||
// 4. Return valid response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"embedding": []float64{0.1, 0.2, 0.3},
|
||||
})
|
||||
})
|
||||
|
||||
server, client := setupTestServer(t, mux)
|
||||
defer server.Close()
|
||||
|
||||
posts := []clustering.Item{
|
||||
{ID: "1", Text: "hello", Embedding: nil},
|
||||
{ID: "2", Text: "world", Embedding: nil},
|
||||
}
|
||||
|
||||
embeddings, err := client.EmbedTexts(context.Background(), posts)
|
||||
if err != nil {
|
||||
t.Fatalf("EmbedTexts failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify outgoing requests: ensure both prompts were sent
|
||||
mu.Lock()
|
||||
if len(receivedRequests) != 2 {
|
||||
t.Errorf("expected 2 HTTP requests, got %d", len(receivedRequests))
|
||||
}
|
||||
promptsReceived := map[string]bool{}
|
||||
for _, req := range receivedRequests {
|
||||
promptsReceived[req.Prompt] = true
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
if !promptsReceived["hello"] || !promptsReceived["world"] {
|
||||
t.Errorf("expected prompts 'hello' and 'world' to be sent, got: %+v", receivedRequests)
|
||||
}
|
||||
|
||||
// Verify parsed results
|
||||
if len(embeddings) != 2 {
|
||||
t.Fatalf("expected 2 embeddings, got %d", len(embeddings))
|
||||
}
|
||||
for i, emb := range embeddings {
|
||||
if len(emb) != 3 {
|
||||
t.Errorf("embedding %d has wrong dimension: %d", i, len(emb))
|
||||
}
|
||||
if emb[0] != 0.1 || emb[1] != 0.2 || emb[2] != 0.3 {
|
||||
t.Errorf("embedding %d has wrong values: %v", i, emb)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbedTexts_EmptyInput(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/embeddings", func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("embeddings endpoint should not be called when input is empty")
|
||||
http.Error(w, "should not be called", http.StatusInternalServerError)
|
||||
})
|
||||
|
||||
server, client := setupTestServer(t, mux)
|
||||
defer server.Close()
|
||||
|
||||
embeddings, err := client.EmbedTexts(context.Background(), []clustering.Item{})
|
||||
if err != nil {
|
||||
t.Fatalf("EmbedTexts with empty input failed: %v", err)
|
||||
}
|
||||
|
||||
if len(embeddings) != 0 {
|
||||
t.Errorf("expected 0 embeddings, got %d", len(embeddings))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbedTexts_Error(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/embeddings", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"error": "model test-embed not found"}`))
|
||||
})
|
||||
|
||||
server, client := setupTestServer(t, mux)
|
||||
defer server.Close()
|
||||
|
||||
posts := []clustering.Item{{ID: "1", Text: "hello"}}
|
||||
|
||||
_, err := client.EmbedTexts(context.Background(), posts)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from failing server, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbedTexts_ContextCanceled(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/embeddings", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Hang until client cancels the request
|
||||
<-r.Context().Done()
|
||||
})
|
||||
|
||||
server, client := setupTestServer(t, mux)
|
||||
defer server.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // Cancel immediately
|
||||
|
||||
posts := []clustering.Item{{ID: "1", Text: "hello"}}
|
||||
_, err := client.EmbedTexts(ctx, posts)
|
||||
if err == nil {
|
||||
t.Fatal("expected error due to canceled context, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbedTexts_RetryThenSuccess(t *testing.T) {
|
||||
var requests atomic.Int32
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/embeddings", func(w http.ResponseWriter, r *http.Request) {
|
||||
if requests.Add(1) == 1 {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"error":"transient model load failure"}`))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"embedding": []float64{9.9},
|
||||
})
|
||||
})
|
||||
|
||||
server, client := setupTestServer(t, mux)
|
||||
defer server.Close()
|
||||
|
||||
posts := []clustering.Item{{ID: "1", Text: "hello"}}
|
||||
embeddings, err := client.EmbedTexts(context.Background(), posts)
|
||||
if err != nil {
|
||||
t.Fatalf("EmbedTexts failed after transient error: %v", err)
|
||||
}
|
||||
if len(embeddings) != 1 || len(embeddings[0]) != 1 || embeddings[0][0] != 9.9 {
|
||||
t.Fatalf("unexpected embeddings after retry: %v", embeddings)
|
||||
}
|
||||
if got := requests.Load(); got != 2 {
|
||||
t.Errorf("expected 2 requests (1 failure + 1 retry), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbedTexts_RetryExhausted(t *testing.T) {
|
||||
var requests atomic.Int32
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/embeddings", func(w http.ResponseWriter, r *http.Request) {
|
||||
requests.Add(1)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"error":"transient model load failure"}`))
|
||||
})
|
||||
|
||||
server, client := setupTestServer(t, mux)
|
||||
defer server.Close()
|
||||
|
||||
posts := []clustering.Item{{ID: "1", Text: "hello"}}
|
||||
_, err := client.EmbedTexts(context.Background(), posts)
|
||||
if err == nil {
|
||||
t.Fatal("expected error after retries exhausted, got nil")
|
||||
}
|
||||
if got := requests.Load(); got != embedRetries+1 {
|
||||
t.Errorf("expected %d attempts (initial + %d retries), got %d", embedRetries+1, embedRetries, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbedTexts_ParallelOrder(t *testing.T) {
|
||||
// Shake ordering: handler sleeps and varies the embedding by prompt length,
|
||||
// so a naive (unordered) worker pool would misalign results.
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/embeddings", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Errorf("failed to decode request body: %v", err)
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"embedding": []float64{float64(len(req.Prompt))},
|
||||
})
|
||||
})
|
||||
|
||||
server, client := setupTestServer(t, mux)
|
||||
defer server.Close()
|
||||
|
||||
posts := make([]clustering.Item, 8)
|
||||
want := make([]float64, 8)
|
||||
for i := range posts {
|
||||
posts[i] = clustering.Item{ID: fmt.Sprintf("%d", i), Text: strings.Repeat("x", i+1)}
|
||||
want[i] = float64(i + 1)
|
||||
}
|
||||
|
||||
embeddings, err := client.EmbedTexts(context.Background(), posts)
|
||||
if err != nil {
|
||||
t.Fatalf("EmbedTexts failed: %v", err)
|
||||
}
|
||||
if len(embeddings) != len(posts) {
|
||||
t.Fatalf("expected %d embeddings, got %d", len(posts), len(embeddings))
|
||||
}
|
||||
for i, emb := range embeddings {
|
||||
if len(emb) != 1 || emb[0] != want[i] {
|
||||
t.Errorf("embedding %d out of order or wrong value: got %v, want %v", i, emb, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SummarizeStyle Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestSummarizeStyle(t *testing.T) {
|
||||
type generateRequest struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
|
||||
var reqBody generateRequest
|
||||
callCount := 0
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/generate", func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
|
||||
// 1. Validate HTTP Method
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("expected method POST, got %s", r.Method)
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Validate Headers
|
||||
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
|
||||
t.Errorf("expected Content-Type application/json, got %q", r.Header.Get("Content-Type"))
|
||||
http.Error(w, "invalid content-type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Validate Request Payload
|
||||
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
|
||||
t.Errorf("failed to decode request body: %v", err)
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if reqBody.Model != "test-style" {
|
||||
t.Errorf("expected model 'test-style', got %q", reqBody.Model)
|
||||
}
|
||||
|
||||
// Ensure the prompt includes input text
|
||||
if !strings.Contains(reqBody.Prompt, "First post") || !strings.Contains(reqBody.Prompt, "Second post") {
|
||||
t.Errorf("prompt missing post contents: %q", reqBody.Prompt)
|
||||
}
|
||||
|
||||
// 4. Stream NDJSON Response
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
t.Error("ResponseWriter is not a Flusher")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||
|
||||
chunks := []string{
|
||||
`{"response":"Style summary: "}` + "\n",
|
||||
`{"response":"concise and "}` + "\n",
|
||||
`{"response":"clear.","done":true}` + "\n",
|
||||
}
|
||||
|
||||
for _, chunk := range chunks {
|
||||
_, _ = w.Write([]byte(chunk))
|
||||
flusher.Flush()
|
||||
}
|
||||
})
|
||||
|
||||
server, client := setupTestServer(t, mux)
|
||||
defer server.Close()
|
||||
|
||||
posts := []clustering.Item{
|
||||
{ID: "1", Text: "First post"},
|
||||
{ID: "2", Text: "Second post"},
|
||||
}
|
||||
|
||||
summary, err := client.SummarizeStyle(context.Background(), posts)
|
||||
if err != nil {
|
||||
t.Fatalf("SummarizeStyle failed: %v", err)
|
||||
}
|
||||
|
||||
expected := "Style summary: concise and clear."
|
||||
if summary != expected {
|
||||
t.Errorf("got summary %q, want %q", summary, expected)
|
||||
}
|
||||
|
||||
if callCount != 1 {
|
||||
t.Errorf("expected 1 call to /api/generate, got %d", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeStyle_EmptyInput(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/generate", func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("generate endpoint should not be called when input is empty")
|
||||
http.Error(w, "should not be called", http.StatusInternalServerError)
|
||||
})
|
||||
|
||||
server, client := setupTestServer(t, mux)
|
||||
defer server.Close()
|
||||
|
||||
summary, err := client.SummarizeStyle(context.Background(), []clustering.Item{})
|
||||
if err != nil {
|
||||
t.Fatalf("SummarizeStyle with empty input failed: %v", err)
|
||||
}
|
||||
|
||||
if summary != "" {
|
||||
t.Errorf("expected empty summary, got %q", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeStyle_Error(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/generate", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"error":"internal model failure"}`))
|
||||
})
|
||||
|
||||
server, client := setupTestServer(t, mux)
|
||||
defer server.Close()
|
||||
|
||||
posts := []clustering.Item{{ID: "1", Text: "hello"}}
|
||||
|
||||
_, err := client.SummarizeStyle(context.Background(), posts)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from failing server, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeStyle_MalformedJSONStream(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/generate", func(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, _ := w.(http.Flusher)
|
||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||
|
||||
// First line is valid JSON, second is corrupt
|
||||
_, _ = w.Write([]byte(`{"response":"valid part"}` + "\n"))
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
_, _ = w.Write([]byte(`{corrupt_json` + "\n"))
|
||||
})
|
||||
|
||||
server, client := setupTestServer(t, mux)
|
||||
defer server.Close()
|
||||
|
||||
posts := []clustering.Item{{ID: "1", Text: "hello"}}
|
||||
|
||||
_, err := client.SummarizeStyle(context.Background(), posts)
|
||||
if err == nil {
|
||||
t.Fatal("expected error on malformed JSON stream, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeStyle_ContextCanceled(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/generate", func(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, _ := w.(http.Flusher)
|
||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||
|
||||
// Send initial chunk
|
||||
_, _ = w.Write([]byte(`{"response":"waiting..."}` + "\n"))
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
// Wait until client context cancels
|
||||
<-r.Context().Done()
|
||||
})
|
||||
|
||||
server, client := setupTestServer(t, mux)
|
||||
defer server.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
posts := []clustering.Item{{ID: "1", Text: "hello"}}
|
||||
|
||||
_, err := client.SummarizeStyle(ctx, posts)
|
||||
if err == nil {
|
||||
t.Fatal("expected error on canceled context, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeStyle_StreamNeverCompletes(t *testing.T) {
|
||||
// Streaming ends without a done:true chunk (simulates a dropped connection
|
||||
// or the http.Client timeout firing mid-generation).
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/generate", func(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, _ := w.(http.Flusher)
|
||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||
_, _ = w.Write([]byte(`{"response":"partial answer"}` + "\n"))
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}) // handler returns without ever writing done:true
|
||||
|
||||
server, client := setupTestServer(t, mux)
|
||||
defer server.Close()
|
||||
|
||||
posts := []clustering.Item{{ID: "1", Text: "hello"}}
|
||||
|
||||
_, err := client.SummarizeStyle(context.Background(), posts)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when stream ends without completion, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ended before completion") {
|
||||
t.Errorf("expected 'ended before completion' error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeStyle_LengthTruncated(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/generate", func(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, _ := w.(http.Flusher)
|
||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||
_, _ = w.Write([]byte(`{"response":"summary...","done":true,"done_reason":"length"}` + "\n"))
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
})
|
||||
|
||||
server, client := setupTestServer(t, mux)
|
||||
defer server.Close()
|
||||
|
||||
posts := []clustering.Item{{ID: "1", Text: "hello"}}
|
||||
|
||||
_, err := client.SummarizeStyle(context.Background(), posts)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when response is truncated at context limit, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "truncated") {
|
||||
t.Errorf("expected 'truncated' error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -32,12 +32,20 @@ func (lm *LoggingMiddleware) OnCall(next mcp.MethodHandler) mcp.MethodHandler {
|
|||
toolName = callReq.Params.Name
|
||||
}
|
||||
|
||||
log.InfoWithContext(ctx, "MCP Tool Processed",
|
||||
"transport", "mcp_native",
|
||||
"tool", toolName,
|
||||
"duration", duration,
|
||||
"success", err == nil,
|
||||
)
|
||||
if err != nil {
|
||||
log.WarnWithContext(ctx, "MCP Tool Failed",
|
||||
"transport", "mcp_native",
|
||||
"tool", toolName,
|
||||
"duration", duration,
|
||||
"error", err.Error(),
|
||||
)
|
||||
} else {
|
||||
log.InfoWithContext(ctx, "MCP Tool Processed",
|
||||
"transport", "mcp_native",
|
||||
"tool", toolName,
|
||||
"duration", duration,
|
||||
)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
return next(ctx, method, request)
|
||||
|
|
|
|||
76
internal/server/actors.go
Normal file
76
internal/server/actors.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
func (s *Server) searchActorsAll(ctx context.Context, query string, limit int) ([]*ActorProfile, error) {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
results []*ActorProfile
|
||||
wg sync.WaitGroup
|
||||
errMu sync.Mutex
|
||||
errs []error
|
||||
success int
|
||||
)
|
||||
call := func(fn func(context.Context, string, int) ([]*ActorProfile, error)) {
|
||||
defer wg.Done()
|
||||
r, err := fn(ctx, query, limit)
|
||||
if err != nil {
|
||||
errMu.Lock()
|
||||
errs = append(errs, err)
|
||||
errMu.Unlock()
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
results = append(results, r...)
|
||||
success++
|
||||
mu.Unlock()
|
||||
}
|
||||
wg.Add(3)
|
||||
go call(s.bskyClient.SearchActors)
|
||||
go call(s.apClient.SearchActors)
|
||||
go call(s.nostrClient.SearchActors)
|
||||
wg.Wait()
|
||||
if success == 0 {
|
||||
return nil, fmt.Errorf("all platforms failed: %v", errs)
|
||||
}
|
||||
return dedupeActors(results), nil
|
||||
}
|
||||
|
||||
// dedupeActors removes near-duplicate profiles across platforms. Two results are considered
|
||||
// the same person when they share a normalized handle (the '@' and any instance/host stripped,
|
||||
// lower-cased) or a normalized display name. The first occurrence wins.
|
||||
func dedupeActors(in []*ActorProfile) []*ActorProfile {
|
||||
seen := make(map[string]struct{}, len(in))
|
||||
out := make([]*ActorProfile, 0, len(in))
|
||||
for _, p := range in {
|
||||
key := normalizeHandle(p.Handle)
|
||||
if key == "" {
|
||||
key = normalizeHandle(p.DisplayName)
|
||||
}
|
||||
if key == "" {
|
||||
out = append(out, p)
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[key]; dup {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeHandle(h string) string {
|
||||
h = strings.ToLower(strings.TrimSpace(h))
|
||||
h = strings.TrimPrefix(h, "@")
|
||||
// Drop an ActivityPub instance suffix (alice@mastodon.social -> alice).
|
||||
if at := strings.Index(h, "@"); at >= 0 {
|
||||
h = h[:at]
|
||||
}
|
||||
return h
|
||||
}
|
||||
966
internal/server/analytics.go
Normal file
966
internal/server/analytics.go
Normal file
|
|
@ -0,0 +1,966 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
log "github.com/nacorid/logger"
|
||||
"github.com/nacorid/naco-api/internal/clustering"
|
||||
"github.com/nacorid/naco-api/internal/store"
|
||||
"github.com/nacorid/naco-api/internal/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAnalyticsLimit = 50
|
||||
maxAnalyticsLimit = 200
|
||||
defaultAnalyticsDays = 30
|
||||
maxTopicCount = 10
|
||||
minTopicCount = 3
|
||||
sentimentBucketDuration = 24 * time.Hour
|
||||
)
|
||||
|
||||
// ---- Analytics request/response helpers ----
|
||||
|
||||
func parseAnalyticsTimeRange(startStr, endStr string) (time.Time, time.Time, error) {
|
||||
now := time.Now().UTC()
|
||||
end := now
|
||||
start := now.AddDate(0, 0, -defaultAnalyticsDays)
|
||||
|
||||
if endStr != "" {
|
||||
t, err := time.Parse(time.RFC3339, endStr)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("invalid endTime %q: %w", endStr, err)
|
||||
}
|
||||
end = t
|
||||
}
|
||||
if startStr != "" {
|
||||
t, err := time.Parse(time.RFC3339, startStr)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("invalid startTime %q: %w", startStr, err)
|
||||
}
|
||||
start = t
|
||||
}
|
||||
if start.After(end) {
|
||||
start, end = end, start
|
||||
}
|
||||
return start, end, nil
|
||||
}
|
||||
|
||||
func hashTimeRange(start, end time.Time, limit int) string {
|
||||
data := fmt.Sprintf("%s|%s|%d", start.Format(time.RFC3339), end.Format(time.RFC3339), limit)
|
||||
h := sha256.Sum256([]byte(data))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func (s *Server) getAnalyticsCache(ctx context.Context, author, platform, toolName, timeRangeHash string) (json.RawMessage, bool) {
|
||||
result, err := s.db.GetAnalyticsCache(ctx, store.GetAnalyticsCacheParams{
|
||||
UserID: author,
|
||||
Platform: platform,
|
||||
ToolName: toolName,
|
||||
TimeRangeHash: timeRangeHash,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return result, true
|
||||
}
|
||||
|
||||
func (s *Server) setAnalyticsCache(ctx context.Context, author, platform, toolName, timeRangeHash string, result any, ttl time.Duration) error {
|
||||
data, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expiresAt := time.Now().Add(ttl)
|
||||
return s.db.UpsertAnalyticsCache(ctx, store.UpsertAnalyticsCacheParams{
|
||||
UserID: author,
|
||||
Platform: platform,
|
||||
ToolName: toolName,
|
||||
TimeRangeHash: timeRangeHash,
|
||||
Result: data,
|
||||
ExpiresAt: expiresAt,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Main analytics entry points ----
|
||||
|
||||
func (s *Server) getEngagementMetrics(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
var args AnalyticsRequest
|
||||
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
return s.computeAnalytics(ctx, args, "engagementMetrics", s.computeEngagementMetrics)
|
||||
}
|
||||
|
||||
func (s *Server) getPostingPatterns(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
var args AnalyticsRequest
|
||||
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
return s.computeAnalytics(ctx, args, "postingPatterns", s.computePostingPatterns)
|
||||
}
|
||||
|
||||
func (s *Server) getTopicModeling(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
var args AnalyticsRequest
|
||||
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
return s.computeAnalytics(ctx, args, "topicModeling", s.computeTopicModeling)
|
||||
}
|
||||
|
||||
func (s *Server) getSentimentTrends(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
var args AnalyticsRequest
|
||||
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
return s.computeAnalytics(ctx, args, "sentimentTrends", s.computeSentimentTrends)
|
||||
}
|
||||
|
||||
func (s *Server) getEngagementDrivers(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
var args AnalyticsRequest
|
||||
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
return s.computeAnalytics(ctx, args, "engagementDrivers", s.computeEngagementDrivers)
|
||||
}
|
||||
|
||||
type analyticsComputer func(ctx context.Context, args AnalyticsRequest, posts []clustering.Item) (any, error)
|
||||
|
||||
func (s *Server) computeAnalytics(ctx context.Context, args AnalyticsRequest, toolName string, compute analyticsComputer) (*mcp.CallToolResult, error) {
|
||||
if args.Author == "" {
|
||||
return nil, fmt.Errorf("author must be a valid did or handle")
|
||||
}
|
||||
|
||||
// Normalize args
|
||||
limit := args.Limit
|
||||
if limit <= 0 {
|
||||
limit = defaultAnalyticsLimit
|
||||
}
|
||||
if limit > maxAnalyticsLimit {
|
||||
limit = maxAnalyticsLimit
|
||||
}
|
||||
|
||||
start, end, err := parseAnalyticsTimeRange(args.StartTime, args.EndTime)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid time range: %w", err)
|
||||
}
|
||||
|
||||
var platforms []string
|
||||
if args.Platform != "" {
|
||||
platforms = []string{args.Platform}
|
||||
} else {
|
||||
// Twitter is included by default only when the API is enabled: the API
|
||||
// costs it incurs are passed on to the caller through the upto pricing
|
||||
// of these tools (see registerTools), so aggregating over it does not
|
||||
// run the server at a loss. When no bearer token is configured there is
|
||||
// no Twitter to aggregate over.
|
||||
platforms = []string{"Bluesky", "ActivityPub", "Nostr"}
|
||||
if s.twitterEnabled {
|
||||
platforms = append(platforms, "Twitter")
|
||||
}
|
||||
}
|
||||
|
||||
timeRangeHash := hashTimeRange(start, end, limit)
|
||||
|
||||
// The analytics result cache is keyed by the scope of the computation: a
|
||||
// single platform when one is requested, or the "all" sentinel when the
|
||||
// result aggregates across all platforms. This keeps aggregate results from
|
||||
// being mixed with single-platform results.
|
||||
cachePlatform := "all"
|
||||
if args.Platform != "" {
|
||||
cachePlatform = args.Platform
|
||||
}
|
||||
|
||||
if cached, ok := s.getAnalyticsCache(ctx, args.Author, cachePlatform, toolName, timeRangeHash); ok {
|
||||
var cachedResult json.RawMessage
|
||||
_ = json.Unmarshal(cached, &cachedResult)
|
||||
return s.toToolResult(cachedResult)
|
||||
}
|
||||
|
||||
// Fetch posts from all platforms
|
||||
var allPosts []clustering.Item
|
||||
twitterPosts := 0
|
||||
for _, p := range platforms {
|
||||
plat, ok := platformFromString(p)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unsupported platform %q", p)
|
||||
}
|
||||
posts, err := s.fetchAnalyticsPosts(ctx, plat, args.Author, start, end, limit)
|
||||
if err != nil {
|
||||
log.WarnWithContext(ctx, "failed to fetch posts for analytics", "platform", p, "error", err)
|
||||
continue
|
||||
}
|
||||
allPosts = append(allPosts, posts...)
|
||||
if plat == Twitter {
|
||||
twitterPosts += len(posts)
|
||||
}
|
||||
}
|
||||
|
||||
if len(allPosts) == 0 {
|
||||
return nil, fmt.Errorf("no posts found for author %s in time range", args.Author)
|
||||
}
|
||||
|
||||
// Compute
|
||||
result, err := compute(ctx, args, allPosts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = setAnalyticsTwitterPostCount(result, twitterPosts)
|
||||
|
||||
// Cache the result under the matching scope key.
|
||||
ttl := s.getCacheTTL(toolName)
|
||||
_ = s.setAnalyticsCache(ctx, args.Author, cachePlatform, toolName, timeRangeHash, result, ttl)
|
||||
|
||||
return s.toToolResult(result)
|
||||
}
|
||||
|
||||
// setAnalyticsTwitterPostCount records how many posts in an analytics result
|
||||
// were sourced from the Twitter API, so upto-scheme settlement can pass the
|
||||
// realized Twitter costs on to the caller. Unknown result types are returned
|
||||
// unchanged.
|
||||
func setAnalyticsTwitterPostCount(result any, count int) any {
|
||||
switch v := result.(type) {
|
||||
case EngagementMetricsResponse:
|
||||
v.TwitterPostCount = count
|
||||
return v
|
||||
case PostingPatternsResponse:
|
||||
v.TwitterPostCount = count
|
||||
return v
|
||||
case TopicModelingResponse:
|
||||
v.TwitterPostCount = count
|
||||
return v
|
||||
case SentimentTrendsResponse:
|
||||
v.TwitterPostCount = count
|
||||
return v
|
||||
case EngagementDriversResponse:
|
||||
v.TwitterPostCount = count
|
||||
return v
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Server) fetchAnalyticsPosts(ctx context.Context, platform platform, author string, start, end time.Time, limit int) ([]clustering.Item, error) {
|
||||
posts, err := s.fetchPostsInTimeForAnalytics(ctx, platform, author, start, end, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
func (s *Server) getCachedAnalyticsPosts(ctx context.Context, author, platform string, start, end time.Time, limit int) ([]clustering.Item, error) {
|
||||
rows, err := s.db.GetAnalyticsPostsInRange(ctx, store.GetAnalyticsPostsInRangeParams{
|
||||
UserID: author,
|
||||
Platform: platform,
|
||||
PostTimestamp: start,
|
||||
PostTimestamp_2: end,
|
||||
Limit: int32(limit),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]clustering.Item, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
items = append(items, clustering.Item{
|
||||
ID: r.PostID,
|
||||
Text: r.PostText,
|
||||
Timestamp: r.PostTimestamp,
|
||||
Likes: r.Likes,
|
||||
Reposts: r.Reposts,
|
||||
Replies: r.Replies,
|
||||
Quotes: r.Quotes,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Server) persistAnalyticsPosts(ctx context.Context, author, platform string, posts []clustering.Item) error {
|
||||
for _, p := range posts {
|
||||
if p.ID == "" {
|
||||
continue
|
||||
}
|
||||
if err := s.db.UpsertAnalyticsPosts(ctx, store.UpsertAnalyticsPostsParams{
|
||||
UserID: author,
|
||||
Platform: platform,
|
||||
PostID: p.ID,
|
||||
PostText: p.Text,
|
||||
PostTimestamp: p.Timestamp,
|
||||
Likes: p.Likes,
|
||||
Reposts: p.Reposts,
|
||||
Replies: p.Replies,
|
||||
Quotes: p.Quotes,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) fetchPostsInTimeForAnalytics(ctx context.Context, platform platform, author string, start, end time.Time, limit int) ([]clustering.Item, error) {
|
||||
var items []clustering.Item
|
||||
switch platform {
|
||||
case Bluesky:
|
||||
res, err := s.bskyClient.GetPostsInTimeframe(ctx, author, start, end, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = blueskyFeedViewsToAnalyticsItems(res)
|
||||
case ActivityPub:
|
||||
res, err := s.apClient.GetPostsInTimeframe(ctx, author, start, end, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = apPostsToAnalyticsItems(res)
|
||||
case Nostr:
|
||||
res, err := s.nostrClient.GetPostsInTimeframe(ctx, author, start, end, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = nostrPostsToAnalyticsItems(res)
|
||||
case Twitter:
|
||||
// Twitter API calls are expensive (paid). Reuse persisted post data and
|
||||
// only call the API for time ranges that are not yet covered by the
|
||||
// cache, using a coverage watermark to guarantee broad coverage.
|
||||
fetched, err := s.fetchTwitterAnalyticsPosts(ctx, author, start, end, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, fetched...)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// fetchTwitterAnalyticsPosts returns posts in [start, end] for the author,
|
||||
// fetching from the Twitter API only the sub-ranges not already covered by the
|
||||
// persistent post cache, and storing any newly fetched posts for future use.
|
||||
//
|
||||
// Coverage is tracked as a set of discrete intervals, so disjoint fetched
|
||||
// ranges (e.g. [Dec 1, Dec 15] and [Dec 20, Dec 31]) are not merged into one
|
||||
// continuous window. A later request that lands in the never-fetched gap
|
||||
// (e.g. [Dec 15, Dec 20]) is therefore fetched from the API rather than being
|
||||
// wrongly served from an incomplete cache.
|
||||
func (s *Server) fetchTwitterAnalyticsPosts(ctx context.Context, author string, start, end time.Time, limit int) ([]clustering.Item, error) {
|
||||
covered, err := s.getAnalyticsCoverageRanges(ctx, author, "Twitter")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If the entire requested range falls within already-covered intervals,
|
||||
// serve from the cache without incurring a paid Twitter request.
|
||||
if len(uncoveredRanges(start, end, covered)) == 0 {
|
||||
return s.getCachedAnalyticsPosts(ctx, author, "Twitter", start, end, limit)
|
||||
}
|
||||
|
||||
// Determine the sub-ranges of [start, end] not yet covered, and fetch each
|
||||
// from the API. Fetching per uncovered range keeps costs bounded while
|
||||
// expanding coverage on every call.
|
||||
ranges := uncoveredRanges(start, end, covered)
|
||||
|
||||
var fetched []clustering.Item
|
||||
for _, r := range ranges {
|
||||
posts, err := s.fetchTwitterFromAPI(ctx, author, r.start, r.end, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fetched = append(fetched, posts...)
|
||||
}
|
||||
|
||||
// Persist newly fetched posts permanently so repeat calls never re-pay.
|
||||
if len(fetched) > 0 {
|
||||
if err := s.persistAnalyticsPosts(ctx, author, "Twitter", fetched); err != nil {
|
||||
log.WarnWithContext(ctx, "failed to persist analytics posts", "platform", "Twitter", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Record the newly fetched intervals as covered so future calls can reuse
|
||||
// them without hitting the paid API.
|
||||
for _, r := range ranges {
|
||||
if err := s.addAnalyticsCoverageRange(ctx, author, "Twitter", r.start, r.end); err != nil {
|
||||
log.WarnWithContext(ctx, "failed to add analytics coverage range", "platform", "Twitter", "range", r, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Return the full set: posts from cache plus anything newly fetched, merged
|
||||
// and de-duplicated by post ID.
|
||||
return s.mergeAnalyticsPosts(ctx, author, start, end, limit, fetched)
|
||||
}
|
||||
|
||||
type timeRange struct {
|
||||
start, end time.Time
|
||||
}
|
||||
|
||||
// uncoveredRanges returns the sub-ranges of [start, end] that are not covered
|
||||
// by any of the given intervals. Intervals are assumed sorted by their start,
|
||||
// and no coverage (empty slice) yields the whole range as uncovered.
|
||||
func uncoveredRanges(start, end time.Time, covered []timeRange) []timeRange {
|
||||
var ranges []timeRange
|
||||
cursor := start
|
||||
for _, c := range covered {
|
||||
if c.end.Before(cursor) || c.end.Equal(cursor) {
|
||||
continue
|
||||
}
|
||||
if c.start.After(end) {
|
||||
break
|
||||
}
|
||||
if c.start.After(cursor) {
|
||||
ranges = append(ranges, timeRange{start: cursor, end: c.start})
|
||||
}
|
||||
if c.end.After(cursor) {
|
||||
cursor = c.end
|
||||
}
|
||||
}
|
||||
if cursor.Before(end) {
|
||||
ranges = append(ranges, timeRange{start: cursor, end: end})
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
|
||||
func (s *Server) getAnalyticsCoverageRanges(ctx context.Context, author, platform string) ([]timeRange, error) {
|
||||
rows, err := s.db.GetAnalyticsCoverageRanges(ctx, store.GetAnalyticsCoverageRangesParams{
|
||||
UserID: author,
|
||||
Platform: platform,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ranges := make([]timeRange, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
ranges = append(ranges, timeRange{start: r.CoveredStart, end: r.CoveredEnd})
|
||||
}
|
||||
return ranges, nil
|
||||
}
|
||||
|
||||
func (s *Server) addAnalyticsCoverageRange(ctx context.Context, author, platform string, start, end time.Time) error {
|
||||
return s.db.InsertAnalyticsCoverageRange(ctx, store.InsertAnalyticsCoverageRangeParams{
|
||||
UserID: author,
|
||||
Platform: platform,
|
||||
CoveredStart: start,
|
||||
CoveredEnd: end,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) fetchTwitterFromAPI(ctx context.Context, author string, start, end time.Time, limit int) ([]clustering.Item, error) {
|
||||
res, err := s.twitterClient.GetPostsInTimeframe(ctx, author, start, end, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return twitterPostsToAnalyticsItems(res), nil
|
||||
}
|
||||
|
||||
// mergeAnalyticsPosts combines cached posts in [start, end] with newly fetched
|
||||
// posts, de-duplicating by post ID and capping the result at limit. The cached
|
||||
// query and each fetched sub-range individually honor limit, so the merged set
|
||||
// can otherwise exceed it when the range is split into multiple fetch calls.
|
||||
func (s *Server) mergeAnalyticsPosts(ctx context.Context, author string, start, end time.Time, limit int, fetched []clustering.Item) ([]clustering.Item, error) {
|
||||
merged := make([]clustering.Item, 0, len(fetched))
|
||||
seen := make(map[string]struct{}, len(fetched))
|
||||
for _, p := range fetched {
|
||||
if len(merged) >= limit {
|
||||
break
|
||||
}
|
||||
if _, ok := seen[p.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[p.ID] = struct{}{}
|
||||
merged = append(merged, p)
|
||||
}
|
||||
|
||||
cached, err := s.getCachedAnalyticsPosts(ctx, author, "Twitter", start, end, limit)
|
||||
if err != nil {
|
||||
return merged, nil
|
||||
}
|
||||
for _, p := range cached {
|
||||
if len(merged) >= limit {
|
||||
break
|
||||
}
|
||||
if _, ok := seen[p.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[p.ID] = struct{}{}
|
||||
merged = append(merged, p)
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
func (s *Server) toToolResult(result any) (*mcp.CallToolResult, error) {
|
||||
data, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: string(data)},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) getCacheTTL(toolName string) time.Duration {
|
||||
switch toolName {
|
||||
case "postingPatterns":
|
||||
return s.analyticsTTLPatterns
|
||||
case "topicModeling":
|
||||
return s.analyticsTTLTopics
|
||||
case "sentimentTrends":
|
||||
return s.analyticsTTLSentiment
|
||||
case "engagementDrivers":
|
||||
return s.analyticsTTLDrivers
|
||||
case "engagementMetrics":
|
||||
fallthrough
|
||||
default:
|
||||
return s.analyticsTTLEngagement
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Computation implementations ----
|
||||
|
||||
func (s *Server) computeEngagementMetrics(ctx context.Context, args AnalyticsRequest, posts []clustering.Item) (any, error) {
|
||||
var metrics []EngagementMetricPoint
|
||||
for _, post := range posts {
|
||||
total := post.Likes + post.Reposts + post.Replies + post.Quotes
|
||||
// Rate is a simple relative measure (interactions per post). Without a
|
||||
// follower/fan base to normalize against, report total interactions.
|
||||
metrics = append(metrics, EngagementMetricPoint{
|
||||
PostID: post.ID,
|
||||
Timestamp: post.Timestamp.Format(time.RFC3339),
|
||||
Likes: post.Likes,
|
||||
Reposts: post.Reposts,
|
||||
Replies: post.Replies,
|
||||
Quotes: post.Quotes,
|
||||
EngagementRate: float64(total),
|
||||
})
|
||||
}
|
||||
|
||||
return EngagementMetricsResponse{
|
||||
Author: args.Author,
|
||||
Metrics: metrics,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) computePostingPatterns(ctx context.Context, args AnalyticsRequest, posts []clustering.Item) (any, error) {
|
||||
// Heatmap: 24 hours x 7 days
|
||||
heatmap := make([][]int, 24)
|
||||
for i := range heatmap {
|
||||
heatmap[i] = make([]int, 7)
|
||||
}
|
||||
dayCounts := make(map[int]int)
|
||||
hourCounts := make(map[int]int)
|
||||
dateSet := make(map[string]bool)
|
||||
|
||||
for _, post := range posts {
|
||||
t := post.Timestamp
|
||||
if t.IsZero() {
|
||||
continue
|
||||
}
|
||||
hour := t.Hour()
|
||||
weekday := int(t.Weekday())
|
||||
heatmap[hour][weekday]++
|
||||
hourCounts[hour]++
|
||||
dayCounts[weekday]++
|
||||
dateSet[t.Format("2006-01-02")] = true
|
||||
}
|
||||
|
||||
// Find peak hours (top 3)
|
||||
type hourCount struct {
|
||||
Hour int
|
||||
Count int
|
||||
}
|
||||
var hours []hourCount
|
||||
for h, c := range hourCounts {
|
||||
hours = append(hours, hourCount{h, c})
|
||||
}
|
||||
sort.Slice(hours, func(i, j int) bool { return hours[i].Count > hours[j].Count })
|
||||
peakHours := make([]int, 0, 3)
|
||||
for i := 0; i < len(hours) && i < 3; i++ {
|
||||
peakHours = append(peakHours, hours[i].Hour)
|
||||
}
|
||||
|
||||
// Find peak days (top 3)
|
||||
type dayCount struct {
|
||||
Day int
|
||||
Count int
|
||||
}
|
||||
var days []dayCount
|
||||
for d, c := range dayCounts {
|
||||
days = append(days, dayCount{d, c})
|
||||
}
|
||||
sort.Slice(days, func(i, j int) bool { return days[i].Count > days[j].Count })
|
||||
peakDays := make([]int, 0, 3)
|
||||
for i := 0; i < len(days) && i < 3; i++ {
|
||||
peakDays = append(peakDays, days[i].Day)
|
||||
}
|
||||
|
||||
totalPosts := len(posts)
|
||||
postingDays := len(dateSet)
|
||||
avgPostsPerDay := 0.0
|
||||
if postingDays > 0 {
|
||||
avgPostsPerDay = float64(totalPosts) / float64(postingDays)
|
||||
}
|
||||
|
||||
return PostingPatternsResponse{
|
||||
Author: args.Author,
|
||||
Heatmap: heatmap,
|
||||
PeakHours: peakHours,
|
||||
PeakDays: peakDays,
|
||||
AvgPostsPerDay: avgPostsPerDay,
|
||||
TotalPosts: totalPosts,
|
||||
PostingDays: postingDays,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) computeTopicModeling(ctx context.Context, args AnalyticsRequest, posts []clustering.Item) (any, error) {
|
||||
topics, _, err := s.clusterPosts(ctx, posts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return TopicModelingResponse{
|
||||
Author: args.Author,
|
||||
Topics: topics,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// clusterPosts runs topic clustering over the posts and returns both the
|
||||
// resulting topics and a map from post ID to the label of the topic the post
|
||||
// belongs to. The membership map lets callers correlate topics with per-post
|
||||
// attributes (e.g. engagement) without repeating the clustering.
|
||||
func (s *Server) clusterPosts(ctx context.Context, posts []clustering.Item) ([]TopicCluster, map[string]string, error) {
|
||||
if len(posts) < minTopicCount {
|
||||
return []TopicCluster{}, map[string]string{}, nil
|
||||
}
|
||||
|
||||
items := posts
|
||||
embeddings, err := s.ollama.EmbedTexts(ctx, items)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to compute embeddings: %w", err)
|
||||
}
|
||||
|
||||
for i := range items {
|
||||
items[i].Embedding = embeddings[i]
|
||||
}
|
||||
|
||||
topicCount := maxTopicCount
|
||||
if len(items) < maxTopicCount {
|
||||
topicCount = len(items)
|
||||
}
|
||||
if topicCount < minTopicCount {
|
||||
topicCount = minTopicCount
|
||||
}
|
||||
|
||||
results, err := clustering.KMeans(items, topicCount, 50, 42)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("kmeans failed: %w", err)
|
||||
}
|
||||
|
||||
membership := make(map[string]string)
|
||||
labelByCluster := make(map[int]string)
|
||||
var topics []TopicCluster
|
||||
for _, cluster := range results {
|
||||
if len(cluster.Items) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var samples []string
|
||||
for _, item := range cluster.Items {
|
||||
if len(samples) >= 3 {
|
||||
break
|
||||
}
|
||||
samples = append(samples, item.Text)
|
||||
}
|
||||
|
||||
// Generate label using Ollama
|
||||
label := s.generateTopicLabel(ctx, samples)
|
||||
labelByCluster[cluster.Cluster] = label
|
||||
keywords := s.extractKeywords(cluster.Items)
|
||||
|
||||
topics = append(topics, TopicCluster{
|
||||
TopicID: cluster.Cluster,
|
||||
Label: label,
|
||||
Keywords: keywords,
|
||||
PostCount: len(cluster.Items),
|
||||
Percentage: float64(len(cluster.Items)) / float64(len(items)) * 100,
|
||||
SamplePosts: samples,
|
||||
})
|
||||
}
|
||||
|
||||
// Map each post to the label of the cluster it belongs to.
|
||||
for _, cluster := range results {
|
||||
label := labelByCluster[cluster.Cluster]
|
||||
for _, item := range cluster.Items {
|
||||
if item.ID != "" {
|
||||
membership[item.ID] = label
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by post count descending
|
||||
sort.Slice(topics, func(i, j int) bool { return topics[i].PostCount > topics[j].PostCount })
|
||||
|
||||
return topics, membership, nil
|
||||
}
|
||||
|
||||
func (s *Server) computeSentimentTrends(ctx context.Context, args AnalyticsRequest, posts []clustering.Item) (any, error) {
|
||||
// Bucket posts by day
|
||||
bucketMap := make(map[string][]string)
|
||||
for _, post := range posts {
|
||||
if post.Timestamp.IsZero() {
|
||||
continue
|
||||
}
|
||||
bucket := post.Timestamp.Truncate(sentimentBucketDuration).Format(time.RFC3339)
|
||||
bucketMap[bucket] = append(bucketMap[bucket], post.Text)
|
||||
}
|
||||
|
||||
var timeSeries []SentimentPoint
|
||||
overallPos, overallNeg, overallNeu := 0.0, 0.0, 0.0
|
||||
emotionCounts := make(map[string]int)
|
||||
var shifts []SentimentShift
|
||||
var prevSentiment string
|
||||
var prevScores [3]float64
|
||||
|
||||
// Only posts with a timestamp are bucketed and contribute to sentiment, so
|
||||
// the overall ratios are normalized by that same count (not the total
|
||||
// number of fetched posts, some of which may lack a timestamp).
|
||||
timestampedPosts := 0
|
||||
for _, post := range posts {
|
||||
if !post.Timestamp.IsZero() {
|
||||
timestampedPosts++
|
||||
}
|
||||
}
|
||||
|
||||
// Process buckets in chronological order so sentiment shifts are detected correctly.
|
||||
buckets := make([]string, 0, len(bucketMap))
|
||||
for b := range bucketMap {
|
||||
buckets = append(buckets, b)
|
||||
}
|
||||
sort.Strings(buckets)
|
||||
|
||||
for _, bucket := range buckets {
|
||||
texts := bucketMap[bucket]
|
||||
pos, neg, neu, emotions := s.analyzeSentiment(ctx, texts)
|
||||
timeSeries = append(timeSeries, SentimentPoint{
|
||||
Timestamp: bucket,
|
||||
Positive: pos,
|
||||
Negative: neg,
|
||||
Neutral: neu,
|
||||
PostCount: len(texts),
|
||||
})
|
||||
|
||||
overallPos += pos * float64(len(texts))
|
||||
overallNeg += neg * float64(len(texts))
|
||||
overallNeu += neu * float64(len(texts))
|
||||
|
||||
for e, c := range emotions {
|
||||
emotionCounts[e] += c
|
||||
}
|
||||
|
||||
// Detect sentiment shifts
|
||||
dominant := maxSentiment(pos, neg, neu)
|
||||
if prevSentiment != "" && dominant != prevSentiment {
|
||||
mag := math.Abs(pos-prevScores[0]) + math.Abs(neg-prevScores[1]) + math.Abs(neu-prevScores[2])
|
||||
shifts = append(shifts, SentimentShift{
|
||||
Timestamp: bucket,
|
||||
FromSentiment: prevSentiment,
|
||||
ToSentiment: dominant,
|
||||
Magnitude: mag,
|
||||
})
|
||||
}
|
||||
prevSentiment = dominant
|
||||
prevScores = [3]float64{pos, neg, neu}
|
||||
}
|
||||
|
||||
totalPosts := float64(timestampedPosts)
|
||||
if totalPosts > 0 {
|
||||
overallPos /= totalPosts
|
||||
overallNeg /= totalPosts
|
||||
overallNeu /= totalPosts
|
||||
}
|
||||
|
||||
// Top emotions
|
||||
var topEmotions []EmotionScore
|
||||
type emoCount struct {
|
||||
Emotion string
|
||||
Count int
|
||||
}
|
||||
var emos []emoCount
|
||||
for e, c := range emotionCounts {
|
||||
emos = append(emos, emoCount{e, c})
|
||||
}
|
||||
sort.Slice(emos, func(i, j int) bool { return emos[i].Count > emos[j].Count })
|
||||
for i := 0; i < len(emos) && i < 5; i++ {
|
||||
topEmotions = append(topEmotions, EmotionScore{
|
||||
Emotion: emos[i].Emotion,
|
||||
Score: float64(emos[i].Count) / totalPosts,
|
||||
})
|
||||
}
|
||||
|
||||
return SentimentTrendsResponse{
|
||||
Author: args.Author,
|
||||
TimeSeries: timeSeries,
|
||||
OverallSentiment: SentimentSummary{
|
||||
Positive: overallPos,
|
||||
Negative: overallNeg,
|
||||
Neutral: overallNeu,
|
||||
},
|
||||
DominantEmotions: topEmotions,
|
||||
SentimentShifts: shifts,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) computeEngagementDrivers(ctx context.Context, args AnalyticsRequest, posts []clustering.Item) (any, error) {
|
||||
// Topic modeling first, getting post->topic membership for correlation.
|
||||
topics, membership, err := s.clusterPosts(ctx, posts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Group engagement by topic label. Each post contributes its total
|
||||
// engagement (likes + reposts + replies + quotes) to its topic.
|
||||
topicEngagement := make(map[string][]float64)
|
||||
for _, post := range posts {
|
||||
label, ok := membership[post.ID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
total := float64(post.Likes + post.Reposts + post.Replies + post.Quotes)
|
||||
topicEngagement[label] = append(topicEngagement[label], total)
|
||||
}
|
||||
|
||||
// Baseline is the mean engagement across all posts, for comparison.
|
||||
var allEngagement []float64
|
||||
for _, post := range posts {
|
||||
allEngagement = append(allEngagement, float64(post.Likes+post.Reposts+post.Replies+post.Quotes))
|
||||
}
|
||||
baseline := mean(allEngagement)
|
||||
|
||||
var drivers []EngagementDriver
|
||||
for _, topic := range topics {
|
||||
values := topicEngagement[topic.Label]
|
||||
topicMean := mean(values)
|
||||
correlation := 0.0
|
||||
if denom := topicMean + baseline; denom > 0 {
|
||||
correlation = (topicMean - baseline) / denom
|
||||
if correlation > 1 {
|
||||
correlation = 1
|
||||
} else if correlation < -1 {
|
||||
correlation = -1
|
||||
}
|
||||
}
|
||||
drivers = append(drivers, EngagementDriver{
|
||||
Topic: topic.Label,
|
||||
AvgEngagement: topicMean,
|
||||
Correlation: correlation,
|
||||
PostCount: topic.PostCount,
|
||||
SamplePosts: topic.SamplePosts[:min(2, len(topic.SamplePosts))],
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(drivers, func(i, j int) bool {
|
||||
return math.Abs(drivers[i].Correlation) > math.Abs(drivers[j].Correlation)
|
||||
})
|
||||
|
||||
return EngagementDriversResponse{
|
||||
Author: args.Author,
|
||||
Drivers: drivers,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func mean(values []float64) float64 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
sum := 0.0
|
||||
for _, v := range values {
|
||||
sum += v
|
||||
}
|
||||
return sum / float64(len(values))
|
||||
}
|
||||
|
||||
// ---- Helper functions ----
|
||||
|
||||
func (s *Server) generateTopicLabel(ctx context.Context, samples []string) string {
|
||||
if len(samples) == 0 {
|
||||
return "General"
|
||||
}
|
||||
prompt := fmt.Sprintf(`Given these sample posts, provide a concise 1-3 word topic label:
|
||||
|
||||
Posts:
|
||||
%s
|
||||
|
||||
Label only (no explanation):`, strings.Join(samples, "\n---\n"))
|
||||
|
||||
resp, err := s.ollama.SummarizeStyle(ctx, []clustering.Item{{Text: prompt}})
|
||||
if err != nil {
|
||||
return "General"
|
||||
}
|
||||
return strings.TrimSpace(resp)
|
||||
}
|
||||
|
||||
func (s *Server) extractKeywords(items []clustering.Item) []string {
|
||||
var allText []string
|
||||
for _, item := range items {
|
||||
allText = append(allText, item.Text)
|
||||
}
|
||||
return utils.TopWords(allText, make(map[string]struct{}), 5)
|
||||
}
|
||||
|
||||
func (s *Server) analyzeSentiment(ctx context.Context, texts []string) (pos, neg, neu float64, emotions map[string]int) {
|
||||
if len(texts) == 0 {
|
||||
return 0, 0, 0, nil
|
||||
}
|
||||
|
||||
// Use Ollama for sentiment analysis
|
||||
prompt := fmt.Sprintf(`Analyze sentiment and emotions in these texts. Return JSON only:
|
||||
{
|
||||
"positive": 0.0-1.0,
|
||||
"negative": 0.0-1.0,
|
||||
"neutral": 0.0-1.0,
|
||||
"emotions": {"emotion": count}
|
||||
}
|
||||
|
||||
Texts:
|
||||
%s`, strings.Join(texts, "\n---\n"))
|
||||
|
||||
resp, err := s.ollama.SummarizeStyle(ctx, []clustering.Item{{Text: prompt}})
|
||||
if err != nil {
|
||||
return 0.33, 0.33, 0.34, nil
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Positive float64 `json:"positive"`
|
||||
Negative float64 `json:"negative"`
|
||||
Neutral float64 `json:"neutral"`
|
||||
Emotions map[string]int `json:"emotions"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(resp), &result)
|
||||
|
||||
// Normalize
|
||||
sum := result.Positive + result.Negative + result.Neutral
|
||||
if sum > 0 {
|
||||
result.Positive /= sum
|
||||
result.Negative /= sum
|
||||
result.Neutral /= sum
|
||||
}
|
||||
|
||||
return result.Positive, result.Negative, result.Neutral, result.Emotions
|
||||
}
|
||||
|
||||
func maxSentiment(pos, neg, neu float64) string {
|
||||
if pos >= neg && pos >= neu {
|
||||
return "positive"
|
||||
}
|
||||
if neg >= pos && neg >= neu {
|
||||
return "negative"
|
||||
}
|
||||
return "neutral"
|
||||
}
|
||||
|
||||
func int64Val(p *int64) int64 {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return *p
|
||||
}
|
||||
318
internal/server/analytics_test.go
Normal file
318
internal/server/analytics_test.go
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nacorid/naco-api/internal/activitypub"
|
||||
"github.com/nacorid/naco-api/internal/clustering"
|
||||
"github.com/nacorid/naco-api/internal/twitter"
|
||||
)
|
||||
|
||||
var errSome = fmt.Errorf("some error")
|
||||
|
||||
func analyticsAPPosts() *fakeAP {
|
||||
base := time.Date(2024, 1, 10, 12, 0, 0, 0, time.UTC)
|
||||
return &fakeAP{posts: []*activitypub.Post{
|
||||
{ID: "p1", Author: "@a@mastodon.social", Content: "first post", CreatedAt: base},
|
||||
{ID: "p2", Author: "@a@mastodon.social", Content: "second post", CreatedAt: base.Add(time.Hour)},
|
||||
}}
|
||||
}
|
||||
|
||||
// ---- validation ----
|
||||
|
||||
func TestAnalyticsEmptyAuthor(t *testing.T) {
|
||||
s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
|
||||
if _, err := callTool(s, "getEngagementMetrics", AnalyticsRequest{Author: ""}); err == nil || !strings.Contains(err.Error(), "author") {
|
||||
t.Fatalf("expected author error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyticsInvalidTimeRange(t *testing.T) {
|
||||
s := newTestServer(&fakeBluesky{}, analyticsAPPosts(), &fakeNostr{}, &fakeTwitter{})
|
||||
_, err := callTool(s, "getEngagementMetrics", AnalyticsRequest{
|
||||
Author: "@a@mastodon.social",
|
||||
StartTime: "not-a-time",
|
||||
EndTime: "2024-12-31T00:00:00Z",
|
||||
Platform: "ActivityPub",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "time range") {
|
||||
t.Fatalf("expected time range error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyticsUnknownPlatform(t *testing.T) {
|
||||
s := newTestServer(&fakeBluesky{}, analyticsAPPosts(), &fakeNostr{}, &fakeTwitter{})
|
||||
_, err := callTool(s, "getEngagementMetrics", AnalyticsRequest{
|
||||
Author: "@a@mastodon.social",
|
||||
Platform: "MySpace",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), `unsupported platform "MySpace"`) {
|
||||
t.Fatalf("expected unsupported platform error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyticsNoPostsFound(t *testing.T) {
|
||||
ap := &fakeAP{} // no posts
|
||||
s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{})
|
||||
_, err := callTool(s, "getEngagementMetrics", AnalyticsRequest{Author: "@a@mastodon.social", Platform: "ActivityPub"})
|
||||
if err == nil || !strings.Contains(err.Error(), "no posts found") {
|
||||
t.Fatalf("expected no-posts error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- success + caching ----
|
||||
|
||||
func TestAnalyticsEngagementMetricsAndCache(t *testing.T) {
|
||||
ap := analyticsAPPosts()
|
||||
st := newFakeStore()
|
||||
s := newServerWith(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{})
|
||||
|
||||
args := AnalyticsRequest{
|
||||
Author: "@a@mastodon.social",
|
||||
StartTime: "2024-01-01T00:00:00Z",
|
||||
EndTime: "2024-12-31T00:00:00Z",
|
||||
Platform: "ActivityPub",
|
||||
}
|
||||
res, err := callTool(s, "getEngagementMetrics", args)
|
||||
if err != nil {
|
||||
t.Fatalf("getEngagementMetrics error: %v", err)
|
||||
}
|
||||
var out EngagementMetricsResponse
|
||||
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if out.Author != "@a@mastodon.social" || len(out.Metrics) != 2 {
|
||||
t.Fatalf("unexpected metrics: author=%q count=%d", out.Author, len(out.Metrics))
|
||||
}
|
||||
|
||||
// Cached result is served without hitting the backend again.
|
||||
if _, err := callTool(s, "getEngagementMetrics", args); err != nil {
|
||||
t.Fatalf("second call error: %v", err)
|
||||
}
|
||||
if ap.calls != 1 {
|
||||
t.Fatalf("expected backend called once due to cache, got %d", ap.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyticsSinglePlatformScopeDoesNotCrossPollinate(t *testing.T) {
|
||||
// Engagement over ActivityPub should never be served from a cache written
|
||||
// under the aggregate "all" scope.
|
||||
ap := analyticsAPPosts()
|
||||
st := newFakeStore()
|
||||
s := newServerWith(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{})
|
||||
|
||||
// Warm cache under "all" scope (no platform) first.
|
||||
args := AnalyticsRequest{
|
||||
Author: "@a@mastodon.social",
|
||||
StartTime: "2024-01-05T00:00:00Z",
|
||||
EndTime: "2024-01-15T00:00:00Z",
|
||||
}
|
||||
if _, err := callTool(s, "getEngagementMetrics", args); err != nil {
|
||||
t.Fatalf("aggregate call error: %v", err)
|
||||
}
|
||||
|
||||
// Now request a single-platform scope; it must still fetch from the backend.
|
||||
spArgs := args
|
||||
spArgs.Platform = "ActivityPub"
|
||||
if _, err := callTool(s, "getEngagementMetrics", spArgs); err != nil {
|
||||
t.Fatalf("scoped call error: %v", err)
|
||||
}
|
||||
if ap.calls != 2 {
|
||||
t.Fatalf("expected scoped request to re-fetch (distinct cache scope), got %d calls", ap.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyticsPartialPlatformFailureTolerated(t *testing.T) {
|
||||
// One platform failing must not abort the whole aggregation.
|
||||
bsky := &fakeBluesky{err: errSome}
|
||||
ap := analyticsAPPosts()
|
||||
st := newFakeStore()
|
||||
s := newServerWith(bsky, ap, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{})
|
||||
_, err := callTool(s, "getEngagementMetrics", AnalyticsRequest{
|
||||
Author: "@a@mastodon.social",
|
||||
StartTime: "2024-01-01T00:00:00Z",
|
||||
EndTime: "2024-12-31T00:00:00Z",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected partial failure to be tolerated, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnalyticsTwitterPostCount verifies the response reports how many posts
|
||||
// were sourced from the Twitter API, so upto-scheme settlement can charge the
|
||||
// realized Twitter API costs.
|
||||
func TestAnalyticsTwitterPostCount(t *testing.T) {
|
||||
tw := &fakeTwitter{posts: []*twitter.Post{
|
||||
{ID: "t1", Content: "first tweet", CreatedAt: time.Date(2024, 1, 11, 12, 0, 0, 0, time.UTC)},
|
||||
{ID: "t2", Content: "second tweet", CreatedAt: time.Date(2024, 1, 12, 12, 0, 0, 0, time.UTC)},
|
||||
{ID: "t3", Content: "third tweet", CreatedAt: time.Date(2024, 1, 13, 12, 0, 0, 0, time.UTC)},
|
||||
}}
|
||||
st := newFakeStore()
|
||||
s := newServerWith(&fakeBluesky{}, analyticsAPPosts(), &fakeNostr{}, tw, st, &fakeOllama{})
|
||||
|
||||
res, err := callTool(s, "getEngagementMetrics", AnalyticsRequest{
|
||||
Author: "@alice",
|
||||
StartTime: "2024-01-01T00:00:00Z",
|
||||
EndTime: "2024-12-31T00:00:00Z",
|
||||
Platform: "Twitter",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("getEngagementMetrics error: %v", err)
|
||||
}
|
||||
var out EngagementMetricsResponse
|
||||
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if out.TwitterPostCount != 3 {
|
||||
t.Fatalf("expected TwitterPostCount 3, got %d", out.TwitterPostCount)
|
||||
}
|
||||
|
||||
// A non-Twitter platform reports 0 and the default aggregate excludes
|
||||
// Twitter when the API is not enabled (twitterEnabled=false in tests).
|
||||
res, err = callTool(s, "getEngagementMetrics", AnalyticsRequest{
|
||||
Author: "@a@mastodon.social",
|
||||
StartTime: "2024-01-01T00:00:00Z",
|
||||
EndTime: "2024-12-31T00:00:00Z",
|
||||
Platform: "ActivityPub",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("getEngagementMetrics error: %v", err)
|
||||
}
|
||||
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if out.TwitterPostCount != 0 {
|
||||
t.Fatalf("expected TwitterPostCount 0 for ActivityPub, got %d", out.TwitterPostCount)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- pure compute functions ----
|
||||
|
||||
func TestComputePostingPatterns(t *testing.T) {
|
||||
s := &Server{}
|
||||
args := AnalyticsRequest{Author: "a"}
|
||||
mon := time.Date(2024, 1, 1, 9, 0, 0, 0, time.UTC) // Monday 09:00
|
||||
posts := []clustering.Item{
|
||||
{ID: "1", Timestamp: mon},
|
||||
{ID: "2", Timestamp: mon.Add(30 * time.Minute)}, // same hour 9
|
||||
{ID: "3"}, // zero timestamp: skipped
|
||||
}
|
||||
res, err := s.computePostingPatterns(context.Background(), args, posts)
|
||||
if err != nil {
|
||||
t.Fatalf("computePostingPatterns error: %v", err)
|
||||
}
|
||||
out := res.(PostingPatternsResponse)
|
||||
// Two timestamped posts across one distinct day.
|
||||
if out.TotalPosts != 3 {
|
||||
t.Fatalf("expected TotalPosts 3, got %d", out.TotalPosts)
|
||||
}
|
||||
if out.PostingDays != 1 {
|
||||
t.Fatalf("expected PostingDays 1, got %d", out.PostingDays)
|
||||
}
|
||||
if out.AvgPostsPerDay != 3 {
|
||||
t.Fatalf("expected AvgPostsPerDay 3, got %f", out.AvgPostsPerDay)
|
||||
}
|
||||
if len(out.PeakHours) == 0 || out.PeakHours[0] != 9 {
|
||||
t.Fatalf("expected peak hour 9, got %+v", out.PeakHours)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputePostingPatternsNoTimestamps(t *testing.T) {
|
||||
s := &Server{}
|
||||
posts := []clustering.Item{{ID: "1"}, {ID: "2"}}
|
||||
res, err := s.computePostingPatterns(context.Background(), AnalyticsRequest{}, posts)
|
||||
if err != nil {
|
||||
t.Fatalf("computePostingPatterns error: %v", err)
|
||||
}
|
||||
out := res.(PostingPatternsResponse)
|
||||
// Division by zero avoided: posting days of 0 leaves AvgPostsPerDay at 0.
|
||||
if out.PostingDays != 0 || out.AvgPostsPerDay != 0 {
|
||||
t.Fatalf("expected 0 posting days / 0 avg, got %d / %f", out.PostingDays, out.AvgPostsPerDay)
|
||||
}
|
||||
if out.TotalPosts != 2 {
|
||||
t.Fatalf("expected TotalPosts 2, got %d", out.TotalPosts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeEngagementMetrics(t *testing.T) {
|
||||
s := &Server{}
|
||||
res, err := s.computeEngagementMetrics(context.Background(), AnalyticsRequest{Author: "a"}, []clustering.Item{
|
||||
{ID: "p", Timestamp: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), Likes: 2, Reposts: 3, Replies: 1, Quotes: 0},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("computeEngagementMetrics error: %v", err)
|
||||
}
|
||||
out := res.(EngagementMetricsResponse)
|
||||
if len(out.Metrics) != 1 {
|
||||
t.Fatalf("expected 1 metric, got %d", len(out.Metrics))
|
||||
}
|
||||
m := out.Metrics[0]
|
||||
if m.EngagementRate != 6 {
|
||||
t.Fatalf("expected engagement rate 6, got %f", m.EngagementRate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeSentimentTrends(t *testing.T) {
|
||||
oll := &fakeOllama{summaries: []string{`{"positive":0.8,"negative":0.1,"neutral":0.1,"emotions":{"joy":1}}`}}
|
||||
s := &Server{ollama: oll}
|
||||
day := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
res, err := s.computeSentimentTrends(context.Background(), AnalyticsRequest{Author: "a"}, []clustering.Item{
|
||||
{ID: "a", Text: "great day", Timestamp: day},
|
||||
{ID: "b", Text: "wonderful", Timestamp: day.Add(2 * time.Hour)},
|
||||
{ID: "c", Text: "no ts"}, // zero timestamp, excluded from overall normalization
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("computeSentimentTrends error: %v", err)
|
||||
}
|
||||
out := res.(SentimentTrendsResponse)
|
||||
if len(out.TimeSeries) != 1 {
|
||||
t.Fatalf("expected 1 daily bucket, got %d", len(out.TimeSeries))
|
||||
}
|
||||
if out.TimeSeries[0].PostCount != 2 {
|
||||
t.Fatalf("expected bucket post count 2, got %d", out.TimeSeries[0].PostCount)
|
||||
}
|
||||
// Overall normalized by timestamped posts (2), not total (3).
|
||||
if out.OverallSentiment.Positive != 0.8 {
|
||||
t.Fatalf("expected overall positive 0.8, got %f", out.OverallSentiment.Positive)
|
||||
}
|
||||
if oll.summCalls == 0 {
|
||||
t.Fatal("expected sentiment ollama call")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeSentimentTrendsNoTimestampedPosts(t *testing.T) {
|
||||
s := &Server{ollama: &fakeOllama{summaries: []string{`{"positive":1,"negative":0,"neutral":0}`}}}
|
||||
res, err := s.computeSentimentTrends(context.Background(), AnalyticsRequest{}, []clustering.Item{
|
||||
{ID: "a", Text: "x"}, // no timestamp
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("computeSentimentTrends error: %v", err)
|
||||
}
|
||||
out := res.(SentimentTrendsResponse)
|
||||
// Division by zero avoided: overall stays 0 when there are no timestamped posts.
|
||||
if out.OverallSentiment.Positive != 0 || out.OverallSentiment.Negative != 0 || out.OverallSentiment.Neutral != 0 {
|
||||
t.Fatalf("expected zero overall sentiment, got %+v", out.OverallSentiment)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeSentimentFallsBackOnOllamaError(t *testing.T) {
|
||||
s := &Server{ollama: &fakeOllama{summErr: errSome}}
|
||||
pos, neg, neu, _ := s.analyzeSentiment(context.Background(), []string{"text"})
|
||||
if pos == 0 || neg == 0 || neu == 0 {
|
||||
t.Fatalf("expected non-zero fallback sentiment, got %f/%f/%f", pos, neg, neu)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateTopicLabelFallsBackToGeneral(t *testing.T) {
|
||||
s := &Server{ollama: &fakeOllama{summErr: errSome}}
|
||||
if got := s.generateTopicLabel(context.Background(), []string{"sample"}); got != "General" {
|
||||
t.Fatalf("expected General on error, got %q", got)
|
||||
}
|
||||
if got := s.generateTopicLabel(context.Background(), []string{}); got != "General" {
|
||||
t.Fatalf("expected General for empty samples, got %q", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,12 +2,16 @@ package server
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
|
@ -16,52 +20,66 @@ import (
|
|||
|
||||
type CDPAuth struct {
|
||||
keyName string
|
||||
privateKey *ecdsa.PrivateKey
|
||||
privateKey crypto.Signer
|
||||
signingMethod jwt.SigningMethod
|
||||
}
|
||||
|
||||
func NewCDPAuth(keyName, privateKeyPEM string) (*CDPAuth, error) {
|
||||
if keyName == "" {
|
||||
return nil, errors.New("key name is required")
|
||||
}
|
||||
if privateKeyPEM == "" {
|
||||
return nil, errors.New("private key is required")
|
||||
}
|
||||
|
||||
// Forgejo/GitHub secrets store multiline PEM as single line with literal \n.
|
||||
// Convert to real newlines before decoding; idempotent if already multiline.
|
||||
privateKeyPEM = strings.ReplaceAll(privateKeyPEM, "\\n", "\n")
|
||||
|
||||
// Check if it's a base64-encoded Ed25519 key (64 bytes).
|
||||
decoded, err := base64.StdEncoding.DecodeString(privateKeyPEM)
|
||||
if err == nil && len(decoded) == 64 {
|
||||
return &CDPAuth{
|
||||
keyName: keyName,
|
||||
privateKey: ed25519.PrivateKey(decoded),
|
||||
signingMethod: jwt.SigningMethodEdDSA,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Otherwise, try PEM-decoded EC key
|
||||
block, _ := pem.Decode([]byte(privateKeyPEM))
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("failed to decode PEM block")
|
||||
}
|
||||
|
||||
var signingMethod jwt.SigningMethod
|
||||
// Try to parse as EC private key first
|
||||
privKey, err := x509.ParseECPrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse private key: %w", err)
|
||||
}
|
||||
var ok bool
|
||||
privKey, ok = parsed.(*ecdsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("not an ECDSA private key")
|
||||
}
|
||||
signingMethod = jwt.SigningMethodEdDSA
|
||||
} else {
|
||||
signingMethod = jwt.SigningMethodES256
|
||||
return nil, fmt.Errorf("failed to parse EC private key: %w", err)
|
||||
}
|
||||
|
||||
return &CDPAuth{
|
||||
keyName: keyName,
|
||||
privateKey: privKey,
|
||||
signingMethod: signingMethod,
|
||||
signingMethod: jwt.SigningMethodES256,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *CDPAuth) generateBearerToken(method, path string) (string, error) {
|
||||
nonceBytes := make([]byte, 16)
|
||||
if _, err := rand.Read(nonceBytes); err != nil {
|
||||
return "", err
|
||||
return "", fmt.Errorf("failed to generate nonce: %w", err)
|
||||
}
|
||||
nonce := hex.EncodeToString(nonceBytes)
|
||||
uri := fmt.Sprintf("%s api.cdp.coinbase.com%s", method, path)
|
||||
now := time.Now().Unix()
|
||||
now := time.Now()
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
"sub": a.keyName,
|
||||
"iss": "cdp",
|
||||
"nbf": now,
|
||||
"exp": now + 120,
|
||||
"nbf": now.Unix(),
|
||||
"iat": now.Unix(),
|
||||
"exp": now.Add(120 * time.Second).Unix(),
|
||||
"uris": []string{uri},
|
||||
}
|
||||
|
||||
|
|
@ -69,16 +87,43 @@ func (a *CDPAuth) generateBearerToken(method, path string) (string, error) {
|
|||
token.Header["kid"] = a.keyName
|
||||
token.Header["nonce"] = nonce
|
||||
|
||||
return token.SignedString(a.privateKey)
|
||||
var signedToken string
|
||||
var err error
|
||||
|
||||
switch a.signingMethod {
|
||||
case jwt.SigningMethodES256, jwt.SigningMethodEdDSA:
|
||||
signedToken, err = token.SignedString(a.privateKey)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported signing method: %v", a.signingMethod)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign token: %w", err)
|
||||
}
|
||||
|
||||
return signedToken, nil
|
||||
}
|
||||
|
||||
func (a *CDPAuth) GetAuthHeaders(ctx context.Context) (x402http.AuthHeaders, error) {
|
||||
verify, _ := a.generateBearerToken("POST", "/platform/v2/x402/verify")
|
||||
settle, _ := a.generateBearerToken("POST", "/platform/v2/x402/settle")
|
||||
supported, _ := a.generateBearerToken("GET", "/platform/v2/x402/supported")
|
||||
verify, err := a.generateBearerToken("POST", "/platform/v2/x402/verify")
|
||||
if err != nil {
|
||||
return x402http.AuthHeaders{}, fmt.Errorf("failed to generate verify token: %w", err)
|
||||
}
|
||||
settle, err := a.generateBearerToken("POST", "/platform/v2/x402/settle")
|
||||
if err != nil {
|
||||
return x402http.AuthHeaders{}, fmt.Errorf("failed to generate settle token: %w", err)
|
||||
}
|
||||
supported, err := a.generateBearerToken("GET", "/platform/v2/x402/supported")
|
||||
if err != nil {
|
||||
return x402http.AuthHeaders{}, fmt.Errorf("failed to generate supported token: %w", err)
|
||||
}
|
||||
return x402http.AuthHeaders{
|
||||
Verify: map[string]string{"Authorization": "Bearer " + verify},
|
||||
Settle: map[string]string{"Authorization": "Bearer " + settle},
|
||||
Supported: map[string]string{"Authorization": "Bearer " + supported},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *CDPAuth) GetSigningMethod() jwt.SigningMethod {
|
||||
return a.signingMethod
|
||||
}
|
||||
|
|
|
|||
213
internal/server/auth_test.go
Normal file
213
internal/server/auth_test.go
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"testing"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
func generateTestECKey() string {
|
||||
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
der, err := x509.MarshalECPrivateKey(privateKey)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
block := &pem.Block{
|
||||
Type: "EC PRIVATE KEY",
|
||||
Bytes: der,
|
||||
}
|
||||
return string(pem.EncodeToMemory(block))
|
||||
}
|
||||
|
||||
func TestNewCDPAuth_ECKey(t *testing.T) {
|
||||
ecKey := generateTestECKey()
|
||||
|
||||
auth, err := NewCDPAuth("test-key-id", ecKey)
|
||||
if err != nil {
|
||||
t.Fatalf("NewCDPAuth failed: %v", err)
|
||||
}
|
||||
|
||||
if auth.signingMethod != jwt.SigningMethodES256 {
|
||||
t.Errorf("expected ES256, got %v", auth.signingMethod)
|
||||
}
|
||||
|
||||
if auth.keyName != "test-key-id" {
|
||||
t.Errorf("expected keyName 'test-key-id', got %v", auth.keyName)
|
||||
}
|
||||
|
||||
if auth.privateKey == nil {
|
||||
t.Error("privateKey should not be nil")
|
||||
}
|
||||
|
||||
_, ok := auth.privateKey.(*ecdsa.PrivateKey)
|
||||
if !ok {
|
||||
t.Error("privateKey should be *ecdsa.PrivateKey")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCDPAuth_Ed25519Key(t *testing.T) {
|
||||
// Generate a valid Ed25519 key
|
||||
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate Ed25519 key: %v", err)
|
||||
}
|
||||
edKey := base64.StdEncoding.EncodeToString(privateKey)
|
||||
|
||||
auth, err := NewCDPAuth("test-key-id", edKey)
|
||||
if err != nil {
|
||||
t.Fatalf("NewCDPAuth failed: %v", err)
|
||||
}
|
||||
|
||||
if auth.signingMethod != jwt.SigningMethodEdDSA {
|
||||
t.Errorf("expected EdDSA, got %v", auth.signingMethod)
|
||||
}
|
||||
|
||||
if auth.privateKey == nil {
|
||||
t.Error("privateKey should not be nil")
|
||||
}
|
||||
|
||||
_, ok := auth.privateKey.(ed25519.PrivateKey)
|
||||
if !ok {
|
||||
t.Error("privateKey should be ed25519.PrivateKey")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCDPAuth_InvalidKey(t *testing.T) {
|
||||
_, err := NewCDPAuth("test-key-id", "invalid-key")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCDPAuth_EmptyKeyName(t *testing.T) {
|
||||
_, err := NewCDPAuth("", "some-key")
|
||||
if err == nil {
|
||||
t.Error("expected error for empty key name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCDPAuth_EmptyKey(t *testing.T) {
|
||||
_, err := NewCDPAuth("test-key-id", "")
|
||||
if err == nil {
|
||||
t.Error("expected error for empty key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateBearerToken_ECKey(t *testing.T) {
|
||||
ecKey := generateTestECKey()
|
||||
|
||||
auth, err := NewCDPAuth("test-key-id", ecKey)
|
||||
if err != nil {
|
||||
t.Fatalf("NewCDPAuth failed: %v", err)
|
||||
}
|
||||
|
||||
token, err := auth.generateBearerToken("POST", "/platform/v2/x402/verify")
|
||||
if err != nil {
|
||||
t.Fatalf("generateBearerToken failed: %v", err)
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
t.Error("token should not be empty")
|
||||
}
|
||||
|
||||
// Parse and verify claims
|
||||
parsed, err := jwt.Parse(token, func(token *jwt.Token) (any, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodECDSA); !ok {
|
||||
return nil, jwt.ErrSignatureInvalid
|
||||
}
|
||||
return auth.privateKey.(*ecdsa.PrivateKey).Public(), nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse token: %v", err)
|
||||
}
|
||||
|
||||
if !parsed.Valid {
|
||||
t.Error("token should be valid")
|
||||
}
|
||||
|
||||
claims, ok := parsed.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
t.Fatal("claims should be MapClaims")
|
||||
}
|
||||
|
||||
if claims["sub"] != "test-key-id" {
|
||||
t.Errorf("expected sub 'test-key-id', got %v", claims["sub"])
|
||||
}
|
||||
if claims["iss"] != "cdp" {
|
||||
t.Errorf("expected iss 'cdp', got %v", claims["iss"])
|
||||
}
|
||||
if claims["iat"] == nil {
|
||||
t.Error("iat claim should be present")
|
||||
}
|
||||
if claims["nbf"] == nil {
|
||||
t.Error("nbf claim should be present")
|
||||
}
|
||||
if claims["exp"] == nil {
|
||||
t.Error("exp claim should be present")
|
||||
}
|
||||
if claims["uris"] == nil {
|
||||
t.Error("uris claim should be present")
|
||||
}
|
||||
|
||||
uris, ok := claims["uris"].([]any)
|
||||
if !ok || len(uris) != 1 {
|
||||
t.Errorf("expected uris array with 1 element, got %v", claims["uris"])
|
||||
}
|
||||
if uris[0] != "POST api.cdp.coinbase.com/platform/v2/x402/verify" {
|
||||
t.Errorf("expected uri 'POST api.cdp.coinbase.com/platform/v2/x402/verify', got %v", uris[0])
|
||||
}
|
||||
|
||||
// Check header
|
||||
if parsed.Header["kid"] != "test-key-id" {
|
||||
t.Errorf("expected kid 'test-key-id', got %v", parsed.Header["kid"])
|
||||
}
|
||||
if parsed.Header["nonce"] == nil {
|
||||
t.Error("nonce header should be present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAuthHeaders(t *testing.T) {
|
||||
ecKey := generateTestECKey()
|
||||
|
||||
auth, err := NewCDPAuth("test-key-id", ecKey)
|
||||
if err != nil {
|
||||
t.Fatalf("NewCDPAuth failed: %v", err)
|
||||
}
|
||||
|
||||
headers, err := auth.GetAuthHeaders(context.TODO())
|
||||
if err != nil {
|
||||
t.Fatalf("GetAuthHeaders failed: %v", err)
|
||||
}
|
||||
|
||||
if headers.Verify == nil || headers.Verify["Authorization"] == "" {
|
||||
t.Error("Verify header should be present")
|
||||
}
|
||||
if headers.Settle == nil || headers.Settle["Authorization"] == "" {
|
||||
t.Error("Settle header should be present")
|
||||
}
|
||||
if headers.Supported == nil || headers.Supported["Authorization"] == "" {
|
||||
t.Error("Supported header should be present")
|
||||
}
|
||||
|
||||
// All should start with "Bearer "
|
||||
for name, header := range map[string]string{
|
||||
"Verify": headers.Verify["Authorization"],
|
||||
"Settle": headers.Settle["Authorization"],
|
||||
"Supported": headers.Supported["Authorization"],
|
||||
} {
|
||||
if len(header) < 7 || header[:7] != "Bearer " {
|
||||
t.Errorf("%s header should start with 'Bearer ', got %s", name, header)
|
||||
}
|
||||
}
|
||||
}
|
||||
76
internal/server/backends.go
Normal file
76
internal/server/backends.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/bluesky-social/indigo/api/bsky"
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
"github.com/nacorid/naco-api/internal/activitypub"
|
||||
"github.com/nacorid/naco-api/internal/actor"
|
||||
"github.com/nacorid/naco-api/internal/clustering"
|
||||
"github.com/nacorid/naco-api/internal/nostr"
|
||||
"github.com/nacorid/naco-api/internal/store"
|
||||
"github.com/nacorid/naco-api/internal/twitter"
|
||||
)
|
||||
|
||||
// The following interfaces let the server depend on platform behaviour rather than on the
|
||||
// concrete client types. They exist so tests can inject fake backends; the production code in
|
||||
// main.go keeps passing the real clients (which satisfy these interfaces).
|
||||
|
||||
type blueskyBackend interface {
|
||||
GetPost(ctx context.Context, actor, collection, recordKey string) (*bsky.FeedDefs_PostView, error)
|
||||
GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*bsky.FeedDefs_FeedViewPost, error)
|
||||
GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]*bsky.FeedDefs_FeedViewPost, error)
|
||||
GetFeed(ctx context.Context, feedURI string, limit int) (*bsky.FeedGetFeed_Output, error)
|
||||
ResolveUser(ctx context.Context, user string) (syntax.DID, error)
|
||||
GetActor(ctx context.Context, author string) (*actor.Profile, error)
|
||||
SearchActors(ctx context.Context, query string, limit int) ([]*actor.Profile, error)
|
||||
}
|
||||
|
||||
type apBackend interface {
|
||||
GetPost(ctx context.Context, postID string) (*activitypub.Post, error)
|
||||
GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*activitypub.Post, error)
|
||||
GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]*activitypub.Post, error)
|
||||
GetActor(ctx context.Context, author string) (*actor.Profile, error)
|
||||
SearchActors(ctx context.Context, query string, limit int) ([]*actor.Profile, error)
|
||||
}
|
||||
|
||||
type nostrBackend interface {
|
||||
GetPost(ctx context.Context, id string) (*nostr.Post, error)
|
||||
GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*nostr.Post, error)
|
||||
GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]*nostr.Post, error)
|
||||
GetActor(ctx context.Context, author string) (*actor.Profile, error)
|
||||
SearchActors(ctx context.Context, query string, limit int) ([]*actor.Profile, error)
|
||||
}
|
||||
|
||||
type twitterBackend interface {
|
||||
GetPost(ctx context.Context, id string) (*twitter.Post, error)
|
||||
GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*twitter.Post, error)
|
||||
GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]*twitter.Post, error)
|
||||
}
|
||||
|
||||
type ollamaBackend interface {
|
||||
EmbedTexts(ctx context.Context, posts []clustering.Item) ([][]float64, error)
|
||||
SummarizeStyle(ctx context.Context, posts []clustering.Item) (string, error)
|
||||
}
|
||||
|
||||
// Store abstracts the persistence layer the server depends on. *store.Queries satisfies it;
|
||||
// tests inject an in-memory fake so db-backed paths (style profiles, analytics, similar users)
|
||||
// can be exercised without a live Postgres.
|
||||
type Store interface {
|
||||
GetAnalyticsCache(ctx context.Context, arg store.GetAnalyticsCacheParams) (json.RawMessage, error)
|
||||
UpsertAnalyticsCache(ctx context.Context, arg store.UpsertAnalyticsCacheParams) error
|
||||
GetAnalyticsPostsInRange(ctx context.Context, arg store.GetAnalyticsPostsInRangeParams) ([]store.GetAnalyticsPostsInRangeRow, error)
|
||||
UpsertAnalyticsPosts(ctx context.Context, arg store.UpsertAnalyticsPostsParams) error
|
||||
GetAnalyticsCoverageRanges(ctx context.Context, arg store.GetAnalyticsCoverageRangesParams) ([]store.GetAnalyticsCoverageRangesRow, error)
|
||||
InsertAnalyticsCoverageRange(ctx context.Context, arg store.InsertAnalyticsCoverageRangeParams) error
|
||||
GetStyleProfile(ctx context.Context, arg store.GetStyleProfileParams) (store.UserStyleProfile, error)
|
||||
UpsertStyleProfile(ctx context.Context, arg store.UpsertStyleProfileParams) error
|
||||
DeleteStylePosts(ctx context.Context, arg store.DeleteStylePostsParams) error
|
||||
GetStylePosts(ctx context.Context, arg store.GetStylePostsParams) ([]store.GetStylePostsRow, error)
|
||||
UpsertStylePosts(ctx context.Context, arg store.UpsertStylePostsParams) error
|
||||
FindSimilarUsers(ctx context.Context, arg store.FindSimilarUsersParams) ([]store.FindSimilarUsersRow, error)
|
||||
PruneStylePosts(ctx context.Context) error
|
||||
}
|
||||
88
internal/server/cache.go
Normal file
88
internal/server/cache.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const postCacheTTL = 5 * time.Minute
|
||||
const postCacheMaxItems = 10000
|
||||
|
||||
type ttlCacheEntry struct {
|
||||
value any
|
||||
exp time.Time
|
||||
}
|
||||
|
||||
type ttlCache struct {
|
||||
mu sync.Mutex
|
||||
items map[string]ttlCacheEntry
|
||||
ttl time.Duration
|
||||
maxItems int
|
||||
}
|
||||
|
||||
func newTTLCache(ttl time.Duration) *ttlCache {
|
||||
return &ttlCache{
|
||||
items: make(map[string]ttlCacheEntry),
|
||||
ttl: ttl,
|
||||
maxItems: postCacheMaxItems,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ttlCache) get(key string) (any, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
e, ok := c.items[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if time.Now().After(e.exp) {
|
||||
delete(c.items, key)
|
||||
return nil, false
|
||||
}
|
||||
return e.value, true
|
||||
}
|
||||
|
||||
func (c *ttlCache) set(key string, value any) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if len(c.items) >= c.maxItems {
|
||||
// Evict the oldest expired entry, or if none expired, the first one.
|
||||
var oldestKey string
|
||||
var oldestTime time.Time
|
||||
first := true
|
||||
for k, e := range c.items {
|
||||
if first || e.exp.Before(oldestTime) {
|
||||
oldestKey = k
|
||||
oldestTime = e.exp
|
||||
first = false
|
||||
}
|
||||
}
|
||||
if oldestKey != "" {
|
||||
delete(c.items, oldestKey)
|
||||
}
|
||||
}
|
||||
c.items[key] = ttlCacheEntry{value: value, exp: time.Now().Add(c.ttl)}
|
||||
}
|
||||
|
||||
// cachedFetch returns the cached value for key or calls fetch to obtain it,
|
||||
// storing the result in the cache before returning. The zero value is returned
|
||||
// on fetch error and not cached.
|
||||
func cachedFetch[T any](c *ttlCache, key string, fetch func() (T, error)) (T, error) {
|
||||
if v, ok := c.get(key); ok {
|
||||
if typed, ok := v.(T); ok {
|
||||
return typed, nil
|
||||
}
|
||||
}
|
||||
val, err := fetch()
|
||||
if err != nil {
|
||||
var zero T
|
||||
return zero, err
|
||||
}
|
||||
c.set(key, val)
|
||||
return val, nil
|
||||
}
|
||||
|
||||
type latestCacheValue struct {
|
||||
posts []*GetPostResponsePost
|
||||
aid string
|
||||
}
|
||||
16
internal/server/cache_version.go
Normal file
16
internal/server/cache_version.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package server
|
||||
|
||||
const (
|
||||
CurrentModelVersion = 1
|
||||
CurrentSchemaVersion = 1
|
||||
CurrentAlgorithmVersion = 1
|
||||
)
|
||||
|
||||
// IsProfileStale returns true if the profile's versions are older than the current versions.
|
||||
// This is used to mark profiles stale for lazy recompute on the next request.
|
||||
// Currently all three version fields are 1 since this is the first version of the combination.
|
||||
func IsProfileStale(model, schema, algorithm int) bool {
|
||||
return model != CurrentModelVersion ||
|
||||
schema != CurrentSchemaVersion ||
|
||||
algorithm != CurrentAlgorithmVersion
|
||||
}
|
||||
100
internal/server/coverage_test.go
Normal file
100
internal/server/coverage_test.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func rangeOf(t *testing.T, s, e string) timeRange {
|
||||
t.Helper()
|
||||
layout := "2006-01-02 15:04"
|
||||
start, err := time.Parse(layout, s)
|
||||
if err != nil {
|
||||
t.Fatalf("bad start %q: %v", s, err)
|
||||
}
|
||||
end, err := time.Parse(layout, e)
|
||||
if err != nil {
|
||||
t.Fatalf("bad end %q: %v", e, err)
|
||||
}
|
||||
return timeRange{start: start, end: end}
|
||||
}
|
||||
|
||||
func assertRanges(t *testing.T, got []timeRange, want ...timeRange) {
|
||||
t.Helper()
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %d ranges, want %d: %+v", len(got), len(want), got)
|
||||
}
|
||||
for i := range want {
|
||||
if !got[i].start.Equal(want[i].start) || !got[i].end.Equal(want[i].end) {
|
||||
t.Fatalf("range %d: got [%v, %v], want [%v, %v]",
|
||||
i, got[i].start, got[i].end, want[i].start, want[i].end)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUncoveredRangesNoCoverage(t *testing.T) {
|
||||
r := rangeOf(t, "2026-01-01 00:00", "2026-01-31 00:00")
|
||||
got := uncoveredRanges(r.start, r.end, nil)
|
||||
assertRanges(t, got, r)
|
||||
}
|
||||
|
||||
func TestUncoveredRangesFullyCovered(t *testing.T) {
|
||||
req := rangeOf(t, "2026-01-10 00:00", "2026-01-20 00:00")
|
||||
cov := rangeOf(t, "2026-01-01 00:00", "2026-01-31 00:00")
|
||||
got := uncoveredRanges(req.start, req.end, []timeRange{cov})
|
||||
assertRanges(t, got)
|
||||
}
|
||||
|
||||
func TestUncoveredRangesLeftOnly(t *testing.T) {
|
||||
req := rangeOf(t, "2026-01-01 00:00", "2026-01-31 00:00")
|
||||
cov := rangeOf(t, "2026-01-15 00:00", "2026-01-31 00:00")
|
||||
got := uncoveredRanges(req.start, req.end, []timeRange{cov})
|
||||
want := rangeOf(t, "2026-01-01 00:00", "2026-01-15 00:00")
|
||||
assertRanges(t, got, want)
|
||||
}
|
||||
|
||||
func TestUncoveredRangesRightOnly(t *testing.T) {
|
||||
req := rangeOf(t, "2026-01-01 00:00", "2026-01-31 00:00")
|
||||
cov := rangeOf(t, "2026-01-01 00:00", "2026-01-15 00:00")
|
||||
got := uncoveredRanges(req.start, req.end, []timeRange{cov})
|
||||
want := rangeOf(t, "2026-01-15 00:00", "2026-01-31 00:00")
|
||||
assertRanges(t, got, want)
|
||||
}
|
||||
|
||||
func TestUncoveredRangesBothSides(t *testing.T) {
|
||||
req := rangeOf(t, "2026-01-01 00:00", "2026-01-31 00:00")
|
||||
cov := rangeOf(t, "2026-01-10 00:00", "2026-01-20 00:00")
|
||||
wantLeft := rangeOf(t, "2026-01-01 00:00", "2026-01-10 00:00")
|
||||
wantRight := rangeOf(t, "2026-01-20 00:00", "2026-01-31 00:00")
|
||||
got := uncoveredRanges(req.start, req.end, []timeRange{cov})
|
||||
assertRanges(t, got, wantLeft, wantRight)
|
||||
}
|
||||
|
||||
// Regression: disjoint covered intervals must not be merged into a single
|
||||
// contiguous window. A request landing in the gap between two covered ranges
|
||||
// must be reported as uncovered so it is fetched rather than served from an
|
||||
// incomplete cache.
|
||||
func TestUncoveredRangesDisjointGap(t *testing.T) {
|
||||
req := rangeOf(t, "2026-01-15 00:00", "2026-01-20 00:00")
|
||||
first := rangeOf(t, "2026-01-01 00:00", "2026-01-15 00:00")
|
||||
second := rangeOf(t, "2026-01-20 00:00", "2026-01-31 00:00")
|
||||
got := uncoveredRanges(req.start, req.end, []timeRange{first, second})
|
||||
assertRanges(t, got, req)
|
||||
}
|
||||
|
||||
func TestUncoveredRangesGapSurroundedByCoverage(t *testing.T) {
|
||||
req := rangeOf(t, "2026-01-10 00:00", "2026-01-25 00:00")
|
||||
first := rangeOf(t, "2026-01-01 00:00", "2026-01-12 00:00")
|
||||
second := rangeOf(t, "2026-01-20 00:00", "2026-01-31 00:00")
|
||||
want := rangeOf(t, "2026-01-12 00:00", "2026-01-20 00:00")
|
||||
got := uncoveredRanges(req.start, req.end, []timeRange{first, second})
|
||||
assertRanges(t, got, want)
|
||||
}
|
||||
|
||||
func TestUncoveredRangesOverlappingCoverage(t *testing.T) {
|
||||
req := rangeOf(t, "2026-01-10 00:00", "2026-01-20 00:00")
|
||||
first := rangeOf(t, "2026-01-01 00:00", "2026-01-15 00:00")
|
||||
second := rangeOf(t, "2026-01-12 00:00", "2026-01-31 00:00")
|
||||
got := uncoveredRanges(req.start, req.end, []timeRange{first, second})
|
||||
assertRanges(t, got)
|
||||
}
|
||||
855
internal/server/mcp_test.go
Normal file
855
internal/server/mcp_test.go
Normal file
|
|
@ -0,0 +1,855 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bluesky-social/indigo/api/bsky"
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
"github.com/bluesky-social/indigo/lex/util"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"github.com/nacorid/naco-api/internal/activitypub"
|
||||
"github.com/nacorid/naco-api/internal/actor"
|
||||
"github.com/nacorid/naco-api/internal/clustering"
|
||||
"github.com/nacorid/naco-api/internal/nostr"
|
||||
"github.com/nacorid/naco-api/internal/store"
|
||||
"github.com/nacorid/naco-api/internal/twitter"
|
||||
)
|
||||
|
||||
// ---- fakes ----
|
||||
|
||||
type fakeBluesky struct {
|
||||
postView *bsky.FeedDefs_PostView
|
||||
feedViews []*bsky.FeedDefs_FeedViewPost
|
||||
profile *actor.Profile
|
||||
actors []*actor.Profile
|
||||
feedOut *bsky.FeedGetFeed_Output
|
||||
err error
|
||||
calls int
|
||||
lastLimit int
|
||||
}
|
||||
|
||||
func (f *fakeBluesky) GetPost(ctx context.Context, actor, collection, recordKey string) (*bsky.FeedDefs_PostView, error) {
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.postView, nil
|
||||
}
|
||||
func (f *fakeBluesky) GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*bsky.FeedDefs_FeedViewPost, error) {
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.feedViews, nil
|
||||
}
|
||||
func (f *fakeBluesky) GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]*bsky.FeedDefs_FeedViewPost, error) {
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.feedViews, nil
|
||||
}
|
||||
func (f *fakeBluesky) GetFeed(ctx context.Context, feedURI string, limit int) (*bsky.FeedGetFeed_Output, error) {
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.feedOut, nil
|
||||
}
|
||||
func (f *fakeBluesky) ResolveUser(ctx context.Context, user string) (syntax.DID, error) {
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return "", f.err
|
||||
}
|
||||
return syntax.ParseDID("did:plc:abc")
|
||||
}
|
||||
func (f *fakeBluesky) GetActor(ctx context.Context, author string) (*actor.Profile, error) {
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.profile, nil
|
||||
}
|
||||
func (f *fakeBluesky) SearchActors(ctx context.Context, query string, limit int) ([]*actor.Profile, error) {
|
||||
f.calls++
|
||||
f.lastLimit = limit
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.actors, nil
|
||||
}
|
||||
|
||||
type fakeAP struct {
|
||||
post *activitypub.Post
|
||||
posts []*activitypub.Post
|
||||
profile *actor.Profile
|
||||
actors []*actor.Profile
|
||||
err error
|
||||
calls int
|
||||
lastLimit int
|
||||
}
|
||||
|
||||
func (f *fakeAP) GetPost(ctx context.Context, postID string) (*activitypub.Post, error) {
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.post, nil
|
||||
}
|
||||
func (f *fakeAP) GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*activitypub.Post, error) {
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.posts, nil
|
||||
}
|
||||
func (f *fakeAP) GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]*activitypub.Post, error) {
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
out := make([]*activitypub.Post, 0, len(f.posts))
|
||||
out = append(out, f.posts...)
|
||||
return out, nil
|
||||
}
|
||||
func (f *fakeAP) GetActor(ctx context.Context, author string) (*actor.Profile, error) {
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.profile, nil
|
||||
}
|
||||
func (f *fakeAP) SearchActors(ctx context.Context, query string, limit int) ([]*actor.Profile, error) {
|
||||
f.calls++
|
||||
f.lastLimit = limit
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.actors, nil
|
||||
}
|
||||
|
||||
type fakeNostr struct {
|
||||
post *nostr.Post
|
||||
posts []*nostr.Post
|
||||
profile *actor.Profile
|
||||
actors []*actor.Profile
|
||||
err error
|
||||
calls int
|
||||
lastLimit int
|
||||
}
|
||||
|
||||
func (f *fakeNostr) GetPost(ctx context.Context, id string) (*nostr.Post, error) {
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.post, nil
|
||||
}
|
||||
func (f *fakeNostr) GetLatestNPosts(ctx context.Context, author string, limit int) ([]*nostr.Post, error) {
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.posts, nil
|
||||
}
|
||||
func (f *fakeNostr) GetPostsInTimeframe(ctx context.Context, author string, start, end time.Time, limit int) ([]*nostr.Post, error) {
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.posts, nil
|
||||
}
|
||||
func (f *fakeNostr) GetActor(ctx context.Context, author string) (*actor.Profile, error) {
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.profile, nil
|
||||
}
|
||||
func (f *fakeNostr) SearchActors(ctx context.Context, query string, limit int) ([]*actor.Profile, error) {
|
||||
f.calls++
|
||||
f.lastLimit = limit
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.actors, nil
|
||||
}
|
||||
|
||||
type fakeTwitter struct {
|
||||
post *twitter.Post
|
||||
posts []*twitter.Post
|
||||
profile *actor.Profile
|
||||
actors []*actor.Profile
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeTwitter) GetPost(ctx context.Context, id string) (*twitter.Post, error) {
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.post, nil
|
||||
}
|
||||
func (f *fakeTwitter) GetLatestNPosts(ctx context.Context, author string, limit int) ([]*twitter.Post, error) {
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.posts, nil
|
||||
}
|
||||
func (f *fakeTwitter) GetPostsInTimeframe(ctx context.Context, author string, start, end time.Time, limit int) ([]*twitter.Post, error) {
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.posts, nil
|
||||
}
|
||||
func (f *fakeTwitter) GetActor(ctx context.Context, author string) (*actor.Profile, error) {
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.profile, nil
|
||||
}
|
||||
func (f *fakeTwitter) SearchActors(ctx context.Context, query string, limit int) ([]*actor.Profile, error) {
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.actors, nil
|
||||
}
|
||||
|
||||
// fakeOllama implements ollamaBackend. Its behavior is driven by a function so
|
||||
// tests can simulate embedding, sentiment, and topic-label responses.
|
||||
type fakeOllama struct {
|
||||
embeddings [][]float64
|
||||
embedErr error
|
||||
summaries []string
|
||||
summaryIdx int
|
||||
summErr error
|
||||
embedCalls int
|
||||
summCalls int
|
||||
}
|
||||
|
||||
func (f *fakeOllama) EmbedTexts(ctx context.Context, posts []clustering.Item) ([][]float64, error) {
|
||||
f.embedCalls++
|
||||
if f.embedErr != nil {
|
||||
return nil, f.embedErr
|
||||
}
|
||||
if f.embeddings != nil {
|
||||
return f.embeddings, nil
|
||||
}
|
||||
out := make([][]float64, len(posts))
|
||||
for i := range posts {
|
||||
out[i] = []float64{float64(i + 1), 0.5}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeOllama) SummarizeStyle(ctx context.Context, posts []clustering.Item) (string, error) {
|
||||
f.summCalls++
|
||||
if f.summErr != nil {
|
||||
return "", f.summErr
|
||||
}
|
||||
if f.summaries != nil {
|
||||
if f.summaryIdx >= len(f.summaries) {
|
||||
f.summaryIdx = len(f.summaries) - 1
|
||||
}
|
||||
out := f.summaries[f.summaryIdx]
|
||||
f.summaryIdx++
|
||||
return out, nil
|
||||
}
|
||||
return "summary", nil
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
func newTestServer(bsky blueskyBackend, ap apBackend, nstr nostrBackend, tw twitterBackend) *Server {
|
||||
return newServerWith(bsky, ap, nstr, tw, newFakeStore(), &fakeOllama{})
|
||||
}
|
||||
|
||||
// newServerWith builds a Server with explicit store and ollama backends so tests
|
||||
// can drive db-backed tool paths (style profiles, analytics, similar users).
|
||||
func newServerWith(bsky blueskyBackend, ap apBackend, nstr nostrBackend, tw twitterBackend, st Store, oll ollamaBackend) *Server {
|
||||
return &Server{
|
||||
bskyClient: bsky,
|
||||
apClient: ap,
|
||||
nostrClient: nstr,
|
||||
twitterClient: tw,
|
||||
db: st,
|
||||
ollama: oll,
|
||||
enrichEnabled: false,
|
||||
enrichSem: make(chan struct{}, 4),
|
||||
postCache: newTTLCache(postCacheTTL),
|
||||
|
||||
analyticsTTLEngagement: time.Hour,
|
||||
analyticsTTLPatterns: time.Hour,
|
||||
analyticsTTLTopics: time.Hour,
|
||||
analyticsTTLSentiment: time.Hour,
|
||||
analyticsTTLDrivers: time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
func callTool(s *Server, name string, args any) (*mcp.CallToolResult, error) {
|
||||
raw, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req := &mcp.CallToolRequest{Params: &mcp.CallToolParamsRaw{Name: name, Arguments: raw}}
|
||||
ctx := context.Background()
|
||||
switch name {
|
||||
case "getActor":
|
||||
return s.getActor(ctx, req)
|
||||
case "searchActors":
|
||||
return s.searchActors(ctx, req)
|
||||
case "getPost":
|
||||
return s.getPost(ctx, req)
|
||||
case "getLatestPosts":
|
||||
return s.getLatestPosts(ctx, req)
|
||||
case "getPostsInTime":
|
||||
return s.getPostsInTime(ctx, req)
|
||||
case "getFeed":
|
||||
return s.getFeed(ctx, req)
|
||||
case "getStyleProfile":
|
||||
return s.getStyleProfile(ctx, req)
|
||||
case "findSimilarUsers":
|
||||
return s.getSimilarProfiles(ctx, req)
|
||||
case "getEngagementMetrics":
|
||||
return s.getEngagementMetrics(ctx, req)
|
||||
case "getPostingPatterns":
|
||||
return s.getPostingPatterns(ctx, req)
|
||||
case "getTopicModeling":
|
||||
return s.getTopicModeling(ctx, req)
|
||||
case "getSentimentTrends":
|
||||
return s.getSentimentTrends(ctx, req)
|
||||
case "getEngagementDrivers":
|
||||
return s.getEngagementDrivers(ctx, req)
|
||||
}
|
||||
return nil, fmt.Errorf("unknown tool %q", name)
|
||||
}
|
||||
|
||||
func textOf(t *testing.T, res *mcp.CallToolResult) string {
|
||||
t.Helper()
|
||||
if res == nil || len(res.Content) == 0 {
|
||||
t.Fatal("empty result")
|
||||
}
|
||||
tc, ok := res.Content[0].(*mcp.TextContent)
|
||||
if !ok {
|
||||
t.Fatalf("expected TextContent, got %T", res.Content[0])
|
||||
}
|
||||
return tc.Text
|
||||
}
|
||||
|
||||
func bskyPostView(text string) *bsky.FeedDefs_PostView {
|
||||
return &bsky.FeedDefs_PostView{
|
||||
Author: &bsky.ActorDefs_ProfileViewBasic{Did: "did:plc:abc"},
|
||||
Record: &util.LexiconTypeDecoder{Val: &bsky.FeedPost{
|
||||
Text: text,
|
||||
CreatedAt: "2024-01-01T00:00:00Z",
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// ---- tests ----
|
||||
|
||||
func TestGetActorDispatch(t *testing.T) {
|
||||
cases := []struct {
|
||||
author string
|
||||
fake func() (blueskyBackend, apBackend, nostrBackend, twitterBackend)
|
||||
profile *actor.Profile
|
||||
}{
|
||||
{"did:plc:abc", func() (blueskyBackend, apBackend, nostrBackend, twitterBackend) {
|
||||
return &fakeBluesky{profile: &actor.Profile{Platform: "Bluesky", Handle: "a"}}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}
|
||||
}, &actor.Profile{Platform: "Bluesky"}},
|
||||
{"@alice@mastodon.social", func() (blueskyBackend, apBackend, nostrBackend, twitterBackend) {
|
||||
return &fakeBluesky{}, &fakeAP{profile: &actor.Profile{Platform: "ActivityPub"}}, &fakeNostr{}, &fakeTwitter{}
|
||||
}, &actor.Profile{Platform: "ActivityPub"}},
|
||||
{"npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", func() (blueskyBackend, apBackend, nostrBackend, twitterBackend) {
|
||||
return &fakeBluesky{}, &fakeAP{}, &fakeNostr{profile: &actor.Profile{Platform: "Nostr"}}, &fakeTwitter{}
|
||||
}, &actor.Profile{Platform: "Nostr"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
s := newTestServer(c.fake())
|
||||
res, err := callTool(s, "getActor", GetActorRequest{Author: c.author})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: getActor error: %v", c.author, err)
|
||||
}
|
||||
var out GetActorResponse
|
||||
if err := json.Unmarshal([]byte(textOf(t, res)), &out); err != nil {
|
||||
t.Fatalf("%s: unmarshal: %v", c.author, err)
|
||||
}
|
||||
if out.Actor.Platform != c.profile.Platform {
|
||||
t.Fatalf("%s: expected platform %s, got %s", c.author, c.profile.Platform, out.Actor.Platform)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchActorsAllSucceed(t *testing.T) {
|
||||
bsky := &fakeBluesky{actors: []*actor.Profile{{Platform: "Bluesky", Handle: "a"}}}
|
||||
ap := &fakeAP{actors: []*actor.Profile{{Platform: "ActivityPub", Handle: "b"}}}
|
||||
nstr := &fakeNostr{actors: []*actor.Profile{{Platform: "Nostr", Handle: "c"}}}
|
||||
tw := &fakeTwitter{actors: []*actor.Profile{{Platform: "Twitter", Handle: "d"}}}
|
||||
s := newTestServer(bsky, ap, nstr, tw)
|
||||
res, err := callTool(s, "searchActors", SearchActorsRequest{Query: "alice", Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("searchActors error: %v", err)
|
||||
}
|
||||
var out SearchActorsResponse
|
||||
if err := json.Unmarshal([]byte(textOf(t, res)), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(out.Results) != 3 {
|
||||
t.Fatalf("expected 3 merged results, got %d", len(out.Results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchActorsAllFail(t *testing.T) {
|
||||
bsky := &fakeBluesky{err: fmt.Errorf("boom")}
|
||||
ap := &fakeAP{err: fmt.Errorf("boom")}
|
||||
nstr := &fakeNostr{err: fmt.Errorf("boom")}
|
||||
tw := &fakeTwitter{err: fmt.Errorf("boom")}
|
||||
s := newTestServer(bsky, ap, nstr, tw)
|
||||
if _, err := callTool(s, "searchActors", SearchActorsRequest{Query: "alice"}); err == nil {
|
||||
t.Fatal("expected error when all platforms fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchActorsPartialSuccess(t *testing.T) {
|
||||
bsky := &fakeBluesky{err: fmt.Errorf("boom")}
|
||||
ap := &fakeAP{actors: []*actor.Profile{{Platform: "ActivityPub", Handle: "b"}}}
|
||||
nstr := &fakeNostr{actors: []*actor.Profile{{Platform: "Nostr", Handle: "c"}}}
|
||||
tw := &fakeTwitter{err: fmt.Errorf("boom")}
|
||||
s := newTestServer(bsky, ap, nstr, tw)
|
||||
res, err := callTool(s, "searchActors", SearchActorsRequest{Query: "alice"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected partial success, got error: %v", err)
|
||||
}
|
||||
var out SearchActorsResponse
|
||||
if err := json.Unmarshal([]byte(textOf(t, res)), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(out.Results) != 2 {
|
||||
t.Fatalf("expected 2 partial results, got %d", len(out.Results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPostTwitter(t *testing.T) {
|
||||
tw := &fakeTwitter{post: &twitter.Post{ID: "123", Content: "hello twitter", Author: "@alice"}}
|
||||
s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, tw)
|
||||
_, err := callTool(s, "getPost", GetPostRequest{Author: "@alice", RecordKey: "123"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected twitter_unavailable error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "twitter_unavailable") {
|
||||
t.Fatalf("expected twitter_unavailable error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestPostsAP(t *testing.T) {
|
||||
ap := &fakeAP{posts: []*activitypub.Post{
|
||||
{Author: "alice@mastodon.social", Content: "one", CreatedAt: time.Now()},
|
||||
{Author: "alice@mastodon.social", Content: "two", CreatedAt: time.Now()},
|
||||
}}
|
||||
s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{})
|
||||
res, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "@alice@mastodon.social", Limit: 5})
|
||||
if err != nil {
|
||||
t.Fatalf("getLatestPosts error: %v", err)
|
||||
}
|
||||
var out GetLatestPostsResponse
|
||||
if err := json.Unmarshal([]byte(textOf(t, res)), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(out.Posts) != 2 {
|
||||
t.Fatalf("expected 2 posts, got %d", len(out.Posts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPostsInTimeNostr(t *testing.T) {
|
||||
nstr := &fakeNostr{posts: []*nostr.Post{{ID: "e1", Author: "npubX", Content: "nostr post", CreatedAt: time.Now()}}}
|
||||
s := newTestServer(&fakeBluesky{}, &fakeAP{}, nstr, &fakeTwitter{})
|
||||
res, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{
|
||||
Author: "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq",
|
||||
StartTime: "2024-01-01T00:00:00Z",
|
||||
EndTime: "2024-12-31T00:00:00Z",
|
||||
Limit: 5,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("getPostsInTime error: %v", err)
|
||||
}
|
||||
var out GetPostsInTimeResponse
|
||||
if err := json.Unmarshal([]byte(textOf(t, res)), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(out.Posts) != 1 || !strings.Contains(out.Posts[0].Content, "nostr post") {
|
||||
t.Fatalf("unexpected posts: %+v", out.Posts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFeedBluesky(t *testing.T) {
|
||||
bsky := &fakeBluesky{feedOut: &bsky.FeedGetFeed_Output{Feed: []*bsky.FeedDefs_FeedViewPost{{Post: bskyPostView("feed post")}}}}
|
||||
s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
|
||||
res, err := callTool(s, "getFeed", GetFeedRequest{URI: "at://did:plc:abc/app.bsky.feed.generator/abc"})
|
||||
if err != nil {
|
||||
t.Fatalf("getFeed error: %v", err)
|
||||
}
|
||||
var out GetFeedResponse
|
||||
if err := json.Unmarshal([]byte(textOf(t, res)), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(out.Posts) != 1 || out.Posts[0].Content != "feed post" {
|
||||
t.Fatalf("unexpected feed posts: %+v", out.Posts)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- getPost success paths ----
|
||||
|
||||
func TestGetPostBlueskyWithCollection(t *testing.T) {
|
||||
bsky := &fakeBluesky{postView: bskyPostView("hello bluesky with collection")}
|
||||
s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
|
||||
res, err := callTool(s, "getPost", GetPostRequest{Author: "did:plc:abc", RecordKey: "k", Collection: "app.bsky.feed.post"})
|
||||
if err != nil {
|
||||
t.Fatalf("getPost bluesky with collection error: %v", err)
|
||||
}
|
||||
if !strings.Contains(textOf(t, res), "hello bluesky with collection") {
|
||||
t.Fatalf("unexpected bluesky post text: %s", textOf(t, res))
|
||||
}
|
||||
if bsky.calls != 1 {
|
||||
t.Fatalf("expected 1 backend call, got %d", bsky.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPostActivityPub(t *testing.T) {
|
||||
ap := &fakeAP{post: &activitypub.Post{Author: "alice@mastodon.social", Content: "hello activitypub", CreatedAt: time.Now()}}
|
||||
s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{})
|
||||
res, err := callTool(s, "getPost", GetPostRequest{Author: "@alice@mastodon.social", RecordKey: "123"})
|
||||
if err != nil {
|
||||
t.Fatalf("getPost activitypub error: %v", err)
|
||||
}
|
||||
if !strings.Contains(textOf(t, res), "hello activitypub") {
|
||||
t.Fatalf("unexpected activitypub post text: %s", textOf(t, res))
|
||||
}
|
||||
if ap.calls != 1 {
|
||||
t.Fatalf("expected 1 backend call, got %d", ap.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPostNostr(t *testing.T) {
|
||||
nstr := &fakeNostr{post: &nostr.Post{ID: "n1", Author: "npub1x", Content: "nostr hello"}}
|
||||
s := newTestServer(&fakeBluesky{}, &fakeAP{}, nstr, &fakeTwitter{})
|
||||
res, err := callTool(s, "getPost", GetPostRequest{Author: "npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", RecordKey: "n1"})
|
||||
if err != nil {
|
||||
t.Fatalf("getPost nostr error: %v", err)
|
||||
}
|
||||
if !strings.Contains(textOf(t, res), "nostr hello") {
|
||||
t.Fatalf("unexpected nostr post text: %s", textOf(t, res))
|
||||
}
|
||||
if nstr.calls != 1 {
|
||||
t.Fatalf("expected 1 backend call, got %d", nstr.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPostBackendError(t *testing.T) {
|
||||
bsky := &fakeBluesky{err: fmt.Errorf("backend failure")}
|
||||
s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
|
||||
_, err := callTool(s, "getPost", GetPostRequest{Author: "did:plc:abc", RecordKey: "k"})
|
||||
if err == nil || !strings.Contains(err.Error(), "backend failure") {
|
||||
t.Fatalf("expected backend error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- getLatestPosts success paths ----
|
||||
|
||||
func TestGetLatestPostsBluesky(t *testing.T) {
|
||||
bsky := &fakeBluesky{feedViews: []*bsky.FeedDefs_FeedViewPost{{Post: bskyPostView("bluesky post 1")}, {Post: bskyPostView("bluesky post 2")}}}
|
||||
s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
|
||||
res, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "did:plc:abc", Limit: 5})
|
||||
if err != nil {
|
||||
t.Fatalf("getLatestPosts bluesky error: %v", err)
|
||||
}
|
||||
var out GetLatestPostsResponse
|
||||
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(out.Posts) != 2 {
|
||||
t.Fatalf("expected 2 posts, got %d", len(out.Posts))
|
||||
}
|
||||
if !strings.Contains(out.Posts[0].Content, "bluesky post 1") {
|
||||
t.Fatalf("unexpected post content: %s", out.Posts[0].Content)
|
||||
}
|
||||
if bsky.calls != 1 {
|
||||
t.Fatalf("expected 1 backend call, got %d", bsky.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestPostsNostr(t *testing.T) {
|
||||
nstr := &fakeNostr{posts: []*nostr.Post{{ID: "n1", Author: "npub1x", Content: "nostr latest 1", CreatedAt: time.Now()}, {ID: "n2", Author: "npub1x", Content: "nostr latest 2", CreatedAt: time.Now()}}}
|
||||
s := newTestServer(&fakeBluesky{}, &fakeAP{}, nstr, &fakeTwitter{})
|
||||
res, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", Limit: 5})
|
||||
if err != nil {
|
||||
t.Fatalf("getLatestPosts nostr error: %v", err)
|
||||
}
|
||||
var out GetLatestPostsResponse
|
||||
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(out.Posts) != 2 {
|
||||
t.Fatalf("expected 2 posts, got %d", len(out.Posts))
|
||||
}
|
||||
if !strings.Contains(out.Posts[0].Content, "nostr latest") {
|
||||
t.Fatalf("unexpected post content: %s", out.Posts[0].Content)
|
||||
}
|
||||
if nstr.calls != 1 {
|
||||
t.Fatalf("expected 1 backend call, got %d", nstr.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- getPostsInTime success paths ----
|
||||
|
||||
func TestGetPostsInTimeBluesky(t *testing.T) {
|
||||
bsky := &fakeBluesky{feedViews: []*bsky.FeedDefs_FeedViewPost{{Post: bskyPostView("bluesky timed 1")}, {Post: bskyPostView("bluesky timed 2")}}}
|
||||
s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
|
||||
res, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "did:plc:abc", StartTime: "2024-01-01T00:00:00Z", EndTime: "2024-12-31T00:00:00Z", Limit: 5})
|
||||
if err != nil {
|
||||
t.Fatalf("getPostsInTime bluesky error: %v", err)
|
||||
}
|
||||
var out GetPostsInTimeResponse
|
||||
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(out.Posts) != 2 {
|
||||
t.Fatalf("expected 2 posts, got %d", len(out.Posts))
|
||||
}
|
||||
if bsky.calls != 1 {
|
||||
t.Fatalf("expected 1 backend call, got %d", bsky.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPostsInTimeActivityPub(t *testing.T) {
|
||||
ap := &fakeAP{posts: []*activitypub.Post{{Author: "a@m.social", Content: "ap timed 1", CreatedAt: time.Date(2024, 6, 15, 0, 0, 0, 0, time.UTC)}, {Author: "a@m.social", Content: "ap timed 2", CreatedAt: time.Date(2024, 7, 15, 0, 0, 0, 0, time.UTC)}}}
|
||||
s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{})
|
||||
res, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "@a@mastodon.social", StartTime: "2024-01-01T00:00:00Z", EndTime: "2024-12-31T00:00:00Z", Limit: 5})
|
||||
if err != nil {
|
||||
t.Fatalf("getPostsInTime activitypub error: %v", err)
|
||||
}
|
||||
var out GetPostsInTimeResponse
|
||||
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(out.Posts) != 2 {
|
||||
t.Fatalf("expected 2 posts, got %d", len(out.Posts))
|
||||
}
|
||||
if ap.calls != 1 {
|
||||
t.Fatalf("expected 1 backend call, got %d", ap.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- getFeed tests ----
|
||||
|
||||
func TestGetFeedBlueskyWithCursor(t *testing.T) {
|
||||
// Test that feed pagination works
|
||||
bsky := &fakeBluesky{feedOut: &bsky.FeedGetFeed_Output{Feed: []*bsky.FeedDefs_FeedViewPost{{Post: bskyPostView("feed post 1")}, {Post: bskyPostView("feed post 2")}}}}
|
||||
s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
|
||||
res, err := callTool(s, "getFeed", GetFeedRequest{URI: "at://did:plc:abc/app.bsky.feed.generator/abc"})
|
||||
if err != nil {
|
||||
t.Fatalf("getFeed error: %v", err)
|
||||
}
|
||||
var out GetFeedResponse
|
||||
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(out.Posts) != 2 {
|
||||
t.Fatalf("expected 2 posts, got %d", len(out.Posts))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- getActor tests ----
|
||||
|
||||
func TestGetActorBlueskyDID(t *testing.T) {
|
||||
bsky := &fakeBluesky{profile: &actor.Profile{Platform: "Bluesky", Handle: "user.bsky.social", DisplayName: "Test User"}}
|
||||
s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
|
||||
res, err := callTool(s, "getActor", GetActorRequest{Author: "did:plc:abc123"})
|
||||
if err != nil {
|
||||
t.Fatalf("getActor bluesky DID error: %v", err)
|
||||
}
|
||||
var out GetActorResponse
|
||||
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if out.Actor.Platform != "Bluesky" {
|
||||
t.Fatalf("expected Bluesky platform, got %s", out.Actor.Platform)
|
||||
}
|
||||
if out.Actor.Handle != "user.bsky.social" {
|
||||
t.Fatalf("expected handle user.bsky.social, got %s", out.Actor.Handle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetActorActivityPub(t *testing.T) {
|
||||
ap := &fakeAP{profile: &actor.Profile{Platform: "ActivityPub", Handle: "alice@mastodon.social", DisplayName: "Alice"}}
|
||||
s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{})
|
||||
res, err := callTool(s, "getActor", GetActorRequest{Author: "@alice@mastodon.social"})
|
||||
if err != nil {
|
||||
t.Fatalf("getActor activitypub error: %v", err)
|
||||
}
|
||||
var out GetActorResponse
|
||||
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if out.Actor.Platform != "ActivityPub" {
|
||||
t.Fatalf("expected ActivityPub platform, got %s", out.Actor.Platform)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetActorNostr(t *testing.T) {
|
||||
nstr := &fakeNostr{profile: &actor.Profile{Platform: "Nostr", Handle: "npub1xxx", DisplayName: "Nostr User"}}
|
||||
s := newTestServer(&fakeBluesky{}, &fakeAP{}, nstr, &fakeTwitter{})
|
||||
res, err := callTool(s, "getActor", GetActorRequest{Author: "npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"})
|
||||
if err != nil {
|
||||
t.Fatalf("getActor nostr error: %v", err)
|
||||
}
|
||||
var out GetActorResponse
|
||||
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if out.Actor.Platform != "Nostr" {
|
||||
t.Fatalf("expected Nostr platform, got %s", out.Actor.Platform)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetActorTwitter(t *testing.T) {
|
||||
// Twitter getActor is blocked by policy (same as other Twitter tools)
|
||||
tw := &fakeTwitter{profile: &actor.Profile{Platform: "Twitter", Handle: "@alice", DisplayName: "Alice Twitter"}}
|
||||
s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, tw)
|
||||
_, err := callTool(s, "getActor", GetActorRequest{Author: "@alice"})
|
||||
if err == nil || !strings.Contains(err.Error(), "twitter_unavailable") {
|
||||
t.Fatalf("expected twitter_unavailable error, got %v", err)
|
||||
}
|
||||
if tw.calls != 0 {
|
||||
t.Fatalf("expected 0 backend calls, got %d", tw.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- searchActors edge cases ----
|
||||
|
||||
func TestSearchActorsLimitClamp(t *testing.T) {
|
||||
bsky := &fakeBluesky{actors: []*actor.Profile{{Platform: "Bluesky", Handle: "a"}}}
|
||||
ap := &fakeAP{actors: []*actor.Profile{{Platform: "ActivityPub", Handle: "b"}}}
|
||||
nstr := &fakeNostr{actors: []*actor.Profile{{Platform: "Nostr", Handle: "c"}}}
|
||||
s := newTestServer(bsky, ap, nstr, &fakeTwitter{})
|
||||
|
||||
// Limit > 50 should be clamped to 50 in the backend call.
|
||||
if _, err := callTool(s, "searchActors", SearchActorsRequest{Query: "test", Limit: 100}); err != nil {
|
||||
t.Fatalf("searchActors error: %v", err)
|
||||
}
|
||||
if bsky.lastLimit != 50 || ap.lastLimit != 50 || nstr.lastLimit != 50 {
|
||||
t.Fatalf("expected limit clamped to 50, got bsky=%d ap=%d nstr=%d", bsky.lastLimit, ap.lastLimit, nstr.lastLimit)
|
||||
}
|
||||
|
||||
// Limit 0 should default to 10.
|
||||
bsky.lastLimit, ap.lastLimit, nstr.lastLimit = 0, 0, 0
|
||||
if _, err := callTool(s, "searchActors", SearchActorsRequest{Query: "test", Limit: 0}); err != nil {
|
||||
t.Fatalf("searchActors error with limit 0: %v", err)
|
||||
}
|
||||
if bsky.lastLimit != 10 || ap.lastLimit != 10 || nstr.lastLimit != 10 {
|
||||
t.Fatalf("expected default limit 10, got bsky=%d ap=%d nstr=%d", bsky.lastLimit, ap.lastLimit, nstr.lastLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchActorsTwitterExcludedFromSearch(t *testing.T) {
|
||||
// Twitter is not supported for search in the current implementation
|
||||
bsky := &fakeBluesky{actors: []*actor.Profile{{Platform: "Bluesky", Handle: "a"}}}
|
||||
ap := &fakeAP{actors: []*actor.Profile{{Platform: "ActivityPub", Handle: "b"}}}
|
||||
nstr := &fakeNostr{actors: []*actor.Profile{{Platform: "Nostr", Handle: "c"}}}
|
||||
tw := &fakeTwitter{actors: []*actor.Profile{{Platform: "Twitter", Handle: "d"}}}
|
||||
s := newTestServer(bsky, ap, nstr, tw)
|
||||
res, err := callTool(s, "searchActors", SearchActorsRequest{Query: "test", Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("searchActors error: %v", err)
|
||||
}
|
||||
var out SearchActorsResponse
|
||||
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
// Twitter should be excluded (only 3 results)
|
||||
if len(out.Results) != 3 {
|
||||
t.Fatalf("expected 3 results (Twitter excluded), got %d", len(out.Results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchActorsNormalizationAndDedup(t *testing.T) {
|
||||
// Test that handles normalize correctly and deduplicate
|
||||
bsky := &fakeBluesky{actors: []*actor.Profile{{Platform: "Bluesky", Handle: "user.bsky.social", DisplayName: "User"}}}
|
||||
ap := &fakeAP{actors: []*actor.Profile{{Platform: "ActivityPub", Handle: "user@mastodon.social", DisplayName: "User"}}}
|
||||
s := newTestServer(bsky, ap, &fakeNostr{}, &fakeTwitter{})
|
||||
res, err := callTool(s, "searchActors", SearchActorsRequest{Query: "user", Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("searchActors error: %v", err)
|
||||
}
|
||||
var out SearchActorsResponse
|
||||
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
// Should be deduped by normalized handle
|
||||
if len(out.Results) != 2 {
|
||||
t.Fatalf("expected 2 deduped results, got %d: %+v", len(out.Results), out.Results)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- cache tests ----
|
||||
|
||||
func TestPostCacheDifferentKeys(t *testing.T) {
|
||||
ap := &fakeAP{posts: []*activitypub.Post{{Author: "a@m.social", Content: "post1", CreatedAt: time.Now()}}}
|
||||
s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{})
|
||||
// Different authors should not share cache
|
||||
_, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "@a@mastodon.social", Limit: 5})
|
||||
if err != nil {
|
||||
t.Fatalf("getLatestPosts error: %v", err)
|
||||
}
|
||||
_, err = callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "@b@mastodon.social", Limit: 5})
|
||||
if err != nil {
|
||||
t.Fatalf("getLatestPosts error for different author: %v", err)
|
||||
}
|
||||
// Each author should have called backend once
|
||||
if ap.calls != 2 {
|
||||
t.Fatalf("expected 2 backend calls for different authors, got %d", ap.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheVersionStaleness(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
// Add a stale profile (older than 30 days)
|
||||
st.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{
|
||||
UserID: "@a@mastodon.social",
|
||||
Platform: "ActivityPub",
|
||||
Embedding: "[0.1,0.2]",
|
||||
TopKeywords: []string{"old"},
|
||||
StyleSummary: "stale summary",
|
||||
PostCount: 5,
|
||||
ModifiedAt: sql.NullTime{Time: time.Now().Add(-31 * 24 * time.Hour), Valid: true},
|
||||
}
|
||||
ap := &fakeAP{posts: []*activitypub.Post{{Author: "@a@mastodon.social", Content: "fresh post", CreatedAt: time.Now()}}}
|
||||
s := newServerWith(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{})
|
||||
res, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "@a@mastodon.social"})
|
||||
if err != nil {
|
||||
t.Fatalf("getStyleProfile error: %v", err)
|
||||
}
|
||||
var out GetStyleProfileResponse
|
||||
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
// Should have rebuilt with fresh data
|
||||
if out.StyleSummary == "stale summary" {
|
||||
t.Fatal("expected rebuild to replace stale summary")
|
||||
}
|
||||
if out.PostCount != 1 {
|
||||
t.Fatalf("expected rebuilt post count 1, got %d", out.PostCount)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- edge cases ----
|
||||
|
|
@ -4,6 +4,9 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
|
@ -11,27 +14,93 @@ import (
|
|||
"github.com/nacorid/naco-api/internal/utils"
|
||||
)
|
||||
|
||||
// meanStat is a cumulative mean over a mutex; used for per-tool latency (ms).
|
||||
type meanStat struct {
|
||||
mu sync.Mutex
|
||||
sum float64
|
||||
n int64
|
||||
}
|
||||
|
||||
func (m *meanStat) Record(v float64) {
|
||||
m.mu.Lock()
|
||||
m.sum += v
|
||||
m.n++
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *meanStat) GetMean() float64 {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.n == 0 {
|
||||
return 0
|
||||
}
|
||||
return m.sum / float64(m.n)
|
||||
}
|
||||
|
||||
type toolStat struct {
|
||||
calls utils.Counter
|
||||
errors utils.Counter
|
||||
latency meanStat
|
||||
}
|
||||
|
||||
type MetricsMiddleware struct {
|
||||
mu sync.Mutex
|
||||
requestsBucket utils.RollingStats
|
||||
totalRequests utils.Counter
|
||||
totalErrors utils.Counter
|
||||
enrichEvents utils.Counter
|
||||
toolStats map[string]*toolStat
|
||||
}
|
||||
|
||||
func NewMetricsHook() *MetricsMiddleware {
|
||||
return &MetricsMiddleware{
|
||||
requestsBucket: *utils.NewRollingStats(60, 1*time.Second),
|
||||
totalRequests: *utils.NewCounter(),
|
||||
totalErrors: *utils.NewCounter(),
|
||||
enrichEvents: *utils.NewCounter(),
|
||||
toolStats: make(map[string]*toolStat),
|
||||
}
|
||||
}
|
||||
|
||||
func (mh *MetricsMiddleware) statsFor(tool string) *toolStat {
|
||||
mh.mu.Lock()
|
||||
defer mh.mu.Unlock()
|
||||
s, ok := mh.toolStats[tool]
|
||||
if !ok {
|
||||
s = &toolStat{}
|
||||
mh.toolStats[tool] = s
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (mh *MetricsMiddleware) RecordTool(tool string, err error, duration time.Duration) {
|
||||
mh.totalRequests.Increment()
|
||||
if err != nil {
|
||||
mh.totalErrors.Increment()
|
||||
}
|
||||
s := mh.statsFor(tool)
|
||||
s.calls.Increment()
|
||||
if err != nil {
|
||||
s.errors.Increment()
|
||||
}
|
||||
s.latency.Record(float64(duration) / float64(time.Millisecond))
|
||||
}
|
||||
|
||||
func (mh *MetricsMiddleware) RecordEnrichment() {
|
||||
mh.enrichEvents.Increment()
|
||||
}
|
||||
|
||||
func (mh *MetricsMiddleware) OnCall(next mcp.MethodHandler) mcp.MethodHandler {
|
||||
return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) {
|
||||
if method == "tools/call" {
|
||||
toolName := "unknown"
|
||||
if callReq, ok := request.(*mcp.CallToolRequest); ok && callReq.Params != nil {
|
||||
toolName = callReq.Params.Name
|
||||
}
|
||||
start := time.Now()
|
||||
mh.requestsBucket.Increment()
|
||||
mh.totalRequests.Increment()
|
||||
result, err := next(ctx, method, request)
|
||||
duration := fmt.Sprintf("%.2fms", float64(time.Since(start))/float64(time.Millisecond))
|
||||
log.DebugWithContext(ctx, "MetricsMiddleware: MCP Handler", "duration", duration)
|
||||
mh.RecordTool(toolName, err, time.Since(start))
|
||||
return result, err
|
||||
}
|
||||
return next(ctx, method, request)
|
||||
|
|
@ -42,7 +111,6 @@ func (mh *MetricsMiddleware) OnCallHTTP(next http.Handler) http.Handler {
|
|||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
mh.requestsBucket.Increment()
|
||||
mh.totalRequests.Increment()
|
||||
next.ServeHTTP(w, r)
|
||||
duration := fmt.Sprintf("%.2fms", float64(time.Since(start))/float64(time.Millisecond))
|
||||
log.DebugWithContext(r.Context(), "MetricsMiddleware: HTTP Handler", "duration", duration)
|
||||
|
|
@ -56,3 +124,42 @@ func (mh *MetricsMiddleware) GetRPS() float64 {
|
|||
func (mh *MetricsMiddleware) GetTotalRequests() int64 {
|
||||
return mh.totalRequests.Get()
|
||||
}
|
||||
|
||||
// MetricsText renders Prometheus-style exposition text for /metrics.
|
||||
func (mh *MetricsMiddleware) MetricsText() string {
|
||||
mh.mu.Lock()
|
||||
tools := make([]string, 0, len(mh.toolStats))
|
||||
for t := range mh.toolStats {
|
||||
tools = append(tools, t)
|
||||
}
|
||||
sort.Strings(tools)
|
||||
mh.mu.Unlock()
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# HELP naco_requests_total Total MCP tool calls.\n")
|
||||
fmt.Fprintf(&b, "# TYPE naco_requests_total counter\n")
|
||||
fmt.Fprintf(&b, "naco_requests_total %d\n", mh.totalRequests.Get())
|
||||
fmt.Fprintf(&b, "# HELP naco_errors_total Total MCP tool calls that returned an error.\n")
|
||||
fmt.Fprintf(&b, "# TYPE naco_errors_total counter\n")
|
||||
fmt.Fprintf(&b, "naco_errors_total %d\n", mh.totalErrors.Get())
|
||||
fmt.Fprintf(&b, "# HELP naco_enrichment_total Total background enrichment runs.\n")
|
||||
fmt.Fprintf(&b, "# TYPE naco_enrichment_total counter\n")
|
||||
fmt.Fprintf(&b, "naco_enrichment_total %d\n", mh.enrichEvents.Get())
|
||||
fmt.Fprintf(&b, "# HELP naco_requests_rps Rolling requests per second.\n")
|
||||
fmt.Fprintf(&b, "# TYPE naco_requests_rps gauge\n")
|
||||
fmt.Fprintf(&b, "naco_requests_rps %.3f\n", mh.GetRPS())
|
||||
|
||||
for _, t := range tools {
|
||||
s := mh.statsFor(t)
|
||||
fmt.Fprintf(&b, "# HELP naco_tool_calls_total Tool calls for %s.\n", t)
|
||||
fmt.Fprintf(&b, "# TYPE naco_tool_calls_total counter\n")
|
||||
fmt.Fprintf(&b, "naco_tool_calls_total{tool=%q} %d\n", t, s.calls.Get())
|
||||
fmt.Fprintf(&b, "# HELP naco_tool_errors_total Tool errors for %s.\n", t)
|
||||
fmt.Fprintf(&b, "# TYPE naco_tool_errors_total counter\n")
|
||||
fmt.Fprintf(&b, "naco_tool_errors_total{tool=%q} %d\n", t, s.errors.Get())
|
||||
fmt.Fprintf(&b, "# HELP naco_tool_latency_ms Rolling mean latency (ms) for %s.\n", t)
|
||||
fmt.Fprintf(&b, "# TYPE naco_tool_latency_ms gauge\n")
|
||||
fmt.Fprintf(&b, "naco_tool_latency_ms{tool=%q} %.3f\n", t, s.latency.GetMean())
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
|
|
|||
44
internal/server/platform.go
Normal file
44
internal/server/platform.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func determinePlatform(author string) (platform, error) {
|
||||
if strings.HasPrefix(author, "did:") {
|
||||
return Bluesky, nil
|
||||
}
|
||||
if strings.HasPrefix(author, "npub") {
|
||||
return Nostr, nil
|
||||
}
|
||||
if strings.HasPrefix(author, "@") {
|
||||
if strings.Count(author, "@") > 1 {
|
||||
return ActivityPub, nil
|
||||
}
|
||||
|
||||
if strings.Contains(author, ".") {
|
||||
return Bluesky, nil
|
||||
}
|
||||
|
||||
return Twitter, nil
|
||||
}
|
||||
if len(author) == 64 {
|
||||
isHex := true
|
||||
for _, c := range author {
|
||||
if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') {
|
||||
isHex = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if isHex {
|
||||
return Nostr, nil
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(author, ".") {
|
||||
return Bluesky, nil
|
||||
}
|
||||
|
||||
return -1, fmt.Errorf("unknown author format: %s", author)
|
||||
}
|
||||
57
internal/server/platform_test.go
Normal file
57
internal/server/platform_test.go
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
package server
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDeterminePlatform(t *testing.T) {
|
||||
cases := []struct {
|
||||
author string
|
||||
want platform
|
||||
err bool
|
||||
}{
|
||||
{"did:plc:abc123", Bluesky, false},
|
||||
{"npub1qwerty", Nostr, false},
|
||||
{"@user@host.social", ActivityPub, false},
|
||||
{"@user.bsky.social", Bluesky, false},
|
||||
{"@user", Twitter, false},
|
||||
{"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", Nostr, false},
|
||||
{"user.bsky.social", Bluesky, false},
|
||||
{"example.com", Bluesky, false},
|
||||
{"justhandle", -1, true},
|
||||
{"", -1, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := determinePlatform(c.author)
|
||||
if c.err {
|
||||
if err == nil {
|
||||
t.Fatalf("%q: expected error", c.author)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("%q: unexpected error %v", c.author, err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Fatalf("%q: got %v want %v", c.author, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformFromString(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want platform
|
||||
ok bool
|
||||
}{
|
||||
{"Bluesky", Bluesky, true},
|
||||
{"ActivityPub", ActivityPub, true},
|
||||
{"Nostr", Nostr, true},
|
||||
{"Twitter", Twitter, true},
|
||||
{"nope", -1, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := platformFromString(c.in)
|
||||
if ok != c.ok || got != c.want {
|
||||
t.Fatalf("%q: got %v ok %v, want %v %v", c.in, got, ok, c.want, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
144
internal/server/posts.go
Normal file
144
internal/server/posts.go
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Server) fetchSinglePost(ctx context.Context, platform platform, author, recordKey, collection string) (*GetPostResponsePost, error) {
|
||||
key := fmt.Sprintf("post:%s|%s|%s|%s", platform, author, collection, recordKey)
|
||||
return cachedFetch(s.postCache, key, func() (*GetPostResponsePost, error) {
|
||||
switch platform {
|
||||
case Bluesky:
|
||||
res, err := s.bskyClient.GetPost(ctx, author, collection, recordKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := bskyPostViewToResponse(res)
|
||||
return r, nil
|
||||
case ActivityPub:
|
||||
p, err := s.apClient.GetPost(ctx, recordKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := apPostToResponse(p)
|
||||
return r, nil
|
||||
case Nostr:
|
||||
p, err := s.nostrClient.GetPost(ctx, recordKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := nostrPostToResponse(p)
|
||||
return r, nil
|
||||
case Twitter:
|
||||
p, err := s.twitterClient.GetPost(ctx, recordKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := twitterPostToResponse(p)
|
||||
return r, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported platform %q", platform)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) fetchLatestPosts(ctx context.Context, platform platform, author string, limit int) ([]*GetPostResponsePost, string, error) {
|
||||
key := fmt.Sprintf("latest:%s|%s|%d", platform, author, limit)
|
||||
cv, err := cachedFetch(s.postCache, key, func() (latestCacheValue, error) {
|
||||
switch platform {
|
||||
case Bluesky:
|
||||
res, err := s.bskyClient.GetLatestNPosts(ctx, author, limit)
|
||||
if err != nil {
|
||||
return latestCacheValue{}, err
|
||||
}
|
||||
posts, aid := blueskyFeedViewsToResponses(res)
|
||||
return latestCacheValue{posts: posts, aid: aid}, nil
|
||||
case ActivityPub:
|
||||
res, err := s.apClient.GetLatestNPosts(ctx, author, limit)
|
||||
if err != nil {
|
||||
return latestCacheValue{}, err
|
||||
}
|
||||
posts, aid := apPostsPtrToResponses(res)
|
||||
return latestCacheValue{posts: posts, aid: aid}, nil
|
||||
case Nostr:
|
||||
res, err := s.nostrClient.GetLatestNPosts(ctx, author, limit)
|
||||
if err != nil {
|
||||
return latestCacheValue{}, err
|
||||
}
|
||||
posts, aid := nostrPostsToResponses(res)
|
||||
return latestCacheValue{posts: posts, aid: aid}, nil
|
||||
case Twitter:
|
||||
res, err := s.twitterClient.GetLatestNPosts(ctx, author, limit)
|
||||
if err != nil {
|
||||
return latestCacheValue{}, err
|
||||
}
|
||||
posts, aid := twitterPostsToResponses(res)
|
||||
return latestCacheValue{posts: posts, aid: aid}, nil
|
||||
}
|
||||
return latestCacheValue{}, fmt.Errorf("unsupported platform %q", platform)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return cv.posts, cv.aid, nil
|
||||
}
|
||||
|
||||
func (s *Server) fetchPostsInTime(ctx context.Context, platform platform, author string, start, end time.Time, limit int) ([]*GetPostResponsePost, string, error) {
|
||||
key := fmt.Sprintf("time:%s|%s|%s|%s|%d", platform, author, start.Format(time.RFC3339), end.Format(time.RFC3339), limit)
|
||||
cv, err := cachedFetch(s.postCache, key, func() (latestCacheValue, error) {
|
||||
switch platform {
|
||||
case Bluesky:
|
||||
res, err := s.bskyClient.GetPostsInTimeframe(ctx, author, start, end, limit)
|
||||
if err != nil {
|
||||
return latestCacheValue{}, err
|
||||
}
|
||||
posts, aid := blueskyFeedViewsToResponses(res)
|
||||
return latestCacheValue{posts: posts, aid: aid}, nil
|
||||
case ActivityPub:
|
||||
res, err := s.apClient.GetPostsInTimeframe(ctx, author, start, end, limit)
|
||||
if err != nil {
|
||||
return latestCacheValue{}, err
|
||||
}
|
||||
posts, aid := apPostsToResponses(res)
|
||||
return latestCacheValue{posts: posts, aid: aid}, nil
|
||||
case Nostr:
|
||||
res, err := s.nostrClient.GetPostsInTimeframe(ctx, author, start, end, limit)
|
||||
if err != nil {
|
||||
return latestCacheValue{}, err
|
||||
}
|
||||
posts, aid := nostrPostsToResponses(res)
|
||||
return latestCacheValue{posts: posts, aid: aid}, nil
|
||||
case Twitter:
|
||||
res, err := s.twitterClient.GetPostsInTimeframe(ctx, author, start, end, limit)
|
||||
if err != nil {
|
||||
return latestCacheValue{}, err
|
||||
}
|
||||
posts, aid := twitterPostsToResponses(res)
|
||||
return latestCacheValue{posts: posts, aid: aid}, nil
|
||||
}
|
||||
return latestCacheValue{}, fmt.Errorf("unsupported platform %q", platform)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return cv.posts, cv.aid, nil
|
||||
}
|
||||
|
||||
func (s *Server) fetchFeed(ctx context.Context, platform platform, uri string) ([]*GetPostResponsePost, error) {
|
||||
key := fmt.Sprintf("feed:%s|%s", platform, uri)
|
||||
return cachedFetch(s.postCache, key, func() ([]*GetPostResponsePost, error) {
|
||||
if platform == Bluesky {
|
||||
res, err := s.bskyClient.GetFeed(ctx, uri, 50)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
posts, _ := blueskyFeedViewsToResponses(res.Feed)
|
||||
return posts, nil
|
||||
}
|
||||
// Non-Bluesky platforms have no custom-feed concept; treat the URI as an author handle
|
||||
// and return their latest posts as the feed.
|
||||
posts, _, err := s.fetchLatestPosts(ctx, platform, uri, 50)
|
||||
return posts, err
|
||||
})
|
||||
}
|
||||
255
internal/server/posts_convert.go
Normal file
255
internal/server/posts_convert.go
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/bluesky-social/indigo/api/bsky"
|
||||
"github.com/nacorid/naco-api/internal/activitypub"
|
||||
"github.com/nacorid/naco-api/internal/clustering"
|
||||
"github.com/nacorid/naco-api/internal/nostr"
|
||||
"github.com/nacorid/naco-api/internal/twitter"
|
||||
)
|
||||
|
||||
func bskyPostViewToResponse(p *bsky.FeedDefs_PostView) *GetPostResponsePost {
|
||||
author := ""
|
||||
if p.Author != nil {
|
||||
author = p.Author.Did
|
||||
}
|
||||
content := ""
|
||||
created := ""
|
||||
if fp, ok := p.Record.Val.(*bsky.FeedPost); ok {
|
||||
content = fp.Text
|
||||
created = fp.CreatedAt
|
||||
}
|
||||
likes, reposts, quotes, replies := int64(0), int64(0), int64(0), int64(0)
|
||||
if p.LikeCount != nil {
|
||||
likes = *p.LikeCount
|
||||
}
|
||||
if p.RepostCount != nil {
|
||||
reposts = *p.RepostCount
|
||||
}
|
||||
if p.QuoteCount != nil {
|
||||
quotes = *p.QuoteCount
|
||||
}
|
||||
if p.ReplyCount != nil {
|
||||
replies = *p.ReplyCount
|
||||
}
|
||||
return &GetPostResponsePost{
|
||||
Author: author,
|
||||
Content: content,
|
||||
Likes: likes,
|
||||
Reposts: reposts,
|
||||
Quotes: quotes,
|
||||
Replies: replies,
|
||||
CreatedAt: created,
|
||||
}
|
||||
}
|
||||
|
||||
func apPostToResponse(p *activitypub.Post) *GetPostResponsePost {
|
||||
created := ""
|
||||
if !p.CreatedAt.IsZero() {
|
||||
created = p.CreatedAt.Format(time.RFC3339)
|
||||
}
|
||||
return &GetPostResponsePost{
|
||||
Author: p.Author,
|
||||
Content: p.Content,
|
||||
Likes: int64(p.FavouritesCount),
|
||||
Reposts: int64(p.ReblogsCount),
|
||||
Replies: int64(p.RepliesCount),
|
||||
CreatedAt: created,
|
||||
}
|
||||
}
|
||||
|
||||
func nostrPostToResponse(p *nostr.Post) *GetPostResponsePost {
|
||||
created := ""
|
||||
if !p.CreatedAt.IsZero() {
|
||||
created = p.CreatedAt.Format(time.RFC3339)
|
||||
}
|
||||
return &GetPostResponsePost{
|
||||
Author: p.Author,
|
||||
Content: p.Content,
|
||||
CreatedAt: created,
|
||||
}
|
||||
}
|
||||
|
||||
func twitterPostToResponse(p *twitter.Post) *GetPostResponsePost {
|
||||
created := ""
|
||||
if !p.CreatedAt.IsZero() {
|
||||
created = p.CreatedAt.Format(time.RFC3339)
|
||||
}
|
||||
return &GetPostResponsePost{
|
||||
Author: p.Author,
|
||||
Content: p.Content,
|
||||
Likes: int64(p.Likes),
|
||||
Reposts: int64(p.Reposts),
|
||||
Quotes: int64(p.Quotes),
|
||||
Replies: int64(p.Replies),
|
||||
CreatedAt: created,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Batch converters for GetPostResponsePost ----
|
||||
|
||||
func blueskyFeedViewsToResponses(views []*bsky.FeedDefs_FeedViewPost) ([]*GetPostResponsePost, string) {
|
||||
posts := make([]*GetPostResponsePost, 0, len(views))
|
||||
var aid string
|
||||
for _, p := range views {
|
||||
if p.Post == nil {
|
||||
continue
|
||||
}
|
||||
posts = append(posts, bskyPostViewToResponse(p.Post))
|
||||
if p.Post.Author != nil {
|
||||
aid = p.Post.Author.Did
|
||||
}
|
||||
}
|
||||
return posts, aid
|
||||
}
|
||||
|
||||
func apPostsPtrToResponses(posts []*activitypub.Post) ([]*GetPostResponsePost, string) {
|
||||
out := make([]*GetPostResponsePost, 0, len(posts))
|
||||
var aid string
|
||||
for _, p := range posts {
|
||||
out = append(out, apPostToResponse(p))
|
||||
aid = p.Author
|
||||
}
|
||||
return out, aid
|
||||
}
|
||||
|
||||
func apPostsToResponses(posts []*activitypub.Post) ([]*GetPostResponsePost, string) {
|
||||
out := make([]*GetPostResponsePost, 0, len(posts))
|
||||
var aid string
|
||||
for _, p := range posts {
|
||||
out = append(out, apPostToResponse(p))
|
||||
aid = p.Author
|
||||
}
|
||||
return out, aid
|
||||
}
|
||||
|
||||
func nostrPostsToResponses(posts []*nostr.Post) ([]*GetPostResponsePost, string) {
|
||||
out := make([]*GetPostResponsePost, 0, len(posts))
|
||||
var aid string
|
||||
for _, p := range posts {
|
||||
out = append(out, nostrPostToResponse(p))
|
||||
aid = p.Author
|
||||
}
|
||||
return out, aid
|
||||
}
|
||||
|
||||
func twitterPostsToResponses(posts []*twitter.Post) ([]*GetPostResponsePost, string) {
|
||||
out := make([]*GetPostResponsePost, 0, len(posts))
|
||||
var aid string
|
||||
for _, p := range posts {
|
||||
out = append(out, twitterPostToResponse(p))
|
||||
aid = p.Author
|
||||
}
|
||||
return out, aid
|
||||
}
|
||||
|
||||
// ---- Batch converters for clustering.Item (style & analytics) ----
|
||||
|
||||
func blueskyFeedViewsToItems(views []*bsky.FeedDefs_FeedViewPost) []clustering.Item {
|
||||
out := make([]clustering.Item, 0, len(views))
|
||||
for _, p := range views {
|
||||
fp, ok := p.Post.Record.Val.(*bsky.FeedPost)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if fp.Text == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, clustering.Item{ID: p.Post.Uri, Text: fp.Text})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func apPostsPtrToItems(posts []*activitypub.Post) []clustering.Item {
|
||||
out := make([]clustering.Item, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
if p.Content != "" {
|
||||
out = append(out, clustering.Item{ID: p.ID, Text: p.Content})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func nostrPostsToItems(posts []*nostr.Post) []clustering.Item {
|
||||
out := make([]clustering.Item, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
if p.Content != "" {
|
||||
out = append(out, clustering.Item{ID: p.ID, Text: p.Content})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func twitterPostsToItems(posts []*twitter.Post) []clustering.Item {
|
||||
out := make([]clustering.Item, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
if p.Content != "" {
|
||||
out = append(out, clustering.Item{ID: p.ID, Text: p.Content})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// analytics-specific converters that preserve engagement + timestamps
|
||||
|
||||
func blueskyFeedViewsToAnalyticsItems(views []*bsky.FeedDefs_FeedViewPost) []clustering.Item {
|
||||
out := make([]clustering.Item, 0, len(views))
|
||||
for _, p := range views {
|
||||
if p.Post == nil || p.Post.Record == nil {
|
||||
continue
|
||||
}
|
||||
fp, ok := p.Post.Record.Val.(*bsky.FeedPost)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
item := clustering.Item{ID: p.Post.Uri, Text: fp.Text}
|
||||
if ts, err := time.Parse(time.RFC3339, p.Post.IndexedAt); err == nil {
|
||||
item.Timestamp = ts
|
||||
}
|
||||
item.Likes = int64Val(p.Post.LikeCount)
|
||||
item.Reposts = int64Val(p.Post.RepostCount)
|
||||
item.Replies = int64Val(p.Post.ReplyCount)
|
||||
item.Quotes = int64Val(p.Post.QuoteCount)
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func apPostsToAnalyticsItems(posts []*activitypub.Post) []clustering.Item {
|
||||
out := make([]clustering.Item, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
if p.Content != "" {
|
||||
out = append(out, clustering.Item{
|
||||
ID: p.ID, Text: p.Content, Timestamp: p.CreatedAt,
|
||||
Likes: int64(p.FavouritesCount), Reposts: int64(p.ReblogsCount), Replies: int64(p.RepliesCount),
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func nostrPostsToAnalyticsItems(posts []*nostr.Post) []clustering.Item {
|
||||
out := make([]clustering.Item, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
if p.Content != "" {
|
||||
out = append(out, clustering.Item{ID: p.ID, Text: p.Content, Timestamp: p.CreatedAt})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func twitterPostsToAnalyticsItems(posts []*twitter.Post) []clustering.Item {
|
||||
out := make([]clustering.Item, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
if p.Content == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, clustering.Item{
|
||||
ID: p.ID, Text: p.Content, Timestamp: p.CreatedAt,
|
||||
Likes: int64(p.Likes), Reposts: int64(p.Reposts), Replies: int64(p.Replies), Quotes: int64(p.Quotes),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
93
internal/server/prewarm.go
Normal file
93
internal/server/prewarm.go
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
log "github.com/nacorid/logger"
|
||||
)
|
||||
|
||||
// StartPrewarm launches a background goroutine that discovers interesting authors and
|
||||
// pre-fetches their style profiles. It issues at most one pre-fetch "request" per minute
|
||||
// so upstream platforms, Ollama and the database are never hit harder than that cadence
|
||||
// allows. Profiles are still only (re)written when older than a week, exactly like a live
|
||||
// getStyleProfile request. Failures (including rate limits) are logged and retried on the
|
||||
// next tick.
|
||||
func (s *Server) StartPrewarm(ctx context.Context) {
|
||||
if s.prewarmFeedURI == "" && len(s.prewarmAuthors) == 0 {
|
||||
log.DebugWithContext(ctx, "no prewarm feed or authors configured, skipping prewarm")
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
authors := s.discoverInterestingAuthors(ctx)
|
||||
if len(authors) == 0 {
|
||||
return
|
||||
}
|
||||
s.prewarmOne(ctx, authors[0])
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
i := 1
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if i >= len(authors) {
|
||||
// re-discover interesting profiles and restart the cycle
|
||||
authors = s.discoverInterestingAuthors(ctx)
|
||||
i = 0
|
||||
if len(authors) == 0 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
author := authors[i]
|
||||
i++
|
||||
s.prewarmOne(ctx, author)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// discoverInterestingAuthors returns a list of authors worth pre-fetching. When a feed URI
|
||||
// is configured it pulls recent posts from that feed and collects the author DIDs;
|
||||
// otherwise it falls back to the statically configured seed list.
|
||||
func (s *Server) discoverInterestingAuthors(ctx context.Context) []string {
|
||||
if s.prewarmFeedURI != "" {
|
||||
feed, err := s.bskyClient.GetFeed(ctx, s.prewarmFeedURI, 50)
|
||||
if err != nil {
|
||||
log.WarnWithContext(ctx, "prewarm: failed to discover authors from feed", "error", err)
|
||||
} else {
|
||||
seen := make(map[string]struct{})
|
||||
authors := make([]string, 0, len(feed.Feed))
|
||||
for _, p := range feed.Feed {
|
||||
if p.Post == nil || p.Post.Author == nil {
|
||||
continue
|
||||
}
|
||||
did := p.Post.Author.Did
|
||||
if did == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[did]; ok {
|
||||
continue
|
||||
}
|
||||
seen[did] = struct{}{}
|
||||
authors = append(authors, did)
|
||||
}
|
||||
if len(authors) > 0 {
|
||||
return authors
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.prewarmAuthors
|
||||
}
|
||||
|
||||
func (s *Server) prewarmOne(ctx context.Context, author string) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
defer cancel()
|
||||
if _, err := s.requestStyleProfile(ctx, author); err != nil {
|
||||
log.WarnWithContext(ctx, "prewarm: failed to build profile", "author", author, "error", err)
|
||||
return
|
||||
}
|
||||
log.InfoWithContext(ctx, "prewarmed style profile", "author", author)
|
||||
}
|
||||
84
internal/server/searchactors_test.go
Normal file
84
internal/server/searchactors_test.go
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/nacorid/naco-api/internal/actor"
|
||||
)
|
||||
|
||||
func TestNormalizeHandle(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"", ""},
|
||||
{" ", ""},
|
||||
{"@alice", "alice"},
|
||||
{"@", ""},
|
||||
{"alice@mastodon.social", "alice"},
|
||||
{"Alice@Mastodon.Social", "alice"},
|
||||
{"@Alice@mastodon.social", "alice"},
|
||||
{"alice.bsky.social", "alice.bsky.social"},
|
||||
{" @bob.X ", "bob.x"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := normalizeHandle(c.in); got != c.want {
|
||||
t.Errorf("normalizeHandle(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupeActors(t *testing.T) {
|
||||
in := []*ActorProfile{
|
||||
{Handle: "@alice", DisplayName: "Alice"},
|
||||
{Handle: "alice@mastodon.social", DisplayName: "Alice"},
|
||||
{Handle: "", DisplayName: ""},
|
||||
{Handle: "bob", DisplayName: "Bob"},
|
||||
}
|
||||
// @alice and alice@mastodon.social dedupe by handle; the empty-handle AND
|
||||
// empty-display-name is appended unconditionally; bob is another person.
|
||||
out := dedupeActors(in)
|
||||
if len(out) != 3 {
|
||||
t.Fatalf("expected 3 deduped results, got %d: %+v", len(out), out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupeActorsByDisplayNameOnly(t *testing.T) {
|
||||
// When handles are empty but display names collide, dedupe by display name.
|
||||
in := []*ActorProfile{
|
||||
{Handle: "", DisplayName: "Shared"},
|
||||
{Handle: "", DisplayName: "Shared"},
|
||||
{Handle: "", DisplayName: "Other"},
|
||||
}
|
||||
out := dedupeActors(in)
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("expected 2 results after display-name dedup, got %d: %+v", len(out), out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchActorsTwitterExcluded(t *testing.T) {
|
||||
// searchActorsAll only queries Bluesky, ActivityPub and Nostr. A Twitter
|
||||
// result must never appear because the backend is not consulted.
|
||||
tw := &fakeTwitter{actors: []*actor.Profile{{Platform: "Twitter", Handle: "t"}}, calls: 0}
|
||||
bsky := &fakeBluesky{actors: []*actor.Profile{{Platform: "Bluesky", Handle: "a"}}}
|
||||
ap := &fakeAP{actors: []*actor.Profile{{Platform: "ActivityPub", Handle: "b"}}}
|
||||
nstr := &fakeNostr{actors: []*actor.Profile{{Platform: "Nostr", Handle: "c"}}}
|
||||
s := newTestServer(bsky, ap, nstr, tw)
|
||||
|
||||
res, err := callTool(s, "searchActors", SearchActorsRequest{Query: "q"})
|
||||
if err != nil {
|
||||
t.Fatalf("searchActors error: %v", err)
|
||||
}
|
||||
var out SearchActorsResponse
|
||||
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
for _, r := range out.Results {
|
||||
if r.Platform == "Twitter" {
|
||||
t.Fatalf("unexpected Twitter result: %+v", r)
|
||||
}
|
||||
}
|
||||
if tw.calls != 0 {
|
||||
t.Fatalf("expected Twitter backend untouched, got %d calls", tw.calls)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,740 +2,119 @@ package server
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bluesky-social/indigo/api/bsky"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
log "github.com/nacorid/logger"
|
||||
"github.com/nacorid/naco-api/internal/activitypub"
|
||||
"github.com/nacorid/naco-api/internal/bluesky"
|
||||
"github.com/nacorid/naco-api/internal/config"
|
||||
"github.com/nacorid/naco-api/internal/nostr"
|
||||
"github.com/nacorid/naco-api/internal/ollama"
|
||||
"github.com/nacorid/naco-api/internal/store"
|
||||
"github.com/nacorid/naco-api/internal/utils"
|
||||
"github.com/nacorid/naco-api/internal/twitter"
|
||||
"github.com/nacorid/naco-api/internal/x402"
|
||||
x402v2 "github.com/x402-foundation/x402/go/v2"
|
||||
x402http "github.com/x402-foundation/x402/go/v2/http/nethttp"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
listenAddress string
|
||||
permissions os.FileMode
|
||||
mcpHandler *x402.X402Server
|
||||
db *store.Queries
|
||||
apClient *activitypub.APClient
|
||||
bskyClient *bluesky.BlueskyClient
|
||||
ollama *ollama.OllamaClient
|
||||
x402Wallet string
|
||||
activeNetworks []NetworkInfo
|
||||
// Transport & middleware
|
||||
listenAddress string
|
||||
permissions os.FileMode
|
||||
mcpHandler *x402.X402Server
|
||||
metrics *MetricsMiddleware
|
||||
|
||||
// Dependencies
|
||||
db Store
|
||||
apClient apBackend
|
||||
bskyClient blueskyBackend
|
||||
nostrClient nostrBackend
|
||||
twitterClient twitterBackend
|
||||
ollama ollamaBackend
|
||||
|
||||
// Cache & background work
|
||||
postCache *ttlCache
|
||||
enrichSem chan struct{}
|
||||
prewarmAuthors []string
|
||||
prewarmFeedURI string
|
||||
enrichEnabled bool
|
||||
|
||||
// Payments & analytics tuning
|
||||
x402Wallet string
|
||||
activeNetworks []NetworkInfo
|
||||
analyticsTTLEngagement time.Duration
|
||||
analyticsTTLPatterns time.Duration
|
||||
analyticsTTLTopics time.Duration
|
||||
analyticsTTLSentiment time.Duration
|
||||
analyticsTTLDrivers time.Duration
|
||||
|
||||
// twitterEnabled reports whether the Twitter API is available (a bearer
|
||||
// token is configured). It flips the Twitter-capable tools to upto
|
||||
// variable pricing so the realized Twitter API costs are passed on to
|
||||
// callers (see registerTools).
|
||||
twitterEnabled bool
|
||||
}
|
||||
|
||||
func New(ctx context.Context, db *store.Queries, ap *activitypub.APClient, bsky *bluesky.BlueskyClient, o *ollama.OllamaClient, cfg *config.Config) *Server {
|
||||
rateLimits := NewRateLimitMiddleware(float64(1.0/10.0), 2, []string{"getFeed"})
|
||||
metrics := NewMetricsHook()
|
||||
logging := NewLoggingMiddleware()
|
||||
toolChain := x402.X402Middlewares{
|
||||
MCPMiddleware: []mcp.Middleware{
|
||||
logging.OnCall,
|
||||
metrics.OnCall,
|
||||
rateLimits.OnCall,
|
||||
},
|
||||
HTTPMiddleware: []func(http.Handler) http.Handler{
|
||||
logging.OnCallHTTP,
|
||||
metrics.OnCallHTTP,
|
||||
rateLimits.OnCallHTTP,
|
||||
},
|
||||
}
|
||||
auth, err := NewCDPAuth(cfg.ApiKey, cfg.ApiSecret)
|
||||
if err != nil {
|
||||
log.FatalWithContext(ctx, "failed to create CDP auth", "error", err)
|
||||
}
|
||||
|
||||
srv, err := x402.NewX402Server(
|
||||
"x402 enabled Bluesky lookingglass for autonomous Agents",
|
||||
"0.2.0",
|
||||
cfg.X402FacilitatorURL,
|
||||
auth,
|
||||
toolChain,
|
||||
GlobalLoggingMiddleware,
|
||||
func(w http.ResponseWriter, r *http.Request, resp *x402v2.SettleResponse) {
|
||||
requirements, ok := x402http.RequirementsFromContext(r.Context())
|
||||
if !ok {
|
||||
log.WarnWithContext(r.Context(), "failed to extract payment requirements from context during settlement hook")
|
||||
return
|
||||
}
|
||||
amount := requirements.Amount
|
||||
asset := requirements.Asset
|
||||
to := requirements.PayTo
|
||||
_ = settleHook(r.Context(), db, resp.Transaction, amount, asset, string(resp.Network), to, resp.Payer)
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
log.FatalWithContext(ctx, "failed to create x402 server", "error", err)
|
||||
}
|
||||
|
||||
srv.OnAfterSettle(func(settleCtx x402v2.SettleResultContext) error {
|
||||
amount := settleCtx.Requirements.GetAmount()
|
||||
asset := settleCtx.Requirements.GetAsset()
|
||||
to := settleCtx.Requirements.GetPayTo()
|
||||
from := settleCtx.Result.Payer
|
||||
|
||||
return settleHook(settleCtx.Ctx, db, settleCtx.Result.Transaction, amount, asset, string(settleCtx.Result.Network), to, from)
|
||||
})
|
||||
|
||||
enabledNetworkNames := parseActiveNetworks(cfg.ActiveNetworks)
|
||||
|
||||
var activeNetworks []NetworkInfo
|
||||
for _, name := range enabledNetworkNames {
|
||||
if info, exists := NetworkRegistry[name]; exists {
|
||||
activeNetworks = append(activeNetworks, info)
|
||||
} else {
|
||||
log.WarnWithContext(ctx, "configured network is not defined in the registry", "name", name)
|
||||
}
|
||||
}
|
||||
func New(ctx context.Context, db *store.Queries, ap *activitypub.APClient, bsky *bluesky.BlueskyClient, n *nostr.Client, t *twitter.Client, o *ollama.OllamaClient, cfg *config.Config) *Server {
|
||||
toolchain, metrics := buildMiddleware()
|
||||
srv := newX402Server(ctx, db, cfg, toolchain, metrics)
|
||||
activeNetworks := resolveActiveNetworks(ctx, cfg.ActiveNetworks)
|
||||
|
||||
s := &Server{
|
||||
listenAddress: cfg.ListenAddress,
|
||||
permissions: cfg.UnixSocketPermissions,
|
||||
mcpHandler: srv,
|
||||
db: db,
|
||||
apClient: ap,
|
||||
bskyClient: bsky,
|
||||
ollama: o,
|
||||
x402Wallet: cfg.X402WalletAddress,
|
||||
activeNetworks: activeNetworks,
|
||||
listenAddress: cfg.ListenAddress,
|
||||
permissions: cfg.UnixSocketPermissions,
|
||||
mcpHandler: srv,
|
||||
db: db,
|
||||
apClient: ap,
|
||||
bskyClient: bsky,
|
||||
nostrClient: n,
|
||||
twitterClient: t,
|
||||
ollama: o,
|
||||
metrics: metrics,
|
||||
postCache: newTTLCache(postCacheTTL),
|
||||
prewarmAuthors: cfg.PrewarmAuthors,
|
||||
prewarmFeedURI: cfg.PrewarmFeedURI,
|
||||
enrichEnabled: cfg.EnrichCrossPlatform,
|
||||
x402Wallet: cfg.X402WalletAddress,
|
||||
activeNetworks: activeNetworks,
|
||||
enrichSem: make(chan struct{}, 4),
|
||||
analyticsTTLEngagement: cfg.AnalyticsTTLEngagement,
|
||||
analyticsTTLPatterns: cfg.AnalyticsTTLPatterns,
|
||||
analyticsTTLTopics: cfg.AnalyticsTTLTopics,
|
||||
analyticsTTLSentiment: cfg.AnalyticsTTLSentiment,
|
||||
analyticsTTLDrivers: cfg.AnalyticsTTLDrivers,
|
||||
twitterEnabled: cfg.TwitterBearerToken != "",
|
||||
}
|
||||
|
||||
// Combine address and port for TCP (not unix socket) if port not already present
|
||||
if !strings.HasPrefix(cfg.ListenAddress, "unix:") && !strings.Contains(cfg.ListenAddress, ":") {
|
||||
s.listenAddress = fmt.Sprintf("%s:%s", cfg.ListenAddress, cfg.ListenPort)
|
||||
}
|
||||
|
||||
log.DebugWithContext(ctx, "server initialized",
|
||||
"networks", len(activeNetworks),
|
||||
"enrich", s.enrichEnabled,
|
||||
"prewarm_authors", len(s.prewarmAuthors),
|
||||
)
|
||||
|
||||
s.registerTools()
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) registerTools() {
|
||||
extra := map[string]any{
|
||||
"OutputSchema": Schema{
|
||||
Input: InputSchema{
|
||||
Type: "HTTP",
|
||||
Method: "POST",
|
||||
Discoverable: true,
|
||||
BodyFields: map[string]any{
|
||||
"actor": Bodyfield{
|
||||
Type: "string",
|
||||
Description: "Did or handle of an actor",
|
||||
Required: true,
|
||||
},
|
||||
"rkey": Bodyfield{
|
||||
Type: "string",
|
||||
Description: "Record key of the post",
|
||||
Required: true,
|
||||
},
|
||||
"collection": Bodyfield{
|
||||
Type: "string",
|
||||
Description: "Collection the post belongs to. Defaults to 'app.bsky.feed.post'",
|
||||
Required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
Output: map[string]any{
|
||||
"actor": Bodyfield{
|
||||
Type: "string",
|
||||
Description: "Did of the actor",
|
||||
},
|
||||
"post": Bodyfield{
|
||||
Type: "object",
|
||||
Description: "The post object. Includes the post content 'content', like 'likes', quote 'quotes', replies 'replies' and repost 'reposts' count",
|
||||
},
|
||||
},
|
||||
},
|
||||
func (s *Server) MetricsText() string {
|
||||
if s.metrics == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
s.mcpHandler.AddPayableTool(
|
||||
mcp.Tool{Name: "getPost",
|
||||
Description: "Get a specific post from a specific actor",
|
||||
InputSchema: GetPostRequest{},
|
||||
OutputSchema: GetPostResponsePost{},
|
||||
},
|
||||
s.getPost,
|
||||
s.buildRequirements("0.01", extra)...,
|
||||
)
|
||||
|
||||
extra = map[string]any{
|
||||
"OutputSchema": Schema{
|
||||
Input: InputSchema{
|
||||
Type: "HTTP",
|
||||
Method: "POST",
|
||||
Discoverable: true,
|
||||
BodyFields: map[string]any{
|
||||
"actor": Bodyfield{
|
||||
Type: "string",
|
||||
Description: "Did or handle of an actor",
|
||||
Required: true,
|
||||
},
|
||||
"n": Bodyfield{
|
||||
Type: "integer",
|
||||
Description: "Number of posts to retrieve, maximum 50",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
Output: map[string]any{
|
||||
"actor": Bodyfield{
|
||||
Type: "string",
|
||||
Description: "Did of an actor",
|
||||
},
|
||||
"posts": Bodyfield{
|
||||
Type: "array",
|
||||
Description: "Array of post objects. Each includes the post content 'content', like 'likes', quote 'quotes', replies 'replies' and repost 'reposts' count",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
s.mcpHandler.AddPayableTool(
|
||||
mcp.Tool{Name: "getLatestPosts",
|
||||
Description: "Get the latest n posts from a specific actor",
|
||||
InputSchema: GetLatestPostsRequest{},
|
||||
OutputSchema: GetLatestPostsResponse{},
|
||||
},
|
||||
s.getLatestPosts,
|
||||
s.buildRequirements("0.10", extra)...,
|
||||
)
|
||||
|
||||
extra = map[string]any{
|
||||
"OutputSchema": Schema{
|
||||
Input: InputSchema{
|
||||
Type: "HTTP",
|
||||
Method: "POST",
|
||||
Discoverable: true,
|
||||
BodyFields: map[string]any{
|
||||
"actor": Bodyfield{
|
||||
Type: "string",
|
||||
Description: "Did or handle of an actor",
|
||||
Required: true,
|
||||
},
|
||||
"start": Bodyfield{
|
||||
Type: "string",
|
||||
Description: "Start time in RFC3339 format",
|
||||
Required: true,
|
||||
},
|
||||
"end": Bodyfield{
|
||||
Type: "string",
|
||||
Description: "End time in RFC3339 format",
|
||||
Required: true,
|
||||
},
|
||||
"n": Bodyfield{
|
||||
Type: "integer",
|
||||
Description: "Maximum number of posts to retrieve, maximum 50",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
Output: map[string]any{
|
||||
"actor": Bodyfield{
|
||||
Type: "string",
|
||||
Description: "Did of an actor",
|
||||
},
|
||||
"posts": Bodyfield{
|
||||
Type: "array",
|
||||
Description: "Array of post objects. Each includes the post content 'content', like 'likes', quote 'quotes', replies 'replies' and repost 'reposts' count",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
s.mcpHandler.AddPayableTool(
|
||||
mcp.Tool{Name: "getPostsInTime",
|
||||
Description: "Get posts from a specific actor in a time range",
|
||||
InputSchema: GetPostsInTimeRequest{},
|
||||
OutputSchema: GetPostsInTimeResponse{},
|
||||
},
|
||||
s.getPostsInTime,
|
||||
s.buildRequirements("0.10", extra)...,
|
||||
)
|
||||
|
||||
extra = map[string]any{
|
||||
"OutputSchema": Schema{
|
||||
Input: InputSchema{
|
||||
Type: "HTTP",
|
||||
Method: "POST",
|
||||
Discoverable: true,
|
||||
BodyFields: map[string]any{
|
||||
"actor": Bodyfield{
|
||||
Type: "string",
|
||||
Description: "Did or handle of an actor",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
Output: map[string]any{
|
||||
"PersonaEmbedding": Bodyfield{
|
||||
Type: "array",
|
||||
Description: "Embedding vector representing the actor's persona",
|
||||
},
|
||||
"TopKeywords": Bodyfield{
|
||||
Type: "array",
|
||||
Description: "Top words used by the actor",
|
||||
},
|
||||
"StyleSummary": Bodyfield{
|
||||
Type: "string",
|
||||
Description: "Summary of the actor's writing style",
|
||||
},
|
||||
"PostCount": Bodyfield{
|
||||
Type: "integer",
|
||||
Description: "Number of posts analyzed",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/*s.mcpHandler.AddPayableTool(
|
||||
mcp.Tool{Name: "getStyleProfile",
|
||||
Description: "Get an actor's style Profile",
|
||||
InputSchema: GetStyleProfileRequest{},
|
||||
OutputSchema: GetStyleProfileResponse{},
|
||||
},
|
||||
s.getStyleProfile,
|
||||
s.buildRequirements("100000", extra)...,
|
||||
)*/
|
||||
|
||||
s.mcpHandler.AddTool(
|
||||
mcp.Tool{Name: "getFeed",
|
||||
Description: "Get a feed by its URI",
|
||||
InputSchema: GetFeedRequest{},
|
||||
OutputSchema: GetFeedResponse{},
|
||||
},
|
||||
s.getFeed,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Server) getPost(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
var args GetPostRequest
|
||||
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
if args.Author == "" {
|
||||
return nil, fmt.Errorf("author must be a valid did or handle")
|
||||
}
|
||||
if args.RecordKey == "" {
|
||||
return nil, fmt.Errorf("rkey must be a valid record key")
|
||||
}
|
||||
res, err := s.bskyClient.GetPost(ctx, args.Author, args.Collection, args.RecordKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
post, ok := res.Record.Val.(*bsky.FeedPost)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("record is not a feed post")
|
||||
}
|
||||
resp := GetPostResponsePost{
|
||||
Author: res.Author.Did,
|
||||
Content: post.Text,
|
||||
Likes: *res.LikeCount,
|
||||
Reposts: *res.RepostCount,
|
||||
Quotes: *res.QuoteCount,
|
||||
Replies: *res.ReplyCount,
|
||||
CreatedAt: post.CreatedAt,
|
||||
}
|
||||
|
||||
respBytes, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: string(respBytes)},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) getLatestPosts(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
var args GetLatestPostsRequest
|
||||
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
if args.Author == "" {
|
||||
return nil, fmt.Errorf("author must be a valid did or handle")
|
||||
}
|
||||
if args.Limit <= 0 {
|
||||
return nil, fmt.Errorf("n must be a positive integer")
|
||||
}
|
||||
if args.Limit > 50 {
|
||||
return nil, fmt.Errorf("n must be at most 50")
|
||||
}
|
||||
res, err := s.bskyClient.GetLatestNPosts(ctx, args.Author, args.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
posts := make([]GetPostResponsePost, 0, len(res))
|
||||
var author string
|
||||
for _, p := range res {
|
||||
post, ok := p.Post.Record.Val.(*bsky.FeedPost)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
posts = append(posts, GetPostResponsePost{
|
||||
Content: post.Text,
|
||||
Likes: *p.Post.LikeCount,
|
||||
Reposts: *p.Post.RepostCount,
|
||||
Quotes: *p.Post.QuoteCount,
|
||||
Replies: *p.Post.ReplyCount,
|
||||
CreatedAt: post.CreatedAt,
|
||||
})
|
||||
author = p.Post.Author.Did
|
||||
}
|
||||
if author == "" {
|
||||
author = args.Author
|
||||
}
|
||||
resp := GetLatestPostsResponse{
|
||||
Author: author,
|
||||
Posts: posts,
|
||||
}
|
||||
|
||||
respBytes, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: string(respBytes)},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) getPostsInTime(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
var args GetPostsInTimeRequest
|
||||
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
if args.Author == "" {
|
||||
return nil, fmt.Errorf("author must be a valid did or handle")
|
||||
}
|
||||
if args.Limit <= 0 {
|
||||
return nil, fmt.Errorf("n must be a positive integer")
|
||||
}
|
||||
limit := min(args.Limit, 50)
|
||||
startTime, err := time.Parse(time.RFC3339, args.StartTime)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("startTime must be in RFC3339 format")
|
||||
}
|
||||
endTime, err := time.Parse(time.RFC3339, args.EndTime)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("startTime must be in RFC3339 format")
|
||||
}
|
||||
if startTime.After(endTime) {
|
||||
tmp := startTime
|
||||
startTime = endTime
|
||||
endTime = tmp
|
||||
}
|
||||
res, err := s.bskyClient.GetPostsInTimeframe(ctx, args.Author, startTime, endTime, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
posts := make([]GetPostResponsePost, 0, len(res))
|
||||
var author string
|
||||
for _, p := range res {
|
||||
post, ok := p.Post.Record.Val.(*bsky.FeedPost)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
posts = append(posts, GetPostResponsePost{
|
||||
Content: post.Text,
|
||||
Likes: *p.Post.LikeCount,
|
||||
Reposts: *p.Post.RepostCount,
|
||||
Quotes: *p.Post.QuoteCount,
|
||||
Replies: *p.Post.ReplyCount,
|
||||
CreatedAt: post.CreatedAt,
|
||||
})
|
||||
author = p.Post.Author.Did
|
||||
}
|
||||
if author == "" {
|
||||
author = args.Author
|
||||
}
|
||||
resp := &GetPostsInTimeResponse{
|
||||
Author: author,
|
||||
Posts: posts,
|
||||
}
|
||||
|
||||
respBytes, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: string(respBytes)},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) getFeed(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
var args GetFeedRequest
|
||||
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
if args.URI == "" {
|
||||
return nil, fmt.Errorf("feedURI must be a valid feed URI")
|
||||
}
|
||||
res, err := s.bskyClient.GetFeed(ctx, args.URI, 50)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
posts := make([]GetPostResponsePost, 0, len(res.Feed))
|
||||
for _, p := range res.Feed {
|
||||
post, ok := p.Post.Record.Val.(*bsky.FeedPost)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
posts = append(posts, GetPostResponsePost{
|
||||
Author: p.Post.Author.Did,
|
||||
Content: post.Text,
|
||||
Likes: *p.Post.LikeCount,
|
||||
Reposts: *p.Post.RepostCount,
|
||||
Quotes: *p.Post.QuoteCount,
|
||||
Replies: *p.Post.ReplyCount,
|
||||
CreatedAt: post.CreatedAt,
|
||||
})
|
||||
}
|
||||
resp := GetFeedResponse{
|
||||
URI: args.URI,
|
||||
Posts: posts,
|
||||
}
|
||||
|
||||
respBytes, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: string(respBytes)},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) getStyleProfile(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
var args GetStyleProfileRequest
|
||||
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
if args.Author == "" {
|
||||
return nil, fmt.Errorf("author must be a valid did or handle")
|
||||
}
|
||||
|
||||
styleProfile, err := s.db.GetStyleProfile(ctx, store.GetStyleProfileParams{
|
||||
UserID: args.Author,
|
||||
Platform: "bluesky",
|
||||
})
|
||||
isRecent := styleProfile.ModifiedAt.Valid && time.Since(styleProfile.ModifiedAt.Time) < 24*7*time.Hour
|
||||
if err == nil && isRecent {
|
||||
embedding, err := utils.ParseEmbedding(styleProfile.Embedding)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := GetStyleProfileResponse{
|
||||
Author: styleProfile.UserID,
|
||||
PersonaEmbedding: embedding,
|
||||
TopKeywords: styleProfile.TopKeywords,
|
||||
PostCount: int(styleProfile.PostCount),
|
||||
StyleSummary: styleProfile.StyleSummary,
|
||||
}
|
||||
|
||||
respBytes, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: string(respBytes)},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
platform, err := determinePlatform(args.Author)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch platform {
|
||||
case Bluesky:
|
||||
|
||||
}
|
||||
res, err := s.bskyClient.GetLatestNPosts(ctx, args.Author, 50)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
posts := make([]string, 0, len(res))
|
||||
var author string
|
||||
for _, p := range res {
|
||||
post, ok := p.Post.Record.Val.(*bsky.FeedPost)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
posts = append(posts, post.Text)
|
||||
author = p.Post.Author.Did
|
||||
}
|
||||
|
||||
embeddings, err := s.ollama.EmbedTexts(ctx, posts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
personaEmbedding := utils.AverageVector(embeddings)
|
||||
topWords := utils.TopWords(posts, make(map[string]struct{}), 10)
|
||||
styleSummary, err := s.ollama.SummarizeStyle(ctx, posts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = s.db.UpsertStyleProfile(ctx, store.UpsertStyleProfileParams{
|
||||
UserID: author,
|
||||
Platform: "bluesky",
|
||||
Embedding: personaEmbedding,
|
||||
TopKeywords: topWords,
|
||||
StyleSummary: styleSummary,
|
||||
PostCount: int32(len(posts)),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if author == "" {
|
||||
author = args.Author
|
||||
}
|
||||
profile := &GetStyleProfileResponse{
|
||||
Author: author,
|
||||
PersonaEmbedding: personaEmbedding,
|
||||
TopKeywords: topWords,
|
||||
StyleSummary: styleSummary,
|
||||
PostCount: len(posts),
|
||||
SourcePlatform: "bluesky",
|
||||
}
|
||||
|
||||
profileBytes, err := json.Marshal(profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: string(profileBytes)},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func determinePlatform(author string) (platform, error) {
|
||||
if strings.HasPrefix(author, "did:") {
|
||||
return Bluesky, nil
|
||||
}
|
||||
if strings.HasPrefix(author, "npub") {
|
||||
return Nostr, nil
|
||||
}
|
||||
if strings.HasPrefix(author, "@") {
|
||||
if strings.Count(author, "@") > 1 {
|
||||
return ActivityPub, nil
|
||||
}
|
||||
|
||||
if strings.Contains(author, ".") {
|
||||
return Bluesky, nil
|
||||
}
|
||||
|
||||
return Twitter, nil
|
||||
}
|
||||
if len(author) == 64 {
|
||||
isHex := true
|
||||
for _, c := range author {
|
||||
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
|
||||
isHex = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if isHex {
|
||||
return Nostr, nil
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(author, ".") {
|
||||
return Bluesky, nil
|
||||
}
|
||||
|
||||
return -1, fmt.Errorf("unknown author format: %s", author)
|
||||
}
|
||||
|
||||
func settleHook(ctx context.Context, db *store.Queries, transaction, amount, asset, network, to, from string) error {
|
||||
ret, err := db.SaveTransaction(ctx, store.SaveTransactionParams{
|
||||
X402TransactionHash: transaction,
|
||||
Amount: amount,
|
||||
Asset: asset,
|
||||
Network: network,
|
||||
Recipient: to,
|
||||
Sender: from,
|
||||
})
|
||||
|
||||
var id int64 = 0
|
||||
if len(ret) > 0 {
|
||||
id = ret[0].ID
|
||||
}
|
||||
|
||||
fields := log.Fields{
|
||||
"transaction_id": id,
|
||||
"X402_transaction_hash": transaction,
|
||||
"amount": amount,
|
||||
"asset": asset,
|
||||
"network": network,
|
||||
"recipient": to,
|
||||
"sender": from,
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.WithFields(fields).ErrorWithContext(ctx, "Failed to save transaction", "value", ret, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.WithFields(fields).DebugWithContext(ctx, "Saved transaction")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) buildRequirements(amount string, extra map[string]any) []x402v2.PaymentRequirements {
|
||||
requirements := make([]x402v2.PaymentRequirements, 0, len(s.activeNetworks))
|
||||
|
||||
for _, info := range s.activeNetworks {
|
||||
mergedExtra := make(map[string]any)
|
||||
maps.Copy(mergedExtra, extra)
|
||||
maps.Copy(mergedExtra, info.Extra)
|
||||
requirements = append(requirements, x402v2.PaymentRequirements{
|
||||
Scheme: "exact",
|
||||
Network: info.Network,
|
||||
Asset: info.Asset,
|
||||
Amount: amount,
|
||||
PayTo: s.x402Wallet,
|
||||
MaxTimeoutSeconds: 60,
|
||||
Extra: mergedExtra,
|
||||
})
|
||||
}
|
||||
|
||||
return requirements
|
||||
}
|
||||
|
||||
func parseActiveNetworks(raw string) []string {
|
||||
parts := strings.Split(raw, ",")
|
||||
var slugs []string
|
||||
for _, part := range parts {
|
||||
trimmed := strings.ToLower(strings.TrimSpace(part))
|
||||
if trimmed != "" {
|
||||
slugs = append(slugs, trimmed)
|
||||
}
|
||||
}
|
||||
return slugs
|
||||
return s.metrics.MetricsText()
|
||||
}
|
||||
|
||||
func (s *Server) Start() error {
|
||||
//go s.startPostPruner()
|
||||
return s.mcpHandler.Start(s.listenAddress, s.permissions)
|
||||
}
|
||||
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
return s.mcpHandler.Shutdown(ctx)
|
||||
}
|
||||
|
|
|
|||
146
internal/server/server_init.go
Normal file
146
internal/server/server_init.go
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
log "github.com/nacorid/logger"
|
||||
"github.com/nacorid/naco-api/internal/config"
|
||||
"github.com/nacorid/naco-api/internal/store"
|
||||
"github.com/nacorid/naco-api/internal/x402"
|
||||
x402v2 "github.com/x402-foundation/x402/go/v2"
|
||||
x402http "github.com/x402-foundation/x402/go/v2/http/nethttp"
|
||||
)
|
||||
|
||||
// buildMiddleware creates the shared middleware chain and its stateful components.
|
||||
func buildMiddleware() (x402.X402Middlewares, *MetricsMiddleware) {
|
||||
rateLimits := NewRateLimitMiddleware(float64(1.0/10.0), 2, []string{"getFeed"})
|
||||
metrics := NewMetricsHook()
|
||||
logging := NewLoggingMiddleware()
|
||||
toolchain := x402.X402Middlewares{
|
||||
MCPMiddleware: []mcp.Middleware{
|
||||
logging.OnCall,
|
||||
metrics.OnCall,
|
||||
rateLimits.OnCall,
|
||||
},
|
||||
HTTPMiddleware: []func(http.Handler) http.Handler{
|
||||
logging.OnCallHTTP,
|
||||
metrics.OnCallHTTP,
|
||||
rateLimits.OnCallHTTP,
|
||||
},
|
||||
}
|
||||
return toolchain, metrics
|
||||
}
|
||||
|
||||
// resolveActiveNetworks parses ACTIVE_NETWORKS and resolves them against NetworkRegistry.
|
||||
func resolveActiveNetworks(ctx context.Context, raw string) []NetworkInfo {
|
||||
names := parseActiveNetworks(raw)
|
||||
var out []NetworkInfo
|
||||
for _, name := range names {
|
||||
if info, ok := NetworkRegistry[name]; ok {
|
||||
out = append(out, info)
|
||||
} else {
|
||||
log.WarnWithContext(ctx, "configured network is not defined in the registry", "name", name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// newX402Server creates the x402-enabled MCP server with settlement and webhook hooks.
|
||||
// db is the Store used to persist settlements; cfg provides wallet/webhook settings.
|
||||
func newX402Server(ctx context.Context, db *store.Queries, cfg *config.Config, toolchain x402.X402Middlewares, metrics *MetricsMiddleware) *x402.X402Server {
|
||||
auth, err := NewCDPAuth(cfg.ApiKey, cfg.ApiSecret)
|
||||
if err != nil {
|
||||
log.FatalWithContext(ctx, "failed to create CDP auth", "error", err)
|
||||
}
|
||||
|
||||
// Resolve active networks for x402 scheme registration
|
||||
activeNetworks := resolveActiveNetworks(ctx, cfg.ActiveNetworks)
|
||||
networkStrings := make([]string, len(activeNetworks))
|
||||
for i, net := range activeNetworks {
|
||||
networkStrings[i] = net.Network
|
||||
}
|
||||
|
||||
srv, err := x402.NewX402Server(
|
||||
"x402 enabled social media lookingglass for autonomous agents",
|
||||
"0.2.0",
|
||||
cfg.X402FacilitatorURL,
|
||||
auth,
|
||||
toolchain,
|
||||
GlobalLoggingMiddleware,
|
||||
func(w http.ResponseWriter, r *http.Request, resp *x402v2.SettleResponse) {
|
||||
requirements, ok := x402http.RequirementsFromContext(r.Context())
|
||||
if !ok {
|
||||
log.WarnWithContext(r.Context(), "failed to extract payment requirements from context during HTTP settlement hook")
|
||||
return
|
||||
}
|
||||
log.InfoWithContext(r.Context(), "persisting HTTP settlement",
|
||||
"transaction", resp.Transaction,
|
||||
"network", string(resp.Network),
|
||||
"payer", resp.Payer,
|
||||
"payTo", requirements.PayTo,
|
||||
"amount", requirements.Amount,
|
||||
"asset", requirements.Asset,
|
||||
)
|
||||
if err := persistSettlement(r.Context(), db, resp.Transaction, requirements.Amount, requirements.Asset, string(resp.Network), requirements.PayTo, resp.Payer); err != nil {
|
||||
log.WarnWithContext(r.Context(), "failed to persist HTTP settlement", "error", err)
|
||||
}
|
||||
},
|
||||
networkStrings,
|
||||
)
|
||||
if err != nil {
|
||||
log.FatalWithContext(ctx, "failed to create x402 server", "error", err)
|
||||
}
|
||||
|
||||
srv.OnAfterSettle(func(settleCtx x402v2.SettleResultContext) error {
|
||||
log.InfoWithContext(settleCtx.Ctx, "MCP settlement succeeded",
|
||||
"transaction", settleCtx.Result.Transaction,
|
||||
"network", string(settleCtx.Result.Network),
|
||||
"payer", settleCtx.Result.Payer,
|
||||
"payTo", settleCtx.Requirements.GetPayTo(),
|
||||
"amount", settleCtx.Requirements.GetAmount(),
|
||||
"asset", settleCtx.Requirements.GetAsset(),
|
||||
"scheme", settleCtx.Requirements.GetScheme(),
|
||||
)
|
||||
if err := persistSettlement(settleCtx.Ctx, db, settleCtx.Result.Transaction,
|
||||
settleCtx.Requirements.GetAmount(), settleCtx.Requirements.GetAsset(),
|
||||
string(settleCtx.Result.Network), settleCtx.Requirements.GetPayTo(), settleCtx.Result.Payer); err != nil {
|
||||
log.WarnWithContext(settleCtx.Ctx, "failed to persist settlement", "error", err)
|
||||
}
|
||||
|
||||
// Webhook is best-effort and non-blocking.
|
||||
go sendSettlementWebhook(settleCtx.Ctx, cfg, metrics, settleCtx.Result.Transaction)
|
||||
return nil
|
||||
})
|
||||
|
||||
return srv
|
||||
}
|
||||
|
||||
// persistSettlement is a thin wrapper around settleHook to keep call sites uniform.
|
||||
func persistSettlement(ctx context.Context, db *store.Queries, transaction, amount, asset, network, to, from string) error {
|
||||
return settleHook(ctx, db, transaction, amount, asset, network, to, from)
|
||||
}
|
||||
|
||||
// sendSettlementWebhook builds and sends the Matrix webhook payload for a settled transaction.
|
||||
func sendSettlementWebhook(ctx context.Context, cfg *config.Config, metrics *MetricsMiddleware, txHash string) {
|
||||
wh := newWebhookSender(cfg, metrics)
|
||||
if wh == nil {
|
||||
return
|
||||
}
|
||||
payload := map[string]any{
|
||||
"event": "transaction_success",
|
||||
"transaction": map[string]any{
|
||||
"x402_transaction_hash": txHash,
|
||||
},
|
||||
"metrics": map[string]any{
|
||||
"platform": "unknown",
|
||||
},
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
"server_id": cfg.MatrixServerID,
|
||||
}
|
||||
jsonBytes, _ := json.Marshal(payload)
|
||||
wh.Send(ctx, jsonBytes)
|
||||
}
|
||||
78
internal/server/settlement.go
Normal file
78
internal/server/settlement.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"maps"
|
||||
"strings"
|
||||
|
||||
log "github.com/nacorid/logger"
|
||||
"github.com/nacorid/naco-api/internal/store"
|
||||
x402v2 "github.com/x402-foundation/x402/go/v2"
|
||||
)
|
||||
|
||||
func settleHook(ctx context.Context, db *store.Queries, transaction, amount, asset, network, to, from string) error {
|
||||
ret, err := db.SaveTransaction(ctx, store.SaveTransactionParams{
|
||||
X402TransactionHash: transaction,
|
||||
Amount: amount,
|
||||
Asset: asset,
|
||||
Network: network,
|
||||
Recipient: to,
|
||||
Sender: from,
|
||||
})
|
||||
|
||||
var id int64 = 0
|
||||
if len(ret) > 0 {
|
||||
id = ret[0].ID
|
||||
}
|
||||
|
||||
fields := log.Fields{
|
||||
"transaction_id": id,
|
||||
"X402_transaction_hash": transaction,
|
||||
"amount": amount,
|
||||
"asset": asset,
|
||||
"network": network,
|
||||
"recipient": to,
|
||||
"sender": from,
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.WithFields(fields).ErrorWithContext(ctx, "Failed to save transaction", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.WithFields(fields).DebugWithContext(ctx, "Saved transaction")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) buildRequirements(amount string, extra map[string]any, scheme string, timeout int) []x402v2.PaymentRequirements {
|
||||
requirements := make([]x402v2.PaymentRequirements, 0, len(s.activeNetworks))
|
||||
|
||||
for _, info := range s.activeNetworks {
|
||||
mergedExtra := make(map[string]any)
|
||||
maps.Copy(mergedExtra, extra)
|
||||
maps.Copy(mergedExtra, info.Extra)
|
||||
requirements = append(requirements, x402v2.PaymentRequirements{
|
||||
Scheme: scheme,
|
||||
Network: info.Network,
|
||||
Asset: info.Asset,
|
||||
Amount: amount,
|
||||
PayTo: s.x402Wallet,
|
||||
MaxTimeoutSeconds: timeout,
|
||||
Extra: mergedExtra,
|
||||
})
|
||||
}
|
||||
|
||||
return requirements
|
||||
}
|
||||
|
||||
func parseActiveNetworks(raw string) []string {
|
||||
parts := strings.Split(raw, ",")
|
||||
var slugs []string
|
||||
for _, part := range parts {
|
||||
trimmed := strings.ToLower(strings.TrimSpace(part))
|
||||
if trimmed != "" {
|
||||
slugs = append(slugs, trimmed)
|
||||
}
|
||||
}
|
||||
return slugs
|
||||
}
|
||||
214
internal/server/storefake_test.go
Normal file
214
internal/server/storefake_test.go
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/nacorid/naco-api/internal/store"
|
||||
)
|
||||
|
||||
// fakeStore is an in-memory implementation of the Store interface used by handler
|
||||
// tests so db-backed tool paths (style profiles, analytics, similar users) can be
|
||||
// exercised without a live Postgres+pgvector.
|
||||
type fakeStore struct {
|
||||
analyticsCache map[cacheKey]cacheEntry
|
||||
analyticsPosts map[string]store.AnalyticsPost
|
||||
coverageRanges []store.AnalyticsCoverageRange
|
||||
styleProfiles map[string]store.UserStyleProfile
|
||||
stylePosts map[string]store.UserStylePost
|
||||
similarRows []store.FindSimilarUsersRow
|
||||
findSimilarErr error
|
||||
styleProfileErr error
|
||||
stylePostsErr error
|
||||
cacheErr bool
|
||||
postsRangeErr bool
|
||||
coverageErr bool
|
||||
}
|
||||
|
||||
type cacheKey struct {
|
||||
user, platform, tool, hash string
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
value json.RawMessage
|
||||
exp time.Time
|
||||
}
|
||||
|
||||
func newFakeStore() *fakeStore {
|
||||
return &fakeStore{
|
||||
analyticsCache: map[cacheKey]cacheEntry{},
|
||||
analyticsPosts: map[string]store.AnalyticsPost{},
|
||||
styleProfiles: map[string]store.UserStyleProfile{},
|
||||
stylePosts: map[string]store.UserStylePost{},
|
||||
}
|
||||
}
|
||||
|
||||
func postKey(user, platform, id string) string {
|
||||
return user + "|" + platform + "|" + id
|
||||
}
|
||||
|
||||
func profileKey(user, platform string) string {
|
||||
return user + "|" + platform
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetAnalyticsCache(ctx context.Context, arg store.GetAnalyticsCacheParams) (json.RawMessage, error) {
|
||||
if f.cacheErr {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
key := cacheKey{arg.UserID, arg.Platform, arg.ToolName, arg.TimeRangeHash}
|
||||
entry, ok := f.analyticsCache[key]
|
||||
if !ok || time.Now().After(entry.exp) {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
return entry.value, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) UpsertAnalyticsCache(ctx context.Context, arg store.UpsertAnalyticsCacheParams) error {
|
||||
key := cacheKey{arg.UserID, arg.Platform, arg.ToolName, arg.TimeRangeHash}
|
||||
f.analyticsCache[key] = cacheEntry{value: arg.Result, exp: arg.ExpiresAt}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetAnalyticsPostsInRange(ctx context.Context, arg store.GetAnalyticsPostsInRangeParams) ([]store.GetAnalyticsPostsInRangeRow, error) {
|
||||
if f.postsRangeErr {
|
||||
return nil, sql.ErrConnDone
|
||||
}
|
||||
var rows []store.GetAnalyticsPostsInRangeRow
|
||||
for _, p := range f.analyticsPosts {
|
||||
if p.UserID != arg.UserID || p.Platform != arg.Platform {
|
||||
continue
|
||||
}
|
||||
if p.PostTimestamp.Before(arg.PostTimestamp) || p.PostTimestamp.After(arg.PostTimestamp_2) {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, store.GetAnalyticsPostsInRangeRow{
|
||||
PostID: p.PostID,
|
||||
PostText: p.PostText,
|
||||
PostTimestamp: p.PostTimestamp,
|
||||
Likes: p.Likes,
|
||||
Reposts: p.Reposts,
|
||||
Replies: p.Replies,
|
||||
Quotes: p.Quotes,
|
||||
})
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool { return rows[i].PostTimestamp.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
|
||||
}
|
||||
360
internal/server/style.go
Normal file
360
internal/server/style.go
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"strings"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
log "github.com/nacorid/logger"
|
||||
"github.com/nacorid/naco-api/internal/clustering"
|
||||
"github.com/nacorid/naco-api/internal/store"
|
||||
"github.com/nacorid/naco-api/internal/utils"
|
||||
)
|
||||
|
||||
func (s *Server) getStyleProfile(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
var args GetStyleProfileRequest
|
||||
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
if args.Author == "" {
|
||||
return nil, fmt.Errorf("author must be a valid did or handle")
|
||||
}
|
||||
|
||||
profile, err := s.requestStyleProfile(ctx, args.Author)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Best-effort: also look for this author on every other platform in the background so
|
||||
// future requests can be served from cache. Failures are logged and never affect the
|
||||
// response. Uses a detached context so the work outlives the request.
|
||||
if s.enrichEnabled {
|
||||
if src, ok := platformFromString(profile.SourcePlatform); ok {
|
||||
go s.enrichCrossPlatform(context.WithoutCancel(ctx), src, args.Author)
|
||||
}
|
||||
}
|
||||
|
||||
profileBytes, err := json.Marshal(profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: string(profileBytes)},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// requestStyleProfile returns a style profile for author. It computes and persists the
|
||||
// profile only when a request is made AND the cached profile is older than a month (or
|
||||
// missing); otherwise it serves the cached copy. The prewarmer calls this same path, so a
|
||||
// pre-fetch is treated as a system-initiated request and obeys the identical update policy.
|
||||
func (s *Server) requestStyleProfile(ctx context.Context, author string) (*GetStyleProfileResponse, error) {
|
||||
platform, err := determinePlatform(author)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.requestStyleProfileForced(ctx, platform, author)
|
||||
}
|
||||
|
||||
// requestStyleProfileForced is the core of requestStyleProfile for an explicitly chosen
|
||||
// platform. It resolves the author id, serves a fresh cached profile, or builds (and
|
||||
// persists) a new one when the cache is stale/missing.
|
||||
func (s *Server) requestStyleProfileForced(ctx context.Context, platform platform, author string) (*GetStyleProfileResponse, error) {
|
||||
var authorID string
|
||||
switch platform {
|
||||
case Bluesky:
|
||||
did, rerr := s.bskyClient.ResolveUser(ctx, author)
|
||||
if rerr != nil {
|
||||
return nil, rerr
|
||||
}
|
||||
authorID = did.String()
|
||||
case ActivityPub, Nostr, Twitter:
|
||||
authorID = author
|
||||
default:
|
||||
return nil, fmt.Errorf("platform %q is not supported yet", platform)
|
||||
}
|
||||
|
||||
styleProfile, err := s.db.GetStyleProfile(ctx, store.GetStyleProfileParams{
|
||||
UserID: authorID,
|
||||
Platform: platform.String(),
|
||||
})
|
||||
if err == nil {
|
||||
isRecent := styleProfile.ModifiedAt.Valid && time.Since(styleProfile.ModifiedAt.Time) < 24*30*time.Hour
|
||||
if isRecent {
|
||||
embedding, perr := utils.ParseEmbedding(styleProfile.Embedding)
|
||||
if perr != nil {
|
||||
return nil, perr
|
||||
}
|
||||
return &GetStyleProfileResponse{
|
||||
Author: styleProfile.UserID,
|
||||
PersonaEmbedding: embedding,
|
||||
TopKeywords: styleProfile.TopKeywords,
|
||||
PostCount: int(styleProfile.PostCount),
|
||||
StyleSummary: styleProfile.StyleSummary,
|
||||
SourcePlatform: platform.String(),
|
||||
}, nil
|
||||
}
|
||||
} else if !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("failed to load cached style profile: %w", err)
|
||||
}
|
||||
|
||||
return s.buildStyleProfile(ctx, platform, authorID, author)
|
||||
}
|
||||
|
||||
// enrichCrossPlatform looks for author on every platform other than sourcePlatform, in the
|
||||
// background. It uses the same identifier on each platform (matching only where that handle
|
||||
// exists; mismatches fail gracefully and are skipped) and obeys the same 1-month update gate
|
||||
// as a live request. Concurrency is bounded to the other three platforms.
|
||||
func (s *Server) enrichCrossPlatform(ctx context.Context, sourcePlatform platform, author string) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if s.metrics != nil {
|
||||
s.metrics.RecordEnrichment()
|
||||
}
|
||||
|
||||
// Bound concurrent background enrichment so a burst of live requests can't overwhelm
|
||||
// Ollama. Block until a slot is free (or the context is cancelled).
|
||||
select {
|
||||
case s.enrichSem <- struct{}{}:
|
||||
defer func() { <-s.enrichSem }()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, p := range []platform{Bluesky, ActivityPub, Nostr, Twitter} {
|
||||
if p == sourcePlatform {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(p platform) {
|
||||
defer wg.Done()
|
||||
if _, err := s.requestStyleProfileForced(ctx, p, author); err != nil {
|
||||
log.DebugWithContext(ctx, "cross-platform enrichment skipped", "platform", p.String(), "author", author, "error", err)
|
||||
return
|
||||
}
|
||||
log.InfoWithContext(ctx, "cross-platform enrichment done", "platform", p.String(), "author", author)
|
||||
}(p)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (s *Server) getSimilarProfiles(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
var args FindSimilarUsersRequest
|
||||
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
if args.Author == "" {
|
||||
return nil, fmt.Errorf("author must be a valid did or handle")
|
||||
}
|
||||
if args.Limit <= 0 {
|
||||
args.Limit = 10
|
||||
}
|
||||
if args.Limit > 50 {
|
||||
args.Limit = 50
|
||||
}
|
||||
|
||||
profile, err := s.requestStyleProfile(ctx, args.Author)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
vec := utils.FormatVector(profile.PersonaEmbedding)
|
||||
rows, err := s.db.FindSimilarUsers(ctx, store.FindSimilarUsersParams{
|
||||
QueryEmbedding: vec,
|
||||
LimitVal: args.Limit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to search similar users: %w", err)
|
||||
}
|
||||
|
||||
matches := make([]SimilarUser, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
if r.UserID == profile.Author {
|
||||
continue
|
||||
}
|
||||
matches = append(matches, SimilarUser{
|
||||
Author: r.UserID,
|
||||
Platform: r.Platform,
|
||||
Similarity: float64(r.Similarity),
|
||||
TopKeywords: r.TopKeywords,
|
||||
StyleSummary: r.StyleSummary,
|
||||
PostCount: int(r.PostCount),
|
||||
})
|
||||
}
|
||||
|
||||
twitterPostCount := 0
|
||||
if profile.SourcePlatform == "Twitter" {
|
||||
twitterPostCount = profile.PostCount
|
||||
}
|
||||
respBytes, err := json.Marshal(FindSimilarUsersResponse{
|
||||
Matches: matches,
|
||||
TwitterPostCount: twitterPostCount,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: string(respBytes)},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) buildStyleProfile(ctx context.Context, platform platform, authorID, author string) (*GetStyleProfileResponse, error) {
|
||||
posts, err := s.fetchProfilePosts(ctx, platform, authorID, 50)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(posts) == 0 {
|
||||
return nil, fmt.Errorf("no posts found for author %s", author)
|
||||
}
|
||||
|
||||
// Drop posts without any text (e.g. Bluesky image/reply-only posts). Embedding them
|
||||
// yields empty vectors, which would poison the persona-average and the persisted
|
||||
// profile.
|
||||
filtered := posts[:0]
|
||||
for _, p := range posts {
|
||||
if strings.TrimSpace(p.Text) == "" {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, p)
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return nil, fmt.Errorf("no posts found for author %s", author)
|
||||
}
|
||||
posts = filtered
|
||||
|
||||
// Delete old posts for non-Twitter platforms before rebuild
|
||||
if platform != Twitter {
|
||||
if err := s.db.DeleteStylePosts(ctx, store.DeleteStylePostsParams{
|
||||
UserID: authorID,
|
||||
Platform: platform.String(),
|
||||
}); err != nil {
|
||||
log.WarnWithContext(ctx, "failed to delete old style posts", "author", authorID, "platform", platform, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Extract texts for embedding and keyword analysis
|
||||
postTexts := make([]string, len(posts))
|
||||
for i, p := range posts {
|
||||
postTexts[i] = p.Text
|
||||
}
|
||||
|
||||
// Fail fast on any server-side error. These are payable tools: returning a degraded
|
||||
// profile here would bill the caller for a deliverable they didn't ask for. The x402
|
||||
// layer only settles payment when the handler succeeds, so returning the error means
|
||||
// the caller is not charged.
|
||||
embeddings, err := s.ollama.EmbedTexts(ctx, posts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
personaEmbedding := utils.AverageVector(embeddings)
|
||||
topWords := utils.TopWords(postTexts, make(map[string]struct{}), 10)
|
||||
styleSummary, err := s.ollama.SummarizeStyle(ctx, posts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = s.db.UpsertStyleProfile(ctx, store.UpsertStyleProfileParams{
|
||||
UserID: authorID,
|
||||
Platform: platform.String(),
|
||||
Embedding: utils.FormatVector(personaEmbedding),
|
||||
TopKeywords: topWords,
|
||||
StyleSummary: styleSummary,
|
||||
PostCount: int32(len(posts)),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &GetStyleProfileResponse{
|
||||
Author: authorID,
|
||||
PersonaEmbedding: personaEmbedding,
|
||||
TopKeywords: topWords,
|
||||
StyleSummary: styleSummary,
|
||||
PostCount: len(posts),
|
||||
SourcePlatform: platform.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) fetchProfilePosts(ctx context.Context, platform platform, author string, limit int) ([]clustering.Item, error) {
|
||||
platformStr := platform.String()
|
||||
|
||||
// 1. Try cache first
|
||||
cached, err := s.db.GetStylePosts(ctx, store.GetStylePostsParams{
|
||||
UserID: author,
|
||||
Platform: platformStr,
|
||||
Limit: int32(limit),
|
||||
})
|
||||
if err == nil && len(cached) >= limit {
|
||||
posts := make([]clustering.Item, len(cached))
|
||||
for i, c := range cached {
|
||||
posts[i] = clustering.Item{ID: c.PostID, Text: c.PostText}
|
||||
}
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
// 2. Fetch from platform
|
||||
var freshPosts []clustering.Item
|
||||
switch platform {
|
||||
case Bluesky:
|
||||
res, err := s.bskyClient.GetLatestNPosts(ctx, author, limit)
|
||||
if err != nil {
|
||||
return toItems(cached), err
|
||||
}
|
||||
freshPosts = blueskyFeedViewsToItems(res)
|
||||
case ActivityPub:
|
||||
res, err := s.apClient.GetLatestNPosts(ctx, author, limit)
|
||||
if err != nil {
|
||||
return toItems(cached), err
|
||||
}
|
||||
freshPosts = apPostsPtrToItems(res)
|
||||
case Nostr:
|
||||
res, err := s.nostrClient.GetLatestNPosts(ctx, author, limit)
|
||||
if err != nil {
|
||||
return toItems(cached), err
|
||||
}
|
||||
freshPosts = nostrPostsToItems(res)
|
||||
case Twitter:
|
||||
res, err := s.twitterClient.GetLatestNPosts(ctx, author, limit)
|
||||
if err != nil {
|
||||
return toItems(cached), err
|
||||
}
|
||||
freshPosts = twitterPostsToItems(res)
|
||||
default:
|
||||
return nil, fmt.Errorf("platform %q is not supported yet", platform)
|
||||
}
|
||||
|
||||
// 3. Upsert to cache (batch)
|
||||
for _, post := range freshPosts {
|
||||
if err := s.db.UpsertStylePosts(ctx, store.UpsertStylePostsParams{
|
||||
UserID: author,
|
||||
Platform: platformStr,
|
||||
PostID: post.ID,
|
||||
PostText: post.Text,
|
||||
}); err != nil {
|
||||
log.WarnWithContext(ctx, "failed to cache style post", "author", author, "platform", platform, "postID", post.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return freshPosts, nil
|
||||
}
|
||||
|
||||
func toItems(rows []store.GetStylePostsRow) []clustering.Item {
|
||||
items := make([]clustering.Item, len(rows))
|
||||
for i, r := range rows {
|
||||
items[i] = clustering.Item{ID: r.PostID, Text: r.PostText}
|
||||
}
|
||||
return items
|
||||
}
|
||||
375
internal/server/styleprofile_test.go
Normal file
375
internal/server/styleprofile_test.go
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bluesky-social/indigo/api/bsky"
|
||||
"github.com/nacorid/naco-api/internal/activitypub"
|
||||
"github.com/nacorid/naco-api/internal/store"
|
||||
)
|
||||
|
||||
// ---- getStyleProfile ----
|
||||
|
||||
func TestGetStyleProfileBuildsFresh(t *testing.T) {
|
||||
ap := &fakeAP{posts: []*activitypub.Post{
|
||||
{Author: "@a@mastodon.social", Content: "alpha post one", CreatedAt: time.Now()},
|
||||
{Author: "@a@mastodon.social", Content: "alpha post two", CreatedAt: time.Now()},
|
||||
}}
|
||||
st := newFakeStore()
|
||||
oll := &fakeOllama{embeddings: [][]float64{{0.1, 0.2}, {0.3, 0.4}}}
|
||||
s := newServerWith(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}, st, oll)
|
||||
|
||||
res, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "@a@mastodon.social"})
|
||||
if err != nil {
|
||||
t.Fatalf("getStyleProfile error: %v", err)
|
||||
}
|
||||
var out GetStyleProfileResponse
|
||||
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if out.Author != "@a@mastodon.social" {
|
||||
t.Fatalf("expected author, got %q", out.Author)
|
||||
}
|
||||
if out.PostCount != 2 {
|
||||
t.Fatalf("expected post count 2, got %d", out.PostCount)
|
||||
}
|
||||
if out.SourcePlatform != "ActivityPub" {
|
||||
t.Fatalf("expected ActivityPub source, got %q", out.SourcePlatform)
|
||||
}
|
||||
if oll.embedCalls != 1 || oll.summCalls != 1 {
|
||||
t.Fatalf("expected 1 embed and 1 summarize call, got %d/%d", oll.embedCalls, oll.summCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStyleProfileServesFreshCache(t *testing.T) {
|
||||
// A profile modified less than the 30-day stale threshold is served without hitting the
|
||||
// backend or ollama.
|
||||
st := newFakeStore()
|
||||
st.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{
|
||||
UserID: "@a@mastodon.social",
|
||||
Platform: "ActivityPub",
|
||||
Embedding: "[0.1,0.2]",
|
||||
TopKeywords: []string{"alpha"},
|
||||
StyleSummary: "cached summary",
|
||||
PostCount: 5,
|
||||
ModifiedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true},
|
||||
}
|
||||
ap := &fakeAP{}
|
||||
oll := &fakeOllama{}
|
||||
s := newServerWith(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}, st, oll)
|
||||
|
||||
res, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "@a@mastodon.social"})
|
||||
if err != nil {
|
||||
t.Fatalf("getStyleProfile error: %v", err)
|
||||
}
|
||||
var out GetStyleProfileResponse
|
||||
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if out.StyleSummary != "cached summary" {
|
||||
t.Fatalf("expected cached summary, got %q", out.StyleSummary)
|
||||
}
|
||||
if ap.calls != 0 {
|
||||
t.Fatalf("expected no backend call on cache hit, got %d", ap.calls)
|
||||
}
|
||||
if oll.summCalls != 0 {
|
||||
t.Fatalf("expected no ollama call on cache hit, got %d", oll.summCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStyleProfileRebuildsStale(t *testing.T) {
|
||||
// A stale (older than a month) cached profile triggers a rebuild.
|
||||
st := newFakeStore()
|
||||
st.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{
|
||||
UserID: "@a@mastodon.social",
|
||||
Platform: "ActivityPub",
|
||||
Embedding: "[0.1,0.2]",
|
||||
TopKeywords: []string{"old"},
|
||||
StyleSummary: "stale summary",
|
||||
PostCount: 5,
|
||||
ModifiedAt: sql.NullTime{Time: time.Now().Add(-31 * 24 * time.Hour), Valid: true},
|
||||
}
|
||||
ap := &fakeAP{posts: []*activitypub.Post{
|
||||
{Author: "@a@mastodon.social", Content: "fresh post", CreatedAt: time.Now()},
|
||||
}}
|
||||
s := newServerWith(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{})
|
||||
|
||||
res, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "@a@mastodon.social"})
|
||||
if err != nil {
|
||||
t.Fatalf("getStyleProfile error: %v", err)
|
||||
}
|
||||
var out GetStyleProfileResponse
|
||||
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if out.StyleSummary == "stale summary" {
|
||||
t.Fatal("expected rebuild to replace stale summary")
|
||||
}
|
||||
if out.PostCount != 1 {
|
||||
t.Fatalf("expected rebuilt post count 1, got %d", out.PostCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStyleProfileDBErrorIsNotNoRows(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
st.styleProfileErr = fmt.Errorf("db down")
|
||||
s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{})
|
||||
if _, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "@a@mastodon.social"}); err == nil || !strings.Contains(err.Error(), "db down") {
|
||||
t.Fatalf("expected db error propagation, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStyleProfileNoPostsFound(t *testing.T) {
|
||||
// Empty backend result while building leads to a "no posts found" error.
|
||||
ap := &fakeAP{} // no posts
|
||||
s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{})
|
||||
_, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "@a@mastodon.social"})
|
||||
if err == nil || !strings.Contains(err.Error(), "no posts found") {
|
||||
t.Fatalf("expected no-posts error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStyleProfileOllamaEmbedFailure(t *testing.T) {
|
||||
ap := &fakeAP{posts: []*activitypub.Post{{ID: "p1", Author: "@a@mastodon.social", Content: "post", CreatedAt: time.Now()}}}
|
||||
oll := &fakeOllama{embedErr: fmt.Errorf("embedding failed")}
|
||||
st := newFakeStore()
|
||||
s := newServerWith(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}, st, oll)
|
||||
if _, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "@a@mastodon.social"}); err == nil || !strings.Contains(err.Error(), "embedding") {
|
||||
t.Fatalf("expected embed failure, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStyleProfileBlueskyResolution(t *testing.T) {
|
||||
// Bluesky resolves the did before building/persisting.
|
||||
bsky := &fakeBluesky{feedViews: []*bsky.FeedDefs_FeedViewPost{{Post: bskyPostView("bsky post")}}}
|
||||
oll := &fakeOllama{embeddings: [][]float64{{0.1, 0.2}}}
|
||||
st := newFakeStore()
|
||||
s := newServerWith(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, oll)
|
||||
res, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "did:plc:abc"})
|
||||
if err != nil {
|
||||
t.Fatalf("getStyleProfile error: %v", err)
|
||||
}
|
||||
var out GetStyleProfileResponse
|
||||
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if out.Author != "did:plc:abc" {
|
||||
t.Fatalf("expected resolved did, got %q", out.Author)
|
||||
}
|
||||
if out.SourcePlatform != "Bluesky" {
|
||||
t.Fatalf("expected Bluesky source, got %q", out.SourcePlatform)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStyleProfileFirstEmbeddingEmpty(t *testing.T) {
|
||||
// Regression: an empty embedding returned by Ollama for a non-first post used to
|
||||
// panic AverageVector when the first vector was also empty (accumulator sized to
|
||||
// length 0, later non-empty vector indexed it). Empty vectors are skipped.
|
||||
bsky := &fakeBluesky{feedViews: []*bsky.FeedDefs_FeedViewPost{
|
||||
{Post: bskyPostView("first post")},
|
||||
{Post: bskyPostView("second post")},
|
||||
}}
|
||||
oll := &fakeOllama{embeddings: [][]float64{{}, {0.1, 0.2}}}
|
||||
st := newFakeStore()
|
||||
s := newServerWith(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, oll)
|
||||
|
||||
res, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "did:plc:abc"})
|
||||
if err != nil {
|
||||
t.Fatalf("getStyleProfile error: %v", err)
|
||||
}
|
||||
var out GetStyleProfileResponse
|
||||
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(out.PersonaEmbedding, []float64{0.1, 0.2}) {
|
||||
t.Fatalf("expected averaged embedding {0.1,0.2}, got %v", out.PersonaEmbedding)
|
||||
}
|
||||
if out.PostCount != 2 {
|
||||
t.Fatalf("expected post count 2, got %d", out.PostCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStyleProfileSkipsEmptyTextPosts(t *testing.T) {
|
||||
// A Bluesky post with no text (image/reply-only) must not reach Ollama: its empty
|
||||
// embedding would poison the persona average. It is dropped before embedding.
|
||||
bsky := &fakeBluesky{feedViews: []*bsky.FeedDefs_FeedViewPost{
|
||||
{Post: bskyPostView("")},
|
||||
{Post: bskyPostView("real post")},
|
||||
}}
|
||||
oll := &fakeOllama{}
|
||||
st := newFakeStore()
|
||||
s := newServerWith(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, oll)
|
||||
|
||||
res, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "did:plc:abc"})
|
||||
if err != nil {
|
||||
t.Fatalf("getStyleProfile error: %v", err)
|
||||
}
|
||||
var out GetStyleProfileResponse
|
||||
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if out.PostCount != 1 {
|
||||
t.Fatalf("expected filtered post count 1, got %d", out.PostCount)
|
||||
}
|
||||
if len(out.PersonaEmbedding) != 2 {
|
||||
t.Fatalf("expected 2-dim embedding, got %v", out.PersonaEmbedding)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- findSimilarUsers ----
|
||||
|
||||
func TestFindSimilarUsers(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
st.similarRows = []store.FindSimilarUsersRow{
|
||||
{UserID: "did:plc:other", Platform: "Bluesky", TopKeywords: []string{"x"}, StyleSummary: "s", PostCount: 3, Similarity: 90},
|
||||
}
|
||||
// Serve a fresh cached profile so requestStyleProfile doesn't need posts.
|
||||
st.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{
|
||||
UserID: "@a@mastodon.social",
|
||||
Platform: "ActivityPub",
|
||||
Embedding: "[0.1,0.2]",
|
||||
ModifiedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true},
|
||||
}
|
||||
s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{})
|
||||
res, err := callTool(s, "findSimilarUsers", FindSimilarUsersRequest{Author: "@a@mastodon.social", Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("findSimilarUsers error: %v", err)
|
||||
}
|
||||
var out FindSimilarUsersResponse
|
||||
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(out.Matches) != 1 {
|
||||
t.Fatalf("expected 1 match, got %d", len(out.Matches))
|
||||
}
|
||||
if out.Matches[0].Author != "did:plc:other" {
|
||||
t.Fatalf("unexpected match: %+v", out.Matches[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindSimilarUsersExcludesSelf(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
// Both rows share the query author's user ID; the self row must be excluded.
|
||||
st.similarRows = []store.FindSimilarUsersRow{
|
||||
{UserID: "@a@mastodon.social", Platform: "ActivityPub"}, // same as query author
|
||||
{UserID: "did:plc:other", Platform: "Bluesky", Similarity: 80},
|
||||
}
|
||||
st.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{
|
||||
UserID: "@a@mastodon.social",
|
||||
Platform: "ActivityPub",
|
||||
Embedding: "[0.1,0.2]",
|
||||
ModifiedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true},
|
||||
}
|
||||
s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{})
|
||||
res, err := callTool(s, "findSimilarUsers", FindSimilarUsersRequest{Author: "@a@mastodon.social", Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("findSimilarUsers error: %v", err)
|
||||
}
|
||||
var out FindSimilarUsersResponse
|
||||
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(out.Matches) != 1 {
|
||||
t.Fatalf("expected self to be excluded, got %d matches: %+v", len(out.Matches), out.Matches)
|
||||
}
|
||||
if out.Matches[0].Author != "did:plc:other" {
|
||||
t.Fatalf("expected only the non-self match, got %+v", out.Matches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindSimilarUsersEmptyMatches(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
st.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{
|
||||
UserID: "@a@mastodon.social",
|
||||
Platform: "ActivityPub",
|
||||
Embedding: "[0.1,0.2]",
|
||||
ModifiedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true},
|
||||
}
|
||||
s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{})
|
||||
res, err := callTool(s, "findSimilarUsers", FindSimilarUsersRequest{Author: "@a@mastodon.social"})
|
||||
if err != nil {
|
||||
t.Fatalf("findSimilarUsers error: %v", err)
|
||||
}
|
||||
var out FindSimilarUsersResponse
|
||||
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(out.Matches) != 0 {
|
||||
t.Fatalf("expected empty matches, got %d", len(out.Matches))
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindSimilarUsersTwitterPostCount verifies the response reports how many
|
||||
// posts went into the query author's style profile when that author is on
|
||||
// Twitter, so upto-scheme settlement can charge the realized Twitter API costs.
|
||||
func TestFindSimilarUsersTwitterPostCount(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
st.similarRows = []store.FindSimilarUsersRow{
|
||||
{UserID: "did:plc:other", Platform: "Bluesky", Similarity: 80},
|
||||
}
|
||||
// Serve a fresh cached Twitter profile with 42 analyzed posts.
|
||||
st.styleProfiles["@alice|Twitter"] = store.UserStyleProfile{
|
||||
UserID: "@alice",
|
||||
Platform: "Twitter",
|
||||
Embedding: "[0.1,0.2]",
|
||||
PostCount: 42,
|
||||
ModifiedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true},
|
||||
}
|
||||
s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{})
|
||||
res, err := callTool(s, "findSimilarUsers", FindSimilarUsersRequest{Author: "@alice"})
|
||||
if err != nil {
|
||||
t.Fatalf("findSimilarUsers error: %v", err)
|
||||
}
|
||||
var out FindSimilarUsersResponse
|
||||
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if out.TwitterPostCount != 42 {
|
||||
t.Fatalf("expected TwitterPostCount 42, got %d", out.TwitterPostCount)
|
||||
}
|
||||
|
||||
// A non-Twitter query author reports 0.
|
||||
st2 := newFakeStore()
|
||||
st2.similarRows = []store.FindSimilarUsersRow{
|
||||
{UserID: "did:plc:other", Platform: "Bluesky", Similarity: 80},
|
||||
}
|
||||
st2.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{
|
||||
UserID: "@a@mastodon.social",
|
||||
Platform: "ActivityPub",
|
||||
Embedding: "[0.1,0.2]",
|
||||
PostCount: 9,
|
||||
ModifiedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true},
|
||||
}
|
||||
s2 := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st2, &fakeOllama{})
|
||||
res2, err := callTool(s2, "findSimilarUsers", FindSimilarUsersRequest{Author: "@a@mastodon.social"})
|
||||
if err != nil {
|
||||
t.Fatalf("findSimilarUsers error: %v", err)
|
||||
}
|
||||
var out2 FindSimilarUsersResponse
|
||||
if err := jsonUnmarshal(t, textOf(t, res2), &out2); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if out2.TwitterPostCount != 0 {
|
||||
t.Fatalf("expected TwitterPostCount 0 for ActivityPub, got %d", out2.TwitterPostCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindSimilarUsersDBError(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
st.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{
|
||||
UserID: "@a@mastodon.social",
|
||||
Platform: "ActivityPub",
|
||||
Embedding: "[0.1,0.2]",
|
||||
ModifiedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true},
|
||||
}
|
||||
st.findSimilarErr = fmt.Errorf("query failed")
|
||||
s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{})
|
||||
if _, err := callTool(s, "findSimilarUsers", FindSimilarUsersRequest{Author: "@a@mastodon.social"}); err == nil || !strings.Contains(err.Error(), "similar") {
|
||||
t.Fatalf("expected similar-users error, got %v", err)
|
||||
}
|
||||
}
|
||||
501
internal/server/tools.go
Normal file
501
internal/server/tools.go
Normal file
|
|
@ -0,0 +1,501 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"github.com/nacorid/naco-api/internal/x402"
|
||||
)
|
||||
|
||||
// registerTools registers all MCP tools with their x402 payment requirements.
|
||||
// The payable tools are defined in a table to avoid repeating Schema construction.
|
||||
func (s *Server) registerTools() {
|
||||
// Helper to build the x402 extra map from input/output BodyFields.
|
||||
schemaExtra := func(input, output map[string]any) map[string]any {
|
||||
return map[string]any{
|
||||
"OutputSchema": Schema{
|
||||
Input: InputSchema{
|
||||
Type: "HTTP",
|
||||
Method: "POST",
|
||||
Discoverable: true,
|
||||
BodyFields: input,
|
||||
},
|
||||
Output: output,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
analyticsInput := map[string]any{
|
||||
"author": Bodyfield{Type: "string", Description: "Did or handle of an author", Required: true},
|
||||
"startTime": Bodyfield{Type: "string", Description: "Start time in RFC3339 format (default: 30 days ago)", Required: false},
|
||||
"endTime": Bodyfield{Type: "string", Description: "End time in RFC3339 format (default: now)", Required: false},
|
||||
"limit": Bodyfield{Type: "integer", Description: "Maximum number of posts to analyze (default: 50, max: 200)", Required: false},
|
||||
"platform": Bodyfield{Type: "string", Description: "Platform to filter (default: all)", Required: false},
|
||||
}
|
||||
|
||||
type payableDef struct {
|
||||
Name string
|
||||
Desc string
|
||||
Input any
|
||||
Output any
|
||||
Handler func(context.Context, *mcp.CallToolRequest) (*mcp.CallToolResult, error)
|
||||
Price string
|
||||
MaxTimeoutSeconds int
|
||||
// UsesTwitter marks tools that may read posts (or set up lookups)
|
||||
// through the Twitter API and return only transformative/aggregated
|
||||
// results, as permitted by the X API policy. With Twitter enabled these
|
||||
// tools charge upto-scheme: base price plus the realized Twitter API
|
||||
// costs. Tools that resell raw post/profile/feed data (getPost,
|
||||
// getLatestPosts, ...) never set this.
|
||||
UsesTwitter bool
|
||||
// TwitterPostCap is the post count used to compute the advertised upto
|
||||
// maximum (see x402.MaxTwitterCostMicro). It only bounds the largest
|
||||
// settlement the tool can demand; the actual charge is settled from the
|
||||
// response.
|
||||
TwitterPostCap int64
|
||||
Extra map[string]any
|
||||
}
|
||||
|
||||
payable := []payableDef{
|
||||
{
|
||||
Name: "getPost", Desc: "Get a specific post from a specific actor",
|
||||
Input: GetPostRequest{}, Output: GetPostResponsePost{}, Handler: s.getPost, Price: "0.01",
|
||||
Extra: schemaExtra(
|
||||
map[string]any{
|
||||
"author": Bodyfield{Type: "string", Description: "Did or handle of an author", Required: true},
|
||||
"rkey": Bodyfield{Type: "string", Description: "Record key of the post", Required: true},
|
||||
"collection": Bodyfield{Type: "string", Description: "Collection the post belongs to (Bluesky only). Defaults to 'app.bsky.feed.post'", Required: false},
|
||||
},
|
||||
map[string]any{
|
||||
"author": Bodyfield{Type: "string", Description: "Did of the author"},
|
||||
"post": Bodyfield{Type: "object", Description: "The post object. Includes the post content 'content', like 'likes', quote 'quotes', replies 'replies' and repost 'reposts' count"},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
Name: "getLatestPosts", Desc: "Get the latest n posts from a specific actor",
|
||||
Input: GetLatestPostsRequest{}, Output: GetLatestPostsResponse{}, Handler: s.getLatestPosts, Price: "0.10",
|
||||
Extra: schemaExtra(
|
||||
map[string]any{
|
||||
"author": Bodyfield{Type: "string", Description: "Did or handle of an author", Required: true},
|
||||
"n": Bodyfield{Type: "integer", Description: "Number of posts to retrieve, maximum 50", Required: true},
|
||||
},
|
||||
map[string]any{
|
||||
"author": Bodyfield{Type: "string", Description: "Did of an actor"},
|
||||
"posts": Bodyfield{Type: "array", Description: "Array of post objects. Each includes the post content 'content', like 'likes', quote 'quotes', replies 'replies' and repost 'reposts' count"},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
Name: "getPostsInTime", Desc: "Get posts from a specific actor in a time range",
|
||||
Input: GetPostsInTimeRequest{}, Output: GetPostsInTimeResponse{}, Handler: s.getPostsInTime, Price: "0.10",
|
||||
Extra: schemaExtra(
|
||||
map[string]any{
|
||||
"author": Bodyfield{Type: "string", Description: "Did or handle of an author", Required: true},
|
||||
"start": Bodyfield{Type: "string", Description: "Start time in RFC3339 format", Required: true},
|
||||
"end": Bodyfield{Type: "string", Description: "End time in RFC3339 format", Required: true},
|
||||
"n": Bodyfield{Type: "integer", Description: "Maximum number of posts to retrieve, maximum 50", Required: true},
|
||||
},
|
||||
map[string]any{
|
||||
"author": Bodyfield{Type: "string", Description: "Did of an actor"},
|
||||
"posts": Bodyfield{Type: "array", Description: "Array of post objects. Each includes the post content 'content', like 'likes', quote 'quotes', replies 'replies' and repost 'reposts' count"},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
// base price is $0.25 (x402.BaseProfileCostMicro). With Twitter
|
||||
// enabled the tool is advertised as upto at the maximum possible
|
||||
// cost - $0.25 base + $0.01 lookup + $0.005/post at the 100-post
|
||||
// cap = $0.76 (x402.MaxTwitterCostMicro(BaseProfileCostMicro,
|
||||
// MaxProfileCountedPosts)) - and the actual charge is settled from
|
||||
// the posts read via x402.NewStyleProfileCostResolver.
|
||||
Name: "getStyleProfile", Desc: "Get an actor's style Profile",
|
||||
Input: GetStyleProfileRequest{}, Output: GetStyleProfileResponse{}, Handler: s.getStyleProfile, Price: "0.25",
|
||||
MaxTimeoutSeconds: 600,
|
||||
UsesTwitter: true, TwitterPostCap: x402.MaxProfileCountedPosts,
|
||||
Extra: schemaExtra(
|
||||
map[string]any{
|
||||
"author": Bodyfield{Type: "string", Description: "Did or handle of an author", Required: true},
|
||||
},
|
||||
map[string]any{
|
||||
"PersonaEmbedding": Bodyfield{Type: "array", Description: "Embedding vector representing the actor's persona"},
|
||||
"TopKeywords": Bodyfield{Type: "array", Description: "Top words used by the actor"},
|
||||
"StyleSummary": Bodyfield{Type: "string", Description: "Summary of the actor's writing style"},
|
||||
"PostCount": Bodyfield{Type: "integer", Description: "Number of posts analyzed"},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
Name: "findSimilarUsers", Desc: "Find authors with a similar writing style to the given author, across all platforms",
|
||||
Input: FindSimilarUsersRequest{}, Output: FindSimilarUsersResponse{}, Handler: s.getSimilarProfiles, Price: "0.10",
|
||||
UsesTwitter: true, TwitterPostCap: x402.MaxProfileCountedPosts,
|
||||
Extra: schemaExtra(
|
||||
map[string]any{
|
||||
"author": Bodyfield{Type: "string", Description: "Did or handle of an author", Required: true},
|
||||
},
|
||||
map[string]any{
|
||||
"matches": Bodyfield{Type: "array", Description: "Ranked list of similar profiles (author, platform, similarity, top keywords, style summary, post count)"},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
Name: "getActor", Desc: "Get an actor's public profile (bio, avatar, follower counts) on the platform inferred from the identifier",
|
||||
Input: GetActorRequest{}, Output: GetActorResponse{}, Handler: s.getActor, Price: "0.01",
|
||||
Extra: schemaExtra(
|
||||
map[string]any{
|
||||
"author": Bodyfield{Type: "string", Description: "Did, handle, npub or @handle of an actor", Required: true},
|
||||
},
|
||||
map[string]any{
|
||||
"actor": Bodyfield{Type: "object", Description: "Profile of the actor (platform, handle, displayName, avatarUrl, bio, followersCount, followingCount, postCount, url)"},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
Name: "searchActors", Desc: "Search for actors by handle/name/keyword across all platforms",
|
||||
Input: SearchActorsRequest{}, Output: SearchActorsResponse{}, Handler: s.searchActors, Price: "0.01",
|
||||
Extra: schemaExtra(
|
||||
map[string]any{
|
||||
"query": Bodyfield{Type: "string", Description: "Search query (handle, name or keyword)", Required: true},
|
||||
"limit": Bodyfield{Type: "integer", Description: "Maximum number of results (default 10, max 50)", Required: false},
|
||||
},
|
||||
map[string]any{
|
||||
"results": Bodyfield{Type: "array", Description: "Ranked list of matching actor profiles across platforms"},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
Name: "getEngagementMetrics", Desc: "Get time-series engagement metrics (likes, reposts, replies, quotes) per post",
|
||||
Input: AnalyticsRequest{}, Output: EngagementMetricsResponse{}, Handler: s.getEngagementMetrics, Price: "0.15",
|
||||
UsesTwitter: true, TwitterPostCap: x402.MaxAnalyticsCountedPosts,
|
||||
Extra: schemaExtra(analyticsInput, map[string]any{
|
||||
"author": Bodyfield{Type: "string", Description: "Author identifier"},
|
||||
"metrics": Bodyfield{Type: "array", Description: "Engagement metrics per post"},
|
||||
}),
|
||||
},
|
||||
{
|
||||
Name: "getPostingPatterns", Desc: "Get posting heatmap and patterns (hour-of-day x day-of-week)",
|
||||
Input: AnalyticsRequest{}, Output: PostingPatternsResponse{}, Handler: s.getPostingPatterns, Price: "0.10",
|
||||
UsesTwitter: true, TwitterPostCap: x402.MaxAnalyticsCountedPosts,
|
||||
Extra: schemaExtra(analyticsInput, map[string]any{
|
||||
"author": Bodyfield{Type: "string", Description: "Author identifier"},
|
||||
"heatmap": Bodyfield{Type: "array", Description: "24x7 matrix: hour (0-23) x day of week (0-6 = Sun-Sat)"},
|
||||
"peakHours": Bodyfield{Type: "array", Description: "Top 3 hours with most posts (0-23)"},
|
||||
"peakDays": Bodyfield{Type: "array", Description: "Top 3 days with most posts (0-6 = Sun-Sat)"},
|
||||
"avgPostsPerDay": Bodyfield{Type: "number", Description: "Average posts per day"},
|
||||
"totalPosts": Bodyfield{Type: "integer", Description: "Total posts in time range"},
|
||||
"postingDays": Bodyfield{Type: "integer", Description: "Number of days with at least one post"},
|
||||
}),
|
||||
},
|
||||
{
|
||||
Name: "getTopicModeling", Desc: "Get LLM-based topic extraction and clustering from posts",
|
||||
Input: AnalyticsRequest{}, Output: TopicModelingResponse{}, Handler: s.getTopicModeling, Price: "0.20",
|
||||
UsesTwitter: true, TwitterPostCap: x402.MaxAnalyticsCountedPosts,
|
||||
Extra: schemaExtra(analyticsInput, map[string]any{
|
||||
"author": Bodyfield{Type: "string", Description: "Author identifier"},
|
||||
"topics": Bodyfield{Type: "array", Description: "Identified topic clusters"},
|
||||
}),
|
||||
},
|
||||
{
|
||||
Name: "getSentimentTrends", Desc: "Get sentiment analysis and emotional arcs over time",
|
||||
Input: AnalyticsRequest{}, Output: SentimentTrendsResponse{}, Handler: s.getSentimentTrends, Price: "0.15",
|
||||
UsesTwitter: true, TwitterPostCap: x402.MaxAnalyticsCountedPosts,
|
||||
Extra: schemaExtra(analyticsInput, map[string]any{
|
||||
"author": Bodyfield{Type: "string", Description: "Author identifier"},
|
||||
"timeSeries": Bodyfield{Type: "array", Description: "Sentiment over time"},
|
||||
"overallSentiment": Bodyfield{Type: "object", Description: "Overall sentiment summary"},
|
||||
"dominantEmotions": Bodyfield{Type: "array", Description: "Most frequent emotions"},
|
||||
"sentimentShifts": Bodyfield{Type: "array", Description: "Significant sentiment changes"},
|
||||
}),
|
||||
},
|
||||
{
|
||||
Name: "getEngagementDrivers", Desc: "Get topics/keywords that correlate with high engagement",
|
||||
Input: AnalyticsRequest{}, Output: EngagementDriversResponse{}, Handler: s.getEngagementDrivers, Price: "0.20",
|
||||
UsesTwitter: true, TwitterPostCap: x402.MaxAnalyticsCountedPosts,
|
||||
Extra: schemaExtra(analyticsInput, map[string]any{
|
||||
"author": Bodyfield{Type: "string", Description: "Author identifier"},
|
||||
"drivers": Bodyfield{Type: "array", Description: "Topics/keywords driving engagement"},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
for _, d := range payable {
|
||||
// Twitter-capable tools are advertised as upto (variable-priced) only
|
||||
// when the Twitter API is actually enabled: the upto maximum adds the
|
||||
// realized Twitter lookup and per-post costs, and the actual charge is
|
||||
// settled from the response. Without Twitter, every tool is exact at
|
||||
// its flat base price.
|
||||
scheme := "exact"
|
||||
price := d.Price
|
||||
var resolver x402.SettleAmountResolver
|
||||
if d.UsesTwitter && s.twitterEnabled {
|
||||
scheme = "upto"
|
||||
baseMicro := x402.MicroFromPriceString(d.Price)
|
||||
price = x402.FormatPriceMicro(x402.MaxTwitterCostMicro(baseMicro, d.TwitterPostCap))
|
||||
if d.Name == "getStyleProfile" {
|
||||
resolver = x402.NewStyleProfileCostResolver()
|
||||
} else {
|
||||
resolver = x402.NewTwitterCountCostResolver(baseMicro)
|
||||
}
|
||||
}
|
||||
s.mcpHandler.AddPayableTool(
|
||||
mcp.Tool{Name: d.Name, Description: d.Desc, InputSchema: d.Input, OutputSchema: d.Output},
|
||||
d.Handler,
|
||||
resolver,
|
||||
s.buildRequirements(price, d.Extra, scheme, d.MaxTimeoutSeconds)...,
|
||||
)
|
||||
}
|
||||
|
||||
// Free tool: getFeed
|
||||
s.mcpHandler.AddTool(
|
||||
mcp.Tool{Name: "getFeed", Description: "Get a feed by its URI", InputSchema: GetFeedRequest{}, OutputSchema: GetFeedResponse{}},
|
||||
s.getFeed,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Server) getPost(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
var args GetPostRequest
|
||||
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
if args.Author == "" {
|
||||
return nil, fmt.Errorf("author must be a valid did or handle")
|
||||
}
|
||||
if args.RecordKey == "" {
|
||||
return nil, fmt.Errorf("rkey must be a valid record key")
|
||||
}
|
||||
platform, err := determinePlatform(args.Author)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if platform == Twitter {
|
||||
return nil, fmt.Errorf("twitter_unavailable: X API policy prohibits reselling post data")
|
||||
}
|
||||
post, err := s.fetchSinglePost(ctx, platform, args.Author, args.RecordKey, args.Collection)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBytes, err := json.Marshal(post)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: string(respBytes)},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) getLatestPosts(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
var args GetLatestPostsRequest
|
||||
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
if args.Author == "" {
|
||||
return nil, fmt.Errorf("author must be a valid did or handle")
|
||||
}
|
||||
if args.Limit <= 0 {
|
||||
return nil, fmt.Errorf("n must be a positive integer")
|
||||
}
|
||||
if args.Limit > 50 {
|
||||
return nil, fmt.Errorf("n must be at most 50")
|
||||
}
|
||||
platform, err := determinePlatform(args.Author)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if platform == Twitter {
|
||||
return nil, fmt.Errorf("twitter_unavailable: X API policy prohibits reselling post data")
|
||||
}
|
||||
posts, authorID, err := s.fetchLatestPosts(ctx, platform, args.Author, args.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if authorID == "" {
|
||||
authorID = args.Author
|
||||
}
|
||||
resp := GetLatestPostsResponse{
|
||||
Author: authorID,
|
||||
Posts: posts,
|
||||
}
|
||||
|
||||
respBytes, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: string(respBytes)},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) getPostsInTime(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
var args GetPostsInTimeRequest
|
||||
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
if args.Author == "" {
|
||||
return nil, fmt.Errorf("author must be a valid did or handle")
|
||||
}
|
||||
if args.Limit <= 0 {
|
||||
return nil, fmt.Errorf("n must be a positive integer")
|
||||
}
|
||||
if args.Limit > 50 {
|
||||
return nil, fmt.Errorf("n must be at most 50")
|
||||
}
|
||||
limit := args.Limit
|
||||
startTime, err := time.Parse(time.RFC3339, args.StartTime)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("startTime must be in RFC3339 format")
|
||||
}
|
||||
endTime, err := time.Parse(time.RFC3339, args.EndTime)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("endTime must be in RFC3339 format")
|
||||
}
|
||||
if startTime.After(endTime) {
|
||||
tmp := startTime
|
||||
startTime = endTime
|
||||
endTime = tmp
|
||||
}
|
||||
platform, err := determinePlatform(args.Author)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if platform == Twitter {
|
||||
return nil, fmt.Errorf("twitter_unavailable: X API policy prohibits reselling post data")
|
||||
}
|
||||
posts, authorID, err := s.fetchPostsInTime(ctx, platform, args.Author, startTime, endTime, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if authorID == "" {
|
||||
authorID = args.Author
|
||||
}
|
||||
resp := GetPostsInTimeResponse{
|
||||
Author: authorID,
|
||||
Posts: posts,
|
||||
}
|
||||
|
||||
respBytes, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: string(respBytes)},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) getFeed(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
var args GetFeedRequest
|
||||
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
if args.URI == "" {
|
||||
return nil, fmt.Errorf("feedURI must be a valid feed URI")
|
||||
}
|
||||
platform, err := determinePlatform(args.URI)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if platform == Twitter {
|
||||
return nil, fmt.Errorf("twitter_unavailable: X API policy prohibits reselling feed data")
|
||||
}
|
||||
posts, err := s.fetchFeed(ctx, platform, args.URI)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := GetFeedResponse{
|
||||
URI: args.URI,
|
||||
Posts: posts,
|
||||
}
|
||||
|
||||
respBytes, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: string(respBytes)},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) getActor(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
var args GetActorRequest
|
||||
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
if args.Author == "" {
|
||||
return nil, fmt.Errorf("author must be a valid did, handle, npub or @handle")
|
||||
}
|
||||
platform, err := determinePlatform(args.Author)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var (
|
||||
prof *ActorProfile
|
||||
e error
|
||||
)
|
||||
switch platform {
|
||||
case Bluesky:
|
||||
prof, e = s.bskyClient.GetActor(ctx, args.Author)
|
||||
case ActivityPub:
|
||||
prof, e = s.apClient.GetActor(ctx, args.Author)
|
||||
case Nostr:
|
||||
prof, e = s.nostrClient.GetActor(ctx, args.Author)
|
||||
case Twitter:
|
||||
return nil, fmt.Errorf("twitter_unavailable: X API policy prohibits reselling user/profile data")
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported platform %q", platform)
|
||||
}
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
respBytes, err := json.Marshal(GetActorResponse{Actor: *prof})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: string(respBytes)},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) searchActors(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
var args SearchActorsRequest
|
||||
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
|
||||
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
if args.Query == "" {
|
||||
return nil, fmt.Errorf("query must not be empty")
|
||||
}
|
||||
limit := args.Limit
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
if limit > 50 {
|
||||
limit = 50
|
||||
}
|
||||
results, err := s.searchActorsAll(ctx, args.Query, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBytes, err := json.Marshal(SearchActorsResponse{Results: results})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: string(respBytes)},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
162
internal/server/twitter_analytics_test.go
Normal file
162
internal/server/twitter_analytics_test.go
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nacorid/naco-api/internal/clustering"
|
||||
"github.com/nacorid/naco-api/internal/store"
|
||||
"github.com/nacorid/naco-api/internal/twitter"
|
||||
)
|
||||
|
||||
func twitterPosts() []*twitter.Post {
|
||||
base := time.Date(2024, 5, 10, 12, 0, 0, 0, time.UTC)
|
||||
return []*twitter.Post{
|
||||
{ID: "t1", Author: "@a", Content: "twitter one", CreatedAt: base},
|
||||
{ID: "t2", Author: "@a", Content: "twitter two", CreatedAt: base.Add(time.Hour)},
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchTwitterAnalyticsPostsFetchesAndPersists(t *testing.T) {
|
||||
tw := &fakeTwitter{posts: twitterPosts()}
|
||||
st := newFakeStore()
|
||||
s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, tw, st, &fakeOllama{})
|
||||
|
||||
start := time.Date(2024, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||
end := time.Date(2024, 5, 31, 0, 0, 0, 0, time.UTC)
|
||||
items, err := s.fetchTwitterAnalyticsPosts(context.Background(), "@a", start, end, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("fetchTwitterAnalyticsPosts error: %v", err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("expected 2 fetched posts, got %d", len(items))
|
||||
}
|
||||
if tw.calls != 1 {
|
||||
t.Fatalf("expected 1 API call, got %d", tw.calls)
|
||||
}
|
||||
|
||||
// Newly fetched posts are persisted for future reuse.
|
||||
if len(st.analyticsPosts) != 2 {
|
||||
t.Fatalf("expected 2 persisted analytics posts, got %d", len(st.analyticsPosts))
|
||||
}
|
||||
// Coverage range for the whole range recorded.
|
||||
if len(st.coverageRanges) != 1 {
|
||||
t.Fatalf("expected 1 coverage range, got %d", len(st.coverageRanges))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchTwitterAnalyticsPostsServedFromCoverage(t *testing.T) {
|
||||
// Posts already persisted and coverage present => no paid API call.
|
||||
st := newFakeStore()
|
||||
base := time.Date(2024, 5, 10, 12, 0, 0, 0, time.UTC)
|
||||
st.coverageRanges = []store.AnalyticsCoverageRange{
|
||||
{UserID: "@a", Platform: "Twitter", CoveredStart: base, CoveredEnd: base.Add(2 * time.Hour)},
|
||||
}
|
||||
st.analyticsPosts[postKey("@a", "Twitter", "t1")] = store.AnalyticsPost{
|
||||
UserID: "@a", Platform: "Twitter", PostID: "t1", PostText: "cached post", PostTimestamp: base,
|
||||
}
|
||||
tw := &fakeTwitter{}
|
||||
s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, tw, st, &fakeOllama{})
|
||||
|
||||
start := base
|
||||
end := base.Add(2 * time.Hour)
|
||||
items, err := s.fetchTwitterAnalyticsPosts(context.Background(), "@a", start, end, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("fetchTwitterAnalyticsPosts error: %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 cached post, got %d", len(items))
|
||||
}
|
||||
if tw.calls != 0 {
|
||||
t.Fatalf("expected no API call when covered, got %d", tw.calls)
|
||||
}
|
||||
if items[0].Text != "cached post" {
|
||||
t.Fatalf("expected cached text, got %q", items[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeAnalyticsPostsDedupsAndCaps(t *testing.T) {
|
||||
base := time.Date(2024, 5, 10, 12, 0, 0, 0, time.UTC)
|
||||
st := newFakeStore()
|
||||
// Cached posts overlap with fetched ones by ID, and both exceed the limit.
|
||||
st.analyticsPosts[postKey("@a", "Twitter", "dup")] = store.AnalyticsPost{
|
||||
UserID: "@a", Platform: "Twitter", PostID: "dup", PostText: "dup cached", PostTimestamp: base,
|
||||
}
|
||||
s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{})
|
||||
|
||||
fetched := []clustering.Item{
|
||||
{ID: "dup", Text: "dup fetched", Timestamp: base},
|
||||
{ID: "b", Text: "b", Timestamp: base},
|
||||
{ID: "c", Text: "c", Timestamp: base},
|
||||
}
|
||||
merged, err := s.mergeAnalyticsPosts(context.Background(), "@a", base, base.Add(time.Hour), 2, fetched)
|
||||
if err != nil {
|
||||
t.Fatalf("mergeAnalyticsPosts error: %v", err)
|
||||
}
|
||||
if len(merged) != 2 {
|
||||
t.Fatalf("expected merged capped at 2, got %d", len(merged))
|
||||
}
|
||||
// Fetched entries come first and dedupe against the cache.
|
||||
if merged[0].ID != "dup" || merged[0].Text != "dup fetched" {
|
||||
t.Fatalf("expected fetched dup first, got %+v", merged[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistAnalyticsPostsSkipsEmptyIDs(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{})
|
||||
err := s.persistAnalyticsPosts(context.Background(), "@a", "Twitter", []clustering.Item{
|
||||
{ID: "", Text: "no id"},
|
||||
{ID: "real", Text: "real", Timestamp: time.Now()},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("persistAnalyticsPosts error: %v", err)
|
||||
}
|
||||
if len(st.analyticsPosts) != 1 {
|
||||
t.Fatalf("expected only the post with an ID persisted, got %d", len(st.analyticsPosts))
|
||||
}
|
||||
if _, ok := st.analyticsPosts[postKey("@a", "Twitter", "real")]; !ok {
|
||||
t.Fatal("expected the 'real' post to be persisted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyticsTwitterEndToEnd(t *testing.T) {
|
||||
// getEngagementMetrics over the Twitter platform drives the coverage logic.
|
||||
tw := &fakeTwitter{posts: twitterPosts()}
|
||||
st := newFakeStore()
|
||||
s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, tw, st, &fakeOllama{})
|
||||
|
||||
res, err := callTool(s, "getEngagementMetrics", AnalyticsRequest{
|
||||
Author: "@a",
|
||||
StartTime: "2024-05-01T00:00:00Z",
|
||||
EndTime: "2024-05-31T00:00:00Z",
|
||||
Platform: "Twitter",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("getEngagementMetrics twitter error: %v", err)
|
||||
}
|
||||
if !strings.Contains(textOf(t, res), `"postId":"t1"`) {
|
||||
t.Fatalf("expected twitter post t1 in result, got %s", textOf(t, res))
|
||||
}
|
||||
if !strings.Contains(textOf(t, res), `"postId":"t2"`) {
|
||||
t.Fatalf("expected twitter post t2 in result, got %s", textOf(t, res))
|
||||
}
|
||||
if tw.calls != 1 {
|
||||
t.Fatalf("expected 1 API call for the uncovered range, got %d", tw.calls)
|
||||
}
|
||||
|
||||
// Second identical request is served from the analytics cache (no API call).
|
||||
if _, err := callTool(s, "getEngagementMetrics", AnalyticsRequest{
|
||||
Author: "@a",
|
||||
StartTime: "2024-05-01T00:00:00Z",
|
||||
EndTime: "2024-05-31T00:00:00Z",
|
||||
Platform: "Twitter",
|
||||
}); err != nil {
|
||||
t.Fatalf("second call error: %v", err)
|
||||
}
|
||||
if tw.calls != 1 {
|
||||
t.Fatalf("expected cache to avoid a second API call, got %d", tw.calls)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
package server
|
||||
|
||||
import "github.com/nacorid/naco-api/internal/actor"
|
||||
|
||||
type Bodyfield struct {
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
|
|
@ -24,8 +26,8 @@ type GetLatestPostsRequest struct {
|
|||
}
|
||||
|
||||
type GetLatestPostsResponse struct {
|
||||
Author string `json:"actor" jsonschema_description:"Did of the actor"`
|
||||
Posts []GetPostResponsePost `json:"posts" jsonschema_description:"List of latest posts"`
|
||||
Author string `json:"actor" jsonschema_description:"Did of the actor"`
|
||||
Posts []*GetPostResponsePost `json:"posts" jsonschema_description:"List of latest posts"`
|
||||
}
|
||||
|
||||
type GetPostsInTimeRequest struct {
|
||||
|
|
@ -36,14 +38,14 @@ type GetPostsInTimeRequest struct {
|
|||
}
|
||||
|
||||
type GetPostsInTimeResponse struct {
|
||||
Author string `json:"author" jsonschema_description:"Did of the author"`
|
||||
Posts []GetPostResponsePost `json:"posts" jsonschema_description:"List of posts in the timeframe"`
|
||||
Author string `json:"author" jsonschema_description:"Did of the author"`
|
||||
Posts []*GetPostResponsePost `json:"posts" jsonschema_description:"List of posts in the timeframe"`
|
||||
}
|
||||
|
||||
type GetPostRequest struct {
|
||||
Author string `json:"author" jsonschema_description:"Did or handle of an author"`
|
||||
RecordKey string `json:"rkey" jsonschema_description:"Record key of the post"`
|
||||
Collection string `json:"collection" jsonschema_description:"Optional collection the post belongs to"`
|
||||
Collection string `json:"collection" jsonschema_description:"Optional collection the post belongs to (Bluesky only). Defaults to 'app.bsky.feed.post'"`
|
||||
}
|
||||
|
||||
type GetPostResponsePost struct {
|
||||
|
|
@ -57,12 +59,12 @@ type GetPostResponsePost struct {
|
|||
}
|
||||
|
||||
type GetFeedRequest struct {
|
||||
URI string `json:"uri" jsonschema_description:"The URI of the feed to retrieve"`
|
||||
URI string `json:"uri" jsonschema_description:"URI of the feed to retrieve. For Bluesky this is a feed URI; for other platforms it is treated as an author handle"`
|
||||
}
|
||||
|
||||
type GetFeedResponse struct {
|
||||
URI string `json:"uri" jsonschema_description:"The URI of the feed"`
|
||||
Posts []GetPostResponsePost `json:"posts" jsonschema_description:"List of posts in the feed"`
|
||||
URI string `json:"uri" jsonschema_description:"The URI of the feed"`
|
||||
Posts []*GetPostResponsePost `json:"posts" jsonschema_description:"List of posts in the feed"`
|
||||
}
|
||||
|
||||
type GetStyleProfileRequest struct {
|
||||
|
|
@ -78,6 +80,47 @@ type GetStyleProfileResponse struct {
|
|||
SourcePlatform string `json:"sourcePlatform,omitempty" jsonschema_description:"The platform from which the posts were sourced"`
|
||||
}
|
||||
|
||||
type FindSimilarUsersRequest struct {
|
||||
Author string `json:"author" jsonschema_description:"Did or handle of an author to find similar profiles for"`
|
||||
Limit int32 `json:"limit,omitempty" jsonschema_description:"Maximum number of similar profiles to return (default 10, max 50)"`
|
||||
}
|
||||
|
||||
type FindSimilarUsersResponse struct {
|
||||
Matches []SimilarUser `json:"matches" jsonschema_description:"Ranked list of similar profiles by persona embedding similarity"`
|
||||
// TwitterPostCount reports how many posts were sourced from the Twitter API
|
||||
// while building the query author's style profile. It is 0 when the query
|
||||
// author is not on Twitter.
|
||||
TwitterPostCount int `json:"twitterPostCount" jsonschema_description:"Number of Twitter-sourced posts used for the query author's profile"`
|
||||
}
|
||||
|
||||
type ActorProfile = actor.Profile
|
||||
|
||||
type GetActorRequest struct {
|
||||
Author string `json:"author" jsonschema_description:"Did, handle, npub or @handle of an actor"`
|
||||
}
|
||||
|
||||
type GetActorResponse struct {
|
||||
Actor ActorProfile `json:"actor" jsonschema_description:"Profile of the actor"`
|
||||
}
|
||||
|
||||
type SearchActorsRequest struct {
|
||||
Query string `json:"query" jsonschema_description:"Search query (handle, name or keyword)"`
|
||||
Limit int `json:"limit,omitempty" jsonschema_description:"Maximum number of results (default 10, max 50)"`
|
||||
}
|
||||
|
||||
type SearchActorsResponse struct {
|
||||
Results []*ActorProfile `json:"results" jsonschema_description:"Ranked list of matching actor profiles across platforms"`
|
||||
}
|
||||
|
||||
type SimilarUser struct {
|
||||
Author string `json:"author" jsonschema_description:"Did or handle of the similar author"`
|
||||
Platform string `json:"platform" jsonschema_description:"Platform the similar author was found on"`
|
||||
Similarity float64 `json:"similarity" jsonschema_description:"Cosine similarity of the persona embeddings, range 0..1"`
|
||||
TopKeywords []string `json:"topKeywords,omitempty" jsonschema_description:"Top keywords used by the similar author"`
|
||||
StyleSummary string `json:"styleSummary,omitempty" jsonschema_description:"Summary of the similar author's writing style"`
|
||||
PostCount int `json:"postCount,omitempty" jsonschema_description:"Number of posts analyzed for the similar author"`
|
||||
}
|
||||
|
||||
type DCAPMessage struct {
|
||||
V int `json:"v" jsonschema_description:"DCAP Version"`
|
||||
T string `json:"t" jsonschema_description:"DCAP Message Type"`
|
||||
|
|
@ -97,3 +140,134 @@ const (
|
|||
func (p platform) String() string {
|
||||
return [...]string{"ActivityPub", "Bluesky", "Nostr", "Twitter"}[p]
|
||||
}
|
||||
|
||||
func platformFromString(name string) (platform, bool) {
|
||||
switch name {
|
||||
case "ActivityPub":
|
||||
return ActivityPub, true
|
||||
case "Bluesky":
|
||||
return Bluesky, true
|
||||
case "Nostr":
|
||||
return Nostr, true
|
||||
case "Twitter":
|
||||
return Twitter, true
|
||||
default:
|
||||
return -1, false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Analytics types ---
|
||||
|
||||
type AnalyticsRequest struct {
|
||||
Author string `json:"author" jsonschema_description:"Did or handle of an author"`
|
||||
StartTime string `json:"startTime,omitempty" jsonschema_description:"Start time in RFC3339 format (default: 30 days ago)"`
|
||||
EndTime string `json:"endTime,omitempty" jsonschema_description:"End time in RFC3339 format (default: now)"`
|
||||
Limit int `json:"limit,omitempty" jsonschema_description:"Maximum number of posts to analyze (default: 50, max: 200)"`
|
||||
Platform string `json:"platform,omitempty" jsonschema_description:"Platform to filter (default: all)"`
|
||||
}
|
||||
|
||||
type EngagementMetricsResponse struct {
|
||||
Author string `json:"author" jsonschema_description:"Author identifier"`
|
||||
Metrics []EngagementMetricPoint `json:"metrics" jsonschema_description:"Engagement metrics per post"`
|
||||
// TwitterPostCount reports how many posts in the result were sourced from
|
||||
// the Twitter API, so upto-scheme settlement can pass the realized Twitter
|
||||
// costs on to the caller.
|
||||
TwitterPostCount int `json:"twitterPostCount" jsonschema_description:"Number of Twitter-sourced posts analyzed"`
|
||||
}
|
||||
|
||||
type EngagementMetricPoint struct {
|
||||
PostID string `json:"postId" jsonschema_description:"Post identifier"`
|
||||
Timestamp string `json:"timestamp" jsonschema_description:"RFC3339 timestamp"`
|
||||
Likes int64 `json:"likes" jsonschema_description:"Number of likes"`
|
||||
Reposts int64 `json:"reposts" jsonschema_description:"Number of reposts"`
|
||||
Replies int64 `json:"replies" jsonschema_description:"Number of replies"`
|
||||
Quotes int64 `json:"quotes" jsonschema_description:"Number of quotes"`
|
||||
EngagementRate float64 `json:"engagementRate" jsonschema_description:"Engagement rate (interactions / impressions estimate)"`
|
||||
}
|
||||
|
||||
type PostingPatternsResponse struct {
|
||||
Author string `json:"author" jsonschema_description:"Author identifier"`
|
||||
Heatmap [][]int `json:"heatmap" jsonschema_description:"24x7 matrix: hour (0-23) x day of week (0-6 = Sun-Sat)"`
|
||||
PeakHours []int `json:"peakHours" jsonschema_description:"Top 3 hours with most posts (0-23)"`
|
||||
PeakDays []int `json:"peakDays" jsonschema_description:"Top 3 days with most posts (0-6 = Sun-Sat)"`
|
||||
AvgPostsPerDay float64 `json:"avgPostsPerDay" jsonschema_description:"Average posts per day"`
|
||||
TotalPosts int `json:"totalPosts" jsonschema_description:"Total posts in time range"`
|
||||
PostingDays int `json:"postingDays" jsonschema_description:"Number of days with at least one post"`
|
||||
// TwitterPostCount reports how many posts in the result were sourced from
|
||||
// the Twitter API, so upto-scheme settlement can pass the realized Twitter
|
||||
// costs on to the caller.
|
||||
TwitterPostCount int `json:"twitterPostCount" jsonschema_description:"Number of Twitter-sourced posts analyzed"`
|
||||
}
|
||||
|
||||
type TopicModelingResponse struct {
|
||||
Author string `json:"author" jsonschema_description:"Author identifier"`
|
||||
Topics []TopicCluster `json:"topics" jsonschema_description:"Identified topic clusters"`
|
||||
// TwitterPostCount reports how many posts in the result were sourced from
|
||||
// the Twitter API, so upto-scheme settlement can pass the realized Twitter
|
||||
// costs on to the caller.
|
||||
TwitterPostCount int `json:"twitterPostCount" jsonschema_description:"Number of Twitter-sourced posts analyzed"`
|
||||
}
|
||||
|
||||
type TopicCluster struct {
|
||||
TopicID int `json:"topicId" jsonschema_description:"Cluster identifier"`
|
||||
Label string `json:"label" jsonschema_description:"Human-readable topic label"`
|
||||
Keywords []string `json:"keywords" jsonschema_description:"Top keywords for this topic"`
|
||||
PostCount int `json:"postCount" jsonschema_description:"Number of posts in this topic"`
|
||||
Percentage float64 `json:"percentage" jsonschema_description:"Percentage of total posts"`
|
||||
SamplePosts []string `json:"samplePosts" jsonschema_description:"Sample post texts (max 3)"`
|
||||
}
|
||||
|
||||
type SentimentTrendsResponse struct {
|
||||
Author string `json:"author" jsonschema_description:"Author identifier"`
|
||||
TimeSeries []SentimentPoint `json:"timeSeries" jsonschema_description:"Sentiment over time"`
|
||||
OverallSentiment SentimentSummary `json:"overallSentiment" jsonschema_description:"Overall sentiment summary"`
|
||||
DominantEmotions []EmotionScore `json:"dominantEmotions" jsonschema_description:"Most frequent emotions"`
|
||||
SentimentShifts []SentimentShift `json:"sentimentShifts" jsonschema_description:"Significant sentiment changes"`
|
||||
// TwitterPostCount reports how many posts in the result were sourced from
|
||||
// the Twitter API, so upto-scheme settlement can pass the realized Twitter
|
||||
// costs on to the caller.
|
||||
TwitterPostCount int `json:"twitterPostCount" jsonschema_description:"Number of Twitter-sourced posts analyzed"`
|
||||
}
|
||||
|
||||
type SentimentPoint struct {
|
||||
Timestamp string `json:"timestamp" jsonschema_description:"RFC3339 timestamp (bucketed by day)"`
|
||||
Positive float64 `json:"positive" jsonschema_description:"Positive sentiment ratio (0-1)"`
|
||||
Negative float64 `json:"negative" jsonschema_description:"Negative sentiment ratio (0-1)"`
|
||||
Neutral float64 `json:"neutral" jsonschema_description:"Neutral sentiment ratio (0-1)"`
|
||||
PostCount int `json:"postCount" jsonschema_description:"Posts in this time bucket"`
|
||||
}
|
||||
|
||||
type SentimentSummary struct {
|
||||
Positive float64 `json:"positive" jsonschema_description:"Overall positive ratio"`
|
||||
Negative float64 `json:"negative" jsonschema_description:"Overall negative ratio"`
|
||||
Neutral float64 `json:"neutral" jsonschema_description:"Overall neutral ratio"`
|
||||
}
|
||||
|
||||
type EmotionScore struct {
|
||||
Emotion string `json:"emotion" jsonschema_description:"Emotion name"`
|
||||
Score float64 `json:"score" jsonschema_description:"Frequency score (0-1)"`
|
||||
}
|
||||
|
||||
type SentimentShift struct {
|
||||
Timestamp string `json:"timestamp" jsonschema_description:"When the shift occurred"`
|
||||
FromSentiment string `json:"fromSentiment" jsonschema_description:"Previous dominant sentiment"`
|
||||
ToSentiment string `json:"toSentiment" jsonschema_description:"New dominant sentiment"`
|
||||
Magnitude float64 `json:"magnitude" jsonschema_description:"Shift magnitude (0-1)"`
|
||||
}
|
||||
|
||||
type EngagementDriversResponse struct {
|
||||
Author string `json:"author" jsonschema_description:"Author identifier"`
|
||||
Drivers []EngagementDriver `json:"drivers" jsonschema_description:"Topics/keywords driving engagement"`
|
||||
// TwitterPostCount reports how many posts in the result were sourced from
|
||||
// the Twitter API, so upto-scheme settlement can pass the realized Twitter
|
||||
// costs on to the caller.
|
||||
TwitterPostCount int `json:"twitterPostCount" jsonschema_description:"Number of Twitter-sourced posts analyzed"`
|
||||
}
|
||||
|
||||
type EngagementDriver struct {
|
||||
Topic string `json:"topic" jsonschema_description:"Topic or keyword"`
|
||||
AvgEngagement float64 `json:"avgEngagement" jsonschema_description:"Average engagement rate for posts with this topic"`
|
||||
Correlation float64 `json:"correlation" jsonschema_description:"Correlation coefficient with engagement (-1 to 1)"`
|
||||
PostCount int `json:"postCount" jsonschema_description:"Number of posts containing this topic"`
|
||||
SamplePosts []string `json:"samplePosts" jsonschema_description:"Sample posts (max 2)"`
|
||||
}
|
||||
|
|
|
|||
261
internal/server/validation_test.go
Normal file
261
internal/server/validation_test.go
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bluesky-social/indigo/api/bsky"
|
||||
"github.com/nacorid/naco-api/internal/activitypub"
|
||||
"github.com/nacorid/naco-api/internal/nostr"
|
||||
)
|
||||
|
||||
// ---- validation matrix ----
|
||||
|
||||
func TestGetPostValidation(t *testing.T) {
|
||||
s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
|
||||
|
||||
if _, err := callTool(s, "getPost", GetPostRequest{Author: "", RecordKey: "k"}); err == nil || !strings.Contains(err.Error(), "author") {
|
||||
t.Fatalf("expected author error, got %v", err)
|
||||
}
|
||||
if _, err := callTool(s, "getPost", GetPostRequest{Author: "did:plc:abc", RecordKey: ""}); err == nil || !strings.Contains(err.Error(), "rkey") {
|
||||
t.Fatalf("expected rkey error, got %v", err)
|
||||
}
|
||||
if _, err := callTool(s, "getPost", "not-an-object"); err == nil {
|
||||
t.Fatal("expected invalid arguments error")
|
||||
}
|
||||
// Unparseable handle -> determinePlatform error
|
||||
if _, err := callTool(s, "getPost", GetPostRequest{Author: "justhandle", RecordKey: "k"}); err == nil {
|
||||
t.Fatal("expected determinePlatform error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestPostsValidation(t *testing.T) {
|
||||
s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
|
||||
|
||||
if _, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "", Limit: 5}); err == nil || !strings.Contains(err.Error(), "author") {
|
||||
t.Fatalf("expected author error, got %v", err)
|
||||
}
|
||||
if _, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "did:plc:abc", Limit: 0}); err == nil || !strings.Contains(err.Error(), "positive") {
|
||||
t.Fatalf("expected limit error, got %v", err)
|
||||
}
|
||||
if _, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "did:plc:abc", Limit: -3}); err == nil {
|
||||
t.Fatal("expected negative limit error")
|
||||
}
|
||||
if _, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "did:plc:abc", Limit: 51}); err == nil || !strings.Contains(err.Error(), "at most 50") {
|
||||
t.Fatalf("expected max-limit error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPostsInTimeValidation(t *testing.T) {
|
||||
s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
|
||||
|
||||
if _, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "", StartTime: "2024-01-01T00:00:00Z", EndTime: "2024-12-31T00:00:00Z", Limit: 5}); err == nil || !strings.Contains(err.Error(), "author") {
|
||||
t.Fatalf("expected author error, got %v", err)
|
||||
}
|
||||
if _, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "did:plc:abc", StartTime: "2024-01-01T00:00:00Z", EndTime: "2024-12-31T00:00:00Z", Limit: 0}); err == nil || !strings.Contains(err.Error(), "positive") {
|
||||
t.Fatalf("expected limit error, got %v", err)
|
||||
}
|
||||
// getPostsInTime now rejects over-max limit for consistency with getLatestPosts.
|
||||
if _, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "did:plc:abc", StartTime: "2024-01-01T00:00:00Z", EndTime: "2024-12-31T00:00:00Z", Limit: 51}); err == nil || !strings.Contains(err.Error(), "at most 50") {
|
||||
t.Fatalf("expected max-limit error, got %v", err)
|
||||
}
|
||||
// Bad start time.
|
||||
if _, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "did:plc:abc", StartTime: "not-a-time", EndTime: "2024-12-31T00:00:00Z", Limit: 5}); err == nil || !strings.Contains(err.Error(), "startTime") {
|
||||
t.Fatalf("expected startTime error, got %v", err)
|
||||
}
|
||||
// Bad end time reports the correct field name.
|
||||
if _, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{Author: "did:plc:abc", StartTime: "2024-01-01T00:00:00Z", EndTime: "not-a-time", Limit: 5}); err == nil || !strings.Contains(err.Error(), "endTime") {
|
||||
t.Fatalf("expected endTime error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPostsInTimeSwapsReversedRange(t *testing.T) {
|
||||
// Nostr fake returns posts regardless of args; the point is that a reversed
|
||||
// range is accepted (swapped) rather than rejected.
|
||||
nstr := &fakeNostr{posts: []*nostr.Post{{ID: "e1", Author: "npub1x", Content: "hi", CreatedAt: time.Now()}}}
|
||||
s := newTestServer(&fakeBluesky{}, &fakeAP{}, nstr, &fakeTwitter{})
|
||||
res, err := callTool(s, "getPostsInTime", GetPostsInTimeRequest{
|
||||
Author: "npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
StartTime: "2024-12-31T00:00:00Z",
|
||||
EndTime: "2024-01-01T00:00:00Z",
|
||||
Limit: 5,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected reversed range to be swapped and succeed, got %v", err)
|
||||
}
|
||||
if res == nil || len(res.Content) == 0 {
|
||||
t.Fatal("expected a result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFeedValidation(t *testing.T) {
|
||||
s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
|
||||
if _, err := callTool(s, "getFeed", GetFeedRequest{URI: ""}); err == nil || !strings.Contains(err.Error(), "URI") {
|
||||
t.Fatalf("expected URI error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetActorValidation(t *testing.T) {
|
||||
s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
|
||||
if _, err := callTool(s, "getActor", GetActorRequest{Author: ""}); err == nil || !strings.Contains(err.Error(), "author") {
|
||||
t.Fatalf("expected author error, got %v", err)
|
||||
}
|
||||
// Bare handle is unparseable -> determinePlatform error.
|
||||
if _, err := callTool(s, "getActor", GetActorRequest{Author: "justhandle"}); err == nil {
|
||||
t.Fatal("expected determinePlatform error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchActorsValidation(t *testing.T) {
|
||||
s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
|
||||
if _, err := callTool(s, "searchActors", SearchActorsRequest{Query: ""}); err == nil || !strings.Contains(err.Error(), "query") {
|
||||
t.Fatalf("expected query error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- twitter policy blocks ----
|
||||
|
||||
func TestTwitterPolicyBlocksAllTools(t *testing.T) {
|
||||
cases := []struct {
|
||||
tool string
|
||||
args any
|
||||
}{
|
||||
{"getPost", GetPostRequest{Author: "@alice", RecordKey: "k"}},
|
||||
{"getLatestPosts", GetLatestPostsRequest{Author: "@alice", Limit: 5}},
|
||||
{"getPostsInTime", GetPostsInTimeRequest{Author: "@alice", StartTime: "2024-01-01T00:00:00Z", EndTime: "2024-12-31T00:00:00Z", Limit: 5}},
|
||||
{"getFeed", GetFeedRequest{URI: "@alice"}},
|
||||
{"getActor", GetActorRequest{Author: "@alice"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
tw := &fakeTwitter{}
|
||||
s := newTestServer(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, tw)
|
||||
_, err := callTool(s, c.tool, c.args)
|
||||
if err == nil || !strings.Contains(err.Error(), "twitter_unavailable") {
|
||||
t.Fatalf("%s: expected twitter_unavailable, got %v", c.tool, err)
|
||||
}
|
||||
if tw.calls != 0 {
|
||||
t.Fatalf("%s: backend should not be called, got %d calls", c.tool, tw.calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- backend error propagation ----
|
||||
|
||||
func TestBackendErrorPropagation(t *testing.T) {
|
||||
bsky := &fakeBluesky{err: fmt.Errorf("boom")}
|
||||
s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
|
||||
if _, err := callTool(s, "getPost", GetPostRequest{Author: "did:plc:abc", RecordKey: "k"}); err == nil || !strings.Contains(err.Error(), "boom") {
|
||||
t.Fatalf("expected backend error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetActorBackendError(t *testing.T) {
|
||||
ap := &fakeAP{err: fmt.Errorf("boom")}
|
||||
s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{})
|
||||
if _, err := callTool(s, "getActor", GetActorRequest{Author: "@a@mastodon.social"}); err == nil || !strings.Contains(err.Error(), "boom") {
|
||||
t.Fatalf("expected backend error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- getPost success paths ----
|
||||
|
||||
func TestGetPostBlueskyAndNostr(t *testing.T) {
|
||||
bsky := &fakeBluesky{postView: bskyPostView("hello bluesky")}
|
||||
s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
|
||||
res, err := callTool(s, "getPost", GetPostRequest{Author: "did:plc:abc", RecordKey: "k"})
|
||||
if err != nil {
|
||||
t.Fatalf("getPost bluesky error: %v", err)
|
||||
}
|
||||
if !strings.Contains(textOf(t, res), "hello bluesky") {
|
||||
t.Fatalf("unexpected bluesky post text: %s", textOf(t, res))
|
||||
}
|
||||
if bsky.calls != 1 {
|
||||
t.Fatalf("expected 1 backend call, got %d", bsky.calls)
|
||||
}
|
||||
|
||||
nstr := &fakeNostr{post: &nostr.Post{ID: "n1", Author: "npub1x", Content: "nostr hello"}}
|
||||
s2 := newTestServer(&fakeBluesky{}, &fakeAP{}, nstr, &fakeTwitter{})
|
||||
res2, err := callTool(s2, "getPost", GetPostRequest{Author: "npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", RecordKey: "n1"})
|
||||
if err != nil {
|
||||
t.Fatalf("getPost nostr error: %v", err)
|
||||
}
|
||||
if !strings.Contains(textOf(t, res2), "nostr hello") {
|
||||
t.Fatalf("unexpected nostr post text: %s", textOf(t, res2))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestPostsEmptyResult(t *testing.T) {
|
||||
ap := &fakeAP{}
|
||||
s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{})
|
||||
res, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "@a@mastodon.social", Limit: 5})
|
||||
if err != nil {
|
||||
t.Fatalf("getLatestPosts error: %v", err)
|
||||
}
|
||||
var out GetLatestPostsResponse
|
||||
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(out.Posts) != 0 {
|
||||
t.Fatalf("expected 0 posts, got %d", len(out.Posts))
|
||||
}
|
||||
if out.Author != "@a@mastodon.social" {
|
||||
t.Fatalf("expected author fallback to request author, got %q", out.Author)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestPostsBlueskyNilPostSkipped(t *testing.T) {
|
||||
bsky := &fakeBluesky{feedViews: []*bsky.FeedDefs_FeedViewPost{
|
||||
{Post: bskyPostView("kept")},
|
||||
{Post: nil},
|
||||
}}
|
||||
s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
|
||||
res, err := callTool(s, "getLatestPosts", GetLatestPostsRequest{Author: "did:plc:abc", Limit: 5})
|
||||
if err != nil {
|
||||
t.Fatalf("getLatestPosts error: %v", err)
|
||||
}
|
||||
var out GetLatestPostsResponse
|
||||
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(out.Posts) != 1 {
|
||||
t.Fatalf("expected 1 post after skipping nil, got %d", len(out.Posts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFeedNonBlueskyFallback(t *testing.T) {
|
||||
// A non-Bluesky feed URI is treated as an author handle and falls back to
|
||||
// fetchLatestPosts (evaluated via the ActivityPub backend here).
|
||||
ap := &fakeAP{posts: []*activitypub.Post{{Author: "@a@mastodon.social", Content: "fallback post", CreatedAt: time.Now()}}}
|
||||
s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{})
|
||||
res, err := callTool(s, "getFeed", GetFeedRequest{URI: "@a@mastodon.social"})
|
||||
if err != nil {
|
||||
t.Fatalf("getFeed fallback error: %v", err)
|
||||
}
|
||||
if !strings.Contains(textOf(t, res), "fallback post") {
|
||||
t.Fatalf("expected fallback post, got %s", textOf(t, res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFeedEmptyResult(t *testing.T) {
|
||||
bsky := &fakeBluesky{feedOut: &bsky.FeedGetFeed_Output{Feed: nil}}
|
||||
s := newTestServer(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{})
|
||||
res, err := callTool(s, "getFeed", GetFeedRequest{URI: "at://did:plc:abc/app.bsky.feed.generator/abc"})
|
||||
if err != nil {
|
||||
t.Fatalf("getFeed error: %v", err)
|
||||
}
|
||||
var out GetFeedResponse
|
||||
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(out.Posts) != 0 {
|
||||
t.Fatalf("expected 0 feed posts, got %d", len(out.Posts))
|
||||
}
|
||||
}
|
||||
|
||||
func jsonUnmarshal(t *testing.T, in string, out any) error {
|
||||
t.Helper()
|
||||
return json.Unmarshal([]byte(in), out)
|
||||
}
|
||||
125
internal/server/webhook.go
Normal file
125
internal/server/webhook.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/nacorid/logger"
|
||||
"github.com/nacorid/naco-api/internal/config"
|
||||
)
|
||||
|
||||
type WebhookSender struct {
|
||||
cfg *config.Config
|
||||
metrics *MetricsMiddleware
|
||||
httpCli *http.Client
|
||||
server string
|
||||
url string
|
||||
}
|
||||
|
||||
func newWebhookSender(cfg *config.Config, m *MetricsMiddleware) *WebhookSender {
|
||||
if cfg.MatrixWebhookURL == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &WebhookSender{
|
||||
cfg: cfg,
|
||||
metrics: m,
|
||||
httpCli: &http.Client{Timeout: 10 * time.Second},
|
||||
server: cfg.MatrixServerID,
|
||||
url: cfg.MatrixWebhookURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (wh *WebhookSender) Send(ctx context.Context, jsonBytes []byte) {
|
||||
if wh == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if len(jsonBytes) == 0 {
|
||||
log.ErrorWithContext(ctx, "webhook: empty payload ignored")
|
||||
return
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= 5; attempt++ {
|
||||
delay := exponentialBackoff(attempt)
|
||||
if attempt > 1 {
|
||||
select {
|
||||
case <-time.After(delay):
|
||||
case <-ctx.Done():
|
||||
log.WarnWithContext(ctx, "webhook cancelled during backoff", "attempt", attempt, "error", ctx.Err())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
err := wh.sendAttempt(ctx, jsonBytes, attempt)
|
||||
if err == nil {
|
||||
log.InfoWithContext(ctx,
|
||||
"webhook succeeded",
|
||||
"attempt", attempt,
|
||||
"server", wh.server,
|
||||
"url", maskURL(wh.url),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
log.WarnWithContext(ctx,
|
||||
"webhook attempt failed",
|
||||
"attempt", attempt,
|
||||
"total_attempts", 5,
|
||||
"server", wh.server,
|
||||
"url", maskURL(wh.url),
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
|
||||
log.ErrorWithContext(ctx,
|
||||
"webhook failed after all attempts",
|
||||
"total_attempts", 5,
|
||||
"server", wh.server,
|
||||
"url", maskURL(wh.url),
|
||||
"error", lastErr,
|
||||
)
|
||||
}
|
||||
|
||||
func (wh *WebhookSender) sendAttempt(ctx context.Context, jsonBytes []byte, attempt int) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, wh.url, strings.NewReader(string(jsonBytes)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "naco-api/"+wh.server)
|
||||
|
||||
resp, err := wh.httpCli.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("do request: %w", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("non-2xx status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
func exponentialBackoff(attempt int) time.Duration {
|
||||
base := 500 * time.Millisecond
|
||||
delay := base << (attempt - 1)
|
||||
if delay > 30*time.Second {
|
||||
delay = 30 * time.Second
|
||||
}
|
||||
return delay
|
||||
}
|
||||
|
||||
func maskURL(url string) string {
|
||||
if len(url) > 30 {
|
||||
return url[:15] + "..." + url[len(url)-15:]
|
||||
}
|
||||
return url
|
||||
}
|
||||
96
internal/store/AnalyticsCache.sql.go
Normal file
96
internal/store/AnalyticsCache.sql.go
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: AnalyticsCache.sql
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
const deleteAnalyticsCache = `-- name: DeleteAnalyticsCache :exec
|
||||
DELETE FROM analytics_cache
|
||||
WHERE user_id = $1 AND platform = $2 AND tool_name = $3 AND time_range_hash = $4
|
||||
`
|
||||
|
||||
type DeleteAnalyticsCacheParams struct {
|
||||
UserID string
|
||||
Platform string
|
||||
ToolName string
|
||||
TimeRangeHash string
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteAnalyticsCache(ctx context.Context, arg DeleteAnalyticsCacheParams) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteAnalyticsCache,
|
||||
arg.UserID,
|
||||
arg.Platform,
|
||||
arg.ToolName,
|
||||
arg.TimeRangeHash,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const getAnalyticsCache = `-- name: GetAnalyticsCache :one
|
||||
SELECT result FROM analytics_cache
|
||||
WHERE user_id = $1 AND platform = $2 AND tool_name = $3 AND time_range_hash = $4
|
||||
AND expires_at > now()
|
||||
`
|
||||
|
||||
type GetAnalyticsCacheParams struct {
|
||||
UserID string
|
||||
Platform string
|
||||
ToolName string
|
||||
TimeRangeHash string
|
||||
}
|
||||
|
||||
func (q *Queries) GetAnalyticsCache(ctx context.Context, arg GetAnalyticsCacheParams) (json.RawMessage, error) {
|
||||
row := q.db.QueryRowContext(ctx, getAnalyticsCache,
|
||||
arg.UserID,
|
||||
arg.Platform,
|
||||
arg.ToolName,
|
||||
arg.TimeRangeHash,
|
||||
)
|
||||
var result json.RawMessage
|
||||
err := row.Scan(&result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
const pruneAnalyticsCache = `-- name: PruneAnalyticsCache :exec
|
||||
DELETE FROM analytics_cache WHERE expires_at <= now()
|
||||
`
|
||||
|
||||
func (q *Queries) PruneAnalyticsCache(ctx context.Context) error {
|
||||
_, err := q.db.ExecContext(ctx, pruneAnalyticsCache)
|
||||
return err
|
||||
}
|
||||
|
||||
const upsertAnalyticsCache = `-- name: UpsertAnalyticsCache :exec
|
||||
INSERT INTO analytics_cache (user_id, platform, tool_name, time_range_hash, result, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (user_id, platform, tool_name, time_range_hash)
|
||||
DO UPDATE SET result = EXCLUDED.result, expires_at = EXCLUDED.expires_at, computed_at = now()
|
||||
`
|
||||
|
||||
type UpsertAnalyticsCacheParams struct {
|
||||
UserID string
|
||||
Platform string
|
||||
ToolName string
|
||||
TimeRangeHash string
|
||||
Result json.RawMessage
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertAnalyticsCache(ctx context.Context, arg UpsertAnalyticsCacheParams) error {
|
||||
_, err := q.db.ExecContext(ctx, upsertAnalyticsCache,
|
||||
arg.UserID,
|
||||
arg.Platform,
|
||||
arg.ToolName,
|
||||
arg.TimeRangeHash,
|
||||
arg.Result,
|
||||
arg.ExpiresAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
96
internal/store/AnalyticsCoverage.sql.go
Normal file
96
internal/store/AnalyticsCoverage.sql.go
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: AnalyticsCoverage.sql
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
const deleteAnalyticsCoverageRange = `-- name: DeleteAnalyticsCoverageRange :exec
|
||||
DELETE FROM analytics_coverage_ranges
|
||||
WHERE user_id = $1 AND platform = $2 AND covered_start = $3 AND covered_end = $4
|
||||
`
|
||||
|
||||
type DeleteAnalyticsCoverageRangeParams struct {
|
||||
UserID string
|
||||
Platform string
|
||||
CoveredStart time.Time
|
||||
CoveredEnd time.Time
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteAnalyticsCoverageRange(ctx context.Context, arg DeleteAnalyticsCoverageRangeParams) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteAnalyticsCoverageRange,
|
||||
arg.UserID,
|
||||
arg.Platform,
|
||||
arg.CoveredStart,
|
||||
arg.CoveredEnd,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const getAnalyticsCoverageRanges = `-- name: GetAnalyticsCoverageRanges :many
|
||||
SELECT covered_start, covered_end FROM analytics_coverage_ranges
|
||||
WHERE user_id = $1 AND platform = $2
|
||||
ORDER BY covered_start
|
||||
`
|
||||
|
||||
type GetAnalyticsCoverageRangesParams struct {
|
||||
UserID string
|
||||
Platform string
|
||||
}
|
||||
|
||||
type GetAnalyticsCoverageRangesRow struct {
|
||||
CoveredStart time.Time
|
||||
CoveredEnd time.Time
|
||||
}
|
||||
|
||||
func (q *Queries) GetAnalyticsCoverageRanges(ctx context.Context, arg GetAnalyticsCoverageRangesParams) ([]GetAnalyticsCoverageRangesRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getAnalyticsCoverageRanges, arg.UserID, arg.Platform)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetAnalyticsCoverageRangesRow
|
||||
for rows.Next() {
|
||||
var i GetAnalyticsCoverageRangesRow
|
||||
if err := rows.Scan(&i.CoveredStart, &i.CoveredEnd); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const insertAnalyticsCoverageRange = `-- name: InsertAnalyticsCoverageRange :exec
|
||||
INSERT INTO analytics_coverage_ranges (user_id, platform, covered_start, covered_end, updated_at)
|
||||
VALUES ($1, $2, $3, $4, now())
|
||||
ON CONFLICT (user_id, platform, covered_start, covered_end)
|
||||
DO UPDATE SET updated_at = now()
|
||||
`
|
||||
|
||||
type InsertAnalyticsCoverageRangeParams struct {
|
||||
UserID string
|
||||
Platform string
|
||||
CoveredStart time.Time
|
||||
CoveredEnd time.Time
|
||||
}
|
||||
|
||||
func (q *Queries) InsertAnalyticsCoverageRange(ctx context.Context, arg InsertAnalyticsCoverageRangeParams) error {
|
||||
_, err := q.db.ExecContext(ctx, insertAnalyticsCoverageRange,
|
||||
arg.UserID,
|
||||
arg.Platform,
|
||||
arg.CoveredStart,
|
||||
arg.CoveredEnd,
|
||||
)
|
||||
return err
|
||||
}
|
||||
113
internal/store/AnalyticsPosts.sql.go
Normal file
113
internal/store/AnalyticsPosts.sql.go
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: AnalyticsPosts.sql
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
const getAnalyticsPostsInRange = `-- name: GetAnalyticsPostsInRange :many
|
||||
SELECT post_id, post_text, post_timestamp, likes, reposts, replies, quotes
|
||||
FROM analytics_posts
|
||||
WHERE user_id = $1 AND platform = $2 AND post_timestamp >= $3 AND post_timestamp <= $4
|
||||
ORDER BY post_timestamp DESC
|
||||
LIMIT $5
|
||||
`
|
||||
|
||||
type GetAnalyticsPostsInRangeParams struct {
|
||||
UserID string
|
||||
Platform string
|
||||
PostTimestamp time.Time
|
||||
PostTimestamp_2 time.Time
|
||||
Limit int32
|
||||
}
|
||||
|
||||
type GetAnalyticsPostsInRangeRow struct {
|
||||
PostID string
|
||||
PostText string
|
||||
PostTimestamp time.Time
|
||||
Likes int64
|
||||
Reposts int64
|
||||
Replies int64
|
||||
Quotes int64
|
||||
}
|
||||
|
||||
func (q *Queries) GetAnalyticsPostsInRange(ctx context.Context, arg GetAnalyticsPostsInRangeParams) ([]GetAnalyticsPostsInRangeRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getAnalyticsPostsInRange,
|
||||
arg.UserID,
|
||||
arg.Platform,
|
||||
arg.PostTimestamp,
|
||||
arg.PostTimestamp_2,
|
||||
arg.Limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetAnalyticsPostsInRangeRow
|
||||
for rows.Next() {
|
||||
var i GetAnalyticsPostsInRangeRow
|
||||
if err := rows.Scan(
|
||||
&i.PostID,
|
||||
&i.PostText,
|
||||
&i.PostTimestamp,
|
||||
&i.Likes,
|
||||
&i.Reposts,
|
||||
&i.Replies,
|
||||
&i.Quotes,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const upsertAnalyticsPosts = `-- name: UpsertAnalyticsPosts :exec
|
||||
INSERT INTO analytics_posts (user_id, platform, post_id, post_text, post_timestamp, likes, reposts, replies, quotes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (user_id, platform, post_id)
|
||||
DO UPDATE SET post_text = EXCLUDED.post_text,
|
||||
post_timestamp = EXCLUDED.post_timestamp,
|
||||
likes = EXCLUDED.likes,
|
||||
reposts = EXCLUDED.reposts,
|
||||
replies = EXCLUDED.replies,
|
||||
quotes = EXCLUDED.quotes
|
||||
`
|
||||
|
||||
type UpsertAnalyticsPostsParams struct {
|
||||
UserID string
|
||||
Platform string
|
||||
PostID string
|
||||
PostText string
|
||||
PostTimestamp time.Time
|
||||
Likes int64
|
||||
Reposts int64
|
||||
Replies int64
|
||||
Quotes int64
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertAnalyticsPosts(ctx context.Context, arg UpsertAnalyticsPostsParams) error {
|
||||
_, err := q.db.ExecContext(ctx, upsertAnalyticsPosts,
|
||||
arg.UserID,
|
||||
arg.Platform,
|
||||
arg.PostID,
|
||||
arg.PostText,
|
||||
arg.PostTimestamp,
|
||||
arg.Likes,
|
||||
arg.Reposts,
|
||||
arg.Replies,
|
||||
arg.Quotes,
|
||||
)
|
||||
return err
|
||||
}
|
||||
25
internal/store/DeleteStylePosts.sql.go
Normal file
25
internal/store/DeleteStylePosts.sql.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: DeleteStylePosts.sql
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const deleteStylePosts = `-- name: DeleteStylePosts :exec
|
||||
DELETE FROM user_style_posts
|
||||
WHERE user_id = $1 AND platform = $2 AND platform != 'Twitter'
|
||||
`
|
||||
|
||||
type DeleteStylePostsParams struct {
|
||||
UserID string
|
||||
Platform string
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteStylePosts(ctx context.Context, arg DeleteStylePostsParams) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteStylePosts, arg.UserID, arg.Platform)
|
||||
return err
|
||||
}
|
||||
|
|
@ -20,8 +20,8 @@ LIMIT $2
|
|||
`
|
||||
|
||||
type FindSimilarUsersParams struct {
|
||||
Column1 interface{}
|
||||
Limit int32
|
||||
QueryEmbedding interface{}
|
||||
LimitVal int32
|
||||
}
|
||||
|
||||
type FindSimilarUsersRow struct {
|
||||
|
|
@ -35,7 +35,7 @@ type FindSimilarUsersRow struct {
|
|||
}
|
||||
|
||||
func (q *Queries) FindSimilarUsers(ctx context.Context, arg FindSimilarUsersParams) ([]FindSimilarUsersRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, findSimilarUsers, arg.Column1, arg.Limit)
|
||||
rows, err := q.db.QueryContext(ctx, findSimilarUsers, arg.QueryEmbedding, arg.LimitVal)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
33
internal/store/GetStylePostByID.sql.go
Normal file
33
internal/store/GetStylePostByID.sql.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: GetStylePostByID.sql
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const getStylePostByID = `-- name: GetStylePostByID :one
|
||||
SELECT post_id, post_text FROM user_style_posts
|
||||
WHERE user_id = $1 AND platform = $2 AND post_id = $3
|
||||
`
|
||||
|
||||
type GetStylePostByIDParams struct {
|
||||
UserID string
|
||||
Platform string
|
||||
PostID string
|
||||
}
|
||||
|
||||
type GetStylePostByIDRow struct {
|
||||
PostID string
|
||||
PostText string
|
||||
}
|
||||
|
||||
func (q *Queries) GetStylePostByID(ctx context.Context, arg GetStylePostByIDParams) (GetStylePostByIDRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getStylePostByID, arg.UserID, arg.Platform, arg.PostID)
|
||||
var i GetStylePostByIDRow
|
||||
err := row.Scan(&i.PostID, &i.PostText)
|
||||
return i, err
|
||||
}
|
||||
51
internal/store/GetStylePosts.sql.go
Normal file
51
internal/store/GetStylePosts.sql.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: GetStylePosts.sql
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const getStylePosts = `-- name: GetStylePosts :many
|
||||
SELECT post_id, post_text FROM user_style_posts
|
||||
WHERE user_id = $1 AND platform = $2
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $3
|
||||
`
|
||||
|
||||
type GetStylePostsParams struct {
|
||||
UserID string
|
||||
Platform string
|
||||
Limit int32
|
||||
}
|
||||
|
||||
type GetStylePostsRow struct {
|
||||
PostID string
|
||||
PostText string
|
||||
}
|
||||
|
||||
func (q *Queries) GetStylePosts(ctx context.Context, arg GetStylePostsParams) ([]GetStylePostsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getStylePosts, arg.UserID, arg.Platform, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetStylePostsRow
|
||||
for rows.Next() {
|
||||
var i GetStylePostsRow
|
||||
if err := rows.Scan(&i.PostID, &i.PostText); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
51
internal/store/GetStylePostsByIDs.sql.go
Normal file
51
internal/store/GetStylePostsByIDs.sql.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: GetStylePostsByIDs.sql
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
const getStylePostsByIDs = `-- name: GetStylePostsByIDs :many
|
||||
SELECT post_id, post_text FROM user_style_posts
|
||||
WHERE user_id = $1 AND platform = $2 AND post_id = ANY($3::text[])
|
||||
`
|
||||
|
||||
type GetStylePostsByIDsParams struct {
|
||||
UserID string
|
||||
Platform string
|
||||
Column3 []string
|
||||
}
|
||||
|
||||
type GetStylePostsByIDsRow struct {
|
||||
PostID string
|
||||
PostText string
|
||||
}
|
||||
|
||||
func (q *Queries) GetStylePostsByIDs(ctx context.Context, arg GetStylePostsByIDsParams) ([]GetStylePostsByIDsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getStylePostsByIDs, arg.UserID, arg.Platform, pq.Array(arg.Column3))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetStylePostsByIDsRow
|
||||
for rows.Next() {
|
||||
var i GetStylePostsByIDsRow
|
||||
if err := rows.Scan(&i.PostID, &i.PostText); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ import (
|
|||
)
|
||||
|
||||
const getStyleProfile = `-- name: GetStyleProfile :one
|
||||
SELECT id, user_id, platform, embedding, top_keywords, style_summary, post_count, modified_at, created_at FROM user_style_profiles
|
||||
SELECT id, user_id, platform, embedding, top_keywords, style_summary, post_count, modified_at, created_at, model_version, schema_version, algorithm_version FROM user_style_profiles
|
||||
WHERE user_id = $1 AND platform = $2
|
||||
`
|
||||
|
||||
|
|
@ -34,6 +34,9 @@ func (q *Queries) GetStyleProfile(ctx context.Context, arg GetStyleProfileParams
|
|||
&i.PostCount,
|
||||
&i.ModifiedAt,
|
||||
&i.CreatedAt,
|
||||
&i.ModelVersion,
|
||||
&i.SchemaVersion,
|
||||
&i.AlgorithmVersion,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
|
|||
20
internal/store/PruneStylePosts.sql.go
Normal file
20
internal/store/PruneStylePosts.sql.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: PruneStylePosts.sql
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const pruneStylePosts = `-- name: PruneStylePosts :exec
|
||||
DELETE FROM user_style_posts
|
||||
WHERE created_at < now() - interval '7 days' AND platform != 'Twitter'
|
||||
`
|
||||
|
||||
func (q *Queries) PruneStylePosts(ctx context.Context) error {
|
||||
_, err := q.db.ExecContext(ctx, pruneStylePosts)
|
||||
return err
|
||||
}
|
||||
34
internal/store/UpsertStylePosts.sql.go
Normal file
34
internal/store/UpsertStylePosts.sql.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: UpsertStylePosts.sql
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const upsertStylePosts = `-- name: UpsertStylePosts :exec
|
||||
INSERT INTO user_style_posts (user_id, platform, post_id, post_text)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_id, platform, post_id)
|
||||
DO UPDATE SET post_text = EXCLUDED.post_text, created_at = now()
|
||||
`
|
||||
|
||||
type UpsertStylePostsParams struct {
|
||||
UserID string
|
||||
Platform string
|
||||
PostID string
|
||||
PostText string
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertStylePosts(ctx context.Context, arg UpsertStylePostsParams) error {
|
||||
_, err := q.db.ExecContext(ctx, upsertStylePosts,
|
||||
arg.UserID,
|
||||
arg.Platform,
|
||||
arg.PostID,
|
||||
arg.PostText,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
|
@ -13,14 +13,14 @@ import (
|
|||
|
||||
const upsertStyleProfile = `-- name: UpsertStyleProfile :exec
|
||||
INSERT INTO user_style_profiles (user_id, platform, embedding, top_keywords, style_summary, post_count)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
VALUES ($1, $2, $3::vector, $4, $5, $6)
|
||||
ON CONFLICT (user_id, platform)
|
||||
DO UPDATE SET
|
||||
embedding = EXCLUDED.embedding,
|
||||
top_keywords = EXCLUDED.top_keywords,
|
||||
style_summary = EXCLUDED.style_summary,
|
||||
post_count = EXCLUDED.post_count,
|
||||
updated_at = NOW()
|
||||
modified_at = NOW()
|
||||
`
|
||||
|
||||
type UpsertStyleProfileParams struct {
|
||||
|
|
|
|||
194
internal/store/integration_test.go
Normal file
194
internal/store/integration_test.go
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// setupTestDB handles connection pooling, migrations, and cleanup per test run.
|
||||
func setupTestDB(t *testing.T) (*sql.DB, *Queries) {
|
||||
t.Helper()
|
||||
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set; skipping store integration test")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
conn, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open db: %v", err)
|
||||
}
|
||||
|
||||
if err := conn.PingContext(ctx); err != nil {
|
||||
_ = conn.Close()
|
||||
t.Fatalf("cannot reach test database: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = conn.Close()
|
||||
})
|
||||
|
||||
if err := Migrate(ctx, conn, "sql/schema"); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
return conn, New(conn)
|
||||
}
|
||||
|
||||
func makeEmbedding(dim int, val string) string {
|
||||
return "[" + strings.TrimSuffix(strings.Repeat(val+",", dim), ",") + "]"
|
||||
}
|
||||
|
||||
func TestStoreRoundTrip(t *testing.T) {
|
||||
conn, q := setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
uid := fmt.Sprintf("test_%d", time.Now().UnixNano())
|
||||
platform := "Bluesky"
|
||||
emb1 := makeEmbedding(1024, "0.1")
|
||||
|
||||
// Clean up after the test so leftover records do not pollute subsequent runs
|
||||
t.Cleanup(func() {
|
||||
_, _ = conn.ExecContext(context.Background(), "DELETE FROM style_profiles WHERE user_id = $1", uid)
|
||||
})
|
||||
|
||||
// 1. Test INSERT path of Upsert
|
||||
err := q.UpsertStyleProfile(ctx, UpsertStyleProfileParams{
|
||||
UserID: uid,
|
||||
Platform: platform,
|
||||
Embedding: emb1,
|
||||
TopKeywords: []string{"alpha", "beta"},
|
||||
StyleSummary: "original summary",
|
||||
PostCount: 5,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("initial upsert failed: %v", err)
|
||||
}
|
||||
|
||||
// 2. Test GET and verify fields
|
||||
p, err := q.GetStyleProfile(ctx, GetStyleProfileParams{UserID: uid, Platform: platform})
|
||||
if err != nil {
|
||||
t.Fatalf("get failed: %v", err)
|
||||
}
|
||||
|
||||
if p.UserID != uid || p.Platform != platform || p.PostCount != 5 {
|
||||
t.Errorf("mismatched basic fields: %+v", p)
|
||||
}
|
||||
if p.StyleSummary != "original summary" {
|
||||
t.Errorf("got summary %q, want %q", p.StyleSummary, "original summary")
|
||||
}
|
||||
if !reflect.DeepEqual(p.TopKeywords, []string{"alpha", "beta"}) {
|
||||
t.Errorf("got keywords %v, want %v", p.TopKeywords, []string{"alpha", "beta"})
|
||||
}
|
||||
|
||||
// 3. Test UPDATE path of Upsert (Verify conflict resolution works)
|
||||
emb2 := makeEmbedding(1024, "0.2")
|
||||
err = q.UpsertStyleProfile(ctx, UpsertStyleProfileParams{
|
||||
UserID: uid,
|
||||
Platform: platform,
|
||||
Embedding: emb2,
|
||||
TopKeywords: []string{"gamma"},
|
||||
StyleSummary: "updated summary",
|
||||
PostCount: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("secondary upsert (update) failed: %v", err)
|
||||
}
|
||||
|
||||
updated, err := q.GetStyleProfile(ctx, GetStyleProfileParams{UserID: uid, Platform: platform})
|
||||
if err != nil {
|
||||
t.Fatalf("get updated profile failed: %v", err)
|
||||
}
|
||||
if updated.PostCount != 10 || updated.StyleSummary != "updated summary" {
|
||||
t.Errorf("upsert failed to update existing record: %+v", updated)
|
||||
}
|
||||
|
||||
// 4. Test pgvector similarity search
|
||||
rows, err := q.FindSimilarUsers(ctx, FindSimilarUsersParams{
|
||||
QueryEmbedding: emb2,
|
||||
LimitVal: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("find similar failed: %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, r := range rows {
|
||||
if r.UserID == uid {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected user %s in similarity results, but was not found", uid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreSaveTransaction(t *testing.T) {
|
||||
conn, q := setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
txHash := fmt.Sprintf("0x_%d", time.Now().UnixNano())
|
||||
amount := "10.0"
|
||||
asset := "ETH"
|
||||
network := "eip155:1"
|
||||
recipient := "0x1234567890abcdef"
|
||||
sender := "0xabcdef1234567890"
|
||||
|
||||
t.Cleanup(func() {
|
||||
_, _ = conn.ExecContext(context.Background(), "DELETE FROM transactions WHERE x402_transaction_hash = $1", txHash)
|
||||
})
|
||||
|
||||
ret, err := q.SaveTransaction(ctx, SaveTransactionParams{
|
||||
X402TransactionHash: txHash,
|
||||
Amount: amount,
|
||||
Asset: asset,
|
||||
Network: network,
|
||||
Recipient: recipient,
|
||||
Sender: sender,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveTransaction error: %v", err)
|
||||
}
|
||||
|
||||
if len(ret) != 1 {
|
||||
t.Fatalf("expected 1 result row returned, got %d", len(ret))
|
||||
}
|
||||
|
||||
saved := ret[0]
|
||||
if saved.ID <= 0 {
|
||||
t.Errorf("expected positive ID, got %d", saved.ID)
|
||||
}
|
||||
if !saved.CreatedAt.Valid {
|
||||
t.Error("expected CreatedAt to be set")
|
||||
}
|
||||
|
||||
// Since the sqlc query only returns `id` and `created_at`,
|
||||
// verify directly that PostgreSQL stored the remaining columns accurately:
|
||||
var (
|
||||
gotHash, gotAmount, gotAsset, gotNetwork, gotRecipient, gotSender string
|
||||
)
|
||||
err = conn.QueryRowContext(ctx, `
|
||||
SELECT x402_transaction_hash, amount, asset, network, recipient, sender
|
||||
FROM transactions
|
||||
WHERE id = $1
|
||||
`, saved.ID).Scan(&gotHash, &gotAmount, &gotAsset, &gotNetwork, &gotRecipient, &gotSender)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query inserted transaction from db: %v", err)
|
||||
}
|
||||
|
||||
if gotHash != txHash || gotAmount != amount || gotAsset != asset ||
|
||||
gotNetwork != network || gotRecipient != recipient || gotSender != sender {
|
||||
t.Errorf("stored values do not match input params: got (%s, %s, %s, %s, %s, %s)",
|
||||
gotHash, gotAmount, gotAsset, gotNetwork, gotRecipient, gotSender)
|
||||
}
|
||||
}
|
||||
106
internal/store/migrate.go
Normal file
106
internal/store/migrate.go
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Migrate applies every *.sql file in dir (in filename order). Each file is expected to
|
||||
// contain a "-- +goose up" section (between that marker and "-- +goose down", or EOF) which
|
||||
// is executed inside a single transaction. Applied versions are recorded in
|
||||
// schema_migrations so re-running is a no-op.
|
||||
func Migrate(ctx context.Context, db *sql.DB, dir string) error {
|
||||
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("create schema_migrations table: %w", err)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migrations dir %q: %w", dir, err)
|
||||
}
|
||||
|
||||
type migration struct{ name, up string }
|
||||
migrations := make([]migration, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration %q: %w", e.Name(), err)
|
||||
}
|
||||
migrations = append(migrations, migration{name: e.Name(), up: extractUpSQL(string(raw))})
|
||||
}
|
||||
sort.Slice(migrations, func(i, j int) bool { return migrations[i].name < migrations[j].name })
|
||||
|
||||
for _, m := range migrations {
|
||||
if strings.TrimSpace(m.up) == "" {
|
||||
continue
|
||||
}
|
||||
applied, err := versionApplied(ctx, db, m.name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if applied {
|
||||
continue
|
||||
}
|
||||
if err := applyMigration(ctx, db, m.name, m.up); err != nil {
|
||||
return fmt.Errorf("apply migration %q: %w", m.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractUpSQL returns the SQL between the "-- +goose up" marker and the next
|
||||
// "-- +goose down" marker (or end of file).
|
||||
func extractUpSQL(content string) string {
|
||||
var b strings.Builder
|
||||
inUp := false
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
switch strings.TrimSpace(line) {
|
||||
case "-- +goose up":
|
||||
inUp = true
|
||||
continue
|
||||
case "-- +goose down":
|
||||
inUp = false
|
||||
continue
|
||||
}
|
||||
if inUp {
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func versionApplied(ctx context.Context, db *sql.DB, version string) (bool, error) {
|
||||
var count int
|
||||
if err := db.QueryRowContext(ctx, `SELECT count(*) FROM schema_migrations WHERE version = $1`, version).Scan(&count); err != nil {
|
||||
return false, fmt.Errorf("check migration %q: %w", version, err)
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func applyMigration(ctx context.Context, db *sql.DB, version, up string) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, up); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations (version) VALUES ($1)`, version); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
|
@ -6,8 +6,43 @@ package store
|
|||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AnalyticsCache struct {
|
||||
ID int64
|
||||
UserID string
|
||||
Platform string
|
||||
ToolName string
|
||||
TimeRangeHash string
|
||||
Result json.RawMessage
|
||||
ComputedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type AnalyticsCoverageRange struct {
|
||||
UserID string
|
||||
Platform string
|
||||
CoveredStart time.Time
|
||||
CoveredEnd time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type AnalyticsPost struct {
|
||||
ID int64
|
||||
UserID string
|
||||
Platform string
|
||||
PostID string
|
||||
PostText string
|
||||
PostTimestamp time.Time
|
||||
Likes int64
|
||||
Reposts int64
|
||||
Replies int64
|
||||
Quotes int64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Transaction struct {
|
||||
ID int64
|
||||
X402TransactionHash string
|
||||
|
|
@ -19,14 +54,26 @@ type Transaction struct {
|
|||
CreatedAt sql.NullTime
|
||||
}
|
||||
|
||||
type UserStyleProfile struct {
|
||||
ID int64
|
||||
UserID string
|
||||
Platform string
|
||||
Embedding interface{}
|
||||
TopKeywords []string
|
||||
StyleSummary string
|
||||
PostCount int32
|
||||
ModifiedAt sql.NullTime
|
||||
CreatedAt sql.NullTime
|
||||
type UserStylePost struct {
|
||||
ID int64
|
||||
UserID string
|
||||
Platform string
|
||||
PostID string
|
||||
PostText string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type UserStyleProfile struct {
|
||||
ID int64
|
||||
UserID string
|
||||
Platform string
|
||||
Embedding interface{}
|
||||
TopKeywords []string
|
||||
StyleSummary string
|
||||
PostCount int32
|
||||
ModifiedAt sql.NullTime
|
||||
CreatedAt sql.NullTime
|
||||
ModelVersion int32
|
||||
SchemaVersion int32
|
||||
AlgorithmVersion int32
|
||||
}
|
||||
|
|
|
|||
369
internal/twitter/client.go
Normal file
369
internal/twitter/client.go
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
package twitter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/nacorid/logger"
|
||||
)
|
||||
|
||||
func parseTwitterTime(s string) time.Time {
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
log.Warn("Failed to parse Twitter timestamp", "timestamp", s, "error", err)
|
||||
return time.Time{}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
const apiBase = "https://api.x.com/2"
|
||||
|
||||
// httpTimeout bounds a single HTTP request so a stalled X backend cannot block
|
||||
// a request indefinitely.
|
||||
const httpTimeout = 60 * time.Second
|
||||
|
||||
const userAgent = "naco-api/1.0"
|
||||
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
bearerToken string
|
||||
}
|
||||
|
||||
func NewTwitterClient(bearerToken string) *Client {
|
||||
return &Client{
|
||||
httpClient: &http.Client{Timeout: httpTimeout},
|
||||
bearerToken: bearerToken,
|
||||
}
|
||||
}
|
||||
|
||||
type Post struct {
|
||||
ID string
|
||||
Author string
|
||||
Content string
|
||||
CreatedAt time.Time
|
||||
Likes int
|
||||
Reposts int
|
||||
Quotes int
|
||||
Replies int
|
||||
}
|
||||
|
||||
type userResponse struct {
|
||||
Data struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Username string `json:"username"`
|
||||
} `json:"data"`
|
||||
Errors []struct {
|
||||
Title string `json:"title"`
|
||||
Detail string `json:"detail"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
|
||||
type tweetMetrics struct {
|
||||
RetweetCount int `json:"retweet_count"`
|
||||
ReplyCount int `json:"reply_count"`
|
||||
LikeCount int `json:"like_count"`
|
||||
QuoteCount int `json:"quote_count"`
|
||||
}
|
||||
|
||||
type tweetData struct {
|
||||
ID string `json:"id"`
|
||||
Text string `json:"text"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
PublicMetrics tweetMetrics `json:"public_metrics"`
|
||||
}
|
||||
|
||||
type tweetsResponse struct {
|
||||
Data []tweetData `json:"data"`
|
||||
Includes struct {
|
||||
Users []struct {
|
||||
Username string `json:"username"`
|
||||
} `json:"users"`
|
||||
} `json:"includes"`
|
||||
Errors []struct {
|
||||
Title string `json:"title"`
|
||||
Detail string `json:"detail"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
|
||||
type singleTweetResponse struct {
|
||||
Data struct {
|
||||
ID string `json:"id"`
|
||||
Text string `json:"text"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
PublicMetrics tweetMetrics `json:"public_metrics"`
|
||||
} `json:"data"`
|
||||
Includes struct {
|
||||
Users []struct {
|
||||
Username string `json:"username"`
|
||||
} `json:"users"`
|
||||
} `json:"includes"`
|
||||
Errors []struct {
|
||||
Title string `json:"title"`
|
||||
Detail string `json:"detail"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
|
||||
const (
|
||||
maxRetries = 4
|
||||
maxRateLimitWait = 30 * time.Second // Never stall a request longer than 30s
|
||||
)
|
||||
|
||||
var defaultBackoff = 500 * time.Millisecond
|
||||
|
||||
func (c *Client) doGet(ctx context.Context, endpoint string) ([]byte, error) {
|
||||
backoff := defaultBackoff
|
||||
|
||||
for attempt := 0; ; attempt++ {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.bearerToken)
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
// Transport-level failure (connection refused/reset, timeout).
|
||||
if attempt >= maxRetries {
|
||||
return nil, fmt.Errorf("twitter request failed after %d attempts: %w", maxRetries+1, err)
|
||||
}
|
||||
log.WarnWithContext(ctx, "twitter transport error, waiting to retry", "attempt", attempt+1, "error", err)
|
||||
if werr := waitForRetry(ctx, backoff); werr != nil {
|
||||
return nil, werr
|
||||
}
|
||||
backoff *= 2
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle 429 Too Many Requests
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
// Drain & close body before retrying.
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
if attempt >= maxRetries {
|
||||
return nil, fmt.Errorf("twitter rate limit exceeded, max retries reached")
|
||||
}
|
||||
|
||||
wait := parseRateLimitWait(resp.Header, backoff)
|
||||
|
||||
// Protect against 15-minute thread freezes
|
||||
if wait > maxRateLimitWait {
|
||||
return nil, fmt.Errorf("twitter rate limited: reset wait (%s) exceeds maximum allowed (%s)", wait, maxRateLimitWait)
|
||||
}
|
||||
|
||||
log.WarnWithContext(ctx, "twitter rate limited, waiting to retry", "attempt", attempt+1, "wait", wait.String())
|
||||
if werr := waitForRetry(ctx, wait); werr != nil {
|
||||
return nil, werr
|
||||
}
|
||||
backoff *= 2
|
||||
continue
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
|
||||
_ = resp.Body.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
// Retry transient server-side failures (5xx) with backoff. 4xx/3xx fail
|
||||
// fast below.
|
||||
if resp.StatusCode >= http.StatusInternalServerError && attempt < maxRetries {
|
||||
log.WarnWithContext(ctx, "twitter server error, waiting to retry", "attempt", attempt+1, "status", resp.Status)
|
||||
if werr := waitForRetry(ctx, backoff); werr != nil {
|
||||
return nil, werr
|
||||
}
|
||||
backoff *= 2
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
errMsg := strings.TrimSpace(string(body))
|
||||
if len(errMsg) > 512 {
|
||||
errMsg = errMsg[:512] + "... (truncated)"
|
||||
}
|
||||
return nil, fmt.Errorf("twitter error (%s): %s", resp.Status, errMsg)
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
}
|
||||
|
||||
// waitForRetry sleeps for wait, aborting early if the context is canceled.
|
||||
// A timer is used (not time.After) so the timer can be stopped to free its
|
||||
// resource when the caller abandons the wait.
|
||||
func waitForRetry(ctx context.Context, wait time.Duration) error {
|
||||
timer := time.NewTimer(wait)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// parseRateLimitWait extracts x-rate-limit-* or Retry-After, falling back to backoff.
|
||||
func parseRateLimitWait(header http.Header, fallback time.Duration) time.Duration {
|
||||
// 1. Check x-rate-limit-reset (Unix timestamp)
|
||||
if remStr := header.Get("x-rate-limit-remaining"); remStr != "" {
|
||||
if rem, err := strconv.Atoi(remStr); err == nil && rem <= 0 {
|
||||
if rstStr := header.Get("x-rate-limit-reset"); rstStr != "" {
|
||||
if rst, err := strconv.ParseInt(rstStr, 10, 64); err == nil {
|
||||
// Add 1s clock skew buffer
|
||||
wait := time.Until(time.Unix(rst, 0)) + (1 * time.Second)
|
||||
if wait > 0 {
|
||||
return wait
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check standard Retry-After (seconds)
|
||||
if ra := header.Get("Retry-After"); ra != "" {
|
||||
if secs, err := strconv.Atoi(ra); err == nil && secs > 0 {
|
||||
return time.Duration(secs)*time.Second + (1 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
func (c *Client) resolveUser(ctx context.Context, username string) (id, handle string, err error) {
|
||||
endpoint := fmt.Sprintf("%s/users/by/username/%s", apiBase, url.PathEscape(username))
|
||||
body, err := c.doGet(ctx, endpoint)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
var ur userResponse
|
||||
if err := json.Unmarshal(body, &ur); err != nil {
|
||||
return "", "", fmt.Errorf("failed to unmarshal user response: %w", err)
|
||||
}
|
||||
if len(ur.Errors) > 0 {
|
||||
return "", "", fmt.Errorf("twitter error: %s: %s", ur.Errors[0].Title, ur.Errors[0].Detail)
|
||||
}
|
||||
if ur.Data.ID == "" {
|
||||
return "", "", fmt.Errorf("twitter user %q not found", username)
|
||||
}
|
||||
return ur.Data.ID, ur.Data.Username, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*Post, error) {
|
||||
if c.bearerToken == "" {
|
||||
return nil, fmt.Errorf("twitter bearer token not configured")
|
||||
}
|
||||
username := strings.TrimPrefix(actor, "@")
|
||||
|
||||
userID, handle, err := c.resolveUser(ctx, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/users/%s/tweets?max_results=%d&tweet.fields=created_at,public_metrics&exclude=retweets,replies",
|
||||
apiBase, url.PathEscape(userID), limit)
|
||||
return c.fetchTweets(ctx, handle, endpoint)
|
||||
}
|
||||
|
||||
func (c *Client) GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]*Post, error) {
|
||||
if c.bearerToken == "" {
|
||||
return nil, fmt.Errorf("twitter bearer token not configured")
|
||||
}
|
||||
username := strings.TrimPrefix(actor, "@")
|
||||
|
||||
userID, handle, err := c.resolveUser(ctx, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/users/%s/tweets?start_time=%s&end_time=%s&max_results=%d&tweet.fields=created_at,public_metrics&exclude=retweets,replies",
|
||||
apiBase, url.PathEscape(userID), start.Format(time.RFC3339), end.Format(time.RFC3339), limit)
|
||||
return c.fetchTweets(ctx, handle, endpoint)
|
||||
}
|
||||
|
||||
// fetchTweets performs the request and converts the tweet payload into Posts,
|
||||
// skipping entries without an ID so downstream dedupe keys are never empty.
|
||||
func (c *Client) fetchTweets(ctx context.Context, username, endpoint string) ([]*Post, error) {
|
||||
body, err := c.doGet(ctx, endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var tr tweetsResponse
|
||||
if err := json.Unmarshal(body, &tr); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal tweets response: %w", err)
|
||||
}
|
||||
if len(tr.Errors) > 0 {
|
||||
return nil, fmt.Errorf("twitter error: %s: %s", tr.Errors[0].Title, tr.Errors[0].Detail)
|
||||
}
|
||||
|
||||
posts := make([]*Post, 0, len(tr.Data))
|
||||
for _, t := range tr.Data {
|
||||
if t.ID == "" {
|
||||
continue
|
||||
}
|
||||
posts = append(posts, &Post{
|
||||
ID: t.ID,
|
||||
Author: username,
|
||||
Content: t.Text,
|
||||
CreatedAt: parseTwitterTime(t.CreatedAt),
|
||||
Likes: t.PublicMetrics.LikeCount,
|
||||
Reposts: t.PublicMetrics.RetweetCount,
|
||||
Quotes: t.PublicMetrics.QuoteCount,
|
||||
Replies: t.PublicMetrics.ReplyCount,
|
||||
})
|
||||
}
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetPost(ctx context.Context, id string) (*Post, error) {
|
||||
if c.bearerToken == "" {
|
||||
return nil, fmt.Errorf("twitter bearer token not configured")
|
||||
}
|
||||
endpoint := fmt.Sprintf("%s/tweets/%s?tweet.fields=created_at,public_metrics&expansions=author_id&user.fields=username",
|
||||
apiBase, url.PathEscape(id))
|
||||
body, err := c.doGet(ctx, endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var tr singleTweetResponse
|
||||
if err := json.Unmarshal(body, &tr); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal tweet response: %w", err)
|
||||
}
|
||||
if len(tr.Errors) > 0 {
|
||||
return nil, fmt.Errorf("twitter error: %s: %s", tr.Errors[0].Title, tr.Errors[0].Detail)
|
||||
}
|
||||
if tr.Data.ID == "" {
|
||||
return nil, fmt.Errorf("tweet %q not found", id)
|
||||
}
|
||||
author := "unknown"
|
||||
if len(tr.Includes.Users) > 0 {
|
||||
author = tr.Includes.Users[0].Username
|
||||
}
|
||||
return &Post{
|
||||
ID: tr.Data.ID,
|
||||
Author: author,
|
||||
Content: tr.Data.Text,
|
||||
CreatedAt: parseTwitterTime(tr.Data.CreatedAt),
|
||||
Likes: tr.Data.PublicMetrics.LikeCount,
|
||||
Reposts: tr.Data.PublicMetrics.RetweetCount,
|
||||
Quotes: tr.Data.PublicMetrics.QuoteCount,
|
||||
Replies: tr.Data.PublicMetrics.ReplyCount,
|
||||
}, nil
|
||||
}
|
||||
445
internal/twitter/client_test.go
Normal file
445
internal/twitter/client_test.go
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
package twitter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Speed up test suite: override defaultBackoff to 1ms so retries are instantaneous.
|
||||
func init() {
|
||||
defaultBackoff = 1 * time.Millisecond
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||
|
||||
// newTestClient returns a Client whose requests are rewritten to the given httptest server.
|
||||
func newTestClient(serverURL string) *Client {
|
||||
c := NewTwitterClient("test-token")
|
||||
host := strings.TrimPrefix(serverURL, "http://")
|
||||
c.httpClient = &http.Client{
|
||||
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
u := *r.URL
|
||||
u.Scheme = "http"
|
||||
u.Host = host
|
||||
r2 := r.Clone(r.Context())
|
||||
r2.URL = &u
|
||||
return http.DefaultTransport.RoundTrip(r2)
|
||||
}),
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestGetLatestNPostsNoToken(t *testing.T) {
|
||||
c := NewTwitterClient("")
|
||||
if _, err := c.GetLatestNPosts(context.Background(), "alice", 10); err == nil {
|
||||
t.Fatal("expected error when bearer token is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestNPosts(t *testing.T) {
|
||||
var tweetsURL string
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer test-token" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"data":{"id":"12345","name":"Test","username":"testuser"}}`)
|
||||
})
|
||||
mux.HandleFunc("/2/users/12345/tweets", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer test-token" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
tweetsURL = r.URL.String()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"data":[{"id":"1","text":"hello world","created_at":"2024-01-01T00:00:00Z"}]}`)
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
c := newTestClient(server.URL)
|
||||
posts, err := c.GetLatestNPosts(context.Background(), "@testuser", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestNPosts error: %v", err)
|
||||
}
|
||||
if len(posts) != 1 {
|
||||
t.Fatalf("expected 1 post, got %d", len(posts))
|
||||
}
|
||||
if posts[0].Content != "hello world" || posts[0].Author != "testuser" {
|
||||
t.Fatalf("unexpected post: %+v", posts[0])
|
||||
}
|
||||
if !strings.Contains(tweetsURL, "max_results=10") {
|
||||
t.Fatalf("expected max_results=10, got %q", tweetsURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestNPostsLimitNoLowerFloor(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"data":{"id":"1","username":"u"}}`)
|
||||
})
|
||||
mux.HandleFunc("/2/users/1/tweets", func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.URL.Query().Get("max_results"); got != "3" {
|
||||
t.Fatalf("expected max_results=3 for limit=3, got %q", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"data":[]}`)
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
c := newTestClient(server.URL)
|
||||
if _, err := c.GetLatestNPosts(context.Background(), "u", 3); err != nil {
|
||||
t.Fatalf("error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestNPostsHTTPError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := newTestClient(server.URL)
|
||||
if _, err := c.GetLatestNPosts(context.Background(), "u", 10); err == nil {
|
||||
t.Fatal("expected error on non-200 status")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPost(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"data":{"id":"12345","username":"testuser"}}`)
|
||||
})
|
||||
mux.HandleFunc("/2/tweets/999", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer test-token" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"data":{"id":"999","text":"a single tweet","created_at":"2024-02-02T00:00:00Z","public_metrics":{"retweet_count":2,"reply_count":1,"like_count":5,"quote_count":0}},"includes":{"users":[{"username":"testuser"}]}}`)
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
c := newTestClient(server.URL)
|
||||
p, err := c.GetPost(context.Background(), "999")
|
||||
if err != nil {
|
||||
t.Fatalf("GetPost error: %v", err)
|
||||
}
|
||||
if p.ID != "999" || p.Content != "a single tweet" || p.Author != "testuser" {
|
||||
t.Fatalf("unexpected post: %+v", p)
|
||||
}
|
||||
if p.Likes != 5 || p.Reposts != 2 || p.Replies != 1 || p.Quotes != 0 {
|
||||
t.Fatalf("unexpected metrics: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPostsInTimeframe(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"data":{"id":"12345","username":"testuser"}}`)
|
||||
})
|
||||
mux.HandleFunc("/2/users/12345/tweets", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer test-token" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("start_time") == "" || r.URL.Query().Get("end_time") == "" {
|
||||
t.Fatalf("expected start_time and end_time query params")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"data":[{"id":"1","text":"timed post","created_at":"2024-03-03T00:00:00Z","public_metrics":{"retweet_count":0,"reply_count":0,"like_count":3,"quote_count":0}}]}`)
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
c := newTestClient(server.URL)
|
||||
start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
end := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC)
|
||||
posts, err := c.GetPostsInTimeframe(context.Background(), "testuser", start, end, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPostsInTimeframe error: %v", err)
|
||||
}
|
||||
if len(posts) != 1 {
|
||||
t.Fatalf("expected 1 post, got %d", len(posts))
|
||||
}
|
||||
if posts[0].Content != "timed post" || posts[0].Likes != 3 {
|
||||
t.Fatalf("unexpected post: %+v", posts[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitRetryWithResetHeader(t *testing.T) {
|
||||
callCount := 0
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
if callCount == 1 {
|
||||
// First call: rate limited with reset header slightly in the past/present
|
||||
w.Header().Set("x-rate-limit-remaining", "0")
|
||||
w.Header().Set("x-rate-limit-reset", fmt.Sprintf("%d", time.Now().Unix()))
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
// Second call: success
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"data":{"id":"12345","name":"Test","username":"testuser"}}`)
|
||||
})
|
||||
mux.HandleFunc("/2/users/12345/tweets", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"data":[{"id":"1","text":"hello","created_at":"2024-01-01T00:00:00Z"}]}`)
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
c := newTestClient(server.URL)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
posts, err := c.GetLatestNPosts(ctx, "@testuser", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestNPosts error after retry: %v", err)
|
||||
}
|
||||
if callCount != 2 {
|
||||
t.Fatalf("expected 2 calls (1 retry), got %d", callCount)
|
||||
}
|
||||
if len(posts) != 1 {
|
||||
t.Fatalf("expected 1 post after retry, got %d", len(posts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitExhaustedAfterMaxRetries(t *testing.T) {
|
||||
callCount := 0
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
w.Header().Set("x-rate-limit-remaining", "0")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
c := newTestClient(server.URL)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := c.GetLatestNPosts(ctx, "@testuser", 10)
|
||||
if err == nil {
|
||||
t.Fatal("expected error after max retries exhausted")
|
||||
}
|
||||
if callCount < 5 {
|
||||
t.Fatalf("expected at least 5 attempts (1 initial + 4 retries), got %d", callCount)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "rate limit") {
|
||||
t.Fatalf("expected rate limit error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserLookupNotFound(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"errors":[{"title":"Not Found","detail":"User not found"}]}`)
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
c := newTestClient(server.URL)
|
||||
_, err := c.GetLatestNPosts(context.Background(), "@nonexistent", 10)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for user not found")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "twitter error") || !strings.Contains(err.Error(), "Not Found") {
|
||||
t.Fatalf("expected 'Not Found' error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserLookupUnauthorized(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"errors":[{"title":"Unauthorized","detail":"Invalid token"}]}`)
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
c := newTestClient(server.URL)
|
||||
_, err := c.GetLatestNPosts(context.Background(), "@testuser", 10)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unauthorized")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "twitter error") || !strings.Contains(err.Error(), "Unauthorized") {
|
||||
t.Fatalf("expected 'Unauthorized' error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserLookupRateLimited(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
c := newTestClient(server.URL)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := c.GetLatestNPosts(ctx, "@testuser", 10)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for rate limited user lookup")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "rate limit") {
|
||||
t.Fatalf("expected rate limit error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextCancellationDuringRetry(t *testing.T) {
|
||||
reqReceived := make(chan struct{})
|
||||
var once sync.Once
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("x-rate-limit-remaining", "0")
|
||||
w.Header().Set("x-rate-limit-reset", fmt.Sprintf("%d", time.Now().Add(10*time.Second).Unix()))
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
once.Do(func() {
|
||||
close(reqReceived) // Signal test that the 429 response was returned
|
||||
})
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
c := newTestClient(server.URL)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := c.GetLatestNPosts(ctx, "@testuser", 10)
|
||||
done <- err
|
||||
}()
|
||||
|
||||
// Deterministic wait: block until the mock server receives the request, then cancel immediately
|
||||
select {
|
||||
case <-reqReceived:
|
||||
cancel()
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for mock request to be hit")
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil {
|
||||
t.Fatal("expected error from context cancellation")
|
||||
}
|
||||
if !errors.Is(err, context.Canceled) && !strings.Contains(err.Error(), "context canceled") {
|
||||
t.Fatalf("expected context.Canceled, got %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("test timed out waiting for goroutine to abort")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUserEmptyData(t *testing.T) {
|
||||
// 200 with an empty data object (banned/suspended or privacy-restricted
|
||||
// account) must error rather than build a bogus /users//tweets request.
|
||||
var tweetsCalled bool
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"data":{}}`)
|
||||
})
|
||||
mux.HandleFunc("/2/users//tweets", func(w http.ResponseWriter, r *http.Request) {
|
||||
tweetsCalled = true
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
c := newTestClient(server.URL)
|
||||
_, err := c.GetLatestNPosts(context.Background(), "@ghost", 10)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty user data, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("expected 'not found' error, got %v", err)
|
||||
}
|
||||
if tweetsCalled {
|
||||
t.Fatal("tweets endpoint should not be called when user lookup returns empty data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestNPostsSkipsEmptyID(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"data":{"id":"1","username":"u"}}`)
|
||||
})
|
||||
mux.HandleFunc("/2/users/1/tweets", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"data":[{"id":"","text":"bad"},{"id":"2","text":"good","created_at":"2024-01-01T00:00:00Z"}]}`)
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
c := newTestClient(server.URL)
|
||||
posts, err := c.GetLatestNPosts(context.Background(), "u", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("error: %v", err)
|
||||
}
|
||||
if len(posts) != 1 {
|
||||
t.Fatalf("expected 1 post (empty-ID tweet skipped), got %d", len(posts))
|
||||
}
|
||||
if posts[0].ID != "2" || posts[0].Content != "good" {
|
||||
t.Fatalf("unexpected post: %+v", posts[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerErrorRetried(t *testing.T) {
|
||||
callCount := 0
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
if callCount == 1 {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = io.WriteString(w, `{"title":"Internal Server Error","detail":"transient"}`)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"data":{"id":"1","username":"u"}}`)
|
||||
})
|
||||
mux.HandleFunc("/2/users/1/tweets", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"data":[{"id":"1","text":"ok","created_at":"2024-01-01T00:00:00Z"}]}`)
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
c := newTestClient(server.URL)
|
||||
posts, err := c.GetLatestNPosts(context.Background(), "u", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestNPosts error after 5xx retry: %v", err)
|
||||
}
|
||||
if callCount != 2 {
|
||||
t.Fatalf("expected 2 calls (1 failure + 1 retry), got %d", callCount)
|
||||
}
|
||||
if len(posts) != 1 || posts[0].Content != "ok" {
|
||||
t.Fatalf("unexpected posts after retry: %+v", posts)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,12 +7,26 @@ import (
|
|||
"strings"
|
||||
)
|
||||
|
||||
func ParseEmbedding(value interface{}) ([]float64, error) {
|
||||
func parseVectorString(v string) ([]float64, error) {
|
||||
s := strings.Trim(v, "[]")
|
||||
parts := strings.Split(s, ",")
|
||||
vec := make([]float64, len(parts))
|
||||
for i, p := range parts {
|
||||
f, err := strconv.ParseFloat(strings.TrimSpace(p), 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vec[i] = f
|
||||
}
|
||||
return vec, nil
|
||||
}
|
||||
|
||||
func ParseEmbedding(value any) ([]float64, error) {
|
||||
switch v := value.(type) {
|
||||
case []float64:
|
||||
return v, nil
|
||||
|
||||
case []interface{}:
|
||||
case []any:
|
||||
vec := make([]float64, len(v))
|
||||
for i, x := range v {
|
||||
f, ok := x.(float64)
|
||||
|
|
@ -24,35 +38,52 @@ func ParseEmbedding(value interface{}) ([]float64, error) {
|
|||
return vec, nil
|
||||
|
||||
case string:
|
||||
// pgvector returns a string like "[0.12, 0.53, ...]" sometimes
|
||||
s := strings.Trim(v, "[]")
|
||||
parts := strings.Split(s, ",")
|
||||
vec := make([]float64, len(parts))
|
||||
for i, p := range parts {
|
||||
f, err := strconv.ParseFloat(strings.TrimSpace(p), 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vec[i] = f
|
||||
}
|
||||
return vec, nil
|
||||
return parseVectorString(v)
|
||||
|
||||
case []byte:
|
||||
return parseVectorString(string(v))
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected embedding type: %T", v)
|
||||
}
|
||||
}
|
||||
|
||||
func AverageVector(vs [][]float64) []float64 {
|
||||
dim := len(vs[0])
|
||||
avg := make([]float64, dim)
|
||||
func FormatVector(v []float64) string {
|
||||
parts := make([]string, len(v))
|
||||
for i, x := range v {
|
||||
parts[i] = strconv.FormatFloat(x, 'f', -1, 64)
|
||||
}
|
||||
return "[" + strings.Join(parts, ",") + "]"
|
||||
}
|
||||
|
||||
// AverageVector returns the element-wise mean of vs. Zero-length vectors (e.g. an
|
||||
// embedding model returning an empty vector for a post without text) are skipped, and
|
||||
// the accumulator is sized from the first non-empty vector so a leading empty vector
|
||||
// cannot panic later in the loop. Returns nil when no usable vector is present.
|
||||
func AverageVector(vs [][]float64) []float64 {
|
||||
dim := 0
|
||||
var valid [][]float64
|
||||
for _, v := range vs {
|
||||
for i, val := range v {
|
||||
avg[i] += val
|
||||
if len(v) == 0 {
|
||||
continue
|
||||
}
|
||||
if dim == 0 {
|
||||
dim = len(v)
|
||||
}
|
||||
valid = append(valid, v)
|
||||
}
|
||||
if len(valid) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
avg := make([]float64, dim)
|
||||
for _, v := range valid {
|
||||
for i := 0; i < dim && i < len(v); i++ {
|
||||
avg[i] += v[i]
|
||||
}
|
||||
}
|
||||
for i := range avg {
|
||||
avg[i] /= float64(len(vs))
|
||||
avg[i] /= float64(len(valid))
|
||||
}
|
||||
return avg
|
||||
}
|
||||
|
|
@ -60,7 +91,7 @@ func AverageVector(vs [][]float64) []float64 {
|
|||
func TopWords(posts []string, stopWords map[string]struct{}, limit int) []string {
|
||||
counts := map[string]int{}
|
||||
for _, p := range posts {
|
||||
for _, w := range strings.Fields(strings.ToLower(p)) {
|
||||
for w := range strings.FieldsSeq(strings.ToLower(p)) {
|
||||
if len(w) < 4 {
|
||||
continue
|
||||
}
|
||||
|
|
|
|||
47
internal/utils/styleProfiles_test.go
Normal file
47
internal/utils/styleProfiles_test.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func assertVectorsEqual(t *testing.T, got, want []float64) {
|
||||
t.Helper()
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("length %d, want %d", len(got), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if math.Abs(got[i]-want[i]) > 1e-9 {
|
||||
t.Fatalf("index %d = %v, want %v", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAverageVector(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in [][]float64
|
||||
want []float64
|
||||
}{
|
||||
{name: "empty input", in: nil, want: nil},
|
||||
{name: "empty slice", in: [][]float64{}, want: nil},
|
||||
{name: "all empty vectors", in: [][]float64{{}, {}, {}}, want: nil},
|
||||
{name: "single vector", in: [][]float64{{0.4, -0.2}}, want: []float64{0.4, -0.2}},
|
||||
{name: "multiple vectors", in: [][]float64{{0.1, 0.2}, {0.3, 0.4}}, want: []float64{0.2, 0.3}},
|
||||
{name: "first vector empty", in: [][]float64{{}, {0.1, 0.2}, {0.3, 0.4}}, want: []float64{0.2, 0.3}},
|
||||
{name: "trailing empty vectors", in: [][]float64{{0.1, 0.2}, {}, {0.3, 0.4}}, want: []float64{0.2, 0.3}},
|
||||
{name: "only first valid", in: [][]float64{{}, {}, {0.1, 0.2}}, want: []float64{0.1, 0.2}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := AverageVector(tt.in)
|
||||
if tt.want == nil {
|
||||
if got != nil {
|
||||
t.Fatalf("AverageVector(%v) = %v, want nil", tt.in, got)
|
||||
}
|
||||
return
|
||||
}
|
||||
assertVectorsEqual(t, got, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
156
internal/x402/mcpwrapper.go
Normal file
156
internal/x402/mcpwrapper.go
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
package x402
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
log "github.com/nacorid/logger"
|
||||
x402 "github.com/x402-foundation/x402/go/v2"
|
||||
"github.com/x402-foundation/x402/go/v2/types"
|
||||
)
|
||||
|
||||
// Payment meta keys exchanged over MCP, mirroring the x402 SDK contract.
|
||||
const (
|
||||
PaymentMetaKey = "x402/payment"
|
||||
PaymentResponseMetaKey = "x402/payment-response"
|
||||
)
|
||||
|
||||
// SettleAmountResolver computes the actual settlement amount for a completed
|
||||
// tool call. It returns ("", false) to settle the amount advertised in the
|
||||
// payment requirements as-is.
|
||||
type SettleAmountResolver func(result *mcp.CallToolResult) (string, bool)
|
||||
|
||||
// PaymentWrapperConfig configures a PaymentWrapper. Unlike the SDK's wrapper,
|
||||
// it accepts a SettleAmountResolver so upto-scheme tools can charge the actual
|
||||
// computed cost after the handler runs (the same behavior the REST path gets
|
||||
// via nethttp.SetSettlementOverrides).
|
||||
type PaymentWrapperConfig struct {
|
||||
Accepts []x402.PaymentRequirements
|
||||
Resource *types.ResourceInfo
|
||||
Extensions map[string]interface{}
|
||||
// SettleAmountResolver is invoked after a successful handler call. When it
|
||||
// returns a non-empty amount, that amount is charged instead of the
|
||||
// advertised maximum (which must therefore be <= the accept amount).
|
||||
SettleAmountResolver SettleAmountResolver
|
||||
}
|
||||
|
||||
// PaymentWrapper gates an MCP tool behind x402 payments and settles them.
|
||||
type PaymentWrapper struct {
|
||||
server *x402.X402ResourceServer
|
||||
config PaymentWrapperConfig
|
||||
}
|
||||
|
||||
// NewPaymentWrapper creates a PaymentWrapper for the given resource server.
|
||||
func NewPaymentWrapper(server *x402.X402ResourceServer, config PaymentWrapperConfig) *PaymentWrapper {
|
||||
if len(config.Accepts) == 0 {
|
||||
panic("PaymentWrapperConfig.Accepts must have at least one payment requirement")
|
||||
}
|
||||
return &PaymentWrapper{server: server, config: config}
|
||||
}
|
||||
|
||||
// Wrap returns a ToolHandler that requires a valid x402 payment before invoking
|
||||
// the wrapped handler and settles it afterwards.
|
||||
func (w *PaymentWrapper) Wrap(handler mcp.ToolHandler) mcp.ToolHandler {
|
||||
return func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
paymentData := extractPaymentFromRequest(request)
|
||||
if paymentData == nil {
|
||||
return w.paymentRequiredResult("Payment Required"), nil
|
||||
}
|
||||
|
||||
payloadBytes, err := json.Marshal(paymentData)
|
||||
if err != nil {
|
||||
return w.paymentRequiredResult(fmt.Sprintf("Invalid payment payload: %v", err)), nil
|
||||
}
|
||||
var payload types.PaymentPayload
|
||||
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
|
||||
return w.paymentRequiredResult(fmt.Sprintf("Invalid payment payload: %v", err)), nil
|
||||
}
|
||||
|
||||
matched := w.server.FindMatchingRequirements(w.config.Accepts, payload)
|
||||
if matched == nil {
|
||||
return w.paymentRequiredResult("No matching payment requirements found"), nil
|
||||
}
|
||||
requirements := *matched
|
||||
|
||||
verifyResp, err := w.server.VerifyPayment(ctx, payload, requirements)
|
||||
if err != nil {
|
||||
return w.paymentRequiredResult(fmt.Sprintf("Payment verification error: %v", err)), nil
|
||||
}
|
||||
if !verifyResp.IsValid {
|
||||
return w.paymentRequiredResult(fmt.Sprintf("Payment verification failed: %s", verifyResp.InvalidReason)), nil
|
||||
}
|
||||
|
||||
result, err := handler(ctx, request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.IsError {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var overrides *x402.SettlementOverrides
|
||||
if w.config.SettleAmountResolver != nil {
|
||||
if amount, ok := w.config.SettleAmountResolver(result); ok && amount != "" {
|
||||
overrides = &x402.SettlementOverrides{Amount: amount}
|
||||
log.Debug("MCP settlement override", "amount", amount)
|
||||
}
|
||||
}
|
||||
|
||||
settleResp, err := w.server.SettlePayment(ctx, payload, requirements, overrides)
|
||||
if err != nil {
|
||||
return w.settlementFailedResult(fmt.Sprintf("Settlement error: %v", err)), nil
|
||||
}
|
||||
if !settleResp.Success {
|
||||
return w.settlementFailedResult(fmt.Sprintf("Settlement failed: %s", settleResp.ErrorReason)), nil
|
||||
}
|
||||
|
||||
if result.Meta == nil {
|
||||
result.Meta = mcp.Meta{}
|
||||
}
|
||||
result.Meta[PaymentResponseMetaKey] = settleResp
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
func extractPaymentFromRequest(request *mcp.CallToolRequest) interface{} {
|
||||
if request == nil || request.Params.Meta == nil {
|
||||
return nil
|
||||
}
|
||||
return request.Params.Meta[PaymentMetaKey]
|
||||
}
|
||||
|
||||
func (w *PaymentWrapper) paymentRequiredResult(errorMessage string) *mcp.CallToolResult {
|
||||
var resource *types.ResourceInfo
|
||||
if w.config.Resource != nil {
|
||||
resource = &types.ResourceInfo{
|
||||
URL: w.config.Resource.URL,
|
||||
Description: w.config.Resource.Description,
|
||||
}
|
||||
}
|
||||
payment := types.PaymentRequired{
|
||||
X402Version: 2,
|
||||
Error: errorMessage,
|
||||
Resource: resource,
|
||||
Accepts: w.config.Accepts,
|
||||
Extensions: w.config.Extensions,
|
||||
}
|
||||
b, err := json.Marshal(payment)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return &mcp.CallToolResult{
|
||||
IsError: true,
|
||||
Content: []mcp.Content{&mcp.TextContent{Text: string(b)}},
|
||||
}
|
||||
}
|
||||
|
||||
func (w *PaymentWrapper) settlementFailedResult(errorMessage string) *mcp.CallToolResult {
|
||||
return &mcp.CallToolResult{
|
||||
IsError: true,
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: fmt.Sprintf("{\"x402Version\":2,\"error\":%q}", errorMessage)},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +1,23 @@
|
|||
package x402
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/user"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/jsonschema-go/jsonschema"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
|
@ -17,8 +25,9 @@ import (
|
|||
x402 "github.com/x402-foundation/x402/go/v2"
|
||||
x402http "github.com/x402-foundation/x402/go/v2/http"
|
||||
nethttp "github.com/x402-foundation/x402/go/v2/http/nethttp"
|
||||
x402mcp "github.com/x402-foundation/x402/go/v2/mcp"
|
||||
evm "github.com/x402-foundation/x402/go/v2/mechanisms/evm/exact/server"
|
||||
uptoevm "github.com/x402-foundation/x402/go/v2/mechanisms/evm/upto/server"
|
||||
"github.com/x402-foundation/x402/go/v2/types"
|
||||
)
|
||||
|
||||
type X402Middlewares struct {
|
||||
|
|
@ -45,11 +54,23 @@ type X402Server struct {
|
|||
middlewareChain func(http.Handler) http.Handler
|
||||
tools []ToolInfo
|
||||
globalReqLog func(http.Handler) http.Handler
|
||||
networks []string
|
||||
|
||||
resourceInitMu sync.Mutex
|
||||
resourceInitDone bool
|
||||
|
||||
// settleResolvers maps payable tool name -> SettleAmountResolver used by
|
||||
// both the MCP wrapper and the REST override path to charge the actual
|
||||
// computed cost (instead of the advertised upto maximum).
|
||||
settleResolvers map[string]SettleAmountResolver
|
||||
|
||||
httpServer *http.Server
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
const VERSION1 = "v1/"
|
||||
|
||||
func NewX402Server(name, version string, facilitatorURL string, auth x402http.AuthProvider, mws X402Middlewares, globalLoggingMw func(http.Handler) http.Handler, settleHook func(http.ResponseWriter, *http.Request, *x402.SettleResponse)) (*X402Server, error) {
|
||||
func NewX402Server(name, version string, facilitatorURL string, auth x402http.AuthProvider, mws X402Middlewares, globalLoggingMw func(http.Handler) http.Handler, settleHook func(http.ResponseWriter, *http.Request, *x402.SettleResponse), networks []string) (*X402Server, error) {
|
||||
mcpServer := mcp.NewServer(
|
||||
&mcp.Implementation{
|
||||
Name: name,
|
||||
|
|
@ -62,18 +83,29 @@ func NewX402Server(name, version string, facilitatorURL string, auth x402http.Au
|
|||
}
|
||||
|
||||
if facilitatorURL == "" {
|
||||
facilitatorURL = "https://facilitator.mogami.tech"
|
||||
facilitatorURL = "https://api.cdp.coinbase.com/platform/v2/x402"
|
||||
}
|
||||
facilitator := x402http.NewHTTPFacilitatorClient(&x402http.FacilitatorConfig{
|
||||
URL: facilitatorURL,
|
||||
AuthProvider: auth,
|
||||
Timeout: 600 * time.Second,
|
||||
})
|
||||
|
||||
resourceServer := x402.Newx402ResourceServer(
|
||||
x402.WithFacilitatorClient(facilitator),
|
||||
)
|
||||
|
||||
resourceServer.Register("eip155:8453", evm.NewExactEvmScheme())
|
||||
// Register exact and upto EVM schemes for each network
|
||||
registered := make(map[string]bool)
|
||||
for _, network := range networks {
|
||||
if registered[network] {
|
||||
continue
|
||||
}
|
||||
net := x402.Network(network)
|
||||
resourceServer.Register(net, evm.NewExactEvmScheme())
|
||||
resourceServer.Register(net, uptoevm.NewUptoEvmScheme())
|
||||
registered[network] = true
|
||||
}
|
||||
|
||||
s := &X402Server{
|
||||
mcpServer: mcpServer,
|
||||
|
|
@ -86,11 +118,29 @@ func NewX402Server(name, version string, facilitatorURL string, auth x402http.Au
|
|||
middlewareChain: chainHTTPMiddlewares(mws.HTTPMiddleware...),
|
||||
tools: make([]ToolInfo, 0),
|
||||
globalReqLog: globalLoggingMw,
|
||||
networks: networks,
|
||||
settleResolvers: make(map[string]SettleAmountResolver),
|
||||
}
|
||||
|
||||
// Warm the facilitator cache now so AddPayableTool can build Accepts with
|
||||
// facilitator extras (e.g. upto facilitatorAddress). Failure is non-fatal;
|
||||
// ensureFacilitatorReady retries on every request until it succeeds once.
|
||||
s.ensureFacilitatorReady(context.Background())
|
||||
|
||||
// Self-healing: retry facilitator initialization on every inbound request
|
||||
// until it succeeds, so a facilitator that was down at boot requires no
|
||||
// process restart. Runs before tool dispatch, i.e. before the PaymentWrapper
|
||||
// performs VerifyPayment/SettlePayment.
|
||||
mcpServer.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler {
|
||||
return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
|
||||
s.ensureFacilitatorReady(ctx)
|
||||
return next(ctx, method, req)
|
||||
}
|
||||
})
|
||||
|
||||
s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
s.mux.HandleFunc("GET /openapi.json", s.serveOpenAPI)
|
||||
|
|
@ -103,6 +153,32 @@ func NewX402Server(name, version string, facilitatorURL string, auth x402http.Au
|
|||
return s, nil
|
||||
}
|
||||
|
||||
// facilitatorRetryTimeout bounds each lazy facilitator init attempt so a down
|
||||
// facilitator can't stall requests indefinitely.
|
||||
const facilitatorRetryTimeout = 10 * time.Second
|
||||
|
||||
// ensureFacilitatorReady lazily initializes the resource server against the
|
||||
// facilitator, retrying on every call until one succeeds. After the first
|
||||
// success the facilitatorClients map persists for the process lifetime, so a
|
||||
// facilitator outage at boot (or afterwards) no longer requires a restart.
|
||||
func (s *X402Server) ensureFacilitatorReady(ctx context.Context) {
|
||||
s.resourceInitMu.Lock()
|
||||
defer s.resourceInitMu.Unlock()
|
||||
|
||||
if s.resourceInitDone {
|
||||
return
|
||||
}
|
||||
|
||||
initCtx, cancel := context.WithTimeout(ctx, facilitatorRetryTimeout)
|
||||
defer cancel()
|
||||
if err := s.x402ResourceServer.Initialize(initCtx); err != nil {
|
||||
log.Warn("x402 facilitator unavailable; will retry on next request", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
s.resourceInitDone = true
|
||||
}
|
||||
|
||||
func (s *X402Server) AddTool(tool mcp.Tool, handler mcp.ToolHandler) {
|
||||
tool.InputSchema = toJSONSchema(tool.InputSchema)
|
||||
tool.OutputSchema = toJSONSchema(tool.OutputSchema)
|
||||
|
|
@ -124,19 +200,45 @@ func (s *X402Server) AddTool(tool mcp.Tool, handler mcp.ToolHandler) {
|
|||
func (s *X402Server) AddPayableTool(
|
||||
tool mcp.Tool,
|
||||
handler mcp.ToolHandler,
|
||||
resolver SettleAmountResolver,
|
||||
requirements ...x402.PaymentRequirements,
|
||||
) {
|
||||
|
||||
if len(requirements) == 0 {
|
||||
log.Fatalf("tool %s requires at least one payment requirement", tool.Name)
|
||||
panic(fmt.Sprintf("tool %s requires at least one payment requirement", tool.Name))
|
||||
}
|
||||
|
||||
// Convert each raw dollar price into the asset's smallest-unit integer amount using the
|
||||
// same server-side conversion the REST middleware applies at request time (ParsePrice +
|
||||
// EnhancePaymentRequirements). The MCP wrapper advertises these requirements directly,
|
||||
// so without conversion clients fail with invalid_exact_evm_client_amount.
|
||||
var mcpAccepts []x402.PaymentRequirements
|
||||
for _, req := range requirements {
|
||||
built, err := s.x402ResourceServer.BuildPaymentRequirementsFromConfig(context.Background(), x402.ResourceConfig{
|
||||
Scheme: req.Scheme,
|
||||
PayTo: req.PayTo,
|
||||
Price: req.Amount,
|
||||
Network: x402.Network(req.Network),
|
||||
MaxTimeoutSeconds: req.MaxTimeoutSeconds,
|
||||
Extra: req.Extra,
|
||||
})
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("tool %s failed to build MCP payment requirements for scheme %s: %v", tool.Name, req.Scheme, err))
|
||||
}
|
||||
mcpAccepts = append(mcpAccepts, built...)
|
||||
}
|
||||
|
||||
tool.InputSchema = toJSONSchema(tool.InputSchema)
|
||||
tool.OutputSchema = toJSONSchema(tool.OutputSchema)
|
||||
|
||||
wrapper := x402mcp.NewPaymentWrapper(s.x402ResourceServer, x402mcp.PaymentWrapperConfig{
|
||||
Accepts: requirements,
|
||||
Resource: &x402mcp.ResourceInfo{
|
||||
if resolver != nil {
|
||||
s.settleResolvers[tool.Name] = resolver
|
||||
}
|
||||
|
||||
wrapper := NewPaymentWrapper(s.x402ResourceServer, PaymentWrapperConfig{
|
||||
Accepts: mcpAccepts,
|
||||
SettleAmountResolver: resolver,
|
||||
Resource: &types.ResourceInfo{
|
||||
URL: "mcp://tool/" + tool.Name,
|
||||
Description: tool.Description,
|
||||
},
|
||||
|
|
@ -154,35 +256,136 @@ func (s *X402Server) AddPayableTool(
|
|||
var options x402http.PaymentOptions
|
||||
for _, req := range requirements {
|
||||
options = append(options, x402http.PaymentOption{
|
||||
Scheme: req.Scheme,
|
||||
PayTo: req.PayTo,
|
||||
Price: req.Amount,
|
||||
Network: x402.Network(req.Network),
|
||||
Scheme: req.Scheme,
|
||||
PayTo: req.PayTo,
|
||||
Price: req.Amount,
|
||||
Network: x402.Network(req.Network),
|
||||
MaxTimeoutSeconds: req.MaxTimeoutSeconds,
|
||||
Extra: req.Extra,
|
||||
})
|
||||
}
|
||||
|
||||
paymentMiddleware := nethttp.X402Payment(nethttp.Config{
|
||||
schemes := buildSchemeConfigs(s.networks, requirements...)
|
||||
|
||||
settlementHandler := func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {
|
||||
requirements, _ := nethttp.RequirementsFromContext(r.Context())
|
||||
log.InfoWithContext(r.Context(), "HTTP settlement succeeded",
|
||||
"tool", tool.Name,
|
||||
"network", string(resp.Network),
|
||||
"transaction", resp.Transaction,
|
||||
"payer", resp.Payer,
|
||||
"payTo", requirements.PayTo,
|
||||
"amount", requirements.Amount,
|
||||
"asset", requirements.Asset,
|
||||
"scheme", requirements.Scheme,
|
||||
"override_amount", r.Header.Get("Settlement-Overrides"),
|
||||
)
|
||||
if s.httpSettlementHook != nil {
|
||||
s.httpSettlementHook(w, r, resp)
|
||||
}
|
||||
}
|
||||
|
||||
basePaymentMiddleware := nethttp.X402Payment(nethttp.Config{
|
||||
Routes: x402http.RoutesConfig{
|
||||
"POST /" + VERSION1 + tool.Name: {
|
||||
Accepts: options,
|
||||
Description: tool.Description,
|
||||
},
|
||||
},
|
||||
Facilitator: s.facilitatorClient,
|
||||
Schemes: []nethttp.SchemeConfig{
|
||||
{Network: "eip155:8453", Server: evm.NewExactEvmScheme()},
|
||||
Facilitator: s.facilitatorClient,
|
||||
Schemes: schemes,
|
||||
SettlementHandler: settlementHandler,
|
||||
Timeout: 600 * time.Second,
|
||||
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
log.ErrorWithContext(r.Context(), "HTTP settlement failed",
|
||||
"tool", tool.Name,
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"paymentSignature", r.Header.Get("PAYMENT-SIGNATURE"),
|
||||
"paymentRequired", w.Header().Get("PAYMENT-REQUIRED"),
|
||||
"settlement_overrides", r.Header.Get("Settlement-Overrides"),
|
||||
"error", err,
|
||||
)
|
||||
http.Error(w, fmt.Sprintf("settlement failed: %v", err), http.StatusPaymentRequired)
|
||||
},
|
||||
SettlementHandler: s.httpSettlementHook,
|
||||
})
|
||||
|
||||
paymentMiddleware := func(next http.Handler) http.Handler {
|
||||
return basePaymentMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capture := &verifyErrorCapture{
|
||||
ResponseWriter: w,
|
||||
req: r,
|
||||
toolName: tool.Name,
|
||||
}
|
||||
next.ServeHTTP(capture, r)
|
||||
|
||||
if capture.statusCode == http.StatusPaymentRequired {
|
||||
prHeader := capture.Header().Get("PAYMENT-REQUIRED")
|
||||
if prHeader != "" {
|
||||
if decoded, err := decodePaymentRequiredHeader(prHeader); err == nil {
|
||||
for i, acc := range decoded.Accepts {
|
||||
log.ErrorWithContext(r.Context(), "HTTP payment verify failed - new challenge issued",
|
||||
"tool", tool.Name,
|
||||
"acceptIndex", i,
|
||||
"scheme", acc.Scheme,
|
||||
"network", acc.Network,
|
||||
"asset", acc.Asset,
|
||||
"amount", acc.Amount,
|
||||
"payTo", acc.PayTo,
|
||||
"resource", decoded.Resource,
|
||||
"acceptsCount", len(decoded.Accepts),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
log.ErrorWithContext(r.Context(), "HTTP payment verify failed - could not decode PAYMENT-REQUIRED",
|
||||
"tool", tool.Name,
|
||||
"rawHeader", prHeader,
|
||||
"decodeError", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Debug: log the payment signature sent by client
|
||||
log.DebugWithContext(r.Context(), "Payment retry debug",
|
||||
"tool", tool.Name,
|
||||
"paymentSignature", r.Header.Get("PAYMENT-SIGNATURE"),
|
||||
"settlementOverrides", w.Header().Get("Settlement-Overrides"),
|
||||
)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
restHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleRESTCall(w, r, tool.Name, handler)
|
||||
})
|
||||
s.mux.Handle("POST /"+VERSION1+tool.Name, s.middlewareChain(paymentMiddleware(restHandler)))
|
||||
}
|
||||
|
||||
// buildSchemeConfigs maps the schemes actually declared in a tool's payment
|
||||
// requirements to the EVM scheme servers a route needs to verify and settle
|
||||
// them, for every configured network. Only the schemes present in the
|
||||
// requirements are registered: an exact-only tool does not expose an upto
|
||||
// scheme, and vice versa. This keeps upto (variable-pricing) authorization from
|
||||
// being offered for tools that cannot vary their price.
|
||||
func buildSchemeConfigs(networks []string, requirements ...x402.PaymentRequirements) []nethttp.SchemeConfig {
|
||||
present := make(map[string]struct{})
|
||||
for _, req := range requirements {
|
||||
present[req.Scheme] = struct{}{}
|
||||
}
|
||||
schemes := make([]nethttp.SchemeConfig, 0, len(networks)*len(present))
|
||||
for _, net := range networks {
|
||||
if _, ok := present["exact"]; ok {
|
||||
schemes = append(schemes, nethttp.SchemeConfig{Network: x402.Network(net), Server: evm.NewExactEvmScheme()})
|
||||
}
|
||||
if _, ok := present["upto"]; ok {
|
||||
schemes = append(schemes, nethttp.SchemeConfig{Network: x402.Network(net), Server: uptoevm.NewUptoEvmScheme()})
|
||||
}
|
||||
}
|
||||
return schemes
|
||||
}
|
||||
|
||||
func (s *X402Server) handleRESTCall(w http.ResponseWriter, r *http.Request, toolName string, handler mcp.ToolHandler) {
|
||||
bodyBytes, err := io.ReadAll(r.Body)
|
||||
defer r.Body.Close()
|
||||
bodyBytes, err := io.ReadAll(io.LimitReader(r.Body, 5*1024*1024))
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read body", http.StatusBadRequest)
|
||||
return
|
||||
|
|
@ -204,19 +407,31 @@ func (s *X402Server) handleRESTCall(w http.ResponseWriter, r *http.Request, tool
|
|||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
if len(resp.Content) == 1 {
|
||||
if textContent, ok := resp.Content[0].(*mcp.TextContent); ok {
|
||||
var js json.RawMessage
|
||||
if json.Unmarshal([]byte(textContent.Text), &js) == nil {
|
||||
w.Write([]byte(textContent.Text))
|
||||
if resolver := s.settleResolvers[toolName]; resolver != nil {
|
||||
if actualCost, ok := resolver(&mcp.CallToolResult{Content: []mcp.Content{textContent}}); ok && actualCost != "" {
|
||||
log.DebugWithContext(r.Context(), "Setting settlement override",
|
||||
"tool", toolName,
|
||||
"actualCost", actualCost,
|
||||
)
|
||||
nethttp.SetSettlementOverrides(w, &x402.SettlementOverrides{
|
||||
Amount: actualCost,
|
||||
})
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(textContent.Text))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func (s *X402Server) OnAfterSettle(hook func(ctx x402.SettleResultContext) error) {
|
||||
|
|
@ -307,6 +522,23 @@ func (s *X402Server) Start(addr string, permissions os.FileMode) error {
|
|||
return fmt.Errorf("failed to set socket permissions: %w", err)
|
||||
}
|
||||
|
||||
grp, err := user.LookupGroup("mcpserver")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to lookup group mcpserver: %w", err)
|
||||
}
|
||||
gid, err := strconv.Atoi(grp.Gid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid gid %q: %w", grp.Gid, err)
|
||||
}
|
||||
|
||||
if err := os.Chown(socketPath, -1, gid); err != nil {
|
||||
return fmt.Errorf("failed to set socket group ownership: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Chmod(socketPath, permissions); err != nil {
|
||||
return fmt.Errorf("failed to set socket permissions: %w", err)
|
||||
}
|
||||
|
||||
log.Info("Starting X402 MCP Server on unix socket", "socketPath", socketPath)
|
||||
} else {
|
||||
log.Info("Starting X402 MCP Server on TCP", "addr", addr)
|
||||
|
|
@ -316,15 +548,33 @@ func (s *X402Server) Start(addr string, permissions os.FileMode) error {
|
|||
return fmt.Errorf("failed to listen on tcp: %w", err)
|
||||
}
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
srv := &http.Server{
|
||||
Handler: s.globalReqLog(s.mux),
|
||||
ErrorLog: slog.NewLogLogger(slog.Default().Handler(), slog.LevelDebug),
|
||||
Handler: s.globalReqLog(s.mux),
|
||||
ErrorLog: slog.NewLogLogger(slog.Default().Handler(), slog.LevelDebug),
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.httpServer = srv
|
||||
s.mu.Unlock()
|
||||
|
||||
return srv.Serve(listener)
|
||||
}
|
||||
|
||||
func (s *X402Server) Shutdown(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
srv := s.httpServer
|
||||
s.mu.Unlock()
|
||||
|
||||
if srv == nil {
|
||||
return nil
|
||||
}
|
||||
return srv.Shutdown(ctx)
|
||||
}
|
||||
|
||||
func toJSONSchema(val any) json.RawMessage {
|
||||
if val == nil {
|
||||
return json.RawMessage(`{"type": "object"}`)
|
||||
|
|
@ -385,9 +635,203 @@ func parseSchema(schemaAny any) any {
|
|||
|
||||
func chainHTTPMiddlewares(middlewares ...func(http.Handler) http.Handler) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
for i := len(middlewares) - 1; i >= 0; i-- {
|
||||
next = middlewares[i](next)
|
||||
for _, middleware := range slices.Backward(middlewares) {
|
||||
next = middleware(next)
|
||||
}
|
||||
return next
|
||||
}
|
||||
}
|
||||
|
||||
// Twitter API costs are itemized below. Every getStyleProfile request, for any
|
||||
// platform, also carries the flat base cost of the endpoint (the same price a
|
||||
// non-Twitter platform pays). Adding that base cost on top of the itemized
|
||||
// Twitter API costs guarantees the call always clears the underlying API cost
|
||||
// and generates a profit - it can never break even or lose money.
|
||||
const (
|
||||
// BaseProfileCostMicro is the base cost of the style-profile endpoint for
|
||||
// the non-Twitter path. It doubles as the profit margin added to Twitter
|
||||
// requests, expressed in micro-dollars (USDC has 6 decimals).
|
||||
BaseProfileCostMicro int64 = 250_000 // $0.25
|
||||
// TwitterUserLookupMicro is the per-request Twitter user lookup API cost.
|
||||
TwitterUserLookupMicro int64 = 10_000 // $0.01
|
||||
// TwitterPostReadMicro is the per-post Twitter read API cost.
|
||||
TwitterPostReadMicro int64 = 5_000 // $0.005 per post
|
||||
// MaxProfileCountedPosts caps the post count a style profile can ever
|
||||
// report. It is NOT applied inside computeTwitterStyleProfileCost (charges
|
||||
// follow the posts actually read, and the server fetches at most this
|
||||
// many); it only bounds the largest settlement the endpoint can demand.
|
||||
MaxProfileCountedPosts int64 = 100
|
||||
// MaxAnalyticsCountedPosts caps the number of posts an analytics tool can
|
||||
// include from any single platform (mirrors maxAnalyticsLimit in the server
|
||||
// package). It bounds the largest settlement an upto analytics request can
|
||||
// demand.
|
||||
MaxAnalyticsCountedPosts int64 = 200
|
||||
)
|
||||
|
||||
// maxProfileCostMicro is the largest price getStyleProfile can ever command:
|
||||
// the flat base cost plus the Twitter lookup and per-post costs at the
|
||||
// post-count cap. The price advertised for getStyleProfile in tools.go must
|
||||
// equal formatDollarAmount(maxProfileCostMicro) ("$0.76").
|
||||
const maxProfileCostMicro = BaseProfileCostMicro + TwitterUserLookupMicro + MaxProfileCountedPosts*TwitterPostReadMicro
|
||||
|
||||
// computeTwitterStyleProfileCost calculates the actual charge for a Twitter
|
||||
// getStyleProfile request based on the number of posts fetched and the user
|
||||
// lookup, and returns it as a dollar price string (e.g. "$0.31") suitable for a
|
||||
// x402 settlement override.
|
||||
//
|
||||
// Pricing: guaranteed base cost of $0.25 (the endpoint cost for other
|
||||
// platforms), plus user lookup $0.01, plus $0.005 per post read. For example,
|
||||
// 10 posts = $0.31. Non-Twitter platforms are charged the flat $0.25 base.
|
||||
//
|
||||
// The price always reflects the posts actually read - it is never clamped to a
|
||||
// cap (the server only ever fetches at most maxProfileCountedPosts, so the
|
||||
// derived max always stays at or below formatDollarAmount(maxProfileCostMicro)).
|
||||
// A PostCount of 0 means no posts were read (server-side defaults are applied
|
||||
// before the profile is built, so responses carry the real count).
|
||||
func computeTwitterStyleProfileCost(_ *http.Request, responseText string) string {
|
||||
// Parse the response to get PostCount and SourcePlatform
|
||||
var resp GetStyleProfileResponse
|
||||
if err := json.Unmarshal([]byte(responseText), &resp); err != nil {
|
||||
return formatDollarAmount(BaseProfileCostMicro) // fallback: base cost
|
||||
}
|
||||
|
||||
costMicro := BaseProfileCostMicro
|
||||
|
||||
// Twitter additionally passes on the user lookup and per-post API costs.
|
||||
if resp.SourcePlatform == "Twitter" {
|
||||
postCount := max(resp.PostCount, 0)
|
||||
costMicro += TwitterUserLookupMicro
|
||||
costMicro += int64(postCount) * TwitterPostReadMicro
|
||||
}
|
||||
|
||||
return formatDollarAmount(costMicro)
|
||||
}
|
||||
|
||||
// formatDollarAmount renders an amount in micro-dollars as a "$"-prefixed
|
||||
// price string with at least two decimal places and trailing zeroes trimmed
|
||||
// beyond that (e.g. "$0.16", "$1.50", "$0.125"), matching the format the x402
|
||||
// settlement override resolver expects.
|
||||
func formatDollarAmount(micro int64) string {
|
||||
dollars := float64(micro) / 1_000_000
|
||||
s := strconv.FormatFloat(dollars, 'f', 6, 64)
|
||||
s = strings.TrimRight(s, "0")
|
||||
if dot := strings.IndexByte(s, '.'); dot >= 0 {
|
||||
if decimals := len(s) - dot - 1; decimals < 2 {
|
||||
s += strings.Repeat("0", 2-decimals)
|
||||
}
|
||||
} else {
|
||||
s += ".00"
|
||||
}
|
||||
return "$" + s
|
||||
}
|
||||
|
||||
// FormatPriceMicro renders an amount in micro-dollars as a plain, un-prefixed
|
||||
// numeric price string ("0.76") as expected by x402 PaymentRequirements.Amount.
|
||||
func FormatPriceMicro(micro int64) string {
|
||||
return strings.TrimPrefix(formatDollarAmount(micro), "$")
|
||||
}
|
||||
|
||||
// MicroFromPriceString parses a plain dollar price string ("0.76") into
|
||||
// micro-dollars so pricing arithmetic can be done without float rounding.
|
||||
func MicroFromPriceString(price string) int64 {
|
||||
micro, err := strconv.ParseFloat(price, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return int64(math.Round(micro * 1_000_000))
|
||||
}
|
||||
|
||||
// MaxTwitterCostMicro is the largest cost a Twitter-capable tool can command
|
||||
// when it reaches its post-count cap: the flat base cost plus the Twitter
|
||||
// lookup and per-post costs at the cap. It is used to advertise the upto
|
||||
// maximum for tools whose actual cost is settled from the response.
|
||||
func MaxTwitterCostMicro(baseMicro, postCap int64) int64 {
|
||||
return baseMicro + TwitterUserLookupMicro + postCap*TwitterPostReadMicro
|
||||
}
|
||||
|
||||
// NewStyleProfileCostResolver returns a SettleAmountResolver that bills a
|
||||
// getStyleProfile request by the posts actually read (see
|
||||
// computeTwitterStyleProfileCost), rather than the advertised upto maximum.
|
||||
func NewStyleProfileCostResolver() SettleAmountResolver {
|
||||
return func(result *mcp.CallToolResult) (string, bool) {
|
||||
if len(result.Content) != 1 {
|
||||
return "", false
|
||||
}
|
||||
textContent, ok := result.Content[0].(*mcp.TextContent)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return computeTwitterStyleProfileCost(nil, textContent.Text), true
|
||||
}
|
||||
}
|
||||
|
||||
// NewTwitterCountCostResolver returns a SettleAmountResolver for tools whose
|
||||
// response carries a `twitterPostCount` JSON field. It settles the flat
|
||||
// baseCostMicro, plus the Twitter lookup and per-post costs when Twitter posts
|
||||
// were actually used (twitterPostCount > 0). When no Twitter posts went into
|
||||
// the response the caller is charged only the base cost.
|
||||
func NewTwitterCountCostResolver(baseCostMicro int64) SettleAmountResolver {
|
||||
return func(result *mcp.CallToolResult) (string, bool) {
|
||||
if len(result.Content) != 1 {
|
||||
return "", false
|
||||
}
|
||||
textContent, ok := result.Content[0].(*mcp.TextContent)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
var payload struct {
|
||||
TwitterPostCount int `json:"twitterPostCount"`
|
||||
}
|
||||
if json.Unmarshal([]byte(textContent.Text), &payload) != nil {
|
||||
return "", false
|
||||
}
|
||||
costMicro := baseCostMicro
|
||||
if payload.TwitterPostCount > 0 {
|
||||
costMicro += TwitterUserLookupMicro + int64(payload.TwitterPostCount)*TwitterPostReadMicro
|
||||
}
|
||||
return formatDollarAmount(costMicro), true
|
||||
}
|
||||
}
|
||||
|
||||
// GetStyleProfileResponse is a local copy of the response schema for cost calculation
|
||||
type GetStyleProfileResponse struct {
|
||||
Author string `json:"author"`
|
||||
PersonaEmbedding []float64 `json:"personaEmbedding"`
|
||||
TopKeywords []string `json:"topKeywords"`
|
||||
StyleSummary string `json:"styleSummary"`
|
||||
PostCount int `json:"postCount"`
|
||||
SourcePlatform string `json:"sourcePlatform"`
|
||||
}
|
||||
|
||||
type verifyErrorCapture struct {
|
||||
http.ResponseWriter
|
||||
req *http.Request
|
||||
toolName string
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (c *verifyErrorCapture) WriteHeader(code int) {
|
||||
c.statusCode = code
|
||||
c.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (c *verifyErrorCapture) Write(b []byte) (int, error) {
|
||||
if c.statusCode == 0 {
|
||||
c.statusCode = http.StatusOK
|
||||
}
|
||||
return c.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
func decodePaymentRequiredHeader(header string) (x402.PaymentRequired, error) {
|
||||
data, err := base64.StdEncoding.DecodeString(header)
|
||||
if err != nil {
|
||||
return x402.PaymentRequired{}, fmt.Errorf("invalid base64 encoding: %w", err)
|
||||
}
|
||||
|
||||
var required x402.PaymentRequired
|
||||
if err := json.Unmarshal(data, &required); err != nil {
|
||||
return x402.PaymentRequired{}, fmt.Errorf("invalid payment required JSON: %w", err)
|
||||
}
|
||||
|
||||
return required, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,14 +3,19 @@ package x402
|
|||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
x402 "github.com/x402-foundation/x402/go/v2"
|
||||
x402http "github.com/x402-foundation/x402/go/v2/http"
|
||||
"github.com/x402-foundation/x402/go/v2/types"
|
||||
)
|
||||
|
||||
type DummyAuthProvider struct{}
|
||||
|
|
@ -31,6 +36,7 @@ func TestX402Server_HTTPBoundaries(t *testing.T) {
|
|||
X402Middlewares{},
|
||||
func(next http.Handler) http.Handler { return next },
|
||||
func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {},
|
||||
[]string{"eip155:8453"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create server: %v", err)
|
||||
|
|
@ -46,6 +52,7 @@ func TestX402Server_HTTPBoundaries(t *testing.T) {
|
|||
Content: []mcp.Content{&mcp.TextContent{Text: `{"status": "success"}`}},
|
||||
}, nil
|
||||
},
|
||||
nil,
|
||||
x402.PaymentRequirements{
|
||||
Scheme: "exact",
|
||||
Network: "eip155:8453",
|
||||
|
|
@ -102,6 +109,7 @@ func TestX402Server_MCPBoundaries(t *testing.T) {
|
|||
X402Middlewares{},
|
||||
func(next http.Handler) http.Handler { return next },
|
||||
func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {},
|
||||
[]string{"eip155:8453"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create server: %v", err)
|
||||
|
|
@ -117,6 +125,7 @@ func TestX402Server_MCPBoundaries(t *testing.T) {
|
|||
Content: []mcp.Content{&mcp.TextContent{Text: `{"status": "success"}`}},
|
||||
}, nil
|
||||
},
|
||||
nil,
|
||||
x402.PaymentRequirements{
|
||||
Scheme: "exact",
|
||||
Network: "eip155:8453",
|
||||
|
|
@ -130,14 +139,14 @@ func TestX402Server_MCPBoundaries(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("failed to connect server session: %v", err)
|
||||
}
|
||||
defer serverSession.Close()
|
||||
defer func() { _ = serverSession.Close() }()
|
||||
|
||||
client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "1.0.0"}, nil)
|
||||
clientSession, err := client.Connect(ctx, clientTransport, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to connect client session: %v", err)
|
||||
}
|
||||
defer clientSession.Close()
|
||||
defer func() { _ = clientSession.Close() }()
|
||||
|
||||
toolsResponse, err := clientSession.ListTools(ctx, nil)
|
||||
if err != nil {
|
||||
|
|
@ -173,10 +182,126 @@ func TestX402Server_MCPBoundaries(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func setupMockFacilitator() *httptest.Server {
|
||||
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
func TestX402Server_MCPChallengesAdvertiseConvertedAmounts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
serverTransport, clientTransport := mcp.NewInMemoryTransports()
|
||||
|
||||
mockFacilitator := setupMockFacilitator()
|
||||
defer mockFacilitator.Close()
|
||||
|
||||
srv, err := NewX402Server(
|
||||
"Test Agent API",
|
||||
"1.0.0",
|
||||
mockFacilitator.URL,
|
||||
&DummyAuthProvider{},
|
||||
X402Middlewares{},
|
||||
func(next http.Handler) http.Handler { return next },
|
||||
func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {},
|
||||
[]string{"eip155:8453", "eip155:84532"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create server: %v", err)
|
||||
}
|
||||
|
||||
srv.AddPayableTool(
|
||||
mcp.Tool{Name: "getExact", Description: "exact gated tool"},
|
||||
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: `{}`}}}, nil
|
||||
},
|
||||
nil,
|
||||
x402.PaymentRequirements{
|
||||
Scheme: "exact",
|
||||
Network: "eip155:8453",
|
||||
Amount: "0.01",
|
||||
PayTo: "0x0000000000000000000000000000000000000000",
|
||||
},
|
||||
)
|
||||
srv.AddPayableTool(
|
||||
mcp.Tool{Name: "getUpto", Description: "upto gated tool"},
|
||||
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: `{}`}}}, nil
|
||||
},
|
||||
nil,
|
||||
x402.PaymentRequirements{
|
||||
Scheme: "upto",
|
||||
Network: "eip155:84532",
|
||||
Amount: "0.51",
|
||||
PayTo: "0x0000000000000000000000000000000000000000",
|
||||
},
|
||||
)
|
||||
|
||||
serverSession, err := srv.mcpServer.Connect(ctx, serverTransport, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to connect server session: %v", err)
|
||||
}
|
||||
defer func() { _ = serverSession.Close() }()
|
||||
|
||||
client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "1.0.0"}, nil)
|
||||
clientSession, err := client.Connect(ctx, clientTransport, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to connect client session: %v", err)
|
||||
}
|
||||
defer func() { _ = clientSession.Close() }()
|
||||
|
||||
cases := []struct {
|
||||
tool string
|
||||
scheme string
|
||||
want string
|
||||
}{
|
||||
{tool: "getExact", scheme: "exact", want: "10000"},
|
||||
{tool: "getUpto", scheme: "upto", want: "510000"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.tool, func(t *testing.T) {
|
||||
result, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: tc.tool, Arguments: json.RawMessage(`{}`)})
|
||||
if err != nil {
|
||||
t.Fatalf("protocol-level error: %v", err)
|
||||
}
|
||||
if !result.IsError || len(result.Content) == 0 {
|
||||
t.Fatalf("expected a payment challenge for %s, got %+v", tc.tool, result)
|
||||
}
|
||||
|
||||
text, ok := result.Content[0].(*mcp.TextContent)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected content type %T", result.Content[0])
|
||||
}
|
||||
|
||||
var pr types.PaymentRequired
|
||||
if err := json.Unmarshal([]byte(text.Text), &pr); err != nil {
|
||||
t.Fatalf("failed to decode payment challenge: %v", err)
|
||||
}
|
||||
if len(pr.Accepts) == 0 {
|
||||
t.Fatalf("expected at least one accept in challenge, got %d", len(pr.Accepts))
|
||||
}
|
||||
|
||||
if got := pr.Accepts[0].Amount; got != tc.want {
|
||||
t.Errorf("accept amount = %q, want %q (converted to smallest units)", got, tc.want)
|
||||
}
|
||||
if got := pr.Accepts[0].Scheme; got != tc.scheme {
|
||||
t.Errorf("accept scheme = %q, want %q", got, tc.scheme)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// toggleFacilitatorServer is a mock facilitator whose availability can be
|
||||
// flipped at runtime to simulate an outage and recovery without a restart.
|
||||
type toggleFacilitatorServer struct {
|
||||
available atomic.Bool
|
||||
srv *httptest.Server
|
||||
}
|
||||
|
||||
func newToggleFacilitatorServer(t *testing.T) *toggleFacilitatorServer {
|
||||
f := &toggleFacilitatorServer{}
|
||||
f.available.Store(true)
|
||||
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !f.available.Load() {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/supported":
|
||||
_, _ = w.Write([]byte(`{
|
||||
|
|
@ -204,5 +329,700 @@ func setupMockFacilitator() *httptest.Server {
|
|||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(f.srv.Close)
|
||||
return f
|
||||
}
|
||||
|
||||
func TestX402Server_MCPVerifyResolvesFacilitator(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
f := newToggleFacilitatorServer(t)
|
||||
|
||||
srv, err := NewX402Server(
|
||||
"Test Agent API",
|
||||
"1.0.0",
|
||||
f.srv.URL,
|
||||
&DummyAuthProvider{},
|
||||
X402Middlewares{},
|
||||
func(next http.Handler) http.Handler { return next },
|
||||
func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {},
|
||||
[]string{"eip155:8453"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create server: %v", err)
|
||||
}
|
||||
|
||||
if !srv.x402ResourceServer.HasFacilitatorSupport("eip155:8453", "exact") {
|
||||
t.Fatal("expected a facilitator client resolved for eip155:8453/exact after construction")
|
||||
}
|
||||
|
||||
req := x402.PaymentRequirements{
|
||||
Scheme: "exact",
|
||||
Network: "eip155:8453",
|
||||
Amount: "10000",
|
||||
Asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
||||
PayTo: "0x0000000000000000000000000000000000000000",
|
||||
}
|
||||
payload := types.PaymentPayload{
|
||||
X402Version: 2,
|
||||
Payload: map[string]interface{}{"chainId": 1},
|
||||
Accepted: req,
|
||||
}
|
||||
|
||||
resp, err := srv.x402ResourceServer.VerifyPayment(ctx, payload, req)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyPayment should succeed once a facilitator is registered, got: %v", err)
|
||||
}
|
||||
if resp == nil || !resp.IsValid {
|
||||
t.Errorf("expected VerifyPayment to return isValid=true, got %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestX402Server_MCPVerifySelfHeals(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
f := newToggleFacilitatorServer(t)
|
||||
f.available.Store(false) // facilitator down at boot
|
||||
|
||||
srv, err := NewX402Server(
|
||||
"Test Agent API",
|
||||
"1.0.0",
|
||||
f.srv.URL,
|
||||
&DummyAuthProvider{},
|
||||
X402Middlewares{},
|
||||
func(next http.Handler) http.Handler { return next },
|
||||
func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {},
|
||||
[]string{"eip155:8453"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create server: %v", err)
|
||||
}
|
||||
|
||||
if srv.x402ResourceServer.HasFacilitatorSupport("eip155:8453", "exact") {
|
||||
t.Fatal("expected no facilitator client while the facilitator is down")
|
||||
}
|
||||
|
||||
req := x402.PaymentRequirements{
|
||||
Scheme: "exact",
|
||||
Network: "eip155:8453",
|
||||
Amount: "10000",
|
||||
Asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
||||
PayTo: "0x0000000000000000000000000000000000000000",
|
||||
}
|
||||
payload := types.PaymentPayload{
|
||||
X402Version: 2,
|
||||
Payload: map[string]interface{}{"chainId": 1},
|
||||
Accepted: req,
|
||||
}
|
||||
|
||||
_, verifyErr := srv.x402ResourceServer.VerifyPayment(ctx, payload, req)
|
||||
if verifyErr == nil || !strings.Contains(verifyErr.Error(), x402.ErrNoFacilitatorForNetwork) {
|
||||
t.Fatalf("expected ErrNoFacilitatorForNetwork while facilitator is down, got: %v", verifyErr)
|
||||
}
|
||||
|
||||
// Facilitator comes back; no restart required.
|
||||
f.available.Store(true)
|
||||
srv.ensureFacilitatorReady(ctx)
|
||||
|
||||
if !srv.x402ResourceServer.HasFacilitatorSupport("eip155:8453", "exact") {
|
||||
t.Fatal("expected facilitator client to be resolved after recovery")
|
||||
}
|
||||
|
||||
resp, err := srv.x402ResourceServer.VerifyPayment(ctx, payload, req)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyPayment should succeed after recovery, got: %v", err)
|
||||
}
|
||||
if resp == nil || !resp.IsValid {
|
||||
t.Errorf("expected VerifyPayment to return isValid=true after recovery, got %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeTwitterStyleProfileCost(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
platform string
|
||||
postCount int
|
||||
want string
|
||||
}{
|
||||
{name: "non-twitter flat base", platform: "Bluesky", postCount: 10, want: "$0.25"},
|
||||
{name: "twitter example from spec", platform: "Twitter", postCount: 10, want: "$0.31"},
|
||||
{name: "twitter zero posts", platform: "Twitter", postCount: 0, want: "$0.26"},
|
||||
{name: "twitter max posts", platform: "Twitter", postCount: 50, want: "$0.51"},
|
||||
{name: "twitter odd post count half cents", platform: "Twitter", postCount: 3, want: "$0.275"},
|
||||
{name: "posts at the max counted cap", platform: "Twitter", postCount: 100, want: "$0.76"},
|
||||
{name: "beyond cap is not clamped", platform: "Twitter", postCount: 200, want: "$1.26"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resp := GetStyleProfileResponse{SourcePlatform: tc.platform, PostCount: tc.postCount}
|
||||
payload, _ := json.Marshal(resp)
|
||||
r, _ := http.NewRequest("POST", "/v1/getStyleProfile", nil)
|
||||
|
||||
got := computeTwitterStyleProfileCost(r, string(payload))
|
||||
if got != tc.want {
|
||||
t.Fatalf("computeTwitterStyleProfileCost(%q, %d) = %q, want %q", tc.platform, tc.postCount, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Verify that every Twitter charge strictly exceeds the flat base cost, so the
|
||||
// endpoint always clears the underlying API cost (never break-even or loss).
|
||||
func TestComputeTwitterStyleProfileCostAlwaysProfitable(t *testing.T) {
|
||||
for postCount := 0; postCount <= 50; postCount++ {
|
||||
resp := GetStyleProfileResponse{SourcePlatform: "Twitter", PostCount: postCount}
|
||||
payload, _ := json.Marshal(resp)
|
||||
r, _ := http.NewRequest("POST", "/v1/getStyleProfile", nil)
|
||||
|
||||
got := computeTwitterStyleProfileCost(r, string(payload))
|
||||
// Cut the "$" prefix and compare numerically to BaseProfileCostMicro.
|
||||
dollars := strings.TrimPrefix(got, "$")
|
||||
parsed, err := strconv.ParseFloat(dollars, 64)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse %q: %v", got, err)
|
||||
}
|
||||
micro := int64(parsed * 1_000_000)
|
||||
if micro <= BaseProfileCostMicro {
|
||||
t.Fatalf("postCount=%d: charge %q (%d micro) must be strictly greater than base %d", postCount, got, micro, BaseProfileCostMicro)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setupMockFacilitator() *httptest.Server {
|
||||
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/supported":
|
||||
_, _ = w.Write([]byte(`{
|
||||
"kinds": [
|
||||
{
|
||||
"x402Version": 2,
|
||||
"scheme": "exact",
|
||||
"network": "eip155:8453"
|
||||
},
|
||||
{
|
||||
"x402Version": 2,
|
||||
"scheme": "upto",
|
||||
"network": "eip155:84532"
|
||||
}
|
||||
]
|
||||
}`))
|
||||
case r.Method == "POST" && r.URL.Path == "/verify":
|
||||
_, _ = w.Write([]byte(`{
|
||||
"isValid": true,
|
||||
"invalidReason": ""
|
||||
}`))
|
||||
case r.Method == "POST" && r.URL.Path == "/settle":
|
||||
_, _ = w.Write([]byte(`{
|
||||
"success": true,
|
||||
"transaction": "0x1234abcd",
|
||||
"network": "eip155:8453",
|
||||
"payer": "0xabcd1234"
|
||||
}`))
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
return s
|
||||
}
|
||||
|
||||
func TestAddTool_NonPayable(t *testing.T) {
|
||||
mockFacilitator := setupMockFacilitator()
|
||||
defer mockFacilitator.Close()
|
||||
|
||||
srv, err := NewX402Server(
|
||||
"Test Agent API",
|
||||
"1.0.0",
|
||||
mockFacilitator.URL,
|
||||
&DummyAuthProvider{},
|
||||
X402Middlewares{},
|
||||
func(next http.Handler) http.Handler { return next },
|
||||
func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {},
|
||||
[]string{"eip155:8453"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create server: %v", err)
|
||||
}
|
||||
|
||||
srv.AddTool(
|
||||
mcp.Tool{
|
||||
Name: "freeTool",
|
||||
Description: "Free test endpoint",
|
||||
},
|
||||
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{&mcp.TextContent{Text: `{"status": "free"}`}},
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
|
||||
// Test HTTP endpoint works without payment
|
||||
reqRec := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/v1/freeTool", strings.NewReader(`{}`))
|
||||
srv.mux.ServeHTTP(reqRec, req)
|
||||
if reqRec.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200 for free tool, got %d", reqRec.Code)
|
||||
}
|
||||
|
||||
// Test OpenAPI includes non-payable tool
|
||||
reqRecOpenAPI := httptest.NewRecorder()
|
||||
reqOpenAPI, _ := http.NewRequest("GET", "/openapi.json", nil)
|
||||
srv.mux.ServeHTTP(reqRecOpenAPI, reqOpenAPI)
|
||||
if !strings.Contains(reqRecOpenAPI.Body.String(), "freeTool") {
|
||||
t.Error("OpenAPI spec did not render non-payable tool")
|
||||
}
|
||||
// Should not have 402 response for free tool
|
||||
if strings.Contains(reqRecOpenAPI.Body.String(), `"402"`) {
|
||||
t.Error("OpenAPI spec should not have 402 for free tool")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddPayableTool_EmptyRequirements(t *testing.T) {
|
||||
mockFacilitator := setupMockFacilitator()
|
||||
defer mockFacilitator.Close()
|
||||
|
||||
srv, err := NewX402Server(
|
||||
"Test Agent API",
|
||||
"1.0.0",
|
||||
mockFacilitator.URL,
|
||||
&DummyAuthProvider{},
|
||||
X402Middlewares{},
|
||||
func(next http.Handler) http.Handler { return next },
|
||||
func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {},
|
||||
[]string{"eip155:8453"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create server: %v", err)
|
||||
}
|
||||
|
||||
// The library validates requirements at AddPayableTool call time
|
||||
// and returns an error (or panics via log.Fatal) for empty requirements
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
errMsg := fmt.Sprintf("%v", r)
|
||||
// The library may panic with log.Fatal or return an error
|
||||
if !strings.Contains(errMsg, "requires at least one payment requirement") &&
|
||||
!strings.Contains(errMsg, "Accepts must have at least one") {
|
||||
t.Errorf("expected panic about empty requirements, got: %v", r)
|
||||
}
|
||||
} else {
|
||||
t.Error("expected panic for empty payment requirements")
|
||||
}
|
||||
}()
|
||||
|
||||
srv.AddPayableTool(
|
||||
mcp.Tool{
|
||||
Name: "badTool",
|
||||
Description: "Bad endpoint",
|
||||
},
|
||||
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{&mcp.TextContent{Text: `{"status": "success"}`}},
|
||||
}, nil
|
||||
},
|
||||
nil,
|
||||
// No requirements provided
|
||||
)
|
||||
}
|
||||
|
||||
func TestX402Server_MultipleTools(t *testing.T) {
|
||||
mockFacilitator := setupMockFacilitator()
|
||||
defer mockFacilitator.Close()
|
||||
|
||||
srv, err := NewX402Server(
|
||||
"Test Agent API",
|
||||
"1.0.0",
|
||||
mockFacilitator.URL,
|
||||
&DummyAuthProvider{},
|
||||
X402Middlewares{},
|
||||
func(next http.Handler) http.Handler { return next },
|
||||
func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {},
|
||||
[]string{"eip155:8453"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create server: %v", err)
|
||||
}
|
||||
|
||||
// Add multiple tools
|
||||
toolNames := []string{"tool1", "tool2", "tool3"}
|
||||
for _, name := range toolNames {
|
||||
name := name
|
||||
srv.AddPayableTool(
|
||||
mcp.Tool{
|
||||
Name: name,
|
||||
Description: "Tool " + name,
|
||||
},
|
||||
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{&mcp.TextContent{Text: `{"tool": "` + name + `"}`}},
|
||||
}, nil
|
||||
},
|
||||
nil,
|
||||
x402.PaymentRequirements{
|
||||
Scheme: "exact",
|
||||
Network: "eip155:8453",
|
||||
Asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
||||
Amount: "1000",
|
||||
PayTo: "0x0000000000000000000000000000000000000000",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Verify all tools are registered in OpenAPI
|
||||
reqRec := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/openapi.json", nil)
|
||||
srv.mux.ServeHTTP(reqRec, req)
|
||||
|
||||
body := reqRec.Body.String()
|
||||
for _, name := range toolNames {
|
||||
if !strings.Contains(body, name) {
|
||||
t.Errorf("OpenAPI missing tool: %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify each tool requires payment
|
||||
for _, name := range toolNames {
|
||||
reqRec := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/v1/"+name, strings.NewReader(`{}`))
|
||||
srv.mux.ServeHTTP(reqRec, req)
|
||||
if reqRec.Code != http.StatusPaymentRequired {
|
||||
t.Errorf("tool %s: expected status 402, got %d", name, reqRec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeTwitterStyleProfileCost_EdgeCases(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
platform string
|
||||
postCount int
|
||||
want string
|
||||
}{
|
||||
{name: "case insensitive twitter", platform: "TWITTER", postCount: 10, want: "$0.25"}, // not "Twitter" so flat base
|
||||
{name: "empty platform", platform: "", postCount: 10, want: "$0.25"},
|
||||
{name: "negative post count", platform: "Twitter", postCount: -5, want: "$0.26"}, // should clamp to 0
|
||||
{name: "few posts", platform: "Twitter", postCount: 1, want: "$0.265"},
|
||||
{name: "very large post count", platform: "Twitter", postCount: 1000, want: "$5.26"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resp := GetStyleProfileResponse{SourcePlatform: tc.platform, PostCount: tc.postCount}
|
||||
payload, _ := json.Marshal(resp)
|
||||
r, _ := http.NewRequest("POST", "/v1/getStyleProfile", nil)
|
||||
|
||||
got := computeTwitterStyleProfileCost(r, string(payload))
|
||||
if got != tc.want {
|
||||
t.Fatalf("computeTwitterStyleProfileCost(%q, %d) = %q, want %q", tc.platform, tc.postCount, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestStyleProfileMaxPriceStaysAdvertised guards the advertised getStyleProfile
|
||||
// price in internal/server/tools.go against silent drift from the x402 pricing
|
||||
// model. internal/server shares the x402 constants (BaseProfileCostMicro /
|
||||
// MaxProfileCountedPosts) to compute the upto maximum, so this only needs to
|
||||
// catch a hand-edited Price string going out of sync.
|
||||
func TestStyleProfileMaxPriceStaysAdvertised(t *testing.T) {
|
||||
if got := formatDollarAmount(maxProfileCostMicro); got != "$0.76" {
|
||||
t.Fatalf("maxProfileCostMicro = %d (%s), want $0.76; update getStyleProfile base Price in internal/server/tools.go if the pricing model changed", maxProfileCostMicro, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestX402Server_MCPStyleProfileSettlementOverride drives the full MCP flow for
|
||||
// the getStyleProfile upto tool and asserts the facilitator is told to settle
|
||||
// the computed per-request cost (not the advertised $0.76 maximum).
|
||||
func TestX402Server_MCPStyleProfileSettlementOverride(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
var mu sync.Mutex
|
||||
var settleAmounts []string
|
||||
mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/supported":
|
||||
_, _ = w.Write([]byte(`{
|
||||
"kinds": [
|
||||
{"x402Version": 2, "scheme": "upto", "network": "eip155:84532"}
|
||||
]
|
||||
}`))
|
||||
case r.Method == "POST" && r.URL.Path == "/verify":
|
||||
_, _ = w.Write([]byte(`{"isValid": true, "invalidReason": ""}`))
|
||||
case r.Method == "POST" && r.URL.Path == "/settle":
|
||||
var body struct {
|
||||
PaymentRequirements map[string]json.RawMessage `json:"paymentRequirements"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if raw := body.PaymentRequirements["amount"]; len(raw) > 0 {
|
||||
var s string
|
||||
var f float64
|
||||
mu.Lock()
|
||||
if json.Unmarshal(raw, &s) == nil {
|
||||
settleAmounts = append(settleAmounts, s)
|
||||
} else if json.Unmarshal(raw, &f) == nil {
|
||||
settleAmounts = append(settleAmounts, strconv.FormatFloat(f, 'f', -1, 64))
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
_, _ = w.Write([]byte(`{
|
||||
"success": true,
|
||||
"transaction": "0x1234abcd",
|
||||
"network": "eip155:84532",
|
||||
"payer": "0xabcd1234"
|
||||
}`))
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer mock.Close()
|
||||
|
||||
serverTransport, clientTransport := mcp.NewInMemoryTransports()
|
||||
|
||||
srv, err := NewX402Server(
|
||||
"Test Agent API",
|
||||
"1.0.0",
|
||||
mock.URL,
|
||||
&DummyAuthProvider{},
|
||||
X402Middlewares{},
|
||||
func(next http.Handler) http.Handler { return next },
|
||||
func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {},
|
||||
[]string{"eip155:84532"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create server: %v", err)
|
||||
}
|
||||
|
||||
responses := []GetStyleProfileResponse{
|
||||
{Author: "@a", TopKeywords: []string{}, StyleSummary: "s", PostCount: 10, SourcePlatform: "Bluesky"},
|
||||
{Author: "@b", TopKeywords: []string{}, StyleSummary: "s", PostCount: 50, SourcePlatform: "Twitter"},
|
||||
}
|
||||
var idx int
|
||||
srv.AddPayableTool(
|
||||
mcp.Tool{Name: "getStyleProfile", Description: "get a style profile"},
|
||||
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
resp := responses[idx%len(responses)]
|
||||
idx++
|
||||
b, _ := json.Marshal(resp)
|
||||
return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: string(b)}}}, nil
|
||||
},
|
||||
NewStyleProfileCostResolver(),
|
||||
x402.PaymentRequirements{
|
||||
Scheme: "upto",
|
||||
Network: "eip155:84532",
|
||||
Amount: "0.76",
|
||||
PayTo: "0x0000000000000000000000000000000000000000",
|
||||
},
|
||||
)
|
||||
|
||||
serverSession, err := srv.mcpServer.Connect(ctx, serverTransport, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to connect server session: %v", err)
|
||||
}
|
||||
defer func() { _ = serverSession.Close() }()
|
||||
|
||||
client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "1.0.0"}, nil)
|
||||
clientSession, err := client.Connect(ctx, clientTransport, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to connect client session: %v", err)
|
||||
}
|
||||
defer func() { _ = clientSession.Close() }()
|
||||
|
||||
paidCall := func() (*mcp.CallToolResult, error) {
|
||||
challenge, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: "getStyleProfile", Arguments: json.RawMessage(`{}`)})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("challenge call failed: %w", err)
|
||||
}
|
||||
if !challenge.IsError {
|
||||
return nil, fmt.Errorf("expected a payment challenge, got %+v", challenge)
|
||||
}
|
||||
text, ok := challenge.Content[0].(*mcp.TextContent)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected challenge content type %T", challenge.Content[0])
|
||||
}
|
||||
var pr types.PaymentRequired
|
||||
if err := json.Unmarshal([]byte(text.Text), &pr); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode payment challenge: %w", err)
|
||||
}
|
||||
if len(pr.Accepts) == 0 {
|
||||
return nil, fmt.Errorf("expected at least one accept, got %d", len(pr.Accepts))
|
||||
}
|
||||
payload := types.PaymentPayload{
|
||||
X402Version: 2,
|
||||
Payload: map[string]interface{}{"chainId": 84532},
|
||||
Accepted: pr.Accepts[0],
|
||||
}
|
||||
return clientSession.CallTool(ctx, &mcp.CallToolParams{
|
||||
Name: "getStyleProfile",
|
||||
Arguments: json.RawMessage(`{}`),
|
||||
Meta: mcp.Meta{PaymentMetaKey: payload},
|
||||
})
|
||||
}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
result, err := paidCall()
|
||||
if err != nil {
|
||||
t.Fatalf("paid call: %v", err)
|
||||
}
|
||||
if result.IsError {
|
||||
text, _ := result.Content[0].(*mcp.TextContent)
|
||||
t.Fatalf("expected successful paid call, got error result: %+v", text)
|
||||
}
|
||||
if resp := result.Meta[PaymentResponseMetaKey]; resp == nil {
|
||||
t.Fatalf("expected %q in result meta", PaymentResponseMetaKey)
|
||||
}
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
got := append([]string(nil), settleAmounts...)
|
||||
mu.Unlock()
|
||||
// The "$0.25" / "$0.51" overrides resolve to 250000 / 510000 atomic units,
|
||||
// with up to 1 micro tolerance for float rounding in the SDK's dollar parser.
|
||||
wants := []int64{250000, 510000}
|
||||
if len(got) != len(wants) {
|
||||
t.Fatalf("settle amount count = %d (%v), want %d: %v", len(got), got, len(wants), wants)
|
||||
}
|
||||
for i, want := range wants {
|
||||
v, err := strconv.ParseInt(got[i], 10, 64)
|
||||
if err != nil {
|
||||
t.Fatalf("settle amount[%d] = %q is not an integer: %v", i, got[i], err)
|
||||
}
|
||||
diff := v - want
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
if diff > 1 {
|
||||
t.Errorf("settle amount[%d] = %d, want ~%d", i, v, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewStyleProfileCostResolver(t *testing.T) {
|
||||
resolver := NewStyleProfileCostResolver()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
resp GetStyleProfileResponse
|
||||
want string
|
||||
}{
|
||||
{name: "bluesky base", resp: GetStyleProfileResponse{SourcePlatform: "Bluesky", PostCount: 10}, want: "$0.25"},
|
||||
{name: "twitter with posts", resp: GetStyleProfileResponse{SourcePlatform: "Twitter", PostCount: 50}, want: "$0.51"},
|
||||
{name: "twitter no posts", resp: GetStyleProfileResponse{SourcePlatform: "Twitter", PostCount: 0}, want: "$0.26"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
payload, _ := json.Marshal(tc.resp)
|
||||
result := &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: string(payload)}}}
|
||||
got, ok := resolver(result)
|
||||
if !ok {
|
||||
t.Fatalf("resolver returned ok=false")
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("resolver = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Non-text content resolves to "no override"; malformed JSON falls back to
|
||||
// the flat base cost via computeTwitterStyleProfileCost.
|
||||
if _, ok := resolver(&mcp.CallToolResult{}); ok {
|
||||
t.Fatalf("expected no override for empty content")
|
||||
}
|
||||
got, ok := resolver(&mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "not json"}}})
|
||||
if !ok {
|
||||
t.Fatalf("expected a base-cost fallback for malformed JSON")
|
||||
}
|
||||
if got != "$0.25" {
|
||||
t.Fatalf("malformed JSON resolver = %q, want base fallback $0.25", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewTwitterCountCostResolver verifies the actual-cost settlement for
|
||||
// tools whose responses carry a `twitterPostCount` field (analytics tools,
|
||||
// findSimilarUsers): flat base price when no Twitter posts went into the
|
||||
// response, and base + lookup + per-post when they did.
|
||||
func TestNewTwitterCountCostResolver(t *testing.T) {
|
||||
const baseMicro = 150_000 // getEngagementMetrics base
|
||||
resolver := NewTwitterCountCostResolver(baseMicro)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
twitterCount int
|
||||
want string
|
||||
}{
|
||||
{name: "no twitter posts", twitterCount: 0, want: "$0.15"},
|
||||
{name: "one twitter post", twitterCount: 1, want: "$0.165"},
|
||||
{name: "ten twitter posts", twitterCount: 10, want: "$0.21"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"metrics": []any{},
|
||||
"twitterPostCount": tc.twitterCount,
|
||||
})
|
||||
result := &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: string(payload)}}}
|
||||
got, ok := resolver(result)
|
||||
if !ok {
|
||||
t.Fatalf("resolver returned ok=false")
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("resolver = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if _, ok := resolver(&mcp.CallToolResult{}); ok {
|
||||
t.Fatalf("expected no override for empty content")
|
||||
}
|
||||
if _, ok := resolver(&mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "not json"}}}); ok {
|
||||
t.Fatalf("expected no override for malformed JSON")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildSchemeConfigs verifies scheme servers are derived from the payment
|
||||
// requirements rather than the tool name, so upto is only registered for tools
|
||||
// that actually declare it.
|
||||
func TestBuildSchemeConfigs(t *testing.T) {
|
||||
req := func(scheme string) x402.PaymentRequirements {
|
||||
return x402.PaymentRequirements{Scheme: scheme, Network: "eip155:8453", Asset: "usdc", Amount: "1", PayTo: "to"}
|
||||
}
|
||||
|
||||
t.Run("exact only", func(t *testing.T) {
|
||||
got := buildSchemeConfigs([]string{"eip155:8453"}, req("exact"))
|
||||
if len(got) != 1 || got[0].Network != x402.Network("eip155:8453") {
|
||||
t.Fatalf("expected 1 exact scheme for one network, got %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upto only", func(t *testing.T) {
|
||||
got := buildSchemeConfigs([]string{"eip155:8453"}, req("upto"))
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 upto scheme, got %d", len(got))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("both schemes per network", func(t *testing.T) {
|
||||
got := buildSchemeConfigs([]string{"eip155:8453", "eip155:84532"}, req("exact"), req("upto"))
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("expected 2 schemes x 2 networks = 4, got %d", len(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPricingHelpers(t *testing.T) {
|
||||
if got := FormatPriceMicro(MaxTwitterCostMicro(BaseProfileCostMicro, MaxProfileCountedPosts)); got != "0.76" {
|
||||
t.Fatalf("style profile upto max = %q, want 0.76", got)
|
||||
}
|
||||
if got := FormatPriceMicro(MaxTwitterCostMicro(150_000, MaxAnalyticsCountedPosts)); got != "1.16" {
|
||||
t.Fatalf("engagement metrics upto max = %q, want 1.16", got)
|
||||
}
|
||||
if got := MicroFromPriceString("0.25"); got != BaseProfileCostMicro {
|
||||
t.Fatalf("MicroFromPriceString(0.25) = %d, want %d", got, BaseProfileCostMicro)
|
||||
}
|
||||
if got := MicroFromPriceString("garbage"); got != 0 {
|
||||
t.Fatalf("MicroFromPriceString(garbage) = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ User=mcpserver
|
|||
Group=mcpserver
|
||||
|
||||
WorkingDirectory=/home/ubuntu/gitSources/mcpServer
|
||||
ExecStart=/home/ubuntu/gitSources/mcpServer/bin/main
|
||||
ExecStart=/home/ubuntu/gitSources/mcpServer/bin/mcpserver
|
||||
|
||||
RuntimeDirectory=mcpserver
|
||||
RuntimeDirectoryMode=0750
|
||||
|
|
|
|||
62
scripts/fund-wallet.sh
Executable file
62
scripts/fund-wallet.sh
Executable file
|
|
@ -0,0 +1,62 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "=== Funding Wallet with Base Sepolia USDC ==="
|
||||
|
||||
# Required environment variables
|
||||
: "${PRIVATE_KEY:?PRIVATE_KEY must be set}"
|
||||
: "${FUNDING_FAUCET_URL:?FUNDING_FAUCET_URL must be set}"
|
||||
|
||||
WALLET_ADDRESS=$(cast wallet address --private-key "$PRIVATE_KEY")
|
||||
echo "Wallet address: $WALLET_ADDRESS"
|
||||
|
||||
# Check current USDC balance
|
||||
USDC_CONTRACT="0x036CbD53842c5426634e7929541eC2318f3dCF7e"
|
||||
RPC_URL="https://sepolia.base.org"
|
||||
|
||||
BALANCE=$(cast call "$USDC_CONTRACT" "balanceOf(address)(uint256)" "$WALLET_ADDRESS" --rpc-url "$RPC_URL" 2>/dev/null || echo "0")
|
||||
BALANCE_DECIMAL=$(cast to-dec "$BALANCE" 2>/dev/null || echo "0")
|
||||
BALANCE_USDC=$(echo "scale=6; $BALANCE_DECIMAL / 1000000" | bc 2>/dev/null || echo "0")
|
||||
|
||||
echo "Current USDC balance: $BALANCE_USDC USDC"
|
||||
|
||||
# If balance is less than 10 USDC, request from faucet
|
||||
if (( $(echo "$BALANCE_USDC < 10" | bc -l) )); then
|
||||
echo "Balance below 10 USDC, requesting from faucet..."
|
||||
|
||||
# Request from faucet (Coinbase CDP faucet API format)
|
||||
curl -X POST "$FUNDING_FAUCET_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"address\": \"$WALLET_ADDRESS\", \"token\": \"USDC\"}" \
|
||||
|| echo "Faucet request failed, may need manual funding"
|
||||
|
||||
# Wait for funding
|
||||
echo "Waiting for funding..."
|
||||
sleep 30
|
||||
|
||||
# Check balance again
|
||||
BALANCE=$(cast call "$USDC_CONTRACT" "balanceOf(address)(uint256)" "$WALLET_ADDRESS" --rpc-url "$RPC_URL" 2>/dev/null || echo "0")
|
||||
BALANCE_DECIMAL=$(cast to-dec "$BALANCE" 2>/dev/null || echo "0")
|
||||
BALANCE_USDC=$(echo "scale=6; $BALANCE_DECIMAL / 1000000" | bc 2>/dev/null || echo "0")
|
||||
echo "New USDC balance: $BALANCE_USDC USDC"
|
||||
else
|
||||
echo "Sufficient USDC balance"
|
||||
fi
|
||||
|
||||
# Also fund with ETH for gas
|
||||
ETH_BALANCE=$(cast balance "$WALLET_ADDRESS" --rpc-url "$RPC_URL" 2>/dev/null || echo "0")
|
||||
ETH_BALANCE_DECIMAL=$(cast to-dec "$ETH_BALANCE" 2>/dev/null || echo "0")
|
||||
ETH_BALANCE_ETH=$(echo "scale=6; $ETH_BALANCE_DECIMAL / 1000000000000000000" | bc 2>/dev/null || echo "0")
|
||||
|
||||
echo "Current ETH balance: $ETH_BALANCE_ETH ETH"
|
||||
|
||||
if (( $(echo "$ETH_BALANCE_ETH < 0.01" | bc -l) )); then
|
||||
echo "ETH balance low, requesting from faucet..."
|
||||
curl -X POST "$FUNDING_FAUCET_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"address\": \"$WALLET_ADDRESS\", \"token\": \"ETH\"}" \
|
||||
|| echo "ETH faucet request failed"
|
||||
sleep 10
|
||||
fi
|
||||
|
||||
echo "=== Wallet Funding Complete ==="
|
||||
101
scripts/start-test-server.sh
Executable file
101
scripts/start-test-server.sh
Executable file
|
|
@ -0,0 +1,101 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "=== Starting Test Server ==="
|
||||
|
||||
# Required environment variables
|
||||
: "${PRIVATE_KEY:?PRIVATE_KEY must be set}"
|
||||
: "${X402_WALLET_ADDRESS:?X402_WALLET_ADDRESS must be set}"
|
||||
|
||||
# Set up environment for local testing
|
||||
export SERVER_ADDRESS="127.0.0.1"
|
||||
export SERVER_PORT="8080"
|
||||
# Allow workflow services to override hosts (postgres/ollama service names)
|
||||
export GOOSE_DBSTRING="${GOOSE_DBSTRING:-postgres://postgres:postgres@localhost:5432/mcpserver?sslmode=disable}"
|
||||
# If postgres host not reachable, try service name
|
||||
if ! pg_isready -h localhost -U postgres -d mcpserver >/dev/null 2>&1 2>/dev/null; then
|
||||
if pg_isready -h postgres -U postgres -d mcpserver >/dev/null 2>&1 2>/dev/null; then
|
||||
export GOOSE_DBSTRING="postgres://postgres:postgres@postgres:5432/mcpserver?sslmode=disable"
|
||||
fi
|
||||
fi
|
||||
export ACTIVE_NETWORKS="sepolia"
|
||||
export X402_FACILITATOR_URL="https://api.cdp.coinbase.com/platform/v2/x402"
|
||||
export X402_WALLET_ADDRESS="$X402_WALLET_ADDRESS"
|
||||
# Respect OLLAMA_HOST from env (workflow services use http://ollama:11434)
|
||||
if curl -sf http://ollama:11434/api/tags >/dev/null 2>&1; then
|
||||
export OLLAMA_HOST="${OLLAMA_HOST:-http://ollama:11434}"
|
||||
else
|
||||
export OLLAMA_HOST="${OLLAMA_HOST:-http://localhost:11434}"
|
||||
fi
|
||||
export MIGRATE_ON_START="true"
|
||||
export MIGRATIONS_DIR="sql/schema"
|
||||
export READYZ_PORT="9090"
|
||||
export METRICS_PORT="9091"
|
||||
export LOG_FILE="/tmp/mcpserver-test.log"
|
||||
export LOG_LEVEL="debug"
|
||||
|
||||
# Use test credentials or empty for local testing
|
||||
export BLUESKY_DID="${BLUESKY_DID:-}"
|
||||
export BLUESKY_APP_PASSWORD="${BLUESKY_APP_PASSWORD:-}"
|
||||
export BLUESKY_HOST="${BLUESKY_HOST:-https://bsky.social}"
|
||||
export AP_HOST="${AP_HOST:-https://mastodon.social}"
|
||||
export AP_ACCESS_TOKEN="${AP_ACCESS_TOKEN:-}"
|
||||
export NOSTR_RELAYS="${NOSTR_RELAYS:-wss://relay.damus.io,wss://nos.lol}"
|
||||
export TWITTER_BEARER_TOKEN="${TWITTER_BEARER_TOKEN:-}"
|
||||
export CDP_API_KEY_ID="${CDP_API_KEY_ID:-}"
|
||||
export CDP_API_SECRET="${CDP_API_SECRET:-}"
|
||||
export ENRICH_CROSS_PLATFORM="false"
|
||||
export PREWARM_AUTHORS=""
|
||||
export PREWARM_FEED_URI=""
|
||||
|
||||
# Build server binary first
|
||||
echo "Building server binary..."
|
||||
go build -o /tmp/mcpserver ./cmd/server/main.go
|
||||
|
||||
# Start the server in background
|
||||
echo "Starting server on port 8080..."
|
||||
/tmp/mcpserver >>/tmp/mcpserver-test.log 2>&1 &
|
||||
SERVER_PID=$!
|
||||
|
||||
# Save PID for cleanup
|
||||
echo $SERVER_PID > /tmp/test-server.pid
|
||||
|
||||
# Wait for server to be ready
|
||||
echo "Waiting for server to be ready..."
|
||||
for i in {1..30}; do
|
||||
if curl -sf http://localhost:9090/readyz >/dev/null 2>&1; then
|
||||
echo "Server is ready"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Verify server is responding
|
||||
curl -sf http://localhost:8080/healthz >/dev/null 2>&1 || {
|
||||
echo "Server health check failed"
|
||||
kill -TERM $SERVER_PID 2>/dev/null || true
|
||||
sleep 2
|
||||
cat /tmp/mcpserver-test.log 2>&1 | tail -n 200
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Verify payment endpoints are working (should return 402 without payment)
|
||||
echo "Verifying payment endpoints..."
|
||||
if curl -sf -X POST http://localhost:8080/v1/getActor \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"author": "did:plc:z72i7hdynmk6r22z27h6tvur"}' >/dev/null 2>&1; then
|
||||
echo "WARNING: Payable endpoint getActor returned 200 without payment!"
|
||||
else
|
||||
echo "Payment endpoint getActor correctly requires payment (402)"
|
||||
fi
|
||||
|
||||
# Verify free endpoint works
|
||||
if curl -sf -X POST http://localhost:8080/v1/getFeed \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"uri": "https://bsky.app/profile/did:plc:z72i7hdynmk6r22z27h6tvur/feed/whats-hot"}' >/dev/null 2>&1; then
|
||||
echo "Free endpoint getFeed works correctly"
|
||||
else
|
||||
echo "WARNING: Free endpoint getFeed failed!"
|
||||
fi
|
||||
|
||||
echo "=== Test Server Started (PID: $SERVER_PID) ==="
|
||||
17
sql/queries/AnalyticsCache.sql
Normal file
17
sql/queries/AnalyticsCache.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
-- name: GetAnalyticsCache :one
|
||||
SELECT result FROM analytics_cache
|
||||
WHERE user_id = $1 AND platform = $2 AND tool_name = $3 AND time_range_hash = $4
|
||||
AND expires_at > now();
|
||||
|
||||
-- name: UpsertAnalyticsCache :exec
|
||||
INSERT INTO analytics_cache (user_id, platform, tool_name, time_range_hash, result, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (user_id, platform, tool_name, time_range_hash)
|
||||
DO UPDATE SET result = EXCLUDED.result, expires_at = EXCLUDED.expires_at, computed_at = now();
|
||||
|
||||
-- name: DeleteAnalyticsCache :exec
|
||||
DELETE FROM analytics_cache
|
||||
WHERE user_id = $1 AND platform = $2 AND tool_name = $3 AND time_range_hash = $4;
|
||||
|
||||
-- name: PruneAnalyticsCache :exec
|
||||
DELETE FROM analytics_cache WHERE expires_at <= now();
|
||||
14
sql/queries/AnalyticsCoverage.sql
Normal file
14
sql/queries/AnalyticsCoverage.sql
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
-- name: GetAnalyticsCoverageRanges :many
|
||||
SELECT covered_start, covered_end FROM analytics_coverage_ranges
|
||||
WHERE user_id = $1 AND platform = $2
|
||||
ORDER BY covered_start;
|
||||
|
||||
-- name: InsertAnalyticsCoverageRange :exec
|
||||
INSERT INTO analytics_coverage_ranges (user_id, platform, covered_start, covered_end, updated_at)
|
||||
VALUES ($1, $2, $3, $4, now())
|
||||
ON CONFLICT (user_id, platform, covered_start, covered_end)
|
||||
DO UPDATE SET updated_at = now();
|
||||
|
||||
-- name: DeleteAnalyticsCoverageRange :exec
|
||||
DELETE FROM analytics_coverage_ranges
|
||||
WHERE user_id = $1 AND platform = $2 AND covered_start = $3 AND covered_end = $4;
|
||||
17
sql/queries/AnalyticsPosts.sql
Normal file
17
sql/queries/AnalyticsPosts.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
-- name: GetAnalyticsPostsInRange :many
|
||||
SELECT post_id, post_text, post_timestamp, likes, reposts, replies, quotes
|
||||
FROM analytics_posts
|
||||
WHERE user_id = $1 AND platform = $2 AND post_timestamp >= $3 AND post_timestamp <= $4
|
||||
ORDER BY post_timestamp DESC
|
||||
LIMIT $5;
|
||||
|
||||
-- name: UpsertAnalyticsPosts :exec
|
||||
INSERT INTO analytics_posts (user_id, platform, post_id, post_text, post_timestamp, likes, reposts, replies, quotes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (user_id, platform, post_id)
|
||||
DO UPDATE SET post_text = EXCLUDED.post_text,
|
||||
post_timestamp = EXCLUDED.post_timestamp,
|
||||
likes = EXCLUDED.likes,
|
||||
reposts = EXCLUDED.reposts,
|
||||
replies = EXCLUDED.replies,
|
||||
quotes = EXCLUDED.quotes;
|
||||
3
sql/queries/DeleteStylePosts.sql
Normal file
3
sql/queries/DeleteStylePosts.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
-- name: DeleteStylePosts :exec
|
||||
DELETE FROM user_style_posts
|
||||
WHERE user_id = $1 AND platform = $2 AND platform != 'Twitter';
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
-- name: FindSimilarUsers :many
|
||||
SELECT user_id, platform, embedding, top_keywords, style_summary, post_count,
|
||||
1 - (embedding <=> $1::vector) AS similarity
|
||||
1 - (embedding <=> @query_embedding::vector) AS similarity
|
||||
FROM user_style_profiles
|
||||
ORDER BY embedding <=> $1::vector
|
||||
LIMIT $2;
|
||||
ORDER BY embedding <=> @query_embedding::vector
|
||||
LIMIT @limit_val;
|
||||
3
sql/queries/GetStylePostByID.sql
Normal file
3
sql/queries/GetStylePostByID.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
-- name: GetStylePostByID :one
|
||||
SELECT post_id, post_text FROM user_style_posts
|
||||
WHERE user_id = $1 AND platform = $2 AND post_id = $3;
|
||||
5
sql/queries/GetStylePosts.sql
Normal file
5
sql/queries/GetStylePosts.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
-- name: GetStylePosts :many
|
||||
SELECT post_id, post_text FROM user_style_posts
|
||||
WHERE user_id = $1 AND platform = $2
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $3;
|
||||
3
sql/queries/GetStylePostsByIDs.sql
Normal file
3
sql/queries/GetStylePostsByIDs.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
-- name: GetStylePostsByIDs :many
|
||||
SELECT post_id, post_text FROM user_style_posts
|
||||
WHERE user_id = $1 AND platform = $2 AND post_id = ANY($3::text[]);
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
-- name: GetStyleProfile :one
|
||||
SELECT * FROM user_style_profiles
|
||||
WHERE user_id = $1 AND platform = $2;
|
||||
SELECT id, user_id, platform, embedding, top_keywords, style_summary, post_count, modified_at, created_at, model_version, schema_version, algorithm_version FROM user_style_profiles
|
||||
WHERE user_id = @user_id AND platform = @platform;
|
||||
3
sql/queries/PruneStylePosts.sql
Normal file
3
sql/queries/PruneStylePosts.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
-- name: PruneStylePosts :exec
|
||||
DELETE FROM user_style_posts
|
||||
WHERE created_at < now() - interval '7 days' AND platform != 'Twitter';
|
||||
5
sql/queries/UpsertStylePosts.sql
Normal file
5
sql/queries/UpsertStylePosts.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
-- name: UpsertStylePosts :exec
|
||||
INSERT INTO user_style_posts (user_id, platform, post_id, post_text)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_id, platform, post_id)
|
||||
DO UPDATE SET post_text = EXCLUDED.post_text, created_at = now();
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
-- name: UpsertStyleProfile :exec
|
||||
INSERT INTO user_style_profiles (user_id, platform, embedding, top_keywords, style_summary, post_count)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
VALUES (@user_id, @platform, @embedding::vector, @top_keywords, @style_summary, @post_count)
|
||||
ON CONFLICT (user_id, platform)
|
||||
DO UPDATE SET
|
||||
embedding = EXCLUDED.embedding,
|
||||
top_keywords = EXCLUDED.top_keywords,
|
||||
style_summary = EXCLUDED.style_summary,
|
||||
post_count = EXCLUDED.post_count,
|
||||
updated_at = NOW();
|
||||
modified_at = NOW();
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
-- +goose up
|
||||
CREATE TABLE transactions (
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
x402_transaction_hash VARCHAR(255) NOT NULL UNIQUE,
|
||||
amount NUMERIC NOT NULL,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
-- +goose up
|
||||
CREATE TABLE user_style_profiles (
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_style_profiles (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
platform TEXT NOT NULL,
|
||||
embedding VECTOR(768) NOT NULL,
|
||||
embedding VECTOR(1024) NOT NULL,
|
||||
top_keywords TEXT[] NOT NULL,
|
||||
style_summary TEXT NOT NULL,
|
||||
post_count INTEGER NOT NULL,
|
||||
|
|
@ -13,4 +15,6 @@ CREATE TABLE user_style_profiles (
|
|||
);
|
||||
|
||||
-- +goose down
|
||||
DROP TABLE IF EXISTS user_style_profiles;
|
||||
DROP TABLE IF EXISTS user_style_profiles;
|
||||
|
||||
DROP EXTENSION IF EXISTS vector;
|
||||
15
sql/schema/003_add_cache_version.sql
Normal file
15
sql/schema/003_add_cache_version.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
-- +goose up
|
||||
ALTER TABLE user_style_profiles
|
||||
ADD COLUMN 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
Loading…
Reference in a new issue