Compare commits

...
Author SHA1 Message Date
8bb4cb539c fix: runs-on
Some checks failed
Test Every Endpoint on Base Sepolia / test_endpoints (push) Failing after 28s
CI/CD Go Verification / build_and_test (push) Successful in 57s
2026-09-03 17:19:45 +00:00
07ab6a13af workflows on ai-main 2026-09-03 17:18:01 +00:00
ee0589c774 chore: tidy store integration test and deps
- remove tautological SaveTransaction SQL/Params constant checks (const string replay)
- go.mod tidy drops unused go-sqlmock indirect
- testClient/main.go: gofmt alignment
2026-09-03 17:00:45 +00:00
193184675e test(x402): cover Twitter style-profile cost passing and payable boundaries
- add ComputeTwitterStyleProfileCost table and AlwaysProfitable invariant
- add edge cases (case-insensitive, negative, large counts)
- cover MultipleTools and AddPayableTool validation, HTTP 402/MCP boundaries
2026-09-03 17:00:36 +00:00
90af0ec4fc refactor(server): split mcp_test into focused suites with fakeStore/fakeOllama
- extract analytics, searchactors, styleprofile, twitter_analytics, validation, storefake from mcp_test
- add fakeStore/fakeOllama harnesses, lastLimit tracking, sql.NullTime staleness
- expand mcp_test with per-platform GetPost/Latest/PostsInTime, actor and cache tests
- touch analytics/backends/cache_version/metrics/server for coverage
2026-09-03 17:00:32 +00:00
c8d1829172 test(clients): expand coverage for rate-limit, pagination and error paths
- nostr: variants for resolvePubkey/EventID, invalid ID/author checks, relay failure
- bluesky: add FeedGetPosts/getProfile/searchActors mocks, pagination and time-filtering with cursor
- twitter: add RateLimit retry (Reset/Retry-After), exhausted retries, lookup errors, context cancel
- activitypub: fix GetPostsInTimeframe to test 2-page pagination and start/end boundary
2026-09-03 17:00:28 +00:00
6477d5845f test: remove tautological stdlib-roundabout tests
Aggressive deletion of tests that only replay stdlib or copy impl:
- config/config_test.go: getEnvBool/splitComma/Load defaults (strings.* wrappers)
- utils/styleProfiles_test.go: ParseEmbedding type-switch
- store/migrate_test.go: extractUpSQL marker parsing
- clustering/kmeans_test.go: IdenticalVectors/SingleItem/DifferentSeeds (trivial)
- ollama/client_test.go: PromptStructure/UsesProvidedModels (setter replay)
2026-09-03 17:00:24 +00:00
0dcb74675e fix(analytics,x402): fix coverage gaps, engagement drivers, and Twitter cost passing
Analytics review fixes:
- Store Twitter analytics coverage as discrete intervals instead of a single
  contiguous window so disjoint fetch ranges no longer mask inner gaps as
  covered (fetch per uncovered range, add gap regression tests)
- Compute real AvgEngagement/Correlation in getEngagementDrivers instead of
  all-zero placeholders
- Make parseAnalyticsTimeRange surface invalid timestamps, cap merged analytics
  posts at limit, and normalize sentiment by timestamped posts only
- gofmt: fix misalignment in config.go and types.go
- Include Twitter in the default aggregate analytics scope; its API costs are
  passed to the caller through paid tool pricing

x402 cost passing:
- computeTwitterStyleProfileCost now charges base cost ($0.10) + user lookup
  ($0.01) + $0.005 per Twitter post, guaranteeing the charge always clears the
  underlying API cost (never break-even or loss)
- Return a $-prefixed dollar string for the settlement override, fixing the
  previously off-by-factor-100 raw-atomic-unit amount
- Raise getStyleProfile base registration to $0.36 to authorize the max Twitter
  charge
- Add tests for the cost formula, profitable floor, and dollar formatting
2026-09-02 22:44:02 +00:00
e4176f99ed chore: ignore local test-endpoints binaries 2026-09-02 23:50:02 +02:00
979cd92445 test: add coverage-range tests for twitter analytics caching 2026-09-02 23:49:57 +02:00
c6b73cba1f feat: add analytics tools with real engagement, pattern, topic and sentiment computation
Register five payable analytics MCP tools (engagement metrics, posting
patterns, topic modeling, sentiment trends, engagement drivers).

- Thread real post timestamps and engagement metrics through the compute
  functions instead of time.Now()/zero placeholders (enabled by the
  enriched clustering.Item).
- Report the requested author (args.Author) instead of a literal string.
- Implement sentiment shift detection (from/to/magnitude) ordered by time.
- Cache analytics results keyed by scope: a single "all" key for
  cross-platform aggregates and per-platform keys for single-platform
  queries, so aggregate results are not mixed with partial data.
- Use configurable per-tool cache TTLs stored on the Server.

Also cache Twitter post data permanently to avoid repeat paid API calls:
newly fetched posts are persisted, and a coverage watermark (analytics
coverage table) ensures the paid Twitter API is only called for time
ranges not already retrieved.
2026-09-02 23:49:54 +02:00
fbd74a37b5 clustering: enrich Item with timestamp and engagement fields
Carry post metadata (timestamp, likes, reposts, replies, quotes) through
the clustering pipeline so analytics compute functions can use real values
instead of placeholders. The clustering algorithm only consumes Text and
Embedding, so this is backward-compatible.
2026-09-02 23:49:41 +02:00
4e027ca857 config: add per-tool analytics cache TTL settings
Wire configurable cache TTLs for each analytics tool from environment
variables, and provide a helper for parsing duration env vars.
2026-09-02 23:49:38 +02:00
c851d0e50f store: add sqlc queries for analytics cache, posts and coverage 2026-09-02 23:49:35 +02:00
ab1eb634f9 schema: add analytics cache, posts and coverage tables
Add tables backing the analytics cache, the permanent per-platform post
store, and the coverage watermark used to avoid re-fetching paid Twitter
posts once a time range has already been retrieved.
2026-09-02 23:49:32 +02:00
686c7ca2b6 ci: use env vars for ollama model pulls in test workflow
- Pull Ollama Models step now uses ${EMBEDDING_MODEL} and ${STYLE_MODEL}
- Values sourced from config/models.env in Load model config step

---

- Use $FORGEJO instead of $GITHUB variables
2026-09-02 02:00:59 +02:00
695e137915 config: single source of truth for model names via config/models.env
- config/models.env: EMBEDDING_MODEL and STYLE_MODEL defaults
- config.go: loads models.env by finding project root (go.mod), removes hardcoded defaults
- test-endpoints.yaml: sources config/models.env to set env vars for CI
- Works from any working directory (server, tests, CI)
2026-09-02 01:41:00 +02:00
4f56def00e config: switch STYLE_MODEL to qwen3:4b-instruct-2507-q4_K_M
- Instruction-tuned variant for reliable prompt following
- Q4_K_M quantization for CPU latency optimization
- Updated test workflow to pull correct model
2026-09-02 01:06:46 +02:00
3c0a7b9506 refactor: simplify ticker channel loop 2026-09-02 00:37:55 +02:00
248f738e07 deps: go mod tidy cleanup
- Remove unused indirect dependencies (fsnotify, oapi-codegen, influxdb, graphql-go, etc.)
- Kept: github.com/ethereum/go-ethereum for test client wallet funding
2026-09-02 00:30:37 +02:00
877f839926 test: add clustering KMeans tests and ollama client tests
- clustering/kmeans_test.go: TestKMeans (basic, empty, k>n, identical, single, deterministic, high-dim), TestCosineSimilarity, TestNormalize, TestInitializeKMeansPlusPlus
- ollama/client_test.go: TestEmbedTexts, TestSummarizeStyle (streaming, errors, empty input), TestNew_UsesProvidedModels
- ollama/client.go: early return for empty input in EmbedTexts/SummarizeStyle
2026-09-02 00:30:19 +02:00
ad67171f37 config: move Ollama model defaults to config package
- config.go: set EMBEDDING_MODEL=qwen3-embedding:0.6b, STYLE_MODEL=qwen3:4b as defaults
- ollama/client.go: remove hardcoded fallback logic, use passed values directly
- Single source of truth for model defaults in config package
2026-09-02 00:09:06 +02:00
5574a8596f db: update embedding dimension to 1024 for qwen3-embedding:0.6b
- Migration 002: VECTOR(768) -> VECTOR(1024) to match qwen3-embedding:0.6b default output
- integration_test.go: update test vector from 768 to 1024 dimensions
2026-09-02 00:00:10 +02:00
56f8625f05 ci: fix test workflow Ollama models to match project defaults
- Pull qwen3-embedding:0.6b and qwen3:4b instead of nomic-embed-text/llama3.1:8b
- Set EMBEDDING_MODEL and STYLE_MODEL env vars for test server
2026-09-01 23:53:09 +02:00
e2cd655bc1 ci: add docker-compose.test.yml for test infrastructure
- PostgreSQL with pgvector for migrations and tests
- Ollama for embedding/style models
- Used by test-endpoints.yaml workflow
2026-09-01 23:47:55 +02:00
63709524de db: make migrations idempotent, regenerate store, minor cleanup
- schema: add IF NOT EXISTS to CREATE TABLE/INDEX for idempotent migrations
- store: regenerate with sqlc v1.31.1 (adds UserStylePost, interface{} for Embedding)
- webhook: minor formatting cleanup
2026-09-01 23:42:23 +02:00
63f8756cc7 db: add user_style_posts table for post ID caching
- migration 004: create user_style_posts with user_id, platform, post_id, post_text, created_at
- queries: UpsertStylePosts, DeleteStylePosts (excludes Twitter), GetStylePosts, GetStylePostsByIDs, GetStylePostByID, PruneStylePosts
- activitypub: add ID field to Post struct
- server: fetchProfilePosts checks cache first, upserts on miss; buildStyleProfile deletes non-Twitter posts before rebuild; background pruner runs daily
2026-09-01 23:41:45 +02:00
d3845671b4 ollama: fix EmbedTexts signature, add model config, use clustering package
- ollama/client.go: EmbedTexts accepts []clustering.Item, returns [][]float64
- backends.go: ollamaBackend interface uses clustering.Item
- utils/styleProfiles.go: remove duplicate KMeans, normalize, cosineSimilarity
- clustering/: new package with Item, Result types and KMeans
- config.go: add EmbeddingModel, StyleModel env vars
- main.go: pass model config to ollama.New()
- go.mod: add github.com/ethereum/go-ethereum for test client
2026-09-01 23:40:21 +02:00
027a4ee5e9 test: add auto-funding to test client and fix CI workflow
- testClient: add ensureFundedWallet() with USDC/ETH balance checks and faucet requests
- fund-wallet.sh: update faucet format for Coinbase CDP API
- start-test-server.sh: use Coinbase facilitator URL, add payment endpoint verification
- test-endpoints.yaml: fund test wallet before server, pass RPC/Faucet URLs to test client
2026-09-01 23:39:14 +02:00
e54d17736b Complete Matrix webhook integration and interface{}→any migration
- Add MATRIX_WEBHOOK_URL and MATRIX_SERVER_ID config fields (optional, defaults to hostname)
- Create internal/server/webhook.go: WebhookSender with exponential backoff retry (5 attempts),
  first/last attempt logging, non-blocking goroutine integration
- Wire webhook into OnAfterSettle callback in server.go (goroutine, fire-and-forget)
- Migrate all persistent interface{} to any (Go 1.25.4 convention):
  db.go: ...any in DBTX interface
  models.go: Embedding any
  FindSimilarUsers.sql.go: QueryEmbedding any, Embedding any
  UpsertStyleProfiles.sql.go: Embedding any
- Migrate server.go map[string]interface{} to map[string]any in webhook payload
- Add TestStoreSaveTransaction integration test (skipped without TEST_DATABASE_URL)
- Add TestSettleHook unit test for param mapping
2026-08-31 01:02:55 +02:00
114574b331 Complete transaction save test suite
- TestSettleHook (internal/server/mcp_test.go): verifies SaveTransactionParams field mapping
  - Tests all 6 params: X402TransactionHash, Amount, Asset, Network, Recipient, Sender
  - Ensures params are settable without panic, critical for legal compliance of x402 flow

- TestStoreSaveTransaction (internal/store/integration_test.go): integration test against real Postgres
  - Exercised SaveTransaction query end-to-end with full param mapping
  - Skipped without TEST_DATABASE_URL (same pattern as TestStoreRoundTrip)
  - Verifies: insert returns ID and CreatedAt, ID is positive, CreatedAt is valid
  - Uses unique transaction hash per test run via time.Now().Unix()

Both tests ensure the critical transaction-saving mechanism is verified at both unit and integration levels, providing regression protection for the legal-compliance x402 payment flow.
2026-08-31 00:01:29 +02:00
fbd1f5af6c Add SaveTransaction SQL and params tests
- TestSaveTransactionSQL: verifies the sqlc-generated SaveTransaction query constant matches the source SQL file
- TestSaveTransactionParams: verifies SaveTransactionParams struct can be constructed with valid and edge-case values (empty hash, empty amount)

Both tests are pure-unit tests that don't require a database connection. They ensure the critical transaction-saving SQL query and its parameter mapping remain consistent, which is important for legal compliance of the x402 payment flow.
2026-08-30 23:56:40 +02:00
9918134fd0 Add ModelVersion/SchemaVersion/AlgorithmVersion fields to UserStyleProfile and update db.go version
- Add missing ModelVersion, SchemaVersion, AlgorithmVersion fields to UserStyleProfile struct
- Update sqlc version from v1.30.0 to v1.31.1 in db.go and models.go
- Fix any/interface{} type consistency in db.go
2026-08-30 22:42:47 +02:00
d7bece250f Use named parameters in SQL queries to avoid ColumnN naming in sqlc generated structs
- Rewrite GetStyleProfile, FindSimilarUsers, UpsertStyleProfiles SQL queries
  to use @named parameters instead of  positional placeholders
- Regenerate sqlc Go files with meaningful parameter names
- Update server.go and integration test to use new parameter field names
- Fix Similarity int32→float64 type conversion
2026-08-30 22:41:53 +02:00
71797cfc4f x402: implement dynamic pricing for Twitter getStyleProfile
- Add upto scheme support for dynamic pricing on getStyleProfile
- computeTwitterStyleProfileCost: calculates actual charge based on posts fetched + user lookup
  - User read: /bin/bash.01 (if not cached)
  - Posts read: /bin/bash.005 per post, max 50 posts = /bin/bash.25
  - Minimum: /bin/bash.10
  - Maximum: /bin/bash.26
- handleRESTCall: apply settlement override for upto scheme on getStyleProfile
- GetStyleProfileResponse: local schema type for cost calculation
2026-08-30 18:20:17 +02:00
d085d9e3ae twitter: policy compliance and per-post pricing
- Change base URI from api.twitter.com to api.x.com/2
- Rate limit: honor x-rate-limit-remaining and x-rate-limit-reset headers
- Remove GetActor/SearchActors from Twitter client (policy: prohibits reselling user/profile data)
- Block Twitter from getActor, searchActors, getPost, getLatestPosts, getPostsInTime, getFeed in MCP server
- Allow Twitter for getStyleProfile with exact /bin/bash.10 pricing (minimum across all platforms)
- Remove GetActor/SearchActors from twitterBackend interface
- Update tests for new Twitter restrictions
- Add upto EVM scheme support for future dynamic pricing
2026-08-30 17:44:30 +02:00
bd707cf2ca refactor: replace interface{} with any
- convert remaining interface{} usages to any in store, utils, bluesky
- consolidate pgvector extension drop into 002 migration
2026-08-30 16:03:03 +02:00
3c024d668e Add observability, post caching, dedup, Bluesky refresh, Twitter backoff, MCP tests
- Per-tool Prometheus-style metrics via /metrics and error logging on failures
- In-memory TTL post cache for getPost/getLatestPosts/getPostsInTime/getFeed
- searchActors dedupes cross-platform duplicates by normalized handle
- Bluesky client refreshes session on auth/expired-token errors (retry once)
- Twitter client backs off on HTTP 429 honoring Retry-After
- MCP tests with fake platform backends (dispatch, caching, dedup, partial/full failure)

Co-Authored-By: Claude (AI assistant) <noreply@anthropic.com>
2026-08-29 02:46:17 +02:00
cab911ea6f fix: searchActors errors only when all platforms fail
Return merged results whenever at least one platform succeeds (even if others errored or
returned empty). Only surface an error when every platform failed, so a caller is never
billed for a search that produced nothing while still getting partial results when available.

Co-Authored-By: Claude (AI assistant) <noreply@anthropic.com>
2026-08-29 02:28:14 +02:00
ec27416cb1 fix: payable tools fail fast on server errors instead of degrading
The x402 layer only settles payment when a tool handler succeeds (server.go:604-605
creates a cancellation dispatcher, not a settlement, on handler error / non-2xx). So
returning a real error is what prevents billing a caller for a deliverable they didn't
ask for.

- Revert the Ollama-down graceful degradation in buildStyleProfile: getStyleProfile and
  findSimilarUsers now return an error when embedding/summary generation fails, instead of a
  successful zero-embedding profile.
- searchActors now fails fast: if any platform client errors, the error is surfaced rather
  than silently returning a partial (other-platforms-only) result.

Co-Authored-By: Claude (AI assistant) <noreply@anthropic.com>
2026-08-29 02:24:08 +02:00
4ac15ff6c2 test: cover getActor/searchActors clients and migrations
Runnable (HTTP-mocked) unit tests:
- activitypub: GetActor (accounts/lookup) plus id-lookup fallback, and SearchActors
  (accounts/search).
- twitter: GetActor (/users/by/username) and SearchActors (/users/search).
- nostr: SearchActors best-effort path (non-resolvable query returns empty, no network).
- store: extractUpSQL parsing helper.

Store integration test (TestStoreRoundTrip) is gated behind TEST_DATABASE_URL and skipped
when no Postgres+pgvector is available; it runs Migrate and exercises the Upsert/Get/
FindSimilarUsers round-trip against a real database.

Co-Authored-By: Claude (AI assistant) <noreply@anthropic.com>
2026-08-29 02:16:39 +02:00
1d97ca1164 feat: add getActor and searchActors tools across platforms
Introduce a shared actor.Profile type (internal/actor) so every platform client can return a
common shape without importing the server package (which would be an import cycle).

- Bluesky: GetActor (app.bsky.actor.getProfile) and SearchActors (app.bsky.actor.searchActors) via XRPC.
- ActivityPub: GetActor (accounts/lookup, falling back to accounts/{id}) and SearchActors
  (accounts/search).
- Nostr: GetActor subscribes to kind:0 metadata; SearchActors is best-effort (resolves a
  pubkey/npub query to a single profile; Nostr has no global actor search).
- Twitter: GetActor (/users/by/username) and SearchActors (/users/search).

server: register getActor and searchActors as payable tools; searchActors fans the query out
to all four platforms and merges results, skipping any platform that errors.

Schema fixes: document that getPost.collection is Bluesky-only, and that getFeed.uri is a feed
URI for Bluesky but an author handle for other platforms.

