From d17f0f5507882c0b63e0c859c2bada9b1c19c605 Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Fri, 5 Sep 2025 12:04:58 +0900 Subject: [PATCH 01/19] async refactor & docker deploy environment --- Dockerfile | 3 +- db.py | 49 +++++++++++---- docker-compose.yml | 1 - handlers/add_button.py | 95 +++++++++++++++++------------ handlers/add_channel.py | 15 ++--- handlers/add_group.py | 8 ++- handlers/channel_buttons.py | 57 +++++++++++------- handlers/del_button.py | 30 ++++++---- handlers/edit_button.py | 28 +++++---- handlers/group_buttons.py | 55 ++++++++++------- handlers/new_post.py | 115 +++++++++++++++++++++++------------- init_db.py | 5 ++ main.py | 81 ++++++++++++++++++++----- models.py | 5 +- requirements.txt | 1 + 15 files changed, 361 insertions(+), 187 deletions(-) create mode 100644 init_db.py diff --git a/Dockerfile b/Dockerfile index ab60e48..2a24d53 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,6 @@ FROM python:3.11-slim WORKDIR /app COPY . /app -RUN pip install --no-cache-dir python-telegram-bot sqlalchemy python-dotenv +RUN apt update && apt install -y gcc && apt install -y sqlite3 +RUN pip install --no-cache-dir -r requirements.txt CMD ["python", "main.py"] diff --git a/db.py b/db.py index 9f68878..9d438fa 100644 --- a/db.py +++ b/db.py @@ -1,13 +1,42 @@ -import os -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker -from models import Base from dotenv import load_dotenv - load_dotenv() -DATABASE_URL = os.getenv('DATABASE_URL', 'sqlite:///bot.db') -engine = create_engine(DATABASE_URL, echo=True) -SessionLocal = sessionmaker(bind=engine) +import os +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.orm import declarative_base +from sqlalchemy.ext.asyncio import async_sessionmaker -def init_db(): - Base.metadata.create_all(engine) +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///bot.db") + +if DATABASE_URL.startswith("sqlite+aiosqlite:///"): + db_path = DATABASE_URL.replace("sqlite+aiosqlite:///", "") + abs_db_path = os.path.abspath(db_path) + db_dir = os.path.dirname(abs_db_path) + if db_dir and not os.path.exists(db_dir): + os.makedirs(db_dir, exist_ok=True) + +engine = create_async_engine(DATABASE_URL, future=True, echo=False) +AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False) +Base = declarative_base() + +async def init_db(): + print(f'База данных: {DATABASE_URL}') + need_create = False + if DATABASE_URL.startswith("sqlite+aiosqlite:///"): + db_path = DATABASE_URL.replace("sqlite+aiosqlite:///", "") + abs_db_path = os.path.abspath(db_path) + print(f"Абсолютный путь к базе данных: {abs_db_path}") + if not os.path.exists(abs_db_path): + print("Файл базы данных отсутствует, будет создан.") + need_create = True + else: + print(f"База данных: {DATABASE_URL}") + # Для других СУБД всегда пытаемся создать таблицы + need_create = True + + if need_create: + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + tables = Base.metadata.tables.keys() + print(f"Созданы таблицы: {', '.join(tables)}") + else: + print("База данных уже существует, создание таблиц пропущено.") \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 8001b78..082c0c5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -version: '3.8' services: bot: build: . diff --git a/handlers/add_button.py b/handlers/add_button.py index 87a5922..e30319e 100644 --- a/handlers/add_button.py +++ b/handlers/add_button.py @@ -1,58 +1,76 @@ -from telegram import Update +from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup from telegram.ext import ContextTypes, ConversationHandler, CommandHandler, CallbackQueryHandler, MessageHandler, filters -from db import SessionLocal +from db import AsyncSessionLocal from models import Channel, Group, Button SELECT_TARGET, INPUT_NAME, INPUT_URL = range(3) async def add_button_start(update: Update, context: ContextTypes.DEFAULT_TYPE): # Если выбран канал или группа уже сохранены — сразу переход к названию - if context.user_data.get('channel_id'): - context.user_data['target'] = f"channel_{context.user_data['channel_id']}" - await update.message.reply_text('Введите название кнопки:') + if context.user_data is None: + context.user_data = {} + user_data = context.user_data + if user_data.get('channel_id'): + context.user_data['target'] = f"channel_{user_data['channel_id']}" + if update.message: + await update.message.reply_text('Введите название кнопки:') return INPUT_NAME - elif context.user_data.get('group_id'): - context.user_data['target'] = f"group_{context.user_data['group_id']}" - await update.message.reply_text('Введите название кнопки:') + elif user_data.get('group_id'): + context.user_data['target'] = f"group_{user_data['group_id']}" + if update.message: + await update.message.reply_text('Введите название кнопки:') return INPUT_NAME # Если нет — стандартный выбор - session = SessionLocal() - channels = session.query(Channel).all() - groups = session.query(Group).all() - session.close() + from sqlalchemy import select + async with AsyncSessionLocal() as session: + result_channels = await session.execute(select(Channel)) + channels = result_channels.scalars().all() + result_groups = await session.execute(select(Group)) + groups = result_groups.scalars().all() keyboard = [] for c in channels: - keyboard.append([{'text': f'Канал: {c.name}', 'callback_data': f'channel_{c.id}'}]) + 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([{'text': f'Группа: {g.name}', 'callback_data': f'group_{g.id}'}]) + keyboard.append([InlineKeyboardButton(f'Группа: {getattr(g, "name", str(g.name))}', callback_data=f'group_{getattr(g, "id", str(g.id))}')]) if not keyboard: - await update.message.reply_text('Нет каналов или групп для добавления кнопки.') + if update.message: + await update.message.reply_text('Нет каналов или групп для добавления кнопки.') return ConversationHandler.END - await update.message.reply_text('Выберите канал или группу:', reply_markup=None) + if update.message: + await update.message.reply_text('Выберите канал или группу:', reply_markup=InlineKeyboardMarkup(keyboard)) context.user_data['keyboard'] = keyboard return SELECT_TARGET async def select_target(update: Update, context: ContextTypes.DEFAULT_TYPE): + if context.user_data is None: + context.user_data = {} query = update.callback_query - await query.answer() - data = query.data - context.user_data['target'] = data - await query.edit_message_text('Введите название кнопки:') - return INPUT_NAME + if query: + await query.answer() + data = query.data + context.user_data['target'] = data + await query.edit_message_text('Введите название кнопки:') + return INPUT_NAME + return ConversationHandler.END async def input_name(update: Update, context: ContextTypes.DEFAULT_TYPE): - context.user_data['button_name'] = update.message.text - await update.message.reply_text('Введите ссылку для кнопки:') - return INPUT_URL + if context.user_data is None: + context.user_data = {} + if update.message and hasattr(update.message, 'text'): + context.user_data['button_name'] = update.message.text + await update.message.reply_text('Введите ссылку для кнопки:') + return INPUT_URL + return ConversationHandler.END async def input_url(update: Update, context: ContextTypes.DEFAULT_TYPE): - url = update.message.text - name = context.user_data.get('button_name') - target = context.user_data.get('target') - if not target or ('_' not in target): - await update.message.reply_text('Ошибка: не выбран канал или группа. Попробуйте снова.') + url = update.message.text if update.message and hasattr(update.message, 'text') else None + name = context.user_data.get('button_name') if context.user_data else None + target = context.user_data.get('target') if context.user_data else None + if not url or not name or not target or ('_' not in target): + if update.message: + await update.message.reply_text('Ошибка: не выбран канал или группа. Попробуйте снова.') return ConversationHandler.END - session = SessionLocal() + session = AsyncSessionLocal() try: type_, obj_id = target.split('_', 1) obj_id = int(obj_id) @@ -61,16 +79,19 @@ async def input_url(update: Update, context: ContextTypes.DEFAULT_TYPE): elif type_ == 'group': button = Button(name=name, url=url, group_id=obj_id) else: - await update.message.reply_text('Ошибка: неверный тип объекта.') - session.close() + if update.message: + await update.message.reply_text('Ошибка: неверный тип объекта.') + await session.close() return ConversationHandler.END session.add(button) - session.commit() - await update.message.reply_text('Кнопка добавлена.') + await session.commit() + if update.message: + await update.message.reply_text('Кнопка добавлена.') except Exception as e: - await update.message.reply_text(f'Ошибка при добавлении кнопки: {e}') + if update.message: + await update.message.reply_text(f'Ошибка при добавлении кнопки: {e}') finally: - session.close() + await session.close() return ConversationHandler.END add_button_conv = ConversationHandler( @@ -81,4 +102,4 @@ add_button_conv = ConversationHandler( INPUT_URL: [MessageHandler(filters.TEXT & ~filters.COMMAND, input_url)], }, fallbacks=[] -) + ) diff --git a/handlers/add_channel.py b/handlers/add_channel.py index a05e1d2..505cdd8 100644 --- a/handlers/add_channel.py +++ b/handlers/add_channel.py @@ -1,17 +1,18 @@ from telegram import Update from telegram.ext import ContextTypes -from db import SessionLocal +from db import AsyncSessionLocal from models import Channel async def add_channel(update: Update, context: ContextTypes.DEFAULT_TYPE): - args = context.args + args = context.args or [] + if update.message is None: + return if len(args) < 2: await update.message.reply_text('Используйте: /add_channel <название> <ссылка>') return name, link = args[0], args[1] - session = SessionLocal() - channel = Channel(name=name, link=link) - session.add(channel) - session.commit() - session.close() + async with AsyncSessionLocal() as session: + channel = Channel(name=name, link=link) + session.add(channel) + await session.commit() await update.message.reply_text(f'Канал "{name}" добавлен.') diff --git a/handlers/add_group.py b/handlers/add_group.py index 7a3d848..8ecfe46 100644 --- a/handlers/add_group.py +++ b/handlers/add_group.py @@ -1,15 +1,17 @@ from telegram import Update from telegram.ext import ContextTypes -from db import SessionLocal +from db import AsyncSessionLocal from models import Group async def add_group(update: Update, context: ContextTypes.DEFAULT_TYPE): - args = context.args + args = context.args or [] + if update.message is None: + return if len(args) < 2: await update.message.reply_text('Используйте: /add_group <название> <ссылка>') return name, link = args[0], args[1] - session = SessionLocal() + session = AsyncSessionLocal() group = Group(name=name, link=link) session.add(group) session.commit() diff --git a/handlers/channel_buttons.py b/handlers/channel_buttons.py index 7c0f3a2..6b641a6 100644 --- a/handlers/channel_buttons.py +++ b/handlers/channel_buttons.py @@ -1,34 +1,47 @@ from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup from telegram.ext import CommandHandler, CallbackQueryHandler, ConversationHandler, ContextTypes -from db import SessionLocal +from db import AsyncSessionLocal from models import Channel, Button SELECT_CHANNEL, MANAGE_BUTTONS = range(2) async def channel_buttons_start(update: Update, context: ContextTypes.DEFAULT_TYPE): - session = SessionLocal() - channels = session.query(Channel).all() - session.close() - keyboard = [[InlineKeyboardButton(c.name, callback_data=str(c.id))] for c in channels] - await update.message.reply_text( - "Выберите канал для настройки клавиатуры:", - reply_markup=InlineKeyboardMarkup(keyboard) - ) - return SELECT_CHANNEL + from sqlalchemy import select + session = AsyncSessionLocal() + try: + channels_result = await session.execute(select(Channel)) + channels = channels_result.scalars().all() + keyboard = [[InlineKeyboardButton(f'{getattr(c, "name", str(c.name))}', callback_data=str(getattr(c, "id", str(c.id))))] for c in channels] + if update.message: + await update.message.reply_text( + "Выберите канал для настройки клавиатуры:", + reply_markup=InlineKeyboardMarkup(keyboard) + ) + return SELECT_CHANNEL + return ConversationHandler.END + finally: + await session.close() async def select_channel(update: Update, context: ContextTypes.DEFAULT_TYPE): query = update.callback_query - await query.answer() - channel_id = int(query.data) - context.user_data['channel_id'] = channel_id - session = SessionLocal() - buttons = session.query(Button).filter_by(channel_id=channel_id).all() - session.close() - text = "Кнопки этого канала:\n" - for b in buttons: - text += f"- {b.name}: {b.url}\n" - text += "\nДобавить новую кнопку: /add_button\nУдалить: /del_button <название>" - await query.edit_message_text(text) + if query and query.data: + await query.answer() + channel_id = int(query.data) + if context.user_data is None: + context.user_data = {} + context.user_data['channel_id'] = channel_id + from sqlalchemy import select + session = AsyncSessionLocal() + try: + buttons_result = await session.execute(select(Button).where(Button.channel_id == channel_id)) + buttons = buttons_result.scalars().all() + text = "Кнопки этого канала:\n" + for b in buttons: + text += f"- {b.name}: {b.url}\n" + text += "\nДобавить новую кнопку: /add_button\nУдалить: /del_button <название>" + await query.edit_message_text(text) + finally: + await session.close() return ConversationHandler.END channel_buttons_conv = ConversationHandler( @@ -37,4 +50,4 @@ channel_buttons_conv = ConversationHandler( SELECT_CHANNEL: [CallbackQueryHandler(select_channel)], }, fallbacks=[] -) + ) diff --git a/handlers/del_button.py b/handlers/del_button.py index c2055bc..0881dd3 100644 --- a/handlers/del_button.py +++ b/handlers/del_button.py @@ -1,21 +1,27 @@ from telegram import Update from telegram.ext import CommandHandler, ContextTypes -from db import SessionLocal +from db import AsyncSessionLocal from models import Button async def del_button(update: Update, context: ContextTypes.DEFAULT_TYPE): args = context.args if not args: - await update.message.reply_text('Используйте: /del_button <название>') + if update.message: + await update.message.reply_text('Используйте: /del_button <название>') return name = args[0] - session = SessionLocal() - button = session.query(Button).filter_by(name=name).first() - if not button: - await update.message.reply_text('Кнопка не найдена.') - session.close() - return - session.delete(button) - session.commit() - session.close() - await update.message.reply_text(f'Кнопка "{name}" удалена.') + session = AsyncSessionLocal() + try: + from sqlalchemy import select + result = await session.execute(select(Button).where(Button.name == name)) + button = result.scalar_one_or_none() + if not button: + if update.message: + await update.message.reply_text('Кнопка не найдена.') + return + await session.delete(button) + await session.commit() + if update.message: + await update.message.reply_text(f'Кнопка "{name}" удалена.') + finally: + await session.close() diff --git a/handlers/edit_button.py b/handlers/edit_button.py index 270988d..2db3aad 100644 --- a/handlers/edit_button.py +++ b/handlers/edit_button.py @@ -1,6 +1,6 @@ from telegram import Update from telegram.ext import CommandHandler, ContextTypes -from db import SessionLocal +from db import AsyncSessionLocal from models import Button async def edit_button(update: Update, context: ContextTypes.DEFAULT_TYPE): @@ -9,14 +9,18 @@ async def edit_button(update: Update, context: ContextTypes.DEFAULT_TYPE): await update.message.reply_text('Используйте: /edit_button <название> <новое_название> <новая_ссылка>') return name, new_name, new_url = args[0], args[1], args[2] - session = SessionLocal() - button = session.query(Button).filter_by(name=name).first() - if not button: - await update.message.reply_text('Кнопка не найдена.') - session.close() - return - button.name = new_name - button.url = new_url - session.commit() - session.close() - await update.message.reply_text(f'Кнопка "{name}" изменена.') + session = AsyncSessionLocal() + try: + result = await session.execute(Button.__table__.select().where(Button.name == name)) + button = result.scalar_one_or_none() + if not button: + if update.message: + await update.message.reply_text('Кнопка не найдена.') + return + button.name = new_name + button.url = new_url + await session.commit() + if update.message: + await update.message.reply_text(f'Кнопка "{name}" изменена.') + finally: + await session.close() diff --git a/handlers/group_buttons.py b/handlers/group_buttons.py index 059d9e7..e60527d 100644 --- a/handlers/group_buttons.py +++ b/handlers/group_buttons.py @@ -1,34 +1,45 @@ from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup from telegram.ext import CommandHandler, CallbackQueryHandler, ConversationHandler, ContextTypes -from db import SessionLocal +from db import AsyncSessionLocal from models import Group, Button SELECT_GROUP, MANAGE_BUTTONS = range(2) async def group_buttons_start(update: Update, context: ContextTypes.DEFAULT_TYPE): - session = SessionLocal() - groups = session.query(Group).all() - session.close() - keyboard = [[InlineKeyboardButton(g.name, callback_data=str(g.id))] for g in groups] - await update.message.reply_text( - "Выберите группу для настройки клавиатуры:", - reply_markup=InlineKeyboardMarkup(keyboard) - ) - return SELECT_GROUP + session = AsyncSessionLocal() + try: + groups_result = await session.execute(Group.__table__.select()) + groups = groups_result.scalars().all() + keyboard = [[InlineKeyboardButton(f'{getattr(g, "name", str(g.name))}', callback_data=str(getattr(g, "id", str(g.id))))] for g in groups] + if update.message: + await update.message.reply_text( + "Выберите группу для настройки клавиатуры:", + reply_markup=InlineKeyboardMarkup(keyboard) + ) + return SELECT_GROUP + return ConversationHandler.END + finally: + await session.close() async def select_group(update: Update, context: ContextTypes.DEFAULT_TYPE): query = update.callback_query - await query.answer() - group_id = int(query.data) - context.user_data['group_id'] = group_id - session = SessionLocal() - buttons = session.query(Button).filter_by(group_id=group_id).all() - session.close() - text = "Кнопки этой группы:\n" - for b in buttons: - text += f"- {b.name}: {b.url}\n" - text += "\nДобавить новую кнопку: /add_button\nУдалить: /del_button <название>" - await query.edit_message_text(text) + if query and query.data: + await query.answer() + group_id = int(query.data) + if context.user_data is None: + context.user_data = {} + context.user_data['group_id'] = group_id + session = AsyncSessionLocal() + try: + buttons_result = await session.execute(Button.__table__.select().where(Button.group_id == group_id)) + buttons = buttons_result.scalars().all() + text = "Кнопки этой группы:\n" + for b in buttons: + text += f"- {b.name}: {b.url}\n" + text += "\nДобавить новую кнопку: /add_button\nУдалить: /del_button <название>" + await query.edit_message_text(text) + finally: + await session.close() return ConversationHandler.END group_buttons_conv = ConversationHandler( @@ -37,4 +48,4 @@ group_buttons_conv = ConversationHandler( SELECT_GROUP: [CallbackQueryHandler(select_group)], }, fallbacks=[] -) + ) diff --git a/handlers/new_post.py b/handlers/new_post.py index 05633bf..ec6fd28 100644 --- a/handlers/new_post.py +++ b/handlers/new_post.py @@ -1,65 +1,95 @@ from telegram import Update, InputMediaPhoto, InlineKeyboardMarkup, InlineKeyboardButton from telegram.ext import ContextTypes, ConversationHandler, MessageHandler, CommandHandler, filters, CallbackQueryHandler, ContextTypes -from db import SessionLocal +from db import AsyncSessionLocal from models import Channel, Group, Button SELECT_MEDIA, SELECT_TEXT, SELECT_TARGET = range(3) async def new_post_start(update: Update, context: ContextTypes.DEFAULT_TYPE): - await update.message.reply_text('Отправьте картинку для поста или /skip:') - return SELECT_MEDIA + if update.message: + 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.photo: + 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 - await update.message.reply_text('Введите текст поста или пересланное сообщение:') - return SELECT_TEXT + if update.message: + await update.message.reply_text('Введите текст поста или пересланное сообщение:') + return SELECT_TEXT + return ConversationHandler.END async def select_text(update: Update, context: ContextTypes.DEFAULT_TYPE): - context.user_data['text'] = update.message.text or update.message.caption - session = SessionLocal() - channels = session.query(Channel).all() - groups = session.query(Group).all() - session.close() - 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}')]) - reply_markup = InlineKeyboardMarkup(keyboard) - await update.message.reply_text('Выберите, куда отправить пост:', reply_markup=reply_markup) - return SELECT_TARGET + 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 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 = SessionLocal() + session = AsyncSessionLocal() try: - if data.startswith('channel_'): + chat_id = None + markup = None + if data and data.startswith('channel_'): + from sqlalchemy import select channel_id = int(data.split('_')[1]) - channel = session.query(Channel).get(channel_id) - buttons = session.query(Button).filter_by(channel_id=channel_id).all() - markup = InlineKeyboardMarkup([[InlineKeyboardButton(b.name, url=b.url)] for b in buttons]) if buttons else None - chat_id = channel.link.strip() - else: + 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() + markup = InlineKeyboardMarkup([[InlineKeyboardButton(str(b.name), url=str(b.url))] for b in buttons]) if buttons else None + chat_id = getattr(channel, 'link', None) + elif data and data.startswith('group_'): + from sqlalchemy import select group_id = int(data.split('_')[1]) - group = session.query(Group).get(group_id) - buttons = session.query(Button).filter_by(group_id=group_id).all() - markup = InlineKeyboardMarkup([[InlineKeyboardButton(b.name, url=b.url)] for b in buttons]) if buttons else None - chat_id = group.link.strip() - # Проверка chat_id - if not (chat_id.startswith('@') or chat_id.startswith('-')): - await query.edit_message_text('Ошибка: ссылка должна быть username (@channel) или числовой ID (-100...)') - return ConversationHandler.END - try: - await context.bot.send_photo(chat_id=chat_id, photo=context.user_data.get('photo'), caption=context.user_data.get('text'), reply_markup=markup) - await query.edit_message_text('Пост отправлен!') - except Exception as e: - await query.edit_message_text(f'Ошибка отправки поста: {e}') + 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() + 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: - session.close() + await session.close() return ConversationHandler.END new_post_conv = ConversationHandler( @@ -69,5 +99,6 @@ new_post_conv = ConversationHandler( SELECT_TEXT: [MessageHandler(filters.TEXT | filters.FORWARDED, select_text)], SELECT_TARGET: [CallbackQueryHandler(select_target)], }, - fallbacks=[] -) + fallbacks=[], + + ) diff --git a/init_db.py b/init_db.py new file mode 100644 index 0000000..a907163 --- /dev/null +++ b/init_db.py @@ -0,0 +1,5 @@ +import asyncio +from db import init_db + +if __name__ == "__main__": + asyncio.run(init_db()) diff --git a/main.py b/main.py index af2d9aa..69151db 100644 --- a/main.py +++ b/main.py @@ -1,32 +1,67 @@ +import sys +import asyncio +if sys.platform.startswith('win'): + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) import logging import os from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup from telegram.ext import Application, CommandHandler, MessageHandler, filters, CallbackQueryHandler, ConversationHandler, ContextTypes from dotenv import load_dotenv -from db import SessionLocal, init_db +from db import AsyncSessionLocal, init_db from models import Admin, Channel, Group, Button - +from asyncio import run as sync_to_async load_dotenv() TELEGRAM_TOKEN = os.getenv('TELEGRAM_TOKEN') logging.basicConfig(level=logging.INFO) +logging.getLogger("httpx").setLevel(logging.WARNING) logger = logging.getLogger(__name__) -init_db() +import asyncio + async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): - session = SessionLocal() - user_id = update.effective_user.id - admin = session.query(Admin).filter_by(tg_id=user_id).first() - if not admin: - admin = Admin(tg_id=user_id) - session.add(admin) - session.commit() - await update.message.reply_text('Вы зарегистрированы как админ.') + 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: - await update.message.reply_text('Вы уже зарегистрированы.') - session.close() + if update.message: + await update.message.reply_text('Вы уже зарегистрированы.') + await session.close() +async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + help_text = ( + "Справка по командам бота:\n\n" + "/start — регистрация пользователя как администратора.\n" + "/help — показать это подробное описание команд.\n\n" + "/add_channel <название> <ссылка> — добавить канал для публикаций. Пример: /add_channel MyChannel @my_channel\n" + "/add_group <название> <ссылка> — добавить группу для публикаций. Пример: /add_group MyGroup @my_group\n" + "/add_button — добавить кнопку к выбранному каналу или группе. Запустит диалог выбора и добавления.\n" + "/edit_button <название> <новое_название> <новая_ссылка> — изменить кнопку. Пример: /edit_button old new https://site.ru\n" + "/del_button <название> — удалить кнопку по названию.\n" + "/new_post — создать новый пост (картинка + текст + выбор канала/группы + кнопки).\n" + "/group_buttons — показать и настроить кнопки для группы.\n" + "/channel_buttons — показать и настроить кнопки для канала.\n\n" + "Примеры использования:\n" + "- /add_channel Новости @news_channel\n" + "- /add_group Чат @chat_group\n" + "- /edit_button Подписка Subscribe https://subscribe.ru\n" + "- /del_button Subscribe\n\n" + "Порядок работы:\n" + "1. Добавьте канал или группу.\n" + "2. Добавьте кнопки для них.\n" + "3. Создайте пост через /new_post и выберите, куда отправить.\n" + "4. Управляйте кнопками через /group_buttons и /channel_buttons.\n\n" + "Если возникли вопросы — используйте /help или обратитесь к администратору." + ) + if update.message: + await update.message.reply_text(help_text, parse_mode='HTML') # Импорт обработчиков from handlers.add_channel import add_channel @@ -38,10 +73,16 @@ from handlers.channel_buttons import channel_buttons_conv from handlers.edit_button import edit_button from handlers.del_button import del_button -def main(): + +def main(): + if not TELEGRAM_TOKEN: + print("Ошибка: TELEGRAM_TOKEN не найден в переменных окружения.") + return + 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)) application.add_handler(CommandHandler('add_channel', add_channel)) application.add_handler(CommandHandler('add_group', add_group)) application.add_handler(add_button_conv) @@ -50,7 +91,17 @@ def main(): application.add_handler(channel_buttons_conv) application.add_handler(CommandHandler('edit_button', edit_button)) application.add_handler(CommandHandler('del_button', del_button)) + import sys + import asyncio + if sys.platform.startswith('win'): + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + try: + loop = asyncio.get_event_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) application.run_polling() -if __name__ == '__main__': +if __name__ == "__main__": main() + diff --git a/models.py b/models.py index 099feb6..50a2a9e 100644 --- a/models.py +++ b/models.py @@ -1,7 +1,6 @@ from sqlalchemy import Column, Integer, String, ForeignKey, Text -from sqlalchemy.orm import declarative_base, relationship - -Base = declarative_base() +from sqlalchemy.orm import relationship +from db import Base class Admin(Base): __tablename__ = 'admins' diff --git a/requirements.txt b/requirements.txt index 08feac5..7a6e55e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ python-telegram-bot>=20.0 sqlalchemy>=2.0 python-dotenv>=1.0 pytest>=7.0 +aiosqlite>=0.19 \ No newline at end of file From fd6007ce933ac0b8559cb736d062e1c8ba21b5d8 Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Fri, 5 Sep 2025 12:16:19 +0900 Subject: [PATCH 02/19] Database creation fix --- db.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/db.py b/db.py index 9d438fa..ee9265b 100644 --- a/db.py +++ b/db.py @@ -9,10 +9,20 @@ DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///bot.db") if DATABASE_URL.startswith("sqlite+aiosqlite:///"): db_path = DATABASE_URL.replace("sqlite+aiosqlite:///", "") + # Убираем лишний слэш в конце, если есть + if db_path.endswith(os.sep): + db_path = db_path.rstrip(os.sep) abs_db_path = os.path.abspath(db_path) db_dir = os.path.dirname(abs_db_path) if db_dir and not os.path.exists(db_dir): os.makedirs(db_dir, exist_ok=True) + # Если по этому пути уже есть папка, удаляем её и создаём файл + if os.path.isdir(abs_db_path): + import shutil + shutil.rmtree(abs_db_path) + # Если файла нет, создаём пустой файл + if not os.path.exists(abs_db_path): + open(abs_db_path, 'a').close() engine = create_async_engine(DATABASE_URL, future=True, echo=False) AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False) From dd065de3edf979a7c66e1f84874d3da5b3b701d2 Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Fri, 5 Sep 2025 13:01:50 +0900 Subject: [PATCH 03/19] DB creation fix --- db.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/db.py b/db.py index ee9265b..1a10e7e 100644 --- a/db.py +++ b/db.py @@ -35,9 +35,34 @@ async def init_db(): db_path = DATABASE_URL.replace("sqlite+aiosqlite:///", "") abs_db_path = os.path.abspath(db_path) print(f"Абсолютный путь к базе данных: {abs_db_path}") + # Если файл отсутствует или пустой, или это папка — создаём таблицы if not os.path.exists(abs_db_path): print("Файл базы данных отсутствует, будет создан.") need_create = True + elif os.path.isdir(abs_db_path): + print("Вместо файла обнаружена папка, будет удалена и создан файл.") + import shutil + shutil.rmtree(abs_db_path) + open(abs_db_path, 'a').close() + need_create = True + elif os.path.getsize(abs_db_path) == 0: + print("Файл базы данных пустой, будут созданы таблицы.") + need_create = True + else: + # Проверяем наличие таблиц + import sqlite3 + try: + conn = sqlite3.connect(abs_db_path) + cursor = conn.cursor() + cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") + tables = cursor.fetchall() + conn.close() + if not tables: + print("В базе нет таблиц, будут созданы.") + need_create = True + except Exception as e: + print(f"Ошибка проверки таблиц: {e}") + need_create = True else: print(f"База данных: {DATABASE_URL}") # Для других СУБД всегда пытаемся создать таблицы @@ -49,4 +74,4 @@ async def init_db(): tables = Base.metadata.tables.keys() print(f"Созданы таблицы: {', '.join(tables)}") else: - print("База данных уже существует, создание таблиц пропущено.") \ No newline at end of file + print("База данных уже существует и содержит таблицы, создание пропущено.") \ No newline at end of file From f35113e5c83563a5c6ea5aacd7386f3bd7d636f7 Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Fri, 5 Sep 2025 13:54:59 +0900 Subject: [PATCH 04/19] migrations + util scripts --- alembic.ini | 147 ++++++++++++++++++++++++++ alembic/README | 1 + alembic/env.py | 53 ++++++++++ alembic/script.py.mako | 28 +++++ alembic/versions/69ef23ef1ed1_init.py | 32 ++++++ bin/update.sh | 16 +++ handlers/admin_panel.py | 40 +++++++ main.py | 2 + requirements.txt | 3 +- 9 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 alembic.ini create mode 100644 alembic/README create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/69ef23ef1ed1_init.py create mode 100644 bin/update.sh create mode 100644 handlers/admin_panel.py diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..09b92bb --- /dev/null +++ b/alembic.ini @@ -0,0 +1,147 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = sqlite:///bot.db + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..d45cdf8 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,53 @@ +from logging.config import fileConfig + +from sqlalchemy import create_engine, pool + +from alembic import context +import os + + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +from models import Base +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + url = os.getenv("DATABASE_URL", config.get_main_option("sqlalchemy.url")) + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + url = config.get_main_option("sqlalchemy.url") + connectable = create_engine(url, poolclass=pool.NullPool) + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/69ef23ef1ed1_init.py b/alembic/versions/69ef23ef1ed1_init.py new file mode 100644 index 0000000..c8b6950 --- /dev/null +++ b/alembic/versions/69ef23ef1ed1_init.py @@ -0,0 +1,32 @@ +"""init + +Revision ID: 69ef23ef1ed1 +Revises: +Create Date: 2025-09-05 13:53:02.737876 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '69ef23ef1ed1' +down_revision: Union[str, Sequence[str], None] = None +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/bin/update.sh b/bin/update.sh new file mode 100644 index 0000000..3c7eec2 --- /dev/null +++ b/bin/update.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -e + +echo "[update.sh] Получение свежего кода..." +git pull + +echo "[update.sh] Пересборка контейнера..." +docker-compose build --no-cache + +echo "[update.sh] Применение миграций Alembic..." +docker-compose run --rm bot alembic upgrade head + +echo "[update.sh] Запуск контейнера..." +docker-compose up -d + +echo "[update.sh] Готово!" diff --git a/handlers/admin_panel.py b/handlers/admin_panel.py new file mode 100644 index 0000000..fb95581 --- /dev/null +++ b/handlers/admin_panel.py @@ -0,0 +1,40 @@ +from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup +from telegram.ext import CommandHandler, CallbackQueryHandler, ConversationHandler, ContextTypes +from db import AsyncSessionLocal +from models import Admin, Channel, Group, Button + +ADMIN_MENU, PREVIEW_POST = range(2) + +async def admin_panel(update: Update, context: ContextTypes.DEFAULT_TYPE): + user_id = update.effective_user.id if update.effective_user else None + async with AsyncSessionLocal() as session: + # Получаем каналы и группы, которыми управляет админ + channels_result = await session.execute(Channel.__table__.select().where(Channel.admin_id == user_id)) + channels = channels_result.scalars().all() + groups_result = await session.execute(Group.__table__.select().where(Group.admin_id == user_id)) + groups = groups_result.scalars().all() + # Статистика + buttons_result = await session.execute(Button.__table__.select()) + buttons = buttons_result.scalars().all() + stats = f"Каналов: {len(channels)}\nГрупп: {len(groups)}\nКнопок: {len(buttons)}" + text = f"Ваша админ-панель\n\n{stats}\n\nВаши каналы:\n" + '\n'.join([f"- {c.name}" for c in channels]) + "\n\nВаши группы:\n" + '\n'.join([f"- {g.name}" for g in groups]) + keyboard = [] + # Кнопка предпросмотра поста (можно доработать) + keyboard.append([InlineKeyboardButton("Предпросмотр поста", callback_data="preview_post")]) + await update.message.reply_text(text, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode='HTML') + return ADMIN_MENU + +async def preview_post_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): + query = update.callback_query + await query.answer() + # Здесь можно реализовать предпросмотр поста с кнопками + await query.edit_message_text("Здесь будет предпросмотр поста с кнопками.") + return ConversationHandler.END + +admin_panel_conv = ConversationHandler( + entry_points=[CommandHandler('admin', admin_panel)], + states={ + ADMIN_MENU: [CallbackQueryHandler(preview_post_callback, pattern="preview_post")], + }, + fallbacks=[] +) diff --git a/main.py b/main.py index 69151db..a6410f3 100644 --- a/main.py +++ b/main.py @@ -10,6 +10,7 @@ from dotenv import load_dotenv from db import AsyncSessionLocal, init_db from models import Admin, Channel, Group, Button from asyncio import run as sync_to_async +from handlers.admin_panel import admin_panel_conv load_dotenv() TELEGRAM_TOKEN = os.getenv('TELEGRAM_TOKEN') @@ -91,6 +92,7 @@ def main(): application.add_handler(channel_buttons_conv) application.add_handler(CommandHandler('edit_button', edit_button)) application.add_handler(CommandHandler('del_button', del_button)) + application.add_handler(admin_panel_conv) import sys import asyncio if sys.platform.startswith('win'): diff --git a/requirements.txt b/requirements.txt index 7a6e55e..5ee2645 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,5 @@ python-telegram-bot>=20.0 sqlalchemy>=2.0 python-dotenv>=1.0 pytest>=7.0 -aiosqlite>=0.19 \ No newline at end of file +aiosqlite +alembic \ No newline at end of file From ec5c09ee2431a4425850862960d66e1ea599626d Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Fri, 5 Sep 2025 13:57:05 +0900 Subject: [PATCH 05/19] script fix --- bin/update.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/bin/update.sh b/bin/update.sh index 3c7eec2..198e96c 100644 --- a/bin/update.sh +++ b/bin/update.sh @@ -5,6 +5,7 @@ echo "[update.sh] Получение свежего кода..." git pull echo "[update.sh] Пересборка контейнера..." +cd .. docker-compose build --no-cache echo "[update.sh] Применение миграций Alembic..." From 100a73355ab9fa14799a283a731877645c1b8a85 Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Fri, 5 Sep 2025 13:59:26 +0900 Subject: [PATCH 06/19] script fix docker-compose vs docker compose --- bin/update.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bin/update.sh b/bin/update.sh index 198e96c..132378d 100644 --- a/bin/update.sh +++ b/bin/update.sh @@ -6,12 +6,12 @@ git pull echo "[update.sh] Пересборка контейнера..." cd .. -docker-compose build --no-cache +docker compose build --no-cache echo "[update.sh] Применение миграций Alembic..." -docker-compose run --rm bot alembic upgrade head +docker compose run --rm bot alembic upgrade head echo "[update.sh] Запуск контейнера..." -docker-compose up -d +docker compose up -d echo "[update.sh] Готово!" From fb82b7f270b92e9ba040fdc15cd937e51e36316c Mon Sep 17 00:00:00 2001 From: "Andrew K. Choi" Date: Fri, 5 Sep 2025 05:01:02 +0000 Subject: [PATCH 07/19] chmod +x lacally added --- bin/update.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 bin/update.sh diff --git a/bin/update.sh b/bin/update.sh old mode 100644 new mode 100755 From 8dc49261b135d2ff9119933f8e6f4e6056eaa7a5 Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Fri, 5 Sep 2025 14:04:45 +0900 Subject: [PATCH 08/19] script fix --- bin/update.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/bin/update.sh b/bin/update.sh index 132378d..42296d7 100644 --- a/bin/update.sh +++ b/bin/update.sh @@ -5,7 +5,6 @@ echo "[update.sh] Получение свежего кода..." git pull echo "[update.sh] Пересборка контейнера..." -cd .. docker compose build --no-cache echo "[update.sh] Применение миграций Alembic..." From a23ca87c84568613255b7cfdad96b0129131a972 Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Fri, 5 Sep 2025 14:16:47 +0900 Subject: [PATCH 09/19] model channels --- .../versions/7506a3320699_channel_table.py | 45 +++++++++++++++++++ models.py | 6 +-- 2 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 alembic/versions/7506a3320699_channel_table.py diff --git a/alembic/versions/7506a3320699_channel_table.py b/alembic/versions/7506a3320699_channel_table.py new file mode 100644 index 0000000..31a8501 --- /dev/null +++ b/alembic/versions/7506a3320699_channel_table.py @@ -0,0 +1,45 @@ +"""channel table + +Revision ID: 7506a3320699 +Revises: 69ef23ef1ed1 +Create Date: 2025-09-05 14:12:37.430983 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.sql import table, column + + +# revision identifiers, used by Alembic. +revision: str = '7506a3320699' +down_revision: Union[str, Sequence[str], None] = '69ef23ef1ed1' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # Для SQLite: создаём новую таблицу, копируем данные, удаляем старую, переименовываем новую + conn = op.get_bind() + # 1. Создать новую таблицу + op.create_table( + 'channel_new', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('admin_id', sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + # 2. Если старая таблица есть, скопировать данные + result = conn.execute(sa.text("SELECT name FROM sqlite_master WHERE type='table' AND name='channel'")).fetchone() + if result: + conn.execute(sa.text("INSERT INTO channel_new (id, name, admin_id) SELECT id, name, admin_id FROM channel")) + op.drop_table('channel') + # 3. Переименовать новую таблицу + conn.execute(sa.text("ALTER TABLE channel_new RENAME TO channel")) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_table('channel') diff --git a/models.py b/models.py index 50a2a9e..5d33d42 100644 --- a/models.py +++ b/models.py @@ -10,9 +10,9 @@ class Admin(Base): class Channel(Base): __tablename__ = 'channels' id = Column(Integer, primary_key=True) - name = Column(String, nullable=False) - link = Column(String, nullable=False) - buttons = relationship('Button', back_populates='channel') + name = Column(String) + link = Column(String) + admin_id = Column(Integer, ForeignKey('admins.id')) # если есть таблица admins class Group(Base): __tablename__ = 'groups' From a91283b681a1a42b7fa422c822e8689c6271a827 Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Fri, 5 Sep 2025 14:22:41 +0900 Subject: [PATCH 10/19] migration fix --- alembic/versions/7506a3320699_channel_table.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/alembic/versions/7506a3320699_channel_table.py b/alembic/versions/7506a3320699_channel_table.py index 31a8501..bc27d49 100644 --- a/alembic/versions/7506a3320699_channel_table.py +++ b/alembic/versions/7506a3320699_channel_table.py @@ -25,21 +25,22 @@ def upgrade() -> None: conn = op.get_bind() # 1. Создать новую таблицу op.create_table( - 'channel_new', + 'channels_new', sa.Column('id', sa.Integer(), nullable=False), - sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('name', sa.String(length=255), nullable=True), + sa.Column('link', sa.String(length=255), nullable=True), sa.Column('admin_id', sa.Integer(), nullable=True), sa.PrimaryKeyConstraint('id') ) # 2. Если старая таблица есть, скопировать данные - result = conn.execute(sa.text("SELECT name FROM sqlite_master WHERE type='table' AND name='channel'")).fetchone() + result = conn.execute(sa.text("SELECT name FROM sqlite_master WHERE type='table' AND name='channels'")).fetchone() if result: - conn.execute(sa.text("INSERT INTO channel_new (id, name, admin_id) SELECT id, name, admin_id FROM channel")) - op.drop_table('channel') + conn.execute(sa.text("INSERT INTO channels_new (id, name, link, admin_id) SELECT id, name, link, admin_id FROM channels")) + op.drop_table('channels') # 3. Переименовать новую таблицу - conn.execute(sa.text("ALTER TABLE channel_new RENAME TO channel")) + conn.execute(sa.text("ALTER TABLE channels_new RENAME TO channels")) def downgrade() -> None: """Downgrade schema.""" - op.drop_table('channel') + op.drop_table('channels') From 3aabe99a4e121087ca8d166666ba9d44ad377bf6 Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Fri, 5 Sep 2025 14:27:07 +0900 Subject: [PATCH 11/19] migrations fix --- .../versions/7506a3320699_channel_table.py | 27 +++++++------------ 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/alembic/versions/7506a3320699_channel_table.py b/alembic/versions/7506a3320699_channel_table.py index bc27d49..57a3a89 100644 --- a/alembic/versions/7506a3320699_channel_table.py +++ b/alembic/versions/7506a3320699_channel_table.py @@ -21,26 +21,19 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: """Upgrade schema.""" - # Для SQLite: создаём новую таблицу, копируем данные, удаляем старую, переименовываем новую + # Для SQLite: добавляем столбцы через ALTER TABLE conn = op.get_bind() - # 1. Создать новую таблицу - op.create_table( - 'channels_new', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('name', sa.String(length=255), nullable=True), - sa.Column('link', sa.String(length=255), nullable=True), - sa.Column('admin_id', sa.Integer(), nullable=True), - sa.PrimaryKeyConstraint('id') - ) - # 2. Если старая таблица есть, скопировать данные - result = conn.execute(sa.text("SELECT name FROM sqlite_master WHERE type='table' AND name='channels'")).fetchone() - if result: - conn.execute(sa.text("INSERT INTO channels_new (id, name, link, admin_id) SELECT id, name, link, admin_id FROM channels")) - op.drop_table('channels') - # 3. Переименовать новую таблицу - conn.execute(sa.text("ALTER TABLE channels_new RENAME TO channels")) + # Добавить столбец link, если его нет + result = conn.execute(sa.text("PRAGMA table_info(channels)")).fetchall() + columns = [row[1] for row in result] + if 'link' not in columns: + conn.execute(sa.text("ALTER TABLE channels ADD COLUMN link VARCHAR(255)")) + # Добавить столбец admin_id, если его нет + if 'admin_id' not in columns: + conn.execute(sa.text("ALTER TABLE channels ADD COLUMN admin_id INTEGER")) def downgrade() -> None: """Downgrade schema.""" + # SQLite не поддерживает удаление столбцов, поэтому просто удаляем таблицу op.drop_table('channels') From 4ea540021aa05a1662bc1a524a9e4d5c876544df Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Fri, 5 Sep 2025 14:32:20 +0900 Subject: [PATCH 12/19] database creation process --- db.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/db.py b/db.py index 1a10e7e..dbefa53 100644 --- a/db.py +++ b/db.py @@ -9,12 +9,11 @@ DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///bot.db") if DATABASE_URL.startswith("sqlite+aiosqlite:///"): db_path = DATABASE_URL.replace("sqlite+aiosqlite:///", "") - # Убираем лишний слэш в конце, если есть - if db_path.endswith(os.sep): - db_path = db_path.rstrip(os.sep) abs_db_path = os.path.abspath(db_path) + # Проверяем, что путь содержит директорию db_dir = os.path.dirname(abs_db_path) - if db_dir and not os.path.exists(db_dir): + # Если путь содержит директорию, создаём её + if db_dir and db_dir != os.path.abspath("") and not os.path.exists(db_dir): os.makedirs(db_dir, exist_ok=True) # Если по этому пути уже есть папка, удаляем её и создаём файл if os.path.isdir(abs_db_path): From 908aff8b3dee1dc38ef95ad2179054a74ba72185 Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Fri, 5 Sep 2025 14:35:27 +0900 Subject: [PATCH 13/19] database creation fixes --- db.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/db.py b/db.py index dbefa53..bc094f1 100644 --- a/db.py +++ b/db.py @@ -10,10 +10,9 @@ DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///bot.db") if DATABASE_URL.startswith("sqlite+aiosqlite:///"): db_path = DATABASE_URL.replace("sqlite+aiosqlite:///", "") abs_db_path = os.path.abspath(db_path) - # Проверяем, что путь содержит директорию db_dir = os.path.dirname(abs_db_path) - # Если путь содержит директорию, создаём её - if db_dir and db_dir != os.path.abspath("") and not os.path.exists(db_dir): + # Создаём директорию только если она не равна текущей ('.') и не пустая + if db_dir and db_dir != os.path.abspath("") and db_dir != '.' and not os.path.exists(db_dir): os.makedirs(db_dir, exist_ok=True) # Если по этому пути уже есть папка, удаляем её и создаём файл if os.path.isdir(abs_db_path): From f1d782bb74391697824eb5571ac7940493409040 Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Fri, 5 Sep 2025 14:40:25 +0900 Subject: [PATCH 14/19] database fix --- db.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/db.py b/db.py index bc094f1..8b5e3ac 100644 --- a/db.py +++ b/db.py @@ -14,8 +14,8 @@ if DATABASE_URL.startswith("sqlite+aiosqlite:///"): # Создаём директорию только если она не равна текущей ('.') и не пустая if db_dir and db_dir != os.path.abspath("") and db_dir != '.' and not os.path.exists(db_dir): os.makedirs(db_dir, exist_ok=True) - # Если по этому пути уже есть папка, удаляем её и создаём файл - if os.path.isdir(abs_db_path): + # Если по этому пути уже есть папка, удаляем её + if os.path.exists(abs_db_path) and os.path.isdir(abs_db_path): import shutil shutil.rmtree(abs_db_path) # Если файла нет, создаём пустой файл From 05990bf36e3cefac8be0d90b38143e29a967f56d Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Fri, 5 Sep 2025 14:56:06 +0900 Subject: [PATCH 15/19] migrations fix --- alembic/versions/69ef23ef1ed1_init.py | 32 --------- .../versions/7506a3320699_channel_table.py | 39 ----------- alembic/versions/eeb6744b9452_init.py | 66 +++++++++++++++++++ models.py | 4 +- 4 files changed, 69 insertions(+), 72 deletions(-) delete mode 100644 alembic/versions/69ef23ef1ed1_init.py delete mode 100644 alembic/versions/7506a3320699_channel_table.py create mode 100644 alembic/versions/eeb6744b9452_init.py diff --git a/alembic/versions/69ef23ef1ed1_init.py b/alembic/versions/69ef23ef1ed1_init.py deleted file mode 100644 index c8b6950..0000000 --- a/alembic/versions/69ef23ef1ed1_init.py +++ /dev/null @@ -1,32 +0,0 @@ -"""init - -Revision ID: 69ef23ef1ed1 -Revises: -Create Date: 2025-09-05 13:53:02.737876 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '69ef23ef1ed1' -down_revision: Union[str, Sequence[str], None] = None -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/7506a3320699_channel_table.py b/alembic/versions/7506a3320699_channel_table.py deleted file mode 100644 index 57a3a89..0000000 --- a/alembic/versions/7506a3320699_channel_table.py +++ /dev/null @@ -1,39 +0,0 @@ -"""channel table - -Revision ID: 7506a3320699 -Revises: 69ef23ef1ed1 -Create Date: 2025-09-05 14:12:37.430983 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -from sqlalchemy.sql import table, column - - -# revision identifiers, used by Alembic. -revision: str = '7506a3320699' -down_revision: Union[str, Sequence[str], None] = '69ef23ef1ed1' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # Для SQLite: добавляем столбцы через ALTER TABLE - conn = op.get_bind() - # Добавить столбец link, если его нет - result = conn.execute(sa.text("PRAGMA table_info(channels)")).fetchall() - columns = [row[1] for row in result] - if 'link' not in columns: - conn.execute(sa.text("ALTER TABLE channels ADD COLUMN link VARCHAR(255)")) - # Добавить столбец admin_id, если его нет - if 'admin_id' not in columns: - conn.execute(sa.text("ALTER TABLE channels ADD COLUMN admin_id INTEGER")) - - -def downgrade() -> None: - """Downgrade schema.""" - # SQLite не поддерживает удаление столбцов, поэтому просто удаляем таблицу - op.drop_table('channels') diff --git a/alembic/versions/eeb6744b9452_init.py b/alembic/versions/eeb6744b9452_init.py new file mode 100644 index 0000000..df25abd --- /dev/null +++ b/alembic/versions/eeb6744b9452_init.py @@ -0,0 +1,66 @@ +"""init + +Revision ID: eeb6744b9452 +Revises: +Create Date: 2025-09-05 14:55:12.005564 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'eeb6744b9452' +down_revision: Union[str, Sequence[str], None] = None +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! ### + op.create_table('admins', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('tg_id', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('tg_id') + ) + op.create_table('channels', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=True), + sa.Column('link', sa.String(), nullable=True), + sa.Column('admin_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['admin_id'], ['admins.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('groups', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('link', sa.String(), nullable=False), + sa.Column('admin_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['admin_id'], ['admins.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('buttons', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('url', sa.String(), nullable=False), + sa.Column('channel_id', sa.Integer(), nullable=True), + sa.Column('group_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['channel_id'], ['channels.id'], ), + sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('buttons') + op.drop_table('groups') + op.drop_table('channels') + op.drop_table('admins') + # ### end Alembic commands ### diff --git a/models.py b/models.py index 5d33d42..30f8d60 100644 --- a/models.py +++ b/models.py @@ -12,13 +12,15 @@ class Channel(Base): id = Column(Integer, primary_key=True) name = Column(String) link = Column(String) - admin_id = Column(Integer, ForeignKey('admins.id')) # если есть таблица admins + admin_id = Column(Integer, ForeignKey('admins.id')) + buttons = relationship('Button', back_populates='channel') class Group(Base): __tablename__ = 'groups' id = Column(Integer, primary_key=True) name = Column(String, nullable=False) link = Column(String, nullable=False) + admin_id = Column(Integer, ForeignKey('admins.id')) buttons = relationship('Button', back_populates='group') class Button(Base): From a0cbdd53580252ca30272c1df82d58fb10885c45 Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Fri, 5 Sep 2025 15:03:13 +0900 Subject: [PATCH 16/19] migrations and db creation --- alembic/versions/eeb6744b9452_init.py | 59 ++++++++++++--------------- 1 file changed, 25 insertions(+), 34 deletions(-) diff --git a/alembic/versions/eeb6744b9452_init.py b/alembic/versions/eeb6744b9452_init.py index df25abd..75f2d6b 100644 --- a/alembic/versions/eeb6744b9452_init.py +++ b/alembic/versions/eeb6744b9452_init.py @@ -19,48 +19,39 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('admins', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('tg_id', sa.Integer(), nullable=False), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('tg_id') + """Создание всех таблиц согласно моделям.""" + op.create_table( + 'admins', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('tg_id', sa.Integer(), unique=True, nullable=False), ) - op.create_table('channels', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('name', sa.String(), nullable=True), - sa.Column('link', sa.String(), nullable=True), - sa.Column('admin_id', sa.Integer(), nullable=True), - sa.ForeignKeyConstraint(['admin_id'], ['admins.id'], ), - sa.PrimaryKeyConstraint('id') + op.create_table( + 'channels', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('name', sa.String, nullable=True), + sa.Column('link', sa.String, nullable=True), + sa.Column('admin_id', sa.Integer(), sa.ForeignKey('admins.id'), nullable=True), ) - op.create_table('groups', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('name', sa.String(), nullable=False), - sa.Column('link', sa.String(), nullable=False), - sa.Column('admin_id', sa.Integer(), nullable=True), - sa.ForeignKeyConstraint(['admin_id'], ['admins.id'], ), - sa.PrimaryKeyConstraint('id') + op.create_table( + 'groups', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('name', sa.String, nullable=False), + sa.Column('link', sa.String, nullable=False), + sa.Column('admin_id', sa.Integer(), sa.ForeignKey('admins.id'), nullable=True), ) - op.create_table('buttons', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('name', sa.String(), nullable=False), - sa.Column('url', sa.String(), nullable=False), - sa.Column('channel_id', sa.Integer(), nullable=True), - sa.Column('group_id', sa.Integer(), nullable=True), - sa.ForeignKeyConstraint(['channel_id'], ['channels.id'], ), - sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ), - sa.PrimaryKeyConstraint('id') + op.create_table( + 'buttons', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('name', sa.String, nullable=False), + sa.Column('url', sa.String, nullable=False), + sa.Column('channel_id', sa.Integer(), sa.ForeignKey('channels.id'), nullable=True), + sa.Column('group_id', sa.Integer(), sa.ForeignKey('groups.id'), nullable=True), ) - # ### end Alembic commands ### def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### + """Удаление всех таблиц.""" op.drop_table('buttons') op.drop_table('groups') op.drop_table('channels') op.drop_table('admins') - # ### end Alembic commands ### From 7254175cdb4267fb0cb720c02a973a416f31108d Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Fri, 5 Sep 2025 15:10:42 +0900 Subject: [PATCH 17/19] migrations --- bin/update.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bin/update.sh b/bin/update.sh index 42296d7..f0259fa 100644 --- a/bin/update.sh +++ b/bin/update.sh @@ -1,6 +1,16 @@ #!/bin/bash set -e +echo "[update.sh] Проверка bot.db..." +if [ -d "bot.db" ]; then + echo "Удаляю папку bot.db..." + rm -rf bot.db +fi +if [ ! -f "bot.db" ]; then + echo "Создаю пустой файл bot.db..." + touch bot.db +fi + echo "[update.sh] Получение свежего кода..." git pull From e7a40b47183299f2fa3d466b2ed53d5f1f8cdb4a Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Fri, 5 Sep 2025 15:40:28 +0900 Subject: [PATCH 18/19] add_group, add_Channel process modification --- handlers/add_channel.py | 64 ++++++++++++++++++++++++++++++------ handlers/add_group.py | 73 ++++++++++++++++++++++++++++++++--------- init_db.py | 11 +++++++ main.py | 8 ++--- 4 files changed, 127 insertions(+), 29 deletions(-) diff --git a/handlers/add_channel.py b/handlers/add_channel.py index 505cdd8..904f19d 100644 --- a/handlers/add_channel.py +++ b/handlers/add_channel.py @@ -1,18 +1,62 @@ + from telegram import Update -from telegram.ext import ContextTypes +from telegram.ext import ContextTypes, ConversationHandler, CommandHandler, MessageHandler, filters from db import AsyncSessionLocal from models import Channel -async def add_channel(update: Update, context: ContextTypes.DEFAULT_TYPE): - args = context.args or [] - if update.message is None: - return - if len(args) < 2: - await update.message.reply_text('Используйте: /add_channel <название> <ссылка>') - return - name, link = args[0], args[1] +INPUT_NAME, INPUT_LINK = range(2) + +async def add_channel_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_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 '' + if ' ' in text: + name, link = text.split(' ', 1) + context.user_data['channel_name'] = name + context.user_data['channel_link'] = link + return await save_channel(update, context) + else: + context.user_data['channel_name'] = text + if update.message: + await update.message.reply_text('Теперь отправьте ссылку на канал:') + 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 '' + context.user_data['channel_link'] = link + return await save_channel(update, context) + +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('Ошибка: не указано название или ссылка.') + return ConversationHandler.END async with AsyncSessionLocal() as session: channel = Channel(name=name, link=link) session.add(channel) await session.commit() - await update.message.reply_text(f'Канал "{name}" добавлен.') + if update.message: + await update.message.reply_text(f'Канал "{name}" добавлен.') + return ConversationHandler.END + +add_channel_conv = ConversationHandler( + entry_points=[CommandHandler('add_channel', add_channel_start)], + states={ + INPUT_NAME: [MessageHandler(filters.TEXT & ~filters.COMMAND, input_channel_name)], + INPUT_LINK: [MessageHandler(filters.TEXT & ~filters.COMMAND, input_channel_link)], + }, + fallbacks=[] +) diff --git a/handlers/add_group.py b/handlers/add_group.py index 8ecfe46..2ca7649 100644 --- a/handlers/add_group.py +++ b/handlers/add_group.py @@ -1,19 +1,62 @@ + from telegram import Update -from telegram.ext import ContextTypes +from telegram.ext import ContextTypes, ConversationHandler, CommandHandler, MessageHandler, filters from db import AsyncSessionLocal from models import Group -async def add_group(update: Update, context: ContextTypes.DEFAULT_TYPE): - args = context.args or [] - if update.message is None: - return - if len(args) < 2: - await update.message.reply_text('Используйте: /add_group <название> <ссылка>') - return - name, link = args[0], args[1] - session = AsyncSessionLocal() - group = Group(name=name, link=link) - session.add(group) - session.commit() - session.close() - await update.message.reply_text(f'Группа "{name}" добавлена.') +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 '' + if ' ' in text: + name, link = text.split(' ', 1) + context.user_data['group_name'] = name + context.user_data['group_link'] = link + return await save_group(update, context) + else: + context.user_data['group_name'] = text + if update.message: + await update.message.reply_text('Теперь отправьте ссылку на группу:') + 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 '' + 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=[] +) diff --git a/init_db.py b/init_db.py index a907163..f419a69 100644 --- a/init_db.py +++ b/init_db.py @@ -1,5 +1,16 @@ +import os import asyncio from db import init_db +# Проверка bot.db перед инициализацией +if os.path.exists("bot.db") and os.path.isdir("bot.db"): + print("Удаляю папку bot.db...") + import shutil + + shutil.rmtree("bot.db") +if not os.path.exists("bot.db"): + print("Создаю пустой файл bot.db...") + open("bot.db", "a").close() + if __name__ == "__main__": asyncio.run(init_db()) diff --git a/main.py b/main.py index a6410f3..c84a329 100644 --- a/main.py +++ b/main.py @@ -65,8 +65,8 @@ async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE): await update.message.reply_text(help_text, parse_mode='HTML') # Импорт обработчиков -from handlers.add_channel import add_channel -from handlers.add_group import add_group +from handlers.add_channel import add_channel_conv +from handlers.add_group import add_group_conv from handlers.add_button import add_button_conv from handlers.new_post import new_post_conv from handlers.group_buttons import group_buttons_conv @@ -84,8 +84,8 @@ def main(): application = Application.builder().token(TELEGRAM_TOKEN).build() application.add_handler(CommandHandler('start', start)) application.add_handler(CommandHandler('help', help_command)) - application.add_handler(CommandHandler('add_channel', add_channel)) - application.add_handler(CommandHandler('add_group', add_group)) + application.add_handler(add_channel_conv) + application.add_handler(add_group_conv) application.add_handler(add_button_conv) application.add_handler(new_post_conv) application.add_handler(group_buttons_conv) From f079ad2cf71665d349e4ebd2924a5bb7ebd4c0c8 Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Fri, 5 Sep 2025 15:47:09 +0900 Subject: [PATCH 19/19] adding channels and groups fixing --- handlers/add_channel.py | 20 +++++++++----------- handlers/add_group.py | 20 +++++++++----------- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/handlers/add_channel.py b/handlers/add_channel.py index 904f19d..f39cb39 100644 --- a/handlers/add_channel.py +++ b/handlers/add_channel.py @@ -10,28 +10,26 @@ async def add_channel_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_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 '' - if ' ' in text: - name, link = text.split(' ', 1) - context.user_data['channel_name'] = name - context.user_data['channel_link'] = link - return await save_channel(update, context) - else: - context.user_data['channel_name'] = text - if update.message: - await update.message.reply_text('Теперь отправьте ссылку на канал:') - return INPUT_LINK + context.user_data['channel_name'] = text + if update.message: + await update.message.reply_text('Теперь отправьте ссылку на канал (должна начинаться с @):') + 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) diff --git a/handlers/add_group.py b/handlers/add_group.py index 2ca7649..4f9459f 100644 --- a/handlers/add_group.py +++ b/handlers/add_group.py @@ -10,28 +10,26 @@ 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 '' - if ' ' in text: - name, link = text.split(' ', 1) - context.user_data['group_name'] = name - context.user_data['group_link'] = link - return await save_group(update, context) - else: - context.user_data['group_name'] = text - if update.message: - await update.message.reply_text('Теперь отправьте ссылку на группу:') - return INPUT_LINK + 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)