From 6e1ff0d85df0bfdb30ee5c915222d5c30c657cda Mon Sep 17 00:00:00 2001 From: "praisonai-triage-agent[bot]" <272766704+praisonai-triage-agent[bot]@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:22:38 +0000 Subject: [PATCH 1/3] fix: add interactive action registry for button/select callbacks (fixes #2104) - Created InteractiveRegistry and dispatch protocol in core SDK - Added encode_action/decode_callback for typed action handling - Updated Telegram, Discord, and Slack adapters to use registry - Registered handlers for 'command' and 'pair' namespaces - Maintains backward compatibility with existing pairing callbacks Co-authored-by: MervinPraison --- .../praisonaiagents/bots/__init__.py | 28 ++ .../praisonaiagents/bots/interactive.py | 248 ++++++++++++++++++ src/praisonai/praisonai/bots/discord.py | 136 +++++++++- src/praisonai/praisonai/bots/slack.py | 197 ++++++++++++-- src/praisonai/praisonai/bots/telegram.py | 155 ++++++++++- 5 files changed, 711 insertions(+), 53 deletions(-) create mode 100644 src/praisonai-agents/praisonaiagents/bots/interactive.py diff --git a/src/praisonai-agents/praisonaiagents/bots/__init__.py b/src/praisonai-agents/praisonaiagents/bots/__init__.py index 9612054c4..676cad593 100644 --- a/src/praisonai-agents/praisonaiagents/bots/__init__.py +++ b/src/praisonai-agents/praisonaiagents/bots/__init__.py @@ -38,6 +38,16 @@ ButtonStyle, BlockType, ) + from .interactive import ( + InteractiveContext, + InteractiveRegistry, + InteractiveHandler, + encode_action, + decode_callback, + get_registry, + register_handler, + unregister_handler, + ) from .protocols import ( BotProtocol, BotMessageProtocol, @@ -68,6 +78,16 @@ ButtonStyle, BlockType, ) +from .interactive import ( + InteractiveContext, + InteractiveRegistry, + InteractiveHandler, + encode_action, + decode_callback, + get_registry, + register_handler, + unregister_handler, +) from .config import BotConfig, BotOSConfig __all__ = [ @@ -99,4 +119,12 @@ "ButtonStyle", "BlockType", "PlatformCapabilities", + "InteractiveContext", + "InteractiveRegistry", + "InteractiveHandler", + "encode_action", + "decode_callback", + "get_registry", + "register_handler", + "unregister_handler", ] diff --git a/src/praisonai-agents/praisonaiagents/bots/interactive.py b/src/praisonai-agents/praisonaiagents/bots/interactive.py new file mode 100644 index 000000000..a114cfcd1 --- /dev/null +++ b/src/praisonai-agents/praisonaiagents/bots/interactive.py @@ -0,0 +1,248 @@ +""" +Interactive action dispatch system for messaging bots. + +Provides a registry and dispatch protocol for handling callbacks from +interactive UI elements (buttons, select menus) across all messaging platforms. +This complements the presentation.py render side with symmetric inbound handling. + +This is a core protocol with no heavy implementations - channel-specific +callback decoding belongs in the wrapper (praisonai). +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any, Awaitable, Callable, Dict, Optional, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from .presentation import PresentationAction + from .protocols import BotAdapter, BotMessage + +logger = logging.getLogger(__name__) + + +@dataclass +class InteractiveContext: + """Context for interactive callback handling. + + Attributes: + callback_data: Raw callback data from the platform + user_id: ID of the user who triggered the action + message_id: ID of the message containing the interactive element + chat_id: ID of the chat where the action was triggered + bot_adapter: The bot adapter handling this interaction + platform_data: Platform-specific additional data + """ + + callback_data: str + user_id: str + message_id: Optional[str] = None + chat_id: Optional[str] = None + bot_adapter: Optional["BotAdapter"] = None + platform_data: Dict[str, Any] = None + + def __post_init__(self): + if self.platform_data is None: + self.platform_data = {} + + +InteractiveHandler = Callable[[InteractiveContext], Awaitable[Optional[str]]] + + +def encode_action(namespace: str, action: "PresentationAction") -> str: + """Encode an action with namespace for callback data. + + Args: + namespace: The namespace for this action handler + action: The presentation action to encode + + Returns: + Encoded callback data string + """ + from .presentation import ActionType + + if action.type == ActionType.CALLBACK: + # For callback type, encode namespace with the value + if action.value: + return f"{namespace}:{action.value}" + return namespace + elif action.type == ActionType.COMMAND: + # For command type, use cmd: prefix + if action.command: + return f"cmd:{action.command}" + return namespace + else: + # URL and web_app types don't need encoding + return namespace + + +def decode_callback(data: str) -> Tuple[str, Dict[str, Any]]: + """Decode callback data into namespace and payload. + + Args: + data: Raw callback data string + + Returns: + Tuple of (namespace, payload_dict) + """ + if not data: + return ("unknown", {}) + + # Handle command callbacks (cmd:) + if data.startswith("cmd:"): + command = data[4:] + return ("command", {"command": command}) + + # Handle namespaced callbacks (namespace:payload) + if ":" in data: + parts = data.split(":", 1) + namespace = parts[0] + payload = parts[1] if len(parts) > 1 else "" + return (namespace, {"value": payload}) + + # Plain data without namespace + return (data, {}) + + +class InteractiveRegistry: + """Registry for interactive callback handlers. + + Manages registration and dispatch of handlers for different + callback namespaces. Each namespace can have one handler. + """ + + def __init__(self): + """Initialize the registry.""" + self._handlers: Dict[str, InteractiveHandler] = {} + self._fallback_handler: Optional[InteractiveHandler] = None + + def register( + self, + namespace: str, + handler: InteractiveHandler + ) -> None: + """Register a handler for a namespace. + + Args: + namespace: The namespace to handle (e.g., "approval", "menu") + handler: Async function to handle callbacks in this namespace + """ + if namespace in self._handlers: + logger.warning(f"Overwriting existing handler for namespace: {namespace}") + self._handlers[namespace] = handler + logger.debug(f"Registered handler for namespace: {namespace}") + + def unregister(self, namespace: str) -> None: + """Unregister a handler. + + Args: + namespace: The namespace to unregister + """ + if namespace in self._handlers: + del self._handlers[namespace] + logger.debug(f"Unregistered handler for namespace: {namespace}") + + def set_fallback(self, handler: InteractiveHandler) -> None: + """Set a fallback handler for unmatched callbacks. + + Args: + handler: Async function to handle unmatched callbacks + """ + self._fallback_handler = handler + + async def dispatch(self, context: InteractiveContext) -> bool: + """Dispatch a callback to the appropriate handler. + + Args: + context: The interactive context + + Returns: + True if handled, False otherwise + """ + namespace, payload = decode_callback(context.callback_data) + + # Try to find a handler for this namespace + handler = self._handlers.get(namespace) + + if handler: + try: + # Add decoded payload to context + context.platform_data["decoded_namespace"] = namespace + context.platform_data["decoded_payload"] = payload + + result = await handler(context) + if result: + logger.debug(f"Handler for namespace '{namespace}' returned: {result}") + return True + except Exception as e: + logger.error(f"Error in handler for namespace '{namespace}': {e}") + return False + + # Try fallback handler + if self._fallback_handler: + try: + context.platform_data["decoded_namespace"] = namespace + context.platform_data["decoded_payload"] = payload + + result = await self._fallback_handler(context) + if result: + logger.debug(f"Fallback handler returned: {result}") + return True + except Exception as e: + logger.error(f"Error in fallback handler: {e}") + return False + + logger.debug(f"No handler found for namespace: {namespace}") + return False + + def has_handler(self, namespace: str) -> bool: + """Check if a namespace has a registered handler. + + Args: + namespace: The namespace to check + + Returns: + True if handler exists + """ + return namespace in self._handlers + + def list_namespaces(self) -> list[str]: + """List all registered namespaces. + + Returns: + List of namespace names + """ + return list(self._handlers.keys()) + + +# Global registry instance +_global_registry = InteractiveRegistry() + + +def get_registry() -> InteractiveRegistry: + """Get the global interactive registry. + + Returns: + The global InteractiveRegistry instance + """ + return _global_registry + + +def register_handler(namespace: str, handler: InteractiveHandler) -> None: + """Register a handler in the global registry. + + Args: + namespace: The namespace to handle + handler: Async function to handle callbacks + """ + _global_registry.register(namespace, handler) + + +def unregister_handler(namespace: str) -> None: + """Unregister a handler from the global registry. + + Args: + namespace: The namespace to unregister + """ + _global_registry.unregister(namespace) \ No newline at end of file diff --git a/src/praisonai/praisonai/bots/discord.py b/src/praisonai/praisonai/bots/discord.py index 7fdd811cc..f0ddedba2 100644 --- a/src/praisonai/praisonai/bots/discord.py +++ b/src/praisonai/praisonai/bots/discord.py @@ -123,6 +123,9 @@ def __init__( # Pairing system self._pairing_store = PairingStore() self._pairing_callback_handler = PairingCallbackHandler(self._pairing_store) + + # Register interactive handlers + self._register_interactive_handlers() self._bot_context: Optional[BotContext] = None @property @@ -695,24 +698,137 @@ async def reply(self, chat_id: str, text: str) -> None: except Exception as e: logger.error(f"Failed to send reply: {e}") + def _register_interactive_handlers(self): + """Register handlers for interactive callbacks.""" + from praisonaiagents.bots import get_registry + + registry = get_registry() + + # Register handler for command callbacks + async def handle_command_callback(ctx): + """Handle command callbacks from buttons.""" + payload = ctx.platform_data.get("decoded_payload", {}) + command = payload.get("command", "") + + # Get the Discord interaction object + interaction = ctx.platform_data.get("interaction") + if not interaction: + return None + + # Parse the command (remove leading slash if present) + if command.startswith("/"): + command = command[1:] + + # Split command and args + parts = command.split(maxsplit=1) + cmd_name = parts[0] if parts else "" + cmd_args = parts[1] if len(parts) > 1 else "" + + # Check if command exists in handlers + if cmd_name in self._command_handlers: + handler = self._command_handlers[cmd_name] + try: + # Create a minimal message object for the handler + from praisonaiagents.bots import BotMessage, BotUser + message = BotMessage( + message_id=str(interaction.message.id) if interaction.message else "", + content=f"/{command}", + sender=BotUser(user_id=ctx.user_id), + chat_id=str(interaction.channel_id) if hasattr(interaction, "channel_id") else "", + command=cmd_name, + command_args=cmd_args + ) + + if asyncio.iscoroutinefunction(handler): + await handler(message) + else: + handler(message) + + # Update the message to show command was executed + await interaction.edit_original_response( + content=f"{interaction.message.content}\n\n✅ Command executed: /{cmd_name}", + view=None + ) + return f"Command {cmd_name} executed" + except Exception as e: + logger.error(f"Command handler error: {e}") + await interaction.edit_original_response( + content=f"{interaction.message.content}\n\n❌ Error executing command", + view=None + ) + return f"Error: {e}" + + logger.debug(f"Unknown command from button: {cmd_name}") + return None + + # Register the command handler + registry.register("command", handle_command_callback) + + # Register handler for pairing callbacks using the new system + async def handle_pairing_callback(ctx): + """Handle pairing callbacks through the new registry.""" + interaction = ctx.platform_data.get("interaction") + if not interaction: + return None + + # The pairing handler already exists, we just wrap it + result = await self._pairing_callback_handler.handle_approval_callback( + callback_data=ctx.callback_data, + owner_user_id=ctx.user_id, + bot_adapter=self + ) + + # Update the message with result + if interaction.message: + await interaction.edit_original_response( + content=f"{interaction.message.content}\n\n{result.message}", + view=None # Remove buttons + ) + + return f"Pairing {result.action}" + + # Register the pairing handler + registry.register("pair", handle_pairing_callback) + async def _handle_pairing_interaction(self, interaction, custom_id: str): - """Handle button interaction for pairing approval.""" + """Handle button interaction through the interactive registry.""" try: # Defer the interaction await interaction.response.defer() - # Handle the pairing callback - result = await self._pairing_callback_handler.handle_approval_callback( + # Create interactive context + from praisonaiagents.bots import InteractiveContext, get_registry + ctx = InteractiveContext( callback_data=custom_id, - owner_user_id=str(interaction.user.id), - bot_adapter=self + user_id=str(interaction.user.id), + message_id=str(interaction.message.id) if interaction.message else None, + chat_id=str(interaction.channel_id) if hasattr(interaction, "channel_id") else None, + bot_adapter=self, + platform_data={ + "interaction": interaction, + } ) - # Edit the message with result - await interaction.edit_original_response( - content=f"{interaction.message.content}\n\n{result.message}", - view=None # Remove buttons - ) + # Try to dispatch through the interactive registry + registry = get_registry() + handled = await registry.dispatch(ctx) + + if not handled: + # Fallback: handle legacy pairing callbacks + if custom_id.startswith("pair:"): + result = await self._pairing_callback_handler.handle_approval_callback( + callback_data=custom_id, + owner_user_id=str(interaction.user.id), + bot_adapter=self + ) + + # Edit the message with result + await interaction.edit_original_response( + content=f"{interaction.message.content}\n\n{result.message}", + view=None # Remove buttons + ) + else: + logger.debug(f"Unhandled callback: {custom_id}") except Exception as e: logger.error(f"Failed to handle pairing interaction: {e}") diff --git a/src/praisonai/praisonai/bots/slack.py b/src/praisonai/praisonai/bots/slack.py index 76359be50..c0d6058a5 100644 --- a/src/praisonai/praisonai/bots/slack.py +++ b/src/praisonai/praisonai/bots/slack.py @@ -10,6 +10,7 @@ import asyncio import logging import os +import re import time from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING, Union @@ -135,6 +136,9 @@ def __init__( # Pairing system self._pairing_store = PairingStore() self._pairing_callback_handler = PairingCallbackHandler(self._pairing_store) + + # Register interactive handlers + self._register_interactive_handlers() self._bot_context: Optional[BotContext] = None # Audio capabilities @@ -419,46 +423,67 @@ async def handle_command(ack, command_data, respond, cmd=command, hdlr=handler): logger.error(f"Command handler error: {e}") await respond(f"Error: {str(e)}") - # Add block action handler for pairing buttons - @self._app.action("pair_approve") - @self._app.action("pair_deny") + # Add generic block action handler for all interactive buttons + @self._app.action(re.compile(".*")) async def handle_block_actions(ack, body, action): await ack() # Extract callback data from button value - callback_data = action.get("value") + callback_data = action.get("value", action.get("action_id", "")) if not callback_data: return - # Handle the pairing callback - result = await self._pairing_callback_handler.handle_approval_callback( + # Create interactive context + from praisonaiagents.bots import InteractiveContext, get_registry + ctx = InteractiveContext( callback_data=callback_data, - owner_user_id=body["user"]["id"], - bot_adapter=self + user_id=body["user"]["id"], + message_id=body["message"]["ts"] if "message" in body else None, + chat_id=body["channel"]["id"] if "channel" in body else None, + bot_adapter=self, + platform_data={ + "body": body, + "action": action, + } ) - # Update the message - blocks = body["message"]["blocks"] - # Remove the action block - blocks = [block for block in blocks if block["type"] != "actions"] - # Add result message - blocks.append({ - "type": "section", - "text": { - "type": "mrkdwn", - "text": result.message - } - }) + # Try to dispatch through the interactive registry + registry = get_registry() + handled = await registry.dispatch(ctx) - try: - await self._client.chat_update( - channel=body["channel"]["id"], - ts=body["message"]["ts"], - blocks=blocks, - text=body["message"]["text"] - ) - except Exception as e: - logger.error(f"Failed to update message: {e}") + if not handled: + # Fallback: handle legacy pairing callbacks + if callback_data.startswith("pair:"): + result = await self._pairing_callback_handler.handle_approval_callback( + callback_data=callback_data, + owner_user_id=body["user"]["id"], + bot_adapter=self + ) + + # Update the message + blocks = body["message"]["blocks"] + # Remove the action block + blocks = [block for block in blocks if block["type"] != "actions"] + # Add result message + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": result.message + } + }) + + try: + await self._client.chat_update( + channel=body["channel"]["id"], + ts=body["message"]["ts"], + blocks=blocks, + text=body["message"]["text"] + ) + except Exception as e: + logger.error(f"Failed to update message: {e}") + else: + logger.debug(f"Unhandled callback: {callback_data}") self._is_running = True logger.info(f"Slack bot started: {self._bot_user.username}") @@ -795,6 +820,120 @@ def _convert_event_to_message(self, event: Dict[str, Any]) -> BotMessage: ) # Adapter methods for pairing system + def _register_interactive_handlers(self): + """Register handlers for interactive callbacks.""" + from praisonaiagents.bots import get_registry + + registry = get_registry() + + # Register handler for command callbacks + async def handle_command_callback(ctx): + """Handle command callbacks from buttons.""" + payload = ctx.platform_data.get("decoded_payload", {}) + command = payload.get("command", "") + + # Get the Slack body and action objects + body = ctx.platform_data.get("body") + action = ctx.platform_data.get("action") + if not body or not action: + return None + + # Parse the command (remove leading slash if present) + if command.startswith("/"): + command = command[1:] + + # Split command and args + parts = command.split(maxsplit=1) + cmd_name = parts[0] if parts else "" + cmd_args = parts[1] if len(parts) > 1 else "" + + # Check if command exists in handlers + if cmd_name in self._command_handlers: + handler = self._command_handlers[cmd_name] + try: + # Create a minimal message object for the handler + from praisonaiagents.bots import BotMessage, BotUser + message = BotMessage( + message_id=body.get("message", {}).get("ts", ""), + content=f"/{command}", + sender=BotUser(user_id=ctx.user_id), + chat_id=ctx.chat_id or "", + command=cmd_name, + command_args=cmd_args + ) + + if asyncio.iscoroutinefunction(handler): + await handler(message) + else: + handler(message) + + # Update the message to show command was executed + if "message" in body: + blocks = body["message"]["blocks"] + blocks = [block for block in blocks if block["type"] != "actions"] + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"✅ Command executed: /{cmd_name}" + } + }) + + await self._client.chat_update( + channel=body["channel"]["id"], + ts=body["message"]["ts"], + blocks=blocks, + text=body["message"]["text"] + ) + return f"Command {cmd_name} executed" + except Exception as e: + logger.error(f"Command handler error: {e}") + return f"Error: {e}" + + logger.debug(f"Unknown command from button: {cmd_name}") + return None + + # Register the command handler + registry.register("command", handle_command_callback) + + # Register handler for pairing callbacks using the new system + async def handle_pairing_callback(ctx): + """Handle pairing callbacks through the new registry.""" + body = ctx.platform_data.get("body") + if not body: + return None + + # The pairing handler already exists, we just wrap it + result = await self._pairing_callback_handler.handle_approval_callback( + callback_data=ctx.callback_data, + owner_user_id=ctx.user_id, + bot_adapter=self + ) + + # Update the message with result + if "message" in body: + blocks = body["message"]["blocks"] + blocks = [block for block in blocks if block["type"] != "actions"] + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": result.message + } + }) + + await self._client.chat_update( + channel=body["channel"]["id"], + ts=body["message"]["ts"], + blocks=blocks, + text=body["message"]["text"] + ) + + return f"Pairing {result.action}" + + # Register the pairing handler + registry.register("pair", handle_pairing_callback) + async def send_approval_dm( self, owner_user_id: str, diff --git a/src/praisonai/praisonai/bots/telegram.py b/src/praisonai/praisonai/bots/telegram.py index 1ab399f5a..e3b7f1e7c 100644 --- a/src/praisonai/praisonai/bots/telegram.py +++ b/src/praisonai/praisonai/bots/telegram.py @@ -164,6 +164,9 @@ def __init__( # Pairing system self._pairing_store = PairingStore() self._pairing_callback_handler = PairingCallbackHandler(self._pairing_store) + + # Register interactive handlers + self._register_interactive_handlers() self._bot_context: Optional[BotContext] = None # Audio capabilities (set by BotCapabilities) @@ -238,6 +241,111 @@ def _init_command_access_policy(self): # Get the global command registry self._command_registry = get_command_registry() + def _register_interactive_handlers(self): + """Register handlers for interactive callbacks.""" + from praisonaiagents.bots import get_registry + + registry = get_registry() + + # Register handler for command callbacks + async def handle_command_callback(ctx): + """Handle command callbacks from buttons.""" + payload = ctx.platform_data.get("decoded_payload", {}) + command = payload.get("command", "") + + # Get the Telegram query object + query = ctx.platform_data.get("query") + if not query: + return None + + # Parse the command (remove leading slash if present) + if command.startswith("/"): + command = command[1:] + + # Split command and args + parts = command.split(maxsplit=1) + cmd_name = parts[0] if parts else "" + cmd_args = parts[1] if len(parts) > 1 else "" + + # Check permissions + if not self._command_policy.can_run(ctx.user_id, cmd_name): + await query.edit_message_text( + text=f"{query.message.text}\n\n⛔ You are not permitted to run /{cmd_name}", + parse_mode="Markdown" + ) + return "Permission denied" + + # Handle known commands + if cmd_name == "approve": + # Handle approval command (from approval buttons) + # This is a special case that should be handled by the approval system + # For now, log it + logger.info(f"Approval command via button: {command}") + return "Approval handled" + + # Check if command exists in handlers + if cmd_name in self._command_handlers: + handler = self._command_handlers[cmd_name] + try: + # Create a minimal message object for the handler + from praisonaiagents.bots import BotMessage, BotUser + message = BotMessage( + message_id=ctx.message_id or "", + content=f"/{command}", + sender=BotUser(user_id=ctx.user_id), + chat_id=ctx.chat_id or "", + command=cmd_name, + command_args=cmd_args + ) + + if asyncio.iscoroutinefunction(handler): + await handler(message) + else: + handler(message) + + # Update the message to show command was executed + await query.edit_message_text( + text=f"{query.message.text}\n\n✅ Command executed: /{cmd_name}", + parse_mode="Markdown" + ) + return f"Command {cmd_name} executed" + except Exception as e: + logger.error(f"Command handler error: {e}") + await query.edit_message_text( + text=f"{query.message.text}\n\n❌ Error executing command", + parse_mode="Markdown" + ) + return f"Error: {e}" + + logger.debug(f"Unknown command from button: {cmd_name}") + return None + + # Register the command handler + registry.register("command", handle_command_callback) + + # Register handler for pairing callbacks using the new system + async def handle_pairing_callback(ctx): + """Handle pairing callbacks through the new registry.""" + # The pairing handler already exists, we just wrap it + result = await self._pairing_callback_handler.handle_approval_callback( + callback_data=ctx.callback_data, + owner_user_id=ctx.user_id, + bot_adapter=self + ) + + # Update the message with result + query = ctx.platform_data.get("query") + if query and query.message: + await query.edit_message_text( + text=f"{query.message.text}\n\n{result.message}", + parse_mode="Markdown" + ) + + return f"Pairing {result.action}" + + # Register the pairing handler + registry.register("pair", handle_pairing_callback) + @property def is_running(self) -> bool: return self._is_running @@ -619,31 +727,50 @@ async def handle_whoami(update: Update, context: ContextTypes.DEFAULT_TYPE): MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message) ) - # Add callback query handler for pairing buttons + # Add callback query handler for all interactive buttons async def handle_callback_query(update: Update, context: ContextTypes.DEFAULT_TYPE): query = update.callback_query if not query or not query.data: return - # Only handle pairing callbacks - if not query.data.startswith("pair:"): - return - # Answer the callback query to stop loading spinner await query.answer() - # Handle the pairing callback - result = await self._pairing_callback_handler.handle_approval_callback( + # Create interactive context + from praisonaiagents.bots import InteractiveContext, get_registry + ctx = InteractiveContext( callback_data=query.data, - owner_user_id=str(query.from_user.id), - bot_adapter=self + user_id=str(query.from_user.id), + message_id=str(query.message.message_id) if query.message else None, + chat_id=str(query.message.chat_id) if query.message else None, + bot_adapter=self, + platform_data={ + "update": update, + "context": context, + "query": query + } ) - # Update the message with result - await query.edit_message_text( - text=f"{query.message.text}\n\n{result.message}", - parse_mode="Markdown" - ) + # Try to dispatch through the interactive registry + registry = get_registry() + handled = await registry.dispatch(ctx) + + if not handled: + # Fallback: handle legacy pairing callbacks + if query.data.startswith("pair:"): + result = await self._pairing_callback_handler.handle_approval_callback( + callback_data=query.data, + owner_user_id=str(query.from_user.id), + bot_adapter=self + ) + + # Update the message with result + await query.edit_message_text( + text=f"{query.message.text}\n\n{result.message}", + parse_mode="Markdown" + ) + else: + logger.debug(f"Unhandled callback: {query.data}") self._application.add_handler(CallbackQueryHandler(handle_callback_query)) From fe288fc44a76773eaa0f43c296c8e42eb1fc1d42 Mon Sep 17 00:00:00 2001 From: "praisonai-triage-agent[bot]" <272766704+praisonai-triage-agent[bot]@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:41:33 +0000 Subject: [PATCH 2/3] fix: address critical issues in interactive registry implementation - Fix dispatch() to properly return False when handlers return None - Replace global registry singleton with create_registry() for adapter-specific instances - Fix invalid BotMessage constructor arguments (chat_id, command, command_args moved to metadata) - Remove redundant TYPE_CHECKING imports - Remove dead code for 'approve' command special case - Use field(default_factory=dict) for proper dataclass default Co-authored-by: Mervin Praison --- .../praisonaiagents/bots/__init__.py | 43 ++----------------- .../praisonaiagents/bots/interactive.py | 43 ++++++++++++++----- src/praisonai/praisonai/bots/discord.py | 25 ++++++----- src/praisonai/praisonai/bots/slack.py | 23 +++++----- src/praisonai/praisonai/bots/telegram.py | 31 ++++++------- 5 files changed, 74 insertions(+), 91 deletions(-) diff --git a/src/praisonai-agents/praisonaiagents/bots/__init__.py b/src/praisonai-agents/praisonaiagents/bots/__init__.py index 676cad593..b9c608c30 100644 --- a/src/praisonai-agents/praisonaiagents/bots/__init__.py +++ b/src/praisonai-agents/praisonaiagents/bots/__init__.py @@ -8,46 +8,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from .protocols import ( - BotOSProtocol, # noqa: F401 - BotProtocol, - BotMessageProtocol, - BotUserProtocol, - BotChannelProtocol, - BotMessage, - BotUser, - BotChannel, - MessageType, - ChatCommandInfo, - ChatCommandProtocol, - ProbeResult, - HealthResult, - EmailProtocol, - EmailInbox, - SupportsPresentation, - PlatformCapabilities, - ) - from .presentation import ( - MessagePresentation, - PresentationBlock, - PresentationButton, - PresentationAction, - SelectOption, - PresentationLimits, - ActionType, - ButtonStyle, - BlockType, - ) - from .interactive import ( - InteractiveContext, - InteractiveRegistry, - InteractiveHandler, - encode_action, - decode_callback, - get_registry, - register_handler, - unregister_handler, - ) + from .protocols import BotOSProtocol # noqa: F401 from .protocols import ( BotProtocol, BotMessageProtocol, @@ -84,6 +45,7 @@ InteractiveHandler, encode_action, decode_callback, + create_registry, get_registry, register_handler, unregister_handler, @@ -124,6 +86,7 @@ "InteractiveHandler", "encode_action", "decode_callback", + "create_registry", "get_registry", "register_handler", "unregister_handler", diff --git a/src/praisonai-agents/praisonaiagents/bots/interactive.py b/src/praisonai-agents/praisonaiagents/bots/interactive.py index a114cfcd1..689a1948d 100644 --- a/src/praisonai-agents/praisonaiagents/bots/interactive.py +++ b/src/praisonai-agents/praisonaiagents/bots/interactive.py @@ -12,7 +12,7 @@ from __future__ import annotations import logging -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, Awaitable, Callable, Dict, Optional, Tuple, TYPE_CHECKING if TYPE_CHECKING: @@ -40,11 +40,7 @@ class InteractiveContext: message_id: Optional[str] = None chat_id: Optional[str] = None bot_adapter: Optional["BotAdapter"] = None - platform_data: Dict[str, Any] = None - - def __post_init__(self): - if self.platform_data is None: - self.platform_data = {} + platform_data: Dict[str, Any] = field(default_factory=dict) InteractiveHandler = Callable[[InteractiveContext], Awaitable[Optional[str]]] @@ -174,10 +170,12 @@ async def dispatch(self, context: InteractiveContext) -> bool: result = await handler(context) if result: logger.debug(f"Handler for namespace '{namespace}' returned: {result}") - return True + return True + else: + logger.debug(f"Handler for namespace '{namespace}' returned None, trying fallback") except Exception as e: logger.error(f"Error in handler for namespace '{namespace}': {e}") - return False + # Continue to fallback handler # Try fallback handler if self._fallback_handler: @@ -188,7 +186,10 @@ async def dispatch(self, context: InteractiveContext) -> bool: result = await self._fallback_handler(context) if result: logger.debug(f"Fallback handler returned: {result}") - return True + return True + else: + logger.debug(f"Fallback handler returned None") + return False except Exception as e: logger.error(f"Error in fallback handler: {e}") return False @@ -216,13 +217,29 @@ def list_namespaces(self) -> list[str]: return list(self._handlers.keys()) -# Global registry instance +# Global registry instance - deprecated, use create_registry() for new code _global_registry = InteractiveRegistry() +def create_registry() -> InteractiveRegistry: + """Create a new interactive registry instance. + + Each adapter should create its own registry to avoid conflicts + when multiple adapters are used in the same process. + + Returns: + A new InteractiveRegistry instance + """ + return InteractiveRegistry() + + def get_registry() -> InteractiveRegistry: """Get the global interactive registry. + .. deprecated:: + Use create_registry() to create adapter-specific registries instead. + The global registry can cause conflicts when multiple adapters are used. + Returns: The global InteractiveRegistry instance """ @@ -232,6 +249,9 @@ def get_registry() -> InteractiveRegistry: def register_handler(namespace: str, handler: InteractiveHandler) -> None: """Register a handler in the global registry. + .. deprecated:: + Use registry.register() on an adapter-specific registry instead. + Args: namespace: The namespace to handle handler: Async function to handle callbacks @@ -242,6 +262,9 @@ def register_handler(namespace: str, handler: InteractiveHandler) -> None: def unregister_handler(namespace: str) -> None: """Unregister a handler from the global registry. + .. deprecated:: + Use registry.unregister() on an adapter-specific registry instead. + Args: namespace: The namespace to unregister """ diff --git a/src/praisonai/praisonai/bots/discord.py b/src/praisonai/praisonai/bots/discord.py index f0ddedba2..72c129fe8 100644 --- a/src/praisonai/praisonai/bots/discord.py +++ b/src/praisonai/praisonai/bots/discord.py @@ -124,7 +124,9 @@ def __init__( self._pairing_store = PairingStore() self._pairing_callback_handler = PairingCallbackHandler(self._pairing_store) - # Register interactive handlers + # Create adapter-specific registry and register handlers + from praisonaiagents.bots import create_registry + self._interactive_registry = create_registry() self._register_interactive_handlers() self._bot_context: Optional[BotContext] = None @@ -700,9 +702,7 @@ async def reply(self, chat_id: str, text: str) -> None: def _register_interactive_handlers(self): """Register handlers for interactive callbacks.""" - from praisonaiagents.bots import get_registry - - registry = get_registry() + registry = self._interactive_registry # Register handler for command callbacks async def handle_command_callback(ctx): @@ -729,14 +729,18 @@ async def handle_command_callback(ctx): handler = self._command_handlers[cmd_name] try: # Create a minimal message object for the handler - from praisonaiagents.bots import BotMessage, BotUser + from praisonaiagents.bots import BotMessage, BotUser, BotChannel message = BotMessage( message_id=str(interaction.message.id) if interaction.message else "", content=f"/{command}", sender=BotUser(user_id=ctx.user_id), - chat_id=str(interaction.channel_id) if hasattr(interaction, "channel_id") else "", - command=cmd_name, - command_args=cmd_args + channel=BotChannel( + channel_id=str(interaction.channel_id) if hasattr(interaction, "channel_id") else "" + ), + metadata={ + "command": cmd_name, + "command_args": cmd_args + } ) if asyncio.iscoroutinefunction(handler): @@ -797,7 +801,7 @@ async def _handle_pairing_interaction(self, interaction, custom_id: str): await interaction.response.defer() # Create interactive context - from praisonaiagents.bots import InteractiveContext, get_registry + from praisonaiagents.bots import InteractiveContext ctx = InteractiveContext( callback_data=custom_id, user_id=str(interaction.user.id), @@ -810,8 +814,7 @@ async def _handle_pairing_interaction(self, interaction, custom_id: str): ) # Try to dispatch through the interactive registry - registry = get_registry() - handled = await registry.dispatch(ctx) + handled = await self._interactive_registry.dispatch(ctx) if not handled: # Fallback: handle legacy pairing callbacks diff --git a/src/praisonai/praisonai/bots/slack.py b/src/praisonai/praisonai/bots/slack.py index c0d6058a5..ee99949ee 100644 --- a/src/praisonai/praisonai/bots/slack.py +++ b/src/praisonai/praisonai/bots/slack.py @@ -137,7 +137,9 @@ def __init__( self._pairing_store = PairingStore() self._pairing_callback_handler = PairingCallbackHandler(self._pairing_store) - # Register interactive handlers + # Create adapter-specific registry and register handlers + from praisonaiagents.bots import create_registry + self._interactive_registry = create_registry() self._register_interactive_handlers() self._bot_context: Optional[BotContext] = None @@ -434,7 +436,7 @@ async def handle_block_actions(ack, body, action): return # Create interactive context - from praisonaiagents.bots import InteractiveContext, get_registry + from praisonaiagents.bots import InteractiveContext ctx = InteractiveContext( callback_data=callback_data, user_id=body["user"]["id"], @@ -448,8 +450,7 @@ async def handle_block_actions(ack, body, action): ) # Try to dispatch through the interactive registry - registry = get_registry() - handled = await registry.dispatch(ctx) + handled = await self._interactive_registry.dispatch(ctx) if not handled: # Fallback: handle legacy pairing callbacks @@ -822,9 +823,7 @@ def _convert_event_to_message(self, event: Dict[str, Any]) -> BotMessage: # Adapter methods for pairing system def _register_interactive_handlers(self): """Register handlers for interactive callbacks.""" - from praisonaiagents.bots import get_registry - - registry = get_registry() + registry = self._interactive_registry # Register handler for command callbacks async def handle_command_callback(ctx): @@ -852,14 +851,16 @@ async def handle_command_callback(ctx): handler = self._command_handlers[cmd_name] try: # Create a minimal message object for the handler - from praisonaiagents.bots import BotMessage, BotUser + from praisonaiagents.bots import BotMessage, BotUser, BotChannel message = BotMessage( message_id=body.get("message", {}).get("ts", ""), content=f"/{command}", sender=BotUser(user_id=ctx.user_id), - chat_id=ctx.chat_id or "", - command=cmd_name, - command_args=cmd_args + channel=BotChannel(channel_id=ctx.chat_id or ""), + metadata={ + "command": cmd_name, + "command_args": cmd_args + } ) if asyncio.iscoroutinefunction(handler): diff --git a/src/praisonai/praisonai/bots/telegram.py b/src/praisonai/praisonai/bots/telegram.py index e3b7f1e7c..ae9f4feff 100644 --- a/src/praisonai/praisonai/bots/telegram.py +++ b/src/praisonai/praisonai/bots/telegram.py @@ -165,7 +165,9 @@ def __init__( self._pairing_store = PairingStore() self._pairing_callback_handler = PairingCallbackHandler(self._pairing_store) - # Register interactive handlers + # Create adapter-specific registry and register handlers + from praisonaiagents.bots import create_registry + self._interactive_registry = create_registry() self._register_interactive_handlers() self._bot_context: Optional[BotContext] = None @@ -243,9 +245,7 @@ def _init_command_access_policy(self): def _register_interactive_handlers(self): """Register handlers for interactive callbacks.""" - from praisonaiagents.bots import get_registry - - registry = get_registry() + registry = self._interactive_registry # Register handler for command callbacks async def handle_command_callback(ctx): @@ -275,27 +275,21 @@ async def handle_command_callback(ctx): ) return "Permission denied" - # Handle known commands - if cmd_name == "approve": - # Handle approval command (from approval buttons) - # This is a special case that should be handled by the approval system - # For now, log it - logger.info(f"Approval command via button: {command}") - return "Approval handled" - # Check if command exists in handlers if cmd_name in self._command_handlers: handler = self._command_handlers[cmd_name] try: # Create a minimal message object for the handler - from praisonaiagents.bots import BotMessage, BotUser + from praisonaiagents.bots import BotMessage, BotUser, BotChannel message = BotMessage( message_id=ctx.message_id or "", content=f"/{command}", sender=BotUser(user_id=ctx.user_id), - chat_id=ctx.chat_id or "", - command=cmd_name, - command_args=cmd_args + channel=BotChannel(channel_id=ctx.chat_id or ""), + metadata={ + "command": cmd_name, + "command_args": cmd_args + } ) if asyncio.iscoroutinefunction(handler): @@ -737,7 +731,7 @@ async def handle_callback_query(update: Update, context: ContextTypes.DEFAULT_TY await query.answer() # Create interactive context - from praisonaiagents.bots import InteractiveContext, get_registry + from praisonaiagents.bots import InteractiveContext ctx = InteractiveContext( callback_data=query.data, user_id=str(query.from_user.id), @@ -752,8 +746,7 @@ async def handle_callback_query(update: Update, context: ContextTypes.DEFAULT_TY ) # Try to dispatch through the interactive registry - registry = get_registry() - handled = await registry.dispatch(ctx) + handled = await self._interactive_registry.dispatch(ctx) if not handled: # Fallback: handle legacy pairing callbacks From 648b1a1e285c7bcba9b5ad232b6e26cfc1a312e8 Mon Sep 17 00:00:00 2001 From: "praisonai-triage-agent[bot]" <272766704+praisonai-triage-agent[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:46:38 +0000 Subject: [PATCH 3/3] fix: remove duplicate BotOSProtocol import from TYPE_CHECKING block The import was redundant as BotOSProtocol is already imported at runtime (line 23) and needs to be available for the __all__ export list. --- src/praisonai-agents/praisonaiagents/bots/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/praisonai-agents/praisonaiagents/bots/__init__.py b/src/praisonai-agents/praisonaiagents/bots/__init__.py index b9c608c30..6a6af517b 100644 --- a/src/praisonai-agents/praisonaiagents/bots/__init__.py +++ b/src/praisonai-agents/praisonaiagents/bots/__init__.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from .protocols import BotOSProtocol # noqa: F401 + pass from .protocols import ( BotProtocol, BotMessageProtocol,