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
15 changes: 15 additions & 0 deletions moss-live-labs/community-demos/slack-discord-qa/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
MOSS_PROJECT_ID=your_moss_project_id
MOSS_PROJECT_KEY=your_moss_project_key
MOSS_INDEX_NAME=workspace-knowledge
MOSS_TOP_K=5

OPENAI_API_KEY=your_openai_api_key
OPENAI_MODEL=gpt-4o-mini

# Slack Socket Mode
SLACK_BOT_TOKEN=xoxb-your-slack-bot-token
SLACK_APP_TOKEN=xapp-your-slack-app-token

# Discord
DISCORD_TOKEN=your_discord_bot-token
DISCORD_PREFIX=!ask
4 changes: 4 additions & 0 deletions moss-live-labs/community-demos/slack-discord-qa/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.env
.venv/
__pycache__/
.pytest_cache/
69 changes: 69 additions & 0 deletions moss-live-labs/community-demos/slack-discord-qa/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Slack / Discord Q&A bot with Moss

This community demo uses a Moss index containing workspace messages and documents, then answers questions in Slack or Discord using the same retrieval-and-answering pipeline.

The adapters are intentionally thin:

- Slack uses Socket Mode and answers `@mentions` in the originating thread.
- Discord answers `!ask ...` messages or direct bot mentions.
- Moss supplies the relevant workspace context, and an OpenAI-compatible chat model turns that context into a concise answer.

## Setup

1. Create a virtual environment and install the demo:

```bash
uv sync --extra dev
```

2. Copy `.env.example` to `.env` and fill in your Moss and OpenAI credentials.

