From c04faf81320d42095e4cf7ef5324477fa56589e8 Mon Sep 17 00:00:00 2001 From: Artem Tsyrulnikov Date: Sat, 3 Jan 2026 16:11:05 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BA=D1=80=D0=B5=D0=B0=D1=82=D0=B8=D0=B2?= =?UTF-8?q?=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- migrations/models/2_20260103113235_update.py | 95 ++++ src/controller/http_v1/internal.py | 14 +- src/domain/__init__.py | 6 +- src/domain/creative.py | 16 +- src/domain/error.py | 4 + src/dto/__init__.py | 2 + src/dto/creative.py | 16 + src/usecase/auth/get_jwt_by_telegram_id.py | 30 +- src/usecase/creative/create_creative.py | 9 + src/usecase/creative/get_creative.py | 3 + src/usecase/creative/get_creatives.py | 3 + src/usecase/creative/update_creative.py | 12 + .../adapter/backend/backend_client.go | 29 ++ telegram_bot/screens/date_time_picker.go | 413 +++++++++++++++--- telegram_bot/screens/project_details.go | 4 +- 15 files changed, 597 insertions(+), 59 deletions(-) create mode 100644 migrations/models/2_20260103113235_update.py diff --git a/migrations/models/2_20260103113235_update.py b/migrations/models/2_20260103113235_update.py new file mode 100644 index 0000000..2bbf382 --- /dev/null +++ b/migrations/models/2_20260103113235_update.py @@ -0,0 +1,95 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "creative" ADD "buttons" JSONB NOT NULL; + ALTER TABLE "creative" ADD "media_data" BYTEA; + ALTER TABLE "creative" ADD "media_file_id" VARCHAR(512); + ALTER TABLE "creative" ADD "media_type" VARCHAR(32);""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "creative" DROP COLUMN "buttons"; + ALTER TABLE "creative" DROP COLUMN "media_data"; + ALTER TABLE "creative" DROP COLUMN "media_file_id"; + ALTER TABLE "creative" DROP COLUMN "media_type";""" + + +MODELS_STATE = ( + "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" + "a4" +) diff --git a/src/controller/http_v1/internal.py b/src/controller/http_v1/internal.py index 68bedfb..4d66193 100644 --- a/src/controller/http_v1/internal.py +++ b/src/controller/http_v1/internal.py @@ -29,8 +29,18 @@ class ValidateTokenRequest(BaseModel): @internal_router.get('/auth/jwt') -async def get_jwt_by_telegram_id(telegram_id: int) -> dto.ValidateLoginTokenOutput: - return await deps.get_usecase().get_jwt_by_telegram_id(telegram_id) +async def get_jwt_by_telegram_id( + telegram_id: int, + username: str | None = None, + first_name: str | None = None, + last_name: str | None = None, +) -> dto.ValidateLoginTokenOutput: + return await deps.get_usecase().get_jwt_by_telegram_id( + telegram_id=telegram_id, + username=username, + first_name=first_name, + last_name=last_name, + ) @internal_router.get('/projects/{workspace_id}/{project_id}') diff --git a/src/domain/__init__.py b/src/domain/__init__.py index 44ff8d3..284d265 100644 --- a/src/domain/__init__.py +++ b/src/domain/__init__.py @@ -21,6 +21,8 @@ __all__ = ( 'PurchaseChannelStatus', 'Creative', 'replace_invite_link_with_tag', + 'validate_media_size', + 'MAX_CREATIVE_MEDIA_BYTES', 'Placement', 'Post', 'Subscription', @@ -53,18 +55,20 @@ __all__ = ( 'CreativeInUse', 'CreativeInviteLinkNotFound', 'CreativeMultipleInviteLinks', + 'CreativeMediaTooLarge', 'PlacementNotFound', 'UserByUsernameNotFound', ) from .channel import Channel -from .creative import Creative, CreativeStatus, replace_invite_link_with_tag +from .creative import Creative, CreativeStatus, MAX_CREATIVE_MEDIA_BYTES, replace_invite_link_with_tag, validate_media_size from .error import ( ChannelAlreadyExists, ChannelNoAdminRights, ChannelNotFound, CreativeInUse, CreativeInviteLinkNotFound, + CreativeMediaTooLarge, CreativeMultipleInviteLinks, CreativeNotFound, LoginTokenAlreadyUsed, diff --git a/src/domain/creative.py b/src/domain/creative.py index b073989..0567b1b 100644 --- a/src/domain/creative.py +++ b/src/domain/creative.py @@ -6,7 +6,7 @@ from uuid import UUID from tortoise import fields from .base import TimestampedModel -from .error import CreativeInviteLinkNotFound, CreativeMultipleInviteLinks +from .error import CreativeInviteLinkNotFound, CreativeMediaTooLarge, CreativeMultipleInviteLinks if TYPE_CHECKING: from .project import Project @@ -21,6 +21,10 @@ class CreativeStatus(str, enum.Enum): class Creative(TimestampedModel): name = fields.CharField(max_length=255) text = fields.TextField() + media_type = fields.CharField(max_length=32, null=True) + media_file_id = fields.CharField(max_length=512, null=True) + media_data = fields.BinaryField(null=True) + buttons = fields.JSONField(default=list) status = fields.CharEnumField(CreativeStatus, default=CreativeStatus.ACTIVE) workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField( @@ -38,6 +42,9 @@ class Creative(TimestampedModel): table = 'creative' +MAX_CREATIVE_MEDIA_BYTES = 20 * 1024 * 1024 + + _INVITE_LINK_HTML = re.compile(r'([^<]*)', re.IGNORECASE) _INVITE_LINK_PLAIN = re.compile(r'https?://t\.me/\+[a-zA-Z0-9_-]+', re.IGNORECASE) @@ -57,3 +64,10 @@ def replace_invite_link_with_tag(text: str) -> str: if html_count == 1: return _INVITE_LINK_HTML.sub(r'\1', text) return _INVITE_LINK_PLAIN.sub('', text) + + +def validate_media_size(media_data: bytes | None) -> None: + if media_data is None: + return + if len(media_data) > MAX_CREATIVE_MEDIA_BYTES: + raise CreativeMediaTooLarge(MAX_CREATIVE_MEDIA_BYTES) diff --git a/src/domain/error.py b/src/domain/error.py index 897658a..702e3cc 100644 --- a/src/domain/error.py +++ b/src/domain/error.py @@ -79,6 +79,10 @@ def CreativeMultipleInviteLinks() -> HTTPException: return HTTPException(status.HTTP_400_BAD_REQUEST, 'Creative text must contain only one invite link') +def CreativeMediaTooLarge(max_bytes: int) -> HTTPException: + return HTTPException(status.HTTP_400_BAD_REQUEST, f'Creative media is too large (max {max_bytes} bytes)') + + def CreativeInUse(creative_id: uuid.UUID) -> HTTPException: return HTTPException( status.HTTP_400_BAD_REQUEST, diff --git a/src/dto/__init__.py b/src/dto/__init__.py index 755a0d7..b6805dd 100644 --- a/src/dto/__init__.py +++ b/src/dto/__init__.py @@ -22,6 +22,7 @@ __all__ = ( 'GetPurchasesInput', 'GetPurchasesOutput', 'GetPurchaseInput', + 'CreativeButton', 'CreativeOutput', 'GetCreativesInput', 'GetCreativesOutput', @@ -95,6 +96,7 @@ from .channel import ( ) from .creative import ( CreateCreativeInput, + CreativeButton, CreativeOutput, DeleteCreativeInput, GetCreativeInput, diff --git a/src/dto/creative.py b/src/dto/creative.py index 48aa6c0..04aedb0 100644 --- a/src/dto/creative.py +++ b/src/dto/creative.py @@ -6,10 +6,18 @@ import pydantic from src import domain +class CreativeButton(pydantic.BaseModel): + text: str + url: str + + class CreativeOutput(pydantic.BaseModel): id: uuid.UUID name: str text: str + media_type: str | None + media_file_id: str | None + buttons: list[CreativeButton] project_id: uuid.UUID project_channel_title: str created_at: datetime.datetime @@ -37,11 +45,19 @@ class GetCreativeInput(pydantic.BaseModel): class CreateCreativeInput(pydantic.BaseModel): name: str text: str + media_type: str | None = None + media_file_id: str | None = None + media_data: bytes | None = None + buttons: list[CreativeButton] = pydantic.Field(default_factory=list) class UpdateCreativeInput(pydantic.BaseModel): name: str | None = None text: str | None = None + media_type: str | None = None + media_file_id: str | None = None + media_data: bytes | None = None + buttons: list[CreativeButton] | None = None status: domain.CreativeStatus | None = None diff --git a/src/usecase/auth/get_jwt_by_telegram_id.py b/src/usecase/auth/get_jwt_by_telegram_id.py index cfc8f1d..c753548 100644 --- a/src/usecase/auth/get_jwt_by_telegram_id.py +++ b/src/usecase/auth/get_jwt_by_telegram_id.py @@ -6,11 +6,35 @@ if typing.TYPE_CHECKING: from .. import Usecase -async def get_jwt_by_telegram_id(self: 'Usecase', telegram_id: int) -> dto.ValidateLoginTokenOutput: +async def get_jwt_by_telegram_id( + self: 'Usecase', + telegram_id: int, + username: str | None = None, + first_name: str | None = None, + last_name: str | None = None, +) -> dto.ValidateLoginTokenOutput: user = await self.database.get_user(telegram_id=telegram_id) - if not user: - raise domain.UserNotFound() + user = domain.User( + telegram_id=telegram_id, + username=username, + first_name=first_name, + last_name=last_name, + ) + await self.database.create_user(user) + else: + updated = False + if username is not None and user.username != username: + user.username = username + updated = True + if first_name is not None and user.first_name != first_name: + user.first_name = first_name + updated = True + if last_name is not None and user.last_name != last_name: + user.last_name = last_name + updated = True + if updated: + await self.database.update_user(user) access_token = self.jwt_encoder.encode_access_token( user_id=user.id, diff --git a/src/usecase/creative/create_creative.py b/src/usecase/creative/create_creative.py index 3131c0e..a01c820 100644 --- a/src/usecase/creative/create_creative.py +++ b/src/usecase/creative/create_creative.py @@ -22,11 +22,17 @@ async def create_creative( log.warning('User %s attempted to create creative for unavailable project %s', user_id, project_id) raise domain.ProjectNotFound(project_id) + domain.validate_media_size(input.media_data) + creative_text = domain.replace_invite_link_with_tag(input.text) creative = domain.Creative( name=input.name, text=creative_text, + media_type=input.media_type, + media_file_id=input.media_file_id, + media_data=input.media_data, + buttons=[button.model_dump() for button in input.buttons], status=domain.CreativeStatus.ACTIVE, project_id=project.id, workspace_id=workspace_id, @@ -37,6 +43,9 @@ async def create_creative( id=creative.id, name=creative.name, text=creative.text, + media_type=creative.media_type, + media_file_id=creative.media_file_id, + buttons=creative.buttons, project_id=project.id, project_channel_title=project.channel.title, created_at=creative.created_at, diff --git a/src/usecase/creative/get_creative.py b/src/usecase/creative/get_creative.py index ed62a53..120b37f 100644 --- a/src/usecase/creative/get_creative.py +++ b/src/usecase/creative/get_creative.py @@ -26,6 +26,9 @@ async def get_creative(self: 'Usecase', input: dto.GetCreativeInput) -> dto.Crea id=creative.id, name=creative.name, text=creative.text, + media_type=creative.media_type, + media_file_id=creative.media_file_id, + buttons=creative.buttons, project_id=creative.project_id, project_channel_title=creative.project.channel.title, created_at=creative.created_at, diff --git a/src/usecase/creative/get_creatives.py b/src/usecase/creative/get_creatives.py index c9ec8e6..7e75904 100644 --- a/src/usecase/creative/get_creatives.py +++ b/src/usecase/creative/get_creatives.py @@ -32,6 +32,9 @@ async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> list[d id=creative.id, name=creative.name, text=creative.text, + media_type=creative.media_type, + media_file_id=creative.media_file_id, + buttons=creative.buttons, project_id=creative.project_id, project_channel_title=creative.project.channel.title, created_at=creative.created_at, diff --git a/src/usecase/creative/update_creative.py b/src/usecase/creative/update_creative.py index b03829b..6b6b7f3 100644 --- a/src/usecase/creative/update_creative.py +++ b/src/usecase/creative/update_creative.py @@ -30,6 +30,15 @@ async def update_creative( creative.name = input.name if input.text: creative.text = domain.replace_invite_link_with_tag(input.text) + if input.media_data is not None: + domain.validate_media_size(input.media_data) + creative.media_data = input.media_data + if input.media_type is not None: + creative.media_type = input.media_type + if input.media_file_id is not None: + creative.media_file_id = input.media_file_id + if input.buttons is not None: + creative.buttons = [button.model_dump() for button in input.buttons] if input.status: creative.status = input.status @@ -41,6 +50,9 @@ async def update_creative( id=creative.id, name=creative.name, text=creative.text, + media_type=creative.media_type, + media_file_id=creative.media_file_id, + buttons=creative.buttons, project_id=creative.project_id, project_channel_title=creative.project.channel.title, created_at=creative.created_at, diff --git a/telegram_bot/adapter/backend/backend_client.go b/telegram_bot/adapter/backend/backend_client.go index 5fc9207..44c6619 100644 --- a/telegram_bot/adapter/backend/backend_client.go +++ b/telegram_bot/adapter/backend/backend_client.go @@ -135,6 +135,35 @@ func (c *Client) GetJWTByTelegramID( return resp.AccessToken, err } +func (c *Client) GetJWTByTelegramUser( + ctx context.Context, + telegramID int64, + username *string, + firstName *string, + lastName *string, +) (string, error) { + q := url.Values{} + q.Set("telegram_id", fmt.Sprint(telegramID)) + if username != nil && *username != "" { + q.Set("username", *username) + } + if firstName != nil && *firstName != "" { + q.Set("first_name", *firstName) + } + if lastName != nil && *lastName != "" { + q.Set("last_name", *lastName) + } + + path := "api/v1/internal/auth/jwt?" + q.Encode() + + var resp struct { + AccessToken string `json:"access_token"` + } + + err := c.do(ctx, http.MethodGet, path, nil, &resp) + return resp.AccessToken, err +} + type Workspace struct { ID string `json:"id"` Name string `json:"name"` diff --git a/telegram_bot/screens/date_time_picker.go b/telegram_bot/screens/date_time_picker.go index 00901c7..a4056e1 100644 --- a/telegram_bot/screens/date_time_picker.go +++ b/telegram_bot/screens/date_time_picker.go @@ -2,6 +2,7 @@ package screens import ( "fmt" + "regexp" "strings" "time" @@ -10,12 +11,11 @@ import ( ) const ( - pickerViewCalendar = "calendar" - pickerViewMonths = "months" - pickerViewTimeHour = "time_hour" - pickerViewTimeMin = "time_minute" - pickerViewConfirm = "confirm" - dtpCallbackPrefix = "dtp:" + pickerViewCalendar = "calendar" + pickerViewMonths = "months" + pickerViewTimeCombined = "time_combined" + pickerViewConfirm = "confirm" + dtpCallbackPrefix = "dtp:" ) var mskLocation = time.FixedZone("MSK", 3*60*60) @@ -72,7 +72,9 @@ func NewDateTimePicker(cfg DateTimePickerConfig) *DateTimePicker { } func (s *DateTimePicker) Enter(b *bot.Bot, mode bot.RenderMode) { - text := fmt.Sprintf("… › %s\n\n%s\n\n", s.Title, s.Title) + text := fmt.Sprintf("… › %s\n\n", s.Title) + text += fmt.Sprintf("%s\n", s.formatSelectedDateTime()) + text += "\n" switch s.view { case pickerViewCalendar: text += s.renderCalendarTitle() @@ -80,14 +82,8 @@ func (s *DateTimePicker) Enter(b *bot.Bot, mode bot.RenderMode) { case pickerViewMonths: text += "Выберите месяц\n\n" b.Render(text, s.monthsKeyboard(), mode) - case pickerViewTimeHour: - text += fmt.Sprintf("Выбрано: %s\n\n", s.formatTimePreview(false)) - text += "Выберите час\n\n" - b.Render(text, s.timeHourKeyboard(), mode) - case pickerViewTimeMin: - text += fmt.Sprintf("Выбрано: %s\n\n", s.formatTimePreview(false)) - text += "Выберите минуты\n\n" - b.Render(text, s.timeMinuteKeyboard(), mode) + case pickerViewTimeCombined: + b.Render(text, s.timeCombinedKeyboard(), mode) case pickerViewConfirm: text += s.renderSelectedDateTime() b.Render(text, s.confirmKeyboard(), mode) @@ -152,13 +148,11 @@ func (s *DateTimePicker) handleBack(b *bot.Bot) { switch s.view { case pickerViewMonths: s.view = pickerViewCalendar - case pickerViewTimeHour: + case pickerViewTimeCombined: s.view = pickerViewCalendar - case pickerViewTimeMin: - s.view = pickerViewTimeHour case pickerViewConfirm: if s.IncludeTime { - s.view = pickerViewTimeMin + s.view = pickerViewTimeCombined } else { s.view = pickerViewCalendar } @@ -172,23 +166,17 @@ func (s *DateTimePicker) handleBack(b *bot.Bot) { } func (s *DateTimePicker) renderCalendarTitle() string { - header := monthName(s.month) - if s.selectedDate != nil { - header = fmt.Sprintf("%s\nВыбрано: %s", header, s.selectedDate.In(mskLocation).Format("02.01.2006")) + if s.year == time.Now().In(mskLocation).Year() { + return "\n" } - return header + "\n\n" + return fmt.Sprintf("%d\n\n", s.year) } func (s *DateTimePicker) calendarKeyboard() echotron.InlineKeyboardMarkup { var rows [][]echotron.InlineKeyboardButton rows = append(rows, bot.Row( - bot.Button("«", dtpCallbackPrefix+"year_prev"), - bot.Button(fmt.Sprintf("%d", s.year), "empty"), - bot.Button("»", dtpCallbackPrefix+"year_next"), - )) - rows = append(rows, bot.Row( - bot.Button("←", dtpCallbackPrefix+"month_prev"), + s.monthPrevButton(), bot.Button(monthName(s.month), dtpCallbackPrefix+"open_months"), bot.Button("→", dtpCallbackPrefix+"month_next"), )) @@ -248,11 +236,6 @@ func (s *DateTimePicker) calendarKeyboard() echotron.InlineKeyboardMarkup { func (s *DateTimePicker) monthsKeyboard() echotron.InlineKeyboardMarkup { var rows [][]echotron.InlineKeyboardButton - rows = append(rows, bot.Row( - bot.Button("«", dtpCallbackPrefix+"year_prev"), - bot.Button(fmt.Sprintf("%d", s.year), "empty"), - bot.Button("»", dtpCallbackPrefix+"year_next"), - )) months := []string{"Янв", "Фев", "Мар", "Апр", "Май", "Июн", "Июл", "Авг", "Сен", "Окт", "Ноя", "Дек"} for i := 0; i < 12; i += 3 { @@ -270,32 +253,35 @@ func (s *DateTimePicker) monthsKeyboard() echotron.InlineKeyboardMarkup { return bot.Keyboard(rows...) } -func (s *DateTimePicker) timeHourKeyboard() echotron.InlineKeyboardMarkup { +func (s *DateTimePicker) timeCombinedKeyboard() echotron.InlineKeyboardMarkup { var rows [][]echotron.InlineKeyboardButton - for i := 0; i < 24; i += 4 { + rows = append(rows, bot.Row(bot.Button("Часы", "empty"))) + for i := 0; i < 24; i += 6 { rows = append(rows, bot.Row( s.hourButton(i), s.hourButton(i+1), s.hourButton(i+2), s.hourButton(i+3), + s.hourButton(i+4), + s.hourButton(i+5), )) } - rows = append(rows, bot.Row(bot.Button("← Назад", "back"))) - return bot.Keyboard(rows...) -} - -func (s *DateTimePicker) timeMinuteKeyboard() echotron.InlineKeyboardMarkup { - var rows [][]echotron.InlineKeyboardButton + rows = append(rows, bot.Row(bot.Button("Минуты", "empty"))) minutes := []int{0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55} - for i := 0; i < len(minutes); i += 4 { + for i := 0; i < len(minutes); i += 6 { rows = append(rows, bot.Row( s.minuteButton(minutes[i]), s.minuteButton(minutes[i+1]), s.minuteButton(minutes[i+2]), s.minuteButton(minutes[i+3]), + s.minuteButton(minutes[i+4]), + s.minuteButton(minutes[i+5]), )) } - rows = append(rows, bot.Row(bot.Button("← Назад", "back"))) + rows = append(rows, bot.Row( + bot.Button("← Назад", "back"), + bot.Button("Готово", dtpCallbackPrefix+"confirm"), + )) return bot.Keyboard(rows...) } @@ -310,7 +296,7 @@ func (s *DateTimePicker) confirmKeyboard() echotron.InlineKeyboardMarkup { func (s *DateTimePicker) renderSelectedDateTime() string { if s.selectedDate == nil { - return "Дата не выбрана" + return "—" } hour := 0 minute := 0 @@ -321,7 +307,7 @@ func (s *DateTimePicker) renderSelectedDateTime() string { minute = *s.selectedMinute } value := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), hour, minute, 0, 0, mskLocation) - return fmt.Sprintf("Выбрано: %s\n\nНажмите «Готово»", value.Format("02.01.2006 15:04")) + return fmt.Sprintf("%s", formatCompactDateTime(value)) } func (s *DateTimePicker) formatTimePreview(showPlaceholders bool) string { @@ -340,6 +326,41 @@ func (s *DateTimePicker) formatTimePreview(showPlaceholders bool) string { return fmt.Sprintf("%s:%s", hour, minute) } +func (s *DateTimePicker) formatSelectedDateTime() string { + displayDate, ok := s.displayDate() + if !ok { + return "—" + } + timeLabel := "--:--" + if s.selectedHour != nil && s.selectedMinute != nil { + timeLabel = fmt.Sprintf("%02d:%02d", *s.selectedHour, *s.selectedMinute) + } + weekday := weekdayName(displayDate.Weekday()) + month := monthShort(displayDate.Month()) + return fmt.Sprintf("%s %02d %s %s %dг.", weekday, displayDate.Day(), month, timeLabel, displayDate.Year()) +} + +func (s *DateTimePicker) displayDate() (time.Time, bool) { + if s.selectedDate == nil { + if s.view == pickerViewCalendar || s.view == pickerViewMonths { + return time.Date(s.year, s.month, 1, 0, 0, 0, 0, mskLocation), true + } + return time.Time{}, false + } + + if (s.view == pickerViewCalendar || s.view == pickerViewMonths) && + (s.selectedDate.Year() != s.year || s.selectedDate.Month() != s.month) { + day := s.selectedDate.Day() + maxDay := daysInMonth(s.year, s.month) + if day > maxDay { + day = maxDay + } + return time.Date(s.year, s.month, day, 0, 0, 0, 0, mskLocation), true + } + + return s.selectedDate.In(mskLocation), true +} + func (s *DateTimePicker) handleDaySelect(payload string) { datePart := strings.TrimPrefix(payload, "day:") parsed, err := time.ParseInLocation("2006-01-02", datePart, mskLocation) @@ -348,7 +369,7 @@ func (s *DateTimePicker) handleDaySelect(payload string) { } s.selectedDate = &parsed if s.IncludeTime { - s.view = pickerViewTimeHour + s.view = pickerViewTimeCombined s.selectedHour = nil s.selectedMinute = nil } else { @@ -365,7 +386,6 @@ func (s *DateTimePicker) handleHourSelect(payload string) { return } s.selectedHour = &hour - s.view = pickerViewTimeMin } func (s *DateTimePicker) handleMinuteSelect(payload string) { @@ -377,13 +397,15 @@ func (s *DateTimePicker) handleMinuteSelect(payload string) { return } s.selectedMinute = &minute - s.view = pickerViewConfirm } func (s *DateTimePicker) confirmSelection(b *bot.Bot) { if s.selectedDate == nil { return } + if s.IncludeTime && (s.selectedHour == nil || s.selectedMinute == nil) { + return + } hour := 0 minute := 0 if s.selectedHour != nil { @@ -453,6 +475,10 @@ func (s *DateTimePicker) isMinuteDisabled(minute int) bool { } func (s *DateTimePicker) prevMonth() { + now := time.Now().In(mskLocation) + if s.year == now.Year() && s.month == now.Month() { + return + } if s.month == time.January { s.month = time.December s.year-- @@ -470,6 +496,14 @@ func (s *DateTimePicker) nextMonth() { } } +func (s *DateTimePicker) monthPrevButton() echotron.InlineKeyboardButton { + now := time.Now().In(mskLocation) + if s.year == now.Year() && s.month == now.Month() { + return bot.Button("-", "empty") + } + return bot.Button("←", dtpCallbackPrefix+"month_prev") +} + func daysInMonth(year int, month time.Month) int { return time.Date(year, month+1, 0, 0, 0, 0, 0, mskLocation).Day() } @@ -496,6 +530,285 @@ func parseInt(value string) int { return result } -func (s *DateTimePicker) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } +func (s *DateTimePicker) HandleMessage(b *bot.Bot, u *echotron.Update) { + if u.Message == nil || strings.TrimSpace(u.Message.Text) == "" { + return + } + + input := strings.TrimSpace(u.Message.Text) + switch s.view { + case pickerViewCalendar: + value, hasDate, hasTime := parseDateTimeInput(input, s.selectedDate, s.selectedHour, s.selectedMinute) + if !hasDate { + s.Enter(b, bot.NewMessage) + return + } + s.selectedDate = &value + if hasTime { + hour := value.Hour() + minute := value.Minute() + s.selectedHour = &hour + s.selectedMinute = &minute + } + if s.IncludeTime { + s.view = pickerViewTimeCombined + } else { + s.view = pickerViewConfirm + } + s.Enter(b, bot.NewMessage) + case pickerViewTimeCombined: + value, hasDate, hasTime := parseDateTimeInput(input, s.selectedDate, s.selectedHour, s.selectedMinute) + if !hasTime { + s.Enter(b, bot.NewMessage) + return + } + if hasDate { + s.selectedDate = &value + } + hour := value.Hour() + minute := value.Minute() + s.selectedHour = &hour + s.selectedMinute = &minute + s.Enter(b, bot.NewMessage) + default: + value, hasDate, hasTime := parseDateTimeInput(input, s.selectedDate, s.selectedHour, s.selectedMinute) + if !hasDate && !hasTime { + s.Enter(b, bot.NewMessage) + return + } + s.selectedDate = &value + hour := value.Hour() + minute := value.Minute() + s.selectedHour = &hour + s.selectedMinute = &minute + s.view = pickerViewConfirm + s.Enter(b, bot.NewMessage) + } +} func (s *DateTimePicker) Handle(_ *bot.Bot, _ *echotron.Update) { return } + +func parseDateTimeInput( + input string, + currentDate *time.Time, + currentHour *int, + currentMinute *int, +) (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 { + 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 value.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) +} + +func weekdayName(day time.Weekday) string { + switch day { + case time.Monday: + return "Пн" + case time.Tuesday: + return "Вт" + case time.Wednesday: + return "Ср" + case time.Thursday: + return "Чт" + case time.Friday: + return "Пт" + case time.Saturday: + return "Сб" + case time.Sunday: + return "Вс" + default: + return "" + } +} + +func formatCompactDateTime(value time.Time) string { + weekday := weekdayName(value.Weekday()) + month := monthShort(value.Month()) + return fmt.Sprintf("%s %02d %s %s", weekday, value.Day(), month, value.Format("15:04")) +} + +func monthShort(month time.Month) string { + months := []string{ + "янв", "фев", "мар", "апр", "май", "июн", + "июл", "авг", "сен", "окт", "ноя", "дек", + } + if int(month) < 1 || int(month) > len(months) { + return "" + } + return months[int(month)-1] +} diff --git a/telegram_bot/screens/project_details.go b/telegram_bot/screens/project_details.go index 930a57e..87cb240 100644 --- a/telegram_bot/screens/project_details.go +++ b/telegram_bot/screens/project_details.go @@ -77,8 +77,8 @@ func (s *ProjectDetails) buildKeyboard() echotron.InlineKeyboardMarkup { // Первый ряд: Креативы и Закупы buttons = append(buttons, bot.Row( - bot.Button("✎ Креативы", fmt.Sprintf("creatives:%s", s.Project.ID)), - bot.Button("☑ Закупы", fmt.Sprintf("purchases:%s", s.Project.ID)), + bot.Button("Креативы", fmt.Sprintf("creatives:%s", s.Project.ID)), + bot.Button("Закупы", fmt.Sprintf("purchases:%s", s.Project.ID)), )) // Второй ряд: Тип вступления по умолчанию для закупов (с подтверждением)