diff --git a/extensions/slack-bot/.env.example b/extensions/slack-bot/.env.example new file mode 100644 index 00000000..b19aee5f --- /dev/null +++ b/extensions/slack-bot/.env.example @@ -0,0 +1,8 @@ +# Slack Bot Token (xoxb-...) +# Get from: OAuth & Permissions > Bot User OAuth Token +SLACK_BOT_TOKEN=xoxb-your-token-here + +# Slack App Token for Socket Mode (xapp-...) +# Get from: Settings > Basic Information > App-Level Tokens +# Needs connections:write scope +SLACK_APP_TOKEN=xapp-your-token-here diff --git a/extensions/slack-bot/.gitignore b/extensions/slack-bot/.gitignore new file mode 100644 index 00000000..793e4176 --- /dev/null +++ b/extensions/slack-bot/.gitignore @@ -0,0 +1,4 @@ +.env +.venv +__pycache__/ +*.pyc diff --git a/extensions/slack-bot/README.md b/extensions/slack-bot/README.md new file mode 100644 index 00000000..20ca42f1 --- /dev/null +++ b/extensions/slack-bot/README.md @@ -0,0 +1,61 @@ +# Slack Bot + +A basic Slack bot using Socket Mode with a web chat interface. + +The bot here just mirrors back your last word as a question, to demonstrate that it's a good listener. Customize the behavior in `app/responder.py` to do whatever you'd like. + +## Routes + +- `/` - Web chat interface (Shiny) +- `/api/health` - Health check endpoint + +## Slack App Setup + +1. Create a new app at https://api.slack.com/apps (From scratch) + +2. **Enable Socket Mode** + - Settings → Socket Mode → Enable + - Create an App-Level Token with `connections:write` scope + - Save this token as `SLACK_APP_TOKEN` (starts with `xapp-`) + +3. **Add Bot Token Scopes** (OAuth & Permissions → Scopes → Bot Token Scopes) + - `app_mentions:read` - receive @-mention events + - `chat:write` - send messages + - `im:history` - receive DM messages + - `im:read` - access DM channel info + - `im:write` - send DMs + +4. **Subscribe to Events** (Event Subscriptions → Subscribe to bot events) + - `app_mention` - when someone @-mentions the bot + - `message.im` - DMs to the bot + +5. **Install to Workspace** + - OAuth & Permissions → Install to Workspace + - Copy the Bot User OAuth Token as `SLACK_BOT_TOKEN` (starts with `xoxb-`) + +## Running Locally + +```bash +# Install dependencies +uv sync + +# Configure environment +cp .env.example .env +# Edit .env with your tokens + +# Run +uv run uvicorn app.main:app --reload +``` + +## Deploying to Posit Connect + +Set `min_processes` to 1 in the runtime settings so the app stays running and listens for Slack messages. Otherwise, the app will go idle and stop responding to Slack. + +## Usage + +**Web chat:** +- Open http://localhost:8000 in your browser + +**Slack:** +- Mention the bot in a channel: `@bot I feel sad` → `Sad?` +- DM the bot: `My dog is sick` → `Sick?` diff --git a/extensions/slack-bot/api.py b/extensions/slack-bot/api.py new file mode 100644 index 00000000..24a73f79 --- /dev/null +++ b/extensions/slack-bot/api.py @@ -0,0 +1,60 @@ +import re + +from fastapi import FastAPI +from slack_bolt.async_app import AsyncApp +from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler + +from config import settings +from responder import generate_response + +# Initialize Slack app (started by main.py lifespan) +slack_app = AsyncApp(token=settings.slack_bot_token) + + +def create_socket_handler() -> AsyncSocketModeHandler: + """Create the Socket Mode handler for Slack.""" + return AsyncSocketModeHandler(slack_app, settings.slack_app_token) + + +def strip_bot_mention(text: str) -> str: + """Remove bot @-mention from message text.""" + return re.sub(r"<@[A-Z0-9]+>\s*", "", text).strip() + + +# Slack event handlers + +@slack_app.event("app_mention") +async def handle_mention(event, say, client): + """Handle @-mentions in channels and threads.""" + thread_ts = event.get("thread_ts") or event["ts"] + user_message = strip_bot_mention(event["text"]) + + response = generate_response(user_message) + await say(text=response, thread_ts=thread_ts) + + +@slack_app.event("message") +async def handle_dm(event, say, client): + """Handle DMs to the bot.""" + # Only handle DMs + if event.get("channel_type") != "im": + return + + # Ignore bot messages and subtypes (edits, etc.) + if event.get("bot_id") or event.get("subtype"): + return + + user_message = event["text"] + + response = generate_response(user_message) + await say(text=response) + + +# FastAPI app (just health check) +fastapi_app = FastAPI() + + +@fastapi_app.get("/health") +async def health(): + """Health check endpoint.""" + return {"status": "healthy"} diff --git a/extensions/slack-bot/chat_ui.py b/extensions/slack-bot/chat_ui.py new file mode 100644 index 00000000..1e7e55e6 --- /dev/null +++ b/extensions/slack-bot/chat_ui.py @@ -0,0 +1,121 @@ +import os + +from shiny.express import ui +from shinychat.express import Chat +from slack_sdk import WebClient + +from responder import generate_response + +# Check for Slack tokens +slack_bot_token = os.environ.get("SLACK_BOT_TOKEN") +slack_app_token = os.environ.get("SLACK_APP_TOKEN") +slack_configured = bool(slack_bot_token and slack_app_token) + +# Get bot name if configured +bot_name = None +if slack_configured: + try: + client = WebClient(token=slack_bot_token) + auth_response = client.auth_test() + bot_name = auth_response.get("user") + except Exception: + pass + +ui.page_opts(fillable=True, title="Slack Bot") + +with ui.sidebar(width=350): + ui.h3("Slack Bot") + + ui.markdown( + """ +This is a basic Slack bot with a web chat interface. + +The bot mirrors back your last word as a question, demonstrating +that it's a good listener. Customize the behavior in +`responder.py` to do whatever you'd like. + +**Usage:** +- Chat here in the web interface +- @mention the bot in Slack +- DM the bot directly +""" + ) + + if slack_configured: + status_text = f"Slack connected as @{bot_name}" if bot_name else "Slack connected" + ui.div( + ui.tags.span(status_text, style="color: green; font-weight: bold;"), + style="margin: 1em 0; padding: 0.5em; background: #e8f5e9; border-radius: 4px;", + ) + else: + ui.div( + ui.markdown( + """ +**Slack not configured** + +Set these environment variables: +- `SLACK_BOT_TOKEN` (xoxb-...) +- `SLACK_APP_TOKEN` (xapp-...) +""" + ), + style="margin: 1em 0; padding: 0.5em; background: #ffebee; border-radius: 4px;", + ) + + ui.HTML( + """ +
+Posit Connect +

