mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 20:20:45 +08:00
重构仓库目录为统一的 runtime 分层并清理历史 openclaw 残留。
本次迁移将网关/通道/工具/技能/脚本与协议资源集中到新结构,统一路径常量与脚本转发机制,减少顶层噪音并保证运行与测试行为一致。 Made-with: Cursor
This commit is contained in:
parent
ba3836f00f
commit
4a23b715a2
498 changed files with 2760 additions and 2200 deletions
30
runtime/extensions/webhooks/README.md
Normal file
30
runtime/extensions/webhooks/README.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Webhooks 扩展(中文重写)
|
||||
|
||||
对应上游目录:`vendor/oclaw/extensions/webhooks`
|
||||
|
||||
## 能力定位
|
||||
- 提供“认证后的入站 webhook”,把外部自动化请求绑定到 Oclaw TaskFlow。
|
||||
- 支持多 route 配置,每个 route 对应 path/session/secret/controller。
|
||||
|
||||
## 关键入口
|
||||
- `oclaw.plugin.json`:声明插件 `id=webhooks` 及配置 schema。
|
||||
- `index.ts`:读取 routes 配置并注册 HTTP 路由。
|
||||
- `api.ts`:导出插件 SDK 类型入口。
|
||||
- `runtime-api.ts`:运行时相关公共接口。
|
||||
|
||||
## 配置核心(来自 schema)
|
||||
- `routes.<routeId>.enabled`:是否启用。
|
||||
- `routes.<routeId>.path`:Webhook 路径。
|
||||
- `routes.<routeId>.sessionKey`:绑定会话键。
|
||||
- `routes.<routeId>.secret`:密钥(字符串或 secretRef)。
|
||||
- `routes.<routeId>.controllerId`:默认控制器标识。
|
||||
- `routes.<routeId>.description`:可选说明。
|
||||
|
||||
## secret 支持类型
|
||||
- 直接字符串密钥。
|
||||
- `secretRef` 对象:`source`(env/file/exec)+ `provider` + `id`。
|
||||
|
||||
## 运行关注点
|
||||
- 路由是否注册成功并与 `sessionKey` 对齐。
|
||||
- secret 解析是否正确(配置路径与 provider 可用性)。
|
||||
- 外部系统调用失败时的日志可观测性(routeId/path/sessionKey)。
|
||||
23
runtime/extensions/webhooks/__init__.py
Normal file
23
runtime/extensions/webhooks/__init__.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
from .api import build_webhooks_plugin_entry
|
||||
from .config import ConfiguredWebhookRoute, resolve_webhooks_plugin_config
|
||||
from .http import TaskFlowWebhookTarget, create_taskflow_webhook_request_handler, execute_webhook_action
|
||||
from .runtime_api import (
|
||||
WEBHOOK_IN_FLIGHT_DEFAULTS,
|
||||
WEBHOOK_RATE_LIMIT_DEFAULTS,
|
||||
normalize_webhook_path,
|
||||
resolve_configured_secret_input_string,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ConfiguredWebhookRoute",
|
||||
"TaskFlowWebhookTarget",
|
||||
"WEBHOOK_IN_FLIGHT_DEFAULTS",
|
||||
"WEBHOOK_RATE_LIMIT_DEFAULTS",
|
||||
"build_webhooks_plugin_entry",
|
||||
"create_taskflow_webhook_request_handler",
|
||||
"execute_webhook_action",
|
||||
"normalize_webhook_path",
|
||||
"resolve_configured_secret_input_string",
|
||||
"resolve_webhooks_plugin_config",
|
||||
]
|
||||
|
||||
17
runtime/extensions/webhooks/api.py
Normal file
17
runtime/extensions/webhooks/api.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from ..api import PluginApi, PluginEntry, define_plugin_entry
|
||||
from .index import register_webhook_routes
|
||||
|
||||
|
||||
def build_webhooks_plugin_entry() -> PluginEntry:
|
||||
return define_plugin_entry(
|
||||
id="webhooks",
|
||||
name="Webhooks",
|
||||
description="Authenticated inbound webhooks that bind external automation to Oclaw TaskFlows.",
|
||||
register=lambda api: register_webhook_routes(api),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["PluginApi", "PluginEntry", "build_webhooks_plugin_entry", "define_plugin_entry"]
|
||||
|
||||
68
runtime/extensions/webhooks/config.py
Normal file
68
runtime/extensions/webhooks/config.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .runtime_api import normalize_webhook_path
|
||||
|
||||
|
||||
SecretInput = str | dict[str, str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConfiguredWebhookRoute:
|
||||
route_id: str
|
||||
path: str
|
||||
session_key: str
|
||||
secret: SecretInput
|
||||
controller_id: str
|
||||
description: str = ""
|
||||
|
||||
|
||||
def _validate_secret(value: Any) -> SecretInput:
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
if isinstance(value, dict):
|
||||
source = str(value.get("source") or "").strip()
|
||||
provider = str(value.get("provider") or "").strip()
|
||||
sid = str(value.get("id") or "").strip()
|
||||
if source in {"env", "file", "exec"} and provider and sid:
|
||||
return {"source": source, "provider": provider, "id": sid}
|
||||
raise ValueError("invalid webhook secret: must be non-empty string or secretRef dict")
|
||||
|
||||
|
||||
def resolve_webhooks_plugin_config(*, plugin_config: Any) -> list[ConfiguredWebhookRoute]:
|
||||
cfg = plugin_config if isinstance(plugin_config, dict) else {}
|
||||
routes = cfg.get("routes")
|
||||
routes = routes if isinstance(routes, dict) else {}
|
||||
out: list[ConfiguredWebhookRoute] = []
|
||||
seen_paths: dict[str, str] = {}
|
||||
for route_id, raw in routes.items():
|
||||
rid = str(route_id or "").strip()
|
||||
if not rid or not isinstance(raw, dict):
|
||||
continue
|
||||
enabled = bool(raw.get("enabled", True))
|
||||
if not enabled:
|
||||
continue
|
||||
session_key = str(raw.get("sessionKey") or "").strip()
|
||||
if not session_key:
|
||||
raise ValueError(f"webhooks.routes.{rid}.sessionKey is required")
|
||||
path = normalize_webhook_path(str(raw.get("path") or f"/plugins/webhooks/{rid}"))
|
||||
if path in seen_paths:
|
||||
raise ValueError(f"webhooks.routes.{rid}.path conflicts with routes.{seen_paths[path]}.path ({path})")
|
||||
seen_paths[path] = rid
|
||||
secret = _validate_secret(raw.get("secret"))
|
||||
controller_id = str(raw.get("controllerId") or f"webhooks/{rid}").strip()
|
||||
description = str(raw.get("description") or "").strip()
|
||||
out.append(
|
||||
ConfiguredWebhookRoute(
|
||||
route_id=rid,
|
||||
path=path,
|
||||
session_key=session_key,
|
||||
secret=secret,
|
||||
controller_id=controller_id,
|
||||
description=description,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
338
runtime/extensions/webhooks/http.py
Normal file
338
runtime/extensions/webhooks/http.py
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .runtime_api import resolve_configured_secret_input_string
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskFlowWebhookTarget:
|
||||
route_id: str
|
||||
path: str
|
||||
secret_input: str | dict[str, str]
|
||||
secret_config_path: str
|
||||
default_controller_id: str
|
||||
task_flow: Any
|
||||
|
||||
|
||||
def _pick_optional(data: dict[str, Any], keys: tuple[str, ...]) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {}
|
||||
for key in keys:
|
||||
if key in data and data[key] is not None:
|
||||
out[key] = data[key]
|
||||
return out
|
||||
|
||||
|
||||
def _to_flow_view(flow: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if not isinstance(flow, dict):
|
||||
return None
|
||||
keep = {
|
||||
"flowId",
|
||||
"syncMode",
|
||||
"controllerId",
|
||||
"revision",
|
||||
"status",
|
||||
"notifyPolicy",
|
||||
"goal",
|
||||
"currentStep",
|
||||
"blockedTaskId",
|
||||
"blockedSummary",
|
||||
"stateJson",
|
||||
"waitJson",
|
||||
"cancelRequestedAt",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"endedAt",
|
||||
}
|
||||
return {k: v for k, v in flow.items() if k in keep and v is not None}
|
||||
|
||||
|
||||
def _to_task_view(task: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if not isinstance(task, dict):
|
||||
return None
|
||||
keep = {
|
||||
"taskId",
|
||||
"runtime",
|
||||
"sourceId",
|
||||
"scopeKind",
|
||||
"childSessionKey",
|
||||
"parentFlowId",
|
||||
"parentTaskId",
|
||||
"agentId",
|
||||
"runId",
|
||||
"label",
|
||||
"task",
|
||||
"status",
|
||||
"deliveryStatus",
|
||||
"notifyPolicy",
|
||||
"createdAt",
|
||||
"startedAt",
|
||||
"endedAt",
|
||||
"lastEventAt",
|
||||
"cleanupAfter",
|
||||
"error",
|
||||
"progressSummary",
|
||||
"terminalSummary",
|
||||
"terminalOutcome",
|
||||
}
|
||||
return {k: v for k, v in task.items() if k in keep and v is not None}
|
||||
|
||||
|
||||
def _timing_safe_equals(left: str, right: str) -> bool:
|
||||
return hmac.compare_digest(left.encode("utf-8"), right.encode("utf-8"))
|
||||
|
||||
|
||||
def _extract_secret(headers: dict[str, str]) -> str:
|
||||
auth = str(headers.get("authorization") or "")
|
||||
if auth.lower().startswith("bearer "):
|
||||
return auth[7:].strip()
|
||||
return str(headers.get("x-oclaw-webhook-secret") or "").strip()
|
||||
|
||||
|
||||
def _map_mutation_status(result: dict[str, Any]) -> tuple[int, str | None, str | None]:
|
||||
if bool(result.get("applied")):
|
||||
return (200, None, None)
|
||||
code = str(result.get("code") or "").strip()
|
||||
if code == "not_found":
|
||||
return (404, "not_found", "TaskFlow not found.")
|
||||
if code == "not_managed":
|
||||
return (409, "not_managed", "TaskFlow is not managed by this webhook surface.")
|
||||
if code == "revision_conflict":
|
||||
return (409, "revision_conflict", "TaskFlow changed since the caller's expected revision.")
|
||||
return (409, "mutation_rejected", "TaskFlow mutation was rejected.")
|
||||
|
||||
|
||||
def _map_run_task_status(result: dict[str, Any]) -> tuple[int, str | None, str | None]:
|
||||
if bool(result.get("created")):
|
||||
return (200, None, None)
|
||||
if not bool(result.get("found", True)):
|
||||
return (404, "not_found", "TaskFlow not found.")
|
||||
reason = str(result.get("reason") or "").strip()
|
||||
if reason == "Flow cancellation has already been requested.":
|
||||
return (409, "cancel_requested", reason)
|
||||
if reason == "Flow does not accept managed child tasks.":
|
||||
return (409, "not_managed", reason)
|
||||
if reason.startswith("Flow is already "):
|
||||
return (409, "terminal", reason)
|
||||
return (409, "task_not_created", reason or "TaskFlow task was not created.")
|
||||
|
||||
|
||||
def _map_cancel_status(result: dict[str, Any]) -> tuple[int, str | None, str | None]:
|
||||
if bool(result.get("cancelled")):
|
||||
return (200, None, None)
|
||||
if not bool(result.get("found", True)):
|
||||
return (404, "not_found", "TaskFlow not found.")
|
||||
reason = str(result.get("reason") or "").strip()
|
||||
if reason == "One or more child tasks are still active.":
|
||||
return (202, "cancel_pending", reason)
|
||||
if reason == "Flow changed while cancellation was in progress.":
|
||||
return (409, "revision_conflict", reason)
|
||||
if reason.startswith("Flow is already "):
|
||||
return (409, "terminal", reason)
|
||||
return (409, "cancel_rejected", reason or "TaskFlow cancellation was rejected.")
|
||||
|
||||
|
||||
def _describe_webhook_outcome(action_name: str, result: dict[str, Any]) -> tuple[int, str | None, str | None]:
|
||||
if action_name in {"set_waiting", "resume_flow", "finish_flow", "fail_flow", "request_cancel"}:
|
||||
return _map_mutation_status(result)
|
||||
if action_name == "cancel_flow":
|
||||
return _map_cancel_status(result)
|
||||
if action_name == "run_task":
|
||||
return _map_run_task_status(result)
|
||||
return (200, None, None)
|
||||
|
||||
|
||||
def _map_flow_mutation_result(result: dict[str, Any]) -> dict[str, Any]:
|
||||
if bool(result.get("applied")):
|
||||
flow = _to_flow_view(result.get("flow") if isinstance(result.get("flow"), dict) else None)
|
||||
return {"applied": True, "flow": flow}
|
||||
current = result.get("current")
|
||||
out = {
|
||||
"applied": False,
|
||||
"code": str(result.get("code") or ""),
|
||||
}
|
||||
if isinstance(current, dict):
|
||||
out["current"] = _to_flow_view(current)
|
||||
return out
|
||||
|
||||
|
||||
def execute_webhook_action(*, action: dict[str, Any], target: TaskFlowWebhookTarget, cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
name = str(action.get("action") or "").strip()
|
||||
tf = target.task_flow
|
||||
if name == "create_flow":
|
||||
flow = tf.create_managed(
|
||||
controller_id=action.get("controllerId") or target.default_controller_id,
|
||||
goal=action["goal"],
|
||||
status=action.get("status"),
|
||||
notify_policy=action.get("notifyPolicy"),
|
||||
current_step=action.get("currentStep"),
|
||||
state_json=action.get("stateJson"),
|
||||
wait_json=action.get("waitJson"),
|
||||
)
|
||||
return {"flow": _to_flow_view(flow)}
|
||||
if name == "get_flow":
|
||||
flow = tf.get(action["flowId"])
|
||||
return {"flow": _to_flow_view(flow)}
|
||||
if name == "list_flows":
|
||||
flows = tf.list()
|
||||
return {"flows": [_to_flow_view(x) for x in flows]}
|
||||
if name == "find_latest_flow":
|
||||
flow = tf.find_latest()
|
||||
return {"flow": _to_flow_view(flow)}
|
||||
if name == "resolve_flow":
|
||||
flow = tf.resolve(action["token"])
|
||||
return {"flow": _to_flow_view(flow)}
|
||||
if name == "get_task_summary":
|
||||
return {"summary": tf.get_task_summary(action["flowId"])}
|
||||
if name == "set_waiting":
|
||||
raw = tf.set_waiting(
|
||||
flow_id=action["flowId"],
|
||||
expected_revision=action["expectedRevision"],
|
||||
current_step=action.get("currentStep"),
|
||||
state_json=action.get("stateJson"),
|
||||
wait_json=action.get("waitJson"),
|
||||
blocked_task_id=action.get("blockedTaskId"),
|
||||
blocked_summary=action.get("blockedSummary"),
|
||||
)
|
||||
return _map_flow_mutation_result(raw if isinstance(raw, dict) else {})
|
||||
if name == "resume_flow":
|
||||
raw = tf.resume(
|
||||
flow_id=action["flowId"],
|
||||
expected_revision=action["expectedRevision"],
|
||||
status=action.get("status"),
|
||||
current_step=action.get("currentStep"),
|
||||
state_json=action.get("stateJson"),
|
||||
)
|
||||
return _map_flow_mutation_result(raw if isinstance(raw, dict) else {})
|
||||
if name == "finish_flow":
|
||||
raw = tf.finish(
|
||||
flow_id=action["flowId"],
|
||||
expected_revision=action["expectedRevision"],
|
||||
state_json=action.get("stateJson"),
|
||||
)
|
||||
return _map_flow_mutation_result(raw if isinstance(raw, dict) else {})
|
||||
if name == "fail_flow":
|
||||
raw = tf.fail(
|
||||
flow_id=action["flowId"],
|
||||
expected_revision=action["expectedRevision"],
|
||||
state_json=action.get("stateJson"),
|
||||
blocked_task_id=action.get("blockedTaskId"),
|
||||
blocked_summary=action.get("blockedSummary"),
|
||||
)
|
||||
return _map_flow_mutation_result(raw if isinstance(raw, dict) else {})
|
||||
if name == "request_cancel":
|
||||
raw = tf.request_cancel(
|
||||
flow_id=action["flowId"],
|
||||
expected_revision=action["expectedRevision"],
|
||||
)
|
||||
return _map_flow_mutation_result(raw if isinstance(raw, dict) else {})
|
||||
if name == "cancel_flow":
|
||||
raw = tf.cancel(flow_id=action["flowId"], cfg=cfg)
|
||||
if not isinstance(raw, dict):
|
||||
return {"found": False, "cancelled": False, "reason": "invalid cancel result"}
|
||||
out = {
|
||||
"found": bool(raw.get("found")),
|
||||
"cancelled": bool(raw.get("cancelled")),
|
||||
}
|
||||
if raw.get("reason") is not None:
|
||||
out["reason"] = str(raw.get("reason"))
|
||||
flow = _to_flow_view(raw.get("flow") if isinstance(raw.get("flow"), dict) else None)
|
||||
if flow is not None:
|
||||
out["flow"] = flow
|
||||
tasks = raw.get("tasks")
|
||||
if isinstance(tasks, list):
|
||||
out["tasks"] = [_to_task_view(t if isinstance(t, dict) else None) for t in tasks]
|
||||
return out
|
||||
if name == "run_task":
|
||||
raw = tf.run_task(
|
||||
flow_id=action["flowId"],
|
||||
runtime=action["runtime"],
|
||||
source_id=action.get("sourceId"),
|
||||
child_session_key=action.get("childSessionKey"),
|
||||
parent_task_id=action.get("parentTaskId"),
|
||||
agent_id=action.get("agentId"),
|
||||
run_id=action.get("runId"),
|
||||
label=action.get("label"),
|
||||
task=action["task"],
|
||||
prefer_metadata=action.get("preferMetadata"),
|
||||
notify_policy=action.get("notifyPolicy"),
|
||||
status=action.get("status"),
|
||||
started_at=action.get("startedAt"),
|
||||
last_event_at=action.get("lastEventAt"),
|
||||
progress_summary=action.get("progressSummary"),
|
||||
)
|
||||
if not isinstance(raw, dict):
|
||||
return {"found": False, "created": False, "reason": "invalid run_task result"}
|
||||
if bool(raw.get("created")):
|
||||
return {
|
||||
"created": True,
|
||||
"flow": _to_flow_view(raw.get("flow") if isinstance(raw.get("flow"), dict) else None),
|
||||
"task": _to_task_view(raw.get("task") if isinstance(raw.get("task"), dict) else None),
|
||||
}
|
||||
out = {
|
||||
"found": bool(raw.get("found")),
|
||||
"created": False,
|
||||
"reason": str(raw.get("reason") or ""),
|
||||
}
|
||||
flow = _to_flow_view(raw.get("flow") if isinstance(raw.get("flow"), dict) else None)
|
||||
if flow is not None:
|
||||
out["flow"] = flow
|
||||
return out
|
||||
raise ValueError(f"unsupported webhook action: {name}")
|
||||
|
||||
|
||||
def create_taskflow_webhook_request_handler(*, cfg: dict[str, Any], targets_by_path: dict[str, list[TaskFlowWebhookTarget]]):
|
||||
def handle(request: dict[str, Any]) -> dict[str, Any]:
|
||||
path = str(request.get("path") or "/")
|
||||
targets = list(targets_by_path.get(path) or [])
|
||||
if not targets:
|
||||
return {"ok": False, "code": "not_found", "error": "route not found"}
|
||||
headers = request.get("headers")
|
||||
headers = headers if isinstance(headers, dict) else {}
|
||||
presented = _extract_secret({str(k).lower(): str(v) for k, v in headers.items()})
|
||||
if not presented:
|
||||
return {"ok": False, "code": "unauthorized", "error": "missing webhook secret"}
|
||||
matched: TaskFlowWebhookTarget | None = None
|
||||
for target in targets:
|
||||
resolved = resolve_configured_secret_input_string(value=target.secret_input)
|
||||
if resolved and _timing_safe_equals(resolved, presented):
|
||||
matched = target
|
||||
break
|
||||
if matched is None:
|
||||
return {"ok": False, "code": "unauthorized", "error": "invalid webhook secret"}
|
||||
body = request.get("json")
|
||||
if isinstance(body, str):
|
||||
body = json.loads(body)
|
||||
if not isinstance(body, dict):
|
||||
return {"ok": False, "code": "invalid_request", "error": "request body must be json object"}
|
||||
try:
|
||||
action_name = str(body.get("action") or "").strip()
|
||||
result = execute_webhook_action(action=body, target=matched, cfg=cfg)
|
||||
except Exception as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"routeId": matched.route_id,
|
||||
"code": "request_rejected",
|
||||
"error": str(exc),
|
||||
}
|
||||
status_code, code, error = _describe_webhook_outcome(action_name, result if isinstance(result, dict) else {})
|
||||
if status_code < 400:
|
||||
out = {"ok": True, "routeId": matched.route_id, "statusCode": status_code, "result": result}
|
||||
if code:
|
||||
out["code"] = code
|
||||
return out
|
||||
out = {
|
||||
"ok": False,
|
||||
"routeId": matched.route_id,
|
||||
"statusCode": status_code,
|
||||
"code": code or "request_rejected",
|
||||
"error": error or "request rejected",
|
||||
"result": result,
|
||||
}
|
||||
return out
|
||||
|
||||
return handle
|
||||
|
||||
26
runtime/extensions/webhooks/index.py
Normal file
26
runtime/extensions/webhooks/index.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .config import resolve_webhooks_plugin_config
|
||||
from .http import TaskFlowWebhookTarget, create_taskflow_webhook_request_handler
|
||||
|
||||
|
||||
def register_webhook_routes(api) -> None:
|
||||
routes = resolve_webhooks_plugin_config(plugin_config=getattr(api, "plugin_config", {}) or {})
|
||||
if not routes:
|
||||
return
|
||||
targets_by_path: dict[str, list[TaskFlowWebhookTarget]] = {}
|
||||
handler = create_taskflow_webhook_request_handler(cfg=getattr(api, "config", {}) or {}, targets_by_path=targets_by_path)
|
||||
for route in routes:
|
||||
task_flow = api.runtime.task_flow.bind_session(session_key=route.session_key)
|
||||
target = TaskFlowWebhookTarget(
|
||||
route_id=route.route_id,
|
||||
path=route.path,
|
||||
secret_input=route.secret,
|
||||
secret_config_path=f"plugins.entries.webhooks.routes.{route.route_id}.secret",
|
||||
default_controller_id=route.controller_id,
|
||||
task_flow=task_flow,
|
||||
)
|
||||
targets_by_path.setdefault(target.path, []).append(target)
|
||||
api.register_http_route(path=target.path, auth="plugin", match="exact", replace_existing=True, handler=handler)
|
||||
api.logger.info(f"[webhooks] registered route {route.route_id} on {route.path} for session {route.session_key}")
|
||||
|
||||
35
runtime/extensions/webhooks/runtime_api.py
Normal file
35
runtime/extensions/webhooks/runtime_api.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
WEBHOOK_RATE_LIMIT_DEFAULTS: dict[str, int] = {
|
||||
"window_ms": 60_000,
|
||||
"max_requests": 120,
|
||||
"max_tracked_keys": 20_000,
|
||||
}
|
||||
|
||||
WEBHOOK_IN_FLIGHT_DEFAULTS: dict[str, int] = {
|
||||
"max_in_flight_per_key": 8,
|
||||
"max_tracked_keys": 20_000,
|
||||
}
|
||||
|
||||
|
||||
def normalize_webhook_path(path: str) -> str:
|
||||
p = str(path or "").strip()
|
||||
if not p:
|
||||
raise ValueError("webhook path is required")
|
||||
if not p.startswith("/"):
|
||||
p = "/" + p
|
||||
while "//" in p:
|
||||
p = p.replace("//", "/")
|
||||
return p.rstrip("/") or "/"
|
||||
|
||||
|
||||
def resolve_configured_secret_input_string(*, value: Any) -> str | None:
|
||||
# Python 重写版先支持直传字符串;ref 由上层配置系统扩展。
|
||||
if isinstance(value, str):
|
||||
s = value.strip()
|
||||
return s or None
|
||||
return None
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue