Compare commits

..

23 Commits

Author SHA1 Message Date
f18cd78ad5 merge branch 'main' into security
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-07 14:22:43 +09:00
86f7c65697 Merge branch 'main' into security
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-07 14:22:04 +09:00
73ce8b745d minor fix
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-06 15:13:09 +09:00
506acfcde5 sharing channels and more
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-06 14:56:46 +09:00
18f91bbd40 script update
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-06 14:04:18 +09:00
929d2face6 select_target fixsync def select_target(update: Update, context:
Some checks failed
continuous-integration/drone/push Build is failing
ContextTypes.DEFAULT_TYPE):
    query = update.callback_query
        if not query:
	        return ConversationHandler.END
		    await query.answer()

		        data = (query.data or "")
			    async with AsyncSessionLocal() as session:
			            chat_id: str | None = None
				            markup: InlineKeyboardMarkup
					    | None = None
					            selected_title: str
						    | None = None
						            btns = []

							            if
								    data.startswith('channel_'):
								                channel_id
										=
										int(data.split('_',
										1)[1])

										            #
											    ACL:
											    право
											    постинга
											    в
											    канал
											                me
													=
													await
													get_or_create_admin(session,
													update.effective_user.id)
													            allowed
														    =
														    await
														    has_scope_on_channel(session,
														    me.id,
														    channel_id,
														    SCOPE_POST)
														                if
																not
																allowed:
																                await
																		query.edit_message_text("У
																		вас
																		нет
																		права
																		постить
																		в
																		этот
																		канал.")
																		                return
																				ConversationHandler.END

																				            channel
																					    =
																					    (await
																					    session.execute(sa_select(Channel).where(Channel.id
																					    ==
																					    channel_id))).scalar_one_or_none()
																					                if
																							not
																							channel:
																							                await
																									query.edit_message_text("Канал
																									не
																									найден.")
																									                return
																											ConversationHandler.END

																											            chat_id
																												    =
																												    (channel.link
																												    or
																												    "").strip()
																												                selected_title
																														=
																														channel.name

																														            #
																															    Кнопки
																															    канала
																															                btns
																																	=
																																	(await
																																	session.execute(sa_select(Button).where(Button.channel_id
																																	==
																																	channel_id))).scalars().all()
																																	            if
																																		    btns:
																																		                    rows
																																				    =
																																				    [[InlineKeyboardButton(str(b.name),
																																				    url=str(b.url))]
																																				    for
																																				    b
																																				    in
																																				    btns]
																																				                    markup
																																						    =
																																						    InlineKeyboardMarkup(rows)

																																						            elif
																																							    data.startswith('group_'):
																																							                group_id
																																									=
																																									int(data.split('_',
																																									1)[1])

																																									            group
																																										    =
																																										    (await
																																										    session.execute(sa_select(Group).where(Group.id
																																										    ==
																																										    group_id))).scalar_one_or_none()
																																										                if
																																												not
																																												group:
																																												                await
																																														query.edit_message_text("Группа
																																														не
																																														найдена.")
																																														                return
																																																ConversationHandler.END

																																																            chat_id
																																																	    =
																																																	    (group.link
																																																	    or
																																																	    "").strip()
																																																	                selected_title
																																																			=
																																																			group.name

																																																			            #
																																																				    Кнопки
																																																				    группы
																																																				                btns
																																																						=
																																																						(await
																																																						session.execute(sa_select(Button).where(Button.group_id
																																																						==
																																																						group_id))).scalars().all()
																																																						            if
																																																							    btns:
																																																							                    rows
																																																									    =
																																																									    [[InlineKeyboardButton(str(b.name),
																																																									    url=str(b.url))]
																																																									    for
																																																									    b
																																																									    in
																																																									    btns]
																																																									                    markup
																																																											    =
																																																											    InlineKeyboardMarkup(rows)

																																																											        if
																																																												not
																																																												chat_id
																																																												or
																																																												not
																																																												(chat_id.startswith('@')
																																																												or
																																																												chat_id.startswith('-')):
																																																												        await
																																																													query.edit_message_text('Ошибка:
																																																													ссылка
																																																													должна
																																																													быть
																																																													username
																																																													(@channel)
																																																													или
																																																													числовой
																																																													ID
																																																													(-100...)')
																																																													        return
																																																														ConversationHandler.END

																																																														    #
																																																														    DEBUG:
																																																														    сколько
																																																														    кнопок
																																																														    нашли
																																																														    и
																																																														    есть
																																																														    ли
																																																														    markup
																																																														        print(f"[DEBUG]
																																																															send
																																																															->
																																																															chat_id={chat_id}
																																																															title={selected_title!r}
																																																															buttons={len(btns)}
																																																															has_markup={bool(markup)}")

																																																															    #
																																																															    Текст
																																																															    и
																																																															    entities
																																																															    (без
																																																															    parse_mode)
																																																															        ud
																																																																=
																																																																context.user_data
																																																																or
																																																																{}
																																																																    text:
																																																																    str
																																																																    =
																																																																    ud.get("text",
																																																																    "")
																																																																    or
																																																																    ""
																																																																        entities:
																																																																	List[MessageEntity]
																																																																	=
																																																																	ud.get("entities",
																																																																	[])
																																																																	or
																																																																	[]
																																																																	    entities
																																																																	    =
																																																																	    _strip_broken_entities(entities)
																																																																	        entities
																																																																		=
																																																																		_split_custom_emoji_by_utf16(text,
																																																																		entities)

																																																																		    #
																																																																		    Всегда
																																																																		    ручная
																																																																		    отправка
																																																																		    (send_*),
																																																																		    чтобы
																																																																		    гарантированно
																																																																		    приклеить
																																																																		    inline-клавиатуру
																																																																		        try:
																																																																			        sent_msg
																																																																				=
																																																																				None
																																																																				        if
																																																																					"photo"
																																																																					in
																																																																					ud:
																																																																					            sent_msg
																																																																						    =
																																																																						    await
																																																																						    context.bot.send_photo(
																																																																						                    chat_id=chat_id,
																																																																								                    photo=ud["photo"],
																																																																										                    caption=(text
																																																																												    or
																																																																												    None),
																																																																												                    caption_entities=(entities
																																																																														    if
																																																																														    text
																																																																														    else
																																																																														    None),
																																																																														                    reply_markup=markup,
																																																																																                )
																																																																																		        elif
																																																																																			"animation"
																																																																																			in
																																																																																			ud:
																																																																																			            sent_msg
																																																																																				    =
																																																																																				    await
																																																																																				    context.bot.send_animation(
																																																																																				                    chat_id=chat_id,
																																																																																						                    animation=ud["animation"],
																																																																																								                    caption=(text
																																																																																										    or
																																																																																										    None),
																																																																																										                    caption_entities=(entities
																																																																																												    if
																																																																																												    text
																																																																																												    else
																																																																																												    None),
																																																																																												                    reply_markup=markup,
																																																																																														                )
																																																																																																        elif
																																																																																																	"video"
																																																																																																	in
																																																																																																	ud:
																																																																																																	            sent_msg
																																																																																																		    =
																																																																																																		    await
																																																																																																		    context.bot.send_video(
																																																																																																		                    chat_id=chat_id,
																																																																																																				                    video=ud["video"],
																																																																																																						                    caption=(text
																																																																																																								    or
																																																																																																								    None),
																																																																																																								                    caption_entities=(entities
																																																																																																										    if
																																																																																																										    text
																																																																																																										    else
																																																																																																										    None),
																																																																																																										                    reply_markup=markup,
																																																																																																												                )
																																																																																																														        elif
																																																																																																															"document"
																																																																																																															in
																																																																																																															ud:
																																																																																																															            sent_msg
																																																																																																																    =
																																																																																																																    await
																																																																																																																    context.bot.send_document(
																																																																																																																                    chat_id=chat_id,
																																																																																																																		                    document=ud["document"],
																																																																																																																				                    caption=(text
																																																																																																																						    or
																																																																																																																						    None),
																																																																																																																						                    caption_entities=(entities
																																																																																																																								    if
																																																																																																																								    text
																																																																																																																								    else
																																																																																																																								    None),
																																																																																																																								                    reply_markup=markup,
																																																																																																																										                )
																																																																																																																												        elif
																																																																																																																													"audio"
																																																																																																																													in
																																																																																																																													ud:
																																																																																																																													            sent_msg
																																																																																																																														    =
																																																																																																																														    await
																																																																																																																														    context.bot.send_audio(
																																																																																																																														                    chat_id=chat_id,
																																																																																																																																                    audio=ud["audio"],
																																																																																																																																		                    caption=(text
																																																																																																																																				    or
																																																																																																																																				    None),
																																																																																																																																				                    caption_entities=(entities
																																																																																																																																						    if
																																																																																																																																						    text
																																																																																																																																						    else
																																																																																																																																						    None),
																																																																																																																																						                    reply_markup=markup,
																																																																																																																																								                )
																																																																																																																																										        elif
																																																																																																																																											"voice"
																																																																																																																																											in
																																																																																																																																											ud:
																																																																																																																																											            sent_msg
																																																																																																																																												    =
																																																																																																																																												    await
																																																																																																																																												    context.bot.send_voice(
																																																																																																																																												                    chat_id=chat_id,
																																																																																																																																														                    voice=ud["voice"],
																																																																																																																																																                    caption=(text
																																																																																																																																																		    or
																																																																																																																																																		    None),
																																																																																																																																																		                    caption_entities=(entities
																																																																																																																																																				    if
																																																																																																																																																				    text
																																																																																																																																																				    else
																																																																																																																																																				    None),
																																																																																																																																																				                    reply_markup=markup,
																																																																																																																																																						                )
																																																																																																																																																								        elif
																																																																																																																																																									"sticker"
																																																																																																																																																									in
																																																																																																																																																									ud:
																																																																																																																																																									            sent_msg
																																																																																																																																																										    =
																																																																																																																																																										    await
																																																																																																																																																										    context.bot.send_sticker(
																																																																																																																																																										                    chat_id=chat_id,
																																																																																																																																																												                    sticker=ud["sticker"],
																																																																																																																																																														                    reply_markup=markup,
																																																																																																																																																																                )
																																																																																																																																																																		            if
																																																																																																																																																																			    text:
																																																																																																																																																																			                    await
																																																																																																																																																																					    context.bot.send_message(chat_id=chat_id,
																																																																																																																																																																					    text=text,
																																																																																																																																																																					    entities=entities)
																																																																																																																																																																					            else:
																																																																																																																																																																						                sent_msg
																																																																																																																																																																								=
																																																																																																																																																																								await
																																																																																																																																																																								context.bot.send_message(
																																																																																																																																																																								                chat_id=chat_id,
																																																																																																																																																																										                text=text,
																																																																																																																																																																												                entities=entities,
																																																																																																																																																																														                reply_markup=markup,
																																																																																																																																																																																            )

																																																																																																																																																																																	            #
																																																																																																																																																																																		    Страховка:
																																																																																																																																																																																		    если
																																																																																																																																																																																		    вдруг
																																																																																																																																																																																		    Telegram
																																																																																																																																																																																		    проглотил
																																																																																																																																																																																		    клаву
																																																																																																																																																																																		    —
																																																																																																																																																																																		    доклеим
																																																																																																																																																																																		    её
																																																																																																																																																																																		            if
																																																																																																																																																																																			    markup
																																																																																																																																																																																			    and
																																																																																																																																																																																			    getattr(sent_msg,
																																																																																																																																																																																			    "message_id",
																																																																																																																																																																																			    None):
																																																																																																																																																																																			                t
2025-09-06 13:54:24 +09:00
47ac64adec script update
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-06 13:46:30 +09:00
e8a2a2ebc7 sript update
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-06 13:44:07 +09:00
2bcf07f6a9 script update
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-06 13:38:55 +09:00
ce8ec7db45 script updated to revision migrations
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-06 13:21:59 +09:00
297af93fff select_target -> disabled COPY using send_
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-06 13:18:42 +09:00
a22ba094db inline keyboard fix
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-06 12:27:17 +09:00
5c81aae29c ACL, channel_charing
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-06 10:57:10 +09:00
c6104455d8 db creation fix
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-06 09:09:07 +09:00
405e663da4 migrations fixes
Some checks failed
continuous-integration/drone/push Build is failing
admin check-in
2025-09-06 08:41:28 +09:00
b987031410 Emoji - compatible fix
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-06 08:32:41 +09:00
9793648ee3 security, audit, fom features
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-06 05:03:45 +09:00
df9d8b295d Merge branch 'main' of ssh://git.smartsoltech.kr:2222/trevor/post_bot
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-05 17:51:30 +09:00
61b9bd8cfe tmp commit 2025-09-05 17:50:25 +09:00
f079ad2cf7 adding channels and groups fixing
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-05 15:47:09 +09:00
e7a40b4718 add_group, add_Channel process modification
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-05 15:40:28 +09:00
443799d480 Merge branch 'v2'
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-05 15:19:43 +09:00
7254175cdb migrations
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-09-05 15:10:42 +09:00
23 changed files with 1237 additions and 148 deletions

View File

@@ -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.

View File

@@ -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 ###

View 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

View 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

View 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

View File

@@ -1,16 +1,146 @@
#!/bin/bash
set -e
#!/usr/bin/env bash
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] Пересборка контейнера..."
docker compose build --no-cache
log(){ echo -e "[update.sh] $*"; }
die(){ echo -e "[update.sh][ERROR] $*" >&2; exit 1; }
echo "[update.sh] Применение миграций Alembic..."
docker compose run --rm bot alembic upgrade head
# Запускать из корня репо, даже если скрипт лежит в bin/
cd "$(dirname "${BASH_SOURCE[0]}")/.."
echo "[update.sh] Запуск контейнера..."
docker compose up -d
# --- Утилиты для alembic в "смонтированном" контейнере (чтобы файлы миграций попадали в репо) ---
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

12
db.py
View File

@@ -1,10 +1,12 @@
from dotenv import load_dotenv
load_dotenv()
import os
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import declarative_base
from sqlalchemy.ext.asyncio import async_sessionmaker
load_dotenv()
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///bot.db")
if DATABASE_URL.startswith("sqlite+aiosqlite:///"):
@@ -72,4 +74,10 @@ async def init_db():
tables = Base.metadata.tables.keys()
print(f"Созданы таблицы: {', '.join(tables)}")
else:
print("База данных уже существует и содержит таблицы, создание пропущено.")
print("База данных уже существует и содержит таблицы, создание пропущено.")
async def log_action(admin_id, action, details=""):
async with AsyncSessionLocal() as session:
log = ActionLog(admin_id=admin_id, action=action, details=details)
session.add(log)
await session.commit()

BIN
db/bot.db-journal Normal file

Binary file not shown.

View File

@@ -4,5 +4,5 @@ services:
env_file:
- .env
volumes:
- ./bot.db:/app/bot.db
- ./db:/app/db
restart: unless-stopped

View File

@@ -1,3 +1,6 @@
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import ContextTypes, ConversationHandler, CommandHandler, CallbackQueryHandler, MessageHandler, filters
from db import AsyncSessionLocal
@@ -90,8 +93,27 @@ async def input_url(update: Update, context: ContextTypes.DEFAULT_TYPE):
except Exception as e:
if update.message:
await update.message.reply_text(f'Ошибка при добавлении кнопки: {e}')
try:
type_, obj_id = target.split('_', 1)
obj_id = int(obj_id)
if type_ == 'channel':
button = Button(name=name, url=url, channel_id=obj_id)
elif type_ == 'group':
button = Button(name=name, url=url, group_id=obj_id)
else:
await update.message.reply_text('Ошибка: неверный тип объекта.')
session.close()
return ConversationHandler.END
session.add(button)
session.commit()
from db import log_action
user_id = update.effective_user.id if update.effective_user else None
log_action(user_id, "add_button", f"type={type_}, obj_id={obj_id}, name={name}, url={url}")
await update.message.reply_text('Кнопка добавлена.')
except Exception as e:
await update.message.reply_text(f'Ошибка при добавлении кнопки: {e}')
finally:
await session.close()
session.close()
return ConversationHandler.END
add_button_conv = ConversationHandler(
@@ -102,4 +124,4 @@ add_button_conv = ConversationHandler(
INPUT_URL: [MessageHandler(filters.TEXT & ~filters.COMMAND, input_url)],
},
fallbacks=[]
)
)

View File

@@ -1,18 +1,95 @@
# handlers/add_channel.py
from telegram import Update
from telegram.ext import ContextTypes
from db import AsyncSessionLocal
from models import Channel
from telegram.ext import (
ContextTypes, ConversationHandler, CommandHandler, MessageHandler, filters
)
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:
<<<<<<< HEAD
channel = Channel(name=name, link=link)
session.add(channel)
await session.commit()
await update.message.reply_text(f'Канал "{name}" добавлен.')
from db import log_action
user_id = update.effective_user.id if update.effective_user else None
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(
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=[]
)

View File

@@ -1,19 +1,177 @@
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):
args = context.args or []
if update.message is None:
return
if len(args) < 2:
await update.message.reply_text('Используйте: /add_group <название> <ссылка>')
return
name, link = args[0], args[1]
session = AsyncSessionLocal()
group = Group(name=name, link=link)
session.add(group)
session.commit()
session.close()
await update.message.reply_text(f'Группа "{name}" добавлена.')
# 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 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:
<<<<<<< HEAD
group = Group(name=name, link=link)
session.add(group)
await session.commit()
from db import log_action
user_id = update.effective_user.id if update.effective_user else None
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)],
states={
INPUT_NAME: [MessageHandler(filters.TEXT & ~filters.COMMAND, input_group_name)],
INPUT_LINK: [MessageHandler(filters.TEXT & ~filters.COMMAND, input_group_link)],
},
fallbacks=[],
)

