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()