From 7153cb09f16d16d5feff5dcae07ca80a0bf6885b Mon Sep 17 00:00:00 2001 From: pablodanswer Date: Wed, 5 Feb 2025 14:26:26 -0800 Subject: [PATCH] add default slack channel config --- ...593925_add_default_slack_channel_config.py | 76 +++++ .../slack/handlers/handle_standard_answers.py | 7 +- backend/onyx/db/constants.py | 1 + backend/onyx/db/models.py | 20 +- backend/onyx/db/slack_bot.py | 12 + backend/onyx/db/slack_channel_config.py | 70 +++-- backend/onyx/onyxbot/slack/config.py | 22 +- .../onyxbot/slack/handlers/handle_message.py | 2 +- .../slack/handlers/handle_regular_answer.py | 18 +- .../slack/handlers/handle_standard_answers.py | 4 +- backend/onyx/onyxbot/slack/listener.py | 6 +- backend/onyx/server/manage/models.py | 7 + backend/onyx/server/manage/slack_bot.py | 82 +++++- web/src/app/admin/bots/SlackBotTable.tsx | 26 +- web/src/app/admin/bots/SlackTokensForm.tsx | 5 +- .../[bot-id]/SlackChannelConfigsTable.tsx | 259 ++++++++++-------- .../SlackChannelConfigCreationForm.tsx | 60 ++-- .../channels/SlackChannelConfigFormFields.tsx | 231 ++++++++++------ web/src/app/admin/bots/[bot-id]/lib.ts | 14 + web/src/app/admin/bots/[bot-id]/page.tsx | 24 -- web/src/components/Dropdown.tsx | 26 +- web/src/components/ui/badge.tsx | 4 +- web/src/components/ui/button.tsx | 8 +- web/src/lib/types.ts | 23 +- 24 files changed, 658 insertions(+), 349 deletions(-) create mode 100644 backend/alembic/versions/eaa3b5593925_add_default_slack_channel_config.py diff --git a/backend/alembic/versions/eaa3b5593925_add_default_slack_channel_config.py b/backend/alembic/versions/eaa3b5593925_add_default_slack_channel_config.py new file mode 100644 index 000000000000..b8954cbf1e8b --- /dev/null +++ b/backend/alembic/versions/eaa3b5593925_add_default_slack_channel_config.py @@ -0,0 +1,76 @@ +"""add default slack channel config + +Revision ID: eaa3b5593925 +Revises: 98a5008d8711 +Create Date: 2025-02-03 18:07:56.552526 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "eaa3b5593925" +down_revision = "98a5008d8711" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add is_default column + op.add_column( + "slack_channel_config", + sa.Column("is_default", sa.Boolean(), nullable=False, server_default="false"), + ) + + op.create_index( + "ix_slack_channel_config_slack_bot_id_default", + "slack_channel_config", + ["slack_bot_id", "is_default"], + unique=True, + postgresql_where=sa.text("is_default IS TRUE"), + ) + + # Create default channel configs for existing slack bots without one + conn = op.get_bind() + slack_bots = conn.execute(sa.text("SELECT id FROM slack_bot")).fetchall() + + for slack_bot in slack_bots: + slack_bot_id = slack_bot[0] + existing_default = conn.execute( + sa.text( + "SELECT id FROM slack_channel_config WHERE slack_bot_id = :bot_id AND is_default = TRUE" + ), + {"bot_id": slack_bot_id}, + ).fetchone() + + if not existing_default: + conn.execute( + sa.text( + """ + INSERT INTO slack_channel_config ( + slack_bot_id, persona_id, channel_config, enable_auto_filters, is_default + ) VALUES ( + :bot_id, NULL, + '{"channel_name": null, "respond_member_group_list": [], "answer_filters": [], "follow_up_tags": []}', + FALSE, TRUE + ) + """ + ), + {"bot_id": slack_bot_id}, + ) + + +def downgrade() -> None: + # Delete default slack channel configs + conn = op.get_bind() + conn.execute(sa.text("DELETE FROM slack_channel_config WHERE is_default = TRUE")) + + # Remove index + op.drop_index( + "ix_slack_channel_config_slack_bot_id_default", + table_name="slack_channel_config", + ) + + # Remove is_default column + op.drop_column("slack_channel_config", "is_default") diff --git a/backend/ee/onyx/onyxbot/slack/handlers/handle_standard_answers.py b/backend/ee/onyx/onyxbot/slack/handlers/handle_standard_answers.py index 5b994c126cb8..2da5ea5ca128 100644 --- a/backend/ee/onyx/onyxbot/slack/handlers/handle_standard_answers.py +++ b/backend/ee/onyx/onyxbot/slack/handlers/handle_standard_answers.py @@ -80,7 +80,7 @@ def oneoff_standard_answers( def _handle_standard_answers( message_info: SlackMessageInfo, receiver_ids: list[str] | None, - slack_channel_config: SlackChannelConfig | None, + slack_channel_config: SlackChannelConfig, prompt: Prompt | None, logger: OnyxLoggingAdapter, client: WebClient, @@ -94,13 +94,10 @@ def _handle_standard_answers( Returns True if standard answers are found to match the user's message and therefore, we still need to respond to the users. """ - # if no channel config, then no standard answers are configured - if not slack_channel_config: - return False slack_thread_id = message_info.thread_to_respond configured_standard_answer_categories = ( - slack_channel_config.standard_answer_categories if slack_channel_config else [] + slack_channel_config.standard_answer_categories ) configured_standard_answers = set( [ diff --git a/backend/onyx/db/constants.py b/backend/onyx/db/constants.py index 935fcf701550..58573d34244a 100644 --- a/backend/onyx/db/constants.py +++ b/backend/onyx/db/constants.py @@ -1 +1,2 @@ SLACK_BOT_PERSONA_PREFIX = "__slack_bot_persona__" +DEFAULT_PERSONA_SLACK_CHANNEL_NAME = "DEFAULT_SLACK_CHANNEL" diff --git a/backend/onyx/db/models.py b/backend/onyx/db/models.py index f8b5cb7c51d0..3256fbebed41 100644 --- a/backend/onyx/db/models.py +++ b/backend/onyx/db/models.py @@ -1716,7 +1716,7 @@ class ChannelConfig(TypedDict): """NOTE: is a `TypedDict` so it can be used as a type hint for a JSONB column in Postgres""" - channel_name: str + channel_name: str | None # None for default channel config respond_tag_only: NotRequired[bool] # defaults to False respond_to_bots: NotRequired[bool] # defaults to False respond_member_group_list: NotRequired[list[str]] @@ -1737,7 +1737,6 @@ class SlackChannelConfig(Base): 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 ) @@ -1746,6 +1745,8 @@ class SlackChannelConfig(Base): Boolean, nullable=False, default=False ) + is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + persona: Mapped[Persona | None] = relationship("Persona") slack_bot: Mapped["SlackBot"] = relationship( "SlackBot", @@ -1757,6 +1758,21 @@ class SlackChannelConfig(Base): back_populates="slack_channel_configs", ) + __table_args__ = ( + UniqueConstraint( + "slack_bot_id", + "is_default", + name="uq_slack_channel_config_slack_bot_id_default", + ), + Index( + "ix_slack_channel_config_slack_bot_id_default", + "slack_bot_id", + "is_default", + unique=True, + postgresql_where=(is_default is True), # type: ignore + ), + ) + class SlackBot(Base): __tablename__ = "slack_bot" diff --git a/backend/onyx/db/slack_bot.py b/backend/onyx/db/slack_bot.py index db50287aa028..ea1e7674cdd8 100644 --- a/backend/onyx/db/slack_bot.py +++ b/backend/onyx/db/slack_bot.py @@ -74,3 +74,15 @@ def remove_slack_bot( def fetch_slack_bots(db_session: Session) -> Sequence[SlackBot]: return db_session.scalars(select(SlackBot)).all() + + +def fetch_slack_bot_tokens( + db_session: Session, slack_bot_id: int +) -> dict[str, str] | None: + slack_bot = db_session.scalar(select(SlackBot).where(SlackBot.id == slack_bot_id)) + if not slack_bot: + return None + return { + "app_token": slack_bot.app_token, + "bot_token": slack_bot.bot_token, + } diff --git a/backend/onyx/db/slack_channel_config.py b/backend/onyx/db/slack_channel_config.py index 523909f22b93..5884fde520aa 100644 --- a/backend/onyx/db/slack_channel_config.py +++ b/backend/onyx/db/slack_channel_config.py @@ -6,6 +6,7 @@ from sqlalchemy.orm import Session from onyx.configs.chat_configs import MAX_CHUNKS_FED_TO_CHAT from onyx.context.search.enums import RecencyBiasSetting +from onyx.db.constants import DEFAULT_PERSONA_SLACK_CHANNEL_NAME from onyx.db.constants import SLACK_BOT_PERSONA_PREFIX from onyx.db.models import ChannelConfig from onyx.db.models import Persona @@ -22,8 +23,8 @@ from onyx.utils.variable_functionality import ( ) -def _build_persona_name(channel_name: str) -> str: - return f"{SLACK_BOT_PERSONA_PREFIX}{channel_name}" +def _build_persona_name(channel_name: str | None) -> str: + return f"{SLACK_BOT_PERSONA_PREFIX}{channel_name if channel_name else DEFAULT_PERSONA_SLACK_CHANNEL_NAME}" def _cleanup_relationships(db_session: Session, persona_id: int) -> None: @@ -40,7 +41,7 @@ def _cleanup_relationships(db_session: Session, persona_id: int) -> None: def create_slack_channel_persona( db_session: Session, - channel_name: str, + channel_name: str | None, document_set_ids: list[int], existing_persona_id: int | None = None, num_chunks: float = MAX_CHUNKS_FED_TO_CHAT, @@ -90,6 +91,7 @@ def insert_slack_channel_config( channel_config: ChannelConfig, standard_answer_category_ids: list[int], enable_auto_filters: bool, + is_default: bool = False, ) -> SlackChannelConfig: versioned_fetch_standard_answer_categories_by_ids = ( fetch_versioned_implementation_with_fallback( @@ -115,12 +117,26 @@ def insert_slack_channel_config( f"Some or all categories with ids {standard_answer_category_ids} do not exist" ) + if is_default: + existing_default = db_session.scalar( + select(SlackChannelConfig).where( + SlackChannelConfig.slack_bot_id == slack_bot_id, + SlackChannelConfig.is_default is True, # type: ignore + ) + ) + if existing_default: + raise ValueError("A default config already exists for this Slack bot.") + else: + if "channel_name" not in channel_config: + raise ValueError("Channel name is required for non-default configs.") + slack_channel_config = SlackChannelConfig( slack_bot_id=slack_bot_id, persona_id=persona_id, channel_config=channel_config, standard_answer_categories=existing_standard_answer_categories, enable_auto_filters=enable_auto_filters, + is_default=is_default, ) db_session.add(slack_channel_config) db_session.commit() @@ -164,12 +180,7 @@ def update_slack_channel_config( f"Some or all categories with ids {standard_answer_category_ids} do not exist" ) - # get the existing persona id before updating the object - existing_persona_id = slack_channel_config.persona_id - # update the config - # NOTE: need to do this before cleaning up the old persona or else we - # will encounter `violates foreign key constraint` errors slack_channel_config.persona_id = persona_id slack_channel_config.channel_config = channel_config slack_channel_config.standard_answer_categories = list( @@ -177,20 +188,6 @@ def update_slack_channel_config( ) slack_channel_config.enable_auto_filters = enable_auto_filters - # if the persona has changed, then clean up the old persona - if persona_id != existing_persona_id and existing_persona_id: - existing_persona = db_session.scalar( - select(Persona).where(Persona.id == existing_persona_id) - ) - # if the existing persona was one created just for use with this Slack channel, - # then clean it up - if existing_persona and existing_persona.name.startswith( - SLACK_BOT_PERSONA_PREFIX - ): - _cleanup_relationships( - db_session=db_session, persona_id=existing_persona_id - ) - db_session.commit() return slack_channel_config @@ -253,3 +250,32 @@ def fetch_slack_channel_config( SlackChannelConfig.id == slack_channel_config_id ) ) + + +def fetch_slack_channel_config_for_channel_or_default( + db_session: Session, slack_bot_id: int, channel_name: str | None +) -> SlackChannelConfig | None: + # attempt to find channel-specific config first + if channel_name: + sc_config = db_session.scalar( + select(SlackChannelConfig).where( + SlackChannelConfig.slack_bot_id == slack_bot_id, + SlackChannelConfig.channel_config["channel_name"].astext + == channel_name, + ) + ) + else: + sc_config = None + + if sc_config: + return sc_config + + # if none found, see if there is a default + default_sc = db_session.scalar( + select(SlackChannelConfig).where( + SlackChannelConfig.slack_bot_id == slack_bot_id, + SlackChannelConfig.is_default == True, # noqa: E712 + ) + ) + + return default_sc diff --git a/backend/onyx/onyxbot/slack/config.py b/backend/onyx/onyxbot/slack/config.py index 6462e78ed999..873c191fcdbd 100644 --- a/backend/onyx/onyxbot/slack/config.py +++ b/backend/onyx/onyxbot/slack/config.py @@ -3,9 +3,11 @@ import os from sqlalchemy.orm import Session from onyx.db.models import SlackChannelConfig +from onyx.db.slack_channel_config import ( + fetch_slack_channel_config_for_channel_or_default, +) from onyx.db.slack_channel_config import fetch_slack_channel_configs - VALID_SLACK_FILTERS = [ "answerable_prefilter", "well_answered_postfilter", @@ -17,18 +19,16 @@ def get_slack_channel_config_for_bot_and_channel( db_session: Session, slack_bot_id: int, channel_name: str | None, -) -> SlackChannelConfig | None: - if not channel_name: - return None - - slack_bot_configs = fetch_slack_channel_configs( - db_session=db_session, slack_bot_id=slack_bot_id +) -> SlackChannelConfig: + slack_bot_config = fetch_slack_channel_config_for_channel_or_default( + db_session=db_session, slack_bot_id=slack_bot_id, channel_name=channel_name ) - for config in slack_bot_configs: - if channel_name in config.channel_config["channel_name"]: - return config + if not slack_bot_config: + raise ValueError( + "No default configuration has been set for this Slack bot. This should not be possible." + ) - return None + return slack_bot_config def validate_channel_name( diff --git a/backend/onyx/onyxbot/slack/handlers/handle_message.py b/backend/onyx/onyxbot/slack/handlers/handle_message.py index ed6a79a964a9..fb0447028087 100644 --- a/backend/onyx/onyxbot/slack/handlers/handle_message.py +++ b/backend/onyx/onyxbot/slack/handlers/handle_message.py @@ -106,7 +106,7 @@ def remove_scheduled_feedback_reminder( def handle_message( message_info: SlackMessageInfo, - slack_channel_config: SlackChannelConfig | None, + slack_channel_config: SlackChannelConfig, client: WebClient, feedback_reminder_id: str | None, tenant_id: str | None, diff --git a/backend/onyx/onyxbot/slack/handlers/handle_regular_answer.py b/backend/onyx/onyxbot/slack/handlers/handle_regular_answer.py index cad549a76442..0fcf12dea993 100644 --- a/backend/onyx/onyxbot/slack/handlers/handle_regular_answer.py +++ b/backend/onyx/onyxbot/slack/handlers/handle_regular_answer.py @@ -64,7 +64,7 @@ def rate_limits( def handle_regular_answer( message_info: SlackMessageInfo, - slack_channel_config: SlackChannelConfig | None, + slack_channel_config: SlackChannelConfig, receiver_ids: list[str] | None, client: WebClient, channel: str, @@ -76,7 +76,7 @@ def handle_regular_answer( should_respond_with_error_msgs: bool = DANSWER_BOT_DISPLAY_ERROR_MSGS, disable_docs_only_answer: bool = DANSWER_BOT_DISABLE_DOCS_ONLY_ANSWER, ) -> bool: - channel_conf = slack_channel_config.channel_config if slack_channel_config else None + channel_conf = slack_channel_config.channel_config messages = message_info.thread_messages @@ -92,7 +92,7 @@ def handle_regular_answer( prompt = None # If no persona is specified, use the default search based persona # This way slack flow always has a persona - persona = slack_channel_config.persona if slack_channel_config else None + persona = slack_channel_config.persona if not persona: with get_session_with_tenant(tenant_id) as db_session: persona = get_persona_by_id(DEFAULT_PERSONA_ID, user, db_session) @@ -134,11 +134,7 @@ def handle_regular_answer( single_message_history = slackify_message_thread(history_messages) or None bypass_acl = False - if ( - slack_channel_config - and slack_channel_config.persona - and slack_channel_config.persona.document_sets - ): + if slack_channel_config.persona and slack_channel_config.persona.document_sets: # For Slack channels, use the full document set, admin will be warned when configuring it # with non-public document sets bypass_acl = True @@ -190,11 +186,7 @@ def handle_regular_answer( # auto_detect_filters = ( # persona.llm_filter_extraction if persona is not None else True # ) - auto_detect_filters = ( - slack_channel_config.enable_auto_filters - if slack_channel_config is not None - else False - ) + auto_detect_filters = slack_channel_config.enable_auto_filters retrieval_details = RetrievalDetails( run_search=OptionalSearchSetting.ALWAYS, real_time=False, diff --git a/backend/onyx/onyxbot/slack/handlers/handle_standard_answers.py b/backend/onyx/onyxbot/slack/handlers/handle_standard_answers.py index bd16f7f99233..98a8bb8c0269 100644 --- a/backend/onyx/onyxbot/slack/handlers/handle_standard_answers.py +++ b/backend/onyx/onyxbot/slack/handlers/handle_standard_answers.py @@ -14,7 +14,7 @@ logger = setup_logger() def handle_standard_answers( message_info: SlackMessageInfo, receiver_ids: list[str] | None, - slack_channel_config: SlackChannelConfig | None, + slack_channel_config: SlackChannelConfig, prompt: Prompt | None, logger: OnyxLoggingAdapter, client: WebClient, @@ -40,7 +40,7 @@ def handle_standard_answers( def _handle_standard_answers( message_info: SlackMessageInfo, receiver_ids: list[str] | None, - slack_channel_config: SlackChannelConfig | None, + slack_channel_config: SlackChannelConfig, prompt: Prompt | None, logger: OnyxLoggingAdapter, client: WebClient, diff --git a/backend/onyx/onyxbot/slack/listener.py b/backend/onyx/onyxbot/slack/listener.py index ae6274adbae2..c682ecadbd7e 100644 --- a/backend/onyx/onyxbot/slack/listener.py +++ b/backend/onyx/onyxbot/slack/listener.py @@ -790,8 +790,7 @@ def process_message( # Be careful about this default, don't want to accidentally spam every channel # Users should be able to DM slack bot in their private channels though if ( - slack_channel_config is None - and not respond_every_channel + not respond_every_channel # Can't have configs for DMs so don't toss them out and not is_dm # If /OnyxBot (is_bot_msg) or @OnyxBot (bypass_filters) @@ -801,8 +800,7 @@ def process_message( return follow_up = bool( - slack_channel_config - and slack_channel_config.channel_config + slack_channel_config.channel_config and slack_channel_config.channel_config.get("follow_up_tags") is not None ) diff --git a/backend/onyx/server/manage/models.py b/backend/onyx/server/manage/models.py index 755b11e042fe..7c74b9765e88 100644 --- a/backend/onyx/server/manage/models.py +++ b/backend/onyx/server/manage/models.py @@ -215,6 +215,7 @@ class SlackChannelConfig(BaseModel): # XXX this is going away soon standard_answer_categories: list[StandardAnswerCategory] enable_auto_filters: bool + is_default: bool @classmethod def from_model( @@ -237,6 +238,7 @@ class SlackChannelConfig(BaseModel): for standard_answer_category_model in slack_channel_config_model.standard_answer_categories ], enable_auto_filters=slack_channel_config_model.enable_auto_filters, + is_default=slack_channel_config_model.is_default, ) @@ -279,3 +281,8 @@ class AllUsersResponse(BaseModel): accepted_pages: int invited_pages: int slack_users_pages: int + + +class SlackChannel(BaseModel): + id: str + name: str diff --git a/backend/onyx/server/manage/slack_bot.py b/backend/onyx/server/manage/slack_bot.py index 3ff8a80fe954..5f68b78009bd 100644 --- a/backend/onyx/server/manage/slack_bot.py +++ b/backend/onyx/server/manage/slack_bot.py @@ -1,6 +1,10 @@ +from typing import Any + from fastapi import APIRouter from fastapi import Depends from fastapi import HTTPException +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError from sqlalchemy.orm import Session from onyx.auth.users import current_admin_user @@ -12,6 +16,7 @@ from onyx.db.models import ChannelConfig from onyx.db.models import User from onyx.db.persona import get_persona_by_id from onyx.db.slack_bot import fetch_slack_bot +from onyx.db.slack_bot import fetch_slack_bot_tokens from onyx.db.slack_bot import fetch_slack_bots from onyx.db.slack_bot import insert_slack_bot from onyx.db.slack_bot import remove_slack_bot @@ -25,6 +30,7 @@ from onyx.db.slack_channel_config import update_slack_channel_config from onyx.onyxbot.slack.config import validate_channel_name from onyx.server.manage.models import SlackBot from onyx.server.manage.models import SlackBotCreationRequest +from onyx.server.manage.models import SlackChannel from onyx.server.manage.models import SlackChannelConfig from onyx.server.manage.models import SlackChannelConfigCreationRequest from onyx.server.manage.validate_tokens import validate_app_token @@ -48,12 +54,6 @@ def _form_channel_config( answer_filters = slack_channel_config_creation_request.answer_filters follow_up_tags = slack_channel_config_creation_request.follow_up_tags - if not raw_channel_name: - raise HTTPException( - status_code=400, - detail="Must provide at least one channel name", - ) - try: cleaned_channel_name = validate_channel_name( db_session=db_session, @@ -108,6 +108,12 @@ def create_slack_channel_config( current_slack_channel_config_id=None, ) + if channel_config["channel_name"] is None: + raise HTTPException( + status_code=400, + detail="Channel name is required", + ) + persona_id = None if slack_channel_config_creation_request.persona_id is not None: persona_id = slack_channel_config_creation_request.persona_id @@ -120,11 +126,11 @@ def create_slack_channel_config( ).id slack_channel_config_model = insert_slack_channel_config( + db_session=db_session, slack_bot_id=slack_channel_config_creation_request.slack_bot_id, persona_id=persona_id, channel_config=channel_config, standard_answer_category_ids=slack_channel_config_creation_request.standard_answer_categories, - db_session=db_session, enable_auto_filters=slack_channel_config_creation_request.enable_auto_filters, ) return SlackChannelConfig.from_model(slack_channel_config_model) @@ -235,6 +241,23 @@ def create_bot( app_token=slack_bot_creation_request.app_token, ) + # Create a default Slack channel config + default_channel_config = ChannelConfig( + channel_name=None, + respond_member_group_list=[], + answer_filters=[], + follow_up_tags=[], + ) + insert_slack_channel_config( + db_session=db_session, + slack_bot_id=slack_bot_model.id, + persona_id=None, + channel_config=default_channel_config, + standard_answer_category_ids=[], + enable_auto_filters=False, + is_default=True, + ) + create_milestone_and_report( user=None, distinct_id=tenant_id or "N/A", @@ -315,3 +338,48 @@ def list_bot_configs( SlackChannelConfig.from_model(slack_bot_config_model) for slack_bot_config_model in slack_bot_config_models ] + + +@router.get( + "/admin/slack-app/bots/{bot_id}/channels", +) +def get_all_channels_from_slack_api( + bot_id: int, + db_session: Session = Depends(get_session), + _: User | None = Depends(current_admin_user), +) -> list[SlackChannel]: + tokens = fetch_slack_bot_tokens(db_session, bot_id) + if not tokens or "bot_token" not in tokens: + raise HTTPException( + status_code=404, detail="Bot token not found for the given bot ID" + ) + + bot_token = tokens["bot_token"] + client = WebClient(token=bot_token) + + try: + channels = [] + cursor = None + while True: + response = client.conversations_list( + types="public_channel,private_channel", + exclude_archived=True, + limit=1000, + cursor=cursor, + ) + for channel in response["channels"]: + channels.append(SlackChannel(id=channel["id"], name=channel["name"])) + + response_metadata: dict[str, Any] = response.get("response_metadata", {}) + if isinstance(response_metadata, dict): + cursor = response_metadata.get("next_cursor") + if not cursor: + break + else: + break + + return channels + except SlackApiError as e: + raise HTTPException( + status_code=500, detail=f"Error fetching channels from Slack API: {str(e)}" + ) diff --git a/web/src/app/admin/bots/SlackBotTable.tsx b/web/src/app/admin/bots/SlackBotTable.tsx index 332459e31876..0dc8edb97821 100644 --- a/web/src/app/admin/bots/SlackBotTable.tsx +++ b/web/src/app/admin/bots/SlackBotTable.tsx @@ -1,10 +1,9 @@ "use client"; import { PageSelector } from "@/components/PageSelector"; -import { SlackBot } from "@/lib/types"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; -import { FiCheck, FiEdit, FiXCircle } from "react-icons/fi"; +import { FiEdit } from "react-icons/fi"; import { Table, TableBody, @@ -13,6 +12,8 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { SlackBot } from "@/lib/types"; const NUM_IN_PAGE = 20; @@ -42,7 +43,7 @@ function ClickableTableRow({ ); } -export function SlackBotTable({ slackBots }: { slackBots: SlackBot[] }) { +export const SlackBotTable = ({ slackBots }: { slackBots: SlackBot[] }) => { const [page, setPage] = useState(1); // sort by id for consistent ordering @@ -67,8 +68,9 @@ export function SlackBotTable({ slackBots }: { slackBots: SlackBot[] }) { Name + Status + Default Config Channel Count - Enabled @@ -85,21 +87,27 @@ export function SlackBotTable({ slackBots }: { slackBots: SlackBot[] }) { {slackBot.name} - {slackBot.configs_count} {slackBot.enabled ? ( - + Enabled ) : ( - + Disabled )} + + Default Set + + {slackBot.configs_count} + + {/* Add any action buttons here if needed */} + ); })} {slackBots.length === 0 && ( Please add a New Slack Bot to begin chatting with Danswer! @@ -128,4 +136,4 @@ export function SlackBotTable({ slackBots }: { slackBots: SlackBot[] }) { )} ); -} +}; diff --git a/web/src/app/admin/bots/SlackTokensForm.tsx b/web/src/app/admin/bots/SlackTokensForm.tsx index ccc06799eacd..adbe7732c00f 100644 --- a/web/src/app/admin/bots/SlackTokensForm.tsx +++ b/web/src/app/admin/bots/SlackTokensForm.tsx @@ -7,6 +7,7 @@ import { createSlackBot, updateSlackBot } from "./new/lib"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { useEffect } from "react"; +import { Switch } from "@/components/ui/switch"; export const SlackTokensForm = ({ isUpdate, @@ -33,7 +34,9 @@ export const SlackTokensForm = ({ return ( { - if (a.id < b.id) { - return -1; - } else if (a.id > b.id) { - return 1; - } else { - return 0; - } - }); + const defaultConfig = slackChannelConfigs.find((config) => config.is_default); + const channelConfigs = slackChannelConfigs.filter( + (config) => !config.is_default + ); return ( -
-
- - - - Channel - Assistant - Document Sets - Delete - - - - {slackChannelConfigs - .slice(numToDisplay * (page - 1), numToDisplay * page) - .map((slackChannelConfig) => { - return ( - { - window.location.href = `/admin/bots/${slackBotId}/channels/${slackChannelConfig.id}`; - }} - > - -
-
- -
-
- {"#" + slackChannelConfig.channel_config.channel_name} -
-
-
- e.stopPropagation()}> - {slackChannelConfig.persona && - !isPersonaASlackBotPersona(slackChannelConfig.persona) ? ( - - {slackChannelConfig.persona.name} - - ) : ( - "-" - )} - - -
- {slackChannelConfig.persona && - slackChannelConfig.persona.document_sets.length > 0 - ? slackChannelConfig.persona.document_sets - .map((documentSet) => documentSet.name) - .join(", ") - : "-"} -
-
- e.stopPropagation()}> -
{ - e.stopPropagation(); - const response = await deleteSlackChannelConfig( - slackChannelConfig.id - ); - if (response.ok) { - setPopup({ - message: `Slack bot config "${slackChannelConfig.id}" deleted`, - type: "success", - }); - } else { - const errorMsg = await response.text(); - setPopup({ - message: `Failed to delete Slack bot config - ${errorMsg}`, - type: "error", - }); - } - refresh(); - }} - > - -
-
-
- ); - })} - - {/* Empty row with message when table has no data */} - {slackChannelConfigs.length === 0 && ( - - - Please add a New Slack Bot Configuration to begin chatting - with Onyx! - - - )} -
-
+
+
+ + + +
-
-
- setPage(newPage)} - /> -
+
+

