креативы

This commit is contained in:
Artem Tsyrulnikov
2026-01-03 16:11:05 +03:00
parent 841a8b5aaa
commit c04faf8132
15 changed files with 597 additions and 59 deletions

View File

@@ -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"
)

View File

@@ -29,8 +29,18 @@ class ValidateTokenRequest(BaseModel):
@internal_router.get('/auth/jwt') @internal_router.get('/auth/jwt')
async def get_jwt_by_telegram_id(telegram_id: int) -> dto.ValidateLoginTokenOutput: async def get_jwt_by_telegram_id(
return await deps.get_usecase().get_jwt_by_telegram_id(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}') @internal_router.get('/projects/{workspace_id}/{project_id}')

View File

@@ -21,6 +21,8 @@ __all__ = (
'PurchaseChannelStatus', 'PurchaseChannelStatus',
'Creative', 'Creative',
'replace_invite_link_with_tag', 'replace_invite_link_with_tag',
'validate_media_size',
'MAX_CREATIVE_MEDIA_BYTES',
'Placement', 'Placement',
'Post', 'Post',
'Subscription', 'Subscription',
@@ -53,18 +55,20 @@ __all__ = (
'CreativeInUse', 'CreativeInUse',
'CreativeInviteLinkNotFound', 'CreativeInviteLinkNotFound',
'CreativeMultipleInviteLinks', 'CreativeMultipleInviteLinks',
'CreativeMediaTooLarge',
'PlacementNotFound', 'PlacementNotFound',
'UserByUsernameNotFound', 'UserByUsernameNotFound',
) )
from .channel import Channel 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 ( from .error import (
ChannelAlreadyExists, ChannelAlreadyExists,
ChannelNoAdminRights, ChannelNoAdminRights,
ChannelNotFound, ChannelNotFound,
CreativeInUse, CreativeInUse,
CreativeInviteLinkNotFound, CreativeInviteLinkNotFound,
CreativeMediaTooLarge,
CreativeMultipleInviteLinks, CreativeMultipleInviteLinks,
CreativeNotFound, CreativeNotFound,
LoginTokenAlreadyUsed, LoginTokenAlreadyUsed,

View File

@@ -6,7 +6,7 @@ from uuid import UUID
from tortoise import fields from tortoise import fields
from .base import TimestampedModel from .base import TimestampedModel
from .error import CreativeInviteLinkNotFound, CreativeMultipleInviteLinks from .error import CreativeInviteLinkNotFound, CreativeMediaTooLarge, CreativeMultipleInviteLinks
if TYPE_CHECKING: if TYPE_CHECKING:
from .project import Project from .project import Project
@@ -21,6 +21,10 @@ class CreativeStatus(str, enum.Enum):
class Creative(TimestampedModel): class Creative(TimestampedModel):
name = fields.CharField(max_length=255) name = fields.CharField(max_length=255)
text = fields.TextField() 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) status = fields.CharEnumField(CreativeStatus, default=CreativeStatus.ACTIVE)
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField( workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
@@ -38,6 +42,9 @@ class Creative(TimestampedModel):
table = 'creative' table = 'creative'
MAX_CREATIVE_MEDIA_BYTES = 20 * 1024 * 1024
_INVITE_LINK_HTML = re.compile(r'<a\s+href=["\']https?://t\.me/\+[a-zA-Z0-9_-]+["\']>([^<]*)</a>', re.IGNORECASE) _INVITE_LINK_HTML = re.compile(r'<a\s+href=["\']https?://t\.me/\+[a-zA-Z0-9_-]+["\']>([^<]*)</a>', re.IGNORECASE)
_INVITE_LINK_PLAIN = re.compile(r'https?://t\.me/\+[a-zA-Z0-9_-]+', 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: if html_count == 1:
return _INVITE_LINK_HTML.sub(r'<a class="tg-link">\1</a>', text) return _INVITE_LINK_HTML.sub(r'<a class="tg-link">\1</a>', text)
return _INVITE_LINK_PLAIN.sub('<a class="tg-link"></a>', text) return _INVITE_LINK_PLAIN.sub('<a class="tg-link"></a>', 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)

View File

@@ -79,6 +79,10 @@ def CreativeMultipleInviteLinks() -> HTTPException:
return HTTPException(status.HTTP_400_BAD_REQUEST, 'Creative text must contain only one invite link') 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: def CreativeInUse(creative_id: uuid.UUID) -> HTTPException:
return HTTPException( return HTTPException(
status.HTTP_400_BAD_REQUEST, status.HTTP_400_BAD_REQUEST,

View File

@@ -22,6 +22,7 @@ __all__ = (
'GetPurchasesInput', 'GetPurchasesInput',
'GetPurchasesOutput', 'GetPurchasesOutput',
'GetPurchaseInput', 'GetPurchaseInput',
'CreativeButton',
'CreativeOutput', 'CreativeOutput',
'GetCreativesInput', 'GetCreativesInput',
'GetCreativesOutput', 'GetCreativesOutput',
@@ -95,6 +96,7 @@ from .channel import (
) )
from .creative import ( from .creative import (
CreateCreativeInput, CreateCreativeInput,
CreativeButton,
CreativeOutput, CreativeOutput,
DeleteCreativeInput, DeleteCreativeInput,
GetCreativeInput, GetCreativeInput,

View File

@@ -6,10 +6,18 @@ import pydantic
from src import domain from src import domain
class CreativeButton(pydantic.BaseModel):
text: str
url: str
class CreativeOutput(pydantic.BaseModel): class CreativeOutput(pydantic.BaseModel):
id: uuid.UUID id: uuid.UUID
name: str name: str
text: str text: str
media_type: str | None
media_file_id: str | None
buttons: list[CreativeButton]
project_id: uuid.UUID project_id: uuid.UUID
project_channel_title: str project_channel_title: str
created_at: datetime.datetime created_at: datetime.datetime
@@ -37,11 +45,19 @@ class GetCreativeInput(pydantic.BaseModel):
class CreateCreativeInput(pydantic.BaseModel): class CreateCreativeInput(pydantic.BaseModel):
name: str name: str
text: 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): class UpdateCreativeInput(pydantic.BaseModel):
name: str | None = None name: str | None = None
text: 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 status: domain.CreativeStatus | None = None

View File

@@ -6,11 +6,35 @@ if typing.TYPE_CHECKING:
from .. import Usecase 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) user = await self.database.get_user(telegram_id=telegram_id)
if not user: 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( access_token = self.jwt_encoder.encode_access_token(
user_id=user.id, user_id=user.id,

View File

@@ -22,11 +22,17 @@ async def create_creative(
log.warning('User %s attempted to create creative for unavailable project %s', user_id, project_id) log.warning('User %s attempted to create creative for unavailable project %s', user_id, project_id)
raise domain.ProjectNotFound(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_text = domain.replace_invite_link_with_tag(input.text)
creative = domain.Creative( creative = domain.Creative(
name=input.name, name=input.name,
text=creative_text, 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, status=domain.CreativeStatus.ACTIVE,
project_id=project.id, project_id=project.id,
workspace_id=workspace_id, workspace_id=workspace_id,
@@ -37,6 +43,9 @@ async def create_creative(
id=creative.id, id=creative.id,
name=creative.name, name=creative.name,
text=creative.text, text=creative.text,
media_type=creative.media_type,
media_file_id=creative.media_file_id,
buttons=creative.buttons,
project_id=project.id, project_id=project.id,
project_channel_title=project.channel.title, project_channel_title=project.channel.title,
created_at=creative.created_at, created_at=creative.created_at,

View File

@@ -26,6 +26,9 @@ async def get_creative(self: 'Usecase', input: dto.GetCreativeInput) -> dto.Crea
id=creative.id, id=creative.id,
name=creative.name, name=creative.name,
text=creative.text, text=creative.text,
media_type=creative.media_type,
media_file_id=creative.media_file_id,
buttons=creative.buttons,
project_id=creative.project_id, project_id=creative.project_id,
project_channel_title=creative.project.channel.title, project_channel_title=creative.project.channel.title,
created_at=creative.created_at, created_at=creative.created_at,

View File

@@ -32,6 +32,9 @@ async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> list[d
id=creative.id, id=creative.id,
name=creative.name, name=creative.name,
text=creative.text, text=creative.text,
media_type=creative.media_type,
media_file_id=creative.media_file_id,
buttons=creative.buttons,
project_id=creative.project_id, project_id=creative.project_id,
project_channel_title=creative.project.channel.title, project_channel_title=creative.project.channel.title,
created_at=creative.created_at, created_at=creative.created_at,

View File

@@ -30,6 +30,15 @@ async def update_creative(
creative.name = input.name creative.name = input.name
if input.text: if input.text:
creative.text = domain.replace_invite_link_with_tag(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: if input.status:
creative.status = input.status creative.status = input.status
@@ -41,6 +50,9 @@ async def update_creative(
id=creative.id, id=creative.id,
name=creative.name, name=creative.name,
text=creative.text, text=creative.text,
media_type=creative.media_type,
media_file_id=creative.media_file_id,
buttons=creative.buttons,
project_id=creative.project_id, project_id=creative.project_id,
project_channel_title=creative.project.channel.title, project_channel_title=creative.project.channel.title,
created_at=creative.created_at, created_at=creative.created_at,

View File

@@ -135,6 +135,35 @@ func (c *Client) GetJWTByTelegramID(
return resp.AccessToken, err 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 { type Workspace struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`

View File

@@ -2,6 +2,7 @@ package screens
import ( import (
"fmt" "fmt"
"regexp"
"strings" "strings"
"time" "time"
@@ -10,12 +11,11 @@ import (
) )
const ( const (
pickerViewCalendar = "calendar" pickerViewCalendar = "calendar"
pickerViewMonths = "months" pickerViewMonths = "months"
pickerViewTimeHour = "time_hour" pickerViewTimeCombined = "time_combined"
pickerViewTimeMin = "time_minute" pickerViewConfirm = "confirm"
pickerViewConfirm = "confirm" dtpCallbackPrefix = "dtp:"
dtpCallbackPrefix = "dtp:"
) )
var mskLocation = time.FixedZone("MSK", 3*60*60) 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) { func (s *DateTimePicker) Enter(b *bot.Bot, mode bot.RenderMode) {
text := fmt.Sprintf("<i>… %s</i>\n\n<b>%s</b>\n\n", s.Title, s.Title) text := fmt.Sprintf("<i>… %s</i>\n\n", s.Title)
text += fmt.Sprintf("<b>%s</b>\n", s.formatSelectedDateTime())
text += "\n"
switch s.view { switch s.view {
case pickerViewCalendar: case pickerViewCalendar:
text += s.renderCalendarTitle() text += s.renderCalendarTitle()
@@ -80,14 +82,8 @@ func (s *DateTimePicker) Enter(b *bot.Bot, mode bot.RenderMode) {
case pickerViewMonths: case pickerViewMonths:
text += "Выберите месяц\n\n" text += "Выберите месяц\n\n"
b.Render(text, s.monthsKeyboard(), mode) b.Render(text, s.monthsKeyboard(), mode)
case pickerViewTimeHour: case pickerViewTimeCombined:
text += fmt.Sprintf("Выбрано: <b>%s</b>\n\n", s.formatTimePreview(false)) b.Render(text, s.timeCombinedKeyboard(), mode)
text += "Выберите час\n\n"
b.Render(text, s.timeHourKeyboard(), mode)
case pickerViewTimeMin:
text += fmt.Sprintf("Выбрано: <b>%s</b>\n\n", s.formatTimePreview(false))
text += "Выберите минуты\n\n"
b.Render(text, s.timeMinuteKeyboard(), mode)
case pickerViewConfirm: case pickerViewConfirm:
text += s.renderSelectedDateTime() text += s.renderSelectedDateTime()
b.Render(text, s.confirmKeyboard(), mode) b.Render(text, s.confirmKeyboard(), mode)
@@ -152,13 +148,11 @@ func (s *DateTimePicker) handleBack(b *bot.Bot) {
switch s.view { switch s.view {
case pickerViewMonths: case pickerViewMonths:
s.view = pickerViewCalendar s.view = pickerViewCalendar
case pickerViewTimeHour: case pickerViewTimeCombined:
s.view = pickerViewCalendar s.view = pickerViewCalendar
case pickerViewTimeMin:
s.view = pickerViewTimeHour
case pickerViewConfirm: case pickerViewConfirm:
if s.IncludeTime { if s.IncludeTime {
s.view = pickerViewTimeMin s.view = pickerViewTimeCombined
} else { } else {
s.view = pickerViewCalendar s.view = pickerViewCalendar
} }
@@ -172,23 +166,17 @@ func (s *DateTimePicker) handleBack(b *bot.Bot) {
} }
func (s *DateTimePicker) renderCalendarTitle() string { func (s *DateTimePicker) renderCalendarTitle() string {
header := monthName(s.month) if s.year == time.Now().In(mskLocation).Year() {
if s.selectedDate != nil { return "\n"
header = fmt.Sprintf("%s\nВыбрано: %s", header, s.selectedDate.In(mskLocation).Format("02.01.2006"))
} }
return header + "\n\n" return fmt.Sprintf("%d\n\n", s.year)
} }
func (s *DateTimePicker) calendarKeyboard() echotron.InlineKeyboardMarkup { func (s *DateTimePicker) calendarKeyboard() echotron.InlineKeyboardMarkup {
var rows [][]echotron.InlineKeyboardButton var rows [][]echotron.InlineKeyboardButton
rows = append(rows, bot.Row( rows = append(rows, bot.Row(
bot.Button("«", dtpCallbackPrefix+"year_prev"), s.monthPrevButton(),
bot.Button(fmt.Sprintf("%d", s.year), "empty"),
bot.Button("»", dtpCallbackPrefix+"year_next"),
))
rows = append(rows, bot.Row(
bot.Button("←", dtpCallbackPrefix+"month_prev"),
bot.Button(monthName(s.month), dtpCallbackPrefix+"open_months"), bot.Button(monthName(s.month), dtpCallbackPrefix+"open_months"),
bot.Button("→", dtpCallbackPrefix+"month_next"), bot.Button("→", dtpCallbackPrefix+"month_next"),
)) ))
@@ -248,11 +236,6 @@ func (s *DateTimePicker) calendarKeyboard() echotron.InlineKeyboardMarkup {
func (s *DateTimePicker) monthsKeyboard() echotron.InlineKeyboardMarkup { func (s *DateTimePicker) monthsKeyboard() echotron.InlineKeyboardMarkup {
var rows [][]echotron.InlineKeyboardButton 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{"Янв", "Фев", "Мар", "Апр", "Май", "Июн", "Июл", "Авг", "Сен", "Окт", "Ноя", "Дек"} months := []string{"Янв", "Фев", "Мар", "Апр", "Май", "Июн", "Июл", "Авг", "Сен", "Окт", "Ноя", "Дек"}
for i := 0; i < 12; i += 3 { for i := 0; i < 12; i += 3 {
@@ -270,32 +253,35 @@ func (s *DateTimePicker) monthsKeyboard() echotron.InlineKeyboardMarkup {
return bot.Keyboard(rows...) return bot.Keyboard(rows...)
} }
func (s *DateTimePicker) timeHourKeyboard() echotron.InlineKeyboardMarkup { func (s *DateTimePicker) timeCombinedKeyboard() echotron.InlineKeyboardMarkup {
var rows [][]echotron.InlineKeyboardButton 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( rows = append(rows, bot.Row(
s.hourButton(i), s.hourButton(i),
s.hourButton(i+1), s.hourButton(i+1),
s.hourButton(i+2), s.hourButton(i+2),
s.hourButton(i+3), s.hourButton(i+3),
s.hourButton(i+4),
s.hourButton(i+5),
)) ))
} }
rows = append(rows, bot.Row(bot.Button("← Назад", "back"))) rows = append(rows, bot.Row(bot.Button("Минуты", "empty")))
return bot.Keyboard(rows...)
}
func (s *DateTimePicker) timeMinuteKeyboard() echotron.InlineKeyboardMarkup {
var rows [][]echotron.InlineKeyboardButton
minutes := []int{0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55} 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( rows = append(rows, bot.Row(
s.minuteButton(minutes[i]), s.minuteButton(minutes[i]),
s.minuteButton(minutes[i+1]), s.minuteButton(minutes[i+1]),
s.minuteButton(minutes[i+2]), s.minuteButton(minutes[i+2]),
s.minuteButton(minutes[i+3]), 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...) return bot.Keyboard(rows...)
} }
@@ -310,7 +296,7 @@ func (s *DateTimePicker) confirmKeyboard() echotron.InlineKeyboardMarkup {
func (s *DateTimePicker) renderSelectedDateTime() string { func (s *DateTimePicker) renderSelectedDateTime() string {
if s.selectedDate == nil { if s.selectedDate == nil {
return "Дата не выбрана" return ""
} }
hour := 0 hour := 0
minute := 0 minute := 0
@@ -321,7 +307,7 @@ func (s *DateTimePicker) renderSelectedDateTime() string {
minute = *s.selectedMinute minute = *s.selectedMinute
} }
value := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), hour, minute, 0, 0, mskLocation) value := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), hour, minute, 0, 0, mskLocation)
return fmt.Sprintf("Выбрано: <b>%s</b>\n\nНажмите «Готово»", value.Format("02.01.2006 15:04")) return fmt.Sprintf("<b>%s</b>", formatCompactDateTime(value))
} }
func (s *DateTimePicker) formatTimePreview(showPlaceholders bool) string { 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) 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) { func (s *DateTimePicker) handleDaySelect(payload string) {
datePart := strings.TrimPrefix(payload, "day:") datePart := strings.TrimPrefix(payload, "day:")
parsed, err := time.ParseInLocation("2006-01-02", datePart, mskLocation) parsed, err := time.ParseInLocation("2006-01-02", datePart, mskLocation)
@@ -348,7 +369,7 @@ func (s *DateTimePicker) handleDaySelect(payload string) {
} }
s.selectedDate = &parsed s.selectedDate = &parsed
if s.IncludeTime { if s.IncludeTime {
s.view = pickerViewTimeHour s.view = pickerViewTimeCombined
s.selectedHour = nil s.selectedHour = nil
s.selectedMinute = nil s.selectedMinute = nil
} else { } else {
@@ -365,7 +386,6 @@ func (s *DateTimePicker) handleHourSelect(payload string) {
return return
} }
s.selectedHour = &hour s.selectedHour = &hour
s.view = pickerViewTimeMin
} }
func (s *DateTimePicker) handleMinuteSelect(payload string) { func (s *DateTimePicker) handleMinuteSelect(payload string) {
@@ -377,13 +397,15 @@ func (s *DateTimePicker) handleMinuteSelect(payload string) {
return return
} }
s.selectedMinute = &minute s.selectedMinute = &minute
s.view = pickerViewConfirm
} }
func (s *DateTimePicker) confirmSelection(b *bot.Bot) { func (s *DateTimePicker) confirmSelection(b *bot.Bot) {
if s.selectedDate == nil { if s.selectedDate == nil {
return return
} }
if s.IncludeTime && (s.selectedHour == nil || s.selectedMinute == nil) {
return
}
hour := 0 hour := 0
minute := 0 minute := 0
if s.selectedHour != nil { if s.selectedHour != nil {
@@ -453,6 +475,10 @@ func (s *DateTimePicker) isMinuteDisabled(minute int) bool {
} }
func (s *DateTimePicker) prevMonth() { func (s *DateTimePicker) prevMonth() {
now := time.Now().In(mskLocation)
if s.year == now.Year() && s.month == now.Month() {
return
}
if s.month == time.January { if s.month == time.January {
s.month = time.December s.month = time.December
s.year-- 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 { func daysInMonth(year int, month time.Month) int {
return time.Date(year, month+1, 0, 0, 0, 0, 0, mskLocation).Day() return time.Date(year, month+1, 0, 0, 0, 0, 0, mskLocation).Day()
} }
@@ -496,6 +530,285 @@ func parseInt(value string) int {
return result 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 (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]
}

View File

@@ -77,8 +77,8 @@ func (s *ProjectDetails) buildKeyboard() echotron.InlineKeyboardMarkup {
// Первый ряд: Креативы и Закупы // Первый ряд: Креативы и Закупы
buttons = append(buttons, bot.Row( buttons = append(buttons, bot.Row(
bot.Button("Креативы", fmt.Sprintf("creatives:%s", s.Project.ID)), bot.Button("Креативы", fmt.Sprintf("creatives:%s", s.Project.ID)),
bot.Button("Закупы", fmt.Sprintf("purchases:%s", s.Project.ID)), bot.Button("Закупы", fmt.Sprintf("purchases:%s", s.Project.ID)),
)) ))
// Второй ряд: Тип вступления по умолчанию для закупов (с подтверждением) // Второй ряд: Тип вступления по умолчанию для закупов (с подтверждением)