|
|
@@ -409,6 +409,58 @@ class _ApprovalPrompt:
|
|
|
|
|
|
INTERACTIVE_PROMPT_TIMEOUT_SECONDS = 300
|
|
|
|
|
|
+# Check-mark variants that Chatto clients send for "the green check". The
|
|
|
+# prompts seed ✅ (U+2705), but emoji pickers offer near-identical glyphs with
|
|
|
+# a different codepoint — ✔️ adds a variation selector, ✓ is the plain text
|
|
|
+# check — and a client may also append or strip U+FE0F in transit. Matching
|
|
|
+# only the seeded form rejected the very reaction the prompt asks for.
|
|
|
+_CHECK_EMOJI_ALIASES: Final[frozenset[str]] = frozenset(
|
|
|
+ {"\u2705", "\u2714", "\u2713", "\u2611", "\u221a"}
|
|
|
+)
|
|
|
+
|
|
|
+
|
|
|
+def _normalize_reaction_emoji(emoji: str) -> str:
|
|
|
+ """The comparison key for a reaction emoji.
|
|
|
+
|
|
|
+ Strips variation selectors (U+FE0E/U+FE0F) so ✔️ and ✔ collapse, then
|
|
|
+ folds check-mark variants onto ✅. Keycap sequences (1️⃣ = digit + VS16 +
|
|
|
+ combining keycap) lose only the VS16 — digit and U+20E3 survive, and seed
|
|
|
+ and incoming reaction normalize to the same key.
|
|
|
+ """
|
|
|
+ stripped = emoji.replace("\ufe0e", "").replace("\ufe0f", "")
|
|
|
+ return "✅" if stripped in _CHECK_EMOJI_ALIASES else stripped
|
|
|
+
|
|
|
+
|
|
|
+# Reaction glyphs for the interactive prompt cards. The display forms keep
|
|
|
+# their variation selectors — they are what the card text and the seed
|
|
|
+# reactions show, and that is what should render as colour emoji. Reaction
|
|
|
+# *matching* never uses them directly: incoming emojis are normalized first
|
|
|
+# (_normalize_reaction_emoji), so every comparison happens against the
|
|
|
+# normalized *_KEY forms below.
|
|
|
+_PROMPT_NUMBER_EMOJIS: Final[tuple[str, ...]] = (
|
|
|
+ "1️⃣",
|
|
|
+ "2️⃣",
|
|
|
+ "3️⃣",
|
|
|
+ "4️⃣",
|
|
|
+ "5️⃣",
|
|
|
+ "6️⃣",
|
|
|
+ "7️⃣",
|
|
|
+ "8️⃣",
|
|
|
+ "9️⃣",
|
|
|
+)
|
|
|
+_PROMPT_OTHER_EMOJI: Final = "✏️"
|
|
|
+_PROMPT_APPROVAL_EMOJIS: Final[dict[str, str]] = {
|
|
|
+ "once": "✅",
|
|
|
+ "session": "🌀",
|
|
|
+ "always": "♾️",
|
|
|
+ "deny": "❌",
|
|
|
+}
|
|
|
+
|
|
|
+_NUMBER_EMOJI_KEYS: Final[tuple[str, ...]] = tuple(
|
|
|
+ _normalize_reaction_emoji(emoji) for emoji in _PROMPT_NUMBER_EMOJIS
|
|
|
+)
|
|
|
+_OTHER_EMOJI_KEY: Final = _normalize_reaction_emoji(_PROMPT_OTHER_EMOJI)
|
|
|
+
|
|
|
|
|
|
_UNSEEDED: Final = object()
|
|
|
"""Sentinel: nothing is known yet about how far this thread's session reaches.
|
|
|
@@ -1969,7 +2021,7 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
return False
|
|
|
room_id = str(room_id)
|
|
|
message_id = str(message_id)
|
|
|
- emoji = str(emoji)
|
|
|
+ emoji = _normalize_reaction_emoji(str(emoji))
|
|
|
actor_id = str(actor_id)
|
|
|
|
|
|
is_clarify = message_id in self._clarify_prompts
|
|
|
@@ -2023,11 +2075,9 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
if is_clarify:
|
|
|
from tools import clarify_gateway # type: ignore[import-not-found]
|
|
|
|
|
|
- number_emojis = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣"]
|
|
|
- other_emoji = "✏️"
|
|
|
choices = list(getattr(prompt, "choices", []) or [])
|
|
|
|
|
|
- if emoji == other_emoji:
|
|
|
+ if emoji == _OTHER_EMOJI_KEY:
|
|
|
prompt.awaiting_text = True # type: ignore[assignment]
|
|
|
try:
|
|
|
clarify_gateway.mark_awaiting_text(prompt.clarify_id) # type: ignore[attr-defined]
|
|
|
@@ -2041,8 +2091,8 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
logger.debug("Chatto: prompt edit failed", exc_info=True)
|
|
|
return True
|
|
|
|
|
|
- if emoji in number_emojis:
|
|
|
- idx = number_emojis.index(emoji)
|
|
|
+ if emoji in _NUMBER_EMOJI_KEYS:
|
|
|
+ idx = _NUMBER_EMOJI_KEYS.index(emoji)
|
|
|
if 0 <= idx < len(choices):
|
|
|
choice = choices[idx]
|
|
|
prompt.resolved = True
|
|
|
@@ -3263,9 +3313,9 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
success=False, error="Chatto client not available", retryable=True
|
|
|
)
|
|
|
|
|
|
- number_emojis = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣"]
|
|
|
+ number_emojis = _PROMPT_NUMBER_EMOJIS
|
|
|
emojis = [number_emojis[i] for i in range(len(choices))]
|
|
|
- other_emoji = "✏️"
|
|
|
+ other_emoji = _PROMPT_OTHER_EMOJI
|
|
|
|
|
|
lines = [f"❓ {question}", ""]
|
|
|
for i, choice in enumerate(choices):
|
|
|
@@ -3273,8 +3323,17 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
lines.append(f"{other_emoji} Other (type your answer)")
|
|
|
content = "\n".join(lines)
|
|
|
|
|
|
+ # The card must carry the conversation's thread: an answer to it is
|
|
|
+ # routed by session key, and a card outside the thread keys to a
|
|
|
+ # different session than the one waiting for the answer.
|
|
|
+ thread_id = self._resolve_outbound_thread(chat_id, None, metadata)
|
|
|
+
|
|
|
try:
|
|
|
- msg = await client.post_message(room_id=str(chat_id), body=content)
|
|
|
+ msg = await client.post_message(
|
|
|
+ room_id=str(chat_id),
|
|
|
+ body=content,
|
|
|
+ thread_root_event_id=str(thread_id) if thread_id else "",
|
|
|
+ )
|
|
|
except ChattoError as e:
|
|
|
logger.warning("Chatto: send_clarify post failed: %s", e)
|
|
|
return SendResult(success=False, error=str(e), retryable=True)
|
|
|
@@ -3307,6 +3366,17 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
self._clarify_prompts[message_id] = prompt
|
|
|
self._clarify_id_to_message[clarify_id] = message_id
|
|
|
|
|
|
+ if thread_id:
|
|
|
+ try:
|
|
|
+ await client.follow_thread(chat_id, thread_id)
|
|
|
+ except Exception:
|
|
|
+ logger.debug(
|
|
|
+ "Chatto: clarify follow_thread failed for %s/%s",
|
|
|
+ chat_id,
|
|
|
+ thread_id,
|
|
|
+ exc_info=True,
|
|
|
+ )
|
|
|
+
|
|
|
return SendResult(success=True, message_id=message_id)
|
|
|
|
|
|
async def retire_clarify_card(self, clarify_id: str, notice: str) -> None:
|
|
|
@@ -3341,23 +3411,19 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
|
|
|
BasePlatformAdapter override
|
|
|
"""
|
|
|
- emoji_map = {
|
|
|
- "once": "✅",
|
|
|
- "session": "🌀",
|
|
|
- "always": "♾️",
|
|
|
- "deny": "❌",
|
|
|
- }
|
|
|
- number_emojis = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣"]
|
|
|
-
|
|
|
choices = list(prompt.choices)
|
|
|
choices_map: dict[str, str] = {}
|
|
|
emojis: list[str] = []
|
|
|
for i, choice in enumerate(choices):
|
|
|
- emoji = emoji_map.get(choice)
|
|
|
+ emoji = _PROMPT_APPROVAL_EMOJIS.get(choice)
|
|
|
if emoji is None:
|
|
|
- emoji = number_emojis[i] if i < len(number_emojis) else "🔢"
|
|
|
+ emoji = (
|
|
|
+ _PROMPT_NUMBER_EMOJIS[i] if i < len(_PROMPT_NUMBER_EMOJIS) else "🔢"
|
|
|
+ )
|
|
|
emojis.append(emoji)
|
|
|
- choices_map[emoji] = choice
|
|
|
+ # Keys in the normalized form: reaction matching looks the
|
|
|
+ # incoming emoji up here after _normalize_reaction_emoji.
|
|
|
+ choices_map[_normalize_reaction_emoji(emoji)] = choice
|
|
|
|
|
|
client = await self._get_chatto_client()
|
|
|
if client is None:
|
|
|
@@ -3389,7 +3455,18 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
content = "\n".join(lines)
|
|
|
|
|
|
try:
|
|
|
- msg = await client.post_message(room_id=str(prompt.chat_id), body=content)
|
|
|
+ # Same thread logic as send_clarify: the card must land in the
|
|
|
+ # conversation's thread, or a text answer to it keys to a session
|
|
|
+ # that is not the one waiting on the approval.
|
|
|
+ thread_id = self._resolve_outbound_thread(
|
|
|
+ str(prompt.chat_id), None, prompt.metadata
|
|
|
+ )
|
|
|
+
|
|
|
+ msg = await client.post_message(
|
|
|
+ room_id=str(prompt.chat_id),
|
|
|
+ body=content,
|
|
|
+ thread_root_event_id=str(thread_id) if thread_id else "",
|
|
|
+ )
|
|
|
except ChattoError as e:
|
|
|
logger.warning("Chatto: send_exec_approval post failed: %s", e)
|
|
|
return SendResult(success=False, error=str(e), retryable=True)
|
|
|
@@ -3420,6 +3497,17 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
)
|
|
|
self._approval_prompts[message_id] = approval_prompt
|
|
|
|
|
|
+ if thread_id:
|
|
|
+ try:
|
|
|
+ await client.follow_thread(str(prompt.chat_id), thread_id)
|
|
|
+ except Exception:
|
|
|
+ logger.debug(
|
|
|
+ "Chatto: approval follow_thread failed for %s/%s",
|
|
|
+ prompt.chat_id,
|
|
|
+ thread_id,
|
|
|
+ exc_info=True,
|
|
|
+ )
|
|
|
+
|
|
|
return SendResult(success=True, message_id=message_id)
|
|
|
|
|
|
async def edit_message(
|