diff --git a/migrations/models/2_20251230191746_move_invite_link_type_to_project.py b/migrations/models/2_20251230191746_move_invite_link_type_to_project.py new file mode 100644 index 0000000..40a6cd4 --- /dev/null +++ b/migrations/models/2_20251230191746_move_invite_link_type_to_project.py @@ -0,0 +1,92 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "placement" DROP COLUMN "invite_link_type"; + ALTER TABLE "project" ADD "invite_link_type" VARCHAR(10) NOT NULL DEFAULT 'approval'; + COMMENT ON COLUMN "project"."invite_link_type" IS 'PUBLIC: public\nAPPROVAL: approval';""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "project" DROP COLUMN "invite_link_type"; + ALTER TABLE "placement" ADD "invite_link_type" VARCHAR(8) NOT NULL; + COMMENT ON COLUMN "placement"."invite_link_type" IS 'PUBLIC: public\nAPPROVAL: approval';""" + + +MODELS_STATE = ( + "eJztXWtv2zgW/SuGP80A2SJxnCYTLBawHTXV1LENP9LZaQpBkRlHG1nySHLSoOh/X1JvSp" + "Ssl23Jvl+KlC9L55LUPYeX5M/mUpsjxfjQexZVFSnN68bPpiouEf4jnHXSaIqrlZ9BEkzx" + "UbHKSoFCj4api5KJk59ExUA4aY4MSZdXpqypOFVdKwpJ1CRcUFYXftJalf9ZI8HUFsh8Rj" + "rO+PYdJ8vqHP1Ahvvf1YvwJCNlTj2sPCe/baUL5vvKSpvN+JtPVknyc4+CpCnrpeqXXr2b" + "z5rqFV+v5fkHUofkLZCKdNFE88BrkKd0XthNsp8YJ5j6GnmPOvcT5uhJXCsEjOa/n9aqRD" + "BoWL9E/mn/p5kBHklTCbSyahIsfv6y38p/Zyu1SX6q97kz/u384+/WW2qGudCtTAuR5i+r" + "omiKdlULVx9ISUfktQXRjAJ6g3NMeYnYoNI1Q+DOnaof3D/ygOwm+Cj7PcyF2YUvH6ZN/A" + "7zoaq8OxZMwHjK33GTaeduRN5kaRj/KBZEnSlHclpW6nso9TfbJBoeH/a48RppfOWnnxvk" + "v42/hwMubDiv3PTvJnkmcW1qgqq9CeI80NncVBcYXNI37Ho1z2lYuiYYdq+GdR7etyuei1" + "E+u9I1S7Cr87Q7NGtNzOi+duIANbE5Frq4FFjfsq684FWTbcdQxZAhMVTb+aAVHJAL8iv/" + "+qPVOj+/bJ2ef7y6aF9eXlydXuGy1iNFsy4TzN3lb/nBlLYeSfhFYyybCoqii30cPQZbt0" + "IIVfwqFZ3oluIPQUHqwnzG/21dXCRgdt8ZW54CLhXq9wMnq2Xn0SCuDaRbf2fAMVinHCi3" + "3kG3D6QoScgwhGfReM424kMVc4343U/W+xnyK9OIghuLrFO6Xoi2ztqX7avzj20PSC8lCT" + "8bK0Kpnl4CXIAkPIrSy5uozwUqJwCqIkpoiVQWtl2n7qcvY6SI1itFEXXI5chtp5qT6S+3" + "e7ipLAeM+AFFYcBN1BkBXfsfkgqDYLdSZxzWuoTnZCTg4aEKjiZSFBWnTTxS1IAUU1eEkL" + "6UDQM3LRiSRiajQuB81fQXY4WnkBl2L0Ze2xPSdCUn7FiQyKSrtbS4aTiatWwtwymiKi6s" + "pya/TX7JVfCIMCO/oiZL3XPzThLlvWAp0PdA3wMZCPQ9MCzoe6Dvpdf3skom5colB6E8me" + "gHYxRMcWqcMvqD1fcrC2FSd+f+mlI93QXqt7vOX79Tvb0/HNy6xQPA9vrDbghPwxTNNcMH" + "J52SU9dLC1UeP6KoSiiCrl97d/g2sefpOKI0xs1Ob8rfc9cNu8CDStDBCTc4BTMonGR3j4" + "zd+CpFJ76K7cJX4Q7sMGWmxB/vztK1ynRro5CXrKIWcWJ92N5cnpcRuHC9I4EuIukxkIzC" + "+EnTkbxQv6D3yLDfwL6rDF+EauNkXXzzKGakj+D3tB0nC+jOpNe54ZqsQVwCgqVKXnvCj5" + "6c2OiBpAwyWSkgbVMm62sLWZ1qL0htMoSyQO5JklSmkHKC6RUEtQzUMhBVQC0Dw4JaBmpZ" + "hmg49wuaOlLLrQDhRQ6E6MdKxhbJMRbomvWc4w5pMKyNfJ8qA+azCplQz6heBaqAcEXAKE" + "FxmTnNVBW0jXJLoFNk1Vq2SZ591YXBnSlJJp46r6hiQJyBONfV9zgYfgXE+UANC8T5UB1N" + "7ysqEMizGjNau54D9ZAsKjm7A0Kur6KJMWEvboWQ6Z5IjbqNv5vhrNvnGqMx1+Mn/HBAm8" + "fKJEk4QbYd4THX6YeEEElbum5l2sihQJVcelKlMNxK7JCsvmLEBUVWX7LIdKFqdYnMouW6" + "i7NWCrkOl4qV66w8CMbaazCWu6shoywTqnYk0gzbxXD2OGWNZ4upf4xQ4uys6PlVCgC2+Q" + "NVTbwggDIPbBBAWZYODcF/WYP/Er8cJeBY6q7YfeEY80XcjGhwa2ZRIANN1RbJkHe2GUCI" + "iA5DmCMimq1QZJ4R0x2DsC/XZfMo9j0zCrMJN20MZv1+ukBoY/3oPWXBwN9JoKlq8moIh6" + "5qOLQ1GFmLuc4gTVjHdUuUuoT7rUl/GpfIMPCTk/99h+XdLTjBJ7C8e+irgLC8e6CGheXd" + "Q10MDHz2IoaMPdWOrlTOAaE7GI0lnG4HpwdsbwXQnRmedG3p6Rb5JxhGMzDb7Dv0INf6zl" + "Gu6iRoxqB1UgpdCoWzqhvGK8T9g8P0VUZvhvAsG6amy0XlEULw70mDn6323iv9sdu9JkJB" + "E6OPhOFL1kqEoPnet6GcBFeZkSk92+48yCYgmwC7BtkEDAuyyaETGfsTK2lrVhhwrG4Sqn" + "WswknAZ8g4Cuiau5nddscIazcK9hZtWCmzFQj+2mmcQ3UpfFygw743ITsRdSxW5gfbJZCx" + "QKGSGVg4piYgggANAxoG3jrQMDAs0LBDd0APeY8bP3BTZLXS+94CWzBtBHOag9XOLg2zwv" + "7aq6gwTDOadft877qxWj8qsoTNMBqNh/edPjZDsFJGM5ydprDD2WmsIUhWaAciLK+m9+dg" + "NxMcB18Yvi1sfoDV/W2s7rv7eAouZpe7p2k/ux/gYHz2DZsl3qxZZ0hgc8weAkGCfYclN4" + "b6VoLmGOzR5SuPIW3xW1DjdOjUdxAcQXAEXQoEx+M1LAiOIDjWT3CspLgIB/SApAWSVqUk" + "GbjhsGC0S0k3HDrSWImyRamC4cERcxedDfw8AGJKmh48vKr0rRrUD0GwEHB3oHjA3cGwwN" + "2LzoFVMuOhcnfy0VYdHh4KSel3BgPC1Z0iblCKRd+toBSSNub+5HpTkqYj4nOSNH4g4IK3" + "Y24yIaFFAi5LUDYe1N7wbtTnrOKStlxZnTePBHB2liaw5Sw+sOUsogLYLylkvrogXBGuMI" + "ArDKzX3MYBJhB+lUurYlGU1EIfo+6RQJi0ryi8CFxUdSk9tGFf0guju0BE1i4jsrYp1lDH" + "0zJUmvDxtfHyjBEuWbYq453PbaPo/N6jfRcniDIgygB3B1EGDAuizKGLMtW+9q1kFwtufS" + "sUoDIbTGbdSW/Md4lEtVY9rymfSpUG/QTwo9gHnyjPFydaHaanfZ9wEnLUU+sSoXpHoklQ" + "MxHFaTJgF6l4JOAlCTrBvTZFxZxy9+3s/YartCqO361KAJHsZqk1fpFBViUhZ4ofZKGLyw" + "l2R1CToeTQBU6SpBzTKSoYXtlt7ogBrQa0GqD0oNUcr2FBqzlUMuR9Slnfsq68iD33NFSx" + "nHNPN3/QCg5I+9TTP1qt8/PL1un5x6uL9uXlxdWpd/xpNCvpHNQuf0uOQqWsFz0b1fNS8s" + "o3Oz42h4lxszfmOkS7Eb52+Ck/uBW+DsdfJqNOj7tueDeqvomyiQ0gePsJHtRIPewnDAZc" + "n1HLWYZk1Bl07lg/QxBmlCahHYzS5LKeB3U0HpIAKtZruFH8kbfIo0W1PqbQolrhse9rUS" + "QrHOCjsu8n+nMyHMQF+KgxVxTNVGzXb3NZMk8aimyY37emBfou6ONaVjCwxgfys1vyQgkW" + "1OQbiQIKB/yEZlXSQJdF3ffDmyxWyqBLLluNZ0lrtwRwI+BG4EIDNwLDAjcCbgTcqErciD" + "gp1t9MehQz/QXq1CRsPUQNLi7ScIOLi3hyQPJCN3DIumEKWbGkawGaLpqKmANMqtJxY5mB" + "PQVA1xYyRkx7QUUP7uuTlqakoWp6P7Eb3xmrm1bjBfEIh0nXFRFrcdiKEyvrDEPeaq3GmA" + "SO7gBg2MAQh6HMMy9rBso25Sn/+BuGRkWdjRMvVFEyK6hVoFZFwQVRA9QqMCyoVaBWxQ7Q" + "rGS1EE/dxxCsEFGFmwfg5gEGFnb4QFEkyjwOcT84wA0MYUiAmgM13xs1d7pLEkH3e1QKmu" + "4ITeWz9egduKR7wPEJQOSB7wGRB8MCkT8GIl/D7f4rpM6JeSIGbI64wQ0/uL1uOEUe1E6v" + "x42sAylFSUIr0z7T8n74xT7S8lV7ybf3fys34M6Fx/eMW50jFY9kq3M4qicjbIEqRwgY3O" + "1R1rZ6uNvjpPDdHv4UVgKItd9WH5nQNwPobr04dugCk3qVziKgFaMkXWTzLhtaygJNBDQR" + "0EQqxblAEzkaw4ImAppIZTSR1Ecg8oN73tJDHEfzQe32hz1LDnlUNCmnHJK0NceVQy5j5Z" + "DLyDmIwOiB0QOjrwatys7ogZAWJqSB+BakL2XDKL4rh6KZI6/VanqJ6UJ/vJcQDElblRbx" + "QgM0IU3XDKWd6RmBfrRJ2aC7XFqNQ1jR1bYmdwQdCP8nQfYA2QPYMcgeYFiQPQ5d9qC/tH" + "mkD7qFfZ8h2Lm54wfCp1m/f90Q50tZFZ5wOe9Mvokw5jo33jl8hkBGaCD365ifcoHsN102" + "A+f/udW93RlOfT/facAv4LQw6nd63B038J/A29bgPoNfwn0Kv4jTSmfQ6f93yvfcRrC7pr" + "ybsuS24eeTnjOcTYVen+99mQRLvsnYQmtTkBRZeiH3t9qHJXqvZp+Q6L2Zm+u+mJttPVEe" + "2ajVTrOtph2/q6YdVo6Y/lx2NeT41KQ0kohQErHfQgz93rWROiw+hzltesbmceActM3m5u" + "WTN2BmW5gGgJkdvAMPzOxADQvMDJgZMDNgZtVkZvSt32ndMbpWAbds8wRUGSpGoeZeK5AR" + "Nrra8eFWgWsX64qccy1FRtyoWseHGuhOoDtVU3dijO0SoEx/bsy+BvTmC0CpKWszeO43tQ" + "T0MhzFVFn4Qi5Gis63pytoK4tg9htoHW+4jB7ot1RX+GhqUCXRvYN0WXpuMuR1J+ckSUgX" + "/TKV0ctjL59Ie+eEY8BiOnlBnc++cqJ11r5sX51/bHs3TXgpSVHs7mUS8fL4K9LjFRo2eo" + "EqR34wYnCWI0MjA4hO8XoCeHZ6mgJAXCoWQCuPcXUf6zu74eo+da9X922NgRzGJX2//g/p" + "CzE0" +) diff --git a/shared/telegram_base.py b/shared/telegram_base.py index bc9bb4b..26fbff5 100644 --- a/shared/telegram_base.py +++ b/shared/telegram_base.py @@ -1,66 +1,15 @@ -import asyncio import logging import pydantic -from aiogram import Bot, Dispatcher, Router +from aiogram import Bot class TelegramConfig(pydantic.BaseModel): TOKEN: str - DROP_PENDING_UPDATES: bool = True class TelegramBase: - def __init__(self, config: TelegramConfig, *routers: Router) -> None: + def __init__(self, config: TelegramConfig) -> None: self.bot: Bot = Bot(token=config.TOKEN) - self.dispatcher: Dispatcher = Dispatcher() - self._polling_task: asyncio.Task[None] | None = None - self._drop_pending_updates: bool = config.DROP_PENDING_UPDATES - - for router in routers: - self.dispatcher.include_router(router) - logging.info('Telegram bot initialized') - async def start_polling(self) -> None: - if self._polling_task is not None: - logging.warning('Bot polling already started') - return - - self._polling_task = asyncio.create_task( - self.dispatcher.start_polling( - self.bot, - drop_pending_updates=self._drop_pending_updates, - handle_signals=False, # Don't handle SIGINT/SIGTERM in dispatcher - ) - ) - - logging.info('Telegram bot polling started') - - async def stop_polling(self) -> None: - if self._polling_task is None: - logging.warning('Bot polling not started') - return - - # Stop dispatcher polling gracefully - await self.dispatcher.stop_polling() - - # Wait for polling task to finish - try: - await asyncio.wait_for(self._polling_task, timeout=5.0) - except TimeoutError: - logging.warning('Polling task did not stop in time, cancelling') - self._polling_task.cancel() - try: - await self._polling_task - except asyncio.CancelledError: - pass - except asyncio.CancelledError: - logging.info('Polling task cancelled') - - # Close bot session - await self.bot.session.close() - - self._polling_task = None - - logging.info('Telegram bot stopped') diff --git a/src/adapter/postgres.py b/src/adapter/postgres.py index 2712f79..31da633 100644 --- a/src/adapter/postgres.py +++ b/src/adapter/postgres.py @@ -8,7 +8,7 @@ from tortoise.transactions import in_transaction from shared.datebase_base import DatabaseBase from src import domain -from usecase import Database +from src.usecase import Database log = logging.getLogger(__name__) diff --git a/src/adapter/telegram_bot.py b/src/adapter/telegram_bot.py index f37ea3b..c61b5e9 100644 --- a/src/adapter/telegram_bot.py +++ b/src/adapter/telegram_bot.py @@ -1,9 +1,9 @@ import logging -from aiogram.types import BotCommand, InlineKeyboardButton, InlineKeyboardMarkup +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from shared.telegram_base import TelegramBase -from usecase import TelegramBotWriter +from src.usecase import TelegramBotWriter log = logging.getLogger(__name__) @@ -29,10 +29,4 @@ class TelegramBot(TelegramBase, TelegramBotWriter): async def edit_message_reply_markup(self, chat_id: int, message_id: int) -> None: await self.bot.edit_message_reply_markup(chat_id=chat_id, message_id=message_id, reply_markup=None) - async def set_commands(self) -> None: - commands = [ - BotCommand(command='start', description='Начать работу с ботом'), - BotCommand(command='new_creative', description='Создать новый креатив'), - ] - await self.bot.set_my_commands(commands) diff --git a/src/controller/http/__init__.py b/src/controller/http/__init__.py deleted file mode 100644 index e6bb53e..0000000 --- a/src/controller/http/__init__.py +++ /dev/null @@ -1,31 +0,0 @@ -from fastapi import APIRouter - -from src.controller.http.analytics import analytics_router -from src.controller.http.auth import auth_router -from src.controller.http.channels import channels_router -from src.controller.http.creatives import creatives_router -from src.controller.http.placements import placements_router -from src.controller.http.projects import projects_router -from src.controller.http.purchase_plans import purchase_plan_channels_router -from src.controller.http.views import views_router -from src.controller.http.workspace_invites import workspace_invites_router -from src.controller.http.workspace_members import workspace_members_router -from src.controller.http.workspaces import workspaces_router - -api_router = APIRouter() - -# API v1 endpoints -api_v1_router = APIRouter(prefix='/api/v1') -api_v1_router.include_router(auth_router) -api_v1_router.include_router(channels_router) -api_v1_router.include_router(projects_router) -api_v1_router.include_router(purchase_plan_channels_router) -api_v1_router.include_router(creatives_router) -api_v1_router.include_router(placements_router) -api_v1_router.include_router(views_router) -api_v1_router.include_router(analytics_router) -api_v1_router.include_router(workspaces_router) -api_v1_router.include_router(workspace_members_router) -api_v1_router.include_router(workspace_invites_router) - -api_router.include_router(api_v1_router) diff --git a/src/controller/http/projects.py b/src/controller/http/projects.py deleted file mode 100644 index 0d9a777..0000000 --- a/src/controller/http/projects.py +++ /dev/null @@ -1,25 +0,0 @@ -import uuid -from typing import Annotated - -from fastapi import Depends -from fastapi.routing import APIRouter -from fastapi_pagination import Page, paginate - -from src import deps, dto -from src.adapter.jwt import JWTPayload - -projects_router = APIRouter(prefix='/workspaces/{workspace_id}/projects', tags=['projects']) - - -@projects_router.get('') -async def get_workspace_projects( - workspace_id: uuid.UUID, - current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], -) -> Page[dto.ProjectOutput]: - input = dto.GetWorkspaceProjectsInput( - user_id=current_user.user_id, - workspace_id=workspace_id, - ) - - result = await deps.get_usecase().get_workspace_projects(input=input) - return paginate(result) # type: ignore[no-any-return] diff --git a/src/controller/http_v1/__init__.py b/src/controller/http_v1/__init__.py new file mode 100644 index 0000000..aa5fa1e --- /dev/null +++ b/src/controller/http_v1/__init__.py @@ -0,0 +1,34 @@ +from fastapi import APIRouter + +from src.controller.http_v1.analytics import analytics_router +from src.controller.http_v1.auth import auth_router +from src.controller.http_v1.channels import channels_router +from src.controller.http_v1.creatives import creatives_router +from src.controller.http_v1.internal import internal_router +from src.controller.http_v1.placements import placements_router +from src.controller.http_v1.projects import projects_router +from src.controller.http_v1.purchase_plans import purchase_plan_channels_router +from src.controller.http_v1.views import views_router +from src.controller.http_v1.workspace_invites import workspace_invites_router, workspace_invites_global_router +from src.controller.http_v1.workspace_members import workspace_members_router +from src.controller.http_v1.workspaces import workspaces_router + +api_router = APIRouter() + +# API v1 endpoints +api_v1_router = APIRouter(prefix='/api/v1') +api_v1_router.include_router(auth_router) +api_v1_router.include_router(internal_router) +api_v1_router.include_router(channels_router) +api_v1_router.include_router(projects_router) +api_v1_router.include_router(purchase_plan_channels_router) +api_v1_router.include_router(creatives_router) +api_v1_router.include_router(placements_router) +api_v1_router.include_router(views_router) +api_v1_router.include_router(analytics_router) +api_v1_router.include_router(workspaces_router) +api_v1_router.include_router(workspace_members_router) +api_v1_router.include_router(workspace_invites_router) +api_v1_router.include_router(workspace_invites_global_router) + +api_router.include_router(api_v1_router) diff --git a/src/controller/http/analytics.py b/src/controller/http_v1/analytics.py similarity index 100% rename from src/controller/http/analytics.py rename to src/controller/http_v1/analytics.py diff --git a/src/controller/http/auth.py b/src/controller/http_v1/auth.py similarity index 100% rename from src/controller/http/auth.py rename to src/controller/http_v1/auth.py diff --git a/src/controller/http/channels.py b/src/controller/http_v1/channels.py similarity index 65% rename from src/controller/http/channels.py rename to src/controller/http_v1/channels.py index 30e9429..5e0f5ac 100644 --- a/src/controller/http/channels.py +++ b/src/controller/http_v1/channels.py @@ -18,3 +18,14 @@ async def get_channels( input = dto.GetChannelsInput(username=username) result = await deps.get_usecase().get_channels(input=input) return paginate(result) # type: ignore[no-any-return] + + +@channels_router.get('/{channel_id}') +async def get_channel( + channel_id: str, + _: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.ChannelOutput: + import uuid + + input_data = dto.GetChannelInput(channel_id=uuid.UUID(channel_id)) + return await deps.get_usecase().get_channel(input=input_data) diff --git a/src/controller/http/creatives.py b/src/controller/http_v1/creatives.py similarity index 100% rename from src/controller/http/creatives.py rename to src/controller/http_v1/creatives.py diff --git a/src/controller/http_v1/internal.py b/src/controller/http_v1/internal.py new file mode 100644 index 0000000..68bedfb --- /dev/null +++ b/src/controller/http_v1/internal.py @@ -0,0 +1,270 @@ +from fastapi.routing import APIRouter +from pydantic import BaseModel + +from src import deps, dto + +internal_router = APIRouter(prefix='/internal', tags=['internal']) + + + + +class CreateLoginTokenResponse(BaseModel): + token: str + + +@internal_router.post('/auth/login-token') +async def create_login_token(input: dto.CreateLoginTokenRequest) -> str: + token = await deps.get_usecase().create_telegram_login_token( + telegram_id=input.telegram_id, + username=input.username, + ) + return token + + +class ValidateTokenRequest(BaseModel): + token: str + telegram_id: int + username: str | None = None + + + +@internal_router.get('/auth/jwt') +async def get_jwt_by_telegram_id(telegram_id: int) -> dto.ValidateLoginTokenOutput: + return await deps.get_usecase().get_jwt_by_telegram_id(telegram_id) + + +@internal_router.get('/projects/{workspace_id}/{project_id}') +async def get_project(workspace_id: str, project_id: str) -> dto.ProjectOutput: + import uuid + + input_data = dto.GetProjectInput( + workspace_id=uuid.UUID(workspace_id), + project_id=uuid.UUID(project_id), + ) + return await deps.get_usecase().get_project(input_data) + + +class AttachChannelToWorkspaceRequest(BaseModel): + channel_id: str + workspace_id: str + user_telegram_id: int + + +@internal_router.post('/projects') +async def attach_channel_to_workspace(input: AttachChannelToWorkspaceRequest) -> dto.ProjectOutput: + """Привязать канал к workspace (вызывается из Golang бота после выбора workspace)""" + import uuid + + input_data = dto.AttachChannelToWorkspaceInput( + channel_id=uuid.UUID(input.channel_id), + workspace_id=uuid.UUID(input.workspace_id), + user_telegram_id=input.user_telegram_id, + ) + + return await deps.get_usecase().attach_channel_to_workspace(input=input_data) + + +@internal_router.post('/telegram/updates') +async def handle_telegram_update(update: dict) -> None: + """Обрабатывает системные события от Golang бота (echotron формат)""" + import json + import logging + + log = logging.getLogger(__name__) + + # Логируем что пришло для отладки + log.info('Received update from Golang bot, keys: %s', list(update.keys())) + + # Обрабатываем ChatJoinRequest (запрос на вступление в канал) + if 'chat_join_request' in update: + log.info('ChatJoinRequest event: %s', json.dumps(update['chat_join_request'], ensure_ascii=False)) + await _handle_chat_join_request(update['chat_join_request']) + return + + # Обрабатываем ChatMember (пользователь вступил/вышел из канала) + if 'chat_member' in update: + event = update['chat_member'] + old_status = event.get('old_chat_member', {}).get('status') + new_status = event.get('new_chat_member', {}).get('status') + log.info('ChatMember event: status change %s -> %s', old_status, new_status) + await _handle_chat_member_updated(event) + return + + # Обрабатываем MyChatMember (бот добавлен/удален из канала) + if 'my_chat_member' in update: + event = update['my_chat_member'] + old_status = event.get('old_chat_member', {}).get('status') + new_status = event.get('new_chat_member', {}).get('status') + log.info('MyChatMember event: bot status changed %s -> %s in chat', old_status, new_status) + await _handle_my_chat_member(event) + return + + log.warning('Unknown update type from Golang bot: %s', list(update.keys())) + + +async def _handle_chat_join_request(event: dict) -> None: + """Обработка запроса на вступление в канал через invite link""" + import logging + + log = logging.getLogger(__name__) + + chat = event.get('chat', {}) + if chat.get('type') not in ['channel', 'supergroup']: + return + + from_user = event.get('from') + if not from_user: + log.error('No user in chat_join_request event') + return + + invite_link_obj = event.get('invite_link', {}) + invite_link = invite_link_obj.get('invite_link') + if not invite_link: + log.debug('No invite_link in chat_join_request event') + return + + await deps.get_usecase().handle_subscription( + user_telegram_id=from_user['id'], + username=from_user.get('username'), + invite_link=invite_link, + first_name=from_user.get('first_name'), + last_name=from_user.get('last_name'), + ) + + +async def _handle_chat_member_updated(event: dict) -> None: + """Обработка изменения статуса участника канала (вступление/выход)""" + import logging + + log = logging.getLogger(__name__) + + chat = event.get('chat', {}) + if chat.get('type') not in ['channel', 'supergroup']: + return + + from_user = event.get('from') + if not from_user: + log.error('No user in chat_member_updated event') + return + + old_member = event.get('old_chat_member', {}) + new_member = event.get('new_chat_member', {}) + + old_status = old_member.get('status') + new_status = new_member.get('status') + + # Проверяем что пользователь присоединился к каналу + was_not_member = old_status in ['left', 'kicked'] + is_now_member = new_status in ['member', 'administrator', 'creator'] + user_joined = was_not_member and is_now_member + + # Проверяем что пользователь покинул канал + was_member = old_status in ['member', 'administrator', 'creator', 'restricted'] + is_now_not_member = new_status in ['left', 'kicked'] + user_left = was_member and is_now_not_member + + if user_joined: + invite_link_obj = event.get('invite_link', {}) + invite_link = invite_link_obj.get('invite_link') if invite_link_obj else None + if not invite_link: + log.debug('No invite_link in chat_member_updated event (user joined)') + return + + await deps.get_usecase().handle_subscription( + user_telegram_id=from_user['id'], + username=from_user.get('username'), + invite_link=invite_link, + first_name=from_user.get('first_name'), + last_name=from_user.get('last_name'), + ) + + elif user_left: + await deps.get_usecase().handle_unsubscription( + user_telegram_id=from_user['id'], + channel_telegram_id=chat['id'], + ) + + +async def _handle_my_chat_member(event: dict) -> None: + """Обработка добавления/удаления бота из канала""" + import logging + + log = logging.getLogger(__name__) + + chat = event.get('chat', {}) + if chat.get('type') not in ['channel', 'supergroup']: + return + + from_user = event.get('from') + if not from_user: + log.error('No user in my_chat_member event') + return + + old_member = event.get('old_chat_member', {}) + new_member = event.get('new_chat_member', {}) + + old_status = old_member.get('status') + new_status = new_member.get('status') + + # Проверяем что бот был удален из канала + was_member = old_status in ['administrator', 'member'] + is_now_not_member = new_status in ['left', 'kicked'] + bot_removed = was_member and is_now_not_member + + if bot_removed: + # Отключаем проект + disconnect_input = dto.DisconnectProjectByTgIdInput( + telegram_id=chat['id'], + user_telegram_id=from_user['id'], + ) + try: + await deps.get_usecase().disconnect_project_by_tg_id(input=disconnect_input) + log.info('Bot removed from channel %s by user %s', chat['id'], from_user['id']) + except Exception as e: + log.error('Failed to disconnect project: %s', e) + return + + # Проверяем изменение прав администратора + permissions_changed = old_status == 'administrator' and new_status == 'administrator' + + # Извлекаем права бота + bot_permissions = dto.ChannelBotPermissions( + is_admin=(new_status == 'administrator'), + can_invite_users=new_member.get('can_invite_users', False), + can_restrict_members=new_member.get('can_restrict_members', False), + ) + + if permissions_changed: + # Обновляем права бота + permissions_input = dto.UpdateProjectPermissionsInput( + telegram_id=chat['id'], + permissions=bot_permissions, + chat_title=chat.get('title') or f'Channel {chat["id"]}', + user_telegram_id=from_user['id'], + ) + try: + await deps.get_usecase().update_project_permissions(input=permissions_input) + log.info('Bot permissions updated in channel %s', chat['id']) + except Exception as e: + log.error('Failed to update permissions: %s', e) + return + + # Проверяем что бот был добавлен в канал + was_not_member = old_status in ['left', 'kicked'] + is_now_member = new_status in ['administrator', 'member'] + bot_added = was_not_member and is_now_member + + if bot_added: + # Добавляем канал как проект + connect_input = dto.ConnectProjectInput( + telegram_id=chat['id'], + title=chat.get('title') or f'Channel {chat["id"]}', + username=chat.get('username'), + user_telegram_id=from_user['id'], + bot_permissions=bot_permissions, + ) + try: + await deps.get_usecase().tg_add_project(input=connect_input) + log.info('Bot added to channel %s by user %s', chat['id'], from_user['id']) + except Exception as e: + log.error('Failed to add project: %s', e) diff --git a/src/controller/http/placements.py b/src/controller/http_v1/placements.py similarity index 100% rename from src/controller/http/placements.py rename to src/controller/http_v1/placements.py diff --git a/src/controller/http_v1/projects.py b/src/controller/http_v1/projects.py new file mode 100644 index 0000000..c72f15a --- /dev/null +++ b/src/controller/http_v1/projects.py @@ -0,0 +1,49 @@ +import uuid +from typing import Annotated + +from fastapi import Depends +from fastapi.routing import APIRouter +from fastapi_pagination import Page, paginate + +from src import deps, dto +from src.adapter.jwt import JWTPayload + +projects_router = APIRouter(prefix='/workspaces/{workspace_id}/projects', tags=['projects']) + + +@projects_router.get('') +async def get_workspace_projects( + workspace_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> Page[dto.ProjectOutput]: + input = dto.GetWorkspaceProjectsInput( + user_id=current_user.user_id, + workspace_id=workspace_id, + ) + + result = await deps.get_usecase().get_workspace_projects(input=input) + return paginate(result) # type: ignore[no-any-return] + + +@projects_router.patch('/{project_id}/invite-link-type') +async def update_project_invite_link_type( + workspace_id: uuid.UUID, + project_id: uuid.UUID, + input: dto.UpdateProjectInviteLinkTypeInput, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.ProjectOutput: + project = await deps.get_usecase().update_project_invite_link_type( + workspace_id=workspace_id, + project_id=project_id, + invite_link_type=input.invite_link_type, + user_id=current_user.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, + invite_link_type=project.invite_link_type, + ) diff --git a/src/controller/http/purchase_plans.py b/src/controller/http_v1/purchase_plans.py similarity index 100% rename from src/controller/http/purchase_plans.py rename to src/controller/http_v1/purchase_plans.py diff --git a/src/controller/http/views.py b/src/controller/http_v1/views.py similarity index 100% rename from src/controller/http/views.py rename to src/controller/http_v1/views.py diff --git a/src/controller/http/workspace_invites.py b/src/controller/http_v1/workspace_invites.py similarity index 67% rename from src/controller/http/workspace_invites.py rename to src/controller/http_v1/workspace_invites.py index 687be3d..07ea391 100644 --- a/src/controller/http/workspace_invites.py +++ b/src/controller/http_v1/workspace_invites.py @@ -34,3 +34,18 @@ async def create_workspace_invite( user_id=current_user.user_id, input=request, ) + + +# Глобальный роутер для invite endpoints (без workspace_id в пути) +workspace_invites_global_router = APIRouter(prefix='/invites', tags=['workspace invites']) + + +@workspace_invites_global_router.post('/{invite_id}/accept') +async def accept_workspace_invite( + invite_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.WorkspaceInviteOutput: + return await deps.get_usecase().accept_workspace_invite( + invite_id=invite_id, + user_id=current_user.user_id, + ) diff --git a/src/controller/http/workspace_members.py b/src/controller/http_v1/workspace_members.py similarity index 100% rename from src/controller/http/workspace_members.py rename to src/controller/http_v1/workspace_members.py diff --git a/src/controller/http/workspaces.py b/src/controller/http_v1/workspaces.py similarity index 100% rename from src/controller/http/workspaces.py rename to src/controller/http_v1/workspaces.py diff --git a/src/controller/telegram_callback/__init__.py b/src/controller/telegram_callback/__init__.py deleted file mode 100644 index 16d66e7..0000000 --- a/src/controller/telegram_callback/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -from aiogram import Router - -telegram_callback_router = Router() - -# Import handlers to register them -from . import ( # noqa: E402, F401 - chat_join_request, - chat_member_updated, - creative_commands, - my_chat_member, - project_commands, - start_with_login, - workspace_invites, -) diff --git a/src/controller/telegram_callback/chat_join_request.py b/src/controller/telegram_callback/chat_join_request.py deleted file mode 100644 index 176fb79..0000000 --- a/src/controller/telegram_callback/chat_join_request.py +++ /dev/null @@ -1,29 +0,0 @@ -import logging - -from aiogram.types import ChatJoinRequest - -from src import deps -from src.controller.telegram_callback import telegram_callback_router - -log = logging.getLogger(__name__) - - -@telegram_callback_router.chat_join_request() -async def on_chat_join_request(event: ChatJoinRequest) -> None: - if event.chat.type not in {'channel', 'supergroup'}: - return - - if not event.from_user: - log.error('Failed to get user data from chat join request') - return - - invite_link = event.invite_link - if not invite_link or not invite_link.invite_link: - log.debug('No invite_link in chat join request event') - return - - await deps.get_usecase().handle_subscription( - user_telegram_id=event.from_user.id, - username=event.from_user.username, - invite_link=invite_link.invite_link, - ) diff --git a/src/controller/telegram_callback/chat_member_updated.py b/src/controller/telegram_callback/chat_member_updated.py deleted file mode 100644 index 84d94e2..0000000 --- a/src/controller/telegram_callback/chat_member_updated.py +++ /dev/null @@ -1,46 +0,0 @@ -import logging - -from aiogram.types import ChatMemberUpdated - -from src import deps -from src.controller.telegram_callback import telegram_callback_router - -log = logging.getLogger(__name__) - - -@telegram_callback_router.chat_member() -async def on_chat_member_updated(event: ChatMemberUpdated) -> None: - if event.chat.type not in {'channel', 'supergroup'}: - return - - if not event.from_user: - log.error('Failed to get user data from chat member update') - return - - old_status = event.old_chat_member.status - new_status = event.new_chat_member.status - - # Проверяем что пользователь присоединился к каналу - was_not_member = old_status in ('left', 'kicked') - is_now_member = new_status in ('member', 'administrator', 'creator') - user_joined = was_not_member and is_now_member - - # Проверяем что пользователь покинул канал - was_member = old_status in ('member', 'administrator', 'creator', 'restricted') - is_now_not_member = new_status in ('left', 'kicked') - user_left = was_member and is_now_not_member - - if user_joined: - invite_link = event.invite_link.invite_link if event.invite_link else None - if not invite_link: - log.debug('No invite_link in chat member update event') - return - - await deps.get_usecase().handle_subscription( - user_telegram_id=event.from_user.id, username=event.from_user.username, invite_link=invite_link - ) - - elif user_left: - await deps.get_usecase().handle_unsubscription( - user_telegram_id=event.from_user.id, channel_telegram_id=event.chat.id - ) diff --git a/src/controller/telegram_callback/creative_commands.py b/src/controller/telegram_callback/creative_commands.py deleted file mode 100644 index 3f91917..0000000 --- a/src/controller/telegram_callback/creative_commands.py +++ /dev/null @@ -1,68 +0,0 @@ -import logging - -from aiogram import F -from aiogram.filters import Command -from aiogram.types import CallbackQuery, Message - -from src import deps -from src.controller.telegram_callback import telegram_callback_router - -log = logging.getLogger(__name__) - - -@telegram_callback_router.message(Command('new_creative')) -async def cmd_new_creative(message: Message) -> None: - if not message.from_user: - log.error('Failed to get user data from message') - return - - usecase = deps.get_usecase() - await usecase.tg_create_creative_1(telegram_id=message.from_user.id, chat_id=message.chat.id) - - -@telegram_callback_router.callback_query(F.data.startswith('tg_create_creative_2:')) -async def callback_creative_workspace(callback: CallbackQuery) -> None: - if not callback.from_user or not callback.message or not callback.data: - log.error('Failed to get required data from callback query') - return - - usecase = deps.get_usecase() - await usecase.tg_create_creative_2( - telegram_id=callback.from_user.id, - chat_id=callback.message.chat.id, - callback_data=callback.data, - message_id=callback.message.message_id, - ) - - await callback.answer() - - -@telegram_callback_router.callback_query(F.data.startswith('tg_create_creative_3:')) -async def callback_creative_channel(callback: CallbackQuery) -> None: - if not callback.from_user or not callback.message or not callback.data: - log.error('Failed to get required data from callback query') - return - - usecase = deps.get_usecase() - await usecase.tg_create_creative_3( - telegram_id=callback.from_user.id, - chat_id=callback.message.chat.id, - callback_data=callback.data, - message_id=callback.message.message_id, - ) - - await callback.answer() - - -@telegram_callback_router.message(F.text & ~F.text.startswith('/')) -async def handle_text_message(message: Message) -> None: - if not message.from_user or not message.text: - return - - usecase = deps.get_usecase() - await usecase.tg_create_creative_4( - telegram_id=message.from_user.id, - chat_id=message.chat.id, - text=message.text, - text_html=message.html_text, - ) diff --git a/src/controller/telegram_callback/my_chat_member.py b/src/controller/telegram_callback/my_chat_member.py deleted file mode 100644 index 7f4a13d..0000000 --- a/src/controller/telegram_callback/my_chat_member.py +++ /dev/null @@ -1,84 +0,0 @@ -import logging - -from aiogram.types import ChatMemberAdministrator, ChatMemberUpdated -from fastapi import HTTPException - -from src import deps, dto -from src.controller.telegram_callback import telegram_callback_router - -log = logging.getLogger(__name__) - - -@telegram_callback_router.my_chat_member() -async def on_bot_added_to_chat(event: ChatMemberUpdated) -> None: - if event.chat.type not in {'channel', 'supergroup'}: - return - - if not event.from_user: - log.error('Failed to get user data from chat member update') - return - - usecase = deps.get_usecase() - - old_status = event.old_chat_member.status - new_status = event.new_chat_member.status - - # Проверяем что бот был удален - was_member = old_status in ('administrator', 'member') - is_now_not_member = new_status in ('left', 'kicked') - bot_removed = was_member and is_now_not_member - - if bot_removed: - disconnect_input = dto.DisconnectProjectByTgIdInput( - telegram_id=event.chat.id, - user_telegram_id=event.from_user.id, - ) - try: - await usecase.disconnect_project_by_tg_id(input=disconnect_input) - except HTTPException as e: - log.error(e) - - return - - # Проверяем изменение прав администратора - permissions_changed = old_status == 'administrator' and new_status == 'administrator' - - # Извлекаем права бота из события - new_member = event.new_chat_member - - bot_permissions = dto.ChannelBotPermissions( - is_admin=isinstance(new_member, ChatMemberAdministrator), - can_invite_users=getattr(new_member, 'can_invite_users', False), - can_restrict_members=getattr(new_member, 'can_restrict_members', False), - ) - - if permissions_changed: - permissions_input = dto.UpdateProjectPermissionsInput( - telegram_id=event.chat.id, - permissions=bot_permissions, - chat_title=event.chat.title or f'Channel {event.chat.id}', - user_telegram_id=event.from_user.id, - ) - try: - await usecase.update_project_permissions(input=permissions_input) - except HTTPException as e: - log.error(e) - return - - # Проверяем что бот был добавлен - was_not_member = old_status in ('left', 'kicked') - is_now_member = new_status in ('administrator', 'member') - bot_added = was_not_member and is_now_member - - if bot_added: - connect_input = dto.ConnectProjectInput( - telegram_id=event.chat.id, - title=event.chat.title or f'Channel {event.chat.id}', - username=event.chat.username, - user_telegram_id=event.from_user.id, - bot_permissions=bot_permissions, - ) - try: - await usecase.tg_add_project_1(input=connect_input) - except HTTPException as e: - log.error(e) diff --git a/src/controller/telegram_callback/project_commands.py b/src/controller/telegram_callback/project_commands.py deleted file mode 100644 index 169cf4e..0000000 --- a/src/controller/telegram_callback/project_commands.py +++ /dev/null @@ -1,26 +0,0 @@ -import logging - -from aiogram import F -from aiogram.types import CallbackQuery - -from src import deps -from src.controller.telegram_callback import telegram_callback_router - -log = logging.getLogger(__name__) - - -@telegram_callback_router.callback_query(F.data.startswith('tg_add_project_2:')) -async def callback_project_workspace(callback: CallbackQuery) -> None: - if not callback.from_user or not callback.message or not callback.data: - log.error('Failed to get required data from callback query') - return - - usecase = deps.get_usecase() - await usecase.tg_add_project_2( - telegram_id=callback.from_user.id, - chat_id=callback.message.chat.id, - callback_data=callback.data, - message_id=callback.message.message_id, - ) - - await callback.answer() diff --git a/src/controller/telegram_callback/start_with_login.py b/src/controller/telegram_callback/start_with_login.py deleted file mode 100644 index 0003045..0000000 --- a/src/controller/telegram_callback/start_with_login.py +++ /dev/null @@ -1,41 +0,0 @@ -import logging - -from aiogram.filters import CommandStart -from aiogram.types import Message - -from src import deps, dto -from src.controller.telegram_callback import telegram_callback_router - -log = logging.getLogger(__name__) - - -@telegram_callback_router.message(CommandStart(deep_link=True)) -async def cmd_start_with_deeplink(message: Message) -> None: - if not message.from_user: - log.error('Failed to get user data from tg_message') - return - - args = message.text.split() if message.text else [] - deeplink_param = args[1].split('_')[0] if len(args) > 1 else None - - if deeplink_param != 'login': - return - - input = dto.TelegramLoginInput( - telegram_id=message.from_user.id, - username=message.from_user.username, - chat_id=message.chat.id, - ) - - await deps.get_usecase().telegram_login(input=input) - - -@telegram_callback_router.message(CommandStart()) -async def cmd_start(message: Message) -> None: - """Handle /start command without deep link.""" - if not message.from_user: - log.error('Failed to get user data from message') - return - - usecase = deps.get_usecase() - await usecase.telegram_start(telegram_id=message.from_user.id, chat_id=message.chat.id) diff --git a/src/controller/telegram_callback/workspace_invites.py b/src/controller/telegram_callback/workspace_invites.py deleted file mode 100644 index ca12b61..0000000 --- a/src/controller/telegram_callback/workspace_invites.py +++ /dev/null @@ -1,27 +0,0 @@ -import logging - -from aiogram import F -from aiogram.types import CallbackQuery - -from src import deps -from src.controller.telegram_callback import telegram_callback_router -from src.usecase.workspace.create_workspace_invite import INVITE_ACCEPT_CALLBACK_PREFIX - -log = logging.getLogger(__name__) - - -@telegram_callback_router.callback_query(F.data.startswith(f'{INVITE_ACCEPT_CALLBACK_PREFIX}:')) -async def callback_workspace_invite_accept(callback: CallbackQuery) -> None: - if not callback.from_user or not callback.message or not callback.data: - log.error('Incomplete callback data for workspace invite acceptance') - return - - usecase = deps.get_usecase() - await usecase.tg_accept_workspace_invite( - telegram_id=callback.from_user.id, - chat_id=callback.message.chat.id, - callback_data=callback.data, - message_id=callback.message.message_id, - ) - - await callback.answer() diff --git a/src/domain/__init__.py b/src/domain/__init__.py index 6522424..984583c 100644 --- a/src/domain/__init__.py +++ b/src/domain/__init__.py @@ -86,10 +86,10 @@ from .error import ( WorkspaceNotFound, ) from .login_token import LoginToken -from .placement import InviteLinkType, Placement, PlacementStatus, PostViewsAvailability +from .placement import Placement, PlacementStatus, PostViewsAvailability from .post import Post from .post_views_history import PostViewsHistory -from .project import Project, ProjectStatus +from .project import InviteLinkType, Project, ProjectStatus from .purchase_plan import ( PurchasePlan, PurchasePlanChannel, diff --git a/src/domain/error.py b/src/domain/error.py index 0ab2fef..fd8b529 100644 --- a/src/domain/error.py +++ b/src/domain/error.py @@ -114,5 +114,9 @@ def WorkspaceInviteAlreadyExists() -> HTTPException: return HTTPException(status.HTTP_409_CONFLICT, 'Workspace invite already exists for this user') +def WorkspaceInviteAlreadyProcessed() -> HTTPException: + return HTTPException(status.HTTP_400_BAD_REQUEST, 'Workspace invite has already been processed') + + def WorkspaceMemberAlreadyExists() -> HTTPException: return HTTPException(status.HTTP_409_CONFLICT, 'User is already a workspace member') diff --git a/src/domain/placement.py b/src/domain/placement.py index 43d39bd..73e211a 100644 --- a/src/domain/placement.py +++ b/src/domain/placement.py @@ -19,11 +19,6 @@ class PlacementStatus(str, enum.Enum): ARCHIVED = 'archived' # Вручную архивировано -class InviteLinkType(str, enum.Enum): - PUBLIC = 'public' # открытая ссылка - APPROVAL = 'approval' # с одобрением ботом - - class PostViewsAvailability(str, enum.Enum): UNKNOWN = 'unknown' # Ещё не проверялось AVAILABLE = 'available' # Просмотры доступны @@ -35,7 +30,6 @@ class Placement(TimestampedModel): placement_date = fields.DatetimeField() cost = fields.FloatField(null=True) comment = fields.TextField(null=True) - invite_link_type = fields.CharEnumField(InviteLinkType) invite_link = fields.CharField(max_length=512) status = fields.CharEnumField(PlacementStatus, default=PlacementStatus.ACTIVE) diff --git a/src/domain/project.py b/src/domain/project.py index 273f0b3..38d0b16 100644 --- a/src/domain/project.py +++ b/src/domain/project.py @@ -17,8 +17,14 @@ class ProjectStatus(str, enum.Enum): ARCHIVED = 'archived' +class InviteLinkType(str, enum.Enum): + PUBLIC = 'public' # открытая ссылка + APPROVAL = 'approval' # с одобрением ботом + + class Project(TimestampedModel): status = fields.CharEnumField(ProjectStatus, default=ProjectStatus.ACTIVE) + invite_link_type = fields.CharEnumField(InviteLinkType, default=InviteLinkType.APPROVAL, max_length=10) workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField( 'models.Workspace', related_name='projects', on_delete=fields.CASCADE, index=True diff --git a/src/dto/__init__.py b/src/dto/__init__.py index e3897af..ce9bf3a 100644 --- a/src/dto/__init__.py +++ b/src/dto/__init__.py @@ -1,7 +1,9 @@ __all__ = ( + 'UpdateProjectInviteLinkTypeInput', 'UpdateProjectPermissionsInput', 'GetWorkspaceProjectsInput', 'GetWorkspaceProjectsOutput', + 'GetProjectInput', 'DisconnectProjectByTgIdInput', 'ConnectProjectInput', 'ProjectOutput', @@ -10,8 +12,10 @@ __all__ = ( 'TelegramLoginInput', 'ChannelBotPermissions', 'ChannelOutput', + 'GetChannelInput', 'GetChannelsInput', 'GetChannelsOutput', + 'AttachChannelToWorkspaceInput', 'PurchasePlanChannelOutput', 'GetPurchasePlanChannelsInput', 'GetPurchasePlanChannelsOutput', @@ -67,6 +71,8 @@ __all__ = ( 'GetWorkspaceInvitesOutput', ) +from pydantic import BaseModel + from .analytics import ( ChannelAnalyticsOutput, CreativeAnalyticsOutput, @@ -82,7 +88,13 @@ from .analytics import ( PlacementAnalyticsOutput, SpendingDataPoint, ) -from .channel import ChannelOutput, GetChannelsInput, GetChannelsOutput +from .channel import ( + AttachChannelToWorkspaceInput, + ChannelOutput, + GetChannelInput, + GetChannelsInput, + GetChannelsOutput, +) from .creative import ( CreateCreativeInput, CreativeOutput, @@ -105,9 +117,11 @@ from .project import ( ChannelBotPermissions, ConnectProjectInput, DisconnectProjectByTgIdInput, + GetProjectInput, GetWorkspaceProjectsInput, GetWorkspaceProjectsOutput, ProjectOutput, + UpdateProjectInviteLinkTypeInput, UpdateProjectPermissionsInput, ) from .purchase_plan import ( @@ -144,3 +158,8 @@ from .workspace import ( WorkspacePermissionScopeInput, WorkspacePermissionScopeOutput, ) + + +class CreateLoginTokenRequest(BaseModel): + telegram_id: int + username: str | None \ No newline at end of file diff --git a/src/dto/channel.py b/src/dto/channel.py index b897bff..867cccc 100644 --- a/src/dto/channel.py +++ b/src/dto/channel.py @@ -16,3 +16,13 @@ class GetChannelsInput(pydantic.BaseModel): class GetChannelsOutput(pydantic.BaseModel): channels: list[ChannelOutput] + + +class GetChannelInput(pydantic.BaseModel): + channel_id: uuid.UUID + + +class AttachChannelToWorkspaceInput(pydantic.BaseModel): + channel_id: uuid.UUID + workspace_id: uuid.UUID + user_telegram_id: int diff --git a/src/dto/internal.py b/src/dto/internal.py new file mode 100644 index 0000000..eb3dd0e --- /dev/null +++ b/src/dto/internal.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class CreateLoginTokenRequest(BaseModel): + telegram_id: int + username: str | None = None diff --git a/src/dto/placement.py b/src/dto/placement.py index 80e4ceb..4773657 100644 --- a/src/dto/placement.py +++ b/src/dto/placement.py @@ -51,7 +51,6 @@ class CreatePlacementInput(pydantic.BaseModel): placement_date: datetime.datetime cost: float | None = None comment: str | None = None - invite_link_type: domain.InviteLinkType = domain.InviteLinkType.PUBLIC class UpdatePlacementInput(pydantic.BaseModel): diff --git a/src/dto/project.py b/src/dto/project.py index 8aee31f..f62b7b5 100644 --- a/src/dto/project.py +++ b/src/dto/project.py @@ -2,6 +2,7 @@ import uuid import pydantic +from src import domain from src.domain.project import ProjectStatus @@ -31,6 +32,11 @@ class ProjectOutput(pydantic.BaseModel): title: str username: str | None status: ProjectStatus + invite_link_type: domain.InviteLinkType + + +class UpdateProjectInviteLinkTypeInput(pydantic.BaseModel): + invite_link_type: domain.InviteLinkType class GetWorkspaceProjectsInput(pydantic.BaseModel): @@ -52,3 +58,8 @@ class UpdateProjectPermissionsInput(pydantic.BaseModel): permissions: ChannelBotPermissions chat_title: str user_telegram_id: int + + +class GetProjectInput(pydantic.BaseModel): + workspace_id: uuid.UUID + project_id: uuid.UUID diff --git a/src/main.py b/src/main.py index d800ec4..557d64f 100644 --- a/src/main.py +++ b/src/main.py @@ -4,6 +4,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi_pagination import add_pagination +from fastapi_pagination.utils import disable_installed_extensions_check from shared import logger from shared.worker_base import WorkerConfig @@ -13,30 +14,27 @@ from src.adapter.parser import ParserClient from src.adapter.postgres import Postgres from src.adapter.telegram_bot import TelegramBot from src.config import settings -from src.controller.http import api_router -from src.controller.telegram_callback import telegram_callback_router +from src.controller.http_v1 import api_router from src.controller.worker.fetch_placement_post import FetchPlacementPostWorker from src.usecase import Usecase @asynccontextmanager -async def lifespan(app: FastAPI) -> AsyncGenerator[None]: +async def lifespan(_: FastAPI) -> AsyncGenerator[None]: await postgres.connect() await fetch_placement_post_worker.start() - await telegram.start_polling() - await telegram.set_commands() yield - await telegram.stop_polling() await fetch_placement_post_worker.stop() await postgres.close() logger.init(settings.logger) +disable_installed_extensions_check() postgres = Postgres(settings.db) -telegram = TelegramBot(settings.telegram, telegram_callback_router) +telegram = TelegramBot(settings.telegram) jwt_encoder = JWT(settings.jwt) parser_client = ParserClient(base_url=settings.parser.URL) diff --git a/src/usecase/__init__.py b/src/usecase/__init__.py index 684eed5..3489596 100644 --- a/src/usecase/__init__.py +++ b/src/usecase/__init__.py @@ -3,6 +3,7 @@ import typing from dataclasses import dataclass from uuid import UUID + if typing.TYPE_CHECKING: from aiogram.types import InlineKeyboardButton @@ -12,19 +13,17 @@ from .analytics.get_channel_analytics import get_channel_analytics from .analytics.get_creatives_analytics import get_creatives_analytics from .analytics.get_placements_analytics import get_placements_analytics from .analytics.get_spending_analytics import get_spending_analytics +from .auth.get_jwt_by_telegram_id import get_jwt_by_telegram_id +from .auth.create_telegram_login_token import create_telegram_login_token from .auth.get_me import get_me -from .auth.telegram_login import telegram_login -from .auth.telegram_start import telegram_start from .auth.validate_login_token import validate_login_token +from .channel.attach_channel_to_workspace import attach_channel_to_workspace +from .channel.get_channel import get_channel from .channel.get_channels import get_channels from .creative.create_creative import create_creative from .creative.delete_creative import delete_creative from .creative.get_creative import get_creative from .creative.get_creatives import get_creatives -from .creative.tg_create_creative_1 import tg_create_creative_1 -from .creative.tg_create_creative_2 import tg_create_creative_2 -from .creative.tg_create_creative_3 import tg_create_creative_3 -from .creative.tg_create_creative_4 import tg_create_creative_4 from .creative.update_creative import update_creative from .placement.create_placement import create_placement from .placement.delete_placement import delete_placement @@ -33,9 +32,10 @@ from .placement.get_placement import get_placement from .placement.get_placements import get_placements from .placement.update_placement import update_placement 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.tg_add_project_1 import tg_add_project_1 -from .project.tg_add_project_2 import tg_add_project_2 +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 from .purchase_plan.attach_channel_to_purchase_plan import attach_channel_to_purchase_plan from .purchase_plan.get_purchase_plan_channels import get_purchase_plan_channels @@ -44,6 +44,7 @@ from .purchase_plan.update_purchase_plan_channel import update_purchase_plan_cha from .subscription.handle_subscription import handle_subscription from .subscription.handle_unsubscription import handle_unsubscription from .views.get_views_history import get_views_history +from .workspace.accept_workspace_invite import accept_workspace_invite from .workspace.create_workspace import create_workspace from .workspace.create_workspace_invite import create_workspace_invite from .workspace.delete_workspace import delete_workspace @@ -371,15 +372,18 @@ class Usecase: return plan validate_login_token = validate_login_token - telegram_login = telegram_login - telegram_start = telegram_start + create_telegram_login_token = create_telegram_login_token + get_jwt_by_telegram_id = get_jwt_by_telegram_id get_me = get_me - tg_add_project_1 = tg_add_project_1 - tg_add_project_2 = tg_add_project_2 + tg_add_project = tg_add_project get_workspace_projects = get_workspace_projects + get_project = get_project 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 get_channels = get_channels + get_channel = get_channel + attach_channel_to_workspace = attach_channel_to_workspace attach_channel_to_purchase_plan = attach_channel_to_purchase_plan get_purchase_plan_channels = get_purchase_plan_channels update_purchase_plan_channel = update_purchase_plan_channel @@ -389,10 +393,6 @@ class Usecase: create_creative = create_creative update_creative = update_creative delete_creative = delete_creative - tg_create_creative_1 = tg_create_creative_1 - tg_create_creative_2 = tg_create_creative_2 - tg_create_creative_3 = tg_create_creative_3 - tg_create_creative_4 = tg_create_creative_4 get_placements = get_placements get_placement = get_placement create_placement = create_placement @@ -414,4 +414,5 @@ class Usecase: update_workspace_member_permissions = update_workspace_member_permissions create_workspace_invite = create_workspace_invite get_workspace_invites = get_workspace_invites + accept_workspace_invite = accept_workspace_invite tg_accept_workspace_invite = tg_accept_workspace_invite diff --git a/src/usecase/auth/create_telegram_login_token.py b/src/usecase/auth/create_telegram_login_token.py new file mode 100644 index 0000000..e74ff3a --- /dev/null +++ b/src/usecase/auth/create_telegram_login_token.py @@ -0,0 +1,32 @@ +import datetime +import secrets +import typing + +from tortoise import timezone + +from src import domain + +if typing.TYPE_CHECKING: + from .. import Usecase + + +async def create_telegram_login_token(self: 'Usecase', telegram_id: int, username: str | None) -> str: + user = await self.database.get_user(telegram_id=telegram_id) + if not user: + user = domain.User( + telegram_id=telegram_id, + username=username, + ) + await self.database.create_user(user) + + token = secrets.token_urlsafe(32) + expires_at = timezone.now() + datetime.timedelta(minutes=10) + + login_token = domain.LoginToken( + token=token, + user=user, + expires_at=expires_at, + ) + await self.database.create_login_token(login_token) + + return token diff --git a/src/usecase/auth/get_jwt_by_telegram_id.py b/src/usecase/auth/get_jwt_by_telegram_id.py new file mode 100644 index 0000000..cfc8f1d --- /dev/null +++ b/src/usecase/auth/get_jwt_by_telegram_id.py @@ -0,0 +1,23 @@ +import typing + +from src import domain, dto + +if typing.TYPE_CHECKING: + from .. import Usecase + + +async def get_jwt_by_telegram_id(self: 'Usecase', telegram_id: int) -> dto.ValidateLoginTokenOutput: + user = await self.database.get_user(telegram_id=telegram_id) + + if not user: + raise domain.UserNotFound() + + access_token = self.jwt_encoder.encode_access_token( + user_id=user.id, + telegram_id=user.telegram_id, + username=user.username, + ) + + return dto.ValidateLoginTokenOutput( + access_token=access_token, + ) diff --git a/src/usecase/auth/telegram_login.py b/src/usecase/auth/telegram_login.py deleted file mode 100644 index f9e1206..0000000 --- a/src/usecase/auth/telegram_login.py +++ /dev/null @@ -1,40 +0,0 @@ -import datetime -import secrets -import typing - -from tortoise import timezone - -from src import domain, dto -from src.config import settings - -if typing.TYPE_CHECKING: - from .. import Usecase - - -async def telegram_login(self: 'Usecase', input: dto.TelegramLoginInput) -> None: - user = await self.database.get_user(telegram_id=input.telegram_id) - if not user: - user = domain.User( - telegram_id=input.telegram_id, - username=input.username, - ) - await self.database.create_user(user) - - token = secrets.token_urlsafe(32) - expires_at = timezone.now() + datetime.timedelta(minutes=10) - - login_token = domain.LoginToken( - token=token, - user=user, - expires_at=expires_at, - ) - await self.database.create_login_token(login_token) - - login_url = f'{settings.app.URL}{login_token.token}' - - await self.telegram_bot.send_message_with_button( - f'Привет, {user.username or "аноним"}!\n\nНажми кнопку ниже для входа на сайт.', - input.chat_id, - button_text='Войти на сайт', - button_url=login_url, - ) diff --git a/src/usecase/auth/telegram_start.py b/src/usecase/auth/telegram_start.py deleted file mode 100644 index 2f8ba92..0000000 --- a/src/usecase/auth/telegram_start.py +++ /dev/null @@ -1,19 +0,0 @@ -import logging -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from .. import Usecase - -log = logging.getLogger(__name__) - - -async def telegram_start(self: 'Usecase', telegram_id: int, chat_id: int) -> None: - user = await self.database.get_user(telegram_id=telegram_id) - - if not user: - message = 'Привет! 👋\n\nДля работы с ботом необходимо авторизоваться через веб-интерфейс.' - await self.telegram_bot.send_message(message, chat_id) - return - - message = f'Привет, {user.username or "аноним"}! 👋\n\nДоступные команды:\n/new_creative - Создать новый креатив' - await self.telegram_bot.send_message(message, chat_id) diff --git a/src/usecase/channel/attach_channel_to_workspace.py b/src/usecase/channel/attach_channel_to_workspace.py new file mode 100644 index 0000000..398c3d1 --- /dev/null +++ b/src/usecase/channel/attach_channel_to_workspace.py @@ -0,0 +1,52 @@ +import logging +from typing import TYPE_CHECKING + +from fastapi import HTTPException + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def attach_channel_to_workspace( + self: 'Usecase', input: dto.AttachChannelToWorkspaceInput +) -> dto.ProjectOutput: + """Привязать канал к workspace (вызывается из Golang бота после выбора workspace пользователем)""" + + channel = await self.database.get_channel(channel_id=input.channel_id) + if not channel: + raise HTTPException(status_code=404, detail='Channel not found') + + workspace = await self.database.get_workspace(input.workspace_id) + if not workspace: + raise HTTPException(status_code=404, detail='Workspace not found') + + # Проверяем что проект еще не существует + project = await self.database.get_project(workspace.id, channel_id=channel.id) + if project: + # Проект уже существует - просто активируем + project.status = domain.ProjectStatus.ACTIVE + await self.database.update_project(project) + log.info('Project %s reactivated in workspace %s', project.id, workspace.id) + else: + # Создаем новый проект + project = domain.Project( + workspace_id=workspace.id, + channel_id=channel.id, + status=domain.ProjectStatus.ACTIVE, + ) + await self.database.create_project(project) + log.info('Project created for channel %s in workspace %s', channel.id, workspace.id) + + await self.ensure_active_purchase_plan(project) + + return dto.ProjectOutput( + id=project.id, + telegram_id=channel.telegram_id, + title=channel.title, + username=channel.username, + status=project.status, + ) \ No newline at end of file diff --git a/src/usecase/channel/get_channel.py b/src/usecase/channel/get_channel.py new file mode 100644 index 0000000..f70acec --- /dev/null +++ b/src/usecase/channel/get_channel.py @@ -0,0 +1,22 @@ +from typing import TYPE_CHECKING + +from fastapi import HTTPException + +from src import dto + +if TYPE_CHECKING: + from .. import Usecase + + +async def get_channel(self: 'Usecase', input: dto.GetChannelInput) -> dto.ChannelOutput: + channel = await self.database.get_channel(channel_id=input.channel_id) + + if not channel: + raise HTTPException(status_code=404, detail='Channel not found') + + return dto.ChannelOutput( + id=channel.id, + telegram_id=channel.telegram_id, + title=channel.title, + username=channel.username, + ) \ No newline at end of file diff --git a/src/usecase/creative/tg_create_creative_1.py b/src/usecase/creative/tg_create_creative_1.py deleted file mode 100644 index 7cb1a19..0000000 --- a/src/usecase/creative/tg_create_creative_1.py +++ /dev/null @@ -1,87 +0,0 @@ -import logging -from typing import TYPE_CHECKING - -from aiogram.types import InlineKeyboardButton - -from src import domain - -if TYPE_CHECKING: - from .. import Usecase - -log = logging.getLogger(__name__) - - -async def tg_create_creative_1(self: 'Usecase', telegram_id: int, chat_id: int) -> None: - user = await self.database.get_user(telegram_id=telegram_id) - if not user: - await self.telegram_bot.send_message('❌ Вы не авторизованы. Используйте /start для входа.', chat_id) - return - - memberships = await self.database.get_user_workspaces(user.id) - if not memberships: - await self.telegram_bot.send_message( - '❌ У вас нет рабочих пространств. Создайте workspace в веб-панели, чтобы продолжить.', chat_id - ) - await self.database.clear_telegram_state(telegram_id) - return - - if len(memberships) == 1: - workspace: domain.Workspace | None = memberships[0].workspace - if not workspace: - workspace = await self.database.get_workspace(memberships[0].workspace_id) - - if not workspace: - log.error('Workspace %s not found for user %s', memberships[0].workspace_id, user.id) - await self.telegram_bot.send_message('❌ Рабочее пространство не найдено. Попробуйте позже.', chat_id) - await self.database.clear_telegram_state(telegram_id) - return - - projects = await self.database.get_workspace_projects(workspace.id) - if not projects: - await self.telegram_bot.send_message( - '❌ В выбранном рабочем пространстве нет подключенных проектов. Добавьте канал и попробуйте снова.', - chat_id, - ) - await self.database.clear_telegram_state(telegram_id) - return - - buttons = [ - [ - InlineKeyboardButton( - text=project.channel.title or project.channel.username or 'Unnamed Channel', - callback_data=f'tg_create_creative_3:{project.id}', - ) - ] - for project in projects - ] - - await self.telegram_bot.send_message_with_inline_keyboard( - '✨ Создание нового креатива\n\nВыберите канал, для которого создается креатив:', chat_id, buttons - ) - - await self.database.set_telegram_state( - telegram_id, - domain.TelegramStateEnum.CREATIVE_WAITING_CHANNEL, - context={'workspace_id': str(workspace.id)}, - ) - return - - buttons = [ - [ - InlineKeyboardButton( - text=membership.workspace.name if membership.workspace else 'Workspace', - callback_data=f'tg_create_creative_2:{membership.workspace_id}', - ) - ] - for membership in memberships - ] - - await self.telegram_bot.send_message_with_inline_keyboard( - '✨ Создание нового креатива\n\nВыберите рабочее пространство:', chat_id, buttons - ) - - await self.database.set_telegram_state( - telegram_id, - domain.TelegramStateEnum.CREATIVE_WAITING_WORKSPACE, - context={}, - ) diff --git a/src/usecase/creative/tg_create_creative_2.py b/src/usecase/creative/tg_create_creative_2.py deleted file mode 100644 index 1e16ab7..0000000 --- a/src/usecase/creative/tg_create_creative_2.py +++ /dev/null @@ -1,77 +0,0 @@ -import logging -import uuid -from typing import TYPE_CHECKING - -from aiogram.types import InlineKeyboardButton - -from src import domain - -if TYPE_CHECKING: - from .. import Usecase - -log = logging.getLogger(__name__) - - -async def tg_create_creative_2( - self: 'Usecase', telegram_id: int, chat_id: int, callback_data: str, message_id: int -) -> None: - state = await self.database.get_telegram_state(telegram_id) - if not state or state.state != domain.TelegramStateEnum.CREATIVE_WAITING_WORKSPACE: - log.debug('User %s has no active creative workspace selection flow', telegram_id) - return - - if not callback_data.startswith('tg_create_creative_2:'): - log.warning('Invalid workspace callback data format: %s', callback_data) - return - - workspace_id_str = callback_data.split(':', 1)[1] - try: - workspace_id = uuid.UUID(workspace_id_str) - except ValueError: - log.error('Invalid workspace UUID in callback data: %s', workspace_id_str) - await self.telegram_bot.send_message('❌ Некорректное рабочее пространство.', chat_id) - await self.database.clear_telegram_state(telegram_id) - return - - user = await self.database.get_user(telegram_id=telegram_id) - if not user: - await self.telegram_bot.send_message('Вы не зарегистрированы на платформе.', chat_id) - await self.database.clear_telegram_state(telegram_id) - return - - workspace = await self.database.get_workspace_for_user(workspace_id, user.id) - if not workspace: - await self.telegram_bot.send_message('❌ Рабочее пространство недоступно.', chat_id) - await self.database.clear_telegram_state(telegram_id) - return - - await self.telegram_bot.edit_message_reply_markup(chat_id, message_id) - - projects = await self.database.get_workspace_projects(workspace.id) - if not projects: - await self.telegram_bot.send_message( - '❌ В выбранном рабочем пространстве нет подключенных проектов. Добавьте канал и попробуйте снова.', - chat_id, - ) - await self.database.clear_telegram_state(telegram_id) - return - - buttons = [ - [ - InlineKeyboardButton( - text=project.channel.title or project.channel.username or 'Unnamed Channel', - callback_data=f'tg_create_creative_3:{project.id}', - ) - ] - for project in projects - ] - - await self.telegram_bot.send_message_with_inline_keyboard( - '✨ Создание нового креатива\n\nВыберите канал, для которого создается креатив:', chat_id, buttons - ) - - await self.database.set_telegram_state( - telegram_id, - domain.TelegramStateEnum.CREATIVE_WAITING_CHANNEL, - context={'workspace_id': str(workspace.id)}, - ) diff --git a/src/usecase/creative/tg_create_creative_3.py b/src/usecase/creative/tg_create_creative_3.py deleted file mode 100644 index 2b8ce5d..0000000 --- a/src/usecase/creative/tg_create_creative_3.py +++ /dev/null @@ -1,81 +0,0 @@ -import logging -import uuid -from typing import TYPE_CHECKING - -from src import domain - -if TYPE_CHECKING: - from .. import Usecase - -log = logging.getLogger(__name__) - - -async def tg_create_creative_3( - self: 'Usecase', telegram_id: int, chat_id: int, callback_data: str, message_id: int -) -> None: - state = await self.database.get_telegram_state(telegram_id) - if not state or state.state != domain.TelegramStateEnum.CREATIVE_WAITING_CHANNEL: - log.debug('User %s has no active creative creation flow or wrong state', telegram_id) - return - - if not callback_data.startswith('tg_create_creative_3:'): - log.warning('Invalid callback data format: %s', callback_data) - return - - project_id_str = callback_data.split(':', 1)[1] - try: - project_id = uuid.UUID(project_id_str) - except ValueError: - log.error('Invalid project UUID in callback data: %s', project_id_str) - return - - user = await self.database.get_user(telegram_id=telegram_id) - if not user: - await self.telegram_bot.send_message('Вы не зарегистрированы на платформе.', chat_id) - await self.database.clear_telegram_state(telegram_id) - return - - workspace = None - workspace_id = None - - workspace_id_str = state.context.get('workspace_id') if state.context else None - if workspace_id_str: - try: - workspace_id = uuid.UUID(workspace_id_str) - except ValueError: - workspace_id = None - - if workspace_id: - workspace = await self.database.get_workspace_for_user(workspace_id, user.id) - - if not workspace: - workspace = await self.get_or_create_personal_workspace(user) - workspace_id = workspace.id - - if not workspace_id: - log.error('Workspace ID missing for user %s when selecting project %s', telegram_id, project_id) - await self.telegram_bot.send_message('❌ Ошибка: рабочее пространство не найдено. Начните заново.', chat_id) - await self.database.clear_telegram_state(telegram_id) - return - - project = await self.database.get_project(workspace_id, project_id=project_id) - if not project: - await self.telegram_bot.send_message('❌ Канал не найден или не принадлежит вам.', chat_id) - await self.database.clear_telegram_state(telegram_id) - return - - await self.database.set_telegram_state( - telegram_id, - domain.TelegramStateEnum.CREATIVE_WAITING_NAME, - { - 'project_id': str(project_id), - 'channel_title': project.channel.title, - 'workspace_id': str(workspace_id), - }, - ) - - await self.telegram_bot.edit_message_reply_markup(chat_id, message_id) - - await self.telegram_bot.send_message( - f'✅ Канал выбран: {project.channel.title}\n\n📝 Введите название креатива:', chat_id - ) diff --git a/src/usecase/creative/tg_create_creative_4.py b/src/usecase/creative/tg_create_creative_4.py deleted file mode 100644 index 67d93c1..0000000 --- a/src/usecase/creative/tg_create_creative_4.py +++ /dev/null @@ -1,112 +0,0 @@ -import logging -import uuid -from typing import TYPE_CHECKING - -from src import domain - -if TYPE_CHECKING: - from .. import Usecase - -log = logging.getLogger(__name__) - - -async def tg_create_creative_4( - self: 'Usecase', telegram_id: int, chat_id: int, text: str, text_html: str | None = None -) -> None: - state = await self.database.get_telegram_state(telegram_id) - if not state: - return - - user = await self.database.get_user(telegram_id=telegram_id) - if not user: - await self.telegram_bot.send_message('❌ Ошибка: пользователь не найден.', chat_id) - await self.database.clear_telegram_state(telegram_id) - return - - if state.state == domain.TelegramStateEnum.CREATIVE_WAITING_NAME: - await self.database.set_telegram_state( - telegram_id, domain.TelegramStateEnum.CREATIVE_WAITING_TEXT, {**state.context, 'creative_name': text} - ) - await self.telegram_bot.send_message( - '✅ Название сохранено!\n\n' - '💬 Теперь отправьте или перешлите текст креатива.\n' - 'Пригласительная ссылка на ваш канал будет автоматически ' - 'заменена на уникальную ссылку для отслеживания.', - chat_id, - ) - - elif state.state == domain.TelegramStateEnum.CREATIVE_WAITING_TEXT: - project_id_str = state.context.get('project_id') - creative_name = state.context.get('creative_name') - workspace_id_str = state.context.get('workspace_id') - - if not project_id_str or not creative_name: - log.error('Missing context data for creative creation: %s', state.context) - await self.telegram_bot.send_message('❌ Ошибка: данные не найдены. Начните заново.', chat_id) - await self.database.clear_telegram_state(telegram_id) - return - - try: - project_id = uuid.UUID(project_id_str) - except ValueError: - log.error('Invalid project_id in context: %s', project_id_str) - await self.telegram_bot.send_message('❌ Ошибка: неверный формат данных.', chat_id) - await self.database.clear_telegram_state(telegram_id) - return - - workspace = None - workspace_id = None - - if workspace_id_str: - try: - workspace_id = uuid.UUID(workspace_id_str) - except ValueError: - workspace_id = None - - if workspace_id: - workspace = await self.database.get_workspace_for_user(workspace_id, user.id) - - if not workspace: - workspace = await self.get_or_create_personal_workspace(user) - workspace_id = workspace.id - - if not workspace_id: - log.error('Workspace ID missing while finalizing creative for user %s', telegram_id) - await self.telegram_bot.send_message('❌ Ошибка: рабочее пространство не найдено. Начните заново.', chat_id) - await self.database.clear_telegram_state(telegram_id) - return - - project = await self.database.get_project(workspace_id, project_id=project_id) - if not project: - await self.telegram_bot.send_message('❌ Канал не найден.', chat_id) - await self.database.clear_telegram_state(telegram_id) - return - - message_html = text_html or text - creative_text = domain.replace_invite_link_with_tag(message_html) - - creative = domain.Creative( - name=creative_name, - text=creative_text, - status=domain.CreativeStatus.ACTIVE, - workspace_id=workspace_id, - project_id=project_id, - ) - - async with self.database.transaction(): - await self.database.create_creative(creative) - await self.database.clear_telegram_state(telegram_id) - - text_preview_source = text - text_preview = text_preview_source[:100] + '...' if len(text_preview_source) > 100 else text_preview_source - await self.telegram_bot.send_message( - f'✅ Креатив успешно создан!\n\n' - f'📝 Название: {creative.name}\n' - f'📢 Канал: {project.channel.title}\n\n' - f'💬 Текст:\n{text_preview}', - chat_id, - ) - - else: - log.warning('Unknown state: %s', state.state) - await self.database.clear_telegram_state(telegram_id) diff --git a/src/usecase/placement/create_placement.py b/src/usecase/placement/create_placement.py index 1d51e2c..f2fa7ca 100644 --- a/src/usecase/placement/create_placement.py +++ b/src/usecase/placement/create_placement.py @@ -34,7 +34,8 @@ async def create_placement( if creative.project_id != project.id: raise domain.CreativeNotFound(input.creative_id) - requires_approval = input.invite_link_type == domain.InviteLinkType.APPROVAL + # Берем тип ссылки из настроек проекта + requires_approval = project.invite_link_type == domain.InviteLinkType.APPROVAL if project.channel.telegram_id is None: raise domain.ChannelNotFound(project.channel.id) @@ -49,7 +50,6 @@ async def create_placement( placement_date=input.placement_date, cost=input.cost, comment=input.comment, - invite_link_type=input.invite_link_type, invite_link=invite_link, status=domain.PlacementStatus.ACTIVE, ) @@ -68,7 +68,7 @@ async def create_placement( cost=placement.cost, comment=placement.comment, ad_post_url=None, - invite_link_type=placement.invite_link_type, + invite_link_type=project.invite_link_type, invite_link=placement.invite_link, status=placement.status, subscriptions_count=0, diff --git a/src/usecase/placement/get_placement.py b/src/usecase/placement/get_placement.py index b64cd77..cf6059b 100644 --- a/src/usecase/placement/get_placement.py +++ b/src/usecase/placement/get_placement.py @@ -37,7 +37,7 @@ async def get_placement(self: 'Usecase', input: dto.GetPlacementInput) -> dto.Pl cost=placement.cost, comment=placement.comment, ad_post_url=ad_post_url, - invite_link_type=placement.invite_link_type, + invite_link_type=placement.project.invite_link_type, invite_link=placement.invite_link, status=placement.status, subscriptions_count=subscriptions_count, diff --git a/src/usecase/placement/get_placements.py b/src/usecase/placement/get_placements.py index 7234c24..ab9c107 100644 --- a/src/usecase/placement/get_placements.py +++ b/src/usecase/placement/get_placements.py @@ -46,7 +46,7 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> list cost=placement.cost, comment=placement.comment, ad_post_url=ad_post_url, - invite_link_type=placement.invite_link_type, + invite_link_type=placement.project.invite_link_type, invite_link=placement.invite_link, status=placement.status, subscriptions_count=subscriptions_counts.get(placement.id, 0), diff --git a/src/usecase/placement/update_placement.py b/src/usecase/placement/update_placement.py index 7b154b4..2b974ad 100644 --- a/src/usecase/placement/update_placement.py +++ b/src/usecase/placement/update_placement.py @@ -53,7 +53,7 @@ async def update_placement( cost=placement.cost, comment=placement.comment, ad_post_url=ad_post_url, - invite_link_type=placement.invite_link_type, + invite_link_type=placement.project.invite_link_type, invite_link=placement.invite_link, status=placement.status, subscriptions_count=subscriptions_count, diff --git a/src/usecase/project/get_project.py b/src/usecase/project/get_project.py new file mode 100644 index 0000000..9f207bb --- /dev/null +++ b/src/usecase/project/get_project.py @@ -0,0 +1,24 @@ +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + + +async def get_project(self: 'Usecase', input: dto.GetProjectInput) -> dto.ProjectOutput: + project = await self.database.get_project(input.workspace_id, project_id=input.project_id) + + if not project: + raise domain.ProjectNotFound() + + await project.fetch_related('channel') + + return dto.ProjectOutput( + id=project.id, + telegram_id=project.channel.telegram_id, + title=project.channel.title, + username=project.channel.username, + status=project.status, + invite_link_type=project.invite_link_type, + ) diff --git a/src/usecase/project/get_workspace_projects.py b/src/usecase/project/get_workspace_projects.py index ad9083e..f3f0be1 100644 --- a/src/usecase/project/get_workspace_projects.py +++ b/src/usecase/project/get_workspace_projects.py @@ -24,6 +24,7 @@ async def get_workspace_projects( title=project.channel.title, username=project.channel.username, status=project.status, + invite_link_type=project.invite_link_type, ) for project in projects ] diff --git a/src/usecase/project/tg_add_project_1.py b/src/usecase/project/tg_add_project.py similarity index 75% rename from src/usecase/project/tg_add_project_1.py rename to src/usecase/project/tg_add_project.py index 5f63d86..94e4cfa 100644 --- a/src/usecase/project/tg_add_project_1.py +++ b/src/usecase/project/tg_add_project.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: log = logging.getLogger(__name__) -async def tg_add_project_1(self: 'Usecase', input: dto.ConnectProjectInput) -> dto.ProjectOutput | None: +async def tg_add_project(self: 'Usecase', input: dto.ConnectProjectInput) -> dto.ProjectOutput | None: permissions = input.bot_permissions if not permissions.is_admin: @@ -125,41 +125,40 @@ async def tg_add_project_1(self: 'Usecase', input: dto.ConnectProjectInput) -> d status=project.status, ) - current_state = await self.database.get_telegram_state(user.telegram_id) - if current_state and current_state.state == domain.TelegramStateEnum.PROJECT_WAITING_WORKSPACE: - await self.telegram_bot.send_message( - '⚠️ Сначала завершите выбор рабочего пространства для предыдущего канала.', user.telegram_id + # Если >1 workspace - создаем/обновляем канал и отправляем уведомление + channel = await self.database.get_channel(telegram_id=input.telegram_id) + if channel: + channel.title = input.title + if input.username is not None: + channel.username = input.username + await self.database.update_channel(channel) + else: + if input.username is None: + log.warning('Cannot create channel %s without username', input.telegram_id) + await self.telegram_bot.send_message( + f'⚠️ Канал "{input.title}" не может быть подключен.\n\nУ канала отсутствует публичный username.', + input.user_telegram_id, + ) + return None + + channel = domain.Channel( + telegram_id=input.telegram_id, + title=input.title, + username=input.username, ) - return None + await self.database.create_channel(channel) - context: dict[str, object] = { - 'channel_data': { - 'telegram_id': input.telegram_id, - 'title': input.title, - 'username': input.username, - 'permissions': input.bot_permissions.model_dump(), - } - } - - if current_state: - context['previous_state'] = current_state.state.value - context['previous_context'] = dict(current_state.context or {}) - - await self.database.set_telegram_state( - user.telegram_id, - domain.TelegramStateEnum.PROJECT_WAITING_WORKSPACE, - context, - ) - - buttons = [ - [InlineKeyboardButton(text=workspace.name, callback_data=f'tg_add_project_2:{workspace.id}')] - for workspace in workspaces - ] + # Callback будет обработан Golang ботом который покажет экран выбора workspace + buttons = [[InlineKeyboardButton(text='Выбрать workspace', callback_data=f'pending_channel:{channel.id}')]] await self.telegram_bot.send_message_with_inline_keyboard( - (f'✨ Подключение канала\n\nВыберите рабочее пространство, в которое добавить канал "{input.title}":'), + f'Бот добавлен в канал "{input.title}".\n\n' + f'У вас {len(workspaces)} рабочих пространств. ' + 'Нажмите кнопку ниже чтобы выбрать, где создать проект.', user.telegram_id, buttons, ) + log.info('Pending channel notification sent to user %s for channel %s', user.telegram_id, channel.id) + return None diff --git a/src/usecase/project/tg_add_project_2.py b/src/usecase/project/tg_add_project_2.py deleted file mode 100644 index 9c0dd3d..0000000 --- a/src/usecase/project/tg_add_project_2.py +++ /dev/null @@ -1,108 +0,0 @@ -import logging -import uuid -from typing import TYPE_CHECKING - -from src import domain - -if TYPE_CHECKING: - from .. import Usecase - -log = logging.getLogger(__name__) - - -async def tg_add_project_2( - self: 'Usecase', telegram_id: int, chat_id: int, callback_data: str, message_id: int -) -> None: - state = await self.database.get_telegram_state(telegram_id) - if not state or state.state != domain.TelegramStateEnum.PROJECT_WAITING_WORKSPACE: - log.debug('User %s has no active project workspace selection flow', telegram_id) - return - - context = state.context or {} - previous_state_value = context.get('previous_state') - previous_context = context.get('previous_context') or {} - previous_state = None - if previous_state_value: - try: - previous_state = domain.TelegramStateEnum(previous_state_value) - except ValueError: - previous_state = None - - if not callback_data.startswith('tg_add_project_2:'): - log.warning('Invalid project workspace callback data: %s', callback_data) - return - - workspace_id_str = callback_data.split(':', 1)[1] - try: - workspace_id = uuid.UUID(workspace_id_str) - except ValueError: - log.error('Invalid workspace UUID in callback data: %s', workspace_id_str) - await self.telegram_bot.send_message('❌ Некорректное рабочее пространство.', chat_id) - if previous_state: - await self.database.set_telegram_state(telegram_id, previous_state, previous_context) - else: - await self.database.clear_telegram_state(telegram_id) - return - - channel_data = context.get('channel_data') - if not channel_data: - log.error('Missing channel data for user %s in project flow', telegram_id) - await self.telegram_bot.send_message('❌ Не удалось подключить канал. Попробуйте снова.', chat_id) - if previous_state: - await self.database.set_telegram_state(telegram_id, previous_state, previous_context) - else: - await self.database.clear_telegram_state(telegram_id) - return - - user = await self.database.get_user(telegram_id=telegram_id) - if not user: - await self.telegram_bot.send_message('Вы не зарегистрированы на платформе.', chat_id) - await self.database.clear_telegram_state(telegram_id) - return - - workspace = await self.database.get_workspace_for_user(workspace_id, user.id) - if not workspace: - await self.telegram_bot.send_message('❌ У вас нет доступа к выбранному рабочему пространству.', chat_id) - return - - telegram_channel_id = int(channel_data['telegram_id']) - channel = await self.database.get_channel(telegram_id=telegram_channel_id) - if channel: - channel.title = str(channel_data['title']) - channel.username = channel_data.get('username') - await self.database.update_channel(channel) - else: - channel = domain.Channel( - telegram_id=telegram_channel_id, - title=str(channel_data['title']), - username=channel_data.get('username'), - ) - await self.database.create_channel(channel) - - project = await self.database.get_project(workspace.id, channel_id=channel.id) - if project: - project.status = domain.ProjectStatus.ACTIVE - await self.database.update_project(project) - else: - project = domain.Project(workspace_id=workspace.id, channel_id=channel.id, status=domain.ProjectStatus.ACTIVE) - await self.database.create_project(project) - - await self.ensure_active_purchase_plan(project) - - log.info( - 'Channel %s connected/updated successfully in workspace %s by user %s', - channel.telegram_id, - workspace.id, - telegram_id, - ) - - await self.telegram_bot.send_message( - f'✅ Канал "{channel.title}" добавлен в рабочее пространство "{workspace.name}".', telegram_id - ) - - await self.telegram_bot.edit_message_reply_markup(chat_id, message_id) - - if previous_state: - await self.database.set_telegram_state(telegram_id, previous_state, previous_context) - else: - await self.database.clear_telegram_state(telegram_id) diff --git a/src/usecase/project/update_project_invite_link_type.py b/src/usecase/project/update_project_invite_link_type.py new file mode 100644 index 0000000..fd0a718 --- /dev/null +++ b/src/usecase/project/update_project_invite_link_type.py @@ -0,0 +1,42 @@ +import logging +import uuid +from typing import TYPE_CHECKING + +from src import domain + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def update_project_invite_link_type( + self: 'Usecase', + workspace_id: uuid.UUID, + project_id: uuid.UUID, + invite_link_type: domain.InviteLinkType, + user_id: uuid.UUID, +) -> domain.Project: + await self.ensure_workspace_permission( + workspace_id, + user_id, + domain.PermissionKey.PROJECTS_WRITE, + for_project_id=project_id, + ) + + async with self.database.transaction(): + project = await self.database.get_project(workspace_id, project_id=project_id) + if not project: + raise domain.ProjectNotFound(project_id) + + project.invite_link_type = invite_link_type + await project.save() + + log.info( + 'Project %s invite_link_type updated to %s by user %s', + project_id, + invite_link_type, + user_id, + ) + + return project diff --git a/src/usecase/workspace/accept_workspace_invite.py b/src/usecase/workspace/accept_workspace_invite.py new file mode 100644 index 0000000..62bd1fe --- /dev/null +++ b/src/usecase/workspace/accept_workspace_invite.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + + +async def accept_workspace_invite( + self: Usecase, + invite_id: uuid.UUID, + user_id: uuid.UUID, +) -> dto.WorkspaceInviteOutput: + invite = await self.database.get_workspace_invite(invite_id) + if not invite or invite.user_id != user_id: + raise domain.WorkspaceInviteNotFound() + + if invite.status != domain.WorkspaceInviteStatus.PENDING: + raise domain.WorkspaceInviteAlreadyProcessed() + + async with self.database.transaction(): + invite.status = domain.WorkspaceInviteStatus.ACCEPTED + await self.database.update_workspace_invite(invite) + + membership = await self.database.get_workspace_membership(invite.workspace_id, user_id) + if membership: + if membership.status != domain.WorkspaceUserStatus.ACTIVE: + membership.status = domain.WorkspaceUserStatus.ACTIVE + await self.database.update_workspace_user(membership) + else: + await self.database.add_user_to_workspace(invite.workspace_id, user_id) + + return dto.WorkspaceInviteOutput.from_domain(invite) \ No newline at end of file diff --git a/telegram_bot/adapter/backend/backend_client.go b/telegram_bot/adapter/backend/backend_client.go new file mode 100644 index 0000000..36e77c1 --- /dev/null +++ b/telegram_bot/adapter/backend/backend_client.go @@ -0,0 +1,761 @@ +package backend + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +var ErrNotFound = errors.New("not found") + +type Config struct { + BaseURL string `envconfig:"BACKEND__BASE_URL" default:"http_v1://localhost:8000"` + LoginURL string `envconfig:"LOGIN_URL" required:"true"` +} + +type Client struct { + http *http.Client + baseURL string + loginURL string +} + +func New(cfg Config) *Client { + return &Client{ + http: &http.Client{ + Timeout: 5 * time.Second, + }, + baseURL: cfg.BaseURL, + loginURL: cfg.LoginURL, + } +} + +func withBearer(token string) func(*http.Request) { + return func(r *http.Request) { + r.Header.Set("Authorization", "Bearer "+token) + } +} + +func (c *Client) do(ctx context.Context, method string, path string, in any, out any, opts ...func(*http.Request)) error { + var body io.Reader + + if in != nil { + b, err := json.Marshal(in) + if err != nil { + return fmt.Errorf("json.Marshal: %w", err) + } + body = bytes.NewReader(b) + } + + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+"/"+path, body) + if err != nil { + return fmt.Errorf("http_v1.NewRequest: %w", err) + } + + if in != nil { + req.Header.Set("Content-Type", "application/json") + } + + for _, opt := range opts { + opt(req) + } + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("client.Do: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return ErrNotFound + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10)) + return fmt.Errorf("request failed: %s: %s", resp.Status, b) + } + + if out != nil { + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("json.Decode: %w", err) + } + } + + return nil +} + +func (c *Client) GetLoginURL(token string) string { + return c.loginURL + token +} + +func (c *Client) CreateLoginToken( + ctx context.Context, + telegramID int64, + username *string, +) (string, error) { + req := struct { + TelegramID int64 `json:"telegram_id"` + Username *string `json:"username"` + }{ + TelegramID: telegramID, + Username: username, + } + + var token string + err := c.do( + ctx, + http.MethodPost, + "api/v1/internal/auth/login-token", + req, + &token, + ) + + return token, err +} + +func (c *Client) GetJWTByTelegramID( + ctx context.Context, + telegramID int64, +) (string, error) { + q := url.Values{} + q.Set("telegram_id", fmt.Sprint(telegramID)) + + path := "api/v1/internal/auth/jwt?" + q.Encode() + + var resp struct { + AccessToken string `json:"access_token"` + } + + err := c.do(ctx, http.MethodGet, path, nil, &resp) + return resp.AccessToken, err +} + +type Workspace struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type Project struct { + ID string `json:"id"` + TelegramID int64 `json:"telegram_id"` + Title string `json:"title"` + Username *string `json:"username"` + Status string `json:"status"` + InviteLinkType string `json:"invite_link_type"` // "public" или "approval" +} + +type Page struct { + Items []Project `json:"items"` + Total int `json:"total"` + Page int `json:"page"` + Size int `json:"size"` + Pages int `json:"pages"` +} + +func (c *Client) GetWorkspaces( + ctx context.Context, + jwt string, +) ([]Workspace, error) { + var resp struct { + Items []Workspace `json:"items"` + } + + err := c.do( + ctx, + http.MethodGet, + "api/v1/workspaces", + nil, + &resp, + withBearer(jwt), + ) + + return resp.Items, err +} + +func (c *Client) GetProjects( + ctx context.Context, + jwt string, + workspaceID string, + page, size int, +) (*Page, error) { + q := url.Values{} + q.Set("page", fmt.Sprint(page)) + q.Set("size", fmt.Sprint(size)) + + path := fmt.Sprintf("api/v1/workspaces/%s/projects?%s", workspaceID, q.Encode()) + + var resp Page + err := c.do( + ctx, + http.MethodGet, + path, + nil, + &resp, + withBearer(jwt), + ) + + return &resp, err +} + +func (c *Client) GetProject( + ctx context.Context, + workspaceID string, + projectID string, +) (*Project, error) { + path := fmt.Sprintf("api/v1/internal/projects/%s/%s", workspaceID, projectID) + + var project Project + err := c.do( + ctx, + http.MethodGet, + path, + nil, + &project, + ) + + return &project, err +} + +func (c *Client) UpdateProjectInviteLinkType( + ctx context.Context, + jwt string, + workspaceID string, + projectID string, + inviteLinkType string, // "public" или "approval" +) (*Project, error) { + path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/invite-link-type", workspaceID, projectID) + + payload := map[string]string{ + "invite_link_type": inviteLinkType, + } + + var project Project + err := c.do( + ctx, + http.MethodPatch, + path, + payload, + &project, + withBearer(jwt), + ) + + return &project, err +} + +func (c *Client) ForwardTelegramUpdate( + ctx context.Context, + update any, +) error { + return c.do( + ctx, + http.MethodPost, + "api/v1/internal/telegram/updates", + update, + nil, + ) +} + +func (c *Client) AttachChannelToWorkspace( + ctx context.Context, + channelID string, + workspaceID string, + userTelegramID int64, +) (*Project, error) { + input := map[string]any{ + "channel_id": channelID, + "workspace_id": workspaceID, + "user_telegram_id": userTelegramID, + } + + var project Project + err := c.do( + ctx, + http.MethodPost, + "api/v1/internal/projects", + input, + &project, + ) + + return &project, err +} + +func (c *Client) AcceptWorkspaceInvite( + ctx context.Context, + jwt string, + inviteID string, +) error { + path := fmt.Sprintf("api/v1/invites/%s/accept", inviteID) + + return c.do( + ctx, + http.MethodPost, + path, + nil, + nil, + withBearer(jwt), + ) +} + +// ============================================================================ +// Creatives +// ============================================================================ + +type Creative struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + Name string `json:"name"` + Text string `json:"text"` + IsArchived bool `json:"is_archived"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type CreativesPage struct { + Items []Creative `json:"items"` + Total int `json:"total"` + Page int `json:"page"` + Size int `json:"size"` + Pages int `json:"pages"` +} + +func (c *Client) GetCreatives( + ctx context.Context, + jwt string, + workspaceID string, + projectID *string, + includeArchived bool, + page, size int, +) (*CreativesPage, error) { + q := url.Values{} + if projectID != nil { + q.Set("project_id", *projectID) + } + q.Set("include_archived", fmt.Sprint(includeArchived)) + q.Set("page", fmt.Sprint(page)) + q.Set("size", fmt.Sprint(size)) + + path := fmt.Sprintf("api/v1/workspaces/%s/creatives?%s", workspaceID, q.Encode()) + + var resp CreativesPage + err := c.do( + ctx, + http.MethodGet, + path, + nil, + &resp, + withBearer(jwt), + ) + + return &resp, err +} + +func (c *Client) GetCreative( + ctx context.Context, + jwt string, + workspaceID string, + creativeID string, +) (*Creative, error) { + path := fmt.Sprintf("api/v1/workspaces/%s/creatives/%s", workspaceID, creativeID) + + var creative Creative + err := c.do( + ctx, + http.MethodGet, + path, + nil, + &creative, + withBearer(jwt), + ) + + return &creative, err +} + +type CreateCreativeInput struct { + Name string `json:"name"` + Text string `json:"text"` +} + +func (c *Client) CreateCreative( + ctx context.Context, + jwt string, + workspaceID string, + projectID string, + input CreateCreativeInput, +) (*Creative, error) { + q := url.Values{} + q.Set("project_id", projectID) + + path := fmt.Sprintf("api/v1/workspaces/%s/creatives?%s", workspaceID, q.Encode()) + + var creative Creative + err := c.do( + ctx, + http.MethodPost, + path, + input, + &creative, + withBearer(jwt), + ) + + return &creative, err +} + +type UpdateCreativeInput struct { + Name *string `json:"name,omitempty"` + Text *string `json:"text,omitempty"` + IsArchived *bool `json:"is_archived,omitempty"` +} + +func (c *Client) UpdateCreative( + ctx context.Context, + jwt string, + workspaceID string, + creativeID string, + input UpdateCreativeInput, +) (*Creative, error) { + path := fmt.Sprintf("api/v1/workspaces/%s/creatives/%s", workspaceID, creativeID) + + var creative Creative + err := c.do( + ctx, + http.MethodPatch, + path, + input, + &creative, + withBearer(jwt), + ) + + return &creative, err +} + +func (c *Client) DeleteCreative( + ctx context.Context, + jwt string, + workspaceID string, + creativeID string, +) error { + path := fmt.Sprintf("api/v1/workspaces/%s/creatives/%s", workspaceID, creativeID) + + return c.do( + ctx, + http.MethodDelete, + path, + nil, + nil, + withBearer(jwt), + ) +} + +// ============================================================================ +// Placements (Размещения/Закупы) +// ============================================================================ + +type Placement struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + PlacementChannelID string `json:"placement_channel_id"` + CreativeID *string `json:"creative_id"` + Name string `json:"name"` + Price *int `json:"price"` + Status string `json:"status"` + InviteLink *string `json:"invite_link"` + IsArchived bool `json:"is_archived"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type PlacementsPage struct { + Items []Placement `json:"items"` + Total int `json:"total"` + Page int `json:"page"` + Size int `json:"size"` + Pages int `json:"pages"` +} + +func (c *Client) GetPlacements( + ctx context.Context, + jwt string, + workspaceID string, + projectID *string, + placementChannelID *string, + creativeID *string, + includeArchived bool, + page, size int, +) (*PlacementsPage, error) { + q := url.Values{} + if projectID != nil { + q.Set("project_id", *projectID) + } + if placementChannelID != nil { + q.Set("placement_channel_id", *placementChannelID) + } + if creativeID != nil { + q.Set("creative_id", *creativeID) + } + q.Set("include_archived", fmt.Sprint(includeArchived)) + q.Set("page", fmt.Sprint(page)) + q.Set("size", fmt.Sprint(size)) + + path := fmt.Sprintf("api/v1/workspaces/%s/placements?%s", workspaceID, q.Encode()) + + var resp PlacementsPage + err := c.do( + ctx, + http.MethodGet, + path, + nil, + &resp, + withBearer(jwt), + ) + + return &resp, err +} + +func (c *Client) GetPlacement( + ctx context.Context, + jwt string, + workspaceID string, + placementID string, +) (*Placement, error) { + path := fmt.Sprintf("api/v1/workspaces/%s/placements/%s", workspaceID, placementID) + + var placement Placement + err := c.do( + ctx, + http.MethodGet, + path, + nil, + &placement, + withBearer(jwt), + ) + + return &placement, err +} + +type CreatePlacementInput struct { + ProjectID string `json:"project_id"` + PlacementChannelID string `json:"placement_channel_id"` + CreativeID *string `json:"creative_id,omitempty"` + Name string `json:"name"` + Price *int `json:"price,omitempty"` +} + +func (c *Client) CreatePlacement( + ctx context.Context, + jwt string, + workspaceID string, + input CreatePlacementInput, +) (*Placement, error) { + path := fmt.Sprintf("api/v1/workspaces/%s/placements", workspaceID) + + var placement Placement + err := c.do( + ctx, + http.MethodPost, + path, + input, + &placement, + withBearer(jwt), + ) + + return &placement, err +} + +type UpdatePlacementInput struct { + Name *string `json:"name,omitempty"` + Price *int `json:"price,omitempty"` + Status *string `json:"status,omitempty"` + IsArchived *bool `json:"is_archived,omitempty"` +} + +func (c *Client) UpdatePlacement( + ctx context.Context, + jwt string, + workspaceID string, + placementID string, + input UpdatePlacementInput, +) (*Placement, error) { + path := fmt.Sprintf("api/v1/workspaces/%s/placements/%s", workspaceID, placementID) + + var placement Placement + err := c.do( + ctx, + http.MethodPatch, + path, + input, + &placement, + withBearer(jwt), + ) + + return &placement, err +} + +func (c *Client) DeletePlacement( + ctx context.Context, + jwt string, + workspaceID string, + placementID string, +) error { + path := fmt.Sprintf("api/v1/workspaces/%s/placements/%s", workspaceID, placementID) + + return c.do( + ctx, + http.MethodDelete, + path, + nil, + nil, + withBearer(jwt), + ) +} + +// ============================================================================ +// Purchase Plan +// ============================================================================ + +type PurchasePlanChannel struct { + ID string `json:"id"` + ChannelID string `json:"channel_id"` + Status string `json:"status"` + PlannedCost *int `json:"planned_cost"` + Comment *string `json:"comment"` +} + +func (c *Client) GetPurchasePlanChannels( + ctx context.Context, + jwt string, + workspaceID string, + projectID string, +) ([]PurchasePlanChannel, error) { + path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase-plan/channels", workspaceID, projectID) + + var resp struct { + Items []PurchasePlanChannel `json:"items"` + } + + err := c.do( + ctx, + http.MethodGet, + path, + nil, + &resp, + withBearer(jwt), + ) + + return resp.Items, err +} + +type AttachChannelToPurchasePlanInput struct { + ChannelID string `json:"channel_id"` + Status *string `json:"status,omitempty"` + PlannedCost *int `json:"planned_cost,omitempty"` + Comment *string `json:"comment,omitempty"` +} + +func (c *Client) AttachChannelToPurchasePlan( + ctx context.Context, + jwt string, + workspaceID string, + projectID string, + input AttachChannelToPurchasePlanInput, +) (*PurchasePlanChannel, error) { + path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase-plan/channels", workspaceID, projectID) + + var planChannel PurchasePlanChannel + err := c.do( + ctx, + http.MethodPost, + path, + input, + &planChannel, + withBearer(jwt), + ) + + return &planChannel, err +} + +type UpdatePurchasePlanChannelInput struct { + Status *string `json:"status,omitempty"` + PlannedCost *int `json:"planned_cost,omitempty"` + Comment *string `json:"comment,omitempty"` +} + +func (c *Client) UpdatePurchasePlanChannel( + ctx context.Context, + jwt string, + workspaceID string, + projectID string, + channelID string, + input UpdatePurchasePlanChannelInput, +) (*PurchasePlanChannel, error) { + path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase-plan/channels/%s", workspaceID, projectID, channelID) + + var planChannel PurchasePlanChannel + err := c.do( + ctx, + http.MethodPatch, + path, + input, + &planChannel, + withBearer(jwt), + ) + + return &planChannel, err +} + +func (c *Client) RemoveChannelFromPurchasePlan( + ctx context.Context, + jwt string, + workspaceID string, + projectID string, + channelID string, +) error { + path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase-plan/channels/%s", workspaceID, projectID, channelID) + + return c.do( + ctx, + http.MethodDelete, + path, + nil, + nil, + withBearer(jwt), + ) +} + +// ============================================================================ +// Workspace Members +// ============================================================================ + +type WorkspaceMember struct { + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + UserID string `json:"user_id"` + Username string `json:"username"` +} + +func (c *Client) GetWorkspaceMembers( + ctx context.Context, + jwt string, + workspaceID string, +) ([]WorkspaceMember, error) { + path := fmt.Sprintf("api/v1/workspaces/%s/members", workspaceID) + + var resp struct { + Items []WorkspaceMember `json:"items"` + } + + err := c.do( + ctx, + http.MethodGet, + path, + nil, + &resp, + withBearer(jwt), + ) + + return resp.Items, err +} diff --git a/telegram_bot/bot/bot.go b/telegram_bot/bot/bot.go new file mode 100644 index 0000000..ca1ba2f --- /dev/null +++ b/telegram_bot/bot/bot.go @@ -0,0 +1,274 @@ +package bot + +import ( + "context" + "fmt" + + "example.com/m/adapter/backend" + "github.com/NicoNex/echotron/v3" + "github.com/rs/zerolog/log" +) + +func Button(text, data string) echotron.InlineKeyboardButton { + return echotron.InlineKeyboardButton{ + Text: text, + CallbackData: data, + } +} + +func Row(buttons ...echotron.InlineKeyboardButton) []echotron.InlineKeyboardButton { return buttons } + +func Keyboard(rows ...[]echotron.InlineKeyboardButton) echotron.InlineKeyboardMarkup { + return echotron.InlineKeyboardMarkup{ + InlineKeyboard: rows, + } +} + +type Exec struct { + handled bool + transitioned bool +} + +type Bot struct { + echotron.API + ChatID int64 + exec *Exec + CurrentState State + Session *Session + LastMessageID int // ID последнего сообщения с inline кнопками + DefaultStateFactory func() State + 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{ + ChatID: chatID, + API: echotron.NewAPI(token), + Session: &Session{}, + DefaultStateFactory: defaultStateFactory, + Backend: backendClient, + GlobalCallbackRouter: globalCallbackRouter, + } + return bot +} + +func (b *Bot) SetState(s State, mode RenderMode) { + log.Info(). + Int64("chat_id", b.ChatID). + Str("from_state", fmt.Sprintf("%T", b.CurrentState)). + Str("to_state", fmt.Sprintf("%T", s)). + Str("mode", fmt.Sprintf("%v", mode)). + Msg("State transition") + + b.CurrentState = s + b.exec.transitioned = true + b.exec.handled = true + 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) + } else { + b.SendNew(text, keyboard) + } +} + +func (b *Bot) SendNew(text string, keyboard echotron.InlineKeyboardMarkup) { + // Удаляем кнопки у предыдущего сообщения перед отправкой нового + if b.LastMessageID != 0 { + b.cleanupKeyboard(b.LastMessageID) + } + + res, err := b.SendMessage(text, b.ChatID, &echotron.MessageOptions{ + ReplyMarkup: keyboard, + ParseMode: echotron.HTML, + }) + b.exec.handled = true + + if err != nil { + log.Error().Err(err).Msg("SendMessage failed") + return + } + + if res.Result != nil { + b.LastMessageID = res.Result.ID + } +} + +func (b *Bot) Edit(text string, keyboard echotron.InlineKeyboardMarkup) { + if b.LastMessageID != 0 { + _, err := b.EditMessageText( + text, + echotron.NewMessageID(b.ChatID, b.LastMessageID), + &echotron.MessageTextOptions{ + ReplyMarkup: keyboard, + ParseMode: echotron.HTML, + }, + ) + if err != nil { + log.Error().Err(err).Msg("EditMessageText") + } + } else { + b.SendNew(text, keyboard) + } + b.exec.handled = true +} + +func (b *Bot) SendMessageWithURLButton(text, buttonText, url string) { + keyboard := echotron.InlineKeyboardMarkup{ + InlineKeyboard: [][]echotron.InlineKeyboardButton{ + { + { + Text: buttonText, + URL: url, + }, + }, + }, + } + + res, err := b.SendMessage(text, b.ChatID, &echotron.MessageOptions{ + ReplyMarkup: keyboard, + }) + if err != nil { + log.Error().Err(err).Msg("SendMessageWithURLButton") + } + b.exec.handled = true + + if err == nil && res.Result != nil { + b.LastMessageID = res.Result.ID + } +} + +func (b *Bot) Update(u *echotron.Update) { + log.Info(). + Int64("chat_id", b.ChatID). + Str("current_state", fmt.Sprintf("%T", b.CurrentState)). + Msg("Update received") + + // Обрабатываем системные события (добавление в канал, подписки и т.д.) + if u.ChatJoinRequest != nil || u.ChatMember != nil || u.MyChatMember != nil { + go func() { + ctx := context.Background() + 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 + 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 + } + + if chatType != "" && chatType != "private" { + log.Debug().Str("chat_type", chatType).Msg("Ignoring event from non-private chat") + return + } + + exec := &Exec{} + + b.exec = exec + defer func() { b.exec = nil }() + + if b.CurrentState == nil && b.DefaultStateFactory != nil { + b.CurrentState = b.DefaultStateFactory() + } + + if u.Message != nil && u.Message.Text == "/start" { + if b.DefaultStateFactory != nil { + b.SetState(b.DefaultStateFactory(), NewMessage) + } + return + } + + if u.CallbackQuery != nil { + b.cleanupCallbackUI(u) + + if b.GlobalCallbackRouter != nil && u.CallbackQuery.Data != "" { + 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 { + log.Info().Msg("Common handler") + b.CurrentState.Handle(b, u) + } + + if !exec.handled && !exec.transitioned { + log.Info().Msg("Default feedback") + b.SetState(b.DefaultStateFactory(), NewMessage) + } +} + +func (b *Bot) cleanupKeyboard(messageID int) { + emptyKeyboard := Keyboard() + _, err := b.EditMessageReplyMarkup( + echotron.NewMessageID(b.ChatID, messageID), + &echotron.MessageReplyMarkupOptions{ReplyMarkup: emptyKeyboard}, + ) + if err != nil { + log.Error().Err(err).Int("message_id", messageID).Msg("Failed to remove keyboard") + } +} + +func (b *Bot) cleanupCallbackUI(u *echotron.Update) { + _, err := b.AnswerCallbackQuery(u.CallbackQuery.ID, nil) + if err != nil { + log.Error().Err(err).Msg("b.AnswerCallbackQuery error") + } + + if u.CallbackQuery.Message != nil { + callbackMessageID := u.CallbackQuery.Message.ID + + if b.LastMessageID == callbackMessageID { + return + } + + b.cleanupKeyboard(callbackMessageID) + } +} diff --git a/telegram_bot/bot/state.go b/telegram_bot/bot/state.go new file mode 100644 index 0000000..767dd71 --- /dev/null +++ b/telegram_bot/bot/state.go @@ -0,0 +1,22 @@ +package bot + +import "github.com/NicoNex/echotron/v3" + +type RenderMode int + +const ( + NewMessage RenderMode = iota + EditMessage +) + +type State interface { + Enter(*Bot, RenderMode) + HandleCallback(*Bot, *echotron.Update) + HandleMessage(*Bot, *echotron.Update) + Handle(*Bot, *echotron.Update) +} + +type Session struct { + UserName string + JWT string +} diff --git a/telegram_bot/go.mod b/telegram_bot/go.mod new file mode 100644 index 0000000..1d68548 --- /dev/null +++ b/telegram_bot/go.mod @@ -0,0 +1,13 @@ +module example.com/m + +go 1.24.4 + +require github.com/rs/zerolog v1.34.0 + +require ( + github.com/NicoNex/echotron/v3 v3.43.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/time v0.5.0 // indirect +) diff --git a/telegram_bot/go.sum b/telegram_bot/go.sum new file mode 100644 index 0000000..6359c6b --- /dev/null +++ b/telegram_bot/go.sum @@ -0,0 +1,22 @@ +github.com/NicoNex/echotron/v3 v3.43.0 h1:efE2spw3mfU0Ev20m0PqqvgMSm0xHzgSxlWAEmC9RC4= +github.com/NicoNex/echotron/v3 v3.43.0/go.mod h1:7LvjveJmezuUOeaoA3nzQduNlSPQYfq219Z+baKY04Q= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= diff --git a/telegram_bot/handlers/global_callbacks.go b/telegram_bot/handlers/global_callbacks.go new file mode 100644 index 0000000..54d19a5 --- /dev/null +++ b/telegram_bot/handlers/global_callbacks.go @@ -0,0 +1,42 @@ +package handlers + +import ( + "strings" + + "example.com/m/bot" + "example.com/m/screens" +) + +// HandleGlobalCallback обрабатывает глобальные callbacks из уведомлений +// Возвращает новый State если callback был обработан, иначе nil +func HandleGlobalCallback(callbackData string) bot.State { + // pending_channel:{channel_id} - выбор workspace для нового канала + if strings.HasPrefix(callbackData, "pending_channel:") { + parts := strings.Split(callbackData, ":") + if len(parts) == 2 { + channelID := parts[1] + return &screens.SelectWorkspace{ + ChannelID: channelID, + BackState: &screens.MainMenu{}, + } + } + } + + // workspace_invite_accept:{invite_id} - принятие приглашения в workspace + if strings.HasPrefix(callbackData, "workspace_invite_accept:") { + parts := strings.Split(callbackData, ":") + if len(parts) == 2 { + inviteID := parts[1] + return &screens.AcceptWorkspaceInvite{ + InviteID: inviteID, + } + } + } + + // Здесь можно добавлять другие глобальные callback паттерны + // Например: + // if strings.HasPrefix(callbackData, "notification:") { ... } + // if strings.HasPrefix(callbackData, "alert:") { ... } + + return nil +} diff --git a/telegram_bot/main.go b/telegram_bot/main.go new file mode 100644 index 0000000..9ee8d91 --- /dev/null +++ b/telegram_bot/main.go @@ -0,0 +1,47 @@ +package main + +import ( + "time" + + "example.com/m/adapter/backend" + "example.com/m/bot" + "example.com/m/handlers" + "example.com/m/pkg/logger" + "example.com/m/screens" + "github.com/NicoNex/echotron/v3" + "github.com/rs/zerolog/log" +) + +const botToken = "8134938541:AAFcZhIGfYynQ9axyuA35R1nd7sPQZodrrQ" + +func main() { + logger.Init(logger.Config{ + AppName: "tgex-bot", + AppVersion: "v0.0.1", + PrettyConsole: true, + }) + + // Инициализация backend client + backendClient := backend.New(backend.Config{ + BaseURL: "http://localhost:8000", + LoginURL: "https://unstabilising-lora-unframable.ngrok-free.dev/api/v1/auth/complete?token=", + }) + + var mainMenu = func() bot.State { return &screens.MainMenu{} } + newBot := func(chatID int64) echotron.Bot { + return bot.NewBot(chatID, botToken, mainMenu, backendClient, handlers.HandleGlobalCallback) + } + + dsp := echotron.NewDispatcher(botToken, newBot) + + log.Info().Msg("bot starting...") + + for { + if err := dsp.Poll(); err != nil { + log.Error().Err(err).Msg("dsp.Poll failed, retrying in 5 seconds...") + time.Sleep(5 * time.Second) + continue + } + break + } +} diff --git a/telegram_bot/pkg/logger/logger.go b/telegram_bot/pkg/logger/logger.go new file mode 100644 index 0000000..32057b2 --- /dev/null +++ b/telegram_bot/pkg/logger/logger.go @@ -0,0 +1,38 @@ +package logger + +import ( + "os" + "time" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +type Config struct { + AppName string `envconfig:"APP_NAME" required:"true"` + AppVersion string `envconfig:"APP_VERSION" required:"true"` + Level string `default:"error" envconfig:"LOGGER_LEVEL"` + PrettyConsole bool `default:"false" envconfig:"LOGGER_PRETTY_CONSOLE"` +} + +func Init(c Config) { + zerolog.TimeFieldFormat = time.RFC3339 + zerolog.SetGlobalLevel(zerolog.InfoLevel) + + level, err := zerolog.ParseLevel(c.Level) + if err != nil { + zerolog.SetGlobalLevel(level) + } + + log.Logger = log.With(). + // Caller(). + // Str("app_name", c.AppName). + // Str("app_version", c.AppVersion). + Logger() + + if c.PrettyConsole { + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: "15:04:05"}) + } + + log.Info().Msg("Logger initialized") +} diff --git a/telegram_bot/screens/accept_workspace_invite.go b/telegram_bot/screens/accept_workspace_invite.go new file mode 100644 index 0000000..81a9731 --- /dev/null +++ b/telegram_bot/screens/accept_workspace_invite.go @@ -0,0 +1,54 @@ +package screens + +import ( + "context" + + "example.com/m/bot" + "github.com/NicoNex/echotron/v3" + "github.com/rs/zerolog/log" +) + +type AcceptWorkspaceInvite struct { + InviteID string +} + +func (s *AcceptWorkspaceInvite) Enter(b *bot.Bot, mode bot.RenderMode) { + // Получаем JWT токен + jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID) + if err != nil { + log.Error().Err(err).Msg("Failed to get JWT") + b.SendNew("❌ Ошибка авторизации. Попробуйте /start", bot.Keyboard()) + return + } + + // Принимаем приглашение + err = b.Backend.AcceptWorkspaceInvite(context.Background(), jwt, s.InviteID) + if err != nil { + log.Error().Err(err).Str("invite_id", s.InviteID).Msg("Failed to accept workspace invite") + b.SendNew("❌ Не удалось принять приглашение. Возможно, оно уже было использовано.", bot.Keyboard()) + return + } + + // Успешно принято + text := "✅ Вы присоединились к рабочему пространству!\n\n" + + "Администратор сможет выдать вам необходимые права в веб-интерфейсе." + + b.SendNew(text, bot.Keyboard( + bot.Row(bot.Button("🏠 Главное меню", "menu")), + )) +} + +func (s *AcceptWorkspaceInvite) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + + if u.CallbackQuery.Data == "menu" { + b.SetState(&MainMenu{}, bot.EditMessage) + } + return +} + +func (s *AcceptWorkspaceInvite) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *AcceptWorkspaceInvite) Handle(_ *bot.Bot, _ *echotron.Update) { return } diff --git a/telegram_bot/screens/add_creative.go b/telegram_bot/screens/add_creative.go new file mode 100644 index 0000000..b21736c --- /dev/null +++ b/telegram_bot/screens/add_creative.go @@ -0,0 +1,686 @@ +package screens + +import ( + "context" + "fmt" + "strings" + + "example.com/m/adapter/backend" + "example.com/m/bot" + "github.com/NicoNex/echotron/v3" + "github.com/rs/zerolog/log" +) + +type AddCreative struct { + ProjectID string + WorkspaceID string + BackState bot.State + + // Промежуточные данные для многошагового ввода + Name *string + Text *string + Buttons []InlineButton + + // Медиа + MediaType string // "photo", "video", "animation" + MediaFileID string // File ID из Telegram + + // Состояние для добавления кнопки + WaitingForButtonText bool + WaitingForButtonURL bool + CurrentButtonText *string + WaitingForMedia bool + + // ID сообщения с креативом (которое постоянно обновляется) + CreativeMessageID *int + // ID сообщения с панелью управления + ControlPanelMessageID *int +} + +type InlineButton struct { + Text string + URL string +} + +func (s *AddCreative) Enter(b *bot.Bot, mode bot.RenderMode) { + text := `➕ Добавить креатив + +Введите название креатива: + +Например: +• Летняя акция +• Баннер 1920x1080 +• Видео промо` + + buttons := [][]echotron.InlineKeyboardButton{ + bot.Row(bot.Button("« Отмена", "cancel")), + } + + keyboard := bot.Keyboard(buttons...) + b.Render(text, keyboard, mode) +} + +func (s *AddCreative) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + + data := u.CallbackQuery.Data + + switch { + case data == "cancel": + if s.BackState != nil { + b.SetState(s.BackState, bot.EditMessage) + } + + case data == "edit_text": + // Редактируем панель управления - просим текст + s.showControlPanel(b, `✏️ Введите текст креатива: + +⚠️ Важно: Текст должен содержать ОДНУ инвайт-ссылку вашего канала + +Формат ссылки: +https://t.me/+xxx + +Пример: +Присоединяйтесь к нашему каналу! +https://t.me/+AbCdEfGhIjKlMn`, [][]echotron.InlineKeyboardButton{ + bot.Row(bot.Button("« Отмена", "cancel_edit")), + }) + + case data == "add_button": + // Начинаем процесс добавления кнопки + s.WaitingForButtonText = true + + s.showControlPanel(b, `➕ Добавить кнопку + +Введите текст кнопки: + +Например: +• Перейти на сайт +• Написать нам`, [][]echotron.InlineKeyboardButton{ + bot.Row(bot.Button("« Отмена", "cancel_add_button")), + }) + + case data == "add_media": + // Начинаем процесс добавления медиа + s.WaitingForMedia = true + + s.showControlPanel(b, `📷 Добавить медиа + +Отправьте фото, видео или GIF + +Это медиа будет отображаться в креативе вместе с текстом`, [][]echotron.InlineKeyboardButton{ + bot.Row(bot.Button("« Отмена", "cancel_media")), + }) + + case data == "delete_media": + // Удаляем медиа + s.MediaType = "" + s.MediaFileID = "" + s.updateCreativePreview(b) + s.showManagementPanel(b) + + case data == "cancel_add_button": + // Отменяем добавление кнопки + // Если мы уже создали кнопку с placeholder - удаляем её + if s.WaitingForButtonURL && len(s.Buttons) > 0 { + lastButton := s.Buttons[len(s.Buttons)-1] + if lastButton.URL == "https://example.com" { + s.Buttons = s.Buttons[:len(s.Buttons)-1] + s.updateCreativePreview(b) + } + } + s.WaitingForButtonText = false + s.WaitingForButtonURL = false + s.CurrentButtonText = nil + s.showManagementPanel(b) + + case data == "cancel_edit", data == "cancel_media": + // Отменяем редактирование текста или добавление медиа + s.WaitingForMedia = false + s.showManagementPanel(b) + + case data == "confirm_create": + // Создаём креатив + s.createCreative(b) + + case strings.HasPrefix(data, "delete_button:"): + // Удаляем кнопку по индексу + parts := strings.Split(data, ":") + if len(parts) == 2 { + var index int + if _, err := fmt.Sscanf(parts[1], "%d", &index); err == nil { + if index >= 0 && index < len(s.Buttons) { + s.Buttons = append(s.Buttons[:index], s.Buttons[index+1:]...) + s.updateCreativePreview(b) + s.showManagementPanel(b) + } + } + } + + default: + s.Enter(b, bot.NewMessage) + } +} + +func (s *AddCreative) HandleMessage(b *bot.Bot, u *echotron.Update) { + if u.Message == nil { + return + } + + // Шаг 1: Получаем название + if s.Name == nil { + if u.Message.Text == "" { + return + } + name := u.Message.Text + s.Name = &name + + // Отправляем креатив + панель управления + s.sendCreativeAndPanel(b) + return + } + + // Обработка медиа + if s.WaitingForMedia { + handled := false + + if u.Message.Photo != nil && len(u.Message.Photo) > 0 { + // Берем самое большое фото + photo := u.Message.Photo[len(u.Message.Photo)-1] + s.MediaType = "photo" + s.MediaFileID = photo.FileID + handled = true + } else if u.Message.Video != nil { + s.MediaType = "video" + s.MediaFileID = u.Message.Video.FileID + handled = true + } else if u.Message.Animation != nil { + s.MediaType = "animation" + s.MediaFileID = u.Message.Animation.FileID + handled = true + } + + if handled { + // Удаляем сообщение пользователя + _, err := b.DeleteMessage(b.ChatID, u.Message.ID) + if err != nil { + log.Error().Err(err).Msg("Failed to delete user message") + } + + s.WaitingForMedia = false + s.updateCreativePreview(b) + s.showManagementPanel(b) + return + } + } + + // Если не медиа и не текст - игнорируем + if u.Message.Text == "" { + return + } + + // Удаляем сообщение пользователя + _, err := b.DeleteMessage(b.ChatID, u.Message.ID) + if err != nil { + log.Error().Err(err).Msg("Failed to delete user message") + } + + // Обработка добавления кнопки - текст кнопки + if s.WaitingForButtonText { + buttonText := u.Message.Text + s.CurrentButtonText = &buttonText + s.WaitingForButtonText = false + s.WaitingForButtonURL = true + + // Сразу создаём кнопку с placeholder URL (используем валидный URL) + s.Buttons = append(s.Buttons, InlineButton{ + Text: buttonText, + URL: "https://example.com", + }) + + // Обновляем превью креатива, чтобы показать новую кнопку + s.updateCreativePreview(b) + + // Редактируем панель управления - просим URL + s.showControlPanel(b, `💡 Введите URL для кнопки: + +URL должен начинаться с https:// + +Например: +https://example.com +https://t.me/yourchannel`, [][]echotron.InlineKeyboardButton{ + bot.Row(bot.Button("« Отмена", "cancel_add_button")), + }) + return + } + + // Обработка добавления кнопки - URL + if s.WaitingForButtonURL { + url := u.Message.Text + + // Базовая валидация URL + if !strings.HasPrefix(url, "https://") { + s.showControlPanel(b, `❌ Неверный формат URL + +URL должен начинаться с https:// + +Попробуйте ещё раз.`, [][]echotron.InlineKeyboardButton{ + bot.Row(bot.Button("« Отмена", "cancel_add_button")), + }) + return + } + + // Обновляем URL последней кнопки (которую создали с placeholder) + if len(s.Buttons) > 0 { + s.Buttons[len(s.Buttons)-1].URL = url + } + s.WaitingForButtonURL = false + s.CurrentButtonText = nil + + // Обновляем креатив и возвращаем панель управления + s.updateCreativePreview(b) + s.showManagementPanel(b) + return + } + + // Шаг 2: Получаем текст + if s.Text == nil { + text := s.getFormattedText(u.Message) + s.Text = &text + + // Обновляем креатив и возвращаем панель управления + s.updateCreativePreview(b) + s.showManagementPanel(b) + return + } + + // Если текст уже задан - это повторное редактирование + text := s.getFormattedText(u.Message) + s.Text = &text + + // Обновляем креатив и возвращаем панель управления + s.updateCreativePreview(b) + s.showManagementPanel(b) +} + +func (s *AddCreative) Handle(_ *bot.Bot, _ *echotron.Update) { return } + +// getFormattedText конвертирует текст сообщения с entities в HTML +func (s *AddCreative) getFormattedText(message *echotron.Message) string { + if message.Text == "" { + return "" + } + + // Если нет entities - возвращаем текст как есть + if len(message.Entities) == 0 { + return message.Text + } + + // Преобразуем текст в руны для правильной работы с UTF-8 + runes := []rune(message.Text) + result := "" + lastPos := 0 + + // Обрабатываем entities + for _, entity := range message.Entities { + // Добавляем текст до entity + if entity.Offset > lastPos { + result += string(runes[lastPos:entity.Offset]) + } + + // Получаем текст entity + entityText := string(runes[entity.Offset : entity.Offset+entity.Length]) + + // Оборачиваем в соответствующие HTML-теги + switch entity.Type { + case "bold": + result += "" + entityText + "" + case "italic": + result += "" + entityText + "" + case "underline": + result += "" + entityText + "" + case "strikethrough": + result += "" + entityText + "" + case "code": + result += "" + entityText + "" + case "pre": + result += "
" + entityText + "
" + case "text_link": + if entity.URL != "" { + result += fmt.Sprintf("%s", entity.URL, entityText) + } else { + result += entityText + } + case "url": + result += entityText + default: + result += entityText + } + + lastPos = entity.Offset + entity.Length + } + + // Добавляем оставшийся текст + if lastPos < len(runes) { + result += string(runes[lastPos:]) + } + + return result +} + +// sendCreativeAndPanel отправляет превью креатива и панель управления +func (s *AddCreative) sendCreativeAndPanel(b *bot.Bot) { + // 1. Отправляем превью креатива + s.sendCreativePreview(b) + + // 2. Отправляем панель управления + s.showManagementPanel(b) +} + +// sendCreativePreview отправляет новое сообщение с превью креатива (с медиа или без) +func (s *AddCreative) sendCreativePreview(b *bot.Bot) { + creativeText := s.getCreativeText() + creativeButtons := s.getCreativeButtons() + + // Создаем клавиатуру с пустым массивом (не nil!) + keyboard := echotron.InlineKeyboardMarkup{ + InlineKeyboard: creativeButtons, + } + if len(creativeButtons) == 0 { + keyboard.InlineKeyboard = [][]echotron.InlineKeyboardButton{} + } + + var msgID int + + // Если есть медиа, отправляем с медиа + if s.MediaFileID != "" { + var res echotron.APIResponseMessage + var err error + + switch s.MediaType { + case "photo": + res, err = b.SendPhoto( + echotron.NewInputFileID(s.MediaFileID), + b.ChatID, + &echotron.PhotoOptions{ + Caption: creativeText, + ParseMode: echotron.HTML, + ReplyMarkup: keyboard, + }, + ) + case "video": + res, err = b.SendVideo( + echotron.NewInputFileID(s.MediaFileID), + b.ChatID, + &echotron.VideoOptions{ + Caption: creativeText, + ParseMode: echotron.HTML, + ReplyMarkup: keyboard, + }, + ) + case "animation": + res, err = b.SendAnimation( + echotron.NewInputFileID(s.MediaFileID), + b.ChatID, + &echotron.AnimationOptions{ + Caption: creativeText, + ParseMode: echotron.HTML, + ReplyMarkup: keyboard, + }, + ) + } + + if err != nil { + log.Error().Err(err).Str("media_type", s.MediaType).Msg("Failed to send media") + // Fallback to text message + b.SendNew(creativeText, keyboard) + msgID = b.LastMessageID + } else if res.Result != nil { + msgID = res.Result.ID + } + } else { + // Отправляем обычное текстовое сообщение + b.SendNew(creativeText, keyboard) + msgID = b.LastMessageID + } + + // ВАЖНО: Сохраняем копию значения, а не указатель на переменную! + s.CreativeMessageID = &msgID + log.Info().Int("creative_msg_id", *s.CreativeMessageID).Bool("has_media", s.MediaFileID != "").Msg("Creative preview sent") +} + +// updateCreativePreview обновляет превью креатива +func (s *AddCreative) updateCreativePreview(b *bot.Bot) { + if s.CreativeMessageID == nil { + log.Warn().Msg("updateCreativePreview: CreativeMessageID is nil") + return + } + + log.Info(). + Int("creative_msg_id", *s.CreativeMessageID). + Bool("has_media", s.MediaFileID != ""). + Str("media_type", s.MediaType). + Msg("Updating creative preview") + + // Удаляем оба сообщения (креатив и панель) + if s.CreativeMessageID != nil { + _, err := b.DeleteMessage(b.ChatID, *s.CreativeMessageID) + if err != nil { + log.Error().Err(err).Int("msg_id", *s.CreativeMessageID).Msg("Failed to delete old creative preview") + } + } + + if s.ControlPanelMessageID != nil { + _, err := b.DeleteMessage(b.ChatID, *s.ControlPanelMessageID) + if err != nil { + log.Error().Err(err).Int("msg_id", *s.ControlPanelMessageID).Msg("Failed to delete old control panel") + } + s.ControlPanelMessageID = nil + } + + // Отправляем оба сообщения заново в правильном порядке + s.sendCreativePreview(b) + s.showManagementPanel(b) + + log.Info().Int("new_creative_msg_id", *s.CreativeMessageID).Msg("Creative preview updated") +} + +// getCreativeText возвращает текст для превью креатива +func (s *AddCreative) getCreativeText() string { + if s.Text != nil { + return *s.Text + } + return ` Я твой креатив + +У меня пока нет текста, добавь его ниже. + +` +} + +// getCreativeButtons возвращает кнопки для превью креатива +func (s *AddCreative) getCreativeButtons() [][]echotron.InlineKeyboardButton { + var buttons [][]echotron.InlineKeyboardButton + + for _, btn := range s.Buttons { + buttons = append(buttons, []echotron.InlineKeyboardButton{ + { + Text: btn.Text, + URL: btn.URL, + }, + }) + } + + return buttons +} + +// showManagementPanel показывает панель управления креативом +func (s *AddCreative) showManagementPanel(b *bot.Bot) { + text := `☝️ Выше отображается превью вашего креатива — так он будет выглядеть при отправке + +Используйте кнопки ниже для редактирования:` + + var buttons [][]echotron.InlineKeyboardButton + + // Кнопки редактирования + row1 := []echotron.InlineKeyboardButton{} + if s.Text != nil { + row1 = append(row1, bot.Button("✏️ Текст", "edit_text")) + } else { + row1 = append(row1, bot.Button("➕ Текст", "edit_text")) + } + + if s.MediaFileID != "" { + row1 = append(row1, bot.Button("✏️ Медиа", "add_media")) + } else { + row1 = append(row1, bot.Button("➕ Медиа", "add_media")) + } + + row1 = append(row1, bot.Button("➕ Кнопка", "add_button")) + buttons = append(buttons, row1) + + // Кнопка удаления медиа (если медиа добавлено) + if s.MediaFileID != "" { + buttons = append(buttons, bot.Row( + bot.Button("🗑 Удалить медиа", "delete_media"), + )) + } + + // Кнопки удаления (если есть кнопки) + for i, btn := range s.Buttons { + buttons = append(buttons, bot.Row( + bot.Button(fmt.Sprintf("🗑 %s", btn.Text), fmt.Sprintf("delete_button:%d", i)), + )) + } + + // Финальные кнопки + if s.Text != nil { + buttons = append(buttons, bot.Row( + bot.Button("✅ Подтвердить", "confirm_create"), + bot.Button("« Отмена", "cancel"), + )) + } else { + buttons = append(buttons, bot.Row( + bot.Button("« Отмена", "cancel"), + )) + } + + s.showControlPanel(b, text, buttons) +} + +// showControlPanel редактирует панель управления с произвольным текстом и кнопками +func (s *AddCreative) showControlPanel(b *bot.Bot, text string, buttons [][]echotron.InlineKeyboardButton) { + if s.ControlPanelMessageID == nil { + // Отправляем новую панель + keyboard := bot.Keyboard(buttons...) + b.SendNew(text, keyboard) + // ВАЖНО: Сохраняем копию значения, а не указатель на переменную! + controlPanelMsgID := b.LastMessageID + s.ControlPanelMessageID = &controlPanelMsgID + log.Info().Int("control_panel_msg_id", *s.ControlPanelMessageID).Msg("Control panel sent") + } else { + // Редактируем существующую панель + log.Info().Int("control_panel_msg_id", *s.ControlPanelMessageID).Msg("Editing control panel") + oldLastMessageID := b.LastMessageID + b.LastMessageID = *s.ControlPanelMessageID + + keyboard := bot.Keyboard(buttons...) + b.Edit(text, keyboard) + + b.LastMessageID = oldLastMessageID + log.Info().Msg("Control panel updated") + } +} + +func (s *AddCreative) createCreative(b *bot.Bot) { + // Получаем JWT + jwt, err := s.ensureJWT(b) + if err != nil { + log.Error().Err(err).Msg("Failed to get JWT") + b.SendNew("❌ Ошибка авторизации\n\nПопробуйте /start login", bot.Keyboard()) + return + } + + // Создаём креатив через API + creative, err := b.Backend.CreateCreative( + context.Background(), + jwt, + s.WorkspaceID, + s.ProjectID, + backend.CreateCreativeInput{ + Name: *s.Name, + Text: *s.Text, + }, + ) + + if err != nil { + log.Error().Err(err).Msg("Failed to create creative") + + // Проверяем, это ошибка валидации инвайт-ссылки + errMsg := err.Error() + var userMsg string + + if strings.Contains(errMsg, "Creative text must contain one invite link") { + userMsg = `❌ Ошибка валидации + +Текст должен содержать ОДНУ инвайт-ссылку вашего канала! + +Формат ссылки: +https://t.me/+xxx + +Пример правильного текста: +Присоединяйтесь к нашему каналу! +https://t.me/+AbCdEfGhIjKlMn` + } else if strings.Contains(errMsg, "Creative text must contain only one invite link") { + userMsg = `❌ Слишком много ссылок + +Текст должен содержать ТОЛЬКО ОДНУ инвайт-ссылку. +У вас их несколько. Удалите лишние.` + } else { + userMsg = `❌ Ошибка создания + +Не удалось создать креатив. +Попробуйте ещё раз или вернитесь назад.` + } + + b.SendNew(userMsg, bot.Keyboard( + bot.Row(bot.Button("« Назад", "back_from_preview")), + )) + return + } + + // Успех! Показываем сообщение и возвращаемся + successText := `✅ Креатив успешно создан! + +📄 Название: ` + creative.Name + + if len(s.Buttons) > 0 { + successText += fmt.Sprintf(` +🔘 Кнопок добавлено: %d`, len(s.Buttons)) + } + + b.SendNew(successText, bot.Keyboard()) + + // Возвращаемся на экран креативов + if s.BackState != nil { + b.SetState(s.BackState, bot.NewMessage) + } +} + +func (s *AddCreative) ensureJWT(b *bot.Bot) (string, error) { + // Проверяем, есть ли JWT в сессии + if b.Session.JWT != "" { + return b.Session.JWT, nil + } + + // Если нет, запрашиваем новый + jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID) + if err != nil { + return "", err + } + + // Сохраняем в сессию + b.Session.JWT = jwt + return jwt, nil +} diff --git a/telegram_bot/screens/add_project.go b/telegram_bot/screens/add_project.go new file mode 100644 index 0000000..960e445 --- /dev/null +++ b/telegram_bot/screens/add_project.go @@ -0,0 +1,59 @@ +package screens + +import ( + "example.com/m/bot" + "github.com/NicoNex/echotron/v3" +) + +const addProjectInstructionText = ` +Как добавить канал в проект + +Инструкция: + +1. Добавьте бота в ваш канал как администратора + +2. Дайте боту следующие права: + • Удаление сообщений + • Приглашение пользователей + • Блокировка пользователей + +3. После добавления бота: + • Если у вас 1 рабочее пространство — канал добавится автоматически + • Если у вас несколько рабочих пространств — вы получите уведомление с кнопкой выбора + +4. Готово! Канал появится в списке проектов +` + +type AddProject struct { + BackState bot.State +} + +func (s *AddProject) Enter(b *bot.Bot, mode bot.RenderMode) { + buttons := [][]echotron.InlineKeyboardButton{ + bot.Row(bot.Button("« Назад", "back")), + } + + keyboard := bot.Keyboard(buttons...) + b.Render(addProjectInstructionText, keyboard, mode) +} + +func (s *AddProject) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + + switch u.CallbackQuery.Data { + case "back": + if s.BackState != nil { + b.SetState(s.BackState, bot.EditMessage) + } + + default: + s.Enter(b, bot.NewMessage) + } + return +} + +func (s *AddProject) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *AddProject) Handle(_ *bot.Bot, _ *echotron.Update) { return } diff --git a/telegram_bot/screens/creatives.go b/telegram_bot/screens/creatives.go new file mode 100644 index 0000000..afaabb9 --- /dev/null +++ b/telegram_bot/screens/creatives.go @@ -0,0 +1,192 @@ +package screens + +import ( + "context" + "fmt" + "strings" + + "example.com/m/bot" + "github.com/NicoNex/echotron/v3" + "github.com/rs/zerolog/log" +) + +const creativesPerPage = 6 + +type Creatives struct { + CurrentPage int + ProjectID string + WorkspaceID string + BackState bot.State +} + +func (s *Creatives) Enter(b *bot.Bot, mode bot.RenderMode) { + jwt, err := s.ensureJWT(b) + if err != nil { + log.Error().Err(err).Msg("Failed to get JWT") + b.SendNew("❌ Ошибка авторизации. Попробуйте /start login", bot.Keyboard()) + return + } + + s.renderCreatives(b, mode, jwt) +} + +func (s *Creatives) renderCreatives(b *bot.Bot, mode bot.RenderMode, jwt string) { + page, err := b.Backend.GetCreatives( + context.Background(), + jwt, + s.WorkspaceID, + &s.ProjectID, + false, // includeArchived + s.CurrentPage+1, + creativesPerPage, + ) + if err != nil { + log.Error().Err(err).Msg("Failed to get creatives") + b.SendNew("❌ Не удалось загрузить креативы", bot.Keyboard()) + return + } + + text := "Креативы • Управление рекламными материалами\n\n" + + var buttons [][]echotron.InlineKeyboardButton + + if len(page.Items) == 0 { + text += "У вас пока нет креативов" + } else { + text += fmt.Sprintf("Выберите креатив для управления (стр. %d из %d):", s.CurrentPage+1, page.Pages) + + // Креативы по 2 в ряду + for i := 0; i < len(page.Items); i += 2 { + var row []echotron.InlineKeyboardButton + + // Первый креатив в ряду + creative := page.Items[i] + row = append(row, bot.Button(creative.Name, fmt.Sprintf("creative:%s", creative.ID))) + + // Второй креатив в ряду (если есть) + if i+1 < len(page.Items) { + creative = page.Items[i+1] + row = append(row, bot.Button(creative.Name, fmt.Sprintf("creative:%s", creative.ID))) + } else { + // Добавляем пустую кнопку для выравнивания + row = append(row, bot.Button(" ", "empty")) + } + + buttons = append(buttons, row) + } + + // Кнопки пагинации с номером страницы посередине (только если есть креативы) + var navRow []echotron.InlineKeyboardButton + + // Левая стрелка (активная или неактивная) + if s.CurrentPage > 0 { + navRow = append(navRow, bot.Button("←", "prev")) + } else { + navRow = append(navRow, bot.Button("·", "empty")) + } + + // Кнопка с номером страницы (неактивная) + pageButton := bot.Button(fmt.Sprintf("• %d/%d •", s.CurrentPage+1, page.Pages), "page_info") + navRow = append(navRow, pageButton) + + // Правая стрелка (активная или неактивная) + if s.CurrentPage+1 < page.Pages { + navRow = append(navRow, bot.Button("→", "next")) + } else { + navRow = append(navRow, bot.Button("·", "empty")) + } + + buttons = append(buttons, navRow) + } + + // Кнопки управления + buttons = append(buttons, bot.Row( + bot.Button("Архив", "archive"), + bot.Button("Добавить креатив", "add_creative"), + )) + + // Кнопка возврата + buttons = append(buttons, bot.Row(bot.Button("« Назад", "back"))) + + keyboard := bot.Keyboard(buttons...) + b.Render(text, keyboard, mode) +} + +func (s *Creatives) ensureJWT(b *bot.Bot) (string, error) { + // Проверяем, есть ли JWT в сессии + if b.Session.JWT != "" { + return b.Session.JWT, nil + } + + // Если нет, запрашиваем новый + jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID) + if err != nil { + return "", err + } + + // Сохраняем в сессию + b.Session.JWT = jwt + return jwt, nil +} + +func (s *Creatives) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + + data := u.CallbackQuery.Data + + switch { + case data == "prev": + if s.CurrentPage > 0 { + s.CurrentPage-- + } + s.Enter(b, bot.EditMessage) + + case data == "next": + s.CurrentPage++ + s.Enter(b, bot.EditMessage) + + case data == "back": + if s.BackState != nil { + b.SetState(s.BackState, bot.EditMessage) + } + + case data == "page_info", data == "empty": + // Неактивные кнопки - ничего не делаем + return + + case data == "archive": + // TODO: реализовать экран архива креативов + b.Edit("📦 Архив креативов\n\nЭта функция в разработке", bot.Keyboard( + bot.Row(bot.Button("« Назад", "back")), + )) + + case data == "add_creative": + b.SetState(&AddCreative{ + ProjectID: s.ProjectID, + WorkspaceID: s.WorkspaceID, + BackState: s, + }, bot.EditMessage) + + case strings.HasPrefix(data, "creative:"): + // Открытие деталей креатива + parts := strings.Split(data, ":") + if len(parts) == 2 { + creativeID := parts[1] + + // TODO: реализовать экран деталей креатива + b.Edit(fmt.Sprintf("📄 Детали креатива\n\nID: %s\n\nЭта функция в разработке", creativeID), bot.Keyboard( + bot.Row(bot.Button("« Назад", "back")), + )) + } + + default: + s.Enter(b, bot.NewMessage) + } + return +} + +func (s *Creatives) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *Creatives) Handle(_ *bot.Bot, _ *echotron.Update) { return } diff --git a/telegram_bot/screens/login.go b/telegram_bot/screens/login.go new file mode 100644 index 0000000..0a86d12 --- /dev/null +++ b/telegram_bot/screens/login.go @@ -0,0 +1,47 @@ +package screens + +import ( + "context" + "fmt" + + "example.com/m/bot" + "github.com/NicoNex/echotron/v3" + "github.com/rs/zerolog/log" +) + +type Login struct{} + +func (s *Login) Enter(b *bot.Bot, mode bot.RenderMode) { + telegramID := b.ChatID + var username *string + + chatInfo, err := b.GetChat(telegramID) + if err == nil && chatInfo.Result != nil && chatInfo.Result.Username != "" { + username = &chatInfo.Result.Username + } + + token, err := b.Backend.CreateLoginToken(context.Background(), telegramID, username) + if err != nil { + log.Error().Err(err).Msg("Failed to create login token") + b.SendMessage("❌ Произошла ошибка при создании токена входа. Попробуйте позже.", b.ChatID, nil) + return + } + + loginURL := b.Backend.GetLoginURL(token) + + usernameDisplay := "аноним" + if username != nil && *username != "" { + usernameDisplay = *username + } + + message := fmt.Sprintf("Привет, %s!\n\nНажми кнопку ниже для входа на сайт.", usernameDisplay) + b.SendMessageWithURLButton(message, "Войти на сайт", loginURL) +} +func (s *Login) HandleCallback(b *bot.Bot, u *echotron.Update) { return } + +func (s *Login) HandleMessage(b *bot.Bot, u *echotron.Update) { return } + +func (s *Login) Handle(b *bot.Bot, u *echotron.Update) { + b.SetState(&MainMenu{}, bot.NewMessage) + return +} diff --git a/telegram_bot/screens/main_menu.go b/telegram_bot/screens/main_menu.go new file mode 100644 index 0000000..db6d33a --- /dev/null +++ b/telegram_bot/screens/main_menu.go @@ -0,0 +1,57 @@ +package screens + +import ( + "example.com/m/bot" + "github.com/NicoNex/echotron/v3" +) + +type MainMenu struct{} + +func (s *MainMenu) Enter(b *bot.Bot, mode bot.RenderMode) { + keyboard := bot.Keyboard( + bot.Row(bot.Button("Мои проекты", "my_projects"), bot.Button("Поддержка", "support")), + bot.Row(bot.Button("Закупы", "placements"), bot.Button("Дашборд", "dashboard")), + ) + + b.Render(tgexAbout, keyboard, mode) +} + +func (s *MainMenu) HandleCallback(b *bot.Bot, u *echotron.Update) { + switch u.CallbackQuery.Data { + case "my_projects": + b.SetState(&MyProjects{BackState: &MainMenu{}}, bot.EditMessage) + case "support": + // b.SetState(&Support{}, bot.NewMessage) + case "placements": + // b.SetState(&Placements{}, bot.NewMessage) + case "dashboard": + // b.SetState(&Dashboard{}, bot.NewMessage) + default: + s.Enter(b, bot.NewMessage) + } + + return +} + +func (s *MainMenu) HandleMessage(b *bot.Bot, u *echotron.Update) { + if u.Message.Text == "/start login" { + b.SetState(&Login{}, bot.NewMessage) + return + } + return +} + +func (s *MainMenu) Handle(b *bot.Bot, u *echotron.Update) { + b.SetState(&MainMenu{}, bot.NewMessage) + return +} + +const tgexAbout = ` +TGEX — Telegram-бот для покупки и продажи рекламы без ЛС и ручных таблиц. + +• Внутренний счёт и безопасные сделки +• Фиксация цен, дат и форматов +• Автоматические отчёты +• Повторные закупки в пару кликов + +Комиссия — 7%. Без скрытых платежей.` diff --git a/telegram_bot/screens/my_projects.go b/telegram_bot/screens/my_projects.go new file mode 100644 index 0000000..fb2de03 --- /dev/null +++ b/telegram_bot/screens/my_projects.go @@ -0,0 +1,222 @@ +package screens + +import ( + "context" + "fmt" + "strings" + + "example.com/m/bot" + "github.com/NicoNex/echotron/v3" + "github.com/rs/zerolog/log" +) + +const projectsPerPage = 6 + +type MyProjects struct { + CurrentPage int + WorkspaceID string + BackState bot.State +} + +func (s *MyProjects) Enter(b *bot.Bot, mode bot.RenderMode) { + // Получаем или запрашиваем JWT + jwt, err := s.ensureJWT(b) + if err != nil { + log.Error().Err(err).Msg("Failed to get JWT") + b.SendNew("❌ Ошибка авторизации. Попробуйте /start login", bot.Keyboard()) + return + } + + // Если workspace еще не выбран, получаем список workspace'ов + if s.WorkspaceID == "" { + workspaces, err := b.Backend.GetWorkspaces(context.Background(), jwt) + if err != nil { + log.Error().Err(err).Msg("Failed to get workspaces") + b.SendNew("❌ Не удалось загрузить workspace'ы", bot.Keyboard()) + return + } + + if len(workspaces) == 0 { + b.SendNew("У вас пока нет workspace'ов", bot.Keyboard()) + return + } + + // Используем первый workspace по умолчанию + s.WorkspaceID = workspaces[0].ID + } + + s.renderProjects(b, mode, jwt) +} + +func (s *MyProjects) renderProjects(b *bot.Bot, mode bot.RenderMode, jwt string) { + page, err := b.Backend.GetProjects(context.Background(), jwt, s.WorkspaceID, s.CurrentPage+1, projectsPerPage) + if err != nil { + log.Error().Err(err).Msg("Failed to get projects") + b.SendNew("❌ Не удалось загрузить проекты", bot.Keyboard()) + return + } + + text := "Страница с проектами • Управление каналами\n\n" + + var buttons [][]echotron.InlineKeyboardButton + + if len(page.Items) == 0 { + text += "У вас пока нет проектов" + } else { + text += fmt.Sprintf("Выберите проект для управления (стр. %d из %d):", s.CurrentPage+1, page.Pages) + + // Проекты по 2 в ряду + for i := 0; i < len(page.Items); i += 2 { + var row []echotron.InlineKeyboardButton + + // Первый проект в ряду + project := page.Items[i] + row = append(row, bot.Button(project.Title, fmt.Sprintf("project:%s", project.ID))) + + // Второй проект в ряду (если есть) + if i+1 < len(page.Items) { + project = page.Items[i+1] + row = append(row, bot.Button(project.Title, fmt.Sprintf("project:%s", project.ID))) + } else { + // Добавляем пустую кнопку для выравнивания + row = append(row, bot.Button(" ", "empty")) + } + + buttons = append(buttons, row) + } + + // Кнопки пагинации с номером страницы посередине (только если есть проекты) + var navRow []echotron.InlineKeyboardButton + + // Левая стрелка (активная или неактивная) + if s.CurrentPage > 0 { + navRow = append(navRow, bot.Button("←", "prev")) + } else { + navRow = append(navRow, bot.Button("·", "empty")) + } + + // Кнопка с номером страницы (неактивная) + pageButton := bot.Button(fmt.Sprintf("• %d/%d •", s.CurrentPage+1, page.Pages), "page_info") + navRow = append(navRow, pageButton) + + // Правая стрелка (активная или неактивная) + if s.CurrentPage+1 < page.Pages { + navRow = append(navRow, bot.Button("→", "next")) + } else { + navRow = append(navRow, bot.Button("·", "empty")) + } + + buttons = append(buttons, navRow) + } + + // Кнопки управления + buttons = append(buttons, bot.Row( + bot.Button("Архив", "archive"), + bot.Button("Добавить проект", "add_project"), + )) + + // Кнопка возврата в главное меню + buttons = append(buttons, bot.Row(bot.Button("🏠 Главное меню", "menu"))) + + keyboard := bot.Keyboard(buttons...) + b.Render(text, keyboard, mode) +} + +func (s *MyProjects) ensureJWT(b *bot.Bot) (string, error) { + // Проверяем, есть ли JWT в сессии + if b.Session.JWT != "" { + return b.Session.JWT, nil + } + + // Если нет, запрашиваем новый + jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID) + if err != nil { + return "", err + } + + // Сохраняем в сессию + b.Session.JWT = jwt + return jwt, nil +} + +func (s *MyProjects) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + + data := u.CallbackQuery.Data + + switch { + case data == "prev": + if s.CurrentPage > 0 { + s.CurrentPage-- + } + s.Enter(b, bot.EditMessage) + + case data == "next": + s.CurrentPage++ + s.Enter(b, bot.EditMessage) + + case data == "menu": + b.SetState(&MainMenu{}, bot.EditMessage) + + case data == "page_info", data == "empty": + // Неактивные кнопки - ничего не делаем + return + + case data == "archive": + // TODO: реализовать экран архива + b.Edit("📦 Архив проектов\n\nЭта функция в разработке", bot.Keyboard( + bot.Row(bot.Button("« Назад", "back_to_projects")), + )) + + case data == "add_project": + b.SetState(&AddProject{ + BackState: &MyProjects{WorkspaceID: s.WorkspaceID}, + }, bot.EditMessage) + + case data == "back_to_projects": + s.Enter(b, bot.EditMessage) + + case strings.HasPrefix(data, "project:"): + // Открытие деталей проекта + parts := strings.Split(data, ":") + if len(parts) == 2 { + projectID := parts[1] + + // Получаем проект напрямую по ID + project, err := b.Backend.GetProject( + context.Background(), + s.WorkspaceID, + projectID, + ) + if err != nil { + log.Error().Err(err).Str("project_id", projectID).Msg("Failed to get project") + return + } + + b.SetState(&ProjectDetails{ + Project: project, + WorkspaceID: s.WorkspaceID, + BackState: &MyProjects{WorkspaceID: s.WorkspaceID}, + }, bot.EditMessage) + } + + case strings.HasPrefix(data, "workspace:"): + // Позволяет переключаться между workspace'ами в будущем + parts := strings.Split(data, ":") + if len(parts) == 2 { + s.WorkspaceID = parts[1] + s.CurrentPage = 0 + s.Enter(b, bot.EditMessage) + } + + default: + s.Enter(b, bot.NewMessage) + } + return +} + +func (s *MyProjects) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *MyProjects) Handle(_ *bot.Bot, _ *echotron.Update) { return } diff --git a/telegram_bot/screens/project_details.go b/telegram_bot/screens/project_details.go new file mode 100644 index 0000000..c030a68 --- /dev/null +++ b/telegram_bot/screens/project_details.go @@ -0,0 +1,210 @@ +package screens + +import ( + "context" + "fmt" + "strings" + + "example.com/m/adapter/backend" + "example.com/m/bot" + "github.com/NicoNex/echotron/v3" + "github.com/rs/zerolog/log" +) + +type ProjectDetails struct { + Project *backend.Project + WorkspaceID string + BackState bot.State + confirmingLinkTypeChange bool // Флаг для двухшагового подтверждения смены типа ссылки +} + +func (s *ProjectDetails) Enter(b *bot.Bot, mode bot.RenderMode) { + if s.Project == nil { + b.SendNew("❌ Проект не найден", bot.Keyboard( + bot.Row(bot.Button("« Назад", "back_to_projects")), + )) + return + } + + text := s.formatProjectDetails() + keyboard := s.buildKeyboard() + b.Render(text, keyboard, mode) +} + +func (s *ProjectDetails) formatProjectDetails() string { + p := s.Project + + // Заголовок + text := fmt.Sprintf(`%s`, p.Title) + + // Информация о канале + if p.Username != nil && *p.Username != "" { + text += fmt.Sprintf(` +Канал: @%s`, *p.Username) + } else { + text += ` +Канал: не привязан` + } + + // Статус проекта + var statusText string + switch p.Status { + case "active": + statusText = "Активный" + case "inactive": + statusText = "Неактивен" + case "archived": + statusText = "Архивный" + case "paused": + statusText = "Приостановлен" + default: + statusText = p.Status + } + text += fmt.Sprintf(` +Статус: %s`, statusText) + + // ID проекта (для технических целей) + text += fmt.Sprintf(` +ID: %s`, p.ID) + + return text +} + +func (s *ProjectDetails) buildKeyboard() echotron.InlineKeyboardMarkup { + var buttons [][]echotron.InlineKeyboardButton + + // Первый ряд: Креативы и Создать закуп + buttons = append(buttons, bot.Row( + bot.Button("Креативы", fmt.Sprintf("creatives:%s", s.Project.ID)), + bot.Button("Создать закуп", fmt.Sprintf("create_purchase:%s", s.Project.ID)), + )) + + // Второй ряд: План закупов + buttons = append(buttons, bot.Row( + bot.Button("План закупов", fmt.Sprintf("purchase_plan:%s", s.Project.ID)), + )) + + // Третий ряд: Тип ссылки по умолчанию + var linkTypeButtonText string + if s.confirmingLinkTypeChange { + // Показываем текст подтверждения с противоположным типом + var newType string + if s.Project.InviteLinkType == "public" { + newType = "С одобрением" + } else { + newType = "Открытая" + } + linkTypeButtonText = fmt.Sprintf("Нажмите еще раз → %s", newType) + } else { + // Показываем текущий тип + var currentType string + if s.Project.InviteLinkType == "public" { + currentType = "Открытая" + } else { + currentType = "С одобрением" + } + linkTypeButtonText = fmt.Sprintf("Тип ссылки: %s", currentType) + } + buttons = append(buttons, bot.Row( + bot.Button(linkTypeButtonText, fmt.Sprintf("link_type:%s", s.Project.ID)), + )) + + // Четвертый ряд: Команда и Архивировать + buttons = append(buttons, bot.Row( + bot.Button("👥 Команда", fmt.Sprintf("team:%s", s.Project.ID)), + bot.Button("Архивировать", fmt.Sprintf("archive:%s", s.Project.ID)), + )) + + // Пятый ряд: Назад и Главное меню + buttons = append(buttons, bot.Row( + bot.Button("Назад", "back_to_projects"), + bot.Button("Главное меню", "main_menu"), + )) + + return bot.Keyboard(buttons...) +} + +func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + + data := u.CallbackQuery.Data + + switch { + case data == "back_to_projects", data == "back": + s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения + if s.BackState != nil { + b.SetState(s.BackState, bot.EditMessage) + } + + case data == "main_menu": + s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения + b.SetState(&MainMenu{}, bot.NewMessage) + + case strings.HasPrefix(data, "creatives:"): + s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения + b.SetState(&Creatives{ + ProjectID: s.Project.ID, + WorkspaceID: s.WorkspaceID, + BackState: s, + }, bot.NewMessage) + + case data == "back_to_project": + s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения + s.Enter(b, bot.EditMessage) + + case strings.HasPrefix(data, "link_type:"): + if !s.confirmingLinkTypeChange { + // Первое нажатие - включаем режим подтверждения + s.confirmingLinkTypeChange = true + s.Enter(b, bot.EditMessage) + } else { + // Второе нажатие - меняем тип ссылки + var newType string + if s.Project.InviteLinkType == "public" { + newType = "approval" + } else { + newType = "public" + } + + log.Info(). + Str("workspace_id", s.WorkspaceID). + Str("project_id", s.Project.ID). + Str("old_type", s.Project.InviteLinkType). + Str("new_type", newType). + Msg("Updating project invite link type") + + updatedProject, err := b.Backend.UpdateProjectInviteLinkType( + context.Background(), + b.Session.JWT, + s.WorkspaceID, + s.Project.ID, + newType, + ) + + if err != nil { + b.SendNew(fmt.Sprintf("❌ Ошибка при изменении типа ссылки: %v", err), bot.Keyboard( + bot.Row(bot.Button("« Назад", "back_to_project")), + )) + return + } + + // Обновляем проект и сбрасываем флаг подтверждения + s.Project = updatedProject + s.confirmingLinkTypeChange = false + s.Enter(b, bot.EditMessage) + } + + default: + // Для остальных кнопок показываем заглушку + b.Edit(fmt.Sprintf("🚧 Функция в разработке\n\nCallback: %s", data), bot.Keyboard( + bot.Row(bot.Button("« Назад", "back_to_project")), + )) + } + return +} + +func (s *ProjectDetails) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *ProjectDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return } diff --git a/telegram_bot/screens/select_workspace.go b/telegram_bot/screens/select_workspace.go new file mode 100644 index 0000000..4ccc1ee --- /dev/null +++ b/telegram_bot/screens/select_workspace.go @@ -0,0 +1,149 @@ +package screens + +import ( + "context" + "fmt" + "strings" + + "example.com/m/bot" + "github.com/NicoNex/echotron/v3" + "github.com/rs/zerolog/log" +) + +type SelectWorkspace struct { + ChannelID string + BackState bot.State +} + +func (s *SelectWorkspace) Enter(b *bot.Bot, mode bot.RenderMode) { + jwt, err := s.ensureJWT(b) + if err != nil { + log.Error().Err(err).Msg("Failed to get JWT") + b.SendNew("❌ Ошибка авторизации. Попробуйте /start", bot.Keyboard()) + return + } + + workspaces, err := b.Backend.GetWorkspaces(context.Background(), jwt) + if err != nil { + log.Error().Err(err).Msg("Failed to get workspaces") + b.SendNew("❌ Не удалось загрузить список workspace'ов", bot.Keyboard()) + return + } + + if len(workspaces) == 0 { + b.SendNew("У вас пока нет workspace'ов", bot.Keyboard( + bot.Row(bot.Button("🏠 Главное меню", "back")), + )) + return + } + + text := "📁 Выбор workspace для канала\n\n" + text += "В какой workspace добавить канал?" + + var buttons [][]echotron.InlineKeyboardButton + + // Создаем кнопки для каждого workspace + for _, ws := range workspaces { + buttons = append(buttons, bot.Row( + bot.Button(ws.Name, fmt.Sprintf("select_workspace:%s", ws.ID)), + )) + } + + // Кнопка отмены + buttons = append(buttons, bot.Row(bot.Button("❌ Отмена", "cancel"))) + + keyboard := bot.Keyboard(buttons...) + b.Render(text, keyboard, mode) +} + +func (s *SelectWorkspace) ensureJWT(b *bot.Bot) (string, error) { + // Проверяем, есть ли JWT в сессии + if b.Session.JWT != "" { + return b.Session.JWT, nil + } + + // Если нет, запрашиваем новый + jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID) + if err != nil { + return "", err + } + + // Сохраняем в сессию + b.Session.JWT = jwt + return jwt, nil +} + +func (s *SelectWorkspace) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + + data := u.CallbackQuery.Data + + switch { + case data == "cancel", data == "back": + if s.BackState != nil { + b.SetState(s.BackState, bot.NewMessage) + } + + case strings.HasPrefix(data, "select_workspace:"): + // Извлекаем workspace_id + parts := strings.Split(data, ":") + if len(parts) != 2 { + log.Error().Str("callback_data", data).Msg("Invalid callback data format") + return + } + + workspaceID := parts[1] + + // Показываем индикатор загрузки + b.Edit("⏳ Добавляем канал в workspace...", bot.Keyboard()) + + // Вызываем API для привязки канала к workspace + project, err := b.Backend.AttachChannelToWorkspace( + context.Background(), + s.ChannelID, + workspaceID, + b.ChatID, + ) + if err != nil { + log.Error().Err(err).Str("channel_id", s.ChannelID).Str("workspace_id", workspaceID).Msg("Failed to attach channel to workspace") + b.Edit("❌ Не удалось добавить канал в workspace", bot.Keyboard( + bot.Row(bot.Button("« Назад", "back_to_select")), + )) + return + } + + // Показываем успешное сообщение + successText := fmt.Sprintf("✅ Канал успешно добавлен!\n\n"+ + "📊 Проект ID: %s\n"+ + "📝 Название: %s\n"+ + "🏷 Статус: %s", + project.ID, + project.Title, + project.Status, + ) + + b.Edit(successText, bot.Keyboard( + bot.Row(bot.Button("📋 Мои проекты", "my_projects")), + bot.Row(bot.Button("🏠 Главное меню", "main_menu")), + )) + + case data == "back_to_select": + s.Enter(b, bot.EditMessage) + + case data == "my_projects": + b.SetState(&MyProjects{}, bot.NewMessage) + + case data == "main_menu": + b.SetState(&MainMenu{}, bot.NewMessage) + + default: + s.Enter(b, bot.NewMessage) + } + return +} + +func (s *SelectWorkspace) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *SelectWorkspace) Handle(_ *bot.Bot, _ *echotron.Update) { return }