Skip to content
Draft
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
8 changes: 8 additions & 0 deletions extensions/slack-bot/.env.example
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions extensions/slack-bot/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.env
.venv
__pycache__/
*.pyc
61 changes: 61 additions & 0 deletions extensions/slack-bot/README.md
Original file line number Diff line number Diff line change
@@ -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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For DMs to the bot to work, I had to also ensure App Home -> "Allow users to send Slash commands and messages from the messages tab" was ticked. (It was unchecked by default.)

image

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will revise the readme with all of the relevant scopes needed, Claude's first guess was wrong, but I've figured it out since. I don't believe slash messages is required but there are more bot scopes needed than were originally figured.


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?`
60 changes: 60 additions & 0 deletions extensions/slack-bot/api.py
Original file line number Diff line number Diff line change
@@ -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"}
121 changes: 121 additions & 0 deletions extensions/slack-bot/chat_ui.py
Original file line number Diff line number Diff line change
@@ -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(
"""
<details>
<summary style="cursor: pointer; font-weight: bold; margin-top: 1em;">Posit Connect</summary>
<p style="margin-top: 0.5em;">Set <code>min_processes</code> to 1 in runtime settings so the app stays running and listens for Slack messages.</p>
</details>

<details>
<summary style="cursor: pointer; font-weight: bold; margin-top: 1em;">Slack App Setup</summary>

<ol style="padding-left: 1.2em; margin-top: 0.5em;">
<li><p>Create a new app at <a href="https://api.slack.com/apps" target="_blank">api.slack.com/apps</a> (From scratch)</p></li>

<li><p><strong>Enable Socket Mode</strong></p>
<ul>
<li>Settings → Socket Mode → Enable</li>
<li>Create an App-Level Token with <code>connections:write</code> scope</li>
<li>Save as <code>SLACK_APP_TOKEN</code> (starts with <code>xapp-</code>)</li>
</ul></li>

<li><p><strong>Add Bot Token Scopes</strong> (OAuth & Permissions → Bot Token Scopes)</p>
<ul>
<li><code>app_mentions:read</code></li>
<li><code>chat:write</code></li>
<li><code>im:history</code></li>
<li><code>im:read</code></li>
<li><code>im:write</code></li>
</ul></li>

<li><p><strong>Subscribe to Events</strong> (Event Subscriptions → Subscribe to bot events)</p>
<ul>
<li><code>app_mention</code></li>
<li><code>message.im</code></li>
</ul></li>

<li><p><strong>Install to Workspace</strong></p>
<ul>
<li>OAuth & Permissions → Install to Workspace</li>
<li>Copy Bot User OAuth Token as <code>SLACK_BOT_TOKEN</code> (starts with <code>xoxb-</code>)</li>
</ul></li>
</ol>
</details>
"""
)

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)
13 changes: 13 additions & 0 deletions extensions/slack-bot/config.py
Original file line number Diff line number Diff line change
@@ -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()
33 changes: 33 additions & 0 deletions extensions/slack-bot/main.py
Original file line number Diff line number Diff line change
@@ -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)
27 changes: 27 additions & 0 deletions extensions/slack-bot/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"]
22 changes: 22 additions & 0 deletions extensions/slack-bot/responder.py
Original file line number Diff line number Diff line change
@@ -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?"