# 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,
)