From 257ba03baee92d2d8aab3c08a572318defed7a68 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 03:12:25 +0000 Subject: [PATCH 1/7] Add Slack channel support via slack-bolt Socket Mode Implements SlackChannel following the same conventions as the existing Discord and Telegram channels. Wires it into bg_server and config (env vars SLACK_BOT_TOKEN, SLACK_APP_TOKEN, SLACK_ALLOW_FROM). https://claude.ai/code/session_01Fa64TcstSLyfugG93cjYR2 --- app/bg_server.py | 25 +++- app/channels/channel.py | 1 + app/channels/slack.py | 122 +++++++++++++++++ app/config.py | 13 +- pyproject.toml | 2 +- tests/test_slack_channel.py | 263 ++++++++++++++++++++++++++++++++++++ uv.lock | 147 +++++++++++--------- 7 files changed, 506 insertions(+), 67 deletions(-) create mode 100644 app/channels/slack.py create mode 100644 tests/test_slack_channel.py diff --git a/app/bg_server.py b/app/bg_server.py index 4409e01..d4f206c 100755 --- a/app/bg_server.py +++ b/app/bg_server.py @@ -15,6 +15,10 @@ async def start_server() -> None: telegram_agent = None discord_channel = None discord_agent = None + web_channel = None + web_agent = None + slack_channel = None + slack_agent = None # Change CWD to PROJECT_HOME/workspace to ensure all file operations are relative to this directory # This is important for the agent to read/write files in the workspace @@ -44,8 +48,6 @@ async def start_server() -> None: discord_channel.start() discord_agent = BackgroundAgent(mq=discord_mq, channel=discord_channel, max_iterations=runtime.get("max_iterations", 250)) - web_channel = None - web_agent = None if config.get("websocket"): from .channels.web_channel import WebChannel ws_config = config.get("websocket") @@ -57,7 +59,19 @@ async def start_server() -> None: web_channel.start() web_agent = BackgroundAgent(mq=web_mq, channel=web_channel, max_iterations=runtime.get("max_iterations", 250)) - if not telegram_channel and not discord_channel and not web_channel: + if config.get("slack"): + slack_bot_token = config.slack.get("BOT_TOKEN") + slack_app_token = config.slack.get("APP_TOKEN") + if not slack_bot_token or not slack_app_token: + log.error("Slack BOT_TOKEN and APP_TOKEN are required, skipping Slack channel") + else: + from .channels.slack import SlackChannel + slack_mq = MessageQueue() + slack_channel = SlackChannel(slack_mq, bot_token=slack_bot_token, app_token=slack_app_token, allow_from=config.slack.get("ALLOW_FROM", [])) + slack_channel.start() + slack_agent = BackgroundAgent(mq=slack_mq, channel=slack_channel, max_iterations=runtime.get("max_iterations", 250)) + + if not telegram_channel and not discord_channel and not web_channel and not slack_channel: log.error("No channels configured, exiting...") return @@ -73,6 +87,9 @@ async def start_server() -> None: from .channels.channel import ChannelType channels[ChannelType.WEB.value] = web_channel mqs[ChannelType.WEB.value] = web_mq + if slack_channel: + channels["slack"] = slack_channel + mqs["slack"] = slack_mq tasks = ScheduledTasks(mqs=mqs, channels=channels) @@ -83,5 +100,7 @@ async def start_server() -> None: coros.extend([discord_channel.run_polling(), discord_agent.process_incoming(), discord_mq.process_outgoing()]) if web_channel: coros.extend([web_channel.run_polling(), web_agent.process_incoming(), web_mq.process_outgoing()]) + if slack_channel: + coros.extend([slack_channel.run_polling(), slack_agent.process_incoming(), slack_mq.process_outgoing()]) await asyncio.gather(*coros) diff --git a/app/channels/channel.py b/app/channels/channel.py index 844e9dd..1e0e98c 100755 --- a/app/channels/channel.py +++ b/app/channels/channel.py @@ -5,6 +5,7 @@ class ChannelType(Enum): CLI = "cli" TELEGRAM = "telegram" DISCORD = "discord" + SLACK = "slack" WEB = "web" class Channel(ABC): diff --git a/app/channels/slack.py b/app/channels/slack.py new file mode 100644 index 0000000..cfb3541 --- /dev/null +++ b/app/channels/slack.py @@ -0,0 +1,122 @@ +import asyncio +import logging + +from slack_bolt.async_app import AsyncApp +from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler + +from .message_queue import MessageQueue +from .channel import Channel, ChannelType +from .message import OutgoingMessage, IncomingMessage + +log = logging.getLogger(__name__) + +MAX_SLACK_LENGTH = 3000 + + +class SlackChannel(Channel): + def __init__( + self, + mq: MessageQueue, + bot_token: str, + app_token: str, + allow_from: list[str] = None, + ) -> None: + self.bot_token = bot_token + self.app_token = app_token + self.allow_from = allow_from or [] + self.mq = mq + self.stopped = False + self._last_channel_id: str | None = None + self._bot_user_id: str | None = None + self.app = AsyncApp(token=bot_token) + mq.register(self, self.send_message) + self._register_handlers() + + def _register_handlers(self) -> None: + self.app.event("message")(self._handle_message) + + @property + def has_stopped(self) -> bool: + return self.stopped + + def clear_stopped(self) -> None: + self.stopped = False + + @property + def channel_type(self) -> ChannelType: + return ChannelType.SLACK + + @property + def default_metadata(self) -> dict: + return {"channel_id": self._last_channel_id} if self._last_channel_id else {} + + async def _handle_message(self, event: dict, say) -> None: + # ignore bot messages and message edits/deletes + if event.get("bot_id") or event.get("subtype"): + return + + user_id = event.get("user", "") + if self.allow_from and user_id not in self.allow_from: + log.warning(f"Slack: ignoring message from unauthorized user id={user_id}") + await say("Sorry, you are not authorized to use this bot.") + return + + content = (event.get("text") or "").strip() + if not content: + return + + channel_id = event.get("channel", "") + metadata = {"channel_id": channel_id} + + if content.startswith("/"): + cmd_name = content[1:].split(maxsplit=1)[0].lower() + if cmd_name == "whoami": + await self.send_message(OutgoingMessage( + content=f"Your user ID is {user_id}.", + channel=ChannelType.SLACK, + metadata=metadata, + )) + return + if cmd_name == "stop": + self.stopped = True + await self.send_message(OutgoingMessage( + content="Stopped.", + channel=ChannelType.SLACK, + metadata=metadata, + )) + return + + self._last_channel_id = channel_id + await self.mq.incoming.put( + IncomingMessage( + content=content, + channel=ChannelType.SLACK, + metadata=metadata, + ) + ) + + async def send_message(self, msg: OutgoingMessage) -> None: + channel_id = msg.metadata.get("channel_id") + if not channel_id: + log.error("Slack: cannot send message, no channel_id in metadata") + return + for i in range(0, len(msg.content), MAX_SLACK_LENGTH): + chunk = msg.content[i : i + MAX_SLACK_LENGTH] + await self.app.client.chat_postMessage(channel=channel_id, text=chunk) + + async def process_message(self, message) -> None: + pass # handled by slack bolt event handlers + + def error_handler(self, update, context) -> None: + log.error(f"Slack error: {context}") + + def start(self) -> None: + log.info("Starting Slack channel...") + + async def run_polling(self) -> None: + handler = AsyncSocketModeHandler(self.app, self.app_token) + await handler.start_async() + try: + await asyncio.Event().wait() + finally: + await handler.close_async() diff --git a/app/config.py b/app/config.py index ed9f4d8..d021a4f 100644 --- a/app/config.py +++ b/app/config.py @@ -44,6 +44,12 @@ def load(path: Path | str = HOME_CONFIG_PATH) -> None: _config.setdefault("websocket", {})["PORT"] = int(v) except ValueError: raise ValueError(f"WEBSOCKET_PORT must be an integer, got: {v!r}") from None + if v := os.environ.get("SLACK_BOT_TOKEN"): + _config.setdefault("slack", {})["BOT_TOKEN"] = v + if v := os.environ.get("SLACK_APP_TOKEN"): + _config.setdefault("slack", {})["APP_TOKEN"] = v + if v := os.environ.get("SLACK_ALLOW_FROM"): + _config.setdefault("slack", {})["ALLOW_FROM"] = [x.strip() for x in v.split(",") if x.strip()] def get(key: str, default=None): @@ -51,7 +57,7 @@ def get(key: str, default=None): def set(key: str, value) -> None: _config[key] = value - + def __getattr__(name: str): if name in _config: return _config[name] @@ -75,4 +81,9 @@ def get_default_config() -> str: [websocket] HOST = "127.0.0.1" PORT = 8765 + +[slack] +BOT_TOKEN = "" # xoxb-... bot token +APP_TOKEN = "" # xapp-... app-level token (required for Socket Mode) +ALLOW_FROM = [] # List of allowed Slack user IDs (strings). Empty means allow all. """ diff --git a/pyproject.toml b/pyproject.toml index f20fb52..e5803a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "discord-py>=2.7.1", "python-fasthtml>=0.14.2", "uvicorn[standard]>=0.48.0", + "slack-bolt>=1.18.0", ] [dependency-groups] @@ -25,4 +26,3 @@ dev = [ [tool.pytest.ini_options] asyncio_mode = "strict" - diff --git a/tests/test_slack_channel.py b/tests/test_slack_channel.py new file mode 100644 index 0000000..6c4a51c --- /dev/null +++ b/tests/test_slack_channel.py @@ -0,0 +1,263 @@ +import pytest +from unittest.mock import patch, MagicMock, AsyncMock + +from app.channels.channel import ChannelType +from app.channels.slack import SlackChannel, MAX_SLACK_LENGTH +from app.channels.message import OutgoingMessage +from app.channels.message_queue import MessageQueue + + +def make_slack_channel(allow_from=None): + mq = MessageQueue() + with patch("app.channels.slack.AsyncApp"), \ + patch("app.channels.slack.AsyncSocketModeHandler"): + sc = SlackChannel(mq=mq, bot_token="xoxb-test", app_token="xapp-test", allow_from=allow_from) + return sc, mq + + +# --- Initialization --- + +def test_registers_delivery_function(): + sc, mq = make_slack_channel() + assert sc in mq._delivery + assert mq._delivery[sc].__func__ is SlackChannel.send_message + + +def test_stores_tokens(): + sc, _ = make_slack_channel() + assert sc.bot_token == "xoxb-test" + assert sc.app_token == "xapp-test" + + +def test_channel_type(): + sc, _ = make_slack_channel() + assert sc.channel_type == ChannelType.SLACK + + +def test_initial_stopped_false(): + sc, _ = make_slack_channel() + assert sc.has_stopped is False + + +def test_clear_stopped_resets_state(): + sc, _ = make_slack_channel() + sc.stopped = True + sc.clear_stopped() + assert sc.has_stopped is False + + +# --- default_metadata --- + +def test_default_metadata_empty_before_any_message(): + sc, _ = make_slack_channel() + assert sc.default_metadata == {} + + +# --- _handle_message --- + +@pytest.mark.asyncio +async def test_handle_message_puts_to_incoming_queue(): + sc, mq = make_slack_channel() + say = AsyncMock() + + await sc._handle_message( + event={"user": "U123", "text": "hello", "channel": "C456"}, + say=say, + ) + + assert not mq.incoming.empty() + msg = await mq.incoming.get() + assert msg.content == "hello" + assert msg.channel == ChannelType.SLACK + assert msg.metadata == {"channel_id": "C456"} + + +@pytest.mark.asyncio +async def test_handle_message_ignores_bot_messages(): + sc, mq = make_slack_channel() + say = AsyncMock() + + await sc._handle_message( + event={"bot_id": "B123", "text": "i am a bot", "channel": "C456"}, + say=say, + ) + + assert mq.incoming.empty() + + +@pytest.mark.asyncio +async def test_handle_message_ignores_subtypes(): + sc, mq = make_slack_channel() + say = AsyncMock() + + await sc._handle_message( + event={"user": "U123", "text": "edited", "channel": "C456", "subtype": "message_changed"}, + say=say, + ) + + assert mq.incoming.empty() + + +@pytest.mark.asyncio +async def test_handle_message_rejects_unauthorized_user(): + sc, mq = make_slack_channel(allow_from=["U111", "U222"]) + say = AsyncMock() + + await sc._handle_message( + event={"user": "U999", "text": "hello", "channel": "C456"}, + say=say, + ) + + assert mq.incoming.empty() + say.assert_called_once() + assert "not authorized" in say.call_args[0][0] + + +@pytest.mark.asyncio +async def test_handle_message_allows_when_allow_from_empty(): + sc, mq = make_slack_channel(allow_from=[]) + + await sc._handle_message( + event={"user": "U999", "text": "hello", "channel": "C456"}, + say=AsyncMock(), + ) + + assert not mq.incoming.empty() + + +@pytest.mark.asyncio +async def test_handle_message_allows_authorized_user(): + sc, mq = make_slack_channel(allow_from=["U123"]) + + await sc._handle_message( + event={"user": "U123", "text": "hello", "channel": "C456"}, + say=AsyncMock(), + ) + + assert not mq.incoming.empty() + + +@pytest.mark.asyncio +async def test_handle_message_trims_whitespace(): + sc, mq = make_slack_channel() + + await sc._handle_message( + event={"user": "U123", "text": " hi there ", "channel": "C456"}, + say=AsyncMock(), + ) + + msg = await mq.incoming.get() + assert msg.content == "hi there" + + +@pytest.mark.asyncio +async def test_handle_message_ignores_empty_text(): + sc, mq = make_slack_channel() + + await sc._handle_message( + event={"user": "U123", "text": " ", "channel": "C456"}, + say=AsyncMock(), + ) + + assert mq.incoming.empty() + + +@pytest.mark.asyncio +async def test_handle_message_updates_last_channel_id(): + sc, mq = make_slack_channel() + + for ch in ["C1", "C2", "C3"]: + await sc._handle_message( + event={"user": "U123", "text": "hi", "channel": ch}, + say=AsyncMock(), + ) + await mq.incoming.get() + + assert sc._last_channel_id == "C3" + assert sc.default_metadata == {"channel_id": "C3"} + + +# --- commands --- + +@pytest.mark.asyncio +async def test_whoami_replies_with_user_id(): + sc, mq = make_slack_channel() + sc.send_message = AsyncMock() + + await sc._handle_message( + event={"user": "U123", "text": "/whoami", "channel": "C456"}, + say=AsyncMock(), + ) + + sc.send_message.assert_called_once() + sent = sc.send_message.call_args[0][0] + assert "U123" in sent.content + assert sent.metadata == {"channel_id": "C456"} + assert mq.incoming.empty() + + +@pytest.mark.asyncio +async def test_stop_sets_stopped_flag(): + sc, mq = make_slack_channel() + sc.send_message = AsyncMock() + + await sc._handle_message( + event={"user": "U123", "text": "/stop", "channel": "C456"}, + say=AsyncMock(), + ) + + assert sc.has_stopped is True + assert mq.incoming.empty() + + +@pytest.mark.asyncio +async def test_other_command_enqueued(): + sc, mq = make_slack_channel() + + await sc._handle_message( + event={"user": "U123", "text": "/status", "channel": "C456"}, + say=AsyncMock(), + ) + + assert not mq.incoming.empty() + msg = await mq.incoming.get() + assert msg.content == "/status" + + +# --- send_message --- + +@pytest.mark.asyncio +async def test_send_message_posts_to_channel(): + sc, _ = make_slack_channel() + sc.app.client.chat_postMessage = AsyncMock() + + msg = OutgoingMessage(content="hello", channel=ChannelType.SLACK, metadata={"channel_id": "C456"}) + await sc.send_message(msg) + + sc.app.client.chat_postMessage.assert_called_once_with(channel="C456", text="hello") + + +@pytest.mark.asyncio +async def test_send_message_splits_long_content(): + sc, _ = make_slack_channel() + sc.app.client.chat_postMessage = AsyncMock() + + long_content = "x" * (MAX_SLACK_LENGTH + 100) + msg = OutgoingMessage(content=long_content, channel=ChannelType.SLACK, metadata={"channel_id": "C456"}) + await sc.send_message(msg) + + assert sc.app.client.chat_postMessage.call_count == 2 + + +@pytest.mark.asyncio +async def test_send_message_logs_error_when_no_channel_id(caplog): + import logging + sc, _ = make_slack_channel() + sc.app.client.chat_postMessage = AsyncMock() + + msg = OutgoingMessage(content="hello", channel=ChannelType.SLACK, metadata={}) + with caplog.at_level(logging.ERROR): + await sc.send_message(msg) + + assert sc.app.client.chat_postMessage.call_count == 0 + assert "channel_id" in caplog.text diff --git a/uv.lock b/uv.lock index ae9276e..6045ac8 100644 --- a/uv.lock +++ b/uv.lock @@ -138,62 +138,62 @@ wheels = [ [[package]] name = "apsw" -version = "3.53.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/5f/0fabac91b26d222f5ad1bf8af92f6c80d82af2e50dafb401a250a02b2f53/apsw-3.53.1.0.tar.gz", hash = "sha256:4f8978ae8c7c405d0133a6ea590f3342f839143bdf39e46029cb36e2fa418692", size = 1242069, upload-time = "2026-05-05T18:49:59.721Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/a6/6fe7389415d0b57879870fc8a40800b13dd8efd346a2979745c00eab6880/apsw-3.53.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:92c2597c5d5b1b916f3e90384a6361c7b553d918a9591c1b71d1321f381b2cc7", size = 3271352, upload-time = "2026-05-05T18:48:20.597Z" }, - { url = "https://files.pythonhosted.org/packages/23/9c/0b33ce05221eedeb71a644618c417f3349dd5c785ad860931199cd5657ff/apsw-3.53.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab7a8a0c61e784b429b8b109fa15fe41561bf40cc3fe9fa8b4afc44cdfa18e39", size = 3067463, upload-time = "2026-05-05T18:48:22.385Z" }, - { url = "https://files.pythonhosted.org/packages/ac/bf/94352cdd603e159efb0de1669f19e75579171158f5331adeba08538ad8b4/apsw-3.53.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:ee7e61b7496a8cdecd238d154b525e48531e99ba6def117466a6dcfbc01d6299", size = 3486388, upload-time = "2026-05-05T18:48:23.997Z" }, - { url = "https://files.pythonhosted.org/packages/76/0b/c793186312df0ad7ce49ad6215f6184dd08a9d87f9dd9e1b09cf0548caf2/apsw-3.53.1.0-cp312-cp312-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1eaf42de9782b1d43c6d2f8d281a0fb7f7d6998818cd9c6e9918e36557499ec8", size = 3270824, upload-time = "2026-05-05T18:48:25.66Z" }, - { url = "https://files.pythonhosted.org/packages/08/15/e703f205f82ff53a6a196a35fd2df76545fd7e891ad4a11ce695d5faf3c6/apsw-3.53.1.0-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:93a3ba00b64e5ada05d25e3c57cd911f1e3a8a0bf241c76ab20f61876137c523", size = 3879312, upload-time = "2026-05-05T18:48:27.287Z" }, - { url = "https://files.pythonhosted.org/packages/0d/ba/527c0697db61f59f376567437a681c2fa47fc828085a6676a3aa8e72864d/apsw-3.53.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:892a7b63db72280e42c388684bb41144822a25fd2d5a6c28e2f719b09468d5f0", size = 3640415, upload-time = "2026-05-05T18:48:29.392Z" }, - { url = "https://files.pythonhosted.org/packages/ea/df/02ba47709eff8f4e8df7a245f569803422aba22d50cc878575382b84b929/apsw-3.53.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a026dccbf8716bc5e3f041fdc1b853cace802aa215a3a4a18a060a4dd16cd421", size = 3493205, upload-time = "2026-05-05T18:48:31.412Z" }, - { url = "https://files.pythonhosted.org/packages/10/c1/6a8485acba0383d1c1674537d0da679a5a72ba996f2d96a1cc6b17fae3f7/apsw-3.53.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908d0a1b393eb434e79bb38c8c7f8a1946db88a0d63a11a0ed615976d03a0435", size = 3278486, upload-time = "2026-05-05T18:48:33.463Z" }, - { url = "https://files.pythonhosted.org/packages/e5/b8/8ef2b5173f2e0c552c396c5ff51d35b5a9b7f25dd0b91f2a6c5a51d04c22/apsw-3.53.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3241518163ced48c31c660b76fff6035cbb40c838705e47f6f687d348bd94602", size = 3896255, upload-time = "2026-05-05T18:48:35.207Z" }, - { url = "https://files.pythonhosted.org/packages/0f/66/9e72093a080afcd40dfa6f4aa3f2794ca59c5602a65ed570e3679536990e/apsw-3.53.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2e6a8f5b5c0cdf5dbb9574d3fbe941b1065bbe23281ff11c7be4ae223391cece", size = 3648722, upload-time = "2026-05-05T18:48:37.04Z" }, - { url = "https://files.pythonhosted.org/packages/50/9c/51e1b8b075d92660a7375ac7d2dda89a31f196e051cf4353f98b820e8603/apsw-3.53.1.0-cp312-cp312-win32.whl", hash = "sha256:e526dadd9544885c47dfd9179cae6c37aed81b303ab962afe085be840d6d0993", size = 2909476, upload-time = "2026-05-05T18:48:38.721Z" }, - { url = "https://files.pythonhosted.org/packages/57/3d/ee6bfbe2dac4b3d2735083e2e3f781d388ffa4445830eb180fda193169f5/apsw-3.53.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:8a97dd446862cc28c4c2b611413cb9ea994dda8fb9d6e2d7a4239bfda09ce347", size = 3220782, upload-time = "2026-05-05T18:48:40.786Z" }, - { url = "https://files.pythonhosted.org/packages/2c/d1/c875f4a521d717f1effaaf36c9456a42a525d1f46e23ee4a74b60b200ba4/apsw-3.53.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:56e1ad4847adc0f785dff374004c4cd9bfb095470f6ba60063758ab9df08fc43", size = 2875529, upload-time = "2026-05-05T18:48:42.393Z" }, - { url = "https://files.pythonhosted.org/packages/41/b5/262c63adb3b9f4e4021a9326ddedc5d5026c43a749303e622949e9f4227f/apsw-3.53.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:12bf947396fc8ac652f63e52de754459865c50500073627d200ba4c800e909a9", size = 3268427, upload-time = "2026-05-05T18:48:44.434Z" }, - { url = "https://files.pythonhosted.org/packages/ae/e5/e8ecbd46eaaef62600e54d237808f92ce91a309d50afb6e2552c49279743/apsw-3.53.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5c81c4854c70d69a472e897d238a874032abf4df23717f925c75e1859b8f4912", size = 3065309, upload-time = "2026-05-05T18:48:46.076Z" }, - { url = "https://files.pythonhosted.org/packages/9d/2b/fc45e8746414a2311bd5cea4627a5645f5339a863837f2d2c3ef0d245b65/apsw-3.53.1.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:d8525ebbc4b9a77110fd0c69d7c1859619d55b1455e4750b7260be8959fcbd6b", size = 3486465, upload-time = "2026-05-05T18:48:48.658Z" }, - { url = "https://files.pythonhosted.org/packages/88/cc/2051660762aefa2aa5a55b82952dc5034b46bce1959b29713adefebd8b51/apsw-3.53.1.0-cp313-cp313-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bf295a288f46e6f8f018b940e6555b238699adc5ae61980bbbf15abf1644c47a", size = 3272768, upload-time = "2026-05-05T18:48:50.342Z" }, - { url = "https://files.pythonhosted.org/packages/87/4e/1c7c318ad5497a238ec1e0311e4722a012237ee88cb29888ca6d9b0e8f36/apsw-3.53.1.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:e2e9b4abc58d375bb79c35136d0107e7efb804f64e6592e620dc665f8a9985d5", size = 3871154, upload-time = "2026-05-05T18:48:51.958Z" }, - { url = "https://files.pythonhosted.org/packages/d0/e4/aedd6ecd2f63d3c08ba8009adeea3b9f25fab277f6877206e3b2471f9b72/apsw-3.53.1.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e477a06eef7e17454487837db88506fc9581e9167f9a0ecc7ac17dfb10623fda", size = 3642123, upload-time = "2026-05-05T18:48:53.889Z" }, - { url = "https://files.pythonhosted.org/packages/60/77/61fedaa8ee285e63242b20162c4465d94993887fb8f2860affb2aa18dbdd/apsw-3.53.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fd47f31af34eb054430cdc2f7468c6d28f993bcf2ef0f5584402c12d6f0e798b", size = 3489257, upload-time = "2026-05-05T18:48:55.727Z" }, - { url = "https://files.pythonhosted.org/packages/be/c6/9ff3673bf4dc59fb9d7a51eded0f0752a35097922dab6ec6b4a4f3829903/apsw-3.53.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:51321e757ae553df2feb2854fb727f660d21201863faf691ffcf0b6190b72bb3", size = 3281737, upload-time = "2026-05-05T18:48:57.598Z" }, - { url = "https://files.pythonhosted.org/packages/f1/df/f40c83870f508d2bda148516ac9755591d0ac290545cfc80bb4c506029be/apsw-3.53.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b8dad0d22c93ca7a1e9f8b981708ce25ebdf2a252ee1bd9ede7959e9fc3f24b4", size = 3889131, upload-time = "2026-05-05T18:48:59.457Z" }, - { url = "https://files.pythonhosted.org/packages/29/7c/ce760653165df3284d6d4c2f7def44105f9cc080da51a10f9411e0491189/apsw-3.53.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bb89433a540c572040fe3cfa3104bfb5039730bed473b5d54c6285bd06f821cb", size = 3650898, upload-time = "2026-05-05T18:49:01.752Z" }, - { url = "https://files.pythonhosted.org/packages/2c/8e/3c6ad293b7e637c4f9ba82db6777c8a26c81035abd7d9d0ab4d77c6c0831/apsw-3.53.1.0-cp313-cp313-win32.whl", hash = "sha256:e19ef1981681225ea66c90bdc04bf01767854132ab328dd2ec9ae7729a57ed54", size = 2908457, upload-time = "2026-05-05T18:49:03.53Z" }, - { url = "https://files.pythonhosted.org/packages/ab/11/71c1afe3bbd2dba9654480445c9d98ef6805f947ee4980c8436440c9e7e1/apsw-3.53.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:f039e2947f16b95fc4f8705e348c6e916b7eaf7fb48dbbf8751ddec9c7529b93", size = 3219700, upload-time = "2026-05-05T18:49:05.364Z" }, - { url = "https://files.pythonhosted.org/packages/69/03/cba7d6d4eec3c8d04fcbfd3b931250e9a290b161c77f1d7f2da0374a1956/apsw-3.53.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:805a9a11b6aec22c7e0ecb31e8e08f250efce5d4cedb222113c5fdc80be2250c", size = 2874907, upload-time = "2026-05-05T18:49:07.475Z" }, - { url = "https://files.pythonhosted.org/packages/47/68/0652d2de5140c51b727a1395f251f83e21092171445ad591c43e1b2a322c/apsw-3.53.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b420e3c3b2805bdbbe9f32e3badc6923e7344ffd932b9be2a811c045837f3880", size = 3272671, upload-time = "2026-05-05T18:49:09.419Z" }, - { url = "https://files.pythonhosted.org/packages/7e/3b/f4a93929c4a633ddeeed58e67506aa9f03dab98057a2e73dc5c2408dabcf/apsw-3.53.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ee3ede9d7e6360da95983a4481f3375873aeadbd2c405a59bcf4de3e4a6c568d", size = 3065765, upload-time = "2026-05-05T18:49:11.156Z" }, - { url = "https://files.pythonhosted.org/packages/18/3b/840e0389155828e16028a0dc94db5742ab154445a7af9a0b4c421d576cd1/apsw-3.53.1.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:668010ec0751d17a6980dbe2cbe646fa8787abb5d37614745e2b260bb5bdce23", size = 3486748, upload-time = "2026-05-05T18:49:12.958Z" }, - { url = "https://files.pythonhosted.org/packages/ee/c0/06104cb012a9850d46053e0fc32e06cca4401230105c2550cab19554b80b/apsw-3.53.1.0-cp314-cp314-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f9a3d6b911c2e502391c700005696d255c88317aa0460723689854727ec59c6d", size = 3271733, upload-time = "2026-05-05T18:49:14.894Z" }, - { url = "https://files.pythonhosted.org/packages/c1/54/15272dfd87cfc05a2bdfb7141b7f38ca8aaac3628991a3847c31102254e1/apsw-3.53.1.0-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:2c42b5d1e6249ea1e0d9751d2a9cf89e26466d4b2f61517f450fcf711790424d", size = 3871553, upload-time = "2026-05-05T18:49:17.574Z" }, - { url = "https://files.pythonhosted.org/packages/22/90/e6515ff917ad2c6a6ce9e738f5322023c9e3a26b0be75ab4edecda4094c8/apsw-3.53.1.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:9e60a0806171dacd69c85b28db6712c502f08b6b7ae679395127fe8b664170c8", size = 3641685, upload-time = "2026-05-05T18:49:19.648Z" }, - { url = "https://files.pythonhosted.org/packages/48/7d/75c34344c7440bfaf05e649e8fac4d6aab883efea2807f4193c4daa4175e/apsw-3.53.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:60b41d360dcad2238b13f89bc84c4d226d9a0e7062414c65b0095af311fc71f3", size = 3489852, upload-time = "2026-05-05T18:49:21.922Z" }, - { url = "https://files.pythonhosted.org/packages/a2/da/467afc2fddc7244410dbce70f0dd0d83ebceeb79e37fa4f7a4d2ba04ed6a/apsw-3.53.1.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:400a3d2ef918aca7970bca7d68b8cc0a8854a1c2c689cc0790f1bb0ae8fcdf17", size = 3279539, upload-time = "2026-05-05T18:49:23.757Z" }, - { url = "https://files.pythonhosted.org/packages/d4/62/9559cac2112cc9fee73d39f8cc10152a6dbbc23cd4c61ed16282abf98493/apsw-3.53.1.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bf2c428b0f33a0458dfd952c818fe09330152439f44994ffd3bb9c73b819462d", size = 3889124, upload-time = "2026-05-05T18:49:25.659Z" }, - { url = "https://files.pythonhosted.org/packages/79/dc/d79de5565b8c4c3787d30878a436c56e7ea082c3034367b517ab87f2b1b9/apsw-3.53.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:db1636427f24f4cfbffeb616ccb3689bb9b7af029a92c50e565f43755a5ba20b", size = 3650776, upload-time = "2026-05-05T18:49:27.359Z" }, - { url = "https://files.pythonhosted.org/packages/bd/6e/faccb565607146d0aee0635a32a2ee72e83fdd750810db3d836b8c6cb82e/apsw-3.53.1.0-cp314-cp314-win32.whl", hash = "sha256:31c7c293cb426a22f2e045d5a3b1bdedb1c87808175d46982a80068171287c18", size = 2977195, upload-time = "2026-05-05T18:49:29.275Z" }, - { url = "https://files.pythonhosted.org/packages/38/db/9dfa71127bac4fee50c0e91f3b71161300ec41294974b32c05358b5569a9/apsw-3.53.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:5c1a18a175ee64968d2440f9ae0399accaaab3c77a4dc49331000e025c89a5d3", size = 3299067, upload-time = "2026-05-05T18:49:31.091Z" }, - { url = "https://files.pythonhosted.org/packages/f6/89/15b2310eec1c08bc54226e5d8095ce2455046e11509b024973f13a850902/apsw-3.53.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:4715e6bf3f93bdefc2ad07859757ca7a9cf04b654ed5d5531f177de903405eda", size = 2948915, upload-time = "2026-05-05T18:49:33.099Z" }, - { url = "https://files.pythonhosted.org/packages/88/a1/cba969fc5b9a94c95ac2a94b9ad048816f575e2974eac61f0f60d28bf618/apsw-3.53.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:e6bb77d3a0abf3f4e6e967da48cd891d53dc3749c523751ef042b3c81269a1f2", size = 3281983, upload-time = "2026-05-05T18:49:34.959Z" }, - { url = "https://files.pythonhosted.org/packages/3c/06/29c595b70fea64a1ef846360d63c7dc546f63979dd214bfe14d07925f1d8/apsw-3.53.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:26af656a6bf4a45cb60d75550d7bae25d4d22940b9ecc9973a3bcc5f5c53b159", size = 3075203, upload-time = "2026-05-05T18:49:36.759Z" }, - { url = "https://files.pythonhosted.org/packages/8c/fc/11e42e77b2797c8cfc95cfa0c975dd2f473b81ee856627b8dba47bc5deab/apsw-3.53.1.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:92501b0633d683554393f3e422692aaa22ebdd221424e3349edb97ede291c0a2", size = 3462395, upload-time = "2026-05-05T18:49:38.646Z" }, - { url = "https://files.pythonhosted.org/packages/ef/cf/5dc6d0f34cb51293655ee28926e4a7eb3a51696d286e6f0da9faeda823a5/apsw-3.53.1.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:22ac2ed00e42b94918b484f4d29b8d39eb3c09c376b6d8e2ecc43828b23b68c3", size = 3251985, upload-time = "2026-05-05T18:49:40.465Z" }, - { url = "https://files.pythonhosted.org/packages/b2/a6/817e0433f497743b2e82d868e128003bd76c8b7ed3fbe0522b5332e8d4f4/apsw-3.53.1.0-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:6658020f966995e7f9e87384f84e1e7785804609c9080df8a66d0e1723d3836e", size = 3843056, upload-time = "2026-05-05T18:49:42.435Z" }, - { url = "https://files.pythonhosted.org/packages/cc/0f/4c3f0178a0ec419c0568808e0c3ce172d032aa6df1bb9adee74fee132356/apsw-3.53.1.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:39ef0857da46c1827e35f9d49bd5b548a21894409d4fa072667a333c542faf4f", size = 3618201, upload-time = "2026-05-05T18:49:44.382Z" }, - { url = "https://files.pythonhosted.org/packages/ee/76/6565a97ae2e800935716d7b1eb884a2b9bf7fbc6222d65ecff03da0df666/apsw-3.53.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5b026bc42008622a115cf8c235c9f9c96363d52e6ea0914633659265a639fe1c", size = 3465453, upload-time = "2026-05-05T18:49:46.186Z" }, - { url = "https://files.pythonhosted.org/packages/ad/00/d68a27a17ffb991ffbd739d86526b562a86cd5e97f953adbe8005ad392b0/apsw-3.53.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:6166755e34d11b2e51926cc1c749ad7a66596dbc0376c312fe5f843ca8f8650b", size = 3256806, upload-time = "2026-05-05T18:49:48.099Z" }, - { url = "https://files.pythonhosted.org/packages/05/c2/9931e56ba32b80cb0f28f33af85cbe413dd504bbf14f7845bf39fa4d4eb0/apsw-3.53.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:bce33072929b57c72812d7ecf8517e82d3d38524ee556f59282270ecbf33b942", size = 3860492, upload-time = "2026-05-05T18:49:49.905Z" }, - { url = "https://files.pythonhosted.org/packages/0d/d2/66ad79db6ad6e941649972c5814d569bf5c86830ff844b24f7577a9b0959/apsw-3.53.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1248e3e432aa7a5461be32d2313732f0590c6fa6cba24abf223a061a03b17983", size = 3627536, upload-time = "2026-05-05T18:49:51.794Z" }, - { url = "https://files.pythonhosted.org/packages/87/40/be8f8166b3b8c58ff57c6aa4d62e8422524e072d0607eadc84f6feee62da/apsw-3.53.1.0-cp314-cp314t-win32.whl", hash = "sha256:adc9d3196a764b6413e33209845c170315670fa86a31cac4bcd9c9fe9a20aa0b", size = 2989839, upload-time = "2026-05-05T18:49:53.934Z" }, - { url = "https://files.pythonhosted.org/packages/f9/48/ea8b2cef1c32d8bd0d4978bcefe31ff669fc4248fba4530bf13a3d4f561f/apsw-3.53.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5ffce724a757c0b57210c1189c033cc264a8be8b45335a694839f6918c71a842", size = 3315024, upload-time = "2026-05-05T18:49:55.731Z" }, - { url = "https://files.pythonhosted.org/packages/ea/5c/fac06fc153fb1634651e4fbed5a9343d0415a72920b5bb3c8c0f7422eb47/apsw-3.53.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bbabe95cc743ae70cc20d5675d35f84d7612a2e9d13c6894404410518c350269", size = 2951484, upload-time = "2026-05-05T18:49:58.051Z" }, +version = "3.53.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/8e/6db1f1b21e461bb77b455bc20ff8ab5fb9642ff8370338743dd812483f8d/apsw-3.53.2.0.tar.gz", hash = "sha256:5c59bde36e20a33c0a0399bfe4021f2cd6fc049ac1b15d58f0dc25b48ed1d87f", size = 1242392, upload-time = "2026-06-04T22:09:21.886Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/6e/bef995245fc48ac653c1dd12950e888571c426113caa327c705785c472fb/apsw-3.53.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2f9661e54199c06fe69d5b0169338e4ca7699a837fa7ea764c678570a3a05627", size = 3272016, upload-time = "2026-06-04T22:07:55.036Z" }, + { url = "https://files.pythonhosted.org/packages/03/e8/3e6b4b11247a3c598f1cfb3d58b6bebbf75afbee828374eaec568d46a02e/apsw-3.53.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9f3674889cf74e10da4db24f69d1f091d73ab35dd59655e71b4041e8fd3e90f", size = 3067363, upload-time = "2026-06-04T22:07:56.467Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f9/efed37d9adde90ad3c4fbc4c820e1c11da2552c8c1aa6169f29fd347d42d/apsw-3.53.2.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3b9136c4625c2d060a6c1a566ad730bbf903c768c5dd5561016b4811c34e85ea", size = 3481500, upload-time = "2026-06-04T22:07:57.839Z" }, + { url = "https://files.pythonhosted.org/packages/e2/80/b9b37868f2cd2892b667b4c4add2b6f0f1615e437897b440ea01db0c4937/apsw-3.53.2.0-cp312-cp312-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:13f69e65fcd185adf44a6b8133209c5a4de58bc8cb65a94796d293279e237d82", size = 3267137, upload-time = "2026-06-04T22:07:59.316Z" }, + { url = "https://files.pythonhosted.org/packages/dd/54/6ed3e9b982348dc9c6b34e73a5ab1c9ecf1089a6aea3d14e0b5ba34cd8d6/apsw-3.53.2.0-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:e019e03b6745da1f7b1d854f3ea92164f0862e69c9fd42c236005c8083df41cc", size = 3874092, upload-time = "2026-06-04T22:08:00.823Z" }, + { url = "https://files.pythonhosted.org/packages/6c/eb/6febeb9bf282119e327ed4382d0afc990e4c4e6998be74262b8411ab2ee6/apsw-3.53.2.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:73163b53467433cd68e7cb7bee65b55fab63f6744be1653171c276fdc68c874c", size = 3640547, upload-time = "2026-06-04T22:08:02.245Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6c/5921524bf30b26ed4c707d52f22fa00d2055fd7d296bc5c47363c845c7c9/apsw-3.53.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:78ebb133079c060f614209b6313543850b1ff0b1729841a79b1e98d25366c265", size = 3489086, upload-time = "2026-06-04T22:08:04.299Z" }, + { url = "https://files.pythonhosted.org/packages/4b/54/4ea1369f909bc1103659ba1169de1525b6e1fed8e336518afcc4e9dafd1e/apsw-3.53.2.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:72c83f1a52cdf7bea471007e97625b99873149a9f45be700c5cf0f31e6816aa7", size = 3276012, upload-time = "2026-06-04T22:08:05.962Z" }, + { url = "https://files.pythonhosted.org/packages/43/6c/d652c23db7c509bd513f70e9db46c1f5b3b2a89fe836bf4bf1dbc38882fd/apsw-3.53.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2aa9f2740b21940e230db86feb7959dbbb240f556070918936592d7b6d7516b8", size = 3892828, upload-time = "2026-06-04T22:08:07.525Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f7/494232eb3e80bedef3f5868248bc301ae1b6f598d923b6746f24a6d47266/apsw-3.53.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8129988659b85e00ecbc27754cb8b0830f51c13d53ce601dbb421bbba5dc81e7", size = 3646562, upload-time = "2026-06-04T22:08:09.362Z" }, + { url = "https://files.pythonhosted.org/packages/74/ed/5543b43eddae532622418e612b874d0a3d6edac73e622692f59822a4289c/apsw-3.53.2.0-cp312-cp312-win32.whl", hash = "sha256:4c5851d2c4dd1ad6e6979703e2f8ed0f532360e241078a7424085beb5202ea76", size = 2904918, upload-time = "2026-06-04T22:08:10.946Z" }, + { url = "https://files.pythonhosted.org/packages/25/fb/a7fab980d4eb31a1ce4d2a855fb3aac8d46466337b1113203eca8024f193/apsw-3.53.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:a3c3d8a257b830b72b0e178784611b19c3e0c910236ccb77475726f367958427", size = 3220526, upload-time = "2026-06-04T22:08:12.432Z" }, + { url = "https://files.pythonhosted.org/packages/2c/63/f8518c71e51edcf3236818d4a4aae17190a22ba43f9989ee9f7f78bc6e28/apsw-3.53.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:ca708d0d79c3b970fabe4f2ab1511af60d7a7d6576bb8848f8111c9e0ec1cdaf", size = 2870535, upload-time = "2026-06-04T22:08:13.924Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5b/2a1625f300a15820c2a30aad6c157b1d74269e6b777a5907c955217eed46/apsw-3.53.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:38b9fe5e06a985765e45a0f7986d115756939e4186c0c6aaf5089903d1904a44", size = 3269255, upload-time = "2026-06-04T22:08:15.433Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/132fd87b6d8667cd91f74bc6705c35fa4478ccb82175ec2336a000eb0332/apsw-3.53.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45b8d428f2ede3863620c73d5bec8f56f72b0f901280164dce1eef38548cdaa1", size = 3065187, upload-time = "2026-06-04T22:08:18.097Z" }, + { url = "https://files.pythonhosted.org/packages/0c/87/6e840fa23c6984dba8c5eccc86b1acf8dd7006ea0004225480e08146316a/apsw-3.53.2.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:b01d57e3a5f110d2ec7a93d7c5e0099874dde72425aedd7a3f6850d130128f54", size = 3481382, upload-time = "2026-06-04T22:08:19.866Z" }, + { url = "https://files.pythonhosted.org/packages/93/e3/53cde467ffc33d124f98cd67ba48f08b0c1b79e8c9fbae194ac29fe82c67/apsw-3.53.2.0-cp313-cp313-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5a188b67cb8c21bbd3376084fb769bc7247f37502f2b292a1e30295e80241d53", size = 3270241, upload-time = "2026-06-04T22:08:21.311Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0f/7c734603ac19c7c750d910ffc8f995e0d9d9408ba25768e420b09ca200cb/apsw-3.53.2.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:722229abe220bbda1da1a6cb2058c85890a4458a832fbfd8db9c0162dbebdcb7", size = 3869738, upload-time = "2026-06-04T22:08:22.831Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3e/2ff08df5db380b111bcd7ff7cddc5516f24a84769570b6951e35c8a4f636/apsw-3.53.2.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:0efdad2f84c669ef940ef4bd0cb39dc08d07f67ff94987ce42eccc3479790ee3", size = 3639825, upload-time = "2026-06-04T22:08:24.291Z" }, + { url = "https://files.pythonhosted.org/packages/a5/5a/78f6e1bdeebfedac8bf63e60d1509eedce2e3c2d1a3bddfa032cfc4197ae/apsw-3.53.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6c45bc518e81826e7d3c7a3902e6bd9c28964673b417dd240b3790cb941b5f52", size = 3487448, upload-time = "2026-06-04T22:08:25.813Z" }, + { url = "https://files.pythonhosted.org/packages/4f/24/36ece077cb090acaab801552ead0b8771b9393b1679f616fcb8f171fb522/apsw-3.53.2.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:dc8d4e6474b033718d427990ff85f8dbdda80bce328bff7e0056a88211b0f998", size = 3277822, upload-time = "2026-06-04T22:08:27.436Z" }, + { url = "https://files.pythonhosted.org/packages/37/99/4c6c25ec83489fc4e59d5283b68c45ad19140384cf765e7b5c4e8e829f4b/apsw-3.53.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bd546e3a436782ed61f30a91a1397cb2c8a04b408780d95ec17dff22f278ec34", size = 3887507, upload-time = "2026-06-04T22:08:29.228Z" }, + { url = "https://files.pythonhosted.org/packages/21/4d/bda72f52ee9f524f8cfbb59de2c2d81e2fba699b273a69f45e8167928ce4/apsw-3.53.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f0981ec85bf39ec5c8b3647463c5f0a46a6b0769e230564f27744fc07f50c191", size = 3645822, upload-time = "2026-06-04T22:08:30.8Z" }, + { url = "https://files.pythonhosted.org/packages/0f/20/fef0eafec420cd661ff9b7c1593a864fad2f4260f7feeed76c01a94cd396/apsw-3.53.2.0-cp313-cp313-win32.whl", hash = "sha256:2b1ea7d48a6778cace2d83ed3918c6e6ede87e48b5522c163ed85ff7c735568d", size = 2903951, upload-time = "2026-06-04T22:08:33.07Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f9/547270cfa6f1a15aba3c6a053e4428bf9eb7e6157fd2b4f1023b8a38d255/apsw-3.53.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:e78b9e3f6d9ec739c6997d43f245f19669994d372d72f9e7b941240ff7f3a452", size = 3219378, upload-time = "2026-06-04T22:08:34.688Z" }, + { url = "https://files.pythonhosted.org/packages/c0/e3/248faea7c4915017d1036967c4ba51e793c163a0be9816d3fa7530f5f56e/apsw-3.53.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:852402b11a0873deb2cbe34d54a79344200ebe8569547b8014b8640d4070efbe", size = 2869623, upload-time = "2026-06-04T22:08:36.562Z" }, + { url = "https://files.pythonhosted.org/packages/58/e0/037289244bd0193db0f2464ebeb74174330f9d42b912e1b0c8a256d11836/apsw-3.53.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:93e724281df13e9b75245bbe7b57edd43d813f988d422fc8196283ee4da13f96", size = 3273298, upload-time = "2026-06-04T22:08:38.243Z" }, + { url = "https://files.pythonhosted.org/packages/82/31/4cc170ade6774a2969c84ffe365e0db2d0b10f4a2b93b42e10d1cd0b40b7/apsw-3.53.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:47dbc9f868cc6798fbf82aaa1bf28c35d009e570a78b1867587cce4faafd73ae", size = 3065423, upload-time = "2026-06-04T22:08:39.988Z" }, + { url = "https://files.pythonhosted.org/packages/a4/19/53c56c489fe226936202281e7af0a9940777081e89ce2bd817106fba33fd/apsw-3.53.2.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:d69c5f73cd17ff5aa4f5417e90f2c9865b638eb610999d56ec7a490b6ccf58fc", size = 3482226, upload-time = "2026-06-04T22:08:41.633Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/a3faa32893a9031316d02f3d85307e644b8a06f53dc3fb237b81e8c570cb/apsw-3.53.2.0-cp314-cp314-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:951553b94ad33a92f797d9d7c4930797b6be5da7504b790453f5c883372a15b6", size = 3268910, upload-time = "2026-06-04T22:08:43.331Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c1/3762f40829c6e3a12dda099e5cb621cb30b37052e4fe8744616572611a3d/apsw-3.53.2.0-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:4309a3cc957cc4baa9b4a775e0ca359bbaf084cbc443fbbc9a8c1e169fb95b57", size = 3870003, upload-time = "2026-06-04T22:08:44.885Z" }, + { url = "https://files.pythonhosted.org/packages/33/25/5dee50e279e8e0e9be97ca5a7717bcd9289f4fb27aaf70595dc83cf82a10/apsw-3.53.2.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:16e457d1fbdb2358791de55eb4bd5518c3f7b2d434322056dc9089503be5bba2", size = 3640426, upload-time = "2026-06-04T22:08:46.329Z" }, + { url = "https://files.pythonhosted.org/packages/45/65/8a7f4b11858de6f5ccb1f1fbcbf211c7050ce3b5eb763e9b81e5fd47a5d0/apsw-3.53.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f758403843ec59b5f855b67c882859fde2fc42d5f226c5583f1aff79bef92d4", size = 3488248, upload-time = "2026-06-04T22:08:48.089Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9a/00a529e5c8bf5b8620d7ea249ca3172f22648b48804ec545b7e09ca8a6cc/apsw-3.53.2.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:32bb9e0437f6f42127954ec015661aa6168312f65a14c54411b441a6a1ff7fa9", size = 3276281, upload-time = "2026-06-04T22:08:49.858Z" }, + { url = "https://files.pythonhosted.org/packages/0b/77/6bae1bbce9f904c524b9e7eefb761e822c3f90f35d77cdf97a534f9219d4/apsw-3.53.2.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2cc3037ce66fa2b0a412ea9e835ce299ad8e90bd62c12ced30fbd7b6e36dae54", size = 3887532, upload-time = "2026-06-04T22:08:51.546Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e3/0a07f04180a16618453b6dc830c1676cd29675d2543a6854538dabb14d2f/apsw-3.53.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cbe7c0674f0d01ab2c053b7c51bb313e22453dde676e7ecb7b000cd15da5a9c3", size = 3645972, upload-time = "2026-06-04T22:08:53.186Z" }, + { url = "https://files.pythonhosted.org/packages/c4/25/54071dbf630651bcd0458ff12337aacd76001aa93053c0ede64bbe401ad2/apsw-3.53.2.0-cp314-cp314-win32.whl", hash = "sha256:6bd831376552028524b67ca5a82a75d48fee3d12a97dafc28f4657b5c7fe61d7", size = 2971678, upload-time = "2026-06-04T22:08:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/66/d3/2511136b4fe0a8b9f9f27ba80eb4c6cc8d4262a2fa44985c8ba42bd9ee43/apsw-3.53.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b8fd7c8dd031cea029ff0e14872f4653fd11866c4890895916308316db3ce84", size = 3298275, upload-time = "2026-06-04T22:08:56.331Z" }, + { url = "https://files.pythonhosted.org/packages/6b/35/2a28b23ca8bd886b8599e269111ae1bf8070c9cf84512b8201f004551686/apsw-3.53.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:83ad743297e3b4f76343d4a0ed3ff9880d6f4009a0a0803a0a1e6203d548d71e", size = 2943411, upload-time = "2026-06-04T22:08:58.203Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f4/cf4b6eb59171809063f59700bd1a78a1143dcc29e756ce8f420d449ba349/apsw-3.53.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ef24cb8ff8099647528c6b38aca1003439d99c5ad8b288886f74ab323b5d1411", size = 3281514, upload-time = "2026-06-04T22:08:59.836Z" }, + { url = "https://files.pythonhosted.org/packages/8b/06/8fab296bdbd57d3aeb716acf6dc3b0f7920998042d62701f4eb856a0412b/apsw-3.53.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:422f7bd7bb847fc0199137671c82cd0f6d91b29bb27a9c91099e952a9e13dbad", size = 3076785, upload-time = "2026-06-04T22:09:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/d1/69/09ceafb924b5f23f6559da7a2bb2326ca4af214f86e725485c1136e71043/apsw-3.53.2.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2d3894121e09a13a89704efebc195756486bfa3cde75a9b3d77b7ae85b59a4d6", size = 3456082, upload-time = "2026-06-04T22:09:03.18Z" }, + { url = "https://files.pythonhosted.org/packages/09/8c/e77bd8850fd8654dd01a5bb74b734418d2115d1bb61c2e4ced1719d60029/apsw-3.53.2.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7449e74aa39210492473507070722255641ce75df4057a6d27cc2a087358a813", size = 3248645, upload-time = "2026-06-04T22:09:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/23/49/a6d663791db4cebdd07da76efbb886f6275d4c7312e02538e76f18c04d04/apsw-3.53.2.0-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:68790d967d8af7010dd3b815df81f443ebe8fc63c8f60ac767cc822d92db8402", size = 3837908, upload-time = "2026-06-04T22:09:06.679Z" }, + { url = "https://files.pythonhosted.org/packages/a1/41/1b931de12f2ca2b216ff681db671e4f8d92dc3b13be9a6c9d5524398b9d6/apsw-3.53.2.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7264a55582bd176bd9901ba02ade5f5e618fd1d9c334dcefc041c185f8331eeb", size = 3614087, upload-time = "2026-06-04T22:09:08.497Z" }, + { url = "https://files.pythonhosted.org/packages/5d/31/43f6f504e5c23ea8d18862c1b5b6ea9985abf9879bdb1c5b5d832d1029f4/apsw-3.53.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:750665199fe6efc39f5b50c872de1cabb68ee61ba75844fd9dbc455c333b6e31", size = 3461514, upload-time = "2026-06-04T22:09:10.033Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/fd6a5c67b876b90bdf4ee781caeeb145ccf67d6cc8aae2e62b9cdf046e3a/apsw-3.53.2.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e3a2f5e8bd34ad3676f2d9a99a3652b816869d81743119ba92e04d717cda2e68", size = 3254505, upload-time = "2026-06-04T22:09:11.631Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/a2bc21bdbc9f6e02f3afd16f6b099b34aaf9084614b392d1c0688b59c89f/apsw-3.53.2.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:fb3d6459f773ff853d897220c10b21d41fbf23cbdcfc26d486c3e1c3c0aaa11e", size = 3856800, upload-time = "2026-06-04T22:09:13.479Z" }, + { url = "https://files.pythonhosted.org/packages/7d/7b/c623f85f71ab3a6644517fdcf1ac948284aebc51730126f65af4a2871a32/apsw-3.53.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:135870e8720c2188976cccb0194954ec7bf82a4bae4a0a563fed47d05982b2c5", size = 3623113, upload-time = "2026-06-04T22:09:15.205Z" }, + { url = "https://files.pythonhosted.org/packages/51/49/7f0432542e6ffba14dfddba8725eff17ef8fac36e21824d1efe030694be0/apsw-3.53.2.0-cp314-cp314t-win32.whl", hash = "sha256:66fa0a440801e999ca6436def12fd6f36b8da0729552efc10579ded3209f7e4a", size = 2985253, upload-time = "2026-06-04T22:09:16.6Z" }, + { url = "https://files.pythonhosted.org/packages/98/4f/5e25a774f6eca5524df7f410de2bfd15a11c950edc1910c80d03fbadbc25/apsw-3.53.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3200500d55831182fa354cb5db3ff2eeef5cb5107cf8b707d6bfaa7fde9e41f3", size = 3314505, upload-time = "2026-06-04T22:09:18.34Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d8/36d4583a51f99325f30be924af865998ee507bf7329bcf577eca5ab7e33b/apsw-3.53.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:549d3935245ae9b17dab73ed7b0192dc5ef9932aba196d0715a0e1eb2cb093d3", size = 2946179, upload-time = "2026-06-04T22:09:19.861Z" }, ] [[package]] @@ -378,6 +378,7 @@ dependencies = [ { name = "python-fasthtml" }, { name = "python-telegram-bot" }, { name = "requests" }, + { name = "slack-bolt" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -399,6 +400,7 @@ requires-dist = [ { name = "python-fasthtml", specifier = ">=0.14.2" }, { name = "python-telegram-bot", specifier = ">=22.7" }, { name = "requests", specifier = ">=2.32.5" }, + { name = "slack-bolt", specifier = ">=1.18.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.48.0" }, ] @@ -1293,11 +1295,11 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.30" +version = "0.0.32" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4b/82/c8cd43a6e0719bf5a3b034f6726dd701f75829c08944c83d4b95d02ed0e8/python_multipart-0.0.30.tar.gz", hash = "sha256:0edfe0475c1f46ddd3ff7785a626f6118af32bdcf359bb21260367313bb32118", size = 46316, upload-time = "2026-05-31T19:24:55.198Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/fd/0318007beb234790993d3ec5afd051d1dbceb733e81e3afe2b981ece3f37/python_multipart-0.0.30-py3-none-any.whl", hash = "sha256:830964def8c90607ac5daa00514e3987815865713ade8d20febc9177ac0c3c5b", size = 29730, upload-time = "2026-05-31T19:24:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] [[package]] @@ -1420,6 +1422,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "slack-bolt" +version = "1.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "slack-sdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/97/a62dde97e84027b252807f2044bed2edcda2d063a5cb0c535fb2be8d9b5d/slack_bolt-1.28.0.tar.gz", hash = "sha256:bfe367d867e8fb157a057248ebd4ac2d7f43acac6d0700fa31381db1e10f3b0f", size = 130768, upload-time = "2026-04-06T23:24:59.936Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/a9/697b6a92c728f09d5ef6b8e83dc6c8a87bc6d59499b2933ed067f11b7e30/slack_bolt-1.28.0-py2.py3-none-any.whl", hash = "sha256:738d1ca5e7c7039b6e18103d29267ced6e18c2517053eff18991fdd593acce5c", size = 234819, upload-time = "2026-04-06T23:24:58.278Z" }, +] + +[[package]] +name = "slack-sdk" +version = "3.42.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/00/16258bfa547559b2c936b50c882b4f0a36ebf6b69639eb763d8fa5e8d6cb/slack_sdk-3.42.0.tar.gz", hash = "sha256:873db9e1f632ac650ffdbf9d8ba825f3e9e7e576a1e4f9604ccb2a15b3727e3d", size = 252136, upload-time = "2026-05-18T17:50:44.727Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/ef/8a1556bd4843443993fc116783790a7cc553601a37f7d965ec26eef95e76/slack_sdk-3.42.0-py2.py3-none-any.whl", hash = "sha256:eb39aff97e476e10cc5a8ac29bd2e79a9959e880d9fe0c03b4e8f05b2ac996ff", size = 315469, upload-time = "2026-05-18T17:50:41.972Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -1495,15 +1518,15 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.48.0" +version = "0.49.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e6/bf/f6544ba992ddb9a6077343a576f9844f7f8f06ab819aefd00206e9255f18/uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37", size = 91074, upload-time = "2026-05-24T12:08:41.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410, upload-time = "2026-05-24T12:08:40.258Z" }, + { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, ] [package.optional-dependencies] From ce9039bbc2c727d3336f951d02678b4100167bbb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 00:57:20 +0000 Subject: [PATCH 2/7] Address PR review: fix subtype filtering, error handler, docs, dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove dead _bot_user_id field - Only filter system subtypes (message_changed/deleted, join/leave, etc.) instead of all subtypes — allows file_share, me_message, thread_broadcast - Register a slack_bolt-compatible error handler via app.error() - Update README: add Slack to feature list, config, Docker section, env table - Update Dockerfile: add SLACK_ALLOW_FROM env default and usage comments - Remove Slack from roadmap (shipped) https://claude.ai/code/session_01Fa64TcstSLyfugG93cjYR2 --- Dockerfile | 4 ++++ README.md | 30 ++++++++++++++++++++++++++---- app/channels/slack.py | 21 ++++++++++++++++++--- tests/test_slack_channel.py | 21 +++++++++++++++++---- 4 files changed, 65 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index f0baaab..b51f8a9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,6 +33,7 @@ ENV LLM_BASE_URL="" ENV MODEL="" ENV TELEGRAM_ALLOW_FROM="" ENV DISCORD_ALLOW_FROM="" +ENV SLACK_ALLOW_FROM="" ENV TZ=UTC # Web channel — bind to all interfaces inside the container so Docker @@ -46,6 +47,9 @@ ENV WEBSOCKET_PORT=8765 # -e LLM_API_KEY=sk-... \ # -e DISCORD_BOT_TOKEN=your-discord-token \ # -e DISCORD_ALLOW_FROM=123456789 \ +# -e SLACK_BOT_TOKEN=xoxb-... \ +# -e SLACK_APP_TOKEN=xapp-... \ +# -e SLACK_ALLOW_FROM=U123456789 \ # -p 8765:8765 \ # -v ./anotherbot-data:/data \ # or: docker run --env-file .env diff --git a/README.md b/README.md index 95c8776..56f8a68 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ A Python-based AI agent that can execute prompts, interact with the filesystem, - **Web UI**: Browser-based chat interface served by FastHTML + uvicorn — dark/light theme, collapsible conversation sidebar, full conversation history - **Telegram Integration**: Built-in Telegram bot — receive messages, respond, run tools, deliver results - **Discord Integration**: Discord bot — same agent loop, per-channel message isolation, owner DM fallback for scheduled tasks +- **Slack Integration**: Slack bot via Socket Mode — no public webhook required - **Scheduled Tasks**: SQLite-backed task scheduler — run prompts on a recurring or one-shot schedule and deliver results to a channel - **Tool Calling**: File I/O, shell commands, web fetch, web search (text/images/video/news/books), calculator, Hacker News, todo list - **Skills System**: Extendable skills in `app/skills/` (e.g., `puppeteer` for headless browsing) @@ -57,6 +58,11 @@ ALLOW_FROM = [] # List of allowed Discord user IDs (integers). Empty means allo [websocket] HOST = "127.0.0.1" # use 0.0.0.0 to expose on all interfaces (required for Docker) PORT = 8765 + +[slack] +BOT_TOKEN = "" # xoxb-... bot token +APP_TOKEN = "" # xapp-... app-level token (required for Socket Mode) +ALLOW_FROM = [] # List of allowed Slack user IDs (strings). Empty means allow all. ``` Message history is stored in `~/.crafterscode/history.db` (SQLite). Each channel maintains its own history with estimated token counts per message. @@ -105,9 +111,9 @@ Then open `http://localhost:8765/` in a browser. - `/help`, `/status`, `/whoami`, `/stop` answered instantly without an LLM call - All other slash commands (`/model`, `/load`, `/fork`, `/rename`, `/export`) forwarded to the agent -### Background Agent (Telegram / Discord) +### Background Agent (Telegram / Discord / Slack) -Configure one or both channels in `~/.crafterscode/config.toml`: +Configure one or more channels in `~/.crafterscode/config.toml`: ```toml [telegram] @@ -117,6 +123,11 @@ ALLOW_FROM = [123456789] # restrict by user ID; empty = allow all [discord] TOKEN = "your-discord-bot-token" ALLOW_FROM = [] # restrict by user ID; empty = allow all + +[slack] +BOT_TOKEN = "xoxb-..." # bot token from Slack app settings +APP_TOKEN = "xapp-..." # app-level token with connections:write scope +ALLOW_FROM = [] # restrict by Slack user ID; empty = allow all ``` ```bash @@ -198,6 +209,15 @@ docker run -d \ -e DISCORD_ALLOW_FROM=123456789 \ -v anotherbot-data:/data \ anotherbot + +# Slack +docker run -d \ + -e LLM_API_KEY=sk-... \ + -e SLACK_BOT_TOKEN=xoxb-... \ + -e SLACK_APP_TOKEN=xapp-... \ + -e SLACK_ALLOW_FROM=U123456789 \ + -v anotherbot-data:/data \ + anotherbot ``` ### Run — all channels at once @@ -233,11 +253,14 @@ docker run -d \ | `TELEGRAM_ALLOW_FROM` | — | Comma-separated Telegram user IDs (empty = allow all) | | `DISCORD_BOT_TOKEN` | — | Discord bot token from developer portal | | `DISCORD_ALLOW_FROM` | — | Comma-separated Discord user IDs (empty = allow all) | +| `SLACK_BOT_TOKEN` | — | Slack bot token (`xoxb-...`) from app settings | +| `SLACK_APP_TOKEN` | — | Slack app-level token (`xapp-...`) with `connections:write` scope | +| `SLACK_ALLOW_FROM` | — | Comma-separated Slack user IDs (empty = allow all) | | `LLM_BASE_URL` | no | API base URL (default: `https://openrouter.ai/api/v1`) | | `MODEL` | no | Model string (default: `deepseek/deepseek-v3.2`) | | `ANOTHERBOT_HOME` | no | Data directory for DB and workspace (default: `/data` in container) | -At least one channel (`WEBSOCKET_HOST`, `TELEGRAM_BOT_TOKEN`, or `DISCORD_BOT_TOKEN`) must be set or the server will exit. +At least one channel (`WEBSOCKET_HOST`, `TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN`, or `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN`) must be set or the server will exit. The `/data` volume persists the SQLite database and workspace across restarts. To supply a `config.toml` instead of env vars, mount it at `/data/config.toml` — env vars always take precedence over the file. @@ -245,7 +268,6 @@ The `/data` volume persists the SQLite database and workspace across restarts. T - **Email Support**: IMAP/SMTP integration for reading and sending emails, attachment handling, and mailbox management - **MCP Support**: Integration with Model Context Protocol for external data sources, tools, and state management -- **Slack Integration**: Slack app with interactive messages, modals, and workspace management - **WhatsApp Support**: WhatsApp Business API integration via providers like Twilio or MessageBird - **Anthropic OAuth**: Direct integration with Claude API using OAuth 2.0 - **Codex OAuth**: OpenAI Codex API authentication diff --git a/app/channels/slack.py b/app/channels/slack.py index cfb3541..b388465 100644 --- a/app/channels/slack.py +++ b/app/channels/slack.py @@ -13,6 +13,19 @@ MAX_SLACK_LENGTH = 3000 +_IGNORED_SUBTYPES = { + "message_changed", + "message_deleted", + "channel_join", + "channel_leave", + "channel_topic", + "channel_purpose", + "channel_name", + "group_join", + "group_leave", +} + + class SlackChannel(Channel): def __init__( self, @@ -27,13 +40,16 @@ def __init__( self.mq = mq self.stopped = False self._last_channel_id: str | None = None - self._bot_user_id: str | None = None self.app = AsyncApp(token=bot_token) mq.register(self, self.send_message) self._register_handlers() def _register_handlers(self) -> None: self.app.event("message")(self._handle_message) + self.app.error(self._slack_error_handler) + + async def _slack_error_handler(self, error: Exception, body: dict, logger) -> None: + logger.error(f"Slack error: {error}", exc_info=error) @property def has_stopped(self) -> bool: @@ -51,8 +67,7 @@ def default_metadata(self) -> dict: return {"channel_id": self._last_channel_id} if self._last_channel_id else {} async def _handle_message(self, event: dict, say) -> None: - # ignore bot messages and message edits/deletes - if event.get("bot_id") or event.get("subtype"): + if event.get("bot_id") or event.get("subtype") in _IGNORED_SUBTYPES: return user_id = event.get("user", "") diff --git a/tests/test_slack_channel.py b/tests/test_slack_channel.py index 6c4a51c..f3fa174 100644 --- a/tests/test_slack_channel.py +++ b/tests/test_slack_channel.py @@ -86,16 +86,29 @@ async def test_handle_message_ignores_bot_messages(): @pytest.mark.asyncio -async def test_handle_message_ignores_subtypes(): +async def test_handle_message_ignores_edit_and_delete_subtypes(): sc, mq = make_slack_channel() say = AsyncMock() + for subtype in ("message_changed", "message_deleted", "channel_join", "channel_leave"): + await sc._handle_message( + event={"user": "U123", "text": "hi", "channel": "C456", "subtype": subtype}, + say=say, + ) + + assert mq.incoming.empty() + + +@pytest.mark.asyncio +async def test_handle_message_allows_user_subtypes(): + sc, mq = make_slack_channel() + await sc._handle_message( - event={"user": "U123", "text": "edited", "channel": "C456", "subtype": "message_changed"}, - say=say, + event={"user": "U123", "text": "check this out", "channel": "C456", "subtype": "file_share"}, + say=AsyncMock(), ) - assert mq.incoming.empty() + assert not mq.incoming.empty() @pytest.mark.asyncio From ef23be7ea55b1c8a61cd567aeee9c104e446d470 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 01:56:31 +0000 Subject: [PATCH 3/7] Fix review comments: exc_info, empty channel guard, tests, cleanup - Fix exc_info=error -> exc_info=True in _slack_error_handler (logging API) - Guard against events with empty/missing channel_id before enqueuing - Raise MAX_SLACK_LENGTH 3000 -> 3800 (plain text= field allows ~40k chars) - Remove unused MagicMock import from test_slack_channel.py - Add test for empty-channel guard - Add Slack env var tests to test_config.py (BOT_TOKEN, APP_TOKEN, ALLOW_FROM) - Add Slack branch tests to test_bg_server.py (happy path, missing tokens, gather coros) https://claude.ai/code/session_01Fa64TcstSLyfugG93cjYR2 --- app/channels/slack.py | 7 +++- tests/test_bg_server.py | 76 ++++++++++++++++++++++++++++++++++++- tests/test_config.py | 39 +++++++++++++++++++ tests/test_slack_channel.py | 14 ++++++- 4 files changed, 132 insertions(+), 4 deletions(-) diff --git a/app/channels/slack.py b/app/channels/slack.py index b388465..9fd7003 100644 --- a/app/channels/slack.py +++ b/app/channels/slack.py @@ -10,7 +10,7 @@ log = logging.getLogger(__name__) -MAX_SLACK_LENGTH = 3000 +MAX_SLACK_LENGTH = 3800 _IGNORED_SUBTYPES = { @@ -49,7 +49,7 @@ def _register_handlers(self) -> None: self.app.error(self._slack_error_handler) async def _slack_error_handler(self, error: Exception, body: dict, logger) -> None: - logger.error(f"Slack error: {error}", exc_info=error) + logger.error(f"Slack error: {error}", exc_info=True) @property def has_stopped(self) -> bool: @@ -81,6 +81,9 @@ async def _handle_message(self, event: dict, say) -> None: return channel_id = event.get("channel", "") + if not channel_id: + log.warning("Slack: ignoring message with no channel in event") + return metadata = {"channel_id": channel_id} if content.startswith("/"): diff --git a/tests/test_bg_server.py b/tests/test_bg_server.py index 718b111..4dac62c 100644 --- a/tests/test_bg_server.py +++ b/tests/test_bg_server.py @@ -5,7 +5,7 @@ from app.bg_server import start_server -def _make_config(telegram_token=None, discord_token=None): +def _make_config(telegram_token=None, discord_token=None, slack_bot_token=None, slack_app_token=None): """Build a mock config that returns only the configured channels.""" mock_config = MagicMock() mock_config.PROJECT_HOME = MagicMock() @@ -24,6 +24,14 @@ def _make_config(telegram_token=None, discord_token=None): "TOKEN": discord_token, }.get(key, default) + if slack_bot_token: + active["slack"] = True + mock_config.slack.get.side_effect = lambda key, default=None: { + "BOT_TOKEN": slack_bot_token, + "APP_TOKEN": slack_app_token, + "ALLOW_FROM": [], + }.get(key, default) + mock_config.get.side_effect = lambda key: active.get(key) return mock_config @@ -152,3 +160,69 @@ async def test_start_server_discord_missing_token_skips_channel(): await start_server() mock_gather.assert_not_called() + + +@pytest.mark.asyncio +async def test_start_server_slack_only_starts_slack_agent(): + mock_gather = AsyncMock() + mock_config = _make_config(slack_bot_token="xoxb-test", slack_app_token="xapp-test") + + with patch("app.bg_server.config", mock_config), \ + patch("app.bg_server.os.chdir"), \ + patch("app.channels.slack.SlackChannel") as MockSC, \ + patch("app.bg_server.BackgroundAgent") as MockAgent, \ + patch("app.bg_server.ScheduledTasks"), \ + patch("app.bg_server.MessageQueue") as MockMQ, \ + patch("asyncio.gather", mock_gather): + + mock_sc = MockSC.return_value + mock_mq = MockMQ.return_value + + await start_server() + + MockSC.assert_called_once_with(mock_mq, bot_token="xoxb-test", app_token="xapp-test", allow_from=[]) + MockAgent.assert_called_once_with(mq=mock_mq, channel=mock_sc, max_iterations=ANY) + assert mock_gather.called + + +@pytest.mark.asyncio +async def test_start_server_slack_gather_has_correct_coros(): + mock_gather = AsyncMock() + mock_config = _make_config(slack_bot_token="xoxb-test", slack_app_token="xapp-test") + + with patch("app.bg_server.config", mock_config), \ + patch("app.bg_server.os.chdir"), \ + patch("app.channels.slack.SlackChannel") as MockSC, \ + patch("app.bg_server.BackgroundAgent") as MockAgent, \ + patch("app.bg_server.ScheduledTasks") as MockTasks, \ + patch("app.bg_server.MessageQueue") as MockMQ, \ + patch("asyncio.gather", mock_gather): + + mock_sc = MockSC.return_value + mock_agent = MockAgent.return_value + mock_mq = MockMQ.return_value + + await start_server() + + # tasks.run + slack run_polling + slack process_incoming + slack process_outgoing = 4 + coros = mock_gather.call_args[0] + assert len(coros) == 4 + mock_sc.run_polling.assert_called_once() + mock_agent.process_incoming.assert_called_once() + mock_mq.process_outgoing.assert_called_once() + + +@pytest.mark.asyncio +async def test_start_server_slack_missing_tokens_skips_channel(): + mock_gather = AsyncMock() + mock_config = _make_config() + mock_config.get.side_effect = lambda key: {"slack": True}.get(key) + mock_config.slack.get.side_effect = lambda key, default=None: None # both tokens missing + + with patch("app.bg_server.config", mock_config), \ + patch("app.bg_server.os.chdir"), \ + patch("asyncio.gather", mock_gather): + + await start_server() + + mock_gather.assert_not_called() diff --git a/tests/test_config.py b/tests/test_config.py index 6199877..e89c3a6 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -104,3 +104,42 @@ def test_docker_scenario_no_file_all_env_vars(tmp_path): assert config.get("telegram")["BOT_TOKEN"] == "bot:token" assert config.get("telegram")["ALLOW_FROM"] == [99, 100] + +# --- Slack env var overlay --- + +def test_slack_bot_token_env_sets_value(tmp_path): + with patch.dict(os.environ, {"SLACK_BOT_TOKEN": "xoxb-test"}, clear=False): + config.load(tmp_path / "missing.toml") + assert config.get("slack")["BOT_TOKEN"] == "xoxb-test" + + +def test_slack_app_token_env_sets_value(tmp_path): + with patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-test"}, clear=False): + config.load(tmp_path / "missing.toml") + assert config.get("slack")["APP_TOKEN"] == "xapp-test" + + +def test_slack_allow_from_env_parsed_to_string_list(tmp_path): + with patch.dict(os.environ, {"SLACK_ALLOW_FROM": "U111,U222,U333"}, clear=False): + config.load(tmp_path / "missing.toml") + assert config.get("slack")["ALLOW_FROM"] == ["U111", "U222", "U333"] + + +def test_slack_allow_from_env_strips_whitespace(tmp_path): + with patch.dict(os.environ, {"SLACK_ALLOW_FROM": "U111, U222"}, clear=False): + config.load(tmp_path / "missing.toml") + assert config.get("slack")["ALLOW_FROM"] == ["U111", "U222"] + + +def test_slack_docker_scenario_all_env_vars(tmp_path): + env = { + "SLACK_BOT_TOKEN": "xoxb-abc", + "SLACK_APP_TOKEN": "xapp-xyz", + "SLACK_ALLOW_FROM": "U123,U456", + } + with patch.dict(os.environ, env, clear=False): + config.load(tmp_path / "missing.toml") + assert config.get("slack")["BOT_TOKEN"] == "xoxb-abc" + assert config.get("slack")["APP_TOKEN"] == "xapp-xyz" + assert config.get("slack")["ALLOW_FROM"] == ["U123", "U456"] + diff --git a/tests/test_slack_channel.py b/tests/test_slack_channel.py index f3fa174..b4992a0 100644 --- a/tests/test_slack_channel.py +++ b/tests/test_slack_channel.py @@ -1,5 +1,5 @@ import pytest -from unittest.mock import patch, MagicMock, AsyncMock +from unittest.mock import patch, AsyncMock from app.channels.channel import ChannelType from app.channels.slack import SlackChannel, MAX_SLACK_LENGTH @@ -111,6 +111,18 @@ async def test_handle_message_allows_user_subtypes(): assert not mq.incoming.empty() +@pytest.mark.asyncio +async def test_handle_message_ignores_event_with_no_channel(): + sc, mq = make_slack_channel() + + await sc._handle_message( + event={"user": "U123", "text": "hello"}, # no "channel" key + say=AsyncMock(), + ) + + assert mq.incoming.empty() + + @pytest.mark.asyncio async def test_handle_message_rejects_unauthorized_user(): sc, mq = make_slack_channel(allow_from=["U111", "U222"]) From f38340184fbd7375302322e92e27dd63f9c8bbbd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 15:51:17 +0000 Subject: [PATCH 4/7] Change allow_from empty list to deny-all instead of allow-all Empty ALLOW_FROM now means no one is permitted, consistent with the principle of least privilege. Previously [] was treated as "no filter" (allow everyone); now it is treated as an empty allowlist (allow nobody). Update config comments, README, and all affected tests accordingly. https://claude.ai/code/session_01Fa64TcstSLyfugG93cjYR2 --- README.md | 16 ++++++++-------- app/channels/discord.py | 2 +- app/channels/slack.py | 2 +- app/channels/telegram.py | 2 +- app/config.py | 6 +++--- tests/test_discord_channel.py | 10 +++++++--- tests/test_slack_channel.py | 10 +++++++--- tests/test_telegram_channel.py | 13 ++++++++----- 8 files changed, 36 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 56f8a68..719c94f 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ ALLOW_FROM = [] # List of allowed Telegram user IDs (integers). [discord] TOKEN = "" -ALLOW_FROM = [] # List of allowed Discord user IDs (integers). Empty means allow all. +ALLOW_FROM = [] # List of allowed Discord user IDs (integers). Empty = deny all. [websocket] HOST = "127.0.0.1" # use 0.0.0.0 to expose on all interfaces (required for Docker) @@ -62,7 +62,7 @@ PORT = 8765 [slack] BOT_TOKEN = "" # xoxb-... bot token APP_TOKEN = "" # xapp-... app-level token (required for Socket Mode) -ALLOW_FROM = [] # List of allowed Slack user IDs (strings). Empty means allow all. +ALLOW_FROM = [] # List of allowed Slack user IDs (strings). Empty = deny all. ``` Message history is stored in `~/.crafterscode/history.db` (SQLite). Each channel maintains its own history with estimated token counts per message. @@ -118,16 +118,16 @@ Configure one or more channels in `~/.crafterscode/config.toml`: ```toml [telegram] BOT_TOKEN = "123456:ABC-your-bot-token" -ALLOW_FROM = [123456789] # restrict by user ID; empty = allow all +ALLOW_FROM = [123456789] # restrict by user ID [discord] TOKEN = "your-discord-bot-token" -ALLOW_FROM = [] # restrict by user ID; empty = allow all +ALLOW_FROM = [] # restrict by user ID [slack] BOT_TOKEN = "xoxb-..." # bot token from Slack app settings APP_TOKEN = "xapp-..." # app-level token with connections:write scope -ALLOW_FROM = [] # restrict by Slack user ID; empty = allow all +ALLOW_FROM = [] # restrict by Slack user ID ``` ```bash @@ -250,12 +250,12 @@ docker run -d \ | `WEBSOCKET_HOST` | — | Bind host for web UI (use `0.0.0.0` in Docker; default: `127.0.0.1`) | | `WEBSOCKET_PORT` | — | Port for web UI and WebSocket (default: `8765`) | | `TELEGRAM_BOT_TOKEN` | — | Telegram bot token from @BotFather | -| `TELEGRAM_ALLOW_FROM` | — | Comma-separated Telegram user IDs (empty = allow all) | +| `TELEGRAM_ALLOW_FROM` | — | Comma-separated Telegram user IDs (empty = deny all) | | `DISCORD_BOT_TOKEN` | — | Discord bot token from developer portal | -| `DISCORD_ALLOW_FROM` | — | Comma-separated Discord user IDs (empty = allow all) | +| `DISCORD_ALLOW_FROM` | — | Comma-separated Discord user IDs (empty = deny all) | | `SLACK_BOT_TOKEN` | — | Slack bot token (`xoxb-...`) from app settings | | `SLACK_APP_TOKEN` | — | Slack app-level token (`xapp-...`) with `connections:write` scope | -| `SLACK_ALLOW_FROM` | — | Comma-separated Slack user IDs (empty = allow all) | +| `SLACK_ALLOW_FROM` | — | Comma-separated Slack user IDs (empty = deny all) | | `LLM_BASE_URL` | no | API base URL (default: `https://openrouter.ai/api/v1`) | | `MODEL` | no | Model string (default: `deepseek/deepseek-v3.2`) | | `ANOTHERBOT_HOME` | no | Data directory for DB and workspace (default: `/data` in container) | diff --git a/app/channels/discord.py b/app/channels/discord.py index 9b97997..ece9523 100644 --- a/app/channels/discord.py +++ b/app/channels/discord.py @@ -46,7 +46,7 @@ async def on_message(self, message: discord.Message) -> None: if message.author.id == self.user.id: return user_id = message.author.id - if self.allow_from and user_id not in self.allow_from: + if user_id not in self.allow_from: log.warning(f"Discord: ignoring message from unauthorized user id={user_id}") await message.reply("Sorry, you are not authorized to use this bot.") return diff --git a/app/channels/slack.py b/app/channels/slack.py index 9fd7003..90b9874 100644 --- a/app/channels/slack.py +++ b/app/channels/slack.py @@ -71,7 +71,7 @@ async def _handle_message(self, event: dict, say) -> None: return user_id = event.get("user", "") - if self.allow_from and user_id not in self.allow_from: + if user_id not in self.allow_from: log.warning(f"Slack: ignoring message from unauthorized user id={user_id}") await say("Sorry, you are not authorized to use this bot.") return diff --git a/app/channels/telegram.py b/app/channels/telegram.py index 27ae922..b0f31bb 100755 --- a/app/channels/telegram.py +++ b/app/channels/telegram.py @@ -93,7 +93,7 @@ async def process_message( self, update: Update, context: ContextTypes.DEFAULT_TYPE ) -> None: user_id = update.effective_user.id if update.effective_user else None - if user_id is None or (self.allow_from and user_id not in self.allow_from): + if user_id is None or user_id not in self.allow_from: log.warning( f"Received message from unauthorized user id={user_id}, ignoring." ) diff --git a/app/config.py b/app/config.py index d021a4f..cac4177 100644 --- a/app/config.py +++ b/app/config.py @@ -72,11 +72,11 @@ def get_default_config() -> str: [telegram] BOT_TOKEN = "" -ALLOW_FROM = [] # List of allowed Telegram user IDs (integers). Must be non-empty. +ALLOW_FROM = [] # List of allowed Telegram user IDs (integers). Empty = deny all. [discord] TOKEN = "" -ALLOW_FROM = [] # List of allowed Discord user IDs (integers). Empty means allow all. +ALLOW_FROM = [] # List of allowed Discord user IDs (integers). Empty = deny all. [websocket] HOST = "127.0.0.1" @@ -85,5 +85,5 @@ def get_default_config() -> str: [slack] BOT_TOKEN = "" # xoxb-... bot token APP_TOKEN = "" # xapp-... app-level token (required for Socket Mode) -ALLOW_FROM = [] # List of allowed Slack user IDs (strings). Empty means allow all. +ALLOW_FROM = [] # List of allowed Slack user IDs (strings). Empty = deny all. """ diff --git a/tests/test_discord_channel.py b/tests/test_discord_channel.py index abf5285..dfab1b3 100644 --- a/tests/test_discord_channel.py +++ b/tests/test_discord_channel.py @@ -9,6 +9,8 @@ def make_discord_channel(allow_from=None): + if allow_from is None: + allow_from = [123] # default authorized user used across these tests mq = MessageQueue() with patch.object(discord.Client, "__init__", return_value=None): dc = DiscordChannel(mq=mq, token="test-token", allow_from=allow_from) @@ -107,16 +109,18 @@ async def test_on_message_rejects_unauthorized_user(): @pytest.mark.asyncio -async def test_on_message_allows_when_allow_from_empty(): +async def test_on_message_denies_when_allow_from_empty(): dc, mq = make_discord_channel(allow_from=[]) message = MagicMock() - message.author.id = 456 # any user allowed when allow_from is empty + message.author.id = 456 message.channel.id = 1 message.content = "hello" + message.reply = AsyncMock() await dc.on_message(message) - assert not mq.incoming.empty() + assert mq.incoming.empty() + message.reply.assert_called_once() @pytest.mark.asyncio diff --git a/tests/test_slack_channel.py b/tests/test_slack_channel.py index b4992a0..1d23555 100644 --- a/tests/test_slack_channel.py +++ b/tests/test_slack_channel.py @@ -8,6 +8,8 @@ def make_slack_channel(allow_from=None): + if allow_from is None: + allow_from = ["U123"] # default authorized user used across these tests mq = MessageQueue() with patch("app.channels.slack.AsyncApp"), \ patch("app.channels.slack.AsyncSocketModeHandler"): @@ -139,15 +141,17 @@ async def test_handle_message_rejects_unauthorized_user(): @pytest.mark.asyncio -async def test_handle_message_allows_when_allow_from_empty(): +async def test_handle_message_denies_when_allow_from_empty(): sc, mq = make_slack_channel(allow_from=[]) + say = AsyncMock() await sc._handle_message( event={"user": "U999", "text": "hello", "channel": "C456"}, - say=AsyncMock(), + say=say, ) - assert not mq.incoming.empty() + assert mq.incoming.empty() + say.assert_called_once() @pytest.mark.asyncio diff --git a/tests/test_telegram_channel.py b/tests/test_telegram_channel.py index 9b7ad39..23bed59 100644 --- a/tests/test_telegram_channel.py +++ b/tests/test_telegram_channel.py @@ -8,6 +8,8 @@ def make_telegram_channel(allow_from=None): + if allow_from is None: + allow_from = [123] # default authorized user used across these tests mq = MessageQueue() with patch("app.channels.telegram.ApplicationBuilder"): tc = TelegramChannel(mq=mq, bot_token="test-token", allow_from=allow_from) @@ -31,8 +33,9 @@ def test_stores_bot_token_and_allow_from(): assert tc.allow_from == [111, 222] -def test_allow_from_defaults_to_empty_list(): - tc, _ = make_telegram_channel() +def test_allow_from_none_becomes_empty_list(): + with patch("app.channels.telegram.ApplicationBuilder"): + tc = TelegramChannel(mq=MessageQueue(), bot_token="t", allow_from=None) assert tc.allow_from == [] @@ -80,7 +83,7 @@ def make_update(text="hello", user_id=123, chat_id=456): @pytest.mark.asyncio async def test_process_message_puts_to_incoming_queue(): tc, mq = make_telegram_channel() - update = make_update(text="do something", user_id=1, chat_id=10) + update = make_update(text="do something", user_id=123, chat_id=10) await tc.process_message(update, AsyncMock()) @@ -104,13 +107,13 @@ async def test_process_message_rejects_unauthorized_user(): @pytest.mark.asyncio -async def test_process_message_allows_when_allow_from_empty(): +async def test_process_message_denies_when_allow_from_empty(): tc, mq = make_telegram_channel(allow_from=[]) update = make_update(user_id=999, text="hi") await tc.process_message(update, AsyncMock()) - assert not mq.incoming.empty() + assert mq.incoming.empty() @pytest.mark.asyncio From 96ff34e65cd83b81b2b3cdec949761d2a741f8ed Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 17:54:25 +0000 Subject: [PATCH 5/7] Fix Slack slash command routing, last_channel_id tracking, and Telegram auth gap - Route Slack slash commands through app.command() handler (not just event("message")), matching how Slack actually delivers /commands - Update _last_channel_id before the command branch in _handle_message so it is always set even when the message triggers a whoami/stop inline response - Add allow_from auth check to Telegram command_handler, which previously had no authorization guard and allowed any user to invoke slash commands - Add tests for all three cases https://claude.ai/code/session_01Fa64TcstSLyfugG93cjYR2 --- app/channels/slack.py | 39 +++++++++++++- app/channels/telegram.py | 5 ++ tests/test_slack_channel.py | 96 ++++++++++++++++++++++++++++++++++ tests/test_telegram_channel.py | 16 +++++- 4 files changed, 153 insertions(+), 3 deletions(-) diff --git a/app/channels/slack.py b/app/channels/slack.py index 90b9874..5d35e40 100644 --- a/app/channels/slack.py +++ b/app/channels/slack.py @@ -1,5 +1,6 @@ import asyncio import logging +import re from slack_bolt.async_app import AsyncApp from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler @@ -46,8 +47,44 @@ def __init__( def _register_handlers(self) -> None: self.app.event("message")(self._handle_message) + self.app.command(re.compile(r".*"))(self._handle_slash_command) self.app.error(self._slack_error_handler) + async def _handle_slash_command(self, ack, command: dict, say) -> None: + await ack() + user_id = command.get("user_id", "") + channel_id = command.get("channel_id", "") + if user_id not in self.allow_from: + log.warning(f"Slack: ignoring slash command from unauthorized user id={user_id}") + await say("Sorry, you are not authorized to use this bot.") + return + if channel_id: + self._last_channel_id = channel_id + cmd_name = (command.get("command") or "").lstrip("/").lower() + text = (command.get("text") or "").strip() + full_content = f"/{cmd_name} {text}".strip() + metadata = {"channel_id": channel_id} + if cmd_name == "whoami": + await self.send_message(OutgoingMessage( + content=f"Your user ID is {user_id}.", + channel=ChannelType.SLACK, + metadata=metadata, + )) + return + if cmd_name == "stop": + self.stopped = True + await self.send_message(OutgoingMessage( + content="Stopped.", + channel=ChannelType.SLACK, + metadata=metadata, + )) + return + await self.mq.incoming.put(IncomingMessage( + content=full_content, + channel=ChannelType.SLACK, + metadata=metadata, + )) + async def _slack_error_handler(self, error: Exception, body: dict, logger) -> None: logger.error(f"Slack error: {error}", exc_info=True) @@ -84,6 +121,7 @@ async def _handle_message(self, event: dict, say) -> None: if not channel_id: log.warning("Slack: ignoring message with no channel in event") return + self._last_channel_id = channel_id metadata = {"channel_id": channel_id} if content.startswith("/"): @@ -104,7 +142,6 @@ async def _handle_message(self, event: dict, say) -> None: )) return - self._last_channel_id = channel_id await self.mq.incoming.put( IncomingMessage( content=content, diff --git a/app/channels/telegram.py b/app/channels/telegram.py index b0f31bb..4fa36de 100755 --- a/app/channels/telegram.py +++ b/app/channels/telegram.py @@ -56,6 +56,11 @@ async def error_handler( ) async def command_handler(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + user_id = update.effective_user.id if update.effective_user else None + if user_id is None or user_id not in self.allow_from: + log.warning(f"Telegram: ignoring command from unauthorized user id={user_id}") + await update.message.reply_text("Sorry, you are not authorized to use this bot.") + return if update.message and update.message.text: content = update.message.text.strip() if content.startswith("/"): diff --git a/tests/test_slack_channel.py b/tests/test_slack_channel.py index 1d23555..34f13d6 100644 --- a/tests/test_slack_channel.py +++ b/tests/test_slack_channel.py @@ -290,3 +290,99 @@ async def test_send_message_logs_error_when_no_channel_id(caplog): assert sc.app.client.chat_postMessage.call_count == 0 assert "channel_id" in caplog.text + + +# --- _handle_slash_command --- + +def make_command(cmd="/status", text="", user_id="U123", channel_id="C456"): + return {"command": cmd, "text": text, "user_id": user_id, "channel_id": channel_id} + + +@pytest.mark.asyncio +async def test_slash_command_enqueued(): + sc, mq = make_slack_channel() + ack = AsyncMock() + + await sc._handle_slash_command(ack, make_command("/status"), AsyncMock()) + + ack.assert_called_once() + assert not mq.incoming.empty() + msg = await mq.incoming.get() + assert msg.content == "/status" + assert msg.metadata == {"channel_id": "C456"} + + +@pytest.mark.asyncio +async def test_slash_command_with_args_enqueued(): + sc, mq = make_slack_channel() + ack = AsyncMock() + + await sc._handle_slash_command(ack, make_command("/model", text="gpt-4"), AsyncMock()) + + ack.assert_called_once() + msg = await mq.incoming.get() + assert msg.content == "/model gpt-4" + + +@pytest.mark.asyncio +async def test_slash_command_whoami(): + sc, mq = make_slack_channel() + sc.send_message = AsyncMock() + ack = AsyncMock() + + await sc._handle_slash_command(ack, make_command("/whoami"), AsyncMock()) + + sc.send_message.assert_called_once() + assert "U123" in sc.send_message.call_args[0][0].content + assert mq.incoming.empty() + + +@pytest.mark.asyncio +async def test_slash_command_stop(): + sc, mq = make_slack_channel() + sc.send_message = AsyncMock() + ack = AsyncMock() + + await sc._handle_slash_command(ack, make_command("/stop"), AsyncMock()) + + assert sc.has_stopped is True + assert mq.incoming.empty() + + +@pytest.mark.asyncio +async def test_slash_command_rejects_unauthorized_user(): + sc, mq = make_slack_channel(allow_from=["U123"]) + ack = AsyncMock() + say = AsyncMock() + + await sc._handle_slash_command(ack, make_command("/status", user_id="U999"), say) + + ack.assert_called_once() + assert mq.incoming.empty() + say.assert_called_once() + assert "not authorized" in say.call_args[0][0] + + +@pytest.mark.asyncio +async def test_slash_command_updates_last_channel_id(): + sc, mq = make_slack_channel() + ack = AsyncMock() + + await sc._handle_slash_command(ack, make_command("/status", channel_id="C999"), AsyncMock()) + + assert sc._last_channel_id == "C999" + + +# --- _last_channel_id updated before command branch in _handle_message --- + +@pytest.mark.asyncio +async def test_handle_message_updates_last_channel_id_for_commands(): + sc, mq = make_slack_channel() + sc.send_message = AsyncMock() + + await sc._handle_message( + event={"user": "U123", "text": "/whoami", "channel": "C777"}, + say=AsyncMock(), + ) + + assert sc._last_channel_id == "C777" diff --git a/tests/test_telegram_channel.py b/tests/test_telegram_channel.py index 23bed59..66d8b17 100644 --- a/tests/test_telegram_channel.py +++ b/tests/test_telegram_channel.py @@ -186,11 +186,23 @@ async def test_command_handler_whoami_replies_with_user_info(): tc, _ = make_telegram_channel() tc.send_message = AsyncMock() - await tc.command_handler(make_update(text="/whoami", user_id=42), MagicMock()) + await tc.command_handler(make_update(text="/whoami", user_id=123), MagicMock()) tc.send_message.assert_called_once() text = tc.send_message.call_args[0][0].content - assert "42" in text + assert "123" in text + + +@pytest.mark.asyncio +async def test_command_handler_rejects_unauthorized_user(): + tc, mq = make_telegram_channel(allow_from=[123]) + update = make_update(text="/status", user_id=999) + + await tc.command_handler(update, MagicMock()) + + assert mq.incoming.empty() + update.message.reply_text.assert_called_once() + assert "not authorized" in update.message.reply_text.call_args[0][0] @pytest.mark.asyncio From 07b2fc2020079107564f01a8eb16e10fd74b13e3 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Jun 2026 05:13:53 -0500 Subject: [PATCH 6/7] Resolving conflicts in old pull request (#15) --- .gitignore | 1 + CLAUDE.md | 18 +- README.md | 79 ++- app/channels/commands.py | 73 ++- app/channels/slack.py | 6 +- app/channels/static/web_channel.css | 79 +++ app/channels/static/web_channel.js | 113 +++- app/channels/web_channel.py | 172 +++++- app/cli/cli_agent.py | 17 +- app/core/agent.py | 107 +++- app/core/background_agent.py | 24 +- app/core/helper_agent.py | 2 +- app/core/mcp_manager.py | 148 +++++ app/core/sys_instructions.md | 6 +- app/core/tool_calls.py | 22 +- app/infra/message_history.py | 3 +- app/infra/setup.py | 2 +- app/infra/tracer.py | 27 + app/main.py | 63 ++- app/tools/helper_agent.py | 35 ++ pyproject.toml | 1 + tests/test_agent.py | 186 ++++++- tests/test_background_agent.py | 31 ++ tests/test_commands.py | 71 ++- tests/test_helper_agent_tool.py | 23 + tests/test_main.py | 86 ++- tests/test_tool_calls.py | 66 ++- tests/test_tracer.py | 44 ++ tests/test_web_channel.py | 112 ++++ uv.lock | 823 ++++++++++++++++++++++++++++ 30 files changed, 2347 insertions(+), 93 deletions(-) create mode 100644 app/core/mcp_manager.py create mode 100644 app/infra/tracer.py create mode 100755 app/tools/helper_agent.py create mode 100755 tests/test_helper_agent_tool.py create mode 100644 tests/test_tracer.py diff --git a/.gitignore b/.gitignore index 3909ff7..cae5323 100644 --- a/.gitignore +++ b/.gitignore @@ -132,6 +132,7 @@ celerybeat.pid # Environments .env .venv +.venv_win env/ venv/ ENV/ diff --git a/CLAUDE.md b/CLAUDE.md index 0c2c954..192d8c5 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,8 @@ Environment variables (all override config file values): - `WEBSOCKET_PORT` — port for web UI + WebSocket (default: `8765`) - `ANOTHERBOT_HOME` — overrides the data directory (default: `~/.crafterscode`) +MCP servers are configured separately in `~/.crafterscode/mcp_servers.json` (same dir, same format as Claude Desktop's `mcpServers` key). No env-var equivalent — in Docker, mount the file at `$ANOTHERBOT_HOME/mcp_servers.json`. + For Docker, no config file is needed — pass everything as env vars. See `Dockerfile` and the Docker section in README. ## Architecture @@ -72,16 +74,24 @@ The shared loop lives in `Agent._loop()` (`app/core/agent.py`). Subclasses overr | `_on_no_choices` | raise | exponential backoff | raise | | `_should_stop` | — | `channel.has_stopped` | — | -Tool calls within a single LLM turn are dispatched in parallel via `asyncio.gather`. After each turn, the full message chain (assistant tool-call message + tool results + final response) is saved to `self.messages` with tool results truncated to `TOOL_RESULT_HISTORY_LIMIT` chars to keep context lean. `MessageHistory` (SQLite) stores only user + final assistant text for cross-session persistence. +Tool calls within a single LLM turn are dispatched in parallel via `asyncio.gather`. After each turn, the full message chain (assistant tool-call message + tool results + final response) is saved to `self.messages` at full length. `MessageHistory` (SQLite) stores only user + final assistant text for cross-session persistence. ### Tool System -Each tool is a class extending `Tool` (`app/core/tool.py`), an ABC requiring a static `spec()` (OpenAI function-call schema) and a static `call()` method. Tools are registered in `app/core/tool_calls.py` in `tool_registry` — a dict mapping tool name → `Tool` class. `run_tool()` dispatches by name and restores `os.getcwd()` after each call. Results are truncated to `MAX_TOOL_RESULT_LENGTH` (16 000 chars). +Each tool is a class extending `Tool` (`app/core/tool.py`), an ABC requiring a static `spec()` (OpenAI function-call schema) and a static `call()` method. Tools are registered in `app/core/tool_calls.py` in `tool_registry` — a dict mapping tool name → `Tool` class. `run_tool()` is asynchronous and dispatches by name, awaiting the tool's `call` if it is a coroutine function, and restores `os.getcwd()` after execution. Results are truncated to `MAX_TOOL_RESULT_LENGTH` (16 000 chars). -Current tools: `read_file`, `write_file`, `bash`, `web_fetch`, `get_skills_dir`, `todo_add/list/update/clear`, `calculator`, `hackernews`, `websearch_text/images/videos/news/books`, `list/add/update/remove_scheduled_task`, `get_scheduled_task_output`, `get_city_state`, `get_datetime`. +Current built-in tools: `read_file`, `write_file`, `bash`, `web_fetch`, `get_skills_dir`, `todo_add/list/update/clear`, `calculator`, `hackernews`, `websearch_text/images/videos/news/books`, `list/add/update/remove_scheduled_task`, `get_scheduled_task_output`, `get_city_state`, `get_datetime`, `helper_agent`. `_HELPER_AGENT_TOOLS` in `tool_calls.py` is an explicit allowlist of tools available to `HelperAgent` (used internally by scheduled tasks). Scheduled task mutation tools (`add/update/remove_scheduled_task`) are excluded to prevent recursion. +`get_all_tool_specs()` merges built-in specs with any MCP tool specs at call time (not module load). `run_tool_async()` is the async dispatcher used by `handle_tool_call` — it routes to `MCPManager.call_tool()` for MCP tools or falls through to the synchronous `run_tool()` for built-ins. + +### MCP Servers + +`MCPManager` (`app/core/mcp_manager.py`) owns persistent FastMCP client connections and their tool catalogs. It is a module-level singleton (`mcp_manager`). `initialize_mcp()` in `main.py` reads `mcp_servers.json`, then calls `mcp_manager.initialize()` which connects all servers concurrently via `asyncio.gather`. Each server's tools are discovered via `list_tools()` and registered under the namespace `servername__toolname` (`_SEP = "__"`). `rpartition` is used when routing calls so tool names may contain underscores freely; only the server name must not contain `__`. + +The singleton is shut down via `mcp_manager.shutdown()` in a `try/finally` block in `main()`. `HelperAgent` does not receive MCP tools — it uses the static `helper_tool_specs` allowlist to prevent recursion. + ### System Context On startup, `load_system_context()` (`app/infra/startup.py`) loads `app/core/sys_instructions.md` and prepends it as the system message to `self.messages`. @@ -94,7 +104,7 @@ On startup, `load_system_context()` (`app/infra/startup.py`) loads `app/core/sys **Channel types** are defined in `ChannelType` enum (`app/channels/channel.py`): `CLI`, `TELEGRAM`, `DISCORD`, `WEB`. Each channel implements the `Channel` ABC and owns a `MessageQueue` instance. `bg_server.py` wires up enabled channels — each gets its own `MessageQueue`, `BackgroundAgent`, and set of coroutines (`run_polling`, `process_incoming`, `process_outgoing`) gathered into the event loop. -**WebChannel** (`app/channels/web_channel.py`) uses `python-fasthtml` + uvicorn to serve both a browser chat UI (`GET /`) and a JSON WebSocket endpoint (`WS /ws`) on the same port. Multiple concurrent browser tabs are supported — each connection gets a UUID tracked in `_connections`. A per-connection `asyncio.Lock` in `_send_locks` serializes writes to each WebSocket. The channel also exposes `GET /api/conversations`, `GET /api/messages` (scoped to web channel only), and `GET /api/status` REST endpoints. Only `/whoami` is handled inline (it needs the per-connection client ID); all other slash commands — including `/help`, `/status`, `/stop` — are forwarded to `BackgroundAgent`'s `CommandRegistry` via the message queue with `is_command=True` in metadata so `send_message()` emits `{"type":"system"}` responses, enabling the sidebar to refresh after conversation-mutating commands. +**WebChannel** (`app/channels/web_channel.py`) uses `python-fasthtml` + uvicorn to serve both a browser chat UI (`GET /`) and a JSON WebSocket endpoint (`WS /ws`) on the same port. Multiple concurrent browser tabs are supported — each connection gets a UUID tracked in `_connections`. A per-connection `asyncio.Lock` in `_send_locks` serializes writes to each WebSocket. The channel also exposes `GET /api/conversations`, `GET /api/messages` (scoped to web channel only), `GET /api/status`, and `POST /api/upload` REST endpoints. `POST /api/upload` accepts multipart `files` (one or more), writes each under `$ANOTHERBOT_HOME/uploads` with a UUID prefix, and returns the stored basenames; the browser (paperclip button next to the input) sends those basenames back in the WebSocket `message` frame's `files` field. `_resolve_upload_paths()` joins each basename to the upload dir (basename-only, so client paths can't traverse out) and the resolved absolute paths reach the agent via `metadata["files"]`, which `Agent._build_user_message()` encodes as image/file parts. Only `/whoami` is handled inline (it needs the per-connection client ID); all other slash commands — including `/help`, `/status`, `/stop` — are forwarded to `BackgroundAgent`'s `CommandRegistry` via the message queue with `is_command=True` in metadata so `send_message()` emits `{"type":"system"}` responses, enabling the sidebar to refresh after conversation-mutating commands. **Slash commands** are handled entirely by `BackgroundAgent`. Channel handlers (`command_handler` in Telegram, `on_message` in Discord) intercept only `/whoami` (resolved inline using the platform user object) and enqueue everything else as a plain `IncomingMessage`. `BackgroundAgent.process_incoming()` detects the leading `/` and dispatches via its own `CommandRegistry`. That registry owns the full command set: `/model`, `/status`, `/stop`, `/help`, `/list`, `/new`, `/load`, `/fork`, `/rename`, `/export`. diff --git a/README.md b/README.md index 719c94f..91a5ec2 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ A Python-based AI agent that can execute prompts, interact with the filesystem, - **Discord Integration**: Discord bot — same agent loop, per-channel message isolation, owner DM fallback for scheduled tasks - **Slack Integration**: Slack bot via Socket Mode — no public webhook required - **Scheduled Tasks**: SQLite-backed task scheduler — run prompts on a recurring or one-shot schedule and deliver results to a channel +- **MCP Servers**: Connect any [Model Context Protocol](https://modelcontextprotocol.io) server via `mcp_servers.json` — tools are auto-discovered and available alongside built-ins - **Tool Calling**: File I/O, shell commands, web fetch, web search (text/images/video/news/books), calculator, Hacker News, todo list - **Skills System**: Extendable skills in `app/skills/` (e.g., `puppeteer` for headless browsing) - **Persistent History**: Per-channel SQLite message history at `~/.crafterscode/app.db` (shared with scheduled tasks) @@ -151,6 +152,83 @@ remove the HN task Tasks persist in `~/.crafterscode/app.db` (shared with message history) and survive restarts. +## MCP Servers + +External [Model Context Protocol (MCP)](https://modelcontextprotocol.io) servers extend the agent with additional tools. Configured servers are initialized at startup; their tools appear alongside the built-in ones in the agent's tool list. + +### Setup + +Create `~/.crafterscode/mcp_servers.json` (same directory as `config.toml`). The format matches Claude Desktop's `mcpServers` config, so existing Claude Desktop configs can be copied directly: + +```json +{ + "mcpServers": { + "memory": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-memory"] + }, + "time": { + "command": "uvx", + "args": ["mcp-server-time"] + }, + "weather": { + "url": "https://weather-mcp.example.com/sse" + }, + "custom": { + "command": "python", + "args": ["./my_server.py"], + "env": {"MY_API_KEY": "secret"} + } + } +} +``` + +Each server entry supports one of two transport types: + +| Field | Required | Description | +|---|---|---| +| `command` | one of | Executable to spawn (stdio transport) | +| `args` | no | Argument list for the command | +| `env` | no | Extra environment variables for the subprocess | +| `url` | one of | SSE/HTTP endpoint URL (remote transport) | +| `disabled` | no | Set to `true` to skip this server at startup | + +To enable only a subset of servers, add `"disabled": true` to those you want to skip: + +```json +{ + "mcpServers": { + "memory": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-memory"] }, + "time": { "command": "uvx", "args": ["mcp-server-time"], "disabled": true } + } +} +``` + +Disabled servers appear in `/mcp` output with status `disabled` so you can see what is configured without connecting it. + +### Tool namespacing + +MCP tool names are prefixed with their server name using `__` as a separator: `servername__toolname`. This prevents collisions between built-in tools and between different servers. + +> **Note:** Server names must not contain `__` — it is reserved as the tool namespace separator. + +### Startup behaviour + +- All servers connect concurrently at startup. +- Failed connections are logged and skipped — the agent starts normally with whichever servers connected successfully. +- All connections are gracefully shut down when the process exits. + +### Docker + +Mount `mcp_servers.json` into the container's data directory: + +```bash +docker run ... \ + -v /path/to/mcp_servers.json:/data/mcp_servers.json \ + -v anotherbot-data:/data \ + anotherbot +``` + ## Docker ### Build @@ -267,7 +345,6 @@ The `/data` volume persists the SQLite database and workspace across restarts. T ## Roadmap - **Email Support**: IMAP/SMTP integration for reading and sending emails, attachment handling, and mailbox management -- **MCP Support**: Integration with Model Context Protocol for external data sources, tools, and state management - **WhatsApp Support**: WhatsApp Business API integration via providers like Twilio or MessageBird - **Anthropic OAuth**: Direct integration with Claude API using OAuth 2.0 - **Codex OAuth**: OpenAI Codex API authentication diff --git a/app/channels/commands.py b/app/channels/commands.py index 17e428c..f469d89 100644 --- a/app/channels/commands.py +++ b/app/channels/commands.py @@ -69,6 +69,20 @@ async def model_cmd(args: str = "") -> str: return f"Model set to: {args.strip()}" +async def trace_cmd(args: str = "") -> str: + arg = args.strip().lower() + tracedir = runtime.get("tracedir") + if arg == "on": + runtime.set("trace", True) + return f"Tracing on. Writing to {tracedir}" + elif arg == "off": + runtime.set("trace", False) + return "Tracing off." + else: + state = runtime.get("trace", False) + return f"Tracing is {'on' if state else 'off'}. Dir: {tracedir}" + + def make_status_cmd(channel_str: str = "") -> CommandHandler: async def _status(args: str = "") -> str: uptime = datetime.now() - _STARTUP_TIME @@ -81,11 +95,15 @@ async def _status(args: str = "") -> str: else: conv_id = runtime.get("conversation_id", "—") conv_name = runtime.get("conversation_name", "—") + tracing = runtime.get("trace", False) + last_trace = runtime.get("last_trace") + trace_line = f"on ({last_trace})" if (tracing and last_trace) else ("on" if tracing else "off") return ( f"Bot status:\n" f" Model: {model}\n" f" Uptime: {hours}h {minutes}m {seconds}s\n" - f" Conversation: [{conv_id}] {conv_name}" + f" Conversation: [{conv_id}] {conv_name}\n" + f" Tracing: {trace_line}" ) return _status @@ -200,3 +218,56 @@ async def _export(args: str = "") -> str: return str(e) return f"Exported to {path}" return _export + + +def mcp_cmd() -> CommandHandler: + async def _mcp(args: str = "") -> str: + from ..core.mcp_manager import mcp_manager + + parts = args.strip().split(maxsplit=1) + subcmd = parts[0].lower() if parts and parts[0] else "" + subargs = parts[1].strip() if len(parts) > 1 else "" + + if subcmd == "tools": + if subargs: + specs = mcp_manager.get_tools_for_server(subargs) + if not specs: + configured = [s["name"] for s in mcp_manager.get_server_status()] + if subargs not in configured: + return f"Server '{subargs}' not found. Configured: {', '.join(configured) or 'none'}" + return f"Server '{subargs}' has no tools." + lines = [f"Tools for '{subargs}' ({len(specs)}):"] + for spec in specs: + fn = spec["function"] + bare = fn["name"].partition("__")[2] + desc = fn.get("description", "") + lines.append(f" {bare}" + (f" — {desc}" if desc else "")) + return "\n".join(lines) + else: + specs = mcp_manager.get_tool_specs() + if not specs: + return "No MCP tools available." + lines = [f"MCP tools ({len(specs)}):"] + for spec in specs: + fn = spec["function"] + desc = fn.get("description", "") + lines.append(f" {fn['name']}" + (f" — {desc}" if desc else "")) + return "\n".join(lines) + else: + statuses = mcp_manager.get_server_status() + if not statuses: + return "No MCP servers configured.\nCreate ~/.crafterscode/mcp_servers.json to add servers." + lines = [f"MCP servers ({len(statuses)}):"] + for s in statuses: + if s["disabled"]: + status = "disabled" + elif s["connected"]: + status = "connected" + else: + status = "disconnected" + lines.append( + f" {s['name']} — {status}, {s['transport']} ({s['target']}), {s['tool_count']} tool(s)" + ) + return "\n".join(lines) + + return _mcp diff --git a/app/channels/slack.py b/app/channels/slack.py index 5d35e40..0f5a7a4 100644 --- a/app/channels/slack.py +++ b/app/channels/slack.py @@ -1,4 +1,3 @@ -import asyncio import logging import re @@ -163,15 +162,14 @@ async def process_message(self, message) -> None: pass # handled by slack bolt event handlers def error_handler(self, update, context) -> None: - log.error(f"Slack error: {context}") + pass # Slack-side errors are handled by _slack_error_handler registered via self.app.error() def start(self) -> None: log.info("Starting Slack channel...") async def run_polling(self) -> None: handler = AsyncSocketModeHandler(self.app, self.app_token) - await handler.start_async() try: - await asyncio.Event().wait() + await handler.start_async() finally: await handler.close_async() diff --git a/app/channels/static/web_channel.css b/app/channels/static/web_channel.css index 942dfbb..94b9069 100644 --- a/app/channels/static/web_channel.css +++ b/app/channels/static/web_channel.css @@ -318,3 +318,82 @@ body { #send-btn:hover { background: var(--accent-h); } #send-btn:active { transform: scale(.97); } #send-btn:disabled { background: var(--border); cursor: not-allowed; } + +/* Hide the file input without display:none — keeps it in the layout/event + tree so its `change` event fires reliably (Edge) when opened via the label. */ +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* ---- attach button (a