"""Structured flavor-choice buttons for normal conversation (non-game).
Stores choice rows in Redis for the Discord send pipeline. Unlike
``set_game_choices``, this does not touch game session state or the
``game:choices:`` Redis key.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
import jsonutil as json
if TYPE_CHECKING:
from tool_context import ToolContext
logger = logging.getLogger(__name__)
TOOL_NAME = "set_conversation_choices"
TOOL_DESCRIPTION = (
"Submit optional gray flavor buttons for normal chat (not a game turn). "
"Call at the end of your reply after narrative text. Each entry needs "
"an emoji and a short label. Buttons are subtle secondary style and do "
"not start any game countdown. Do not use this during an active "
"GameGirl Color session when ``set_game_choices`` is required instead."
)
TOOL_PARAMETERS = {
"type": "object",
"properties": {
"choices": {
"type": "array",
"description": (
"Array of choice objects with emoji and label. "
"Maximum 10 choices; labels under 70 characters."
),
"items": {
"type": "object",
"properties": {
"emoji": {
"type": "string",
"description": "Single emoji for the button.",
},
"label": {
"type": "string",
"description": "Short label text for the choice.",
},
},
"required": ["emoji", "label"],
},
"maxItems": 10,
},
},
"required": ["choices"],
}
[docs]
async def run(
choices: list[dict[str, str]],
ctx: ToolContext | None = None,
) -> str:
"""Persist non-game flavor choices for the outbound Discord renderer.
Backs the ``set_conversation_choices`` tool: it lets the bot attach a few
gray secondary buttons under an ordinary chat reply without involving any
game session or starting a countdown. Each incoming choice is validated --
the list is capped at 10, the emoji is normalized against a unicode-emoji
regex (with a fallback so a malformed emoji never blocks the button), and
the label is truncated to 70 characters. The cleaned list is then stored as
JSON under the per-channel Redis key ``conversation:choices:{channel_id}``
with a 300-second TTL; a Redis failure is logged but does not fail the call.
The outbound renderer in ``game_ui/components.py`` later reads that key
(and deletes it after consuming) to draw the buttons, deliberately keeping
it separate from the ``game:choices:`` key used by ``set_game_choices``.
Registered via this module's ``TOOL_NAME`` / ``TOOL_DESCRIPTION`` /
``TOOL_PARAMETERS`` metadata and exposed as the module-level ``run`` handler;
dispatched by name from the inference worker's tool loop, not called
directly elsewhere in the repo.
Args:
choices: A list of choice dicts, each with an ``emoji`` and a ``label``;
only the first 10 valid entries are kept.
ctx: The active ``ToolContext`` supplying ``channel_id`` and the Redis
client, or ``None``.
Returns:
A JSON string with ``success`` true, the kept ``choices_count``, and an
instruction note, or a JSON error object when there is no context, no
input, or nothing survives validation.
"""
if ctx is None:
return json.dumps({"error": "No tool context available."})
if not choices:
return json.dumps({"error": "No choices provided."})
channel_id = str(ctx.channel_id)
clean_choices: list[dict[str, str]] = []
for c in choices[:10]:
emoji = c.get("emoji", "")
label = c.get("label", "")
if emoji and label:
import re as _re
_m = _re.match(
r"[\U0001f300-\U0001faff\U00002702-\U000027b0"
r"\U0000fe00-\U0000fe0f\U0000200d\U0001f3fb-\U0001f3ff]+",
emoji,
)
_clean_emoji = _m.group(0) if _m else (emoji[:2] if emoji else "\U0001f300")
clean_choices.append(
{
"emoji": _clean_emoji,
"label": label[:70],
}
)
if not clean_choices:
return json.dumps({"error": "No valid choices after validation."})
redis = getattr(ctx, "redis", None)
if redis is not None:
try:
await redis.set(
f"conversation:choices:{channel_id}",
json.dumps(clean_choices),
ex=300,
)
except Exception as exc:
logger.warning(
"Failed to store conversation choices in Redis: %s",
exc,
exc_info=True,
)
return json.dumps(
{
"success": True,
"choices_count": len(clean_choices),
"instruction": (
"Flavor choices registered. They will appear as gray secondary "
"buttons under your message on Discord (no game countdown)."
),
}
)