new telegram bot
This commit is contained in:
@@ -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"
|
||||||
|
)
|
||||||
@@ -1,66 +1,15 @@
|
|||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import pydantic
|
import pydantic
|
||||||
from aiogram import Bot, Dispatcher, Router
|
from aiogram import Bot
|
||||||
|
|
||||||
|
|
||||||
class TelegramConfig(pydantic.BaseModel):
|
class TelegramConfig(pydantic.BaseModel):
|
||||||
TOKEN: str
|
TOKEN: str
|
||||||
DROP_PENDING_UPDATES: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class TelegramBase:
|
class TelegramBase:
|
||||||
def __init__(self, config: TelegramConfig, *routers: Router) -> None:
|
def __init__(self, config: TelegramConfig) -> None:
|
||||||
self.bot: Bot = Bot(token=config.TOKEN)
|
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')
|
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')
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from tortoise.transactions import in_transaction
|
|||||||
|
|
||||||
from shared.datebase_base import DatabaseBase
|
from shared.datebase_base import DatabaseBase
|
||||||
from src import domain
|
from src import domain
|
||||||
from usecase import Database
|
from src.usecase import Database
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from aiogram.types import BotCommand, InlineKeyboardButton, InlineKeyboardMarkup
|
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
|
|
||||||
from shared.telegram_base import TelegramBase
|
from shared.telegram_base import TelegramBase
|
||||||
from usecase import TelegramBotWriter
|
from src.usecase import TelegramBotWriter
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
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:
|
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)
|
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)
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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]
|
|
||||||
34
src/controller/http_v1/__init__.py
Normal file
34
src/controller/http_v1/__init__.py
Normal file
@@ -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)
|
||||||
@@ -18,3 +18,14 @@ async def get_channels(
|
|||||||
input = dto.GetChannelsInput(username=username)
|
input = dto.GetChannelsInput(username=username)
|
||||||
result = await deps.get_usecase().get_channels(input=input)
|
result = await deps.get_usecase().get_channels(input=input)
|
||||||
return paginate(result) # type: ignore[no-any-return]
|
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)
|
||||||
270
src/controller/http_v1/internal.py
Normal file
270
src/controller/http_v1/internal.py
Normal file
@@ -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)
|
||||||
49
src/controller/http_v1/projects.py
Normal file
49
src/controller/http_v1/projects.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
@@ -34,3 +34,18 @@ async def create_workspace_invite(
|
|||||||
user_id=current_user.user_id,
|
user_id=current_user.user_id,
|
||||||
input=request,
|
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,
|
||||||
|
)
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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()
|
|
||||||
@@ -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)
|
|
||||||
@@ -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()
|
|
||||||
@@ -86,10 +86,10 @@ from .error import (
|
|||||||
WorkspaceNotFound,
|
WorkspaceNotFound,
|
||||||
)
|
)
|
||||||
from .login_token import LoginToken
|
from .login_token import LoginToken
|
||||||
from .placement import InviteLinkType, Placement, PlacementStatus, PostViewsAvailability
|
from .placement import Placement, PlacementStatus, PostViewsAvailability
|
||||||
from .post import Post
|
from .post import Post
|
||||||
from .post_views_history import PostViewsHistory
|
from .post_views_history import PostViewsHistory
|
||||||
from .project import Project, ProjectStatus
|
from .project import InviteLinkType, Project, ProjectStatus
|
||||||
from .purchase_plan import (
|
from .purchase_plan import (
|
||||||
PurchasePlan,
|
PurchasePlan,
|
||||||
PurchasePlanChannel,
|
PurchasePlanChannel,
|
||||||
|
|||||||
@@ -114,5 +114,9 @@ def WorkspaceInviteAlreadyExists() -> HTTPException:
|
|||||||
return HTTPException(status.HTTP_409_CONFLICT, 'Workspace invite already exists for this user')
|
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:
|
def WorkspaceMemberAlreadyExists() -> HTTPException:
|
||||||
return HTTPException(status.HTTP_409_CONFLICT, 'User is already a workspace member')
|
return HTTPException(status.HTTP_409_CONFLICT, 'User is already a workspace member')
|
||||||
|
|||||||
@@ -19,11 +19,6 @@ class PlacementStatus(str, enum.Enum):
|
|||||||
ARCHIVED = 'archived' # Вручную архивировано
|
ARCHIVED = 'archived' # Вручную архивировано
|
||||||
|
|
||||||
|
|
||||||
class InviteLinkType(str, enum.Enum):
|
|
||||||
PUBLIC = 'public' # открытая ссылка
|
|
||||||
APPROVAL = 'approval' # с одобрением ботом
|
|
||||||
|
|
||||||
|
|
||||||
class PostViewsAvailability(str, enum.Enum):
|
class PostViewsAvailability(str, enum.Enum):
|
||||||
UNKNOWN = 'unknown' # Ещё не проверялось
|
UNKNOWN = 'unknown' # Ещё не проверялось
|
||||||
AVAILABLE = 'available' # Просмотры доступны
|
AVAILABLE = 'available' # Просмотры доступны
|
||||||
@@ -35,7 +30,6 @@ class Placement(TimestampedModel):
|
|||||||
placement_date = fields.DatetimeField()
|
placement_date = fields.DatetimeField()
|
||||||
cost = fields.FloatField(null=True)
|
cost = fields.FloatField(null=True)
|
||||||
comment = fields.TextField(null=True)
|
comment = fields.TextField(null=True)
|
||||||
invite_link_type = fields.CharEnumField(InviteLinkType)
|
|
||||||
invite_link = fields.CharField(max_length=512)
|
invite_link = fields.CharField(max_length=512)
|
||||||
|
|
||||||
status = fields.CharEnumField(PlacementStatus, default=PlacementStatus.ACTIVE)
|
status = fields.CharEnumField(PlacementStatus, default=PlacementStatus.ACTIVE)
|
||||||
|
|||||||
@@ -17,8 +17,14 @@ class ProjectStatus(str, enum.Enum):
|
|||||||
ARCHIVED = 'archived'
|
ARCHIVED = 'archived'
|
||||||
|
|
||||||
|
|
||||||
|
class InviteLinkType(str, enum.Enum):
|
||||||
|
PUBLIC = 'public' # открытая ссылка
|
||||||
|
APPROVAL = 'approval' # с одобрением ботом
|
||||||
|
|
||||||
|
|
||||||
class Project(TimestampedModel):
|
class Project(TimestampedModel):
|
||||||
status = fields.CharEnumField(ProjectStatus, default=ProjectStatus.ACTIVE)
|
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(
|
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
|
||||||
'models.Workspace', related_name='projects', on_delete=fields.CASCADE, index=True
|
'models.Workspace', related_name='projects', on_delete=fields.CASCADE, index=True
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
__all__ = (
|
__all__ = (
|
||||||
|
'UpdateProjectInviteLinkTypeInput',
|
||||||
'UpdateProjectPermissionsInput',
|
'UpdateProjectPermissionsInput',
|
||||||
'GetWorkspaceProjectsInput',
|
'GetWorkspaceProjectsInput',
|
||||||
'GetWorkspaceProjectsOutput',
|
'GetWorkspaceProjectsOutput',
|
||||||
|
'GetProjectInput',
|
||||||
'DisconnectProjectByTgIdInput',
|
'DisconnectProjectByTgIdInput',
|
||||||
'ConnectProjectInput',
|
'ConnectProjectInput',
|
||||||
'ProjectOutput',
|
'ProjectOutput',
|
||||||
@@ -10,8 +12,10 @@ __all__ = (
|
|||||||
'TelegramLoginInput',
|
'TelegramLoginInput',
|
||||||
'ChannelBotPermissions',
|
'ChannelBotPermissions',
|
||||||
'ChannelOutput',
|
'ChannelOutput',
|
||||||
|
'GetChannelInput',
|
||||||
'GetChannelsInput',
|
'GetChannelsInput',
|
||||||
'GetChannelsOutput',
|
'GetChannelsOutput',
|
||||||
|
'AttachChannelToWorkspaceInput',
|
||||||
'PurchasePlanChannelOutput',
|
'PurchasePlanChannelOutput',
|
||||||
'GetPurchasePlanChannelsInput',
|
'GetPurchasePlanChannelsInput',
|
||||||
'GetPurchasePlanChannelsOutput',
|
'GetPurchasePlanChannelsOutput',
|
||||||
@@ -67,6 +71,8 @@ __all__ = (
|
|||||||
'GetWorkspaceInvitesOutput',
|
'GetWorkspaceInvitesOutput',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from .analytics import (
|
from .analytics import (
|
||||||
ChannelAnalyticsOutput,
|
ChannelAnalyticsOutput,
|
||||||
CreativeAnalyticsOutput,
|
CreativeAnalyticsOutput,
|
||||||
@@ -82,7 +88,13 @@ from .analytics import (
|
|||||||
PlacementAnalyticsOutput,
|
PlacementAnalyticsOutput,
|
||||||
SpendingDataPoint,
|
SpendingDataPoint,
|
||||||
)
|
)
|
||||||
from .channel import ChannelOutput, GetChannelsInput, GetChannelsOutput
|
from .channel import (
|
||||||
|
AttachChannelToWorkspaceInput,
|
||||||
|
ChannelOutput,
|
||||||
|
GetChannelInput,
|
||||||
|
GetChannelsInput,
|
||||||
|
GetChannelsOutput,
|
||||||
|
)
|
||||||
from .creative import (
|
from .creative import (
|
||||||
CreateCreativeInput,
|
CreateCreativeInput,
|
||||||
CreativeOutput,
|
CreativeOutput,
|
||||||
@@ -105,9 +117,11 @@ from .project import (
|
|||||||
ChannelBotPermissions,
|
ChannelBotPermissions,
|
||||||
ConnectProjectInput,
|
ConnectProjectInput,
|
||||||
DisconnectProjectByTgIdInput,
|
DisconnectProjectByTgIdInput,
|
||||||
|
GetProjectInput,
|
||||||
GetWorkspaceProjectsInput,
|
GetWorkspaceProjectsInput,
|
||||||
GetWorkspaceProjectsOutput,
|
GetWorkspaceProjectsOutput,
|
||||||
ProjectOutput,
|
ProjectOutput,
|
||||||
|
UpdateProjectInviteLinkTypeInput,
|
||||||
UpdateProjectPermissionsInput,
|
UpdateProjectPermissionsInput,
|
||||||
)
|
)
|
||||||
from .purchase_plan import (
|
from .purchase_plan import (
|
||||||
@@ -144,3 +158,8 @@ from .workspace import (
|
|||||||
WorkspacePermissionScopeInput,
|
WorkspacePermissionScopeInput,
|
||||||
WorkspacePermissionScopeOutput,
|
WorkspacePermissionScopeOutput,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CreateLoginTokenRequest(BaseModel):
|
||||||
|
telegram_id: int
|
||||||
|
username: str | None
|
||||||
@@ -16,3 +16,13 @@ class GetChannelsInput(pydantic.BaseModel):
|
|||||||
|
|
||||||
class GetChannelsOutput(pydantic.BaseModel):
|
class GetChannelsOutput(pydantic.BaseModel):
|
||||||
channels: list[ChannelOutput]
|
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
|
||||||
|
|||||||
6
src/dto/internal.py
Normal file
6
src/dto/internal.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class CreateLoginTokenRequest(BaseModel):
|
||||||
|
telegram_id: int
|
||||||
|
username: str | None = None
|
||||||
@@ -51,7 +51,6 @@ class CreatePlacementInput(pydantic.BaseModel):
|
|||||||
placement_date: datetime.datetime
|
placement_date: datetime.datetime
|
||||||
cost: float | None = None
|
cost: float | None = None
|
||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
invite_link_type: domain.InviteLinkType = domain.InviteLinkType.PUBLIC
|
|
||||||
|
|
||||||
|
|
||||||
class UpdatePlacementInput(pydantic.BaseModel):
|
class UpdatePlacementInput(pydantic.BaseModel):
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import uuid
|
|||||||
|
|
||||||
import pydantic
|
import pydantic
|
||||||
|
|
||||||
|
from src import domain
|
||||||
from src.domain.project import ProjectStatus
|
from src.domain.project import ProjectStatus
|
||||||
|
|
||||||
|
|
||||||
@@ -31,6 +32,11 @@ class ProjectOutput(pydantic.BaseModel):
|
|||||||
title: str
|
title: str
|
||||||
username: str | None
|
username: str | None
|
||||||
status: ProjectStatus
|
status: ProjectStatus
|
||||||
|
invite_link_type: domain.InviteLinkType
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateProjectInviteLinkTypeInput(pydantic.BaseModel):
|
||||||
|
invite_link_type: domain.InviteLinkType
|
||||||
|
|
||||||
|
|
||||||
class GetWorkspaceProjectsInput(pydantic.BaseModel):
|
class GetWorkspaceProjectsInput(pydantic.BaseModel):
|
||||||
@@ -52,3 +58,8 @@ class UpdateProjectPermissionsInput(pydantic.BaseModel):
|
|||||||
permissions: ChannelBotPermissions
|
permissions: ChannelBotPermissions
|
||||||
chat_title: str
|
chat_title: str
|
||||||
user_telegram_id: int
|
user_telegram_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class GetProjectInput(pydantic.BaseModel):
|
||||||
|
workspace_id: uuid.UUID
|
||||||
|
project_id: uuid.UUID
|
||||||
|
|||||||
12
src/main.py
12
src/main.py
@@ -4,6 +4,7 @@ from contextlib import asynccontextmanager
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi_pagination import add_pagination
|
from fastapi_pagination import add_pagination
|
||||||
|
from fastapi_pagination.utils import disable_installed_extensions_check
|
||||||
|
|
||||||
from shared import logger
|
from shared import logger
|
||||||
from shared.worker_base import WorkerConfig
|
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.postgres import Postgres
|
||||||
from src.adapter.telegram_bot import TelegramBot
|
from src.adapter.telegram_bot import TelegramBot
|
||||||
from src.config import settings
|
from src.config import settings
|
||||||
from src.controller.http import api_router
|
from src.controller.http_v1 import api_router
|
||||||
from src.controller.telegram_callback import telegram_callback_router
|
|
||||||
from src.controller.worker.fetch_placement_post import FetchPlacementPostWorker
|
from src.controller.worker.fetch_placement_post import FetchPlacementPostWorker
|
||||||
from src.usecase import Usecase
|
from src.usecase import Usecase
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
|
async def lifespan(_: FastAPI) -> AsyncGenerator[None]:
|
||||||
await postgres.connect()
|
await postgres.connect()
|
||||||
await fetch_placement_post_worker.start()
|
await fetch_placement_post_worker.start()
|
||||||
await telegram.start_polling()
|
|
||||||
await telegram.set_commands()
|
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
await telegram.stop_polling()
|
|
||||||
await fetch_placement_post_worker.stop()
|
await fetch_placement_post_worker.stop()
|
||||||
await postgres.close()
|
await postgres.close()
|
||||||
|
|
||||||
|
|
||||||
logger.init(settings.logger)
|
logger.init(settings.logger)
|
||||||
|
disable_installed_extensions_check()
|
||||||
|
|
||||||
postgres = Postgres(settings.db)
|
postgres = Postgres(settings.db)
|
||||||
telegram = TelegramBot(settings.telegram, telegram_callback_router)
|
telegram = TelegramBot(settings.telegram)
|
||||||
jwt_encoder = JWT(settings.jwt)
|
jwt_encoder = JWT(settings.jwt)
|
||||||
parser_client = ParserClient(base_url=settings.parser.URL)
|
parser_client = ParserClient(base_url=settings.parser.URL)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import typing
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
from aiogram.types import InlineKeyboardButton
|
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_creatives_analytics import get_creatives_analytics
|
||||||
from .analytics.get_placements_analytics import get_placements_analytics
|
from .analytics.get_placements_analytics import get_placements_analytics
|
||||||
from .analytics.get_spending_analytics import get_spending_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.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 .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 .channel.get_channels import get_channels
|
||||||
from .creative.create_creative import create_creative
|
from .creative.create_creative import create_creative
|
||||||
from .creative.delete_creative import delete_creative
|
from .creative.delete_creative import delete_creative
|
||||||
from .creative.get_creative import get_creative
|
from .creative.get_creative import get_creative
|
||||||
from .creative.get_creatives import get_creatives
|
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 .creative.update_creative import update_creative
|
||||||
from .placement.create_placement import create_placement
|
from .placement.create_placement import create_placement
|
||||||
from .placement.delete_placement import delete_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.get_placements import get_placements
|
||||||
from .placement.update_placement import update_placement
|
from .placement.update_placement import update_placement
|
||||||
from .project.disconnect_project_by_tg_id import disconnect_project_by_tg_id
|
from .project.disconnect_project_by_tg_id import disconnect_project_by_tg_id
|
||||||
|
from .project.get_project import get_project
|
||||||
from .project.get_workspace_projects import get_workspace_projects
|
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 import tg_add_project
|
||||||
from .project.tg_add_project_2 import tg_add_project_2
|
from .project.update_project_invite_link_type import update_project_invite_link_type
|
||||||
from .project.update_project_permissions import update_project_permissions
|
from .project.update_project_permissions import update_project_permissions
|
||||||
from .purchase_plan.attach_channel_to_purchase_plan import attach_channel_to_purchase_plan
|
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
|
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_subscription import handle_subscription
|
||||||
from .subscription.handle_unsubscription import handle_unsubscription
|
from .subscription.handle_unsubscription import handle_unsubscription
|
||||||
from .views.get_views_history import get_views_history
|
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 import create_workspace
|
||||||
from .workspace.create_workspace_invite import create_workspace_invite
|
from .workspace.create_workspace_invite import create_workspace_invite
|
||||||
from .workspace.delete_workspace import delete_workspace
|
from .workspace.delete_workspace import delete_workspace
|
||||||
@@ -371,15 +372,18 @@ class Usecase:
|
|||||||
return plan
|
return plan
|
||||||
|
|
||||||
validate_login_token = validate_login_token
|
validate_login_token = validate_login_token
|
||||||
telegram_login = telegram_login
|
create_telegram_login_token = create_telegram_login_token
|
||||||
telegram_start = telegram_start
|
get_jwt_by_telegram_id = get_jwt_by_telegram_id
|
||||||
get_me = get_me
|
get_me = get_me
|
||||||
tg_add_project_1 = tg_add_project_1
|
tg_add_project = tg_add_project
|
||||||
tg_add_project_2 = tg_add_project_2
|
|
||||||
get_workspace_projects = get_workspace_projects
|
get_workspace_projects = get_workspace_projects
|
||||||
|
get_project = get_project
|
||||||
disconnect_project_by_tg_id = disconnect_project_by_tg_id
|
disconnect_project_by_tg_id = disconnect_project_by_tg_id
|
||||||
update_project_permissions = update_project_permissions
|
update_project_permissions = update_project_permissions
|
||||||
|
update_project_invite_link_type = update_project_invite_link_type
|
||||||
get_channels = get_channels
|
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
|
attach_channel_to_purchase_plan = attach_channel_to_purchase_plan
|
||||||
get_purchase_plan_channels = get_purchase_plan_channels
|
get_purchase_plan_channels = get_purchase_plan_channels
|
||||||
update_purchase_plan_channel = update_purchase_plan_channel
|
update_purchase_plan_channel = update_purchase_plan_channel
|
||||||
@@ -389,10 +393,6 @@ class Usecase:
|
|||||||
create_creative = create_creative
|
create_creative = create_creative
|
||||||
update_creative = update_creative
|
update_creative = update_creative
|
||||||
delete_creative = delete_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_placements = get_placements
|
||||||
get_placement = get_placement
|
get_placement = get_placement
|
||||||
create_placement = create_placement
|
create_placement = create_placement
|
||||||
@@ -414,4 +414,5 @@ class Usecase:
|
|||||||
update_workspace_member_permissions = update_workspace_member_permissions
|
update_workspace_member_permissions = update_workspace_member_permissions
|
||||||
create_workspace_invite = create_workspace_invite
|
create_workspace_invite = create_workspace_invite
|
||||||
get_workspace_invites = get_workspace_invites
|
get_workspace_invites = get_workspace_invites
|
||||||
|
accept_workspace_invite = accept_workspace_invite
|
||||||
tg_accept_workspace_invite = tg_accept_workspace_invite
|
tg_accept_workspace_invite = tg_accept_workspace_invite
|
||||||
|
|||||||
32
src/usecase/auth/create_telegram_login_token.py
Normal file
32
src/usecase/auth/create_telegram_login_token.py
Normal file
@@ -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
|
||||||
23
src/usecase/auth/get_jwt_by_telegram_id.py
Normal file
23
src/usecase/auth/get_jwt_by_telegram_id.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
@@ -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)
|
|
||||||
52
src/usecase/channel/attach_channel_to_workspace.py
Normal file
52
src/usecase/channel/attach_channel_to_workspace.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
22
src/usecase/channel/get_channel.py
Normal file
22
src/usecase/channel/get_channel.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
@@ -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={},
|
|
||||||
)
|
|
||||||
@@ -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)},
|
|
||||||
)
|
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
@@ -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)
|
|
||||||
@@ -34,7 +34,8 @@ async def create_placement(
|
|||||||
if creative.project_id != project.id:
|
if creative.project_id != project.id:
|
||||||
raise domain.CreativeNotFound(input.creative_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:
|
if project.channel.telegram_id is None:
|
||||||
raise domain.ChannelNotFound(project.channel.id)
|
raise domain.ChannelNotFound(project.channel.id)
|
||||||
@@ -49,7 +50,6 @@ async def create_placement(
|
|||||||
placement_date=input.placement_date,
|
placement_date=input.placement_date,
|
||||||
cost=input.cost,
|
cost=input.cost,
|
||||||
comment=input.comment,
|
comment=input.comment,
|
||||||
invite_link_type=input.invite_link_type,
|
|
||||||
invite_link=invite_link,
|
invite_link=invite_link,
|
||||||
status=domain.PlacementStatus.ACTIVE,
|
status=domain.PlacementStatus.ACTIVE,
|
||||||
)
|
)
|
||||||
@@ -68,7 +68,7 @@ async def create_placement(
|
|||||||
cost=placement.cost,
|
cost=placement.cost,
|
||||||
comment=placement.comment,
|
comment=placement.comment,
|
||||||
ad_post_url=None,
|
ad_post_url=None,
|
||||||
invite_link_type=placement.invite_link_type,
|
invite_link_type=project.invite_link_type,
|
||||||
invite_link=placement.invite_link,
|
invite_link=placement.invite_link,
|
||||||
status=placement.status,
|
status=placement.status,
|
||||||
subscriptions_count=0,
|
subscriptions_count=0,
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ async def get_placement(self: 'Usecase', input: dto.GetPlacementInput) -> dto.Pl
|
|||||||
cost=placement.cost,
|
cost=placement.cost,
|
||||||
comment=placement.comment,
|
comment=placement.comment,
|
||||||
ad_post_url=ad_post_url,
|
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,
|
invite_link=placement.invite_link,
|
||||||
status=placement.status,
|
status=placement.status,
|
||||||
subscriptions_count=subscriptions_count,
|
subscriptions_count=subscriptions_count,
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> list
|
|||||||
cost=placement.cost,
|
cost=placement.cost,
|
||||||
comment=placement.comment,
|
comment=placement.comment,
|
||||||
ad_post_url=ad_post_url,
|
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,
|
invite_link=placement.invite_link,
|
||||||
status=placement.status,
|
status=placement.status,
|
||||||
subscriptions_count=subscriptions_counts.get(placement.id, 0),
|
subscriptions_count=subscriptions_counts.get(placement.id, 0),
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ async def update_placement(
|
|||||||
cost=placement.cost,
|
cost=placement.cost,
|
||||||
comment=placement.comment,
|
comment=placement.comment,
|
||||||
ad_post_url=ad_post_url,
|
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,
|
invite_link=placement.invite_link,
|
||||||
status=placement.status,
|
status=placement.status,
|
||||||
subscriptions_count=subscriptions_count,
|
subscriptions_count=subscriptions_count,
|
||||||
|
|||||||
24
src/usecase/project/get_project.py
Normal file
24
src/usecase/project/get_project.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
@@ -24,6 +24,7 @@ async def get_workspace_projects(
|
|||||||
title=project.channel.title,
|
title=project.channel.title,
|
||||||
username=project.channel.username,
|
username=project.channel.username,
|
||||||
status=project.status,
|
status=project.status,
|
||||||
|
invite_link_type=project.invite_link_type,
|
||||||
)
|
)
|
||||||
for project in projects
|
for project in projects
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ if TYPE_CHECKING:
|
|||||||
log = logging.getLogger(__name__)
|
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
|
permissions = input.bot_permissions
|
||||||
|
|
||||||
if not permissions.is_admin:
|
if not permissions.is_admin:
|
||||||
@@ -125,41 +125,40 @@ async def tg_add_project_1(self: 'Usecase', input: dto.ConnectProjectInput) -> d
|
|||||||
status=project.status,
|
status=project.status,
|
||||||
)
|
)
|
||||||
|
|
||||||
current_state = await self.database.get_telegram_state(user.telegram_id)
|
# Если >1 workspace - создаем/обновляем канал и отправляем уведомление
|
||||||
if current_state and current_state.state == domain.TelegramStateEnum.PROJECT_WAITING_WORKSPACE:
|
channel = await self.database.get_channel(telegram_id=input.telegram_id)
|
||||||
await self.telegram_bot.send_message(
|
if channel:
|
||||||
'⚠️ Сначала завершите выбор рабочего пространства для предыдущего канала.', user.telegram_id
|
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] = {
|
# Callback будет обработан Golang ботом который покажет экран выбора workspace
|
||||||
'channel_data': {
|
buttons = [[InlineKeyboardButton(text='Выбрать workspace', callback_data=f'pending_channel:{channel.id}')]]
|
||||||
'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
|
|
||||||
]
|
|
||||||
|
|
||||||
await self.telegram_bot.send_message_with_inline_keyboard(
|
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,
|
user.telegram_id,
|
||||||
buttons,
|
buttons,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
log.info('Pending channel notification sent to user %s for channel %s', user.telegram_id, channel.id)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
@@ -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)
|
|
||||||
42
src/usecase/project/update_project_invite_link_type.py
Normal file
42
src/usecase/project/update_project_invite_link_type.py
Normal file
@@ -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
|
||||||
36
src/usecase/workspace/accept_workspace_invite.py
Normal file
36
src/usecase/workspace/accept_workspace_invite.py
Normal file
@@ -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)
|
||||||
761
telegram_bot/adapter/backend/backend_client.go
Normal file
761
telegram_bot/adapter/backend/backend_client.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
274
telegram_bot/bot/bot.go
Normal file
274
telegram_bot/bot/bot.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
22
telegram_bot/bot/state.go
Normal file
22
telegram_bot/bot/state.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
13
telegram_bot/go.mod
Normal file
13
telegram_bot/go.mod
Normal file
@@ -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
|
||||||
|
)
|
||||||
22
telegram_bot/go.sum
Normal file
22
telegram_bot/go.sum
Normal file
@@ -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=
|
||||||
42
telegram_bot/handlers/global_callbacks.go
Normal file
42
telegram_bot/handlers/global_callbacks.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
47
telegram_bot/main.go
Normal file
47
telegram_bot/main.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
38
telegram_bot/pkg/logger/logger.go
Normal file
38
telegram_bot/pkg/logger/logger.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
54
telegram_bot/screens/accept_workspace_invite.go
Normal file
54
telegram_bot/screens/accept_workspace_invite.go
Normal file
@@ -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 }
|
||||||
686
telegram_bot/screens/add_creative.go
Normal file
686
telegram_bot/screens/add_creative.go
Normal file
@@ -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 := `<b>➕ Добавить креатив</b>
|
||||||
|
|
||||||
|
Введите <b>название</b> креатива:
|
||||||
|
|
||||||
|
<i>Например:</i>
|
||||||
|
• Летняя акция
|
||||||
|
• Баннер 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, `<b>✏️ Введите текст креатива:</b>
|
||||||
|
|
||||||
|
⚠️ <b>Важно:</b> Текст должен содержать <b>ОДНУ</b> инвайт-ссылку вашего канала
|
||||||
|
|
||||||
|
<b>Формат ссылки:</b>
|
||||||
|
<code>https://t.me/+xxx</code>
|
||||||
|
|
||||||
|
<b>Пример:</b>
|
||||||
|
<i>Присоединяйтесь к нашему каналу!
|
||||||
|
https://t.me/+AbCdEfGhIjKlMn</i>`, [][]echotron.InlineKeyboardButton{
|
||||||
|
bot.Row(bot.Button("« Отмена", "cancel_edit")),
|
||||||
|
})
|
||||||
|
|
||||||
|
case data == "add_button":
|
||||||
|
// Начинаем процесс добавления кнопки
|
||||||
|
s.WaitingForButtonText = true
|
||||||
|
|
||||||
|
s.showControlPanel(b, `<b>➕ Добавить кнопку</b>
|
||||||
|
|
||||||
|
Введите <b>текст кнопки:</b>
|
||||||
|
|
||||||
|
<i>Например:</i>
|
||||||
|
• Перейти на сайт
|
||||||
|
• Написать нам`, [][]echotron.InlineKeyboardButton{
|
||||||
|
bot.Row(bot.Button("« Отмена", "cancel_add_button")),
|
||||||
|
})
|
||||||
|
|
||||||
|
case data == "add_media":
|
||||||
|
// Начинаем процесс добавления медиа
|
||||||
|
s.WaitingForMedia = true
|
||||||
|
|
||||||
|
s.showControlPanel(b, `<b>📷 Добавить медиа</b>
|
||||||
|
|
||||||
|
Отправьте <b>фото, видео или GIF</b>
|
||||||
|
|
||||||
|
<i>Это медиа будет отображаться в креативе вместе с текстом</i>`, [][]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, `<b>💡 Введите URL для кнопки:</b>
|
||||||
|
|
||||||
|
URL должен начинаться с <code>https://</code>
|
||||||
|
|
||||||
|
<i>Например:</i>
|
||||||
|
<code>https://example.com</code>
|
||||||
|
<code>https://t.me/yourchannel</code>`, [][]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, `<b>❌ Неверный формат URL</b>
|
||||||
|
|
||||||
|
URL должен начинаться с <code>https://</code>
|
||||||
|
|
||||||
|
Попробуйте ещё раз.`, [][]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 += "<b>" + entityText + "</b>"
|
||||||
|
case "italic":
|
||||||
|
result += "<i>" + entityText + "</i>"
|
||||||
|
case "underline":
|
||||||
|
result += "<u>" + entityText + "</u>"
|
||||||
|
case "strikethrough":
|
||||||
|
result += "<s>" + entityText + "</s>"
|
||||||
|
case "code":
|
||||||
|
result += "<code>" + entityText + "</code>"
|
||||||
|
case "pre":
|
||||||
|
result += "<pre>" + entityText + "</pre>"
|
||||||
|
case "text_link":
|
||||||
|
if entity.URL != "" {
|
||||||
|
result += fmt.Sprintf("<a href=\"%s\">%s</a>", 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 `<i> Я твой креатив
|
||||||
|
|
||||||
|
У меня пока нет текста, добавь его ниже.
|
||||||
|
|
||||||
|
</i>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 := `<b>☝️ Выше отображается <b>превью вашего креатива</b> — так он будет выглядеть при отправке</b>
|
||||||
|
|
||||||
|
Используйте кнопки ниже для редактирования:`
|
||||||
|
|
||||||
|
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("<b>❌ Ошибка авторизации</b>\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 = `<b>❌ Ошибка валидации</b>
|
||||||
|
|
||||||
|
Текст должен содержать <b>ОДНУ</b> инвайт-ссылку вашего канала!
|
||||||
|
|
||||||
|
<b>Формат ссылки:</b>
|
||||||
|
<code>https://t.me/+xxx</code>
|
||||||
|
|
||||||
|
<b>Пример правильного текста:</b>
|
||||||
|
<i>Присоединяйтесь к нашему каналу!
|
||||||
|
https://t.me/+AbCdEfGhIjKlMn</i>`
|
||||||
|
} else if strings.Contains(errMsg, "Creative text must contain only one invite link") {
|
||||||
|
userMsg = `<b>❌ Слишком много ссылок</b>
|
||||||
|
|
||||||
|
Текст должен содержать <b>ТОЛЬКО ОДНУ</b> инвайт-ссылку.
|
||||||
|
У вас их несколько. Удалите лишние.`
|
||||||
|
} else {
|
||||||
|
userMsg = `<b>❌ Ошибка создания</b>
|
||||||
|
|
||||||
|
Не удалось создать креатив.
|
||||||
|
Попробуйте ещё раз или вернитесь назад.`
|
||||||
|
}
|
||||||
|
|
||||||
|
b.SendNew(userMsg, bot.Keyboard(
|
||||||
|
bot.Row(bot.Button("« Назад", "back_from_preview")),
|
||||||
|
))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Успех! Показываем сообщение и возвращаемся
|
||||||
|
successText := `<b>✅ Креатив успешно создан!</b>
|
||||||
|
|
||||||
|
📄 <b>Название:</b> ` + creative.Name
|
||||||
|
|
||||||
|
if len(s.Buttons) > 0 {
|
||||||
|
successText += fmt.Sprintf(`
|
||||||
|
🔘 <b>Кнопок добавлено:</b> %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
|
||||||
|
}
|
||||||
59
telegram_bot/screens/add_project.go
Normal file
59
telegram_bot/screens/add_project.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package screens
|
||||||
|
|
||||||
|
import (
|
||||||
|
"example.com/m/bot"
|
||||||
|
"github.com/NicoNex/echotron/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const addProjectInstructionText = `
|
||||||
|
<b>Как добавить канал в проект</b>
|
||||||
|
|
||||||
|
<b>Инструкция:</b>
|
||||||
|
|
||||||
|
<b>1.</b> Добавьте бота в ваш канал как администратора
|
||||||
|
|
||||||
|
<b>2.</b> Дайте боту следующие права:
|
||||||
|
• Удаление сообщений
|
||||||
|
• Приглашение пользователей
|
||||||
|
• Блокировка пользователей
|
||||||
|
|
||||||
|
<b>3.</b> После добавления бота:
|
||||||
|
• Если у вас <b>1 рабочее пространство</b> — канал добавится автоматически
|
||||||
|
• Если у вас <b>несколько рабочих пространств</b> — вы получите уведомление с кнопкой выбора
|
||||||
|
|
||||||
|
<b>4.</b> Готово! Канал появится в списке проектов
|
||||||
|
`
|
||||||
|
|
||||||
|
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 }
|
||||||
192
telegram_bot/screens/creatives.go
Normal file
192
telegram_bot/screens/creatives.go
Normal file
@@ -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 }
|
||||||
47
telegram_bot/screens/login.go
Normal file
47
telegram_bot/screens/login.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
57
telegram_bot/screens/main_menu.go
Normal file
57
telegram_bot/screens/main_menu.go
Normal file
@@ -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%. Без скрытых платежей.`
|
||||||
222
telegram_bot/screens/my_projects.go
Normal file
222
telegram_bot/screens/my_projects.go
Normal file
@@ -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 }
|
||||||
210
telegram_bot/screens/project_details.go
Normal file
210
telegram_bot/screens/project_details.go
Normal file
@@ -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(`<b>%s</b>`, p.Title)
|
||||||
|
|
||||||
|
// Информация о канале
|
||||||
|
if p.Username != nil && *p.Username != "" {
|
||||||
|
text += fmt.Sprintf(`
|
||||||
|
<b>Канал:</b> @%s`, *p.Username)
|
||||||
|
} else {
|
||||||
|
text += `
|
||||||
|
<b>Канал:</b> <i>не привязан</i>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Статус проекта
|
||||||
|
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(`
|
||||||
|
<b>Статус:</b> %s`, statusText)
|
||||||
|
|
||||||
|
// ID проекта (для технических целей)
|
||||||
|
text += fmt.Sprintf(`
|
||||||
|
<b>ID:</b> <code>%s</code>`, 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 }
|
||||||
149
telegram_bot/screens/select_workspace.go
Normal file
149
telegram_bot/screens/select_workspace.go
Normal file
@@ -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 }
|
||||||
Reference in New Issue
Block a user