Isolate official account RPC capabilities from API key device driver

This commit is contained in:
Pigway
2026-10-03 07:03:35 +08:00
parent 142d771ffa
commit 5aa0e9f762
15 changed files with 434 additions and 126 deletions
+1 -1
View File
@@ -762,7 +762,7 @@ def create_app(settings=None, start_worker=True):
dependencies=[Depends(require("devices:read"))],
)
def official_widgets(device_id: str):
return registry.get(device_id).widgets()
return registry.official_widgets(device_id)
@app.get(
"/api/v1/devices/{device_id}/current-content",
+8 -2
View File
@@ -4,9 +4,9 @@ from pigway_frame_flow.drivers.base import FrameDeviceDriver
from pigway_frame_flow.drivers.kokonna.client import KokonnaClient
from pigway_frame_flow.drivers.kokonna.driver import KokonnaDriver
from pigway_frame_flow.drivers.kokonna.mock import MockClient
from pigway_frame_flow.drivers.kokonna.widgets import WidgetReader
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.integrations.kokonna_official.widgets import WidgetReader
from pigway_frame_flow.settings import secret
@@ -26,6 +26,7 @@ class DeviceRegistry:
}
)
self.drivers: dict[str, FrameDeviceDriver] = {}
self.official: dict[str, WidgetReader] = {}
self.keys = []
for id, entry in devices.items():
if not entry.get("enabled", True):
@@ -55,9 +56,14 @@ class DeviceRegistry:
widgets = WidgetReader(
widget_token, entry.get("widget_robot_id"), settings.mock
)
self.drivers[id] = KokonnaDriver(client, id, settings.sanitize, widgets)
self.official[id] = widgets
self.drivers[id] = KokonnaDriver(client, id, settings.sanitize)
def get(self, id: str) -> FrameDeviceDriver:
if id not in self.drivers:
raise FlowError(E.DEVICE_NOT_FOUND, "Device not found", 404)
return self.drivers[id]
def official_widgets(self, id: str):
self.get(id)
return self.official[id].read()
-1
View File
@@ -4,7 +4,6 @@ from pigway_frame_flow.models import Device, GalleryImage
class FrameDeviceDriver(Protocol):
def widgets(self): ...
def invalidate(self) -> None: ...
def device(self, refresh: bool = False) -> Device: ...
def get_current_image(self) -> int | None: ...
+1 -5
View File
@@ -8,9 +8,8 @@ from pigway_frame_flow.models import (
class KokonnaDriver:
def __init__(self, client, device_id, sanitize=lambda v: v, widgets=None):
def __init__(self, client, device_id, sanitize=lambda v: v):
self.client, self.device_id, self.sanitize = client, device_id, sanitize
self.widget_reader = widgets
self.cached: Device | None = None
self.cached_at = 0.0
@@ -63,9 +62,6 @@ class KokonnaDriver:
self.cached_at = time.monotonic()
return result
def widgets(self):
return self.widget_reader.read()
def get_current_image(self):
return self.device().current_content.id
+3 -116
View File
@@ -1,118 +1,5 @@
"""Read-only official widget RPC; separate from the device OpenAPI credential."""
"""Compatibility import; official capabilities live outside device drivers."""
import json
import time
from pathlib import Path
from pigway_frame_flow.integrations.kokonna_official.widgets import WidgetReader
import requests
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.models import Widget, WidgetState
class WidgetReader:
def __init__(self, token="", robot_id=None, mock=False):
self._token = token
self._robot_id = robot_id
self.mock = mock
self._cached = None
self._cached_at = 0.0
def read(self) -> WidgetState:
if self._cached is not None and time.monotonic() - self._cached_at < 60:
return self._cached
snapshot = json.loads(
Path(__file__).with_name("widget_catalog.json").read_text()
)
catalog = [
Widget(**{k: row[k] for k in ("id", "name", "tags", "arg_keys")})
for row in snapshot["catalog"]
]
if self.mock:
return WidgetState(
source="mock",
configured=True,
catalog=catalog,
current=catalog[0],
current_verified=True,
)
if not self._token or self._robot_id is None:
return WidgetState(
source="snapshot",
configured=False,
catalog=catalog,
observed_at=snapshot["observed_at"],
status="AUTH_REQUIRED",
)
try:
response = requests.post(
"https://robot.galaxyguide.cn/api/rpc",
headers={"Authorization": f"Bearer {self._token}"},
json={
"jsonrpc": "2.0",
"method": "appFrame.getFrameCovers",
"params": {"robotId": self._robot_id},
"id": int(time.time() * 1000),
},
timeout=15,
allow_redirects=False,
)
if response.status_code in (401, 403):
raise FlowError(E.UNAUTHORIZED, "Official widget session expired", 401)
response.raise_for_status()
payload = response.json()
if payload.get("error"):
raise FlowError(
E.CLOUD_UNAVAILABLE, "Official widget query rejected", 502
)
data = payload["result"]
rows = data["rows"]
catalog = [
Widget(
id=row["id"],
name=row["name"],
tags=row.get("tags") or [],
arg_keys=list((row.get("args") or {}).keys())
if isinstance(row.get("args"), dict)
else [],
)
for row in rows
]
selected = data.get("frameCoverRobot") or {}
cover = selected.get("frameCover") or {}
current_id = cover.get("id", selected.get("coverId"))
current = next((row for row in catalog if row.id == current_id), None)
robot = data.get("robot") or {}
no_widget = (
"frameCoverRobot" in data
and data["frameCoverRobot"] is None
and "frameCoverRobotId" in robot
and robot["frameCoverRobotId"] is None
)
result = WidgetState(
source="live",
configured=True,
catalog=catalog,
current=current,
current_verified=current is not None or no_widget,
status="OK"
if current
else "NO_WIDGET"
if no_widget
else "CURRENT_UNVERIFIED",
)
except FlowError:
raise
except (
requests.RequestException,
ValueError,
KeyError,
TypeError,
AttributeError,
):
raise FlowError(
E.CLOUD_UNAVAILABLE, "Official widget query unavailable", 502
) from None
self._cached, self._cached_at = result, time.monotonic()
return result
__all__ = ["WidgetReader"]
@@ -0,0 +1 @@
"""Optional integrations, independent of device drivers."""
@@ -0,0 +1,23 @@
# 官方能力模块 / Official capabilities
Optional official-account integration, independent of the KoKonna device API Key
client. The application composition root attaches it per configured device; the
FrameDeviceDriver protocol does not depend on account login or widgets.
- `rpc.py`: in-memory official login/session, disconnect, device discovery/detail
and widget query. Strict method allowlist, no retries, no raw vendor errors.
- `widgets.py`: normalized widget states and 60-second successful query cache.
- `capabilities.py`: full discovered client operation inventory and evidence.
Discovered does not mean implemented or enabled. Unenabled operations are blocked.
- `widget_catalog.json`: dated, non-secret fallback catalog.
The public `/api/v1/devices/{device_id}/widgets` endpoint is unchanged. The old
`drivers.kokonna.widgets.WidgetReader` import remains a compatibility re-export.
No raw RPC proxy or public password-login endpoint is introduced. Future admin
account connection UI must use this module and keep credentials server-side.
No account or session secret is automatically persisted by this module.
设备 API Key 仍负责现有设备、图片和图库接口。官方账号功能集中在本目录,
不进入 DisplayService、Renderer、Scheduler 或 FrameDeviceDriver。
账号登录已提供内部客户端能力;页面连接入口、写操作和会话持久化尚未实现。
所有已发现但未启用的 RPC 方法均拒绝执行,不复用 API Key 的写入开关。
@@ -0,0 +1 @@
"""Optional KoKonna official-account capabilities; no device API Key dependency."""
@@ -0,0 +1,109 @@
"""Discovered RPC operations, not an authorization or live support guarantee."""
from dataclasses import dataclass
@dataclass(frozen=True)
class OfficialCapability:
method: str
implemented: bool
effect: str
evidence: str
READ_OPERATIONS = frozenset(
{"app.getRobots", "appFrame.getFrame", "appFrame.getFrameCovers"}
)
DISCOVERED_METHODS = (
"app.addFriendRobot",
"app.addShareRobot",
"app.bindRobot",
"app.changePassword",
"app.cleanMemory",
"app.createCustomRole",
"app.createPointOrder",
"app.deleteFriendRobot",
"app.forgetPassword",
"app.getChatHistory",
"app.getCustomRoleInfo",
"app.getDistributors",
"app.getFirmwares",
"app.getPayOrder",
"app.getPayOrderList",
"app.getPoints",
"app.getRedeem",
"app.getRobot",
"app.getRobotByUUID",
"app.getRobots",
"app.getUserInfo",
"app.login",
"app.redeemRobotPointByCode",
"app.register",
"app.removeShare",
"app.sendChat",
"app.sendCtrl",
"app.setRobotNetwork",
"app.setTimezone",
"app.setUpgradeFirmware",
"app.testCustomRoleVoice",
"app.unlink",
"app.updateCustomRole",
"app.updateRobot",
"app.wxlogin",
"appFrame.addFrameArtist",
"appFrame.addFrameGalleryImage",
"appFrame.addTag",
"appFrame.changeFrameImageName",
"appFrame.changeName",
"appFrame.chat",
"appFrame.deleteAllImage",
"appFrame.deleteImage",
"appFrame.displayImage",
"appFrame.enablePair",
"appFrame.enableShareCode",
"appFrame.getFrame",
"appFrame.getFrameArtists",
"appFrame.getFrameCovers",
"appFrame.getFrameGalleryImages",
"appFrame.getFrameGallerys",
"appFrame.getFrameSplashs",
"appFrame.getImages",
"appFrame.getShareImages",
"appFrame.importImagesByTransferToken",
"appFrame.likeImage",
"appFrame.recognizeVoice",
"appFrame.refreshImage",
"appFrame.refreshShareUUID",
"appFrame.removeTag",
"appFrame.reportFrameImage",
"appFrame.reset",
"appFrame.setFrameCover",
"appFrame.setFrameImageChangeInterval",
"appFrame.setFrameImageChangeMinute",
"appFrame.setFrameImageChangeTags",
"appFrame.setFrameImageChangeType",
"appFrame.setImageTags",
"appFrame.setRotate",
"appFrame.setSplash",
"appFrame.shareImage",
"appFrame.text2speech",
"appFrame.uploadImage",
)
def capabilities() -> tuple[OfficialCapability, ...]:
return tuple(
OfficialCapability(
method=method,
implemented=method in READ_OPERATIONS or method == "app.login",
effect="read"
if method in READ_OPERATIONS
else "authentication"
if method == "app.login"
else "unverified",
evidence="live_verified"
if method in READ_OPERATIONS or method == "app.login"
else "apk_discovered",
)
for method in DISCOVERED_METHODS
)
@@ -0,0 +1,82 @@
"""Strict account RPC allowlist. Never falls back to a device key or retries."""
import time
import requests
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.integrations.kokonna_official.capabilities import READ_OPERATIONS
class OfficialRPC:
def __init__(self, token: str = ""):
self._token = token
@property
def authorized(self) -> bool:
return bool(self._token)
def _call(self, method: str, params: dict) -> dict:
if method not in READ_OPERATIONS and method != "app.login":
raise FlowError(
E.UNSUPPORTED_CAPABILITY, "Official operation is not enabled", 422
)
if method != "app.login" and not self._token:
raise FlowError(
E.CONFIG_REQUIRED, "Official account authorization required", 503
)
try:
response = requests.post(
"https://robot.galaxyguide.cn/api/rpc",
headers={"Authorization": "Bearer " + self._token}
if method != "app.login"
else {},
json={
"jsonrpc": "2.0",
"method": method,
"params": params,
"id": int(time.time() * 1000),
},
timeout=15,
allow_redirects=False,
)
if response.status_code in (401, 403):
raise FlowError(
E.UNAUTHORIZED, "Official account authentication required", 401
)
payload = response.json()
error = payload.get("error")
if isinstance(error, dict) and error.get("message") == "请重新登陆":
raise FlowError(E.UNAUTHORIZED, "Official account session expired", 401)
response.raise_for_status()
if error or not isinstance(payload.get("result"), dict):
raise ValueError()
return payload["result"]
except FlowError:
raise
except (requests.RequestException, ValueError, AttributeError):
raise FlowError(
E.CLOUD_UNAVAILABLE, "Official service query failed", 502
) from None
def login(self, username: str, password: str) -> None:
if not username or not password:
raise FlowError(E.INVALID_REQUEST, "Official account credentials required")
result = self._call("app.login", {"username": username, "password": password})
token = result.get("token")
if not isinstance(token, str) or not token:
raise FlowError(E.UNAUTHORIZED, "Official account login failed", 401)
self._token = token
def disconnect(self) -> None:
self._token = ""
def devices(self) -> dict:
return self._call("app.getRobots", {})
def device(self, robot_id: int) -> dict:
return self._call("appFrame.getFrame", {"id": robot_id})
def widgets(self, robot_id: int) -> dict:
return self._call("appFrame.getFrameCovers", {"robotId": robot_id})
@@ -0,0 +1,100 @@
"""Read-only official widget RPC; separate from the device OpenAPI credential."""
import json
import time
from pathlib import Path
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.integrations.kokonna_official.rpc import OfficialRPC
from pigway_frame_flow.models import Widget, WidgetState
class WidgetReader:
def __init__(self, token="", robot_id=None, mock=False):
self.rpc = OfficialRPC(token)
self._robot_id = robot_id
self.mock = mock
self._cached = None
self._cached_at = 0.0
def read(self) -> WidgetState:
if (
self.rpc.authorized
and self._cached is not None
and time.monotonic() - self._cached_at < 60
):
return self._cached
snapshot = json.loads(
Path(__file__).with_name("widget_catalog.json").read_text()
)
catalog = [
Widget(**{k: row[k] for k in ("id", "name", "tags", "arg_keys")})
for row in snapshot["catalog"]
]
if self.mock:
return WidgetState(
source="mock",
configured=True,
catalog=catalog,
current=catalog[0],
current_verified=True,
)
if not self.rpc.authorized or self._robot_id is None:
return WidgetState(
source="snapshot",
configured=False,
catalog=catalog,
observed_at=snapshot["observed_at"],
status="AUTH_REQUIRED",
)
try:
data = self.rpc.widgets(self._robot_id)
rows = data["rows"]
catalog = [
Widget(
id=row["id"],
name=row["name"],
tags=row.get("tags") or [],
arg_keys=list((row.get("args") or {}).keys())
if isinstance(row.get("args"), dict)
else [],
)
for row in rows
]
selected = data.get("frameCoverRobot") or {}
cover = selected.get("frameCover") or {}
current_id = cover.get("id", selected.get("coverId"))
current = next((row for row in catalog if row.id == current_id), None)
robot = data.get("robot") or {}
no_widget = (
"frameCoverRobot" in data
and data["frameCoverRobot"] is None
and "frameCoverRobotId" in robot
and robot["frameCoverRobotId"] is None
)
result = WidgetState(
source="live",
configured=True,
catalog=catalog,
current=current,
current_verified=current is not None or no_widget,
status="OK"
if current
else "NO_WIDGET"
if no_widget
else "CURRENT_UNVERIFIED",
)
except FlowError:
raise
except (
ValueError,
KeyError,
TypeError,
AttributeError,
):
raise FlowError(
E.CLOUD_UNAVAILABLE, "Official widget query unavailable", 502
) from None
self._cached, self._cached_at = result, time.monotonic()
return result