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) }