This commit is contained in:
Artem Tsyrulnikov
2026-01-09 13:11:32 +03:00
parent 9fbb6fdbbf
commit a0b12dbcca
10 changed files with 257 additions and 119 deletions

View File

@@ -6,19 +6,13 @@ from src import deps, dto
internal_router = APIRouter(prefix='/internal', tags=['internal']) internal_router = APIRouter(prefix='/internal', tags=['internal'])
class CreateLoginTokenResponse(BaseModel): class CreateLoginTokenResponse(BaseModel):
token: str token: str
@internal_router.post('/auth/login-token') @internal_router.post('/auth/login-token')
async def create_login_token(input: dto.CreateLoginTokenRequest) -> str: async def create_login_token(input: dto.CreateLoginTokenRequest) -> str:
token = await deps.get_usecase().create_telegram_login_token( return await deps.get_usecase().create_telegram_login_token(telegram_id=input.telegram_id)
telegram_id=input.telegram_id,
username=input.username,
)
return token
class ValidateTokenRequest(BaseModel): class ValidateTokenRequest(BaseModel):
@@ -27,7 +21,6 @@ class ValidateTokenRequest(BaseModel):
username: str | None = None username: str | None = None
@internal_router.get('/auth/jwt') @internal_router.get('/auth/jwt')
async def get_jwt_by_telegram_id( async def get_jwt_by_telegram_id(
telegram_id: int, telegram_id: int,

View File

@@ -98,19 +98,19 @@ from .analytics import (
GetChannelAnalyticsOutput, GetChannelAnalyticsOutput,
GetCreativesAnalyticsInput, GetCreativesAnalyticsInput,
GetCreativesAnalyticsOutput, GetCreativesAnalyticsOutput,
GetOverviewAnalyticsInput,
GetOverviewAnalyticsOutput,
GetPlacementsAnalyticsInput, GetPlacementsAnalyticsInput,
GetPlacementsAnalyticsOutput, GetPlacementsAnalyticsOutput,
GetOverviewAnalyticsInput,
GetProjectsAnalyticsInput, GetProjectsAnalyticsInput,
GetProjectsAnalyticsOutput, GetProjectsAnalyticsOutput,
GetSpendingAnalyticsInput, GetSpendingAnalyticsInput,
GetSpendingAnalyticsOutput, GetSpendingAnalyticsOutput,
GetOverviewAnalyticsOutput,
NumberWithDelta, NumberWithDelta,
PlacementAnalyticsOutput,
OverviewDailyPoint,
OverviewChannelPerformance, OverviewChannelPerformance,
OverviewDailyPoint,
OverviewProjectSpending, OverviewProjectSpending,
PlacementAnalyticsOutput,
ProjectAnalyticsPeriod, ProjectAnalyticsPeriod,
ProjectMetrics, ProjectMetrics,
ProjectMetricsData, ProjectMetricsData,
@@ -189,4 +189,3 @@ from .workspace import (
class CreateLoginTokenRequest(BaseModel): class CreateLoginTokenRequest(BaseModel):
telegram_id: int telegram_id: int
username: str | None

View File

@@ -10,14 +10,11 @@ if typing.TYPE_CHECKING:
from .. import Usecase from .. import Usecase
async def create_telegram_login_token(self: 'Usecase', telegram_id: int, username: str | None) -> str: async def create_telegram_login_token(self: 'Usecase', telegram_id: int) -> str:
telegram_user = await self.database.get_telegram_user(telegram_id=telegram_id) telegram_user = await self.database.get_telegram_user(telegram_id=telegram_id)
if not telegram_user: if not telegram_user:
telegram_user = domain.TelegramUser(telegram_id=telegram_id, username=username) telegram_user = domain.TelegramUser(telegram_id=telegram_id)
await self.database.create_telegram_user(telegram_user) await self.database.create_telegram_user(telegram_user)
elif username and telegram_user.username != username:
telegram_user.username = username
await self.database.update_telegram_user(telegram_user)
user = await self.database.get_user(telegram_id=telegram_id) user = await self.database.get_user(telegram_id=telegram_id)
if not user: if not user:

View File

@@ -89,31 +89,19 @@ func (c *Client) do(ctx context.Context, method string, path string, in any, out
return nil return nil
} }
func (c *Client) GetLoginURL(token string) string { func (c *Client) LoginURL(token string) string {
return c.loginURL + token return c.loginURL + token
} }
func (c *Client) CreateLoginToken( func (c *Client) CreateLoginToken(ctx context.Context, telegramID int64) (string, error) {
ctx context.Context,
telegramID int64,
username *string,
) (string, error) {
req := struct { req := struct {
TelegramID int64 `json:"telegram_id"` TelegramID int64 `json:"telegram_id"`
Username *string `json:"username"`
}{ }{
TelegramID: telegramID, TelegramID: telegramID,
Username: username,
} }
var token string var token string
err := c.do( err := c.do(ctx, http.MethodPost, "api/v1/internal/auth/login-token", req, &token)
ctx,
http.MethodPost,
"api/v1/internal/auth/login-token",
req,
&token,
)
return token, err return token, err
} }
@@ -313,6 +301,28 @@ func (c *Client) ForwardTelegramUpdate(
) )
} }
func (c *Client) SearchChannels(
ctx context.Context,
jwt string,
username string,
) ([]Channel, error) {
path := fmt.Sprintf("api/v1/channels?username=%s", username)
var response struct {
Items []Channel `json:"items"`
}
err := c.do(
ctx,
http.MethodGet,
path,
nil,
&response,
withBearer(jwt),
)
return response.Items, err
}
func (c *Client) AttachChannelToWorkspace( func (c *Client) AttachChannelToWorkspace(
ctx context.Context, ctx context.Context,
channelID string, channelID string,

View File

@@ -12,6 +12,10 @@ import (
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
var emptyKeyboard = echotron.InlineKeyboardMarkup{
InlineKeyboard: [][]echotron.InlineKeyboardButton{},
}
type Exec struct { type Exec struct {
handled bool handled bool
transitioned bool transitioned bool
@@ -77,23 +81,17 @@ func (b *Bot) GetOrCreateJWT(u *echotron.Update) error {
} }
} }
// Запрашиваем JWT с актуальными данными пользователя (или создаем нового если не существует) jwt, err := b.Backend.GetJWTByTelegramUser(context.Background(), b.ChatID, username, firstName, lastName)
jwt, err := b.Backend.GetJWTByTelegramUser(
context.Background(),
b.ChatID,
username,
firstName,
lastName,
)
if err != nil { if err != nil {
return err return err
} }
// Сохраняем в сессию
b.Session.JWT = jwt b.Session.JWT = jwt
if username != nil {
b.Session.UserName = *username if firstName != nil {
b.Session.FirstName = *firstName
} }
return nil return nil
} }
@@ -276,7 +274,11 @@ func (b *Bot) Update(u *echotron.Update) {
return return
} }
_ = b.GetOrCreateJWT(u) err := b.GetOrCreateJWT(u)
if err != nil || b.Session.JWT == "" {
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", emptyKeyboard)
return
}
exec := &Exec{} exec := &Exec{}
b.exec = exec b.exec = exec
@@ -336,10 +338,6 @@ func (b *Bot) Update(u *echotron.Update) {
} }
func (b *Bot) cleanupKeyboard(messageID int) { func (b *Bot) cleanupKeyboard(messageID int) {
emptyKeyboard := echotron.InlineKeyboardMarkup{
InlineKeyboard: [][]echotron.InlineKeyboardButton{},
}
_, err := b.EditMessageReplyMarkup( _, err := b.EditMessageReplyMarkup(
echotron.NewMessageID(b.ChatID, messageID), echotron.NewMessageID(b.ChatID, messageID),
&echotron.MessageReplyMarkupOptions{ReplyMarkup: emptyKeyboard}, &echotron.MessageReplyMarkupOptions{ReplyMarkup: emptyKeyboard},

View File

@@ -17,6 +17,6 @@ type State interface {
} }
type Session struct { type Session struct {
UserName string FirstName string
JWT string JWT string
} }

View File

@@ -6,42 +6,35 @@ import (
"example.com/m/bot" "example.com/m/bot"
"github.com/NicoNex/echotron/v3" "github.com/NicoNex/echotron/v3"
"github.com/rs/zerolog/log"
) )
type Login struct{} type Login struct{}
func (s *Login) Enter(b *bot.Bot, mode bot.RenderMode) { const msgHelloLogin = `
// JWT уже создан в Bot.Update() с актуальными данными пользователя Привет, %s!
// Теперь создаем login token (это разовый токен для авторизации на сайте) 👇Нажми кнопку для входа на сайт.
var username *string `
// Получаем username из сессии если он там сохранен
if b.Session.UserName != "" {
username = &b.Session.UserName
}
token, err := b.Backend.CreateLoginToken(context.Background(), b.ChatID, username) func (s *Login) Enter(b *bot.Bot, mode bot.RenderMode) {
token, err := b.Backend.CreateLoginToken(context.Background(), b.ChatID)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to create login token") b.SendNew("❌ Произошла ошибка при создании токена входа. Попробуйте позже.", emptyKeyboard)
b.SendMessage("❌ Произошла ошибка при создании токена входа. Попробуйте позже.", b.ChatID, nil)
return return
} }
loginURL := b.Backend.GetLoginURL(token) loginURL := b.Backend.LoginURL(token)
usernameDisplay := "аноним" usernameDisplay := "аноним"
if username != nil && *username != "" { if b.Session.FirstName != "" {
usernameDisplay = *username usernameDisplay = b.Session.FirstName
} }
message := fmt.Sprintf("Привет, %s!\n\nНажми кнопку ниже для входа на сайт.", usernameDisplay) kb := Keyboard(Row(URLButton("Войти на сайт", loginURL)))
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 } b.SendNew(fmt.Sprintf(msgHelloLogin, usernameDisplay), kb)
func (s *Login) Handle(b *bot.Bot, u *echotron.Update) {
b.SetState(&MainMenu{}, bot.NewMessage)
return
} }
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) { b.SetState(&MainMenu{}, bot.NewMessage) }

