-
Notifications
You must be signed in to change notification settings - Fork 90
feat: add Slack and Discord Moss Q&A demo #397
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
hasnainkhatri87
wants to merge
2
commits into
usemoss:main
Choose a base branch
from
hasnainkhatri87:fix/384-slack-discord-qa-demo
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
15 changes: 15 additions & 0 deletions
15
moss-live-labs/community-demos/slack-discord-qa/.env.example
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| .env | ||
| .venv/ | ||
| __pycache__/ | ||
| .pytest_cache/ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | |
1 change: 1 addition & 0 deletions
1
moss-live-labs/community-demos/slack-discord-qa/moss_slack_discord/__init__.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| """Slack and Discord Q&A demo backed by Moss retrieval.""" |
93 changes: 93 additions & 0 deletions
93
moss-live-labs/community-demos/slack-discord-qa/moss_slack_discord/adapters.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
|
|
||
| await client.start(token) | ||
123 changes: 123 additions & 0 deletions
123
moss-live-labs/community-demos/slack-discord-qa/moss_slack_discord/app.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. BLOCKING ```python |
||
| 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() | ||
73 changes: 73 additions & 0 deletions
73
moss-live-labs/community-demos/slack-discord-qa/moss_slack_discord/qa_engine.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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." | ||
| ) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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)