This commit is contained in:
Artem Tsyrulnikov
2026-02-03 22:20:56 +03:00
parent 7c8589e000
commit 09145e510a
10 changed files with 124 additions and 101 deletions

View File

@@ -9,6 +9,7 @@ from aiogram.types import (
InputMediaDocument, InputMediaDocument,
InputMediaPhoto, InputMediaPhoto,
InputMediaVideo, InputMediaVideo,
LinkPreviewOptions,
) )
from shared.telegram_base import TelegramBase from shared.telegram_base import TelegramBase
@@ -23,14 +24,14 @@ class TelegramBot(TelegramBase, TelegramBotWriter):
text: str, text: str,
chat_id: int, chat_id: int,
parse_mode: str | None = None, parse_mode: str | None = None,
disable_preview: bool = False, disable_preview: bool = True,
reply_to_message_id: int | None = None, reply_to_message_id: int | None = None,
) -> int: ) -> int:
message = await self.bot.send_message( message = await self.bot.send_message(
chat_id=chat_id, chat_id=chat_id,
text=text, text=text,
parse_mode=parse_mode, parse_mode=parse_mode,
disable_web_page_preview=disable_preview, link_preview_options=LinkPreviewOptions(is_disabled=disable_preview),
reply_to_message_id=reply_to_message_id, reply_to_message_id=reply_to_message_id,
) )
return message.message_id return message.message_id
@@ -49,7 +50,7 @@ class TelegramBot(TelegramBase, TelegramBotWriter):
chat_id: int, chat_id: int,
buttons: list[list[InlineKeyboardButton]], buttons: list[list[InlineKeyboardButton]],
parse_mode: str | None = None, parse_mode: str | None = None,
disable_preview: bool = False, disable_preview: bool = True,
reply_to_message_id: int | None = None, reply_to_message_id: int | None = None,
) -> int: ) -> int:
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
@@ -58,7 +59,7 @@ class TelegramBot(TelegramBase, TelegramBotWriter):
text=text, text=text,
reply_markup=keyboard, reply_markup=keyboard,
parse_mode=parse_mode, parse_mode=parse_mode,
disable_web_page_preview=disable_preview, link_preview_options=LinkPreviewOptions(is_disabled=disable_preview),
reply_to_message_id=reply_to_message_id, reply_to_message_id=reply_to_message_id,
) )
return message.message_id return message.message_id
@@ -110,6 +111,7 @@ class TelegramBot(TelegramBase, TelegramBotWriter):
parse_mode=parse_mode, parse_mode=parse_mode,
reply_markup=keyboard, reply_markup=keyboard,
reply_to_message_id=reply_to_message_id, reply_to_message_id=reply_to_message_id,
link_preview_options=LinkPreviewOptions(is_disabled=True),
) )
return message.message_id return message.message_id
@@ -128,9 +130,17 @@ class TelegramBot(TelegramBase, TelegramBotWriter):
for index, item in enumerate(media_items): for index, item in enumerate(media_items):
item_caption = caption if index == 0 else None item_caption = caption if index == 0 else None
if item.media_type == 'photo': if item.media_type == 'photo':
group.append(InputMediaPhoto(media=item.media_file_id, caption=item_caption, parse_mode=parse_mode)) group.append(InputMediaPhoto(
media=item.media_file_id,
caption=item_caption,
parse_mode=parse_mode,
))
elif item.media_type == 'video': elif item.media_type == 'video':
group.append(InputMediaVideo(media=item.media_file_id, caption=item_caption, parse_mode=parse_mode)) group.append(InputMediaVideo(
media=item.media_file_id,
caption=item_caption,
parse_mode=parse_mode,
))
else: else:
raise ValueError(f'Unsupported media type for group: {item.media_type}') raise ValueError(f'Unsupported media type for group: {item.media_type}')

View File

