фиксы
This commit is contained in:
@@ -307,6 +307,10 @@ class Postgres(DatabaseBase, Database):
|
|||||||
project.deleted_at = datetime.datetime.now()
|
project.deleted_at = datetime.datetime.now()
|
||||||
await project.save()
|
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:
|
async def get_active_purchase(self, project_id: uuid.UUID, creative_id: uuid.UUID) -> domain.Purchase | None:
|
||||||
return await domain.Purchase.get_or_none(
|
return await domain.Purchase.get_or_none(
|
||||||
project_id=project_id, creative_id=creative_id, status=domain.PurchaseStatus.ACTIVE
|
project_id=project_id, creative_id=creative_id, status=domain.PurchaseStatus.ACTIVE
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import logging
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import aioboto3
|
import aioboto3
|
||||||
|
from botocore.config import Config
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from types_aiobotocore_s3.client import S3Client
|
from types_aiobotocore_s3.client import S3Client
|
||||||
|
|
||||||
@@ -23,6 +24,19 @@ class S3(S3Storage):
|
|||||||
self.config = config
|
self.config = config
|
||||||
self.session = aioboto3.Session()
|
self.session = aioboto3.Session()
|
||||||
self._client: S3Client | None = None
|
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:
|
async def connect(self) -> None:
|
||||||
# Создаем клиента для проверки/создания bucket
|
# Создаем клиента для проверки/создания bucket
|
||||||
@@ -32,6 +46,7 @@ class S3(S3Storage):
|
|||||||
aws_access_key_id=self.config.ACCESS_KEY_ID,
|
aws_access_key_id=self.config.ACCESS_KEY_ID,
|
||||||
aws_secret_access_key=self.config.SECRET_ACCESS_KEY,
|
aws_secret_access_key=self.config.SECRET_ACCESS_KEY,
|
||||||
region_name=self.config.REGION,
|
region_name=self.config.REGION,
|
||||||
|
config=self.boto_config,
|
||||||
) as client:
|
) as client:
|
||||||
# Проверяем существование bucket
|
# Проверяем существование bucket
|
||||||
try:
|
try:
|
||||||
@@ -54,6 +69,7 @@ class S3(S3Storage):
|
|||||||
aws_access_key_id=self.config.ACCESS_KEY_ID,
|
aws_access_key_id=self.config.ACCESS_KEY_ID,
|
||||||
aws_secret_access_key=self.config.SECRET_ACCESS_KEY,
|
aws_secret_access_key=self.config.SECRET_ACCESS_KEY,
|
||||||
region_name=self.config.REGION,
|
region_name=self.config.REGION,
|
||||||
|
config=self.boto_config,
|
||||||
) as client:
|
) as client:
|
||||||
await client.put_object(
|
await client.put_object(
|
||||||
Bucket=self.config.BUCKET_NAME,
|
Bucket=self.config.BUCKET_NAME,
|
||||||
@@ -70,6 +86,7 @@ class S3(S3Storage):
|
|||||||
aws_access_key_id=self.config.ACCESS_KEY_ID,
|
aws_access_key_id=self.config.ACCESS_KEY_ID,
|
||||||
aws_secret_access_key=self.config.SECRET_ACCESS_KEY,
|
aws_secret_access_key=self.config.SECRET_ACCESS_KEY,
|
||||||
region_name=self.config.REGION,
|
region_name=self.config.REGION,
|
||||||
|
config=self.boto_config,
|
||||||
) as client:
|
) as client:
|
||||||
response = await client.get_object(Bucket=self.config.BUCKET_NAME, Key=key)
|
response = await client.get_object(Bucket=self.config.BUCKET_NAME, Key=key)
|
||||||
data = await response['Body'].read()
|
data = await response['Body'].read()
|
||||||
@@ -83,6 +100,7 @@ class S3(S3Storage):
|
|||||||
aws_access_key_id=self.config.ACCESS_KEY_ID,
|
aws_access_key_id=self.config.ACCESS_KEY_ID,
|
||||||
aws_secret_access_key=self.config.SECRET_ACCESS_KEY,
|
aws_secret_access_key=self.config.SECRET_ACCESS_KEY,
|
||||||
region_name=self.config.REGION,
|
region_name=self.config.REGION,
|
||||||
|
config=self.boto_config,
|
||||||
) as client:
|
) as client:
|
||||||
await client.delete_object(Bucket=self.config.BUCKET_NAME, Key=key)
|
await client.delete_object(Bucket=self.config.BUCKET_NAME, Key=key)
|
||||||
log.info(f'Deleted file from S3: {key}')
|
log.info(f'Deleted file from S3: {key}')
|
||||||
|
|||||||
@@ -67,3 +67,18 @@ async def delete_project(
|
|||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
user_id=current_user.user_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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ __all__ = (
|
|||||||
'LoginTokenExpired',
|
'LoginTokenExpired',
|
||||||
'LoginTokenAlreadyUsed',
|
'LoginTokenAlreadyUsed',
|
||||||
'ProjectNotFound',
|
'ProjectNotFound',
|
||||||
|
'ProjectChannelConflict',
|
||||||
'PurchaseNotFound',
|
'PurchaseNotFound',
|
||||||
'PurchaseChannelNotFound',
|
'PurchaseChannelNotFound',
|
||||||
'ChannelNotFound',
|
'ChannelNotFound',
|
||||||
@@ -78,6 +79,7 @@ from .error import (
|
|||||||
LoginTokenNotFound,
|
LoginTokenNotFound,
|
||||||
PlacementNotFound,
|
PlacementNotFound,
|
||||||
ProjectNotFound,
|
ProjectNotFound,
|
||||||
|
ProjectChannelConflict,
|
||||||
PurchaseChannelNotFound,
|
PurchaseChannelNotFound,
|
||||||
PurchaseNotFound,
|
PurchaseNotFound,
|
||||||
TelegramChannelNotFound,
|
TelegramChannelNotFound,
|
||||||
|
|||||||
@@ -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')
|
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:
|
def PurchaseNotFound(purchase_id: uuid.UUID | None = None) -> HTTPException:
|
||||||
if purchase_id is None:
|
if purchase_id is None:
|
||||||
return HTTPException(status.HTTP_404_NOT_FOUND, 'Purchase not found')
|
return HTTPException(status.HTTP_404_NOT_FOUND, 'Purchase not found')
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ __all__ = (
|
|||||||
'GetWorkspaceProjectsOutput',
|
'GetWorkspaceProjectsOutput',
|
||||||
'GetProjectInput',
|
'GetProjectInput',
|
||||||
'ArchiveProjectInput',
|
'ArchiveProjectInput',
|
||||||
|
'MoveProjectRequest',
|
||||||
'DisconnectProjectByTgIdInput',
|
'DisconnectProjectByTgIdInput',
|
||||||
'ConnectProjectInput',
|
'ConnectProjectInput',
|
||||||
'ProjectOutput',
|
'ProjectOutput',
|
||||||
@@ -129,6 +130,7 @@ from .project import (
|
|||||||
GetProjectInput,
|
GetProjectInput,
|
||||||
GetWorkspaceProjectsInput,
|
GetWorkspaceProjectsInput,
|
||||||
GetWorkspaceProjectsOutput,
|
GetWorkspaceProjectsOutput,
|
||||||
|
MoveProjectRequest,
|
||||||
ProjectOutput,
|
ProjectOutput,
|
||||||
UpdateProjectInviteLinkTypeInput,
|
UpdateProjectInviteLinkTypeInput,
|
||||||
UpdateProjectPermissionsInput,
|
UpdateProjectPermissionsInput,
|
||||||
|
|||||||
@@ -70,3 +70,7 @@ class ArchiveProjectInput(pydantic.BaseModel):
|
|||||||
workspace_id: uuid.UUID
|
workspace_id: uuid.UUID
|
||||||
project_id: uuid.UUID
|
project_id: uuid.UUID
|
||||||
user_id: uuid.UUID
|
user_id: uuid.UUID
|
||||||
|
|
||||||
|
|
||||||
|
class MoveProjectRequest(pydantic.BaseModel):
|
||||||
|
target_workspace_id: uuid.UUID
|
||||||
|
|||||||
@@ -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.disconnect_project_by_tg_id import disconnect_project_by_tg_id
|
||||||
from .project.get_project import get_project
|
from .project.get_project import get_project
|
||||||
from .project.get_workspace_projects import get_workspace_projects
|
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.tg_add_project import tg_add_project
|
||||||
from .project.update_project_invite_link_type import update_project_invite_link_type
|
from .project.update_project_invite_link_type import update_project_invite_link_type
|
||||||
from .project.update_project_permissions import update_project_permissions
|
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 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_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: ...
|
async def get_purchase(self, workspace_id: UUID, purchase_id: UUID) -> domain.Purchase | None: ...
|
||||||
@@ -423,6 +426,7 @@ class Usecase:
|
|||||||
get_project = get_project
|
get_project = get_project
|
||||||
archive_project = archive_project
|
archive_project = archive_project
|
||||||
delete_project = delete_project
|
delete_project = delete_project
|
||||||
|
move_project_to_workspace = move_project_to_workspace
|
||||||
disconnect_project_by_tg_id = disconnect_project_by_tg_id
|
disconnect_project_by_tg_id = disconnect_project_by_tg_id
|
||||||
update_project_permissions = update_project_permissions
|
update_project_permissions = update_project_permissions
|
||||||
update_project_invite_link_type = update_project_invite_link_type
|
update_project_invite_link_type = update_project_invite_link_type
|
||||||
|
|||||||
72
src/usecase/project/move_project_to_workspace.py
Normal file
72
src/usecase/project/move_project_to_workspace.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
@@ -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 Row(buttons ...echotron.InlineKeyboardButton) []echotron.InlineKeyboardButton { return buttons }
|
||||||
|
|
||||||
func Keyboard(rows ...[]echotron.InlineKeyboardButton) echotron.InlineKeyboardMarkup {
|
func Keyboard(rows ...[]echotron.InlineKeyboardButton) echotron.InlineKeyboardMarkup {
|
||||||
@@ -44,28 +51,34 @@ type Bot struct {
|
|||||||
exec *Exec
|
exec *Exec
|
||||||
CurrentState State
|
CurrentState State
|
||||||
Session *Session
|
Session *Session
|
||||||
LastMessageID int // ID последнего сообщения с inline кнопками
|
LastMessageID int // ID последнего сообщения с inline кнопками
|
||||||
DefaultStateFactory func() State
|
commandRouter func(cmd string) State // Роутер для команд (например, /start, /help)
|
||||||
|
globalCallbackRouter func(callbackData string) State // Роутер для глобальных callbacks из уведомлений
|
||||||
Backend *backend.Client
|
Backend *backend.Client
|
||||||
GlobalCallbackRouter func(callbackData string) State // Роутер для глобальных callbacks из уведомлений
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewBot(
|
func NewBot(chatID int64, token string, commandRouter func(string) State, backendClient *backend.Client, globalCallbackRouter func(string) State) *Bot {
|
||||||
chatID int64,
|
if commandRouter == nil {
|
||||||
token string,
|
panic("bot: commandRouter cannot be nil")
|
||||||
defaultStateFactory func() State,
|
}
|
||||||
backendClient *backend.Client,
|
if globalCallbackRouter == nil {
|
||||||
globalCallbackRouter func(string) State,
|
panic("bot: globalCallbackRouter cannot be nil")
|
||||||
) *Bot {
|
}
|
||||||
bot := &Bot{
|
if commandRouter("/start") == nil {
|
||||||
|
panic("bot: commandRouter requires /start state answer")
|
||||||
|
}
|
||||||
|
if backendClient == nil {
|
||||||
|
panic("bot: backendClient cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Bot{
|
||||||
ChatID: chatID,
|
ChatID: chatID,
|
||||||
API: echotron.NewAPI(token),
|
API: echotron.NewAPI(token),
|
||||||
Session: &Session{},
|
Session: &Session{},
|
||||||
DefaultStateFactory: defaultStateFactory,
|
commandRouter: commandRouter,
|
||||||
Backend: backendClient,
|
Backend: backendClient,
|
||||||
GlobalCallbackRouter: globalCallbackRouter,
|
globalCallbackRouter: globalCallbackRouter,
|
||||||
}
|
}
|
||||||
return bot
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetOrCreateJWT получает JWT токен, передавая актуальные данные пользователя из update
|
// GetOrCreateJWT получает JWT токен, передавая актуальные данные пользователя из update
|
||||||
@@ -129,19 +142,6 @@ func (b *Bot) SetState(s State, mode RenderMode) {
|
|||||||
s.Enter(b, mode)
|
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) {
|
func (b *Bot) Render(text string, keyboard echotron.InlineKeyboardMarkup, mode RenderMode) {
|
||||||
if mode == EditMessage {
|
if mode == EditMessage {
|
||||||
b.Edit(text, keyboard)
|
b.Edit(text, keyboard)
|
||||||
@@ -262,126 +262,104 @@ func (b *Bot) Update(u *echotron.Update) {
|
|||||||
Bool("has_edited_message", u.EditedMessage != nil).
|
Bool("has_edited_message", u.EditedMessage != nil).
|
||||||
Msg("Update received")
|
Msg("Update received")
|
||||||
|
|
||||||
// Обрабатываем системные события (добавление в канал, подписки и т.д.)
|
// 1. Системные события (ChatJoinRequest, ChatMember, etc.) -> форвардим асинхронно
|
||||||
if u.ChatJoinRequest != nil || u.ChatMember != nil || u.MyChatMember != nil {
|
if u.ChatJoinRequest != nil || u.ChatMember != nil || u.MyChatMember != nil {
|
||||||
go func() {
|
go func() {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
if raw, err := json.Marshal(u); err == nil {
|
raw, err := json.Marshal(u)
|
||||||
log.Info().RawJSON("update", raw).Msg("System event payload")
|
if err != nil {
|
||||||
} else {
|
log.Error().RawJSON("update", raw).Msg("Failed to marshal update")
|
||||||
log.Error().Err(err).Msg("Failed to marshal system event payload")
|
|
||||||
}
|
}
|
||||||
err := b.Backend.ForwardTelegramUpdate(ctx, u)
|
err = b.Backend.ForwardTelegramUpdate(ctx, u)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Msg("Failed to forward system event to backend")
|
log.Error().Err(err).Msg("Failed to forward system event to backend")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Info().Msg("System event forwarded to backend")
|
log.Info().Msg("System event forwarded to backend")
|
||||||
}()
|
}()
|
||||||
// Системные события не требуют ответа бота - выходим
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Игнорируем все события не из личных чатов (кроме уже обработанных системных)
|
// 2. Игнорируем события не из private чатов
|
||||||
var chatType string
|
chatType := ""
|
||||||
switch {
|
switch {
|
||||||
case u.Message != nil:
|
case u.Message != nil:
|
||||||
chatType = u.Message.Chat.Type
|
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:
|
case u.CallbackQuery != nil && u.CallbackQuery.Message != nil:
|
||||||
chatType = u.CallbackQuery.Message.Chat.Type
|
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" {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Игнорируем edited_message обновления, чтобы не сбрасывать состояние после наших Edit операций.
|
// 3. Игнорируем edited messages
|
||||||
if u.EditedMessage != nil || u.EditedChannelPost != nil || u.EditedBusinessMessage != nil {
|
if u.EditedMessage != nil || u.EditedChannelPost != nil || u.EditedBusinessMessage != nil {
|
||||||
log.Debug().Msg("Ignoring edited message update")
|
log.Debug().Msg("Ignoring edited message")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получаем или создаем JWT, обновляя данные пользователя (username, first_name, last_name)
|
_ = b.GetOrCreateJWT(u)
|
||||||
if err := b.GetOrCreateJWT(u); err != nil {
|
|
||||||
log.Error().Err(err).Msg("Failed to get or create JWT")
|
|
||||||
// Не блокируем работу бота если JWT не удалось получить - это не критично для всех действий
|
|
||||||
}
|
|
||||||
|
|
||||||
exec := &Exec{}
|
exec := &Exec{}
|
||||||
|
|
||||||
b.exec = exec
|
b.exec = exec
|
||||||
defer func() { b.exec = nil }()
|
defer func() { b.exec = nil }()
|
||||||
|
|
||||||
if b.CurrentState == nil && b.DefaultStateFactory != nil {
|
if b.CurrentState == nil {
|
||||||
b.CurrentState = b.DefaultStateFactory()
|
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 {
|
// 1. Команды (приоритетный маршрут)
|
||||||
log.Info().
|
if u.Message != nil {
|
||||||
Str("callback_data", u.CallbackQuery.Data).
|
switch u.Message.Text {
|
||||||
Int("callback_msg_id", func() int {
|
case "/start":
|
||||||
if u.CallbackQuery.Message != nil {
|
b.SetState(b.commandRouter("/start"), NewMessage)
|
||||||
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
|
|
||||||
return
|
return
|
||||||
}
|
case "/start login":
|
||||||
|
b.SetState(b.commandRouter("/start login"), NewMessage)
|
||||||
if b.GlobalCallbackRouter != nil && u.CallbackQuery.Data != "" {
|
return
|
||||||
if newState := b.GlobalCallbackRouter(u.CallbackQuery.Data); newState != nil {
|
default:
|
||||||
b.SetState(newState, NewMessage)
|
b.CurrentState.HandleMessage(b, u)
|
||||||
|
if exec.handled || exec.transitioned {
|
||||||
return
|
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)
|
b.CurrentState.HandleCallback(b, u)
|
||||||
} else if u.Message != nil {
|
if exec.handled || exec.transitioned {
|
||||||
log.Info().Msg("Handling message")
|
return
|
||||||
b.CurrentState.HandleMessage(b, u)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exec.handled && !exec.transitioned {
|
// 3. Универсальный обработчик состояния (если ничего не подошло)
|
||||||
log.Info().
|
b.CurrentState.Handle(b, u)
|
||||||
Bool("has_callback", u.CallbackQuery != nil).
|
if exec.handled || exec.transitioned {
|
||||||
Bool("has_message", u.Message != nil).
|
return
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exec.handled && !exec.transitioned {
|
// 4. Fallback - сброс в начальное состояние
|
||||||
log.Info().
|
b.SetState(b.commandRouter("/start"), NewMessage)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bot) cleanupKeyboard(messageID int) {
|
func (b *Bot) cleanupKeyboard(messageID int) {
|
||||||
|
|||||||
@@ -29,9 +29,18 @@ func main() {
|
|||||||
LoginURL: loginURL,
|
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 {
|
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)
|
dsp := echotron.NewDispatcher(botToken, newBot)
|
||||||
|
|||||||
@@ -500,26 +500,8 @@ func escapeHTML(s string) string {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// showCreativeConfirmation показывает превью креатива + панель с предложением добавить
|
// buildEditorButtons создает кнопки для панели редактирования креатива
|
||||||
func (s *AddCreative) showCreativeConfirmation(b *bot.Bot) {
|
func (s *AddCreative) buildEditorButtons(confirmText, cancelCallback string) [][]echotron.InlineKeyboardButton {
|
||||||
|
|
||||||
// 1. Отправляем/обновляем превью креатива
|
|
||||||
if s.CreativeMessageID == nil {
|
|
||||||
s.SendCreativePreview(b)
|
|
||||||
} else {
|
|
||||||
s.UpdateCreativePreview(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Формируем текст панели управления
|
|
||||||
// Экранируем HTML-символы в названии для безопасного вывода
|
|
||||||
escapedName := escapeHTML(*s.Name)
|
|
||||||
confirmText := fmt.Sprintf(`<b>➕ Добавляем этот креатив?</b>
|
|
||||||
|
|
||||||
<b>Название креатива:</b> %s
|
|
||||||
|
|
||||||
<i>Если нужно изменить название креатива, просто введите его здесь. Название автоматически сохранится. Лимит 200 символов.</i>`, escapedName)
|
|
||||||
|
|
||||||
// 3. Показываем панель с кнопками редактирования + сохранения
|
|
||||||
var buttons [][]echotron.InlineKeyboardButton
|
var buttons [][]echotron.InlineKeyboardButton
|
||||||
|
|
||||||
// Кнопки редактирования
|
// Кнопки редактирования
|
||||||
@@ -559,15 +541,39 @@ func (s *AddCreative) showCreativeConfirmation(b *bot.Bot) {
|
|||||||
// Финальные кнопки
|
// Финальные кнопки
|
||||||
if s.Text != nil {
|
if s.Text != nil {
|
||||||
buttons = append(buttons, bot.Row(
|
buttons = append(buttons, bot.Row(
|
||||||
bot.Button("← Отмена", "cancel"),
|
bot.Button("← "+cancelCallback, "cancel"),
|
||||||
bot.Button("✓ Сохранить", "confirm_create"),
|
bot.Button(confirmText, "confirm_create"),
|
||||||
))
|
))
|
||||||
} else {
|
} else {
|
||||||
buttons = append(buttons, bot.Row(
|
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(`<b>➕ Добавляем этот креатив?</b>
|
||||||
|
|
||||||
|
<b>Название креатива:</b> %s
|
||||||
|
|
||||||
|
<i>Если нужно изменить название креатива, просто введите его здесь. Название автоматически сохранится. Лимит 200 символов.</i>`, escapedName)
|
||||||
|
|
||||||
|
// 3. Показываем панель с кнопками редактирования + сохранения
|
||||||
|
buttons := s.buildEditorButtons("✓ Сохранить", "Отмена")
|
||||||
s.ShowControlPanel(b, confirmText, buttons)
|
s.ShowControlPanel(b, confirmText, buttons)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -596,9 +602,9 @@ func (s *AddCreative) createCreative(b *bot.Bot) {
|
|||||||
mediaData, err = b.DownloadFileBytes(s.MediaFileID)
|
mediaData, err = b.DownloadFileBytes(s.MediaFileID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Msg("Failed to download creative media")
|
log.Error().Err(err).Msg("Failed to download creative media")
|
||||||
b.SendNew("<b>❌ Не удалось загрузить медиа</b>", bot.Keyboard(
|
// Показываем ошибку через панель управления
|
||||||
bot.Row(bot.Button("← Назад", "cancel")),
|
buttons := s.buildEditorButtons("✓ Попробовать снова", "Отмена")
|
||||||
))
|
s.ShowControlPanel(b, "<b>❌ Не удалось загрузить медиа</b>\n\nПопробуйте удалить медиа и добавить заново.", buttons)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -653,12 +659,16 @@ func (s *AddCreative) createCreative(b *bot.Bot) {
|
|||||||
|
|
||||||
<b>Пример правильного текста:</b>
|
<b>Пример правильного текста:</b>
|
||||||
<i>Присоединяйтесь к нашему каналу!
|
<i>Присоединяйтесь к нашему каналу!
|
||||||
https://t.me/+AbCdEfGhIjKlMn</i>`
|
https://t.me/+AbCdEfGhIjKlMn</i>
|
||||||
|
|
||||||
|
<b>Нажмите "✎ Текст" ниже чтобы исправить.</b>`
|
||||||
} else if strings.Contains(errMsg, "Creative text must contain only one invite link") {
|
} else if strings.Contains(errMsg, "Creative text must contain only one invite link") {
|
||||||
userMsg = `<b>❌ Слишком много ссылок</b>
|
userMsg = `<b>❌ Слишком много ссылок</b>
|
||||||
|
|
||||||
Текст должен содержать <b>ТОЛЬКО ОДНУ</b> инвайт-ссылку.
|
Текст должен содержать <b>ТОЛЬКО ОДНУ</b> инвайт-ссылку.
|
||||||
У вас их несколько. Удалите лишние.`
|
У вас их несколько. Удалите лишние.
|
||||||
|
|
||||||
|
<b>Нажмите "✎ Текст" ниже чтобы исправить.</b>`
|
||||||
} else {
|
} else {
|
||||||
userMsg = `<b>❌ Ошибка создания</b>
|
userMsg = `<b>❌ Ошибка создания</b>
|
||||||
|
|
||||||
@@ -666,9 +676,9 @@ https://t.me/+AbCdEfGhIjKlMn</i>`
|
|||||||
Попробуйте ещё раз или вернитесь назад.`
|
Попробуйте ещё раз или вернитесь назад.`
|
||||||
}
|
}
|
||||||
|
|
||||||
b.SendNew(userMsg, bot.Keyboard(
|
// Показываем ошибку через панель управления, чтобы не ломать порядок сообщений
|
||||||
bot.Row(bot.Button("← Назад", "back_from_preview")),
|
buttons := s.buildEditorButtons("✓ Попробовать снова", "Отмена")
|
||||||
))
|
s.ShowControlPanel(b, userMsg, buttons)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -425,9 +425,11 @@ func (s *CreativeDetails) updateCreative(b *bot.Bot) {
|
|||||||
mediaData, err := b.DownloadFileBytes(s.MediaFileID)
|
mediaData, err := b.DownloadFileBytes(s.MediaFileID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Msg("Failed to download creative media")
|
log.Error().Err(err).Msg("Failed to download creative media")
|
||||||
b.SendNew("<b>❌ Не удалось загрузить медиа</b>", bot.Keyboard(
|
// Показываем ошибку через панель управления
|
||||||
|
s.ShowControlPanel(b, "<b>❌ Не удалось загрузить медиа</b>\n\nПопробуйте удалить медиа и добавить заново, или нажмите \"Сохранить\" без изменения медиа.", [][]echotron.InlineKeyboardButton{
|
||||||
bot.Row(bot.Button("← Назад", "back")),
|
bot.Row(bot.Button("← Назад", "back")),
|
||||||
))
|
bot.Row(bot.Button("✓ Сохранить без медиа", "confirm_save")),
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
input.MediaData = &mediaData
|
input.MediaData = &mediaData
|
||||||
@@ -457,9 +459,11 @@ func (s *CreativeDetails) updateCreative(b *bot.Bot) {
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to update creative")
|
log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to update creative")
|
||||||
b.SendNew("<b>❌ Не удалось сохранить изменения</b>", bot.Keyboard(
|
// Показываем ошибку через панель управления
|
||||||
|
s.ShowControlPanel(b, "<b>❌ Не удалось сохранить изменения</b>\n\nПроверьте подключение и попробуйте снова.", [][]echotron.InlineKeyboardButton{
|
||||||
bot.Row(bot.Button("← Назад", "back")),
|
bot.Row(bot.Button("← Назад", "back")),
|
||||||
))
|
bot.Row(bot.Button("✓ Попробовать снова", "confirm_save")),
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,7 +472,7 @@ func (s *CreativeDetails) updateCreative(b *bot.Bot) {
|
|||||||
// Возвращаемся назад
|
// Возвращаемся назад
|
||||||
if s.BackState != nil {
|
if s.BackState != nil {
|
||||||
b.SendNew("✅ Креатив успешно обновлен!", bot.Keyboard())
|
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 {
|
if err != nil {
|
||||||
log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to delete creative")
|
log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to delete creative")
|
||||||
b.SendNew("<b>❌ Не удалось удалить креатив</b>", bot.Keyboard(
|
// Показываем ошибку через панель управления
|
||||||
|
s.confirmingDelete = false // Сбрасываем флаг подтверждения
|
||||||
|
s.ShowControlPanel(b, "<b>❌ Не удалось удалить креатив</b>\n\nПроверьте подключение и попробуйте снова.", [][]echotron.InlineKeyboardButton{
|
||||||
bot.Row(bot.Button("← Назад", "back")),
|
bot.Row(bot.Button("← Назад", "back")),
|
||||||
))
|
bot.Row(bot.Button("⌦ Попробовать снова", "delete_creative")),
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,6 +509,6 @@ func (s *CreativeDetails) deleteCreative(b *bot.Bot) {
|
|||||||
// Возвращаемся назад
|
// Возвращаемся назад
|
||||||
if s.BackState != nil {
|
if s.BackState != nil {
|
||||||
b.SendNew("✅ Креатив успешно удален!", bot.Keyboard())
|
b.SendNew("✅ Креатив успешно удален!", bot.Keyboard())
|
||||||
b.SetState(s.BackState, bot.NewMessage)
|
b.SetState(s.BackState, bot.EditMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ type CreativeEditorFields struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const inviteLinkPlaceholder = "{{invite_link}}"
|
const inviteLinkPlaceholder = "{{invite_link}}"
|
||||||
const inviteLinkPreviewURL = "https://t.me/J7_channel"
|
const inviteLinkPreviewURL = "https://t.me/+nlOUmIsLGAlmZTMy" //ссылка приглашения для предпросмотра
|
||||||
|
|
||||||
// GetCreativeText возвращает текст для превью креатива
|
// GetCreativeText возвращает текст для превью креатива
|
||||||
func (e *CreativeEditorFields) GetCreativeText() string {
|
func (e *CreativeEditorFields) GetCreativeText() string {
|
||||||
@@ -64,8 +64,79 @@ func (e *CreativeEditorFields) GetCreativeText() string {
|
|||||||
</i>`
|
</i>`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Заменяем пустую ссылку <a class="tg-link"></a> на валидную preview-ссылку
|
// Заменяем пригласительные ссылки с классом tg-link на preview-ссылку
|
||||||
text = strings.ReplaceAll(text, `<a class="tg-link"></a>`, `<a href="`+inviteLinkPreviewURL+`">👉 Подписаться</a>`)
|
// Находим все вхождения <a class="tg-link">...</a> и заменяем на кликабельную ссылку
|
||||||
|
replacementCount := 0
|
||||||
|
for strings.Contains(text, `<a class="tg-link">`) {
|
||||||
|
startIdx := strings.Index(text, `<a class="tg-link">`)
|
||||||
|
if startIdx == -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
endIdx := strings.Index(text[startIdx:], "</a>")
|
||||||
|
if endIdx == -1 {
|
||||||
|
log.Warn().Msg("Found <a class=\"tg-link\"> but no closing </a>")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
endIdx += startIdx
|
||||||
|
|
||||||
|
// Извлекаем текст между тегами
|
||||||
|
linkText := text[startIdx+len(`<a class="tg-link">`) : 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(`<a class="tg-link" href="%s">%s</a>`, 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("</a>"):]
|
||||||
|
replacementCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().
|
||||||
|
Int("replacements_made", replacementCount).
|
||||||
|
Str("final_text", text).
|
||||||
|
Msg("Finished processing tg-links")
|
||||||
|
|
||||||
// Также заменяем плейсхолдер {{invite_link}} на preview URL (как в кнопках)
|
// Также заменяем плейсхолдер {{invite_link}} на preview URL (как в кнопках)
|
||||||
text = strings.ReplaceAll(text, inviteLinkPlaceholder, inviteLinkPreviewURL)
|
text = strings.ReplaceAll(text, inviteLinkPlaceholder, inviteLinkPreviewURL)
|
||||||
@@ -409,18 +480,55 @@ func (e *CreativeEditorFields) GetFormattedText(message *echotron.Message) strin
|
|||||||
// Преобразуем текст в руны для правильной работы с UTF-8
|
// Преобразуем текст в руны для правильной работы с UTF-8
|
||||||
runes := []rune(text)
|
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
|
||||||
|
// Создаем пустой <a class="tg-link"></a>, а сам 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
|
var spans []tagSpan
|
||||||
for _, entity := range entities {
|
for _, entity := range entities {
|
||||||
// URL уже присутствует в сыром тексте, чтобы не дублировать - пропускаем entity.
|
|
||||||
if entity.Type == "url" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
entityText := ""
|
entityText := ""
|
||||||
if entity.Offset+entity.Length <= len(runes) {
|
if entity.Offset+entity.Length <= len(runes) {
|
||||||
entityText = string(runes[entity.Offset : entity.Offset+entity.Length])
|
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
|
var openTag, closeTag string
|
||||||
switch entity.Type {
|
switch entity.Type {
|
||||||
case "bold":
|
case "bold":
|
||||||
@@ -435,10 +543,29 @@ func (e *CreativeEditorFields) GetFormattedText(message *echotron.Message) strin
|
|||||||
openTag, closeTag = "<code>", "</code>"
|
openTag, closeTag = "<code>", "</code>"
|
||||||
case "pre":
|
case "pre":
|
||||||
openTag, closeTag = "<pre>", "</pre>"
|
openTag, closeTag = "<pre>", "</pre>"
|
||||||
|
case "url":
|
||||||
|
// Конвертируем простые URL в HTML-ссылки
|
||||||
|
// Для пригласительных ссылок (t.me/+ или t.me/joinchat/) используем пустой тег
|
||||||
|
if strings.Contains(entityText, "t.me/+") || strings.Contains(entityText, "t.me/joinchat/") {
|
||||||
|
// Пригласительная ссылка - создаем пустой <a class="tg-link"></a>
|
||||||
|
// Текст ссылки будет пропущен при рендеринге (см. skipRanges)
|
||||||
|
openTag = `<a class="tg-link">`
|
||||||
|
closeTag = "</a>"
|
||||||
|
} else {
|
||||||
|
// Обычная ссылка - оставляем как есть (ссылка на себя)
|
||||||
|
openTag = fmt.Sprintf("<a href=\"%s\">", entityText)
|
||||||
|
closeTag = "</a>"
|
||||||
|
}
|
||||||
case "text_link":
|
case "text_link":
|
||||||
if entity.URL != "" {
|
if entity.URL != "" {
|
||||||
openTag = fmt.Sprintf("<a href=\"%s\">", entity.URL)
|
// Проверяем, является ли URL пригласительной ссылкой
|
||||||
closeTag = "</a>"
|
if strings.Contains(entity.URL, "t.me/+") || strings.Contains(entity.URL, "t.me/joinchat/") {
|
||||||
|
openTag = `<a class="tg-link">`
|
||||||
|
closeTag = "</a>"
|
||||||
|
} else {
|
||||||
|
openTag = fmt.Sprintf("<a href=\"%s\">", entity.URL)
|
||||||
|
closeTag = "</a>"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
_ = entityText
|
_ = entityText
|
||||||
@@ -462,6 +589,16 @@ func (e *CreativeEditorFields) GetFormattedText(message *echotron.Message) strin
|
|||||||
closes[span.end] = append(closes[span.end], span)
|
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-теги по позициям
|
||||||
// и экранируя HTML-спецсимволы
|
// и экранируя HTML-спецсимволы
|
||||||
var result strings.Builder
|
var result strings.Builder
|
||||||
@@ -479,6 +616,10 @@ func (e *CreativeEditorFields) GetFormattedText(message *echotron.Message) strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if i < len(runes) {
|
if i < len(runes) {
|
||||||
|
// Пропускаем символы, которые находятся в skipRanges (текст пригласительных ссылок)
|
||||||
|
if isInSkipRange(i) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
// Экранируем HTML-спецсимволы
|
// Экранируем HTML-спецсимволы
|
||||||
switch runes[i] {
|
switch runes[i] {
|
||||||
case '<':
|
case '<':
|
||||||
|
|||||||
@@ -155,10 +155,6 @@ func (s *Creatives) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
|||||||
b.SetState(s.BackState, bot.EditMessage)
|
b.SetState(s.BackState, bot.EditMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
case data == "page_info", data == "empty":
|
|
||||||
// Неактивные кнопки - ничего не делаем
|
|
||||||
return
|
|
||||||
|
|
||||||
case data == "archive":
|
case data == "archive":
|
||||||
// TODO: реализовать экран архива креативов
|
// TODO: реализовать экран архива креативов
|
||||||
b.Edit("📦 Архив креативов\n\nЭта функция в разработке", bot.Keyboard(
|
b.Edit("📦 Архив креативов\n\nЭта функция в разработке", bot.Keyboard(
|
||||||
|
|||||||
@@ -7,50 +7,9 @@ import (
|
|||||||
|
|
||||||
type MainMenu struct{}
|
type MainMenu struct{}
|
||||||
|
|
||||||
func (s *MainMenu) Enter(b *bot.Bot, mode bot.RenderMode) {
|
const msgAbout = `
|
||||||
keyboard := bot.Keyboard(
|
<i>Главное меню</i>
|
||||||
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"},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
text := "<i>Главное меню</i>\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 — платформа для покупки и продажи рекламы без ЛС и ручных таблиц.
|
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) }
|
||||||
|
|||||||
@@ -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 := "<i>… › Главное меню › Размещения</i>\n\n<b>Мои размещения</b>\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 }
|
|
||||||
@@ -188,12 +188,7 @@ func (s *MyProjects) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
|||||||
case data == "menu", data == "main_menu":
|
case data == "menu", data == "main_menu":
|
||||||
b.SetState(&MainMenu{}, bot.EditMessage)
|
b.SetState(&MainMenu{}, bot.EditMessage)
|
||||||
|
|
||||||
case data == "page_info", data == "empty":
|
|
||||||
// Неактивные кнопки - ничего не делаем
|
|
||||||
return
|
|
||||||
|
|
||||||
case data == "archive":
|
case data == "archive":
|
||||||
// TODO: реализовать экран архива
|
|
||||||
b.Edit("📦 Архив проектов\n\nЭта функция в разработке", bot.Keyboard(
|
b.Edit("📦 Архив проектов\n\nЭта функция в разработке", bot.Keyboard(
|
||||||
bot.Row(bot.Button("← Назад", "back_to_projects")),
|
bot.Row(bot.Button("← Назад", "back_to_projects")),
|
||||||
))
|
))
|
||||||
@@ -229,13 +224,13 @@ func (s *MyProjects) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
|||||||
ProjectID: project.ID,
|
ProjectID: project.ID,
|
||||||
ProjectTitle: project.Title,
|
ProjectTitle: project.Title,
|
||||||
BackState: &MyProjects{WorkspaceID: s.WorkspaceID, OpenPurchases: true},
|
BackState: &MyProjects{WorkspaceID: s.WorkspaceID, OpenPurchases: true},
|
||||||
}, bot.NewMessage)
|
}, bot.EditMessage)
|
||||||
} else {
|
} else {
|
||||||
b.SetState(&ProjectDetails{
|
b.SetState(&ProjectDetails{
|
||||||
Project: project,
|
Project: project,
|
||||||
WorkspaceID: s.WorkspaceID,
|
WorkspaceID: s.WorkspaceID,
|
||||||
BackState: &MyProjects{WorkspaceID: s.WorkspaceID},
|
BackState: &MyProjects{WorkspaceID: s.WorkspaceID},
|
||||||
}, bot.NewMessage)
|
}, bot.EditMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
|||||||
ProjectTitle: s.Project.Title,
|
ProjectTitle: s.Project.Title,
|
||||||
WorkspaceID: s.WorkspaceID,
|
WorkspaceID: s.WorkspaceID,
|
||||||
BackState: s,
|
BackState: s,
|
||||||
}, bot.NewMessage)
|
}, bot.EditMessage)
|
||||||
|
|
||||||
case strings.HasPrefix(data, "purchases:"):
|
case strings.HasPrefix(data, "purchases:"):
|
||||||
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
|
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
|
||||||
@@ -145,7 +145,7 @@ func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
|||||||
ProjectID: s.Project.ID,
|
ProjectID: s.Project.ID,
|
||||||
ProjectTitle: s.Project.Title,
|
ProjectTitle: s.Project.Title,
|
||||||
BackState: s,
|
BackState: s,
|
||||||
}, bot.NewMessage)
|
}, bot.EditMessage)
|
||||||
|
|
||||||
case data == "back_to_project":
|
case data == "back_to_project":
|
||||||
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
|
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ func (s *Purchases) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
|||||||
ProjectTitle: s.ProjectTitle,
|
ProjectTitle: s.ProjectTitle,
|
||||||
PurchaseID: purchaseID,
|
PurchaseID: purchaseID,
|
||||||
BackState: s,
|
BackState: s,
|
||||||
}, bot.NewMessage)
|
}, bot.EditMessage)
|
||||||
} else {
|
} else {
|
||||||
s.Enter(b, bot.NewMessage)
|
s.Enter(b, bot.NewMessage)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user