feat: add remote URL attachment support and project editing endpoint
This commit is contained in:
@@ -47,6 +47,7 @@ STEALTH_HEADERS = {
|
||||
}
|
||||
TITLE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
|
||||
MIN_FETCH_CHARS = 1000
|
||||
ALLOWED_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS")
|
||||
|
||||
NAT64_PREFIXES = (
|
||||
ipaddress.ip_network("64:ff9b::/96"),
|
||||
@@ -71,9 +72,11 @@ class FetchController:
|
||||
self._settings = settings
|
||||
|
||||
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
|
||||
if name != "fetch_url":
|
||||
raise ToolInputError(f"Unknown fetch tool: {name}")
|
||||
return await self._fetch_url(arguments)
|
||||
if name == "fetch_url":
|
||||
return await self._fetch_url(arguments)
|
||||
if name == "http_request":
|
||||
return await self._http_request(arguments)
|
||||
raise ToolInputError(f"Unknown fetch tool: {name}")
|
||||
|
||||
def _optional_cap(self, requested: Any, content: str) -> str:
|
||||
if requested is None:
|
||||
@@ -121,6 +124,88 @@ class FetchController:
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
def _coerce_mapping(self, value: Any, label: str) -> dict[str, Any] | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
return None
|
||||
try:
|
||||
value = json.loads(stripped)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ToolInputError(f"{label} must be a JSON object.") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise ToolInputError(f"{label} must be a JSON object.")
|
||||
return {str(key): val for key, val in value.items()}
|
||||
|
||||
def _coerce_json(self, value: Any) -> Any:
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ToolInputError("json must be valid JSON.") from exc
|
||||
return value
|
||||
|
||||
async def _http_request(self, arguments: dict[str, Any]) -> str:
|
||||
url = str(arguments.get("url", "")).strip()
|
||||
if not url:
|
||||
raise ToolInputError("http_request requires a url.")
|
||||
if "://" not in url:
|
||||
url = "https://" + url
|
||||
method = (str(arguments.get("method", "")).strip() or "GET").upper()
|
||||
if method not in ALLOWED_METHODS:
|
||||
raise ToolInputError(
|
||||
f"Unsupported HTTP method '{method}'. Use one of {', '.join(ALLOWED_METHODS)}."
|
||||
)
|
||||
await self._guard(url)
|
||||
|
||||
headers = self._coerce_mapping(arguments.get("headers"), "headers")
|
||||
json_body = (
|
||||
self._coerce_json(arguments["json"])
|
||||
if arguments.get("json") is not None
|
||||
else None
|
||||
)
|
||||
form = (
|
||||
self._coerce_mapping(arguments.get("form"), "form")
|
||||
if arguments.get("form") is not None
|
||||
else None
|
||||
)
|
||||
raw_body = arguments.get("body")
|
||||
content = None if raw_body is None else str(raw_body)
|
||||
|
||||
body, final_url, content_type, status, response_headers = await self._stream(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
json_body=json_body,
|
||||
form=form,
|
||||
content=content,
|
||||
raise_5xx=False,
|
||||
)
|
||||
|
||||
if "json" in content_type or content_type.startswith("text/"):
|
||||
content_text = body
|
||||
elif "html" in content_type or "xml" in content_type:
|
||||
content_text = html_to_text(body)
|
||||
elif not content_type:
|
||||
content_text = body
|
||||
else:
|
||||
content_text = f"(non-text response: {content_type})"
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "success",
|
||||
"url": final_url,
|
||||
"method": method,
|
||||
"http_status": status,
|
||||
"content_type": content_type,
|
||||
"headers": response_headers,
|
||||
"content": self._optional_cap(arguments.get("max_chars"), content_text),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
async def _guard(self, url: str) -> None:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
@@ -150,15 +235,41 @@ class FetchController:
|
||||
)
|
||||
|
||||
async def _download(self, url: str) -> tuple[str, str, str, int]:
|
||||
body, final_url, content_type, status, _ = await self._stream(
|
||||
"GET", url, raise_5xx=True
|
||||
)
|
||||
return body, final_url, content_type, status
|
||||
|
||||
async def _stream(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
headers: dict[str, Any] | None = None,
|
||||
json_body: Any = None,
|
||||
form: dict[str, Any] | None = None,
|
||||
content: str | None = None,
|
||||
raise_5xx: bool = False,
|
||||
) -> tuple[str, str, str, int, dict[str, str]]:
|
||||
limit = self._settings.fetch_max_bytes
|
||||
merged_headers = dict(STEALTH_HEADERS)
|
||||
if headers:
|
||||
merged_headers.update({str(k): str(v) for k, v in headers.items()})
|
||||
request_kwargs: dict[str, Any] = {}
|
||||
if json_body is not None:
|
||||
request_kwargs["json"] = json_body
|
||||
elif form is not None:
|
||||
request_kwargs["data"] = form
|
||||
elif content is not None:
|
||||
request_kwargs["content"] = content
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
headers=STEALTH_HEADERS,
|
||||
headers=merged_headers,
|
||||
follow_redirects=True,
|
||||
timeout=self._settings.fetch_timeout_seconds,
|
||||
) as client:
|
||||
async with client.stream("GET", url) as response:
|
||||
if response.status_code >= 500:
|
||||
async with client.stream(method, url, **request_kwargs) as response:
|
||||
if raise_5xx and response.status_code >= 500:
|
||||
raise UpstreamError(
|
||||
f"Server returned {response.status_code}.",
|
||||
status=response.status_code,
|
||||
@@ -178,7 +289,16 @@ class FetchController:
|
||||
except LookupError:
|
||||
body = raw.decode("utf-8", errors="replace")
|
||||
content_type = response.headers.get("content-type", "").lower()
|
||||
return body, str(response.url), content_type, response.status_code
|
||||
response_headers = {
|
||||
key: value for key, value in response.headers.items()
|
||||
}
|
||||
return (
|
||||
body,
|
||||
str(response.url),
|
||||
content_type,
|
||||
response.status_code,
|
||||
response_headers,
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise NetworkError(f"Request timed out fetching {url}", url=url) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
|
||||
Reference in New Issue
Block a user