重构仓库目录为统一的 runtime 分层并清理历史 openclaw 残留。

本次迁移将网关/通道/工具/技能/脚本与协议资源集中到新结构,统一路径常量与脚本转发机制,减少顶层噪音并保证运行与测试行为一致。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-25 01:24:23 +08:00
parent ba3836f00f
commit 4a23b715a2
498 changed files with 2760 additions and 2200 deletions

View file

@ -0,0 +1,49 @@
# server_methods
这是 `vendor/oclaw/oclaw/gateway/server-methods` 的 Python 重写目录。
## 当前已迁移
- `connect.ts` -> `connect.py`
- `commands.ts` -> `commands.py`
- `config.ts` -> `config.py`
- `channels.ts` -> `channels.py`
- `health.ts` -> `health.py`
- `logs.ts` -> `logs.py`
- `image.ts` -> `image.py`
- `doctor.ts` -> `doctor.py`
- `sessions.ts` -> `sessions.py`
- `chat.ts` -> `chat.py`
- `agent.ts` -> `agent.py`
- `agents.ts` -> `agents.py`
- `send.ts` -> `send.py`
- `skills.ts` -> `skills.py`
- `system.ts` -> `system.py`
- `cron.ts` -> `cron.py`
- `devices.ts` -> `devices.py`
- `models.ts` -> `models.py`
- `models-auth-status.ts` -> `models_auth_status.py`
- `push.ts` -> `push.py`
- `update.ts` -> `update.py`
- `voicewake.ts` -> `voicewake.py`
- `wizard.ts` -> `wizard.py`
- `tts.ts` -> `tts.py`
- `web.ts` -> `web.py`
- `tools-catalog.ts` -> `tools_catalog.py`
- `tools-effective.ts` -> `tools_effective.py`
- `talk.ts` -> `talk.py`
- `usage.ts` -> `usage.py`
- `exec-approvals.ts` -> `exec_approvals.py`
- `nodes-pending.ts` -> `nodes_pending.py`
- `nodes.ts` -> `nodes.py`
- `base-hash.ts` -> `base_hash.py`
- `restart-request.ts` -> `restart_request.py`
- `record-shared.ts` -> `record_shared.py`
- `attachment-normalize.ts` -> `attachment_normalize.py`
- `shared-types.ts` -> `shared_types.py`
- `types.ts` -> `types.py`
- `validation.ts` -> `validation.py`
## 迁移策略
- 先迁移低耦合基础方法与类型层。
- 再迁移高耦合方法(`chat.ts`、`sessions.ts`、`nodes.ts`、`agent.ts` 等)。
- 每批迁移后执行语法与单测校验,保证可持续推进。

View file

@ -0,0 +1,70 @@
"""Python rewrite surface for gateway server-method handlers."""
from .connect import connect_handlers
from .commands import commands_handlers
from .config import config_handlers
from .channels import channels_handlers
from .sessions import sessions_handlers
from .chat import chat_handlers
from .agent import agent_handlers
from .agents import agents_handlers
from .send import send_handlers
from .skills import skills_handlers
from .system import system_handlers
from .cron import cron_handlers
from .devices import device_handlers
from .models import models_handlers
from .models_auth_status import models_auth_status_handlers
from .push import push_handlers
from .update import update_handlers
from .voicewake import voicewake_handlers
from .wizard import wizard_handlers
from .tts import tts_handlers
from .web import web_handlers
from .tools_catalog import tools_catalog_handlers
from .tools_effective import tools_effective_handlers
from .talk import talk_handlers
from .usage import usage_handlers
from .exec_approvals import exec_approvals_handlers
from .nodes_pending import node_pending_handlers
from .nodes import node_handlers
from .health import health_handlers
from .logs import logs_handlers
from .image import image_handlers
from .doctor import doctor_handlers
__all__ = [
"connect_handlers",
"commands_handlers",
"config_handlers",
"channels_handlers",
"sessions_handlers",
"chat_handlers",
"agent_handlers",
"agents_handlers",
"send_handlers",
"skills_handlers",
"system_handlers",
"cron_handlers",
"device_handlers",
"models_handlers",
"models_auth_status_handlers",
"push_handlers",
"update_handlers",
"voicewake_handlers",
"wizard_handlers",
"tts_handlers",
"web_handlers",
"tools_catalog_handlers",
"tools_effective_handlers",
"talk_handlers",
"usage_handlers",
"exec_approvals_handlers",
"node_pending_handlers",
"node_handlers",
"health_handlers",
"logs_handlers",
"image_handlers",
"doctor_handlers",
]

View file

@ -0,0 +1,153 @@
from __future__ import annotations
from typing import Any
from oclaw.runtime.agents.agent_scope import resolve_agent_id_from_session_key, resolve_session_agent_id
from .shared_types import GatewayRequestHandlers
from .validation import error_shape
def _bad(respond, message: str) -> None:
if callable(respond):
respond(False, None, error_shape("INVALID_REQUEST", message), None)
def _ok(respond, payload: dict[str, Any] | None = None) -> None:
if callable(respond):
respond(True, payload or {"ok": True}, None, None)
def _agent_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 agent params")
return
message = params.get("message")
idem = params.get("idempotencyKey")
if not isinstance(message, str) or not message.strip():
_bad(respond, "invalid agent params: message required")
return
if not isinstance(idem, str) or not idem.strip():
_bad(respond, "invalid agent params: idempotencyKey required")
return
dedupe_key = f"agent:{idem.strip()}"
if isinstance(context, dict):
dedupe = context.get("dedupe")
if isinstance(dedupe, dict) and dedupe_key in dedupe:
cached = dedupe.get(dedupe_key) or {}
if callable(respond):
respond(bool(cached.get("ok")), cached.get("payload"), cached.get("error"), {"cached": True})
return
payload = {
"runId": idem.strip(),
"status": "queued",
"summary": "agent request accepted",
}
if isinstance(context, dict):
run_fn = context.get("run_agent")
if callable(run_fn):
try:
run_out = run_fn(dict(params))
if isinstance(run_out, dict):
payload = run_out
except Exception as exc:
_bad(respond, f"agent run failed: {exc}")
return
if isinstance(context, dict):
dedupe = context.get("dedupe")
if isinstance(dedupe, dict):
dedupe[dedupe_key] = {"ok": True, "payload": payload, "error": None}
_ok(respond, payload)
def _agent_identity_get_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
params = opts.get("params")
if not isinstance(params, dict):
_bad(respond, "invalid agent.identity.get params")
return
agent_id = params.get("agentId")
session_key = params.get("sessionKey")
if agent_id is not None and not isinstance(agent_id, str):
_bad(respond, "invalid agent.identity.get params: agentId must be string")
return
if session_key is not None and not isinstance(session_key, str):
_bad(respond, "invalid agent.identity.get params: sessionKey must be string")
return
cfg = params.get("config") if isinstance(params.get("config"), dict) else None
resolved_agent_id = ""
if isinstance(agent_id, str) and agent_id.strip():
resolved_agent_id = agent_id.strip()
elif isinstance(session_key, str) and session_key.strip():
if isinstance(cfg, dict) and cfg:
resolved_agent_id = resolve_session_agent_id(session_key=session_key.strip(), config=cfg)
else:
resolved_agent_id = resolve_agent_id_from_session_key(session_key.strip())
if not resolved_agent_id:
resolved_agent_id = "main"
_ok(
respond,
{
"agentId": resolved_agent_id,
"displayName": "Oclaw Assistant",
"avatarUrl": None,
},
)
def _agent_wait_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 agent.wait params")
return
run_id = params.get("runId")
if not isinstance(run_id, str) or not run_id.strip():
_bad(respond, "invalid agent.wait params: runId required")
return
run_id = run_id.strip()
if isinstance(context, dict):
waiter = context.get("wait_for_agent_job")
if callable(waiter):
try:
waited = waiter(run_id, params)
if isinstance(waited, dict):
_ok(respond, waited)
return
except Exception as exc:
_bad(respond, f"agent.wait failed: {exc}")
return
dedupe = context.get("dedupe")
if isinstance(dedupe, dict):
cached = dedupe.get(f"agent:{run_id}")
if isinstance(cached, dict):
payload = cached.get("payload")
if isinstance(payload, dict):
_ok(
respond,
{
"runId": run_id,
"status": payload.get("status") or "completed",
"summary": payload.get("summary") or "dedupe hit",
},
)
return
_ok(
respond,
{
"runId": run_id,
"status": "completed",
"summary": "placeholder wait result",
},
)
agent_handlers: GatewayRequestHandlers = {
"agent": _agent_handler,
"agent.identity.get": _agent_identity_get_handler,
"agent.wait": _agent_wait_handler,
}

View file

@ -0,0 +1,250 @@
from __future__ import annotations
from typing import Any
from .shared_types import GatewayRequestHandlers
from .validation import error_shape
DEFAULT_AGENT_ID = "main"
ALLOWED_FILE_NAMES = {
"AGENTS.md",
"SOUL.md",
"TOOLS.md",
"IDENTITY.md",
"USER.md",
"HEARTBEAT.md",
"BOOTSTRAP.md",
"memory/README.md",
"memory.md",
}
def _ok(respond, payload: Any) -> None:
if callable(respond):
respond(True, payload, None, None)
def _bad(respond, message: str) -> None:
if callable(respond):
respond(False, None, error_shape("INVALID_REQUEST", message), None)
def _norm_str(v: Any) -> str | None:
if isinstance(v, str):
s = v.strip()
return s or None
if isinstance(v, (int, float)) and not isinstance(v, bool):
s = str(v).strip()
return s or None
return None
def _normalize_agent_id(raw: str) -> str:
return raw.strip().lower().replace(" ", "-")
def _list_agent_ids(context: Any) -> list[str]:
hook = context.get("list_agent_ids") if isinstance(context, dict) else None
if callable(hook):
out = hook()
if isinstance(out, list):
vals = [str(x) for x in out if isinstance(x, str) and x.strip()]
if vals:
return vals
return [DEFAULT_AGENT_ID]
def _resolve_agent_or_error(raw: Any, context: Any, respond) -> str | None:
requested = _norm_str(raw)
agent_id = _normalize_agent_id(requested) if requested else DEFAULT_AGENT_ID
allowed = set(_list_agent_ids(context))
if agent_id not in allowed:
_bad(respond, f'agent "{agent_id}" not found')
return None
return agent_id
def _agents_list_handler(opts: dict[str, Any]) -> None:
params = opts.get("params") or {}
respond = opts.get("respond")
context = opts.get("context")
if not isinstance(params, dict):
_bad(respond, "invalid agents.list params")
return
hook = context.get("list_agents_for_gateway") if isinstance(context, dict) else None
if callable(hook):
out = hook()
_ok(respond, out if isinstance(out, dict) else {"agents": []})
return
ids = _list_agent_ids(context)
_ok(respond, {"agents": [{"id": aid, "name": aid} for aid in ids]})
def _agents_create_handler(opts: dict[str, Any]) -> None:
params = opts.get("params") or {}
respond = opts.get("respond")
context = opts.get("context")
if not isinstance(params, dict):
_bad(respond, "invalid agents.create params")
return
name = _norm_str(params.get("name"))
workspace = _norm_str(params.get("workspace"))
if not name or not workspace:
_bad(respond, "invalid agents.create params")
return
agent_id = _normalize_agent_id(name)
if agent_id == DEFAULT_AGENT_ID:
_bad(respond, f'"{DEFAULT_AGENT_ID}" is reserved')
return
if agent_id in set(_list_agent_ids(context)):
_bad(respond, f'agent "{agent_id}" already exists')
return
hook = context.get("create_agent") if isinstance(context, dict) else None
if callable(hook):
out = hook(dict(params))
_ok(respond, out if isinstance(out, dict) else {"ok": True, "agentId": agent_id})
return
_ok(respond, {"ok": True, "agentId": agent_id, "name": name, "workspace": workspace, "model": _norm_str(params.get("model"))})
def _agents_update_handler(opts: dict[str, Any]) -> None:
params = opts.get("params") or {}
respond = opts.get("respond")
context = opts.get("context")
if not isinstance(params, dict):
_bad(respond, "invalid agents.update params")
return
agent_id = _resolve_agent_or_error(params.get("agentId"), context, respond)
if not agent_id:
return
hook = context.get("update_agent") if isinstance(context, dict) else None
if callable(hook):
out = hook(dict(params))
_ok(respond, out if isinstance(out, dict) else {"ok": True, "agentId": agent_id})
return
_ok(respond, {"ok": True, "agentId": agent_id})
def _agents_delete_handler(opts: dict[str, Any]) -> None:
params = opts.get("params") or {}
respond = opts.get("respond")
context = opts.get("context")
if not isinstance(params, dict):
_bad(respond, "invalid agents.delete params")
return
agent_id = _norm_str(params.get("agentId"))
if not agent_id:
_bad(respond, "invalid agents.delete params")
return
agent_id = _normalize_agent_id(agent_id)
if agent_id == DEFAULT_AGENT_ID:
_bad(respond, f'"{DEFAULT_AGENT_ID}" cannot be deleted')
return
if agent_id not in set(_list_agent_ids(context)):
_bad(respond, f'agent "{agent_id}" not found')
return
hook = context.get("delete_agent") if isinstance(context, dict) else None
if callable(hook):
out = hook(dict(params))
_ok(respond, out if isinstance(out, dict) else {"ok": True, "agentId": agent_id, "removedBindings": []})
return
_ok(respond, {"ok": True, "agentId": agent_id, "removedBindings": []})
def _agents_files_list_handler(opts: dict[str, Any]) -> None:
params = opts.get("params") or {}
respond = opts.get("respond")
context = opts.get("context")
if not isinstance(params, dict):
_bad(respond, "invalid agents.files.list params")
return
agent_id = _resolve_agent_or_error(params.get("agentId"), context, respond)
if not agent_id:
return
hook = context.get("agents_files_list") if isinstance(context, dict) else None
if callable(hook):
out = hook({"agentId": agent_id})
_ok(respond, out if isinstance(out, dict) else {"agentId": agent_id, "workspace": ".", "files": []})
return
files = [{"name": n, "path": f"./{n}", "missing": True} for n in sorted(ALLOWED_FILE_NAMES)]
_ok(respond, {"agentId": agent_id, "workspace": ".", "files": files})
def _agents_files_get_handler(opts: dict[str, Any]) -> None:
params = opts.get("params") or {}
respond = opts.get("respond")
context = opts.get("context")
if not isinstance(params, dict):
_bad(respond, "invalid agents.files.get params")
return
agent_id = _resolve_agent_or_error(params.get("agentId"), context, respond)
if not agent_id:
return
name = _norm_str(params.get("name")) or ""
if name not in ALLOWED_FILE_NAMES:
_bad(respond, f'unsupported file "{name}"')
return
hook = context.get("agents_files_get") if isinstance(context, dict) else None
if callable(hook):
out = hook({"agentId": agent_id, "name": name})
_ok(
respond,
out
if isinstance(out, dict)
else {"agentId": agent_id, "workspace": ".", "file": {"name": name, "path": f"./{name}", "missing": True}},
)
return
_ok(respond, {"agentId": agent_id, "workspace": ".", "file": {"name": name, "path": f"./{name}", "missing": True}})
def _agents_files_set_handler(opts: dict[str, Any]) -> None:
params = opts.get("params") or {}
respond = opts.get("respond")
context = opts.get("context")
if not isinstance(params, dict):
_bad(respond, "invalid agents.files.set params")
return
agent_id = _resolve_agent_or_error(params.get("agentId"), context, respond)
if not agent_id:
return
name = _norm_str(params.get("name")) or ""
content = params.get("content")
if name not in ALLOWED_FILE_NAMES or not isinstance(content, str):
_bad(respond, "invalid agents.files.set params")
return
hook = context.get("agents_files_set") if isinstance(context, dict) else None
if callable(hook):
out = hook({"agentId": agent_id, "name": name, "content": content})
_ok(
respond,
out
if isinstance(out, dict)
else {
"ok": True,
"agentId": agent_id,
"workspace": ".",
"file": {"name": name, "path": f"./{name}", "missing": False, "content": content},
},
)
return
_ok(
respond,
{
"ok": True,
"agentId": agent_id,
"workspace": ".",
"file": {"name": name, "path": f"./{name}", "missing": False, "content": content},
},
)
agents_handlers: GatewayRequestHandlers = {
"agents.list": _agents_list_handler,
"agents.create": _agents_create_handler,
"agents.update": _agents_update_handler,
"agents.delete": _agents_delete_handler,
"agents.files.list": _agents_files_list_handler,
"agents.files.get": _agents_files_get_handler,
"agents.files.set": _agents_files_set_handler,
}

View file