Co-Authored-By: Claude (AI assistant) <noreply@anthropic.com>
2026-08-29 02:14:29 +02:00
8cfb1291aa ops: migrator, enrichment backpressure, ollama fallback, readiness
- Add dependency-free in-process schema migrator (internal/store/migrate.go) that applies
  sql/schema/*.sql in order, tracking versions in schema_migrations. Gated by MIGRATE_ON_START
  and MIGRATIONS_DIR (default sql/schema).
- Fix migration ordering: 002 now creates the 'vector' extension before defining the
  user_style_profiles table (previously the extension was only created later in 003).
- Bound concurrent background enrichment with a 4-slot semaphore so bursts of live
  getStyleProfile calls cannot overwhelm Ollama.
- buildStyleProfile degrades gracefully when Ollama is down: stores a zero embedding and a
  keyword-based fallback summary instead of failing the whole request.
- Add a /readyz side server (READYZ_PORT, default 9090) that checks DB connectivity and
  Ollama availability.

Co-Authored-By: Claude (AI assistant) <noreply@anthropic.com>
2026-08-29 02:09:50 +02:00
533ecede00 test: cover new cross-platform client methods
Add runnable (HTTP-mocked) unit tests for the client methods added when extending the
post tools to all platforms:

- twitter: GetPost (single-tweet object response + author expansion + public_metrics) and
  GetPostsInTimeframe (start_time/end_time query params + metrics)
- activitypub: GetPostsInTimeframe (paginated Mastodon statuses within a window)
- nostr: resolvePubkey/resolveEventID resolution helpers via real npub/note encoding

Also fix a latent bug in internal/nostr: resolvePubkey/resolveEventID wrapped the wrong
error variable (the nip19 error instead of the hex-decode error) when the 64-hex fallback
failed, producing misleading messages.

Co-Authored-By: Claude (AI assistant) <noreply@anthropic.com>
2026-08-29 01:44:39 +02:00
4d11cea6cd feat: extend post tools (getPost, getLatestPosts, getPostsInTime, getFeed) to all platforms
Previously these tools were Bluesky-only. They now dispatch by platform:

- getPost: Bluesky uses actor+collection+rkey; ActivityPub uses GetPost(statusID);
  Nostr uses GetPost(noteID); Twitter uses GetPost(tweetID)
- getLatestPosts / getPostsInTime: delegate to each platform client
- getFeed: Bluesky uses GetFeed; other platforms treat the URI as an author handle and
  return their latest posts (no custom-feed concept elsewhere)

Client additions:
- twitter: GetPost, GetPostsInTimeframe; Post now carries like/repost/quote/reply
  counts from public_metrics (populated in GetLatestNPosts and GetPostsInTimeframe)
- nostr: GetPost, GetPostsInTimeframe (relay queries with Since/Until); resolveEventID
  helper mirroring resolvePubkey

Server gains normalized mapping/dispatch helpers (bskyPostViewToResponse,
apPostToResponse, nostrPostToResponse, twitterPostToResponse, fetchSinglePost,
fetchLatestPosts, fetchPostsInTime, fetchFeed) that return a uniform
GetPostResponsePost, mapping missing engagement counts to 0.

Co-Authored-By: Claude (AI assistant) <noreply@anthropic.com>
2026-08-29 01:41:14 +02:00
e31a4313f4 test: add unit tests for pure helpers and HTTP clients
Add runnable unit tests (no external services required):

- internal/utils: FormatVector/ParseEmbedding (all type branches + round-trip),
  AverageVector, TopWords (min-length + stopword filtering + ranking)
- internal/config: getEnvBool, splitComma, Load defaults/override
- internal/server: determinePlatform (did/npub/@-handle/64-hex/dotted) and
  platformFromString
- internal/twitter: mocked Twitter API v2 (empty-token error, fetch+parse,
  max_results clamping, non-200 error)
- internal/activitypub: mocked Mastodon API (constructor validation,
  GetLatestNPosts, GetPost, non-200 error)

Not covered (require live services / heavy mocking): store sqlc queries +
pgvector migrations (need Postgres), nostr client (needs a relay), ollama
client (wraps the ollama/go API over HTTP), and server orchestration
(requestStyleProfile, enrichCrossPlatform, fetchProfilePosts, getSimilarProfiles
need a DB + platform clients).

Co-Authored-By: Claude (AI assistant) <noreply@anthropic.com>
2026-08-29 01:33:42 +02:00
e8a37831cb add: findSimilarUsers payable tool (pgvector similarity search)
- register findSimilarUsers (price 0.05) which reuses requestStyleProfile to get the source
  persona embedding, encodes it with utils.FormatVector, and queries the existing (previously
  unused) FindSimilarUsers pgvector query for ranked cross-platform matches; self excluded
- add FindSimilarUsersRequest/Response/SimilarUser types and an OutputSchema for the tool
- fix generated store: FindSimilarUsersRow.Similarity int32 -> float64 (SQL returns
  1 - (embedding <=> $1::vector), a float8; scanning into int32 would error)
2026-08-29 01:26:41 +02:00
bc168e1bca add: background cross-platform enrichment on profile request
- refactor requestStyleProfile into a shared requestStyleProfileForced core (resolve -> cache
  check -> build) so a specific platform can be targeted regardless of identifier format
- on a live getStyleProfile request, fire-and-forget enrichCrossPlatform: bounded fan-out
  (<=3 goroutines) tries the same identifier on every other platform, logs/skips mismatches,
  and still obeys the 1-week update gate; uses a detached context (context.WithoutCancel +
  5m timeout) so it outlives the request
- trigger only on live tool calls (not the prewarmer) and gated by ENRICH_CROSS_PLATFORM
  (default true)
- add platformFromString helper
2026-08-29 01:26:08 +02:00
e5168e33f3 add: multi-platform getStyleProfile with slow, discovery-based prewarmer
- Nostr client (fiatjaf.com/nostr) fetching kind:1 notes from relays
- Twitter client using raw Twitter API v2 (no external X library), optional (no token = lazy error)
- getStyleProfile dispatches by platform; a profile is updated only when a request is
  made and the cached profile is older than a week (served from cache otherwise)
- shared requestStyleProfile path used by both the tool and the prewarmer
- StartPrewarm discovers interesting authors (from a feed URI or seed list) and
  pre-fetches at most one profile per minute; fresh profiles are never re-embedded
2026-08-29 01:07:47 +02:00
12bf3e8311 add: make getStyleProfile work (embeddings, platform gating, pgvector extension) 2026-08-29 00:32:50 +02:00
b8b39af276 fix: long standing typo 2026-08-03 13:23:52 +00:00
dbf0e2591b add: supported network config option
update: align testClient with x402-v2
2026-08-03 12:40:04 +00:00
4af4d79940 add: global logging 2026-08-03 11:08:53 +00:00
33f07ea75a revert label 2026-08-02 00:53:31 +00:00
bd62f05b5d update runner label 2026-08-02 00:49:54 +00:00
641bf66dbb Merge pull request 'task/migrate-x402-v2' (#1) from task/migrate-x402-v2 into main
Reviewed-on: #1
2026-07-30 08:13:05 +00:00
9f59a7d9d6 add x402 unit test
add test workflow
2026-07-30 06:45:26 +00:00
5c89b8077a critical: fix infinite recursion 2026-07-29 22:09:32 +00:00
8b27ecacfa Remove obsolete types 2026-07-29 22:09:31 +00:00
b655faca4a rewrite wrapper
migrate server from v1 to v2
2026-07-29 22:09:27 +00:00
f7c75e05f0 Migrate internal x402 wrapper 2026-07-29 22:09:12 +00:00
f144cf8868 migrate middlewares 2026-07-29 22:09:01 +00:00
fed1a8d294 feat: add activitypub 2026-07-18 22:42:44 +00:00
90 changed files with 11977 additions and 1263 deletions

View file

@ -0,0 +1,142 @@
name: Test Every Endpoint on Base Sepolia
on:
push:
branches: [ main, ai-main ]
pull_request:
branches: [ main ]
workflow_dispatch:
env:
GO_VERSION: '1.25'
POSTGRES_DB: mcpserver
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
TEST_SERVER_URL: http://localhost:8080
RPC_URL: https://sepolia.base.org
X402_FACILITATOR_URL: https://api.cdp.coinbase.com/platform/v2/x402
jobs:
test_endpoints:
runs-on: node20
timeout-minutes: 30
env:
PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }}
X402_WALLET_ADDRESS: ${{ secrets.X402_WALLET_ADDRESS }}
FUNDING_FAUCET_URL: ${{ secrets.FUNDING_FAUCET_URL }}
BLUESKY_DID: ${{ secrets.BLUESKY_DID }}
BLUESKY_APP_PASSWORD: ${{ secrets.BLUESKY_APP_PASSWORD }}
AP_ACCESS_TOKEN: ${{ secrets.AP_ACCESS_TOKEN }}
TWITTER_BEARER_TOKEN: ${{ secrets.TWITTER_BEARER_TOKEN }}
CDP_API_KEY_ID: ${{ secrets.CDP_API_KEY_ID }}
CDP_API_SECRET: ${{ secrets.CDP_API_SECRET }}
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Load model config
run: |
source config/models.env
echo "EMBEDDING_MODEL=$EMBEDDING_MODEL" >> $FORGEJO_ENV
echo "STYLE_MODEL=$STYLE_MODEL" >> $FORGEJO_ENV
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: Install Dependencies
run: |
go mod download
# Install foundry (cast) for wallet funding
curl -L https://foundry.paradigm.xyz | bash
echo "$HOME/.foundry/bin" >> $FORGEJO_PATH
$HOME/.foundry/bin/foundryup
# Install bc for float math
sudo apt-get update && sudo apt-get install -y bc
- name: Verify Code Formatting
run: |
if [ -n "$(gofmt -l .)" ]; then
echo "Code is not formatted correctly. Run 'gofmt -w .'"
exit 1
fi
- name: Start Test Infrastructure
run: |
docker-compose -f docker-compose.test.yml up -d
- name: Wait for PostgreSQL
run: |
for i in {1..30}; do
if docker-compose -f docker-compose.test.yml exec -T postgres pg_isready -U postgres -d mcpserver >/dev/null 2>&1; then
echo "PostgreSQL is ready"
exit 0
fi
sleep 2
done
echo "PostgreSQL failed to start"
exit 1
- name: Wait for Ollama
run: |
for i in {1..30}; do
if curl -sf http://localhost:11434/api/tags >/dev/null 2>&1; then
echo "Ollama is ready"
exit 0
fi
sleep 2
done
echo "Ollama failed to start"
exit 1
- name: Pull Ollama Models
run: |
docker-compose -f docker-compose.test.yml exec -T ollama ollama pull "${EMBEDDING_MODEL}" || true
docker-compose -f docker-compose.test.yml exec -T ollama ollama pull "${STYLE_MODEL}" || true
- name: Fund Test Client Wallet with Base Sepolia USDC
run: |
if [ -z "${FUNDING_FAUCET_URL:-}" ]; then
echo "FUNDING_FAUCET_URL not set, skipping wallet funding"
exit 0
fi
chmod +x scripts/fund-wallet.sh
./scripts/fund-wallet.sh
- name: Fund Server Wallet with Base Sepolia USDC
run: |
if [ -z "${FUNDING_FAUCET_URL:-}" ]; then
echo "FUNDING_FAUCET_URL not set, skipping server wallet funding"
exit 0
fi
# Fund the server wallet (X402_WALLET_ADDRESS) using the same faucet
# This uses the PRIVATE_KEY from env which corresponds to the test client
# For server wallet, we'd need a separate private key, but for testing we use test client key
echo "Server wallet funding uses same faucet (test client wallet)"
- name: Start Test Server
run: |
chmod +x scripts/start-test-server.sh
./scripts/start-test-server.sh
- name: Build Test Client
run: |
go build -o test-endpoints ./testClient
- name: Run Endpoint Tests
env:
RPC_URL: ${{ env.RPC_URL }}
FUNDING_FAUCET_URL: ${{ secrets.FUNDING_FAUCET_URL }}
run: |
./test-endpoints
- name: Cleanup
if: always()
run: |
if [ -f /tmp/test-server.pid ]; then
kill $(cat /tmp/test-server.pid) 2>/dev/null || true
fi
docker-compose -f docker-compose.test.yml down -v

View file

@ -0,0 +1,36 @@
name: CI/CD Go Verification
on:
push:
branches: [ main, dev, ai-main ]
pull_request:
branches: [ main ]
jobs:
build_and_test:
runs-on: node20
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
cache: true
- name: Install dependencies
run: go mod download
- name: Check code formatting
run: |
if [ -n "$(gofmt -l .)" ]; then
echo "Code is not formatted correctly. Run 'gofmt -w .'"
exit 1
fi
- name: Build Application
run: go build -v ./...
- name: Run Tests (with Mocked Boundaries)
run: go test -v -cover ./...

4
.gitignore vendored
View file

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

View file

@ -11,11 +11,14 @@ import (
_ "github.com/lib/pq" _ "github.com/lib/pq"
log "github.com/nacorid/logger" 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/bluesky"
"github.com/nacorid/naco-api/internal/config" "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/ollama"
"github.com/nacorid/naco-api/internal/server" "github.com/nacorid/naco-api/internal/server"
"github.com/nacorid/naco-api/internal/store" "github.com/nacorid/naco-api/internal/store"
"github.com/nacorid/naco-api/internal/twitter"
) )
func main() { func main() {
@ -36,6 +39,13 @@ func main() {
defer conn.Close() defer conn.Close()
db := store.New(conn) db := store.New(conn)
if cfg.MigrateOnStart {
if err := store.Migrate(ctx, conn, cfg.MigrationsDir); err != nil {
log.Fatalf("Failed to apply migrations: %v", err)
}
log.Info("Database migrations applied", "dir", cfg.MigrationsDir)
}
bskyClient, err := bluesky.NewBlueskyClient(ctx, cfg.BlueskyDID, cfg.BlueskyAppPassword, cfg.BlueskyHost) bskyClient, err := bluesky.NewBlueskyClient(ctx, cfg.BlueskyDID, cfg.BlueskyAppPassword, cfg.BlueskyHost)
if err != nil { if err != nil {
log.Fatal("Failed to create bluesky client", "error", err) log.Fatal("Failed to create bluesky client", "error", err)
@ -45,9 +55,55 @@ func main() {
if err != nil { if err != nil {
log.Fatal("Invalid OLLAMA_HOST URL", "error", err) log.Fatal("Invalid OLLAMA_HOST URL", "error", err)
} }
ollamaClient := ollama.New(ctx, uri, http.DefaultClient) ollamaClient := ollama.New(ctx, uri, http.DefaultClient, cfg.EmbeddingModel, cfg.StyleModel)
srv := server.New(ctx, db, bskyClient, ollamaClient, cfg) apClient, err := activitypub.NewAPClient(ctx, cfg.ActivityPubHost, cfg.ActivityPubToken)
if err != nil {
log.Fatal("Failed to create activitypub client", "error", err)
}
nostrClient, err := nostr.NewNostrClient(cfg.NostrRelays)
if err != nil {
log.Fatal("Failed to create nostr client", "error", err)
}
twitterClient := twitter.NewTwitterClient(cfg.TwitterBearerToken)
srv := server.New(ctx, db, apClient, bskyClient, nostrClient, twitterClient, ollamaClient, cfg)
srv.StartPrewarm(ctx)
go func() {
mux := http.NewServeMux()
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
if err := conn.PingContext(r.Context()); err != nil {
http.Error(w, "database unavailable", http.StatusServiceUnavailable)
return
}
req, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, cfg.OllamaHost+"/api/tags", nil)
resp, err := http.DefaultClient.Do(req)
if err != nil || resp.StatusCode != http.StatusOK {
if resp != nil {
resp.Body.Close()
}
http.Error(w, "ollama unavailable", http.StatusServiceUnavailable)
return
}
resp.Body.Close()
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("GET /metrics", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
w.WriteHeader(http.StatusOK)
w.Write([]byte(srv.MetricsText()))
})
addr := ":" + cfg.ReadyzPort
log.Info("Readiness server starting", "address", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Error("Readiness server error", "error", err)
}
}()
go func() { go func() {
log.Info("Server starting", "address", cfg.ListenAddress) log.Info("Server starting", "address", cfg.ListenAddress)

5
config/models.env Normal file
View 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
View file

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

103
go.mod
View file

@ -3,73 +3,53 @@ module github.com/nacorid/naco-api
go 1.25.4 go 1.25.4
require ( require (
fiatjaf.com/nostr v0.0.0-20260827104008-28e324b57f7e
github.com/bluesky-social/indigo v0.0.0-20251009224519-09f107c1109e 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/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
github.com/mark3labs/mcp-go v0.43.2 github.com/modelcontextprotocol/go-sdk v1.6.1
github.com/mark3labs/x402-go v0.12.1 github.com/nacorid/logger v0.0.0-20251221013547-5fc8fd94ed80
github.com/nacorid/logger v0.0.0-20251221003040-586da7a03fa0
github.com/ollama/ollama v0.12.10 github.com/ollama/ollama v0.12.10
github.com/x402-foundation/x402/go/v2 v2.19.0
golang.org/x/time v0.14.0 golang.org/x/time v0.14.0
) )
require ( require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e // indirect
github.com/FactomProject/btcutilecc v0.0.0-20130527213604-d3a63a5752ec // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/benbjohnson/clock v1.3.5 // indirect
github.com/bits-and-blooms/bitset v1.24.2 // indirect github.com/bits-and-blooms/bitset v1.24.2 // indirect
github.com/blendle/zapdriver v1.3.1 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/consensys/gnark-crypto v0.19.2 // indirect github.com/consensys/gnark-crypto v0.19.2 // indirect
github.com/crate-crypto/go-eth-kzg v1.4.0 // indirect github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/deckarep/golang-set/v2 v2.8.0 // indirect github.com/deckarep/golang-set/v2 v2.8.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect
github.com/ethereum/go-ethereum v1.16.5 // indirect github.com/ethereum/go-ethereum v1.17.2
github.com/ethereum/go-verkle v0.2.2 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gagliardetto/binary v0.8.0 // indirect
github.com/gagliardetto/solana-go v1.14.0 // indirect
github.com/gagliardetto/treeout v0.1.4 // indirect
github.com/holiman/uint256 v1.3.2 // indirect github.com/holiman/uint256 v1.3.2 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.1 // indirect github.com/klauspost/compress v1.18.1 // indirect
github.com/logrusorgru/aurora v2.0.3+incompatible // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/streamingfast/logging v0.0.0-20250918142248-ac5a1e292845 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/supranational/blst v0.3.16 // indirect github.com/supranational/blst v0.3.16 // indirect
github.com/tyler-smith/go-bip32 v1.0.0 // indirect
github.com/tyler-smith/go-bip39 v1.1.0 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.mongodb.org/mongo-driver v1.17.6 // indirect golang.org/x/sync v0.19.0 // indirect
go.uber.org/ratelimit v0.3.1 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/term v0.36.0 // indirect
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )
require ( 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/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/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.1 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.5 // indirect github.com/hashicorp/go-retryablehttp v0.7.5 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect
@ -86,9 +66,14 @@ require (
github.com/ipfs/go-log/v2 v2.5.1 // indirect github.com/ipfs/go-log/v2 v2.5.1 // indirect
github.com/ipfs/go-metrics-interface v0.0.1 // indirect github.com/ipfs/go-metrics-interface v0.0.1 // indirect
github.com/jbenet/goprocess v0.1.4 // 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/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/mattn/go-isatty v0.0.20 // indirect
github.com/minio/sha256-simd v1.0.1 // 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/mr-tron/base58 v1.2.0 // indirect
github.com/multiformats/go-base32 v0.1.0 // indirect github.com/multiformats/go-base32 v0.1.0 // indirect
github.com/multiformats/go-base36 v0.2.0 // indirect github.com/multiformats/go-base36 v0.2.0 // indirect
@ -97,20 +82,38 @@ require (
github.com/multiformats/go-varint v0.0.7 // indirect github.com/multiformats/go-varint v0.0.7 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // 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/sokkalf/slog-seq v0.5.1 // indirect
github.com/spaolacci/murmur3 v1.1.0 // 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 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect go.opentelemetry.io/otel v1.40.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/otel/sdk v1.35.0 // indirect go.opentelemetry.io/otel/sdk v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect go.opentelemetry.io/otel/trace v1.40.0 // indirect
go.uber.org/atomic v1.11.0 // indirect go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect go.uber.org/zap v1.27.0 // indirect
golang.org/x/crypto v0.43.0 // indirect golang.org/x/crypto v0.46.0 // indirect
golang.org/x/sys v0.37.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
lukechampine.com/blake3 v1.2.1 // indirect lukechampine.com/blake3 v1.2.1 // indirect
) )

394
go.sum
View file

@ -1,80 +1,113 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= fiatjaf.com/nostr v0.0.0-20260827104008-28e324b57f7e h1:BuWR1BP/CBzM+ZbLoUKuV6KlktlDZEyD9N/pdxqJJyE=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= fiatjaf.com/nostr v0.0.0-20260827104008-28e324b57f7e/go.mod h1:b1EIUDnd133Ie8Pg8O/biaKdFyCMz28aD4n64g1GqvM=
github.com/AlekSi/pointer v1.1.0 h1:SSDMPcXD9jSl8FPy9cRzoRaMJtm9g9ggGTxecRUbQoI=
github.com/AlekSi/pointer v1.1.0/go.mod h1:y7BvfRI3wXPWKXEBhU71nbnIEEZX0QTSB2Bj48UJIZE=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e h1:ahyvB3q25YnZWly5Gq1ekg6jcmWaGj/vG/MhF4aisoc= github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e/go.mod h1:kGUqhHd//musdITWjFvNTHn90WG9bMLBEPQZ17Cmlpw= github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
github.com/FactomProject/btcutilecc v0.0.0-20130527213604-d3a63a5752ec h1:1Qb69mGp/UtRPn422BH4/Y4Q3SLUrD9KHuDkm8iodFc= github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 h1:ClzzXMDDuUbWfNNZqGeYq4PnYOlwlOVIvSyNaIy0ykg=
github.com/FactomProject/btcutilecc v0.0.0-20130527213604-d3a63a5752ec/go.mod h1:CD8UlnlLDiqb36L110uqiP2iSflVjx9g/3U9hCI4q2U= github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3/go.mod h1:we0YA5CsBbH5+/NUzC/AlMmxaDtWlXeNsqrwXjTzmzA=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA=
github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bits-and-blooms/bitset v1.24.2 h1:M7/NzVbsytmtfHbumG+K2bremQPMJuqv1JD3vOaFxp0= github.com/bits-and-blooms/bitset v1.24.2 h1:M7/NzVbsytmtfHbumG+K2bremQPMJuqv1JD3vOaFxp0=
github.com/bits-and-blooms/bitset v1.24.2/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bits-and-blooms/bitset v1.24.2/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/blendle/zapdriver v1.3.1 h1:C3dydBOWYRiOk+B8X9IVZ5IOe+7cl+tGOexN4QqHfpE=
github.com/blendle/zapdriver v1.3.1/go.mod h1:mdXfREi6u5MArG4j9fewC+FGnXaBR+T4Ox4J2u4eHCc=
github.com/bluesky-social/indigo v0.0.0-20251009224519-09f107c1109e h1:6oNYIMh+kbArCQiwZYPGgfxQq88tnMyiBIZtg5clq90= github.com/bluesky-social/indigo v0.0.0-20251009224519-09f107c1109e h1:6oNYIMh+kbArCQiwZYPGgfxQq88tnMyiBIZtg5clq90=
github.com/bluesky-social/indigo v0.0.0-20251009224519-09f107c1109e/go.mod h1:RuQVrCGm42QNsgumKaR6se+XkFKfCPNwdCiTvqKRUck= github.com/bluesky-social/indigo v0.0.0-20251009224519-09f107c1109e/go.mod h1:RuQVrCGm42QNsgumKaR6se+XkFKfCPNwdCiTvqKRUck=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= 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 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc=
github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8= github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8=
github.com/cespare/cp v0.1.0 h1:SE+dxFebS7Iik5LK0tsi1k9ZCxEaFX4AjQmoyA+1dJk= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/cp v0.1.0/go.mod h1:SOGHArjBr4JWaSDEVpWpo/hNg6RoKrls6Oh40hiwW+s= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cmars/basen v0.0.0-20150613233007-fe3947df716e h1:0XBUw73chJ1VYSsfvcPvVT7auykAJce9FpRr10L6Qhw= github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I=
github.com/cmars/basen v0.0.0-20150613233007-fe3947df716e/go.mod h1:P13beTBKr5Q18lJe1rIoLUqjM+CB1zYrRg44ZqGuQSA= github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8=
github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce h1:giXvy4KSc/6g/esnpM7Geqxka4WSqI1SZc7sMJFd3y4=
github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M=
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE=
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs=
github.com/cockroachdb/pebble v1.1.5 h1:5AAWCBWbat0uE0blr8qzufZP5tBjkRyy/jWe1QWLnvw=
github.com/cockroachdb/pebble v1.1.5/go.mod h1:17wO9el1YEigxkP/YtV8NtCivQDgoCyBg5c4VR/eOWo=
github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30=
github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo=
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ=
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/consensys/gnark-crypto v0.19.2 h1:qrEAIXq3T4egxqiliFFoNrepkIWVEeIYwt3UL0fvS80= github.com/consensys/gnark-crypto v0.19.2 h1:qrEAIXq3T4egxqiliFFoNrepkIWVEeIYwt3UL0fvS80=
github.com/consensys/gnark-crypto v0.19.2/go.mod h1:rT23F0XSZqE0mUA0+pRtnL56IbPxs6gp4CeRsBk4XS0= github.com/consensys/gnark-crypto v0.19.2/go.mod h1:rT23F0XSZqE0mUA0+pRtnL56IbPxs6gp4CeRsBk4XS0=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/crate-crypto/go-eth-kzg v1.4.0 h1:WzDGjHk4gFg6YzV0rJOAsTK4z3Qkz5jd4RE3DAvPFkg= github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
github.com/crate-crypto/go-eth-kzg v1.4.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI= github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a h1:W8mUrRp6NOVl3J+MYp5kPMoUZPp7aOYHtaua31lwRHg= github.com/crate-crypto/go-eth-kzg v1.5.0 h1:FYRiJMJG2iv+2Dy3fi14SVGjcPteZ5HAAUe4YWlJygc=
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a/go.mod h1:sTwzHBvIzm2RfVCGNEBZgRyjwK40bVoun3ZnGOCafNM= 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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dchest/siphash v1.2.3 h1:QXwFc8cFOR2dSa/gE6o/HokBMWtLUaNDVd+22aKHeEA=
github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc=
github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ= github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ=
github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= 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 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= 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 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= 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 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A=
github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s=
github.com/ethereum/c-kzg-4844/v2 v2.1.5 h1:aVtoLK5xwJ6c5RiqO8g8ptJ5KU+2Hdquf6G3aXiHh5s= github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn27fRjSls=
github.com/ethereum/c-kzg-4844/v2 v2.1.5/go.mod h1:u59hRTTah4Co6i9fDWtiCjTrblJv0UwsqZKCc0GfgUs= github.com/ethereum/c-kzg-4844/v2 v2.1.6/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw=
github.com/ethereum/go-ethereum v1.16.5 h1:GZI995PZkzP7ySCxEFaOPzS8+bd8NldE//1qvQDQpe0= github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk=
github.com/ethereum/go-ethereum v1.16.5/go.mod h1:kId9vOtlYg3PZk9VwKbGlQmSACB5ESPTBGT+M9zjmok= github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8=
github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8= github.com/ethereum/go-ethereum v1.17.2 h1:ag6geu0kn8Hv5FLKTpH+Hm2DHD+iuFtuqKxEuwUsDOI=
github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk= github.com/ethereum/go-ethereum v1.17.2/go.mod h1:KHcRXfGOUfUmKg51IhQ0IowiqZ6PqZf08CMtk0g5K1o=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 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 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY=
github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg= github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
github.com/gagliardetto/binary v0.8.0 h1:U9ahc45v9HW0d15LoN++vIXSJyqR/pWw8DDlhd7zvxg=
github.com/gagliardetto/binary v0.8.0/go.mod h1:2tfj51g5o9dnvsc+fL3Jxr22MuWzYXwx9wEoN0XQ7/c=
github.com/gagliardetto/gofuzz v1.2.2 h1:XL/8qDMzcgvR4+CyRQW9UGdwPRPMHVJfqQ/uMvSUuQw=
github.com/gagliardetto/gofuzz v1.2.2/go.mod h1:bkH/3hYLZrMLbfYWA0pWzXmi5TTRZnu4pMGZBkqMKvY=
github.com/gagliardetto/solana-go v1.14.0 h1:3WfAi70jOOjAJ0deFMjdhFYlLXATF4tOQXsDNWJtOLw=
github.com/gagliardetto/solana-go v1.14.0/go.mod h1:l/qqqIN6qJJPtxW/G1PF4JtcE3Zg2vD2EliZrr9Gn5k=
github.com/gagliardetto/treeout v0.1.4 h1:ozeYerrLCmCubo1TcIjFiOWTTGteOOHND1twdFpgwaw=
github.com/gagliardetto/treeout v0.1.4/go.mod h1:loUefvXTrlRG5rYmJmExNryyBRh8f89VZhmMOyCyqok=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
@ -82,16 +115,45 @@ github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb1T8ac=
github.com/grafana/pyroscope-go v1.2.7/go.mod h1:o/bpSLiJYYP6HQtvcoVKiE9s5RiNgjYTj1DhiddP2Pc=
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og=
github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU=
github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE=
github.com/hashicorp/go-bexpr v0.1.10/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI=
@ -100,10 +162,15 @@ github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT
github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db h1:IZUYC/xb3giYwBLMnr8d0TGTzPKFGNTCGgGLoyeX330=
github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db/go.mod h1:xTEYN9KCHxuYHs+NmrmzFcnvHMzLLNiGFafCb1n3Mfg=
github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZKVu0fao=
github.com/holiman/bloomfilter/v2 v2.0.3/go.mod h1:zpoh+gs7qcpqrHr3dB55AMiJwo0iURXE7ZOP9L9hSkA=
github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA= github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA=
github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0= github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs= github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs=
@ -131,24 +198,30 @@ github.com/ipfs/go-log/v2 v2.5.1 h1:1XdUzF7048prq4aBjDQQ4SL5RxftpRGdXhNRwKSAlcY=
github.com/ipfs/go-log/v2 v2.5.1/go.mod h1:prSpmC1Gpllc9UYWxDiZDreBYw7zp4Iqp1kOLU9U5UI= github.com/ipfs/go-log/v2 v2.5.1/go.mod h1:prSpmC1Gpllc9UYWxDiZDreBYw7zp4Iqp1kOLU9U5UI=
github.com/ipfs/go-metrics-interface v0.0.1 h1:j+cpbjYvu4R8zbleSs36gvB7jR+wsL2fGD6n0jO4kdg= github.com/ipfs/go-metrics-interface v0.0.1 h1:j+cpbjYvu4R8zbleSs36gvB7jR+wsL2fGD6n0jO4kdg=
github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j/b/tL7HTWtJ4VPgWY= github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j/b/tL7HTWtJ4VPgWY=
github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus=
github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc=
github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5Djy1+FlA= github.com/jbenet/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 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o=
github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4= 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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 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 h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 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/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
@ -161,34 +234,29 @@ github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzW
github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c= github.com/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 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I=
github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
github.com/mark3labs/x402-go v0.12.1 h1:yWlT/mjF0P1j2sB0xAM4GJb+1/Ycv6p6EqUrT6RB2X0=
github.com/mark3labs/x402-go v0.12.1/go.mod h1:srAvV9FosjBiqrclF15thrQbz0fVVfNXtMcqD0e1hKU=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 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-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU=
github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8=
github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag=
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A=
github.com/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4=
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 h1:mPMvm6X6tf4w8y7j9YIt6V9jfWhL6QlbEc7CCmeQlWk=
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1/go.mod h1:ye2e/VUEtE2BHE+G/QcKkcLQVAEJoYRFj5VUOQatCRE=
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 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= github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE=
@ -201,31 +269,62 @@ github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7B
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= github.com/multiformats/go-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 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU=
github.com/nacorid/logger v0.0.0-20251221003040-586da7a03fa0 h1:vKVU0JbEOiG7xI+UNwb8M72mo98uHCiPNyPUd9oBC9c= github.com/nacorid/logger v0.0.0-20251221013547-5fc8fd94ed80 h1:nZwGInvJNQzPSFYpstVR/TLumqBEZt0+yUXchdyBZiI=
github.com/nacorid/logger v0.0.0-20251221003040-586da7a03fa0/go.mod h1:hd9h4KEPU1f/Vv2qP0x/fZUwz2clFvOV/uIF0pkGo+s= github.com/nacorid/logger v0.0.0-20251221013547-5fc8fd94ed80/go.mod h1:hd9h4KEPU1f/Vv2qP0x/fZUwz2clFvOV/uIF0pkGo+s=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/ollama/ollama v0.12.10 h1:Dd0/SeCc+nv+FffxmWuQTGiRreib7Gt3nBhIIFuKwZA= 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/ollama/ollama v0.12.10/go.mod h1:RUSmYywUWx/YZMaHrqtnT1ZChu+iSz/7jx2aO9+Mgfg=
github.com/onsi/gomega v1.10.1 h1:o0+MgICZLuZ7xjH7Vx6zS/zcu93/BEp1VwkIW1mEXCE= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/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 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8=
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
github.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0=
github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ=
github.com/pion/transport/v2 v2.2.1 h1:7qYnCBlpgSJNYMbLCKuSY9KbQdBFoETvPNETv0y4N7c=
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouANiM=
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0=
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY=
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM=
github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY=
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU= github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU=
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs= github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs=
github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
@ -235,128 +334,150 @@ github.com/sokkalf/slog-seq v0.5.1 h1:4LicZGsMhuCtmZkbS++JOm3Wn8PLIrjFkuvEp0yeGH
github.com/sokkalf/slog-seq v0.5.1/go.mod h1:B82pc/cMpdQQg6hkBbstHEL4vqI1eZ1MISuN1IK7h14= github.com/sokkalf/slog-seq v0.5.1/go.mod h1:B82pc/cMpdQQg6hkBbstHEL4vqI1eZ1MISuN1IK7h14=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091/go.mod h1:VlduQ80JcGJSargkRU4Sg9Xo63wZD/l8A5NC/Uo1/uU=
github.com/streamingfast/logging v0.0.0-20250918142248-ac5a1e292845 h1:VMA0pZ3MI8BErRA3kh8dKJThP5d0Xh5vZVk5yFIgH/A=
github.com/streamingfast/logging v0.0.0-20250918142248-ac5a1e292845/go.mod h1:BtDq81Tyc7H8up5aXNi/I95nPmG3C0PLEqGWY/iWQ2E=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.1.5-0.20170601210322-f6abca593680/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 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.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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jqwYhE= github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jqwYhE=
github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw=
github.com/test-go/testify v1.1.4 h1:Tf9lntrKUMHiXQ07qBScBTSA0dhYQlu83hswqelv1iE= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY=
github.com/test-go/testify v1.1.4/go.mod h1:rH7cfJo/47vWGdi4GPj16x3/t1xGOj2YxzmNQzk2ghU= 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 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/tyler-smith/go-bip32 v1.0.0 h1:sDR9juArbUgX+bO/iblgZnMPeWY1KZMUC2AFUJdv5KE= github.com/urfave/cli v1.22.10 h1:p8Fspmz3iTctJstry1PYS3HVdllxnEzTEsgIgtxTrCk=
github.com/tyler-smith/go-bip32 v1.0.0/go.mod h1:onot+eHknzV4BVPwrzqY5OoVpyCvnwD7lMawL5aQupE=
github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8=
github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U=
github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ= github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ=
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4= github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4=
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/x402-foundation/x402/go/v2 v2.19.0 h1:vyU9cKFfqw8oI6To66r+UH67vFwWpoMDRX9zGM8bCJI=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/x402-foundation/x402/go/v2 v2.19.0/go.mod h1:OAsOuB+FYhCoKzijOGZ+PcJk9g4RBfCJvpABGxi+NcE=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/ratelimit v0.3.1 h1:K4qVE+byfv/B3tC+4nYWP7v/6SimcO7HzHekoMNBma0=
go.uber.org/ratelimit v0.3.1/go.mod h1:6euWsTB6U/Nb3X++xEUXA8ciPJvr19Q/0h1+oDcJhRk=
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= go.uber.org/zap v1.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.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20170613210332-850760c427c5/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-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-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.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/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-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-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-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-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-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= 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-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-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-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.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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-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-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-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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= 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.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -369,21 +490,36 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-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 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= 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.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.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 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 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= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@ -391,7 +527,5 @@ gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
launchpad.net/gocheck v0.0.0-20140225173054-000000000087 h1:Izowp2XBH6Ya6rv+hqbceQyw/gSGoXfH/UPoTGduL54=
launchpad.net/gocheck v0.0.0-20140225173054-000000000087/go.mod h1:hj7XX3B/0A+80Vse0e+BUHsHMTEhd0O4cpUHr/e/BUM=
lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI= lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI=
lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k=

View file

@ -2,8 +2,14 @@ package activitypub
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/url"
"time"
"github.com/nacorid/naco-api/internal/actor"
) )
type APClient struct { type APClient struct {
@ -22,3 +28,217 @@ func NewAPClient(ctx context.Context, host, apikey string) (*APClient, error) {
bearerToken: fmt.Sprintf("Bearer %s", apikey), bearerToken: fmt.Sprintf("Bearer %s", apikey),
}, nil }, nil
} }
func (ap *APClient) GetPost(ctx context.Context, postID string) (*Post, error) {
url := fmt.Sprintf("%s/api/v1/statuses/%s", ap.host, postID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", ap.bearerToken)
resp, err := ap.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to get post: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to get post: %s", resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
var internal = &internalPost{}
if err = json.Unmarshal(body, internal); err != nil {
return nil, fmt.Errorf("failed to unmarshal json response: %w", err)
}
return internal.toPost(), nil
}
func (ap *APClient) GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*Post, error) {
url := fmt.Sprintf("%s/api/v1/accounts/%s/statuses?limit=%d", ap.host, actor, limit)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", ap.bearerToken)
resp, err := ap.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to get latest posts: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to get latest posts: %s", resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
var internal []*internalPost
if err = json.Unmarshal(body, &internal); err != nil {
return nil, fmt.Errorf("failed to unmarshal json response: %w", err)
}
posts := make([]*Post, len(internal))
for i, p := range internal {
posts[i] = p.toPost()
}
return posts, nil
}
func (ap *APClient) GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]Post, error) {
var posts []Post
var maxID string
for {
if ctx.Err() != nil {
return nil, ctx.Err()
}
if len(posts) >= limit {
break
}
url := fmt.Sprintf("%s/api/v1/accounts/%s/statuses?limit=50", ap.host, actor)
if maxID != "" {
url += fmt.Sprintf("&max_id=%s", maxID)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", ap.bearerToken)
resp, err := ap.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to get posts: %w", err)
}
// !!IMPORTANT!! Manually close body instead of deferring, due to loop
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("failed to get posts: %s", resp.Status)
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
var internal []*internalPost
if err := json.Unmarshal(body, &internal); err != nil {
return nil, fmt.Errorf("failed to unmarshal response body: %w", err)
}
if len(internal) == 0 {
break
}
for _, i := range internal {
if i.CreatedAt.After(end) {
continue
}
if i.CreatedAt.Before(start) {
return posts, nil
}
posts = append(posts, *i.toPost())
if len(posts) >= limit {
return posts, nil
}
}
maxID = internal[len(internal)-1].ID
}
return posts, nil
}
func (ap *APClient) GetActor(ctx context.Context, actor string) (*actor.Profile, error) {
// Mastodon exposes actor lookup by acct; fall back to a direct id lookup.
prof, err := ap.fetchAccount(ctx, fmt.Sprintf("%s/api/v1/accounts/lookup?acct=%s", ap.host, url.QueryEscape(actor)))
if err != nil {
prof, err = ap.fetchAccount(ctx, fmt.Sprintf("%s/api/v1/accounts/%s", ap.host, url.PathEscape(actor)))
if err != nil {
return nil, err
}
}
return prof, nil
}
func (ap *APClient) fetchAccount(ctx context.Context, endpoint string) (*actor.Profile, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", ap.bearerToken)
resp, err := ap.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to get actor: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to get actor: %s", resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
var acc Account
if err := json.Unmarshal(body, &acc); err != nil {
return nil, fmt.Errorf("failed to unmarshal account: %w", err)
}
return &actor.Profile{
Platform: "ActivityPub",
Handle: acc.Acct,
DisplayName: acc.DisplayName,
AvatarURL: acc.Avatar,
Bio: acc.Note,
FollowersCount: acc.FollowersCount,
FollowingCount: acc.FollowingCount,
PostCount: acc.StatusesCount,
URL: acc.URL,
}, nil
}
func (ap *APClient) SearchActors(ctx context.Context, query string, limit int) ([]actor.Profile, error) {
endpoint := fmt.Sprintf("%s/api/v1/accounts/search?q=%s&limit=%d", ap.host, url.QueryEscape(query), limit)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", ap.bearerToken)
resp, err := ap.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to search actors: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to search actors: %s", resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
var accounts []Account
if err := json.Unmarshal(body, &accounts); err != nil {
return nil, fmt.Errorf("failed to unmarshal accounts: %w", err)
}
results := make([]actor.Profile, 0, len(accounts))
for _, acc := range accounts {
results = append(results, actor.Profile{
Platform: "ActivityPub",
Handle: acc.Acct,
DisplayName: acc.DisplayName,
AvatarURL: acc.Avatar,
Bio: acc.Note,
FollowersCount: acc.FollowersCount,
FollowingCount: acc.FollowingCount,
PostCount: acc.StatusesCount,
URL: acc.URL,
})
}
return results, nil
}

View file

@ -0,0 +1,220 @@
package activitypub
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func newTestClient(serverURL string) *APClient {
ap, err := NewAPClient(context.Background(), "https://example.com", "tok")
if err != nil {
panic(err)
}
host := strings.TrimPrefix(serverURL, "http://")
ap.client = &http.Client{
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
u := *r.URL
u.Scheme = "http"
u.Host = host
r2 := r.Clone(r.Context())
r2.URL = &u
return http.DefaultTransport.RoundTrip(r2)
}),
}
return ap
}
func TestNewAPClientEmpty(t *testing.T) {
if _, err := NewAPClient(context.Background(), "", "tok"); err == nil {
t.Fatal("expected error for empty host")
}
if _, err := NewAPClient(context.Background(), "host", ""); err == nil {
t.Fatal("expected error for empty apikey")
}
}
func TestGetLatestNPosts(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/accounts/alice/statuses", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer tok" {
w.WriteHeader(http.StatusUnauthorized)
return
}
fmt.Fprint(w, `[{"id":"1","content":"hello world","text":"hello world","created_at":"2024-01-01T00:00:00Z","account":{"acct":"alice"}}]`)
})
server := httptest.NewServer(mux)
defer server.Close()
ap := newTestClient(server.URL)
posts, err := ap.GetLatestNPosts(context.Background(), "alice", 10)
if err != nil {
t.Fatalf("GetLatestNPosts error: %v", err)
}
if len(posts) != 1 {
t.Fatalf("expected 1 post, got %d", len(posts))
}
if posts[0].Content != "hello world" || posts[0].Author != "alice" {
t.Fatalf("unexpected post: %+v", posts[0])
}
}
func TestGetPost(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/statuses/42", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"id":"42","content":"a post","text":"a post","created_at":"2024-01-01T00:00:00Z","account":{"acct":"bob"}}`)
})
server := httptest.NewServer(mux)
defer server.Close()
ap := newTestClient(server.URL)
post, err := ap.GetPost(context.Background(), "42")
if err != nil {
t.Fatalf("GetPost error: %v", err)
}
if post.Content != "a post" || post.Author != "bob" {
t.Fatalf("unexpected post: %+v", post)
}
}
func TestGetLatestNPostsHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
ap := newTestClient(server.URL)
if _, err := ap.GetLatestNPosts(context.Background(), "alice", 10); err == nil {
t.Fatal("expected error on non-200 status")
}
}
func TestGetPostsInTimeframe(t *testing.T) {
// Feed is reverse-chronological: newer than `end`, two in-window, then older
// than `start`. The client must skip the too-new post, return both in-window
// posts, and stop when it crosses the `start` boundary.
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/accounts/alice/statuses", func(w http.ResponseWriter, r *http.Request) {
feed := []map[string]any{}
switch r.URL.Query().Get("max_id") {
case "":
// Page 1: too-new post and one in-window post.
feed = []map[string]any{
{"id": "5", "content": "too-new", "text": "too-new", "created_at": "2025-03-01T00:00:00Z", "account": map[string]any{"acct": "alice"}},
{"id": "4", "content": "mid", "text": "mid", "created_at": "2024-06-01T00:00:00Z", "account": map[string]any{"acct": "alice"}},
}
case "4":
// Page 2: in-window post then too-old post.
feed = []map[string]any{
{"id": "3", "content": "early-mid", "text": "early-mid", "created_at": "2024-03-01T00:00:00Z", "account": map[string]any{"acct": "alice"}},
{"id": "2", "content": "too-old", "text": "too-old", "created_at": "2023-01-01T00:00:00Z", "account": map[string]any{"acct": "alice"}},
}
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(feed); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
})
server := httptest.NewServer(mux)
defer server.Close()
ap := newTestClient(server.URL)
start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC)
posts, err := ap.GetPostsInTimeframe(context.Background(), "alice", start, end, 5)
if err != nil {
t.Fatalf("GetPostsInTimeframe error: %v", err)
}
if len(posts) != 2 {
t.Fatalf("expected 2 in-window posts, got %d", len(posts))
}
if posts[0].Content != "mid" || posts[1].Content != "early-mid" {
t.Fatalf("unexpected posts: %+v", posts)
}
}
func TestGetActor(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/accounts/lookup", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("acct") != "alice" {
w.WriteHeader(http.StatusBadRequest)
return
}
fmt.Fprint(w, `{"id":"1","acct":"alice@inst.social","display_name":"Alice","note":"hello there","avatar":"https://img/avatar.png","followers_count":10,"following_count":5,"statuses_count":3,"url":"https://inst.social/@alice"}`)
})
server := httptest.NewServer(mux)
defer server.Close()
ap := newTestClient(server.URL)
prof, err := ap.GetActor(context.Background(), "alice")
if err != nil {
t.Fatalf("GetActor error: %v", err)
}
if prof.Platform != "ActivityPub" || prof.Handle != "alice@inst.social" || prof.DisplayName != "Alice" {
t.Fatalf("unexpected profile: %+v", prof)
}
if prof.Bio != "hello there" || prof.AvatarURL != "https://img/avatar.png" {
t.Fatalf("unexpected profile fields: %+v", prof)
}
if prof.FollowersCount != 10 || prof.FollowingCount != 5 || prof.PostCount != 3 {
t.Fatalf("unexpected counts: %+v", prof)
}
if prof.URL != "https://inst.social/@alice" {
t.Fatalf("unexpected url: %q", prof.URL)
}
}
func TestGetActorIDFallback(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/accounts/lookup", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
mux.HandleFunc("/api/v1/accounts/alice", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"id":"1","acct":"alice@inst.social","display_name":"Alice","note":"hi","avatar":"https://img/a.png","followers_count":1,"following_count":2,"statuses_count":3,"url":"https://x"}`)
})
server := httptest.NewServer(mux)
defer server.Close()
ap := newTestClient(server.URL)
prof, err := ap.GetActor(context.Background(), "alice")
if err != nil {
t.Fatalf("GetActor error: %v", err)
}
if prof.Handle != "alice@inst.social" {
t.Fatalf("expected fallback id lookup to succeed, got %+v", prof)
}
}
func TestSearchActors(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("q") != "alice" {
w.WriteHeader(http.StatusBadRequest)
return
}
fmt.Fprint(w, `[{"id":"1","acct":"alice@inst.social","display_name":"Alice","note":"bio","avatar":"https://img/a.png","followers_count":1,"following_count":2,"statuses_count":3,"url":"https://x"}]`)
})
server := httptest.NewServer(mux)
defer server.Close()
ap := newTestClient(server.URL)
results, err := ap.SearchActors(context.Background(), "alice", 10)
if err != nil {
t.Fatalf("SearchActors error: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
if results[0].Platform != "ActivityPub" || results[0].Handle != "alice@inst.social" {
t.Fatalf("unexpected result: %+v", results[0])
}
}

View file

@ -0,0 +1,272 @@
package activitypub
import "time"
type Post struct {
ID string `json:"id"`
Author string `json:"author"`
Content string `json:"content"`
Text string `json:"text"`
CreatedAt time.Time `json:"created_at"`
FavouritesCount int `json:"favourites_count"`
ReblogsCount int `json:"reblogs_count"`
RepliesCount int `json:"replies_count"`
}
func (i *internalPost) toPost() *Post {
return &Post{
ID: i.ID,
Author: i.Account.Acct,
Content: i.Content,
Text: i.Text,
CreatedAt: i.CreatedAt,
FavouritesCount: i.FavouritesCount,
ReblogsCount: i.ReblogsCount,
RepliesCount: i.RepliesCount,
}
}
type internalPost struct {
Account Account `json:"account"`
Application Application `json:"application"`
Bookmarked bool `json:"bookmarked"`
Card Card `json:"card"`
Content string `json:"content"`
ContentType string `json:"content_type"`
CreatedAt time.Time `json:"created_at"`
EditedAt time.Time `json:"edited_at"`
Emojis []Emoji `json:"emojis"`
Favourited bool `json:"favourited"`
FavouritesCount int `json:"favourites_count"`
Filtered []FilterResult `json:"filtered"`
ID string `json:"id"`
InReplyToAccountID string `json:"in_reply_to_account_id"`
InReplyToID string `json:"in_reply_to_id"`
InteractionPolicy InteractionPolicy `json:"interaction_policy"`
Language string `json:"language"`
LocalOnly bool `json:"local_only"`
MediaAttachments []MediaAttachment `json:"media_attachments"`
Mentions []Mention `json:"mentions"`
Muted bool `json:"muted"`
Pinned bool `json:"pinned"`
Poll Poll `json:"poll"`
Reblog *internalPost `json:"reblog"` // Pointer used for recursive structure
Reblogged bool `json:"reblogged"`
ReblogsCount int `json:"reblogs_count"`
RepliesCount int `json:"replies_count"`
Sensitive bool `json:"sensitive"`
SpoilerText string `json:"spoiler_text"`
Tags []Tag `json:"tags"`
Text string `json:"text"`
URI string `json:"uri"`
URL string `json:"url"`
Visibility string `json:"visibility"`
}
type Account struct {
Acct string `json:"acct"`
Avatar string `json:"avatar"`
AvatarDescription string `json:"avatar_description"`
AvatarMediaID string `json:"avatar_media_id"`
AvatarStatic string `json:"avatar_static"`
Bot bool `json:"bot"`
CreatedAt string `json:"created_at"`
CustomCSS string `json:"custom_css"`
Discoverable bool `json:"discoverable"`
DisplayName string `json:"display_name"`
Emojis []Emoji `json:"emojis"`
EnableRSS bool `json:"enable_rss"`
Fields []Field `json:"fields"`
FollowersCount int `json:"followers_count"`
FollowingCount int `json:"following_count"`
Group bool `json:"group"`
Header string `json:"header"`
HeaderDescription string `json:"header_description"`
HeaderMediaID string `json:"header_media_id"`
HeaderStatic string `json:"header_static"`
HideCollections bool `json:"hide_collections"`
ID string `json:"id"`
LastStatusAt string `json:"last_status_at"`
Locked bool `json:"locked"`
Moved string `json:"moved"`
Note string `json:"note"`
Role Role `json:"role"`
Roles []Role `json:"roles"`
Source Source `json:"source"`
StatusesCount int `json:"statuses_count"`
Suspended bool `json:"suspended"`
Theme string `json:"theme"`
URL string `json:"url"`
Username string `json:"username"`
}
type Application struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
CreatedAt string `json:"created_at"`
ID string `json:"id"`
Name string `json:"name"`
RedirectURI string `json:"redirect_uri"`
RedirectURIs []string `json:"redirect_uris"`
Scopes []string `json:"scopes"`
VapidKey string `json:"vapid_key"`
Website string `json:"website"`
}
type Card struct {
AuthorName string `json:"author_name"`
AuthorURL string `json:"author_url"`
Blurhash string `json:"blurhash"`
Description string `json:"description"`
EmbedURL string `json:"embed_url"`
Height int `json:"height"`
HTML string `json:"html"`
Image string `json:"image"`
ProviderName string `json:"provider_name"`
ProviderURL string `json:"provider_url"`
Title string `json:"title"`
Type string `json:"type"`
URL string `json:"url"`
Width int `json:"width"`
}
type Emoji struct {
Category string `json:"category"`
Shortcode string `json:"shortcode"`
StaticURL string `json:"static_url"`
URL string `json:"url"`
VisibleInPicker bool `json:"visible_in_picker"`
}
type Field struct {
Name string `json:"name"`
Value string `json:"value"`
VerifiedAt string `json:"verified_at"`
}
type FilterResult struct {
Filter FilterInfo `json:"filter"`
KeywordMatches []string `json:"keyword_matches"`
StatusMatches []string `json:"status_matches"`
}
type FilterInfo struct {
Context []string `json:"context"`
ExpiresAt string `json:"expires_at"`
FilterAction string `json:"filter_action"`
ID string `json:"id"`
Keywords []FilterKeyword `json:"keywords"`
Statuses []FilterStatus `json:"statuses"`
Title string `json:"title"`
}
type FilterKeyword struct {
ID string `json:"id"`
Keyword string `json:"keyword"`
WholeWord bool `json:"whole_word"`
}
type FilterStatus struct {
ID string `json:"id"`
Phrase string `json:"phrase"`
}
type InteractionPolicy struct {
CanFavourite PolicyDetail `json:"can_favourite"`
CanReblog PolicyDetail `json:"can_reblog"`
CanReply PolicyDetail `json:"can_reply"`
}
type PolicyDetail struct {
Always []string `json:"always"`
AutomaticApproval []string `json:"automatic_approval"`
ManualApproval []string `json:"manual_approval"`
WithApproval []string `json:"with_approval"`
}
type MediaAttachment struct {
Blurhash string `json:"blurhash"`
Description string `json:"description"`
ID string `json:"id"`
Meta AttachmentMeta `json:"meta"`
PreviewRemoteURL string `json:"preview_remote_url"`
PreviewURL string `json:"preview_url"`
RemoteURL string `json:"remote_url"`
TextURL string `json:"text_url"`
Type string `json:"type"`
URL string `json:"url"`
}
type AttachmentMeta struct {
Focus MetaFocus `json:"focus"`
Original MetaDetail `json:"original"`
Small MetaDetail `json:"small"`
}
type MetaFocus struct {
X float64 `json:"x"`
Y float64 `json:"y"`
}
type MetaDetail struct {
Aspect float64 `json:"aspect"`
Bitrate int `json:"bitrate"`
Duration float64 `json:"duration"`
FrameRate string `json:"frame_rate"`
Height int `json:"height"`
Size string `json:"size"`
Width int `json:"width"`
}
type Mention struct {
Acct string `json:"acct"`
ID string `json:"id"`
URL string `json:"url"`
Username string `json:"username"`
}
type Poll struct {
Emojis []Emoji `json:"emojis"`
Expired bool `json:"expired"`
ExpiresAt string `json:"expires_at"`
ID string `json:"id"`
Multiple bool `json:"multiple"`
Options []PollOption `json:"options"`
OwnVotes []int `json:"own_votes"`
Voted bool `json:"voted"`
VotersCount int `json:"voters_count"`
VotesCount int `json:"votes_count"`
}
type PollOption struct {
Title string `json:"title"`
VotesCount int `json:"votes_count"`
}
type Role struct {
Color string `json:"color"`
Highlighted bool `json:"highlighted"`
ID string `json:"id"`
Name string `json:"name"`
Permissions string `json:"permissions"`
}
type Source struct {
AlsoKnownAsURIs []string `json:"also_known_as_uris"`
Fields []Field `json:"fields"`
FollowRequestsCount int `json:"follow_requests_count"`
Language string `json:"language"`
Note string `json:"note"`
Privacy string `json:"privacy"`
Sensitive bool `json:"sensitive"`
StatusContentType string `json:"status_content_type"`
WebLayout string `json:"web_layout"`
WebVisibility string `json:"web_visibility"`
}
type Tag struct {
Following bool `json:"following"`
History []string `json:"history"`
Name string `json:"name"`
URL string `json:"url"`
}

16
internal/actor/profile.go Normal file
View 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"`
}

View file

@ -2,6 +2,7 @@ package bluesky
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"net/url" "net/url"
"strings" "strings"
@ -12,6 +13,7 @@ import (
"github.com/bluesky-social/indigo/atproto/syntax" "github.com/bluesky-social/indigo/atproto/syntax"
"github.com/bluesky-social/indigo/xrpc" "github.com/bluesky-social/indigo/xrpc"
log "github.com/nacorid/logger" log "github.com/nacorid/logger"
"github.com/nacorid/naco-api/internal/actor"
) )
type BlueskyClient struct { type BlueskyClient struct {
@ -40,58 +42,113 @@ func NewBlueskyClient(ctx context.Context, handle, password, host string) (*Blue
return &BlueskyClient{client: client}, nil return &BlueskyClient{client: client}, nil
} }
func (c *BlueskyClient) GetPost(ctx context.Context, actor, collection, recordKey string) (*bsky.FeedDefs_PostView, error) { // withRefresh runs fn. If it fails with an auth/expired-token XRPC error, the session is
repo, err := c.resolveUser(ctx, actor) // refreshed once and fn is retried. This makes long-running and background enrichment calls
if err != nil { // resilient to Bluesky access-token expiry without callers having to handle it.
return nil, err func (c *BlueskyClient) withRefresh(ctx context.Context, fn func() error) error {
firstErr := fn()
if firstErr == nil {
return nil
} }
if !isAuthError(firstErr) {
return firstErr
}
if rerr := c.refreshSession(ctx); rerr != nil {
log.WarnWithContext(ctx, "bluesky session refresh failed", "error", rerr)
return firstErr
}
return fn()
}
var uri string func (c *BlueskyClient) refreshSession(ctx context.Context) error {
if collection != "" { refreshed, err := atproto.ServerRefreshSession(ctx, c.client)
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 { if err != nil {
return nil, fmt.Errorf("failed to get post: %w", err) return fmt.Errorf("failed to refresh bluesky session: %w", err)
} }
if len(resp.Posts) == 0 { c.client.Auth.AccessJwt = refreshed.AccessJwt
return nil, fmt.Errorf("post not found") c.client.Auth.RefreshJwt = refreshed.RefreshJwt
return nil
}
func isAuthError(err error) bool {
var xe *xrpc.XRPCError
if !errors.As(err, &xe) {
return false
} }
return resp.Posts[0], nil switch xe.ErrStr {
case "ExpiredToken", "TokenRequired", "AuthMissing", "AuthorizationRequired":
return true
}
msg := strings.ToLower(xe.Message)
return strings.Contains(msg, "expired") || strings.Contains(msg, "unauthorized") || strings.Contains(msg, "token")
}
func (c *BlueskyClient) GetPost(ctx context.Context, actor, collection, recordKey string) (*bsky.FeedDefs_PostView, error) {
var result *bsky.FeedDefs_PostView
err := c.withRefresh(ctx, func() error {
repo, err := c.resolveUser(ctx, actor)
if err != nil {
return err
}
var uri string
if collection != "" {
uri = fmt.Sprintf("at://%s/%s/%s", repo.String(), collection, recordKey)
} else {
uri = fmt.Sprintf("at://%s/app.bsky.feed.post/%s", repo.String(), recordKey)
}
log.DebugWithContext(ctx, "Fetching post", "URI", uri)
resp, err := bsky.FeedGetPosts(ctx, c.client, []string{uri})
if err != nil {
return fmt.Errorf("failed to get post: %w", err)
}
if len(resp.Posts) == 0 {
return fmt.Errorf("post not found")
}
result = resp.Posts[0]
return nil
})
return result, err
} }
func (c *BlueskyClient) GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*bsky.FeedDefs_FeedViewPost, error) { func (c *BlueskyClient) GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*bsky.FeedDefs_FeedViewPost, error) {
repo, err := c.resolveUser(ctx, actor) var result []*bsky.FeedDefs_FeedViewPost
if err != nil { err := c.withRefresh(ctx, func() error {
return nil, err repo, err := c.resolveUser(ctx, actor)
} if err != nil {
resp, err := bsky.FeedGetAuthorFeed(ctx, c.client, repo.String(), "", "posts", false, int64(limit)) return err
if err != nil { }
return nil, fmt.Errorf("failed to get latest posts: %w", err) resp, err := bsky.FeedGetAuthorFeed(ctx, c.client, repo.String(), "", "posts", false, int64(limit))
} if err != nil {
return resp.Feed, nil return fmt.Errorf("failed to get latest posts: %w", err)
}
result = resp.Feed
return nil
})
return result, err
} }
func (c *BlueskyClient) GetPostsInTimefame(ctx context.Context, actor string, start, end time.Time, limit int) ([]*bsky.FeedDefs_FeedViewPost, error) { 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) var result []*bsky.FeedDefs_FeedViewPost
if err != nil { err := c.withRefresh(ctx, func() error {
return nil, err repo, err := c.resolveUser(ctx, actor)
}
var posts []*bsky.FeedDefs_FeedViewPost
var cursor string
for len(posts) < limit {
feed, err := bsky.FeedGetAuthorFeed(ctx, c.client, repo.String(), cursor, "posts", false, 50)
if err != nil { if err != nil {
return nil, err return err
} }
for _, post := range feed.Feed { var posts []*bsky.FeedDefs_FeedViewPost
if post.Post.Record != nil { var cursor string
for len(posts) < limit {
feed, err := bsky.FeedGetAuthorFeed(ctx, c.client, repo.String(), cursor, "posts", false, 50)
if err != nil {
return err
}
for _, post := range feed.Feed {
if post.Post == nil || post.Post.Record == nil {
continue
}
rec, ok := post.Post.Record.Val.(*bsky.FeedPost) rec, ok := post.Post.Record.Val.(*bsky.FeedPost)
if !ok { if !ok {
log.WarnWithContext(ctx, "Failed to parse post", "post", post.Post.Uri) log.WarnWithContext(ctx, "Failed to parse post", "post", post.Post.Uri)
@ -107,18 +164,21 @@ func (c *BlueskyClient) GetPostsInTimefame(ctx context.Context, actor string, st
if createdAt.After(start) && createdAt.Before(end) { if createdAt.After(start) && createdAt.Before(end) {
posts = append(posts, post) posts = append(posts, post)
if len(posts) >= limit { if len(posts) >= limit {
return posts, nil result = posts
return nil
} }
} }
} }
}
if feed.Cursor == nil || *feed.Cursor == "" { if feed.Cursor == nil || *feed.Cursor == "" {
break break
}
cursor = *feed.Cursor
} }
cursor = *feed.Cursor result = posts
} return nil
return posts, nil })
return result, err
} }
func (c *BlueskyClient) GetFeed(ctx context.Context, feedURI string, limit int) (*bsky.FeedGetFeed_Output, error) { func (c *BlueskyClient) GetFeed(ctx context.Context, feedURI string, limit int) (*bsky.FeedGetFeed_Output, error) {
@ -126,7 +186,16 @@ func (c *BlueskyClient) GetFeed(ctx context.Context, feedURI string, limit int)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return bsky.FeedGetFeed(ctx, c.client, "", uri, int64(50)) var result *bsky.FeedGetFeed_Output
err = c.withRefresh(ctx, func() error {
resp, err := bsky.FeedGetFeed(ctx, c.client, "", uri, int64(50))
if err != nil {
return fmt.Errorf("failed to get feed: %w", err)
}
result = resp
return nil
})
return result, err
} }
func (c *BlueskyClient) resolveUser(ctx context.Context, user string) (syntax.DID, error) { func (c *BlueskyClient) resolveUser(ctx context.Context, user string) (syntax.DID, error) {
@ -146,6 +215,82 @@ func (c *BlueskyClient) resolveUser(ctx context.Context, user string) (syntax.DI
return did, nil return did, nil
} }
func (c *BlueskyClient) ResolveUser(ctx context.Context, user string) (syntax.DID, error) {
return c.resolveUser(ctx, user)
}
func (c *BlueskyClient) GetActor(ctx context.Context, author string) (*actor.Profile, error) {
var result *actor.Profile
err := c.withRefresh(ctx, func() error {
params := map[string]any{"actor": author}
var prof bskyActorProfile
if err := c.client.Do(ctx, xrpc.Query, "", "app.bsky.actor.getProfile", params, nil, &prof); err != nil {
return fmt.Errorf("failed to get actor profile: %w", err)
}
result = &actor.Profile{
Platform: "Bluesky",
Handle: prof.Handle,
DisplayName: prof.DisplayName,
AvatarURL: prof.Avatar,
Bio: prof.Description,
FollowersCount: prof.FollowersCount,
FollowingCount: prof.FollowsCount,
PostCount: prof.PostsCount,
URL: prof.URL,
}
return nil
})
return result, err
}
func (c *BlueskyClient) SearchActors(ctx context.Context, query string, limit int) ([]actor.Profile, error) {
var result []actor.Profile
err := c.withRefresh(ctx, func() error {
params := map[string]any{"q": query, "limit": limit}
var out bskyActorSearch
if err := c.client.Do(ctx, xrpc.Query, "", "app.bsky.actor.searchActors", params, nil, &out); err != nil {
return fmt.Errorf("failed to search actors: %w", err)
}
results := make([]actor.Profile, 0, len(out.Actors))
for _, a := range out.Actors {
results = append(results, actor.Profile{
Platform: "Bluesky",
Handle: a.Handle,
DisplayName: a.DisplayName,
AvatarURL: a.Avatar,
Bio: a.Description,
URL: a.URL,
})
}
result = results
return nil
})
return result, err
}
type bskyActorProfile struct {
Did string `json:"did"`
Handle string `json:"handle"`
DisplayName string `json:"displayName"`
Avatar string `json:"avatar"`
Description string `json:"description"`
FollowersCount int `json:"followersCount"`
FollowsCount int `json:"followsCount"`
PostsCount int `json:"postsCount"`
URL string `json:"url"`
}
type bskyActorSearch struct {
Actors []struct {
Did string `json:"did"`
Handle string `json:"handle"`
DisplayName string `json:"displayName"`
Avatar string `json:"avatar"`
Description string `json:"description"`
URL string `json:"url"`
} `json:"actors"`
}
func (c *BlueskyClient) sanitizeFeedURI(ctx context.Context, input string) (string, error) { func (c *BlueskyClient) sanitizeFeedURI(ctx context.Context, input string) (string, error) {
input = strings.TrimSpace(input) input = strings.TrimSpace(input)

View file

@ -8,6 +8,7 @@ import (
"net/http/httptest" "net/http/httptest"
"strconv" "strconv"
"testing" "testing"
"time"
"github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/api/atproto"
"github.com/bluesky-social/indigo/api/bsky" "github.com/bluesky-social/indigo/api/bsky"
@ -109,6 +110,126 @@ func setupMockServer() *httptest.Server {
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,
"url": "https://bsky.app/profile/" + actor,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
})
// Mock for app.bsky.actor.searchActors (used by SearchActors)
mux.HandleFunc("/xrpc/app.bsky.actor.searchActors", func(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
limitStr := r.URL.Query().Get("limit")
limit := 10
if limitStr != "" {
limit, _ = strconv.Atoi(limitStr)
}
if query == "" {
http.Error(w, `{"error": "MissingParameter", "message": "q parameter is required"}`, http.StatusBadRequest)
return
}
actors := make([]map[string]any, 0, limit)
for i := 0; i < limit; i++ {
actors = append(actors, map[string]any{
"did": fmt.Sprintf("did:plc:test%d", i),
"handle": fmt.Sprintf("%s%d.test", query, i),
"displayName": fmt.Sprintf("Test User %d", i),
"avatar": "https://example.com/avatar.jpg",
"description": fmt.Sprintf("Bio for user %d", i),
"url": fmt.Sprintf("https://bsky.app/profile/%s%d.test", query, i),
})
}
resp := map[string]any{"actors": actors}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
})
// Mock for com.atproto.identity.resolveHandle (used by ResolveUser)
mux.HandleFunc("/xrpc/com.atproto.identity.resolveHandle", func(w http.ResponseWriter, r *http.Request) {
handle := r.URL.Query().Get("handle")
if handle == "" {
http.Error(w, `{"error": "MissingParameter", "message": "handle parameter is required"}`, http.StatusBadRequest)
return
}
resp := atproto.IdentityResolveHandle_Output{
Did: "did:plc:resolved",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
})
// Mock for com.atproto.server.refreshSession (used by withRefresh)
mux.HandleFunc("/xrpc/com.atproto.server.refreshSession", func(w http.ResponseWriter, r *http.Request) {
// Check for Authorization header
auth := r.Header.Get("Authorization")
if auth == "" {
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{"error": "TokenRequired"})
return
}
resp := atproto.ServerRefreshSession_Output{
AccessJwt: "refreshed_access_jwt",
RefreshJwt: "refreshed_refresh_jwt",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
})
return httptest.NewServer(mux) return httptest.NewServer(mux)
} }
@ -227,3 +348,264 @@ func TestGetFeed(t *testing.T) {
t.Errorf("Unexpected post URI: %s", output.Feed[0].Post.Uri) t.Errorf("Unexpected post URI: %s", output.Feed[0].Post.Uri)
} }
} }
func TestGetPost(t *testing.T) {
server := setupMockServer()
defer server.Close()
client := newTestClient(t, server.URL)
ctx := context.Background()
postURI := "at://did:plc:testactor/app.bsky.feed.post/123"
post, err := client.GetPost(ctx, "did:plc:testactor", "", "123")
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if post == nil {
t.Fatal("Expected post not to be nil")
}
if post.Uri != postURI {
t.Errorf("Expected post URI %q, got %q", postURI, post.Uri)
}
if post.Author.Handle != "test.handle" {
t.Errorf("Expected author handle 'test.handle', got %q", post.Author.Handle)
}
// Test with custom collection
post2, err := client.GetPost(ctx, "did:plc:testactor", "app.bsky.feed.post", "456")
if err != nil {
t.Fatalf("Expected no error with custom collection, got %v", err)
}
if post2 == nil {
t.Fatal("Expected post not to be nil")
}
}
func TestGetPostsInTimeframe(t *testing.T) {
// The mock author-feed returns two pages: the first contains out-of-range posts,
// then in-range posts, verifying both date filtering and cursor pagination.
requests := 0
mux := http.NewServeMux()
mux.HandleFunc("/xrpc/com.atproto.server.createSession", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(atproto.ServerCreateSession_Output{Handle: "test.handle", Did: "did:plc:test"})
})
mux.HandleFunc("/xrpc/app.bsky.feed.getAuthorFeed", func(w http.ResponseWriter, r *http.Request) {
requests++
mk := func(uri, createdAt string) *bsky.FeedDefs_FeedViewPost {
return &bsky.FeedDefs_FeedViewPost{
Post: &bsky.FeedDefs_PostView{
Uri: uri,
Author: &bsky.ActorDefs_ProfileViewBasic{
Did: "did:plc:testactor",
Handle: "testactor.test",
},
Record: &util.LexiconTypeDecoder{
Val: &bsky.FeedPost{Text: uri, CreatedAt: createdAt},
},
},
}
}
var feed []*bsky.FeedDefs_FeedViewPost
var cursor string
if requests == 1 {
// Page 1: one out-of-range (before start), one out-of-range (after end), and a cursor.
feed = []*bsky.FeedDefs_FeedViewPost{
mk("at://did:plc:testactor/app.bsky.feed.post/3", "2022-01-01T00:00:00Z"),
mk("at://did:plc:testactor/app.bsky.feed.post/4", "2024-01-01T00:00:00Z"),
}
cursor = "page-2-cursor"
} else {
// Page 2: two in-range posts and no cursor (end of feed).
feed = []*bsky.FeedDefs_FeedViewPost{
mk("at://did:plc:testactor/app.bsky.feed.post/1", "2023-06-01T00:00:00Z"),
mk("at://did:plc:testactor/app.bsky.feed.post/2", "2023-08-01T00:00:00Z"),
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(bsky.FeedGetAuthorFeed_Output{Feed: feed, Cursor: &cursor})
})
server := httptest.NewServer(mux)
defer server.Close()
client := newTestClient(t, server.URL)
ctx := context.Background()
actor := "did:plc:testactor"
start := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2023, 12, 31, 23, 59, 59, 0, time.UTC)
limit := 10
posts, err := client.GetPostsInTimeframe(ctx, actor, start, end, limit)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
// Only the two in-range posts must survive filtering; cursor pagination must
// advance into page 2 to reach them.
if len(posts) != 2 {
t.Fatalf("expected 2 in-range posts, got %d", len(posts))
}
for _, post := range posts {
rec, ok := post.Post.Record.Val.(*bsky.FeedPost)
if !ok {
t.Fatalf("unexpected record type")
}
createdAt, _ := time.Parse(time.RFC3339, rec.CreatedAt)
if createdAt.Before(start) || createdAt.After(end) {
t.Errorf("Post %s returned outside time range", post.Post.Uri)
}
}
if requests != 2 {
t.Errorf("expected pagination to make 2 feed requests, got %d", requests)
}
}
func TestGetActor(t *testing.T) {
server := setupMockServer()
defer server.Close()
client := newTestClient(t, server.URL)
ctx := context.Background()
actor := "test.handle"
profile, err := client.GetActor(ctx, actor)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if profile == nil {
t.Fatal("Expected profile not to be nil")
}
if profile.Handle != actor {
t.Errorf("Expected handle %q, got %q", actor, profile.Handle)
}
if profile.DisplayName != "Test User" {
t.Errorf("Expected display name 'Test User', got %q", profile.DisplayName)
}
if profile.Platform != "Bluesky" {
t.Errorf("Expected platform 'Bluesky', got %q", profile.Platform)
}
if profile.FollowersCount != 100 || profile.FollowingCount != 50 || profile.PostCount != 200 {
t.Errorf("Unexpected counts: followers=%d, following=%d, posts=%d", profile.FollowersCount, profile.FollowingCount, profile.PostCount)
}
}
func TestSearchActors(t *testing.T) {
server := setupMockServer()
defer server.Close()
client := newTestClient(t, server.URL)
ctx := context.Background()
query := "alice"
limit := 5
results, err := client.SearchActors(ctx, query, limit)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if results == nil {
t.Fatal("Expected results not to be nil")
}
if len(results) != limit {
t.Errorf("Expected %d results, got %d", limit, len(results))
}
for i, result := range results {
expectedHandle := fmt.Sprintf("%s%d.test", query, i)
if result.Handle != expectedHandle {
t.Errorf("Result %d: expected handle %q, got %q", i, expectedHandle, result.Handle)
}
if result.Platform != "Bluesky" {
t.Errorf("Result %d: expected platform 'Bluesky', got %q", i, result.Platform)
}
}
}
func TestResolveUser(t *testing.T) {
server := setupMockServer()
defer server.Close()
client := newTestClient(t, server.URL)
ctx := context.Background()
// Test with handle
did, err := client.ResolveUser(ctx, "test.handle")
if err != nil {
t.Fatalf("Expected no error resolving handle, got %v", err)
}
if did.String() != "did:plc:resolved" {
t.Errorf("Expected resolved DID 'did:plc:resolved', got %q", did.String())
}
// Test with DID directly
did2, err := client.ResolveUser(ctx, "did:plc:direct")
if err != nil {
t.Fatalf("Expected no error with direct DID, got %v", err)
}
if did2.String() != "did:plc:direct" {
t.Errorf("Expected same DID 'did:plc:direct', got %q", did2.String())
}
}
func TestSanitizeFeedURI(t *testing.T) {
server := setupMockServer()
defer server.Close()
client := newTestClient(t, server.URL)
ctx := context.Background()
tests := []struct {
name string
input string
expected string
wantErr bool
}{
{
name: "AT URI",
input: "at://did:plc:abc/app.bsky.feed.generator/xyz",
expected: "at://did:plc:abc/app.bsky.feed.generator/xyz",
wantErr: false,
},
{
name: "Web URI with profile/feed",
input: "https://bsky.app/profile/did:plc:abc/feed/xyz",
expected: "at://did:plc:abc/app.bsky.feed.generator/xyz",
wantErr: false,
},
{
name: "Invalid URI",
input: "not-a-uri",
expected: "",
wantErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result, err := client.sanitizeFeedURI(ctx, tc.input)
if tc.wantErr {
if err == nil {
t.Fatalf("Expected error for input %q, got nil", tc.input)
}
return
}
if err != nil {
t.Fatalf("Unexpected error for input %q: %v", tc.input, err)
}
if result != tc.expected {
t.Errorf("Input %q: expected %q, got %q", tc.input, tc.expected, result)
}
})
}
}

View 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
}

View 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
}

View 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))
}
}
}

View 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
}

View file

@ -2,30 +2,70 @@ package config
import ( import (
"os" "os"
"path/filepath"
"strconv" "strconv"
"strings"
"time"
"github.com/joho/godotenv" "github.com/joho/godotenv"
log "github.com/nacorid/logger" log "github.com/nacorid/logger"
) )
type Config struct { type Config struct {
ListenAddress string ListenAddress string
ListenPort string ListenPort string
UnixSocketPath string UnixSocketPath string
UnixSocketPermissions os.FileMode UnixSocketPermissions os.FileMode
DatabaseURL string DatabaseURL string
BlueskyDID string ActivityPubHost string
BlueskyAppPassword string ActivityPubToken string
BlueskyHost string BlueskyDID string
OllamaHost string BlueskyAppPassword string
X402FacilitatorURL string BlueskyHost string
X402WalletAddress string NostrRelays []string
ApiKey string TwitterBearerToken string
ApiSecret string PrewarmAuthors []string
LogFile string PrewarmFeedURI string
EnrichCrossPlatform bool
OllamaHost string
MigrateOnStart bool
MigrationsDir string
ReadyzPort string
MetricsPort string
X402FacilitatorURL string
X402WalletAddress string
ApiKey string
ApiSecret string
LogFile string
ActiveNetworks string
MatrixWebhookURL string
MatrixServerID string
EmbeddingModel string
StyleModel string
AnalyticsTTLEngagement time.Duration
AnalyticsTTLPatterns time.Duration
AnalyticsTTLTopics time.Duration
AnalyticsTTLSentiment time.Duration
AnalyticsTTLDrivers time.Duration
} }
func Load() *Config { 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 { if err := godotenv.Load(); err != nil {
log.Debug("No .env file found, reading from environment") log.Debug("No .env file found, reading from environment")
} }
@ -35,21 +75,52 @@ func Load() *Config {
if err != nil { if err != nil {
log.Fatal("Invalid UNIX_SOCKET_PERMISSIONS", "error", err) log.Fatal("Invalid UNIX_SOCKET_PERMISSIONS", "error", err)
} }
return &Config{ cfg := &Config{
ListenAddress: getEnv("SERVER_ADDRESS", "127.0.0.1"), ListenAddress: getEnv("SERVER_ADDRESS", "127.0.0.1"),
ListenPort: getEnv("SERVER_PORT", "8080"), ListenPort: getEnv("SERVER_PORT", "8080"),
UnixSocketPermissions: os.FileMode(perms), UnixSocketPermissions: os.FileMode(perms),
DatabaseURL: getEnv("GOOSE_DBSTRING", "postgres:///mcpserver"), DatabaseURL: getEnv("GOOSE_DBSTRING", "postgres:///mcpserver"),
BlueskyDID: getEnv("BLUESKY_DID", ""), ActivityPubHost: getEnv("AP_HOST", "https://mastodon.social"),
BlueskyAppPassword: getEnv("BLUESKY_APP_PASSWORD", ""), ActivityPubToken: getEnv("AP_ACCESS_TOKEN", ""),
BlueskyHost: getEnv("BLUESKY_HOST", "https://bsky.social"), BlueskyDID: getEnv("BLUESKY_DID", ""),
OllamaHost: getEnv("OLLAMA_HOST", "http://localhost:11434"), BlueskyAppPassword: getEnv("BLUESKY_APP_PASSWORD", ""),
X402FacilitatorURL: getEnv("X402_FACILITATOR_URL", "https://facilitator.x402.rs"), BlueskyHost: getEnv("BLUESKY_HOST", "https://bsky.social"),
X402WalletAddress: getEnv("X402_WALLET_ADDRESS", ""), NostrRelays: splitComma(getEnv("NOSTR_RELAYS", "wss://relay.damus.io,wss://nos.lol")),
ApiKey: getEnv("CDP_API_KEY_ID", ""), TwitterBearerToken: getEnv("TWITTER_BEARER_TOKEN", ""),
ApiSecret: getEnv("CDP_API_SECRET", ""), PrewarmAuthors: splitComma(getEnv("PREWARM_AUTHORS", "")),
LogFile: getEnv("LOG_FILE", "/var/log/mcpserver/output.log"), PrewarmFeedURI: getEnv("PREWARM_FEED_URI", ""),
EnrichCrossPlatform: getEnvBool("ENRICH_CROSS_PLATFORM", "true"),
OllamaHost: getEnv("OLLAMA_HOST", "http://localhost:11434"),
MigrateOnStart: getEnvBool("MIGRATE_ON_START", "true"),
MigrationsDir: getEnv("MIGRATIONS_DIR", "sql/schema"),
ReadyzPort: getEnv("READYZ_PORT", "9090"),
MetricsPort: getEnv("METRICS_PORT", "9091"),
X402FacilitatorURL: getEnv("X402_FACILITATOR_URL", "https://facilitator.x402.rs"),
X402WalletAddress: getEnv("X402_WALLET_ADDRESS", ""),
ApiKey: getEnv("CDP_API_KEY_ID", ""),
ApiSecret: getEnv("CDP_API_SECRET", ""),
LogFile: getEnv("LOG_FILE", "/var/log/mcpserver/output.log"),
ActiveNetworks: getEnv("ACTIVE_NETWORKS", "base"),
MatrixWebhookURL: getEnv("MATRIX_WEBHOOK_URL", ""),
MatrixServerID: getEnv("MATRIX_SERVER_ID", ""),
EmbeddingModel: getEnv("EMBEDDING_MODEL", ""),
StyleModel: getEnv("STYLE_MODEL", ""),
AnalyticsTTLEngagement: getEnvDuration("ANALYTICS_TTL_ENGAGEMENT", "1h"),
AnalyticsTTLPatterns: getEnvDuration("ANALYTICS_TTL_PATTERNS", "6h"),
AnalyticsTTLTopics: getEnvDuration("ANALYTICS_TTL_TOPICS", "24h"),
AnalyticsTTLSentiment: getEnvDuration("ANALYTICS_TTL_SENTIMENT", "6h"),
AnalyticsTTLDrivers: getEnvDuration("ANALYTICS_TTL_DRIVERS", "1h"),
} }
// Default MatrixServerID to system hostname if not explicitly set
if cfg.MatrixServerID == "" {
hostname, err := os.Hostname()
if err == nil && hostname != "" {
cfg.MatrixServerID = hostname
}
}
return cfg
} }
func getEnv(key, fallback string) string { func getEnv(key, fallback string) string {
@ -61,3 +132,51 @@ func getEnv(key, fallback string) string {
} }
return fallback return fallback
} }
func getEnvBool(key, fallback string) bool {
v := getEnv(key, fallback)
switch strings.ToLower(strings.TrimSpace(v)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
func getEnvDuration(key, fallback string) time.Duration {
v := getEnv(key, fallback)
d, err := time.ParseDuration(v)
if err != nil {
d, _ = time.ParseDuration(fallback)
}
return d
}
func splitComma(s string) []string {
parts := make([]string, 0)
for p := range strings.SplitSeq(s, ",") {
if trimmed := strings.TrimSpace(p); trimmed != "" {
parts = append(parts, trimmed)
}
}
return parts
}
func findProjectRoot() string {
// Start from current working directory and walk up looking for go.mod
dir, err := os.Getwd()
if err != nil {
return "."
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
break // reached filesystem root
}
dir = parent
}
return "."
}

333
internal/nostr/client.go Normal file
View file

@ -0,0 +1,333 @@
package nostr
import (
"context"
"encoding/json"
"fmt"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
"github.com/nacorid/naco-api/internal/actor"
)
type Client struct {
relays []string
}
func NewNostrClient(relays []string) (*Client, error) {
if len(relays) == 0 {
return nil, fmt.Errorf("at least one relay is required")
}
return &Client{relays: relays}, nil
}
type Post struct {
ID string
Author string
Content string
CreatedAt time.Time
}
func (c *Client) GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*Post, error) {
pubkey, err := resolvePubkey(actor)
if err != nil {
return nil, err
}
posts := make([]*Post, 0, limit)
seen := make(map[string]struct{})
connected := false
for _, url := range c.relays {
relay, err := nostr.RelayConnect(ctx, url, nostr.RelayOptions{})
if err != nil {
continue
}
connected = true
sub, err := relay.Subscribe(ctx, nostr.Filter{
Authors: []nostr.PubKey{pubkey},
Kinds: []nostr.Kind{1},
Limit: limit,
}, nostr.SubscriptionOptions{})
if err != nil {
relay.Close()
continue
}
func() {
defer sub.Unsub()
defer relay.Close()
collect:
for {
select {
case <-sub.EndOfStoredEvents:
break collect
case ev, ok := <-sub.Events:
if !ok {
break collect
}
id := ev.ID.Hex()
if _, dup := seen[id]; dup {
continue
}
seen[id] = struct{}{}
posts = append(posts, &Post{
ID: id,
Author: ev.PubKey.Hex(),
Content: ev.Content,
CreatedAt: ev.CreatedAt.Time(),
})
case <-ctx.Done():
break collect
}
}
}()
if len(posts) >= limit {
break
}
}
if !connected {
return nil, fmt.Errorf("could not connect to any relay")
}
return posts, nil
}
func (c *Client) GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]*Post, error) {
pubkey, err := resolvePubkey(actor)
if err != nil {
return nil, err
}
since := nostr.Timestamp(start.Unix())
until := nostr.Timestamp(end.Unix())
posts := make([]*Post, 0, limit)
seen := make(map[string]struct{})
connected := false
for _, url := range c.relays {
relay, err := nostr.RelayConnect(ctx, url, nostr.RelayOptions{})
if err != nil {
continue
}
connected = true
sub, err := relay.Subscribe(ctx, nostr.Filter{
Authors: []nostr.PubKey{pubkey},
Kinds: []nostr.Kind{1},
Since: since,
Until: until,
Limit: limit,
}, nostr.SubscriptionOptions{})
if err != nil {
relay.Close()
continue
}
func() {
defer sub.Unsub()
defer relay.Close()
collect:
for {
select {
case <-sub.EndOfStoredEvents:
break collect
case ev, ok := <-sub.Events:
if !ok {
break collect
}
id := ev.ID.Hex()
if _, dup := seen[id]; dup {
continue
}
seen[id] = struct{}{}
posts = append(posts, &Post{
ID: id,
Author: ev.PubKey.Hex(),
Content: ev.Content,
CreatedAt: ev.CreatedAt.Time(),
})
case <-ctx.Done():
break collect
}
}
}()
if len(posts) >= limit {
break
}
}
if !connected {
return nil, fmt.Errorf("could not connect to any relay")
}
return posts, nil
}
func (c *Client) GetPost(ctx context.Context, id string) (*Post, error) {
eid, err := resolveEventID(id)
if err != nil {
return nil, err
}
for _, url := range c.relays {
relay, err := nostr.RelayConnect(ctx, url, nostr.RelayOptions{})
if err != nil {
continue
}
sub, err := relay.Subscribe(ctx, nostr.Filter{
IDs: []nostr.ID{eid},
Kinds: []nostr.Kind{1},
}, nostr.SubscriptionOptions{})
if err != nil {
relay.Close()
continue
}
defer sub.Unsub()
defer relay.Close()
for {
select {
case <-sub.EndOfStoredEvents:
return nil, fmt.Errorf("post %q not found", id)
case ev, ok := <-sub.Events:
if !ok {
return nil, fmt.Errorf("post %q not found", id)
}
return &Post{
ID: ev.ID.Hex(),
Author: ev.PubKey.Hex(),
Content: ev.Content,
CreatedAt: ev.CreatedAt.Time(),
}, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
}
return nil, fmt.Errorf("could not connect to any relay")
}
func (c *Client) GetActor(ctx context.Context, author string) (*actor.Profile, error) {
pubkey, err := resolvePubkey(author)
if err != nil {
return nil, err
}
npub := nip19.EncodeNpub(pubkey)
for _, u := range c.relays {
relay, err := nostr.RelayConnect(ctx, u, nostr.RelayOptions{})
if err != nil {
continue
}
sub, err := relay.Subscribe(ctx, nostr.Filter{
Authors: []nostr.PubKey{pubkey},
Kinds: []nostr.Kind{0},
Limit: 1,
}, nostr.SubscriptionOptions{})
if err != nil {
relay.Close()
continue
}
defer sub.Unsub()
defer relay.Close()
collect:
for {
select {
case <-sub.EndOfStoredEvents:
break collect
case ev, ok := <-sub.Events:
if !ok {
break collect
}
var meta struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
About string `json:"about"`
Picture string `json:"picture"`
Website string `json:"website"`
}
if err := json.Unmarshal([]byte(ev.Content), &meta); err == nil {
return &actor.Profile{
Platform: "Nostr",
Handle: npub,
DisplayName: meta.DisplayName,
AvatarURL: meta.Picture,
Bio: meta.About,
URL: "https://njump.me/" + npub,
}, nil
}
break collect
case <-ctx.Done():
return nil, ctx.Err()
}
}
}
return nil, fmt.Errorf("no profile metadata found for %q", author)
}
func (c *Client) SearchActors(ctx context.Context, query string, limit int) ([]actor.Profile, error) {
// Nostr has no global actor search; best-effort: if the query resolves to a
// pubkey/npub, return that single profile.
if _, err := resolvePubkey(query); err != nil {
return []actor.Profile{}, nil
}
prof, err := c.GetActor(ctx, query)
if err != nil {
return []actor.Profile{}, nil
}
return []actor.Profile{*prof}, nil
}
func resolvePubkey(actor string) (nostr.PubKey, error) {
prefix, value, err := nip19.Decode(actor)
if err == nil {
if prefix != "npub" {
return nostr.PubKey{}, fmt.Errorf("unsupported nostr entity %q", prefix)
}
pk, ok := value.(nostr.PubKey)
if !ok {
return nostr.PubKey{}, fmt.Errorf("unexpected decoded value type %T", value)
}
return pk, nil
}
if len(actor) == 64 {
pk, e := nostr.PubKeyFromHex(actor)
if e != nil {
return nostr.PubKey{}, fmt.Errorf("invalid nostr author %q: %w", actor, e)
}
return pk, nil
}
return nostr.PubKey{}, fmt.Errorf("invalid nostr author %q: %w", actor, err)
}
func resolveEventID(id string) (nostr.ID, error) {
prefix, value, err := nip19.Decode(id)
if err == nil {
if prefix != "note" {
return nostr.ID{}, fmt.Errorf("unsupported nostr entity %q", prefix)
}
eid, ok := value.(nostr.ID)
if !ok {
return nostr.ID{}, fmt.Errorf("unexpected decoded value type %T", value)
}
return eid, nil
}
if len(id) == 64 {
eid, e := nostr.IDFromHex(id)
if e != nil {
return nostr.ID{}, fmt.Errorf("invalid nostr event id %q: %w", id, e)
}
return eid, nil
}
return nostr.ID{}, fmt.Errorf("invalid nostr event id %q: %w", id, err)
}

View file

@ -0,0 +1,231 @@
package nostr
import (
"context"
"strings"
"testing"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
)
func TestNewNostrClientEmptyRelays(t *testing.T) {
_, err := NewNostrClient([]string{})
if err == nil {
t.Fatal("expected error for empty relays")
}
if !strings.Contains(err.Error(), "at least one relay") {
t.Fatalf("expected 'at least one relay' error, got %v", err)
}
}
func TestResolvePubkeyVariants(t *testing.T) {
// Create a valid pubkey for testing using Generate()
sk := nostr.Generate()
pk := sk.Public()
npub := nip19.EncodeNpub(pk)
hex := pk.Hex()
tests := []struct {
name string
input string
wantErr bool
}{
{"npub encoding", npub, false},
{"64-char hex", hex, false},
{"invalid npub prefix", "nsec" + npub[4:], true},
{"too short hex", hex[:32], true},
{"random string", "not-a-key", true},
{"empty string", "", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := resolvePubkey(tc.input)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error for %q, got nil", tc.input)
}
} else {
if err != nil {
t.Fatalf("unexpected error for %q: %v", tc.input, err)
}
}
})
}
}
func TestResolveEventIDVariants(t *testing.T) {
var eid nostr.ID
for i := range eid {
eid[i] = byte(i + 1)
}
hex := eid.Hex()
tests := []struct {
name string
input string
wantErr bool
}{
{"64-char hex", hex, false},
{"too short hex", hex[:32], true},
{"random string", "not-an-id", true},
{"empty string", "", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := resolveEventID(tc.input)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error for %q, got nil", tc.input)
}
} else {
if err != nil {
t.Fatalf("unexpected error for %q: %v", tc.input, err)
}
}
})
}
}
func TestGetPostInvalidID(t *testing.T) {
c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"})
if err != nil {
t.Fatal(err)
}
_, err = c.GetPost(context.Background(), "invalid-id")
if err == nil {
t.Fatal("expected error for invalid event ID")
}
if !strings.Contains(err.Error(), "invalid nostr event id") {
t.Fatalf("expected 'invalid nostr event id' error, got %v", err)
}
}
func TestGetActorInvalidAuthor(t *testing.T) {
c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"})
if err != nil {
t.Fatal(err)
}
_, err = c.GetActor(context.Background(), "invalid-author")
if err == nil {
t.Fatal("expected error for invalid author")
}
if !strings.Contains(err.Error(), "invalid nostr author") {
t.Fatalf("expected 'invalid nostr author' error, got %v", err)
}
}
func TestGetLatestNPostsInvalidAuthor(t *testing.T) {
c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"})
if err != nil {
t.Fatal(err)
}
_, err = c.GetLatestNPosts(context.Background(), "invalid-author", 10)
if err == nil {
t.Fatal("expected error for invalid author")
}
if !strings.Contains(err.Error(), "invalid nostr author") {
t.Fatalf("expected 'invalid nostr author' error, got %v", err)
}
}
func TestGetPostsInTimeframeInvalidAuthor(t *testing.T) {
c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"})
if err != nil {
t.Fatal(err)
}
start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC)
_, err = c.GetPostsInTimeframe(context.Background(), "invalid-author", start, end, 10)
if err == nil {
t.Fatal("expected error for invalid author")
}
if !strings.Contains(err.Error(), "invalid nostr author") {
t.Fatalf("expected 'invalid nostr author' error, got %v", err)
}
}
func TestRelayConnectionFailureReturnsError(t *testing.T) {
// When no relay can be reached, GetLatestNPosts/GetPostsInTimeframe must
// report the connection failure rather than silently returning an empty feed.
c, err := NewNostrClient([]string{
"wss://invalid1.relay.invalid",
"wss://invalid2.relay.invalid",
"wss://invalid3.relay.invalid",
})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
sk := nostr.Generate()
npub := nip19.EncodeNpub(sk.Public())
_, err = c.GetLatestNPosts(ctx, npub, 10)
if err == nil {
t.Fatal("expected error when no relay is reachable")
}
if !strings.Contains(err.Error(), "could not connect to any relay") {
t.Fatalf("expected 'could not connect to any relay' error, got %v", err)
}
start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC)
_, err = c.GetPostsInTimeframe(ctx, npub, start, end, 10)
if err == nil {
t.Fatal("expected error when no relay is reachable")
}
if !strings.Contains(err.Error(), "could not connect to any relay") {
t.Fatalf("expected 'could not connect to any relay' error, got %v", err)
}
}
func TestGetPostWithHexID(t *testing.T) {
c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"})
if err != nil {
t.Fatal(err)
}
var eid nostr.ID
for i := range eid {
eid[i] = byte(i + 1)
}
hex := eid.Hex()
_, err = c.GetPost(context.Background(), hex)
if err == nil {
t.Fatal("expected error when relay unavailable")
}
if !strings.Contains(err.Error(), "could not connect to any relay") {
t.Fatalf("expected 'could not connect to any relay' error, got %v", err)
}
}
func TestGetActorWithHexPubkey(t *testing.T) {
c, err := NewNostrClient([]string{"wss://invalid.relay.invalid"})
if err != nil {
t.Fatal(err)
}
sk := nostr.Generate()
hex := sk.Public().Hex()
_, err = c.GetActor(context.Background(), hex)
if err == nil {
t.Fatal("expected error when relay unavailable")
}
// GetActor surfaces the failure as an absence of a profile after the relay
// loop runs without connecting.
if !strings.Contains(err.Error(), "no profile metadata found") {
t.Fatalf("expected 'no profile metadata found' error, got %v", err)
}
}

View file

@ -6,39 +6,64 @@ import (
"net/url" "net/url"
"strings" "strings"
"github.com/nacorid/naco-api/internal/clustering"
"github.com/ollama/ollama/api" "github.com/ollama/ollama/api"
) )
type OllamaClient struct { type OllamaClient struct {
client api.Client client api.Client
embeddingModel string
styleModel string
} }
func New(ctx context.Context, host *url.URL, http *http.Client) *OllamaClient { func New(ctx context.Context, host *url.URL, http *http.Client, em, sm string) *OllamaClient {
client := api.NewClient(host, http) client := api.NewClient(host, http)
return &OllamaClient{client: *client} return &OllamaClient{client: *client, embeddingModel: em, styleModel: sm}
} }
func (o *OllamaClient) EmbedTexts(ctx context.Context, posts []string) ([][]float64, error) { func (o *OllamaClient) EmbedTexts(ctx context.Context, posts []clustering.Item) ([][]float64, error) {
vectors := make([][]float64, 0, len(posts)) if len(posts) == 0 {
for _, t := range posts { return nil, nil
}
embeddings := make([][]float64, 0, len(posts))
for _, post := range posts {
resp, err := o.client.Embeddings(ctx, &api.EmbeddingRequest{ resp, err := o.client.Embeddings(ctx, &api.EmbeddingRequest{
Model: "nomic-embed-text", Model: o.embeddingModel,
Prompt: t, Prompt: post.Text,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
} }
vectors = append(vectors, resp.Embedding) embeddings = append(embeddings, resp.Embedding)
} }
return vectors, nil
return embeddings, nil
} }
func (o *OllamaClient) SummarizeStyle(ctx context.Context, posts []string) (string, error) { func (o *OllamaClient) SummarizeStyle(ctx context.Context, posts []clustering.Item) (string, error) {
prompt := "Summarize writing style, recurring themes, tone, personality traits of this user:\n\n" + if len(posts) == 0 {
strings.Join(posts, "\n") + "\n\nStyle summary:" return "", nil
}
var promptParts []string
for _, post := range posts {
promptParts = append(promptParts, post.Text)
}
prompt := "Summarize the user's writing style. Analyze:\n" +
"- tone and emotional register\n" +
"- recurring themes and interests\n" +
"- vocabulary and sentence structure\n" +
"- communication habits\n" +
"- personality traits expressed through writing\n\n" +
"Base your analysis only on the supplied posts. Do not infer sensitive personal attributes.\n\n" +
"Posts:\n\n" +
strings.Join(promptParts, "\n---\n") +
"\n\nStyle summary:"
var fullResponse strings.Builder var fullResponse strings.Builder
err := o.client.Generate(ctx, &api.GenerateRequest{ err := o.client.Generate(ctx, &api.GenerateRequest{
Model: "mistral", Model: o.styleModel,
Prompt: prompt, Prompt: prompt,
}, func(resp api.GenerateResponse) error { }, func(resp api.GenerateResponse) error {
fullResponse.WriteString(resp.Response) fullResponse.WriteString(resp.Response)

View file

@ -0,0 +1,215 @@
package ollama
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/nacorid/naco-api/internal/clustering"
)
func TestEmbedTexts(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/embeddings", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"embedding":[0.1,0.2,0.3]}`))
})
server := httptest.NewServer(mux)
defer server.Close()
host, _ := url.Parse(server.URL)
client := New(context.Background(), host, http.DefaultClient, "test-embed", "test-style")
posts := []clustering.Item{
{ID: "1", Text: "hello", Embedding: nil},
{ID: "2", Text: "world", Embedding: nil},
}
embeddings, err := client.EmbedTexts(context.Background(), posts)
if err != nil {
t.Fatalf("EmbedTexts failed: %v", err)
}
if len(embeddings) != 2 {
t.Fatalf("expected 2 embeddings, got %d", len(embeddings))
}
for i, emb := range embeddings {
if len(emb) != 3 {
t.Errorf("embedding %d has wrong dim: %d", i, len(emb))
}
if emb[0] != 0.1 || emb[1] != 0.2 || emb[2] != 0.3 {
t.Errorf("embedding %d wrong values: %v", i, emb)
}
}
}
func TestEmbedTexts_Error(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/embeddings", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
})
server := httptest.NewServer(mux)
defer server.Close()
host, _ := url.Parse(server.URL)
client := New(context.Background(), host, http.DefaultClient, "test-embed", "test-style")
posts := []clustering.Item{{ID: "1", Text: "hello", Embedding: nil}}
_, err := client.EmbedTexts(context.Background(), posts)
if err == nil {
t.Fatal("expected error from failing server")
}
}
func TestSummarizeStyle(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/generate", func(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
t.Fatal("response writer doesn't support flushing")
}
w.Header().Set("Content-Type", "application/x-ndjson")
// Stream response as newline-delimited JSON
w.Write([]byte(`{"response":"Style summary: "}` + "\n"))
if flusher != nil {
flusher.Flush()
}
w.Write([]byte(`{"response":"concise and clear."}` + "\n"))
})
server := httptest.NewServer(mux)
defer server.Close()
host, _ := url.Parse(server.URL)
client := New(context.Background(), host, http.DefaultClient, "test-embed", "test-style")
posts := []clustering.Item{
{ID: "1", Text: "First post", Embedding: nil},
{ID: "2", Text: "Second post", Embedding: nil},
}
summary, err := client.SummarizeStyle(context.Background(), posts)
if err != nil {
t.Fatalf("SummarizeStyle failed: %v", err)
}
if summary != "Style summary: concise and clear." {
t.Errorf("unexpected summary: %q", summary)
}
}
func TestSummarizeStyle_Error(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/generate", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
})
server := httptest.NewServer(mux)
defer server.Close()
host, _ := url.Parse(server.URL)
client := New(context.Background(), host, http.DefaultClient, "test-embed", "test-style")
posts := []clustering.Item{{ID: "1", Text: "hello", Embedding: nil}}
_, err := client.SummarizeStyle(context.Background(), posts)
if err == nil {
t.Fatal("expected error from failing server")
}
}
func TestSummarizeStyle_Streaming(t *testing.T) {
callCount := 0
mux := http.NewServeMux()
mux.HandleFunc("/api/generate", func(w http.ResponseWriter, r *http.Request) {
callCount++
flusher, ok := w.(http.Flusher)
if !ok {
t.Fatal("response writer doesn't support flushing")
}
w.Header().Set("Content-Type", "application/x-ndjson")
// Simulate streaming response
w.Write([]byte(`{"response":"part1"}` + "\n"))
if flusher != nil {
flusher.Flush()
}
w.Write([]byte(`{"response":"part2"}` + "\n"))
if flusher != nil {
flusher.Flush()
}
w.Write([]byte(`{"response":"part3","done":true}` + "\n"))
})
server := httptest.NewServer(mux)
defer server.Close()
host, _ := url.Parse(server.URL)
client := New(context.Background(), host, http.DefaultClient, "test-embed", "test-style")
posts := []clustering.Item{{ID: "1", Text: "test", Embedding: nil}}
summary, err := client.SummarizeStyle(context.Background(), posts)
if err != nil {
t.Fatalf("SummarizeStyle failed: %v", err)
}
if summary != "part1part2part3" {
t.Errorf("streaming summary = %q, want %q", summary, "part1part2part3")
}
if callCount != 1 {
t.Errorf("generate endpoint called %d times, want 1", callCount)
}
}
func TestEmbedTexts_EmptyInput(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/embeddings", func(w http.ResponseWriter, r *http.Request) {
t.Fatal("embeddings endpoint should not be called for empty input")
})
server := httptest.NewServer(mux)
defer server.Close()
host, _ := url.Parse(server.URL)
client := New(context.Background(), host, http.DefaultClient, "test-embed", "test-style")
embeddings, err := client.EmbedTexts(context.Background(), []clustering.Item{})
if err != nil {
t.Fatalf("EmbedTexts with empty input failed: %v", err)
}
if len(embeddings) != 0 {
t.Errorf("expected empty embeddings, got %d", len(embeddings))
}
}
func TestSummarizeStyle_EmptyInput(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/generate", func(w http.ResponseWriter, r *http.Request) {
t.Fatal("generate endpoint should not be called for empty input")
})
server := httptest.NewServer(mux)
defer server.Close()
host, _ := url.Parse(server.URL)
client := New(context.Background(), host, http.DefaultClient, "test-embed", "test-style")
summary, err := client.SummarizeStyle(context.Background(), []clustering.Item{})
if err != nil {
t.Fatalf("SummarizeStyle with empty input failed: %v", err)
}
if summary != "" {
t.Errorf("expected empty summary, got %q", summary)
}
}

View file

@ -0,0 +1,63 @@
package server
import (
"fmt"
"net/http"
"strings"
"time"
log "github.com/nacorid/logger"
)
type statusTrackingWriter struct {
http.ResponseWriter
statusCode int
}
func (stw *statusTrackingWriter) WriteHeader(code int) {
stw.statusCode = code
stw.ResponseWriter.WriteHeader(code)
}
func (stw *statusTrackingWriter) Write(b []byte) (int, error) {
if stw.statusCode == 0 {
stw.statusCode = http.StatusOK
}
return stw.ResponseWriter.Write(b)
}
func GlobalLoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/healthz" {
next.ServeHTTP(w, r)
return
}
start := time.Now()
ipAddr := r.RemoteAddr
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
ipAddr = strings.TrimSpace(strings.Split(forwarded, ",")[0])
} else if realIP := r.Header.Get("X-Real-Ip"); realIP != "" {
ipAddr = realIP
}
stw := &statusTrackingWriter{ResponseWriter: w}
next.ServeHTTP(stw, r)
duration := fmt.Sprintf("%.2fms", float64(time.Since(start))/float64(time.Millisecond))
status := stw.statusCode
if status == 0 {
status = http.StatusOK
}
log.InfoWithContext(r.Context(), "HTTP Request Processed",
"method", r.Method,
"path", r.URL.Path,
"status", status,
"duration", duration,
"remote_ip", ipAddr,
"user_agent", r.UserAgent(),
)
})
}

View file

@ -0,0 +1,74 @@
package server
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
log "github.com/nacorid/logger"
)
type LoggingMiddleware struct{}
func NewLoggingMiddleware() *LoggingMiddleware {
return &LoggingMiddleware{}
}
// OnCall logs native MCP tool requests streamed over SSE/WebSockets
func (lm *LoggingMiddleware) OnCall(next mcp.MethodHandler) mcp.MethodHandler {
return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) {
if method == "tools/call" {
start := time.Now()
result, err := next(ctx, method, request)
duration := fmt.Sprintf("%.2fms", float64(time.Since(start))/float64(time.Millisecond))
toolName := "unknown"
if callReq, ok := request.(*mcp.CallToolRequest); ok && callReq.Params != nil {
toolName = callReq.Params.Name
}
if err != nil {
log.WarnWithContext(ctx, "MCP Tool Failed",
"transport", "mcp_native",
"tool", toolName,
"duration", duration,
"error", err.Error(),
)
} else {
log.InfoWithContext(ctx, "MCP Tool Processed",
"transport", "mcp_native",
"tool", toolName,
"duration", duration,
)
}
return result, err
}
return next(ctx, method, request)
}
}
func (lm *LoggingMiddleware) OnCallHTTP(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ipAddr := r.RemoteAddr
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
ipAddr = strings.TrimSpace(strings.Split(forwarded, ",")[0])
}
next.ServeHTTP(w, r)
duration := fmt.Sprintf("%.2fms", float64(time.Since(start))/float64(time.Millisecond))
log.InfoWithContext(r.Context(), "REST Tool Processed",
"transport", "rest",
"path", r.URL.Path,
"duration", duration,
"remote_ip", ipAddr,
)
})
}

View file

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

View file

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

84
internal/server/auth.go Normal file
View file

@ -0,0 +1,84 @@
package server
import (
"context"
"crypto/ecdsa"
"crypto/rand"
"crypto/x509"
"encoding/hex"
"encoding/pem"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
x402http "github.com/x402-foundation/x402/go/v2/http"
)
type CDPAuth struct {
keyName string
privateKey *ecdsa.PrivateKey
signingMethod jwt.SigningMethod
}
func NewCDPAuth(keyName, privateKeyPEM string) (*CDPAuth, error) {
block, _ := pem.Decode([]byte(privateKeyPEM))
if block == nil {
return nil, fmt.Errorf("failed to decode PEM block")
}
var signingMethod jwt.SigningMethod
privKey, err := x509.ParseECPrivateKey(block.Bytes)
if err != nil {
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse private key: %w", err)
}
var ok bool
privKey, ok = parsed.(*ecdsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("not an ECDSA private key")
}
signingMethod = jwt.SigningMethodEdDSA
} else {
signingMethod = jwt.SigningMethodES256
}
return &CDPAuth{
keyName: keyName,
privateKey: privKey,
signingMethod: signingMethod,
}, nil
}
func (a *CDPAuth) generateBearerToken(method, path string) (string, error) {
nonceBytes := make([]byte, 16)
if _, err := rand.Read(nonceBytes); err != nil {
return "", err
}
nonce := hex.EncodeToString(nonceBytes)
uri := fmt.Sprintf("%s api.cdp.coinbase.com%s", method, path)
now := time.Now().Unix()
claims := jwt.MapClaims{
"sub": a.keyName,
"iss": "cdp",
"nbf": now,
"exp": now + 120,
"uris": []string{uri},
}
token := jwt.NewWithClaims(a.signingMethod, claims)
token.Header["kid"] = a.keyName
token.Header["nonce"] = nonce
return token.SignedString(a.privateKey)
}
func (a *CDPAuth) GetAuthHeaders(ctx context.Context) (x402http.AuthHeaders, error) {
verify, _ := a.generateBearerToken("POST", "/platform/v2/x402/verify")
settle, _ := a.generateBearerToken("POST", "/platform/v2/x402/settle")
supported, _ := a.generateBearerToken("GET", "/platform/v2/x402/supported")
return x402http.AuthHeaders{
Verify: map[string]string{"Authorization": "Bearer " + verify},
Settle: map[string]string{"Authorization": "Bearer " + settle},
Supported: map[string]string{"Authorization": "Bearer " + supported},
}, nil
}

View file

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

51
internal/server/cache.go Normal file
View file

@ -0,0 +1,51 @@
package server
import (
"sync"
"time"
)
const postCacheTTL = 5 * time.Minute
type ttlCacheEntry struct {
value any
exp time.Time
}
type ttlCache struct {
mu sync.Mutex
items map[string]ttlCacheEntry
ttl time.Duration
}
func newTTLCache(ttl time.Duration) *ttlCache {
return &ttlCache{
items: make(map[string]ttlCacheEntry),
ttl: ttl,
}
}
func (c *ttlCache) get(key string) (any, bool) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.items[key]
if !ok {
return nil, false
}
if time.Now().After(e.exp) {
delete(c.items, key)
return nil, false
}
return e.value, true
}
func (c *ttlCache) set(key string, value any) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = ttlCacheEntry{value: value, exp: time.Now().Add(c.ttl)}
}
type latestCacheValue struct {
posts []GetPostResponsePost
aid string
}

View file

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

View file

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

857
internal/server/mcp_test.go Normal file
View file

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

View file

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

View file

@ -0,0 +1,34 @@
package server
type NetworkInfo struct {
Network string
Asset string
Extra map[string]any
}
// NetworkRegistry maps human-readable names (slugs) to their full configuration parameters.
var NetworkRegistry = map[string]NetworkInfo{
"base": {
Network: "eip155:8453",
Asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
},
"sepolia": {
Network: "eip155:84532",
Asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
},
"polygon": {
Network: "eip155:137",
Asset: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359",
},
"arbitrum": {
Network: "eip155:42161",
Asset: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
},
"solana": {
Network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
Asset: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
Extra: map[string]any{
"feePayer": "BENrLoUbndxoNMUS5JXApGMtNykLjFXXixMtpDwDR9SP",
},
},
}

View 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)
}
}
}

View file

@ -8,8 +8,8 @@ import (
"strings" "strings"
"sync" "sync"
"github.com/mark3labs/mcp-go/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/mark3labs/mcp-go/server" log "github.com/nacorid/logger"
"golang.org/x/time/rate" "golang.org/x/time/rate"
) )
@ -45,23 +45,32 @@ func (m *RateLimitMiddleware) getLimiter(sessionID string) *rate.Limiter {
return limiter return limiter
} }
func (m *RateLimitMiddleware) OnCall(next server.ToolHandlerFunc) server.ToolHandlerFunc { func (m *RateLimitMiddleware) OnCall(next mcp.MethodHandler) mcp.MethodHandler {
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
if slices.Contains(m.request, req.Params.Name) { if method == "tools/call" {
var sessionID string callReq, ok := req.(*mcp.CallToolRequest)
session := server.ClientSessionFromContext(ctx) if ok && slices.Contains(m.request, callReq.Params.Name) {
if session == nil { var sessionID string
sessionID = getClientIP(req.Header) session := req.GetSession()
} else { if session == nil || session.ID() == "" {
sessionID = session.SessionID() if extra := req.GetExtra(); extra != nil {
} sessionID = getClientIP(req.GetExtra().Header)
limiter := m.getLimiter(sessionID) } else {
log.WarnWithContext(ctx, "No session or extra information found in request; using 'unknown' as session ID")
sessionID = "unknown"
}
} else {
sessionID = session.ID()
}
limiter := m.getLimiter(sessionID)
if !limiter.Allow() { if !limiter.Allow() {
return nil, fmt.Errorf("rate limit exceeded for session %s\nRatelimit: %v requests per second", sessionID, limiter.Limit()) return nil, fmt.Errorf("rate limit exceeded for session %s\nRatelimit: %v requests per second", sessionID, limiter.Limit())
}
} }
return next(ctx, method, req)
} }
return next(ctx, req) return next(ctx, method, req)
} }
} }

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -0,0 +1,264 @@
package server
import (
"database/sql"
"fmt"
"strings"
"testing"
"time"
"github.com/bluesky-social/indigo/api/bsky"
"github.com/nacorid/naco-api/internal/activitypub"
"github.com/nacorid/naco-api/internal/store"
)
// ---- getStyleProfile ----
func TestGetStyleProfileBuildsFresh(t *testing.T) {
ap := &fakeAP{posts: []*activitypub.Post{
{Author: "@a@mastodon.social", Content: "alpha post one", Text: "alpha post one", CreatedAt: time.Now()},
{Author: "@a@mastodon.social", Content: "alpha post two", Text: "alpha post two", CreatedAt: time.Now()},
}}
st := newFakeStore()
oll := &fakeOllama{embeddings: [][]float64{{0.1, 0.2}, {0.3, 0.4}}}
s := newServerWith(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}, st, oll)
res, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "@a@mastodon.social"})
if err != nil {
t.Fatalf("getStyleProfile error: %v", err)
}
var out GetStyleProfileResponse
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if out.Author != "@a@mastodon.social" {
t.Fatalf("expected author, got %q", out.Author)
}
if out.PostCount != 2 {
t.Fatalf("expected post count 2, got %d", out.PostCount)
}
if out.SourcePlatform != "ActivityPub" {
t.Fatalf("expected ActivityPub source, got %q", out.SourcePlatform)
}
if oll.embedCalls != 1 || oll.summCalls != 1 {
t.Fatalf("expected 1 embed and 1 summarize call, got %d/%d", oll.embedCalls, oll.summCalls)
}
}
func TestGetStyleProfileServesFreshCache(t *testing.T) {
// A profile modified less than a week ago is served without hitting the
// backend or ollama.
st := newFakeStore()
st.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{
UserID: "@a@mastodon.social",
Platform: "ActivityPub",
Embedding: "[0.1,0.2]",
TopKeywords: []string{"alpha"},
StyleSummary: "cached summary",
PostCount: 5,
ModifiedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true},
}
ap := &fakeAP{}
oll := &fakeOllama{}
s := newServerWith(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}, st, oll)
res, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "@a@mastodon.social"})
if err != nil {
t.Fatalf("getStyleProfile error: %v", err)
}
var out GetStyleProfileResponse
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if out.StyleSummary != "cached summary" {
t.Fatalf("expected cached summary, got %q", out.StyleSummary)
}
if ap.calls != 0 {
t.Fatalf("expected no backend call on cache hit, got %d", ap.calls)
}
if oll.summCalls != 0 {
t.Fatalf("expected no ollama call on cache hit, got %d", oll.summCalls)
}
}
func TestGetStyleProfileRebuildsStale(t *testing.T) {
// A stale (older than a week) cached profile triggers a rebuild.
st := newFakeStore()
st.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{
UserID: "@a@mastodon.social",
Platform: "ActivityPub",
Embedding: "[0.1,0.2]",
TopKeywords: []string{"old"},
StyleSummary: "stale summary",
PostCount: 5,
ModifiedAt: sql.NullTime{Time: time.Now().Add(-10 * 24 * time.Hour), Valid: true},
}
ap := &fakeAP{posts: []*activitypub.Post{
{Author: "@a@mastodon.social", Content: "fresh post", Text: "fresh post", CreatedAt: time.Now()},
}}
s := newServerWith(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{})
res, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "@a@mastodon.social"})
if err != nil {
t.Fatalf("getStyleProfile error: %v", err)
}
var out GetStyleProfileResponse
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if out.StyleSummary == "stale summary" {
t.Fatal("expected rebuild to replace stale summary")
}
if out.PostCount != 1 {
t.Fatalf("expected rebuilt post count 1, got %d", out.PostCount)
}
}
func TestGetStyleProfileDBErrorIsNotNoRows(t *testing.T) {
st := newFakeStore()
st.styleProfileErr = fmt.Errorf("db down")
s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{})
if _, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "@a@mastodon.social"}); err == nil || !strings.Contains(err.Error(), "db down") {
t.Fatalf("expected db error propagation, got %v", err)
}
}
func TestGetStyleProfileNoPostsFound(t *testing.T) {
// Empty backend result while building leads to a "no posts found" error.
ap := &fakeAP{} // no posts
s := newTestServer(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{})
_, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "@a@mastodon.social"})
if err == nil || !strings.Contains(err.Error(), "no posts found") {
t.Fatalf("expected no-posts error, got %v", err)
}
}
func TestGetStyleProfileOllamaEmbedFailure(t *testing.T) {
ap := &fakeAP{posts: []*activitypub.Post{{ID: "p1", Author: "@a@mastodon.social", Content: "post", Text: "post", CreatedAt: time.Now()}}}
oll := &fakeOllama{embedErr: fmt.Errorf("embedding failed")}
st := newFakeStore()
s := newServerWith(&fakeBluesky{}, ap, &fakeNostr{}, &fakeTwitter{}, st, oll)
if _, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "@a@mastodon.social"}); err == nil || !strings.Contains(err.Error(), "embedding") {
t.Fatalf("expected embed failure, got %v", err)
}
}
func TestGetStyleProfileBlueskyResolution(t *testing.T) {
// Bluesky resolves the did before building/persisting.
bsky := &fakeBluesky{feedViews: []*bsky.FeedDefs_FeedViewPost{{Post: bskyPostView("bsky post")}}}
oll := &fakeOllama{embeddings: [][]float64{{0.1, 0.2}}}
st := newFakeStore()
s := newServerWith(bsky, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, oll)
res, err := callTool(s, "getStyleProfile", GetStyleProfileRequest{Author: "did:plc:abc"})
if err != nil {
t.Fatalf("getStyleProfile error: %v", err)
}
var out GetStyleProfileResponse
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if out.Author != "did:plc:abc" {
t.Fatalf("expected resolved did, got %q", out.Author)
}
if out.SourcePlatform != "Bluesky" {
t.Fatalf("expected Bluesky source, got %q", out.SourcePlatform)
}
}
// ---- findSimilarUsers ----
func TestFindSimilarUsers(t *testing.T) {
st := newFakeStore()
st.similarRows = []store.FindSimilarUsersRow{
{UserID: "did:plc:other", Platform: "Bluesky", TopKeywords: []string{"x"}, StyleSummary: "s", PostCount: 3, Similarity: 90},
}
// Serve a fresh cached profile so requestStyleProfile doesn't need posts.
st.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{
UserID: "@a@mastodon.social",
Platform: "ActivityPub",
Embedding: "[0.1,0.2]",
ModifiedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true},
}
s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{})
res, err := callTool(s, "findSimilarUsers", FindSimilarUsersRequest{Author: "@a@mastodon.social", Limit: 10})
if err != nil {
t.Fatalf("findSimilarUsers error: %v", err)
}
var out FindSimilarUsersResponse
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(out.Matches) != 1 {
t.Fatalf("expected 1 match, got %d", len(out.Matches))
}
if out.Matches[0].Author != "did:plc:other" {
t.Fatalf("unexpected match: %+v", out.Matches[0])
}
}
func TestFindSimilarUsersExcludesSelf(t *testing.T) {
st := newFakeStore()
// Both rows share the query author's user ID; the self row must be excluded.
st.similarRows = []store.FindSimilarUsersRow{
{UserID: "@a@mastodon.social", Platform: "ActivityPub"}, // same as query author
{UserID: "did:plc:other", Platform: "Bluesky", Similarity: 80},
}
st.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{
UserID: "@a@mastodon.social",
Platform: "ActivityPub",
Embedding: "[0.1,0.2]",
ModifiedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true},
}
s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{})
res, err := callTool(s, "findSimilarUsers", FindSimilarUsersRequest{Author: "@a@mastodon.social", Limit: 10})
if err != nil {
t.Fatalf("findSimilarUsers error: %v", err)
}
var out FindSimilarUsersResponse
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(out.Matches) != 1 {
t.Fatalf("expected self to be excluded, got %d matches: %+v", len(out.Matches), out.Matches)
}
if out.Matches[0].Author != "did:plc:other" {
t.Fatalf("expected only the non-self match, got %+v", out.Matches)
}
}
func TestFindSimilarUsersEmptyMatches(t *testing.T) {
st := newFakeStore()
st.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{
UserID: "@a@mastodon.social",
Platform: "ActivityPub",
Embedding: "[0.1,0.2]",
ModifiedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true},
}
s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{})
res, err := callTool(s, "findSimilarUsers", FindSimilarUsersRequest{Author: "@a@mastodon.social"})
if err != nil {
t.Fatalf("findSimilarUsers error: %v", err)
}
var out FindSimilarUsersResponse
if err := jsonUnmarshal(t, textOf(t, res), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(out.Matches) != 0 {
t.Fatalf("expected empty matches, got %d", len(out.Matches))
}
}
func TestFindSimilarUsersDBError(t *testing.T) {
st := newFakeStore()
st.styleProfiles["@a@mastodon.social|ActivityPub"] = store.UserStyleProfile{
UserID: "@a@mastodon.social",
Platform: "ActivityPub",
Embedding: "[0.1,0.2]",
ModifiedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true},
}
st.findSimilarErr = fmt.Errorf("query failed")
s := newServerWith(&fakeBluesky{}, &fakeAP{}, &fakeNostr{}, &fakeTwitter{}, st, &fakeOllama{})
if _, err := callTool(s, "findSimilarUsers", FindSimilarUsersRequest{Author: "@a@mastodon.social"}); err == nil || !strings.Contains(err.Error(), "similar") {
t.Fatalf("expected similar-users error, got %v", err)
}
}

View 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)
}
}

View file

@ -1,41 +1,55 @@
package server package server
import "github.com/nacorid/naco-api/internal/actor"
type Bodyfield struct { type Bodyfield struct {
Type string `json:"type"` Type string `json:"type"`
Description string `json:"description"` Description string `json:"description"`
Required bool `json:"required,omitempty"` Required bool `json:"required,omitempty"`
} }
type InputSchema struct {
BodyFields map[string]any `json:"bodyFields,omitempty"`
Discoverable bool `json:"discoverable,omitempty"`
Method string `json:"method,omitempty"`
Type string `json:"type,omitempty"`
}
type Schema struct {
Input InputSchema `json:"input"`
Output map[string]any `json:"output"`
}
type GetLatestPostsRequest struct { type GetLatestPostsRequest struct {
Actor string `json:"actor" jsonschema_description:"Did or handle of an actor"` Author string `json:"author" jsonschema_description:"Did or handle of an author"`
N int `json:"n" jsonschema_description:"Number of posts to retrieve"` Limit int `json:"limit" jsonschema_description:"Number of posts to retrieve"`
} }
type GetLatestPostsResponse struct { type GetLatestPostsResponse struct {
Actor string `json:"actor" jsonschema_description:"Did of the actor"` Author string `json:"actor" jsonschema_description:"Did of the actor"`
Posts []GetPostResponsePost `json:"posts" jsonschema_description:"List of latest posts"` Posts []GetPostResponsePost `json:"posts" jsonschema_description:"List of latest posts"`
} }
type GetPostsInTimeRequest struct { type GetPostsInTimeRequest struct {
Actor string `json:"actor" jsonschema_description:"Did or handle of an actor"` Author string `json:"author" jsonschema_description:"Did or handle of an author"`
StartTime string `json:"startTime" jsonschema_description:"Start time in RFC3339 format"` StartTime string `json:"startTime" jsonschema_description:"Start time in RFC3339 format"`
EndTime string `json:"endTime" jsonschema_description:"End time in RFC3339 format"` EndTime string `json:"endTime" jsonschema_description:"End time in RFC3339 format"`
N int `json:"n" jsonschema_description:"Number of posts to retrieve at most"` Limit int `json:"limit" jsonschema_description:"Number of posts to retrieve at most"`
} }
type GetPostsInTimeResponse struct { type GetPostsInTimeResponse struct {
Actor string `json:"actor" jsonschema_description:"Did of the actor"` Author string `json:"author" jsonschema_description:"Did of the author"`
Posts []GetPostResponsePost `json:"posts" jsonschema_description:"List of posts in the timeframe"` Posts []GetPostResponsePost `json:"posts" jsonschema_description:"List of posts in the timeframe"`
} }
type GetPostRequest struct { type GetPostRequest struct {
Actor string `json:"actor" jsonschema_description:"Did or handle of an actor"` Author string `json:"author" jsonschema_description:"Did or handle of an author"`
RecordKey string `json:"rkey" jsonschema_description:"Record key of the post"` 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 { type GetPostResponsePost struct {
Actor string `json:"actor,omitempty" jsonschema_description:"Did of the actor"` Author string `json:"author,omitempty" jsonschema_description:"Did of the author"`
Content string `json:"content" jsonschema_description:"The post's content"` Content string `json:"content" jsonschema_description:"The post's content"`
Likes int64 `json:"likes" jsonschema_description:"Number of likes"` Likes int64 `json:"likes" jsonschema_description:"Number of likes"`
Reposts int64 `json:"reposts" jsonschema_description:"Number of reposts"` Reposts int64 `json:"reposts" jsonschema_description:"Number of reposts"`
@ -45,7 +59,7 @@ type GetPostResponsePost struct {
} }
type GetFeedRequest 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 { type GetFeedResponse struct {
@ -54,21 +68,182 @@ type GetFeedResponse struct {
} }
type GetStyleProfileRequest struct { type GetStyleProfileRequest struct {
Actor string `json:"actor" jsonschema_description:"Did or handle of an actor"` Author string `json:"author" jsonschema_description:"Did or handle of an author"`
} }
type GetStyleProfileResponse struct { type GetStyleProfileResponse struct {
Actor string `json:"actor" jsonschema_description:"Did of the actor"` Author string `json:"author" jsonschema_description:"Did of the author"`
PersonaEmbedding []float64 `json:"personaEmbedding" jsonschema_description:"Vector embedding representing the actor's persona"` PersonaEmbedding []float64 `json:"personaEmbedding" jsonschema_description:"Vector embedding representing the actor's persona"`
TopKeywords []string `json:"topKeywords" jsonschema_description:"List of top keywords used by the actor"` TopKeywords []string `json:"topKeywords" jsonschema_description:"List of top keywords used by the actor"`
StyleSummary string `json:"styleSummary" jsonschema_description:"Summary of the actor's writing style"` StyleSummary string `json:"styleSummary" jsonschema_description:"Summary of the author's writing style"`
PostCount int `json:"postCount" jsonschema_description:"Number of posts analyzed for the style profile"` PostCount int `json:"postCount" jsonschema_description:"Number of posts analyzed for the style profile"`
SourcePlatform string `json:"sourcePlatform,omitempty" jsonschema_description:"The platform from which the posts were sourced"` SourcePlatform string `json:"sourcePlatform,omitempty" jsonschema_description:"The platform from which the posts were sourced"`
} }
type FindSimilarUsersRequest struct {
Author string `json:"author" jsonschema_description:"Did or handle of an author to find similar profiles for"`
Limit int32 `json:"limit,omitempty" jsonschema_description:"Maximum number of similar profiles to return (default 10, max 50)"`
}
type FindSimilarUsersResponse struct {
Matches []SimilarUser `json:"matches" jsonschema_description:"Ranked list of similar profiles by persona embedding similarity"`
}
type ActorProfile = actor.Profile
type GetActorRequest struct {
Author string `json:"author" jsonschema_description:"Did, handle, npub or @handle of an actor"`
}
type GetActorResponse struct {
Actor ActorProfile `json:"actor" jsonschema_description:"Profile of the actor"`
}
type SearchActorsRequest struct {
Query string `json:"query" jsonschema_description:"Search query (handle, name or keyword)"`
Limit int `json:"limit,omitempty" jsonschema_description:"Maximum number of results (default 10, max 50)"`
}
type SearchActorsResponse struct {
Results []ActorProfile `json:"results" jsonschema_description:"Ranked list of matching actor profiles across platforms"`
}
type SimilarUser struct {
Author string `json:"author" jsonschema_description:"Did or handle of the similar author"`
Platform string `json:"platform" jsonschema_description:"Platform the similar author was found on"`
Similarity float64 `json:"similarity" jsonschema_description:"Cosine similarity of the persona embeddings, range 0..1"`
TopKeywords []string `json:"topKeywords,omitempty" jsonschema_description:"Top keywords used by the similar author"`
StyleSummary string `json:"styleSummary,omitempty" jsonschema_description:"Summary of the similar author's writing style"`
PostCount int `json:"postCount,omitempty" jsonschema_description:"Number of posts analyzed for the similar author"`
}
type DCAPMessage struct { type DCAPMessage struct {
V int `json:"v" jsonschema_description:"DCAP Version"` V int `json:"v" jsonschema_description:"DCAP Version"`
T string `json:"t" jsonschema_description:"DCAP Message Type"` T string `json:"t" jsonschema_description:"DCAP Message Type"`
TS int64 `json:"ts" jsonschema_description:"Unix timestamp of the message"` TS int64 `json:"ts" jsonschema_description:"Unix timestamp of the message"`
SID string `json:"sid" jsonschema_description:"Server ID, 8-12 characters"` SID string `json:"sid" jsonschema_description:"Server ID, 8-12 characters"`
} }
type platform int
const (
ActivityPub platform = iota
Bluesky
Nostr
Twitter
)
func (p platform) String() string {
return [...]string{"ActivityPub", "Bluesky", "Nostr", "Twitter"}[p]
}
func platformFromString(name string) (platform, bool) {
switch name {
case "ActivityPub":
return ActivityPub, true
case "Bluesky":
return Bluesky, true
case "Nostr":
return Nostr, true
case "Twitter":
return Twitter, true
default:
return -1, false
}
}
// --- Analytics types ---
type AnalyticsRequest struct {
Author string `json:"author" jsonschema_description:"Did or handle of an author"`
StartTime string `json:"startTime,omitempty" jsonschema_description:"Start time in RFC3339 format (default: 30 days ago)"`
EndTime string `json:"endTime,omitempty" jsonschema_description:"End time in RFC3339 format (default: now)"`
Limit int `json:"limit,omitempty" jsonschema_description:"Maximum number of posts to analyze (default: 50, max: 200)"`
Platform string `json:"platform,omitempty" jsonschema_description:"Platform to filter (default: all)"`
}
type EngagementMetricsResponse struct {
Author string `json:"author" jsonschema_description:"Author identifier"`
Metrics []EngagementMetricPoint `json:"metrics" jsonschema_description:"Engagement metrics per post"`
}
type EngagementMetricPoint struct {
PostID string `json:"postId" jsonschema_description:"Post identifier"`
Timestamp string `json:"timestamp" jsonschema_description:"RFC3339 timestamp"`
Likes int64 `json:"likes" jsonschema_description:"Number of likes"`
Reposts int64 `json:"reposts" jsonschema_description:"Number of reposts"`
Replies int64 `json:"replies" jsonschema_description:"Number of replies"`
Quotes int64 `json:"quotes" jsonschema_description:"Number of quotes"`
EngagementRate float64 `json:"engagementRate" jsonschema_description:"Engagement rate (interactions / impressions estimate)"`
}
type PostingPatternsResponse struct {
Author string `json:"author" jsonschema_description:"Author identifier"`
Heatmap [][]int `json:"heatmap" jsonschema_description:"24x7 matrix: hour (0-23) x day of week (0-6 = Sun-Sat)"`
PeakHours []int `json:"peakHours" jsonschema_description:"Top 3 hours with most posts (0-23)"`
PeakDays []int `json:"peakDays" jsonschema_description:"Top 3 days with most posts (0-6 = Sun-Sat)"`
AvgPostsPerDay float64 `json:"avgPostsPerDay" jsonschema_description:"Average posts per day"`
TotalPosts int `json:"totalPosts" jsonschema_description:"Total posts in time range"`
PostingDays int `json:"postingDays" jsonschema_description:"Number of days with at least one post"`
}
type TopicModelingResponse struct {
Author string `json:"author" jsonschema_description:"Author identifier"`
Topics []TopicCluster `json:"topics" jsonschema_description:"Identified topic clusters"`
}
type TopicCluster struct {
TopicID int `json:"topicId" jsonschema_description:"Cluster identifier"`
Label string `json:"label" jsonschema_description:"Human-readable topic label"`
Keywords []string `json:"keywords" jsonschema_description:"Top keywords for this topic"`
PostCount int `json:"postCount" jsonschema_description:"Number of posts in this topic"`
Percentage float64 `json:"percentage" jsonschema_description:"Percentage of total posts"`
SamplePosts []string `json:"samplePosts" jsonschema_description:"Sample post texts (max 3)"`
}
type SentimentTrendsResponse struct {
Author string `json:"author" jsonschema_description:"Author identifier"`
TimeSeries []SentimentPoint `json:"timeSeries" jsonschema_description:"Sentiment over time"`
OverallSentiment SentimentSummary `json:"overallSentiment" jsonschema_description:"Overall sentiment summary"`
DominantEmotions []EmotionScore `json:"dominantEmotions" jsonschema_description:"Most frequent emotions"`
SentimentShifts []SentimentShift `json:"sentimentShifts" jsonschema_description:"Significant sentiment changes"`
}
type SentimentPoint struct {
Timestamp string `json:"timestamp" jsonschema_description:"RFC3339 timestamp (bucketed by day)"`
Positive float64 `json:"positive" jsonschema_description:"Positive sentiment ratio (0-1)"`
Negative float64 `json:"negative" jsonschema_description:"Negative sentiment ratio (0-1)"`
Neutral float64 `json:"neutral" jsonschema_description:"Neutral sentiment ratio (0-1)"`
PostCount int `json:"postCount" jsonschema_description:"Posts in this time bucket"`
}
type SentimentSummary struct {
Positive float64 `json:"positive" jsonschema_description:"Overall positive ratio"`
Negative float64 `json:"negative" jsonschema_description:"Overall negative ratio"`
Neutral float64 `json:"neutral" jsonschema_description:"Overall neutral ratio"`
}
type EmotionScore struct {
Emotion string `json:"emotion" jsonschema_description:"Emotion name"`
Score float64 `json:"score" jsonschema_description:"Frequency score (0-1)"`
}
type SentimentShift struct {
Timestamp string `json:"timestamp" jsonschema_description:"When the shift occurred"`
FromSentiment string `json:"fromSentiment" jsonschema_description:"Previous dominant sentiment"`
ToSentiment string `json:"toSentiment" jsonschema_description:"New dominant sentiment"`
Magnitude float64 `json:"magnitude" jsonschema_description:"Shift magnitude (0-1)"`
}
type EngagementDriversResponse struct {
Author string `json:"author" jsonschema_description:"Author identifier"`
Drivers []EngagementDriver `json:"drivers" jsonschema_description:"Topics/keywords driving engagement"`
}
type EngagementDriver struct {
Topic string `json:"topic" jsonschema_description:"Topic or keyword"`
AvgEngagement float64 `json:"avgEngagement" jsonschema_description:"Average engagement rate for posts with this topic"`
Correlation float64 `json:"correlation" jsonschema_description:"Correlation coefficient with engagement (-1 to 1)"`
PostCount int `json:"postCount" jsonschema_description:"Number of posts containing this topic"`
SamplePosts []string `json:"samplePosts" jsonschema_description:"Sample posts (max 2)"`
}

View file

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

120
internal/server/webhook.go Normal file
View file

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

View 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
}

View 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
}

View 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
}

View 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
}

View file

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

View 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
}

View 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
}

View 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
}

View file

@ -12,7 +12,7 @@ import (
) )
const getStyleProfile = `-- name: GetStyleProfile :one 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 WHERE user_id = $1 AND platform = $2
` `
@ -34,6 +34,9 @@ func (q *Queries) GetStyleProfile(ctx context.Context, arg GetStyleProfileParams
&i.PostCount, &i.PostCount,
&i.ModifiedAt, &i.ModifiedAt,
&i.CreatedAt, &i.CreatedAt,
&i.ModelVersion,
&i.SchemaVersion,
&i.AlgorithmVersion,
) )
return i, err return i, err
} }

View 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
}

View 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
}

View file

@ -13,14 +13,14 @@ import (
const upsertStyleProfile = `-- name: UpsertStyleProfile :exec const upsertStyleProfile = `-- name: UpsertStyleProfile :exec
INSERT INTO user_style_profiles (user_id, platform, embedding, top_keywords, style_summary, post_count) 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) ON CONFLICT (user_id, platform)
DO UPDATE SET DO UPDATE SET
embedding = EXCLUDED.embedding, embedding = EXCLUDED.embedding,
top_keywords = EXCLUDED.top_keywords, top_keywords = EXCLUDED.top_keywords,
style_summary = EXCLUDED.style_summary, style_summary = EXCLUDED.style_summary,
post_count = EXCLUDED.post_count, post_count = EXCLUDED.post_count,
updated_at = NOW() modified_at = NOW()
` `
type UpsertStyleProfileParams struct { type UpsertStyleProfileParams struct {

View file

@ -0,0 +1,134 @@
package store
import (
"context"
"database/sql"
"fmt"
"os"
"strings"
"testing"
"time"
_ "github.com/lib/pq"
)
// TestStoreRoundTrip exercises the sqlc queries against a real Postgres+pgvector database.
// It is skipped unless TEST_DATABASE_URL is set (e.g. a local instance with the vector
// extension available), so it never fails in environments without a database.
func TestStoreRoundTrip(t *testing.T) {
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set; skipping store integration test")
}
ctx := context.Background()
conn, err := sql.Open("postgres", dsn)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if err := conn.PingContext(ctx); err != nil {
t.Fatalf("cannot reach test database: %v", err)
}
if err := Migrate(ctx, conn, "sql/schema"); err != nil {
t.Fatalf("migrate: %v", err)
}
q := New(conn)
uid := fmt.Sprintf("test_%d", time.Now().UnixNano())
emb := "[" + strings.TrimSuffix(strings.Repeat("0.0,", 1024), ",") + "]"
if err := q.UpsertStyleProfile(ctx, UpsertStyleProfileParams{
UserID: uid,
Platform: "Bluesky",
Embedding: emb,
TopKeywords: []string{"alpha", "beta"},
StyleSummary: "summary",
PostCount: 5,
}); err != nil {
t.Fatalf("upsert: %v", err)
}
p, err := q.GetStyleProfile(ctx, GetStyleProfileParams{UserID: uid, Platform: "Bluesky"})
if err != nil {
t.Fatalf("get: %v", err)
}
if p.UserID != uid || p.Platform != "Bluesky" || p.PostCount != 5 {
t.Fatalf("unexpected profile: %+v", p)
}
rows, err := q.FindSimilarUsers(ctx, FindSimilarUsersParams{QueryEmbedding: emb, LimitVal: 10})
if err != nil {
t.Fatalf("find similar: %v", err)
}
found := false
for _, r := range rows {
if r.UserID == uid {
found = true
}
}
if !found {
t.Fatalf("expected newly inserted profile to be returned by similarity search")
}
}
// TestStoreSaveTransaction exercises SaveTransaction against a real Postgres database.
// It is skipped unless TEST_DATABASE_URL is set.
func TestStoreSaveTransaction(t *testing.T) {
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set; skipping store integration test")
}
ctx := context.Background()
conn, err := sql.Open("postgres", dsn)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if err := conn.PingContext(ctx); err != nil {
t.Fatalf("cannot reach test database: %v", err)
}
if err := Migrate(ctx, conn, "sql/schema"); err != nil {
t.Fatalf("migrate: %v", err)
}
q := New(conn)
txHash := fmt.Sprintf("0x_%d", time.Now().Unix())
amount := "10.0"
asset := "ETH"
network := "eip155:1"
now := time.Now().Unix()
recipient := "0x" + strings.TrimLeft(fmt.Sprintf("%x", now%100000000), "0")
sender := "0x" + strings.TrimLeft(fmt.Sprintf("%x", now%100000000+0xab), "0")
ret, err := q.SaveTransaction(ctx, SaveTransactionParams{
X402TransactionHash: txHash,
Amount: amount,
Asset: asset,
Network: network,
Recipient: recipient,
Sender: sender,
})
if err != nil {
t.Fatalf("SaveTransaction error: %v", err)
}
if len(ret) != 1 {
t.Fatalf("expected 1 result, got %d", len(ret))
}
id := ret[0].ID
if id <= 0 {
t.Fatalf("expected positive ID, got %d", id)
}
// Verify the returned row has the expected fields
if !ret[0].CreatedAt.Valid {
t.Fatal("expected CreatedAt to be set")
}
// Verify we can retrieve the transaction back
// Note: we'd need a SELECT query for full verification, but the insert
// returning id and created_at is the critical path tested here
t.Logf("SaveTransaction successful: id=%d, hash=%s", id, txHash)
}

106
internal/store/migrate.go Normal file
View 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()
}

View file

@ -6,8 +6,43 @@ package store
import ( import (
"database/sql" "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 { type Transaction struct {
ID int64 ID int64
X402TransactionHash string X402TransactionHash string
@ -19,14 +54,26 @@ type Transaction struct {
CreatedAt sql.NullTime CreatedAt sql.NullTime
} }
type UserStyleProfile struct { type UserStylePost struct {
ID int64 ID int64
UserID string UserID string
Platform string Platform string
Embedding interface{} PostID string
TopKeywords []string PostText string
StyleSummary string CreatedAt time.Time
PostCount int32 }
ModifiedAt sql.NullTime
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
ModelVersion int32
SchemaVersion int32
AlgorithmVersion int32
} }

347
internal/twitter/client.go Normal file
View file

@ -0,0 +1,347 @@
package twitter
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
log "github.com/nacorid/logger"
)
func parseTwitterTime(s string) time.Time {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
log.Warn("Failed to parse Twitter timestamp", "timestamp", s, "error", err)
return time.Time{}
}
return t
}
const apiBase = "https://api.x.com/2"
type Client struct {
httpClient *http.Client
bearerToken string
}
func NewTwitterClient(bearerToken string) *Client {
return &Client{
httpClient: http.DefaultClient,
bearerToken: bearerToken,
}
}
type Post struct {
ID string
Author string
Content string
CreatedAt time.Time
Likes int
Reposts int
Quotes int
Replies int
}
type userResponse struct {
Data struct {
ID string `json:"id"`
Name string `json:"name"`
Username string `json:"username"`
} `json:"data"`
Errors []struct {
Title string `json:"title"`
Detail string `json:"detail"`
} `json:"errors"`
}
type tweetMetrics struct {
RetweetCount int `json:"retweet_count"`
ReplyCount int `json:"reply_count"`
LikeCount int `json:"like_count"`
QuoteCount int `json:"quote_count"`
}
type tweetData struct {
ID string `json:"id"`
Text string `json:"text"`
CreatedAt string `json:"created_at"`
PublicMetrics tweetMetrics `json:"public_metrics"`
}
type tweetsResponse struct {
Data []tweetData `json:"data"`
Includes struct {
Users []struct {
Username string `json:"username"`
} `json:"users"`
} `json:"includes"`
Errors []struct {
Title string `json:"title"`
Detail string `json:"detail"`
} `json:"errors"`
}
type singleTweetResponse struct {
Data struct {
ID string `json:"id"`
Text string `json:"text"`
CreatedAt string `json:"created_at"`
PublicMetrics tweetMetrics `json:"public_metrics"`
} `json:"data"`
Includes struct {
Users []struct {
Username string `json:"username"`
} `json:"users"`
} `json:"includes"`
Errors []struct {
Title string `json:"title"`
Detail string `json:"detail"`
} `json:"errors"`
}
// doGet issues a GET and retries with exponential backoff when Twitter returns HTTP 429
// (Too Many Requests). It honors the x-rate-limit-* headers and caps retries so a
// sustained rate limit still surfaces as a real error to the caller (and thus isn't billed).
func (c *Client) doGet(ctx context.Context, endpoint string) ([]byte, error) {
const maxRetries = 4
backoff := 500 * time.Millisecond
for attempt := 0; ; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.bearerToken)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to do request: %w", err)
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode == http.StatusTooManyRequests {
if attempt >= maxRetries {
return nil, fmt.Errorf("twitter rate limited after retries: %s", resp.Status)
}
// Honor x-rate-limit-* headers first
remaining := resp.Header.Get("x-rate-limit-remaining")
reset := resp.Header.Get("x-rate-limit-reset")
if ra := remaining; ra != "" {
if rem, perr := strconv.Atoi(ra); perr == nil {
if rem <= 0 && reset != "" {
if rst, perr2 := strconv.Atoi(reset); perr2 == nil {
// Wait until the rate limit resets (Unix timestamp)
now := time.Now().Unix()
rst64 := int64(rst)
wait := time.Duration(rst64-now) * time.Second
if wait > 0 {
log.WarnWithContext(ctx, "twitter rate limited, waiting until reset", "reset", reset, "wait", wait.String())
select {
case <-time.After(wait):
case <-ctx.Done():
return nil, ctx.Err()
}
backoff = 500 * time.Millisecond
continue
}
}
}
}
}
// Fall back to Retry-After if headers not useful
if ra := resp.Header.Get("Retry-After"); ra != "" {
if secs, perr := strconv.Atoi(ra); perr == nil && secs > 0 {
backoff = time.Duration(secs) * time.Second
}
}
log.WarnWithContext(ctx, "twitter rate limited, backing off", "attempt", attempt+1, "wait", backoff.String())
select {
case <-time.After(backoff):
case <-ctx.Done():
return nil, ctx.Err()
}
backoff *= 2
continue
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("twitter error: %s: %s", resp.Status, string(body))
}
return body, nil
}
}
func (c *Client) resolveUser(ctx context.Context, username string) (string, error) {
endpoint := fmt.Sprintf("%s/users/by/username/%s", apiBase, url.PathEscape(username))
body, err := c.doGet(ctx, endpoint)
if err != nil {
return "", err
}
var ur userResponse
if err := json.Unmarshal(body, &ur); err != nil {
return "", fmt.Errorf("failed to unmarshal user response: %w", err)
}
if len(ur.Errors) > 0 {
return "", fmt.Errorf("twitter error: %s: %s", ur.Errors[0].Title, ur.Errors[0].Detail)
}
return ur.Data.ID, nil
}
func (c *Client) GetLatestNPosts(ctx context.Context, actor string, limit int) ([]*Post, error) {
if c.bearerToken == "" {
return nil, fmt.Errorf("twitter bearer token not configured")
}
username := strings.TrimPrefix(actor, "@")
userID, err := c.resolveUser(ctx, username)
if err != nil {
return nil, err
}
if limit > 100 {
limit = 100
}
if limit < 5 {
limit = 5
}
endpoint := fmt.Sprintf("%s/users/%s/tweets?max_results=%d&tweet.fields=created_at,public_metrics&exclude=retweets,replies",
apiBase, url.PathEscape(userID), limit)
body, err := c.doGet(ctx, endpoint)
if err != nil {
return nil, err
}
var tr tweetsResponse
if err := json.Unmarshal(body, &tr); err != nil {
return nil, fmt.Errorf("failed to unmarshal tweets response: %w", err)
}
if len(tr.Errors) > 0 {
return nil, fmt.Errorf("twitter error: %s: %s", tr.Errors[0].Title, tr.Errors[0].Detail)
}
posts := make([]*Post, 0, len(tr.Data))
for _, t := range tr.Data {
created := parseTwitterTime(t.CreatedAt)
posts = append(posts, &Post{
ID: t.ID,
Author: userID,
Content: t.Text,
CreatedAt: created,
Likes: t.PublicMetrics.LikeCount,
Reposts: t.PublicMetrics.RetweetCount,
Quotes: t.PublicMetrics.QuoteCount,
Replies: t.PublicMetrics.ReplyCount,
})
}
return posts, nil
}
func (c *Client) GetPostsInTimeframe(ctx context.Context, actor string, start, end time.Time, limit int) ([]*Post, error) {
if c.bearerToken == "" {
return nil, fmt.Errorf("twitter bearer token not configured")
}
username := strings.TrimPrefix(actor, "@")
userID, err := c.resolveUser(ctx, username)
if err != nil {
return nil, err
}
if limit > 100 {
limit = 100
}
if limit < 5 {
limit = 5
}
endpoint := fmt.Sprintf("%s/users/%s/tweets?start_time=%s&end_time=%s&max_results=%d&tweet.fields=created_at,public_metrics&exclude=retweets,replies",
apiBase, url.PathEscape(userID), start.Format(time.RFC3339), end.Format(time.RFC3339), limit)
body, err := c.doGet(ctx, endpoint)
if err != nil {
return nil, err
}
var tr tweetsResponse
if err := json.Unmarshal(body, &tr); err != nil {
return nil, fmt.Errorf("failed to unmarshal tweets response: %w", err)
}
if len(tr.Errors) > 0 {
return nil, fmt.Errorf("twitter error: %s: %s", tr.Errors[0].Title, tr.Errors[0].Detail)
}
posts := make([]*Post, 0, len(tr.Data))
for _, t := range tr.Data {
created := parseTwitterTime(t.CreatedAt)
posts = append(posts, &Post{
ID: t.ID,
Author: userID,
Content: t.Text,
CreatedAt: created,
Likes: t.PublicMetrics.LikeCount,
Reposts: t.PublicMetrics.RetweetCount,
Quotes: t.PublicMetrics.QuoteCount,
Replies: t.PublicMetrics.ReplyCount,
})
}
return posts, nil
}
func (c *Client) GetPost(ctx context.Context, id string) (*Post, error) {
if c.bearerToken == "" {
return nil, fmt.Errorf("twitter bearer token not configured")
}
endpoint := fmt.Sprintf("%s/tweets/%s?tweet.fields=created_at,public_metrics&expansions=author_id&user.fields=username",
apiBase, url.PathEscape(id))
body, err := c.doGet(ctx, endpoint)
if err != nil {
return nil, err
}
var tr singleTweetResponse
if err := json.Unmarshal(body, &tr); err != nil {
return nil, fmt.Errorf("failed to unmarshal tweet response: %w", err)
}
if len(tr.Errors) > 0 {
return nil, fmt.Errorf("twitter error: %s: %s", tr.Errors[0].Title, tr.Errors[0].Detail)
}
if tr.Data.ID == "" {
return nil, fmt.Errorf("tweet %q not found", id)
}
author := id
if len(tr.Includes.Users) > 0 {
author = tr.Includes.Users[0].Username
}
created, _ := time.Parse(time.RFC3339, tr.Data.CreatedAt)
return &Post{
ID: tr.Data.ID,
Author: author,
Content: tr.Data.Text,
CreatedAt: created,
Likes: tr.Data.PublicMetrics.LikeCount,
Reposts: tr.Data.PublicMetrics.RetweetCount,
Quotes: tr.Data.PublicMetrics.QuoteCount,
Replies: tr.Data.PublicMetrics.ReplyCount,
}, nil
}
type userFields struct {
ID string `json:"id"`
Name string `json:"name"`
Username string `json:"username"`
Description string `json:"description"`
ProfileImageURL string `json:"profile_image_url"`
PublicMetrics struct {
FollowersCount int `json:"followers_count"`
FollowingCount int `json:"following_count"`
TweetCount int `json:"tweet_count"`
} `json:"public_metrics"`
}

View file

@ -0,0 +1,378 @@
package twitter
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
// newTestClient returns a Client whose requests are rewritten to the given httptest server.
func newTestClient(serverURL string) *Client {
c := NewTwitterClient("test-token")
host := strings.TrimPrefix(serverURL, "http://")
c.httpClient = &http.Client{
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
u := *r.URL
u.Scheme = "http"
u.Host = host
r2 := r.Clone(r.Context())
r2.URL = &u
return http.DefaultTransport.RoundTrip(r2)
}),
}
return c
}
func TestGetLatestNPostsNoToken(t *testing.T) {
c := NewTwitterClient("")
if _, err := c.GetLatestNPosts(context.Background(), "alice", 10); err == nil {
t.Fatal("expected error when bearer token is empty")
}
}
func TestGetLatestNPosts(t *testing.T) {
var tweetsURL string
mux := http.NewServeMux()
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer test-token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
fmt.Fprint(w, `{"data":{"id":"12345","name":"Test","username":"testuser"}}`)
})
mux.HandleFunc("/2/users/12345/tweets", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer test-token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
tweetsURL = r.URL.String()
fmt.Fprint(w, `{"data":[{"id":"1","text":"hello world","created_at":"2024-01-01T00:00:00Z"}]}`)
})
server := httptest.NewServer(mux)
defer server.Close()
c := newTestClient(server.URL)
posts, err := c.GetLatestNPosts(context.Background(), "@testuser", 10)
if err != nil {
t.Fatalf("GetLatestNPosts error: %v", err)
}
if len(posts) != 1 {
t.Fatalf("expected 1 post, got %d", len(posts))
}
if posts[0].Content != "hello world" || posts[0].Author != "12345" {
t.Fatalf("unexpected post: %+v", posts[0])
}
if !strings.Contains(tweetsURL, "max_results=10") {
t.Fatalf("expected max_results=10, got %q", tweetsURL)
}
}
func TestGetLatestNPostsLimitClamp(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"data":{"id":"1","username":"u"}}`)
})
mux.HandleFunc("/2/users/1/tweets", func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("max_results"); got != "5" {
t.Fatalf("expected clamped max_results=5 for limit=3, got %q", got)
}
fmt.Fprint(w, `{"data":[]}`)
})
server := httptest.NewServer(mux)
defer server.Close()
c := newTestClient(server.URL)
if _, err := c.GetLatestNPosts(context.Background(), "u", 3); err != nil {
t.Fatalf("error: %v", err)
}
}
func TestGetLatestNPostsHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
c := newTestClient(server.URL)
if _, err := c.GetLatestNPosts(context.Background(), "u", 10); err == nil {
t.Fatal("expected error on non-200 status")
}
}
func TestGetPost(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"data":{"id":"12345","username":"testuser"}}`)
})
mux.HandleFunc("/2/tweets/999", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer test-token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
fmt.Fprint(w, `{"data":{"id":"999","text":"a single tweet","created_at":"2024-02-02T00:00:00Z","public_metrics":{"retweet_count":2,"reply_count":1,"like_count":5,"quote_count":0}},"includes":{"users":[{"username":"testuser"}]}}`)
})
server := httptest.NewServer(mux)
defer server.Close()
c := newTestClient(server.URL)
p, err := c.GetPost(context.Background(), "999")
if err != nil {
t.Fatalf("GetPost error: %v", err)
}
if p.ID != "999" || p.Content != "a single tweet" || p.Author != "testuser" {
t.Fatalf("unexpected post: %+v", p)
}
if p.Likes != 5 || p.Reposts != 2 || p.Replies != 1 || p.Quotes != 0 {
t.Fatalf("unexpected metrics: %+v", p)
}
}
func TestGetPostsInTimeframe(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"data":{"id":"12345","username":"testuser"}}`)
})
mux.HandleFunc("/2/users/12345/tweets", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer test-token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
if r.URL.Query().Get("start_time") == "" || r.URL.Query().Get("end_time") == "" {
t.Fatalf("expected start_time and end_time query params")
}
fmt.Fprint(w, `{"data":[{"id":"1","text":"timed post","created_at":"2024-03-03T00:00:00Z","public_metrics":{"retweet_count":0,"reply_count":0,"like_count":3,"quote_count":0}}]}`)
})
server := httptest.NewServer(mux)
defer server.Close()
c := newTestClient(server.URL)
start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC)
posts, err := c.GetPostsInTimeframe(context.Background(), "testuser", start, end, 10)
if err != nil {
t.Fatalf("GetPostsInTimeframe error: %v", err)
}
if len(posts) != 1 {
t.Fatalf("expected 1 post, got %d", len(posts))
}
if posts[0].Content != "timed post" || posts[0].Likes != 3 {
t.Fatalf("unexpected post: %+v", posts[0])
}
}
func TestRateLimitRetryWithResetHeader(t *testing.T) {
callCount := 0
mux := http.NewServeMux()
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
callCount++
if callCount == 1 {
// First call: rate limited with reset header
w.Header().Set("x-rate-limit-remaining", "0")
w.Header().Set("x-rate-limit-reset", fmt.Sprintf("%d", time.Now().Add(100*time.Millisecond).Unix()))
w.WriteHeader(http.StatusTooManyRequests)
return
}
// Second call: success
fmt.Fprint(w, `{"data":{"id":"12345","name":"Test","username":"testuser"}}`)
})
mux.HandleFunc("/2/users/12345/tweets", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"data":[{"id":"1","text":"hello","created_at":"2024-01-01T00:00:00Z"}]}`)
})
server := httptest.NewServer(mux)
defer server.Close()
c := newTestClient(server.URL)
// Use a short context timeout to avoid waiting too long
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
posts, err := c.GetLatestNPosts(ctx, "@testuser", 10)
if err != nil {
t.Fatalf("GetLatestNPosts error after retry: %v", err)
}
if callCount != 2 {
t.Fatalf("expected 2 calls (1 retry), got %d", callCount)
}
if len(posts) != 1 {
t.Fatalf("expected 1 post after retry, got %d", len(posts))
}
}
func TestRateLimitRetryWithRetryAfterHeader(t *testing.T) {
callCount := 0
mux := http.NewServeMux()
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
callCount++
if callCount == 1 {
// First call: rate limited with Retry-After header
w.Header().Set("Retry-After", "1") // 1 second
w.WriteHeader(http.StatusTooManyRequests)
return
}
// Second call: success
fmt.Fprint(w, `{"data":{"id":"12345","name":"Test","username":"testuser"}}`)
})
mux.HandleFunc("/2/users/12345/tweets", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"data":[{"id":"1","text":"hello","created_at":"2024-01-01T00:00:00Z"}]}`)
})
server := httptest.NewServer(mux)
defer server.Close()
c := newTestClient(server.URL)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
posts, err := c.GetLatestNPosts(ctx, "@testuser", 10)
if err != nil {
t.Fatalf("GetLatestNPosts error after retry: %v", err)
}
if callCount != 2 {
t.Fatalf("expected 2 calls (1 retry), got %d", callCount)
}
if len(posts) != 1 {
t.Fatalf("expected 1 post after retry, got %d", len(posts))
}
}
func TestRateLimitExhaustedAfterMaxRetries(t *testing.T) {
callCount := 0
mux := http.NewServeMux()
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
callCount++
// Always rate limited - no reset header, no Retry-After, so exponential backoff is used
w.Header().Set("x-rate-limit-remaining", "0")
// Don't set x-rate-limit-reset or Retry-After to force exponential backoff
w.WriteHeader(http.StatusTooManyRequests)
})
server := httptest.NewServer(mux)
defer server.Close()
c := newTestClient(server.URL)
// Use a timeout long enough for all retries (4 retries with exponential backoff: 500ms, 1s, 2s, 4s = ~7.5s)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
_, err := c.GetLatestNPosts(ctx, "@testuser", 10)
if err == nil {
t.Fatal("expected error after max retries exhausted")
}
// Should have tried maxRetries (4) + 1 initial = 5 times
if callCount < 5 {
t.Fatalf("expected at least 5 attempts (1 initial + 4 retries), got %d", callCount)
}
if !strings.Contains(err.Error(), "rate limited after retries") {
t.Fatalf("expected 'rate limited after retries' error, got %v", err)
}
}
func TestUserLookupNotFound(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
fmt.Fprint(w, `{"errors":[{"title":"Not Found","detail":"User not found"}]}`)
})
server := httptest.NewServer(mux)
defer server.Close()
c := newTestClient(server.URL)
_, err := c.GetLatestNPosts(context.Background(), "@nonexistent", 10)
if err == nil {
t.Fatal("expected error for user not found")
}
if !strings.Contains(err.Error(), "twitter error") || !strings.Contains(err.Error(), "Not Found") {
t.Fatalf("expected 'Not Found' error, got %v", err)
}
}
func TestUserLookupUnauthorized(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprint(w, `{"errors":[{"title":"Unauthorized","detail":"Invalid token"}]}`)
})
server := httptest.NewServer(mux)
defer server.Close()
c := newTestClient(server.URL)
_, err := c.GetLatestNPosts(context.Background(), "@testuser", 10)
if err == nil {
t.Fatal("expected error for unauthorized")
}
if !strings.Contains(err.Error(), "twitter error") || !strings.Contains(err.Error(), "Unauthorized") {
t.Fatalf("expected 'Unauthorized' error, got %v", err)
}
}
func TestUserLookupRateLimited(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Retry-After", "1")
w.WriteHeader(http.StatusTooManyRequests)
})
server := httptest.NewServer(mux)
defer server.Close()
c := newTestClient(server.URL)
// Need enough time for max retries (4 retries * 1 second = 4 seconds) + initial
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := c.GetLatestNPosts(ctx, "@testuser", 10)
if err == nil {
t.Fatal("expected error for rate limited user lookup")
}
if !strings.Contains(err.Error(), "twitter error") && !strings.Contains(err.Error(), "rate limited after retries") {
t.Fatalf("expected twitter error or rate limited after retries, got %v", err)
}
}
func TestContextCancellationDuringRetry(t *testing.T) {
callCount := 0
mux := http.NewServeMux()
mux.HandleFunc("/2/users/by/username/", func(w http.ResponseWriter, r *http.Request) {
callCount++
// First call: rate limited with long reset time
if callCount == 1 {
w.Header().Set("x-rate-limit-remaining", "0")
w.Header().Set("x-rate-limit-reset", fmt.Sprintf("%d", time.Now().Add(10*time.Second).Unix()))
w.WriteHeader(http.StatusTooManyRequests)
return
}
})
server := httptest.NewServer(mux)
defer server.Close()
c := newTestClient(server.URL)
// Cancel context immediately after first call
ctx, cancel := context.WithCancel(context.Background())
// Start the request in a goroutine
done := make(chan error, 1)
go func() {
_, err := c.GetLatestNPosts(ctx, "@testuser", 10)
done <- err
}()
// Give it a moment to make the first call, then cancel
time.Sleep(100 * time.Millisecond)
cancel()
select {
case err := <-done:
if err == nil {
t.Fatal("expected error from context cancellation")
}
if !strings.Contains(err.Error(), "context canceled") && !strings.Contains(err.Error(), "context cancelled") {
t.Fatalf("expected context cancellation error, got %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("test timed out waiting for cancellation")
}
}

View file

@ -3,7 +3,7 @@ package utils
import "sync/atomic" import "sync/atomic"
type Counter struct { type Counter struct {
valut atomic.Int64 value atomic.Int64
} }
func NewCounter() *Counter { func NewCounter() *Counter {
@ -11,13 +11,13 @@ func NewCounter() *Counter {
} }
func (c *Counter) Increment() { func (c *Counter) Increment() {
c.valut.Add(1) c.value.Add(1)
} }
func (c *Counter) Get() int64 { func (c *Counter) Get() int64 {
return c.valut.Load() return c.value.Load()
} }
func (c *Counter) Reset() { func (c *Counter) Reset() {
c.valut.Store(0) c.value.Store(0)
} }

View file

@ -7,12 +7,26 @@ import (
"strings" "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) { switch v := value.(type) {
case []float64: case []float64:
return v, nil return v, nil
case []interface{}: case []any:
vec := make([]float64, len(v)) vec := make([]float64, len(v))
for i, x := range v { for i, x := range v {
f, ok := x.(float64) f, ok := x.(float64)
@ -24,24 +38,24 @@ func ParseEmbedding(value interface{}) ([]float64, error) {
return vec, nil return vec, nil
case string: case string:
// pgvector returns a string like "[0.12, 0.53, ...]" sometimes return parseVectorString(v)
s := strings.Trim(v, "[]")
parts := strings.Split(s, ",") case []byte:
vec := make([]float64, len(parts)) return parseVectorString(string(v))
for i, p := range parts {
f, err := strconv.ParseFloat(strings.TrimSpace(p), 64)
if err != nil {
return nil, err
}
vec[i] = f
}
return vec, nil
default: default:
return nil, fmt.Errorf("unexpected embedding type: %T", v) return nil, fmt.Errorf("unexpected embedding type: %T", v)
} }
} }
func FormatVector(v []float64) string {
parts := make([]string, len(v))
for i, x := range v {
parts[i] = strconv.FormatFloat(x, 'f', -1, 64)
}
return "[" + strings.Join(parts, ",") + "]"
}
func AverageVector(vs [][]float64) []float64 { func AverageVector(vs [][]float64) []float64 {
dim := len(vs[0]) dim := len(vs[0])
avg := make([]float64, dim) avg := make([]float64, dim)
@ -60,7 +74,7 @@ func AverageVector(vs [][]float64) []float64 {
func TopWords(posts []string, stopWords map[string]struct{}, limit int) []string { func TopWords(posts []string, stopWords map[string]struct{}, limit int) []string {
counts := map[string]int{} counts := map[string]int{}
for _, p := range posts { for _, p := range posts {
for _, w := range strings.Fields(strings.ToLower(p)) { for w := range strings.FieldsSeq(strings.ToLower(p)) {
if len(w) < 4 { if len(w) < 4 {
continue continue
} }

View file

@ -1,110 +0,0 @@
package x402
type InputSchema struct {
BodyFields map[string]any `json:"bodyFields,omitempty"`
Discoverable bool `json:"discoverable,omitempty"`
Method string `json:"method,omitempty"`
Type string `json:"type,omitempty"`
}
type Schema struct {
Input InputSchema `json:"input"`
Output map[string]any `json:"output"`
}
// PaymentRequirement defines payment requirements for a resource/tool
// as defined in the x402 specification section 5.1
type PaymentRequirement struct {
Scheme string `json:"scheme"`
Network string `json:"network"`
MaxAmountRequired string `json:"maxAmountRequired"`
Asset string `json:"asset"`
PayTo string `json:"payTo"`
Resource string `json:"resource"`
Description string `json:"description"`
MimeType string `json:"mimeType"`
OutputSchema Schema `json:"outputSchema"`
MaxTimeoutSeconds int `json:"maxTimeoutSeconds"`
Extra map[string]string `json:"extra,omitempty"`
}
// PaymentRequirements402Response is the HTTP 402 response body
type PaymentRequirements402Response struct {
X402Version int `json:"x402Version"`
Error string `json:"error"`
Accepts []PaymentRequirement `json:"accepts"`
}
// PaymentPayload represents the X-PAYMENT header content
// as defined in the x402 specification section 5.2
type PaymentPayload struct {
X402Version int `json:"x402Version"`
Scheme string `json:"scheme"`
Network string `json:"network"`
Payload struct {
Signature string `json:"signature"`
Authorization struct {
From string `json:"from"`
To string `json:"to"`
Value string `json:"value"`
ValidAfter string `json:"validAfter"`
ValidBefore string `json:"validBefore"`
Nonce string `json:"nonce"`
} `json:"authorization"`
} `json:"payload"`
}
// SettlementResponse is included in X-PAYMENT-RESPONSE header
// as defined in the x402 specification section 5.3
type SettleResponse struct {
Success bool `json:"success"`
Transaction string `json:"transaction"`
Network string `json:"network"`
Payer string `json:"payer"`
ErrorReason string `json:"errorReason,omitempty"`
}
// VerifyRequest sent to facilitator /verify endpoint
// as defined in the x402 specification section 7.1
// Note: x402Version added at root level for facilitator compatibility
type VerifyRequest struct {
X402Version int `json:"x402Version"`
PaymentPayload *PaymentPayload `json:"paymentPayload"`
PaymentRequirements *PaymentRequirement `json:"paymentRequirements"`
}
// VerifyResponse from facilitator
// as defined in the x402 specification section 7.1
type VerifyResponse struct {
IsValid bool `json:"isValid"`
Payer string `json:"payer"`
InvalidReason string `json:"invalidReason,omitempty"`
}
// SettleRequest sent to facilitator /settle endpoint
// as defined in the x402 specification section 7.2
// Note: x402Version added at root level for facilitator compatibility
type SettleRequest struct {
X402Version int `json:"x402Version"`
PaymentPayload *PaymentPayload `json:"paymentPayload"`
PaymentRequirements *PaymentRequirement `json:"paymentRequirements"`
}
// Config for X402Server
type Config struct {
// FacilitatorURL is the base URL of the x402 facilitator service
FacilitatorURL string
CbApiKey *string
CbApiSecret *string
// PaymentTools maps tool names to their payment requirements
// Each tool can have multiple payment options
PaymentTools map[string][]PaymentRequirement
AllTools []string
// VerifyOnly if true, only verifies but doesn't settle payments
VerifyOnly bool
// Verbose if true, logs detailed request and payment information
Verbose bool
}

View file

@ -1,8 +1,6 @@
package x402 package x402
import ( import (
"bytes"
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@ -10,211 +8,298 @@ import (
"net" "net"
"net/http" "net/http"
"os" "os"
"reflect"
"strconv"
"strings" "strings"
"github.com/mark3labs/mcp-go/mcp" "github.com/google/jsonschema-go/jsonschema"
mcpserver "github.com/mark3labs/mcp-go/server" "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/mark3labs/x402-go"
x402http "github.com/mark3labs/x402-go/http"
"github.com/mark3labs/x402-go/mcp/server"
log "github.com/nacorid/logger" log "github.com/nacorid/logger"
x402 "github.com/x402-foundation/x402/go/v2"
x402http "github.com/x402-foundation/x402/go/v2/http"
nethttp "github.com/x402-foundation/x402/go/v2/http/nethttp"
x402mcp "github.com/x402-foundation/x402/go/v2/mcp"
evm "github.com/x402-foundation/x402/go/v2/mechanisms/evm/exact/server"
uptoevm "github.com/x402-foundation/x402/go/v2/mechanisms/evm/upto/server"
) )
type X402Middlewares struct { type X402Middlewares struct {
MCPMiddleware []mcpserver.ToolHandlerMiddleware MCPMiddleware []mcp.Middleware
HTTPMiddleware []func(http.Handler) http.Handler HTTPMiddleware []func(http.Handler) http.Handler
} }
type HTTPMiddleware func(http.Handler) http.Handler type ToolInfo struct {
Tool mcp.Tool
type X402Server struct { IsPayable bool
mcpHandler *mcpserver.MCPServer Requirements []x402.PaymentRequirements
mcpServer *server.X402Handler Handler mcp.ToolHandler
x402Middlewares map[string]func(http.Handler) http.Handler
httpConfig *x402http.Config
mcpConfig *server.Config
toolMiddlewareChain mcpserver.ToolHandlerMiddleware
middlewareChain func(http.Handler) http.Handler
AllEndpoints []string
Handlers map[string]mcpserver.ToolHandlerFunc
} }
func (s *X402Server) AddTool(tool mcp.Tool, handler mcpserver.ToolHandlerFunc) { type X402Server struct {
s.mcpHandler.AddTool(tool, handler) mcpServer *mcp.Server
s.AllEndpoints = append(s.AllEndpoints, tool.Name) x402ResourceServer *x402.X402ResourceServer
s.Handlers[tool.Name] = handler facilitatorClient *x402http.HTTPFacilitatorClient
s.x402Middlewares[tool.Name] = noOpMiddleware httpSettlementHook func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse)
mux *http.ServeMux
name string
version string
middlewareChain func(http.Handler) http.Handler
tools []ToolInfo
globalReqLog func(http.Handler) http.Handler
}
const VERSION1 = "v1/"
func NewX402Server(name, version string, facilitatorURL string, auth x402http.AuthProvider, mws X402Middlewares, globalLoggingMw func(http.Handler) http.Handler, settleHook func(http.ResponseWriter, *http.Request, *x402.SettleResponse)) (*X402Server, error) {
mcpServer := mcp.NewServer(
&mcp.Implementation{
Name: name,
Version: version,
},
nil,
)
for _, mw := range mws.MCPMiddleware {
mcpServer.AddReceivingMiddleware(mw)
}
if facilitatorURL == "" {
facilitatorURL = "https://facilitator.mogami.tech"
}
facilitator := x402http.NewHTTPFacilitatorClient(&x402http.FacilitatorConfig{
URL: facilitatorURL,
AuthProvider: auth,
})
resourceServer := x402.Newx402ResourceServer(
x402.WithFacilitatorClient(facilitator),
)
resourceServer.Register("eip155:8453", evm.NewExactEvmScheme())
resourceServer.Register("eip155:8453", uptoevm.NewUptoEvmScheme())
s := &X402Server{
mcpServer: mcpServer,
x402ResourceServer: resourceServer,
facilitatorClient: facilitator,
httpSettlementHook: settleHook,
mux: http.NewServeMux(),
name: name,
version: version,
middlewareChain: chainHTTPMiddlewares(mws.HTTPMiddleware...),
tools: make([]ToolInfo, 0),
globalReqLog: globalLoggingMw,
}
s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
s.mux.HandleFunc("GET /openapi.json", s.serveOpenAPI)
mcpHandler := mcp.NewStreamableHTTPHandler(func(req *http.Request) *mcp.Server {
return s.mcpServer
}, nil)
s.mux.Handle("/mcp", mcpHandler)
return s, nil
}
func (s *X402Server) AddTool(tool mcp.Tool, handler mcp.ToolHandler) {
tool.InputSchema = toJSONSchema(tool.InputSchema)
tool.OutputSchema = toJSONSchema(tool.OutputSchema)
s.mcpServer.AddTool(&tool, handler)
s.tools = append(s.tools, ToolInfo{
Tool: tool,
IsPayable: false,
Handler: handler,
})
restHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.handleRESTCall(w, r, tool.Name, handler)
})
s.mux.Handle("POST /"+VERSION1+tool.Name, s.middlewareChain(restHandler))
} }
func (s *X402Server) AddPayableTool( func (s *X402Server) AddPayableTool(
tool mcp.Tool, tool mcp.Tool,
handler mcpserver.ToolHandlerFunc, handler mcp.ToolHandler,
requirements ...x402.PaymentRequirement, requirements ...x402.PaymentRequirements,
) { ) {
s.mcpHandler.AddTool(tool, handler)
if len(requirements) == 0 { if len(requirements) == 0 {
log.Fatalf("tool %s requires at least one payment requirement", tool.Name) log.Fatalf("tool %s requires at least one payment requirement", tool.Name)
} }
s.mcpConfig.PaymentTools[tool.Name] = requirements tool.InputSchema = toJSONSchema(tool.InputSchema)
s.AllEndpoints = append(s.AllEndpoints, tool.Name) tool.OutputSchema = toJSONSchema(tool.OutputSchema)
s.Handlers[tool.Name] = handler
cfg := newMiddlewareConfig(s.httpConfig, func(c *x402http.Config) {
c.PaymentRequirements = requirements
})
s.x402Middlewares[tool.Name] = x402http.NewX402Middleware(&cfg)
}
func noOpMiddleware(next http.Handler) http.Handler { wrapper := x402mcp.NewPaymentWrapper(s.x402ResourceServer, x402mcp.PaymentWrapperConfig{
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { Accepts: requirements,
next.ServeHTTP(w, r) Resource: &x402mcp.ResourceInfo{
}) URL: "mcp://tool/" + tool.Name,
} Description: tool.Description,
func NewX402Server(name, version string, httpConfig *x402http.Config, mws X402Middlewares) (*X402Server, error) {
toolChain := chainToolMiddlewares(mws.MCPMiddleware...)
httpChain := chainHTTPMiddlewares(mws.HTTPMiddleware...)
mcp := mcpserver.NewMCPServer(name, version,
mcpserver.WithToolHandlerMiddleware(toolChain),
mcpserver.WithRecovery(),
)
return &X402Server{
mcpHandler: mcp,
x402Middlewares: make(map[string]func(http.Handler) http.Handler),
httpConfig: httpConfig,
mcpConfig: &server.Config{
FacilitatorURL: "https://facilitator.mogami.tech",
Verbose: true,
PaymentTools: make(map[string][]x402.PaymentRequirement),
}, },
toolMiddlewareChain: toolChain, })
middlewareChain: httpChain,
Handlers: make(map[string]mcpserver.ToolHandlerFunc), s.mcpServer.AddTool(&tool, wrapper.Wrap(handler))
}, nil
s.tools = append(s.tools, ToolInfo{
Tool: tool,
IsPayable: true,
Requirements: requirements,
Handler: handler,
})
var options x402http.PaymentOptions
for _, req := range requirements {
options = append(options, x402http.PaymentOption{
Scheme: req.Scheme,
PayTo: req.PayTo,
Price: req.Amount,
Network: x402.Network(req.Network),
})
}
paymentMiddleware := nethttp.X402Payment(nethttp.Config{
Routes: x402http.RoutesConfig{
"POST /" + VERSION1 + tool.Name: {
Accepts: options,
Description: tool.Description,
},
},
Facilitator: s.facilitatorClient,
Schemes: []nethttp.SchemeConfig{
{Network: "eip155:8453", Server: evm.NewExactEvmScheme()},
},
SettlementHandler: s.httpSettlementHook,
})
restHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.handleRESTCall(w, r, tool.Name, handler)
})
s.mux.Handle("POST /"+VERSION1+tool.Name, s.middlewareChain(paymentMiddleware(restHandler)))
} }
func chainToolMiddlewares(middlewares ...mcpserver.ToolHandlerMiddleware) mcpserver.ToolHandlerMiddleware { func (s *X402Server) handleRESTCall(w http.ResponseWriter, r *http.Request, toolName string, handler mcp.ToolHandler) {
return func(next mcpserver.ToolHandlerFunc) mcpserver.ToolHandlerFunc { bodyBytes, err := io.ReadAll(r.Body)
for i := len(middlewares) - 1; i >= 0; i-- { if err != nil {
next = middlewares[i](next) http.Error(w, "Failed to read body", http.StatusBadRequest)
}
return next
}
}
func chainHTTPMiddlewares(middlewares ...func(http.Handler) http.Handler) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
for i := len(middlewares) - 1; i >= 0; i-- {
next = middlewares[i](next)
}
return next
}
}
func newMiddlewareConfig(httpConfig *x402http.Config, overrides ...func(*x402http.Config)) x402http.Config {
cfg := *httpConfig
for _, override := range overrides {
override(&cfg)
}
return cfg
}
func (s *X402Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
toolName := strings.TrimPrefix(r.URL.Path, "/")
addr := "@"
forwarded := r.Header.Get("X-Forwarded-For")
if forwarded != "" {
ip := strings.Split(forwarded, ",")[0]
addr = strings.TrimSpace(ip)
} else {
realIP := r.Header.Get("X-Real-Ip")
if realIP != "" {
addr = realIP
}
}
log.InfoWithContext(r.Context(), "Request received", "host", r.Host, "method", r.Method, "path", r.URL.Path, "remoteAddr", addr)
if r.URL.Path == "/healthz" {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
return return
} }
if r.Method != http.MethodPost { log.DebugWithContext(r.Context(), "RESTful API request.", "RemoteAddr", r.RemoteAddr, "X-Forwarded-For", r.Header.Get("X-Forwarded-For"))
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
callReq := &mcp.CallToolRequest{
Params: &mcp.CallToolParamsRaw{
Name: toolName,
Arguments: json.RawMessage(bodyBytes),
},
}
resp, err := handler(r.Context(), callReq)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
if handler, ok := s.Handlers[toolName]; ok { w.Header().Set("Content-Type", "application/json")
bodyBytes, err := io.ReadAll(r.Body) w.WriteHeader(http.StatusOK)
if err != nil {
log.WarnWithContext(r.Context(), "Failed to read request body", "error", err) if len(resp.Content) == 1 {
http.Error(w, "Failed to read request body", http.StatusBadRequest) if textContent, ok := resp.Content[0].(*mcp.TextContent); ok {
return var js json.RawMessage
} if json.Unmarshal([]byte(textContent.Text), &js) == nil {
var args map[string]any // For upto scheme tools, compute actual cost and apply settlement override
if len(bodyBytes) > 0 { if toolName == "getStyleProfile" {
if err := json.Unmarshal(bodyBytes, &args); err != nil { actualCost := computeTwitterStyleProfileCost(r, textContent.Text)
log.WarnWithContext(r.Context(), "Failed to parse request body as JSON", "tool", toolName, "error", err) nethttp.SetSettlementOverrides(w, &x402.SettlementOverrides{
http.Error(w, "Invalid JSON body", http.StatusBadRequest) Amount: actualCost,
})
}
w.Write([]byte(textContent.Text))
return return
} }
} else { }
log.DebugWithContext(r.Context(), "Empty request body for tool", "tool", toolName) }
args = make(map[string]any)
json.NewEncoder(w).Encode(resp)
}
func (s *X402Server) OnAfterSettle(hook func(ctx x402.SettleResultContext) error) {
s.x402ResourceServer.OnAfterSettle(hook)
}
func (s *X402Server) serveOpenAPI(w http.ResponseWriter, r *http.Request) {
paths := make(map[string]any)
log.DebugWithContext(r.Context(), "OpenAPI Spec requested.", "RemoteAddr", r.RemoteAddr, "X-Forwarded-For", r.Header.Get("X-Forwarded-For"))
for _, info := range s.tools {
inputSchema := parseSchema(info.Tool.InputSchema)
outputSchema := parseSchema(info.Tool.OutputSchema)
postOp := map[string]any{
"summary": info.Tool.Description,
"description": fmt.Sprintf("Exposes the MCP tool '%s' as a RESTful endpoint.", info.Tool.Name),
"requestBody": map[string]any{
"required": true,
"content": map[string]any{
"application/json": map[string]any{
"schema": inputSchema,
},
},
},
"responses": map[string]any{
"200": map[string]any{
"description": "Successful operation",
"content": map[string]any{
"application/json": map[string]any{
"schema": outputSchema,
},
},
},
},
} }
// Store parsed args in context if info.IsPayable {
type contextKey string postOp["description"] = fmt.Sprintf("%s\n\n**Paid Route**: Requires payments via x402.", postOp["description"])
const argsKey contextKey = "parsed_args" postOp["responses"].(map[string]any)["402"] = map[string]any{
ctx := context.WithValue(r.Context(), argsKey, args) "description": "Payment Required. Responds with the PAYMENT-REQUIRED header challenge.",
r = r.WithContext(ctx) "headers": map[string]any{
"PAYMENT-REQUIRED": map[string]any{
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // Restore body for further reading "schema": map[string]any{"type": "string"},
functionHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { "description": "Base64 encoded PaymentRequired challenge matching the x402 specification.",
args := r.Context().Value(argsKey).(map[string]any) },
callReq := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: toolName,
Arguments: args,
}, },
} }
resp, err := s.toolMiddlewareChain(handler)(r.Context(), callReq)
if err != nil {
log.WarnWithContext(r.Context(), "Tool handler error in direct handler", "tool", toolName, "error", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
respBytes, err := json.Marshal(resp.Result)
if err != nil {
log.WarnWithContext(r.Context(), "Failed to marshal response", "error", err)
http.Error(w, "Failed to marshal response", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(respBytes)
})
middleware, ok := s.x402Middlewares[toolName]
if !ok {
log.ErrorWithContext(r.Context(), "No X402 middleware found for tool", "tool", toolName)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
} }
s.middlewareChain(middleware(functionHandler)).ServeHTTP(w, r)
} else { paths["/"+VERSION1+info.Tool.Name] = map[string]any{
http.NotFound(w, r) "post": postOp,
}
} }
openapiDoc := map[string]any{
"openapi": "3.0.3",
"info": map[string]any{
"title": s.name,
"version": s.version,
"description": "Auto-generated REST API matching active Model Context Protocol (MCP) tools.",
"contact": map[string]any{
"email": "x402@naco.li",
},
},
"paths": paths,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(openapiDoc)
} }
// Start starts the x402 server on the specified address
func (s *X402Server) Start(addr string, permissions os.FileMode) error { func (s *X402Server) Start(addr string, permissions os.FileMode) error {
var ( var (
listener net.Listener listener net.Listener
@ -233,10 +318,8 @@ func (s *X402Server) Start(addr string, permissions os.FileMode) error {
} }
log.Info("Starting X402 MCP Server on unix socket", "socketPath", socketPath) log.Info("Starting X402 MCP Server on unix socket", "socketPath", socketPath)
log.Info("MCP endpoint (unix socket)", "socketPath", socketPath)
} else { } else {
log.Info("Starting X402 MCP Server on TCP", "addr", addr) log.Info("Starting X402 MCP Server on TCP", "addr", addr)
log.Infof("MCP endpoint: http://localhost%s", addr)
listener, err = net.Listen("tcp", addr) listener, err = net.Listen("tcp", addr)
if err != nil { if err != nil {
@ -245,12 +328,150 @@ func (s *X402Server) Start(addr string, permissions os.FileMode) error {
} }
defer listener.Close() defer listener.Close()
httpServer := mcpserver.NewStreamableHTTPServer(s.mcpHandler)
s.mcpServer = server.NewX402Handler(httpServer, s.mcpConfig)
srv := &http.Server{ srv := &http.Server{
Handler: s, Handler: s.globalReqLog(s.mux),
ErrorLog: slog.NewLogLogger(slog.Default().Handler(), slog.LevelDebug), ErrorLog: slog.NewLogLogger(slog.Default().Handler(), slog.LevelDebug),
} }
return srv.Serve(listener) return srv.Serve(listener)
} }
func toJSONSchema(val any) json.RawMessage {
if val == nil {
return json.RawMessage(`{"type": "object"}`)
}
switch v := val.(type) {
case json.RawMessage:
if len(v) == 0 {
return json.RawMessage(`{"type": "object"}`)
}
return v
case []byte:
if len(v) == 0 {
return json.RawMessage(`{"type": "object"}`)
}
return json.RawMessage(v)
case string:
if v == "" {
return json.RawMessage(`{"type": "object"}`)
}
return json.RawMessage(v)
}
t := reflect.TypeOf(val)
schema, err := jsonschema.ForType(t, &jsonschema.ForOptions{})
if err != nil {
return json.RawMessage(`{"type": "object"}`)
}
bytes, err := json.Marshal(schema)
if err != nil {
return json.RawMessage(`{"type": "object"}`)
}
return bytes
}
func parseSchema(schemaAny any) any {
if schemaAny == nil {
return map[string]any{"type": "object"}
}
switch v := schemaAny.(type) {
case json.RawMessage:
var m any
if err := json.Unmarshal(v, &m); err == nil {
return m
}
case []byte:
var m any
if err := json.Unmarshal(v, &m); err == nil {
return m
}
case string:
var m any
if err := json.Unmarshal([]byte(v), &m); err == nil {
return m
}
}
return schemaAny
}
func chainHTTPMiddlewares(middlewares ...func(http.Handler) http.Handler) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
for i := len(middlewares) - 1; i >= 0; i-- {
next = middlewares[i](next)
}
return next
}
}
// Twitter API costs are itemized below. Every getStyleProfile request, for any
// platform, also carries the flat base cost of the endpoint (the same price a
// non-Twitter platform pays). Adding that base cost on top of the itemized
// Twitter API costs guarantees the call always clears the underlying API cost
// and generates a profit - it can never break even or lose money.
const (
// baseProfileCostMicro is the base cost of the style-profile endpoint for
// the non-Twitter path. It doubles as the profit margin added to Twitter
// requests, expressed in micro-dollars (USDC has 6 decimals).
baseProfileCostMicro int64 = 100_000 // $0.10
// twitterUserLookupMicro is the per-request Twitter user lookup API cost.
twitterUserLookupMicro int64 = 10_000 // $0.01
// twitterPostReadMicro is the per-post Twitter read API cost.
twitterPostReadMicro int64 = 5_000 // $0.005 per post
)
// computeTwitterStyleProfileCost calculates the actual charge for a Twitter
// getStyleProfile request based on the number of posts fetched and the user
// lookup, and returns it as a dollar price string (e.g. "$0.16") suitable for a
// x402 settlement override.
//
// Pricing: guaranteed base cost of $0.10 (the endpoint cost for other
// platforms), plus user lookup $0.01, plus $0.005 per post read. For example,
// 10 posts = $0.16. Non-Twitter platforms are charged the flat $0.10 base.
func computeTwitterStyleProfileCost(r *http.Request, responseText string) string {
// Parse the response to get PostCount and SourcePlatform
var resp GetStyleProfileResponse
if err := json.Unmarshal([]byte(responseText), &resp); err != nil {
return formatDollarAmount(baseProfileCostMicro) // fallback: base cost
}
costMicro := baseProfileCostMicro
// Twitter additionally passes on the user lookup and per-post API costs.
if resp.SourcePlatform == "Twitter" {
postCount := resp.PostCount
if postCount < 0 {
postCount = 0
}
costMicro += twitterUserLookupMicro
costMicro += int64(postCount) * twitterPostReadMicro
}
return formatDollarAmount(costMicro)
}
// formatDollarAmount renders an amount in micro-dollars as a "$"-prefixed
// price string with at least two decimal places and trailing zeroes trimmed
// beyond that (e.g. "$0.16", "$1.50", "$0.125"), matching the format the x402
// settlement override resolver expects.
func formatDollarAmount(micro int64) string {
dollars := float64(micro) / 1_000_000
s := strconv.FormatFloat(dollars, 'f', 6, 64)
s = strings.TrimRight(s, "0")
if dot := strings.IndexByte(s, '.'); dot >= 0 {
if decimals := len(s) - dot - 1; decimals < 2 {
s += strings.Repeat("0", 2-decimals)
}
} else {
s += ".00"
}
return "$" + s
}
// GetStyleProfileResponse is a local copy of the response schema for cost calculation
type GetStyleProfileResponse struct {
Author string `json:"author"`
PersonaEmbedding []float64 `json:"personaEmbedding"`
TopKeywords []string `json:"topKeywords"`
StyleSummary string `json:"styleSummary"`
PostCount int `json:"postCount"`
SourcePlatform string `json:"sourcePlatform"`
}

447
internal/x402/x402_test.go Normal file
View file

@ -0,0 +1,447 @@
package x402
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
x402 "github.com/x402-foundation/x402/go/v2"
x402http "github.com/x402-foundation/x402/go/v2/http"
)
type DummyAuthProvider struct{}
func (d *DummyAuthProvider) GetAuthHeaders(ctx context.Context) (x402http.AuthHeaders, error) {
return x402http.AuthHeaders{}, nil
}
func TestX402Server_HTTPBoundaries(t *testing.T) {
mockFacilitator := setupMockFacilitator()
defer mockFacilitator.Close()
srv, err := NewX402Server(
"Test Agent API",
"1.0.0",
mockFacilitator.URL,
&DummyAuthProvider{},
X402Middlewares{},
func(next http.Handler) http.Handler { return next },
func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {},
)
if err != nil {
t.Fatalf("failed to create server: %v", err)
}
srv.AddPayableTool(
mcp.Tool{
Name: "getTestPost",
Description: "Gated test endpoint",
},
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: `{"status": "success"}`}},
}, nil
},
x402.PaymentRequirements{
Scheme: "exact",
Network: "eip155:8453",
Asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
Amount: "1000",
PayTo: "0x0000000000000000000000000000000000000000",
},
)
reqRec := httptest.NewRecorder()
reqHealth, _ := http.NewRequest("GET", "/healthz", nil)
srv.mux.ServeHTTP(reqRec, reqHealth)
if reqRec.Code != http.StatusOK {
t.Errorf("expected healthz status 200, got %d", reqRec.Code)
}
reqRecOpenAPI := httptest.NewRecorder()
reqOpenAPI, _ := http.NewRequest("GET", "/openapi.json", nil)
srv.mux.ServeHTTP(reqRecOpenAPI, reqOpenAPI)
if reqRecOpenAPI.Code != http.StatusOK {
t.Errorf("expected OpenAPI status 200, got %d", reqRecOpenAPI.Code)
}
if !strings.Contains(reqRecOpenAPI.Body.String(), "getTestPost") {
t.Error("OpenAPI spec did not render registered tool definitions")
}
reqRecPayable := httptest.NewRecorder()
reqPayable, _ := http.NewRequest("POST", "/v1/getTestPost", strings.NewReader(`{}`))
srv.mux.ServeHTTP(reqRecPayable, reqPayable)
if reqRecPayable.Code != http.StatusPaymentRequired {
t.Errorf("expected status 402 (Payment Required), got %d", reqRecPayable.Code)
}
payHeader := reqRecPayable.Header().Get("PAYMENT-REQUIRED")
if payHeader == "" {
t.Error("missing PAYMENT-REQUIRED response header on 402 challenge")
}
}
func TestX402Server_MCPBoundaries(t *testing.T) {
ctx := context.Background()
serverTransport, clientTransport := mcp.NewInMemoryTransports()
mockFacilitator := setupMockFacilitator()
defer mockFacilitator.Close()
srv, err := NewX402Server(
"Test Agent API",
"1.0.0",
mockFacilitator.URL,
&DummyAuthProvider{},
X402Middlewares{},
func(next http.Handler) http.Handler { return next },
func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {},
)
if err != nil {
t.Fatalf("failed to create server: %v", err)
}
srv.AddPayableTool(
mcp.Tool{
Name: "getTestPost",
Description: "Gated test endpoint",
},
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: `{"status": "success"}`}},
}, nil
},
x402.PaymentRequirements{
Scheme: "exact",
Network: "eip155:8453",
Asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
Amount: "1000",
PayTo: "0x0000000000000000000000000000000000000000",
},
)
serverSession, err := srv.mcpServer.Connect(ctx, serverTransport, nil)
if err != nil {
t.Fatalf("failed to connect server session: %v", err)
}
defer serverSession.Close()
client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "1.0.0"}, nil)
clientSession, err := client.Connect(ctx, clientTransport, nil)
if err != nil {
t.Fatalf("failed to connect client session: %v", err)
}
defer clientSession.Close()
toolsResponse, err := clientSession.ListTools(ctx, nil)
if err != nil {
t.Fatalf("failed to list tools over MCP: %v", err)
}
found := false
for _, tool := range toolsResponse.Tools {
if tool.Name == "getTestPost" {
found = true
break
}
}
if !found {
t.Error("expected tool 'getTestPost' to be discovered by the MCP client, but it was missing")
}
result, err := clientSession.CallTool(ctx, &mcp.CallToolParams{
Name: "getTestPost",
Arguments: json.RawMessage(`{}`),
})
if err != nil {
t.Errorf("Unexpected protocol-level error: %v", err)
} else {
if !result.IsError {
t.Error("expected CallTool to return a payment challenge, but it returned a successful, free result")
} else {
t.Logf("Intercepted expected application-level payment challenge.")
t.Logf("Content: %v", result.Content)
t.Logf("Meta fields: %v", result.Meta)
}
}
}
func TestComputeTwitterStyleProfileCost(t *testing.T) {
cases := []struct {
name string
platform string
postCount int
want string
}{
{name: "non-twitter flat base", platform: "Bluesky", postCount: 10, want: "$0.10"},
{name: "twitter example from spec", platform: "Twitter", postCount: 10, want: "$0.16"},
{name: "twitter zero posts", platform: "Twitter", postCount: 0, want: "$0.11"},
{name: "twitter max posts", platform: "Twitter", postCount: 50, want: "$0.36"},
{name: "twitter odd post count half cents", platform: "Twitter", postCount: 3, want: "$0.125"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
resp := GetStyleProfileResponse{SourcePlatform: tc.platform, PostCount: tc.postCount}
payload, _ := json.Marshal(resp)
r, _ := http.NewRequest("POST", "/v1/getStyleProfile", nil)
got := computeTwitterStyleProfileCost(r, string(payload))
if got != tc.want {
t.Fatalf("computeTwitterStyleProfileCost(%q, %d) = %q, want %q", tc.platform, tc.postCount, got, tc.want)
}
})
}
}
// Verify that every Twitter charge strictly exceeds the flat base cost, so the
// endpoint always clears the underlying API cost (never break-even or loss).
func TestComputeTwitterStyleProfileCostAlwaysProfitable(t *testing.T) {
for postCount := 0; postCount <= 50; postCount++ {
resp := GetStyleProfileResponse{SourcePlatform: "Twitter", PostCount: postCount}
payload, _ := json.Marshal(resp)
r, _ := http.NewRequest("POST", "/v1/getStyleProfile", nil)
got := computeTwitterStyleProfileCost(r, string(payload))
// Cut the "$" prefix and compare numerically to baseProfileCostMicro.
dollars := strings.TrimPrefix(got, "$")
parsed, err := strconv.ParseFloat(dollars, 64)
if err != nil {
t.Fatalf("failed to parse %q: %v", got, err)
}
micro := int64(parsed * 1_000_000)
if micro <= baseProfileCostMicro {
t.Fatalf("postCount=%d: charge %q (%d micro) must be strictly greater than base %d", postCount, got, micro, baseProfileCostMicro)
}
}
}
func setupMockFacilitator() *httptest.Server {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == "GET" && r.URL.Path == "/supported":
_, _ = w.Write([]byte(`{
"kinds": [
{
"x402Version": 2,
"scheme": "exact",
"network": "eip155:8453"
}
]
}`))
case r.Method == "POST" && r.URL.Path == "/verify":
_, _ = w.Write([]byte(`{
"isValid": true,
"invalidReason": ""
}`))
case r.Method == "POST" && r.URL.Path == "/settle":
_, _ = w.Write([]byte(`{
"success": true,
"transaction": "0x1234abcd",
"network": "eip155:8453",
"payer": "0xabcd1234"
}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
return s
}
func TestAddTool_NonPayable(t *testing.T) {
mockFacilitator := setupMockFacilitator()
defer mockFacilitator.Close()
srv, err := NewX402Server(
"Test Agent API",
"1.0.0",
mockFacilitator.URL,
&DummyAuthProvider{},
X402Middlewares{},
func(next http.Handler) http.Handler { return next },
func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {},
)
if err != nil {
t.Fatalf("failed to create server: %v", err)
}
srv.AddTool(
mcp.Tool{
Name: "freeTool",
Description: "Free test endpoint",
},
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: `{"status": "free"}`}},
}, nil
},
)
// Test HTTP endpoint works without payment
reqRec := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/v1/freeTool", strings.NewReader(`{}`))
srv.mux.ServeHTTP(reqRec, req)
if reqRec.Code != http.StatusOK {
t.Errorf("expected status 200 for free tool, got %d", reqRec.Code)
}
// Test OpenAPI includes non-payable tool
reqRecOpenAPI := httptest.NewRecorder()
reqOpenAPI, _ := http.NewRequest("GET", "/openapi.json", nil)
srv.mux.ServeHTTP(reqRecOpenAPI, reqOpenAPI)
if !strings.Contains(reqRecOpenAPI.Body.String(), "freeTool") {
t.Error("OpenAPI spec did not render non-payable tool")
}
// Should not have 402 response for free tool
if strings.Contains(reqRecOpenAPI.Body.String(), `"402"`) {
t.Error("OpenAPI spec should not have 402 for free tool")
}
}
func TestAddPayableTool_EmptyRequirements(t *testing.T) {
mockFacilitator := setupMockFacilitator()
defer mockFacilitator.Close()
srv, err := NewX402Server(
"Test Agent API",
"1.0.0",
mockFacilitator.URL,
&DummyAuthProvider{},
X402Middlewares{},
func(next http.Handler) http.Handler { return next },
func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {},
)
if err != nil {
t.Fatalf("failed to create server: %v", err)
}
// The library validates requirements at AddPayableTool call time
// and returns an error (or panics via log.Fatal) for empty requirements
defer func() {
if r := recover(); r != nil {
errMsg := fmt.Sprintf("%v", r)
// The library may panic with log.Fatal or return an error
if !strings.Contains(errMsg, "requires at least one payment requirement") &&
!strings.Contains(errMsg, "Accepts must have at least one") {
t.Errorf("expected panic about empty requirements, got: %v", r)
}
} else {
t.Error("expected panic for empty payment requirements")
}
}()
srv.AddPayableTool(
mcp.Tool{
Name: "badTool",
Description: "Bad endpoint",
},
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: `{"status": "success"}`}},
}, nil
},
// No requirements provided
)
}
func TestX402Server_MultipleTools(t *testing.T) {
mockFacilitator := setupMockFacilitator()
defer mockFacilitator.Close()
srv, err := NewX402Server(
"Test Agent API",
"1.0.0",
mockFacilitator.URL,
&DummyAuthProvider{},
X402Middlewares{},
func(next http.Handler) http.Handler { return next },
func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {},
)
if err != nil {
t.Fatalf("failed to create server: %v", err)
}
// Add multiple tools
toolNames := []string{"tool1", "tool2", "tool3"}
for _, name := range toolNames {
name := name
srv.AddPayableTool(
mcp.Tool{
Name: name,
Description: "Tool " + name,
},
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: `{"tool": "` + name + `"}`}},
}, nil
},
x402.PaymentRequirements{
Scheme: "exact",
Network: "eip155:8453",
Asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
Amount: "1000",
PayTo: "0x0000000000000000000000000000000000000000",
},
)
}
// Verify all tools are registered in OpenAPI
reqRec := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/openapi.json", nil)
srv.mux.ServeHTTP(reqRec, req)
body := reqRec.Body.String()
for _, name := range toolNames {
if !strings.Contains(body, name) {
t.Errorf("OpenAPI missing tool: %s", name)
}
}
// Verify each tool requires payment
for _, name := range toolNames {
reqRec := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/v1/"+name, strings.NewReader(`{}`))
srv.mux.ServeHTTP(reqRec, req)
if reqRec.Code != http.StatusPaymentRequired {
t.Errorf("tool %s: expected status 402, got %d", name, reqRec.Code)
}
}
}
func TestComputeTwitterStyleProfileCost_EdgeCases(t *testing.T) {
cases := []struct {
name string
platform string
postCount int
want string
}{
{name: "case insensitive twitter", platform: "TWITTER", postCount: 10, want: "$0.10"}, // not "Twitter" so flat base
{name: "empty platform", platform: "", postCount: 10, want: "$0.10"},
{name: "negative post count", platform: "Twitter", postCount: -5, want: "$0.11"}, // should clamp to 0
{name: "very large post count", platform: "Twitter", postCount: 1000, want: "$5.11"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
resp := GetStyleProfileResponse{SourcePlatform: tc.platform, PostCount: tc.postCount}
payload, _ := json.Marshal(resp)
r, _ := http.NewRequest("POST", "/v1/getStyleProfile", nil)
got := computeTwitterStyleProfileCost(r, string(payload))
if got != tc.want {
t.Fatalf("computeTwitterStyleProfileCost(%q, %d) = %q, want %q", tc.platform, tc.postCount, got, tc.want)
}
})
}
}

View file

@ -4,12 +4,13 @@ After=network.target
[Service] [Service]
User=mcpserver User=mcpserver
Group=www-data Group=mcpserver
WorkingDirectory=/home/ubuntu/gitSources/mcpServer WorkingDirectory=/home/ubuntu/gitSources/mcpServer
ExecStart=/home/ubuntu/gitSources/mcpServer/bin/main ExecStart=/home/ubuntu/gitSources/mcpServer/bin/main
RuntimeDirectory=mcpserver RuntimeDirectory=mcpserver
RuntimeDirectoryMode=0750
Restart=on-failure Restart=on-failure
RestartSec=3s RestartSec=3s

62
scripts/fund-wallet.sh Executable file
View file

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

83
scripts/start-test-server.sh Executable file
View file

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

View 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();

View 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;

View 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;

View file

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

View file

@ -1,6 +1,6 @@
-- name: FindSimilarUsers :many -- name: FindSimilarUsers :many
SELECT user_id, platform, embedding, top_keywords, style_summary, post_count, 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 FROM user_style_profiles
ORDER BY embedding <=> $1::vector ORDER BY embedding <=> @query_embedding::vector
LIMIT $2; LIMIT @limit_val;

View 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;

View 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;

View 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[]);

View file

@ -1,3 +1,3 @@
-- name: GetStyleProfile :one -- name: GetStyleProfile :one
SELECT * 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; WHERE user_id = @user_id AND platform = @platform;

View file

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

View 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();

View file

@ -1,10 +1,10 @@
-- name: UpsertStyleProfile :exec -- name: UpsertStyleProfile :exec
INSERT INTO user_style_profiles (user_id, platform, embedding, top_keywords, style_summary, post_count) 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) ON CONFLICT (user_id, platform)
DO UPDATE SET DO UPDATE SET
embedding = EXCLUDED.embedding, embedding = EXCLUDED.embedding,
top_keywords = EXCLUDED.top_keywords, top_keywords = EXCLUDED.top_keywords,
style_summary = EXCLUDED.style_summary, style_summary = EXCLUDED.style_summary,
post_count = EXCLUDED.post_count, post_count = EXCLUDED.post_count,
updated_at = NOW(); modified_at = NOW();

View file

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

View file

@ -1,9 +1,11 @@
-- +goose up -- +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, id BIGSERIAL PRIMARY KEY,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
platform TEXT NOT NULL, platform TEXT NOT NULL,
embedding VECTOR(768) NOT NULL, embedding VECTOR(1024) NOT NULL,
top_keywords TEXT[] NOT NULL, top_keywords TEXT[] NOT NULL,
style_summary TEXT NOT NULL, style_summary TEXT NOT NULL,
post_count INTEGER NOT NULL, post_count INTEGER NOT NULL,
@ -13,4 +15,6 @@ CREATE TABLE user_style_profiles (
); );
-- +goose down -- +goose down
DROP TABLE IF EXISTS user_style_profiles; DROP TABLE IF EXISTS user_style_profiles;
DROP EXTENSION IF EXISTS vector;

View file

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

View file

@ -0,0 +1,17 @@
-- +goose up
CREATE TABLE IF NOT EXISTS user_style_posts (
id BIGSERIAL PRIMARY KEY,
user_id TEXT NOT NULL,
platform TEXT NOT NULL,
post_id TEXT NOT NULL,
post_text TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, platform, post_id)
);
CREATE INDEX idx_user_style_posts_user_platform
ON user_style_posts (user_id, platform);
-- +goose down
DROP INDEX IF EXISTS idx_user_style_posts_user_platform;
DROP TABLE IF EXISTS user_style_posts;

View file

@ -0,0 +1,23 @@
-- +goose up
CREATE TABLE IF NOT EXISTS analytics_cache (
id BIGSERIAL PRIMARY KEY,
user_id TEXT NOT NULL,
platform TEXT NOT NULL,
tool_name TEXT NOT NULL,
time_range_hash TEXT NOT NULL,
result JSONB NOT NULL,
computed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
UNIQUE (user_id, platform, tool_name, time_range_hash)
);
CREATE INDEX IF NOT EXISTS idx_analytics_cache_lookup
ON analytics_cache (user_id, platform, tool_name, time_range_hash);
CREATE INDEX IF NOT EXISTS idx_analytics_cache_expires
ON analytics_cache (expires_at);
-- +goose down
DROP INDEX IF EXISTS idx_analytics_cache_expires;
DROP INDEX IF EXISTS idx_analytics_cache_lookup;
DROP TABLE IF EXISTS analytics_cache;

View file

@ -0,0 +1,22 @@
-- +goose up
CREATE TABLE IF NOT EXISTS analytics_posts (
id BIGSERIAL PRIMARY KEY,
user_id TEXT NOT NULL,
platform TEXT NOT NULL,
post_id TEXT NOT NULL,
post_text TEXT NOT NULL,
post_timestamp TIMESTAMPTZ NOT NULL,
likes BIGINT NOT NULL DEFAULT 0,
reposts BIGINT NOT NULL DEFAULT 0,
replies BIGINT NOT NULL DEFAULT 0,
quotes BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, platform, post_id)
);
CREATE INDEX IF NOT EXISTS idx_analytics_posts_user_platform_time
ON analytics_posts (user_id, platform, post_timestamp);
-- +goose down
DROP INDEX IF EXISTS idx_analytics_posts_user_platform_time;
DROP TABLE IF EXISTS analytics_posts;

View file

@ -0,0 +1,21 @@
-- +goose up
-- Tracks which time ranges have already been fetched for an author on a
-- platform so paid API calls (e.g. Twitter) are not repeated. Coverage is
-- stored as discrete non-overlapping intervals rather than a single
-- contiguous window, so gaps between separately-fetched ranges are not
-- mistaken for covered data.
CREATE TABLE IF NOT EXISTS analytics_coverage_ranges (
user_id TEXT NOT NULL,
platform TEXT NOT NULL,
covered_start TIMESTAMPTZ NOT NULL,
covered_end TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, platform, covered_start, covered_end)
);
CREATE INDEX IF NOT EXISTS idx_analytics_coverage_ranges_lookup
ON analytics_coverage_ranges (user_id, platform, covered_start);
-- +goose down
DROP INDEX IF EXISTS idx_analytics_coverage_ranges_lookup;
DROP TABLE IF EXISTS analytics_coverage_ranges;

View file

@ -1,27 +1,39 @@
package main package main
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log" "log"
"os" "math/big"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
"os"
"strings"
"time"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/joho/godotenv" "github.com/joho/godotenv"
//"github.com/mark3labs/mcp-go/client" x402 "github.com/x402-foundation/x402/go/v2"
"github.com/mark3labs/x402-go" x402http "github.com/x402-foundation/x402/go/v2/http"
x402http "github.com/mark3labs/x402-go/http" evm "github.com/x402-foundation/x402/go/v2/mechanisms/evm/exact/client"
evmsigners "github.com/x402-foundation/x402/go/v2/signers/evm"
//x402client "github.com/mark3labs/x402-go/mcp/client"
"github.com/mark3labs/x402-go/signers/evm"
) )
const (
USDCContractAddress = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"
BaseSepoliaRPC = "https://sepolia.base.org"
MinUSDCRequired = 10.0
MinETHRequired = 0.01
)
var usdcABI = `[{"constant":true,"inputs":[{"name":"_owner","type":"address"}],"name":"balanceOf","outputs":[{"name":"balance","type":"uint256"}],"type":"function"}]`
type LoggingRoundTripper struct { type LoggingRoundTripper struct {
Proxied http.RoundTripper Proxied http.RoundTripper
} }
@ -34,7 +46,6 @@ func (lrt *LoggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, er
req.Body = rc req.Body = rc
} }
// Dump request
reqDump, err := httputil.DumpRequestOut(req, true) reqDump, err := httputil.DumpRequestOut(req, true)
if err != nil { if err != nil {
log.Printf("Error dumping request: %v", err) log.Printf("Error dumping request: %v", err)
@ -42,13 +53,11 @@ func (lrt *LoggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, er
fmt.Printf("\n--- REQUEST ---\n%s\n----------------\n", reqDump) fmt.Printf("\n--- REQUEST ---\n%s\n----------------\n", reqDump)
} }
// Perform request
resp, err := lrt.Proxied.RoundTrip(req) resp, err := lrt.Proxied.RoundTrip(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Dump response
respDump, err := httputil.DumpResponse(resp, true) respDump, err := httputil.DumpResponse(resp, true)
if err != nil { if err != nil {
log.Printf("Error dumping response: %v", err) log.Printf("Error dumping response: %v", err)
@ -59,338 +68,265 @@ func (lrt *LoggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, er
return resp, nil return resp, nil
} }
func callEndpoint(client *http.Client, method, endpoint, body string) error {
baseURL := getEnv("TEST_SERVER_URL", "http://localhost:8080")
url := fmt.Sprintf("%s/v1/%s", baseURL, endpoint)
req, err := http.NewRequest(method, url, strings.NewReader(body))
if err != nil {
return fmt.Errorf("failed to create %s request: %w", endpoint, err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("%s HTTP request failed: %w", endpoint, err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
fmt.Printf("%s Response Status: %s\n", endpoint, resp.Status)
if len(respBody) > 0 {
fmt.Printf("%s Response Body: %s\n", endpoint, string(respBody))
}
if resp.StatusCode == 402 {
return fmt.Errorf("%s: payment required (402)", endpoint)
}
return nil
}
func getEnv(key, fallback string) string {
if value, ok := os.LookupEnv(key); ok {
return value
}
return fallback
}
func getUSDCBalance(client *ethclient.Client, address common.Address) (*big.Int, error) {
contractAddr := common.HexToAddress(USDCContractAddress)
parsedABI, err := abi.JSON(strings.NewReader(usdcABI))
if err != nil {
return nil, fmt.Errorf("failed to parse USDC ABI: %w", err)
}
data, err := parsedABI.Pack("balanceOf", address)
if err != nil {
return nil, fmt.Errorf("failed to pack balanceOf call: %w", err)
}
msg := ethereum.CallMsg{
To: &contractAddr,
Data: data,
}
result, err := client.CallContract(context.Background(), msg, nil)
if err != nil {
return nil, fmt.Errorf("failed to call balanceOf: %w", err)
}
unpacked, err := parsedABI.Unpack("balanceOf", result)
if err != nil {
return nil, fmt.Errorf("failed to unpack balance: %w", err)
}
if len(unpacked) == 0 {
return nil, fmt.Errorf("no balance returned")
}
balance, ok := unpacked[0].(*big.Int)
if !ok {
return nil, fmt.Errorf("unexpected balance type: %T", unpacked[0])
}
return balance, nil
}
func getETHBalance(client *ethclient.Client, address common.Address) (*big.Int, error) {
balance, err := client.BalanceAt(context.Background(), address, nil)
if err != nil {
return nil, fmt.Errorf("failed to get ETH balance: %w", err)
}
return balance, nil
}
func requestFaucetFunds(faucetURL, address, token string) error {
payload := map[string]string{
"address": address,
"token": token,
}
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal faucet request: %w", err)
}
resp, err := http.Post(faucetURL, "application/json", strings.NewReader(string(jsonData)))
if err != nil {
return fmt.Errorf("faucet request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("faucet returned error %d: %s", resp.StatusCode, string(body))
}
return nil
}
func ensureFundedWallet(privateKey, faucetURL, rpcURL string) error {
client, err := ethclient.Dial(rpcURL)
if err != nil {
return fmt.Errorf("failed to connect to RPC: %w", err)
}
defer client.Close()
signer, err := evmsigners.NewClientSignerFromPrivateKey(privateKey)
if err != nil {
return fmt.Errorf("failed to create signer: %w", err)
}
addrStr := signer.Address()
address := common.HexToAddress(addrStr)
log.Printf("Checking wallet balance for %s...", addrStr)
usdcBalance, err := getUSDCBalance(client, address)
if err != nil {
return fmt.Errorf("failed to get USDC balance: %w", err)
}
usdcBalanceFloat := new(big.Float).Quo(new(big.Float).SetInt(usdcBalance), big.NewFloat(1e6))
usdcFloat, _ := usdcBalanceFloat.Float64()
log.Printf("Current USDC balance: %.6f USDC", usdcFloat)
ethBalance, err := getETHBalance(client, address)
if err != nil {
return fmt.Errorf("failed to get ETH balance: %w", err)
}
ethBalanceFloat := new(big.Float).Quo(new(big.Float).SetInt(ethBalance), big.NewFloat(1e18))
ethFloat, _ := ethBalanceFloat.Float64()
log.Printf("Current ETH balance: %.6f ETH", ethFloat)
needsFunding := false
if usdcFloat < MinUSDCRequired {
log.Printf("USDC balance %.6f below minimum %.2f, requesting funding...", usdcFloat, MinUSDCRequired)
needsFunding = true
}
if ethFloat < MinETHRequired {
log.Printf("ETH balance %.6f below minimum %.4f, requesting funding...", ethFloat, MinETHRequired)
needsFunding = true
}
if needsFunding {
if faucetURL == "" {
return fmt.Errorf("FUNDING_FAUCET_URL not set but wallet needs funding")
}
if usdcFloat < MinUSDCRequired {
log.Printf("Requesting USDC from faucet...")
if err := requestFaucetFunds(faucetURL, addrStr, "USDC"); err != nil {
return fmt.Errorf("failed to request USDC from faucet: %w", err)
}
}
if ethFloat < MinETHRequired {
log.Printf("Requesting ETH from faucet...")
if err := requestFaucetFunds(faucetURL, addrStr, "ETH"); err != nil {
return fmt.Errorf("failed to request ETH from faucet: %w", err)
}
}
log.Printf("Waiting for funding to arrive...")
time.Sleep(30 * time.Second)
usdcBalance, err = getUSDCBalance(client, address)
if err != nil {
return fmt.Errorf("failed to get USDC balance after funding: %w", err)
}
usdcBalanceFloat = new(big.Float).Quo(new(big.Float).SetInt(usdcBalance), big.NewFloat(1e6))
usdcFloat, _ = usdcBalanceFloat.Float64()
log.Printf("New USDC balance: %.6f USDC", usdcFloat)
ethBalance, err = getETHBalance(client, address)
if err != nil {
return fmt.Errorf("failed to get ETH balance after funding: %w", err)
}
ethBalanceFloat = new(big.Float).Quo(new(big.Float).SetInt(ethBalance), big.NewFloat(1e18))
ethFloat, _ = ethBalanceFloat.Float64()
log.Printf("New ETH balance: %.6f ETH", ethFloat)
if usdcFloat < MinUSDCRequired {
return fmt.Errorf("USDC balance still insufficient after funding: %.6f < %.2f", usdcFloat, MinUSDCRequired)
}
if ethFloat < MinETHRequired {
return fmt.Errorf("ETH balance still insufficient after funding: %.6f < %.4f", ethFloat, MinETHRequired)
}
} else {
log.Printf("Wallet has sufficient funds")
}
return nil
}
func main() { func main() {
godotenv.Load() godotenv.Load()
var ( privateKey := os.Getenv("PRIVATE_KEY")
privateKey = os.Getenv("PRIVATE_KEY") serverURL := getEnv("TEST_SERVER_URL", "http://localhost:8080")
serverURL = "https://api.naco.li" faucetURL := getEnv("FUNDING_FAUCET_URL", "")
err error rpcURL := getEnv("RPC_URL", BaseSepoliaRPC)
)
chainConfig := x402.BaseMainnet if privateKey == "" {
log.Fatal("PRIVATE_KEY environment variable is required")
signerOpts := []evm.SignerOption{
evm.WithPrivateKey(privateKey),
evm.WithNetwork(chainConfig.NetworkID),
evm.WithToken(chainConfig.USDCAddress, chainConfig.EIP3009Name, int(chainConfig.Decimals)),
} }
signer, err := evm.NewSigner(signerOpts...) log.Printf("Ensuring wallet is funded...")
if err != nil { if err := ensureFundedWallet(privateKey, faucetURL, rpcURL); err != nil {
log.Fatal("Failed to create signer:", err) log.Fatal("Failed to ensure wallet is funded: ", err)
} }
/*x402mcpTransport, err := x402client.NewTransport(serverURL+"/mcp", x402client.WithSigner(signer)) signer, err := evmsigners.NewClientSignerFromPrivateKey(privateKey)
if err != nil { if err != nil {
log.Fatal("Failed to create transport:", err) log.Fatal("Failed to create EVM signer:", err)
}*/ }
x402Client := x402.Newx402Client().
Register("eip155:84532", evm.NewExactEvmScheme(signer, nil))
debugClient := &http.Client{ debugClient := &http.Client{
Transport: &LoggingRoundTripper{ Transport: &LoggingRoundTripper{
Proxied: http.DefaultTransport, Proxied: http.DefaultTransport,
}, },
Timeout: 60 * time.Second,
} }
callback := x402.PaymentCallback(func(pe x402.PaymentEvent) { httpClient := x402http.WrapHTTPClientWithPayment(
log.Printf("Type: %s, Tool: %s, Error: %s", pe.Type, pe.Tool, pe.Error) debugClient,
}) x402http.Newx402HTTPClient(x402Client),
// Create MCP client with x402 transport
//mcpClient := client.NewClient(x402mcpTransport)
httpClient, err := x402http.NewClient(x402http.WithPaymentCallbacks(
callback,
callback,
callback,
),
x402http.WithHTTPClient(debugClient),
x402http.WithSelector(x402.NewDefaultPaymentSelector()),
x402http.WithSigner(signer),
) )
if err != nil {
log.Fatal("Failed to create HTTP client:", err) log.Printf("Starting REST API endpoint tests against %s...", serverURL)
testCases := []struct {
name string
method string
endpoint string
body string
}{
{"getFeed", "POST", "getFeed", `{"uri": "https://bsky.app/profile/did:plc:z72i7hdynmk6r22z27h6tvur/feed/whats-hot"}`},
{"getActor", "POST", "getActor", `{"author": "did:plc:z72i7hdynmk6r22z27h6tvur"}`},
{"searchActors", "POST", "searchActors", `{"query": "test", "limit": 5}`},
{"getPost", "POST", "getPost", `{"actor":"sharkgamiart.bsky.social","rkey":"3m47y27c67c2b"}`},
{"getLatestPosts", "POST", "getLatestPosts", `{"author":"sharkgamiart.bsky.social","limit": 5}`},
{"getPostsInTime", "POST", "getPostsInTime", `{"author":"sharkgamiart.bsky.social","startTime": "2024-01-01T00:00:00Z","endTime": "2024-12-31T23:59:59Z","limit": 5}`},
{"getStyleProfile", "POST", "getStyleProfile", `{"author":"did:plc:z72i7hdynmk6r22z27h6tvur"}`},
{"findSimilarUsers", "POST", "findSimilarUsers", `{"author":"did:plc:z72i7hdynmk6r22z27h6tvur","limit": 5}`},
} }
ctx := context.Background() passed := 0
/*if err := mcpClient.Start(ctx); err != nil { failed := 0
log.Fatal("Failed to start client:", err)
}
defer mcpClient.Close()
*/
//startTime, _ := time.Parse(time.DateOnly, "2025-10-25")
//endTime, _ := time.Parse(time.DateOnly, "2025-10-31")
/*// Initialize MCP session for _, tc := range testCases {
initResp, err := mcpClient.Initialize(ctx, mcp.InitializeRequest{ fmt.Printf("\n=== Testing %s ===\n", tc.name)
Params: mcp.InitializeParams{ err := callEndpoint(httpClient, tc.method, tc.endpoint, tc.body)
ProtocolVersion: "2024-11-05",
ClientInfo: mcp.Implementation{
Name: "x402-test",
Version: "1.0.0",
},
},
})
if err != nil {
log.Fatal("Failed to initialize:", err)
}
log.Printf("Connected to server: %s v%s",
initResp.ServerInfo.Name, initResp.ServerInfo.Version)
// List all available tools
log.Println("\nListing available tools...")
toolsResp, err := mcpClient.ListTools(ctx, mcp.ListToolsRequest{})
if err != nil {
log.Printf("Failed to list tools: %v", err)
} else {
log.Println("Available tools:")
for _, tool := range toolsResp.Tools {
log.Printf(" - %s: %s", tool.Name, tool.Description)
}
}
log.Println("\nCalling feed tool (free)...")
_, err = mcpClient.CallTool(ctx, mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "getFeed",
Arguments: map[string]any{
"uri": "https://bsky.app/profile/did:plc:z72i7hdynmk6r22z27h6tvur/feed/whats-hot",
},
},
})
if err != nil {
log.Printf("Feed failed: %v", err)
} else {
log.Println("Feed response:")
if len(echoResp.Content) > 0 {
if textContent, ok := mcp.AsTextContent(echoResp.Content[0]); ok {
log.Println(textContent.Text)
}
}
log.Println("Feed response received successfully.")
}
log.Println("\nCalling feed tool again with proper feed uri...")
_, err = mcpClient.CallTool(ctx, mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "getFeed",
Arguments: map[string]any{
"uri": "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot",
},
},
})
if err != nil {
log.Printf("Feed failed: %v", err)
} else {
log.Println("Feed response:")
if len(echoResp.Content) > 0 {
if textContent, ok := mcp.AsTextContent(echoResp.Content[0]); ok {
log.Println(textContent.Text)
}
}
log.Println("Feed response received successfully.")
}
// Call the search tool (paid)
log.Println("\nGet Post (paid tool)...")
searchResp, err := mcpClient.CallTool(ctx, mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "getPost",
Arguments: map[string]any{
"actor": "sharkgamiart.bsky.social",
"rkey": "3m47y27c67c2b",
},
},
})
if err != nil {
log.Fatalf("Get Post failed: %v", err)
}
log.Println("Post results:")
if len(searchResp.Content) > 0 {
if textContent, ok := mcp.AsTextContent(searchResp.Content[0]); ok {
log.Println(textContent.Text)
}
}
log.Println("\nGet latest posts (paid tool)...")
latestResp, err := mcpClient.CallTool(ctx, mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "getLatestPosts",
Arguments: map[string]any{
"actor": "sharkgamiart.bsky.social",
"n": 10,
},
},
})
if err != nil {
log.Fatalf("Get Latest Posts failed: %v", err)
}
log.Printf("Latest posts (%d):\n", len(latestResp.Content))
if len(latestResp.Content) > 0 {
if textContent, ok := mcp.AsTextContent(latestResp.Content[0]); ok {
log.Println(" - ", textContent.Text)
}
}
log.Println("\nGet posts in timeframe (paid tool)...")
timeframeResp, err := mcpClient.CallTool(ctx, mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "getPostsInTime",
Arguments: map[string]any{
"actor": "sharkgamiart.bsky.social",
"startTime": startTime.Format(time.RFC3339),
"endTime": endTime.Format(time.RFC3339),
"n": 5,
},
},
})
if err != nil {
log.Fatalf("Get Posts in Timeframe failed: %v", err)
}
log.Println("Posts in timeframe results:")
if len(timeframeResp.Content) > 0 {
if textContent, ok := mcp.AsTextContent(timeframeResp.Content[0]); ok {
log.Println(textContent.Text)
}
}
mcpClient.Close()*/
var dataGetFeed any
log.Println("Calling getFeed via HTTP client")
resp, err := httpClient.Post(fmt.Sprintf("%s/getFeed", serverURL), "application/json", bytes.NewReader([]byte(`{"uri": "https://bsky.app/profile/did:plc:z72i7hdynmk6r22z27h6tvur/feed/whats-hot"}`)))
if err != nil {
log.Println("getFeed failed:", err)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Println("Failed to read getFeed response body:", err)
}
if resp.StatusCode != http.StatusOK {
log.Printf("Raw Response Body: %s\n", string(body))
}
err = json.Unmarshal(body, &dataGetFeed)
if err != nil {
log.Println("Failed to unmarshal getFeed response body:", err)
log.Printf("Received Raw Body: %s\n", string(body))
}
//log.Println("getFeed response:", dataGetFeed)
resp.Body.Close()
/* var dataGetFeed2 any
log.Println("Calling getFeed via HTTP client with proper feed uri")
resp, err = httpClient.Post(fmt.Sprintf("%s/getFeed", serverURL), "application/json", bytes.NewReader([]byte(`{"uri":"at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot"}`)))
if err != nil { if err != nil {
log.Println("getFeed failed:", err) log.Printf("FAIL %s: %v\n", tc.name, err)
failed++
} else {
fmt.Printf("PASS %s\n", tc.name)
passed++
} }
body, err = io.ReadAll(resp.Body)
if err != nil {
log.Println("Failed to read getFeed response body:", err)
}
if resp.StatusCode != http.StatusOK {
log.Printf("Raw Response Body: %s\n", string(body))
}
err = json.Unmarshal(body, &dataGetFeed2)
if err != nil {
log.Println("Failed to unmarshal getFeed response body:", err)
log.Printf("Received Raw Body: %s\n", string(body))
}
//log.Println("getFeed response:", dataGetFeed2)
resp.Body.Close()
*/
var dataGetPost any
log.Println("Calling getPost via HTTP client")
bodyBytes := []byte(`{"actor":"sharkgamiart.bsky.social","rkey":"3m47y27c67c2b"}`)
req, _ := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("%s/getPost", serverURL), bytes.NewReader(bodyBytes))
req.GetBody = func() (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(bodyBytes)), nil
}
req.Header.Set("Content-Type", "application/json")
resp, err = httpClient.Do(req)
if err != nil {
log.Println("getPost failed:", err)
} }
body, err = io.ReadAll(resp.Body) log.Printf("\n=== Test Summary ===")
if err != nil { log.Printf("Passed: %d", passed)
log.Println("Failed to read getPost response body:", err) log.Printf("Failed: %d", failed)
if failed > 0 {
os.Exit(1)
} }
if resp.StatusCode != http.StatusOK {
log.Printf("Raw Response Body: %s\n", string(body))
}
err = json.Unmarshal(body, &dataGetPost)
if err != nil {
log.Println("Failed to unmarshal getPost response body:", err)
log.Printf("Received Raw Body: %s\n", string(body))
}
log.Println("getPost response:", dataGetPost)
resp.Body.Close()
/*
var dataGetLatestPosts any
log.Println("Calling getLatestPosts via HTTP client")
resp, err = httpClient.Post(fmt.Sprintf("%s/getLatestPosts", serverURL), "application/json", bytes.NewReader([]byte(`{"actor":"sharkgamiart.bsky.social","n":10}`)))
if err != nil {
log.Println("getLatestPosts failed:", err)
}
log.Println(resp.Proto)
body, err = io.ReadAll(resp.Body)
if err != nil {
log.Println("Failed to read getLatestPosts response body:", err)
}
if resp.StatusCode != http.StatusOK {
log.Printf("Raw Response Body: %s\n", string(body))
}
err = json.Unmarshal(body, &dataGetLatestPosts)
if err != nil {
log.Println("Failed to unmarshal getLatestPosts response body:", err)
log.Printf("Received Raw Body: %s\n", string(body))
}
log.Println("getLatestPosts response:", dataGetLatestPosts)
resp.Body.Close()
var dataGetPostsInTime any
log.Println("Calling getPostsInTime via HTTP client")
resp, err = httpClient.Post(fmt.Sprintf("%s/getPostsInTime", serverURL), "application/json", bytes.NewReader([]byte(fmt.Sprintf(`{"actor":"sharkgamiart.bsky.social","startTime":"%s","endTime":"%s","n":5}`, startTime.Format(time.RFC3339), endTime.Format(time.RFC3339)))))
if err != nil {
log.Println("getPostsInTime failed:", err)
}
body, err = io.ReadAll(resp.Body)
if err != nil {
log.Println("Failed to read getPostsInTime response body:", err)
}
if resp.StatusCode != http.StatusOK {
log.Printf("Raw Response Body: %s\n", string(body))
}
err = json.Unmarshal(body, &dataGetPostsInTime)
if err != nil {
log.Println("Failed to unmarshal getPostsInTime response body:", err)
log.Printf("Received Raw Body: %s\n", string(body))
}
log.Println("getPostsInTime response:", dataGetPostsInTime)
resp.Body.Close()
*/
} }