From b987031410c74a8ce627e13cce8492ed425c078e Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Sat, 6 Sep 2025 08:32:41 +0900 Subject: [PATCH 01/14] Emoji - compatible fix --- alembic/versions/69ef23ef1ed1_init.py | 0 .../versions/7506a3320699_channel_table.py | 0 alembic/versions/ff9722e468e8_models.py | 0 handlers/new_post.py | 344 ++++++++++++++---- update.sh | 0 5 files changed, 277 insertions(+), 67 deletions(-) create mode 100644 alembic/versions/69ef23ef1ed1_init.py create mode 100644 alembic/versions/7506a3320699_channel_table.py create mode 100644 alembic/versions/ff9722e468e8_models.py create mode 100644 update.sh diff --git a/alembic/versions/69ef23ef1ed1_init.py b/alembic/versions/69ef23ef1ed1_init.py new file mode 100644 index 0000000..e69de29 diff --git a/alembic/versions/7506a3320699_channel_table.py b/alembic/versions/7506a3320699_channel_table.py new file mode 100644 index 0000000..e69de29 diff --git a/alembic/versions/ff9722e468e8_models.py b/alembic/versions/ff9722e468e8_models.py new file mode 100644 index 0000000..e69de29 diff --git a/handlers/new_post.py b/handlers/new_post.py index ec6fd28..5dafc3d 100644 --- a/handlers/new_post.py +++ b/handlers/new_post.py @@ -1,104 +1,314 @@ -from telegram import Update, InputMediaPhoto, InlineKeyboardMarkup, InlineKeyboardButton -from telegram.ext import ContextTypes, ConversationHandler, MessageHandler, CommandHandler, filters, CallbackQueryHandler, ContextTypes +from typing import List, Optional + +from telegram import ( + Update, + InlineKeyboardMarkup, + InlineKeyboardButton, + MessageEntity, +) +from telegram.ext import ( + ContextTypes, + ConversationHandler, + MessageHandler, + CommandHandler, + CallbackQueryHandler, + filters, +) +from telegram.constants import MessageEntityType + +from sqlalchemy import select as sa_select from db import AsyncSessionLocal from models import Channel, Group, Button + SELECT_MEDIA, SELECT_TEXT, SELECT_TARGET = range(3) + +# ========== UTF-16 helpers ========== + +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) + return out + + +def _strip_none_entities(entities: Optional[List[MessageEntity]]) -> List[MessageEntity]: + """ + Фильтрация мусорных/пустых entity, чтобы не падать на сервере. + """ + 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 + cleaned.append(e) + 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 ========== + 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 update.message and hasattr(update.message, 'photo') and update.message.photo: - if context.user_data is None: - context.user_data = {} - context.user_data['photo'] = update.message.photo[-1].file_id - if update.message: - await update.message.reply_text('Введите текст поста или пересланное сообщение:') + 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("Введите текст поста или пересланное сообщение (можно с эмодзи/разметкой):") return SELECT_TEXT - return ConversationHandler.END + + # Сохраняем медиа (последний вариант — стикер) + 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("Введите текст поста или пересланное сообщение (можно с эмодзи/разметкой):") + return SELECT_TEXT + async def select_text(update: Update, context: ContextTypes.DEFAULT_TYPE): - if update.message: - if context.user_data is None: - context.user_data = {} - context.user_data['text'] = getattr(update.message, 'text', None) or getattr(update.message, 'caption', None) - from sqlalchemy import select - session = AsyncSessionLocal() - try: - channels_result = await session.execute(select(Channel)) - channels = channels_result.scalars().all() - groups_result = await session.execute(select(Group)) - groups = groups_result.scalars().all() - keyboard = [] - for c in channels: - keyboard.append([InlineKeyboardButton(f'Канал: {getattr(c, "name", str(c.name))}', callback_data=f'channel_{getattr(c, "id", str(c.id))}')]) - for g in groups: - keyboard.append([InlineKeyboardButton(f'Группа: {getattr(g, "name", str(g.name))}', callback_data=f'group_{getattr(g, "id", str(g.id))}')]) - reply_markup = InlineKeyboardMarkup(keyboard) - await update.message.reply_text('Выберите, куда отправить пост:', reply_markup=reply_markup) - return SELECT_TARGET - finally: - await session.close() - return ConversationHandler.END + 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 [] + + # Сохраняем исходные данные БЕЗ изменений + context.user_data["text"] = text + context.user_data["entities"] = entities + 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 + async with AsyncSessionLocal() as session: + channels = (await session.execute(sa_select(Channel))).scalars().all() + 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}')]) + for g in groups: + keyboard.append([InlineKeyboardButton(f'Группа: {g.name}', callback_data=f'group_{g.id}')]) + + 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: return ConversationHandler.END await query.answer() + data = query.data - session = AsyncSessionLocal() - try: + from sqlalchemy import select as sa_select + async with AsyncSessionLocal() as session: chat_id = None markup = None + if data and data.startswith('channel_'): - from sqlalchemy import select channel_id = int(data.split('_')[1]) - channel_result = await session.execute(select(Channel).where(Channel.id == channel_id)) - channel = channel_result.scalar_one_or_none() - buttons_result = await session.execute(select(Button).where(Button.channel_id == channel_id)) - buttons = buttons_result.scalars().all() + 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 = getattr(channel, 'link', None) + chat_id = channel.link if channel else None + elif data and data.startswith('group_'): - from sqlalchemy import select group_id = int(data.split('_')[1]) - group_result = await session.execute(select(Group).where(Group.id == group_id)) - group = group_result.scalar_one_or_none() - buttons_result = await session.execute(select(Button).where(Button.group_id == group_id)) - buttons = buttons_result.scalars().all() + 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 = getattr(group, 'link', None) - if chat_id: - chat_id = chat_id.strip() - if not (chat_id.startswith('@') or chat_id.startswith('-')): - await query.edit_message_text('Ошибка: ссылка должна быть username (@channel) или числовой ID (-100...)') - return ConversationHandler.END - try: - photo = context.user_data.get('photo') if context.user_data else None - if photo: - await context.bot.send_photo(chat_id=chat_id, photo=photo, caption=context.user_data.get('text') if context.user_data else None, reply_markup=markup) - await query.edit_message_text('Пост отправлен!') - else: - await query.edit_message_text('Ошибка: не выбрано фото для поста.') - except Exception as e: - await query.edit_message_text(f'Ошибка отправки поста: {e}') - finally: - await session.close() + chat_id = group.link if group else None + + if not chat_id: + await query.edit_message_text('Ошибка: объект не найден.') + return ConversationHandler.END + + chat_id = chat_id.strip() + if not (chat_id.startswith('@') or chat_id.startswith('-')): + 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 [] + + # Если это одно входящее сообщение без “сборки” из двух шагов — идеально копируем: + # (то есть пользователь НЕ загружал медиа в 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: + 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, + ) + await query.edit_message_text('Пост отправлен!') + return ConversationHandler.END + + # Иначе — отправляем собранный пост, но entities/emoji берём ИСХОДНЫЕ + 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) + 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) + 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) + 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) + 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) + 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) + sent = True + 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) + sent = True + else: + await context.bot.send_message(chat_id=chat_id, text=text, entities=entities, reply_markup=markup) + sent = True + + await query.edit_message_text('Пост отправлен!' if sent else 'Ошибка: не удалось отправить сообщение.') + + except Exception as e: + await query.edit_message_text(f'Ошибка отправки поста: {e}') + return ConversationHandler.END + + new_post_conv = ConversationHandler( - entry_points=[CommandHandler('new_post', new_post_start)], + entry_points=[CommandHandler("new_post", new_post_start)], states={ - SELECT_MEDIA: [MessageHandler(filters.PHOTO | filters.Document.IMAGE | filters.COMMAND, select_media)], - SELECT_TEXT: [MessageHandler(filters.TEXT | filters.FORWARDED, select_text)], + # Принимаем любые медиа + /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_TARGET: [CallbackQueryHandler(select_target)], }, fallbacks=[], - - ) +) diff --git a/update.sh b/update.sh new file mode 100644 index 0000000..e69de29 From 405e663da4d39cfdce0ab47f2e344ad71cf37f43 Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Sat, 6 Sep 2025 08:41:28 +0900 Subject: [PATCH 02/14] migrations fixes admin check-in --- .../versions/21c6fd6ac065_admins_checkin.py | 32 +++++++++++++++++++ alembic/versions/69ef23ef1ed1_init.py | 0 .../versions/7506a3320699_channel_table.py | 0 alembic/versions/ff9722e468e8_models.py | 0 handlers/new_post.py | 19 ++++++++--- 5 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 alembic/versions/21c6fd6ac065_admins_checkin.py delete mode 100644 alembic/versions/69ef23ef1ed1_init.py delete mode 100644 alembic/versions/7506a3320699_channel_table.py delete mode 100644 alembic/versions/ff9722e468e8_models.py diff --git a/alembic/versions/21c6fd6ac065_admins_checkin.py b/alembic/versions/21c6fd6ac065_admins_checkin.py new file mode 100644 index 0000000..ae14fe6 --- /dev/null +++ b/alembic/versions/21c6fd6ac065_admins_checkin.py @@ -0,0 +1,32 @@ +"""admins checkin + +Revision ID: 21c6fd6ac065 +Revises: eeb6744b9452 +Create Date: 2025-09-06 08:41:08.145822 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '21c6fd6ac065' +down_revision: Union[str, Sequence[str], None] = 'eeb6744b9452' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/alembic/versions/69ef23ef1ed1_init.py b/alembic/versions/69ef23ef1ed1_init.py deleted file mode 100644 index e69de29..0000000 diff --git a/alembic/versions/7506a3320699_channel_table.py b/alembic/versions/7506a3320699_channel_table.py deleted file mode 100644 index e69de29..0000000 diff --git a/alembic/versions/ff9722e468e8_models.py b/alembic/versions/ff9722e468e8_models.py deleted file mode 100644 index e69de29..0000000 diff --git a/handlers/new_post.py b/handlers/new_post.py index 5dafc3d..198f409 100644 --- a/handlers/new_post.py +++ b/handlers/new_post.py @@ -20,6 +20,7 @@ from sqlalchemy import select as sa_select from db import AsyncSessionLocal from models import Channel, Group, Button +from models import Admin SELECT_MEDIA, SELECT_TEXT, SELECT_TARGET = range(3) @@ -162,14 +163,24 @@ async def select_text(update: Update, context: ContextTypes.DEFAULT_TYPE): # Сохраняем исходные данные БЕЗ изменений context.user_data["text"] = text context.user_data["entities"] = entities - context.user_data["src_chat_id"] = update.effective_chat.id + 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_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: - channels = (await session.execute(sa_select(Channel))).scalars().all() - groups = (await session.execute(sa_select(Group))).scalars().all() + 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 [] keyboard = [] for c in channels: @@ -207,7 +218,7 @@ async def select_target(update: Update, context: ContextTypes.DEFAULT_TYPE): 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 not chat_id: + if chat_id is None: await query.edit_message_text('Ошибка: объект не найден.') return ConversationHandler.END From c6104455d81bb18ed3a17b1c0193b7dc2d0d3952 Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Sat, 6 Sep 2025 09:09:07 +0900 Subject: [PATCH 03/14] db creation fix --- alembic/env.py | 4 ++++ docker-compose.yml | 2 +- main.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/alembic/env.py b/alembic/env.py index d45cdf8..a1389e0 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -9,6 +9,10 @@ import os # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config +import os +db_url = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///db/bot.db") +db_url_sync = db_url.replace("sqlite+aiosqlite", "sqlite") # Alembic нужен sync-драйвер +config.set_main_option("sqlalchemy.url", db_url_sync) # Interpret the config file for Python logging. # This line sets up loggers basically. diff --git a/docker-compose.yml b/docker-compose.yml index 082c0c5..85106a5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,5 +4,5 @@ services: env_file: - .env volumes: - - ./bot.db:/app/bot.db + - ./db:/app/db restart: unless-stopped diff --git a/main.py b/main.py index c84a329..ff26b1d 100644 --- a/main.py +++ b/main.py @@ -80,7 +80,7 @@ def main(): if not TELEGRAM_TOKEN: print("Ошибка: TELEGRAM_TOKEN не найден в переменных окружения.") return - sync_to_async(init_db()) + # sync_to_async(init_db()) application = Application.builder().token(TELEGRAM_TOKEN).build() application.add_handler(CommandHandler('start', start)) application.add_handler(CommandHandler('help', help_command)) From 5c81aae29cae6ddcf889eead40962213ceacb6b3 Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Sat, 6 Sep 2025 10:57:10 +0900 Subject: [PATCH 04/14] 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) From a22ba094dbd49e2e35182e5efdbe354d12367aac Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Sat, 6 Sep 2025 12:27:17 +0900 Subject: [PATCH 05/14] inline keyboard fix --- handlers/new_post.py | 55 +++++++++++++++++++++++++++++++++----------- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/handlers/new_post.py b/handlers/new_post.py index bf128fa..a385295 100644 --- a/handlers/new_post.py +++ b/handlers/new_post.py @@ -15,6 +15,7 @@ from sqlalchemy import select as sa_select from db import AsyncSessionLocal from models import Channel, Group from .permissions import get_or_create_admin, list_channels_for_admin, has_scope_on_channel, SCOPE_POST +from models import Channel, Group, Button SELECT_MEDIA, SELECT_TEXT, SELECT_TARGET = range(3) @@ -161,12 +162,28 @@ async def select_target(update: Update, context: ContextTypes.DEFAULT_TYPE): return ConversationHandler.END channel = (await session.execute(sa_select(Channel).where(Channel.id == channel_id))).scalar_one_or_none() - chat_id = channel.link if channel else None + if not channel: + await query.edit_message_text("Канал не найден.") + return ConversationHandler.END + chat_id = channel.link + + # КНОПКИ ДЛЯ КАНАЛА + btns = (await session.execute(sa_select(Button).where(Button.channel_id == channel_id))).scalars().all() + if btns: + markup = InlineKeyboardMarkup([[InlineKeyboardButton(str(b.name), url=str(b.url))] for b in btns]) 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() - chat_id = group.link if group else None + if not group: + await query.edit_message_text("Группа не найдена.") + return ConversationHandler.END + chat_id = group.link + + # КНОПКИ ДЛЯ ГРУППЫ + btns = (await session.execute(sa_select(Button).where(Button.group_id == group_id))).scalars().all() + if btns: + markup = InlineKeyboardMarkup([[InlineKeyboardButton(str(b.name), url=str(b.url))] for b in btns]) if not chat_id: await query.edit_message_text('Ошибка: объект не найден.') @@ -181,11 +198,11 @@ async def select_target(update: Update, context: ContextTypes.DEFAULT_TYPE): text: str = ud.get("text", "") or "" entities: List[MessageEntity] = ud.get("entities", []) or [] - # санация перед отправкой + # санация перед отправкой (custom_emoji/UTF-16) entities = _strip_broken_entities(entities) entities = _split_custom_emoji_by_utf16(text, entities) - # 1) Пытаемся «бит-в-бит» копию + # 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: @@ -193,46 +210,55 @@ async def select_target(update: Update, context: ContextTypes.DEFAULT_TYPE): chat_id=chat_id, from_chat_id=ud["src_chat_id"], message_id=ud["src_msg_id"], + reply_markup=markup, # <— ВАЖНО ) await query.edit_message_text("Пост отправлен!") return ConversationHandler.END except BadRequest: - pass # упадем в fallback + pass # упадём в fallback - # 2) fallback — отправка с entities/caption_entities (без parse_mode) + # 2) fallback — отправка с entities/caption_entities и КЛАВИАТУРОЙ 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)) + caption=(text or None), caption_entities=(entities if text else None), + reply_markup=markup) 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)) + caption=(text or None), caption_entities=(entities if text else None), + reply_markup=markup) 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)) + caption=(text or None), caption_entities=(entities if text else None), + reply_markup=markup) 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)) + caption=(text or None), caption_entities=(entities if text else None), + reply_markup=markup) 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)) + caption=(text or None), caption_entities=(entities if text else None), + reply_markup=markup) 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)) + caption=(text or None), caption_entities=(entities if text else None), + reply_markup=markup) sent = True elif "sticker" in ud: - await context.bot.send_sticker(chat_id=chat_id, sticker=ud["sticker"]) + # Можно прикрепить клавиатуру и к стикеру (Telegram поддерживает reply_markup) + await context.bot.send_sticker(chat_id=chat_id, sticker=ud["sticker"], reply_markup=markup) + # Если есть текст — отправим вторым сообщением (кнопки уже в первом) if text: 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) + await context.bot.send_message(chat_id=chat_id, text=text, entities=entities, reply_markup=markup) sent = True await query.edit_message_text('Пост отправлен!' if sent else 'Ошибка: не удалось отправить сообщение.') @@ -241,6 +267,7 @@ async def select_target(update: Update, context: ContextTypes.DEFAULT_TYPE): return ConversationHandler.END + new_post_conv = ConversationHandler( entry_points=[CommandHandler("new_post", new_post_start)], states={ From 297af93fffcc61706ba56a90bce6309acc510b9c Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Sat, 6 Sep 2025 13:18:42 +0900 Subject: [PATCH 06/14] select_target -> disabled COPY using send_ --- handlers/new_post.py | 211 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 171 insertions(+), 40 deletions(-) diff --git a/handlers/new_post.py b/handlers/new_post.py index a385295..91234b3 100644 --- a/handlers/new_post.py +++ b/handlers/new_post.py @@ -140,6 +140,151 @@ async def select_text(update: Update, context: ContextTypes.DEFAULT_TYPE): 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: +# return ConversationHandler.END +# await query.answer() + +# data = query.data +# async with AsyncSessionLocal() as session: +# chat_id = None +# markup = None +# selected_kind = None # "channel" | "group" +# selected_title = None + +# if data and data.startswith('channel_'): +# selected_kind = "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() +# if not channel: +# await query.edit_message_text("Канал не найден.") +# return ConversationHandler.END +# chat_id = (channel.link or "").strip() +# selected_title = channel.name + +# # кнопки канала +# btns = (await session.execute(sa_select(Button).where(Button.channel_id == channel_id))).scalars().all() +# if btns: +# markup = InlineKeyboardMarkup( +# [[InlineKeyboardButton(str(b.name), url=str(b.url))] for b in btns] +# ) + +# elif data and data.startswith('group_'): +# selected_kind = "group" +# group_id = int(data.split('_')[1]) +# group = (await session.execute(sa_select(Group).where(Group.id == group_id))).scalar_one_or_none() +# if not group: +# await query.edit_message_text("Группа не найдена.") +# return ConversationHandler.END +# chat_id = (group.link or "").strip() +# selected_title = group.name + +# # кнопки группы +# btns = (await session.execute(sa_select(Button).where(Button.group_id == group_id))).scalars().all() +# if btns: +# markup = InlineKeyboardMarkup( +# [[InlineKeyboardButton(str(b.name), url=str(b.url))] for b in btns] +# ) + +# if not chat_id or not (chat_id.startswith('@') or chat_id.startswith('-')): +# await query.edit_message_text('Ошибка: ссылка должна быть username (@channel) или числовой ID (-100...)') +# return ConversationHandler.END + +# ud = context.user_data or {} +# text: str = ud.get("text", "") or "" +# entities: List[MessageEntity] = ud.get("entities", []) or [] + +# # санация для custom_emoji +# entities = _strip_broken_entities(entities) +# entities = _split_custom_emoji_by_utf16(text, entities) + +# # если есть клавиатура, ПРЕДПОЧИТАЕМ отправку "вручную" (а не copyMessage), +# # т.к. в некоторых кейсах клавиатура при копировании может не приклеиться +# prefer_manual_send = markup is not None + +# # определим, были ли “части медиа” +# has_media_parts = any(k in ud for k in ("photo","animation","video","document","audio","voice","sticker")) + +# try: +# if not prefer_manual_send and ud.get("src_chat_id") and ud.get("src_msg_id") and not has_media_parts: +# # 1) copyMessage без медиа и без клавиатуры — максимальная идентичность +# msg_id_obj = 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 # если вдруг есть, попробуем сразу +# ) +# # страховка: если клавиатуры нет/не приклеилась — прикрутим edit_message_reply_markup +# if markup and getattr(msg_id_obj, "message_id", None): +# try: +# await context.bot.edit_message_reply_markup( +# chat_id=chat_id, +# message_id=msg_id_obj.message_id, +# reply_markup=markup +# ) +# except Exception: +# pass +# await query.edit_message_text(f'Пост отправлен{" в: " + selected_title if selected_title else "!"}') +# return ConversationHandler.END + +# # 2) fallback / manual — ВСЕГДА прикладываем reply_markup +# 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) +# 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) +# 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) +# 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) +# 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) +# 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) +# sent = True +# elif "sticker" in ud: +# await context.bot.send_sticker(chat_id=chat_id, sticker=ud["sticker"], reply_markup=markup) +# if text: +# 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) +# sent = True + +# await query.edit_message_text(f'Пост отправлен{" в: " + selected_title if selected_title else "!"}' if sent else 'Ошибка: не удалось отправить сообщение.') + +# except BadRequest as e: +# await query.edit_message_text(f'Ошибка отправки поста: {e}') + +# return ConversationHandler.END + + async def select_target(update: Update, context: ContextTypes.DEFAULT_TYPE): query = update.callback_query if not query: @@ -150,14 +295,14 @@ async def select_target(update: Update, context: ContextTypes.DEFAULT_TYPE): async with AsyncSessionLocal() as session: chat_id = None markup = None + selected_title = None if data and data.startswith('channel_'): channel_id = int(data.split('_')[1]) - # ACL: проверить право постинга + # 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: + if not await has_scope_on_channel(session, me.id, channel_id, SCOPE_POST): await query.edit_message_text("У вас нет права постить в этот канал.") return ConversationHandler.END @@ -165,9 +310,10 @@ async def select_target(update: Update, context: ContextTypes.DEFAULT_TYPE): if not channel: await query.edit_message_text("Канал не найден.") return ConversationHandler.END - chat_id = channel.link + chat_id = (channel.link or "").strip() + selected_title = channel.name - # КНОПКИ ДЛЯ КАНАЛА + # КНОПКИ btns = (await session.execute(sa_select(Button).where(Button.channel_id == channel_id))).scalars().all() if btns: markup = InlineKeyboardMarkup([[InlineKeyboardButton(str(b.name), url=str(b.url))] for b in btns]) @@ -178,82 +324,66 @@ async def select_target(update: Update, context: ContextTypes.DEFAULT_TYPE): if not group: await query.edit_message_text("Группа не найдена.") return ConversationHandler.END - chat_id = group.link + chat_id = (group.link or "").strip() + selected_title = group.name - # КНОПКИ ДЛЯ ГРУППЫ btns = (await session.execute(sa_select(Button).where(Button.group_id == group_id))).scalars().all() if btns: markup = InlineKeyboardMarkup([[InlineKeyboardButton(str(b.name), url=str(b.url))] for b in btns]) - if not chat_id: - await query.edit_message_text('Ошибка: объект не найден.') - return ConversationHandler.END - - chat_id = chat_id.strip() - if not (chat_id.startswith('@') or chat_id.startswith('-')): - await query.edit_message_text('Ошибка: ссылка должна быть username (@channel) или числовой ID (-100...)') + if not chat_id or not (chat_id.startswith('@') or chat_id.startswith('-')): + await query.edit_message_text('Ошибка: ссылка должна быть @username или -100...') return ConversationHandler.END ud = context.user_data or {} text: str = ud.get("text", "") or "" entities: List[MessageEntity] = ud.get("entities", []) or [] - # санация перед отправкой (custom_emoji/UTF-16) + # санация custom_emoji/UTF-16 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, # <— ВАЖНО - ) - await query.edit_message_text("Пост отправлен!") - return ConversationHandler.END - except BadRequest: - pass # упадём в fallback - - # 2) fallback — отправка с entities/caption_entities и КЛАВИАТУРОЙ + # >>> ВАЖНО: НИКАКОГО copyMessage. Всегда manual send с reply_markup <<< 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), + caption=(text or None), + caption_entities=(entities if text else None), reply_markup=markup) 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), + caption=(text or None), + caption_entities=(entities if text else None), reply_markup=markup) 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), + caption=(text or None), + caption_entities=(entities if text else None), reply_markup=markup) 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), + caption=(text or None), + caption_entities=(entities if text else None), reply_markup=markup) 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), + caption=(text or None), + caption_entities=(entities if text else None), reply_markup=markup) 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), + caption=(text or None), + caption_entities=(entities if text else None), reply_markup=markup) sent = True elif "sticker" in ud: - # Можно прикрепить клавиатуру и к стикеру (Telegram поддерживает reply_markup) await context.bot.send_sticker(chat_id=chat_id, sticker=ud["sticker"], reply_markup=markup) - # Если есть текст — отправим вторым сообщением (кнопки уже в первом) if text: await context.bot.send_message(chat_id=chat_id, text=text, entities=entities) sent = True @@ -261,13 +391,14 @@ async def select_target(update: Update, context: ContextTypes.DEFAULT_TYPE): await context.bot.send_message(chat_id=chat_id, text=text, entities=entities, reply_markup=markup) sent = True - await query.edit_message_text('Пост отправлен!' if sent else 'Ошибка: не удалось отправить сообщение.') + await query.edit_message_text(f'Пост отправлен{(" в: " + selected_title) if selected_title else "!"}' if sent else 'Ошибка: не удалось отправить сообщение.') 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={ From ce8ec7db45faadd209fda9fab375e55a65fe6c39 Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Sat, 6 Sep 2025 13:21:59 +0900 Subject: [PATCH 07/14] script updated to revision migrations --- bin/update.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/bin/update.sh b/bin/update.sh index f0259fa..aa11eed 100755 --- a/bin/update.sh +++ b/bin/update.sh @@ -18,6 +18,7 @@ echo "[update.sh] Пересборка контейнера..." docker compose build --no-cache echo "[update.sh] Применение миграций Alembic..." +docker compose run --rm bot alembic revision --autogenerate -m "update" docker compose run --rm bot alembic upgrade head echo "[update.sh] Запуск контейнера..." From 2bcf07f6a9397d0b07819abb8078bc19c2c321d7 Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Sat, 6 Sep 2025 13:38:55 +0900 Subject: [PATCH 08/14] script update --- bin/update.sh | 98 ++++++++++++++---- handlers/new_post.py | 234 +++++++++---------------------------------- 2 files changed, 129 insertions(+), 203 deletions(-) diff --git a/bin/update.sh b/bin/update.sh index aa11eed..6f6056f 100755 --- a/bin/update.sh +++ b/bin/update.sh @@ -1,27 +1,91 @@ -#!/bin/bash -set -e +#!/usr/bin/env bash +set -Eeuo pipefail -echo "[update.sh] Проверка bot.db..." -if [ -d "bot.db" ]; then - echo "Удаляю папку bot.db..." - rm -rf bot.db +# === Настройки === +SERVICE="bot" # имя сервиса в docker-compose.yml +APP_DIR="/app" # рабочая директория в контейнере +HOST_DB_DIR="./db" # каталог БД на хосте +HOST_DB_FILE="./db/bot.db" # файл БД на хосте +DEFAULT_DB_URL="sqlite+aiosqlite:////app/db/bot.db" # единый путь БД внутри контейнера + +log() { echo -e "[update.sh] $*"; } + +# === Работаем из директории скрипта === +cd "$(dirname "${BASH_SOURCE[0]}")" + +# === Приводим БД к каталогу ./db/bot.db === +log "Проверка корректности БД (./db/bot.db)..." +mkdir -p "${HOST_DB_DIR}" + +# если раньше был конфликтный объект ./bot.db +if [[ -d "./bot.db" ]]; then + log "Найдена ПАПКА ./bot.db → удаляю, чтобы не конфликтовала с файлом БД." + rm -rf ./bot.db fi -if [ ! -f "bot.db" ]; then - echo "Создаю пустой файл bot.db..." - touch bot.db +if [[ -f "./bot.db" && ! -f "${HOST_DB_FILE}" ]]; then + log "Переношу старый файл ./bot.db в ${HOST_DB_FILE} ..." + mv ./bot.db "${HOST_DB_FILE}" +fi +if [[ ! -f "${HOST_DB_FILE}" ]]; then + log "Создаю пустой файл БД: ${HOST_DB_FILE}" + :> "${HOST_DB_FILE}" fi -echo "[update.sh] Получение свежего кода..." -git pull +# === .env: страхуем DATABASE_URL === +if [[ -f ".env" ]]; then + if ! grep -q '^DATABASE_URL=' .env; then + log "В .env не найден DATABASE_URL — дописываю с ${DEFAULT_DB_URL}" + echo "DATABASE_URL=${DEFAULT_DB_URL}" >> .env + fi +else + log "Файл .env не найден — создаю и прописываю DATABASE_URL=${DEFAULT_DB_URL}" + echo "DATABASE_URL=${DEFAULT_DB_URL}" > .env +fi -echo "[update.sh] Пересборка контейнера..." +# === Git pull === +log "Получение свежего кода (git pull --rebase --autostash)..." +git pull --rebase --autostash + +# === Пересборка образа === +log "Пересборка контейнера..." docker compose build --no-cache -echo "[update.sh] Применение миграций Alembic..." -docker compose run --rm bot alembic revision --autogenerate -m "update" -docker compose run --rm bot alembic upgrade head +# === (Опционально) создаём ревизию Alembic с комментарием === +MIG_MSG="${1-}" +if [[ -z "${MIG_MSG}" ]]; then + read -rp "[update.sh] Комментарий для новой миграции Alembic (Enter — пропустить создание ревизии): " MIG_MSG +fi -echo "[update.sh] Запуск контейнера..." +if [[ -n "${MIG_MSG}" ]]; then + log "Создание ревизии Alembic с комментарием: ${MIG_MSG}" + docker compose run --rm -T \ + -e MIG_MSG="${MIG_MSG}" \ + "${SERVICE}" sh -lc "cd '${APP_DIR}' && alembic revision --autogenerate -m \"\${MIG_MSG}\"" +else + log "Создание ревизии пропущено." +fi + +# === Применяем миграции === +log "Применение миграций Alembic (upgrade head)..." +docker compose run --rm -T "${SERVICE}" sh -lc "cd '${APP_DIR}' && alembic upgrade head" + +# === Запуск приложения === +log "Запуск контейнера в фоне..." docker compose up -d -echo "[update.sh] Готово!" +# === Мини-проверка окружения и таблиц === +log "Проверка DATABASE_URL внутри контейнера и списка таблиц..." +docker compose exec -T "${SERVICE}" sh -lc " + echo 'DATABASE_URL='\"\$DATABASE_URL\"; + if [ -f /app/db/bot.db ]; then + echo '[DB] /app/db/bot.db найден. Таблицы:'; + sqlite3 /app/db/bot.db '.tables' || true; + elif [ -f /db/bot.db ]; then + echo '[DB] /db/bot.db найден (проверь docker-compose volume!). Таблицы:'; + sqlite3 /db/bot.db '.tables' || true; + else + echo 'Файл БД не найден в стандартных путях /app/db/bot.db или /db/bot.db'; + fi +" + +log "Готово!" diff --git a/handlers/new_post.py b/handlers/new_post.py index 91234b3..701929b 100644 --- a/handlers/new_post.py +++ b/handlers/new_post.py @@ -140,149 +140,6 @@ async def select_text(update: Update, context: ContextTypes.DEFAULT_TYPE): 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: -# return ConversationHandler.END -# await query.answer() - -# data = query.data -# async with AsyncSessionLocal() as session: -# chat_id = None -# markup = None -# selected_kind = None # "channel" | "group" -# selected_title = None - -# if data and data.startswith('channel_'): -# selected_kind = "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() -# if not channel: -# await query.edit_message_text("Канал не найден.") -# return ConversationHandler.END -# chat_id = (channel.link or "").strip() -# selected_title = channel.name - -# # кнопки канала -# btns = (await session.execute(sa_select(Button).where(Button.channel_id == channel_id))).scalars().all() -# if btns: -# markup = InlineKeyboardMarkup( -# [[InlineKeyboardButton(str(b.name), url=str(b.url))] for b in btns] -# ) - -# elif data and data.startswith('group_'): -# selected_kind = "group" -# group_id = int(data.split('_')[1]) -# group = (await session.execute(sa_select(Group).where(Group.id == group_id))).scalar_one_or_none() -# if not group: -# await query.edit_message_text("Группа не найдена.") -# return ConversationHandler.END -# chat_id = (group.link or "").strip() -# selected_title = group.name - -# # кнопки группы -# btns = (await session.execute(sa_select(Button).where(Button.group_id == group_id))).scalars().all() -# if btns: -# markup = InlineKeyboardMarkup( -# [[InlineKeyboardButton(str(b.name), url=str(b.url))] for b in btns] -# ) - -# if not chat_id or not (chat_id.startswith('@') or chat_id.startswith('-')): -# await query.edit_message_text('Ошибка: ссылка должна быть username (@channel) или числовой ID (-100...)') -# return ConversationHandler.END - -# ud = context.user_data or {} -# text: str = ud.get("text", "") or "" -# entities: List[MessageEntity] = ud.get("entities", []) or [] - -# # санация для custom_emoji -# entities = _strip_broken_entities(entities) -# entities = _split_custom_emoji_by_utf16(text, entities) - -# # если есть клавиатура, ПРЕДПОЧИТАЕМ отправку "вручную" (а не copyMessage), -# # т.к. в некоторых кейсах клавиатура при копировании может не приклеиться -# prefer_manual_send = markup is not None - -# # определим, были ли “части медиа” -# has_media_parts = any(k in ud for k in ("photo","animation","video","document","audio","voice","sticker")) - -# try: -# if not prefer_manual_send and ud.get("src_chat_id") and ud.get("src_msg_id") and not has_media_parts: -# # 1) copyMessage без медиа и без клавиатуры — максимальная идентичность -# msg_id_obj = 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 # если вдруг есть, попробуем сразу -# ) -# # страховка: если клавиатуры нет/не приклеилась — прикрутим edit_message_reply_markup -# if markup and getattr(msg_id_obj, "message_id", None): -# try: -# await context.bot.edit_message_reply_markup( -# chat_id=chat_id, -# message_id=msg_id_obj.message_id, -# reply_markup=markup -# ) -# except Exception: -# pass -# await query.edit_message_text(f'Пост отправлен{" в: " + selected_title if selected_title else "!"}') -# return ConversationHandler.END - -# # 2) fallback / manual — ВСЕГДА прикладываем reply_markup -# 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) -# 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) -# 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) -# 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) -# 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) -# 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) -# sent = True -# elif "sticker" in ud: -# await context.bot.send_sticker(chat_id=chat_id, sticker=ud["sticker"], reply_markup=markup) -# if text: -# 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) -# sent = True - -# await query.edit_message_text(f'Пост отправлен{" в: " + selected_title if selected_title else "!"}' if sent else 'Ошибка: не удалось отправить сообщение.') - -# except BadRequest as e: -# await query.edit_message_text(f'Ошибка отправки поста: {e}') - -# return ConversationHandler.END async def select_target(update: Update, context: ContextTypes.DEFAULT_TYPE): @@ -296,11 +153,11 @@ async def select_target(update: Update, context: ContextTypes.DEFAULT_TYPE): chat_id = None markup = None selected_title = None + btns = [] if data and data.startswith('channel_'): channel_id = int(data.split('_')[1]) - # ACL me = await get_or_create_admin(session, update.effective_user.id) if not await has_scope_on_channel(session, me.id, channel_id, SCOPE_POST): await query.edit_message_text("У вас нет права постить в этот канал.") @@ -310,10 +167,10 @@ async def select_target(update: Update, context: ContextTypes.DEFAULT_TYPE): if not channel: await query.edit_message_text("Канал не найден.") return ConversationHandler.END + chat_id = (channel.link or "").strip() selected_title = channel.name - # КНОПКИ btns = (await session.execute(sa_select(Button).where(Button.channel_id == channel_id))).scalars().all() if btns: markup = InlineKeyboardMarkup([[InlineKeyboardButton(str(b.name), url=str(b.url))] for b in btns]) @@ -324,74 +181,79 @@ async def select_target(update: Update, context: ContextTypes.DEFAULT_TYPE): if not group: await query.edit_message_text("Группа не найдена.") return ConversationHandler.END + chat_id = (group.link or "").strip() selected_title = group.name btns = (await session.execute(sa_select(Button).where(Button.group_id == group_id))).scalars().all() if btns: - markup = InlineKeyboardMarkup([[InlineKeyboardButton(str(b.name), url=str(b.url))] for b in btns]) + markup = InlineKeyboardMarkup([[InlineKeyboardButton(str(b.name), url=str(b.url))] for b in btns]]) if not chat_id or not (chat_id.startswith('@') or chat_id.startswith('-')): - await query.edit_message_text('Ошибка: ссылка должна быть @username или -100...') + await query.edit_message_text('Ошибка: ссылка должна быть @username или -100…') return ConversationHandler.END + # DEBUG: покажем, сколько кнопок нашли и куда шлём + print(f"[DEBUG] send -> chat_id={chat_id} title={selected_title!r} buttons={len(btns)} has_markup={bool(markup)}") + ud = context.user_data or {} text: str = ud.get("text", "") or "" entities: List[MessageEntity] = ud.get("entities", []) or [] - - # санация custom_emoji/UTF-16 entities = _strip_broken_entities(entities) entities = _split_custom_emoji_by_utf16(text, entities) - # >>> ВАЖНО: НИКАКОГО copyMessage. Всегда manual send с reply_markup <<< try: - sent = False + sent_msg = None + + # ВСЕГДА ручная отправка (никакого copyMessage), чтобы приклеить reply_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), - reply_markup=markup) - sent = True + sent_msg = 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) 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) - sent = True + sent_msg = 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 "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) - sent = True + sent_msg = 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 "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) - sent = True + sent_msg = 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 "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) - sent = True + sent_msg = 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 "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) - sent = True + sent_msg = 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 "sticker" in ud: - await context.bot.send_sticker(chat_id=chat_id, sticker=ud["sticker"], reply_markup=markup) + sent_msg = await context.bot.send_sticker(chat_id=chat_id, sticker=ud["sticker"], reply_markup=markup) if text: 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) - sent = True + sent_msg = await context.bot.send_message(chat_id=chat_id, text=text, entities=entities, reply_markup=markup) + + # страховка: если вдруг Telegram проглотил клаву — доклеим через edit_message_reply_markup + if markup and getattr(sent_msg, "message_id", None): + try: + await context.bot.edit_message_reply_markup(chat_id=chat_id, + message_id=sent_msg.message_id, + reply_markup=markup) + except Exception: + pass + + await query.edit_message_text(f'Пост отправлен{(" в: " + selected_title) if selected_title else "!"}') - await query.edit_message_text(f'Пост отправлен{(" в: " + selected_title) if selected_title else "!"}' if sent else 'Ошибка: не удалось отправить сообщение.') except BadRequest as e: await query.edit_message_text(f'Ошибка отправки поста: {e}') From e8a2a2ebc704ad7c6d76f93140b49668ea788a0f Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Sat, 6 Sep 2025 13:44:07 +0900 Subject: [PATCH 09/14] sript update --- bin/update.sh | 106 +++++++++++++++++++++++++++----------------------- update.sh | 0 2 files changed, 57 insertions(+), 49 deletions(-) delete mode 100644 update.sh diff --git a/bin/update.sh b/bin/update.sh index 6f6056f..7705344 100755 --- a/bin/update.sh +++ b/bin/update.sh @@ -1,29 +1,28 @@ #!/usr/bin/env bash set -Eeuo pipefail -# === Настройки === -SERVICE="bot" # имя сервиса в docker-compose.yml -APP_DIR="/app" # рабочая директория в контейнере -HOST_DB_DIR="./db" # каталог БД на хосте -HOST_DB_FILE="./db/bot.db" # файл БД на хосте -DEFAULT_DB_URL="sqlite+aiosqlite:////app/db/bot.db" # единый путь БД внутри контейнера +SERVICE="bot" # имя сервиса в docker-compose +APP_DIR="/app" # рабочая директория в контейнере +HOST_DB_DIR="./db" # каталог БД на хосте +HOST_DB_FILE="./db/bot.db" # файл БД на хосте +DB_URL_DEFAULT="sqlite+aiosqlite:///db/bot.db" # единый путь БД в контейнере -log() { echo -e "[update.sh] $*"; } +log(){ echo -e "[update.sh] $*"; } +die(){ echo -e "[update.sh][ERROR] $*" >&2; exit 1; } -# === Работаем из директории скрипта === -cd "$(dirname "${BASH_SOURCE[0]}")" +cd "$(dirname "${BASH_SOURCE[0]}")/.." -# === Приводим БД к каталогу ./db/bot.db === -log "Проверка корректности БД (./db/bot.db)..." +# --- 0) Приводим БД к ./db/bot.db и .env к единому URL --- +log "Проверка каталога БД ${HOST_DB_DIR} ..." mkdir -p "${HOST_DB_DIR}" -# если раньше был конфликтный объект ./bot.db +# гашим древний конфликт ./bot.db (файл/папка) if [[ -d "./bot.db" ]]; then - log "Найдена ПАПКА ./bot.db → удаляю, чтобы не конфликтовала с файлом БД." + log "Удаляю конфликтующую ПАПКУ ./bot.db" rm -rf ./bot.db fi if [[ -f "./bot.db" && ! -f "${HOST_DB_FILE}" ]]; then - log "Переношу старый файл ./bot.db в ${HOST_DB_FILE} ..." + log "Переношу старый файл ./bot.db -> ${HOST_DB_FILE}" mv ./bot.db "${HOST_DB_FILE}" fi if [[ ! -f "${HOST_DB_FILE}" ]]; then @@ -31,61 +30,70 @@ if [[ ! -f "${HOST_DB_FILE}" ]]; then :> "${HOST_DB_FILE}" fi -# === .env: страхуем DATABASE_URL === -if [[ -f ".env" ]]; then +# .env: гарантируем DATABASE_URL +if [[ -f .env ]]; then if ! grep -q '^DATABASE_URL=' .env; then - log "В .env не найден DATABASE_URL — дописываю с ${DEFAULT_DB_URL}" - echo "DATABASE_URL=${DEFAULT_DB_URL}" >> .env + log "В .env не найден DATABASE_URL — дописываю ${DB_URL_DEFAULT}" + echo "DATABASE_URL=${DB_URL_DEFAULT}" >> .env + else + # аккуратно заменим на нужный абсолютный путь, если другой + sed -i "s|^DATABASE_URL=.*$|DATABASE_URL=${DB_URL_DEFAULT}|g" .env + log "DATABASE_URL в .env → ${DB_URL_DEFAULT}" fi else - log "Файл .env не найден — создаю и прописываю DATABASE_URL=${DEFAULT_DB_URL}" - echo "DATABASE_URL=${DEFAULT_DB_URL}" > .env + log "Создаю .env с DATABASE_URL=${DB_URL_DEFAULT}" + echo "DATABASE_URL=${DB_URL_DEFAULT}" > .env fi -# === Git pull === -log "Получение свежего кода (git pull --rebase --autostash)..." -git pull --rebase --autostash +# --- 1) git pull + build --- +log "git pull --rebase --autostash ..." +git pull --rebase --autostash || die "git pull не удался" -# === Пересборка образа === -log "Пересборка контейнера..." +log "Пересборка образа ..." docker compose build --no-cache -# === (Опционально) создаём ревизию Alembic с комментарием === +# --- 2) Функция безопасного апгрейда Alembic --- +safe_upgrade() { + log "alembic upgrade head ..." + if docker compose run --rm -T "${SERVICE}" sh -lc "cd '${APP_DIR}' && alembic upgrade head"; then + return 0 + fi + log "upgrade head не прошёл. Пытаюсь выровнять ревизии: alembic stamp head → upgrade head" + docker compose run --rm -T "${SERVICE}" sh -lc "cd '${APP_DIR}' && alembic stamp head" || die "alembic stamp head провалился" + docker compose run --rm -T "${SERVICE}" sh -lc "cd '${APP_DIR}' && alembic upgrade head" || die "alembic upgrade head провалился повторно" +} + +# --- 3) Сначала выравниваем цепочку миграций --- +safe_upgrade + +# --- 4) (Опционально) создаём новую ревизию с твоим комментарием --- MIG_MSG="${1-}" if [[ -z "${MIG_MSG}" ]]; then - read -rp "[update.sh] Комментарий для новой миграции Alembic (Enter — пропустить создание ревизии): " MIG_MSG + read -rp "[update.sh] Комментарий для новой миграции (Enter — пропустить): " MIG_MSG || true fi if [[ -n "${MIG_MSG}" ]]; then - log "Создание ревизии Alembic с комментарием: ${MIG_MSG}" - docker compose run --rm -T \ - -e MIG_MSG="${MIG_MSG}" \ - "${SERVICE}" sh -lc "cd '${APP_DIR}' && alembic revision --autogenerate -m \"\${MIG_MSG}\"" + log "Создаю ревизию Alembic с комментарием: ${MIG_MSG}" + docker compose run --rm -T "${SERVICE}" sh -lc "cd '${APP_DIR}' && alembic revision --autogenerate -m \"${MIG_MSG}\"" \ + || die "alembic revision --autogenerate не удался" + # применяем новую ревизию + safe_upgrade else log "Создание ревизии пропущено." fi -# === Применяем миграции === -log "Применение миграций Alembic (upgrade head)..." -docker compose run --rm -T "${SERVICE}" sh -lc "cd '${APP_DIR}' && alembic upgrade head" +# --- 5) Запуск сервиса и пост-проверки --- +log "Запускаю контейнер ..." +docker compose up -d || die "docker compose up -d не удался" -# === Запуск приложения === -log "Запуск контейнера в фоне..." -docker compose up -d - -# === Мини-проверка окружения и таблиц === -log "Проверка DATABASE_URL внутри контейнера и списка таблиц..." +log "Проверка переменных и таблиц внутри контейнера ..." docker compose exec -T "${SERVICE}" sh -lc " echo 'DATABASE_URL='\"\$DATABASE_URL\"; + cd '${APP_DIR}'; + echo 'Alembic HEADS:'; alembic heads -v || true; + echo 'Alembic CURRENT:'; alembic current -v || true; if [ -f /app/db/bot.db ]; then - echo '[DB] /app/db/bot.db найден. Таблицы:'; + echo 'Таблицы SQLite (./db/bot.db):'; sqlite3 /app/db/bot.db '.tables' || true; - elif [ -f /db/bot.db ]; then - echo '[DB] /db/bot.db найден (проверь docker-compose volume!). Таблицы:'; - sqlite3 /db/bot.db '.tables' || true; else - echo 'Файл БД не найден в стандартных путях /app/db/bot.db или /db/bot.db'; - fi -" - -log "Готово!" + echo 'Вним diff --git a/update.sh b/update.sh deleted file mode 100644 index e69de29..0000000 From 47ac64adec556ffbd5a0d9452ac4922cbe69b513 Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Sat, 6 Sep 2025 13:46:30 +0900 Subject: [PATCH 10/14] script update --- bin/update.sh | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/bin/update.sh b/bin/update.sh index 7705344..9655dac 100755 --- a/bin/update.sh +++ b/bin/update.sh @@ -5,7 +5,7 @@ SERVICE="bot" # имя сервиса в docker-compose APP_DIR="/app" # рабочая директория в контейнере HOST_DB_DIR="./db" # каталог БД на хосте HOST_DB_FILE="./db/bot.db" # файл БД на хосте -DB_URL_DEFAULT="sqlite+aiosqlite:///db/bot.db" # единый путь БД в контейнере +DB_URL_DEFAULT="sqlite+aiosqlite:////app/db/bot.db" # единый путь БД в контейнере log(){ echo -e "[update.sh] $*"; } die(){ echo -e "[update.sh][ERROR] $*" >&2; exit 1; } @@ -93,7 +93,11 @@ docker compose exec -T "${SERVICE}" sh -lc " echo 'Alembic HEADS:'; alembic heads -v || true; echo 'Alembic CURRENT:'; alembic current -v || true; if [ -f /app/db/bot.db ]; then - echo 'Таблицы SQLite (./db/bot.db):'; + echo 'Таблицы SQLite (/app/db/bot.db):'; sqlite3 /app/db/bot.db '.tables' || true; else - echo 'Вним + echo 'Внимание: /app/db/bot.db отсутствует!'; + fi +" + +log "Готово ✅" From 929d2face640bfc1e825926f262bcdfa9ecff6e3 Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Sat, 6 Sep 2025 13:54:24 +0900 Subject: [PATCH 11/14] select_target fixsync def select_target(update: Update, context: ContextTypes.DEFAULT_TYPE): query = update.callback_query if not query: return ConversationHandler.END await query.answer() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit data = (query.data or "") async with AsyncSessionLocal() as session: chat_id: str | None = None markup: InlineKeyboardMarkup | None = None selected_title: str | None = None btns = [] if data.startswith('channel_'): channel_id = int(data.split('_', 1)[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() if not channel: await query.edit_message_text("Канал не найден.") return ConversationHandler.END chat_id = (channel.link or "").strip() selected_title = channel.name # Кнопки канала btns = (await session.execute(sa_select(Button).where(Button.channel_id == channel_id))).scalars().all() if btns: rows = [[InlineKeyboardButton(str(b.name), url=str(b.url))] for b in btns] markup = InlineKeyboardMarkup(rows) elif data.startswith('group_'): group_id = int(data.split('_', 1)[1]) group = (await session.execute(sa_select(Group).where(Group.id == group_id))).scalar_one_or_none() if not group: await query.edit_message_text("Группа не найдена.") return ConversationHandler.END chat_id = (group.link or "").strip() selected_title = group.name # Кнопки группы btns = (await session.execute(sa_select(Button).where(Button.group_id == group_id))).scalars().all() if btns: rows = [[InlineKeyboardButton(str(b.name), url=str(b.url))] for b in btns] markup = InlineKeyboardMarkup(rows) if not chat_id or not (chat_id.startswith('@') or chat_id.startswith('-')): await query.edit_message_text('Ошибка: ссылка должна быть username (@channel) или числовой ID (-100...)') return ConversationHandler.END # DEBUG: сколько кнопок нашли и есть ли markup print(f"[DEBUG] send -> chat_id={chat_id} title={selected_title!r} buttons={len(btns)} has_markup={bool(markup)}") # Текст и entities (без parse_mode) ud = context.user_data or {} text: str = ud.get("text", "") or "" entities: List[MessageEntity] = ud.get("entities", []) or [] entities = _strip_broken_entities(entities) entities = _split_custom_emoji_by_utf16(text, entities) # Всегда ручная отправка (send_*), чтобы гарантированно приклеить inline-клавиатуру try: sent_msg = None if "photo" in ud: sent_msg = 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, ) elif "animation" in ud: sent_msg = 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 "video" in ud: sent_msg = 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 "document" in ud: sent_msg = 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 "audio" in ud: sent_msg = 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 "voice" in ud: sent_msg = 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 "sticker" in ud: sent_msg = await context.bot.send_sticker( chat_id=chat_id, sticker=ud["sticker"], reply_markup=markup, ) if text: await context.bot.send_message(chat_id=chat_id, text=text, entities=entities) else: sent_msg = await context.bot.send_message( chat_id=chat_id, text=text, entities=entities, reply_markup=markup, ) # Страховка: если вдруг Telegram проглотил клаву — доклеим её if markup and getattr(sent_msg, "message_id", None): t --- handlers/new_post.py | 130 +++++++++++++++++++++++++++---------------- 1 file changed, 82 insertions(+), 48 deletions(-) diff --git a/handlers/new_post.py b/handlers/new_post.py index 701929b..02cfb8d 100644 --- a/handlers/new_post.py +++ b/handlers/new_post.py @@ -140,26 +140,26 @@ async def select_text(update: Update, context: ContextTypes.DEFAULT_TYPE): 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: return ConversationHandler.END await query.answer() - data = query.data + data = (query.data or "") async with AsyncSessionLocal() as session: - chat_id = None - markup = None - selected_title = None + chat_id: str | None = None + markup: InlineKeyboardMarkup | None = None + selected_title: str | None = None btns = [] - if data and data.startswith('channel_'): - channel_id = int(data.split('_')[1]) + if data.startswith('channel_'): + channel_id = int(data.split('_', 1)[1]) + # ACL: право постинга в канал me = await get_or_create_admin(session, update.effective_user.id) - if not await has_scope_on_channel(session, me.id, channel_id, SCOPE_POST): + allowed = await has_scope_on_channel(session, me.id, channel_id, SCOPE_POST) + if not allowed: await query.edit_message_text("У вас нет права постить в этот канал.") return ConversationHandler.END @@ -171,12 +171,15 @@ async def select_target(update: Update, context: ContextTypes.DEFAULT_TYPE): chat_id = (channel.link or "").strip() selected_title = channel.name + # Кнопки канала btns = (await session.execute(sa_select(Button).where(Button.channel_id == channel_id))).scalars().all() if btns: - markup = InlineKeyboardMarkup([[InlineKeyboardButton(str(b.name), url=str(b.url))] for b in btns]) + rows = [[InlineKeyboardButton(str(b.name), url=str(b.url))] for b in btns] + markup = InlineKeyboardMarkup(rows) + + elif data.startswith('group_'): + group_id = int(data.split('_', 1)[1]) - 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() if not group: await query.edit_message_text("Группа не найдена.") @@ -185,75 +188,105 @@ async def select_target(update: Update, context: ContextTypes.DEFAULT_TYPE): chat_id = (group.link or "").strip() selected_title = group.name + # Кнопки группы btns = (await session.execute(sa_select(Button).where(Button.group_id == group_id))).scalars().all() if btns: - markup = InlineKeyboardMarkup([[InlineKeyboardButton(str(b.name), url=str(b.url))] for b in btns]]) + rows = [[InlineKeyboardButton(str(b.name), url=str(b.url))] for b in btns] + markup = InlineKeyboardMarkup(rows) if not chat_id or not (chat_id.startswith('@') or chat_id.startswith('-')): - await query.edit_message_text('Ошибка: ссылка должна быть @username или -100…') + await query.edit_message_text('Ошибка: ссылка должна быть username (@channel) или числовой ID (-100...)') return ConversationHandler.END - # DEBUG: покажем, сколько кнопок нашли и куда шлём + # DEBUG: сколько кнопок нашли и есть ли markup print(f"[DEBUG] send -> chat_id={chat_id} title={selected_title!r} buttons={len(btns)} has_markup={bool(markup)}") + # Текст и entities (без parse_mode) ud = context.user_data or {} text: str = ud.get("text", "") or "" entities: List[MessageEntity] = ud.get("entities", []) or [] entities = _strip_broken_entities(entities) entities = _split_custom_emoji_by_utf16(text, entities) + # Всегда ручная отправка (send_*), чтобы гарантированно приклеить inline-клавиатуру try: sent_msg = None - - # ВСЕГДА ручная отправка (никакого copyMessage), чтобы приклеить reply_markup if "photo" in ud: - sent_msg = 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) + sent_msg = 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, + ) elif "animation" in ud: - sent_msg = 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) + sent_msg = 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 "video" in ud: - sent_msg = 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) + sent_msg = 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 "document" in ud: - sent_msg = 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) + sent_msg = 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 "audio" in ud: - sent_msg = 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) + sent_msg = 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 "voice" in ud: - sent_msg = 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) + sent_msg = 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 "sticker" in ud: - sent_msg = await context.bot.send_sticker(chat_id=chat_id, sticker=ud["sticker"], reply_markup=markup) + sent_msg = await context.bot.send_sticker( + chat_id=chat_id, + sticker=ud["sticker"], + reply_markup=markup, + ) if text: await context.bot.send_message(chat_id=chat_id, text=text, entities=entities) else: - sent_msg = await context.bot.send_message(chat_id=chat_id, text=text, entities=entities, reply_markup=markup) + sent_msg = await context.bot.send_message( + chat_id=chat_id, + text=text, + entities=entities, + reply_markup=markup, + ) - # страховка: если вдруг Telegram проглотил клаву — доклеим через edit_message_reply_markup + # Страховка: если вдруг Telegram проглотил клаву — доклеим её if markup and getattr(sent_msg, "message_id", None): try: - await context.bot.edit_message_reply_markup(chat_id=chat_id, - message_id=sent_msg.message_id, - reply_markup=markup) + await context.bot.edit_message_reply_markup( + chat_id=chat_id, + message_id=sent_msg.message_id, + reply_markup=markup, + ) except Exception: pass await query.edit_message_text(f'Пост отправлен{(" в: " + selected_title) if selected_title else "!"}') - except BadRequest as e: await query.edit_message_text(f'Ошибка отправки поста: {e}') @@ -261,6 +294,7 @@ async def select_target(update: Update, context: ContextTypes.DEFAULT_TYPE): + new_post_conv = ConversationHandler( entry_points=[CommandHandler("new_post", new_post_start)], states={ From 18f91bbd40952c415d64cc1368e7d18376ef2666 Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Sat, 6 Sep 2025 14:04:18 +0900 Subject: [PATCH 12/14] script update --- bin/update.sh | 101 ++++++++++++++++++++++++++++------------ handlers/permissions.py | 38 +++++++-------- 2 files changed, 91 insertions(+), 48 deletions(-) diff --git a/bin/update.sh b/bin/update.sh index 9655dac..dcacb94 100755 --- a/bin/update.sh +++ b/bin/update.sh @@ -1,27 +1,38 @@ #!/usr/bin/env bash set -Eeuo pipefail -SERVICE="bot" # имя сервиса в docker-compose -APP_DIR="/app" # рабочая директория в контейнере -HOST_DB_DIR="./db" # каталог БД на хосте -HOST_DB_FILE="./db/bot.db" # файл БД на хосте -DB_URL_DEFAULT="sqlite+aiosqlite:////app/db/bot.db" # единый путь БД в контейнере +# === Настройки === +SERVICE="bot" # имя сервиса из docker-compose.yml +APP_DIR="/app" # рабочая директория кода в контейнере +HOST_DB_DIR="./db" # каталог БД на хосте +HOST_DB_FILE="./db/bot.db" # файл БД на хосте +DB_URL_DEFAULT="sqlite+aiosqlite:////app/db/bot.db" # ЕДИНЫЙ URL БД в контейнере log(){ echo -e "[update.sh] $*"; } die(){ echo -e "[update.sh][ERROR] $*" >&2; exit 1; } +# Запускать из корня репо, даже если скрипт лежит в bin/ cd "$(dirname "${BASH_SOURCE[0]}")/.." -# --- 0) Приводим БД к ./db/bot.db и .env к единому URL --- +# --- Утилиты для alembic в "смонтированном" контейнере (чтобы файлы миграций попадали в репо) --- +alembic_run_mounted() { + # использование: alembic_run_mounted "upgrade head" + docker compose run --rm -T \ + -v "$PWD":/app \ + -w /app \ + "${SERVICE}" sh -lc "alembic $*" +} + +# --- 0) Приводим БД и .env к единому виду --- log "Проверка каталога БД ${HOST_DB_DIR} ..." mkdir -p "${HOST_DB_DIR}" -# гашим древний конфликт ./bot.db (файл/папка) -if [[ -d "./bot.db" ]]; then +# Если в проекте остался прежний конфликтный объект ./bot.db — убираем/переносим +if [[ -d ./bot.db ]]; then log "Удаляю конфликтующую ПАПКУ ./bot.db" rm -rf ./bot.db fi -if [[ -f "./bot.db" && ! -f "${HOST_DB_FILE}" ]]; then +if [[ -f ./bot.db && ! -f "${HOST_DB_FILE}" ]]; then log "Переношу старый файл ./bot.db -> ${HOST_DB_FILE}" mv ./bot.db "${HOST_DB_FILE}" fi @@ -30,43 +41,64 @@ if [[ ! -f "${HOST_DB_FILE}" ]]; then :> "${HOST_DB_FILE}" fi -# .env: гарантируем DATABASE_URL +# .env: зафиксировать DATABASE_URL if [[ -f .env ]]; then - if ! grep -q '^DATABASE_URL=' .env; then - log "В .env не найден DATABASE_URL — дописываю ${DB_URL_DEFAULT}" - echo "DATABASE_URL=${DB_URL_DEFAULT}" >> .env - else - # аккуратно заменим на нужный абсолютный путь, если другой + if grep -q '^DATABASE_URL=' .env; then sed -i "s|^DATABASE_URL=.*$|DATABASE_URL=${DB_URL_DEFAULT}|g" .env log "DATABASE_URL в .env → ${DB_URL_DEFAULT}" + else + echo "DATABASE_URL=${DB_URL_DEFAULT}" >> .env + log "Добавил DATABASE_URL в .env → ${DB_URL_DEFAULT}" fi else - log "Создаю .env с DATABASE_URL=${DB_URL_DEFAULT}" echo "DATABASE_URL=${DB_URL_DEFAULT}" > .env + log "Создал .env с DATABASE_URL=${DB_URL_DEFAULT}" fi -# --- 1) git pull + build --- +# --- 1) git pull + сборка --- log "git pull --rebase --autostash ..." git pull --rebase --autostash || die "git pull не удался" log "Пересборка образа ..." -docker compose build --no-cache +docker compose build --no-cache || die "docker compose build не удался" -# --- 2) Функция безопасного апгрейда Alembic --- +# --- 2) Безопасный upgrade: выравниваем БД до HEAD; при «потере» ревизии чиним alembic_version --- safe_upgrade() { log "alembic upgrade head ..." - if docker compose run --rm -T "${SERVICE}" sh -lc "cd '${APP_DIR}' && alembic upgrade head"; then + set +e + UPG_LOG="$(alembic_run_mounted 'upgrade head' 2>&1)" + RC=$? + set -e + if [[ $RC -eq 0 ]]; then return 0 fi - log "upgrade head не прошёл. Пытаюсь выровнять ревизии: alembic stamp head → upgrade head" - docker compose run --rm -T "${SERVICE}" sh -lc "cd '${APP_DIR}' && alembic stamp head" || die "alembic stamp head провалился" - docker compose run --rm -T "${SERVICE}" sh -lc "cd '${APP_DIR}' && alembic upgrade head" || die "alembic upgrade head провалился повторно" + + echo "$UPG_LOG" + if grep -q "Can't locate revision identified by" <<< "$UPG_LOG"; then + log "Обнаружена «потерянная» ревизия. Автопочинка: подшиваю БД к актуальному HEAD ..." + # Узнаём актуальный HEAD из каталога миграций + set +e + HEADREV="$(alembic_run_mounted 'heads -v' | awk '/^Rev:/{print $2; exit}')" + set -e + [[ -n "${HEADREV:-}" ]] || die "Не удалось определить HEAD ревизию" + + # Переписываем alembic_version в файле БД (внутри контейнера сервиса) + docker compose run --rm -T \ + -v "$PWD":/app \ + -w /app \ + "${SERVICE}" sh -lc "sqlite3 /app/db/bot.db \"UPDATE alembic_version SET version_num='${HEADREV}';\" || true" + + # Повторяем апгрейд + alembic_run_mounted 'upgrade head' || die "Повторный upgrade head не удался" + else + die "alembic upgrade head не удался" + fi } -# --- 3) Сначала выравниваем цепочку миграций --- +# --- 3) Выравниваем миграции до текущего HEAD --- safe_upgrade -# --- 4) (Опционально) создаём новую ревизию с твоим комментарием --- +# --- 4) (опционально) создаём ревизию с комментарием и применяем её --- MIG_MSG="${1-}" if [[ -z "${MIG_MSG}" ]]; then read -rp "[update.sh] Комментарий для новой миграции (Enter — пропустить): " MIG_MSG || true @@ -74,9 +106,8 @@ fi if [[ -n "${MIG_MSG}" ]]; then log "Создаю ревизию Alembic с комментарием: ${MIG_MSG}" - docker compose run --rm -T "${SERVICE}" sh -lc "cd '${APP_DIR}' && alembic revision --autogenerate -m \"${MIG_MSG}\"" \ - || die "alembic revision --autogenerate не удался" - # применяем новую ревизию + alembic_run_mounted "revision --autogenerate -m \"${MIG_MSG}\"" || die "alembic revision --autogenerate не удался" + # Сразу применяем safe_upgrade else log "Создание ревизии пропущено." @@ -98,6 +129,18 @@ docker compose exec -T "${SERVICE}" sh -lc " else echo 'Внимание: /app/db/bot.db отсутствует!'; fi -" +" || true log "Готово ✅" +log "Проверка переменных и таблиц внутри контейнера ..." +docker compose exec -T "${SERVICE}" sh -lc " + echo 'DATABASE_URL='\"\$DATABASE_URL\"; + cd '${APP_DIR}'; + echo 'Alembic HEADS:'; alembic heads -v || true; + echo 'Alembic CURRENT:'; alembic current -v || true; + if [ -f /app/db/bot.db ]; then + echo 'Таблицы SQLite (/app/db/bot.db):'; + sqlite3 /app/db/bot.db '.tables' || true; + else + echo 'Внимание: /app/db/bot.db отсутствует!'; + fi \ No newline at end of file diff --git a/handlers/permissions.py b/handlers/permissions.py index fe3ca10..8f34b88 100644 --- a/handlers/permissions.py +++ b/handlers/permissions.py @@ -3,6 +3,9 @@ import hashlib, secrets from datetime import datetime, timedelta from sqlalchemy import select from models import Admin, Channel, ChannelAccess, SCOPE_POST, SCOPE_SHARE +from sqlalchemy.exc import OperationalError + + def make_token(nbytes: int = 9) -> str: # Короткий URL-safe токен (<= ~12-16 символов укладывается в /start payload) @@ -41,28 +44,25 @@ async def has_scope_on_channel(session, admin_id: int, channel_id: int, scope: i 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", + try: + 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: + rows = q2.scalars().all() + except OperationalError: + return owned # таблицы ещё нет — просто вернём свои каналы + + can_post_ids = {r.channel_id for r in rows if (r.scopes & SCOPE_POST)} + if not can_post_ids: return owned - - q3 = await session.execute(select(Channel).where(Channel.id.in_(access_map))) + q3 = await session.execute(select(Channel).where(Channel.id.in_(can_post_ids))) shared = q3.scalars().all() - - # Уникальный список (owner + shared) - all_channels = {c.id: c for c in owned} + d = {c.id: c for c in owned} for c in shared: - all_channels[c.id] = c - return list(all_channels.values()) + d[c.id] = c + return list(d.values()) From 506acfcde5029abd512ad51319a46cd35b1b6f04 Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Sat, 6 Sep 2025 14:56:46 +0900 Subject: [PATCH 13/14] sharing channels and more --- handlers/share_channel.py | 55 +++++++++++++++++++++++++++++---------- main.py | 12 ++++++++- 2 files changed, 52 insertions(+), 15 deletions(-) diff --git a/handlers/share_channel.py b/handlers/share_channel.py index 307b46b..82742cc 100644 --- a/handlers/share_channel.py +++ b/handlers/share_channel.py @@ -9,6 +9,17 @@ 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 +from telegram.error import BadRequest +import os +from telegram import InlineKeyboardMarkup, InlineKeyboardButton + +async def _get_bot_username(context: ContextTypes.DEFAULT_TYPE) -> str: + # кэшируем, чтобы не дёргать get_me() каждый раз + uname = context.application.bot.username + if uname: + return uname + me = await context.bot.get_me() + return me.username SELECT_CHANNEL, CONFIRM_INVITE = range(2) @@ -51,24 +62,32 @@ async def select_channel(update: Update, context: ContextTypes.DEFAULT_TYPE): async def confirm_invite(update: Update, context: ContextTypes.DEFAULT_TYPE): q = update.callback_query - if not q: return ConversationHandler.END + if not q: + return ConversationHandler.END + # Лёгкий ACK, чтобы исчез «часик» на кнопке await q.answer() data = q.data + + # --- настройки TTL (ничего не меняем в разметке, только сохраняем выбор) --- 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) + context.user_data["ttl_days"] = {"ttl_7": 7, "ttl_30": 30, "ttl_inf": None}[data] + # Нечего редактировать — markup не менялся. Просто остаёмся в состоянии. return CONFIRM_INVITE + + # --- права: сейчас фиксировано SCOPE_POST, разметку не меняем --- if data == "scope_post": - # пока фиксируем только POST - await q.edit_message_reply_markup(reply_markup=q.message.reply_markup) + # если позже сделаешь тумблеры прав — тут можно перестраивать клавиатуру + context.user_data["scopes"] = SCOPE_POST 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") + scopes = context.user_data.get("scopes", SCOPE_POST) async with AsyncSessionLocal() as session: me = await get_or_create_admin(session, update.effective_user.id) @@ -78,6 +97,7 @@ async def confirm_invite(update: Update, context: ContextTypes.DEFAULT_TYPE): expires_at = None if ttl_days: + from datetime import datetime, timedelta expires_at = datetime.utcnow() + timedelta(days=ttl_days) acc = ChannelAccess( @@ -94,14 +114,21 @@ async def confirm_invite(update: Update, context: ContextTypes.DEFAULT_TYPE): 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 + bot_username = await _get_bot_username(context) + deep_link = f"https://t.me/{bot_username}?start={payload}" + + # Кнопка для удобства + kb = InlineKeyboardMarkup([[InlineKeyboardButton("Открыть ссылку", url=deep_link)]]) + + await q.edit_message_text( + "Ссылка для предоставления доступа к каналу:\n" + f"`{deep_link}`\n\n" + "Передайте её коллеге. Срок действия — " + + ("не ограничен." if ttl_days is None else f"{ttl_days} дней."), + parse_mode="Markdown", + reply_markup=kb, + ) + return ConversationHandler.END share_channel_conv = ConversationHandler( entry_points=[CommandHandler("share_channel", share_channel_start)], diff --git a/main.py b/main.py index b67104e..7c9e8ee 100644 --- a/main.py +++ b/main.py @@ -99,6 +99,16 @@ from handlers.edit_button import edit_button from handlers.del_button import del_button from handlers.share_channel import share_channel_conv +import logging +from telegram.error import BadRequest +logger = logging.getLogger(__name__) + +async def on_error(update: Update, context: ContextTypes.DEFAULT_TYPE): + err = context.error + # подавляем шумные 400-е, когда контент/markup не меняется + if isinstance(err, BadRequest) and "Message is not modified" in str(err): + return + logger.exception("Unhandled exception", exc_info=err) @@ -130,7 +140,7 @@ def main(): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) application.run_polling() - + if __name__ == "__main__": main() From 73ce8b745d3a71c2a59ee9b3e4d2fa9a3c9a4e9f Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Sat, 6 Sep 2025 15:13:09 +0900 Subject: [PATCH 14/14] minor fix --- db/bot.db-journal | Bin 0 -> 4616 bytes handlers/share_channel.py | 2 +- update.sh | 0 3 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 db/bot.db-journal create mode 100644 update.sh diff --git a/db/bot.db-journal b/db/bot.db-journal new file mode 100644 index 0000000000000000000000000000000000000000..27c34214acb05d765c2f36f8e023a10b9e6c2d19 GIT binary patch literal 4616 zcmeIvy^a$x6a`>!*di&i&>|W{zoYx(dwrd53LeBB+i05=1yF1^EssPAZcLSmcyi_NdBzZc8*NhPfr5sA`EM zDKUz$hlQuZ;1#{}UbF#uhO_X&AIDUIk)M5L_%L%rVyhzO;fh#i(StS&o!VxH52B-raquzg6J_!{h;3 z;35$}DfZ9v&jk6g{;zk~*6rrZix3v>=k?=5P0~uq7NaLLtE8boD-vwsp847zh7uEd zs^EbmmGnn14hPoBz?Aj8$|)gTw^wIXYFYa2v?wM?Vn+*97w{p7p&@o5f