From c9d5f1e2ea578744e9f32c48c835b25c97ce0f50 Mon Sep 17 00:00:00 2001 From: Artem Tsyrulnikov Date: Sat, 28 Feb 2026 13:06:30 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=B2=D0=B2=D0=BE=D0=B4=20=D0=B4=D0=B0?= =?UTF-8?q?=D1=82=D1=8B,=20=D0=B2=D0=B2=D0=BE=D0=B4=20=D1=84=D0=BE=D1=80?= =?UTF-8?q?=D0=BC=D0=B0=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- migrations/models/18_20260228130403_update.py | 165 ++++++++ src/domain/__init__.py | 8 + src/domain/placement.py | 204 ++++++++- src/dto/purchase.py | 4 + .../placement/update_post_status_cycle.py | 2 +- .../purchase/build_placement_creative.py | 7 +- src/usecase/purchase/create_placements.py | 35 +- src/usecase/purchase/update_placement.py | 22 +- tests/test_format_parsing.py | 162 ++++++++ tg_bot/backend/backend_client.go | 2 + tg_bot/go.mod | 3 + tg_bot/go.sum | 5 + tg_bot/screens/purchase_optional_details.go | 393 +++++++++++++++++- tg_bot/screens/ui/date_time_picker.go | 212 ++-------- 14 files changed, 1012 insertions(+), 212 deletions(-) create mode 100644 migrations/models/18_20260228130403_update.py create mode 100644 tests/test_format_parsing.py diff --git a/migrations/models/18_20260228130403_update.py b/migrations/models/18_20260228130403_update.py new file mode 100644 index 0000000..bf0b1a6 --- /dev/null +++ b/migrations/models/18_20260228130403_update.py @@ -0,0 +1,165 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "placement" ADD "feed_time_minutes" INT; + ALTER TABLE "placement" ADD "top_time_minutes" INT; + + -- Migrate known patterns: "X / Y" where X is top hours, Y is feed hours + -- Pattern: "1 / 24", "1/24" + UPDATE "placement" SET top_time_minutes = 60, feed_time_minutes = 1440 + WHERE format IS NOT NULL AND lower(trim(format)) ~ '^1\\s*/\\s*24$'; + + -- Pattern: "1 / 36", "1/36" + UPDATE "placement" SET top_time_minutes = 60, feed_time_minutes = 2160 + WHERE format IS NOT NULL AND lower(trim(format)) ~ '^1\\s*/\\s*36$'; + + -- Pattern: "1 / 48", "1/48" + UPDATE "placement" SET top_time_minutes = 60, feed_time_minutes = 2880 + WHERE format IS NOT NULL AND lower(trim(format)) ~ '^1\\s*/\\s*48$'; + + -- Pattern: "1 / 72", "1/72" + UPDATE "placement" SET top_time_minutes = 60, feed_time_minutes = 4320 + WHERE format IS NOT NULL AND lower(trim(format)) ~ '^1\\s*/\\s*72$'; + + -- Pattern: "2 / 24", "2/24" + UPDATE "placement" SET top_time_minutes = 120, feed_time_minutes = 1440 + WHERE format IS NOT NULL AND lower(trim(format)) ~ '^2\\s*/\\s*24$'; + + -- Pattern: "2 / 36", "2/36" + UPDATE "placement" SET top_time_minutes = 120, feed_time_minutes = 2160 + WHERE format IS NOT NULL AND lower(trim(format)) ~ '^2\\s*/\\s*36$'; + + -- Pattern: "2 / 48", "2/48" + UPDATE "placement" SET top_time_minutes = 120, feed_time_minutes = 2880 + WHERE format IS NOT NULL AND lower(trim(format)) ~ '^2\\s*/\\s*48$'; + + -- Pattern: "2 / 72", "2/72" + UPDATE "placement" SET top_time_minutes = 120, feed_time_minutes = 4320 + WHERE format IS NOT NULL AND lower(trim(format)) ~ '^2\\s*/\\s*72$'; + + -- Pattern: "X / (N дней)" or "X / (N дн)" + UPDATE "placement" SET + top_time_minutes = CASE + WHEN substring(lower(trim(format)) from '^(\\d+)\\s*/') ~ '^\\d+$' + AND (substring(lower(trim(format)) from '^(\\d+)\\s*/'))::int <= 12 + THEN (substring(lower(trim(format)) from '^(\\d+)\\s*/'))::int * 60 + ELSE (substring(lower(trim(format)) from '^(\\d+)\\s*/'))::int + END, + feed_time_minutes = (substring(lower(trim(format)) from '(\\d+)\\s*дн'))::int * 24 * 60 + WHERE format IS NOT NULL + AND top_time_minutes IS NULL + AND lower(trim(format)) ~ '^\\d+\\s*/.*\\d+\\s*дн'; + + -- Pattern: "X / (без удаления)" or "X / без удаления" + UPDATE "placement" SET + top_time_minutes = CASE + WHEN substring(lower(trim(format)) from '^(\\d+)\\s*/') ~ '^\\d+$' + AND (substring(lower(trim(format)) from '^(\\d+)\\s*/'))::int <= 12 + THEN (substring(lower(trim(format)) from '^(\\d+)\\s*/'))::int * 60 + ELSE (substring(lower(trim(format)) from '^(\\d+)\\s*/'))::int + END, + feed_time_minutes = 0 + WHERE format IS NOT NULL + AND top_time_minutes IS NULL + AND lower(trim(format)) ~ 'без удаления'; + """ + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "placement" DROP COLUMN IF EXISTS "feed_time_minutes"; + ALTER TABLE "placement" DROP COLUMN IF EXISTS "top_time_minutes";""" + + +MODELS_STATE = ( + "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" +) diff --git a/src/domain/__init__.py b/src/domain/__init__.py index 8fe2ba2..f521ad3 100644 --- a/src/domain/__init__.py +++ b/src/domain/__init__.py @@ -20,6 +20,10 @@ __all__ = ( 'PlacementStatus', 'PlacementType', 'parse_format_duration', + 'parse_format_string', + 'format_display_string', + 'get_feed_duration_seconds', + 'COMMON_FORMATS', 'PlacementPost', 'PlacementPostStatus', 'Creative', @@ -116,12 +120,16 @@ from .error import ( ) from .login_token import LoginToken from .placement import ( + COMMON_FORMATS, CostType, InviteLinkType, Placement, PlacementStatus, PlacementType, + format_display_string, + get_feed_duration_seconds, parse_format_duration, + parse_format_string, ) from .placement_post import PlacementPost, PlacementPostStatus from .post import Post diff --git a/src/domain/placement.py b/src/domain/placement.py index becb114..80e49dc 100644 --- a/src/domain/placement.py +++ b/src/domain/placement.py @@ -12,7 +12,18 @@ if TYPE_CHECKING: from .creative import Creative from .project import Project -__all__ = ['Placement', 'PlacementStatus', 'PlacementType', 'InviteLinkType', 'CostType', 'parse_format_duration'] +__all__ = [ + 'Placement', + 'PlacementStatus', + 'PlacementType', + 'InviteLinkType', + 'CostType', + 'parse_format_duration', + 'parse_format_string', + 'format_display_string', + 'get_feed_duration_seconds', + 'COMMON_FORMATS', +] class CostType(enum.StrEnum): @@ -43,9 +54,141 @@ class InviteLinkType(enum.StrEnum): APPROVAL = 'approval' # с одобрением ботом +def parse_format_string(format_str: str | None) -> tuple[int | None, int | None]: + """ + Парсит строку формата размещения в числовые значения (минуты). + + Разделяет строку по '/' на 2 части (top, feed). + Top: число <= 12 → часы (*60), > 12 → минуты (для миграции старых данных). + Feed: "без удаления" → 0; "Xд"/"X дней" → дни (*24*60); число → часы (*60). + + Returns: + (top_time_minutes, feed_time_minutes) — (None, None) если не распознано + """ + if not format_str: + return None, None + + format_str = format_str.strip().lower() + + if not format_str: + return None, None + + # Проверяем наличие разделителя + if '/' not in format_str: + return None, None + + parts = format_str.split('/', 1) + if len(parts) != 2: + return None, None + + top_str = parts[0].strip().strip('(').strip(')') + feed_str = parts[1].strip().strip('(').strip(')') + + # Парсим top + top_minutes = _parse_top_part(top_str) + if top_minutes is None: + return None, None + + # Парсим feed + feed_minutes = _parse_feed_part(feed_str) + if feed_minutes is None: + return None, None + + return top_minutes, feed_minutes + + +def _parse_top_part(s: str) -> int | None: + """Парсит часть top из строки формата.""" + s = s.strip() + # Убираем суффиксы единиц + s = re.sub(r'\s*(ч|час|часов|часа|мин|минут|минуты)\s*$', '', s) + s = s.strip() + + match = re.match(r'^(\d+)$', s) + if not match: + return None + + value = int(match.group(1)) + if value <= 0: + return None + + # <= 12 → часы, > 12 → минуты (для обратной совместимости со старыми данными) + if value <= 12: + return value * 60 + return value + + +def _parse_feed_part(s: str) -> int | None: + """Парсит часть feed из строки формата. Возвращает минуты, 0 = без удаления.""" + s = s.strip() + + # "без удаления" + if 'без удаления' in s: + return 0 + + # Ищем дни: "7д", "7 дней", "7 дн", "(7 дней)" + days_match = re.search(r'(\d+)\s*(?:д(?:н|ней|ня)?)', s) + if days_match: + days = int(days_match.group(1)) + if days <= 0: + return None + return days * 24 * 60 + + # Ищем часы: "24ч", "24 часов", или просто число + s = re.sub(r'\s*(ч|час|часов|часа|мин|минут|минуты)\s*$', '', s) + s = s.strip().strip('(').strip(')') + + match = re.match(r'^(\d+)$', s) + if not match: + return None + + value = int(match.group(1)) + if value <= 0: + return None + + # Значение — часы, конвертируем в минуты + return value * 60 + + +def format_display_string(top_minutes: int | None, feed_minutes: int | None) -> str | None: + """ + Формирует строку отображения из числовых значений. + + Returns: + Строка вида "1ч / 24ч", "30мин / 7д", "1ч / без удаления", или None + """ + if top_minutes is None and feed_minutes is None: + return None + + top_str = _format_duration_top(top_minutes) if top_minutes is not None else '?' + feed_str = _format_duration_feed(feed_minutes) if feed_minutes is not None else '?' + + return f'{top_str} / {feed_str}' + + +def _format_duration_top(minutes: int) -> str: + """Форматирует время топа: кратно 60 → 'Xч', иначе → 'Xмин'.""" + if minutes > 0 and minutes % 60 == 0: + return f'{minutes // 60}ч' + return f'{minutes}мин' + + +def _format_duration_feed(minutes: int) -> str: + """Форматирует время ленты: 0 → 'без удаления', ≥7д и кратно дню → 'Xд', кратно 60 → 'Xч', иначе → 'Xмин'.""" + if minutes == 0: + return 'без удаления' + # Дни используем только для >= 7 дней (10080 мин), иначе часы (24ч, 48ч, 72ч выглядят привычнее) + if minutes >= 7 * 24 * 60 and minutes % (24 * 60) == 0: + return f'{minutes // (24 * 60)}д' + if minutes % 60 == 0: + return f'{minutes // 60}ч' + return f'{minutes}мин' + + def parse_format_duration(format_str: str | None) -> int | None: """ - Парсит формат размещения и возвращает длительность в секундах. + Парсит формат размещения и возвращает длительность ленты в секундах. + Обёртка для обратной совместимости. Примеры форматов: - "1 / 24" -> 86400 сек (24 часа) @@ -59,28 +202,51 @@ def parse_format_duration(format_str: str | None) -> int | None: Returns: int | None: Длительность в секундах или None для "без удаления" """ - if not format_str: + _, feed_minutes = parse_format_string(format_str) + if feed_minutes is None or feed_minutes == 0: return None + return feed_minutes * 60 - format_str = format_str.strip().lower() - # Проверяем "без удаления" - if 'без удаления' in format_str: - return None +def get_feed_duration_seconds(placement: 'Placement') -> int | None: + """ + Возвращает длительность ленты в секундах для placement. - # Ищем число часов (например "24", "48", "72") - hours_match = re.search(r'/\s*(\d+)\s*$', format_str) - if hours_match: - hours = int(hours_match.group(1)) - return hours * 3600 # конвертируем в секунды + Сначала проверяет числовые поля (feed_time_minutes), + потом fallback на parse_format_duration(placement.format). + 0 = без удаления → None. - # Ищем дни (например "(7 дней)", "(30 дней)") - days_match = re.search(r'\((\d+)\s*дн', format_str) - if days_match: - days = int(days_match.group(1)) - return days * 24 * 3600 # конвертируем в секунды + Returns: + int | None: Длительность в секундах или None + """ + if placement.feed_time_minutes is not None: + if placement.feed_time_minutes == 0: + return None + return placement.feed_time_minutes * 60 - return None + return parse_format_duration(placement.format) + + +COMMON_FORMATS: list[dict[str, int | str]] = [ + {'top_time_minutes': 60, 'feed_time_minutes': 1440, 'label': '1ч / 24ч'}, + {'top_time_minutes': 60, 'feed_time_minutes': 2160, 'label': '1ч / 36ч'}, + {'top_time_minutes': 60, 'feed_time_minutes': 2880, 'label': '1ч / 48ч'}, + {'top_time_minutes': 60, 'feed_time_minutes': 4320, 'label': '1ч / 72ч'}, + {'top_time_minutes': 60, 'feed_time_minutes': 10080, 'label': '1ч / 7д'}, + {'top_time_minutes': 60, 'feed_time_minutes': 43200, 'label': '1ч / 30д'}, + {'top_time_minutes': 60, 'feed_time_minutes': 86400, 'label': '1ч / 60д'}, + {'top_time_minutes': 60, 'feed_time_minutes': 129600, 'label': '1ч / 90д'}, + {'top_time_minutes': 60, 'feed_time_minutes': 0, 'label': '1ч / без удаления'}, + {'top_time_minutes': 120, 'feed_time_minutes': 1440, 'label': '2ч / 24ч'}, + {'top_time_minutes': 120, 'feed_time_minutes': 2160, 'label': '2ч / 36ч'}, + {'top_time_minutes': 120, 'feed_time_minutes': 2880, 'label': '2ч / 48ч'}, + {'top_time_minutes': 120, 'feed_time_minutes': 4320, 'label': '2ч / 72ч'}, + {'top_time_minutes': 120, 'feed_time_minutes': 10080, 'label': '2ч / 7д'}, + {'top_time_minutes': 120, 'feed_time_minutes': 43200, 'label': '2ч / 30д'}, + {'top_time_minutes': 120, 'feed_time_minutes': 86400, 'label': '2ч / 60д'}, + {'top_time_minutes': 120, 'feed_time_minutes': 129600, 'label': '2ч / 90д'}, + {'top_time_minutes': 120, 'feed_time_minutes': 0, 'label': '2ч / без удаления'}, +] class Placement(TimestampedModel): @@ -93,6 +259,8 @@ class Placement(TimestampedModel): cost_before_bargain = fields.FloatField(null=True) placement_type = fields.CharEnumField(PlacementType, null=True, max_length=16) format = fields.TextField(null=True) + top_time_minutes = fields.IntField(null=True) + feed_time_minutes = fields.IntField(null=True) comment = fields.TextField(null=True) invite_link = fields.CharField(max_length=512, null=True) diff --git a/src/dto/purchase.py b/src/dto/purchase.py index 26dee9e..6241ed2 100644 --- a/src/dto/purchase.py +++ b/src/dto/purchase.py @@ -22,6 +22,8 @@ class PlacementDetails(pydantic.BaseModel): cost_before_bargain: CostInfo | None = None placement_type: PlacementType | None = None format: str | None = None + top_time_minutes: int | None = None + feed_time_minutes: int | None = None comment: str | None = None creative_id: uuid.UUID | None = None invite_link_type: InviteLinkType | None = None @@ -110,6 +112,8 @@ class UpdatePlacementInput(pydantic.BaseModel): cost_before_bargain: CostInfo | None = None placement_type: PlacementType | None = None format: str | None = None + top_time_minutes: int | None = None + feed_time_minutes: int | None = None class DeletePlacementInput(pydantic.BaseModel): diff --git a/src/usecase/placement/update_post_status_cycle.py b/src/usecase/placement/update_post_status_cycle.py index ed1e28f..35c766f 100644 --- a/src/usecase/placement/update_post_status_cycle.py +++ b/src/usecase/placement/update_post_status_cycle.py @@ -107,7 +107,7 @@ def _determine_status( time_on_top = int((now - published_at).total_seconds()) # Получаем требуемую длительность из формата - required_duration = domain.parse_format_duration(placement.format) + required_duration = domain.get_feed_duration_seconds(placement) # Если формат "без удаления" - размещение отработало, если прошло 24 часа if required_duration is None: diff --git a/src/usecase/purchase/build_placement_creative.py b/src/usecase/purchase/build_placement_creative.py index e29a4ae..9c05ee4 100644 --- a/src/usecase/purchase/build_placement_creative.py +++ b/src/usecase/purchase/build_placement_creative.py @@ -109,8 +109,11 @@ def _build_info_message( datetime_str = placement.placement_at.strftime('%d.%m.%Y %H:%M') lines.append(f'Дата и время: {datetime_str}') - # Format (optional) - if placement.format: + # Format (optional) — prefer display string from numeric fields + display_format = domain.format_display_string(placement.top_time_minutes, placement.feed_time_minutes) + if display_format: + lines.append(f'Формат: {display_format}') + elif placement.format: lines.append(f'Формат: {placement.format}') # Add empty line before footer diff --git a/src/usecase/purchase/create_placements.py b/src/usecase/purchase/create_placements.py index 5b86ebf..9dc3cdd 100644 --- a/src/usecase/purchase/create_placements.py +++ b/src/usecase/purchase/create_placements.py @@ -94,6 +94,8 @@ def _build_placement_details(placement: domain.Placement) -> dto.PlacementDetail cost_before_bargain=_build_cost_info(placement.cost_before_bargain_type, placement.cost_before_bargain), placement_type=placement.placement_type, format=placement.format, + top_time_minutes=placement.top_time_minutes, + feed_time_minutes=placement.feed_time_minutes, comment=placement.comment, ) if details.model_dump(exclude_none=True): @@ -101,6 +103,30 @@ def _build_placement_details(placement: domain.Placement) -> dto.PlacementDetail return None +def _sync_format_fields( + format_str: str | None, + top_time_minutes: int | None, + feed_time_minutes: int | None, +) -> tuple[str | None, int | None, int | None]: + """Синхронизирует format string и числовые поля. + + Если есть числовые поля — вычисляем display string. + Если только format string — парсим числовые поля. + """ + if top_time_minutes is not None or feed_time_minutes is not None: + display = domain.format_display_string(top_time_minutes, feed_time_minutes) + if display: + format_str = display + elif format_str: + parsed_top, parsed_feed = domain.parse_format_string(format_str) + if parsed_top is not None: + top_time_minutes = parsed_top + if parsed_feed is not None: + feed_time_minutes = parsed_feed + + return format_str, top_time_minutes, feed_time_minutes + + def _build_placement_output( placement: domain.Placement, project: domain.Project | None = None, @@ -201,6 +227,11 @@ async def create_placements( raise domain.CreativeNotFound(creative_id) creatives_by_id[creative_id] = creative + raw_format = channel_details.format if channel_details else None + raw_top = channel_details.top_time_minutes if channel_details else None + raw_feed = channel_details.feed_time_minutes if channel_details else None + synced_format, synced_top, synced_feed = _sync_format_fields(raw_format, raw_top, raw_feed) + placement = domain.Placement( project_id=project.id, creative_id=creative.id if creative else None, @@ -227,7 +258,9 @@ async def create_placements( else None ), placement_type=channel_details.placement_type if channel_details else None, - format=channel_details.format if channel_details else None, + format=synced_format, + top_time_minutes=synced_top, + feed_time_minutes=synced_feed, ) await self.database.create_placement(placement) diff --git a/src/usecase/purchase/update_placement.py b/src/usecase/purchase/update_placement.py index 08d7c05..b6079dc 100644 --- a/src/usecase/purchase/update_placement.py +++ b/src/usecase/purchase/update_placement.py @@ -69,8 +69,28 @@ async def update_placement( placement.cost_before_bargain = input.cost_before_bargain.value if 'placement_type' in fields_set: placement.placement_type = input.placement_type - if 'format' in fields_set: + + # Синхронизация format / top_time_minutes / feed_time_minutes + format_changed = 'format' in fields_set + top_changed = 'top_time_minutes' in fields_set + feed_changed = 'feed_time_minutes' in fields_set + + if format_changed: placement.format = input.format + if top_changed: + placement.top_time_minutes = input.top_time_minutes + if feed_changed: + placement.feed_time_minutes = input.feed_time_minutes + + if format_changed or top_changed or feed_changed: + from .create_placements import _sync_format_fields + + synced_format, synced_top, synced_feed = _sync_format_fields( + placement.format, placement.top_time_minutes, placement.feed_time_minutes + ) + placement.format = synced_format + placement.top_time_minutes = synced_top + placement.feed_time_minutes = synced_feed await self.database.update_placement(placement) diff --git a/tests/test_format_parsing.py b/tests/test_format_parsing.py new file mode 100644 index 0000000..17e2cf2 --- /dev/null +++ b/tests/test_format_parsing.py @@ -0,0 +1,162 @@ +import pytest + +from src.domain.placement import format_display_string, parse_format_duration, parse_format_string + + +class TestParseFormatString: + """Тесты для parse_format_string — парсинг строки формата в (top_minutes, feed_minutes).""" + + def test_standard_format_1_24(self) -> None: + assert parse_format_string('1 / 24') == (60, 1440) + + def test_standard_format_no_spaces(self) -> None: + assert parse_format_string('1/48') == (60, 2880) + + def test_standard_format_1_72(self) -> None: + assert parse_format_string('1 / 72') == (60, 4320) + + def test_days_format_7(self) -> None: + assert parse_format_string('1 / (7 дней)') == (60, 10080) + + def test_days_format_30(self) -> None: + assert parse_format_string('1 / (30 дней)') == (60, 43200) + + def test_no_deletion(self) -> None: + assert parse_format_string('1 / (без удаления)') == (60, 0) + + def test_no_deletion_no_parens(self) -> None: + assert parse_format_string('1 / без удаления') == (60, 0) + + def test_top_2_hours(self) -> None: + assert parse_format_string('2 / 24') == (120, 1440) + + def test_top_2_no_space(self) -> None: + assert parse_format_string('2/48') == (120, 2880) + + def test_top_2_72(self) -> None: + assert parse_format_string('2/72') == (120, 4320) + + def test_top_minutes_value_30(self) -> None: + """Значение > 12 интерпретируется как минуты.""" + assert parse_format_string('30 / 24') == (30, 1440) + + def test_none_input(self) -> None: + assert parse_format_string(None) == (None, None) + + def test_empty_string(self) -> None: + assert parse_format_string('') == (None, None) + + def test_whitespace_string(self) -> None: + assert parse_format_string(' ') == (None, None) + + def test_unparseable_string(self) -> None: + assert parse_format_string('пост') == (None, None) + + def test_no_slash(self) -> None: + assert parse_format_string('24') == (None, None) + + def test_days_short_form(self) -> None: + assert parse_format_string('1 / 7д') == (60, 10080) + + def test_days_short_form_dn(self) -> None: + assert parse_format_string('1 / 7 дн') == (60, 10080) + + def test_with_unit_suffixes(self) -> None: + assert parse_format_string('1ч / 24ч') == (60, 1440) + + def test_with_unit_suffixes_days(self) -> None: + assert parse_format_string('1ч / 7д') == (60, 10080) + + +class TestFormatDisplayString: + """Тесты для format_display_string — формирование строки из числовых значений.""" + + def test_hours_hours(self) -> None: + assert format_display_string(60, 1440) == '1ч / 24ч' + + def test_minutes_hours(self) -> None: + assert format_display_string(30, 1440) == '30мин / 24ч' + + def test_no_deletion(self) -> None: + assert format_display_string(60, 0) == '1ч / без удаления' + + def test_days(self) -> None: + assert format_display_string(60, 10080) == '1ч / 7д' + + def test_none_none(self) -> None: + assert format_display_string(None, None) is None + + def test_2h_48h(self) -> None: + assert format_display_string(120, 2880) == '2ч / 48ч' + + def test_2h_30d(self) -> None: + assert format_display_string(120, 43200) == '2ч / 30д' + + def test_top_only(self) -> None: + assert format_display_string(60, None) == '1ч / ?' + + def test_feed_only(self) -> None: + assert format_display_string(None, 1440) == '? / 24ч' + + def test_36h(self) -> None: + assert format_display_string(60, 2160) == '1ч / 36ч' + + def test_72h(self) -> None: + assert format_display_string(60, 4320) == '1ч / 72ч' + + def test_90d(self) -> None: + assert format_display_string(60, 129600) == '1ч / 90д' + + +class TestParseFormatDuration: + """Тесты обратной совместимости parse_format_duration — возвращает feed time в секундах.""" + + def test_1_24(self) -> None: + assert parse_format_duration('1 / 24') == 86400 + + def test_1_48(self) -> None: + assert parse_format_duration('1/48') == 172800 + + def test_1_72(self) -> None: + assert parse_format_duration('1 / 72') == 259200 + + def test_7_days(self) -> None: + assert parse_format_duration('1 / (7 дней)') == 604800 + + def test_30_days(self) -> None: + assert parse_format_duration('1 / (30 дней)') == 2592000 + + def test_no_deletion(self) -> None: + assert parse_format_duration('1 / (без удаления)') is None + + def test_none(self) -> None: + assert parse_format_duration(None) is None + + def test_empty(self) -> None: + assert parse_format_duration('') is None + + def test_2_24(self) -> None: + assert parse_format_duration('2 / 24') == 86400 + + +class TestRoundtrip: + """Тесты round-trip: parse → format → parse.""" + + @pytest.mark.parametrize( + 'top,feed', + [ + (60, 1440), + (60, 2880), + (60, 10080), + (60, 0), + (120, 1440), + (120, 43200), + (30, 1440), + ], + ) + def test_roundtrip(self, top: int, feed: int) -> None: + display = format_display_string(top, feed) + assert display is not None + parsed_top, parsed_feed = parse_format_string(display) + assert parsed_top == top + assert parsed_feed == feed diff --git a/tg_bot/backend/backend_client.go b/tg_bot/backend/backend_client.go index f438be1..1b0275d 100644 --- a/tg_bot/backend/backend_client.go +++ b/tg_bot/backend/backend_client.go @@ -596,6 +596,8 @@ type PlacementDetails struct { CostBeforeBargain *CostInfo `json:"cost_before_bargain,omitempty"` PlacementType *string `json:"placement_type,omitempty"` Format *string `json:"format,omitempty"` + TopTimeMinutes *int `json:"top_time_minutes,omitempty"` + FeedTimeMinutes *int `json:"feed_time_minutes,omitempty"` Comment *string `json:"comment,omitempty"` CreativeID *string `json:"creative_id,omitempty"` CreativeName *string `json:"creative_name,omitempty"` diff --git a/tg_bot/go.mod b/tg_bot/go.mod index 4610315..ed58e83 100644 --- a/tg_bot/go.mod +++ b/tg_bot/go.mod @@ -9,8 +9,11 @@ require ( ) require ( + github.com/AlekSi/pointer v1.0.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/olebedev/when v1.1.0 // indirect + github.com/pkg/errors v0.9.1 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.29.0 // indirect golang.org/x/time v0.5.0 // indirect diff --git a/tg_bot/go.sum b/tg_bot/go.sum index 99c3704..d400350 100644 --- a/tg_bot/go.sum +++ b/tg_bot/go.sum @@ -1,3 +1,5 @@ +github.com/AlekSi/pointer v1.0.0 h1:KWCWzsvFxNLcmM5XmiqHsGTTsuwZMsLFwWF9Y+//bNE= +github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QKVOSOA8= github.com/NicoNex/echotron/v3 v3.43.0 h1:efE2spw3mfU0Ev20m0PqqvgMSm0xHzgSxlWAEmC9RC4= github.com/NicoNex/echotron/v3 v3.43.0/go.mod h1:7LvjveJmezuUOeaoA3nzQduNlSPQYfq219Z+baKY04Q= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= @@ -9,6 +11,9 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/olebedev/when v1.1.0 h1:dlpoRa7huImhNtEx4yl0WYfTHVEWmJmIWd7fEkTHayc= +github.com/olebedev/when v1.1.0/go.mod h1:T0THb4kP9D3NNqlvCwIG4GyUioTAzEhB4RNVzig/43E= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= diff --git a/tg_bot/screens/purchase_optional_details.go b/tg_bot/screens/purchase_optional_details.go index 1a5a25f..cf1f030 100644 --- a/tg_bot/screens/purchase_optional_details.go +++ b/tg_bot/screens/purchase_optional_details.go @@ -29,6 +29,13 @@ type PurchaseOptionalDetails struct { CostBeforeBargain *CostEntry PurchaseType string Format string + TopTimeMinutes *int + FeedTimeMinutes *int // nil = не указан, 0 = без удаления + TopTimeByChannel map[string]*int + FeedTimeByChannel map[string]*int + CustomFormatTopUnit string // "hours" | "minutes" + CustomFormatFeedUnit string // "hours" | "days" + CustomFormatTopValue *int // промежуточное значение (в минутах) Comment string InviteLinkType string InputMode string @@ -209,6 +216,9 @@ func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update) case "format_custom": s.InputMode = "format_custom" + s.CustomFormatTopUnit = "hours" + s.CustomFormatFeedUnit = "hours" + s.CustomFormatTopValue = nil s.Enter(b, bot.EditMessage) case "cost_type_toggle": @@ -328,6 +338,90 @@ func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update) s.Enter(b, bot.EditMessage) return } + if strings.HasPrefix(u.CallbackQuery.Data, "format_preset:") { + parts := strings.Split(strings.TrimPrefix(u.CallbackQuery.Data, "format_preset:"), ":") + if len(parts) == 2 { + topMin, err1 := strconv.Atoi(parts[0]) + feedMin, err2 := strconv.Atoi(parts[1]) + if err1 == nil && err2 == nil { + s.setFormatPreset(topMin, feedMin) + } + } + if s.ReturnMode != "" { + s.InputMode = s.ReturnMode + s.ReturnMode = "" + } else { + s.InputMode = "" + } + s.CurrentChannel = "" + s.Enter(b, bot.EditMessage) + return + } + if strings.HasPrefix(u.CallbackQuery.Data, "format_custom_top:") { + valStr := strings.TrimPrefix(u.CallbackQuery.Data, "format_custom_top:") + minutes, err := strconv.Atoi(valStr) + if err == nil && minutes > 0 { + s.CustomFormatTopValue = &minutes + s.InputMode = "format_custom_feed" + } + s.Enter(b, bot.EditMessage) + return + } + if strings.HasPrefix(u.CallbackQuery.Data, "format_custom_feed:") { + valStr := strings.TrimPrefix(u.CallbackQuery.Data, "format_custom_feed:") + feedMinutes, err := strconv.Atoi(valStr) + if err == nil && s.CustomFormatTopValue != nil { + s.setFormatPreset(*s.CustomFormatTopValue, feedMinutes) + s.CustomFormatTopValue = nil + if s.ReturnMode != "" { + s.InputMode = s.ReturnMode + s.ReturnMode = "" + } else { + s.InputMode = "" + } + s.CurrentChannel = "" + } + s.Enter(b, bot.EditMessage) + return + } + if u.CallbackQuery.Data == "format_custom_top_toggle_unit" { + if s.CustomFormatTopUnit == "hours" { + s.CustomFormatTopUnit = "minutes" + } else { + s.CustomFormatTopUnit = "hours" + } + s.Enter(b, bot.EditMessage) + return + } + if u.CallbackQuery.Data == "format_custom_feed_toggle_unit" { + if s.CustomFormatFeedUnit == "hours" { + s.CustomFormatFeedUnit = "days" + } else { + s.CustomFormatFeedUnit = "hours" + } + s.Enter(b, bot.EditMessage) + return + } + if u.CallbackQuery.Data == "format_custom_top_input_mode" { + s.InputMode = "format_custom_top_input" + s.Enter(b, bot.EditMessage) + return + } + if u.CallbackQuery.Data == "format_custom_feed_input_mode" { + s.InputMode = "format_custom_feed_input" + s.Enter(b, bot.EditMessage) + return + } + if u.CallbackQuery.Data == "format_custom_back_to_select" { + s.InputMode = "format_select" + s.Enter(b, bot.EditMessage) + return + } + if u.CallbackQuery.Data == "format_custom_back_to_top" { + s.InputMode = "format_custom" + s.Enter(b, bot.EditMessage) + return + } if strings.HasPrefix(u.CallbackQuery.Data, "format:") { value := strings.TrimPrefix(u.CallbackQuery.Data, "format:") s.setFormatValue(value) @@ -370,8 +464,36 @@ func (s *PurchaseOptionalDetails) HandleMessage(b *bot.Bot, u *echotron.Update) } switch s.InputMode { - case "format_custom": - s.setFormatValue(text) + case "format_custom_top_input": + value, err := strconv.Atoi(text) + if err != nil || value <= 0 { + s.renderInputPrompt(b, bot.EditMessage) + return + } + // Конвертируем в минуты по текущей единице + if s.CustomFormatTopUnit == "hours" { + value = value * 60 + } + s.CustomFormatTopValue = &value + s.InputMode = "format_custom_feed" + s.Enter(b, bot.NewMessage) + return + case "format_custom_feed_input": + value, err := strconv.Atoi(text) + if err != nil || value <= 0 { + s.renderInputPrompt(b, bot.EditMessage) + return + } + // Конвертируем в минуты по текущей единице + if s.CustomFormatFeedUnit == "days" { + value = value * 24 * 60 + } else { + value = value * 60 + } + if s.CustomFormatTopValue != nil { + s.setFormatPreset(*s.CustomFormatTopValue, value) + s.CustomFormatTopValue = nil + } case "comment": if s.CurrentChannel != "" { s.CommentByChannel[s.CurrentChannel] = text @@ -699,22 +821,34 @@ func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderM text += "Выберите формат\n\n" keyboard := Keyboard( Row( - Button("1 / 24", "format:1 / 24"), - Button("1/48", "format:1/48"), - Button("1 / 72", "format:1 / 72"), + Button("1ч / 24ч", "format_preset:60:1440"), + Button("1ч / 36ч", "format_preset:60:2160"), + Button("1ч / 48ч", "format_preset:60:2880"), + Button("1ч / 72ч", "format_preset:60:4320"), ), Row( - Button("1 / (7 дней)", "format:1 / (7 дней)"), - Button("1 / (30 дней)", "format:1 / (30 дней)"), - Button("1 / (без удаления)", "format:1 / (без удаления)"), + Button("1ч / 7д", "format_preset:60:10080"), + Button("1ч / 30д", "format_preset:60:43200"), + Button("1ч / 60д", "format_preset:60:86400"), + Button("1ч / 90д", "format_preset:60:129600"), ), Row( - Button("2 / 24", "format:2 / 24"), - Button("2/48", "format:2/48"), - Button("2/72", "format:2/72"), + Button("1ч / без удаления", "format_preset:60:0"), ), Row( - Button("Назад", s.backAction()), + Button("2ч / 24ч", "format_preset:120:1440"), + Button("2ч / 36ч", "format_preset:120:2160"), + Button("2ч / 48ч", "format_preset:120:2880"), + Button("2ч / 72ч", "format_preset:120:4320"), + ), + Row( + Button("2ч / 7д", "format_preset:120:10080"), + Button("2ч / 30д", "format_preset:120:43200"), + Button("2ч / 60д", "format_preset:120:86400"), + Button("2ч / 90д", "format_preset:120:129600"), + ), + Row( + Button("← Назад", s.backAction()), Button("Свой формат", "format_custom"), ), ) @@ -725,7 +859,25 @@ func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderM } return case "format_custom": - text += "Введите формат\n\nНапример: пост" + s.renderCustomFormatTop(b, mode) + return + case "format_custom_top_input": + text += "Введите число для времени в топе\n\n" + unit := "часах" + if s.CustomFormatTopUnit == "minutes" { + unit = "минутах" + } + text += fmt.Sprintf("Единица: %s", unit) + case "format_custom_feed": + s.renderCustomFormatFeed(b, mode) + return + case "format_custom_feed_input": + text += "Введите число для времени в ленте\n\n" + unit := "часах" + if s.CustomFormatFeedUnit == "days" { + unit = "днях" + } + text += fmt.Sprintf("Единица: %s", unit) case "invite_link_type": text = "Страница выбора типа ссылки\n\n" if s.CurrentChannel != "" { @@ -777,6 +929,127 @@ func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderM } } +func (s *PurchaseOptionalDetails) renderCustomFormatTop(b *bot.Bot, mode bot.RenderMode) { + text := "Свой формат — время в топе\n\n" + if s.CurrentChannel != "" { + text += fmt.Sprintf("Канал: %s\n\n", channelLabelByKey(s.Channels, s.CurrentChannel)) + } + + var rows [][]echotron.InlineKeyboardButton + + if s.CustomFormatTopUnit == "minutes" { + text += "⏱ Единица: минуты\n\n" + text += "Выберите время в топе" + rows = append(rows, + Row( + Button("10", "format_custom_top:10"), + Button("15", "format_custom_top:15"), + Button("20", "format_custom_top:20"), + Button("30", "format_custom_top:30"), + ), + Row( + Button("45", "format_custom_top:45"), + Button("60", "format_custom_top:60"), + Button("90", "format_custom_top:90"), + Button("120", "format_custom_top:120"), + ), + Row( + Button("↔ Часы", "format_custom_top_toggle_unit"), + Button("Ввести число", "format_custom_top_input_mode"), + ), + ) + } else { + text += "⏱ Единица: часы\n\n" + text += "Выберите время в топе" + rows = append(rows, + Row( + Button("1", "format_custom_top:60"), + Button("2", "format_custom_top:120"), + Button("3", "format_custom_top:180"), + Button("4", "format_custom_top:240"), + ), + Row( + Button("5", "format_custom_top:300"), + Button("6", "format_custom_top:360"), + Button("8", "format_custom_top:480"), + Button("12", "format_custom_top:720"), + ), + Row( + Button("↔ Минуты", "format_custom_top_toggle_unit"), + Button("Ввести число", "format_custom_top_input_mode"), + ), + ) + } + + rows = append(rows, Row(Button("← Назад", "format_custom_back_to_select"))) + + b.Render(text, Keyboard(rows...), mode) +} + +func (s *PurchaseOptionalDetails) renderCustomFormatFeed(b *bot.Bot, mode bot.RenderMode) { + text := "Свой формат — время в ленте\n\n" + if s.CurrentChannel != "" { + text += fmt.Sprintf("Канал: %s\n\n", channelLabelByKey(s.Channels, s.CurrentChannel)) + } + + if s.CustomFormatTopValue != nil { + text += fmt.Sprintf("Время в топе: %s ✓\n\n", formatDuration(*s.CustomFormatTopValue, "top")) + } + + var rows [][]echotron.InlineKeyboardButton + + if s.CustomFormatFeedUnit == "days" { + text += "⏱ Единица: дни\n\n" + text += "Выберите время в ленте" + rows = append(rows, + Row( + Button("7", "format_custom_feed:10080"), + Button("14", "format_custom_feed:20160"), + Button("30", "format_custom_feed:43200"), + Button("60", "format_custom_feed:86400"), + ), + Row( + Button("90", "format_custom_feed:129600"), + Button("120", "format_custom_feed:172800"), + Button("180", "format_custom_feed:259200"), + Button("365", "format_custom_feed:525600"), + ), + Row( + Button("↔ Часы", "format_custom_feed_toggle_unit"), + Button("Без удаления", "format_custom_feed:0"), + ), + ) + } else { + text += "⏱ Единица: часы\n\n" + text += "Выберите время в ленте" + rows = append(rows, + Row( + Button("24", "format_custom_feed:1440"), + Button("36", "format_custom_feed:2160"), + Button("48", "format_custom_feed:2880"), + Button("72", "format_custom_feed:4320"), + ), + Row( + Button("96", "format_custom_feed:5760"), + Button("120", "format_custom_feed:7200"), + Button("144", "format_custom_feed:8640"), + Button("168", "format_custom_feed:10080"), + ), + Row( + Button("↔ Дни", "format_custom_feed_toggle_unit"), + Button("Без удаления", "format_custom_feed:0"), + ), + ) + } + + rows = append(rows, + Row(Button("Ввести число", "format_custom_feed_input_mode")), + Row(Button("← Назад", "format_custom_back_to_top")), + ) + + b.Render(text, Keyboard(rows...), mode) +} + func (s *PurchaseOptionalDetails) renderModeSelect(b *bot.Bot, mode bot.RenderMode) { text := "⚙ Переключить режим параметра\n\n" @@ -1382,6 +1655,8 @@ func (s *PurchaseOptionalDetails) copyCommonValueToChannels(param string) { for _, ch := range s.Channels { key := channelKey(ch) s.FormatByChannel[key] = s.Format + s.TopTimeByChannel[key] = s.TopTimeMinutes + s.FeedTimeByChannel[key] = s.FeedTimeMinutes } case "invite_link_type": if s.InviteLinkType == "" { @@ -1512,6 +1787,8 @@ func (s *PurchaseOptionalDetails) clearParamValue(param, channelKey string) { delete(s.CommentByChannel, channelKey) case "format": delete(s.FormatByChannel, channelKey) + delete(s.TopTimeByChannel, channelKey) + delete(s.FeedTimeByChannel, channelKey) case "invite_link_type": delete(s.InviteLinkTypeByChannel, channelKey) } @@ -1660,6 +1937,18 @@ func (s *PurchaseOptionalDetails) ensureDefaults() { if s.FormatByChannel == nil { s.FormatByChannel = make(map[string]string) } + if s.TopTimeByChannel == nil { + s.TopTimeByChannel = make(map[string]*int) + } + if s.FeedTimeByChannel == nil { + s.FeedTimeByChannel = make(map[string]*int) + } + if s.CustomFormatTopUnit == "" { + s.CustomFormatTopUnit = "hours" + } + if s.CustomFormatFeedUnit == "" { + s.CustomFormatFeedUnit = "hours" + } if s.InviteLinkTypeByChannel == nil { s.InviteLinkTypeByChannel = make(map[string]string) } @@ -1709,6 +1998,16 @@ func (s *PurchaseOptionalDetails) syncChannelMaps() { delete(s.FormatByChannel, key) } } + for key := range s.TopTimeByChannel { + if _, ok := valid[key]; !ok { + delete(s.TopTimeByChannel, key) + } + } + for key := range s.FeedTimeByChannel { + if _, ok := valid[key]; !ok { + delete(s.FeedTimeByChannel, key) + } + } for key := range s.InviteLinkTypeByChannel { if _, ok := valid[key]; !ok { delete(s.InviteLinkTypeByChannel, key) @@ -2156,12 +2455,64 @@ func (s *PurchaseOptionalDetails) setCostBeforeValue(value float64) { s.CostBeforeByChannel[s.CurrentChannel] = entry } +func formatDuration(minutes int, unit string) string { + switch unit { + case "top": + if minutes > 0 && minutes%60 == 0 { + return fmt.Sprintf("%dч", minutes/60) + } + return fmt.Sprintf("%dмин", minutes) + case "feed": + if minutes == 0 { + return "без удаления" + } + if minutes >= 7*24*60 && minutes%(24*60) == 0 { + return fmt.Sprintf("%dд", minutes/(24*60)) + } + if minutes%60 == 0 { + return fmt.Sprintf("%dч", minutes/60) + } + return fmt.Sprintf("%dмин", minutes) + } + return fmt.Sprintf("%d", minutes) +} + +func formatLabel(topMinutes, feedMinutes int) string { + return formatDuration(topMinutes, "top") + " / " + formatDuration(feedMinutes, "feed") +} + +func (s *PurchaseOptionalDetails) setFormatPreset(topMinutes int, feedMinutes int) { + label := formatLabel(topMinutes, feedMinutes) + topPtr := &topMinutes + var feedPtr *int + if feedMinutes == 0 { + // 0 = без удаления — сохраняем как 0 + feedPtr = &feedMinutes + } else { + feedPtr = &feedMinutes + } + + if s.CurrentChannel == "" { + s.Format = label + s.TopTimeMinutes = topPtr + s.FeedTimeMinutes = feedPtr + } else { + s.FormatByChannel[s.CurrentChannel] = label + s.TopTimeByChannel[s.CurrentChannel] = topPtr + s.FeedTimeByChannel[s.CurrentChannel] = feedPtr + } +} + func (s *PurchaseOptionalDetails) setFormatValue(value string) { if s.CurrentChannel == "" { s.Format = value + s.TopTimeMinutes = nil + s.FeedTimeMinutes = nil return } s.FormatByChannel[s.CurrentChannel] = value + delete(s.TopTimeByChannel, s.CurrentChannel) + delete(s.FeedTimeByChannel, s.CurrentChannel) } func (s *PurchaseOptionalDetails) setInviteLinkTypeValue(value string) { @@ -2314,8 +2665,18 @@ func (s *PurchaseOptionalDetails) buildChannelDetails(channelKey string, placeme if value, ok := s.FormatByChannel[channelKey]; ok && value != "" { details.Format = &value } - } else if s.Format != "" { - details.Format = &s.Format + if value, ok := s.TopTimeByChannel[channelKey]; ok && value != nil { + details.TopTimeMinutes = value + } + if value, ok := s.FeedTimeByChannel[channelKey]; ok && value != nil { + details.FeedTimeMinutes = value + } + } else { + if s.Format != "" { + details.Format = &s.Format + } + details.TopTimeMinutes = s.TopTimeMinutes + details.FeedTimeMinutes = s.FeedTimeMinutes } // Invite link type @@ -2351,6 +2712,8 @@ func (s *PurchaseOptionalDetails) isChannelDetailsEmpty(details *backend.Placeme details.CostBeforeBargain == nil && details.PlacementType == nil && details.Format == nil && + details.TopTimeMinutes == nil && + details.FeedTimeMinutes == nil && details.Comment == nil && details.InviteLinkType == nil } diff --git a/tg_bot/screens/ui/date_time_picker.go b/tg_bot/screens/ui/date_time_picker.go index 82d250f..2ed728d 100644 --- a/tg_bot/screens/ui/date_time_picker.go +++ b/tg_bot/screens/ui/date_time_picker.go @@ -8,6 +8,9 @@ import ( "github.com/NicoNex/echotron/v3" "github.com/TelegramExchange/tgex-backend/tg_bot/bot" + "github.com/olebedev/when" + "github.com/olebedev/when/rules/common" + "github.com/olebedev/when/rules/ru" ) const ( @@ -381,6 +384,7 @@ func (s *DateTimePicker) handleDaySelect(payload string, b *bot.Bot) { s.view = pickerViewTimeCombined s.selectedHour = nil s.selectedMinute = nil + s.Enter(b, bot.EditMessage) } else { // Сразу подтверждаем выбор для даты без времени s.confirmSelection(b) @@ -629,192 +633,52 @@ func (s *DateTimePicker) Handle(_ *bot.Bot, _ *echotron.Update) { return } func (s *DateTimePicker) Exit() {} +var whenParser *when.Parser + +func init() { + whenParser = when.New(nil) + whenParser.Add(ru.All...) + whenParser.Add(common.All...) +} + +var ( + timeIndicator = regexp.MustCompile(`\d{1,2}[:.]\d{2}|утр|вечер|днём|ночь|час|минут|полдень|полночь`) + dateIndicator = regexp.MustCompile(`\d{1,2}[./-]\d|сегодня|завтра|вчера|послезавтра|понедельн|вторник|сред[уыа]|четверг|пятниц|суббот|воскресен|янв|фев|мар|апр|ма[йя]|июн|июл|авг|сен|окт|ноя|дек|через.*дн|через.*недел|через.*месяц|через.*год`) +) + +func detectTime(matched string) bool { + return timeIndicator.MatchString(matched) +} + +func detectDate(matched string) bool { + return dateIndicator.MatchString(matched) +} + func parseDateTimeInput( input string, - currentDate *time.Time, - currentHour *int, - currentMinute *int, + _ *time.Time, + _ *int, + _ *int, allowPast bool, ) (time.Time, bool, bool) { - text := strings.ToLower(strings.TrimSpace(input)) - text = strings.ReplaceAll(text, ",", " ") - text = strings.ReplaceAll(text, " ", " ") - now := time.Now().In(MskLocation) - baseDate := now - if currentDate != nil { - baseDate = currentDate.In(MskLocation) - } - date, dateOk := parseDate(text, baseDate) - hour, minute, timeOk := parseTime(text, currentHour, currentMinute) - - if !dateOk && !timeOk { + r, err := whenParser.Parse(input, now) + if err != nil || r == nil { return time.Time{}, false, false } - if !dateOk { - date = time.Date(baseDate.Year(), baseDate.Month(), baseDate.Day(), 0, 0, 0, 0, MskLocation) - } - if !timeOk { - hour = 0 - minute = 0 - } - value := time.Date(date.Year(), date.Month(), date.Day(), hour, minute, 0, 0, MskLocation) - if !allowPast && value.Before(now) { + result := r.Time.In(MskLocation) + matched := strings.ToLower(r.Text) + + hasDate := detectDate(matched) + hasTime := detectTime(matched) + + if !allowPast && result.Before(now) { return time.Time{}, false, false } - return value, dateOk, timeOk -} -func parseDate(text string, baseDate time.Time) (time.Time, bool) { - if strings.Contains(text, "сегодня") { - return baseDate, true - } - if strings.Contains(text, "завтра") { - return baseDate.AddDate(0, 0, 1), true - } - if strings.Contains(text, "послезавтра") { - return baseDate.AddDate(0, 0, 2), true - } - - if date := parseNumericDate(text); !date.IsZero() { - return date, true - } - if date := parseWordDate(text); !date.IsZero() { - return date, true - } - return time.Time{}, false -} - -func parseNumericDate(text string) time.Time { - reYMD := regexp.MustCompile(`\b(\d{4})[./-](\d{1,2})[./-](\d{1,2})\b`) - if match := reYMD.FindStringSubmatch(text); len(match) == 4 { - year := parseInt(match[1]) - month := parseInt(match[2]) - day := parseInt(match[3]) - return safeDate(year, month, day) - } - - reDMY := regexp.MustCompile(`\b(\d{1,2})[./-](\d{1,2})(?:[./-](\d{2,4}))?\b`) - if match := reDMY.FindStringSubmatch(text); len(match) >= 3 { - day := parseInt(match[1]) - month := parseInt(match[2]) - year := time.Now().In(MskLocation).Year() - if len(match) == 4 && match[3] != "" { - year = parseInt(match[3]) - if year < 100 { - year += 2000 - } - } - return safeDate(year, month, day) - } - - return time.Time{} -} - -func parseWordDate(text string) time.Time { - monthMap := map[string]time.Month{ - "янв": 1, "январ": 1, "января": 1, - "фев": 2, "феврал": 2, "февраля": 2, - "мар": 3, "марта": 3, - "апр": 4, "апрел": 4, "апреля": 4, - "май": 5, "мая": 5, - "июн": 6, "июня": 6, - "июл": 7, "июля": 7, - "авг": 8, "август": 8, "августа": 8, - "сен": 9, "сент": 9, "сентябр": 9, "сентября": 9, - "окт": 10, "октябр": 10, "октября": 10, - "ноя": 11, "ноябр": 11, "ноября": 11, - "дек": 12, "декабр": 12, "декабря": 12, - } - - re := regexp.MustCompile(`\b(\d{1,2})\s+([a-zа-яё]+)(?:\s+(\d{2,4}))?\b`) - match := re.FindStringSubmatch(text) - if len(match) == 0 { - return time.Time{} - } - day := parseInt(match[1]) - monthToken := match[2] - var month time.Month - for key, value := range monthMap { - if strings.HasPrefix(monthToken, key) { - month = value - break - } - } - if month == 0 { - return time.Time{} - } - - year := time.Now().In(MskLocation).Year() - if len(match) == 4 && match[3] != "" { - year = parseInt(match[3]) - if year < 100 { - year += 2000 - } - } - - return safeDate(year, int(month), day) -} - -func parseTime(text string, currentHour *int, currentMinute *int) (int, int, bool) { - re := regexp.MustCompile(`\b(\d{1,2})[:.](\d{2})\b`) - if match := re.FindStringSubmatch(text); len(match) == 3 { - hour := parseInt(match[1]) - minute := parseInt(match[2]) - if hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59 { - return hour, minute, true - } - } - - reCompact := regexp.MustCompile(`\b(\d{1,2})\s+(\d{2})\b`) - if match := reCompact.FindStringSubmatch(text); len(match) == 3 { - hour := parseInt(match[1]) - minute := parseInt(match[2]) - if hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59 { - return hour, minute, true - } - } - - reHour := regexp.MustCompile(`\b(\d{1,2})\s*(?:ч|час|часов)\b`) - if match := reHour.FindStringSubmatch(text); len(match) == 2 { - hour := parseInt(match[1]) - if hour >= 0 && hour <= 23 { - minute := 0 - if currentMinute != nil { - minute = *currentMinute - } - return hour, minute, true - } - } - - reJustHour := regexp.MustCompile(`\b(\d{1,2})\b`) - if match := reJustHour.FindStringSubmatch(text); len(match) == 2 && strings.Contains(text, "в") { - hour := parseInt(match[1]) - if hour >= 0 && hour <= 23 { - minute := 0 - if currentMinute != nil { - minute = *currentMinute - } - return hour, minute, true - } - } - - if currentHour != nil && currentMinute != nil { - return *currentHour, *currentMinute, true - } - return 0, 0, false -} - -func safeDate(year, month, day int) time.Time { - if month < 1 || month > 12 { - return time.Time{} - } - if day < 1 || day > 31 { - return time.Time{} - } - return time.Date(year, time.Month(month), day, 0, 0, 0, 0, MskLocation) + return result, hasDate, hasTime } func WeekdayName(day time.Weekday) string {