Compare commits
33 Commits
c6574efee3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 73ce8b745d | |||
| 506acfcde5 | |||
| 18f91bbd40 | |||
| 929d2face6 | |||
| 47ac64adec | |||
| e8a2a2ebc7 | |||
| 2bcf07f6a9 | |||
| ce8ec7db45 | |||
| 297af93fff | |||
| a22ba094db | |||
| 5c81aae29c | |||
| c6104455d8 | |||
| 405e663da4 | |||
| b987031410 | |||
| df9d8b295d | |||
| 61b9bd8cfe | |||
| f079ad2cf7 | |||
| e7a40b4718 | |||
| 443799d480 | |||
| 7254175cdb | |||
| c2b56ba8d6 | |||
| a0cbdd5358 | |||
| 05990bf36e | |||
| 97b20d799e | |||
| f1d782bb74 | |||
| 2dc77169da | |||
| 908aff8b3d | |||
| 7c80a51a82 | |||
| 4ea540021a | |||
| 0365659d55 | |||
| 3aabe99a4e | |||
| 1c87721611 | |||
| a91283b681 |
@@ -9,6 +9,10 @@ import os
|
|||||||
# this is the Alembic Config object, which provides
|
# this is the Alembic Config object, which provides
|
||||||
# access to the values within the .ini file in use.
|
# access to the values within the .ini file in use.
|
||||||
config = context.config
|
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.
|
# Interpret the config file for Python logging.
|
||||||
# This line sets up loggers basically.
|
# This line sets up loggers basically.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""init
|
"""admins checkin
|
||||||
|
|
||||||
Revision ID: 69ef23ef1ed1
|
Revision ID: 21c6fd6ac065
|
||||||
Revises:
|
Revises: eeb6744b9452
|
||||||
Create Date: 2025-09-05 13:53:02.737876
|
Create Date: 2025-09-06 08:41:08.145822
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
@@ -12,8 +12,8 @@ import sqlalchemy as sa
|
|||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = '69ef23ef1ed1'
|
revision: str = '21c6fd6ac065'
|
||||||
down_revision: Union[str, Sequence[str], None] = None
|
down_revision: Union[str, Sequence[str], None] = 'eeb6744b9452'
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
28
alembic/versions/50652f5156d8_channel_accesses.py
Normal file
28
alembic/versions/50652f5156d8_channel_accesses.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""channel_accesses
|
||||||
|
|
||||||
|
Revision ID: 50652f5156d8
|
||||||
|
Revises: 96a65ea5f555
|
||||||
|
Create Date: 2025-09-06 10:01:41.613022
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '50652f5156d8'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '96a65ea5f555'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
pass
|
||||||
@@ -1,45 +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: создаём новую таблицу, копируем данные, удаляем старую, переименовываем новую
|
|
||||||
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')
|
|
||||||
28
alembic/versions/96a65ea5f555_channel_accesses.py
Normal file
28
alembic/versions/96a65ea5f555_channel_accesses.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""channel_accesses
|
||||||
|
|
||||||
|
Revision ID: 96a65ea5f555
|
||||||
|
Revises: ae94c53e7343
|
||||||
|
Create Date: 2025-09-06 09:59:33.965591
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '96a65ea5f555'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = 'ae94c53e7343'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
pass
|
||||||
28
alembic/versions/ae94c53e7343_channel_accesses.py
Normal file
28
alembic/versions/ae94c53e7343_channel_accesses.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""channel_accesses
|
||||||
|
|
||||||
|
Revision ID: ae94c53e7343
|
||||||
|
Revises: 21c6fd6ac065
|
||||||
|
Create Date: 2025-09-06 09:51:14.502916
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'ae94c53e7343'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '21c6fd6ac065'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
pass
|
||||||
57
alembic/versions/eeb6744b9452_init.py
Normal file
57
alembic/versions/eeb6744b9452_init.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""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:
|
||||||
|
"""Создание всех таблиц согласно моделям."""
|
||||||
|
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(), 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(), 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(), 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),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Удаление всех таблиц."""
|
||||||
|
op.drop_table('buttons')
|
||||||
|
op.drop_table('groups')
|
||||||
|
op.drop_table('channels')
|
||||||
|
op.drop_table('admins')
|
||||||
152
bin/update.sh
152
bin/update.sh
@@ -1,16 +1,146 @@
|
|||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
set -e
|
set -Eeuo pipefail
|
||||||
|
|
||||||
echo "[update.sh] Получение свежего кода..."
|
# === Настройки ===
|
||||||
git pull
|
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 БД в контейнере
|
||||||
|
|
||||||
echo "[update.sh] Пересборка контейнера..."
|
log(){ echo -e "[update.sh] $*"; }
|
||||||
docker compose build --no-cache
|
die(){ echo -e "[update.sh][ERROR] $*" >&2; exit 1; }
|
||||||
|
|
||||||
echo "[update.sh] Применение миграций Alembic..."
|
# Запускать из корня репо, даже если скрипт лежит в bin/
|
||||||
docker compose run --rm bot alembic upgrade head
|
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||||
|
|
||||||
echo "[update.sh] Запуск контейнера..."
|
# --- Утилиты для alembic в "смонтированном" контейнере (чтобы файлы миграций попадали в репо) ---
|
||||||
docker compose up -d
|
alembic_run_mounted() {
|
||||||
|
# использование: alembic_run_mounted "upgrade head"
|
||||||
|
docker compose run --rm -T \
|
||||||
|
-v "$PWD":/app \
|
||||||
|
-w /app \
|
||||||
|
"${SERVICE}" sh -lc "alembic $*"
|
||||||
|
}
|
||||||
|
|
||||||
echo "[update.sh] Готово!"
|
# --- 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 && ! -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
|
||||||
|
|
||||||
|
# .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
|
||||||
|
|
||||||
|
# --- 1) git pull + сборка ---
|
||||||
|
log "git pull --rebase --autostash ..."
|
||||||
|
git pull --rebase --autostash || die "git pull не удался"
|
||||||
|
|
||||||
|
log "Пересборка образа ..."
|
||||||
|
docker compose build --no-cache || die "docker compose build не удался"
|
||||||
|
|
||||||
|
# --- 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 "$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
|
||||||
10
db.py
10
db.py
@@ -9,15 +9,13 @@ DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///bot.db")
|
|||||||
|
|
||||||
if DATABASE_URL.startswith("sqlite+aiosqlite:///"):
|
if DATABASE_URL.startswith("sqlite+aiosqlite:///"):
|
||||||
db_path = DATABASE_URL.replace("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)
|
abs_db_path = os.path.abspath(db_path)
|
||||||
db_dir = os.path.dirname(abs_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 db_dir != '.' and not os.path.exists(db_dir):
|
||||||
os.makedirs(db_dir, exist_ok=True)
|
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
|
import shutil
|
||||||
shutil.rmtree(abs_db_path)
|
shutil.rmtree(abs_db_path)
|
||||||
# Если файла нет, создаём пустой файл
|
# Если файла нет, создаём пустой файл
|
||||||
|
|||||||
BIN
db/bot.db-journal
Normal file
BIN
db/bot.db-journal
Normal file
Binary file not shown.
@@ -4,5 +4,5 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
volumes:
|
volumes:
|
||||||
- ./bot.db:/app/bot.db
|
- ./db:/app/db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -1,18 +1,84 @@
|
|||||||
|
# handlers/add_channel.py
|
||||||
from telegram import Update
|
from telegram import Update
|
||||||
from telegram.ext import ContextTypes
|
from telegram.ext import (
|
||||||
from db import AsyncSessionLocal
|
ContextTypes, ConversationHandler, CommandHandler, MessageHandler, filters
|
||||||
from models import Channel
|
)
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from db import AsyncSessionLocal
|
||||||
|
from models import Channel, Admin
|
||||||
|
|
||||||
|
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 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 _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 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 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]
|
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
channel = Channel(name=name, link=link)
|
admin = await _get_or_create_admin(session, user.id)
|
||||||
session.add(channel)
|
|
||||||
await session.commit()
|
# если канал уже есть — обновим имя и владельца
|
||||||
await update.message.reply_text(f'Канал "{name}" добавлен.')
|
existing_q = await session.execute(select(Channel).where(Channel.link == link))
|
||||||
|
existing = existing_q.scalar_one_or_none()
|
||||||
|
if existing:
|
||||||
|
existing.name = name
|
||||||
|
existing.admin_id = admin.id
|
||||||
|
await session.commit()
|
||||||
|
await update.message.reply_text(f'Канал "{name}" уже был — обновил владельца и имя.')
|
||||||
|
else:
|
||||||
|
channel = Channel(name=name, link=link, admin_id=admin.id)
|
||||||
|
session.add(channel)
|
||||||
|
await session.commit()
|
||||||
|
await update.message.reply_text(f'Канал "{name}" добавлен и привязан к вашему админ-аккаунту.')
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
add_channel_conv = ConversationHandler(
|
||||||
|
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=[]
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,19 +1,166 @@
|
|||||||
from telegram import Update
|
|
||||||
from telegram.ext import ContextTypes
|
|
||||||
from db import AsyncSessionLocal
|
|
||||||
from models import Group
|
|
||||||
|
|
||||||
async def add_group(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
# from telegram import Update
|
||||||
args = context.args or []
|
# from telegram.ext import ContextTypes, ConversationHandler, CommandHandler, MessageHandler, filters
|
||||||
if update.message is None:
|
# from db import AsyncSessionLocal
|
||||||
return
|
# from models import Group
|
||||||
if len(args) < 2:
|
|
||||||
await update.message.reply_text('Используйте: /add_group <название> <ссылка>')
|
# INPUT_NAME, INPUT_LINK = range(2)
|
||||||
return
|
|
||||||
name, link = args[0], args[1]
|
# async def add_group_start(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
session = AsyncSessionLocal()
|
# if context.user_data is None:
|
||||||
group = Group(name=name, link=link)
|
# context.user_data = {}
|
||||||
session.add(group)
|
# if update.message:
|
||||||
session.commit()
|
# await update.message.reply_text('Введите имя группы:')
|
||||||
session.close()
|
# return INPUT_NAME
|
||||||
await update.message.reply_text(f'Группа "{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 sqlalchemy import select
|
||||||
|
|
||||||
|
from db import AsyncSessionLocal
|
||||||
|
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("Введите имя группы:")
|
||||||
|
return INPUT_NAME
|
||||||
|
|
||||||
|
|
||||||
|
async def input_group_name(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
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 _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:
|
||||||
|
# гарантируем наличие админа
|
||||||
|
admin = await _get_or_create_admin(session, user.id)
|
||||||
|
|
||||||
|
# проверка на существование группы по ссылке
|
||||||
|
existing_q = await session.execute(select(Group).where(Group.link == link))
|
||||||
|
existing = existing_q.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
existing.name = name
|
||||||
|
existing.admin_id = admin.id
|
||||||
|
await session.commit()
|
||||||
|
await update.message.reply_text(
|
||||||
|
f'Группа "{name}" уже была в базе — обновил владельца и имя.'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
group = Group(name=name, link=link, admin_id=admin.id)
|
||||||
|
session.add(group)
|
||||||
|
await session.commit()
|
||||||
|
await update.message.reply_text(f'Группа "{name}" добавлена и привязана к вашему админ-аккаунту.')
|
||||||
|
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
|
||||||
|
add_group_conv = ConversationHandler(
|
||||||
|
entry_points=[CommandHandler("add_group", add_group_start)],
|
||||||
|
states={
|
||||||
|
INPUT_NAME: [MessageHandler(filters.TEXT & ~filters.COMMAND, input_group_name)],
|
||||||
|
INPUT_LINK: [MessageHandler(filters.TEXT & ~filters.COMMAND, input_group_link)],
|
||||||
|
},
|
||||||
|
fallbacks=[],
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,104 +1,310 @@
|
|||||||
from telegram import Update, InputMediaPhoto, InlineKeyboardMarkup, InlineKeyboardButton
|
# handlers/new_post.py
|
||||||
from telegram.ext import ContextTypes, ConversationHandler, MessageHandler, CommandHandler, filters, CallbackQueryHandler, ContextTypes
|
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
|
from db import AsyncSessionLocal
|
||||||
|
from models import Channel, Group
|
||||||
|
from .permissions import get_or_create_admin, list_channels_for_admin, has_scope_on_channel, SCOPE_POST
|
||||||
from models import Channel, Group, Button
|
from models import Channel, Group, Button
|
||||||
|
|
||||||
SELECT_MEDIA, SELECT_TEXT, SELECT_TARGET = range(3)
|
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):
|
async def new_post_start(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
if update.message:
|
if update.message:
|
||||||
await update.message.reply_text('Отправьте картинку для поста или /skip:')
|
await update.message.reply_text("Отправьте медиа для поста или пришлите /skip:")
|
||||||
return SELECT_MEDIA
|
return SELECT_MEDIA
|
||||||
return ConversationHandler.END
|
return ConversationHandler.END
|
||||||
|
|
||||||
async def select_media(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
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:
|
||||||
if context.user_data is None:
|
context.user_data = {}
|
||||||
context.user_data = {}
|
if not update.message:
|
||||||
context.user_data['photo'] = update.message.photo[-1].file_id
|
return ConversationHandler.END
|
||||||
if update.message:
|
|
||||||
await update.message.reply_text('Введите текст поста или пересланное сообщение:')
|
msg = update.message
|
||||||
|
if msg.text and msg.text.strip().lower() == "/skip":
|
||||||
|
await update.message.reply_text("Введите текст поста или перешлите сообщение (можно с кастом-эмодзи):")
|
||||||
return SELECT_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):
|
async def select_text(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
if update.message:
|
if not update.message:
|
||||||
if context.user_data is None:
|
return ConversationHandler.END
|
||||||
context.user_data = {}
|
if context.user_data is None:
|
||||||
context.user_data['text'] = getattr(update.message, 'text', None) or getattr(update.message, 'caption', None)
|
context.user_data = {}
|
||||||
from sqlalchemy import select
|
|
||||||
session = AsyncSessionLocal()
|
msg = update.message
|
||||||
try:
|
text, entities, _ = _extract_text_and_entities(msg)
|
||||||
channels_result = await session.execute(select(Channel))
|
entities = _strip_broken_entities(entities)
|
||||||
channels = channels_result.scalars().all()
|
entities = _split_custom_emoji_by_utf16(text, entities)
|
||||||
groups_result = await session.execute(select(Group))
|
|
||||||
groups = groups_result.scalars().all()
|
# сохраним исходник для copyMessage
|
||||||
keyboard = []
|
context.user_data["text"] = text
|
||||||
for c in channels:
|
context.user_data["entities"] = entities
|
||||||
keyboard.append([InlineKeyboardButton(f'Канал: {getattr(c, "name", str(c.name))}', callback_data=f'channel_{getattr(c, "id", str(c.id))}')])
|
context.user_data["src_chat_id"] = update.effective_chat.id
|
||||||
for g in groups:
|
context.user_data["src_msg_id"] = update.message.message_id
|
||||||
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)
|
async with AsyncSessionLocal() as session:
|
||||||
return SELECT_TARGET
|
me = await get_or_create_admin(session, update.effective_user.id)
|
||||||
finally:
|
channels = await list_channels_for_admin(session, me.id)
|
||||||
await session.close()
|
|
||||||
return ConversationHandler.END
|
# группы оставляем без 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
|
||||||
|
|
||||||
async def select_target(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
async def select_target(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
query = update.callback_query
|
query = update.callback_query
|
||||||
if not query:
|
if not query:
|
||||||
return ConversationHandler.END
|
return ConversationHandler.END
|
||||||
await query.answer()
|
await query.answer()
|
||||||
data = query.data
|
|
||||||
session = AsyncSessionLocal()
|
data = (query.data or "")
|
||||||
try:
|
async with AsyncSessionLocal() as session:
|
||||||
chat_id = None
|
chat_id: str | None = None
|
||||||
markup = None
|
markup: InlineKeyboardMarkup | None = None
|
||||||
if data and data.startswith('channel_'):
|
selected_title: str | None = None
|
||||||
from sqlalchemy import select
|
btns = []
|
||||||
channel_id = int(data.split('_')[1])
|
|
||||||
channel_result = await session.execute(select(Channel).where(Channel.id == channel_id))
|
if data.startswith('channel_'):
|
||||||
channel = channel_result.scalar_one_or_none()
|
channel_id = int(data.split('_', 1)[1])
|
||||||
buttons_result = await session.execute(select(Button).where(Button.channel_id == channel_id))
|
|
||||||
buttons = buttons_result.scalars().all()
|
# ACL: право постинга в канал
|
||||||
markup = InlineKeyboardMarkup([[InlineKeyboardButton(str(b.name), url=str(b.url))] for b in buttons]) if buttons else None
|
me = await get_or_create_admin(session, update.effective_user.id)
|
||||||
chat_id = getattr(channel, 'link', None)
|
allowed = await has_scope_on_channel(session, me.id, channel_id, SCOPE_POST)
|
||||||
elif data and data.startswith('group_'):
|
if not allowed:
|
||||||
from sqlalchemy import select
|
await query.edit_message_text("У вас нет права постить в этот канал.")
|
||||||
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...)')
|
|
||||||
return ConversationHandler.END
|
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:
|
try:
|
||||||
photo = context.user_data.get('photo') if context.user_data else None
|
await context.bot.edit_message_reply_markup(
|
||||||
if photo:
|
chat_id=chat_id,
|
||||||
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)
|
message_id=sent_msg.message_id,
|
||||||
await query.edit_message_text('Пост отправлен!')
|
reply_markup=markup,
|
||||||
else:
|
)
|
||||||
await query.edit_message_text('Ошибка: не выбрано фото для поста.')
|
except Exception:
|
||||||
except Exception as e:
|
pass
|
||||||
await query.edit_message_text(f'Ошибка отправки поста: {e}')
|
|
||||||
finally:
|
await query.edit_message_text(f'Пост отправлен{(" в: " + selected_title) if selected_title else "!"}')
|
||||||
await session.close()
|
except BadRequest as e:
|
||||||
|
await query.edit_message_text(f'Ошибка отправки поста: {e}')
|
||||||
|
|
||||||
return ConversationHandler.END
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
new_post_conv = ConversationHandler(
|
new_post_conv = ConversationHandler(
|
||||||
entry_points=[CommandHandler('new_post', new_post_start)],
|
entry_points=[CommandHandler("new_post", new_post_start)],
|
||||||
states={
|
states={
|
||||||
SELECT_MEDIA: [MessageHandler(filters.PHOTO | filters.Document.IMAGE | filters.COMMAND, select_media)],
|
SELECT_MEDIA: [MessageHandler(
|
||||||
SELECT_TEXT: [MessageHandler(filters.TEXT | filters.FORWARDED, select_text)],
|
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)],
|
SELECT_TARGET: [CallbackQueryHandler(select_target)],
|
||||||
},
|
},
|
||||||
fallbacks=[],
|
fallbacks=[],
|
||||||
|
)
|
||||||
)
|
|
||||||
|
|||||||
68
handlers/permissions.py
Normal file
68
handlers/permissions.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# permissions.py
|
||||||
|
import hashlib, secrets
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from sqlalchemy import select
|
||||||
|
from models import Admin, Channel, ChannelAccess, SCOPE_POST, SCOPE_SHARE
|
||||||
|
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())
|
||||||
140
handlers/share_channel.py
Normal file
140
handlers/share_channel.py
Normal file
@@ -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=[],
|
||||||
|
)
|
||||||
11
init_db.py
11
init_db.py
@@ -1,5 +1,16 @@
|
|||||||
|
import os
|
||||||
import asyncio
|
import asyncio
|
||||||
from db import init_db
|
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__":
|
if __name__ == "__main__":
|
||||||
asyncio.run(init_db())
|
asyncio.run(init_db())
|
||||||
|
|||||||
77
main.py
77
main.py
@@ -19,22 +19,46 @@ logging.getLogger("httpx").setLevel(logging.WARNING)
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
import asyncio
|
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):
|
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
session = AsyncSessionLocal()
|
args = (context.args or [])
|
||||||
user_id = update.effective_user.id if update.effective_user else None
|
if args and args[0].startswith("sch_"):
|
||||||
result = await session.execute(Admin.__table__.select().where(Admin.tg_id == user_id))
|
# формат: sch_<invite_id>_<token>
|
||||||
admin = result.first() if user_id else None
|
try:
|
||||||
if not admin and user_id:
|
_, sid, token = args[0].split("_", 2)
|
||||||
await session.execute(Admin.__table__.insert().values(tg_id=user_id))
|
invite_id = int(sid)
|
||||||
await session.commit()
|
except Exception:
|
||||||
if update.message:
|
await update.message.reply_text("Неверная ссылка приглашения.")
|
||||||
await update.message.reply_text('Вы зарегистрированы как админ.')
|
return
|
||||||
else:
|
|
||||||
if update.message:
|
async with AsyncSessionLocal() as session:
|
||||||
await update.message.reply_text('Вы уже зарегистрированы.')
|
me = await get_or_create_admin(session, update.effective_user.id)
|
||||||
await session.close()
|
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):
|
async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
help_text = (
|
help_text = (
|
||||||
@@ -65,14 +89,26 @@ async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||||||
await update.message.reply_text(help_text, parse_mode='HTML')
|
await update.message.reply_text(help_text, parse_mode='HTML')
|
||||||
|
|
||||||
# Импорт обработчиков
|
# Импорт обработчиков
|
||||||
from handlers.add_channel import add_channel
|
from handlers.add_channel import add_channel_conv
|
||||||
from handlers.add_group import add_group
|
from handlers.add_group import add_group_conv
|
||||||
from handlers.add_button import add_button_conv
|
from handlers.add_button import add_button_conv
|
||||||
from handlers.new_post import new_post_conv
|
from handlers.new_post import new_post_conv
|
||||||
from handlers.group_buttons import group_buttons_conv
|
from handlers.group_buttons import group_buttons_conv
|
||||||
from handlers.channel_buttons import channel_buttons_conv
|
from handlers.channel_buttons import channel_buttons_conv
|
||||||
from handlers.edit_button import edit_button
|
from handlers.edit_button import edit_button
|
||||||
from handlers.del_button import del_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)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -80,12 +116,12 @@ def main():
|
|||||||
if not TELEGRAM_TOKEN:
|
if not TELEGRAM_TOKEN:
|
||||||
print("Ошибка: TELEGRAM_TOKEN не найден в переменных окружения.")
|
print("Ошибка: TELEGRAM_TOKEN не найден в переменных окружения.")
|
||||||
return
|
return
|
||||||
sync_to_async(init_db())
|
# sync_to_async(init_db())
|
||||||
application = Application.builder().token(TELEGRAM_TOKEN).build()
|
application = Application.builder().token(TELEGRAM_TOKEN).build()
|
||||||
application.add_handler(CommandHandler('start', start))
|
application.add_handler(CommandHandler('start', start))
|
||||||
application.add_handler(CommandHandler('help', help_command))
|
application.add_handler(CommandHandler('help', help_command))
|
||||||
application.add_handler(CommandHandler('add_channel', add_channel))
|
application.add_handler(add_channel_conv)
|
||||||
application.add_handler(CommandHandler('add_group', add_group))
|
application.add_handler(add_group_conv)
|
||||||
application.add_handler(add_button_conv)
|
application.add_handler(add_button_conv)
|
||||||
application.add_handler(new_post_conv)
|
application.add_handler(new_post_conv)
|
||||||
application.add_handler(group_buttons_conv)
|
application.add_handler(group_buttons_conv)
|
||||||
@@ -93,6 +129,7 @@ def main():
|
|||||||
application.add_handler(CommandHandler('edit_button', edit_button))
|
application.add_handler(CommandHandler('edit_button', edit_button))
|
||||||
application.add_handler(CommandHandler('del_button', del_button))
|
application.add_handler(CommandHandler('del_button', del_button))
|
||||||
application.add_handler(admin_panel_conv)
|
application.add_handler(admin_panel_conv)
|
||||||
|
application.add_handler(share_channel_conv)
|
||||||
import sys
|
import sys
|
||||||
import asyncio
|
import asyncio
|
||||||
if sys.platform.startswith('win'):
|
if sys.platform.startswith('win'):
|
||||||
@@ -103,7 +140,7 @@ def main():
|
|||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
asyncio.set_event_loop(loop)
|
asyncio.set_event_loop(loop)
|
||||||
application.run_polling()
|
application.run_polling()
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
||||||
|
|||||||
35
models.py
35
models.py
@@ -1,7 +1,36 @@
|
|||||||
from sqlalchemy import Column, Integer, String, ForeignKey, Text
|
from sqlalchemy import Column, Integer, String, ForeignKey, Text, DateTime, Boolean
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from db import Base
|
from db import Base
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Битовые флаги прав
|
||||||
|
SCOPE_POST = 1 # право постить
|
||||||
|
SCOPE_MANAGE_BTNS = 2 # право управлять кнопками (опционально)
|
||||||
|
SCOPE_SHARE = 4 # право делиться дальше (опционально)
|
||||||
|
|
||||||
|
class ChannelAccess(Base):
|
||||||
|
__tablename__ = "channel_accesses"
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
channel_id = Column(Integer, ForeignKey("channels.id"), nullable=False)
|
||||||
|
|
||||||
|
# Кто выдал доступ (владелец/менеджер с SCOPE_SHARE)
|
||||||
|
invited_by_admin_id = Column(Integer, ForeignKey("admins.id"), nullable=False)
|
||||||
|
|
||||||
|
# Кому выдан доступ (заполняется при активации, до активации = NULL)
|
||||||
|
invited_admin_id = Column(Integer, ForeignKey("admins.id"), nullable=True)
|
||||||
|
|
||||||
|
# Безопасно: храним ХЭШ токена приглашения (сам токен не храним)
|
||||||
|
token_hash = Column(String, nullable=False)
|
||||||
|
|
||||||
|
scopes = Column(Integer, default=SCOPE_POST, nullable=False) # битовая маска
|
||||||
|
status = Column(String, default="pending", nullable=False) # pending|active|revoked|expired
|
||||||
|
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
accepted_at = Column(DateTime, nullable=True)
|
||||||
|
revoked_at = Column(DateTime, nullable=True)
|
||||||
|
expires_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
channel = relationship("Channel", foreign_keys=[channel_id])
|
||||||
class Admin(Base):
|
class Admin(Base):
|
||||||
__tablename__ = 'admins'
|
__tablename__ = 'admins'
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
@@ -12,13 +41,15 @@ class Channel(Base):
|
|||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
name = Column(String)
|
name = Column(String)
|
||||||
link = 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):
|
class Group(Base):
|
||||||
__tablename__ = 'groups'
|
__tablename__ = 'groups'
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
name = Column(String, nullable=False)
|
name = Column(String, nullable=False)
|
||||||
link = Column(String, nullable=False)
|
link = Column(String, nullable=False)
|
||||||
|
admin_id = Column(Integer, ForeignKey('admins.id'))
|
||||||
buttons = relationship('Button', back_populates='group')
|
buttons = relationship('Button', back_populates='group')
|
||||||
|
|
||||||
class Button(Base):
|
class Button(Base):
|
||||||
|
|||||||
Reference in New Issue
Block a user