Channel-Specific Configurations

+ + + + + Channel + Assistant + Document Sets + Actions + + + + {channelConfigs + .slice(numToDisplay * (page - 1), numToDisplay * page) + .map((slackChannelConfig) => { + return ( + { + window.location.href = `/admin/bots/${slackBotId}/channels/${slackChannelConfig.id}`; + }} + > + +
+
+ +
+
+ {"#" + + slackChannelConfig.channel_config.channel_name} +
+
+
+ e.stopPropagation()}> + {slackChannelConfig.persona && + !isPersonaASlackBotPersona( + slackChannelConfig.persona + ) ? ( + + {slackChannelConfig.persona.name} + + ) : ( + "-" + )} + + +
+ {slackChannelConfig.persona && + slackChannelConfig.persona.document_sets.length > 0 + ? slackChannelConfig.persona.document_sets + .map((documentSet) => documentSet.name) + .join(", ") + : "-"} +
+
+ e.stopPropagation()}> + + +
+ ); + })} + + {channelConfigs.length === 0 && ( + + + No channel-specific configurations. Add a new configuration + to customize behavior for specific channels. + + + )} +
+
+
+ + {channelConfigs.length > numToDisplay && ( +
+ setPage(newPage)} + /> +
+ )}
); diff --git a/web/src/app/admin/bots/[bot-id]/channels/SlackChannelConfigCreationForm.tsx b/web/src/app/admin/bots/[bot-id]/channels/SlackChannelConfigCreationForm.tsx index c45d06740c7c..7cf74a23ae97 100644 --- a/web/src/app/admin/bots/[bot-id]/channels/SlackChannelConfigCreationForm.tsx +++ b/web/src/app/admin/bots/[bot-id]/channels/SlackChannelConfigCreationForm.tsx @@ -1,21 +1,29 @@ "use client"; -import React, { useMemo } from "react"; -import { Formik } from "formik"; +import React, { useMemo, useState, useEffect } from "react"; +import { Formik, Form, Field } from "formik"; import * as Yup from "yup"; import { usePopup } from "@/components/admin/connectors/Popup"; -import { DocumentSet, SlackChannelConfig } from "@/lib/types"; +import { + DocumentSet, + SlackChannelConfig, + SlackBotResponseType, +} from "@/lib/types"; import { createSlackChannelConfig, isPersonaASlackBotPersona, updateSlackChannelConfig, + fetchSlackChannels, } from "../lib"; import CardSection from "@/components/admin/CardSection"; import { useRouter } from "next/navigation"; import { Persona } from "@/app/admin/assistants/interfaces"; import { StandardAnswerCategoryResponse } from "@/components/standardAnswers/getStandardAnswerCategoriesIfEE"; import { SEARCH_TOOL_ID, SEARCH_TOOL_NAME } from "@/app/chat/tools/constants"; -import { SlackChannelConfigFormFields } from "./SlackChannelConfigFormFields"; +import { + SlackChannelConfigFormFields, + SlackChannelConfigFormFieldsProps, +} from "./SlackChannelConfigFormFields"; export const SlackChannelConfigCreationForm = ({ slack_bot_id, @@ -33,6 +41,7 @@ export const SlackChannelConfigCreationForm = ({ const { popup, setPopup } = usePopup(); const router = useRouter(); const isUpdate = Boolean(existingSlackChannelConfig); + const isDefault = existingSlackChannelConfig?.is_default || false; const existingSlackBotUsesPersona = existingSlackChannelConfig?.persona ? !isPersonaASlackBotPersona(existingSlackChannelConfig.persona) : false; @@ -46,13 +55,16 @@ export const SlackChannelConfigCreationForm = ({ }, [personas]); return ( - + {popup} + () .oneOf(["quotes", "citations"]) - .required("Response type is required"), + .required(), answer_validity_check_enabled: Yup.boolean().required(), questionmark_prefilter_enabled: Yup.boolean().required(), respond_tag_only: Yup.boolean().required(), @@ -159,6 +171,7 @@ export const SlackChannelConfigCreationForm = ({ standard_answer_categories: values.standard_answer_categories.map( (category: any) => category.id ), + response_type: values.response_type as SlackBotResponseType, }; if (!cleanedValues.still_need_help_enabled) { @@ -191,13 +204,22 @@ export const SlackChannelConfigCreationForm = ({ } }} > - + {({ isSubmitting, values, setFieldValue }) => ( +
+
+ +
+
+ )}
); diff --git a/web/src/app/admin/bots/[bot-id]/channels/SlackChannelConfigFormFields.tsx b/web/src/app/admin/bots/[bot-id]/channels/SlackChannelConfigFormFields.tsx index 02dfaeca1053..7f5681969608 100644 --- a/web/src/app/admin/bots/[bot-id]/channels/SlackChannelConfigFormFields.tsx +++ b/web/src/app/admin/bots/[bot-id]/channels/SlackChannelConfigFormFields.tsx @@ -1,7 +1,13 @@ "use client"; import React, { useState, useEffect, useMemo } from "react"; -import { FieldArray, Form, useFormikContext, ErrorMessage } from "formik"; +import { + FieldArray, + Form, + useFormikContext, + ErrorMessage, + Field, +} from "formik"; import { CCPairDescriptor, DocumentSet } from "@/lib/types"; import { BooleanFormField, @@ -31,9 +37,15 @@ import { TooltipProvider } from "@radix-ui/react-tooltip"; import { SourceIcon } from "@/components/SourceIcon"; import Link from "next/link"; import { AssistantIcon } from "@/components/assistants/AssistantIcon"; +import { SearchMultiSelectDropdown } from "@/components/Dropdown"; +import { fetchSlackChannels } from "../lib"; +import { Badge } from "@/components/ui/badge"; +import useSWR from "swr"; +import { ThreeDotsLoader } from "@/components/Loading"; -interface SlackChannelConfigFormFieldsProps { +export interface SlackChannelConfigFormFieldsProps { isUpdate: boolean; + isDefault: boolean; documentSets: DocumentSet[]; searchEnabledAssistants: Persona[]; standardAnswerCategoryResponse: StandardAnswerCategoryResponse; @@ -41,19 +53,23 @@ interface SlackChannelConfigFormFieldsProps { message: string; type: "error" | "success" | "warning"; }) => void; + slack_bot_id: number; } export function SlackChannelConfigFormFields({ isUpdate, + isDefault, documentSets, searchEnabledAssistants, standardAnswerCategoryResponse, setPopup, + slack_bot_id, }: SlackChannelConfigFormFieldsProps) { const router = useRouter(); const { values, setFieldValue } = useFormikContext(); const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); const [viewUnselectableSets, setViewUnselectableSets] = useState(false); + const [currentSearchTerm, setCurrentSearchTerm] = useState(""); const [viewSyncEnabledAssistants, setViewSyncEnabledAssistants] = useState(false); @@ -152,11 +168,54 @@ export function SlackChannelConfigFormFields({ ); }, [documentSets]); - return ( -
-
- + const { data: channelOptions, isLoading } = useSWR( + `/api/manage/admin/slack-app/bots/${slack_bot_id}/channels`, + async (url: string) => { + const channels = await fetchSlackChannels(slack_bot_id); + return channels.map((channel: any) => ({ + name: channel.name, + value: channel.id, + })); + } + ); + if (isLoading) { + return ; + } + return ( + <> +
+ {isDefault && ( + + Default Configuration + + )} + {!isDefault && ( + <> + {" "} + + {({ field, form }: { field: any; form: any }) => ( + { + form.setFieldValue("channel_name", selected.name); + setCurrentSearchTerm(selected.name); + }} + initialSearchTerm={field.value} + onSearchTermChange={(term) => { + setCurrentSearchTerm(term); + form.setFieldValue("channel_name", term); + }} + /> + )} + + + )}
{selectableSets.length + unselectableSets.length > 0 && (
- {values.knowledge_source === "document_sets" && documentSets.length > 0 && (
@@ -281,7 +339,6 @@ export function SlackChannelConfigFormFields({ />
)} - {values.knowledge_source === "assistant" && (
@@ -353,15 +410,15 @@ export function SlackChannelConfigFormFields({ )}
-
+
{showAdvancedOptions && ( -
-
+
+
-
- { - setFieldValue("still_need_help_enabled", checked); - if (!checked) { - setFieldValue("follow_up_tags", []); - } - }} - label={'Give a "Still need help?" button'} - tooltip={`OnyxBot's response will include a button at the bottom - of the response that asks the user if they still need help.`} - /> - {values.still_need_help_enabled && ( - - - The Slack users / groups we should tag if the user clicks - the "Still need help?" button. If no emails are - provided, we will not tag anyone and will just react with - a 🆘 emoji to the original message. -
- } - placeholder="User email or user group name..." - /> - - )} - - - - - - - -
+ { + setFieldValue("still_need_help_enabled", checked); + if (!checked) { + setFieldValue("follow_up_tags", []); + } + }} + label={'Give a "Still need help?" button'} + tooltip={`OnyxBot's response will include a button at the bottom + of the response that asks the user if they still need help.`} + /> + {values.still_need_help_enabled && ( + + The Slack users / groups we should tag if the user clicks + the "Still need help?" button. If no emails are + provided, we will not tag anyone and will just react with a + 🆘 emoji to the original message. +
+ } placeholder="User email or user group name..." /> -
-
+ + )} + + + + + + + + )} -
+
{shouldShowPrivacyAlert && ( @@ -518,13 +571,11 @@ export function SlackChannelConfigFormFields({ )} - +
- + ); } diff --git a/web/src/app/admin/bots/[bot-id]/lib.ts b/web/src/app/admin/bots/[bot-id]/lib.ts index 1e6bbfe056f2..1984441643b2 100644 --- a/web/src/app/admin/bots/[bot-id]/lib.ts +++ b/web/src/app/admin/bots/[bot-id]/lib.ts @@ -94,3 +94,17 @@ export const deleteSlackChannelConfig = async (id: number) => { export function isPersonaASlackBotPersona(persona: Persona) { return persona.name.startsWith("__slack_bot_persona__"); } + +export const fetchSlackChannels = async (botId: number) => { + return fetch(`/api/manage/admin/slack-app/bots/${botId}/channels`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }).then((response) => { + if (!response.ok) { + throw new Error("Failed to fetch Slack channels"); + } + return response.json(); + }); +}; diff --git a/web/src/app/admin/bots/[bot-id]/page.tsx b/web/src/app/admin/bots/[bot-id]/page.tsx index f99e877137aa..aea206d0ee49 100644 --- a/web/src/app/admin/bots/[bot-id]/page.tsx +++ b/web/src/app/admin/bots/[bot-id]/page.tsx @@ -78,30 +78,6 @@ function SlackBotEditPage({ /> -
- - -
- - New Slack Channel Configuration -
- -
void; itemComponent?: FC<{ option: StringOrNumberOption }>; onCreate?: (name: string) => void; onDelete?: (name: string) => void; + onSearchTermChange?: (term: string) => void; + initialSearchTerm?: string; }) { const [isOpen, setIsOpen] = useState(false); - const [searchTerm, setSearchTerm] = useState(""); + const [searchTerm, setSearchTerm] = useState(initialSearchTerm); const dropdownRef = useRef(null); const handleSelect = (option: StringOrNumberOption) => { @@ -89,6 +93,10 @@ export function SearchMultiSelectDropdown({ }; }, []); + useEffect(() => { + setSearchTerm(initialSearchTerm); + }, [initialSearchTerm]); + return (
@@ -105,21 +113,21 @@ export function SearchMultiSelectDropdown({ } }} onFocus={() => setIsOpen(true)} - className="inline-flex justify-between w-full px-4 py-2 text-sm bg-background border border-border rounded-md shadow-sm" + className="inline-flex justify-between w-full px-4 py-2 text-sm bg-white text-gray-800 border border-gray-300 rounded-md shadow-sm" />
{isOpen && ( -
+
-
+
@@ -170,7 +178,7 @@ export function SearchMultiSelectDropdown({ {filteredOptions.length === 0 && (!onCreate || searchTerm.trim() === "") && ( -
+
No matches found
)} diff --git a/web/src/components/ui/badge.tsx b/web/src/components/ui/badge.tsx index 3b2746600af0..9ade7c74cf15 100644 --- a/web/src/components/ui/badge.tsx +++ b/web/src/components/ui/badge.tsx @@ -10,7 +10,9 @@ const badgeVariants = cva( variant: { "agent-faded": "border-neutral-200 bg-neutral-100 text-neutral-600 hover:bg-neutral-200", - agent: "border-agent bg-agent text-white hover:bg-agent-hover", + agent: + "border-orange-200 bg-orange-50 text-orange-600 hover:bg-orange-75 dark:bg-orange-900 dark:text-neutral-50 dark:hover:bg-orange-850", + canceled: "border-gray-200 bg-gray-50 text-gray-600 hover:bg-gray-75 dark:bg-gray-900 dark:text-neutral-50 dark:hover:bg-gray-850", orange: diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx index 73d71be08527..93fa526aefe5 100644 --- a/web/src/components/ui/button.tsx +++ b/web/src/components/ui/button.tsx @@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; const buttonVariants = cva( - "inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:ring-offset-neutral-950 dark:focus-visible:ring-neutral-300", + "inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded text-sm font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:ring-offset-neutral-950 dark:focus-visible:ring-neutral-300", { variants: { variant: { @@ -58,8 +58,8 @@ const buttonVariants = cva( size: { default: "h-10 px-4 py-2", xs: "h-8 px-3 py-1", - sm: "h-9 rounded-md px-3", - lg: "h-11 rounded-md px-8", + sm: "h-9 px-3", + lg: "h-11 px-8", icon: "h-10 w-10", }, reverse: { @@ -118,7 +118,7 @@ const Button = React.forwardRef( return (
{button} -
+
{tooltip}
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 4f2ac5b4059a..8ad658fb0e28 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -258,23 +258,34 @@ export type SlackBotResponseType = "quotes" | "citations"; export interface SlackChannelConfig { id: number; slack_bot_id: number; + persona_id: number | null; persona: Persona | null; channel_config: ChannelConfig; - response_type: SlackBotResponseType; - standard_answer_categories: StandardAnswerCategory[]; enable_auto_filters: boolean; + standard_answer_categories: StandardAnswerCategory[]; + is_default: boolean; } -export interface SlackBot { +export interface SlackChannelDescriptor { + id: string; + name: string; +} + +export type SlackBot = { id: number; name: string; enabled: boolean; configs_count: number; - - // tokens + slack_channel_configs: Array<{ + id: number; + is_default: boolean; + channel_config: { + channel_name: string; + }; + }>; bot_token: string; app_token: string; -} +}; export interface SlackBotTokens { bot_token: string;