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 2da5ea5ca..3f7b08934 100644 --- a/backend/ee/onyx/onyxbot/slack/handlers/handle_standard_answers.py +++ b/backend/ee/onyx/onyxbot/slack/handlers/handle_standard_answers.py @@ -22,7 +22,7 @@ from onyx.onyxbot.slack.blocks import get_restate_blocks from onyx.onyxbot.slack.constants import GENERATE_ANSWER_BUTTON_ACTION_ID from onyx.onyxbot.slack.handlers.utils import send_team_member_message from onyx.onyxbot.slack.models import SlackMessageInfo -from onyx.onyxbot.slack.utils import respond_in_thread +from onyx.onyxbot.slack.utils import respond_in_thread_or_channel from onyx.onyxbot.slack.utils import update_emote_react from onyx.utils.logger import OnyxLoggingAdapter from onyx.utils.logger import setup_logger @@ -216,7 +216,7 @@ def _handle_standard_answers( all_blocks = restate_question_blocks + answer_blocks try: - respond_in_thread( + respond_in_thread_or_channel( client=client, channel=message_info.channel_to_respond, receiver_ids=receiver_ids, @@ -231,6 +231,7 @@ def _handle_standard_answers( client=client, channel=message_info.channel_to_respond, thread_ts=slack_thread_id, + receiver_ids=receiver_ids, ) return True diff --git a/backend/onyx/connectors/slack/utils.py b/backend/onyx/connectors/slack/utils.py index 8428a4534..757036a9f 100644 --- a/backend/onyx/connectors/slack/utils.py +++ b/backend/onyx/connectors/slack/utils.py @@ -72,6 +72,7 @@ def make_slack_api_rate_limited( @wraps(call) def rate_limited_call(**kwargs: Any) -> SlackResponse: last_exception = None + for _ in range(max_retries): try: # Make the API call diff --git a/backend/onyx/db/models.py b/backend/onyx/db/models.py index 2da8be9ec..484d24620 100644 --- a/backend/onyx/db/models.py +++ b/backend/onyx/db/models.py @@ -1790,6 +1790,7 @@ class ChannelConfig(TypedDict): 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 + is_ephemeral: NotRequired[bool] # defaults to False respond_member_group_list: NotRequired[list[str]] answer_filters: NotRequired[list[AllowedAnswerFilters]] # If None then no follow up diff --git a/backend/onyx/onyxbot/slack/blocks.py b/backend/onyx/onyxbot/slack/blocks.py index 9eae5d017..37874ed89 100644 --- a/backend/onyx/onyxbot/slack/blocks.py +++ b/backend/onyx/onyxbot/slack/blocks.py @@ -31,12 +31,18 @@ from onyx.onyxbot.slack.constants import FEEDBACK_DOC_BUTTON_BLOCK_ACTION_ID from onyx.onyxbot.slack.constants import FOLLOWUP_BUTTON_ACTION_ID from onyx.onyxbot.slack.constants import FOLLOWUP_BUTTON_RESOLVED_ACTION_ID from onyx.onyxbot.slack.constants import IMMEDIATE_RESOLVED_BUTTON_ACTION_ID +from onyx.onyxbot.slack.constants import KEEP_TO_YOURSELF_ACTION_ID from onyx.onyxbot.slack.constants import LIKE_BLOCK_ACTION_ID +from onyx.onyxbot.slack.constants import SHOW_EVERYONE_ACTION_ID from onyx.onyxbot.slack.formatting import format_slack_message from onyx.onyxbot.slack.icons import source_to_github_img_link +from onyx.onyxbot.slack.models import ActionValuesEphemeralMessage +from onyx.onyxbot.slack.models import ActionValuesEphemeralMessageChannelConfig +from onyx.onyxbot.slack.models import ActionValuesEphemeralMessageMessageInfo from onyx.onyxbot.slack.models import SlackMessageInfo from onyx.onyxbot.slack.utils import build_continue_in_web_ui_id from onyx.onyxbot.slack.utils import build_feedback_id +from onyx.onyxbot.slack.utils import build_publish_ephemeral_message_id from onyx.onyxbot.slack.utils import remove_slack_text_interactions from onyx.onyxbot.slack.utils import translate_vespa_highlight_to_slack from onyx.utils.text_processing import decode_escapes @@ -105,6 +111,77 @@ def _build_qa_feedback_block( ) +def _build_ephemeral_publication_block( + channel_id: str, + chat_message_id: int, + message_info: SlackMessageInfo, + original_question_ts: str, + channel_conf: ChannelConfig, + feedback_reminder_id: str | None = None, +) -> Block: + # check whether the message is in a thread + if ( + message_info is not None + and message_info.msg_to_respond is not None + and message_info.thread_to_respond is not None + and (message_info.msg_to_respond == message_info.thread_to_respond) + ): + respond_ts = None + else: + respond_ts = original_question_ts + + action_values_ephemeral_message_channel_config = ( + ActionValuesEphemeralMessageChannelConfig( + channel_name=channel_conf.get("channel_name"), + respond_tag_only=channel_conf.get("respond_tag_only"), + respond_to_bots=channel_conf.get("respond_to_bots"), + is_ephemeral=channel_conf.get("is_ephemeral", False), + respond_member_group_list=channel_conf.get("respond_member_group_list"), + answer_filters=channel_conf.get("answer_filters"), + follow_up_tags=channel_conf.get("follow_up_tags"), + show_continue_in_web_ui=channel_conf.get("show_continue_in_web_ui", False), + ) + ) + + action_values_ephemeral_message_message_info = ( + ActionValuesEphemeralMessageMessageInfo( + bypass_filters=message_info.bypass_filters, + channel_to_respond=message_info.channel_to_respond, + msg_to_respond=message_info.msg_to_respond, + email=message_info.email, + sender_id=message_info.sender_id, + thread_messages=[], + is_bot_msg=message_info.is_bot_msg, + is_bot_dm=message_info.is_bot_dm, + thread_to_respond=respond_ts, + ) + ) + + action_values_ephemeral_message = ActionValuesEphemeralMessage( + original_question_ts=original_question_ts, + feedback_reminder_id=feedback_reminder_id, + chat_message_id=chat_message_id, + message_info=action_values_ephemeral_message_message_info, + channel_conf=action_values_ephemeral_message_channel_config, + ) + + return ActionsBlock( + block_id=build_publish_ephemeral_message_id(original_question_ts), + elements=[ + ButtonElement( + action_id=SHOW_EVERYONE_ACTION_ID, + text="📢 Share with Everyone", + value=action_values_ephemeral_message.model_dump_json(), + ), + ButtonElement( + action_id=KEEP_TO_YOURSELF_ACTION_ID, + text="🤫 Keep to Yourself", + value=action_values_ephemeral_message.model_dump_json(), + ), + ], + ) + + def get_document_feedback_blocks() -> Block: return SectionBlock( text=( @@ -486,16 +563,21 @@ def build_slack_response_blocks( use_citations: bool, feedback_reminder_id: str | None, skip_ai_feedback: bool = False, + offer_ephemeral_publication: bool = False, expecting_search_result: bool = False, + skip_restated_question: bool = False, ) -> list[Block]: """ This function is a top level function that builds all the blocks for the Slack response. It also handles combining all the blocks together. """ # If called with the OnyxBot slash command, the question is lost so we have to reshow it - restate_question_block = get_restate_blocks( - message_info.thread_messages[-1].message, message_info.is_bot_msg - ) + if not skip_restated_question: + restate_question_block = get_restate_blocks( + message_info.thread_messages[-1].message, message_info.is_bot_msg + ) + else: + restate_question_block = [] if expecting_search_result: answer_blocks = _build_qa_response_blocks( @@ -520,12 +602,36 @@ def build_slack_response_blocks( ) follow_up_block = [] - if channel_conf and channel_conf.get("follow_up_tags") is not None: + if ( + channel_conf + and channel_conf.get("follow_up_tags") is not None + and not channel_conf.get("is_ephemeral", False) + ): follow_up_block.append( _build_follow_up_block(message_id=answer.chat_message_id) ) - ai_feedback_block = [] + publish_ephemeral_message_block = [] + + if ( + offer_ephemeral_publication + and answer.chat_message_id is not None + and message_info.msg_to_respond is not None + and channel_conf is not None + ): + publish_ephemeral_message_block.append( + _build_ephemeral_publication_block( + channel_id=message_info.channel_to_respond, + chat_message_id=answer.chat_message_id, + original_question_ts=message_info.msg_to_respond, + message_info=message_info, + channel_conf=channel_conf, + feedback_reminder_id=feedback_reminder_id, + ) + ) + + ai_feedback_block: list[Block] = [] + if answer.chat_message_id is not None and not skip_ai_feedback: ai_feedback_block.append( _build_qa_feedback_block( @@ -547,6 +653,7 @@ def build_slack_response_blocks( all_blocks = ( restate_question_block + answer_blocks + + publish_ephemeral_message_block + ai_feedback_block + citations_divider + citations_blocks diff --git a/backend/onyx/onyxbot/slack/constants.py b/backend/onyx/onyxbot/slack/constants.py index 6a5b3ed43..1f2d4ed68 100644 --- a/backend/onyx/onyxbot/slack/constants.py +++ b/backend/onyx/onyxbot/slack/constants.py @@ -2,6 +2,8 @@ from enum import Enum LIKE_BLOCK_ACTION_ID = "feedback-like" DISLIKE_BLOCK_ACTION_ID = "feedback-dislike" +SHOW_EVERYONE_ACTION_ID = "show-everyone" +KEEP_TO_YOURSELF_ACTION_ID = "keep-to-yourself" CONTINUE_IN_WEB_UI_ACTION_ID = "continue-in-web-ui" FEEDBACK_DOC_BUTTON_BLOCK_ACTION_ID = "feedback-doc-button" IMMEDIATE_RESOLVED_BUTTON_ACTION_ID = "immediate-resolved-button" diff --git a/backend/onyx/onyxbot/slack/handlers/handle_buttons.py b/backend/onyx/onyxbot/slack/handlers/handle_buttons.py index 548e3ebfc..9d2693d65 100644 --- a/backend/onyx/onyxbot/slack/handlers/handle_buttons.py +++ b/backend/onyx/onyxbot/slack/handlers/handle_buttons.py @@ -1,3 +1,4 @@ +import json from typing import Any from typing import cast @@ -5,21 +6,32 @@ from slack_sdk import WebClient from slack_sdk.models.blocks import SectionBlock from slack_sdk.models.views import View from slack_sdk.socket_mode.request import SocketModeRequest +from slack_sdk.webhook import WebhookClient +from onyx.chat.models import ChatOnyxBotResponse +from onyx.chat.models import CitationInfo +from onyx.chat.models import QADocsResponse from onyx.configs.constants import MessageType from onyx.configs.constants import SearchFeedbackType from onyx.configs.onyxbot_configs import DANSWER_FOLLOWUP_EMOJI from onyx.connectors.slack.utils import expert_info_from_slack_id from onyx.connectors.slack.utils import make_slack_api_rate_limited +from onyx.context.search.models import SavedSearchDoc +from onyx.db.chat import get_chat_message +from onyx.db.chat import translate_db_message_to_chat_message_detail from onyx.db.engine import get_session_with_current_tenant from onyx.db.feedback import create_chat_message_feedback from onyx.db.feedback import create_doc_retrieval_feedback +from onyx.db.users import get_user_by_email from onyx.onyxbot.slack.blocks import build_follow_up_resolved_blocks +from onyx.onyxbot.slack.blocks import build_slack_response_blocks from onyx.onyxbot.slack.blocks import get_document_feedback_blocks from onyx.onyxbot.slack.config import get_slack_channel_config_for_bot_and_channel from onyx.onyxbot.slack.constants import DISLIKE_BLOCK_ACTION_ID from onyx.onyxbot.slack.constants import FeedbackVisibility +from onyx.onyxbot.slack.constants import KEEP_TO_YOURSELF_ACTION_ID from onyx.onyxbot.slack.constants import LIKE_BLOCK_ACTION_ID +from onyx.onyxbot.slack.constants import SHOW_EVERYONE_ACTION_ID from onyx.onyxbot.slack.constants import VIEW_DOC_FEEDBACK_ID from onyx.onyxbot.slack.handlers.handle_message import ( remove_scheduled_feedback_reminder, @@ -35,15 +47,48 @@ from onyx.onyxbot.slack.utils import fetch_slack_user_ids_from_emails from onyx.onyxbot.slack.utils import get_channel_name_from_id from onyx.onyxbot.slack.utils import get_feedback_visibility from onyx.onyxbot.slack.utils import read_slack_thread -from onyx.onyxbot.slack.utils import respond_in_thread +from onyx.onyxbot.slack.utils import respond_in_thread_or_channel from onyx.onyxbot.slack.utils import TenantSocketModeClient from onyx.onyxbot.slack.utils import update_emote_react +from onyx.server.query_and_chat.models import ChatMessageDetail from onyx.utils.logger import setup_logger logger = setup_logger() +def _convert_db_doc_id_to_document_ids( + citation_dict: dict[int, int], top_documents: list[SavedSearchDoc] +) -> list[CitationInfo]: + citation_list_with_document_id = [] + for citation_num, db_doc_id in citation_dict.items(): + if db_doc_id is not None: + matching_doc = next( + (d for d in top_documents if d.db_doc_id == db_doc_id), None + ) + if matching_doc: + citation_list_with_document_id.append( + CitationInfo( + citation_num=citation_num, document_id=matching_doc.document_id + ) + ) + return citation_list_with_document_id + + +def _build_citation_list(chat_message_detail: ChatMessageDetail) -> list[CitationInfo]: + citation_dict = chat_message_detail.citations + if citation_dict is None: + return [] + else: + top_documents = ( + chat_message_detail.context_docs.top_documents + if chat_message_detail.context_docs + else [] + ) + citation_list = _convert_db_doc_id_to_document_ids(citation_dict, top_documents) + return citation_list + + def handle_doc_feedback_button( req: SocketModeRequest, client: TenantSocketModeClient, @@ -58,7 +103,7 @@ def handle_doc_feedback_button( external_id = build_feedback_id(query_event_id, doc_id, doc_rank) channel_id = req.payload["container"]["channel_id"] - thread_ts = req.payload["container"]["thread_ts"] + thread_ts = req.payload["container"].get("thread_ts", None) data = View( type="modal", @@ -84,7 +129,7 @@ def handle_generate_answer_button( channel_id = req.payload["channel"]["id"] channel_name = req.payload["channel"]["name"] message_ts = req.payload["message"]["ts"] - thread_ts = req.payload["container"]["thread_ts"] + thread_ts = req.payload["container"].get("thread_ts", None) user_id = req.payload["user"]["id"] expert_info = expert_info_from_slack_id(user_id, client.web_client, user_cache={}) email = expert_info.email if expert_info else None @@ -106,7 +151,7 @@ def handle_generate_answer_button( # tell the user that we're working on it # Send an ephemeral message to the user that we're generating the answer - respond_in_thread( + respond_in_thread_or_channel( client=client.web_client, channel=channel_id, receiver_ids=[user_id], @@ -142,6 +187,178 @@ def handle_generate_answer_button( ) +def handle_publish_ephemeral_message_button( + req: SocketModeRequest, + client: TenantSocketModeClient, + action_id: str, +) -> None: + """ + This function handles the Share with Everyone/Keep for Yourself buttons + for ephemeral messages. + """ + channel_id = req.payload["channel"]["id"] + ephemeral_message_ts = req.payload["container"]["message_ts"] + + slack_sender_id = req.payload["user"]["id"] + response_url = req.payload["response_url"] + webhook = WebhookClient(url=response_url) + + # The additional data required that was added to buttons. + # Specifically, this contains the message_info, channel_conf information + # and some additional attributes. + value_dict = json.loads(req.payload["actions"][0]["value"]) + + original_question_ts = value_dict.get("original_question_ts") + if not original_question_ts: + raise ValueError("Missing original_question_ts in the payload") + if not ephemeral_message_ts: + raise ValueError("Missing ephemeral_message_ts in the payload") + + feedback_reminder_id = value_dict.get("feedback_reminder_id") + + slack_message_info = SlackMessageInfo(**value_dict["message_info"]) + channel_conf = value_dict.get("channel_conf") + + user_email = value_dict.get("message_info", {}).get("email") + + chat_message_id = value_dict.get("chat_message_id") + + # Obtain onyx_user and chat_message information + if not chat_message_id: + raise ValueError("Missing chat_message_id in the payload") + + with get_session_with_current_tenant() as db_session: + onyx_user = get_user_by_email(user_email, db_session) + if not onyx_user: + raise ValueError("Cannot determine onyx_user_id from email in payload") + try: + chat_message = get_chat_message(chat_message_id, onyx_user.id, db_session) + except ValueError: + chat_message = get_chat_message( + chat_message_id, None, db_session + ) # is this good idea? + except Exception as e: + logger.error(f"Failed to get chat message: {e}") + raise e + + chat_message_detail = translate_db_message_to_chat_message_detail(chat_message) + + # construct the proper citation format and then the answer in the suitable format + # we need to construct the blocks. + citation_list = _build_citation_list(chat_message_detail) + + onyx_bot_answer = ChatOnyxBotResponse( + answer=chat_message_detail.message, + citations=citation_list, + chat_message_id=chat_message_id, + docs=QADocsResponse( + top_documents=chat_message_detail.context_docs.top_documents + if chat_message_detail.context_docs + else [], + predicted_flow=None, + predicted_search=None, + applied_source_filters=None, + applied_time_cutoff=None, + recency_bias_multiplier=1.0, + ), + llm_selected_doc_indices=None, + error_msg=None, + ) + + # Note: we need to use the webhook and the respond_url to update/delete ephemeral messages + if action_id == SHOW_EVERYONE_ACTION_ID: + # Convert to non-ephemeral message in thread + try: + webhook.send( + response_type="ephemeral", + text="", + blocks=[], + replace_original=True, + delete_original=True, + ) + except Exception as e: + logger.error(f"Failed to send webhook: {e}") + + # remove handling of empheremal block and add AI feedback. + all_blocks = build_slack_response_blocks( + answer=onyx_bot_answer, + message_info=slack_message_info, + channel_conf=channel_conf, + use_citations=True, + feedback_reminder_id=feedback_reminder_id, + skip_ai_feedback=False, + offer_ephemeral_publication=False, + skip_restated_question=True, + ) + try: + # Post in thread as non-ephemeral message + respond_in_thread_or_channel( + client=client.web_client, + channel=channel_id, + receiver_ids=None, # If respond_member_group_list is set, send to them. TODO: check! + text="Hello! Onyx has some results for you!", + blocks=all_blocks, + thread_ts=original_question_ts, + # don't unfurl, since otherwise we will have 5+ previews which makes the message very long + unfurl=False, + send_as_ephemeral=False, + ) + except Exception as e: + logger.error(f"Failed to publish ephemeral message: {e}") + raise e + + elif action_id == KEEP_TO_YOURSELF_ACTION_ID: + # Keep as ephemeral message in channel or thread, but remove the publish button and add feedback button + + changed_blocks = build_slack_response_blocks( + answer=onyx_bot_answer, + message_info=slack_message_info, + channel_conf=channel_conf, + use_citations=True, + feedback_reminder_id=feedback_reminder_id, + skip_ai_feedback=False, + offer_ephemeral_publication=False, + skip_restated_question=True, + ) + + try: + if slack_message_info.thread_to_respond is not None: + # There seems to be a bug in slack where an update within the thread + # actually leads to the update to be posted in the channel. Therefore, + # for now we delete the original ephemeral message and post a new one + # if the ephemeral message is in a thread. + webhook.send( + response_type="ephemeral", + text="", + blocks=[], + replace_original=True, + delete_original=True, + ) + + respond_in_thread_or_channel( + client=client.web_client, + channel=channel_id, + receiver_ids=[slack_sender_id], + text="Your personal response, sent as an ephemeral message.", + blocks=changed_blocks, + thread_ts=original_question_ts, + # don't unfurl, since otherwise we will have 5+ previews which makes the message very long + unfurl=False, + send_as_ephemeral=True, + ) + else: + # This works fine if the ephemeral message is in the channel + webhook.send( + response_type="ephemeral", + text="Your personal response, sent as an ephemeral message.", + blocks=changed_blocks, + replace_original=True, + delete_original=False, + ) + except Exception as e: + logger.error(f"Failed to send webhook: {e}") + + def handle_slack_feedback( feedback_id: str, feedback_type: str, @@ -153,13 +370,20 @@ def handle_slack_feedback( ) -> None: message_id, doc_id, doc_rank = decompose_action_id(feedback_id) + # Get Onyx user from Slack ID + expert_info = expert_info_from_slack_id( + user_id_to_post_confirmation, client, user_cache={} + ) + email = expert_info.email if expert_info else None + with get_session_with_current_tenant() as db_session: + onyx_user = get_user_by_email(email, db_session) if email else None if feedback_type in [LIKE_BLOCK_ACTION_ID, DISLIKE_BLOCK_ACTION_ID]: create_chat_message_feedback( is_positive=feedback_type == LIKE_BLOCK_ACTION_ID, feedback_text="", chat_message_id=message_id, - user_id=None, # no "user" for Slack bot for now + user_id=onyx_user.id if onyx_user else None, db_session=db_session, ) remove_scheduled_feedback_reminder( @@ -213,7 +437,7 @@ def handle_slack_feedback( else: msg = f"<@{user_id_to_post_confirmation}> has {feedback_response_txt} the AI Answer" - respond_in_thread( + respond_in_thread_or_channel( client=client, channel=channel_id_to_post_confirmation, text=msg, @@ -232,7 +456,7 @@ def handle_followup_button( action_id = cast(str, action.get("block_id")) channel_id = req.payload["container"]["channel_id"] - thread_ts = req.payload["container"]["thread_ts"] + thread_ts = req.payload["container"].get("thread_ts", None) update_emote_react( emoji=DANSWER_FOLLOWUP_EMOJI, @@ -265,7 +489,7 @@ def handle_followup_button( blocks = build_follow_up_resolved_blocks(tag_ids=tag_ids, group_ids=group_ids) - respond_in_thread( + respond_in_thread_or_channel( client=client.web_client, channel=channel_id, text="Received your request for more help", @@ -315,7 +539,7 @@ def handle_followup_resolved_button( ) -> None: channel_id = req.payload["container"]["channel_id"] message_ts = req.payload["container"]["message_ts"] - thread_ts = req.payload["container"]["thread_ts"] + thread_ts = req.payload["container"].get("thread_ts", None) clicker_name = get_clicker_name(req, client) @@ -349,7 +573,7 @@ def handle_followup_resolved_button( resolved_block = SectionBlock(text=msg_text) - respond_in_thread( + respond_in_thread_or_channel( client=client.web_client, channel=channel_id, text="Your request for help as been addressed!", diff --git a/backend/onyx/onyxbot/slack/handlers/handle_message.py b/backend/onyx/onyxbot/slack/handlers/handle_message.py index 96e79cb45..ec91257ef 100644 --- a/backend/onyx/onyxbot/slack/handlers/handle_message.py +++ b/backend/onyx/onyxbot/slack/handlers/handle_message.py @@ -18,7 +18,7 @@ from onyx.onyxbot.slack.handlers.handle_standard_answers import ( from onyx.onyxbot.slack.models import SlackMessageInfo from onyx.onyxbot.slack.utils import fetch_slack_user_ids_from_emails from onyx.onyxbot.slack.utils import fetch_user_ids_from_groups -from onyx.onyxbot.slack.utils import respond_in_thread +from onyx.onyxbot.slack.utils import respond_in_thread_or_channel from onyx.onyxbot.slack.utils import slack_usage_report from onyx.onyxbot.slack.utils import update_emote_react from onyx.utils.logger import setup_logger @@ -29,7 +29,7 @@ logger_base = setup_logger() def send_msg_ack_to_user(details: SlackMessageInfo, client: WebClient) -> None: if details.is_bot_msg and details.sender_id: - respond_in_thread( + respond_in_thread_or_channel( client=client, channel=details.channel_to_respond, thread_ts=details.msg_to_respond, @@ -202,7 +202,7 @@ def handle_message( # which would just respond to the sender if send_to and is_bot_msg: if sender_id: - respond_in_thread( + respond_in_thread_or_channel( client=client, channel=channel, receiver_ids=[sender_id], @@ -220,6 +220,7 @@ def handle_message( add_slack_user_if_not_exists(db_session, message_info.email) # first check if we need to respond with a standard answer + # standard answers should be published in a thread used_standard_answer = handle_standard_answers( message_info=message_info, receiver_ids=send_to, diff --git a/backend/onyx/onyxbot/slack/handlers/handle_regular_answer.py b/backend/onyx/onyxbot/slack/handlers/handle_regular_answer.py index 73b123a32..02ce29372 100644 --- a/backend/onyx/onyxbot/slack/handlers/handle_regular_answer.py +++ b/backend/onyx/onyxbot/slack/handlers/handle_regular_answer.py @@ -33,7 +33,7 @@ from onyx.onyxbot.slack.blocks import build_slack_response_blocks from onyx.onyxbot.slack.handlers.utils import send_team_member_message from onyx.onyxbot.slack.handlers.utils import slackify_message_thread from onyx.onyxbot.slack.models import SlackMessageInfo -from onyx.onyxbot.slack.utils import respond_in_thread +from onyx.onyxbot.slack.utils import respond_in_thread_or_channel from onyx.onyxbot.slack.utils import SlackRateLimiter from onyx.onyxbot.slack.utils import update_emote_react from onyx.server.query_and_chat.models import CreateChatMessageRequest @@ -82,12 +82,38 @@ def handle_regular_answer( message_ts_to_respond_to = message_info.msg_to_respond is_bot_msg = message_info.is_bot_msg + + # Capture whether response mode for channel is ephemeral. Even if the channel is set + # to respond with an ephemeral message, we still send as non-ephemeral if + # the message is a dm with the Onyx bot. + send_as_ephemeral = ( + slack_channel_config.channel_config.get("is_ephemeral", False) + and not message_info.is_bot_dm + ) + + # If the channel mis configured to respond with an ephemeral message, + # or the message is a dm to the Onyx bot, we should use the proper onyx user from the email. + # This will make documents privately accessible to the user available to Onyx Bot answers. + # Otherwise - if not ephemeral or DM to Onyx Bot - we must use None as the user to restrict + # to public docs. + user = None - if message_info.is_bot_dm: + if message_info.is_bot_dm or send_as_ephemeral: if message_info.email: with get_session_with_current_tenant() as db_session: user = get_user_by_email(message_info.email, db_session) + target_thread_ts = ( + None + if send_as_ephemeral and len(message_info.thread_messages) < 2 + else message_ts_to_respond_to + ) + target_receiver_ids = ( + [message_info.sender_id] + if message_info.sender_id and send_as_ephemeral + else receiver_ids + ) + document_set_names: list[str] | None = None prompt = None # If no persona is specified, use the default search based persona @@ -134,11 +160,10 @@ def handle_regular_answer( history_messages = messages[:-1] single_message_history = slackify_message_thread(history_messages) or None + # Always check for ACL permissions, also for documnt sets that were explicitly added + # to the Bot by the Administrator. (Change relative to earlier behavior where all documents + # in an attached document set were available to all users in the channel.) bypass_acl = False - 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 if not message_ts_to_respond_to and not is_bot_msg: # if the message is not "/onyx" command, then it should have a message ts to respond to @@ -219,12 +244,13 @@ def handle_regular_answer( # Optionally, respond in thread with the error message, Used primarily # for debugging purposes if should_respond_with_error_msgs: - respond_in_thread( + respond_in_thread_or_channel( client=client, channel=channel, - receiver_ids=None, + receiver_ids=target_receiver_ids, text=f"Encountered exception when trying to answer: \n\n```{e}```", - thread_ts=message_ts_to_respond_to, + thread_ts=target_thread_ts, + send_as_ephemeral=send_as_ephemeral, ) # In case of failures, don't keep the reaction there permanently @@ -242,32 +268,36 @@ def handle_regular_answer( if answer is None: assert DISABLE_GENERATIVE_AI is True try: - respond_in_thread( + respond_in_thread_or_channel( client=client, channel=channel, - receiver_ids=receiver_ids, + receiver_ids=target_receiver_ids, text="Hello! Onyx has some results for you!", blocks=[ SectionBlock( text="Onyx is down for maintenance.\nWe're working hard on recharging the AI!" ) ], - thread_ts=message_ts_to_respond_to, + thread_ts=target_thread_ts, + send_as_ephemeral=send_as_ephemeral, # don't unfurl, since otherwise we will have 5+ previews which makes the message very long unfurl=False, ) # For DM (ephemeral message), we need to create a thread via a normal message so the user can see # the ephemeral message. This also will give the user a notification which ephemeral message does not. - if receiver_ids: - respond_in_thread( + + # If the channel is ephemeral, we don't need to send a message to the user since they will already see the message + if target_receiver_ids and not send_as_ephemeral: + respond_in_thread_or_channel( client=client, channel=channel, text=( "👋 Hi, we've just gathered and forwarded the relevant " + "information to the team. They'll get back to you shortly!" ), - thread_ts=message_ts_to_respond_to, + thread_ts=target_thread_ts, + send_as_ephemeral=send_as_ephemeral, ) return False @@ -316,12 +346,13 @@ def handle_regular_answer( # Optionally, respond in thread with the error message # Used primarily for debugging purposes if should_respond_with_error_msgs: - respond_in_thread( + respond_in_thread_or_channel( client=client, channel=channel, - receiver_ids=None, + receiver_ids=target_receiver_ids, text="Found no documents when trying to answer. Did you index any documents?", - thread_ts=message_ts_to_respond_to, + thread_ts=target_thread_ts, + send_as_ephemeral=send_as_ephemeral, ) return True @@ -349,15 +380,27 @@ def handle_regular_answer( # Optionally, respond in thread with the error message # Used primarily for debugging purposes if should_respond_with_error_msgs: - respond_in_thread( + respond_in_thread_or_channel( client=client, channel=channel, - receiver_ids=None, + receiver_ids=target_receiver_ids, text="Found no citations or quotes when trying to answer.", - thread_ts=message_ts_to_respond_to, + thread_ts=target_thread_ts, + send_as_ephemeral=send_as_ephemeral, ) return True + if ( + send_as_ephemeral + and target_receiver_ids is not None + and len(target_receiver_ids) == 1 + ): + offer_ephemeral_publication = True + skip_ai_feedback = True + else: + offer_ephemeral_publication = False + skip_ai_feedback = False if feedback_reminder_id else True + all_blocks = build_slack_response_blocks( message_info=message_info, answer=answer, @@ -365,31 +408,39 @@ def handle_regular_answer( use_citations=True, # No longer supporting quotes feedback_reminder_id=feedback_reminder_id, expecting_search_result=expecting_search_result, + offer_ephemeral_publication=offer_ephemeral_publication, + skip_ai_feedback=skip_ai_feedback, ) try: - respond_in_thread( + respond_in_thread_or_channel( client=client, channel=channel, - receiver_ids=[message_info.sender_id] - if message_info.is_bot_msg and message_info.sender_id - else receiver_ids, + receiver_ids=target_receiver_ids, text="Hello! Onyx has some results for you!", blocks=all_blocks, - thread_ts=message_ts_to_respond_to, + thread_ts=target_thread_ts, # don't unfurl, since otherwise we will have 5+ previews which makes the message very long unfurl=False, + send_as_ephemeral=send_as_ephemeral, ) # For DM (ephemeral message), we need to create a thread via a normal message so the user can see # the ephemeral message. This also will give the user a notification which ephemeral message does not. # if there is no message_ts_to_respond_to, and we have made it this far, then this is a /onyx message # so we shouldn't send_team_member_message - if receiver_ids and message_ts_to_respond_to is not None: + if ( + target_receiver_ids + and message_ts_to_respond_to is not None + and not send_as_ephemeral + and target_thread_ts is not None + ): send_team_member_message( client=client, channel=channel, - thread_ts=message_ts_to_respond_to, + thread_ts=target_thread_ts, + receiver_ids=target_receiver_ids, + send_as_ephemeral=send_as_ephemeral, ) return False diff --git a/backend/onyx/onyxbot/slack/handlers/utils.py b/backend/onyx/onyxbot/slack/handlers/utils.py index ea8ab3288..83835e87b 100644 --- a/backend/onyx/onyxbot/slack/handlers/utils.py +++ b/backend/onyx/onyxbot/slack/handlers/utils.py @@ -2,7 +2,7 @@ from slack_sdk import WebClient from onyx.chat.models import ThreadMessage from onyx.configs.constants import MessageType -from onyx.onyxbot.slack.utils import respond_in_thread +from onyx.onyxbot.slack.utils import respond_in_thread_or_channel def slackify_message_thread(messages: list[ThreadMessage]) -> str: @@ -32,8 +32,10 @@ def send_team_member_message( client: WebClient, channel: str, thread_ts: str, + receiver_ids: list[str] | None = None, + send_as_ephemeral: bool = False, ) -> None: - respond_in_thread( + respond_in_thread_or_channel( client=client, channel=channel, text=( @@ -41,4 +43,6 @@ def send_team_member_message( + "information to the team. They'll get back to you shortly!" ), thread_ts=thread_ts, + receiver_ids=None, + send_as_ephemeral=send_as_ephemeral, ) diff --git a/backend/onyx/onyxbot/slack/listener.py b/backend/onyx/onyxbot/slack/listener.py index 2cec1a66e..75f662fd6 100644 --- a/backend/onyx/onyxbot/slack/listener.py +++ b/backend/onyx/onyxbot/slack/listener.py @@ -57,7 +57,9 @@ from onyx.onyxbot.slack.constants import FOLLOWUP_BUTTON_ACTION_ID from onyx.onyxbot.slack.constants import FOLLOWUP_BUTTON_RESOLVED_ACTION_ID from onyx.onyxbot.slack.constants import GENERATE_ANSWER_BUTTON_ACTION_ID from onyx.onyxbot.slack.constants import IMMEDIATE_RESOLVED_BUTTON_ACTION_ID +from onyx.onyxbot.slack.constants import KEEP_TO_YOURSELF_ACTION_ID from onyx.onyxbot.slack.constants import LIKE_BLOCK_ACTION_ID +from onyx.onyxbot.slack.constants import SHOW_EVERYONE_ACTION_ID from onyx.onyxbot.slack.constants import VIEW_DOC_FEEDBACK_ID from onyx.onyxbot.slack.handlers.handle_buttons import handle_doc_feedback_button from onyx.onyxbot.slack.handlers.handle_buttons import handle_followup_button @@ -67,6 +69,9 @@ from onyx.onyxbot.slack.handlers.handle_buttons import ( from onyx.onyxbot.slack.handlers.handle_buttons import ( handle_generate_answer_button, ) +from onyx.onyxbot.slack.handlers.handle_buttons import ( + handle_publish_ephemeral_message_button, +) from onyx.onyxbot.slack.handlers.handle_buttons import handle_slack_feedback from onyx.onyxbot.slack.handlers.handle_message import handle_message from onyx.onyxbot.slack.handlers.handle_message import ( @@ -81,7 +86,7 @@ from onyx.onyxbot.slack.utils import get_onyx_bot_slack_bot_id from onyx.onyxbot.slack.utils import read_slack_thread from onyx.onyxbot.slack.utils import remove_onyx_bot_tag from onyx.onyxbot.slack.utils import rephrase_slack_message -from onyx.onyxbot.slack.utils import respond_in_thread +from onyx.onyxbot.slack.utils import respond_in_thread_or_channel from onyx.onyxbot.slack.utils import TenantSocketModeClient from onyx.redis.redis_pool import get_redis_client from onyx.server.manage.models import SlackBotTokens @@ -667,7 +672,11 @@ def process_feedback(req: SocketModeRequest, client: TenantSocketModeClient) -> feedback_msg_reminder = cast(str, action.get("value")) feedback_id = cast(str, action.get("block_id")) channel_id = cast(str, req.payload["container"]["channel_id"]) - thread_ts = cast(str, req.payload["container"]["thread_ts"]) + thread_ts = cast( + str, + req.payload["container"].get("thread_ts") + or req.payload["container"].get("message_ts"), + ) else: logger.error("Unable to process feedback. Action not found") return @@ -783,7 +792,7 @@ def apologize_for_fail( details: SlackMessageInfo, client: TenantSocketModeClient, ) -> None: - respond_in_thread( + respond_in_thread_or_channel( client=client.web_client, channel=details.channel_to_respond, thread_ts=details.msg_to_respond, @@ -859,6 +868,14 @@ def action_routing(req: SocketModeRequest, client: TenantSocketModeClient) -> No if action["action_id"] in [DISLIKE_BLOCK_ACTION_ID, LIKE_BLOCK_ACTION_ID]: # AI Answer feedback return process_feedback(req, client) + elif action["action_id"] in [ + SHOW_EVERYONE_ACTION_ID, + KEEP_TO_YOURSELF_ACTION_ID, + ]: + # Publish ephemeral message or keep hidden in main channel + return handle_publish_ephemeral_message_button( + req, client, action["action_id"] + ) elif action["action_id"] == FEEDBACK_DOC_BUTTON_BLOCK_ACTION_ID: # Activation of the "source feedback" button return handle_doc_feedback_button(req, client) diff --git a/backend/onyx/onyxbot/slack/models.py b/backend/onyx/onyxbot/slack/models.py index f3cb6add2..81b8bf1f4 100644 --- a/backend/onyx/onyxbot/slack/models.py +++ b/backend/onyx/onyxbot/slack/models.py @@ -1,3 +1,5 @@ +from typing import Literal + from pydantic import BaseModel from onyx.chat.models import ThreadMessage @@ -13,3 +15,37 @@ class SlackMessageInfo(BaseModel): bypass_filters: bool # User has tagged @OnyxBot is_bot_msg: bool # User is using /OnyxBot is_bot_dm: bool # User is direct messaging to OnyxBot + + +# Models used to encode the relevant data for the ephemeral message actions +class ActionValuesEphemeralMessageMessageInfo(BaseModel): + bypass_filters: bool | None + channel_to_respond: str | None + msg_to_respond: str | None + email: str | None + sender_id: str | None + thread_messages: list[ThreadMessage] | None + is_bot_msg: bool | None + is_bot_dm: bool | None + thread_to_respond: str | None + + +class ActionValuesEphemeralMessageChannelConfig(BaseModel): + channel_name: str | None + respond_tag_only: bool | None + respond_to_bots: bool | None + is_ephemeral: bool + respond_member_group_list: list[str] | None + answer_filters: list[ + Literal["well_answered_postfilter", "questionmark_prefilter"] + ] | None + follow_up_tags: list[str] | None + show_continue_in_web_ui: bool + + +class ActionValuesEphemeralMessage(BaseModel): + original_question_ts: str | None + feedback_reminder_id: str | None + chat_message_id: int + message_info: ActionValuesEphemeralMessageMessageInfo + channel_conf: ActionValuesEphemeralMessageChannelConfig diff --git a/backend/onyx/onyxbot/slack/utils.py b/backend/onyx/onyxbot/slack/utils.py index 1f85ca2da..ee942fa99 100644 --- a/backend/onyx/onyxbot/slack/utils.py +++ b/backend/onyx/onyxbot/slack/utils.py @@ -184,7 +184,7 @@ def _build_error_block(error_message: str) -> Block: backoff=2, logger=cast(logging.Logger, logger), ) -def respond_in_thread( +def respond_in_thread_or_channel( client: WebClient, channel: str, thread_ts: str | None, @@ -193,6 +193,7 @@ def respond_in_thread( receiver_ids: list[str] | None = None, metadata: Metadata | None = None, unfurl: bool = True, + send_as_ephemeral: bool | None = True, ) -> list[str]: if not text and not blocks: raise ValueError("One of `text` or `blocks` must be provided") @@ -236,6 +237,7 @@ def respond_in_thread( message_ids.append(response["message_ts"]) else: slack_call = make_slack_api_rate_limited(client.chat_postEphemeral) + for receiver in receiver_ids: try: response = slack_call( @@ -299,6 +301,12 @@ def build_feedback_id( return unique_prefix + ID_SEPARATOR + feedback_id +def build_publish_ephemeral_message_id( + original_question_ts: str, +) -> str: + return "publish_ephemeral_message__" + original_question_ts + + def build_continue_in_web_ui_id( message_id: int, ) -> str: @@ -539,7 +547,7 @@ def read_slack_thread( # If auto-detected filters are on, use the second block for the actual answer # The first block is the auto-detected filters - if message.startswith("_Filters"): + if message is not None and message.startswith("_Filters"): if len(blocks) < 2: logger.warning(f"Only filter blocks found: {reply}") continue @@ -611,7 +619,7 @@ class SlackRateLimiter: def notify( self, client: WebClient, channel: str, position: int, thread_ts: str | None ) -> None: - respond_in_thread( + respond_in_thread_or_channel( client=client, channel=channel, receiver_ids=None, diff --git a/backend/onyx/server/manage/models.py b/backend/onyx/server/manage/models.py index 786b9074a..cf51a7b08 100644 --- a/backend/onyx/server/manage/models.py +++ b/backend/onyx/server/manage/models.py @@ -181,6 +181,7 @@ class SlackChannelConfigCreationRequest(BaseModel): channel_name: str respond_tag_only: bool = False respond_to_bots: bool = False + is_ephemeral: bool = False show_continue_in_web_ui: bool = False enable_auto_filters: bool = False # If no team members, assume respond in the channel to everyone diff --git a/backend/onyx/server/manage/slack_bot.py b/backend/onyx/server/manage/slack_bot.py index ec79e2c21..d47f8d828 100644 --- a/backend/onyx/server/manage/slack_bot.py +++ b/backend/onyx/server/manage/slack_bot.py @@ -71,6 +71,15 @@ def _form_channel_config( "also respond to a predetermined set of users." ) + if ( + slack_channel_config_creation_request.is_ephemeral + and slack_channel_config_creation_request.respond_member_group_list + ): + raise ValueError( + "Cannot set OnyxBot to respond to users in a private (ephemeral) message " + "and also respond to a selected list of users." + ) + channel_config: ChannelConfig = { "channel_name": cleaned_channel_name, } @@ -91,6 +100,8 @@ def _form_channel_config( "respond_to_bots" ] = slack_channel_config_creation_request.respond_to_bots + channel_config["is_ephemeral"] = slack_channel_config_creation_request.is_ephemeral + channel_config["disabled"] = slack_channel_config_creation_request.disabled return channel_config 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 503771d98..d1826e955 100644 --- a/web/src/app/admin/bots/[bot-id]/channels/SlackChannelConfigCreationForm.tsx +++ b/web/src/app/admin/bots/[bot-id]/channels/SlackChannelConfigCreationForm.tsx @@ -83,6 +83,8 @@ export const SlackChannelConfigCreationForm = ({ respond_tag_only: existingSlackChannelConfig?.channel_config?.respond_tag_only || false, + is_ephemeral: + existingSlackChannelConfig?.channel_config?.is_ephemeral || false, respond_to_bots: existingSlackChannelConfig?.channel_config?.respond_to_bots || false, @@ -135,6 +137,7 @@ export const SlackChannelConfigCreationForm = ({ questionmark_prefilter_enabled: Yup.boolean().required(), respond_tag_only: Yup.boolean().required(), respond_to_bots: Yup.boolean().required(), + is_ephemeral: Yup.boolean().required(), show_continue_in_web_ui: Yup.boolean().required(), enable_auto_filters: Yup.boolean().required(), respond_member_group_list: Yup.array().of(Yup.string()).required(), 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 92553b97b..1e82f04b9 100644 --- a/web/src/app/admin/bots/[bot-id]/channels/SlackChannelConfigFormFields.tsx +++ b/web/src/app/admin/bots/[bot-id]/channels/SlackChannelConfigFormFields.tsx @@ -597,6 +597,13 @@ export function SlackChannelConfigFormFields({ label="Respond to Bot messages" tooltip="If not set, OnyxBot will always ignore messages from Bots" /> +

- Please note that at least one of the documents accessible by - your OnyxBot is marked as private and may contain sensitive - information. These documents will be accessible to all users - of this OnyxBot. Ensure this aligns with your intended - document sharing policy. + Please note that if the private (ephemeral) response is *not + selected*, only public documents within the selected document + sets will be accessible for user queries. If the private + (ephemeral) response *is selected*, user quries can also + leverage documents that the user has already been granted + access to. Note that users will be able to share the response + with others in the channel, so please ensure that this is + aligned with your company sharing policies.

diff --git a/web/src/app/admin/bots/[bot-id]/lib.ts b/web/src/app/admin/bots/[bot-id]/lib.ts index 5c72c9f20..d9058d188 100644 --- a/web/src/app/admin/bots/[bot-id]/lib.ts +++ b/web/src/app/admin/bots/[bot-id]/lib.ts @@ -14,6 +14,7 @@ interface SlackChannelConfigCreationRequest { answer_validity_check_enabled: boolean; questionmark_prefilter_enabled: boolean; respond_tag_only: boolean; + is_ephemeral: boolean; respond_to_bots: boolean; show_continue_in_web_ui: boolean; respond_member_group_list: string[]; @@ -45,6 +46,7 @@ const buildRequestBodyFromCreationRequest = ( channel_name: creationRequest.channel_name, respond_tag_only: creationRequest.respond_tag_only, respond_to_bots: creationRequest.respond_to_bots, + is_ephemeral: creationRequest.is_ephemeral, show_continue_in_web_ui: creationRequest.show_continue_in_web_ui, enable_auto_filters: creationRequest.enable_auto_filters, respond_member_group_list: creationRequest.respond_member_group_list, diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 70476663c..c667bc156 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -273,6 +273,7 @@ export interface ChannelConfig { channel_name: string; respond_tag_only?: boolean; respond_to_bots?: boolean; + is_ephemeral?: boolean; show_continue_in_web_ui?: boolean; respond_member_group_list?: string[]; answer_filters?: AnswerFilterOption[];