From 5c81aae29cae6ddcf889eead40962213ceacb6b3 Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Sat, 6 Sep 2025 10:57:10 +0900 Subject: [PATCH] ACL, channel_charing --- .../versions/50652f5156d8_channel_accesses.py | 28 ++ .../versions/96a65ea5f555_channel_accesses.py | 28 ++ .../versions/ae94c53e7343_channel_accesses.py | 28 ++ handlers/add_channel.py | 88 +++-- handlers/add_group.py | 174 ++++++++-- handlers/new_post.py | 319 +++++++----------- handlers/permissions.py | 68 ++++ handlers/share_channel.py | 113 +++++++ main.py | 55 ++- models.py | 31 +- 10 files changed, 657 insertions(+), 275 deletions(-) create mode 100644 alembic/versions/50652f5156d8_channel_accesses.py create mode 100644 alembic/versions/96a65ea5f555_channel_accesses.py create mode 100644 alembic/versions/ae94c53e7343_channel_accesses.py create mode 100644 handlers/permissions.py create mode 100644 handlers/share_channel.py diff --git a/alembic/versions/50652f5156d8_channel_accesses.py b/alembic/versions/50652f5156d8_channel_accesses.py new file mode 100644 index 0000000..230740d --- /dev/null +++ b/alembic/versions/50652f5156d8_channel_accesses.py @@ -0,0 +1,28 @@ +"""channel_accesses + +Revision ID: 50652f5156d8 +Revises: 96a65ea5f555 +Create Date: 2025-09-06 10:01:41.613022 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '50652f5156d8' +down_revision: Union[str, Sequence[str], None] = '96a65ea5f555' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + pass + + +def downgrade() -> None: + """Downgrade schema.""" + pass diff --git a/alembic/versions/96a65ea5f555_channel_accesses.py b/alembic/versions/96a65ea5f555_channel_accesses.py new file mode 100644 index 0000000..2e000a2 --- /dev/null +++ b/alembic/versions/96a65ea5f555_channel_accesses.py @@ -0,0 +1,28 @@ +"""channel_accesses + +Revision ID: 96a65ea5f555 +Revises: ae94c53e7343 +Create Date: 2025-09-06 09:59:33.965591 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '96a65ea5f555' +down_revision: Union[str, Sequence[str], None] = 'ae94c53e7343' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + pass + + +def downgrade() -> None: + """Downgrade schema.""" + pass diff --git a/alembic/versions/ae94c53e7343_channel_accesses.py b/alembic/versions/ae94c53e7343_channel_accesses.py new file mode 100644 index 0000000..2a272ea --- /dev/null +++ b/alembic/versions/ae94c53e7343_channel_accesses.py @@ -0,0 +1,28 @@ +"""channel_accesses + +Revision ID: ae94c53e7343 +Revises: 21c6fd6ac065 +Create Date: 2025-09-06 09:51:14.502916 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'ae94c53e7343' +down_revision: Union[str, Sequence[str], None] = '21c6fd6ac065' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + pass + + +def downgrade() -> None: + """Downgrade schema.""" + pass diff --git a/handlers/add_channel.py b/handlers/add_channel.py index f39cb39..cee2bee 100644 --- a/handlers/add_channel.py +++ b/handlers/add_channel.py @@ -1,8 +1,12 @@ - +# handlers/add_channel.py from telegram import Update -from telegram.ext import ContextTypes, ConversationHandler, CommandHandler, MessageHandler, filters +from telegram.ext import ( + ContextTypes, ConversationHandler, CommandHandler, MessageHandler, filters +) +from sqlalchemy import select + from db import AsyncSessionLocal -from models import Channel +from models import Channel, Admin INPUT_NAME, INPUT_LINK = range(2) @@ -14,40 +18,60 @@ async def add_channel_start(update: Update, context: ContextTypes.DEFAULT_TYPE): return INPUT_NAME async def input_channel_name(update: Update, context: ContextTypes.DEFAULT_TYPE): - if context.user_data is None: - context.user_data = {} - text = update.message.text.strip() if update.message and update.message.text else '' - context.user_data['channel_name'] = text - if update.message: - await update.message.reply_text('Теперь отправьте ссылку на канал (должна начинаться с @):') + if not update.message: + return ConversationHandler.END + name = (update.message.text or "").strip() + if not name: + await update.message.reply_text("Имя не может быть пустым. Введите имя канала:") + return INPUT_NAME + context.user_data["channel_name"] = name + await update.message.reply_text('Отправьте ссылку на канал (формат "@username" или "-100..."):') return INPUT_LINK -async def input_channel_link(update: Update, context: ContextTypes.DEFAULT_TYPE): - if context.user_data is None: - context.user_data = {} - link = update.message.text.strip() if update.message and update.message.text else '' - if not link.startswith('@'): - if update.message: - await update.message.reply_text('Ошибка: ссылка на канал должна начинаться с @. Попробуйте снова.') - return INPUT_LINK - context.user_data['channel_link'] = link - return await save_channel(update, context) +async def _get_or_create_admin(session, tg_id: int) -> Admin: + res = await session.execute(select(Admin).where(Admin.tg_id == tg_id)) + admin = res.scalar_one_or_none() + if not admin: + admin = Admin(tg_id=tg_id) + session.add(admin) + await session.flush() + return admin -async def save_channel(update: Update, context: ContextTypes.DEFAULT_TYPE): - if context.user_data is None: - context.user_data = {} - name = context.user_data.get('channel_name') - link = context.user_data.get('channel_link') - if not name or not link: - if update.message: - await update.message.reply_text('Ошибка: не указано название или ссылка.') +async def input_channel_link(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not update.message: return ConversationHandler.END + + link = (update.message.text or "").strip() + if not (link.startswith("@") or link.startswith("-100")): + await update.message.reply_text('Неверный формат. Укажите "@username" или "-100...".') + return INPUT_LINK + + name = (context.user_data or {}).get("channel_name", "").strip() + if not name: + await update.message.reply_text("Не найдено имя. Начните заново: /add_channel") + return ConversationHandler.END + + user = update.effective_user + if not user: + await update.message.reply_text("Не удалось определить администратора.") + return ConversationHandler.END + async with AsyncSessionLocal() as session: - channel = Channel(name=name, link=link) - session.add(channel) - await session.commit() - if update.message: - await update.message.reply_text(f'Канал "{name}" добавлен.') + admin = await _get_or_create_admin(session, user.id) + + # если канал уже есть — обновим имя и владельца + existing_q = await session.execute(select(Channel).where(Channel.link == link)) + existing = existing_q.scalar_one_or_none() + if existing: + existing.name = name + existing.admin_id = admin.id + await session.commit() + await update.message.reply_text(f'Канал "{name}" уже был — обновил владельца и имя.') + else: + channel = Channel(name=name, link=link, admin_id=admin.id) + session.add(channel) + await session.commit() + await update.message.reply_text(f'Канал "{name}" добавлен и привязан к вашему админ-аккаунту.') return ConversationHandler.END add_channel_conv = ConversationHandler( diff --git a/handlers/add_group.py b/handlers/add_group.py index 4f9459f..6465ed5 100644 --- a/handlers/add_group.py +++ b/handlers/add_group.py @@ -1,60 +1,166 @@ +# from telegram import Update +# from telegram.ext import ContextTypes, ConversationHandler, CommandHandler, MessageHandler, filters +# from db import AsyncSessionLocal +# from models import Group + +# INPUT_NAME, INPUT_LINK = range(2) + +# async def add_group_start(update: Update, context: ContextTypes.DEFAULT_TYPE): +# if context.user_data is None: +# context.user_data = {} +# if update.message: +# await update.message.reply_text('Введите имя группы:') +# return INPUT_NAME + +# async def input_group_name(update: Update, context: ContextTypes.DEFAULT_TYPE): +# if context.user_data is None: +# context.user_data = {} +# text = update.message.text.strip() if update.message and update.message.text else '' +# context.user_data['group_name'] = text +# if update.message: +# await update.message.reply_text('Теперь отправьте chat_id группы (например, -1001234567890):') +# return INPUT_LINK + +# async def input_group_link(update: Update, context: ContextTypes.DEFAULT_TYPE): +# if context.user_data is None: +# context.user_data = {} +# link = update.message.text.strip() if update.message and update.message.text else '' +# if not link.startswith('-100'): +# if update.message: +# await update.message.reply_text('Ошибка: chat_id группы должен начинаться с -100. Попробуйте снова.') +# return INPUT_LINK +# context.user_data['group_link'] = link +# return await save_group(update, context) + +# async def save_group(update: Update, context: ContextTypes.DEFAULT_TYPE): +# if context.user_data is None: +# context.user_data = {} +# name = context.user_data.get('group_name') +# link = context.user_data.get('group_link') +# if not name or not link: +# if update.message: +# await update.message.reply_text('Ошибка: не указано название или ссылка.') +# return ConversationHandler.END +# async with AsyncSessionLocal() as session: +# group = Group(name=name, link=link) +# session.add(group) +# await session.commit() +# if update.message: +# await update.message.reply_text(f'Группа "{name}" добавлена.') +# return ConversationHandler.END + +# add_group_conv = ConversationHandler( +# entry_points=[CommandHandler('add_group', add_group_start)], +# states={ +# INPUT_NAME: [MessageHandler(filters.TEXT & ~filters.COMMAND, input_group_name)], +# INPUT_LINK: [MessageHandler(filters.TEXT & ~filters.COMMAND, input_group_link)], +# }, +# fallbacks=[] +# ) + + +# handlers/add_group.py from telegram import Update -from telegram.ext import ContextTypes, ConversationHandler, CommandHandler, MessageHandler, filters +from telegram.ext import ( + ContextTypes, + ConversationHandler, + CommandHandler, + MessageHandler, + filters, +) +from sqlalchemy import select + from db import AsyncSessionLocal -from models import Group +from models import Group, Admin INPUT_NAME, INPUT_LINK = range(2) + async def add_group_start(update: Update, context: ContextTypes.DEFAULT_TYPE): if context.user_data is None: context.user_data = {} if update.message: - await update.message.reply_text('Введите имя группы:') + await update.message.reply_text("Введите имя группы:") return INPUT_NAME + async def input_group_name(update: Update, context: ContextTypes.DEFAULT_TYPE): - if context.user_data is None: - context.user_data = {} - text = update.message.text.strip() if update.message and update.message.text else '' - context.user_data['group_name'] = text - if update.message: - await update.message.reply_text('Теперь отправьте chat_id группы (например, -1001234567890):') + if not update.message: + return ConversationHandler.END + + name = (update.message.text or "").strip() + if not name: + await update.message.reply_text("Имя не может быть пустым. Введите имя группы:") + return INPUT_NAME + + context.user_data["group_name"] = name + await update.message.reply_text('Отправьте ссылку на группу (формат "@username" или "-100..."):') + return INPUT_LINK -async def input_group_link(update: Update, context: ContextTypes.DEFAULT_TYPE): - if context.user_data is None: - context.user_data = {} - link = update.message.text.strip() if update.message and update.message.text else '' - if not link.startswith('-100'): - if update.message: - await update.message.reply_text('Ошибка: chat_id группы должен начинаться с -100. Попробуйте снова.') - return INPUT_LINK - context.user_data['group_link'] = link - return await save_group(update, context) -async def save_group(update: Update, context: ContextTypes.DEFAULT_TYPE): - if context.user_data is None: - context.user_data = {} - name = context.user_data.get('group_name') - link = context.user_data.get('group_link') - if not name or not link: - if update.message: - await update.message.reply_text('Ошибка: не указано название или ссылка.') +async def _get_or_create_admin(session: AsyncSessionLocal, tg_id: int) -> Admin: + res = await session.execute(select(Admin).where(Admin.tg_id == tg_id)) + admin = res.scalar_one_or_none() + if not admin: + admin = Admin(tg_id=tg_id) + session.add(admin) + # Чтобы получить admin.id до commit + await session.flush() + return admin + + +async def input_group_link(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not update.message: return ConversationHandler.END + + link = (update.message.text or "").strip() + if not (link.startswith("@") or link.startswith("-100")): + await update.message.reply_text( + 'Неверный формат. Укажите "@username" (публичная группа/супергруппа) или "-100..." (ID).' + ) + return INPUT_LINK + + name = (context.user_data or {}).get("group_name", "").strip() + if not name: + await update.message.reply_text("Не найдено имя группы. Начните заново: /add_group") + return ConversationHandler.END + + user = update.effective_user + if not user: + await update.message.reply_text("Не удалось определить администратора. Попробуйте ещё раз.") + return ConversationHandler.END + async with AsyncSessionLocal() as session: - group = Group(name=name, link=link) - session.add(group) - await session.commit() - if update.message: - await update.message.reply_text(f'Группа "{name}" добавлена.') + # гарантируем наличие админа + admin = await _get_or_create_admin(session, user.id) + + # проверка на существование группы по ссылке + existing_q = await session.execute(select(Group).where(Group.link == link)) + existing = existing_q.scalar_one_or_none() + + if existing: + existing.name = name + existing.admin_id = admin.id + await session.commit() + await update.message.reply_text( + f'Группа "{name}" уже была в базе — обновил владельца и имя.' + ) + else: + group = Group(name=name, link=link, admin_id=admin.id) + session.add(group) + await session.commit() + await update.message.reply_text(f'Группа "{name}" добавлена и привязана к вашему админ-аккаунту.') + return ConversationHandler.END + add_group_conv = ConversationHandler( - entry_points=[CommandHandler('add_group', add_group_start)], + entry_points=[CommandHandler("add_group", add_group_start)], states={ INPUT_NAME: [MessageHandler(filters.TEXT & ~filters.COMMAND, input_group_name)], INPUT_LINK: [MessageHandler(filters.TEXT & ~filters.COMMAND, input_group_link)], }, - fallbacks=[] + fallbacks=[], ) diff --git a/handlers/new_post.py b/handlers/new_post.py index 198f409..bf128fa 100644 --- a/handlers/new_post.py +++ b/handlers/new_post.py @@ -1,197 +1,144 @@ -from typing import List, Optional +# handlers/new_post.py +from __future__ import annotations +from typing import List, Optional, Tuple from telegram import ( - Update, - InlineKeyboardMarkup, - InlineKeyboardButton, - MessageEntity, + Update, InlineKeyboardMarkup, InlineKeyboardButton, MessageEntity, Bot ) from telegram.ext import ( - ContextTypes, - ConversationHandler, - MessageHandler, - CommandHandler, - CallbackQueryHandler, - filters, + ContextTypes, ConversationHandler, MessageHandler, CommandHandler, CallbackQueryHandler, filters ) from telegram.constants import MessageEntityType - +from telegram.error import BadRequest from sqlalchemy import select as sa_select from db import AsyncSessionLocal -from models import Channel, Group, Button -from models import Admin - +from models import Channel, Group +from .permissions import get_or_create_admin, list_channels_for_admin, has_scope_on_channel, SCOPE_POST SELECT_MEDIA, SELECT_TEXT, SELECT_TARGET = range(3) - -# ========== UTF-16 helpers ========== - +# ===== UTF-16 helpers (для custom_emoji) ===== def _utf16_units_len(s: str) -> int: - """Длина строки в UTF-16 code units (LE).""" return len(s.encode("utf-16-le")) // 2 - -def _normalize_custom_emoji_entities(text: str, entities: List[MessageEntity]) -> List[MessageEntity]: - """ - Для entity типа CUSTOM_EMOJI Telegram ожидает offset/length в UTF-16. - Если вдруг кастомная эмодзи пришла с length > 1 (несколько символов), - дробим на несколько entity, каждая длиной в реальное кол-во UTF-16 юнитов - (обычно 2 на эмодзи). - Остальные entity не трогаем (у них уже корректные UTF-16 offset/length). - """ - if not text or not entities: - return entities or [] - - out: List[MessageEntity] = [] - for e in entities: - if ( - e.type == MessageEntityType.CUSTOM_EMOJI - and e.length is not None - and e.length > 1 - and getattr(e, "custom_emoji_id", None) - ): - # Возьмём срез по codepoints — нормально, но считать длины будем в UTF-16 - substr = text[e.offset: e.offset + e.length] - rel_utf16 = 0 - for ch in substr: - ch_len16 = _utf16_units_len(ch) # чаще всего 2 - out.append( - MessageEntity( - type=MessageEntityType.CUSTOM_EMOJI, - offset=e.offset + rel_utf16, - length=ch_len16, - custom_emoji_id=e.custom_emoji_id, - url=None, user=None, language=None - ) - ) - rel_utf16 += ch_len16 - else: - out.append(e) +def _utf16_index_map(text: str) -> List[Tuple[int, int, str]]: + out: List[Tuple[int, int, str]] = [] + off = 0 + for ch in text: + ln = _utf16_units_len(ch) + out.append((off, ln, ch)) + off += ln return out +def _split_custom_emoji_by_utf16(text: str, entities: List[MessageEntity]) -> List[MessageEntity]: + if not text or not entities: + return entities or [] + map_utf16 = _utf16_index_map(text) + out: List[MessageEntity] = [] + for e in entities: + if (e.type == MessageEntityType.CUSTOM_EMOJI and e.length and e.length > 1 and getattr(e, "custom_emoji_id", None)): + start = e.offset + end = e.offset + e.length + for uoff, ulen, _ in map_utf16: + if start <= uoff < end: + out.append(MessageEntity( + type=MessageEntityType.CUSTOM_EMOJI, + offset=uoff, + length=ulen, + custom_emoji_id=e.custom_emoji_id, + )) + else: + out.append(e) + out.sort(key=lambda x: x.offset) + return out -def _strip_none_entities(entities: Optional[List[MessageEntity]]) -> List[MessageEntity]: - """ - Фильтрация мусорных/пустых entity, чтобы не падать на сервере. - """ +def _strip_broken_entities(entities: Optional[List[MessageEntity]]) -> List[MessageEntity]: cleaned: List[MessageEntity] = [] for e in entities or []: if e.offset is None or e.length is None or e.offset < 0 or e.length < 1: continue - if e.type == MessageEntityType.CUSTOM_EMOJI: - if not getattr(e, "custom_emoji_id", None): - continue + if e.type == MessageEntityType.CUSTOM_EMOJI and not getattr(e, "custom_emoji_id", None): + continue cleaned.append(e) + cleaned.sort(key=lambda x: x.offset) return cleaned - def _extract_text_and_entities(msg) -> tuple[str, List[MessageEntity], bool]: - """ - Унифицированно достаём текст и entities из сообщения: - - Если есть text -> берём text/entities, is_caption=False - - Иначе если есть caption -> берём caption/caption_entities, is_caption=True - - Иначе пусто - """ if getattr(msg, "text", None): return msg.text, (msg.entities or []), False if getattr(msg, "caption", None): return msg.caption, (msg.caption_entities or []), True return "", [], False - -# ========== Conversation handlers ========== - +# ===== Conversation ===== async def new_post_start(update: Update, context: ContextTypes.DEFAULT_TYPE): if update.message: - await update.message.reply_text("Отправьте картинку/медиа для поста или пришлите /skip:") + await update.message.reply_text("Отправьте медиа для поста или пришлите /skip:") return SELECT_MEDIA return ConversationHandler.END - async def select_media(update: Update, context: ContextTypes.DEFAULT_TYPE): if context.user_data is None: context.user_data = {} - if not update.message: return ConversationHandler.END msg = update.message - - # Поддержка пропуска медиа if msg.text and msg.text.strip().lower() == "/skip": - await update.message.reply_text("Введите текст поста или пересланное сообщение (можно с эмодзи/разметкой):") + await update.message.reply_text("Введите текст поста или перешлите сообщение (можно с кастом-эмодзи):") return SELECT_TEXT - # Сохраняем медиа (последний вариант — стикер) - if msg.photo: - context.user_data["photo"] = msg.photo[-1].file_id - elif msg.animation: - context.user_data["animation"] = msg.animation.file_id - elif msg.video: - context.user_data["video"] = msg.video.file_id - elif msg.document: - context.user_data["document"] = msg.document.file_id - elif msg.audio: - context.user_data["audio"] = msg.audio.file_id - elif msg.voice: - context.user_data["voice"] = msg.voice.file_id - elif msg.sticker: - context.user_data["sticker"] = msg.sticker.file_id + if msg.photo: context.user_data["photo"] = msg.photo[-1].file_id + elif msg.animation:context.user_data["animation"] = msg.animation.file_id + elif msg.video: context.user_data["video"] = msg.video.file_id + elif msg.document: context.user_data["document"] = msg.document.file_id + elif msg.audio: context.user_data["audio"] = msg.audio.file_id + elif msg.voice: context.user_data["voice"] = msg.voice.file_id + elif msg.sticker: context.user_data["sticker"] = msg.sticker.file_id - await update.message.reply_text("Введите текст поста или пересланное сообщение (можно с эмодзи/разметкой):") + await update.message.reply_text("Введите текст поста или перешлите сообщение (можно с кастом-эмодзи):") return SELECT_TEXT - async def select_text(update: Update, context: ContextTypes.DEFAULT_TYPE): if not update.message: return ConversationHandler.END - if context.user_data is None: context.user_data = {} msg = update.message - # Берём как есть: либо text/entities, либо caption/caption_entities - if msg.text: - text = msg.text - entities = msg.entities or [] - else: - text = msg.caption or "" - entities = msg.caption_entities or [] + text, entities, _ = _extract_text_and_entities(msg) + entities = _strip_broken_entities(entities) + entities = _split_custom_emoji_by_utf16(text, entities) - # Сохраняем исходные данные БЕЗ изменений + # сохраним исходник для copyMessage context.user_data["text"] = text context.user_data["entities"] = entities - if update.effective_chat and hasattr(update.effective_chat, 'id'): - context.user_data["src_chat_id"] = update.effective_chat.id - else: - context.user_data["src_chat_id"] = None + context.user_data["src_chat_id"] = update.effective_chat.id context.user_data["src_msg_id"] = update.message.message_id - # Дальше как у тебя: выбор канала/группы - from sqlalchemy import select as sa_select - user = update.effective_user - if not user or not hasattr(user, 'id'): - await update.message.reply_text('Ошибка: не удалось определить пользователя.') - return ConversationHandler.END - admin_tg_id = user.id + # дать выбор только тех каналов, где у текущего админа есть право постинга async with AsyncSessionLocal() as session: - admin_obj = (await session.execute(sa_select(Admin).where(Admin.tg_id == admin_tg_id))).scalar_one_or_none() - admin_id = admin_obj.id if admin_obj else None - channels = (await session.execute(sa_select(Channel).where(Channel.admin_id == admin_id))).scalars().all() if admin_id is not None else [] - groups = (await session.execute(sa_select(Group).where(Group.admin_id == admin_id))).scalars().all() if admin_id is not None else [] + me = await get_or_create_admin(session, update.effective_user.id) + channels = await list_channels_for_admin(session, me.id) + # группы оставляем без ACL (как было) + groups = (await session.execute(sa_select(Group))).scalars().all() + + # если каналов нет — всё равно покажем группы keyboard = [] for c in channels: - keyboard.append([InlineKeyboardButton(f'Канал: {c.name}', callback_data=f'channel_{c.id}')]) + keyboard.append([InlineKeyboardButton(f'Канал: {c.name}', callback_data=f'channel_{c.id}')]) for g in groups: keyboard.append([InlineKeyboardButton(f'Группа: {g.name}', callback_data=f'group_{g.id}')]) + if not keyboard: + await update.message.reply_text("Нет доступных каналов/групп для отправки.") + return ConversationHandler.END + await update.message.reply_text('Выберите, куда отправить пост:', reply_markup=InlineKeyboardMarkup(keyboard)) return SELECT_TARGET - async def select_target(update: Update, context: ContextTypes.DEFAULT_TYPE): query = update.callback_query if not query: @@ -199,26 +146,29 @@ async def select_target(update: Update, context: ContextTypes.DEFAULT_TYPE): await query.answer() data = query.data - from sqlalchemy import select as sa_select async with AsyncSessionLocal() as session: chat_id = None markup = None if data and data.startswith('channel_'): channel_id = int(data.split('_')[1]) + + # ACL: проверить право постинга + me = await get_or_create_admin(session, update.effective_user.id) + allowed = await has_scope_on_channel(session, me.id, channel_id, SCOPE_POST) + if not allowed: + await query.edit_message_text("У вас нет права постить в этот канал.") + return ConversationHandler.END + channel = (await session.execute(sa_select(Channel).where(Channel.id == channel_id))).scalar_one_or_none() - buttons = (await session.execute(sa_select(Button).where(Button.channel_id == channel_id))).scalars().all() - markup = InlineKeyboardMarkup([[InlineKeyboardButton(str(b.name), url=str(b.url))] for b in buttons]) if buttons else None chat_id = channel.link if channel else None elif data and data.startswith('group_'): group_id = int(data.split('_')[1]) group = (await session.execute(sa_select(Group).where(Group.id == group_id))).scalar_one_or_none() - buttons = (await session.execute(sa_select(Button).where(Button.group_id == group_id))).scalars().all() - markup = InlineKeyboardMarkup([[InlineKeyboardButton(str(b.name), url=str(b.url))] for b in buttons]) if buttons else None chat_id = group.link if group else None - if chat_id is None: + if not chat_id: await query.edit_message_text('Ошибка: объект не найден.') return ConversationHandler.END @@ -227,98 +177,79 @@ async def select_target(update: Update, context: ContextTypes.DEFAULT_TYPE): await query.edit_message_text('Ошибка: ссылка должна быть username (@channel) или числовой ID (-100...)') return ConversationHandler.END - try: - ud = context.user_data or {} - text = ud.get('text', '') or '' - entities = ud.get('entities', []) or [] + ud = context.user_data or {} + text: str = ud.get("text", "") or "" + entities: List[MessageEntity] = ud.get("entities", []) or [] - # Если это одно входящее сообщение без “сборки” из двух шагов — идеально копируем: - # (то есть пользователь НЕ загружал медиа в SELECT_MEDIA) - has_media = any(k in ud for k in ('photo','animation','video','document','audio','voice','sticker')) - if ud.get('src_chat_id') and ud.get('src_msg_id') and not has_media: + # санация перед отправкой + entities = _strip_broken_entities(entities) + entities = _split_custom_emoji_by_utf16(text, entities) + + # 1) Пытаемся «бит-в-бит» копию + has_media_parts = any(k in ud for k in ("photo","animation","video","document","audio","voice","sticker")) + try: + if ud.get("src_chat_id") and ud.get("src_msg_id") and not has_media_parts: await context.bot.copy_message( chat_id=chat_id, - from_chat_id=ud['src_chat_id'], - message_id=ud['src_msg_id'], - reply_markup=markup, + from_chat_id=ud["src_chat_id"], + message_id=ud["src_msg_id"], ) - await query.edit_message_text('Пост отправлен!') + await query.edit_message_text("Пост отправлен!") return ConversationHandler.END + except BadRequest: + pass # упадем в fallback - # Иначе — отправляем собранный пост, но entities/emoji берём ИСХОДНЫЕ + # 2) fallback — отправка с entities/caption_entities (без parse_mode) + try: sent = False - if 'photo' in ud: - await context.bot.send_photo(chat_id=chat_id, photo=ud['photo'], - caption=text or None, caption_entities=entities if text else None, - reply_markup=markup) + if "photo" in ud: + await context.bot.send_photo(chat_id=chat_id, photo=ud["photo"], + caption=(text or None), caption_entities=(entities if text else None)) sent = True - elif 'animation' in ud: - await context.bot.send_animation(chat_id=chat_id, animation=ud['animation'], - caption=text or None, caption_entities=entities if text else None, - reply_markup=markup) + elif "animation" in ud: + await context.bot.send_animation(chat_id=chat_id, animation=ud["animation"], + caption=(text or None), caption_entities=(entities if text else None)) sent = True - elif 'video' in ud: - await context.bot.send_video(chat_id=chat_id, video=ud['video'], - caption=text or None, caption_entities=entities if text else None, - reply_markup=markup) + elif "video" in ud: + await context.bot.send_video(chat_id=chat_id, video=ud["video"], + caption=(text or None), caption_entities=(entities if text else None)) sent = True - elif 'document' in ud: - await context.bot.send_document(chat_id=chat_id, document=ud['document'], - caption=text or None, caption_entities=entities if text else None, - reply_markup=markup) + elif "document" in ud: + await context.bot.send_document(chat_id=chat_id, document=ud["document"], + caption=(text or None), caption_entities=(entities if text else None)) sent = True - elif 'audio' in ud: - await context.bot.send_audio(chat_id=chat_id, audio=ud['audio'], - caption=text or None, caption_entities=entities if text else None, - reply_markup=markup) + elif "audio" in ud: + await context.bot.send_audio(chat_id=chat_id, audio=ud["audio"], + caption=(text or None), caption_entities=(entities if text else None)) sent = True - elif 'voice' in ud: - await context.bot.send_voice(chat_id=chat_id, voice=ud['voice'], - caption=text or None, caption_entities=entities if text else None, - reply_markup=markup) + elif "voice" in ud: + await context.bot.send_voice(chat_id=chat_id, voice=ud["voice"], + caption=(text or None), caption_entities=(entities if text else None)) sent = True - elif 'sticker' in ud: - await context.bot.send_sticker(chat_id=chat_id, sticker=ud['sticker']) + elif "sticker" in ud: + await context.bot.send_sticker(chat_id=chat_id, sticker=ud["sticker"]) if text: - await context.bot.send_message(chat_id=chat_id, text=text, entities=entities, reply_markup=markup) + await context.bot.send_message(chat_id=chat_id, text=text, entities=entities) sent = True else: - await context.bot.send_message(chat_id=chat_id, text=text, entities=entities, reply_markup=markup) + await context.bot.send_message(chat_id=chat_id, text=text, entities=entities) sent = True await query.edit_message_text('Пост отправлен!' if sent else 'Ошибка: не удалось отправить сообщение.') - - except Exception as e: + except BadRequest as e: await query.edit_message_text(f'Ошибка отправки поста: {e}') return ConversationHandler.END - - new_post_conv = ConversationHandler( entry_points=[CommandHandler("new_post", new_post_start)], states={ - # Принимаем любые медиа + /skip - SELECT_MEDIA: [ - MessageHandler( - filters.PHOTO - | filters.ANIMATION - | filters.VIDEO - | filters.Document.ALL - | filters.AUDIO - | filters.VOICE - | filters.Sticker.ALL - | filters.COMMAND, - select_media, - ) - ], - # Принимаем любой текст/пересланное (caption попадёт как caption_entities через media-фильтры на предыдущем шаге) - SELECT_TEXT: [ - MessageHandler( - filters.ALL & (filters.TEXT | filters.FORWARDED), - select_text - ) - ], + SELECT_MEDIA: [MessageHandler( + filters.PHOTO | filters.ANIMATION | filters.VIDEO | filters.Document.ALL | + filters.AUDIO | filters.VOICE | filters.Sticker.ALL | filters.COMMAND, + select_media + )], + SELECT_TEXT: [MessageHandler(filters.TEXT | filters.FORWARDED | filters.CAPTION, select_text)], SELECT_TARGET: [CallbackQueryHandler(select_target)], }, fallbacks=[], diff --git a/handlers/permissions.py b/handlers/permissions.py new file mode 100644 index 0000000..fe3ca10 --- /dev/null +++ b/handlers/permissions.py @@ -0,0 +1,68 @@ +# permissions.py +import hashlib, secrets +from datetime import datetime, timedelta +from sqlalchemy import select +from models import Admin, Channel, ChannelAccess, SCOPE_POST, SCOPE_SHARE + +def make_token(nbytes: int = 9) -> str: + # Короткий URL-safe токен (<= ~12-16 символов укладывается в /start payload) + return secrets.token_urlsafe(nbytes) + +def token_hash(token: str) -> str: + return hashlib.sha256(token.encode('utf-8')).hexdigest() + +async def get_or_create_admin(session, tg_id: int) -> Admin: + res = await session.execute(select(Admin).where(Admin.tg_id == tg_id)) + admin = res.scalar_one_or_none() + if not admin: + admin = Admin(tg_id=tg_id) + session.add(admin) + await session.flush() + return admin + +async def has_scope_on_channel(session, admin_id: int, channel_id: int, scope: int) -> bool: + # Владелец канала — всегда полный доступ + res = await session.execute(select(Channel).where(Channel.id == channel_id)) + ch = res.scalar_one_or_none() + if ch and ch.admin_id == admin_id: + return True + + # Иначе ищем активный доступ с нужной маской + res = await session.execute( + select(ChannelAccess).where( + ChannelAccess.channel_id == channel_id, + ChannelAccess.invited_admin_id == admin_id, + ChannelAccess.status == "active", + ) + ) + acc = res.scalar_one_or_none() + if not acc: + return False + return (acc.scopes & scope) == scope + +async def list_channels_for_admin(session, admin_id: int): + """Каналы, куда можно постить: владелец + активные доступы с SCOPE_POST.""" + # Владелец + q1 = await session.execute(select(Channel).where(Channel.admin_id == admin_id)) + owned = q1.scalars().all() + + # Доступы + q2 = await session.execute( + select(ChannelAccess).where( + ChannelAccess.invited_admin_id == admin_id, + ChannelAccess.status == "active", + ) + ) + access_rows = q2.scalars().all() + access_map = {ar.channel_id for ar in access_rows if (ar.scopes & SCOPE_POST)} + if not access_map: + return owned + + q3 = await session.execute(select(Channel).where(Channel.id.in_(access_map))) + shared = q3.scalars().all() + + # Уникальный список (owner + shared) + all_channels = {c.id: c for c in owned} + for c in shared: + all_channels[c.id] = c + return list(all_channels.values()) diff --git a/handlers/share_channel.py b/handlers/share_channel.py new file mode 100644 index 0000000..307b46b --- /dev/null +++ b/handlers/share_channel.py @@ -0,0 +1,113 @@ +# handlers/share_channel.py +from datetime import datetime, timedelta +from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup +from telegram.ext import ( + ContextTypes, ConversationHandler, CommandHandler, CallbackQueryHandler +) +from sqlalchemy import select + +from db import AsyncSessionLocal +from models import Channel, ChannelAccess, SCOPE_POST +from .permissions import get_or_create_admin, make_token, token_hash + +SELECT_CHANNEL, CONFIRM_INVITE = range(2) + +async def share_channel_start(update: Update, context: ContextTypes.DEFAULT_TYPE): + async with AsyncSessionLocal() as session: + me = await get_or_create_admin(session, update.effective_user.id) + q = await session.execute(select(Channel).where(Channel.admin_id == me.id)) + channels = q.scalars().all() + + if not channels: + if update.message: + await update.message.reply_text("Нет каналов, которыми вы владеете.") + return ConversationHandler.END + + kb = [[InlineKeyboardButton(f"{c.name} ({c.link})", callback_data=f"sch_{c.id}")] for c in channels] + rm = InlineKeyboardMarkup(kb) + if update.message: + await update.message.reply_text("Выберите канал для выдачи доступа:", reply_markup=rm) + return SELECT_CHANNEL + +async def select_channel(update: Update, context: ContextTypes.DEFAULT_TYPE): + q = update.callback_query + if not q: return ConversationHandler.END + await q.answer() + if not q.data.startswith("sch_"): return ConversationHandler.END + channel_id = int(q.data.split("_")[1]) + context.user_data["share_channel_id"] = channel_id + + kb = [ + [InlineKeyboardButton("Срок: 7 дней", callback_data="ttl_7"), + InlineKeyboardButton("30 дней", callback_data="ttl_30"), + InlineKeyboardButton("∞", callback_data="ttl_inf")], + [InlineKeyboardButton("Выдать право постинга", callback_data="scope_post")], + [InlineKeyboardButton("Сгенерировать ссылку", callback_data="go")], + ] + await q.edit_message_text("Настройте приглашение:", reply_markup=InlineKeyboardMarkup(kb)) + context.user_data["ttl_days"] = 7 + context.user_data["scopes"] = SCOPE_POST + return CONFIRM_INVITE + +async def confirm_invite(update: Update, context: ContextTypes.DEFAULT_TYPE): + q = update.callback_query + if not q: return ConversationHandler.END + await q.answer() + + data = q.data + if data.startswith("ttl_"): + context.user_data["ttl_days"] = {"ttl_7":7, "ttl_30":30, "ttl_inf":None}[data] + await q.edit_message_reply_markup(reply_markup=q.message.reply_markup) + return CONFIRM_INVITE + if data == "scope_post": + # пока фиксируем только POST + await q.edit_message_reply_markup(reply_markup=q.message.reply_markup) + return CONFIRM_INVITE + if data != "go": + return CONFIRM_INVITE + + channel_id = context.user_data.get("share_channel_id") + ttl_days = context.user_data.get("ttl_days") + scopes = context.user_data.get("scopes") + + async with AsyncSessionLocal() as session: + me = await get_or_create_admin(session, update.effective_user.id) + + token = make_token(9) + thash = token_hash(token) + + expires_at = None + if ttl_days: + expires_at = datetime.utcnow() + timedelta(days=ttl_days) + + acc = ChannelAccess( + channel_id=channel_id, + invited_by_admin_id=me.id, + token_hash=thash, + scopes=scopes, + status="pending", + created_at=datetime.utcnow(), + expires_at=expires_at, + ) + session.add(acc) + await session.commit() + invite_id = acc.id + + payload = f"sch_{invite_id}_{token}" + await q.edit_message_text( + "Ссылка для выдачи доступа к каналу:\n" + f"`https://t.me/?start={payload}`\n\n" + "Передайте её коллеге. Срок действия — " + + ("не ограничен." if ttl_days is None else f"{ttl_days} дней."), + parse_mode="Markdown", + ) + return ConversationHandler.END + +share_channel_conv = ConversationHandler( + entry_points=[CommandHandler("share_channel", share_channel_start)], + states={ + SELECT_CHANNEL: [CallbackQueryHandler(select_channel, pattern="^sch_")], + CONFIRM_INVITE: [CallbackQueryHandler(confirm_invite)], + }, + fallbacks=[], +) diff --git a/main.py b/main.py index ff26b1d..b67104e 100644 --- a/main.py +++ b/main.py @@ -19,22 +19,46 @@ logging.getLogger("httpx").setLevel(logging.WARNING) logger = logging.getLogger(__name__) import asyncio - +from telegram.ext import CommandHandler +from sqlalchemy import select +from datetime import datetime +from db import AsyncSessionLocal +from models import ChannelAccess +from handlers.permissions import get_or_create_admin, token_hash async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): - session = AsyncSessionLocal() - user_id = update.effective_user.id if update.effective_user else None - result = await session.execute(Admin.__table__.select().where(Admin.tg_id == user_id)) - admin = result.first() if user_id else None - if not admin and user_id: - await session.execute(Admin.__table__.insert().values(tg_id=user_id)) - await session.commit() - if update.message: - await update.message.reply_text('Вы зарегистрированы как админ.') - else: - if update.message: - await update.message.reply_text('Вы уже зарегистрированы.') - await session.close() + args = (context.args or []) + if args and args[0].startswith("sch_"): + # формат: sch__ + try: + _, sid, token = args[0].split("_", 2) + invite_id = int(sid) + except Exception: + await update.message.reply_text("Неверная ссылка приглашения.") + return + + async with AsyncSessionLocal() as session: + me = await get_or_create_admin(session, update.effective_user.id) + res = await session.execute(select(ChannelAccess).where(ChannelAccess.id == invite_id)) + acc = res.scalar_one_or_none() + if not acc or acc.status != "pending": + await update.message.reply_text("Приглашение не найдено или уже активировано/отозвано.") + return + if acc.expires_at and acc.expires_at < datetime.utcnow(): + await update.message.reply_text("Срок действия приглашения истёк.") + return + if token_hash(token) != acc.token_hash: + await update.message.reply_text("Неверный токен приглашения.") + return + + acc.invited_admin_id = me.id + acc.accepted_at = datetime.utcnow() + acc.status = "active" + await session.commit() + + await update.message.reply_text("Доступ к каналу успешно активирован. Можно постить через /new_post.") + return + async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE): help_text = ( @@ -73,6 +97,8 @@ from handlers.group_buttons import group_buttons_conv from handlers.channel_buttons import channel_buttons_conv from handlers.edit_button import edit_button from handlers.del_button import del_button +from handlers.share_channel import share_channel_conv + @@ -93,6 +119,7 @@ def main(): application.add_handler(CommandHandler('edit_button', edit_button)) application.add_handler(CommandHandler('del_button', del_button)) application.add_handler(admin_panel_conv) + application.add_handler(share_channel_conv) import sys import asyncio if sys.platform.startswith('win'): diff --git a/models.py b/models.py index 30f8d60..9d59696 100644 --- a/models.py +++ b/models.py @@ -1,7 +1,36 @@ -from sqlalchemy import Column, Integer, String, ForeignKey, Text +from sqlalchemy import Column, Integer, String, ForeignKey, Text, DateTime, Boolean from sqlalchemy.orm import relationship from db import Base +from datetime import datetime +# Битовые флаги прав +SCOPE_POST = 1 # право постить +SCOPE_MANAGE_BTNS = 2 # право управлять кнопками (опционально) +SCOPE_SHARE = 4 # право делиться дальше (опционально) + +class ChannelAccess(Base): + __tablename__ = "channel_accesses" + id = Column(Integer, primary_key=True) + channel_id = Column(Integer, ForeignKey("channels.id"), nullable=False) + + # Кто выдал доступ (владелец/менеджер с SCOPE_SHARE) + invited_by_admin_id = Column(Integer, ForeignKey("admins.id"), nullable=False) + + # Кому выдан доступ (заполняется при активации, до активации = NULL) + invited_admin_id = Column(Integer, ForeignKey("admins.id"), nullable=True) + + # Безопасно: храним ХЭШ токена приглашения (сам токен не храним) + token_hash = Column(String, nullable=False) + + scopes = Column(Integer, default=SCOPE_POST, nullable=False) # битовая маска + status = Column(String, default="pending", nullable=False) # pending|active|revoked|expired + + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + accepted_at = Column(DateTime, nullable=True) + revoked_at = Column(DateTime, nullable=True) + expires_at = Column(DateTime, nullable=True) + + channel = relationship("Channel", foreign_keys=[channel_id]) class Admin(Base): __tablename__ = 'admins' id = Column(Integer, primary_key=True)