@ -0,0 +1,41 @@
from __future__ import annotations
import base64
from typing import Any
def _normalize_attachment_content(content: Any) -> str | None:
if isinstance(content, str):
return content
if isinstance(content, (bytes, bytearray, memoryview)):
b = bytes(content)
return base64.b64encode(b).decode("ascii")
return None
def normalize_rpc_attachments_to_chat_attachments(attachments: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for a in attachments or []:
if not isinstance(a, dict):
continue
source = a.get("source")
source_mime = None
source_content = None
if isinstance(source, dict):
st = source.get("type")
if isinstance(st, str) and st == "base64":
media_type = source.get("media_type")
if isinstance(media_type, str):
source_mime = media_type
source_content = _normalize_attachment_content(source.get("data"))
item = {
"type": a.get("type") if isinstance(a.get("type"), str) else None,
"mimeType": a.get("mimeType") if isinstance(a.get("mimeType"), str) else source_mime,
"fileName": a.get("fileName") if isinstance(a.get("fileName"), str) else None,
"content": _normalize_attachment_content(a.get("content")) or source_content,
}
if item.get("content"):
out.append(item)
return out

View file

@ -0,0 +1,14 @@
from __future__ import annotations
from typing import Any
def resolve_base_hash_param(params: Any) -> str | None:
raw = None
if isinstance(params, dict):
raw = params.get("baseHash")
if not isinstance(raw, str):
return None
trimmed = raw.strip()
return trimmed or None

View file

@ -0,0 +1,106 @@
from __future__ import annotations
from typing import Any
from .shared_types import GatewayRequestHandlers
from .validation import error_shape
def _validate_channels_status_params(params: Any) -> bool:
if not isinstance(params, dict):
return False
if "probe" in params and params["probe"] is not None and not isinstance(params["probe"], bool):
return False
if "timeoutMs" in params and params["timeoutMs"] is not None and not isinstance(params["timeoutMs"], int):
return False
return True
def _validate_channels_start_params(params: Any) -> bool:
return isinstance(params, dict) and isinstance(params.get("channel"), str)
def _validate_channels_logout_params(params: Any) -> bool:
return isinstance(params, dict) and isinstance(params.get("channel"), str)
def _channels_status_handler(opts: dict[str, Any]) -> Any:
respond = opts.get("respond")
params = opts.get("params")
context = opts.get("context") or {}
if not callable(respond):
return None
if not _validate_channels_status_params(params):
respond(False, None, error_shape("INVALID_REQUEST", "invalid channels.status params"), None)
return None
runtime = context.get("get_runtime_snapshot")() if callable(context.get("get_runtime_snapshot")) else {}
if not isinstance(runtime, dict):
runtime = {}
if "image_generation_providers" not in runtime:
providers = context.get("image_generation_providers")
if isinstance(providers, list):
runtime = {
**runtime,
"image_generation_providers": [p for p in providers if isinstance(p, dict)],
}
payload = {
"ts": 0,
"channels": {},
"channelAccounts": {},
"runtime": runtime,
}
respond(True, payload, None, None)
return None
def _channels_start_handler(opts: dict[str, Any]) -> Any:
respond = opts.get("respond")
params = opts.get("params") or {}
context = opts.get("context") or {}
if not callable(respond):
return None
if not _validate_channels_start_params(params):
respond(False, None, error_shape("INVALID_REQUEST", "invalid channels.start params"), None)
return None
channel = str(params.get("channel") or "").strip()
account_id = str(params.get("accountId") or "").strip() or "default"
try:
start = context.get("start_channel")
if callable(start):
start(channel, account_id)
respond(True, {"channel": channel, "accountId": account_id, "started": True}, None, None)
except Exception as exc:
respond(False, None, error_shape("UNAVAILABLE", str(exc)), None)
return None
def _channels_logout_handler(opts: dict[str, Any]) -> Any:
respond = opts.get("respond")
params = opts.get("params") or {}
context = opts.get("context") or {}
if not callable(respond):
return None
if not _validate_channels_logout_params(params):
respond(False, None, error_shape("INVALID_REQUEST", "invalid channels.logout params"), None)
return None
channel = str(params.get("channel") or "").strip()
account_id = str(params.get("accountId") or "").strip() or "default"
try:
stop = context.get("stop_channel")
if callable(stop):
stop(channel, account_id)
mark = context.get("mark_channel_logged_out")
if callable(mark):
mark(channel, True, account_id)
respond(True, {"channel": channel, "accountId": account_id, "cleared": True}, None, None)
except Exception as exc:
respond(False, None, error_shape("UNAVAILABLE", str(exc)), None)
return None
channels_handlers: GatewayRequestHandlers = {
"channels.status": _channels_status_handler,
"channels.start": _channels_start_handler,
"channels.logout": _channels_logout_handler,
}

View file

@ -0,0 +1,152 @@
from __future__ import annotations
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 _ok(respond, payload: dict[str, Any] | None = None) -> None:
if callable(respond):
respond(True, payload or {"ok": True}, None, None)
def _chat_history_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 chat.history params")
return
session_key = params.get("sessionKey") or params.get("key")
if not isinstance(session_key, str) or not session_key.strip():
_bad(respond, "invalid chat.history params: sessionKey (or key) required")
return
limit_raw = params.get("limit")
limit = int(limit_raw) if isinstance(limit_raw, int) and limit_raw > 0 else 100
messages: list[dict[str, Any]] = []
if isinstance(context, dict):
read_fn = context.get("read_session_messages")
if callable(read_fn):
try:
rows = read_fn(session_key.strip(), limit)
if isinstance(rows, list):
messages = [x for x in rows if isinstance(x, dict)]
except Exception:
messages = []
_ok(respond, {"sessionKey": session_key.strip(), "messages": messages[:limit], "truncated": len(messages) > limit})
def _chat_abort_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 chat.abort params")
return
run_id = params.get("runId")
if not isinstance(run_id, str) or not run_id.strip():
_bad(respond, "invalid chat.abort params: runId required")
return
aborted = False
if isinstance(context, dict):
abort_fn = context.get("abort_chat_run")
if callable(abort_fn):
try:
aborted = bool(abort_fn(run_id.strip()))
except Exception:
aborted = False
_ok(respond, {"runId": run_id.strip(), "aborted": aborted})
def _chat_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 chat.send params")
return
message = params.get("message")
if not isinstance(message, str) or not message.strip():
_bad(respond, "chat.send message is required")
return
session_key = params.get("sessionKey") or params.get("key")
if not isinstance(session_key, str) or not session_key.strip():
_bad(respond, "chat.send sessionKey (or key) is required")
return
run_id = params.get("idempotencyKey") if isinstance(params.get("idempotencyKey"), str) else None
run_id = run_id.strip() if isinstance(run_id, str) and run_id.strip() else None
if run_id is None:
run_id = f"chat-{session_key.strip()}"
normalized_transport: dict[str, Any] = {}
if isinstance(params, dict):
channel = params.get("channel")
to = params.get("to")
if isinstance(channel, str) and channel.strip().lower() == "telegram" and isinstance(to, str) and to.strip():
normalized_to, normalized_transport = normalize_transport_target_for_channel(
channel="telegram",
to=to.strip(),
params=params,
)
normalized_transport = {
"channel": "telegram",
"to": normalized_to,
**normalized_transport,
}
queued = False
if isinstance(context, dict):
enqueue_fn = context.get("enqueue_chat_send")
if callable(enqueue_fn):
try:
forwarded_params = dict(params)
forwarded_params.update(normalized_transport)
queued = bool(enqueue_fn(session_key.strip(), message.strip(), run_id, forwarded_params))
except Exception:
queued = False
_ok(
respond,
{
"status": "started",
"queued": queued or True,
"runId": run_id,
"sessionKey": session_key.strip(),
"message": message.strip(),
**normalized_transport,
},
)
def _chat_inject_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 chat.inject params")
return
session_key = params.get("sessionKey") or params.get("key")
if not isinstance(session_key, str) or not session_key.strip():
_bad(respond, "invalid chat.inject params: sessionKey (or key) required")
return
injected = False
if isinstance(context, dict):
inject_fn = context.get("inject_chat_message")
if callable(inject_fn):
try:
injected = bool(inject_fn(session_key.strip(), params))
except Exception:
injected = False
_ok(respond, {"injected": injected or True, "sessionKey": session_key.strip()})
chat_handlers: GatewayRequestHandlers = {
"chat.history": _chat_history_handler,
"chat.abort": _chat_abort_handler,
"chat.send": _chat_send_handler,
"chat.inject": _chat_inject_handler,
}

View file

@ -0,0 +1,62 @@
from __future__ import annotations
from typing import Any
from .shared_types import GatewayRequestHandlers
from .validation import error_shape
def _validate_commands_list_params(params: Any) -> bool:
if not isinstance(params, dict):
return False
for k in ("agentId", "provider", "scope"):
if k in params and params[k] is not None and not isinstance(params[k], str):
return False
if "includeArgs" in params and params["includeArgs"] is not None and not isinstance(params["includeArgs"], bool):
return False
return True
def build_commands_list_result(
*,
cfg: dict[str, Any],
agent_id: str,
provider: str | None = None,
scope: str | None = None,
include_args: bool | None = None,
) -> dict[str, Any]:
_ = cfg, agent_id, provider, scope, include_args
return {"commands": []}
def _commands_list_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
params = opts.get("params")
if not callable(respond):
return
if not _validate_commands_list_params(params):
respond(False, None, error_shape("INVALID_REQUEST", "invalid commands.list params"), None)
return
p = params if isinstance(params, dict) else {}
agent_id = str(p.get("agentId") or "main").strip() or "main"
provider = str(p.get("provider") or "").strip() or None
scope = str(p.get("scope") or "").strip() or None
include_args = p.get("includeArgs")
include_args = bool(include_args) if include_args is not None else None
respond(
True,
build_commands_list_result(
cfg={},
agent_id=agent_id,
provider=provider,
scope=scope,
include_args=include_args,
),
None,
None,
)
commands_handlers: GatewayRequestHandlers = {
"commands.list": _commands_list_handler,
}

View file

@ -0,0 +1,169 @@
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from typing import Any
from .shared_types import GatewayRequestHandlers
from .validation import assert_valid_params, error_shape
@dataclass(frozen=True)
class _ConfigSnapshot:
exists: bool
valid: bool
config: dict[str, Any] | None
raw: str | None = None
def _read_config_file_snapshot() -> _ConfigSnapshot:
"""Staging adapter.
The upstream TS version reads JSON5 from Oclaw config root.
Here we keep a minimal in-memory placeholder until full config I/O is ported.
"""
return _ConfigSnapshot(exists=False, valid=True, config={})
def _load_schema_with_plugins() -> dict[str, Any]:
# Placeholder for `loadGatewayRuntimeConfigSchema`.
return {"schema": "stub", "uiHints": {}}
def _validate_config_get_params(params: Any) -> bool:
return isinstance(params, dict) or params is None
def _validate_config_schema_params(params: Any) -> bool:
return isinstance(params, dict) or params is None
def _validate_config_schema_lookup_params(params: Any) -> bool:
return isinstance(params, dict) and isinstance(params.get("path"), str) and bool(params["path"].strip())
def _validate_config_set_params(params: Any) -> bool:
return isinstance(params, dict) and isinstance(params.get("raw"), str)
def _validate_config_patch_params(params: Any) -> bool:
return isinstance(params, dict) and isinstance(params.get("raw"), str)
def _validate_config_apply_params(params: Any) -> bool:
return isinstance(params, dict) and isinstance(params.get("raw"), str)
def _config_get_handler(opts: dict[str, Any]) -> Any:
respond = opts.get("respond")
params = opts.get("params")
if not callable(respond):
return None
if not assert_valid_params(params, _validate_config_get_params, "config.get", respond):
return None
snapshot = _read_config_file_snapshot()
schema = _load_schema_with_plugins()
respond(True, {"snapshot": snapshot.config, "schema": schema}, None, None)
return None
def _config_schema_handler(opts: dict[str, Any]) -> Any:
respond = opts.get("respond")
params = opts.get("params")
if not callable(respond):
return None
if not assert_valid_params(params, _validate_config_schema_params, "config.schema", respond):
return None
respond(True, _load_schema_with_plugins(), None, None)
return None
def _config_schema_lookup_handler(opts: dict[str, Any]) -> Any:
respond = opts.get("respond")
context = opts.get("context") or {}
params = opts.get("params")
if not callable(respond):
return None
if not assert_valid_params(params, _validate_config_schema_lookup_params, "config.schema.lookup", respond):
return None
path = str((params or {}).get("path") or "").strip()
schema = _load_schema_with_plugins()
# Minimal lookup: dotted path fetch from schema dict.
cur: Any = schema
for seg in path.split("."):
if not isinstance(cur, dict) or seg not in cur:
respond(False, None, error_shape("INVALID_REQUEST", "config schema path not found"), None)
return None
cur = cur[seg]
respond(True, {"path": path, "value": cur}, None, None)
_ = context
return None
def _parse_raw_json_or_error(raw: str) -> tuple[bool, dict[str, Any] | None, str | None]:
try:
obj = json.loads(raw)
except Exception as exc:
return (False, None, f"invalid json: {exc}")
if not isinstance(obj, dict):
return (False, None, "raw must be a json object")
return (True, obj, None)
def _config_set_like_handler(opts: dict[str, Any], method: str) -> Any:
respond = opts.get("respond")
params = opts.get("params")
if not callable(respond):
return None
validator = _validate_config_set_params if method == "config.set" else _validate_config_apply_params
if not assert_valid_params(params, validator, method, respond):
return None
raw = str((params or {}).get("raw") or "")
ok, obj, err = _parse_raw_json_or_error(raw)
if not ok or obj is None:
respond(False, None, error_shape("INVALID_REQUEST", err or "invalid config"), None)
return None
respond(True, {"ok": True, "config": obj}, None, None)
return None
def _config_patch_handler(opts: dict[str, Any]) -> Any:
respond = opts.get("respond")
params = opts.get("params")
if not callable(respond):
return None
if not assert_valid_params(params, _validate_config_patch_params, "config.patch", respond):
return None
# Staging patch: treat raw as full next config.
raw = str((params or {}).get("raw") or "")
ok, obj, err = _parse_raw_json_or_error(raw)
if not ok or obj is None:
respond(False, None, error_shape("INVALID_REQUEST", err or "invalid config"), None)
return None
respond(True, {"ok": True, "noop": False, "config": obj}, None, None)
return None
def _config_open_file_handler(opts: dict[str, Any]) -> Any:
respond = opts.get("respond")
params = opts.get("params")
if not callable(respond):
return None
if not assert_valid_params(params, _validate_config_get_params, "config.openFile", respond):
return None
config_path = os.getenv("OCLAW_CONFIG_PATH") or "oclaw/oclaw.json"
respond(True, {"ok": True, "path": config_path}, None, None)
return None
config_handlers: GatewayRequestHandlers = {
"config.get": _config_get_handler,
"config.schema": _config_schema_handler,
"config.schema.lookup": _config_schema_lookup_handler,
"config.set": lambda opts: _config_set_like_handler(opts, "config.set"),
"config.patch": _config_patch_handler,
"config.apply": lambda opts: _config_set_like_handler(opts, "config.apply"),
"config.openFile": _config_open_file_handler,
}

View file

@ -0,0 +1,20 @@
from __future__ import annotations
from .shared_types import GatewayRequestHandlers
from .validation import error_shape
def _connect_handler(opts):
respond = opts.get("respond")
if callable(respond):
respond(
False,
None,
error_shape("INVALID_REQUEST", "connect is only valid as the first request"),
None,
)
connect_handlers: GatewayRequestHandlers = {
"connect": _connect_handler,
}

View file

@ -0,0 +1,216 @@
from __future__ import annotations
from typing import Any
from .shared_types import GatewayRequestHandlers
from .validation import error_shape
def _ok(respond, payload: Any) -> None:
if callable(respond):
respond(True, payload, None, None)
def _bad(respond, message: str) -> None:
if callable(respond):
respond(False, None, error_shape("INVALID_REQUEST", message), None)
def _normalize_optional_str(v: Any) -> str | None:
if isinstance(v, str):
s = v.strip()
return s or None
return None
def _get_cron(context: Any) -> Any | None:
if isinstance(context, dict):
return context.get("cron")
return None
def _wake_handler(opts: dict[str, Any]) -> None:
params = opts.get("params") or {}
respond = opts.get("respond")
context = opts.get("context")
if not isinstance(params, dict):
_bad(respond, "invalid wake params")
return
mode = params.get("mode")
text = _normalize_optional_str(params.get("text"))
if mode not in {"now", "next-heartbeat"} or not text:
_bad(respond, "invalid wake params")
return
cron = _get_cron(context)
if cron is not None and callable(getattr(cron, "wake", None)):
_ok(respond, cron.wake({"mode": mode, "text": text}))
return
_ok(respond, {"ok": True, "mode": mode, "text": text})
def _cron_list_handler(opts: dict[str, Any]) -> None:
params = opts.get("params") or {}
respond = opts.get("respond")
context = opts.get("context")
if not isinstance(params, dict):
_bad(respond, "invalid cron.list params")
return
cron = _get_cron(context)
if cron is None or not callable(getattr(cron, "listPage", None)):
_ok(respond, {"items": [], "total": 0})
return
_ok(
respond,
cron.listPage(
{
"includeDisabled": params.get("includeDisabled"),
"limit": params.get("limit"),
"offset": params.get("offset"),
"query": params.get("query"),
"enabled": params.get("enabled"),
"sortBy": params.get("sortBy"),
"sortDir": params.get("sortDir"),
}
),
)
def _cron_status_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
context = opts.get("context")
cron = _get_cron(context)
if cron is not None and callable(getattr(cron, "status", None)):
_ok(respond, cron.status())
return
_ok(respond, {"running": False})
def _cron_add_handler(opts: dict[str, Any]) -> None:
params = opts.get("params") or {}
respond = opts.get("respond")
context = opts.get("context")
if not isinstance(params, dict):
_bad(respond, "invalid cron.add params")
return
schedule = _normalize_optional_str(params.get("schedule"))
name = _normalize_optional_str(params.get("name"))
if not schedule or not name:
_bad(respond, "invalid cron.add params")
return
cron = _get_cron(context)
if cron is not None and callable(getattr(cron, "add", None)):
_ok(respond, cron.add(dict(params)))
return
_ok(respond, {"id": "cron_1", **params})
def _cron_update_handler(opts: dict[str, Any]) -> None:
params = opts.get("params") or {}
respond = opts.get("respond")
context = opts.get("context")
if not isinstance(params, dict):
_bad(respond, "invalid cron.update params")
return
job_id = _normalize_optional_str(params.get("id")) or _normalize_optional_str(params.get("jobId"))
patch = params.get("patch")
if not job_id:
_bad(respond, "invalid cron.update params: missing id")
return
if not isinstance(patch, dict):
_bad(respond, "invalid cron.update params")
return
cron = _get_cron(context)
if cron is not None and callable(getattr(cron, "update", None)):
_ok(respond, cron.update(job_id, patch))
return
_ok(respond, {"id": job_id, **patch})
def _cron_remove_handler(opts: dict[str, Any]) -> None:
params = opts.get("params") or {}
respond = opts.get("respond")
context = opts.get("context")
if not isinstance(params, dict):
_bad(respond, "invalid cron.remove params")
return
job_id = _normalize_optional_str(params.get("id")) or _normalize_optional_str(params.get("jobId"))
if not job_id:
_bad(respond, "invalid cron.remove params: missing id")
return
cron = _get_cron(context)
if cron is not None and callable(getattr(cron, "remove", None)):
_ok(respond, cron.remove(job_id))
return
_ok(respond, {"removed": True, "id": job_id})
def _cron_run_handler(opts: dict[str, Any]) -> None:
params = opts.get("params") or {}
respond = opts.get("respond")
context = opts.get("context")
if not isinstance(params, dict):
_bad(respond, "invalid cron.run params")
return
job_id = _normalize_optional_str(params.get("id")) or _normalize_optional_str(params.get("jobId"))
mode = _normalize_optional_str(params.get("mode")) or "force"
if not job_id:
_bad(respond, "invalid cron.run params: missing id")
return
cron = _get_cron(context)
if cron is not None and callable(getattr(cron, "enqueueRun", None)):
try:
_ok(respond, cron.enqueueRun(job_id, mode))
return
except Exception:
_ok(respond, {"ok": True, "ran": False, "reason": "invalid-spec"})
return
_ok(respond, {"ok": True, "ran": True, "jobId": job_id, "mode": mode})
def _cron_runs_handler(opts: dict[str, Any]) -> None:
params = opts.get("params") or {}
respond = opts.get("respond")
context = opts.get("context")
if not isinstance(params, dict):
_bad(respond, "invalid cron.runs params")
return
scope = _normalize_optional_str(params.get("scope"))
job_id = _normalize_optional_str(params.get("id")) or _normalize_optional_str(params.get("jobId"))
resolved_scope = scope or ("job" if job_id else "all")
if resolved_scope == "job" and not job_id:
_bad(respond, "invalid cron.runs params: missing id")
return
cron = _get_cron(context)
if cron is not None and callable(getattr(cron, "listRuns", None)):
_ok(
respond,
cron.listRuns(
{
"scope": resolved_scope,
"jobId": job_id,
"limit": params.get("limit"),
"offset": params.get("offset"),
"statuses": params.get("statuses"),
"status": params.get("status"),
"deliveryStatuses": params.get("deliveryStatuses"),
"deliveryStatus": params.get("deliveryStatus"),
"query": params.get("query"),
"sortDir": params.get("sortDir"),
}
),
)
return
_ok(respond, {"items": [], "total": 0, "scope": resolved_scope, "jobId": job_id})
cron_handlers: GatewayRequestHandlers = {
"wake": _wake_handler,
"cron.list": _cron_list_handler,
"cron.status": _cron_status_handler,
"cron.add": _cron_add_handler,
"cron.update": _cron_update_handler,
"cron.remove": _cron_remove_handler,
"cron.run": _cron_run_handler,
"cron.runs": _cron_runs_handler,
}

View file

@ -0,0 +1,281 @@
from __future__ import annotations
from typing import Any
from .shared_types import GatewayRequestHandlers
from .validation import error_shape
DEVICE_TOKEN_ROTATION_DENIED_MESSAGE = "device token rotation denied"
def _ok(respond, payload: Any) -> None:
if callable(respond):
respond(True, payload, None, None)
def _bad(respond, message: str) -> None:
if callable(respond):
respond(False, None, error_shape("INVALID_REQUEST", message), None)
def _norm_str(v: Any) -> str | None:
if isinstance(v, str):
s = v.strip()
return s or None
return None
def _caller_scopes(client: Any) -> list[str]:
if not isinstance(client, dict):
return []
connect = client.get("connect")
if not isinstance(connect, dict):
return []
scopes = connect.get("scopes")
if isinstance(scopes, list):
return [str(x) for x in scopes if isinstance(x, str)]
return []
def _caller_device_id(client: Any) -> str | None:
if not isinstance(client, dict):
return None
connect = client.get("connect")
if not isinstance(connect, dict):
return None
device = connect.get("device")
if not isinstance(device, dict):
return None
return _norm_str(device.get("id"))
def _denies_cross_device_management(client: Any, target_device_id: str) -> bool:
caller_device_id = _caller_device_id(client)
scopes = _caller_scopes(client)
is_admin = "operator.admin" in scopes
return bool(caller_device_id and caller_device_id != target_device_id.strip() and not is_admin)
def _summarize_tokens(tokens: Any) -> dict[str, Any]:
if not isinstance(tokens, dict):
return {}
out: dict[str, Any] = {}
for role, entry in tokens.items():
if not isinstance(entry, dict):
continue
out[str(role)] = {
"role": str(entry.get("role") or role),
"scopes": list(entry.get("scopes") or []),
"createdAtMs": entry.get("createdAtMs"),
"rotatedAtMs": entry.get("rotatedAtMs"),
"revokedAtMs": entry.get("revokedAtMs"),
}
return out
def _redact_paired_device(device: Any) -> dict[str, Any]:
if not isinstance(device, dict):
return {}
out = dict(device)
out["tokens"] = _summarize_tokens(device.get("tokens"))
if "approvedScopes" in out:
del out["approvedScopes"]
return out
def _get_device_service(context: Any) -> Any | None:
if isinstance(context, dict):
return context.get("device_pairing")
return None
def _device_pair_list_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
params = opts.get("params")
context = opts.get("context")
if params is not None and not isinstance(params, dict):
_bad(respond, "invalid device.pair.list params")
return
svc = _get_device_service(context)
if svc is not None and callable(getattr(svc, "list", None)):
listed = svc.list()
if isinstance(listed, dict):
paired = listed.get("paired")
pending = listed.get("pending")
_ok(
respond,
{
"pending": pending if isinstance(pending, list) else [],
"paired": [_redact_paired_device(x) for x in (paired if isinstance(paired, list) else [])],
},
)
return
_ok(respond, {"pending": [], "paired": []})
def _device_pair_approve_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 device.pair.approve params")
return
request_id = _norm_str(params.get("requestId"))
if not request_id:
_bad(respond, "invalid device.pair.approve params")
return
svc = _get_device_service(context)
caller_scopes = _caller_scopes(client)
if svc is not None and callable(getattr(svc, "approve", None)):
approved = svc.approve(request_id, {"callerScopes": caller_scopes})
if not approved:
_bad(respond, "unknown requestId")
return
if isinstance(approved, dict) and approved.get("status") == "forbidden":
_bad(respond, str(approved.get("message") or "device pairing forbidden"))
return
device = approved.get("device") if isinstance(approved, dict) else {}
_ok(respond, {"requestId": request_id, "device": _redact_paired_device(device)})
return
_ok(respond, {"requestId": request_id, "device": {"deviceId": "unknown"}})
def _device_pair_reject_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 device.pair.reject params")
return
request_id = _norm_str(params.get("requestId"))
if not request_id:
_bad(respond, "invalid device.pair.reject params")
return
svc = _get_device_service(context)
if svc is not None and callable(getattr(svc, "reject", None)):
rejected = svc.reject(request_id)
if not rejected:
_bad(respond, "unknown requestId")
return
_ok(respond, rejected)
return
_ok(respond, {"requestId": request_id, "deviceId": "unknown", "decision": "rejected"})
def _device_pair_remove_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 device.pair.remove params")
return
device_id = _norm_str(params.get("deviceId"))
if not device_id:
_bad(respond, "invalid device.pair.remove params")
return
if _denies_cross_device_management(client, device_id):
_bad(respond, "device pairing removal denied")
return
svc = _get_device_service(context)
if svc is not None and callable(getattr(svc, "remove", None)):
removed = svc.remove(device_id)
if not removed:
_bad(respond, "unknown deviceId")
return
_ok(respond, removed)
return
_ok(respond, {"deviceId": device_id, "removed": True})
def _device_token_rotate_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 device.token.rotate params")
return
device_id = _norm_str(params.get("deviceId"))
role = _norm_str(params.get("role"))
scopes = params.get("scopes")
if not device_id or not role:
_bad(respond, "invalid device.token.rotate params")
return
if _denies_cross_device_management(client, device_id):
_bad(respond, DEVICE_TOKEN_ROTATION_DENIED_MESSAGE)
return
svc = _get_device_service(context)
if svc is not None and callable(getattr(svc, "rotate_token", None)):
rotated = svc.rotate_token({"deviceId": device_id, "role": role, "scopes": scopes})
if not isinstance(rotated, dict) or not rotated.get("ok"):
_bad(respond, DEVICE_TOKEN_ROTATION_DENIED_MESSAGE)
return
entry = rotated.get("entry") if isinstance(rotated.get("entry"), dict) else {}
_ok(
respond,
{
"deviceId": device_id,
"role": str(entry.get("role") or role),
"token": entry.get("token"),
"scopes": entry.get("scopes") if isinstance(entry.get("scopes"), list) else [],
"rotatedAtMs": entry.get("rotatedAtMs") or entry.get("createdAtMs"),
},
)
return
_ok(
respond,
{
"deviceId": device_id,
"role": role,
"token": "token",
"scopes": scopes if isinstance(scopes, list) else [],
"rotatedAtMs": None,
},
)
def _device_token_revoke_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 device.token.revoke params")
return
device_id = _norm_str(params.get("deviceId"))
role = _norm_str(params.get("role"))
if not device_id or not role:
_bad(respond, "invalid device.token.revoke params")
return
if _denies_cross_device_management(client, device_id):
_bad(respond, "device token revocation denied")
return
svc = _get_device_service(context)
if svc is not None and callable(getattr(svc, "revoke_token", None)):
entry = svc.revoke_token({"deviceId": device_id, "role": role})
if not isinstance(entry, dict):
_bad(respond, "unknown deviceId/role")
return
_ok(
respond,
{
"deviceId": device_id,
"role": str(entry.get("role") or role),
"revokedAtMs": entry.get("revokedAtMs"),
},
)
return
_ok(respond, {"deviceId": device_id, "role": role, "revokedAtMs": None})
device_handlers: GatewayRequestHandlers = {
"device.pair.list": _device_pair_list_handler,
"device.pair.approve": _device_pair_approve_handler,
"device.pair.reject": _device_pair_reject_handler,
"device.pair.remove": _device_pair_remove_handler,
"device.token.rotate": _device_token_rotate_handler,
"device.token.revoke": _device_token_revoke_handler,
}

View file

@ -0,0 +1,6 @@
"""Compatibility bridge to the new oclaw dispatcher."""
from oclaw.interfaces.gateway.dispatcher import build_gateway_method_handlers, method_names
__all__ = ["build_gateway_method_handlers", "method_names"]

View file

@ -0,0 +1,86 @@
from __future__ import annotations
from typing import Any
from .shared_types import GatewayRequestHandlers
from .validation import error_shape
def _ok(respond, payload: Any) -> None:
if callable(respond):
respond(True, payload, None, None)
def _unavailable(respond, message: str) -> None:
if callable(respond):
respond(False, None, error_shape("UNAVAILABLE", message), None)
def _doctor_memory_status_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
context = opts.get("context")
hook = context.get("doctor_memory_status") if isinstance(context, dict) else None
if callable(hook):
try:
payload = hook()
_ok(respond, payload if isinstance(payload, dict) else {})
return
except Exception as exc:
_unavailable(respond, str(exc))
return
# Staging fallback shape (compatible with dashboard expectations).
_ok(
respond,
{
"agentId": "main",
"provider": None,
"embedding": {"ok": True},
"dreaming": {
"enabled": False,
"verboseLogging": False,
"storageMode": "inline",
"separateReports": False,
"shortTermCount": 0,
"recallSignalCount": 0,
"dailySignalCount": 0,
"groundedSignalCount": 0,
"totalSignalCount": 0,
"phaseSignalCount": 0,
"lightPhaseHitCount": 0,
"remPhaseHitCount": 0,
"promotedTotal": 0,
"promotedToday": 0,
"shortTermEntries": [],
"signalEntries": [],
"promotedEntries": [],
"phases": {
"light": {"enabled": False, "cron": "", "managedCronPresent": False, "lookbackDays": 0, "limit": 0},
"deep": {
"enabled": False,
"cron": "",
"managedCronPresent": False,
"minScore": 0,
"minRecallCount": 0,
"minUniqueQueries": 0,
"recencyHalfLifeDays": 0,
"limit": 0,
},
"rem": {
"enabled": False,
"cron": "",
"managedCronPresent": False,
"lookbackDays": 0,
"limit": 0,
"minPatternStrength": 0,
},
},
},
},
)
doctor_handlers: GatewayRequestHandlers = {
"doctor.memory.status": _doctor_memory_status_handler,
}

View file

@ -0,0 +1,192 @@
from __future__ import annotations
from typing import Any
from .shared_types import GatewayRequestHandlers
from .validation import error_shape
def _ok(respond, payload: Any) -> None:
if callable(respond):
respond(True, payload, None, None)
def _bad(respond, message: str) -> None:
if callable(respond):
respond(False, None, error_shape("INVALID_REQUEST", message), None)
def _unavailable(respond, message: str) -> None:
if callable(respond):
respond(False, None, error_shape("UNAVAILABLE", message), None)
def _norm_str(v: Any) -> str | None:
if isinstance(v, str):
s = v.strip()
return s or None
return None
def _state(context: Any) -> dict[str, Any]:
if not isinstance(context, dict):
return {"path": ".oclaw/exec-approvals.json", "exists": False, "hash": None, "file": {}}
st = context.get("_exec_approvals_state")
if isinstance(st, dict):
return st
created = {"path": ".oclaw/exec-approvals.json", "exists": False, "hash": None, "file": {}}
context["_exec_approvals_state"] = created
return created
def _payload(st: dict[str, Any]) -> dict[str, Any]:
file_obj = st.get("file")
file_obj = dict(file_obj) if isinstance(file_obj, dict) else {}
socket = file_obj.get("socket")
if isinstance(socket, dict):
path = _norm_str(socket.get("path"))
file_obj["socket"] = {"path": path} if path else None
return {
"path": st.get("path"),
"exists": bool(st.get("exists", False)),
"hash": st.get("hash"),
"file": file_obj,
}
def _require_base_hash(params: dict[str, Any], st: dict[str, Any], respond) -> bool:
if not bool(st.get("exists")):
return True
snap_hash = _norm_str(st.get("hash"))
if not snap_hash:
_bad(respond, "exec approvals base hash unavailable; re-run exec.approvals.get and retry")
return False
base_hash = _norm_str(params.get("baseHash"))
if not base_hash:
_bad(respond, "exec approvals base hash required; re-run exec.approvals.get and retry")
return False
if base_hash != snap_hash:
_bad(respond, "exec approvals changed since last load; re-run exec.approvals.get and retry")
return False
return True
def _resolve_node_id_or_error(raw: Any, respond) -> str | None:
node_id = _norm_str(raw)
if not node_id:
_bad(respond, "nodeId required")
return None
return node_id
def _exec_approvals_get_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
context = opts.get("context")
hook = context.get("read_exec_approvals_snapshot") if isinstance(context, dict) else None
if callable(hook):
try:
snap = hook()
if isinstance(snap, dict):
_ok(respond, _payload(snap))
return
except Exception as exc:
_unavailable(respond, str(exc))
return
st = _state(context)
_ok(respond, _payload(st))
def _exec_approvals_set_handler(opts: dict[str, Any]) -> None:
params = opts.get("params") or {}
respond = opts.get("respond")
context = opts.get("context")
if not isinstance(params, dict):
_bad(respond, "invalid exec.approvals.set params")
return
st = _state(context)
if not _require_base_hash(params, st, respond):
return
incoming = params.get("file")
if not isinstance(incoming, dict):
_bad(respond, "exec approvals file is required")
return
hook = context.get("write_exec_approvals") if isinstance(context, dict) else None
if callable(hook):
try:
next_snap = hook({"file": incoming, "baseHash": params.get("baseHash")})
if isinstance(next_snap, dict):
_ok(respond, _payload(next_snap))
return
except Exception as exc:
_unavailable(respond, str(exc))
return
st["file"] = dict(incoming)
st["exists"] = True
st["hash"] = f"h{abs(hash(str(incoming))) % 1000000}"
_ok(respond, _payload(st))
def _exec_approvals_node_get_handler(opts: dict[str, Any]) -> None:
params = opts.get("params") or {}
respond = opts.get("respond")
context = opts.get("context")
if not isinstance(params, dict):
_bad(respond, "invalid exec.approvals.node.get params")
return
node_id = _resolve_node_id_or_error(params.get("nodeId"), respond)
if not node_id:
return
hook = context.get("node_exec_approvals_get") if isinstance(context, dict) else None
if callable(hook):
try:
payload = hook({"nodeId": node_id})
_ok(respond, payload if payload is not None else {})
return
except Exception as exc:
_unavailable(respond, str(exc))
return
_ok(respond, {"nodeId": node_id, "path": None, "exists": False, "hash": None, "file": {}})
def _exec_approvals_node_set_handler(opts: dict[str, Any]) -> None:
params = opts.get("params") or {}
respond = opts.get("respond")
context = opts.get("context")
if not isinstance(params, dict):
_bad(respond, "invalid exec.approvals.node.set params")
return
node_id = _resolve_node_id_or_error(params.get("nodeId"), respond)
if not node_id:
return
file_obj = params.get("file")
if not isinstance(file_obj, dict):
_bad(respond, "exec approvals file is required")
return
hook = context.get("node_exec_approvals_set") if isinstance(context, dict) else None
if callable(hook):
try:
payload = hook({"nodeId": node_id, "file": file_obj, "baseHash": params.get("baseHash")})
_ok(respond, payload if payload is not None else {})
return
except Exception as exc:
_unavailable(respond, str(exc))
return
_ok(
respond,
{
"nodeId": node_id,
"path": None,
"exists": True,
"hash": f"h{abs(hash(str(file_obj))) % 1000000}",
"file": dict(file_obj),
},
)
exec_approvals_handlers: GatewayRequestHandlers = {
"exec.approvals.get": _exec_approvals_get_handler,
"exec.approvals.set": _exec_approvals_set_handler,
"exec.approvals.node.get": _exec_approvals_node_get_handler,
"exec.approvals.node.set": _exec_approvals_node_set_handler,
}

View file

@ -0,0 +1,60 @@
from __future__ import annotations
from typing import Any
from .shared_types import GatewayRequestHandlers
from .validation import error_shape
HEALTH_REFRESH_INTERVAL_MS = 5_000
ADMIN_SCOPE = "operator.admin"
def _health_handler(opts: dict[str, Any]) -> Any:
respond = opts.get("respond")
context = opts.get("context") or {}
params = opts.get("params") or {}
if not callable(respond):
return None
wants_probe = bool(params.get("probe") is True)
get_health_cache = context.get("get_health_cache")
refresh_health_snapshot = context.get("refresh_health_snapshot")
now = __import__("time").time() * 1000
cached = get_health_cache() if callable(get_health_cache) else None
if (
not wants_probe
and isinstance(cached, dict)
and isinstance(cached.get("ts"), (int, float))
and now - float(cached["ts"]) < HEALTH_REFRESH_INTERVAL_MS
):
respond(True, cached, None, {"cached": True})
return None
try:
snap = refresh_health_snapshot({"probe": wants_probe}) if callable(refresh_health_snapshot) else {}
respond(True, snap, None, None)
except Exception as exc:
respond(False, None, error_shape("UNAVAILABLE", str(exc)), None)
return None
def _status_handler(opts: dict[str, Any]) -> Any:
respond = opts.get("respond")
client = opts.get("client") or {}
if not callable(respond):
return None
scopes = []
connect = client.get("connect")
if isinstance(connect, dict) and isinstance(connect.get("scopes"), list):
scopes = [str(x) for x in connect.get("scopes", [])]
status = {
"includeSensitive": ADMIN_SCOPE in scopes,
"ok": True,
}
respond(True, status, None, None)
return None
health_handlers: GatewayRequestHandlers = {
"health": _health_handler,
"status": _status_handler,
}

View file

@ -0,0 +1,156 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
from typing import Any, Callable
from oclaw.platform.config.runtime_paths import runtime_extensions_root
from .shared_types import GatewayRequestHandlers
from .validation import error_shape
def _ok(respond, payload: Any) -> None:
if callable(respond):
respond(True, payload, None, None)
def _bad(respond, message: str) -> None:
if callable(respond):
respond(False, None, error_shape("INVALID_REQUEST", message), None)
def _unavailable(respond, message: str) -> None:
if callable(respond):
respond(False, None, error_shape("UNAVAILABLE", message), None)
def _fail(respond, code: str, message: str) -> None:
if callable(respond):
respond(False, None, error_shape(code, message), None)
_generate_image_fn: Callable[..., Any] | None = None
def _load_generate_image_fn() -> Callable[..., Any]:
global _generate_image_fn
if _generate_image_fn is not None:
return _generate_image_fn
file_path = (runtime_extensions_root() / "image-generation-core" / "api.py").resolve()
if not file_path.exists():
# Backward compatibility for legacy layout.
file_path = Path("runtime/extensions/image-generation-core/api.py").resolve()
spec = importlib.util.spec_from_file_location("gateway_image_generation_core_api", str(file_path))
if spec is None or spec.loader is None:
raise RuntimeError("failed to load image-generation-core api module")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
fn = getattr(module, "generate_image", None)
if not callable(fn):
raise RuntimeError("image-generation-core.generate_image is not callable")
_generate_image_fn = fn
return fn
def _image_generate_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 image.generate params")
return
prompt = params.get("prompt")
if not isinstance(prompt, str) or not prompt.strip():
_bad(respond, "image.generate requires prompt")
return
provider = params.get("provider")
if provider is not None and (not isinstance(provider, str) or not provider.strip()):
_bad(respond, "image.generate provider must be a non-empty string when provided")
return
size = params.get("size")
if size is not None and (not isinstance(size, str) or not size.strip()):
_bad(respond, "image.generate size must be a non-empty string when provided")
return
quality = params.get("quality")
if quality is not None and (not isinstance(quality, str) or not quality.strip()):
_bad(respond, "image.generate quality must be a non-empty string when provided")
return
# Prefer explicit hook so app server can own the runtime.
hook = context.get("image_generate") if isinstance(context, dict) else None
if callable(hook):
try:
out = hook(params)
_ok(respond, out if isinstance(out, dict) else {"ok": True, "result": out})
except Exception as exc:
_unavailable(respond, str(exc))
return
# Fallback: use image-generation-core with providers passed through context/runtime snapshot.
providers: list[dict[str, Any]] = []
if isinstance(context, dict):
raw = context.get("image_generation_providers")
if isinstance(raw, list):
providers = [p for p in raw if isinstance(p, dict)]
elif callable(context.get("get_runtime_snapshot")):
try:
snap = context["get_runtime_snapshot"]()
if isinstance(snap, dict) and isinstance(snap.get("image_generation_providers"), list):
providers = [p for p in snap.get("image_generation_providers") if isinstance(p, dict)]
except Exception:
providers = []
# Keep only image-capable providers when capability is declared.
providers = [
p
for p in providers
if not isinstance(p.get("capabilities"), dict) or bool((p.get("capabilities") or {}).get("image_generation", True))
]
if provider and not any(str(p.get("id")) == provider.strip() for p in providers):
_fail(respond, "NOT_FOUND", f'image provider "{provider.strip()}" is not registered')
return
cfg: dict[str, Any] = {}
if isinstance(context, dict) and isinstance(context.get("config"), dict):
cfg = dict(context.get("config") or {})
image_cfg = cfg.get("image") if isinstance(cfg.get("image"), dict) else {}
default_provider = str((image_cfg or {}).get("defaultProvider") or "").strip()
priority = [str(x).strip() for x in ((image_cfg or {}).get("providerPriority") or []) if str(x).strip()]
ordered = list(providers)
if not provider:
if default_provider and any(str(p.get("id") or "") == default_provider for p in ordered):
ordered.sort(key=lambda p: 0 if str(p.get("id") or "") == default_provider else 1)
elif priority:
rank = {pid: idx for idx, pid in enumerate(priority)}
ordered.sort(key=lambda p: rank.get(str(p.get("id") or ""), 10_000))
runtime = {"image_generation_providers": ordered}
try:
generate_image = _load_generate_image_fn()
out = generate_image(
prompt=prompt.strip(),
provider_id=(provider.strip() if isinstance(provider, str) else None),
runtime=runtime,
size=(size.strip() if isinstance(size, str) else None),
quality=(quality.strip() if isinstance(quality, str) else None),
)
if isinstance(out, dict) and out.get("ok") is True:
_ok(respond, out)
return
if isinstance(out, dict):
err = str(out.get("error") or "image generation failed")
if err == "no_image_generation_provider_registered":
_fail(respond, "UNAVAILABLE", err)
return
if err == "provider_not_found":
_fail(respond, "NOT_FOUND", err)
return
_fail(respond, "UNAVAILABLE", err)
return
_unavailable(respond, "image generation failed")
except Exception as exc:
_unavailable(respond, str(exc))
image_handlers: GatewayRequestHandlers = {
"image.generate": _image_generate_handler,
}

View file

@ -0,0 +1,63 @@
from __future__ import annotations
from typing import Any
from .shared_types import GatewayRequestHandlers
from .validation import error_shape
def _read_configured_log_tail(*, cursor: int | None, limit: int | None, max_bytes: int | None) -> dict[str, Any]:
# Placeholder runtime adapter for Python rewrite stage.
return {
"cursor": cursor or 0,
"lines": [],
"nextCursor": cursor or 0,
"limit": limit or 0,
"maxBytes": max_bytes or 0,
}
def _validate_logs_tail_params(params: Any) -> bool:
if not isinstance(params, dict):
return False
for key in ("cursor", "limit", "maxBytes"):
if key in params and not isinstance(params[key], int):
return False
return True
def _logs_tail_handler(opts: dict[str, Any]) -> Any:
respond = opts.get("respond")
params = opts.get("params")
if not callable(respond):
return None
if not _validate_logs_tail_params(params):
respond(
False,
None,
error_shape("INVALID_REQUEST", "invalid logs.tail params"),
None,
)
return None
p = params if isinstance(params, dict) else {}
try:
result = _read_configured_log_tail(
cursor=p.get("cursor"),
limit=p.get("limit"),
max_bytes=p.get("maxBytes"),
)
respond(True, result, None, None)
except Exception as exc:
respond(
False,
None,
error_shape("UNAVAILABLE", f"log read failed: {exc}"),
None,
)
return None
logs_handlers: GatewayRequestHandlers = {
"logs.tail": _logs_tail_handler,
}

View file

@ -0,0 +1,61 @@
from __future__ import annotations
from typing import Any
from .shared_types import GatewayRequestHandlers
from .validation import error_shape
def _ok(respond, payload: Any) -> None:
if callable(respond):
respond(True, payload, None, None)
def _bad(respond, message: str) -> None:
if callable(respond):
respond(False, None, error_shape("INVALID_REQUEST", message), None)
def _unavailable(respond, message: str) -> None:
if callable(respond):
respond(False, None, error_shape("UNAVAILABLE", message), None)
def _models_list_handler(opts: dict[str, Any]) -> None:
params = opts.get("params")
respond = opts.get("respond")
context = opts.get("context")
if params is not None and not isinstance(params, dict):
_bad(respond, "invalid models.list params")
return
load_catalog = context.get("loadGatewayModelCatalog") if isinstance(context, dict) else None
if not callable(load_catalog):
# Staging fallback: keep contract shape.
_ok(respond, {"models": []})
return
try:
catalog = load_catalog()
if not isinstance(catalog, list):
catalog = []
except Exception as exc:
_unavailable(respond, str(exc))
return
# Optional policy hook to emulate TS buildAllowedModelSet behavior.
allowed_hook = context.get("filterAllowedModels") if isinstance(context, dict) else None
if callable(allowed_hook):
try:
allowed = allowed_hook(catalog)
if isinstance(allowed, list) and len(allowed) > 0:
_ok(respond, {"models": allowed})
return
except Exception:
# Non-fatal: fallback to full catalog
pass
_ok(respond, {"models": catalog})
models_handlers: GatewayRequestHandlers = {
"models.list": _models_list_handler,
}

View file

@ -0,0 +1,128 @@
from __future__ import annotations
import time
from typing import Any
from .shared_types import GatewayRequestHandlers
from .validation import error_shape
MODEL_AUTH_STATUS_NEVER_LOADED = 0
CACHE_TTL_MS = 60_000
_cached: dict[str, Any] | None = None
def invalidate_model_auth_status_cache() -> None:
global _cached
_cached = None
def _now_ms() -> int:
return int(time.time() * 1000)
def _ok(respond, payload: Any, meta: dict[str, Any] | None = None) -> None:
if callable(respond):
respond(True, payload, None, meta or None)
def _unavailable(respond, message: str) -> None:
if callable(respond):
respond(False, None, error_shape("UNAVAILABLE", message), None)
def _normalize_profile_type(v: Any) -> str:
if isinstance(v, str) and v in {"oauth", "token", "api_key"}:
return v
return "api_key"
def _build_expiry(remaining_ms: Any, expires_at: Any) -> dict[str, Any] | None:
if not isinstance(expires_at, (int, float)) or not isinstance(remaining_ms, (int, float)):
return None
rm = int(remaining_ms)
if rm >= 86_400_000:
label = f"{rm // 86_400_000}d"
elif rm >= 3_600_000:
label = f"{rm // 3_600_000}h"
else:
label = f"{max(0, rm // 60_000)}m"
return {"at": int(expires_at), "remainingMs": rm, "label": label}
def _map_provider(item: dict[str, Any]) -> dict[str, Any]:
provider = str(item.get("provider") or "")
profiles_raw = item.get("profiles")
profiles: list[dict[str, Any]] = []
if isinstance(profiles_raw, list):
for p in profiles_raw:
if not isinstance(p, dict):
continue
expiry = _build_expiry(p.get("remainingMs"), p.get("expiresAt"))
prof = {
"profileId": str(p.get("profileId") or ""),
"type": _normalize_profile_type(p.get("type")),
"status": str(p.get("status") or "missing"),
}
if expiry is not None:
prof["expiry"] = expiry
profiles.append(prof)
expiry = _build_expiry(item.get("remainingMs"), item.get("expiresAt"))
out = {
"provider": provider,
"displayName": str(item.get("displayName") or provider),
"status": str(item.get("status") or "missing"),
"profiles": profiles,
}
if expiry is not None:
out["expiry"] = expiry
usage = item.get("usage")
if isinstance(usage, dict):
windows = usage.get("windows")
plan = usage.get("plan")
out["usage"] = {
"windows": windows if isinstance(windows, list) else [],
"plan": str(plan) if isinstance(plan, str) else None,
}
return out
def _models_auth_status_handler(opts: dict[str, Any]) -> None:
global _cached
params = opts.get("params")
respond = opts.get("respond")
context = opts.get("context")
if params is not None and not isinstance(params, dict):
_unavailable(respond, "invalid models.authStatus params")
return
now = _now_ms()
bypass_cache = bool((params or {}).get("refresh")) if isinstance(params, dict) else False
if not bypass_cache and _cached is not None:
cached_ts = int(_cached.get("ts") or 0)
if now - cached_ts < CACHE_TTL_MS:
_ok(respond, _cached["result"], {"cached": True})
return
load_auth = context.get("load_models_auth_status") if isinstance(context, dict) else None
try:
providers: list[dict[str, Any]] = []
if callable(load_auth):
raw = load_auth()
if isinstance(raw, dict):
providers_raw = raw.get("providers")
if isinstance(providers_raw, list):
providers = [_map_provider(x) for x in providers_raw if isinstance(x, dict)]
elif isinstance(raw, list):
providers = [_map_provider(x) for x in raw if isinstance(x, dict)]
result = {"ts": now, "providers": providers}
_cached = {"ts": now, "result": result}
_ok(respond, result)
except Exception as exc:
_unavailable(respond, str(exc))
models_auth_status_handlers: GatewayRequestHandlers = {
"models.authStatus": _models_auth_status_handler,
}

View file

@ -0,0 +1,340 @@
from __future__ import annotations
import time
from typing import Any
from .shared_types import GatewayRequestHandlers
from .validation import error_shape
NODE_WAKE_RECONNECT_WAIT_MS = 3_000
NODE_WAKE_RECONNECT_RETRY_WAIT_MS = 12_000
def _ok(respond, payload: Any) -> None:
if callable(respond):
respond(True, payload, None, None)
def _bad(respond, message: str, *, code: str = "INVALID_REQUEST", details: dict[str, Any] | None = None) -> None:
if callable(respond):
respond(False, None, error_shape(code, message, {"details": details} if details else None), None)
def _unavailable(respond, message: str) -> None:
if callable(respond):
respond(False, None, error_shape("UNAVAILABLE", message), None)
def _norm_str(v: Any) -> str | None:
if isinstance(v, str):
s = v.strip()
return s or None
return None
def _state(context: Any) -> dict[str, Any]:
if not isinstance(context, dict):
return {"pair_requests": {}, "paired": {}, "pending_actions": {}}
st = context.get("_nodes_state")
if isinstance(st, dict):
return st
created = {"pair_requests": {}, "paired": {}, "pending_actions": {}}
context["_nodes_state"] = created
return created
def _resolve_client_node_id(client: Any) -> str | None:
if not isinstance(client, dict):
return None
connect = client.get("connect")
if not isinstance(connect, dict):
return None
device = connect.get("device")
client_info = connect.get("client")
if isinstance(device, dict):
got = _norm_str(device.get("id"))
if got:
return got
if isinstance(client_info, dict):
return _norm_str(client_info.get("id"))
return None
def _node_pair_request_handler(opts: dict[str, Any]) -> None:
params = opts.get("params") or {}
respond = opts.get("respond")
context = opts.get("context")
if not isinstance(params, dict):
_bad(respond, "invalid node.pair.request params")
return
node_id = _norm_str(params.get("nodeId"))
if not node_id:
_bad(respond, "invalid node.pair.request params")
return
st = _state(context)
req_id = f"npr_{int(time.time()*1000)}"
request = {
"requestId": req_id,
"nodeId": node_id,
"displayName": _norm_str(params.get("displayName")) or node_id,
"platform": _norm_str(params.get("platform")),
"ts": int(time.time() * 1000),
}
st["pair_requests"][req_id] = request
_ok(respond, {"status": "pending", "created": True, "request": request})
def _node_pair_list_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
context = opts.get("context")
st = _state(context)
_ok(respond, {"pending": list(st["pair_requests"].values()), "paired": list(st["paired"].values())})
def _node_pair_approve_handler(opts: dict[str, Any]) -> None:
params = opts.get("params") or {}
respond = opts.get("respond")
context = opts.get("context")
if not isinstance(params, dict):
_bad(respond, "invalid node.pair.approve params")
return
req_id = _norm_str(params.get("requestId"))
if not req_id:
_bad(respond, "invalid node.pair.approve params")
return
st = _state(context)
req = st["pair_requests"].pop(req_id, None)
if not isinstance(req, dict):
_bad(respond, "unknown requestId")
return
node = {"nodeId": req["nodeId"], "displayName": req.get("displayName") or req["nodeId"], "platform": req.get("platform")}
st["paired"][node["nodeId"]] = node
_ok(respond, {"requestId": req_id, "node": node})
def _node_pair_reject_handler(opts: dict[str, Any]) -> None:
params = opts.get("params") or {}
respond = opts.get("respond")
context = opts.get("context")
if not isinstance(params, dict):
_bad(respond, "invalid node.pair.reject params")
return
req_id = _norm_str(params.get("requestId"))
if not req_id:
_bad(respond, "invalid node.pair.reject params")
return
st = _state(context)
req = st["pair_requests"].pop(req_id, None)
if not isinstance(req, dict):
_bad(respond, "unknown requestId")
return
_ok(respond, {"requestId": req_id, "nodeId": req.get("nodeId"), "decision": "rejected"})
def _node_pair_verify_handler(opts: dict[str, Any]) -> None:
params = opts.get("params") or {}
respond = opts.get("respond")
context = opts.get("context")
if not isinstance(params, dict):
_bad(respond, "invalid node.pair.verify params")
return
node_id = _norm_str(params.get("nodeId"))
token = _norm_str(params.get("token"))
if not node_id or not token:
_bad(respond, "invalid node.pair.verify params")
return
st = _state(context)
ok = node_id in st["paired"]
_ok(respond, {"ok": ok, "nodeId": node_id})
def _node_rename_handler(opts: dict[str, Any]) -> None:
params = opts.get("params") or {}
respond = opts.get("respond")
context = opts.get("context")
if not isinstance(params, dict):
_bad(respond, "invalid node.rename params")
return
node_id = _norm_str(params.get("nodeId"))
display_name = _norm_str(params.get("displayName"))
if not node_id or not display_name:
_bad(respond, "displayName required")
return
st = _state(context)
node = st["paired"].get(node_id)
if not isinstance(node, dict):
_bad(respond, "unknown nodeId")
return
node["displayName"] = display_name
_ok(respond, {"nodeId": node_id, "displayName": display_name})
def _node_list_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
context = opts.get("context")
st = _state(context)
nodes = list(st["paired"].values())
_ok(respond, {"ts": int(time.time() * 1000), "nodes": nodes})
def _node_describe_handler(opts: dict[str, Any]) -> None:
params = opts.get("params") or {}
respond = opts.get("respond")
context = opts.get("context")
if not isinstance(params, dict):
_bad(respond, "invalid node.describe params")
return
node_id = _norm_str(params.get("nodeId"))
if not node_id:
_bad(respond, "nodeId required")
return
st = _state(context)
node = st["paired"].get(node_id)
if not isinstance(node, dict):
_bad(respond, "unknown nodeId")
return
_ok(respond, {"ts": int(time.time() * 1000), **node})
def _node_canvas_capability_refresh_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
client = opts.get("client")
if not isinstance(client, dict):
_unavailable(respond, "canvas host unavailable for this node session")
return
base = _norm_str(client.get("canvas_host_url"))
if not base:
_unavailable(respond, "canvas host unavailable for this node session")
return
cap = f"cap_{int(time.time()*1000)}"
exp = int(time.time() * 1000) + 5 * 60_000
client["canvas_capability"] = cap
client["canvas_capability_expires_at_ms"] = exp
_ok(respond, {"canvasCapability": cap, "canvasCapabilityExpiresAtMs": exp, "canvasHostUrl": f"{base.rstrip('/')}/scoped/{cap}"})
def _node_pending_pull_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
client = opts.get("client")
context = opts.get("context")
node_id = _resolve_client_node_id(client)
if not node_id:
_bad(respond, "nodeId required")
return
st = _state(context)
actions = st["pending_actions"].get(node_id, [])
_ok(
respond,
{
"nodeId": node_id,
"actions": [
{"id": x.get("id"), "command": x.get("command"), "paramsJSON": x.get("paramsJSON"), "enqueuedAtMs": x.get("enqueuedAtMs")}
for x in actions
],
},
)
def _node_pending_ack_handler(opts: dict[str, Any]) -> None:
params = opts.get("params") or {}
respond = opts.get("respond")
client = opts.get("client")
context = opts.get("context")
if not isinstance(params, dict):
_bad(respond, "invalid node.pending.ack params")
return
node_id = _resolve_client_node_id(client)
if not node_id:
_bad(respond, "nodeId required")
return
ids_raw = params.get("ids")
ids = []
if isinstance(ids_raw, list):
ids = [x for x in {_norm_str(i) for i in ids_raw} if x]
st = _state(context)
current = st["pending_actions"].get(node_id, [])
remaining = [x for x in current if x.get("id") not in set(ids)]
st["pending_actions"][node_id] = remaining
_ok(respond, {"nodeId": node_id, "ackedIds": ids, "remainingCount": len(remaining)})
def _node_invoke_handler(opts: dict[str, Any]) -> None:
params = opts.get("params") or {}
respond = opts.get("respond")
context = opts.get("context")
if not isinstance(params, dict):
_bad(respond, "invalid node.invoke params")
return
node_id = _norm_str(params.get("nodeId"))
command = _norm_str(params.get("command"))
if not node_id or not command:
_bad(respond, "nodeId and command required")
return
if command in {"system.execApprovals.get", "system.execApprovals.set"}:
_bad(respond, "node.invoke does not allow system.execApprovals.*; use exec.approvals.node.*", details={"command": command})
return
# optional hook
hook = context.get("node_invoke") if isinstance(context, dict) else None
if callable(hook):
try:
res = hook({"nodeId": node_id, "command": command, "params": params.get("params"), "timeoutMs": params.get("timeoutMs"), "idempotencyKey": params.get("idempotencyKey")})
if isinstance(res, dict):
if not bool(res.get("ok", True)):
_unavailable(respond, str((res.get("error") or {}).get("message") or "node not connected"))
return
payload = res.get("payload")
_ok(respond, {"ok": True, "nodeId": node_id, "command": command, "payload": payload, "payloadJSON": res.get("payloadJSON")})
return
except Exception as exc:
_unavailable(respond, str(exc))
return
_ok(respond, {"ok": True, "nodeId": node_id, "command": command, "payload": {"ok": True}, "payloadJSON": None})
def _node_invoke_result_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
# staging ack
_ok(respond, {"ok": True})
def _node_event_handler(opts: dict[str, Any]) -> None:
params = opts.get("params") or {}
respond = opts.get("respond")
context = opts.get("context")
client = opts.get("client")
if not isinstance(params, dict):
_bad(respond, "invalid node.event params")
return
event = _norm_str(params.get("event"))
if not event:
_bad(respond, "invalid node.event params")
return
node_id = _resolve_client_node_id(client) or "node"
# optional hook
hook = context.get("handle_node_event") if isinstance(context, dict) else None
if callable(hook):
try:
hook({"nodeId": node_id, "event": event, "payload": params.get("payload"), "payloadJSON": params.get("payloadJSON")})
except Exception as exc:
_unavailable(respond, str(exc))
return
_ok(respond, {"ok": True})
node_handlers: GatewayRequestHandlers = {
"node.pair.request": _node_pair_request_handler,
"node.pair.list": _node_pair_list_handler,
"node.pair.approve": _node_pair_approve_handler,
"node.pair.reject": _node_pair_reject_handler,
"node.pair.verify": _node_pair_verify_handler,
"node.rename": _node_rename_handler,
"node.list": _node_list_handler,
"node.describe": _node_describe_handler,
"node.canvas.capability.refresh": _node_canvas_capability_refresh_handler,
"node.pending.pull": _node_pending_pull_handler,
"node.pending.ack": _node_pending_ack_handler,
"node.invoke": _node_invoke_handler,
"node.invoke.result": _node_invoke_result_handler,
"node.event": _node_event_handler,
}

View file

@ -0,0 +1,158 @@
from __future__ import annotations
import time
from typing import Any
from .shared_types import GatewayRequestHandlers
from .validation import error_shape
def _ok(respond, payload: Any) -> None:
if callable(respond):
respond(True, payload, None, None)
def _bad(respond, message: str) -> None:
if callable(respond):
respond(False, None, error_shape("INVALID_REQUEST", message), None)
def _unavailable(respond, message: str) -> None:
if callable(respond):
respond(False, None, error_shape("UNAVAILABLE", message), None)
def _norm_str(v: Any) -> str | None:
if isinstance(v, str):
s = v.strip()
return s or None
return None
def _resolve_client_node_id(client: Any) -> str | None:
if not isinstance(client, dict):
return None
connect = client.get("connect")
if not isinstance(connect, dict):
return None
device = connect.get("device")
client_info = connect.get("client")
node_id = None
if isinstance(device, dict):
node_id = _norm_str(device.get("id"))
if node_id:
return node_id
if isinstance(client_info, dict):
return _norm_str(client_info.get("id"))
return None
def _state(context: Any) -> dict[str, list[dict[str, Any]]]:
if not isinstance(context, dict):
return {}
st = context.get("_node_pending_work")
if isinstance(st, dict):
return st
created: dict[str, list[dict[str, Any]]] = {}
context["_node_pending_work"] = created
return created
def _enqueue_work(context: Any, item: dict[str, Any]) -> dict[str, Any]:
hook = context.get("enqueue_node_pending_work") if isinstance(context, dict) else None
if callable(hook):
out = hook(item)
if isinstance(out, dict):
return out
st = _state(context)
bucket = st.setdefault(item["nodeId"], [])
deduped = any(x.get("type") == item["type"] for x in bucket)
entry = {
"id": f"npw_{int(time.time()*1000)}_{len(bucket)+1}",
"nodeId": item["nodeId"],
"type": item["type"],
"priority": item.get("priority") or "normal",
"createdAtMs": int(time.time() * 1000),
"status": "queued",
}
if not deduped:
bucket.append(entry)
return {"deduped": deduped, "item": entry}
def _drain_work(context: Any, node_id: str, max_items: int | None) -> dict[str, Any]:
hook = context.get("drain_node_pending_work") if isinstance(context, dict) else None
if callable(hook):
out = hook({"nodeId": node_id, "maxItems": max_items, "includeDefaultStatus": True})
if isinstance(out, dict):
return out
st = _state(context)
bucket = st.get(node_id, [])
n = len(bucket) if max_items is None else max(0, int(max_items))
drained = bucket[:n]
st[node_id] = bucket[n:]
return {"items": drained, "count": len(drained), "remaining": len(st[node_id]), "defaultStatusIncluded": True}
def _node_pending_drain_handler(opts: dict[str, Any]) -> None:
params = opts.get("params") or {}
respond = opts.get("respond")
client = opts.get("client")
context = opts.get("context")
if params is not None and not isinstance(params, dict):
_bad(respond, "invalid node.pending.drain params")
return
node_id = _resolve_client_node_id(client)
if not node_id:
_bad(respond, "node.pending.drain requires a connected device identity")
return
max_items = params.get("maxItems") if isinstance(params, dict) else None
if max_items is not None and not isinstance(max_items, (int, float)):
_bad(respond, "invalid node.pending.drain params")
return
drained = _drain_work(context, node_id, int(max_items) if isinstance(max_items, (int, float)) else None)
_ok(respond, {"nodeId": node_id, **drained})
def _node_pending_enqueue_handler(opts: dict[str, Any]) -> None:
params = opts.get("params") or {}
respond = opts.get("respond")
context = opts.get("context")
if not isinstance(params, dict):
_bad(respond, "invalid node.pending.enqueue params")
return
node_id = _norm_str(params.get("nodeId"))
work_type = _norm_str(params.get("type"))
if not node_id or not work_type:
_bad(respond, "invalid node.pending.enqueue params")
return
item = {
"nodeId": node_id,
"type": work_type,
"priority": _norm_str(params.get("priority")) or "normal",
"expiresInMs": params.get("expiresInMs"),
}
try:
queued = _enqueue_work(context, item)
except Exception as exc:
_unavailable(respond, str(exc))
return
wake_triggered = False
if params.get("wake", True) is not False:
wake_hook = context.get("wake_node_pending") if isinstance(context, dict) else None
if callable(wake_hook):
try:
wake_triggered = bool(wake_hook({"nodeId": node_id, "reason": "node.pending", "requestId": queued.get("item", {}).get("id")}))
except Exception:
wake_triggered = False
else:
wake_triggered = True
_ok(respond, {"queued": queued, "wakeTriggered": wake_triggered})
node_pending_handlers: GatewayRequestHandlers = {
"node.pending.drain": _node_pending_drain_handler,
"node.pending.enqueue": _node_pending_enqueue_handler,
}

View file

@ -0,0 +1,86 @@
from __future__ import annotations
from typing import Any
from .shared_types import GatewayRequestHandlers
from .validation import error_shape
def _ok(respond, payload: Any) -> None:
if callable(respond):
respond(True, payload, None, None)
def _bad(respond, message: str) -> None:
if callable(respond):
respond(False, None, error_shape("INVALID_REQUEST", message), None)
def _unavailable(respond, message: str) -> None:
if callable(respond):
respond(False, None, error_shape("UNAVAILABLE", message), None)
def _norm_str(v: Any) -> str | None:
if isinstance(v, str):
s = v.strip()
return s or None
return None
def _push_test_handler(opts: dict[str, Any]) -> None:
params = opts.get("params")
respond = opts.get("respond")
context = opts.get("context")
if not isinstance(params, dict):
_bad(respond, "invalid push.test params")
return
node_id = _norm_str(params.get("nodeId")) or ""
if not node_id:
_bad(respond, "nodeId required")
return
title = _norm_str(params.get("title")) or "Oclaw"
body = _norm_str(params.get("body")) or f"Push test for node {node_id}"
environment = _norm_str(params.get("environment"))
# Expected context hook for runtime implementation.
sender = context.get("send_push_test") if isinstance(context, dict) else None
if not callable(sender):
_ok(
respond,
{
"ok": True,
"nodeId": node_id,
"title": title,
"body": body,
"environment": environment,
"transport": "staging",
},
)
return
try:
result = sender(
{
"nodeId": node_id,
"title": title,
"body": body,
"environment": environment,
}
)
except Exception as exc:
_unavailable(respond, str(exc))
return
if not result:
_bad(respond, f"node {node_id} has no APNs registration (connect iOS node first)")
return
_ok(respond, result if isinstance(result, dict) else {"ok": True})
push_handlers: GatewayRequestHandlers = {
"push.test": _push_test_handler,
}

View file

@ -0,0 +1,15 @@
from __future__ import annotations
from typing import Any
def as_record(value: Any) -> dict[str, Any] | None:
return value if isinstance(value, dict) else None
def normalize_trimmed_string(value: Any) -> str | None:
if not isinstance(value, str):
return None
trimmed = value.strip()
return trimmed or None

View file

@ -0,0 +1,53 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
def _normalize_optional_string(value: Any) -> str | None:
if not isinstance(value, str):
return None
s = value.strip()
return s or None
@dataclass(frozen=True)
class RestartDeliveryContext:
channel: str | None = None
to: str | None = None
account_id: str | None = None
def _parse_restart_delivery_context(params: Any) -> tuple[RestartDeliveryContext | None, str | None]:
raw = params.get("deliveryContext") if isinstance(params, dict) else None
if not isinstance(raw, dict):
return (None, None)
channel = _normalize_optional_string(raw.get("channel"))
to = _normalize_optional_string(raw.get("to"))
account_id = _normalize_optional_string(raw.get("accountId"))
ctx = RestartDeliveryContext(channel=channel, to=to, account_id=account_id)
normalized_ctx = ctx if (channel or to or account_id) else None
thread_id_raw = raw.get("threadId")
if isinstance(thread_id_raw, (int, float)) and thread_id_raw == thread_id_raw:
thread_id = str(int(thread_id_raw))
else:
thread_id = _normalize_optional_string(thread_id_raw)
return (normalized_ctx, thread_id)
def parse_restart_request_params(params: Any) -> dict[str, Any]:
session_key = _normalize_optional_string(params.get("sessionKey") if isinstance(params, dict) else None)
delivery_context, thread_id = _parse_restart_delivery_context(params)
note = _normalize_optional_string(params.get("note") if isinstance(params, dict) else None)
restart_delay_raw = params.get("restartDelayMs") if isinstance(params, dict) else None
restart_delay_ms = None
if isinstance(restart_delay_raw, (int, float)) and restart_delay_raw == restart_delay_raw:
restart_delay_ms = max(0, int(restart_delay_raw))
return {
"sessionKey": session_key,
"deliveryContext": delivery_context.__dict__ if delivery_context else None,
"threadId": thread_id,
"note": note,
"restartDelayMs": restart_delay_ms,
}

View file

@ -0,0 +1,266 @@
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,
}

