diff --git a/backend/autometabuilder/integrations/notifications.py b/backend/autometabuilder/integrations/notifications.py deleted file mode 100644 index 2564a66..0000000 --- a/backend/autometabuilder/integrations/notifications.py +++ /dev/null @@ -1,55 +0,0 @@ -import os -import logging -from slack_sdk import WebClient -from slack_sdk.errors import SlackApiError -import discord -import asyncio - -logger = logging.getLogger("autometabuilder.notifications") - -def send_slack_notification(message: str): - token = os.environ.get("SLACK_BOT_TOKEN") - channel = os.environ.get("SLACK_CHANNEL") - if not token or not channel: - logger.warning("Slack notification skipped: SLACK_BOT_TOKEN or SLACK_CHANNEL missing.") - return - - client = WebClient(token=token) - try: - client.chat_postMessage(channel=channel, text=message) - logger.info("Slack notification sent successfully.") - except SlackApiError as e: - logger.error(f"Error sending Slack notification: {e}") - -async def send_discord_notification_async(message: str): - token = os.environ.get("DISCORD_BOT_TOKEN") - channel_id = os.environ.get("DISCORD_CHANNEL_ID") - if not token or not channel_id: - logger.warning("Discord notification skipped: DISCORD_BOT_TOKEN or DISCORD_CHANNEL_ID missing.") - return - - intents = discord.Intents.default() - client = discord.Client(intents=intents) - - @client.event - async def on_ready(): - channel = client.get_channel(int(channel_id)) - if channel: - await channel.send(message) - logger.info("Discord notification sent successfully.") - await client.close() - - try: - await client.start(token) - except Exception as e: - logger.error(f"Error sending Discord notification: {e}") - -def send_discord_notification(message: str): - try: - asyncio.run(send_discord_notification_async(message)) - except Exception as e: - logger.error(f"Error running Discord notification: {e}") - -def notify_all(message: str): - send_slack_notification(message) - send_discord_notification(message) diff --git a/backend/autometabuilder/workflow/notification_helpers.py b/backend/autometabuilder/workflow/notification_helpers.py new file mode 100644 index 0000000..7764d89 --- /dev/null +++ b/backend/autometabuilder/workflow/notification_helpers.py @@ -0,0 +1,73 @@ +"""Notification helpers for workflow plugins.""" +import os +import logging +import asyncio + +logger = logging.getLogger("autometabuilder.notifications") + + +def send_slack_notification(runtime, message: str): + """Send a notification to Slack using client from runtime context.""" + client = runtime.context.get("slack_client") if runtime else None + channel = os.environ.get("SLACK_CHANNEL") + + if not client: + logger.warning("Slack notification skipped: Slack client not initialized.") + return + + if not channel: + logger.warning("Slack notification skipped: SLACK_CHANNEL missing.") + return + + try: + from slack_sdk.errors import SlackApiError + client.chat_postMessage(channel=channel, text=message) + logger.info("Slack notification sent successfully.") + except SlackApiError as e: + logger.error(f"Error sending Slack notification: {e}") + + +async def send_discord_notification_async(message: str, token: str, intents, channel_id: str): + """Send Discord notification asynchronously.""" + import discord + + client = discord.Client(intents=intents) + + @client.event + async def on_ready(): + channel = client.get_channel(int(channel_id)) + if channel: + await channel.send(message) + logger.info("Discord notification sent successfully.") + await client.close() + + try: + await client.start(token) + except Exception as e: + logger.error(f"Error sending Discord notification: {e}") + + +def send_discord_notification(runtime, message: str): + """Send a Discord notification using config from runtime context.""" + token = runtime.context.get("discord_token") if runtime else None + intents = runtime.context.get("discord_intents") if runtime else None + channel_id = os.environ.get("DISCORD_CHANNEL_ID") + + if not token: + logger.warning("Discord notification skipped: Discord client not initialized.") + return + + if not channel_id: + logger.warning("Discord notification skipped: DISCORD_CHANNEL_ID missing.") + return + + try: + asyncio.run(send_discord_notification_async(message, token, intents, channel_id)) + except Exception as e: + logger.error(f"Error running Discord notification: {e}") + + +def notify_all(runtime, message: str): + """Send notification to all configured channels.""" + send_slack_notification(runtime, message) + send_discord_notification(runtime, message) diff --git a/backend/autometabuilder/workflow/plugins/README.md b/backend/autometabuilder/workflow/plugins/README.md index b829165..bf1b29a 100644 --- a/backend/autometabuilder/workflow/plugins/README.md +++ b/backend/autometabuilder/workflow/plugins/README.md @@ -5,9 +5,10 @@ This document describes all available workflow plugins for building declarative ## Directory Structure Plugins are now organized into subdirectories by category: -- **backend/** - Backend infrastructure and initialization plugins (12 plugins) +- **backend/** - Backend infrastructure and initialization plugins (14 plugins) - **core/** - Core workflow orchestration plugins (7 plugins) - **tools/** - Tool execution and development plugins (7 plugins) +- **notifications/** - External notification integrations (3 plugins) - **logic/** - Logic and comparison operations (9 plugins) - **list/** - List/array operations (7 plugins) - **dict/** - Dictionary/object operations (6 plugins) @@ -18,13 +19,15 @@ Plugins are now organized into subdirectories by category: - **var/** - Variable management (4 plugins) - **test/** - Unit testing and assertions (5 plugins) - **utils/** - Utility functions (7 plugins) +- **web/** - Web UI and Flask operations (26 plugins) -**Total: 90 plugins** +**Total: 95 plugins** ## Categories - [Core Plugins](#core-plugins) - AI and context management - [Tool Plugins](#tool-plugins) - File system and SDLC operations +- [Notification Plugins](#notification-plugins) - External notification integrations - [Logic Plugins](#logic-plugins) - Boolean logic and comparisons - [List Plugins](#list-plugins) - Collection operations - [Dictionary Plugins](#dictionary-plugins) - Object/map operations @@ -36,6 +39,7 @@ Plugins are now organized into subdirectories by category: - [Test Plugins](#test-plugins) - Unit testing and assertions - [Backend Plugins](#backend-plugins) - System initialization - [Utility Plugins](#utility-plugins) - General utilities +- [Web Plugins](#web-plugins) - Web UI and Flask operations --- @@ -172,6 +176,57 @@ Run command inside Docker container. --- +## Notification Plugins + +**Note:** Notification plugins require the corresponding backend plugins (`backend.create_slack` and/or `backend.create_discord`) to be run first to initialize the clients. + +### `notifications.slack` +Send notification to Slack. + +**Prerequisites:** +- `backend.create_slack` must be run first to initialize the Slack client + +**Inputs:** +- `message` - The message to send +- `channel` - Optional channel (defaults to SLACK_CHANNEL env var) + +**Outputs:** +- `success` - Boolean (true if sent successfully) +- `message` - Status message +- `error` - Error message (if failed) +- `skipped` - Boolean (true if skipped due to missing config) + +### `notifications.discord` +Send notification to Discord. + +**Prerequisites:** +- `backend.create_discord` must be run first to initialize the Discord configuration + +**Inputs:** +- `message` - The message to send +- `channel_id` - Optional channel ID (defaults to DISCORD_CHANNEL_ID env var) + +**Outputs:** +- `success` - Boolean (true if sent successfully) +- `message` - Status message +- `error` - Error message (if failed) +- `skipped` - Boolean (true if skipped due to missing config) + +### `notifications.all` +Send notification to all configured channels (Slack and Discord). + +**Prerequisites:** +- `backend.create_slack` and `backend.create_discord` should be run first for full functionality + +**Inputs:** +- `message` - The message to send to all channels + +**Outputs:** +- `success` - Boolean +- `message` - Status message + +--- + ## Logic Plugins ### `logic.and` @@ -812,6 +867,28 @@ Initialize OpenAI client. - `result` - OpenAI client - `initialized` - Boolean +### `backend.create_slack` +Initialize Slack WebClient. + +**Inputs:** +- `token` - Optional Slack bot token (defaults to SLACK_BOT_TOKEN env var) + +**Outputs:** +- `result` - Slack client +- `initialized` - Boolean +- `error` - Error message (if failed) + +### `backend.create_discord` +Initialize Discord client configuration. + +**Inputs:** +- `token` - Optional Discord bot token (defaults to DISCORD_BOT_TOKEN env var) + +**Outputs:** +- `result` - Discord token +- `initialized` - Boolean +- `error` - Error message (if failed) + ### `backend.load_metadata` Load metadata.json. diff --git a/backend/autometabuilder/workflow/plugins/backend/backend_create_discord.py b/backend/autometabuilder/workflow/plugins/backend/backend_create_discord.py new file mode 100644 index 0000000..005df8c --- /dev/null +++ b/backend/autometabuilder/workflow/plugins/backend/backend_create_discord.py @@ -0,0 +1,36 @@ +"""Workflow plugin: create Discord client.""" +import os +import logging +import discord + +logger = logging.getLogger("autometabuilder") + + +def run(runtime, inputs): + """ + Initialize Discord Client (without starting the connection). + + Note: Discord client needs to be started asynchronously. This plugin + just stores the token and intents configuration for later use by + notification plugins. + + Inputs: + token: Optional Discord bot token (defaults to DISCORD_BOT_TOKEN env var) + + Returns: + dict: Contains initialization status and configuration + """ + token = inputs.get("token") or os.environ.get("DISCORD_BOT_TOKEN") + + if not token: + logger.warning("Discord client not initialized: DISCORD_BOT_TOKEN missing.") + runtime.context["discord_token"] = None + return {"result": None, "initialized": False, "error": "DISCORD_BOT_TOKEN missing"} + + # Store token and intents configuration in context + # Discord client must be created per-use due to its async nature + runtime.context["discord_token"] = token + runtime.context["discord_intents"] = discord.Intents.default() + + logger.info("Discord configuration initialized successfully.") + return {"result": token, "initialized": True} diff --git a/backend/autometabuilder/workflow/plugins/backend/backend_create_slack.py b/backend/autometabuilder/workflow/plugins/backend/backend_create_slack.py new file mode 100644 index 0000000..a87ae54 --- /dev/null +++ b/backend/autometabuilder/workflow/plugins/backend/backend_create_slack.py @@ -0,0 +1,33 @@ +"""Workflow plugin: create Slack client.""" +import os +import logging +from slack_sdk import WebClient + +logger = logging.getLogger("autometabuilder") + + +def run(runtime, inputs): + """ + Initialize Slack WebClient. + + Inputs: + token: Optional Slack bot token (defaults to SLACK_BOT_TOKEN env var) + + Returns: + dict: Contains the Slack client in result and initialized status + """ + token = inputs.get("token") or os.environ.get("SLACK_BOT_TOKEN") + + if not token: + logger.warning("Slack client not initialized: SLACK_BOT_TOKEN missing.") + runtime.context["slack_client"] = None + return {"result": None, "initialized": False, "error": "SLACK_BOT_TOKEN missing"} + + # Create Slack client + client = WebClient(token=token) + + # Store in context for other plugins to use + runtime.context["slack_client"] = client + + logger.info("Slack client initialized successfully.") + return {"result": client, "initialized": True} diff --git a/backend/autometabuilder/workflow/plugins/core/core_append_tool_results.py b/backend/autometabuilder/workflow/plugins/core/core_append_tool_results.py index 5347acc..6c75a5b 100644 --- a/backend/autometabuilder/workflow/plugins/core/core_append_tool_results.py +++ b/backend/autometabuilder/workflow/plugins/core/core_append_tool_results.py @@ -1,7 +1,7 @@ """Workflow plugin: append tool results.""" import os import re -from ....integrations.notifications import notify_all +from ...notification_helpers import notify_all def _is_mvp_reached() -> bool: @@ -47,6 +47,6 @@ def run(runtime, inputs): if runtime.context["args"].yolo and _is_mvp_reached(): runtime.logger.info("MVP reached. Stopping YOLO loop.") - notify_all("AutoMetabuilder YOLO loop stopped: MVP reached.") + notify_all(runtime, "AutoMetabuilder YOLO loop stopped: MVP reached.") return {"messages": messages} diff --git a/backend/autometabuilder/workflow/plugins/core/core_run_tool_calls.py b/backend/autometabuilder/workflow/plugins/core/core_run_tool_calls.py index 41d0683..6088791 100644 --- a/backend/autometabuilder/workflow/plugins/core/core_run_tool_calls.py +++ b/backend/autometabuilder/workflow/plugins/core/core_run_tool_calls.py @@ -1,5 +1,5 @@ """Workflow plugin: run tool calls.""" -from ....integrations.notifications import notify_all +from ...notification_helpers import notify_all from ..tool_calls_handler import handle_tool_calls @@ -19,7 +19,7 @@ def run(runtime, inputs): runtime.logger ) if not tool_calls and resp_msg.content: - notify_all(f"AutoMetabuilder task complete: {resp_msg.content[:100]}...") + notify_all(runtime, f"AutoMetabuilder task complete: {resp_msg.content[:100]}...") return { "tool_results": tool_results, "no_tool_calls": not bool(tool_calls) diff --git a/backend/autometabuilder/integrations/__init__.py b/backend/autometabuilder/workflow/plugins/notifications/__init__.py similarity index 100% rename from backend/autometabuilder/integrations/__init__.py rename to backend/autometabuilder/workflow/plugins/notifications/__init__.py diff --git a/backend/autometabuilder/workflow/plugins/notifications/notifications_all.py b/backend/autometabuilder/workflow/plugins/notifications/notifications_all.py new file mode 100644 index 0000000..3ea3574 --- /dev/null +++ b/backend/autometabuilder/workflow/plugins/notifications/notifications_all.py @@ -0,0 +1,83 @@ +"""Workflow plugin: send notification to all channels.""" +import os +import logging +import asyncio + +logger = logging.getLogger("autometabuilder.notifications") + + +def _send_slack(client, message: str, channel: str): + """Send Slack notification using provided client.""" + if not client or not channel: + logger.warning("Slack notification skipped: client or channel missing.") + return + + try: + from slack_sdk.errors import SlackApiError + client.chat_postMessage(channel=channel, text=message) + logger.info("Slack notification sent successfully.") + except SlackApiError as e: + logger.error(f"Error sending Slack notification: {e}") + + +async def _send_discord_async(message: str, token: str, intents, channel_id: str): + """Send Discord notification asynchronously.""" + import discord + + client = discord.Client(intents=intents) + + @client.event + async def on_ready(): + channel = client.get_channel(int(channel_id)) + if channel: + await channel.send(message) + logger.info("Discord notification sent successfully.") + await client.close() + + try: + await client.start(token) + except Exception as e: + logger.error(f"Error sending Discord notification: {e}") + + +def _send_discord(message: str, token: str, intents, channel_id: str): + """Send Discord notification.""" + if not token or not channel_id: + logger.warning("Discord notification skipped: token or channel_id missing.") + return + + try: + asyncio.run(_send_discord_async(message, token, intents, channel_id)) + except Exception as e: + logger.error(f"Error running Discord notification: {e}") + + +def run(runtime, inputs): + """ + Send a notification to all configured channels (Slack and Discord). + + Inputs: + message: The message to send to all channels + + Returns: + dict: Contains success status for all channels + """ + message = inputs.get("message", "") + + # Get Slack client from runtime context + slack_client = runtime.context.get("slack_client") + slack_channel = os.environ.get("SLACK_CHANNEL") + + # Get Discord config from runtime context + discord_token = runtime.context.get("discord_token") + discord_intents = runtime.context.get("discord_intents") + discord_channel_id = os.environ.get("DISCORD_CHANNEL_ID") + + # Send to both channels + _send_slack(slack_client, message, slack_channel) + _send_discord(message, discord_token, discord_intents, discord_channel_id) + + return { + "success": True, + "message": "Notifications sent to all channels" + } diff --git a/backend/autometabuilder/workflow/plugins/notifications/notifications_discord.py b/backend/autometabuilder/workflow/plugins/notifications/notifications_discord.py new file mode 100644 index 0000000..10de22f --- /dev/null +++ b/backend/autometabuilder/workflow/plugins/notifications/notifications_discord.py @@ -0,0 +1,70 @@ +"""Workflow plugin: send Discord notification.""" +import os +import logging +import asyncio + +logger = logging.getLogger("autometabuilder.notifications") + + +async def _send_discord_notification_async(message: str, token: str, intents, channel_id: str): + """Send Discord notification asynchronously.""" + # Import discord here to avoid loading it at module level + import discord + + client = discord.Client(intents=intents) + + @client.event + async def on_ready(): + channel = client.get_channel(int(channel_id)) + if channel: + await channel.send(message) + logger.info("Discord notification sent successfully.") + await client.close() + + try: + await client.start(token) + except Exception as e: + logger.error(f"Error sending Discord notification: {e}") + raise + + +def run(runtime, inputs): + """ + Send a notification to Discord. + + Inputs: + message: The message to send + channel_id: Optional channel ID (defaults to DISCORD_CHANNEL_ID env var) + + Returns: + dict: Contains success status and any error message + """ + message = inputs.get("message", "") + channel_id = inputs.get("channel_id") or os.environ.get("DISCORD_CHANNEL_ID") + + # Get Discord token and intents from runtime context (initialized by backend.create_discord) + token = runtime.context.get("discord_token") + intents = runtime.context.get("discord_intents") + + if not token: + logger.warning("Discord notification skipped: Discord client not initialized.") + return { + "success": False, + "skipped": True, + "error": "Discord client not initialized" + } + + if not channel_id: + logger.warning("Discord notification skipped: DISCORD_CHANNEL_ID missing.") + return { + "success": False, + "skipped": True, + "error": "DISCORD_CHANNEL_ID missing" + } + + try: + asyncio.run(_send_discord_notification_async(message, token, intents, channel_id)) + return {"success": True, "message": "Discord notification sent"} + except Exception as e: + logger.error(f"Error running Discord notification: {e}") + return {"success": False, "error": str(e)} diff --git a/backend/autometabuilder/workflow/plugins/notifications/notifications_slack.py b/backend/autometabuilder/workflow/plugins/notifications/notifications_slack.py new file mode 100644 index 0000000..dc3b1d3 --- /dev/null +++ b/backend/autometabuilder/workflow/plugins/notifications/notifications_slack.py @@ -0,0 +1,49 @@ +"""Workflow plugin: send Slack notification.""" +import os +import logging + +logger = logging.getLogger("autometabuilder.notifications") + + +def run(runtime, inputs): + """ + Send a notification to Slack. + + Inputs: + message: The message to send + channel: Optional channel (defaults to SLACK_CHANNEL env var) + + Returns: + dict: Contains success status and any error message + """ + message = inputs.get("message", "") + channel = inputs.get("channel") or os.environ.get("SLACK_CHANNEL") + + # Get Slack client from runtime context (initialized by backend.create_slack) + client = runtime.context.get("slack_client") + + if not client: + logger.warning("Slack notification skipped: Slack client not initialized.") + return { + "success": False, + "skipped": True, + "error": "Slack client not initialized" + } + + if not channel: + logger.warning("Slack notification skipped: SLACK_CHANNEL missing.") + return { + "success": False, + "skipped": True, + "error": "SLACK_CHANNEL missing" + } + + try: + # Import SlackApiError here to handle errors from the client + from slack_sdk.errors import SlackApiError + client.chat_postMessage(channel=channel, text=message) + logger.info("Slack notification sent successfully.") + return {"success": True, "message": "Slack notification sent"} + except SlackApiError as e: + logger.error(f"Error sending Slack notification: {e}") + return {"success": False, "error": str(e)}