Compare commits
10 Commits
0ad9fe36a6
...
68dc1e1538
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68dc1e1538 | ||
|
|
4e4621a09c | ||
|
|
b12f34a856 | ||
|
|
62cd191846 | ||
|
|
ffd7a7c10f | ||
|
|
9c8fd68ee3 | ||
|
|
8c6339c4d9 | ||
|
|
4518544521 | ||
|
|
751f3b657a | ||
|
|
c9d5f1e2ea |
20
.github/workflows/deploy.yml
vendored
Normal file
20
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
name: Build & Push
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to GHCR
|
||||||
|
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
run: |
|
||||||
|
docker build -t ghcr.io/${{ github.repository }}/app:latest .
|
||||||
|
docker push ghcr.io/${{ github.repository }}/app:latest
|
||||||
165
migrations/models/18_20260228130403_update.py
Normal file
165
migrations/models/18_20260228130403_update.py
Normal file
@@ -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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"
|
||||||
|
)
|
||||||
@@ -20,6 +20,10 @@ __all__ = (
|
|||||||
'PlacementStatus',
|
'PlacementStatus',
|
||||||
'PlacementType',
|
'PlacementType',
|
||||||
'parse_format_duration',
|
'parse_format_duration',
|
||||||
|
'parse_format_string',
|
||||||
|
'format_display_string',
|
||||||
|
'get_feed_duration_seconds',
|
||||||
|
'COMMON_FORMATS',
|
||||||
'PlacementPost',
|
'PlacementPost',
|
||||||
'PlacementPostStatus',
|
'PlacementPostStatus',
|
||||||
'Creative',
|
'Creative',
|
||||||
@@ -116,12 +120,16 @@ from .error import (
|
|||||||
)
|
)
|
||||||
from .login_token import LoginToken
|
from .login_token import LoginToken
|
||||||
from .placement import (
|
from .placement import (
|
||||||
|
COMMON_FORMATS,
|
||||||
CostType,
|
CostType,
|
||||||
InviteLinkType,
|
InviteLinkType,
|
||||||
Placement,
|
Placement,
|
||||||
PlacementStatus,
|
PlacementStatus,
|
||||||
PlacementType,
|
PlacementType,
|
||||||
|
format_display_string,
|
||||||
|
get_feed_duration_seconds,
|
||||||
parse_format_duration,
|
parse_format_duration,
|
||||||
|
parse_format_string,
|
||||||
)
|
)
|
||||||
from .placement_post import PlacementPost, PlacementPostStatus
|
from .placement_post import PlacementPost, PlacementPostStatus
|
||||||
from .post import Post
|
from .post import Post
|
||||||
|
|||||||
@@ -12,7 +12,18 @@ if TYPE_CHECKING:
|
|||||||
from .creative import Creative
|
from .creative import Creative
|
||||||
from .project import Project
|
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):
|
class CostType(enum.StrEnum):
|
||||||
@@ -43,9 +54,141 @@ class InviteLinkType(enum.StrEnum):
|
|||||||
APPROVAL = 'approval' # с одобрением ботом
|
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:
|
def parse_format_duration(format_str: str | None) -> int | None:
|
||||||
"""
|
"""
|
||||||
Парсит формат размещения и возвращает длительность в секундах.
|
Парсит формат размещения и возвращает длительность ленты в секундах.
|
||||||
|
Обёртка для обратной совместимости.
|
||||||
|
|
||||||
Примеры форматов:
|
Примеры форматов:
|
||||||
- "1 / 24" -> 86400 сек (24 часа)
|
- "1 / 24" -> 86400 сек (24 часа)
|
||||||
@@ -59,28 +202,51 @@ def parse_format_duration(format_str: str | None) -> int | None:
|
|||||||
Returns:
|
Returns:
|
||||||
int | None: Длительность в секундах или None для "без удаления"
|
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 None
|
||||||
|
return feed_minutes * 60
|
||||||
|
|
||||||
format_str = format_str.strip().lower()
|
|
||||||
|
|
||||||
# Проверяем "без удаления"
|
def get_feed_duration_seconds(placement: 'Placement') -> int | None:
|
||||||
if 'без удаления' in format_str:
|
"""
|
||||||
return None
|
Возвращает длительность ленты в секундах для placement.
|
||||||
|
|
||||||
# Ищем число часов (например "24", "48", "72")
|
Сначала проверяет числовые поля (feed_time_minutes),
|
||||||
hours_match = re.search(r'/\s*(\d+)\s*$', format_str)
|
потом fallback на parse_format_duration(placement.format).
|
||||||
if hours_match:
|
0 = без удаления → None.
|
||||||
hours = int(hours_match.group(1))
|
|
||||||
return hours * 3600 # конвертируем в секунды
|
|
||||||
|
|
||||||
# Ищем дни (например "(7 дней)", "(30 дней)")
|
Returns:
|
||||||
days_match = re.search(r'\((\d+)\s*дн', format_str)
|
int | None: Длительность в секундах или None
|
||||||
if days_match:
|
"""
|
||||||
days = int(days_match.group(1))
|
if placement.feed_time_minutes is not None:
|
||||||
return days * 24 * 3600 # конвертируем в секунды
|
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):
|
class Placement(TimestampedModel):
|
||||||
@@ -93,6 +259,8 @@ class Placement(TimestampedModel):
|
|||||||
cost_before_bargain = fields.FloatField(null=True)
|
cost_before_bargain = fields.FloatField(null=True)
|
||||||
placement_type = fields.CharEnumField(PlacementType, null=True, max_length=16)
|
placement_type = fields.CharEnumField(PlacementType, null=True, max_length=16)
|
||||||
format = fields.TextField(null=True)
|
format = fields.TextField(null=True)
|
||||||
|
top_time_minutes = fields.IntField(null=True)
|
||||||
|
feed_time_minutes = fields.IntField(null=True)
|
||||||
comment = fields.TextField(null=True)
|
comment = fields.TextField(null=True)
|
||||||
|
|
||||||
invite_link = fields.CharField(max_length=512, null=True)
|
invite_link = fields.CharField(max_length=512, null=True)
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ class PlacementDetails(pydantic.BaseModel):
|
|||||||
cost_before_bargain: CostInfo | None = None
|
cost_before_bargain: CostInfo | None = None
|
||||||
placement_type: PlacementType | None = None
|
placement_type: PlacementType | None = None
|
||||||
format: str | None = None
|
format: str | None = None
|
||||||
|
top_time_minutes: int | None = None
|
||||||
|
feed_time_minutes: int | None = None
|
||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
creative_id: uuid.UUID | None = None
|
creative_id: uuid.UUID | None = None
|
||||||
invite_link_type: InviteLinkType | None = None
|
invite_link_type: InviteLinkType | None = None
|
||||||
@@ -110,6 +112,8 @@ class UpdatePlacementInput(pydantic.BaseModel):
|
|||||||
cost_before_bargain: CostInfo | None = None
|
cost_before_bargain: CostInfo | None = None
|
||||||
placement_type: PlacementType | None = None
|
placement_type: PlacementType | None = None
|
||||||
format: str | None = None
|
format: str | None = None
|
||||||
|
top_time_minutes: int | None = None
|
||||||
|
feed_time_minutes: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class DeletePlacementInput(pydantic.BaseModel):
|
class DeletePlacementInput(pydantic.BaseModel):
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ def _determine_status(
|
|||||||
time_on_top = int((now - published_at).total_seconds())
|
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 часа
|
# Если формат "без удаления" - размещение отработало, если прошло 24 часа
|
||||||
if required_duration is None:
|
if required_duration is None:
|
||||||
|
|||||||
@@ -109,8 +109,11 @@ def _build_info_message(
|
|||||||
datetime_str = placement.placement_at.strftime('%d.%m.%Y %H:%M')
|
datetime_str = placement.placement_at.strftime('%d.%m.%Y %H:%M')
|
||||||
lines.append(f'Дата и время: {datetime_str}')
|
lines.append(f'Дата и время: {datetime_str}')
|
||||||
|
|
||||||
# Format (optional)
|
# Format (optional) — prefer display string from numeric fields
|
||||||
if placement.format:
|
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}')
|
lines.append(f'Формат: {placement.format}')
|
||||||
|
|
||||||
# Add empty line before footer
|
# Add empty line before footer
|
||||||
|
|||||||
@@ -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),
|
cost_before_bargain=_build_cost_info(placement.cost_before_bargain_type, placement.cost_before_bargain),
|
||||||
placement_type=placement.placement_type,
|
placement_type=placement.placement_type,
|
||||||
format=placement.format,
|
format=placement.format,
|
||||||
|
top_time_minutes=placement.top_time_minutes,
|
||||||
|
feed_time_minutes=placement.feed_time_minutes,
|
||||||
comment=placement.comment,
|
comment=placement.comment,
|
||||||
)
|
)
|
||||||
if details.model_dump(exclude_none=True):
|
if details.model_dump(exclude_none=True):
|
||||||
@@ -101,6 +103,30 @@ def _build_placement_details(placement: domain.Placement) -> dto.PlacementDetail
|
|||||||
return None
|
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(
|
def _build_placement_output(
|
||||||
placement: domain.Placement,
|
placement: domain.Placement,
|
||||||
project: domain.Project | None = None,
|
project: domain.Project | None = None,
|
||||||
@@ -201,6 +227,11 @@ async def create_placements(
|
|||||||
raise domain.CreativeNotFound(creative_id)
|
raise domain.CreativeNotFound(creative_id)
|
||||||
creatives_by_id[creative_id] = creative
|
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(
|
placement = domain.Placement(
|
||||||
project_id=project.id,
|
project_id=project.id,
|
||||||
creative_id=creative.id if creative else None,
|
creative_id=creative.id if creative else None,
|
||||||
@@ -227,7 +258,9 @@ async def create_placements(
|
|||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
placement_type=channel_details.placement_type if channel_details 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)
|
await self.database.create_placement(placement)
|
||||||
|
|||||||
@@ -69,8 +69,28 @@ async def update_placement(
|
|||||||
placement.cost_before_bargain = input.cost_before_bargain.value
|
placement.cost_before_bargain = input.cost_before_bargain.value
|
||||||
if 'placement_type' in fields_set:
|
if 'placement_type' in fields_set:
|
||||||
placement.placement_type = input.placement_type
|
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
|
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)
|
await self.database.update_placement(placement)
|
||||||
|
|
||||||
|
|||||||
162
tests/test_format_parsing.py
Normal file
162
tests/test_format_parsing.py
Normal file
@@ -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
|
||||||
@@ -596,6 +596,8 @@ type PlacementDetails struct {
|
|||||||
CostBeforeBargain *CostInfo `json:"cost_before_bargain,omitempty"`
|
CostBeforeBargain *CostInfo `json:"cost_before_bargain,omitempty"`
|
||||||
PlacementType *string `json:"placement_type,omitempty"`
|
PlacementType *string `json:"placement_type,omitempty"`
|
||||||
Format *string `json:"format,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"`
|
Comment *string `json:"comment,omitempty"`
|
||||||
CreativeID *string `json:"creative_id,omitempty"`
|
CreativeID *string `json:"creative_id,omitempty"`
|
||||||
CreativeName *string `json:"creative_name,omitempty"`
|
CreativeName *string `json:"creative_name,omitempty"`
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/AlekSi/pointer v1.0.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // 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/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.29.0 // indirect
|
golang.org/x/text v0.29.0 // indirect
|
||||||
golang.org/x/time v0.5.0 // indirect
|
golang.org/x/time v0.5.0 // indirect
|
||||||
|
|||||||
@@ -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 h1:efE2spw3mfU0Ev20m0PqqvgMSm0xHzgSxlWAEmC9RC4=
|
||||||
github.com/NicoNex/echotron/v3 v3.43.0/go.mod h1:7LvjveJmezuUOeaoA3nzQduNlSPQYfq219Z+baKY04Q=
|
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=
|
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.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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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/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/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ type CreativeDetails struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *CreativeDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
|
func (s *CreativeDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||||
|
// Удаляем сообщение предыдущего экрана
|
||||||
|
if b.LastMessageID != 0 {
|
||||||
|
b.DeleteMessage(b.ChatID, b.LastMessageID)
|
||||||
|
b.LastMessageID = 0
|
||||||
|
}
|
||||||
|
|
||||||
creative, err := b.Backend.GetCreative(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.CreativeID)
|
creative, err := b.Backend.GetCreative(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.CreativeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to get creative")
|
log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to get creative")
|
||||||
@@ -94,12 +100,12 @@ func (s *CreativeDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
|||||||
// Обрабатываем общие callbacks (edit_text, add_button, add_media, delete_button, delete_media)
|
// Обрабатываем общие callbacks (edit_text, add_button, add_media, delete_button, delete_media)
|
||||||
switch {
|
switch {
|
||||||
case data == "cancel", data == "back":
|
case data == "cancel", data == "back":
|
||||||
// Отмена - возвращаемся назад
|
// Удаляем сообщения текущего экрана перед переходом
|
||||||
|
s.CleanupMessages(b)
|
||||||
if s.ViewMode && s.ViewBackTo != nil {
|
if s.ViewMode && s.ViewBackTo != nil {
|
||||||
// Если открыты из view mode, возвращаемся к просмотру
|
b.SetState(s.ViewBackTo, bot.NewMessage)
|
||||||
b.SetState(s.ViewBackTo, bot.EditMessage)
|
|
||||||
} else if s.BackState != nil {
|
} else if s.BackState != nil {
|
||||||
b.SetState(s.BackState, bot.EditMessage)
|
b.SetState(s.BackState, bot.NewMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
case data == "edit_text":
|
case data == "edit_text":
|
||||||
@@ -351,12 +357,11 @@ func (s *CreativeDetails) updateCreative(b *bot.Bot) {
|
|||||||
log.Info().Str("creative_id", s.CreativeID).Msg("Creative updated successfully")
|
log.Info().Str("creative_id", s.CreativeID).Msg("Creative updated successfully")
|
||||||
|
|
||||||
// Возвращаемся назад
|
// Возвращаемся назад
|
||||||
|
s.CleanupMessages(b)
|
||||||
if s.ViewMode && s.ViewBackTo != nil {
|
if s.ViewMode && s.ViewBackTo != nil {
|
||||||
// Если открыты из view mode, возвращаемся к просмотру
|
b.SetState(s.ViewBackTo, bot.NewMessage)
|
||||||
b.SetState(s.ViewBackTo, bot.EditMessage)
|
|
||||||
} else if s.BackState != nil {
|
} else if s.BackState != nil {
|
||||||
// Иначе возвращаемся к BackState
|
b.SetState(s.BackState, bot.NewMessage)
|
||||||
b.SetState(s.BackState, bot.EditMessage)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,7 +377,8 @@ func (s *CreativeDetails) deleteCreative(b *bot.Bot) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.CleanupMessages(b)
|
||||||
if s.BackState != nil {
|
if s.BackState != nil {
|
||||||
b.SetState(s.BackState, bot.EditMessage)
|
b.SetState(s.BackState, bot.NewMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,6 +136,19 @@ func (e *CreativeEditorFields) clearExtraMediaMessages(b *bot.Bot) {
|
|||||||
e.ExtraMediaMessageIDs = nil
|
e.ExtraMediaMessageIDs = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *CreativeEditorFields) CleanupMessages(b *bot.Bot) {
|
||||||
|
if e.CreativeMessageID != nil {
|
||||||
|
b.DeleteMessage(b.ChatID, *e.CreativeMessageID)
|
||||||
|
e.CreativeMessageID = nil
|
||||||
|
}
|
||||||
|
e.clearExtraMediaMessages(b)
|
||||||
|
if e.ControlPanelMessageID != nil {
|
||||||
|
b.DeleteMessage(b.ChatID, *e.ControlPanelMessageID)
|
||||||
|
e.ControlPanelMessageID = nil
|
||||||
|
}
|
||||||
|
b.LastMessageID = 0
|
||||||
|
}
|
||||||
|
|
||||||
const mediaGroupDebounce = 2 * time.Second
|
const mediaGroupDebounce = 2 * time.Second
|
||||||
|
|
||||||
func (e *CreativeEditorFields) scheduleMediaGroupAction(groupID string, action func()) {
|
func (e *CreativeEditorFields) scheduleMediaGroupAction(groupID string, action func()) {
|
||||||
@@ -321,6 +334,9 @@ func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) {
|
|||||||
}
|
}
|
||||||
} else if len(res.Result) > 0 {
|
} else if len(res.Result) > 0 {
|
||||||
msgID = res.Result[0].ID
|
msgID = res.Result[0].ID
|
||||||
|
for i := 1; i < len(res.Result); i++ {
|
||||||
|
e.ExtraMediaMessageIDs = append(e.ExtraMediaMessageIDs, res.Result[i].ID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if media := e.primaryMedia(); media != nil {
|
} else if media := e.primaryMedia(); media != nil {
|
||||||
|
|||||||
@@ -89,9 +89,9 @@ func (e *CreativeEditorFields) ShowTagPanel(b *bot.Bot) {
|
|||||||
|
|
||||||
func (e *CreativeEditorFields) GetTagLabel() string {
|
func (e *CreativeEditorFields) GetTagLabel() string {
|
||||||
if e.Tag != nil && *e.Tag == "production" {
|
if e.Tag != nil && *e.Tag == "production" {
|
||||||
return "🏷 Рабочий"
|
return "◉ Рабочий"
|
||||||
}
|
}
|
||||||
return "🏷 Тестовый"
|
return "◉ Тестовый"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *CreativeEditorFields) ShowAddButtonPanel(b *bot.Bot) {
|
func (e *CreativeEditorFields) ShowAddButtonPanel(b *bot.Bot) {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package screens
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/NicoNex/echotron/v3"
|
"github.com/NicoNex/echotron/v3"
|
||||||
"github.com/TelegramExchange/tgex-backend/tg_bot/backend"
|
"github.com/TelegramExchange/tgex-backend/tg_bot/backend"
|
||||||
@@ -24,15 +26,25 @@ type CreativeView struct {
|
|||||||
ProjectStatus string
|
ProjectStatus string
|
||||||
|
|
||||||
BackState bot.State
|
BackState bot.State
|
||||||
|
|
||||||
|
renaming bool // режим ввода нового названия
|
||||||
}
|
}
|
||||||
|
|
||||||
const msgCreativeView = `
|
func msgCreativeView(name string) string {
|
||||||
<b>📋 Просмотр креатива</b>
|
return fmt.Sprintf("<b>📋 %s</b>\n\nВыберите действие:", name)
|
||||||
|
}
|
||||||
Выберите действие:
|
|
||||||
`
|
|
||||||
|
|
||||||
func (s *CreativeView) Enter(b *bot.Bot, mode bot.RenderMode) {
|
func (s *CreativeView) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||||
|
// Удаляем сообщение предыдущего экрана (его клавиатуру не очищает SendCreativePreview)
|
||||||
|
if b.LastMessageID != 0 {
|
||||||
|
b.DeleteMessage(b.ChatID, b.LastMessageID)
|
||||||
|
b.LastMessageID = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сбрасываем старые ID — при re-enter всегда создаём свежие сообщения
|
||||||
|
s.CreativeMessageID = nil
|
||||||
|
s.ControlPanelMessageID = nil
|
||||||
|
|
||||||
// Загружаем креатив
|
// Загружаем креатив
|
||||||
creative, err := b.Backend.GetCreative(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.CreativeID)
|
creative, err := b.Backend.GetCreative(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.CreativeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -94,27 +106,38 @@ func (s *CreativeView) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
|||||||
|
|
||||||
switch {
|
switch {
|
||||||
case data == "back":
|
case data == "back":
|
||||||
|
s.CleanupMessages(b)
|
||||||
if s.BackState != nil {
|
if s.BackState != nil {
|
||||||
b.SetState(s.BackState, bot.EditMessage)
|
b.SetState(s.BackState, bot.NewMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case data == "rename":
|
||||||
|
s.renaming = true
|
||||||
|
s.ShowControlPanel(b, "<b>✏️ Введите новое название креатива:</b>", [][]echotron.InlineKeyboardButton{
|
||||||
|
Row(Button("← Отмена", "cancel_rename")),
|
||||||
|
})
|
||||||
|
|
||||||
|
case data == "cancel_rename":
|
||||||
|
s.renaming = false
|
||||||
|
s.showActionPanel(b)
|
||||||
|
|
||||||
case data == "edit":
|
case data == "edit":
|
||||||
// Переход к редактированию креатива
|
s.CleanupMessages(b)
|
||||||
b.SetState(&CreativeDetails{
|
b.SetState(&CreativeDetails{
|
||||||
CreativeID: s.CreativeID,
|
CreativeID: s.CreativeID,
|
||||||
ProjectID: s.ProjectID,
|
ProjectID: s.ProjectID,
|
||||||
ViewMode: true, // Флаг, что мы открылись из view mode
|
ViewMode: true,
|
||||||
ViewBackTo: s, // Возврат к этому экрану после сохранения
|
ViewBackTo: s,
|
||||||
ProjectTitle: s.ProjectTitle,
|
ProjectTitle: s.ProjectTitle,
|
||||||
BackState: s.BackState,
|
BackState: s.BackState,
|
||||||
}, bot.EditMessage)
|
}, bot.NewMessage)
|
||||||
|
|
||||||
case data == "toggle_tag":
|
case data == "toggle_tag":
|
||||||
// Переключение тега (testing <-> production)
|
// Переключение тега (testing <-> production)
|
||||||
s.toggleTag(b)
|
s.toggleTag(b)
|
||||||
|
|
||||||
case data == "purchase_creative":
|
case data == "purchase_creative":
|
||||||
// Переход к созданию закупа с предвыбором проекта и креатива
|
s.CleanupMessages(b)
|
||||||
creativeTitle := ""
|
creativeTitle := ""
|
||||||
if s.Name != nil {
|
if s.Name != nil {
|
||||||
creativeTitle = *s.Name
|
creativeTitle = *s.Name
|
||||||
@@ -136,7 +159,32 @@ func (s *CreativeView) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *CreativeView) HandleMessage(_ *bot.Bot, _ *echotron.Update) {}
|
func (s *CreativeView) HandleMessage(b *bot.Bot, u *echotron.Update) {
|
||||||
|
if !s.renaming || u.Message == nil || u.Message.Text == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newName := strings.TrimSpace(u.Message.Text)
|
||||||
|
s.DeleteUserMessage(b, u.Message.ID)
|
||||||
|
|
||||||
|
input := backend.UpdateCreativeInput{
|
||||||
|
Name: &newName,
|
||||||
|
}
|
||||||
|
_, err := b.Backend.UpdateCreative(
|
||||||
|
context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.CreativeID, input,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to rename creative")
|
||||||
|
s.ShowControlPanel(b, "<b>❌ Не удалось переименовать</b>", [][]echotron.InlineKeyboardButton{
|
||||||
|
Row(Button("← Назад", "cancel_rename")),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Name = &newName
|
||||||
|
s.renaming = false
|
||||||
|
s.showActionPanel(b)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *CreativeView) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
func (s *CreativeView) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||||
|
|
||||||
@@ -145,32 +193,24 @@ func (s *CreativeView) Exit() {}
|
|||||||
// showActionPanel показывает панель действий с кнопками
|
// showActionPanel показывает панель действий с кнопками
|
||||||
func (s *CreativeView) showActionPanel(b *bot.Bot) {
|
func (s *CreativeView) showActionPanel(b *bot.Bot) {
|
||||||
tagLabel := s.GetTagLabel()
|
tagLabel := s.GetTagLabel()
|
||||||
purchaseLabel := "🛒 Закупить на креатив"
|
|
||||||
|
|
||||||
// Если креатив архивирован (в будущем), можно изменить кнопки
|
buttons := [][]echotron.InlineKeyboardButton{
|
||||||
var buttons [][]echotron.InlineKeyboardButton
|
Row(
|
||||||
|
Button("✎ Переименовать", "rename"),
|
||||||
|
Button("✎ Редактировать", "edit"),
|
||||||
|
),
|
||||||
|
Row(Button("+ Закупить", "purchase_creative")),
|
||||||
|
Row(
|
||||||
|
Button("← Назад", "back"),
|
||||||
|
Button(tagLabel, "toggle_tag"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
// Кнопка редактирования
|
name := "Креатив"
|
||||||
buttons = append(buttons, Row(
|
if s.Name != nil && *s.Name != "" {
|
||||||
Button("✏️ Редактировать", "edit"),
|
name = *s.Name
|
||||||
))
|
}
|
||||||
|
s.ShowControlPanel(b, msgCreativeView(name), buttons)
|
||||||
// Кнопка тега
|
|
||||||
buttons = append(buttons, Row(
|
|
||||||
Button(tagLabel, "toggle_tag"),
|
|
||||||
))
|
|
||||||
|
|
||||||
// Кнопка закупа
|
|
||||||
buttons = append(buttons, Row(
|
|
||||||
Button(purchaseLabel, "purchase_creative"),
|
|
||||||
))
|
|
||||||
|
|
||||||
// Кнопка назад
|
|
||||||
buttons = append(buttons, Row(
|
|
||||||
Button("← Назад", "back"),
|
|
||||||
))
|
|
||||||
|
|
||||||
s.ShowControlPanel(b, msgCreativeView, buttons)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// toggleTag переключает тег креатива (testing <-> production)
|
// toggleTag переключает тег креатива (testing <-> production)
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ type PurchaseOptionalDetails struct {
|
|||||||
CostBeforeBargain *CostEntry
|
CostBeforeBargain *CostEntry
|
||||||
PurchaseType string
|
PurchaseType string
|
||||||
Format 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
|
Comment string
|
||||||
InviteLinkType string
|
InviteLinkType string
|
||||||
InputMode string
|
InputMode string
|
||||||
@@ -209,6 +216,9 @@ func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update)
|
|||||||
|
|
||||||
case "format_custom":
|
case "format_custom":
|
||||||
s.InputMode = "format_custom"
|
s.InputMode = "format_custom"
|
||||||
|
s.CustomFormatTopUnit = "hours"
|
||||||
|
s.CustomFormatFeedUnit = "hours"
|
||||||
|
s.CustomFormatTopValue = nil
|
||||||
s.Enter(b, bot.EditMessage)
|
s.Enter(b, bot.EditMessage)
|
||||||
|
|
||||||
case "cost_type_toggle":
|
case "cost_type_toggle":
|
||||||
@@ -318,13 +328,99 @@ func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update)
|
|||||||
if strings.HasPrefix(u.CallbackQuery.Data, "type:") {
|
if strings.HasPrefix(u.CallbackQuery.Data, "type:") {
|
||||||
value := strings.TrimPrefix(u.CallbackQuery.Data, "type:")
|
value := strings.TrimPrefix(u.CallbackQuery.Data, "type:")
|
||||||
// Нормализуем значение для БД
|
// Нормализуем значение для БД
|
||||||
|
var normalized string
|
||||||
switch value {
|
switch value {
|
||||||
case "mutual_pr":
|
case "mutual_pr":
|
||||||
s.PurchaseType = "взаимный пиар"
|
normalized = "взаимный пиар"
|
||||||
case "standard":
|
case "standard":
|
||||||
s.PurchaseType = "стандарт"
|
normalized = "стандарт"
|
||||||
}
|
}
|
||||||
s.InputMode = ""
|
if s.CurrentChannel != "" {
|
||||||
|
s.PurchaseTypeByChannel[s.CurrentChannel] = normalized
|
||||||
|
} else {
|
||||||
|
s.PurchaseType = normalized
|
||||||
|
}
|
||||||
|
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_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_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)
|
s.Enter(b, bot.EditMessage)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -371,7 +467,33 @@ func (s *PurchaseOptionalDetails) HandleMessage(b *bot.Bot, u *echotron.Update)
|
|||||||
|
|
||||||
switch s.InputMode {
|
switch s.InputMode {
|
||||||
case "format_custom":
|
case "format_custom":
|
||||||
s.setFormatValue(text)
|
value, err := strconv.Atoi(text)
|
||||||
|
if err != nil || value <= 0 {
|
||||||
|
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":
|
||||||
|
value, err := strconv.Atoi(text)
|
||||||
|
if err != nil || value <= 0 {
|
||||||
|
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":
|
case "comment":
|
||||||
if s.CurrentChannel != "" {
|
if s.CurrentChannel != "" {
|
||||||
s.CommentByChannel[s.CurrentChannel] = text
|
s.CommentByChannel[s.CurrentChannel] = text
|
||||||
@@ -699,22 +821,34 @@ func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderM
|
|||||||
text += "Выберите формат\n\n"
|
text += "Выберите формат\n\n"
|
||||||
keyboard := Keyboard(
|
keyboard := Keyboard(
|
||||||
Row(
|
Row(
|
||||||
Button("1 / 24", "format:1 / 24"),
|
Button("1ч / 24ч", "format_preset:60:1440"),
|
||||||
Button("1/48", "format:1/48"),
|
Button("1ч / 36ч", "format_preset:60:2160"),
|
||||||
Button("1 / 72", "format:1 / 72"),
|
Button("1ч / 48ч", "format_preset:60:2880"),
|
||||||
|
Button("1ч / 72ч", "format_preset:60:4320"),
|
||||||
),
|
),
|
||||||
Row(
|
Row(
|
||||||
Button("1 / (7 дней)", "format:1 / (7 дней)"),
|
Button("1ч / 7д", "format_preset:60:10080"),
|
||||||
Button("1 / (30 дней)", "format:1 / (30 дней)"),
|
Button("1ч / 30д", "format_preset:60:43200"),
|
||||||
Button("1 / (без удаления)", "format:1 / (без удаления)"),
|
Button("1ч / 60д", "format_preset:60:86400"),
|
||||||
|
Button("1ч / 90д", "format_preset:60:129600"),
|
||||||
),
|
),
|
||||||
Row(
|
Row(
|
||||||
Button("2 / 24", "format:2 / 24"),
|
Button("1ч / без удаления", "format_preset:60:0"),
|
||||||
Button("2/48", "format:2/48"),
|
|
||||||
Button("2/72", "format:2/72"),
|
|
||||||
),
|
),
|
||||||
Row(
|
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"),
|
Button("Свой формат", "format_custom"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -725,7 +859,11 @@ func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderM
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
case "format_custom":
|
case "format_custom":
|
||||||
text += "<b>Введите формат</b>\n\nНапример: <code>пост</code>"
|
s.renderCustomFormatTop(b, mode)
|
||||||
|
return
|
||||||
|
case "format_custom_feed":
|
||||||
|
s.renderCustomFormatFeed(b, mode)
|
||||||
|
return
|
||||||
case "invite_link_type":
|
case "invite_link_type":
|
||||||
text = "<i>Страница выбора типа ссылки</i>\n\n"
|
text = "<i>Страница выбора типа ссылки</i>\n\n"
|
||||||
if s.CurrentChannel != "" {
|
if s.CurrentChannel != "" {
|
||||||
@@ -777,6 +915,114 @@ func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderM
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *PurchaseOptionalDetails) renderCustomFormatTop(b *bot.Bot, mode bot.RenderMode) {
|
||||||
|
text := "<b>Свой формат — время в топе</b>\n\n"
|
||||||
|
if s.CurrentChannel != "" {
|
||||||
|
text += fmt.Sprintf("Канал: <b>%s</b>\n\n", channelLabelByKey(s.Channels, s.CurrentChannel))
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows [][]echotron.InlineKeyboardButton
|
||||||
|
|
||||||
|
if s.CustomFormatTopUnit == "minutes" {
|
||||||
|
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")),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
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")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 := "<b>Свой формат — время в ленте</b>\n\n"
|
||||||
|
if s.CurrentChannel != "" {
|
||||||
|
text += fmt.Sprintf("Канал: <b>%s</b>\n\n", channelLabelByKey(s.Channels, s.CurrentChannel))
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.CustomFormatTopValue != nil {
|
||||||
|
text += fmt.Sprintf("Время в топе: <b>%s</b> ✓\n\n", formatDuration(*s.CustomFormatTopValue, "top"))
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows [][]echotron.InlineKeyboardButton
|
||||||
|
|
||||||
|
if s.CustomFormatFeedUnit == "days" {
|
||||||
|
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 += "Выберите или введите число (в часах):"
|
||||||
|
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_back_to_top")))
|
||||||
|
|
||||||
|
b.Render(text, Keyboard(rows...), mode)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *PurchaseOptionalDetails) renderModeSelect(b *bot.Bot, mode bot.RenderMode) {
|
func (s *PurchaseOptionalDetails) renderModeSelect(b *bot.Bot, mode bot.RenderMode) {
|
||||||
text := "<b>⚙ Переключить режим параметра</b>\n\n"
|
text := "<b>⚙ Переключить режим параметра</b>\n\n"
|
||||||
|
|
||||||
@@ -879,6 +1125,10 @@ func (s *PurchaseOptionalDetails) renderParamScreens(b *bot.Bot, mode bot.Render
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *PurchaseOptionalDetails) renderParamChannels(b *bot.Bot, mode bot.RenderMode, param string) {
|
func (s *PurchaseOptionalDetails) renderParamChannels(b *bot.Bot, mode bot.RenderMode, param string) {
|
||||||
|
// Сбрасываем на редактирование при смене параметра
|
||||||
|
if s.CurrentParam != param || s.ChannelEditMode == "" {
|
||||||
|
s.ChannelEditMode = "edit"
|
||||||
|
}
|
||||||
s.CurrentParam = param
|
s.CurrentParam = param
|
||||||
text := fmt.Sprintf("<b>%s — по каналам</b>\n\n", s.paramTitle(param))
|
text := fmt.Sprintf("<b>%s — по каналам</b>\n\n", s.paramTitle(param))
|
||||||
text += s.renderParamChannelSummary(param) + "\n\n"
|
text += s.renderParamChannelSummary(param) + "\n\n"
|
||||||
@@ -896,10 +1146,6 @@ func (s *PurchaseOptionalDetails) renderParamChannels(b *bot.Bot, mode bot.Rende
|
|||||||
}
|
}
|
||||||
start, end := ui2.GetPageBounds(s.ParamPage, perPage, total)
|
start, end := ui2.GetPageBounds(s.ParamPage, perPage, total)
|
||||||
|
|
||||||
// Устанавливаем режим по умолчанию
|
|
||||||
if s.ChannelEditMode == "" {
|
|
||||||
s.ChannelEditMode = "edit"
|
|
||||||
}
|
|
||||||
|
|
||||||
var rows [][]echotron.InlineKeyboardButton
|
var rows [][]echotron.InlineKeyboardButton
|
||||||
|
|
||||||
@@ -1382,6 +1628,8 @@ func (s *PurchaseOptionalDetails) copyCommonValueToChannels(param string) {
|
|||||||
for _, ch := range s.Channels {
|
for _, ch := range s.Channels {
|
||||||
key := channelKey(ch)
|
key := channelKey(ch)
|
||||||
s.FormatByChannel[key] = s.Format
|
s.FormatByChannel[key] = s.Format
|
||||||
|
s.TopTimeByChannel[key] = s.TopTimeMinutes
|
||||||
|
s.FeedTimeByChannel[key] = s.FeedTimeMinutes
|
||||||
}
|
}
|
||||||
case "invite_link_type":
|
case "invite_link_type":
|
||||||
if s.InviteLinkType == "" {
|
if s.InviteLinkType == "" {
|
||||||
@@ -1512,6 +1760,8 @@ func (s *PurchaseOptionalDetails) clearParamValue(param, channelKey string) {
|
|||||||
delete(s.CommentByChannel, channelKey)
|
delete(s.CommentByChannel, channelKey)
|
||||||
case "format":
|
case "format":
|
||||||
delete(s.FormatByChannel, channelKey)
|
delete(s.FormatByChannel, channelKey)
|
||||||
|
delete(s.TopTimeByChannel, channelKey)
|
||||||
|
delete(s.FeedTimeByChannel, channelKey)
|
||||||
case "invite_link_type":
|
case "invite_link_type":
|
||||||
delete(s.InviteLinkTypeByChannel, channelKey)
|
delete(s.InviteLinkTypeByChannel, channelKey)
|
||||||
}
|
}
|
||||||
@@ -1660,6 +1910,18 @@ func (s *PurchaseOptionalDetails) ensureDefaults() {
|
|||||||
if s.FormatByChannel == nil {
|
if s.FormatByChannel == nil {
|
||||||
s.FormatByChannel = make(map[string]string)
|
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 {
|
if s.InviteLinkTypeByChannel == nil {
|
||||||
s.InviteLinkTypeByChannel = make(map[string]string)
|
s.InviteLinkTypeByChannel = make(map[string]string)
|
||||||
}
|
}
|
||||||
@@ -1709,6 +1971,16 @@ func (s *PurchaseOptionalDetails) syncChannelMaps() {
|
|||||||
delete(s.FormatByChannel, key)
|
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 {
|
for key := range s.InviteLinkTypeByChannel {
|
||||||
if _, ok := valid[key]; !ok {
|
if _, ok := valid[key]; !ok {
|
||||||
delete(s.InviteLinkTypeByChannel, key)
|
delete(s.InviteLinkTypeByChannel, key)
|
||||||
@@ -2156,12 +2428,64 @@ func (s *PurchaseOptionalDetails) setCostBeforeValue(value float64) {
|
|||||||
s.CostBeforeByChannel[s.CurrentChannel] = entry
|
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) {
|
func (s *PurchaseOptionalDetails) setFormatValue(value string) {
|
||||||
if s.CurrentChannel == "" {
|
if s.CurrentChannel == "" {
|
||||||
s.Format = value
|
s.Format = value
|
||||||
|
s.TopTimeMinutes = nil
|
||||||
|
s.FeedTimeMinutes = nil
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.FormatByChannel[s.CurrentChannel] = value
|
s.FormatByChannel[s.CurrentChannel] = value
|
||||||
|
delete(s.TopTimeByChannel, s.CurrentChannel)
|
||||||
|
delete(s.FeedTimeByChannel, s.CurrentChannel)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PurchaseOptionalDetails) setInviteLinkTypeValue(value string) {
|
func (s *PurchaseOptionalDetails) setInviteLinkTypeValue(value string) {
|
||||||
@@ -2294,7 +2618,10 @@ func (s *PurchaseOptionalDetails) buildChannelDetails(channelKey string, placeme
|
|||||||
// Placement type
|
// Placement type
|
||||||
if s.PurchaseTypeMode == "per_channel" && len(s.Channels) > 1 {
|
if s.PurchaseTypeMode == "per_channel" && len(s.Channels) > 1 {
|
||||||
if value, ok := s.PurchaseTypeByChannel[channelKey]; ok && value != "" {
|
if value, ok := s.PurchaseTypeByChannel[channelKey]; ok && value != "" {
|
||||||
details.PlacementType = &value
|
normalized := normalizePurchaseTypeValue(value)
|
||||||
|
if normalized != nil {
|
||||||
|
details.PlacementType = normalized
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if placementType != nil {
|
} else if placementType != nil {
|
||||||
details.PlacementType = placementType
|
details.PlacementType = placementType
|
||||||
@@ -2314,8 +2641,18 @@ func (s *PurchaseOptionalDetails) buildChannelDetails(channelKey string, placeme
|
|||||||
if value, ok := s.FormatByChannel[channelKey]; ok && value != "" {
|
if value, ok := s.FormatByChannel[channelKey]; ok && value != "" {
|
||||||
details.Format = &value
|
details.Format = &value
|
||||||
}
|
}
|
||||||
} else if s.Format != "" {
|
if value, ok := s.TopTimeByChannel[channelKey]; ok && value != nil {
|
||||||
details.Format = &s.Format
|
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
|
// Invite link type
|
||||||
@@ -2351,25 +2688,35 @@ func (s *PurchaseOptionalDetails) isChannelDetailsEmpty(details *backend.Placeme
|
|||||||
details.CostBeforeBargain == nil &&
|
details.CostBeforeBargain == nil &&
|
||||||
details.PlacementType == nil &&
|
details.PlacementType == nil &&
|
||||||
details.Format == nil &&
|
details.Format == nil &&
|
||||||
|
details.TopTimeMinutes == nil &&
|
||||||
|
details.FeedTimeMinutes == nil &&
|
||||||
details.Comment == nil &&
|
details.Comment == nil &&
|
||||||
details.InviteLinkType == nil
|
details.InviteLinkType == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PurchaseOptionalDetails) normalizePurchaseType() (*string, bool) {
|
func normalizePurchaseTypeValue(raw string) *string {
|
||||||
raw := strings.TrimSpace(s.PurchaseType)
|
raw = strings.TrimSpace(raw)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
return nil, true
|
return nil
|
||||||
}
|
}
|
||||||
normalized := strings.ToLower(raw)
|
normalized := strings.ToLower(raw)
|
||||||
normalized = strings.TrimSpace(strings.Trim(normalized, "."))
|
normalized = strings.TrimSpace(strings.Trim(normalized, "."))
|
||||||
switch normalized {
|
switch normalized {
|
||||||
case "взаимный пиар", "взаимнопиар", "mutual_pr", "mutual pr", "вп", "vp":
|
case "взаимный пиар", "взаимнопиар", "self_promo", "mutual_pr", "mutual pr", "вп", "vp":
|
||||||
value := "mutual_pr"
|
value := "self_promo"
|
||||||
return &value, true
|
return &value
|
||||||
case "стандарт", "standard":
|
case "стандарт", "standard":
|
||||||
value := "standard"
|
value := "standard"
|
||||||
return &value, true
|
return &value
|
||||||
default:
|
default:
|
||||||
return nil, false
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *PurchaseOptionalDetails) normalizePurchaseType() (*string, bool) {
|
||||||
|
result := normalizePurchaseTypeValue(s.PurchaseType)
|
||||||
|
if s.PurchaseType == "" {
|
||||||
|
return nil, true
|
||||||
|
}
|
||||||
|
return result, result != nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import (
|
|||||||
|
|
||||||
"github.com/NicoNex/echotron/v3"
|
"github.com/NicoNex/echotron/v3"
|
||||||
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
|
"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 (
|
const (
|
||||||
@@ -381,6 +384,7 @@ func (s *DateTimePicker) handleDaySelect(payload string, b *bot.Bot) {
|
|||||||
s.view = pickerViewTimeCombined
|
s.view = pickerViewTimeCombined
|
||||||
s.selectedHour = nil
|
s.selectedHour = nil
|
||||||
s.selectedMinute = nil
|
s.selectedMinute = nil
|
||||||
|
s.Enter(b, bot.EditMessage)
|
||||||
} else {
|
} else {
|
||||||
// Сразу подтверждаем выбор для даты без времени
|
// Сразу подтверждаем выбор для даты без времени
|
||||||
s.confirmSelection(b)
|
s.confirmSelection(b)
|
||||||
@@ -629,192 +633,52 @@ func (s *DateTimePicker) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
|||||||
|
|
||||||
func (s *DateTimePicker) Exit() {}
|
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(
|
func parseDateTimeInput(
|
||||||
input string,
|
input string,
|
||||||
currentDate *time.Time,
|
_ *time.Time,
|
||||||
currentHour *int,
|
_ *int,
|
||||||
currentMinute *int,
|
_ *int,
|
||||||
allowPast bool,
|
allowPast bool,
|
||||||
) (time.Time, bool, bool) {
|
) (time.Time, bool, bool) {
|
||||||
text := strings.ToLower(strings.TrimSpace(input))
|
|
||||||
text = strings.ReplaceAll(text, ",", " ")
|
|
||||||
text = strings.ReplaceAll(text, " ", " ")
|
|
||||||
|
|
||||||
now := time.Now().In(MskLocation)
|
now := time.Now().In(MskLocation)
|
||||||
baseDate := now
|
|
||||||
if currentDate != nil {
|
|
||||||
baseDate = currentDate.In(MskLocation)
|
|
||||||
}
|
|
||||||
|
|
||||||
date, dateOk := parseDate(text, baseDate)
|
r, err := whenParser.Parse(input, now)
|
||||||
hour, minute, timeOk := parseTime(text, currentHour, currentMinute)
|
if err != nil || r == nil {
|
||||||
|
|
||||||
if !dateOk && !timeOk {
|
|
||||||
return time.Time{}, false, false
|
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)
|
result := r.Time.In(MskLocation)
|
||||||
if !allowPast && value.Before(now) {
|
matched := strings.ToLower(r.Text)
|
||||||
|
|
||||||
|
hasDate := detectDate(matched)
|
||||||
|
hasTime := detectTime(matched)
|
||||||
|
|
||||||
|
if !allowPast && result.Before(now) {
|
||||||
return time.Time{}, false, false
|
return time.Time{}, false, false
|
||||||
}
|
}
|
||||||
return value, dateOk, timeOk
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseDate(text string, baseDate time.Time) (time.Time, bool) {
|
return result, hasDate, hasTime
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func WeekdayName(day time.Weekday) string {
|
func WeekdayName(day time.Weekday) string {
|
||||||
|
|||||||
Reference in New Issue
Block a user