View File

@@ -1,6 +1,6 @@
from telegram import Update
from telegram.ext import CommandHandler, ContextTypes
from db import AsyncSessionLocal
from db import AsyncSessionLocal, log_action
from models import Button
async def del_button(update: Update, context: ContextTypes.DEFAULT_TYPE):
@@ -10,9 +10,8 @@ async def del_button(update: Update, context: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text('Используйте: /del_button <название>')
return
name = args[0]
session = AsyncSessionLocal()
try:
from sqlalchemy import select
from sqlalchemy import select
async with AsyncSessionLocal() as session:
result = await session.execute(select(Button).where(Button.name == name))
button = result.scalar_one_or_none()
if not button:
@@ -21,7 +20,7 @@ async def del_button(update: Update, context: ContextTypes.DEFAULT_TYPE):
return
await session.delete(button)
await session.commit()
user_id = update.effective_user.id if update.effective_user else None
await log_action(user_id, "del_button", f"name={name}")
if update.message:
await update.message.reply_text(f'Кнопка "{name}" удалена.')
finally:
await session.close()
await update.message.reply_text(f'Кнопка \"{name}\" удалена.')

View File

@@ -20,6 +20,9 @@ async def edit_button(update: Update, context: ContextTypes.DEFAULT_TYPE):
button.name = new_name
button.url = new_url
await session.commit()
from db import log_action
user_id = update.effective_user.id if update.effective_user else None
log_action(user_id, "edit_button", f"old_name={name}, new_name={new_name}, new_url={new_url}")
if update.message:
await update.message.reply_text(f'Кнопка "{name}" изменена.')
finally:

26
handlers/invite_admin.py Normal file
View File

@@ -0,0 +1,26 @@
from telegram import Update
from telegram.ext import CommandHandler, ContextTypes
from db import AsyncSessionLocal, log_action
from models import Admin
async def invite_admin(update: Update, context: ContextTypes.DEFAULT_TYPE):
args = context.args
if len(args) < 3:
await update.message.reply_text("Неверная ссылка.")
return
channel_id, inviter_id, token = args
user_id = update.effective_user.id
session = AsyncSessionLocal()
admin = session.query(Admin).filter_by(invite_token=token, channel_id=channel_id).first()
if not admin:
await update.message.reply_text("Ссылка недействительна.")
session.close()
return
new_admin = Admin(tg_id=user_id, channel_id=channel_id, inviter_id=inviter_id)
session.add(new_admin)
session.commit()
session.close()
await update.message.reply_text("Вы добавлены как администратор канала!")
log_action(user_id, "invite_admin", f"channel_id={channel_id}, inviter_id={inviter_id}")
invite_admin_handler = CommandHandler("invite_admin", invite_admin)

View File

@@ -1,104 +1,314 @@
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):
if update.message:
if context.user_data is None:
context.user_data = {}
context.user_data['text'] = getattr(update.message, 'text', None) or getattr(update.message, 'caption', None)
from sqlalchemy import select
session = AsyncSessionLocal()
try:
channels_result = await session.execute(select(Channel))
channels = channels_result.scalars().all()
groups_result = await session.execute(select(Group))
groups = groups_result.scalars().all()
keyboard = []
for c in channels:
keyboard.append([InlineKeyboardButton(f'Канал: {getattr(c, "name", str(c.name))}', callback_data=f'channel_{getattr(c, "id", str(c.id))}')])
for g in groups:
keyboard.append([InlineKeyboardButton(f'Группа: {getattr(g, "name", str(g.name))}', callback_data=f'group_{getattr(g, "id", str(g.id))}')])
reply_markup = InlineKeyboardMarkup(keyboard)
await update.message.reply_text('Выберите, куда отправить пост:', reply_markup=reply_markup)
return SELECT_TARGET
finally:
await session.close()
return ConversationHandler.END
if not update.message:
return ConversationHandler.END
if context.user_data is None:
context.user_data = {}
msg = update.message
text, entities, _ = _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
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:
photo = context.user_data.get('photo') if context.user_data else None
if photo:
await context.bot.send_photo(chat_id=chat_id, photo=photo, caption=context.user_data.get('text') if context.user_data else None, reply_markup=markup)
await query.edit_message_text('Пост отправлен!')
else:
await query.edit_message_text('Ошибка: не выбрано фото для поста.')
except Exception as e:
await query.edit_message_text(f'Ошибка отправки поста: {e}')
finally:
await session.close()
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}')
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=[],
)
)

