Fix text dialog routing and club card registration recovery
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-09-13 21:39:40 +09:00
parent 3c1a6a02a5
commit f3f8d0f737
28 changed files with 788 additions and 334 deletions

View File

@@ -0,0 +1,54 @@
"""Parse each account record independently, preserving card/account associations."""
import re
ACCOUNT = re.compile(r"(?<![\w-])(?:[0-9]{2}(?:[- ][0-9]{2}){6}|[0-9]{14})(?![\w-])")
CARD = re.compile(r"[0-9]{1,50}")
def parse_account_records(text):
from .account_utils import format_account_number
lines = [line.strip() for line in text.splitlines() if line.strip()]
records = []
if any(line.lower().startswith("viposnova") for line in lines):
current = []
for line in lines:
if line.lower().startswith("viposnova") and current:
records.append(" ".join(current))
current = []
current.append(line)
if current:
records.append(" ".join(current))
else:
i = 0
while i < len(lines):
record = lines[i]
# A bare account followed by a card on the next line is one record.
if ACCOUNT.fullmatch(record) and i + 1 < len(lines) and CARD.fullmatch(lines[i + 1]) and not ACCOUNT.fullmatch(lines[i + 1]):
record += " " + lines[i + 1]
i += 1
records.append(record)
i += 1
entries, errors = [], []
for index, record in enumerate(records, 1):
matches = list(ACCOUNT.finditer(record))
if not matches:
errors.append(f"Запись {index}: номер счёта не распознан; нужно 14 цифр.")
continue
for match in matches:
account = format_account_number(match.group())
card = None
if len(matches) == 1:
prefix = record[:match.start()].strip()
suffix = record[match.end():].strip()
if CARD.fullmatch(prefix):
card = prefix
elif CARD.fullmatch(suffix):
card = suffix
elif record.lower().startswith("viposnova"):
candidates = [part for part in suffix.split() if CARD.fullmatch(part)]
card = candidates[-1] if candidates else None
entry = f"{card} {account}" if card else account
if entry not in entries:
entries.append(entry)
return entries, errors

View File

@@ -98,109 +98,9 @@ def mask_account_number(account_number: str, show_last_digits: int = 4) -> str:
def parse_accounts_from_message(text: str) -> List[str]:
"""
Извлекает все валидные номера счетов из текста сообщения.
Поддерживает формат: "КАРТА СЧЕТ" (например "2521 11-22-33-44-55-66-77")
или просто "СЧЕТ" (например "11-22-33-44-55-66-77")
Также обрабатывает многострочный текст из кабинета:
Запись начинается со слова "Viposnova" и содержит несколько строк до следующего "Viposnova":
"Viposnova 16-11-2025 22:19:36
17-24-66-42-38-31-53
0.00 2918"
Args:
text: Текст сообщения
Returns:
List[str]: Список найденных строк (может включать номер карты и счета через пробел)
"""
if not text:
return []
accounts = []
# Группируем строки по записям (от "Viposnova" до следующего "Viposnova")
lines = text.strip().split('\n')
current_record = []
records = []
for line in lines:
stripped = line.strip()
# Если строка начинается с Viposnova и у нас уже есть текущая запись - сохраняем её
if stripped.startswith('Viposnova') and current_record:
records.append(' '.join(current_record))
current_record = [stripped]
else:
current_record.append(stripped)
# Добавляем последнюю запись
if current_record:
records.append(' '.join(current_record))
# Обрабатываем каждую запись
for record in records:
parts = record.split()
# Ищем счет в записи
account_number = None
account_idx = None
for i, part in enumerate(parts):
if re.match(r'^\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}$', part):
account_number = format_account_number(part)
account_idx = i
break
if not account_number or account_number in accounts:
continue
# Ищем клубную карту (4-значное число после счета)
card = None
if account_idx is not None:
for j in range(account_idx + 1, len(parts)):
if re.match(r'^\d{4}$', parts[j]):
card = parts[j]
break
# Добавляем результат
if card:
full_account = f"{card} {account_number}"
if full_account not in accounts:
accounts.append(full_account)
else:
accounts.append(account_number)
# Если построчная обработка ничего не нашла, используем старый метод
if not accounts:
# Паттерн 1: номер карты (4 цифры) + пробел + счет (7 пар цифр)
pattern_with_card = r'(\d{4})\s+(\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2})'
# Находим все совпадения с картой и удаляем их из текста
text_copy = text
for match in re.finditer(pattern_with_card, text):
card = match.group(1)
account = match.group(2)
formatted = format_account_number(account)
if formatted:
full_account = f"{card} {formatted}"
if full_account not in accounts:
accounts.append(full_account)
# Удаляем это совпадение из копии текста, чтобы не найти повторно
text_copy = text_copy.replace(match.group(0), ' ' * len(match.group(0)))
# Паттерн 2: только счет (7 пар цифр) в оставшемся тексте
pattern_only_account = r'\b\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}\b'
matches_only = re.findall(pattern_only_account, text_copy)
for match in matches_only:
formatted = format_account_number(match)
if formatted and formatted not in accounts:
# Дополнительная проверка - этот счет не должен быть частью уже найденных "карта + счет"
is_duplicate = any(formatted in acc for acc in accounts)
if not is_duplicate:
accounts.append(formatted)
return accounts
"""Return all recognized records, preserving card numbers and leading zeroes."""
from .account_input import parse_account_records
return parse_account_records(text or "")[0]
def search_accounts_by_pattern(pattern: str, account_list: List[str]) -> List[str]:

View File

@@ -0,0 +1,47 @@
"""Validation shared by dialog steps and database services."""
import re
def club_card(value):
value = value.strip() if isinstance(value, str) else ""
if not re.fullmatch(r"[0-9]{1,50}", value):
raise ValueError("Номер клубной карты должен содержать от 1 до 50 цифр без букв и разделителей.")
return value # Leading zeroes are part of the card number.
def phone_number(value):
if value is None or value.strip() == "-":
return None
value = value.strip()
if not value or len(value) > 20 or not re.fullmatch(r"\+?[0-9() -]+", value) or not re.search(r"[0-9]", value):
raise ValueError("Введите телефон: цифры, при необходимости +, пробелы, скобки или дефисы; не более 20 символов. Для пропуска отправьте '-'.")
return value
def numeric_id(value, *, maximum=2**63 - 1, signed=False):
value = str(value).strip()
pattern = r"-?[0-9]{1,19}" if signed else r"[0-9]{1,19}"
if not re.fullmatch(pattern, value) or not 0 < abs(int(value)) <= maximum:
raise ValueError("Некорректный ID: требуется число в допустимом диапазоне.")
return int(value)
def lottery_field(field, value):
if field == "title":
if not isinstance(value, str) or not 1 <= len(value.strip()) <= 500:
raise ValueError("Название должно содержать от 1 до 500 символов.")
return value.strip()
if field == "description":
if value is None or value == "" or value.strip() == "-":
return None
if not value.strip() or len(value) > 4096:
raise ValueError("Введите описание до 4096 символов или '-' для пропуска.")
return value.strip()
if field == "prizes":
prizes = [line.strip() for line in value.splitlines() if line.strip()] if isinstance(value, str) else value
if not isinstance(prizes, list) or not 1 <= len(prizes) <= 100 or any(
not isinstance(prize, str) or not 1 <= len(prize.strip()) <= 500 for prize in prizes
):
raise ValueError("Введите от 1 до 100 призов, каждый с новой строки и не длиннее 500 символов.")
return [prize.strip() for prize in prizes]
raise ValueError("Неизвестное поле розыгрыша.")

12
src/utils/text_output.py Normal file
View File

@@ -0,0 +1,12 @@
"""Long plain-text form previews keep their action buttons on the last page."""
async def answer_plain_pages(message, text, **kwargs):
# A Python character occupies at most two Telegram UTF-16 units.
# Plain text needs neither entity splitting nor HTML tag repair.
while len(text) > 2000:
end = text.rfind("\n", 0, 2001)
end = end + 1 if end > 0 else 2000
await message.answer(text[:end], parse_mode=None)
text = text[end:]
return await message.answer(text, parse_mode=None, **kwargs)