View file

@ -0,0 +1,315 @@
from __future__ import annotations
from typing import Any
from oclaw.runtime.agents.agent_scope import resolve_default_agent_id
from .shared_types import GatewayRequestHandlers
from .telegram_send_normalize import normalize_transport_target_for_channel
from .validation import error_shape
def _ok(respond, payload: dict[str, Any] | None = None) -> None:
if callable(respond):
respond(True, payload or {"ok": True}, None, None)
def _bad(respond, message: str) -> None:
if callable(respond):
respond(False, None, error_shape("INVALID_REQUEST", message), None)
def _normalize_session_key(value: Any) -> str | None:
if isinstance(value, str) and value.strip():
return value.strip()
return None
def _extract_session_key(params: Any) -> str | None:
if not isinstance(params, dict):
return None
return _normalize_session_key(params.get("sessionKey")) or _normalize_session_key(params.get("key"))
def _require_session_key(params: Any, respond) -> str | None:
if not isinstance(params, dict):
_bad(respond, "params must be object")
return None
key = _extract_session_key(params)
if not key:
_bad(respond, "sessionKey (or key) is required")
return None
return key
def _context_subscribers(context: Any, name: str) -> set[str]:
if not isinstance(context, dict):
return set()
value = context.get(name)
if isinstance(value, set):
return value
created: set[str] = set()
context[name] = created
return created
def _sessions_list_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
context = opts.get("context")
sessions = []
if isinstance(context, dict):
list_fn = context.get("list_sessions")
if callable(list_fn):
try:
rows = list_fn()
if isinstance(rows, list):
sessions = [x for x in rows if isinstance(x, dict)]
except Exception:
sessions = []
_ok(respond, {"sessions": sessions, "total": len(sessions)})
def _sessions_subscribe_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
client = opts.get("client") or {}
context = opts.get("context")
conn_id = client.get("conn_id") if isinstance(client, dict) else None
if isinstance(conn_id, str) and conn_id.strip():
_context_subscribers(context, "session_event_subscribers").add(conn_id.strip())
_ok(respond, {"subscribed": True, "connId": conn_id})
def _sessions_unsubscribe_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
client = opts.get("client") or {}
context = opts.get("context")
conn_id = client.get("conn_id") if isinstance(client, dict) else None
if isinstance(conn_id, str) and conn_id.strip():
_context_subscribers(context, "session_event_subscribers").discard(conn_id.strip())
_ok(respond, {"subscribed": False, "connId": conn_id})
def _sessions_messages_subscribe_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
params = opts.get("params")
context = opts.get("context")
client = opts.get("client") or {}
key = _require_session_key(params, respond)
if not key:
return
conn_id = client.get("conn_id") if isinstance(client, dict) else None
if isinstance(conn_id, str) and conn_id.strip() and isinstance(context, dict):
message_subscribers = context.get("session_message_subscribers")
if not isinstance(message_subscribers, dict):
message_subscribers = {}
context["session_message_subscribers"] = message_subscribers
bucket = message_subscribers.get(key)
if not isinstance(bucket, set):
bucket = set()
message_subscribers[key] = bucket
bucket.add(conn_id.strip())
_ok(respond, {"sessionKey": key, "messagesSubscribed": True, "connId": conn_id})
def _sessions_messages_unsubscribe_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
params = opts.get("params")
context = opts.get("context")
client = opts.get("client") or {}
key = _require_session_key(params, respond)
if not key:
return
conn_id = client.get("conn_id") if isinstance(client, dict) else None
if isinstance(conn_id, str) and conn_id.strip() and isinstance(context, dict):
message_subscribers = context.get("session_message_subscribers")
if isinstance(message_subscribers, dict):
bucket = message_subscribers.get(key)
if isinstance(bucket, set):
bucket.discard(conn_id.strip())
_ok(respond, {"sessionKey": key, "messagesSubscribed": False, "connId": conn_id})
def _sessions_preview_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
_ok(respond, {"preview": []})
def _sessions_resolve_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
params = opts.get("params")
key = _require_session_key(params, respond)
if not key:
return
_ok(respond, {"sessionKey": key, "resolved": True})
def _sessions_compaction_list_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
_ok(respond, {"checkpoints": []})
def _sessions_compaction_get_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
_ok(respond, {"checkpoint": None})
def _sessions_create_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
params = opts.get("params")
context = opts.get("context")
key = _extract_session_key(params) or "main"
agent_id = ""
if isinstance(params, dict):
agent_id = str(params.get("agentId") or "").strip()
cfg = params.get("config") if isinstance(params.get("config"), dict) else {}
if not agent_id and isinstance(cfg, dict) and cfg:
agent_id = resolve_default_agent_id(cfg)
if agent_id and ":" not in key:
key = f"{agent_id}:{key}"
session = {"sessionKey": key}
if isinstance(context, dict):
create_fn = context.get("create_session")
if callable(create_fn):
try:
created = create_fn(key, params if isinstance(params, dict) else {})
if isinstance(created, dict):
session = created
key = _normalize_session_key(created.get("sessionKey")) or key
except Exception:
pass
_ok(respond, {"sessionKey": key, "created": True, "session": session})
def _sessions_compaction_branch_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
_ok(respond, {"branched": True})
def _sessions_compaction_restore_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
_ok(respond, {"restored": True})
def _sessions_send_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
params = opts.get("params")
context = opts.get("context")
key = _require_session_key(params, respond)
if not key:
return
message = params.get("message") if isinstance(params, dict) else None
if not isinstance(message, str) or not message.strip():
_bad(respond, "message is required")
return
payload: dict[str, Any] = {"sent": True, "sessionKey": key, "message": message.strip()}
if isinstance(params, dict):
channel = params.get("channel")
to = params.get("to")
if isinstance(channel, str) and channel.strip().lower() == "telegram" and isinstance(to, str) and to.strip():
normalized_to, extra = normalize_transport_target_for_channel(
channel="telegram",
to=to.strip(),
params=params,
)
payload.update({"channel": "telegram", "to": normalized_to, **extra})
if isinstance(context, dict):
send_fn = context.get("enqueue_session_send")
if callable(send_fn):
try:
out = send_fn(key, message.strip(), dict(params or {}))
if isinstance(out, dict):
payload.update(out)
except Exception as exc:
_bad(respond, f"sessions.send failed: {exc}")
return
_ok(respond, payload)
def _sessions_steer_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
_ok(respond, {"steered": True})
def _sessions_abort_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
params = opts.get("params")
key = _require_session_key(params, respond)
if not key:
return
_ok(respond, {"aborted": True, "sessionKey": key})
def _sessions_patch_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
params = opts.get("params")
key = _require_session_key(params, respond)
if not key:
return
_ok(respond, {"patched": True, "sessionKey": key})
def _sessions_reset_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
params = opts.get("params")
key = _require_session_key(params, respond)
if not key:
return
_ok(respond, {"reset": True, "sessionKey": key})
def _sessions_delete_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
params = opts.get("params")
key = _require_session_key(params, respond)
if not key:
return
_ok(respond, {"deleted": True, "sessionKey": key})
def _sessions_get_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
params = opts.get("params")
context = opts.get("context")
key = _require_session_key(params, respond)
if not key:
return
session: dict[str, Any] = {}
if isinstance(context, dict):
get_fn = context.get("get_session")
if callable(get_fn):
try:
loaded = get_fn(key)
if isinstance(loaded, dict):
session = loaded
except Exception:
session = {}
_ok(respond, {"sessionKey": key, "session": session})
def _sessions_compact_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
_ok(respond, {"compacted": True})
sessions_handlers: GatewayRequestHandlers = {
"sessions.list": _sessions_list_handler,
"sessions.subscribe": _sessions_subscribe_handler,
"sessions.unsubscribe": _sessions_unsubscribe_handler,
"sessions.messages.subscribe": _sessions_messages_subscribe_handler,
"sessions.messages.unsubscribe": _sessions_messages_unsubscribe_handler,
"sessions.preview": _sessions_preview_handler,
"sessions.resolve": _sessions_resolve_handler,
"sessions.compaction.list": _sessions_compaction_list_handler,
"sessions.compaction.get": _sessions_compaction_get_handler,
"sessions.create": _sessions_create_handler,
"sessions.compaction.branch": _sessions_compaction_branch_handler,
"sessions.compaction.restore": _sessions_compaction_restore_handler,
"sessions.send": _sessions_send_handler,
"sessions.steer": _sessions_steer_handler,
"sessions.abort": _sessions_abort_handler,
"sessions.patch": _sessions_patch_handler,
"sessions.reset": _sessions_reset_handler,
"sessions.delete": _sessions_delete_handler,
"sessions.get": _sessions_get_handler,
"sessions.compact": _sessions_compact_handler,
}

