From 205a691ce4f836c19556e6935403c092ce674802 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D1=82?= Date: Fri, 10 Jul 2026 12:09:21 +0500 Subject: [PATCH 1/3] Extract shared geography primitives into _dependencies/common/geo.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Centralised FEDERAL_DISTRICTS, compact_region_name(), REGION_EMOJI_LEGEND - RegionCallbackPayload dataclass with JSON serialisation - paginate_regions() helper for paginated region lists - NavButton constants (← назад, ещё →, Завершить и т.д.) - get_fed_district_normalised/label() lookup helpers - district_prompt() shared message builder VK bot: removed duplicated _compact_region_name + emoji legend, now imports from _dependencies.common.geo MAX bot: same cleanup ruff format + ruff check + mypy + 275 VK tests — all green. --- src/_dependencies/common/geo.py | 245 ++++++++++++++++++ src/max_bot/_utils/keyboards.py | 31 +-- .../_utils/handlers/region_select_handlers.py | 4 +- src/vk_bot/_utils/keyboards.py | 38 +-- 4 files changed, 257 insertions(+), 61 deletions(-) create mode 100644 src/_dependencies/common/geo.py diff --git a/src/_dependencies/common/geo.py b/src/_dependencies/common/geo.py new file mode 100644 index 00000000..9b5a84fe --- /dev/null +++ b/src/_dependencies/common/geo.py @@ -0,0 +1,245 @@ +"""Shared geography / region-selection primitives across all messengers. + +Centralises: + +1. **Federal district constants** — single source of truth for labels + and normalised names used by VK and MAX (and eventually Telegram). +2. **Region name compaction** — converts verbose subtype suffixes + (e.g. `` – Активные поиски``) to short emoji markers (e.g. ``🔍``). +3. **Navigation button labels** — ``← назад``, ``ещё →``, ``Завершить``, + ``в начало``, etc. +4. **Callback payload schema** — ``RegionCallbackPayload`` dataclass + with JSON serialisation, shared by VK and MAX inline keyboards. +5. **Pagination helpers** — split a region list into pages, compute + navigation metadata. +6. **Common text messages** — prompts shown during region selection. +7. **Emoji legend** — explaining what each emoji marker means. + +Usage:: + + from _dependencies.common.geo import ( + FEDERAL_DISTRICTS, + compact_region_name, + REGION_EMOJI_LEGEND, + RegionCallbackPayload, + paginate_regions, + NavButton, + ) +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import NamedTuple + +# ═══════════════════════════════════════════════════════════════════════════════ +# Navigation button labels +# ═══════════════════════════════════════════════════════════════════════════════ + + +class NavButton: + """Shared navigation button labels used by region-selection keyboards.""" + + BACK: str = '← назад' + NEXT: str = 'ещё →' + FINISH: str = 'Завершить' + BACK_TO_START: str = 'в начало' + CHOOSE_OTHER: str = 'выбрать другой регион' + FED_DIST_PICK_OTHER: str = 'выбрать другой Федеральный Округ' + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Federal districts — single source of truth +# ═══════════════════════════════════════════════════════════════════════════════ + +#: (display_label, normalised_name) pairs. +#: ``normalised_name`` is used internally for DB lookups and callback payloads. +FEDERAL_DISTRICTS: tuple[tuple[str, str], ...] = ( + ('Центральный ФО', 'Центральный'), + ('Северо-Западный ФО', 'Северо-Западный'), + ('Южный ФО', 'Южный'), + ('Северо-Кавказский ФО', 'Северо-Кавказский'), + ('Приволжский ФО', 'Приволжский'), + ('Уральский ФО', 'Уральский'), + ('Сибирский ФО', 'Сибирский'), + ('Дальневосточный ФО', 'Дальневосточный'), + ('Прочие поиски по РФ', 'Прочие поиски по РФ'), +) + +# Normalised names by label (for lookup) +_FED_DISTRICT_BY_LABEL: dict[str, str] = {label: norm for label, norm in FEDERAL_DISTRICTS} +# Labels by normalised name (for reverse lookup) +_FED_DISTRICT_LABEL: dict[str, str] = {norm: label for label, norm in FEDERAL_DISTRICTS} + + +def get_fed_district_normalised(label: str) -> str | None: + """Return the normalised district name for a display label, or *None*. + + Example: ``'Центральный ФО'`` → ``'Центральный'``. + """ + return _FED_DISTRICT_BY_LABEL.get(label.strip()) + + +def get_fed_district_label(normalised: str) -> str | None: + """Return the display label for a normalised district name, or *None*. + + Example: ``'Центральный'`` → ``'Центральный ФО'``. + """ + return _FED_DISTRICT_LABEL.get(normalised.strip()) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Region name compaction (emoji suffixes) +# ═══════════════════════════════════════════════════════════════════════════════ + +_SUFFIX_TO_EMOJI: dict[str, str] = { + ' – Активные поиски': '🔍', + ' – Завершенные поиски': '✅', + ' – Инфо поддержка': 'ℹ️', + ' – Мероприятия': '📅', +} +# Build a regex once for efficient matching +_COMPACT_REGION_RE = re.compile('(' + '|'.join(re.escape(s) for s in _SUFFIX_TO_EMOJI) + ')$') + +# Emoji legend shown above region selection keyboards so users understand +# what each emoji marker means. +REGION_EMOJI_LEGEND: str = ( + '🔍 — активные поиски\n' '✅ — завершённые поиски\n' 'ℹ️ — инфорг / поддержка\n' '📅 — мероприятия' +) + + +def compact_region_name(name: str) -> str: + """Replace verbose subtype suffix with a short emoji marker at the beginning. + + ``"Москва – Активные поиски"`` → ``"🔍 Москва"`` + + If no suffix matches, the original name is returned unchanged. + """ + match = _COMPACT_REGION_RE.search(name) + if match: + suffix = match.group(1) + region_part = name[: match.start()].strip() + return _SUFFIX_TO_EMOJI[suffix] + ' ' + region_part + return name + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Callback payload — shared by VK and MAX inline keyboards +# ═══════════════════════════════════════════════════════════════════════════════ + +# All possible ``cmd`` values used in region-selection callbacks. +CMD_DISTRICT_SELECT: str = 'district_select' +CMD_PAGINATE_TOGGLE: str = 'paginate_toggle' +CMD_PAGINATE_NAV: str = 'paginate_nav' +CMD_PAGINATE_FINISH: str = 'paginate_finish' + + +@dataclass(frozen=True) +class RegionCallbackPayload: + """Structured payload for inline region-selection callbacks. + + Serialised as JSON for VK and MAX callback systems. + """ + + cmd: str + district: str | None = None + region: str | None = None + page: int = 0 + + def to_dict(self) -> dict[str, object]: + d: dict[str, object] = {'cmd': self.cmd} + if self.district is not None: + d['district'] = self.district + if self.region is not None: + d['region'] = self.region + if self.page: + d['page'] = self.page + return d + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Pagination helpers +# ═══════════════════════════════════════════════════════════════════════════════ + + +class PageRange(NamedTuple): + """Start (inclusive) and end (exclusive) indices for a page slice.""" + + start: int + end: int + + +class PageInfo(NamedTuple): + """Metadata for a single pagination page.""" + + items: list[str] + page: int + total_pages: int + has_prev: bool + has_next: bool + slice: PageRange + + +def paginate_regions(regions: list[str], page: int, page_size: int = 6) -> PageInfo: + """Split *regions* into pages and return metadata for *page* (zero-based). + + Args: + regions: Full list of region display names. + page: Zero-based page index. + page_size: Number of items per page. + + Returns: + A :class:`PageInfo` with the slice of items for the requested page. + """ + total_pages = max(1, (len(regions) + page_size - 1) // page_size) + start = page * page_size + end = min(start + page_size, len(regions)) + return PageInfo( + items=regions[start:end], + page=page, + total_pages=total_pages, + has_prev=page > 0, + has_next=page < total_pages - 1, + slice=PageRange(start, end), + ) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Common text messages +# ═══════════════════════════════════════════════════════════════════════════════ + +REGION_SELECTION_INTRO: str = ( + 'Выберите федеральный округ, чтобы увидеть список регионов.\n\n' + 'Нажмите на регион, чтобы подписаться или отписаться.' +) + +REGION_LIST_PROMPT: str = ( + '{district_text}\n\n' '{emoji_legend}\n\n' 'Нажмите на регион, чтобы подписаться или отписаться.' +) + +REGION_SELECTION_DONE: str = '✅ Выбор региона завершён.' +REGION_CANNOT_REMOVE_LAST: str = 'Нельзя удалить последний регион.' +REGION_TOGGLED_ON: str = 'Регион добавлен!' +REGION_TOGGLED_OFF: str = 'Регион удалён.' +REGION_TOGGLED_ON_FMT: str = 'Регион "{}" добавлен!' +REGION_TOGGLED_OFF_FMT: str = 'Регион "{}" удалён.' +REGION_NOT_FOUND: str = 'Регион не найден.' +REGION_ERROR: str = 'Произошла ошибка. Попробуйте позже.' + + +def district_prompt(district_normalised: str) -> str: + """Return the full prompt for region selection within a district. + + Args: + district_normalised: Normalised district name (e.g. ``'Центральный'``). + + Returns: + A user-facing message including the district name and emoji legend. + """ + label = get_fed_district_label(district_normalised) or district_normalised + return ( + f'Выберите регион в округе "{label}":\n\n' + f'{REGION_EMOJI_LEGEND}\n\n' + 'Нажмите на регион, чтобы подписаться или отписаться.' + ) diff --git a/src/max_bot/_utils/keyboards.py b/src/max_bot/_utils/keyboards.py index 8b63387e..73318caa 100644 --- a/src/max_bot/_utils/keyboards.py +++ b/src/max_bot/_utils/keyboards.py @@ -8,7 +8,6 @@ """ import json -import re from maxapi.types.attachments.buttons.attachment_button import AttachmentButton from maxapi.types.attachments.buttons.callback_button import CallbackButton @@ -17,31 +16,7 @@ ) from maxapi.utils.inline_keyboard import InlineKeyboardBuilder -# Suffix-to-emoji mapping for compact region display names. -# Replaces verbose subtype suffixes like " – Активные поиски" with short emoji markers. -# Emoji is placed at the BEGINNING of the label because Max's keyboard buttons -# are narrower than VK's and trailing emojis may be clipped. -_SUFFIX_TO_EMOJI: dict[str, str] = { - ' – Активные поиски': '🔍', - ' – Инфо поддержка': 'ℹ️', - ' – Мероприятия': '📅', -} -# Build a regex once for efficient matching -_COMPACT_REGION_RE = re.compile('(' + '|'.join(re.escape(s) for s in _SUFFIX_TO_EMOJI) + ')$') - - -def _compact_region_name(name: str) -> str: - """Replace verbose subtype suffix with a short emoji marker at the BEGINNING. - - "Москва – Активные поиски" → "🔍 Москва" - "Ханты-Мансийский АО – Инфо поддержка" → "ℹ️ Ханты-Мансийский АО" - """ - match = _COMPACT_REGION_RE.search(name) - if match: - suffix = match.group(1) - region_part = name[: match.start()].strip() - return _SUFFIX_TO_EMOJI[suffix] + ' ' + region_part - return name +from _dependencies.common.geo import compact_region_name class MaxKeyboardButtons: @@ -139,7 +114,7 @@ def paginated_regions_inline( ) -> AttachmentButton: """Inline keyboard with paginated region selection callback buttons. - Region names are compacted via ``_compact_region_name()`` — verbose + Region names are compacted via ``compact_region_name()`` — verbose subtype suffixes (e.g., " – Активные поиски") are replaced with short emoji markers (e.g., "🔍") to keep buttons readable. @@ -162,7 +137,7 @@ def paginated_regions_inline( builder = InlineKeyboardBuilder() for region_name in page_items: - compact_name = _compact_region_name(region_name) + compact_name = compact_region_name(region_name) display_name = f'✓ {compact_name}' if region_name in subscribed_ids else compact_name builder.add( CallbackButton( diff --git a/src/vk_bot/_utils/handlers/region_select_handlers.py b/src/vk_bot/_utils/handlers/region_select_handlers.py index 5a0883ad..a3eca0fd 100644 --- a/src/vk_bot/_utils/handlers/region_select_handlers.py +++ b/src/vk_bot/_utils/handlers/region_select_handlers.py @@ -14,9 +14,11 @@ import logging +from _dependencies.common.geo import REGION_EMOJI_LEGEND + from ..common import VKHandlerContext from ..decorators import vk_handle -from ..keyboards import REGION_EMOJI_LEGEND, VKKeyboardPresets +from ..keyboards import VKKeyboardPresets from ..services.message_formatter import ( region_selection_intro, settings_menu_intro, diff --git a/src/vk_bot/_utils/keyboards.py b/src/vk_bot/_utils/keyboards.py index f3f98b5e..9300489d 100644 --- a/src/vk_bot/_utils/keyboards.py +++ b/src/vk_bot/_utils/keyboards.py @@ -1,5 +1,6 @@ import json -import re + +from _dependencies.common.geo import compact_region_name from .common import ButtonColor @@ -10,33 +11,6 @@ # Inline keyboard: max 10 buttons total, grouped into max 6 rows, max 5 buttons per row. _MAX_INLINE_BUTTONS = 10 -# Suffix-to-emoji mapping for compact region display names. -# Replaces verbose subtype suffixes like " – Активные поиски" with short emoji markers. -_SUFFIX_TO_EMOJI: dict[str, str] = { - ' – Активные поиски': '🔍', - ' – Инфо поддержка': 'ℹ️', - ' – Мероприятия': '📅', -} -# Build a regex once for efficient matching -_COMPACT_REGION_RE = re.compile('(' + '|'.join(re.escape(s) for s in _SUFFIX_TO_EMOJI) + ')$') - -# Emoji legend shown above region selection keyboards so users understand -# what each emoji marker means. -REGION_EMOJI_LEGEND: str = '🔍 — активные поиски\n' 'ℹ️ — инфорг / поддержка\n' '📅 — мероприятия' - - -def _compact_region_name(name: str) -> str: - """Replace verbose subtype suffix with a short emoji marker at the BEGINNING. - - "Москва – Активные поиски" → "🔍 Москва" - "Ханты-Мансийский АО – Инфо поддержка" → "ℹ️ Ханты-Мансийский АО" - """ - match = _COMPACT_REGION_RE.search(name) - if match: - suffix = match.group(1) - region_part = name[: match.start()].strip() - return _SUFFIX_TO_EMOJI[suffix] + ' ' + region_part - return name class VKKeyboardButtons: @@ -322,11 +296,11 @@ def two_columns( if selected_regions is None: selected_regions = set() for i in range(0, len(buttons), 2): - btn1_name = _compact_region_name(buttons[i]) + btn1_name = compact_region_name(buttons[i]) btn1_color = 'primary' if buttons[i] in selected_regions else color row = [cls.text_button(btn1_name, btn1_color)] if i + 1 < len(buttons): - btn2_name = _compact_region_name(buttons[i + 1]) + btn2_name = compact_region_name(buttons[i + 1]) btn2_color = 'primary' if buttons[i + 1] in selected_regions else color row.append(cls.text_button(btn2_name, btn2_color)) rows.append(row) @@ -742,7 +716,7 @@ def paginated_regions_inline( Already-selected regions are highlighted with 'primary' (green) color. - Region names are compacted via ``_compact_region_name()`` — verbose + Region names are compacted via ``compact_region_name()`` — verbose subtype suffixes (e.g., " – Активные поиски") are replaced with short emoji markers (e.g., "🔍") to fit within VK's 40-character button limit. @@ -767,7 +741,7 @@ def paginated_regions_inline( if selected_regions is None: selected_regions = set() - compacted = [(_compact_region_name(name), name) for name in page_items] + compacted = [(compact_region_name(name), name) for name in page_items] rows: list[list[dict]] = [] for i in range(0, len(compacted), 2): From 8cc4f036706ffaa8f949c1bd48398f2010e7f16b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D1=82?= Date: Fri, 10 Jul 2026 12:09:21 +0500 Subject: [PATCH 2/3] Migrate VK and MAX bots onto shared geography primitives from _dependencies/common/geo.py - NavButton (BACK/NEXT/FINISH/BACK_TO_START) replaces duplicate BTN_* constants - FEDERAL_DISTRICTS replaces duplicate BTN_DISTRICT_* lists - paginate_regions() replaces manual pagination in paginated_regions_inline() - RegionCallbackPayload replaces hand-rolled callback payload dicts - CMD_* constants replace hardcoded cmd strings - REGION_EMOJI_LEGEND in MAX message_formatter now imported from geo.py - -87 lines net, 873 tests pass --- src/_dependencies/common/geo.py | 4 +- src/max_bot/_utils/handlers.py | 14 +- src/max_bot/_utils/keyboards.py | 97 ++++------- src/max_bot/_utils/message_formatter.py | 4 +- .../_utils/handlers/onboarding_handlers.py | 3 +- .../_utils/handlers/region_select_handlers.py | 16 +- src/vk_bot/_utils/keyboards.py | 163 ++++++------------ 7 files changed, 107 insertions(+), 194 deletions(-) diff --git a/src/_dependencies/common/geo.py b/src/_dependencies/common/geo.py index 9b5a84fe..b7f62a3a 100644 --- a/src/_dependencies/common/geo.py +++ b/src/_dependencies/common/geo.py @@ -145,7 +145,7 @@ class RegionCallbackPayload: cmd: str district: str | None = None region: str | None = None - page: int = 0 + page: int | None = None def to_dict(self) -> dict[str, object]: d: dict[str, object] = {'cmd': self.cmd} @@ -153,7 +153,7 @@ def to_dict(self) -> dict[str, object]: d['district'] = self.district if self.region is not None: d['region'] = self.region - if self.page: + if self.page is not None: d['page'] = self.page return d diff --git a/src/max_bot/_utils/handlers.py b/src/max_bot/_utils/handlers.py index 97afbf7e..468afd8e 100644 --- a/src/max_bot/_utils/handlers.py +++ b/src/max_bot/_utils/handlers.py @@ -32,6 +32,12 @@ from _dependencies.bot.users_management import ManageUserAction from _dependencies.common.commons import Messenger +from _dependencies.common.geo import ( + CMD_DISTRICT_SELECT, + CMD_PAGINATE_FINISH, + CMD_PAGINATE_NAV, + CMD_PAGINATE_TOGGLE, +) from _dependencies.models import DialogState from _dependencies.user_repository import UserRepository @@ -333,7 +339,7 @@ async def on_enable_notifications(event: MessageCallback) -> None: # ─── Region Selection: Federal Districts ───────────────────────────────── -@router.message_callback(PayloadCmd('district_select')) +@router.message_callback(PayloadCmd(CMD_DISTRICT_SELECT)) async def on_district_select(event: MessageCallback) -> None: """Show paginated regions for the selected federal district.""" payload = _parse_payload(event.callback.payload) @@ -367,7 +373,7 @@ async def on_district_select(event: MessageCallback) -> None: # ─── Region Selection: Pagination ──────────────────────────────────────── -@router.message_callback(PayloadCmd('paginate_nav')) +@router.message_callback(PayloadCmd(CMD_PAGINATE_NAV)) async def on_paginate_nav(event: MessageCallback) -> None: """Navigate between pages of regions.""" payload = _parse_payload(event.callback.payload) @@ -398,7 +404,7 @@ async def on_paginate_nav(event: MessageCallback) -> None: # ─── Region Selection: Toggle Subscribe ────────────────────────────────── -@router.message_callback(PayloadCmd('paginate_toggle')) +@router.message_callback(PayloadCmd(CMD_PAGINATE_TOGGLE)) async def on_paginate_toggle(event: MessageCallback) -> None: """Toggle subscription for a region.""" payload = _parse_payload(event.callback.payload) @@ -461,7 +467,7 @@ async def on_paginate_toggle(event: MessageCallback) -> None: ) -@router.message_callback(PayloadCmd('paginate_finish')) +@router.message_callback(PayloadCmd(CMD_PAGINATE_FINISH)) async def on_paginate_finish(event: MessageCallback) -> None: """Finish region selection and return to main menu.""" await event.ack(notification=REGION_SELECTION_DONE) diff --git a/src/max_bot/_utils/keyboards.py b/src/max_bot/_utils/keyboards.py index 73318caa..b8866789 100644 --- a/src/max_bot/_utils/keyboards.py +++ b/src/max_bot/_utils/keyboards.py @@ -16,16 +16,23 @@ ) from maxapi.utils.inline_keyboard import InlineKeyboardBuilder -from _dependencies.common.geo import compact_region_name +from _dependencies.common.geo import ( + CMD_DISTRICT_SELECT, + CMD_PAGINATE_FINISH, + CMD_PAGINATE_NAV, + CMD_PAGINATE_TOGGLE, + FEDERAL_DISTRICTS, + NavButton, + RegionCallbackPayload, + compact_region_name, + paginate_regions, +) class MaxKeyboardButtons: """Button label constants — single source of truth for all button labels.""" # Navigation - BTN_BACK: str = '← назад' - BTN_FINISH: str = 'Завершить' - BTN_NEXT: str = 'ещё →' # Main menu BTN_DISABLE_NOTIFICATIONS: str = 'полностью отключить уведомления' @@ -45,16 +52,6 @@ class MaxKeyboardButtons: BTN_RADIUS_VIEW: str = 'посмотреть радиус' BTN_RADIUS_DELETE: str = 'удалить радиус' - # Federal districts - BTN_DISTRICT_CFO: str = 'Центральный ФО' - BTN_DISTRICT_SZFO: str = 'Северо-Западный ФО' - BTN_DISTRICT_YUFO: str = 'Южный ФО' - BTN_DISTRICT_SKFO: str = 'Северо-Кавказский ФО' - BTN_DISTRICT_PFO: str = 'Приволжский ФО' - BTN_DISTRICT_UFO: str = 'Уральский ФО' - BTN_DISTRICT_SFO: str = 'Сибирский ФО' - BTN_DISTRICT_DFO: str = 'Дальневосточный ФО' - BTN_DISTRICT_OTHER: str = 'Прочие поиски по РФ' class MaxKeyboardPresets(MaxKeyboardButtons): @@ -83,24 +80,13 @@ def main_menu(cls, notifications_disabled: bool = False) -> AttachmentButton: @classmethod def fed_districts_inline(cls) -> AttachmentButton: """Federal districts selection as inline callback buttons.""" - districts = [ - (cls.BTN_DISTRICT_CFO, 'Центральный'), - (cls.BTN_DISTRICT_SZFO, 'Северо-Западный'), - (cls.BTN_DISTRICT_YUFO, 'Южный'), - (cls.BTN_DISTRICT_SKFO, 'Северо-Кавказский'), - (cls.BTN_DISTRICT_PFO, 'Приволжский'), - (cls.BTN_DISTRICT_UFO, 'Уральский'), - (cls.BTN_DISTRICT_SFO, 'Сибирский'), - (cls.BTN_DISTRICT_DFO, 'Дальневосточный'), - (cls.BTN_DISTRICT_OTHER, 'Прочие поиски по РФ'), - ] builder = InlineKeyboardBuilder() - for label, district in districts: - builder.add( - CallbackButton(text=label, payload=json.dumps({'cmd': 'district_select', 'district': district})) - ) + for label, district in FEDERAL_DISTRICTS: + payload = RegionCallbackPayload(CMD_DISTRICT_SELECT, district=district) + builder.add(CallbackButton(text=label, payload=json.dumps(payload.to_dict()))) builder.adjust(2) - builder.row(CallbackButton(text=cls.BTN_FINISH, payload=json.dumps({'cmd': 'paginate_finish'}))) + finish_payload = RegionCallbackPayload(CMD_PAGINATE_FINISH) + builder.row(CallbackButton(text=NavButton.FINISH, payload=json.dumps(finish_payload.to_dict()))) return builder.as_markup() @classmethod @@ -127,50 +113,29 @@ def paginated_regions_inline( page_size: Number of items per page (default: 6). subscribed_ids: Set of region names that are already subscribed. """ - total_pages = max(1, (len(region_buttons) + page_size - 1) // page_size) - start = page * page_size - end = start + page_size - page_items = region_buttons[start:end] + p = paginate_regions(region_buttons, page, page_size) if subscribed_ids is None: subscribed_ids = set() builder = InlineKeyboardBuilder() - for region_name in page_items: + for region_name in p.items: compact_name = compact_region_name(region_name) display_name = f'✓ {compact_name}' if region_name in subscribed_ids else compact_name - builder.add( - CallbackButton( - text=display_name, - payload=json.dumps( - { - 'cmd': 'paginate_toggle', - 'region': region_name, - 'district': district, - 'page': page, - } - ), - ) - ) + payload = RegionCallbackPayload(CMD_PAGINATE_TOGGLE, district=district, region=region_name, page=page) + builder.add(CallbackButton(text=display_name, payload=json.dumps(payload.to_dict()))) builder.adjust(2) # Navigation row nav_builder = InlineKeyboardBuilder() - if page > 0: - nav_builder.add( - CallbackButton( - text=cls.BTN_BACK, - payload=json.dumps({'cmd': 'paginate_nav', 'district': district, 'page': page - 1}), - ) - ) - if page < total_pages - 1: - nav_builder.add( - CallbackButton( - text=cls.BTN_NEXT, - payload=json.dumps({'cmd': 'paginate_nav', 'district': district, 'page': page + 1}), - ) - ) - nav_builder.add(CallbackButton(text=cls.BTN_FINISH, payload=json.dumps({'cmd': 'paginate_finish'}))) + if p.has_prev: + payload = RegionCallbackPayload(CMD_PAGINATE_NAV, district=district, page=page - 1) + nav_builder.add(CallbackButton(text=NavButton.BACK, payload=json.dumps(payload.to_dict()))) + if p.has_next: + payload = RegionCallbackPayload(CMD_PAGINATE_NAV, district=district, page=page + 1) + nav_builder.add(CallbackButton(text=NavButton.NEXT, payload=json.dumps(payload.to_dict()))) + finish_payload = RegionCallbackPayload(CMD_PAGINATE_FINISH) + nav_builder.add(CallbackButton(text=NavButton.FINISH, payload=json.dumps(finish_payload.to_dict()))) builder.row(*nav_builder.payload[0]) return builder.as_markup() @@ -184,7 +149,7 @@ def radius_menu(cls) -> AttachmentButton: .row(CallbackButton(text=cls.BTN_RADIUS_SET, payload=json.dumps({'cmd': 'radius_set'}))) .row(CallbackButton(text=cls.BTN_RADIUS_VIEW, payload=json.dumps({'cmd': 'radius_view'}))) .row(CallbackButton(text=cls.BTN_RADIUS_DELETE, payload=json.dumps({'cmd': 'radius_delete'}))) - .row(CallbackButton(text=cls.BTN_BACK, payload=json.dumps({'cmd': 'back_to_main'}))) + .row(CallbackButton(text=NavButton.BACK, payload=json.dumps({'cmd': 'back_to_main'}))) .as_markup() ) @@ -199,7 +164,7 @@ def coords_menu(cls) -> AttachmentButton: .row(RequestGeoLocationButton(text=cls.BTN_COORDS_SEND_GEO)) .row(CallbackButton(text=cls.BTN_COORDS_VIEW, payload=json.dumps({'cmd': 'coords_view'}))) .row(CallbackButton(text=cls.BTN_COORDS_DELETE, payload=json.dumps({'cmd': 'coords_delete'}))) - .row(CallbackButton(text=cls.BTN_BACK, payload=json.dumps({'cmd': 'back_to_main'}))) + .row(CallbackButton(text=NavButton.BACK, payload=json.dumps({'cmd': 'back_to_main'}))) .as_markup() ) @@ -210,6 +175,6 @@ def back_to_main(cls) -> AttachmentButton: """Single 'back to main menu' button.""" return ( InlineKeyboardBuilder() - .row(CallbackButton(text=cls.BTN_BACK, payload=json.dumps({'cmd': 'back_to_main'}))) + .row(CallbackButton(text=NavButton.BACK, payload=json.dumps({'cmd': 'back_to_main'}))) .as_markup() ) diff --git a/src/max_bot/_utils/message_formatter.py b/src/max_bot/_utils/message_formatter.py index cc2daeb6..1ba0e362 100644 --- a/src/max_bot/_utils/message_formatter.py +++ b/src/max_bot/_utils/message_formatter.py @@ -5,7 +5,7 @@ ``message_formatter.py``. """ -# ─── Welcome & Registration ────────────────────────────────────────────── +from _dependencies.common.geo import REGION_EMOJI_LEGEND WELCOME_TEXT: str = ( 'Привет! Я бот LizaAlert. 👋\n\n' @@ -32,7 +32,7 @@ FED_DISTRICTS_PROMPT: str = 'Выбери федеральный округ, чтобы настроить регионы поисков:' -REGION_EMOJI_LEGEND: str = '🔍 — активные поиски\n' 'ℹ️ — инфорг / поддержка\n' '📅 — мероприятия' +# ─── Welcome & Registration ────────────────────────────────────────────── REGION_LIST_PROMPT: str = ( f'{REGION_EMOJI_LEGEND}\n\n' diff --git a/src/vk_bot/_utils/handlers/onboarding_handlers.py b/src/vk_bot/_utils/handlers/onboarding_handlers.py index afb7d149..efac7257 100644 --- a/src/vk_bot/_utils/handlers/onboarding_handlers.py +++ b/src/vk_bot/_utils/handlers/onboarding_handlers.py @@ -7,6 +7,7 @@ from ..common import VKHandlerContext from ..decorators import vk_handle from ..keyboards import VKKeyboardButtons, VKKeyboardPresets +from _dependencies.common.geo import NavButton from ..services.message_formatter import ( onboarding_completed_message, region_selection_intro, @@ -147,7 +148,7 @@ def handle_main_menu(ctx: VKHandlerContext) -> None: ) -@vk_handle(text=VKKeyboardButtons.BTN_BACK_TO_START) +@vk_handle(text=NavButton.BACK_TO_START) def handle_back_to_start(ctx: VKHandlerContext) -> None: """Handle 'в начало' button — return to main menu.""" ctx.reply( diff --git a/src/vk_bot/_utils/handlers/region_select_handlers.py b/src/vk_bot/_utils/handlers/region_select_handlers.py index a3eca0fd..74f77522 100644 --- a/src/vk_bot/_utils/handlers/region_select_handlers.py +++ b/src/vk_bot/_utils/handlers/region_select_handlers.py @@ -14,7 +14,13 @@ import logging -from _dependencies.common.geo import REGION_EMOJI_LEGEND +from _dependencies.common.geo import ( + CMD_DISTRICT_SELECT, + CMD_PAGINATE_FINISH, + CMD_PAGINATE_NAV, + CMD_PAGINATE_TOGGLE, + REGION_EMOJI_LEGEND, +) from ..common import VKHandlerContext from ..decorators import vk_handle @@ -149,7 +155,7 @@ def _toggle_region_inline(ctx: VKHandlerContext, region_name: str) -> str: return f'Регион "{region_name}" добавлен!' -@vk_handle(callback_data='paginate_nav') +@vk_handle(callback_data=CMD_PAGINATE_NAV) def handle_inline_pagination_nav(ctx: VKHandlerContext) -> None: """Handle inline pagination navigation callback. @@ -200,7 +206,7 @@ def handle_inline_pagination_nav(ctx: VKHandlerContext) -> None: ) -@vk_handle(callback_data='paginate_toggle') +@vk_handle(callback_data=CMD_PAGINATE_TOGGLE) def handle_inline_pagination_toggle(ctx: VKHandlerContext) -> None: """Handle inline pagination region toggle callback. @@ -266,7 +272,7 @@ def handle_inline_pagination_back(ctx: VKHandlerContext) -> None: ) -@vk_handle(callback_data='paginate_finish') +@vk_handle(callback_data=CMD_PAGINATE_FINISH) def handle_inline_pagination_finish(ctx: VKHandlerContext) -> None: """Handle inline pagination finish callback — end region selection.""" payload = ctx.message.payload_as_dict @@ -290,7 +296,7 @@ def handle_inline_pagination_finish(ctx: VKHandlerContext) -> None: ) -@vk_handle(callback_data='district_select') +@vk_handle(callback_data=CMD_DISTRICT_SELECT) def handle_district_select(ctx: VKHandlerContext) -> None: """Handle federal district selection via inline callback. diff --git a/src/vk_bot/_utils/keyboards.py b/src/vk_bot/_utils/keyboards.py index 9300489d..55c7c376 100644 --- a/src/vk_bot/_utils/keyboards.py +++ b/src/vk_bot/_utils/keyboards.py @@ -1,6 +1,16 @@ import json -from _dependencies.common.geo import compact_region_name +from _dependencies.common.geo import ( + CMD_DISTRICT_SELECT, + CMD_PAGINATE_FINISH, + CMD_PAGINATE_NAV, + CMD_PAGINATE_TOGGLE, + FEDERAL_DISTRICTS, + NavButton, + RegionCallbackPayload, + compact_region_name, + paginate_regions, +) from .common import ButtonColor @@ -23,11 +33,7 @@ class VKKeyboardButtons: """ # Navigation - BTN_BACK_TO_START: str = 'в начало' BTN_MORE_SEARCHES: str = 'еще поиски' - BTN_FINISH: str = 'Завершить' - BTN_BACK: str = '← назад' - BTN_NEXT: str = 'ещё →' # Main menu BTN_SETTINGS_BOT: str = 'настроить бот' @@ -124,16 +130,7 @@ class VKKeyboardButtons: BTN_ORDERED: str = 'уже заказал(а)' BTN_ORDER_LATER: str = 'закажу позже' - # Federal districts - BTN_DISTRICT_CFO: str = 'Центральный ФО' - BTN_DISTRICT_SZFO: str = 'Северо-Западный ФО' - BTN_DISTRICT_YUFO: str = 'Южный ФО' - BTN_DISTRICT_SKFO: str = 'Северо-Кавказский ФО' - BTN_DISTRICT_PFO: str = 'Приволжский ФО' - BTN_DISTRICT_UFO: str = 'Уральский ФО' - BTN_DISTRICT_SFO: str = 'Сибирский ФО' - BTN_DISTRICT_DFO: str = 'Дальневосточный ФО' - BTN_DISTRICT_OTHER: str = 'Прочие поиски по РФ' + class VKKeyboardBase: @@ -370,7 +367,7 @@ def settings_menu(cls, notifications_disabled: bool = False) -> dict: cls.BTN_SETTINGS_COORDS, cls.BTN_SETTINGS_RADIUS, delivery_status_button, - cls.BTN_BACK_TO_START, + NavButton.BACK_TO_START, ] ) @@ -382,7 +379,7 @@ def coords_menu(cls) -> dict: cls.BTN_COORDS_ENTER, cls.BTN_COORDS_VIEW, cls.BTN_COORDS_DELETE, - cls.BTN_BACK_TO_START, + NavButton.BACK_TO_START, ] ) @@ -408,7 +405,7 @@ def yes_no(cls) -> dict: @classmethod def back_to_start(cls) -> dict: """Single 'back to start' button.""" - return cls.one_column([cls.BTN_BACK_TO_START]) + return cls.one_column([NavButton.BACK_TO_START]) @classmethod def other_menu(cls) -> dict: @@ -419,7 +416,7 @@ def other_menu(cls) -> dict: cls.BTN_OTHER_FEEDBACK, cls.BTN_OTHER_NEWBIE_INFO, cls.BTN_OTHER_PHOTOS, - cls.BTN_BACK_TO_START, + NavButton.BACK_TO_START, ] ) @@ -431,7 +428,7 @@ def distance_settings(cls) -> dict: cls.BTN_RADIUS_ENABLE, cls.BTN_RADIUS_DISABLE, cls.BTN_RADIUS_CHANGE, - cls.BTN_BACK_TO_START, + NavButton.BACK_TO_START, ] ) @@ -464,27 +461,15 @@ def notification_settings(cls) -> dict: cls.text_button(cls.BTN_NOTIF_FLEXIBLE), cls.text_button(cls.BTN_NOTIF_ALL_OFF, 'negative'), ], - [cls.text_button(cls.BTN_BACK_TO_START)], + [cls.text_button(NavButton.BACK_TO_START)], ], } @classmethod def fed_districts(cls) -> dict: """Federal districts selection (regular text buttons).""" - return cls.one_column( - [ - cls.BTN_DISTRICT_CFO, - cls.BTN_DISTRICT_SZFO, - cls.BTN_DISTRICT_YUFO, - cls.BTN_DISTRICT_SKFO, - cls.BTN_DISTRICT_PFO, - cls.BTN_DISTRICT_UFO, - cls.BTN_DISTRICT_SFO, - cls.BTN_DISTRICT_DFO, - cls.BTN_DISTRICT_OTHER, - cls.BTN_BACK_TO_START, - ] - ) + labels = [label for label, _ in FEDERAL_DISTRICTS] + [NavButton.BACK_TO_START] + return cls.one_column(labels) @classmethod def fed_districts_inline(cls) -> dict: @@ -502,36 +487,15 @@ def fed_districts_inline(cls) -> dict: Returns: An inline VK keyboard dict with district callback buttons. """ - districts = [ - (cls.BTN_DISTRICT_CFO, 'Центральный'), - (cls.BTN_DISTRICT_SZFO, 'Северо-Западный'), - (cls.BTN_DISTRICT_YUFO, 'Южный'), - (cls.BTN_DISTRICT_SKFO, 'Северо-Кавказский'), - (cls.BTN_DISTRICT_PFO, 'Приволжский'), - (cls.BTN_DISTRICT_UFO, 'Уральский'), - (cls.BTN_DISTRICT_SFO, 'Сибирский'), - (cls.BTN_DISTRICT_DFO, 'Дальневосточный'), - (cls.BTN_DISTRICT_OTHER, 'Прочие поиски по РФ'), - ] rows: list[list[dict]] = [] - for i in range(0, len(districts), 2): - label_a, district_a = districts[i] - row = [ - cls.inline_callback_button( - label_a, - {'cmd': 'district_select', 'district': district_a}, - ) - ] - if i + 1 < len(districts): - label_b, district_b = districts[i + 1] - row.append( - cls.inline_callback_button( - label_b, - {'cmd': 'district_select', 'district': district_b}, - ) - ) + for i in range(0, len(FEDERAL_DISTRICTS), 2): + label_a, district_a = FEDERAL_DISTRICTS[i] + row = [cls.inline_callback_button(label_a, RegionCallbackPayload(CMD_DISTRICT_SELECT, district=district_a).to_dict())] + if i + 1 < len(FEDERAL_DISTRICTS): + label_b, district_b = FEDERAL_DISTRICTS[i + 1] + row.append(cls.inline_callback_button(label_b, RegionCallbackPayload(CMD_DISTRICT_SELECT, district=district_b).to_dict())) rows.append(row) - rows.append([cls.inline_callback_button(cls.BTN_FINISH, {'cmd': 'paginate_finish'})]) + rows.append([cls.inline_callback_button(NavButton.FINISH, RegionCallbackPayload(CMD_PAGINATE_FINISH).to_dict())]) cls.validate_rows(rows, inline=True) return {'inline': True, 'buttons': rows} @@ -560,7 +524,7 @@ def age_settings(cls) -> dict: cls.BTN_AGE_TEENS, cls.BTN_AGE_ADULTS, cls.BTN_AGE_ELDERLY, - cls.BTN_BACK_TO_START, + NavButton.BACK_TO_START, ] ) @@ -571,7 +535,7 @@ def topic_type_settings(cls) -> dict: [ cls.BTN_TYPE_SEARCH, cls.BTN_TYPE_INFO, - cls.BTN_BACK_TO_START, + NavButton.BACK_TO_START, ] ) @@ -583,7 +547,7 @@ def region_actions(cls, region_name: str, is_subscribed: bool) -> dict: [ action_btn, cls.BTN_REGION_CHOOSE_OTHER, - cls.BTN_BACK_TO_START, + NavButton.BACK_TO_START, ] ) @@ -595,13 +559,13 @@ def radius_settings(cls, has_radius: bool = False) -> dict: [ cls.BTN_RADIUS_EDIT, cls.BTN_RADIUS_DISABLE, - cls.BTN_BACK_TO_START, + NavButton.BACK_TO_START, ] ) return cls.one_column( [ cls.BTN_RADIUS_ENABLE, - cls.BTN_BACK_TO_START, + NavButton.BACK_TO_START, ] ) @@ -616,7 +580,7 @@ def forum_linking(cls) -> dict: return cls.one_column( [ cls.BTN_FORUM_ENTER_NICK, - cls.BTN_BACK_TO_START, + NavButton.BACK_TO_START, ] ) @@ -626,7 +590,7 @@ def vk_linking(cls) -> dict: return cls.one_column( [ cls.BTN_VK_LINK, - cls.BTN_BACK_TO_START, + NavButton.BACK_TO_START, ] ) @@ -660,7 +624,7 @@ def search_view_menu(cls) -> dict: cls.BTN_SEARCH_ACTIVE, cls.BTN_SEARCH_LAST_20, cls.BTN_SEARCH_FOLLOW_MGMT, - cls.BTN_BACK_TO_START, + NavButton.BACK_TO_START, ] ) @@ -672,7 +636,7 @@ def search_follow_menu(cls) -> dict: cls.BTN_FOLLOW_ENABLE, cls.BTN_FOLLOW_DISABLE, cls.BTN_FOLLOW_SHOW, - cls.BTN_BACK_TO_START, + NavButton.BACK_TO_START, ] ) @@ -684,7 +648,7 @@ def search_actions(cls, topic_id: int) -> dict: f'+{topic_id} — следить', f'-{topic_id} — игнорировать', cls.BTN_MORE_SEARCHES, - cls.BTN_BACK_TO_START, + NavButton.BACK_TO_START, ] ) @@ -695,7 +659,7 @@ def search_navigation(cls) -> dict: [ cls.BTN_MORE_SEARCHES, cls.BTN_FOLLOW_MANAGE, - cls.BTN_BACK_TO_START, + NavButton.BACK_TO_START, ] ) @@ -733,59 +697,30 @@ def paginated_regions_inline( Returns: An inline VK keyboard dict with region + navigation buttons. """ - total_pages = (len(region_buttons) + page_size - 1) // page_size - start = page * page_size - end = start + page_size - page_items = region_buttons[start:end] + p = paginate_regions(region_buttons, page, page_size) if selected_regions is None: selected_regions = set() - compacted = [(compact_region_name(name), name) for name in page_items] + compacted = [(compact_region_name(name), name) for name in p.items] rows: list[list[dict]] = [] for i in range(0, len(compacted), 2): row: list[dict] = [] for compact_name, original_name in compacted[i : i + 2]: btn_color: ButtonColor = 'primary' if original_name in selected_regions else 'secondary' - row.append( - cls.inline_callback_button( - compact_name, - { - 'cmd': 'paginate_toggle', - 'region': original_name, - 'district': district, - 'page': page, - }, - color=btn_color, - ) - ) + payload = RegionCallbackPayload(CMD_PAGINATE_TOGGLE, district=district, region=original_name, page=page) + row.append(cls.inline_callback_button(compact_name, payload.to_dict(), color=btn_color)) rows.append(row) bottom_row: list[dict] = [] - if page > 0: - bottom_row.append( - cls.inline_callback_button( - cls.BTN_BACK, - { - 'cmd': 'paginate_nav', - 'district': district, - 'page': page - 1, - }, - ) - ) - if page < total_pages - 1: - bottom_row.append( - cls.inline_callback_button( - cls.BTN_NEXT, - { - 'cmd': 'paginate_nav', - 'district': district, - 'page': page + 1, - }, - ) - ) - bottom_row.append(cls.inline_callback_button(cls.BTN_FINISH, {'cmd': 'paginate_finish'})) + if p.has_prev: + payload = RegionCallbackPayload(CMD_PAGINATE_NAV, district=district, page=page - 1) + bottom_row.append(cls.inline_callback_button(NavButton.BACK, payload.to_dict())) + if p.has_next: + payload = RegionCallbackPayload(CMD_PAGINATE_NAV, district=district, page=page + 1) + bottom_row.append(cls.inline_callback_button(NavButton.NEXT, payload.to_dict())) + bottom_row.append(cls.inline_callback_button(NavButton.FINISH, RegionCallbackPayload(CMD_PAGINATE_FINISH).to_dict())) rows.append(bottom_row) cls.validate_rows(rows, inline=True) From c3c0854a610f46ec3a712eb30ab01358761a3c85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D1=82?= Date: Fri, 10 Jul 2026 12:09:21 +0500 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20review=20fixes=20for=20geo-common=20?= =?UTF-8?q?=E2=80=94=20remove=20dead=20code,=20use=20shared=20constants=20?= =?UTF-8?q?in=20VK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused NavButton.CHOOSE_OTHER, NavButton.FED_DIST_PICK_OTHER - Remove accidental '✅ завершённые поиски' from emoji legend/suffix map - Remove dead code: REGION_SELECTION_INTRO, REGION_LIST_PROMPT, district_prompt() - Fix REGION_TOGGLED_OFF_FMT: удалён → удален (match VK existing text) - Derive VK _KNOWN_DISTRICTS from FEDERAL_DISTRICTS (single source of truth) - Replace hardcoded strings in VK region_select_handlers with shared constants - Fix misplaced section comment in MAX message_formatter.py --- src/_dependencies/common/geo.py | 35 +----------------- src/max_bot/_utils/keyboards.py | 1 - src/max_bot/_utils/message_formatter.py | 2 +- .../_utils/handlers/onboarding_handlers.py | 3 +- .../_utils/handlers/region_select_handlers.py | 37 +++++++++---------- src/vk_bot/_utils/keyboards.py | 23 ++++++++---- 6 files changed, 38 insertions(+), 63 deletions(-) diff --git a/src/_dependencies/common/geo.py b/src/_dependencies/common/geo.py index b7f62a3a..8ec9c02e 100644 --- a/src/_dependencies/common/geo.py +++ b/src/_dependencies/common/geo.py @@ -45,8 +45,6 @@ class NavButton: NEXT: str = 'ещё →' FINISH: str = 'Завершить' BACK_TO_START: str = 'в начало' - CHOOSE_OTHER: str = 'выбрать другой регион' - FED_DIST_PICK_OTHER: str = 'выбрать другой Федеральный Округ' # ═══════════════════════════════════════════════════════════════════════════════ @@ -95,7 +93,6 @@ def get_fed_district_label(normalised: str) -> str | None: _SUFFIX_TO_EMOJI: dict[str, str] = { ' – Активные поиски': '🔍', - ' – Завершенные поиски': '✅', ' – Инфо поддержка': 'ℹ️', ' – Мероприятия': '📅', } @@ -104,9 +101,7 @@ def get_fed_district_label(normalised: str) -> str | None: # Emoji legend shown above region selection keyboards so users understand # what each emoji marker means. -REGION_EMOJI_LEGEND: str = ( - '🔍 — активные поиски\n' '✅ — завершённые поиски\n' 'ℹ️ — инфорг / поддержка\n' '📅 — мероприятия' -) +REGION_EMOJI_LEGEND: str = '🔍 — активные поиски\n' 'ℹ️ — инфорг / поддержка\n' '📅 — мероприятия' def compact_region_name(name: str) -> str: @@ -209,37 +204,11 @@ def paginate_regions(regions: list[str], page: int, page_size: int = 6) -> PageI # Common text messages # ═══════════════════════════════════════════════════════════════════════════════ -REGION_SELECTION_INTRO: str = ( - 'Выберите федеральный округ, чтобы увидеть список регионов.\n\n' - 'Нажмите на регион, чтобы подписаться или отписаться.' -) - -REGION_LIST_PROMPT: str = ( - '{district_text}\n\n' '{emoji_legend}\n\n' 'Нажмите на регион, чтобы подписаться или отписаться.' -) - REGION_SELECTION_DONE: str = '✅ Выбор региона завершён.' REGION_CANNOT_REMOVE_LAST: str = 'Нельзя удалить последний регион.' REGION_TOGGLED_ON: str = 'Регион добавлен!' REGION_TOGGLED_OFF: str = 'Регион удалён.' REGION_TOGGLED_ON_FMT: str = 'Регион "{}" добавлен!' -REGION_TOGGLED_OFF_FMT: str = 'Регион "{}" удалён.' +REGION_TOGGLED_OFF_FMT: str = 'Регион "{}" удален.' REGION_NOT_FOUND: str = 'Регион не найден.' REGION_ERROR: str = 'Произошла ошибка. Попробуйте позже.' - - -def district_prompt(district_normalised: str) -> str: - """Return the full prompt for region selection within a district. - - Args: - district_normalised: Normalised district name (e.g. ``'Центральный'``). - - Returns: - A user-facing message including the district name and emoji legend. - """ - label = get_fed_district_label(district_normalised) or district_normalised - return ( - f'Выберите регион в округе "{label}":\n\n' - f'{REGION_EMOJI_LEGEND}\n\n' - 'Нажмите на регион, чтобы подписаться или отписаться.' - ) diff --git a/src/max_bot/_utils/keyboards.py b/src/max_bot/_utils/keyboards.py index b8866789..d548ecd8 100644 --- a/src/max_bot/_utils/keyboards.py +++ b/src/max_bot/_utils/keyboards.py @@ -53,7 +53,6 @@ class MaxKeyboardButtons: BTN_RADIUS_DELETE: str = 'удалить радиус' - class MaxKeyboardPresets(MaxKeyboardButtons): """Preset keyboard builders for the MAX bot.""" diff --git a/src/max_bot/_utils/message_formatter.py b/src/max_bot/_utils/message_formatter.py index 1ba0e362..081632ac 100644 --- a/src/max_bot/_utils/message_formatter.py +++ b/src/max_bot/_utils/message_formatter.py @@ -32,7 +32,7 @@ FED_DISTRICTS_PROMPT: str = 'Выбери федеральный округ, чтобы настроить регионы поисков:' -# ─── Welcome & Registration ────────────────────────────────────────────── +# ─── Region List ──────────────────────────────────────────────────────── REGION_LIST_PROMPT: str = ( f'{REGION_EMOJI_LEGEND}\n\n' diff --git a/src/vk_bot/_utils/handlers/onboarding_handlers.py b/src/vk_bot/_utils/handlers/onboarding_handlers.py index efac7257..55fa8a74 100644 --- a/src/vk_bot/_utils/handlers/onboarding_handlers.py +++ b/src/vk_bot/_utils/handlers/onboarding_handlers.py @@ -4,10 +4,11 @@ matching button text or command patterns. """ +from _dependencies.common.geo import NavButton + from ..common import VKHandlerContext from ..decorators import vk_handle from ..keyboards import VKKeyboardButtons, VKKeyboardPresets -from _dependencies.common.geo import NavButton from ..services.message_formatter import ( onboarding_completed_message, region_selection_intro, diff --git a/src/vk_bot/_utils/handlers/region_select_handlers.py b/src/vk_bot/_utils/handlers/region_select_handlers.py index 74f77522..eb2eb968 100644 --- a/src/vk_bot/_utils/handlers/region_select_handlers.py +++ b/src/vk_bot/_utils/handlers/region_select_handlers.py @@ -19,7 +19,14 @@ CMD_PAGINATE_FINISH, CMD_PAGINATE_NAV, CMD_PAGINATE_TOGGLE, + FEDERAL_DISTRICTS, + REGION_CANNOT_REMOVE_LAST, REGION_EMOJI_LEGEND, + REGION_ERROR, + REGION_NOT_FOUND, + REGION_SELECTION_DONE, + REGION_TOGGLED_OFF_FMT, + REGION_TOGGLED_ON_FMT, ) from ..common import VKHandlerContext @@ -30,18 +37,8 @@ settings_menu_intro, ) -# Known federal districts from VKKeyboardPresets.fed_districts() -_KNOWN_DISTRICTS = [ - 'центральный фо', - 'северо-западный фо', - 'южный фо', - 'северо-кавказский фо', - 'приволжский фо', - 'уральский фо', - 'сибирский фо', - 'дальневосточный фо', - 'прочие поиски по рф', -] +# Derived from FEDERAL_DISTRICTS — single source of truth +_KNOWN_DISTRICTS = [label.lower() for label, _ in FEDERAL_DISTRICTS] def _get_district_name(text: str) -> str | None: @@ -121,7 +118,7 @@ def _toggle_region_inline(ctx: VKHandlerContext, region_name: str) -> str: """ folders = ctx.db.get_geo_folders() if not folders: - return 'Произошла ошибка. Попробуйте позже.' + return REGION_ERROR folder_dict: dict[str, tuple[int, ...]] = {} for fid, name in folders: @@ -129,7 +126,7 @@ def _toggle_region_inline(ctx: VKHandlerContext, region_name: str) -> str: folder_dict[name] = folder_dict.get(name, ()) + (fid,) if region_name not in folder_dict: - return 'Регион не найден.' + return REGION_NOT_FOUND # Check current subscription state region_folder_ids = folder_dict[region_name] @@ -141,18 +138,18 @@ def _toggle_region_inline(ctx: VKHandlerContext, region_name: str) -> str: result = ctx.db.toggle_region_by_name(ctx.user_id, region_name, folder_dict) except Exception: logging.exception(f'Failed to toggle region for user {ctx.user_id}: {region_name}') - return 'Произошла ошибка. Попробуйте позже.' + return REGION_ERROR if result is False: - return 'Нельзя удалить последний регион.' - return f'Регион "{region_name}" удален.' + return REGION_CANNOT_REMOVE_LAST + return REGION_TOGGLED_OFF_FMT.format(region_name) else: try: ctx.db.toggle_region_by_name(ctx.user_id, region_name, folder_dict) except Exception: logging.exception(f'Failed to toggle region for user {ctx.user_id}: {region_name}') - return 'Произошла ошибка. Попробуйте позже.' - return f'Регион "{region_name}" добавлен!' + return REGION_ERROR + return REGION_TOGGLED_ON_FMT.format(region_name) @vk_handle(callback_data=CMD_PAGINATE_NAV) @@ -285,7 +282,7 @@ def handle_inline_pagination_finish(ctx: VKHandlerContext) -> None: ctx.answer_callback() empty_keyboard = {'inline': True, 'buttons': []} ctx.edit( - text='✅ Выбор региона завершён.', + text=REGION_SELECTION_DONE, keyboard=empty_keyboard, conversation_message_id=conversation_message_id, message_id=message_id, diff --git a/src/vk_bot/_utils/keyboards.py b/src/vk_bot/_utils/keyboards.py index 55c7c376..7edf787c 100644 --- a/src/vk_bot/_utils/keyboards.py +++ b/src/vk_bot/_utils/keyboards.py @@ -22,7 +22,6 @@ _MAX_INLINE_BUTTONS = 10 - class VKKeyboardButtons: """Mixin providing button label constants. @@ -131,8 +130,6 @@ class VKKeyboardButtons: BTN_ORDER_LATER: str = 'закажу позже' - - class VKKeyboardBase: """Low-level keyboard building blocks: validation and primitive button builders. @@ -490,12 +487,22 @@ def fed_districts_inline(cls) -> dict: rows: list[list[dict]] = [] for i in range(0, len(FEDERAL_DISTRICTS), 2): label_a, district_a = FEDERAL_DISTRICTS[i] - row = [cls.inline_callback_button(label_a, RegionCallbackPayload(CMD_DISTRICT_SELECT, district=district_a).to_dict())] + row = [ + cls.inline_callback_button( + label_a, RegionCallbackPayload(CMD_DISTRICT_SELECT, district=district_a).to_dict() + ) + ] if i + 1 < len(FEDERAL_DISTRICTS): label_b, district_b = FEDERAL_DISTRICTS[i + 1] - row.append(cls.inline_callback_button(label_b, RegionCallbackPayload(CMD_DISTRICT_SELECT, district=district_b).to_dict())) + row.append( + cls.inline_callback_button( + label_b, RegionCallbackPayload(CMD_DISTRICT_SELECT, district=district_b).to_dict() + ) + ) rows.append(row) - rows.append([cls.inline_callback_button(NavButton.FINISH, RegionCallbackPayload(CMD_PAGINATE_FINISH).to_dict())]) + rows.append( + [cls.inline_callback_button(NavButton.FINISH, RegionCallbackPayload(CMD_PAGINATE_FINISH).to_dict())] + ) cls.validate_rows(rows, inline=True) return {'inline': True, 'buttons': rows} @@ -720,7 +727,9 @@ def paginated_regions_inline( if p.has_next: payload = RegionCallbackPayload(CMD_PAGINATE_NAV, district=district, page=page + 1) bottom_row.append(cls.inline_callback_button(NavButton.NEXT, payload.to_dict())) - bottom_row.append(cls.inline_callback_button(NavButton.FINISH, RegionCallbackPayload(CMD_PAGINATE_FINISH).to_dict())) + bottom_row.append( + cls.inline_callback_button(NavButton.FINISH, RegionCallbackPayload(CMD_PAGINATE_FINISH).to_dict()) + ) rows.append(bottom_row) cls.validate_rows(rows, inline=True)