diff --git a/bot/handlers/birthdays.py b/bot/handlers/birthdays.py index 6ba1785..89b0dad 100644 --- a/bot/handlers/birthdays.py +++ b/bot/handlers/birthdays.py @@ -9,7 +9,7 @@ from ..db.repo_users import UsersRepo from ..db.repo_groups import GroupsRepo from ..db.repo_friends import FriendsRepo -from ..keyboards import main_menu_kb +from ..keyboards import birthdays_wishlist_kb from ..i18n import t def _log_id() -> str: @@ -51,16 +51,6 @@ def _when_str(update: Update, context: ContextTypes.DEFAULT_TYPE, days: int) -> return t("when_unknown", update=update, context=context) return t("when_in_days", update=update, context=context, n=days) -def _wishlist_menu_kb(*, update=None, context=None): - from telegram import ReplyKeyboardMarkup - from ..i18n import t - rows = [ - [t("btn_wishlist_my", update=update, context=context), t("btn_wishlist_edit", update=update, context=context)], - [t("btn_wishlist_view", update=update, context=context)], - [t("btn_back_main", update=update, context=context)], - ] - return ReplyKeyboardMarkup(rows, resize_keyboard=True, one_time_keyboard=False) - class BirthdaysHandler: def __init__(self, users: UsersRepo, friends: FriendsRepo, groups: GroupsRepo): self.users = users @@ -136,7 +126,7 @@ async def menu_entry(self, update: Update, context: ContextTypes.DEFAULT_TYPE): if not merged: await update.message.reply_text( t("birthdays_empty", update=update, context=context), - reply_markup=_wishlist_menu_kb(update=update, context=context), + reply_markup=birthdays_wishlist_kb(update=update, context=context), ) return @@ -166,4 +156,4 @@ async def menu_entry(self, update: Update, context: ContextTypes.DEFAULT_TYPE): lines.append(f"{icon} {name} — {bd} ({when}){badge_str}{groups_note}") - await update.message.reply_text("\n".join(lines), reply_markup=_wishlist_menu_kb(update=update, context=context)) + await update.message.reply_text("\n".join(lines), reply_markup=birthdays_wishlist_kb(update=update, context=context)) diff --git a/bot/handlers/wishlist.py b/bot/handlers/wishlist.py index a59ac86..492301b 100644 --- a/bot/handlers/wishlist.py +++ b/bot/handlers/wishlist.py @@ -12,6 +12,7 @@ from ..db.repo_wishlist import WishlistRepo from ..db.repo_users import UsersRepo from ..i18n import t, btn_regex +from ..keyboards import birthdays_wishlist_kb log = logging.getLogger("wishlist") @@ -28,18 +29,10 @@ def _kb(rows): # small helper return ReplyKeyboardMarkup(rows, resize_keyboard=True, one_time_keyboard=True) -def wishlist_menu_kb(*, update=None, context=None): - return _kb([ - [t("btn_wishlist_my", update=update, context=context), t("btn_wishlist_edit", update=update, context=context)], - [t("btn_wishlist_view", update=update, context=context)], - [t("btn_back", update=update, context=context)], - ]) - - def wishlist_edit_kb(*, update=None, context=None): return _kb([ [t("btn_wishlist_add", update=update, context=context), t("btn_wishlist_del", update=update, context=context)], - [t("btn_back", update=update, context=context)], + [t("btn_cancel", update=update, context=context)], ]) @@ -47,10 +40,6 @@ def cancel_kb(*, update=None, context=None): return _kb([[t("btn_cancel", update=update, context=context)]]) -def back_cancel_kb(*, update=None, context=None): - return _kb([[t("btn_back", update=update, context=context), t("btn_cancel", update=update, context=context)]]) - - def _parse_price_number(s: Optional[str]) -> float: """ Try to extract a numeric value from a price string. @@ -61,16 +50,11 @@ def _parse_price_number(s: Optional[str]) -> float: if not s: return float("inf") txt = str(s) - # remove currency symbols cleaned = re.sub(r"[^\d.,\s]", "", txt) - # replace spaces as thousands separators cleaned = cleaned.replace(" ", "") - # if there are both ',' and '.', assume ',' thousands and '.' decimal if "," in cleaned and "." in cleaned: - # just drop commas cleaned = cleaned.replace(",", "") else: - # if only comma present, treat as decimal cleaned = cleaned.replace(",", ".") m = re.search(r"(\d+(?:\.\d+)?)", cleaned) if not m: @@ -82,35 +66,33 @@ def _parse_price_number(s: Optional[str]) -> float: def _format_item_html(it: dict) -> str: - """ - HTML-safe line: - [n]. title - price - (the leading "[n]. " is added by caller; here we build link+price piece) - """ title = html.escape(it.get("title") or "—") url = (it.get("url") or "").strip() price = (it.get("price") or "").strip() - if url: - link = f'{title}' - else: - link = title # no link available - + link = f'{title}' if url else title if price: return f"{link} - {html.escape(price)}" return link def _sort_items_by_price(items: List[Dict]) -> List[Dict]: - return sorted(items, key=lambda x: (_parse_price_number(x.get("price")), (x.get("title") or "").lower(), x.get("id") or 0)) + return sorted( + items, + key=lambda x: ( + _parse_price_number(x.get("price")), + (x.get("title") or "").lower(), + x.get("id") or 0, + ), + ) class WishlistHandler: - def __init__(self, wishlist: WishlistRepo, users: UsersRepo): + def __init__(self, users: UsersRepo, wishlist: WishlistRepo): self.wishlist = wishlist self.users = users - # ------ Entry points (triggered via birthdays screen buttons) ------ + # ------ Entry points (from birthdays screen) ------ async def my_list(self, update: Update, context: ContextTypes.DEFAULT_TYPE): uid = update.effective_user.id @@ -118,21 +100,19 @@ async def my_list(self, update: Update, context: ContextTypes.DEFAULT_TYPE): if not items: await update.message.reply_text( t("wishlist_empty", update=update, context=context), - reply_markup=wishlist_menu_kb(update=update, context=context), + reply_markup=birthdays_wishlist_kb(update=update, context=context), ) return items_sorted = _sort_items_by_price(items) - # Build mapping index -> db_id for deletion by short number - id_map = [int(it["id"]) for it in items_sorted] - context.user_data["__wl_map"] = id_map + context.user_data["__wl_map"] = [int(it["id"]) for it in items_sorted] lines = [t("wishlist_header_my", update=update, context=context)] for i, it in enumerate(items_sorted, start=1): lines.append(f"{i}. {_format_item_html(it)}") await update.message.reply_text( "\n".join(lines), - reply_markup=wishlist_menu_kb(update=update, context=context), + reply_markup=birthdays_wishlist_kb(update=update, context=context), parse_mode=ParseMode.HTML, disable_web_page_preview=False, ) @@ -147,9 +127,8 @@ async def edit_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE): async def edit_pick(self, update: Update, context: ContextTypes.DEFAULT_TYPE): text = (update.message.text or "").strip() - if text == t("btn_back", update=update, context=context): - # Return to birthdays (outer menu will handle back) - from .birthdays import BirthdaysHandler # lazy import OK + if text == t("btn_cancel", update=update, context=context): + # Return to birthdays bh = context.application.bot_data.get("birthdays_handler") if bh: await bh.menu_entry(update, context) @@ -158,12 +137,11 @@ async def edit_pick(self, update: Update, context: ContextTypes.DEFAULT_TYPE): if text == t("btn_wishlist_add", update=update, context=context): await update.message.reply_text( t("wishlist_add_title", update=update, context=context), - reply_markup=back_cancel_kb(update=update, context=context), + reply_markup=cancel_kb(update=update, context=context), ) return W_ADD_TITLE if text == t("btn_wishlist_del", update=update, context=context): - # show my list first, with local numbering uid = update.effective_user.id items = await self.wishlist.list_for_user(uid) if not items: @@ -179,13 +157,13 @@ async def edit_pick(self, update: Update, context: ContextTypes.DEFAULT_TYPE): for i, it in enumerate(items_sorted, start=1): id_map.append(int(it["id"])) lines.append(f"{i}. {_format_item_html(it)}") - context.user_data["__wl_map"] = id_map + lines.append("") lines.append(t("wishlist_del_prompt", update=update, context=context)) await update.message.reply_text( "\n".join(lines), - reply_markup=back_cancel_kb(update=update, context=context), + reply_markup=cancel_kb(update=update, context=context), parse_mode=ParseMode.HTML, disable_web_page_preview=False, ) @@ -202,7 +180,7 @@ async def edit_pick(self, update: Update, context: ContextTypes.DEFAULT_TYPE): async def add_title(self, update: Update, context: ContextTypes.DEFAULT_TYPE): text = (update.message.text or "").strip() - if text in (t("btn_back", update=update, context=context), t("btn_cancel", update=update, context=context)): + if text == t("btn_cancel", update=update, context=context): await update.message.reply_text( t("canceled", update=update, context=context), reply_markup=wishlist_edit_kb(update=update, context=context), @@ -212,7 +190,7 @@ async def add_title(self, update: Update, context: ContextTypes.DEFAULT_TYPE): if not text: await update.message.reply_text( t("wishlist_add_title_bad", update=update, context=context), - reply_markup=back_cancel_kb(update=update, context=context), + reply_markup=cancel_kb(update=update, context=context), ) return W_ADD_TITLE @@ -221,19 +199,13 @@ async def add_title(self, update: Update, context: ContextTypes.DEFAULT_TYPE): t("wishlist_add_url", update=update, context=context), reply_markup=_kb([ [t("btn_skip", update=update, context=context)], - [t("btn_back", update=update, context=context), t("btn_cancel", update=update, context=context)], + [t("btn_cancel", update=update, context=context)], ]), ) return W_ADD_URL async def add_url(self, update: Update, context: ContextTypes.DEFAULT_TYPE): text = (update.message.text or "").strip() - if text == t("btn_back", update=update, context=context): - await update.message.reply_text( - t("wishlist_add_title", update=update, context=context), - reply_markup=back_cancel_kb(update=update, context=context), - ) - return W_ADD_TITLE if text == t("btn_cancel", update=update, context=context): context.user_data.pop("__wl_new", None) await update.message.reply_text( @@ -248,22 +220,13 @@ async def add_url(self, update: Update, context: ContextTypes.DEFAULT_TYPE): t("wishlist_add_price", update=update, context=context), reply_markup=_kb([ [t("btn_skip", update=update, context=context)], - [t("btn_back", update=update, context=context), t("btn_cancel", update=update, context=context)], + [t("btn_cancel", update=update, context=context)], ]), ) return W_ADD_PRICE async def add_price(self, update: Update, context: ContextTypes.DEFAULT_TYPE): text = (update.message.text or "").strip() - if text == t("btn_back", update=update, context=context): - await update.message.reply_text( - t("wishlist_add_url", update=update, context=context), - reply_markup=_kb([ - [t("btn_skip", update=update, context=context)], - [t("btn_back", update=update, context=context), t("btn_cancel", update=update, context=context)], - ]), - ) - return W_ADD_URL if text == t("btn_cancel", update=update, context=context): context.user_data.pop("__wl_new", None) await update.message.reply_text( @@ -271,6 +234,7 @@ async def add_price(self, update: Update, context: ContextTypes.DEFAULT_TYPE): reply_markup=wishlist_edit_kb(update=update, context=context), ) return W_EDIT_PICK + if text != t("btn_skip", update=update, context=context): context.user_data.setdefault("__wl_new", {})["price"] = text @@ -301,12 +265,6 @@ async def add_price(self, update: Update, context: ContextTypes.DEFAULT_TYPE): async def del_id(self, update: Update, context: ContextTypes.DEFAULT_TYPE): text = (update.message.text or "").strip() - if text == t("btn_back", update=update, context=context): - await update.message.reply_text( - t("wishlist_edit_pick", update=update, context=context), - reply_markup=wishlist_edit_kb(update=update, context=context), - ) - return W_EDIT_PICK if text == t("btn_cancel", update=update, context=context): await update.message.reply_text( t("canceled", update=update, context=context), @@ -314,28 +272,24 @@ async def del_id(self, update: Update, context: ContextTypes.DEFAULT_TYPE): ) return W_EDIT_PICK - # Accept either displayed local index (1..N) or real DB id wl_map: List[int] = context.user_data.get("__wl_map") or [] target_db_id: Optional[int] = None if text.isdigit(): num = int(text) - # if matches local index 1..N -> map if 1 <= num <= len(wl_map): target_db_id = wl_map[num - 1] else: - # maybe user typed real db id; accept as is target_db_id = num if not target_db_id: await update.message.reply_text( t("wishlist_del_bad", update=update, context=context), - reply_markup=back_cancel_kb(update=update, context=context), + reply_markup=cancel_kb(update=update, context=context), ) return W_DEL_ID uid = update.effective_user.id - ok = False try: ok = await self.wishlist.delete_item(uid, target_db_id) except Exception: @@ -352,26 +306,20 @@ async def del_id(self, update: Update, context: ContextTypes.DEFAULT_TYPE): async def view_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE): await update.message.reply_text( t("wishlist_view_prompt", update=update, context=context), - reply_markup=back_cancel_kb(update=update, context=context), + reply_markup=cancel_kb(update=update, context=context), ) return W_VIEW_OTHER async def view_wait(self, update: Update, context: ContextTypes.DEFAULT_TYPE): text = (update.message.text or "").strip() - if text == t("btn_back", update=update, context=context): - await update.message.reply_text( - t("wishlist_open_menu", update=update, context=context), - reply_markup=wishlist_menu_kb(update=update, context=context), - ) - return ConversationHandler.END + if text == t("btn_cancel", update=update, context=context): await update.message.reply_text( t("canceled", update=update, context=context), - reply_markup=wishlist_menu_kb(update=update, context=context), + reply_markup=birthdays_wishlist_kb(update=update, context=context), ) return ConversationHandler.END - # parse @username or id target_id: Optional[int] = None username: Optional[str] = None if text.startswith("@"): @@ -382,7 +330,6 @@ async def view_wait(self, update: Update, context: ContextTypes.DEFAULT_TYPE): except Exception: target_id = None - # resolve user id if username given if username and not target_id: up = await self.users.get_user_by_username(username) if up: @@ -391,7 +338,7 @@ async def view_wait(self, update: Update, context: ContextTypes.DEFAULT_TYPE): if not target_id: await update.message.reply_text( t("wishlist_view_not_found", update=update, context=context), - reply_markup=back_cancel_kb(update=update, context=context), + reply_markup=cancel_kb(update=update, context=context), ) return W_VIEW_OTHER @@ -399,7 +346,7 @@ async def view_wait(self, update: Update, context: ContextTypes.DEFAULT_TYPE): if not items: await update.message.reply_text( t("wishlist_empty_other", update=update, context=context), - reply_markup=wishlist_menu_kb(update=update, context=context), + reply_markup=birthdays_wishlist_kb(update=update, context=context), ) return ConversationHandler.END @@ -409,7 +356,7 @@ async def view_wait(self, update: Update, context: ContextTypes.DEFAULT_TYPE): lines.append(f"{i}. {_format_item_html(it)}") await update.message.reply_text( "\n".join(lines), - reply_markup=wishlist_menu_kb(update=update, context=context), + reply_markup=birthdays_wishlist_kb(update=update, context=context), parse_mode=ParseMode.HTML, disable_web_page_preview=False, ) diff --git a/bot/keyboards.py b/bot/keyboards.py index 2599c7e..a4f641f 100644 --- a/bot/keyboards.py +++ b/bot/keyboards.py @@ -3,11 +3,9 @@ from telegram import ReplyKeyboardMarkup from .i18n import t - def _kb(rows: list[list[str]]) -> ReplyKeyboardMarkup: return ReplyKeyboardMarkup(rows, resize_keyboard=True, one_time_keyboard=False) - # ----- main menu ----- def main_menu_kb(*, update=None, context=None) -> ReplyKeyboardMarkup: return _kb( @@ -18,7 +16,6 @@ def main_menu_kb(*, update=None, context=None) -> ReplyKeyboardMarkup: ] ) - # ----- groups ----- def groups_menu_kb(*, update=None, context=None) -> ReplyKeyboardMarkup: return _kb( @@ -38,7 +35,6 @@ def group_mgmt_kb(*, update=None, context=None) -> ReplyKeyboardMarkup: ] ) - # ----- friends ----- def friends_menu_kb(*, update=None, context=None) -> ReplyKeyboardMarkup: return _kb( @@ -48,7 +44,6 @@ def friends_menu_kb(*, update=None, context=None) -> ReplyKeyboardMarkup: ] ) - # ----- settings ----- def settings_menu_kb(*, update=None, context=None) -> ReplyKeyboardMarkup: return _kb( @@ -59,7 +54,6 @@ def settings_menu_kb(*, update=None, context=None) -> ReplyKeyboardMarkup: ] ) - # ----- about / donate ----- def about_kb(*, update=None, context=None) -> ReplyKeyboardMarkup: return _kb( @@ -71,9 +65,8 @@ def about_kb(*, update=None, context=None) -> ReplyKeyboardMarkup: ) # single cancel keyboard (used inside convs) -def cancel_kb(*, context=None) -> ReplyKeyboardMarkup: - return ReplyKeyboardMarkup([[t("btn_cancel", context=context)]], resize_keyboard=True, one_time_keyboard=True) - +def cancel_kb(*, update=None, context=None) -> ReplyKeyboardMarkup: + return ReplyKeyboardMarkup([[t("btn_cancel", update=update, context=context)]], resize_keyboard=True, one_time_keyboard=True) # ----- birthdays nested: wishlist ----- def birthdays_wishlist_kb(*, update=None, context=None) -> ReplyKeyboardMarkup: diff --git a/bot/main.py b/bot/main.py index ca792fd..7404394 100644 --- a/bot/main.py +++ b/bot/main.py @@ -1,12 +1,12 @@ +# FILE: bot/main.py from __future__ import annotations -# main entry with maintenance guard and admin events polling - import asyncio import logging -from typing import Tuple, List +from typing import Tuple, List, Optional from telegram import Update +from telegram.constants import ParseMode from telegram.ext import ( Application, ApplicationBuilder, @@ -15,11 +15,10 @@ MessageHandler, PreCheckoutQueryHandler, ContextTypes, - filters, ApplicationHandlerStop, - Defaults + Defaults, + filters, ) -from telegram.constants import ParseMode from . import config @@ -27,28 +26,37 @@ from .db.repo_users import UsersRepo from .db.repo_groups import GroupsRepo from .db.repo_friends import FriendsRepo +from .db.repo_wishlist import WishlistRepo # handlers from .handlers.start import StartHandler, AWAITING_LANG_PICK, AWAITING_REGISTRATION_BDAY from .handlers.groups import GroupsHandler from .handlers.friends import FriendsHandler -from .handlers.settings import SettingsHandler, S_WAIT_BDAY, S_WAIT_TZ, S_WAIT_ALERT_DAYS, S_WAIT_ALERT_TIME, S_WAIT_LANG +from .handlers.settings import ( + SettingsHandler, + S_WAIT_BDAY, + S_WAIT_TZ, + S_WAIT_ALERT_DAYS, + S_WAIT_ALERT_TIME, + S_WAIT_LANG, +) from .handlers.about import AboutHandler +from .handlers.birthdays import BirthdaysHandler +from .handlers.wishlist import WishlistHandler # keyboards from .keyboards import main_menu_kb -# notif service +# services from .services.notif_service import NotifService # i18n from .i18n import t, btn_regex -# re-use admin repo to read events and chat list +# admin repo (events) from .adminbot.repo import AdminRepo -# logging setup def _setup_logging() -> None: level = getattr(logging, (config.LOG_LEVEL or "INFO").upper(), logging.INFO) logging.basicConfig( @@ -59,16 +67,14 @@ def _setup_logging() -> None: logging.getLogger("telegram").setLevel(logging.WARNING) -# build repos -def _build_repos() -> Tuple[UsersRepo, GroupsRepo, FriendsRepo]: +def _build_repos() -> Tuple[UsersRepo, GroupsRepo, FriendsRepo, WishlistRepo]: db_path = config.DB_PATH users = UsersRepo(db_path) groups = GroupsRepo(db_path) friends = FriendsRepo(db_path) - return users, groups, friends - + wishlist = WishlistRepo(db_path) + return users, groups, friends, wishlist -# helpers def _is_admin(update: Update) -> bool: try: @@ -78,41 +84,31 @@ def _is_admin(update: Update) -> bool: allowed = getattr(config, "ADMIN_ALLOWED_IDS", []) or [] return bool(uid and uid in allowed) + async def _broadcast_key_to_all(app: Application, users_repo: UsersRepo, key: str) -> int: - # per-user localization by reading profile lang repo = AdminRepo(config.DB_PATH) chat_ids = await repo.list_all_chat_ids() sent = 0 for cid in chat_ids: - lang = "en" try: - u = await users_repo.get_user(int(cid)) - if u and u.get("lang"): - lang = str(u["lang"]) - except Exception: - pass - # NOTE: sending plain key text (t() without context falls back to default) — - # per-user language broadcast is handled by the main bot below when it reads admin_events. - text = t(key) - try: - await app.bot.send_message(chat_id=cid, text=text) + await app.bot.send_message(chat_id=cid, text=t(key)) sent += 1 except Exception: pass return sent -# main menu async def show_main_menu(update: Update, context: ContextTypes.DEFAULT_TYPE): - await update.message.reply_text(t("main_menu_title"), reply_markup=main_menu_kb(context=context)) + await (update.effective_message or update.message).reply_text( + t("main_menu_title"), + reply_markup=main_menu_kb(update=update, context=context), + ) -# global error handler async def on_error(update: object, context: ContextTypes.DEFAULT_TYPE) -> None: logging.getLogger("birthdaybot").exception("unhandled error", exc_info=context.error) -# test alerts command: /alert_test async def alert_test_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE): app = context.application notif: NotifService = app.bot_data.get("notif_service") # type: ignore @@ -131,17 +127,13 @@ async def alert_test_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE): await update.message.reply_text(f"test: sent {sent}.") -# maintenance guard (soft): block any user input except admins async def maintenance_guard(update: Update, context: ContextTypes.DEFAULT_TYPE): maint = context.application.bot_data.get("maintenance") or {} if not maint.get("enabled"): - return # pass through - - # admins bypass + return if _is_admin(update): return - # hard/soft messages mode = maint.get("mode", "soft") key = maint.get("key") or ("maintenance_hard" if mode == "hard" else "maintenance_soft") @@ -154,11 +146,10 @@ async def maintenance_guard(update: Update, context: ContextTypes.DEFAULT_TYPE): pass cd[mem_key] = True - # CRITICAL: stop further processing for this update - raise ApplicationHandlerStop + from telegram.ext import ApplicationHandlerStop as Stop + raise Stop -# --- admin events polling (from admin_events table) async def _process_admin_events(context: ContextTypes.DEFAULT_TYPE): app = context.application users_repo: UsersRepo = app.bot_data["users_repo"] @@ -178,11 +169,14 @@ async def _process_admin_events(context: ContextTypes.DEFAULT_TYPE): for ev in events: kind = ev.get("kind") payload = ev.get("payload") or {} + if kind == "maint": key = payload.get("key") or "maintenance_soft" + if key == "maintenance_on_soft": app.bot_data["maintenance"] = {"enabled": True, "mode": "soft", "key": "maintenance_soft"} await _broadcast_key_to_all(app, users_repo, "maintenance_soft") + elif key == "maintenance_on_hard": app.bot_data["maintenance"] = {"enabled": True, "mode": "hard", "key": "maintenance_hard"} await _broadcast_key_to_all(app, users_repo, "maintenance_hard") @@ -191,16 +185,20 @@ async def _process_admin_events(context: ContextTypes.DEFAULT_TYPE): await notif.shutdown() except Exception: pass + async def _stop(): await asyncio.sleep(1.0) try: await app.stop() except Exception: pass + asyncio.create_task(_stop()) + elif key == "maintenance_off": app.bot_data["maintenance"] = {"enabled": False, "mode": "soft", "key": None} await _broadcast_key_to_all(app, users_repo, "maintenance_off") + done_ids.append(int(ev["id"])) else: done_ids.append(int(ev["id"])) @@ -212,32 +210,38 @@ async def _stop(): pass -# build application and register handlers def build_application() -> Application: _setup_logging() log = logging.getLogger("birthdaybot") - users_repo, groups_repo, friends_repo = _build_repos() + users_repo, groups_repo, friends_repo, wishlist_repo = _build_repos() + defaults = Defaults(parse_mode=ParseMode.HTML) app = ApplicationBuilder().token(config.BOT_TOKEN).defaults(defaults).build() + # stash repos app.bot_data["users_repo"] = users_repo app.bot_data["groups_repo"] = groups_repo app.bot_data["friends_repo"] = friends_repo + app.bot_data["wishlist_repo"] = wishlist_repo app.bot_data.setdefault("maintenance", {"enabled": False, "mode": "soft", "key": None}) + # handlers instances start_handler = StartHandler(users_repo) groups_handler = GroupsHandler(groups_repo, users_repo) friends_handler = FriendsHandler(users_repo, friends_repo, groups_repo) settings_handler = SettingsHandler(users_repo, friends_repo, groups_repo) about_handler = AboutHandler() + birthdays_handler = BirthdaysHandler(users_repo, friends_repo, groups_repo) + wishlist_handler = WishlistHandler(users_repo, wishlist_repo) - app.add_error_handler(on_error) + # 👇 важно: ссылка для возврата из вишлиста по «Отмена» + app.bot_data["birthdays_handler"] = birthdays_handler - # maintenance guard first (very early group) + app.add_error_handler(on_error) app.add_handler(MessageHandler(filters.ALL, maintenance_guard), group=-100) - # start / registration (add language state) + # /start app.add_handler( ConversationHandler( entry_points=[CommandHandler("start", start_handler.start)], @@ -252,28 +256,20 @@ def build_application() -> Application: group=0, ) - # birthdays screen - async def show_birthdays(update: Update, context: ContextTypes.DEFAULT_TYPE): - from .handlers.birthdays import BirthdaysHandler - bh = context.application.bot_data.get("birthdays_handler") - if not bh: - bh = BirthdaysHandler(users_repo, friends_repo, groups_repo) - context.application.bot_data["birthdays_handler"] = bh - await bh.menu_entry(update, context) - - app.add_handler(MessageHandler(filters.Regex(btn_regex("btn_birthdays")), show_birthdays), group=0) + # Birthdays root + app.add_handler(MessageHandler(filters.Regex(btn_regex("btn_birthdays")), birthdays_handler.menu_entry), group=0) - # groups flows + # Groups for ch in groups_handler.conversation_handlers(): app.add_handler(ch, group=0) app.add_handler(MessageHandler(filters.Regex(btn_regex("btn_groups")), groups_handler.menu_entry), group=0) - # friends flows + # Friends app.add_handler(MessageHandler(filters.Regex(btn_regex("btn_friends")), friends_handler.menu_entry), group=1) for ch in friends_handler.conversation_handlers(): app.add_handler(ch, group=1) - # settings + # Settings app.add_handler(MessageHandler(filters.Regex(btn_regex("btn_settings")), settings_handler.menu_entry), group=2) app.add_handler( @@ -323,49 +319,65 @@ async def show_birthdays(update: Update, context: ContextTypes.DEFAULT_TYPE): group=2, ) - # about / donations + # Wishlist + for ch in wishlist_handler.conversation_handlers(): + app.add_handler(ch, group=1) + app.add_handler(MessageHandler(filters.Regex(btn_regex("btn_wishlist_my")), wishlist_handler.my_list), group=1) + # ВАЖНО: не добавляем отдельные handlers для btn_wishlist_edit/view — они уже entry_points разговоров. + + # About / donations app.add_handler(MessageHandler(filters.Regex(btn_regex("btn_about")), about_handler.menu_entry), group=3) - app.add_handler(MessageHandler(filters.Regex(r"^\⭐ 50$"), about_handler.donate_50), group=3) - app.add_handler(MessageHandler(filters.Regex(r"^\⭐ 100$"), about_handler.donate_100), group=3) - app.add_handler(MessageHandler(filters.Regex(r"^\⭐ 500$"), about_handler.donate_500), group=3) + app.add_handler(MessageHandler(filters.Regex(r"^\⭐\s*50$"), about_handler.donate_50), group=3) + app.add_handler(MessageHandler(filters.Regex(r"^\⭐\s*100$"), about_handler.donate_100), group=3) + app.add_handler(MessageHandler(filters.Regex(r"^\⭐\s*500$"), about_handler.donate_500), group=3) app.add_handler(PreCheckoutQueryHandler(about_handler.precheckout), group=3) app.add_handler(MessageHandler(filters.SUCCESSFUL_PAYMENT, about_handler.successful_payment), group=3) - # exit/back to main + # Back to main app.add_handler(MessageHandler(filters.Regex(btn_regex("btn_exit")), show_main_menu), group=3) app.add_handler(MessageHandler(filters.Regex(btn_regex("btn_back_main")), show_main_menu), group=3) - # test alerts + # Commands app.add_handler(CommandHandler("alert_test", alert_test_cmd), group=3) - # debug logger last async def log_incoming(update: Update, _): if update.message and update.message.text: logging.getLogger("incoming").info("text=%r", update.message.text) app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, log_incoming), group=99) - # post-init: schedule window, daily refresh, admin events poller async def _post_init(application: Application): if getattr(application, "job_queue", None) is None: log.info("job queue not available, skipping schedule") return + users = application.bot_data.get("users_repo") groups = application.bot_data.get("groups_repo") friends = application.bot_data.get("friends_repo") notif = NotifService(application, users, groups, friends) application.bot_data["notif_service"] = notif + try: - horizon = getattr(config, "SCHEDULE_HORIZON_DAYS", 7) + horizon_raw = getattr(config, "SCHEDULE_HORIZON_DAYS", 7) + try: + horizon = int(horizon_raw) + except Exception: + horizon = 7 + await notif.schedule_all(horizon_days=horizon) await notif.schedule_daily_refresh(at_hour=3) - application.job_queue.run_repeating(_process_admin_events, interval=5.0, first=3.0, name="admin_events_poll") + + application.job_queue.run_repeating( + _process_admin_events, + interval=5.0, + first=3.0, + name="admin_events_poll", + ) log.info("birthday notifications scheduled, daily refresh set, admin events poller on") except Exception as e: log.exception("post-init failed: %s", e) app.post_init = _post_init - return app @@ -373,6 +385,5 @@ def main() -> None: app = build_application() app.run_polling() - if __name__ == "__main__": main()