View File

@@ -158,6 +158,28 @@ func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Updat
return return
} }
// Проверяем существование каналов через API
jwt := b.Session.JWT
if jwt == "" {
log.Error().Msg("JWT is empty in session")
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", Keyboard())
return
}
notFoundChannels := s.validateChannels(b, jwt)
if len(notFoundChannels) > 0 {
text := "❌ Следующие каналы не найдены:\n\n"
for _, username := range notFoundChannels {
text += fmt.Sprintf("• @%s\n", username)
}
text += "\nУдалите их из списка и попробуйте снова"
b.Edit(text, Keyboard(
Row(Button("← Назад", "back_to_select")),
))
return
}
b.SetState(&PurchaseOptionalDetails{ b.SetState(&PurchaseOptionalDetails{
WorkspaceID: s.WorkspaceID, WorkspaceID: s.WorkspaceID,
ProjectID: s.ProjectID, ProjectID: s.ProjectID,
@@ -313,6 +335,39 @@ func (s *SelectChannelsForPurchase) HandleMessage(b *bot.Bot, u *echotron.Update
s.Enter(b, bot.NewMessage) s.Enter(b, bot.NewMessage)
} }
func (s *SelectChannelsForPurchase) validateChannels(b *bot.Bot, jwt string) []string {
var notFound []string
for _, ch := range s.Channels {
channels, err := b.Backend.SearchChannels(
context.Background(),
jwt,
ch.Username,
)
if err != nil {
log.Error().Err(err).Str("username", ch.Username).Msg("Failed to search channel")
notFound = append(notFound, ch.Username)
continue
}
// Проверяем что нашли хотя бы один канал с точным совпадением username
found := false
for _, channel := range channels {
if channel.Username != nil && *channel.Username == ch.Username {
found = true
break
}
}
if !found {
notFound = append(notFound, ch.Username)
}
}
return notFound
}
func (s *SelectChannelsForPurchase) Handle(b *bot.Bot, u *echotron.Update) { func (s *SelectChannelsForPurchase) Handle(b *bot.Bot, u *echotron.Update) {
// Обрабатываем callback после успешного создания // Обрабатываем callback после успешного создания
if u.CallbackQuery != nil && u.CallbackQuery.Data == "done" { if u.CallbackQuery != nil && u.CallbackQuery.Data == "done" {

View File

@@ -6,6 +6,7 @@ import (
"strings" "strings"
"example.com/m/bot" "example.com/m/bot"
"example.com/m/ui"
"github.com/NicoNex/echotron/v3" "github.com/NicoNex/echotron/v3"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@@ -13,48 +14,66 @@ import (
type SelectWorkspace struct { type SelectWorkspace struct {
ChannelID string ChannelID string
BackState bot.State 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) { func (s *SelectWorkspace) Enter(b *bot.Bot, mode bot.RenderMode) {
// JWT уже создан в Bot.Update(), просто берем из сессии workspaces, err := b.Backend.GetWorkspaces(context.Background(), b.Session.JWT)
jwt := b.Session.JWT
if jwt == "" {
log.Error().Msg("JWT is empty in session")
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", Keyboard())
return
}
workspaces, err := b.Backend.GetWorkspaces(context.Background(), jwt)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to get workspaces")
b.SendNew("❌ Не удалось загрузить список workspace'ов", Keyboard()) b.SendNew("❌ Не удалось загрузить список workspace'ов", Keyboard())
return return
} }
if len(workspaces) == 0 { if len(workspaces) == 0 {
b.SendNew("У вас пока нет workspace'ов", Keyboard( kb := Keyboard(Row(Button("Главное меню", "back")))
Row(Button("🏠 Главное меню", "back")), b.SendNew("У вас пока нет workspace'ов", kb)
))
return return
} }
text := "📁 Выбор workspace для канала\n\n" // Создаем кнопки для всех workspace'ов
text += "В какой 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 var buttons [][]echotron.InlineKeyboardButton
// Создаем кнопки для каждого workspace // Раскладываем workspace'ы в grid (2 в ряд) с пагинацией
for _, ws := range workspaces { elementRows := ui.BuildElementRows(ui.ElementLayoutConfig{
buttons = append(buttons, Row( CurrentPage: s.CurrentPage,
Button(ws.Name, fmt.Sprintf("select_workspace:%s", ws.ID)), 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"))) buttons = append(buttons, Row(Button("Отмена", "cancel")))
keyboard := Keyboard(buttons...) kb := Keyboard(buttons...)
b.Render(text, keyboard, mode) b.Render(msgChooseWorkspace, kb, mode)
} }
func (s *SelectWorkspace) HandleCallback(b *bot.Bot, u *echotron.Update) { func (s *SelectWorkspace) HandleCallback(b *bot.Bot, u *echotron.Update) {
@@ -65,13 +84,22 @@ func (s *SelectWorkspace) HandleCallback(b *bot.Bot, u *echotron.Update) {
data := u.CallbackQuery.Data data := u.CallbackQuery.Data
switch { 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": case data == "cancel", data == "back":
if s.BackState != nil { if s.BackState != nil {
b.SetState(s.BackState, bot.NewMessage) b.SetState(s.BackState, bot.NewMessage)
} }
case strings.HasPrefix(data, "select_workspace:"): case strings.HasPrefix(data, "select_workspace:"):
// Извлекаем workspace_id
parts := strings.Split(data, ":") parts := strings.Split(data, ":")
if len(parts) != 2 { if len(parts) != 2 {
log.Error().Str("callback_data", data).Msg("Invalid callback data format") log.Error().Str("callback_data", data).Msg("Invalid callback data format")
@@ -80,10 +108,6 @@ func (s *SelectWorkspace) HandleCallback(b *bot.Bot, u *echotron.Update) {
workspaceID := parts[1] workspaceID := parts[1]
// Показываем индикатор загрузки
b.Edit("⏳ Добавляем канал в workspace...", Keyboard())
// Вызываем API для привязки канала к workspace
project, err := b.Backend.AttachChannelToWorkspace( project, err := b.Backend.AttachChannelToWorkspace(
context.Background(), context.Background(),
s.ChannelID, s.ChannelID,
@@ -91,27 +115,19 @@ func (s *SelectWorkspace) HandleCallback(b *bot.Bot, u *echotron.Update) {
b.ChatID, b.ChatID,
) )
if err != nil { 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", Keyboard( b.Edit("❌ Не удалось добавить канал в workspace", Keyboard(
Row(Button("← Назад", "back_to_select")), Row(Button("← Назад", "back_to_select")),
)) ))
return return
} }
// Показываем успешное сообщение successText := fmt.Sprintf(msgSuccessChooseWorkspace, project.ID, project.Title, project.Status)
successText := fmt.Sprintf("✅ Канал успешно добавлен!\n\n"+ kb := Keyboard(
"📊 Проект ID: %s\n"+ Row(Button("Мои проекты", "my_projects")),
"📝 Название: %s\n"+ Row(Button("Главное меню", "main_menu")),
"🏷 Статус: %s",
project.ID,
project.Title,
project.Status,
) )
b.Edit(successText, Keyboard( b.Edit(successText, kb)
Row(Button("📋 Мои проекты", "my_projects")),
Row(Button("🏠 Главное меню", "main_menu")),
))
case data == "back_to_select": case data == "back_to_select":
s.Enter(b, bot.EditMessage) s.Enter(b, bot.EditMessage)

View File

@@ -96,3 +96,80 @@ func FormatPageInfo(currentPage, totalPages int) string {
} }
return fmt.Sprintf(" <i>(стр. %d/%d)</i>", currentPage+1, totalPages) 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
}