oclaw/gateway/server_methods/send.py
oliver ba3836f00f 初始化:独立 oclaw 仓库首提交
- 在 oclaw/ 下重新初始化 Git 仓库
- 补齐子仓库 .gitignore,避免提交本地运行态数据(_local、node_modules、logs 等)
- 提交当前工程代码与配置

Made-with: Cursor
2026-04-24 22:31:22 +08:00

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,
}