Compare commits

...

28 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
c2b56ba8d6 Merge pull request 'v2' (#11) from v2 into main
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #11
2025-09-05 06:03:46 +00:00
a0cbdd5358 migrations and db creation
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-09-05 15:03:13 +09:00
05990bf36e migrations fix
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-05 14:56:06 +09:00
97b20d799e Merge pull request 'database fix' (#10) from v2 into main
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #10
2025-09-05 05:41:07 +00:00
f1d782bb74 database fix
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-09-05 14:40:25 +09:00
25 changed files with 1273 additions and 196 deletions

View File

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

View File

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

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

@@ -1,39 +0,0 @@
"""channel table
Revision ID: 7506a3320699
Revises: 69ef23ef1ed1
Create Date: 2025-09-05 14:12:37.430983
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import table, column
# revision identifiers, used by Alembic.
revision: str = '7506a3320699'
down_revision: Union[str, Sequence[str], None] = '69ef23ef1ed1'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# Для SQLite: добавляем столбцы через ALTER TABLE
conn = op.get_bind()
# Добавить столбец link, если его нет
result = conn.execute(sa.text("PRAGMA table_info(channels)")).fetchall()
columns = [row[1] for row in result]
if 'link' not in columns:
conn.execute(sa.text("ALTER TABLE channels ADD COLUMN link VARCHAR(255)"))
# Добавить столбец admin_id, если его нет
if 'admin_id' not in columns:
conn.execute(sa.text("ALTER TABLE channels ADD COLUMN admin_id INTEGER"))
def downgrade() -> None:
"""Downgrade schema."""
# SQLite не поддерживает удаление столбцов, поэтому просто удаляем таблицу
op.drop_table('channels')

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

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

View File

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

14
db.py
View File

@@ -1,10 +1,12 @@
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv()
import os import os
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import declarative_base from sqlalchemy.orm import declarative_base
from sqlalchemy.ext.asyncio import async_sessionmaker from sqlalchemy.ext.asyncio import async_sessionmaker
load_dotenv()
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///bot.db") DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///bot.db")
if DATABASE_URL.startswith("sqlite+aiosqlite:///"): if DATABASE_URL.startswith("sqlite+aiosqlite:///"):
@@ -14,8 +16,8 @@ if DATABASE_URL.startswith("sqlite+aiosqlite:///"):
# Создаём директорию только если она не равна текущей ('.') и не пустая # Создаём директорию только если она не равна текущей ('.') и не пустая
if db_dir and db_dir != os.path.abspath("") and db_dir != '.' and not os.path.exists(db_dir): 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)
# Если файла нет, создаём пустой файл # Если файла нет, создаём пустой файл
@@ -73,3 +75,9 @@ async def init_db():
print(f"Созданы таблицы: {', '.join(tables)}") print(f"Созданы таблицы: {', '.join(tables)}")
else: 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_file:
- .env - .env
volumes: volumes:
- ./bot.db:/app/bot.db - ./db:/app/db
restart: unless-stopped restart: unless-stopped

View File

@@ -1,3 +1,6 @@
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import ContextTypes, ConversationHandler, CommandHandler, CallbackQueryHandler, MessageHandler, filters from telegram.ext import ContextTypes, ConversationHandler, CommandHandler, CallbackQueryHandler, MessageHandler, filters
from db import AsyncSessionLocal from db import AsyncSessionLocal
@@ -90,8 +93,27 @@ async def input_url(update: Update, context: ContextTypes.DEFAULT_TYPE):
except Exception as e: except Exception as e:
if update.message: if update.message:
await update.message.reply_text(f'Ошибка при добавлении кнопки: {e}') 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: finally:
await session.close() session.close()
return ConversationHandler.END return ConversationHandler.END
add_button_conv = ConversationHandler( add_button_conv = ConversationHandler(
@@ -102,4 +124,4 @@ add_button_conv = ConversationHandler(
INPUT_URL: [MessageHandler(filters.TEXT & ~filters.COMMAND, input_url)], INPUT_URL: [MessageHandler(filters.TEXT & ~filters.COMMAND, input_url)],
}, },
fallbacks=[] fallbacks=[]
) )

View File

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

View File

@@ -20,6 +20,9 @@ async def edit_button(update: Update, context: ContextTypes.DEFAULT_TYPE):
button.name = new_name button.name = new_name
button.url = new_url button.url = new_url
await session.commit() 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: if update.message:
await update.message.reply_text(f'Кнопка "{name}" изменена.') await update.message.reply_text(f'Кнопка "{name}" изменена.')
finally: 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 # 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
<<<<<<< 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 from models import Channel, Group, Button
>>>>>>> main
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
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 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())

82
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 sys
import asyncio import asyncio
if sys.platform.startswith('win'): if sys.platform.startswith('win'):
@@ -19,22 +21,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 +91,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 +118,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 +131,12 @@ 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)
<<<<<<< HEAD
application.add_handler(share_bot_handler)
application.add_handler(invite_admin_handler)
=======
application.add_handler(share_channel_conv)
>>>>>>> main
import sys import sys
import asyncio import asyncio
if sys.platform.startswith('win'): if sys.platform.startswith('win'):

View File

@@ -1,24 +1,76 @@
<<<<<<< HEAD
from datetime import datetime
from sqlalchemy import Column, Integer, String, ForeignKey, Text 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 sqlalchemy.orm import relationship
from db import Base 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): class Admin(Base):
__tablename__ = 'admins' __tablename__ = 'admins'
id = Column(Integer, primary_key=True) 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): class Channel(Base):
__tablename__ = 'channels' __tablename__ = 'channels'
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):

0
update.sh Normal file
View File