This commit is contained in:
28
alembic/versions/50652f5156d8_channel_accesses.py
Normal file
28
alembic/versions/50652f5156d8_channel_accesses.py
Normal 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
|
||||
28
alembic/versions/96a65ea5f555_channel_accesses.py
Normal file
28
alembic/versions/96a65ea5f555_channel_accesses.py
Normal 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
|
||||
28
alembic/versions/ae94c53e7343_channel_accesses.py
Normal file
28
alembic/versions/ae94c53e7343_channel_accesses.py
Normal 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
|
||||
@@ -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(
|
||||
|
||||
@@ -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=[],
|
||||
)
|
||||
|
||||
@@ -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=[],
|
||||
|
||||
68
handlers/permissions.py
Normal file
68
handlers/permissions.py
Normal 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
113
handlers/share_channel.py
Normal 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
55
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_<invite_id>_<token>
|
||||
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'):
|
||||
|
||||
31
models.py
31
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)
|
||||
|
||||
Reference in New Issue
Block a user