diff --git a/.env.example b/.env.example index a7ed3e9..ca0028c 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,8 @@ ANTHROPIC_API_KEY=your-anthropic-key # Telegram bot settings TELEGRAM_BOT_TOKEN=your-telegram-bot-token ALLOWED_USER_IDS=123456789,987654321 + +# Slack bot settings +SLACK_BOT_TOKEN=xoxb-your-bot-token +SLACK_APP_TOKEN=xapp-your-app-level-token +SLACK_ALLOWED_CHANNELS=C01ABCDEF,C02GHIJKL diff --git a/README.md b/README.md index f48ae51..bd77dd0 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Store your thoughts. Save your images and links. Ask anything, anytime. [![Anthropic API](https://img.shields.io/badge/Anthropic-API-blueviolet.svg)](https://docs.anthropic.com/) [![Telegram Bot](https://img.shields.io/badge/Telegram-Bot-26A5E4.svg)](https://core.telegram.org/bots) [![WhatsApp](https://img.shields.io/badge/WhatsApp-Bot-25D366.svg)](https://github.com/krypton-byte/neonize) +[![Slack Bot](https://img.shields.io/badge/Slack-Bot-4A154B.svg)](https://api.slack.com/bolt) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md) @@ -50,7 +51,7 @@ On first run, Memclaw will prompt you for your API keys and save them to `~/.mem The main way to use Memclaw. Just talk to it naturally — no commands needed. Send text, photos, voice messages, or links. The agent figures out what to do: store it, search your memories, retrieve images, or just chat. -Both platforms share the same agent, memories, and search index — your data is unified regardless of how you interact. +All platforms share the same agent, memories, and search index — your data is unified regardless of how you interact. ### Telegram Bot @@ -88,13 +89,83 @@ On first run a QR code is printed to your terminal. On your phone: **Settings Only messages you send to yourself (via WhatsApp's "Message Yourself" chat) are processed. DMs from other people and group messages are ignored. +### Slack Bot + +The Slack bot connects via **Socket Mode** (WebSocket) — no public URL or webhook server needed. + +#### Setup (from manifest — recommended) + +1. Go to [api.slack.com/apps](https://api.slack.com/apps) → **Create New App** → **From a manifest**, pick your workspace, and paste: + + ```yaml + display_information: + name: Memclaw + description: Your personal memory vault, powered by AI. + background_color: "#1a1a1a" + features: + bot_user: + display_name: Memclaw + always_online: true + app_home: + home_tab_enabled: false + messages_tab_enabled: true + messages_tab_read_only_enabled: false + oauth_config: + scopes: + bot: + - app_mentions:read + - channels:history + - chat:write + - files:read + - files:write + - im:history + - im:read + - im:write + settings: + event_subscriptions: + bot_events: + - app_mention + - message.im + interactivity: + is_enabled: false + socket_mode_enabled: true + ``` + +2. **Install to Workspace** and copy the **Bot Token** (`xoxb-...`). +3. Under **Basic Information → App-Level Tokens**, generate a token with `connections:write` scope and copy it (`xapp-...`). +4. Start the bot: + +```bash +memclaw slack +``` + +#### Setup (from scratch) + +If you'd rather click through the UI instead of using the manifest: + +1. Create a [Slack App](https://api.slack.com/apps) and enable **Socket Mode**. +2. Under **OAuth & Permissions**, add these bot token scopes: `app_mentions:read`, `chat:write`, `files:read`, `files:write`, `im:history`, `im:read`, `im:write`, `channels:history`. +3. Under **Event Subscriptions**, subscribe to: `message.im`, `app_mention`. +4. Under **App Home** → **Messages Tab**, enable the tab and check **"Allow users to send Slash commands and messages from the messages tab"** (otherwise DMs show "Sending messages to this app has been turned off"). +5. Install the app to your workspace and copy the **Bot Token** (`xoxb-...`). +6. Generate an **App-Level Token** (`xapp-...`) with `connections:write` scope. +7. Start the bot: + +```bash +memclaw slack +``` + +You can DM the bot directly or mention it in channels (`@Memclaw save this...`). Optionally restrict it to specific channels with `SLACK_ALLOWED_CHANNELS`. + +To update keys later: `memclaw configure`. + ### What it handles | Message type | What happens | |-------------|-------------| | **Text** | Agent decides: store as memory, search existing memories, or both. Links are extracted, fetched, and summarized automatically. | | **Photo** | AI-described via vision model, stored and indexed. Agent acknowledges and responds. Saved for later retrieval. | -| **Voice** | Transcribed via Whisper, stored as text. Agent responds to the content. Links extracted. | +| **Voice / Audio** | Transcribed via Whisper, stored as text. Agent responds to the content. Links extracted. | ### Examples @@ -117,6 +188,7 @@ Here's the sprint planning whiteboard you saved last week. flowchart LR TG[Telegram] <-->|text / images / links
response + images| Agent[Memclaw Agent] WA[WhatsApp] <-->|text / images / links
response + images| Agent + SL[Slack] <-->|text / images / links
response + images| Agent subgraph sandbox ["~/.memclaw/"] Agent -->|save| Tools1["memory_save
image_save
file_write"] @@ -233,6 +305,9 @@ memclaw --memory-dir ~/my-vault # override storage location | `ANTHROPIC_API_KEY` | Yes | Powers the Claude agent | | `TELEGRAM_BOT_TOKEN` | For Telegram bot | Your Telegram bot token | | `ALLOWED_USER_IDS` | For Telegram bot | Comma-separated Telegram user IDs | +| `SLACK_BOT_TOKEN` | For Slack bot | Slack bot token (`xoxb-...`) | +| `SLACK_APP_TOKEN` | For Slack bot | Slack app-level token for Socket Mode (`xapp-...`) | +| `SLACK_ALLOWED_CHANNELS` | For Slack bot | Comma-separated Slack channel IDs (optional) | ### Directory Structure diff --git a/memclaw/agent.py b/memclaw/agent.py index 9974b9a..7003424 100644 --- a/memclaw/agent.py +++ b/memclaw/agent.py @@ -25,6 +25,18 @@ {agent_instructions} +=== REPLY FORMATTING === +Replies are delivered to messaging apps with limited markdown support. Use \ +ONLY this minimal syntax — anything else leaks as literal characters: +- Bold: `*bold*` (single asterisk). NEVER use `**double asterisks**`. +- Italic: `_italic_`. +- Bullet lists: plain `- item` on its own line. +- Paragraphs: separate with a blank line. +- Headings (`#`, `##`, ...) are NOT supported — use a bold line on its own \ +(e.g. `*Section name*`) followed by a blank line instead. +- No backticks or fenced code blocks. +- No `[label](url)` links — write the bare URL. + === MEMORY CONTEXT === {context} diff --git a/memclaw/bot/handlers.py b/memclaw/bot/handlers.py index c1b9407..b8132ee 100644 --- a/memclaw/bot/handlers.py +++ b/memclaw/bot/handlers.py @@ -64,7 +64,13 @@ async def _send_response( logger.error(f"Failed to send image {img.get('file_id')}: {e}") if response_text: - await update.message.reply_text(response_text[:4096]) + try: + await update.message.reply_text(response_text[:4096], parse_mode="Markdown") + except Exception as e: + # Fall back to plain text if the agent emitted something Telegram's + # legacy Markdown parser rejects (unbalanced * or _, etc.). + logger.warning(f"Markdown parse failed, sending plain: {e}") + await update.message.reply_text(response_text[:4096]) async def _send_with_typing( self, diff --git a/memclaw/bot/slack_handlers.py b/memclaw/bot/slack_handlers.py new file mode 100644 index 0000000..3c1a0b8 --- /dev/null +++ b/memclaw/bot/slack_handlers.py @@ -0,0 +1,317 @@ +"""Slack bot event handlers for Memclaw. + +Every message (text, file/image) goes through the unified MemclawAgent, +which autonomously decides whether to store, search, or just respond. + +Uses slack-bolt with Socket Mode — no public URL required. +""" + +from __future__ import annotations + +import base64 +import uuid +from pathlib import Path + +import httpx +from loguru import logger +from openai import AsyncOpenAI +from slack_bolt.async_app import AsyncApp +from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler + +from ..agent import MemclawAgent +from ..config import MemclawConfig +from .link_processor import LinkProcessor + +# Image MIME types we handle +_IMAGE_MIMES = {"image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp"} + + +class SlackHandlers: + """Routes every Slack message through the unified Memclaw agent.""" + + def __init__(self, config: MemclawConfig, openai_client: AsyncOpenAI): + self.config = config + self.openai_client = openai_client + self.agent = MemclawAgent(config, platform="slack") + self.link_processor = LinkProcessor(openai_client) + + self.app = AsyncApp(token=config.slack_bot_token) + self._register_handlers() + + def _register_handlers(self): + """Register Slack event handlers on the bolt app.""" + + @self.app.event("message") + async def handle_message_event(event, say, client): + await self._route_event(event, say, client) + + @self.app.event("app_mention") + async def handle_mention(event, say, client): + await self._route_event(event, say, client) + + # ------------------------------------------------------------------ + # Access control + # ------------------------------------------------------------------ + + def _check_channel(self, channel: str) -> bool: + allowed = self.config.slack_allowed_channels_list + if not allowed: + return True # no allowlist = allow all + return channel in allowed + + # ------------------------------------------------------------------ + # Event routing + # ------------------------------------------------------------------ + + async def _route_event(self, event: dict, say, client): + # Ignore bot messages to prevent loops + if event.get("bot_id") or event.get("subtype") == "bot_message": + return + + channel = event.get("channel", "") + if not self._check_channel(channel): + return + + user = event.get("user", "unknown") + thread_ts = event.get("thread_ts") or event.get("ts", "") + text = event.get("text", "") + files = event.get("files", []) + + # Strip bot mention from text (e.g. "<@U123ABC> hello" → "hello") + text = self._strip_mention(text) + + # Check if there are image files attached + image_files = [f for f in files if f.get("mimetype", "") in _IMAGE_MIMES] + audio_files = [f for f in files if f.get("mimetype", "").startswith("audio/")] + + if image_files: + await self._handle_image(user, channel, thread_ts, text, image_files[0], say, client) + elif audio_files: + await self._handle_audio(user, channel, thread_ts, text, audio_files[0], say, client) + elif text: + await self._handle_text(user, channel, thread_ts, text, say) + else: + logger.debug("Ignoring Slack event with no text or supported files") + + @staticmethod + def _strip_mention(text: str) -> str: + """Remove bot mention tags like <@U123ABC> from the start of messages.""" + import re + return re.sub(r"^\s*<@[A-Z0-9]+>\s*", "", text).strip() + + # ------------------------------------------------------------------ + # Text messages + # ------------------------------------------------------------------ + + async def _handle_text(self, user: str, channel: str, thread_ts: str, text: str, say): + logger.info("Slack text from {u} in {c}: {t}", u=user, c=channel, t=text[:100]) + + prompt_parts = [text] + links = await self.link_processor.process_links(text) + for link in links: + if link.get("summary"): + prompt_parts.append( + f"\n[Link summary] {link['url']}: {link['summary']}" + "\nThis summary has NOT been saved yet. Save it if the content is worth remembering." + ) + + prompt = "\n".join(prompt_parts) + response_text, found_images = await self.agent.handle(prompt) + await self._send_response(channel, thread_ts, response_text, found_images, say) + + # ------------------------------------------------------------------ + # Image messages + # ------------------------------------------------------------------ + + async def _handle_image( + self, user: str, channel: str, thread_ts: str, caption: str, + file_info: dict, say, client, + ): + file_name = file_info.get("name", "image") + logger.info("Slack image from {u}: {f}, caption={c!r}", u=user, f=file_name, c=caption) + + # Download image via Slack file URL (requires bot token for auth) + image_bytes = await self._download_slack_file(file_info) + if image_bytes is None: + await say(text="Sorry, I couldn't download that image.", thread_ts=thread_ts) + return + + # Save locally under the slack media dir so retrievals can upload it back. + mime = file_info.get("mimetype", "image/jpeg") + ext = _mime_to_ext(mime) or ".jpg" + local_path = self.config.slack_media_dir / f"{uuid.uuid4().hex}{ext}" + local_path.write_bytes(image_bytes) + + base64_image = base64.b64encode(image_bytes).decode("utf-8") + logger.debug("Downloaded Slack image: {n} bytes -> {p}", n=len(image_bytes), p=local_path) + + # Process links in caption + link_info = "" + if caption: + links = await self.link_processor.process_links(caption) + for link in links: + if link.get("summary"): + link_info += ( + f"\n[Link summary] {link['url']}: {link['summary']}" + "\nThis summary has NOT been saved yet. Save it if the content is worth remembering." + ) + + prompt_text = f"User sent a photo. media_ref={local_path}" + if caption: + prompt_text += f"\nCaption: {caption}" + if link_info: + prompt_text += link_info + + media_type = mime if mime.startswith("image/") else "image/jpeg" + response_text, found_images = await self.agent.handle( + prompt_text, image_b64=base64_image, image_media_type=media_type, + ) + await self._send_response(channel, thread_ts, response_text, found_images, say) + + # ------------------------------------------------------------------ + # Audio messages + # ------------------------------------------------------------------ + + async def _handle_audio( + self, user: str, channel: str, thread_ts: str, caption: str, + file_info: dict, say, client, + ): + file_name = file_info.get("name", "audio") + logger.info("Slack audio from {u}: {f}", u=user, f=file_name) + + audio_bytes = await self._download_slack_file(file_info) + if audio_bytes is None: + await say(text="Sorry, I couldn't download that audio file.", thread_ts=thread_ts) + return + + mime = file_info.get("mimetype", "audio/mp4") + ext = _mime_to_ext(mime) or ".m4a" + + transcription = await self.openai_client.audio.transcriptions.create( + model="whisper-1", + file=(f"audio{ext}", audio_bytes, mime), + ) + text = transcription.text + logger.debug("Transcribed Slack audio: {t}", t=text[:100]) + + link_info = "" + links = await self.link_processor.process_links(text) + for link in links: + if link.get("summary"): + link_info += ( + f"\n[Link summary] {link['url']}: {link['summary']}" + "\nThis summary has NOT been saved yet. Save it if the content is worth remembering." + ) + + prompt = ( + f"[Voice message] {text}" + "\nThis transcription has NOT been saved yet. Save it if the content is worth remembering." + f"{link_info}" + ) + response_text, found_images = await self.agent.handle(prompt) + await self._send_response(channel, thread_ts, response_text, found_images, say) + + # ------------------------------------------------------------------ + # Slack API helpers + # ------------------------------------------------------------------ + + async def _download_slack_file(self, file_info: dict) -> bytes | None: + """Download a file from Slack using the url_private_download URL.""" + url = file_info.get("url_private_download") or file_info.get("url_private", "") + if not url: + logger.error("No download URL in Slack file info") + return None + + try: + async with httpx.AsyncClient( + headers={"Authorization": f"Bearer {self.config.slack_bot_token}"}, + timeout=30.0, + ) as client: + resp = await client.get(url) + resp.raise_for_status() + return resp.content + except Exception as exc: + logger.error("Failed to download Slack file: {exc}", exc=exc) + return None + + async def _upload_and_share_image(self, channel: str, thread_ts: str, image_path: str, caption: str | None = None): + """Upload a local image to Slack and share it in a channel.""" + path = Path(image_path) + if not path.exists(): + logger.error("Image file not found for Slack upload: {p}", p=image_path) + return + + try: + result = await self.app.client.files_upload_v2( + channel=channel, + file=str(path), + filename=path.name, + initial_comment=caption or "", + thread_ts=thread_ts if thread_ts else None, + ) + logger.debug("Uploaded image to Slack: {r}", r=result.get("ok")) + except Exception as exc: + logger.error("Failed to upload image to Slack: {exc}", exc=exc) + + async def _send_response( + self, + channel: str, + thread_ts: str, + response_text: str, + found_images: list[dict], + say, + ): + """Send agent response: images first, then text.""" + for img in found_images: + platform = img.get("platform", "telegram") + media_ref = img.get("media_ref") or img.get("file_id", "") + caption = img.get("caption") or None + + if platform == "slack": + await self._upload_and_share_image(channel, thread_ts, media_ref, caption) + else: + # For images from other platforms (e.g. Telegram file_ids), + # include a note in the text response + desc = img.get("description", "an image") + if response_text: + response_text += f"\n\n_(Found image: {desc} -- originally saved via {platform})_" + else: + response_text = f"_(Found image: {desc} -- originally saved via {platform})_" + + if response_text: + await say(text=response_text[:4000], thread_ts=thread_ts) + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def start(self): + """Start the Slack bot via Socket Mode.""" + await self.agent.start() + await self.agent.start_background_sync(interval=60) + handler = AsyncSocketModeHandler(self.app, self.config.slack_app_token) + await handler.start_async() + + def close(self): + self.agent.close() + + +# ------------------------------------------------------------------ +# Utilities +# ------------------------------------------------------------------ + +def _mime_to_ext(mime_type: str) -> str: + mapping = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", + "image/gif": ".gif", + "image/bmp": ".bmp", + "audio/ogg": ".ogg", + "audio/mpeg": ".mp3", + "audio/mp4": ".m4a", + "audio/x-m4a": ".m4a", + "audio/wav": ".wav", + "audio/webm": ".webm", + } + return mapping.get(mime_type, "") diff --git a/memclaw/cli.py b/memclaw/cli.py index e466148..6723b7f 100644 --- a/memclaw/cli.py +++ b/memclaw/cli.py @@ -416,3 +416,72 @@ def whatsapp(ctx): pass finally: bot_.close() + + +# ------------------------------------------------------------------ +# Slack bot (Socket Mode via slack-bolt) +# ------------------------------------------------------------------ + +@cli.command() +@click.pass_context +def slack(ctx): + """Start the Memclaw Slack bot (Socket Mode).""" + import sys + + from loguru import logger + from openai import AsyncOpenAI + + from .bot.slack_handlers import SlackHandlers + + _ensure_setup(ctx, channel="slack") + config: MemclawConfig = ctx.obj["config"] + + if not config.slack_bot_token: + console.print("[red]Error:[/red] SLACK_BOT_TOKEN is not set.") + console.print("Run [bold]memclaw configure[/bold] to set it.") + raise SystemExit(1) + + if not config.slack_app_token: + console.print("[red]Error:[/red] SLACK_APP_TOKEN is not set.") + console.print("Run [bold]memclaw configure[/bold] to set it.") + raise SystemExit(1) + + if not config.openai_api_key: + console.print("[red]Error:[/red] OPENAI_API_KEY is not set.") + console.print("Run [bold]memclaw configure[/bold] to set it.") + raise SystemExit(1) + + if not config.anthropic_api_key: + console.print("[red]Error:[/red] ANTHROPIC_API_KEY is not set.") + console.print("Run [bold]memclaw configure[/bold] to set it.") + raise SystemExit(1) + + # Logging + logger.remove() + logger.add(sys.stderr, level="INFO", + format="{time:HH:mm:ss} | {level:<8} | {message}") + logger.add( + str(config.memory_dir / "slack.log"), + rotation="10 MB", + retention="7 days", + level="DEBUG", + ) + + openai_client = AsyncOpenAI(api_key=config.openai_api_key) + handlers = SlackHandlers(config, openai_client) + + console.print( + f"[green]Starting Memclaw Slack bot (Socket Mode)...[/green] " + f"(allowed channels: {config.slack_allowed_channels_list or 'all'})" + ) + + async def _run(): + try: + await handlers.start() + except KeyboardInterrupt: + pass + finally: + handlers.close() + console.print("\nMemclaw Slack bot shut down.") + + asyncio.run(_run()) diff --git a/memclaw/config.py b/memclaw/config.py index 0bfe355..c2bbdb2 100644 --- a/memclaw/config.py +++ b/memclaw/config.py @@ -40,6 +40,11 @@ class MemclawConfig: telegram_bot_token: str = "" allowed_user_ids: str = "" + # Slack bot settings + slack_bot_token: str = "" + slack_app_token: str = "" + slack_allowed_channels: str = "" + def __post_init__(self): if not self.openai_api_key: self.openai_api_key = os.environ.get("OPENAI_API_KEY", "") @@ -49,6 +54,12 @@ def __post_init__(self): self.telegram_bot_token = os.environ.get("TELEGRAM_BOT_TOKEN", "") if not self.allowed_user_ids: self.allowed_user_ids = os.environ.get("ALLOWED_USER_IDS", "") + if not self.slack_bot_token: + self.slack_bot_token = os.environ.get("SLACK_BOT_TOKEN", "") + if not self.slack_app_token: + self.slack_app_token = os.environ.get("SLACK_APP_TOKEN", "") + if not self.slack_allowed_channels: + self.slack_allowed_channels = os.environ.get("SLACK_ALLOWED_CHANNELS", "") self.memory_dir = Path(self.memory_dir) self.memory_dir.mkdir(parents=True, exist_ok=True) self.memory_subdir.mkdir(exist_ok=True) @@ -107,6 +118,18 @@ def whatsapp_media_dir(self) -> Path: d.mkdir(exist_ok=True) return d + @property + def slack_dir(self) -> Path: + d = self.memory_dir / "slack" + d.mkdir(exist_ok=True) + return d + + @property + def slack_media_dir(self) -> Path: + d = self.slack_dir / "media" + d.mkdir(exist_ok=True) + return d + @property def allowed_user_ids_list(self) -> list[int]: if not self.allowed_user_ids: @@ -117,3 +140,8 @@ def allowed_user_ids_list(self) -> list[int]: if uid.strip() ] + @property + def slack_allowed_channels_list(self) -> list[str]: + if not self.slack_allowed_channels: + return [] + return [c.strip() for c in self.slack_allowed_channels.split(",") if c.strip()] diff --git a/memclaw/defaults/AGENTS.md b/memclaw/defaults/AGENTS.md index 6898243..7594619 100644 --- a/memclaw/defaults/AGENTS.md +++ b/memclaw/defaults/AGENTS.md @@ -8,7 +8,8 @@ You are Memclaw, a personal memory assistant. You help users store and retrieve 2. **Search**: When the user asks a question or wants to recall something — search using memory_search. Present results clearly with dates. 3. **Images**: When you see an image with a media_ref (from a messaging channel or a local path), describe what you see in detail and save using image_save. Pass the media_ref verbatim. 4. **Image retrieval**: When the user asks to find an image — use image_search. The image will be sent automatically. -5. **Conversation**: Sometimes the user just wants to chat. Respond naturally. If they mention something worth remembering, save it too. +5. **Links**: URLs in the user's message arrive pre-fetched and summarised as `[Link summary] : `. If worth keeping, save the URL + summary with memory_save. Tell users you can do this when asked. +6. **Conversation**: Sometimes the user just wants to chat. Respond naturally. If they mention something worth remembering, save it too. ## Storage guidelines diff --git a/memclaw/index.py b/memclaw/index.py index 62fbb35..eb0edcc 100644 --- a/memclaw/index.py +++ b/memclaw/index.py @@ -107,7 +107,7 @@ def _init_db(self): ) """) - # Platform-agnostic image registry (Telegram, WhatsApp, etc.) + # Platform-agnostic image registry (Telegram, WhatsApp, Slack, etc.) self.db.execute(""" CREATE TABLE IF NOT EXISTS platform_images ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -377,7 +377,7 @@ async def store_platform_image( _skip_embed: bool = False, _embedding: np.ndarray | None = None, ): - """Store an image reference for any platform (telegram, whatsapp, etc.).""" + """Store an image reference for any platform (telegram, whatsapp, slack, etc.).""" embedding = _embedding if _skip_embed else await self.get_embedding(description) self.db.execute( "INSERT INTO platform_images (platform, media_ref, description, caption, embedding) " diff --git a/memclaw/setup.py b/memclaw/setup.py index 003f172..99ff85f 100644 --- a/memclaw/setup.py +++ b/memclaw/setup.py @@ -15,11 +15,17 @@ # Keys in the order they are prompted. # `channel` is None for always-asked keys, or a channel name (e.g. "telegram") # for keys that are only relevant to that bot command. +# `required` for a channel-scoped key means "required when invoked via that +# channel" (e.g. SLACK_BOT_TOKEN is required during `memclaw slack`, but not +# enforced during `memclaw configure` which shows everything). KEYS: list[tuple[str, str, bool, str | None]] = [ ("OPENAI_API_KEY", "OpenAI API key", True, None), ("ANTHROPIC_API_KEY", "Anthropic API key", True, None), - ("TELEGRAM_BOT_TOKEN", "Telegram bot token", False, "telegram"), + ("TELEGRAM_BOT_TOKEN", "Telegram bot token", True, "telegram"), ("ALLOWED_USER_IDS", "Allowed Telegram user IDs (comma-separated)", False, "telegram"), + ("SLACK_BOT_TOKEN", "Slack bot token (xoxb-...)", True, "slack"), + ("SLACK_APP_TOKEN", "Slack app-level token for Socket Mode (xapp-...)", True, "slack"), + ("SLACK_ALLOWED_CHANNELS", "Allowed Slack channel IDs (comma-separated)", False, "slack"), ] @@ -85,6 +91,13 @@ def run_setup(*, reconfigure: bool = False, channel: str | None = None) -> None: # skip this round are preserved. values: dict[str, str] = dict(existing) + # A channel-scoped required key is only enforced when invoked via that + # channel; in reconfigure mode nothing is enforced (user is just editing). + def _is_required(required: bool, key_channel: str | None) -> bool: + if reconfigure or not required: + return False + return key_channel is None or key_channel == channel + for env_key, label, required, key_channel in KEYS: # Skip channel-scoped keys that don't match this invocation (unless # the user is explicitly reconfiguring, in which case show all). @@ -93,10 +106,11 @@ def run_setup(*, reconfigure: bool = False, channel: str | None = None) -> None: current = existing.get(env_key, "") masked = _mask(current) + is_required = _is_required(required, key_channel) if reconfigure and current: prompt_text = f"{label} [{masked}]" - elif required: + elif is_required: prompt_text = f"{label} (required)" else: prompt_text = f"{label} (optional)" @@ -108,13 +122,11 @@ def run_setup(*, reconfigure: bool = False, channel: str | None = None) -> None: elif current: values[env_key] = current - # Validate required keys - if not values.get("OPENAI_API_KEY"): - console.print("[red]Error:[/red] OpenAI API key is required.") - raise SystemExit(1) - if not values.get("ANTHROPIC_API_KEY"): - console.print("[red]Error:[/red] Anthropic API key is required.") - raise SystemExit(1) + # Validate required keys (always-required + channel-scoped required). + for env_key, label, required, key_channel in KEYS: + if _is_required(required, key_channel) and not values.get(env_key): + console.print(f"[red]Error:[/red] {label} is required.") + raise SystemExit(1) # Write to ~/.memclaw/.env ENV_FILE.parent.mkdir(parents=True, exist_ok=True) diff --git a/pyproject.toml b/pyproject.toml index d70c16e..56e682d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,8 @@ dependencies = [ "click>=8.0.0", "rich>=13.0.0", "python-telegram-bot>=21.0", + "slack-bolt>=1.18.0", + "aiohttp>=3.9.0", "httpx>=0.27.0", "beautifulsoup4>=4.12.0", "loguru>=0.7.0", diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 2fd03bd..d85c2d0 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -114,7 +114,7 @@ async def test_response_sent_after_agent(self): await handlers._send_with_typing(update, context, "Hi") - update.message.reply_text.assert_called_once_with("Hello!") + update.message.reply_text.assert_called_once_with("Hello!", parse_mode="Markdown") class TestAgentsFile: