diff --git a/migrations/versions/27dda7e6236a_create_group_request.py b/migrations/versions/27dda7e6236a_create_group_request.py new file mode 100644 index 0000000..a2b0c70 --- /dev/null +++ b/migrations/versions/27dda7e6236a_create_group_request.py @@ -0,0 +1,38 @@ +"""Create group request + +Revision ID: 27dda7e6236a +Revises: 62addefd9655 +Create Date: 2024-04-15 03:59:03.133907 + +""" + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision = '27dda7e6236a' +down_revision = '62addefd9655' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'create_group_request', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('secret_key', sa.String(), nullable=False), + sa.Column('owner_id', sa.Integer(), nullable=False), + sa.Column('mapped_group_id', sa.Integer(), nullable=True), + sa.Column('create_ts', sa.DateTime(), nullable=False), + sa.Column('valid_ts', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ['mapped_group_id'], + ['group.id'], + ), + sa.PrimaryKeyConstraint('id'), + ) + + +def downgrade(): + op.drop_table('create_group_request') diff --git a/social/exceptions.py b/social/exceptions.py index e69de29..f3ae27c 100644 --- a/social/exceptions.py +++ b/social/exceptions.py @@ -0,0 +1,11 @@ +class SocialApiError(Exception): + """Корневая ошибка Social API""" + + +class GroupRequestNotFound(SocialApiError): + """Не найдено запроса на создание группы""" + + def __init__(self, user_id: int, secret_key: str, *args) -> None: + self.user_id = user_id + self.secret_key = secret_key + super().__init__(*args) diff --git a/social/handlers_telegram/base.py b/social/handlers_telegram/base.py index c33d0ec..10e1956 100644 --- a/social/handlers_telegram/base.py +++ b/social/handlers_telegram/base.py @@ -2,10 +2,12 @@ from functools import lru_cache from textwrap import dedent +from fastapi_sqlalchemy import db from telegram import Update from telegram.ext import Application, CommandHandler, ContextTypes from social.settings import get_settings +from social.utils.telegram_groups import approve_telegram_group from .handlers_viribus import register_handlers from .utils import CustomContext @@ -24,6 +26,7 @@ def get_application(): logger.info("Telegram API initialized successfully") # Общие хэндлеры app.add_handler(CommandHandler(callback=send_help, command="help")) + app.add_handler(CommandHandler(callback=validate_group, command="validate", has_args=1)) # Хэндлеры конкретных чатов register_handlers(app) @@ -43,3 +46,11 @@ async def send_help(update: Update, context: CustomContext): ), parse_mode='markdown', ) + + +async def validate_group(update: Update, context: CustomContext): + logger.info("Validation message received") + with db(): + approve_telegram_group(update) + res = await update.effective_message.delete() + logger.info(f"Validation message handled, delete status = {res}") diff --git a/social/models/__init__.py b/social/models/__init__.py index f5530c2..012c0e3 100644 --- a/social/models/__init__.py +++ b/social/models/__init__.py @@ -1,5 +1,6 @@ +from .create_group_request import CreateGroupRequest from .group import TelegramChannel, TelegramChat, VkChat, VkGroup from .webhook_storage import WebhookStorage -__all__ = ['WebhookStorage', 'TelegramChannel', 'TelegramChat', 'VkGroup', 'VkChat'] +__all__ = ['WebhookStorage', 'TelegramChannel', 'TelegramChat', 'VkGroup', 'VkChat', 'CreateGroupRequest'] diff --git a/social/models/create_group_request.py b/social/models/create_group_request.py new file mode 100644 index 0000000..b567837 --- /dev/null +++ b/social/models/create_group_request.py @@ -0,0 +1,21 @@ +from datetime import UTC, datetime, timedelta + +import sqlalchemy as sa +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from social.utils.string import random_string + +from .base import Base +from .group import Group + + +class CreateGroupRequest(Base): + id: Mapped[int] = mapped_column(primary_key=True) + secret_key: Mapped[str] = mapped_column(default=lambda: random_string(32)) + owner_id: Mapped[int] + mapped_group_id: Mapped[int | None] = mapped_column(sa.ForeignKey("group.id")) + + create_ts: Mapped[datetime] = mapped_column(default=lambda: datetime.now(UTC)) + valid_ts: Mapped[datetime] = mapped_column(default=lambda: datetime.now(UTC) + timedelta(days=1)) + + mapped_group: Mapped[Group | None] = relationship(Group) diff --git a/social/models/group.py b/social/models/group.py index 1c74850..1b2276a 100644 --- a/social/models/group.py +++ b/social/models/group.py @@ -12,7 +12,7 @@ class Group(Base): owner_id: Mapped[int | None] is_deleted: Mapped[bool] = mapped_column(default=False) - last_active_ts: Mapped[datetime | None] + last_active_ts: Mapped[datetime] create_ts: Mapped[datetime] = mapped_column(default=lambda: datetime.now(UTC)) update_ts: Mapped[datetime] = mapped_column(default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)) @@ -44,7 +44,7 @@ class VkChat(Group): class TelegramChannel(Group): id: Mapped[int] = mapped_column(sa.ForeignKey("group.id"), primary_key=True) - channel_id: Mapped[int] + channel_id: Mapped[int] = mapped_column(sa.BigInteger) __mapper_args__ = { "polymorphic_identity": "tg_channel", @@ -53,7 +53,7 @@ class TelegramChannel(Group): class TelegramChat(Group): id: Mapped[int] = mapped_column(sa.ForeignKey("group.id"), primary_key=True) - chat_id: Mapped[int] + chat_id: Mapped[int] = mapped_column(sa.BigInteger) __mapper_args__ = { "polymorphic_identity": "tg_chat", diff --git a/social/routes/__init__.py b/social/routes/__init__.py index e69de29..92f9000 100644 --- a/social/routes/__init__.py +++ b/social/routes/__init__.py @@ -0,0 +1 @@ +from . import exceptions # noqa diff --git a/social/routes/base.py b/social/routes/base.py index ae79ad2..396920a 100644 --- a/social/routes/base.py +++ b/social/routes/base.py @@ -10,6 +10,7 @@ from .discord import router as discord_router from .github import router as github_router +from .group import router as group_router from .telegram import router as telegram_router from .vk import router as vk_router @@ -56,6 +57,7 @@ async def lifespan(app: FastAPI): ) +app.include_router(group_router) app.include_router(github_router) app.include_router(telegram_router) app.include_router(vk_router) diff --git a/social/routes/exceptions.py b/social/routes/exceptions.py new file mode 100644 index 0000000..d5da725 --- /dev/null +++ b/social/routes/exceptions.py @@ -0,0 +1,19 @@ +from fastapi import Request +from fastapi.responses import JSONResponse + +from social.exceptions import GroupRequestNotFound + +from .base import app + + +@app.exception_handler(GroupRequestNotFound) +def group_request_not_found(request: Request, exc: GroupRequestNotFound) -> JSONResponse: + return JSONResponse( + status_code=404, + content={ + 'details': 'Group request not found', + 'ru': 'Запрос на создание группы не найден', + 'user_id': exc.user_id, + 'secret_key': exc.secret_key, + }, + ) diff --git a/social/routes/group.py b/social/routes/group.py new file mode 100644 index 0000000..1694c0f --- /dev/null +++ b/social/routes/group.py @@ -0,0 +1,54 @@ +import logging +from datetime import UTC, datetime + +from auth_lib.fastapi import UnionAuth +from fastapi import APIRouter, Depends +from fastapi_sqlalchemy import db +from pydantic import BaseModel + +from social.exceptions import GroupRequestNotFound +from social.models.create_group_request import CreateGroupRequest +from social.settings import get_settings + + +router = APIRouter(prefix="/group", tags=['User defined groups']) +settings = get_settings() +logger = logging.getLogger(__name__) + + +class GroupRequestGet(BaseModel): + secret_key: str + valid_ts: datetime + + +class GroupGet(BaseModel): + id: int + + +@router.post('') +def create_group_request( + user: dict[str] = Depends(UnionAuth(["social.group.create"])), +) -> GroupRequestGet: + obj = CreateGroupRequest(owner_id=user.get("id")) + db.session.add(obj) + db.session.commit() + return obj + + +@router.get('') +def validate_group_request( + secret_key: str, + user: dict[str] = Depends(UnionAuth(["social.group.create"])), +) -> GroupGet | GroupRequestGet: + obj = ( + db.session.query(CreateGroupRequest) + .where(CreateGroupRequest.secret_key == secret_key, CreateGroupRequest.owner_id == user.get("id")) + .one_or_none() + ) + if obj is None or obj.valid_ts.replace(tzinfo=UTC) < datetime.now(UTC): + raise GroupRequestNotFound(user_id=user.get("id"), secret_key=secret_key) + + if obj.mapped_group_id is not None: + return GroupGet.model_validate(obj.mapped_group, from_attributes=True) + + return GroupRequestGet.model_validate(obj, from_attributes=True) diff --git a/social/routes/telegram.py b/social/routes/telegram.py index 1a595f7..db6ac71 100644 --- a/social/routes/telegram.py +++ b/social/routes/telegram.py @@ -1,15 +1,13 @@ import logging -from asyncio import create_task -from datetime import UTC, datetime from fastapi import APIRouter, Request from fastapi_sqlalchemy import db from telegram import Update from social.handlers_telegram import get_application -from social.models import TelegramChannel, TelegramChat from social.models.webhook_storage import WebhookStorage, WebhookSystems from social.settings import get_settings +from social.utils.telegram_groups import create_telegram_group router = APIRouter(prefix="/telegram", tags=["webhooks"]) @@ -33,27 +31,8 @@ async def telegram_webhook(request: Request): db.session.commit() update = Update.de_json(data=request_data, bot=application.bot) - add_msg = create_task(application.update_queue.put(update)) + await application.update_queue.put(update) try: - chat = update.effective_chat - obj = None - if chat.type in ['group', 'supergroup']: - obj = db.session.query(TelegramChat).where(TelegramChat.chat_id == chat.id).one_or_none() - if obj is None: - obj = TelegramChat(chat_id=chat.id) - db.session.add(obj) - elif chat.type == 'channel': - obj = db.session.query(TelegramChannel).where(TelegramChannel.channel_id == chat.id).one_or_none() - if obj is None: - obj = TelegramChannel(channel_id=chat.id) - db.session.add(obj) - - obj.last_active_ts = datetime.now(UTC) - db.session.commit() - logger.debug(obj) + create_telegram_group(update) except Exception as exc: logger.exception(exc) - finally: - await add_msg - - return diff --git a/social/routes/vk.py b/social/routes/vk.py index 83c2003..c226a7b 100644 --- a/social/routes/vk.py +++ b/social/routes/vk.py @@ -7,7 +7,6 @@ from fastapi_sqlalchemy import db from pydantic import BaseModel, ConfigDict -from social.handlers_telegram import get_application from social.models.group import VkChat, VkGroup from social.models.webhook_storage import WebhookStorage, WebhookSystems from social.settings import get_settings @@ -17,7 +16,6 @@ router = APIRouter(prefix="/vk", tags=['vk']) settings = get_settings() logger = logging.getLogger(__name__) -application = get_application() class VkGroupCreate(BaseModel): @@ -81,7 +79,8 @@ async def vk_webhook(request: Request) -> str: @router.put('/{group_id}') def create_or_replace_group( - group_id: int, group_info: VkGroupCreate, + group_id: int, + group_info: VkGroupCreate, user: dict[str] = Depends(UnionAuth(["social.group.create"])), ) -> VkGroupCreateResponse: group = db.session.query(VkGroup).where(VkGroup.group_id == group_id).one_or_none() diff --git a/social/utils/telegram_groups.py b/social/utils/telegram_groups.py new file mode 100644 index 0000000..382f2f4 --- /dev/null +++ b/social/utils/telegram_groups.py @@ -0,0 +1,46 @@ +import logging +from datetime import UTC, datetime + +from fastapi_sqlalchemy import db +from telegram import Update + +from social.models import CreateGroupRequest, TelegramChannel, TelegramChat + + +logger = logging.getLogger(__name__) + + +def create_telegram_group(update: Update): + chat = update.effective_chat + obj = None + if chat.type in ['group', 'supergroup']: + obj = db.session.query(TelegramChat).where(TelegramChat.chat_id == chat.id).one_or_none() + if obj is None: + obj = TelegramChat(chat_id=chat.id) + db.session.add(obj) + elif chat.type == 'channel': + obj = db.session.query(TelegramChannel).where(TelegramChannel.channel_id == chat.id).one_or_none() + if obj is None: + obj = TelegramChannel(channel_id=chat.id) + db.session.add(obj) + + if not obj: + return + + obj.last_active_ts = datetime.now(UTC) + db.session.commit() + return obj + + +def approve_telegram_group(update: Update): + logger.debug("Validation started") + group = create_telegram_group(update) + text = update.effective_message.text + if not text or not group: + logger.error("Telegram group not validated (secret=%s, group=%s)", text, group) + return + text = text.removeprefix('/validate').removeprefix('@ViribusSocialBot').strip() + db.session.query(CreateGroupRequest).where(CreateGroupRequest.secret_key == text).update( + {CreateGroupRequest.mapped_group_id: group.id} + ) + logger.info("Telegram group %d validated (secret=%s)", group.id, text)