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 }