@@ -115,7 +115,7 @@ def _build_info_message(
# Add empty line before footer # Add empty line before footer
lines.append('') lines.append('')
lines.append('Пост ниже') lines.append('Пост ниже 👇')
return '\n'.join(lines) return '\n'.join(lines)

View File

@@ -196,7 +196,11 @@ func (b *Bot) SendNew(text string, keyboard echotron.InlineKeyboardMarkup) {
b.cleanupKeyboard(b.LastMessageID) b.cleanupKeyboard(b.LastMessageID)
} }
res, err := b.SendMessage(text, b.ChatID, &echotron.MessageOptions{ReplyMarkup: keyboard, ParseMode: echotron.HTML}) res, err := b.SendMessage(text, b.ChatID, &echotron.MessageOptions{
ReplyMarkup: keyboard,
ParseMode: echotron.HTML,
LinkPreviewOptions: echotron.LinkPreviewOptions{IsDisabled: true},
})
if err != nil { if err != nil {
log.Error().Err(err).Msg("SendMessage failed") log.Error().Err(err).Msg("SendMessage failed")
return return
@@ -238,9 +242,17 @@ func (b *Bot) Edit(text string, keyboard echotron.InlineKeyboardMarkup) {
msgID := echotron.NewMessageID(b.ChatID, b.LastMessageID) msgID := echotron.NewMessageID(b.ChatID, b.LastMessageID)
if b.lastRender.isMedia { if b.lastRender.isMedia {
_, err = b.EditMessageCaption(msgID, &echotron.MessageCaptionOptions{Caption: text, ParseMode: echotron.HTML, ReplyMarkup: keyboard}) _, err = b.EditMessageCaption(msgID, &echotron.MessageCaptionOptions{
Caption: text,
ParseMode: echotron.HTML,
ReplyMarkup: keyboard,
})
} else { } else {
_, err = b.EditMessageText(text, msgID, &echotron.MessageTextOptions{ReplyMarkup: keyboard, ParseMode: echotron.HTML}) _, err = b.EditMessageText(text, msgID, &echotron.MessageTextOptions{
ReplyMarkup: keyboard,
ParseMode: echotron.HTML,
LinkPreviewOptions: echotron.LinkPreviewOptions{IsDisabled: true},
})
} }
if err == nil { if err == nil {

View File

@@ -15,26 +15,28 @@ const addPurchaseCreativesPerPage = 5
const addPurchaseProjectsPerPage = 6 const addPurchaseProjectsPerPage = 6
type AddPurchase struct { type AddPurchase struct {
ProjectID string ProjectID string
ProjectTitle string ProjectTitle string
ProjectTelegramID int64 ProjectTelegramID int64
ProjectUsername string ProjectUsername string
ProjectStatus string ProjectStatus string
CreativeID string ProjectDefaultLinkType string
CreativeTitle string CreativeID string
ProjectPage int CreativeTitle string
CreativePage int ProjectPage int
ActivePicker string // "" | "project_list" | "creative_list" CreativePage int
BackState bot.State ActivePicker string // "" | "project_list" | "creative_list"
lastProjects map[string]projectInfo BackState bot.State
lastCreatives map[string]creativeInfo lastProjects map[string]projectInfo
lastCreatives map[string]creativeInfo
} }
type projectInfo struct { type projectInfo struct {
Title string Title string
TelegramID int64 TelegramID int64
Username string Username string
Status string Status string
DefaultInviteType string
} }
type creativeInfo struct { type creativeInfo struct {
@@ -85,10 +87,11 @@ func (s *AddPurchase) renderSelection(b *bot.Bot, mode bot.RenderMode) {
username = *project.Username username = *project.Username
} }
s.lastProjects[project.ID] = projectInfo{ s.lastProjects[project.ID] = projectInfo{
Title: project.Title, Title: project.Title,
TelegramID: project.TelegramID, TelegramID: project.TelegramID,
Username: username, Username: username,
Status: project.Status, Status: project.Status,
DefaultInviteType: project.PurchaseInviteTypeDefault,
} }
} }
@@ -263,17 +266,18 @@ func (s *AddPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) {
} }
if s.ProjectID != "" && s.CreativeID != "" { if s.ProjectID != "" && s.CreativeID != "" {
b.SetState(&SelectChannelsForPurchase{ b.SetState(&SelectChannelsForPurchase{
ProjectID: s.ProjectID, ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle, ProjectTitle: s.ProjectTitle,
ProjectTelegramID: s.ProjectTelegramID, ProjectTelegramID: s.ProjectTelegramID,
ProjectUsername: s.ProjectUsername, ProjectUsername: s.ProjectUsername,
ProjectStatus: s.ProjectStatus, ProjectStatus: s.ProjectStatus,
CreativeID: s.CreativeID, ProjectDefaultLinkType: s.ProjectDefaultLinkType,
CreativeTitle: s.CreativeTitle, CreativeID: s.CreativeID,
Channels: []PurchaseChannelInput{}, CreativeTitle: s.CreativeTitle,
Duplicates: []string{}, Channels: []PurchaseChannelInput{},
ParsingErrors: []ParseError{}, Duplicates: []string{},
BackState: s.BackState, ParsingErrors: []ParseError{},
BackState: s.BackState,
}, bot.EditMessage) }, bot.EditMessage)
} }
@@ -286,6 +290,7 @@ func (s *AddPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) {
s.ProjectTelegramID = info.TelegramID s.ProjectTelegramID = info.TelegramID
s.ProjectUsername = info.Username s.ProjectUsername = info.Username
s.ProjectStatus = info.Status s.ProjectStatus = info.Status
s.ProjectDefaultLinkType = info.DefaultInviteType
} }
} }
s.ProjectID = projectID s.ProjectID = projectID