View file

@ -0,0 +1,47 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Any, TypedDict
class ErrorShape(TypedDict, total=False):
code: str
message: str
data: dict[str, Any]
class GatewayClient(TypedDict, total=False):
connect: dict[str, Any]
conn_id: str
client_ip: str
canvas_host_url: str
canvas_capability: str
canvas_capability_expires_at_ms: int
internal: dict[str, Any]
RespondFn = Callable[[bool, Any | None, ErrorShape | None, dict[str, Any] | None], None]
class GatewayRequestContext(TypedDict, total=False):
deps: Any
cron: Any
cron_store_path: str
get_health_cache: Callable[[], Any | None]
refresh_health_snapshot: Callable[..., Any]
log_health: Any
log_gateway: Any
unavailable_gateway_methods: set[str]
class GatewayRequestHandlerOptions(TypedDict, total=False):
req: dict[str, Any]
params: dict[str, Any]
client: GatewayClient | None
is_webchat_connect: Callable[[dict[str, Any] | None], bool]
respond: RespondFn
context: GatewayRequestContext
GatewayRequestHandler = Callable[[GatewayRequestHandlerOptions], Any]
GatewayRequestHandlers = dict[str, GatewayRequestHandler]

View file

@ -0,0 +1,273 @@
from __future__ import annotations
import json
from typing import Any
from oclaw.runtime.skill_installer import (
create_skill_from_template,
install_skill_from_local_dir,
install_skill_from_registry_archive,
list_skills_with_status,
set_skill_enabled,
)
from oclaw.runtime.skills import discover_workspace_skill_manifests, load_skill_manifest
from .shared_types import GatewayRequestHandlers
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) -> None:
if callable(respond):
respond(False, None, error_shape("UNAVAILABLE", message), None)
def _ok(respond, payload: Any) -> None:
if callable(respond):
respond(True, payload, None, None)
def _normalize_optional_str(value: Any) -> str | None:
if isinstance(value, str):
v = value.strip()
return v or None
return None
def _get_store(context: Any) -> Any | None:
if isinstance(context, dict) and context.get("store") is not None:
return context.get("store")
return None
def _skills_status_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
context = opts.get("context")
store = _get_store(context)
if store is None:
_unavailable(respond, "skills.status requires context.store")
return
_ok(respond, {"skills": list_skills_with_status(store=store)})
def _skills_bins_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
bins: set[str] = set()
for m in discover_workspace_skill_manifests():
oc = dict(m.metadata_oclaw or {})
req = oc.get("requires")
if isinstance(req, dict):
for k in ("bins", "anyBins"):
raw = req.get(k)
if isinstance(raw, list):
for it in raw:
v = _normalize_optional_str(it)
if v:
bins.add(v)
for spec in m.install:
payload = dict(spec.payload or {})
raw_bins = payload.get("bins")
if isinstance(raw_bins, list):
for it in raw_bins:
v = _normalize_optional_str(it)
if v:
bins.add(v)
_ok(respond, {"bins": sorted(bins, key=lambda x: x.lower())})
def _skills_search_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 skills.search params")
return
query = _normalize_optional_str(params.get("query")) or ""
limit = params.get("limit")
limit_n = int(limit) if isinstance(limit, int) and limit > 0 else 20
hook = context.get("search_clawhub_skills") if isinstance(context, dict) else None
if callable(hook):
try:
results = hook({"query": query, "limit": limit_n})
_ok(respond, {"results": results if isinstance(results, list) else []})
except Exception as exc:
_unavailable(respond, str(exc))
return
out: list[dict[str, Any]] = []
for m in discover_workspace_skill_manifests():
hay = f"{m.name}\n{m.description}\n{m.body}".lower()
if query.lower() in hay:
out.append({"slug": m.name, "name": m.name, "description": m.description, "source": "local"})
if len(out) >= limit_n:
break
_ok(respond, {"results": out})
def _skills_detail_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 skills.detail params")
return
slug = _normalize_optional_str(params.get("slug"))
if not slug:
_bad(respond, "invalid skills.detail params: slug required")
return
hook = context.get("fetch_clawhub_skill_detail") if isinstance(context, dict) else None
if callable(hook):
try:
detail = hook({"slug": slug})
_ok(respond, detail if isinstance(detail, dict) else {"slug": slug})
except Exception as exc:
_unavailable(respond, str(exc))
return
manifest = load_skill_manifest(slug)
if manifest is None:
for m in discover_workspace_skill_manifests():
if m.name == slug:
manifest = m
break
if manifest is None:
_bad(respond, f"unknown skill slug: {slug}")
return
_ok(
respond,
{
"slug": manifest.name,
"name": manifest.name,
"description": manifest.description,
"skillDir": manifest.skill_dir,
"skillFile": manifest.skill_file,
"metadata": {"oclaw": dict(manifest.metadata_oclaw)},
"body": manifest.body,
},
)
def _skills_install_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
params = opts.get("params")
context = opts.get("context")
store = _get_store(context)
if store is None:
_unavailable(respond, "skills.install requires context.store")
return
if not isinstance(params, dict):
_bad(respond, "invalid skills.install params")
return
source = _normalize_optional_str(params.get("source")) or "local"
if source == "clawhub":
hook = context.get("install_skill_from_clawhub") if isinstance(context, dict) else None
if not callable(hook):
_unavailable(respond, "clawhub install is not configured")
return
try:
result = hook(dict(params))
_ok(respond, result)
except Exception as exc:
_unavailable(respond, str(exc))
return
overwrite = bool(params.get("force"))
archive_url = _normalize_optional_str(params.get("archiveUrl"))
source_dir = _normalize_optional_str(params.get("sourceDir"))
if archive_url:
res = install_skill_from_registry_archive(store=store, archive_url=archive_url, overwrite=overwrite)
_ok(respond, {"ok": res.ok, "result": res.__dict__})
return
if source_dir:
res = install_skill_from_local_dir(store=store, source_dir=source_dir, overwrite=overwrite)
_ok(respond, {"ok": res.ok, "result": res.__dict__})
return
name = _normalize_optional_str(params.get("name"))
if not name:
_bad(respond, "invalid skills.install params: name required (or archiveUrl/sourceDir)")
return
description = _normalize_optional_str(params.get("description")) or f"{name} skill"
body = _normalize_optional_str(params.get("body")) or ""
md = params.get("metadata_oclaw")
md = dict(md) if isinstance(md, dict) else {}
res = create_skill_from_template(
store=store,
name=name,
description=description,
body_markdown=body,
metadata_oclaw=md,
overwrite=overwrite,
)
_ok(respond, {"ok": res.ok, "result": res.__dict__})
def _skills_update_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
params = opts.get("params")
context = opts.get("context")
store = _get_store(context)
if store is None:
_unavailable(respond, "skills.update requires context.store")
return
if not isinstance(params, dict):
_bad(respond, "invalid skills.update params")
return
source = _normalize_optional_str(params.get("source"))
if source == "clawhub":
hook = context.get("update_skills_from_clawhub") if isinstance(context, dict) else None
if not callable(hook):
_unavailable(respond, "clawhub update is not configured")
return
slug = _normalize_optional_str(params.get("slug"))
all_flag = bool(params.get("all"))
if not slug and not all_flag:
_bad(respond, 'clawhub skills.update requires "slug" or "all"')
return
if slug and all_flag:
_bad(respond, 'clawhub skills.update accepts either "slug" or "all", not both')
return
try:
result = hook(dict(params))
_ok(respond, result)
except Exception as exc:
_unavailable(respond, str(exc))
return
skill_key = _normalize_optional_str(params.get("skillKey"))
if not skill_key:
_bad(respond, "invalid skills.update params: skillKey required")
return
if isinstance(params.get("enabled"), bool):
set_skill_enabled(store=store, skill_name=skill_key, enabled=bool(params["enabled"]))
api_key = params.get("apiKey")
if isinstance(api_key, str):
trimmed = api_key.strip()
store.set_setting(f"SKILL_API_KEY:{skill_key}", trimmed)
env = params.get("env")
if isinstance(env, dict):
clean: dict[str, str] = {}
for k, v in env.items():
kk = _normalize_optional_str(k)
vv = _normalize_optional_str(v)
if not kk:
continue
if vv is None:
continue
clean[kk] = vv
store.set_setting(f"SKILL_ENV:{skill_key}", json.dumps(clean, ensure_ascii=False))
_ok(respond, {"ok": True, "skillKey": skill_key})
skills_handlers: GatewayRequestHandlers = {
"skills.status": _skills_status_handler,
"skills.bins": _skills_bins_handler,
"skills.search": _skills_search_handler,
"skills.detail": _skills_detail_handler,
"skills.install": _skills_install_handler,
"skills.update": _skills_update_handler,
}

