diff --git a/src/adapter/postgres.py b/src/adapter/postgres.py index 8d2b635..847e8e9 100644 --- a/src/adapter/postgres.py +++ b/src/adapter/postgres.py @@ -307,6 +307,10 @@ class Postgres(DatabaseBase, Database): project.deleted_at = datetime.datetime.now() await project.save() + async def check_channel_exists_in_workspace(self, channel_id: uuid.UUID, workspace_id: uuid.UUID) -> bool: + project = await domain.Project.get_or_none(channel_id=channel_id, workspace_id=workspace_id) + return project is not None + async def get_active_purchase(self, project_id: uuid.UUID, creative_id: uuid.UUID) -> domain.Purchase | None: return await domain.Purchase.get_or_none( project_id=project_id, creative_id=creative_id, status=domain.PurchaseStatus.ACTIVE diff --git a/src/adapter/s3.py b/src/adapter/s3.py index 0bae205..3294518 100644 --- a/src/adapter/s3.py +++ b/src/adapter/s3.py @@ -2,6 +2,7 @@ import logging import uuid import aioboto3 +from botocore.config import Config from pydantic import BaseModel from types_aiobotocore_s3.client import S3Client @@ -23,6 +24,19 @@ class S3(S3Storage): self.config = config self.session = aioboto3.Session() self._client: S3Client | None = None + # Конфигурация для S3-совместимых хранилищ (не AWS) + # Отключаем строгую проверку контрольных сумм для совместимости с Beget и другими провайдерами + # См: https://github.com/open-webui/open-webui/issues/16758 + self.boto_config = Config( + signature_version='s3v4', + s3={ + 'payload_signing_enabled': False, + 'addressing_style': 'auto', + }, + # Отключаем строгую проверку контрольных сумм (boto3 >= 1.40.5) + request_checksum_calculation='when_required', + response_checksum_validation='when_required', + ) async def connect(self) -> None: # Создаем клиента для проверки/создания bucket @@ -32,6 +46,7 @@ class S3(S3Storage): aws_access_key_id=self.config.ACCESS_KEY_ID, aws_secret_access_key=self.config.SECRET_ACCESS_KEY, region_name=self.config.REGION, + config=self.boto_config, ) as client: # Проверяем существование bucket try: @@ -54,6 +69,7 @@ class S3(S3Storage): aws_access_key_id=self.config.ACCESS_KEY_ID, aws_secret_access_key=self.config.SECRET_ACCESS_KEY, region_name=self.config.REGION, + config=self.boto_config, ) as client: await client.put_object( Bucket=self.config.BUCKET_NAME, @@ -70,6 +86,7 @@ class S3(S3Storage): aws_access_key_id=self.config.ACCESS_KEY_ID, aws_secret_access_key=self.config.SECRET_ACCESS_KEY, region_name=self.config.REGION, + config=self.boto_config, ) as client: response = await client.get_object(Bucket=self.config.BUCKET_NAME, Key=key) data = await response['Body'].read() @@ -83,6 +100,7 @@ class S3(S3Storage): aws_access_key_id=self.config.ACCESS_KEY_ID, aws_secret_access_key=self.config.SECRET_ACCESS_KEY, region_name=self.config.REGION, + config=self.boto_config, ) as client: await client.delete_object(Bucket=self.config.BUCKET_NAME, Key=key) log.info(f'Deleted file from S3: {key}') diff --git a/src/controller/http_v1/projects.py b/src/controller/http_v1/projects.py index beab770..0997084 100644 --- a/src/controller/http_v1/projects.py +++ b/src/controller/http_v1/projects.py @@ -67,3 +67,18 @@ async def delete_project( project_id=project_id, user_id=current_user.user_id, ) + + +@projects_router.put('/{project_id}/move') +async def move_project( + workspace_id: uuid.UUID, + project_id: uuid.UUID, + request: dto.MoveProjectRequest, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.ProjectOutput: + return await deps.get_usecase().move_project_to_workspace( + user_id=current_user.user_id, + source_workspace_id=workspace_id, + project_id=project_id, + target_workspace_id=request.target_workspace_id, + ) diff --git a/src/domain/__init__.py b/src/domain/__init__.py index 898a1a3..349a119 100644 --- a/src/domain/__init__.py +++ b/src/domain/__init__.py @@ -47,6 +47,7 @@ __all__ = ( 'LoginTokenExpired', 'LoginTokenAlreadyUsed', 'ProjectNotFound', + 'ProjectChannelConflict', 'PurchaseNotFound', 'PurchaseChannelNotFound', 'ChannelNotFound', @@ -78,6 +79,7 @@ from .error import ( LoginTokenNotFound, PlacementNotFound, ProjectNotFound, + ProjectChannelConflict, PurchaseChannelNotFound, PurchaseNotFound, TelegramChannelNotFound, diff --git a/src/domain/error.py b/src/domain/error.py index 702e3cc..9575a10 100644 --- a/src/domain/error.py +++ b/src/domain/error.py @@ -43,6 +43,10 @@ def ProjectNotFound(project_id: uuid.UUID | None = None) -> HTTPException: return HTTPException(status.HTTP_404_NOT_FOUND, f'Project {project_id} not found') +def ProjectChannelConflict() -> HTTPException: + return HTTPException(status.HTTP_409_CONFLICT, 'Project channel already exists in target workspace') + + def PurchaseNotFound(purchase_id: uuid.UUID | None = None) -> HTTPException: if purchase_id is None: return HTTPException(status.HTTP_404_NOT_FOUND, 'Purchase not found') diff --git a/src/dto/__init__.py b/src/dto/__init__.py index 045bd85..0652846 100644 --- a/src/dto/__init__.py +++ b/src/dto/__init__.py @@ -5,6 +5,7 @@ __all__ = ( 'GetWorkspaceProjectsOutput', 'GetProjectInput', 'ArchiveProjectInput', + 'MoveProjectRequest', 'DisconnectProjectByTgIdInput', 'ConnectProjectInput', 'ProjectOutput', @@ -129,6 +130,7 @@ from .project import ( GetProjectInput, GetWorkspaceProjectsInput, GetWorkspaceProjectsOutput, + MoveProjectRequest, ProjectOutput, UpdateProjectInviteLinkTypeInput, UpdateProjectPermissionsInput, diff --git a/src/dto/project.py b/src/dto/project.py index 06678cd..344701d 100644 --- a/src/dto/project.py +++ b/src/dto/project.py @@ -70,3 +70,7 @@ class ArchiveProjectInput(pydantic.BaseModel): workspace_id: uuid.UUID project_id: uuid.UUID user_id: uuid.UUID + + +class MoveProjectRequest(pydantic.BaseModel): + target_workspace_id: uuid.UUID diff --git a/src/usecase/__init__.py b/src/usecase/__init__.py index cb2f136..ca14fa0 100644 --- a/src/usecase/__init__.py +++ b/src/usecase/__init__.py @@ -33,6 +33,7 @@ from .project.delete_project import delete_project from .project.disconnect_project_by_tg_id import disconnect_project_by_tg_id from .project.get_project import get_project from .project.get_workspace_projects import get_workspace_projects +from .project.move_project_to_workspace import move_project_to_workspace from .project.tg_add_project import tg_add_project from .project.update_project_invite_link_type import update_project_invite_link_type from .project.update_project_permissions import update_project_permissions @@ -167,6 +168,8 @@ class Database(typing.Protocol): async def delete_project(self, workspace_id: UUID, project_id: UUID) -> None: ... + async def check_channel_exists_in_workspace(self, channel_id: UUID, workspace_id: UUID) -> bool: ... + async def get_active_purchase(self, project_id: UUID, creative_id: UUID) -> domain.Purchase | None: ... async def get_purchase(self, workspace_id: UUID, purchase_id: UUID) -> domain.Purchase | None: ... @@ -423,6 +426,7 @@ class Usecase: get_project = get_project archive_project = archive_project delete_project = delete_project + move_project_to_workspace = move_project_to_workspace disconnect_project_by_tg_id = disconnect_project_by_tg_id update_project_permissions = update_project_permissions update_project_invite_link_type = update_project_invite_link_type diff --git a/src/usecase/project/move_project_to_workspace.py b/src/usecase/project/move_project_to_workspace.py new file mode 100644 index 0000000..4a3385f --- /dev/null +++ b/src/usecase/project/move_project_to_workspace.py @@ -0,0 +1,72 @@ +import logging +import uuid +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def move_project_to_workspace( + self: 'Usecase', + user_id: uuid.UUID, + source_workspace_id: uuid.UUID, + project_id: uuid.UUID, + target_workspace_id: uuid.UUID, +) -> dto.ProjectOutput: + # Получаем project из source workspace + project = await self.database.get_project(source_workspace_id, project_id=project_id) + if not project: + raise domain.ProjectNotFound(project_id) + + await project.fetch_related('channel') + + # Проверяем что target workspace существует + target_workspace = await self.database.get_workspace(target_workspace_id) + if not target_workspace: + raise domain.WorkspaceNotFound(target_workspace_id) + + # Проверяем permissions в source workspace (scope на конкретный project) + await self.ensure_workspace_permission( + source_workspace_id, user_id, domain.PermissionKey.PROJECTS_WRITE, for_project_id=project_id + ) + + # Проверяем permissions в target workspace + await self.ensure_workspace_permission(target_workspace_id, user_id, domain.PermissionKey.PROJECTS_WRITE) + + # Проверяем что канал не существует в target workspace (UNIQUE constraint) + if await self.database.check_channel_exists_in_workspace(project.channel.id, target_workspace_id): + raise domain.ProjectChannelConflict() + + # Выполняем перенос в транзакции + async with self.database.transaction(): + # Удаляем project-scoped permissions (они теряют смысл в новом workspace) + await domain.WorkspaceUserPermissionScope.filter(project_id=project_id).delete() + + # Обновляем workspace_id + project.workspace_id = target_workspace_id + await self.database.update_project(project) + + # Логирование операции + log.info( + 'Project moved to another workspace', + extra={ + 'project_id': str(project_id), + 'channel_id': str(project.channel.id), + 'source_workspace_id': str(source_workspace_id), + 'target_workspace_id': str(target_workspace_id), + 'user_id': str(user_id), + }, + ) + + return dto.ProjectOutput( + id=project.id, + telegram_id=project.channel.telegram_id, + title=project.channel.title, + username=project.channel.username, + status=project.status, + purchase_invite_type_default=project.purchase_invite_type_default, + ) diff --git a/telegram_bot/bot/bot.go b/telegram_bot/bot/bot.go index f414c6f..1f00c88 100644 --- a/telegram_bot/bot/bot.go +++ b/telegram_bot/bot/bot.go @@ -19,6 +19,13 @@ func Button(text, data string) echotron.InlineKeyboardButton { } } +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 { @@ -44,28 +51,34 @@ type Bot struct { exec *Exec CurrentState State Session *Session - LastMessageID int // ID последнего сообщения с inline кнопками - DefaultStateFactory func() State + LastMessageID int // ID последнего сообщения с inline кнопками + commandRouter func(cmd string) State // Роутер для команд (например, /start, /help) + globalCallbackRouter func(callbackData string) State // Роутер для глобальных callbacks из уведомлений Backend *backend.Client - GlobalCallbackRouter func(callbackData string) State // Роутер для глобальных callbacks из уведомлений } -func NewBot( - chatID int64, - token string, - defaultStateFactory func() State, - backendClient *backend.Client, - globalCallbackRouter func(string) State, -) *Bot { - bot := &Bot{ +func NewBot(chatID int64, token string, commandRouter func(string) State, backendClient *backend.Client, globalCallbackRouter func(string) State) *Bot { + if commandRouter == nil { + panic("bot: commandRouter cannot be nil") + } + if globalCallbackRouter == nil { + panic("bot: globalCallbackRouter cannot be nil") + } + if commandRouter("/start") == nil { + panic("bot: commandRouter requires /start state answer") + } + if backendClient == nil { + panic("bot: backendClient cannot be nil") + } + + return &Bot{ ChatID: chatID, API: echotron.NewAPI(token), Session: &Session{}, - DefaultStateFactory: defaultStateFactory, + commandRouter: commandRouter, Backend: backendClient, - GlobalCallbackRouter: globalCallbackRouter, + globalCallbackRouter: globalCallbackRouter, } - return bot } // GetOrCreateJWT получает JWT токен, передавая актуальные данные пользователя из update @@ -129,19 +142,6 @@ func (b *Bot) SetState(s State, mode RenderMode) { s.Enter(b, mode) } -//func (b *Bot) RemoveLastKeyboard() { -// if b.LastMessageID != 0 { -// emptyKeyboard := Keyboard() // Пустая клавиатура -// _, err := b.EditMessageReplyMarkup( -// echotron.NewMessageID(b.ChatID, b.LastMessageID), -// &echotron.MessageReplyMarkupOptions{ReplyMarkup: emptyKeyboard}, -// ) -// if err != nil { -// log.Error().Err(err).Msg("RemoveLastKeyboard: b.EditMessageReplyMarkup") -// } -// } -//} - func (b *Bot) Render(text string, keyboard echotron.InlineKeyboardMarkup, mode RenderMode) { if mode == EditMessage { b.Edit(text, keyboard) @@ -262,126 +262,104 @@ func (b *Bot) Update(u *echotron.Update) { Bool("has_edited_message", u.EditedMessage != nil). Msg("Update received") - // Обрабатываем системные события (добавление в канал, подписки и т.д.) + // 1. Системные события (ChatJoinRequest, ChatMember, etc.) -> форвардим асинхронно if u.ChatJoinRequest != nil || u.ChatMember != nil || u.MyChatMember != nil { go func() { ctx := context.Background() - if raw, err := json.Marshal(u); err == nil { - log.Info().RawJSON("update", raw).Msg("System event payload") - } else { - log.Error().Err(err).Msg("Failed to marshal system event payload") + raw, err := json.Marshal(u) + if err != nil { + log.Error().RawJSON("update", raw).Msg("Failed to marshal update") } - err := b.Backend.ForwardTelegramUpdate(ctx, u) + err = b.Backend.ForwardTelegramUpdate(ctx, u) if err != nil { log.Error().Err(err).Msg("Failed to forward system event to backend") return } log.Info().Msg("System event forwarded to backend") }() - // Системные события не требуют ответа бота - выходим return } - // Игнорируем все события не из личных чатов (кроме уже обработанных системных) - var chatType string + // 2. Игнорируем события не из private чатов + chatType := "" switch { case u.Message != nil: chatType = u.Message.Chat.Type - case u.EditedMessage != nil: - chatType = u.EditedMessage.Chat.Type - case u.ChannelPost != nil: - chatType = u.ChannelPost.Chat.Type - case u.EditedChannelPost != nil: - chatType = u.EditedChannelPost.Chat.Type case u.CallbackQuery != nil && u.CallbackQuery.Message != nil: chatType = u.CallbackQuery.Message.Chat.Type + case u.EditedMessage != nil: + chatType = u.EditedMessage.Chat.Type + case u.ChannelPost != nil || u.EditedChannelPost != nil: + return // Каналы игнорируем сразу } - if chatType != "" && chatType != "private" { - log.Debug().Str("chat_type", chatType).Msg("Ignoring event from non-private chat") + log.Debug().Str("chat_type", chatType).Msg("Ignoring non-private chat") return } - // Игнорируем edited_message обновления, чтобы не сбрасывать состояние после наших Edit операций. + // 3. Игнорируем edited messages if u.EditedMessage != nil || u.EditedChannelPost != nil || u.EditedBusinessMessage != nil { - log.Debug().Msg("Ignoring edited message update") + log.Debug().Msg("Ignoring edited message") return } - // Получаем или создаем JWT, обновляя данные пользователя (username, first_name, last_name) - if err := b.GetOrCreateJWT(u); err != nil { - log.Error().Err(err).Msg("Failed to get or create JWT") - // Не блокируем работу бота если JWT не удалось получить - это не критично для всех действий - } + _ = b.GetOrCreateJWT(u) exec := &Exec{} - b.exec = exec defer func() { b.exec = nil }() - if b.CurrentState == nil && b.DefaultStateFactory != nil { - b.CurrentState = b.DefaultStateFactory() + if b.CurrentState == nil { + b.CurrentState = b.commandRouter("/start") } - if u.Message != nil && u.Message.Text == "/start" { - if b.DefaultStateFactory != nil { - b.SetState(b.DefaultStateFactory(), NewMessage) - } - return - } + // === Обработка входящего события === - if u.CallbackQuery != nil { - log.Info(). - Str("callback_data", u.CallbackQuery.Data). - Int("callback_msg_id", func() int { - if u.CallbackQuery.Message != nil { - return u.CallbackQuery.Message.ID - } - return 0 - }()). - Int("last_msg_id", b.LastMessageID). - Msg("Callback received") - b.cleanupCallbackUI(u) - if u.CallbackQuery.Data == "empty" || u.CallbackQuery.Data == "page_info" || u.CallbackQuery.Data == "noop" { - exec.handled = true + // 1. Команды (приоритетный маршрут) + if u.Message != nil { + switch u.Message.Text { + case "/start": + b.SetState(b.commandRouter("/start"), NewMessage) return - } - - if b.GlobalCallbackRouter != nil && u.CallbackQuery.Data != "" { - if newState := b.GlobalCallbackRouter(u.CallbackQuery.Data); newState != nil { - b.SetState(newState, NewMessage) + case "/start login": + b.SetState(b.commandRouter("/start login"), NewMessage) + return + default: + b.CurrentState.HandleMessage(b, u) + if exec.handled || exec.transitioned { return } } + } + + // 2. Callback запросы + if u.CallbackQuery != nil { + b.cleanupCallbackUI(u) + + if u.CallbackQuery.Data == "empty" { + return + } + + // Глобальные callbacks (из уведомлений) + if newState := b.globalCallbackRouter(u.CallbackQuery.Data); newState != nil { + b.SetState(newState, NewMessage) + return + } - log.Info().Msg("Handling callback") b.CurrentState.HandleCallback(b, u) - } else if u.Message != nil { - log.Info().Msg("Handling message") - b.CurrentState.HandleMessage(b, u) + if exec.handled || exec.transitioned { + return + } } - if !exec.handled && !exec.transitioned { - log.Info(). - Bool("has_callback", u.CallbackQuery != nil). - Bool("has_message", u.Message != nil). - Bool("handled", exec.handled). - Bool("transitioned", exec.transitioned). - Msg("No handler handled update, calling common handler") - log.Info().Msg("Common handler") - b.CurrentState.Handle(b, u) + // 3. Универсальный обработчик состояния (если ничего не подошло) + b.CurrentState.Handle(b, u) + if exec.handled || exec.transitioned { + return } - if !exec.handled && !exec.transitioned { - log.Info(). - Bool("has_callback", u.CallbackQuery != nil). - Bool("has_message", u.Message != nil). - Bool("handled", exec.handled). - Bool("transitioned", exec.transitioned). - Msg("Default feedback triggered") - log.Info().Msg("Default feedback") - b.SetState(b.DefaultStateFactory(), NewMessage) - } + // 4. Fallback - сброс в начальное состояние + b.SetState(b.commandRouter("/start"), NewMessage) } func (b *Bot) cleanupKeyboard(messageID int) { diff --git a/telegram_bot/main.go b/telegram_bot/main.go index efa5143..f8ea4de 100644 --- a/telegram_bot/main.go +++ b/telegram_bot/main.go @@ -29,9 +29,18 @@ func main() { LoginURL: loginURL, }) - var mainMenu = func() bot.State { return &screens.MainMenu{} } + var commandRouter = func(command string) bot.State { + switch command { + case "/start": + return &screens.MainMenu{} + case "/start login": + return &screens.Login{} + } + panic("Unknown command: " + command) + } + newBot := func(chatID int64) echotron.Bot { - return bot.NewBot(chatID, botToken, mainMenu, backendClient, handlers.HandleGlobalCallback) + return bot.NewBot(chatID, botToken, commandRouter, backendClient, handlers.HandleGlobalCallback) } dsp := echotron.NewDispatcher(botToken, newBot) diff --git a/telegram_bot/screens/add_creative.go b/telegram_bot/screens/add_creative.go index 7c64290..9718660 100644 --- a/telegram_bot/screens/add_creative.go +++ b/telegram_bot/screens/add_creative.go @@ -500,26 +500,8 @@ func escapeHTML(s string) string { return s } -// showCreativeConfirmation показывает превью креатива + панель с предложением добавить -func (s *AddCreative) showCreativeConfirmation(b *bot.Bot) { - - // 1. Отправляем/обновляем превью креатива - if s.CreativeMessageID == nil { - s.SendCreativePreview(b) - } else { - s.UpdateCreativePreview(b) - } - - // 2. Формируем текст панели управления - // Экранируем HTML-символы в названии для безопасного вывода - escapedName := escapeHTML(*s.Name) - confirmText := fmt.Sprintf(`➕ Добавляем этот креатив? - -Название креатива: %s - -Если нужно изменить название креатива, просто введите его здесь. Название автоматически сохранится. Лимит 200 символов.`, escapedName) - - // 3. Показываем панель с кнопками редактирования + сохранения +// buildEditorButtons создает кнопки для панели редактирования креатива +func (s *AddCreative) buildEditorButtons(confirmText, cancelCallback string) [][]echotron.InlineKeyboardButton { var buttons [][]echotron.InlineKeyboardButton // Кнопки редактирования @@ -559,15 +541,39 @@ func (s *AddCreative) showCreativeConfirmation(b *bot.Bot) { // Финальные кнопки if s.Text != nil { buttons = append(buttons, bot.Row( - bot.Button("← Отмена", "cancel"), - bot.Button("✓ Сохранить", "confirm_create"), + bot.Button("← "+cancelCallback, "cancel"), + bot.Button(confirmText, "confirm_create"), )) } else { buttons = append(buttons, bot.Row( - bot.Button("← Отмена", "cancel"), + bot.Button("← "+cancelCallback, "cancel"), )) } + return buttons +} + +// showCreativeConfirmation показывает превью креатива + панель с предложением добавить +func (s *AddCreative) showCreativeConfirmation(b *bot.Bot) { + + // 1. Отправляем/обновляем превью креатива + if s.CreativeMessageID == nil { + s.SendCreativePreview(b) + } else { + s.UpdateCreativePreview(b) + } + + // 2. Формируем текст панели управления + // Экранируем HTML-символы в названии для безопасного вывода + escapedName := escapeHTML(*s.Name) + confirmText := fmt.Sprintf(`➕ Добавляем этот креатив? + +Название креатива: %s + +Если нужно изменить название креатива, просто введите его здесь. Название автоматически сохранится. Лимит 200 символов.`, escapedName) + + // 3. Показываем панель с кнопками редактирования + сохранения + buttons := s.buildEditorButtons("✓ Сохранить", "Отмена") s.ShowControlPanel(b, confirmText, buttons) } @@ -596,9 +602,9 @@ func (s *AddCreative) createCreative(b *bot.Bot) { mediaData, err = b.DownloadFileBytes(s.MediaFileID) if err != nil { log.Error().Err(err).Msg("Failed to download creative media") - b.SendNew("❌ Не удалось загрузить медиа", bot.Keyboard( - bot.Row(bot.Button("← Назад", "cancel")), - )) + // Показываем ошибку через панель управления + buttons := s.buildEditorButtons("✓ Попробовать снова", "Отмена") + s.ShowControlPanel(b, "❌ Не удалось загрузить медиа\n\nПопробуйте удалить медиа и добавить заново.", buttons) return } } @@ -653,12 +659,16 @@ func (s *AddCreative) createCreative(b *bot.Bot) { Пример правильного текста: Присоединяйтесь к нашему каналу! -https://t.me/+AbCdEfGhIjKlMn` +https://t.me/+AbCdEfGhIjKlMn + +Нажмите "✎ Текст" ниже чтобы исправить.` } else if strings.Contains(errMsg, "Creative text must contain only one invite link") { userMsg = `❌ Слишком много ссылок Текст должен содержать ТОЛЬКО ОДНУ инвайт-ссылку. -У вас их несколько. Удалите лишние.` +У вас их несколько. Удалите лишние. + +Нажмите "✎ Текст" ниже чтобы исправить.` } else { userMsg = `❌ Ошибка создания @@ -666,9 +676,9 @@ https://t.me/+AbCdEfGhIjKlMn` Попробуйте ещё раз или вернитесь назад.` } - b.SendNew(userMsg, bot.Keyboard( - bot.Row(bot.Button("← Назад", "back_from_preview")), - )) + // Показываем ошибку через панель управления, чтобы не ломать порядок сообщений + buttons := s.buildEditorButtons("✓ Попробовать снова", "Отмена") + s.ShowControlPanel(b, userMsg, buttons) return } diff --git a/telegram_bot/screens/creative_details.go b/telegram_bot/screens/creative_details.go index 64c9dfa..958728c 100644 --- a/telegram_bot/screens/creative_details.go +++ b/telegram_bot/screens/creative_details.go @@ -425,9 +425,11 @@ func (s *CreativeDetails) updateCreative(b *bot.Bot) { mediaData, err := b.DownloadFileBytes(s.MediaFileID) if err != nil { log.Error().Err(err).Msg("Failed to download creative media") - b.SendNew("❌ Не удалось загрузить медиа", bot.Keyboard( + // Показываем ошибку через панель управления + s.ShowControlPanel(b, "❌ Не удалось загрузить медиа\n\nПопробуйте удалить медиа и добавить заново, или нажмите \"Сохранить\" без изменения медиа.", [][]echotron.InlineKeyboardButton{ bot.Row(bot.Button("← Назад", "back")), - )) + bot.Row(bot.Button("✓ Сохранить без медиа", "confirm_save")), + }) return } input.MediaData = &mediaData @@ -457,9 +459,11 @@ func (s *CreativeDetails) updateCreative(b *bot.Bot) { if err != nil { log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to update creative") - b.SendNew("❌ Не удалось сохранить изменения", bot.Keyboard( + // Показываем ошибку через панель управления + s.ShowControlPanel(b, "❌ Не удалось сохранить изменения\n\nПроверьте подключение и попробуйте снова.", [][]echotron.InlineKeyboardButton{ bot.Row(bot.Button("← Назад", "back")), - )) + bot.Row(bot.Button("✓ Попробовать снова", "confirm_save")), + }) return } @@ -468,7 +472,7 @@ func (s *CreativeDetails) updateCreative(b *bot.Bot) { // Возвращаемся назад if s.BackState != nil { b.SendNew("✅ Креатив успешно обновлен!", bot.Keyboard()) - b.SetState(s.BackState, bot.NewMessage) + b.SetState(s.BackState, bot.EditMessage) } } @@ -491,9 +495,12 @@ func (s *CreativeDetails) deleteCreative(b *bot.Bot) { if err != nil { log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to delete creative") - b.SendNew("❌ Не удалось удалить креатив", bot.Keyboard( + // Показываем ошибку через панель управления + s.confirmingDelete = false // Сбрасываем флаг подтверждения + s.ShowControlPanel(b, "❌ Не удалось удалить креатив\n\nПроверьте подключение и попробуйте снова.", [][]echotron.InlineKeyboardButton{ bot.Row(bot.Button("← Назад", "back")), - )) + bot.Row(bot.Button("⌦ Попробовать снова", "delete_creative")), + }) return } @@ -502,6 +509,6 @@ func (s *CreativeDetails) deleteCreative(b *bot.Bot) { // Возвращаемся назад if s.BackState != nil { b.SendNew("✅ Креатив успешно удален!", bot.Keyboard()) - b.SetState(s.BackState, bot.NewMessage) + b.SetState(s.BackState, bot.EditMessage) } } diff --git a/telegram_bot/screens/creative_editor.go b/telegram_bot/screens/creative_editor.go index 622a2a6..8f51d15 100644 --- a/telegram_bot/screens/creative_editor.go +++ b/telegram_bot/screens/creative_editor.go @@ -45,7 +45,7 @@ type CreativeEditorFields struct { } const inviteLinkPlaceholder = "{{invite_link}}" -const inviteLinkPreviewURL = "https://t.me/J7_channel" +const inviteLinkPreviewURL = "https://t.me/+nlOUmIsLGAlmZTMy" //ссылка приглашения для предпросмотра // GetCreativeText возвращает текст для превью креатива func (e *CreativeEditorFields) GetCreativeText() string { @@ -64,8 +64,79 @@ func (e *CreativeEditorFields) GetCreativeText() string { ` } - // Заменяем пустую ссылку на валидную preview-ссылку - text = strings.ReplaceAll(text, ``, `👉 Подписаться`) + // Заменяем пригласительные ссылки с классом tg-link на preview-ссылку + // Находим все вхождения ... и заменяем на кликабельную ссылку + replacementCount := 0 + for strings.Contains(text, ``) { + startIdx := strings.Index(text, ``) + if startIdx == -1 { + break + } + endIdx := strings.Index(text[startIdx:], "") + if endIdx == -1 { + log.Warn().Msg("Found but no closing ") + break + } + endIdx += startIdx + + // Извлекаем текст между тегами + linkText := text[startIdx+len(``) : endIdx] + log.Info(). + Int("replacement_num", replacementCount). + Str("linkText_before_sanitize", linkText). + Msg("Processing tg-link") + + // ВАЖНО: Удаляем все HTML-теги из текста ссылки для предотвращения неправильной вложенности + // Это нужно для старых креативов с неправильным HTML + tagsRemoved := 0 + for { + openTag := strings.Index(linkText, "<") + if openTag == -1 { + break + } + closeTag := strings.Index(linkText[openTag:], ">") + if closeTag == -1 { + log.Warn().Str("linkText", linkText).Msg("Found < but no closing >") + break + } + // Удаляем тег + removedTag := linkText[openTag : openTag+closeTag+1] + linkText = linkText[:openTag] + linkText[openTag+closeTag+1:] + tagsRemoved++ + log.Info(). + Str("removed_tag", removedTag). + Str("linkText_after", linkText). + Int("tags_removed_total", tagsRemoved). + Msg("Removed HTML tag from link text") + } + + linkText = strings.TrimSpace(linkText) + + // Если текст ссылки не пустой, добавляем атрибут href с примером ссылки + // Если пустой, показываем просто текст примера ссылки (без тега) + var replacement string + if linkText != "" { + // Есть текст - добавляем href атрибут к тегу + replacement = fmt.Sprintf(`%s`, inviteLinkPreviewURL, linkText) + } else { + // Пусто - показываем просто текст примера ссылки + replacement = fmt.Sprintf(`%s`, inviteLinkPreviewURL) + } + + log.Info(). + Str("linkText_after_sanitize", linkText). + Int("tags_removed", tagsRemoved). + Str("replacement", replacement). + Msg("Sanitized link text") + + text = text[:startIdx] + replacement + text[endIdx+len(""):] + replacementCount++ + } + + log.Info(). + Int("replacements_made", replacementCount). + Str("final_text", text). + Msg("Finished processing tg-links") // Также заменяем плейсхолдер {{invite_link}} на preview URL (как в кнопках) text = strings.ReplaceAll(text, inviteLinkPlaceholder, inviteLinkPreviewURL) @@ -409,18 +480,55 @@ func (e *CreativeEditorFields) GetFormattedText(message *echotron.Message) strin // Преобразуем текст в руны для правильной работы с UTF-8 runes := []rune(text) + // Сначала находим все url entities, чтобы исключить перекрывающиеся форматирования + urlRanges := make([][2]int, 0) + // Также отмечаем диапазоны, которые нужно полностью исключить из текста (простые url ссылки) + skipRanges := make(map[[2]int]bool) + + for _, entity := range entities { + if entity.Type == "url" || entity.Type == "text_link" { + urlRanges = append(urlRanges, [2]int{entity.Offset, entity.Offset + entity.Length}) + // Для простых url (не text_link) с пригласительными ссылками - пропускаем текст URL + // Создаем пустой , а сам URL не сохраняем + if entity.Type == "url" { + entityText := "" + if entity.Offset+entity.Length <= len(runes) { + entityText = string(runes[entity.Offset : entity.Offset+entity.Length]) + } + if strings.Contains(entityText, "t.me/+") || strings.Contains(entityText, "t.me/joinchat/") { + skipRanges[[2]int{entity.Offset, entity.Offset + entity.Length}] = true + } + } + } + } + + // Проверяем, находится ли entity внутри URL + isInsideURL := func(offset, length int) bool { + for _, urlRange := range urlRanges { + // Если entity полностью внутри URL (но не сам URL) + if offset > urlRange[0] && offset+length <= urlRange[1] { + return true + } + // Если entity пересекается с URL + 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 { - // URL уже присутствует в сыром тексте, чтобы не дублировать - пропускаем entity. - if entity.Type == "url" { - continue - } - entityText := "" if entity.Offset+entity.Length <= len(runes) { entityText = string(runes[entity.Offset : entity.Offset+entity.Length]) } + // Пропускаем форматирование (bold, italic, etc), если оно находится внутри URL + if entity.Type != "url" && entity.Type != "text_link" && isInsideURL(entity.Offset, entity.Length) { + continue + } + var openTag, closeTag string switch entity.Type { case "bold": @@ -435,10 +543,29 @@ func (e *CreativeEditorFields) GetFormattedText(message *echotron.Message) strin openTag, closeTag = "", "" case "pre": openTag, closeTag = "
", "
" + case "url": + // Конвертируем простые URL в HTML-ссылки + // Для пригласительных ссылок (t.me/+ или t.me/joinchat/) используем пустой тег + if strings.Contains(entityText, "t.me/+") || strings.Contains(entityText, "t.me/joinchat/") { + // Пригласительная ссылка - создаем пустой + // Текст ссылки будет пропущен при рендеринге (см. skipRanges) + openTag = `` + closeTag = "" + } else { + // Обычная ссылка - оставляем как есть (ссылка на себя) + openTag = fmt.Sprintf("", entityText) + closeTag = "" + } case "text_link": if entity.URL != "" { - openTag = fmt.Sprintf("", entity.URL) - closeTag = "" + // Проверяем, является ли URL пригласительной ссылкой + if strings.Contains(entity.URL, "t.me/+") || strings.Contains(entity.URL, "t.me/joinchat/") { + openTag = `` + closeTag = "" + } else { + openTag = fmt.Sprintf("", entity.URL) + closeTag = "" + } } default: _ = entityText @@ -462,6 +589,16 @@ func (e *CreativeEditorFields) GetFormattedText(message *echotron.Message) strin closes[span.end] = append(closes[span.end], span) } + // Функция для проверки, находится ли позиция в skipRanges + isInSkipRange := func(pos int) bool { + for skipRange := range skipRanges { + if pos >= skipRange[0] && pos < skipRange[1] { + return true + } + } + return false + } + // Пишем текст один раз, вставляя HTML-теги по позициям // и экранируя HTML-спецсимволы var result strings.Builder @@ -479,6 +616,10 @@ func (e *CreativeEditorFields) GetFormattedText(message *echotron.Message) strin } } if i < len(runes) { + // Пропускаем символы, которые находятся в skipRanges (текст пригласительных ссылок) + if isInSkipRange(i) { + continue + } // Экранируем HTML-спецсимволы switch runes[i] { case '<': diff --git a/telegram_bot/screens/creatives.go b/telegram_bot/screens/creatives.go index 6767b4f..7188ef7 100644 --- a/telegram_bot/screens/creatives.go +++ b/telegram_bot/screens/creatives.go @@ -155,10 +155,6 @@ func (s *Creatives) HandleCallback(b *bot.Bot, u *echotron.Update) { b.SetState(s.BackState, bot.EditMessage) } - case data == "page_info", data == "empty": - // Неактивные кнопки - ничего не делаем - return - case data == "archive": // TODO: реализовать экран архива креативов b.Edit("📦 Архив креативов\n\nЭта функция в разработке", bot.Keyboard( diff --git a/telegram_bot/screens/main_menu.go b/telegram_bot/screens/main_menu.go index 91023ad..a1dedfb 100644 --- a/telegram_bot/screens/main_menu.go +++ b/telegram_bot/screens/main_menu.go @@ -7,50 +7,9 @@ import ( type MainMenu struct{} -func (s *MainMenu) Enter(b *bot.Bot, mode bot.RenderMode) { - keyboard := bot.Keyboard( - bot.Row( - bot.Button("Мои проекты", "my_projects"), - echotron.InlineKeyboardButton{Text: "Поддержка", URL: "https://example.com"}, - ), - bot.Row( - bot.Button("Закупы", "purchases"), - echotron.InlineKeyboardButton{Text: "Дашборд", URL: "https://example.com"}, - ), - ) +const msgAbout = ` +Главное меню - text := "Главное меню\n\n" + tgexAbout - - b.Render(text, 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.NewMessage) - case "purchases": - b.SetState(&MyProjects{BackState: &MainMenu{}, OpenPurchases: true}, 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 = ` Smartpost — платформа для покупки и продажи рекламы без ЛС и ручных таблиц. • Внутренний счёт и безопасные сделки @@ -58,4 +17,35 @@ Smartpost — платформа для покупки и продажи рек • Автоматические отчёты • Повторные закупки в пару кликов -Комиссия — 7%. Без скрытых платежей.` +Комиссия — 7%. Без скрытых платежей. +` + +func (s *MainMenu) Enter(b *bot.Bot, mode bot.RenderMode) { + keyboard := bot.Keyboard( + bot.Row( + bot.Button("Мои проекты", "my_projects"), + bot.URLButton("Поддержка", "https://example.com"), + ), + bot.Row( + bot.Button("Закупы", "purchases"), + bot.URLButton("Дашборд", "https://example.com"), + ), + ) + + b.Render(msgAbout, 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 "purchases": + b.SetState(&MyProjects{BackState: &MainMenu{}, OpenPurchases: true}, 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) } diff --git a/telegram_bot/screens/my_placements.go b/telegram_bot/screens/my_placements.go deleted file mode 100644 index 689d7e4..0000000 --- a/telegram_bot/screens/my_placements.go +++ /dev/null @@ -1,194 +0,0 @@ -package screens - -import ( - "context" - "fmt" - "strings" - - "example.com/m/bot" - "example.com/m/ui" - "github.com/NicoNex/echotron/v3" - "github.com/rs/zerolog/log" -) - -const placementsPerPage = 6 - -type MyPlacements struct { - CurrentPage int - WorkspaceID string - BackState bot.State -} - -func (s *MyPlacements) Enter(b *bot.Bot, mode bot.RenderMode) { - // JWT уже создан в Bot.Update(), просто берем из сессии - jwt := b.Session.JWT - if jwt == "" { - log.Error().Msg("JWT is empty in session") - 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.renderPlacements(b, mode, jwt) -} - -func (s *MyPlacements) renderPlacements(b *bot.Bot, mode bot.RenderMode, jwt string) { - page, err := b.Backend.GetPlacements( - context.Background(), - jwt, - s.WorkspaceID, - nil, // projectID - nil, // placementChannelID - nil, // creativeID - false, // includeArchived - s.CurrentPage+1, - placementsPerPage, - ) - if err != nil { - log.Error().Err(err).Msg("Failed to get placements") - b.SendNew("❌ Не удалось загрузить закупы", bot.Keyboard()) - return - } - - text := "… › Главное меню › Размещения\n\nМои размещения\n\n" - - var buttons [][]echotron.InlineKeyboardButton - - if len(page.Items) == 0 { - text += `У вас пока нет размещений - -Размещения создаются автоматически при создании закупа` - } else { - text += fmt.Sprintf("Выберите размещение для деталей%s\n\n", ui.FormatPageInfo(s.CurrentPage, page.Pages)) - - // Размещения по 2 в ряду - var slots []echotron.InlineKeyboardButton - for _, placement := range page.Items { - placementName := placement.CreativeName - if placement.PlacementChannelTitle != nil && *placement.PlacementChannelTitle != "" { - placementName = fmt.Sprintf("%s → %s", placement.CreativeName, *placement.PlacementChannelTitle) - } - slots = append(slots, bot.Button(placementName, fmt.Sprintf("placement:%s", placement.ID))) - } - if page.Pages >= 2 { - for len(slots) < placementsPerPage { - slots = append(slots, bot.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, bot.Button(" ", "empty")) - } - buttons = append(buttons, row) - } - - // Кнопки пагинации с номером страницы посередине (только если есть закупы) - if navRow := ui.BuildNavigationRow(ui.PaginationConfig{ - CurrentPage: s.CurrentPage, - TotalPages: page.Pages, - MiddleButtons: []echotron.InlineKeyboardButton{bot.Button(fmt.Sprintf("• %d/%d •", s.CurrentPage+1, page.Pages), "page_info")}, - }); navRow != nil { - buttons = append(buttons, navRow) - } - } - - // Кнопки управления - buttons = append(buttons, bot.Row( - bot.Button("▪ Статистика", "statistics"), - )) - - // Кнопка возврата в главное меню - buttons = append(buttons, bot.Row(bot.Button("← Назад", "back"))) - - keyboard := bot.Keyboard(buttons...) - b.Render(text, keyboard, mode) -} - -func (s *MyPlacements) 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) - } else { - b.SetState(&MainMenu{}, bot.EditMessage) - } - - case data == "page_info", data == "empty": - // Неактивные кнопки - ничего не делаем - return - - case data == "statistics": - // TODO: реализовать экран статистики - b.Edit("📊 Статистика размещений\n\nЭта функция в разработке", bot.Keyboard( - bot.Row(bot.Button("← Назад", "back_to_placements")), - )) - - case data == "back_to_placements": - s.Enter(b, bot.EditMessage) - - case strings.HasPrefix(data, "placement:"): - // Открытие деталей закупа - parts := strings.Split(data, ":") - if len(parts) == 2 { - placementID := parts[1] - - // TODO: реализовать экран деталей закупа - b.Edit(fmt.Sprintf("📄 Детали закупа\n\nID: %s\n\nЭта функция в разработке", placementID), bot.Keyboard( - bot.Row(bot.Button("← Назад", "back_to_placements")), - )) - } - - 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 *MyPlacements) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } - -func (s *MyPlacements) Handle(_ *bot.Bot, _ *echotron.Update) { return } diff --git a/telegram_bot/screens/my_projects.go b/telegram_bot/screens/my_projects.go index 067f339..21dd4ff 100644 --- a/telegram_bot/screens/my_projects.go +++ b/telegram_bot/screens/my_projects.go @@ -188,12 +188,7 @@ func (s *MyProjects) HandleCallback(b *bot.Bot, u *echotron.Update) { case data == "menu", data == "main_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")), )) @@ -229,13 +224,13 @@ func (s *MyProjects) HandleCallback(b *bot.Bot, u *echotron.Update) { ProjectID: project.ID, ProjectTitle: project.Title, BackState: &MyProjects{WorkspaceID: s.WorkspaceID, OpenPurchases: true}, - }, bot.NewMessage) + }, bot.EditMessage) } else { b.SetState(&ProjectDetails{ Project: project, WorkspaceID: s.WorkspaceID, BackState: &MyProjects{WorkspaceID: s.WorkspaceID}, - }, bot.NewMessage) + }, bot.EditMessage) } } diff --git a/telegram_bot/screens/project_details.go b/telegram_bot/screens/project_details.go index 87cb240..40b5a91 100644 --- a/telegram_bot/screens/project_details.go +++ b/telegram_bot/screens/project_details.go @@ -136,7 +136,7 @@ func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) { ProjectTitle: s.Project.Title, WorkspaceID: s.WorkspaceID, BackState: s, - }, bot.NewMessage) + }, bot.EditMessage) case strings.HasPrefix(data, "purchases:"): s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения @@ -145,7 +145,7 @@ func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) { ProjectID: s.Project.ID, ProjectTitle: s.Project.Title, BackState: s, - }, bot.NewMessage) + }, bot.EditMessage) case data == "back_to_project": s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения diff --git a/telegram_bot/screens/purchases.go b/telegram_bot/screens/purchases.go index 3b05031..245ef67 100644 --- a/telegram_bot/screens/purchases.go +++ b/telegram_bot/screens/purchases.go @@ -141,7 +141,7 @@ func (s *Purchases) HandleCallback(b *bot.Bot, u *echotron.Update) { ProjectTitle: s.ProjectTitle, PurchaseID: purchaseID, BackState: s, - }, bot.NewMessage) + }, bot.EditMessage) } else { s.Enter(b, bot.NewMessage) }