diff --git a/alembic/env.py b/alembic/env.py index d45cdf8..a1389e0 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -9,6 +9,10 @@ import os # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config +import os +db_url = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///db/bot.db") +db_url_sync = db_url.replace("sqlite+aiosqlite", "sqlite") # Alembic нужен sync-драйвер +config.set_main_option("sqlalchemy.url", db_url_sync) # Interpret the config file for Python logging. # This line sets up loggers basically. diff --git a/alembic/versions/21c6fd6ac065_admins_checkin.py b/alembic/versions/21c6fd6ac065_admins_checkin.py new file mode 100644 index 0000000..ae14fe6 --- /dev/null +++ b/alembic/versions/21c6fd6ac065_admins_checkin.py @@ -0,0 +1,32 @@ +"""admins checkin + +Revision ID: 21c6fd6ac065 +Revises: eeb6744b9452 +Create Date: 2025-09-06 08:41:08.145822 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '21c6fd6ac065' +down_revision: Union[str, Sequence[str], None] = 'eeb6744b9452' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/alembic/versions/50652f5156d8_channel_accesses.py b/alembic/versions/50652f5156d8_channel_accesses.py new file mode 100644 index 0000000..230740d --- /dev/null +++ b/alembic/versions/50652f5156d8_channel_accesses.py @@ -0,0 +1,28 @@ +"""channel_accesses + +Revision ID: 50652f5156d8 +Revises: 96a65ea5f555 +Create Date: 2025-09-06 10:01:41.613022 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '50652f5156d8' +down_revision: Union[str, Sequence[str], None] = '96a65ea5f555' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + pass + + +def downgrade() -> None: + """Downgrade schema.""" + pass diff --git a/alembic/versions/96a65ea5f555_channel_accesses.py b/alembic/versions/96a65ea5f555_channel_accesses.py new file mode 100644 index 0000000..2e000a2 --- /dev/null +++ b/alembic/versions/96a65ea5f555_channel_accesses.py @@ -0,0 +1,28 @@ +"""channel_accesses + +Revision ID: 96a65ea5f555 +Revises: ae94c53e7343 +Create Date: 2025-09-06 09:59:33.965591 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '96a65ea5f555' +down_revision: Union[str, Sequence[str], None] = 'ae94c53e7343' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + pass + + +def downgrade() -> None: + """Downgrade schema.""" + pass diff --git a/alembic/versions/ae94c53e7343_channel_accesses.py b/alembic/versions/ae94c53e7343_channel_accesses.py new file mode 100644 index 0000000..2a272ea --- /dev/null +++ b/alembic/versions/ae94c53e7343_channel_accesses.py @@ -0,0 +1,28 @@ +"""channel_accesses + +Revision ID: ae94c53e7343 +Revises: 21c6fd6ac065 +Create Date: 2025-09-06 09:51:14.502916 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'ae94c53e7343' +down_revision: Union[str, Sequence[str], None] = '21c6fd6ac065' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + pass + + +def downgrade() -> None: + """Downgrade schema.""" + pass diff --git a/bin/update.sh b/bin/update.sh index f0259fa..dcacb94 100755 --- a/bin/update.sh +++ b/bin/update.sh @@ -1,26 +1,146 @@ -#!/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" # файл БД на хосте +DB_URL_DEFAULT="sqlite+aiosqlite:////app/db/bot.db" # ЕДИНЫЙ URL БД в контейнере + +log(){ echo -e "[update.sh] $*"; } +die(){ echo -e "[update.sh][ERROR] $*" >&2; exit 1; } + +# Запускать из корня репо, даже если скрипт лежит в bin/ +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +# --- Утилиты для alembic в "смонтированном" контейнере (чтобы файлы миграций попадали в репо) --- +alembic_run_mounted() { + # использование: alembic_run_mounted "upgrade head" + docker compose run --rm -T \ + -v "$PWD":/app \ + -w /app \ + "${SERVICE}" sh -lc "alembic $*" +} + +# --- 0) Приводим БД и .env к единому виду --- +log "Проверка каталога БД ${HOST_DB_DIR} ..." +mkdir -p "${HOST_DB_DIR}" + +# Если в проекте остался прежний конфликтный объект ./bot.db — убираем/переносим +if [[ -d ./bot.db ]]; then + 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 + sed -i "s|^DATABASE_URL=.*$|DATABASE_URL=${DB_URL_DEFAULT}|g" .env + log "DATABASE_URL в .env → ${DB_URL_DEFAULT}" + else + echo "DATABASE_URL=${DB_URL_DEFAULT}" >> .env + log "Добавил DATABASE_URL в .env → ${DB_URL_DEFAULT}" + fi +else + echo "DATABASE_URL=${DB_URL_DEFAULT}" > .env + log "Создал .env с DATABASE_URL=${DB_URL_DEFAULT}" +fi -echo "[update.sh] Пересборка контейнера..." -docker compose build --no-cache +# --- 1) git pull + сборка --- +log "git pull --rebase --autostash ..." +git pull --rebase --autostash || die "git pull не удался" -echo "[update.sh] Применение миграций Alembic..." -docker compose run --rm bot alembic upgrade head +log "Пересборка образа ..." +docker compose build --no-cache || die "docker compose build не удался" -echo "[update.sh] Запуск контейнера..." -docker compose up -d +# --- 2) Безопасный upgrade: выравниваем БД до HEAD; при «потере» ревизии чиним alembic_version --- +safe_upgrade() { + log "alembic upgrade head ..." + set +e + UPG_LOG="$(alembic_run_mounted 'upgrade head' 2>&1)" + RC=$? + set -e + if [[ $RC -eq 0 ]]; then + return 0 + fi -echo "[update.sh] Готово!" + echo "$UPG_LOG" + if grep -q "Can't locate revision identified by" <<< "$UPG_LOG"; then + log "Обнаружена «потерянная» ревизия. Автопочинка: подшиваю БД к актуальному HEAD ..." + # Узнаём актуальный HEAD из каталога миграций + set +e + HEADREV="$(alembic_run_mounted 'heads -v' | awk '/^Rev:/{print $2; exit}')" + set -e + [[ -n "${HEADREV:-}" ]] || die "Не удалось определить HEAD ревизию" + + # Переписываем alembic_version в файле БД (внутри контейнера сервиса) + docker compose run --rm -T \ + -v "$PWD":/app \ + -w /app \ + "${SERVICE}" sh -lc "sqlite3 /app/db/bot.db \"UPDATE alembic_version SET version_num='${HEADREV}';\" || true" + + # Повторяем апгрейд + alembic_run_mounted 'upgrade head' || die "Повторный upgrade head не удался" + else + die "alembic upgrade head не удался" + fi +} + +# --- 3) Выравниваем миграции до текущего HEAD --- +safe_upgrade + +# --- 4) (опционально) создаём ревизию с комментарием и применяем её --- +MIG_MSG="${1-}" +if [[ -z "${MIG_MSG}" ]]; then + read -rp "[update.sh] Комментарий для новой миграции (Enter — пропустить): " MIG_MSG || true +fi + +if [[ -n "${MIG_MSG}" ]]; then + log "Создаю ревизию Alembic с комментарием: ${MIG_MSG}" + alembic_run_mounted "revision --autogenerate -m \"${MIG_MSG}\"" || die "alembic revision --autogenerate не удался" + # Сразу применяем + safe_upgrade +else + log "Создание ревизии пропущено." +fi + +# --- 5) Запуск сервиса и пост-проверки --- +log "Запускаю контейнер ..." +docker compose up -d || die "docker compose up -d не удался" + +log "Проверка переменных и таблиц внутри контейнера ..." +docker compose exec -T "${SERVICE}" sh -lc " + echo 'DATABASE_URL='\"\$DATABASE_URL\"; + cd '${APP_DIR}'; + echo 'Alembic HEADS:'; alembic heads -v || true; + echo 'Alembic CURRENT:'; alembic current -v || true; + if [ -f /app/db/bot.db ]; then + echo 'Таблицы SQLite (/app/db/bot.db):'; + sqlite3 /app/db/bot.db '.tables' || true; + else + echo 'Внимание: /app/db/bot.db отсутствует!'; + fi +" || true + +log "Готово ✅" +log "Проверка переменных и таблиц внутри контейнера ..." +docker compose exec -T "${SERVICE}" sh -lc " + echo 'DATABASE_URL='\"\$DATABASE_URL\"; + cd '${APP_DIR}'; + echo 'Alembic HEADS:'; alembic heads -v || true; + echo 'Alembic CURRENT:'; alembic current -v || true; + if [ -f /app/db/bot.db ]; then + echo 'Таблицы SQLite (/app/db/bot.db):'; + sqlite3 /app/db/bot.db '.tables' || true; + else + echo 'Внимание: /app/db/bot.db отсутствует!'; + fi \ No newline at end of file diff --git a/db/bot.db-journal b/db/bot.db-journal new file mode 100644 index 0000000..27c3421 Binary files /dev/null and b/db/bot.db-journal differ diff --git a/docker-compose.yml b/docker-compose.yml index 082c0c5..85106a5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,5 +4,5 @@ services: env_file: - .env volumes: - - ./bot.db:/app/bot.db + - ./db:/app/db restart: unless-stopped diff --git a/handlers/add_channel.py b/handlers/add_channel.py index 450868e..c95d9e2 100644 --- a/handlers/add_channel.py +++ b/handlers/add_channel.py @@ -1,8 +1,12 @@ - +# handlers/add_channel.py from telegram import Update -from telegram.ext import ContextTypes, ConversationHandler, CommandHandler, MessageHandler, filters +from telegram.ext import ( + ContextTypes, ConversationHandler, CommandHandler, MessageHandler, filters +) +from sqlalchemy import select + from db import AsyncSessionLocal -from models import Channel +from models import Channel, Admin INPUT_NAME, INPUT_LINK = range(2) @@ -14,35 +18,46 @@ async def add_channel_start(update: Update, context: ContextTypes.DEFAULT_TYPE): return INPUT_NAME async def input_channel_name(update: Update, context: ContextTypes.DEFAULT_TYPE): - if context.user_data is None: - context.user_data = {} - text = update.message.text.strip() if update.message and update.message.text else '' - context.user_data['channel_name'] = text - if update.message: - await update.message.reply_text('Теперь отправьте ссылку на канал (должна начинаться с @):') + if not update.message: + return ConversationHandler.END + name = (update.message.text or "").strip() + if not name: + await update.message.reply_text("Имя не может быть пустым. Введите имя канала:") + return INPUT_NAME + context.user_data["channel_name"] = name + await update.message.reply_text('Отправьте ссылку на канал (формат "@username" или "-100..."):') return INPUT_LINK -async def input_channel_link(update: Update, context: ContextTypes.DEFAULT_TYPE): - if context.user_data is None: - context.user_data = {} - link = update.message.text.strip() if update.message and update.message.text else '' - if not link.startswith('@'): - if update.message: - await update.message.reply_text('Ошибка: ссылка на канал должна начинаться с @. Попробуйте снова.') - return INPUT_LINK - context.user_data['channel_link'] = link - return await save_channel(update, context) +async def _get_or_create_admin(session, tg_id: int) -> Admin: + res = await session.execute(select(Admin).where(Admin.tg_id == tg_id)) + admin = res.scalar_one_or_none() + if not admin: + admin = Admin(tg_id=tg_id) + session.add(admin) + await session.flush() + return admin -async def save_channel(update: Update, context: ContextTypes.DEFAULT_TYPE): - if context.user_data is None: - context.user_data = {} - name = context.user_data.get('channel_name') - link = context.user_data.get('channel_link') - if not name or not link: - if update.message: - await update.message.reply_text('Ошибка: не указано название или ссылка.') +async def input_channel_link(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not update.message: return ConversationHandler.END + + link = (update.message.text or "").strip() + if not (link.startswith("@") or link.startswith("-100")): + await update.message.reply_text('Неверный формат. Укажите "@username" или "-100...".') + return INPUT_LINK + + name = (context.user_data or {}).get("channel_name", "").strip() + if not name: + await update.message.reply_text("Не найдено имя. Начните заново: /add_channel") + return ConversationHandler.END + + user = update.effective_user + if not user: + await update.message.reply_text("Не удалось определить администратора.") + return ConversationHandler.END + async with AsyncSessionLocal() as session: +<<<<<<< HEAD channel = Channel(name=name, link=link) session.add(channel) await session.commit() @@ -51,6 +66,23 @@ async def save_channel(update: Update, context: ContextTypes.DEFAULT_TYPE): await log_action(user_id, "add_channel", f"name={name}, link={link}") if update.message: await update.message.reply_text(f'Канал "{name}" добавлен.') +======= + admin = await _get_or_create_admin(session, user.id) + + # если канал уже есть — обновим имя и владельца + existing_q = await session.execute(select(Channel).where(Channel.link == link)) + existing = existing_q.scalar_one_or_none() + if existing: + existing.name = name + existing.admin_id = admin.id + await session.commit() + await update.message.reply_text(f'Канал "{name}" уже был — обновил владельца и имя.') + else: + channel = Channel(name=name, link=link, admin_id=admin.id) + session.add(channel) + await session.commit() + await update.message.reply_text(f'Канал "{name}" добавлен и привязан к вашему админ-аккаунту.') +>>>>>>> main return ConversationHandler.END add_channel_conv = ConversationHandler( diff --git a/handlers/add_group.py b/handlers/add_group.py index 76e0dc3..f2d74ff 100644 --- a/handlers/add_group.py +++ b/handlers/add_group.py @@ -1,48 +1,139 @@ +# from telegram import Update +# from telegram.ext import ContextTypes, ConversationHandler, CommandHandler, MessageHandler, filters +# from db import AsyncSessionLocal +# from models import Group + +# INPUT_NAME, INPUT_LINK = range(2) + +# async def add_group_start(update: Update, context: ContextTypes.DEFAULT_TYPE): +# if context.user_data is None: +# context.user_data = {} +# if update.message: +# await update.message.reply_text('Введите имя группы:') +# return INPUT_NAME + +# async def input_group_name(update: Update, context: ContextTypes.DEFAULT_TYPE): +# if context.user_data is None: +# context.user_data = {} +# text = update.message.text.strip() if update.message and update.message.text else '' +# context.user_data['group_name'] = text +# if update.message: +# await update.message.reply_text('Теперь отправьте chat_id группы (например, -1001234567890):') +# return INPUT_LINK + +# async def input_group_link(update: Update, context: ContextTypes.DEFAULT_TYPE): +# if context.user_data is None: +# context.user_data = {} +# link = update.message.text.strip() if update.message and update.message.text else '' +# if not link.startswith('-100'): +# if update.message: +# await update.message.reply_text('Ошибка: chat_id группы должен начинаться с -100. Попробуйте снова.') +# return INPUT_LINK +# context.user_data['group_link'] = link +# return await save_group(update, context) + +# async def save_group(update: Update, context: ContextTypes.DEFAULT_TYPE): +# if context.user_data is None: +# context.user_data = {} +# name = context.user_data.get('group_name') +# link = context.user_data.get('group_link') +# if not name or not link: +# if update.message: +# await update.message.reply_text('Ошибка: не указано название или ссылка.') +# return ConversationHandler.END +# async with AsyncSessionLocal() as session: +# group = Group(name=name, link=link) +# session.add(group) +# await session.commit() +# if update.message: +# await update.message.reply_text(f'Группа "{name}" добавлена.') +# return ConversationHandler.END + +# add_group_conv = ConversationHandler( +# entry_points=[CommandHandler('add_group', add_group_start)], +# states={ +# INPUT_NAME: [MessageHandler(filters.TEXT & ~filters.COMMAND, input_group_name)], +# INPUT_LINK: [MessageHandler(filters.TEXT & ~filters.COMMAND, input_group_link)], +# }, +# fallbacks=[] +# ) + + +# handlers/add_group.py from telegram import Update -from telegram.ext import ContextTypes, ConversationHandler, CommandHandler, MessageHandler, filters +from telegram.ext import ( + ContextTypes, + ConversationHandler, + CommandHandler, + MessageHandler, + filters, +) +from sqlalchemy import select + from db import AsyncSessionLocal -from models import Group +from models import Group, Admin INPUT_NAME, INPUT_LINK = range(2) + async def add_group_start(update: Update, context: ContextTypes.DEFAULT_TYPE): if context.user_data is None: context.user_data = {} if update.message: - await update.message.reply_text('Введите имя группы:') + await update.message.reply_text("Введите имя группы:") return INPUT_NAME + async def input_group_name(update: Update, context: ContextTypes.DEFAULT_TYPE): - if context.user_data is None: - context.user_data = {} - text = update.message.text.strip() if update.message and update.message.text else '' - context.user_data['group_name'] = text - if update.message: - await update.message.reply_text('Теперь отправьте chat_id группы (например, -1001234567890):') + if not update.message: + return ConversationHandler.END + + name = (update.message.text or "").strip() + if not name: + await update.message.reply_text("Имя не может быть пустым. Введите имя группы:") + return INPUT_NAME + + context.user_data["group_name"] = name + await update.message.reply_text('Отправьте ссылку на группу (формат "@username" или "-100..."):') + return INPUT_LINK -async def input_group_link(update: Update, context: ContextTypes.DEFAULT_TYPE): - if context.user_data is None: - context.user_data = {} - link = update.message.text.strip() if update.message and update.message.text else '' - if not link.startswith('-100'): - if update.message: - await update.message.reply_text('Ошибка: chat_id группы должен начинаться с -100. Попробуйте снова.') - return INPUT_LINK - context.user_data['group_link'] = link - return await save_group(update, context) -async def save_group(update: Update, context: ContextTypes.DEFAULT_TYPE): - if context.user_data is None: - context.user_data = {} - name = context.user_data.get('group_name') - link = context.user_data.get('group_link') - if not name or not link: - if update.message: - await update.message.reply_text('Ошибка: не указано название или ссылка.') +async def _get_or_create_admin(session: AsyncSessionLocal, tg_id: int) -> Admin: + res = await session.execute(select(Admin).where(Admin.tg_id == tg_id)) + admin = res.scalar_one_or_none() + if not admin: + admin = Admin(tg_id=tg_id) + session.add(admin) + # Чтобы получить admin.id до commit + await session.flush() + return admin + + +async def input_group_link(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not update.message: return ConversationHandler.END + + link = (update.message.text or "").strip() + if not (link.startswith("@") or link.startswith("-100")): + await update.message.reply_text( + 'Неверный формат. Укажите "@username" (публичная группа/супергруппа) или "-100..." (ID).' + ) + return INPUT_LINK + + name = (context.user_data or {}).get("group_name", "").strip() + if not name: + await update.message.reply_text("Не найдено имя группы. Начните заново: /add_group") + return ConversationHandler.END + + user = update.effective_user + if not user: + await update.message.reply_text("Не удалось определить администратора. Попробуйте ещё раз.") + return ConversationHandler.END + async with AsyncSessionLocal() as session: +<<<<<<< HEAD group = Group(name=name, link=link) session.add(group) await session.commit() @@ -51,13 +142,36 @@ async def save_group(update: Update, context: ContextTypes.DEFAULT_TYPE): log_action(user_id, "add_group", f"name={name}, link={link}") if update.message: await update.message.reply_text(f'Группа "{name}" добавлена.') +======= + # гарантируем наличие админа + admin = await _get_or_create_admin(session, user.id) + + # проверка на существование группы по ссылке + existing_q = await session.execute(select(Group).where(Group.link == link)) + existing = existing_q.scalar_one_or_none() + + if existing: + existing.name = name + existing.admin_id = admin.id + await session.commit() + await update.message.reply_text( + f'Группа "{name}" уже была в базе — обновил владельца и имя.' + ) + else: + group = Group(name=name, link=link, admin_id=admin.id) + session.add(group) + await session.commit() + await update.message.reply_text(f'Группа "{name}" добавлена и привязана к вашему админ-аккаунту.') + +>>>>>>> main return ConversationHandler.END + add_group_conv = ConversationHandler( - entry_points=[CommandHandler('add_group', add_group_start)], + entry_points=[CommandHandler("add_group", add_group_start)], states={ INPUT_NAME: [MessageHandler(filters.TEXT & ~filters.COMMAND, input_group_name)], INPUT_LINK: [MessageHandler(filters.TEXT & ~filters.COMMAND, input_group_link)], }, - fallbacks=[] + fallbacks=[], ) diff --git a/handlers/new_post.py b/handlers/new_post.py index a85daac..11faf81 100644 --- a/handlers/new_post.py +++ b/handlers/new_post.py @@ -1,28 +1,112 @@ -from telegram import Update, InputMediaPhoto, InlineKeyboardMarkup, InlineKeyboardButton -from telegram.ext import ContextTypes, ConversationHandler, MessageHandler, CommandHandler, filters, CallbackQueryHandler, ContextTypes +# handlers/new_post.py +from __future__ import annotations + +from typing import List, Optional, Tuple +from telegram import ( + Update, InlineKeyboardMarkup, InlineKeyboardButton, MessageEntity, Bot +) +from telegram.ext import ( + ContextTypes, ConversationHandler, MessageHandler, CommandHandler, CallbackQueryHandler, filters +) +from telegram.constants import MessageEntityType +from telegram.error import BadRequest +from sqlalchemy import select as sa_select from db import AsyncSessionLocal +<<<<<<< HEAD from models import Channel, Group, Button, Admin +======= +from models import Channel, Group +from .permissions import get_or_create_admin, list_channels_for_admin, has_scope_on_channel, SCOPE_POST +from models import Channel, Group, Button +>>>>>>> main SELECT_MEDIA, SELECT_TEXT, SELECT_TARGET = range(3) +# ===== UTF-16 helpers (для custom_emoji) ===== +def _utf16_units_len(s: str) -> int: + return len(s.encode("utf-16-le")) // 2 + +def _utf16_index_map(text: str) -> List[Tuple[int, int, str]]: + out: List[Tuple[int, int, str]] = [] + off = 0 + for ch in text: + ln = _utf16_units_len(ch) + out.append((off, ln, ch)) + off += ln + return out + +def _split_custom_emoji_by_utf16(text: str, entities: List[MessageEntity]) -> List[MessageEntity]: + if not text or not entities: + return entities or [] + map_utf16 = _utf16_index_map(text) + out: List[MessageEntity] = [] + for e in entities: + if (e.type == MessageEntityType.CUSTOM_EMOJI and e.length and e.length > 1 and getattr(e, "custom_emoji_id", None)): + start = e.offset + end = e.offset + e.length + for uoff, ulen, _ in map_utf16: + if start <= uoff < end: + out.append(MessageEntity( + type=MessageEntityType.CUSTOM_EMOJI, + offset=uoff, + length=ulen, + custom_emoji_id=e.custom_emoji_id, + )) + else: + out.append(e) + out.sort(key=lambda x: x.offset) + return out + +def _strip_broken_entities(entities: Optional[List[MessageEntity]]) -> List[MessageEntity]: + cleaned: List[MessageEntity] = [] + for e in entities or []: + if e.offset is None or e.length is None or e.offset < 0 or e.length < 1: + continue + if e.type == MessageEntityType.CUSTOM_EMOJI and not getattr(e, "custom_emoji_id", None): + continue + cleaned.append(e) + cleaned.sort(key=lambda x: x.offset) + return cleaned + +def _extract_text_and_entities(msg) -> tuple[str, List[MessageEntity], bool]: + if getattr(msg, "text", None): + return msg.text, (msg.entities or []), False + if getattr(msg, "caption", None): + return msg.caption, (msg.caption_entities or []), True + return "", [], False + +# ===== Conversation ===== async def new_post_start(update: Update, context: ContextTypes.DEFAULT_TYPE): if update.message: - await update.message.reply_text('Отправьте картинку для поста или /skip:') + await update.message.reply_text("Отправьте медиа для поста или пришлите /skip:") return SELECT_MEDIA return ConversationHandler.END async def select_media(update: Update, context: ContextTypes.DEFAULT_TYPE): - if update.message and hasattr(update.message, 'photo') and update.message.photo: - if context.user_data is None: - context.user_data = {} - context.user_data['photo'] = update.message.photo[-1].file_id - if update.message: - await update.message.reply_text('Введите текст поста или пересланное сообщение:') + if context.user_data is None: + context.user_data = {} + if not update.message: + return ConversationHandler.END + + msg = update.message + if msg.text and msg.text.strip().lower() == "/skip": + await update.message.reply_text("Введите текст поста или перешлите сообщение (можно с кастом-эмодзи):") return SELECT_TEXT - return ConversationHandler.END + + if msg.photo: context.user_data["photo"] = msg.photo[-1].file_id + elif msg.animation:context.user_data["animation"] = msg.animation.file_id + elif msg.video: context.user_data["video"] = msg.video.file_id + elif msg.document: context.user_data["document"] = msg.document.file_id + elif msg.audio: context.user_data["audio"] = msg.audio.file_id + elif msg.voice: context.user_data["voice"] = msg.voice.file_id + elif msg.sticker: context.user_data["sticker"] = msg.sticker.file_id + + await update.message.reply_text("Введите текст поста или перешлите сообщение (можно с кастом-эмодзи):") + return SELECT_TEXT async def select_text(update: Update, context: ContextTypes.DEFAULT_TYPE): +<<<<<<< HEAD if update.message: if context.user_data is None: context.user_data = {} @@ -58,41 +142,185 @@ async def select_text(update: Update, context: ContextTypes.DEFAULT_TYPE): finally: await session.close() return ConversationHandler.END +======= + if not update.message: + return ConversationHandler.END + if context.user_data is None: + context.user_data = {} + + msg = update.message + text, entities, _ = _extract_text_and_entities(msg) + entities = _strip_broken_entities(entities) + entities = _split_custom_emoji_by_utf16(text, entities) + + # сохраним исходник для copyMessage + context.user_data["text"] = text + context.user_data["entities"] = entities + context.user_data["src_chat_id"] = update.effective_chat.id + context.user_data["src_msg_id"] = update.message.message_id + + # дать выбор только тех каналов, где у текущего админа есть право постинга + async with AsyncSessionLocal() as session: + me = await get_or_create_admin(session, update.effective_user.id) + channels = await list_channels_for_admin(session, me.id) + + # группы оставляем без ACL (как было) + groups = (await session.execute(sa_select(Group))).scalars().all() + + # если каналов нет — всё равно покажем группы + keyboard = [] + for c in channels: + keyboard.append([InlineKeyboardButton(f'Канал: {c.name}', callback_data=f'channel_{c.id}')]) + for g in groups: + keyboard.append([InlineKeyboardButton(f'Группа: {g.name}', callback_data=f'group_{g.id}')]) + + if not keyboard: + await update.message.reply_text("Нет доступных каналов/групп для отправки.") + return ConversationHandler.END + + await update.message.reply_text('Выберите, куда отправить пост:', reply_markup=InlineKeyboardMarkup(keyboard)) + return SELECT_TARGET +>>>>>>> main async def select_target(update: Update, context: ContextTypes.DEFAULT_TYPE): query = update.callback_query if not query: return ConversationHandler.END await query.answer() - data = query.data - session = AsyncSessionLocal() - try: - chat_id = None - markup = None - if data and data.startswith('channel_'): - from sqlalchemy import select - channel_id = int(data.split('_')[1]) - channel_result = await session.execute(select(Channel).where(Channel.id == channel_id)) - channel = channel_result.scalar_one_or_none() - buttons_result = await session.execute(select(Button).where(Button.channel_id == channel_id)) - buttons = buttons_result.scalars().all() - 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_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...)') + + data = (query.data or "") + async with AsyncSessionLocal() as session: + chat_id: str | None = None + markup: InlineKeyboardMarkup | None = None + selected_title: str | None = None + btns = [] + + if data.startswith('channel_'): + channel_id = int(data.split('_', 1)[1]) + + # ACL: право постинга в канал + me = await get_or_create_admin(session, update.effective_user.id) + allowed = await has_scope_on_channel(session, me.id, channel_id, SCOPE_POST) + if not allowed: + await query.edit_message_text("У вас нет права постить в этот канал.") return ConversationHandler.END + + channel = (await session.execute(sa_select(Channel).where(Channel.id == channel_id))).scalar_one_or_none() + if not channel: + await query.edit_message_text("Канал не найден.") + return ConversationHandler.END + + chat_id = (channel.link or "").strip() + selected_title = channel.name + + # Кнопки канала + btns = (await session.execute(sa_select(Button).where(Button.channel_id == channel_id))).scalars().all() + if btns: + rows = [[InlineKeyboardButton(str(b.name), url=str(b.url))] for b in btns] + markup = InlineKeyboardMarkup(rows) + + elif data.startswith('group_'): + group_id = int(data.split('_', 1)[1]) + + group = (await session.execute(sa_select(Group).where(Group.id == group_id))).scalar_one_or_none() + if not group: + await query.edit_message_text("Группа не найдена.") + return ConversationHandler.END + + chat_id = (group.link or "").strip() + selected_title = group.name + + # Кнопки группы + btns = (await session.execute(sa_select(Button).where(Button.group_id == group_id))).scalars().all() + if btns: + rows = [[InlineKeyboardButton(str(b.name), url=str(b.url))] for b in btns] + markup = InlineKeyboardMarkup(rows) + + if not chat_id or not (chat_id.startswith('@') or chat_id.startswith('-')): + await query.edit_message_text('Ошибка: ссылка должна быть username (@channel) или числовой ID (-100...)') + return ConversationHandler.END + + # DEBUG: сколько кнопок нашли и есть ли markup + print(f"[DEBUG] send -> chat_id={chat_id} title={selected_title!r} buttons={len(btns)} has_markup={bool(markup)}") + + # Текст и entities (без parse_mode) + ud = context.user_data or {} + text: str = ud.get("text", "") or "" + entities: List[MessageEntity] = ud.get("entities", []) or [] + entities = _strip_broken_entities(entities) + entities = _split_custom_emoji_by_utf16(text, entities) + + # Всегда ручная отправка (send_*), чтобы гарантированно приклеить inline-клавиатуру + try: + sent_msg = None + if "photo" in ud: + sent_msg = await context.bot.send_photo( + chat_id=chat_id, + photo=ud["photo"], + caption=(text or None), + caption_entities=(entities if text else None), + reply_markup=markup, + ) + elif "animation" in ud: + sent_msg = await context.bot.send_animation( + chat_id=chat_id, + animation=ud["animation"], + caption=(text or None), + caption_entities=(entities if text else None), + reply_markup=markup, + ) + elif "video" in ud: + sent_msg = await context.bot.send_video( + chat_id=chat_id, + video=ud["video"], + caption=(text or None), + caption_entities=(entities if text else None), + reply_markup=markup, + ) + elif "document" in ud: + sent_msg = await context.bot.send_document( + chat_id=chat_id, + document=ud["document"], + caption=(text or None), + caption_entities=(entities if text else None), + reply_markup=markup, + ) + elif "audio" in ud: + sent_msg = await context.bot.send_audio( + chat_id=chat_id, + audio=ud["audio"], + caption=(text or None), + caption_entities=(entities if text else None), + reply_markup=markup, + ) + elif "voice" in ud: + sent_msg = await context.bot.send_voice( + chat_id=chat_id, + voice=ud["voice"], + caption=(text or None), + caption_entities=(entities if text else None), + reply_markup=markup, + ) + elif "sticker" in ud: + sent_msg = await context.bot.send_sticker( + chat_id=chat_id, + sticker=ud["sticker"], + reply_markup=markup, + ) + if text: + await context.bot.send_message(chat_id=chat_id, text=text, entities=entities) + else: + sent_msg = await context.bot.send_message( + chat_id=chat_id, + text=text, + entities=entities, + reply_markup=markup, + ) + + # Страховка: если вдруг Telegram проглотил клаву — доклеим её + if markup and getattr(sent_msg, "message_id", None): try: +<<<<<<< HEAD # Пересылка исходного сообщения await context.bot.forward_message( chat_id=chat_id, @@ -107,15 +335,35 @@ async def select_target(update: Update, context: ContextTypes.DEFAULT_TYPE): await query.edit_message_text(f'Ошибка пересылки поста: {e}') finally: await session.close() +======= + await context.bot.edit_message_reply_markup( + chat_id=chat_id, + message_id=sent_msg.message_id, + reply_markup=markup, + ) + except Exception: + pass + + await query.edit_message_text(f'Пост отправлен{(" в: " + selected_title) if selected_title else "!"}') + except BadRequest as e: + await query.edit_message_text(f'Ошибка отправки поста: {e}') + +>>>>>>> main return ConversationHandler.END + + + new_post_conv = ConversationHandler( - entry_points=[CommandHandler('new_post', new_post_start)], + entry_points=[CommandHandler("new_post", new_post_start)], states={ - SELECT_MEDIA: [MessageHandler(filters.PHOTO | filters.Document.IMAGE | filters.COMMAND, select_media)], - SELECT_TEXT: [MessageHandler(filters.TEXT | filters.FORWARDED, select_text)], + SELECT_MEDIA: [MessageHandler( + filters.PHOTO | filters.ANIMATION | filters.VIDEO | filters.Document.ALL | + filters.AUDIO | filters.VOICE | filters.Sticker.ALL | filters.COMMAND, + select_media + )], + SELECT_TEXT: [MessageHandler(filters.TEXT | filters.FORWARDED | filters.CAPTION, select_text)], SELECT_TARGET: [CallbackQueryHandler(select_target)], }, fallbacks=[], - - ) +) diff --git a/handlers/permissions.py b/handlers/permissions.py new file mode 100644 index 0000000..8f34b88 --- /dev/null +++ b/handlers/permissions.py @@ -0,0 +1,68 @@ +# permissions.py +import hashlib, secrets +from datetime import datetime, timedelta +from sqlalchemy import select +from models import Admin, Channel, ChannelAccess, SCOPE_POST, SCOPE_SHARE +from sqlalchemy.exc import OperationalError + + + +def make_token(nbytes: int = 9) -> str: + # Короткий URL-safe токен (<= ~12-16 символов укладывается в /start payload) + return secrets.token_urlsafe(nbytes) + +def token_hash(token: str) -> str: + return hashlib.sha256(token.encode('utf-8')).hexdigest() + +async def get_or_create_admin(session, tg_id: int) -> Admin: + res = await session.execute(select(Admin).where(Admin.tg_id == tg_id)) + admin = res.scalar_one_or_none() + if not admin: + admin = Admin(tg_id=tg_id) + session.add(admin) + await session.flush() + return admin + +async def has_scope_on_channel(session, admin_id: int, channel_id: int, scope: int) -> bool: + # Владелец канала — всегда полный доступ + res = await session.execute(select(Channel).where(Channel.id == channel_id)) + ch = res.scalar_one_or_none() + if ch and ch.admin_id == admin_id: + return True + + # Иначе ищем активный доступ с нужной маской + res = await session.execute( + select(ChannelAccess).where( + ChannelAccess.channel_id == channel_id, + ChannelAccess.invited_admin_id == admin_id, + ChannelAccess.status == "active", + ) + ) + acc = res.scalar_one_or_none() + if not acc: + return False + return (acc.scopes & scope) == scope + +async def list_channels_for_admin(session, admin_id: int): + q1 = await session.execute(select(Channel).where(Channel.admin_id == admin_id)) + owned = q1.scalars().all() + try: + q2 = await session.execute( + select(ChannelAccess).where( + ChannelAccess.invited_admin_id == admin_id, + ChannelAccess.status == "active", + ) + ) + rows = q2.scalars().all() + except OperationalError: + return owned # таблицы ещё нет — просто вернём свои каналы + + can_post_ids = {r.channel_id for r in rows if (r.scopes & SCOPE_POST)} + if not can_post_ids: + return owned + q3 = await session.execute(select(Channel).where(Channel.id.in_(can_post_ids))) + shared = q3.scalars().all() + d = {c.id: c for c in owned} + for c in shared: + d[c.id] = c + return list(d.values()) diff --git a/handlers/share_channel.py b/handlers/share_channel.py new file mode 100644 index 0000000..4a427eb --- /dev/null +++ b/handlers/share_channel.py @@ -0,0 +1,140 @@ +# handlers/share_channel.py +from datetime import datetime, timedelta +from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup +from telegram.ext import ( + ContextTypes, ConversationHandler, CommandHandler, CallbackQueryHandler +) +from sqlalchemy import select + +from db import AsyncSessionLocal +from models import Channel, ChannelAccess, SCOPE_POST +from .permissions import get_or_create_admin, make_token, token_hash +from telegram.error import BadRequest +import os +from telegram import InlineKeyboardMarkup, InlineKeyboardButton + +async def _get_bot_username(context: ContextTypes.DEFAULT_TYPE) -> str: + # кэшируем, чтобы не дёргать get_me() каждый раз + uname = context.application.bot.username + if uname: + return uname + me = await context.bot.get_me() + return me.username + +SELECT_CHANNEL, CONFIRM_INVITE = range(2) + +async def share_channel_start(update: Update, context: ContextTypes.DEFAULT_TYPE): + async with AsyncSessionLocal() as session: + me = await get_or_create_admin(session, update.effective_user.id) + q = await session.execute(select(Channel).where(Channel.admin_id == me.id)) + channels = q.scalars().all() + + if not channels: + if update.message: + await update.message.reply_text("Нет каналов, которыми вы владеете.") + return ConversationHandler.END + + kb = [[InlineKeyboardButton(f"{c.name} ({c.link})", callback_data=f"sch_{c.id}")] for c in channels] + rm = InlineKeyboardMarkup(kb) + if update.message: + await update.message.reply_text("Выберите канал для выдачи доступа:", reply_markup=rm) + return SELECT_CHANNEL + +async def select_channel(update: Update, context: ContextTypes.DEFAULT_TYPE): + q = update.callback_query + if not q: return ConversationHandler.END + await q.answer() + if not q.data.startswith("sch_"): return ConversationHandler.END + channel_id = int(q.data.split("_")[1]) + context.user_data["share_channel_id"] = channel_id + + kb = [ + [InlineKeyboardButton("Срок: 7 дней", callback_data="ttl_7"), + InlineKeyboardButton("30 дней", callback_data="ttl_30"), + InlineKeyboardButton("∞", callback_data="ttl_inf")], + [InlineKeyboardButton("Выдать право постинга", callback_data="scope_post")], + [InlineKeyboardButton("Сгенерировать ссылку", callback_data="go")], + ] + await q.edit_message_text("Настройте приглашение:", reply_markup=InlineKeyboardMarkup(kb)) + context.user_data["ttl_days"] = 7 + context.user_data["scopes"] = SCOPE_POST + return CONFIRM_INVITE + +async def confirm_invite(update: Update, context: ContextTypes.DEFAULT_TYPE): + q = update.callback_query + if not q: + return ConversationHandler.END + # Лёгкий ACK, чтобы исчез «часик» на кнопке + await q.answer() + + data = q.data + + # --- настройки TTL (ничего не меняем в разметке, только сохраняем выбор) --- + if data.startswith("ttl_"): + context.user_data["ttl_days"] = {"ttl_7": 7, "ttl_30": 30, "ttl_inf": None}[data] + # Нечего редактировать — markup не менялся. Просто остаёмся в состоянии. + return CONFIRM_INVITE + + # --- права: сейчас фиксировано SCOPE_POST, разметку не меняем --- + if data == "scope_post": + # если позже сделаешь тумблеры прав — тут можно перестраивать клавиатуру + context.user_data["scopes"] = SCOPE_POST + return CONFIRM_INVITE + + # --- генерация ссылки приглашения --- + if data != "go": + return CONFIRM_INVITE + + channel_id = context.user_data.get("share_channel_id") + ttl_days = context.user_data.get("ttl_days") + scopes = context.user_data.get("scopes", SCOPE_POST) + + async with AsyncSessionLocal() as session: + me = await get_or_create_admin(session, update.effective_user.id) + + token = make_token(9) + thash = token_hash(token) + + expires_at = None + if ttl_days: + from datetime import datetime, timedelta + expires_at = datetime.utcnow() + timedelta(days=ttl_days) + + acc = ChannelAccess( + channel_id=channel_id, + invited_by_admin_id=me.id, + token_hash=thash, + scopes=scopes, + status="pending", + created_at=datetime.utcnow(), + expires_at=expires_at, + ) + session.add(acc) + await session.commit() + invite_id = acc.id + + payload = f"sch_{invite_id}_{token}" + bot_username = await _get_bot_username(context) + deep_link = f"https://t.me/{bot_username}?start={payload}" + + # Кнопка для удобства + kb = InlineKeyboardMarkup([[InlineKeyboardButton("Открыть ссылку", url=deep_link)]]) + + await q.edit_message_text( + "Ссылка для предоставления доступа к каналу:\n" + f"`{deep_link}`\n\n" + "Передайте её коллеге. Срок действия — " + + ("не ограничен." if ttl_days is None else f"{ttl_days} дней."), + parse_mode="Markdown", + reply_markup=kb, + ) + return ConversationHandler.END + +share_channel_conv = ConversationHandler( + entry_points=[CommandHandler("share_channel", share_channel_start)], + states={ + SELECT_CHANNEL: [CallbackQueryHandler(select_channel, pattern="^sch_")], + CONFIRM_INVITE: [CallbackQueryHandler(confirm_invite)], + }, + fallbacks=[], +) diff --git a/main.py b/main.py index 87e5db1..fbdc37c 100644 --- a/main.py +++ b/main.py @@ -21,22 +21,46 @@ logging.getLogger("httpx").setLevel(logging.WARNING) logger = logging.getLogger(__name__) import asyncio - +from telegram.ext import CommandHandler +from sqlalchemy import select +from datetime import datetime +from db import AsyncSessionLocal +from models import ChannelAccess +from handlers.permissions import get_or_create_admin, token_hash async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): - session = AsyncSessionLocal() - user_id = update.effective_user.id if update.effective_user else None - result = await session.execute(Admin.__table__.select().where(Admin.tg_id == user_id)) - admin = result.first() if user_id else None - if not admin and user_id: - await session.execute(Admin.__table__.insert().values(tg_id=user_id)) - await session.commit() - if update.message: - await update.message.reply_text('Вы зарегистрированы как админ.') - else: - if update.message: - await update.message.reply_text('Вы уже зарегистрированы.') - await session.close() + args = (context.args or []) + if args and args[0].startswith("sch_"): + # формат: sch__ + try: + _, sid, token = args[0].split("_", 2) + invite_id = int(sid) + except Exception: + await update.message.reply_text("Неверная ссылка приглашения.") + return + + async with AsyncSessionLocal() as session: + me = await get_or_create_admin(session, update.effective_user.id) + res = await session.execute(select(ChannelAccess).where(ChannelAccess.id == invite_id)) + acc = res.scalar_one_or_none() + if not acc or acc.status != "pending": + await update.message.reply_text("Приглашение не найдено или уже активировано/отозвано.") + return + if acc.expires_at and acc.expires_at < datetime.utcnow(): + await update.message.reply_text("Срок действия приглашения истёк.") + return + if token_hash(token) != acc.token_hash: + await update.message.reply_text("Неверный токен приглашения.") + return + + acc.invited_admin_id = me.id + acc.accepted_at = datetime.utcnow() + acc.status = "active" + await session.commit() + + await update.message.reply_text("Доступ к каналу успешно активирован. Можно постить через /new_post.") + return + async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE): help_text = ( @@ -75,6 +99,18 @@ from handlers.group_buttons import group_buttons_conv from handlers.channel_buttons import channel_buttons_conv from handlers.edit_button import edit_button from handlers.del_button import del_button +from handlers.share_channel import share_channel_conv + +import logging +from telegram.error import BadRequest +logger = logging.getLogger(__name__) + +async def on_error(update: Update, context: ContextTypes.DEFAULT_TYPE): + err = context.error + # подавляем шумные 400-е, когда контент/markup не меняется + if isinstance(err, BadRequest) and "Message is not modified" in str(err): + return + logger.exception("Unhandled exception", exc_info=err) @@ -82,7 +118,7 @@ def main(): if not TELEGRAM_TOKEN: print("Ошибка: TELEGRAM_TOKEN не найден в переменных окружения.") return - sync_to_async(init_db()) + # sync_to_async(init_db()) application = Application.builder().token(TELEGRAM_TOKEN).build() application.add_handler(CommandHandler('start', start)) application.add_handler(CommandHandler('help', help_command)) @@ -95,8 +131,12 @@ def main(): application.add_handler(CommandHandler('edit_button', edit_button)) application.add_handler(CommandHandler('del_button', del_button)) application.add_handler(admin_panel_conv) +<<<<<<< HEAD application.add_handler(share_bot_handler) application.add_handler(invite_admin_handler) +======= + application.add_handler(share_channel_conv) +>>>>>>> main import sys import asyncio if sys.platform.startswith('win'): @@ -107,7 +147,7 @@ def main(): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) application.run_polling() - + if __name__ == "__main__": main() diff --git a/models.py b/models.py index efb1a89..39f2d84 100644 --- a/models.py +++ b/models.py @@ -1,10 +1,16 @@ +<<<<<<< HEAD from datetime import datetime from sqlalchemy import Column, Integer, String, ForeignKey, Text +======= +from sqlalchemy import Column, Integer, String, ForeignKey, Text, DateTime, Boolean +>>>>>>> main from sqlalchemy.orm import relationship from db import Base +from datetime import datetime +<<<<<<< HEAD class ActionLog(Base): __tablename__ = 'action_logs' id = Column(Integer, primary_key=True) @@ -13,6 +19,36 @@ class ActionLog(Base): details = Column(String) timestamp = Column(String, default=lambda: datetime.utcnow().isoformat()) +======= +# Битовые флаги прав +SCOPE_POST = 1 # право постить +SCOPE_MANAGE_BTNS = 2 # право управлять кнопками (опционально) +SCOPE_SHARE = 4 # право делиться дальше (опционально) + +class ChannelAccess(Base): + __tablename__ = "channel_accesses" + id = Column(Integer, primary_key=True) + channel_id = Column(Integer, ForeignKey("channels.id"), nullable=False) + + # Кто выдал доступ (владелец/менеджер с SCOPE_SHARE) + invited_by_admin_id = Column(Integer, ForeignKey("admins.id"), nullable=False) + + # Кому выдан доступ (заполняется при активации, до активации = NULL) + invited_admin_id = Column(Integer, ForeignKey("admins.id"), nullable=True) + + # Безопасно: храним ХЭШ токена приглашения (сам токен не храним) + token_hash = Column(String, nullable=False) + + scopes = Column(Integer, default=SCOPE_POST, nullable=False) # битовая маска + status = Column(String, default="pending", nullable=False) # pending|active|revoked|expired + + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + accepted_at = Column(DateTime, nullable=True) + revoked_at = Column(DateTime, nullable=True) + expires_at = Column(DateTime, nullable=True) + + channel = relationship("Channel", foreign_keys=[channel_id]) +>>>>>>> main class Admin(Base): __tablename__ = 'admins' id = Column(Integer, primary_key=True) diff --git a/update.sh b/update.sh new file mode 100644 index 0000000..e69de29