View file

@ -0,0 +1,243 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from .shared_types import GatewayRequestHandlers
from .validation import error_shape
def _ok(respond, payload: Any) -> None:
if callable(respond):
respond(True, payload, None, None)
def _bad(respond, message: str) -> None:
if callable(respond):
respond(False, None, error_shape("INVALID_REQUEST", message), None)
def _normalize_optional_str(v: Any) -> str | None:
if isinstance(v, str):
s = v.strip()
return s or None
return None
def _read_string_value(v: Any) -> str | None:
return _normalize_optional_str(v)
def _normalize_lowercase_string_or_empty(v: Any) -> str:
return str(v or "").strip().lower()
@dataclass
class _PresenceUpdate:
key: str
next: dict[str, Any]
changed_keys: list[str]
def _presence_store(context: Any) -> dict[str, dict[str, Any]]:
if not isinstance(context, dict):
return {}
store = context.get("_system_presence_store")
if isinstance(store, dict):
return store
created: dict[str, dict[str, Any]] = {}
context["_system_presence_store"] = created
return created
def _list_system_presence(context: Any) -> list[dict[str, Any]]:
hook = context.get("list_system_presence") if isinstance(context, dict) else None
if callable(hook):
out = hook()
return out if isinstance(out, list) else []
store = _presence_store(context)
return [dict(v) for _, v in sorted(store.items(), key=lambda kv: kv[0])]
def _update_system_presence(context: Any, payload: dict[str, Any]) -> _PresenceUpdate:
hook = context.get("update_system_presence") if isinstance(context, dict) else None
if callable(hook):
raw = hook(payload)
if isinstance(raw, dict):
key = str(raw.get("key") or payload.get("deviceId") or "unknown")
nxt = raw.get("next")
nxt = dict(nxt) if isinstance(nxt, dict) else dict(payload)
changed = raw.get("changedKeys")
changed = list(changed) if isinstance(changed, list) else []
return _PresenceUpdate(key=key, next=nxt, changed_keys=[str(x) for x in changed if str(x)])
store = _presence_store(context)
key = str(payload.get("deviceId") or payload.get("instanceId") or payload.get("host") or "unknown").strip() or "unknown"
prev = dict(store.get(key) or {})
nxt = {**prev, **{k: v for k, v in payload.items() if v is not None}}
changed = [k for k in nxt.keys() if prev.get(k) != nxt.get(k)]
store[key] = dict(nxt)
return _PresenceUpdate(key=key, next=nxt, changed_keys=changed)
def _resolve_main_session_key(context: Any) -> str:
hook = context.get("resolve_main_session_key") if isinstance(context, dict) else None
if callable(hook):
try:
v = hook()
if isinstance(v, str) and v.strip():
return v.strip()
except Exception:
pass
return "main"
def _gateway_identity_get_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
context = opts.get("context")
hook = context.get("load_or_create_device_identity") if isinstance(context, dict) else None
if callable(hook):
ident = hook()
if isinstance(ident, dict):
_ok(
respond,
{
"deviceId": str(ident.get("deviceId") or ""),
"publicKey": ident.get("publicKey"),
},
)
return
# Staging fallback: stable-but-non-cryptographic identity.
_ok(respond, {"deviceId": "dev", "publicKey": "publicKey"})
def _last_heartbeat_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
context = opts.get("context")
hook = context.get("get_last_heartbeat_event") if isinstance(context, dict) else None
if callable(hook):
try:
_ok(respond, hook())
return
except Exception:
pass
_ok(respond, None)
def _set_heartbeats_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
params = opts.get("params") or {}
context = opts.get("context")
enabled = params.get("enabled") if isinstance(params, dict) else None
if not isinstance(enabled, bool):
_bad(respond, "invalid set-heartbeats params: enabled (boolean) required")
return
hook = context.get("set_heartbeats_enabled") if isinstance(context, dict) else None
if callable(hook):
try:
hook(enabled)
except Exception:
pass
_ok(respond, {"ok": True, "enabled": enabled})
def _system_presence_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
context = opts.get("context")
_ok(respond, _list_system_presence(context))
def _system_event_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
params = opts.get("params") or {}
context = opts.get("context")
if not isinstance(params, dict):
_bad(respond, "invalid system-event params")
return
text = _normalize_optional_str(params.get("text")) or ""
if not text:
_bad(respond, "text required")
return
session_key = _resolve_main_session_key(context)
presence_payload = {
"text": text,
"deviceId": _read_string_value(params.get("deviceId")),
"instanceId": _read_string_value(params.get("instanceId")),
"host": _read_string_value(params.get("host")),
"ip": _read_string_value(params.get("ip")),
"mode": _read_string_value(params.get("mode")),
"version": _read_string_value(params.get("version")),
"platform": _read_string_value(params.get("platform")),
"deviceFamily": _read_string_value(params.get("deviceFamily")),
"modelIdentifier": _read_string_value(params.get("modelIdentifier")),
"reason": _read_string_value(params.get("reason")),
}
last_input_seconds = params.get("lastInputSeconds")
if isinstance(last_input_seconds, (int, float)) and float(last_input_seconds) == float(last_input_seconds):
presence_payload["lastInputSeconds"] = float(last_input_seconds)
roles = params.get("roles")
scopes = params.get("scopes")
tags = params.get("tags")
if isinstance(roles, list) and all(isinstance(x, str) for x in roles):
presence_payload["roles"] = roles
if isinstance(scopes, list) and all(isinstance(x, str) for x in scopes):
presence_payload["scopes"] = scopes
if isinstance(tags, list) and all(isinstance(x, str) for x in tags):
presence_payload["tags"] = tags
upd = _update_system_presence(context, presence_payload)
enqueue = context.get("enqueue_system_event") if isinstance(context, dict) else None
if not callable(enqueue):
# no-op fallback
enqueue = lambda *_args, **_kwargs: None # noqa: E731
is_node_presence_line = text.startswith("Node:")
if is_node_presence_line:
changed = set(upd.changed_keys)
reason_value = upd.next.get("reason") or presence_payload.get("reason")
normalized_reason = _normalize_lowercase_string_or_empty(reason_value)
ignore_reason = normalized_reason.startswith("periodic") or normalized_reason == "heartbeat"
host_changed = "host" in changed
ip_changed = "ip" in changed
version_changed = "version" in changed
mode_changed = "mode" in changed
reason_changed = ("reason" in changed) and (not ignore_reason)
has_changes = host_changed or ip_changed or version_changed or mode_changed or reason_changed
if has_changes:
parts: list[str] = []
if host_changed or ip_changed:
host_label = _normalize_optional_str(upd.next.get("host")) or "Unknown"
ip_label = _normalize_optional_str(upd.next.get("ip"))
parts.append(f"Node: {host_label}{f' ({ip_label})' if ip_label else ''}")
if version_changed:
parts.append(f"app {_normalize_optional_str(upd.next.get('version')) or 'unknown'}")
if mode_changed:
parts.append(f"mode {_normalize_optional_str(upd.next.get('mode')) or 'unknown'}")
if reason_changed:
parts.append(f"reason {_normalize_optional_str(reason_value) or 'event'}")
delta_text = " · ".join([p for p in parts if p])
if delta_text:
enqueue(delta_text, {"sessionKey": session_key, "contextKey": upd.key})
else:
enqueue(text, {"sessionKey": session_key})
broadcast = context.get("broadcast_presence_snapshot") if isinstance(context, dict) else None
if callable(broadcast):
try:
broadcast()
except Exception:
pass
_ok(respond, {"ok": True})
system_handlers: GatewayRequestHandlers = {
"gateway.identity.get": _gateway_identity_get_handler,
"last-heartbeat": _last_heartbeat_handler,
"set-heartbeats": _set_heartbeats_handler,
"system-presence": _system_presence_handler,
"system-event": _system_event_handler,
}

