diff --git a/.gitignore b/.gitignore index cc79f847eb..f2272e91eb 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ pnpm-debug.log* lerna-debug.log* node_modules +.pnpm-store dist dist-ssr *.local @@ -61,4 +62,4 @@ dist-ssr backend/README.md backend/.dmypy.json -.history \ No newline at end of file +.history diff --git a/backend/chainlit/server.py b/backend/chainlit/server.py index c281cff124..035dc6ccc3 100644 --- a/backend/chainlit/server.py +++ b/backend/chainlit/server.py @@ -147,6 +147,14 @@ async def watch_files_for_changes(): discord_task = asyncio.create_task(client.start(discord_bot_token)) + slack_task = None + + # Slack Socket Handler if env variable SLACK_WEBSOCKET_TOKEN is set + if os.environ.get("SLACK_BOT_TOKEN") and os.environ.get("SLACK_WEBSOCKET_TOKEN"): + from chainlit.slack.app import start_socket_mode + + slack_task = asyncio.create_task(start_socket_mode()) + try: yield finally: @@ -162,6 +170,10 @@ async def watch_files_for_changes(): if discord_task: discord_task.cancel() await discord_task + + if slack_task: + slack_task.cancel() + await slack_task except asyncio.exceptions.CancelledError: pass @@ -294,10 +306,14 @@ async def serve_copilot_file( # ------------------------------------------------------------------------------- -# SLACK HANDLER +# SLACK HTTP HANDLER # ------------------------------------------------------------------------------- -if os.environ.get("SLACK_BOT_TOKEN") and os.environ.get("SLACK_SIGNING_SECRET"): +if ( + os.environ.get("SLACK_BOT_TOKEN") + and os.environ.get("SLACK_SIGNING_SECRET") + and not os.environ.get("SLACK_WEBSOCKET_TOKEN") +): from chainlit.slack.app import slack_app_handler @router.post("/slack/events") diff --git a/backend/chainlit/slack/app.py b/backend/chainlit/slack/app.py index 332a0f9283..f592284785 100644 --- a/backend/chainlit/slack/app.py +++ b/backend/chainlit/slack/app.py @@ -7,6 +7,7 @@ import httpx from slack_bolt.adapter.fastapi.async_handler import AsyncSlackRequestHandler +from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler from slack_bolt.async_app import AsyncApp from chainlit.config import config @@ -125,6 +126,16 @@ async def update_step(self, step_dict: StepDict): ) +async def start_socket_mode(): + """ + Initializes and starts the Slack app in Socket Mode asynchronously. + + Uses the SLACK_WEBSOCKET_TOKEN from environment variables to authenticate. + """ + handler = AsyncSocketModeHandler(slack_app, os.environ.get("SLACK_WEBSOCKET_TOKEN")) + await handler.start_async() + + def init_slack_context( session: HTTPSession, slack_channel_id: str, diff --git a/backend/tests/test_slack_socket_mode.py b/backend/tests/test_slack_socket_mode.py new file mode 100644 index 0000000000..8e0e4368a4 --- /dev/null +++ b/backend/tests/test_slack_socket_mode.py @@ -0,0 +1,54 @@ +# tests/test_slack_socket_mode.py +import importlib +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.mark.asyncio +async def test_start_socket_mode_starts_handler(monkeypatch): + """ + The function should: + • build an AsyncSocketModeHandler with the global slack_app + • use the token found in SLACK_WEBSOCKET_TOKEN + • await the handler.start_async() coroutine exactly once + """ + token = "xapp-fake-token" + # minimal env required for the Slack module to initialise + monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-fake-bot") + monkeypatch.setenv("SLACK_WEBSOCKET_TOKEN", token) + + # Import the module first to avoid lazy import registry issues + slack_app_mod = importlib.import_module("chainlit.slack.app") + + # Patch the object directly instead of using string path + with patch.object( + slack_app_mod, "AsyncSocketModeHandler", autospec=True + ) as handler_cls: + handler_instance = AsyncMock() + handler_cls.return_value = handler_instance + + # Run: should build handler + await start_async + await slack_app_mod.start_socket_mode() + + handler_cls.assert_called_once_with(slack_app_mod.slack_app, token) + handler_instance.start_async.assert_awaited_once() + + +def test_slack_http_route_registered(monkeypatch): + """ + When only the classic HTTP tokens are set (no websocket token), + the FastAPI app should expose POST /slack/events. + """ + # HTTP-only environment + monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-fake-bot") + monkeypatch.setenv("SLACK_SIGNING_SECRET", "shhh-fake-secret") + monkeypatch.delenv("SLACK_WEBSOCKET_TOKEN", raising=False) + + # Re-import server with the fresh env so the route table is built correctly + server = importlib.reload(importlib.import_module("chainlit.server")) + + assert any( + route.path == "/slack/events" and "POST" in route.methods + for route in server.router.routes + ), "Slack HTTP handler route was not registered"