3. Create or load an index named by `MOSS_INDEX_NAME` using the [Moss retrieval workflow](https://docs.moss.dev/docs/integrate/retrieval). Add the messages and documents that the bot should be able to answer from.

4. Configure at least one chat adapter, then start the bot:

```bash
uv run moss-slack-discord
```

## Slack configuration

Create a Slack app with Socket Mode enabled, subscribe to the `app_mention` event, and grant the bot permission to read and write messages. Set both `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN` in `.env`.

Mention the bot in a channel:

```text
@Workspace Bot What is our refund policy?
```

The answer is posted as a reply in that thread.

## Discord configuration

Create a Discord bot, enable the **Message Content Intent**, and invite it to a server with permission to read and send messages. Set `DISCORD_TOKEN` in `.env`.

Ask a question with the configured prefix or by mentioning the bot:

```text
!ask Where is the onboarding guide?
@Workspace Bot How do I request access?
```

## Tests

The tests cover adapter message extraction and the shared answer engine without requiring chat-platform, Moss, or LLM credentials:

```bash
uv run pytest
```

## Environment variables

| Variable | Purpose |
| --- | --- |
| `MOSS_PROJECT_ID` / `MOSS_PROJECT_KEY` | Moss Cloud credentials |
| `MOSS_INDEX_NAME` | Workspace index to load |
| `MOSS_TOP_K` | Number of Moss results passed to the answerer |
| `OPENAI_API_KEY` / `OPENAI_MODEL` | Answer-generation model configuration |
| `SLACK_BOT_TOKEN` / `SLACK_APP_TOKEN` | Optional Slack Socket Mode adapter |
| `DISCORD_TOKEN` / `DISCORD_PREFIX` | Optional Discord adapter and command prefix |
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Slack and Discord Q&A demo backed by Moss retrieval."""
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""Slack and Discord event adapters for the shared Q&A engine."""

from __future__ import annotations

import re

from .qa_engine import AnswerEngine


def extract_slack_question(text: str) -> str:
"""Remove the leading bot mention from an app mention event."""
return re.sub(r"^\s*<@[^>]+>\s*", "", text).strip()


def extract_discord_question(
content: str, bot_user_id: int | None, prefix: str
) -> str | None:
"""Return a question only when a Discord message mentions the bot or uses the prefix."""
question = content.strip()
prefix_lower = prefix.lower()
if question.lower().startswith(prefix_lower):
return question[len(prefix) :].strip()

if bot_user_id is None:
return None

mention_patterns = (f"<@{bot_user_id}>", f"<@!{bot_user_id}>")
for mention in mention_patterns:
if mention in question:
return question.replace(mention, "", 1).strip()
return None


async def run_slack(engine: AnswerEngine, bot_token: str, app_token: str) -> None:
"""Run a Slack Socket Mode adapter that answers app mentions in threads."""
from slack_bolt.adapter.socket_mode.aiohttp import AsyncSocketModeHandler
from slack_bolt.async_app import AsyncApp

app = AsyncApp(token=bot_token)

@app.event("app_mention")
async def handle_app_mention(body, say, logger) -> None:
event = body.get("event", {})
question = extract_slack_question(event.get("text", ""))
thread_ts = event.get("thread_ts") or event.get("ts")
if not question:
await say(
"Please include a question after mentioning me.", thread_ts=thread_ts
)
return

try:
answer = await engine.answer(question)
except Exception:
logger.exception("Failed to answer Slack question")
answer = "I couldn't answer that right now. Please try again."
await say(answer, thread_ts=thread_ts)

handler = AsyncSocketModeHandler(app, app_token)
await handler.start_async()


async def run_discord(engine: AnswerEngine, token: str, prefix: str) -> None:
"""Run a Discord adapter that answers mentions and prefixed questions."""
import discord

intents = discord.Intents.default()
intents.message_content = True
client = discord.Client(intents=intents)

@client.event
async def on_message(message: discord.Message) -> None:
if message.author.bot:
return

bot_user_id = client.user.id if client.user else None
question = extract_discord_question(message.content, bot_user_id, prefix)
if question is None:
return
if not question:
await message.reply(
"Please include a question after mentioning me.", mention_author=False
)
return

async with message.channel.typing():
try:
answer = await engine.answer(question)
except Exception:
answer = "I couldn't answer that right now. Please try again."
await message.reply(answer, mention_author=False)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CONSIDER ```python
await message.reply(answer, mention_author=False)

`mention_author=False` only suppresses the reply ping; it does not suppress mentions contained in the model-controlled `answer`. A user question or indexed document can cause the bot to emit `@everyone`, role, or user mentions if the bot has those permissions. Fix by passing `allowed_mentions=discord.AllowedMentions.none()` for generated replies, and apply the same outbound mention-suppression/sanitization policy to Slack responses.


await client.start(token)
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""Application wiring for the Slack and Discord Moss Q&A demo."""

from __future__ import annotations

import asyncio
import os
from typing import Any

from dotenv import load_dotenv

from .adapters import run_discord, run_slack
from .qa_engine import AnswerEngine, RetrievedDocument


def require_env(name: str) -> str:
value = os.getenv(name)
if not value:
raise RuntimeError(f"Missing required environment variable: {name}")
return value


class MossRetriever:
"""Adapt MossClient.query to the shared Retriever protocol."""

def __init__(self, client: Any, index_name: str, query_options_type: Any) -> None:
self.client = client
self.index_name = index_name
self.query_options_type = query_options_type

async def retrieve(self, question: str, top_k: int) -> list[RetrievedDocument]:
results = await self.client.query(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

BLOCKING ```python
results = await self.client.query(

This queries the entire configured workspace index without carrying the caller, channel, server, or any authorization constraint from the chat event. If the index contains private channels or documents, any user who can reach the bot can ask for content they would not normally be allowed to read. Fix by passing request identity through `AnswerEngine.retrieve(...)` and restricting retrieval to authorized documents, for example with per-channel indexes or metadata/ACL filters on the Moss query.

self.index_name,
question,
self.query_options_type(top_k=top_k),
)
return [
RetrievedDocument(text=document.text, score=document.score)
for document in results.docs
if document.text
]


class OpenAIResponder:
"""Generate a concise answer from the Moss context using OpenAI's async client."""

def __init__(self, client: Any, model: str) -> None:
self.client = client
self.model = model

async def respond(self, question: str, context: str) -> str:
response = await self.client.chat.completions.create(
model=self.model,
temperature=0.2,
messages=[
{
"role": "system",
"content": (
"You answer workspace questions using only the supplied context. "
"If the context does not contain the answer, say that you do not know. "
"Keep the response concise and do not mention the retrieval process."
),
},
{
"role": "user",
"content": f"Context:\n{context}\n\nQuestion: {question}",
},
],
)
return response.choices[0].message.content or ""


async def build_answer_engine() -> AnswerEngine:
"""Load the configured Moss index and create the shared answer engine."""
from moss import MossClient, QueryOptions
from openai import AsyncOpenAI

moss_client = MossClient(
require_env("MOSS_PROJECT_ID"), require_env("MOSS_PROJECT_KEY")
)
index_name = require_env("MOSS_INDEX_NAME")
await moss_client.load_index(index_name)

return AnswerEngine(
retriever=MossRetriever(moss_client, index_name, QueryOptions),
responder=OpenAIResponder(
AsyncOpenAI(api_key=require_env("OPENAI_API_KEY")),
os.getenv("OPENAI_MODEL", "gpt-4o-mini"),
),
top_k=int(os.getenv("MOSS_TOP_K", "5")),
)


async def run() -> None:
load_dotenv()
engine = await build_answer_engine()
tasks = []

slack_bot_token = os.getenv("SLACK_BOT_TOKEN")
slack_app_token = os.getenv("SLACK_APP_TOKEN")
if slack_bot_token or slack_app_token:
if not slack_bot_token or not slack_app_token:
raise RuntimeError(
"SLACK_BOT_TOKEN and SLACK_APP_TOKEN must be set together"
)
tasks.append(run_slack(engine, slack_bot_token, slack_app_token))

discord_token = os.getenv("DISCORD_TOKEN")
if discord_token:
tasks.append(
run_discord(engine, discord_token, os.getenv("DISCORD_PREFIX", "!ask"))
)

if not tasks:
raise RuntimeError("Configure at least one adapter: Slack or Discord")
await asyncio.gather(*tasks)


def main() -> None:
asyncio.run(run())


if __name__ == "__main__":
main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""Shared retrieval and answer-generation logic for both chat adapters."""

from collections.abc import Sequence
from dataclasses import dataclass
from typing import Protocol


NO_RESULTS_MESSAGE = (
"I couldn't find anything relevant in the workspace knowledge base."
)


@dataclass(frozen=True)
class RetrievedDocument:
"""The small part of a Moss result needed by the answer engine."""

text: str
score: float | None = None


class Retriever(Protocol):
async def retrieve(
self, question: str, top_k: int
) -> Sequence[RetrievedDocument]: ...


class Responder(Protocol):
async def respond(self, question: str, context: str) -> str: ...


def build_context(
documents: Sequence[RetrievedDocument], max_chars: int = 12_000
) -> str:
"""Format retrieved documents into a bounded context for the LLM."""
sections: list[str] = []
remaining = max_chars

for index, document in enumerate(documents, start=1):
text = document.text.strip()
if not text or remaining <= 0:
continue

score = f" (score: {document.score:.3f})" if document.score is not None else ""
section = f"[{index}]{score}\n{text}"
section = section[:remaining]
sections.append(section)
remaining -= len(section) + 2

return "\n\n".join(sections)


@dataclass
class AnswerEngine:
"""Answer questions using retrieved Moss documents as the source of truth."""

retriever: Retriever
responder: Responder
top_k: int = 5

async def answer(self, question: str) -> str:
question = question.strip()
if not question:
return "Please include a question after mentioning me."

documents = await self.retriever.retrieve(question, self.top_k)
context = build_context(documents)
if not context:
return NO_RESULTS_MESSAGE

answer = (await self.responder.respond(question, context)).strip()
return (
answer or "I couldn't generate an answer from the workspace knowledge base."
)
Loading
Loading