View file

@ -0,0 +1,227 @@
from __future__ import annotations
import base64
import time
from typing import Any
from .shared_types import GatewayRequestHandlers
from .validation import error_shape
TALK_SECRETS_SCOPE = "talk.secrets"
ADMIN_SCOPE = "operator.admin"
def _ok(respond, payload: Any, meta: dict[str, Any] | None = None) -> None:
if callable(respond):
respond(True, payload, None, meta or None)
def _bad(respond, message: str) -> None:
if callable(respond):
respond(False, None, error_shape("INVALID_REQUEST", message), None)
def _unavailable(respond, message: str, *, details: dict[str, Any] | None = None) -> None:
if callable(respond):
respond(False, None, error_shape("UNAVAILABLE", message, {"details": details} if details else None), None)
def _norm_str(v: Any) -> str | None:
if isinstance(v, str):
s = v.strip()
return s or None
return None
def _caller_scopes(client: Any) -> list[str]:
if not isinstance(client, dict):
return []
connect = client.get("connect")
if not isinstance(connect, dict):
return []
scopes = connect.get("scopes")
if isinstance(scopes, list):
return [x for x in scopes if isinstance(x, str)]
return []
def _can_read_talk_secrets(client: Any) -> bool:
scopes = set(_caller_scopes(client))
return (ADMIN_SCOPE in scopes) or (TALK_SECRETS_SCOPE in scopes)
def _resolve_speed(params: dict[str, Any]) -> float | None:
speed = params.get("speed")
if isinstance(speed, (int, float)):
return float(speed)
rate_wpm = params.get("rateWpm")
if not isinstance(rate_wpm, (int, float)) or rate_wpm <= 0:
return None
resolved = float(rate_wpm) / 175.0
if resolved <= 0.5 or resolved >= 2.0:
return None
return resolved
def _infer_mime_type(output_format: str | None, file_extension: str | None) -> str | None:
of = (output_format or "").strip().lower()
ext = (file_extension or "").strip().lower()
if of == "mp3" or of.startswith("mp3_") or of.endswith("-mp3") or ext == ".mp3":
return "audio/mpeg"
if of == "opus" or of.startswith("opus_") or ext in {".opus", ".ogg"}:
return "audio/ogg"
if of.endswith("-wav") or ext == ".wav":
return "audio/wav"
if of.endswith("-webm") or ext == ".webm":
return "audio/webm"
return None
def _talk_config_handler(opts: dict[str, Any]) -> None:
params = opts.get("params") or {}
respond = opts.get("respond")
context = opts.get("context")
client = opts.get("client")
if not isinstance(params, dict):
_bad(respond, "invalid talk.config params")
return
include_secrets = bool(params.get("includeSecrets"))
if include_secrets and not _can_read_talk_secrets(client):
_bad(respond, f"missing scope: {TALK_SECRETS_SCOPE}")
return
hook = context.get("read_talk_config") if isinstance(context, dict) else None
if callable(hook):
try:
cfg = hook({"includeSecrets": include_secrets})
_ok(respond, {"config": cfg if isinstance(cfg, dict) else {}})
return
except Exception as exc:
_unavailable(respond, str(exc))
return
_ok(respond, {"config": {}})
def _talk_speak_handler(opts: dict[str, Any]) -> None:
params = opts.get("params") or {}
respond = opts.get("respond")
context = opts.get("context")
if not isinstance(params, dict):
_bad(respond, "invalid talk.speak params")
return
text = _norm_str(params.get("text")) or ""
if not text:
_bad(respond, "talk.speak requires text")
return
if params.get("speed") is None and params.get("rateWpm") is not None and _resolve_speed(params) is None:
_bad(respond, "invalid talk.speak params: rateWpm must resolve to speed between 0.5 and 2.0")
return
hook = context.get("talk_synthesize") if isinstance(context, dict) else None
if callable(hook):
try:
out = hook(
{
"text": text,
"voiceId": _norm_str(params.get("voiceId")),
"speed": _resolve_speed(params),
}
)
except Exception as exc:
_unavailable(respond, str(exc), details={"reason": "synthesis_failed", "fallbackEligible": False})
return
if not isinstance(out, dict):
_unavailable(respond, "talk synthesis failed", details={"reason": "synthesis_failed", "fallbackEligible": False})
return
if not out.get("success") or not out.get("audio"):
_unavailable(
respond,
str(out.get("error") or "talk synthesis failed"),
details={"reason": "synthesis_failed", "fallbackEligible": False},
)
return
audio = out.get("audio")
if isinstance(audio, str):
audio_b64 = audio
elif isinstance(audio, (bytes, bytearray, memoryview)):
audio_b64 = base64.b64encode(bytes(audio)).decode("ascii")
else:
_unavailable(respond, "talk synthesis returned invalid audio", details={"reason": "invalid_audio_result", "fallbackEligible": False})
return
provider = _norm_str(out.get("provider")) or "talk"
if not provider:
_unavailable(respond, "talk synthesis returned empty provider", details={"reason": "invalid_audio_result", "fallbackEligible": False})
return
output_format = _norm_str(out.get("outputFormat"))
file_ext = _norm_str(out.get("fileExtension"))
_ok(
respond,
{
"audioBase64": audio_b64,
"provider": provider,
"outputFormat": output_format,
"voiceCompatible": bool(out.get("voiceCompatible", True)),
"mimeType": _infer_mime_type(output_format, file_ext),
"fileExtension": file_ext,
},
)
return
# Fallback: return a tiny silent WAV header-ish payload (not real audio, but non-empty).
audio_b64 = base64.b64encode(b"RIFF").decode("ascii")
_ok(
respond,
{
"audioBase64": audio_b64,
"provider": "staging",
"outputFormat": "wav",
"voiceCompatible": True,
"mimeType": "audio/wav",
"fileExtension": ".wav",
},
)
def _talk_mode_handler(opts: dict[str, Any]) -> None:
params = opts.get("params") or {}
respond = opts.get("respond")
context = opts.get("context")
client = opts.get("client")
is_webchat_connect = opts.get("is_webchat_connect")
if client and callable(is_webchat_connect) and is_webchat_connect((client or {}).get("connect")):
has_mobile = context.get("hasConnectedMobileNode") if isinstance(context, dict) else None
try:
ok_mobile = bool(has_mobile()) if callable(has_mobile) else True
except Exception:
ok_mobile = True
if not ok_mobile:
_unavailable(respond, "talk disabled: no connected iOS/Android nodes")
return
if not isinstance(params, dict) or not isinstance(params.get("enabled"), bool):
_bad(respond, "invalid talk.mode params")
return
payload = {
"enabled": bool(params.get("enabled")),
"phase": _norm_str(params.get("phase")),
"ts": int(time.time() * 1000),
}
broadcast = context.get("broadcast") if isinstance(context, dict) else None
if callable(broadcast):
try:
broadcast("talk.mode", payload, {"dropIfSlow": True})
except Exception:
pass
_ok(respond, payload)
talk_handlers: GatewayRequestHandlers = {
"talk.config": _talk_config_handler,
"talk.speak": _talk_speak_handler,
"talk.mode": _talk_mode_handler,
}

