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

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

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

View file

@ -0,0 +1,4 @@
from __future__ import annotations
__all__ = []

1532
interfaces/admin/chat_api.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,184 @@
"""Build dynamic MCP E2E probe plans from the live ToolRegistry (one tool use per MCP server_id)."""
from __future__ import annotations
from typing import Any
from oclaw.runtime.tools.base import ToolRegistry, ToolSpec
from oclaw.runtime.tools.tool_validation import validate_tool_arguments
def parse_mcp_bound_tool_name(full_name: str) -> tuple[str, str] | None:
"""Parse ``mcp__{server_id}__{mcp_tool}`` into (server_id, mcp_tool_name)."""
if not full_name.startswith("mcp__"):
return None
rest = full_name[len("mcp__") :]
idx = rest.find("__")
if idx < 0:
return None
server_id = rest[:idx].strip()
tool = rest[idx + 2 :].strip()
if not server_id or not tool:
return None
return server_id, tool
def _json_schema_required_keys(parameters: dict[str, Any]) -> list[str]:
if not isinstance(parameters, dict) or parameters.get("type") != "object":
return []
req = parameters.get("required")
if not isinstance(req, list):
return []
return [str(x).strip() for x in req if str(x).strip()]
def _fill_required_from_properties(
parameters: dict[str, Any],
*,
workspace_root: str,
) -> dict[str, Any] | None:
props = parameters.get("properties") if isinstance(parameters.get("properties"), dict) else {}
req = _json_schema_required_keys(parameters)
out: dict[str, Any] = {}
for key in req:
prop = props.get(key) if isinstance(props.get(key), dict) else {}
t = prop.get("type")
lk = key.lower()
if t == "string":
if lk in ("path", "cwd", "repopath", "directory", "filepath") or lk.endswith("path"):
out[key] = workspace_root
elif lk == "url":
out[key] = "https://example.com"
elif lk == "query":
out[key] = "model context protocol"
elif lk == "message":
out[key] = "mcp-e2e"
elif lk == "timezone":
out[key] = "UTC"
else:
out[key] = ""
elif t == "boolean":
out[key] = True if lk in ("includeuntracked", "includetracked") else False
elif t in ("number", "integer"):
out[key] = 0
elif t == "array":
out[key] = []
elif t == "object":
out[key] = {}
else:
return None
return out
# MCP tool names (suffix after server_id) with explicit args when schema is missing or validation needs concrete values.
_KNOWN_MCP_TOOL_ARGS: dict[str, dict[str, Any]] = {
"sequentialthinking": {
"thought": "e2e",
"nextThoughtNeeded": False,
"thoughtNumber": 1,
"totalThoughts": 1,
},
}
# Lower index = higher priority when multiple tools are callable.
_PROBE_PRIORITY: tuple[str, ...] = (
"browser_close",
"read_graph",
"db_info",
"list_pdfs",
"echo",
"list_directory",
"git_status",
"fetch_markdown",
"web_search",
"sequentialthinking",
)
def _probe_args_for_spec(spec: ToolSpec, *, workspace_root: str) -> dict[str, Any] | None:
parsed = parse_mcp_bound_tool_name(spec.name)
if not parsed:
return None
_, mcp_tool = parsed
params = spec.parameters if isinstance(spec.parameters, dict) else {}
if mcp_tool in _KNOWN_MCP_TOOL_ARGS:
args = dict(_KNOWN_MCP_TOOL_ARGS[mcp_tool])
ok, _ = validate_tool_arguments(params, args)
return args if ok else None
req = _json_schema_required_keys(params)
if not req:
args: dict[str, Any] = {}
ok, _ = validate_tool_arguments(params, args)
return args if ok else None
filled = _fill_required_from_properties(params, workspace_root=workspace_root)
if filled is None:
return None
ok, _ = validate_tool_arguments(params, filled)
return filled if ok else None
def _pick_probe_for_server(specs: list[ToolSpec], *, workspace_root: str) -> tuple[ToolSpec, dict[str, Any]] | None:
candidates: list[tuple[int, str, ToolSpec, dict[str, Any]]] = []
for sp in specs:
args = _probe_args_for_spec(sp, workspace_root=workspace_root)
if args is None:
continue
ok, _ = validate_tool_arguments(sp.parameters or {}, args)
if not ok:
continue
parsed = parse_mcp_bound_tool_name(sp.name)
mcp_tool = (parsed or ("", ""))[1]
try:
pri = _PROBE_PRIORITY.index(mcp_tool)
except ValueError:
pri = 900
candidates.append((pri, mcp_tool, sp, args))
if not candidates:
return None
candidates.sort(key=lambda x: (x[0], x[1], x[2].name))
return candidates[0][2], candidates[0][3]
def build_mcp_e2e_probe_plans(
reg: ToolRegistry,
*,
workspace_root: str,
) -> tuple[list[tuple[str, str, dict[str, Any]]], list[str]]:
"""
Returns (plans, skipped_server_ids).
``plans`` entries are ``(server_id, full_tool_name, arguments)`` for ``ToolExecutor.execute_tool_uses``.
One probe per MCP ``server_id`` discovered from registry names ``mcp__*__*`` with tag ``mcp``.
``skipped_server_ids`` lists servers that had MCP tools in the registry but no schema-safe probe
could be constructed (no false positives from arbitrary ``tools/call``).
"""
by_server: dict[str, list[ToolSpec]] = {}
for spec in reg.list():
if "mcp" not in (spec.tags or frozenset()):
continue
parsed = parse_mcp_bound_tool_name(spec.name)
if not parsed:
continue
sid, _ = parsed
by_server.setdefault(sid, []).append(spec)
plans: list[tuple[str, str, dict[str, Any]]] = []
for sid in sorted(by_server.keys()):
picked = _pick_probe_for_server(by_server[sid], workspace_root=workspace_root)
if picked is None:
continue
spec, args = picked
plans.append((sid, spec.name, args))
planned = {p[0] for p in plans}
skipped = [s for s in sorted(by_server.keys()) if s not in planned]
return plans, skipped
__all__ = [
"build_mcp_e2e_probe_plans",
"parse_mcp_bound_tool_name",
]

View file