68
handlers/permissions.py Normal file
View 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())

23
handlers/share_bot.py Normal file
View File

@@ -0,0 +1,23 @@
import secrets
from telegram import Update
from telegram.ext import CommandHandler, ContextTypes
from db import AsyncSessionLocal, log_action
from models import Admin
async def share_bot(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
channel_id = context.user_data.get("channel_id")
if not channel_id:
await update.message.reply_text("Сначала выберите канал через /channel_buttons.")
return
token = secrets.token_urlsafe(16)
session = AsyncSessionLocal()
admin = Admin(tg_id=user_id, channel_id=channel_id, inviter_id=user_id, invite_token=token)
session.add(admin)
session.commit()
session.close()
link = f"/invite_admin {channel_id} {user_id} {token}"
await update.message.reply_text(f"Инвайт-ссылка для нового администратора:\n{link}")
log_action(user_id, "share_bot", f"channel_id={channel_id}, token={token}")
share_bot_handler = CommandHandler("share_bot", share_bot)

140
handlers/share_channel.py Normal file
View 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=[],
)

View File

@@ -1,5 +1,16 @@
import os
import asyncio
from db import init_db
# Проверка bot.db перед инициализацией
if os.path.exists("bot.db") and os.path.isdir("bot.db"):
print("Удаляю папку bot.db...")
import shutil
shutil.rmtree("bot.db")
if not os.path.exists("bot.db"):
print("Создаю пустой файл bot.db...")
open("bot.db", "a").close()
if __name__ == "__main__":
asyncio.run(init_db())

