File size: 7,054 Bytes
09e30f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
"""
backend/tools/trigger_webhook.py — P17-F4: Generic webhook trigger tool.

Permette all'agente di inviare notifiche / callback a URL esterni
(Zapier, Make, n8n, webhook Discord/Slack, CI/CD, ecc.) con payload arbitrario.

Registrato nel TOOL_REGISTRY come "trigger_webhook".

Sicurezza:
  - URL allowlist via WEBHOOK_ALLOWED_HOSTS env (CSV). Vuoto = tutti consentiti (dev).
  - Timeout massimo 10s — mai blocca il loop principale.
  - Payload massimo 32KB — protegge da exfiltration involontaria.
  - Metodi consentiti: POST, GET, PUT (default POST).
  - Rate limit per-host via sliding window: WEBHOOK_RATE_LIMIT_MAX (default 10) /
    WEBHOOK_RATE_LIMIT_WINDOW (default 60s).
"""
from __future__ import annotations

import json
import logging
import os
import asyncio
import time
from collections import defaultdict, deque
from typing import Any
from urllib.parse import urlparse

import httpx

_logger = logging.getLogger("agente_ai.tools.trigger_webhook")

_ALLOWED_HOSTS_ENV = os.getenv("WEBHOOK_ALLOWED_HOSTS", "")
_ALLOWED_HOSTS: set[str] = (
    {h.strip().lower() for h in _ALLOWED_HOSTS_ENV.split(",") if h.strip()}
    if _ALLOWED_HOSTS_ENV else set()
)
_MAX_PAYLOAD_BYTES = 32_768   # 32 KB
_TIMEOUT_S         = 10.0

# ── P17-F4 rate-limit guard (sliding window per-host) ─────────────────────────
_RATE_LIMIT_MAX: int   = int(os.getenv("WEBHOOK_RATE_LIMIT_MAX", "10"))      # max calls per window
_RATE_LIMIT_WIN: float = float(os.getenv("WEBHOOK_RATE_LIMIT_WINDOW", "60")) # window seconds
_rate_lock                              = asyncio.Lock()
_rate_windows: dict[str, deque[float]] = defaultdict(deque)                  # host → timestamps


async def _check_rate_limit(host: str) -> bool:
    """Sliding window rate limiter per host. Ritorna True se entro il limite."""
    async with _rate_lock:
        now = time.monotonic()
        win = _rate_windows[host]
        while win and win[0] < now - _RATE_LIMIT_WIN:
            win.popleft()
        if len(win) >= _RATE_LIMIT_MAX:
            return False
        win.append(now)
        return True


def _check_host(url: str) -> bool:
    """Verifica che l'host sia nell'allowlist (se configurata)."""
    if not _ALLOWED_HOSTS:
        return True  # dev mode — tutti consentiti
    try:
        host = urlparse(url).hostname or ""
        return host.lower() in _ALLOWED_HOSTS
    except Exception:
        return False


async def trigger_webhook(
    url: str,
    payload: dict[str, Any] | str | None = None,
    method: str = "POST",
    headers: dict[str, str] | None = None,
    timeout: float = _TIMEOUT_S,
) -> dict[str, Any]:
    """
    Trigger an HTTP webhook with an arbitrary payload.

    Args:
        url:     Target URL (https://...)
        payload: JSON-serializable dict, raw string, or None
        method:  HTTP method — POST (default), GET, PUT
        headers: Additional HTTP headers (optional)
        timeout: Request timeout in seconds (max 10)

    Returns:
        {"ok": bool, "status": int, "body": str, "error": str|None}
    """
    method = method.upper()
    if method not in ("POST", "GET", "PUT"):
        return {"ok": False, "status": 0, "body": "", "error": f"Metodo non consentito: {method}"}

    if not url.startswith(("https://", "http://")):
        return {"ok": False, "status": 0, "body": "", "error": "URL deve iniziare con http(s)://"}

    if not _check_host(url):
        return {"ok": False, "status": 0, "body": "", "error": "Host non nell'allowlist WEBHOOK_ALLOWED_HOSTS"}

    # P17-F4: rate-limit guard — sliding window per-host
    _rl_host = urlparse(url).hostname or url
    if not await _check_rate_limit(_rl_host):
        _logger.warning(
            "trigger_webhook rate limit superato: %s (%s req/%ss)",
            _rl_host, _RATE_LIMIT_MAX, int(_RATE_LIMIT_WIN),
        )
        return {
            "ok": False,
            "status": 429,
            "body": "",
            "error": f"Rate limit ({_RATE_LIMIT_MAX} chiamate/{_RATE_LIMIT_WIN:.0f}s) superato per {_rl_host}",
        }

    # Serializza payload
    body_bytes: bytes | None = None
    req_headers: dict[str, str] = {"User-Agent": "agente-ai/1.0"}
    if headers:
        req_headers.update(headers)

    if payload is not None:
        if isinstance(payload, dict):
            raw = json.dumps(payload, ensure_ascii=False)
            req_headers.setdefault("Content-Type", "application/json")
        else:
            raw = str(payload)
            req_headers.setdefault("Content-Type", "text/plain")
        body_bytes = raw.encode("utf-8")[:_MAX_PAYLOAD_BYTES]

    _effective_timeout = min(float(timeout), _TIMEOUT_S)
    try:
        async with httpx.AsyncClient(timeout=_effective_timeout, follow_redirects=True) as client:
            if method == "GET":
                resp = await client.get(url, headers=req_headers)
            elif method == "PUT":
                resp = await client.put(url, content=body_bytes, headers=req_headers)
            else:
                resp = await client.post(url, content=body_bytes, headers=req_headers)

        body_str = resp.text[:2000]
        ok = resp.is_success
        _logger.info(
            "trigger_webhook %s %s → %d (%s)",
            method, url[:80], resp.status_code, "OK" if ok else "FAIL",
        )
        return {"ok": ok, "status": resp.status_code, "body": body_str, "error": None}

    except httpx.TimeoutException:
        _logger.warning("trigger_webhook timeout (%.0fs): %s", _effective_timeout, url[:80])
        return {"ok": False, "status": 0, "body": "", "error": f"timeout dopo {_effective_timeout:.0f}s"}
    except Exception as exc:
        _logger.warning("trigger_webhook error: %s", exc)
        return {"ok": False, "status": 0, "body": "", "error": str(exc)[:200]}


# ── Tool registry descriptor ───────────────────────────────────────────────────
TOOL_DESCRIPTOR = {
    "name":        "trigger_webhook",
    "description": (
        "Invia una richiesta HTTP (POST/GET/PUT) a un URL esterno con payload JSON. "
        "Usa per notificare webhook Zapier/Make/n8n, CI/CD, Discord, Slack, ecc. "
        "Restituisce status HTTP + risposta body (max 2000 chars). "
        "Rate limit: max 10 call/60s per host (configurabile via WEBHOOK_RATE_LIMIT_MAX)."
    ),
    "parameters": {
        "type": "object",
        "properties": {
            "url":     {"type": "string", "description": "URL destinazione (https://...)"},
            "payload": {"type": "object", "description": "Payload JSON da inviare"},
            "method":  {"type": "string", "enum": ["POST", "GET", "PUT"], "default": "POST"},
            "headers": {"type": "object", "description": "Header HTTP aggiuntivi"},
            "timeout": {"type": "number", "description": "Timeout secondi (max 10)", "default": 10},
        },
        "required": ["url"],
    },
    "fn": trigger_webhook,
}