mcp-server-agentpay 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentpay/__init__.py +3 -0
- agentpay/client/__init__.py +0 -0
- agentpay/client/http_client.py +208 -0
- agentpay/config.py +68 -0
- agentpay/gateway/__init__.py +0 -0
- agentpay/gateway/app.py +207 -0
- agentpay/gateway/data.py +65 -0
- agentpay/gateway/middleware.py +53 -0
- agentpay/mcp_server/__init__.py +0 -0
- agentpay/mcp_server/server.py +154 -0
- agentpay/payment/__init__.py +0 -0
- agentpay/payment/verifier.py +222 -0
- agentpay/payment/x402_setup.py +170 -0
- agentpay/xhs/__init__.py +5 -0
- agentpay/xhs/dataflow.py +214 -0
- agentpay/xhs/fetcher.py +263 -0
- agentpay/xhs/normalize.py +245 -0
- agentpay/xhs/tikhub.py +112 -0
- mcp_server_agentpay-0.1.0.dist-info/METADATA +206 -0
- mcp_server_agentpay-0.1.0.dist-info/RECORD +23 -0
- mcp_server_agentpay-0.1.0.dist-info/WHEEL +4 -0
- mcp_server_agentpay-0.1.0.dist-info/entry_points.txt +3 -0
- mcp_server_agentpay-0.1.0.dist-info/licenses/LICENSE +21 -0
agentpay/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Autonomous x402 payment HTTP client for AI agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from agentpay.config import Settings, get_settings
|
|
11
|
+
from agentpay.payment.verifier import create_demo_payment_signature
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PaymentPolicyError(Exception):
|
|
17
|
+
"""Raised when a payment exceeds agent spending policy."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class X402PaymentRequired(Exception):
|
|
21
|
+
"""Raised when 402 is received but auto-pay is disabled."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, challenge: dict[str, Any], headers: dict[str, str]):
|
|
24
|
+
self.challenge = challenge
|
|
25
|
+
self.headers = headers
|
|
26
|
+
super().__init__("Payment required")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class X402AgentClient:
|
|
30
|
+
"""
|
|
31
|
+
HTTP client that intercepts 402 Payment Required responses,
|
|
32
|
+
signs payment authorization, and automatically retries.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, settings: Settings | None = None):
|
|
36
|
+
self.settings = settings or get_settings()
|
|
37
|
+
self._payer_address: str | None = None
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def payer_address(self) -> str:
|
|
41
|
+
if self._payer_address:
|
|
42
|
+
return self._payer_address
|
|
43
|
+
|
|
44
|
+
if self.settings.agent_private_key:
|
|
45
|
+
try:
|
|
46
|
+
from eth_account import Account
|
|
47
|
+
|
|
48
|
+
account = Account.from_key(self.settings.agent_private_key)
|
|
49
|
+
self._payer_address = account.address
|
|
50
|
+
return self._payer_address
|
|
51
|
+
except Exception as e:
|
|
52
|
+
logger.warning("Invalid agent private key: %s", e)
|
|
53
|
+
|
|
54
|
+
self._payer_address = "0xAgentDemoWallet000000000000000000000001"
|
|
55
|
+
return self._payer_address
|
|
56
|
+
|
|
57
|
+
def _check_spending_policy(self, amount_str: str) -> None:
|
|
58
|
+
amount = float(str(amount_str).lstrip("$"))
|
|
59
|
+
if amount > self.settings.max_spend_per_call:
|
|
60
|
+
raise PaymentPolicyError(
|
|
61
|
+
f"Payment {amount} USDC exceeds max_spend_per_call "
|
|
62
|
+
f"({self.settings.max_spend_per_call} USDC)"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def _sign_demo_payment(self, challenge: dict[str, Any]) -> str:
|
|
66
|
+
amount = challenge.get("amount") or self.settings.payment_amount
|
|
67
|
+
recipient = challenge.get("recipient") or self.settings.wallet_recipient_address
|
|
68
|
+
self._check_spending_policy(str(amount))
|
|
69
|
+
sig = create_demo_payment_signature(
|
|
70
|
+
payer=self.payer_address,
|
|
71
|
+
amount=str(amount).lstrip("$"),
|
|
72
|
+
recipient=recipient,
|
|
73
|
+
)
|
|
74
|
+
logger.info("Demo payment signed: %s USDC -> %s", amount, recipient[:10])
|
|
75
|
+
return sig
|
|
76
|
+
|
|
77
|
+
async def request(
|
|
78
|
+
self,
|
|
79
|
+
method: str,
|
|
80
|
+
url: str,
|
|
81
|
+
*,
|
|
82
|
+
params: dict[str, Any] | None = None,
|
|
83
|
+
auto_pay: bool = True,
|
|
84
|
+
**kwargs: Any,
|
|
85
|
+
) -> httpx.Response:
|
|
86
|
+
"""Make HTTP request with automatic 402 handling."""
|
|
87
|
+
if not self.settings.is_demo_mode:
|
|
88
|
+
return await self._request_official_x402(
|
|
89
|
+
method, url, params=params, auto_pay=auto_pay, **kwargs
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
async with httpx.AsyncClient(timeout=90.0) as client:
|
|
93
|
+
response = await client.request(method, url, params=params, **kwargs)
|
|
94
|
+
|
|
95
|
+
if response.status_code != 402:
|
|
96
|
+
return response
|
|
97
|
+
|
|
98
|
+
if not auto_pay:
|
|
99
|
+
raise X402PaymentRequired(response.json(), dict(response.headers))
|
|
100
|
+
|
|
101
|
+
challenge = response.json()
|
|
102
|
+
amount = (
|
|
103
|
+
challenge.get("amount")
|
|
104
|
+
or response.headers.get("X-Payment-Amount")
|
|
105
|
+
or self.settings.payment_amount
|
|
106
|
+
)
|
|
107
|
+
challenge["amount"] = amount
|
|
108
|
+
challenge["recipient"] = (
|
|
109
|
+
challenge.get("recipient")
|
|
110
|
+
or response.headers.get("X-Payment-Recipient")
|
|
111
|
+
or self.settings.wallet_recipient_address
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
signature = self._sign_demo_payment(challenge)
|
|
115
|
+
headers = kwargs.pop("headers", {}) or {}
|
|
116
|
+
headers["X-Payment-Signature"] = signature
|
|
117
|
+
headers["X-Payment-Payer"] = self.payer_address
|
|
118
|
+
|
|
119
|
+
logger.info("Retrying paid request to %s", url)
|
|
120
|
+
return await client.request(method, url, params=params, headers=headers, **kwargs)
|
|
121
|
+
|
|
122
|
+
async def _request_official_x402(
|
|
123
|
+
self,
|
|
124
|
+
method: str,
|
|
125
|
+
url: str,
|
|
126
|
+
*,
|
|
127
|
+
params: dict[str, Any] | None = None,
|
|
128
|
+
auto_pay: bool = True,
|
|
129
|
+
**kwargs: Any,
|
|
130
|
+
) -> httpx.Response:
|
|
131
|
+
"""Production path: official x402 SDK signs + facilitator settles."""
|
|
132
|
+
if not self.settings.agent_private_key:
|
|
133
|
+
raise ValueError("AGENT_PRIVATE_KEY required for production x402 payments")
|
|
134
|
+
|
|
135
|
+
from eth_account import Account
|
|
136
|
+
from x402 import x402Client
|
|
137
|
+
from x402.http.clients.httpx import x402HttpxClient
|
|
138
|
+
from x402.mechanisms.evm.exact import ExactEvmScheme
|
|
139
|
+
from x402.mechanisms.evm.signers import EthAccountSigner
|
|
140
|
+
|
|
141
|
+
self._check_spending_policy(self.settings.payment_amount)
|
|
142
|
+
|
|
143
|
+
account = Account.from_key(self.settings.agent_private_key)
|
|
144
|
+
self._payer_address = account.address
|
|
145
|
+
signer = EthAccountSigner(account)
|
|
146
|
+
|
|
147
|
+
payment_client = x402Client()
|
|
148
|
+
payment_client.register(self.settings.payment_network, ExactEvmScheme(signer))
|
|
149
|
+
# Also register wildcard for network matching quirks
|
|
150
|
+
payment_client.register("eip155:*", ExactEvmScheme(signer))
|
|
151
|
+
|
|
152
|
+
if not auto_pay:
|
|
153
|
+
async with httpx.AsyncClient(timeout=90.0) as client:
|
|
154
|
+
response = await client.request(method, url, params=params, **kwargs)
|
|
155
|
+
if response.status_code == 402:
|
|
156
|
+
raise X402PaymentRequired(
|
|
157
|
+
response.json() if response.content else {},
|
|
158
|
+
dict(response.headers),
|
|
159
|
+
)
|
|
160
|
+
return response
|
|
161
|
+
|
|
162
|
+
async with x402HttpxClient(payment_client, timeout=90.0) as client:
|
|
163
|
+
response = await client.request(method, url, params=params, **kwargs)
|
|
164
|
+
return response
|
|
165
|
+
|
|
166
|
+
async def get_sourcing(
|
|
167
|
+
self,
|
|
168
|
+
keyword: str,
|
|
169
|
+
category_id: str | None = None,
|
|
170
|
+
max_price: float | None = None,
|
|
171
|
+
) -> dict[str, Any]:
|
|
172
|
+
settings = self.settings
|
|
173
|
+
url = f"{settings.gateway_base_url}/api/v1/paid/sourcing"
|
|
174
|
+
params: dict[str, Any] = {"keyword": keyword}
|
|
175
|
+
if category_id:
|
|
176
|
+
params["category_id"] = category_id
|
|
177
|
+
if max_price is not None:
|
|
178
|
+
params["max_price"] = max_price
|
|
179
|
+
|
|
180
|
+
response = await self.request("GET", url, params=params)
|
|
181
|
+
response.raise_for_status()
|
|
182
|
+
return response.json()
|
|
183
|
+
|
|
184
|
+
async def get_xhs_note(
|
|
185
|
+
self,
|
|
186
|
+
note: str,
|
|
187
|
+
note_type: str | None = None,
|
|
188
|
+
) -> dict[str, Any]:
|
|
189
|
+
url = f"{self.settings.gateway_base_url}/api/v1/paid/xhs/note"
|
|
190
|
+
params: dict[str, Any] = {"note": note}
|
|
191
|
+
if note_type:
|
|
192
|
+
params["note_type"] = note_type
|
|
193
|
+
response = await self.request("GET", url, params=params)
|
|
194
|
+
response.raise_for_status()
|
|
195
|
+
return response.json()
|
|
196
|
+
|
|
197
|
+
async def get_xhs_user_notes(
|
|
198
|
+
self,
|
|
199
|
+
user_id: str,
|
|
200
|
+
cursor: str = "",
|
|
201
|
+
) -> dict[str, Any]:
|
|
202
|
+
url = f"{self.settings.gateway_base_url}/api/v1/paid/xhs/user_notes"
|
|
203
|
+
params: dict[str, Any] = {"user_id": user_id}
|
|
204
|
+
if cursor:
|
|
205
|
+
params["cursor"] = cursor
|
|
206
|
+
response = await self.request("GET", url, params=params)
|
|
207
|
+
response.raise_for_status()
|
|
208
|
+
return response.json()
|
agentpay/config.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Central configuration loaded from environment variables."""
|
|
2
|
+
|
|
3
|
+
from functools import lru_cache
|
|
4
|
+
|
|
5
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
6
|
+
|
|
7
|
+
# Well-known USDC contract addresses
|
|
8
|
+
USDC_BASE_MAINNET = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
|
|
9
|
+
USDC_BASE_SEPOLIA = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Settings(BaseSettings):
|
|
13
|
+
model_config = SettingsConfigDict(
|
|
14
|
+
env_file=".env",
|
|
15
|
+
env_file_encoding="utf-8",
|
|
16
|
+
extra="ignore",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Gateway
|
|
20
|
+
wallet_recipient_address: str = "0x5f29e616e34bf86dD72B622Ef392b2FDcF1Aa50E"
|
|
21
|
+
payment_network: str = "eip155:8453" # Base Mainnet; use eip155:84532 for Sepolia test
|
|
22
|
+
payment_amount: str = "0.05"
|
|
23
|
+
payment_token_address: str = USDC_BASE_MAINNET
|
|
24
|
+
gateway_host: str = "127.0.0.1"
|
|
25
|
+
gateway_port: int = 8402
|
|
26
|
+
gateway_base_url: str = "http://127.0.0.1:8402"
|
|
27
|
+
|
|
28
|
+
# demo = local fake pay | production = official x402 facilitator settle
|
|
29
|
+
payment_mode: str = "demo"
|
|
30
|
+
base_rpc_url: str = "https://mainnet.base.org"
|
|
31
|
+
|
|
32
|
+
# Agent client
|
|
33
|
+
agent_private_key: str = ""
|
|
34
|
+
max_spend_per_call: float = 0.10
|
|
35
|
+
|
|
36
|
+
# Optional override; default picks testnet vs mainnet facilitator
|
|
37
|
+
x402_facilitator_url: str = ""
|
|
38
|
+
|
|
39
|
+
# Xiaohongshu data providers (gateway-side secrets only)
|
|
40
|
+
dataflow_base_url: str = "https://dataflowserver.org"
|
|
41
|
+
dataflow_api_token: str = ""
|
|
42
|
+
tikhub_api_key: str = ""
|
|
43
|
+
|
|
44
|
+
# Operator debug only — agents should always pay the gateway
|
|
45
|
+
xhs_direct_mode: bool = False
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def payment_token_symbol(self) -> str:
|
|
49
|
+
return "USDC"
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def is_demo_mode(self) -> bool:
|
|
53
|
+
return self.payment_mode.lower() == "demo"
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def is_testnet(self) -> bool:
|
|
57
|
+
return "84532" in self.payment_network
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def has_xhs_credentials(self) -> bool:
|
|
61
|
+
return bool(
|
|
62
|
+
(self.dataflow_api_token or "").strip() or (self.tikhub_api_key or "").strip()
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@lru_cache
|
|
67
|
+
def get_settings() -> Settings:
|
|
68
|
+
return Settings()
|
|
File without changes
|
agentpay/gateway/app.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""AgentPay x402 Micropayment Data Gateway — FastAPI application."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
|
|
8
|
+
import uvicorn
|
|
9
|
+
from fastapi import FastAPI, Query
|
|
10
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
11
|
+
|
|
12
|
+
from agentpay.config import get_settings
|
|
13
|
+
from agentpay.gateway.data import query_wholesale_pricing
|
|
14
|
+
from agentpay.gateway.middleware import X402PaymentMiddleware
|
|
15
|
+
from agentpay.mcp_server.server import mcp as mcp_server
|
|
16
|
+
from agentpay.payment.x402_setup import build_paid_routes, build_resource_server, settlement_info
|
|
17
|
+
from agentpay.xhs.fetcher import fetch_note_detail, fetch_user_notes
|
|
18
|
+
|
|
19
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def create_app() -> FastAPI:
|
|
24
|
+
settings = get_settings()
|
|
25
|
+
|
|
26
|
+
# Remote MCP (Streamable HTTP) for Smithery / Glama connectors
|
|
27
|
+
mcp_http = mcp_server.streamable_http_app(
|
|
28
|
+
streamable_http_path="/",
|
|
29
|
+
stateless_http=True,
|
|
30
|
+
host="0.0.0.0",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
@asynccontextmanager
|
|
34
|
+
async def lifespan(_app: FastAPI):
|
|
35
|
+
async with mcp_http.router.lifespan_context(mcp_http):
|
|
36
|
+
logger.info("MCP Streamable HTTP mounted at /mcp")
|
|
37
|
+
yield
|
|
38
|
+
|
|
39
|
+
app = FastAPI(
|
|
40
|
+
title="AgentPay x402 Gateway",
|
|
41
|
+
description=(
|
|
42
|
+
"Monetized API gateway with HTTP 402 micropayment enforcement. "
|
|
43
|
+
"Agents pay USDC on Base per request for Xiaohongshu note data and other premium APIs. "
|
|
44
|
+
"Also exposes MCP tools at /mcp (Streamable HTTP)."
|
|
45
|
+
),
|
|
46
|
+
version="0.1.0",
|
|
47
|
+
lifespan=lifespan,
|
|
48
|
+
)
|
|
49
|
+
app.mount("/mcp", mcp_http)
|
|
50
|
+
|
|
51
|
+
# Railway/Fly terminate TLS; trust X-Forwarded-* so 402 resource URLs are https://
|
|
52
|
+
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
|
53
|
+
|
|
54
|
+
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
|
|
55
|
+
|
|
56
|
+
app.add_middleware(
|
|
57
|
+
CORSMiddleware,
|
|
58
|
+
allow_origins=["*"],
|
|
59
|
+
allow_methods=["*"],
|
|
60
|
+
allow_headers=["*"],
|
|
61
|
+
expose_headers=[
|
|
62
|
+
"X-Payment-Required",
|
|
63
|
+
"X-Payment-Network",
|
|
64
|
+
"X-Payment-Token",
|
|
65
|
+
"X-Payment-Amount",
|
|
66
|
+
"X-Payment-Recipient",
|
|
67
|
+
"X-Payment-Verified",
|
|
68
|
+
"X-Payment-Payer",
|
|
69
|
+
"PAYMENT-REQUIRED",
|
|
70
|
+
"PAYMENT-RESPONSE",
|
|
71
|
+
],
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if settings.is_demo_mode:
|
|
75
|
+
# Local/dev: accept demo signatures (no on-chain settlement)
|
|
76
|
+
app.add_middleware(X402PaymentMiddleware, settings=settings)
|
|
77
|
+
logger.info("Payment middleware: demo (no chain settlement)")
|
|
78
|
+
else:
|
|
79
|
+
# Production/testnet: official x402 verify + settle via facilitator
|
|
80
|
+
from x402.http.middleware.fastapi import PaymentMiddlewareASGI
|
|
81
|
+
|
|
82
|
+
server = build_resource_server(settings)
|
|
83
|
+
routes = build_paid_routes(settings)
|
|
84
|
+
app.add_middleware(PaymentMiddlewareASGI, routes=routes, server=server)
|
|
85
|
+
logger.info(
|
|
86
|
+
"Payment middleware: x402 facilitator=%s network=%s pay_to=%s",
|
|
87
|
+
settlement_info(settings)["facilitator"],
|
|
88
|
+
settings.payment_network,
|
|
89
|
+
settings.wallet_recipient_address,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
@app.get("/health")
|
|
93
|
+
async def health():
|
|
94
|
+
info = settlement_info(settings)
|
|
95
|
+
return {
|
|
96
|
+
"status": "ok",
|
|
97
|
+
"service": "agentpay-gateway",
|
|
98
|
+
"payment_mode": settings.payment_mode,
|
|
99
|
+
"xhs_credentials": settings.has_xhs_credentials,
|
|
100
|
+
"mcp": "/mcp",
|
|
101
|
+
"settlement": info,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
@app.get("/api/v1/pricing")
|
|
105
|
+
async def pricing_info():
|
|
106
|
+
"""Public endpoint: returns payment requirements without serving data."""
|
|
107
|
+
return {
|
|
108
|
+
"services": [
|
|
109
|
+
{
|
|
110
|
+
"name": "xhs_note_detail",
|
|
111
|
+
"paid_endpoint": "/api/v1/paid/xhs/note",
|
|
112
|
+
"amount": settings.payment_amount,
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"name": "xhs_user_notes",
|
|
116
|
+
"paid_endpoint": "/api/v1/paid/xhs/user_notes",
|
|
117
|
+
"amount": settings.payment_amount,
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
"name": "china_wholesale_pricing",
|
|
121
|
+
"paid_endpoint": "/api/v1/paid/sourcing",
|
|
122
|
+
"amount": settings.payment_amount,
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
"payment_required": True,
|
|
126
|
+
"network": settings.payment_network,
|
|
127
|
+
"token": settings.payment_token_symbol,
|
|
128
|
+
"recipient": settings.wallet_recipient_address,
|
|
129
|
+
"settlement": settlement_info(settings),
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
@app.get("/api/v1/paid/sourcing")
|
|
133
|
+
async def get_sourcing_data(
|
|
134
|
+
keyword: str = Query(..., description="Product search query"),
|
|
135
|
+
category_id: str | None = Query(None, description="Optional category filter"),
|
|
136
|
+
max_price: float | None = Query(None, description="Max unit price USD"),
|
|
137
|
+
):
|
|
138
|
+
"""Monetized endpoint: wholesale pricing data (requires x402 payment)."""
|
|
139
|
+
return query_wholesale_pricing(keyword, category_id, max_price)
|
|
140
|
+
|
|
141
|
+
@app.get("/api/v1/paid/xhs/note")
|
|
142
|
+
async def get_xhs_note(
|
|
143
|
+
note: str = Query(..., description="Xiaohongshu note URL or 24-char note_id"),
|
|
144
|
+
note_type: str | None = Query(
|
|
145
|
+
None, description="Optional hint: image | video | 图文 | 视频"
|
|
146
|
+
),
|
|
147
|
+
):
|
|
148
|
+
"""Monetized: fetch Xiaohongshu note detail."""
|
|
149
|
+
if not settings.has_xhs_credentials:
|
|
150
|
+
return {
|
|
151
|
+
"status": "error",
|
|
152
|
+
"error": "Note data provider not configured on gateway",
|
|
153
|
+
}
|
|
154
|
+
return await fetch_note_detail(
|
|
155
|
+
note_id_or_link=note,
|
|
156
|
+
note_type_hint=note_type,
|
|
157
|
+
settings=settings,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
@app.get("/api/v1/paid/xhs/user_notes")
|
|
161
|
+
async def get_xhs_user_notes(
|
|
162
|
+
user_id: str = Query(..., description="Xiaohongshu user ID"),
|
|
163
|
+
cursor: str = Query("", description="Pagination cursor from previous response"),
|
|
164
|
+
):
|
|
165
|
+
"""Monetized: fetch a user's posted notes list."""
|
|
166
|
+
if not settings.has_xhs_credentials:
|
|
167
|
+
return {
|
|
168
|
+
"status": "error",
|
|
169
|
+
"error": "Note data provider not configured on gateway",
|
|
170
|
+
}
|
|
171
|
+
return await fetch_user_notes(user_id=user_id, cursor=cursor, settings=settings)
|
|
172
|
+
|
|
173
|
+
return app
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
app = create_app()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def main() -> None:
|
|
180
|
+
# Clear settings cache so .env edits apply on restart
|
|
181
|
+
get_settings.cache_clear()
|
|
182
|
+
settings = get_settings()
|
|
183
|
+
info = settlement_info(settings)
|
|
184
|
+
# Railway/Cloud inject PORT
|
|
185
|
+
import os
|
|
186
|
+
|
|
187
|
+
port = int(os.getenv("PORT") or settings.gateway_port)
|
|
188
|
+
host = os.getenv("HOST") or settings.gateway_host or "0.0.0.0"
|
|
189
|
+
logger.info(
|
|
190
|
+
"Starting AgentPay Gateway on %s:%s mode=%s network=%s pay_to=%s facilitator=%s",
|
|
191
|
+
host,
|
|
192
|
+
port,
|
|
193
|
+
settings.payment_mode,
|
|
194
|
+
settings.payment_network,
|
|
195
|
+
settings.wallet_recipient_address,
|
|
196
|
+
info["facilitator"],
|
|
197
|
+
)
|
|
198
|
+
uvicorn.run(
|
|
199
|
+
"agentpay.gateway.app:app",
|
|
200
|
+
host=host,
|
|
201
|
+
port=port,
|
|
202
|
+
reload=False,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
if __name__ == "__main__":
|
|
207
|
+
main()
|
agentpay/gateway/data.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Mock wholesale pricing data provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _stable_price(keyword: str, index: int) -> float:
|
|
10
|
+
seed = hashlib.md5(f"{keyword}:{index}".encode()).hexdigest()
|
|
11
|
+
return round(0.5 + (int(seed[:8], 16) % 500) / 100, 2)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _factory_locations() -> list[str]:
|
|
15
|
+
return [
|
|
16
|
+
"Yiwu, Zhejiang",
|
|
17
|
+
"Shenzhen, Guangdong",
|
|
18
|
+
"Guangzhou, Guangdong",
|
|
19
|
+
"Dongguan, Guangdong",
|
|
20
|
+
"Ningbo, Zhejiang",
|
|
21
|
+
"Hangzhou, Zhejiang",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def query_wholesale_pricing(
|
|
26
|
+
keyword: str,
|
|
27
|
+
category_id: str | None = None,
|
|
28
|
+
max_price: float | None = None,
|
|
29
|
+
) -> dict[str, Any]:
|
|
30
|
+
"""
|
|
31
|
+
Return structured manufacturing/wholesale data.
|
|
32
|
+
In production, this would call 1688/Yiwu APIs behind the paywall.
|
|
33
|
+
"""
|
|
34
|
+
locations = _factory_locations()
|
|
35
|
+
items: list[dict[str, Any]] = []
|
|
36
|
+
|
|
37
|
+
for i in range(5):
|
|
38
|
+
unit_price = _stable_price(keyword, i)
|
|
39
|
+
if max_price is not None and unit_price > max_price:
|
|
40
|
+
continue
|
|
41
|
+
|
|
42
|
+
item_id = f"CN-{hashlib.md5(f'{keyword}{i}'.encode()).hexdigest()[:6].upper()}"
|
|
43
|
+
items.append(
|
|
44
|
+
{
|
|
45
|
+
"item_id": item_id,
|
|
46
|
+
"name": f"Factory Direct: {keyword.title()} — Model {i + 1}",
|
|
47
|
+
"factory_location": locations[i % len(locations)],
|
|
48
|
+
"supplier_verified": i % 2 == 0,
|
|
49
|
+
"moq": [100, 200, 500, 1000][i % 4],
|
|
50
|
+
"unit_price_usd": unit_price,
|
|
51
|
+
"lead_time_days": [5, 7, 10, 14][i % 4],
|
|
52
|
+
"platform": ["1688", "Yiwu Market", "Alibaba"][i % 3],
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
"status": "success",
|
|
58
|
+
"data": {
|
|
59
|
+
"query": keyword,
|
|
60
|
+
"category_id": category_id,
|
|
61
|
+
"currency": "USD",
|
|
62
|
+
"total_results": len(items),
|
|
63
|
+
"items": items,
|
|
64
|
+
},
|
|
65
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""FastAPI x402 payment enforcement middleware."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from fastapi import Request
|
|
8
|
+
from fastapi.responses import JSONResponse
|
|
9
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
10
|
+
from starlette.responses import Response
|
|
11
|
+
|
|
12
|
+
from agentpay.config import Settings, get_settings
|
|
13
|
+
from agentpay.payment.verifier import build_402_body, build_402_headers, verify_payment
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
PROTECTED_PREFIX = "/api/v1/paid/"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class X402PaymentMiddleware(BaseHTTPMiddleware):
|
|
21
|
+
"""Intercept unpaid requests to monetized routes and return HTTP 402."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, app, settings: Settings | None = None):
|
|
24
|
+
super().__init__(app)
|
|
25
|
+
self.settings = settings or get_settings()
|
|
26
|
+
|
|
27
|
+
async def dispatch(self, request: Request, call_next) -> Response:
|
|
28
|
+
if not request.url.path.startswith(PROTECTED_PREFIX):
|
|
29
|
+
return await call_next(request)
|
|
30
|
+
|
|
31
|
+
signature = request.headers.get("X-Payment-Signature")
|
|
32
|
+
payer = request.headers.get("X-Payment-Payer")
|
|
33
|
+
|
|
34
|
+
if not verify_payment(signature, payer, self.settings):
|
|
35
|
+
logger.info(
|
|
36
|
+
"402 challenge for %s %s (payer=%s)",
|
|
37
|
+
request.method,
|
|
38
|
+
request.url.path,
|
|
39
|
+
payer or "none",
|
|
40
|
+
)
|
|
41
|
+
return JSONResponse(
|
|
42
|
+
status_code=402,
|
|
43
|
+
headers=build_402_headers(self.settings),
|
|
44
|
+
content=build_402_body(self.settings),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
response = await call_next(request)
|
|
48
|
+
|
|
49
|
+
# Attach settlement confirmation header
|
|
50
|
+
response.headers["X-Payment-Verified"] = "true"
|
|
51
|
+
if payer:
|
|
52
|
+
response.headers["X-Payment-Payer"] = payer
|
|
53
|
+
return response
|
|
File without changes
|