@ -0,0 +1,561 @@
"""Admin API: LLM profiles, agent bindings, UI language, eval (parity with Streamlit settings)."""
from __future__ import annotations
import csv
import io
import json
import os
from collections.abc import Callable
from typing import Any
from fastapi import APIRouter, Body, Header, HTTPException, Query
from fastapi.responses import Response
from oclaw.runtime.agents.factory import DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_MODEL
from oclaw.runtime.agents.specialists import (
AGENT_PROFILE_BINDINGS_KEY,
AGENT_ROLE_IDS,
dump_agent_profile_bindings,
parse_agent_profile_bindings,
)
from oclaw.platform.config.paths import db_path
from oclaw.runtime.orchestration.evaluation import eval_summary
from oclaw.platform.persistence.sqlite_store import (
LLM_BUILTIN_OLLAMA_PROFILE_ID,
SqliteStore,
active_llm_profile_setting_key,
agent_profile_bindings_setting_key,
is_administrator_model_pool,
)
_LLM_MODE_OPTIONS = frozenset({"openai", "openai_responses", "anthropic", "google", "ollama", "rule"})
_LLM_USER_CREATE_MODES = frozenset({"openai", "anthropic", "google", "ollama", "rule"})
def _require_permission(ctx: dict[str, Any], permission: str) -> None:
perms = set(str(x) for x in (ctx.get("permissions") or []))
if permission in perms:
return
if str(ctx.get("role") or "") == "owner":
return
raise HTTPException(status_code=403, detail=f"forbidden:{permission}")
def _require_models_mutate(ctx: dict[str, Any]) -> None:
"""administrator 编辑全局池需 tenant:write;其余用户编辑自己的复制池仅需 read。"""
uname = str(ctx.get("username") or "").strip()
if is_administrator_model_pool(uname):
_require_permission(ctx, "admin:tenant:write")
else:
_require_permission(ctx, "admin:read")
def _models_list_kwargs(ctx: dict[str, Any]) -> dict[str, Any]:
uid = str(ctx.get("user_id") or "").strip()
uname = str(ctx.get("username") or "").strip()
tid = str(ctx.get("tenant_id") or "").strip()
if not uid:
return {}
out: dict[str, Any] = {"viewer_user_id": uid, "viewer_username": uname or None}
if tid:
out["viewer_tenant_id"] = tid
return out
def _active_key(ctx: dict[str, Any]) -> str:
uid = str(ctx.get("user_id") or "").strip()
uname = str(ctx.get("username") or "").strip()
if not uid:
return "active_llm_profile_id"
return active_llm_profile_setting_key(uid, uname or None)
def _bindings_key(ctx: dict[str, Any]) -> str:
uid = str(ctx.get("user_id") or "").strip()
uname = str(ctx.get("username") or "").strip()
if not uid:
return AGENT_PROFILE_BINDINGS_KEY
return agent_profile_bindings_setting_key(uid, uname or None)
def _assert_profile_mutable(ctx: dict[str, Any], prof: dict[str, Any] | None) -> dict[str, Any]:
if not prof:
raise HTTPException(status_code=404, detail="profile_not_found")
if prof.get("is_builtin"):
return prof
own = str(prof.get("owner_user_id") or "").strip()
uid = str(ctx.get("user_id") or "").strip()
uname = str(ctx.get("username") or "").strip()
if is_administrator_model_pool(uname):
return prof
if own == uid:
return prof
raise HTTPException(status_code=403, detail="profile_forbidden")
def _can_manage_llm_grants(ctx: dict[str, Any]) -> bool:
uname = str(ctx.get("username") or "").strip()
if not is_administrator_model_pool(uname):
return False
perms = set(str(x) for x in (ctx.get("permissions") or []))
if "admin:tenant:write" in perms:
return True
return str(ctx.get("role") or "") == "owner"
def _require_grant_manager(ctx: dict[str, Any]) -> None:
if not _can_manage_llm_grants(ctx):
raise HTTPException(status_code=403, detail="grants_administrator_only")
def _profile_shareable_for_admin_grant(ctx: dict[str, Any], prof: dict[str, Any] | None) -> bool:
"""仅全局池(无 owner)或操作者本人名下的 profile 可被授权给团队/用户。"""
if not prof or prof.get("is_builtin"):
return False
own = str(prof.get("owner_user_id") or "").strip()
uid = str(ctx.get("user_id") or "").strip()
if not own:
return True
return bool(uid) and own == uid
def _normalize_active(
store: SqliteStore, profiles: list[dict[str, Any]], profile_ids: list[str], ctx: dict[str, Any]
) -> str:
if not profile_ids:
return ""
key = _active_key(ctx)
active_id = str(store.get_setting(key) or "").strip()
if active_id not in profile_ids:
store.set_setting(key, LLM_BUILTIN_OLLAMA_PROFILE_ID)
active_id = LLM_BUILTIN_OLLAMA_PROFILE_ID
if active_id not in profile_ids:
active_id = profile_ids[0]
store.set_setting(key, active_id)
return active_id
def include_model_mgmt_routes(
router: APIRouter,
*,
resolve_auth: Callable[[SqliteStore, str | None], dict[str, Any]],
) -> None:
mg = APIRouter(prefix="/admin/api/models", tags=["models"])
@mg.get("")
def api_models_state(
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_permission(ctx, "admin:read")
uid = str(ctx.get("user_id") or "").strip()
uname = str(ctx.get("username") or "").strip()
lk = _models_list_kwargs(ctx)
profiles = store.list_llm_profiles(visible_only=True, **lk)
profile_ids = [str(p["id"]) for p in profiles]
active_id = _normalize_active(store, profiles, profile_ids, ctx)
bindings = parse_agent_profile_bindings(store.get_setting(_bindings_key(ctx)))
ui_lang = str(store.get_setting("ui_lang") or "zh").strip().lower()
if ui_lang not in ("zh", "en"):
ui_lang = "zh"
secret = ""
if active_id and active_id in profile_ids:
active_prof = next((p for p in profiles if str(p.get("id") or "") == active_id), None)
if is_administrator_model_pool(uname) or (active_prof and active_prof.get("mutable", True)):
secret = store.get_llm_profile_secret(active_id) or ""
out: dict[str, Any] = {
"ok": True,
"active_llm_profile_id": active_id,
"profiles": profiles,
"bindings": bindings,
"ui_lang": ui_lang,
"builtin_ollama_profile_id": LLM_BUILTIN_OLLAMA_PROFILE_ID,
"has_openai_api_key_env": bool((os.getenv("OPENAI_API_KEY") or "").strip()),
"role_ids": list(AGENT_ROLE_IDS),
"profile_secret": secret,
"can_manage_llm_grants": _can_manage_llm_grants(ctx),
# 便于核对「浏览器连的是哪台网关、网关读的是哪个库文件」
"db_path": db_path(),
}
return out
@mg.post("/active")
def api_models_set_active(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
# 与能进入控制台一致:切换「当前选用」不写密钥,仅需读权限即可。
_require_permission(ctx, "admin:read")
profiles = store.list_llm_profiles(visible_only=True, **_models_list_kwargs(ctx))
profile_ids = [str(p["id"]) for p in profiles]
pid = str(payload.get("profile_id") or "").strip()
if pid not in profile_ids:
raise HTTPException(status_code=400, detail="invalid_profile_id")
store.set_setting(_active_key(ctx), pid)
return {"ok": True, "active_llm_profile_id": pid}
@mg.post("/bindings")
def api_models_set_bindings(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_models_mutate(ctx)
profiles = store.list_llm_profiles(visible_only=True, **_models_list_kwargs(ctx))
profile_ids = set(str(p["id"]) for p in profiles)
raw = payload.get("bindings")
if not isinstance(raw, dict):
raise HTTPException(status_code=400, detail="bindings_object_required")
cur = parse_agent_profile_bindings(store.get_setting(_bindings_key(ctx)))
for rid in AGENT_ROLE_IDS:
v = raw.get(rid)
if v is None:
continue
s = str(v).strip()
if s and s not in profile_ids:
raise HTTPException(status_code=400, detail=f"invalid_binding:{rid}")
cur[rid] = s
store.set_setting(_bindings_key(ctx), dump_agent_profile_bindings(cur))
return {"ok": True, "bindings": cur}
@mg.post("/profiles")
def api_models_create_profile(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_models_mutate(ctx)
name = str(payload.get("name") or "").strip() or "新配置"
mode = str(payload.get("mode") or "openai").strip().lower()
if mode not in _LLM_USER_CREATE_MODES:
raise HTTPException(status_code=400, detail="invalid_mode")
if mode == "openai":
new_model = "gpt-4o-mini"
new_bu = ""
else:
new_model = DEFAULT_OLLAMA_MODEL
new_bu = DEFAULT_OLLAMA_BASE_URL
own: str | None = None
uid = str(ctx.get("user_id") or "").strip()
uname = str(ctx.get("username") or "").strip()
if uid and not is_administrator_model_pool(uname):
own = uid
pid = store.create_llm_profile(name=name, mode=mode, model=new_model, base_url=new_bu or None, owner_user_id=own)
store.set_setting(_active_key(ctx), pid)
prof = store.get_llm_profile(pid)
return {"ok": True, "profile_id": pid, "profile": prof}
@mg.patch("/profiles/{profile_id}")
def api_models_patch_profile(
profile_id: str,
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_models_mutate(ctx)
pid = str(profile_id or "").strip()
prof = _assert_profile_mutable(ctx, store.get_llm_profile(pid))
name = str(payload.get("name") if payload.get("name") is not None else prof.get("name") or "").strip() or "未命名"
mode_raw = str(payload.get("mode") if payload.get("mode") is not None else prof.get("mode") or "openai").strip().lower()
if pid == LLM_BUILTIN_OLLAMA_PROFILE_ID:
mode_save = "ollama"
else:
if mode_raw not in _LLM_MODE_OPTIONS:
raise HTTPException(status_code=400, detail="invalid_mode")
mode_save = mode_raw
model = payload.get("model")
base_url = payload.get("base_url")
model_s = str(model).strip() if model is not None else str(prof.get("model") or "").strip()
bu_s = str(base_url).strip() if base_url is not None else str(prof.get("base_url") or "").strip()
store.update_llm_profile(
profile_id=pid,
name=name,
mode=mode_save,
model=model_s or None,
base_url=bu_s or None,
)
store.set_setting(_active_key(ctx), pid)
return {"ok": True, "profile": store.get_llm_profile(pid)}
@mg.post("/profiles/{profile_id}/secret")
def api_models_profile_secret(
profile_id: str,
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_models_mutate(ctx)
pid = str(profile_id or "").strip()
prof = _assert_profile_mutable(ctx, store.get_llm_profile(pid))
remember = bool(payload.get("remember"))
key_text = str(payload.get("secret") or "").strip()
mode_save = str(prof.get("mode") or "openai").strip().lower()
if pid == LLM_BUILTIN_OLLAMA_PROFILE_ID:
mode_save = "ollama"
if mode_save in ("openai", "ollama"):
if remember:
if key_text:
store.set_llm_profile_secret(pid, key_text)
elif mode_save == "openai":
raise HTTPException(status_code=400, detail="remember_key_empty")
else:
store.clear_llm_profile_secret(pid)
else:
store.clear_llm_profile_secret(pid)
return {"ok": True, "profile": store.get_llm_profile(pid)}
@mg.delete("/profiles/{profile_id}")
def api_models_delete_profile(
profile_id: str,
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_models_mutate(ctx)
pid = str(profile_id or "").strip()
_assert_profile_mutable(ctx, store.get_llm_profile(pid))
try:
store.delete_llm_profile(pid)
except ValueError:
raise HTTPException(status_code=400, detail="cannot_delete_builtin")
remaining = store.list_llm_profiles(visible_only=True, **_models_list_kwargs(ctx))
new_active = remaining[0]["id"] if remaining else LLM_BUILTIN_OLLAMA_PROFILE_ID
store.set_setting(_active_key(ctx), new_active)
return {"ok": True, "active_llm_profile_id": new_active}
@mg.get("/members")
def api_models_members(
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_grant_manager(ctx)
tid = str(ctx.get("tenant_id") or "").strip()
if not tid:
raise HTTPException(status_code=400, detail="tenant_required")
users = store.list_users(tenant_id=tid, limit=500, offset=0, include_inactive=True)
members: list[dict[str, Any]] = []
for u in users:
mid = str(u.get("id") or "").strip()
un = str(u.get("username") or "").strip()
if not mid:
continue
profs = store.list_llm_profiles(
visible_only=True,
viewer_user_id=mid,
viewer_username=un or None,
viewer_tenant_id=tid,
)
members.append(
{
"user_id": mid,
"username": un,
"display_name": str(u.get("display_name") or "").strip(),
"role": str(u.get("role") or "").strip(),
"profiles": profs,
}
)
return {"ok": True, "members": members}
@mg.get("/grants/tenant")
def api_models_grants_tenant_get(
profile_id: str = Query(...),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_grant_manager(ctx)
tid = str(ctx.get("tenant_id") or "").strip()
pid = str(profile_id or "").strip()
if not tid or not pid:
raise HTTPException(status_code=400, detail="tenant_or_profile_required")
granted = store.tenant_has_llm_profile_grant(tid, pid)
return {"ok": True, "granted": granted}
@mg.post("/grants/tenant")
def api_models_grants_tenant_create(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_grant_manager(ctx)
tid = str(ctx.get("tenant_id") or "").strip()
pid = str(payload.get("profile_id") or "").strip()
if not tid or not pid:
raise HTTPException(status_code=400, detail="profile_id_required")
prof = store.get_llm_profile(pid)
if not _profile_shareable_for_admin_grant(ctx, prof):
raise HTTPException(status_code=403, detail="profile_not_shareable")
actor = str(ctx.get("user_id") or "").strip() or None
try:
gid = store.grant_llm_profile_to_tenant(
tenant_id=tid, profile_id=pid, created_by_user_id=actor
)
except ValueError as e:
code = str(e)
if code == "profile_not_found":
raise HTTPException(status_code=404, detail=code) from e
raise HTTPException(status_code=400, detail=code) from e
return {"ok": True, "grant_id": gid}
@mg.delete("/grants/tenant")
def api_models_grants_tenant_revoke(
profile_id: str = Query(...),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_grant_manager(ctx)
tid = str(ctx.get("tenant_id") or "").strip()
pid = str(profile_id or "").strip()
if not tid or not pid:
raise HTTPException(status_code=400, detail="profile_id_required")
n = store.revoke_llm_profile_tenant_grant(tenant_id=tid, profile_id=pid)
return {"ok": True, "removed": int(n)}
@mg.get("/grants")
def api_models_grants_list(
profile_id: str = Query(..., description="llm_profile id"),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_grant_manager(ctx)
tid = str(ctx.get("tenant_id") or "").strip()
pid = str(profile_id or "").strip()
if not tid or not pid:
raise HTTPException(status_code=400, detail="tenant_or_profile_required")
rows = store.list_llm_profile_grants_for_profile(tid, pid)
return {"ok": True, "grants": rows}
@mg.post("/grants")
def api_models_grants_create(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_grant_manager(ctx)
tid = str(ctx.get("tenant_id") or "").strip()
pid = str(payload.get("profile_id") or "").strip()
uid = str(payload.get("user_id") or "").strip()
if not tid or not pid or not uid:
raise HTTPException(status_code=400, detail="profile_id_and_user_id_required")
prof = store.get_llm_profile(pid)
if not _profile_shareable_for_admin_grant(ctx, prof):
raise HTTPException(status_code=403, detail="profile_not_shareable")
actor = str(ctx.get("user_id") or "").strip() or None
try:
gid = store.grant_llm_profile_to_user(
tenant_id=tid,
profile_id=pid,
user_id=uid,
created_by_user_id=actor,
)
except ValueError as e:
code = str(e)
if code == "profile_not_found":
raise HTTPException(status_code=404, detail=code) from e
if code == "user_not_found":
raise HTTPException(status_code=404, detail=code) from e
raise HTTPException(status_code=400, detail=code) from e
return {"ok": True, "grant_id": gid}
@mg.delete("/grants")
def api_models_grants_revoke(
profile_id: str = Query(...),
user_id: str = Query(...),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_grant_manager(ctx)
tid = str(ctx.get("tenant_id") or "").strip()
pid = str(profile_id or "").strip()
uid = str(user_id or "").strip()
if not tid or not pid or not uid:
raise HTTPException(status_code=400, detail="profile_id_and_user_id_required")
n = store.revoke_llm_profile_grant(tenant_id=tid, profile_id=pid, user_id=uid)
return {"ok": True, "removed": int(n)}
@mg.get("/eval")
def api_models_eval(
authorization: str | None = Header(default=None),
limit_logs: int = Query(default=100, ge=1, le=500),
limit_summary: int = Query(default=500, ge=1, le=5000),
) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_permission(ctx, "admin:read")
summary = eval_summary(store, limit=limit_summary)
logs = store.list_agent_eval_logs(limit=limit_logs)
return {"ok": True, "summary": summary, "logs": logs}
_EVAL_EXPORT_FIELDS = (
"timestamp",
"session_id",
"specialist",
"task_kind",
"success",
"latency_ms",
"cost_hint",
"notes",
)
@mg.get("/eval/export")
def api_models_eval_export(
authorization: str | None = Header(default=None),
format: str = Query(default="csv", description="csv or json"),
limit: int = Query(default=100_000, ge=1, le=200_000),
) -> Response:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_permission(ctx, "admin:read")
fmt = str(format or "csv").strip().lower()
rows = store.list_agent_eval_logs(limit=limit)
if fmt == "json":
body = json.dumps(rows, ensure_ascii=False, indent=2)
return Response(
content=body.encode("utf-8"),
media_type="application/json; charset=utf-8",
headers={
"Content-Disposition": 'attachment; filename="agent_eval_logs.json"',
},
)
if fmt != "csv":
raise HTTPException(status_code=400, detail="invalid_format")
buf = io.StringIO()
w = csv.DictWriter(buf, fieldnames=list(_EVAL_EXPORT_FIELDS), extrasaction="ignore")
w.writeheader()
for r in rows:
row = {k: r.get(k) for k in _EVAL_EXPORT_FIELDS}
if "success" in row:
row["success"] = 1 if bool(row.get("success")) else 0
w.writerow(row)
payload = "\ufeff" + buf.getvalue()
return Response(
content=payload.encode("utf-8"),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": 'attachment; filename="agent_eval_logs.csv"'},
)
router.include_router(mg)
__all__ = ["include_model_mgmt_routes"]

3069
interfaces/admin/routes.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,604 @@
from __future__ import annotations
import json
from collections.abc import Callable
from typing import Any
from fastapi import APIRouter, Body, Header, HTTPException
from oclaw.runtime.agents.factory import build_gateway_executor
from oclaw.runtime.skill_installer import (
auto_install_skill_from_payload,
create_skill_from_template,
install_skill_from_local_dir,
install_skill_from_registry_archive,
list_skills_with_status,
set_skill_enabled,
)
from oclaw.runtime.skill_role_binding import (
SKILL_ROLE_BINDING_ENABLED_SETTING,
SKILL_ROLE_BINDING_KEY,
load_skill_role_binding_dict,
normalize_skill_role_binding,
ordered_binding_roles,
skill_role_binding_enabled,
)
from oclaw.runtime.skills_prompt import collect_skill_catalog_entries
from oclaw.runtime.skills import _allowed_tool_names_after_wire_policy, discover_workspace_skill_manifests
from oclaw.platform.config.paths import db_path
from oclaw.platform.persistence.sqlite_store import SqliteStore
from oclaw.runtime.tools.skills.clawhub_client import get_skill_detail as clawhub_get_skill_detail
from oclaw.runtime.tools.skills.clawhub_client import search_skills as clawhub_search_skills
def include_skill_routes(
router: APIRouter,
*,
resolve_auth: Callable[[SqliteStore, str | None], dict[str, Any]],
) -> None:
sk = APIRouter(prefix="/admin/api/skills", tags=["skills"])
def _require_admin(ctx: dict[str, Any]) -> None:
perms = set(str(x) for x in (ctx.get("permissions") or []))
if "admin:read" in perms or str(ctx.get("role") or "") == "owner":
return
raise HTTPException(status_code=403, detail="forbidden:admin:read")
def _require_tenant_write(ctx: dict[str, Any]) -> None:
perms = set(str(x) for x in (ctx.get("permissions") or []))
if "admin:tenant:write" in perms or str(ctx.get("role") or "") == "owner":
return
raise HTTPException(status_code=403, detail="forbidden:admin:tenant:write")
def _audit(store: SqliteStore, ctx: dict[str, Any], *, action: str, target_id: str, status: str, detail: dict[str, Any] | None = None) -> None:
try:
store.add_admin_audit_log(
actor_tenant_id=str(ctx.get("tenant_id") or ""),
actor_user_id=str(ctx.get("user_id") or ""),
action=action,
target_type="skill",
target_id=str(target_id or ""),
status=status,
detail=detail or {},
)
except Exception:
pass
def _normalized_skill_binding(store: SqliteStore) -> tuple[list[str], dict[str, list[str]], set[str]]:
roles = ordered_binding_roles()
valid = {str(m.name).strip() for m in discover_workspace_skill_manifests() if str(m.name or "").strip()}
mapping = normalize_skill_role_binding(
mapping_raw=load_skill_role_binding_dict(store),
valid_skill_names=valid,
available_roles=roles,
)
return roles, mapping, valid
@sk.get("")
def api_skills_list(authorization: str | None = Header(default=None)) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
items = list_skills_with_status(store=store)
return {"ok": True, "items": items}
@sk.post("/install")
def api_skills_install(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
source_dir = str(payload.get("source_dir") or "").strip()
if not source_dir:
raise HTTPException(status_code=400, detail="source_dir_required")
overwrite = bool(payload.get("overwrite"))
_audit(store, ctx, action="skill_install_started", target_id=source_dir, status="start", detail={"source": "local"})
out = install_skill_from_local_dir(store=store, source_dir=source_dir, overwrite=overwrite)
_audit(
store,
ctx,
action="skill_install_finished" if out.ok else "skill_install_failed",
target_id=out.name or source_dir,
status="ok" if out.ok else "fail",
detail={"detail": out.detail, "target_dir": out.target_dir, "source": "local", "input_target": source_dir},
)
return {
"ok": bool(out.ok),
"result": {
"name": out.name,
"target_dir": out.target_dir,
"detail": out.detail,
"error_code": out.error_code,
"retryable": bool(out.retryable),
},
}
@sk.post("/install-registry")
def api_skills_install_registry(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
archive_url = str(payload.get("archive_url") or "").strip()
if not archive_url:
raise HTTPException(status_code=400, detail="archive_url_required")
overwrite = bool(payload.get("overwrite"))
_audit(store, ctx, action="skill_install_started", target_id=archive_url, status="start", detail={"source": "registry"})
out = install_skill_from_registry_archive(store=store, archive_url=archive_url, overwrite=overwrite)
_audit(
store,
ctx,
action="skill_install_finished" if out.ok else "skill_install_failed",
target_id=out.name or archive_url,
status="ok" if out.ok else "fail",
detail={"detail": out.detail, "target_dir": out.target_dir, "source": "registry", "input_target": archive_url},
)
return {
"ok": bool(out.ok),
"result": {
"name": out.name,
"target_dir": out.target_dir,
"detail": out.detail,
"error_code": out.error_code,
"retryable": bool(out.retryable),
},
}
@sk.get("/market/search")
def api_skills_market_search(
q: str | None = None,
limit: int | None = None,
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
query = str(q or "").strip()
lim = int(limit) if isinstance(limit, int) and limit > 0 else 20
lim = max(1, min(lim, 200))
items = clawhub_search_skills(query, limit=lim)
return {"ok": True, "items": items}
@sk.get("/market/detail")
def api_skills_market_detail(
slug: str | None = None,
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
s = str(slug or "").strip()
if not s:
raise HTTPException(status_code=400, detail="slug_required")
detail = clawhub_get_skill_detail(s)
return {"ok": True, "detail": detail}
@sk.post("/market/install")
def api_skills_market_install(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
s = str(payload.get("slug") or "").strip()
if not s:
raise HTTPException(status_code=400, detail="slug_required")
requested_version = str(payload.get("version") or "").strip()
overwrite = bool(payload.get("overwrite"))
detail = clawhub_get_skill_detail(s)
archive_url = str(detail.get("archiveUrl") or "").strip()
chosen_version = str(detail.get("latestVersion") or "").strip()
if requested_version:
chosen_version = requested_version
archive_url = ""
for v in (detail.get("versions") or []):
if not isinstance(v, dict):
continue
if str(v.get("version") or "").strip() == requested_version:
archive_url = str(v.get("archiveUrl") or "").strip()
break
if not archive_url:
raise HTTPException(status_code=400, detail="archive_url_unavailable")
_audit(
store,
ctx,
action="skill_install_started",
target_id=archive_url,
status="start",
detail={"source": "clawhub", "slug": s, "version": chosen_version, "input_target": s},
)
out = install_skill_from_registry_archive(store=store, archive_url=archive_url, overwrite=overwrite)
_audit(
store,
ctx,
action="skill_install_finished" if out.ok else "skill_install_failed",
target_id=out.name or archive_url,
status="ok" if out.ok else "fail",
detail={
"detail": out.detail,
"target_dir": out.target_dir,
"source": "clawhub",
"slug": s,
"version": chosen_version,
"input_target": archive_url,
},
)
return {
"ok": bool(out.ok),
"result": {
"name": out.name,
"target_dir": out.target_dir,
"detail": out.detail,
"error_code": out.error_code,
"retryable": bool(out.retryable),
},
}
@sk.get("/binding")
def api_skills_binding_get(authorization: str | None = Header(default=None)) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_tenant_write(ctx)
roles, mapping, _valid = _normalized_skill_binding(store)
items = list_skills_with_status(store=store)
return {
"ok": True,
"enabled": bool(skill_role_binding_enabled(store=store)),
"available_roles": roles,
"installed_skills": items,
"mapping": mapping,
}
@sk.post("/binding")
def api_skills_binding_save(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_tenant_write(ctx)
if "enabled" in payload:
store.set_setting(SKILL_ROLE_BINDING_ENABLED_SETTING, "1" if bool(payload.get("enabled")) else "0")
roles, _prev_mapping, valid = _normalized_skill_binding(store)
mapping_raw = payload.get("mapping") if isinstance(payload.get("mapping"), dict) else {}
mapping = normalize_skill_role_binding(
mapping_raw=mapping_raw,
valid_skill_names=valid,
available_roles=roles,
)
store.set_setting(SKILL_ROLE_BINDING_KEY, json.dumps(mapping, ensure_ascii=False))
_audit(
store,
ctx,
action="skill_binding_update",
target_id="skill_role_binding",
status="ok",
detail={"mapping": mapping, "enabled": bool(skill_role_binding_enabled(store=store))},
)
return {
"ok": True,
"enabled": bool(skill_role_binding_enabled(store=store)),
"available_roles": roles,
"mapping": mapping,
}
@sk.get("/effective")
def api_skills_effective(authorization: str | None = Header(default=None)) -> dict[str, Any]:
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
roles, mapping, _valid = _normalized_skill_binding(store)
role_rows: list[dict[str, Any]] = []
for role in roles:
specialist = "generalist" if role == "manager" else role
ex = build_gateway_executor(store=store, specialist=specialist)
tools = getattr(ex, "tools", None)
model = getattr(ex, "model", None)
if tools is None:
role_rows.append(
{
"role": role,
"total": 0,
"workspace_total": 0,
"workspace_direct": 0,
"workspace_inherited_manager": 0,
"mcp_total": 0,
"tool_total": 0,
"names_preview": [],
}
)
continue
base_url = str(getattr(model, "base_url", "") or "")
entries = collect_skill_catalog_entries(
store=store,
registry=tools,
base_url=base_url,
skill_binding_role=role,
)
allowed_tool_names, _hidden_tool_names = _allowed_tool_names_after_wire_policy(
registry=tools,
store=store,
base_url=base_url,
)
direct_set = set(mapping.get(role) or [])
manager_set = set(mapping.get("manager") or [])
workspace_total = 0
workspace_direct = 0
workspace_inherited = 0
workspace_resolved_tool_match = 0
workspace_docs_only = 0
mcp_total = 0
tool_total = 0
names: list[str] = []
docs_only_names: list[str] = []
resolved_workspace_names: list[str] = []
for nm, _desc, loc in entries:
names.append(str(nm))
vloc = str(loc or "")
if vloc.endswith("SKILL.md"):
workspace_total += 1
if nm in allowed_tool_names:
workspace_resolved_tool_match += 1
resolved_workspace_names.append(str(nm))
else:
workspace_docs_only += 1
docs_only_names.append(str(nm))
if nm in direct_set:
workspace_direct += 1
elif role != "manager" and nm in manager_set:
workspace_inherited += 1
elif str(nm).startswith("mcp__"):
mcp_total += 1
else:
tool_total += 1
role_rows.append(
{
"role": role,
"total": len(entries),
"workspace_total": workspace_total,
"workspace_direct": workspace_direct,
"workspace_inherited_manager": workspace_inherited,
"workspace_resolved_tool_match": workspace_resolved_tool_match,
"workspace_docs_only": workspace_docs_only,
"mcp_total": mcp_total,
"tool_total": tool_total,
"names_preview": names[:20],
"docs_only_names_preview": docs_only_names[:20],
"resolved_workspace_names_preview": resolved_workspace_names[:20],
}
)
return {"ok": True, "enabled": bool(skill_role_binding_enabled(store=store)), "items": role_rows}
@sk.post("/create")
def api_skills_create(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
name = str(payload.get("name") or "").strip()
desc = str(payload.get("description") or "").strip()
body = str(payload.get("body_markdown") or "").strip()
md = payload.get("metadata_oclaw")
md = dict(md) if isinstance(md, dict) else {}
overwrite = bool(payload.get("overwrite"))
out = create_skill_from_template(
store=store,
name=name,
description=desc,
body_markdown=body,
metadata_oclaw=md,
overwrite=overwrite,
)
_audit(
store,
ctx,
action="skill_create",
target_id=out.name or name,
status="ok" if out.ok else "fail",
detail={"detail": out.detail, "target_dir": out.target_dir},
)
return {
"ok": bool(out.ok),
"result": {
"name": out.name,
"target_dir": out.target_dir,
"detail": out.detail,
"error_code": out.error_code,
"retryable": bool(out.retryable),
},
}
@sk.post("/enable")
def api_skills_enable(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
name = str(payload.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name_required")
set_skill_enabled(store=store, skill_name=name, enabled=True)
_audit(store, ctx, action="skill_enable", target_id=name, status="ok")
return {"ok": True}
@sk.post("/disable")
def api_skills_disable(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
name = str(payload.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name_required")
set_skill_enabled(store=store, skill_name=name, enabled=False)
_audit(store, ctx, action="skill_disable", target_id=name, status="ok")
return {"ok": True}
@sk.post("/auto-install")
def api_skills_auto_install(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
auto_name = str(payload.get("name") or "")
_audit(
store,
ctx,
action="skill_install_started",
target_id=auto_name,
status="start",
detail={"source": "auto", "input_target": auto_name},
)
out = auto_install_skill_from_payload(store=store, payload=payload)
_audit(
store,
ctx,
action="skill_install_finished" if out.ok else "skill_install_failed",
target_id=out.name or str(payload.get("name") or ""),
status="ok" if out.ok else "fail",
detail={"detail": out.detail, "target_dir": out.target_dir, "source": "auto", "input_target": auto_name},
)
return {
"ok": bool(out.ok),
"result": {
"name": out.name,
"target_dir": out.target_dir,
"detail": out.detail,
"error_code": out.error_code,
"retryable": bool(out.retryable),
},
}
@sk.post("/retry-install")
def api_skills_retry_install(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
source = str(payload.get("source") or "").strip().lower()
target = str(payload.get("target") or "").strip()
if source not in {"local", "registry", "auto"}:
raise HTTPException(status_code=400, detail="invalid_source")
if not target:
raise HTTPException(status_code=400, detail="target_required")
_audit(
store,
ctx,
action="skill_install_started",
target_id=target,
status="start",
detail={"source": source, "retry": True, "input_target": target},
)
if source == "local":
out = install_skill_from_local_dir(store=store, source_dir=target, overwrite=True)
elif source == "registry":
out = install_skill_from_registry_archive(store=store, archive_url=target, overwrite=True)
else:
out = auto_install_skill_from_payload(
store=store,
payload={
"name": str(payload.get("name") or "").strip() or target,
"description": str(payload.get("description") or "retry auto install"),
"body_markdown": str(payload.get("body_markdown") or ""),
"metadata_oclaw": dict(payload.get("metadata_oclaw") or {})
if isinstance(payload.get("metadata_oclaw"), dict)
else {},
},
)
_audit(
store,
ctx,
action="skill_install_finished" if out.ok else "skill_install_failed",
target_id=out.name or target,
status="ok" if out.ok else "fail",
detail={
"detail": out.detail,
"target_dir": out.target_dir,
"source": source,
"retry": True,
"input_target": target,
},
)
return {
"ok": bool(out.ok),
"result": {
"name": out.name,
"target_dir": out.target_dir,
"detail": out.detail,
"error_code": out.error_code,
"retryable": bool(out.retryable),
},
}
@sk.post("/test-run")
def api_skills_test_run(
payload: dict[str, Any] | None = Body(default=None),
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
payload = payload or {}
store = SqliteStore(db_path())
ctx = resolve_auth(store, authorization)
_require_admin(ctx)
name = str(payload.get("name") or "").strip()
args = payload.get("args")
if not name:
raise HTTPException(status_code=400, detail="name_required")
if args is None:
args = {}
if not isinstance(args, dict):
raise HTTPException(status_code=400, detail="args_must_be_object")
ex = build_gateway_executor(store=store, specialist="generalist")
tools = getattr(ex, "tools", None)
if tools is None:
raise HTTPException(status_code=500, detail="tool_registry_unavailable")
spec = tools.get(name)
if spec is None:
raise HTTPException(status_code=404, detail="tool_not_found")
try:
result = spec.handler(dict(args))
except Exception as exc:
result = {"ok": False, "error_code": "exception", "error": f"{type(exc).__name__}:{exc}"}
_audit(
store,
ctx,
action="skill_test_run",
target_id=name,
status="ok" if bool((result or {}).get("ok")) else "fail",
detail={"args_keys": list(args.keys()), "result_ok": bool((result or {}).get("ok"))},
)
return {"ok": True, "name": name, "result": result}
router.include_router(sk)
__all__ = ["include_skill_routes"]

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,455 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Chat</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@500;600&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/admin/assets/styles.css" />
<link rel="stylesheet" href="/admin/assets/theme-deepseek.css" />
<style>
body.theme-ds-body.chat-standalone-page {
margin: 0;
min-height: 100vh;
background: var(--ds-bg, #0d0d0d);
color: var(--ds-text, #e8e8e8);
display: flex;
flex-direction: column;
}
#app {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 0;
box-sizing: border-box;
}
.chat-app--login {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
box-sizing: border-box;
}
#app .chat-layout {
flex: 1;
min-height: 0;
}
.chat-sess-row {
display: flex;
align-items: stretch;
gap: 0;
margin-bottom: 4px;
border: 1px solid transparent;
border-radius: 0;
background: transparent;
}
.chat-sess-row:hover {
background: var(--chat-sess-hover-bg, rgba(255, 255, 255, 0.05));
border-color: var(--chat-sess-border, rgba(255, 255, 255, 0.09));
}
.chat-sess-row--active {
background: var(--chat-sess-active-bg, rgba(255, 255, 255, 0.062));
border-color: rgba(255, 255, 255, 0.08);
}
.chat-sess-row .chat-sess-btn {
flex: 1;
min-width: 0;
}
.chat-sess-more {
flex: 0 0 2rem;
min-width: 2rem;
padding: 10px 0;
border-radius: 0;
border: 1px solid transparent;
background: transparent;
color: inherit;
cursor: pointer;
font-size: 1.1rem;
line-height: 1;
-webkit-appearance: none;
appearance: none;
box-shadow: none;
}
.chat-user-row {
display: flex;
align-items: stretch;
gap: 0;
border: 1px solid transparent;
border-radius: 0;
background: transparent;
}
.chat-user-row:hover {
background: var(--chat-sess-hover-bg, rgba(255, 255, 255, 0.06));
border-color: var(--chat-sess-border, rgba(255, 255, 255, 0.12));
}
.chat-user-row .chat-sess-btn {
flex: 1;
min-width: 0;
}
.chat-sess-more:hover {
background: transparent;
}
.chat-sess-more:focus {
outline: none;
}
.chat-sess-more:focus-visible {
outline: 1px solid var(--chat-sess-border, rgba(255, 255, 255, 0.2));
outline-offset: 1px;
}
.chat-sess-more--active {
background: transparent;
border-color: transparent;
}
.chat-sess-more--active:hover {
background: transparent;
}
.chat-sess-menu-pop {
z-index: 200;
background: var(--ds-panel, #222);
border: 1px solid var(--ds-border, rgba(255, 255, 255, 0.12));
border-radius: 8px;
padding: 4px;
min-width: 11rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}
.chat-sess-menu-item {
display: block;
width: 100%;
text-align: left;
padding: 8px 10px;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
border-radius: 6px;
font-size: 13px;
}
.chat-sess-menu-item:hover {
background: rgba(255, 255, 255, 0.06);
}
.chat-msg__md {
line-height: 1.45;
word-break: break-word;
}
.chat-msg .chat-msg__md p {
margin: 0.28em 0;
}
.chat-msg .chat-msg__md > :first-child {
margin-top: 0;
}
.chat-msg .chat-msg__md > :last-child {
margin-bottom: 0;
}
.chat-msg__md pre {
overflow: auto;
padding: 8px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.35);
font-size: 12px;
}
.chat-msg__md code {
font-family: ui-monospace, monospace;
font-size: 0.92em;
}
/* 父级 .chat-msg 的 pre-wrap 会继承到子节点,影响图片与 Markdown 排版 */
.chat-msg .chat-msg__md,
.chat-msg .chat-att-wrap {
white-space: normal;
}
.chat-msg__md img {
max-width: 100%;
height: auto;
border-radius: 8px;
display: block;
margin: 0.25rem 0;
vertical-align: middle;
cursor: zoom-in;
}
.chat-msg__plain {
white-space: pre-wrap;
}
.chat-msg__stream-status {
font-size: 12px;
line-height: 1.35;
color: var(--ds-text-muted, rgba(232, 232, 232, 0.62));
margin-bottom: 6px;
}
.chat-att-wrap {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.chat-att-img {
max-width: 180px;
max-height: 180px;
border-radius: 8px;
object-fit: cover;
cursor: zoom-in;
}
.chat-img-lightbox {
position: fixed;
inset: 0;
z-index: 300;
background: rgba(0, 0, 0, 0.88);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
box-sizing: border-box;
}
.chat-img-lightbox__inner {
position: relative;
max-width: 100%;
max-height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.chat-img-lightbox__img {
max-width: min(96vw, 100%);
max-height: min(92vh, 100%);
width: auto;
height: auto;
object-fit: contain;
border-radius: 4px;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.6);
}
.chat-img-lightbox__close {
position: absolute;
top: -8px;
right: -8px;
width: 40px;
height: 40px;
border: none;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
color: #eee;
font-size: 26px;
line-height: 1;
cursor: pointer;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
}
.chat-img-lightbox__close:hover {
background: rgba(255, 255, 255, 0.22);
}
.chat-att-chip {
font-size: 12px;
opacity: 0.9;
}
.chat-composer-shell {
border-radius: 12px;
border: 1px solid var(--ds-border, rgba(255, 255, 255, 0.12));
background: rgba(0, 0, 0, 0.35);
padding: 6px 8px 6px 6px;
}
.chat-composer-shell--busy .chat-composer__field {
opacity: 0.72;
}
.chat-pending-files {
display: flex;
flex-wrap: wrap;
gap: 6px;
max-height: 4.5rem;
overflow-y: auto;
padding: 2px 4px 6px;
margin: 0 -2px;
}
.chat-pending-files:empty {
display: none;
}
.chat-pending-row {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
padding: 2px 8px 2px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
font-size: 11px;
}
.chat-pending-row .muted {
max-width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-pending-row .chat-pending-remove {
padding: 0 4px;
min-height: 22px;
min-width: 22px;
font-size: 14px;
line-height: 1;
border: none;
background: transparent;
color: inherit;
opacity: 0.75;
border-radius: 6px;
cursor: pointer;
}
.chat-pending-row .chat-pending-remove:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.12);
}
.chat-composer-row {
display: flex;
align-items: flex-end;
gap: 4px;
}
.chat-composer-iconbtn {
flex: 0 0 40px;
width: 40px;
height: 40px;
position: relative;
padding: 0;
margin: 0 0 2px 2px;
border: none;
border-radius: 10px;
background: transparent;
color: var(--ds-text-muted, rgba(232, 232, 232, 0.75));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.chat-composer-iconbtn:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--ds-text, #e8e8e8);
}
.chat-composer-iconbtn:focus-visible {
outline: 1px solid var(--chat-sess-border, rgba(255, 255, 255, 0.25));
outline-offset: 1px;
}
.chat-composer-iconbtn svg {
display: block;
}
.chat-composer__field {
flex: 1;
min-width: 0;
min-height: 44px;
max-height: 200px;
resize: none;
border: none;
border-radius: 0;
padding: 10px 8px 12px 4px;
margin-bottom: 2px;
background: transparent;
color: inherit;
line-height: 1.45;
}
.chat-composer__field:focus {
outline: none;
}
.chat-composer__field::placeholder {
color: var(--ds-text-muted, rgba(232, 232, 232, 0.45));
}
.chat-composer-actions {
position: relative;
flex: 0 0 40px;
width: 40px;
height: 40px;
margin: 0 2px 2px 0;
}
.chat-composer-send,
.chat-composer-stop {
position: absolute;
right: 0;
bottom: 0;
width: 40px;
height: 40px;
padding: 0;
border: none;
border-radius: 999px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.12s ease;
}
.chat-composer-send {
background: rgba(94, 179, 255, 0.92);
color: #0a0a0c;
}
.chat-composer-send:hover:not(:disabled) {
background: rgba(120, 195, 255, 1);
}
.chat-composer-send:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.chat-composer-stop {
background: rgba(255, 255, 255, 0.12);
color: #f3f3f3;
opacity: 0;
pointer-events: none;
}
.chat-composer-stop:hover:not(:disabled) {
background: rgba(255, 80, 80, 0.35);
color: #fff;
}
.chat-composer-stop:disabled {
opacity: 0;
pointer-events: none;
}
.chat-composer-shell--busy .chat-composer-send {
opacity: 0;
pointer-events: none;
}
.chat-composer-shell--busy .chat-composer-stop:not(:disabled) {
opacity: 1;
pointer-events: auto;
}
.chat-msg-cap {
padding: 6px 0;
font-size: 12px;
}
#app .chat-status {
flex-shrink: 0;
min-height: 1.25rem;
line-height: 1.35;
}
.chat-confirm-backdrop {
position: fixed;
inset: 0;
z-index: 400;
background: rgba(0, 0, 0, 0.58);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.chat-confirm-card {
width: min(420px, 92vw);
background: linear-gradient(180deg, rgba(11, 18, 30, 0.98), rgba(7, 12, 22, 0.98));
border: 1px solid rgba(94, 179, 255, 0.35);
border-radius: 12px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
color: var(--ds-text, #e8e8e8);
padding: 14px;
}
.chat-confirm-text {
font-size: 14px;
line-height: 1.45;
color: var(--ds-text, #e8e8e8);
}
</style>
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.8/dist/purify.min.js" crossorigin="anonymous"></script>
</head>
<body class="theme-ds-body chat-standalone-page">
<div id="app"></div>
<!-- Cache-bust for desktop webview: avoid stale chat.js -->
<script src="/admin/assets/chat.js?v=20260421-1"></script>
</body>
</html>

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 307 KiB

View file

@ -0,0 +1,59 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>oliver</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@500;600&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/admin/assets/styles.css" />
<link rel="stylesheet" href="/admin/assets/theme-deepseek.css" />
</head>
<body class="theme-ds-body">
<div class="layout">
<aside class="sidebar">
<div class="brand">
<div class="brand__logoWrap">
<img class="brand__logo" src="/admin/assets/oliver.svg" alt="oliver logo" />
</div>
</div>
<nav class="nav">
<a class="nav__item" data-page="models" href="#/models" data-i18n="nav.models">模型管理</a>
<a class="nav__item" data-page="api-grants" href="#/api-grants" data-i18n="nav.apiGrants">API 使用授权</a>
<a class="nav__item" data-page="stack" href="#/stack" data-i18n="nav.stack">Runtime</a>
<a class="nav__item" data-page="users" href="#/users" data-i18n="nav.users">用户管理</a>
<a class="nav__item" data-page="workspace-paths" href="#/workspace-paths" data-i18n="nav.workspacePaths">工作区路径</a>
<a class="nav__item" data-page="memory" href="#/memory" data-i18n="nav.memory">Memory</a>
<a class="nav__item" data-page="audit" href="#/audit" data-i18n="nav.audit">Audit & Trace</a>
<a class="nav__item" data-page="session-monitor" href="#/session-monitor" data-i18n="nav.sessionMonitor">会话监控</a>
<a class="nav__item" data-page="admin-audit" href="#/admin-audit" data-i18n="nav.adminAudit">Admin Audit</a>
<a class="nav__item" data-page="plugins" href="#/plugins" data-i18n="nav.plugins">Plugins</a>
<a class="nav__item" data-page="skills" href="#/skills" data-i18n="nav.skills">Skills</a>
<a class="nav__item" data-page="attachments" href="#/attachments" data-i18n="nav.attachments">附件</a>
<a class="nav__item" data-page="profile" href="#/profile" data-i18n="nav.profile">设置</a>
</nav>
<div class="sidebar__footer">
<div class="muted" data-i18n="notice.noLogin">v1 无登录:请仅在内网访问</div>
</div>
</aside>
<main class="main">
<header class="topbar">
<div id="topTitle" class="topbar__title">Runtime</div>
<div class="topbar__actions">
<span id="authUser" class="muted"></span>
<a id="btnBackChat" class="btn" data-i18n="nav.chat" href="chat">对话</a>
<button id="btnLogout" class="btn" data-i18n="auth.logout">退出登录</button>
<button id="btnLang" class="btn">中文</button>
<button id="btnRefresh" class="btn" data-i18n="action.refresh">刷新</button>
</div>
</header>
<section id="content" class="content"></section>
</main>
</div>
<script src="/admin/assets/app.js"></script>
</body>
</html>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 111 KiB

View file

@ -0,0 +1,315 @@
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji",
"Segoe UI Emoji";
/* Prefer theme variables when enabled (e.g. theme-deepseek.css). */
color: var(--ds-text, #e2e8f0);
background: var(--ds-bg, #0b1020);
}
.layout { display: flex; height: 100vh; }
.sidebar { width: 260px; background: #0f172a; color: #e2e8f0; display: flex; flex-direction: column; border-right: 1px solid rgba(148,163,184,0.2); }
.brand { padding: 16px 16px 8px; }
.brand__logoWrap {
width: 100%;
max-width: 180px;
height: 44px;
overflow: hidden;
border-radius: 10px;
}
.brand__logo {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
.brand__title {
font-family: "Outfit", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
font-weight: 600;
font-size: 1.125rem;
letter-spacing: 0.02em;
}
.brand__sub { font-size: 12px; color: rgba(226,232,240,0.7); margin-top: 4px; }
.nav { padding: 8px; display: flex; flex-direction: column; gap: 4px; }
.nav__item { text-decoration: none; color: rgba(226,232,240,0.85); padding: 10px 12px; border-radius: 10px; }
.nav__item:hover { background: rgba(148,163,184,0.12); }
.nav__item--active { background: rgba(59,130,246,0.22); color: #e2e8f0; }
.sidebar__footer { margin-top: auto; padding: 12px 16px; border-top: 1px solid rgba(148,163,184,0.2); }
.muted { font-size: 12px; color: rgba(226,232,240,0.65); }
.main { flex: 1; display: flex; flex-direction: column; background: #0b1020; }
.topbar { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; border-bottom: 1px solid rgba(148,163,184,0.18); background: rgba(2,6,23,0.6); backdrop-filter: blur(10px); }
.topbar__title { color: #e2e8f0; font-weight: 650; }
.btn { background: rgba(148,163,184,0.12); border: 1px solid rgba(148,163,184,0.25); color: #e2e8f0; border-radius: 10px; padding: 8px 12px; cursor: pointer; }
.btn:hover { background: rgba(148,163,184,0.18); }
.btn--primary { background: rgba(59,130,246,0.25); border-color: rgba(59,130,246,0.4); }
.btn--danger { background: rgba(239,68,68,0.18); border-color: rgba(239,68,68,0.35); }
.content { padding: 18px; overflow: auto; color: #e2e8f0; }
.card { background: rgba(15,23,42,0.9); border: 1px solid rgba(148,163,184,0.18); border-radius: 14px; padding: 14px; margin-bottom: 12px; }
.card__title { font-weight: 650; margin-bottom: 10px; }
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.chat-login-fields {
display: flex;
flex-direction: column;
gap: 12px;
margin: 6px 0 10px;
}
.chat-login-fields .input {
width: 100%;
min-width: 0;
}
.chat-login-actions {
margin-bottom: 8px;
}
.row--wecom-form {
flex-wrap: nowrap;
align-items: flex-end;
gap: 10px;
overflow-x: auto;
padding-bottom: 4px;
margin-bottom: 4px;
}
.row--wecom-form__field {
flex: 1 1 140px;
min-width: 0;
max-width: 280px;
}
.row--wecom-form__field .input {
min-width: 0;
width: 100%;
max-width: 100%;
}
.row--wecom-form__chk {
flex: 0 0 auto;
white-space: nowrap;
}
.row--wecom-form .btn {
flex: 0 0 auto;
}
.kv { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 12px; background: rgba(148,163,184,0.08); border: 1px solid rgba(148,163,184,0.14); padding: 6px 8px; border-radius: 10px; }
.input { background: rgba(2,6,23,0.65); border: 1px solid rgba(148,163,184,0.25); color: #e2e8f0; border-radius: 10px; padding: 8px 10px; min-width: 240px; }
.input--compact {
min-width: 0;
padding: 6px 8px;
}
.input--readonly {
max-width: min(100%, 720px);
cursor: default;
color: rgba(226,232,240,0.82);
border-color: rgba(148,163,184,0.18);
background: rgba(2,6,23,0.45);
}
.input--readonly::placeholder {
color: rgba(226,232,240,0.45);
}
.table { width: 100%; border-collapse: collapse; }
.table th, .table td { border-bottom: 1px solid rgba(148,163,184,0.18); padding: 10px 8px; text-align: left; font-size: 13px; }
.table-wrap { width: 100%; overflow-x: auto; border: 1px solid rgba(148,163,184,0.12); border-radius: 10px; }
.table--compact { table-layout: fixed; min-width: 860px; }
.table--compact th, .table--compact td { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.table--compact td.table__cell--actions,
.table--compact td.table__cell--form {
overflow: visible;
text-overflow: clip;
}
.table__cell-actions {
display: flex;
flex-wrap: wrap;
gap: 6px 8px;
align-items: center;
justify-content: flex-end;
}
.session-monitor-row--active {
background: rgba(94, 179, 255, 0.12);
}
.session-monitor-row--active td {
border-bottom-color: rgba(94, 179, 255, 0.35);
}
.session-monitor-modal {
position: fixed;
inset: 0;
z-index: 320;
background: rgba(0, 0, 0, 0.58);
align-items: center;
justify-content: center;
padding: 16px;
}
.session-monitor-modal__card {
width: min(1200px, 96vw);
max-height: 92vh;
overflow: auto;
}
.session-monitor-session-table th:nth-child(1),
.session-monitor-session-table td:nth-child(1) { width: 18%; }
.session-monitor-session-table th:nth-child(2),
.session-monitor-session-table td:nth-child(2) { width: 30%; }
.session-monitor-session-table th:nth-child(3),
.session-monitor-session-table td:nth-child(3) { width: 12%; }
.session-monitor-session-table th:nth-child(4),
.session-monitor-session-table td:nth-child(4) { width: 9%; }
.session-monitor-session-table th:nth-child(5),
.session-monitor-session-table td:nth-child(5) { width: 15%; }
.session-monitor-session-table th:nth-child(6),
.session-monitor-session-table td:nth-child(6) { width: 8%; }
.session-monitor-session-table th:nth-child(7),
.session-monitor-session-table td:nth-child(7) {
width: 8%;
min-width: 4rem;
}
.session-monitor-detail-table th:nth-child(1),
.session-monitor-detail-table td:nth-child(1) { width: 8%; }
.session-monitor-detail-table th:nth-child(2),
.session-monitor-detail-table td:nth-child(2) { width: 8%; }
.session-monitor-detail-table th:nth-child(3),
.session-monitor-detail-table td:nth-child(3) {
width: 62%;
white-space: normal;
word-break: break-word;
}
.session-monitor-detail-table th:nth-child(4),
.session-monitor-detail-table td:nth-child(4) { width: 22%; }
/* Match chat-page "three dots" action style */
.chat-sess-more {
flex: 0 0 2rem;
min-width: 2rem;
padding: 6px 4px;
border-radius: 10px;
border: 1px solid transparent;
background: transparent;
color: inherit;
cursor: pointer;
font-size: 1.1rem;
line-height: 1;
-webkit-appearance: none;
appearance: none;
box-shadow: none;
}
.chat-sess-more:hover {
background: rgba(255, 255, 255, 0.06);
}
.chat-sess-more:focus {
outline: none;
}
.chat-sess-more:focus-visible {
outline: 1px solid rgba(94, 179, 255, 0.35);
outline-offset: 1px;
}
.chat-sess-more--active {
background: rgba(94, 179, 255, 0.18);
border-color: rgba(94, 179, 255, 0.3);
}
.chat-sess-more--active:hover {
background: rgba(94, 179, 255, 0.22);
}
.chat-sess-menu-pop {
z-index: 300;
background: #222;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
padding: 4px;
min-width: 11rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}
.chat-sess-menu-item {
display: block;
width: 100%;
text-align: left;
padding: 8px 10px;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
border-radius: 6px;
font-size: 13px;
}
.chat-sess-menu-item:hover {
background: rgba(255, 255, 255, 0.06);
}
.table--resizable thead th {
position: relative;
}
.table-col-resizer {
position: absolute;
top: 0;
right: -3px;
width: 6px;
height: 100%;
cursor: col-resize;
user-select: none;
touch-action: none;
}
.table-col-resizer:hover {
background: rgba(94, 179, 255, 0.18);
}
body.col-resize-active {
cursor: col-resize;
}
/* User management: fill row with % columns + roomier padding */
.table--compact.table--users-mgmt {
width: 100%;
min-width: 100%;
}
.table--users-mgmt th,
.table--users-mgmt td { padding: 12px 14px; }
.table--users-mgmt th:nth-child(1),
.table--users-mgmt td:nth-child(1) { width: 15%; }
.table--users-mgmt th:nth-child(2),
.table--users-mgmt td:nth-child(2) { width: 18%; }
.table--users-mgmt th:nth-child(3),
.table--users-mgmt td:nth-child(3) { width: 8%; }
.table--users-mgmt th:nth-child(4),
.table--users-mgmt td:nth-child(4) { width: 6%; }
.table--users-mgmt th:nth-child(5),
.table--users-mgmt td:nth-child(5) { width: 13%; min-width: 7.5rem; }
.table--users-mgmt th:nth-child(6),
.table--users-mgmt td:nth-child(6) { width: 15%; min-width: 9rem; }
.table--users-mgmt th:nth-child(7),
.table--users-mgmt td:nth-child(7) {
width: 25%;
min-width: 15rem;
text-align: left;
}
.table--users-mgmt .table__cell-actions {
flex-wrap: nowrap;
gap: 8px 10px;
justify-content: flex-start;
}
.table--users-mgmt .table__cell--form .input {
min-width: 0;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.cell-copyable { cursor: copy; position: relative; }
.cell-copyable:hover { background: rgba(59,130,246,0.12); }
.cell-copyable.cell-selected { background: rgba(59,130,246,0.2); outline: 1px solid rgba(59,130,246,0.45); }
.cell-copyable.cell-copied::after {
content: "Copied";
position: absolute;
right: 6px;
top: 4px;
font-size: 11px;
color: #93c5fd;
}
.details { border: 1px solid rgba(148,163,184,0.18); border-radius: 10px; padding: 8px 10px; background: rgba(2,6,23,0.3); }
.details > summary { cursor: pointer; color: #cbd5e1; font-weight: 600; }
.plugins-fold { margin-bottom: 12px; }
.plugins-fold__inner { margin-top: 10px; padding-top: 8px; border-top: 1px solid rgba(148,163,184,0.12); }
.plugins-pager .btn.btn--small { min-width: 72px; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; border: 1px solid rgba(148,163,184,0.25); background: rgba(148,163,184,0.08); }
.badge--ok { border-color: rgba(34,197,94,0.4); background: rgba(34,197,94,0.12); }
.badge--bad { border-color: rgba(239,68,68,0.45); background: rgba(239,68,68,0.12); }
.badge--mode-restricted { border-color: rgba(250,204,21,0.45); background: rgba(250,204,21,0.12); }
.badge--mode-unrestricted { border-color: rgba(59,130,246,0.45); background: rgba(59,130,246,0.16); }
.pre { white-space: pre-wrap; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 12px; }
.alert { margin: 8px 0; padding: 10px 12px; border-radius: 10px; border: 1px solid rgba(148,163,184,0.25); font-size: 13px; }
.alert--critical { border-color: rgba(239,68,68,0.6); background: rgba(239,68,68,0.12); color: #fecaca; }
.alert-list { margin: 8px 0 0; padding-left: 18px; color: #cbd5e1; font-size: 13px; }
.alert-list li { margin: 4px 0; }

View file

@ -0,0 +1,775 @@
/* DeepSeek-like dark theme when body.theme-ds-body is set (e.g. #/chat). */
body.theme-ds-body {
--ds-bg: #0d0d0d;
/* 右侧对话主区:比侧栏/页面略深的黑 */
--ds-chat-main: #050505;
--ds-surface: #1a1a1c;
--ds-surface-2: #222224;
--ds-border: rgba(255, 255, 255, 0.08);
--ds-text: #e8e8e8;
--ds-text-muted: rgba(232, 232, 232, 0.55);
--ds-accent: #5eb3ff;
--ds-accent-soft: rgba(94, 179, 255, 0.18);
/* 左侧会话列表:贴近侧栏 #0d0d0d,选中仅略提亮 */
--chat-sess-hover-bg: rgba(255, 255, 255, 0.05);
--chat-sess-active-bg: rgba(255, 255, 255, 0.062);
--chat-sess-border: rgba(255, 255, 255, 0.09);
/* 用户消息气泡(微信式绿底深字) */
--chat-user-bubble-bg: #42b983;
--chat-user-bubble-fg: #111111;
}
body.theme-ds-body .layout .sidebar {
background: #161618;
border-right-color: var(--ds-border);
}
body.theme-ds-body .brand__title,
body.theme-ds-body .nav__item {
color: var(--ds-text);
}
body.theme-ds-body .nav__item:hover {
background: rgba(255, 255, 255, 0.06);
}
body.theme-ds-body .nav__item--active {
background: var(--ds-accent-soft);
border: 1px solid rgba(94, 179, 255, 0.25);
color: var(--ds-text);
}
body.theme-ds-body .sidebar__footer {
border-top-color: var(--ds-border);
}
body.theme-ds-body .main {
background: var(--ds-bg);
}
body.theme-ds-body .topbar {
background: rgba(22, 22, 24, 0.92);
border-bottom-color: var(--ds-border);
}
body.theme-ds-body .topbar__title {
color: var(--ds-text);
}
body.theme-ds-body .content {
background: var(--ds-bg);
color: var(--ds-text);
}
body.theme-ds-body .muted {
color: var(--ds-text-muted);
}
body.theme-ds-body .btn {
background: rgba(255, 255, 255, 0.06);
border-color: var(--ds-border);
color: var(--ds-text);
}
body.theme-ds-body .btn:hover {
background: rgba(255, 255, 255, 0.1);
}
body.theme-ds-body .btn--primary {
background: var(--ds-accent-soft);
border-color: rgba(94, 179, 255, 0.45);
color: var(--ds-text);
}
body.theme-ds-body .input {
background: var(--ds-surface-2);
border-color: var(--ds-border);
color: var(--ds-text);
}
body.theme-ds-body .card {
background: var(--ds-surface);
border-color: var(--ds-border);
color: var(--ds-text);
}
/* Chat layout(侧栏与主区融入背景:无外侧框、无列间距) */
.chat-layout {
display: flex;
gap: 0;
flex: 1;
min-height: 0;
}
.chat-nav {
width: 252px;
flex-shrink: 0;
min-height: 0;
display: flex;
flex-direction: column;
border: none;
border-radius: 0;
background: var(--ds-bg);
overflow: hidden;
}
.chat-nav__top {
flex-shrink: 0;
display: block;
padding: 12px 0 6px;
border-bottom: none;
}
.chat-nav__toolbar {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0;
padding: 0;
border-bottom: none;
margin-top: -14px;
position: relative;
z-index: 1;
}
.chat-nav__new {
margin: 0;
width: 100%;
box-sizing: border-box;
min-height: 40px;
padding: 8px 0 8px 8px;
font-size: 13px;
font-family: inherit;
border-radius: 0;
border: none;
/* 与侧栏底一致,上移后与 logo 下沿重叠时盖住底边 */
background: var(--ds-bg);
color: inherit;
box-shadow: none;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 6px;
cursor: pointer;
text-align: left;
-webkit-appearance: none;
appearance: none;
}
.chat-nav__new:hover {
background: var(--chat-sess-hover-bg, rgba(255, 255, 255, 0.06));
}
.chat-nav__newGlyph {
flex-shrink: 0;
position: relative;
display: inline-block;
width: 28px;
height: 28px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.14);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.22);
transition: background-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
}
.chat-nav__new:hover .chat-nav__newGlyph {
background-color: rgba(255, 255, 255, 0.22);
transform: scale(1.05);
}
.chat-nav__newGlyph::before,
.chat-nav__newGlyph::after {
content: "";
position: absolute;
background-color: #fff;
border-radius: 2px;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.chat-nav__newGlyph::before {
width: 14px;
height: 2px;
}
.chat-nav__newGlyph::after {
width: 2px;
height: 14px;
}
.chat-nav__newLabel,
.chat-nav__newText {
font-size: 13px;
line-height: 1.2;
white-space: nowrap;
}
.chat-nav__brand {
flex: 1;
min-width: 0;
font-family: "Outfit", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
font-weight: 600;
font-size: 1.125rem;
letter-spacing: 0.02em;
line-height: 1.25;
}
.chat-nav__brandWrap {
width: 100%;
max-width: 180px;
height: 44px;
overflow: hidden;
border-radius: 0;
}
.chat-nav__brandLogo {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
/* 负水平偏移:在 cover 裁切下再向左露出一点画面 */
object-position: -10px center;
}
.chat-nav__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 0 0 6px;
}
.chat-sessions__list {
padding: 0;
}
.chat-nav__footer {
flex-shrink: 0;
border-top: none;
padding: 10px 0 12px;
display: flex;
flex-direction: column;
gap: 8px;
background: transparent;
}
.chat-nav__user {
min-height: 0;
}
.chat-nav__user-name {
font-size: 13px;
font-weight: 500;
line-height: 1.35;
word-break: break-word;
}
.chat-nav__user-role {
font-size: 11px;
margin-top: 2px;
line-height: 1.3;
}
.chat-nav__link {
display: inline-block;
font-size: 13px;
color: rgba(94, 179, 255, 0.95);
text-decoration: none;
}
.chat-nav__link:hover {
text-decoration: underline;
}
.chat-nav__footer-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.chat-sess-btn {
display: block;
width: 100%;
text-align: left;
padding: 10px 0 10px 8px;
margin-bottom: 0;
border-radius: 0;
border: 1px solid transparent;
background: transparent;
color: inherit;
cursor: pointer;
font-size: 13px;
}
.chat-sess-btn:hover {
background: transparent;
}
.chat-sess-row {
border: 1px solid transparent;
border-radius: 0;
}
.chat-sess-row:hover {
background: var(--chat-sess-hover-bg, rgba(255, 255, 255, 0.06));
border-color: var(--chat-sess-border, rgba(255, 255, 255, 0.12));
}
.chat-sess-row--active {
background: var(--chat-sess-active-bg, rgba(255, 255, 255, 0.062));
border-color: rgba(255, 255, 255, 0.08);
}
.chat-sess-row--active .chat-sess-btn {
color: var(--ds-text, #e8e8e8);
}
.chat-sess-row .chat-sess-more {
opacity: 0;
pointer-events: none;
}
.chat-sess-row:hover .chat-sess-more,
.chat-sess-row:focus-within .chat-sess-more {
opacity: 1;
pointer-events: auto;
}
.chat-sess-row .chat-sess-more--active {
opacity: 1;
pointer-events: auto;
}
.chat-main {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
border: none;
border-radius: 0;
background: var(--ds-chat-main, #050505);
overflow: hidden;
}
.chat-messages {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
/* 滚动条:管理台等仍用隐藏式;独立 /chat 页见文件末尾 body.chat-standalone-page */
.chat-msg__reasoning-pre,
.chat-pending-files,
.table-wrap,
.session-monitor-modal__card {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
.chat-msg__reasoning-pre:hover,
.chat-pending-files:hover,
.table-wrap:hover,
.session-monitor-modal__card:hover {
scrollbar-color: rgba(94, 179, 255, 0.45) rgba(255, 255, 255, 0.06);
}
.chat-msg__reasoning-pre::-webkit-scrollbar,
.chat-pending-files::-webkit-scrollbar,
.table-wrap::-webkit-scrollbar,
.session-monitor-modal__card::-webkit-scrollbar {
width: 0px;
height: 0px;
}
.chat-msg__reasoning-pre:hover::-webkit-scrollbar,
.chat-pending-files:hover::-webkit-scrollbar,
.table-wrap:hover::-webkit-scrollbar,
.session-monitor-modal__card:hover::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.chat-msg__reasoning-pre::-webkit-scrollbar-track,
.chat-pending-files::-webkit-scrollbar-track,
.table-wrap::-webkit-scrollbar-track,
.session-monitor-modal__card::-webkit-scrollbar-track {
background: transparent;
border-radius: 999px;
}
.chat-msg__reasoning-pre::-webkit-scrollbar-thumb,
.chat-pending-files::-webkit-scrollbar-thumb,
.table-wrap::-webkit-scrollbar-thumb,
.session-monitor-modal__card::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 999px;
}
.chat-msg__reasoning-pre:hover::-webkit-scrollbar-track,
.chat-pending-files:hover::-webkit-scrollbar-track,
.table-wrap:hover::-webkit-scrollbar-track,
.session-monitor-modal__card:hover::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.06);
}
.chat-msg__reasoning-pre:hover::-webkit-scrollbar-thumb,
.chat-pending-files:hover::-webkit-scrollbar-thumb,
.table-wrap:hover::-webkit-scrollbar-thumb,
.session-monitor-modal__card:hover::-webkit-scrollbar-thumb {
background: rgba(94, 179, 255, 0.45);
}
.chat-msg__reasoning-pre::-webkit-scrollbar-thumb:hover,
.chat-pending-files::-webkit-scrollbar-thumb:hover,
.table-wrap::-webkit-scrollbar-thumb:hover,
.session-monitor-modal__card::-webkit-scrollbar-thumb:hover {
background: rgba(94, 179, 255, 0.65);
}
.chat-msg {
max-width: 92%;
padding: 6px 12px;
border-radius: 12px;
font-size: 14px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.chat-row {
display: flex;
align-items: flex-end;
gap: 10px;
width: 100%;
box-sizing: border-box;
}
.chat-row--assistant {
justify-content: flex-start;
}
.chat-row--user {
justify-content: flex-end;
}
/* 对话区宽度:用户消息不超过中线(右半区减头像轨);助手消息可越过中线约 2cm。50% 相对 .chat-messages 内一行宽度。 */
.chat-msg-col {
min-width: 0;
display: flex;
flex-direction: column;
align-items: stretch;
}
/* 与 .chat-avatar-slot 48px + .chat-row gap 10px 一致 */
.chat-msg-col--assistant {
max-width: min(max(0px, calc(50% + 2cm - 58px)), 100%);
}
.chat-msg-col--user {
align-items: flex-end;
max-width: min(max(0px, calc(50% - 58px)), 100%);
}
.chat-msg-col .chat-msg {
max-width: 100%;
}
.chat-msg-col--user .chat-msg--user {
align-self: flex-end;
}
.chat-avatar-slot {
flex: 0 0 48px;
width: 48px;
height: 48px;
min-width: 0;
min-height: 0;
overflow: hidden;
border-radius: 999px;
}
.chat-avatar {
display: block;
width: 48px;
height: 48px;
max-width: 100%;
max-height: 100%;
min-width: 0;
min-height: 0;
border-radius: 999px;
object-fit: cover;
flex-shrink: 0;
box-sizing: border-box;
}
/* 与助手侧 oliver.svg 一致:浅底 + 内边距,矢量用 contain(避免大 viewBox 在 flex 下撑开一行) */
.chat-avatar--bot {
background: rgba(255, 255, 255, 0.08);
padding: 5px;
object-fit: contain;
}
.chat-avatar--userBuiltin {
background: rgba(255, 255, 255, 0.08);
padding: 5px;
object-fit: contain;
}
.chat-avatar--userPhoto {
object-fit: cover;
padding: 0;
background: transparent;
}
.chat-row--meta {
padding-left: 58px;
max-width: min(calc(50% + 2cm), 100%);
box-sizing: border-box;
}
.chat-msg__time {
font-size: 11px;
line-height: 1.3;
color: var(--ds-text-muted, rgba(232, 232, 232, 0.5));
margin-bottom: 6px;
white-space: nowrap;
user-select: none;
}
.chat-msg--user {
align-self: flex-end;
background: var(--chat-user-bubble-bg, #42b983);
color: var(--chat-user-bubble-fg, #111);
border: 1px solid rgba(0, 0, 0, 0.12);
}
.chat-msg--user .chat-msg__md,
.chat-msg--user .chat-msg__plain {
color: inherit;
}
.chat-msg--user .chat-msg__md a {
color: #0b4d8c;
}
.chat-msg--user .chat-msg__md pre,
.chat-msg--user .chat-msg__md code {
background: rgba(0, 0, 0, 0.12);
color: #111;
border-color: rgba(0, 0, 0, 0.12);
}
.chat-msg--assistant {
align-self: flex-start;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--ds-border, rgba(255, 255, 255, 0.08));
}
.chat-msg--tool {
align-self: flex-start;
font-family: ui-monospace, monospace;
font-size: 12px;
background: rgba(0, 0, 0, 0.25);
border: 1px dashed rgba(255, 255, 255, 0.12);
}
/* In-dialog progress (e.g. Core: analyzing request…) during stream; not the footer status line */
.chat-msg--thinking {
align-self: flex-start;
max-width: 95%;
font-size: 12px;
line-height: 1.45;
color: var(--ds-text-muted, rgba(232, 232, 232, 0.72));
background: rgba(255, 255, 255, 0.04);
border: 1px dashed rgba(255, 255, 255, 0.14);
white-space: pre-wrap;
word-break: break-word;
}
.chat-task-stage {
display: flex;
gap: 10px;
align-items: center;
padding: 8px 10px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
background: rgba(255, 255, 255, 0.03);
cursor: default;
}
.chat-task-stage__toggle {
margin-left: auto;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
color: var(--ds-text-muted, rgba(232, 232, 232, 0.72));
border-radius: 8px;
padding: 1px 6px;
line-height: 1.2;
cursor: pointer;
}
.chat-task-stage__toggle:hover {
background: rgba(255, 255, 255, 0.08);
}
/* Fold model reasoning blocks in assistant bubbles (same rules as Streamlit messages.py). */
.chat-msg--rich {
display: flex;
flex-direction: column;
gap: 6px;
white-space: normal;
}
.chat-msg__text {
white-space: pre-wrap;
word-break: break-word;
}
.chat-msg__reasoning {
align-self: stretch;
margin: 4px 0 2px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
background: rgba(255, 255, 255, 0.03);
padding: 4px 10px 6px;
}
.chat-msg__reasoning:not([open]) {
padding-bottom: 2px;
}
.chat-msg__reasoning > summary {
cursor: pointer;
font-size: 12px;
color: var(--ds-text-muted, rgba(232, 232, 232, 0.7));
padding: 2px 2px 4px;
user-select: none;
list-style-position: outside;
}
.chat-msg__reasoning[open] > summary {
color: var(--ds-text, #e8e8e8);
margin-bottom: 6px;
}
.chat-msg__reasoning-pre {
margin: 0;
padding: 8px 10px;
max-height: 260px;
overflow: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
background: rgba(0, 0, 0, 0.2);
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.chat-msg__reasoning-block {
margin-bottom: 8px;
}
.chat-msg__timeline-logs {
display: flex;
flex-direction: column;
gap: 6px;
margin: 6px 0 2px;
}
.chat-msg__timeline-detail {
margin-top: 0;
}
.chat-msg__process-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.chat-msg__process-summary::-webkit-details-marker {
display: none;
}
.chat-msg__process-summary::marker {
content: "";
}
.chat-msg__process-status {
color: var(--ds-text-muted, rgba(232, 232, 232, 0.78));
}
.chat-msg__process-caret {
color: rgba(232, 232, 232, 0.6);
font-size: 12px;
line-height: 1;
}
.chat-msg__reasoning-title {
font-size: 11px;
opacity: 0.78;
margin-bottom: 4px;
}
.chat-composer {
flex-shrink: 0;
border-top: 1px solid rgba(255, 255, 255, 0.05);
padding: 10px 12px 12px;
}
.chat-composer textarea {
font-family: inherit;
font-size: 14px;
}
.chat-status {
padding: 6px 12px;
font-size: 12px;
color: var(--ds-text-muted, rgba(232, 232, 232, 0.55));
}
/* 独立 /chat:左侧会话列表不显示滚动条(仍可滚轮滚动),右侧消息区灰滚动条 */
body.chat-standalone-page .chat-nav__scroll {
scrollbar-width: none;
-ms-overflow-style: none;
}
body.chat-standalone-page .chat-nav__scroll::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
body.chat-standalone-page .chat-messages {
scrollbar-width: thin;
scrollbar-color: #7a7a7a #2e2e2e;
}
body.chat-standalone-page .chat-messages::-webkit-scrollbar {
width: 8px;
height: 8px;
}
body.chat-standalone-page .chat-messages::-webkit-scrollbar-track {
background: #2e2e2e;
}
body.chat-standalone-page .chat-messages::-webkit-scrollbar-thumb {
background: #7a7a7a;
border-radius: 0;
}
body.chat-standalone-page .chat-messages::-webkit-scrollbar-thumb:hover {
background: #909090;
}

View file

@ -0,0 +1,4 @@
from __future__ import annotations
__all__ = []

View file

@ -0,0 +1,46 @@
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any, Protocol
@dataclass(frozen=True)
class InboundMessage:
channel: str
external_user_id: str
external_chat_id: str
text: str
is_group: bool = False
mentions: list[str] = field(default_factory=list)
attachments: list[dict[str, Any]] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class OutboundMessage:
external_chat_id: str
text: str
attachments: list[dict[str, Any]] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
class ChannelAdapter(Protocol):
channel_name: str
def parse_inbound(self, payload: dict[str, Any]) -> InboundMessage:
raise NotImplementedError
def format_outbound(self, msg: OutboundMessage) -> dict[str, Any]:
raise NotImplementedError
def safe_json_loads(raw: str) -> dict[str, Any]:
try:
obj = json.loads(raw or "")
return obj if isinstance(obj, dict) else {}
except Exception:
return {}
__all__ = ["InboundMessage", "OutboundMessage", "ChannelAdapter", "safe_json_loads"]

View file

@ -0,0 +1,6 @@
from __future__ import annotations
from .wecom_bridge import WeComAdapter
__all__ = ["WeComAdapter"]

View file

@ -0,0 +1,737 @@
from __future__ import annotations
import json
import os
import time
import urllib.request
import uuid
import queue
import threading
from collections import deque
from pathlib import Path
from typing import Any
from oclaw.runtime.application.gateway import process_inbound_payload_usecase
from oclaw.interfaces.channels.wecom.normalize import normalize_wecom_event, normalize_wecom_event_batch
from oclaw.platform.config.paths import db_path
from oclaw.platform.integrations.wecom_client import WeComClient
from oclaw.platform.persistence.sqlite_store import SqliteStore
def _safe_json(obj: Any) -> str:
try:
return json.dumps(obj, ensure_ascii=True, default=str)
except Exception:
return str(obj)
def _account_id_from_payload(payload: dict[str, Any], store: SqliteStore) -> str:
meta = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
for key in ("aibotid", "bot_id", "account_id"):
v = meta.get(key)
if v:
return str(v).strip()
for key in ("aibotid", "bot_id", "account_id"):
v = payload.get(key)
if v:
return str(v).strip()
raw = payload.get("raw")
if isinstance(raw, dict):
for key in ("aibotid", "bot_id", "account_id"):
v = raw.get(key)
if v:
return str(v).strip()
return str(store.get_setting("wecom_bot_id") or "").strip()
def _sanitize_outbound_text(text: str, *, max_chars: int = 1800) -> str:
s = str(text or "").strip()
if not s:
return ""
# Decode escaped newlines so WeCom shows real line breaks.
s = s.replace("\\r\\n", "\n").replace("\\n", "\n").replace("\\N", "\n")
# Remove hidden reasoning block before delivering to end users.
lower = s.lower()
start_tag = "<redacted_thinking>"
end_tag = "</redacted_thinking>"
if start_tag in lower and end_tag in lower:
start = lower.find(start_tag)
end = lower.find(end_tag, start)
if end >= 0:
s = (s[:start] + s[end + len(end_tag) :]).strip()
if s.startswith(start_tag):
s = s[len(start_tag) :].strip()
# Collapse excessive blank lines for better mobile display.
while "\n\n\n" in s:
s = s.replace("\n\n\n", "\n\n")
if len(s) > max_chars:
s = s[:max_chars].rstrip() + "\n\n(回复过长,已截断)"
return s
class _SingleInstanceLock:
def __init__(self, lock_path: Path) -> None:
self.lock_path = lock_path
self.fh: Any | None = None
def acquire(self) -> None:
self.lock_path.parent.mkdir(parents=True, exist_ok=True)
self.fh = open(self.lock_path, "a+b")
self.fh.seek(0)
try:
if os.name == "nt":
import msvcrt # type: ignore
msvcrt.locking(self.fh.fileno(), msvcrt.LK_NBLCK, 1)
else:
import fcntl # type: ignore
fcntl.flock(self.fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except Exception as exc:
raise RuntimeError(f"wecom_longconn_already_running: {self.lock_path}") from exc
def release(self) -> None:
if self.fh is None:
return
try:
if os.name == "nt":
import msvcrt # type: ignore
self.fh.seek(0)
msvcrt.locking(self.fh.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl # type: ignore
fcntl.flock(self.fh.fileno(), fcntl.LOCK_UN)
except Exception:
pass
try:
self.fh.close()
except Exception:
pass
self.fh = None
def _http_get_json(url: str, timeout: float = 15.0) -> dict[str, Any]:
req = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", errors="replace")
obj = json.loads(raw or "{}")
return obj if isinstance(obj, dict) else {}
def _http_post_json(url: str, payload: dict[str, Any], timeout: float = 10.0) -> dict[str, Any]:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
url,
data=data,
method="POST",
headers={"content-type": "application/json", "accept": "application/json"},
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", errors="replace")
try:
obj = json.loads(raw or "{}")
except Exception:
return {"ok": True, "raw": raw}
return obj if isinstance(obj, dict) else {"ok": True, "raw": raw}
def _retry_count() -> int:
raw = str(os.getenv("WECOM_LONGCONN_SEND_RETRY") or "2").strip()
return max(1, min(int(raw) if raw.isdigit() else 2, 5))
def _load_mock_events() -> list[dict[str, Any]]:
seed = str(os.getenv("WECOM_LONGCONN_MOCK_TEXT") or "帮助").strip()
return [
{
"user_id": "u_mock_001",
"chat_id": "u_mock_001",
"text": seed,
"is_group": False,
"msgid": f"mock-{int(time.time())}",
}
]
def _load_events_once(mode: str) -> list[dict[str, Any]]:
if mode == "mock":
return _load_mock_events()
if mode == "pull":
url = str(os.getenv("WECOM_LONGCONN_PULL_URL") or "").strip()
if not url:
raise RuntimeError("missing WECOM_LONGCONN_PULL_URL when mode=pull")
obj = _http_get_json(url)
return normalize_wecom_event_batch(obj)
raise RuntimeError(f"unsupported WECOM_LONGCONN_MODE: {mode}")
def _run_ws_forever(*, sender: WeComClient, deliver_outbound: bool, use_response_url: bool) -> int:
try:
import websocket # type: ignore
except Exception as exc:
raise RuntimeError("websocket-client not installed; run: pip install websocket-client") from exc
bot_id, bot_secret = sender.get_bot_credentials()
ws_url = str(os.getenv("WECOM_LONGCONN_WS_URL") or "wss://openws.work.weixin.qq.com").strip()
seen_ids: deque[str] = deque(maxlen=2000)
seen_set: set[str] = set()
print(f"[wecom-longconn] websocket connecting url={ws_url} bot={bot_id}")
store = sender.store
workers_raw = (
str(store.get_setting("AIA_WECOM_LONGCONN_WORKERS") or "").strip()
or str(store.get_setting("WECOM_LONGCONN_WORKERS") or "").strip()
or str(os.getenv("AIA_WECOM_LONGCONN_WORKERS") or "").strip()
or str(os.getenv("WECOM_LONGCONN_WORKERS") or "").strip()
)
workers = 2
if workers_raw.isdigit():
workers = max(1, min(int(workers_raw), 8))
in_max_raw = (
str(store.get_setting("AIA_WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE") or "").strip()
or str(store.get_setting("WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE") or "").strip()
or str(os.getenv("AIA_WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE") or "").strip()
or str(os.getenv("WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE") or "").strip()
)
in_q_max = 200
if in_max_raw.isdigit():
in_q_max = max(20, min(int(in_max_raw), 5000))
inbound_q: "queue.Queue[tuple[dict[str, Any], dict[str, Any]]]" = queue.Queue(maxsize=in_q_max)
outbound_q: "queue.Queue[dict[str, Any]]" = queue.Queue()
ws_ref: dict[str, Any] = {"ws": None}
stop_sender = threading.Event()
def _drain_outbound_queue(*, ws: Any) -> None:
while True:
try:
ob = outbound_q.get_nowait()
except Exception:
break
try:
if not deliver_outbound:
print("[wecom-longconn] outbound_skipped", _safe_json(ob))
continue
text = str(ob.get("text") or "").strip()
if not text:
continue
response_url = str(ob.get("response_url") or "").strip()
req_id = str(ob.get("callback_req_id") or uuid.uuid4().hex)
rsp = {
"cmd": "aibot_respond_msg",
"headers": {"req_id": req_id},
"body": {"msgtype": "markdown", "markdown": {"content": text}},
}
sent = False
if use_response_url and response_url:
try:
cb_payload = {"msgtype": "text", "text": {"content": text}}
cb_res: dict[str, Any] = {}
cb_err = -1
for _i in range(_retry_count()):
cb_res = _http_post_json(response_url, cb_payload, timeout=12)
cb_err = (
int(cb_res.get("errcode"))
if isinstance(cb_res, dict) and str(cb_res.get("errcode", "")).strip() != ""
else 0
)
if cb_err == 0:
break
if cb_err == 0:
sent = True
try:
store.set_setting("wecom_last_outbound_mode", "response_url")
store.set_setting("wecom_last_outbound_error", "")
except Exception:
pass
print("[wecom-longconn] outbound_sent_response_url", _safe_json({"url": response_url, "res": cb_res}))
else:
try:
store.set_setting(
"wecom_last_outbound_error",
f"response_url_errcode={cb_err}: {json.dumps(cb_res, ensure_ascii=False)}",
)
except Exception:
pass
print(
"[wecom-longconn] response_url_send_not_ok_fallback_ws",
_safe_json({"url": response_url, "res": cb_res}),
)
except Exception as exc:
try:
store.set_setting("wecom_last_outbound_error", f"response_url:{type(exc).__name__}: {exc}")
except Exception:
pass
print(f"[wecom-longconn] response_url_send_error: {type(exc).__name__}: {exc}")
if not sent:
ws_ok = False
ws_err = ""
for _i in range(_retry_count()):
try:
ws.send(json.dumps(rsp, ensure_ascii=False))
ws_ok = True
ws_err = ""
break
except Exception as exc:
ws_err = f"{type(exc).__name__}: {exc}"
time.sleep(0.15)
if ws_ok:
try:
store.set_setting("wecom_last_outbound_mode", "ws")
store.set_setting("wecom_last_outbound_error", "")
except Exception:
pass
print("[wecom-longconn] outbound_sent_ws", _safe_json(rsp))
else:
try:
store.set_setting("wecom_last_outbound_error", f"ws_send_failed:{ws_err}")
except Exception:
pass
finally:
try:
outbound_q.task_done()
except Exception:
pass
def _sender_loop() -> None:
while not stop_sender.is_set():
try:
# Block briefly to react immediately after worker enqueues replies.
ob = outbound_q.get(timeout=0.3)
except queue.Empty:
continue
except Exception:
continue
try:
ws_obj = ws_ref.get("ws")
if ws_obj is None:
# websocket reconnecting; put back and retry soon.
outbound_q.put(ob)
time.sleep(0.2)
continue
if not deliver_outbound:
print("[wecom-longconn] outbound_skipped", _safe_json(ob))
continue
text = str(ob.get("text") or "").strip()
if not text:
continue
response_url = str(ob.get("response_url") or "").strip()
req_id = str(ob.get("callback_req_id") or uuid.uuid4().hex)
rsp = {
"cmd": "aibot_respond_msg",
"headers": {"req_id": req_id},
"body": {"msgtype": "markdown", "markdown": {"content": text}},
}
sent = False
if use_response_url and response_url:
try:
cb_payload = {"msgtype": "text", "text": {"content": text}}
cb_res: dict[str, Any] = {}
cb_err = -1
for _i in range(_retry_count()):
cb_res = _http_post_json(response_url, cb_payload, timeout=12)
cb_err = (
int(cb_res.get("errcode"))
if isinstance(cb_res, dict) and str(cb_res.get("errcode", "")).strip() != ""
else 0
)
if cb_err == 0:
break
if cb_err == 0:
sent = True
try:
store.set_setting("wecom_last_outbound_mode", "response_url")
store.set_setting("wecom_last_outbound_error", "")
except Exception:
pass
print("[wecom-longconn] outbound_sent_response_url", _safe_json({"url": response_url, "res": cb_res}))
else:
try:
store.set_setting(
"wecom_last_outbound_error",
f"response_url_errcode={cb_err}: {json.dumps(cb_res, ensure_ascii=False)}",
)
except Exception:
pass
print("[wecom-longconn] response_url_send_not_ok_fallback_ws", _safe_json({"url": response_url, "res": cb_res}))
except Exception as exc:
try:
store.set_setting("wecom_last_outbound_error", f"response_url:{type(exc).__name__}: {exc}")
except Exception:
pass
print(f"[wecom-longconn] response_url_send_error: {type(exc).__name__}: {exc}")
if not sent:
ws_ok = False
ws_err = ""
for _i in range(_retry_count()):
try:
ws_obj.send(json.dumps(rsp, ensure_ascii=False))
ws_ok = True
ws_err = ""
break
except Exception as exc:
ws_err = f"{type(exc).__name__}: {exc}"
time.sleep(0.15)
if ws_ok:
try:
store.set_setting("wecom_last_outbound_mode", "ws")
store.set_setting("wecom_last_outbound_error", "")
except Exception:
pass
print("[wecom-longconn] outbound_sent_ws", _safe_json(rsp))
else:
try:
store.set_setting("wecom_last_outbound_error", f"ws_send_failed:{ws_err}")
except Exception:
pass
finally:
try:
outbound_q.task_done()
except Exception:
pass
def _worker_loop(idx: int) -> None:
while True:
payload, meta2 = inbound_q.get()
try:
out = process_inbound_payload_usecase(payload)
replies = out.get("replies") if isinstance(out, dict) else []
if not isinstance(replies, list):
replies = []
for rep in replies:
if not isinstance(rep, dict):
continue
text = str(rep.get("text") or "").strip()
text = _sanitize_outbound_text(text)
if not text:
continue
outbound_q.put(
{
"callback_req_id": str(meta2.get("callback_req_id") or ""),
"response_url": str(meta2.get("response_url") or ""),
"text": text,
"raw_rep": rep,
}
)
except Exception as exc:
outbound_q.put(
{
"callback_req_id": str(meta2.get("callback_req_id") or ""),
"response_url": str(meta2.get("response_url") or ""),
"text": f"[wecom-longconn] worker_error: {type(exc).__name__}: {exc}",
"raw_rep": {},
}
)
finally:
inbound_q.task_done()
for i in range(workers):
threading.Thread(target=_worker_loop, args=(i,), name=f"wecom_worker_{i}", daemon=True).start()
threading.Thread(target=_sender_loop, name="wecom_sender", daemon=True).start()
while True:
ws = None
try:
ws = websocket.create_connection(ws_url, timeout=30)
ws.settimeout(60)
ws_ref["ws"] = ws
sub = {
"cmd": "aibot_subscribe",
"headers": {"req_id": uuid.uuid4().hex},
"body": {"bot_id": bot_id, "secret": bot_secret},
}
ws.send(json.dumps(sub, ensure_ascii=False))
print("[wecom-longconn] subscribe sent")
while True:
try:
raw = ws.recv()
except websocket.WebSocketTimeoutException:
# Idle timeout is expected when no inbound message arrives.
# Keep the connection alive instead of reconnecting.
try:
ws.ping()
print("[wecom-longconn] ping")
except Exception:
raise
# Drain outbound queue on idle ticks so replies are pushed immediately,
# not delayed until the next inbound message.
_drain_outbound_queue(ws=ws)
continue
if not raw:
continue
try:
msg = json.loads(raw)
except Exception:
print("[wecom-longconn] non_json_message", raw)
continue
if not isinstance(msg, dict):
continue
cmd = str(msg.get("cmd") or "").strip()
if not cmd:
body0 = msg.get("body") if isinstance(msg.get("body"), dict) else {}
cmd = str(body0.get("cmd") or body0.get("type") or msg.get("type") or "").strip()
if not cmd and "errcode" in msg and "errmsg" in msg:
ack_req_id = ""
h = msg.get("headers")
if isinstance(h, dict):
ack_req_id = str(h.get("req_id") or "").strip()
ack_err = int(msg.get("errcode") or 0)
ack_msg = str(msg.get("errmsg") or "").strip()
try:
store.set_setting("wecom_last_ack_req_id", ack_req_id)
store.set_setting("wecom_last_ack_errcode", str(ack_err))
store.set_setting("wecom_last_ack_errmsg", ack_msg)
if ack_err == 0:
store.set_setting("wecom_last_outbound_error", "")
else:
store.set_setting("wecom_last_outbound_error", f"ack_errcode={ack_err}: {ack_msg}")
except Exception:
pass
print("[wecom-longconn] outbound_ack", _safe_json(msg))
continue
try:
store.set_setting("wecom_last_cmd", cmd)
except Exception:
pass
if cmd == "aibot_subscribe_rsp":
print("[wecom-longconn] subscribe_rsp", json.dumps(msg, ensure_ascii=False))
continue
if cmd not in ("aibot_msg_callback", "aibot_event_callback"):
try:
store.set_setting(
"wecom_last_unknown_cmd_payload",
json.dumps(msg, ensure_ascii=False, default=str)[:4000],
)
except Exception:
pass
print(
"[wecom-longconn] unknown_cmd",
_safe_json(
{"cmd": cmd, "keys": sorted(msg.keys())[:20]},
),
)
continue
body = msg.get("body") if isinstance(msg.get("body"), dict) else {}
headers = msg.get("headers") if isinstance(msg.get("headers"), dict) else {}
callback_req_id = str(headers.get("req_id") or "").strip()
try:
store.set_setting(
"wecom_last_raw_body",
json.dumps(body, ensure_ascii=False, default=str)[:4000],
)
from_obj = body.get("from")
from_uid = ""
if isinstance(from_obj, dict):
from_uid = str(from_obj.get("userid") or from_obj.get("user_id") or from_obj.get("id") or "").strip()
elif isinstance(from_obj, str):
from_uid = from_obj.strip()
if from_uid:
store.set_setting("wecom_last_raw_from_user", from_uid)
except Exception:
pass
if cmd == "aibot_event_callback":
event_type = str(body.get("event") or body.get("event_type") or body.get("type") or "").strip()
event_obj = body.get("event") if isinstance(body.get("event"), dict) else {}
event_type_norm = str(
event_obj.get("eventtype")
or event_obj.get("type")
or event_type
).strip()
from_obj = body.get("from")
from_user = ""
if isinstance(from_obj, dict):
from_user = str(
from_obj.get("userid")
or from_obj.get("user_id")
or from_obj.get("id")
or from_obj.get("from_user_id")
or ""
).strip()
elif isinstance(from_obj, str):
from_user = from_obj.strip()
print(
"[wecom-longconn] event_callback",
_safe_json(
{
"event_type": event_type_norm or event_type,
"from_user": from_user,
"keys": sorted(body.keys())[:20],
},
),
)
if (event_type_norm or event_type).lower() == "disconnected_event":
try:
store.set_setting("wecom_last_parse_error", "disconnected_event")
except Exception:
pass
continue
try:
payload = normalize_wecom_event(body)
except Exception as exc:
print(
"[wecom-longconn] skip_invalid_msg_callback",
_safe_json(
{
"error": f"{type(exc).__name__}: {exc}",
"keys": sorted(body.keys())[:30],
},
),
)
try:
store.set_setting("wecom_last_parse_error", f"{type(exc).__name__}: {exc}")
except Exception:
pass
continue
try:
store.set_setting(
"wecom_last_normalized_payload",
json.dumps(payload, ensure_ascii=False, default=str)[:4000],
)
except Exception:
pass
msgid = str(body.get("msgid") or payload.get("metadata", {}).get("msgid") or "").strip()
if msgid and msgid in seen_set:
continue
if msgid:
if len(seen_ids) >= seen_ids.maxlen and seen_ids:
old = seen_ids.popleft()
seen_set.discard(old)
seen_ids.append(msgid)
seen_set.add(msgid)
try:
now = str(int(time.time()))
user_id = str(payload.get("user_id") or "").strip()
store.set_setting("wecom_last_msg_ts", now)
if user_id:
store.set_setting("wecom_last_from_user", user_id)
raw_recent = str(store.get_setting("wecom_recent_from_users") or "[]")
recent = json.loads(raw_recent)
if not isinstance(recent, list):
recent = []
recent = [x for x in recent if isinstance(x, dict)]
if user_id:
recent = [x for x in recent if str(x.get("user_id") or "") != user_id]
recent.insert(0, {"user_id": user_id, "ts": now})
store.set_setting("wecom_recent_from_users", json.dumps(recent[:20], ensure_ascii=False))
except Exception:
pass
try:
inbound_q.put_nowait(
(
payload,
{
"callback_req_id": callback_req_id or uuid.uuid4().hex,
"response_url": str(body.get("response_url") or "").strip(),
},
)
)
except Exception:
# Inbound backlog; drop gracefully to keep the websocket loop healthy.
try:
store.set_setting("wecom_last_outbound_error", "inbound_queue_full")
except Exception:
pass
# Drain outbound queue opportunistically.
_drain_outbound_queue(ws=ws)
except KeyboardInterrupt:
print("[wecom-longconn] stopped by user")
return 0
except Exception as exc:
print(f"[wecom-longconn] ws_loop_error: {type(exc).__name__}: {exc}")
time.sleep(3)
finally:
ws_ref["ws"] = None
if ws is not None:
try:
ws.close()
except Exception:
pass
def run_forever() -> int:
store = SqliteStore(db_path())
sender = WeComClient(store)
lock = _SingleInstanceLock(Path(db_path()).resolve().parent / "locks" / "wecom_longconn.lock")
lock.acquire()
default_mode = "ws"
mode = str(os.getenv("WECOM_LONGCONN_MODE") or default_mode).strip().lower()
interval_s = max(1.0, float(os.getenv("WECOM_LONGCONN_INTERVAL_SEC") or "3"))
deliver_outbound = str(os.getenv("WECOM_LONGCONN_DELIVER_OUTBOUND") or "1").strip().lower() not in (
"0",
"false",
"no",
)
seen_ids: deque[str] = deque(maxlen=2000)
seen_set: set[str] = set()
# Prefer response_url by default for lower latency and better delivery semantics.
use_response_url = str(os.getenv("WECOM_LONGCONN_USE_RESPONSE_URL") or "1").strip().lower() in (
"1",
"true",
"yes",
"on",
)
print(
f"[wecom-longconn] started mode={mode} interval={interval_s}s outbound={deliver_outbound} use_response_url={use_response_url}"
)
try:
if mode == "ws":
return _run_ws_forever(
sender=sender,
deliver_outbound=deliver_outbound,
use_response_url=use_response_url,
)
while True:
try:
events = _load_events_once(mode)
if not events:
time.sleep(interval_s)
continue
for evt in events:
payload = normalize_wecom_event(evt)
meta = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
msgid = str(meta.get("msgid") or "").strip()
if msgid and msgid in seen_set:
continue
if msgid:
if len(seen_ids) >= seen_ids.maxlen and seen_ids:
old = seen_ids.popleft()
seen_set.discard(old)
seen_ids.append(msgid)
seen_set.add(msgid)
out = process_inbound_payload_usecase(payload)
replies = out.get("replies") if isinstance(out, dict) else []
if not isinstance(replies, list):
replies = []
for rep in replies:
if not isinstance(rep, dict):
continue
if not deliver_outbound:
print("[wecom-longconn] outbound_skipped", _safe_json(rep))
continue
to_user = str(payload.get("user_id") or "").strip()
text = str(rep.get("text") or "").strip()
text = _sanitize_outbound_text(text)
if not to_user or not text:
continue
aid = _account_id_from_payload(payload, store)
print(
"[wecom-longconn] outbound_skipped_http_removed use_ws_mode",
_safe_json({"to_user": to_user, "account_id": aid or "", "text_len": len(text)}),
)
except KeyboardInterrupt:
print("[wecom-longconn] stopped by user")
return 0
except Exception as exc:
print(f"[wecom-longconn] loop_error: {type(exc).__name__}: {exc}")
time.sleep(interval_s)
finally:
lock.release()
def main() -> int:
return run_forever()
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,127 @@
from __future__ import annotations
from typing import Any
def _first_non_empty(*values: Any) -> str:
for v in values:
s = str(v or "").strip()
if s:
return s
return ""
def _pick(d: dict[str, Any], *keys: str) -> Any:
for k in keys:
if k in d:
return d.get(k)
return None
def _extract_text(raw: dict[str, Any]) -> str:
direct_text = _pick(raw, "text")
if isinstance(direct_text, str) and direct_text.strip():
return direct_text.strip()
direct = _first_non_empty(
_pick(raw, "content", "Content"),
)
if direct:
return direct
text_obj = raw.get("text")
if isinstance(text_obj, dict):
return _first_non_empty(_pick(text_obj, "content", "Content"))
text_raw_obj = raw.get("text_raw")
if isinstance(text_raw_obj, dict):
return _first_non_empty(_pick(text_raw_obj, "content", "Content", "text"))
content_obj = raw.get("content")
if isinstance(content_obj, dict):
return _first_non_empty(_pick(content_obj, "text", "content", "Content"))
if isinstance(raw.get("msg"), dict):
return _first_non_empty(_pick(raw.get("msg", {}), "text", "content", "Content"))
msg_obj = raw.get("message")
if isinstance(msg_obj, dict):
return _first_non_empty(
_pick(msg_obj, "text", "content", "Content"),
_pick(msg_obj.get("text", {}), "content") if isinstance(msg_obj.get("text"), dict) else None,
)
payload = raw.get("payload")
if isinstance(payload, dict):
return _first_non_empty(
_pick(payload, "text", "content", "Content"),
_pick(payload.get("text", {}), "content") if isinstance(payload.get("text"), dict) else None,
)
return ""
def normalize_wecom_event(raw: dict[str, Any]) -> dict[str, Any]:
"""Convert WeCom-like raw events into normalized gateway payload."""
chat_id = _first_non_empty(
_pick(raw, "chat_id", "conversation_id", "conversationId", "chatid", "roomid", "RoomId"),
_pick(raw.get("message", {}), "chat_id", "conversation_id", "chatid")
if isinstance(raw.get("message"), dict)
else None,
_pick(raw.get("chat", {}), "id", "chat_id", "chatid", "roomid")
if isinstance(raw.get("chat"), dict)
else None,
_pick(raw.get("conversation", {}), "id", "chat_id", "chatid")
if isinstance(raw.get("conversation"), dict)
else None,
_pick(raw.get("room", {}), "id", "roomid", "chatid")
if isinstance(raw.get("room"), dict)
else None,
)
from_obj = raw.get("from")
user_id = _first_non_empty(
_pick(raw, "user_id", "from_user_id", "fromUserId", "FromUserName", "userid", "external_userid"),
from_obj if isinstance(from_obj, str) else None,
_pick(from_obj, "userid", "user_id", "id", "from_user_id", "UserId", "userid64", "uid")
if isinstance(from_obj, dict)
else None,
_pick(raw.get("sender", {}), "userid", "user_id", "id") if isinstance(raw.get("sender"), dict) else None,
_pick(raw.get("message", {}), "from_user_id", "fromUserId") if isinstance(raw.get("message"), dict) else None,
chat_id,
)
if not chat_id:
chat_id = user_id
text = _extract_text(raw)
msgid = _first_non_empty(_pick(raw, "msgid", "msg_id", "id"), _pick(raw.get("message", {}), "msgid", "id"))
agentid = _first_non_empty(_pick(raw, "agentid", "agent_id"), _pick(raw.get("message", {}), "agentid"))
chat_type = _first_non_empty(_pick(raw, "chat_type", "conversation_type"))
is_group = bool(raw.get("is_group")) or chat_type in ("group", "room")
if not is_group and chat_id:
is_group = chat_id.endswith("@chatroom")
# If group but room id was missing, we previously fell back chat_id=user_id — same key as private 1:1.
if is_group and chat_id == user_id:
chat_id = f"group:unknown:{user_id}"
return {
"channel": "wecom",
"user_id": user_id,
"chat_id": chat_id or user_id,
"text": text,
"is_group": is_group,
"metadata": {
"agentid": agentid,
"msgid": msgid,
"source": "wecom_longconn",
"raw": raw,
},
}
def normalize_wecom_event_batch(obj: Any) -> list[dict[str, Any]]:
"""Extract event list from common envelopes returned by pull APIs."""
if isinstance(obj, list):
return [x for x in obj if isinstance(x, dict)]
if not isinstance(obj, dict):
return []
for key in ("events", "messages", "items", "data"):
arr = obj.get(key)
if isinstance(arr, list):
return [x for x in arr if isinstance(x, dict)]
return [obj]
__all__ = ["normalize_wecom_event", "normalize_wecom_event_batch"]

View file

@ -0,0 +1,49 @@
from __future__ import annotations
from typing import Any
from oclaw.interfaces.channels.base import ChannelAdapter, InboundMessage, OutboundMessage
class WeComAdapter(ChannelAdapter):
channel_name = "wecom"
def parse_inbound(self, payload: dict[str, Any]) -> InboundMessage:
user_id = str(payload.get("user_id") or payload.get("external_user_id") or "").strip()
chat_id = str(payload.get("chat_id") or payload.get("external_chat_id") or user_id).strip()
text = str(payload.get("text") or "").strip()
if not user_id:
raise ValueError("missing user_id")
if not chat_id:
chat_id = user_id
is_group = bool(payload.get("is_group"))
if is_group and chat_id == user_id:
chat_id = f"group:unknown:{user_id}"
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
mentions_raw = payload.get("mentions")
mentions: list[str] = []
if isinstance(mentions_raw, list):
mentions = [str(x).strip() for x in mentions_raw if str(x).strip()]
attachments = payload.get("attachments") if isinstance(payload.get("attachments"), list) else []
return InboundMessage(
channel=self.channel_name,
external_user_id=user_id,
external_chat_id=chat_id,
text=text,
is_group=is_group,
mentions=mentions,
attachments=[a for a in attachments if isinstance(a, dict)],
metadata={str(k): v for k, v in metadata.items()},
)
def format_outbound(self, msg: OutboundMessage) -> dict[str, Any]:
return {
"channel": self.channel_name,
"chat_id": msg.external_chat_id,
"text": msg.text,
"attachments": msg.attachments,
"metadata": msg.metadata,
}
__all__ = ["WeComAdapter"]

View file

@ -0,0 +1,18 @@
# gateway
`gateway` 是对 `vendor/oclaw/oclaw/gateway` 的 Python 重写目录。
## 当前状态
- 已重写核心挂载链路:
- `server-plugins.ts` -> `server_plugins.py`
- `server-startup-plugins.ts` -> `server_startup_plugins.py`
- 目标是保持网关启动期插件加载契约一致(加载插件、合并方法、返回 registry)。
## 迁移策略
- 由于上游 `gateway` 规模很大(数百个 TS 文件),采用分批迁移:
1. 启动与挂载链路(已完成)
2. server-methods 与 auth
3. protocol / server 子模块
4. 其余工具与测试配套
如果你确认,我会继续下一批,优先迁移 `server-methods` 目录。

View file

@ -2,9 +2,9 @@ from __future__ import annotations
from typing import Any
from oclaw.openclaw_runtime.skill_installer import install_skill_from_registry_archive
from oclaw.tools.skills.clawhub_client import get_skill_detail as clawhub_get_skill_detail
from oclaw.tools.skills.clawhub_client import search_skills as clawhub_search_skills
from oclaw.runtime.skill_installer import install_skill_from_registry_archive
from oclaw.runtime.tools.skills.clawhub_client import get_skill_detail as clawhub_get_skill_detail
from oclaw.runtime.tools.skills.clawhub_client import search_skills as clawhub_search_skills
def build_common_gateway_context(*, store: Any) -> dict[str, Any]:

View file

@ -1,12 +1,12 @@
from __future__ import annotations
from oclaw.gateway.server_methods.agent import agent_handlers
from oclaw.gateway.server_methods.chat import chat_handlers
from oclaw.gateway.server_methods.commands import commands_handlers
from oclaw.gateway.server_methods.connect import connect_handlers
from oclaw.gateway.server_methods.sessions import sessions_handlers
from oclaw.gateway.server_methods.skills import skills_handlers
from oclaw.gateway.server_methods.shared_types import GatewayRequestHandlers
from oclaw.interfaces.gateway.server_methods.agent import agent_handlers
from oclaw.interfaces.gateway.server_methods.chat import chat_handlers
from oclaw.interfaces.gateway.server_methods.commands import commands_handlers
from oclaw.interfaces.gateway.server_methods.connect import connect_handlers
from oclaw.interfaces.gateway.server_methods.sessions import sessions_handlers
from oclaw.interfaces.gateway.server_methods.skills import skills_handlers
from oclaw.interfaces.gateway.server_methods.shared_types import GatewayRequestHandlers
def build_gateway_method_handlers() -> GatewayRequestHandlers:

View file

@ -0,0 +1,266 @@
from __future__ import annotations
import importlib.util
import pathlib
import sys
from dataclasses import dataclass, field
from types import ModuleType
from typing import Any, Iterable
from types import SimpleNamespace
from oclaw.platform.config.runtime_paths import runtime_extensions_root
from oclaw.runtime.extensions.plugin_api import PluginEntry
def _sanitize_module_name(value: str) -> str:
out = []
for ch in value:
if ch.isalnum() or ch == "_":
out.append(ch)
else:
out.append("_")
sanitized = "".join(out).strip("_") or "ext"
if sanitized[0].isdigit():
sanitized = f"ext_{sanitized}"
return sanitized
def _load_module_from_path(*, module_name: str, file_path: pathlib.Path) -> ModuleType:
spec = importlib.util.spec_from_file_location(module_name, str(file_path))
if spec is None or spec.loader is None:
raise RuntimeError(f"Failed to create import spec for {file_path}")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module
def _load_package_from_dir(*, package_name: str, package_dir: pathlib.Path) -> ModuleType:
init_file = package_dir / "__init__.py"
if not init_file.exists():
# Allow package-like loading even if __init__.py is missing.
init_file = package_dir / "index.py"
spec = importlib.util.spec_from_file_location(
package_name,
str(init_file),
submodule_search_locations=[str(package_dir)],
)
if spec is None or spec.loader is None:
raise RuntimeError(f"Failed to create package spec for {package_dir}")
module = importlib.util.module_from_spec(spec)
sys.modules[package_name] = module
spec.loader.exec_module(module)
return module
def _iter_extension_dirs(root_dir: pathlib.Path) -> Iterable[pathlib.Path]:
if not root_dir.exists() or not root_dir.is_dir():
return []
return (
p
for p in root_dir.iterdir()
if p.is_dir()
and not p.name.startswith(".")
and p.name != "__pycache__"
and ((p / "__init__.py").exists() or (p / "index.py").exists())
)
@dataclass
class LoadedExtension:
id: str
dir_path: str
module_name: str
module_file: str
plugin_entry: PluginEntry
@dataclass
class RuntimePluginApi:
"""Very small in-process plugin API used by the Python gateway."""
plugin_config: dict[str, Any] = field(default_factory=dict)
config: dict[str, Any] = field(default_factory=dict)
runtime: Any = None
logger: Any = None
providers: list[dict[str, Any]] = field(default_factory=list)
tools: list[dict[str, Any]] = field(default_factory=list)
channels: list[dict[str, Any]] = field(default_factory=list)
http_routes: list[dict[str, Any]] = field(default_factory=list)
image_generation_providers: list[dict[str, Any]] = field(default_factory=list)
active_plugin_id: str = ""
def __post_init__(self) -> None:
# Compatibility shim for plugins expecting `api.runtime.task_flow.bind_session(...)`.
self.runtime = SimpleNamespace(
task_flow=SimpleNamespace(
bind_session=lambda **kwargs: {"session_key": str(kwargs.get("session_key") or "")},
),
image_generation_providers=self.image_generation_providers,
)
def register_provider(self, provider: dict[str, Any]) -> None:
self.providers.append(dict(provider or {}))
def register_tool(self, tool: dict[str, Any], *_args, **_kwargs) -> None:
row = dict(tool or {})
pid = str(self.active_plugin_id or "").strip()
if pid and not str(row.get("plugin_id") or "").strip():
row["plugin_id"] = pid
self.tools.append(row)
def register_channel(self, channel: dict[str, Any]) -> None:
self.channels.append(dict(channel or {}))
def register_http_route(
self,
*,
path: str,
auth: str,
match: str,
replace_existing: bool,
handler: Any,
) -> None:
self.http_routes.append(
{
"path": str(path or "").strip(),
"auth": str(auth or "").strip(),
"match": str(match or "").strip(),
"replace_existing": bool(replace_existing),
"handler": handler,
}
)
def register_image_generation_provider(self, provider: dict[str, Any]) -> None:
self.image_generation_providers.append(dict(provider or {}))
def _is_plugin_entry_like(value: Any) -> bool:
return (
value is not None
and isinstance(getattr(value, "id", None), str)
and isinstance(getattr(value, "name", None), str)
and isinstance(getattr(value, "description", None), str)
and callable(getattr(value, "register", None))
)
def discover_python_extension_entries(
*,
root_dir: str,
only_ids: list[str] | None = None,
) -> list[LoadedExtension]:
root = pathlib.Path(root_dir).resolve()
wanted = {str(x).strip() for x in (only_ids or []) if str(x).strip()}
loaded: list[LoadedExtension] = []
for ext_dir in _iter_extension_dirs(root):
ext_id = ext_dir.name
if wanted and ext_id not in wanted:
continue
module_name = f"oclaw_pyext_{_sanitize_module_name(ext_id)}"
module = _load_package_from_dir(package_name=module_name, package_dir=ext_dir)
entry = getattr(module, "plugin_entry", None)
if isinstance(entry, PluginEntry):
pass
elif _is_plugin_entry_like(entry):
# Accept PluginEntry-like objects from local extension API.
pass
else:
continue
loaded.append(
LoadedExtension(
id=entry.id,
dir_path=str(ext_dir),
module_name=module_name,
module_file=str(ext_dir / "__init__.py"),
plugin_entry=entry,
)
)
return loaded
def build_python_extensions_registry(
*,
app_config: dict[str, Any],
workspace_dir: str,
only_plugin_ids: list[str],
log: dict[str, Any] | None = None,
) -> dict[str, Any]:
ws_dir = pathlib.Path(workspace_dir).resolve()
logger = (log or {}).get("info")
api = RuntimePluginApi(plugin_config={}, config=app_config, logger=logger)
# Support both workspace roots:
# - repo root (contains `oclaw/runtime/extensions`)
# - `oclaw` package root (contains `runtime/extensions`)
extension_roots = [runtime_extensions_root(), ws_dir / "oclaw" / "runtime" / "extensions", ws_dir / "runtime" / "extensions"]
diagnostics: list[dict[str, Any]] = []
loaded_by_id: dict[str, LoadedExtension] = {}
for root in extension_roots:
discovered = discover_python_extension_entries(root_dir=str(root), only_ids=only_plugin_ids)
for item in discovered:
pid = str(item.id or "").strip()
if not pid:
continue
if pid in loaded_by_id:
diagnostics.append(
{
"plugin_id": pid,
"status": "duplicate_plugin_id",
"error": "duplicate plugin id ignored (first source kept)",
"module": item.module_name,
"file": item.module_file,
}
)
continue
loaded_by_id[pid] = item
loaded = list(loaded_by_id.values())
for item in loaded:
try:
plugin_cfg = (
((app_config.get("plugins") or {}).get("entries") or {}).get(item.plugin_entry.id)
if isinstance(app_config, dict)
else {}
)
api.plugin_config = dict(plugin_cfg or {})
api.active_plugin_id = str(item.plugin_entry.id or "")
item.plugin_entry.register(api)
api.active_plugin_id = ""
except Exception as exc: # noqa: BLE001
diagnostics.append(
{
"plugin_id": item.plugin_entry.id,
"status": "error",
"error": str(exc),
"module": item.module_name,
"file": item.module_file,
}
)
return {
"plugins": [
{
"id": item.plugin_entry.id,
"name": item.plugin_entry.name,
"description": item.plugin_entry.description,
"config_schema": getattr(item.plugin_entry, "config_schema", None),
"dir": item.dir_path,
"module": item.module_name,
"file": item.module_file,
"status": "loaded",
}
for item in loaded
],
"providers": api.providers,
"tools": api.tools,
"channels": api.channels,
"image_generation_providers": api.image_generation_providers,
"http_routes": api.http_routes,
"gateway_handlers": {},
"diagnostics": diagnostics,
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,266 @@
from __future__ import annotations
import asyncio
from typing import Any
from .shared_types import GatewayRequestHandlers
from .telegram_send_normalize import normalize_transport_target_for_channel
from .validation import error_shape
def _bad(respond, message: str) -> None:
if callable(respond):
respond(False, None, error_shape("INVALID_REQUEST", message), None)
def _unavailable(respond, message: str, *, meta: dict[str, Any] | None = None) -> None:
if callable(respond):
respond(False, None, error_shape("UNAVAILABLE", message), meta or None)
def _ok(respond, payload: dict[str, Any] | None = None, *, meta: dict[str, Any] | None = None) -> None:
if callable(respond):
respond(True, payload or {"ok": True}, None, meta or None)
def _normalize_optional_str(value: Any) -> str | None:
if isinstance(value, str):
v = value.strip()
return v or None
return None
def _normalize_channel(value: Any) -> str | None:
ch = _normalize_optional_str(value)
if not ch:
return None
lower = ch.lower()
# TS rejects webchat as internal-only for these endpoints.
if lower == "webchat":
return None
return lower
def _dedupe_get(context: Any, key: str) -> dict[str, Any] | None:
if not isinstance(context, dict):
return None
dedupe = context.get("dedupe")
if isinstance(dedupe, dict):
cached = dedupe.get(key)
return cached if isinstance(cached, dict) else None
return None
def _dedupe_set_success(context: Any, key: str, payload: Any) -> None:
if not isinstance(context, dict):
return
dedupe = context.get("dedupe")
if isinstance(dedupe, dict):
dedupe[key] = {"ok": True, "payload": payload, "error": None}
def _dedupe_set_failure(context: Any, key: str, error: Any) -> None:
if not isinstance(context, dict):
return
dedupe = context.get("dedupe")
if isinstance(dedupe, dict):
dedupe[key] = {"ok": False, "payload": None, "error": error}
def _run_maybe_await(value: Any) -> Any:
"""Run coroutine results in a sync handler.
Gateway handlers in this repo are synchronous today, but some context hooks
may be authored as async. We support both by executing coroutine results
when no event loop is running; otherwise we raise to avoid deadlocks.
"""
if asyncio.iscoroutine(value):
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop is not None and loop.is_running():
raise RuntimeError("async hook used from sync handler while event loop is running")
return asyncio.run(value)
return value
def _message_action_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
params = opts.get("params")
context = opts.get("context")
client = opts.get("client")
if not isinstance(params, dict):
_bad(respond, "invalid message.action params")
return
idem = _normalize_optional_str(params.get("idempotencyKey"))
if not idem:
_bad(respond, "invalid message.action params: idempotencyKey required")
return
dedupe_key = f"message.action:{idem}"
cached = _dedupe_get(context, dedupe_key)
if cached:
if callable(respond):
respond(bool(cached.get("ok")), cached.get("payload"), cached.get("error"), {"cached": True})
return
channel = _normalize_channel(params.get("channel"))
if not channel:
_bad(
respond,
"unsupported channel: webchat (internal-only). Use `chat.send` for WebChat UI messages or choose a deliverable channel.",
)
return
action = _normalize_optional_str(params.get("action"))
if not action:
_bad(respond, "invalid message.action params: action required")
return
action_params = params.get("params")
if not isinstance(action_params, dict):
_bad(respond, "invalid message.action params: params must be object")
return
# Authorization: we only trust `senderIsOwner` if the caller is already admin-scoped.
sender_is_owner_wire = params.get("senderIsOwner") is True
caller_scopes = []
if isinstance(client, dict):
connect = client.get("connect")
if isinstance(connect, dict) and isinstance(connect.get("scopes"), list):
caller_scopes = [x for x in connect.get("scopes") if isinstance(x, str)]
caller_is_full_operator = "operator.admin" in caller_scopes
sender_is_owner = bool(caller_is_full_operator and sender_is_owner_wire)
dispatch = context.get("dispatch_message_action") if isinstance(context, dict) else None
if not callable(dispatch):
payload = {"channel": channel, "action": action, "handled": False}
_dedupe_set_success(context, dedupe_key, payload)
_ok(respond, payload, meta={"channel": channel})
return
try:
handled = _run_maybe_await(
dispatch(
{
"channel": channel,
"action": action,
"params": action_params,
"senderIsOwner": sender_is_owner,
"raw": params,
}
)
)
payload = handled if isinstance(handled, dict) else {"handled": bool(handled)}
_dedupe_set_success(context, dedupe_key, payload)
_ok(respond, payload, meta={"channel": channel})
except Exception as exc:
err = error_shape("UNAVAILABLE", str(exc))
_dedupe_set_failure(context, dedupe_key, err)
_unavailable(respond, str(exc), meta={"channel": channel})
def _send_handler(opts: dict[str, Any]) -> None:
respond = opts.get("respond")
params = opts.get("params")
context = opts.get("context")
if not isinstance(params, dict):
_bad(respond, "invalid send params")
return
idem = _normalize_optional_str(params.get("idempotencyKey"))
if not idem:
_bad(respond, "invalid send params: idempotencyKey required")
return
dedupe_key = f"send:{idem}"
cached = _dedupe_get(context, dedupe_key)
if cached:
if callable(respond):
respond(bool(cached.get("ok")), cached.get("payload"), cached.get("error"), {"cached": True})
return
to = _normalize_optional_str(params.get("to")) or ""
if not to:
_bad(respond, "invalid send params: to required")
return
message = _normalize_optional_str(params.get("message")) or ""
media_url = _normalize_optional_str(params.get("mediaUrl"))
media_urls_raw = params.get("mediaUrls")
media_urls: list[str] = []
if isinstance(media_urls_raw, list):
for entry in media_urls_raw:
v = _normalize_optional_str(entry)
if v:
media_urls.append(v)
if not message and not media_url and not media_urls:
_bad(respond, "invalid send params: text or media is required")
return
channel = _normalize_channel(params.get("channel")) or "auto"
if channel == "auto":
# If caller doesn't specify a channel, allow context to choose a default deliverable channel.
choose = context.get("resolve_default_channel") if isinstance(context, dict) else None
if callable(choose):
try:
chosen = choose()
channel = _normalize_channel(chosen) or "auto"
except Exception:
channel = "auto"
if channel == "auto":
channel = "unknown"
to, channel_extra = normalize_transport_target_for_channel(channel=channel, to=to, params=params)
deliver = context.get("deliver_outbound") if isinstance(context, dict) else None
if not callable(deliver):
payload = {
"runId": idem,
"channel": channel,
"to": to,
"messageId": f"msg_{idem}",
**channel_extra,
}
_dedupe_set_success(context, dedupe_key, payload)
_ok(respond, payload, meta={"channel": channel})
return
try:
result = _run_maybe_await(
deliver(
{
"runId": idem,
"channel": channel,
"to": to,
"message": message or None,
"mediaUrl": media_url,
"mediaUrls": media_urls,
**channel_extra,
"raw": params,
}
)
)
payload = result if isinstance(result, dict) else {"ok": True}
if "runId" not in payload:
payload["runId"] = idem
if "channel" not in payload:
payload["channel"] = channel
_dedupe_set_success(context, dedupe_key, payload)
_ok(respond, payload, meta={"channel": channel})
except Exception as exc:
err = error_shape("UNAVAILABLE", str(exc))
_dedupe_set_failure(context, dedupe_key, err)
_unavailable(respond, str(exc), meta={"channel": channel})
send_handlers: GatewayRequestHandlers = {
"message.action": _message_action_handler,
"send": _send_handler,
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,137 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable
from oclaw.platform.config.runtime_paths import runtime_extensions_root
from .python_extensions_loader import build_python_extensions_registry
GatewayHandler = Callable[..., Any]
LogFn = Callable[[str], None]
@dataclass(frozen=True)
class GatewayPluginLoadResult:
plugin_registry: dict[str, Any]
gateway_methods: list[str]
def _apply_plugin_auto_enable(cfg: dict[str, Any]) -> dict[str, Any]:
"""Python rewrite placeholder for plugin auto-enable policy.
Keep behavior deterministic: when no runtime policy engine is attached,
the incoming config is treated as already normalized.
"""
return dict(cfg or {})
def _resolve_gateway_startup_plugin_ids(
*,
config: dict[str, Any],
workspace_dir: str,
) -> list[str]:
ws_root = Path(workspace_dir).resolve()
plugins = ((config.get("plugins") or {}).get("enabled") or []) if isinstance(config, dict) else []
out = [str(x).strip() for x in plugins if str(x).strip()]
slot_cfg = ((config.get("plugins") or {}).get("slots") or {}) if isinstance(config, dict) else {}
memory_slot = str(slot_cfg.get("memory") or "").strip()
memory_plugin_ids = {"memory-core", "memory-wiki", "memory-lancedb"}
if out:
if memory_slot:
out = [x for x in out if x not in memory_plugin_ids]
if memory_slot.lower() != "none":
out.append(memory_slot)
return out
# Auto-discover local plugins when explicit list is absent.
roots = [
runtime_extensions_root(),
ws_root / "oclaw" / "runtime" / "extensions",
ws_root / "runtime" / "extensions",
]
seen: set[str] = set()
for root in roots:
if not root.exists() or not root.is_dir():
continue
for child in root.iterdir():
if not child.is_dir():
continue
pid = str(child.name or "").strip()
if not pid or pid.startswith(".") or pid in seen:
continue
seen.add(pid)
out.append(pid)
if memory_slot:
out = [x for x in out if x not in memory_plugin_ids]
if memory_slot.lower() != "none":
out.append(memory_slot)
return out
def _load_oclaw_plugins(
*,
config: dict[str, Any],
workspace_dir: str,
only_plugin_ids: list[str],
core_gateway_handlers: dict[str, GatewayHandler] | None = None,
) -> dict[str, Any]:
"""Minimal Python replacement for loadOclawPlugins.
This function intentionally returns a stable registry shape that matches
the gateway startup contract used by this project.
"""
core_gateway_handlers = dict(core_gateway_handlers or {})
registry = build_python_extensions_registry(
app_config=dict(config or {}),
workspace_dir=workspace_dir,
only_plugin_ids=list(only_plugin_ids or []),
)
registry["gateway_handlers"] = {k: v for k, v in core_gateway_handlers.items()}
registry["workspace_dir"] = workspace_dir
return registry
def load_gateway_plugins(
*,
cfg: dict[str, Any],
workspace_dir: str,
log: dict[str, LogFn],
core_gateway_handlers: dict[str, GatewayHandler],
base_methods: list[str],
plugin_ids: list[str] | None = None,
) -> GatewayPluginLoadResult:
"""Python rewrite of `gateway/server-plugins.ts::loadGatewayPlugins`.
Behavior:
- Resolve startup plugin ids
- Load plugin registry
- Merge plugin gateway handlers into gateway methods
"""
resolved_cfg = _apply_plugin_auto_enable(cfg)
chosen_plugin_ids = list(
plugin_ids or _resolve_gateway_startup_plugin_ids(config=resolved_cfg, workspace_dir=workspace_dir)
)
if not chosen_plugin_ids:
plugin_registry = {"plugins": [], "gateway_handlers": {}, "http_routes": [], "diagnostics": []}
return GatewayPluginLoadResult(plugin_registry=plugin_registry, gateway_methods=list(base_methods))
plugin_registry = _load_oclaw_plugins(
config=resolved_cfg,
workspace_dir=workspace_dir,
only_plugin_ids=chosen_plugin_ids,
core_gateway_handlers=core_gateway_handlers,
)
plugin_methods = list((plugin_registry.get("gateway_handlers") or {}).keys())
merged_methods: list[str] = []
seen: set[str] = set()
for method in [*base_methods, *plugin_methods]:
if method not in seen:
seen.add(method)
merged_methods.append(method)
info = log.get("info")
if callable(info):
info(f"[gateway] loaded {len(chosen_plugin_ids)} plugins for workspace={workspace_dir}")
return GatewayPluginLoadResult(plugin_registry=plugin_registry, gateway_methods=merged_methods)

View file

@ -0,0 +1,88 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable
from oclaw.runtime.agents.agent_scope import resolve_agent_workspace_dir, resolve_default_agent_id
from oclaw.runtime.agents.subagent_registry import init_subagent_registry
from .server_plugins import GatewayPluginLoadResult, load_gateway_plugins
LogFn = Callable[[str], None]
@dataclass(frozen=True)
class GatewayPluginBootstrapResult:
gateway_plugin_config_at_start: dict[str, Any]
default_workspace_dir: str
deferred_configured_channel_plugin_ids: list[str]
startup_plugin_ids: list[str]
base_methods: list[str]
plugin_registry: dict[str, Any]
base_gateway_methods: list[str]
def _resolve_default_workspace_dir(cfg: dict[str, Any]) -> str:
default_agent_id = resolve_default_agent_id(cfg)
workspace_dir = resolve_agent_workspace_dir(cfg, default_agent_id)
return str(workspace_dir or ".").strip() or "."
def _resolve_deferred_configured_channel_plugin_ids(cfg: dict[str, Any]) -> list[str]:
channels = (cfg.get("channels") or {}) if isinstance(cfg, dict) else {}
deferred = channels.get("deferred_plugins")
if not isinstance(deferred, list):
return []
return [str(x).strip() for x in deferred if str(x).strip()]
def _resolve_gateway_startup_plugin_ids(cfg: dict[str, Any]) -> list[str]:
plugins = (cfg.get("plugins") or {}) if isinstance(cfg, dict) else {}
enabled = plugins.get("enabled")
if not isinstance(enabled, list):
return []
return [str(x).strip() for x in enabled if str(x).strip()]
def prepare_gateway_plugin_bootstrap(
*,
cfg_at_start: dict[str, Any],
startup_runtime_config: dict[str, Any],
minimal_test_gateway: bool,
log: dict[str, LogFn],
core_gateway_handlers: dict[str, Callable[..., Any]],
base_methods: list[str],
) -> GatewayPluginBootstrapResult:
_ = startup_runtime_config
init_subagent_registry()
gateway_plugin_config_at_start = dict(cfg_at_start or {})
default_workspace_dir = _resolve_default_workspace_dir(gateway_plugin_config_at_start)
deferred_configured_channel_plugin_ids = (
[] if minimal_test_gateway else _resolve_deferred_configured_channel_plugin_ids(gateway_plugin_config_at_start)
)
startup_plugin_ids = [] if minimal_test_gateway else _resolve_gateway_startup_plugin_ids(gateway_plugin_config_at_start)
if minimal_test_gateway:
plugin_registry = {"plugins": [], "gateway_handlers": {}, "http_routes": [], "diagnostics": []}
base_gateway_methods = list(base_methods)
else:
loaded: GatewayPluginLoadResult = load_gateway_plugins(
cfg=gateway_plugin_config_at_start,
workspace_dir=default_workspace_dir,
log=log,
core_gateway_handlers=core_gateway_handlers,
base_methods=base_methods,
plugin_ids=startup_plugin_ids,
)
plugin_registry = loaded.plugin_registry
base_gateway_methods = loaded.gateway_methods
return GatewayPluginBootstrapResult(
gateway_plugin_config_at_start=gateway_plugin_config_at_start,
default_workspace_dir=default_workspace_dir,
deferred_configured_channel_plugin_ids=deferred_configured_channel_plugin_ids,
startup_plugin_ids=startup_plugin_ids,
base_methods=list(base_methods),
plugin_registry=plugin_registry,
base_gateway_methods=base_gateway_methods,
)

View file

@ -10,18 +10,18 @@ from fastapi import Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from oclaw.application.gateway import process_inbound_payload_usecase
from oclaw.runtime.application.gateway import process_inbound_payload_usecase
from oclaw.interfaces.gateway.http_adapter import dispatch_gateway_http_method
from oclaw.interfaces.ws import ws_gateway_loop
from oclaw.admin.routes import admin_static_dir, build_admin_router
from oclaw.agents.agent_scope import list_agent_ids, resolve_agent_workspace_dir, resolve_default_agent_id
from oclaw.gateway.server_startup_plugins import prepare_gateway_plugin_bootstrap
from oclaw.openclaw_runtime.hooks_runtime import (
from oclaw.interfaces.admin.routes import admin_static_dir, build_admin_router
from oclaw.runtime.agents.agent_scope import list_agent_ids, resolve_agent_workspace_dir, resolve_default_agent_id
from oclaw.interfaces.gateway.server_startup_plugins import prepare_gateway_plugin_bootstrap
from oclaw.runtime.hooks_runtime import (
initialize_hooks_runtime,
resolve_runtime_config,
trigger_hook_event,
)
from oclaw.openclaw_runtime.skills import skill_runtime_diagnostics
from oclaw.runtime.skills import skill_runtime_diagnostics
from oclaw.platform.config.paths import PROJECT_ROOT, db_path
from oclaw.platform.persistence.sqlite_store import SqliteStore
from oclaw.interfaces.http.weixin_ilink_api import router as weixin_ilink_router
@ -36,7 +36,7 @@ def _resolve_startup_workspace_dir(cfg: dict[str, Any]) -> str:
return ws_text
except Exception:
pass
return str((PROJECT_ROOT / "oclaw" / "workspace-main").resolve())
return str((PROJECT_ROOT / "oclaw" / "runtime" / "assets" / "agent_workspaces" / "workspace-main").resolve())
def _resolve_startup_workspace_dirs(cfg: dict[str, Any]) -> list[tuple[str, str]]:

View file

@ -15,7 +15,7 @@ def _require_ilink_auth(
authorization_type: str | None,
authorization: str | None,
) -> None:
# Minimal contract required by openclaw-weixin:
# Minimal contract required by oclaw-weixin:
# - AuthorizationType: ilink_bot_token
# - Authorization: Bearer <token>
if (authorization_type or "").strip() != "ilink_bot_token":

View file

@ -0,0 +1,29 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "oclaw://schemas/agent.json",
"title": "AgentParams",
"type": "object",
"additionalProperties": false,
"required": ["message", "idempotencyKey"],
"properties": {
"message": { "type": "string", "minLength": 1 },
"agentId": { "type": "string", "minLength": 1 },
"provider": { "type": "string", "minLength": 1 },
"model": { "type": "string", "minLength": 1 },
"to": { "type": "string", "minLength": 1 },
"replyTo": { "type": "string", "minLength": 1 },
"sessionId": { "type": "string", "minLength": 1 },
"sessionKey": { "type": "string", "minLength": 1 },
"thinking": { "type": "string" },
"deliver": { "type": "boolean" },
"attachments": { "type": "array", "items": {} },
"channel": { "type": "string" },
"timeout": { "type": "integer", "minimum": 0 },
"extraSystemPrompt": { "type": "string" },
"bootstrapContextMode": { "enum": ["full", "lightweight"] },
"bootstrapContextRunKind": { "enum": ["default", "heartbeat", "cron"] },
"idempotencyKey": { "type": "string", "minLength": 1 },
"label": { "type": "string" }
}
}

View file

@ -0,0 +1,13 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "oclaw://schemas/agent_wait.json",
"title": "AgentWaitParams",
"type": "object",
"additionalProperties": false,
"required": ["runId"],
"properties": {
"runId": { "type": "string", "minLength": 1 },
"timeoutMs": { "type": "integer", "minimum": 0 }
}
}

View file

@ -0,0 +1,45 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "oclaw://schemas/chat.json",
"title": "ChatParams",
"$defs": {
"NonEmptyString": { "type": "string", "minLength": 1 },
"ChatSendParams": {
"type": "object",
"additionalProperties": false,
"required": ["sessionKey", "message", "idempotencyKey"],
"properties": {
"sessionKey": { "$ref": "#/$defs/NonEmptyString" },
"message": { "type": "string" },
"thinking": { "type": "string" },
"interaction_mode": { "type": "string" },
"specialist": { "type": "string" },
"relay_share_envelope": { "type": "object" },
"acp_parent_run_id": { "type": "string" },
"acp_child_run_id": { "type": "string" },
"attachments": { "type": "array", "items": {} },
"timeoutMs": { "type": "integer", "minimum": 0 },
"idempotencyKey": { "$ref": "#/$defs/NonEmptyString" }
}
},
"ChatHistoryParams": {
"type": "object",
"additionalProperties": false,
"required": ["sessionKey"],
"properties": {
"sessionKey": { "$ref": "#/$defs/NonEmptyString" },
"limit": { "type": "integer", "minimum": 1 },
"maxChars": { "type": "integer", "minimum": 1 }
}
},
"ChatAbortParams": {
"type": "object",
"additionalProperties": false,
"required": ["sessionKey"],
"properties": {
"sessionKey": { "$ref": "#/$defs/NonEmptyString" },
"runId": { "type": "string", "minLength": 1 }
}
}
}
}

View file

@ -0,0 +1,61 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "oclaw://schemas/connect.json",
"title": "ConnectParams",
"type": "object",
"additionalProperties": false,
"required": ["minProtocol", "maxProtocol", "client"],
"properties": {
"minProtocol": { "type": "integer", "minimum": 1 },
"maxProtocol": { "type": "integer", "minimum": 1 },
"client": {
"type": "object",
"additionalProperties": false,
"required": ["id", "version", "platform", "mode"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"displayName": { "type": "string", "minLength": 1 },
"version": { "type": "string", "minLength": 1 },
"platform": { "type": "string", "minLength": 1 },
"deviceFamily": { "type": "string", "minLength": 1 },
"modelIdentifier": { "type": "string", "minLength": 1 },
"mode": { "type": "string", "minLength": 1 },
"instanceId": { "type": "string", "minLength": 1 }
}
},
"caps": { "type": "array", "items": { "type": "string", "minLength": 1 } },
"commands": { "type": "array", "items": { "type": "string", "minLength": 1 } },
"permissions": {
"type": "object",
"additionalProperties": { "type": "boolean" }
},
"pathEnv": { "type": "string" },
"role": { "type": "string", "minLength": 1 },
"scopes": { "type": "array", "items": { "type": "string", "minLength": 1 } },
"auth": {
"type": "object",
"additionalProperties": false,
"properties": {
"token": { "type": "string" },
"bootstrapToken": { "type": "string" },
"deviceToken": { "type": "string" },
"password": { "type": "string" }
}
},
"device": {
"type": "object",
"additionalProperties": false,
"required": ["id", "publicKey", "signature", "signedAt", "nonce"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"publicKey": { "type": "string", "minLength": 1 },
"signature": { "type": "string", "minLength": 1 },
"signedAt": { "type": "integer", "minimum": 0 },
"nonce": { "type": "string", "minLength": 1 }
}
},
"locale": { "type": "string" },
"userAgent": { "type": "string" }
}
}

View file

@ -0,0 +1,70 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "oclaw://schemas/frames.json",
"title": "GatewayFrame",
"type": "object",
"oneOf": [
{ "$ref": "#/$defs/RequestFrame" },
{ "$ref": "#/$defs/ResponseFrame" },
{ "$ref": "#/$defs/EventFrame" }
],
"$defs": {
"NonEmptyString": { "type": "string", "minLength": 1 },
"ErrorShape": {
"type": "object",
"additionalProperties": false,
"required": ["code", "message"],
"properties": {
"code": { "$ref": "#/$defs/NonEmptyString" },
"message": { "$ref": "#/$defs/NonEmptyString" },
"details": {},
"retryable": { "type": "boolean" },
"retryAfterMs": { "type": "integer", "minimum": 0 }
}
},
"RequestFrame": {
"type": "object",
"additionalProperties": false,
"required": ["type", "id", "method"],
"properties": {
"type": { "const": "req" },
"id": { "$ref": "#/$defs/NonEmptyString" },
"method": { "$ref": "#/$defs/NonEmptyString" },
"params": {}
}
},
"ResponseFrame": {
"type": "object",
"additionalProperties": false,
"required": ["type", "id", "ok"],
"properties": {
"type": { "const": "res" },
"id": { "$ref": "#/$defs/NonEmptyString" },
"ok": { "type": "boolean" },
"payload": {},
"error": { "$ref": "#/$defs/ErrorShape" }
}
},
"EventFrame": {
"type": "object",
"additionalProperties": false,
"required": ["type", "event"],
"properties": {
"type": { "const": "event" },
"event": { "$ref": "#/$defs/NonEmptyString" },
"payload": {},
"seq": { "type": "integer", "minimum": 0 },
"stateVersion": {
"type": "object",
"additionalProperties": false,
"required": ["presence", "health"],
"properties": {
"presence": { "type": "integer", "minimum": 0 },
"health": { "type": "integer", "minimum": 0 }
}
}
}
}
}
}

View file

@ -0,0 +1,86 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "oclaw://schemas/hello_ok.json",
"title": "HelloOk",
"type": "object",
"additionalProperties": false,
"required": ["type", "protocol", "server", "features", "snapshot", "policy"],
"properties": {
"type": { "const": "hello-ok" },
"protocol": { "type": "integer", "minimum": 1 },
"server": {
"type": "object",
"additionalProperties": false,
"required": ["version", "connId"],
"properties": {
"version": { "type": "string", "minLength": 1 },
"connId": { "type": "string", "minLength": 1 }
}
},
"features": {
"type": "object",
"additionalProperties": false,
"required": ["methods", "events"],
"properties": {
"methods": { "type": "array", "items": { "type": "string", "minLength": 1 } },
"events": { "type": "array", "items": { "type": "string", "minLength": 1 } }
}
},
"snapshot": {
"type": "object",
"additionalProperties": false,
"required": ["presence", "health", "stateVersion", "uptimeMs"],
"properties": {
"presence": { "type": "array", "items": { "type": "object" } },
"health": {},
"stateVersion": {
"type": "object",
"additionalProperties": false,
"required": ["presence", "health"],
"properties": {
"presence": { "type": "integer", "minimum": 0 },
"health": { "type": "integer", "minimum": 0 }
}
},
"uptimeMs": { "type": "integer", "minimum": 0 }
}
},
"policy": {
"type": "object",
"additionalProperties": false,
"required": ["maxPayload", "maxBufferedBytes", "tickIntervalMs"],
"properties": {
"maxPayload": { "type": "integer", "minimum": 1 },
"maxBufferedBytes": { "type": "integer", "minimum": 1 },
"tickIntervalMs": { "type": "integer", "minimum": 1 }
}
},
"canvasHostUrl": { "type": "string", "minLength": 1 },
"auth": {
"type": "object",
"additionalProperties": false,
"required": ["role", "scopes"],
"properties": {
"deviceToken": { "type": "string", "minLength": 1 },
"role": { "type": "string", "minLength": 1 },
"scopes": { "type": "array", "items": { "type": "string", "minLength": 1 } },
"issuedAtMs": { "type": "integer", "minimum": 0 },
"deviceTokens": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["deviceToken", "role", "scopes", "issuedAtMs"],
"properties": {
"deviceToken": { "type": "string", "minLength": 1 },
"role": { "type": "string", "minLength": 1 },
"scopes": { "type": "array", "items": { "type": "string", "minLength": 1 } },
"issuedAtMs": { "type": "integer", "minimum": 0 }
}
}
}
}
}
}
}

View file

@ -0,0 +1,38 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "oclaw://schemas/sessions.json",
"title": "SessionsParams",
"$defs": {
"NonEmptyString": { "type": "string", "minLength": 1 },
"SessionsListParams": {
"type": "object",
"additionalProperties": false,
"properties": {
"limit": { "type": "integer", "minimum": 1 },
"search": { "type": "string" }
}
},
"SessionsGetParams": {
"type": "object",
"additionalProperties": false,
"required": ["key"],
"properties": { "key": { "$ref": "#/$defs/NonEmptyString" } }
},
"SessionsSendParams": {
"type": "object",
"additionalProperties": false,
"required": ["key", "message"],
"properties": {
"key": { "$ref": "#/$defs/NonEmptyString" },
"message": { "type": "string" },
"thinking": { "type": "string" },
"attachments": { "type": "array", "items": {} },
"timeoutMs": { "type": "integer", "minimum": 0 },
"idempotencyKey": { "$ref": "#/$defs/NonEmptyString" },
"interaction_mode": { "$ref": "#/$defs/NonEmptyString" },
"specialist": { "$ref": "#/$defs/NonEmptyString" }
}
}
}
}

View file

@ -32,7 +32,7 @@ from oclaw.interfaces.ws.runtime_loop import close_ws as close_ws_impl, run_conn
from oclaw.interfaces.ws.server_methods_bridge import build_gateway_context, dispatch_via_server_methods
from oclaw.interfaces.ws.turn_runner import run_agent_turn_via_bridge
from oclaw.interfaces.ws.ws_schema import format_validation_errors, get_ws_schemas, validate_or_errors
from oclaw.openclaw_runtime.relay_pointer import validate_relay_share_envelope
from oclaw.runtime.relay_pointer import validate_relay_share_envelope
class OclawWsGatewayConnection:

View file

@ -2,14 +2,13 @@ from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from jsonschema import Draft202012Validator
from oclaw.platform.config.paths import PROJECT_ROOT
from oclaw.platform.config.runtime_paths import ws_protocol_schemas_root
_SCHEMA_DIR = Path(PROJECT_ROOT) / "oclaw" / "openclaw_protocol_schemas"
_SCHEMA_DIR = ws_protocol_schemas_root()
def _load_schema(name: str) -> dict[str, Any]:

View file

@ -105,7 +105,7 @@ def build_gateway_context(
rid = str(run_id or "").strip()
if not rid:
return {"status": "error", "runId": rid, "summary": "runId required"}
run = store.openclaw_run_get(run_id=rid)
run = store.oclaw_run_get(run_id=rid)
if not run:
return {"status": "pending", "runId": rid, "summary": "run not finished", "pollAfterMs": 250}
status = str(run.status or "").strip().lower()

View file

@ -5,9 +5,9 @@ import threading
import uuid
from typing import Any, Callable
from oclaw.agents.factory import build_gateway_executor
from oclaw.openclaw_runtime.gateway import OpenClawGateway
from oclaw.openclaw_runtime.types import StandardMessage
from oclaw.runtime.agents.factory import build_gateway_executor
from oclaw.runtime.gateway import OclawGateway
from oclaw.runtime.types import StandardMessage
from oclaw.platform.config.paths import db_path
from oclaw.platform.persistence.sqlite_store import SqliteStore
@ -27,7 +27,7 @@ async def run_agent_turn_via_bridge(
msg_text = str(p.get("message") or "").strip()
attachments = list(p.get("attachments") or [])
store = SqliteStore(db_path())
gw = OpenClawGateway(store=store)
gw = OclawGateway(store=store)
ctx = conn.auth_ctx or {}
tenant_id = str(ctx.get("tenant_id") or "")