diff --git a/.github/workflows/build_and_deploy.yml b/.github/workflows/build_and_deploy.yml index 963982d..44f9fee 100644 --- a/.github/workflows/build_and_deploy.yml +++ b/.github/workflows/build_and_deploy.yml @@ -52,7 +52,6 @@ jobs: run: | cd ./${{ env.CI_JOB_ID }} touch .env - echo JOIN_ON_INVITE=${{ vars.JOIN_ON_INVITE }} >> .env echo SALT=${{ secrets.SALT }} >> .env echo MATRIX_HOME_SERVER=${{ secrets.MATRIX_HOME_SERVER }} >> .env echo MATRIX_BOT_USERNAME=${{ secrets.MATRIX_BOT_USERNAME }} >> .env diff --git a/.gitignore b/.gitignore index 53865b9..5074a5f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,8 +5,7 @@ # Project-specific store session.txt -users_pending.txt -users_whitelist.txt +users.json *.pyc __pycache__ diff --git a/README.md b/README.md index 3eb8cdd..c07b814 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,6 @@ cp app/.env.example app/.env Les variables d'environnement à renseigner sont les suivantes : -- `JOIN_ON_INVITE` : booléen facultatif pour activer ou non l'acceptation automatique des invitations dans les salons (exemple : `JOIN_ON_INVITE=True`. Par défaut, `False`) - `SALT` : il est conseillé de changer la valeur du salt pour ne pas avoir celle par défaut. Il faudra en revanche qu'elle de change pas entre deux sessions. - `MATRIX_HOME_SERVER` : l'URL du serveur Matrix à utiliser (exemple : `MATRIX_HOME_SERVER="https://matrix.agent.ministere_example.tchap.gouv.fr"`) - `MATRIX_BOT_USERNAME` : le nom d'utilisateur du bot Matrix (exemple : `MATRIX_BOT_USERNAME="tchapbot@ministere_example.gouv.fr"`) @@ -167,7 +166,6 @@ cp app/.env.example app/.env The following environment variables must be entered: -- `JOIN_ON_INVITE`: optional boolean to enable or disable automatic acceptance of invitations to Tchap rooms (example: `JOIN_ON_INVITE=True`. Default: `False`). - `SALT`: it is advisable to change the salt value to avoid having the default one. However, it must not change between sessions. - `MATRIX_HOME_SERVER`: the URL of the Matrix server to be used (example: `MATRIX_HOME_SERVER=“https://matrix.agent.ministere_example.tchap.gouv.fr”`). - `MATRIX_BOT_USERNAME`: the Matrix bot username (example: `MATRIX_BOT_USERNAME=“tchapbot@ministere_example.gouv.fr”`) diff --git a/app/.env.example b/app/.env.example index 562e311..535b98d 100644 --- a/app/.env.example +++ b/app/.env.example @@ -5,7 +5,6 @@ VERBOSE=False SYSTEMD_LOGGING=True -JOIN_ON_INVITE=True SALT=b"\xce,\xa1\xc6lY\x80\xe3X}\x91\xa60m\xa8N" MATRIX_HOME_SERVER="https://matrix.agent.ministere_example.tchap.gouv.fr" MATRIX_BOT_USERNAME="jean.quidam@ministere_example.gouv.fr" diff --git a/app/commands.py b/app/commands.py index 88dbcfb..eca375d 100755 --- a/app/commands.py +++ b/app/commands.py @@ -3,7 +3,6 @@ # # SPDX-License-Identifier: MIT -import time from collections import defaultdict from dataclasses import dataclass @@ -144,25 +143,7 @@ def decorator(func): ) async def help(ep: EventParser, matrix_client: MatrixClient): config = user_configs[ep.sender] - await matrix_client.room_typing(ep.room.room_id) - await matrix_client.send_markdown_message(ep.room.room_id, command_registry.get_help(config)) - - -@register_feature( - group="albert", - onEvent=RoomMemberEvent, - # @DEBUG: RoomCreateEvent is not captured ? - help=None, -) -async def albert_welcome(ep: EventParser, matrix_client: MatrixClient): - """ - Receive the join/invite event and send the welcome/help message - """ - config = user_configs[ep.sender] - ep.only_on_direct_message() - ep.only_on_join() - config.update_last_activity() - time.sleep(3) # wait for the room to be ready - otherwise the encryption seems to be not ready + await ep.only_allowed_sender(config) await matrix_client.room_typing(ep.room.room_id) await matrix_client.send_markdown_message(ep.room.room_id, command_registry.get_help(config)) @@ -175,6 +156,7 @@ async def albert_welcome(ep: EventParser, matrix_client: MatrixClient): ) async def albert_reset(ep: EventParser, matrix_client: MatrixClient): config = user_configs[ep.sender] + await ep.only_allowed_sender(config) if config.albert_with_history: config.update_last_activity() await matrix_client.room_typing(ep.room.room_id) @@ -195,6 +177,7 @@ async def albert_reset(ep: EventParser, matrix_client: MatrixClient): ) async def albert_conversation(ep: EventParser, matrix_client: MatrixClient): config = user_configs[ep.sender] + await ep.only_allowed_sender(config) await matrix_client.room_typing(ep.room.room_id) if config.albert_with_history: config.albert_with_history = False @@ -216,6 +199,7 @@ async def albert_conversation(ep: EventParser, matrix_client: MatrixClient): ) async def albert_debug(ep: EventParser, matrix_client: MatrixClient): config = user_configs[ep.sender] + await ep.only_allowed_sender(config) await matrix_client.room_typing(ep.room.room_id) debug_message = f"Configuration actuelle :\n\n" debug_message += f"- Version: {APP_VERSION}\n" @@ -237,6 +221,7 @@ async def albert_debug(ep: EventParser, matrix_client: MatrixClient): ) async def albert_mode(ep: EventParser, matrix_client: MatrixClient): config = user_configs[ep.sender] + await ep.only_allowed_sender(config) await matrix_client.room_typing(ep.room.room_id) commands = ep.event.body.split() # Get all available mode for the current model @@ -263,8 +248,8 @@ async def albert_mode(ep: EventParser, matrix_client: MatrixClient): ) async def albert_sources(ep: EventParser, matrix_client: MatrixClient): config = user_configs[ep.sender] + await ep.only_allowed_sender(config) await matrix_client.room_typing(ep.room.room_id) - try: if config.albert_stream_id: sources = generate_sources(config=config, stream_id=config.albert_stream_id) @@ -294,8 +279,17 @@ async def albert_answer(ep: EventParser, matrix_client: MatrixClient): """ Receive a message event which is not a command, send the prompt to Albert API and return the response to the user """ - # user_prompt: str = await ep.hl() config = user_configs[ep.sender] + await ep.only_allowed_sender(config) + + # If the user has never used Albert, we send the help message + if not config.has_activity(ep.sender): + config.update_last_activity(ep.sender) + await matrix_client.room_typing(ep.room.room_id) + await matrix_client.send_markdown_message( + ep.room.room_id, command_registry.get_help(config) + ) + user_prompt = ep.event.body if user_prompt.startswith(COMMAND_PREFIX): raise EventNotConcerned @@ -313,7 +307,7 @@ async def albert_answer(ep: EventParser, matrix_client: MatrixClient): ep.room.room_id, reset_message, msgtype="m.notice" ) - config.update_last_activity() + config.update_last_activity(ep.sender) await matrix_client.room_typing(ep.room.room_id, typing_state=True, timeout=180_000) try: answer = generate(config=config, query=query) diff --git a/app/config.py b/app/config.py index 6df8981..d98ab0f 100755 --- a/app/config.py +++ b/app/config.py @@ -8,7 +8,7 @@ import tomllib from pathlib import Path -from matrix_bot.config import bot_lib_config +from matrix_bot.config import bot_lib_config, logger from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict @@ -45,7 +45,8 @@ class Config(BaseConfig): description="List of allowed Tchap users email domains allowed to use Albert Tchap bot", ) groups_used: list[str] = Field(["basic"], description="List of commands groups to use") - last_activity: int = Field(int(time.time()), description="Last activity timestamp") + last_activity: int | None = Field(None, description="Last activity timestamp") + authorized: bool | None = Field(None, description="is the user authorized to use the bot") # Albert API settings albert_api_url: str = Field("http://localhost:8090/api/v2", description="Albert API base URL") albert_api_token: str = Field("", description="Albert API Token") @@ -59,12 +60,47 @@ class Config(BaseConfig): albert_chat_id: int | None = Field(None, description="Current chat id") albert_stream_id: int | None = Field(None, description="Current stream id") + def is_authorized(self, sender_id: str) -> bool: + if not hasattr(self, "authorized") or self.authorized is None: + # The authorization status is not known yet, it might be because: + # - it's the first time the user talks to the bot + # - the bot has been restarted and lost the user config + # For both cases, we need to check it in the persistent storage + if not bot_lib_config.users.get(sender_id): + self.authorized = False + # Add user to pending users list + bot_lib_config.users[sender_id] = {"authorized": False, "has_activity": False} + bot_lib_config.save_users() + else: + self.authorized = bot_lib_config.users[sender_id]["authorized"] + return self.authorized + + def has_activity(self, sender_id: str) -> bool: + if not hasattr(self, "last_activity") or self.last_activity is None: + # The last_activity is not known, it might be because: + # - it's the first time the user talks to the bot + # - the bot has been restarted and lost the user config + # For this last case, we need to check it in the persistent storage + if not bot_lib_config.users.get(sender_id): + logger.error(f"User {sender_id} doesn't exist in the users list") + else: + return bot_lib_config.users[sender_id]["has_activity"] + else: + return True + + def update_last_activity(self, sender_id: str) -> None: + if not hasattr(self, "last_activity") or self.last_activity is None: + self.last_activity = int(time.time()) + bot_lib_config.users[sender_id]["has_activity"] = True + bot_lib_config.save_users() + else: + self.last_activity = int(time.time()) + @property def is_conversation_obsolete(self) -> bool: - return int(time.time()) - self.last_activity > bot_lib_config.conversation_obsolescence - - def update_last_activity(self) -> None: - self.last_activity = int(time.time()) + if self.last_activity: + return int(time.time()) - self.last_activity > bot_lib_config.conversation_obsolescence + return False env_config = Config() diff --git a/app/matrix_bot/callbacks.py b/app/matrix_bot/callbacks.py index 047a8f6..40989f8 100755 --- a/app/matrix_bot/callbacks.py +++ b/app/matrix_bot/callbacks.py @@ -15,7 +15,7 @@ ) from .client import MatrixClient -from .config import bot_lib_config, logger +from .config import logger from .eventparser import ( EventNotConcerned, EventParser, @@ -82,7 +82,6 @@ async def wrapped_func(room, event): ) ep.do_not_accept_own_message() # avoid infinite loop - await ep.only_allowed_sender() # only allowed senders await func(ep=ep, matrix_client=self.matrix_client) self.client_callback.append((wrapped_func, onEvent)) @@ -101,8 +100,7 @@ def register_on_startup(self, func): async def setup_callbacks(self): """Add callbacks to async_client""" - if bot_lib_config.join_on_invite: - self.matrix_client.add_event_callback(self.invite_callback, InviteMemberEvent) + self.matrix_client.add_event_callback(self.invite_callback, InviteMemberEvent) self.matrix_client.add_event_callback(self.decryption_failure, MegolmEvent) diff --git a/app/matrix_bot/config.py b/app/matrix_bot/config.py index 13e91a0..959dfd3 100644 --- a/app/matrix_bot/config.py +++ b/app/matrix_bot/config.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: 2023 Pôle d'Expertise de la Régulation Numérique # # SPDX-License-Identifier: MIT +import json import logging from pathlib import Path @@ -21,9 +22,6 @@ class BotLibConfig(BaseSettings): description="The maximum time that the server should wait for new events before " "it should return the request anyways, in milliseconds", ) - join_on_invite: bool = Field( - default=False, description="Do the bot automatically join when invited" - ) encryption_enabled: bool = Field(default=ENCRYPTION_ENABLED) ignore_unverified_devices: bool = Field(default=True, description="True by default in Element") store_path: Path = Field( @@ -32,6 +30,10 @@ class BotLibConfig(BaseSettings): session_path: Path = Field( default="/data/session.txt", description="path of the file to store session identifier" ) + users_path: Path = Field( + default="/data/users.json", description="path of the file to store pending users" + ) + users: dict = Field(default={}, description="pending users") log_level: int = Field(default=logging.INFO, description="log level for the library") salt: bytes = Field( default=b"\xce,\xa1\xc6lY\x80\xe3X}\x91\xa60m\xa8N", @@ -43,6 +45,20 @@ class BotLibConfig(BaseSettings): model_config = SettingsConfigDict(env_file=Path(".matrix_bot_env")) + def __init__(self): + super().__init__() + if not self.users_path.exists(): + with open(self.users_path, "w") as f: + json.dump({}, f) + self.users = {} + else: + with open(self.users_path, "r") as f: + self.users: dict = json.load(f) + + def save_users(self): + with open(self.users_path, "w") as f: + json.dump(self.users, f, indent=2) + bot_lib_config = BotLibConfig() diff --git a/app/matrix_bot/eventparser.py b/app/matrix_bot/eventparser.py index 574ba4e..5a881bd 100755 --- a/app/matrix_bot/eventparser.py +++ b/app/matrix_bot/eventparser.py @@ -10,7 +10,7 @@ from nio import Event, MatrixRoom, RoomMessageText from .client import MatrixClient -from .config import logger +from .config import bot_lib_config, logger from .room_utils import room_is_direct_message @@ -40,7 +40,7 @@ def is_from_userid(self, userid: str) -> bool: def is_from_this_bot(self) -> bool: return self.is_from_userid(self.matrix_client.user_id) - def is_sender_allowed(self) -> bool: + def is_sender_domain_allowed(self) -> bool: if "*" in env_config.user_allowed_domains: return True return self.sender_domain() in env_config.user_allowed_domains @@ -94,14 +94,22 @@ def only_on_join(self) -> None: if not self.event.source.get("content", {}).get("membership") == "invite": raise EventNotConcerned - async def only_allowed_sender(self) -> None: + async def only_allowed_sender(self, user_config) -> None: """ :raise EventNotConcerned: if the sender is not allowed to send messages """ - if not self.is_sender_allowed(): + if not self.is_sender_domain_allowed(): + await self.matrix_client.send_text_message( + self.room.room_id, + "Albert n'est pas encore disponible pour votre domaine. Merci de rester en contact, il sera disponible après un beta test.", + msgtype="m.notice", + ) + raise EventNotConcerned + if not user_config.is_authorized(self.sender): await self.matrix_client.send_markdown_message( self.room.room_id, - "Albert n'est pas encore disponible pour votre domaine. Merci de rester en contact, il sera disponible après un beta test !", + "Albert est en phase de test et n'est pas encore disponible pour votre utilisateur. Contactez albert-contact@data.gouv.fr pour demander un accès.", + msgtype="m.notice", ) raise EventNotConcerned diff --git a/docker-compose.yml b/docker-compose.yml index f30c0db..21368d9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,6 @@ services: - VERBOSE=true - SYSTEMD_LOGGING=True - SESSION_PATH=/data/session.txt - - JOIN_ON_INVITE=${JOIN_ON_INVITE} - SALT=${SALT} - MATRIX_HOME_SERVER=${MATRIX_HOME_SERVER} - MATRIX_BOT_USERNAME=${MATRIX_BOT_USERNAME}