ACL, channel_charing
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
2025-09-06 10:57:10 +09:00
parent c6104455d8
commit 5c81aae29c
10 changed files with 657 additions and 275 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -1,8 +1,12 @@
# handlers/add_channel.py
from telegram import Update 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 db import AsyncSessionLocal
from models import Channel from models import Channel, Admin
INPUT_NAME, INPUT_LINK = range(2) INPUT_NAME, INPUT_LINK = range(2)
@@ -14,40 +18,60 @@ async def add_channel_start(update: Update, context: ContextTypes.DEFAULT_TYPE):
return INPUT_NAME return INPUT_NAME
async def input_channel_name(update: Update, context: ContextTypes.DEFAULT_TYPE): async def input_channel_name(update: Update, context: ContextTypes.DEFAULT_TYPE):
if context.user_data is None: if not update.message:
context.user_data = {} return ConversationHandler.END
text = update.message.text.strip() if update.message and update.message.text else '' name = (update.message.text or "").strip()
context.user_data['channel_name'] = text if not name:
if update.message: await update.message.reply_text("Имя не может быть пустым. Введите имя канала:")
await update.message.reply_text('Теперь отправьте ссылку на канал (должна начинаться с @):') return INPUT_NAME
context.user_data["channel_name"] = name
await update.message.reply_text('Отправьте ссылку на канал (формат "@username" или "-100..."):')
return INPUT_LINK return INPUT_LINK
async def input_channel_link(update: Update, context: ContextTypes.DEFAULT_TYPE): async def _get_or_create_admin(session, tg_id: int) -> Admin:
if context.user_data is None: res = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
context.user_data = {} admin = res.scalar_one_or_none()
link = update.message.text.strip() if update.message and update.message.text else '' if not admin:
if not link.startswith('@'): admin = Admin(tg_id=tg_id)
if update.message: session.add(admin)
await update.message.reply_text('Ошибка: ссылка на канал должна начинаться с @. Попробуйте снова.') await session.flush()
return INPUT_LINK return admin
context.user_data['channel_link'] = link
return await save_channel(update, context)
async def save_channel(update: Update, context: ContextTypes.DEFAULT_TYPE): async def input_channel_link(update: Update, context: ContextTypes.DEFAULT_TYPE):
if context.user_data is None: if not update.message:
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('Ошибка: не указано название или ссылка.')
return ConversationHandler.END 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: async with AsyncSessionLocal() as session:
channel = Channel(name=name, link=link) admin = await _get_or_create_admin(session, user.id)
session.add(channel)
await session.commit() # если канал уже есть — обновим имя и владельца
if update.message: existing_q = await session.execute(select(Channel).where(Channel.link == link))
await update.message.reply_text(f'Канал "{name}" добавлен.') 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 return ConversationHandler.END
add_channel_conv = ConversationHandler( add_channel_conv = ConversationHandler(

View File

@@ -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 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 db import AsyncSessionLocal
from models import Group from models import Group, Admin
INPUT_NAME, INPUT_LINK = range(2) INPUT_NAME, INPUT_LINK = range(2)
async def add_group_start(update: Update, context: ContextTypes.DEFAULT_TYPE): async def add_group_start(update: Update, context: ContextTypes.DEFAULT_TYPE):
if context.user_data is None: if context.user_data is None:
context.user_data = {} context.user_data = {}
if update.message: if update.message:
await update.message.reply_text('Введите имя группы:') await update.message.reply_text("Введите имя группы:")
return INPUT_NAME return INPUT_NAME
async def input_group_name(update: Update, context: ContextTypes.DEFAULT_TYPE): async def input_group_name(update: Update, context: ContextTypes.DEFAULT_TYPE):
if context.user_data is None: if not update.message:
context.user_data = {} return ConversationHandler.END
text = update.message.text.strip() if update.message and update.message.text else ''
context.user_data['group_name'] = text name = (update.message.text or "").strip()
if update.message: if not name:
await update.message.reply_text('Теперь отправьте chat_id группы (например, -1001234567890):') await update.message.reply_text("Имя не может быть пустым. Введите имя группы:")
return INPUT_NAME
context.user_data["group_name"] = name
await update.message.reply_text('Отправьте ссылку на группу (формат "@username" или "-100..."):')
return INPUT_LINK 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): async def _get_or_create_admin(session: AsyncSessionLocal, tg_id: int) -> Admin:
if context.user_data is None: res = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
context.user_data = {} admin = res.scalar_one_or_none()
name = context.user_data.get('group_name') if not admin:
link = context.user_data.get('group_link') admin = Admin(tg_id=tg_id)
if not name or not link: session.add(admin)
if update.message: # Чтобы получить admin.id до commit
await update.message.reply_text('Ошибка: не указано название или ссылка.') await session.flush()
return admin
async def input_group_link(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not update.message:
return ConversationHandler.END 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: async with AsyncSessionLocal() as session:
group = Group(name=name, link=link) # гарантируем наличие админа
session.add(group) admin = await _get_or_create_admin(session, user.id)
await session.commit()
if update.message: # проверка на существование группы по ссылке
await update.message.reply_text(f'Группа "{name}" добавлена.') 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 return ConversationHandler.END
add_group_conv = ConversationHandler( add_group_conv = ConversationHandler(
entry_points=[CommandHandler('add_group', add_group_start)], entry_points=[CommandHandler("add_group", add_group_start)],
states={ states={
INPUT_NAME: [MessageHandler(filters.TEXT & ~filters.COMMAND, input_group_name)], INPUT_NAME: [MessageHandler(filters.TEXT & ~filters.COMMAND, input_group_name)],
INPUT_LINK: [MessageHandler(filters.TEXT & ~filters.COMMAND, input_group_link)], INPUT_LINK: [MessageHandler(filters.TEXT & ~filters.COMMAND, input_group_link)],
}, },
fallbacks=[] fallbacks=[],
) )

View File

@@ -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 ( from telegram import (
Update, Update, InlineKeyboardMarkup, InlineKeyboardButton, MessageEntity, Bot
InlineKeyboardMarkup,
InlineKeyboardButton,
MessageEntity,
) )
from telegram.ext import ( from telegram.ext import (
ContextTypes, ContextTypes, ConversationHandler, MessageHandler, CommandHandler, CallbackQueryHandler, filters
ConversationHandler,
MessageHandler,
CommandHandler,
CallbackQueryHandler,
filters,
) )
from telegram.constants import MessageEntityType from telegram.constants import MessageEntityType
from telegram.error import BadRequest
from sqlalchemy import select as sa_select from sqlalchemy import select as sa_select
from db import AsyncSessionLocal from db import AsyncSessionLocal
from models import Channel, Group, Button from models import Channel, Group
from models import Admin 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) SELECT_MEDIA, SELECT_TEXT, SELECT_TARGET = range(3)
# ===== UTF-16 helpers (для custom_emoji) =====
# ========== UTF-16 helpers ==========
def _utf16_units_len(s: str) -> int: def _utf16_units_len(s: str) -> int:
"""Длина строки в UTF-16 code units (LE)."""
return len(s.encode("utf-16-le")) // 2 return len(s.encode("utf-16-le")) // 2
def _utf16_index_map(text: str) -> List[Tuple[int, int, str]]:
def _normalize_custom_emoji_entities(text: str, entities: List[MessageEntity]) -> List[MessageEntity]: out: List[Tuple[int, int, str]] = []
""" off = 0
Для entity типа CUSTOM_EMOJI Telegram ожидает offset/length в UTF-16. for ch in text:
Если вдруг кастомная эмодзи пришла с length > 1 (несколько символов), ln = _utf16_units_len(ch)
дробим на несколько entity, каждая длиной в реальное кол-во UTF-16 юнитов out.append((off, ln, ch))
(обычно 2 на эмодзи). off += ln
Остальные 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)
return out 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]: def _strip_broken_entities(entities: Optional[List[MessageEntity]]) -> List[MessageEntity]:
"""
Фильтрация мусорных/пустых entity, чтобы не падать на сервере.
"""
cleaned: List[MessageEntity] = [] cleaned: List[MessageEntity] = []
for e in entities or []: for e in entities or []:
if e.offset is None or e.length is None or e.offset < 0 or e.length < 1: if e.offset is None or e.length is None or e.offset < 0 or e.length < 1:
continue continue
if e.type == MessageEntityType.CUSTOM_EMOJI: if e.type == MessageEntityType.CUSTOM_EMOJI and not getattr(e, "custom_emoji_id", None):
if not getattr(e, "custom_emoji_id", None): continue
continue
cleaned.append(e) cleaned.append(e)
cleaned.sort(key=lambda x: x.offset)
return cleaned return cleaned
def _extract_text_and_entities(msg) -> tuple[str, List[MessageEntity], bool]: 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): if getattr(msg, "text", None):
return msg.text, (msg.entities or []), False return msg.text, (msg.entities or []), False
if getattr(msg, "caption", None): if getattr(msg, "caption", None):
return msg.caption, (msg.caption_entities or []), True return msg.caption, (msg.caption_entities or []), True
return "", [], False return "", [], False
# ===== Conversation =====
# ========== Conversation handlers ==========
async def new_post_start(update: Update, context: ContextTypes.DEFAULT_TYPE): async def new_post_start(update: Update, context: ContextTypes.DEFAULT_TYPE):
if update.message: if update.message:
await update.message.reply_text("Отправьте картинку/медиа для поста или пришлите /skip:") await update.message.reply_text("Отправьте медиа для поста или пришлите /skip:")
return SELECT_MEDIA return SELECT_MEDIA
return ConversationHandler.END return ConversationHandler.END
async def select_media(update: Update, context: ContextTypes.DEFAULT_TYPE): async def select_media(update: Update, context: ContextTypes.DEFAULT_TYPE):
if context.user_data is None: if context.user_data is None:
context.user_data = {} context.user_data = {}
if not update.message: if not update.message:
return ConversationHandler.END return ConversationHandler.END
msg = update.message msg = update.message
# Поддержка пропуска медиа
if msg.text and msg.text.strip().lower() == "/skip": if msg.text and msg.text.strip().lower() == "/skip":
await update.message.reply_text("Введите текст поста или пересланное сообщение (можно с эмодзи/разметкой):") await update.message.reply_text("Введите текст поста или перешлите сообщение (можно с кастом-эмодзи):")
return SELECT_TEXT return SELECT_TEXT
# Сохраняем медиа (последний вариант — стикер) if msg.photo: context.user_data["photo"] = msg.photo[-1].file_id
if msg.photo: elif msg.animation:context.user_data["animation"] = msg.animation.file_id
context.user_data["photo"] = msg.photo[-1].file_id elif msg.video: context.user_data["video"] = msg.video.file_id
elif msg.animation: elif msg.document: context.user_data["document"] = msg.document.file_id
context.user_data["animation"] = msg.animation.file_id elif msg.audio: context.user_data["audio"] = msg.audio.file_id
elif msg.video: elif msg.voice: context.user_data["voice"] = msg.voice.file_id
context.user_data["video"] = msg.video.file_id elif msg.sticker: context.user_data["sticker"] = msg.sticker.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 return SELECT_TEXT
async def select_text(update: Update, context: ContextTypes.DEFAULT_TYPE): async def select_text(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not update.message: if not update.message:
return ConversationHandler.END return ConversationHandler.END
if context.user_data is None: if context.user_data is None:
context.user_data = {} context.user_data = {}
msg = update.message msg = update.message
# Берём как есть: либо text/entities, либо caption/caption_entities text, entities, _ = _extract_text_and_entities(msg)
if msg.text: entities = _strip_broken_entities(entities)
text = msg.text entities = _split_custom_emoji_by_utf16(text, entities)
entities = msg.entities or []
else:
text = msg.caption or ""
entities = msg.caption_entities or []
# Сохраняем исходные данные БЕЗ изменений # сохраним исходник для copyMessage
context.user_data["text"] = text context.user_data["text"] = text
context.user_data["entities"] = entities 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
context.user_data["src_chat_id"] = update.effective_chat.id
else:
context.user_data["src_chat_id"] = None
context.user_data["src_msg_id"] = update.message.message_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: async with AsyncSessionLocal() as session:
admin_obj = (await session.execute(sa_select(Admin).where(Admin.tg_id == admin_tg_id))).scalar_one_or_none() me = await get_or_create_admin(session, update.effective_user.id)
admin_id = admin_obj.id if admin_obj else None channels = await list_channels_for_admin(session, me.id)
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 []
# группы оставляем без ACL (как было)
groups = (await session.execute(sa_select(Group))).scalars().all()
# если каналов нет — всё равно покажем группы
keyboard = [] keyboard = []
for c in channels: 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: for g in groups:
keyboard.append([InlineKeyboardButton(f'Группа: {g.name}', callback_data=f'group_{g.id}')]) 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)) await update.message.reply_text('Выберите, куда отправить пост:', reply_markup=InlineKeyboardMarkup(keyboard))
return SELECT_TARGET return SELECT_TARGET
async def select_target(update: Update, context: ContextTypes.DEFAULT_TYPE): async def select_target(update: Update, context: ContextTypes.DEFAULT_TYPE):
query = update.callback_query query = update.callback_query
if not query: if not query:
@@ -199,26 +146,29 @@ async def select_target(update: Update, context: ContextTypes.DEFAULT_TYPE):
await query.answer() await query.answer()
data = query.data data = query.data
from sqlalchemy import select as sa_select
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
chat_id = None chat_id = None
markup = None markup = None
if data and data.startswith('channel_'): if data and data.startswith('channel_'):
channel_id = int(data.split('_')[1]) 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() 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 chat_id = channel.link if channel else None
elif data and data.startswith('group_'): elif data and data.startswith('group_'):
group_id = int(data.split('_')[1]) group_id = int(data.split('_')[1])
group = (await session.execute(sa_select(Group).where(Group.id == group_id))).scalar_one_or_none() 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 chat_id = group.link if group else None
if chat_id is None: if not chat_id:
await query.edit_message_text('Ошибка: объект не найден.') await query.edit_message_text('Ошибка: объект не найден.')
return ConversationHandler.END 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...)') await query.edit_message_text('Ошибка: ссылка должна быть username (@channel) или числовой ID (-100...)')
return ConversationHandler.END return ConversationHandler.END
try: ud = context.user_data or {}
ud = context.user_data or {} text: str = ud.get("text", "") or ""
text = ud.get('text', '') or '' entities: List[MessageEntity] = ud.get("entities", []) or []
entities = ud.get('entities', []) or []
# Если это одно входящее сообщение без “сборки” из двух шагов — идеально копируем: # санация перед отправкой
# (то есть пользователь НЕ загружал медиа в SELECT_MEDIA) entities = _strip_broken_entities(entities)
has_media = any(k in ud for k in ('photo','animation','video','document','audio','voice','sticker')) entities = _split_custom_emoji_by_utf16(text, entities)
if ud.get('src_chat_id') and ud.get('src_msg_id') and not has_media:
# 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( await context.bot.copy_message(
chat_id=chat_id, chat_id=chat_id,
from_chat_id=ud['src_chat_id'], from_chat_id=ud["src_chat_id"],
message_id=ud['src_msg_id'], message_id=ud["src_msg_id"],
reply_markup=markup,
) )
await query.edit_message_text('Пост отправлен!') await query.edit_message_text("Пост отправлен!")
return ConversationHandler.END return ConversationHandler.END
except BadRequest:
pass # упадем в fallback
# Иначе — отправляем собранный пост, но entities/emoji берём ИСХОДНЫЕ # 2) fallback — отправка с entities/caption_entities (без parse_mode)
try:
sent = False sent = False
if 'photo' in ud: if "photo" in ud:
await context.bot.send_photo(chat_id=chat_id, photo=ud['photo'], await context.bot.send_photo(chat_id=chat_id, photo=ud["photo"],
caption=text or None, caption_entities=entities if text else None, caption=(text or None), caption_entities=(entities if text else None))
reply_markup=markup)
sent = True sent = True
elif 'animation' in ud: elif "animation" in ud:
await context.bot.send_animation(chat_id=chat_id, animation=ud['animation'], await context.bot.send_animation(chat_id=chat_id, animation=ud["animation"],
caption=text or None, caption_entities=entities if text else None, caption=(text or None), caption_entities=(entities if text else None))
reply_markup=markup)
sent = True sent = True
elif 'video' in ud: elif "video" in ud:
await context.bot.send_video(chat_id=chat_id, video=ud['video'], await context.bot.send_video(chat_id=chat_id, video=ud["video"],
caption=text or None, caption_entities=entities if text else None, caption=(text or None), caption_entities=(entities if text else None))
reply_markup=markup)
sent = True sent = True
elif 'document' in ud: elif "document" in ud:
await context.bot.send_document(chat_id=chat_id, document=ud['document'], await context.bot.send_document(chat_id=chat_id, document=ud["document"],
caption=text or None, caption_entities=entities if text else None, caption=(text or None), caption_entities=(entities if text else None))
reply_markup=markup)
sent = True sent = True
elif 'audio' in ud: elif "audio" in ud:
await context.bot.send_audio(chat_id=chat_id, audio=ud['audio'], await context.bot.send_audio(chat_id=chat_id, audio=ud["audio"],
caption=text or None, caption_entities=entities if text else None, caption=(text or None), caption_entities=(entities if text else None))
reply_markup=markup)
sent = True sent = True
elif 'voice' in ud: elif "voice" in ud:
await context.bot.send_voice(chat_id=chat_id, voice=ud['voice'], await context.bot.send_voice(chat_id=chat_id, voice=ud["voice"],
caption=text or None, caption_entities=entities if text else None, caption=(text or None), caption_entities=(entities if text else None))
reply_markup=markup)
sent = True sent = True
elif 'sticker' in ud: elif "sticker" in ud:
await context.bot.send_sticker(chat_id=chat_id, sticker=ud['sticker']) await context.bot.send_sticker(chat_id=chat_id, sticker=ud["sticker"])
if text: 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 sent = True
else: 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 sent = True
await query.edit_message_text('Пост отправлен!' if sent else 'Ошибка: не удалось отправить сообщение.') await query.edit_message_text('Пост отправлен!' if sent else 'Ошибка: не удалось отправить сообщение.')
except BadRequest as e:
except Exception as e:
await query.edit_message_text(f'Ошибка отправки поста: {e}') await query.edit_message_text(f'Ошибка отправки поста: {e}')
return ConversationHandler.END return ConversationHandler.END
new_post_conv = ConversationHandler( new_post_conv = ConversationHandler(
entry_points=[CommandHandler("new_post", new_post_start)], entry_points=[CommandHandler("new_post", new_post_start)],
states={ states={
# Принимаем любые медиа + /skip SELECT_MEDIA: [MessageHandler(
SELECT_MEDIA: [ filters.PHOTO | filters.ANIMATION | filters.VIDEO | filters.Document.ALL |
MessageHandler( filters.AUDIO | filters.VOICE | filters.Sticker.ALL | filters.COMMAND,
filters.PHOTO select_media
| filters.ANIMATION )],
| filters.VIDEO SELECT_TEXT: [MessageHandler(filters.TEXT | filters.FORWARDED | filters.CAPTION, select_text)],
| 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_TARGET: [CallbackQueryHandler(select_target)], SELECT_TARGET: [CallbackQueryHandler(select_target)],
}, },
fallbacks=[], fallbacks=[],

68
handlers/permissions.py Normal file
View File

@@ -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())

113
handlers/share_channel.py Normal file
View File

@@ -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/<YOUR_BOT_USERNAME>?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=[],
)

55
main.py
View File

@@ -19,22 +19,46 @@ logging.getLogger("httpx").setLevel(logging.WARNING)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
import asyncio 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): async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
session = AsyncSessionLocal() args = (context.args or [])
user_id = update.effective_user.id if update.effective_user else None if args and args[0].startswith("sch_"):
result = await session.execute(Admin.__table__.select().where(Admin.tg_id == user_id)) # формат: sch_<invite_id>_<token>
admin = result.first() if user_id else None try:
if not admin and user_id: _, sid, token = args[0].split("_", 2)
await session.execute(Admin.__table__.insert().values(tg_id=user_id)) invite_id = int(sid)
await session.commit() except Exception:
if update.message: await update.message.reply_text("Неверная ссылка приглашения.")
await update.message.reply_text('Вы зарегистрированы как админ.') return
else:
if update.message: async with AsyncSessionLocal() as session:
await update.message.reply_text('Вы уже зарегистрированы.') me = await get_or_create_admin(session, update.effective_user.id)
await session.close() 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): async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
help_text = ( help_text = (
@@ -73,6 +97,8 @@ from handlers.group_buttons import group_buttons_conv
from handlers.channel_buttons import channel_buttons_conv from handlers.channel_buttons import channel_buttons_conv
from handlers.edit_button import edit_button from handlers.edit_button import edit_button
from handlers.del_button import del_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('edit_button', edit_button))
application.add_handler(CommandHandler('del_button', del_button)) application.add_handler(CommandHandler('del_button', del_button))
application.add_handler(admin_panel_conv) application.add_handler(admin_panel_conv)
application.add_handler(share_channel_conv)
import sys import sys
import asyncio import asyncio
if sys.platform.startswith('win'): if sys.platform.startswith('win'):

View File

@@ -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 sqlalchemy.orm import relationship
from db import Base 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): class Admin(Base):
__tablename__ = 'admins' __tablename__ = 'admins'
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)