|
# retoor <retoor@molodetz.nl>
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from typing import Any
|
|
|
|
from devplacepy.services.devii.errors import ToolInputError
|
|
|
|
from .broker import InteractionBroker
|
|
from .capabilities import interactions_enabled
|
|
from . import prefs
|
|
from .schema import sanitize_text
|
|
|
|
logger = logging.getLogger("devii.interaction")
|
|
|
|
|
|
class InteractionController:
|
|
def __init__(
|
|
self,
|
|
broker: InteractionBroker,
|
|
owner_kind: str = "guest",
|
|
owner_id: str = "",
|
|
) -> None:
|
|
self._broker = broker
|
|
self._owner_kind = owner_kind
|
|
self._owner_id = owner_id
|
|
|
|
@property
|
|
def broker(self) -> InteractionBroker:
|
|
return self._broker
|
|
|
|
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
|
|
if name == "interactions_get":
|
|
return self._pref_get()
|
|
if name == "interactions_set":
|
|
return self._pref_set(arguments or {})
|
|
if name in ("ui_prompt", "ui_notify") and not interactions_enabled(
|
|
self._owner_kind, self._owner_id
|
|
):
|
|
raise ToolInputError(
|
|
"Interactive widgets are disabled for this account. "
|
|
"Enable them with interactions_set or on the profile, or use a short numbered menu."
|
|
)
|
|
if name == "ui_prompt":
|
|
return await self._prompt(arguments)
|
|
if name == "ui_cancel":
|
|
return await self._cancel(arguments)
|
|
if name == "ui_notify":
|
|
return await self._notify(arguments)
|
|
raise ToolInputError(f"Unknown interaction tool: {name}")
|
|
|
|
def _require_user(self) -> None:
|
|
if self._owner_kind != "user" or not self._owner_id:
|
|
raise ToolInputError(
|
|
"Interactive-widget preferences are only available for signed-in users."
|
|
)
|
|
|
|
def _pref_get(self) -> str:
|
|
self._require_user()
|
|
data = prefs.snapshot(self._owner_kind, self._owner_id)
|
|
return json.dumps({"status": "success", **data}, ensure_ascii=False)
|
|
|
|
def _pref_set(self, arguments: dict[str, Any]) -> str:
|
|
self._require_user()
|
|
reset = arguments.get("reset")
|
|
if isinstance(reset, bool):
|
|
do_reset = reset
|
|
else:
|
|
do_reset = str(reset or "").strip().lower() in ("true", "1", "yes", "on")
|
|
if do_reset:
|
|
data = prefs.set_user_pref(self._owner_id, None)
|
|
return json.dumps({"status": "success", **data}, ensure_ascii=False)
|
|
if "enabled" not in arguments or arguments.get("enabled") is None:
|
|
raise ToolInputError(
|
|
"Pass enabled=true/false to override, or reset=true to inherit the admin default."
|
|
)
|
|
raw = arguments.get("enabled")
|
|
if isinstance(raw, bool):
|
|
enabled = raw
|
|
else:
|
|
enabled = str(raw).strip().lower() in ("true", "1", "yes", "on")
|
|
data = prefs.set_user_pref(self._owner_id, enabled)
|
|
return json.dumps({"status": "success", **data}, ensure_ascii=False)
|
|
|
|
async def _prompt(self, arguments: dict[str, Any]) -> str:
|
|
ctx = self._broker.channel_context()
|
|
if "ui_prompt" not in ctx.tools and self._broker.open_id():
|
|
raise ToolInputError(
|
|
"An interaction is already open. Wait for it, or call ui_cancel first."
|
|
)
|
|
if "ui_prompt" not in ctx.tools:
|
|
raise ToolInputError(
|
|
"ui_prompt is not available. Enable interactive widgets with interactions_set "
|
|
"or use a short numbered markdown menu."
|
|
)
|
|
result = await self._broker.prompt(arguments or {})
|
|
return json.dumps(result.to_dict(), ensure_ascii=False)
|
|
|
|
async def _cancel(self, arguments: dict[str, Any]) -> str:
|
|
interaction_id = sanitize_text(arguments.get("id", ""), 64)
|
|
reason = sanitize_text(arguments.get("reason", "cancelled"), 40) or "cancelled"
|
|
result = await self._broker.cancel(interaction_id, reason=reason)
|
|
return json.dumps(result.to_dict(), ensure_ascii=False)
|
|
|
|
async def _notify(self, arguments: dict[str, Any]) -> str:
|
|
text = sanitize_text(arguments.get("text", ""), 200)
|
|
if not text:
|
|
raise ToolInputError("'text' is required for ui_notify.")
|
|
ctx = self._broker.channel_context()
|
|
if not ctx.capabilities.get("ephemeral_status"):
|
|
return json.dumps(
|
|
{
|
|
"status": "skipped",
|
|
"channel_id": ctx.channel_id,
|
|
"message": "ephemeral status is not supported on this channel",
|
|
},
|
|
ensure_ascii=False,
|
|
)
|
|
emit = getattr(self._broker, "_emit", None)
|
|
if emit is not None:
|
|
await emit({"type": "status", "text": text})
|
|
return json.dumps(
|
|
{
|
|
"status": "ok",
|
|
"channel_id": ctx.channel_id,
|
|
"text": text,
|
|
},
|
|
ensure_ascii=False,
|
|
)
|