Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,11 @@ Current built-in tools: `read_file`, `write_file`, `bash`, `web_fetch`, `get_ski

`_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.
`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 `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 `__`.
`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 = "__"`). `partition` 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.

Expand Down
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
40 changes: 31 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
- **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
Expand Down Expand Up @@ -53,11 +54,16 @@ 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)
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 = deny all.
```

Message history is stored in `~/.crafterscode/history.db` (SQLite). Each channel maintains its own history with estimated token counts per message.
Expand Down Expand Up @@ -106,18 +112,23 @@ 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]
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

Comment on lines 124 to +127
[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
Comment on lines +128 to +131
```

```bash
Expand Down Expand Up @@ -276,6 +287,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
Expand Down Expand Up @@ -308,21 +328,23 @@ 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 = 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) |

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.

## Roadmap

- **Email Support**: IMAP/SMTP integration for reading and sending emails, attachment handling, and mailbox 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
Expand Down
25 changes: 22 additions & 3 deletions app/bg_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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))

Comment thread
Rikul marked this conversation as resolved.
if not telegram_channel and not discord_channel and not web_channel and not slack_channel:
log.error("No channels configured, exiting...")
return

Expand All @@ -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
Comment on lines +90 to +92

tasks = ScheduledTasks(mqs=mqs, channels=channels)

Expand All @@ -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)
1 change: 1 addition & 0 deletions app/channels/channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ class ChannelType(Enum):
CLI = "cli"
TELEGRAM = "telegram"
DISCORD = "discord"
SLACK = "slack"
WEB = "web"

class Channel(ABC):
Expand Down
2 changes: 1 addition & 1 deletion app/channels/discord.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
175 changes: 175 additions & 0 deletions app/channels/slack.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import logging
import re

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 = 3800


_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,
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.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)
Comment thread
Rikul marked this conversation as resolved.
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
Comment thread
Rikul marked this conversation as resolved.
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)

@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:
if event.get("bot_id") or event.get("subtype") in _IGNORED_SUBTYPES:
return

user_id = event.get("user", "")
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
Comment thread
Rikul marked this conversation as resolved.

content = (event.get("text") or "").strip()
if not content:
return

channel_id = event.get("channel", "")
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}
Comment thread
Rikul marked this conversation as resolved.

Comment thread
Rikul marked this conversation as resolved.
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

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:
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)
try:
await handler.start_async()
finally:
await handler.close_async()
Comment thread
Rikul marked this conversation as resolved.
Loading
Loading