Source code for tools.dismiss_egregore

"""DISMISS -- send an egregore back to the Dollhouse.

Removes an active egregore from the VN canvas and the system context.
Clears their sprite and summoning prompt from Redis.

@fire @skull BACK TO THE SHELF, DOLL
"""

from __future__ import annotations

import json
import logging
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from tool_context import ToolContext

logger = logging.getLogger(__name__)

TOOL_NAME = "dismiss_egregore"
TOOL_DESCRIPTION = (
    "Send a summoned egregore back to the Dollhouse. Removes their "
    "sprite from the VN canvas and their persona from the active context.\n\n"
    "Use 'all' as the name to dismiss all active egregores at once.\n\n"
    "Examples:\n"
    "  - Dismiss Orion: name='orion'\n"
    "  - Dismiss everyone: name='all'"
)

TOOL_PARAMETERS = {
    "type": "object",
    "properties": {
        "name": {
            "type": "string",
            "description": (
                "Egregore name to dismiss (e.g. 'orion'), or 'all' to "
                "dismiss all active egregores."
            ),
        },
    },
    "required": ["name"],
}

ACTIVE_EGREGORES_KEY = "star:active_egregores"  # legacy global (unused)
CHARACTERS_KEY = "star:sprite:characters"  # legacy global (unused)


# 💀 Per-channel Redis key builders
def _egregore_key(channel_key: str) -> str:
    """Build the per-channel Redis key holding active egregore state.

    Namespaces the active-egregore registry by channel so each
    platform/channel pair (see :func:`_channel_key_from_ctx`) keeps its own set
    of summoned egregores, superseding the legacy global
    :data:`ACTIVE_EGREGORES_KEY`. This is a pure string builder with no I/O.

    The key it returns is read and written by :func:`run` in this module to
    load and persist the active-egregore dict; the same builder is used by
    ``tools/summon_egregore.py`` so summon and dismiss operate on the same key.

    Args:
        channel_key: The ``"{platform}:{channel_id}"`` identifier for the
            current channel.

    Returns:
        str: The Redis key ``"star:egregores:{channel_key}"``.
    """
    return f"star:egregores:{channel_key}"


def _characters_key(channel_key: str) -> str:
    """Build the per-channel Redis key holding sprite/character state.

    Namespaces the visual-novel character roster (the sprites shown on the VN
    canvas) by channel, superseding the legacy global :data:`CHARACTERS_KEY`.
    This is a pure string builder with no I/O.

    The returned key is read and written by :func:`run` to remove a dismissed
    egregore's sprite from the canvas roster; ``tools/summon_egregore.py`` and
    ``tools/set_sprite.py`` use the same builder so summon, set-sprite, and
    dismiss share one character store per channel.

    Args:
        channel_key: The ``"{platform}:{channel_id}"`` identifier for the
            current channel.

    Returns:
        str: The Redis key ``"star:sprite:chars:{channel_key}"``.
    """
    return f"star:sprite:chars:{channel_key}"


def _channel_key_from_ctx(ctx: "ToolContext") -> str:
    """Derive the per-channel namespace key from a tool context.

    Reads ``ctx.platform`` and ``ctx.channel_id`` (both defensively via
    ``getattr`` and coerced to a lowercase, empty-safe string) and joins them
    into the ``"{platform}:{channel_id}"`` token that scopes all per-channel
    egregore and sprite Redis keys. Performs no I/O and mutates nothing.

    Its output feeds :func:`_egregore_key` and :func:`_characters_key` and is
    published as ``channel_key`` on the ``star:sprite:update`` Redis channel so
    the web UI can filter sprite updates by channel. It is called by
    :func:`run` here and by the parallel ``tools/summon_egregore.py``,
    ``tools/set_sprite.py``, and ``tools/modulate_egregore_ncm.py`` so every
    egregore tool keys state identically.

    Args:
        ctx: The tool ``ToolContext`` carrying the originating ``platform`` and
            ``channel_id``.

    Returns:
        str: The channel namespace ``"{platform}:{channel_id}"`` (platform
        lowercased; missing parts render as empty strings).
    """
    plat = (getattr(ctx, "platform", "") or "").lower()
    cid = getattr(ctx, "channel_id", "") or ""
    return f"{plat}:{cid}"