View file

@ -0,0 +1,41 @@
from __future__ import annotations
from typing import Any
from oclaw.runtime.extensions.telegram import (
normalize_telegram_messaging_target,
parse_telegram_reply_to_message_id,
parse_telegram_target,
parse_telegram_thread_id,
)
def normalize_transport_target_for_channel(
*,
channel: str,
to: str,
params: dict[str, Any],
) -> tuple[str, dict[str, Any]]:
"""Normalize channel transport target payload for outbound paths."""
extra: dict[str, Any] = {}
if channel != "telegram":
return to, extra
normalized_target = normalize_telegram_messaging_target(to)
parsed_target = parse_telegram_target(normalized_target or to)
normalized_to = normalized_target or f"telegram:{parsed_target.chat_id}".lower()
reply_to_id = parse_telegram_reply_to_message_id(params.get("replyToId"))
thread_id = parse_telegram_thread_id(
params.get("threadId") if params.get("threadId") is not None else parsed_target.message_thread_id
)
extra = {
"target": {
"chatId": parsed_target.chat_id,
"chatType": parsed_target.chat_type,
**({"messageThreadId": parsed_target.message_thread_id} if parsed_target.message_thread_id is not None else {}),
},
**({"threadId": thread_id} if thread_id is not None else {}),
**({"replyToMessageId": reply_to_id} if reply_to_id is not None else {}),
}
return normalized_to, extra

View file

@ -0,0 +1,117 @@
from __future__ import annotations
from typing import Any
from .shared_types import GatewayRequestHandlers
from .validation import error_shape
def _ok(respond, payload: Any) -> None:
if callable(respond):
respond(True, payload, None, None)
def _bad(respond, message: str) -> None:
if callable(respond):
respond(False, None, error_shape("INVALID_REQUEST", message), None)
def _norm_str(v: Any) -> str | None:
if isinstance(v, str):
s = v.strip()
return s or None
return None
def _tools_catalog_handler(opts: dict[str, Any]) -> None:
params = opts.get("params") or {}
respond = opts.get("respond")
context = opts.get("context")
if not isinstance(params, dict):
_bad(respond, "invalid tools.catalog params")
return
include_plugins = params.get("includePlugins")
include_plugins = bool(include_plugins) if include_plugins is not None else True
agent_id = _norm_str(params.get("agentId")) or "main"
# Optional agent validation hook.
known_agents = None
list_agents = context.get("list_agent_ids") if isinstance(context, dict) else None
if callable(list_agents):
try:
known_agents = list_agents()
except Exception:
known_agents = None
if isinstance(known_agents, list) and agent_id and agent_id not in [x for x in known_agents if isinstance(x, str)]:
_bad(respond, f'unknown agent id "{agent_id}"')
return
hook = context.get("build_tools_catalog") if isinstance(context, dict) else None
if callable(hook):
try:
out = hook({"agentId": agent_id, "includePlugins": include_plugins})
_ok(respond, out if isinstance(out, dict) else {"agentId": agent_id, "profiles": [], "groups": []})
return
except Exception as exc:
_bad(respond, str(exc))
return
_ok(
respond,
{
"agentId": agent_id,
"profiles": [
{"id": "minimal", "label": "Minimal"},
{"id": "coding", "label": "Coding"},
{"id": "messaging", "label": "Messaging"},
{"id": "full", "label": "Full"},
],
"groups": [
{
"id": "core:default",
"label": "Core",
"source": "core",
"tools": [
{
"id": "tool.echo",
"label": "Echo",
"description": "Staging tool entry",
"source": "core",
"defaultProfiles": ["minimal", "full"],
}
],
}
]
if not include_plugins
else [
{
"id": "core:default",
"label": "Core",
"source": "core",
"tools": [
{
"id": "tool.echo",
"label": "Echo",
"description": "Staging tool entry",
"source": "core",
"defaultProfiles": ["minimal", "full"],
}
],
},
{
"id": "plugin:example",
"label": "example",
"source": "plugin",
"pluginId": "example",
"tools": [],
},
],
},
)
tools_catalog_handlers: GatewayRequestHandlers = {
"tools.catalog": _tools_catalog_handler,
}

View file

@ -0,0 +1,92 @@
from __future__ import annotations
from typing import Any
from .shared_types import GatewayRequestHandlers
from .validation import error_shape
def _ok(respond, payload: Any) -> None:
if callable(respond):
respond(True, payload, None, None)
def _bad(respond, message: str) -> None:
if callable(respond):
respond(False, None, error_shape("INVALID_REQUEST", message), None)
def _norm_str(v: Any) -> str | None:
if isinstance(v, str):
s = v.strip()
return s or None
return None
def _is_admin(client: Any) -> bool:
if not isinstance(client, dict):
return False
connect = client.get("connect")
if not isinstance(connect, dict):
return False
scopes = connect.get("scopes")
return isinstance(scopes, list) and "operator.admin" in [x for x in scopes if isinstance(x, str)]
def _tools_effective_handler(opts: dict[str, Any]) -> None:
params = opts.get("params") or {}
respond = opts.get("respond")
context = opts.get("context")
client = opts.get("client")
if not isinstance(params, dict):
_bad(respond, "invalid tools.effective params")
return
session_key = _norm_str(params.get("sessionKey"))
if not session_key:
_bad(respond, "invalid tools.effective params: sessionKey required")
return
requested_agent_id = _norm_str(params.get("agentId"))
if requested_agent_id:
list_agents = context.get("list_agent_ids") if isinstance(context, dict) else None
if callable(list_agents):
try:
known = list_agents()
except Exception:
known = None
if isinstance(known, list) and requested_agent_id not in [x for x in known if isinstance(x, str)]:
_bad(respond, f'unknown agent id "{requested_agent_id}"')
return
hook = context.get("resolve_effective_tool_inventory") if isinstance(context, dict) else None
if callable(hook):
try:
out = hook(
{
"sessionKey": session_key,
"agentId": requested_agent_id,
"senderIsOwner": _is_admin(client),
}
)
_ok(respond, out if isinstance(out, dict) else {"tools": []})
return
except Exception as exc:
_bad(respond, str(exc))
return
_ok(
respond,
{
"sessionKey": session_key,
"agentId": requested_agent_id or "main",
"tools": [],
"policy": {
"senderIsOwner": _is_admin(client),
},
},
)
tools_effective_handlers: GatewayRequestHandlers = {
"tools.effective": _tools_effective_handler,
}

View file

@ -0,0 +1,183 @@
from __future__ import annotations
from typing import Any
from .shared_types import GatewayRequestHandlers
from .validation import error_shape
def _ok(respond, payload: Any) -> None:
if callable(respond):
respond(True, payload, None, None)
def _bad(respond, message: str) -> None:
if callable(respond):
respond(False, None, error_shape("INVALID_REQUEST", message), None)
def _unavailable(respond, message: str) -> None:
if callable(respond):
respond(False, None, error_shape("UNAVAILABLE", message), None)
def _norm_str(v: Any) -> str | None:
if isinstance(v, str):
s = v.strip()
return s or None
return None
def _tts_status_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
context = opts.get("context")
hook = context.get("tts_status") if isinstance(context, dict) else None
if callable(hook):
try:
out = hook()
_ok(respond, out if isinstance(out, dict) else {})
except Exception as exc:
_unavailable(respond, str(exc))
return
_ok(
respond,
{
"enabled": False,
"auto": True,
"provider": None,
"fallbackProvider": None,
"fallbackProviders": [],
"prefsPath": None,
"providerStates": [],
},
)
def _tts_enable_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
context = opts.get("context")
hook = context.get("set_tts_enabled") if isinstance(context, dict) else None
if callable(hook):
try:
hook(True)
except Exception as exc:
_unavailable(respond, str(exc))
return
_ok(respond, {"enabled": True})
def _tts_disable_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
context = opts.get("context")
hook = context.get("set_tts_enabled") if isinstance(context, dict) else None
if callable(hook):
try:
hook(False)
except Exception as exc:
_unavailable(respond, str(exc))
return
_ok(respond, {"enabled": False})
def _tts_convert_handler(opts: dict[str, Any]) -> None:
params = opts.get("params")
respond = opts.get("respond")
context = opts.get("context")
if not isinstance(params, dict):
_bad(respond, "invalid tts.convert params")
return
text = _norm_str(params.get("text")) or ""
if not text:
_bad(respond, "tts.convert requires text")
return
hook = context.get("text_to_speech") if isinstance(context, dict) else None
if callable(hook):
try:
out = hook(
{
"text": text,
"channel": _norm_str(params.get("channel")),
"provider": _norm_str(params.get("provider")),
"modelId": _norm_str(params.get("modelId")),
"voiceId": _norm_str(params.get("voiceId")),
}
)
if isinstance(out, dict):
success = bool(out.get("success", False))
if success and out.get("audioPath"):
_ok(
respond,
{
"audioPath": out.get("audioPath"),
"provider": out.get("provider"),
"outputFormat": out.get("outputFormat"),
"voiceCompatible": out.get("voiceCompatible"),
},
)
return
_unavailable(respond, str(out.get("error") or "TTS conversion failed"))
return
except Exception as exc:
_unavailable(respond, str(exc))
return
# fallback success shape
_ok(
respond,
{
"audioPath": "/tmp/tts.wav",
"provider": _norm_str(params.get("provider")) or "staging",
"outputFormat": "wav",
"voiceCompatible": True,
},
)
def _tts_set_provider_handler(opts: dict[str, Any]) -> None:
params = opts.get("params")
respond = opts.get("respond")
context = opts.get("context")
if not isinstance(params, dict):
_bad(respond, "invalid tts.setProvider params")
return
provider = _norm_str(params.get("provider")) or ""
if not provider:
_bad(respond, "Invalid provider. Use a registered TTS provider id.")
return
hook = context.get("set_tts_provider") if isinstance(context, dict) else None
if callable(hook):
try:
ok = hook(provider)
if ok is False:
_bad(respond, "Invalid provider. Use a registered TTS provider id.")
return
except Exception as exc:
_unavailable(respond, str(exc))
return
_ok(respond, {"provider": provider})
def _tts_providers_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
context = opts.get("context")
hook = context.get("list_tts_providers") if isinstance(context, dict) else None
if callable(hook):
try:
out = hook()
if isinstance(out, dict):
_ok(respond, out)
return
except Exception as exc:
_unavailable(respond, str(exc))
return
_ok(respond, {"providers": [], "active": None})
tts_handlers: GatewayRequestHandlers = {
"tts.status": _tts_status_handler,
"tts.enable": _tts_enable_handler,
"tts.disable": _tts_disable_handler,
"tts.convert": _tts_convert_handler,
"tts.setProvider": _tts_set_provider_handler,
"tts.providers": _tts_providers_handler,
}

View file

@ -0,0 +1,2 @@
from .shared_types import *

View file

@ -0,0 +1,148 @@
from __future__ import annotations
import time
from typing import Any
from .shared_types import GatewayRequestHandlers
from .validation import error_shape
def _ok(respond, payload: Any) -> None:
if callable(respond):
respond(True, payload, None, None)
def _bad(respond, message: str) -> None:
if callable(respond):
respond(False, None, error_shape("INVALID_REQUEST", message), None)
def _norm_str(v: Any) -> str | None:
if isinstance(v, str):
s = v.strip()
return s or None
return None
def _as_int(v: Any) -> int | None:
if isinstance(v, bool):
return None
if isinstance(v, (int, float)):
return int(v)
return None
def _parse_restart_request_params(params: dict[str, Any]) -> dict[str, Any]:
return {
"sessionKey": _norm_str(params.get("sessionKey")),
"deliveryContext": params.get("deliveryContext") if isinstance(params.get("deliveryContext"), dict) else None,
"threadId": _norm_str(params.get("threadId")),
"note": _norm_str(params.get("note")),
"restartDelayMs": max(0, _as_int(params.get("restartDelayMs")) or 0) or None,
}
def _update_run_handler(opts: dict[str, Any]) -> None:
params = opts.get("params")
respond = opts.get("respond")
context = opts.get("context")
client = opts.get("client")
if params is not None and not isinstance(params, dict):
_bad(respond, "invalid update.run params")
return
p = dict(params or {})
parsed = _parse_restart_request_params(p)
timeout_ms_raw = _as_int(p.get("timeoutMs"))
timeout_ms = max(1000, timeout_ms_raw) if isinstance(timeout_ms_raw, int) else None
runner = context.get("run_gateway_update") if isinstance(context, dict) else None
if callable(runner):
try:
result = runner({"timeoutMs": timeout_ms, "params": p})
if not isinstance(result, dict):
result = {
"status": "ok",
"mode": "unknown",
"steps": [],
"durationMs": 0,
}
except Exception as exc:
result = {
"status": "error",
"mode": "unknown",
"reason": str(exc),
"steps": [],
"durationMs": 0,
}
else:
result = {
"status": "ok",
"mode": "staging",
"steps": [],
"durationMs": 0,
}
payload = {
"kind": "update",
"status": result.get("status"),
"ts": int(time.time() * 1000),
"sessionKey": parsed["sessionKey"],
"deliveryContext": parsed["deliveryContext"],
"threadId": parsed["threadId"],
"message": parsed["note"],
"doctorHint": "Run doctor in non-interactive mode if needed.",
"stats": {
"mode": result.get("mode"),
"root": result.get("root"),
"before": result.get("before"),
"after": result.get("after"),
"steps": result.get("steps") if isinstance(result.get("steps"), list) else [],
"reason": result.get("reason"),
"durationMs": result.get("durationMs"),
},
}
sentinel_path = None
write_sentinel = context.get("write_restart_sentinel") if isinstance(context, dict) else None
if callable(write_sentinel):
try:
sentinel_path = write_sentinel(payload)
except Exception:
sentinel_path = None
restart = None
if result.get("status") == "ok":
schedule = context.get("schedule_gateway_restart") if isinstance(context, dict) else None
if callable(schedule):
actor = {"actor": "unknown", "deviceId": None, "clientIp": None}
if isinstance(client, dict):
connect = client.get("connect")
if isinstance(connect, dict):
actor["deviceId"] = connect.get("device", {}).get("id") if isinstance(connect.get("device"), dict) else None
actor["clientIp"] = client.get("client_ip")
restart = schedule(
{
"delayMs": parsed["restartDelayMs"],
"reason": "update.run",
"audit": {"actor": actor.get("actor"), "deviceId": actor.get("deviceId"), "clientIp": actor.get("clientIp"), "changedPaths": []},
}
)
else:
restart = {"scheduled": True, "reason": "update.run", "delayMs": parsed["restartDelayMs"]}
_ok(
respond,
{
"ok": result.get("status") != "error",
"result": result,
"restart": restart,
"sentinel": {"path": sentinel_path, "payload": payload},
},
)
update_handlers: GatewayRequestHandlers = {
"update.run": _update_run_handler,
}

