diff --git a/src/bot.py b/src/bot.py index 0495ec1..c0ac228 100644 --- a/src/bot.py +++ b/src/bot.py @@ -25,6 +25,7 @@ ) from .jobs.utils import get_job_runnable from .tg import handlers, sender +from .tg.handler_registry import HANDLER_REGISTRY from .tg.handlers.utils import admin_only, direct_message_only, manager_only logging.addLevelName(USAGE_LOG_LEVEL, "NOTICE") @@ -39,6 +40,17 @@ def usage(self, message, *args, **kws): class SysBlokBot: + """ + Main Bot class responsible for initialization and handler registration. + + Attributes: + config_manager: Manages configuration settings. + application: Telegram Application instance. + telegram_sender: wrapper for sending messages. + app_context: Application context state. + handlers_info: Dictionary to store handler descriptions for /help command. + """ + def __init__( self, config_manager: ConfigManager, @@ -92,351 +104,52 @@ def __init__( logger.info("SysBlokBot successfully initialized") def init_handlers(self): - # all command handlers defined here - # business logic cmds - self.add_admin_handler( - "send_trello_board_state", - CommandCategories.BROADCAST, - self.admin_broadcast_handler("trello_board_state_job"), - "рассылка сводки о состоянии доски", - ) - self.add_manager_handler( - "get_trello_board_state", - CommandCategories.SUMMARY, - self.manager_reply_handler("trello_board_state_job"), - "получить сводку о состоянии доски", - ) - self.add_manager_handler( - "get_publication_plans", - CommandCategories.SUMMARY, - self.manager_reply_handler("publication_plans_job"), - "получить сводку о публикуемыми на неделе постами", - ) - self.add_admin_handler( - "send_publication_plans", - CommandCategories.BROADCAST, - self.admin_broadcast_handler("publication_plans_job"), - "рассылка сводки о публикуемых на неделе постах", - ) - self.add_manager_handler( - "get_manager_status", - CommandCategories.SUMMARY, - direct_message_only( - self.manager_reply_handler("board_my_cards_razvitie_job") - ), - "получить мои карточки из доски Развитие", - ) - self.add_manager_handler( - "fill_posts_list", - CommandCategories.DEBUG, - direct_message_only(self.manager_reply_handler("fill_posts_list_job")), - "заполнить реестр постов (пока не работает)", - ) - self.add_manager_handler( - "fill_posts_list_focalboard", - CommandCategories.DEBUG, - direct_message_only( - self.manager_reply_handler("fill_posts_list_focalboard_job") - ), - "заполнить реестр постов из Focalboard (пока не работает)", - ) - self.add_admin_handler( - "hr_acquisition", - CommandCategories.HR, - self.manager_reply_handler("hr_acquisition_job"), - "обработать новые анкеты", - ) - self.add_admin_handler( - "hr_acquisition_pt", - CommandCategories.HR, - self.manager_reply_handler("hr_acquisition_pt_job"), - "обработать новые анкеты Пишу Тебе", - ) - self.add_manager_handler( - "get_hr_status", - CommandCategories.HR, - self.manager_reply_handler("hr_status_job"), - "получить статус по работе hr (по новичкам и участникам на испытательном)", - ) - self.add_admin_handler( - "send_hr_status", - CommandCategories.BROADCAST, - self.admin_broadcast_handler("hr_status_job"), - "разослать статус по работе hr (по новичкам и участинкам на испытательном)", - ) - self.add_manager_handler( - "create_folders_for_illustrators", - CommandCategories.REGISTRY, - self.manager_reply_handler("create_folders_for_illustrators_job"), - "создать папки для иллюстраторов", - ) - self.add_manager_handler( - "get_tasks_report_focalboard", - CommandCategories.MOST_USED, - # CommandCategories.SUMMARY, - direct_message_only(handlers.get_tasks_report_focalboard), - "получить список задач из Focalboard", - ) - - self.add_manager_handler( - "get_rubrics", - CommandCategories.MOST_USED, - direct_message_only(handlers.get_rubrics), - "получить рубрики из доски Редакция", - ) - - self.add_manager_handler( - "get_articles_rubric", - CommandCategories.DEBUG, # used to be SUMMARY but hiding for now - self.manager_reply_handler("trello_get_articles_rubric_job"), - "получить карточки по названию рубрики в трелло", - ) - self.add_manager_handler( - "get_chat_id", - CommandCategories.MOST_USED, - handlers.get_chat_id, - "получить chat_id (свой или группы)", - ) - self.add_manager_handler( - "manage_reminders", - CommandCategories.MOST_USED, - handlers.manage_reminders, - "настроить напоминания", - ) - self.add_manager_handler( - "get_fb_analytics_report", - CommandCategories.STATS, - self.manager_reply_handler("fb_analytics_report_job"), - "получить статистику facebook страницы за неделю", - ) - self.add_manager_handler( - "get_ig_analytics_report", - CommandCategories.STATS, - self.manager_reply_handler("ig_analytics_report_job"), - "получить статистику instagram страницы за неделю", - ) - self.add_manager_handler( - "get_tg_analytics_report", - CommandCategories.STATS, - self.manager_reply_handler("tg_analytics_report_job"), - "получить статистику telegram канала за неделю", - ) - self.add_manager_handler( - "get_report_from_sheet", - CommandCategories.SUMMARY, - self.manager_reply_handler("sheet_report_job"), - "получить статистику по табличке (например, оцифровка открыток)", - ) - # hidden from /help command for curator enrollment - self.add_manager_handler( - "enroll_curator", CommandCategories.HR, handlers.enroll_curator - ) - - # admin-only technical cmds - self.add_admin_handler( - "update_config", - CommandCategories.CONFIG, - self.admin_reply_handler("config_updater_job"), - "обновить конфиг вне расписания", - ) - self.add_admin_handler( - "list_jobs", - CommandCategories.CONFIG, - handlers.list_jobs, - "показать статус асинхронных задач", - ) - self.add_admin_handler( - "get_usage_list", - CommandCategories.CONFIG, - handlers.list_chats, - "показать места использование бота: пользователи и чаты", - ) - self.add_admin_handler( - "set_log_level", - CommandCategories.LOGGING, - handlers.set_log_level, - "изменить уровень логирования (info / debug)", - ) - self.add_admin_handler( - "mute_errors", - CommandCategories.LOGGING, - handlers.mute_errors, - "отключить логирование ошибок в телеграм", - ) - self.add_admin_handler( - "unmute_errors", - CommandCategories.LOGGING, - handlers.unmute_errors, - "включить логирование ошибок в телеграм", - ) - self.add_admin_handler( - "get_config", - CommandCategories.CONFIG, - handlers.get_config, - "получить текущий конфиг (частично или полностью)", - ) - self.add_admin_handler( - "get_config_jobs", - CommandCategories.CONFIG, - handlers.get_config_jobs, - "получить текущий конфиг джобов (частично или полностью)", - ) - self.add_admin_handler( - "reload_config_jobs", - CommandCategories.CONFIG, - handlers.reload_config_jobs, - "обновить конфиг джобов с Google-диска", - ) - self.add_admin_handler( - "set_config", - CommandCategories.CONFIG, - handlers.set_config, - "установить новое значение в конфиге", - ) - self.add_admin_handler( - "add_manager", - CommandCategories.MOST_USED, - handlers.add_manager, - "добавить менеджера в список", - ) - self.add_admin_handler( - "change_board", - CommandCategories.CONFIG, - handlers.change_board, - "изменить Trello board_id", - ) - self.add_admin_handler( - "send_reminders", - CommandCategories.BROADCAST, - self.admin_reply_handler("send_reminders_job"), - "отослать напоминания вне расписания", - ) - self.add_admin_handler( - "send_trello_curator_notification", - CommandCategories.BROADCAST, - self.admin_reply_handler("trello_board_state_notifications_job"), - "разослать кураторам состояние их карточек вне расписания", - ) - self.add_admin_handler( - "manage_all_reminders", - CommandCategories.MOST_USED, - handlers.manage_all_reminders, - "настроить все напоминания", - ) - self.add_admin_handler( - "get_roles_for_member", - # CommandCategories.HR, - CommandCategories.DEBUG, - handlers.get_roles_for_member, - "показать роли для участника", - ) - self.add_admin_handler( - "get_members_for_role", - # CommandCategories.HR, - CommandCategories.DEBUG, - handlers.get_members_for_role, - "показать участников для роли", - ) - self.add_admin_handler( - "check_chat_consistency", - CommandCategories.HR, - self.admin_reply_handler("hr_check_chat_consistency_job"), - "консистентность чата редакции", - ) - self.add_admin_handler( - "check_chat_consistency_frozen", - CommandCategories.HR, - self.admin_reply_handler("hr_check_chat_consistency_frozen_job"), - "консистентность чата редакции (замороженные участники)", - ) - self.add_admin_handler( - "get_members_without_telegram", - CommandCategories.HR, - self.admin_reply_handler("hr_get_members_without_telegram_job"), - ( - "активные участники без указанного телеграма" - "(телефон это 10+ цифр+-(), отсутствие включает #N/A и кириллицу)" - ), - ) - self.add_admin_handler( - "check_site_health", - CommandCategories.DATA_SYNC, - self.admin_reply_handler("site_health_check_job"), - "проверка статуса сайта", - ) - self.add_admin_handler( - "get_chat_data", - CommandCategories.DEBUG, - handlers.get_chat_data, - "get_chat_data", - ) - self.add_admin_handler( - "clean_chat_data", - CommandCategories.DEBUG, - handlers.clean_chat_data, - "clean_chat_data", - ) - self.add_admin_handler( - "get_managers", - CommandCategories.MOST_USED, - handlers.get_managers, - "get_managers", - ) - - # sample handler - self.add_handler( - "sample_handler", - self.admin_reply_handler("sample_job"), - ) - - # db commands hidden from /help command - self.add_handler( - "db_fetch_authors_sheet", - self.admin_reply_handler("db_fetch_authors_sheet_job"), - ) - self.add_handler( - "db_fetch_curators_sheet", - self.admin_reply_handler("db_fetch_curators_sheet_job"), - ) - self.add_handler( - "db_fetch_team_sheet", - self.admin_reply_handler("db_fetch_team_sheet_job"), - ) - self.add_handler( - "db_fetch_strings_sheet", - self.admin_reply_handler("db_fetch_strings_sheet_job"), - ) - self.add_admin_handler( - "db_fetch_all_team_members", - CommandCategories.MOST_USED, - self.admin_reply_handler("db_fetch_all_team_members_job"), - "db_fetch_all_team_members", - ) - self.add_admin_handler( - "backfill_telegram_user_ids", - CommandCategories.DATA_SYNC, - self.admin_reply_handler("backfill_telegram_user_ids_job"), - "backfill Telegram user IDs from team member usernames", - ) + """ + Initializes Telegram handlers based on the configuration in `HANDLER_REGISTRY`. + Iterates over the registry, resolves handlers (direct or job-based), + applies wrappers (e.g. direct_only), and registers them with the application. + """ + # Register handlers from registry + for config in HANDLER_REGISTRY: + # Resolve handler + handler = config.handler_func + if config.job_name: + if config.job_type == "admin_broadcast": + handler = self.admin_broadcast_handler(config.job_name) + elif config.job_type == "admin_reply": + handler = self.admin_reply_handler(config.job_name) + elif config.job_type == "manager_reply": + handler = self.manager_reply_handler(config.job_name) + elif config.job_type == "user_reply": + handler = self.user_handler(config.job_name) + + # Apply modifiers + if config.direct_only: + handler = direct_message_only(handler) + + # Register + if config.access_level == "admin": + self.add_admin_handler( + config.command, config.category, handler, config.description + ) + elif config.access_level == "manager": + self.add_manager_handler( + config.command, config.category, handler, config.description + ) + elif config.access_level == "user": + self.add_user_handler( + config.command, config.category, handler, config.description + ) + else: # hidden + self.add_handler(config.command, handler) - # general purpose cmds - self.add_admin_handler( - "start", CommandCategories.DEBUG, handlers.start, "начать чат с ботом" - ) + # Special case: Help handler (requires dynamic self.handlers_info) self.add_admin_handler( "help", CommandCategories.DEBUG, - # CommandCategories.MOST_USED, lambda update, context: handlers.help(update, context, self.handlers_info), "получить список доступных команд", ) - self.add_admin_handler( - "shrug", - CommandCategories.DEBUG, - # CommandCategories.MOST_USED, - self.admin_reply_handler("shrug_job"), - "¯\\_(ツ)_/¯", - ) # on non-command user message diff --git a/src/facebook/facebook_client.py b/src/facebook/facebook_client.py index ab265bf..cf43038 100644 --- a/src/facebook/facebook_client.py +++ b/src/facebook/facebook_client.py @@ -70,10 +70,12 @@ def get_total_reach( ) -> List[Tuple[datetime, int]]: """ Get statistics on the total reach of new posts. + NOTE: 'page_posts_impressions_unique' is deprecated in v19.0. + Using 'page_impressions_unique' (Total Page Reach) as replacement. """ batches = self._get_all_batches( connection_name="insights", - metric="page_posts_impressions_unique", + metric="page_impressions_unique", period=period.value, since=since, until=until, @@ -91,7 +93,7 @@ def get_organic_reach( """ batches = self._get_all_batches( connection_name="insights", - metric="page_posts_impressions_organic_unique", + metric="page_impressions_organic_unique", period=period.value, since=since, until=until, @@ -127,7 +129,7 @@ def get_new_fan_count( """ batches = self._get_all_batches( connection_name="insights", - metric="page_fan_adds_unique", + metric="page_daily_follows_unique", period=period.value, since=since, until=until, @@ -147,7 +149,12 @@ def _get_all_batches( } params.update(kwargs) page = self._make_graph_api_call(f"{self._page_id}/{connection_name}", params) - result += page["data"] + + if "error" in page: + logger.error(f"Error fetching {connection_name}: {page['error']}") + return result + + result += page.get("data", []) # process next result += self._iterate_over_pages(connection_name, since, until, page, True) # process previous @@ -192,10 +199,16 @@ def _iterate_over_pages( current_page = self._make_graph_api_call( f"{self._page_id}/{connection_name}", args ) + if "error" in current_page: + logger.error( + f"Error in pagination for {connection_name}: {current_page['error']}" + ) + break + if "data" in current_page: result += current_page["data"] else: - logger.error(f"Error in pagination: {current_page}") + logger.warning(f"No data found in pagination response: {current_page}") break return result diff --git a/src/tg/handler_registry.py b/src/tg/handler_registry.py new file mode 100644 index 0000000..6a8955a --- /dev/null +++ b/src/tg/handler_registry.py @@ -0,0 +1,444 @@ +from dataclasses import dataclass +from typing import Callable, Optional, Literal + +from ..consts import CommandCategories +from . import handlers + + +@dataclass +class HandlerConfig: + command: str + description: str = "" + category: Optional[CommandCategories] = None + access_level: Literal["admin", "manager", "user", "hidden"] = "hidden" + + # Logic configuration + handler_func: Optional[Callable] = None + job_name: Optional[str] = None + job_type: Literal[ + "admin_broadcast", "admin_reply", "manager_reply", "user_reply" + ] = "manager_reply" + + # Modifiers + direct_only: bool = False + + +HANDLER_REGISTRY = [ + # Business logic cmds + HandlerConfig( + command="send_trello_board_state", + category=CommandCategories.BROADCAST, + access_level="admin", + job_name="trello_board_state_job", + job_type="admin_broadcast", + description="рассылка сводки о состоянии доски", + ), + HandlerConfig( + command="get_trello_board_state", + category=CommandCategories.SUMMARY, + access_level="manager", + job_name="trello_board_state_job", + job_type="manager_reply", + description="получить сводку о состоянии доски", + ), + HandlerConfig( + command="get_publication_plans", + category=CommandCategories.SUMMARY, + access_level="manager", + job_name="publication_plans_job", + job_type="manager_reply", + description="получить сводку о публикуемыми на неделе постами", + ), + HandlerConfig( + command="send_publication_plans", + category=CommandCategories.BROADCAST, + access_level="admin", + job_name="publication_plans_job", + job_type="admin_broadcast", + description="рассылка сводки о публикуемых на неделе постах", + ), + HandlerConfig( + command="get_manager_status", + category=CommandCategories.SUMMARY, + access_level="manager", + job_name="board_my_cards_razvitie_job", + job_type="manager_reply", + direct_only=True, + description="получить мои карточки из доски Развитие", + ), + HandlerConfig( + command="fill_posts_list", + category=CommandCategories.DEBUG, + access_level="manager", + job_name="fill_posts_list_job", + job_type="manager_reply", + direct_only=True, + description="заполнить реестр постов (пока не работает)", + ), + HandlerConfig( + command="fill_posts_list_focalboard", + category=CommandCategories.DEBUG, + access_level="manager", + job_name="fill_posts_list_focalboard_job", + job_type="manager_reply", + direct_only=True, + description="заполнить реестр постов из Focalboard (пока не работает)", + ), + HandlerConfig( + command="hr_acquisition", + category=CommandCategories.HR, + access_level="admin", + job_name="hr_acquisition_job", + job_type="manager_reply", + description="обработать новые анкеты", + ), + HandlerConfig( + command="hr_acquisition_pt", + category=CommandCategories.HR, + access_level="admin", + job_name="hr_acquisition_pt_job", + job_type="manager_reply", + description="обработать новые анкеты Пишу Тебе", + ), + HandlerConfig( + command="get_hr_status", + category=CommandCategories.HR, + access_level="manager", + job_name="hr_status_job", + job_type="manager_reply", + description="получить статус по работе hr (по новичкам и участникам на испытательном)", + ), + HandlerConfig( + command="send_hr_status", + category=CommandCategories.BROADCAST, + access_level="admin", + job_name="hr_status_job", + job_type="admin_broadcast", + description="разослать статус по работе hr (по новичкам и участинкам на испытательном)", + ), + HandlerConfig( + command="create_folders_for_illustrators", + category=CommandCategories.REGISTRY, + access_level="manager", + job_name="create_folders_for_illustrators_job", + job_type="manager_reply", + description="создать папки для иллюстраторов", + ), + HandlerConfig( + command="get_tasks_report_focalboard", + category=CommandCategories.MOST_USED, + access_level="manager", + handler_func=handlers.get_tasks_report_focalboard, + direct_only=True, + description="получить список задач из Focalboard", + ), + HandlerConfig( + command="get_rubrics", + category=CommandCategories.MOST_USED, + access_level="manager", + handler_func=handlers.get_rubrics, + direct_only=True, + description="получить рубрики из доски Редакция", + ), + HandlerConfig( + command="get_articles_rubric", + category=CommandCategories.DEBUG, + access_level="manager", + job_name="trello_get_articles_rubric_job", + job_type="manager_reply", + description="получить карточки по названию рубрики в трелло", + ), + HandlerConfig( + command="get_chat_id", + category=CommandCategories.MOST_USED, + access_level="manager", + handler_func=handlers.get_chat_id, + description="получить chat_id (свой или группы)", + ), + HandlerConfig( + command="manage_reminders", + category=CommandCategories.MOST_USED, + access_level="manager", + handler_func=handlers.manage_reminders, + description="настроить напоминания", + ), + HandlerConfig( + command="get_fb_analytics_report", + category=CommandCategories.STATS, + access_level="manager", + job_name="fb_analytics_report_job", + job_type="manager_reply", + description="получить статистику facebook страницы за неделю", + ), + HandlerConfig( + command="get_ig_analytics_report", + category=CommandCategories.STATS, + access_level="manager", + job_name="ig_analytics_report_job", + job_type="manager_reply", + description="получить статистику instagram страницы за неделю", + ), + HandlerConfig( + command="get_tg_analytics_report", + category=CommandCategories.STATS, + access_level="manager", + job_name="tg_analytics_report_job", + job_type="manager_reply", + description="получить статистику telegram канала за неделю", + ), + HandlerConfig( + command="get_report_from_sheet", + category=CommandCategories.SUMMARY, + access_level="manager", + job_name="sheet_report_job", + job_type="manager_reply", + description="получить статистику по табличке (например, оцифровка открыток)", + ), + HandlerConfig( + command="enroll_curator", + category=CommandCategories.HR, + access_level="manager", + handler_func=handlers.enroll_curator, + description="", # hidden from help + ), + # Admin-only technical cmds + HandlerConfig( + command="update_config", + category=CommandCategories.CONFIG, + access_level="admin", + job_name="config_updater_job", + job_type="admin_reply", + description="обновить конфиг вне расписания", + ), + HandlerConfig( + command="list_jobs", + category=CommandCategories.CONFIG, + access_level="admin", + handler_func=handlers.list_jobs, + description="показать статус асинхронных задач", + ), + HandlerConfig( + command="get_usage_list", + category=CommandCategories.CONFIG, + access_level="admin", + handler_func=handlers.list_chats, + description="показать места использование бота: пользователи и чаты", + ), + HandlerConfig( + command="set_log_level", + category=CommandCategories.LOGGING, + access_level="admin", + handler_func=handlers.set_log_level, + description="изменить уровень логирования (info / debug)", + ), + HandlerConfig( + command="mute_errors", + category=CommandCategories.LOGGING, + access_level="admin", + handler_func=handlers.mute_errors, + description="отключить логирование ошибок в телеграм", + ), + HandlerConfig( + command="unmute_errors", + category=CommandCategories.LOGGING, + access_level="admin", + handler_func=handlers.unmute_errors, + description="включить логирование ошибок в телеграм", + ), + HandlerConfig( + command="get_config", + category=CommandCategories.CONFIG, + access_level="admin", + handler_func=handlers.get_config, + description="получить текущий конфиг (частично или полностью)", + ), + HandlerConfig( + command="get_config_jobs", + category=CommandCategories.CONFIG, + access_level="admin", + handler_func=handlers.get_config_jobs, + description="получить текущий конфиг джобов (частично или полностью)", + ), + HandlerConfig( + command="reload_config_jobs", + category=CommandCategories.CONFIG, + access_level="admin", + handler_func=handlers.reload_config_jobs, + description="обновить конфиг джобов с Google-диска", + ), + HandlerConfig( + command="set_config", + category=CommandCategories.CONFIG, + access_level="admin", + handler_func=handlers.set_config, + description="установить новое значение в конфиге", + ), + HandlerConfig( + command="add_manager", + category=CommandCategories.MOST_USED, + access_level="admin", + handler_func=handlers.add_manager, + description="добавить менеджера в список", + ), + HandlerConfig( + command="change_board", + category=CommandCategories.CONFIG, + access_level="admin", + handler_func=handlers.change_board, + description="изменить Trello board_id", + ), + HandlerConfig( + command="send_reminders", + category=CommandCategories.BROADCAST, + access_level="admin", + job_name="send_reminders_job", + job_type="admin_reply", + description="отослать напоминания вне расписания", + ), + HandlerConfig( + command="send_trello_curator_notification", + category=CommandCategories.BROADCAST, + access_level="admin", + job_name="trello_board_state_notifications_job", + job_type="admin_reply", + description="разослать кураторам состояние их карточек вне расписания", + ), + HandlerConfig( + command="manage_all_reminders", + category=CommandCategories.MOST_USED, + access_level="admin", + handler_func=handlers.manage_all_reminders, + description="настроить все напоминания", + ), + HandlerConfig( + command="get_roles_for_member", + category=CommandCategories.DEBUG, + access_level="admin", + handler_func=handlers.get_roles_for_member, + description="показать роли для участника", + ), + HandlerConfig( + command="get_members_for_role", + category=CommandCategories.DEBUG, + access_level="admin", + handler_func=handlers.get_members_for_role, + description="показать участников для роли", + ), + HandlerConfig( + command="check_chat_consistency", + category=CommandCategories.HR, + access_level="admin", + job_name="hr_check_chat_consistency_job", + job_type="admin_reply", + description="консистентность чата редакции", + ), + HandlerConfig( + command="check_chat_consistency_frozen", + category=CommandCategories.HR, + access_level="admin", + job_name="hr_check_chat_consistency_frozen_job", + job_type="admin_reply", + description="консистентность чата редакции (замороженные участники)", + ), + HandlerConfig( + command="get_members_without_telegram", + category=CommandCategories.HR, + access_level="admin", + job_name="hr_get_members_without_telegram_job", + job_type="admin_reply", + description="активные участники без указанного телеграма(телефон это 10+ цифр+-(), отсутствие включает #N/A и кириллицу)", + ), + HandlerConfig( + command="check_site_health", + category=CommandCategories.DATA_SYNC, + access_level="admin", + job_name="site_health_check_job", + job_type="admin_reply", + description="проверка статуса сайта", + ), + HandlerConfig( + command="get_chat_data", + category=CommandCategories.DEBUG, + access_level="admin", + handler_func=handlers.get_chat_data, + description="get_chat_data", + ), + HandlerConfig( + command="clean_chat_data", + category=CommandCategories.DEBUG, + access_level="admin", + handler_func=handlers.clean_chat_data, + description="clean_chat_data", + ), + HandlerConfig( + command="get_managers", + category=CommandCategories.MOST_USED, + access_level="admin", + handler_func=handlers.get_managers, + description="get_managers", + ), + # Sample and DB commands + HandlerConfig( + command="sample_handler", + access_level="hidden", + job_name="sample_job", + job_type="admin_reply", + ), + HandlerConfig( + command="db_fetch_authors_sheet", + access_level="hidden", + job_name="db_fetch_authors_sheet_job", + job_type="admin_reply", + ), + HandlerConfig( + command="db_fetch_curators_sheet", + access_level="hidden", + job_name="db_fetch_curators_sheet_job", + job_type="admin_reply", + ), + HandlerConfig( + command="db_fetch_team_sheet", + access_level="hidden", + job_name="db_fetch_team_sheet_job", + job_type="admin_reply", + ), + HandlerConfig( + command="db_fetch_strings_sheet", + access_level="hidden", + job_name="db_fetch_strings_sheet_job", + job_type="admin_reply", + ), + HandlerConfig( + command="db_fetch_all_team_members", + category=CommandCategories.MOST_USED, + access_level="admin", + job_name="db_fetch_all_team_members_job", + job_type="admin_reply", + description="db_fetch_all_team_members", + ), + HandlerConfig( + command="backfill_telegram_user_ids", + category=CommandCategories.DATA_SYNC, + access_level="admin", + job_name="backfill_telegram_user_ids_job", + job_type="admin_reply", + description="backfill Telegram user IDs from team member usernames", + ), + # General purpose + HandlerConfig( + command="start", + category=CommandCategories.DEBUG, + access_level="admin", + handler_func=handlers.start, + description="начать чат с ботом", + ), + HandlerConfig( + command="shrug", + category=CommandCategories.DEBUG, + access_level="admin", + job_name="shrug_job", + job_type="admin_reply", + description="¯\\_(ツ)_/¯", + ), + # HELP is handled specially or requires lambda +] diff --git a/src/tg/handlers/__init__.py b/src/tg/handlers/__init__.py index 3ab3803..380b372 100644 --- a/src/tg/handlers/__init__.py +++ b/src/tg/handlers/__init__.py @@ -44,6 +44,6 @@ # Plain text message handler from .user_message_handler import ( handle_callback_query, - handle_new_members, handle_user_message, ) +from .flow_handlers import handle_new_members diff --git a/src/tg/handlers/flow_handlers.py b/src/tg/handlers/flow_handlers.py new file mode 100644 index 0000000..b9e16b6 --- /dev/null +++ b/src/tg/handlers/flow_handlers.py @@ -0,0 +1,971 @@ +import calendar +import logging +import telegram +from datetime import datetime +from abc import ABC, abstractmethod +from typing import Optional + +from ... import consts +from ...app_context import AppContext +from ...consts import ( + ButtonValues, + GetTasksReportData, + PlainTextUserAction, + BoardListAlias, +) +from ...db.db_client import DBClient +from ...db.db_objects import Reminder +from ...focalboard.focalboard_client import FocalboardClient +from ...strings import load +from ...tg.handlers import get_tasks_report_handler +from ...trello.trello_client import TrelloClient +from .utils import get_sender_id, reply, get_sender_username, get_chat_id, get_chat_name + +logger = logging.getLogger(__name__) + +SECTIONS = [ + ("Идеи для статей", BoardListAlias.TOPIC_SUGGESTION_1), + ("Готовая тема", BoardListAlias.TOPIC_READY_2), + ("Уже пишу", BoardListAlias.DRAFT_N_PROGRESS_3, True), + ("Передано на редактуру", BoardListAlias.DRAFT_COMPLETED_4), + ("На редактуре", BoardListAlias.PENDING_EDITOR_5), + ("Проверка качества SEO", BoardListAlias.PENDING_SEO_EDITOR_6), + ("Отредактировано", BoardListAlias.APPROVED_EDITOR_7), + ("Отобрано на финальную проверку", BoardListAlias.PENDING_CHIEF_EDITOR_8), + ("Отобрано для публикации", BoardListAlias.PUBLISH_BACKLOG_9), + ("Готово для вёрстки", BoardListAlias.PUBLISH_IN_PROGRESS_10), +] + + +def handle_stateless_message(update, tg_context): + """ + Handles messages that don't have an active command state (stateless). + Forwards message to n8n and upserts user. + """ + if update.message and update.message.text: + try: + app_context = AppContext() + user_id = get_sender_id(update) + username = ( + get_sender_username(update) + if update.message.from_user.username + else None + ) + + # Auto-create/update User record + team_member_id = None + if username: + # Normalize username (remove @ if present) + normalized_username = username.lstrip("@") + # Find TeamMember with matching telegram username + team_members = app_context.db_client.get_all_members() + matching_member = next( + ( + m + for m in team_members + if m.telegram + and m.telegram.strip().lstrip("@").lower() + == normalized_username.lower() + ), + None, + ) + if matching_member: + team_member_id = matching_member.id + + # Upsert User record + app_context.db_client.upsert_user_from_telegram( + telegram_user_id=user_id, + telegram_username=username, + team_member_id=team_member_id, + ) + + query = update.message.text.strip() + app_context.n8n_client.send_webhook(user_id, query) + except Exception as e: + logger.error( + f"Failed to send message to n8n: {e}", + exc_info=True, + ) + + +def handle_new_members( + update: telegram.Update, tg_context: telegram.ext.CallbackContext +): + # writes chat_id and chat name to db when anybody (including the bot) is added to a new chat + # very heuristic solution + DBClient().set_chat_name(get_chat_id(update), get_chat_name(update)) + + +class BaseUserMessageHandler(ABC): + def __init__(self, update, tg_context, command_data, user_input, button): + self.update = update + self.tg_context = tg_context + self.command_data = command_data + self.user_input = user_input + self.button = button + + @abstractmethod + def handle(self) -> Optional[PlainTextUserAction]: + raise NotImplementedError + + +def _generate_rubric_summary(update, rubric_name: str) -> None: + try: + app_context = AppContext() + fc = app_context.focalboard_client + labels = fc._get_labels() + rubric_label = next( + ( + lbl + for lbl in labels + if lbl.name.strip().lower() == rubric_name.strip().lower() + ), + None, + ) + if not rubric_label: + logger.warning( + f"_generate_rubric_summary: Рубрика не найдена: {rubric_name}" + ) + reply( + load( + "rubric_not_found", + rubric_name=rubric_name, + ), + update, + ) + return + + # Get all lists + try: + lists = fc.get_lists(board_id=fc.board_id, sorted=False) + except Exception as e: + logger.error( + f"_generate_rubric_summary: не удалось получить lists: {e}", + exc_info=True, + ) + reply(load("failed_get_board_lists"), update) + return + + message_parts = [ + load( + "rubric_report_job__intro", + rubric=rubric_name, + ) + ] + + had_errors = False + + for column_name, alias, *meta_flag in SECTIONS: + need_meta = bool(meta_flag and meta_flag[0]) + heading = load(alias.value) + # Find column + target_list = next( + (lst for lst in lists if lst.name.strip().startswith(column_name)), None + ) + + if not target_list: + message_parts.append(f"{heading} (0)") + message_parts.append("") + continue + + try: + cards = fc.get_cards(list_ids=[target_list.id], board_id=fc.board_id) + except Exception: + had_errors = True + message_parts.append(f"{heading} (0)") + message_parts.append("") + continue + + filtered = [ + card + for card in cards + if any(lbl.id == rubric_label.id for lbl in card.labels) + ] + + filtered.sort(key=lambda c: c.due or datetime.max) + + count = len(filtered) + message_parts.append(f"{heading} ({count})") + + if not filtered: + message_parts.append("(пусто)") + else: + for card in filtered: + link = f'{card.name}' + if need_meta: + due_str = ( + card.due.strftime("%d.%m.%Y") if card.due else "без срока" + ) + try: + fields = fc.get_custom_fields(card.id) + authors = ( + ", ".join(fields.authors) + if fields.authors + else "неизвестно" + ) + except Exception: + authors = "неизвестно" + message_parts.append(f"- {link}") + message_parts.append(f" • Дедлайн: {due_str}") + message_parts.append(f" • Автор: {authors}") + else: + message_parts.append(f"- {link}") + + message_parts.append("") + + if had_errors: + message_parts.append(load("partial_data_error ")) + + reply("\n".join(message_parts), update, parse_mode="HTML") + + except Exception: + reply(load("failed_try_later"), update) + + +def _show_reminder_edit_options( + reminder: Reminder, update: telegram.Update, command_data: dict +): + """ + Shows the menu with options to edit a specific reminder. + """ + # keyboard for edit + button_list = [ + [ + telegram.InlineKeyboardButton( + load("manage_reminders_handler__edit_text_btn"), + callback_data=ButtonValues.MANAGE_REMINDERS__EDIT__TEXT.value, + ) + ], + [ + telegram.InlineKeyboardButton( + load("manage_reminders_handler__edit_datetime_btn"), + callback_data=ButtonValues.MANAGE_REMINDERS__EDIT__DATETIME.value, + ) + ], + [ + telegram.InlineKeyboardButton( + load("manage_reminders_handler__edit_title_btn"), + callback_data=ButtonValues.MANAGE_REMINDERS__EDIT__TITLE.value, + ) + ], + [ + telegram.InlineKeyboardButton( + load("manage_reminders_handler__edit_chat_btn"), + callback_data=ButtonValues.MANAGE_REMINDERS__EDIT__CHAT.value, + ) + ], + [ + ( + telegram.InlineKeyboardButton( + load("manage_reminders_handler__edit_suspend_btn"), + callback_data=ButtonValues.MANAGE_REMINDERS__EDIT__SUSPEND.value, + ) + if reminder.is_active + else telegram.InlineKeyboardButton( + load("manage_reminders_handler__edit_resume_btn"), + callback_data=ButtonValues.MANAGE_REMINDERS__EDIT__RESUME.value, + ) + ) + ], + [ + ( + telegram.InlineKeyboardButton( + load("manage_reminders_handler__edit_poll_active_btn"), + callback_data=ButtonValues.MANAGE_REMINDERS__DISABLE_POLL.value, + ) + if reminder.send_poll + else telegram.InlineKeyboardButton( + load("manage_reminders_handler__edit_poll_inactive_btn"), + callback_data=ButtonValues.MANAGE_REMINDERS__ENABLE_POLL.value, + ) + ) + ], + ] + reply_markup = telegram.InlineKeyboardMarkup(button_list) + weekday_str = ( + calendar.TextCalendar().formatweekday(int(reminder.weekday), 15).strip() + ) + reply( + load( + "manage_reminders_handler__weekly_reminder", + weekday=weekday_str, + time=reminder.time, + text=reminder.text, + ), + update, + reply_markup=reply_markup, + ) + # The caller is expected to return the next action transition to CHOOSE_EDIT_ACTION + + +def _handle_task_report_helper(command_data, add_labels, update): + board_id = command_data[consts.GetTasksReportData.BOARD_ID] + list_id = command_data[consts.GetTasksReportData.LIST_ID] + introduction = command_data[consts.GetTasksReportData.INTRO_TEXT] + use_focalboard = command_data[consts.GetTasksReportData.USE_FOCALBOARD] + messages = get_tasks_report_handler.generate_report_messages( + board_id, list_id, introduction, add_labels, use_focalboard=use_focalboard + ) + for message in messages: + reply(message, update) + # finished with last action for /trello_client_get_lists + return None + + +class GetRubricsChooseRubricHandler(BaseUserMessageHandler): + def handle(self) -> Optional[PlainTextUserAction]: + try: + idx = int(self.user_input) - 1 + rubrics = self.command_data.get( + GetTasksReportData.LISTS + ) or self.tg_context.chat_data.get("available_rubrics", []) + if not (0 <= idx < len(rubrics)): + raise ValueError + except Exception: + reply( + load( + "invalid_rubric_number", + max=len(rubrics), + ), + self.update, + ) + return PlainTextUserAction.GET_RUBRICS__CHOOSE_RUBRIC + + selected = rubrics[idx] + _generate_rubric_summary(self.update, selected) + + self.tg_context.chat_data.pop(consts.LAST_ACTIONABLE_COMMAND, None) + return None + + +class GetTasksReportEnterBoardUrlHandler(BaseUserMessageHandler): + def handle(self) -> Optional[PlainTextUserAction]: + trello_client = TrelloClient() + try: + board = trello_client.get_board_by_url(self.user_input) + trello_lists = trello_client.get_lists(board.id) + except Exception: + reply(load("get_tasks_report_handler__board_not_found"), self.update) + return PlainTextUserAction.GET_TASKS_REPORT__ENTER_BOARD_URL + + self.command_data[consts.GetTasksReportData.BOARD_ID] = board.id + self.command_data[consts.GetTasksReportData.LISTS] = [ + lst.to_dict() for lst in trello_lists + ] + + trello_lists_formatted = "\n".join( + [f"{i + 1}) {lst.name}" for i, lst in enumerate(trello_lists)] + ) + reply( + load( + "get_tasks_report_handler__choose_trello_list", + lists=trello_lists_formatted, + ), + self.update, + ) + return PlainTextUserAction.GET_TASKS_REPORT__ENTER_LIST_NUMBER + + +class GetTasksReportEnterBoardNumberHandler(BaseUserMessageHandler): + def handle(self) -> Optional[PlainTextUserAction]: + trello_client = TrelloClient() + focalboard_client = FocalboardClient() + try: + board_list = self.tg_context.chat_data[consts.GetTasksReportData.LISTS] + use_focalboard = self.tg_context.chat_data[ + consts.GetTasksReportData.USE_FOCALBOARD + ] + list_idx = int(self.user_input) - 1 + assert 0 <= list_idx < len(board_list) + board_id = board_list[list_idx]["id"] + if use_focalboard: + trello_lists = focalboard_client.get_lists(board_id, sorted=True) + trello_lists = trello_lists[::-1] + else: + trello_lists = trello_client.get_lists(board_id) + trello_lists = trello_lists[::-1] + except Exception as e: + logger.warning("Failed to parse board number", exc_info=e) + reply( + load( + "get_tasks_report_handler__enter_the_number", + max_val=len(board_list), + ), + self.update, + ) + return PlainTextUserAction.GET_TASKS_REPORT__ENTER_BOARD_NUMBER + + self.command_data[consts.GetTasksReportData.BOARD_ID] = board_id + self.command_data[consts.GetTasksReportData.USE_FOCALBOARD] = use_focalboard + self.command_data[consts.GetTasksReportData.LISTS] = [ + lst.to_dict() for lst in trello_lists + ] + + trello_lists_formatted = "\n".join( + [ + f"{len(trello_lists) - i}) {lst.name}" + for i, lst in enumerate(trello_lists) + ] + ) + reply( + load( + "get_tasks_report_handler__choose_trello_list", + lists=trello_lists_formatted, + ), + self.update, + ) + return PlainTextUserAction.GET_TASKS_REPORT__ENTER_LIST_NUMBER + + +class GetTasksReportEnterListNumberHandler(BaseUserMessageHandler): + def handle(self) -> Optional[PlainTextUserAction]: + try: + trello_lists = self.command_data.get(consts.GetTasksReportData.LISTS, []) + list_idx = -int(self.user_input) + assert 0 > list_idx >= -len(trello_lists) + list_id = trello_lists[list_idx]["id"] + except Exception as e: + logger.warning("Failed to parse list number", exc_info=e) + reply( + load( + "get_tasks_report_handler__enter_the_number", + max_val=len(trello_lists), + ), + self.update, + ) + return PlainTextUserAction.GET_TASKS_REPORT__ENTER_LIST_NUMBER + + self.command_data[consts.GetTasksReportData.LIST_ID] = list_id + + reply_markup = telegram.InlineKeyboardMarkup( + [ + [ + telegram.InlineKeyboardButton( + load("get_tasks_report_handler__no_text_btn"), + callback_data=ButtonValues.GET_TASKS_REPORT__NO_INTRO.value, + ) + ] + ] + ) + if not self.tg_context.chat_data.get("advanced"): + add_labels = self.button == ButtonValues.GET_TASKS_REPORT__LABELS__NO + self.command_data[consts.GetTasksReportData.INTRO_TEXT] = None + return _handle_task_report_helper( + self.command_data, add_labels, self.update + ) + + reply( + load("get_tasks_report_handler__enter_intro"), + self.update, + reply_markup=reply_markup, + ) + return PlainTextUserAction.GET_TASKS_REPORT__ENTER_INTRO + + +class GetTasksReportEnterIntroHandler(BaseUserMessageHandler): + def handle(self) -> Optional[PlainTextUserAction]: + if ( + self.button is not None + and self.button == ButtonValues.GET_TASKS_REPORT__NO_INTRO + ): + self.command_data[consts.GetTasksReportData.INTRO_TEXT] = None + else: + self.command_data[consts.GetTasksReportData.INTRO_TEXT] = self.user_input + + button_list = [ + [ + telegram.InlineKeyboardButton( + load("common__yes"), + callback_data=ButtonValues.GET_TASKS_REPORT__LABELS__YES.value, + ), + telegram.InlineKeyboardButton( + load("common__no"), + callback_data=ButtonValues.GET_TASKS_REPORT__LABELS__NO.value, + ), + ] + ] + reply_markup = telegram.InlineKeyboardMarkup(button_list) + reply( + load("get_tasks_report_handler__choose_if_fill_labels"), + self.update, + reply_markup=reply_markup, + ) + return PlainTextUserAction.GET_TASKS_REPORT__CHOOSE_IF_FILL_LABELS + + +class GetTasksReportChooseIfFillLabelsHandler(BaseUserMessageHandler): + def handle(self) -> Optional[PlainTextUserAction]: + if self.button is None: + reply(load("user_message_handler__press_button_please"), self.update) + return PlainTextUserAction.GET_TASKS_REPORT__CHOOSE_IF_FILL_LABELS + + add_labels = self.button == ButtonValues.GET_TASKS_REPORT__LABELS__YES + return _handle_task_report_helper(self.command_data, add_labels, self.update) + + +class ManageRemindersChooseActionHandler(BaseUserMessageHandler): + def _handle_direct_reminder_edit(self): + reminder_ids = self.command_data.get( + consts.ManageRemindersData.EXISTING_REMINDERS, [] + ) + try: + assert 0 < int(self.user_input) <= len(reminder_ids) + reminder_id, _, _ = reminder_ids[int(self.user_input) - 1] + except Exception: + reply(load("manage_reminders_handler__reminder_number_bad"), self.update) + return PlainTextUserAction.MANAGE_REMINDERS__CHOOSE_ACTION + + self.command_data[consts.ManageRemindersData.ACTION_TYPE] = ( + ButtonValues.MANAGE_REMINDERS__ACTIONS__EDIT + ) + self.command_data[consts.ManageRemindersData.CHOSEN_REMINDER_ID] = reminder_id + reminder = DBClient().get_reminder_by_id(reminder_id) + _show_reminder_edit_options(reminder, self.update, self.command_data) + return PlainTextUserAction.MANAGE_REMINDERS__CHOOSE_EDIT_ACTION + + def _handle_new_action(self): + reply(load("manager_reminders_handler__enter_chat_id"), self.update) + return PlainTextUserAction.MANAGE_REMINDERS__ENTER_CHAT_ID + + def _handle_delete_action(self): + reply( + load("manage_reminders_handler__enter_reminder_number_to_delete"), + self.update, + ) + return PlainTextUserAction.MANAGE_REMINDERS__ENTER_REMINDER_NUMBER + + def _handle_edit_action(self): + reply( + load("manage_reminders_handler__enter_reminder_number_to_edit"), self.update + ) + return PlainTextUserAction.MANAGE_REMINDERS__ENTER_REMINDER_NUMBER + + def handle(self) -> Optional[PlainTextUserAction]: + # If user sends a number, interpret it as a shortcut to edit that reminder + if self.user_input and self.user_input.isdigit(): + return self._handle_direct_reminder_edit() + + if self.button is None: + reply(load("user_message_handler__press_button_please"), self.update) + return PlainTextUserAction.MANAGE_REMINDERS__CHOOSE_ACTION + + action_map = { + ButtonValues.MANAGE_REMINDERS__ACTIONS__NEW: self._handle_new_action, + ButtonValues.MANAGE_REMINDERS__ACTIONS__DELETE: self._handle_delete_action, + ButtonValues.MANAGE_REMINDERS__ACTIONS__EDIT: self._handle_edit_action, + } + + handler_method = action_map.get(self.button) + if handler_method: + self.command_data[consts.ManageRemindersData.ACTION_TYPE] = self.button + return handler_method() + + # Fallback if unknown button + return PlainTextUserAction.MANAGE_REMINDERS__CHOOSE_ACTION + + +class ManageRemindersEnterReminderNumberHandler(BaseUserMessageHandler): + def _handle_delete_action(self, chat_title, reminder_name): + button_list = [ + [ + telegram.InlineKeyboardButton( + load("common__yes"), + callback_data=ButtonValues.MANAGE_REMINDERS__DELETE__YES.value, + ), + telegram.InlineKeyboardButton( + load("common__no"), + callback_data=ButtonValues.MANAGE_REMINDERS__DELETE__NO.value, + ), + ] + ] + reply_markup = telegram.InlineKeyboardMarkup(button_list) + reply( + load( + "manage_reminders_handler__confirm_delete", + chat=chat_title, + reminder=reminder_name, + ), + self.update, + reply_markup=reply_markup, + ) + return PlainTextUserAction.MANAGE_REMINDERS__DELETE_REQUEST + + def _handle_edit_action(self, reminder_id): + reminder = DBClient().get_reminder_by_id(reminder_id) + _show_reminder_edit_options(reminder, self.update, self.command_data) + return PlainTextUserAction.MANAGE_REMINDERS__CHOOSE_EDIT_ACTION + + def handle(self) -> Optional[PlainTextUserAction]: + reminder_ids = self.command_data[consts.ManageRemindersData.EXISTING_REMINDERS] + try: + assert 0 < int(self.user_input) <= len(reminder_ids) + reminder_id, chat_title, reminder_name = reminder_ids[ + int(self.user_input) - 1 + ] + except Exception: + reply(load("manage_reminders_handler__reminder_number_bad"), self.update) + return PlainTextUserAction.MANAGE_REMINDERS__ENTER_REMINDER_NUMBER + self.command_data[consts.ManageRemindersData.CHOSEN_REMINDER_ID] = reminder_id + + action = self.command_data[consts.ManageRemindersData.ACTION_TYPE] + if action == ButtonValues.MANAGE_REMINDERS__ACTIONS__DELETE: + return self._handle_delete_action(chat_title, reminder_name) + elif action == ButtonValues.MANAGE_REMINDERS__ACTIONS__EDIT: + return self._handle_edit_action(reminder_id) + else: + logger.error(f'Bad reminder action "{action}"') + return None + + +class ManageRemindersChooseEditActionHandler(BaseUserMessageHandler): + def _handle_edit_text(self): + reply(load("manage_reminders_handler__enter_text"), self.update) + return PlainTextUserAction.MANAGE_REMINDERS__ENTER_TEXT + + def _handle_edit_title(self, reminder): + reply( + load("manage_reminders_handler__enter_new_name", old_name=reminder.name), + self.update, + ) + return PlainTextUserAction.MANAGE_REMINDERS__ENTER_NAME + + def _handle_edit_chat(self, reminder): + reply( + load("manage_reminders_handler__enter_chat_id", name=reminder.name), + self.update, + ) + return PlainTextUserAction.MANAGE_REMINDERS__ENTER_CHAT_ID + + def _handle_edit_datetime(self): + reply_markup = telegram.InlineKeyboardMarkup(consts.WEEKDAY_BUTTONS) + reply( + load("manage_reminders_handler__choose_weekday"), + self.update, + reply_markup=reply_markup, + ) + return PlainTextUserAction.MANAGE_REMINDERS__CHOOSE_WEEKDAY + + def _handle_suspend(self, reminder_id, reminder): + DBClient().update_reminder(reminder_id, is_active=False) + reply( + load( + "manage_reminders_handler__reminder_was_suspended", + name=reminder.name, + ), + self.update, + ) + return None + + def _handle_resume(self, reminder_id, reminder): + DBClient().update_reminder(reminder_id, is_active=True) + reply( + load( + "manage_reminders_handler__reminder_was_resumed", + next_reminder_datetime=reminder.next_reminder_datetime, + ), + self.update, + ) + return None + + def _handle_disable_poll_request(self): + button_no = telegram.InlineKeyboardButton( + load("manage_reminders_handler__disable_poll_btn"), + callback_data=consts.ButtonValues.MANAGE_REMINDERS__DISABLE_POLL__YES.value, + ) + keyboard = [[button_no]] + reply( + load("manage_reminders_handler__disable_poll_question"), + self.update, + reply_markup=telegram.InlineKeyboardMarkup(keyboard), + ) + return PlainTextUserAction.MANAGE_REMINDERS__DISABLE_POLL + + def _handle_enable_poll_request(self): + button_yes = telegram.InlineKeyboardButton( + load("manage_reminders_handler__enable_poll_btn"), + callback_data=consts.ButtonValues.MANAGE_REMINDERS__ENABLE_POLL__YES.value, + ) + keyboard = [[button_yes]] + reply( + load("manage_reminders_handler__enable_poll_question"), + self.update, + reply_markup=telegram.InlineKeyboardMarkup(keyboard), + ) + return PlainTextUserAction.MANAGE_REMINDERS__ENABLE_POLL + + def handle(self) -> Optional[PlainTextUserAction]: + if self.button is None: + reply(load("manage_reminders_handler__press_button_please"), self.update) + return PlainTextUserAction.MANAGE_REMINDERS__CHOOSE_EDIT_ACTION + + reminder_id = int( + self.command_data[consts.ManageRemindersData.CHOSEN_REMINDER_ID] + ) + reminder = DBClient().get_reminder_by_id(reminder_id) + + action_map = { + ButtonValues.MANAGE_REMINDERS__EDIT__TEXT: self._handle_edit_text, + ButtonValues.MANAGE_REMINDERS__EDIT__TITLE: lambda: self._handle_edit_title( + reminder + ), + ButtonValues.MANAGE_REMINDERS__EDIT__CHAT: lambda: self._handle_edit_chat( + reminder + ), + ButtonValues.MANAGE_REMINDERS__EDIT__DATETIME: self._handle_edit_datetime, + ButtonValues.MANAGE_REMINDERS__EDIT__SUSPEND: lambda: self._handle_suspend( + reminder_id, reminder + ), + ButtonValues.MANAGE_REMINDERS__EDIT__RESUME: lambda: self._handle_resume( + reminder_id, reminder + ), + ButtonValues.MANAGE_REMINDERS__DISABLE_POLL: self._handle_disable_poll_request, + ButtonValues.MANAGE_REMINDERS__ENABLE_POLL: self._handle_enable_poll_request, + } + + handler = action_map.get(self.button) + if handler: + return handler() + + return PlainTextUserAction.MANAGE_REMINDERS__CHOOSE_EDIT_ACTION + + +class ManageRemindersEnablePollHandler(BaseUserMessageHandler): + def handle(self) -> Optional[PlainTextUserAction]: + if self.button == consts.ButtonValues.MANAGE_REMINDERS__ENABLE_POLL__YES: + db_client = DBClient() + reminder_id = int( + self.command_data[consts.ManageRemindersData.CHOSEN_REMINDER_ID] + ) + db_client.update_reminder(reminder_id, send_poll=True) + reply( + load("manage_reminders_handler__poll_was_enabled"), + self.update, + ) + return None + return PlainTextUserAction.MANAGE_REMINDERS__ENABLE_POLL + + +class ManageRemindersDisablePollHandler(BaseUserMessageHandler): + def handle(self) -> Optional[PlainTextUserAction]: + if self.button == consts.ButtonValues.MANAGE_REMINDERS__DISABLE_POLL__YES: + db_client = DBClient() + reminder_id = int( + self.command_data[consts.ManageRemindersData.CHOSEN_REMINDER_ID] + ) + db_client.update_reminder(reminder_id, send_poll=False) + reply( + load("manage_reminders_handler__poll_was_disabled"), + self.update, + ) + return None + return PlainTextUserAction.MANAGE_REMINDERS__DISABLE_POLL + + +class ManageRemindersDeleteRequestHandler(BaseUserMessageHandler): + def handle(self) -> Optional[PlainTextUserAction]: + if self.button is None: + reply(load("user_message_handler__press_button_please"), self.update) + return PlainTextUserAction.MANAGE_REMINDERS__DELETE_REQUEST + + if self.button == ButtonValues.MANAGE_REMINDERS__DELETE__YES: + DBClient().delete_reminder( + self.command_data.get(consts.ManageRemindersData.CHOSEN_REMINDER_ID) + ) + reply(load("manage_reminders_handler__reminder_was_deleted"), self.update) + return None + elif self.button == ButtonValues.MANAGE_REMINDERS__DELETE__NO: + reply( + load("manage_reminders_handler__reminder_was_not_deleted"), self.update + ) + return None + return PlainTextUserAction.MANAGE_REMINDERS__DELETE_REQUEST + + +class ManageRemindersEnterChatIdHandler(BaseUserMessageHandler): + def _handle_new_action(self, chat_title): + reply( + load("manage_reminders_handler__enter_name", chat_title=chat_title), + self.update, + ) + return PlainTextUserAction.MANAGE_REMINDERS__ENTER_NAME + + def _handle_edit_action(self, chat_title, chat_id): + reminder_id = int( + self.command_data[consts.ManageRemindersData.CHOSEN_REMINDER_ID] + ) + reminder = DBClient().get_reminder_by_id(reminder_id) + DBClient().update_reminder(reminder_id, group_chat_id=chat_id) + reply( + load( + "manage_reminders_handler__reminder_set", + name=reminder.name, + chat_title=chat_title, + ), + self.update, + ) + return None + + def handle(self) -> Optional[PlainTextUserAction]: + try: + chat_id = int(self.user_input) + chat_title = DBClient().get_chat_name(chat_id) + except Exception as e: + reply(load("manage_reminders_handler__bad_chat_id"), self.update) + logger.info("Failed to parse chat id", exc_info=e) + return PlainTextUserAction.MANAGE_REMINDERS__ENTER_CHAT_ID + + self.command_data[consts.ManageRemindersData.GROUP_CHAT_ID] = chat_id + action = self.command_data[consts.ManageRemindersData.ACTION_TYPE] + + if action == ButtonValues.MANAGE_REMINDERS__ACTIONS__NEW: + return self._handle_new_action(chat_title) + elif action == ButtonValues.MANAGE_REMINDERS__ACTIONS__EDIT: + return self._handle_edit_action(chat_title, chat_id) + return PlainTextUserAction.MANAGE_REMINDERS__ENTER_CHAT_ID + + +class ManageRemindersEnterNameHandler(BaseUserMessageHandler): + def handle(self) -> Optional[PlainTextUserAction]: + self.command_data[consts.ManageRemindersData.REMINDER_NAME] = self.user_input + action = self.command_data[consts.ManageRemindersData.ACTION_TYPE] + + if action == ButtonValues.MANAGE_REMINDERS__ACTIONS__NEW: + reply(load("manage_reminders_handler__enter_text"), self.update) + return PlainTextUserAction.MANAGE_REMINDERS__ENTER_TEXT + elif action == ButtonValues.MANAGE_REMINDERS__ACTIONS__EDIT: + reminder_id = int( + self.command_data[consts.ManageRemindersData.CHOSEN_REMINDER_ID] + ) + DBClient().update_reminder(reminder_id, name=self.user_input) + reply( + load("manage_reminders_handler__reminder_name_was_changed"), self.update + ) + return None + return PlainTextUserAction.MANAGE_REMINDERS__ENTER_NAME + + +class ManageRemindersEnterTextHandler(BaseUserMessageHandler): + def handle(self) -> Optional[PlainTextUserAction]: + # keeps original formatting, e.g. hyperlinks + text = self.update.message.text_html.strip() + self.command_data[consts.ManageRemindersData.REMINDER_TEXT] = text + action = self.command_data[consts.ManageRemindersData.ACTION_TYPE] + + if action == ButtonValues.MANAGE_REMINDERS__ACTIONS__NEW: + reply_markup = telegram.InlineKeyboardMarkup(consts.WEEKDAY_BUTTONS) + reply( + load("manage_reminders_handler__choose_weekday"), + self.update, + reply_markup=reply_markup, + ) + return PlainTextUserAction.MANAGE_REMINDERS__CHOOSE_WEEKDAY + elif action == ButtonValues.MANAGE_REMINDERS__ACTIONS__EDIT: + reminder_id = int( + self.command_data[consts.ManageRemindersData.CHOSEN_REMINDER_ID] + ) + DBClient().update_reminder(reminder_id, text=text) + reply( + load("manage_reminders_handler__reminder_text_was_changed"), self.update + ) + return None + return PlainTextUserAction.MANAGE_REMINDERS__ENTER_TEXT + + +class ManageRemindersChooseWeekdayHandler(BaseUserMessageHandler): + def handle(self) -> Optional[PlainTextUserAction]: + if self.button is None: + reply(load("user_message_handler__press_button_please"), self.update) + return PlainTextUserAction.MANAGE_REMINDERS__CHOOSE_WEEKDAY + + weekday_num, weekday_name = self.button.value.split(":") + self.command_data[consts.ManageRemindersData.WEEKDAY_NUM] = int(weekday_num) + self.command_data[consts.ManageRemindersData.WEEKDAY_NAME] = weekday_name + reply( + load("manage_reminders_handler__enter_time", weekday_name=weekday_name), + self.update, + ) + return PlainTextUserAction.MANAGE_REMINDERS__ENTER_TIME + + +class ManageRemindersEnterTimeHandler(BaseUserMessageHandler): + def handle(self) -> Optional[PlainTextUserAction]: + try: + datetime.strptime(self.user_input or "", "%H:%M") + except ValueError: + reply(load("manage_reminders_handler__time_bad"), self.update) + return PlainTextUserAction.MANAGE_REMINDERS__ENTER_TIME + self.command_data[consts.ManageRemindersData.TIME] = self.user_input + action = self.command_data[consts.ManageRemindersData.ACTION_TYPE] + weekday_num = self.command_data[consts.ManageRemindersData.WEEKDAY_NUM] + time = self.command_data[consts.ManageRemindersData.TIME] + + if action == ButtonValues.MANAGE_REMINDERS__ACTIONS__EDIT: + reminder_id = int( + self.command_data[consts.ManageRemindersData.CHOSEN_REMINDER_ID] + ) + DBClient().update_reminder(reminder_id, weekday=weekday_num, time=time) + return None + + button_yes = telegram.InlineKeyboardButton( + load("manage_reminders_handler__poll_yes_btn"), + callback_data=consts.ButtonValues.MANAGE_REMINDERS__POLL__YES.value, + ) + button_no = telegram.InlineKeyboardButton( + load("manage_reminders_handler__poll_no_btn"), + callback_data=consts.ButtonValues.MANAGE_REMINDERS__POLL__NO.value, + ) + buttons = [button_yes, button_no] + reply( + load("manage_reminders_handler__poll_question"), + self.update, + reply_markup=telegram.InlineKeyboardMarkup([buttons]), + ) + return PlainTextUserAction.MANAGE_REMINDERS__SUCCESS + + +class ManageRemindersSuccessHandler(BaseUserMessageHandler): + def handle(self) -> Optional[PlainTextUserAction]: + text = self.command_data.get(consts.ManageRemindersData.REMINDER_TEXT) + group_chat_id = self.command_data.get(consts.ManageRemindersData.GROUP_CHAT_ID) + name = self.command_data.get(consts.ManageRemindersData.REMINDER_NAME) + weekday_num = self.command_data[consts.ManageRemindersData.WEEKDAY_NUM] + weekday_name = self.command_data[consts.ManageRemindersData.WEEKDAY_NAME] + time = self.command_data[consts.ManageRemindersData.TIME] + + if self.button == consts.ButtonValues.MANAGE_REMINDERS__TOGGLE_POLL__YES: + if text is None: + reminder_id = int( + self.command_data[consts.ManageRemindersData.CHOSEN_REMINDER_ID] + ) + DBClient().update_reminder(reminder_id, weekday=weekday_num, time=time) + else: + DBClient().add_reminder( + creator_chat_id=get_sender_id(self.update), + group_chat_id=group_chat_id, + name=name, + text=text, + weekday_num=weekday_num, + time=time, + send_poll=True, + ) + else: + DBClient().add_reminder( + creator_chat_id=get_sender_id(self.update), + group_chat_id=group_chat_id, + name=name, + text=text, + weekday_num=weekday_num, + time=time, + send_poll=False, + ) + weekday_name = self.command_data[consts.ManageRemindersData.WEEKDAY_NAME] + time = self.command_data[consts.ManageRemindersData.TIME] + reply( + load( + "manage_reminders_handler__success_time", + weekday_name=weekday_name, + time=time, + ), + self.update, + ) + return None diff --git a/src/tg/handlers/user_message_handler.py b/src/tg/handlers/user_message_handler.py index 374e899..d4dd006 100644 --- a/src/tg/handlers/user_message_handler.py +++ b/src/tg/handlers/user_message_handler.py @@ -1,149 +1,15 @@ -import calendar import logging import telegram -from datetime import datetime from ... import consts -from ...app_context import AppContext from ...consts import ( ButtonValues, - GetTasksReportData, PlainTextUserAction, - BoardListAlias, ) -from ...db.db_client import DBClient -from ...db.db_objects import Reminder -from ...focalboard.focalboard_client import FocalboardClient -from ...strings import load -from ...tg.handlers import get_tasks_report_handler -from ...trello.trello_client import TrelloClient -from .utils import get_chat_id, get_chat_name, get_sender_id, get_sender_username, reply -logger = logging.getLogger(__name__) - -SECTIONS = [ - ("Идеи для статей", BoardListAlias.TOPIC_SUGGESTION_1), - ("Готовая тема", BoardListAlias.TOPIC_READY_2), - ("Уже пишу", BoardListAlias.DRAFT_N_PROGRESS_3, True), - ("Передано на редактуру", BoardListAlias.DRAFT_COMPLETED_4), - ("На редактуре", BoardListAlias.PENDING_EDITOR_5), - ("Проверка качества SEO", BoardListAlias.PENDING_SEO_EDITOR_6), - ("Отредактировано", BoardListAlias.APPROVED_EDITOR_7), - ("Отобрано на финальную проверку", BoardListAlias.PENDING_CHIEF_EDITOR_8), - ("Отобрано для публикации", BoardListAlias.PUBLISH_BACKLOG_9), - ("Готово для вёрстки", BoardListAlias.PUBLISH_IN_PROGRESS_10), -] - - -def _generate_rubric_summary(update, rubric_name: str) -> None: - try: - app_context = AppContext() - fc = app_context.focalboard_client - labels = fc._get_labels() - rubric_label = next( - ( - lbl - for lbl in labels - if lbl.name.strip().lower() == rubric_name.strip().lower() - ), - None, - ) - if not rubric_label: - logger.warning( - f"_generate_rubric_summary: Рубрика не найдена: {rubric_name}" - ) - reply( - load( - "rubric_not_found", - rubric_name=rubric_name, - ), - ) - return - - # Get all lists - try: - lists = fc.get_lists(board_id=fc.board_id, sorted=False) - except Exception as e: - logger.error( - f"_generate_rubric_summary: не удалось получить lists: {e}", - exc_info=True, - ) - reply(load("failed_get_board_lists"), update) - return - - message_parts = [ - load( - "rubric_report_job__intro", - rubric=rubric_name, - ) - ] - - had_errors = False - - for column_name, alias, *meta_flag in SECTIONS: - need_meta = bool(meta_flag and meta_flag[0]) - heading = load(alias.value) - # Find column - target_list = next( - (lst for lst in lists if lst.name.strip().startswith(column_name)), None - ) - - if not target_list: - message_parts.append(f"{heading} (0)") - message_parts.append("") - continue - try: - cards = fc.get_cards(list_ids=[target_list.id], board_id=fc.board_id) - except Exception: - had_errors = True - message_parts.append(f"{heading} (0)") - message_parts.append("") - continue +from . import flow_handlers - filtered = [ - card - for card in cards - if any(lbl.id == rubric_label.id for lbl in card.labels) - ] - - filtered.sort(key=lambda c: c.due or datetime.max) - - count = len(filtered) - message_parts.append(f"{heading} ({count})") - - if not filtered: - message_parts.append("(пусто)") - else: - for card in filtered: - link = f'{card.name}' - if need_meta: - due_str = ( - card.due.strftime("%d.%m.%Y") if card.due else "без срока" - ) - try: - fields = fc.get_custom_fields(card.id) - authors = ( - ", ".join(fields.authors) - if fields.authors - else "неизвестно" - ) - except Exception: - authors = "неизвестно" - message_parts.append(f"- {link}") - message_parts.append(f" • Дедлайн: {due_str}") - message_parts.append(f" • Автор: {authors}") - else: - message_parts.append(f"- {link}") - - message_parts.append("") - - if had_errors: - message_parts.append(load("partial_data_error ")) - - reply("\n".join(message_parts), update, parse_mode="HTML") - - except Exception: - reply(load("failed_try_later"), update) +logger = logging.getLogger(__name__) def handle_callback_query( @@ -156,83 +22,30 @@ def handle_callback_query( handle_user_message(update, tg_context, ButtonValues(update.callback_query.data)) -# helper to avoid code duplication -def _show_reminder_edit_options( - reminder: Reminder, update: telegram.Update, command_data: dict -): - """ - Shows the menu with options to edit a specific reminder. - """ - # keyboard for edit - button_list = [ - [ - telegram.InlineKeyboardButton( - load("manage_reminders_handler__edit_text_btn"), - callback_data=ButtonValues.MANAGE_REMINDERS__EDIT__TEXT.value, - ) - ], - [ - telegram.InlineKeyboardButton( - load("manage_reminders_handler__edit_datetime_btn"), - callback_data=ButtonValues.MANAGE_REMINDERS__EDIT__DATETIME.value, - ) - ], - [ - telegram.InlineKeyboardButton( - load("manage_reminders_handler__edit_title_btn"), - callback_data=ButtonValues.MANAGE_REMINDERS__EDIT__TITLE.value, - ) - ], - [ - telegram.InlineKeyboardButton( - load("manage_reminders_handler__edit_chat_btn"), - callback_data=ButtonValues.MANAGE_REMINDERS__EDIT__CHAT.value, - ) - ], - [ - ( - telegram.InlineKeyboardButton( - load("manage_reminders_handler__edit_suspend_btn"), - callback_data=ButtonValues.MANAGE_REMINDERS__EDIT__SUSPEND.value, - ) - if reminder.is_active - else telegram.InlineKeyboardButton( - load("manage_reminders_handler__edit_resume_btn"), - callback_data=ButtonValues.MANAGE_REMINDERS__EDIT__RESUME.value, - ) - ) - ], - [ - ( - telegram.InlineKeyboardButton( - load("manage_reminders_handler__edit_poll_active_btn"), - callback_data=ButtonValues.MANAGE_REMINDERS__DISABLE_POLL.value, - ) - if reminder.send_poll - else telegram.InlineKeyboardButton( - load("manage_reminders_handler__edit_poll_inactive_btn"), - callback_data=ButtonValues.MANAGE_REMINDERS__ENABLE_POLL.value, - ) - ) - ], - ] - reply_markup = telegram.InlineKeyboardMarkup(button_list) - weekday_str = ( - calendar.TextCalendar().formatweekday(int(reminder.weekday), 15).strip() - ) - reply( - load( - "manage_reminders_handler__weekly_reminder", - weekday=weekday_str, - time=reminder.time, - text=reminder.text, - ), - update, - reply_markup=reply_markup, - ) - set_next_action( - command_data, PlainTextUserAction.MANAGE_REMINDERS__CHOOSE_EDIT_ACTION - ) +ACTION_HANDLERS = { + PlainTextUserAction.GET_RUBRICS__CHOOSE_RUBRIC: flow_handlers.GetRubricsChooseRubricHandler, + PlainTextUserAction.GET_TASKS_REPORT__ENTER_BOARD_URL: flow_handlers.GetTasksReportEnterBoardUrlHandler, + PlainTextUserAction.GET_TASKS_REPORT__ENTER_BOARD_NUMBER: flow_handlers.GetTasksReportEnterBoardNumberHandler, + PlainTextUserAction.GET_TASKS_REPORT__ENTER_LIST_NUMBER: flow_handlers.GetTasksReportEnterListNumberHandler, + PlainTextUserAction.GET_TASKS_REPORT__ENTER_INTRO: flow_handlers.GetTasksReportEnterIntroHandler, + PlainTextUserAction.GET_TASKS_REPORT__CHOOSE_IF_FILL_LABELS: flow_handlers.GetTasksReportChooseIfFillLabelsHandler, + PlainTextUserAction.MANAGE_REMINDERS__CHOOSE_ACTION: flow_handlers.ManageRemindersChooseActionHandler, + PlainTextUserAction.MANAGE_REMINDERS__ENTER_REMINDER_NUMBER: flow_handlers.ManageRemindersEnterReminderNumberHandler, + PlainTextUserAction.MANAGE_REMINDERS__CHOOSE_EDIT_ACTION: flow_handlers.ManageRemindersChooseEditActionHandler, + PlainTextUserAction.MANAGE_REMINDERS__ENABLE_POLL: flow_handlers.ManageRemindersEnablePollHandler, + PlainTextUserAction.MANAGE_REMINDERS__DISABLE_POLL: flow_handlers.ManageRemindersDisablePollHandler, + PlainTextUserAction.MANAGE_REMINDERS__DELETE_REQUEST: flow_handlers.ManageRemindersDeleteRequestHandler, + PlainTextUserAction.MANAGE_REMINDERS__ENTER_CHAT_ID: flow_handlers.ManageRemindersEnterChatIdHandler, + PlainTextUserAction.MANAGE_REMINDERS__ENTER_NAME: flow_handlers.ManageRemindersEnterNameHandler, + PlainTextUserAction.MANAGE_REMINDERS__ENTER_TEXT: flow_handlers.ManageRemindersEnterTextHandler, + PlainTextUserAction.MANAGE_REMINDERS__CHOOSE_WEEKDAY: flow_handlers.ManageRemindersChooseWeekdayHandler, + PlainTextUserAction.MANAGE_REMINDERS__ENTER_TIME: flow_handlers.ManageRemindersEnterTimeHandler, + PlainTextUserAction.MANAGE_REMINDERS__SUCCESS: flow_handlers.ManageRemindersSuccessHandler, +} + + +def set_next_action(command_data: dict, next_action: PlainTextUserAction): + command_data[consts.NEXT_ACTION] = next_action.value if next_action else next_action def handle_user_message( @@ -247,50 +60,7 @@ def handle_user_message( # to understand what kind of data currently expected from user if not command_id: # No active command - forward to n8n if message exists - if update.message and update.message.text: - try: - app_context = AppContext() - user_id = get_sender_id(update) - username = ( - get_sender_username(update) - if update.message.from_user.username - else None - ) - - # Auto-create/update User record - team_member_id = None - if username: - # Normalize username (remove @ if present) - normalized_username = username.lstrip("@") - # Find TeamMember with matching telegram username - team_members = app_context.db_client.get_all_members() - matching_member = next( - ( - m - for m in team_members - if m.telegram - and m.telegram.strip().lstrip("@").lower() - == normalized_username.lower() - ), - None, - ) - if matching_member: - team_member_id = matching_member.id - - # Upsert User record - app_context.db_client.upsert_user_from_telegram( - telegram_user_id=user_id, - telegram_username=username, - team_member_id=team_member_id, - ) - - query = update.message.text.strip() - app_context.n8n_client.send_webhook(user_id, query) - except Exception as e: - logger.error( - f"Failed to send message to n8n: {e}", - exc_info=True, - ) + flow_handlers.handle_stateless_message(update, tg_context) return command_data = tg_context.chat_data.get(command_id, {}) tg_context.chat_data[command_id] = command_data @@ -298,616 +68,14 @@ def handle_user_message( if not next_action: # last action for a command was successfully executed and nothing left to do return - next_action = PlainTextUserAction(next_action) + next_action_enum = PlainTextUserAction(next_action) user_input = update.message.text.strip() if update.message is not None else None - if next_action == PlainTextUserAction.GET_RUBRICS__CHOOSE_RUBRIC: - try: - idx = int(user_input) - 1 - rubrics = command_data.get( - GetTasksReportData.LISTS - ) or tg_context.chat_data.get("available_rubrics", []) - if not (0 <= idx < len(rubrics)): - raise ValueError - except Exception: - reply( - load( - "invalid_rubric_number", - max=len(rubrics), - ), - update, - ) - return - - selected = rubrics[idx] - _generate_rubric_summary(update, selected) - - tg_context.chat_data.pop(consts.LAST_ACTIONABLE_COMMAND, None) - tg_context.chat_data.pop(command_id, None) + if next_action_enum in ACTION_HANDLERS: + handler_cls = ACTION_HANDLERS[next_action_enum] + handler = handler_cls(update, tg_context, command_data, user_input, button) + next_state = handler.handle() + set_next_action(command_data, next_state) return - # Below comes a long switch of possible next_action. - # Following conventions are used: - # - If you got an exception or `user_input` is invalid, call `reply('...', update)` explaining - # what's wrong and what user can do. We'll ask user for the same data until we get it. - # - If data from user is good, after processing it call `set_next_action(command_data, ...)` - # You probably also want to call `reply` to guide user to the next piece of data. - # - If it's the last piece of info expected from user, you should call - # `set_next_action(command_data, None)` so that we won't talk to user anymore, - # until they start a new command. - if next_action == PlainTextUserAction.GET_TASKS_REPORT__ENTER_BOARD_URL: - trello_client = TrelloClient() - try: - board = trello_client.get_board_by_url(user_input) - trello_lists = trello_client.get_lists(board.id) - except Exception: - reply(load("get_tasks_report_handler__board_not_found"), update) - return - command_data[consts.GetTasksReportData.BOARD_ID] = board.id - command_data[consts.GetTasksReportData.LISTS] = [ - lst.to_dict() for lst in trello_lists - ] - - trello_lists_formatted = "\n".join( - [f"{i + 1}) {lst.name}" for i, lst in enumerate(trello_lists)] - ) - reply( - load( - "get_tasks_report_handler__choose_trello_list", - lists=trello_lists_formatted, - ), - update, - ) - set_next_action( - command_data, PlainTextUserAction.GET_TASKS_REPORT__ENTER_LIST_NUMBER - ) - return - elif next_action == PlainTextUserAction.GET_TASKS_REPORT__ENTER_BOARD_NUMBER: - trello_client = TrelloClient() - focalboard_client = FocalboardClient() - try: - board_list = tg_context.chat_data[consts.GetTasksReportData.LISTS] - use_focalboard = tg_context.chat_data[ - consts.GetTasksReportData.USE_FOCALBOARD - ] - list_idx = int(user_input) - 1 - assert 0 <= list_idx < len(board_list) - board_id = board_list[list_idx]["id"] - if use_focalboard: - trello_lists = focalboard_client.get_lists(board_id, sorted=True) - trello_lists = trello_lists[::-1] - else: - trello_lists = trello_client.get_lists(board_id) - trello_lists = trello_lists[::-1] - except Exception as e: - logger.warning("Failed to parse board number", exc_info=e) - reply( - load( - "get_tasks_report_handler__enter_the_number", - max_val=len(board_list), - ), - update, - ) - return - - command_data[consts.GetTasksReportData.BOARD_ID] = board_id - command_data[consts.GetTasksReportData.USE_FOCALBOARD] = use_focalboard - command_data[consts.GetTasksReportData.LISTS] = [ - lst.to_dict() for lst in trello_lists - ] - - trello_lists_formatted = "\n".join( - [ - f"{len(trello_lists) - i}) {lst.name}" - for i, lst in enumerate(trello_lists) - ] - ) - reply( - load( - "get_tasks_report_handler__choose_trello_list", - lists=trello_lists_formatted, - ), - update, - ) - set_next_action( - command_data, PlainTextUserAction.GET_TASKS_REPORT__ENTER_LIST_NUMBER - ) - return - elif next_action == PlainTextUserAction.GET_TASKS_REPORT__ENTER_LIST_NUMBER: - try: - trello_lists = command_data.get(consts.GetTasksReportData.LISTS, []) - list_idx = -int(user_input) - assert 0 > list_idx >= -len(trello_lists) - list_id = trello_lists[list_idx]["id"] - except Exception as e: - logger.warning("Failed to parse list number", exc_info=e) - reply( - load( - "get_tasks_report_handler__enter_the_number", - max_val=len(trello_lists), - ), - update, - ) - return - command_data[consts.GetTasksReportData.LIST_ID] = list_id - - reply_markup = telegram.InlineKeyboardMarkup( - [ - [ - telegram.InlineKeyboardButton( - load("get_tasks_report_handler__no_text_btn"), - callback_data=ButtonValues.GET_TASKS_REPORT__NO_INTRO.value, - ) - ] - ] - ) - if not tg_context.chat_data.get("advanced"): - add_labels = button == ButtonValues.GET_TASKS_REPORT__LABELS__NO - command_data[consts.GetTasksReportData.INTRO_TEXT] = None - handle_task_report(command_data, add_labels, update) - return - - reply( - load("get_tasks_report_handler__enter_intro"), - update, - reply_markup=reply_markup, - ) - set_next_action(command_data, PlainTextUserAction.GET_TASKS_REPORT__ENTER_INTRO) - return - elif next_action == PlainTextUserAction.GET_TASKS_REPORT__ENTER_INTRO: - if button is not None and button == ButtonValues.GET_TASKS_REPORT__NO_INTRO: - command_data[consts.GetTasksReportData.INTRO_TEXT] = None - else: - command_data[consts.GetTasksReportData.INTRO_TEXT] = user_input - - button_list = [ - [ - telegram.InlineKeyboardButton( - load("common__yes"), - callback_data=ButtonValues.GET_TASKS_REPORT__LABELS__YES.value, - ), - telegram.InlineKeyboardButton( - load("common__no"), - callback_data=ButtonValues.GET_TASKS_REPORT__LABELS__NO.value, - ), - ] - ] - reply_markup = telegram.InlineKeyboardMarkup(button_list) - reply( - load("get_tasks_report_handler__choose_if_fill_labels"), - update, - reply_markup=reply_markup, - ) - set_next_action( - command_data, PlainTextUserAction.GET_TASKS_REPORT__CHOOSE_IF_FILL_LABELS - ) - return - elif next_action == PlainTextUserAction.GET_TASKS_REPORT__CHOOSE_IF_FILL_LABELS: - if button is None: - reply(load("user_message_handler__press_button_please"), update) - return - add_labels = button == ButtonValues.GET_TASKS_REPORT__LABELS__YES - handle_task_report(command_data, add_labels, update) - elif next_action == PlainTextUserAction.MANAGE_REMINDERS__CHOOSE_ACTION: - # If user sends a number, interpret it as a shortcut to edit that reminder - if user_input and user_input.isdigit(): - reminder_ids = command_data.get( - consts.ManageRemindersData.EXISTING_REMINDERS, [] - ) - try: - assert 0 < int(user_input) <= len(reminder_ids) - reminder_id, _, _ = reminder_ids[int(user_input) - 1] - except Exception: - reply(load("manage_reminders_handler__reminder_number_bad"), update) - return - - command_data[consts.ManageRemindersData.ACTION_TYPE] = ( - ButtonValues.MANAGE_REMINDERS__ACTIONS__EDIT - ) - command_data[consts.ManageRemindersData.CHOSEN_REMINDER_ID] = reminder_id - reminder = DBClient().get_reminder_by_id(reminder_id) - _show_reminder_edit_options(reminder, update, command_data) - return - - if button is None: - reply(load("user_message_handler__press_button_please"), update) - return - - if button == ButtonValues.MANAGE_REMINDERS__ACTIONS__NEW: - reply(load("manager_reminders_handler__enter_chat_id"), update) - set_next_action( - command_data, PlainTextUserAction.MANAGE_REMINDERS__ENTER_CHAT_ID - ) - elif button == ButtonValues.MANAGE_REMINDERS__ACTIONS__DELETE: - reply( - load("manage_reminders_handler__enter_reminder_number_to_delete"), - update, - ) - set_next_action( - command_data, - PlainTextUserAction.MANAGE_REMINDERS__ENTER_REMINDER_NUMBER, - ) - elif button == ButtonValues.MANAGE_REMINDERS__ACTIONS__EDIT: - reply( - load("manage_reminders_handler__enter_reminder_number_to_edit"), update - ) - set_next_action( - command_data, - PlainTextUserAction.MANAGE_REMINDERS__ENTER_REMINDER_NUMBER, - ) - command_data[consts.ManageRemindersData.ACTION_TYPE] = button - return - elif next_action == PlainTextUserAction.MANAGE_REMINDERS__ENTER_REMINDER_NUMBER: - reminder_ids = command_data[consts.ManageRemindersData.EXISTING_REMINDERS] - try: - assert 0 < int(user_input) <= len(reminder_ids) - reminder_id, chat_title, reminder_name = reminder_ids[int(user_input) - 1] - except Exception: - reply(load("manage_reminders_handler__reminder_number_bad"), update) - return - command_data[consts.ManageRemindersData.CHOSEN_REMINDER_ID] = reminder_id - - action = command_data[consts.ManageRemindersData.ACTION_TYPE] - if action == ButtonValues.MANAGE_REMINDERS__ACTIONS__DELETE: - # keyboard for delete - button_list = [ - [ - telegram.InlineKeyboardButton( - load("common__yes"), - callback_data=ButtonValues.MANAGE_REMINDERS__DELETE__YES.value, - ), - telegram.InlineKeyboardButton( - load("common__no"), - callback_data=ButtonValues.MANAGE_REMINDERS__DELETE__NO.value, - ), - ] - ] - reply_markup = telegram.InlineKeyboardMarkup(button_list) - reply( - load( - "manage_reminders_handler__confirm_delete", - chat=chat_title, - reminder=reminder_name, - ), - update, - reply_markup=reply_markup, - ) - set_next_action( - command_data, PlainTextUserAction.MANAGE_REMINDERS__DELETE_REQUEST - ) - elif action == ButtonValues.MANAGE_REMINDERS__ACTIONS__EDIT: - reminder = DBClient().get_reminder_by_id(reminder_id) - _show_reminder_edit_options(reminder, update, command_data) - else: - logger.error(f'Bad reminder action "{action}"') - elif next_action == PlainTextUserAction.MANAGE_REMINDERS__CHOOSE_EDIT_ACTION: - if button is None: - reply(load("manage_reminders_handler__press_button_please"), update) - return - db_client = DBClient() - reminder_id = int(command_data[consts.ManageRemindersData.CHOSEN_REMINDER_ID]) - reminder = db_client.get_reminder_by_id(reminder_id) - if button == ButtonValues.MANAGE_REMINDERS__EDIT__TEXT: - reply(load("manage_reminders_handler__enter_text"), update) - set_next_action( - command_data, PlainTextUserAction.MANAGE_REMINDERS__ENTER_TEXT - ) - return - elif button == ButtonValues.MANAGE_REMINDERS__EDIT__TITLE: - reply( - load( - "manage_reminders_handler__enter_new_name", old_name=reminder.name - ), - update, - ) - set_next_action( - command_data, PlainTextUserAction.MANAGE_REMINDERS__ENTER_NAME - ) - return - elif button == ButtonValues.MANAGE_REMINDERS__EDIT__CHAT: - reply( - load("manage_reminders_handler__enter_chat_id", name=reminder.name), - update, - ) - set_next_action( - command_data, PlainTextUserAction.MANAGE_REMINDERS__ENTER_CHAT_ID - ) - return - elif button == ButtonValues.MANAGE_REMINDERS__EDIT__DATETIME: - reply_markup = telegram.InlineKeyboardMarkup(consts.WEEKDAY_BUTTONS) - reply( - load("manage_reminders_handler__choose_weekday"), - update, - reply_markup=reply_markup, - ) - set_next_action( - command_data, PlainTextUserAction.MANAGE_REMINDERS__CHOOSE_WEEKDAY - ) - return - elif button == ButtonValues.MANAGE_REMINDERS__EDIT__SUSPEND: - db_client.update_reminder(reminder_id, is_active=False) - reply( - load( - "manage_reminders_handler__reminder_was_suspended", - name=reminder.name, - ), - update, - ) - set_next_action(command_data, None) - return - elif button == ButtonValues.MANAGE_REMINDERS__EDIT__RESUME: - db_client.update_reminder(reminder_id, is_active=True) - reply( - load( - "manage_reminders_handler__reminder_was_resumed", - next_reminder_datetime=reminder.next_reminder_datetime, - ), - update, - ) - set_next_action(command_data, None) - elif button == ButtonValues.MANAGE_REMINDERS__DISABLE_POLL: - button_no = telegram.InlineKeyboardButton( - load("manage_reminders_handler__disable_poll_btn"), - callback_data=consts.ButtonValues.MANAGE_REMINDERS__DISABLE_POLL__YES.value, - ) - keyboard = [[button_no]] - reply( - load("manage_reminders_handler__disable_poll_question"), - update, - reply_markup=telegram.InlineKeyboardMarkup(keyboard), - ) - set_next_action( - command_data, PlainTextUserAction.MANAGE_REMINDERS__DISABLE_POLL - ) - return - elif button == ButtonValues.MANAGE_REMINDERS__ENABLE_POLL: - button_yes = telegram.InlineKeyboardButton( - load("manage_reminders_handler__enable_poll_btn"), - callback_data=consts.ButtonValues.MANAGE_REMINDERS__ENABLE_POLL__YES.value, - ) - keyboard = [[button_yes]] - reply( - load("manage_reminders_handler__enable_poll_question"), - update, - reply_markup=telegram.InlineKeyboardMarkup(keyboard), - ) - set_next_action( - command_data, PlainTextUserAction.MANAGE_REMINDERS__ENABLE_POLL - ) - return - elif next_action == PlainTextUserAction.MANAGE_REMINDERS__ENABLE_POLL: - if button == consts.ButtonValues.MANAGE_REMINDERS__ENABLE_POLL__YES: - db_client = DBClient() - reminder_id = int( - command_data[consts.ManageRemindersData.CHOSEN_REMINDER_ID] - ) - reminder = db_client.get_reminder_by_id(reminder_id) - db_client.update_reminder(reminder_id, send_poll=True) - reply( - load("manage_reminders_handler__poll_was_enabled"), - update, - ) - return - elif next_action == PlainTextUserAction.MANAGE_REMINDERS__DISABLE_POLL: - if button == consts.ButtonValues.MANAGE_REMINDERS__DISABLE_POLL__YES: - db_client = DBClient() - reminder_id = int( - command_data[consts.ManageRemindersData.CHOSEN_REMINDER_ID] - ) - reminder = db_client.get_reminder_by_id(reminder_id) - db_client.update_reminder(reminder_id, send_poll=False) - reply( - load("manage_reminders_handler__poll_was_disabled"), - update, - ) - return - elif next_action == PlainTextUserAction.MANAGE_REMINDERS__DELETE_REQUEST: - if button is None: - reply(load("user_message_handler__press_button_please"), update) - return - if button == ButtonValues.MANAGE_REMINDERS__DELETE__YES: - DBClient().delete_reminder( - command_data.get(consts.ManageRemindersData.CHOSEN_REMINDER_ID) - ) - reply(load("manage_reminders_handler__reminder_was_deleted"), update) - set_next_action(command_data, None) - elif button == ButtonValues.MANAGE_REMINDERS__DELETE__NO: - reply(load("manage_reminders_handler__reminder_was_not_deleted"), update) - set_next_action(command_data, None) - return - elif next_action == PlainTextUserAction.MANAGE_REMINDERS__ENTER_CHAT_ID: - try: - chat_id = int(user_input) - chat_title = DBClient().get_chat_name(chat_id) - except Exception as e: - reply(load("manage_reminders_handler__bad_chat_id"), update) - logger.info("Failed to parse chat id", exc_info=e) - return - command_data[consts.ManageRemindersData.GROUP_CHAT_ID] = chat_id - action = command_data[consts.ManageRemindersData.ACTION_TYPE] - - if action == ButtonValues.MANAGE_REMINDERS__ACTIONS__NEW: - reply( - load("manage_reminders_handler__enter_name", chat_title=chat_title), - update, - ) - set_next_action( - command_data, PlainTextUserAction.MANAGE_REMINDERS__ENTER_NAME - ) - elif action == ButtonValues.MANAGE_REMINDERS__ACTIONS__EDIT: - reminder_id = int( - command_data[consts.ManageRemindersData.CHOSEN_REMINDER_ID] - ) - reminder = DBClient().get_reminder_by_id(reminder_id) - DBClient().update_reminder(reminder_id, group_chat_id=chat_id) - reply( - load( - "manage_reminders_handler__reminder_set", - name=reminder.name, - chat_title=chat_title, - ), - update, - ) - set_next_action(command_data, None) - return - elif next_action == PlainTextUserAction.MANAGE_REMINDERS__ENTER_NAME: - command_data[consts.ManageRemindersData.REMINDER_NAME] = user_input - action = command_data[consts.ManageRemindersData.ACTION_TYPE] - - if action == ButtonValues.MANAGE_REMINDERS__ACTIONS__NEW: - reply(load("manage_reminders_handler__enter_text"), update) - set_next_action( - command_data, PlainTextUserAction.MANAGE_REMINDERS__ENTER_TEXT - ) - elif action == ButtonValues.MANAGE_REMINDERS__ACTIONS__EDIT: - reminder_id = int( - command_data[consts.ManageRemindersData.CHOSEN_REMINDER_ID] - ) - DBClient().update_reminder(reminder_id, name=user_input) - reply(load("manage_reminders_handler__reminder_name_was_changed"), update) - set_next_action(command_data, None) - return - elif next_action == PlainTextUserAction.MANAGE_REMINDERS__ENTER_TEXT: - # keeps original formatting, e.g. hyperlinks - text = update.message.text_html.strip() - command_data[consts.ManageRemindersData.REMINDER_TEXT] = text - action = command_data[consts.ManageRemindersData.ACTION_TYPE] - - if action == ButtonValues.MANAGE_REMINDERS__ACTIONS__NEW: - reply_markup = telegram.InlineKeyboardMarkup(consts.WEEKDAY_BUTTONS) - set_next_action( - command_data, PlainTextUserAction.MANAGE_REMINDERS__CHOOSE_WEEKDAY - ) - reply( - load("manage_reminders_handler__choose_weekday"), - update, - reply_markup=reply_markup, - ) - elif action == ButtonValues.MANAGE_REMINDERS__ACTIONS__EDIT: - reminder_id = int( - command_data[consts.ManageRemindersData.CHOSEN_REMINDER_ID] - ) - DBClient().update_reminder(reminder_id, text=text) - reply(load("manage_reminders_handler__reminder_text_was_changed"), update) - set_next_action(command_data, None) - return - elif next_action == PlainTextUserAction.MANAGE_REMINDERS__CHOOSE_WEEKDAY: - if button is None: - reply(load("user_message_handler__press_button_please"), update) - return - weekday_num, weekday_name = button.value.split(":") - command_data[consts.ManageRemindersData.WEEKDAY_NUM] = int(weekday_num) - command_data[consts.ManageRemindersData.WEEKDAY_NAME] = weekday_name - set_next_action(command_data, PlainTextUserAction.MANAGE_REMINDERS__ENTER_TIME) - reply( - load("manage_reminders_handler__enter_time", weekday_name=weekday_name), - update, - ) - return - elif next_action == PlainTextUserAction.MANAGE_REMINDERS__ENTER_TIME: - try: - datetime.strptime(user_input or "", "%H:%M") - except ValueError: - reply(load("manage_reminders_handler__time_bad"), update) - return - command_data[consts.ManageRemindersData.TIME] = user_input - action = command_data[consts.ManageRemindersData.ACTION_TYPE] - weekday_num = command_data[consts.ManageRemindersData.WEEKDAY_NUM] - time = command_data[consts.ManageRemindersData.TIME] - - if action == ButtonValues.MANAGE_REMINDERS__ACTIONS__EDIT: - reminder_id = int( - command_data[consts.ManageRemindersData.CHOSEN_REMINDER_ID] - ) - DBClient().update_reminder(reminder_id, weekday=weekday_num, time=time) - set_next_action(command_data, None) - button_yes = telegram.InlineKeyboardButton( - load("manage_reminders_handler__poll_yes_btn"), - callback_data=consts.ButtonValues.MANAGE_REMINDERS__POLL__YES.value, - ) - button_no = telegram.InlineKeyboardButton( - load("manage_reminders_handler__poll_no_btn"), - callback_data=consts.ButtonValues.MANAGE_REMINDERS__POLL__NO.value, - ) - buttons = [button_yes, button_no] - reply( - load("manage_reminders_handler__poll_question"), - update, - reply_markup=telegram.InlineKeyboardMarkup([buttons]), - ) - set_next_action(command_data, PlainTextUserAction.MANAGE_REMINDERS__SUCCESS) - return - elif next_action == PlainTextUserAction.MANAGE_REMINDERS__SUCCESS: - text = command_data.get(consts.ManageRemindersData.REMINDER_TEXT) - group_chat_id = command_data.get(consts.ManageRemindersData.GROUP_CHAT_ID) - name = command_data.get(consts.ManageRemindersData.REMINDER_NAME) - weekday_num = command_data[consts.ManageRemindersData.WEEKDAY_NUM] - weekday_name = command_data[consts.ManageRemindersData.WEEKDAY_NAME] - time = command_data[consts.ManageRemindersData.TIME] - if button == consts.ButtonValues.MANAGE_REMINDERS__TOGGLE_POLL__YES: - if text is None: - reminder_id = int( - command_data[consts.ManageRemindersData.CHOSEN_REMINDER_ID] - ) - DBClient().update_reminder(reminder_id, weekday=weekday_num, time=time) - else: - DBClient().add_reminder( - creator_chat_id=get_sender_id(update), - group_chat_id=group_chat_id, - name=name, - text=text, - weekday_num=weekday_num, - time=time, - send_poll=True, - ) - else: - DBClient().add_reminder( - creator_chat_id=get_sender_id(update), - group_chat_id=group_chat_id, - name=name, - text=text, - weekday_num=weekday_num, - time=time, - send_poll=False, - ) - weekday_name = command_data[consts.ManageRemindersData.WEEKDAY_NAME] - time = command_data[consts.ManageRemindersData.TIME] - reply( - load( - "manage_reminders_handler__success_time", - weekday_name=weekday_name, - time=time, - ), - update, - ) - set_next_action(command_data, None) - return - else: - logger.error(f"Unknown user action: {next_action}") - - -def set_next_action(command_data: dict, next_action: PlainTextUserAction): - command_data[consts.NEXT_ACTION] = next_action.value if next_action else next_action - - -def handle_new_members( - update: telegram.Update, tg_context: telegram.ext.CallbackContext -): - # writes chat_id and chat name to db when anybody (including the bot) is added to a new chat - # very heuristic solution - DBClient().set_chat_name(get_chat_id(update), get_chat_name(update)) - - -def handle_task_report(command_data, add_labels, update): - board_id = command_data[consts.GetTasksReportData.BOARD_ID] - list_id = command_data[consts.GetTasksReportData.LIST_ID] - introduction = command_data[consts.GetTasksReportData.INTRO_TEXT] - use_focalboard = command_data[consts.GetTasksReportData.USE_FOCALBOARD] - messages = get_tasks_report_handler.generate_report_messages( - board_id, list_id, introduction, add_labels, use_focalboard=use_focalboard - ) - for message in messages: - reply(message, update) - # finished with last action for /trello_client_get_lists - set_next_action(command_data, None) - return + logger.error(f"Unknown user action: {next_action}")