Source code for tools.set_conversation_choices

"""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)." ), } )