new telegram bot
This commit is contained in:
54
telegram_bot/screens/accept_workspace_invite.go
Normal file
54
telegram_bot/screens/accept_workspace_invite.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"example.com/m/bot"
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type AcceptWorkspaceInvite struct {
|
||||
InviteID string
|
||||
}
|
||||
|
||||
func (s *AcceptWorkspaceInvite) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
// Получаем JWT токен
|
||||
jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to get JWT")
|
||||
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", bot.Keyboard())
|
||||
return
|
||||
}
|
||||
|
||||
// Принимаем приглашение
|
||||
err = b.Backend.AcceptWorkspaceInvite(context.Background(), jwt, s.InviteID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("invite_id", s.InviteID).Msg("Failed to accept workspace invite")
|
||||
b.SendNew("❌ Не удалось принять приглашение. Возможно, оно уже было использовано.", bot.Keyboard())
|
||||
return
|
||||
}
|
||||
|
||||
// Успешно принято
|
||||
text := "✅ Вы присоединились к рабочему пространству!\n\n" +
|
||||
"Администратор сможет выдать вам необходимые права в веб-интерфейсе."
|
||||
|
||||
b.SendNew(text, bot.Keyboard(
|
||||
bot.Row(bot.Button("🏠 Главное меню", "menu")),
|
||||
))
|
||||
}
|
||||
|
||||
func (s *AcceptWorkspaceInvite) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if u.CallbackQuery.Data == "menu" {
|
||||
b.SetState(&MainMenu{}, bot.EditMessage)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *AcceptWorkspaceInvite) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *AcceptWorkspaceInvite) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
686
telegram_bot/screens/add_creative.go
Normal file
686
telegram_bot/screens/add_creative.go
Normal file
@@ -0,0 +1,686 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"example.com/m/adapter/backend"
|
||||
"example.com/m/bot"
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type AddCreative struct {
|
||||
ProjectID string
|
||||
WorkspaceID string
|
||||
BackState bot.State
|
||||
|
||||
// Промежуточные данные для многошагового ввода
|
||||
Name *string
|
||||
Text *string
|
||||
Buttons []InlineButton
|
||||
|
||||
// Медиа
|
||||
MediaType string // "photo", "video", "animation"
|
||||
MediaFileID string // File ID из Telegram
|
||||
|
||||
// Состояние для добавления кнопки
|
||||
WaitingForButtonText bool
|
||||
WaitingForButtonURL bool
|
||||
CurrentButtonText *string
|
||||
WaitingForMedia bool
|
||||
|
||||
// ID сообщения с креативом (которое постоянно обновляется)
|
||||
CreativeMessageID *int
|
||||
// ID сообщения с панелью управления
|
||||
ControlPanelMessageID *int
|
||||
}
|
||||
|
||||
type InlineButton struct {
|
||||
Text string
|
||||
URL string
|
||||
}
|
||||
|
||||
func (s *AddCreative) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
text := `<b>➕ Добавить креатив</b>
|
||||
|
||||
Введите <b>название</b> креатива:
|
||||
|
||||
<i>Например:</i>
|
||||
• Летняя акция
|
||||
• Баннер 1920x1080
|
||||
• Видео промо`
|
||||
|
||||
buttons := [][]echotron.InlineKeyboardButton{
|
||||
bot.Row(bot.Button("« Отмена", "cancel")),
|
||||
}
|
||||
|
||||
keyboard := bot.Keyboard(buttons...)
|
||||
b.Render(text, keyboard, mode)
|
||||
}
|
||||
|
||||
func (s *AddCreative) 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.BackState != nil {
|
||||
b.SetState(s.BackState, bot.EditMessage)
|
||||
}
|
||||
|
||||
case data == "edit_text":
|
||||
// Редактируем панель управления - просим текст
|
||||
s.showControlPanel(b, `<b>✏️ Введите текст креатива:</b>
|
||||
|
||||
⚠️ <b>Важно:</b> Текст должен содержать <b>ОДНУ</b> инвайт-ссылку вашего канала
|
||||
|
||||
<b>Формат ссылки:</b>
|
||||
<code>https://t.me/+xxx</code>
|
||||
|
||||
<b>Пример:</b>
|
||||
<i>Присоединяйтесь к нашему каналу!
|
||||
https://t.me/+AbCdEfGhIjKlMn</i>`, [][]echotron.InlineKeyboardButton{
|
||||
bot.Row(bot.Button("« Отмена", "cancel_edit")),
|
||||
})
|
||||
|
||||
case data == "add_button":
|
||||
// Начинаем процесс добавления кнопки
|
||||
s.WaitingForButtonText = true
|
||||
|
||||
s.showControlPanel(b, `<b>➕ Добавить кнопку</b>
|
||||
|
||||
Введите <b>текст кнопки:</b>
|
||||
|
||||
<i>Например:</i>
|
||||
• Перейти на сайт
|
||||
• Написать нам`, [][]echotron.InlineKeyboardButton{
|
||||
bot.Row(bot.Button("« Отмена", "cancel_add_button")),
|
||||
})
|
||||
|
||||
case data == "add_media":
|
||||
// Начинаем процесс добавления медиа
|
||||
s.WaitingForMedia = true
|
||||
|
||||
s.showControlPanel(b, `<b>📷 Добавить медиа</b>
|
||||
|
||||
Отправьте <b>фото, видео или GIF</b>
|
||||
|
||||
<i>Это медиа будет отображаться в креативе вместе с текстом</i>`, [][]echotron.InlineKeyboardButton{
|
||||
bot.Row(bot.Button("« Отмена", "cancel_media")),
|
||||
})
|
||||
|
||||
case data == "delete_media":
|
||||
// Удаляем медиа
|
||||
s.MediaType = ""
|
||||
s.MediaFileID = ""
|
||||
s.updateCreativePreview(b)
|
||||
s.showManagementPanel(b)
|
||||
|
||||
case data == "cancel_add_button":
|
||||
// Отменяем добавление кнопки
|
||||
// Если мы уже создали кнопку с placeholder - удаляем её
|
||||
if s.WaitingForButtonURL && len(s.Buttons) > 0 {
|
||||
lastButton := s.Buttons[len(s.Buttons)-1]
|
||||
if lastButton.URL == "https://example.com" {
|
||||
s.Buttons = s.Buttons[:len(s.Buttons)-1]
|
||||
s.updateCreativePreview(b)
|
||||
}
|
||||
}
|
||||
s.WaitingForButtonText = false
|
||||
s.WaitingForButtonURL = false
|
||||
s.CurrentButtonText = nil
|
||||
s.showManagementPanel(b)
|
||||
|
||||
case data == "cancel_edit", data == "cancel_media":
|
||||
// Отменяем редактирование текста или добавление медиа
|
||||
s.WaitingForMedia = false
|
||||
s.showManagementPanel(b)
|
||||
|
||||
case data == "confirm_create":
|
||||
// Создаём креатив
|
||||
s.createCreative(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.showManagementPanel(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
s.Enter(b, bot.NewMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AddCreative) HandleMessage(b *bot.Bot, u *echotron.Update) {
|
||||
if u.Message == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Шаг 1: Получаем название
|
||||
if s.Name == nil {
|
||||
if u.Message.Text == "" {
|
||||
return
|
||||
}
|
||||
name := u.Message.Text
|
||||
s.Name = &name
|
||||
|
||||
// Отправляем креатив + панель управления
|
||||
s.sendCreativeAndPanel(b)
|
||||
return
|
||||
}
|
||||
|
||||
// Обработка медиа
|
||||
if s.WaitingForMedia {
|
||||
handled := false
|
||||
|
||||
if u.Message.Photo != nil && len(u.Message.Photo) > 0 {
|
||||
// Берем самое большое фото
|
||||
photo := u.Message.Photo[len(u.Message.Photo)-1]
|
||||
s.MediaType = "photo"
|
||||
s.MediaFileID = photo.FileID
|
||||
handled = true
|
||||
} else if u.Message.Video != nil {
|
||||
s.MediaType = "video"
|
||||
s.MediaFileID = u.Message.Video.FileID
|
||||
handled = true
|
||||
} else if u.Message.Animation != nil {
|
||||
s.MediaType = "animation"
|
||||
s.MediaFileID = u.Message.Animation.FileID
|
||||
handled = true
|
||||
}
|
||||
|
||||
if handled {
|
||||
// Удаляем сообщение пользователя
|
||||
_, err := b.DeleteMessage(b.ChatID, u.Message.ID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to delete user message")
|
||||
}
|
||||
|
||||
s.WaitingForMedia = false
|
||||
s.updateCreativePreview(b)
|
||||
s.showManagementPanel(b)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Если не медиа и не текст - игнорируем
|
||||
if u.Message.Text == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Удаляем сообщение пользователя
|
||||
_, err := b.DeleteMessage(b.ChatID, u.Message.ID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to delete user message")
|
||||
}
|
||||
|
||||
// Обработка добавления кнопки - текст кнопки
|
||||
if s.WaitingForButtonText {
|
||||
buttonText := u.Message.Text
|
||||
s.CurrentButtonText = &buttonText
|
||||
s.WaitingForButtonText = false
|
||||
s.WaitingForButtonURL = true
|
||||
|
||||
// Сразу создаём кнопку с placeholder URL (используем валидный URL)
|
||||
s.Buttons = append(s.Buttons, InlineButton{
|
||||
Text: buttonText,
|
||||
URL: "https://example.com",
|
||||
})
|
||||
|
||||
// Обновляем превью креатива, чтобы показать новую кнопку
|
||||
s.updateCreativePreview(b)
|
||||
|
||||
// Редактируем панель управления - просим URL
|
||||
s.showControlPanel(b, `<b>💡 Введите URL для кнопки:</b>
|
||||
|
||||
URL должен начинаться с <code>https://</code>
|
||||
|
||||
<i>Например:</i>
|
||||
<code>https://example.com</code>
|
||||
<code>https://t.me/yourchannel</code>`, [][]echotron.InlineKeyboardButton{
|
||||
bot.Row(bot.Button("« Отмена", "cancel_add_button")),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Обработка добавления кнопки - URL
|
||||
if s.WaitingForButtonURL {
|
||||
url := u.Message.Text
|
||||
|
||||
// Базовая валидация URL
|
||||
if !strings.HasPrefix(url, "https://") {
|
||||
s.showControlPanel(b, `<b>❌ Неверный формат URL</b>
|
||||
|
||||
URL должен начинаться с <code>https://</code>
|
||||
|
||||
Попробуйте ещё раз.`, [][]echotron.InlineKeyboardButton{
|
||||
bot.Row(bot.Button("« Отмена", "cancel_add_button")),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Обновляем URL последней кнопки (которую создали с placeholder)
|
||||
if len(s.Buttons) > 0 {
|
||||
s.Buttons[len(s.Buttons)-1].URL = url
|
||||
}
|
||||
s.WaitingForButtonURL = false
|
||||
s.CurrentButtonText = nil
|
||||
|
||||
// Обновляем креатив и возвращаем панель управления
|
||||
s.updateCreativePreview(b)
|
||||
s.showManagementPanel(b)
|
||||
return
|
||||
}
|
||||
|
||||
// Шаг 2: Получаем текст
|
||||
if s.Text == nil {
|
||||
text := s.getFormattedText(u.Message)
|
||||
s.Text = &text
|
||||
|
||||
// Обновляем креатив и возвращаем панель управления
|
||||
s.updateCreativePreview(b)
|
||||
s.showManagementPanel(b)
|
||||
return
|
||||
}
|
||||
|
||||
// Если текст уже задан - это повторное редактирование
|
||||
text := s.getFormattedText(u.Message)
|
||||
s.Text = &text
|
||||
|
||||
// Обновляем креатив и возвращаем панель управления
|
||||
s.updateCreativePreview(b)
|
||||
s.showManagementPanel(b)
|
||||
}
|
||||
|
||||
func (s *AddCreative) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
// getFormattedText конвертирует текст сообщения с entities в HTML
|
||||
func (s *AddCreative) getFormattedText(message *echotron.Message) string {
|
||||
if message.Text == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Если нет entities - возвращаем текст как есть
|
||||
if len(message.Entities) == 0 {
|
||||
return message.Text
|
||||
}
|
||||
|
||||
// Преобразуем текст в руны для правильной работы с UTF-8
|
||||
runes := []rune(message.Text)
|
||||
result := ""
|
||||
lastPos := 0
|
||||
|
||||
// Обрабатываем entities
|
||||
for _, entity := range message.Entities {
|
||||
// Добавляем текст до entity
|
||||
if entity.Offset > lastPos {
|
||||
result += string(runes[lastPos:entity.Offset])
|
||||
}
|
||||
|
||||
// Получаем текст entity
|
||||
entityText := string(runes[entity.Offset : entity.Offset+entity.Length])
|
||||
|
||||
// Оборачиваем в соответствующие HTML-теги
|
||||
switch entity.Type {
|
||||
case "bold":
|
||||
result += "<b>" + entityText + "</b>"
|
||||
case "italic":
|
||||
result += "<i>" + entityText + "</i>"
|
||||
case "underline":
|
||||
result += "<u>" + entityText + "</u>"
|
||||
case "strikethrough":
|
||||
result += "<s>" + entityText + "</s>"
|
||||
case "code":
|
||||
result += "<code>" + entityText + "</code>"
|
||||
case "pre":
|
||||
result += "<pre>" + entityText + "</pre>"
|
||||
case "text_link":
|
||||
if entity.URL != "" {
|
||||
result += fmt.Sprintf("<a href=\"%s\">%s</a>", entity.URL, entityText)
|
||||
} else {
|
||||
result += entityText
|
||||
}
|
||||
case "url":
|
||||
result += entityText
|
||||
default:
|
||||
result += entityText
|
||||
}
|
||||
|
||||
lastPos = entity.Offset + entity.Length
|
||||
}
|
||||
|
||||
// Добавляем оставшийся текст
|
||||
if lastPos < len(runes) {
|
||||
result += string(runes[lastPos:])
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// sendCreativeAndPanel отправляет превью креатива и панель управления
|
||||
func (s *AddCreative) sendCreativeAndPanel(b *bot.Bot) {
|
||||
// 1. Отправляем превью креатива
|
||||
s.sendCreativePreview(b)
|
||||
|
||||
// 2. Отправляем панель управления
|
||||
s.showManagementPanel(b)
|
||||
}
|
||||
|
||||
// sendCreativePreview отправляет новое сообщение с превью креатива (с медиа или без)
|
||||
func (s *AddCreative) sendCreativePreview(b *bot.Bot) {
|
||||
creativeText := s.getCreativeText()
|
||||
creativeButtons := s.getCreativeButtons()
|
||||
|
||||
// Создаем клавиатуру с пустым массивом (не nil!)
|
||||
keyboard := echotron.InlineKeyboardMarkup{
|
||||
InlineKeyboard: creativeButtons,
|
||||
}
|
||||
if len(creativeButtons) == 0 {
|
||||
keyboard.InlineKeyboard = [][]echotron.InlineKeyboardButton{}
|
||||
}
|
||||
|
||||
var msgID int
|
||||
|
||||
// Если есть медиа, отправляем с медиа
|
||||
if s.MediaFileID != "" {
|
||||
var res echotron.APIResponseMessage
|
||||
var err error
|
||||
|
||||
switch s.MediaType {
|
||||
case "photo":
|
||||
res, err = b.SendPhoto(
|
||||
echotron.NewInputFileID(s.MediaFileID),
|
||||
b.ChatID,
|
||||
&echotron.PhotoOptions{
|
||||
Caption: creativeText,
|
||||
ParseMode: echotron.HTML,
|
||||
ReplyMarkup: keyboard,
|
||||
},
|
||||
)
|
||||
case "video":
|
||||
res, err = b.SendVideo(
|
||||
echotron.NewInputFileID(s.MediaFileID),
|
||||
b.ChatID,
|
||||
&echotron.VideoOptions{
|
||||
Caption: creativeText,
|
||||
ParseMode: echotron.HTML,
|
||||
ReplyMarkup: keyboard,
|
||||
},
|
||||
)
|
||||
case "animation":
|
||||
res, err = b.SendAnimation(
|
||||
echotron.NewInputFileID(s.MediaFileID),
|
||||
b.ChatID,
|
||||
&echotron.AnimationOptions{
|
||||
Caption: creativeText,
|
||||
ParseMode: echotron.HTML,
|
||||
ReplyMarkup: keyboard,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("media_type", s.MediaType).Msg("Failed to send media")
|
||||
// Fallback to text message
|
||||
b.SendNew(creativeText, keyboard)
|
||||
msgID = b.LastMessageID
|
||||
} else if res.Result != nil {
|
||||
msgID = res.Result.ID
|
||||
}
|
||||
} else {
|
||||
// Отправляем обычное текстовое сообщение
|
||||
b.SendNew(creativeText, keyboard)
|
||||
msgID = b.LastMessageID
|
||||
}
|
||||
|
||||
// ВАЖНО: Сохраняем копию значения, а не указатель на переменную!
|
||||
s.CreativeMessageID = &msgID
|
||||
log.Info().Int("creative_msg_id", *s.CreativeMessageID).Bool("has_media", s.MediaFileID != "").Msg("Creative preview sent")
|
||||
}
|
||||
|
||||
// updateCreativePreview обновляет превью креатива
|
||||
func (s *AddCreative) updateCreativePreview(b *bot.Bot) {
|
||||
if s.CreativeMessageID == nil {
|
||||
log.Warn().Msg("updateCreativePreview: CreativeMessageID is nil")
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Int("creative_msg_id", *s.CreativeMessageID).
|
||||
Bool("has_media", s.MediaFileID != "").
|
||||
Str("media_type", s.MediaType).
|
||||
Msg("Updating creative preview")
|
||||
|
||||
// Удаляем оба сообщения (креатив и панель)
|
||||
if s.CreativeMessageID != nil {
|
||||
_, err := b.DeleteMessage(b.ChatID, *s.CreativeMessageID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Int("msg_id", *s.CreativeMessageID).Msg("Failed to delete old creative preview")
|
||||
}
|
||||
}
|
||||
|
||||
if s.ControlPanelMessageID != nil {
|
||||
_, err := b.DeleteMessage(b.ChatID, *s.ControlPanelMessageID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Int("msg_id", *s.ControlPanelMessageID).Msg("Failed to delete old control panel")
|
||||
}
|
||||
s.ControlPanelMessageID = nil
|
||||
}
|
||||
|
||||
// Отправляем оба сообщения заново в правильном порядке
|
||||
s.sendCreativePreview(b)
|
||||
s.showManagementPanel(b)
|
||||
|
||||
log.Info().Int("new_creative_msg_id", *s.CreativeMessageID).Msg("Creative preview updated")
|
||||
}
|
||||
|
||||
// getCreativeText возвращает текст для превью креатива
|
||||
func (s *AddCreative) getCreativeText() string {
|
||||
if s.Text != nil {
|
||||
return *s.Text
|
||||
}
|
||||
return `<i> Я твой креатив
|
||||
|
||||
У меня пока нет текста, добавь его ниже.
|
||||
|
||||
</i>`
|
||||
}
|
||||
|
||||
// getCreativeButtons возвращает кнопки для превью креатива
|
||||
func (s *AddCreative) getCreativeButtons() [][]echotron.InlineKeyboardButton {
|
||||
var buttons [][]echotron.InlineKeyboardButton
|
||||
|
||||
for _, btn := range s.Buttons {
|
||||
buttons = append(buttons, []echotron.InlineKeyboardButton{
|
||||
{
|
||||
Text: btn.Text,
|
||||
URL: btn.URL,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return buttons
|
||||
}
|
||||
|
||||
// showManagementPanel показывает панель управления креативом
|
||||
func (s *AddCreative) showManagementPanel(b *bot.Bot) {
|
||||
text := `<b>☝️ Выше отображается <b>превью вашего креатива</b> — так он будет выглядеть при отправке</b>
|
||||
|
||||
Используйте кнопки ниже для редактирования:`
|
||||
|
||||
var buttons [][]echotron.InlineKeyboardButton
|
||||
|
||||
// Кнопки редактирования
|
||||
row1 := []echotron.InlineKeyboardButton{}
|
||||
if s.Text != nil {
|
||||
row1 = append(row1, bot.Button("✏️ Текст", "edit_text"))
|
||||
} else {
|
||||
row1 = append(row1, bot.Button("➕ Текст", "edit_text"))
|
||||
}
|
||||
|
||||
if s.MediaFileID != "" {
|
||||
row1 = append(row1, bot.Button("✏️ Медиа", "add_media"))
|
||||
} else {
|
||||
row1 = append(row1, bot.Button("➕ Медиа", "add_media"))
|
||||
}
|
||||
|
||||
row1 = append(row1, bot.Button("➕ Кнопка", "add_button"))
|
||||
buttons = append(buttons, row1)
|
||||
|
||||
// Кнопка удаления медиа (если медиа добавлено)
|
||||
if s.MediaFileID != "" {
|
||||
buttons = append(buttons, bot.Row(
|
||||
bot.Button("🗑 Удалить медиа", "delete_media"),
|
||||
))
|
||||
}
|
||||
|
||||
// Кнопки удаления (если есть кнопки)
|
||||
for i, btn := range s.Buttons {
|
||||
buttons = append(buttons, bot.Row(
|
||||
bot.Button(fmt.Sprintf("🗑 %s", btn.Text), fmt.Sprintf("delete_button:%d", i)),
|
||||
))
|
||||
}
|
||||
|
||||
// Финальные кнопки
|
||||
if s.Text != nil {
|
||||
buttons = append(buttons, bot.Row(
|
||||
bot.Button("✅ Подтвердить", "confirm_create"),
|
||||
bot.Button("« Отмена", "cancel"),
|
||||
))
|
||||
} else {
|
||||
buttons = append(buttons, bot.Row(
|
||||
bot.Button("« Отмена", "cancel"),
|
||||
))
|
||||
}
|
||||
|
||||
s.showControlPanel(b, text, buttons)
|
||||
}
|
||||
|
||||
// showControlPanel редактирует панель управления с произвольным текстом и кнопками
|
||||
func (s *AddCreative) showControlPanel(b *bot.Bot, text string, buttons [][]echotron.InlineKeyboardButton) {
|
||||
if s.ControlPanelMessageID == nil {
|
||||
// Отправляем новую панель
|
||||
keyboard := bot.Keyboard(buttons...)
|
||||
b.SendNew(text, keyboard)
|
||||
// ВАЖНО: Сохраняем копию значения, а не указатель на переменную!
|
||||
controlPanelMsgID := b.LastMessageID
|
||||
s.ControlPanelMessageID = &controlPanelMsgID
|
||||
log.Info().Int("control_panel_msg_id", *s.ControlPanelMessageID).Msg("Control panel sent")
|
||||
} else {
|
||||
// Редактируем существующую панель
|
||||
log.Info().Int("control_panel_msg_id", *s.ControlPanelMessageID).Msg("Editing control panel")
|
||||
oldLastMessageID := b.LastMessageID
|
||||
b.LastMessageID = *s.ControlPanelMessageID
|
||||
|
||||
keyboard := bot.Keyboard(buttons...)
|
||||
b.Edit(text, keyboard)
|
||||
|
||||
b.LastMessageID = oldLastMessageID
|
||||
log.Info().Msg("Control panel updated")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AddCreative) createCreative(b *bot.Bot) {
|
||||
// Получаем JWT
|
||||
jwt, err := s.ensureJWT(b)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to get JWT")
|
||||
b.SendNew("<b>❌ Ошибка авторизации</b>\n\nПопробуйте /start login", bot.Keyboard())
|
||||
return
|
||||
}
|
||||
|
||||
// Создаём креатив через API
|
||||
creative, err := b.Backend.CreateCreative(
|
||||
context.Background(),
|
||||
jwt,
|
||||
s.WorkspaceID,
|
||||
s.ProjectID,
|
||||
backend.CreateCreativeInput{
|
||||
Name: *s.Name,
|
||||
Text: *s.Text,
|
||||
},
|
||||
)
|
||||
|
||||
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 = `<b>❌ Ошибка валидации</b>
|
||||
|
||||
Текст должен содержать <b>ОДНУ</b> инвайт-ссылку вашего канала!
|
||||
|
||||
<b>Формат ссылки:</b>
|
||||
<code>https://t.me/+xxx</code>
|
||||
|
||||
<b>Пример правильного текста:</b>
|
||||
<i>Присоединяйтесь к нашему каналу!
|
||||
https://t.me/+AbCdEfGhIjKlMn</i>`
|
||||
} else if strings.Contains(errMsg, "Creative text must contain only one invite link") {
|
||||
userMsg = `<b>❌ Слишком много ссылок</b>
|
||||
|
||||
Текст должен содержать <b>ТОЛЬКО ОДНУ</b> инвайт-ссылку.
|
||||
У вас их несколько. Удалите лишние.`
|
||||
} else {
|
||||
userMsg = `<b>❌ Ошибка создания</b>
|
||||
|
||||
Не удалось создать креатив.
|
||||
Попробуйте ещё раз или вернитесь назад.`
|
||||
}
|
||||
|
||||
b.SendNew(userMsg, bot.Keyboard(
|
||||
bot.Row(bot.Button("« Назад", "back_from_preview")),
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// Успех! Показываем сообщение и возвращаемся
|
||||
successText := `<b>✅ Креатив успешно создан!</b>
|
||||
|
||||
📄 <b>Название:</b> ` + creative.Name
|
||||
|
||||
if len(s.Buttons) > 0 {
|
||||
successText += fmt.Sprintf(`
|
||||
🔘 <b>Кнопок добавлено:</b> %d`, len(s.Buttons))
|
||||
}
|
||||
|
||||
b.SendNew(successText, bot.Keyboard())
|
||||
|
||||
// Возвращаемся на экран креативов
|
||||
if s.BackState != nil {
|
||||
b.SetState(s.BackState, bot.NewMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AddCreative) ensureJWT(b *bot.Bot) (string, error) {
|
||||
// Проверяем, есть ли JWT в сессии
|
||||
if b.Session.JWT != "" {
|
||||
return b.Session.JWT, nil
|
||||
}
|
||||
|
||||
// Если нет, запрашиваем новый
|
||||
jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Сохраняем в сессию
|
||||
b.Session.JWT = jwt
|
||||
return jwt, nil
|
||||
}
|
||||
59
telegram_bot/screens/add_project.go
Normal file
59
telegram_bot/screens/add_project.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"example.com/m/bot"
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
)
|
||||
|
||||
const addProjectInstructionText = `
|
||||
<b>Как добавить канал в проект</b>
|
||||
|
||||
<b>Инструкция:</b>
|
||||
|
||||
<b>1.</b> Добавьте бота в ваш канал как администратора
|
||||
|
||||
<b>2.</b> Дайте боту следующие права:
|
||||
• Удаление сообщений
|
||||
• Приглашение пользователей
|
||||
• Блокировка пользователей
|
||||
|
||||
<b>3.</b> После добавления бота:
|
||||
• Если у вас <b>1 рабочее пространство</b> — канал добавится автоматически
|
||||
• Если у вас <b>несколько рабочих пространств</b> — вы получите уведомление с кнопкой выбора
|
||||
|
||||
<b>4.</b> Готово! Канал появится в списке проектов
|
||||
`
|
||||
|
||||
type AddProject struct {
|
||||
BackState bot.State
|
||||
}
|
||||
|
||||
func (s *AddProject) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
buttons := [][]echotron.InlineKeyboardButton{
|
||||
bot.Row(bot.Button("« Назад", "back")),
|
||||
}
|
||||
|
||||
keyboard := bot.Keyboard(buttons...)
|
||||
b.Render(addProjectInstructionText, 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)
|
||||
}
|
||||
|
||||
default:
|
||||
s.Enter(b, bot.NewMessage)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *AddProject) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *AddProject) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
192
telegram_bot/screens/creatives.go
Normal file
192
telegram_bot/screens/creatives.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"example.com/m/bot"
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const creativesPerPage = 6
|
||||
|
||||
type Creatives struct {
|
||||
CurrentPage int
|
||||
ProjectID string
|
||||
WorkspaceID string
|
||||
BackState bot.State
|
||||
}
|
||||
|
||||
func (s *Creatives) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
jwt, err := s.ensureJWT(b)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to get JWT")
|
||||
b.SendNew("❌ Ошибка авторизации. Попробуйте /start login", bot.Keyboard())
|
||||
return
|
||||
}
|
||||
|
||||
s.renderCreatives(b, mode, jwt)
|
||||
}
|
||||
|
||||
func (s *Creatives) renderCreatives(b *bot.Bot, mode bot.RenderMode, jwt string) {
|
||||
page, err := b.Backend.GetCreatives(
|
||||
context.Background(),
|
||||
jwt,
|
||||
s.WorkspaceID,
|
||||
&s.ProjectID,
|
||||
false, // includeArchived
|
||||
s.CurrentPage+1,
|
||||
creativesPerPage,
|
||||
)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to get creatives")
|
||||
b.SendNew("❌ Не удалось загрузить креативы", bot.Keyboard())
|
||||
return
|
||||
}
|
||||
|
||||
text := "Креативы • Управление рекламными материалами\n\n"
|
||||
|
||||
var buttons [][]echotron.InlineKeyboardButton
|
||||
|
||||
if len(page.Items) == 0 {
|
||||
text += "У вас пока нет креативов"
|
||||
} else {
|
||||
text += fmt.Sprintf("Выберите креатив для управления (стр. %d из %d):", s.CurrentPage+1, page.Pages)
|
||||
|
||||
// Креативы по 2 в ряду
|
||||
for i := 0; i < len(page.Items); i += 2 {
|
||||
var row []echotron.InlineKeyboardButton
|
||||
|
||||
// Первый креатив в ряду
|
||||
creative := page.Items[i]
|
||||
row = append(row, bot.Button(creative.Name, fmt.Sprintf("creative:%s", creative.ID)))
|
||||
|
||||
// Второй креатив в ряду (если есть)
|
||||
if i+1 < len(page.Items) {
|
||||
creative = page.Items[i+1]
|
||||
row = append(row, bot.Button(creative.Name, fmt.Sprintf("creative:%s", creative.ID)))
|
||||
} else {
|
||||
// Добавляем пустую кнопку для выравнивания
|
||||
row = append(row, bot.Button(" ", "empty"))
|
||||
}
|
||||
|
||||
buttons = append(buttons, row)
|
||||
}
|
||||
|
||||
// Кнопки пагинации с номером страницы посередине (только если есть креативы)
|
||||
var navRow []echotron.InlineKeyboardButton
|
||||
|
||||
// Левая стрелка (активная или неактивная)
|
||||
if s.CurrentPage > 0 {
|
||||
navRow = append(navRow, bot.Button("←", "prev"))
|
||||
} else {
|
||||
navRow = append(navRow, bot.Button("·", "empty"))
|
||||
}
|
||||
|
||||
// Кнопка с номером страницы (неактивная)
|
||||
pageButton := bot.Button(fmt.Sprintf("• %d/%d •", s.CurrentPage+1, page.Pages), "page_info")
|
||||
navRow = append(navRow, pageButton)
|
||||
|
||||
// Правая стрелка (активная или неактивная)
|
||||
if s.CurrentPage+1 < page.Pages {
|
||||
navRow = append(navRow, bot.Button("→", "next"))
|
||||
} else {
|
||||
navRow = append(navRow, bot.Button("·", "empty"))
|
||||
}
|
||||
|
||||
buttons = append(buttons, navRow)
|
||||
}
|
||||
|
||||
// Кнопки управления
|
||||
buttons = append(buttons, bot.Row(
|
||||
bot.Button("Архив", "archive"),
|
||||
bot.Button("Добавить креатив", "add_creative"),
|
||||
))
|
||||
|
||||
// Кнопка возврата
|
||||
buttons = append(buttons, bot.Row(bot.Button("« Назад", "back")))
|
||||
|
||||
keyboard := bot.Keyboard(buttons...)
|
||||
b.Render(text, keyboard, mode)
|
||||
}
|
||||
|
||||
func (s *Creatives) ensureJWT(b *bot.Bot) (string, error) {
|
||||
// Проверяем, есть ли JWT в сессии
|
||||
if b.Session.JWT != "" {
|
||||
return b.Session.JWT, nil
|
||||
}
|
||||
|
||||
// Если нет, запрашиваем новый
|
||||
jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Сохраняем в сессию
|
||||
b.Session.JWT = jwt
|
||||
return jwt, nil
|
||||
}
|
||||
|
||||
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 == "page_info", data == "empty":
|
||||
// Неактивные кнопки - ничего не делаем
|
||||
return
|
||||
|
||||
case data == "archive":
|
||||
// TODO: реализовать экран архива креативов
|
||||
b.Edit("📦 Архив креативов\n\nЭта функция в разработке", bot.Keyboard(
|
||||
bot.Row(bot.Button("« Назад", "back")),
|
||||
))
|
||||
|
||||
case data == "add_creative":
|
||||
b.SetState(&AddCreative{
|
||||
ProjectID: s.ProjectID,
|
||||
WorkspaceID: s.WorkspaceID,
|
||||
BackState: s,
|
||||
}, bot.EditMessage)
|
||||
|
||||
case strings.HasPrefix(data, "creative:"):
|
||||
// Открытие деталей креатива
|
||||
parts := strings.Split(data, ":")
|
||||
if len(parts) == 2 {
|
||||
creativeID := parts[1]
|
||||
|
||||
// TODO: реализовать экран деталей креатива
|
||||
b.Edit(fmt.Sprintf("📄 Детали креатива\n\nID: %s\n\nЭта функция в разработке", creativeID), bot.Keyboard(
|
||||
bot.Row(bot.Button("« Назад", "back")),
|
||||
))
|
||||
}
|
||||
|
||||
default:
|
||||
s.Enter(b, bot.NewMessage)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Creatives) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *Creatives) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
47
telegram_bot/screens/login.go
Normal file
47
telegram_bot/screens/login.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"example.com/m/bot"
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type Login struct{}
|
||||
|
||||
func (s *Login) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
telegramID := b.ChatID
|
||||
var username *string
|
||||
|
||||
chatInfo, err := b.GetChat(telegramID)
|
||||
if err == nil && chatInfo.Result != nil && chatInfo.Result.Username != "" {
|
||||
username = &chatInfo.Result.Username
|
||||
}
|
||||
|
||||
token, err := b.Backend.CreateLoginToken(context.Background(), telegramID, username)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to create login token")
|
||||
b.SendMessage("❌ Произошла ошибка при создании токена входа. Попробуйте позже.", b.ChatID, nil)
|
||||
return
|
||||
}
|
||||
|
||||
loginURL := b.Backend.GetLoginURL(token)
|
||||
|
||||
usernameDisplay := "аноним"
|
||||
if username != nil && *username != "" {
|
||||
usernameDisplay = *username
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("Привет, %s!\n\nНажми кнопку ниже для входа на сайт.", usernameDisplay)
|
||||
b.SendMessageWithURLButton(message, "Войти на сайт", loginURL)
|
||||
}
|
||||
func (s *Login) HandleCallback(b *bot.Bot, u *echotron.Update) { return }
|
||||
|
||||
func (s *Login) HandleMessage(b *bot.Bot, u *echotron.Update) { return }
|
||||
|
||||
func (s *Login) Handle(b *bot.Bot, u *echotron.Update) {
|
||||
b.SetState(&MainMenu{}, bot.NewMessage)
|
||||
return
|
||||
}
|
||||
57
telegram_bot/screens/main_menu.go
Normal file
57
telegram_bot/screens/main_menu.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"example.com/m/bot"
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
)
|
||||
|
||||
type MainMenu struct{}
|
||||
|
||||
func (s *MainMenu) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
keyboard := bot.Keyboard(
|
||||
bot.Row(bot.Button("Мои проекты", "my_projects"), bot.Button("Поддержка", "support")),
|
||||
bot.Row(bot.Button("Закупы", "placements"), bot.Button("Дашборд", "dashboard")),
|
||||
)
|
||||
|
||||
b.Render(tgexAbout, 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 "support":
|
||||
// b.SetState(&Support{}, bot.NewMessage)
|
||||
case "placements":
|
||||
// b.SetState(&Placements{}, bot.NewMessage)
|
||||
case "dashboard":
|
||||
// b.SetState(&Dashboard{}, bot.NewMessage)
|
||||
default:
|
||||
s.Enter(b, bot.NewMessage)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *MainMenu) HandleMessage(b *bot.Bot, u *echotron.Update) {
|
||||
if u.Message.Text == "/start login" {
|
||||
b.SetState(&Login{}, bot.NewMessage)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *MainMenu) Handle(b *bot.Bot, u *echotron.Update) {
|
||||
b.SetState(&MainMenu{}, bot.NewMessage)
|
||||
return
|
||||
}
|
||||
|
||||
const tgexAbout = `
|
||||
TGEX — Telegram-бот для покупки и продажи рекламы без ЛС и ручных таблиц.
|
||||
|
||||
• Внутренний счёт и безопасные сделки
|
||||
• Фиксация цен, дат и форматов
|
||||
• Автоматические отчёты
|
||||
• Повторные закупки в пару кликов
|
||||
|
||||
Комиссия — 7%. Без скрытых платежей.`
|
||||
222
telegram_bot/screens/my_projects.go
Normal file
222
telegram_bot/screens/my_projects.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"example.com/m/bot"
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const projectsPerPage = 6
|
||||
|
||||
type MyProjects struct {
|
||||
CurrentPage int
|
||||
WorkspaceID string
|
||||
BackState bot.State
|
||||
}
|
||||
|
||||
func (s *MyProjects) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
// Получаем или запрашиваем JWT
|
||||
jwt, err := s.ensureJWT(b)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to get JWT")
|
||||
b.SendNew("❌ Ошибка авторизации. Попробуйте /start login", bot.Keyboard())
|
||||
return
|
||||
}
|
||||
|
||||
// Если workspace еще не выбран, получаем список workspace'ов
|
||||
if s.WorkspaceID == "" {
|
||||
workspaces, err := b.Backend.GetWorkspaces(context.Background(), jwt)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to get workspaces")
|
||||
b.SendNew("❌ Не удалось загрузить workspace'ы", bot.Keyboard())
|
||||
return
|
||||
}
|
||||
|
||||
if len(workspaces) == 0 {
|
||||
b.SendNew("У вас пока нет workspace'ов", bot.Keyboard())
|
||||
return
|
||||
}
|
||||
|
||||
// Используем первый workspace по умолчанию
|
||||
s.WorkspaceID = workspaces[0].ID
|
||||
}
|
||||
|
||||
s.renderProjects(b, mode, jwt)
|
||||
}
|
||||
|
||||
func (s *MyProjects) renderProjects(b *bot.Bot, mode bot.RenderMode, jwt string) {
|
||||
page, err := b.Backend.GetProjects(context.Background(), jwt, s.WorkspaceID, s.CurrentPage+1, projectsPerPage)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to get projects")
|
||||
b.SendNew("❌ Не удалось загрузить проекты", bot.Keyboard())
|
||||
return
|
||||
}
|
||||
|
||||
text := "Страница с проектами • Управление каналами\n\n"
|
||||
|
||||
var buttons [][]echotron.InlineKeyboardButton
|
||||
|
||||
if len(page.Items) == 0 {
|
||||
text += "У вас пока нет проектов"
|
||||
} else {
|
||||
text += fmt.Sprintf("Выберите проект для управления (стр. %d из %d):", s.CurrentPage+1, page.Pages)
|
||||
|
||||
// Проекты по 2 в ряду
|
||||
for i := 0; i < len(page.Items); i += 2 {
|
||||
var row []echotron.InlineKeyboardButton
|
||||
|
||||
// Первый проект в ряду
|
||||
project := page.Items[i]
|
||||
row = append(row, bot.Button(project.Title, fmt.Sprintf("project:%s", project.ID)))
|
||||
|
||||
// Второй проект в ряду (если есть)
|
||||
if i+1 < len(page.Items) {
|
||||
project = page.Items[i+1]
|
||||
row = append(row, bot.Button(project.Title, fmt.Sprintf("project:%s", project.ID)))
|
||||
} else {
|
||||
// Добавляем пустую кнопку для выравнивания
|
||||
row = append(row, bot.Button(" ", "empty"))
|
||||
}
|
||||
|
||||
buttons = append(buttons, row)
|
||||
}
|
||||
|
||||
// Кнопки пагинации с номером страницы посередине (только если есть проекты)
|
||||
var navRow []echotron.InlineKeyboardButton
|
||||
|
||||
// Левая стрелка (активная или неактивная)
|
||||
if s.CurrentPage > 0 {
|
||||
navRow = append(navRow, bot.Button("←", "prev"))
|
||||
} else {
|
||||
navRow = append(navRow, bot.Button("·", "empty"))
|
||||
}
|
||||
|
||||
// Кнопка с номером страницы (неактивная)
|
||||
pageButton := bot.Button(fmt.Sprintf("• %d/%d •", s.CurrentPage+1, page.Pages), "page_info")
|
||||
navRow = append(navRow, pageButton)
|
||||
|
||||
// Правая стрелка (активная или неактивная)
|
||||
if s.CurrentPage+1 < page.Pages {
|
||||
navRow = append(navRow, bot.Button("→", "next"))
|
||||
} else {
|
||||
navRow = append(navRow, bot.Button("·", "empty"))
|
||||
}
|
||||
|
||||
buttons = append(buttons, navRow)
|
||||
}
|
||||
|
||||
// Кнопки управления
|
||||
buttons = append(buttons, bot.Row(
|
||||
bot.Button("Архив", "archive"),
|
||||
bot.Button("Добавить проект", "add_project"),
|
||||
))
|
||||
|
||||
// Кнопка возврата в главное меню
|
||||
buttons = append(buttons, bot.Row(bot.Button("🏠 Главное меню", "menu")))
|
||||
|
||||
keyboard := bot.Keyboard(buttons...)
|
||||
b.Render(text, keyboard, mode)
|
||||
}
|
||||
|
||||
func (s *MyProjects) ensureJWT(b *bot.Bot) (string, error) {
|
||||
// Проверяем, есть ли JWT в сессии
|
||||
if b.Session.JWT != "" {
|
||||
return b.Session.JWT, nil
|
||||
}
|
||||
|
||||
// Если нет, запрашиваем новый
|
||||
jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Сохраняем в сессию
|
||||
b.Session.JWT = jwt
|
||||
return jwt, nil
|
||||
}
|
||||
|
||||
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 == "menu":
|
||||
b.SetState(&MainMenu{}, bot.EditMessage)
|
||||
|
||||
case data == "page_info", data == "empty":
|
||||
// Неактивные кнопки - ничего не делаем
|
||||
return
|
||||
|
||||
case data == "archive":
|
||||
// TODO: реализовать экран архива
|
||||
b.Edit("📦 Архив проектов\n\nЭта функция в разработке", bot.Keyboard(
|
||||
bot.Row(bot.Button("« Назад", "back_to_projects")),
|
||||
))
|
||||
|
||||
case data == "add_project":
|
||||
b.SetState(&AddProject{
|
||||
BackState: &MyProjects{WorkspaceID: s.WorkspaceID},
|
||||
}, bot.EditMessage)
|
||||
|
||||
case data == "back_to_projects":
|
||||
s.Enter(b, bot.EditMessage)
|
||||
|
||||
case strings.HasPrefix(data, "project:"):
|
||||
// Открытие деталей проекта
|
||||
parts := strings.Split(data, ":")
|
||||
if len(parts) == 2 {
|
||||
projectID := parts[1]
|
||||
|
||||
// Получаем проект напрямую по ID
|
||||
project, err := b.Backend.GetProject(
|
||||
context.Background(),
|
||||
s.WorkspaceID,
|
||||
projectID,
|
||||
)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("project_id", projectID).Msg("Failed to get project")
|
||||
return
|
||||
}
|
||||
|
||||
b.SetState(&ProjectDetails{
|
||||
Project: project,
|
||||
WorkspaceID: s.WorkspaceID,
|
||||
BackState: &MyProjects{WorkspaceID: s.WorkspaceID},
|
||||
}, bot.EditMessage)
|
||||
}
|
||||
|
||||
case strings.HasPrefix(data, "workspace:"):
|
||||
// Позволяет переключаться между workspace'ами в будущем
|
||||
parts := strings.Split(data, ":")
|
||||
if len(parts) == 2 {
|
||||
s.WorkspaceID = parts[1]
|
||||
s.CurrentPage = 0
|
||||
s.Enter(b, bot.EditMessage)
|
||||
}
|
||||
|
||||
default:
|
||||
s.Enter(b, bot.NewMessage)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *MyProjects) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *MyProjects) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
210
telegram_bot/screens/project_details.go
Normal file
210
telegram_bot/screens/project_details.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"example.com/m/adapter/backend"
|
||||
"example.com/m/bot"
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type ProjectDetails struct {
|
||||
Project *backend.Project
|
||||
WorkspaceID string
|
||||
BackState bot.State
|
||||
confirmingLinkTypeChange bool // Флаг для двухшагового подтверждения смены типа ссылки
|
||||
}
|
||||
|
||||
func (s *ProjectDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
if s.Project == nil {
|
||||
b.SendNew("❌ Проект не найден", bot.Keyboard(
|
||||
bot.Row(bot.Button("« Назад", "back_to_projects")),
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
text := s.formatProjectDetails()
|
||||
keyboard := s.buildKeyboard()
|
||||
b.Render(text, keyboard, mode)
|
||||
}
|
||||
|
||||
func (s *ProjectDetails) formatProjectDetails() string {
|
||||
p := s.Project
|
||||
|
||||
// Заголовок
|
||||
text := fmt.Sprintf(`<b>%s</b>`, p.Title)
|
||||
|
||||
// Информация о канале
|
||||
if p.Username != nil && *p.Username != "" {
|
||||
text += fmt.Sprintf(`
|
||||
<b>Канал:</b> @%s`, *p.Username)
|
||||
} else {
|
||||
text += `
|
||||
<b>Канал:</b> <i>не привязан</i>`
|
||||
}
|
||||
|
||||
// Статус проекта
|
||||
var statusText string
|
||||
switch p.Status {
|
||||
case "active":
|
||||
statusText = "Активный"
|
||||
case "inactive":
|
||||
statusText = "Неактивен"
|
||||
case "archived":
|
||||
statusText = "Архивный"
|
||||
case "paused":
|
||||
statusText = "Приостановлен"
|
||||
default:
|
||||
statusText = p.Status
|
||||
}
|
||||
text += fmt.Sprintf(`
|
||||
<b>Статус:</b> %s`, statusText)
|
||||
|
||||
// ID проекта (для технических целей)
|
||||
text += fmt.Sprintf(`
|
||||
<b>ID:</b> <code>%s</code>`, p.ID)
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
func (s *ProjectDetails) buildKeyboard() echotron.InlineKeyboardMarkup {
|
||||
var buttons [][]echotron.InlineKeyboardButton
|
||||
|
||||
// Первый ряд: Креативы и Создать закуп
|
||||
buttons = append(buttons, bot.Row(
|
||||
bot.Button("Креативы", fmt.Sprintf("creatives:%s", s.Project.ID)),
|
||||
bot.Button("Создать закуп", fmt.Sprintf("create_purchase:%s", s.Project.ID)),
|
||||
))
|
||||
|
||||
// Второй ряд: План закупов
|
||||
buttons = append(buttons, bot.Row(
|
||||
bot.Button("План закупов", fmt.Sprintf("purchase_plan:%s", s.Project.ID)),
|
||||
))
|
||||
|
||||
// Третий ряд: Тип ссылки по умолчанию
|
||||
var linkTypeButtonText string
|
||||
if s.confirmingLinkTypeChange {
|
||||
// Показываем текст подтверждения с противоположным типом
|
||||
var newType string
|
||||
if s.Project.InviteLinkType == "public" {
|
||||
newType = "С одобрением"
|
||||
} else {
|
||||
newType = "Открытая"
|
||||
}
|
||||
linkTypeButtonText = fmt.Sprintf("Нажмите еще раз → %s", newType)
|
||||
} else {
|
||||
// Показываем текущий тип
|
||||
var currentType string
|
||||
if s.Project.InviteLinkType == "public" {
|
||||
currentType = "Открытая"
|
||||
} else {
|
||||
currentType = "С одобрением"
|
||||
}
|
||||
linkTypeButtonText = fmt.Sprintf("Тип ссылки: %s", currentType)
|
||||
}
|
||||
buttons = append(buttons, bot.Row(
|
||||
bot.Button(linkTypeButtonText, fmt.Sprintf("link_type:%s", s.Project.ID)),
|
||||
))
|
||||
|
||||
// Четвертый ряд: Команда и Архивировать
|
||||
buttons = append(buttons, bot.Row(
|
||||
bot.Button("👥 Команда", fmt.Sprintf("team:%s", s.Project.ID)),
|
||||
bot.Button("Архивировать", fmt.Sprintf("archive:%s", s.Project.ID)),
|
||||
))
|
||||
|
||||
// Пятый ряд: Назад и Главное меню
|
||||
buttons = append(buttons, bot.Row(
|
||||
bot.Button("Назад", "back_to_projects"),
|
||||
bot.Button("Главное меню", "main_menu"),
|
||||
))
|
||||
|
||||
return bot.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_to_projects", data == "back":
|
||||
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
|
||||
if s.BackState != nil {
|
||||
b.SetState(s.BackState, bot.EditMessage)
|
||||
}
|
||||
|
||||
case data == "main_menu":
|
||||
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
|
||||
b.SetState(&MainMenu{}, bot.NewMessage)
|
||||
|
||||
case strings.HasPrefix(data, "creatives:"):
|
||||
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
|
||||
b.SetState(&Creatives{
|
||||
ProjectID: s.Project.ID,
|
||||
WorkspaceID: s.WorkspaceID,
|
||||
BackState: s,
|
||||
}, bot.NewMessage)
|
||||
|
||||
case data == "back_to_project":
|
||||
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
|
||||
s.Enter(b, bot.EditMessage)
|
||||
|
||||
case strings.HasPrefix(data, "link_type:"):
|
||||
if !s.confirmingLinkTypeChange {
|
||||
// Первое нажатие - включаем режим подтверждения
|
||||
s.confirmingLinkTypeChange = true
|
||||
s.Enter(b, bot.EditMessage)
|
||||
} else {
|
||||
// Второе нажатие - меняем тип ссылки
|
||||
var newType string
|
||||
if s.Project.InviteLinkType == "public" {
|
||||
newType = "approval"
|
||||
} else {
|
||||
newType = "public"
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Str("workspace_id", s.WorkspaceID).
|
||||
Str("project_id", s.Project.ID).
|
||||
Str("old_type", s.Project.InviteLinkType).
|
||||
Str("new_type", newType).
|
||||
Msg("Updating project invite link type")
|
||||
|
||||
updatedProject, err := b.Backend.UpdateProjectInviteLinkType(
|
||||
context.Background(),
|
||||
b.Session.JWT,
|
||||
s.WorkspaceID,
|
||||
s.Project.ID,
|
||||
newType,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
b.SendNew(fmt.Sprintf("❌ Ошибка при изменении типа ссылки: %v", err), bot.Keyboard(
|
||||
bot.Row(bot.Button("« Назад", "back_to_project")),
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// Обновляем проект и сбрасываем флаг подтверждения
|
||||
s.Project = updatedProject
|
||||
s.confirmingLinkTypeChange = false
|
||||
s.Enter(b, bot.EditMessage)
|
||||
}
|
||||
|
||||
default:
|
||||
// Для остальных кнопок показываем заглушку
|
||||
b.Edit(fmt.Sprintf("🚧 Функция в разработке\n\nCallback: %s", data), bot.Keyboard(
|
||||
bot.Row(bot.Button("« Назад", "back_to_project")),
|
||||
))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *ProjectDetails) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *ProjectDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
149
telegram_bot/screens/select_workspace.go
Normal file
149
telegram_bot/screens/select_workspace.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"example.com/m/bot"
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type SelectWorkspace struct {
|
||||
ChannelID string
|
||||
BackState bot.State
|
||||
}
|
||||
|
||||
func (s *SelectWorkspace) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
jwt, err := s.ensureJWT(b)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to get JWT")
|
||||
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", bot.Keyboard())
|
||||
return
|
||||
}
|
||||
|
||||
workspaces, err := b.Backend.GetWorkspaces(context.Background(), jwt)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to get workspaces")
|
||||
b.SendNew("❌ Не удалось загрузить список workspace'ов", bot.Keyboard())
|
||||
return
|
||||
}
|
||||
|
||||
if len(workspaces) == 0 {
|
||||
b.SendNew("У вас пока нет workspace'ов", bot.Keyboard(
|
||||
bot.Row(bot.Button("🏠 Главное меню", "back")),
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
text := "📁 Выбор workspace для канала\n\n"
|
||||
text += "В какой workspace добавить канал?"
|
||||
|
||||
var buttons [][]echotron.InlineKeyboardButton
|
||||
|
||||
// Создаем кнопки для каждого workspace
|
||||
for _, ws := range workspaces {
|
||||
buttons = append(buttons, bot.Row(
|
||||
bot.Button(ws.Name, fmt.Sprintf("select_workspace:%s", ws.ID)),
|
||||
))
|
||||
}
|
||||
|
||||
// Кнопка отмены
|
||||
buttons = append(buttons, bot.Row(bot.Button("❌ Отмена", "cancel")))
|
||||
|
||||
keyboard := bot.Keyboard(buttons...)
|
||||
b.Render(text, keyboard, mode)
|
||||
}
|
||||
|
||||
func (s *SelectWorkspace) ensureJWT(b *bot.Bot) (string, error) {
|
||||
// Проверяем, есть ли JWT в сессии
|
||||
if b.Session.JWT != "" {
|
||||
return b.Session.JWT, nil
|
||||
}
|
||||
|
||||
// Если нет, запрашиваем новый
|
||||
jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Сохраняем в сессию
|
||||
b.Session.JWT = jwt
|
||||
return jwt, nil
|
||||
}
|
||||
|
||||
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 == "cancel", data == "back":
|
||||
if s.BackState != nil {
|
||||
b.SetState(s.BackState, bot.NewMessage)
|
||||
}
|
||||
|
||||
case strings.HasPrefix(data, "select_workspace:"):
|
||||
// Извлекаем workspace_id
|
||||
parts := strings.Split(data, ":")
|
||||
if len(parts) != 2 {
|
||||
log.Error().Str("callback_data", data).Msg("Invalid callback data format")
|
||||
return
|
||||
}
|
||||
|
||||
workspaceID := parts[1]
|
||||
|
||||
// Показываем индикатор загрузки
|
||||
b.Edit("⏳ Добавляем канал в workspace...", bot.Keyboard())
|
||||
|
||||
// Вызываем API для привязки канала к workspace
|
||||
project, err := b.Backend.AttachChannelToWorkspace(
|
||||
context.Background(),
|
||||
s.ChannelID,
|
||||
workspaceID,
|
||||
b.ChatID,
|
||||
)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("channel_id", s.ChannelID).Str("workspace_id", workspaceID).Msg("Failed to attach channel to workspace")
|
||||
b.Edit("❌ Не удалось добавить канал в workspace", bot.Keyboard(
|
||||
bot.Row(bot.Button("« Назад", "back_to_select")),
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// Показываем успешное сообщение
|
||||
successText := fmt.Sprintf("✅ Канал успешно добавлен!\n\n"+
|
||||
"📊 Проект ID: %s\n"+
|
||||
"📝 Название: %s\n"+
|
||||
"🏷 Статус: %s",
|
||||
project.ID,
|
||||
project.Title,
|
||||
project.Status,
|
||||
)
|
||||
|
||||
b.Edit(successText, bot.Keyboard(
|
||||
bot.Row(bot.Button("📋 Мои проекты", "my_projects")),
|
||||
bot.Row(bot.Button("🏠 Главное меню", "main_menu")),
|
||||
))
|
||||
|
||||
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 }
|
||||
Reference in New Issue
Block a user