From d41d8441161915476ee7904284fd8ea9188c45e7 Mon Sep 17 00:00:00 2001 From: Chris Weaver <25087905+Weves@users.noreply.github.com> Date: Tue, 26 Sep 2023 14:03:27 -0700 Subject: [PATCH] Slack bot management dashboard (#483) --- .../7da543f5672f_add_slackbotconfig_table.py | 38 +++ ..._add_document_set_persona_relationship_.py | 37 +++ backend/danswer/bots/slack/config.py | 40 +++ .../bots/slack/handlers/handle_message.py | 30 +- backend/danswer/bots/slack/listener.py | 17 +- backend/danswer/bots/slack/tokens.py | 35 ++ backend/danswer/bots/slack/utils.py | 17 + backend/danswer/db/chat.py | 7 +- backend/danswer/db/models.py | 111 +++++-- backend/danswer/db/slack_bot_config.py | 149 +++++++++ backend/danswer/main.py | 2 + backend/danswer/server/models.py | 49 +++ .../danswer/server/slack_bot_management.py | 159 ++++++++++ .../admin/bot/SlackBotConfigCreationForm.tsx | 188 +++++++++++ web/src/app/admin/bot/SlackBotTokensForm.tsx | 94 ++++++ web/src/app/admin/bot/hooks.ts | 23 ++ web/src/app/admin/bot/lib.ts | 51 +++ web/src/app/admin/bot/page.tsx | 300 ++++++++++++++++++ web/src/app/admin/documents/sets/hooks.tsx | 7 +- web/src/app/admin/layout.tsx | 17 +- web/src/components/admin/connectors/Field.tsx | 29 +- web/src/components/icons/icons.tsx | 8 + web/src/lib/fetcher.ts | 24 ++ web/src/lib/types.ts | 18 ++ 24 files changed, 1401 insertions(+), 49 deletions(-) create mode 100644 backend/alembic/versions/7da543f5672f_add_slackbotconfig_table.py create mode 100644 backend/alembic/versions/febe9eaa0644_add_document_set_persona_relationship_.py create mode 100644 backend/danswer/bots/slack/config.py create mode 100644 backend/danswer/bots/slack/tokens.py create mode 100644 backend/danswer/db/slack_bot_config.py create mode 100644 backend/danswer/server/slack_bot_management.py create mode 100644 web/src/app/admin/bot/SlackBotConfigCreationForm.tsx create mode 100644 web/src/app/admin/bot/SlackBotTokensForm.tsx create mode 100644 web/src/app/admin/bot/hooks.ts create mode 100644 web/src/app/admin/bot/lib.ts create mode 100644 web/src/app/admin/bot/page.tsx diff --git a/backend/alembic/versions/7da543f5672f_add_slackbotconfig_table.py b/backend/alembic/versions/7da543f5672f_add_slackbotconfig_table.py new file mode 100644 index 000000000..7766f4cf5 --- /dev/null +++ b/backend/alembic/versions/7da543f5672f_add_slackbotconfig_table.py @@ -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") diff --git a/backend/alembic/versions/febe9eaa0644_add_document_set_persona_relationship_.py b/backend/alembic/versions/febe9eaa0644_add_document_set_persona_relationship_.py new file mode 100644 index 000000000..6486fcd11 --- /dev/null +++ b/backend/alembic/versions/febe9eaa0644_add_document_set_persona_relationship_.py @@ -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") diff --git a/backend/danswer/bots/slack/config.py b/backend/danswer/bots/slack/config.py new file mode 100644 index 000000000..8b8465fa8 --- /dev/null +++ b/backend/danswer/bots/slack/config.py @@ -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 diff --git a/backend/danswer/bots/slack/handlers/handle_message.py b/backend/danswer/bots/slack/handlers/handle_message.py index da833aa65..0278a7f07 100644 --- a/backend/danswer/bots/slack/handlers/handle_message.py +++ b/backend/danswer/bots/slack/handlers/handle_message.py @@ -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, ) ) diff --git a/backend/danswer/bots/slack/listener.py b/backend/danswer/bots/slack/listener.py index dd2e0a2fa..086bcb375 100644 --- a/backend/danswer/bots/slack/listener.py +++ b/backend/danswer/bots/slack/listener.py @@ -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), ) diff --git a/backend/danswer/bots/slack/tokens.py b/backend/danswer/bots/slack/tokens.py new file mode 100644 index 000000000..f6122ce22 --- /dev/null +++ b/backend/danswer/bots/slack/tokens.py @@ -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), + ) diff --git a/backend/danswer/bots/slack/utils.py b/backend/danswer/bots/slack/utils.py index 0c682fddf..2ee950aa8 100644 --- a/backend/danswer/bots/slack/utils.py +++ b/backend/danswer/bots/slack/utils.py @@ -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"] diff --git a/backend/danswer/db/chat.py b/backend/danswer/db/chat.py index 18527e1ea..1e2b57ccc 100644 --- a/backend/danswer/db/chat.py +++ b/backend/danswer/db/chat.py @@ -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 diff --git a/backend/danswer/db/models.py b/backend/danswer/db/models.py index 0130fa88f..dfa90a8df 100644 --- a/backend/danswer/db/models.py +++ b/backend/danswer/db/models.py @@ -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") diff --git a/backend/danswer/db/slack_bot_config.py b/backend/danswer/db/slack_bot_config.py new file mode 100644 index 000000000..3e03a1fc2 --- /dev/null +++ b/backend/danswer/db/slack_bot_config.py @@ -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() diff --git a/backend/danswer/main.py b/backend/danswer/main.py index 70b9e1584..fbfa78253 100644 --- a/backend/danswer/main.py +++ b/backend/danswer/main.py @@ -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( diff --git a/backend/danswer/server/models.py b/backend/danswer/server/models.py index 24a9474bd..3f78b17d5 100644 --- a/backend/danswer/server/models.py +++ b/backend/danswer/server/models.py @@ -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 diff --git a/backend/danswer/server/slack_bot_management.py b/backend/danswer/server/slack_bot_management.py new file mode 100644 index 000000000..4e2b8d267 --- /dev/null +++ b/backend/danswer/server/slack_bot_management.py @@ -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") diff --git a/web/src/app/admin/bot/SlackBotConfigCreationForm.tsx b/web/src/app/admin/bot/SlackBotConfigCreationForm.tsx new file mode 100644 index 000000000..523f2e1d5 --- /dev/null +++ b/web/src/app/admin/bot/SlackBotConfigCreationForm.tsx @@ -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[]; + existingSlackBotConfig?: SlackBotConfig; +} + +export const SlackBotCreationForm = ({ + onClose, + setPopup, + documentSets, + existingSlackBotConfig, +}: SetCreationPopupProps) => { + const isUpdate = existingSlackBotConfig !== undefined; + + return ( +
+
+
event.stopPropagation()} + > + 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 }) => ( +
+

+ {isUpdate + ? "Update a DanswerBot Config" + : "Create a new DanswerBot Config"} +

+ +
+ +
+ ( +
+
+ Document Sets: +
+
+ The document sets that DanswerBot should search + through. If left blank, DanswerBot will search through + all documents. +
+
+
+ {documentSets.map((documentSet) => { + const ind = values.document_sets.indexOf( + documentSet.id + ); + let isSelected = ind !== -1; + return ( +
{ + if (isSelected) { + arrayHelpers.remove(ind); + } else { + arrayHelpers.push(documentSet.id); + } + }} + > +
{documentSet.name}
+
+ ); + })} +
+
+ )} + /> +
+ +
+ + )} + +
+
+
+ ); +}; diff --git a/web/src/app/admin/bot/SlackBotTokensForm.tsx b/web/src/app/admin/bot/SlackBotTokensForm.tsx new file mode 100644 index 000000000..e44336d4f --- /dev/null +++ b/web/src/app/admin/bot/SlackBotTokensForm.tsx @@ -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 ( +
+
+
event.stopPropagation()} + > + { + 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 }) => ( +
+

Set Slack Bot Tokens

+ + +
+ +
+ + )} +
+
+
+
+ ); +}; diff --git a/web/src/app/admin/bot/hooks.ts b/web/src/app/admin/bot/hooks.ts new file mode 100644 index 000000000..95829938e --- /dev/null +++ b/web/src/app/admin/bot/hooks.ts @@ -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(url, errorHandlingFetcher); + + return { + ...swrResponse, + refreshSlackBotConfigs: () => mutate(url), + }; +}; + +export const useSlackBotTokens = () => { + const url = "/api/manage/admin/slack-bot/tokens"; + const swrResponse = useSWR(url, errorHandlingFetcher); + + return { + ...swrResponse, + refreshSlackBotTokens: () => mutate(url), + }; +}; diff --git a/web/src/app/admin/bot/lib.ts b/web/src/app/admin/bot/lib.ts new file mode 100644 index 000000000..57f940dbc --- /dev/null +++ b/web/src/app/admin/bot/lib.ts @@ -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), + }); +}; diff --git a/web/src/app/admin/bot/page.tsx b/web/src/app/admin/bot/page.tsx new file mode 100644 index 000000000..58f37482d --- /dev/null +++ b/web/src/app/admin/bot/page.tsx @@ -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[]; + refreshSlackBotConfigs: () => void; +}) => { + const [isEditPopupOpen, setEditPopupOpen] = useState(false); + return ( + <> + {isEditPopupOpen && ( + { + setEditPopupOpen(false); + refreshSlackBotConfigs(); + }} + setPopup={setPopup} + documentSets={documentSets} + existingSlackBotConfig={existingSlackBotConfig} + /> + )} +
setEditPopupOpen(true)} + > + +
+ + ); +}; + +interface DocumentFeedbackTableProps { + slackBotConfigs: SlackBotConfig[]; + documentSets: DocumentSet[]; + 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 ( +
+ { + return { + channels: ( +
+ +
+ {slackBotConfig.channel_config.channel_names + .map((channel_name) => `#${channel_name}`) + .join(", ")} +
+
+ ), + document_sets: ( +
+ {slackBotConfig.document_sets + .map((documentSet) => documentSet.name) + .join(", ")} +
+ ), + answer_validity_check_enabled: slackBotConfig.channel_config + .answer_validity_check_enabled ? ( +
Yes
+ ) : ( +
No
+ ), + delete: ( +
{ + 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(); + }} + > + +
+ ), + }; + })} + /> +
+
+ setPage(newPage)} + /> +
+
+
+ ); +}; + +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 ; + } + + if (slackBotConfigsError || !slackBotConfigs) { + return
Error: {slackBotConfigsError}
; + } + + if (documentSetsError || !documentSets) { + return
Error: {documentSetsError}
; + } + + return ( +
+ {popup} + +

Step 1: Configure Slack Tokens

+ {!slackBotTokens ? ( + refreshSlackBotTokens()} + setPopup={setPopup} + /> + ) : ( + <> +
Tokens saved!
+ + {slackBotTokensModalIsOpen && ( + { + refreshSlackBotTokens(); + setSlackBotTokensModalIsOpen(false); + }} + setPopup={setPopup} + existingTokens={slackBotTokens} + /> + )} + + )} + {slackBotTokens && ( + <> +

+ Step 2: Setup DanswerBot +

+
+ Configure Danswer to automatically answer questions in Slack + channels. +
+ +
+ +
+ +
+ + + + {slackBotConfigModalIsOpen && ( + { + refreshSlackBotConfigs(); + setSlackBotConfigModalIsOpen(false); + }} + setPopup={setPopup} + /> + )} + + )} +
+ ); +}; + +const Page = () => { + return ( +
+
+ +

Slack Bot Configuration

+
+ +
+
+ ); +}; + +export default Page; diff --git a/web/src/app/admin/documents/sets/hooks.tsx b/web/src/app/admin/documents/sets/hooks.tsx index 879fc753f..2a5104388 100644 --- a/web/src/app/admin/documents/sets/hooks.tsx +++ b/web/src/app/admin/documents/sets/hooks.tsx @@ -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[]>(url, fetcher); + const swrResponse = useSWR[]>( + url, + errorHandlingFetcher + ); return { ...swrResponse, diff --git a/web/src/app/admin/layout.tsx b/web/src/app/admin/layout.tsx index 112a2f8e0..c911b3b83 100644 --- a/web/src/app/admin/layout.tsx +++ b/web/src/app/admin/layout.tsx @@ -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 (
-
+
+ +
Slack Bot
+
+ ), + link: "/admin/bot", + }, + ], + }, ]} />
diff --git a/web/src/components/admin/connectors/Field.tsx b/web/src/components/admin/connectors/Field.tsx index dd4d5531d..fbe65bde1 100644 --- a/web/src/components/admin/connectors/Field.tsx +++ b/web/src/components/admin/connectors/Field.tsx @@ -87,20 +87,22 @@ export const BooleanFormField = ({ ); }; -interface TextArrayFieldProps { +interface TextArrayFieldProps { name: string; label: string; + values: T; subtext?: string; type?: string; } -export function TextArrayFieldBuilder({ +export function TextArrayField({ name, label, + values, subtext, - type = "text", -}: TextArrayFieldProps): FormBodyBuilder { - const TextArrayField: FormBodyBuilder = (values) => ( + type, +}: TextArrayFieldProps) { + return (
); - return TextArrayField; +} + +interface TextArrayFieldBuilderProps { + name: string; + label: string; + subtext?: string; + type?: string; +} + +export function TextArrayFieldBuilder( + props: TextArrayFieldBuilderProps +): FormBodyBuilder { + const _TextArrayField: FormBodyBuilder = (values) => ( + + ); + return _TextArrayField; } diff --git a/web/src/components/icons/icons.tsx b/web/src/components/icons/icons.tsx index 54a820e2d..572577b29 100644 --- a/web/src/components/icons/icons.tsx +++ b/web/src/components/icons/icons.tsx @@ -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 ; }; +export const CPUIcon = ({ + size = 16, + className = defaultTailwindCSS, +}: IconProps) => { + return ; +}; + // // COMPANY LOGOS // diff --git a/web/src/lib/fetcher.ts b/web/src/lib/fetcher.ts index 50b047c9a..e9ca26c20 100644 --- a/web/src/lib/fetcher.ts +++ b/web/src/lib/fetcher.ts @@ -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(); +}; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 666342e87..e1e9211c0 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -228,3 +228,21 @@ export interface DocumentSet { cc_pair_descriptors: CCPairDescriptor[]; 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[]; + channel_config: ChannelConfig; +} + +export interface SlackBotTokens { + bot_token: string; + app_token: string; +}