From de24797085a0e88a986488d3a6624368ed6eb67d Mon Sep 17 00:00:00 2001 From: Jashon Osala <64925863+osala-eng@users.noreply.github.com> Date: Sat, 6 Apr 2024 22:47:37 +0300 Subject: [PATCH] feat: Slack chat integration. (#236) This feature is a Docq Slack bot that can be added to a Slack workspace. When invited into a channel, users can [at] mention the bot and ask questions. Docq org admins can configure a channel the bot is in to be associated with a space group. *adds Slack auth and event handling endpoints * refactor(UI)(admin section): Rename tab names, drop the "admin prefix" * bump version v0.9.7 -> v0.10.0 * make the redirect relative to the current domain rather than based on the configured server address. * refactor(slack): remove defaulting to ENV VARS. set explicitly --------- Co-authored-by: Janaka Abeywardhana --- poetry.lock | 57 +++-- pyproject.toml | 3 +- source/docq/config.py | 4 +- source/docq/integrations/__init__.py | 8 + .../docq/integrations/slack/manage_slack.py | 226 ++++++++++++++++++ .../slack/manage_slack_messages.py | 81 +++++++ source/docq/integrations/slack/models.py | 42 ++++ .../integrations/slack/slack_application.py | 69 ++++++ .../integrations/slack/slack_oauth_flow.py | 172 +++++++++++++ source/docq/setup.py | 2 + source/docq/support/auth_utils.py | 53 +++- source/docq/support/store.py | 11 + web/admin/admin_integrations.py | 37 +++ web/admin/admin_settings.py | 4 +- web/admin/index.py | 103 ++++++-- web/admin_spaces.py | 5 +- web/api/index_handler.py | 2 + web/api/integration/slack/app_home.py | 60 +++++ web/api/integration/slack/chat_handler.py | 38 +++ web/api/integration/slack/index_handler.py | 52 ++++ .../slack/slack_request_handlers.py | 20 ++ web/api/integration/slack/slack_utils.py | 97 ++++++++ web/api/integration/utils.py | 32 +++ web/utils/handlers.py | 77 +++++- web/utils/layout.py | 126 +++++++--- web/utils/streamlit_application.py | 6 +- 26 files changed, 1298 insertions(+), 89 deletions(-) create mode 100644 source/docq/integrations/__init__.py create mode 100755 source/docq/integrations/slack/manage_slack.py create mode 100644 source/docq/integrations/slack/manage_slack_messages.py create mode 100644 source/docq/integrations/slack/models.py create mode 100644 source/docq/integrations/slack/slack_application.py create mode 100644 source/docq/integrations/slack/slack_oauth_flow.py create mode 100644 web/admin/admin_integrations.py create mode 100644 web/api/integration/slack/app_home.py create mode 100644 web/api/integration/slack/chat_handler.py create mode 100644 web/api/integration/slack/index_handler.py create mode 100644 web/api/integration/slack/slack_request_handlers.py create mode 100644 web/api/integration/slack/slack_utils.py create mode 100644 web/api/integration/utils.py diff --git a/poetry.lock b/poetry.lock index 759a89bf..6dee4b3b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. [[package]] name = "accelerate" @@ -1359,11 +1359,11 @@ files = [ google-auth = ">=2.14.1,<3.0.dev0" googleapis-common-protos = ">=1.56.2,<2.0.dev0" grpcio = [ - {version = ">=1.33.2,<2.0dev", optional = true, markers = "python_version < \"3.11\" and extra == \"grpc\""}, + {version = ">=1.33.2,<2.0dev", optional = true, markers = "extra == \"grpc\""}, {version = ">=1.49.1,<2.0dev", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}, ] grpcio-status = [ - {version = ">=1.33.2,<2.0.dev0", optional = true, markers = "python_version < \"3.11\" and extra == \"grpc\""}, + {version = ">=1.33.2,<2.0.dev0", optional = true, markers = "extra == \"grpc\""}, {version = ">=1.49.1,<2.0.dev0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}, ] protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0.dev0" @@ -2527,16 +2527,6 @@ files = [ {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, @@ -3195,7 +3185,6 @@ optional = false python-versions = ">=3" files = [ {file = "nvidia_nvjitlink_cu12-12.3.101-py3-none-manylinux1_x86_64.whl", hash = "sha256:64335a8088e2b9d196ae8665430bc6a2b7e6ef2eb877a9c735c804bd4ff6467c"}, - {file = "nvidia_nvjitlink_cu12-12.3.101-py3-none-manylinux2014_aarch64.whl", hash = "sha256:211a63e7b30a9d62f1a853e19928fbb1a750e3f17a13a3d1f98ff0ced19478dd"}, {file = "nvidia_nvjitlink_cu12-12.3.101-py3-none-win_amd64.whl", hash = "sha256:1b2e317e437433753530792f13eece58f0aec21a2b05903be7bffe58a606cbd1"}, ] @@ -5134,7 +5123,6 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -6001,6 +5989,41 @@ files = [ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] +[[package]] +name = "slack-bolt" +version = "1.18.1" +description = "The Bolt Framework for Python" +optional = false +python-versions = ">=3.6" +files = [ + {file = "slack_bolt-1.18.1-py2.py3-none-any.whl", hash = "sha256:2509e5bb43898a593667bf37965057a9b9a41008b29628e3b57a6136b650b90e"}, + {file = "slack_bolt-1.18.1.tar.gz", hash = "sha256:694f84a81ba1c4c428ba7daa01d599d3e9fba7a54ad10c11008aa22573b23ff0"}, +] + +[package.dependencies] +slack-sdk = ">=3.25.0,<4" + +[package.extras] +adapter = ["CherryPy (>=18,<19)", "Django (>=3,<5)", "Flask (>=1,<3)", "Werkzeug (>=2,<3)", "boto3 (<=2)", "bottle (>=0.12,<1)", "chalice (>=1.28,<2)", "falcon (>=2,<4)", "fastapi (>=0.70.0,<1)", "gunicorn (>=20,<21)", "pyramid (>=1,<3)", "sanic (>=22,<23)", "starlette (>=0.14,<1)", "tornado (>=6,<7)", "uvicorn (<1)", "websocket-client (>=1.2.3,<2)"] +adapter-testing = ["Flask (>=1,<2)", "Werkzeug (>=1,<2)", "boddle (>=0.2,<0.3)", "docker (>=5,<6)", "moto (>=3,<4)", "requests (>=2,<3)", "sanic-testing (>=0.7)"] +async = ["aiohttp (>=3,<4)", "websockets (>=10,<11)"] +testing = ["Flask-Sockets (>=0.2,<1)", "Jinja2 (==3.0.3)", "Werkzeug (>=1,<2)", "aiohttp (>=3,<4)", "black (==22.8.0)", "click (<=8.0.4)", "itsdangerous (==2.0.1)", "pytest (>=6.2.5,<7)", "pytest-asyncio (>=0.18.2,<1)", "pytest-cov (>=3,<4)"] +testing-without-asyncio = ["Flask-Sockets (>=0.2,<1)", "Jinja2 (==3.0.3)", "Werkzeug (>=1,<2)", "black (==22.8.0)", "click (<=8.0.4)", "itsdangerous (==2.0.1)", "pytest (>=6.2.5,<7)", "pytest-cov (>=3,<4)"] + +[[package]] +name = "slack-sdk" +version = "3.27.1" +description = "The Slack API Platform SDK for Python" +optional = false +python-versions = ">=3.6" +files = [ + {file = "slack_sdk-3.27.1-py2.py3-none-any.whl", hash = "sha256:c108e509160cf1324c5c8b1f47ca52fb5e287021b8caf9f4ec78ad737ab7b1d9"}, + {file = "slack_sdk-3.27.1.tar.gz", hash = "sha256:85d86b34d807c26c8bb33c1569ec0985876f06ae4a2692afba765b7a5490d28c"}, +] + +[package.extras] +optional = ["SQLAlchemy (>=1.4,<3)", "aiodns (>1.0)", "aiohttp (>=3.7.3,<4)", "boto3 (<=2)", "websocket-client (>=1,<2)", "websockets (>=10,<11)", "websockets (>=9.1,<10)"] + [[package]] name = "smmap" version = "5.0.1" @@ -6121,7 +6144,7 @@ files = [ ] [package.dependencies] -greenlet = {version = "!=0.4.17", optional = true, markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\" or extra == \"asyncio\""} +greenlet = {version = "!=0.4.17", optional = true, markers = "platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\" or extra == \"asyncio\""} typing-extensions = ">=4.6.0" [package.extras] @@ -7270,4 +7293,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = ">=3.10,<3.12" -content-hash = "1f3a6d65e7a8c26beb54e9794d4056402bd5763d291ac68ef30b2097a79f4f2b" +content-hash = "8a1a77bac2b4866d8dddea48caa8e9b2a8ace6334af09ccd8d3ee37bdfb2b173" diff --git a/pyproject.toml b/pyproject.toml index 8926c46d..3c4cdcf5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "docq" -version = "0.9.7" +version = "0.10.0" description = "Docq.AI - Your private ChatGPT alternative. Securely unlock knowledge from confidential documents." authors = ["Docq.AI Team "] maintainers = ["Docq.AI Team "] @@ -61,6 +61,7 @@ jwt = "^1.3.1" llama-index-embeddings-huggingface-optimum = "^0.1.4" llama-index-core = "^0.10.21.post1" llama-index-readers-file = "^0.1.12" +slack-bolt = "^1.18.1" [tool.poetry.group.dev.dependencies] pre-commit = "^2.18.1" diff --git a/source/docq/config.py b/source/docq/config.py index cd89048d..45d0843a 100644 --- a/source/docq/config.py +++ b/source/docq/config.py @@ -10,7 +10,9 @@ SESSION_COOKIE_NAME = "docqai/_docq" ENV_VAR_DOCQ_GROQ_API_KEY = "DOCQ_GROQ_API_KEY" - +ENV_VAR_DOCQ_SLACK_CLIENT_ID = "DOCQ_SLACK_CLIENT_ID" +ENV_VAR_DOCQ_SLACK_CLIENT_SECRET = "DOCQ_SLACK_CLIENT_SECRET" # noqa: S105 +ENV_VAR_DOCQ_SLACK_SIGNING_SECRET = "DOCQ_SLACK_SIGNING_SECRET" # noqa: S105 class SpaceType(Enum): diff --git a/source/docq/integrations/__init__.py b/source/docq/integrations/__init__.py new file mode 100644 index 00000000..3ea6f293 --- /dev/null +++ b/source/docq/integrations/__init__.py @@ -0,0 +1,8 @@ +"""Initialize integrations.""" + +from .slack import manage_slack, manage_slack_messages + + +def _init() -> None: + """Initialize integrations.""" + manage_slack._init() diff --git a/source/docq/integrations/slack/manage_slack.py b/source/docq/integrations/slack/manage_slack.py new file mode 100755 index 00000000..ca06cb88 --- /dev/null +++ b/source/docq/integrations/slack/manage_slack.py @@ -0,0 +1,226 @@ +"""Manage integrations with third-party services.""" + +import logging +import sqlite3 +from contextlib import closing +from typing import Optional + +from docq.config import SpaceType +from docq.domain import SpaceKey +from docq.support.store import get_sqlite_shared_system_file +from slack_sdk.oauth.installation_store import Installation + +from .models import SlackChannel, SlackInstallation + +SQL_CREATE_DOCQ_SLACK_APPLICATIONS_TABLE = """ +CREATE TABLE IF NOT EXISTS docq_slack_installations ( + id INTEGER PRIMARY KEY, + app_id TEXT NOT NULL, + team_id TEXT NOT NULL, + team_name TEXT NOT NULL, -- References a slack workspace + org_id INTEGER NOT NULL, + space_group_id INTEGER, -- TODO: Implement globally available content for the entire slack workspace + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (org_id) REFERENCES orgs(id), + FOREIGN KEY (space_group_id) REFERENCES space_groups(id), + UNIQUE (app_id, team_id, org_id) +); +""" + +SQL_CREATE_DOCQ_SLACK_CHANNELS_TABLE = """ +CREATE TABLE IF NOT EXISTS docq_slack_channels ( + id INTEGER PRIMARY KEY, + channel_id TEXT NOT NULL, + channel_name TEXT NOT NULL, + org_id INTEGER NOT NULL, + space_group_id INTEGER, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (org_id) REFERENCES orgs(id), + FOREIGN KEY (space_group_id) REFERENCES space_groups(id), + UNIQUE (channel_id, org_id) +); +""" + + + +def _init() -> None: + """Initialize the Slack integration.""" + with closing(sqlite3.connect(get_sqlite_shared_system_file())) as connection: + connection.execute(SQL_CREATE_DOCQ_SLACK_APPLICATIONS_TABLE) + connection.execute(SQL_CREATE_DOCQ_SLACK_CHANNELS_TABLE) + connection.commit() + + +def create_docq_slack_installation(installation: Installation, org_id: int) -> None: + """Create a Docq installation.""" + with closing(sqlite3.connect(get_sqlite_shared_system_file())) as connection: + connection.execute( + "INSERT OR REPLACE INTO docq_slack_installations (app_id, team_id, team_name, org_id) VALUES (?, ?, ?, ?)", + (installation.app_id, installation.team_id, installation.team_name, org_id), + ) + connection.commit() + + +def update_docq_slack_installation(app_id: str, team_name: str, org_id: int, space_group_id: int) -> None: + """Update a Docq installation.""" + with closing(sqlite3.connect(get_sqlite_shared_system_file())) as connection: + connection.execute( + "UPDATE docq_slack_installations SET space_group_id = ? WHERE app_id = ? AND team_name = ? AND org_id = ?", + (space_group_id, app_id, team_name, org_id), + ) + connection.commit() + + +def list_docq_slack_installations(org_id: Optional[int], team_id: Optional[str]) -> list[SlackInstallation]: + """List Docq installations.""" + with closing(sqlite3.connect(get_sqlite_shared_system_file())) as connection: + cursor = connection.cursor() + if org_id: + criteria = " WHERE org_id = ?" + params = (org_id,) + elif team_id: + criteria = " WHERE team_id = ?" + params = (team_id,) + + cursor.execute( + f"SELECT app_id, team_id, team_name, org_id, space_group_id, created_at FROM docq_slack_installations{criteria}", + params, + ) + rows = cursor.fetchall() + return [ + SlackInstallation( + app_id=row[0], team_id=row[1], team_name=row[2], org_id=row[3], space_group_id=row[4], created_at=row[5] + ) + for row in rows + ] + + +# def get_docq_slack_installation(app_id: str, team_id: str, org_id: int) -> SlackInstallation: +# """Get a Docq installation.""" +# with closing(sqlite3.connect(get_sqlite_shared_system_file())) as connection: +# cursor = connection.cursor() +# cursor.execute( +# "SELECT app_id, team_id, team_name, org_id, space_group_id, created_at FROM docq_slack_installations WHERE app_id = ? AND team_id = ? AND org_id = ?", +# (app_id, team_id, org_id), +# ) +# row = cursor.fetchone() +# return SlackInstallation( +# app_id=row[0], team_id=row[1], team_name=row[2], org_id=row[3], space_group_id=row[4], created_at=row[5] +# ) + + +def integration_exists(app_id: str, team_id: str, selected_org_id: int) -> bool: + """Check if an integration exists.""" + with closing(sqlite3.connect(get_sqlite_shared_system_file())) as connection: + cursor = connection.cursor() + cursor.execute( + "SELECT id FROM docq_slack_installations WHERE app_id = ? AND team_id = ? AND org_id = ?", + (app_id, team_id, selected_org_id), + ) + return cursor.fetchone() is not None + + +def insert_or_update_slack_channel(channel_id: str, channel_name: str, org_id: int) -> None: + """Insert or update a channel.""" + with closing(sqlite3.connect(get_sqlite_shared_system_file())) as connection: + connection.execute( + "INSERT OR REPLACE INTO docq_slack_channels (channel_id, channel_name, org_id) VALUES (?, ?, ?)", + (channel_id, channel_name, org_id), + ) + connection.commit() + + +def link_space_group_to_slack_channel(org_id: int, channel_id: str, channel_name: str, space_group_id: int,) -> None: + """Add a space group to a channel.""" + with closing(sqlite3.connect(get_sqlite_shared_system_file())) as connection: + connection.execute( + "INSERT OR REPLACE INTO docq_slack_channels (space_group_id, channel_id, channel_name, org_id) VALUES (?, ?, ?, ?)", + (space_group_id, channel_id, channel_name, org_id), + ) + connection.commit() + + +def get_slack_channel_linked_space_group_id(org_id: int, channel_id: str) -> Optional[int]: + """Get a channel space group id.""" + with closing(sqlite3.connect(get_sqlite_shared_system_file())) as connection: + cursor = connection.cursor() + try: + cursor.execute( + "SELECT space_group_id FROM docq_slack_channels WHERE channel_id = ? AND org_id = ?", + (channel_id, org_id), + ) + result = cursor.fetchone() + return result[0] if result is not None else None + except sqlite3.OperationalError: + logging.error("No installations found.") + return None + + +def list_slack_channels(org_id: int) -> list[SlackChannel]: + """List Slack channels.""" + with closing(sqlite3.connect(get_sqlite_shared_system_file())) as connection: + cursor = connection.cursor() + cursor.execute( + "SELECT channel_id, channel_name, org_id, space_group_id, created_at FROM docq_slack_channels WHERE org_id = ?", + (org_id,), + ) + rows = cursor.fetchall() + return [ + SlackChannel( + channel_id=row[0], channel_name=row[1], org_id=row[2], space_group_id=row[3], created_at=row[4] + ) + for row in rows + ] + + +def get_slack_channel(channel_id: str) -> SlackChannel: + """Get a channel.""" + with closing(sqlite3.connect(get_sqlite_shared_system_file())) as connection: + cursor = connection.cursor() + cursor.execute( + "SELECT channel_id, channel_name, org_id, space_group_id, created_at FROM docq_slack_channels WHERE channel_id = ?", + (channel_id,), + ) + row = cursor.fetchone() + return SlackChannel( + channel_id=row[0], channel_name=row[1], org_id=row[2], space_group_id=row[3], created_at=row[4] + ) + + +def get_slack_bot_token(app_id: str, team_id: str) -> str: + """Get a bot token.""" + with closing(sqlite3.connect(get_sqlite_shared_system_file())) as connection: + cursor = connection.cursor() + cursor.execute( + "SELECT bot_token FROM slack_bots WHERE app_id = ? AND team_id = ?", (app_id, team_id) + ) + return cursor.fetchone()[0] + +def get_rag_spaces(channel_id: str) -> Optional[list[SpaceKey]]: + """Get a list of spaces configured for the given channel.""" + with closing(sqlite3.connect(get_sqlite_shared_system_file())) as connection: + cursor = connection.cursor() + cursor.execute( + """ + SELECT s.id, s.org_id, s.name, s.summary, s.archived, s.datasource_type, s.datasource_configs, s.space_type, s.created_at, s.updated_at + FROM spaces s + JOIN space_group_members ON s.id = space_group_members.space_id + JOIN docq_slack_channels ON space_group_members.group_id = docq_slack_channels.space_group_id + WHERE docq_slack_channels.channel_id = ? + """, + (channel_id,), + ) + spaces = cursor.fetchall() + + return [ SpaceKey(SpaceType[row[7]], row[0], row[1], row[3]) for row in spaces ] if spaces else None + + +def get_org_id_from_channel_id(channel_id: str) -> Optional[int]: + """Get the org id from a channel id.""" + with closing(sqlite3.connect(get_sqlite_shared_system_file())) as connection: + cursor = connection.cursor() + cursor.execute( + "SELECT org_id FROM docq_slack_channels WHERE channel_id = ?", (channel_id,) + ) + result = cursor.fetchone() + return result[0] if result is not None else None diff --git a/source/docq/integrations/slack/manage_slack_messages.py b/source/docq/integrations/slack/manage_slack_messages.py new file mode 100644 index 00000000..6f732733 --- /dev/null +++ b/source/docq/integrations/slack/manage_slack_messages.py @@ -0,0 +1,81 @@ +"""Slack messages handler.""" + +import sqlite3 +from contextlib import closing + +from ...support.store import get_sqlite_org_slack_messages_file +from .models import SlackMessage + +SQL_CREATE_TABLE_DOCQ_SLACK_MESSAGES = """ +CREATE TABLE IF NOT EXISTS docq_slack_messages ( + id INTEGER PRIMARY KEY, + client_msg_id TEXT NOT NULL, -- Unique identifier for the message + type TEXT NOT NULL, + channel_id TEXT NOT NULL, + team_id TEXT NOT NULL, + user_id TEXT NOT NULL, + text TEXT NOT NULL, + ts TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +""" + + +def _init(org_id: int) -> None: + """Initialize the Slack integration. + + We don't call this in setup because and org_id context is required. + """ + with closing(sqlite3.connect(get_sqlite_org_slack_messages_file(org_id=org_id))) as connection: + connection.execute(SQL_CREATE_TABLE_DOCQ_SLACK_MESSAGES) + connection.commit() + + +def insert_or_update_message( + client_msg_id: str, type_: str, channel: str, team: str, user: str, text: str, ts: str, org_id: int +) -> None: + """Insert or update a message.""" + _init(org_id) + with closing(sqlite3.connect(get_sqlite_org_slack_messages_file(org_id=org_id))) as connection: + connection.execute( + "INSERT OR REPLACE INTO docq_slack_messages (client_msg_id, type, channel_id, team_id, user_id, text, ts) VALUES (?, ?, ?, ?, ?, ?, ?)", + (client_msg_id, type_, channel, team, user, text, ts), + ) + connection.commit() + + +def is_message_handled(client_msg_id: str, ts: str, org_id: int) -> bool: + """Check if a message exists.""" + _init(org_id) + with closing(sqlite3.connect(get_sqlite_org_slack_messages_file(org_id=org_id))) as connection: + cursor = connection.cursor() + cursor.execute( + "SELECT id FROM docq_slack_messages WHERE client_msg_id = ? AND ts = ?", + (client_msg_id, ts), + ) + return cursor.fetchone() is not None + + +def list_slack_messages(channel: str, org_id: int) -> list[SlackMessage]: + """Get a list of messages for a specific channel.""" + _init(org_id) + with closing(sqlite3.connect(get_sqlite_org_slack_messages_file(org_id=org_id))) as connection: + cursor = connection.cursor() + cursor.execute( + "SELECT client_msg_id, type, channel_id, team_id, user_id, text, ts, created_at FROM docq_slack_messages WHERE channel = ?", + (channel,), + ) + rows = cursor.fetchall() + return [ + SlackMessage( + client_msg_id=row[0], + type=row[1], + channel_id=row[2], + team_id=row[3], + user_id=row[4], + text=row[5], + ts=row[6], + created_at=row[7], + ) + for row in rows + ] diff --git a/source/docq/integrations/slack/models.py b/source/docq/integrations/slack/models.py new file mode 100644 index 00000000..3d353e9b --- /dev/null +++ b/source/docq/integrations/slack/models.py @@ -0,0 +1,42 @@ +"""Slack integration data models.""" + +from typing import Optional + +from attr import dataclass + + +@dataclass +class SlackInstallation: + """Slack application model.""" + + app_id: str + team_id: str + team_name: str + org_id: int + space_group_id: Optional[int] + created_at: str + + +@dataclass +class SlackChannel: + """Slack channel model.""" + + channel_id: str + channel_name: str + org_id: int + space_group_id: Optional[int] + created_at: str + + +@dataclass +class SlackMessage: + """Slack message model.""" + + client_msg_id: str + type: str + channel_id: str + team_id: str + user_id: str + text: str + ts: str + created_at: str diff --git a/source/docq/integrations/slack/slack_application.py b/source/docq/integrations/slack/slack_application.py new file mode 100644 index 00000000..7116c60b --- /dev/null +++ b/source/docq/integrations/slack/slack_application.py @@ -0,0 +1,69 @@ +"""Slack application.""" + +import logging +import os + +from docq.integrations.slack.slack_oauth_flow import SlackOAuthFlow +from docq.support.store import get_sqlite_shared_system_file +from opentelemetry import trace +from slack_bolt import App, BoltResponse +from slack_bolt.oauth.callback_options import CallbackOptions, FailureArgs, SuccessArgs + +from ...config import ENV_VAR_DOCQ_SLACK_CLIENT_ID, ENV_VAR_DOCQ_SLACK_CLIENT_SECRET, ENV_VAR_DOCQ_SLACK_SIGNING_SECRET + +tracer = trace.get_tracer(__name__) + +CLIENT_ID = os.environ.get(ENV_VAR_DOCQ_SLACK_CLIENT_ID) +CLIENT_SECRET = os.environ.get(ENV_VAR_DOCQ_SLACK_CLIENT_SECRET) +SIGNING_SECRET = os.environ.get(ENV_VAR_DOCQ_SLACK_SIGNING_SECRET) +SCOPES = ["app_mentions:read", "im:history", "chat:write", "channels:read", "groups:read", "im:read", "mpim:read"] +USER_SCOPES = [] # OAuth scopes to request if the bot needs to take actions on behalf of the user. Docq doesn't need to do this right now. + + +@tracer.start_as_current_span(name="slack_success_callback") +def success_callback(success_args: SuccessArgs) -> BoltResponse: + """Success callback.""" + return success_args.default.success(success_args) + +@tracer.start_as_current_span(name="slack_failure_callback") +def failure_callback(failure_args: FailureArgs) -> BoltResponse: + """Failure callback.""" + span = trace.get_current_span() + span.set_attribute("slack_failure_callback_args", str(failure_args)) + return failure_args.default.failure(failure_args) + +with tracer.start_as_current_span(name="initialise_slack_app") as slack_app_span: + slack_app_span.set_attributes( + { + ENV_VAR_DOCQ_SLACK_CLIENT_ID: CLIENT_ID if CLIENT_ID else "value missing", + ENV_VAR_DOCQ_SLACK_CLIENT_SECRET: "value present" if CLIENT_SECRET else "value missing", + ENV_VAR_DOCQ_SLACK_SIGNING_SECRET: "value present" if SIGNING_SECRET else "value missing", + } + ) + if CLIENT_ID and CLIENT_SECRET and SIGNING_SECRET: + slack_app = App( + process_before_response=True, + request_verification_enabled=True, + signing_secret=SIGNING_SECRET, + oauth_flow=SlackOAuthFlow.sqlite3( + database=get_sqlite_shared_system_file(), + install_path="/api/integration/slack/v1/install", + redirect_uri_path="/api/integration/slack/v1/oauth_redirect", + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + scopes=SCOPES, + user_scopes=USER_SCOPES, + callback_options=CallbackOptions(success=success_callback, failure=failure_callback), + ), + ) + else: + slack_app_span.record_exception( + ValueError( + f"One or more Slack environment variables are not set. Expected env var names: {ENV_VAR_DOCQ_SLACK_CLIENT_ID}, {ENV_VAR_DOCQ_SLACK_CLIENT_SECRET}, {ENV_VAR_DOCQ_SLACK_SIGNING_SECRET}. Values for these are part of your app config in Slack." + ) + ) + slack_app_span.set_status(trace.StatusCode.ERROR) + logging.error( + f"One or more Slack environment variables are not set. Expected env var names: {ENV_VAR_DOCQ_SLACK_CLIENT_ID}, {ENV_VAR_DOCQ_SLACK_CLIENT_SECRET}, {ENV_VAR_DOCQ_SLACK_SIGNING_SECRET}. Values for these are part of your app config in Slack." + ) + # raise ValueError("Slack client ID and client secret must be set in the environment.") diff --git a/source/docq/integrations/slack/slack_oauth_flow.py b/source/docq/integrations/slack/slack_oauth_flow.py new file mode 100644 index 00000000..dccf1c26 --- /dev/null +++ b/source/docq/integrations/slack/slack_oauth_flow.py @@ -0,0 +1,172 @@ +""""Custom slack oauth flow.""" + +from logging import Logger +from typing import Optional, Self, Sequence + +from docq.integrations.slack.manage_slack import create_docq_slack_installation +from opentelemetry import trace +from slack_bolt.error import BoltError +from slack_bolt.oauth.callback_options import CallbackOptions +from slack_bolt.oauth.oauth_flow import OAuthFlow +from slack_bolt.oauth.oauth_settings import OAuthSettings +from slack_bolt.request import BoltRequest +from slack_sdk.oauth import OAuthStateUtils +from slack_sdk.oauth.installation_store import Installation +from slack_sdk.oauth.installation_store.sqlite3 import SQLite3InstallationStore +from slack_sdk.oauth.state_store.sqlite3 import SQLite3OAuthStateStore +from slack_sdk.web import WebClient + +tracer = trace.get_tracer(__name__) + + +class SlackOAuthFlow(OAuthFlow): + """Custom slack oauth flow. + + This is used by slack_sdk to during installation of slack applications. + Shares the docq app state with the slack app installation. + + We override the default installation methods to link the slack workspaces to docq organisations during installation. + """ + + @classmethod + @tracer.start_as_current_span(name="slack_oauth_flow_sqlite3") + def sqlite3( + cls, # noqa: ANN102 + database: str, + client_id: str, + client_secret: str, + scopes: Sequence[str], + user_scopes: Sequence[str], + redirect_uri: Optional[str] = None, + install_path: Optional[str] = None, + redirect_uri_path: Optional[str] = None, + callback_options: Optional[CallbackOptions] = None, + success_url: Optional[str] = None, + failure_url: Optional[str] = None, + authorization_url: Optional[str] = None, + state_cookie_name: str = OAuthStateUtils.default_cookie_name, + state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, + installation_store_bot_only: bool = False, + token_rotation_expiration_minutes: int = 120, + client: Optional[WebClient] = None, + logger: Optional[Logger] = None, + ) -> "SlackOAuthFlow": + """Create a SlackOAuthFlow.""" + # client_id = client_id # or os.environ["SLACK_CLIENT_ID"] + # client_secret = client_secret # or os.environ["SLACK_CLIENT_SECRET"] + # scopes = scopes # or os.environ.get("SLACK_SCOPES", "").split(",") + # user_scopes = user_scopes # or os.environ.get("SLACK_USER_SCOPES", "").split(",") + # redirect_uri = redirect_uri # or os.environ.get("SLACK_REDIRECT_URI") + span = trace.get_current_span() + span.set_attributes( + attributes={ + "slack__client_id": client_id if client_id else "None", + "slack__client_secret": "value present" if client_secret else "None", + "slack__scopes": scopes if scopes else "None", + "slack__user_scopes": user_scopes if user_scopes else "None", + "slack__redirect_uri": redirect_uri if redirect_uri else "None", + "slack__install_path": install_path if install_path else "None", + "slack__redirect_uri_path": redirect_uri_path if redirect_uri_path else "None", + "slack__callback_options": str(callback_options) if callback_options else "None", + "slack__success_url": success_url if success_url else "None", + "slack__failure_url": failure_url if failure_url else "None", + "slack__authorization_url": authorization_url if authorization_url else "None", + "slack__state_cookie_name": state_cookie_name if state_cookie_name else "None", + "slack__state_expiration_seconds": state_expiration_seconds if state_expiration_seconds else "None", + "slack__installation_store_bot_only": installation_store_bot_only + if installation_store_bot_only + else "None", + "slack__token_rotation_expiration_minutes": token_rotation_expiration_minutes + if token_rotation_expiration_minutes + else "None", + } + ) + return SlackOAuthFlow( + client=client or WebClient(), + logger=logger, + settings=OAuthSettings( + client_id=client_id, + client_secret=client_secret, + scopes=scopes, + user_scopes=user_scopes, + redirect_uri=redirect_uri, + install_path=install_path, # type: ignore + redirect_uri_path=redirect_uri_path, # type: ignore + callback_options=callback_options, + success_url=success_url, + failure_url=failure_url, + authorization_url=authorization_url, + installation_store=SQLite3InstallationStore( + database=database, + client_id=client_id, + logger=logger, # type: ignore + ), + installation_store_bot_only=installation_store_bot_only, + token_rotation_expiration_minutes=token_rotation_expiration_minutes, + state_store=SQLite3OAuthStateStore( + database=database, + expiration_seconds=state_expiration_seconds, + logger=logger, # type: ignore + ), + state_cookie_name=state_cookie_name, + state_expiration_seconds=state_expiration_seconds, + ), + ) + + def get_cookie(self: Self, name: str, cookies: Optional[str | Sequence[str]]) -> Optional[str]: + """Get a cookie.""" + from docq.support.auth_utils import decrypt_cookie_value + + if not cookies: + return None + + if isinstance(cookies, str): + cookies = [cookies] + for cookie in cookies: + for item in cookie.split(";"): + key, value = item.split("=", 1) + if key.strip() == name: + return decrypt_cookie_value(value) + return None + + @tracer.start_as_current_span(name="save_docq_slack_installation") + def save_docq_slack_installation(self: Self, request: BoltRequest, installation: Installation) -> None: + """Save a Docq slack installation.""" + span = trace.get_current_span() + docq_slack_app_state = self.get_cookie("docq_slack_app_state", request.headers.get("cookie")) + if docq_slack_app_state is not None: + _, selected_org_id = docq_slack_app_state.split(":") + create_docq_slack_installation(installation, int(selected_org_id)) + else: + span.record_exception( + ValueError("No Docq slack app state found in cookies. Login to Docq before installing the slack app.") + ) + span.set_status(trace.StatusCode.ERROR) + raise BoltError("Login to Docq before installing the slack app.") + + def store_installation(self: Self, request: BoltRequest, installation: Installation) -> None: + """Store an installation.""" + self.save_docq_slack_installation(request, installation) + self.settings.installation_store.save(installation) + + # NOTE: This only shows how to create a custom installation page if not provided the default one from slack is used. + # def build_install_page_html(self: Self, url: str, request: BoltRequest) -> str: + # """Build the installation page html.""" + # return f""" + # + # + # + # + # + # + #

Docq Slack App Installation

+ #

+ # + # + # """ diff --git a/source/docq/setup.py b/source/docq/setup.py index f99e6611..cdd8a8c4 100644 --- a/source/docq/setup.py +++ b/source/docq/setup.py @@ -7,6 +7,7 @@ from docq import db_migrations, extensions, manage_assistants from . import ( + integrations, manage_organisations, manage_settings, manage_space_groups, @@ -38,6 +39,7 @@ def init() -> None: manage_assistants._init() db_migrations.run() # run db migrations after all tables are created services._init() + integrations._init() services.credential_utils.setup_all_service_credentials() store._init() manage_organisations._init_default_org_if_necessary() diff --git a/source/docq/support/auth_utils.py b/source/docq/support/auth_utils.py index 776aefb0..aac5fcfa 100644 --- a/source/docq/support/auth_utils.py +++ b/source/docq/support/auth_utils.py @@ -23,7 +23,7 @@ TTL_SEC = 60 * 60 * TTL_HOURS CACHE_CONFIG = (1024 * 1, TTL_SEC) AUTH_KEY = Fernet.generate_key() -AUTH_SESSION_SECRET_KEY: str = os.environ.get(ENV_VAR_DOCQ_COOKIE_HMAC_SECRET_KEY) +AUTH_SESSION_SECRET_KEY = os.environ.get(ENV_VAR_DOCQ_COOKIE_HMAC_SECRET_KEY) # Cache of session data keyed by hmac hash (hmac of session id) cached_session_data: TTLCache[str, bytes] = TTLCache(*CACHE_CONFIG) @@ -157,7 +157,7 @@ def verify_cookie_hmac_session_id() -> str | None: return hmac_session_id -def _encrypt(payload: dict) -> bytes: +def _encrypt(payload: dict) -> bytes | None: """Encrypt some data.""" try: data = json.dumps(payload).encode() @@ -168,7 +168,7 @@ def _encrypt(payload: dict) -> bytes: return None -def _decrypt(encrypted_payload: bytes) -> dict: +def _decrypt(encrypted_payload: bytes) -> dict | None: """Decrypt some data.""" try: cipher = Fernet(AUTH_KEY) @@ -215,12 +215,24 @@ def set_cache_auth_session(val: dict) -> None: @tracer.start_as_current_span("get_cache_auth_session") def get_cache_auth_session() -> dict | None: """Verify the session auth token and get the cached session state for the current session. The current session is identified by a session_id wrapped in a auth token in a browser session cookie.""" + span = trace.get_current_span() try: decrypted_auth_session_data = None hmac_session_id = _get_cookie_session_id() - if hmac_session_id in cached_session_data: - encrypted_auth_session_data = cached_session_data[hmac_session_id] - decrypted_auth_session_data = _decrypt(encrypted_auth_session_data) + span.set_attribute("session_id", "value present" if hmac_session_id else "value missing") + if hmac_session_id: + log.debug("get_cache_auth_session() - hmac session id: %s", hmac_session_id) + log.debug("get_cache_auth_session(): %s", cached_session_data.keys().__str__()) + if hmac_session_id in cached_session_data: + encrypted_auth_session_data = cached_session_data[hmac_session_id] + decrypted_auth_session_data = _decrypt(encrypted_auth_session_data) + else: + log.debug("Session id not found in cache") + span.add_event("session_id not found in session cache data") + else: + log.warning("Session id not found in cookie") + span.add_event("session_id not found in cookie") + return decrypted_auth_session_data except Exception as e: log.error("Failed to get auth session from cache: %s", e) @@ -228,15 +240,18 @@ def get_cache_auth_session() -> dict | None: def remove_cache_auth_session() -> None: - """Remove the cached session state for the current session. The current session is identified by a session_id in a particular browsersession cookie.""" + """Remove the cached session state for the current session. The current session is identified by a session_id in a particular browser session cookie.""" try: hmac_session_id = _get_cookie_session_id() - if hmac_session_id in cached_session_data: - del cached_session_data[hmac_session_id] - log.debug("Removed from cached_session: %s", hmac_session_id) - if hmac_session_id in cached_session_ids: - del cached_session_ids[hmac_session_id] - log.debug("Removed from session_data: %s", hmac_session_id) + if hmac_session_id: + if hmac_session_id in cached_session_data: + del cached_session_data[hmac_session_id] + log.debug("Removed from cached_session: %s", hmac_session_id) + if hmac_session_id in cached_session_ids: + del cached_session_ids[hmac_session_id] + log.debug("Removed from session_data: %s", hmac_session_id) + else: + log.warning("Session id not found in cache") except Exception as e: log.error("Failed to remove auth session from cache: %s", e) @@ -248,3 +263,15 @@ def reset_cache_and_cookie_auth_session() -> None: _clear_cookie(SESSION_COOKIE_NAME) except Exception as e: log.error("Failed to clear session data caches (hmac, session data, and session cookie ): %s", e) + + +def encrypt_cookie_value(data: str) -> str: + """Encrypt a string.""" + cipher = Fernet(AUTH_KEY) + return cipher.encrypt(data.encode()).decode("utf-8") + + +def decrypt_cookie_value(data: str) -> str: + """Decrypt a string.""" + cipher = Fernet(AUTH_KEY) + return cipher.decrypt(data.encode()).decode("utf-8") diff --git a/source/docq/support/store.py b/source/docq/support/store.py index 87eeffd2..6697ac00 100644 --- a/source/docq/support/store.py +++ b/source/docq/support/store.py @@ -27,6 +27,8 @@ class _SqliteFilename(Enum): USAGE = "usage.db" SYSTEM = "system.db" + SLACK_MESSAGES = "slack_messages.db" + class _DataScope(Enum): """The data access scopes used to partition data persistance. Note scoped by ownership not sharing and access. @@ -170,6 +172,15 @@ def get_sqlite_org_system_file(org_id: int) -> str: """Get the SQLite file for the storing org scoped system data.""" return _get_path(store=_StoreDir.SQLITE, data_scope=_DataScope.ORG, subtype=str(org_id), filename=_SqliteFilename.SYSTEM.value) +def get_sqlite_org_slack_messages_file(org_id: int) -> str: + """Get the SQLite file for storing external system data.""" + return _get_path( + store=_StoreDir.SQLITE, + data_scope=_DataScope.ORG, + subtype=str(org_id), + filename=_SqliteFilename.SLACK_MESSAGES.value, + ) + def get_history_table_name(type_: OrganisationFeatureType) -> str: """Get the history table name for a feature.""" diff --git a/web/admin/admin_integrations.py b/web/admin/admin_integrations.py new file mode 100644 index 00000000..a3340c4e --- /dev/null +++ b/web/admin/admin_integrations.py @@ -0,0 +1,37 @@ +"""Slack install handler.""" + +import streamlit as st + +from web.utils.layout import render_integrations, render_slack_installation_button, tracer + + +@tracer.start_as_current_span("admin_integrations_page") +def admin_integrations_page() -> None: + """Admin integrations section.""" + integrations = [ + { + "name": "Slack", + "description": "Slack is a business communication platform that allows teams to communicate and collaborate.", + "icon": "slack", + "url": "/api/integration/slack/v1/install", + }, + { + "name": "Teams", + "description": "Google Drive is a file storage and synchronization service developed by Google.", + "icon": "google-drive", + "url": "/api/integration/google-drive/v1/install", + }, + ] + + integration = st.selectbox( + "Select an integration to get started", + options=[integration["name"] for integration in integrations], + ) + + if integration == "Slack": + render_slack_installation_button() + + render_integrations() + + else: + st.info("Coming soon!") diff --git a/web/admin/admin_settings.py b/web/admin/admin_settings.py index 83705972..3d26e644 100644 --- a/web/admin/admin_settings.py +++ b/web/admin/admin_settings.py @@ -8,5 +8,5 @@ def admin_settings_page() -> None: """Page: Admin / Manage Settings.""" organisation_settings_ui() - is_super_admin() - system_settings_ui() + if is_super_admin(): + system_settings_ui() diff --git a/web/admin/index.py b/web/admin/index.py index 6b2c4146..e9e5ce4a 100644 --- a/web/admin/index.py +++ b/web/admin/index.py @@ -3,8 +3,9 @@ import streamlit as st from utils.layout import auth_required, render_page_title_and_favicon from utils.observability import baggage_as_attributes, tracer -from utils.sessions import is_current_user_super_admin +from utils.sessions import is_current_user_selected_org_admin, is_current_user_super_admin +from web.admin.admin_integrations import admin_integrations_page from web.admin.admin_logs import admin_logs_page from web.admin.admin_orgs import admin_orgs_page from web.admin.admin_settings import admin_settings_page @@ -14,17 +15,21 @@ from web.admin.admin_users import admin_users_page -def admin_pages() -> None: - """Admin Section.""" - admin_orgs, admin_users, admin_user_groups, admin_spaces, admin_space_groups, admin_logs, admin_settings= st.tabs( - ["Admin Orgs", "Admin Users", "Admin User Groups", "Admin Spaces", "Admin Space Groups", "Admin Logs", "Admin Settings"] - ) +def super_and_org_admin_pages() -> None: + """Sections if both super admin and current selected org admin.""" + ( + admin_orgs, + admin_users, + admin_user_groups, + admin_spaces, + admin_space_groups, + admin_settings, + admin_chat_integrations, + admin_logs, + ) = st.tabs(["Orgs", "Users", "User Groups", "Spaces", "Space Groups", "Settings", "Chat Integrations", "Logs"]) + - with admin_logs: - admin_logs_page() - with admin_settings: - admin_settings_page() with admin_orgs: admin_orgs_page() @@ -41,30 +46,90 @@ def admin_pages() -> None: with admin_space_groups: admin_space_groups_page() + with admin_settings: + admin_settings_page() + + with admin_chat_integrations: + admin_integrations_page() + + with admin_logs: + admin_logs_page() + + +def org_admin_pages() -> None: + """Sections if only org admin.""" + ( + admin_orgs, + admin_users, + admin_user_groups, + admin_spaces, + admin_space_groups, + admin_settings, + admin_chat_integrations, + admin_logs, + ) = st.tabs(["Org", "Users", "User Groups", "Spaces", "Space Groups", "Settings", "Chat Integrations", "Logs"]) + + with admin_orgs: + admin_orgs_page() + + with admin_users: + admin_users_page() + + with admin_user_groups: + admin_user_groups_page() -def user_admin_pages() -> None: - """Admin Section.""" - admin_spaces, admin_space_groups, admin_logs, admin_settings = st.tabs( - ["Admin Spaces", "Admin Space Groups", "Admin Logs", "Admin Settings"] - ) with admin_spaces: admin_spaces_page() with admin_space_groups: admin_space_groups_page() + with admin_settings: + admin_settings_page() + + with admin_chat_integrations: + admin_integrations_page() + with admin_logs: admin_logs_page() + +def super_admin_pages() -> None: + """Sections if only super admin.""" + ( + admin_orgs, + admin_users, + admin_settings, + ) = st.tabs( + [ + "Orgs", + "Users", + "Settings", + ] + ) + with admin_settings: admin_settings_page() + with admin_orgs: + admin_orgs_page() + + with admin_users: + admin_users_page() + -with tracer().start_as_current_span("admin_section", attributes=baggage_as_attributes()): +with tracer().start_as_current_span("admin_section", attributes=baggage_as_attributes()) as span: render_page_title_and_favicon() auth_required(requiring_selected_org_admin=True) - if is_current_user_super_admin(): - admin_pages() + if is_current_user_super_admin() and is_current_user_selected_org_admin(): + span.set_attribute("user_role", "super_admin_and_org_admin") + super_and_org_admin_pages() + elif is_current_user_selected_org_admin: + span.set_attribute("user_role", "org_admin_only") + org_admin_pages() + elif is_current_user_super_admin(): + span.set_attribute("user_role", "super_admin_only") + super_admin_pages() else: - user_admin_pages() + span.set_attribute("user_role", "non_admin_user") diff --git a/web/admin_spaces.py b/web/admin_spaces.py index ed99bec5..cf00c9cd 100644 --- a/web/admin_spaces.py +++ b/web/admin_spaces.py @@ -1,4 +1,7 @@ -"""Page: Admin / Manage Documents.""" +"""Page: Admin / Manage Documents. + +This duplicate is needed for the G Drive, OneDrive etc. auth flow.. +""" from utils.layout import ( admin_docs_ui, auth_required, diff --git a/web/api/index_handler.py b/web/api/index_handler.py index 24c1ea20..464be40d 100644 --- a/web/api/index_handler.py +++ b/web/api/index_handler.py @@ -14,6 +14,7 @@ class name: route replace capitalise route segments remove `/` and `_`. Example: """ # for now we'll manually add imports. TODO: convert to walk the directory and dynamically import using importlib + from . import ( chat_completion_handler, # noqa: F401 DO NOT REMOVE hello_handler, # noqa: F401 DO NOT REMOVE @@ -22,3 +23,4 @@ class name: route replace capitalise route segments remove `/` and `_`. Example: threads_handler, # noqa: F401 DO NOT REMOVE token_handler, # noqa: F401 DO NOT REMOVE ) +from .integration.slack import index_handler # noqa: F401 DO NOT REMOVE diff --git a/web/api/integration/slack/app_home.py b/web/api/integration/slack/app_home.py new file mode 100644 index 00000000..2cef75b7 --- /dev/null +++ b/web/api/integration/slack/app_home.py @@ -0,0 +1,60 @@ +"""DocQ bot app home.""" + +from typing import Any, Callable + +import docq.integrations.slack.slack_application as slack_app +from slack_sdk import WebClient + + +def get_header_block() -> dict[str, Any]: + """Get header block.""" + return { + "type": "header", + "text": { + "type": "plain_text", + "text": "Docq.AI Your private ChatGPT alternative", + "emoji": True + } + } + +def get_divider_block() -> dict[str, Any]: + """Get divider block.""" + return { "type": "divider" } + +def get_context_block() -> dict[str, Any]: + """Get context block.""" + return { + "type": "context", + "elements": [ + { + "type": "plain_text", + "text": "Securely unlock knowledge from your business documents. Give your employees' a second-brain.", + "emoji": True + } + ] + } + +def get_image_block() -> dict[str, Any]: + """Get image block.""" + return { + "type": "image", + "image_url": "https://camo.githubusercontent.com/dc0e67c1884b3629ad73259f7f32ffcadcf974b4c92a1ebb9eaa2ffd0cfb2825/68747470733a2f2f646f637161692e6769746875622e696f2f646f63712f6173736574732f646f63712d646961672d6e6f76323032332e706e67", + "alt_text": "Docq overview" + } + +@slack_app.slack_app.event("app_home_opened") +def handle_app_home_opened_events(ack: Callable, client: WebClient, event: Any) -> None: + """Handle app home opened events.""" + ack() + client.views_publish( + user_id=event["user"], + view={ + "type": "home", + "blocks": [ + get_header_block(), + get_divider_block(), + get_context_block(), + get_image_block(), + ], + } + ) diff --git a/web/api/integration/slack/chat_handler.py b/web/api/integration/slack/chat_handler.py new file mode 100644 index 00000000..b0019982 --- /dev/null +++ b/web/api/integration/slack/chat_handler.py @@ -0,0 +1,38 @@ +"""Slack chat action handler.""" + + + +from docq.integrations.slack.slack_application import slack_app +from opentelemetry import trace +from slack_bolt.context.say import Say + +from web.api.integration.utils import chat_completion, rag_completion + +from .slack_utils import message_handled_middleware, persist_message_middleware + +tracer = trace.get_tracer(__name__) + +CHANNEL_TEMPLATE = "<@{user}> {response}" + + +@slack_app.event("app_mention", middleware=[message_handled_middleware, persist_message_middleware]) +@tracer.start_as_current_span(name="handle_app_mention") +def handle_app_mention(body: dict, say: Say) -> None: + """Handle @Docq App mentions.""" + response = rag_completion(body["event"]["text"], body["event"]["channel"]) + say( + text=CHANNEL_TEMPLATE.format(user=body["event"]["user"], response=response), + channel=body["event"]["channel"], + mrkdwn=True, + ) + +@slack_app.event("message", middleware=[message_handled_middleware, persist_message_middleware]) +@tracer.start_as_current_span(name="handle_message_im") +def handle_message_im(body: dict, say: Say) -> None: + """Handle bot messages.""" + if body["event"]["channel_type"] == "im": + say( + text=chat_completion(body["event"]["text"]), + channel=body["event"]["channel"], + mrkdwn=True, + ) diff --git a/web/api/integration/slack/index_handler.py b/web/api/integration/slack/index_handler.py new file mode 100644 index 00000000..7a968676 --- /dev/null +++ b/web/api/integration/slack/index_handler.py @@ -0,0 +1,52 @@ +"""Slack application package init file.""" + +import json +import logging +from typing import Self + +from opentelemetry import trace + +from web.utils.streamlit_application import st_app + +from ...base_handlers import BaseRequestHandler + +tracer = trace.get_tracer(__name__) +with tracer.start_as_current_span("slack_integration_register_api_handlers") as span: + try: + from docq.integrations.slack.slack_application import slack_app + + from . import app_home, chat_handler, slack_request_handlers + + __all__ = ["chat_handler", "app_home", "slack_request_handlers"] + span.add_event("Successfully registered Slack integration API handlers.") + except ImportError as e: + span.record_exception(e) + span.set_status( + trace.StatusCode.ERROR, + "Was unable to initialise the Slack integration. Check configuration and environment variables.", + ) + logging.error( + "Was unable to initialise the Slack integration. Check configuration and environment variables. %s", e + ) + + @st_app.api_route("/api/integration/slack/v1/events") + class SlackEventHandler(BaseRequestHandler): + """Handle /slack/events when the Slack integration is not available. + + We do this to avoid silent failures and to help troubleshoot by logging the messages below. + This is a hack mainly because how we hook into the Streamlit Tornado instance to add API route handlers. + """ + + def post(self: Self) -> None: + """Handle GET request.""" + payload = json.loads(self.request.body.decode()) + logging.warning( + "This is a Slack bot request that was unhandled. The Slack integration is not available. Likely due to a configuration error. From Slack Team ID: %s", + payload.get("team_id"), + ) + + trace.get_current_span().add_event("Unhandled Slack bot request") + trace.get_current_span().set_status( + trace.StatusCode.ERROR, + "This is a Slack bot request that was unhandled. The Slack integration is not available. Likely due to a configuration error.", + ) diff --git a/web/api/integration/slack/slack_request_handlers.py b/web/api/integration/slack/slack_request_handlers.py new file mode 100644 index 00000000..0856ff0e --- /dev/null +++ b/web/api/integration/slack/slack_request_handlers.py @@ -0,0 +1,20 @@ +"""Slack application request handlers.""" + +from docq.integrations.slack.slack_application import slack_app +from slack_bolt.adapter.tornado import SlackEventsHandler, SlackOAuthHandler + +from web.api.base_handlers import BaseRequestHandler +from web.utils.streamlit_application import st_app + + +@st_app.api_route("/api/integration/slack/v1/events", dict(app=slack_app)) +class SlackEventHandler(SlackEventsHandler, BaseRequestHandler): + """Handle /slack/events requests.""" + +@st_app.api_route("/api/integration/slack/v1/install", dict(app=slack_app)) +class SlackInstallHandler(SlackOAuthHandler, BaseRequestHandler): + """Handle /slack/install requests.""" + +@st_app.api_route("/api/integration/slack/v1/oauth_redirect", dict(app=slack_app)) +class SlackOAuthRedirectHandler(SlackOAuthHandler, BaseRequestHandler): + """Handle /slack/oauth_redirect requests.""" diff --git a/web/api/integration/slack/slack_utils.py b/web/api/integration/slack/slack_utils.py new file mode 100644 index 00000000..ba1d7aa5 --- /dev/null +++ b/web/api/integration/slack/slack_utils.py @@ -0,0 +1,97 @@ +"""Slack application utils.""" + +from typing import Callable + +import docq.integrations.slack.manage_slack as manage_slack +import docq.integrations.slack.manage_slack_messages as manage_slack_messages +import scipy as sp +import streamlit as st +from opentelemetry import trace +from slack_sdk import WebClient + +tracer = trace.get_tracer(__name__) + + +@st.cache_data(ttl=6000) +def get_org_id(team_id: str) -> int | None: + """Get the org id for a Slack team / workspace.""" + result = manage_slack.list_docq_slack_installations(org_id=None, team_id=team_id) + return result[0].org_id if result else None + + +@tracer.start_as_current_span(name="list_slack_team_channels") +def list_slack_team_channels(app_id: str, team_id: str) -> list[dict[str, str]]: + """List Slack team channels.""" + token = manage_slack.get_slack_bot_token(app_id, team_id) + client = WebClient(token=token) + response = client.conversations_list(team_id=team_id, exclude_archived=True, types="public_channel, private_channel") + + return [ channel for channel in response["channels"] if channel["is_member"] ] + +@tracer.start_as_current_span(name="message_handled_middleware") +def message_handled_middleware(ack: Callable, body: dict, next_: Callable) -> None: + """Middleware to check if a message has already been handled. This prevents duplicate processing of messages.""" + span = trace.get_current_span() + ack() + + client_msg_id, ts, team_id = body["event"]["client_msg_id"], body["event"]["ts"], body["event"]["team"] + org_id = get_org_id(team_id) + span.set_attributes( + attributes={ + "event__client_msg_id": client_msg_id, + "event__ts": ts, + "event__team_id": team_id, + "org_id": org_id if org_id else "None", + } + ) + if org_id is None: + span.record_exception(ValueError(f"No Org ID found for Slack team ID '{team_id}'")) + span.set_status(trace.StatusCode.ERROR, "No Org ID found") + raise ValueError(f"No Org ID found for Slack team ID '{team_id}'") + message_handled = manage_slack_messages.is_message_handled(client_msg_id, ts, org_id) + span.set_attribute("message_handled", message_handled) + if message_handled: + return + next_() + +@tracer.start_as_current_span(name="persist_message_middleware") +def persist_message_middleware(body: dict, next_: Callable) -> None: + """Middleware to persist messages.""" + span = trace.get_current_span() + client_msg_id = body["event"]["client_msg_id"] + type_ = body["event"]["type"] + channel = body["event"]["channel"] + team = body["event"]["team"] + user = body["event"]["user"] + text = body["event"]["text"] + ts = body["event"]["ts"] + span.set_attributes( + attributes={ + "event__client_msg_id": client_msg_id, + "event__type": type_, + "event__channel": channel, + "event__team": team, + "event__user": user, + "event__ts": ts, + } + ) + org_id = get_org_id(team) + span.set_attributes( + attributes={ + "event__client_msg_id": client_msg_id, + "event__type": type_, + "event__channel": channel, + "event__team": team, + "event__user": user, + "event__ts": ts, + "org_id": org_id if org_id else "None", + } + ) + if org_id is None: + span.record_exception(ValueError(f"No Org ID found for Slack team ID '{team}'")) + span.set_status(trace.StatusCode.ERROR, "No Org ID found") + raise ValueError(f"No Org ID found for Slack team ID '{team}'") + manage_slack_messages.insert_or_update_message( + client_msg_id=client_msg_id, type_=type_, channel=channel, team=team, user=user, text=text, ts=ts, org_id=org_id + ) + next_() diff --git a/web/api/integration/utils.py b/web/api/integration/utils.py new file mode 100644 index 00000000..a80e32c0 --- /dev/null +++ b/web/api/integration/utils.py @@ -0,0 +1,32 @@ +"""Slack utility functions.""" + +import docq.integrations.slack.manage_slack as manage_slack +from docq.manage_assistants import get_personas_fixed +from docq.model_selection.main import get_model_settings_collection, get_saved_model_settings_collection +from docq.support.llm import run_ask, run_chat + + +def chat_completion(text: str) -> str: + """Middleware to handle chat completion.""" + input_ = text + history = "" + model_settings_collection = get_model_settings_collection("azure_openai_latest") + assistant = get_personas_fixed(model_settings_collection.key)["default"] + response = run_chat(input_, history, model_settings_collection, assistant) + return response.response + + +def rag_completion(text: str, channel_id: str) -> str: + """Middleware to handle RAG completion.""" + spaces = manage_slack.get_rag_spaces(channel_id) + org_id = manage_slack.get_org_id_from_channel_id(channel_id) + + if not spaces: + return "This channel is not configured in Docq. Please contact your administrator to setup the channel.\nhttps://docq.ai" + + history = "" + model_collection_settings = get_saved_model_settings_collection(org_id) if org_id else get_model_settings_collection("azure_openai_latest") + assistant = get_personas_fixed(model_collection_settings.key)["default"] + response = run_ask(text, history, model_collection_settings, assistant, spaces) + + return str(response.response) if response else "I am sorry, I could not find any relevant information." # type: ignore diff --git a/web/utils/handlers.py b/web/utils/handlers.py index 126bf538..b5fb9a25 100644 --- a/web/utils/handlers.py +++ b/web/utils/handlers.py @@ -4,10 +4,11 @@ import hashlib import logging as log import math +import os import random import re import time -from datetime import datetime +from datetime import datetime, timedelta from typing import Any, Dict, List, Optional, Tuple from urllib.parse import unquote_plus @@ -30,6 +31,8 @@ from docq.data_source.list import SpaceDataSources from docq.domain import DocumentListItem, SpaceKey from docq.extensions import ExtensionContext, _registered_extensions +from docq.integrations.slack import manage_slack +from docq.integrations.slack.models import SlackInstallation from docq.manage_assistants import get_assistant_or_default from docq.model_selection.main import ( LlmUsageSettingsCollection, @@ -37,6 +40,7 @@ get_saved_model_settings_collection, ) from docq.services.smtp_service import mailer_ready, send_verification_email +from docq.support.auth_utils import _get_cookies as get_cookies from docq.support.auth_utils import reset_cache_and_cookie_auth_session from opentelemetry import baggage, trace from pydantic import RootModel @@ -65,6 +69,7 @@ set_selected_org_id, set_settings_session, ) +from .streamlit_application import st_app tracer = trace.get_tracer("docq.web.handler") @@ -1044,7 +1049,8 @@ def handle_get_user_email() -> Optional[str]: def handle_redirect_to_url(url: str, key: str) -> None: """Redirect to url.""" - html(f""" + html( + f""" - """, height=0 + """, + height=0, ) @@ -1082,3 +1089,67 @@ def handle_click_chat_history_thread(feature: domain.FeatureKey, thread_id: int) set_chat_session(datetime.now(), feature.type_, SessionKeyNameForChat.CUTOFF) set_chat_session([], feature.type_, SessionKeyNameForChat.HISTORY) query_chat_history(feature) + + +@st.cache_data(ttl=300) +def handle_list_slack_channels(app_id: str, team_id: str) -> Any: + """Handle list slack channels.""" + from web.api.integration.slack.slack_utils import list_slack_team_channels + + with st.spinner("Loading channels..."): + channel_lists = list_slack_team_channels(app_id, team_id) + return channel_lists + + +def handle_list_slack_installations() -> list [SlackInstallation]: + """Handle list slack installations.""" + selected_org_id = get_selected_org_id() + if selected_org_id is not None: + return manage_slack.list_docq_slack_installations(org_id=selected_org_id, team_id=None) + return [] + + +def handle_set_cookie(name: str, value: str, expiry: datetime, path: str = "/", secure: bool = True) -> None: + """Handle set cookie.""" + from docq.support.auth_utils import encrypt_cookie_value + + value = encrypt_cookie_value(value) + html(f""" + + """, height=0) + + +def handle_install_docq_slack_application(app_state: int = 0) -> None: + """Handle install docq slack application.""" + selected_org_id = get_selected_org_id() + slack_app_state = f"state:{selected_org_id}" + expiry = datetime.now() + timedelta(minutes=5) + handle_set_cookie(name="docq_slack_app_state", value=slack_app_state, expiry=expiry) + path = "/api/integration/slack/v1/install" + handle_redirect_to_url(f"{path}", "slack-install") + + +def handle_link_slack_channel_to_space_group(channel_id: str, channel_name: str) -> None: + """Handle link slack channel to space group.""" + selected_org_id = get_selected_org_id() + space_group = st.session_state[f"selected_space_group_{channel_id}"] + if selected_org_id is not None: + manage_slack.link_space_group_to_slack_channel( + org_id=selected_org_id, channel_id=channel_id, + channel_name=channel_name, + space_group_id=space_group[0] + ) + + +def handle_get_linked_space_group_index(channel_id: str, space_groups: list[tuple]) -> Optional[int]: + """Get linked space group id.""" + selected_org_id = get_selected_org_id() + if selected_org_id is not None: + space_group_id = manage_slack.get_slack_channel_linked_space_group_id(selected_org_id, channel_id) + for i, space_group in enumerate(space_groups): + if space_group[0] == space_group_id: + return i + return None diff --git a/web/utils/layout.py b/web/utils/layout.py index 3a691aec..ee863a6a 100644 --- a/web/utils/layout.py +++ b/web/utils/layout.py @@ -21,8 +21,9 @@ SystemFeatureType, SystemSettingsKey, ) -from docq.domain import AssistantType, ConfigKey, DocumentListItem, FeatureKey, SourcePageType, SpaceKey +from docq.domain import AssistantType, ConfigKey, DocumentListItem, FeatureKey, SpaceKey from docq.extensions import ExtensionContext +from docq.integrations.slack.models import SlackInstallation from docq.manage_assistants import list_assistants from docq.model_selection.main import ( LlmUsageSettingsCollection, @@ -75,12 +76,17 @@ handle_fire_extensions_callbacks, handle_get_chat_history_threads, handle_get_gravatar_url, + handle_get_linked_space_group_index, handle_get_system_settings, handle_get_thread_space, handle_get_user_email, handle_index_thread_space, + handle_install_docq_slack_application, + handle_link_slack_channel_to_space_group, handle_list_documents, handle_list_orgs, + handle_list_slack_channels, + handle_list_slack_installations, handle_login, handle_logout, handle_manage_space_permissions, @@ -400,7 +406,7 @@ def __not_authorised() -> None: st.info( f"You're logged in as `{get_auth_session()[SessionKeyNameForAuth.NAME.name]}`. Please login as a different user with correct permissions to try again." ) - st.stop() + # st.stop() def public_access() -> None: @@ -1186,14 +1192,11 @@ def organisation_settings_ui() -> None: ) available_models = list_available_model_settings_collections() - log.debug("available models %s", available_models) - saved_model = ( - settings[OrganisationSettingsKey.MODEL_COLLECTION.name] - if OrganisationSettingsKey.MODEL_COLLECTION.name in settings - else None - ) - log.debug("saved model: %s", saved_model) + span.set_attribute("available_models", str(available_models)) + saved_model = settings.get(OrganisationSettingsKey.MODEL_COLLECTION.name, None) + + span.set_attribute("saved_model", saved_model) list_keys = list(available_models.keys()) saved_model_index = list_keys.index(saved_model) if saved_model and list_keys.count(saved_model) > 0 else 0 @@ -1204,27 +1207,37 @@ def organisation_settings_ui() -> None: index=saved_model_index, key=f"org_settings_default_{OrganisationSettingsKey.MODEL_COLLECTION.name}", ) - log.debug( - "selected model in session state: %s", - st.session_state[f"org_settings_default_{OrganisationSettingsKey.MODEL_COLLECTION.name}"][0], + # log.debug( + # "selected model in session state: %s", + # st.session_state[f"org_settings_default_{OrganisationSettingsKey.MODEL_COLLECTION.name}"][0], + # ) + # log.debug("selected model: %s", selected_model[0]) + + span.set_attributes( + { + "selected_model": selected_model if selected_model else "None", + "selected_model_in_state": st.session_state[ + f"org_settings_default_{OrganisationSettingsKey.MODEL_COLLECTION.name}" + ], + } ) - log.debug("selected model: %s", selected_model[0]) - selected_model_settings: LlmUsageSettingsCollection = get_model_settings_collection(selected_model[0]) - - with model_settings_container.expander("Model details"): - for _, model_settings in selected_model_settings.model_usage_settings.items(): - st.write(f"{model_settings.model_capability.value} model: ") - st.write(f"- Model Vendor: `{model_settings.service_instance_config.vendor.value}`") - st.write(f"- Model Name: `{model_settings.service_instance_config.model_name}`") - st.write(f"- Temperature: `{model_settings.temperature}`") - st.write( - f"- Deployment Name: `{model_settings.service_instance_config.model_deployment_name if model_settings.service_instance_config.model_deployment_name else 'n/a'}`" - ) - st.write( - f"- License: `{model_settings.service_instance_config.license_ if model_settings.service_instance_config.license_ else 'unknown'}`" - ) - st.write(f"- Citation: `{model_settings.service_instance_config.citation}`") - st.divider() + if selected_model: + selected_model_settings: LlmUsageSettingsCollection = get_model_settings_collection(selected_model[0]) + + with model_settings_container.expander("Model details"): + for _, model_settings in selected_model_settings.model_usage_settings.items(): + st.write(f"{model_settings.model_capability.value} model: ") + st.write(f"- Model Vendor: `{model_settings.service_instance_config.vendor.value}`") + st.write(f"- Model Name: `{model_settings.service_instance_config.model_name}`") + st.write(f"- Temperature: `{model_settings.temperature}`") + st.write( + f"- Deployment Name: `{model_settings.service_instance_config.model_deployment_name if model_settings.service_instance_config.model_deployment_name else 'n/a'}`" + ) + st.write( + f"- License: `{model_settings.service_instance_config.license_ if model_settings.service_instance_config.license_ else 'unknown'}`" + ) + st.write(f"- Citation: `{model_settings.service_instance_config.citation}`") + st.divider() def _get_create_space_config_input_values() -> str: @@ -1809,3 +1822,58 @@ def verify_email_ui() -> None: st.error("Email verification failed!") st.info("Please try again or contact your administrator.") + +def render_integrations() -> None: + """Render integrations.""" + teams = handle_list_slack_installations() + team: Optional[SlackInstallation] = st.selectbox( + "Select a slack team", + options=teams, + format_func=lambda x: x.team_name, + key="selected_slack_team" + ) + + st.divider() + st.write("### Channels") + + if team is not None: + slack_channels = handle_list_slack_channels(team.app_id, team.team_id) + space_groups = list_space_groups() + space_groups_exist = len(space_groups) > 0 + slack_channels_exist = len(slack_channels) > 0 + + for channel in slack_channels: + with st.expander(f"### {channel['name']}"): + st.write(channel['purpose']['value']) + if space_groups_exist: + selected_space_group = st.selectbox( + "Select a space group", + options=space_groups, + format_func=lambda x: x[2], + key=f"selected_space_group_{channel['id']}", + index=handle_get_linked_space_group_index(channel["id"], space_groups), + ) + _, save_btn, _ = st.columns([1, 1, 1]) + disable_save_button = selected_space_group is None + save_btn.button( + "Save Space Group Selection", + on_click=handle_link_slack_channel_to_space_group, + key=f"save_space_group_selection_{channel['id']}", + args=(channel["id"], channel["name"]), + disabled=disable_save_button, + ) + else: + st.info("No Space Groups found. Create a Space Group first.") + + if not slack_channels_exist: + st.info("No slack channels found") + else: + st.info("No slack teams found") + + +def render_slack_installation_button() -> None: + """Render slack installation button.""" + _, center, _ = st.columns([1, 1, 1]) + with center: + if st.button("Install Docq Slack Application", type="primary", use_container_width=True): + handle_install_docq_slack_application() diff --git a/web/utils/streamlit_application.py b/web/utils/streamlit_application.py index f962266e..dcfbedfe 100644 --- a/web/utils/streamlit_application.py +++ b/web/utils/streamlit_application.py @@ -3,7 +3,7 @@ import gc import logging import re -from typing import Callable, List, Self, Type +from typing import Callable, List, Optional, Self, Type from tornado.routing import PathMatches, Rule from tornado.web import Application, RequestHandler @@ -53,7 +53,7 @@ def add_route_handler(self: Self, rule: Rule) -> None: # tornado_app.wildcard_router.rules.insert(0,rule) # logging.debug("Registered %s routes with the Streamlit Tornado Application instance.", len(self._rules)) - def api_route(self: Self, path: str) -> Callable[[Type[RequestHandler]], Type[RequestHandler]]: + def api_route(self: Self, path: str, kwargs: Optional[dict] = None) -> Callable[[Type[RequestHandler]], Type[RequestHandler]]: """Decorator factory for adding a route to a handler. Example: @@ -99,7 +99,7 @@ def convert_args_in_path_to_regex(match: re.Match) -> str: def decorator(cls: Type[RequestHandler]) -> Type[RequestHandler]: logging.debug("Decorator adding route handler: %s", cls) - self.add_route_handler(Rule(PathMatches(path), cls)) + self.add_route_handler(Rule(PathMatches(path), cls, target_kwargs=kwargs)) return cls return decorator