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