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}')