креативы
This commit is contained in:
95
migrations/models/2_20260103113235_update.py
Normal file
95
migrations/models/2_20260103113235_update.py
Normal 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"
|
||||||
|
)
|
||||||
@@ -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}')
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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]
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)),
|
||||||
))
|
))
|
||||||
|
|
||||||
// Второй ряд: Тип вступления по умолчанию для закупов (с подтверждением)
|
// Второй ряд: Тип вступления по умолчанию для закупов (с подтверждением)
|
||||||
|
|||||||
Reference in New Issue
Block a user