mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 07:20:44 +08:00
重构仓库目录为统一的 runtime 分层并清理历史 openclaw 残留。
本次迁移将网关/通道/工具/技能/脚本与协议资源集中到新结构,统一路径常量与脚本转发机制,减少顶层噪音并保证运行与测试行为一致。 Made-with: Cursor
This commit is contained in:
parent
ba3836f00f
commit
4a23b715a2
498 changed files with 2760 additions and 2200 deletions
49
interfaces/gateway/server_methods/README.md
Normal file
49
interfaces/gateway/server_methods/README.md
Normal 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` 等)。
|
||||
- 每批迁移后执行语法与单测校验,保证可持续推进。
|
||||
70
interfaces/gateway/server_methods/__init__.py
Normal file
70
interfaces/gateway/server_methods/__init__.py
Normal 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",
|
||||
]
|
||||
|
||||
153
interfaces/gateway/server_methods/agent.py
Normal file
153
interfaces/gateway/server_methods/agent.py
Normal 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,
|
||||
}
|
||||
250
interfaces/gateway/server_methods/agents.py
Normal file
250
interfaces/gateway/server_methods/agents.py
Normal 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,
|
||||
}
|
||||
|
||||
41
interfaces/gateway/server_methods/attachment_normalize.py
Normal file
41
interfaces/gateway/server_methods/attachment_normalize.py
Normal 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
|
||||
|
||||
14
interfaces/gateway/server_methods/base_hash.py
Normal file
14
interfaces/gateway/server_methods/base_hash.py
Normal 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
|
||||
|
||||
106
interfaces/gateway/server_methods/channels.py
Normal file
106
interfaces/gateway/server_methods/channels.py
Normal 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,
|
||||
}
|
||||
|
||||
152
interfaces/gateway/server_methods/chat.py
Normal file
152
interfaces/gateway/server_methods/chat.py
Normal 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,
|
||||
}
|
||||
62
interfaces/gateway/server_methods/commands.py
Normal file
62
interfaces/gateway/server_methods/commands.py
Normal 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,
|
||||
}
|
||||
169
interfaces/gateway/server_methods/config.py
Normal file
169
interfaces/gateway/server_methods/config.py
Normal 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,
|
||||
}
|
||||
|
||||
20
interfaces/gateway/server_methods/connect.py
Normal file
20
interfaces/gateway/server_methods/connect.py
Normal 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,
|
||||
}
|
||||
216
interfaces/gateway/server_methods/cron.py
Normal file
216
interfaces/gateway/server_methods/cron.py
Normal 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,
|
||||
}
|
||||
|
||||
281
interfaces/gateway/server_methods/devices.py
Normal file
281
interfaces/gateway/server_methods/devices.py
Normal 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,
|
||||
}
|
||||
|
||||
6
interfaces/gateway/server_methods/dispatcher.py
Normal file
6
interfaces/gateway/server_methods/dispatcher.py
Normal 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"]
|
||||
|
||||
86
interfaces/gateway/server_methods/doctor.py
Normal file
86
interfaces/gateway/server_methods/doctor.py
Normal 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,
|
||||
}
|
||||
|
||||
192
interfaces/gateway/server_methods/exec_approvals.py
Normal file
192
interfaces/gateway/server_methods/exec_approvals.py
Normal 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,
|
||||
}
|
||||
|
||||
60
interfaces/gateway/server_methods/health.py
Normal file
60
interfaces/gateway/server_methods/health.py
Normal 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,
|
||||
}
|
||||
|
||||
156
interfaces/gateway/server_methods/image.py
Normal file
156
interfaces/gateway/server_methods/image.py
Normal 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,
|
||||
}
|
||||
|
||||
63
interfaces/gateway/server_methods/logs.py
Normal file
63
interfaces/gateway/server_methods/logs.py
Normal 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,
|
||||
}
|
||||
|
||||
61
interfaces/gateway/server_methods/models.py
Normal file
61
interfaces/gateway/server_methods/models.py
Normal 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,
|
||||
}
|
||||
|
||||
128
interfaces/gateway/server_methods/models_auth_status.py
Normal file
128
interfaces/gateway/server_methods/models_auth_status.py
Normal 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,
|
||||
}
|
||||
|
||||
340
interfaces/gateway/server_methods/nodes.py
Normal file
340
interfaces/gateway/server_methods/nodes.py
Normal 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,
|
||||
}
|
||||
|
||||
158
interfaces/gateway/server_methods/nodes_pending.py
Normal file
158
interfaces/gateway/server_methods/nodes_pending.py
Normal 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,
|
||||
}
|
||||
|
||||
86
interfaces/gateway/server_methods/push.py
Normal file
86
interfaces/gateway/server_methods/push.py
Normal 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,
|
||||
}
|
||||
|
||||
15
interfaces/gateway/server_methods/record_shared.py
Normal file
15
interfaces/gateway/server_methods/record_shared.py
Normal 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
|
||||
|
||||
53
interfaces/gateway/server_methods/restart_request.py
Normal file
53
interfaces/gateway/server_methods/restart_request.py
Normal 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,
|
||||
}
|
||||
|
||||
266
interfaces/gateway/server_methods/send.py
Normal file
266
interfaces/gateway/server_methods/send.py
Normal 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,
|
||||
}
|
||||
|
||||
315
interfaces/gateway/server_methods/sessions.py
Normal file
315
interfaces/gateway/server_methods/sessions.py
Normal 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,
|
||||
}
|
||||
47
interfaces/gateway/server_methods/shared_types.py
Normal file
47
interfaces/gateway/server_methods/shared_types.py
Normal 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]
|
||||
273
interfaces/gateway/server_methods/skills.py
Normal file
273
interfaces/gateway/server_methods/skills.py
Normal 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,
|
||||
}
|
||||
243
interfaces/gateway/server_methods/system.py
Normal file
243
interfaces/gateway/server_methods/system.py
Normal 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,
|
||||
}
|
||||
|
||||
227
interfaces/gateway/server_methods/talk.py
Normal file
227
interfaces/gateway/server_methods/talk.py
Normal 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,
|
||||
}
|
||||
|
||||
41
interfaces/gateway/server_methods/telegram_send_normalize.py
Normal file
41
interfaces/gateway/server_methods/telegram_send_normalize.py
Normal 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
|
||||
117
interfaces/gateway/server_methods/tools_catalog.py
Normal file
117
interfaces/gateway/server_methods/tools_catalog.py
Normal 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,
|
||||
}
|
||||
|
||||
92
interfaces/gateway/server_methods/tools_effective.py
Normal file
92
interfaces/gateway/server_methods/tools_effective.py
Normal 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,
|
||||
}
|
||||
|
||||
183
interfaces/gateway/server_methods/tts.py
Normal file
183
interfaces/gateway/server_methods/tts.py
Normal 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,
|
||||
}
|
||||
|
||||
2
interfaces/gateway/server_methods/types.py
Normal file
2
interfaces/gateway/server_methods/types.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .shared_types import *
|
||||
|
||||
148
interfaces/gateway/server_methods/update.py
Normal file
148
interfaces/gateway/server_methods/update.py
Normal 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,
|
||||
}
|
||||
|
||||
268
interfaces/gateway/server_methods/usage.py
Normal file
268
interfaces/gateway/server_methods/usage.py
Normal 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,
|
||||
}
|
||||
|
||||
40
interfaces/gateway/server_methods/validation.py
Normal file
40
interfaces/gateway/server_methods/validation.py
Normal 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"]
|
||||
88
interfaces/gateway/server_methods/voicewake.py
Normal file
88
interfaces/gateway/server_methods/voicewake.py
Normal 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,
|
||||
}
|
||||
|
||||
120
interfaces/gateway/server_methods/web.py
Normal file
120
interfaces/gateway/server_methods/web.py
Normal 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,
|
||||
}
|
||||
|
||||
170
interfaces/gateway/server_methods/wizard.py
Normal file
170
interfaces/gateway/server_methods/wizard.py
Normal 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,
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue