diff --git a/docs/source/dbschema.rst b/docs/source/dbschema.rst index 3752ba54..e35fd694 100644 --- a/docs/source/dbschema.rst +++ b/docs/source/dbschema.rst @@ -7,23 +7,15 @@ This is the schema of the database used to store information about the spots and .. mermaid:: erDiagram - "pending_post" { - user_id BIGINT - u_message_id BIGINT - g_message_id BIGINT PK - admin_group_id BIGINT PK - message_date TIMESTAMP - } - "admin_votes" { admin_id BIGINT PK g_message_id BIGINT PK admin_group_id BIGINT PK is_upvote BOOLEAN + credit_username VARCHAR + message_date TIMESTAMP } - pending_post ||--o{ admin_votes : receives - "published_post" { channel_id BIGINT PK c_message_id BIGINT PK diff --git a/src/spotted/__init__.py b/src/spotted/__init__.py index 3e809c9e..339bd59e 100644 --- a/src/spotted/__init__.py +++ b/src/spotted/__init__.py @@ -1,17 +1,98 @@ """Modules used in this bot""" -from telegram.ext import Application +import signal -from spotted.data import Config, init_db +from telegram.error import TelegramError +from telegram.ext import Application, CallbackContext + +from spotted.data import Config, PendingPost, init_db +from spotted.debug import logger from spotted.handlers import add_commands, add_handlers, add_jobs +from spotted.handlers.job_handlers import clean_pending + + +async def _drain_notify(context: CallbackContext): + """Notifies admins that the bot is shutting down and starts the drain check loop""" + admin_group_id = Config.post_get("admin_group_id") + n_pending = len(PendingPost.get_all(admin_group_id=admin_group_id)) + + if n_pending == 0: + logger.info("No pending posts, shutting down immediately") + context.application.stop_running() + return + + timeout = Config.debug_get("drain_timeout") + await context.bot.send_message( + chat_id=admin_group_id, + text=( + f"Il bot si sta spegnendo. Ci sono {n_pending} spot in sospeso.\n" + f"Approvateli o rifiutateli entro {timeout // 60} minuti." + ), + ) + + context.application.job_queue.run_repeating( + _drain_check, interval=5, first=5, data={"timeout": timeout, "elapsed": 0} + ) + + +async def _drain_check(context: CallbackContext): + """Checks if drain is complete (no pending posts or timeout reached)""" + admin_group_id = Config.post_get("admin_group_id") + n_pending = len(PendingPost.get_all(admin_group_id=admin_group_id)) + data = context.job.data + data["elapsed"] += 5 + + if n_pending == 0: + logger.info("Drain complete: no pending posts remaining") + context.job.schedule_removal() + context.application.stop_running() + elif data["elapsed"] >= data["timeout"]: + logger.warning("Drain timeout reached with %d pending posts", n_pending) + pending_posts = PendingPost.get_all(admin_group_id=admin_group_id) + await clean_pending( + context.bot, + admin_group_id, + pending_posts, + "Il bot è stato riavviato e il tuo spot in sospeso è andato perso.\n" + "Per favore, invia nuovamente il tuo spot con /spot", + ) + await context.bot.send_message( + chat_id=admin_group_id, + text=f"Timeout raggiunto. {n_pending} spot in sospeso sono stati eliminati. Il bot si spegne.", + ) + context.job.schedule_removal() + context.application.stop_running() + + +async def _post_stop(application: Application): + """Called after the application stops. Sends a final message to admins.""" + try: + admin_group_id = Config.post_get("admin_group_id") + await application.bot.send_message(chat_id=admin_group_id, text="Bot spento.") + except TelegramError: + logger.error("Failed to send shutdown message to admin group") def run_bot(): """Init the database, add the handlers and start the bot""" init_db() - application = Application.builder().token(Config.settings_get("token")).post_init(add_commands).build() + application = ( + Application.builder().token(Config.settings_get("token")).post_init(add_commands).post_stop(_post_stop).build() + ) add_handlers(application) add_jobs(application) - application.run_polling() + def _handle_signal(sig, _frame): + """Custom signal handler that starts the drain process instead of immediately stopping""" + if PendingPost.is_draining(): + logger.info("Received signal %s while already draining, ignoring", signal.Signals(sig).name) + return + logger.info("Received signal %s, starting graceful drain", signal.Signals(sig).name) + PendingPost.start_drain() + application.job_queue.run_once(_drain_notify, when=0) + + signal.signal(signal.SIGTERM, _handle_signal) + signal.signal(signal.SIGINT, _handle_signal) + + application.run_polling(stop_signals=()) diff --git a/src/spotted/config/db/post_db_del.sql b/src/spotted/config/db/post_db_del.sql index f3e35aa8..20c7e2f0 100644 --- a/src/spotted/config/db/post_db_del.sql +++ b/src/spotted/config/db/post_db_del.sql @@ -3,8 +3,6 @@ DROP TABLE IF EXISTS credited_users ----- DROP TABLE IF EXISTS admin_votes ----- -DROP TABLE IF EXISTS pending_post ------ DROP TABLE IF EXISTS published_post ----- DROP TABLE IF EXISTS banned_users @@ -14,3 +12,5 @@ DROP TABLE IF EXISTS spot_report DROP TABLE IF EXISTS user_report ----- DROP TABLE IF EXISTS user_follow +----- +DROP TABLE IF EXISTS pending_post diff --git a/src/spotted/config/db/post_db_init.sql b/src/spotted/config/db/post_db_init.sql index 0e48d0fc..47875ecc 100644 --- a/src/spotted/config/db/post_db_init.sql +++ b/src/spotted/config/db/post_db_init.sql @@ -1,13 +1,13 @@ /*Used to instantiate the database the first time*/ -CREATE TABLE IF NOT EXISTS pending_post +CREATE TABLE IF NOT EXISTS admin_votes ( - user_id BIGINT NOT NULL, - u_message_id BIGINT NOT NULL, + admin_id BIGINT NOT NULL, g_message_id BIGINT NOT NULL, admin_group_id BIGINT NOT NULL, + is_upvote boolean NOT NULL, credit_username VARCHAR(255) DEFAULT NULL, message_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (admin_group_id, g_message_id) + PRIMARY KEY (admin_id, g_message_id, admin_group_id) ); ----- CREATE TABLE IF NOT EXISTS published_post @@ -18,16 +18,6 @@ CREATE TABLE IF NOT EXISTS published_post PRIMARY KEY (channel_id, c_message_id) ); ----- -CREATE TABLE IF NOT EXISTS admin_votes -( - admin_id BIGINT NOT NULL, - g_message_id BIGINT NOT NULL, - admin_group_id BIGINT NOT NULL, - is_upvote boolean NOT NULL, - PRIMARY KEY (admin_id, g_message_id, admin_group_id), - FOREIGN KEY (g_message_id, admin_group_id) REFERENCES pending_post (g_message_id, admin_group_id) ON DELETE CASCADE ON UPDATE CASCADE -); ------ CREATE TABLE IF NOT EXISTS credited_users ( user_id BIGINT NOT NULL, diff --git a/src/spotted/config/yaml/settings.yaml b/src/spotted/config/yaml/settings.yaml index e5f03f18..340e2aff 100644 --- a/src/spotted/config/yaml/settings.yaml +++ b/src/spotted/config/yaml/settings.yaml @@ -6,6 +6,7 @@ debug: db_file: "spotted.sqlite3" crypto_key: "" zip_backup: false + drain_timeout: 300 post: community_group_id: -1 channel_id: -2 diff --git a/src/spotted/config/yaml/settings.yaml.types b/src/spotted/config/yaml/settings.yaml.types index 1ada9a02..43e5e121 100644 --- a/src/spotted/config/yaml/settings.yaml.types +++ b/src/spotted/config/yaml/settings.yaml.types @@ -5,6 +5,7 @@ debug: log_error_file: str crypto_key: str zip_backup: bool + drain_timeout: int post: community_group_id: int channel_id: int diff --git a/src/spotted/data/pending_post.py b/src/spotted/data/pending_post.py index f9865136..6dd55336 100644 --- a/src/spotted/data/pending_post.py +++ b/src/spotted/data/pending_post.py @@ -1,19 +1,25 @@ """Pending post management""" +import hashlib +import hmac +import os from dataclasses import dataclass from datetime import datetime, timezone +from typing import ClassVar, TypeAlias +from cryptography.fernet import Fernet from telegram import Message from .db_manager import DbManager +_StoreKey: TypeAlias = tuple[int, int] + @dataclass() class PendingPost: - """Class that represents a pending post + """Class that represents a pending post. Args: - user_id: id of the user that sent the post u_message_id: id of the original message of the post g_message_id: id of the post in the group admin_group_id: id of the admin group @@ -21,13 +27,54 @@ class PendingPost: date: when the post was sent """ - user_id: int + _store: ClassVar[dict[_StoreKey, "PendingPost"]] = {} + _key_to_user: ClassVar[dict[_StoreKey, bytearray]] = {} + _user_to_key: ClassVar[dict[bytes, _StoreKey]] = {} + _fernet: ClassVar[Fernet] = Fernet(Fernet.generate_key()) + _hmac_key: ClassVar[bytearray] = bytearray(os.urandom(32)) + _draining: ClassVar[bool] = False + u_message_id: int g_message_id: int admin_group_id: int date: datetime credit_username: str | None = None + @property + def _key(self) -> _StoreKey: + """Returns the store key for this post""" + return (self.admin_group_id, self.g_message_id) + + @property + def user_id(self) -> int: + """Retrieves and decrypts the user_id from the separate mapping""" + return PendingPost._decrypt_user_id(PendingPost._key_to_user[self._key]) + + @classmethod + def _encrypt_user_id(cls, user_id: int) -> bytearray: + """Encrypts user_id with Fernet using the ephemeral boot-time key""" + return bytearray(cls._fernet.encrypt(user_id.to_bytes(8, "big"))) + + @classmethod + def _decrypt_user_id(cls, token: bytearray) -> int: + """Decrypts user_id from Fernet token using the ephemeral boot-time key""" + return int.from_bytes(cls._fernet.decrypt(bytes(token)), "big") + + @classmethod + def _hash_user_id(cls, user_id: int) -> bytes: + """Produces a deterministic HMAC-SHA256 of user_id for use as a lookup key""" + return hmac.new(cls._hmac_key, user_id.to_bytes(8, "big"), hashlib.sha256).digest() + + @classmethod + def is_draining(cls) -> bool: + """Returns whether the bot is in drain mode (shutting down)""" + return cls._draining + + @classmethod + def start_drain(cls): + """Sets the drain flag, blocking new spot submissions""" + cls._draining = True + @classmethod def create( cls, user_message: Message, g_message_id: int, admin_group_id: int, credit_username: str | None = None @@ -48,13 +95,12 @@ def create( date = datetime.now(tz=timezone.utc) return cls( - user_id=user_id, u_message_id=u_message_id, g_message_id=g_message_id, admin_group_id=admin_group_id, credit_username=credit_username, date=date, - ).save_post() + ).save_post(user_id=user_id) @classmethod def from_group(cls, g_message_id: int, admin_group_id: int) -> "PendingPost | None": @@ -67,24 +113,7 @@ def from_group(cls, g_message_id: int, admin_group_id: int) -> "PendingPost | No Returns: instance of the class """ - pending_post_arr = DbManager.select_from( - select="*", - table_name="pending_post", - where="admin_group_id = %s and g_message_id = %s", - where_args=(admin_group_id, g_message_id), - ) - if not pending_post_arr: - return None - - pending_post = pending_post_arr[0] - return cls( - user_id=pending_post["user_id"], - u_message_id=pending_post["u_message_id"], - admin_group_id=pending_post["admin_group_id"], - g_message_id=pending_post["g_message_id"], - credit_username=pending_post["credit_username"], - date=pending_post["message_date"], - ) + return cls._store.get((admin_group_id, g_message_id)) @classmethod def from_user(cls, user_id: int) -> "PendingPost | None": @@ -96,21 +125,11 @@ def from_user(cls, user_id: int) -> "PendingPost | None": Returns: instance of the class """ - pending_post_arr = DbManager.select_from( - select="*", table_name="pending_post", where="user_id = %s", where_args=(user_id,) - ) - if not pending_post_arr: + user_hash = cls._hash_user_id(user_id) + key = cls._user_to_key.get(user_hash) + if key is None: return None - - pending_post = pending_post_arr[0] - return cls( - user_id=pending_post["user_id"], - u_message_id=pending_post["u_message_id"], - admin_group_id=pending_post["admin_group_id"], - g_message_id=pending_post["g_message_id"], - credit_username=pending_post["credit_username"], - date=pending_post["message_date"], - ) + return cls._store.get(key) @staticmethod def get_all(admin_group_id: int, before: datetime | None = None) -> list["PendingPost"]: @@ -124,38 +143,26 @@ def get_all(admin_group_id: int, before: datetime | None = None) -> list["Pendin Returns: list of ids of pending posts """ - if before: - pending_posts_id = DbManager.select_from( - select="g_message_id", - table_name="pending_post", - where="admin_group_id = %s and (message_date < %s or message_date IS NULL)", - where_args=(admin_group_id, before), - ) - else: - pending_posts_id = DbManager.select_from( - select="g_message_id", - table_name="pending_post", - where="admin_group_id = %s", - where_args=(admin_group_id,), - ) - pending_posts = [] - for post in pending_posts_id: - g_message_id = int(post["g_message_id"]) - pending_posts.append(PendingPost.from_group(admin_group_id=admin_group_id, g_message_id=g_message_id)) - return pending_posts - - def save_post(self) -> "PendingPost": - """Saves the pending_post in the database""" - columns = ("user_id", "u_message_id", "g_message_id", "admin_group_id", "message_date") - values = (self.user_id, self.u_message_id, self.g_message_id, self.admin_group_id, self.date) - if self.credit_username is not None: - columns += ("credit_username",) - values += (self.credit_username,) - DbManager.insert_into( - table_name="pending_post", - columns=columns, - values=values, - ) + posts = [] + for post in PendingPost._store.values(): + if post.admin_group_id != admin_group_id: + continue + if before and post.date is not None: + post_date = post.date.replace(tzinfo=None) if post.date.tzinfo else post.date + before_date = before.replace(tzinfo=None) if before.tzinfo else before + if post_date >= before_date: + continue + posts.append(post) + return posts + + def save_post(self, user_id: int) -> "PendingPost": + """Saves the pending_post in the in-memory store and records the user mapping separately. + The user_id is encrypted at rest and the lookup key is an HMAC hash. + """ + key = self._key + PendingPost._store[key] = self + PendingPost._key_to_user[key] = PendingPost._encrypt_user_id(user_id) + PendingPost._user_to_key[PendingPost._hash_user_id(user_id)] = key return self def get_votes(self, vote: bool) -> int: @@ -203,9 +210,9 @@ def get_list_admin_votes(self, vote: "bool | None" = None) -> "list[int] | list[ ) if vote is None: - return [(vote["admin_id"], vote["is_upvote"]) for vote in votes] + return [(v["admin_id"], v["is_upvote"]) for v in votes] - return [vote["admin_id"] for vote in votes] + return [v["admin_id"] for v in votes] def __get_admin_vote(self, admin_id: int) -> bool | None: """Gets the vote of a specific admin on a pending post @@ -242,8 +249,8 @@ def set_admin_vote(self, admin_id: int, approval: bool) -> int: if vote is None: # there isn't a vote yet DbManager.insert_into( table_name="admin_votes", - columns=("admin_id", "g_message_id", "admin_group_id", "is_upvote"), - values=(admin_id, self.g_message_id, self.admin_group_id, approval), + columns=("admin_id", "g_message_id", "admin_group_id", "is_upvote", "credit_username", "message_date"), + values=(admin_id, self.g_message_id, self.admin_group_id, approval, self.credit_username, self.date), ) number_of_votes = self.get_votes(vote=approval) elif bool(vote) != approval: # the vote was different from the approval @@ -259,25 +266,28 @@ def set_admin_vote(self, admin_id: int, approval: bool) -> int: return number_of_votes def delete_post(self): - """Removes all entries on a post that is no longer pending""" - - DbManager.delete_from( - table_name="pending_post", - where="g_message_id = %s and admin_group_id = %s", - where_args=(self.g_message_id, self.admin_group_id), - ) + """Removes the post from the in-memory store, cleans up user mappings, and deletes votes from the database""" + key = self._key + encrypted = PendingPost._key_to_user.pop(key, None) + if encrypted is not None: + user_id = PendingPost._decrypt_user_id(encrypted) + PendingPost._user_to_key.pop(PendingPost._hash_user_id(user_id), None) + encrypted[:] = b"\x00" * len(encrypted) + PendingPost._store.pop(key, None) DbManager.delete_from( table_name="admin_votes", where="g_message_id = %s and admin_group_id = %s", where_args=(self.g_message_id, self.admin_group_id), ) + self.u_message_id = 0 + self.g_message_id = 0 + self.admin_group_id = 0 + self.date = None + self.credit_username = None def __repr__(self) -> str: return ( - f"PendingPost: [ user_id: {self.user_id}\n" - f"u_message_id: {self.u_message_id}\n" - f"admin_group_id: {self.admin_group_id}\n" + f"PendingPost: [ admin_group_id: {self.admin_group_id}\n" f"g_message_id: {self.g_message_id}\n" - f"credit_username: {self.credit_username}\n" f"date : {self.date} ]" ) diff --git a/src/spotted/handlers/approve.py b/src/spotted/handlers/approve.py index 99a153cd..78cba42d 100644 --- a/src/spotted/handlers/approve.py +++ b/src/spotted/handlers/approve.py @@ -53,12 +53,12 @@ async def reject_post(info: EventInfo, pending_post: PendingPost, reason: str | pending_post: pending post to reject reason: reason for the rejection, currently used on autoreply """ - user_id = pending_post.user_id pending_post.set_admin_vote(info.user_id, False) try: await info.bot.send_message( - chat_id=user_id, text="Il tuo ultimo post è stato rifiutato\nPuoi controllare le regole con /rules" + chat_id=pending_post.user_id, + text="Il tuo ultimo post è stato rifiutato\nPuoi controllare le regole con /rules", ) # notify the user except (BadRequest, Forbidden) as ex: logger.warning("Notifying the user on approve_no: %s", ex) @@ -72,7 +72,7 @@ async def approve_yes_callback(update: Update, context: CallbackContext): """Handles the approve_yes callback. Add a positive vote to the post, updating the keyboard if necessary. If the number of positive votes is greater than the number of votes required, the post is approved, - deleting it from the pending_post table and copying it to the channel + deleting it from the pending posts and copying it to the channel Args: update: update event @@ -88,12 +88,12 @@ async def approve_yes_callback(update: Update, context: CallbackContext): # The post passed the approval phase and is to be published if n_approve >= Config.post_get("n_votes"): - user_id = pending_post.user_id - await info.send_post_to_channel(user_id=user_id) + await info.send_post_to_channel(user_id=pending_post.user_id) try: await info.bot.send_message( - chat_id=user_id, text=f"Il tuo ultimo post è stato pubblicato su {Config.post_get('channel_tag')}" + chat_id=pending_post.user_id, + text=f"Il tuo ultimo post è stato pubblicato su {Config.post_get('channel_tag')}", ) # notify the user except (BadRequest, Forbidden) as ex: logger.warning("Notifying the user on approve_yes: %s", ex) @@ -112,7 +112,7 @@ async def approve_no_callback(update: Update, context: CallbackContext): """Handles the approve_no callback. Add a negative vote to the post, updating the keyboard if necessary. If the number of negative votes is greater than the number of votes required, the post is rejected, - deleting it from the pending_post table and notifying the user + deleting it from the pending posts and notifying the user Args: update: update event diff --git a/src/spotted/handlers/job_handlers.py b/src/spotted/handlers/job_handlers.py index 9015a65b..6cfd0b9a 100644 --- a/src/spotted/handlers/job_handlers.py +++ b/src/spotted/handlers/job_handlers.py @@ -14,6 +14,33 @@ from spotted.utils import EventInfo +async def clean_pending(bot, admin_group_id: int, pending_posts: list, user_text: str) -> int: + """Cleans up the given pending posts: deletes admin messages, notifies users, removes from store. + + Args: + bot: the Telegram bot instance + admin_group_id: id of the admin group + pending_posts: list of PendingPost to clean up + user_text: message to send to each poster + + Returns: + number of admin messages successfully deleted + """ + removed = 0 + for pending_post in pending_posts: + try: + await bot.delete_message(chat_id=admin_group_id, message_id=pending_post.g_message_id) + removed += 1 + except BadRequest as ex: + logger.error("Deleting old pending message: %s", ex) + try: + await bot.send_message(chat_id=pending_post.user_id, text=user_text) + except (BadRequest, Forbidden) as ex: + logger.warning("Notifying user on clean_pending: %s", ex) + pending_post.delete_post() + return removed + + async def clean_pending_job(context: CallbackContext): """Job called each day at 05:00 utc. Automatically rejects all pending posts that are older than the chosen amount of hours @@ -27,24 +54,12 @@ async def clean_pending_job(context: CallbackContext): before_time = datetime.now(tz=timezone.utc) - timedelta(hours=Config.post_get("remove_after_h")) pending_posts = PendingPost.get_all(admin_group_id=admin_group_id, before=before_time) - # For each pending post older than before_time - removed = 0 - for pending_post in pending_posts: - message_id = pending_post.g_message_id - try: # deleting the message associated with the pending post to remote - await info.bot.delete_message(chat_id=admin_group_id, message_id=message_id) - removed += 1 - try: # sending a notification to the user - await info.bot.send_message( - chat_id=pending_post.user_id, - text="Gli admin erano sicuramente molto impegnati e non sono riusciti a valutare lo spot in tempo", - ) - except (BadRequest, Forbidden) as ex: - logger.warning("Notifying the user on /clean_pending: %s", ex) - except BadRequest as ex: - logger.error("Deleting old pending message: %s", ex) - finally: # delete the data associated with the pending post - pending_post.delete_post() + removed = await clean_pending( + info.bot, + admin_group_id, + pending_posts, + "Gli admin erano sicuramente molto impegnati e non sono riusciti a valutare lo spot in tempo", + ) await info.bot.send_message( chat_id=admin_group_id, text=f"Sono stati eliminati {removed} messaggi rimasti in sospeso" diff --git a/src/spotted/handlers/spot.py b/src/spotted/handlers/spot.py index d76924d3..419d0304 100644 --- a/src/spotted/handlers/spot.py +++ b/src/spotted/handlers/spot.py @@ -12,7 +12,7 @@ filters, ) -from spotted.data import Config, User +from spotted.data import Config, PendingPost, User from spotted.data.data_reader import read_md from spotted.utils import EventInfo, conv_cancel, get_confirm_kb, get_preview_kb @@ -64,6 +64,10 @@ async def spot_cmd(update: Update, context: CallbackContext) -> int: await info.bot.send_message(chat_id=info.chat_id, text=CHAT_PRIVATE_ERROR) return ConversationState.END.value + if PendingPost.is_draining(): # the bot is shutting down + await info.bot.send_message(chat_id=info.chat_id, text="Il bot si sta riavviando, riprova tra poco") + return ConversationState.END.value + if user.is_banned: # the user is banned await info.bot.send_message(chat_id=info.chat_id, text="Sei stato bannato 😅") return ConversationState.END.value diff --git a/tests/conftest.py b/tests/conftest.py index be1dec6d..be2de3ae 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,7 +5,7 @@ import pytest -from spotted.data import Config, DbManager +from spotted.data import Config, DbManager, PendingPost @pytest.fixture(scope="class", autouse=True) @@ -41,8 +41,11 @@ def create_test_db() -> DbManager: @pytest.fixture(scope="function") def test_table(create_test_db: DbManager) -> DbManager: """Called once per at the beginning of each function. - Resets the state of the database + Resets the state of the database and in-memory pending post store """ create_test_db.query_from_file("config", "db", "post_db_del.sql") create_test_db.query_from_file("config", "db", "post_db_init.sql") + PendingPost._store.clear() + PendingPost._key_to_user.clear() + PendingPost._user_to_key.clear() return create_test_db diff --git a/tests/integration/test_bot.py b/tests/integration/test_bot.py index c898445e..d4ab6d7d 100644 --- a/tests/integration/test_bot.py +++ b/tests/integration/test_bot.py @@ -364,8 +364,8 @@ async def test_clean_pending( user2 = TGUser(2, first_name="User2", is_bot=False, username="user2") _ = await pending_post(telegram, user=user) g_message2 = await pending_post(telegram, user=user2) - test_table.update_from( - "pending_post", "message_date=%s", "g_message_id=%s", (datetime.fromtimestamp(1), g_message2.message_id) + PendingPost.from_group(g_message_id=g_message2.message_id, admin_group_id=admin_group.id).date = ( + datetime.fromtimestamp(1) ) await telegram.send_command("/clean_pending", chat=admin_group) assert ( @@ -408,7 +408,7 @@ async def test_spot_pending_cmd(self, telegram: TelegramSimulator): """Tests the /spot command. Spot is not allowed for users with a pending post """ - PendingPost(user_id=1, u_message_id=1, g_message_id=1, admin_group_id=1, date=datetime.now()).save_post() + PendingPost(u_message_id=1, g_message_id=1, admin_group_id=1, date=datetime.now()).save_post(user_id=1) await telegram.send_command("/spot") assert telegram.last_message.text == "Hai già un post in approvazione 🧐"