mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-11-01 21:35:25 +08:00
295 lines
10 KiB
Python
295 lines
10 KiB
Python
"""n8n Webhook client for Runner.
|
|
|
|
This module provides a minimal n8n webhook client that doesn't depend on LangBot internals.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import time
|
|
import typing
|
|
|
|
import httpx
|
|
|
|
from pkg.endpoint import endpoint
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Same response character bound as the native n8n runner.
|
|
_MAX_N8N_RESPONSE_CHARS = 1024 * 1024
|
|
|
|
|
|
class N8nAPIError(Exception):
|
|
"""n8n API error."""
|
|
|
|
def __init__(self, message: str, code: str = "n8n.api_error"):
|
|
self.message = message
|
|
self.code = code
|
|
super().__init__(message)
|
|
|
|
|
|
class N8nConfigError(Exception):
|
|
"""n8n configuration error."""
|
|
|
|
def __init__(self, message: str, code: str = "n8n.config_invalid"):
|
|
self.message = message
|
|
self.code = code
|
|
super().__init__(message)
|
|
|
|
|
|
class AsyncN8nClient:
|
|
"""Minimal n8n Webhook client for Runner.
|
|
|
|
Supports:
|
|
- Webhook calls with various authentication types
|
|
- Streaming response (type: item/end format)
|
|
- Non-streaming JSON response
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
webhook_url: str,
|
|
timeout: float = 120.0,
|
|
output_key: str = "response",
|
|
response_handling: str = "reply",
|
|
):
|
|
if response_handling not in ("reply", "ignore"):
|
|
raise N8nConfigError("response-handling must be reply or ignore")
|
|
self.webhook_url = endpoint(webhook_url, query=True)
|
|
self.timeout = timeout
|
|
self.output_key = output_key
|
|
self.response_handling = response_handling
|
|
|
|
async def call_webhook(
|
|
self,
|
|
payload: dict[str, typing.Any],
|
|
auth_type: str = "none",
|
|
auth_config: dict[str, typing.Any] = None,
|
|
) -> typing.AsyncGenerator[dict[str, typing.Any], None]:
|
|
"""Call n8n webhook with authentication.
|
|
|
|
Yields response events as dicts with:
|
|
- type: "item" for content chunks, "end" for completion, "json" for non-streaming
|
|
- content: text content for "item" type
|
|
- data: full response data for "json" type
|
|
"""
|
|
auth_config = auth_config or {}
|
|
|
|
async with httpx.AsyncClient(
|
|
timeout=self.timeout,
|
|
trust_env=False,
|
|
) as client:
|
|
headers = self._build_headers(auth_type, auth_config)
|
|
auth = self._build_auth(auth_type, auth_config)
|
|
|
|
try:
|
|
# Native aiohttp timeout is a total request deadline. Keep it
|
|
# alongside HTTPX's per-phase timeout so trickle bodies cannot
|
|
# keep a run alive indefinitely.
|
|
async with (
|
|
asyncio.timeout(self.timeout),
|
|
client.stream(
|
|
"POST",
|
|
self.webhook_url,
|
|
json=payload,
|
|
headers=headers,
|
|
auth=auth,
|
|
) as response,
|
|
):
|
|
status_ok = (
|
|
200 <= response.status_code < 300
|
|
if self.response_handling == "ignore"
|
|
else response.status_code == 200
|
|
)
|
|
if not status_ok:
|
|
# Remote error bodies can echo credentials. Report status
|
|
# only, without buffering an untrusted or blocked body.
|
|
raise N8nAPIError(
|
|
f"n8n webhook error: {response.status_code}",
|
|
code="n8n.http_error",
|
|
)
|
|
|
|
if self.response_handling == "ignore":
|
|
# Exiting the stream context closes it without reading.
|
|
# This waits for response headers, not workflow completion.
|
|
return
|
|
|
|
async for event in self._parse_response(response):
|
|
yield event
|
|
|
|
except (httpx.TimeoutException, TimeoutError):
|
|
raise N8nAPIError(
|
|
f"n8n webhook request timed out after {self.timeout}s",
|
|
code="n8n.timeout",
|
|
) from None
|
|
except httpx.HTTPStatusError as e:
|
|
raise N8nAPIError(
|
|
f"n8n HTTP error: {e.response.status_code}",
|
|
code="n8n.http_error",
|
|
) from None
|
|
except httpx.RequestError:
|
|
raise N8nAPIError(
|
|
"n8n webhook transport failed",
|
|
code="n8n.network_error",
|
|
) from None
|
|
|
|
def _build_headers(
|
|
self,
|
|
auth_type: str,
|
|
auth_config: dict[str, typing.Any],
|
|
) -> dict[str, str]:
|
|
"""Build request headers based on auth type."""
|
|
headers = {"Content-Type": "application/json"}
|
|
|
|
if auth_type == "jwt":
|
|
import jwt
|
|
|
|
secret = auth_config.get("jwt_secret", "")
|
|
algorithm = auth_config.get("jwt_algorithm", "HS256")
|
|
|
|
jwt_payload = {
|
|
"exp": int(time.time()) + 3600, # 1 hour expiry
|
|
"iat": int(time.time()),
|
|
"sub": "n8n-webhook",
|
|
}
|
|
token = jwt.encode(jwt_payload, secret, algorithm=algorithm)
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
logger.debug("Using JWT authentication")
|
|
|
|
elif auth_type == "header":
|
|
header_name = auth_config.get("header_name", "")
|
|
header_value = auth_config.get("header_value", "")
|
|
if header_name:
|
|
headers[header_name] = header_value
|
|
logger.debug("Using header authentication")
|
|
|
|
return headers
|
|
|
|
def _build_auth(
|
|
self,
|
|
auth_type: str,
|
|
auth_config: dict[str, typing.Any],
|
|
) -> httpx.Auth | None:
|
|
"""Build httpx auth object for basic auth."""
|
|
if auth_type == "basic":
|
|
username = auth_config.get("basic_username", "")
|
|
password = auth_config.get("basic_password", "")
|
|
encoding = auth_config.get("basic_encoding", "utf-8")
|
|
logger.debug("Using basic authentication")
|
|
return httpx.BasicAuth(username.encode(encoding), password.encode(encoding))
|
|
return None
|
|
|
|
async def _parse_response(
|
|
self,
|
|
response: httpx.Response,
|
|
) -> typing.AsyncGenerator[dict[str, typing.Any], None]:
|
|
"""Parse n8n webhook response.
|
|
|
|
Supports:
|
|
1. Streaming format: JSON objects with type: "item" and type: "end"
|
|
2. Non-streaming format: Plain JSON object
|
|
|
|
Yields events:
|
|
- {"type": "item", "content": "..."} for streaming chunks
|
|
- {"type": "end"} for streaming completion
|
|
- {"type": "json", "data": {...}, "content": "..."} for non-streaming response
|
|
"""
|
|
full_text = ""
|
|
buffer = ""
|
|
decoder = json.JSONDecoder()
|
|
chunk_idx = 0
|
|
content_chars = 0
|
|
|
|
# HTTPX's incremental decoder preserves UTF-8 split across network chunks.
|
|
response.encoding = "utf-8"
|
|
async for chunk_str in response.aiter_text():
|
|
if not chunk_str:
|
|
continue
|
|
|
|
try:
|
|
if len(full_text) + len(chunk_str) > _MAX_N8N_RESPONSE_CHARS:
|
|
raise N8nAPIError("n8n response exceeds the runtime limit", code="n8n.response_too_large")
|
|
full_text += chunk_str
|
|
buffer += chunk_str
|
|
|
|
# Try to parse JSON objects from buffer
|
|
while buffer:
|
|
buffer = buffer.lstrip()
|
|
if not buffer:
|
|
break
|
|
try:
|
|
obj, idx = decoder.raw_decode(buffer)
|
|
buffer = buffer[idx:]
|
|
|
|
if not isinstance(obj, dict):
|
|
continue
|
|
|
|
if obj.get("type") == "item" and "content" in obj:
|
|
chunk_idx += 1
|
|
content = str(obj["content"])
|
|
content_chars += len(content)
|
|
if content_chars > _MAX_N8N_RESPONSE_CHARS:
|
|
raise N8nAPIError(
|
|
"n8n response exceeds the runtime limit", code="n8n.response_too_large"
|
|
)
|
|
yield {
|
|
"type": "item",
|
|
"content": content,
|
|
"chunk_idx": chunk_idx,
|
|
}
|
|
elif obj.get("type") == "end":
|
|
yield {"type": "end"}
|
|
|
|
except json.JSONDecodeError:
|
|
# Incomplete JSON, wait for more data
|
|
break
|
|
|
|
except N8nAPIError:
|
|
raise
|
|
except Exception as e:
|
|
logger.warning(f"Failed to process chunk: {e}")
|
|
|
|
# Process remaining buffer after stream ends
|
|
if buffer:
|
|
try:
|
|
buffer = buffer.strip()
|
|
if buffer:
|
|
obj, _ = decoder.raw_decode(buffer)
|
|
if isinstance(obj, dict):
|
|
if obj.get("type") == "item" and "content" in obj:
|
|
chunk_idx += 1
|
|
yield {
|
|
"type": "item",
|
|
"content": obj["content"],
|
|
"chunk_idx": chunk_idx,
|
|
}
|
|
elif obj.get("type") == "end":
|
|
yield {"type": "end"}
|
|
except Exception as e:
|
|
logger.warning(f"Failed to parse remaining buffer: {e}")
|
|
|
|
# If no streaming chunks were received, parse as non-streaming JSON
|
|
if chunk_idx == 0:
|
|
output_content = self._extract_output(full_text)
|
|
yield {
|
|
"type": "json",
|
|
"content": output_content,
|
|
"raw": full_text,
|
|
}
|
|
|
|
def _extract_output(self, response_text: str) -> str:
|
|
"""Extract output content from non-streaming response."""
|
|
try:
|
|
response_data = json.loads(response_text.strip())
|
|
if isinstance(response_data, dict):
|
|
if self.output_key in response_data:
|
|
return response_data[self.output_key]
|
|
else:
|
|
return json.dumps(response_data, ensure_ascii=False)
|
|
else:
|
|
return response_text
|
|
except json.JSONDecodeError:
|
|
return response_text
|