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.
[](https://docs.anthropic.com/)
[](https://core.telegram.org/bots)
[](https://github.com/krypton-byte/neonize)
+[](https://api.slack.com/bolt)
[](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: