Fix text dialog routing and club card registration recovery
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
54
src/utils/account_input.py
Normal file
54
src/utils/account_input.py
Normal 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
|
||||
@@ -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]:
|
||||
|
||||
47
src/utils/input_validation.py
Normal file
47
src/utils/input_validation.py
Normal 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
12
src/utils/text_output.py
Normal 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)
|
||||
Reference in New Issue
Block a user