mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 04:40:45 +08:00
本次迁移将网关/通道/工具/技能/脚本与协议资源集中到新结构,统一路径常量与脚本转发机制,减少顶层噪音并保证运行与测试行为一致。 Made-with: Cursor
266 lines
8.9 KiB
Python
266 lines
8.9 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from typing import Any
|
|
|
|
from .shared_types import GatewayRequestHandlers
|
|
from .telegram_send_normalize import normalize_transport_target_for_channel
|
|
from .validation import error_shape
|
|
|
|
|
|
def _bad(respond, message: str) -> None:
|
|
if callable(respond):
|
|
respond(False, None, error_shape("INVALID_REQUEST", message), None)
|
|
|
|
|
|
def _unavailable(respond, message: str, *, meta: dict[str, Any] | None = None) -> None:
|
|
if callable(respond):
|
|
respond(False, None, error_shape("UNAVAILABLE", message), meta or None)
|
|
|
|
|
|
def _ok(respond, payload: dict[str, Any] | None = None, *, meta: dict[str, Any] | None = None) -> None:
|
|
if callable(respond):
|
|
respond(True, payload or {"ok": True}, None, meta or None)
|
|
|
|
|
|
def _normalize_optional_str(value: Any) -> str | None:
|
|
if isinstance(value, str):
|
|
v = value.strip()
|
|
return v or None
|
|
return None
|
|
|
|
|
|
def _normalize_channel(value: Any) -> str | None:
|
|
ch = _normalize_optional_str(value)
|
|
if not ch:
|
|
return None
|
|
lower = ch.lower()
|
|
# TS rejects webchat as internal-only for these endpoints.
|
|
if lower == "webchat":
|
|
return None
|
|
return lower
|
|
|
|
|
|
def _dedupe_get(context: Any, key: str) -> dict[str, Any] | None:
|
|
if not isinstance(context, dict):
|
|
return None
|
|
dedupe = context.get("dedupe")
|
|
if isinstance(dedupe, dict):
|
|
cached = dedupe.get(key)
|
|
return cached if isinstance(cached, dict) else None
|
|
return None
|
|
|
|
|
|
def _dedupe_set_success(context: Any, key: str, payload: Any) -> None:
|
|
if not isinstance(context, dict):
|
|
return
|
|
dedupe = context.get("dedupe")
|
|
if isinstance(dedupe, dict):
|
|
dedupe[key] = {"ok": True, "payload": payload, "error": None}
|
|
|
|
|
|
def _dedupe_set_failure(context: Any, key: str, error: Any) -> None:
|
|
if not isinstance(context, dict):
|
|
return
|
|
dedupe = context.get("dedupe")
|
|
if isinstance(dedupe, dict):
|
|
dedupe[key] = {"ok": False, "payload": None, "error": error}
|
|
|
|
|
|
def _run_maybe_await(value: Any) -> Any:
|
|
"""Run coroutine results in a sync handler.
|
|
|
|
Gateway handlers in this repo are synchronous today, but some context hooks
|
|
may be authored as async. We support both by executing coroutine results
|
|
when no event loop is running; otherwise we raise to avoid deadlocks.
|
|
"""
|
|
if asyncio.iscoroutine(value):
|
|
try:
|
|
loop = asyncio.get_running_loop()
|
|
except RuntimeError:
|
|
loop = None
|
|
if loop is not None and loop.is_running():
|
|
raise RuntimeError("async hook used from sync handler while event loop is running")
|
|
return asyncio.run(value)
|
|
return value
|
|
|
|
|
|
def _message_action_handler(opts: dict[str, Any]) -> None:
|
|
respond = opts.get("respond")
|
|
params = opts.get("params")
|
|
context = opts.get("context")
|
|
client = opts.get("client")
|
|
|
|
if not isinstance(params, dict):
|
|
_bad(respond, "invalid message.action params")
|
|
return
|
|
|
|
idem = _normalize_optional_str(params.get("idempotencyKey"))
|
|
if not idem:
|
|
_bad(respond, "invalid message.action params: idempotencyKey required")
|
|
return
|
|
|
|
dedupe_key = f"message.action:{idem}"
|
|
cached = _dedupe_get(context, dedupe_key)
|
|
if cached:
|
|
if callable(respond):
|
|
respond(bool(cached.get("ok")), cached.get("payload"), cached.get("error"), {"cached": True})
|
|
return
|
|
|
|
channel = _normalize_channel(params.get("channel"))
|
|
if not channel:
|
|
_bad(
|
|
respond,
|
|
"unsupported channel: webchat (internal-only). Use `chat.send` for WebChat UI messages or choose a deliverable channel.",
|
|
)
|
|
return
|
|
|
|
action = _normalize_optional_str(params.get("action"))
|
|
if not action:
|
|
_bad(respond, "invalid message.action params: action required")
|
|
return
|
|
|
|
action_params = params.get("params")
|
|
if not isinstance(action_params, dict):
|
|
_bad(respond, "invalid message.action params: params must be object")
|
|
return
|
|
|
|
# Authorization: we only trust `senderIsOwner` if the caller is already admin-scoped.
|
|
sender_is_owner_wire = params.get("senderIsOwner") is True
|
|
caller_scopes = []
|
|
if isinstance(client, dict):
|
|
connect = client.get("connect")
|
|
if isinstance(connect, dict) and isinstance(connect.get("scopes"), list):
|
|
caller_scopes = [x for x in connect.get("scopes") if isinstance(x, str)]
|
|
caller_is_full_operator = "operator.admin" in caller_scopes
|
|
sender_is_owner = bool(caller_is_full_operator and sender_is_owner_wire)
|
|
|
|
dispatch = context.get("dispatch_message_action") if isinstance(context, dict) else None
|
|
if not callable(dispatch):
|
|
payload = {"channel": channel, "action": action, "handled": False}
|
|
_dedupe_set_success(context, dedupe_key, payload)
|
|
_ok(respond, payload, meta={"channel": channel})
|
|
return
|
|
|
|
try:
|
|
handled = _run_maybe_await(
|
|
dispatch(
|
|
{
|
|
"channel": channel,
|
|
"action": action,
|
|
"params": action_params,
|
|
"senderIsOwner": sender_is_owner,
|
|
"raw": params,
|
|
}
|
|
)
|
|
)
|
|
payload = handled if isinstance(handled, dict) else {"handled": bool(handled)}
|
|
_dedupe_set_success(context, dedupe_key, payload)
|
|
_ok(respond, payload, meta={"channel": channel})
|
|
except Exception as exc:
|
|
err = error_shape("UNAVAILABLE", str(exc))
|
|
_dedupe_set_failure(context, dedupe_key, err)
|
|
_unavailable(respond, str(exc), meta={"channel": channel})
|
|
|
|
|
|
def _send_handler(opts: dict[str, Any]) -> None:
|
|
respond = opts.get("respond")
|
|
params = opts.get("params")
|
|
context = opts.get("context")
|
|
|
|
if not isinstance(params, dict):
|
|
_bad(respond, "invalid send params")
|
|
return
|
|
|
|
idem = _normalize_optional_str(params.get("idempotencyKey"))
|
|
if not idem:
|
|
_bad(respond, "invalid send params: idempotencyKey required")
|
|
return
|
|
|
|
dedupe_key = f"send:{idem}"
|
|
cached = _dedupe_get(context, dedupe_key)
|
|
if cached:
|
|
if callable(respond):
|
|
respond(bool(cached.get("ok")), cached.get("payload"), cached.get("error"), {"cached": True})
|
|
return
|
|
|
|
to = _normalize_optional_str(params.get("to")) or ""
|
|
if not to:
|
|
_bad(respond, "invalid send params: to required")
|
|
return
|
|
|
|
message = _normalize_optional_str(params.get("message")) or ""
|
|
media_url = _normalize_optional_str(params.get("mediaUrl"))
|
|
media_urls_raw = params.get("mediaUrls")
|
|
media_urls: list[str] = []
|
|
if isinstance(media_urls_raw, list):
|
|
for entry in media_urls_raw:
|
|
v = _normalize_optional_str(entry)
|
|
if v:
|
|
media_urls.append(v)
|
|
|
|
if not message and not media_url and not media_urls:
|
|
_bad(respond, "invalid send params: text or media is required")
|
|
return
|
|
|
|
channel = _normalize_channel(params.get("channel")) or "auto"
|
|
if channel == "auto":
|
|
# If caller doesn't specify a channel, allow context to choose a default deliverable channel.
|
|
choose = context.get("resolve_default_channel") if isinstance(context, dict) else None
|
|
if callable(choose):
|
|
try:
|
|
chosen = choose()
|
|
channel = _normalize_channel(chosen) or "auto"
|
|
except Exception:
|
|
channel = "auto"
|
|
if channel == "auto":
|
|
channel = "unknown"
|
|
|
|
to, channel_extra = normalize_transport_target_for_channel(channel=channel, to=to, params=params)
|
|
|
|
deliver = context.get("deliver_outbound") if isinstance(context, dict) else None
|
|
if not callable(deliver):
|
|
payload = {
|
|
"runId": idem,
|
|
"channel": channel,
|
|
"to": to,
|
|
"messageId": f"msg_{idem}",
|
|
**channel_extra,
|
|
}
|
|
_dedupe_set_success(context, dedupe_key, payload)
|
|
_ok(respond, payload, meta={"channel": channel})
|
|
return
|
|
|
|
try:
|
|
result = _run_maybe_await(
|
|
deliver(
|
|
{
|
|
"runId": idem,
|
|
"channel": channel,
|
|
"to": to,
|
|
"message": message or None,
|
|
"mediaUrl": media_url,
|
|
"mediaUrls": media_urls,
|
|
**channel_extra,
|
|
"raw": params,
|
|
}
|
|
)
|
|
)
|
|
payload = result if isinstance(result, dict) else {"ok": True}
|
|
if "runId" not in payload:
|
|
payload["runId"] = idem
|
|
if "channel" not in payload:
|
|
payload["channel"] = channel
|
|
_dedupe_set_success(context, dedupe_key, payload)
|
|
_ok(respond, payload, meta={"channel": channel})
|
|
except Exception as exc:
|
|
err = error_shape("UNAVAILABLE", str(exc))
|
|
_dedupe_set_failure(context, dedupe_key, err)
|
|
_unavailable(respond, str(exc), meta={"channel": channel})
|
|
|
|
|
|
send_handlers: GatewayRequestHandlers = {
|
|
"message.action": _message_action_handler,
|
|
"send": _send_handler,
|
|
}
|
|
|