Set min_processes to 1 in runtime settings so the app stays running and listens for Slack messages.

+
+ +
+Slack App Setup + +
    +
  1. Create a new app at api.slack.com/apps (From scratch)

  2. + +
  3. Enable Socket Mode

    +
      +
    • Settings → Socket Mode → Enable
    • +
    • Create an App-Level Token with connections:write scope
    • +
    • Save as SLACK_APP_TOKEN (starts with xapp-)
    • +
  4. + +
  5. Add Bot Token Scopes (OAuth & Permissions → Bot Token Scopes)

    +
      +
    • app_mentions:read
    • +
    • chat:write
    • +
    • im:history
    • +
    • im:read
    • +
    • im:write
    • +
  6. + +
  7. Subscribe to Events (Event Subscriptions → Subscribe to bot events)

    +
      +
    • app_mention
    • +
    • message.im
    • +
  8. + +
  9. Install to Workspace

    +
      +
    • OAuth & Permissions → Install to Workspace
    • +
    • Copy Bot User OAuth Token as SLACK_BOT_TOKEN (starts with xoxb-)
    • +
  10. +
+
+""" + ) + +chat = Chat(id="chat") + +chat.ui( + messages=[ + {"content": "Hello! How can I help you?", "role": "assistant"}, + ], +) + + +@chat.on_user_submit +async def handle_user_input(user_input: str): + response = generate_response(user_input) + await chat.append_message(response) diff --git a/extensions/slack-bot/config.py b/extensions/slack-bot/config.py new file mode 100644 index 00000000..fd30920a --- /dev/null +++ b/extensions/slack-bot/config.py @@ -0,0 +1,13 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + # Slack tokens + slack_bot_token: str # xoxb-... + slack_app_token: str # xapp-... (for Socket Mode) + + class Config: + env_file = ".env" + + +settings = Settings() diff --git a/extensions/slack-bot/main.py b/extensions/slack-bot/main.py new file mode 100644 index 00000000..f6ebb433 --- /dev/null +++ b/extensions/slack-bot/main.py @@ -0,0 +1,33 @@ +import asyncio +from contextlib import asynccontextmanager +from pathlib import Path + +from starlette.applications import Starlette +from starlette.routing import Mount +from shiny.express import wrap_express_app + +from api import fastapi_app, create_socket_handler + +# Wrap the Shiny Express app for mounting +chat_ui_path = Path(__file__).parent / "chat_ui.py" +shiny_app = wrap_express_app(chat_ui_path) + + +@asynccontextmanager +async def lifespan(app): + """Start Slack Socket Mode handler on startup.""" + handler = create_socket_handler() + asyncio.create_task(handler.start_async()) + print("Slack bot started!") + + yield + + await handler.close_async() + + +routes = [ + Mount("/api", app=fastapi_app), + Mount("/", app=shiny_app), +] + +app = Starlette(routes=routes, lifespan=lifespan) diff --git a/extensions/slack-bot/pyproject.toml b/extensions/slack-bot/pyproject.toml new file mode 100644 index 00000000..f2ba9280 --- /dev/null +++ b/extensions/slack-bot/pyproject.toml @@ -0,0 +1,27 @@ +[project] +name = "slack-bot" +version = "0.1.0" +description = "A Slack bot using Socket Mode" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.109.0", + "uvicorn>=0.27.0", + "slack-bolt>=1.18.0", + "aiohttp>=3.9.0", + "pydantic-settings>=2.1.0", + "shiny>=1.0.0", + "shinychat>=0.1.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["app"] diff --git a/extensions/slack-bot/responder.py b/extensions/slack-bot/responder.py new file mode 100644 index 00000000..13413c12 --- /dev/null +++ b/extensions/slack-bot/responder.py @@ -0,0 +1,22 @@ +"""Message response generation.""" + + +def generate_response(message: str, context: list[dict] | None = None) -> str: + """Generate a response to a user message. + + Args: + message: The user's message text. + context: Optional conversation history (for future use). + + Returns: + The response text. + """ + return _get_last_word_question(message) + + +def _get_last_word_question(text: str) -> str: + """Extract the last word and return it as a question.""" + words = text.strip().rstrip("?!.,").split() + if words: + return f"{words[-1].capitalize()}?" + return "Hmm?"