View File

@@ -33,8 +33,9 @@ func (s *Login) Enter(b *bot.Bot, mode bot.RenderMode) {
kb := Keyboard(Row(URLButton("Войти на сайт", loginURL))) kb := Keyboard(Row(URLButton("Войти на сайт", loginURL)))
res, err := b.SendMessage(fmt.Sprintf(msgHelloLogin, usernameDisplay), b.ChatID, &echotron.MessageOptions{ res, err := b.SendMessage(fmt.Sprintf(msgHelloLogin, usernameDisplay), b.ChatID, &echotron.MessageOptions{
ReplyMarkup: kb, ReplyMarkup: kb,
ParseMode: echotron.HTML, ParseMode: echotron.HTML,
LinkPreviewOptions: echotron.LinkPreviewOptions{IsDisabled: true},
}) })
if err != nil { if err != nil {
log.Err(err).Msg("SendMessage error") log.Err(err).Msg("SendMessage error")

View File

@@ -14,7 +14,7 @@ type MainMenu struct{}
const msgAbout = ` const msgAbout = `
<i>Главное меню</i> <i>Главное меню</i>
<blockquote> <blockquote expandable>
За что отвечает каждая кнопка в этом окне: За что отвечает каждая кнопка в этом окне:
<b>• Рабочее пространство</b> — Одно можно использовать для своих проектов, а другое, например, для проектов, где вы являетесь закупщиком. <b>• Рабочее пространство</b> — Одно можно использовать для своих проектов, а другое, например, для проектов, где вы являетесь закупщиком.

View File

@@ -29,8 +29,7 @@ func (s *Placements) Enter(b *bot.Bot, mode bot.RenderMode) {
var rows [][]echotron.InlineKeyboardButton var rows [][]echotron.InlineKeyboardButton
rows = append(rows, Row(Button(" Создать размещение", "add_purchase"))) rows = append(rows, Row(Button(" Создать размещение", "add_purchase")))
rows = append(rows, Row(Button("Размещения", "placements_list"))) rows = append(rows, Row(Button("Список размещений", "placements_list"), URLButton("План закупов", fmt.Sprintf("https://app.smart-post.ru/dashboard/%s/purchase-plans/%s", b.Session.WorkspaceID, s.ProjectID))))
rows = append(rows, Row(URLButton("План закупов", fmt.Sprintf("https://app.smart-post.ru/dashboard/%s/purchase-plans/%s", b.Session.WorkspaceID, s.ProjectID))))
rows = append(rows, Row(Button("← Назад", "back"))) rows = append(rows, Row(Button("← Назад", "back")))
keyboard := Keyboard(rows...) keyboard := Keyboard(rows...)

View File

@@ -17,6 +17,7 @@ import (
type PurchaseOptionalDetails struct { type PurchaseOptionalDetails struct {
ProjectID string ProjectID string
ProjectTitle string ProjectTitle string
ProjectDefaultLinkType string
CreativeID string CreativeID string
CreativeTitle string CreativeTitle string
Channels []PurchaseChannelInput Channels []PurchaseChannelInput
@@ -254,15 +255,16 @@ func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update)
case "back": case "back":
// Сохраняем текущее состояние перед возвратом назад // Сохраняем текущее состояние перед возвратом назад
b.SetState(&SelectChannelsForPurchase{ b.SetState(&SelectChannelsForPurchase{
ProjectID: s.ProjectID, ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle, ProjectTitle: s.ProjectTitle,
CreativeID: s.CreativeID, ProjectDefaultLinkType: s.ProjectDefaultLinkType,
CreativeTitle: s.CreativeTitle, CreativeID: s.CreativeID,
Channels: s.Channels, CreativeTitle: s.CreativeTitle,
Duplicates: []string{}, Channels: s.Channels,
ParsingErrors: []ParseError{}, Duplicates: []string{},
OptionalDetailsState: s, // Сохраняем текущее состояние ParsingErrors: []ParseError{},
BackState: s.BackState, OptionalDetailsState: s, // Сохраняем текущее состояние
BackState: s.BackState,
}, bot.EditMessage) }, bot.EditMessage)
case "next": case "next":
jwt := b.Session.JWT jwt := b.Session.JWT
@@ -314,11 +316,6 @@ func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update)
} }
if strings.HasPrefix(u.CallbackQuery.Data, "type:") { if strings.HasPrefix(u.CallbackQuery.Data, "type:") {
value := strings.TrimPrefix(u.CallbackQuery.Data, "type:") value := strings.TrimPrefix(u.CallbackQuery.Data, "type:")
if value == "custom" {
s.InputMode = "type_custom"
s.Enter(b, bot.EditMessage)
return
}
// Нормализуем значение для БД // Нормализуем значение для БД
switch value { switch value {
case "self_promo": case "self_promo":
@@ -372,12 +369,6 @@ func (s *PurchaseOptionalDetails) HandleMessage(b *bot.Bot, u *echotron.Update)
} }
switch s.InputMode { switch s.InputMode {
case "type_custom":
if s.CurrentChannel != "" {
s.PurchaseTypeByChannel[s.CurrentChannel] = text
} else {
s.PurchaseType = text
}
case "format_custom": case "format_custom":
s.setFormatValue(text) s.setFormatValue(text)
case "comment": case "comment":
@@ -639,11 +630,7 @@ func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderM
case "type": case "type":
text += "<b>Выберите тип закупа</b>" text += "<b>Выберите тип закупа</b>"
keyboard := Keyboard( keyboard := Keyboard(
Row( Row(Button("Самопиар", "type:self_promo"), Button("Стандарт", "type:standard")),
Button("Самопиар", "type:self_promo"),
Button("Стандарт", "type:standard"),
),
Row(Button("Другое", "type:custom")),
Row(Button("← Назад", "back_to_optional")), Row(Button("← Назад", "back_to_optional")),
) )
if mode == bot.EditMessage { if mode == bot.EditMessage {
@@ -652,8 +639,6 @@ func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderM
b.SendNew(text, keyboard) b.SendNew(text, keyboard)
} }
return return
case "type_custom":
text += "<b>Введите тип закупа</b>\n\nНапример: <code>вп</code>"
case "cost_value": case "cost_value":
text += "<b>Ввод стоимости</b>\n\nНапример: <code>15000</code>" text += "<b>Ввод стоимости</b>\n\nНапример: <code>15000</code>"
if s.CurrentChannel != "" { if s.CurrentChannel != "" {
@@ -1639,6 +1624,12 @@ func (s *PurchaseOptionalDetails) ensureDefaults() {
if s.InviteLinkTypeMode == "" { if s.InviteLinkTypeMode == "" {
s.InviteLinkTypeMode = "common" s.InviteLinkTypeMode = "common"
} }
if s.InviteLinkType == "" {
s.InviteLinkType = s.ProjectDefaultLinkType
if s.InviteLinkType == "" {
s.InviteLinkType = "approval"
}
}
if s.PlacementByChannel == nil { if s.PlacementByChannel == nil {
s.PlacementByChannel = make(map[string]*time.Time) s.PlacementByChannel = make(map[string]*time.Time)
} }
@@ -2255,22 +2246,22 @@ func (s *PurchaseOptionalDetails) buildChannelDetails(channelKey string, placeme
// Placement date // Placement date
if s.PlacementMode == "per_channel" && len(s.Channels) > 1 { if s.PlacementMode == "per_channel" && len(s.Channels) > 1 {
if value, ok := s.PlacementByChannel[channelKey]; ok && value != nil { if value, ok := s.PlacementByChannel[channelKey]; ok && value != nil {
formatted := value.Format(time.RFC3339) formatted := value.UTC().Format(time.RFC3339)
details.PlacementAt = &formatted details.PlacementAt = &formatted
} }
} else if s.PlacementDateTime != nil { } else if s.PlacementDateTime != nil {
formatted := s.PlacementDateTime.Format(time.RFC3339) formatted := s.PlacementDateTime.UTC().Format(time.RFC3339)
details.PlacementAt = &formatted details.PlacementAt = &formatted
} }
// Payment date // Payment date
if s.PaymentDateMode == "per_channel" && len(s.Channels) > 1 { if s.PaymentDateMode == "per_channel" && len(s.Channels) > 1 {
if value, ok := s.PaymentDateByChannel[channelKey]; ok && value != nil { if value, ok := s.PaymentDateByChannel[channelKey]; ok && value != nil {
formatted := value.Format(time.RFC3339) formatted := value.UTC().Format(time.RFC3339)
details.PaymentAt = &formatted details.PaymentAt = &formatted
} }
} else if s.PaymentDate != nil { } else if s.PaymentDate != nil {
formatted := s.PaymentDate.Format(time.RFC3339) formatted := s.PaymentDate.UTC().Format(time.RFC3339)
details.PaymentAt = &formatted details.PaymentAt = &formatted
} }

View File

@@ -22,20 +22,21 @@ type PurchaseChannelInput struct {
} }
type SelectChannelsForPurchase struct { type SelectChannelsForPurchase struct {
ProjectID string ProjectID string
ProjectTitle string ProjectTitle string
ProjectTelegramID int64 ProjectTelegramID int64
ProjectUsername string ProjectUsername string
ProjectStatus string ProjectStatus string
CreativeID string ProjectDefaultLinkType string
CreativeTitle string CreativeID string
Channels []PurchaseChannelInput CreativeTitle string
CurrentPage int Channels []PurchaseChannelInput
InvalidUsernames []string CurrentPage int
Duplicates []string // дубликаты каналов InvalidUsernames []string
ParsingErrors []ParseError // ошибки парсинга с предложениями Duplicates []string // дубликаты каналов
OptionalDetailsState *PurchaseOptionalDetails // сохранённое состояние деталей ParsingErrors []ParseError // ошибки парсинга с предложениями
BackState bot.State OptionalDetailsState *PurchaseOptionalDetails // сохранённое состояние деталей
BackState bot.State
} }
// ParseError представляет ошибку парсинга с предложением исправления // ParseError представляет ошибку парсинга с предложением исправления
@@ -225,12 +226,13 @@ func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Updat
optionalDetails.Channels = s.Channels optionalDetails.Channels = s.Channels
} else { } else {
optionalDetails = &PurchaseOptionalDetails{ optionalDetails = &PurchaseOptionalDetails{
ProjectID: s.ProjectID, ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle, ProjectTitle: s.ProjectTitle,
CreativeID: s.CreativeID, ProjectDefaultLinkType: s.ProjectDefaultLinkType,
CreativeTitle: s.CreativeTitle, CreativeID: s.CreativeID,
Channels: s.Channels, CreativeTitle: s.CreativeTitle,
BackState: s, Channels: s.Channels,
BackState: s,
} }
} }
b.SetState(optionalDetails, bot.EditMessage) b.SetState(optionalDetails, bot.EditMessage)

View File

@@ -130,7 +130,8 @@ func (s *DateTimePicker) HandleCallback(b *bot.Bot, u *echotron.Update) {
s.view = pickerViewCalendar s.view = pickerViewCalendar
} }
case strings.HasPrefix(payload, "day:"): case strings.HasPrefix(payload, "day:"):
s.handleDaySelect(payload) s.handleDaySelect(payload, b)
return // confirmSelection уже делает Enter
case strings.HasPrefix(payload, "hour:"): case strings.HasPrefix(payload, "hour:"):
s.handleHourSelect(payload) s.handleHourSelect(payload)
case strings.HasPrefix(payload, "min:"): case strings.HasPrefix(payload, "min:"):
@@ -140,6 +141,7 @@ func (s *DateTimePicker) HandleCallback(b *bot.Bot, u *echotron.Update) {
return return
} }
// Если не было подтверждения (для дат без времени), перерисовываем
s.Enter(b, bot.EditMessage) s.Enter(b, bot.EditMessage)
} }
@@ -365,7 +367,7 @@ func (s *DateTimePicker) displayDate() (time.Time, bool) {
return s.selectedDate.In(MskLocation), true return s.selectedDate.In(MskLocation), true
} }
func (s *DateTimePicker) handleDaySelect(payload string) { func (s *DateTimePicker) handleDaySelect(payload string, b *bot.Bot) {
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)
if err != nil { if err != nil {
@@ -377,7 +379,8 @@ func (s *DateTimePicker) handleDaySelect(payload string) {
s.selectedHour = nil s.selectedHour = nil
s.selectedMinute = nil s.selectedMinute = nil
} else { } else {
s.view = pickerViewConfirm // Сразу подтверждаем выбор для даты без времени
s.confirmSelection(b)
} }
} }