@@ -0,0 +1,45 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
|
||||
)
|
||||
|
||||
type AcceptWorkspaceInvite struct {
|
||||
InviteID string
|
||||
}
|
||||
|
||||
const msgAcceptWorkspaceInviteSuccessfully = `
|
||||
✅ Вы присоединились к рабочему пространству!
|
||||
Администратор сможет выдать вам необходимые права в веб-интерфейсе.
|
||||
`
|
||||
|
||||
func (s *AcceptWorkspaceInvite) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
jwt := b.Session.JWT
|
||||
if jwt == "" {
|
||||
b.SendNew("<b>❌ Ошибка авторизации</b>\n\nПопробуйте /start", emptyKeyboard)
|
||||
return
|
||||
}
|
||||
|
||||
err := b.Backend.AcceptWorkspaceInvite(context.Background(), jwt, s.InviteID)
|
||||
if err != nil {
|
||||
b.SendNew("❌ Не удалось принять приглашение. Возможно, оно уже было использовано.", emptyKeyboard)
|
||||
return
|
||||
}
|
||||
|
||||
kb := Keyboard(Row(Button("Главное меню", "main_menu")))
|
||||
|
||||
b.SendNew(msgAcceptWorkspaceInviteSuccessfully, kb)
|
||||
}
|
||||
|
||||
func (s *AcceptWorkspaceInvite) HandleCallback(b *bot.Bot, u *echotron.Update) {}
|
||||
|
||||
func (s *AcceptWorkspaceInvite) HandleMessage(_ *bot.Bot, _ *echotron.Update) {}
|
||||
|
||||
func (s *AcceptWorkspaceInvite) Handle(b *bot.Bot, _ *echotron.Update) {
|
||||
b.SetState(&MainMenu{}, bot.EditMessage)
|
||||
}
|
||||
|
||||
func (s *AcceptWorkspaceInvite) Exit() {}
|
||||
@@ -0,0 +1,489 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/backend"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type AddCreativeCtx struct {
|
||||
CreativeEditorFields
|
||||
|
||||
ProjectID string
|
||||
BackState bot.State
|
||||
WaitMessageSent bool
|
||||
}
|
||||
|
||||
type AddCreativeStart struct{ Ctx *AddCreativeCtx }
|
||||
|
||||
type AddCreativeEdit struct{ ctx *AddCreativeCtx }
|
||||
|
||||
type AddCreativeInput struct{ ctx *AddCreativeCtx }
|
||||
|
||||
const msgWaitCreative = `
|
||||
<b>+ Добавить креатив</b>
|
||||
|
||||
📄 <b>Пришлите креатив который хотите добавить</b>
|
||||
|
||||
• Обрабатываем любое сообщение Telegram
|
||||
• Отправляйте сразу оформленный пост
|
||||
• На следующем шаге можно добавить кнопки или изменить контент
|
||||
|
||||
`
|
||||
const msgConfirmCreativeFormat = `
|
||||
<b>➕ Добавляем этот креатив?</b>
|
||||
|
||||
<b>Название:</b> %s
|
||||
|
||||
<i>Чтобы изменить название, отправь мне сообщение 👇:</i>
|
||||
|
||||
`
|
||||
const msgDownloadMediaError = `
|
||||
<b>❌ Не удалось загрузить медиа</b>
|
||||
|
||||
Попробуйте удалить медиа и добавить заново.
|
||||
|
||||
`
|
||||
const msgInviteLinkRequired = `
|
||||
<b>❌ Ошибка валидации</b>
|
||||
|
||||
Текст должен содержать <b>хотя бы одну</b> инвайт-ссылку вашего канала!
|
||||
|
||||
<b>Формат ссылки:</b>
|
||||
<code>https://t.me/+xxx</code>
|
||||
|
||||
<b>Пример правильного текста:</b>
|
||||
<i>Присоединяйтесь к нашему каналу!
|
||||
https://t.me/+AbCdEfGhIjKlMn</i>
|
||||
|
||||
<b>Нажмите "✎ Текст" ниже чтобы исправить.</b>
|
||||
|
||||
`
|
||||
const msgInviteLinkTooMany = `
|
||||
<b>❌ Слишком много ссылок</b>
|
||||
|
||||
Текст должен содержать <b>ТОЛЬКО ОДНУ</b> инвайт-ссылку.
|
||||
У вас их несколько. Удалите лишние.
|
||||
|
||||
<b>Нажмите "✎ Текст" ниже чтобы исправить.</b>
|
||||
|
||||
`
|
||||
const msgCreateError = `
|
||||
<b>❌ Ошибка создания</b>
|
||||
|
||||
Не удалось создать креатив.
|
||||
Попробуйте ещё раз или вернитесь назад.
|
||||
|
||||
`
|
||||
const msgCreativeCreated = `
|
||||
<b>✅ Креатив успешно создан!</b>
|
||||
|
||||
📄 <b>Название:</b> %s
|
||||
|
||||
`
|
||||
const msgCreativeCreatedButtons = `
|
||||
🔘 <b>Кнопок добавлено:</b> %d
|
||||
|
||||
`
|
||||
|
||||
func filterCreateButtons(rows [][]echotron.InlineKeyboardButton) [][]echotron.InlineKeyboardButton {
|
||||
filtered := make([][]echotron.InlineKeyboardButton, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
skip := false
|
||||
for _, btn := range row {
|
||||
switch btn.CallbackData {
|
||||
case "edit_text", "add_media", "delete_media":
|
||||
skip = true
|
||||
}
|
||||
}
|
||||
if !skip {
|
||||
filtered = append(filtered, row)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func (s *AddCreativeStart) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
if s.Ctx.WaitMessageSent {
|
||||
return
|
||||
}
|
||||
|
||||
kb := Keyboard(Row(Button("« Отмена", "cancel")))
|
||||
b.Render(msgWaitCreative, kb, mode)
|
||||
s.Ctx.WaitMessageSent = true
|
||||
}
|
||||
|
||||
func (s *AddCreativeStart) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
|
||||
return
|
||||
}
|
||||
if u.CallbackQuery.Data == "cancel" && s.Ctx.BackState != nil {
|
||||
b.SetState(s.Ctx.BackState, bot.EditMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AddCreativeStart) HandleMessage(b *bot.Bot, u *echotron.Update) {
|
||||
if u.Message == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.Ctx.extractCreativeFromMessage(u.Message)
|
||||
if s.Ctx.Name == nil {
|
||||
name := s.Ctx.generateCreativeName()
|
||||
s.Ctx.Name = &name
|
||||
}
|
||||
|
||||
if u.Message.MediaGroupID != "" {
|
||||
groupID := u.Message.MediaGroupID
|
||||
s.Ctx.scheduleMediaGroupAction(groupID, func() {
|
||||
b.SetState(&AddCreativeEdit{ctx: s.Ctx}, bot.NewMessage)
|
||||
})
|
||||
b.MarkHandled()
|
||||
return
|
||||
}
|
||||
|
||||
b.SetState(&AddCreativeEdit{ctx: s.Ctx}, bot.NewMessage)
|
||||
}
|
||||
|
||||
func (s *AddCreativeStart) Handle(_ *bot.Bot, _ *echotron.Update) {}
|
||||
|
||||
func (s *AddCreativeStart) Exit() {}
|
||||
|
||||
func (s *AddCreativeEdit) Enter(b *bot.Bot, _ bot.RenderMode) {
|
||||
s.ctx.showCreativeConfirmation(b)
|
||||
}
|
||||
|
||||
func (s *AddCreativeEdit) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
|
||||
return
|
||||
}
|
||||
|
||||
data := u.CallbackQuery.Data
|
||||
|
||||
switch {
|
||||
case data == "cancel":
|
||||
if s.ctx.BackState != nil {
|
||||
b.SetState(s.ctx.BackState, bot.EditMessage)
|
||||
}
|
||||
case data == "edit_text":
|
||||
s.ctx.InputMode = inputModeText
|
||||
b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage)
|
||||
case data == "add_button":
|
||||
s.ctx.InputMode = inputModeButtonText
|
||||
s.ctx.PendingButtonType = "invite"
|
||||
b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage)
|
||||
case data == "add_media":
|
||||
s.ctx.InputMode = inputModeMedia
|
||||
b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage)
|
||||
case data == "delete_media":
|
||||
s.ctx.MediaItems = nil
|
||||
s.ctx.MediaChanged = true
|
||||
s.ctx.UpdateCreativePreview(b)
|
||||
s.Enter(b, bot.EditMessage)
|
||||
case data == "edit_tag":
|
||||
s.ctx.ShowTagPanel(b)
|
||||
case data == "tag_testing":
|
||||
tag := "testing"
|
||||
s.ctx.Tag = &tag
|
||||
s.Enter(b, bot.EditMessage)
|
||||
case data == "tag_production":
|
||||
tag := "production"
|
||||
s.ctx.Tag = &tag
|
||||
s.Enter(b, bot.EditMessage)
|
||||
case data == "cancel_edit":
|
||||
s.Enter(b, bot.EditMessage)
|
||||
case data == "confirm_create":
|
||||
s.ctx.createCreative(b)
|
||||
case strings.HasPrefix(data, "delete_button:"):
|
||||
indexStr := strings.TrimPrefix(data, "delete_button:")
|
||||
index, err := strconv.Atoi(indexStr)
|
||||
if err != nil || index < 0 || index >= len(s.ctx.Buttons) {
|
||||
return
|
||||
}
|
||||
s.ctx.Buttons = append(s.ctx.Buttons[:index], s.ctx.Buttons[index+1:]...)
|
||||
s.ctx.UpdateCreativePreview(b)
|
||||
s.Enter(b, bot.EditMessage)
|
||||
default:
|
||||
s.Enter(b, bot.NewMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AddCreativeEdit) HandleMessage(b *bot.Bot, u *echotron.Update) {
|
||||
if u.Message == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if u.Message.Text == "" {
|
||||
if s.ctx.SetMediaFromMessage(u.Message) {
|
||||
if u.Message.MediaGroupID != "" {
|
||||
groupID := u.Message.MediaGroupID
|
||||
s.ctx.scheduleMediaGroupAction(groupID, func() {
|
||||
s.ctx.UpdateCreativePreview(b)
|
||||
s.Enter(b, bot.EditMessage)
|
||||
})
|
||||
b.MarkHandled()
|
||||
return
|
||||
}
|
||||
s.ctx.UpdateCreativePreview(b)
|
||||
s.Enter(b, bot.EditMessage)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
newName := u.Message.Text
|
||||
runes := []rune(newName)
|
||||
if len(runes) > 200 {
|
||||
newName = string(runes[:200])
|
||||
}
|
||||
|
||||
s.ctx.Name = &newName
|
||||
s.ctx.DeleteUserMessage(b, u.Message.ID)
|
||||
s.Enter(b, bot.EditMessage)
|
||||
}
|
||||
|
||||
func (s *AddCreativeEdit) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *AddCreativeEdit) Exit() {}
|
||||
|
||||
func (s *AddCreativeInput) Enter(b *bot.Bot, _ bot.RenderMode) {
|
||||
switch s.ctx.InputMode {
|
||||
case inputModeText:
|
||||
s.ctx.ShowTextEditPanel(b)
|
||||
case inputModeButtonText:
|
||||
s.ctx.ShowAddButtonPanel(b)
|
||||
case inputModeButtonURL:
|
||||
s.ctx.ShowButtonURLPanel(b)
|
||||
case inputModeMedia:
|
||||
s.ctx.ShowMediaPanel(b)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AddCreativeInput) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
|
||||
return
|
||||
}
|
||||
|
||||
switch u.CallbackQuery.Data {
|
||||
case "button_type_invite":
|
||||
s.ctx.PendingButtonType = "invite"
|
||||
s.ctx.ShowAddButtonPanel(b)
|
||||
case "button_type_custom":
|
||||
s.ctx.PendingButtonType = "custom"
|
||||
s.ctx.ShowAddButtonPanel(b)
|
||||
case "cancel_add_button":
|
||||
s.ctx.CancelPendingButton(b)
|
||||
b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage)
|
||||
case "cancel_edit", "cancel_media":
|
||||
s.ctx.ClearInputMode()
|
||||
b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AddCreativeInput) HandleMessage(b *bot.Bot, u *echotron.Update) {
|
||||
if u.Message == nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch s.ctx.InputMode {
|
||||
case inputModeText:
|
||||
if u.Message.Text == "" {
|
||||
return
|
||||
}
|
||||
text := ui.FormatMessageHTML(u.Message)
|
||||
s.ctx.Text = &text
|
||||
s.ctx.DeleteUserMessage(b, u.Message.ID)
|
||||
s.ctx.ClearInputMode()
|
||||
b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage)
|
||||
case inputModeButtonText:
|
||||
if u.Message.Text == "" {
|
||||
return
|
||||
}
|
||||
buttonText := u.Message.Text
|
||||
s.ctx.DeleteUserMessage(b, u.Message.ID)
|
||||
|
||||
if s.ctx.PendingButtonType == "custom" {
|
||||
s.ctx.AddCustomButtonPlaceholder(buttonText)
|
||||
s.ctx.UpdateCreativePreview(b)
|
||||
s.ctx.InputMode = inputModeButtonURL
|
||||
b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage)
|
||||
return
|
||||
}
|
||||
|
||||
s.ctx.AddInviteButton(buttonText)
|
||||
s.ctx.UpdateCreativePreview(b)
|
||||
s.ctx.PendingButtonType = ""
|
||||
s.ctx.ClearInputMode()
|
||||
b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage)
|
||||
case inputModeButtonURL:
|
||||
if u.Message.Text == "" {
|
||||
return
|
||||
}
|
||||
url := strings.TrimSpace(u.Message.Text)
|
||||
s.ctx.DeleteUserMessage(b, u.Message.ID)
|
||||
|
||||
if !s.ctx.IsValidButtonURL(url, true) {
|
||||
s.ctx.ShowInvalidButtonURLPanel(b)
|
||||
return
|
||||
}
|
||||
|
||||
s.ctx.UpdateLastButtonURL(url)
|
||||
s.ctx.UpdateCreativePreview(b)
|
||||
s.ctx.PendingButtonType = ""
|
||||
s.ctx.ClearInputMode()
|
||||
b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage)
|
||||
case inputModeMedia:
|
||||
if !s.ctx.SetMediaFromMessage(u.Message) {
|
||||
return
|
||||
}
|
||||
if u.Message.MediaGroupID != "" {
|
||||
groupID := u.Message.MediaGroupID
|
||||
s.ctx.scheduleMediaGroupAction(groupID, func() {
|
||||
s.ctx.UpdateCreativePreview(b)
|
||||
s.ctx.ShowMediaPanel(b)
|
||||
b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage)
|
||||
})
|
||||
b.MarkHandled()
|
||||
return
|
||||
}
|
||||
s.ctx.UpdateCreativePreview(b)
|
||||
s.ctx.ShowMediaPanel(b)
|
||||
b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AddCreativeInput) Handle(_ *bot.Bot, _ *echotron.Update) {}
|
||||
|
||||
func (s *AddCreativeInput) Exit() {}
|
||||
|
||||
func (s *AddCreativeCtx) extractCreativeFromMessage(message *echotron.Message) {
|
||||
if message.Text != "" || message.Caption != "" {
|
||||
text := ui.FormatMessageHTML(message)
|
||||
if text != "" {
|
||||
s.Text = &text
|
||||
}
|
||||
}
|
||||
|
||||
s.SetMediaFromMessage(message)
|
||||
|
||||
if message.ReplyMarkup != nil && len(message.ReplyMarkup.InlineKeyboard) > 0 {
|
||||
for _, row := range message.ReplyMarkup.InlineKeyboard {
|
||||
for _, button := range row {
|
||||
if button.URL != "" {
|
||||
s.Buttons = append(s.Buttons, InlineButton{
|
||||
Text: button.Text,
|
||||
URL: button.URL,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AddCreativeCtx) generateCreativeName() string {
|
||||
if s.Text != nil && *s.Text != "" {
|
||||
re := regexp.MustCompile(`<[^>]*>`)
|
||||
cleanText := re.ReplaceAllString(*s.Text, "")
|
||||
|
||||
runes := []rune(strings.TrimSpace(cleanText))
|
||||
if len(runes) > 10 {
|
||||
return string(runes[:10]) + "..."
|
||||
}
|
||||
if len(runes) > 0 {
|
||||
return string(runes)
|
||||
}
|
||||
}
|
||||
|
||||
return "Новый креатив"
|
||||
}
|
||||
|
||||
func (s *AddCreativeCtx) showCreativeConfirmation(b *bot.Bot) {
|
||||
if s.CreativeMessageID == nil {
|
||||
s.SendCreativePreview(b)
|
||||
} else {
|
||||
s.UpdateCreativePreview(b)
|
||||
}
|
||||
|
||||
escapedName := ui.EscapeHTML(*s.Name)
|
||||
confirmText := fmt.Sprintf(msgConfirmCreativeFormat, escapedName)
|
||||
buttons := filterCreateButtons(s.BuildEditorButtons("✓ Сохранить", "confirm_create", "Отмена", "cancel"))
|
||||
s.ShowControlPanel(b, confirmText, buttons)
|
||||
}
|
||||
|
||||
func (s *AddCreativeCtx) createCreative(b *bot.Bot) {
|
||||
var err error
|
||||
var mediaItems []backend.CreativeMediaInput
|
||||
if len(s.MediaItems) > 0 {
|
||||
mediaItems, err = s.BuildMediaInputs(b)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to download creative media")
|
||||
buttons := filterCreateButtons(s.BuildEditorButtons("✓ Попробовать снова", "confirm_create", "Отмена", "cancel"))
|
||||
s.ShowControlPanel(b, msgDownloadMediaError, buttons)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
buttons := make([]backend.CreativeButton, 0, len(s.Buttons))
|
||||
for _, button := range s.Buttons {
|
||||
buttons = append(buttons, backend.CreativeButton{
|
||||
Text: button.Text,
|
||||
URL: button.URL,
|
||||
})
|
||||
}
|
||||
|
||||
log.Info().Str("text", *s.Text).Msg("Creative text")
|
||||
|
||||
tag := "testing"
|
||||
if s.Tag != nil {
|
||||
tag = *s.Tag
|
||||
}
|
||||
|
||||
input := backend.CreateCreativeInput{
|
||||
Name: *s.Name,
|
||||
Text: *s.Text,
|
||||
Buttons: buttons,
|
||||
Tag: &tag,
|
||||
}
|
||||
if len(mediaItems) > 0 {
|
||||
input.MediaItems = mediaItems
|
||||
}
|
||||
|
||||
creative, err := b.Backend.CreateCreative(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.ProjectID, input)
|
||||
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to create creative")
|
||||
|
||||
errMsg := err.Error()
|
||||
var userMsg string
|
||||
|
||||
if strings.Contains(errMsg, "Creative text must contain one invite link") {
|
||||
userMsg = msgInviteLinkRequired
|
||||
} else if strings.Contains(errMsg, "Creative text must contain only one invite link") {
|
||||
userMsg = msgInviteLinkTooMany
|
||||
} else {
|
||||
userMsg = msgCreateError
|
||||
}
|
||||
|
||||
buttons := filterCreateButtons(s.BuildEditorButtons("✓ Попробовать снова", "confirm_create", "Отмена", "cancel"))
|
||||
s.ShowControlPanel(b, userMsg, buttons)
|
||||
return
|
||||
}
|
||||
|
||||
successText := fmt.Sprintf(msgCreativeCreated, creative.Name)
|
||||
if len(s.Buttons) > 0 {
|
||||
successText += fmt.Sprintf(msgCreativeCreatedButtons, len(s.Buttons))
|
||||
}
|
||||
|
||||
b.SendNew(successText, Keyboard())
|
||||
|
||||
if s.BackState != nil {
|
||||
b.SetState(s.BackState, bot.NewMessage)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
|
||||
)
|
||||
|
||||
type AddProject struct {
|
||||
BackState bot.State
|
||||
}
|
||||
|
||||
const addProjectInstructionText = `<b>Как добавить канал в проект</b>
|
||||
|
||||
Назначьте <b>@%s</b> администратором канала.
|
||||
|
||||
<blockquote expandable><b>Необходимые права:</b>
|
||||
|
||||
➔ Управление сообщениями
|
||||
➔ Добавление участников
|
||||
|
||||
<b>После добавления бота:</b>
|
||||
|
||||
• Если у вас <b>1 рабочее пространство</b> — канал добавится автоматически
|
||||
• Если у вас <b>несколько рабочих пространств</b> — вы получите уведомление с кнопкой выбора</blockquote>`
|
||||
|
||||
func (s *AddProject) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
botUsername := strings.TrimPrefix(bot.Username(), "@")
|
||||
addBotURL := fmt.Sprintf("https://t.me/%s?startchannel&admin=invite_users+post_messages+edit_messages+delete_messages", botUsername)
|
||||
|
||||
buttons := [][]echotron.InlineKeyboardButton{
|
||||
Row(Stylish(URLButton("Назначить администратором", addBotURL), echotron.DangerButtonStyle)),
|
||||
Row(Button("← Назад", "back")),
|
||||
}
|
||||
|
||||
keyboard := Keyboard(buttons...)
|
||||
b.Render(fmt.Sprintf(addProjectInstructionText, botUsername), keyboard, mode)
|
||||
}
|
||||
|
||||
func (s *AddProject) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
|
||||
return
|
||||
}
|
||||
|
||||
switch u.CallbackQuery.Data {
|
||||
case "back":
|
||||
if s.BackState != nil {
|
||||
b.SetState(s.BackState, bot.EditMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AddProject) HandleMessage(_ *bot.Bot, _ *echotron.Update) {}
|
||||
|
||||
func (s *AddProject) Handle(_ *bot.Bot, _ *echotron.Update) {}
|
||||
|
||||
func (s *AddProject) Exit() {}
|
||||
@@ -0,0 +1,325 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/backend"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const addPurchaseCreativesPerPage = 5
|
||||
const addPurchaseProjectsPerPage = 6
|
||||
|
||||
type AddPurchase struct {
|
||||
ProjectID string
|
||||
ProjectTitle string
|
||||
ProjectTelegramID int64
|
||||
ProjectUsername string
|
||||
ProjectStatus string
|
||||
ProjectDefaultLinkType string
|
||||
CreativeID string
|
||||
CreativeTitle string
|
||||
ProjectPage int
|
||||
CreativePage int
|
||||
ActivePicker string // "" | "project_list" | "creative_list"
|
||||
BackState bot.State
|
||||
lastProjects map[string]projectInfo
|
||||
lastCreatives map[string]creativeInfo
|
||||
}
|
||||
|
||||
type projectInfo struct {
|
||||
Title string
|
||||
TelegramID int64
|
||||
Username string
|
||||
Status string
|
||||
DefaultInviteType string
|
||||
}
|
||||
|
||||
type creativeInfo struct {
|
||||
Title string
|
||||
}
|
||||
|
||||
func (s *AddPurchase) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
s.renderSelection(b, mode)
|
||||
}
|
||||
|
||||
func (s *AddPurchase) renderSelection(b *bot.Bot, mode bot.RenderMode) {
|
||||
text := "<b>Создание закупа</b>\n\n"
|
||||
text += "Выберите проект и креатив.\n\n"
|
||||
|
||||
var buttons [][]echotron.InlineKeyboardButton
|
||||
|
||||
projectLabel := "Проект: не выбрано"
|
||||
if s.ProjectTitle != "" {
|
||||
projectLabel = fmt.Sprintf("Проект: %s", s.ProjectTitle)
|
||||
}
|
||||
creativeLabel := "Креатив: не выбрано"
|
||||
if s.CreativeTitle != "" {
|
||||
creativeLabel = fmt.Sprintf("Креатив: %s", s.CreativeTitle)
|
||||
}
|
||||
|
||||
if s.ActivePicker == "" {
|
||||
buttons = append(buttons, Row(
|
||||
Button(projectLabel, "pick_project"),
|
||||
))
|
||||
buttons = append(buttons, Row(
|
||||
Button(creativeLabel, "pick_creative"),
|
||||
))
|
||||
}
|
||||
|
||||
switch s.ActivePicker {
|
||||
case "project_list":
|
||||
page, err := b.Backend.GetProjects(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.ProjectPage+1, addPurchaseProjectsPerPage)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to get projects")
|
||||
b.SendNew("❌ Не удалось загрузить проекты", Keyboard())
|
||||
return
|
||||
}
|
||||
|
||||
s.lastProjects = make(map[string]projectInfo)
|
||||
for _, project := range page.Items {
|
||||
username := ""
|
||||
if project.Username != nil {
|
||||
username = *project.Username
|
||||
}
|
||||
s.lastProjects[project.ID] = projectInfo{
|
||||
Title: project.Title,
|
||||
TelegramID: project.TelegramID,
|
||||
Username: username,
|
||||
Status: project.Status,
|
||||
DefaultInviteType: project.PurchaseInviteTypeDefault,
|
||||
}
|
||||
}
|
||||
|
||||
if len(page.Items) == 0 {
|
||||
text += "У вас нет проектов. Создайте проект и попробуйте снова.\n\n"
|
||||
} else {
|
||||
text += fmt.Sprintf("Проекты%s\n\n", ui.FormatPageInfo(s.ProjectPage, page.Pages))
|
||||
|
||||
buttons = append(buttons, ui.BuildGrid(
|
||||
page.Items,
|
||||
2,
|
||||
addPurchaseProjectsPerPage,
|
||||
page.Pages,
|
||||
func(project backend.Project) (string, string) {
|
||||
return project.Title, "project:" + project.ID
|
||||
},
|
||||
)...)
|
||||
|
||||
if navRow := ui.BuildNavigationRow(ui.PaginationConfig{
|
||||
CurrentPage: s.ProjectPage,
|
||||
TotalPages: page.Pages,
|
||||
}); navRow != nil {
|
||||
buttons = append(buttons, navRow)
|
||||
}
|
||||
}
|
||||
|
||||
buttons = append(buttons, Row(
|
||||
Button("← Назад", "back_to_selection"),
|
||||
))
|
||||
|
||||
case "creative_list":
|
||||
if s.ProjectID == "" {
|
||||
text += "Сначала выберите проект, чтобы увидеть список креативов.\n\n"
|
||||
buttons = append(buttons, Row(
|
||||
Button("← Назад", "back_to_selection"),
|
||||
))
|
||||
break
|
||||
}
|
||||
|
||||
page, err := b.Backend.GetCreatives(context.Background(), b.Session.JWT, b.Session.WorkspaceID, &s.ProjectID, false, s.CreativePage+1, addPurchaseCreativesPerPage)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to get creatives")
|
||||
b.SendNew("❌ Не удалось загрузить креативы", Keyboard())
|
||||
return
|
||||
}
|
||||
|
||||
s.lastCreatives = make(map[string]creativeInfo)
|
||||
for _, creative := range page.Items {
|
||||
s.lastCreatives[creative.ID] = creativeInfo{
|
||||
Title: creative.Name,
|
||||
}
|
||||
}
|
||||
|
||||
if len(page.Items) == 0 {
|
||||
text += `Для создания закупа сначала нужно создать креатив.
|
||||
|
||||
Вернитесь назад и создайте креатив в разделе <b>Креативы</b>`
|
||||
|
||||
buttons = append(buttons, Row(
|
||||
Button("🎨 Перейти к креативам", "go_to_creatives"),
|
||||
))
|
||||
} else {
|
||||
text += fmt.Sprintf("Креативы%s\n\n", ui.FormatPageInfo(s.CreativePage, page.Pages))
|
||||
|
||||
buttons = append(buttons, ui.BuildGrid(
|
||||
page.Items,
|
||||
2,
|
||||
addPurchaseCreativesPerPage,
|
||||
page.Pages,
|
||||
func(creative backend.Creative) (string, string) {
|
||||
return creative.Name, "creative:" + creative.ID
|
||||
},
|
||||
)...)
|
||||
|
||||
if navRow := ui.BuildNavigationRow(ui.PaginationConfig{
|
||||
CurrentPage: s.CreativePage,
|
||||
TotalPages: page.Pages,
|
||||
}); navRow != nil {
|
||||
buttons = append(buttons, navRow)
|
||||
}
|
||||
}
|
||||
|
||||
buttons = append(buttons, Row(
|
||||
Button("← Назад", "back_to_selection"),
|
||||
))
|
||||
}
|
||||
|
||||
if s.ActivePicker == "" {
|
||||
if s.ProjectID != "" && s.CreativeID != "" {
|
||||
buttons = append(buttons, Row(
|
||||
Button("← Отмена", "back"),
|
||||
Button("→ Далее", "next"),
|
||||
))
|
||||
} else {
|
||||
buttons = append(buttons, Row(
|
||||
Button("← Отмена", "back"),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
keyboard := Keyboard(buttons...)
|
||||
b.Render(text, keyboard, mode)
|
||||
|
||||
if s.ActivePicker == "" && s.ProjectTelegramID != 0 {
|
||||
messageID := b.LastMessageID
|
||||
updateProjectHeaderMedia(b, messageID, text, keyboard, s.ProjectTelegramID, s.ProjectTitle, s.ProjectUsername, s.ProjectStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AddPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
|
||||
return
|
||||
}
|
||||
|
||||
data := u.CallbackQuery.Data
|
||||
|
||||
switch data {
|
||||
case "back":
|
||||
if s.BackState != nil {
|
||||
b.SetState(s.BackState, bot.EditMessage)
|
||||
}
|
||||
|
||||
case "pick_project":
|
||||
s.ActivePicker = "project_list"
|
||||
s.Enter(b, bot.EditMessage)
|
||||
|
||||
case "pick_creative":
|
||||
s.ActivePicker = "creative_list"
|
||||
s.Enter(b, bot.EditMessage)
|
||||
|
||||
case "back_to_selection":
|
||||
s.ActivePicker = ""
|
||||
s.Enter(b, bot.EditMessage)
|
||||
|
||||
case "go_to_creatives":
|
||||
b.SetState(&Creatives{
|
||||
CurrentPage: 0,
|
||||
ProjectID: s.ProjectID,
|
||||
ProjectTitle: s.ProjectTitle,
|
||||
ProjectTelegramID: s.ProjectTelegramID,
|
||||
ProjectUsername: s.ProjectUsername,
|
||||
ProjectStatus: s.ProjectStatus,
|
||||
BackState: s,
|
||||
}, bot.EditMessage)
|
||||
|
||||
case "prev":
|
||||
if s.ActivePicker == "project_list" {
|
||||
if s.ProjectPage > 0 {
|
||||
s.ProjectPage--
|
||||
}
|
||||
s.Enter(b, bot.EditMessage)
|
||||
return
|
||||
}
|
||||
if s.ActivePicker == "creative_list" {
|
||||
if s.CreativePage > 0 {
|
||||
s.CreativePage--
|
||||
}
|
||||
s.Enter(b, bot.EditMessage)
|
||||
return
|
||||
}
|
||||
|
||||
case "next":
|
||||
if s.ActivePicker == "project_list" {
|
||||
s.ProjectPage++
|
||||
s.Enter(b, bot.EditMessage)
|
||||
return
|
||||
}
|
||||
if s.ActivePicker == "creative_list" {
|
||||
s.CreativePage++
|
||||
s.Enter(b, bot.EditMessage)
|
||||
return
|
||||
}
|
||||
if s.ProjectID != "" && s.CreativeID != "" {
|
||||
b.SetState(&SelectChannelsForPurchase{
|
||||
ProjectID: s.ProjectID,
|
||||
ProjectTitle: s.ProjectTitle,
|
||||
ProjectTelegramID: s.ProjectTelegramID,
|
||||
ProjectUsername: s.ProjectUsername,
|
||||
ProjectStatus: s.ProjectStatus,
|
||||
ProjectDefaultLinkType: s.ProjectDefaultLinkType,
|
||||
CreativeID: s.CreativeID,
|
||||
CreativeTitle: s.CreativeTitle,
|
||||
Channels: []PurchaseChannelInput{},
|
||||
Duplicates: []string{},
|
||||
ParsingErrors: []ParseError{},
|
||||
BackState: s.BackState,
|
||||
}, bot.EditMessage)
|
||||
}
|
||||
|
||||
default:
|
||||
if len(data) > 8 && data[:8] == "project:" {
|
||||
projectID := data[8:]
|
||||
if s.lastProjects != nil {
|
||||
if info, ok := s.lastProjects[projectID]; ok {
|
||||
s.ProjectTitle = info.Title
|
||||
s.ProjectTelegramID = info.TelegramID
|
||||
s.ProjectUsername = info.Username
|
||||
s.ProjectStatus = info.Status
|
||||
s.ProjectDefaultLinkType = info.DefaultInviteType
|
||||
}
|
||||
}
|
||||
s.ProjectID = projectID
|
||||
s.CreativeID = ""
|
||||
s.CreativeTitle = ""
|
||||
s.CreativePage = 0
|
||||
s.ActivePicker = ""
|
||||
s.Enter(b, bot.EditMessage)
|
||||
return
|
||||
}
|
||||
if len(data) > 9 && data[:9] == "creative:" {
|
||||
creativeID := data[9:]
|
||||
if s.lastCreatives != nil {
|
||||
if info, ok := s.lastCreatives[creativeID]; ok {
|
||||
s.CreativeTitle = info.Title
|
||||
}
|
||||
}
|
||||
s.CreativeID = creativeID
|
||||
s.ActivePicker = ""
|
||||
s.Enter(b, bot.EditMessage)
|
||||
return
|
||||
} else {
|
||||
s.Enter(b, bot.NewMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AddPurchase) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *AddPurchase) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *AddPurchase) Exit() {}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,380 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ChannelInput представляет результат парсинга ввода пользователя
|
||||
type ChannelInput struct {
|
||||
Input string // оригинальный ввод пользователя
|
||||
Username string // извлеченный username (если применимо)
|
||||
InviteLink string // извлеченный invite link (если применимо)
|
||||
Source string // источник: telegram, tgstat, telemetr
|
||||
Type string // тип: username, invite, deeplink, stat
|
||||
Valid bool // валидный ли формат
|
||||
}
|
||||
|
||||
// Парсеры для разных форматов
|
||||
var (
|
||||
// Username без @: 4-32 символа, начинается с буквы
|
||||
channelUsernameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{3,31}$`)
|
||||
|
||||
// Invite link: t.me/+xxxxx или t.me/joinchat/xxxxx
|
||||
inviteLinkRe = regexp.MustCompile(`^t\.me/\+[a-zA-Z0-9_-]+$`)
|
||||
inviteLinkJoinRe = regexp.MustCompile(`^t\.me/joinchat/[a-zA-Z0-9_-]+$`)
|
||||
|
||||
// TGStat: tgstat.ru/channel_name или tgstat.ru/channel/@username
|
||||
tgstatRe = regexp.MustCompile(`^(?:https?://)?(?:www\.)?tgstat\.ru/(?:channel/)?@?([A-Za-z][A-Za-z0-9_.]{3,31})`)
|
||||
|
||||
// Telemetr: telemetr.io/channel/username или telemetr.io/channel/@username
|
||||
telemetrRe = regexp.MustCompile(`^(?:https?://)?(?:www\.)?telemetr\.io/(?:channel|channels)/@?([A-Za-z][A-Za-z0-9_.]{3,31})`)
|
||||
)
|
||||
|
||||
// ParseChannelInput парсит ввод пользователя и определяет тип канала
|
||||
func ParseChannelInput(input string) ChannelInput {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return ChannelInput{Valid: false}
|
||||
}
|
||||
|
||||
result := ChannelInput{Input: input}
|
||||
|
||||
// 1. Проверяем Telegram deeplink (tg://...)
|
||||
if strings.HasPrefix(input, "tg://") {
|
||||
return parseTelegramDeeplink(input)
|
||||
}
|
||||
|
||||
// 2. Проверяем invite ссылки (t.me/+xxx или t.me/joinchat/xxx)
|
||||
if inviteLinkRe.MatchString(input) || inviteLinkJoinRe.MatchString(input) {
|
||||
result.Type = "invite"
|
||||
result.Source = "telegram"
|
||||
result.InviteLink = normalizeInviteLink(input)
|
||||
result.Valid = true
|
||||
return result
|
||||
}
|
||||
|
||||
// 3. Проверяем URL форматы (http://, https://, t.me/, telegram.me/)
|
||||
if isURLFormat(input) {
|
||||
return parseURLFormat(input)
|
||||
}
|
||||
|
||||
// 4. Проверяем @username
|
||||
if strings.HasPrefix(input, "@") {
|
||||
username := strings.TrimPrefix(input, "@")
|
||||
username = strings.TrimSpace(username)
|
||||
if channelUsernameRe.MatchString(username) {
|
||||
result.Username = username
|
||||
result.Type = "username"
|
||||
result.Source = "telegram"
|
||||
result.Valid = true
|
||||
return result
|
||||
}
|
||||
result.Valid = false
|
||||
return result
|
||||
}
|
||||
|
||||
// 5. Проверяем username (без @)
|
||||
if channelUsernameRe.MatchString(input) {
|
||||
result.Username = input
|
||||
result.Type = "username"
|
||||
result.Source = "telegram"
|
||||
result.Valid = true
|
||||
return result
|
||||
}
|
||||
|
||||
// 6. Ничего не подошло
|
||||
result.Valid = false
|
||||
return result
|
||||
}
|
||||
|
||||
// parseTelegramDeeplink парсит tg:// ссылки
|
||||
func parseTelegramDeeplink(input string) ChannelInput {
|
||||
result := ChannelInput{Input: input, Source: "telegram", Type: "deeplink"}
|
||||
|
||||
// tg://resolve?domain=username&post=123
|
||||
if strings.HasPrefix(input, "tg://resolve?domain=") {
|
||||
parsed, err := url.Parse(input)
|
||||
if err != nil {
|
||||
result.Valid = false
|
||||
return result
|
||||
}
|
||||
query := parsed.Query()
|
||||
domain := query.Get("domain")
|
||||
if domain != "" && channelUsernameRe.MatchString(domain) {
|
||||
result.Username = domain
|
||||
result.Type = "username"
|
||||
result.Valid = true
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// tg://join?invite=abcdef
|
||||
if strings.HasPrefix(input, "tg://join?invite=") {
|
||||
parsed, err := url.Parse(input)
|
||||
if err != nil {
|
||||
result.Valid = false
|
||||
return result
|
||||
}
|
||||
query := parsed.Query()
|
||||
invite := query.Get("invite")
|
||||
if invite != "" {
|
||||
result.InviteLink = "t.me/+" + invite
|
||||
result.Type = "invite"
|
||||
result.Valid = true
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
result.Valid = false
|
||||
return result
|
||||
}
|
||||
|
||||
// isURLFormat проверяет, является ли ввод URL или ссылкой
|
||||
func isURLFormat(input string) bool {
|
||||
return strings.HasPrefix(input, "http://") ||
|
||||
strings.HasPrefix(input, "https://") ||
|
||||
strings.HasPrefix(input, "t.me/") ||
|
||||
strings.HasPrefix(input, "telegram.me/") ||
|
||||
strings.Contains(input, "t.me/") ||
|
||||
strings.Contains(input, "telegram.me/")
|
||||
}
|
||||
|
||||
// parseURLFormat парсит URL-форматы ссылок
|
||||
func parseURLFormat(input string) ChannelInput {
|
||||
result := ChannelInput{Input: input}
|
||||
|
||||
// Нормализуем: убираем протокол
|
||||
normalized := input
|
||||
normalized = strings.TrimPrefix(normalized, "https://")
|
||||
normalized = strings.TrimPrefix(normalized, "http://")
|
||||
normalized = strings.TrimPrefix(normalized, "www.")
|
||||
|
||||
// Проверяем TGStat
|
||||
if matches := tgstatRe.FindStringSubmatch(input); len(matches) > 1 {
|
||||
username := strings.TrimSuffix(matches[1], "_")
|
||||
result.Username = username
|
||||
result.Type = "stat"
|
||||
result.Source = "tgstat"
|
||||
result.Valid = true
|
||||
return result
|
||||
}
|
||||
|
||||
// Проверяем Telemetr
|
||||
if matches := telemetrRe.FindStringSubmatch(input); len(matches) > 1 {
|
||||
username := strings.TrimSuffix(matches[1], "_")
|
||||
result.Username = username
|
||||
result.Type = "stat"
|
||||
result.Source = "telemetr"
|
||||
result.Valid = true
|
||||
return result
|
||||
}
|
||||
|
||||
// Проверяем telegram.me или t.me
|
||||
if strings.HasPrefix(normalized, "t.me/") || strings.HasPrefix(normalized, "telegram.me/") {
|
||||
return parseTelegramLink(normalized)
|
||||
}
|
||||
|
||||
result.Valid = false
|
||||
return result
|
||||
}
|
||||
|
||||
// parseTelegramLink парсит ссылки t.me/... и telegram.me/...
|
||||
func parseTelegramLink(link string) ChannelInput {
|
||||
result := ChannelInput{Source: "telegram"}
|
||||
|
||||
// Убираем t.me/ или telegram.me/
|
||||
path := link
|
||||
if strings.HasPrefix(path, "telegram.me/") {
|
||||
path = strings.TrimPrefix(path, "telegram.me/")
|
||||
} else {
|
||||
path = strings.TrimPrefix(path, "t.me/")
|
||||
}
|
||||
|
||||
// Проверяем invite link: +xxxxx или joinchat/xxxxx
|
||||
if strings.HasPrefix(path, "+") {
|
||||
inviteHash := strings.TrimPrefix(path, "+")
|
||||
inviteHash = extractBeforeChar(inviteHash, '?', '/')
|
||||
if inviteHash != "" {
|
||||
result.InviteLink = "t.me/+" + inviteHash
|
||||
result.Type = "invite"
|
||||
result.Valid = true
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "joinchat/") {
|
||||
inviteHash := strings.TrimPrefix(path, "joinchat/")
|
||||
inviteHash = extractBeforeChar(inviteHash, '?', '/')
|
||||
if inviteHash != "" {
|
||||
result.InviteLink = "t.me/joinchat/" + inviteHash
|
||||
result.Type = "invite"
|
||||
result.Valid = true
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// Извлекаем username из t.me/username или t.me/username/12345
|
||||
parts := strings.SplitN(path, "/", 2)
|
||||
if len(parts) > 0 {
|
||||
username := parts[0]
|
||||
// Убираем @ если есть
|
||||
username = strings.TrimPrefix(username, "@")
|
||||
|
||||
// Валидируем username
|
||||
if channelUsernameRe.MatchString(username) {
|
||||
result.Username = username
|
||||
result.Type = "username"
|
||||
result.Valid = true
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
result.Valid = false
|
||||
return result
|
||||
}
|
||||
|
||||
// normalizeInviteLink нормализует invite ссылку
|
||||
func normalizeInviteLink(link string) string {
|
||||
link = strings.TrimSpace(link)
|
||||
|
||||
// Добавляем протокол если нужно
|
||||
if !strings.HasPrefix(link, "http://") && !strings.HasPrefix(link, "https://") && !strings.HasPrefix(link, "t.me/") {
|
||||
if strings.HasPrefix(link, "t.me/") {
|
||||
// Уже в нужном формате
|
||||
return link
|
||||
}
|
||||
}
|
||||
|
||||
// Нормализуем t.me/+xxxxx
|
||||
if strings.HasPrefix(link, "t.me/+") {
|
||||
return link
|
||||
}
|
||||
|
||||
// Нормализуем t.me/joinchat/xxxxx
|
||||
if strings.HasPrefix(link, "t.me/joinchat/") {
|
||||
return link
|
||||
}
|
||||
|
||||
return link
|
||||
}
|
||||
|
||||
// extractBeforeChar извлекает часть строки до первого встреченного символа
|
||||
func extractBeforeChar(s string, chars ...rune) string {
|
||||
for _, c := range chars {
|
||||
if idx := strings.IndexRune(s, c); idx != -1 {
|
||||
return s[:idx]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SuggestFix предлагает исправление для некорректного ввода
|
||||
func SuggestFix(input string) (string, bool) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// 1. Исправляем опечатки в протоколе
|
||||
if strings.HasPrefix(input, "htp://") {
|
||||
fixed := strings.Replace(input, "htp://", "https://", 1)
|
||||
if parsed := ParseChannelInput(fixed); parsed.Valid {
|
||||
return fixed, true
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(input, "ttps://") {
|
||||
fixed := strings.Replace(input, "ttps://", "https://", 1)
|
||||
if parsed := ParseChannelInput(fixed); parsed.Valid {
|
||||
return fixed, true
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(input, "ttp://") {
|
||||
fixed := strings.Replace(input, "ttp://", "http://", 1)
|
||||
if parsed := ParseChannelInput(fixed); parsed.Valid {
|
||||
return fixed, true
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Исправляем tme/ → t.me/
|
||||
if strings.HasPrefix(input, "tme/") {
|
||||
fixed := strings.Replace(input, "tme/", "t.me/", 1)
|
||||
if parsed := ParseChannelInput(fixed); parsed.Valid {
|
||||
return fixed, true
|
||||
}
|
||||
}
|
||||
if strings.Contains(input, "tme/") {
|
||||
fixed := strings.Replace(input, "tme/", "t.me/", 1)
|
||||
if parsed := ParseChannelInput(fixed); parsed.Valid {
|
||||
return fixed, true
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Исправляем @@ или @@
|
||||
if strings.HasPrefix(input, "@@") {
|
||||
fixed := strings.TrimPrefix(input, "@")
|
||||
if parsed := ParseChannelInput(fixed); parsed.Valid {
|
||||
return fixed, true
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Пробуем добавить @ в начало username
|
||||
if !strings.HasPrefix(input, "@") && !isURLFormat(input) {
|
||||
fixed := "@" + input
|
||||
if parsed := ParseChannelInput(fixed); parsed.Valid {
|
||||
return fixed, true
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Пробуем убрать лишние символы в конце
|
||||
cleaned := input
|
||||
cleaned = strings.TrimSuffix(cleaned, ".")
|
||||
cleaned = strings.TrimSuffix(cleaned, ",")
|
||||
cleaned = strings.TrimSuffix(cleaned, ";")
|
||||
cleaned = strings.TrimSpace(cleaned)
|
||||
if cleaned != input {
|
||||
if parsed := ParseChannelInput(cleaned); parsed.Valid {
|
||||
return cleaned, true
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Пробуем извлечь username из сложной ссылки
|
||||
if strings.Contains(input, "/") {
|
||||
parts := strings.Split(input, "/")
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
part := strings.TrimSpace(parts[i])
|
||||
part = strings.TrimPrefix(part, "@")
|
||||
if channelUsernameRe.MatchString(part) {
|
||||
return "@" + part, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// FormatChannelLabel форматирует метку канала для отображения
|
||||
func FormatChannelLabel(input ChannelInput) string {
|
||||
if input.Username != "" {
|
||||
return "@" + input.Username
|
||||
}
|
||||
if input.InviteLink != "" {
|
||||
return formatInviteLabel(input.InviteLink)
|
||||
}
|
||||
return input.Input
|
||||
}
|
||||
|
||||
// IsDuplicate проверяет, является ли канал дубликатом (case-insensitive для username)
|
||||
func IsDuplicate(input ChannelInput, channels []PurchaseChannelInput) bool {
|
||||
for _, ch := range channels {
|
||||
// Case-insensitive сравнение username (@MyChannel == @mychannel)
|
||||
if input.Username != "" && ch.Username != "" && strings.EqualFold(ch.Username, input.Username) {
|
||||
return true
|
||||
}
|
||||
if input.InviteLink != "" && ch.InviteLink == input.InviteLink {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/backend"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type CreativeDetails struct {
|
||||
CreativeEditorFields // Встраивание общих полей
|
||||
|
||||
CreativeID string
|
||||
ProjectID string
|
||||
ProjectTitle string // Название проекта для сообщений
|
||||
|
||||
BackState bot.State
|
||||
|
||||
// View mode: открыты из режима просмотра
|
||||
ViewMode bool // Открыты из CreativeView
|
||||
ViewBackTo bot.State // Состояние для возврата (CreativeView)
|
||||
|
||||
// Флаг подтверждения удаления
|
||||
confirmingDelete bool
|
||||
}
|
||||
|
||||
func (s *CreativeDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
// Удаляем сообщение предыдущего экрана
|
||||
if b.LastMessageID != 0 {
|
||||
b.DeleteMessage(b.ChatID, b.LastMessageID)
|
||||
b.LastMessageID = 0
|
||||
}
|
||||
|
||||
creative, err := b.Backend.GetCreative(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.CreativeID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to get creative")
|
||||
b.SendNew("<b>❌ Не удалось загрузить креатив</b>", Keyboard(
|
||||
Row(Button("← Назад", "back")),
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// Заполняем поля из загруженного креатива
|
||||
s.Name = &creative.Name
|
||||
if creative.Text != "" {
|
||||
sanitizedText := ui.SanitizeHTML(creative.Text)
|
||||
s.Text = &sanitizedText
|
||||
log.Info().Str("creative_text", creative.Text).Msg("Set creative text from backend")
|
||||
} else {
|
||||
log.Info().Msg("Creative text is empty from backend")
|
||||
}
|
||||
s.MediaItems = nil
|
||||
if len(creative.MediaItems) > 0 {
|
||||
mediaItems := append([]backend.CreativeMediaItem(nil), creative.MediaItems...)
|
||||
sort.Slice(mediaItems, func(i, j int) bool {
|
||||
return mediaItems[i].Position < mediaItems[j].Position
|
||||
})
|
||||
for _, item := range mediaItems {
|
||||
s.MediaItems = append(s.MediaItems, MediaItem{
|
||||
MediaType: item.MediaType,
|
||||
MediaFileID: item.MediaFileID,
|
||||
})
|
||||
}
|
||||
}
|
||||
s.Buttons = nil
|
||||
for _, btn := range creative.Buttons {
|
||||
s.Buttons = append(s.Buttons, InlineButton{Text: btn.Text, URL: btn.URL})
|
||||
}
|
||||
if creative.Tag != "" {
|
||||
s.Tag = &creative.Tag
|
||||
}
|
||||
s.MediaChanged = false
|
||||
s.ClearInputMode()
|
||||
|
||||
log.Info().
|
||||
Str("creative_id", s.CreativeID).
|
||||
Str("name", creative.Name).
|
||||
Bool("has_text", creative.Text != "").
|
||||
Bool("s_text_is_nil", s.Text == nil).
|
||||
Msg("Sending creative preview and panel")
|
||||
|
||||
// Отправляем превью и панель управления
|
||||
s.SendCreativePreview(b)
|
||||
s.showManagementPanelWithDelete(b)
|
||||
}
|
||||
|
||||
func (s *CreativeDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
|
||||
return
|
||||
}
|
||||
|
||||
data := u.CallbackQuery.Data
|
||||
|
||||
// Обрабатываем общие callbacks (edit_text, add_button, add_media, delete_button, delete_media)
|
||||
switch {
|
||||
case data == "cancel", data == "back":
|
||||
// Удаляем сообщения текущего экрана перед переходом
|
||||
s.CleanupMessages(b)
|
||||
if s.ViewMode && s.ViewBackTo != nil {
|
||||
b.SetState(s.ViewBackTo, bot.NewMessage)
|
||||
} else if s.BackState != nil {
|
||||
b.SetState(s.BackState, bot.NewMessage)
|
||||
}
|
||||
|
||||
case data == "edit_text":
|
||||
s.InputMode = inputModeText
|
||||
s.ShowTextEditPanel(b)
|
||||
|
||||
case data == "add_button":
|
||||
s.InputMode = inputModeButtonText
|
||||
s.PendingButtonType = "invite"
|
||||
s.ShowAddButtonPanel(b)
|
||||
|
||||
case data == "add_media":
|
||||
s.InputMode = inputModeMedia
|
||||
s.ShowMediaPanel(b)
|
||||
|
||||
case data == "delete_media":
|
||||
s.MediaItems = nil
|
||||
s.MediaChanged = true
|
||||
s.UpdateCreativePreview(b)
|
||||
s.showManagementPanelWithDelete(b)
|
||||
|
||||
case data == "cancel_add_button":
|
||||
// Отменяем добавление кнопки
|
||||
s.CancelPendingButton(b)
|
||||
s.showManagementPanelWithDelete(b)
|
||||
|
||||
case data == "button_type_invite":
|
||||
s.PendingButtonType = "invite"
|
||||
s.InputMode = inputModeButtonText
|
||||
s.ShowAddButtonPanel(b)
|
||||
|
||||
case data == "button_type_custom":
|
||||
s.PendingButtonType = "custom"
|
||||
s.InputMode = inputModeButtonText
|
||||
s.ShowAddButtonPanel(b)
|
||||
|
||||
case data == "cancel_edit", data == "cancel_media":
|
||||
s.ClearInputMode()
|
||||
s.showManagementPanelWithDelete(b)
|
||||
|
||||
case data == "confirm_save":
|
||||
s.updateCreative(b)
|
||||
|
||||
case data == "delete_creative":
|
||||
if !s.confirmingDelete {
|
||||
// Первое нажатие - просим подтверждение
|
||||
s.confirmingDelete = true
|
||||
s.showManagementPanelWithDelete(b)
|
||||
} else {
|
||||
// Второе нажатие - удаляем
|
||||
s.deleteCreative(b)
|
||||
}
|
||||
|
||||
case data == "confirm_delete":
|
||||
// Это только для безопасности, главная логика в delete_creative
|
||||
s.deleteCreative(b)
|
||||
|
||||
case strings.HasPrefix(data, "delete_button:"):
|
||||
parts := strings.Split(data, ":")
|
||||
if len(parts) == 2 {
|
||||
var index int
|
||||
if _, err := fmt.Sscanf(parts[1], "%d", &index); err == nil {
|
||||
if index >= 0 && index < len(s.Buttons) {
|
||||
s.Buttons = append(s.Buttons[:index], s.Buttons[index+1:]...)
|
||||
s.UpdateCreativePreview(b)
|
||||
s.showManagementPanelWithDelete(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
// Сбрасываем флаг подтверждения удаления при любом другом действии
|
||||
if s.confirmingDelete {
|
||||
s.confirmingDelete = false
|
||||
s.showManagementPanelWithDelete(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CreativeDetails) HandleMessage(b *bot.Bot, u *echotron.Update) {
|
||||
if u.Message == nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch s.InputMode {
|
||||
case inputModeMedia:
|
||||
if !s.SetMediaFromMessage(u.Message) {
|
||||
return
|
||||
}
|
||||
if u.Message.MediaGroupID != "" {
|
||||
groupID := u.Message.MediaGroupID
|
||||
s.scheduleMediaGroupAction(groupID, func() {
|
||||
s.UpdateCreativePreview(b)
|
||||
s.ShowMediaPanel(b)
|
||||
})
|
||||
b.MarkHandled()
|
||||
return
|
||||
}
|
||||
s.UpdateCreativePreview(b)
|
||||
s.ShowMediaPanel(b)
|
||||
return
|
||||
case inputModeButtonText:
|
||||
if u.Message.Text == "" {
|
||||
return
|
||||
}
|
||||
buttonText := u.Message.Text
|
||||
s.DeleteUserMessage(b, u.Message.ID)
|
||||
|
||||
// Создаём кнопку с placeholder URL
|
||||
if s.PendingButtonType == "custom" {
|
||||
s.AddCustomButtonPlaceholder(buttonText)
|
||||
s.UpdateCreativePreview(b)
|
||||
s.InputMode = inputModeButtonURL
|
||||
s.ShowButtonURLPanel(b)
|
||||
} else {
|
||||
s.AddInviteButton(buttonText)
|
||||
s.UpdateCreativePreview(b)
|
||||
s.PendingButtonType = ""
|
||||
s.ClearInputMode()
|
||||
s.showManagementPanelWithDelete(b)
|
||||
}
|
||||
return
|
||||
case inputModeButtonURL:
|
||||
if u.Message.Text == "" {
|
||||
return
|
||||
}
|
||||
url := strings.TrimSpace(u.Message.Text)
|
||||
s.DeleteUserMessage(b, u.Message.ID)
|
||||
|
||||
// Простая валидация URL
|
||||
if !s.IsValidButtonURL(url, true) {
|
||||
s.ShowInvalidButtonURLPanel(b)
|
||||
return
|
||||
}
|
||||
|
||||
// Обновляем URL последней кнопки
|
||||
s.UpdateLastButtonURL(url)
|
||||
|
||||
s.ClearInputMode()
|
||||
s.PendingButtonType = ""
|
||||
|
||||
s.UpdateCreativePreview(b)
|
||||
s.showManagementPanelWithDelete(b)
|
||||
return
|
||||
case inputModeText:
|
||||
if u.Message.Text == "" {
|
||||
return
|
||||
}
|
||||
text := ui.FormatMessageHTML(u.Message)
|
||||
s.Text = &text
|
||||
s.DeleteUserMessage(b, u.Message.ID)
|
||||
|
||||
s.UpdateCreativePreview(b)
|
||||
s.ClearInputMode()
|
||||
s.showManagementPanelWithDelete(b)
|
||||
return
|
||||
}
|
||||
|
||||
// Обрабатываем имя креатива (если нет других состояний)
|
||||
if u.Message.Text != "" {
|
||||
text := ui.FormatMessageHTML(u.Message)
|
||||
s.Text = &text
|
||||
s.DeleteUserMessage(b, u.Message.ID)
|
||||
|
||||
s.UpdateCreativePreview(b)
|
||||
s.showManagementPanelWithDelete(b)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CreativeDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *CreativeDetails) Exit() {}
|
||||
|
||||
func (s *CreativeDetails) showManagementPanelWithDelete(b *bot.Bot) {
|
||||
var confirmText, confirmCallback string
|
||||
|
||||
if s.confirmingDelete {
|
||||
confirmText = "✖ Подтвердить удаление"
|
||||
confirmCallback = "confirm_delete"
|
||||
} else {
|
||||
confirmText = "✓ Сохранить"
|
||||
confirmCallback = "confirm_save"
|
||||
}
|
||||
|
||||
// Добавляем кнопку удаления креатива
|
||||
deleteButton := Row(Button("⌦ Удалить креатив", "delete_creative"))
|
||||
|
||||
s.ShowManagementPanel(b, confirmText, confirmCallback, "cancel", deleteButton)
|
||||
}
|
||||
|
||||
func (s *CreativeDetails) updateCreative(b *bot.Bot) {
|
||||
// JWT уже создан в Bot.Update(), просто берем из сессии
|
||||
jwt := b.Session.JWT
|
||||
if jwt == "" {
|
||||
log.Error().Msg("JWT is empty in session")
|
||||
b.SendNew("<b>❌ Ошибка авторизации</b>\n\nПопробуйте /start", Keyboard())
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
// Подготавливаем данные для обновления
|
||||
input := backend.UpdateCreativeInput{
|
||||
Name: s.Name,
|
||||
Text: s.Text,
|
||||
Tag: s.Tag,
|
||||
}
|
||||
if s.MediaChanged {
|
||||
if len(s.MediaItems) > 0 {
|
||||
mediaItems, err := s.BuildMediaInputs(b)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to download creative media")
|
||||
// Показываем ошибку через панель управления
|
||||
s.ShowControlPanel(b, "<b>❌ Не удалось загрузить медиа</b>\n\nПопробуйте удалить медиа и добавить заново, или нажмите \"Сохранить\" без изменения медиа.", [][]echotron.InlineKeyboardButton{
|
||||
Row(Button("← Назад", "back")),
|
||||
Row(Button("✓ Сохранить без медиа", "confirm_save")),
|
||||
})
|
||||
return
|
||||
}
|
||||
input.MediaItems = &mediaItems
|
||||
} else {
|
||||
emptyItems := []backend.CreativeMediaInput{}
|
||||
input.MediaItems = &emptyItems
|
||||
}
|
||||
}
|
||||
|
||||
buttons := make([]backend.CreativeButton, 0, len(s.Buttons))
|
||||
for _, button := range s.Buttons {
|
||||
buttons = append(buttons, backend.CreativeButton{
|
||||
Text: button.Text,
|
||||
URL: button.URL,
|
||||
})
|
||||
}
|
||||
input.Buttons = &buttons
|
||||
|
||||
// Отправляем запрос на обновление
|
||||
_, err = b.Backend.UpdateCreative(context.Background(), jwt, b.Session.WorkspaceID, s.CreativeID, input)
|
||||
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to update creative")
|
||||
// Показываем ошибку через панель управления
|
||||
s.ShowControlPanel(b, "<b>❌ Не удалось сохранить изменения</b>\n\nПроверьте подключение и попробуйте снова.", [][]echotron.InlineKeyboardButton{
|
||||
Row(Button("← Назад", "back")),
|
||||
Row(Button("✓ Попробовать снова", "confirm_save")),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().Str("creative_id", s.CreativeID).Msg("Creative updated successfully")
|
||||
|
||||
// Возвращаемся назад
|
||||
s.CleanupMessages(b)
|
||||
if s.ViewMode && s.ViewBackTo != nil {
|
||||
b.SetState(s.ViewBackTo, bot.NewMessage)
|
||||
} else if s.BackState != nil {
|
||||
b.SetState(s.BackState, bot.NewMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CreativeDetails) deleteCreative(b *bot.Bot) {
|
||||
err := b.Backend.DeleteCreative(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.CreativeID)
|
||||
|
||||
if err != nil {
|
||||
s.confirmingDelete = false // Сбрасываем флаг подтверждения
|
||||
s.ShowControlPanel(b, "<b>❌ Не удалось удалить креатив</b>\n\nПроверьте подключение и попробуйте снова.", [][]echotron.InlineKeyboardButton{
|
||||
Row(Button("← Назад", "back")),
|
||||
Row(Button("⌦ Попробовать снова", "delete_creative")),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
s.CleanupMessages(b)
|
||||
if s.BackState != nil {
|
||||
b.SetState(s.BackState, bot.NewMessage)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,777 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/backend"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// InlineButton представляет кнопку с URL
|
||||
type InlineButton struct {
|
||||
Text string
|
||||
URL string
|
||||
}
|
||||
|
||||
type MediaItem struct {
|
||||
MediaType string // "photo", "video", "animation"
|
||||
MediaFileID string // File ID из Telegram
|
||||
}
|
||||
|
||||
// CreativeEditorFields содержит общие поля и методы для создания/редактирования креатива
|
||||
type CreativeEditorFields struct {
|
||||
// Данные креатива
|
||||
Name *string
|
||||
Text *string
|
||||
Buttons []InlineButton
|
||||
Tag *string // "testing" or "production"
|
||||
|
||||
// Медиа
|
||||
MediaItems []MediaItem
|
||||
MediaChanged bool
|
||||
|
||||
// Режим ввода
|
||||
InputMode string
|
||||
|
||||
// Состояние для добавления кнопки
|
||||
PendingButtonType string
|
||||
|
||||
// ID сообщения с креативом (которое постоянно обновляется)
|
||||
CreativeMessageID *int
|
||||
// ID сообщения с панелью управления
|
||||
ControlPanelMessageID *int
|
||||
// ID сообщений дополнительного медиа (после первого)
|
||||
ExtraMediaMessageIDs []int
|
||||
|
||||
pendingMediaGroupMu sync.Mutex
|
||||
pendingMediaGroupID string
|
||||
pendingMediaGroupTimer *time.Timer
|
||||
pendingMediaGroupGen int64
|
||||
|
||||
// Предыдущее состояние для определения изменения типа сообщения
|
||||
previousMediaItems []MediaItem
|
||||
}
|
||||
|
||||
const inviteLinkPlaceholder = "{{invite_link}}"
|
||||
const inviteLinkPreviewURL = "https://t.me/joinchat/nlOUmIsLGAlmZTMy" //ссылка приглашения для предпросмотра
|
||||
const buttonURLPlaceholder = "https://example.com"
|
||||
|
||||
var inviteLinkPattern = regexp.MustCompile(`https?://t\.me/(?:\+|joinchat/)[a-zA-Z0-9_-]+`)
|
||||
var inviteLinkTagPattern = regexp.MustCompile(`<a\s+class="tg-link">\s*(.*?)\s*</a>`)
|
||||
|
||||
const (
|
||||
inputModeText = "text"
|
||||
inputModeButtonText = "button_text"
|
||||
inputModeButtonURL = "button_url"
|
||||
inputModeMedia = "media"
|
||||
)
|
||||
const msgCreativeDefault = `
|
||||
<i>💭 Я твой креатив
|
||||
|
||||
У меня пока нет текста, добавь его ниже.
|
||||
|
||||
</i>
|
||||
`
|
||||
const msgCreativeManagement = `
|
||||
<b>👆Превью креатива выше</b> — так он будет выглядеть при отправке.
|
||||
|
||||
Выберите, что изменить:
|
||||
|
||||
`
|
||||
const msgCreativePreviewBroken = "⚠️ Не удалось показать превью: креатив содержит некорректную разметку. Создайте его заново или отредактируйте текст."
|
||||
|
||||
func buildCreativeKeyboard(buttons [][]echotron.InlineKeyboardButton) echotron.InlineKeyboardMarkup {
|
||||
keyboard := echotron.InlineKeyboardMarkup{
|
||||
InlineKeyboard: buttons,
|
||||
}
|
||||
if len(buttons) == 0 {
|
||||
keyboard.InlineKeyboard = [][]echotron.InlineKeyboardButton{}
|
||||
}
|
||||
return keyboard
|
||||
}
|
||||
|
||||
func copyMediaItems(items []MediaItem) []MediaItem {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
clone := make([]MediaItem, len(items))
|
||||
copy(clone, items)
|
||||
return clone
|
||||
}
|
||||
|
||||
func mediaItemsEqual(a, b []MediaItem) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i].MediaType != b[i].MediaType || a[i].MediaFileID != b[i].MediaFileID {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (e *CreativeEditorFields) primaryMedia() *MediaItem {
|
||||
if len(e.MediaItems) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &e.MediaItems[0]
|
||||
}
|
||||
|
||||
func (e *CreativeEditorFields) hasMultipleMedia() bool {
|
||||
return len(e.MediaItems) > 1
|
||||
}
|
||||
|
||||
func (e *CreativeEditorFields) clearExtraMediaMessages(b *bot.Bot) {
|
||||
for _, msgID := range e.ExtraMediaMessageIDs {
|
||||
b.DeleteMessage(b.ChatID, msgID)
|
||||
}
|
||||
e.ExtraMediaMessageIDs = nil
|
||||
}
|
||||
|
||||
func (e *CreativeEditorFields) CleanupMessages(b *bot.Bot) {
|
||||
if e.CreativeMessageID != nil {
|
||||
b.DeleteMessage(b.ChatID, *e.CreativeMessageID)
|
||||
e.CreativeMessageID = nil
|
||||
}
|
||||
e.clearExtraMediaMessages(b)
|
||||
if e.ControlPanelMessageID != nil {
|
||||
b.DeleteMessage(b.ChatID, *e.ControlPanelMessageID)
|
||||
e.ControlPanelMessageID = nil
|
||||
}
|
||||
b.LastMessageID = 0
|
||||
}
|
||||
|
||||
const mediaGroupDebounce = 2 * time.Second
|
||||
|
||||
func (e *CreativeEditorFields) scheduleMediaGroupAction(groupID string, action func()) {
|
||||
e.pendingMediaGroupMu.Lock()
|
||||
defer e.pendingMediaGroupMu.Unlock()
|
||||
|
||||
if groupID == "" {
|
||||
action()
|
||||
return
|
||||
}
|
||||
|
||||
if e.pendingMediaGroupTimer != nil {
|
||||
e.pendingMediaGroupTimer.Stop()
|
||||
}
|
||||
e.pendingMediaGroupID = groupID
|
||||
e.pendingMediaGroupGen++
|
||||
gen := e.pendingMediaGroupGen
|
||||
e.pendingMediaGroupTimer = time.AfterFunc(mediaGroupDebounce, func() {
|
||||
e.pendingMediaGroupMu.Lock()
|
||||
currentGroup := e.pendingMediaGroupID
|
||||
currentGen := e.pendingMediaGroupGen
|
||||
if currentGen == gen && currentGroup == groupID {
|
||||
e.pendingMediaGroupID = ""
|
||||
e.pendingMediaGroupTimer = nil
|
||||
}
|
||||
e.pendingMediaGroupMu.Unlock()
|
||||
if currentGen == gen && currentGroup == groupID {
|
||||
action()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func isTelegramParseError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "can't parse entities") || strings.Contains(msg, "Unexpected end tag")
|
||||
}
|
||||
|
||||
// GetCreativeText возвращает текст для превью креатива
|
||||
func (e *CreativeEditorFields) GetCreativeText() string {
|
||||
if e.Text == nil {
|
||||
return msgCreativeDefault
|
||||
}
|
||||
|
||||
text := strings.TrimSpace(ui.SanitizeHTML(*e.Text))
|
||||
if text == "" {
|
||||
return msgCreativeDefault
|
||||
}
|
||||
|
||||
// Заменяем плейсхолдеры tg-link на кликабельную preview-ссылку.
|
||||
text = inviteLinkTagPattern.ReplaceAllStringFunc(text, func(match string) string {
|
||||
sub := inviteLinkTagPattern.FindStringSubmatch(match)
|
||||
inner := ""
|
||||
if len(sub) > 1 {
|
||||
inner = strings.TrimSpace(sub[1])
|
||||
}
|
||||
if inner == "" {
|
||||
inner = inviteLinkPreviewURL
|
||||
}
|
||||
return fmt.Sprintf(`<a href="%s">%s</a>`, inviteLinkPreviewURL, inner)
|
||||
})
|
||||
|
||||
// Заменяем пригласительные ссылки на preview URL только для отображения.
|
||||
text = inviteLinkPattern.ReplaceAllString(text, inviteLinkPreviewURL)
|
||||
|
||||
// Также заменяем плейсхолдер {{invite_link}} на preview URL (как в кнопках).
|
||||
text = strings.ReplaceAll(text, inviteLinkPlaceholder, inviteLinkPreviewURL)
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
// GetCreativeButtons возвращает кнопки для превью креатива
|
||||
func (e *CreativeEditorFields) GetCreativeButtons() [][]echotron.InlineKeyboardButton {
|
||||
var buttons [][]echotron.InlineKeyboardButton
|
||||
|
||||
for _, btn := range e.Buttons {
|
||||
url := btn.URL
|
||||
if url == inviteLinkPlaceholder {
|
||||
url = inviteLinkPreviewURL
|
||||
}
|
||||
buttons = append(buttons, []echotron.InlineKeyboardButton{
|
||||
{
|
||||
Text: btn.Text,
|
||||
URL: url,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return buttons
|
||||
}
|
||||
|
||||
// SendCreativePreview отправляет превью креатива
|
||||
func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) {
|
||||
creativeText := e.GetCreativeText()
|
||||
creativeButtons := e.GetCreativeButtons()
|
||||
|
||||
// Создаем клавиатуру с пустым массивом (не nil!)
|
||||
keyboard := buildCreativeKeyboard(creativeButtons)
|
||||
|
||||
var msgID int
|
||||
sendText := func() (int, error) {
|
||||
res, err := b.SendMessage(creativeText, b.ChatID, &echotron.MessageOptions{
|
||||
ReplyMarkup: keyboard,
|
||||
ParseMode: echotron.HTML,
|
||||
LinkPreviewOptions: echotron.LinkPreviewOptions{
|
||||
IsDisabled: true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if res.Result == nil {
|
||||
return 0, fmt.Errorf("send message: empty result")
|
||||
}
|
||||
return res.Result.ID, nil
|
||||
}
|
||||
sendFallback := func() (int, error) {
|
||||
res, err := b.SendMessage(msgCreativePreviewBroken, b.ChatID, &echotron.MessageOptions{
|
||||
ReplyMarkup: keyboard,
|
||||
LinkPreviewOptions: echotron.LinkPreviewOptions{
|
||||
IsDisabled: true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if res.Result == nil {
|
||||
return 0, fmt.Errorf("send message: empty result")
|
||||
}
|
||||
return res.Result.ID, nil
|
||||
}
|
||||
|
||||
// Если есть медиа, отправляем с медиа
|
||||
if len(e.MediaItems) > 1 {
|
||||
e.clearExtraMediaMessages(b)
|
||||
group := make([]echotron.GroupableInputMedia, 0, len(e.MediaItems))
|
||||
for i, item := range e.MediaItems {
|
||||
caption := ""
|
||||
parseMode := echotron.ParseMode("")
|
||||
if i == 0 {
|
||||
caption = creativeText
|
||||
parseMode = echotron.HTML
|
||||
}
|
||||
switch item.MediaType {
|
||||
case "photo":
|
||||
group = append(group, echotron.InputMediaPhoto{
|
||||
Type: echotron.MediaTypePhoto,
|
||||
Media: echotron.NewInputFileID(item.MediaFileID),
|
||||
Caption: caption,
|
||||
ParseMode: parseMode,
|
||||
})
|
||||
case "video":
|
||||
group = append(group, echotron.InputMediaVideo{
|
||||
Type: echotron.MediaTypeVideo,
|
||||
Media: echotron.NewInputFileID(item.MediaFileID),
|
||||
Caption: caption,
|
||||
ParseMode: parseMode,
|
||||
})
|
||||
case "animation":
|
||||
group = append(group, echotron.InputMediaVideo{
|
||||
Type: echotron.MediaTypeVideo,
|
||||
Media: echotron.NewInputFileID(item.MediaFileID),
|
||||
Caption: caption,
|
||||
ParseMode: parseMode,
|
||||
})
|
||||
default:
|
||||
log.Warn().Str("media_type", item.MediaType).Msg("Unsupported media type for media group")
|
||||
}
|
||||
}
|
||||
|
||||
if len(group) > 0 {
|
||||
res, err := b.SendMediaGroup(b.ChatID, group, nil)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to send media group")
|
||||
if id, err := sendText(); err == nil {
|
||||
msgID = id
|
||||
} else if isTelegramParseError(err) {
|
||||
if id, err := sendFallback(); err == nil {
|
||||
msgID = id
|
||||
}
|
||||
}
|
||||
} else if len(res.Result) > 0 {
|
||||
msgID = res.Result[0].ID
|
||||
for i := 1; i < len(res.Result); i++ {
|
||||
e.ExtraMediaMessageIDs = append(e.ExtraMediaMessageIDs, res.Result[i].ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if media := e.primaryMedia(); media != nil {
|
||||
var res echotron.APIResponseMessage
|
||||
var err error
|
||||
|
||||
switch media.MediaType {
|
||||
case "photo":
|
||||
res, err = b.SendPhoto(
|
||||
echotron.NewInputFileID(media.MediaFileID),
|
||||
b.ChatID,
|
||||
&echotron.PhotoOptions{
|
||||
Caption: creativeText,
|
||||
ParseMode: echotron.HTML,
|
||||
ReplyMarkup: keyboard,
|
||||
},
|
||||
)
|
||||
case "video":
|
||||
res, err = b.SendVideo(
|
||||
echotron.NewInputFileID(media.MediaFileID),
|
||||
b.ChatID,
|
||||
&echotron.VideoOptions{
|
||||
Caption: creativeText,
|
||||
ParseMode: echotron.HTML,
|
||||
ReplyMarkup: keyboard,
|
||||
},
|
||||
)
|
||||
case "animation":
|
||||
res, err = b.SendAnimation(
|
||||
echotron.NewInputFileID(media.MediaFileID),
|
||||
b.ChatID,
|
||||
&echotron.AnimationOptions{
|
||||
Caption: creativeText,
|
||||
ParseMode: echotron.HTML,
|
||||
ReplyMarkup: keyboard,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("media_type", media.MediaType).Msg("Failed to send media")
|
||||
// Fallback to text message - используем прямой SendMessage
|
||||
if id, err := sendText(); err == nil {
|
||||
msgID = id
|
||||
} else if isTelegramParseError(err) {
|
||||
if id, err := sendFallback(); err == nil {
|
||||
msgID = id
|
||||
}
|
||||
}
|
||||
} else if res.Result != nil {
|
||||
msgID = res.Result.ID
|
||||
}
|
||||
|
||||
} else {
|
||||
// Отправляем обычное текстовое сообщение напрямую (не через SendNew)
|
||||
// чтобы не затронуть LastMessageID и не затронуть cleanup
|
||||
id, err := sendText()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to send creative preview")
|
||||
if isTelegramParseError(err) {
|
||||
id, err = sendFallback()
|
||||
if err == nil {
|
||||
msgID = id
|
||||
} else {
|
||||
log.Error().Err(err).Msg("Failed to send fallback creative preview")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
msgID = id
|
||||
}
|
||||
}
|
||||
|
||||
// ВАЖНО: Сохраняем копию значения, а не указатель на переменную!
|
||||
e.CreativeMessageID = &msgID
|
||||
e.previousMediaItems = copyMediaItems(e.MediaItems)
|
||||
|
||||
// Устанавливаем LastMessageID чтобы ShowControlPanel мог корректно работать
|
||||
// ВАЖНО: Это позволяет избежать конфликтов при навигации между экранами
|
||||
b.LastMessageID = msgID
|
||||
}
|
||||
|
||||
// UpdateCreativePreview обновляет превью креатива
|
||||
func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) {
|
||||
if e.CreativeMessageID == nil {
|
||||
log.Warn().Msg("UpdateCreativePreview: CreativeMessageID is nil")
|
||||
// Если превью еще не создано, создаем его
|
||||
e.SendCreativePreview(b)
|
||||
return
|
||||
}
|
||||
|
||||
currentHasMedia := len(e.MediaItems) > 0
|
||||
previousHasMedia := len(e.previousMediaItems) > 0
|
||||
mediaItemsChanged := !mediaItemsEqual(e.MediaItems, e.previousMediaItems)
|
||||
|
||||
// Если тип сообщения изменился (текст ↔ медиа), нужно пересоздать оба сообщения
|
||||
if currentHasMedia != previousHasMedia {
|
||||
// Удаляем оба сообщения
|
||||
if e.CreativeMessageID != nil {
|
||||
b.DeleteMessage(b.ChatID, *e.CreativeMessageID)
|
||||
e.CreativeMessageID = nil
|
||||
}
|
||||
e.clearExtraMediaMessages(b)
|
||||
if e.ControlPanelMessageID != nil {
|
||||
b.DeleteMessage(b.ChatID, *e.ControlPanelMessageID)
|
||||
e.ControlPanelMessageID = nil
|
||||
}
|
||||
|
||||
// Создаем превью заново (панель будет создана через ShowManagementPanel после этого)
|
||||
e.SendCreativePreview(b)
|
||||
return
|
||||
}
|
||||
|
||||
if mediaItemsChanged && (len(e.MediaItems) > 1 || len(e.previousMediaItems) > 1) {
|
||||
if e.CreativeMessageID != nil {
|
||||
b.DeleteMessage(b.ChatID, *e.CreativeMessageID)
|
||||
e.CreativeMessageID = nil
|
||||
}
|
||||
e.clearExtraMediaMessages(b)
|
||||
e.SendCreativePreview(b)
|
||||
return
|
||||
}
|
||||
|
||||
// Тип не изменился - просто редактируем на месте
|
||||
creativeText := e.GetCreativeText()
|
||||
creativeButtons := e.GetCreativeButtons()
|
||||
|
||||
keyboard := buildCreativeKeyboard(creativeButtons)
|
||||
|
||||
msgID := echotron.NewMessageID(b.ChatID, *e.CreativeMessageID)
|
||||
logEditError := func(msg string, err error) {
|
||||
textPreview := creativeText
|
||||
runes := []rune(textPreview)
|
||||
if len(runes) > 200 {
|
||||
textPreview = string(runes[:200]) + "..."
|
||||
}
|
||||
mediaType := ""
|
||||
if media := e.primaryMedia(); media != nil {
|
||||
mediaType = media.MediaType
|
||||
}
|
||||
log.Error().
|
||||
Err(err).
|
||||
Int("creative_message_id", *e.CreativeMessageID).
|
||||
Str("media_type", mediaType).
|
||||
Int("text_len", len([]rune(creativeText))).
|
||||
Str("text_preview", textPreview).
|
||||
Msg(msg)
|
||||
}
|
||||
|
||||
// Если есть медиа - редактируем caption
|
||||
updated := false
|
||||
if media := e.primaryMedia(); media != nil {
|
||||
var prevMedia MediaItem
|
||||
if len(e.previousMediaItems) > 0 {
|
||||
prevMedia = e.previousMediaItems[0]
|
||||
}
|
||||
mediaChanged := media.MediaFileID != prevMedia.MediaFileID || media.MediaType != prevMedia.MediaType
|
||||
if mediaChanged {
|
||||
var inputMedia echotron.InputMedia
|
||||
switch media.MediaType {
|
||||
case "photo":
|
||||
inputMedia = echotron.InputMediaPhoto{
|
||||
Type: echotron.MediaTypePhoto,
|
||||
Media: echotron.NewInputFileID(media.MediaFileID),
|
||||
Caption: creativeText,
|
||||
ParseMode: echotron.HTML,
|
||||
}
|
||||
case "video":
|
||||
inputMedia = echotron.InputMediaVideo{
|
||||
Type: echotron.MediaTypeVideo,
|
||||
Media: echotron.NewInputFileID(media.MediaFileID),
|
||||
Caption: creativeText,
|
||||
ParseMode: echotron.HTML,
|
||||
}
|
||||
case "animation":
|
||||
inputMedia = echotron.InputMediaAnimation{
|
||||
Type: echotron.MediaTypeAnimation,
|
||||
Media: echotron.NewInputFileID(media.MediaFileID),
|
||||
Caption: creativeText,
|
||||
ParseMode: echotron.HTML,
|
||||
}
|
||||
}
|
||||
|
||||
if inputMedia != nil {
|
||||
_, err := b.EditMessageMedia(
|
||||
msgID,
|
||||
inputMedia,
|
||||
&echotron.MessageMediaOptions{
|
||||
ReplyMarkup: keyboard,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
logEditError("Failed to edit message media", err)
|
||||
} else {
|
||||
updated = true
|
||||
}
|
||||
} else {
|
||||
log.Warn().Str("media_type", media.MediaType).Msg("Unsupported media type for edit")
|
||||
}
|
||||
} else {
|
||||
_, err := b.EditMessageCaption(
|
||||
msgID,
|
||||
&echotron.MessageCaptionOptions{
|
||||
Caption: creativeText,
|
||||
ParseMode: echotron.HTML,
|
||||
ReplyMarkup: keyboard,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
logEditError("Failed to edit media caption", err)
|
||||
} else {
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Текстовое сообщение - редактируем текст
|
||||
_, err := b.EditMessageText(
|
||||
creativeText,
|
||||
msgID,
|
||||
&echotron.MessageTextOptions{
|
||||
ParseMode: echotron.HTML,
|
||||
ReplyMarkup: keyboard,
|
||||
LinkPreviewOptions: echotron.LinkPreviewOptions{
|
||||
IsDisabled: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
logEditError("Failed to edit message text", err)
|
||||
} else {
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
|
||||
if updated {
|
||||
if currentHasMedia {
|
||||
e.previousMediaItems = copyMediaItems(e.MediaItems)
|
||||
} else {
|
||||
e.previousMediaItems = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *CreativeEditorFields) DeleteUserMessage(b *bot.Bot, messageID int) {
|
||||
_, err := b.DeleteMessage(b.ChatID, messageID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to delete user message")
|
||||
}
|
||||
}
|
||||
|
||||
func (e *CreativeEditorFields) SetMediaFromMessage(message *echotron.Message) bool {
|
||||
var item MediaItem
|
||||
switch {
|
||||
case message.Photo != nil && len(message.Photo) > 0:
|
||||
photo := message.Photo[len(message.Photo)-1]
|
||||
item.MediaType = "photo"
|
||||
item.MediaFileID = photo.FileID
|
||||
case message.Video != nil:
|
||||
item.MediaType = "video"
|
||||
item.MediaFileID = message.Video.FileID
|
||||
case message.Animation != nil:
|
||||
item.MediaType = "animation"
|
||||
item.MediaFileID = message.Animation.FileID
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
e.MediaItems = append(e.MediaItems, item)
|
||||
e.MediaChanged = true
|
||||
return true
|
||||
}
|
||||
|
||||
func (e *CreativeEditorFields) AddInviteButton(text string) {
|
||||
e.Buttons = append(e.Buttons, InlineButton{
|
||||
Text: text,
|
||||
URL: inviteLinkPlaceholder,
|
||||
})
|
||||
}
|
||||
|
||||
func (e *CreativeEditorFields) AddCustomButtonPlaceholder(text string) {
|
||||
e.Buttons = append(e.Buttons, InlineButton{
|
||||
Text: text,
|
||||
URL: buttonURLPlaceholder,
|
||||
})
|
||||
}
|
||||
|
||||
func (e *CreativeEditorFields) UpdateLastButtonURL(url string) {
|
||||
if len(e.Buttons) > 0 {
|
||||
e.Buttons[len(e.Buttons)-1].URL = url
|
||||
}
|
||||
}
|
||||
|
||||
func (e *CreativeEditorFields) ClearInputMode() {
|
||||
e.InputMode = ""
|
||||
e.PendingButtonType = ""
|
||||
}
|
||||
|
||||
func (e *CreativeEditorFields) CancelPendingButton(b *bot.Bot) {
|
||||
if e.InputMode == inputModeButtonURL && len(e.Buttons) > 0 {
|
||||
lastButton := e.Buttons[len(e.Buttons)-1]
|
||||
if lastButton.URL == buttonURLPlaceholder || lastButton.URL == inviteLinkPlaceholder {
|
||||
e.Buttons = e.Buttons[:len(e.Buttons)-1]
|
||||
e.UpdateCreativePreview(b)
|
||||
}
|
||||
}
|
||||
|
||||
e.ClearInputMode()
|
||||
}
|
||||
|
||||
func (e *CreativeEditorFields) IsValidButtonURL(url string, allowHTTP bool) bool {
|
||||
if allowHTTP {
|
||||
return strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://")
|
||||
}
|
||||
return strings.HasPrefix(url, "https://")
|
||||
}
|
||||
|
||||
// ShowManagementPanel показывает панель управления креативом
|
||||
// confirmButtonText - текст кнопки подтверждения (например "✓ Сохранить")
|
||||
// confirmCallback - callback для кнопки подтверждения
|
||||
// cancelCallback - callback для кнопки отмены
|
||||
// extraButtons - дополнительные кнопки которые будут добавлены перед финальными кнопками
|
||||
func (e *CreativeEditorFields) ShowManagementPanel(b *bot.Bot, confirmButtonText, confirmCallback, cancelCallback string, extraButtons ...[]echotron.InlineKeyboardButton) {
|
||||
text := msgCreativeManagement
|
||||
|
||||
buttons := e.BuildEditorButtons(confirmButtonText, confirmCallback, "Назад", cancelCallback, extraButtons...)
|
||||
e.ShowControlPanel(b, text, buttons)
|
||||
}
|
||||
|
||||
func (e *CreativeEditorFields) BuildEditorButtons(confirmText, confirmCallback, cancelText, cancelCallback string, extraButtons ...[]echotron.InlineKeyboardButton) [][]echotron.InlineKeyboardButton {
|
||||
var buttons [][]echotron.InlineKeyboardButton
|
||||
|
||||
// Блок редактирования контента
|
||||
buttons = append(buttons, Row(
|
||||
Button("✎ Текст", "edit_text"),
|
||||
Button("+ Медиа", "add_media"),
|
||||
))
|
||||
addButtonLabel := "+ Добавить кнопку"
|
||||
addButtonCallback := "add_button"
|
||||
if e.hasMultipleMedia() {
|
||||
addButtonLabel = "Кнопки недоступны для >1 медиа"
|
||||
addButtonCallback = "empty"
|
||||
}
|
||||
buttons = append(buttons, Row(Button(addButtonLabel, addButtonCallback)))
|
||||
|
||||
// Кнопка удаления медиа (если медиа добавлено)
|
||||
if len(e.MediaItems) > 0 {
|
||||
label := "⌫ Убрать медиа"
|
||||
if len(e.MediaItems) > 1 {
|
||||
label = fmt.Sprintf("⌫ Убрать медиа (%d)", len(e.MediaItems))
|
||||
}
|
||||
buttons = append(buttons, Row(Button(label, "delete_media")))
|
||||
}
|
||||
|
||||
// Кнопки удаления (если есть кнопки)
|
||||
if len(e.Buttons) > 0 {
|
||||
var row []echotron.InlineKeyboardButton
|
||||
for i, btn := range e.Buttons {
|
||||
row = append(row, Button(
|
||||
fmt.Sprintf(`⌫ Убрать «%s»`, btn.Text),
|
||||
fmt.Sprintf("delete_button:%d", i),
|
||||
))
|
||||
|
||||
if len(row) == 2 {
|
||||
buttons = append(buttons, row)
|
||||
row = nil
|
||||
}
|
||||
}
|
||||
|
||||
if len(row) > 0 {
|
||||
buttons = append(buttons, row)
|
||||
}
|
||||
}
|
||||
|
||||
// Дополнительные кнопки (например "Удалить креатив")
|
||||
for _, extraRow := range extraButtons {
|
||||
buttons = append(buttons, extraRow)
|
||||
}
|
||||
|
||||
// Финальные кнопки
|
||||
cancelLabel := "← " + cancelText
|
||||
if e.Text != nil {
|
||||
buttons = append(buttons, Row(
|
||||
Button(cancelLabel, cancelCallback),
|
||||
Button(confirmText, confirmCallback),
|
||||
))
|
||||
} else {
|
||||
buttons = append(buttons, Row(
|
||||
Button(cancelLabel, cancelCallback),
|
||||
))
|
||||
}
|
||||
|
||||
return buttons
|
||||
}
|
||||
|
||||
// ShowControlPanel редактирует панель управления с произвольным текстом и кнопками
|
||||
func (e *CreativeEditorFields) ShowControlPanel(b *bot.Bot, text string, buttons [][]echotron.InlineKeyboardButton) {
|
||||
if e.ControlPanelMessageID == nil {
|
||||
// Отправляем новую панель
|
||||
keyboard := Keyboard(buttons...)
|
||||
b.SendNew(text, keyboard)
|
||||
// ВАЖНО: Сохраняем копию значения, а не указатель на переменную!
|
||||
controlPanelMsgID := b.LastMessageID
|
||||
e.ControlPanelMessageID = &controlPanelMsgID
|
||||
} else {
|
||||
// Редактируем существующую панель
|
||||
// Временно заменяем LastMessageID на ID панели управления
|
||||
oldLastMessageID := b.LastMessageID
|
||||
b.LastMessageID = *e.ControlPanelMessageID
|
||||
defer func() { b.LastMessageID = oldLastMessageID }()
|
||||
|
||||
keyboard := Keyboard(buttons...)
|
||||
b.Edit(text, keyboard)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *CreativeEditorFields) BuildMediaInputs(b *bot.Bot) ([]backend.CreativeMediaInput, error) {
|
||||
if len(e.MediaItems) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
mediaItems := make([]backend.CreativeMediaInput, 0, len(e.MediaItems))
|
||||
for _, item := range e.MediaItems {
|
||||
if item.MediaFileID == "" {
|
||||
continue
|
||||
}
|
||||
mediaData, err := b.DownloadFileBytes(item.MediaFileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mediaItems = append(mediaItems, backend.CreativeMediaInput{
|
||||
MediaType: item.MediaType,
|
||||
MediaFileID: item.MediaFileID,
|
||||
MediaData: mediaData,
|
||||
})
|
||||
}
|
||||
return mediaItems, nil
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
|
||||
)
|
||||
|
||||
const msgEditCreativeText = `
|
||||
<b>✎ Введите текст креатива:</b>
|
||||
|
||||
⚠ <b>Важно:</b> Текст должен содержать <b>ОДНУ</b> инвайт-ссылку вашего канала
|
||||
|
||||
<b>Формат ссылки:</b>
|
||||
<code>https://t.me/+xxx</code>
|
||||
|
||||
<b>Пример:</b>
|
||||
<i>Присоединяйтесь к нашему каналу!
|
||||
https://t.me/+AbCdEfGhIjKlMn</i>
|
||||
|
||||
`
|
||||
const msgAddButtonPrompt = `
|
||||
<b>+ Добавить кнопку</b>
|
||||
|
||||
Введите <b>текст кнопки:</b>
|
||||
|
||||
<i>Например:</i>
|
||||
• Перейти на сайт
|
||||
• Написать нам
|
||||
|
||||
<i>Ссылка на канал подставится автоматически при создании закупа.</i>
|
||||
|
||||
`
|
||||
const msgAddMediaPrompt = `
|
||||
<b>+ Добавить медиа</b>
|
||||
|
||||
Отправьте <b>фото, видео или GIF</b>
|
||||
|
||||
<i>Можно добавить несколько медиа — отправляйте по одному.</i>
|
||||
<i>Нажмите «Отмена», когда закончите.</i>
|
||||
|
||||
`
|
||||
const msgButtonURLPrompt = `
|
||||
<b>✧ Введите URL для кнопки:</b>
|
||||
|
||||
URL должен начинаться с <code>http://</code> или <code>https://</code>
|
||||
|
||||
<i>Например:</i>
|
||||
<code>https://example.com</code>
|
||||
<code>https://t.me/yourchannel</code>
|
||||
|
||||
`
|
||||
const msgInvalidURL = `
|
||||
<b>❌ Неверный формат URL</b>
|
||||
|
||||
URL должен начинаться с <code>http://</code> или <code>https://</code>
|
||||
|
||||
Попробуйте ещё раз.
|
||||
|
||||
`
|
||||
|
||||
const msgEditTag = `
|
||||
<b>🏷 Выберите тег креатива:</b>
|
||||
|
||||
🧪 <b>Тестовый</b> — для A/B тестов и экспериментов
|
||||
🚀 <b>Рабочий</b> — проверенный эффективный креатив
|
||||
|
||||
`
|
||||
|
||||
func (e *CreativeEditorFields) ShowTagPanel(b *bot.Bot) {
|
||||
testingLabel := "🧪 Тестовый"
|
||||
productionLabel := "🚀 Рабочий"
|
||||
|
||||
if e.Tag != nil {
|
||||
if *e.Tag == "testing" {
|
||||
testingLabel = "✓ 🧪 Тестовый"
|
||||
} else if *e.Tag == "production" {
|
||||
productionLabel = "✓ 🚀 Рабочий"
|
||||
}
|
||||
}
|
||||
|
||||
e.ShowControlPanel(b, msgEditTag, [][]echotron.InlineKeyboardButton{
|
||||
Row(
|
||||
Button(testingLabel, "tag_testing"),
|
||||
Button(productionLabel, "tag_production"),
|
||||
),
|
||||
Row(Button("« Назад", "cancel_edit")),
|
||||
})
|
||||
}
|
||||
|
||||
func (e *CreativeEditorFields) GetTagLabel() string {
|
||||
if e.Tag != nil && *e.Tag == "production" {
|
||||
return "◉ Рабочий"
|
||||
}
|
||||
return "◉ Тестовый"
|
||||
}
|
||||
|
||||
func (e *CreativeEditorFields) ShowAddButtonPanel(b *bot.Bot) {
|
||||
inviteLabel := "Ссылка на канал"
|
||||
customLabel := "Своя ссылка"
|
||||
if e.PendingButtonType == "invite" {
|
||||
inviteLabel = "✓ Ссылка на канал"
|
||||
}
|
||||
if e.PendingButtonType == "custom" {
|
||||
customLabel = "✓ Своя ссылка"
|
||||
}
|
||||
|
||||
e.ShowControlPanel(b, msgAddButtonPrompt, [][]echotron.InlineKeyboardButton{
|
||||
Row(
|
||||
Button(inviteLabel, "button_type_invite"),
|
||||
Button(customLabel, "button_type_custom"),
|
||||
),
|
||||
Row(Button("« Отмена", "cancel_add_button")),
|
||||
})
|
||||
}
|
||||
|
||||
func (e *CreativeEditorFields) ShowTextEditPanel(b *bot.Bot) {
|
||||
e.ShowControlPanel(b, msgEditCreativeText, [][]echotron.InlineKeyboardButton{
|
||||
Row(Button("« Отмена", "cancel_edit")),
|
||||
})
|
||||
}
|
||||
|
||||
func (e *CreativeEditorFields) ShowMediaPanel(b *bot.Bot) {
|
||||
e.ShowControlPanel(b, msgAddMediaPrompt, [][]echotron.InlineKeyboardButton{
|
||||
Row(Button("« Отмена", "cancel_media")),
|
||||
})
|
||||
}
|
||||
|
||||
func (e *CreativeEditorFields) ShowButtonURLPanel(b *bot.Bot) {
|
||||
e.ShowControlPanel(b, msgButtonURLPrompt, [][]echotron.InlineKeyboardButton{
|
||||
Row(Button("« Отмена", "cancel_add_button")),
|
||||
})
|
||||
}
|
||||
|
||||
func (e *CreativeEditorFields) ShowInvalidButtonURLPanel(b *bot.Bot) {
|
||||
e.ShowControlPanel(b, msgInvalidURL, [][]echotron.InlineKeyboardButton{
|
||||
Row(Button("« Отмена", "cancel_add_button")),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/backend"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// CreativeView - экран просмотра креатива (read-only mode)
|
||||
// Показывает превью креатива и кнопки действий: Редактировать, Тег, Закупить
|
||||
type CreativeView struct {
|
||||
CreativeEditorFields // Встраивание общих полей для отображения превью
|
||||
|
||||
CreativeID string
|
||||
ProjectID string
|
||||
|
||||
// Данные проекта для передачи в AddPurchase
|
||||
ProjectTitle string
|
||||
ProjectTelegramID int64
|
||||
ProjectUsername string
|
||||
ProjectStatus string
|
||||
|
||||
BackState bot.State
|
||||
|
||||
renaming bool // режим ввода нового названия
|
||||
}
|
||||
|
||||
func msgCreativeView(name string) string {
|
||||
return fmt.Sprintf("<b>📋 %s</b>\n\nВыберите действие:", name)
|
||||
}
|
||||
|
||||
func (s *CreativeView) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
// Удаляем сообщение предыдущего экрана (его клавиатуру не очищает SendCreativePreview)
|
||||
if b.LastMessageID != 0 {
|
||||
b.DeleteMessage(b.ChatID, b.LastMessageID)
|
||||
b.LastMessageID = 0
|
||||
}
|
||||
|
||||
// Сбрасываем старые ID — при re-enter всегда создаём свежие сообщения
|
||||
s.CreativeMessageID = nil
|
||||
s.ControlPanelMessageID = nil
|
||||
|
||||
// Загружаем креатив
|
||||
creative, err := b.Backend.GetCreative(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.CreativeID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to get creative")
|
||||
b.SendNew("<b>❌ Не удалось загрузить креатив</b>", Keyboard(
|
||||
Row(Button("← Назад", "back")),
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// Загружаем проект для получения данных
|
||||
project, err := b.Backend.GetProject(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.ProjectID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("project_id", s.ProjectID).Msg("Failed to get project")
|
||||
} else {
|
||||
s.ProjectTitle = project.Title
|
||||
s.ProjectTelegramID = project.TelegramID
|
||||
s.ProjectStatus = project.Status
|
||||
if project.Username != nil {
|
||||
s.ProjectUsername = *project.Username
|
||||
}
|
||||
}
|
||||
|
||||
// Заполняем поля креатива для отображения превью
|
||||
s.Name = &creative.Name
|
||||
if creative.Text != "" {
|
||||
s.Text = &creative.Text
|
||||
}
|
||||
s.MediaItems = nil
|
||||
if len(creative.MediaItems) > 0 {
|
||||
for _, item := range creative.MediaItems {
|
||||
s.MediaItems = append(s.MediaItems, MediaItem{
|
||||
MediaType: item.MediaType,
|
||||
MediaFileID: item.MediaFileID,
|
||||
})
|
||||
}
|
||||
}
|
||||
s.Buttons = nil
|
||||
for _, btn := range creative.Buttons {
|
||||
s.Buttons = append(s.Buttons, InlineButton{Text: btn.Text, URL: btn.URL})
|
||||
}
|
||||
if creative.Tag != "" {
|
||||
s.Tag = &creative.Tag
|
||||
}
|
||||
|
||||
// Отправляем превью креатива
|
||||
s.SendCreativePreview(b)
|
||||
|
||||
// Отправляем панель управления с кнопками действий
|
||||
s.showActionPanel(b)
|
||||
}
|
||||
|
||||
func (s *CreativeView) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
|
||||
return
|
||||
}
|
||||
|
||||
data := u.CallbackQuery.Data
|
||||
|
||||
switch {
|
||||
case data == "back":
|
||||
s.CleanupMessages(b)
|
||||
if s.BackState != nil {
|
||||
b.SetState(s.BackState, bot.NewMessage)
|
||||
}
|
||||
|
||||
case data == "rename":
|
||||
s.renaming = true
|
||||
s.ShowControlPanel(b, "<b>✏️ Введите новое название креатива:</b>", [][]echotron.InlineKeyboardButton{
|
||||
Row(Button("← Отмена", "cancel_rename")),
|
||||
})
|
||||
|
||||
case data == "cancel_rename":
|
||||
s.renaming = false
|
||||
s.showActionPanel(b)
|
||||
|
||||
case data == "edit":
|
||||
s.CleanupMessages(b)
|
||||
b.SetState(&CreativeDetails{
|
||||
CreativeID: s.CreativeID,
|
||||
ProjectID: s.ProjectID,
|
||||
ViewMode: true,
|
||||
ViewBackTo: s,
|
||||
ProjectTitle: s.ProjectTitle,
|
||||
BackState: s.BackState,
|
||||
}, bot.NewMessage)
|
||||
|
||||
case data == "toggle_tag":
|
||||
// Переключение тега (testing <-> production)
|
||||
s.toggleTag(b)
|
||||
|
||||
case data == "purchase_creative":
|
||||
s.CleanupMessages(b)
|
||||
creativeTitle := ""
|
||||
if s.Name != nil {
|
||||
creativeTitle = *s.Name
|
||||
}
|
||||
|
||||
b.SetState(&AddPurchase{
|
||||
ProjectID: s.ProjectID,
|
||||
ProjectTitle: s.ProjectTitle,
|
||||
ProjectTelegramID: s.ProjectTelegramID,
|
||||
ProjectUsername: s.ProjectUsername,
|
||||
ProjectStatus: s.ProjectStatus,
|
||||
CreativeID: s.CreativeID,
|
||||
CreativeTitle: creativeTitle,
|
||||
BackState: s,
|
||||
}, bot.NewMessage)
|
||||
|
||||
default:
|
||||
log.Warn().Str("callback", data).Msg("Unknown callback in CreativeView")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CreativeView) HandleMessage(b *bot.Bot, u *echotron.Update) {
|
||||
if !s.renaming || u.Message == nil || u.Message.Text == "" {
|
||||
return
|
||||
}
|
||||
|
||||
newName := strings.TrimSpace(u.Message.Text)
|
||||
s.DeleteUserMessage(b, u.Message.ID)
|
||||
|
||||
input := backend.UpdateCreativeInput{
|
||||
Name: &newName,
|
||||
}
|
||||
_, err := b.Backend.UpdateCreative(
|
||||
context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.CreativeID, input,
|
||||
)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to rename creative")
|
||||
s.ShowControlPanel(b, "<b>❌ Не удалось переименовать</b>", [][]echotron.InlineKeyboardButton{
|
||||
Row(Button("← Назад", "cancel_rename")),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
s.Name = &newName
|
||||
s.renaming = false
|
||||
s.showActionPanel(b)
|
||||
}
|
||||
|
||||
func (s *CreativeView) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *CreativeView) Exit() {}
|
||||
|
||||
// showActionPanel показывает панель действий с кнопками
|
||||
func (s *CreativeView) showActionPanel(b *bot.Bot) {
|
||||
tagLabel := s.GetTagLabel()
|
||||
|
||||
buttons := [][]echotron.InlineKeyboardButton{
|
||||
Row(
|
||||
Button("✎ Переименовать", "rename"),
|
||||
Button("✎ Редактировать", "edit"),
|
||||
),
|
||||
Row(Button("+ Закупить", "purchase_creative")),
|
||||
Row(
|
||||
Button("← Назад", "back"),
|
||||
Button(tagLabel, "toggle_tag"),
|
||||
),
|
||||
}
|
||||
|
||||
name := "Креатив"
|
||||
if s.Name != nil && *s.Name != "" {
|
||||
name = *s.Name
|
||||
}
|
||||
s.ShowControlPanel(b, msgCreativeView(name), buttons)
|
||||
}
|
||||
|
||||
// toggleTag переключает тег креатива (testing <-> production)
|
||||
func (s *CreativeView) toggleTag(b *bot.Bot) {
|
||||
newTag := "testing"
|
||||
if s.Tag != nil && *s.Tag == "testing" {
|
||||
newTag = "production"
|
||||
}
|
||||
|
||||
// Обновляем тег через backend
|
||||
input := backend.UpdateCreativeInput{
|
||||
Tag: &newTag,
|
||||
}
|
||||
|
||||
_, err := b.Backend.UpdateCreative(
|
||||
context.Background(),
|
||||
b.Session.JWT,
|
||||
b.Session.WorkspaceID,
|
||||
s.CreativeID,
|
||||
input,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to update creative tag")
|
||||
s.ShowControlPanel(b, "<b>❌ Не удалось изменить тег</b>\n\nПопробуйте еще раз.", [][]echotron.InlineKeyboardButton{
|
||||
Row(Button("← Назад", "back")),
|
||||
Row(Button("↻ Попробовать снова", "toggle_tag")),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Обновляем локальное значение
|
||||
s.Tag = &newTag
|
||||
|
||||
// Обновляем панель (показывает новый тег)
|
||||
s.showActionPanel(b)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/backend"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type Creatives struct {
|
||||
CurrentPage int
|
||||
ProjectID string
|
||||
ProjectTitle string
|
||||
ProjectTelegramID int64
|
||||
ProjectUsername string
|
||||
ProjectStatus string
|
||||
BackState bot.State
|
||||
}
|
||||
|
||||
const creativesPerPage = 6
|
||||
const creativesPerRow = 2
|
||||
|
||||
const msgNoCreatives = `
|
||||
<b>Креативы</b>
|
||||
|
||||
У вас пока нет креативов
|
||||
|
||||
<i>Креатив — это рекламный материал:</i>
|
||||
‣ Текст объявления
|
||||
‣ Медиа контент (фото, видео)
|
||||
‣ Шаблон для автопостинга
|
||||
‣ Варианты для A/B тестирования
|
||||
|
||||
Начните с добавления первого креатива
|
||||
`
|
||||
|
||||
const msgCreatives = `
|
||||
<b>Креативы</b>
|
||||
|
||||
Управление рекламными материалами
|
||||
|
||||
Выберите креатив
|
||||
`
|
||||
|
||||
func (s *Creatives) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
page, err := b.Backend.GetCreatives(context.Background(), b.Session.JWT, b.Session.WorkspaceID, &s.ProjectID, false, s.CurrentPage+1, creativesPerPage)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to get creatives")
|
||||
b.SendNew("❌ Не удалось загрузить креативы", refreshKeyboard)
|
||||
return
|
||||
}
|
||||
|
||||
text := msgCreatives
|
||||
if len(page.Items) == 0 {
|
||||
text = msgNoCreatives
|
||||
}
|
||||
|
||||
var kbRows [][]echotron.InlineKeyboardButton
|
||||
|
||||
rowsGrid := ui.BuildGrid(page.Items, creativesPerRow, creativesPerPage, page.Pages,
|
||||
func(creative backend.Creative) (string, string) { return creative.Name, "creative:" + creative.ID },
|
||||
)
|
||||
kbRows = append(kbRows, rowsGrid...)
|
||||
|
||||
if navRow := ui.BuildNavigationRow(ui.PaginationConfig{
|
||||
CurrentPage: s.CurrentPage,
|
||||
TotalPages: page.Pages,
|
||||
MiddleButtons: Row(Button("+ Добавить", "add_creative")),
|
||||
}); navRow != nil {
|
||||
kbRows = append(kbRows, navRow)
|
||||
}
|
||||
|
||||
kbRows = append(kbRows, Row(Button("← Назад", "back"), Button("≡ Архив", "archive")))
|
||||
|
||||
kb := Keyboard(kbRows...)
|
||||
b.Render(text, kb, mode)
|
||||
|
||||
updateProjectHeaderMedia(b, b.LastMessageID, text, kb, s.ProjectTelegramID, s.ProjectTitle, s.ProjectUsername, s.ProjectStatus)
|
||||
}
|
||||
|
||||
func (s *Creatives) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
|
||||
return
|
||||
}
|
||||
|
||||
data := u.CallbackQuery.Data
|
||||
|
||||
switch {
|
||||
case data == "prev":
|
||||
if s.CurrentPage > 0 {
|
||||
s.CurrentPage--
|
||||
}
|
||||
s.Enter(b, bot.EditMessage)
|
||||
|
||||
case data == "next":
|
||||
s.CurrentPage++
|
||||
s.Enter(b, bot.EditMessage)
|
||||
|
||||
case data == "back":
|
||||
if s.BackState != nil {
|
||||
b.SetState(s.BackState, bot.EditMessage)
|
||||
}
|
||||
|
||||
case data == "archive":
|
||||
b.Edit("📦 Архив креативов\n\nЭта функция в разработке", Keyboard(Row(Button("← Назад", "back"))))
|
||||
|
||||
case data == "add_creative":
|
||||
b.SetState(&AddCreativeStart{Ctx: &AddCreativeCtx{
|
||||
ProjectID: s.ProjectID,
|
||||
BackState: s,
|
||||
}}, bot.EditMessage)
|
||||
|
||||
case strings.HasPrefix(data, "creative:"):
|
||||
creativeID, ok := strings.CutPrefix(data, "creative:")
|
||||
if !ok || creativeID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Открываем CreativeView вместо CreativeDetails
|
||||
b.SetState(&CreativeView{
|
||||
CreativeID: creativeID,
|
||||
ProjectID: s.ProjectID,
|
||||
// Данные проекта для передачи в AddPurchase
|
||||
ProjectTitle: s.ProjectTitle,
|
||||
ProjectTelegramID: s.ProjectTelegramID,
|
||||
ProjectUsername: s.ProjectUsername,
|
||||
ProjectStatus: s.ProjectStatus,
|
||||
BackState: s,
|
||||
}, bot.NewMessage)
|
||||
|
||||
default:
|
||||
s.Enter(b, bot.NewMessage)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Creatives) HandleMessage(_ *bot.Bot, _ *echotron.Update) {}
|
||||
|
||||
func (s *Creatives) Handle(_ *bot.Bot, _ *echotron.Update) {}
|
||||
|
||||
func (s *Creatives) Exit() {}
|
||||
@@ -0,0 +1,54 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
|
||||
)
|
||||
|
||||
const msgHelp = `
|
||||
<b>❓ Помощь</b>
|
||||
|
||||
<b>Команды бота:</b>
|
||||
/start — Главное меню
|
||||
/projects — Мои проекты
|
||||
/placements — Размещения
|
||||
/platform — Веб-платформа
|
||||
/help — Эта справка
|
||||
|
||||
<b>Как работать с ботом:</b>
|
||||
1. Создайте проект и добавьте креативы
|
||||
2. Выберите каналы для размещения
|
||||
3. Оформите покупку через безопасную сделку
|
||||
4. Получите автоматический отчёт
|
||||
|
||||
<b>Поддержка:</b>
|
||||
Если возникли вопросы, напишите в поддержку.
|
||||
`
|
||||
|
||||
type Help struct{}
|
||||
|
||||
func (s *Help) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
keyboard := Keyboard(
|
||||
Row(URLButton("Поддержка", "https://t.me/SmartpostSupport")),
|
||||
Row(Button("↩ В главное меню", "main_menu")),
|
||||
)
|
||||
|
||||
b.Render(msgHelp, keyboard, mode)
|
||||
}
|
||||
|
||||
func (s *Help) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
switch u.CallbackQuery.Data {
|
||||
case "main_menu":
|
||||
b.SetState(&MainMenu{}, bot.EditMessage)
|
||||
default:
|
||||
s.Enter(b, bot.NewMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Help) HandleMessage(b *bot.Bot, u *echotron.Update) {}
|
||||
|
||||
func (s *Help) Handle(b *bot.Bot, u *echotron.Update) {
|
||||
b.SetState(&MainMenu{}, bot.NewMessage)
|
||||
}
|
||||
|
||||
func (s *Help) Exit() {}
|
||||
@@ -0,0 +1,41 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
)
|
||||
|
||||
var emptyKeyboard = Keyboard()
|
||||
var refreshKeyboard = Keyboard(Row(Button("↻ Обновить", "refresh")))
|
||||
|
||||
func Button(text, data string) echotron.InlineKeyboardButton {
|
||||
return echotron.InlineKeyboardButton{
|
||||
Text: text,
|
||||
CallbackData: data,
|
||||
}
|
||||
}
|
||||
|
||||
func Stylish(btn echotron.InlineKeyboardButton, style echotron.ButtonStyle) echotron.InlineKeyboardButton {
|
||||
btn.Style = style
|
||||
|
||||
return btn
|
||||
}
|
||||
|
||||
func URLButton(text, url string) echotron.InlineKeyboardButton {
|
||||
return echotron.InlineKeyboardButton{
|
||||
Text: text,
|
||||
URL: url,
|
||||
}
|
||||
}
|
||||
|
||||
func Row(buttons ...echotron.InlineKeyboardButton) []echotron.InlineKeyboardButton { return buttons }
|
||||
|
||||
func Keyboard(rows ...[]echotron.InlineKeyboardButton) echotron.InlineKeyboardMarkup {
|
||||
if len(rows) == 0 {
|
||||
return echotron.InlineKeyboardMarkup{
|
||||
InlineKeyboard: [][]echotron.InlineKeyboardButton{},
|
||||
}
|
||||
}
|
||||
return echotron.InlineKeyboardMarkup{
|
||||
InlineKeyboard: rows,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type Login struct{}
|
||||
|
||||
const msgHelloLogin = `
|
||||
Привет, %s!
|
||||
Нажми кнопку для входа на сайт. 👇
|
||||
`
|
||||
|
||||
func (s *Login) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
token, err := b.Backend.CreateLoginToken(context.Background(), b.ChatID)
|
||||
if err != nil {
|
||||
b.SendNew("❌ Произошла ошибка при создании токена входа. Попробуйте позже.", emptyKeyboard)
|
||||
return
|
||||
}
|
||||
|
||||
loginURL := b.Backend.LoginURL(token)
|
||||
|
||||
usernameDisplay := "аноним"
|
||||
if b.Session.FirstName != "" {
|
||||
usernameDisplay = b.Session.FirstName
|
||||
}
|
||||
|
||||
kb := Keyboard(Row(URLButton("Войти на сайт", loginURL)))
|
||||
|
||||
res, err := b.SendMessage(fmt.Sprintf(msgHelloLogin, usernameDisplay), b.ChatID, &echotron.MessageOptions{
|
||||
ReplyMarkup: kb,
|
||||
ParseMode: echotron.HTML,
|
||||
LinkPreviewOptions: echotron.LinkPreviewOptions{IsDisabled: true},
|
||||
})
|
||||
if err != nil {
|
||||
log.Err(err).Msg("SendMessage error")
|
||||
return
|
||||
}
|
||||
|
||||
if res.Result != nil {
|
||||
if err := b.Backend.AttachLoginTokenMessage(context.Background(), token, res.Result.ID); err != nil {
|
||||
log.Err(err).Msg("AttachLoginTokenMessage error")
|
||||
}
|
||||
}
|
||||
|
||||
b.SetState(&MainMenu{}, bot.NewMessage)
|
||||
}
|
||||
func (s *Login) HandleCallback(b *bot.Bot, u *echotron.Update) {}
|
||||
|
||||
func (s *Login) HandleMessage(b *bot.Bot, u *echotron.Update) {}
|
||||
|
||||
func (s *Login) Handle(b *bot.Bot, u *echotron.Update) {}
|
||||
|
||||
func (s *Login) Exit() {}
|
||||
@@ -0,0 +1,100 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type MainMenu struct{}
|
||||
|
||||
const msgAbout = `
|
||||
<i>Главное меню</i>
|
||||
|
||||
<blockquote expandable>
|
||||
За что отвечает каждая кнопка в этом окне:
|
||||
|
||||
<b>• Рабочее пространство</b> — Одно можно использовать для своих проектов, а другое, например, для проектов, где вы являетесь закупщиком.
|
||||
|
||||
<b>• Мои проекты</b> — Управляйте вашими телеграм-каналами: добавляйте новые, привязывайте к ним креативы, создавайте закупы.
|
||||
|
||||
<b>• Размещения</b> — Просматривайте статистику по всем сделанным размещениям или создавайте новые (бот автоматически сформирует для вас креатив и заменит в нём пригласительную ссылку).
|
||||
|
||||
<b>• Веб-платформа</b> — Используйте функционал нашего сервиса на все 100%: планируйте размещения, просматривайте аналитику, следите за показателями проектов.
|
||||
|
||||
<b>• Помощь</b> — Изучите инструкции, как пользоваться нашим сервисом или напишите в поддержку, если возникли какие-то вопросы.
|
||||
</blockquote>
|
||||
|
||||
Чтобы делать закупы, добавьте канал и креатив:
|
||||
<b>/addchannel</b> — новый канал
|
||||
`
|
||||
|
||||
func (s *MainMenu) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
msg := msgAbout
|
||||
if b.Session.WasRestarted {
|
||||
msg = "⚠️ Бот был перезапущен. Извиняемся за неудобства :(\n\n" + msgAbout
|
||||
b.Session.WasRestarted = false
|
||||
}
|
||||
|
||||
loadWorkspace := func() string {
|
||||
workspaces, err := b.Backend.GetWorkspaces(context.Background(), b.Session.JWT)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to get workspaces for main menu")
|
||||
return "ошибка"
|
||||
}
|
||||
if len(workspaces) == 0 {
|
||||
workspace, err := b.Backend.CreateWorkspace(context.Background(), b.Session.JWT, "Личное")
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to create workspace")
|
||||
}
|
||||
|
||||
workspaces = append(workspaces, workspace)
|
||||
}
|
||||
|
||||
selected := workspaces[0] // дефолт — первый
|
||||
|
||||
if b.Session.WorkspaceID != "" {
|
||||
for _, ws := range workspaces {
|
||||
if ws.ID == b.Session.WorkspaceID {
|
||||
selected = ws
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
b.Session.WorkspaceID = selected.ID
|
||||
return selected.Name
|
||||
}
|
||||
|
||||
workspaceName := loadWorkspace()
|
||||
|
||||
keyboard := Keyboard(
|
||||
Row(Button(fmt.Sprintf("Рабочее пространство: %s", workspaceName), "workspace_menu")),
|
||||
Row(Button("Мои проекты", "my_projects"), URLButton("Поддержка", "https://t.me/SmartpostSupport")),
|
||||
Row(Button("Размещения", "placements"), URLButton("Дашборд", fmt.Sprintf("https://app.smart-post.ru/dashboard/%s", b.Session.WorkspaceID))),
|
||||
)
|
||||
|
||||
b.Render(msg, keyboard, mode)
|
||||
}
|
||||
|
||||
func (s *MainMenu) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
switch u.CallbackQuery.Data {
|
||||
case "my_projects":
|
||||
b.SetState(&MyProjects{BackState: &MainMenu{}}, bot.EditMessage)
|
||||
case "placements":
|
||||
b.SetState(&MyProjects{BackState: &MainMenu{}, OpenPlacements: true}, bot.EditMessage)
|
||||
case "workspace_menu":
|
||||
b.SetState(&WorkspaceMenu{BackState: &MainMenu{}}, bot.EditMessage)
|
||||
default:
|
||||
s.Enter(b, bot.NewMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MainMenu) HandleMessage(b *bot.Bot, u *echotron.Update) {}
|
||||
|
||||
func (s *MainMenu) Handle(b *bot.Bot, u *echotron.Update) { b.SetState(&MainMenu{}, bot.NewMessage) }
|
||||
|
||||
func (s *MainMenu) Exit() {}
|
||||
@@ -0,0 +1,197 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/backend"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const projectsPerPage = 6
|
||||
const projectsPerRow = 2
|
||||
|
||||
type MyProjects struct {
|
||||
CurrentPage int
|
||||
BackState bot.State
|
||||
OpenPlacements bool
|
||||
|
||||
cancel context.CancelFunc
|
||||
lastProjectCount int
|
||||
}
|
||||
|
||||
const msgNoProjects = `
|
||||
<b>Мои проекты</b>
|
||||
|
||||
У вас пока нет подключенных каналов.
|
||||
|
||||
<i>Что дает подключение канала:</i>
|
||||
▸ Управление типом приглашений (публичные/с одобрением)
|
||||
▸ Автоматическая отправка креативов в канал
|
||||
▸ Интеграция с планом закупов
|
||||
▸ Статистика и аналитика подписчиков
|
||||
▸ Настройка уведомлений
|
||||
|
||||
Начните с добавления первого проекта
|
||||
`
|
||||
|
||||
const msgProjects = `
|
||||
<b>Мои проекты</b>
|
||||
|
||||
Управление вашими Telegram-каналами.
|
||||
|
||||
Выберите канал
|
||||
`
|
||||
|
||||
func (s *MyProjects) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
s.renderProjects(b, mode)
|
||||
|
||||
s.startPolling(b)
|
||||
}
|
||||
|
||||
func (s *MyProjects) renderProjects(b *bot.Bot, mode bot.RenderMode) {
|
||||
page, err := b.Backend.GetProjects(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.CurrentPage+1, projectsPerPage)
|
||||
if err != nil {
|
||||
b.SendNew("❌ Не удалось загрузить проекты", refreshKeyboard)
|
||||
return
|
||||
}
|
||||
|
||||
s.lastProjectCount = page.Total
|
||||
|
||||
var kbRows [][]echotron.InlineKeyboardButton
|
||||
|
||||
grid := ui.BuildGrid(page.Items, projectsPerRow, projectsPerPage, page.Pages,
|
||||
func(project backend.Project) (string, string) { return project.Title, "project:" + project.ID },
|
||||
)
|
||||
kbRows = append(kbRows, grid...)
|
||||
|
||||
if navRow := ui.BuildNavigationRow(ui.PaginationConfig{
|
||||
CurrentPage: s.CurrentPage,
|
||||
TotalPages: page.Pages,
|
||||
MiddleButtons: Row(Button("+ Добавить", "add_project")),
|
||||
}); navRow != nil {
|
||||
kbRows = append(kbRows, navRow)
|
||||
}
|
||||
|
||||
kbRows = append(kbRows, Row(Button("← Назад", "main_menu"), Button("≡ Архив", "archive")))
|
||||
|
||||
keyboard := Keyboard(kbRows...)
|
||||
|
||||
text := msgProjects
|
||||
if len(page.Items) == 0 {
|
||||
text = msgNoProjects
|
||||
}
|
||||
|
||||
b.Render(text, keyboard, mode)
|
||||
}
|
||||
|
||||
func (s *MyProjects) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
|
||||
return
|
||||
}
|
||||
|
||||
data := u.CallbackQuery.Data
|
||||
|
||||
switch {
|
||||
case data == "prev":
|
||||
if s.CurrentPage > 0 {
|
||||
s.CurrentPage--
|
||||
}
|
||||
s.Enter(b, bot.EditMessage)
|
||||
|
||||
case data == "next":
|
||||
s.CurrentPage++
|
||||
s.Enter(b, bot.EditMessage)
|
||||
|
||||
case data == "main_menu":
|
||||
b.SetState(&MainMenu{}, bot.EditMessage)
|
||||
|
||||
case data == "archive":
|
||||
b.Edit("Эта функция в разработке", Keyboard(Row(Button("← Назад", "in_dev"))))
|
||||
|
||||
case data == "add_project":
|
||||
b.SetState(&AddProject{BackState: &MyProjects{}}, bot.EditMessage)
|
||||
|
||||
case strings.HasPrefix(data, "project:"):
|
||||
projectID, ok := strings.CutPrefix(data, "project:")
|
||||
if !ok || projectID == "" {
|
||||
return
|
||||
}
|
||||
project, err := b.Backend.GetProject(context.Background(), b.Session.JWT, b.Session.WorkspaceID, projectID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("project_id", projectID).Msg("Failed to get project")
|
||||
return
|
||||
}
|
||||
|
||||
if s.OpenPlacements {
|
||||
b.SetState(&Placements{
|
||||
ProjectID: project.ID,
|
||||
ProjectTitle: project.Title,
|
||||
BackState: &MyProjects{OpenPlacements: true},
|
||||
}, bot.EditMessage)
|
||||
} else {
|
||||
b.SetState(&ProjectDetails{
|
||||
Project: project,
|
||||
BackState: &MyProjects{},
|
||||
}, bot.EditMessage)
|
||||
}
|
||||
|
||||
default:
|
||||
s.Enter(b, bot.EditMessage)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *MyProjects) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *MyProjects) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *MyProjects) Exit() {
|
||||
if s.cancel != nil {
|
||||
log.Info().Msg("MyProjects: cancelling polling goroutine")
|
||||
s.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MyProjects) startPolling(b *bot.Bot) {
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
|
||||
s.cancel = cancel
|
||||
|
||||
go s.pollProjects(ctx, b)
|
||||
}
|
||||
|
||||
func (s *MyProjects) pollProjects(ctx context.Context, b *bot.Bot) {
|
||||
interval := 3 * time.Second
|
||||
timeToSlow := time.Now().Add(30 * time.Second)
|
||||
|
||||
timer := time.NewTimer(interval)
|
||||
defer timer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
|
||||
case <-timer.C:
|
||||
if time.Now().After(timeToSlow) {
|
||||
interval = 10 * time.Second
|
||||
}
|
||||
|
||||
page, err := b.Backend.GetProjects(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.CurrentPage+1, projectsPerPage)
|
||||
if err == nil && page.Total != s.lastProjectCount {
|
||||
s.lastProjectCount = page.Total
|
||||
s.renderProjects(b, bot.EditMessage)
|
||||
}
|
||||
|
||||
timer.Reset(interval)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/backend"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
|
||||
)
|
||||
|
||||
type PlacementDetails struct {
|
||||
ProjectID string
|
||||
PlacementID string
|
||||
BackState bot.State
|
||||
}
|
||||
|
||||
func (s *PlacementDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
s.renderDetails(b, mode)
|
||||
}
|
||||
|
||||
func (s *PlacementDetails) renderDetails(b *bot.Bot, mode bot.RenderMode) {
|
||||
placement, err := b.Backend.GetPlacement(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.ProjectID, s.PlacementID)
|
||||
if err != nil {
|
||||
b.SendNew("❌ Не удалось загрузить размещение", Keyboard())
|
||||
return
|
||||
}
|
||||
|
||||
placementPost := placement.PlacementPost
|
||||
|
||||
text := "<b>Детали размещения</b>\n\n"
|
||||
|
||||
// Размещение в канале
|
||||
text += "📺 <b>Размещение в канале:</b>\n"
|
||||
text += formatChannelWithName(placement.Channel)
|
||||
text += "\n"
|
||||
|
||||
// Рекламируемый проект
|
||||
if placement.Project != nil {
|
||||
text += "🎯 <b>Рекламируемый проект:</b>\n"
|
||||
text += formatProject(*placement.Project)
|
||||
text += "\n"
|
||||
}
|
||||
|
||||
// Ссылка на пост
|
||||
if placementPost != nil && placementPost.Post.URL != nil && *placementPost.Post.URL != "" {
|
||||
text += fmt.Sprintf("🔗 <b>Ссылка на пост:</b> %s\n", *placementPost.Post.URL)
|
||||
}
|
||||
|
||||
// Пригласительная ссылка
|
||||
if placement.InviteLink != nil && *placement.InviteLink != "" {
|
||||
text += fmt.Sprintf("📩 <b>Пригласительная ссылка:</b> %s\n", *placement.InviteLink)
|
||||
}
|
||||
|
||||
// Тип ссылки
|
||||
text += fmt.Sprintf("🔓 <b>Тип ссылки:</b> %s\n", formatInviteLinkType(placement.InviteLinkType))
|
||||
|
||||
// Short ID
|
||||
if placement.ShortID != "" {
|
||||
text += fmt.Sprintf("🔢 <b>ID:</b> %s\n", placement.ShortID)
|
||||
}
|
||||
|
||||
// Креатив
|
||||
if placement.Details != nil && placement.Details.CreativeName != nil && *placement.Details.CreativeName != "" {
|
||||
text += fmt.Sprintf("✨ <b>Креатив:</b> %s\n", *placement.Details.CreativeName)
|
||||
} else if placement.CreativeName != nil && *placement.CreativeName != "" {
|
||||
text += fmt.Sprintf("✨ <b>Креатив:</b> %s\n", *placement.CreativeName)
|
||||
}
|
||||
|
||||
text += "\n"
|
||||
|
||||
// Финансовая информация
|
||||
if placement.Details != nil {
|
||||
// Стоимость
|
||||
if placement.Details.Cost != nil {
|
||||
costType := formatCostType(placement.Details.Cost.Type)
|
||||
text += fmt.Sprintf("💰 <b>Стоимость:</b> %s %.0f₽\n", costType, placement.Details.Cost.Value)
|
||||
}
|
||||
|
||||
// Стоимость до торга
|
||||
if placement.Details.CostBeforeBargain != nil {
|
||||
costType := formatCostType(placement.Details.CostBeforeBargain.Type)
|
||||
text += fmt.Sprintf("💸 <b>До торга:</b> %s %.0f₽\n", costType, placement.Details.CostBeforeBargain.Value)
|
||||
}
|
||||
|
||||
// Дата размещения
|
||||
if placement.Details.PlacementAt != nil && *placement.Details.PlacementAt != "" {
|
||||
text += fmt.Sprintf("📅 <b>Дата размещения:</b> %s\n", formatDateTime(*placement.Details.PlacementAt))
|
||||
}
|
||||
|
||||
// Дата оплаты
|
||||
if placement.Details.PaymentAt != nil && *placement.Details.PaymentAt != "" {
|
||||
text += fmt.Sprintf("💳 <b>Дата оплаты:</b> %s\n", formatDateTime(*placement.Details.PaymentAt))
|
||||
}
|
||||
|
||||
// Формат
|
||||
if placement.Details.Format != nil && *placement.Details.Format != "" {
|
||||
text += fmt.Sprintf("📐 <b>Формат:</b> %s\n", *placement.Details.Format)
|
||||
}
|
||||
|
||||
// Тип закупа
|
||||
if placement.Details.PlacementType != nil {
|
||||
text += fmt.Sprintf("🔄 <b>Тип закупа:</b> %s\n", formatPlacementType(*placement.Details.PlacementType))
|
||||
}
|
||||
}
|
||||
|
||||
text += "\n"
|
||||
|
||||
// Статистика
|
||||
if placementPost != nil {
|
||||
// Кол-во подписчиков
|
||||
if placementPost.SubscriptionsCount > 0 {
|
||||
text += fmt.Sprintf("👥 <b>Подписчики:</b> %d\n", placementPost.SubscriptionsCount)
|
||||
}
|
||||
|
||||
// Просмотры поста
|
||||
if placementPost.ViewsCount != nil {
|
||||
text += fmt.Sprintf("👁️ <b>Просмотры:</b> %d\n", *placementPost.ViewsCount)
|
||||
}
|
||||
|
||||
// CPF (стоимость подписчика)
|
||||
if placementPost.SubscriptionsCount > 0 && placement.Details != nil && placement.Details.Cost != nil {
|
||||
cpf := calculateCPF(placement.Details.Cost.Value, placementPost.SubscriptionsCount)
|
||||
text += fmt.Sprintf("💵 <b>CPF:</b> %.2f₽\n", cpf)
|
||||
}
|
||||
|
||||
// Конверсия
|
||||
if placementPost.ViewsCount != nil && *placementPost.ViewsCount > 0 && placementPost.SubscriptionsCount > 0 {
|
||||
conversion := calculateConversion(placementPost.SubscriptionsCount, *placementPost.ViewsCount)
|
||||
text += fmt.Sprintf("📊 <b>Конверсия:</b> %.1f%%\n", conversion)
|
||||
}
|
||||
}
|
||||
|
||||
// Комментарий
|
||||
if placement.Details != nil && placement.Details.Comment != nil && *placement.Details.Comment != "" {
|
||||
text += fmt.Sprintf("\n📝 <b>Комментарий:</b> %s\n", *placement.Details.Comment)
|
||||
}
|
||||
|
||||
keyboard := Keyboard(Row(Button("← Назад", "back")))
|
||||
b.Render(text, keyboard, mode)
|
||||
}
|
||||
|
||||
func formatChannelWithName(channel backend.Channel) string {
|
||||
var name string
|
||||
if channel.Title != nil && *channel.Title != "" {
|
||||
name = *channel.Title
|
||||
} else if channel.Username != nil && *channel.Username != "" {
|
||||
name = "@" + *channel.Username
|
||||
} else {
|
||||
name = "Без названия"
|
||||
}
|
||||
|
||||
// Добавляем ссылку если есть username или invite_link
|
||||
if channel.Username != nil && *channel.Username != "" {
|
||||
return fmt.Sprintf(" %s (@%s)\n", name, *channel.Username)
|
||||
} else if channel.InviteLink != nil && *channel.InviteLink != "" {
|
||||
return fmt.Sprintf(" %s\n", name)
|
||||
}
|
||||
return fmt.Sprintf(" %s\n", name)
|
||||
}
|
||||
|
||||
func formatProject(project backend.ProjectOutput) string {
|
||||
var name string
|
||||
if project.Channel.Title != nil && *project.Channel.Title != "" {
|
||||
name = *project.Channel.Title
|
||||
} else if project.Channel.Username != nil && *project.Channel.Username != "" {
|
||||
name = "@" + *project.Channel.Username
|
||||
} else {
|
||||
name = "Без названия"
|
||||
}
|
||||
|
||||
// Добавляем ссылку если есть username
|
||||
if project.Channel.Username != nil && *project.Channel.Username != "" {
|
||||
return fmt.Sprintf(" %s (@%s)\n", name, *project.Channel.Username)
|
||||
}
|
||||
return fmt.Sprintf(" %s\n", name)
|
||||
}
|
||||
|
||||
func calculateCPF(cost float64, subscriptions int) float64 {
|
||||
if subscriptions == 0 {
|
||||
return 0
|
||||
}
|
||||
return cost / float64(subscriptions)
|
||||
}
|
||||
|
||||
func calculateConversion(subscriptions int, views int) float64 {
|
||||
if views == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(subscriptions) / float64(views) * 100
|
||||
}
|
||||
|
||||
func formatInviteLinkType(value string) string {
|
||||
if value == "approval" {
|
||||
return "с одобрением"
|
||||
}
|
||||
return "публичная"
|
||||
}
|
||||
|
||||
func formatPlacementStatus(status string) string {
|
||||
normalized := strings.TrimSpace(strings.ToLower(status))
|
||||
switch normalized {
|
||||
case "planned":
|
||||
return "Планируется"
|
||||
case "approved":
|
||||
return "Согласовано"
|
||||
case "rejected":
|
||||
return "Отклонено"
|
||||
case "in_progress":
|
||||
return "В работе"
|
||||
case "completed":
|
||||
return "Размещено"
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
func formatPlacementType(value string) string {
|
||||
normalized := strings.TrimSpace(strings.ToLower(value))
|
||||
switch normalized {
|
||||
case "self_promo":
|
||||
return "Самопиар"
|
||||
case "standard":
|
||||
return "Стандарт"
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func formatCostType(value string) string {
|
||||
switch strings.TrimSpace(strings.ToLower(value)) {
|
||||
case "cpm":
|
||||
return "СРМ"
|
||||
default:
|
||||
return "Фикс"
|
||||
}
|
||||
}
|
||||
|
||||
func formatDateTime(value string) string {
|
||||
if value == "" {
|
||||
return value
|
||||
}
|
||||
var parsed time.Time
|
||||
var err error
|
||||
|
||||
// Try parsing with timezone
|
||||
if parsed, err = time.Parse(time.RFC3339, value); err == nil {
|
||||
return formatCompactDateTime(parsed.In(ui.MskLocation))
|
||||
}
|
||||
if parsed, err = time.Parse(time.RFC3339Nano, value); err == nil {
|
||||
return formatCompactDateTime(parsed.In(ui.MskLocation))
|
||||
}
|
||||
if parsed, err = time.ParseInLocation("2006-01-02T15:04:05", value, ui.MskLocation); err == nil {
|
||||
return formatCompactDateTime(parsed.In(ui.MskLocation))
|
||||
}
|
||||
if parsed, err = time.ParseInLocation("2006-01-02", value, ui.MskLocation); err == nil {
|
||||
return formatCompactDateTime(parsed.In(ui.MskLocation))
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
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 MonthShort(month time.Month) string {
|
||||
months := []string{
|
||||
"янв", "фев", "мар", "апр", "май", "июн",
|
||||
"июл", "авг", "сен", "окт", "ноя", "дек",
|
||||
}
|
||||
if int(month) < 1 || int(month) > len(months) {
|
||||
return ""
|
||||
}
|
||||
return months[int(month)-1]
|
||||
}
|
||||
|
||||
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 (s *PlacementDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
|
||||
return
|
||||
}
|
||||
|
||||
switch u.CallbackQuery.Data {
|
||||
case "back":
|
||||
if s.BackState != nil {
|
||||
b.SetState(s.BackState, bot.EditMessage)
|
||||
}
|
||||
default:
|
||||
s.Enter(b, bot.NewMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PlacementDetails) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *PlacementDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *PlacementDetails) Exit() {}
|
||||
@@ -0,0 +1,246 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
|
||||
)
|
||||
|
||||
type Placements struct {
|
||||
ProjectID string
|
||||
ProjectTitle string
|
||||
ProjectTelegramID int64
|
||||
ProjectUsername string
|
||||
ProjectStatus string
|
||||
BackState bot.State
|
||||
}
|
||||
|
||||
const msgPlacements = `
|
||||
<b>Размещения</b>
|
||||
|
||||
Управление размещениями для проекта.
|
||||
`
|
||||
|
||||
func (s *Placements) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
var rows [][]echotron.InlineKeyboardButton
|
||||
|
||||
rows = append(rows, Row(Button("+ Создать размещение", "add_purchase")))
|
||||
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(Button("← Назад", "back")))
|
||||
|
||||
keyboard := Keyboard(rows...)
|
||||
b.Render(msgPlacements, keyboard, mode)
|
||||
|
||||
updateProjectHeaderMedia(b, b.LastMessageID, msgPlacements, keyboard, s.ProjectTelegramID, s.ProjectTitle, s.ProjectUsername, s.ProjectStatus)
|
||||
}
|
||||
|
||||
func (s *Placements) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
|
||||
return
|
||||
}
|
||||
|
||||
data := u.CallbackQuery.Data
|
||||
|
||||
switch data {
|
||||
case "back":
|
||||
if s.BackState != nil {
|
||||
b.SetState(s.BackState, bot.EditMessage)
|
||||
}
|
||||
|
||||
case "add_purchase":
|
||||
b.SetState(&AddPurchase{
|
||||
ProjectID: s.ProjectID,
|
||||
ProjectTitle: s.ProjectTitle,
|
||||
ProjectTelegramID: s.ProjectTelegramID,
|
||||
ProjectUsername: s.ProjectUsername,
|
||||
ProjectStatus: s.ProjectStatus,
|
||||
ActivePicker: "",
|
||||
BackState: s,
|
||||
}, bot.EditMessage)
|
||||
|
||||
case "placements_list":
|
||||
b.SetState(&PlacementsList{
|
||||
ProjectID: s.ProjectID,
|
||||
ProjectTitle: s.ProjectTitle,
|
||||
ProjectTelegramID: s.ProjectTelegramID,
|
||||
ProjectUsername: s.ProjectUsername,
|
||||
ProjectStatus: s.ProjectStatus,
|
||||
BackState: s,
|
||||
}, bot.EditMessage)
|
||||
|
||||
default:
|
||||
s.Enter(b, bot.NewMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Placements) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *Placements) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *Placements) Exit() {}
|
||||
|
||||
// PlacementsList - экран со списком размещений
|
||||
const placementsPerPage = 6
|
||||
const placementsPerRow = 1
|
||||
|
||||
type PlacementsList struct {
|
||||
ProjectID string
|
||||
ProjectTitle string
|
||||
ProjectTelegramID int64
|
||||
ProjectUsername string
|
||||
ProjectStatus string
|
||||
CurrentPage int
|
||||
BackState bot.State
|
||||
}
|
||||
|
||||
func (s *PlacementsList) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
s.render(b, mode)
|
||||
}
|
||||
|
||||
func (s *PlacementsList) render(b *bot.Bot, mode bot.RenderMode) {
|
||||
page, err := b.Backend.GetPlacements(
|
||||
context.Background(),
|
||||
b.Session.JWT,
|
||||
b.Session.WorkspaceID,
|
||||
s.ProjectID,
|
||||
s.CurrentPage+1,
|
||||
placementsPerPage,
|
||||
)
|
||||
if err != nil {
|
||||
b.SendNew("❌ Не удалось загрузить размещения", Keyboard())
|
||||
return
|
||||
}
|
||||
|
||||
text := fmt.Sprintf("<b>Список размещений</b>%s\n\n", ui.FormatPageInfo(s.CurrentPage, page.Pages))
|
||||
|
||||
var rows [][]echotron.InlineKeyboardButton
|
||||
|
||||
if len(page.Items) == 0 {
|
||||
text += `Список размещений пуст.
|
||||
|
||||
<i>Размещение — это план публикации рекламы в канале:</i>
|
||||
‣ Выбор креатива
|
||||
‣ Канал размещения
|
||||
‣ Стоимость и формат
|
||||
‣ Статус выполнения
|
||||
|
||||
Создайте первое размещение`
|
||||
} else {
|
||||
// Формируем кнопки для каждого размещения
|
||||
buttons := make([]echotron.InlineKeyboardButton, 0, len(page.Items))
|
||||
for i, placement := range page.Items {
|
||||
// Нумерация по порядку создания: первое размещение = #1 (на последней странице),
|
||||
// последнее размещение = #N (вверху).
|
||||
globalNum := page.Total - (s.CurrentPage * placementsPerPage) - i
|
||||
|
||||
// Название канала
|
||||
channelName := "Без названия"
|
||||
if placement.Channel.Title != nil && *placement.Channel.Title != "" {
|
||||
channelName = *placement.Channel.Title
|
||||
} else if placement.Channel.Username != nil && *placement.Channel.Username != "" {
|
||||
channelName = "@" + *placement.Channel.Username
|
||||
}
|
||||
|
||||
// Дата размещения
|
||||
dateStr := ""
|
||||
if placement.Details != nil && placement.Details.PlacementAt != nil && *placement.Details.PlacementAt != "" {
|
||||
dateStr = formatDate(*placement.Details.PlacementAt)
|
||||
}
|
||||
|
||||
// Текст кнопки: #N • Канал • DD.MM
|
||||
buttonText := fmt.Sprintf("#%d • %s", globalNum, channelName)
|
||||
if dateStr != "" {
|
||||
buttonText += fmt.Sprintf(" • %s", dateStr)
|
||||
}
|
||||
|
||||
buttons = append(buttons, echotron.InlineKeyboardButton{
|
||||
Text: buttonText,
|
||||
CallbackData: fmt.Sprintf("placement:%s", placement.ID),
|
||||
})
|
||||
}
|
||||
|
||||
// Раскладываем по сетке
|
||||
rows = ui.BuildPageRows(buttons, placementsPerRow, placementsPerPage, page.Pages)
|
||||
}
|
||||
|
||||
// Навигация (без MiddleButtons - без "Создать размещение")
|
||||
if navRow := ui.BuildNavigationRow(ui.PaginationConfig{
|
||||
CurrentPage: s.CurrentPage,
|
||||
TotalPages: page.Pages,
|
||||
}); navRow != nil {
|
||||
rows = append(rows, navRow)
|
||||
}
|
||||
|
||||
// Кнопка назад
|
||||
rows = append(rows, Row(Button("← Назад", "back")))
|
||||
|
||||
keyboard := Keyboard(rows...)
|
||||
b.Render(text, keyboard, mode)
|
||||
}
|
||||
|
||||
func (s *PlacementsList) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
|
||||
return
|
||||
}
|
||||
|
||||
data := u.CallbackQuery.Data
|
||||
|
||||
switch data {
|
||||
case "back":
|
||||
if s.BackState != nil {
|
||||
b.SetState(s.BackState, bot.EditMessage)
|
||||
}
|
||||
|
||||
case "prev":
|
||||
if s.CurrentPage > 0 {
|
||||
s.CurrentPage--
|
||||
}
|
||||
s.Enter(b, bot.EditMessage)
|
||||
|
||||
case "next":
|
||||
s.CurrentPage++
|
||||
s.Enter(b, bot.EditMessage)
|
||||
|
||||
default:
|
||||
// Проверяем, не нажали ли на конкретное размещение
|
||||
if len(data) > 10 && data[:10] == "placement:" {
|
||||
placementID := data[10:]
|
||||
b.SetState(&PlacementDetails{
|
||||
ProjectID: s.ProjectID,
|
||||
PlacementID: placementID,
|
||||
BackState: s,
|
||||
}, bot.EditMessage)
|
||||
} else {
|
||||
s.Enter(b, bot.NewMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PlacementsList) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *PlacementsList) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *PlacementsList) Exit() {}
|
||||
|
||||
// formatDate парсит ISO дату и форматирует как "27.01"
|
||||
func formatDate(isoDate string) string {
|
||||
// Пробуем парсить RFC3339
|
||||
t, err := time.Parse(time.RFC3339, isoDate)
|
||||
if err != nil {
|
||||
// Пробуем другие форматы
|
||||
t, err = time.ParseInLocation("2006-01-02T15:04:05", isoDate, ui.MskLocation)
|
||||
if err != nil {
|
||||
// Иногда бэкенд может вернуть только дату без времени
|
||||
t, err = time.ParseInLocation("2006-01-02", isoDate, ui.MskLocation)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return t.In(ui.MskLocation).Format("02.01")
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
|
||||
)
|
||||
|
||||
const msgPlatform = `
|
||||
<b>🌐 Веб-платформа</b>
|
||||
|
||||
Управляйте проектами в браузере — удобно для работы с компьютера.
|
||||
|
||||
Полный функционал доступен на сайте.
|
||||
`
|
||||
|
||||
type PlatformLink struct{}
|
||||
|
||||
func (s *PlatformLink) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
keyboard := Keyboard(
|
||||
Row(URLButton("Открыть платформу", fmt.Sprintf("https://app.smart-post.ru/dashboard/%s", b.Session.WorkspaceID))),
|
||||
Row(Button("↩ В главное меню", "main_menu")),
|
||||
)
|
||||
|
||||
b.Render(msgPlatform, keyboard, mode)
|
||||
}
|
||||
|
||||
func (s *PlatformLink) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
switch u.CallbackQuery.Data {
|
||||
case "main_menu":
|
||||
b.SetState(&MainMenu{}, bot.EditMessage)
|
||||
default:
|
||||
s.Enter(b, bot.NewMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PlatformLink) HandleMessage(b *bot.Bot, u *echotron.Update) {}
|
||||
|
||||
func (s *PlatformLink) Handle(b *bot.Bot, u *echotron.Update) {
|
||||
b.SetState(&MainMenu{}, bot.NewMessage)
|
||||
}
|
||||
|
||||
func (s *PlatformLink) Exit() {}
|
||||
@@ -0,0 +1,191 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/backend"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type ProjectDetails struct {
|
||||
Project *backend.Project
|
||||
BackState bot.State
|
||||
}
|
||||
|
||||
func (s *ProjectDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
if s.Project == nil {
|
||||
b.SendNew("❌ Проект не найден", Keyboard(
|
||||
Row(Button("← Назад", "back")),
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
text := s.formatProjectDetails()
|
||||
keyboard := s.buildKeyboard()
|
||||
b.Render(text, keyboard, mode)
|
||||
|
||||
messageID := b.LastMessageID
|
||||
username := usernameFromProject(s.Project)
|
||||
status := statusFromProject(s.Project)
|
||||
updateProjectHeaderMedia(b, messageID, text, keyboard, s.Project.TelegramID, s.Project.Title, username, status)
|
||||
}
|
||||
|
||||
func (s *ProjectDetails) formatProjectDetails() string {
|
||||
p := s.Project
|
||||
|
||||
text := fmt.Sprintf("<b>%s</b>\n\n", p.Title)
|
||||
|
||||
if p.Username != nil && *p.Username != "" {
|
||||
text += fmt.Sprintf("▸ Tg username: @%s\n", *p.Username)
|
||||
} else {
|
||||
text += "▸ Tg username: <i>не привязан</i>\n"
|
||||
}
|
||||
|
||||
// Статус проекта
|
||||
var statusSymbol, statusText string
|
||||
switch p.Status {
|
||||
case "active":
|
||||
statusSymbol, statusText = "●", "Активный"
|
||||
case "inactive":
|
||||
statusSymbol, statusText = "○", "Неактивен"
|
||||
case "archived":
|
||||
statusSymbol, statusText = "■", "Архивный"
|
||||
case "paused":
|
||||
statusSymbol, statusText = "◐", "Приостановлен"
|
||||
default:
|
||||
statusSymbol, statusText = "○", p.Status
|
||||
}
|
||||
text += fmt.Sprintf("▸ Статус: %s %s\n", statusSymbol, statusText)
|
||||
|
||||
text += "\nУправление проектом"
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
func (s *ProjectDetails) buildKeyboard() echotron.InlineKeyboardMarkup {
|
||||
var buttons [][]echotron.InlineKeyboardButton
|
||||
|
||||
buttons = append(buttons, Row(
|
||||
Button("Креативы", fmt.Sprintf("creatives:%s", s.Project.ID)),
|
||||
Button("Размещения", fmt.Sprintf("placements:%s", s.Project.ID)),
|
||||
))
|
||||
|
||||
// Второй ряд: Тип вступления по умолчанию для закупов
|
||||
var currentType string
|
||||
if s.Project.PurchaseInviteTypeDefault == "public" {
|
||||
currentType = "открытая"
|
||||
} else {
|
||||
currentType = "с заявками"
|
||||
}
|
||||
buttons = append(buttons, Row(
|
||||
Button(fmt.Sprintf("Ссылка: %s", currentType), fmt.Sprintf("link_type:%s", s.Project.ID)),
|
||||
))
|
||||
|
||||
// Нижний ряд: Назад и Архивировать
|
||||
buttons = append(buttons, Row(
|
||||
Button("← Назад", "back"),
|
||||
Button("≡ Архивировать", fmt.Sprintf("archive:%s", s.Project.ID)),
|
||||
))
|
||||
|
||||
return Keyboard(buttons...)
|
||||
}
|
||||
|
||||
func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
|
||||
return
|
||||
}
|
||||
|
||||
data := u.CallbackQuery.Data
|
||||
|
||||
switch {
|
||||
case data == "back":
|
||||
if s.BackState != nil {
|
||||
s.transitionWithNewMessage(b, s.BackState)
|
||||
}
|
||||
|
||||
case strings.HasPrefix(data, "creatives:"):
|
||||
b.SetState(&Creatives{
|
||||
ProjectID: s.Project.ID,
|
||||
ProjectTitle: s.Project.Title,
|
||||
ProjectTelegramID: s.Project.TelegramID,
|
||||
ProjectUsername: usernameFromProject(s.Project),
|
||||
ProjectStatus: statusFromProject(s.Project),
|
||||
BackState: s,
|
||||
}, bot.EditMessage)
|
||||
|
||||
case strings.HasPrefix(data, "placements:"):
|
||||
b.SetState(&Placements{
|
||||
ProjectID: s.Project.ID,
|
||||
ProjectTitle: s.Project.Title,
|
||||
ProjectTelegramID: s.Project.TelegramID,
|
||||
ProjectUsername: usernameFromProject(s.Project),
|
||||
ProjectStatus: statusFromProject(s.Project),
|
||||
BackState: s,
|
||||
}, bot.EditMessage)
|
||||
|
||||
case data == "back_to_project":
|
||||
s.Enter(b, bot.EditMessage)
|
||||
|
||||
case strings.HasPrefix(data, "link_type:"):
|
||||
var newType string
|
||||
if s.Project.PurchaseInviteTypeDefault == "public" {
|
||||
newType = "approval"
|
||||
} else {
|
||||
newType = "public"
|
||||
}
|
||||
|
||||
updatedProject, err := b.Backend.UpdateProjectInviteLinkType(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.Project.ID, newType)
|
||||
|
||||
if err != nil {
|
||||
b.SendNew(fmt.Sprintf("❌ Ошибка при изменении типа ссылки: %v", err), Keyboard(
|
||||
Row(Button("← Назад", "back_to_project")),
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
s.Project = updatedProject
|
||||
s.Enter(b, bot.EditMessage)
|
||||
|
||||
default:
|
||||
// Для остальных кнопок показываем заглушку
|
||||
b.Edit(fmt.Sprintf("🚧 Функция в разработке\n\nCallback: %s", data), Keyboard(
|
||||
Row(Button("← Назад", "back_to_project")),
|
||||
))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *ProjectDetails) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *ProjectDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *ProjectDetails) Exit() {}
|
||||
|
||||
func (s *ProjectDetails) transitionWithNewMessage(b *bot.Bot, next bot.State) {
|
||||
if b.LastMessageID != 0 {
|
||||
if _, err := b.DeleteMessage(b.ChatID, b.LastMessageID); err != nil {
|
||||
log.Error().Err(err).Msg("DeleteMessage failed")
|
||||
} else {
|
||||
b.LastMessageID = 0
|
||||
}
|
||||
}
|
||||
b.SetState(next, bot.NewMessage)
|
||||
}
|
||||
|
||||
func usernameFromProject(p *backend.Project) string {
|
||||
if p == nil || p.Username == nil {
|
||||
return ""
|
||||
}
|
||||
return *p.Username
|
||||
}
|
||||
|
||||
func statusFromProject(p *backend.Project) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return p.Status
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/jpeg"
|
||||
_ "image/png"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
|
||||
"github.com/rs/zerolog/log"
|
||||
xdraw "golang.org/x/image/draw"
|
||||
"golang.org/x/image/font"
|
||||
"golang.org/x/image/font/opentype"
|
||||
"golang.org/x/image/math/fixed"
|
||||
)
|
||||
|
||||
//go:embed assets/fonts/JetBrainsMono-Bold.ttf
|
||||
var jetBrainsMonoBold []byte
|
||||
|
||||
//go:embed assets/fonts/JetBrainsMono-Regular.ttf
|
||||
var jetBrainsMonoRegular []byte
|
||||
|
||||
const projectHeaderCacheTTL = 30 * time.Minute
|
||||
const projectHeaderCacheVersion = "img-v1"
|
||||
const projectHeaderCacheMaxBytes = 32 * 1024 * 1024
|
||||
|
||||
type projectHeaderCacheEntry struct {
|
||||
bytes []byte
|
||||
sizeBytes int
|
||||
uniqueID string
|
||||
fetchedAt time.Time
|
||||
}
|
||||
|
||||
var projectHeaderCache = struct {
|
||||
mu sync.Mutex
|
||||
items map[string]projectHeaderCacheEntry
|
||||
total int
|
||||
}{
|
||||
items: make(map[string]projectHeaderCacheEntry),
|
||||
}
|
||||
|
||||
var projectHeaderLastMediaKey = struct {
|
||||
mu sync.Mutex
|
||||
items map[string]string
|
||||
}{
|
||||
items: make(map[string]string),
|
||||
}
|
||||
|
||||
func updateProjectHeaderMedia(b *bot.Bot, messageID int, caption string, keyboard echotron.InlineKeyboardMarkup, chatID int64, title string, username string, status string) {
|
||||
if messageID == 0 || chatID == 0 || title == "" {
|
||||
return
|
||||
}
|
||||
|
||||
cacheKey := projectHeaderCacheKey(chatID, username, status)
|
||||
if cached, ok := getProjectHeaderFromCache(cacheKey); ok && time.Since(cached.fetchedAt) < projectHeaderCacheTTL {
|
||||
mediaKey := cacheKey + ":" + cached.uniqueID
|
||||
if shouldSkipMediaEdit(chatID, messageID, mediaKey) {
|
||||
return
|
||||
}
|
||||
if err := editProjectHeaderMedia(b, messageID, caption, keyboard, cached.bytes, chatID); err == nil {
|
||||
setLastMediaKey(chatID, messageID, mediaKey)
|
||||
b.SetLastMessageIsMedia(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
chatInfo, err := b.GetChat(chatID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Int64("chat_id", chatID).Msg("GetChat failed")
|
||||
return
|
||||
}
|
||||
if chatInfo.Result == nil || chatInfo.Result.Photo == nil || chatInfo.Result.Photo.SmallFileID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
uniqueID := chatInfo.Result.Photo.SmallFileUniqueID
|
||||
if cached, ok := getProjectHeaderFromCache(cacheKey); ok && cached.uniqueID == uniqueID && len(cached.bytes) > 0 {
|
||||
setProjectHeaderCache(cacheKey, cached.bytes, uniqueID)
|
||||
mediaKey := cacheKey + ":" + uniqueID
|
||||
if shouldSkipMediaEdit(chatID, messageID, mediaKey) {
|
||||
return
|
||||
}
|
||||
if err := editProjectHeaderMedia(b, messageID, caption, keyboard, cached.bytes, chatID); err == nil {
|
||||
setLastMediaKey(chatID, messageID, mediaKey)
|
||||
b.SetLastMessageIsMedia(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
photoBytes, err := b.DownloadFileBytes(chatInfo.Result.Photo.SmallFileID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Int64("chat_id", chatID).Msg("Download chat photo failed")
|
||||
return
|
||||
}
|
||||
|
||||
compositeBytes, err := buildProjectHeaderImage(photoBytes, title, username, status)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Int64("chat_id", chatID).Msg("Build project header image failed")
|
||||
return
|
||||
}
|
||||
setProjectHeaderCache(cacheKey, compositeBytes, uniqueID)
|
||||
|
||||
mediaKey := cacheKey + ":" + uniqueID
|
||||
if !shouldSkipMediaEdit(chatID, messageID, mediaKey) {
|
||||
if err := editProjectHeaderMedia(b, messageID, caption, keyboard, compositeBytes, chatID); err == nil {
|
||||
setLastMediaKey(chatID, messageID, mediaKey)
|
||||
b.SetLastMessageIsMedia(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func editProjectHeaderMedia(b *bot.Bot, messageID int, caption string, keyboard echotron.InlineKeyboardMarkup, compositeBytes []byte, chatID int64) error {
|
||||
if len(compositeBytes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
media := echotron.InputMediaPhoto{
|
||||
Type: echotron.MediaTypePhoto,
|
||||
Media: echotron.NewInputFileBytes("project_header.jpg", compositeBytes),
|
||||
Caption: caption,
|
||||
ParseMode: echotron.HTML,
|
||||
}
|
||||
|
||||
_, err := b.EditMessageMedia(
|
||||
echotron.NewMessageID(b.ChatID, messageID),
|
||||
media,
|
||||
&echotron.MessageMediaOptions{
|
||||
ReplyMarkup: keyboard,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "message is not modified") {
|
||||
log.Info().Int64("chat_id", chatID).Msg("EditMessageMedia not modified")
|
||||
return nil
|
||||
}
|
||||
log.Error().Err(err).Int64("chat_id", chatID).Msg("EditMessageMedia failed")
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func shouldSkipMediaEdit(chatID int64, messageID int, mediaKey string) bool {
|
||||
key := fmt.Sprintf("%d:%d", chatID, messageID)
|
||||
projectHeaderLastMediaKey.mu.Lock()
|
||||
defer projectHeaderLastMediaKey.mu.Unlock()
|
||||
lastKey, ok := projectHeaderLastMediaKey.items[key]
|
||||
return ok && lastKey == mediaKey
|
||||
}
|
||||
|
||||
func setLastMediaKey(chatID int64, messageID int, mediaKey string) {
|
||||
key := fmt.Sprintf("%d:%d", chatID, messageID)
|
||||
projectHeaderLastMediaKey.mu.Lock()
|
||||
defer projectHeaderLastMediaKey.mu.Unlock()
|
||||
projectHeaderLastMediaKey.items[key] = mediaKey
|
||||
}
|
||||
|
||||
func projectHeaderCacheKey(chatID int64, username string, status string) string {
|
||||
return fmt.Sprintf("%s:%d:%s:%s", projectHeaderCacheVersion, chatID, strings.ToUpper(username), strings.ToUpper(status))
|
||||
}
|
||||
|
||||
func getProjectHeaderFromCache(key string) (projectHeaderCacheEntry, bool) {
|
||||
projectHeaderCache.mu.Lock()
|
||||
defer projectHeaderCache.mu.Unlock()
|
||||
entry, ok := projectHeaderCache.items[key]
|
||||
return entry, ok
|
||||
}
|
||||
|
||||
func setProjectHeaderCache(key string, value []byte, uniqueID string) {
|
||||
projectHeaderCache.mu.Lock()
|
||||
defer projectHeaderCache.mu.Unlock()
|
||||
if existing, ok := projectHeaderCache.items[key]; ok {
|
||||
projectHeaderCache.total -= existing.sizeBytes
|
||||
}
|
||||
projectHeaderCache.items[key] = projectHeaderCacheEntry{
|
||||
bytes: value,
|
||||
sizeBytes: len(value),
|
||||
uniqueID: uniqueID,
|
||||
fetchedAt: time.Now(),
|
||||
}
|
||||
projectHeaderCache.total += len(value)
|
||||
projectHeaderCacheEvictIfNeeded()
|
||||
}
|
||||
|
||||
func projectHeaderCacheEvictIfNeeded() {
|
||||
for projectHeaderCache.total > projectHeaderCacheMaxBytes && len(projectHeaderCache.items) > 0 {
|
||||
var oldestKey string
|
||||
var oldestTime time.Time
|
||||
first := true
|
||||
for key, entry := range projectHeaderCache.items {
|
||||
if first || entry.fetchedAt.Before(oldestTime) {
|
||||
oldestKey = key
|
||||
oldestTime = entry.fetchedAt
|
||||
first = false
|
||||
}
|
||||
}
|
||||
if oldestKey == "" {
|
||||
return
|
||||
}
|
||||
projectHeaderCache.total -= projectHeaderCache.items[oldestKey].sizeBytes
|
||||
delete(projectHeaderCache.items, oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
func buildProjectHeaderImage(photoBytes []byte, title string, username string, status string) ([]byte, error) {
|
||||
const (
|
||||
canvasW = 720
|
||||
canvasH = 260
|
||||
)
|
||||
|
||||
srcImg, _, err := image.Decode(bytes.NewReader(photoBytes))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
canvas := image.NewRGBA(image.Rect(0, 0, canvasW, canvasH))
|
||||
drawGradient(canvas, color.RGBA{R: 6, G: 8, B: 16, A: 255}, color.RGBA{R: 18, G: 10, B: 28, A: 255})
|
||||
|
||||
avatarSize := 168
|
||||
avatarX := 24
|
||||
avatarY := (canvasH - avatarSize) / 2
|
||||
|
||||
cropped := cropCenterSquare(srcImg)
|
||||
scaled := image.NewRGBA(image.Rect(0, 0, avatarSize, avatarSize))
|
||||
xdraw.CatmullRom.Scale(scaled, scaled.Bounds(), cropped, cropped.Bounds(), xdraw.Over, nil)
|
||||
|
||||
mask := circleMask(avatarSize)
|
||||
draw.DrawMask(
|
||||
canvas,
|
||||
image.Rect(avatarX, avatarY, avatarX+avatarSize, avatarY+avatarSize),
|
||||
scaled,
|
||||
image.Point{},
|
||||
mask,
|
||||
image.Point{},
|
||||
draw.Over,
|
||||
)
|
||||
|
||||
textColor := image.NewUniform(color.RGBA{R: 245, G: 247, B: 250, A: 255})
|
||||
usernameColor := image.NewUniform(color.RGBA{R: 84, G: 156, B: 255, A: 255})
|
||||
textX := avatarX + avatarSize + 24
|
||||
textMaxWidth := canvasW - textX - 24
|
||||
|
||||
titleSize := fitFontSize(jetBrainsMonoBold, title, textMaxWidth, float64(canvasH)*0.22, 20)
|
||||
titleFace, err := loadFontFace(jetBrainsMonoBold, titleSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer titleFace.Close()
|
||||
|
||||
usernameText := formatUsername(username)
|
||||
statusText, statusColor := statusInfo(status)
|
||||
statusLine := usernameText
|
||||
if statusText != "" {
|
||||
if statusLine != "" {
|
||||
statusLine += " | "
|
||||
}
|
||||
statusLine += statusText
|
||||
}
|
||||
|
||||
statusSize := titleSize * 0.6
|
||||
if statusSize < 14 {
|
||||
statusSize = 14
|
||||
}
|
||||
if statusLine != "" {
|
||||
statusSize = fitFontSize(jetBrainsMonoRegular, statusLine, textMaxWidth, statusSize, 12)
|
||||
}
|
||||
statusFace, err := loadFontFace(jetBrainsMonoRegular, statusSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer statusFace.Close()
|
||||
|
||||
titleMetrics := titleFace.Metrics()
|
||||
statusMetrics := statusFace.Metrics()
|
||||
lineGapRatio := 0.03
|
||||
lineGap := int(float64(canvasH) * lineGapRatio)
|
||||
totalHeight := titleMetrics.Height.Ceil()
|
||||
if statusLine != "" {
|
||||
totalHeight += lineGap + statusMetrics.Height.Ceil()
|
||||
}
|
||||
verticalOffsetRatio := 0.02
|
||||
startY := (canvasH-totalHeight)/2 + titleMetrics.Ascent.Ceil() + int(float64(canvasH)*verticalOffsetRatio)
|
||||
|
||||
drawText(canvas, titleFace, textColor, textX, startY, title)
|
||||
if statusLine != "" {
|
||||
statusY := startY + titleMetrics.Descent.Ceil() + lineGap + statusMetrics.Ascent.Ceil()
|
||||
drawStatusLine(canvas, statusFace, textX, statusY, usernameText, statusText, usernameColor, textColor, statusColor)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
if err := jpeg.Encode(&out, canvas, &jpeg.Options{Quality: 85}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
func cropCenterSquare(img image.Image) image.Image {
|
||||
b := img.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
size := w
|
||||
if h < w {
|
||||
size = h
|
||||
}
|
||||
x0 := b.Min.X + (w-size)/2
|
||||
y0 := b.Min.Y + (h-size)/2
|
||||
cropRect := image.Rect(x0, y0, x0+size, y0+size)
|
||||
|
||||
if sub, ok := img.(interface {
|
||||
SubImage(r image.Rectangle) image.Image
|
||||
}); ok {
|
||||
return sub.SubImage(cropRect)
|
||||
}
|
||||
|
||||
dst := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||
draw.Draw(dst, dst.Bounds(), img, cropRect.Min, draw.Src)
|
||||
return dst
|
||||
}
|
||||
|
||||
func circleMask(diameter int) *image.Alpha {
|
||||
mask := image.NewAlpha(image.Rect(0, 0, diameter, diameter))
|
||||
r := float64(diameter) / 2
|
||||
cx := r
|
||||
cy := r
|
||||
for y := 0; y < diameter; y++ {
|
||||
for x := 0; x < diameter; x++ {
|
||||
dx := float64(x) + 0.5 - cx
|
||||
dy := float64(y) + 0.5 - cy
|
||||
if dx*dx+dy*dy <= r*r {
|
||||
mask.SetAlpha(x, y, color.Alpha{A: 255})
|
||||
}
|
||||
}
|
||||
}
|
||||
return mask
|
||||
}
|
||||
func drawGradient(img *image.RGBA, top, bottom color.RGBA) {
|
||||
b := img.Bounds()
|
||||
h := b.Dy()
|
||||
w := b.Dx()
|
||||
for y := 0; y < h; y++ {
|
||||
t := float64(y) / float64(h-1)
|
||||
r := uint8(float64(top.R)*(1-t) + float64(bottom.R)*t)
|
||||
g := uint8(float64(top.G)*(1-t) + float64(bottom.G)*t)
|
||||
bb := uint8(float64(top.B)*(1-t) + float64(bottom.B)*t)
|
||||
for x := 0; x < w; x++ {
|
||||
img.Set(x, y, color.RGBA{R: r, G: g, B: bb, A: 255})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadFontFace(fontData []byte, size float64) (font.Face, error) {
|
||||
ft, err := opentype.Parse(fontData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return opentype.NewFace(ft, &opentype.FaceOptions{
|
||||
Size: size,
|
||||
DPI: 72,
|
||||
Hinting: font.HintingFull,
|
||||
})
|
||||
}
|
||||
|
||||
func fitFontSize(fontData []byte, text string, maxWidth int, startSize float64, minSize float64) float64 {
|
||||
size := startSize
|
||||
for size >= minSize {
|
||||
face, err := loadFontFace(fontData, size)
|
||||
if err != nil {
|
||||
return size
|
||||
}
|
||||
width := font.MeasureString(face, text).Ceil()
|
||||
face.Close()
|
||||
if width <= maxWidth {
|
||||
return size
|
||||
}
|
||||
size -= 2
|
||||
}
|
||||
return minSize
|
||||
}
|
||||
|
||||
func drawText(dst *image.RGBA, face font.Face, src image.Image, x int, y int, text string) {
|
||||
d := &font.Drawer{
|
||||
Dst: dst,
|
||||
Src: src,
|
||||
Face: face,
|
||||
Dot: fixed.P(x, y),
|
||||
}
|
||||
d.DrawString(text)
|
||||
}
|
||||
|
||||
func formatUsername(username string) string {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(username, "@") {
|
||||
return username
|
||||
}
|
||||
return "@" + username
|
||||
}
|
||||
|
||||
func statusInfo(status string) (string, color.RGBA) {
|
||||
switch status {
|
||||
case "active":
|
||||
return "Активный", color.RGBA{R: 66, G: 211, B: 114, A: 255}
|
||||
case "inactive":
|
||||
return "Неактивен", color.RGBA{R: 160, G: 170, B: 180, A: 255}
|
||||
case "archived":
|
||||
return "Архивный", color.RGBA{R: 180, G: 180, B: 180, A: 255}
|
||||
case "paused":
|
||||
return "Приостановлен", color.RGBA{R: 245, G: 179, B: 66, A: 255}
|
||||
default:
|
||||
if strings.TrimSpace(status) == "" {
|
||||
return "", color.RGBA{}
|
||||
}
|
||||
return status, color.RGBA{R: 160, G: 170, B: 180, A: 255}
|
||||
}
|
||||
}
|
||||
|
||||
func drawStatusLine(dst *image.RGBA, face font.Face, x int, y int, usernameText string, statusText string, usernameColor image.Image, textColor image.Image, statusColor color.RGBA) {
|
||||
drawX := x
|
||||
if usernameText != "" {
|
||||
drawText(dst, face, usernameColor, drawX, y, usernameText)
|
||||
drawX += font.MeasureString(face, usernameText).Ceil()
|
||||
}
|
||||
if statusText != "" {
|
||||
separator := " | "
|
||||
if usernameText != "" {
|
||||
drawText(dst, face, textColor, drawX, y, separator)
|
||||
drawX += font.MeasureString(face, separator).Ceil()
|
||||
}
|
||||
statusSymbol := "●"
|
||||
statusColorImg := image.NewUniform(statusColor)
|
||||
drawText(dst, face, statusColorImg, drawX, y, statusSymbol)
|
||||
drawX += font.MeasureString(face, statusSymbol).Ceil() + 6
|
||||
drawText(dst, face, textColor, drawX, y, statusText)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,641 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/backend"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type PurchaseChannelInput struct {
|
||||
ChannelID string
|
||||
Username string
|
||||
Title string
|
||||
InviteLink string
|
||||
PlannedCost *float64
|
||||
Comment *string
|
||||
}
|
||||
|
||||
type SelectChannelsForPurchase struct {
|
||||
ProjectID string
|
||||
ProjectTitle string
|
||||
ProjectTelegramID int64
|
||||
ProjectUsername string
|
||||
ProjectStatus string
|
||||
ProjectDefaultLinkType string
|
||||
CreativeID string
|
||||
CreativeTitle string
|
||||
Channels []PurchaseChannelInput
|
||||
CurrentPage int
|
||||
InvalidUsernames []string
|
||||
Duplicates []string // дубликаты каналов
|
||||
ParsingErrors []ParseError // ошибки парсинга с предложениями
|
||||
OptionalDetailsState *PurchaseOptionalDetails // сохранённое состояние деталей
|
||||
BackState bot.State
|
||||
}
|
||||
|
||||
// ParseError представляет ошибку парсинга с предложением исправления
|
||||
type ParseError struct {
|
||||
Input string
|
||||
Suggestion string
|
||||
}
|
||||
|
||||
func (s *SelectChannelsForPurchase) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
s.renderChannelSelection(b, mode)
|
||||
}
|
||||
|
||||
func (s *SelectChannelsForPurchase) renderChannelSelection(b *bot.Bot, mode bot.RenderMode) {
|
||||
text := "<b>Создание закупа</b>\n\n"
|
||||
text += "<b>Шаг 2/2:</b> Добавление каналов\n\n"
|
||||
|
||||
var buttons [][]echotron.InlineKeyboardButton
|
||||
|
||||
if len(s.Channels) == 0 {
|
||||
text += `Добавьте каналы для размещения рекламы
|
||||
|
||||
Отправьте username (без @) или invite link приватного канала
|
||||
Например: <code>channel_name</code> или <code>https://t.me/+abcdef</code>
|
||||
|
||||
После добавления всех каналов нажмите <b>Далее</b>`
|
||||
} else {
|
||||
text += fmt.Sprintf("Добавлено каналов: <b>%d</b>\n\n", len(s.Channels))
|
||||
|
||||
for i, ch := range s.Channels {
|
||||
channelText := fmt.Sprintf(" %d. %s", i+1, channelLabel(ch))
|
||||
if ch.PlannedCost != nil {
|
||||
channelText += fmt.Sprintf(" — %.0f₽", *ch.PlannedCost)
|
||||
}
|
||||
text += channelText + "\n"
|
||||
}
|
||||
text += "\n"
|
||||
|
||||
const channelsPerPage = 6
|
||||
if s.CurrentPage*channelsPerPage >= len(s.Channels) {
|
||||
s.CurrentPage = 0
|
||||
}
|
||||
start, end := ui.GetPageBounds(s.CurrentPage, channelsPerPage, len(s.Channels))
|
||||
|
||||
var slots []echotron.InlineKeyboardButton
|
||||
for i := start; i < end; i++ {
|
||||
ch := s.Channels[i]
|
||||
buttonText := channelLabel(ch)
|
||||
if ch.PlannedCost != nil {
|
||||
buttonText = fmt.Sprintf("%s — %.0f₽", channelLabel(ch), *ch.PlannedCost)
|
||||
}
|
||||
slots = append(slots, Button("✖ "+buttonText, fmt.Sprintf("remove_channel:%d", i)))
|
||||
}
|
||||
|
||||
if len(s.Channels) > channelsPerPage {
|
||||
for len(slots) < channelsPerPage {
|
||||
slots = append(slots, Button(" ", "empty"))
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < len(slots); i += 2 {
|
||||
row := []echotron.InlineKeyboardButton{slots[i]}
|
||||
if i+1 < len(slots) {
|
||||
row = append(row, slots[i+1])
|
||||
} else {
|
||||
row = append(row, Button(" ", "empty"))
|
||||
}
|
||||
buttons = append(buttons, row)
|
||||
}
|
||||
|
||||
pages := ui.CalculatePages(len(s.Channels), channelsPerPage)
|
||||
navRow := ui.BuildNavigationRow(ui.PaginationConfig{
|
||||
CurrentPage: s.CurrentPage,
|
||||
TotalPages: pages,
|
||||
})
|
||||
if navRow != nil {
|
||||
buttons = append(buttons, navRow)
|
||||
}
|
||||
|
||||
text += "\nДобавьте еще каналы или создайте закуп"
|
||||
|
||||
// Кнопка создания закупа (доступна только если есть каналы)
|
||||
}
|
||||
|
||||
if len(s.InvalidUsernames) > 0 {
|
||||
text += fmt.Sprintf("\n\n⚠️ Пропущены: %s", strings.Join(s.InvalidUsernames, ", "))
|
||||
s.InvalidUsernames = nil
|
||||
}
|
||||
|
||||
// Показываем дубликаты
|
||||
if len(s.Duplicates) > 0 {
|
||||
text += fmt.Sprintf("\n\n⏭️ Пропущены (дубликаты): %s", strings.Join(s.Duplicates, ", "))
|
||||
s.Duplicates = nil
|
||||
}
|
||||
|
||||
// Показываем ошибки с предложениями
|
||||
if len(s.ParsingErrors) > 0 {
|
||||
text += "\n\n❌ Ошибки форматирования:\n"
|
||||
for i, err := range s.ParsingErrors {
|
||||
if i < 3 { // Показываем максимум 3 ошибки
|
||||
if err.Suggestion != "" {
|
||||
text += fmt.Sprintf("• %s → возможно: %s\n", err.Input, err.Suggestion)
|
||||
} else {
|
||||
text += fmt.Sprintf("• %s\n", err.Input)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(s.ParsingErrors) > 3 {
|
||||
text += fmt.Sprintf("• ... и еще %d\n", len(s.ParsingErrors)-3)
|
||||
}
|
||||
s.ParsingErrors = nil
|
||||
}
|
||||
|
||||
// Кнопки навигации
|
||||
if len(s.Channels) > 0 {
|
||||
buttons = append(buttons, Row(
|
||||
Button("← Назад", "back"),
|
||||
Button("→ Далее", "next_step"),
|
||||
))
|
||||
} else {
|
||||
buttons = append(buttons, Row(
|
||||
Button("← Назад", "back"),
|
||||
))
|
||||
}
|
||||
|
||||
keyboard := Keyboard(buttons...)
|
||||
b.Render(text, keyboard, mode)
|
||||
}
|
||||
|
||||
func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
|
||||
return
|
||||
}
|
||||
|
||||
data := u.CallbackQuery.Data
|
||||
|
||||
switch data {
|
||||
case "back":
|
||||
// Возвращаемся к выбору креатива
|
||||
b.SetState(&AddPurchase{
|
||||
ProjectID: s.ProjectID,
|
||||
ProjectTitle: s.ProjectTitle,
|
||||
ProjectTelegramID: s.ProjectTelegramID,
|
||||
ProjectUsername: s.ProjectUsername,
|
||||
ProjectStatus: s.ProjectStatus,
|
||||
CreativeID: s.CreativeID,
|
||||
CreativeTitle: s.CreativeTitle,
|
||||
ActivePicker: "",
|
||||
BackState: s.BackState,
|
||||
}, bot.EditMessage)
|
||||
|
||||
case "next_step":
|
||||
if len(s.Channels) == 0 {
|
||||
b.Edit("❌ Добавьте хотя бы один канал", Keyboard(
|
||||
Row(Button("← Назад", "back_to_select")),
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// Проверяем, что все каналы резолвлены (имеют ChannelID)
|
||||
var unresolved []string
|
||||
for _, ch := range s.Channels {
|
||||
if ch.ChannelID == "" {
|
||||
label := channelLabel(ch)
|
||||
unresolved = append(unresolved, label)
|
||||
}
|
||||
}
|
||||
|
||||
if len(unresolved) > 0 {
|
||||
text := "❌ Некоторые каналы не были найдены:\n\n"
|
||||
for _, label := range unresolved {
|
||||
text += fmt.Sprintf("• %s\n", label)
|
||||
}
|
||||
text += "\nУдалите их из списка и попробуйте снова"
|
||||
|
||||
b.Edit(text, Keyboard(
|
||||
Row(Button("← Назад", "back_to_select")),
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// Каналы уже резолвлены в HandleMessage, переходим к следующему шагу
|
||||
// Если есть сохранённое состояние - используем его, иначе создаём новое
|
||||
var optionalDetails *PurchaseOptionalDetails
|
||||
if s.OptionalDetailsState != nil {
|
||||
optionalDetails = s.OptionalDetailsState
|
||||
// Обновляем каналы на случай если они изменились
|
||||
optionalDetails.Channels = s.Channels
|
||||
} else {
|
||||
optionalDetails = &PurchaseOptionalDetails{
|
||||
ProjectID: s.ProjectID,
|
||||
ProjectTitle: s.ProjectTitle,
|
||||
ProjectDefaultLinkType: s.ProjectDefaultLinkType,
|
||||
CreativeID: s.CreativeID,
|
||||
CreativeTitle: s.CreativeTitle,
|
||||
Channels: s.Channels,
|
||||
BackState: s,
|
||||
}
|
||||
}
|
||||
b.SetState(optionalDetails, bot.EditMessage)
|
||||
|
||||
case "back_to_select":
|
||||
s.Enter(b, bot.EditMessage)
|
||||
|
||||
case "prev":
|
||||
if s.CurrentPage > 0 {
|
||||
s.CurrentPage--
|
||||
}
|
||||
s.Enter(b, bot.EditMessage)
|
||||
|
||||
case "next":
|
||||
s.CurrentPage++
|
||||
s.Enter(b, bot.EditMessage)
|
||||
|
||||
default:
|
||||
if len(data) > 15 && data[:15] == "remove_channel:" {
|
||||
var index int
|
||||
if _, err := fmt.Sscanf(data[15:], "%d", &index); err == nil {
|
||||
if index >= 0 && index < len(s.Channels) {
|
||||
s.Channels = append(s.Channels[:index], s.Channels[index+1:]...)
|
||||
if s.CurrentPage > 0 {
|
||||
channelsPerPage := 6
|
||||
pages := ui.CalculatePages(len(s.Channels), channelsPerPage)
|
||||
if s.CurrentPage >= pages {
|
||||
s.CurrentPage = pages - 1
|
||||
}
|
||||
}
|
||||
s.Enter(b, bot.EditMessage)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
s.Enter(b, bot.NewMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SelectChannelsForPurchase) createPurchase(b *bot.Bot, jwt string) {
|
||||
// Преобразуем каналы в формат API
|
||||
var apiChannels []backend.CreatePlacementChannelInput
|
||||
for _, ch := range s.Channels {
|
||||
var details *backend.PlacementDetails
|
||||
if ch.PlannedCost != nil {
|
||||
cost := backend.CostInfo{
|
||||
Type: "fixed",
|
||||
Value: *ch.PlannedCost,
|
||||
}
|
||||
details = &backend.PlacementDetails{
|
||||
Cost: &cost,
|
||||
}
|
||||
}
|
||||
apiChannels = append(apiChannels, backend.CreatePlacementChannelInput{
|
||||
ChannelID: ch.ChannelID,
|
||||
Comment: ch.Comment,
|
||||
Details: details,
|
||||
})
|
||||
}
|
||||
|
||||
input := backend.CreatePlacementsInput{
|
||||
CreativeID: &s.CreativeID,
|
||||
Channels: apiChannels,
|
||||
}
|
||||
|
||||
placements, err := b.Backend.CreatePlacements(context.Background(), jwt, b.Session.WorkspaceID, s.ProjectID, input)
|
||||
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to create placements")
|
||||
b.Edit("❌ Не удалось создать размещения\n\nВозможные причины:\n• Один из каналов не найден\n• Ошибка сервера", Keyboard(
|
||||
Row(Button("← Назад", "back_to_select")),
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
if len(placements.Placements) == 0 {
|
||||
b.Edit("❌ Размещения не созданы", Keyboard(
|
||||
Row(Button("← Назад", "back_to_select")),
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
b.SetState(&PlacementDetails{
|
||||
ProjectID: s.ProjectID,
|
||||
PlacementID: placements.Placements[0].ID,
|
||||
BackState: s.BackState,
|
||||
}, bot.EditMessage)
|
||||
}
|
||||
|
||||
func (s *SelectChannelsForPurchase) HandleMessage(b *bot.Bot, u *echotron.Update) {
|
||||
if u.Message == nil || u.Message.Text == "" {
|
||||
return
|
||||
}
|
||||
|
||||
raw := strings.TrimSpace(u.Message.Text)
|
||||
if raw == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Проверяем JWT
|
||||
jwt := b.Session.JWT
|
||||
if jwt == "" {
|
||||
log.Error().Msg("JWT is empty in session")
|
||||
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", Keyboard())
|
||||
return
|
||||
}
|
||||
|
||||
// Разбиваем ввод на токены
|
||||
tokens := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == ' ' || r == '\n' || r == '\t' || r == ',' || r == ';'
|
||||
})
|
||||
|
||||
added := 0
|
||||
s.Duplicates = nil
|
||||
s.InvalidUsernames = nil
|
||||
s.ParsingErrors = nil
|
||||
|
||||
tokensLoop:
|
||||
for _, token := range tokens {
|
||||
entry := strings.TrimSpace(token)
|
||||
entry = strings.Trim(entry, ",;")
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Используем новый парсер
|
||||
parsed := ParseChannelInput(entry)
|
||||
|
||||
if !parsed.Valid {
|
||||
// Пробуем предложить исправление
|
||||
if suggestion, ok := SuggestFix(entry); ok {
|
||||
s.ParsingErrors = append(s.ParsingErrors, ParseError{
|
||||
Input: entry,
|
||||
Suggestion: suggestion,
|
||||
})
|
||||
} else {
|
||||
s.InvalidUsernames = append(s.InvalidUsernames, entry)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Проверяем дубликаты по username/invite link
|
||||
if IsDuplicate(parsed, s.Channels) {
|
||||
label := FormatChannelLabel(parsed)
|
||||
s.Duplicates = append(s.Duplicates, label)
|
||||
continue
|
||||
}
|
||||
|
||||
// Сразу резолвим канал через бэкенд
|
||||
channel, err := s.resolveSingleChannel(b, jwt, parsed)
|
||||
if err != nil {
|
||||
// Канал не найден или ошибка
|
||||
s.InvalidUsernames = append(s.InvalidUsernames, entry)
|
||||
log.Error().Err(err).Str("input", entry).Msg("Failed to resolve channel")
|
||||
continue
|
||||
}
|
||||
|
||||
// Проверяем дубликаты по ID канала (после резолва)
|
||||
for _, ch := range s.Channels {
|
||||
if ch.ChannelID != "" && ch.ChannelID == channel.ID {
|
||||
var title, username string
|
||||
if channel.Title != nil {
|
||||
title = *channel.Title
|
||||
}
|
||||
if channel.Username != nil {
|
||||
username = *channel.Username
|
||||
}
|
||||
label := channelLabelByID(&channel.ID, &title, &username)
|
||||
s.Duplicates = append(s.Duplicates, label)
|
||||
continue tokensLoop // <-- Выход из внешнего цикла, а не внутреннего!
|
||||
}
|
||||
|
||||
// Дополнительная проверка по username (case-insensitive)
|
||||
// На случай, если канал еще не резолвлен или ID отличается
|
||||
if channel.Username != nil && ch.Username != "" {
|
||||
if strings.EqualFold(ch.Username, *channel.Username) {
|
||||
var title string
|
||||
if channel.Title != nil {
|
||||
title = *channel.Title
|
||||
}
|
||||
label := channelLabelByID(&channel.ID, &title, channel.Username)
|
||||
s.Duplicates = append(s.Duplicates, label)
|
||||
continue tokensLoop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Добавляем канал с полными данными
|
||||
var title string
|
||||
if channel.Title != nil {
|
||||
title = *channel.Title
|
||||
}
|
||||
s.Channels = append(s.Channels, PurchaseChannelInput{
|
||||
ChannelID: channel.ID,
|
||||
Username: parsed.Username,
|
||||
Title: title,
|
||||
InviteLink: parsed.InviteLink,
|
||||
})
|
||||
added++
|
||||
}
|
||||
|
||||
// Если ничего не добавлено и нет ошибок - не обновляем экран
|
||||
if added == 0 && len(s.InvalidUsernames) == 0 && len(s.Duplicates) == 0 && len(s.ParsingErrors) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Обновляем экран
|
||||
s.Enter(b, bot.NewMessage)
|
||||
}
|
||||
|
||||
// resolveSingleChannel резолвит один канал через бэкенд
|
||||
func (s *SelectChannelsForPurchase) resolveSingleChannel(b *bot.Bot, jwt string, parsed ChannelInput) (*backend.Channel, error) {
|
||||
var input backend.CreateChannelInput
|
||||
if parsed.InviteLink != "" {
|
||||
link := parsed.InviteLink
|
||||
input = backend.CreateChannelInput{InviteLink: &link}
|
||||
} else {
|
||||
username := parsed.Username
|
||||
input = backend.CreateChannelInput{Username: &username}
|
||||
}
|
||||
|
||||
resp, err := b.Backend.CreateChannels(context.Background(), jwt, backend.CreateChannelsInput{
|
||||
Channels: []backend.CreateChannelInput{input},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(resp.Results) == 0 {
|
||||
return nil, fmt.Errorf("no response from backend")
|
||||
}
|
||||
|
||||
result := resp.Results[0]
|
||||
if result.Status == "failed" || result.Channel == nil || result.Channel.ID == "" {
|
||||
errMsg := "channel not found"
|
||||
if result.Error != nil {
|
||||
errMsg = *result.Error
|
||||
}
|
||||
return nil, fmt.Errorf(errMsg)
|
||||
}
|
||||
|
||||
// Если пришел username из ответа, обновляем его
|
||||
if result.Channel.Username != nil && *result.Channel.Username != "" {
|
||||
parsed.Username = *result.Channel.Username
|
||||
}
|
||||
|
||||
return result.Channel, nil
|
||||
}
|
||||
|
||||
// channelLabelByID формирует метку канала по ID
|
||||
func channelLabelByID(id, title, username *string) string {
|
||||
if title != nil && *title != "" {
|
||||
return *title
|
||||
}
|
||||
if username != nil && *username != "" {
|
||||
return "@" + *username
|
||||
}
|
||||
if id != nil && *id != "" {
|
||||
return *id
|
||||
}
|
||||
return "канал"
|
||||
}
|
||||
|
||||
func (s *SelectChannelsForPurchase) resolveChannels(b *bot.Bot, jwt string) []string {
|
||||
inputs := make([]backend.CreateChannelInput, 0, len(s.Channels))
|
||||
for _, ch := range s.Channels {
|
||||
if ch.InviteLink != "" {
|
||||
link := ch.InviteLink
|
||||
inputs = append(inputs, backend.CreateChannelInput{InviteLink: &link})
|
||||
} else {
|
||||
username := ch.Username
|
||||
inputs = append(inputs, backend.CreateChannelInput{Username: &username})
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := b.Backend.CreateChannels(context.Background(), jwt, backend.CreateChannelsInput{
|
||||
Channels: inputs,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to create channels")
|
||||
return []string{"ошибка сервера"}
|
||||
}
|
||||
|
||||
var failed []string
|
||||
for _, result := range resp.Results {
|
||||
if result.Status == "failed" || result.Channel == nil || result.Channel.ID == "" {
|
||||
if result.Index >= 0 && result.Index < len(s.Channels) {
|
||||
failed = append(failed, channelLabel(s.Channels[result.Index]))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if result.Index < 0 || result.Index >= len(s.Channels) {
|
||||
continue
|
||||
}
|
||||
ch := &s.Channels[result.Index]
|
||||
ch.ChannelID = result.Channel.ID
|
||||
if result.Channel.Username != nil {
|
||||
ch.Username = *result.Channel.Username
|
||||
}
|
||||
if result.Channel.Title != nil {
|
||||
ch.Title = *result.Channel.Title
|
||||
}
|
||||
}
|
||||
|
||||
return failed
|
||||
}
|
||||
|
||||
func (s *SelectChannelsForPurchase) Handle(b *bot.Bot, u *echotron.Update) {
|
||||
// Обрабатываем callback после успешного создания
|
||||
if u.CallbackQuery != nil && u.CallbackQuery.Data == "done" {
|
||||
// Возвращаемся к списку закупов
|
||||
if s.BackState != nil {
|
||||
b.SetState(s.BackState, bot.EditMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SelectChannelsForPurchase) Exit() {}
|
||||
|
||||
func channelLabel(ch PurchaseChannelInput) string {
|
||||
if ch.Username != "" {
|
||||
return "@" + ch.Username
|
||||
}
|
||||
if ch.Title != "" {
|
||||
return ch.Title
|
||||
}
|
||||
if ch.InviteLink != "" {
|
||||
return formatInviteLabel(ch.InviteLink)
|
||||
}
|
||||
if ch.ChannelID != "" {
|
||||
return ch.ChannelID
|
||||
}
|
||||
return "канал"
|
||||
}
|
||||
|
||||
func channelKey(ch PurchaseChannelInput) string {
|
||||
if ch.ChannelID != "" {
|
||||
return ch.ChannelID
|
||||
}
|
||||
if ch.Username != "" {
|
||||
return ch.Username
|
||||
}
|
||||
if ch.InviteLink != "" {
|
||||
return ch.InviteLink
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func channelLabelByKey(channels []PurchaseChannelInput, key string) string {
|
||||
for _, ch := range channels {
|
||||
if channelKey(ch) == key {
|
||||
return channelLabel(ch)
|
||||
}
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func isInviteLink(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if strings.Contains(value, "t.me/") || strings.Contains(value, "telegram.me/") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(value, "tg://") || strings.HasPrefix(value, "tg:") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func formatInviteLabel(inviteLink string) string {
|
||||
link := strings.TrimSpace(inviteLink)
|
||||
if link == "" {
|
||||
return "приватный канал"
|
||||
}
|
||||
|
||||
code := link
|
||||
if strings.HasPrefix(code, "tg://") || strings.HasPrefix(code, "tg:") {
|
||||
if idx := strings.Index(code, "invite="); idx != -1 {
|
||||
code = code[idx+len("invite="):]
|
||||
if end := strings.IndexAny(code, "&?#"); end != -1 {
|
||||
code = code[:end]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
code = strings.TrimPrefix(code, "https://")
|
||||
code = strings.TrimPrefix(code, "http://")
|
||||
code = strings.TrimPrefix(code, "t.me/")
|
||||
code = strings.TrimPrefix(code, "telegram.me/")
|
||||
code = strings.TrimPrefix(code, "joinchat/")
|
||||
code = strings.TrimPrefix(code, "+")
|
||||
if idx := strings.LastIndex(code, "/"); idx != -1 {
|
||||
code = code[idx+1:]
|
||||
}
|
||||
if end := strings.IndexAny(code, "?#"); end != -1 {
|
||||
code = code[:end]
|
||||
}
|
||||
}
|
||||
|
||||
code = strings.Trim(code, "/+ ")
|
||||
if code == "" {
|
||||
return "приватный канал"
|
||||
}
|
||||
if len(code) > 10 {
|
||||
code = code[:6] + "..." + code[len(code)-2:]
|
||||
}
|
||||
return "приватный: " + code
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type SelectWorkspace struct {
|
||||
ChannelID string
|
||||
BackState bot.State
|
||||
CurrentPage int
|
||||
}
|
||||
|
||||
const msgChooseWorkspace = `
|
||||
📁 Выбор workspace для канала
|
||||
В какой workspace добавить канал?
|
||||
`
|
||||
|
||||
const msgSuccessChooseWorkspace = `
|
||||
✅ Канал успешно добавлен!
|
||||
📊 Проект ID: %s
|
||||
📝 Название: %s
|
||||
🏷 Статус: %s
|
||||
`
|
||||
|
||||
func (s *SelectWorkspace) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
workspaces, err := b.Backend.GetWorkspaces(context.Background(), b.Session.JWT)
|
||||
if err != nil {
|
||||
b.SendNew("❌ Не удалось загрузить список workspace'ов", Keyboard())
|
||||
return
|
||||
}
|
||||
|
||||
if len(workspaces) == 0 {
|
||||
kb := Keyboard(Row(Button("Главное меню", "back")))
|
||||
b.SendNew("У вас пока нет workspace'ов", kb)
|
||||
return
|
||||
}
|
||||
|
||||
// Создаем кнопки для всех workspace'ов
|
||||
var allButtons []echotron.InlineKeyboardButton
|
||||
for _, ws := range workspaces {
|
||||
allButtons = append(allButtons, Button(ws.Name, fmt.Sprintf("select_workspace:%s", ws.ID)))
|
||||
}
|
||||
|
||||
// Используем компонент пагинации для автоматической раскладки
|
||||
const workspacesPerPage = 6
|
||||
var buttons [][]echotron.InlineKeyboardButton
|
||||
|
||||
// Раскладываем workspace'ы в grid (2 в ряд) с пагинацией
|
||||
elementRows := ui.BuildElementRows(ui.ElementLayoutConfig{
|
||||
CurrentPage: s.CurrentPage,
|
||||
ItemsPerPage: workspacesPerPage,
|
||||
ItemsPerRow: 2,
|
||||
}, allButtons)
|
||||
buttons = append(buttons, elementRows...)
|
||||
|
||||
// Добавляем навигацию (стрелки появятся только если страниц > 1)
|
||||
if navRow := ui.BuildNavigationRow(ui.PaginationConfig{
|
||||
CurrentPage: s.CurrentPage,
|
||||
TotalItems: len(allButtons),
|
||||
ItemsPerPage: workspacesPerPage,
|
||||
}); navRow != nil {
|
||||
buttons = append(buttons, navRow)
|
||||
}
|
||||
|
||||
// Кнопка отмены
|
||||
buttons = append(buttons, Row(Button("Отмена", "cancel")))
|
||||
|
||||
kb := Keyboard(buttons...)
|
||||
b.Render(msgChooseWorkspace, kb, mode)
|
||||
}
|
||||
|
||||
func (s *SelectWorkspace) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
|
||||
return
|
||||
}
|
||||
|
||||
data := u.CallbackQuery.Data
|
||||
|
||||
switch {
|
||||
case data == "prev":
|
||||
if s.CurrentPage > 0 {
|
||||
s.CurrentPage--
|
||||
}
|
||||
s.Enter(b, bot.EditMessage)
|
||||
|
||||
case data == "next":
|
||||
s.CurrentPage++
|
||||
s.Enter(b, bot.EditMessage)
|
||||
|
||||
case data == "cancel", data == "back":
|
||||
if s.BackState != nil {
|
||||
b.SetState(s.BackState, bot.NewMessage)
|
||||
}
|
||||
|
||||
case strings.HasPrefix(data, "select_workspace:"):
|
||||
parts := strings.Split(data, ":")
|
||||
if len(parts) != 2 {
|
||||
log.Error().Str("callback_data", data).Msg("Invalid callback data format")
|
||||
return
|
||||
}
|
||||
|
||||
workspaceID := parts[1]
|
||||
|
||||
project, err := b.Backend.AttachChannelToWorkspace(
|
||||
context.Background(),
|
||||
s.ChannelID,
|
||||
workspaceID,
|
||||
b.ChatID,
|
||||
)
|
||||
if err != nil {
|
||||
b.Edit("❌ Не удалось добавить канал в workspace", Keyboard(
|
||||
Row(Button("← Назад", "back_to_select")),
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
successText := fmt.Sprintf(msgSuccessChooseWorkspace, project.ID, project.Title, project.Status)
|
||||
kb := Keyboard(
|
||||
Row(Button("Мои проекты", "my_projects")),
|
||||
Row(Button("Главное меню", "main_menu")),
|
||||
)
|
||||
|
||||
b.Edit(successText, kb)
|
||||
|
||||
case data == "back_to_select":
|
||||
s.Enter(b, bot.EditMessage)
|
||||
|
||||
case data == "my_projects":
|
||||
b.SetState(&MyProjects{}, bot.NewMessage)
|
||||
|
||||
case data == "main_menu":
|
||||
b.SetState(&MainMenu{}, bot.NewMessage)
|
||||
|
||||
default:
|
||||
s.Enter(b, bot.NewMessage)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *SelectWorkspace) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *SelectWorkspace) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *SelectWorkspace) Exit() {}
|
||||
@@ -0,0 +1,21 @@
|
||||
package ui
|
||||
|
||||
import "github.com/NicoNex/echotron/v3"
|
||||
|
||||
// BuildGrid builds button rows from items using a callback builder.
|
||||
func BuildGrid[T any](items []T, itemsPerRow, itemsPerPage, totalPages int, itemFn func(T) (title string, callback string)) [][]echotron.InlineKeyboardButton {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
buttons := make([]echotron.InlineKeyboardButton, 0, len(items))
|
||||
for _, item := range items {
|
||||
title, callback := itemFn(item)
|
||||
buttons = append(buttons, echotron.InlineKeyboardButton{
|
||||
Text: title,
|
||||
CallbackData: callback,
|
||||
})
|
||||
}
|
||||
|
||||
return BuildPageRows(buttons, itemsPerRow, itemsPerPage, totalPages)
|
||||
}
|
||||
@@ -0,0 +1,720 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
|
||||
"github.com/olebedev/when"
|
||||
"github.com/olebedev/when/rules/common"
|
||||
"github.com/olebedev/when/rules/ru"
|
||||
)
|
||||
|
||||
const (
|
||||
pickerViewCalendar = "calendar"
|
||||
pickerViewMonths = "months"
|
||||
pickerViewTimeCombined = "time_combined"
|
||||
pickerViewConfirm = "confirm"
|
||||
dtpCallbackPrefix = "dtp:"
|
||||
)
|
||||
|
||||
var MskLocation = time.FixedZone("MSK", 3*60*60)
|
||||
|
||||
type DateTimePickerConfig struct {
|
||||
Title string
|
||||
Key string
|
||||
IncludeTime bool
|
||||
AllowPast bool
|
||||
Selected *time.Time
|
||||
BackState bot.State
|
||||
}
|
||||
|
||||
type DateTimeSelectionTarget interface {
|
||||
SetDateTimeSelection(key string, value time.Time)
|
||||
}
|
||||
|
||||
type DateTimePicker struct {
|
||||
Title string
|
||||
Key string
|
||||
IncludeTime bool
|
||||
AllowPast bool
|
||||
BackState bot.State
|
||||
|
||||
view string
|
||||
year int
|
||||
month time.Month
|
||||
selectedDate *time.Time
|
||||
selectedHour *int
|
||||
selectedMinute *int
|
||||
}
|
||||
|
||||
func NewDateTimePicker(cfg DateTimePickerConfig) *DateTimePicker {
|
||||
now := time.Now().In(MskLocation)
|
||||
year := now.Year()
|
||||
month := now.Month()
|
||||
var selected *time.Time
|
||||
|
||||
if cfg.Selected != nil {
|
||||
value := cfg.Selected.In(MskLocation)
|
||||
selected = &value
|
||||
year = value.Year()
|
||||
month = value.Month()
|
||||
}
|
||||
|
||||
return &DateTimePicker{
|
||||
Title: cfg.Title,
|
||||
Key: cfg.Key,
|
||||
IncludeTime: cfg.IncludeTime,
|
||||
AllowPast: cfg.AllowPast,
|
||||
BackState: cfg.BackState,
|
||||
view: pickerViewCalendar,
|
||||
year: year,
|
||||
month: month,
|
||||
selectedDate: selected,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
text := fmt.Sprintf("<b>%s</b>\n\n", s.Title)
|
||||
text += fmt.Sprintf("%s\n\n", s.formatSelectedDateTime())
|
||||
switch s.view {
|
||||
case pickerViewCalendar:
|
||||
text += s.renderCalendarTitle()
|
||||
b.Render(text, s.calendarKeyboard(), mode)
|
||||
case pickerViewMonths:
|
||||
text += "Выберите месяц\n\n"
|
||||
b.Render(text, s.monthsKeyboard(), mode)
|
||||
case pickerViewTimeCombined:
|
||||
b.Render(text, s.timeCombinedKeyboard(), mode)
|
||||
case pickerViewConfirm:
|
||||
text += s.renderSelectedDateTime()
|
||||
b.Render(text, s.confirmKeyboard(), mode)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
|
||||
return
|
||||
}
|
||||
|
||||
data := u.CallbackQuery.Data
|
||||
if data == "back" {
|
||||
s.handleBack(b)
|
||||
return
|
||||
}
|
||||
if data == "cancel" {
|
||||
if s.BackState != nil {
|
||||
b.SetState(s.BackState, bot.EditMessage)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(data, dtpCallbackPrefix) {
|
||||
s.Enter(b, bot.EditMessage)
|
||||
return
|
||||
}
|
||||
|
||||
payload := strings.TrimPrefix(data, dtpCallbackPrefix)
|
||||
switch {
|
||||
case payload == "month_prev":
|
||||
s.prevMonth()
|
||||
case payload == "month_next":
|
||||
s.nextMonth()
|
||||
case payload == "open_months":
|
||||
s.view = pickerViewMonths
|
||||
case payload == "year_prev":
|
||||
s.year--
|
||||
case payload == "year_next":
|
||||
s.year++
|
||||
case strings.HasPrefix(payload, "month:"):
|
||||
month := parseInt(strings.TrimPrefix(payload, "month:"))
|
||||
if month >= 1 && month <= 12 {
|
||||
s.month = time.Month(month)
|
||||
s.view = pickerViewCalendar
|
||||
}
|
||||
case strings.HasPrefix(payload, "day:"):
|
||||
s.handleDaySelect(payload, b)
|
||||
return // confirmSelection уже делает Enter
|
||||
case strings.HasPrefix(payload, "hour:"):
|
||||
s.handleHourSelect(payload)
|
||||
case strings.HasPrefix(payload, "min:"):
|
||||
s.handleMinuteSelect(payload)
|
||||
case payload == "confirm":
|
||||
s.confirmSelection(b)
|
||||
return
|
||||
}
|
||||
|
||||
// Если не было подтверждения (для дат без времени), перерисовываем
|
||||
s.Enter(b, bot.EditMessage)
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) handleBack(b *bot.Bot) {
|
||||
switch s.view {
|
||||
case pickerViewMonths:
|
||||
s.view = pickerViewCalendar
|
||||
case pickerViewTimeCombined:
|
||||
s.view = pickerViewCalendar
|
||||
case pickerViewConfirm:
|
||||
if s.IncludeTime {
|
||||
s.view = pickerViewTimeCombined
|
||||
} else {
|
||||
s.view = pickerViewCalendar
|
||||
}
|
||||
default:
|
||||
if s.BackState != nil {
|
||||
b.SetState(s.BackState, bot.EditMessage)
|
||||
return
|
||||
}
|
||||
}
|
||||
s.Enter(b, bot.EditMessage)
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) renderCalendarTitle() string {
|
||||
if s.year == time.Now().In(MskLocation).Year() {
|
||||
return "\n"
|
||||
}
|
||||
return fmt.Sprintf("%d\n\n", s.year)
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) calendarKeyboard() echotron.InlineKeyboardMarkup {
|
||||
var rows [][]echotron.InlineKeyboardButton
|
||||
|
||||
rows = append(rows, []echotron.InlineKeyboardButton{
|
||||
s.monthPrevButton(),
|
||||
{Text: monthName(s.month), CallbackData: dtpCallbackPrefix + "open_months"},
|
||||
{Text: "→", CallbackData: dtpCallbackPrefix + "month_next"},
|
||||
})
|
||||
|
||||
rows = append(rows, []echotron.InlineKeyboardButton{
|
||||
{Text: "Пн", CallbackData: "empty"},
|
||||
{Text: "Вт", CallbackData: "empty"},
|
||||
{Text: "Ср", CallbackData: "empty"},
|
||||
{Text: "Чт", CallbackData: "empty"},
|
||||
{Text: "Пт", CallbackData: "empty"},
|
||||
{Text: "Сб", CallbackData: "empty"},
|
||||
{Text: "Вс", CallbackData: "empty"},
|
||||
})
|
||||
|
||||
firstOfMonth := time.Date(s.year, s.month, 1, 0, 0, 0, 0, MskLocation)
|
||||
weekday := int(firstOfMonth.Weekday())
|
||||
if weekday == 0 {
|
||||
weekday = 7
|
||||
}
|
||||
daysInMonth := daysInMonth(s.year, s.month)
|
||||
today := time.Now().In(MskLocation)
|
||||
todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, MskLocation)
|
||||
|
||||
var row []echotron.InlineKeyboardButton
|
||||
for i := 1; i < weekday; i++ {
|
||||
row = append(row, echotron.InlineKeyboardButton{Text: " ", CallbackData: "empty"})
|
||||
}
|
||||
|
||||
for day := 1; day <= daysInMonth; day++ {
|
||||
date := time.Date(s.year, s.month, day, 0, 0, 0, 0, MskLocation)
|
||||
if !s.AllowPast && date.Before(todayDate) {
|
||||
row = append(row, echotron.InlineKeyboardButton{Text: "-", CallbackData: "empty"})
|
||||
} else {
|
||||
label := fmt.Sprintf("%d", day)
|
||||
row = append(row, echotron.InlineKeyboardButton{
|
||||
Text: label,
|
||||
CallbackData: fmt.Sprintf("%sday:%04d-%02d-%02d", dtpCallbackPrefix, s.year, int(s.month), day),
|
||||
})
|
||||
}
|
||||
if len(row) == 7 {
|
||||
rows = append(rows, row)
|
||||
row = nil
|
||||
}
|
||||
}
|
||||
|
||||
if len(row) > 0 {
|
||||
for len(row) < 7 {
|
||||
row = append(row, echotron.InlineKeyboardButton{Text: " ", CallbackData: "empty"})
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
rows = append(rows, []echotron.InlineKeyboardButton{
|
||||
{Text: "← Назад", CallbackData: "back"},
|
||||
{Text: "Отмена", CallbackData: "cancel"},
|
||||
})
|
||||
|
||||
return echotron.InlineKeyboardMarkup{InlineKeyboard: rows}
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) monthsKeyboard() echotron.InlineKeyboardMarkup {
|
||||
var rows [][]echotron.InlineKeyboardButton
|
||||
|
||||
months := []string{"Янв", "Фев", "Мар", "Апр", "Май", "Июн", "Июл", "Авг", "Сен", "Окт", "Ноя", "Дек"}
|
||||
for i := 0; i < 12; i += 3 {
|
||||
rows = append(rows, []echotron.InlineKeyboardButton{
|
||||
{Text: months[i], CallbackData: fmt.Sprintf("%smonth:%d", dtpCallbackPrefix, i+1)},
|
||||
{Text: months[i+1], CallbackData: fmt.Sprintf("%smonth:%d", dtpCallbackPrefix, i+2)},
|
||||
{Text: months[i+2], CallbackData: fmt.Sprintf("%smonth:%d", dtpCallbackPrefix, i+3)},
|
||||
})
|
||||
}
|
||||
|
||||
rows = append(rows, []echotron.InlineKeyboardButton{
|
||||
{Text: "← Назад", CallbackData: "back"},
|
||||
})
|
||||
|
||||
return echotron.InlineKeyboardMarkup{InlineKeyboard: rows}
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) timeCombinedKeyboard() echotron.InlineKeyboardMarkup {
|
||||
var rows [][]echotron.InlineKeyboardButton
|
||||
rows = append(rows, []echotron.InlineKeyboardButton{{Text: "Часы", CallbackData: "empty"}})
|
||||
for i := 0; i < 24; i += 6 {
|
||||
rows = append(rows, []echotron.InlineKeyboardButton{
|
||||
s.hourButton(i),
|
||||
s.hourButton(i + 1),
|
||||
s.hourButton(i + 2),
|
||||
s.hourButton(i + 3),
|
||||
s.hourButton(i + 4),
|
||||
s.hourButton(i + 5),
|
||||
})
|
||||
}
|
||||
rows = append(rows, []echotron.InlineKeyboardButton{{Text: "Минуты", CallbackData: "empty"}})
|
||||
minutes := []int{0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55}
|
||||
for i := 0; i < len(minutes); i += 6 {
|
||||
rows = append(rows, []echotron.InlineKeyboardButton{
|
||||
s.minuteButton(minutes[i]),
|
||||
s.minuteButton(minutes[i+1]),
|
||||
s.minuteButton(minutes[i+2]),
|
||||
s.minuteButton(minutes[i+3]),
|
||||
s.minuteButton(minutes[i+4]),
|
||||
s.minuteButton(minutes[i+5]),
|
||||
})
|
||||
}
|
||||
rows = append(rows, []echotron.InlineKeyboardButton{
|
||||
{Text: "← Назад", CallbackData: "back"},
|
||||
{Text: "Готово", CallbackData: dtpCallbackPrefix + "confirm"},
|
||||
})
|
||||
return echotron.InlineKeyboardMarkup{InlineKeyboard: rows}
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) confirmKeyboard() echotron.InlineKeyboardMarkup {
|
||||
return echotron.InlineKeyboardMarkup{
|
||||
InlineKeyboard: [][]echotron.InlineKeyboardButton{
|
||||
{
|
||||
{Text: "← Назад", CallbackData: "back"},
|
||||
{Text: "Готово", CallbackData: dtpCallbackPrefix + "confirm"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) renderSelectedDateTime() string {
|
||||
if s.selectedDate == nil {
|
||||
return "—"
|
||||
}
|
||||
hour := 0
|
||||
minute := 0
|
||||
if s.selectedHour != nil {
|
||||
hour = *s.selectedHour
|
||||
}
|
||||
if s.selectedMinute != nil {
|
||||
minute = *s.selectedMinute
|
||||
}
|
||||
value := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), hour, minute, 0, 0, MskLocation)
|
||||
return fmt.Sprintf("<b>%s</b>", formatCompactDateTime(value))
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) formatTimePreview(showPlaceholders bool) string {
|
||||
hour := "__"
|
||||
minute := "__"
|
||||
if s.selectedHour != nil {
|
||||
hour = fmt.Sprintf("%02d", *s.selectedHour)
|
||||
} else if !showPlaceholders {
|
||||
hour = "00"
|
||||
}
|
||||
if s.selectedMinute != nil {
|
||||
minute = fmt.Sprintf("%02d", *s.selectedMinute)
|
||||
} else if !showPlaceholders {
|
||||
minute = "00"
|
||||
}
|
||||
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, b *bot.Bot) {
|
||||
datePart := strings.TrimPrefix(payload, "day:")
|
||||
parsed, err := time.ParseInLocation("2006-01-02", datePart, MskLocation)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.selectedDate = &parsed
|
||||
if s.IncludeTime {
|
||||
s.view = pickerViewTimeCombined
|
||||
s.selectedHour = nil
|
||||
s.selectedMinute = nil
|
||||
s.Enter(b, bot.EditMessage)
|
||||
} else {
|
||||
// Сразу подтверждаем выбор для даты без времени
|
||||
s.confirmSelection(b)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) handleHourSelect(payload string) {
|
||||
hour := parseInt(strings.TrimPrefix(payload, "hour:"))
|
||||
if hour < 0 || hour > 23 {
|
||||
return
|
||||
}
|
||||
if s.isHourDisabled(hour) {
|
||||
return
|
||||
}
|
||||
s.selectedHour = &hour
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) handleMinuteSelect(payload string) {
|
||||
minute := parseInt(strings.TrimPrefix(payload, "min:"))
|
||||
if minute < 0 || minute > 59 {
|
||||
return
|
||||
}
|
||||
if s.isMinuteDisabled(minute) {
|
||||
return
|
||||
}
|
||||
s.selectedMinute = &minute
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) confirmSelection(b *bot.Bot) {
|
||||
if s.selectedDate == nil {
|
||||
s.Enter(b, bot.EditMessage)
|
||||
return
|
||||
}
|
||||
if s.IncludeTime && (s.selectedHour == nil || s.selectedMinute == nil) {
|
||||
s.view = pickerViewTimeCombined
|
||||
s.Enter(b, bot.EditMessage)
|
||||
return
|
||||
}
|
||||
hour := 0
|
||||
minute := 0
|
||||
if s.selectedHour != nil {
|
||||
hour = *s.selectedHour
|
||||
}
|
||||
if s.selectedMinute != nil {
|
||||
minute = *s.selectedMinute
|
||||
}
|
||||
value := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), hour, minute, 0, 0, MskLocation)
|
||||
if target, ok := s.BackState.(DateTimeSelectionTarget); ok {
|
||||
target.SetDateTimeSelection(s.Key, value)
|
||||
}
|
||||
if s.BackState != nil {
|
||||
b.SetState(s.BackState, bot.EditMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) hourButton(hour int) echotron.InlineKeyboardButton {
|
||||
label := fmt.Sprintf("%02d", hour)
|
||||
if s.isHourDisabled(hour) {
|
||||
return echotron.InlineKeyboardButton{Text: "-", CallbackData: "empty"}
|
||||
}
|
||||
if s.selectedHour != nil && *s.selectedHour == hour {
|
||||
label = "● " + label
|
||||
}
|
||||
return echotron.InlineKeyboardButton{
|
||||
Text: label,
|
||||
CallbackData: fmt.Sprintf("%shour:%02d", dtpCallbackPrefix, hour),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) minuteButton(minute int) echotron.InlineKeyboardButton {
|
||||
label := fmt.Sprintf("%02d", minute)
|
||||
if s.isMinuteDisabled(minute) {
|
||||
return echotron.InlineKeyboardButton{Text: "-", CallbackData: "empty"}
|
||||
}
|
||||
if s.selectedMinute != nil && *s.selectedMinute == minute {
|
||||
label = "● " + label
|
||||
}
|
||||
return echotron.InlineKeyboardButton{
|
||||
Text: label,
|
||||
CallbackData: fmt.Sprintf("%smin:%02d", dtpCallbackPrefix, minute),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) isHourDisabled(hour int) bool {
|
||||
if s.AllowPast {
|
||||
return false
|
||||
}
|
||||
if s.selectedDate == nil {
|
||||
return false
|
||||
}
|
||||
today := time.Now().In(MskLocation)
|
||||
date := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), 0, 0, 0, 0, MskLocation)
|
||||
todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, MskLocation)
|
||||
if date.After(todayDate) {
|
||||
return false
|
||||
}
|
||||
return hour < today.Hour()
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) isMinuteDisabled(minute int) bool {
|
||||
if s.AllowPast {
|
||||
return false
|
||||
}
|
||||
if s.selectedDate == nil || s.selectedHour == nil {
|
||||
return false
|
||||
}
|
||||
today := time.Now().In(MskLocation)
|
||||
date := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), 0, 0, 0, 0, MskLocation)
|
||||
todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, MskLocation)
|
||||
if date.After(todayDate) {
|
||||
return false
|
||||
}
|
||||
if *s.selectedHour > today.Hour() {
|
||||
return false
|
||||
}
|
||||
return minute < today.Minute()
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) prevMonth() {
|
||||
if s.AllowPast {
|
||||
if s.month == time.January {
|
||||
s.month = time.December
|
||||
s.year--
|
||||
} else {
|
||||
s.month--
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().In(MskLocation)
|
||||
if s.year == now.Year() && s.month == now.Month() {
|
||||
return
|
||||
}
|
||||
if s.month == time.January {
|
||||
s.month = time.December
|
||||
s.year--
|
||||
} else {
|
||||
s.month--
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) nextMonth() {
|
||||
if s.month == time.December {
|
||||
s.month = time.January
|
||||
s.year++
|
||||
} else {
|
||||
s.month++
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) monthPrevButton() echotron.InlineKeyboardButton {
|
||||
if s.AllowPast {
|
||||
return echotron.InlineKeyboardButton{Text: "←", CallbackData: dtpCallbackPrefix + "month_prev"}
|
||||
}
|
||||
|
||||
now := time.Now().In(MskLocation)
|
||||
if s.year == now.Year() && s.month == now.Month() {
|
||||
return echotron.InlineKeyboardButton{Text: "-", CallbackData: "empty"}
|
||||
}
|
||||
return echotron.InlineKeyboardButton{Text: "←", CallbackData: dtpCallbackPrefix + "month_prev"}
|
||||
}
|
||||
|
||||
func daysInMonth(year int, month time.Month) int {
|
||||
return time.Date(year, month+1, 0, 0, 0, 0, 0, MskLocation).Day()
|
||||
}
|
||||
|
||||
func monthName(month time.Month) string {
|
||||
names := []string{
|
||||
"Январь", "Февраль", "Март", "Апрель", "Май", "Июнь",
|
||||
"Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь",
|
||||
}
|
||||
if int(month) < 1 || int(month) > len(names) {
|
||||
return ""
|
||||
}
|
||||
return names[int(month)-1]
|
||||
}
|
||||
|
||||
func parseInt(value string) int {
|
||||
result := 0
|
||||
for _, r := range value {
|
||||
if r < '0' || r > '9' {
|
||||
return 0
|
||||
}
|
||||
result = result*10 + int(r-'0')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
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, s.AllowPast)
|
||||
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, s.AllowPast)
|
||||
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, s.AllowPast)
|
||||
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) Exit() {}
|
||||
|
||||
var whenParser *when.Parser
|
||||
|
||||
func init() {
|
||||
whenParser = when.New(nil)
|
||||
whenParser.Add(ru.All...)
|
||||
whenParser.Add(common.All...)
|
||||
}
|
||||
|
||||
var (
|
||||
timeIndicator = regexp.MustCompile(`\d{1,2}[:.]\d{2}|утр|вечер|днём|ночь|час|минут|полдень|полночь`)
|
||||
dateIndicator = regexp.MustCompile(`\d{1,2}[./-]\d|сегодня|завтра|вчера|послезавтра|понедельн|вторник|сред[уыа]|четверг|пятниц|суббот|воскресен|янв|фев|мар|апр|ма[йя]|июн|июл|авг|сен|окт|ноя|дек|через.*дн|через.*недел|через.*месяц|через.*год`)
|
||||
)
|
||||
|
||||
func detectTime(matched string) bool {
|
||||
return timeIndicator.MatchString(matched)
|
||||
}
|
||||
|
||||
func detectDate(matched string) bool {
|
||||
return dateIndicator.MatchString(matched)
|
||||
}
|
||||
|
||||
func parseDateTimeInput(
|
||||
input string,
|
||||
_ *time.Time,
|
||||
_ *int,
|
||||
_ *int,
|
||||
allowPast bool,
|
||||
) (time.Time, bool, bool) {
|
||||
now := time.Now().In(MskLocation)
|
||||
|
||||
r, err := whenParser.Parse(input, now)
|
||||
if err != nil || r == nil {
|
||||
return time.Time{}, false, false
|
||||
}
|
||||
|
||||
result := r.Time.In(MskLocation)
|
||||
matched := strings.ToLower(r.Text)
|
||||
|
||||
hasDate := detectDate(matched)
|
||||
hasTime := detectTime(matched)
|
||||
|
||||
if !allowPast && result.Before(now) {
|
||||
return time.Time{}, false, false
|
||||
}
|
||||
|
||||
return result, hasDate, hasTime
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
)
|
||||
|
||||
type htmlTag struct {
|
||||
open string
|
||||
close string
|
||||
}
|
||||
|
||||
var simpleHTMLTags = map[echotron.MessageEntityType]htmlTag{
|
||||
echotron.BoldEntity: {open: "<b>", close: "</b>"},
|
||||
echotron.ItalicEntity: {open: "<i>", close: "</i>"},
|
||||
echotron.UnderlineEntity: {open: "<u>", close: "</u>"},
|
||||
echotron.StrikethroughEntity: {open: "<s>", close: "</s>"},
|
||||
echotron.CodeEntity: {open: "<code>", close: "</code>"},
|
||||
echotron.PreEntity: {open: "<pre>", close: "</pre>"},
|
||||
}
|
||||
|
||||
// FormatMessageHTML converts Telegram entities to HTML and escapes the rest.
|
||||
func FormatMessageHTML(message *echotron.Message) string {
|
||||
if message == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Telegram sends formatting as entities, so we rebuild the HTML from offsets.
|
||||
text := message.Text
|
||||
entities := message.Entities
|
||||
if text == "" {
|
||||
text = message.Caption
|
||||
entities = message.CaptionEntities
|
||||
}
|
||||
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if len(entities) == 0 {
|
||||
return EscapeHTML(text)
|
||||
}
|
||||
|
||||
type tagSpan struct {
|
||||
open string
|
||||
close string
|
||||
start int
|
||||
end int
|
||||
len int
|
||||
}
|
||||
|
||||
runes := []rune(text)
|
||||
positions := make([]int, len(runes)+1)
|
||||
utf16Count := 0
|
||||
for i, r := range runes {
|
||||
positions[i] = utf16Count
|
||||
if r > 0xFFFF {
|
||||
utf16Count += 2
|
||||
} else {
|
||||
utf16Count++
|
||||
}
|
||||
}
|
||||
positions[len(runes)] = utf16Count
|
||||
|
||||
utf16ToRuneIndex := func(utf16Index int) (int, bool) {
|
||||
i := sort.Search(len(positions), func(i int) bool { return positions[i] >= utf16Index })
|
||||
if i < len(positions) && positions[i] == utf16Index {
|
||||
return i, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
toRuneRange := func(offset, length int) (int, int, bool) {
|
||||
start, ok := utf16ToRuneIndex(offset)
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
end, ok := utf16ToRuneIndex(offset + length)
|
||||
if !ok || end > len(runes) || end < start {
|
||||
return 0, 0, false
|
||||
}
|
||||
return start, end, true
|
||||
}
|
||||
|
||||
// URL ranges are used to prevent nested tags inside links.
|
||||
urlRanges := make([][2]int, 0, len(entities))
|
||||
|
||||
for _, entity := range entities {
|
||||
if entity == nil {
|
||||
continue
|
||||
}
|
||||
if entity.Type != echotron.UrlEntity && entity.Type != echotron.TextLinkEntity {
|
||||
continue
|
||||
}
|
||||
start, end, ok := toRuneRange(entity.Offset, entity.Length)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
urlRanges = append(urlRanges, [2]int{start, end})
|
||||
if entity.Type == echotron.UrlEntity {
|
||||
_ = string(runes[start:end])
|
||||
}
|
||||
}
|
||||
|
||||
isInsideURL := func(offset, length int) bool {
|
||||
for _, urlRange := range urlRanges {
|
||||
if offset > urlRange[0] && offset+length <= urlRange[1] {
|
||||
return true
|
||||
}
|
||||
if offset < urlRange[1] && offset+length > urlRange[0] && (offset != urlRange[0] || length != urlRange[1]-urlRange[0]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var spans []tagSpan
|
||||
for _, entity := range entities {
|
||||
if entity == nil {
|
||||
continue
|
||||
}
|
||||
start, end, ok := toRuneRange(entity.Offset, entity.Length)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if entity.Type != echotron.UrlEntity && entity.Type != echotron.TextLinkEntity && isInsideURL(start, end-start) {
|
||||
continue
|
||||
}
|
||||
|
||||
var openTag, closeTag string
|
||||
if tag, ok := simpleHTMLTags[entity.Type]; ok {
|
||||
openTag, closeTag = tag.open, tag.close
|
||||
} else if entity.Type == echotron.UrlEntity {
|
||||
entityText := string(runes[start:end])
|
||||
openTag = fmt.Sprintf("<a href=\"%s\">", entityText)
|
||||
closeTag = "</a>"
|
||||
} else if entity.Type == echotron.TextLinkEntity {
|
||||
if entity.URL != "" {
|
||||
openTag = fmt.Sprintf("<a href=\"%s\">", entity.URL)
|
||||
closeTag = "</a>"
|
||||
}
|
||||
}
|
||||
|
||||
if openTag != "" && closeTag != "" {
|
||||
spans = append(spans, tagSpan{
|
||||
open: openTag,
|
||||
close: closeTag,
|
||||
start: start,
|
||||
end: end,
|
||||
len: end - start,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
opens := make(map[int][]tagSpan)
|
||||
closes := make(map[int][]tagSpan)
|
||||
for _, span := range spans {
|
||||
opens[span.start] = append(opens[span.start], span)
|
||||
closes[span.end] = append(closes[span.end], span)
|
||||
}
|
||||
|
||||
// Build the final text with tags inserted and HTML escaped.
|
||||
var result strings.Builder
|
||||
for i := 0; i <= len(runes); i++ {
|
||||
if closing, ok := closes[i]; ok {
|
||||
sort.Slice(closing, func(a, b int) bool { return closing[a].len < closing[b].len })
|
||||
for _, span := range closing {
|
||||
result.WriteString(span.close)
|
||||
}
|
||||
}
|
||||
if opening, ok := opens[i]; ok {
|
||||
sort.Slice(opening, func(a, b int) bool { return opening[a].len > opening[b].len })
|
||||
for _, span := range opening {
|
||||
result.WriteString(span.open)
|
||||
}
|
||||
}
|
||||
if i < len(runes) {
|
||||
switch runes[i] {
|
||||
case '<':
|
||||
result.WriteString("<")
|
||||
case '>':
|
||||
result.WriteString(">")
|
||||
case '&':
|
||||
result.WriteString("&")
|
||||
default:
|
||||
result.WriteRune(runes[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return normalizeHTMLTags(result.String())
|
||||
}
|
||||
|
||||
func normalizeHTMLTags(input string) string {
|
||||
if input == "" {
|
||||
return input
|
||||
}
|
||||
|
||||
allowed := map[string]bool{
|
||||
"a": true,
|
||||
"b": true,
|
||||
"i": true,
|
||||
"u": true,
|
||||
"s": true,
|
||||
"code": true,
|
||||
"pre": true,
|
||||
}
|
||||
|
||||
var out strings.Builder
|
||||
out.Grow(len(input))
|
||||
stack := make([]string, 0, 8)
|
||||
|
||||
for i := 0; i < len(input); {
|
||||
if input[i] != '<' {
|
||||
out.WriteByte(input[i])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
end := strings.IndexByte(input[i:], '>')
|
||||
if end == -1 {
|
||||
out.WriteByte(input[i])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
end += i
|
||||
tag := input[i+1 : end]
|
||||
if tag == "" {
|
||||
out.WriteString(input[i : end+1])
|
||||
i = end + 1
|
||||
continue
|
||||
}
|
||||
|
||||
isClosing := tag[0] == '/'
|
||||
tagName := tag
|
||||
if isClosing {
|
||||
tagName = tag[1:]
|
||||
}
|
||||
if space := strings.IndexByte(tagName, ' '); space != -1 {
|
||||
tagName = tagName[:space]
|
||||
}
|
||||
tagName = strings.TrimSpace(tagName)
|
||||
|
||||
if !allowed[tagName] {
|
||||
out.WriteString(input[i : end+1])
|
||||
i = end + 1
|
||||
continue
|
||||
}
|
||||
|
||||
if isClosing {
|
||||
// Find the tag in stack
|
||||
foundIndex := -1
|
||||
for j := len(stack) - 1; j >= 0; j-- {
|
||||
if stack[j] == tagName {
|
||||
foundIndex = j
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if foundIndex >= 0 {
|
||||
// Close all tags from top of stack down to foundIndex
|
||||
for j := len(stack) - 1; j > foundIndex; j-- {
|
||||
out.WriteString("</")
|
||||
out.WriteString(stack[j])
|
||||
out.WriteString(">")
|
||||
}
|
||||
// Close the found tag
|
||||
out.WriteString(input[i : end+1])
|
||||
// Remove closed tags from stack
|
||||
stack = stack[:foundIndex]
|
||||
}
|
||||
} else {
|
||||
stack = append(stack, tagName)
|
||||
out.WriteString(input[i : end+1])
|
||||
}
|
||||
|
||||
i = end + 1
|
||||
}
|
||||
|
||||
for i := len(stack) - 1; i >= 0; i-- {
|
||||
out.WriteString("</")
|
||||
out.WriteString(stack[i])
|
||||
out.WriteString(">")
|
||||
}
|
||||
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// EscapeHTML escapes HTML special characters for safe output.
|
||||
func EscapeHTML(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
return s
|
||||
}
|
||||
|
||||
// SanitizeHTML normalizes tag nesting to prevent invalid HTML in Telegram parse mode.
|
||||
func SanitizeHTML(input string) string {
|
||||
return normalizeHTMLTags(input)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
)
|
||||
|
||||
// PaginationConfig конфигурация для построения навигационного ряда пагинации
|
||||
type PaginationConfig struct {
|
||||
CurrentPage int
|
||||
TotalPages int // Для API пагинации (из page.Pages)
|
||||
TotalItems int // Для локальной пагинации (len(array))
|
||||
ItemsPerPage int // Для расчета TotalPages из TotalItems
|
||||
PrevCallback string // По умолчанию "prev"
|
||||
NextCallback string // По умолчанию "next"
|
||||
MiddleButtons []echotron.InlineKeyboardButton // Опциональные кнопки в центре (например, "+ Добавить")
|
||||
}
|
||||
|
||||
// BuildNavigationRow создает навигационный ряд с динамической шириной.
|
||||
// Показывает ряд только если страниц > 1.
|
||||
// Возвращает nil если пагинация не нужна.
|
||||
func BuildNavigationRow(config PaginationConfig) []echotron.InlineKeyboardButton {
|
||||
// Вычисляем TotalPages из TotalItems если не задан
|
||||
totalPages := config.TotalPages
|
||||
if totalPages == 0 && config.TotalItems > 0 && config.ItemsPerPage > 0 {
|
||||
totalPages = CalculatePages(config.TotalItems, config.ItemsPerPage)
|
||||
}
|
||||
|
||||
// Устанавливаем значения по умолчанию для callbacks
|
||||
prevCallback := config.PrevCallback
|
||||
if prevCallback == "" {
|
||||
prevCallback = "prev"
|
||||
}
|
||||
nextCallback := config.NextCallback
|
||||
if nextCallback == "" {
|
||||
nextCallback = "next"
|
||||
}
|
||||
|
||||
var row []echotron.InlineKeyboardButton
|
||||
|
||||
// Левая стрелка (только если есть предыдущая страница)
|
||||
if totalPages > 1 && config.CurrentPage > 0 {
|
||||
row = append(row, echotron.InlineKeyboardButton{
|
||||
Text: "←",
|
||||
CallbackData: prevCallback,
|
||||
})
|
||||
}
|
||||
|
||||
// Средние кнопки (если есть)
|
||||
if len(config.MiddleButtons) > 0 {
|
||||
row = append(row, config.MiddleButtons...)
|
||||
}
|
||||
|
||||
// Правая стрелка (только если есть следующая страница)
|
||||
if totalPages > 1 && config.CurrentPage+1 < totalPages {
|
||||
row = append(row, echotron.InlineKeyboardButton{
|
||||
Text: "→",
|
||||
CallbackData: nextCallback,
|
||||
})
|
||||
}
|
||||
|
||||
if len(row) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return row
|
||||
}
|
||||
|
||||
// CalculatePages вычисляет количество страниц из общего количества элементов
|
||||
func CalculatePages(totalItems, itemsPerPage int) int {
|
||||
if itemsPerPage <= 0 {
|
||||
return 0
|
||||
}
|
||||
return (totalItems + itemsPerPage - 1) / itemsPerPage
|
||||
}
|
||||
|
||||
// GetPageBounds возвращает start/end индексы для текущей страницы.
|
||||
// Автоматически сбрасывает на первую страницу если currentPage выходит за границы.
|
||||
func GetPageBounds(currentPage, itemsPerPage, totalItems int) (start, end int) {
|
||||
start = currentPage * itemsPerPage
|
||||
if start >= totalItems {
|
||||
start = 0
|
||||
}
|
||||
end = start + itemsPerPage
|
||||
if end > totalItems {
|
||||
end = totalItems
|
||||
}
|
||||
return start, end
|
||||
}
|
||||
|
||||
// FormatPageInfo форматирует текст индикатора страницы для заголовка
|
||||
func FormatPageInfo(currentPage, totalPages int) string {
|
||||
if totalPages <= 1 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" <i>(стр. %d/%d)</i>", currentPage+1, totalPages)
|
||||
}
|
||||
|
||||
// ElementLayoutConfig конфигурация для автоматической раскладки элементов с пагинацией
|
||||
type ElementLayoutConfig struct {
|
||||
CurrentPage int // Текущая страница (0-indexed)
|
||||
ItemsPerPage int // Элементов на странице
|
||||
ItemsPerRow int // Элементов в одном ряду
|
||||
EmptyButton *echotron.InlineKeyboardButton // Кнопка-заполнитель (опционально, по умолчанию " ")
|
||||
}
|
||||
|
||||
// BuildElementRows автоматически раскладывает элементы с пагинацией.
|
||||
// Принимает все элементы, возвращает ряды для текущей страницы с правильной раскладкой.
|
||||
// Автоматически заполняет пустыми кнопками если страниц > 1 (для консистентности высоты).
|
||||
// Возвращает 2D массив кнопок готовый к добавлению в клавиатуру.
|
||||
//
|
||||
// Пример использования:
|
||||
//
|
||||
// config := ui.ElementLayoutConfig{
|
||||
// CurrentPage: 0,
|
||||
// ItemsPerPage: 6,
|
||||
// ItemsPerRow: 2,
|
||||
// }
|
||||
// rows := ui.BuildElementRows(config, allButtons)
|
||||
// keyboard = append(keyboard, rows...)
|
||||
func BuildElementRows(config ElementLayoutConfig, allItems []echotron.InlineKeyboardButton) [][]echotron.InlineKeyboardButton {
|
||||
if len(allItems) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Параметры по умолчанию
|
||||
if config.ItemsPerPage <= 0 {
|
||||
config.ItemsPerPage = 6
|
||||
}
|
||||
if config.ItemsPerRow <= 0 {
|
||||
config.ItemsPerRow = 2
|
||||
}
|
||||
|
||||
// Кнопка-заполнитель по умолчанию
|
||||
emptyButton := echotron.InlineKeyboardButton{Text: " ", CallbackData: "empty"}
|
||||
if config.EmptyButton != nil {
|
||||
emptyButton = *config.EmptyButton
|
||||
}
|
||||
|
||||
// Вычисляем границы страницы
|
||||
totalPages := CalculatePages(len(allItems), config.ItemsPerPage)
|
||||
start, end := GetPageBounds(config.CurrentPage, config.ItemsPerPage, len(allItems))
|
||||
|
||||
// Получаем элементы текущей страницы
|
||||
pageItems := allItems[start:end]
|
||||
|
||||
// Если страниц >= 2, заполняем до ItemsPerPage пустыми кнопками
|
||||
// Это обеспечивает одинаковую высоту клавиатуры на всех страницах
|
||||
if totalPages >= 2 {
|
||||
for len(pageItems) < config.ItemsPerPage {
|
||||
pageItems = append(pageItems, emptyButton)
|
||||
}
|
||||
}
|
||||
|
||||
// Раскладываем элементы по рядам
|
||||
var rows [][]echotron.InlineKeyboardButton
|
||||
for i := 0; i < len(pageItems); i += config.ItemsPerRow {
|
||||
var row []echotron.InlineKeyboardButton
|
||||
|
||||
// Добавляем элементы в ряд
|
||||
for j := 0; j < config.ItemsPerRow && i+j < len(pageItems); j++ {
|
||||
row = append(row, pageItems[i+j])
|
||||
}
|
||||
|
||||
// Если в ряду меньше элементов чем ItemsPerRow, заполняем пустыми
|
||||
for len(row) < config.ItemsPerRow {
|
||||
row = append(row, emptyButton)
|
||||
}
|
||||
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
// BuildPageRows раскладывает элементы текущей страницы без собственной пагинации.
|
||||
// Автоматически заполняет пустыми кнопками если страниц > 1 (для консистентности высоты).
|
||||
func BuildPageRows(pageItems []echotron.InlineKeyboardButton, itemsPerRow, itemsPerPage, totalPages int) [][]echotron.InlineKeyboardButton {
|
||||
if len(pageItems) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Параметры по умолчанию
|
||||
if itemsPerPage <= 0 {
|
||||
itemsPerPage = 6
|
||||
}
|
||||
if itemsPerRow <= 0 {
|
||||
itemsPerRow = 2
|
||||
}
|
||||
|
||||
// Кнопка-заполнитель по умолчанию
|
||||
emptyButton := echotron.InlineKeyboardButton{Text: " ", CallbackData: "empty"}
|
||||
|
||||
// Если страниц >= 2, заполняем до ItemsPerPage пустыми кнопками
|
||||
if totalPages >= 2 {
|
||||
for len(pageItems) < itemsPerPage {
|
||||
pageItems = append(pageItems, emptyButton)
|
||||
}
|
||||
}
|
||||
|
||||
// Раскладываем элементы по рядам
|
||||
var rows [][]echotron.InlineKeyboardButton
|
||||
for i := 0; i < len(pageItems); i += itemsPerRow {
|
||||
var row []echotron.InlineKeyboardButton
|
||||
|
||||
for j := 0; j < itemsPerRow && i+j < len(pageItems); j++ {
|
||||
row = append(row, pageItems[i+j])
|
||||
}
|
||||
|
||||
for len(row) < itemsPerRow {
|
||||
row = append(row, emptyButton)
|
||||
}
|
||||
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type WorkspaceMenu struct {
|
||||
CurrentPage int
|
||||
BackState bot.State
|
||||
}
|
||||
|
||||
const msgWorkspaceEmpty = `
|
||||
У вас пока нет рабочих пространств.
|
||||
`
|
||||
|
||||
const workspacesPerPage = 6
|
||||
|
||||
func (s *WorkspaceMenu) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
workspaces, err := b.Backend.GetWorkspaces(context.Background(), b.Session.JWT)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to get workspaces for workspace menu")
|
||||
b.SendNew("❌ Не удалось загрузить рабочие пространства", Keyboard(Row(Button("← Назад", "back"))))
|
||||
return
|
||||
}
|
||||
|
||||
if len(workspaces) == 0 {
|
||||
kb := Keyboard(Row(Button("← Назад", "back")))
|
||||
b.Render(msgWorkspaceEmpty, kb, mode)
|
||||
return
|
||||
}
|
||||
|
||||
var allButtons []echotron.InlineKeyboardButton
|
||||
for _, ws := range workspaces {
|
||||
label := ws.Name
|
||||
if ws.ID == b.Session.WorkspaceID {
|
||||
label = "● " + label
|
||||
}
|
||||
allButtons = append(allButtons, Button(label, fmt.Sprintf("workspace_select:%s", ws.ID)))
|
||||
}
|
||||
|
||||
elementRows := ui.BuildElementRows(ui.ElementLayoutConfig{
|
||||
CurrentPage: s.CurrentPage,
|
||||
ItemsPerPage: workspacesPerPage,
|
||||
ItemsPerRow: 2,
|
||||
}, allButtons)
|
||||
|
||||
var buttons [][]echotron.InlineKeyboardButton
|
||||
buttons = append(buttons, elementRows...)
|
||||
|
||||
if navRow := ui.BuildNavigationRow(ui.PaginationConfig{
|
||||
CurrentPage: s.CurrentPage,
|
||||
TotalItems: len(allButtons),
|
||||
ItemsPerPage: workspacesPerPage,
|
||||
}); navRow != nil {
|
||||
buttons = append(buttons, navRow)
|
||||
}
|
||||
|
||||
buttons = append(buttons, Row(Button("← Назад", "back")))
|
||||
|
||||
totalPages := ui.CalculatePages(len(allButtons), workspacesPerPage)
|
||||
text := fmt.Sprintf(`<b>Рабочие пространства</b>%s
|
||||
|
||||
Выберите текущее рабочее пространство.`, ui.FormatPageInfo(s.CurrentPage, totalPages))
|
||||
|
||||
b.Render(text, Keyboard(buttons...), mode)
|
||||
}
|
||||
|
||||
func (s *WorkspaceMenu) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
|
||||
return
|
||||
}
|
||||
|
||||
data := u.CallbackQuery.Data
|
||||
|
||||
switch {
|
||||
case data == "prev":
|
||||
if s.CurrentPage > 0 {
|
||||
s.CurrentPage--
|
||||
}
|
||||
s.Enter(b, bot.EditMessage)
|
||||
|
||||
case data == "next":
|
||||
s.CurrentPage++
|
||||
s.Enter(b, bot.EditMessage)
|
||||
|
||||
case data == "back":
|
||||
if s.BackState != nil {
|
||||
b.SetState(s.BackState, bot.EditMessage)
|
||||
return
|
||||
}
|
||||
b.SetState(&MainMenu{}, bot.EditMessage)
|
||||
|
||||
case strings.HasPrefix(data, "workspace_select:"):
|
||||
parts := strings.Split(data, ":")
|
||||
if len(parts) != 2 {
|
||||
return
|
||||
}
|
||||
workspaceID := parts[1]
|
||||
workspaces, err := b.Backend.GetWorkspaces(context.Background(), b.Session.JWT)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to get workspaces for selection")
|
||||
b.SendNew("❌ Не удалось загрузить рабочие пространства", Keyboard(Row(Button("← Назад", "back"))))
|
||||
return
|
||||
}
|
||||
for _, ws := range workspaces {
|
||||
if ws.ID == workspaceID {
|
||||
b.Session.WorkspaceID = ws.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if s.BackState != nil {
|
||||
b.SetState(s.BackState, bot.EditMessage)
|
||||
return
|
||||
}
|
||||
b.SetState(&MainMenu{}, bot.EditMessage)
|
||||
|
||||
default:
|
||||
s.Enter(b, bot.NewMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WorkspaceMenu) HandleMessage(_ *bot.Bot, _ *echotron.Update) {}
|
||||
|
||||
func (s *WorkspaceMenu) Handle(_ *bot.Bot, _ *echotron.Update) {}
|
||||
|
||||
func (s *WorkspaceMenu) Exit() {}
|
||||
Reference in New Issue
Block a user