mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 17:38:35 +00:00
refactor: register webhook providers internally
maintainer edit: route provider-specific webhook auth, context, and default prompt details through a small internal registry so new built-in platforms can be added without changing the router flow.
This commit is contained in:
parent
7283556048
commit
6e562efd37
@ -307,7 +307,7 @@ class WebhookRouteConfig(Base):
|
|||||||
|
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
path: str = "" # Defaults to /webhooks/<route-name>.
|
path: str = "" # Defaults to /webhooks/<route-name>.
|
||||||
provider: Literal["generic", "github"] = "generic"
|
provider: str = "generic"
|
||||||
auth: Literal["secret", "none"] = "secret"
|
auth: Literal["secret", "none"] = "secret"
|
||||||
secret: str = Field(default="", repr=False)
|
secret: str = Field(default="", repr=False)
|
||||||
to: str = "" # Required when enabled, e.g. "websocket:github" or "telegram:12345".
|
to: str = "" # Required when enabled, e.g. "websocket:github" or "telegram:12345".
|
||||||
@ -319,6 +319,10 @@ class WebhookRouteConfig(Base):
|
|||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _validate_route(self) -> "WebhookRouteConfig":
|
def _validate_route(self) -> "WebhookRouteConfig":
|
||||||
|
if re.fullmatch(r"[A-Za-z0-9_.-]{1,64}", self.provider) is None:
|
||||||
|
raise ValueError(
|
||||||
|
"webhook provider names may contain only letters, numbers, '_', '.', and '-'"
|
||||||
|
)
|
||||||
if self.path:
|
if self.path:
|
||||||
if not self.path.startswith("/"):
|
if not self.path.startswith("/"):
|
||||||
raise ValueError("webhook route path must start with '/'")
|
raise ValueError("webhook route path must start with '/'")
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import hashlib
|
|||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from collections.abc import Mapping
|
from collections.abc import Callable, Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@ -46,6 +46,14 @@ class WebhookError(Exception):
|
|||||||
self.message = message
|
self.message = message
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WebhookProvider:
|
||||||
|
verify_secret: Callable[[str, Mapping[str, str], bytes], None]
|
||||||
|
context: Callable[[Mapping[str, str], Mapping[str, Any]], dict[str, Any]]
|
||||||
|
default_prompt_lines: Callable[[dict[str, Any]], list[str]]
|
||||||
|
require_json: bool = False
|
||||||
|
|
||||||
|
|
||||||
class WebhookRouter:
|
class WebhookRouter:
|
||||||
"""Validate webhook requests and enqueue accepted events on the message bus."""
|
"""Validate webhook requests and enqueue accepted events on the message bus."""
|
||||||
|
|
||||||
@ -67,6 +75,10 @@ class WebhookRouter:
|
|||||||
for name, route in config.routes.items():
|
for name, route in config.routes.items():
|
||||||
if not route.enabled:
|
if not route.enabled:
|
||||||
continue
|
continue
|
||||||
|
try:
|
||||||
|
_webhook_provider(route.provider)
|
||||||
|
except WebhookError as exc:
|
||||||
|
raise ValueError(exc.message) from exc
|
||||||
self._routes[_route_path(name, route)] = (name, route)
|
self._routes[_route_path(name, route)] = (name, route)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -132,7 +144,7 @@ class WebhookRouter:
|
|||||||
raise WebhookError(405, "webhook routes require POST")
|
raise WebhookError(405, "webhook routes require POST")
|
||||||
if len(body) > route.max_body_bytes:
|
if len(body) > route.max_body_bytes:
|
||||||
raise WebhookError(413, "webhook body is too large")
|
raise WebhookError(413, "webhook body is too large")
|
||||||
self._verify_auth(route, headers, body)
|
_verify_auth(route, headers, body)
|
||||||
payload, body_text = _decode_body(route, body)
|
payload, body_text = _decode_body(route, body)
|
||||||
context = _template_context(
|
context = _template_context(
|
||||||
name=name,
|
name=name,
|
||||||
@ -184,34 +196,6 @@ class WebhookRouter:
|
|||||||
"delivery_id": delivery_id or None,
|
"delivery_id": delivery_id or None,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _verify_auth(
|
|
||||||
self,
|
|
||||||
route: WebhookRouteConfig,
|
|
||||||
headers: Mapping[str, str],
|
|
||||||
body: bytes,
|
|
||||||
) -> None:
|
|
||||||
if route.auth == "none":
|
|
||||||
return
|
|
||||||
secret = route.secret.strip()
|
|
||||||
if not secret:
|
|
||||||
raise WebhookError(500, "webhook route secret is not configured")
|
|
||||||
if route.provider == "github":
|
|
||||||
signature = headers.get("x-hub-signature-256", "")
|
|
||||||
if _hmac_matches(signature, secret, body):
|
|
||||||
return
|
|
||||||
raise WebhookError(401, "invalid GitHub webhook signature")
|
|
||||||
|
|
||||||
signature = headers.get("x-nanobot-signature-256", "")
|
|
||||||
if signature and _hmac_matches(signature, secret, body):
|
|
||||||
return
|
|
||||||
bearer = _bearer_token(headers.get("authorization", ""))
|
|
||||||
header_token = headers.get("x-nanobot-auth", "")
|
|
||||||
if (bearer and hmac.compare_digest(bearer, secret)) or (
|
|
||||||
header_token and hmac.compare_digest(header_token, secret)
|
|
||||||
):
|
|
||||||
return
|
|
||||||
raise WebhookError(401, "invalid webhook secret")
|
|
||||||
|
|
||||||
def _is_duplicate(
|
def _is_duplicate(
|
||||||
self,
|
self,
|
||||||
route_name: str,
|
route_name: str,
|
||||||
@ -266,6 +250,39 @@ def _hmac_matches(signature: str, secret: str, body: bytes) -> bool:
|
|||||||
return hmac.compare_digest(supplied, expected)
|
return hmac.compare_digest(supplied, expected)
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_auth(
|
||||||
|
route: WebhookRouteConfig,
|
||||||
|
headers: Mapping[str, str],
|
||||||
|
body: bytes,
|
||||||
|
) -> None:
|
||||||
|
if route.auth == "none":
|
||||||
|
return
|
||||||
|
secret = route.secret.strip()
|
||||||
|
if not secret:
|
||||||
|
raise WebhookError(500, "webhook route secret is not configured")
|
||||||
|
_webhook_provider(route.provider).verify_secret(secret, headers, body)
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_generic_secret(secret: str, headers: Mapping[str, str], body: bytes) -> None:
|
||||||
|
signature = headers.get("x-nanobot-signature-256", "")
|
||||||
|
if signature and _hmac_matches(signature, secret, body):
|
||||||
|
return
|
||||||
|
bearer = _bearer_token(headers.get("authorization", ""))
|
||||||
|
header_token = headers.get("x-nanobot-auth", "")
|
||||||
|
if (bearer and hmac.compare_digest(bearer, secret)) or (
|
||||||
|
header_token and hmac.compare_digest(header_token, secret)
|
||||||
|
):
|
||||||
|
return
|
||||||
|
raise WebhookError(401, "invalid webhook secret")
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_github_secret(secret: str, headers: Mapping[str, str], body: bytes) -> None:
|
||||||
|
signature = headers.get("x-hub-signature-256", "")
|
||||||
|
if _hmac_matches(signature, secret, body):
|
||||||
|
return
|
||||||
|
raise WebhookError(401, "invalid GitHub webhook signature")
|
||||||
|
|
||||||
|
|
||||||
def _decode_body(route: WebhookRouteConfig, body: bytes) -> tuple[Any, str]:
|
def _decode_body(route: WebhookRouteConfig, body: bytes) -> tuple[Any, str]:
|
||||||
try:
|
try:
|
||||||
text = body.decode("utf-8")
|
text = body.decode("utf-8")
|
||||||
@ -276,8 +293,8 @@ def _decode_body(route: WebhookRouteConfig, body: bytes) -> tuple[Any, str]:
|
|||||||
try:
|
try:
|
||||||
return json.loads(text), text
|
return json.loads(text), text
|
||||||
except json.JSONDecodeError as exc:
|
except json.JSONDecodeError as exc:
|
||||||
if route.provider == "github":
|
if _webhook_provider(route.provider).require_json:
|
||||||
raise WebhookError(400, "GitHub webhook body must be JSON") from exc
|
raise WebhookError(400, f"{route.provider} webhook body must be JSON") from exc
|
||||||
return None, text
|
return None, text
|
||||||
|
|
||||||
|
|
||||||
@ -291,15 +308,9 @@ def _template_context(
|
|||||||
remote: str | None,
|
remote: str | None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
event = payload if isinstance(payload, dict) else {}
|
event = payload if isinstance(payload, dict) else {}
|
||||||
github = _github_context(headers, event) if route.provider == "github" else {}
|
provider_context = _webhook_provider(route.provider).context(headers, event)
|
||||||
event_name = github.get("event") or headers.get("x-nanobot-event") or ""
|
event_name = provider_context.pop("event_name", "")
|
||||||
delivery_id = (
|
delivery_id = provider_context.pop("delivery_id", "")
|
||||||
github.get("delivery_id")
|
|
||||||
or headers.get("x-nanobot-delivery")
|
|
||||||
or headers.get("x-webhook-id")
|
|
||||||
or headers.get("x-request-id")
|
|
||||||
or ""
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
"route": {
|
"route": {
|
||||||
"name": name,
|
"name": name,
|
||||||
@ -315,12 +326,59 @@ def _template_context(
|
|||||||
"body": body_text,
|
"body": body_text,
|
||||||
"headers": _safe_headers(headers),
|
"headers": _safe_headers(headers),
|
||||||
"remote": remote or "",
|
"remote": remote or "",
|
||||||
"github": github,
|
"github": provider_context.get("github", {}),
|
||||||
"event_name": event_name,
|
"event_name": event_name,
|
||||||
"delivery_id": delivery_id,
|
"delivery_id": delivery_id,
|
||||||
|
**provider_context,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _generic_context(headers: Mapping[str, str], _payload: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"event_name": headers.get("x-nanobot-event", ""),
|
||||||
|
"delivery_id": (
|
||||||
|
headers.get("x-nanobot-delivery")
|
||||||
|
or headers.get("x-webhook-id")
|
||||||
|
or headers.get("x-request-id")
|
||||||
|
or ""
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _generic_prompt_lines(_context: dict[str, Any]) -> list[str]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _github_provider_context(
|
||||||
|
headers: Mapping[str, str],
|
||||||
|
payload: Mapping[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
github = _github_context(headers, payload)
|
||||||
|
return {
|
||||||
|
"github": github,
|
||||||
|
"event_name": github.get("event", ""),
|
||||||
|
"delivery_id": github.get("delivery_id", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _github_prompt_lines(context: dict[str, Any]) -> list[str]:
|
||||||
|
lines: list[str] = []
|
||||||
|
github = context.get("github") or {}
|
||||||
|
if github.get("repository_full_name"):
|
||||||
|
lines.append(f"Repository: {github['repository_full_name']}")
|
||||||
|
if github.get("action"):
|
||||||
|
lines.append(f"Action: {github['action']}")
|
||||||
|
if github.get("sender_login"):
|
||||||
|
lines.append(f"Sender: {github['sender_login']}")
|
||||||
|
if github.get("ref"):
|
||||||
|
lines.append(f"Ref: {github['ref']}")
|
||||||
|
if github.get("pull_request_title"):
|
||||||
|
lines.append(f"Pull request: {github['pull_request_title']}")
|
||||||
|
elif github.get("issue_title"):
|
||||||
|
lines.append(f"Issue: {github['issue_title']}")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
def _github_context(headers: Mapping[str, str], payload: Mapping[str, Any]) -> dict[str, Any]:
|
def _github_context(headers: Mapping[str, str], payload: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
repo = payload.get("repository")
|
repo = payload.get("repository")
|
||||||
sender = payload.get("sender")
|
sender = payload.get("sender")
|
||||||
@ -342,6 +400,29 @@ def _github_context(headers: Mapping[str, str], payload: Mapping[str, Any]) -> d
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_WEBHOOK_PROVIDERS: dict[str, WebhookProvider] = {
|
||||||
|
# ponytail: internal registry, add entry-point loading if third-party providers appear.
|
||||||
|
"generic": WebhookProvider(
|
||||||
|
verify_secret=_verify_generic_secret,
|
||||||
|
context=_generic_context,
|
||||||
|
default_prompt_lines=_generic_prompt_lines,
|
||||||
|
),
|
||||||
|
"github": WebhookProvider(
|
||||||
|
verify_secret=_verify_github_secret,
|
||||||
|
context=_github_provider_context,
|
||||||
|
default_prompt_lines=_github_prompt_lines,
|
||||||
|
require_json=True,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _webhook_provider(name: str) -> WebhookProvider:
|
||||||
|
try:
|
||||||
|
return _WEBHOOK_PROVIDERS[name]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise WebhookError(500, f"webhook provider {name!r} is not registered") from exc
|
||||||
|
|
||||||
|
|
||||||
def _safe_headers(headers: Mapping[str, str]) -> dict[str, str]:
|
def _safe_headers(headers: Mapping[str, str]) -> dict[str, str]:
|
||||||
safe: dict[str, str] = {}
|
safe: dict[str, str] = {}
|
||||||
for key, value in headers.items():
|
for key, value in headers.items():
|
||||||
@ -399,20 +480,7 @@ def _default_prompt(context: dict[str, Any]) -> str:
|
|||||||
lines.append(f"Event: {event_name}")
|
lines.append(f"Event: {event_name}")
|
||||||
if delivery_id:
|
if delivery_id:
|
||||||
lines.append(f"Delivery ID: {delivery_id}")
|
lines.append(f"Delivery ID: {delivery_id}")
|
||||||
if provider == "github":
|
lines.extend(_webhook_provider(provider).default_prompt_lines(context))
|
||||||
github = context.get("github") or {}
|
|
||||||
if github.get("repository_full_name"):
|
|
||||||
lines.append(f"Repository: {github['repository_full_name']}")
|
|
||||||
if github.get("action"):
|
|
||||||
lines.append(f"Action: {github['action']}")
|
|
||||||
if github.get("sender_login"):
|
|
||||||
lines.append(f"Sender: {github['sender_login']}")
|
|
||||||
if github.get("ref"):
|
|
||||||
lines.append(f"Ref: {github['ref']}")
|
|
||||||
if github.get("pull_request_title"):
|
|
||||||
lines.append(f"Pull request: {github['pull_request_title']}")
|
|
||||||
elif github.get("issue_title"):
|
|
||||||
lines.append(f"Issue: {github['issue_title']}")
|
|
||||||
lines.extend(["", "Payload:", _format_payload(context.get("payload"), context.get("body", ""))])
|
lines.extend(["", "Payload:", _format_payload(context.get("payload"), context.get("body", ""))])
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|||||||
@ -359,6 +359,21 @@ def test_webhook_config_requires_target_for_enabled_routes() -> None:
|
|||||||
WebhooksConfig(routes={"bad": WebhookRouteConfig(auth="none", to="telegram")})
|
WebhooksConfig(routes={"bad": WebhookRouteConfig(auth="none", to="telegram")})
|
||||||
|
|
||||||
|
|
||||||
|
def test_webhook_router_rejects_unregistered_provider() -> None:
|
||||||
|
config = WebhooksConfig(
|
||||||
|
routes={
|
||||||
|
"stripe": WebhookRouteConfig(
|
||||||
|
provider="stripe",
|
||||||
|
auth="none",
|
||||||
|
to="telegram:1",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="not registered"):
|
||||||
|
WebhookRouter(config, MessageBus())
|
||||||
|
|
||||||
|
|
||||||
def test_webhook_config_allows_incomplete_routes_when_webhooks_disabled() -> None:
|
def test_webhook_config_allows_incomplete_routes_when_webhooks_disabled() -> None:
|
||||||
config = WebhooksConfig(
|
config = WebhooksConfig(
|
||||||
enabled=False,
|
enabled=False,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user