ир
Some checks failed
CI / Lint (ruff + mypy) (push) Failing after 33s
CI / Run tests (push) Has been skipped
CI / Docker build test (push) Successful in 12s
Security / Dependency security scan (push) Failing after 52s

This commit is contained in:
2026-04-03 01:03:52 +07:00
parent 94afa86920
commit a13f4e378c
7 changed files with 308 additions and 78 deletions

View File

@@ -1,4 +1,4 @@
from __future__ import annotations
from __future__ import annotations
import asyncio
import logging
@@ -19,30 +19,31 @@ from session_bot.config import load_actor_config, load_settings
from session_bot.html_entities import html_to_text_entities
from session_bot.render import build_channel_text
from session_bot.storage import JsonStateStorage
from session_bot.telethon_publisher import TelethonPublisher
router = Router()
STATUS_CHOICES = {
"open": "Открыть",
"backstage": "Закулисье",
"delay": "Задержка",
"rest": "Антракт",
"open": "Открыть",
"backstage": "Закулисье",
"delay": "Задержка",
"rest": "Антракт",
}
HELP_TEXT = (
"Команды\n"
"/start или /panel - открыть панель\n"
"/help - показать справку\n"
"/refresh - перерисовать пост в канале\n"
"/post_test - отправить текущий пост себе в личку для проверки\n"
"/cancel - сбросить текущий ввод\n"
"/post - сохранить шаблон поста\n"
"/template_dump - показать HTML из сообщения-реплая\n\n"
"Шаблон поста\n"
"Можно использовать один общий {{actors}} для всего блока актеров.\n"
"Или точечные плейсхолдеры: {{actor:liebe}}, {{actor:mari}}.\n"
"Можно добавить {{hidden_link}} в начало, либо задать HIDDEN_LINK_URL в .env.\n"
"Если нужна готовая Telegram-разметка, используйте /post ответом на уже оформленное сообщение."
"Команды\n"
"/start или /panel - открыть панель\n"
"/help - показать справку\n"
"/refresh - перерисовать пост в канале\n"
"/post_test - отправить текущий пост себе в личку для проверки\n"
"/cancel - сбросить текущий ввод\n"
"/post - сохранить шаблон поста\n"
"/template_dump - показать HTML из сообщения-реплая\n\n"
"Шаблон поста\n"
"Можно использовать один общий {{actors}} для всего блока актеров.\n"
"Или точечные плейсхолдеры: {{actor:liebe}}, {{actor:mari}}.\n"
"Можно добавить {{hidden_link}} в начало, либо задать HIDDEN_LINK_URL в .env.\n"
"Если нужна готовая Telegram-разметка, используйте /post ответом на уже оформленное сообщение."
)
@@ -82,21 +83,21 @@ def build_status_keyboard(actor_key: str) -> InlineKeyboardBuilder:
keyboard = InlineKeyboardBuilder()
for status_key, title in STATUS_CHOICES.items():
keyboard.button(text=title, callback_data=f"status:{actor_key}:{status_key}")
keyboard.button(text="Своя фраза", callback_data=f"custom:{actor_key}")
keyboard.button(text="⬅️ Назад", callback_data="nav:panel")
keyboard.button(text="Своя фраза", callback_data=f"custom:{actor_key}")
keyboard.button(text="⬅️ Назад", callback_data="nav:panel")
keyboard.adjust(2)
return keyboard
def build_back_to_actor_keyboard(actor_key: str) -> InlineKeyboardBuilder:
keyboard = InlineKeyboardBuilder()
keyboard.button(text="⬅️ Назад", callback_data=f"actor:{actor_key}")
keyboard.button(text="⬅️ Назад", callback_data=f"actor:{actor_key}")
return keyboard
def build_back_to_panel_keyboard() -> InlineKeyboardBuilder:
keyboard = InlineKeyboardBuilder()
keyboard.button(text="К панели", callback_data="nav:panel")
keyboard.button(text="К панели", callback_data="nav:panel")
return keyboard
@@ -161,9 +162,9 @@ def validate_template_structure(template: str) -> str | None:
specific_count = len(re.findall(r"\{\{actor:[a-z0-9_\-]+\}\}", normalized, flags=re.IGNORECASE))
if common_count == 0 and specific_count == 0:
return "Шаблон сохранен, но в нем нет {{actors}} или {{actor:key}}."
return "Шаблон сохранен, но в нем нет {{actors}} или {{actor:key}}."
if common_count > 1:
return "Шаблон сохранен, но в нем несколько {{actors}}. Нужен только один общий {{actors}}."
return "Шаблон сохранен, но в нем несколько {{actors}}. Нужен только один общий {{actors}}."
return None
@@ -181,7 +182,7 @@ async def safe_edit_message(callback: CallbackQuery, text: str, reply_markup=Non
async def show_panel(target: Message | CallbackQuery, user_id: int, app_config: dict, settings) -> None:
keyboard = build_actor_keyboard(app_config, user_id, settings.admin_ids)
text = "Выберите персонажа для смены статуса."
text = "Выберите персонажа для смены статуса."
if isinstance(target, CallbackQuery):
await safe_edit_message(target, text, keyboard.as_markup())
@@ -197,16 +198,22 @@ async def show_actor_status_menu(callback: CallbackQuery, actor: dict[str, Any],
current_phrase = runtime_state.get("phrase", actor.get("phrases", {}).get(current_status, ""))
lines = [
f"Выбран {actor['display_name']}.",
f"Текущий статус: {STATUS_CHOICES.get(current_status, current_status)}.",
f"Выбран {actor['display_name']}.",
f"Текущий статус: {STATUS_CHOICES.get(current_status, current_status)}.",
]
if current_phrase:
lines.append(f"Текущая фраза: {current_phrase}")
lines.append(f"Текущая фраза: {current_phrase}")
await safe_edit_message(callback, "\n".join(lines), build_status_keyboard(actor["key"]).as_markup())
async def update_channel_post(bot: Bot, app_config: dict, state_storage: JsonStateStorage, settings) -> None:
async def update_channel_post(
bot: Bot,
app_config: dict,
state_storage: JsonStateStorage,
settings,
mtproto_publisher: TelethonPublisher | None = None,
) -> None:
state = state_storage.load()
if "template" in state and "text" in state["template"]:
normalized_template = normalize_template_placeholders(state["template"]["text"])
@@ -214,6 +221,10 @@ async def update_channel_post(bot: Bot, app_config: dict, state_storage: JsonSta
state["template"]["text"] = normalized_template
state_storage.save(state)
if mtproto_publisher and mtproto_publisher.enabled:
await mtproto_publisher.edit_channel_post(app_config, state_storage, settings.channel_message_id)
return
link_preview_options = None
if app_config.get("hidden_link_url", "").strip():
link_preview_options = LinkPreviewOptions(
@@ -263,6 +274,7 @@ async def apply_status_update(
app_config: dict,
state_storage: JsonStateStorage,
settings,
mtproto_publisher: TelethonPublisher | None = None,
) -> None:
payload = state_storage.load()
payload.setdefault("actors", {})
@@ -272,7 +284,7 @@ async def apply_status_update(
"updated_by": user_id,
}
state_storage.save(payload)
await update_channel_post(bot, app_config, state_storage, settings)
await update_channel_post(bot, app_config, state_storage, settings, mtproto_publisher)
def save_post_template(state_storage: JsonStateStorage, template: str) -> None:
@@ -311,7 +323,7 @@ async def start_handler(message: Message, state: FSMContext, app_config: dict, a
await state.clear()
user_id = message.from_user.id
if not is_allowed(user_id, actor_lookup, settings.admin_ids):
await message.answer("У вас нет доступа к этой панели.")
await message.answer("У вас нет доступа к этой панели.")
return
await show_panel(message, user_id, app_config, settings)
@@ -326,7 +338,7 @@ async def cancel_handler(message: Message, state: FSMContext, app_config: dict,
await state.clear()
user_id = message.from_user.id
if not is_allowed(user_id, actor_lookup, settings.admin_ids):
await message.answer("Состояние очищено.")
await message.answer("Состояние очищено.")
return
await show_panel(message, user_id, app_config, settings)
@@ -339,14 +351,15 @@ async def refresh_handler(
state_storage: JsonStateStorage,
actor_lookup: dict,
settings,
mtproto_publisher: TelethonPublisher | None = None,
) -> None:
user_id = message.from_user.id
if not is_allowed(user_id, actor_lookup, settings.admin_ids):
await message.answer("У вас нет доступа к обновлению.")
await message.answer("У вас нет доступа к обновлению.")
return
await update_channel_post(bot, app_config, state_storage, settings)
await message.answer("Сообщение канала обновлено.")
await update_channel_post(bot, app_config, state_storage, settings, mtproto_publisher)
await message.answer("Сообщение канала обновлено.")
@router.message(Command("post_test"))
@@ -360,32 +373,32 @@ async def post_test_handler(
) -> None:
user_id = message.from_user.id
if not is_allowed(user_id, actor_lookup, settings.admin_ids):
await message.answer("У вас нет доступа к тестовой отправке.")
await message.answer("У вас нет доступа к тестовой отправке.")
return
try:
await send_test_post(bot, message.chat.id, app_config, state_storage)
except TelegramBadRequest as exc:
await message.answer(f"Тестовая отправка не удалась:\n{exc}")
await message.answer(f"Тестовая отправка не удалась:\n{exc}")
return
await message.answer("Тестовый пост отправлен.")
await message.answer("Тестовый пост отправлен.")
@router.message(Command("template_dump"))
async def template_dump_handler(message: Message, actor_lookup: dict, settings) -> None:
user_id = message.from_user.id
if not is_allowed(user_id, actor_lookup, settings.admin_ids):
await message.answer("У вас нет доступа к шаблонам поста.")
await message.answer("У вас нет доступа к шаблонам поста.")
return
if message.reply_to_message is None:
await message.answer("Используйте /template_dump ответом на оформленное сообщение.")
await message.answer("Используйте /template_dump ответом на оформленное сообщение.")
return
source = message.reply_to_message
if source.html_text:
await message.answer(source.html_text)
return
await message.answer("У сообщения нет HTML-представления, которое можно извлечь.")
await message.answer("У сообщения нет HTML-представления, которое можно извлечь.")
@router.message(Command("post"))
@@ -398,7 +411,7 @@ async def post_handler(
) -> None:
user_id = message.from_user.id
if not is_allowed(user_id, actor_lookup, settings.admin_ids):
await message.answer("У вас нет доступа к шаблонам поста.")
await message.answer("У вас нет доступа к шаблонам поста.")
return
template = extract_template_text(message)
@@ -414,19 +427,19 @@ async def post_handler(
if message.text and message.text.startswith("/post "):
await message.answer(
"Шаблон сохранен.\n"
"Но если нужна готовая разметка, ссылки и premium emoji, лучше использовать /post ответом на уже оформленное сообщение."
"Шаблон сохранен.\n"
"Но если нужна готовая разметка, ссылки и premium emoji, лучше использовать /post ответом на уже оформленное сообщение."
)
return
await message.answer("Шаблон поста сохранен.")
await message.answer("Шаблон поста сохранен.")
return
await state.set_state(SessionForm.waiting_for_post_template)
await message.answer(
"Перешлите или отправьте шаблон одним сообщением.\n"
"Используйте {{actors}} для общего блока или {{actor:key}} для конкретного актера.\n"
"Для скрытой ссылки можно использовать {{hidden_link}}."
"Перешлите или отправьте шаблон одним сообщением.\n"
"Используйте {{actors}} для общего блока или {{actor:key}} для конкретного актера.\n"
"Для скрытой ссылки можно использовать {{hidden_link}}."
)
@@ -435,7 +448,7 @@ async def panel_callback(callback: CallbackQuery, state: FSMContext, app_config:
await state.clear()
user_id = callback.from_user.id
if not is_allowed(user_id, actor_lookup, settings.admin_ids):
await callback.answer("Нет доступа.", show_alert=True)
await callback.answer("Нет доступа.", show_alert=True)
return
await show_panel(callback, user_id, app_config, settings)
@@ -452,17 +465,17 @@ async def actor_handler(
await state.clear()
user_id = callback.from_user.id
if not is_allowed(user_id, actor_lookup, settings.admin_ids):
await callback.answer("Нет доступа.", show_alert=True)
await callback.answer("Нет доступа.", show_alert=True)
return
actor_key = callback.data.split(":", maxsplit=1)[1]
actor = find_actor(app_config, actor_key)
if actor is None:
await callback.answer("Персонаж не найден.", show_alert=True)
await callback.answer("Персонаж не найден.", show_alert=True)
return
if user_id not in settings.admin_ids and user_id not in actor_operator_ids(actor):
await callback.answer("Можно менять только свой статус.", show_alert=True)
await callback.answer("Можно менять только свой статус.", show_alert=True)
return
await show_actor_status_menu(callback, actor, state_storage)
@@ -477,20 +490,21 @@ async def status_handler(
actor_lookup: dict,
app_config: dict,
state_storage: JsonStateStorage,
mtproto_publisher: TelethonPublisher | None = None,
) -> None:
user_id = callback.from_user.id
if not is_allowed(user_id, actor_lookup, settings.admin_ids):
await callback.answer("Нет доступа.", show_alert=True)
await callback.answer("Нет доступа.", show_alert=True)
return
_, actor_key, status_key = callback.data.split(":")
actor = find_actor(app_config, actor_key)
if actor is None:
await callback.answer("Персонаж не найден.", show_alert=True)
await callback.answer("Персонаж не найден.", show_alert=True)
return
if user_id not in settings.admin_ids and user_id not in actor_operator_ids(actor):
await callback.answer("Можно менять только свой статус.", show_alert=True)
await callback.answer("Можно менять только свой статус.", show_alert=True)
return
try:
@@ -503,23 +517,24 @@ async def status_handler(
app_config=app_config,
state_storage=state_storage,
settings=settings,
mtproto_publisher=mtproto_publisher,
)
except TelegramBadRequest as exc:
except Exception as exc:
logging.exception("Failed to edit channel message")
await callback.answer("Не удалось обновить сообщение канала.", show_alert=True)
await callback.answer("Не удалось обновить сообщение канала.", show_alert=True)
await safe_edit_message(
callback,
f"Статус сохранен, но сообщение канала не обновилось:\n{exc}",
f"Статус сохранен, но сообщение канала не обновилось:\n{exc}",
build_back_to_panel_keyboard().as_markup(),
)
return
await safe_edit_message(
callback,
f"Готово.\n{actor['display_name']} -> {STATUS_CHOICES[status_key]}",
f"Готово.\n{actor['display_name']} -> {STATUS_CHOICES[status_key]}",
build_back_to_panel_keyboard().as_markup(),
)
await callback.answer("Готово")
await callback.answer("Готово")
@router.callback_query(F.data.startswith("custom:"))
@@ -533,17 +548,17 @@ async def custom_phrase_handler(
) -> None:
user_id = callback.from_user.id
if not is_allowed(user_id, actor_lookup, settings.admin_ids):
await callback.answer("Нет доступа.", show_alert=True)
await callback.answer("Нет доступа.", show_alert=True)
return
_, actor_key = callback.data.split(":")
actor = find_actor(app_config, actor_key)
if actor is None:
await callback.answer("Персонаж не найден.", show_alert=True)
await callback.answer("Персонаж не найден.", show_alert=True)
return
if user_id not in settings.admin_ids and user_id not in actor_operator_ids(actor):
await callback.answer("Можно менять только свой статус.", show_alert=True)
await callback.answer("Можно менять только свой статус.", show_alert=True)
return
runtime_state = get_actor_runtime_state(actor, state_storage)
@@ -554,8 +569,8 @@ async def custom_phrase_handler(
await safe_edit_message(
callback,
(
f"Введите свою фразу для {actor['display_name']}.\n"
f"Текущий статус останется: {STATUS_CHOICES.get(status_key, status_key)}."
f"Введите свою фразу для {actor['display_name']}.\n"
f"Текущий статус останется: {STATUS_CHOICES.get(status_key, status_key)}."
),
build_back_to_actor_keyboard(actor_key).as_markup(),
)
@@ -570,9 +585,10 @@ async def phrase_handler(
app_config: dict,
state_storage: JsonStateStorage,
settings,
mtproto_publisher: TelethonPublisher | None = None,
) -> None:
if message.text is None:
await message.answer("Нужен текст сообщения.")
await message.answer("Нужен текст сообщения.")
return
data = await state.get_data()
@@ -581,12 +597,12 @@ async def phrase_handler(
actor = find_actor(app_config, actor_key)
if actor is None:
await state.clear()
await message.answer("Персонаж не найден.")
await message.answer("Персонаж не найден.")
return
phrase = message.text.strip()
if not phrase:
await message.answer("Фраза не должна быть пустой.")
await message.answer("Фраза не должна быть пустой.")
return
try:
@@ -599,16 +615,17 @@ async def phrase_handler(
app_config=app_config,
state_storage=state_storage,
settings=settings,
mtproto_publisher=mtproto_publisher,
)
except TelegramBadRequest as exc:
except Exception as exc:
logging.exception("Failed to edit channel message")
await message.answer(f"Статус сохранен, но сообщение канала не обновилось:\n{exc}")
await message.answer(f"Статус сохранен, но сообщение канала не обновилось:\n{exc}")
await state.clear()
return
await state.clear()
await message.answer(
f"Обновлено: {actor['display_name']} -> {STATUS_CHOICES[status_key].lower()}.",
f"Обновлено: {actor['display_name']} -> {STATUS_CHOICES[status_key].lower()}.",
reply_markup=build_back_to_panel_keyboard().as_markup(),
)
@@ -621,7 +638,7 @@ async def post_template_handler(
) -> None:
template = extract_template_text(message)
if template is None:
await message.answer("Нужен текстовый шаблон. Перешлите текстовый пост или отправьте текст.")
await message.answer("Нужен текстовый шаблон. Перешлите текстовый пост или отправьте текст.")
return
normalized = normalize_template_placeholders(template)
@@ -633,15 +650,21 @@ async def post_template_handler(
await message.answer(validation_error)
return
await message.answer("Шаблон поста сохранен.")
await message.answer("Шаблон поста сохранен.")
def build_dispatcher(app_config: dict, settings, state_storage: JsonStateStorage) -> Dispatcher:
def build_dispatcher(
app_config: dict,
settings,
state_storage: JsonStateStorage,
mtproto_publisher: TelethonPublisher | None = None,
) -> Dispatcher:
dispatcher = Dispatcher(storage=MemoryStorage())
dispatcher["app_config"] = app_config
dispatcher["actor_lookup"] = build_actor_lookup(app_config)
dispatcher["settings"] = settings
dispatcher["state_storage"] = state_storage
dispatcher["mtproto_publisher"] = mtproto_publisher
dispatcher.include_router(router)
return dispatcher
@@ -655,10 +678,17 @@ async def main() -> None:
if settings.hidden_link_char:
app_config["hidden_link_char"] = settings.hidden_link_char
state_storage = JsonStateStorage(settings.state_path)
mtproto_publisher = TelethonPublisher(settings) if settings.telethon_enabled else None
bot = Bot(token=settings.bot_token)
dispatcher = build_dispatcher(app_config, settings, state_storage)
await dispatcher.start_polling(bot)
dispatcher = build_dispatcher(app_config, settings, state_storage, mtproto_publisher)
try:
if mtproto_publisher is not None:
await mtproto_publisher.start()
await dispatcher.start_polling(bot)
finally:
if mtproto_publisher is not None:
await mtproto_publisher.close()
def run() -> None: