Slack bot management dashboard (#483)

This commit is contained in:
Chris Weaver 2023-09-26 14:03:27 -07:00 committed by GitHub
parent 0c58c8d6cb
commit d41d844116
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1401 additions and 49 deletions

View File

@ -0,0 +1,38 @@
"""Add SlackBotConfig table
Revision ID: 7da543f5672f
Revises: febe9eaa0644
Create Date: 2023-09-24 16:34:17.526128
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = "7da543f5672f"
down_revision = "febe9eaa0644"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"slack_bot_config",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("persona_id", sa.Integer(), nullable=True),
sa.Column(
"channel_config",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
),
sa.ForeignKeyConstraint(
["persona_id"],
["persona.id"],
),
sa.PrimaryKeyConstraint("id"),
)
def downgrade() -> None:
op.drop_table("slack_bot_config")

View File

@ -0,0 +1,37 @@
"""Add document_set / persona relationship table
Revision ID: febe9eaa0644
Revises: 57b53544726e
Create Date: 2023-09-24 13:06:24.018610
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "febe9eaa0644"
down_revision = "57b53544726e"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"persona__document_set",
sa.Column("persona_id", sa.Integer(), nullable=False),
sa.Column("document_set_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(
["document_set_id"],
["document_set.id"],
),
sa.ForeignKeyConstraint(
["persona_id"],
["persona.id"],
),
sa.PrimaryKeyConstraint("persona_id", "document_set_id"),
)
def downgrade() -> None:
op.drop_table("persona__document_set")

View File

@ -0,0 +1,40 @@
from sqlalchemy.orm import Session
from danswer.db.models import SlackBotConfig
from danswer.db.slack_bot_config import fetch_slack_bot_configs
def get_slack_bot_config_for_channel(
channel_name: str, db_session: Session
) -> SlackBotConfig | None:
slack_bot_configs = fetch_slack_bot_configs(db_session=db_session)
for config in slack_bot_configs:
if channel_name in config.channel_config["channel_names"]:
return config
return None
def validate_channel_names(
channel_names: list[str],
current_slack_bot_config_id: int | None,
db_session: Session,
) -> list[str]:
"""Make sure that these channel_names don't exist in other slack bot configs.
Returns a list of cleaned up channel names (e.g. '#' removed if present)"""
slack_bot_configs = fetch_slack_bot_configs(db_session=db_session)
cleaned_channel_names = [
channel_name.lstrip("#").lower() for channel_name in channel_names
]
for slack_bot_config in slack_bot_configs:
if slack_bot_config.id == current_slack_bot_config_id:
continue
for channel_name in cleaned_channel_names:
if channel_name in slack_bot_config.channel_config["channel_names"]:
raise ValueError(
f"Channel name '{channel_name}' already exists in "
"another slack bot config"
)
return cleaned_channel_names

View File

@ -6,12 +6,16 @@ from sqlalchemy.orm import Session
from danswer.bots.slack.blocks import build_documents_blocks
from danswer.bots.slack.blocks import build_qa_response_blocks
from danswer.bots.slack.config import get_slack_bot_config_for_channel
from danswer.bots.slack.utils import get_channel_name_from_id
from danswer.bots.slack.utils import respond_in_thread
from danswer.configs.app_configs import DANSWER_BOT_ANSWER_GENERATION_TIMEOUT
from danswer.configs.app_configs import DANSWER_BOT_DISABLE_DOCS_ONLY_ANSWER
from danswer.configs.app_configs import DANSWER_BOT_DISPLAY_ERROR_MSGS
from danswer.configs.app_configs import DANSWER_BOT_NUM_RETRIES
from danswer.configs.app_configs import DOCUMENT_INDEX_NAME
from danswer.configs.app_configs import ENABLE_DANSWERBOT_REFLEXION
from danswer.configs.constants import DOCUMENT_SETS
from danswer.db.engine import get_sqlalchemy_engine
from danswer.direct_qa.answer_question import answer_qa_query
from danswer.server.models import QAResponse
@ -29,6 +33,27 @@ def handle_message(
should_respond_with_error_msgs: bool = DANSWER_BOT_DISPLAY_ERROR_MSGS,
disable_docs_only_answer: bool = DANSWER_BOT_DISABLE_DOCS_ONLY_ANSWER,
) -> None:
engine = get_sqlalchemy_engine()
with Session(engine) as db_session:
channel_name = get_channel_name_from_id(client=client, channel_id=channel)
slack_bot_config = get_slack_bot_config_for_channel(
channel_name=channel_name, db_session=db_session
)
document_set_names: list[str] | None = None
validity_check_enabled = ENABLE_DANSWERBOT_REFLEXION
if slack_bot_config and slack_bot_config.persona:
document_set_names = [
document_set.name
for document_set in slack_bot_config.persona.document_sets
]
validity_check_enabled = slack_bot_config.channel_config.get(
"answer_validity_check_enabled", validity_check_enabled
)
logger.info(
"Found slack bot config for channel. Restricting bot to use document "
f"sets: {document_set_names}, validity check enabled: {validity_check_enabled}"
)
@retry(
tries=num_retries,
delay=0.25,
@ -45,6 +70,7 @@ def handle_message(
db_session=db_session,
answer_generation_timeout=answer_generation_timeout,
real_time_flow=False,
enable_reflexion=validity_check_enabled,
)
if not answer.error_msg:
return answer
@ -57,7 +83,9 @@ def handle_message(
query=msg,
collection=DOCUMENT_INDEX_NAME,
use_keyword=False, # always use semantic search when handling Slack messages
filters=None,
filters=[{DOCUMENT_SETS: document_set_names}]
if document_set_names
else None,
offset=None,
)
)

View File

@ -1,5 +1,4 @@
import logging
import os
from collections.abc import MutableMapping
from typing import Any
from typing import cast
@ -12,6 +11,8 @@ from slack_sdk.socket_mode.response import SocketModeResponse
from danswer.bots.slack.handlers.handle_feedback import handle_slack_feedback
from danswer.bots.slack.handlers.handle_message import handle_message
from danswer.bots.slack.utils import decompose_block_id
from danswer.dynamic_configs.interface import ConfigNotFoundError
from danswer.server.slack_bot_management import get_tokens
from danswer.utils.logger import setup_logger
logger = setup_logger()
@ -37,16 +38,14 @@ class _ChannelIdAdapter(logging.LoggerAdapter):
def _get_socket_client() -> SocketModeClient:
# For more info on how to set this up, checkout the docs:
# https://docs.danswer.dev/slack_bot_setup
app_token = os.environ.get("DANSWER_BOT_SLACK_APP_TOKEN")
if not app_token:
raise RuntimeError("DANSWER_BOT_SLACK_APP_TOKEN is not set")
bot_token = os.environ.get("DANSWER_BOT_SLACK_BOT_TOKEN")
if not bot_token:
raise RuntimeError("DANSWER_BOT_SLACK_BOT_TOKEN is not set")
try:
slack_bot_tokens = get_tokens()
except ConfigNotFoundError:
raise RuntimeError("Slack tokens not found")
return SocketModeClient(
# This app-level token will be used only for establishing a connection
app_token=app_token,
web_client=WebClient(token=bot_token),
app_token=slack_bot_tokens.app_token,
web_client=WebClient(token=slack_bot_tokens.bot_token),
)

View File

@ -0,0 +1,35 @@
import os
from typing import cast
from danswer.dynamic_configs import get_dynamic_config_store
from danswer.dynamic_configs.interface import ConfigNotFoundError
from danswer.server.models import SlackBotTokens
_SLACK_BOT_TOKENS_CONFIG_KEY = "slack_bot_tokens_config_key"
def fetch_tokens() -> SlackBotTokens:
# first check env variables
app_token = os.environ.get("DANSWER_BOT_SLACK_APP_TOKEN")
bot_token = os.environ.get("DANSWER_BOT_SLACK_BOT_TOKEN")
if app_token and bot_token:
return SlackBotTokens(app_token=app_token, bot_token=bot_token)
dynamic_config_store = get_dynamic_config_store()
try:
return SlackBotTokens(
**cast(dict, dynamic_config_store.load(key=_SLACK_BOT_TOKENS_CONFIG_KEY))
)
except ConfigNotFoundError as e:
raise ValueError from e
def save_tokens(
tokens: SlackBotTokens,
) -> None:
dynamic_config_store = get_dynamic_config_store()
dynamic_config_store.store(
key=_SLACK_BOT_TOKENS_CONFIG_KEY,
val=dict(tokens),
)

View File

@ -2,6 +2,7 @@ import logging
import random
import re
import string
from typing import Any
from typing import cast
from retry import retry
@ -9,6 +10,7 @@ from slack_sdk import WebClient
from slack_sdk.models.blocks import Block
from slack_sdk.models.metadata import Metadata
from danswer.bots.slack.tokens import fetch_tokens
from danswer.configs.app_configs import DANSWER_BOT_NUM_RETRIES
from danswer.configs.constants import ID_SEPARATOR
from danswer.connectors.slack.utils import make_slack_api_rate_limited
@ -19,6 +21,11 @@ from danswer.utils.text_processing import replace_whitespaces_w_space
logger = setup_logger()
def get_web_client() -> WebClient:
slack_tokens = fetch_tokens()
return WebClient(token=slack_tokens.bot_token)
@retry(
tries=DANSWER_BOT_NUM_RETRIES,
delay=0.25,
@ -119,3 +126,13 @@ def remove_slack_text_interactions(slack_str: str) -> str:
slack_str = UserIdReplacer.replace_links(slack_str)
slack_str = UserIdReplacer.add_zero_width_whitespace_after_tag(slack_str)
return slack_str
def get_channel_from_id(client: WebClient, channel_id: str) -> dict[str, Any]:
response = client.conversations_info(channel=channel_id)
response.validate()
return response["channel"]
def get_channel_name_from_id(client: WebClient, channel_id: str) -> str:
return get_channel_from_id(client, channel_id)["name"]

View File

@ -270,6 +270,7 @@ def create_persona(
hint_text: str | None,
default_persona: bool,
db_session: Session,
commit: bool = True,
) -> Persona:
persona = db_session.query(Persona).filter_by(id=persona_id).first()
@ -292,6 +293,10 @@ def create_persona(
)
db_session.add(persona)
db_session.commit()
if commit:
db_session.commit()
else:
# flush the session so that the persona has an ID
db_session.flush()
return persona

View File

@ -2,6 +2,8 @@ import datetime
from enum import Enum as PyEnum
from typing import Any
from typing import List
from typing import NotRequired
from typing import TypedDict
from uuid import UUID
from fastapi_users.db import SQLAlchemyBaseOAuthAccountTableUUID
@ -79,6 +81,43 @@ class AccessToken(SQLAlchemyBaseAccessTokenTableUUID, Base):
pass
"""
Association tables
NOTE: must be at the top since they are referenced by other tables
"""
class Persona__DocumentSet(Base):
__tablename__ = "persona__document_set"
persona_id: Mapped[int] = mapped_column(ForeignKey("persona.id"), primary_key=True)
document_set_id: Mapped[int] = mapped_column(
ForeignKey("document_set.id"), primary_key=True
)
class DocumentSet__ConnectorCredentialPair(Base):
__tablename__ = "document_set__connector_credential_pair"
document_set_id: Mapped[int] = mapped_column(
ForeignKey("document_set.id"), primary_key=True
)
connector_credential_pair_id: Mapped[int] = mapped_column(
ForeignKey("connector_credential_pair.id"), primary_key=True
)
# if `True`, then is part of the current state of the document set
# if `False`, then is a part of the prior state of the document set
# rows with `is_current=False` should be deleted when the document
# set is updated and should not exist for a given document set if
# `DocumentSet.is_up_to_date == True`
is_current: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=True,
primary_key=True,
)
class ConnectorCredentialPair(Base):
"""Connectors and Credentials can have a many-to-many relationship
I.e. A Confluence Connector may have multiple admin users who can run it with their own credentials
@ -118,6 +157,11 @@ class ConnectorCredentialPair(Base):
credential: Mapped["Credential"] = relationship(
"Credential", back_populates="connectors"
)
document_sets: Mapped[List["DocumentSet"]] = relationship(
"DocumentSet",
secondary=DocumentSet__ConnectorCredentialPair.__table__,
back_populates="connector_credential_pairs",
)
class Connector(Base):
@ -349,36 +393,15 @@ class DocumentSet(Base):
# whether or not changes to the document set have been propogated
is_up_to_date: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
connector_credential_pair_relationships: Mapped[
list["DocumentSet__ConnectorCredentialPair"]
] = relationship(
"DocumentSet__ConnectorCredentialPair", back_populates="document_set"
connector_credential_pairs: Mapped[list[ConnectorCredentialPair]] = relationship(
"ConnectorCredentialPair",
secondary=DocumentSet__ConnectorCredentialPair.__table__,
back_populates="document_sets",
)
class DocumentSet__ConnectorCredentialPair(Base):
__tablename__ = "document_set__connector_credential_pair"
document_set_id: Mapped[int] = mapped_column(
ForeignKey("document_set.id"), primary_key=True
)
connector_credential_pair_id: Mapped[int] = mapped_column(
ForeignKey("connector_credential_pair.id"), primary_key=True
)
# if `True`, then is part of the current state of the document set
# if `False`, then is a part of the prior state of the document set
# rows with `is_current=False` should be deleted when the document
# set is updated and should not exist for a given document set if
# `DocumentSet.is_up_to_date == True`
is_current: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=True,
primary_key=True,
)
document_set: Mapped[DocumentSet] = relationship(
"DocumentSet", back_populates="connector_credential_pair_relationships"
personas: Mapped[list["Persona"]] = relationship(
"Persona",
secondary=Persona__DocumentSet.__table__,
back_populates="document_sets",
)
@ -421,6 +444,12 @@ class Persona(Base):
# If it's updated and no longer latest (should no longer be shown), it is also considered deleted
deleted: Mapped[bool] = mapped_column(Boolean, default=False)
document_sets: Mapped[list[DocumentSet]] = relationship(
"DocumentSet",
secondary=Persona__DocumentSet.__table__,
back_populates="personas",
)
class ChatMessage(Base):
__tablename__ = "chat_message"
@ -446,3 +475,27 @@ class ChatMessage(Base):
chat_session: Mapped[ChatSession] = relationship("ChatSession")
persona: Mapped[Persona | None] = relationship("Persona")
class ChannelConfig(TypedDict):
"""NOTE: is a `TypedDict` so it can be used a type hint for a JSONB column
in Postgres"""
channel_names: list[str]
answer_validity_check_enabled: NotRequired[bool] # not specified => False
team_members: NotRequired[list[str]]
class SlackBotConfig(Base):
__tablename__ = "slack_bot_config"
id: Mapped[int] = mapped_column(primary_key=True)
persona_id: Mapped[int | None] = mapped_column(
ForeignKey("persona.id"), nullable=True
)
# JSON for flexibility. Contains things like: channel name, team members, etc.
channel_config: Mapped[ChannelConfig] = mapped_column(
postgresql.JSONB(), nullable=False
)
persona: Mapped[Persona | None] = relationship("Persona")

View File

@ -0,0 +1,149 @@
from collections.abc import Sequence
from sqlalchemy import select
from sqlalchemy.orm import Session
from danswer.db.chat import create_persona
from danswer.db.models import ChannelConfig
from danswer.db.models import Persona
from danswer.db.models import Persona__DocumentSet
from danswer.db.models import SlackBotConfig
def _build_persona_name(channel_names: list[str]) -> str:
return f"__slack_bot_persona__{'-'.join(channel_names)}"
def _cleanup_relationships(db_session: Session, persona_id: int) -> None:
"""NOTE: does not commit changes"""
# delete existing persona-document_set relationships
existing_relationships = db_session.scalars(
select(Persona__DocumentSet).where(
Persona__DocumentSet.persona_id == persona_id
)
)
for rel in existing_relationships:
db_session.delete(rel)
def _create_slack_bot_persona(
db_session: Session,
channel_names: list[str],
document_sets: list[int],
existing_persona_id: int | None = None,
) -> Persona:
"""NOTE: does not commit changes"""
# create/update persona associated with the slack bot
persona_name = _build_persona_name(channel_names)
persona = create_persona(
persona_id=existing_persona_id,
name=persona_name,
retrieval_enabled=True,
system_text=None,
tools_text=None,
hint_text=None,
default_persona=False,
db_session=db_session,
commit=False,
)
if existing_persona_id:
_cleanup_relationships(db_session=db_session, persona_id=existing_persona_id)
# create relationship between the new persona and the desired document_sets
for document_set_id in document_sets:
db_session.add(
Persona__DocumentSet(persona_id=persona.id, document_set_id=document_set_id)
)
return persona
def insert_slack_bot_config(
document_sets: list[int],
channel_config: ChannelConfig,
db_session: Session,
) -> SlackBotConfig:
persona = None
if document_sets:
persona = _create_slack_bot_persona(
db_session=db_session,
channel_names=channel_config["channel_names"],
document_sets=document_sets,
)
slack_bot_config = SlackBotConfig(
persona_id=persona.id if persona else None,
channel_config=channel_config,
)
db_session.add(slack_bot_config)
db_session.commit()
return slack_bot_config
def update_slack_bot_config(
slack_bot_config_id: int,
document_sets: list[int],
channel_config: ChannelConfig,
db_session: Session,
) -> SlackBotConfig:
slack_bot_config = db_session.scalar(
select(SlackBotConfig).where(SlackBotConfig.id == slack_bot_config_id)
)
if slack_bot_config is None:
raise ValueError(
f"Unable to find slack bot config with ID {slack_bot_config_id}"
)
existing_persona_id = slack_bot_config.persona_id
persona = None
if document_sets:
persona = _create_slack_bot_persona(
db_session=db_session,
channel_names=channel_config["channel_names"],
document_sets=document_sets,
existing_persona_id=slack_bot_config.persona_id,
)
else:
# if no document sets and an existing persona exists, then
# remove persona + persona -> document set relationships
if existing_persona_id:
_cleanup_relationships(
db_session=db_session, persona_id=existing_persona_id
)
existing_persona = db_session.scalar(
select(Persona).where(Persona.id == existing_persona_id)
)
db_session.delete(existing_persona)
slack_bot_config.persona_id = persona.id if persona else None
slack_bot_config.channel_config = channel_config
db_session.commit()
return slack_bot_config
def remove_slack_bot_config(
slack_bot_config_id: int,
db_session: Session,
) -> None:
slack_bot_config = db_session.scalar(
select(SlackBotConfig).where(SlackBotConfig.id == slack_bot_config_id)
)
if slack_bot_config is None:
raise ValueError(
f"Unable to find slack bot config with ID {slack_bot_config_id}"
)
existing_persona_id = slack_bot_config.persona_id
if existing_persona_id:
_cleanup_relationships(db_session=db_session, persona_id=existing_persona_id)
db_session.delete(slack_bot_config)
db_session.commit()
def fetch_slack_bot_configs(db_session: Session) -> Sequence[SlackBotConfig]:
return db_session.scalars(select(SlackBotConfig)).all()

View File

@ -42,6 +42,7 @@ from danswer.server.event_loading import router as event_processing_router
from danswer.server.health import router as health_router
from danswer.server.manage import router as admin_router
from danswer.server.search_backend import router as backend_router
from danswer.server.slack_bot_management import router as slack_bot_management_router
from danswer.server.users import router as user_router
from danswer.utils.logger import setup_logger
@ -79,6 +80,7 @@ def get_application() -> FastAPI:
application.include_router(user_router)
application.include_router(credential_router)
application.include_router(document_set_router)
application.include_router(slack_bot_management_router)
application.include_router(health_router)
application.include_router(

View File

@ -16,9 +16,11 @@ from danswer.configs.constants import QAFeedbackType
from danswer.configs.constants import SearchFeedbackType
from danswer.connectors.models import InputType
from danswer.datastores.interfaces import IndexFilter
from danswer.db.models import ChannelConfig
from danswer.db.models import Connector
from danswer.db.models import Credential
from danswer.db.models import DeletionStatus
from danswer.db.models import DocumentSet as DocumentSetDBModel
from danswer.db.models import IndexAttempt
from danswer.db.models import IndexingStatus
from danswer.direct_qa.interfaces import DanswerQuote
@ -386,3 +388,50 @@ class DocumentSet(BaseModel):
description: str
cc_pair_descriptors: list[ConnectorCredentialPairDescriptor]
is_up_to_date: bool
@classmethod
def from_model(cls, document_set_model: DocumentSetDBModel) -> "DocumentSet":
return cls(
id=document_set_model.id,
name=document_set_model.name,
description=document_set_model.description,
cc_pair_descriptors=[
ConnectorCredentialPairDescriptor(
id=cc_pair.id,
name=cc_pair.name,
connector=ConnectorSnapshot.from_connector_db_model(
cc_pair.connector
),
credential=CredentialSnapshot.from_credential_db_model(
cc_pair.credential
),
)
for cc_pair in document_set_model.connector_credential_pairs
],
is_up_to_date=document_set_model.is_up_to_date,
)
class SlackBotTokens(BaseModel):
bot_token: str
app_token: str
class SlackBotConfigCreationRequest(BaseModel):
# currently, a persona is created for each slack bot config
# in the future, `document_sets` will probably be replaced
# by an optional `PersonaSnapshot` object. Keeping it like this
# for now for simplicity / speed of development
document_sets: list[int]
channel_names: list[str]
answer_validity_check_enabled: bool
class SlackBotConfig(BaseModel):
id: int
# currently, a persona is created for each slack bot config
# in the future, `document_sets` will probably be replaced
# by an optional `PersonaSnapshot` object. Keeping it like this
# for now for simplicity / speed of development
document_sets: list[DocumentSet]
channel_config: ChannelConfig

View File

@ -0,0 +1,159 @@
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from sqlalchemy.orm import Session
from danswer.auth.users import current_admin_user
from danswer.bots.slack.config import validate_channel_names
from danswer.bots.slack.tokens import fetch_tokens
from danswer.bots.slack.tokens import save_tokens
from danswer.db.engine import get_session
from danswer.db.models import ChannelConfig
from danswer.db.models import User
from danswer.db.slack_bot_config import fetch_slack_bot_configs
from danswer.db.slack_bot_config import insert_slack_bot_config
from danswer.db.slack_bot_config import remove_slack_bot_config
from danswer.db.slack_bot_config import update_slack_bot_config
from danswer.server.models import DocumentSet
from danswer.server.models import SlackBotConfig
from danswer.server.models import SlackBotConfigCreationRequest
from danswer.server.models import SlackBotTokens
router = APIRouter(prefix="/manage")
@router.post("/admin/slack-bot/config")
def create_slack_bot_config(
slack_bot_config_creation_request: SlackBotConfigCreationRequest,
db_session: Session = Depends(get_session),
_: User | None = Depends(current_admin_user),
) -> SlackBotConfig:
if not slack_bot_config_creation_request.channel_names:
raise HTTPException(
status_code=400,
detail="Must provide at least one channel name",
)
try:
cleaned_channel_names = validate_channel_names(
channel_names=slack_bot_config_creation_request.channel_names,
current_slack_bot_config_id=None,
db_session=db_session,
)
except ValueError as e:
raise HTTPException(
status_code=400,
detail=str(e),
)
channel_config: ChannelConfig = {
"channel_names": cleaned_channel_names,
"answer_validity_check_enabled": slack_bot_config_creation_request.answer_validity_check_enabled,
}
slack_bot_config_model = insert_slack_bot_config(
document_sets=slack_bot_config_creation_request.document_sets,
channel_config=channel_config,
db_session=db_session,
)
return SlackBotConfig(
id=slack_bot_config_model.id,
document_sets=[
DocumentSet.from_model(document_set)
for document_set in slack_bot_config_model.persona.document_sets
]
if slack_bot_config_model.persona
else [],
channel_config=slack_bot_config_model.channel_config,
)
@router.patch("/admin/slack-bot/config/{slack_bot_config_id}")
def patch_slack_bot_config(
slack_bot_config_id: int,
slack_bot_config_creation_request: SlackBotConfigCreationRequest,
db_session: Session = Depends(get_session),
_: User | None = Depends(current_admin_user),
) -> SlackBotConfig:
if not slack_bot_config_creation_request.channel_names:
raise HTTPException(
status_code=400,
detail="Must provide at least one channel name",
)
try:
cleaned_channel_names = validate_channel_names(
channel_names=slack_bot_config_creation_request.channel_names,
current_slack_bot_config_id=slack_bot_config_id,
db_session=db_session,
)
except ValueError as e:
raise HTTPException(
status_code=400,
detail=str(e),
)
slack_bot_config_model = update_slack_bot_config(
slack_bot_config_id=slack_bot_config_id,
document_sets=slack_bot_config_creation_request.document_sets,
channel_config={
"channel_names": cleaned_channel_names,
"answer_validity_check_enabled": slack_bot_config_creation_request.answer_validity_check_enabled,
},
db_session=db_session,
)
return SlackBotConfig(
id=slack_bot_config_model.id,
document_sets=[
DocumentSet.from_model(document_set)
for document_set in slack_bot_config_model.persona.document_sets
]
if slack_bot_config_model.persona
else [],
channel_config=slack_bot_config_model.channel_config,
)
@router.delete("/admin/slack-bot/config/{slack_bot_config_id}")
def delete_slack_bot_config(
slack_bot_config_id: int,
db_session: Session = Depends(get_session),
_: User | None = Depends(current_admin_user),
) -> None:
remove_slack_bot_config(
slack_bot_config_id=slack_bot_config_id, db_session=db_session
)
@router.get("/admin/slack-bot/config")
def list_slack_bot_configs(
db_session: Session = Depends(get_session),
_: User | None = Depends(current_admin_user),
) -> list[SlackBotConfig]:
slack_bot_config_models = fetch_slack_bot_configs(db_session=db_session)
return [
SlackBotConfig(
id=slack_bot_config_model.id,
document_sets=[
DocumentSet.from_model(document_set)
for document_set in slack_bot_config_model.persona.document_sets
]
if slack_bot_config_model.persona
else [],
channel_config=slack_bot_config_model.channel_config,
)
for slack_bot_config_model in slack_bot_config_models
]
@router.put("/admin/slack-bot/tokens")
def put_tokens(tokens: SlackBotTokens) -> None:
save_tokens(tokens=tokens)
@router.get("/admin/slack-bot/tokens")
def get_tokens() -> SlackBotTokens:
try:
return fetch_tokens()
except ValueError:
raise HTTPException(status_code=404, detail="No tokens found")

View File

@ -0,0 +1,188 @@
import { ArrayHelpers, FieldArray, Form, Formik } from "formik";
import * as Yup from "yup";
import { PopupSpec } from "@/components/admin/connectors/Popup";
import { DocumentSet, SlackBotConfig } from "@/lib/types";
import {
BooleanFormField,
TextArrayField,
} from "@/components/admin/connectors/Field";
import { createSlackBotConfig, updateSlackBotConfig } from "./lib";
import { channel } from "diagnostics_channel";
interface SetCreationPopupProps {
onClose: () => void;
setPopup: (popupSpec: PopupSpec | null) => void;
documentSets: DocumentSet<any, any>[];
existingSlackBotConfig?: SlackBotConfig;
}
export const SlackBotCreationForm = ({
onClose,
setPopup,
documentSets,
existingSlackBotConfig,
}: SetCreationPopupProps) => {
const isUpdate = existingSlackBotConfig !== undefined;
return (
<div>
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
onClick={onClose}
>
<div
className="bg-gray-800 p-6 rounded border border-gray-700 shadow-lg relative w-1/2 text-sm"
onClick={(event) => event.stopPropagation()}
>
<Formik
initialValues={{
channel_names: existingSlackBotConfig
? existingSlackBotConfig.channel_config.channel_names
: ([] as string[]),
answer_validity_check_enabled:
existingSlackBotConfig?.channel_config
?.answer_validity_check_enabled || false,
document_sets: existingSlackBotConfig
? existingSlackBotConfig.document_sets.map(
(documentSet) => documentSet.id
)
: ([] as number[]),
}}
validationSchema={Yup.object().shape({
channel_names: Yup.array().of(Yup.string()),
answer_validity_check_enabled: Yup.boolean().required(),
document_sets: Yup.array().of(Yup.number()),
})}
onSubmit={async (values, formikHelpers) => {
formikHelpers.setSubmitting(true);
// remove empty channel names
const cleanedValues = {
...values,
channel_names: values.channel_names.filter(
(channelName) => channelName !== ""
),
};
let response;
if (isUpdate) {
response = await updateSlackBotConfig(
existingSlackBotConfig.id,
cleanedValues
);
} else {
response = await createSlackBotConfig(cleanedValues);
}
formikHelpers.setSubmitting(false);
if (response.ok) {
setPopup({
message: isUpdate
? "Successfully updated DanswerBot config!"
: "Successfully created DanswerBot config!",
type: "success",
});
onClose();
} else {
const errorMsg = (await response.json()).detail;
setPopup({
message: isUpdate
? `Error updating DanswerBot config - ${errorMsg}`
: `Error creating DanswerBot config - ${errorMsg}`,
type: "error",
});
}
}}
>
{({ isSubmitting, values }) => (
<Form>
<h2 className="text-lg font-bold mb-3">
{isUpdate
? "Update a DanswerBot Config"
: "Create a new DanswerBot Config"}
</h2>
<TextArrayField
name="channel_names"
label="Channel Names:"
values={values}
subtext="The names of the Slack channels you want DanswerBot to assist in. For example, '#ask-danswer'."
/>
<div className="border-t border-gray-700 py-2" />
<BooleanFormField
name="answer_validity_check_enabled"
label="Hide Non-Answers"
subtext="If set, will only answer questions that the model determines it can answer"
/>
<div className="border-t border-gray-700 py-2" />
<FieldArray
name="document_sets"
render={(arrayHelpers: ArrayHelpers) => (
<div>
<div>
Document Sets:
<br />
<div className="text-xs">
The document sets that DanswerBot should search
through. If left blank, DanswerBot will search through
all documents.
</div>
</div>
<div className="mb-3 mt-2 flex gap-2 flex-wrap">
{documentSets.map((documentSet) => {
const ind = values.document_sets.indexOf(
documentSet.id
);
let isSelected = ind !== -1;
return (
<div
key={documentSet.id}
className={
`
px-3
py-1
rounded-lg
border
border-gray-700
w-fit
flex
cursor-pointer ` +
(isSelected
? " bg-gray-600"
: " bg-gray-900 hover:bg-gray-700")
}
onClick={() => {
if (isSelected) {
arrayHelpers.remove(ind);
} else {
arrayHelpers.push(documentSet.id);
}
}}
>
<div className="my-auto">{documentSet.name}</div>
</div>
);
})}
</div>
</div>
)}
/>
<div className="flex">
<button
type="submit"
disabled={isSubmitting}
className={
"bg-slate-500 hover:bg-slate-700 text-white " +
"font-bold py-2 px-4 rounded focus:outline-none " +
"focus:shadow-outline w-full max-w-sm mx-auto"
}
>
{isUpdate ? "Update!" : "Create!"}
</button>
</div>
</Form>
)}
</Formik>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,94 @@
import { Form, Formik } from "formik";
import * as Yup from "yup";
import { PopupSpec } from "@/components/admin/connectors/Popup";
import { SlackBotTokens } from "@/lib/types";
import {
TextArrayField,
TextFormField,
} from "@/components/admin/connectors/Field";
import {
createSlackBotConfig,
setSlackBotTokens,
updateSlackBotConfig,
} from "./lib";
interface SlackBotTokensFormProps {
onClose: () => void;
setPopup: (popupSpec: PopupSpec | null) => void;
existingTokens?: SlackBotTokens;
}
export const SlackBotTokensForm = ({
onClose,
setPopup,
existingTokens,
}: SlackBotTokensFormProps) => {
return (
<div>
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
onClick={onClose}
>
<div
className="bg-gray-800 p-6 rounded border border-gray-700 shadow-lg relative w-1/2 text-sm"
onClick={(event) => event.stopPropagation()}
>
<Formik
initialValues={existingTokens || { app_token: "", bot_token: "" }}
validationSchema={Yup.object().shape({
channel_names: Yup.array().of(Yup.string().required()),
document_sets: Yup.array().of(Yup.number()),
})}
onSubmit={async (values, formikHelpers) => {
formikHelpers.setSubmitting(true);
const response = await setSlackBotTokens(values);
formikHelpers.setSubmitting(false);
if (response.ok) {
setPopup({
message: "Successfully set Slack tokens!",
type: "success",
});
onClose();
} else {
const errorMsg = await response.text();
setPopup({
message: `Error setting Slack tokens - ${errorMsg}`,
type: "error",
});
}
}}
>
{({ isSubmitting }) => (
<Form>
<h2 className="text-lg font-bold mb-3">Set Slack Bot Tokens</h2>
<TextFormField
name="bot_token"
label="Slack Bot Token"
type="password"
/>
<TextFormField
name="app_token"
label="Slack App Token"
type="password"
/>
<div className="flex">
<button
type="submit"
disabled={isSubmitting}
className={
"bg-slate-500 hover:bg-slate-700 text-white " +
"font-bold py-2 px-4 rounded focus:outline-none " +
"focus:shadow-outline w-full max-w-sm mx-auto"
}
>
Set Tokens
</button>
</div>
</Form>
)}
</Formik>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,23 @@
import { errorHandlingFetcher } from "@/lib/fetcher";
import { SlackBotConfig, SlackBotTokens } from "@/lib/types";
import useSWR, { mutate } from "swr";
export const useSlackBotConfigs = () => {
const url = "/api/manage/admin/slack-bot/config";
const swrResponse = useSWR<SlackBotConfig[]>(url, errorHandlingFetcher);
return {
...swrResponse,
refreshSlackBotConfigs: () => mutate(url),
};
};
export const useSlackBotTokens = () => {
const url = "/api/manage/admin/slack-bot/tokens";
const swrResponse = useSWR<SlackBotTokens>(url, errorHandlingFetcher);
return {
...swrResponse,
refreshSlackBotTokens: () => mutate(url),
};
};

View File

@ -0,0 +1,51 @@
import { ChannelConfig, SlackBotTokens } from "@/lib/types";
interface SlackBotConfigCreationRequest {
document_sets: number[];
channel_names: string[];
answer_validity_check_enabled: boolean;
}
export const createSlackBotConfig = async (
creationRequest: SlackBotConfigCreationRequest
) => {
return fetch("/api/manage/admin/slack-bot/config", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(creationRequest),
});
};
export const updateSlackBotConfig = async (
id: number,
creationRequest: SlackBotConfigCreationRequest
) => {
return fetch(`/api/manage/admin/slack-bot/config/${id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(creationRequest),
});
};
export const deleteSlackBotConfig = async (id: number) => {
return fetch(`/api/manage/admin/slack-bot/config/${id}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
});
};
export const setSlackBotTokens = async (slackBotTokens: SlackBotTokens) => {
return fetch(`/api/manage/admin/slack-bot/tokens`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(slackBotTokens),
});
};

View File

@ -0,0 +1,300 @@
"use client";
import { Button } from "@/components/Button";
import { ThreeDotsLoader } from "@/components/Loading";
import { PageSelector } from "@/components/PageSelector";
import { BasicTable } from "@/components/admin/connectors/BasicTable";
import {
BookmarkIcon,
CPUIcon,
EditIcon,
TrashIcon,
} from "@/components/icons/icons";
import { DocumentSet, SlackBotConfig } from "@/lib/types";
import { useState } from "react";
import { useSlackBotConfigs, useSlackBotTokens } from "./hooks";
import { PopupSpec, usePopup } from "@/components/admin/connectors/Popup";
import { SlackBotCreationForm } from "./SlackBotConfigCreationForm";
import { deleteSlackBotConfig } from "./lib";
import { SlackBotTokensForm } from "./SlackBotTokensForm";
import { useDocumentSets } from "../documents/sets/hooks";
const numToDisplay = 50;
const EditRow = ({
existingSlackBotConfig,
setPopup,
documentSets,
refreshSlackBotConfigs,
}: {
existingSlackBotConfig: SlackBotConfig;
setPopup: (popupSpec: PopupSpec | null) => void;
documentSets: DocumentSet<any, any>[];
refreshSlackBotConfigs: () => void;
}) => {
const [isEditPopupOpen, setEditPopupOpen] = useState(false);
return (
<>
{isEditPopupOpen && (
<SlackBotCreationForm
onClose={() => {
setEditPopupOpen(false);
refreshSlackBotConfigs();
}}
setPopup={setPopup}
documentSets={documentSets}
existingSlackBotConfig={existingSlackBotConfig}
/>
)}
<div
className="cursor-pointer my-auto"
onClick={() => setEditPopupOpen(true)}
>
<EditIcon />
</div>
</>
);
};
interface DocumentFeedbackTableProps {
slackBotConfigs: SlackBotConfig[];
documentSets: DocumentSet<any, any>[];
refresh: () => void;
setPopup: (popupSpec: PopupSpec | null) => void;
}
const SlackBotConfigsTable = ({
slackBotConfigs,
documentSets,
refresh,
setPopup,
}: DocumentFeedbackTableProps) => {
const [page, setPage] = useState(1);
// sort by name for consistent ordering
slackBotConfigs.sort((a, b) => {
if (a.id < b.id) {
return -1;
} else if (a.id > b.id) {
return 1;
} else {
return 0;
}
});
return (
<div>
<BasicTable
columns={[
{
header: "Channels",
key: "channels",
},
{
header: "Document Sets",
key: "document_sets",
},
{
header: "Hide Non-Answers",
key: "answer_validity_check_enabled",
},
{
header: "Delete",
key: "delete",
width: "50px",
},
]}
data={slackBotConfigs
.slice((page - 1) * numToDisplay, page * numToDisplay)
.map((slackBotConfig) => {
return {
channels: (
<div className="flex gap-x-2">
<EditRow
existingSlackBotConfig={slackBotConfig}
setPopup={setPopup}
refreshSlackBotConfigs={refresh}
documentSets={documentSets}
/>
<div className="my-auto">
{slackBotConfig.channel_config.channel_names
.map((channel_name) => `#${channel_name}`)
.join(", ")}
</div>
</div>
),
document_sets: (
<div>
{slackBotConfig.document_sets
.map((documentSet) => documentSet.name)
.join(", ")}
</div>
),
answer_validity_check_enabled: slackBotConfig.channel_config
.answer_validity_check_enabled ? (
<div className="text-gray-300">Yes</div>
) : (
<div className="text-gray-300">No</div>
),
delete: (
<div
className="cursor-pointer"
onClick={async () => {
const response = await deleteSlackBotConfig(
slackBotConfig.id
);
if (response.ok) {
setPopup({
message: `Slack bot config "${slackBotConfig.id}" deleted`,
type: "success",
});
} else {
const errorMsg = await response.text();
setPopup({
message: `Failed to delete Slack bot config - ${errorMsg}`,
type: "error",
});
}
refresh();
}}
>
<TrashIcon />
</div>
),
};
})}
/>
<div className="mt-3 flex">
<div className="mx-auto">
<PageSelector
totalPages={Math.ceil(slackBotConfigs.length / numToDisplay)}
currentPage={page}
onPageChange={(newPage) => setPage(newPage)}
/>
</div>
</div>
</div>
);
};
const Main = () => {
const [slackBotConfigModalIsOpen, setSlackBotConfigModalIsOpen] =
useState(false);
const [slackBotTokensModalIsOpen, setSlackBotTokensModalIsOpen] =
useState(false);
const { popup, setPopup } = usePopup();
const {
data: slackBotConfigs,
isLoading: isSlackBotConfigsLoading,
error: slackBotConfigsError,
refreshSlackBotConfigs,
} = useSlackBotConfigs();
const {
data: documentSets,
isLoading: isDocumentSetsLoading,
error: documentSetsError,
} = useDocumentSets();
const { data: slackBotTokens, refreshSlackBotTokens } = useSlackBotTokens();
if (isSlackBotConfigsLoading || isDocumentSetsLoading) {
return <ThreeDotsLoader />;
}
if (slackBotConfigsError || !slackBotConfigs) {
return <div>Error: {slackBotConfigsError}</div>;
}
if (documentSetsError || !documentSets) {
return <div>Error: {documentSetsError}</div>;
}
return (
<div className="mb-8">
{popup}
<h2 className="text-lg font-bold mb-2">Step 1: Configure Slack Tokens</h2>
{!slackBotTokens ? (
<SlackBotTokensForm
onClose={() => refreshSlackBotTokens()}
setPopup={setPopup}
/>
) : (
<>
<div className="text-sm italic">Tokens saved!</div>
<Button
onClick={() => setSlackBotTokensModalIsOpen(true)}
className="mt-2"
>
Edit Tokens
</Button>
{slackBotTokensModalIsOpen && (
<SlackBotTokensForm
onClose={() => {
refreshSlackBotTokens();
setSlackBotTokensModalIsOpen(false);
}}
setPopup={setPopup}
existingTokens={slackBotTokens}
/>
)}
</>
)}
{slackBotTokens && (
<>
<h2 className="text-lg font-bold mb-2 mt-4">
Step 2: Setup DanswerBot
</h2>
<div className="text-sm mb-3">
Configure Danswer to automatically answer questions in Slack
channels.
</div>
<div className="mb-2"></div>
<div className="flex mb-3">
<Button
className="ml-2 my-auto"
onClick={() => setSlackBotConfigModalIsOpen(true)}
>
New Slack Bot
</Button>
</div>
<SlackBotConfigsTable
slackBotConfigs={slackBotConfigs}
documentSets={documentSets}
refresh={refreshSlackBotConfigs}
setPopup={setPopup}
/>
{slackBotConfigModalIsOpen && (
<SlackBotCreationForm
documentSets={documentSets}
onClose={() => {
refreshSlackBotConfigs();
setSlackBotConfigModalIsOpen(false);
}}
setPopup={setPopup}
/>
)}
</>
)}
</div>
);
};
const Page = () => {
return (
<div>
<div className="border-solid border-gray-600 border-b pb-2 mb-4 flex">
<CPUIcon size={32} />
<h1 className="text-3xl font-bold pl-2">Slack Bot Configuration</h1>
</div>
<Main />
</div>
);
};
export default Page;

View File

@ -1,10 +1,13 @@
import { fetcher } from "@/lib/fetcher";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { DocumentSet } from "@/lib/types";
import useSWR, { mutate } from "swr";
export const useDocumentSets = () => {
const url = "/api/manage/document-set";
const swrResponse = useSWR<DocumentSet<any, any>[]>(url, fetcher);
const swrResponse = useSWR<DocumentSet<any, any>[]>(
url,
errorHandlingFetcher
);
return {
...swrResponse,

View File

@ -20,6 +20,7 @@ import {
UsersIcon,
ThumbsUpIcon,
BookmarkIcon,
CPUIcon,
} from "@/components/icons/icons";
import { DISABLE_AUTH } from "@/lib/constants";
import { getCurrentUserSS } from "@/lib/userSS";
@ -44,7 +45,7 @@ export default async function AdminLayout({
return (
<div>
<Header user={user} />
<div className="bg-gray-900 pt-8 flex">
<div className="bg-gray-900 pt-8 pb-8 flex">
<Sidebar
title="Connector"
collections={[
@ -244,6 +245,20 @@ export default async function AdminLayout({
},
],
},
{
name: "Bots",
items: [
{
name: (
<div className="flex">
<CPUIcon size={18} />
<div className="ml-1">Slack Bot</div>
</div>
),
link: "/admin/bot",
},
],
},
]}
/>
<div className="px-12 min-h-screen bg-gray-900 text-gray-100 w-full">

View File

@ -87,20 +87,22 @@ export const BooleanFormField = ({
);
};
interface TextArrayFieldProps {
interface TextArrayFieldProps<T extends Yup.AnyObject> {
name: string;
label: string;
values: T;
subtext?: string;
type?: string;
}
export function TextArrayFieldBuilder<T extends Yup.AnyObject>({
export function TextArrayField<T extends Yup.AnyObject>({
name,
label,
values,
subtext,
type = "text",
}: TextArrayFieldProps): FormBodyBuilder<T> {
const TextArrayField: FormBodyBuilder<T> = (values) => (
type,
}: TextArrayFieldProps<T>) {
return (
<div className="mb-4">
<label htmlFor={name} className="block">
{label}
@ -153,5 +155,20 @@ export function TextArrayFieldBuilder<T extends Yup.AnyObject>({
/>
</div>
);
return TextArrayField;
}
interface TextArrayFieldBuilderProps<T extends Yup.AnyObject> {
name: string;
label: string;
subtext?: string;
type?: string;
}
export function TextArrayFieldBuilder<T extends Yup.AnyObject>(
props: TextArrayFieldBuilderProps<T>
): FormBodyBuilder<T> {
const _TextArrayField: FormBodyBuilder<T> = (values) => (
<TextArrayField {...props} values={values} />
);
return _TextArrayField;
}

View File

@ -32,6 +32,7 @@ import {
FiZoomIn,
FiCopy,
FiBookmark,
FiCpu,
} from "react-icons/fi";
import { SiBookstack } from "react-icons/si";
import Image from "next/image";
@ -251,6 +252,13 @@ export const BookmarkIcon = ({
return <FiBookmark size={size} className={className} />;
};
export const CPUIcon = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => {
return <FiCpu size={size} className={className} />;
};
//
// COMPANY LOGOS
//

View File

@ -1 +1,25 @@
export const fetcher = (url: string) => fetch(url).then((res) => res.json());
class FetchError extends Error {
status: number;
info: any;
constructor(message: string, status: number, info: any) {
super(message);
this.status = status;
this.info = info;
}
}
export const errorHandlingFetcher = async (url: string) => {
const res = await fetch(url);
if (!res.ok) {
const error = new FetchError(
"An error occurred while fetching the data.",
res.status,
await res.json()
);
throw error;
}
return res.json();
};

View File

@ -228,3 +228,21 @@ export interface DocumentSet<ConnectorType, CredentialType> {
cc_pair_descriptors: CCPairDescriptor<ConnectorType, CredentialType>[];
is_up_to_date: boolean;
}
// SLACK BOT CONFIGS
export interface ChannelConfig {
channel_names: string[];
answer_validity_check_enabled?: boolean;
team_members?: string[];
}
export interface SlackBotConfig {
id: number;
document_sets: DocumentSet<any, any>[];
channel_config: ChannelConfig;
}
export interface SlackBotTokens {
bot_token: string;
app_token: string;
}