diff --git a/packages/models-library/src/models_library/conversations.py b/packages/models-library/src/models_library/conversations.py index a0eb177b7f7a..60ec0be59fff 100644 --- a/packages/models-library/src/models_library/conversations.py +++ b/packages/models-library/src/models_library/conversations.py @@ -38,7 +38,10 @@ class ConversationMessageType(StrAutoEnum): # -IsSupportUser: TypeAlias = bool +class ConversationUserType(StrAutoEnum): + SUPPORT_USER = auto() + CHATBOT_USER = auto() + REGULAR_USER = auto() class ConversationGetDB(BaseModel): @@ -58,7 +61,29 @@ class ConversationGetDB(BaseModel): modified: datetime last_message_created_at: datetime - model_config = ConfigDict(from_attributes=True) + model_config = ConfigDict( + from_attributes=True, + json_schema_extra={ + "examples": [ + # Support message + { + "conversation_id": "42838344-03de-4ce2-8d93-589a5dcdfd05", + "product_name": "osparc", + "name": "test_conversation", + "project_uuid": "42838344-03de-4ce2-8d93-589a5dcdfd05", + "user_group_id": "789", + "type": ConversationType.SUPPORT, + "extra_context": {}, + "fogbugz_case_id": None, + "is_read_by_user": False, + "is_read_by_support": False, + "created": "2024-01-01T12:00:00", + "modified": "2024-01-01T12:00:00", + "last_message_created_at": "2024-01-01T12:00:00", + } + ] + }, + ) class ConversationMessageGetDB(BaseModel): diff --git a/packages/models-library/src/models_library/rabbitmq_messages.py b/packages/models-library/src/models_library/rabbitmq_messages.py index 8ea54f2e9e55..34a297470ec8 100644 --- a/packages/models-library/src/models_library/rabbitmq_messages.py +++ b/packages/models-library/src/models_library/rabbitmq_messages.py @@ -9,6 +9,7 @@ from common_library.basic_types import DEFAULT_FACTORY from pydantic import BaseModel, Field +from .conversations import ConversationGetDB, ConversationMessageID from .products import ProductName from .progress_bar import ProgressReport from .projects import ProjectID @@ -93,6 +94,17 @@ def routing_key(self) -> str | None: return None +class WebserverChatbotRabbitMessage(RabbitMessageBase): + channel_name: Literal["simcore.services.webserver-chatbot"] = ( + "simcore.services.webserver-chatbot" + ) + conversation: ConversationGetDB + last_message_id: ConversationMessageID + + def routing_key(self) -> str | None: + return None + + class ProgressType(StrAutoEnum): COMPUTATION_RUNNING = auto() # NOTE: this is the original only progress report diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/e1c7e416461c_add_chatbot_user_id_to_products_table.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/e1c7e416461c_add_chatbot_user_id_to_products_table.py new file mode 100644 index 000000000000..842e0386ce8d --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/e1c7e416461c_add_chatbot_user_id_to_products_table.py @@ -0,0 +1,42 @@ +"""add chatbot user id to products table + +Revision ID: e1c7e416461c +Revises: f641b3eacafd +Create Date: 2025-10-16 07:51:44.033767+00:00 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "e1c7e416461c" +down_revision = "f641b3eacafd" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "products", sa.Column("support_chatbot_user_id", sa.BigInteger(), nullable=True) + ) + op.create_foreign_key( + "fk_products_support_chatbot_user_id", + "products", + "users", + ["support_chatbot_user_id"], + ["id"], + onupdate="CASCADE", + ondelete="SET NULL", + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint( + "fk_products_support_chatbot_user_id", "products", type_="foreignkey" + ) + op.drop_column("products", "support_chatbot_user_id") + # ### end Alembic commands ### diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/ff13501db935_add_base_url_to_product_table.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/ff13501db935_add_base_url_to_product_table.py new file mode 100644 index 000000000000..d22df4c348de --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/ff13501db935_add_base_url_to_product_table.py @@ -0,0 +1,32 @@ +"""Add base_url to product table + +Revision ID: ff13501db935 +Revises: e1c7e416461c +Create Date: 2025-10-17 14:48:02.509847+00:00 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "ff13501db935" +down_revision = "e1c7e416461c" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("products", sa.Column("base_url", sa.String(), nullable=True)) + # ### end Alembic commands ### + + op.execute("UPDATE products SET base_url = 'http://CHANGE_ME.localhost'") + + op.alter_column("products", "base_url", existing_type=sa.String(), nullable=True) + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("products", "base_url") + # ### end Alembic commands ### diff --git a/packages/postgres-database/src/simcore_postgres_database/models/products.py b/packages/postgres-database/src/simcore_postgres_database/models/products.py index 414e7e4b2c06..105e0f43f291 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/products.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/products.py @@ -19,6 +19,7 @@ from .base import metadata from .groups import groups from .jinja2_templates import jinja2_templates +from .users import users # NOTE: a default entry is created in the table Product # see packages/postgres-database/src/simcore_postgres_database/migration/versions/350103a7efbd_modified_products_table.py @@ -153,6 +154,12 @@ class ProductLoginSettingsDict(TypedDict, total=False): nullable=False, doc="Regular expression that matches product hostname from an url string", ), + sa.Column( + "base_url", + sa.String, + nullable=False, + doc="Product base URL (scheme + host), ex. https://osparc.io", + ), # EMAILS -------------------- sa.Column( "support_email", @@ -281,6 +288,19 @@ class ProductLoginSettingsDict(TypedDict, total=False): nullable=True, doc="Group associated to this product support", ), + sa.Column( + "support_chatbot_user_id", + sa.BigInteger, + sa.ForeignKey( + users.c.id, + name="fk_products_support_chatbot_user_id", + ondelete=RefActions.SET_NULL, + onupdate=RefActions.CASCADE, + ), + unique=False, + nullable=True, + doc="User associated to this product chatbot user", + ), sa.Column( "support_assigned_fogbugz_person_id", sa.BigInteger, diff --git a/packages/postgres-database/tests/conftest.py b/packages/postgres-database/tests/conftest.py index 9016cff0d322..880a18d1fbf7 100644 --- a/packages/postgres-database/tests/conftest.py +++ b/packages/postgres-database/tests/conftest.py @@ -354,7 +354,9 @@ async def _creator(product_name: str) -> Row: async with asyncpg_engine.begin() as connection: result = await connection.execute( sa.insert(products) - .values(name=product_name, host_regex=".*") + .values( + name=product_name, host_regex=".*", base_url="https://example.com" + ) .returning(sa.literal_column("*")) ) assert result diff --git a/packages/postgres-database/tests/products/test_models_products.py b/packages/postgres-database/tests/products/test_models_products.py index 1f34fab7aa49..4bab5fb0c433 100644 --- a/packages/postgres-database/tests/products/test_models_products.py +++ b/packages/postgres-database/tests/products/test_models_products.py @@ -67,11 +67,13 @@ async def test_jinja2_templates_table( { "name": "osparc", "host_regex": r"^osparc.", + "base_url": "https://osparc.io", "registration_email_template": registration_email_template, }, { "name": "s4l", "host_regex": r"(^s4l[\.-])|(^sim4life\.)", + "base_url": "https://sim4life.info", "short_name": "s4l web", "registration_email_template": registration_email_template, }, @@ -79,6 +81,7 @@ async def test_jinja2_templates_table( "name": "tis", "short_name": "TIP", "host_regex": r"(^ti.[\.-])|(^ti-solution\.)", + "base_url": "https://tis.com", }, ]: # aiopg doesn't support executemany!! @@ -133,6 +136,7 @@ async def test_insert_select_product( "display_name": "o²S²PARC", "short_name": "osparc", "host_regex": r"([\.-]{0,1}osparc[\.-])", + "base_url": "https://osparc.io", "support_email": "foo@osparc.io", "twilio_messaging_sid": None, "vendor": Vendor( diff --git a/packages/postgres-database/tests/test_utils_services.py b/packages/postgres-database/tests/test_utils_services.py index 70b102fea70e..7560b8f80718 100644 --- a/packages/postgres-database/tests/test_utils_services.py +++ b/packages/postgres-database/tests/test_utils_services.py @@ -151,6 +151,7 @@ def services_fixture(faker: Faker, pg_sa_engine: sa.engine.Engine) -> ServicesFi "display_name": "Product Osparc", "short_name": "osparc", "host_regex": r"^osparc.", + "base_url": "https://osparc.io", "priority": 0, } product_name = conn.execute( diff --git a/packages/postgres-database/tests/test_utils_services_environments.py b/packages/postgres-database/tests/test_utils_services_environments.py index 0a58704d3f8d..a8fc5628e36d 100644 --- a/packages/postgres-database/tests/test_utils_services_environments.py +++ b/packages/postgres-database/tests/test_utils_services_environments.py @@ -34,7 +34,9 @@ class ExpectedSecrets(NamedTuple): async def product_name(connection: SAConnection) -> str: a_product_name = "a_prod" await connection.execute( - products.insert().values(name=a_product_name, host_regex="") + products.insert().values( + name=a_product_name, host_regex="", base_url="http://example.com" + ) ) yield a_product_name await connection.execute(products.delete()) diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/faker_factories.py b/packages/pytest-simcore/src/pytest_simcore/helpers/faker_factories.py index fe43da30f158..1d78bd25601f 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/faker_factories.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/faker_factories.py @@ -306,6 +306,7 @@ def random_product( "display_name": suffix.capitalize().replace("_", " "), "short_name": suffix[:4], "host_regex": r"[a-zA-Z0-9]+\.com", + "base_url": f"https://{suffix}.com", "support_email": f"support@{suffix}.io", "product_owners_email": fake.random_element( elements=[f"product-owners@{suffix}.io", None] diff --git a/services/docker-compose.yml b/services/docker-compose.yml index 8919cecf3e7c..223277d9b1a3 100644 --- a/services/docker-compose.yml +++ b/services/docker-compose.yml @@ -24,7 +24,6 @@ x-tracing-open-telemetry: &tracing_open_telemetry_environments TRACING_OPENTELEMETRY_COLLECTOR_PORT: ${TRACING_OPENTELEMETRY_COLLECTOR_PORT} TRACING_OPENTELEMETRY_SAMPLING_PROBABILITY: ${TRACING_OPENTELEMETRY_SAMPLING_PROBABILITY} - ## third-party party services x-postgres-settings: &postgres_settings POSTGRES_DB: ${POSTGRES_DB} @@ -66,7 +65,6 @@ x-s3-settings: &s3_settings S3_REGION: ${S3_REGION} S3_SECRET_KEY: ${S3_SECRET_KEY} - x-smtp-settings: &smtp_settings SMTP_HOST: ${SMTP_HOST} SMTP_PORT: ${SMTP_PORT} @@ -74,7 +72,6 @@ x-smtp-settings: &smtp_settings SMTP_PASSWORD: ${SMTP_PASSWORD} SMTP_PROTOCOL: ${SMTP_PROTOCOL} - ## simcore stack services x-catalog-settings: &catalog_settings @@ -108,7 +105,6 @@ x-invitations-settings: &invitations_settings INVITATIONS_SECRET_KEY: ${INVITATIONS_SECRET_KEY} INVITATIONS_OSPARC_URL: ${INVITATIONS_OSPARC_URL} - services: api-server: image: ${DOCKER_REGISTRY:-itisfoundation}/api-server:${DOCKER_IMAGE_TAG:-latest} @@ -157,7 +153,6 @@ services: networks: &api_server_networks - default - api-worker: image: ${DOCKER_REGISTRY:-itisfoundation}/api-server:${DOCKER_IMAGE_TAG:-latest} init: true @@ -172,7 +167,6 @@ services: CELERY_QUEUES: api_worker_queue networks: *api_server_networks - autoscaling: image: ${DOCKER_REGISTRY:-itisfoundation}/autoscaling:${DOCKER_IMAGE_TAG:-latest} init: true @@ -433,7 +427,6 @@ services: RESOURCE_USAGE_TRACKER_HOST: ${RESOURCE_USAGE_TRACKER_HOST} RESOURCE_USAGE_TRACKER_PORT: ${RESOURCE_USAGE_TRACKER_EXTERNAL_PORT} - STORAGE_HOST: ${STORAGE_HOST} STORAGE_PORT: ${STORAGE_PORT} DIRECTOR_V2_NODE_PORTS_STORAGE_AUTH: ${DIRECTOR_V2_NODE_PORTS_STORAGE_AUTH} @@ -562,7 +555,6 @@ services: RESOURCE_USAGE_TRACKER_TRACING: ${RESOURCE_USAGE_TRACKER_TRACING} RESOURCE_USAGE_TRACKER_PORT: ${RESOURCE_USAGE_TRACKER_PORT} - dynamic-schdlr: image: ${DOCKER_REGISTRY:-itisfoundation}/dynamic-scheduler:${DOCKER_IMAGE_TAG:-latest} init: true @@ -597,8 +589,6 @@ services: DYNAMIC_SCHEDULER_USE_INTERNAL_SCHEDULER: ${DYNAMIC_SCHEDULER_USE_INTERNAL_SCHEDULER} DYNAMIC_SIDECAR_API_SAVE_RESTORE_STATE_TIMEOUT: ${DYNAMIC_SIDECAR_API_SAVE_RESTORE_STATE_TIMEOUT} - - docker-api-proxy: image: ${DOCKER_REGISTRY:-itisfoundation}/docker-api-proxy:${DOCKER_IMAGE_TAG:-latest} init: true @@ -717,6 +707,7 @@ services: WEBSERVER_CATALOG: ${WEBSERVER_CATALOG} WEBSERVER_CHATBOT: ${WEBSERVER_CHATBOT} + WEBSERVER_CONVERSATIONS: "true" # WEBSERVER_CREDIT_COMPUTATION WEBSERVER_CREDIT_COMPUTATION_ENABLED: ${WEBSERVER_CREDIT_COMPUTATION_ENABLED} @@ -886,6 +877,9 @@ services: WEBSERVER_PORT: ${WB_API_WEBSERVER_PORT} WEBSERVER_RPC_NAMESPACE: ${WB_API_WEBSERVER_HOST} WEBSERVER_STATICWEB: "null" + WEBSERVER_CONVERSATIONS: "false" # override *webserver_environment + WEBSERVER_CHATBOT: "null" # override *webserver_environment + WEBSERVER_FOGBUGZ: "null" # override *webserver_environment # NOTE: keep in sync with the prefix form the hostname LONG_RUNNING_TASKS_NAMESPACE_SUFFIX: api @@ -928,6 +922,7 @@ services: WEBSERVER_ANNOUNCEMENTS: ${WB_DB_EL_ANNOUNCEMENTS} WEBSERVER_CATALOG: ${WB_DB_EL_CATALOG} WEBSERVER_CHATBOT: "null" + WEBSERVER_CONVERSATIONS: "false" WEBSERVER_CELERY: "null" WEBSERVER_DB_LISTENER: ${WB_DB_EL_DB_LISTENER} WEBSERVER_DIAGNOSTICS: ${WB_DB_EL_DIAGNOSTICS} @@ -957,7 +952,6 @@ services: WEBSERVER_USERS: ${WB_DB_EL_USERS} WEBSERVER_WALLETS: ${WB_DB_EL_WALLETS} - RESOURCE_MANAGER_RESOURCE_TTL_S: ${RESOURCE_MANAGER_RESOURCE_TTL_S} deploy: @@ -987,7 +981,6 @@ services: GUNICORN_CMD_ARGS: ${WEBSERVER_GUNICORN_CMD_ARGS} - # NOTE: keep in sync with the prefix form the hostname LONG_RUNNING_TASKS_NAMESPACE_SUFFIX: gc @@ -1012,6 +1005,7 @@ services: WEBSERVER_ANNOUNCEMENTS: ${WB_GC_ANNOUNCEMENTS} WEBSERVER_CATALOG: ${WB_GC_CATALOG} WEBSERVER_CHATBOT: "null" + WEBSERVER_CONVERSATIONS: "false" WEBSERVER_CELERY: "null" WEBSERVER_DB_LISTENER: ${WB_GC_DB_LISTENER} WEBSERVER_DIAGNOSTICS: ${WB_GC_DIAGNOSTICS} @@ -1084,11 +1078,11 @@ services: SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE} SESSION_COOKIE_HTTPONLY: ${SESSION_COOKIE_HTTPONLY} - WEBSERVER_ACTIVITY: "null" WEBSERVER_ANNOUNCEMENTS: 0 WEBSERVER_CATALOG: "null" WEBSERVER_CHATBOT: "null" + WEBSERVER_CONVERSATIONS: "false" WEBSERVER_CELERY: "null" WEBSERVER_DB_LISTENER: 0 WEBSERVER_DIRECTOR_V2: "null" @@ -1226,7 +1220,6 @@ services: DATCORE_ADAPTER_LOG_FORMAT_LOCAL_DEV_ENABLED: ${LOG_FORMAT_LOCAL_DEV_ENABLED} DATCORE_ADAPTER_TRACING: ${DATCORE_ADAPTER_TRACING} - storage: image: ${DOCKER_REGISTRY:-itisfoundation}/storage:${DOCKER_IMAGE_TAG:-latest} init: true @@ -1327,7 +1320,15 @@ services: - default - interactive_services_subnet healthcheck: - test: [ "CMD", "pg_isready", "--username", "${POSTGRES_USER}", "--dbname", "${POSTGRES_DB}" ] + test: + [ + "CMD", + "pg_isready", + "--username", + "${POSTGRES_USER}", + "--dbname", + "${POSTGRES_DB}", + ] interval: 5s retries: 5 # NOTES: this is not yet compatible with portainer deployment but could work also for other containers @@ -1341,19 +1342,24 @@ services: # - net.ipv4.tcp_keepalive_probes=9 # - net.ipv4.tcp_keepalive_time=600 # - command: - [ + command: [ "postgres", - "-c", "tcp_keepalives_idle=600", - "-c", "tcp_keepalives_interval=600", - "-c", "tcp_keepalives_count=5", - "-c", "max_connections=413", - "-c", "shared_buffers=256MB", + "-c", + "tcp_keepalives_idle=600", + "-c", + "tcp_keepalives_interval=600", + "-c", + "tcp_keepalives_count=5", + "-c", + "max_connections=413", + "-c", + "shared_buffers=256MB", # statement_timeout is set to 120 seconds (120_000 in ms), so that long running queries # are killed after 2 minutes. Since simcore services have timeout of 1 minute, so longer # queries will not be used. Setting >1 minutes to be safe # https://github.com/ITISFoundation/osparc-simcore/issues/7682#issuecomment-2923048445 - "-c", "statement_timeout=120000" + "-c", + "statement_timeout=120000", ] redis: @@ -1365,7 +1371,19 @@ services: # also aof (append only) is also enabled such that we get full durability at the expense # of backup size. The backup is written into /data. # https://redis.io/topics/persistence - [ "redis-server", "--save", "60 1", "--loglevel", "verbose", "--databases", "11", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD}" ] + [ + "redis-server", + "--save", + "60 1", + "--loglevel", + "verbose", + "--databases", + "11", + "--appendonly", + "yes", + "--requirepass", + "${REDIS_PASSWORD}", + ] networks: - default - autoscaling_subnet @@ -1373,7 +1391,7 @@ services: volumes: - redis-data:/data healthcheck: - test: [ "CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping" ] + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] interval: 5s timeout: 30s retries: 50 diff --git a/services/web/server/src/simcore_service_webserver/chatbot/_process_chatbot_trigger_service.py b/services/web/server/src/simcore_service_webserver/chatbot/_process_chatbot_trigger_service.py new file mode 100644 index 000000000000..0d17c69ebe0b --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/chatbot/_process_chatbot_trigger_service.py @@ -0,0 +1,127 @@ +import functools +import logging +from collections.abc import AsyncIterator +from typing import Final + +from aiohttp import web +from models_library.basic_types import IDStr +from models_library.conversations import ConversationMessageType, ConversationUserType +from models_library.rabbitmq_messages import WebserverChatbotRabbitMessage +from models_library.rest_ordering import OrderBy, OrderDirection +from pydantic import TypeAdapter +from servicelib.logging_utils import log_catch, log_context +from servicelib.rabbitmq import RabbitMQClient + +from ..conversations import conversations_service +from ..conversations.errors import ConversationErrorNotFoundError +from ..products import products_service +from ..rabbitmq import get_rabbitmq_client +from .chatbot_service import get_chatbot_rest_client + +_logger = logging.getLogger(__name__) + + +_RABBITMQ_WEBSERVER_CHATBOT_CONSUMER_APPKEY: Final = web.AppKey( + "RABBITMQ_WEBSERVER_CHATBOT_CONSUMER", str +) + +_CHATBOT_PROCESS_MESSAGE_TTL_IN_MS = 2 * 60 * 60 * 1000 # 2 hours + + +async def _process_chatbot_trigger_message(app: web.Application, data: bytes) -> bool: + rabbit_message = TypeAdapter(WebserverChatbotRabbitMessage).validate_json(data) + assert app # nosec + + _product_name = rabbit_message.conversation.product_name + _product = products_service.get_product(app, product_name=_product_name) + + if _product.support_chatbot_user_id is None: + _logger.error( + "Product %s does not have support_chatbot_user_id configured, cannot process chatbot message. (This should not happen)", + _product_name, + ) + return True # return true to avoid re-processing + + # Get last 20 messages for the conversation ID + _, messages = await conversations_service.list_messages_for_conversation( + app=app, + conversation_id=rabbit_message.conversation.conversation_id, + offset=0, + limit=20, + order_by=OrderBy(field=IDStr("created"), direction=OrderDirection.DESC), + ) + _question_for_chatbot = "" + for inx, msg in enumerate(messages): + if inx == 0: + # Make last message stand out as the question + _question_for_chatbot += ( + "User last message: \n" + f"{msg.content.strip()} \n\n" + "Previous messages in the conversation: \n" + ) + else: + _question_for_chatbot += f"{msg.content.strip()}\n" + + # Talk to the chatbot service + chatbot_client = get_chatbot_rest_client(app) + chat_response = await chatbot_client.ask_question(_question_for_chatbot) + + try: + await conversations_service.create_support_message( + app=app, + product_name=rabbit_message.conversation.product_name, + user_id=_product.support_chatbot_user_id, + conversation_user_type=ConversationUserType.CHATBOT_USER, + conversation=rabbit_message.conversation, + content=chat_response.answer, + type_=ConversationMessageType.MESSAGE, + ) + except ConversationErrorNotFoundError: + _logger.debug( + "Can not create a support message as conversation %s was not found", + rabbit_message.conversation.conversation_id, + ) + return True + + +async def _subscribe_to_rabbitmq(app) -> str: + with log_context( + _logger, + logging.INFO, + msg=f"Subscribing to {WebserverChatbotRabbitMessage.get_channel_name()} channel", + ): + rabbit_client: RabbitMQClient = get_rabbitmq_client(app) + subscribed_queue, _ = await rabbit_client.subscribe( + WebserverChatbotRabbitMessage.get_channel_name(), + message_handler=functools.partial(_process_chatbot_trigger_message, app), + exclusive_queue=False, + message_ttl=_CHATBOT_PROCESS_MESSAGE_TTL_IN_MS, + ) + return subscribed_queue + + +async def _unsubscribe_from_rabbitmq(app) -> None: + with ( + log_context( + _logger, + logging.INFO, + msg=f"Unsubscribing from {WebserverChatbotRabbitMessage.get_channel_name()} channel", + ), + log_catch(_logger, reraise=False), + ): + rabbit_client: RabbitMQClient = get_rabbitmq_client(app) + if app[_RABBITMQ_WEBSERVER_CHATBOT_CONSUMER_APPKEY]: + await rabbit_client.unsubscribe( + app[_RABBITMQ_WEBSERVER_CHATBOT_CONSUMER_APPKEY] + ) + + +async def on_cleanup_ctx_rabbitmq_consumer( + app: web.Application, +) -> AsyncIterator[None]: + app[_RABBITMQ_WEBSERVER_CHATBOT_CONSUMER_APPKEY] = await _subscribe_to_rabbitmq(app) + + yield + + # cleanup + await _unsubscribe_from_rabbitmq(app) diff --git a/services/web/server/src/simcore_service_webserver/chatbot/plugin.py b/services/web/server/src/simcore_service_webserver/chatbot/plugin.py index c8c24006ddb2..fbea507ec2aa 100644 --- a/services/web/server/src/simcore_service_webserver/chatbot/plugin.py +++ b/services/web/server/src/simcore_service_webserver/chatbot/plugin.py @@ -4,7 +4,9 @@ from ..application_setup import ModuleCategory, app_setup_func from ..products.plugin import setup_products +from ..rabbitmq import setup_rabbitmq from ._client import setup_chatbot_rest_client +from ._process_chatbot_trigger_service import on_cleanup_ctx_rabbitmq_consumer _logger = logging.getLogger(__name__) @@ -17,4 +19,6 @@ ) def setup_chatbot(app: web.Application): setup_products(app) + setup_rabbitmq(app) app.on_startup.append(setup_chatbot_rest_client) + app.cleanup_ctx.append(on_cleanup_ctx_rabbitmq_consumer) diff --git a/services/web/server/src/simcore_service_webserver/conversations/_controller/_conversations_messages_rest.py b/services/web/server/src/simcore_service_webserver/conversations/_controller/_conversations_messages_rest.py index ced1bfb1434e..777c165eb3fe 100644 --- a/services/web/server/src/simcore_service_webserver/conversations/_controller/_conversations_messages_rest.py +++ b/services/web/server/src/simcore_service_webserver/conversations/_controller/_conversations_messages_rest.py @@ -1,8 +1,6 @@ import logging -from typing import Any from aiohttp import web -from common_library.json_serialization import json_dumps from models_library.api_schemas_webserver.conversations import ( ConversationMessagePatch, ConversationMessageRestGet, @@ -18,7 +16,6 @@ PageQueryParameters, ) from models_library.rest_pagination_utils import paginate_data -from models_library.utils.fastapi_encoders import jsonable_encoder from pydantic import BaseModel, ConfigDict from servicelib.aiohttp import status from servicelib.aiohttp.requests_validation import ( @@ -58,10 +55,6 @@ class _ConversationMessageCreateBodyParams(BaseModel): model_config = ConfigDict(extra="forbid") -def _json_encoder_and_dumps(obj: Any, **kwargs): - return json_dumps(jsonable_encoder(obj), **kwargs) - - @routes.post( f"/{VTAG}/conversations/{{conversation_id}}/messages", name="create_conversation_message", @@ -83,21 +76,21 @@ async def create_conversation_message(request: web.Request): raise_unsupported_type(_conversation.type) # This function takes care of granting support user access to the message - _, is_support_user = await _conversation_service.get_support_conversation_for_user( - app=request.app, - user_id=req_ctx.user_id, - product_name=req_ctx.product_name, - conversation_id=path_params.conversation_id, + _, conversation_user_type = ( + await _conversation_service.get_support_conversation_for_user( + app=request.app, + user_id=req_ctx.user_id, + product_name=req_ctx.product_name, + conversation_id=path_params.conversation_id, + ) ) message = await _conversation_message_service.create_support_message( app=request.app, product_name=req_ctx.product_name, user_id=req_ctx.user_id, - is_support_user=is_support_user, + conversation_user_type=conversation_user_type, conversation=_conversation, - request_url=request.url, - request_host=request.host, content=body_params.content, type_=body_params.type, ) diff --git a/services/web/server/src/simcore_service_webserver/conversations/_conversation_message_repository.py b/services/web/server/src/simcore_service_webserver/conversations/_conversation_message_repository.py index dc43f38c74ff..40e8537fce24 100644 --- a/services/web/server/src/simcore_service_webserver/conversations/_conversation_message_repository.py +++ b/services/web/server/src/simcore_service_webserver/conversations/_conversation_message_repository.py @@ -24,7 +24,10 @@ from sqlalchemy.sql import select from ..db.plugin import get_asyncpg_engine -from .errors import ConversationMessageErrorNotFoundError +from .errors import ( + ConversationErrorNotFoundError, + ConversationMessageErrorNotFoundError, +) _logger = logging.getLogger(__name__) @@ -56,7 +59,10 @@ async def create( ) .returning(*_SELECTION_ARGS) ) - row = result.one() + row = result.one_or_none() + if row is None: + raise ConversationErrorNotFoundError(conversation_id=conversation_id) + return ConversationMessageGetDB.model_validate(row) diff --git a/services/web/server/src/simcore_service_webserver/conversations/_conversation_message_service.py b/services/web/server/src/simcore_service_webserver/conversations/_conversation_message_service.py index 99e463781a73..83bf3ef80d20 100644 --- a/services/web/server/src/simcore_service_webserver/conversations/_conversation_message_service.py +++ b/services/web/server/src/simcore_service_webserver/conversations/_conversation_message_service.py @@ -13,18 +13,20 @@ ConversationMessagePatchDB, ConversationMessageType, ConversationPatchDB, + ConversationUserType, ) from models_library.products import ProductName from models_library.projects import ProjectID +from models_library.rabbitmq_messages import WebserverChatbotRabbitMessage from models_library.rest_ordering import OrderBy, OrderDirection from models_library.rest_pagination import PageTotalCount from models_library.users import UserID from servicelib.redis import exclusive -from simcore_service_webserver.application_keys import APP_SETTINGS_APPKEY -from simcore_service_webserver.groups import api as group_service -from yarl import URL +from ..application_keys import APP_SETTINGS_APPKEY +from ..groups import api as group_service from ..products import products_service +from ..rabbitmq import get_rabbitmq_client from ..redis import get_redis_lock_manager_client_sdk from ..users import users_service from . import ( @@ -37,6 +39,7 @@ notify_conversation_message_deleted, notify_conversation_message_updated, ) +from .errors import ConversationError _logger = logging.getLogger(__name__) @@ -112,7 +115,7 @@ async def _create_support_message_with_first_check( *, product_name: ProductName, user_id: UserID, - is_support_user: bool, + conversation_user_type: ConversationUserType, conversation_id: ConversationID, # Creation attributes content: str, @@ -178,12 +181,20 @@ async def _create_support_message_and_check_if_it_is_first_message() -> ( ) # NOTE: Update conversation last modified (for frontend listing) and read states - if is_support_user: - _is_read_by_user = False - _is_read_by_support = True - else: - _is_read_by_user = True - _is_read_by_support = False + match conversation_user_type: + case ConversationUserType.REGULAR_USER: + _is_read_by_user = True + _is_read_by_support = False + case ConversationUserType.SUPPORT_USER: + _is_read_by_user = False + _is_read_by_support = True + case ConversationUserType.CHATBOT_USER: + _is_read_by_user = False + _is_read_by_support = False + case _: + msg = f"Unknown conversation user type: {conversation_user_type}" + raise ConversationError(msg) + await _conversation_repository.update( app, conversation_id=conversation_id, @@ -196,15 +207,32 @@ async def _create_support_message_and_check_if_it_is_first_message() -> ( return message, is_first_message +async def _trigger_chatbot_processing( + app: web.Application, + conversation: ConversationGetDB, + last_message_id: ConversationMessageID, +) -> None: + """Triggers chatbot processing for a specific conversation.""" + rabbitmq_client = get_rabbitmq_client(app) + message = WebserverChatbotRabbitMessage( + conversation=conversation, + last_message_id=last_message_id, + ) + _logger.debug( + "Publishing chatbot processing message with conversation id %s and last message id %s.", + conversation.conversation_id, + last_message_id, + ) + await rabbitmq_client.publish(message.channel_name, message) + + async def create_support_message( app: web.Application, *, product_name: ProductName, user_id: UserID, - is_support_user: bool, + conversation_user_type: ConversationUserType, conversation: ConversationGetDB, - request_url: URL, - request_host: str, # Creation attributes content: str, type_: ConversationMessageType, @@ -213,7 +241,7 @@ async def create_support_message( app=app, product_name=product_name, user_id=user_id, - is_support_user=is_support_user, + conversation_user_type=conversation_user_type, conversation_id=conversation.conversation_id, content=content, type_=type_, @@ -221,7 +249,9 @@ async def create_support_message( product = products_service.get_product(app, product_name=product_name) fogbugz_settings_or_none = app[APP_SETTINGS_APPKEY].WEBSERVER_FOGBUGZ - _conversation_url = f"{request_url.scheme}://{request_url.host}/#/conversation/{conversation.conversation_id}" + _conversation_url = ( + f"{product.base_url}#/conversation/{conversation.conversation_id}" + ) if ( product.support_standard_group_id is None @@ -249,7 +279,7 @@ async def create_support_message( user_id=user_id, message_content=message.content, conversation_url=_conversation_url, - host=request_host, + host=product.base_url.host or "unknown", product_support_assigned_fogbugz_project_id=product.support_assigned_fogbugz_project_id, fogbugz_url=str(fogbugz_settings_or_none.FOGBUGZ_URL), ) @@ -297,6 +327,17 @@ async def create_support_message( ) ) + if ( + product.support_chatbot_user_id + and conversation_user_type == ConversationUserType.CHATBOT_USER + ): + # If enabled, ask Chatbot to analyze the message history and respond + await _trigger_chatbot_processing( + app, + conversation=conversation, + last_message_id=message.message_id, + ) + return message @@ -412,13 +453,16 @@ async def list_messages_for_conversation( # pagination offset: int = 0, limit: int = 20, + # ordering + order_by: OrderBy | None = None, ) -> tuple[PageTotalCount, list[ConversationMessageGetDB]]: return await _conversation_message_repository.list_( app, conversation_id=conversation_id, offset=offset, limit=limit, - order_by=OrderBy( + order_by=order_by + or OrderBy( field=IDStr("created"), direction=OrderDirection.DESC ), # NOTE: Message should be ordered by creation date (latest first) ) diff --git a/services/web/server/src/simcore_service_webserver/conversations/_conversation_service.py b/services/web/server/src/simcore_service_webserver/conversations/_conversation_service.py index facb37be99d3..1bd7da3fb11d 100644 --- a/services/web/server/src/simcore_service_webserver/conversations/_conversation_service.py +++ b/services/web/server/src/simcore_service_webserver/conversations/_conversation_service.py @@ -12,7 +12,7 @@ ConversationID, ConversationPatchDB, ConversationType, - IsSupportUser, + ConversationUserType, ) from models_library.products import ProductName from models_library.projects import ProjectID @@ -185,10 +185,21 @@ async def get_support_conversation_for_user( user_id: UserID, product_name: ProductName, conversation_id: ConversationID, -) -> tuple[ConversationGetDB, IsSupportUser]: +) -> tuple[ConversationGetDB, ConversationUserType]: # Check if user is part of support group (in that case he has access to all support conversations) product = products_service.get_product(app, product_name=product_name) _support_standard_group_id = product.support_standard_group_id + _chatbot_user_id = product.support_chatbot_user_id + + # Check if user is an AI bot + if _chatbot_user_id and user_id == _chatbot_user_id: + return ( + await get_conversation( + app, conversation_id=conversation_id, type_=ConversationType.SUPPORT + ), + ConversationUserType.CHATBOT_USER, + ) + if _support_standard_group_id is not None: _user_group_ids = await list_user_groups_ids_with_read_access( app, user_id=user_id @@ -199,7 +210,7 @@ async def get_support_conversation_for_user( await get_conversation( app, conversation_id=conversation_id, type_=ConversationType.SUPPORT ), - True, + ConversationUserType.SUPPORT_USER, ) _user_group_id = await users_service.get_user_primary_group_id(app, user_id=user_id) @@ -210,7 +221,7 @@ async def get_support_conversation_for_user( user_group_id=_user_group_id, type_=ConversationType.SUPPORT, ), - False, + ConversationUserType.REGULAR_USER, ) @@ -285,7 +296,7 @@ async def create_fogbugz_case_for_support_conversation( fogbugz_client = get_fogbugz_rest_client(app) fogbugz_case_data = FogbugzCaseCreate( fogbugz_project_id=product_support_assigned_fogbugz_project_id, - title=f"Request for Support on {host}", + title=f"Request for Support on {host} by {user['email']}", description=description, ) case_id = await fogbugz_client.create_case(fogbugz_case_data) diff --git a/services/web/server/src/simcore_service_webserver/conversations/conversations_service.py b/services/web/server/src/simcore_service_webserver/conversations/conversations_service.py index 61dcfe3330c7..718679279b72 100644 --- a/services/web/server/src/simcore_service_webserver/conversations/conversations_service.py +++ b/services/web/server/src/simcore_service_webserver/conversations/conversations_service.py @@ -1,6 +1,7 @@ # mypy: disable-error-code=truthy-function from ._conversation_message_service import ( create_message, + create_support_message, delete_message, get_message, list_messages_for_conversation, @@ -25,5 +26,6 @@ "list_messages_for_conversation", "update_conversation", "update_message", + "create_support_message", ) # nopycln: file diff --git a/services/web/server/src/simcore_service_webserver/products/_models.py b/services/web/server/src/simcore_service_webserver/products/_models.py index 127533d16c7a..8753d5a14f4d 100644 --- a/services/web/server/src/simcore_service_webserver/products/_models.py +++ b/services/web/server/src/simcore_service_webserver/products/_models.py @@ -17,6 +17,7 @@ BeforeValidator, ConfigDict, Field, + HttpUrl, PositiveInt, field_serializer, field_validator, @@ -87,6 +88,11 @@ class Product(BaseModel): Field(description="Host regex"), ] + base_url: Annotated[ + HttpUrl, + Field(description="Product Base URL"), + ] + support_email: Annotated[ LowerCaseEmailStr, Field( @@ -143,6 +149,9 @@ class Product(BaseModel): support_standard_group_id: Annotated[ int | None, Field(description="Support standard group ID, None if disabled") ] = None + support_chatbot_user_id: Annotated[ + int | None, Field(description="Support chatbot user ID, None if disabled") + ] = None support_assigned_fogbugz_person_id: Annotated[ int | None, Field(description="Support assigned Fogbugz person ID, None if disabled"), @@ -209,6 +218,7 @@ def _update_json_schema_extra(schema: JsonDict) -> None: # fake mandatory "name": "osparc", "host_regex": r"([\.-]{0,1}osparc[\.-])", + "base_url": "https://osparc.io", "twilio_messaging_sid": "1" * 34, "registration_email_template": "osparc_registration_email", "login_settings": { @@ -229,6 +239,7 @@ def _update_json_schema_extra(schema: JsonDict) -> None: "display_name": "TI PT", "short_name": "TIPI", "host_regex": r"(^tis[\.-])|(^ti-solutions\.)|(^ti-plan\.)", + "base_url": "https://tip.io", "support_email": "support@foo.com", "manual_url": "https://foo.com", "issues_login_url": None, @@ -244,6 +255,7 @@ def _update_json_schema_extra(schema: JsonDict) -> None: "display_name": "o²S²PARC FOO", "short_name": "osparcf", "host_regex": "([\\.-]{0,1}osparcf[\\.-])", + "base_url": "https://osparc.io", "support_email": "foo@osparcf.io", "vendor": { "url": "https://acme.com", @@ -348,3 +360,8 @@ def get_template_name_for(self, filename: str) -> str | None: template_name_attribute: str = getattr(self, name) return template_name_attribute return None + + +class ProductBaseUrl(BaseModel): + scheme: str + host: str diff --git a/services/web/server/src/simcore_service_webserver/products/_repository.py b/services/web/server/src/simcore_service_webserver/products/_repository.py index 64bf33af45e7..c2ad406d5a5a 100644 --- a/services/web/server/src/simcore_service_webserver/products/_repository.py +++ b/services/web/server/src/simcore_service_webserver/products/_repository.py @@ -42,6 +42,7 @@ products.c.display_name, products.c.short_name, products.c.host_regex, + products.c.base_url, products.c.support_email, products.c.product_owners_email, products.c.twilio_messaging_sid, @@ -54,6 +55,7 @@ products.c.max_open_studies_per_user, products.c.group_id, products.c.support_standard_group_id, + products.c.support_chatbot_user_id, products.c.support_assigned_fogbugz_person_id, products.c.support_assigned_fogbugz_project_id, ] diff --git a/services/web/server/src/simcore_service_webserver/products/_web_helpers.py b/services/web/server/src/simcore_service_webserver/products/_web_helpers.py index f70bc79ca97f..370557fafee3 100644 --- a/services/web/server/src/simcore_service_webserver/products/_web_helpers.py +++ b/services/web/server/src/simcore_service_webserver/products/_web_helpers.py @@ -1,4 +1,5 @@ import contextlib +import logging from pathlib import Path import aiofiles @@ -19,6 +20,8 @@ ) from .models import Product +_logger = logging.getLogger(__name__) + def get_product_name(request: web.Request) -> str: """Returns product name in request but might be undefined""" diff --git a/services/web/server/tests/unit/isolated/test_security_web.py b/services/web/server/tests/unit/isolated/test_security_web.py index 166e0b025066..896f8d69e946 100644 --- a/services/web/server/tests/unit/isolated/test_security_web.py +++ b/services/web/server/tests/unit/isolated/test_security_web.py @@ -107,16 +107,19 @@ def app_products(expected_product_name: ProductName) -> OrderedDict[str, Product pp["tis"] = Product( name="tis", host_regex="tis", + base_url="https://tip.io", **column_defaults, ) pp["osparc"] = Product( name="osparc", host_regex="osparc", + base_url="https://osparc.io", **column_defaults, ) pp["s4l"] = Product( name="s4l", host_regex="s4l", + base_url="https://s4l.io", **column_defaults, ) diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py index 3fa5482fddfc..3fd91466466f 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py @@ -305,7 +305,10 @@ def s4l_products_db_name( with postgres_db.connect() as conn: conn.execute( products.insert().values( - name=s4l_product_name, host_regex="pytest", display_name="pytest" + name=s4l_product_name, + host_regex="pytest", + base_url="https://pytest.com", + display_name="pytest", ) ) diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_twofa.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_twofa.py index 832da7c5b9b6..e934e2d06814 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_twofa.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_twofa.py @@ -405,6 +405,7 @@ async def test_send_email_code( name="osparc", display_name="The Foo Product", host_regex=re.compile(r".+"), + base_url="https://osparc.io", vendor={}, short_name="foo", support_email=support_email, diff --git a/services/web/server/tests/unit/with_dbs/04/chatbot/__init__.py b/services/web/server/tests/unit/with_dbs/04/chatbot/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/services/web/server/tests/unit/with_dbs/04/test_chatbot_client.py b/services/web/server/tests/unit/with_dbs/04/chatbot/conftest.py similarity index 63% rename from services/web/server/tests/unit/with_dbs/04/test_chatbot_client.py rename to services/web/server/tests/unit/with_dbs/04/chatbot/conftest.py index 365ef932f6ec..da21e1443659 100644 --- a/services/web/server/tests/unit/with_dbs/04/test_chatbot_client.py +++ b/services/web/server/tests/unit/with_dbs/04/chatbot/conftest.py @@ -9,14 +9,10 @@ import httpx import pytest import respx -from aiohttp.test_utils import TestClient +from pytest_mock import MockerFixture, MockType from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict from pytest_simcore.helpers.typing_env import EnvVarsDict -from simcore_service_webserver.chatbot._client import ( - ChatResponse, - get_chatbot_rest_client, -) -from simcore_service_webserver.chatbot.settings import ChatbotSettings +from simcore_service_webserver.products import products_service @pytest.fixture @@ -53,20 +49,10 @@ def mocked_chatbot_api() -> Iterator[respx.MockRouter]: yield mock -async def test_chatbot_client( - app_environment: EnvVarsDict, - client: TestClient, - mocked_chatbot_api: respx.MockRouter, -): - assert client.app - - settings = ChatbotSettings.create_from_envs() - assert settings.CHATBOT_HOST - assert settings.CHATBOT_PORT - - chatbot_client = get_chatbot_rest_client(client.app) - assert chatbot_client - - output = await chatbot_client.ask_question("What is the meaning of life?") - assert isinstance(output, ChatResponse) - assert output.answer == "42" +@pytest.fixture +def mocked_get_current_product(mocker: MockerFixture) -> MockType: + mock = mocker.patch.object(products_service, "get_product") + mocked_product = mocker.Mock() + mocked_product.support_chatbot_user_id = 123 + mock.return_value = mocked_product + return mock diff --git a/services/web/server/tests/unit/with_dbs/04/chatbot/test_chatbot_client.py b/services/web/server/tests/unit/with_dbs/04/chatbot/test_chatbot_client.py new file mode 100644 index 000000000000..faa2c64fa7d8 --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/04/chatbot/test_chatbot_client.py @@ -0,0 +1,34 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments +# pylint: disable=too-many-statements + + +import respx +from aiohttp.test_utils import TestClient +from pytest_simcore.helpers.typing_env import EnvVarsDict +from simcore_service_webserver.chatbot._client import ( + ChatResponse, + get_chatbot_rest_client, +) +from simcore_service_webserver.chatbot.settings import ChatbotSettings + + +async def test_chatbot_client( + app_environment: EnvVarsDict, + client: TestClient, + mocked_chatbot_api: respx.MockRouter, +): + assert client.app + + settings = ChatbotSettings.create_from_envs() + assert settings.CHATBOT_HOST + assert settings.CHATBOT_PORT + + chatbot_client = get_chatbot_rest_client(client.app) + assert chatbot_client + + output = await chatbot_client.ask_question("What is the meaning of life?") + assert isinstance(output, ChatResponse) + assert output.answer == "42" diff --git a/services/web/server/tests/unit/with_dbs/04/chatbot/test_process_chatbot_trigger_message.py b/services/web/server/tests/unit/with_dbs/04/chatbot/test_process_chatbot_trigger_message.py new file mode 100644 index 000000000000..f1d4a46a36ea --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/04/chatbot/test_process_chatbot_trigger_message.py @@ -0,0 +1,82 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments +# pylint: disable=too-many-statements + + +import pytest +import respx +from aiohttp.test_utils import TestClient +from models_library.conversations import ConversationGetDB +from models_library.rabbitmq_messages import WebserverChatbotRabbitMessage +from pytest_mock import MockerFixture, MockType +from pytest_simcore.helpers.typing_env import EnvVarsDict +from simcore_service_webserver.chatbot._process_chatbot_trigger_service import ( + _process_chatbot_trigger_message, +) +from simcore_service_webserver.conversations import conversations_service + + +@pytest.fixture +def mocked_conversations_service(mocker: MockerFixture) -> dict: + # Mock message objects with content attribute + mock_message_1 = mocker.Mock() + mock_message_1.content = "Hello, I need help with my simulation" + + mock_message_2 = mocker.Mock() + mock_message_2.content = "It's not working properly" + + mock_messages = [mock_message_1, mock_message_2] + + # Mock list_messages_for_conversation + list_messages_mock = mocker.patch.object( + conversations_service, "list_messages_for_conversation" + ) + list_messages_mock.return_value = (len(mock_messages), mock_messages) + + # Mock create_support_message + create_message_mock = mocker.patch.object( + conversations_service, "create_support_message" + ) + + return { + "list_messages": list_messages_mock, + "create_message": create_message_mock, + "mock_messages": mock_messages, + } + + +async def test_process_chatbot_trigger_message( + app_environment: EnvVarsDict, + client: TestClient, + mocked_get_current_product: MockType, + mocked_chatbot_api: respx.MockRouter, + mocked_conversations_service: dict, +): + assert client.app + + # Prepare message to bytes for processing + _conversation = ConversationGetDB.model_config["json_schema_extra"]["examples"][0] + _message = WebserverChatbotRabbitMessage( + conversation=_conversation, + last_message_id="42838344-03de-4ce2-8d93-589a5dcdfd05", + ) + assert _message + + message_bytes = _message.model_dump_json().encode() + + # This is the function under test + await _process_chatbot_trigger_message(app=client.app, data=message_bytes) + + # Assert that the necessary service calls were made + mocked_conversations_service["list_messages"].assert_called_once() + + assert mocked_chatbot_api.calls.call_count == 1 + _last_request_content = mocked_chatbot_api.calls.last.request.content.decode( + "utf-8" + ) + assert "Hello, I need help with my simulation" in _last_request_content + assert "It's not working properly" in _last_request_content + + mocked_conversations_service["create_message"].assert_called_once() diff --git a/services/web/server/tests/unit/with_dbs/04/products/test_products_repository.py b/services/web/server/tests/unit/with_dbs/04/products/test_products_repository.py index ed4550eee6d3..06d095c4e0b7 100644 --- a/services/web/server/tests/unit/with_dbs/04/products/test_products_repository.py +++ b/services/web/server/tests/unit/with_dbs/04/products/test_products_repository.py @@ -46,6 +46,7 @@ def products_raw_data() -> dict[ProductName, dict[str, Any]]: "display_name": "COMPLETE example", "short_name": "dummy", "host_regex": r"([\.-]{0,1}dummy[\.-])", + "base_url": "http://example.com", "support_email": "foo@osparc.io", "twilio_messaging_sid": None, "vendor": Vendor( @@ -83,6 +84,7 @@ def products_raw_data() -> dict[ProductName, dict[str, Any]]: "display_name": "MINIMAL example", "short_name": "dummy", "host_regex": "([\\.-]{0,1}osparc[\\.-])", + "base_url": "http://example.com", "support_email": "support@osparc.io", }