84
main.py
View File

@@ -1,3 +1,5 @@
from handlers.share_bot import share_bot_handler
from handlers.invite_admin import invite_admin_handler
import sys
import asyncio
if sys.platform.startswith('win'):
@@ -19,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_<invite_id>_<token>
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 = (
@@ -65,14 +91,26 @@ async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text(help_text, parse_mode='HTML')
# Импорт обработчиков
from handlers.add_channel import add_channel
from handlers.add_group import add_group
from handlers.add_channel import add_channel_conv
from handlers.add_group import add_group_conv
from handlers.add_button import add_button_conv
from handlers.new_post import new_post_conv
from handlers.group_buttons import group_buttons_conv
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)
@@ -80,12 +118,12 @@ 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))
application.add_handler(CommandHandler('add_channel', add_channel))
application.add_handler(CommandHandler('add_group', add_group))
application.add_handler(add_channel_conv)
application.add_handler(add_group_conv)
application.add_handler(add_button_conv)
application.add_handler(new_post_conv)
application.add_handler(group_buttons_conv)
@@ -93,6 +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'):
@@ -103,7 +147,7 @@ def main():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
application.run_polling()
if __name__ == "__main__":
main()

View File

@@ -1,11 +1,61 @@
<<<<<<< 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)
admin_id = Column(Integer)
action = Column(String)
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)
tg_id = Column(Integer, unique=True, nullable=False)
tg_id = Column(Integer, nullable=False)
channel_id = Column(Integer, ForeignKey('channels.id'), nullable=True)
inviter_id = Column(Integer, nullable=True)
invite_token = Column(String, nullable=True, unique=True)
class Channel(Base):
__tablename__ = 'channels'

0
update.sh Normal file
View File