[docs] async def run( name: str = "", ctx: "ToolContext | None" = None, ) -> str: """Dismiss one or all summoned egregores from the current channel. The ``dismiss_egregore`` tool's entry point: it tears down an active egregore (or every active egregore when *name* is ``"all"``) so its sprite leaves the visual-novel canvas and its persona leaves the active context, "sending it back to the Dollhouse." Stargazer is always preserved across an ``"all"`` dismissal. All state is keyed per channel via :func:`_channel_key_from_ctx`: it reads and rewrites the active-egregore registry (:func:`_egregore_key`) and the sprite/character roster (:func:`_characters_key`) through ``ctx.redis``, then publishes a ``dismiss`` payload on the ``star:sprite:update`` Redis channel so the web UI drops the sprites. For each dismissed egregore it also cleans up side channels: Discord impersonation webhooks are deleted via ``tools._egregore_discord.delete_egregore_webhook_by_id`` (using a Discord client from the context or the ``discord`` adapter), Matrix ghost users are removed by leaving the room through ``egregore_bridge.get_bridge``, and the per-egregore ``star:egregore_ncm:{name}`` NCM keys are deleted. Failures in those cleanups are logged and swallowed so the dismissal still completes. Dispatched by the tool executor via ``tool_loader`` when the model invokes the tool; there are no direct Python callers. Args: name: The egregore to dismiss (case-insensitive), or ``"all"`` to dismiss every active egregore in the channel. ctx: The tool ``ToolContext`` supplying ``redis``, ``platform``, ``channel_id``, and adapter access. Required. Returns: str: A JSON string. On success, a ``success: true`` object with a human-readable ``message``, the ``dismissed`` list, and the ``remaining_active`` egregores. Otherwise a ``success: false`` object with an ``error`` message when the context or Redis is missing, no name was given, the named egregore is not summoned, or an unexpected error occurs. """ if ctx is None: return json.dumps({"success": False, "error": "No tool context available."}) redis = getattr(ctx, "redis", None) if redis is None: return json.dumps({"success": False, "error": "Redis not available."}) name = name.strip().lower() if not name: return json.dumps({"success": False, "error": "No egregore name provided."}) try: channel_key = _channel_key_from_ctx(ctx) ek = _egregore_key(channel_key) ck = _characters_key(channel_key) # Read current active egregores (per-channel) active_raw = await redis.get(ek) active: dict = json.loads(active_raw) if active_raw else {} # Read current characters (per-channel) chars_raw = await redis.get(ck) characters: dict = json.loads(chars_raw) if chars_raw else {} dismissed: list[str] = [] discord_cleanups: list[tuple[str, str]] = [] if name == "all": # 💀 Dismiss ALL egregores (keep stargazer) for dname, st in active.items(): dd = (st or {}).get("discord") or {} wid, gid = dd.get("webhook_id"), dd.get("guild_id") if wid and gid: discord_cleanups.append((str(gid), str(wid))) dismissed = list(active.keys()) active.clear() # Remove all non-stargazer characters characters = {k: v for k, v in characters.items() if k == "stargazer"} else: if name not in active: return json.dumps( { "success": False, "error": f"'{name}' is not currently summoned.", "active": list(active.keys()), } ) st = active.get(name) or {} dd = st.get("discord") or {} wid, gid = dd.get("webhook_id"), dd.get("guild_id") if wid and gid: discord_cleanups.append((str(gid), str(wid))) # Remove specific egregore del active[name] characters.pop(name, None) dismissed = [name] # Write back to Redis (per-channel) await redis.set(ek, json.dumps(active)) await redis.set(ck, json.dumps(characters)) # Publish sprite update (with channel_key for WS filtering) try: await redis.publish( "star:sprite:update", json.dumps( { "action": "dismiss", "channel_key": channel_key, "dismissed": dismissed, "characters": characters, } ), ) except Exception: pass # Delete Discord webhooks (works when bot is connected even if dismiss from Matrix) if discord_cleanups: try: from tools._egregore_discord import delete_egregore_webhook_by_id client = None if getattr(ctx, "platform", None) in ("discord", "discord-self"): from tools._discord_helpers import get_discord_client client = get_discord_client(ctx) if client is None: ad = (ctx.adapters_by_name or {}).get("discord") if ad is not None: client = getattr(ad, "client", None) if client is not None: for gid, whid in discord_cleanups: ok, err = await delete_egregore_webhook_by_id( client, gid, whid, ) if not ok: logger.warning( "Discord webhook delete %s: %s", whid, err, ) else: logger.warning( "Discord client unavailable; could not delete %d " "egregore webhook(s)", len(discord_cleanups), ) except Exception as e: logger.warning("Discord webhook delete failed: %s", e) # Remove ghost users from Matrix room (only valid Matrix room IDs) plat = (getattr(ctx, "platform", None) or "").lower() if plat == "matrix": try: from egregore_bridge import get_bridge bridge = get_bridge() room_id = getattr(ctx, "channel_id", None) if room_id: for d in dismissed: await bridge.leave_room(d, room_id) except Exception as e: logger.warning("Ghost user leave failed: %s", e) try: for d in dismissed: await redis.delete(f"star:egregore_ncm:{d}") except Exception as e: logger.warning("egregore NCM cleanup failed: %s", e) names_str = ", ".join(d.title() for d in dismissed) logger.info("Egregores dismissed: %s", names_str) return json.dumps( { "success": True, "message": f"Dismissed: {names_str}. Back to the Dollhouse.", "dismissed": dismissed, "remaining_active": list(active.keys()), }, indent=2, ) except Exception as e: logger.error("dismiss_egregore error: %s", e, exc_info=True) return json.dumps( { "success": False, "error": f"Failed to dismiss egregore: {e}", } )