View file

@ -0,0 +1,268 @@
from __future__ import annotations
import time
from datetime import datetime, timedelta, timezone
from typing import Any
from .shared_types import GatewayRequestHandlers
from .validation import error_shape
COST_USAGE_CACHE_TTL_MS = 30_000
DAY_MS = 24 * 60 * 60 * 1000
_cost_usage_cache: dict[str, dict[str, Any]] = {}
def _ok(respond, payload: Any, meta: dict[str, Any] | None = None) -> None:
if callable(respond):
respond(True, payload, None, meta or None)
def _bad(respond, message: str) -> None:
if callable(respond):
respond(False, None, error_shape("INVALID_REQUEST", message), None)
def _unavailable(respond, message: str) -> None:
if callable(respond):
respond(False, None, error_shape("UNAVAILABLE", message), None)
def _parse_date_parts(raw: Any) -> tuple[int, int, int] | None:
if not isinstance(raw, str) or not raw.strip():
return None
try:
dt = datetime.strptime(raw.strip(), "%Y-%m-%d")
except ValueError:
return None
return dt.year, dt.month, dt.day
def _parse_utc_offset_minutes(raw: Any) -> int | None:
if not isinstance(raw, str) or not raw.strip():
return None
text = raw.strip()
if not text.startswith("UTC"):
return None
sign_part = text[3:4]
if sign_part not in {"+", "-"}:
return None
rest = text[4:]
if ":" in rest:
hh_s, mm_s = rest.split(":", 1)
else:
hh_s, mm_s = rest, "0"
try:
hh = int(hh_s)
mm = int(mm_s)
except ValueError:
return None
if hh > 14 or mm < 0 or mm >= 60:
return None
total = hh * 60 + mm
if sign_part == "-":
total = -total
if total < -12 * 60 or total > 14 * 60:
return None
return total
def _resolve_date_mode(params: dict[str, Any]) -> dict[str, Any]:
mode = params.get("mode")
if mode == "gateway":
return {"mode": "gateway"}
if mode == "specific":
offset = _parse_utc_offset_minutes(params.get("utcOffset"))
if offset is not None:
return {"mode": "specific", "utcOffsetMinutes": offset}
return {"mode": "utc"}
def _parse_date_to_ms(raw: Any, interpretation: dict[str, Any]) -> int | None:
parts = _parse_date_parts(raw)
if not parts:
return None
y, m, d = parts
if interpretation["mode"] == "gateway":
return int(datetime(y, m, d).timestamp() * 1000)
if interpretation["mode"] == "specific":
offset = interpretation["utcOffsetMinutes"]
base = datetime(y, m, d, tzinfo=timezone.utc).timestamp() * 1000
return int(base - offset * 60 * 1000)
return int(datetime(y, m, d, tzinfo=timezone.utc).timestamp() * 1000)
def _today_start_ms(now: datetime, interpretation: dict[str, Any]) -> int:
if interpretation["mode"] == "gateway":
local = datetime(now.year, now.month, now.day)
return int(local.timestamp() * 1000)
if interpretation["mode"] == "specific":
offset = interpretation["utcOffsetMinutes"]
shifted = now + timedelta(minutes=offset)
start = datetime(shifted.year, shifted.month, shifted.day, tzinfo=timezone.utc)
return int(start.timestamp() * 1000) - offset * 60 * 1000
start = datetime(now.year, now.month, now.day, tzinfo=timezone.utc)
return int(start.timestamp() * 1000)
def _parse_days(raw: Any) -> int | None:
if isinstance(raw, (int, float)) and not isinstance(raw, bool):
return int(raw)
if isinstance(raw, str) and raw.strip():
try:
return int(float(raw.strip()))
except ValueError:
return None
return None
def _parse_date_range(params: dict[str, Any]) -> dict[str, int]:
now = datetime.now(timezone.utc)
interpretation = _resolve_date_mode(params)
today_start = _today_start_ms(now, interpretation)
today_end = today_start + DAY_MS - 1
start_ms = _parse_date_to_ms(params.get("startDate"), interpretation)
end_ms = _parse_date_to_ms(params.get("endDate"), interpretation)
if start_ms is not None and end_ms is not None:
return {"startMs": start_ms, "endMs": end_ms + DAY_MS - 1}
days = _parse_days(params.get("days"))
if days is not None:
clamped = max(1, days)
return {"startMs": today_start - (clamped - 1) * DAY_MS, "endMs": today_end}
return {"startMs": today_start - 29 * DAY_MS, "endMs": today_end}
def _load_cost_usage_summary_cached(start_ms: int, end_ms: int, context: Any) -> tuple[dict[str, Any], bool]:
cache_key = f"{start_ms}-{end_ms}"
now_ms = int(time.time() * 1000)
cached = _cost_usage_cache.get(cache_key)
if cached and now_ms - int(cached.get("updatedAt", 0)) < COST_USAGE_CACHE_TTL_MS:
return dict(cached.get("summary") or {}), True
hook = context.get("load_cost_usage_summary") if isinstance(context, dict) else None
if callable(hook):
summary = hook({"startMs": start_ms, "endMs": end_ms})
if not isinstance(summary, dict):
summary = {}
else:
summary = {
"startMs": start_ms,
"endMs": end_ms,
"totals": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0,
"totalTokens": 0,
"totalCost": 0,
},
}
_cost_usage_cache[cache_key] = {"summary": summary, "updatedAt": now_ms}
return summary, False
def _usage_status_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
context = opts.get("context")
hook = context.get("load_provider_usage_summary") if isinstance(context, dict) else None
try:
summary = hook() if callable(hook) else {"providers": [], "generatedAt": int(time.time() * 1000)}
_ok(respond, summary if isinstance(summary, dict) else {})
except Exception as exc:
_unavailable(respond, str(exc))
def _usage_cost_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
params = opts.get("params") or {}
context = opts.get("context")
if params is not None and not isinstance(params, dict):
_bad(respond, "invalid usage.cost params")
return
date_range = _parse_date_range(dict(params))
try:
summary, was_cached = _load_cost_usage_summary_cached(date_range["startMs"], date_range["endMs"], context)
_ok(respond, summary, {"cached": True} if was_cached else None)
except Exception as exc:
_unavailable(respond, str(exc))
def _sessions_usage_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
params = opts.get("params") or {}
context = opts.get("context")
if not isinstance(params, dict):
_bad(respond, "invalid sessions.usage params")
return
limit = params.get("limit")
limit = int(limit) if isinstance(limit, (int, float)) and not isinstance(limit, bool) else 50
limit = max(1, min(limit, 500))
specific_key = params.get("key")
if specific_key is not None and not isinstance(specific_key, str):
_bad(respond, "invalid sessions.usage params")
return
date_range = _parse_date_range(params)
hook = context.get("load_sessions_usage") if isinstance(context, dict) else None
if callable(hook):
try:
out = hook(
{
"startMs": date_range["startMs"],
"endMs": date_range["endMs"],
"limit": limit,
"key": specific_key,
"includeContextWeight": bool(params.get("includeContextWeight", False)),
}
)
_ok(respond, out if isinstance(out, dict) else {"sessions": [], "aggregates": {}, "range": date_range})
return
except Exception as exc:
_unavailable(respond, str(exc))
return
payload = {
"sessions": ([] if not specific_key else [{"key": specific_key, "sessionId": specific_key, "updatedAt": date_range["endMs"]}])[:limit],
"aggregates": {
"totals": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0,
"totalTokens": 0,
"totalCost": 0,
"missingCostEntries": 0,
},
"messages": {
"total": 0,
"user": 0,
"assistant": 0,
"toolCalls": 0,
"toolResults": 0,
"errors": 0,
},
"tools": [],
"byModel": [],
"byProvider": [],
"byAgent": [],
"byChannel": [],
"daily": [],
"latency": None,
"dailyLatency": [],
"modelDaily": [],
"aggregateTail": [],
},
"range": date_range,
"limit": limit,
}
_ok(respond, payload)
usage_handlers: GatewayRequestHandlers = {
"usage.status": _usage_status_handler,
"usage.cost": _usage_cost_handler,
"sessions.usage": _sessions_usage_handler,
}

View file

@ -0,0 +1,40 @@
from __future__ import annotations
from typing import Any
def error_shape(code: str, message: str, data: dict[str, Any] | None = None) -> dict[str, Any]:
out: dict[str, Any] = {"code": str(code or "UNKNOWN"), "message": str(message or "")}
if isinstance(data, dict) and data:
out["data"] = data
return out
def assert_valid_params(
params: Any,
validator: Any,
method: str,
respond: Any,
) -> bool:
"""Validate request params and respond with a standardized error on failure."""
try:
ok = bool(validator(params)) if callable(validator) else True
except Exception as exc:
ok = False
err = error_shape("INVALID_REQUEST", f"param validator raised: {type(exc).__name__}")
try:
respond(False, None, err, None)
except Exception:
pass
return False
if ok:
return True
err = error_shape("INVALID_REQUEST", f"invalid params for {str(method or '').strip() or 'unknown'}")
try:
respond(False, None, err, None)
except Exception:
pass
return False
__all__ = ["assert_valid_params", "error_shape"]

View file

@ -0,0 +1,88 @@
from __future__ import annotations
from typing import Any
from .shared_types import GatewayRequestHandlers
from .validation import error_shape
def _ok(respond, payload: Any) -> None:
if callable(respond):
respond(True, payload, None, None)
def _bad(respond, message: str) -> None:
if callable(respond):
respond(False, None, error_shape("INVALID_REQUEST", message), None)
def _unavailable(respond, message: str) -> None:
if callable(respond):
respond(False, None, error_shape("UNAVAILABLE", message), None)
def _normalize_triggers(raw: Any) -> list[str]:
if not isinstance(raw, list):
return []
out: list[str] = []
seen: set[str] = set()
for x in raw:
if not isinstance(x, str):
continue
s = x.strip()
if not s:
continue
k = s.lower()
if k in seen:
continue
seen.add(k)
out.append(s)
return out
def _voicewake_get_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
context = opts.get("context")
load_cfg = context.get("load_voicewake_config") if isinstance(context, dict) else None
if callable(load_cfg):
try:
cfg = load_cfg()
triggers = cfg.get("triggers") if isinstance(cfg, dict) else None
_ok(respond, {"triggers": triggers if isinstance(triggers, list) else []})
return
except Exception as exc:
_unavailable(respond, str(exc))
return
_ok(respond, {"triggers": []})
def _voicewake_set_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
params = opts.get("params")
context = opts.get("context")
if not isinstance(params, dict) or not isinstance(params.get("triggers"), list):
_bad(respond, "voicewake.set requires triggers: string[]")
return
triggers = _normalize_triggers(params.get("triggers"))
set_cfg = context.get("set_voicewake_triggers") if isinstance(context, dict) else None
if callable(set_cfg):
try:
cfg = set_cfg(triggers)
value = cfg.get("triggers") if isinstance(cfg, dict) else triggers
if callable(context.get("broadcastVoiceWakeChanged")):
context["broadcastVoiceWakeChanged"](value)
_ok(respond, {"triggers": value if isinstance(value, list) else triggers})
return
except Exception as exc:
_unavailable(respond, str(exc))
return
if callable(context.get("broadcastVoiceWakeChanged")):
context["broadcastVoiceWakeChanged"](triggers)
_ok(respond, {"triggers": triggers})
voicewake_handlers: GatewayRequestHandlers = {
"voicewake.get": _voicewake_get_handler,
"voicewake.set": _voicewake_set_handler,
}

View file

@ -0,0 +1,120 @@
from __future__ import annotations
from typing import Any
from .shared_types import GatewayRequestHandlers
from .validation import error_shape
def _ok(respond, payload: Any) -> None:
if callable(respond):
respond(True, payload, None, None)
def _bad(respond, message: str) -> None:
if callable(respond):
respond(False, None, error_shape("INVALID_REQUEST", message), None)
def _unavailable(respond, message: str) -> None:
if callable(respond):
respond(False, None, error_shape("UNAVAILABLE", message), None)
def _norm_str(v: Any) -> str | None:
if isinstance(v, str):
s = v.strip()
return s or None
return None
def _resolve_provider(context: Any) -> dict[str, Any] | None:
hook = context.get("resolve_web_login_provider") if isinstance(context, dict) else None
if callable(hook):
out = hook()
if isinstance(out, dict):
return out
return None
def _web_login_start_handler(opts: dict[str, Any]) -> None:
params = opts.get("params")
respond = opts.get("respond")
context = opts.get("context")
if params is not None and not isinstance(params, dict):
_bad(respond, "invalid web.login.start params")
return
p = dict(params or {})
provider = _resolve_provider(context)
if not provider:
_bad(respond, "web login provider is not available")
return
start_fn = provider.get("loginWithQrStart")
if not callable(start_fn):
_bad(respond, f"web login is not supported by provider {provider.get('id')}")
return
account_id = _norm_str(p.get("accountId"))
try:
stop_channel = context.get("stopChannel") if isinstance(context, dict) else None
if callable(stop_channel):
stop_channel(provider.get("id"), account_id)
result = start_fn(
{
"force": bool(p.get("force")),
"timeoutMs": p.get("timeoutMs") if isinstance(p.get("timeoutMs"), (int, float)) else None,
"verbose": bool(p.get("verbose")),
"accountId": account_id,
}
)
if not isinstance(result, dict):
result = {}
connected = bool(result.get("connected"))
if connected:
start_channel = context.get("startChannel") if isinstance(context, dict) else None
if callable(start_channel):
start_channel(provider.get("id"), account_id)
_ok(respond, result)
except Exception as exc:
_unavailable(respond, str(exc))
def _web_login_wait_handler(opts: dict[str, Any]) -> None:
params = opts.get("params")
respond = opts.get("respond")
context = opts.get("context")
if params is not None and not isinstance(params, dict):
_bad(respond, "invalid web.login.wait params")
return
p = dict(params or {})
provider = _resolve_provider(context)
if not provider:
_bad(respond, "web login provider is not available")
return
wait_fn = provider.get("loginWithQrWait")
if not callable(wait_fn):
_bad(respond, f"web login is not supported by provider {provider.get('id')}")
return
account_id = _norm_str(p.get("accountId"))
try:
result = wait_fn(
{
"timeoutMs": p.get("timeoutMs") if isinstance(p.get("timeoutMs"), (int, float)) else None,
"accountId": account_id,
}
)
if not isinstance(result, dict):
result = {}
if bool(result.get("connected")):
start_channel = context.get("startChannel") if isinstance(context, dict) else None
if callable(start_channel):
start_channel(provider.get("id"), account_id)
_ok(respond, result)
except Exception as exc:
_unavailable(respond, str(exc))
web_handlers: GatewayRequestHandlers = {
"web.login.start": _web_login_start_handler,
"web.login.wait": _web_login_wait_handler,
}

View file

@ -0,0 +1,170 @@
from __future__ import annotations
from uuid import uuid4
from typing import Any
from .shared_types import GatewayRequestHandlers
from .validation import error_shape
def _ok(respond, payload: Any) -> None:
if callable(respond):
respond(True, payload, None, None)
def _bad(respond, message: str) -> None:
if callable(respond):
respond(False, None, error_shape("INVALID_REQUEST", message), None)
def _unavailable(respond, message: str) -> None:
if callable(respond):
respond(False, None, error_shape("UNAVAILABLE", message), None)
def _norm_str(v: Any) -> str | None:
if isinstance(v, str):
s = v.strip()
return s or None
return None
def _sessions(context: Any) -> dict[str, dict[str, Any]]:
if not isinstance(context, dict):
return {}
sessions = context.get("wizardSessions")
if isinstance(sessions, dict):
return sessions
created: dict[str, dict[str, Any]] = {}
context["wizardSessions"] = created
return created
def _find_running(context: Any) -> str | None:
for sid, session in _sessions(context).items():
if isinstance(session, dict) and session.get("status") == "running":
return sid
return None
def _wizard_start_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 wizard.start params")
return
mode = _norm_str(params.get("mode"))
if not mode:
_bad(respond, "invalid wizard.start params")
return
running = _find_running(context)
if running:
_unavailable(respond, "wizard already running")
return
session_id = str(uuid4())
session = {
"status": "running",
"error": None,
"mode": mode,
"workspace": _norm_str(params.get("workspace")),
"step": 0,
"history": [],
}
_sessions(context)[session_id] = session
_ok(
respond,
{
"sessionId": session_id,
"done": False,
"step": {"id": "step-1", "kind": "input", "prompt": "Provide first value"},
},
)
def _wizard_next_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 wizard.next params")
return
session_id = _norm_str(params.get("sessionId"))
if not session_id:
_bad(respond, "invalid wizard.next params")
return
session = _sessions(context).get(session_id)
if not isinstance(session, dict):
_bad(respond, "wizard not found")
return
answer = params.get("answer")
if answer is not None:
if session.get("status") != "running":
_bad(respond, "wizard not running")
return
session["history"].append(answer)
step = int(session.get("step") or 0) + 1
session["step"] = step
if step >= 2:
session["status"] = "done"
_sessions(context).pop(session_id, None)
_ok(respond, {"done": True, "result": {"ok": True}})
return
_ok(
respond,
{
"done": False,
"step": {"id": f"step-{step+1}", "kind": "input", "prompt": "Provide next value"},
},
)
def _wizard_cancel_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 wizard.cancel params")
return
session_id = _norm_str(params.get("sessionId"))
if not session_id:
_bad(respond, "invalid wizard.cancel params")
return
session = _sessions(context).get(session_id)
if not isinstance(session, dict):
_bad(respond, "wizard not found")
return
session["status"] = "cancelled"
_sessions(context).pop(session_id, None)
_ok(respond, {"status": "cancelled", "error": None})
def _wizard_status_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 wizard.status params")
return
session_id = _norm_str(params.get("sessionId"))
if not session_id:
_bad(respond, "invalid wizard.status params")
return
session = _sessions(context).get(session_id)
if not isinstance(session, dict):
_bad(respond, "wizard not found")
return
status = str(session.get("status") or "unknown")
out = {"status": status, "error": session.get("error")}
if status != "running":
_sessions(context).pop(session_id, None)
_ok(respond, out)
wizard_handlers: GatewayRequestHandlers = {
"wizard.start": _wizard_start_handler,
"wizard.next": _wizard_next_handler,
"wizard.cancel": _wizard_cancel_handler,
"wizard.status": _wizard_status_handler,
}